@scemoon/cdh 1.0.8 → 1.0.9-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 (786) 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__/migrate.cpython-314.pyc +0 -0
  312. package/onecode/agent/__init__.py +0 -0
  313. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  314. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  315. package/onecode/agent/__pycache__/checkpoint.cpython-314.pyc +0 -0
  316. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  317. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  318. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  319. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  320. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  321. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  322. package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
  323. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  324. package/onecode/agent/__pycache__/turn_record.cpython-314.pyc +0 -0
  325. package/onecode/agent/agents/__init__.py +29 -0
  326. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  327. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  328. package/onecode/agent/agents/prompts/build-agent.md +50 -0
  329. package/onecode/agent/agents/prompts/compaction-instructions.md +12 -0
  330. package/onecode/agent/agents/prompts/explore-agent.md +42 -0
  331. package/onecode/agent/agents/prompts/plan-agent.md +44 -0
  332. package/onecode/agent/agents/prompts/plan-gate-hard.md +4 -0
  333. package/onecode/agent/agents/prompts/plan-gate-soft.md +3 -0
  334. package/onecode/agent/agents/prompts/solo-agent.md +57 -0
  335. package/onecode/agent/agents/prompts/subagent-constraints.md +10 -0
  336. package/onecode/agent/agents/prompts/summary-instructions.md +7 -0
  337. package/onecode/agent/agents/prompts/title-instructions.md +3 -0
  338. package/onecode/agent/agents/prompts/tool-descriptions.md +129 -0
  339. package/onecode/agent/agents/types.py +786 -0
  340. package/onecode/agent/attachments.py +112 -0
  341. package/onecode/agent/cdh_loader.py +3 -0
  342. package/onecode/agent/checkpoint.py +285 -0
  343. package/onecode/agent/context.py +465 -0
  344. package/onecode/agent/engine.py +3547 -0
  345. package/onecode/agent/event_bridge.py +75 -0
  346. package/onecode/agent/hooks.py +86 -0
  347. package/onecode/agent/onecode_agent_acp.py +2993 -0
  348. package/onecode/agent/permissions.py +150 -0
  349. package/onecode/agent/permissions_store.py +73 -0
  350. package/onecode/agent/project_doc.py +19 -0
  351. package/onecode/agent/question_detect.py +230 -0
  352. package/onecode/agent/session.py +3 -0
  353. package/onecode/agent/snapshot.py +144 -0
  354. package/onecode/agent/tools/__init__.py +17 -0
  355. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  356. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  357. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  358. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  359. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  360. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  361. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  362. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  363. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  364. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  365. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  366. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  367. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  368. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  369. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  370. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  371. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  372. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  373. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  374. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  375. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  376. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  377. package/onecode/agent/tools/agent_tools.py +128 -0
  378. package/onecode/agent/tools/apply_patch_tool.py +138 -0
  379. package/onecode/agent/tools/bash_tool.py +121 -0
  380. package/onecode/agent/tools/communication_tools.py +154 -0
  381. package/onecode/agent/tools/config_tool.py +130 -0
  382. package/onecode/agent/tools/cron_tools.py +178 -0
  383. package/onecode/agent/tools/errors.py +17 -0
  384. package/onecode/agent/tools/file_ops.py +221 -0
  385. package/onecode/agent/tools/file_tools.py +225 -0
  386. package/onecode/agent/tools/git_tools.py +88 -0
  387. package/onecode/agent/tools/lsp_tools.py +404 -0
  388. package/onecode/agent/tools/mcp_tools.py +211 -0
  389. package/onecode/agent/tools/permission_handler.py +46 -0
  390. package/onecode/agent/tools/permissions.py +77 -0
  391. package/onecode/agent/tools/protocol.py +20 -0
  392. package/onecode/agent/tools/registry.py +210 -0
  393. package/onecode/agent/tools/sandbox.py +460 -0
  394. package/onecode/agent/tools/schema_validation.py +100 -0
  395. package/onecode/agent/tools/skill_tools.py +52 -0
  396. package/onecode/agent/tools/todo_tools.py +208 -0
  397. package/onecode/agent/tools/web_tools.py +530 -0
  398. package/onecode/agent/turn_record.py +28 -0
  399. package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
  400. package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
  401. package/onecode/builtin_skills/cloudbase/SKILL.md +162 -0
  402. package/onecode/builtin_skills/cloudbase/skill.yaml +7 -0
  403. package/onecode/builtin_skills/git/SKILL.md +64 -0
  404. package/onecode/builtin_skills/git/skill.yaml +7 -0
  405. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  406. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  407. package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
  408. package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
  409. package/onecode/cli.py +1325 -0
  410. package/onecode/codebase/__init__.py +69 -0
  411. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  412. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  413. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  414. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  415. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  416. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  417. package/onecode/codebase/chunker.py +125 -0
  418. package/onecode/codebase/indexer.py +154 -0
  419. package/onecode/codebase/retriever.py +240 -0
  420. package/onecode/codebase/storage.py +153 -0
  421. package/onecode/codebase/tools.py +59 -0
  422. package/onecode/commands.py +673 -0
  423. package/onecode/config.py +539 -0
  424. package/onecode/config_screen.py +486 -0
  425. package/onecode/cron/__init__.py +3 -0
  426. package/onecode/lsp/__init__.py +3 -0
  427. package/onecode/mcp/__init__.py +11 -0
  428. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  429. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  430. package/onecode/mcp/__pycache__/cloudbase.cpython-314.pyc +0 -0
  431. package/onecode/mcp/__pycache__/config.cpython-314.pyc +0 -0
  432. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  433. package/onecode/mcp/__pycache__/oauth.cpython-314.pyc +0 -0
  434. package/onecode/mcp/client.py +305 -0
  435. package/onecode/mcp/cloudbase.py +188 -0
  436. package/onecode/mcp/config.py +363 -0
  437. package/onecode/mcp/manager.py +603 -0
  438. package/onecode/mcp/oauth.py +199 -0
  439. package/onecode/memory/__init__.py +106 -0
  440. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  441. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  442. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  443. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  444. package/onecode/memory/backend.py +179 -0
  445. package/onecode/memory/pyramid.py +145 -0
  446. package/onecode/memory/recall.py +322 -0
  447. package/onecode/migrate.py +122 -0
  448. package/onecode/models/__init__.py +4 -0
  449. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  450. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  451. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  452. package/onecode/models/__pycache__/ollama_manager.cpython-314.pyc +0 -0
  453. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  454. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  455. package/onecode/models/errors.py +104 -0
  456. package/onecode/models/messages.py +750 -0
  457. package/onecode/models/ollama_manager.py +167 -0
  458. package/onecode/models/provider.py +730 -0
  459. package/onecode/models/providers/__init__.py +17 -0
  460. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  461. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  462. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  463. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  464. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  465. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  466. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  467. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  468. package/onecode/models/providers/anthropic_provider.py +286 -0
  469. package/onecode/models/providers/deepseek_provider.py +221 -0
  470. package/onecode/models/providers/glm_provider.py +219 -0
  471. package/onecode/models/providers/minimax_provider.py +182 -0
  472. package/onecode/models/providers/minimaxi_provider.py +426 -0
  473. package/onecode/models/providers/ollama_provider.py +183 -0
  474. package/onecode/models/providers/openai_provider.py +234 -0
  475. package/onecode/models/registry.py +349 -0
  476. package/onecode/server/__init__.py +3 -0
  477. package/onecode/server/app.py +251 -0
  478. package/onecode/skills/__init__.py +11 -0
  479. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  480. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  481. package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
  482. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  483. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  484. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  485. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  486. package/onecode/skills/argument_substitution.py +38 -0
  487. package/onecode/skills/bootstrap.py +37 -0
  488. package/onecode/skills/create.py +56 -0
  489. package/onecode/skills/frontmatter.py +33 -0
  490. package/onecode/skills/loader.py +215 -0
  491. package/onecode/skills/manager.py +75 -0
  492. package/onecode/skills/model.py +43 -0
  493. package/onecode/storage/__init__.py +0 -0
  494. package/onecode/storage/activity.py +63 -0
  495. package/onecode/storage/project.py +54 -0
  496. package/onecode/storage/session.py +112 -0
  497. package/onecode/tasks/__init__.py +26 -0
  498. package/onecode/tasks/manager.py +232 -0
  499. package/onecode/tasks/models.py +240 -0
  500. package/onecode/trace/__init__.py +0 -0
  501. package/onecode/trace/tracer.py +173 -0
  502. package/onecode/utils/__init__.py +0 -0
  503. package/onecode/utils/helpers.py +20 -0
  504. package/onecode/verification/__init__.py +5 -0
  505. package/onecode/verification/__pycache__/__init__.cpython-314.pyc +0 -0
  506. package/onecode/verification/__pycache__/aggregation.cpython-314.pyc +0 -0
  507. package/onecode/verification/__pycache__/loop.cpython-314.pyc +0 -0
  508. package/onecode/verification/__pycache__/policy.cpython-314.pyc +0 -0
  509. package/onecode/verification/aggregation.py +53 -0
  510. package/onecode/verification/gates/__init__.py +6 -0
  511. package/onecode/verification/gates/__pycache__/__init__.cpython-314.pyc +0 -0
  512. package/onecode/verification/gates/__pycache__/base.cpython-314.pyc +0 -0
  513. package/onecode/verification/gates/__pycache__/lint_gate.cpython-314.pyc +0 -0
  514. package/onecode/verification/gates/__pycache__/test_gate.cpython-314.pyc +0 -0
  515. package/onecode/verification/gates/__pycache__/type_gate.cpython-314.pyc +0 -0
  516. package/onecode/verification/gates/base.py +20 -0
  517. package/onecode/verification/gates/lint_gate.py +45 -0
  518. package/onecode/verification/gates/test_gate.py +62 -0
  519. package/onecode/verification/gates/type_gate.py +46 -0
  520. package/onecode/verification/loop.py +95 -0
  521. package/onecode/verification/policy.py +29 -0
  522. package/package.json +2 -2
  523. package/pyproject.toml +84 -0
  524. package/tui/__init__.py +46 -0
  525. package/tui/__main__.py +4 -0
  526. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  527. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  528. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  529. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  530. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  531. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  532. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  533. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  534. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  535. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  536. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  537. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  538. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  539. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  540. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  541. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  542. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  543. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  544. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  545. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  546. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  547. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  548. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  549. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  550. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  551. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  552. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  553. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  554. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  555. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  556. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  557. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  558. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  559. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  560. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  561. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  562. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  563. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  564. package/tui/_loop.py +86 -0
  565. package/tui/_path_fuzzy_search.py +48 -0
  566. package/tui/_path_match.py +141 -0
  567. package/tui/about.py +95 -0
  568. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  569. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  570. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  571. package/tui/acp/__pycache__/event_tap.cpython-314.pyc +0 -0
  572. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  573. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  574. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  575. package/tui/acp/agent.py +1212 -0
  576. package/tui/acp/api.py +79 -0
  577. package/tui/acp/encode_tool_call_id.py +12 -0
  578. package/tui/acp/event_tap.py +107 -0
  579. package/tui/acp/messages.py +222 -0
  580. package/tui/acp/prompt.py +53 -0
  581. package/tui/acp/protocol.py +551 -0
  582. package/tui/agent.py +70 -0
  583. package/tui/agent_schema.py +76 -0
  584. package/tui/agents.py +46 -0
  585. package/tui/ansi/__init__.py +1 -0
  586. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  587. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  588. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  589. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  590. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  591. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  592. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  593. package/tui/ansi/_ansi.py +1689 -0
  594. package/tui/ansi/_ansi_colors.py +264 -0
  595. package/tui/ansi/_control_codes.py +37 -0
  596. package/tui/ansi/_keys.py +251 -0
  597. package/tui/ansi/_sgr_styles.py +64 -0
  598. package/tui/ansi/_stream_parser.py +418 -0
  599. package/tui/answer.py +12 -0
  600. package/tui/app.py +1195 -0
  601. package/tui/atomic.py +37 -0
  602. package/tui/cli.py +208 -0
  603. package/tui/code_analyze.py +27 -0
  604. package/tui/complete.py +38 -0
  605. package/tui/constants.py +61 -0
  606. package/tui/conversation_markdown.py +30 -0
  607. package/tui/danger.py +372 -0
  608. package/tui/data/agents/ampcode.com.toml +52 -0
  609. package/tui/data/agents/augmentcode.com.toml +41 -0
  610. package/tui/data/agents/cdh.cloud-dev-harness.toml +23 -0
  611. package/tui/data/agents/claude.com.toml +41 -0
  612. package/tui/data/agents/copilot.github.com.toml +29 -0
  613. package/tui/data/agents/crowai.dev.toml +36 -0
  614. package/tui/data/agents/cursor.com.toml +38 -0
  615. package/tui/data/agents/deepagents.langchain.com +34 -0
  616. package/tui/data/agents/docker.com.toml +60 -0
  617. package/tui/data/agents/geminicli.com.toml +28 -0
  618. package/tui/data/agents/goose.ai.toml +52 -0
  619. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  620. package/tui/data/agents/kimi.com.toml +35 -0
  621. package/tui/data/agents/kiro.dev.toml +43 -0
  622. package/tui/data/agents/onecode.dev.toml +22 -0
  623. package/tui/data/agents/openai.com.toml +53 -0
  624. package/tui/data/agents/openclaw.ai.toml +37 -0
  625. package/tui/data/agents/opencode.ai.toml +39 -0
  626. package/tui/data/agents/openhands.dev.toml +46 -0
  627. package/tui/data/agents/stakpak.dev.toml +62 -0
  628. package/tui/data/agents/vibe.mistral.ai.toml +31 -0
  629. package/tui/data/agents/vtcode.dev.toml +63 -0
  630. package/tui/data/images/frog.png +0 -0
  631. package/tui/data/sounds/question.wav +0 -0
  632. package/tui/data/sounds/turn-over.wav +0 -0
  633. package/tui/db.py +213 -0
  634. package/tui/dec.py +332 -0
  635. package/tui/diff_utils.py +131 -0
  636. package/tui/directory.py +234 -0
  637. package/tui/directory_suggester.py +93 -0
  638. package/tui/directory_watcher.py +231 -0
  639. package/tui/format_path.py +25 -0
  640. package/tui/fuzzy.py +140 -0
  641. package/tui/fuzzy_index.py +275 -0
  642. package/tui/gist.py +33 -0
  643. package/tui/history.py +138 -0
  644. package/tui/jsonrpc.py +599 -0
  645. package/tui/menus.py +14 -0
  646. package/tui/message_log.py +69 -0
  647. package/tui/messages.py +144 -0
  648. package/tui/option_content.py +51 -0
  649. package/tui/os.py +0 -0
  650. package/tui/path_complete.py +217 -0
  651. package/tui/path_filter.py +124 -0
  652. package/tui/paths.py +87 -0
  653. package/tui/pill.py +23 -0
  654. package/tui/platform_commands.py +52 -0
  655. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  656. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  657. package/tui/prompt/extract.py +19 -0
  658. package/tui/prompt/resource.py +68 -0
  659. package/tui/protocol.py +28 -0
  660. package/tui/screens/__init__.py +0 -0
  661. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  662. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  663. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  664. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  665. package/tui/screens/action_modal.py +94 -0
  666. package/tui/screens/agent_modal.py +175 -0
  667. package/tui/screens/command_edit_modal.py +74 -0
  668. package/tui/screens/diff_explorer_screen.py +419 -0
  669. package/tui/screens/diff_screen.py +84 -0
  670. package/tui/screens/ide_screen.py +461 -0
  671. package/tui/screens/main.py +397 -0
  672. package/tui/screens/main.tcss +662 -0
  673. package/tui/screens/permissions.py +404 -0
  674. package/tui/screens/permissions.tcss +72 -0
  675. package/tui/screens/projects.tcss +84 -0
  676. package/tui/screens/projects_app.py +341 -0
  677. package/tui/screens/projects_screen.py +277 -0
  678. package/tui/screens/session_resume_modal.py +172 -0
  679. package/tui/screens/session_resume_modal.tcss +35 -0
  680. package/tui/screens/sessions.py +171 -0
  681. package/tui/screens/sessions.tcss +119 -0
  682. package/tui/screens/settings.py +293 -0
  683. package/tui/screens/settings.tcss +113 -0
  684. package/tui/screens/store.py +502 -0
  685. package/tui/screens/store.tcss +274 -0
  686. package/tui/screens/tool_content_screen.py +59 -0
  687. package/tui/screens/trace.py +317 -0
  688. package/tui/session_tracker.py +123 -0
  689. package/tui/settings.py +353 -0
  690. package/tui/settings_schema.py +467 -0
  691. package/tui/shell.py +304 -0
  692. package/tui/shell_read.py +42 -0
  693. package/tui/slash_command.py +34 -0
  694. package/tui/tui.tcss +246 -0
  695. package/tui/twitter.py +38 -0
  696. package/tui/version.py +80 -0
  697. package/tui/visuals/__init__.py +0 -0
  698. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  699. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  700. package/tui/visuals/columns.py +272 -0
  701. package/tui/widgets/__init__.py +0 -0
  702. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  703. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  704. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  705. package/tui/widgets/__pycache__/aidlc.cpython-314.pyc +0 -0
  706. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  707. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  708. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  709. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  710. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  711. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  712. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  713. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  714. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  715. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  716. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  717. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  718. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  719. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  720. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  721. package/tui/widgets/__pycache__/pending_prompts.cpython-314.pyc +0 -0
  722. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  723. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  724. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  725. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  726. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  727. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  728. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  729. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  730. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  731. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  732. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  733. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  734. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  735. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  736. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  737. package/tui/widgets/acp_content.py +70 -0
  738. package/tui/widgets/agent_response.py +76 -0
  739. package/tui/widgets/agent_thought.py +167 -0
  740. package/tui/widgets/aidlc.py +334 -0
  741. package/tui/widgets/ask_user.py +604 -0
  742. package/tui/widgets/command_pane.py +223 -0
  743. package/tui/widgets/condensed_path.py +115 -0
  744. package/tui/widgets/context_stats.py +66 -0
  745. package/tui/widgets/conversation.py +3235 -0
  746. package/tui/widgets/danger_warning.py +65 -0
  747. package/tui/widgets/diff_view.py +69 -0
  748. package/tui/widgets/directory_input.py +8 -0
  749. package/tui/widgets/edit_field.py +206 -0
  750. package/tui/widgets/flash.py +81 -0
  751. package/tui/widgets/future_text.py +126 -0
  752. package/tui/widgets/grid_select.py +234 -0
  753. package/tui/widgets/highlighted_textarea.py +183 -0
  754. package/tui/widgets/load_more.py +24 -0
  755. package/tui/widgets/mandelbrot.py +294 -0
  756. package/tui/widgets/markdown_note.py +13 -0
  757. package/tui/widgets/menu.py +147 -0
  758. package/tui/widgets/model_status.py +230 -0
  759. package/tui/widgets/modified_files.py +315 -0
  760. package/tui/widgets/non_selectable_label.py +5 -0
  761. package/tui/widgets/note.py +18 -0
  762. package/tui/widgets/path_search.py +492 -0
  763. package/tui/widgets/pending_prompts.py +36 -0
  764. package/tui/widgets/plan.py +285 -0
  765. package/tui/widgets/project_directory_tree.py +102 -0
  766. package/tui/widgets/project_grid_select.py +68 -0
  767. package/tui/widgets/project_summary.py +33 -0
  768. package/tui/widgets/prompt.py +935 -0
  769. package/tui/widgets/question.py +360 -0
  770. package/tui/widgets/session_grid_select.py +230 -0
  771. package/tui/widgets/session_summary.py +115 -0
  772. package/tui/widgets/session_tabs.py +194 -0
  773. package/tui/widgets/shell_result.py +35 -0
  774. package/tui/widgets/shell_terminal.py +40 -0
  775. package/tui/widgets/side_bar.py +124 -0
  776. package/tui/widgets/slash_complete.py +235 -0
  777. package/tui/widgets/strike_text.py +70 -0
  778. package/tui/widgets/subagent.py +287 -0
  779. package/tui/widgets/subagent_screen.py +715 -0
  780. package/tui/widgets/terminal.py +576 -0
  781. package/tui/widgets/terminal_tool.py +360 -0
  782. package/tui/widgets/throbber.py +100 -0
  783. package/tui/widgets/tool_call.py +432 -0
  784. package/tui/widgets/user_input.py +23 -0
  785. package/tui/widgets/version.py +5 -0
  786. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,3547 @@
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
+ return None
1385
+
1386
+ def hidden_tool_names(self) -> set[str]:
1387
+ """Tools that must NOT be advertised to the LLM for the current agent.
1388
+
1389
+ Mirrors ``_check_tool_permission`` exactly: a tool is hidden when it
1390
+ is disallowed by the agent's allow/deny lists, or when its permission
1391
+ key resolves to hard DENY. Unmapped non-read-only tools fall back to
1392
+ the edit permission, matching the runtime fallback.
1393
+ """
1394
+ from onecode.agent.agents.types import AgentPermission
1395
+ agent = self.current_agent
1396
+ tools_config = agent.get_tools_config()
1397
+ hidden: set[str] = set()
1398
+ for spec in self._tool_registry.list_specs():
1399
+ name = spec.name
1400
+ if not agent.tool_allowed(name):
1401
+ hidden.add(name)
1402
+ continue
1403
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
1404
+ if perm_key is not None:
1405
+ if tools_config.get(perm_key, AgentPermission.ALLOW) == AgentPermission.DENY:
1406
+ hidden.add(name)
1407
+ elif not spec.is_read_only:
1408
+ if tools_config.get("edit", AgentPermission.ALLOW) == AgentPermission.DENY:
1409
+ hidden.add(name)
1410
+ return hidden
1411
+
1412
+ def _register_tool_denial(self, name: str) -> None:
1413
+ """Track repeated permission denials for one tool and inject an
1414
+ escalating hint so the model stops retrying calls that can never
1415
+ succeed in the current mode."""
1416
+ count = self._tool_denial_count.get(name, 0) + 1
1417
+ self._tool_denial_count[name] = count
1418
+ marker = f"<!-- TOOL_DENIED_{name} -->"
1419
+ self.context.remove_system_by_marker(marker)
1420
+ if count >= 3:
1421
+ self.context.insert_system_before_non_system(
1422
+ f"{marker}\n"
1423
+ f"## Repeatedly blocked tool: {name}\n"
1424
+ f"You have tried to use `{name}` {count} times and it was "
1425
+ f"denied every time. It will NEVER succeed in the current "
1426
+ f"agent/mode. Stop calling it. Re-read the plan and the "
1427
+ f"available tool list, adjust your approach, or use AskUser "
1428
+ f"to ask the user how to proceed."
1429
+ )
1430
+ else:
1431
+ self.context.insert_system_before_non_system(
1432
+ f"{marker}\n"
1433
+ f"You attempted to use `{name}` but it was denied by the "
1434
+ f"current agent's permissions. Do NOT retry the same call — "
1435
+ f"adjust your approach or ask the user via AskUser."
1436
+ )
1437
+
1438
+ def _save_plan_document(self, plan_text: str) -> str | None:
1439
+ """Persist the submitted plan to ``.cdh/plans/plan-{id}.md``.
1440
+
1441
+ Returns the absolute path, or None on failure. The document survives
1442
+ context compaction and is re-read by the execution agent at handoff.
1443
+ """
1444
+ try:
1445
+ from onecode.agent.cdh_loader import CdhProjectLoader
1446
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
1447
+ if cdh_dir is None:
1448
+ cdh_dir = self._project_dir / CdhProjectLoader.CDH_DIRNAME
1449
+ plans_dir = cdh_dir / "plans"
1450
+ plans_dir.mkdir(parents=True, exist_ok=True)
1451
+ path = plans_dir / f"plan-{int(time.time())}.md"
1452
+ path.write_text(plan_text or "(empty plan)", "utf-8")
1453
+ return str(path)
1454
+ except Exception as e:
1455
+ logger.warning("Failed to save plan document: %s", e)
1456
+ return None
1457
+
1458
+ def _plan_handoff(self, plan_path: str | None) -> None:
1459
+ """Switch from the plan agent to the execution agent after approval.
1460
+
1461
+ Keeps the approved task list (todos) and injects the approved plan
1462
+ document into the execution agent's system context so it survives
1463
+ compaction and stays the single source of truth.
1464
+ """
1465
+ try:
1466
+ if self.current_agent.name != "plan":
1467
+ return
1468
+ self.set_agent("solo", keep_todos=True)
1469
+ plan_text = ""
1470
+ if plan_path:
1471
+ try:
1472
+ plan_text = Path(plan_path).read_text("utf-8")
1473
+ except Exception:
1474
+ plan_text = ""
1475
+ todos = "\n".join(
1476
+ f"- [{t['id']}] {t['subject']}"
1477
+ for t in self._todo_manager.list_todos()
1478
+ )
1479
+ section = (
1480
+ "<!-- APPROVED_PLAN -->\n"
1481
+ "## Approved plan (execution mode)\n"
1482
+ f"Plan document: {plan_path}\n\n"
1483
+ f"{plan_text}\n\n"
1484
+ "### Task list\n"
1485
+ f"{todos or '(empty)'}\n\n"
1486
+ "Execute the approved plan: work through the task list and "
1487
+ "mark each todo completed via TodoUpdate as you finish it."
1488
+ )
1489
+ if not self.context.replace_system_section("<!-- APPROVED_PLAN -->", section):
1490
+ self.context.insert_system_before_non_system(section)
1491
+ except Exception as e:
1492
+ logger.warning("Plan handoff failed: %s", e)
1493
+
1494
+ def _react_phase_text(self, round_no: int) -> str:
1495
+ """Per-round CoT guidance header; the plan agent has its own stages."""
1496
+ if self.current_agent and self.current_agent.name == "plan":
1497
+ return f"<!-- REACT_PHASE -->\n## Round {round_no} — 思考 → 规划 → 审批\n"
1498
+ return f"<!-- REACT_PHASE -->\n## Round {round_no} — 思考 → Todo → 行动\n"
1499
+
1500
+ async def _execute_tool(self, tool_call: dict) -> dict:
1501
+ from onecode.agent.tools.registry import ToolCall as RegistryToolCall
1502
+ name = tool_call["name"]
1503
+ tid = tool_call["id"]
1504
+ inp = tool_call["input"]
1505
+ category = self._tool_category(name)
1506
+ base = {"tool_use_id": tid, "is_error": False, "category": category}
1507
+ try:
1508
+ denied = self._check_tool_permission(name, inp)
1509
+ if denied:
1510
+ self._register_tool_denial(name)
1511
+ return {**base, "content": denied, "is_error": True}
1512
+
1513
+ call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
1514
+ result = await self._tool_registry.dispatch_async(
1515
+ call, cancel_check=lambda: self._cancelled,
1516
+ )
1517
+
1518
+ # Handle SendMessage tracking
1519
+ if name == "SendMessage":
1520
+ msg_output = result.output if isinstance(result.output, dict) else {}
1521
+ self._last_user_msg = msg_output.get("message", "")
1522
+ return {**base, "content": json.dumps(result.output), "is_error": result.is_error}
1523
+
1524
+ # Format output for backward compatibility
1525
+ output = self._format_tool_output(result)
1526
+ return {**base, "content": output, "is_error": result.is_error}
1527
+ except Exception as e:
1528
+ logger.exception(f"Tool execution error: {e}")
1529
+ return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
1530
+
1531
+ def _format_tool_output(self, result: RegistryToolResult) -> str: # noqa: F821
1532
+ import json
1533
+ output = result.output
1534
+ if isinstance(output, str):
1535
+ return output
1536
+ try:
1537
+ return json.dumps(output)
1538
+ except (TypeError, ValueError):
1539
+ return str(output)
1540
+
1541
+ def cancel(self):
1542
+ """Cancel the current chat_stream turn and all in-flight subagents."""
1543
+ self._cancelled = True
1544
+ for child in self._child_engines:
1545
+ child._cancelled = True
1546
+ self._mcp.cancel_all()
1547
+
1548
+ async def shutdown(self):
1549
+ """Release OS resources held by this engine (LSP, MCP, cron)."""
1550
+ self._cancelled = True
1551
+ for child in self._child_engines:
1552
+ child._cancelled = True
1553
+ self._lsp_tool.stop_all()
1554
+ await self._mcp.disconnect_all()
1555
+ self._cron_scheduler.stop_loop()
1556
+
1557
+ def __del__(self):
1558
+ """Fallback cleanup on garbage collection — wrapped in try/except
1559
+ to avoid crashing during interpreter teardown when modules may
1560
+ already be collected."""
1561
+ try:
1562
+ self._lsp_tool.stop_all()
1563
+ except Exception:
1564
+ pass
1565
+ try:
1566
+ self._cron_scheduler.stop_loop()
1567
+ except Exception:
1568
+ pass
1569
+
1570
+ async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
1571
+ await asyncio.to_thread(self._load_skills)
1572
+ self._ctx_err_retries = 0
1573
+ self._verification_fail_count = 0
1574
+ preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
1575
+ logger.info(f"chat_stream() called with user_input='{preview}'")
1576
+
1577
+ project_name = getattr(self.app, "current_project", None) or ""
1578
+ if project_name:
1579
+ was_context_loaded = self._project_context_loaded
1580
+ self._inject_project_context(project_name)
1581
+ if not was_context_loaded:
1582
+ yield StreamEvent.text_delta(
1583
+ f"\n📋 项目: {project_name}\n继续开发中...\n\n"
1584
+ )
1585
+ else:
1586
+ pass
1587
+
1588
+ # Emit initial plan so the TUI Plan widget is mounted (or refreshed
1589
+ # to the current snapshot) at the start of every turn. This is
1590
+ # required because the Plan widget only renders when an ``entries``
1591
+ # value arrives — without this, the widget never appears until the
1592
+ # agent happens to call a task tool.
1593
+ for event in self._emit_plan_update():
1594
+ yield event
1595
+ self._plan_dirty = False
1596
+
1597
+ # ── Codebase auto-retrieval ──
1598
+ # Skipped entirely for subagent engines (see _disable_retrieval)
1599
+ # to avoid the 2.3s+ indexer loop and the per-chunk embedding httpx
1600
+ # calls that block the event loop and freeze streaming/cancel.
1601
+ # Also skipped when _ctx_crisis is set to prevent "compact →
1602
+ # re-inject → compact" loops after a ContextLengthError recovery.
1603
+ if (
1604
+ isinstance(user_input, str)
1605
+ and self._should_retrieve_codebase()
1606
+ and not self._disable_retrieval
1607
+ and not self._ctx_crisis
1608
+ ):
1609
+ try:
1610
+ engine = await self._get_codebase_engine()
1611
+ if engine:
1612
+ chunks = await engine.retrieve(user_input)
1613
+ ctx_text = engine.format_context(
1614
+ chunks, max_tokens=self.app.config.codebase.max_chunk_tokens
1615
+ )
1616
+ if ctx_text:
1617
+ tagged = f"<!-- CODEBASE -->\n{ctx_text}"
1618
+ if not self.context.replace_system_section("<!-- CODEBASE -->", tagged):
1619
+ self.context.add_system(tagged)
1620
+ else:
1621
+ self.context.remove_system_by_marker("<!-- CODEBASE -->")
1622
+ except Exception as e:
1623
+ logger.warning("Codebase retrieval failed: %s", e)
1624
+
1625
+ # ── Long-term memory recall ──
1626
+ if (
1627
+ isinstance(user_input, str)
1628
+ and self._session
1629
+ and self.app.config.memory.enabled
1630
+ and self.app.config.memory.auto_recall
1631
+ and not self._disable_retrieval
1632
+ and not self._ctx_crisis
1633
+ ):
1634
+ try:
1635
+ top_k = self.app.config.memory.top_k
1636
+ results = self._memory.search_memories(user_input, top_k=top_k)
1637
+ if results:
1638
+ lines = ["## Relevant past memories"]
1639
+ for r in results:
1640
+ lines.append(r.content[:300])
1641
+ tagged = "<!-- MEMORY -->\n" + "\n".join(lines)
1642
+ if not self.context.replace_system_section("<!-- MEMORY -->", tagged):
1643
+ self.context.add_system(tagged)
1644
+ else:
1645
+ self.context.remove_system_by_marker("<!-- MEMORY -->")
1646
+ except Exception as e:
1647
+ logger.warning("Memory recall failed: %s", e)
1648
+
1649
+ # ── REACT_PHASE must be injected BEFORE add_user so system
1650
+ # messages stay at the top of the message list. On subsequent
1651
+ # chat_stream calls replace_system_section updates it in-place.
1652
+ cot_phase_init = self._react_phase_text(1)
1653
+ if not self.context.replace_system_section("<!-- REACT_PHASE -->", cot_phase_init):
1654
+ self.context.add_system(cot_phase_init)
1655
+
1656
+ self.context.add_user(user_input)
1657
+
1658
+ # Plan gate: activate based on agent mode, not content heuristics.
1659
+ # Gate fires reactively when agent tries execution tools without a plan.
1660
+ self._plan_gate_mode = self._resolve_plan_gate_mode()
1661
+
1662
+ self.context.set_model(self.app.current_model)
1663
+ if self.context.should_compact():
1664
+ level = self.context.compact()
1665
+ if level not in ("none",):
1666
+ logger.info("Context compaction at turn start: %s", level)
1667
+
1668
+ provider_name = self.app.current_provider
1669
+ model_name = self.app.current_model
1670
+ logger.info(f"Using provider='{provider_name}', model='{model_name}'")
1671
+ logger.info(
1672
+ "chat_stream engine state: project=%s agent=%s todos=%d context_msgs=%d ctx_tokens=%d",
1673
+ project_name or "(none)",
1674
+ self.current_agent.name if self.current_agent else "None",
1675
+ len(self._todo_manager.list_todos()),
1676
+ len(self.context.messages),
1677
+ self.context._token_count,
1678
+ )
1679
+
1680
+ provider_cls = ProviderRegistry.get(provider_name)
1681
+ if not provider_cls:
1682
+ available = list(ProviderRegistry._registry.keys()) if hasattr(ProviderRegistry, '_registry') else "unknown"
1683
+ error_msg = f"Provider '{provider_name}' not available. Available: {available}"
1684
+ logger.error(error_msg)
1685
+ yield StreamEvent.error(error_msg)
1686
+ return
1687
+
1688
+ config = self.app.config.providers.get(provider_name)
1689
+ if config is None:
1690
+ available = list(self.app.config.providers.keys())
1691
+ error_msg = f"Provider '{provider_name}' not configured. Available providers: {available}"
1692
+ logger.error(error_msg)
1693
+ yield StreamEvent.error(error_msg)
1694
+ return
1695
+
1696
+ logger.info(f"Creating provider instance: {provider_cls.__name__}")
1697
+ provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
1698
+ provider = provider_cls(**provider_kwargs)
1699
+
1700
+ # If the provider supports native tool schemas (OpenAI/Anthropic/DeepSeek
1701
+ # etc.), strip the prose TOOL_DESCRIPTIONS from system context to save
1702
+ # tokens. The structured ``tools`` kwarg provides the same info.
1703
+ if not hasattr(provider, 'supports_native_tools') or provider.supports_native_tools():
1704
+ self._strip_agent_config_tool_descriptions()
1705
+
1706
+ # Reset per-turn usage tracking
1707
+ self._turn_usages = []
1708
+
1709
+ # Guard against concurrent chat_stream calls: if the engine was
1710
+ # already cancelled before the adapter called us, honour that.
1711
+ # Do NOT blindly reset _cancelled — a caller may have called
1712
+ # cancel() between turn boundary.
1713
+ if self._cancelled:
1714
+ logger.warning("chat_stream: engine already cancelled, aborting")
1715
+ yield StreamEvent.text_delta("\n\n*Cancelled*\n\n")
1716
+ return
1717
+
1718
+ # ── Agent loop: 思考 → Todo → 行动 (per-Round) ──
1719
+ is_anthropic = provider.is_anthropic_style()
1720
+ max_turns = self.current_agent.max_turns or 5000
1721
+ hard_limit = max_turns
1722
+ hard_limit_extensions = 0
1723
+ max_hard_limit_extensions = float('inf') if (
1724
+ self.current_agent.name == "solo" or self._has_pending_todos()
1725
+ ) else 5
1726
+
1727
+ def _infinite_turns():
1728
+ t = 0
1729
+ while True:
1730
+ yield t
1731
+ t += 1
1732
+
1733
+ for turn in _infinite_turns():
1734
+ # Absolute ceiling: never exceed max_iterations (safety net)
1735
+ absolute_ceiling = self.app.config.agent.max_iterations
1736
+ if turn >= absolute_ceiling:
1737
+ if not self._has_pending_todos():
1738
+ logger.warning(
1739
+ "Absolute ceiling (%d) reached at turn %d — stopping",
1740
+ absolute_ceiling, turn,
1741
+ )
1742
+ break
1743
+ # Dynamic extension: if hard_limit reached but agent is still
1744
+ # actively working (pending todos, or just called tools), keep going.
1745
+ if turn >= hard_limit:
1746
+ if hard_limit_extensions < max_hard_limit_extensions and (
1747
+ self._has_pending_todos() or self._empty_tool_turns == 0
1748
+ ):
1749
+ pending = sum(1 for t in self._todo_manager.list_todos()
1750
+ if t.get("status") in ("pending", "in_progress"))
1751
+ hard_limit_extensions += 1
1752
+ logger.info(
1753
+ "hard_limit (%d) reached at turn %d with %d pending todos — "
1754
+ "extending dynamically (extension %d/%d)",
1755
+ hard_limit, turn, pending,
1756
+ hard_limit_extensions, max_hard_limit_extensions,
1757
+ )
1758
+ hard_limit += 20
1759
+ lines = [
1760
+ "<!-- FORCE_CONTINUE -->",
1761
+ f"## {turn} rounds completed, {pending} todos still pending",
1762
+ "Continue executing the next todo. Do NOT stop or summarise.",
1763
+ ]
1764
+ self._inject_nudge(lines)
1765
+ else:
1766
+ if self._has_pending_todos():
1767
+ pending = sum(1 for t in self._todo_manager.list_todos()
1768
+ if t.get("status") in ("pending", "in_progress"))
1769
+ logger.warning(
1770
+ "hard_limit extensions exhausted at turn %d with %d pending todos — pausing",
1771
+ turn, pending,
1772
+ )
1773
+ yield StreamEvent.text_delta(
1774
+ f"\n\n*Pausing: hard_limit extensions exhausted with {pending} pending todos. "
1775
+ "Type 'continue' to proceed or 'stop' to end.*\n\n"
1776
+ )
1777
+ return
1778
+ if self._cancelled:
1779
+ self._cleanup_injected_messages()
1780
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1781
+ return
1782
+
1783
+ # ── Thought Phase: update CoT reasoning guidance for this turn ──
1784
+ # Cleanup stale reminders that may have accumulated
1785
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1786
+ self.context.remove_system_by_marker("<!-- PLAN_REMINDER -->")
1787
+ # Inject (or refresh) a nudge that prioritizes any unfinished todos
1788
+ # from this session. The marker is replaced in-place so the context
1789
+ # stays bounded — the agent always sees the current open list.
1790
+ self._refresh_pending_todos_nudge()
1791
+ if turn > 0:
1792
+ self.context.replace_system_section(
1793
+ "<!-- REACT_PHASE -->",
1794
+ self._react_phase_text(turn + 1),
1795
+ )
1796
+
1797
+ self._pending_thinking_blocks = []
1798
+
1799
+ self.context.set_model(self.app.current_model)
1800
+ if self.context.should_compact():
1801
+ level = self.context.compact()
1802
+ if level not in ("none",):
1803
+ logger.info("Context compaction at turn %d: %s", turn, level)
1804
+ elif turn > 0 and turn % 3 == 0:
1805
+ self.context._update_token_count()
1806
+ logger.debug("Periodic token recalibration at turn %d: ~%d tokens",
1807
+ turn + 1, self.context._token_count)
1808
+
1809
+ turn_retries = 0
1810
+ turn_usage: dict[str, int] = {}
1811
+ response_text = ""
1812
+ tool_uses: list[dict] = []
1813
+
1814
+ try:
1815
+ context_messages = self.context.get_context()
1816
+ logger.info(f"Calling provider.chat_stream_response (turn {turn+1})")
1817
+ self._streaming_used = False
1818
+ if self.on_text_chunk is not None:
1819
+ _original_cb = self.on_text_chunk
1820
+ def _stream_wrapper(text: str) -> None:
1821
+ self._streaming_used = True
1822
+ if self._cancelled:
1823
+ raise TurnCancelledError()
1824
+ _original_cb(text)
1825
+ stream_cb = _stream_wrapper
1826
+ else:
1827
+ stream_cb = None
1828
+ chat_response = await provider.chat_stream_response(
1829
+ context_messages,
1830
+ model=model_name,
1831
+ on_text_chunk=stream_cb,
1832
+ on_tool_call_delta=self.on_tool_call_delta,
1833
+ cancel_check=lambda: self._cancelled,
1834
+ tools=self._tool_registry.make_openai_schemas(
1835
+ exclude=self.hidden_tool_names()
1836
+ ),
1837
+ )
1838
+ response_text = chat_response.content
1839
+ tool_uses = chat_response.tool_uses
1840
+ if not tool_uses and (
1841
+ "<minimax:tool_call>" in response_text
1842
+ or _LEGACY_OPEN in response_text
1843
+ ):
1844
+ parsed_uses, response_text, self._tool_id_counter = (
1845
+ _extract_minimax_tool_uses(
1846
+ response_text, id_start=self._tool_id_counter
1847
+ )
1848
+ )
1849
+ legacy_uses, response_text, self._tool_id_counter = (
1850
+ _extract_legacy_tool_uses(
1851
+ response_text, id_start=self._tool_id_counter
1852
+ )
1853
+ )
1854
+ tool_uses = parsed_uses + legacy_uses
1855
+ if tool_uses:
1856
+ logger.info(
1857
+ "Extracted %d text tool uses (%d minimax, %d legacy) from response",
1858
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1859
+ )
1860
+ if chat_response.usage:
1861
+ turn_usage = chat_response.usage
1862
+ except NotImplementedError:
1863
+ logger.info("chat_stream_response not supported, falling back to non-streaming chat()")
1864
+ try:
1865
+ context_messages = self.context.get_context()
1866
+ model_response = await provider.chat(
1867
+ context_messages,
1868
+ model=model_name,
1869
+ tools=self._tool_registry.make_openai_schemas(
1870
+ exclude=self.hidden_tool_names()
1871
+ ),
1872
+ )
1873
+ response_text = model_response.get_text()
1874
+ tool_uses = [
1875
+ {"id": tu.id, "name": tu.name, "input": tu.input}
1876
+ for cb in model_response.content
1877
+ if cb.type == ContentBlockType.TOOL_USE and cb.tool_use
1878
+ for tu in [cb.tool_use]
1879
+ ]
1880
+ if not tool_uses and (
1881
+ "<minimax:tool_call>" in response_text
1882
+ or _LEGACY_OPEN in response_text
1883
+ ):
1884
+ parsed_uses, response_text, self._tool_id_counter = (
1885
+ _extract_minimax_tool_uses(
1886
+ response_text, id_start=self._tool_id_counter
1887
+ )
1888
+ )
1889
+ legacy_uses, response_text, self._tool_id_counter = (
1890
+ _extract_legacy_tool_uses(
1891
+ response_text, id_start=self._tool_id_counter
1892
+ )
1893
+ )
1894
+ tool_uses = parsed_uses + legacy_uses
1895
+ if tool_uses:
1896
+ logger.info(
1897
+ "Extracted %d text tool uses (%d minimax, %d legacy) from chat() fallback",
1898
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1899
+ )
1900
+ if model_response.usage:
1901
+ turn_usage = model_response.usage
1902
+ except TransientProviderError:
1903
+ raise # propagate to outer TransientProviderError handler for retry
1904
+ except (KeyboardInterrupt, asyncio.CancelledError):
1905
+ self._cleanup_injected_messages()
1906
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1907
+ return
1908
+ except Exception as e:
1909
+ logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1910
+ if self._has_pending_todos():
1911
+ pending = sum(1 for t in self._todo_manager.list_todos()
1912
+ if t.get("status") in ("pending", "in_progress"))
1913
+ yield StreamEvent.text_delta(
1914
+ f"\n\n*Error: {safe_error_msg(e)}, {pending} todos remaining. Attempting to continue...*\n\n"
1915
+ )
1916
+ continue
1917
+ yield StreamEvent.error(safe_error_msg(e))
1918
+ break
1919
+ except TurnCancelledError:
1920
+ self._cleanup_injected_messages()
1921
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1922
+ return
1923
+ except ContextLengthError as e:
1924
+ ctx_retries = getattr(self, '_ctx_err_retries', 0) + 1
1925
+ self._ctx_err_retries = ctx_retries
1926
+ ctx_msgs_before = len(self.context.messages)
1927
+ ctx_tokens_before = self.context._token_count
1928
+ logger.warning(
1929
+ "ContextLengthError on turn %d (ctx_attempt %d): %s\n"
1930
+ " context_msgs=%d ctx_tokens=%d — forcing compaction",
1931
+ turn + 1, ctx_retries, e,
1932
+ ctx_msgs_before, ctx_tokens_before,
1933
+ )
1934
+ self._turn_events.append({
1935
+ "turn": turn + 1,
1936
+ "kind": "context_overflow_retry",
1937
+ "ctx_retries": ctx_retries,
1938
+ "message": str(e),
1939
+ })
1940
+ if ctx_retries >= 3:
1941
+ logger.error(
1942
+ "ContextLengthError exhausted %d compaction retries "
1943
+ "on turn %d — giving up", ctx_retries, turn + 1,
1944
+ )
1945
+ self._turn_events.append({
1946
+ "turn": turn + 1,
1947
+ "kind": "context_overflow_exhausted",
1948
+ "ctx_retries": ctx_retries,
1949
+ "message": str(e),
1950
+ })
1951
+ yield StreamEvent.error(
1952
+ f"Context length exceeded (turn {turn+1}), "
1953
+ f"compaction exhausted after {ctx_retries} attempts"
1954
+ )
1955
+ if self._has_pending_todos():
1956
+ pending = sum(1 for t in self._todo_manager.list_todos()
1957
+ if t.get("status") in ("pending", "in_progress"))
1958
+ yield StreamEvent.text_delta(
1959
+ f"\n\n*Context overflow with {pending} pending todos. Pausing for user input...*\n\n"
1960
+ )
1961
+ return
1962
+ break
1963
+ level = self.context.compact()
1964
+ ctx_tokens_after = self.context._token_count
1965
+ tokens_unchanged = ctx_tokens_after >= ctx_tokens_before
1966
+ if tokens_unchanged and ctx_retries >= 2:
1967
+ self._ctx_crisis = True
1968
+ for marker in ("<!-- SKILL:", "<!-- AI-DLC:",
1969
+ "<!-- PROJECT_DOC -->",
1970
+ "<!-- CODEBASE -->", "<!-- MEMORY -->",
1971
+ "<!-- CDH_PROJECT -->", "<!-- COMPACT_SUMMARY -->"):
1972
+ removed = self.context.remove_system_by_marker(marker)
1973
+ if removed:
1974
+ logger.info(
1975
+ "ContextLengthError crisis: removed %d system "
1976
+ "msg(s) with marker %s", removed, marker,
1977
+ )
1978
+ level = self.context.compact()
1979
+ ctx_tokens_after = self.context._token_count
1980
+ if ctx_tokens_after >= ctx_tokens_before and self._ctx_crisis:
1981
+ for m in self.context.messages:
1982
+ if m.role == "system" and isinstance(m.content, str) and len(m.content) > 2000:
1983
+ m.content = m.content[:2000] + "\n... [emergency truncation]"
1984
+ self.context._update_token_count()
1985
+ ctx_tokens_after = self.context._token_count
1986
+ if ctx_tokens_after < ctx_tokens_before:
1987
+ logger.info(
1988
+ "ContextLengthError recovery (ctx_attempt %d): compact "
1989
+ "level=%s tokens %d→%d, retrying turn %d",
1990
+ ctx_retries, level, ctx_tokens_before,
1991
+ ctx_tokens_after, turn + 1,
1992
+ )
1993
+ yield StreamEvent.text_delta(
1994
+ "\n\n*Context window exceeded, compressing and retrying…*\n\n"
1995
+ )
1996
+ continue
1997
+ logger.error(
1998
+ "ContextLengthError recovery failed (ctx_attempt %d): "
1999
+ "level=%s tokens before=%d after=%d",
2000
+ ctx_retries, level, ctx_tokens_before, ctx_tokens_after,
2001
+ )
2002
+ yield StreamEvent.error(
2003
+ f"Context length exceeded (turn {turn+1}), "
2004
+ f"compaction did not reduce tokens"
2005
+ )
2006
+ break
2007
+ except TransientProviderError as e:
2008
+ turn_retries += 1
2009
+ max_retries = self._cfg.retry.max_attempts
2010
+ backoff_max = self._cfg.retry.backoff_max
2011
+ backoff_jitter = self._cfg.retry.backoff_jitter
2012
+ if turn_retries <= max_retries:
2013
+ if self._cancelled:
2014
+ self._cleanup_injected_messages()
2015
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2016
+ return
2017
+ if e.retry_after is not None:
2018
+ delay = max(0.0, e.retry_after)
2019
+ else:
2020
+ delay = min(2 ** (turn_retries - 1), backoff_max)
2021
+ jitter = random.uniform(-backoff_jitter, backoff_jitter) if backoff_jitter > 0 else 0.0
2022
+ delay = max(0.1, delay + jitter)
2023
+ logger.warning(
2024
+ "Transient provider error on turn %d, "
2025
+ "retry %d/%d after %.1fs: %s",
2026
+ turn + 1, turn_retries, max_retries, delay, e,
2027
+ )
2028
+ self._turn_events.append({
2029
+ "turn": turn + 1,
2030
+ "kind": "provider_error_retry",
2031
+ "retry": turn_retries,
2032
+ "delay": round(delay, 2),
2033
+ "message": str(e),
2034
+ })
2035
+ yield StreamEvent.text_delta(
2036
+ f"\n\n*Transient error, retrying in {delay:.1f}s…*\n\n"
2037
+ )
2038
+ await asyncio.sleep(delay)
2039
+ continue
2040
+ ctx_msgs = len(self.context.messages)
2041
+ ctx_tokens = self.context._token_count
2042
+ logger.exception(
2043
+ f"Transient provider error on turn {turn+1} "
2044
+ f"(exhausted {turn_retries} retries): {e}\n"
2045
+ f" provider={provider_name} model={model_name} "
2046
+ f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
2047
+ )
2048
+ self._turn_events.append({
2049
+ "turn": turn + 1,
2050
+ "kind": "provider_error_exhausted",
2051
+ "message": str(e),
2052
+ })
2053
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
2054
+ if self._has_pending_todos():
2055
+ pending = sum(1 for t in self._todo_manager.list_todos()
2056
+ if t.get("status") in ("pending", "in_progress"))
2057
+ yield StreamEvent.text_delta(
2058
+ f"\n\n*Transient error exhausted with {pending} pending todos. Pausing for user input...*\n\n"
2059
+ )
2060
+ return
2061
+ break
2062
+ except (KeyboardInterrupt, asyncio.CancelledError):
2063
+ self._cleanup_injected_messages()
2064
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2065
+ return
2066
+ except Exception as e:
2067
+ ctx_msgs = len(self.context.messages)
2068
+ ctx_tokens = self.context._token_count
2069
+ logger.exception(
2070
+ f"Error during chat_stream_response turn {turn+1}: {e}\n"
2071
+ f" provider={provider_name} model={model_name} "
2072
+ f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
2073
+ )
2074
+ if self._has_pending_todos():
2075
+ pending = sum(1 for t in self._todo_manager.list_todos()
2076
+ if t.get("status") in ("pending", "in_progress"))
2077
+ logger.info(f"Error with %d pending todos, attempting to continue...", pending)
2078
+ yield StreamEvent.text_delta(
2079
+ f"\n\n*Error: {safe_error_msg(e)}, {pending} todos remaining. Attempting to continue...*\n\n"
2080
+ )
2081
+ continue
2082
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
2083
+ break
2084
+
2085
+ # Check cancellation after provider call (covers non-streaming
2086
+ # responses and cases where on_text_chunk was never called).
2087
+ if self._cancelled:
2088
+ self._cleanup_injected_messages()
2089
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2090
+ return
2091
+
2092
+ # Track usage
2093
+ self._turn_usages.append(turn_usage)
2094
+ if turn_usage:
2095
+ self.total_tokens += turn_usage.get("total_tokens", 0)
2096
+
2097
+ # Extract thinking blocks from response. In the streaming path
2098
+ # the adapter's _make_stream_callback already stripped <thinking>
2099
+ # markers from the text and routed them through self.on_thinking
2100
+ # (which appends to _pending_thinking_blocks). The non-streaming
2101
+ # fallback still has the markers inline, so we parse them here.
2102
+ thinking_blocks = list(self._pending_thinking_blocks)
2103
+ clean_text = response_text
2104
+ for match in THINKING_RE.finditer(response_text):
2105
+ tb = match.group(1)
2106
+ if tb not in thinking_blocks:
2107
+ thinking_blocks.append(tb)
2108
+ if thinking_blocks or self._pending_thinking_blocks:
2109
+ clean_text = THINKING_RE.sub('', response_text).strip()
2110
+ if not self._streaming_used:
2111
+ for tb in thinking_blocks:
2112
+ yield StreamEvent.thinking(tb)
2113
+
2114
+ # Log the raw model response so developers can verify the
2115
+ # model is actually emitting ``<thinking>`` markers (and not
2116
+ # bleeding planning prose into the visible answer). Goes
2117
+ # to the onecode root logger → ~/.onecode/logs/onecode.log when
2118
+ # ``setup_logging(DEBUG)`` is in effect.
2119
+ logger.debug(
2120
+ "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
2121
+ "thinking_blocks=%d tool_uses=%d streaming_used=%s",
2122
+ turn + 1,
2123
+ len(response_text),
2124
+ response_text[:200],
2125
+ len(thinking_blocks),
2126
+ len(tool_uses),
2127
+ self._streaming_used,
2128
+ )
2129
+
2130
+ # Add assistant response to context with proper content blocks.
2131
+ # Persist thinking blocks so they survive session reload — the
2132
+ # TUI replays them as collapsible Thought widgets.
2133
+ if tool_uses or thinking_blocks:
2134
+ assistant_blocks: list = []
2135
+ for tb in thinking_blocks:
2136
+ assistant_blocks.append({"type": "thinking", "thinking": tb})
2137
+ if clean_text:
2138
+ assistant_blocks.append({"type": "text", "text": clean_text})
2139
+ for tu in tool_uses:
2140
+ assistant_blocks.append({
2141
+ "type": "tool_use",
2142
+ "id": tu["id"],
2143
+ "name": tu["name"],
2144
+ "input": tu["input"],
2145
+ })
2146
+ self.context.add_assistant(assistant_blocks)
2147
+ else:
2148
+ self.context.add_assistant(clean_text)
2149
+
2150
+ self.iterations += 1
2151
+ self._ctx_err_retries = 0
2152
+
2153
+ # Always yield text content as TEXT_DELTA, regardless of tool uses.
2154
+ # Without this, subagent text is consumed silently when it also
2155
+ # emits tool calls — the text goes to context but never reaches
2156
+ # _spawn_subagent_async_streaming / the subagent_chunk stream.
2157
+ if not self._streaming_used and clean_text.strip():
2158
+ for i in range(0, len(clean_text), 12):
2159
+ yield StreamEvent.text_delta(clean_text[i:i + 12])
2160
+
2161
+ if not tool_uses:
2162
+ # ── Store conversation in long-term memory ──
2163
+ if self._session:
2164
+ try:
2165
+ conv_id = self._session.id
2166
+ input_str = user_input if isinstance(user_input, str) else str(user_input)
2167
+ self._memory.remember(
2168
+ MemoryLayer.L0_CONVERSATION,
2169
+ f"user: {input_str}\nassistant: {clean_text}",
2170
+ {"conversation_id": conv_id},
2171
+ )
2172
+ except Exception as e:
2173
+ logger.warning("Memory store failed: %s", e)
2174
+
2175
+ # ── Force continuation if todos remain ──
2176
+ # After subagents complete, the LLM may stop calling tools
2177
+ # because the structured output strongly signals "done".
2178
+ # If there are still pending/in-progress todos, inject a
2179
+ # forceful continuation nudge and loop again instead of
2180
+ # exiting the turn loop.
2181
+ self._empty_tool_turns += 1
2182
+ max_empty_turns = getattr(self._cfg.agent, 'max_empty_turns', 10)
2183
+ if self._has_pending_todos():
2184
+ if self._empty_tool_turns >= max_empty_turns:
2185
+ pending_count = sum(
2186
+ 1 for t in self._todo_manager.list_todos()
2187
+ if t.get("status") in ("pending", "in_progress")
2188
+ )
2189
+ logger.warning(
2190
+ "LLM produced %d consecutive empty turns with %d "
2191
+ "remaining todos — pausing for user input",
2192
+ self._empty_tool_turns, pending_count,
2193
+ )
2194
+ yield StreamEvent.text_delta(
2195
+ f"\n\n*Pausing: {self._empty_tool_turns} empty turns with {pending_count} pending todos. "
2196
+ "Type 'continue' to proceed or 'stop' to end.*\n\n"
2197
+ )
2198
+ return
2199
+ open_count = sum(
2200
+ 1 for t in self._todo_manager.list_todos()
2201
+ if t.get("status") in ("pending", "in_progress")
2202
+ )
2203
+ logger.info(
2204
+ "LLM stopped calling tools but %d pending todos remain — "
2205
+ "injecting FORCE_CONTINUE nudge (empty_tool_turns=%d)",
2206
+ open_count, self._empty_tool_turns,
2207
+ )
2208
+ next_todo = None
2209
+ for t in self._todo_manager.list_todos():
2210
+ if t.get("status") == "pending":
2211
+ next_todo = t
2212
+ break
2213
+ if next_todo:
2214
+ lines = [
2215
+ "# Continue working",
2216
+ "",
2217
+ f"The next pending todo is `{next_todo['id']}`: "
2218
+ f"{next_todo.get('subject', '')}",
2219
+ "",
2220
+ "Execute it now. Do NOT summarise or stop.",
2221
+ ]
2222
+ self._inject_nudge(lines)
2223
+ else:
2224
+ lines = [
2225
+ "<!-- FORCE_CONTINUE -->",
2226
+ "## CRITICAL: Unfinished todos detected",
2227
+ "You stopped without completing all planned todos.",
2228
+ "You MUST continue working:",
2229
+ "1. Mark any finished work as complete.",
2230
+ "2. Then Spawn or execute the next pending todo.",
2231
+ "3. Repeat until ALL todos are Done.",
2232
+ "Do NOT stop. Do NOT summarise. Keep executing.",
2233
+ ]
2234
+ self._inject_nudge(lines)
2235
+ continue
2236
+ # All todos completed — end session immediately
2237
+ all_todos = self._todo_manager.list_todos()
2238
+ if all_todos and all(t.get("status") == "completed" for t in all_todos):
2239
+ logger.info("All %d todo(s) completed, ending session", len(all_todos))
2240
+ break
2241
+ if self._empty_tool_turns >= max_empty_turns:
2242
+ logger.warning(
2243
+ "LLM produced %d consecutive empty turns with no pending todos — breaking loop",
2244
+ self._empty_tool_turns,
2245
+ )
2246
+ break
2247
+ # No pending todos and no tool calls: work appears done.
2248
+ if not all_todos:
2249
+ break
2250
+
2251
+ # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
2252
+ for tu in tool_uses:
2253
+ self._notify_event(ToolEvent(
2254
+ kind="tool_use",
2255
+ tool_name=tu["name"],
2256
+ tool_input=tu["input"],
2257
+ tool_use_id=tu["id"],
2258
+ ))
2259
+ yield StreamEvent.tool_call_start(tu["name"], tu["id"])
2260
+ for tu in tool_uses:
2261
+ yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
2262
+
2263
+ for tu in tool_uses:
2264
+ if self._cancelled:
2265
+ self._cleanup_injected_messages()
2266
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2267
+ return
2268
+ logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
2269
+
2270
+ # ── Plan mode guard ──
2271
+ # In plan mode (edit=DENY && bash=DENY), reject any tool not
2272
+ # marked as read-only UNLESS the permission system explicitly
2273
+ # allows it (e.g. TodoCreate/TodoUpdate, which are the plan
2274
+ # artifact and are ALLOWed for the plan agent). Returns a
2275
+ # clear message telling the LLM to switch to Build mode
2276
+ # instead of silently failing.
2277
+ if self._is_plan_mode():
2278
+ spec = self._tool_registry.get(tu["name"])
2279
+ if spec and not spec.spec().is_read_only:
2280
+ denied = self._check_tool_permission(tu["name"], tu["input"])
2281
+ if denied is not None:
2282
+ self._plan_denial_count += 1
2283
+ self._register_tool_denial(tu["name"])
2284
+ result = {
2285
+ "tool_use_id": tu["id"],
2286
+ "is_error": True,
2287
+ "category": "mode",
2288
+ "content": json.dumps({
2289
+ "success": False,
2290
+ "error": (
2291
+ f"Tool '{tu['name']}' is not available in Plan mode. "
2292
+ "Plan mode is read-only: you may use Read/Glob/Grep/WebFetch/"
2293
+ "WebSearch for analysis, AskUser for questions, and "
2294
+ "TodoCreate/TodoUpdate to build the task list. "
2295
+ "Writing files, running shell commands, or any "
2296
+ "other mutation requires switching to Build mode."
2297
+ ),
2298
+ }),
2299
+ }
2300
+ if self._plan_denial_count >= 3:
2301
+ self.context.insert_system_before_non_system(
2302
+ "<!-- PLAN_MODE_DENIED -->\n"
2303
+ "## CRITICAL: Repeated write attempts in Plan mode\n"
2304
+ "You have repeatedly attempted write operations in Plan mode.\n"
2305
+ "This will NEVER succeed. Stop trying and use only read/planning tools.\n"
2306
+ "To execute changes, the user must switch to Build mode first."
2307
+ )
2308
+ else:
2309
+ self.context.insert_system_before_non_system(
2310
+ "<!-- PLAN_MODE_DENIED -->\n"
2311
+ f"You attempted to use '{tu['name']}' which requires write access.\n"
2312
+ "Plan mode is read-only. Do NOT call write-capable tools again.\n"
2313
+ "Switch to Build mode if you need to execute changes."
2314
+ )
2315
+ # Emit tool result event before continuing to next tool
2316
+ result_str = str(result.get("content", ""))
2317
+ is_error = result.get("is_error", False)
2318
+ _cat = result.get("category", "unknown")
2319
+ from onecode.models.messages import ToolCategory as MsgToolCategory
2320
+ try:
2321
+ result_cat = MsgToolCategory(_cat)
2322
+ except ValueError:
2323
+ result_cat = MsgToolCategory.UNKNOWN
2324
+ self._notify_event(ToolEvent(
2325
+ kind="tool_result",
2326
+ tool_name=tu["name"],
2327
+ tool_use_id=tu["id"],
2328
+ tool_output=result_str,
2329
+ is_error=is_error,
2330
+ ))
2331
+ yield StreamEvent.tool_result(
2332
+ call_id=tu["id"],
2333
+ content=result_str,
2334
+ is_error=is_error,
2335
+ category=result_cat,
2336
+ )
2337
+ if is_anthropic:
2338
+ self.context.add_tool_result(tu["id"], result_str, is_error)
2339
+ else:
2340
+ self.context.add_message(
2341
+ "tool",
2342
+ [{"type": "tool_result", "tool_use_id": tu["id"],
2343
+ "content": result_str, "is_error": is_error}],
2344
+ name=tu["id"],
2345
+ )
2346
+ continue
2347
+
2348
+ # Spawn tool: forward subagent text deltas to the TUI as
2349
+ # subagent_chunk events so the SubAgent widget actually has
2350
+ # content to render. The Spawn tool's spec is (agent_type,
2351
+ # prompt); we read them from the tool input.
2352
+ if tu["name"] == "Spawn":
2353
+ # Permission check
2354
+ denied = self._check_tool_permission("Spawn", tu["input"])
2355
+ if denied:
2356
+ result = {
2357
+ "tool_use_id": tu["id"],
2358
+ "is_error": True,
2359
+ "category": "task",
2360
+ "content": denied,
2361
+ }
2362
+ else:
2363
+ subagent_type = tu["input"].get("agent_type", "general")
2364
+ subagent_prompt = tu["input"].get("prompt", "")
2365
+ _sp_id = tu["id"]
2366
+ _sp_bytes_fwd = 0
2367
+ _sp_chunk_count = 0
2368
+ logger.debug(
2369
+ "[SPAWN-PARENT %s] start subagent_type=%s prompt_len=%d "
2370
+ "tool_id=%s cancelled=%s",
2371
+ _sp_id, subagent_type, len(subagent_prompt),
2372
+ _sp_id, self._cancelled,
2373
+ )
2374
+ is_failed = False
2375
+ error_msg = ""
2376
+ yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
2377
+ accumulated: list[str] = []
2378
+ try:
2379
+ async for sub_event, sub_text in self._spawn_subagent_async_streaming(
2380
+ subagent_type, subagent_prompt, subagent_id=tu["id"]
2381
+ ):
2382
+ _sp_chunk_count += 1
2383
+ if self._cancelled:
2384
+ logger.debug(
2385
+ "[SPAWN-PARENT %s] cancelled mid-iter "
2386
+ "chunk=%d → break", _sp_id, _sp_chunk_count)
2387
+ break
2388
+ if sub_event.type == StreamEventType.SUBAGENT_END:
2389
+ if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
2390
+ is_failed = True
2391
+ error_msg = sub_event.subagent_error or ""
2392
+ logger.debug(
2393
+ "[SPAWN-PARENT %s] recv SUBAGENT_END "
2394
+ "status=%s → continue",
2395
+ _sp_id, getattr(sub_event, "subagent_status", "?"),
2396
+ )
2397
+ continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
2398
+ if sub_event.type == StreamEventType.SUBAGENT_THINKING:
2399
+ yield StreamEvent.subagent_thinking(tu["id"], sub_text)
2400
+ continue
2401
+ # Structured subagent tool events: forward as-is
2402
+ # (they already carry subagent_id=tu["id"]) so the
2403
+ # ACP emits tool_call/tool_call_update sessionUpdates
2404
+ # tagged with subagentId and the TUI renders real
2405
+ # ToolCall cards inside the SubAgent widget.
2406
+ if sub_event.type in (
2407
+ StreamEventType.SUBAGENT_TOOL_CALL,
2408
+ StreamEventType.SUBAGENT_TOOL_RESULT,
2409
+ ):
2410
+ yield sub_event
2411
+ continue
2412
+ if not sub_text:
2413
+ continue
2414
+ accumulated.append(sub_text)
2415
+ _sp_bytes_fwd += len(sub_text)
2416
+ if _sp_chunk_count <= 5 or _sp_chunk_count % 50 == 0:
2417
+ logger.debug(
2418
+ "[SPAWN-PARENT %s] fwd chunk#%d bytes=%d "
2419
+ "total=%d",
2420
+ _sp_id, _sp_chunk_count, len(sub_text),
2421
+ _sp_bytes_fwd,
2422
+ )
2423
+ yield StreamEvent.subagent_chunk(tu["id"], sub_text)
2424
+ except asyncio.CancelledError:
2425
+ logger.debug(
2426
+ "[SPAWN-PARENT %s] CancelledError → subagent_end "
2427
+ "failed=cancelled + re-raise", _sp_id)
2428
+ yield StreamEvent.subagent_end(
2429
+ tu["id"],
2430
+ agent_type=subagent_type,
2431
+ status="failed",
2432
+ error="cancelled",
2433
+ )
2434
+ raise
2435
+ logger.debug(
2436
+ "[SPAWN-PARENT %s] subagent done fwd_chunks=%d "
2437
+ "bytes_fwd=%d is_failed=%s err=%r → yield subagent_end",
2438
+ _sp_id, _sp_chunk_count, _sp_bytes_fwd, is_failed,
2439
+ error_msg[:80],
2440
+ )
2441
+ yield StreamEvent.subagent_end(
2442
+ tu["id"],
2443
+ agent_type=subagent_type,
2444
+ status="failed" if is_failed else "completed",
2445
+ error=error_msg,
2446
+ )
2447
+ raw_output = "".join(accumulated)
2448
+ logger.debug(
2449
+ "[SPAWN-PARENT %s] formatting result raw_len=%d "
2450
+ "is_failed=%s", _sp_id, len(raw_output), is_failed,
2451
+ )
2452
+ formatted = self._format_subagent_output(
2453
+ subagent_type, subagent_prompt, raw_output
2454
+ ) if not is_failed else (
2455
+ f"SUMMARY:\nSub-agent failed with error: {error_msg}\n\n"
2456
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
2457
+ f"BLOCKERS:\n{error_msg}"
2458
+ )
2459
+ result = {
2460
+ "tool_use_id": tu["id"],
2461
+ "is_error": is_failed, # 修复 Bug 2: 子智能体失败时 is_error=True
2462
+ "category": "task",
2463
+ "content": formatted,
2464
+ "raw_content": raw_output,
2465
+ }
2466
+ # Auto-advance todo after subagent completes
2467
+ if not is_failed:
2468
+ self._auto_advance_after_spawn(subagent_prompt)
2469
+ elif (self._plan_gate_mode != "off"
2470
+ and not self._has_pending_todos()
2471
+ and tu["name"] in _EXECUTION_TOOLS):
2472
+ if self._plan_gate_mode == "hard":
2473
+ result = {
2474
+ "tool_use_id": tu["id"],
2475
+ "is_error": True,
2476
+ "category": "todo",
2477
+ "content": json.dumps({
2478
+ "success": False,
2479
+ "error": (
2480
+ "Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
2481
+ "Before using execution tools, output a step-by-step plan as "
2482
+ "Markdown for user review, and only then proceed to execute."
2483
+ ),
2484
+ }),
2485
+ }
2486
+ else:
2487
+ # Soft gate: execute but inject a routing reminder for next turn
2488
+ self.context.insert_system_before_non_system(
2489
+ "<!-- PLAN_REMINDER -->\n"
2490
+ "## Routing Decision\n"
2491
+ "Output a step-by-step plan as Markdown before executing.\n"
2492
+ "Then route EXECUTION by complexity:\n\n"
2493
+ "**Simple / single-step** → execute directly with "
2494
+ "`Read`/`Edit`/`Bash`.\n\n"
2495
+ "**Complex / multi-step / needs isolated context** → "
2496
+ "`Spawn(agent_type, prompt)` delegates to a focused subagent.\n"
2497
+ )
2498
+ result = await self._execute_tool(tu)
2499
+ if self._just_exited_plan_mode:
2500
+ spec = self._tool_registry.get(tu["name"])
2501
+ if spec and not spec.spec().is_read_only:
2502
+ self._just_exited_plan_mode = False
2503
+ else:
2504
+ result = await self._execute_tool(tu)
2505
+ # Reset _just_exited_plan_mode once real work begins
2506
+ if self._just_exited_plan_mode:
2507
+ spec = self._tool_registry.get(tu["name"])
2508
+ if spec and not spec.spec().is_read_only:
2509
+ self._just_exited_plan_mode = False
2510
+ # Routing: track direct execution tool use → nudge routing decision
2511
+ if tu["name"] in _EXECUTION_TOOLS:
2512
+ self._direct_execution_count += 1
2513
+ if self._direct_execution_count >= 2:
2514
+ self.context.insert_system_before_non_system(
2515
+ "<!-- ROUTING_REMINDER -->\n"
2516
+ "You've used direct execution tools repeatedly. Pause and decide:\n"
2517
+ "- Is this work part of a plan? If not, output a plan as Markdown first.\n"
2518
+ "- If the task is a single-step trivial change → continue directly.\n"
2519
+ "- If this work involves >1 tool call, multiple files, or "
2520
+ "research → STOP direct execution and delegate via "
2521
+ "`Spawn(agent_type=\"general\", prompt=\"...\")` instead."
2522
+ )
2523
+ result_str = str(result.get("content", ""))
2524
+ is_error = result.get("is_error", False)
2525
+ category = result.get("category", "unknown")
2526
+ # For Spawn tools, use raw subagent output for TUI display
2527
+ # (no SUMMARY/CHANGES/… structured wrapper)
2528
+ tui_content = str(result.get("raw_content", result_str))
2529
+
2530
+ # Handle SendMessage — user-visible only, skip from LLM context
2531
+ if tu["name"] == "SendMessage":
2532
+ try:
2533
+ parsed = json.loads(result_str) if result_str else {}
2534
+ msg = parsed.get("message", "") if isinstance(parsed, dict) else ""
2535
+ if msg:
2536
+ self._last_user_msg = msg
2537
+ yield StreamEvent.text_delta(f"\n💬 {msg}\n")
2538
+ except (json.JSONDecodeError, ValueError):
2539
+ pass
2540
+ self._notify_event(ToolEvent(
2541
+ kind="tool_result",
2542
+ tool_name="SendMessage",
2543
+ tool_use_id=tu["id"],
2544
+ tool_output={"message": self._last_user_msg},
2545
+ ))
2546
+ continue
2547
+
2548
+ # Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
2549
+ if tu["name"] == "AskUser":
2550
+ parsed = json.loads(result_str) if result_str else {}
2551
+ question = parsed.get("question", "") if isinstance(parsed, dict) else ""
2552
+ context = parsed.get("context", "") if isinstance(parsed, dict) else ""
2553
+ options = parsed.get("options", []) if isinstance(parsed, dict) else []
2554
+ questions = parsed.get("questions", []) if isinstance(parsed, dict) else []
2555
+ plan_submit = bool(parsed.get("plan_submit", False)) if isinstance(parsed, dict) else False
2556
+
2557
+ # Plan submission: persist the plan document so it survives
2558
+ # compaction and can be re-read by the execution agent.
2559
+ plan_path = None
2560
+ if plan_submit:
2561
+ plan_path = self._save_plan_document(
2562
+ context or question or result_str
2563
+ )
2564
+ if plan_path:
2565
+ self._approved_plan_path = plan_path
2566
+ suffix = f"\n\nPlan saved to: {plan_path}"
2567
+ if context:
2568
+ context = f"{context}{suffix}"
2569
+ elif question:
2570
+ question = f"{question}{suffix}"
2571
+
2572
+ # Check for auto-default on single question + single option
2573
+ if not questions and not plan_submit and len(options) == 1 and options[0].get("default"):
2574
+ default_val = options[0]["value"]
2575
+ self._pending_approval = None
2576
+ yield StreamEvent.tool_result(
2577
+ call_id=tu["id"],
2578
+ content=json.dumps({"answer": default_val}),
2579
+ )
2580
+ continue
2581
+
2582
+ # Auto-default for multi-question: skip questions that have
2583
+ # a single option with default=true
2584
+ if questions and not plan_submit:
2585
+ auto_answers = {}
2586
+ remaining = []
2587
+ for i, q in enumerate(questions):
2588
+ qopts = q.get("options", [])
2589
+ if len(qopts) == 1 and qopts[0].get("default"):
2590
+ auto_answers[str(i)] = qopts[0]["value"]
2591
+ else:
2592
+ remaining.append(q)
2593
+ if not remaining:
2594
+ self._pending_approval = None
2595
+ yield StreamEvent.tool_result(
2596
+ call_id=tu["id"],
2597
+ content=json.dumps({"answers": auto_answers}),
2598
+ )
2599
+ continue
2600
+ questions = remaining
2601
+
2602
+ has_questions = bool(questions)
2603
+ self._pending_approval = {
2604
+ "tool_call": tu,
2605
+ "category": category,
2606
+ "ask_user": True,
2607
+ "plan_submit": plan_submit,
2608
+ "plan_path": plan_path,
2609
+ }
2610
+ if has_questions:
2611
+ yield StreamEvent.ask_user(
2612
+ call_id=tu["id"],
2613
+ action="AskUser",
2614
+ question="", # not used when questions is present
2615
+ context=result_str,
2616
+ options=[],
2617
+ questions=questions,
2618
+ action_type="ask_user",
2619
+ plan_submit=plan_submit,
2620
+ )
2621
+ else:
2622
+ yield StreamEvent.ask_user(
2623
+ call_id=tu["id"],
2624
+ action="AskUser",
2625
+ question=question,
2626
+ context=result_str,
2627
+ options=options,
2628
+ action_type="ask_user",
2629
+ plan_submit=plan_submit,
2630
+ )
2631
+ continue
2632
+
2633
+ # Detect ASK permission denial
2634
+ requires_approval = False
2635
+ try:
2636
+ parsed = json.loads(result_str) if result_str else {}
2637
+ requires_approval = isinstance(parsed, dict) and parsed.get("requires_approval", False)
2638
+ except (json.JSONDecodeError, ValueError):
2639
+ pass
2640
+
2641
+ if requires_approval:
2642
+ checkpoint_id = ""
2643
+ tool_input = tu.get("input", {})
2644
+ affected_files = self._get_affected_files(tu["name"], tool_input)
2645
+ should_ckpt, reason = self._checkpoint_manager.should_checkpoint(
2646
+ tu["name"], tool_input, affected_files
2647
+ )
2648
+ if should_ckpt:
2649
+ checkpoint_id = self._checkpoint_manager.create(
2650
+ agent=self.current_agent.name,
2651
+ reason=reason,
2652
+ files=affected_files,
2653
+ tool_call_id=tu["id"],
2654
+ description=f"{tu['name']} requires approval",
2655
+ )
2656
+ self._pending_approval = {
2657
+ "tool_call": tu,
2658
+ "category": category,
2659
+ "checkpoint_id": checkpoint_id,
2660
+ }
2661
+ self._notify_event(ToolEvent(
2662
+ kind="tool_result",
2663
+ tool_name=tu["name"],
2664
+ tool_use_id=tu["id"],
2665
+ is_error=True,
2666
+ error="Requires approval",
2667
+ ))
2668
+ yield StreamEvent.ask_user(
2669
+ call_id=tu["id"],
2670
+ action=tu["name"],
2671
+ question=f"Approve {tu['name']} operation?",
2672
+ context=result_str,
2673
+ action_type=tu["name"].lower(),
2674
+ path=tu["input"].get("path", ""),
2675
+ command=tu["input"].get("command", "")[:200],
2676
+ checkpoint_id=checkpoint_id,
2677
+ )
2678
+ # Generator resumes here after approval. The approved tool
2679
+ # may have mutated todos (e.g. TodoUpdate via resolve_approval),
2680
+ # flushing _plan_dirty so the TUI stays in sync.
2681
+ if self._plan_dirty:
2682
+ for event in self._emit_plan_update():
2683
+ yield event
2684
+ self._plan_dirty = False
2685
+ else:
2686
+ from onecode.models.messages import ToolCategory as MsgToolCategory
2687
+ try:
2688
+ result_cat = MsgToolCategory(category)
2689
+ except ValueError:
2690
+ result_cat = MsgToolCategory.UNKNOWN
2691
+ self._last_tool_results[tu["id"]] = result_str
2692
+ self._notify_event(ToolEvent(
2693
+ kind="tool_result",
2694
+ tool_name=tu["name"],
2695
+ tool_output=result_str,
2696
+ tool_use_id=tu["id"],
2697
+ is_error=is_error,
2698
+ ))
2699
+ yield StreamEvent.tool_result(
2700
+ call_id=tu["id"],
2701
+ content=tui_content,
2702
+ is_error=is_error,
2703
+ category=result_cat,
2704
+ )
2705
+ # Add structured tool_result to LLM context
2706
+ if is_anthropic:
2707
+ self.context.add_tool_result(tu["id"], result_str, is_error)
2708
+ else:
2709
+ self.context.add_message(
2710
+ "tool",
2711
+ [{"type": "tool_result", "tool_use_id": tu["id"], "content": result_str, "is_error": is_error}],
2712
+ name=tu["id"],
2713
+ )
2714
+ # Mid-turn compaction: tool results can add significant
2715
+ # context; compress immediately if we're over threshold.
2716
+ self.context.set_model(self.app.current_model)
2717
+ if self.context.should_compact():
2718
+ try:
2719
+ level = self.context.compact()
2720
+ if level not in ("none",):
2721
+ logger.info(
2722
+ "Mid-turn compaction at turn %d after tool %s: %s",
2723
+ turn + 1, tu["name"], level,
2724
+ )
2725
+ except Exception as e:
2726
+ logger.error(
2727
+ "Mid-turn compaction failed at turn %d after tool %s: %s",
2728
+ turn + 1, tu["name"], e,
2729
+ )
2730
+ # Emit plan update when tasks/todos changed. Any
2731
+ # mutating path through TodoManager flips
2732
+ # ``_plan_dirty`` via the ``on_change`` callback, so we
2733
+ # do not need to enumerate tool names here.
2734
+ if self._plan_dirty:
2735
+ for event in self._emit_plan_update():
2736
+ yield event
2737
+ self._plan_dirty = False
2738
+
2739
+ # Tools were called this turn — reset the empty-turn counter
2740
+ self._empty_tool_turns = 0
2741
+
2742
+ # L2: Run verification loop if applicable
2743
+ verification_retry = False
2744
+ if self._verification_loop is not None:
2745
+ from onecode.agent.turn_record import TurnRecord
2746
+ for tu in tool_uses:
2747
+ if self._verification_loop.should_verify(tu["name"]):
2748
+ result_str = self._last_tool_results.get(tu["id"], "")
2749
+ turn_record = TurnRecord(
2750
+ turn_number=turn,
2751
+ thought=clean_text,
2752
+ tool_name=tu["name"],
2753
+ tool_input=tu.get("input"),
2754
+ tool_output=result_str,
2755
+ )
2756
+ agg_result = await self._verification_loop.run_gates(turn_record)
2757
+ turn_record.add_verification(agg_result.to_dict())
2758
+ evt_type = StreamEventType.VERIFICATION_PASSED if agg_result.passed else StreamEventType.VERIFICATION_FAILED
2759
+ yield StreamEvent(
2760
+ type=evt_type,
2761
+ text="passed" if agg_result.passed else f"failed: {', '.join(agg_result.failed_gates)}",
2762
+ verification_passed=agg_result.passed,
2763
+ verification_failed_gates=list(agg_result.failed_gates),
2764
+ )
2765
+ if not agg_result.passed:
2766
+ self._verification_fail_count += 1
2767
+ logger.warning(
2768
+ "Verification failed (attempt %d/3): %s",
2769
+ self._verification_fail_count, agg_result.failed_gates,
2770
+ )
2771
+ self._turn_events.append({
2772
+ "turn": turn + 1,
2773
+ "kind": "verify_fail",
2774
+ "gates": list(agg_result.failed_gates),
2775
+ "attempt": self._verification_fail_count,
2776
+ })
2777
+ if self._verification_fail_count <= 3:
2778
+ lines = [
2779
+ "<!-- VERIFY_FAIL -->",
2780
+ f"## Verification failed: {', '.join(agg_result.failed_gates)}",
2781
+ f"Attempt {self._verification_fail_count}/3 — fix the issues above and retry.",
2782
+ "Do NOT stop. Fix the code and re-run verification tools.",
2783
+ ]
2784
+ self.context.insert_system_before_non_system("\n".join(lines))
2785
+ if self._event_bridge is not None:
2786
+ self._event_bridge.on_verification_failed(agg_result)
2787
+ verification_retry = True
2788
+ break
2789
+ else:
2790
+ logger.warning(
2791
+ "Verification failed after %d retries, stopping execution",
2792
+ self._verification_fail_count,
2793
+ )
2794
+ self._turn_events.append({
2795
+ "turn": turn + 1,
2796
+ "kind": "verify_fail_exhausted",
2797
+ "gates": list(agg_result.failed_gates),
2798
+ })
2799
+ yield StreamEvent.text_delta(
2800
+ f"\n⚠️ Verification failed after 3 retries: {', '.join(agg_result.failed_gates)}\n"
2801
+ )
2802
+ if self._event_bridge is not None:
2803
+ self._event_bridge.on_verification_failed(agg_result)
2804
+ self._cleanup_injected_messages()
2805
+ return
2806
+ else:
2807
+ self._verification_fail_count = 0
2808
+ if self._event_bridge is not None:
2809
+ self._event_bridge.on_verification_passed()
2810
+ if verification_retry:
2811
+ continue
2812
+
2813
+ if self._cancelled:
2814
+ self._cleanup_injected_messages()
2815
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2816
+ return
2817
+
2818
+ self._cleanup_injected_messages()
2819
+ usage_summary = ", ".join(
2820
+ f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
2821
+ for i, u in enumerate(self._turn_usages) if u
2822
+ )
2823
+ logger.info(f"Chat stream complete after {self.iterations} round(s). Usage: [{usage_summary}]")
2824
+
2825
+ # ── Session end: clear todos if all completed ──
2826
+ # When every todo has reached "completed" status, wipe the todo list
2827
+ # so the next session starts clean instead of inheriting a stale plan.
2828
+ try:
2829
+ final_todos = self._todo_manager.list_todos()
2830
+ if final_todos and all(t.get("status") == "completed" for t in final_todos):
2831
+ logger.info(
2832
+ "Session end: all %d todo(s) completed, clearing todo list",
2833
+ len(final_todos),
2834
+ )
2835
+ self._todo_manager.clear_todos()
2836
+ except Exception as e:
2837
+ logger.warning("Session-end todo cleanup failed: %s", e, exc_info=True)
2838
+
2839
+ def has_pending_approval(self) -> bool:
2840
+ """Check if there's a pending approval request from ASK permission."""
2841
+ return self._pending_approval is not None
2842
+
2843
+ async def resolve_approval(self, approved: bool, answer: str = "") -> dict | None:
2844
+ """Execute the pending action if approved, or return denial.
2845
+
2846
+ For AskUser, ``answer`` is the user's free-text response.
2847
+
2848
+ Returns the tool result dict, or None if no pending approval.
2849
+ """
2850
+ from onecode.agent.agents.types import AgentPermission
2851
+ if not self._pending_approval:
2852
+ return None
2853
+
2854
+ tc = self._pending_approval["tool_call"]
2855
+ is_ask_user = self._pending_approval.get("ask_user", False)
2856
+ plan_submit = self._pending_approval.get("plan_submit", False)
2857
+ plan_path = self._pending_approval.get("plan_path")
2858
+ checkpoint_id = self._pending_approval.get("checkpoint_id", "")
2859
+ self._pending_approval = None
2860
+
2861
+ # Handle AskUser — return user's answer as tool result
2862
+ if is_ask_user:
2863
+ if not approved:
2864
+ return {
2865
+ "tool_use_id": tc["id"],
2866
+ "content": json.dumps({"answer": "", "error": "User cancelled"}),
2867
+ "is_error": True,
2868
+ "category": tc.get("category", "unknown"),
2869
+ }
2870
+ # Plan approved → hand off to the execution agent (keeps todos).
2871
+ if plan_submit:
2872
+ self._plan_handoff(plan_path)
2873
+ # answer is a JSON string when multi-question, plain text otherwise
2874
+ try:
2875
+ parsed = json.loads(answer)
2876
+ is_structured = isinstance(parsed, dict) or isinstance(parsed, list)
2877
+ except (json.JSONDecodeError, TypeError):
2878
+ is_structured = False
2879
+ if is_structured:
2880
+ return {
2881
+ "tool_use_id": tc["id"],
2882
+ "content": json.dumps({"answers": parsed}),
2883
+ "is_error": False,
2884
+ "category": tc.get("category", "unknown"),
2885
+ }
2886
+ return {
2887
+ "tool_use_id": tc["id"],
2888
+ "content": json.dumps({"answer": answer}),
2889
+ "is_error": False,
2890
+ "category": tc.get("category", "unknown"),
2891
+ }
2892
+
2893
+ if not approved:
2894
+ return {
2895
+ "tool_use_id": tc["id"],
2896
+ "content": json.dumps({"success": False, "error": "User denied the operation"}),
2897
+ "is_error": True,
2898
+ "category": tc.get("category", "unknown"),
2899
+ }
2900
+
2901
+ if answer == "__rollback__" and checkpoint_id:
2902
+ self._checkpoint_manager.restore(checkpoint_id)
2903
+ return {
2904
+ "tool_use_id": tc["id"],
2905
+ "content": json.dumps({"success": False, "error": "Rolled back to checkpoint"}),
2906
+ "is_error": True,
2907
+ "category": tc.get("category", "unknown"),
2908
+ }
2909
+
2910
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(tc["name"])
2911
+ if perm_key is None:
2912
+ return await self._execute_tool(tc)
2913
+
2914
+ attr_name = f"permission_{perm_key}"
2915
+ saved = getattr(self.current_agent, attr_name)
2916
+ try:
2917
+ setattr(self.current_agent, attr_name, AgentPermission.ALLOW)
2918
+ return await self._execute_tool(tc)
2919
+ finally:
2920
+ setattr(self.current_agent, attr_name, saved)
2921
+
2922
+ async def rollback_to_checkpoint(self, checkpoint_id: str) -> bool:
2923
+ """Restore files to a previous checkpoint state."""
2924
+ return self._checkpoint_manager.restore(checkpoint_id)
2925
+
2926
+ def get_checkpoint_diff(self, checkpoint_id: str, file_path: str) -> str:
2927
+ """Get diff between checkpoint and current state for a file."""
2928
+ return self._checkpoint_manager.get_diff(checkpoint_id, file_path)
2929
+
2930
+ def list_checkpoints(self, limit: int = 10) -> list[dict]:
2931
+ """List recent checkpoints."""
2932
+ return self._checkpoint_manager.list(limit=limit)
2933
+
2934
+ def _reset_react_state(self) -> None:
2935
+ """Reset ReAct loop state for a fresh cycle."""
2936
+ self._react_phase = "thought"
2937
+ self._direct_execution_count = 0
2938
+ self._empty_tool_turns = 0
2939
+ self._plan_denial_count = 0
2940
+ self._verification_fail_count = 0
2941
+ self._last_tool_results.clear()
2942
+ self._cleanup_injected_messages()
2943
+ self.context.remove_system_by_marker("<!-- PLAN_MODE_DENIED -->")
2944
+ for name in list(self._tool_denial_count):
2945
+ self.context.remove_system_by_marker(f"<!-- TOOL_DENIED_{name} -->")
2946
+ self._tool_denial_count.clear()
2947
+ self.context.remove_system_by_marker("<!-- VERIFY_FAIL -->")
2948
+ self._last_emitted_plan = ()
2949
+ self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
2950
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
2951
+ self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
2952
+
2953
+ def reset(self):
2954
+ self.context.reset()
2955
+ self.iterations = 0
2956
+ self.total_tokens = 0
2957
+ self._turn_usages = []
2958
+ self._turn_events = []
2959
+ self._skills_loaded = False
2960
+ self._project_context_loaded = False
2961
+ self._ctx_crisis = False
2962
+ self._skill_loader.invalidate_cache()
2963
+ self._reset_react_state()
2964
+
2965
+ def status(self) -> str:
2966
+ return (
2967
+ f"Agent: {self.current_agent.name}\n"
2968
+ f"Iterations: {self.iterations}\n"
2969
+ f"Total tokens: {self.total_tokens}\n"
2970
+ f"Context: {self.context.info()}"
2971
+ )
2972
+
2973
+ def read_file(self, path: str, offset: int = 0, limit: int = 0) -> str:
2974
+ if self.current_agent.permission_read == AgentPermission.DENY:
2975
+ return "Read denied"
2976
+ return self.file_ops.read(path, offset, limit)
2977
+
2978
+ def write_file(self, path: str, content: str) -> dict:
2979
+ if self.current_agent.permission_edit == AgentPermission.DENY:
2980
+ return {"success": False, "error": "Edit denied"}
2981
+ if self.current_agent.permission_edit == AgentPermission.ASK:
2982
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
2983
+ return self.file_ops.write(path, content)
2984
+
2985
+ def edit_file(self, path: str, old: str, new: str) -> dict:
2986
+ if self.current_agent.permission_edit == AgentPermission.DENY:
2987
+ return {"success": False, "error": "Edit denied"}
2988
+ if self.current_agent.permission_edit == AgentPermission.ASK:
2989
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
2990
+ return self.file_ops.edit(path, old, new)
2991
+
2992
+ def glob_files(self, pattern: str) -> list[str]:
2993
+ return self.file_ops.glob(pattern)
2994
+
2995
+ def grep_files(self, pattern: str, include: str = None) -> list[str]:
2996
+ return self.file_ops.grep(pattern, include)
2997
+
2998
+ def list_dir(self, path: str = ".") -> list[dict]:
2999
+ return self.file_ops.list(path)
3000
+
3001
+ def exec_shell(self, cmd: str, timeout: int = 60) -> dict:
3002
+ if self.current_agent.permission_bash == AgentPermission.DENY:
3003
+ return {"success": False, "error": "Bash denied"}
3004
+ if self.current_agent.permission_bash == AgentPermission.ASK:
3005
+ return {"success": False, "error": "Bash requires approval", "requires_approval": True}
3006
+ return self.shell.exec(cmd, timeout=timeout)
3007
+
3008
+ def web_fetch(self, url: str, prompt: str = None) -> str:
3009
+ if self.current_agent.permission_webfetch == AgentPermission.DENY:
3010
+ return "WebFetch denied"
3011
+ from onecode.agent.tools.web_tools import webfetch
3012
+ return webfetch(url, prompt)
3013
+
3014
+ def web_search(self, query: str, num_results: int = 5) -> str:
3015
+ if self.current_agent.permission_websearch == AgentPermission.DENY:
3016
+ return "WebSearch denied"
3017
+ from onecode.agent.tools.web_tools import websearch
3018
+ return websearch(query, num_results)
3019
+
3020
+ async def _spawn_subagent_async(self, agent_type: str, prompt: str) -> str:
3021
+ """Spawn a sub-agent with streaming-like output.
3022
+
3023
+ Uses chat_stream to get structured output that the ChatPanel
3024
+ can parse for professional rendering.
3025
+ """
3026
+ parts: list[str] = []
3027
+ async for _event, text in self._spawn_subagent_async_streaming(agent_type, prompt):
3028
+ parts.append(text)
3029
+ result = "".join(parts)
3030
+ return self._format_subagent_output(agent_type, prompt, result)
3031
+
3032
+ async def _spawn_subagent_async_streaming(
3033
+ self, agent_type: str, prompt: str, subagent_id: str = ""
3034
+ ) -> AsyncIterator[tuple[StreamEvent, str]]:
3035
+ """Streaming variant of :meth:`_spawn_subagent_async`.
3036
+
3037
+ Yields ``(event, text)`` pairs so the caller (typically the Task-tool
3038
+ path in :meth:`chat_stream`) can forward each text delta as a
3039
+ ``subagent_chunk`` notification on the ACP wire.
3040
+
3041
+ Inherits project context and skills from the parent
3042
+ engine so the subagent does not start from a blank slate.
3043
+ """
3044
+ # Depth check: limit from config (default 1 = leaf nodes only).
3045
+ depth_limit = getattr(self.app.config.agent, 'max_subagent_depth', _MAX_SUBAGENT_DEPTH)
3046
+ subagent_timeout = getattr(self.app.config.agent, 'max_subagent_timeout', _SUBAGENT_TIMEOUT)
3047
+ if self._subagent_depth >= depth_limit:
3048
+ err = f"Subagent cannot spawn nested subagents (max depth={depth_limit})"
3049
+ yield (StreamEvent.subagent_end(
3050
+ "", agent_type=agent_type, status="failed", error=err,
3051
+ ), err)
3052
+ return
3053
+
3054
+ if self.current_agent.name == "plan" and agent_type not in ("explore", "scout"):
3055
+ err = f"PlanAgent can only spawn read-only agents (explore, scout), not '{agent_type}'"
3056
+ yield (StreamEvent.subagent_end(
3057
+ "", agent_type=agent_type, status="failed", error=err,
3058
+ ), err)
3059
+ return
3060
+
3061
+ sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
3062
+
3063
+ sub_engine._subagent_depth = self._subagent_depth + 1
3064
+
3065
+ # Inherit parent context: project info (but NOT full skill content,
3066
+ # which contains Master Orchestrator instructions that conflict with
3067
+ # the subagent role). The subagent's own _load_skills() will load
3068
+ # only non-conflicting reference info via its own SkillLoader.
3069
+ sub_engine._project_context_loaded = self._project_context_loaded
3070
+ sub_engine._project_config = dict(self._project_config)
3071
+ sub_engine._skills_loaded = False
3072
+
3073
+ # Inherit the parent's already-built codebase engine so the subagent
3074
+ # does NOT re-run the full project index (CodebaseIndexer.index()
3075
+ # is a synchronous chunk loop that would otherwise block the event
3076
+ # loop for the duration of indexing and freeze streaming/cancel).
3077
+ # Sharing the instance also avoids duplicate SQLite writes.
3078
+ sub_engine._codebase_engine = self._codebase_engine
3079
+ logger.debug(
3080
+ "[SA-STREAM %s] inheriting codebase_engine from parent: %s",
3081
+ agent_type, "shared" if self._codebase_engine is not None else "none",
3082
+ )
3083
+
3084
+ # CRITICAL: disable codebase auto-retrieval + memory recall inside
3085
+ # the subagent. Production logs prove that every Spawn hung here:
3086
+ # the subagent's ``chat_stream`` re-ran ``CodebaseIndexer.index()``
3087
+ # (a 2.3–2.6s synchronous loop, no await yield points) and then
3088
+ # ``CodebaseRetriever._retrieve_embedding()`` issued one ``httpx``
3089
+ # POST per chunk (30s timeout each, no effective cap), so a 148-
3090
+ # chunk project could block the event loop for ~minutes — during
3091
+ # which ``on_text_chunk`` never fires (no stream output) and
3092
+ # ``CancelledError`` injected by ``prompt_task.cancel()`` cannot
3093
+ # propagate (cannot be stopped). A subagent executes a specific
3094
+ # prompt handed down by the parent; it does not need project-level
3095
+ # codebase seeding or memory recall.
3096
+ sub_engine._disable_retrieval = True
3097
+ logger.debug(
3098
+ "[SA-STREAM %s] set _disable_retrieval=True (subagent skips "
3099
+ "codebase auto-retrieval + memory recall)", agent_type,
3100
+ )
3101
+
3102
+ # Do NOT propagate parent TUI-facing callbacks to the sub_engine.
3103
+ # - on_tool_call_delta: would fire the ACP's _on_tool_call_delta for
3104
+ # the subagent's inner tools (Read/Bash/...), leaking their NAMES
3105
+ # into the MAIN conversation as orphaned in_progress cards while
3106
+ # the RESULT only reaches the SubAgent widget via the stream path
3107
+ # below (tool_call_start/tool_result -> text_delta -> subagent_chunk).
3108
+ # - on_thinking: would append subagent thinking into the PARENT's
3109
+ # _pending_thinking_blocks, polluting the parent's persisted session.
3110
+ # The subagent's tool calls/thinking are already relayed to the
3111
+ # SubAgent widget via SUBAGENT_CHUNK / SUBAGENT_THINKING (see the
3112
+ # consumer loop below). on_event is left propagated: the ACP does
3113
+ # not wire it, so it is None on the parent and a no-op here.
3114
+ if self.on_event is not None:
3115
+ sub_engine.on_event = self.on_event
3116
+
3117
+ # Apply the requested agent_type's config (role description,
3118
+ # response-style / <thinking> tags, tool allow/deny list, REACT_CYCLE)
3119
+ # as the AGENT_CONFIG system section.
3120
+ sub_engine.set_agent(agent_type)
3121
+
3122
+ # Track child so parent cancellation cascades
3123
+ self._child_engines.append(sub_engine)
3124
+
3125
+ # ── Real-time streaming via asyncio.Queue ──
3126
+ # on_text_chunk puts provider tokens into the queue as they arrive,
3127
+ # and a background task puts chat_stream events into the same queue.
3128
+ # The main loop reads from the queue and yields (event, text) pairs,
3129
+ # giving the TUI live subagent output instead of "等待输出".
3130
+ _queue: asyncio.Queue[tuple[str, object]] = asyncio.Queue(maxsize=1000)
3131
+ _sa_log_prefix = f"[SA-STREAM {agent_type} depth={self._subagent_depth + 1}]"
3132
+ _sa_total_bytes = 0
3133
+ _sa_chunk_count = 0
3134
+ _sa_text_count = 0
3135
+ _sa_first_byte_logged = False
3136
+
3137
+ def _on_subagent_text(text: str) -> None:
3138
+ nonlocal _sa_total_bytes, _sa_text_count, _sa_first_byte_logged
3139
+ try:
3140
+ _queue.put_nowait(("text", text))
3141
+ except asyncio.QueueFull:
3142
+ logger.warning(
3143
+ "%s on_text_chunk queue full (maxsize=1000) — dropping text chunk "
3144
+ "of %d bytes. Consider increasing max_subagent_timeout to allow "
3145
+ "faster processing.",
3146
+ _sa_log_prefix, len(text),
3147
+ )
3148
+ return
3149
+ _sa_text_count += 1
3150
+ _sa_total_bytes += len(text)
3151
+ if not _sa_first_byte_logged:
3152
+ _sa_first_byte_logged = True
3153
+ logger.debug(
3154
+ "%s on_text_chunk FIRST token bytes=%d (text_count so far=%d)",
3155
+ _sa_log_prefix, len(text), _sa_text_count,
3156
+ )
3157
+ if _sa_text_count % 20 == 0:
3158
+ logger.debug(
3159
+ "%s on_text_chunk progress text_calls=%d total_bytes=%d "
3160
+ "queued=%d",
3161
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes,
3162
+ _queue.qsize(),
3163
+ )
3164
+
3165
+ sub_engine.on_text_chunk = _on_subagent_text
3166
+ logger.debug("%s wiring on_text_chunk → queue (sub_engine id=%x)",
3167
+ _sa_log_prefix, id(sub_engine))
3168
+
3169
+ async def _run_chat_stream() -> None:
3170
+ logger.debug("%s _run_chat_stream task started", _sa_log_prefix)
3171
+ try:
3172
+ async for chunk in sub_engine.chat_stream(prompt):
3173
+ await _queue.put(("event", chunk))
3174
+ except asyncio.CancelledError:
3175
+ logger.debug("%s _run_chat_stream CancelledError → queue(cancelled)",
3176
+ _sa_log_prefix)
3177
+ await _queue.put(("cancelled", None))
3178
+ except Exception as e:
3179
+ logger.exception("%s _run_chat_stream exception: %s", _sa_log_prefix, e)
3180
+ await _queue.put(("error", e))
3181
+ finally:
3182
+ logger.debug(
3183
+ "%s _run_chat_stream finally → queue(done) text=%d bytes=%d "
3184
+ "chunk_events=%d",
3185
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3186
+ )
3187
+ await _queue.put(("done", None))
3188
+
3189
+ _stream_task = asyncio.create_task(_run_chat_stream())
3190
+ logger.debug("%s created _stream_task id=%x cancelled_flag=%s",
3191
+ _sa_log_prefix, id(_stream_task), self._cancelled)
3192
+
3193
+ parts: list[str] = []
3194
+ _streamed_via_text = False # True once on_text_chunk has delivered tokens
3195
+
3196
+ try:
3197
+ logger.debug("%s entering queue.get() consumer loop", _sa_log_prefix)
3198
+ _sa_deadline = subagent_timeout
3199
+ while True:
3200
+ if self._cancelled:
3201
+ logger.debug("%s cancelled — breaking consumer loop", _sa_log_prefix)
3202
+ yield (StreamEvent.subagent_end(
3203
+ "", agent_type=agent_type, status="failed", error="cancelled",
3204
+ ), "")
3205
+ return
3206
+ try:
3207
+ item_type, item = await asyncio.wait_for(
3208
+ _queue.get(),
3209
+ timeout=min(1.0, _sa_deadline),
3210
+ )
3211
+ _sa_deadline = subagent_timeout # reset on each item
3212
+ except asyncio.TimeoutError:
3213
+ _sa_deadline -= 1.0
3214
+ if _sa_deadline <= 0:
3215
+ logger.warning(
3216
+ "%s subagent timed out after %ds — cancelling",
3217
+ _sa_log_prefix, subagent_timeout,
3218
+ )
3219
+ yield (StreamEvent.subagent_end(
3220
+ "", agent_type=agent_type, status="failed",
3221
+ error=f"timed out after {subagent_timeout}s",
3222
+ ), "")
3223
+ return
3224
+ continue
3225
+ _sa_chunk_count += 1
3226
+ if _sa_chunk_count <= 5 or _sa_chunk_count % 50 == 0:
3227
+ logger.debug(
3228
+ "%s queue.get() #%d type=%s cancelled_flag=%s qsize=%d "
3229
+ "text=%d bytes=%d",
3230
+ _sa_log_prefix, _sa_chunk_count, item_type,
3231
+ self._cancelled, _queue.qsize(), _sa_text_count,
3232
+ _sa_total_bytes,
3233
+ )
3234
+
3235
+ if item_type == "text":
3236
+ _streamed_via_text = True
3237
+ parts.append(item)
3238
+ yield (StreamEvent.text_delta(item), item)
3239
+
3240
+ elif item_type == "event":
3241
+ chunk = item
3242
+ if isinstance(chunk, StreamEvent):
3243
+ if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
3244
+ # Skip TEXT_DELTA if tokens were already streamed
3245
+ # in real-time via on_text_chunk (avoids doubles).
3246
+ if not _streamed_via_text:
3247
+ if _sa_chunk_count <= 5:
3248
+ logger.debug(
3249
+ "%s TEXT_DELTA via event path (no "
3250
+ "streamed_via_text) bytes=%d",
3251
+ _sa_log_prefix, len(chunk.text),
3252
+ )
3253
+ parts.append(chunk.text)
3254
+ yield (chunk, chunk.text)
3255
+ else:
3256
+ if _sa_chunk_count <= 3:
3257
+ logger.debug(
3258
+ "%s TEXT_DELTA skipped (already "
3259
+ "streamed_via_text) bytes=%d",
3260
+ _sa_log_prefix, len(chunk.text),
3261
+ )
3262
+ elif chunk.type == StreamEventType.THINKING and chunk.thinking:
3263
+ logger.debug("%s THINKING event bytes=%d",
3264
+ _sa_log_prefix, len(chunk.thinking))
3265
+ yield (chunk, chunk.thinking)
3266
+ elif chunk.type == StreamEventType.TOOL_CALL_START:
3267
+ logger.debug("%s TOOL_CALL_START tool=%s",
3268
+ _sa_log_prefix, chunk.tool_name)
3269
+ yield (StreamEvent.subagent_tool_call(
3270
+ subagent_id, "start", chunk.tool_id,
3271
+ chunk.tool_name, chunk.tool_category,
3272
+ ), "")
3273
+ elif chunk.type == StreamEventType.TOOL_CALL_COMPLETE:
3274
+ logger.debug("%s TOOL_CALL_COMPLETE tool=%s",
3275
+ _sa_log_prefix, chunk.tool_name)
3276
+ yield (StreamEvent.subagent_tool_call(
3277
+ subagent_id, "complete", chunk.tool_id,
3278
+ chunk.tool_name, chunk.tool_category,
3279
+ chunk.tool_args,
3280
+ ), "")
3281
+ elif chunk.type == StreamEventType.TOOL_RESULT:
3282
+ logger.debug("%s TOOL_RESULT tool=%s",
3283
+ _sa_log_prefix, chunk.tool_name)
3284
+ yield (StreamEvent.subagent_tool_result(
3285
+ subagent_id, chunk.tool_id, chunk.tool_name,
3286
+ chunk.result_content, chunk.result_is_error,
3287
+ chunk.result_category,
3288
+ ), "")
3289
+ elif chunk.type == StreamEventType.ERROR:
3290
+ logger.debug("%s ERROR event msg=%s",
3291
+ _sa_log_prefix, chunk.error_message)
3292
+ text = f"\n[Error: {chunk.error_message}]\n"
3293
+ yield (StreamEvent.text_delta(text), text)
3294
+ else:
3295
+ if chunk:
3296
+ parts.append(chunk)
3297
+
3298
+ elif item_type == "done":
3299
+ logger.debug("%s queue.get()=done → break loop", _sa_log_prefix)
3300
+ break
3301
+
3302
+ elif item_type == "cancelled":
3303
+ logger.debug(
3304
+ "%s queue.get()=cancelled → yield subagent_end "
3305
+ "failed=cancelled", _sa_log_prefix)
3306
+ yield (StreamEvent.subagent_end(
3307
+ "", agent_type=agent_type, status="failed", error="cancelled",
3308
+ ), "")
3309
+ return
3310
+
3311
+ elif item_type == "error":
3312
+ logger.exception("%s queue.get()=error: %s", _sa_log_prefix, item)
3313
+ err_text = (
3314
+ f"SUMMARY:\nSub-agent failed with error: {item}\n\n"
3315
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
3316
+ f"BLOCKERS:\n{item}"
3317
+ )
3318
+ yield (StreamEvent.subagent_end(
3319
+ "", agent_type=agent_type, status="failed", error=str(item),
3320
+ ), err_text)
3321
+ return
3322
+
3323
+ except asyncio.CancelledError:
3324
+ logger.debug("%s consumer loop CancelledError → subagent_end", _sa_log_prefix)
3325
+ # Parent cancelled — emit subagent_end so the TUI widget
3326
+ # transitions out of "running", then re-raise.
3327
+ yield (StreamEvent.subagent_end(
3328
+ "", agent_type=agent_type, status="failed", error="cancelled",
3329
+ ), "")
3330
+ raise
3331
+ finally:
3332
+ logger.debug(
3333
+ "%s consumer finally: stream_task.done=%s cancelled_flag=%s "
3334
+ "text=%d bytes=%d chunk_events=%d",
3335
+ _sa_log_prefix, _stream_task.done(), self._cancelled,
3336
+ _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3337
+ )
3338
+ if not _stream_task.done():
3339
+ logger.debug("%s finally: cancelling _stream_task (still running)",
3340
+ _sa_log_prefix)
3341
+ _stream_task.cancel()
3342
+ try:
3343
+ logger.debug("%s finally: awaiting _stream_task ...",
3344
+ _sa_log_prefix)
3345
+ await _stream_task
3346
+ logger.debug("%s finally: await _stream_task returned cleanly",
3347
+ _sa_log_prefix)
3348
+ except asyncio.CancelledError:
3349
+ logger.debug("%s finally: await _stream_task CancelledError",
3350
+ _sa_log_prefix)
3351
+ except Exception as e:
3352
+ logger.debug("%s finally: await _stream_task exception=%s",
3353
+ _sa_log_prefix, e)
3354
+ if sub_engine in self._child_engines:
3355
+ self._child_engines.remove(sub_engine)
3356
+ logger.debug(
3357
+ "%s finally done text=%d bytes=%d chunk_events=%d",
3358
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3359
+ )
3360
+
3361
+ # Normal completion
3362
+ yield (StreamEvent.subagent_end(
3363
+ "", agent_type=agent_type, status="completed",
3364
+ ), "".join(parts))
3365
+
3366
+ _SUBAGENT_SECTION_RE = re.compile(
3367
+ r'^(SUMMARY|CHANGES|EVIDENCE|RISKS|BLOCKERS):\s*$',
3368
+ re.IGNORECASE | re.MULTILINE,
3369
+ )
3370
+
3371
+ def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
3372
+ """Format sub-agent output in DeepSeek-TUI structured contract format.
3373
+
3374
+ Output contract:
3375
+ SUMMARY: one paragraph; what you did and what happened
3376
+ CHANGES: files modified, with one-line descriptions; "None." if read-only
3377
+ EVIDENCE: path:line-range citations and key findings; one bullet each
3378
+ RISKS: what could go wrong / what the parent should double-check
3379
+ BLOCKERS: what stopped you; "None." if you finished cleanly
3380
+ """
3381
+ # Detect structured sections via line-start regex, not substring match
3382
+ has_sections = self._SUBAGENT_SECTION_RE.search(result) is not None
3383
+
3384
+ if has_sections:
3385
+ return result
3386
+
3387
+ return (
3388
+ f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
3389
+ f"CHANGES:\nNone detected.\n\n"
3390
+ f"EVIDENCE:\n- See detailed output below\n\n"
3391
+ f"RISKS:\nNone identified.\n\n"
3392
+ f"BLOCKERS:\nNone.\n\n"
3393
+ f"--- Raw Output ---\n{result}"
3394
+ )
3395
+
3396
+ def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
3397
+ if self.current_agent.permission_task == AgentPermission.DENY:
3398
+ return {"success": False, "error": "Subagent denied"}
3399
+ import asyncio
3400
+ try:
3401
+ result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
3402
+ except Exception as e:
3403
+ logger.exception(f"Subagent error: {e}")
3404
+ return {"success": False, "error": safe_error_msg(e)}
3405
+ return {
3406
+ "success": True,
3407
+ "agent_type": agent_type,
3408
+ "response": result,
3409
+ "iterations": 1,
3410
+ }
3411
+
3412
+ def tool_task(self, agent_type: str, prompt: str) -> str:
3413
+ result = self.spawn_subagent(agent_type, prompt)
3414
+ if result["success"]:
3415
+ return result["response"]
3416
+ return f"Error: {result.get('error') or 'Unknown error'}"
3417
+
3418
+ def attach_session(self, session: AgentSession) -> None:
3419
+ self._session = session
3420
+ if session.messages:
3421
+ self.context.load_from_session(session.messages)
3422
+ if session.todos:
3423
+ self._todo_manager.reload_from_dict({
3424
+ "todos": session.todos,
3425
+ "id_counter": len(session.todos),
3426
+ })
3427
+ # Restore context usage stats
3428
+ self._restore_session_stats(session)
3429
+
3430
+ def save_session(self) -> None:
3431
+ if self._session:
3432
+ try:
3433
+ self._session.messages = self.context.to_session_format()
3434
+ tm_data = self._todo_manager.to_dict()
3435
+ self._session.todos = tm_data.get("todos", [])
3436
+ # Persist context usage stats so they survive session reload
3437
+ self._session.update_state("stats", {
3438
+ "total_tokens": self.total_tokens,
3439
+ "iterations": self.iterations,
3440
+ "turn_usages": [
3441
+ {k: v for k, v in u.items() if isinstance(v, (int, float))}
3442
+ for u in self._turn_usages
3443
+ ],
3444
+ })
3445
+ self._session.save()
3446
+ except Exception as e:
3447
+ logger.exception("Failed to save session: %s", e)
3448
+
3449
+ def load_session(self, session_id: str) -> bool:
3450
+ session = AgentSession(session_id)
3451
+ if not session.load():
3452
+ return False
3453
+ self._session = session
3454
+ self.context.load_from_session(session.messages)
3455
+ if session.todos:
3456
+ self._todo_manager.reload_from_dict({
3457
+ "todos": session.todos,
3458
+ "id_counter": len(session.todos),
3459
+ })
3460
+ self._inject_loaded_tasks_into_context()
3461
+ # Restore context usage stats
3462
+ self._restore_session_stats(session)
3463
+ return True
3464
+
3465
+ def _inject_loaded_tasks_into_context(self) -> None:
3466
+ """Surface loaded todos to the LLM so it can continue them.
3467
+
3468
+ Todos loaded from a previous session are not automatically visible
3469
+ in the LLM context — without this nudge the agent would re-create
3470
+ them on every fresh turn instead of resuming pending work.
3471
+ """
3472
+ all_todos = self._todo_manager.list_todos()
3473
+ pending = [t for t in all_todos if t.get("status") in ("pending", "in_progress")]
3474
+ if not pending:
3475
+ return
3476
+
3477
+ lines = ["# Resumed todos from previous session", ""]
3478
+ lines.append("## Todos")
3479
+ for t in pending:
3480
+ status = t.get("status", "pending")
3481
+ marker = {"in_progress": "[~]", "pending": "[ ]"}.get(status, "[?]")
3482
+ subject = t.get("subject") or t.get("description", "")
3483
+ desc = t.get("description") or ""
3484
+ if desc and desc != subject:
3485
+ lines.append(f"- {marker} (id={t.get('id')}) {subject} — {desc}")
3486
+ else:
3487
+ lines.append(f"- {marker} (id={t.get('id')}) {subject}")
3488
+ lines.append("")
3489
+
3490
+ lines.append(
3491
+ "Continue working on any in_progress or pending todos above. "
3492
+ "Use TodoList/TodoGet to inspect details and TodoUpdate to "
3493
+ "advance status. Do NOT recreate todos that already exist."
3494
+ )
3495
+ marker = "<!-- loaded_todos_resume -->"
3496
+ body = f"{marker}\n" + "\n".join(lines)
3497
+ if not self.context.replace_system_section(marker, body):
3498
+ self.context.add_system(body)
3499
+
3500
+ def _restore_session_stats(self, session: AgentSession) -> None:
3501
+ """Restore context usage stats from session lifecycle_state."""
3502
+ stats = session.get_state("stats", {})
3503
+ if not stats:
3504
+ return
3505
+ self.total_tokens = int(stats.get("total_tokens", self.total_tokens))
3506
+ self.iterations = int(stats.get("iterations", self.iterations))
3507
+ restored_turns = stats.get("turn_usages", [])
3508
+ if restored_turns and isinstance(restored_turns, list):
3509
+ self._turn_usages = list(restored_turns)
3510
+
3511
+ def save_todos_to_project(self) -> None:
3512
+ """Save todos to .cdh/todos.json — uses find_cdh_dir_for_todos
3513
+ so sub-projects never overwrite a parent project's todos."""
3514
+ from onecode.agent.cdh_loader import CdhProjectLoader
3515
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
3516
+ if cdh_dir is None:
3517
+ cdh_dir = self._project_dir / CdhProjectLoader.CDH_DIRNAME
3518
+ try:
3519
+ cdh_dir.mkdir(parents=True, exist_ok=True)
3520
+ except Exception as e:
3521
+ logger.warning("Failed to create .cdh directory: %s", e)
3522
+ return
3523
+ try:
3524
+ tm_data = self._todo_manager.to_dict()
3525
+ CdhProjectLoader.save_todos(cdh_dir, tm_data)
3526
+ except Exception as e:
3527
+ logger.warning("Failed to save todos to .cdh: %s", e)
3528
+
3529
+ def load_todos_from_project(self) -> None:
3530
+ """Restore todos from .cdh/todos.json — uses find_cdh_dir_for_todos
3531
+ so sub-projects never accidentally load a parent project's todos.
3532
+ If todos are found, the ``<!-- NEW_SESSION_HINT -->`` marker is
3533
+ removed from the system context (it only applies to blank sessions)."""
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
+ return
3538
+ try:
3539
+ data = CdhProjectLoader.load_todos(cdh_dir)
3540
+ if data and (data.get("tasks") or data.get("todos")):
3541
+ self._todo_manager.reload_from_dict(data)
3542
+ self.context.remove_system_by_marker("<!-- NEW_SESSION_HINT -->")
3543
+ except Exception as e:
3544
+ logger.warning("Failed to load todos from .cdh: %s", e)
3545
+
3546
+ def get_session(self) -> Optional[AgentSession]:
3547
+ return self._session