@scemoon/cdh 1.0.7 → 1.0.9-beta.1

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 (787) 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/run.js +2 -1
  525. package/tui/__init__.py +46 -0
  526. package/tui/__main__.py +4 -0
  527. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  528. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  529. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  530. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  531. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  532. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  533. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  534. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  535. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  536. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  537. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  538. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  539. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  540. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  541. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  542. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  543. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  544. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  545. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  546. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  547. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  548. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  549. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  550. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  551. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  552. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  553. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  554. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  555. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  556. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  557. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  558. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  559. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  560. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  561. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  562. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  563. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  564. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  565. package/tui/_loop.py +86 -0
  566. package/tui/_path_fuzzy_search.py +48 -0
  567. package/tui/_path_match.py +141 -0
  568. package/tui/about.py +95 -0
  569. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  570. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  571. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  572. package/tui/acp/__pycache__/event_tap.cpython-314.pyc +0 -0
  573. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  574. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  575. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  576. package/tui/acp/agent.py +1212 -0
  577. package/tui/acp/api.py +79 -0
  578. package/tui/acp/encode_tool_call_id.py +12 -0
  579. package/tui/acp/event_tap.py +107 -0
  580. package/tui/acp/messages.py +222 -0
  581. package/tui/acp/prompt.py +53 -0
  582. package/tui/acp/protocol.py +551 -0
  583. package/tui/agent.py +70 -0
  584. package/tui/agent_schema.py +76 -0
  585. package/tui/agents.py +46 -0
  586. package/tui/ansi/__init__.py +1 -0
  587. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  588. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  589. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  590. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  591. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  592. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  593. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  594. package/tui/ansi/_ansi.py +1689 -0
  595. package/tui/ansi/_ansi_colors.py +264 -0
  596. package/tui/ansi/_control_codes.py +37 -0
  597. package/tui/ansi/_keys.py +251 -0
  598. package/tui/ansi/_sgr_styles.py +64 -0
  599. package/tui/ansi/_stream_parser.py +418 -0
  600. package/tui/answer.py +12 -0
  601. package/tui/app.py +1195 -0
  602. package/tui/atomic.py +37 -0
  603. package/tui/cli.py +208 -0
  604. package/tui/code_analyze.py +27 -0
  605. package/tui/complete.py +38 -0
  606. package/tui/constants.py +61 -0
  607. package/tui/conversation_markdown.py +30 -0
  608. package/tui/danger.py +372 -0
  609. package/tui/data/agents/ampcode.com.toml +52 -0
  610. package/tui/data/agents/augmentcode.com.toml +41 -0
  611. package/tui/data/agents/cdh.cloud-dev-harness.toml +23 -0
  612. package/tui/data/agents/claude.com.toml +41 -0
  613. package/tui/data/agents/copilot.github.com.toml +29 -0
  614. package/tui/data/agents/crowai.dev.toml +36 -0
  615. package/tui/data/agents/cursor.com.toml +38 -0
  616. package/tui/data/agents/deepagents.langchain.com +34 -0
  617. package/tui/data/agents/docker.com.toml +60 -0
  618. package/tui/data/agents/geminicli.com.toml +28 -0
  619. package/tui/data/agents/goose.ai.toml +52 -0
  620. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  621. package/tui/data/agents/kimi.com.toml +35 -0
  622. package/tui/data/agents/kiro.dev.toml +43 -0
  623. package/tui/data/agents/onecode.dev.toml +22 -0
  624. package/tui/data/agents/openai.com.toml +53 -0
  625. package/tui/data/agents/openclaw.ai.toml +37 -0
  626. package/tui/data/agents/opencode.ai.toml +39 -0
  627. package/tui/data/agents/openhands.dev.toml +46 -0
  628. package/tui/data/agents/stakpak.dev.toml +62 -0
  629. package/tui/data/agents/vibe.mistral.ai.toml +31 -0
  630. package/tui/data/agents/vtcode.dev.toml +63 -0
  631. package/tui/data/images/frog.png +0 -0
  632. package/tui/data/sounds/question.wav +0 -0
  633. package/tui/data/sounds/turn-over.wav +0 -0
  634. package/tui/db.py +213 -0
  635. package/tui/dec.py +332 -0
  636. package/tui/diff_utils.py +131 -0
  637. package/tui/directory.py +234 -0
  638. package/tui/directory_suggester.py +93 -0
  639. package/tui/directory_watcher.py +231 -0
  640. package/tui/format_path.py +25 -0
  641. package/tui/fuzzy.py +140 -0
  642. package/tui/fuzzy_index.py +275 -0
  643. package/tui/gist.py +33 -0
  644. package/tui/history.py +138 -0
  645. package/tui/jsonrpc.py +599 -0
  646. package/tui/menus.py +14 -0
  647. package/tui/message_log.py +69 -0
  648. package/tui/messages.py +144 -0
  649. package/tui/option_content.py +51 -0
  650. package/tui/os.py +0 -0
  651. package/tui/path_complete.py +217 -0
  652. package/tui/path_filter.py +124 -0
  653. package/tui/paths.py +87 -0
  654. package/tui/pill.py +23 -0
  655. package/tui/platform_commands.py +52 -0
  656. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  657. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  658. package/tui/prompt/extract.py +19 -0
  659. package/tui/prompt/resource.py +68 -0
  660. package/tui/protocol.py +28 -0
  661. package/tui/screens/__init__.py +0 -0
  662. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  663. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  664. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  665. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  666. package/tui/screens/action_modal.py +94 -0
  667. package/tui/screens/agent_modal.py +175 -0
  668. package/tui/screens/command_edit_modal.py +74 -0
  669. package/tui/screens/diff_explorer_screen.py +419 -0
  670. package/tui/screens/diff_screen.py +84 -0
  671. package/tui/screens/ide_screen.py +461 -0
  672. package/tui/screens/main.py +397 -0
  673. package/tui/screens/main.tcss +662 -0
  674. package/tui/screens/permissions.py +404 -0
  675. package/tui/screens/permissions.tcss +72 -0
  676. package/tui/screens/projects.tcss +84 -0
  677. package/tui/screens/projects_app.py +341 -0
  678. package/tui/screens/projects_screen.py +277 -0
  679. package/tui/screens/session_resume_modal.py +172 -0
  680. package/tui/screens/session_resume_modal.tcss +35 -0
  681. package/tui/screens/sessions.py +171 -0
  682. package/tui/screens/sessions.tcss +119 -0
  683. package/tui/screens/settings.py +293 -0
  684. package/tui/screens/settings.tcss +113 -0
  685. package/tui/screens/store.py +502 -0
  686. package/tui/screens/store.tcss +274 -0
  687. package/tui/screens/tool_content_screen.py +59 -0
  688. package/tui/screens/trace.py +317 -0
  689. package/tui/session_tracker.py +123 -0
  690. package/tui/settings.py +353 -0
  691. package/tui/settings_schema.py +467 -0
  692. package/tui/shell.py +304 -0
  693. package/tui/shell_read.py +42 -0
  694. package/tui/slash_command.py +34 -0
  695. package/tui/tui.tcss +246 -0
  696. package/tui/twitter.py +38 -0
  697. package/tui/version.py +80 -0
  698. package/tui/visuals/__init__.py +0 -0
  699. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  700. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  701. package/tui/visuals/columns.py +272 -0
  702. package/tui/widgets/__init__.py +0 -0
  703. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  704. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  705. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  706. package/tui/widgets/__pycache__/aidlc.cpython-314.pyc +0 -0
  707. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  708. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  709. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  710. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  711. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  712. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  713. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  714. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  715. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  716. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  717. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  718. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  719. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  720. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  721. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  722. package/tui/widgets/__pycache__/pending_prompts.cpython-314.pyc +0 -0
  723. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  724. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  725. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  726. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  727. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  728. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  729. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  730. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  731. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  732. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  733. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  734. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  735. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  736. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  737. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  738. package/tui/widgets/acp_content.py +70 -0
  739. package/tui/widgets/agent_response.py +76 -0
  740. package/tui/widgets/agent_thought.py +167 -0
  741. package/tui/widgets/aidlc.py +334 -0
  742. package/tui/widgets/ask_user.py +604 -0
  743. package/tui/widgets/command_pane.py +223 -0
  744. package/tui/widgets/condensed_path.py +115 -0
  745. package/tui/widgets/context_stats.py +66 -0
  746. package/tui/widgets/conversation.py +3235 -0
  747. package/tui/widgets/danger_warning.py +65 -0
  748. package/tui/widgets/diff_view.py +69 -0
  749. package/tui/widgets/directory_input.py +8 -0
  750. package/tui/widgets/edit_field.py +206 -0
  751. package/tui/widgets/flash.py +81 -0
  752. package/tui/widgets/future_text.py +126 -0
  753. package/tui/widgets/grid_select.py +234 -0
  754. package/tui/widgets/highlighted_textarea.py +183 -0
  755. package/tui/widgets/load_more.py +24 -0
  756. package/tui/widgets/mandelbrot.py +294 -0
  757. package/tui/widgets/markdown_note.py +13 -0
  758. package/tui/widgets/menu.py +147 -0
  759. package/tui/widgets/model_status.py +230 -0
  760. package/tui/widgets/modified_files.py +315 -0
  761. package/tui/widgets/non_selectable_label.py +5 -0
  762. package/tui/widgets/note.py +18 -0
  763. package/tui/widgets/path_search.py +492 -0
  764. package/tui/widgets/pending_prompts.py +36 -0
  765. package/tui/widgets/plan.py +285 -0
  766. package/tui/widgets/project_directory_tree.py +102 -0
  767. package/tui/widgets/project_grid_select.py +68 -0
  768. package/tui/widgets/project_summary.py +33 -0
  769. package/tui/widgets/prompt.py +935 -0
  770. package/tui/widgets/question.py +360 -0
  771. package/tui/widgets/session_grid_select.py +230 -0
  772. package/tui/widgets/session_summary.py +115 -0
  773. package/tui/widgets/session_tabs.py +194 -0
  774. package/tui/widgets/shell_result.py +35 -0
  775. package/tui/widgets/shell_terminal.py +40 -0
  776. package/tui/widgets/side_bar.py +124 -0
  777. package/tui/widgets/slash_complete.py +235 -0
  778. package/tui/widgets/strike_text.py +70 -0
  779. package/tui/widgets/subagent.py +287 -0
  780. package/tui/widgets/subagent_screen.py +715 -0
  781. package/tui/widgets/terminal.py +576 -0
  782. package/tui/widgets/terminal_tool.py +360 -0
  783. package/tui/widgets/throbber.py +100 -0
  784. package/tui/widgets/tool_call.py +432 -0
  785. package/tui/widgets/user_input.py +23 -0
  786. package/tui/widgets/version.py +5 -0
  787. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,2993 @@
1
+ #!/usr/bin/env python3
2
+ """CDH ACP Adapter - Allows CDH Agent to communicate via ACP protocol.
3
+
4
+ This adapter runs as a subprocess and translates JSONRPC calls from A2TUI
5
+ into CDH AgentEngine calls.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import re
13
+ import sys
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+
17
+ from cdh.project_loader import CdhProjectLoader
18
+
19
+ logger = logging.getLogger("onecode.agent.cdh_acp")
20
+
21
+
22
+ def _short_path(p: str, project_dir: Path | None = None) -> str:
23
+ """Return *p* relative to *project_dir* if it is an absolute path under it."""
24
+ if not p or not project_dir:
25
+ return p
26
+ try:
27
+ resolved = Path(p).resolve()
28
+ return str(resolved.relative_to(project_dir.resolve()))
29
+ except (ValueError, OSError):
30
+ return p
31
+
32
+
33
+ def debug_log(*args, **kwargs):
34
+ """Debug log via the ``onecode.agent.onecode_acp`` logger.
35
+
36
+ Falls back to stderr if the logging system has not been configured
37
+ yet (e.g. during very early adapter import when ``setup_logging`` has
38
+ not been called). This keeps the function safe to call from any
39
+ module-import-time path without breaking the JSON-RPC stream on stdout.
40
+ """
41
+ if logger.handlers or logging.getLogger().handlers:
42
+ logger.debug(" ".join(str(a) for a in args), **kwargs)
43
+ else:
44
+ print("[onecode]", *args, file=sys.stderr, flush=True)
45
+
46
+
47
+ def info_log(*args, **kwargs):
48
+ """INFO-level lifecycle log so critical events survive even default log_level=info.
49
+
50
+ ``debug_log`` is filtered out at INFO; previously all ACP/subagent
51
+ diagnostic markers were DEBUG, so users running with the default
52
+ ``log_level: info`` saw an empty log and could not diagnose the
53
+ subagent hang. Use ``info_log`` for critical lifecycle boundaries
54
+ (adapter init, cancel, subagent dispatch markers).
55
+ """
56
+ if logger.handlers or logging.getLogger().handlers:
57
+ logger.info(" ".join(str(a) for a in args), **kwargs)
58
+ else:
59
+ print("[onecode]", *args, file=sys.stderr, flush=True)
60
+
61
+
62
+ _CRASH_LOG_DIR = Path.home() / ".onecode" / "logs"
63
+
64
+
65
+ def _dump_crash(context: str) -> None:
66
+ """Write traceback to ``~/.onecode/logs/onecode_crash.log`` so it survives subprocess exit."""
67
+ import os
68
+ import traceback
69
+ now = __import__("datetime").datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
70
+ _CRASH_LOG_DIR.mkdir(parents=True, exist_ok=True)
71
+ path = _CRASH_LOG_DIR / "onecode_crash.log"
72
+ try:
73
+ lines = [f"=== crash at {now} context={context} ===\n"]
74
+ for tb_line in traceback.format_exc().splitlines(True):
75
+ lines.append(tb_line)
76
+ with open(os.fspath(path), "a", encoding="utf-8") as f:
77
+ f.writelines(lines)
78
+ except Exception:
79
+ pass
80
+
81
+
82
+ from onecode.agent.cdh_loader import CdhProjectLoader
83
+ from onecode.agent.engine import AgentEngine
84
+ from onecode.agent.permissions_store import PermissionStore
85
+ from onecode.agent.session import AgentSession
86
+ from onecode.config import load_config
87
+ from onecode.models.provider import ProviderRegistry
88
+ from onecode.models.registry import ModelRegistry
89
+ from onecode.models.messages import (
90
+ StreamEventType,
91
+ ToolCategory,
92
+ TextBlock,
93
+ ThinkBlock,
94
+ ToolCall as MsgToolCall,
95
+ ToolResult,
96
+ SubAgentBlock,
97
+ LifecycleStatus,
98
+ get_tool_category,
99
+ )
100
+ import onecode.models.providers # noqa: F401 trigger ProviderRegistry.register()
101
+
102
+
103
+
104
+ _CATEGORY_TO_ACP_KIND: dict[str, str] = {
105
+ ToolCategory.FILE_READ: "read",
106
+ ToolCategory.FILE_WRITE: "edit",
107
+ ToolCategory.FILE_EDIT: "edit",
108
+ ToolCategory.FILE_LIST: "read",
109
+ ToolCategory.FILE_GLOB: "search",
110
+ ToolCategory.FILE_GREP: "search",
111
+ ToolCategory.BASH: "execute",
112
+ ToolCategory.WEB_FETCH: "fetch",
113
+ ToolCategory.WEB_SEARCH: "search",
114
+ ToolCategory.TASK: "other",
115
+ ToolCategory.TODO_MGMT: "other",
116
+ ToolCategory.INTERACTION: "other",
117
+ ToolCategory.UNKNOWN: "other",
118
+ }
119
+
120
+
121
+ def _kind_for_category(cat: ToolCategory) -> str:
122
+ """Map internal ``ToolCategory`` enum to ACP wire-format ``kind`` string.
123
+
124
+ The TUI expects ``"read"``, ``"edit"``, ``"search"``, ``"execute"``,
125
+ ``"fetch"`` or ``"other"`` (see ``tui/acp/protocol.py:ToolCall``).
126
+ """
127
+ return _CATEGORY_TO_ACP_KIND.get(cat, "other")
128
+
129
+
130
+ _DEFAULT_MODES = {
131
+ "currentModeId": "build",
132
+ "availableModes": [
133
+ {"id": "build", "name": "Build", "description": "Full development agent for direct requests. Plans briefly for complex work, then executes. Edits and shell commands require user approval."},
134
+ {"id": "plan", "name": "Plan", "description": "Read-only planning and analysis. Edits and shell commands are denied."},
135
+ {"id": "solo", "name": "Solo", "description": "Execution agent for approved plans (handoff from plan mode). Edits and shell commands require user approval."},
136
+ ],
137
+ }
138
+
139
+ _OPEN_MARKER = "[TOOL_CALL]"
140
+ _CLOSE_MARKER = "[/TOOL_CALL]"
141
+
142
+ # Structured tool call markers (see engine.py MINIMAX_TOOL_CALL_RE).
143
+ # The adapter's stream callback strips these from the user-visible text so
144
+ # raw ``<minimax:tool_call>`` XML never reaches the chat.
145
+ _MINIMAX_OPEN = "<minimax:tool_call>"
146
+ _MINIMAX_CLOSE = "</minimax:tool_call>"
147
+
148
+
149
+ def _extract_text_from_blocks(content: list) -> str:
150
+ """Concatenate the `text` field of each `text` block in a content list."""
151
+ parts: list[str] = []
152
+ for item in content:
153
+ if isinstance(item, dict) and item.get("type") == "text":
154
+ parts.append(item.get("text", ""))
155
+ return "".join(parts)
156
+
157
+
158
+ def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
159
+ """Return position of the matching '}' for the '{' at open_pos.
160
+
161
+ Handles nested braces, single/double-quoted strings, and
162
+ single-line comments. Returns None if no matching brace is found.
163
+ """
164
+ if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
165
+ return None
166
+ depth = 0
167
+ i = open_pos
168
+ n = len(text)
169
+ while i < n:
170
+ c = text[i]
171
+ if c == "{":
172
+ depth += 1
173
+ i += 1
174
+ elif c == "}":
175
+ depth -= 1
176
+ if depth == 0:
177
+ return i
178
+ i += 1
179
+ elif c == '"':
180
+ i += 1
181
+ while i < n and text[i] != '"':
182
+ if text[i] == "\\" and i + 1 < n:
183
+ i += 2
184
+ else:
185
+ i += 1
186
+ i += 1
187
+ elif c == "'":
188
+ i += 1
189
+ while i < n and text[i] != "'":
190
+ if text[i] == "\\" and i + 1 < n:
191
+ i += 2
192
+ else:
193
+ i += 1
194
+ i += 1
195
+ elif c == "/" and i + 1 < n and text[i + 1] == "/":
196
+ while i < n and text[i] != "\n":
197
+ i += 1
198
+ else:
199
+ i += 1
200
+ return None
201
+
202
+
203
+ def _parse_tool_call_body(body: str) -> dict:
204
+ """Parse the body of a [TOOL_CALL]...[/TOOL_CALL] block.
205
+
206
+ Returns a dict with 'name' (str | None) and 'arguments' (dict).
207
+ """
208
+ body = body.strip()
209
+ if not body or not body.startswith("{"):
210
+ return {"name": None, "arguments": {}}
211
+
212
+ body_close = _scan_balanced_braces(body, 0)
213
+ if body_close is None:
214
+ return {"name": None, "arguments": {}}
215
+ inner = body[1:body_close]
216
+
217
+ name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
218
+ name = name_match.group(1) if name_match else None
219
+
220
+ arguments: dict = {}
221
+ args_match = re.search(r'args\s*=>\s*\{', inner)
222
+ if args_match:
223
+ args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
224
+ if args_outer is not None:
225
+ args_body = inner[args_match.end():args_outer]
226
+ for am in re.finditer(
227
+ r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
228
+ ):
229
+ arguments[am.group(1)] = am.group(2)
230
+
231
+ return {"name": name, "arguments": arguments}
232
+
233
+
234
+ def _extract_legacy_tool_call(text: str) -> dict | None:
235
+ """Parse a complete [TOOL_CALL]...[/TOOL_CALL] block from text.
236
+
237
+ Returns a dict with:
238
+ - name: tool name
239
+ - arguments: dict of argument name -> value
240
+ - span: (start, end) of the full [TOOL_CALL]...[/TOOL_CALL]
241
+ Returns None if no complete block is found.
242
+ """
243
+ open_idx = text.find(_OPEN_MARKER)
244
+ if open_idx < 0:
245
+ return None
246
+ body_start = open_idx + len(_OPEN_MARKER)
247
+ close_idx = text.find(_CLOSE_MARKER, body_start)
248
+ if close_idx < 0:
249
+ return None
250
+ body = text[body_start:close_idx]
251
+ span_end = close_idx + len(_CLOSE_MARKER)
252
+
253
+ parsed = _parse_tool_call_body(body)
254
+ parsed["span"] = (open_idx, span_end)
255
+ return parsed
256
+
257
+
258
+ _LANG_BY_EXT: dict[str, str] = {
259
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
260
+ ".jsx": "jsx", ".tsx": "tsx", ".json": "json", ".yml": "yaml",
261
+ ".yaml": "yaml", ".toml": "toml", ".md": "markdown", ".sh": "bash",
262
+ ".html": "html", ".css": "css", ".rs": "rust", ".go": "go",
263
+ ".java": "java", ".kt": "kotlin", ".swift": "swift", ".rb": "ruby",
264
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".sql": "sql",
265
+ }
266
+
267
+
268
+ def _language_for_path(path: str) -> str:
269
+ """Return a Markdown code-fence language hint based on file extension."""
270
+ if not path:
271
+ return ""
272
+ dot = path.rfind(".")
273
+ slash = max(path.rfind("/"), path.rfind("\\"))
274
+ if dot > slash and dot >= 0:
275
+ return _LANG_BY_EXT.get(path[dot:].lower(), "")
276
+ return ""
277
+
278
+
279
+ _STATUS_TO_WIRE: dict[str, str] = {
280
+ "pending": "pending",
281
+ "in_progress": "in_progress",
282
+ "running": "in_progress",
283
+ "complete": "completed",
284
+ "completed": "completed",
285
+ "failed": "failed",
286
+ "cancelled": "failed",
287
+ }
288
+
289
+
290
+ def _wire_status(value: str | None) -> str:
291
+ """Map an internal LifecycleStatus value to the ACP wire literal.
292
+
293
+ The ACP protocol (TUI side) expects ``ToolCallStatus = Literal["pending",
294
+ "in_progress", "completed", "failed"]``; the internal enum uses ``complete``
295
+ and ``cancelled``. This helper normalises everything to a wire-valid value
296
+ and defaults to ``"completed"`` for any unknown value.
297
+ """
298
+ if not value:
299
+ return "completed"
300
+ return _STATUS_TO_WIRE.get(value, "completed")
301
+
302
+
303
+ def _try_pretty_print_json(text: str) -> str:
304
+ """If *text* looks like JSON, pretty-print it; otherwise return as-is.
305
+
306
+ Handles both raw JSON and JSON that was embedded as a string inside
307
+ another JSON value (i.e. with ``\\n``, ``\\"`` etc. as literal escapes).
308
+ """
309
+ stripped = text.strip()
310
+ if not (stripped.startswith("{") or stripped.startswith("[")):
311
+ return text
312
+
313
+ # Direct parse
314
+ try:
315
+ obj = json.loads(stripped)
316
+ return json.dumps(obj, indent=2, ensure_ascii=False)
317
+ except (json.JSONDecodeError, ValueError):
318
+ pass
319
+
320
+ # The text may contain literal escape sequences (e.g. ``\n``, ``\"``)
321
+ # from being nested inside another JSON string. Unescape and retry.
322
+ try:
323
+ unescaped = stripped.encode("utf-8").decode("unicode_escape")
324
+ obj = json.loads(unescaped)
325
+ return json.dumps(obj, indent=2, ensure_ascii=False)
326
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
327
+ return text
328
+
329
+
330
+ def _build_tool_call_content(name: str | None, arguments: dict) -> list:
331
+ """Convert a tool's args into the ACP content blocks the TUI expects.
332
+
333
+ The TUI has dedicated widgets for `diff` (DiffView) and structured text
334
+ (Markdown / Static). JSON-fence fallbacks only kick in for tools we
335
+ don't recognise, so known tools render as their intended visual.
336
+
337
+ Write emits a code fence so the content reads as proper code
338
+ rather than a noisy all-additions diff view.
339
+ Edit keeps the diff block because there are real before/after changes.
340
+ """
341
+ if not arguments:
342
+ return []
343
+
344
+ if name == "Read":
345
+ return []
346
+
347
+ _HEADER_ONLY_TOOLS = frozenset({
348
+ "TodoCreate", "TodoGet", "TodoList", "TodoUpdate", "TodoOutput", "TodoStop", "TodoClear",
349
+ "Glob", "List", "Spawn",
350
+ })
351
+ if name in _HEADER_ONLY_TOOLS:
352
+ return []
353
+
354
+ if name == "Write":
355
+ path = str(arguments.get("path", ""))
356
+ content = str(arguments.get("content", ""))
357
+ if path:
358
+ lang = _language_for_path(path)
359
+ return [{
360
+ "type": "content",
361
+ "content": {
362
+ "type": "text",
363
+ "text": f"```{lang}\n{content}\n```",
364
+ },
365
+ }]
366
+
367
+ if name == "Edit":
368
+ path = str(arguments.get("path", ""))
369
+ old = str(arguments.get("old_string", arguments.get("oldText", "")))
370
+ new = str(arguments.get("new_string", arguments.get("newText", "")))
371
+ if path:
372
+ return [{
373
+ "type": "diff",
374
+ "path": path,
375
+ "oldText": old,
376
+ "newText": new,
377
+ }]
378
+
379
+ if name == "Bash":
380
+ cmd = str(arguments.get("command", arguments.get("cmd", "")))
381
+ if cmd:
382
+ return [{
383
+ "type": "content",
384
+ "content": {
385
+ "type": "text",
386
+ "text": f"```bash\n$ {cmd}\n```",
387
+ },
388
+ }]
389
+
390
+ if name == "Grep":
391
+ pattern = str(arguments.get("pattern", ""))
392
+ include = arguments.get("include")
393
+ if pattern:
394
+ text = f"🔍 Pattern: `{pattern}`"
395
+ if include:
396
+ text += f"\n📁 Filter: `{include}`"
397
+ return [{
398
+ "type": "content",
399
+ "content": {"type": "text", "text": text},
400
+ }]
401
+ if include:
402
+ text = f"📁 Filter: `{include}`"
403
+ return [{
404
+ "type": "content",
405
+ "content": {"type": "text", "text": text},
406
+ }]
407
+ try:
408
+ return [{
409
+ "type": "content",
410
+ "content": {"type": "text", "text": f"```json\n{json.dumps(arguments, indent=2)}\n```"},
411
+ }]
412
+ except (TypeError, ValueError):
413
+ return []
414
+
415
+ if name == "ToolSearch":
416
+ return []
417
+
418
+ if name == "ApplyPatch":
419
+ patch = str(arguments.get("patch", ""))
420
+ if patch:
421
+ return [{
422
+ "type": "content",
423
+ "content": {"type": "text", "text": f"```diff\n{patch}\n```"},
424
+ }]
425
+
426
+ if name == "Insert":
427
+ path = str(arguments.get("path", ""))
428
+ line = arguments.get("line", -1)
429
+ text = str(arguments.get("text", ""))
430
+ if path:
431
+ lang = _language_for_path(path)
432
+ return [{
433
+ "type": "content",
434
+ "content": {
435
+ "type": "text",
436
+ "text": f"📝 {path} (line {line})\n```{lang}\n{text}\n```",
437
+ },
438
+ }]
439
+
440
+ if name == "UndoEdit":
441
+ path = str(arguments.get("path", ""))
442
+ if path:
443
+ return [{
444
+ "type": "content",
445
+ "content": {"type": "text", "text": f"↩️ Undo edit on {path}"},
446
+ }]
447
+
448
+ if name == "AskUser":
449
+ q = str(arguments.get("question", ""))
450
+ opts = arguments.get("options", [])
451
+ lines = [f"❓ {q}"]
452
+ for o in opts:
453
+ label = o.get("label", "")
454
+ desc = o.get("description", "")
455
+ if desc:
456
+ lines.append(f" • {label} — {desc}")
457
+ else:
458
+ lines.append(f" • {label}")
459
+ return [{
460
+ "type": "content",
461
+ "content": {"type": "text", "text": "\n".join(lines)},
462
+ }] if lines else []
463
+
464
+ if name is None or (name is not None and name not in (
465
+ "Read", "Write", "Edit", "Bash", "Grep", "ToolSearch", "ApplyPatch",
466
+ "Insert", "UndoEdit", "AskUser",
467
+ ) and not name.startswith("Todo") and not name.startswith("MCPTool")):
468
+ try:
469
+ return [{
470
+ "type": "content",
471
+ "content": {"type": "text", "text": f"```json\n{json.dumps(arguments, indent=2)}\n```"},
472
+ }]
473
+ except (TypeError, ValueError):
474
+ return []
475
+
476
+ return []
477
+
478
+
479
+ def _format_tui_display_text(result_text: str, tool_name: str = "") -> str:
480
+ """Convert internal tool result JSON to user-visible text for TUI.
481
+
482
+ Tools return dicts like ``{"success": true, "path": "..."}`` that are
483
+ meaningful to the LLM but noisy for the user. This function strips
484
+ internal JSON wrappers and returns only what the user should see.
485
+
486
+ Success results that have a ``path`` field render as ``✓ /path``;
487
+ other success results fall through to a compact view of the rest
488
+ of the dict.
489
+
490
+ Todo tools (TodoCreate, TodoUpdate, TodoList, …) are special-
491
+ cased: their verbose ``{"task": {...}}`` / ``{"tasks": [...]}`` results
492
+ are collapsed to a single concise marker (``✓ updated``) since the
493
+ LLM's tool-call args already show what the agent did, and re-printing
494
+ the full todo object after every call clutters the conversation view.
495
+ """
496
+ if not result_text:
497
+ return ""
498
+ try:
499
+ parsed = json.loads(result_text)
500
+ except json.JSONDecodeError:
501
+ return result_text
502
+ if not isinstance(parsed, dict):
503
+ return result_text
504
+ if tool_name == "ToolSearch":
505
+ matches = parsed.get("matches", [])
506
+ if matches:
507
+ lines = [f"🔍 Found {len(matches)} tool(s):", ""]
508
+ for m in matches:
509
+ name = m.get("name", "")
510
+ desc = m.get("description", "")
511
+ if desc:
512
+ lines.append(f" • **{name}** — {desc}")
513
+ else:
514
+ lines.append(f" • **{name}**")
515
+ return "\n".join(lines)
516
+ return "No matching tools found."
517
+ if "error" in parsed:
518
+ return str(parsed["error"])
519
+ # Read: show actual file content (code fence + language wrapping in _emit_tool_result).
520
+ # FileOps.read returns plain text; only unwrap a structured {"content": ...} result,
521
+ # otherwise the raw text (which may itself be valid JSON, e.g. a .json file) must be
522
+ # shown verbatim — otherwise JSON-parsable files render as an empty card.
523
+ if tool_name == "Read":
524
+ if "content" in parsed:
525
+ return parsed.get("content", "")
526
+ return result_text
527
+ # Todo tools: collapse verbose state echoes to one-liner.
528
+ if tool_name in _STATUS_ONLY_TOOLS:
529
+ return "✓ updated"
530
+ if tool_name == "Write":
531
+ return ""
532
+ if parsed.get("success") is True:
533
+ if path := parsed.get("path"):
534
+ return f"✓ {path}"
535
+ visible = {k: v for k, v in parsed.items() if k != "success" and v not in ("", None)}
536
+ if not visible:
537
+ return "✓ done"
538
+ try:
539
+ return json.dumps(visible, ensure_ascii=False)
540
+ except (TypeError, ValueError):
541
+ return str(visible)
542
+ return result_text
543
+
544
+
545
+ _STATUS_ONLY_TOOLS = frozenset({
546
+ "TodoCreate",
547
+ "TodoGet",
548
+ "TodoList",
549
+ "TodoUpdate",
550
+ "TodoOutput",
551
+ "TodoStop",
552
+ "TodoClear",
553
+ })
554
+
555
+
556
+ _BARE_LEGACY_RE = re.compile(
557
+ r"\{[^{}]*tool\s*=>\s*\"(?P<name>\w+)\"[^{}]*args\s*=>\s*\{",
558
+ re.DOTALL,
559
+ )
560
+
561
+
562
+ def _find_bare_legacy_tool_call(text: str) -> int | None:
563
+ """Find the start of a bare `{tool => "X", args => { ... }}` body.
564
+
565
+ Unlike `[TOOL_CALL]...[/TOOL_CALL]`, this pattern has no enclosing
566
+ markers — we have to scan to the matching `}` ourselves.
567
+ """
568
+ m = _BARE_LEGACY_RE.search(text)
569
+ if m is None:
570
+ return None
571
+ # Reject matches that look like the inside of a fenced code block.
572
+ if "```" in text[: m.start()]:
573
+ last_fence = text.rfind("```", 0, m.start())
574
+ if last_fence >= 0 and text.count("```", last_fence, m.start()) == 1:
575
+ return None
576
+ return m.start()
577
+
578
+
579
+ def _filter_tool_call_text(text: str) -> str:
580
+ """Remove complete ``[TOOL_CALL]...[/TOOL_CALL]`` and
581
+ ``<minimax:tool_call>...</minimax:tool_call>`` blocks from text.
582
+
583
+ Incomplete blocks (no closing marker) are left in place so the caller
584
+ can hold the buffer and wait for more input.
585
+ """
586
+ out: list[str] = []
587
+ pos = 0
588
+ while pos < len(text):
589
+ parsed = _extract_legacy_tool_call(text[pos:])
590
+ minimax_start = text.find(_MINIMAX_OPEN, pos)
591
+ minimax_end = (
592
+ text.find(_MINIMAX_CLOSE, pos) if minimax_start >= 0 else -1
593
+ )
594
+ minimax_span = (
595
+ (minimax_start, minimax_end + len(_MINIMAX_CLOSE))
596
+ if minimax_start >= 0 and minimax_end >= 0
597
+ else None
598
+ )
599
+
600
+ candidates: list[tuple[int, tuple[int, int]]] = []
601
+ if parsed is not None:
602
+ candidates.append((parsed["span"][0], parsed["span"]))
603
+ if minimax_span is not None:
604
+ candidates.append((minimax_span[0], minimax_span))
605
+
606
+ if not candidates:
607
+ out.append(text[pos:])
608
+ break
609
+
610
+ candidates.sort(key=lambda c: c[0])
611
+ rel_start, span = candidates[0]
612
+ rel_end = span[1]
613
+ abs_start = pos + rel_start
614
+ abs_end = pos + rel_end
615
+ before = text[pos:abs_start]
616
+ if before:
617
+ out.append(before)
618
+ pos = abs_end
619
+ return "".join(out)
620
+
621
+
622
+ def _create_engine(cwd: str, perm_store: PermissionStore | None = None) -> AgentEngine:
623
+ cfg = load_config()
624
+ ModelRegistry.initialize()
625
+ provider_cls = ProviderRegistry.get(cfg.default_provider)
626
+ if provider_cls is None:
627
+ provider_cls = ProviderRegistry.get("minimaxi")
628
+
629
+ class MinimalApp:
630
+ config = cfg
631
+ current_provider = cfg.default_provider
632
+ current_model = cfg.default_model
633
+
634
+ project_dir = Path(cwd).resolve() if cwd else Path.cwd()
635
+ return AgentEngine(MinimalApp(), project_dir=project_dir, perm_store=perm_store)
636
+
637
+
638
+ class TextChunker:
639
+ """对文本流按语义边界分组后发送,减少 ACP 消息频率。
640
+
641
+ 三种模式:
642
+ - "words": 每 N 个分隔符(空格/换行/tab)切一次
643
+ - "line": 每遇到换行切一次
644
+ - "auto": 自动检测 ``` 代码块:代码块内用 line,外部用 words
645
+
646
+ Args:
647
+ send_fn: 收到完整文本块时的回调
648
+ mode: "words" | "line" | "auto"
649
+ words: words 模式下的分隔符计数阈值
650
+ line_max: line 模式下无换行时的强制截断长度
651
+ word_max: words 模式下无分隔符时的强制截断长度
652
+ """
653
+
654
+ def __init__(
655
+ self,
656
+ send_fn: Callable[[str], None],
657
+ mode: str = "auto",
658
+ words: int = 5,
659
+ line_max: int = 500,
660
+ word_max: int = 100,
661
+ ):
662
+ self._buf = ""
663
+ self._send = send_fn
664
+ self._mode = mode
665
+ self._words = words
666
+ self._line_max = line_max
667
+ self._word_max = word_max
668
+
669
+ def append(self, text: str) -> None:
670
+ if not text:
671
+ return
672
+ self._buf += text
673
+ self._process()
674
+
675
+ def flush(self) -> None:
676
+ if self._buf:
677
+ self._send(self._buf)
678
+ self._buf = ""
679
+
680
+ def _process(self) -> None:
681
+ while self._buf:
682
+ in_code = False
683
+ if self._mode == "auto":
684
+ in_code = (self._buf.count("```") % 2 == 1)
685
+
686
+ if in_code or self._mode == "line":
687
+ n = self._buf.find("\n")
688
+ if n >= 0:
689
+ n += 1
690
+ elif len(self._buf) >= self._line_max:
691
+ n = self._line_max
692
+ else:
693
+ break
694
+ else:
695
+ n = self._buf.find("\n")
696
+ if n >= 0:
697
+ n += 1
698
+ else:
699
+ sep_count = 0
700
+ for i, ch in enumerate(self._buf):
701
+ if ch in (' ', '\n', '\t'):
702
+ sep_count += 1
703
+ if sep_count >= self._words:
704
+ n = i + 1
705
+ break
706
+ if n < 0:
707
+ if len(self._buf) >= self._word_max:
708
+ n = self._word_max
709
+ else:
710
+ break
711
+
712
+ chunk = self._buf[:n]
713
+ self._buf = self._buf[n:].lstrip()
714
+ if chunk:
715
+ self._send(chunk)
716
+
717
+
718
+ class CDHACPAdapter:
719
+ """Adapter that translates ACP protocol to CDH AgentEngine."""
720
+
721
+ def __init__(self):
722
+ self.agent = None
723
+ self.session_id = None
724
+ self.tool_calls = {}
725
+ self.in_thinking = False
726
+ self._pending_requests: dict[str, asyncio.Future[dict]] = {}
727
+ self._request_seq = 0
728
+ self._perm_store = PermissionStore()
729
+ self._ask_user_future: asyncio.Future[dict] | None = None
730
+ # Diagnostics: per-subagent ACP forward counters (id → int)
731
+ self._subagent_fwd_count: dict[str, int] = {}
732
+ self._subagent_fwd_bytes: dict[str, int] = {}
733
+ # Turn counter for session/event notifications
734
+ self._turn_count: int = 0
735
+ # Accumulated plain-text output from the agent during the current turn,
736
+ # used to detect text-based questions and auto-convert to AskUser.
737
+ self._agent_text_output: str = ""
738
+ # Display state for tools invoked *inside* a subagent, keyed by a
739
+ # namespaced id ``"sa:{subagent_id}:{inner_tool_id}"`` (kept separate
740
+ # from ``self.tool_calls`` so subagent tool cards never collide with
741
+ # main-agent tool ids nor get persisted into the main session view).
742
+ self._subagent_tool_calls: dict[str, dict] = {}
743
+ # AIDLC phase tracking: detect when agent is working on a phase
744
+ self._last_aidlc_phase: str = ""
745
+ self._phase_skill_detected: str = ""
746
+
747
+ def _perm_store_load(self, cwd: str | None = None) -> None:
748
+ """Load permission overrides from ``.cdh/permissions.json``."""
749
+ project_dir = Path(cwd).resolve() if cwd else (self.agent._project_dir if self.agent else Path.cwd())
750
+ cdh_dir = CdhProjectLoader.find_cdh_dir(project_dir)
751
+ if cdh_dir:
752
+ data = CdhProjectLoader.load_permissions(cdh_dir)
753
+ if data:
754
+ self._perm_store = PermissionStore.from_dict(data)
755
+
756
+ def _perm_store_save(self) -> None:
757
+ """Save permission overrides to ``.cdh/permissions.json``."""
758
+ if not self.agent:
759
+ return
760
+ cdh_dir = CdhProjectLoader.find_cdh_dir(self.agent._project_dir)
761
+ if cdh_dir:
762
+ CdhProjectLoader.save_permissions(cdh_dir, self._perm_store.to_dict())
763
+
764
+ def _handle_reset_permission(self, key: str = "") -> dict:
765
+ """Handle /permission slash command.
766
+
767
+ Without argument: clears all overrides.
768
+ With argument (e.g. ``bash``): clears that specific override.
769
+ """
770
+ if key:
771
+ old = self._perm_store.get_override(key)
772
+ if old:
773
+ self._perm_store.clear_override(key)
774
+ setattr(self.agent.current_agent, f"permission_{key}", None)
775
+ msg = f"Permission override `{key}` ({old.value}) cleared."
776
+ else:
777
+ msg = f"No override found for `{key}`."
778
+ else:
779
+ count = len(self._perm_store._overrides)
780
+ self._perm_store.clear_all()
781
+ msg = f"All {count} permission override(s) cleared."
782
+ self._perm_store_save()
783
+ self.send_session_update({
784
+ "sessionUpdate": "agent_message_chunk",
785
+ "content": {"type": "text", "text": f"✅ {msg}"},
786
+ })
787
+ return {"stopReason": "end_turn"}
788
+
789
+ @staticmethod
790
+ def _content_to_blocks(content: str | list) -> list:
791
+ """Convert old-format message content to list of AgentBlock objects."""
792
+ blocks: list = []
793
+ if isinstance(content, str):
794
+ blocks = CDHACPAdapter._text_to_blocks(content)
795
+ elif isinstance(content, list):
796
+ for item in content:
797
+ if not isinstance(item, dict):
798
+ continue
799
+ item_type = item.get("type", "")
800
+ if item_type == "text":
801
+ blocks.append(TextBlock(content=item.get("text", "")))
802
+ elif item_type == "thinking":
803
+ blocks.append(ThinkBlock(content=item.get("thinking", "")))
804
+ elif item_type == "tool_use":
805
+ blocks.append(MsgToolCall(
806
+ id=item.get("id", ""),
807
+ name=item.get("name", ""),
808
+ arguments=item.get("input", {}),
809
+ status=LifecycleStatus(item.get("status", "complete")),
810
+ ))
811
+ elif item_type == "tool_result":
812
+ blocks.append(ToolResult(
813
+ tool_use_id=item.get("tool_use_id", ""),
814
+ content=item.get("content", ""),
815
+ is_error=item.get("is_error", False),
816
+ ))
817
+ elif item_type == "subagent":
818
+ blocks.append(SubAgentBlock(
819
+ id=item.get("id", ""),
820
+ agent_type=item.get("agent_type", "default"),
821
+ prompt=item.get("prompt", ""),
822
+ result=item.get("result", ""),
823
+ status=item.get("status", "complete"),
824
+ ))
825
+ return blocks
826
+
827
+ @staticmethod
828
+ def _text_to_blocks(text: str) -> list:
829
+ """Convert text containing legacy [TOOL_CALL] / <thinking> patterns to blocks.
830
+
831
+ Parses text that may contain:
832
+ - [TOOL_CALL] {tool => "Name", args => { ... }} [/TOOL_CALL] patterns
833
+ - <thinking>...</thinking> tags
834
+ Returns a list of TextBlock, ThinkBlock, and MsgToolCall blocks.
835
+ """
836
+ THINKING_RE = re.compile(r'<think(?:ing)?>(.*?)</think(?:ing)?>', re.DOTALL)
837
+
838
+ blocks: list = []
839
+ remaining = text
840
+ legacy_id = 0
841
+
842
+ while remaining:
843
+ parsed_tool = _extract_legacy_tool_call(remaining)
844
+ tool_start = parsed_tool["span"][0] if parsed_tool else -1
845
+ think_match = THINKING_RE.search(remaining)
846
+
847
+ candidates: list[tuple[int, str]] = []
848
+ if parsed_tool is not None:
849
+ candidates.append((tool_start, "tool"))
850
+ if think_match is not None:
851
+ candidates.append((think_match.start(), "think"))
852
+
853
+ if not candidates:
854
+ break
855
+
856
+ candidates.sort()
857
+ next_start, match_type = candidates[0]
858
+
859
+ before = remaining[:next_start]
860
+ if before.strip():
861
+ blocks.append(TextBlock(content=before))
862
+
863
+ if match_type == "tool":
864
+ assert parsed_tool is not None
865
+ tool_name = parsed_tool["name"]
866
+ span_end = parsed_tool["span"][1]
867
+ if tool_name:
868
+ blocks.append(MsgToolCall(
869
+ id=f"legacy-{legacy_id}",
870
+ name=tool_name,
871
+ arguments=parsed_tool["arguments"],
872
+ status=LifecycleStatus.COMPLETE,
873
+ ))
874
+ legacy_id += 1
875
+ remaining = remaining[span_end:]
876
+ else:
877
+ assert think_match is not None
878
+ blocks.append(ThinkBlock(content=think_match.group(1)))
879
+ remaining = remaining[think_match.end():]
880
+
881
+ if remaining.strip():
882
+ blocks.append(TextBlock(content=remaining))
883
+
884
+ return blocks
885
+
886
+ def send_notification(self, method: str, params: dict):
887
+ """Send a JSONRPC notification to A2TUI."""
888
+ try:
889
+ notification = {"jsonrpc": "2.0", "method": method, "params": params}
890
+ print(json.dumps(notification), flush=True)
891
+ except (TypeError, ValueError, OSError) as e:
892
+ _dump_crash("send_notification")
893
+ debug_log("send_notification failed: %s", e)
894
+
895
+ def send_request(self, method: str, params: dict) -> asyncio.Future[dict]:
896
+ """Send a JSONRPC request to A2TUI and return a Future for the response."""
897
+ self._request_seq += 1
898
+ req_id = f"svr_{self._request_seq}"
899
+ future: asyncio.Future[dict] = asyncio.Future()
900
+ self._pending_requests[req_id] = future
901
+ request = {"jsonrpc": "2.0", "method": method, "params": params, "id": req_id}
902
+ try:
903
+ print(json.dumps(request), flush=True)
904
+ except (TypeError, ValueError, OSError) as e:
905
+ _dump_crash("send_request")
906
+ debug_log("send_request failed: %s", e)
907
+ if not future.done():
908
+ future.set_exception(e)
909
+ return future
910
+
911
+ def resolve_pending_request(self, req_id: str, result: dict) -> bool:
912
+ """Resolve a pending request with the given result. Returns True if found."""
913
+ future = self._pending_requests.pop(req_id, None)
914
+ if future is not None and not future.done():
915
+ future.set_result(result)
916
+ return True
917
+ return False
918
+
919
+ def cancel_prompt(self):
920
+ """Synchronously cancel the current prompt (called from main loop)."""
921
+ if self.agent:
922
+ n_children = len(self.agent._child_engines)
923
+ child_ids = [hex(id(c)) for c in self.agent._child_engines]
924
+ info_log(
925
+ "[ACP-CANCEL] cancel_prompt called; marking agent._cancelled=True; "
926
+ "children=%d ids=%s already_cancelled=%s",
927
+ n_children, child_ids, self.agent._cancelled,
928
+ )
929
+ self.agent._cancelled = True
930
+ for child in self.agent._child_engines:
931
+ child._cancelled = True
932
+ info_log(
933
+ "[ACP-CANCEL] after mark: agent._cancelled=%s subagent_fwd=%s",
934
+ self.agent._cancelled,
935
+ dict(self._subagent_fwd_count),
936
+ )
937
+ else:
938
+ info_log("[ACP-CANCEL] cancel_prompt called but self.agent is None")
939
+
940
+ def resolve_ask_user(self, answer: str, cancelled: bool, rollback: bool = False) -> dict:
941
+ """Resolve the pending ask_user request with the user's answer.
942
+
943
+ Called from the main loop when a ``session/ask_user_answer`` request arrives.
944
+ """
945
+ if self._ask_user_future is not None and not self._ask_user_future.done():
946
+ self._ask_user_future.set_result({"answer": answer, "cancelled": cancelled, "rollback": rollback})
947
+ return {"ok": True}
948
+
949
+ def send_session_update(self, update: dict):
950
+ """Send a session/update notification with proper ACP protocol format."""
951
+ self.send_notification("session/update", {
952
+ "sessionId": self.session_id,
953
+ "update": update,
954
+ })
955
+
956
+ def send_awaiting_user_input(self, prompt_preview: str = "") -> None:
957
+ """Notify the TUI that the agent has yielded control and is waiting
958
+ for user input but did NOT invoke the AskUser tool.
959
+
960
+ The TUI switches turn to "client" so the next user_input_submitted
961
+ is routed as a reply to the question currently on screen rather
962
+ than being appended to the prompt queue as a brand-new task.
963
+
964
+ Note: this method is opt-in. Callers (engine, sub-agents, custom
965
+ workflows) decide when to invoke it. Streaming output is left
966
+ intact; only the turn state changes on the TUI side.
967
+ """
968
+ self.send_session_update({
969
+ "sessionUpdate": "awaiting_user_input",
970
+ "promptPreview": prompt_preview or "",
971
+ })
972
+
973
+ def send_session_event(self, event: dict, metrics: dict | None = None):
974
+ """Send a session/event notification (onecode enhancement).
975
+
976
+ Provides structured event data for L3/L4 loop analysis.
977
+ See docs/loop.md §9.2 for protocol spec.
978
+
979
+ Args:
980
+ event: Event payload dict with at minimum a ``type`` key.
981
+ metrics: Optional derived metrics dict for session-level stats.
982
+ """
983
+ payload: dict = {"event": event}
984
+ if metrics is not None:
985
+ payload["metrics"] = metrics
986
+ self.send_notification("session/event", {
987
+ "sessionId": self.session_id,
988
+ **payload,
989
+ })
990
+
991
+ def _subagent_tool_ns_id(self, subagent_id: str, inner_tool_id: str) -> str:
992
+ """Namespaced toolCallId so subagent tool cards never collide with
993
+ main-agent tool ids in the TUI or in ``self.tool_calls``."""
994
+ return f"sa:{subagent_id}:{inner_tool_id}"
995
+
996
+ def _handle_subagent_tool_call(self, event) -> None:
997
+ """Render a tool invoked *inside* a subagent as a real tool_call /
998
+ tool_call_update sessionUpdate tagged with ``subagentId`` so the TUI
999
+ mounts a ToolCall card inside the owning SubAgent widget (same style
1000
+ as main-conversation tools) instead of a ``[Tool: X]`` text line."""
1001
+ sa_id = event.subagent_id
1002
+ ns_id = self._subagent_tool_ns_id(sa_id, event.tool_id)
1003
+ tname = event.tool_name or "Tool"
1004
+ if tname == "Spawn":
1005
+ # Nested spawn is disallowed (depth limit); skip rendering.
1006
+ return
1007
+ tool_kind = _kind_for_category(event.tool_category)
1008
+
1009
+ if event.subagent_tool_phase != "complete":
1010
+ # start: in_progress card with the tool name as title.
1011
+ entry = self._subagent_tool_calls.get(ns_id)
1012
+ if entry is None:
1013
+ entry = {
1014
+ "sessionUpdate": "tool_call",
1015
+ "toolCallId": ns_id,
1016
+ "subagentId": sa_id,
1017
+ "title": tname,
1018
+ "kind": tool_kind,
1019
+ "status": "in_progress",
1020
+ "content": [],
1021
+ "_tool_name": tname,
1022
+ "_tool_args": {},
1023
+ }
1024
+ self._subagent_tool_calls[ns_id] = entry
1025
+ else:
1026
+ entry["subagentId"] = sa_id
1027
+ entry["kind"] = tool_kind
1028
+ entry["status"] = "in_progress"
1029
+ entry["_tool_name"] = tname
1030
+ self.send_session_update(entry)
1031
+ return
1032
+
1033
+ # complete: enrich title from args + attach args content.
1034
+ args = event.tool_args or {}
1035
+ title = tname
1036
+ if args:
1037
+ if args.get("path", ""):
1038
+ title = f"{tname}: {_short_path(args.get('path', ''), self.agent._project_dir)}"
1039
+ elif tname == "Bash":
1040
+ cmd = str(args.get("command", ""))
1041
+ if cmd:
1042
+ title = f"Bash: {cmd}"
1043
+ elif tname in ("TodoCreate", "TodoUpdate"):
1044
+ subject = str(args.get("subject", ""))
1045
+ if subject:
1046
+ title = f"{tname}: {subject}"
1047
+ elif tname in ("TodoGet", "TodoStop", "TodoOutput"):
1048
+ tid = str(args.get("taskId", args.get("task_id", "")))
1049
+ if tid:
1050
+ title = f"{tname}: {tid}"
1051
+ elif tname == "Glob":
1052
+ pattern = str(args.get("pattern", ""))
1053
+ if pattern:
1054
+ title = f"Glob: {pattern}"
1055
+ elif tname == "ToolSearch":
1056
+ query = str(args.get("query", ""))
1057
+ if query:
1058
+ title = f"ToolSearch: {query}"
1059
+ else:
1060
+ for key in ("query", "command", "pattern", "name", "subject"):
1061
+ val = str(args.get(key, ""))
1062
+ if val:
1063
+ title = f"{tname}: {val}"
1064
+ break
1065
+ content = _build_tool_call_content(tname, args)
1066
+ entry = self._subagent_tool_calls.get(ns_id)
1067
+ if entry is None:
1068
+ entry = {
1069
+ "sessionUpdate": "tool_call",
1070
+ "toolCallId": ns_id,
1071
+ "subagentId": sa_id,
1072
+ "title": tname,
1073
+ "kind": tool_kind,
1074
+ "status": "in_progress",
1075
+ "content": [],
1076
+ "_tool_name": tname,
1077
+ "_tool_args": {},
1078
+ }
1079
+ self._subagent_tool_calls[ns_id] = entry
1080
+ entry.update({
1081
+ "sessionUpdate": "tool_call_update",
1082
+ "toolCallId": ns_id,
1083
+ "subagentId": sa_id,
1084
+ "title": title,
1085
+ "status": "in_progress",
1086
+ "content": content,
1087
+ "_tool_name": tname,
1088
+ "_tool_args": args,
1089
+ })
1090
+ self.send_session_update(entry)
1091
+
1092
+ def _handle_subagent_tool_result(self, event) -> None:
1093
+ """Attach a subagent tool's result to its card (completed/failed)."""
1094
+ sa_id = event.subagent_id
1095
+ ns_id = self._subagent_tool_ns_id(sa_id, event.tool_id)
1096
+ entry = self._subagent_tool_calls.get(ns_id)
1097
+ tname = (entry or {}).get("_tool_name", "") or event.tool_name or "Tool"
1098
+ targs = (entry or {}).get("_tool_args", {}) or {}
1099
+ if tname == "Spawn":
1100
+ return
1101
+ status = "failed" if event.result_is_error else "completed"
1102
+ display_text = _format_tui_display_text(event.result_content or "", tname)
1103
+ # Fence multi-line results so the TUI renders a code block (mirrors
1104
+ # the main TOOL_RESULT handler).
1105
+ if display_text and "\n" in display_text and "```" not in display_text:
1106
+ lang = ""
1107
+ if tname in ("Bash", "Glob"):
1108
+ lang = "bash"
1109
+ elif tname == "Read" and isinstance(targs, dict):
1110
+ path = str(targs.get("path", ""))
1111
+ if path:
1112
+ lang = _language_for_path(path)
1113
+ display_text = f"```{lang}\n{display_text}\n```" if lang else f"```\n{display_text}\n```"
1114
+ content_block = [{
1115
+ "type": "content",
1116
+ "content": {"type": "text", "text": display_text},
1117
+ }] if display_text else []
1118
+ existing_content = (entry or {}).get("content", [])
1119
+ if not isinstance(existing_content, list):
1120
+ existing_content = []
1121
+ if entry is None:
1122
+ entry = {
1123
+ "sessionUpdate": "tool_call_update",
1124
+ "toolCallId": ns_id,
1125
+ "subagentId": sa_id,
1126
+ "title": tname,
1127
+ "kind": _kind_for_category(get_tool_category(tname)),
1128
+ "status": status,
1129
+ "content": existing_content + content_block,
1130
+ "_tool_name": tname,
1131
+ "_tool_args": targs,
1132
+ }
1133
+ self._subagent_tool_calls[ns_id] = entry
1134
+ else:
1135
+ entry["subagentId"] = sa_id
1136
+ entry["status"] = status
1137
+ entry["content"] = existing_content + content_block
1138
+ entry["sessionUpdate"] = "tool_call_update"
1139
+ self.send_session_update(entry)
1140
+
1141
+ def _emit_tool_result(self, block: ToolResult) -> None:
1142
+ """Send a tool_result as a tool_call_update, accumulating with existing content."""
1143
+ # Spawn results are rendered as SubAgent widgets via
1144
+ # _replay_tool_or_subagent → subagent_start/chunk/end events.
1145
+ if self.tool_calls.get(block.tool_use_id, {}).get("_tool_name") == "Spawn":
1146
+ return
1147
+ # Look up tool name from the tracked tool_calls entry
1148
+ tool_name = self.tool_calls.get(block.tool_use_id, {}).get("title", "")
1149
+ # Title may be like "Bash: ls -la" — keep just the leading tool name
1150
+ if tool_name and ":" in tool_name:
1151
+ tool_name = tool_name.split(":", 1)[0].strip()
1152
+ display_text = _format_tui_display_text(block.content, tool_name)
1153
+
1154
+ # For edit/write tools the diff/code block + header ✔ status is
1155
+ # sufficient — skip the redundant "✓ /path" result text.
1156
+ if display_text and self.tool_calls.get(block.tool_use_id, {}).get("kind") == "edit":
1157
+ display_text = ""
1158
+
1159
+ # Update title with path/subject from result if not already set
1160
+ if block.content and block.tool_use_id in self.tool_calls:
1161
+ try:
1162
+ parsed = json.loads(block.content)
1163
+ except json.JSONDecodeError:
1164
+ pass
1165
+ else:
1166
+ if isinstance(parsed, dict) and parsed.get("success") is True:
1167
+ current_title = self.tool_calls[block.tool_use_id].get("title", "")
1168
+ if ":" not in current_title:
1169
+ if tool_name in ("TodoCreate", "TodoUpdate"):
1170
+ task_info = parsed.get("task", {})
1171
+ if isinstance(task_info, dict):
1172
+ if subject := task_info.get("subject", ""):
1173
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {subject}"
1174
+ elif path := parsed.get("path"):
1175
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
1176
+ elif path := parsed.get("path"):
1177
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
1178
+
1179
+ # Wrap multi-line results in a fenced code block for proper rendering.
1180
+ # Use bash-style fence for Bash/Glob, detect language for Read.
1181
+ if display_text and "\n" in display_text and "```" not in display_text:
1182
+ lang = ""
1183
+ tool_info = self.tool_calls.get(block.tool_use_id, {})
1184
+ tname = tool_info.get("_tool_name", "")
1185
+ targs = tool_info.get("_tool_args", {})
1186
+ if tname in ("Bash", "Glob"):
1187
+ lang = "bash"
1188
+ elif tname == "Read" and isinstance(targs, dict):
1189
+ path = str(targs.get("path", ""))
1190
+ if path:
1191
+ lang = _language_for_path(path)
1192
+ if lang:
1193
+ display_text = f"```{lang}\n{display_text}\n```"
1194
+ else:
1195
+ display_text = f"```\n{display_text}\n```"
1196
+
1197
+ content_block = [{
1198
+ "type": "content",
1199
+ "content": {"type": "text", "text": display_text},
1200
+ }] if display_text else []
1201
+ existing = self.tool_calls.get(block.tool_use_id, {}).get("content", [])
1202
+ update = {
1203
+ "sessionUpdate": "tool_call_update",
1204
+ "toolCallId": block.tool_use_id,
1205
+ "status": "failed" if block.is_error else "completed",
1206
+ "content": existing + content_block,
1207
+ }
1208
+ if block.tool_use_id in self.tool_calls:
1209
+ self.tool_calls[block.tool_use_id].update(update)
1210
+ else:
1211
+ self.tool_calls[block.tool_use_id] = {
1212
+ "sessionUpdate": "tool_call",
1213
+ "toolCallId": block.tool_use_id,
1214
+ "title": "Tool call",
1215
+ "kind": "other",
1216
+ **update,
1217
+ }
1218
+ self.send_session_update(self.tool_calls[block.tool_use_id])
1219
+
1220
+ def _replay_tool_or_subagent(self, tool_use_id: str, content: str, is_error: bool) -> None:
1221
+ """Replay a tool result — subagent events for Task, tool_call_update otherwise."""
1222
+ tc = self.tool_calls.get(tool_use_id)
1223
+ if tc and tc.get("_tool_name") == "Spawn":
1224
+ args = tc.get("_tool_args", {})
1225
+ agent_type = args.get("agent_type", "general")
1226
+ prompt = args.get("prompt", "")
1227
+ self.send_session_update({
1228
+ "sessionUpdate": "subagent_start",
1229
+ "subagentId": tool_use_id,
1230
+ "agentType": agent_type,
1231
+ "prompt": prompt,
1232
+ })
1233
+ if content:
1234
+ self.send_session_update({
1235
+ "sessionUpdate": "subagent_chunk",
1236
+ "subagentId": tool_use_id,
1237
+ "text": content,
1238
+ })
1239
+ self.send_session_update({
1240
+ "sessionUpdate": "subagent_end",
1241
+ "subagentId": tool_use_id,
1242
+ "agentType": agent_type,
1243
+ })
1244
+ else:
1245
+ tr = ToolResult(
1246
+ tool_use_id=tool_use_id,
1247
+ content=content,
1248
+ is_error=is_error,
1249
+ )
1250
+ self._emit_tool_result(tr)
1251
+
1252
+ def _emit_plan_update_to_tui(self) -> None:
1253
+ """Send a `plan` session update built from current todo state.
1254
+
1255
+ Used right after ``session_load`` so the TUI Plan widget shows the
1256
+ resumed todos immediately, without waiting for the user to
1257
+ type a new prompt. Delegates dedupe to the engine's
1258
+ ``_emit_plan_update`` so the session-resume snapshot and the
1259
+ per-turn snapshot share a single cache.
1260
+ """
1261
+ tm = getattr(self.agent, "_todo_manager", None)
1262
+ if tm is None:
1263
+ return
1264
+ if not tm.list_todos():
1265
+ return
1266
+ for event in self.agent._emit_plan_update():
1267
+ self.send_session_update({
1268
+ "sessionUpdate": "plan",
1269
+ "entries": event.plan_entries,
1270
+ })
1271
+
1272
+ def _emit_aidlc_state_to_tui(self) -> None:
1273
+ if self.agent is None:
1274
+ return
1275
+ cdh_dir = CdhProjectLoader.find_cdh_dir(self.agent._project_dir)
1276
+ if cdh_dir is None:
1277
+ return
1278
+ state = CdhProjectLoader.load_project_state(cdh_dir)
1279
+
1280
+ # Auto-advance phase if a phase skill was detected during this turn
1281
+ phase_to_advance = ""
1282
+ if self._phase_skill_detected:
1283
+ phase_to_advance = self._phase_skill_detected
1284
+ else:
1285
+ # Check if any subagent was spawned for a phase
1286
+ if self._subagent_fwd_count:
1287
+ for subagent_type in self._subagent_fwd_count.keys():
1288
+ if subagent_type and subagent_type.startswith("ai-dlc-"):
1289
+ phase = subagent_type.replace("ai-dlc-", "")
1290
+ if phase in CdhProjectLoader._PHASE_SEQUENCE:
1291
+ phase_to_advance = phase
1292
+ break
1293
+ # Fallback: check agent output for phase keywords
1294
+ if not phase_to_advance and self._agent_text_output:
1295
+ output_lower = self._agent_text_output.lower()
1296
+ for phase in CdhProjectLoader._PHASE_SEQUENCE:
1297
+ if phase != "init" and phase in output_lower:
1298
+ phase_to_advance = phase
1299
+ break
1300
+
1301
+ if phase_to_advance:
1302
+ current_phase = state.get("current_phase", "")
1303
+ if phase_to_advance != current_phase:
1304
+ CdhProjectLoader.advance_phase(self.agent._project_dir, phase_to_advance)
1305
+ state = CdhProjectLoader.load_project_state(cdh_dir)
1306
+ self._phase_skill_detected = ""
1307
+
1308
+ self.send_session_update({
1309
+ "sessionUpdate": "aidlc_state",
1310
+ "current_phase": state.get("current_phase", ""),
1311
+ "completed_phases": state.get("completed_phases", []),
1312
+ "gate_results": state.get("gate_results", {}),
1313
+ })
1314
+
1315
+ async def initialize(self, protocol_version: int, client_capabilities: dict, client_info: dict):
1316
+ """Handle ACP initialize."""
1317
+ return {
1318
+ "protocolVersion": 1,
1319
+ "agentCapabilities": {
1320
+ "loadSession": True,
1321
+ "promptCapabilities": {
1322
+ "audio": False,
1323
+ "embeddedContent": True,
1324
+ "image": True,
1325
+ },
1326
+ },
1327
+ "authMethods": [],
1328
+ "serverInfo": {
1329
+ "name": "onecode-agent",
1330
+ "title": "OneCode Agent",
1331
+ "version": "1.0.0",
1332
+ },
1333
+ }
1334
+
1335
+ def _send_available_commands(self) -> None:
1336
+ """Send slash commands available in the current agent mode."""
1337
+ self.send_session_update({
1338
+ "sessionUpdate": "available_commands_update",
1339
+ "availableCommands": [
1340
+ {"name": "exit", "description": "Exit A2TUI", "input": None},
1341
+ {"name": "permission", "description": "Clear permission overrides (use `/permission edit` for specific key)", "input": None},
1342
+ ],
1343
+ })
1344
+
1345
+ async def session_new(self, cwd: str, mcp_servers: list):
1346
+ """Create new session."""
1347
+ if self.agent is not None:
1348
+ await self.agent.shutdown()
1349
+ cfg = load_config()
1350
+ self.agent = _create_engine(cwd, perm_store=self._perm_store)
1351
+ self.agent.set_agent(cfg.default_mode)
1352
+ self._perm_store_load()
1353
+ self._perm_store.apply_to(self.agent.current_agent)
1354
+
1355
+ session = AgentSession()
1356
+ session.name = "New Session"
1357
+ session.mode = cfg.default_mode
1358
+ session.project = str(Path(cwd).resolve() if cwd else Path.cwd())
1359
+ session.model = cfg.default_model
1360
+ session.provider = cfg.default_provider
1361
+ session.save()
1362
+ self.agent.attach_session(session)
1363
+ self.session_id = session.id
1364
+
1365
+ # New sessions start with a blank plan — inject a hint so the user
1366
+ # knows previous session todos are not restored.
1367
+ self.agent.context.add_system(
1368
+ "<!-- NEW_SESSION_HINT -->\n"
1369
+ "This is a new session with a blank plan. Previous session todos "
1370
+ "are not automatically restored. Use `/resume <session_id>` to "
1371
+ "recover a previous session's plan if needed.\n"
1372
+ )
1373
+ self._send_available_commands()
1374
+
1375
+ return {
1376
+ "sessionId": self.session_id,
1377
+ "modes": {
1378
+ "currentModeId": cfg.default_mode,
1379
+ "availableModes": _DEFAULT_MODES["availableModes"],
1380
+ },
1381
+ }
1382
+
1383
+ async def session_load(self, cwd: str, mcp_servers: list, session_id: str):
1384
+ """Load existing session."""
1385
+ if self.agent is not None:
1386
+ await self.agent.shutdown()
1387
+ self._perm_store_load(cwd)
1388
+ self.agent = _create_engine(cwd, perm_store=self._perm_store)
1389
+ self.session_id = session_id
1390
+
1391
+ loaded = self.agent.load_session(session_id)
1392
+ cfg = load_config()
1393
+ session_mode = self.agent._session.mode if self.agent._session else cfg.default_mode
1394
+ self.agent.set_agent(session_mode)
1395
+ self._perm_store.apply_to(self.agent.current_agent)
1396
+ self._send_available_commands()
1397
+
1398
+ # Also restore tasks from project .cdh/ as fallback / supplement
1399
+ self.agent.load_todos_from_project()
1400
+
1401
+ # Surface loaded tasks to the TUI Plan widget so the user sees
1402
+ # pending work without having to send a new message first.
1403
+ self._emit_plan_update_to_tui()
1404
+ self._emit_aidlc_state_to_tui()
1405
+
1406
+ if not loaded:
1407
+ storage_path = getattr(self.agent, "_session", None) and self.agent._session.storage_path
1408
+ return {
1409
+ "modes": _DEFAULT_MODES,
1410
+ "error": f"Session {session_id} not found at {storage_path}",
1411
+ }
1412
+
1413
+ # Walk the in-memory context (preserves list-of-blocks structure)
1414
+ # instead of the raw _session.messages dicts, which only carry
1415
+ # `content: str`. Engine.chat_stream() stores tool_use / tool_result
1416
+ # blocks in context.Message.content, and we need them at replay time.
1417
+ # Only replay the most recent MAX_VISIBLE messages; older ones are
1418
+ # fetched on demand via session/load_earlier.
1419
+ MAX_VISIBLE = 50
1420
+ total_messages = len(self.agent.context.messages)
1421
+ visible_start = max(0, total_messages - MAX_VISIBLE)
1422
+ self._replay_messages(self.agent.context.messages[visible_start:])
1423
+
1424
+ return {
1425
+ "modes": {
1426
+ "currentModeId": cfg.default_mode,
1427
+ "availableModes": _DEFAULT_MODES["availableModes"],
1428
+ },
1429
+ "_total_messages": total_messages,
1430
+ "_visible_start": visible_start,
1431
+ }
1432
+
1433
+ async def session_load_earlier(self, session_id: str, offset: int, limit: int) -> dict:
1434
+ """Replay a range of earlier messages (used by 'load earlier messages')."""
1435
+ if self.agent is None or self.agent.context is None:
1436
+ return {"count": 0, "remaining": 0}
1437
+ messages = self.agent.context.messages
1438
+ end = min(offset + limit, len(messages))
1439
+ segment = messages[offset:end]
1440
+ self._replay_messages(segment)
1441
+ return {"count": end - offset, "remaining": offset}
1442
+
1443
+ def _replay_messages(self, messages: list) -> None:
1444
+ """Replay a list of context messages as session/update notifications."""
1445
+ for ctx_msg in messages:
1446
+ role = ctx_msg.role
1447
+ content = ctx_msg.content
1448
+ if role == "user":
1449
+ user_text = content if isinstance(content, str) else _extract_text_from_blocks(content)
1450
+ self.send_session_update({
1451
+ "sessionUpdate": "user_message_chunk",
1452
+ "content": {"type": "text", "text": user_text},
1453
+ })
1454
+ elif role == "assistant":
1455
+ if isinstance(content, str):
1456
+ blocks = self._content_to_blocks(content)
1457
+ else:
1458
+ blocks = self._content_to_blocks(content)
1459
+
1460
+ for block in blocks:
1461
+ if isinstance(block, TextBlock):
1462
+ filtered_text = _filter_tool_call_text(block.content)
1463
+ if filtered_text.strip():
1464
+ self.send_session_update({
1465
+ "sessionUpdate": "agent_message_chunk",
1466
+ "content": {"type": "text", "text": filtered_text},
1467
+ })
1468
+ elif isinstance(block, ThinkBlock):
1469
+ filtered_thought = _filter_tool_call_text(block.content)
1470
+ if filtered_thought.strip():
1471
+ self.send_session_update({
1472
+ "sessionUpdate": "agent_thought_chunk",
1473
+ "content": {"type": "text", "text": filtered_thought},
1474
+ })
1475
+ elif isinstance(block, MsgToolCall):
1476
+ tool_kind = _kind_for_category(get_tool_category(block.name))
1477
+ content_blocks = _build_tool_call_content(
1478
+ block.name, block.arguments or {}
1479
+ )
1480
+ title = block.name
1481
+ args = block.arguments or {}
1482
+ if path := args.get("path", ""):
1483
+ title = f"{block.name}: {_short_path(path, self.agent._project_dir)}"
1484
+ elif block.name == "Bash":
1485
+ cmd = str(args.get("command", ""))
1486
+ title = f"Bash: {cmd}"
1487
+ elif block.name == "TodoCreate":
1488
+ subject = str(args.get("subject", ""))
1489
+ if subject:
1490
+ title = f"TodoCreate: {subject}"
1491
+ elif block.name == "TodoUpdate":
1492
+ subject = str(args.get("subject", ""))
1493
+ if subject:
1494
+ title = f"TodoUpdate: {subject}"
1495
+ elif block.name in ("TodoGet", "TodoStop", "TodoOutput"):
1496
+ tid = str(args.get("taskId", args.get("task_id", "")))
1497
+ if tid:
1498
+ title = f"{block.name}: {tid}"
1499
+ elif block.name == "Glob":
1500
+ pattern = str(args.get("pattern", ""))
1501
+ if pattern:
1502
+ title = f"Glob: {pattern}"
1503
+ self.tool_calls[block.id] = {
1504
+ "sessionUpdate": "tool_call",
1505
+ "toolCallId": block.id,
1506
+ "title": title,
1507
+ "kind": tool_kind,
1508
+ "status": _wire_status(block.status.value),
1509
+ "content": content_blocks,
1510
+ "_tool_name": block.name,
1511
+ "_tool_args": block.arguments or {},
1512
+ }
1513
+ # Skip sending tool_call for Task tools — SubAgent widget
1514
+ # is rendered via subagent_start/subagent_chunk/subagent_end
1515
+ # when the corresponding tool_result is processed below.
1516
+ if block.name != "Spawn":
1517
+ self.send_session_update(self.tool_calls[block.id])
1518
+ elif isinstance(block, ToolResult):
1519
+ self._emit_tool_result(block)
1520
+ elif isinstance(block, SubAgentBlock):
1521
+ self.send_session_update({
1522
+ "sessionUpdate": "subagent_start",
1523
+ "subagentId": block.id,
1524
+ "agentType": block.agent_type,
1525
+ "prompt": block.prompt,
1526
+ })
1527
+ if block.result:
1528
+ self.send_session_update({
1529
+ "sessionUpdate": "subagent_chunk",
1530
+ "subagentId": block.id,
1531
+ "text": block.result,
1532
+ })
1533
+ self.send_session_update({
1534
+ "sessionUpdate": "subagent_end",
1535
+ "subagentId": block.id,
1536
+ "agentType": block.agent_type,
1537
+ })
1538
+ elif role == "tool":
1539
+ if isinstance(content, list):
1540
+ for item in content:
1541
+ if isinstance(item, dict) and item.get("type") == "tool_result":
1542
+ self._replay_tool_or_subagent(
1543
+ item.get("tool_use_id", ""),
1544
+ item.get("content", ""),
1545
+ item.get("is_error", False),
1546
+ )
1547
+ elif isinstance(content, dict) and content.get("type") == "tool_result":
1548
+ self._replay_tool_or_subagent(
1549
+ content.get("tool_use_id", ""),
1550
+ content.get("content", ""),
1551
+ content.get("is_error", False),
1552
+ )
1553
+
1554
+ def _make_stream_callback(self):
1555
+ """Create a thinking-aware streaming callback for real-time token output.
1556
+
1557
+ Buffers text deltas from the provider, detects <thinking>...</thinking>,
1558
+ [TOOL_CALL]...[/TOOL_CALL], <minimax:tool_call>...</minimax:tool_call>,
1559
+ and bare `{tool => "X", args => { ... }}` boundaries (all cross-chunk
1560
+ tolerant), strips them from the text buffer so they don't appear as
1561
+ raw agent messages, and routes remaining content to the appropriate
1562
+ ACP session update type.
1563
+
1564
+ Tool call notifications are sent by TOOL_CALL_START/TOOL_CALL_COMPLETE
1565
+ events from the engine, not from text parsing here.
1566
+ """
1567
+ # Safety cap on the held buffer: if a model emits an unclosed
1568
+ # ``<thinking>`` / ``[TOOL_CALL]`` / ``<minimax:tool_call>`` tag
1569
+ # and then the stream is truncated (network drop, cancel,
1570
+ # provider error before the close marker arrives), the buffer
1571
+ # would otherwise grow forever and every subsequent chunk
1572
+ # would be silently swallowed. When the buffer exceeds this
1573
+ # cap we give up on detecting markers and flush the held text
1574
+ # as a plain message so the user still sees *something*.
1575
+ _MAX_HELD_BYTES = 64 * 1024
1576
+
1577
+ chunker = getattr(self, "_text_chunker", None)
1578
+ _direct_send = self.send_session_update # fallback when chunker not configured
1579
+ text_buffer = ""
1580
+ in_thinking = False
1581
+ in_tool_call = False
1582
+ in_minimax_tool_call = False
1583
+ in_bare_tool_call = False
1584
+ bare_tool_start = 0
1585
+ thinking_sent_len = 0 # how much of text_buffer has been sent to TUI
1586
+
1587
+ def _flush_held_buffer() -> None:
1588
+ """Force-emit the held buffer as a plain message and reset
1589
+ all "in marker" flags. Used by the watchdog below and by
1590
+ the early-exit path when a turn ends without a close
1591
+ marker.
1592
+ """
1593
+ nonlocal text_buffer, thinking_sent_len
1594
+ nonlocal in_thinking, in_tool_call
1595
+ nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
1596
+ if chunker:
1597
+ chunker.flush()
1598
+ if text_buffer.strip():
1599
+ _direct_send({
1600
+ "sessionUpdate": "agent_message_chunk",
1601
+ "content": {"type": "text", "text": text_buffer},
1602
+ })
1603
+ text_buffer = ""
1604
+ thinking_sent_len = 0
1605
+ in_thinking = in_tool_call = False
1606
+ in_minimax_tool_call = in_bare_tool_call = False
1607
+ bare_tool_start = 0
1608
+
1609
+ def _emit_message(text: str) -> None:
1610
+ if not text:
1611
+ return
1612
+ if chunker:
1613
+ chunker.append(text)
1614
+ else:
1615
+ _direct_send({
1616
+ "sessionUpdate": "agent_message_chunk",
1617
+ "content": {"type": "text", "text": text},
1618
+ })
1619
+
1620
+ def _safe_start(s: str) -> str:
1621
+ """Return text before any partial <thinking / <think or [TOOL_CALL] or
1622
+ <minimax:tool_call> tag at buffer end."""
1623
+ tags = ("<thinking>", "<think>", "[TOOL_CALL]", _MINIMAX_OPEN)
1624
+ cut = len(s)
1625
+ for tag in tags:
1626
+ for i in range(len(tag) - 1, 0, -1):
1627
+ if s.endswith(tag[:i]):
1628
+ cut = min(cut, len(s) - i)
1629
+ break
1630
+ return s[:cut]
1631
+
1632
+ def on_chunk(text: str):
1633
+ nonlocal text_buffer, thinking_sent_len, in_thinking, in_tool_call
1634
+ nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
1635
+ text_buffer += text
1636
+ self._agent_text_output += text
1637
+
1638
+ # Watchdog: if a marker opened but the close never arrived
1639
+ # and the buffer has grown past the safety cap, give up
1640
+ # and emit the held text as a plain message. The model
1641
+ # is either malformed or the stream was truncated; either
1642
+ # way the user should see *something* rather than a
1643
+ # silently held buffer that never resolves.
1644
+ if (
1645
+ in_thinking
1646
+ or in_tool_call
1647
+ or in_minimax_tool_call
1648
+ or in_bare_tool_call
1649
+ ) and len(text_buffer) > _MAX_HELD_BYTES:
1650
+ _flush_held_buffer()
1651
+ # After flushing, fall through into the normal
1652
+ # scanning loop on the now-empty buffer.
1653
+
1654
+ while text_buffer:
1655
+ if in_thinking:
1656
+ close_thinking = text_buffer.find("</thinking>")
1657
+ close_think = (
1658
+ text_buffer.find("</think>")
1659
+ if close_thinking < 0 else -1
1660
+ )
1661
+ idx = close_thinking if close_thinking >= 0 else close_think
1662
+ close_len = (
1663
+ len("</thinking>") if close_thinking >= 0
1664
+ else len("</think>") if close_think >= 0 else 0
1665
+ )
1666
+ if idx >= 0:
1667
+ thinking = text_buffer[:idx]
1668
+ if thinking:
1669
+ # Send any remaining partial before the close
1670
+ if thinking_sent_len < len(thinking):
1671
+ partial = thinking[thinking_sent_len:]
1672
+ self.send_session_update({
1673
+ "sessionUpdate": "agent_thought_chunk",
1674
+ "content": {"type": "text", "text": partial},
1675
+ })
1676
+ # Persist COMPLETE thinking to engine context
1677
+ on_thinking = getattr(self.agent, "on_thinking", None)
1678
+ if on_thinking is not None:
1679
+ try:
1680
+ on_thinking(thinking)
1681
+ except Exception:
1682
+ debug_log("on_thinking callback failed", exc_info=True)
1683
+ text_buffer = text_buffer[idx + close_len:]
1684
+ thinking_sent_len = 0
1685
+ in_thinking = False
1686
+ else:
1687
+ # Stream partial thinking content incrementally to TUI
1688
+ if thinking_sent_len < len(text_buffer):
1689
+ partial = text_buffer[thinking_sent_len:]
1690
+ self.send_session_update({
1691
+ "sessionUpdate": "agent_thought_chunk",
1692
+ "content": {"type": "text", "text": partial},
1693
+ })
1694
+ thinking_sent_len = len(text_buffer)
1695
+ break
1696
+ elif in_tool_call:
1697
+ close_idx = text_buffer.find(_CLOSE_MARKER)
1698
+ if close_idx < 0:
1699
+ # No close marker yet — hold the whole buffer.
1700
+ break
1701
+ # Strip [TOOL_CALL]...[/TOOL_CALL] from text buffer.
1702
+ # The engine emits TOOL_CALL_START/COMPLETE separately.
1703
+ text_buffer = text_buffer[close_idx + len(_CLOSE_MARKER):]
1704
+ in_tool_call = False
1705
+ elif in_minimax_tool_call:
1706
+ close_idx = text_buffer.find(_MINIMAX_CLOSE)
1707
+ if close_idx < 0:
1708
+ # No close marker yet — hold the whole buffer.
1709
+ break
1710
+ # Strip <minimax:tool_call>...</minimax:tool_call> from
1711
+ # the text buffer. The engine emits the structured
1712
+ # TOOL_CALL_START/COMPLETE events separately.
1713
+ text_buffer = text_buffer[close_idx + len(_MINIMAX_CLOSE):]
1714
+ in_minimax_tool_call = False
1715
+ elif in_bare_tool_call:
1716
+ m = _BARE_LEGACY_RE.match(text_buffer, bare_tool_start)
1717
+ if m is None:
1718
+ # Pattern got invalidated — discard the held span.
1719
+ text_buffer = text_buffer[bare_tool_start:]
1720
+ in_bare_tool_call = False
1721
+ bare_tool_start = 0
1722
+ break
1723
+ args_open = text_buffer.find("{", m.end() - 1)
1724
+ if args_open < 0:
1725
+ break
1726
+ args_close = _scan_balanced_braces(text_buffer, args_open)
1727
+ if args_close is None:
1728
+ # Need more chunks to close the inner args.
1729
+ break
1730
+ outer_close = _scan_balanced_braces(text_buffer, bare_tool_start)
1731
+ if outer_close is None or outer_close <= args_close:
1732
+ break
1733
+ text_buffer = text_buffer[outer_close + 1:]
1734
+ in_bare_tool_call = False
1735
+ bare_tool_start = 0
1736
+ else:
1737
+ tc_idx = text_buffer.find(_OPEN_MARKER)
1738
+ minimax_idx = text_buffer.find(_MINIMAX_OPEN)
1739
+ think_idx = text_buffer.find("<thinking>")
1740
+ if think_idx < 0:
1741
+ think_short_idx = text_buffer.find("<think>")
1742
+ else:
1743
+ think_short_idx = -1
1744
+ bare_idx = _find_bare_legacy_tool_call(text_buffer)
1745
+
1746
+ # Pick the earliest opener among all known markers.
1747
+ candidates: list[tuple[int, str]] = []
1748
+ if tc_idx >= 0:
1749
+ candidates.append((tc_idx, "legacy"))
1750
+ if minimax_idx >= 0:
1751
+ candidates.append((minimax_idx, "minimax"))
1752
+ if think_idx >= 0:
1753
+ candidates.append((think_idx, "think"))
1754
+ if think_short_idx >= 0:
1755
+ candidates.append((think_short_idx, "think_short"))
1756
+ if bare_idx is not None:
1757
+ candidates.append((bare_idx, "bare"))
1758
+
1759
+ if not candidates:
1760
+ safe = _safe_start(text_buffer)
1761
+ if safe:
1762
+ _emit_message(safe)
1763
+ text_buffer = text_buffer[len(safe):]
1764
+ if not _safe_start(text_buffer):
1765
+ text_buffer = ""
1766
+ continue
1767
+
1768
+ candidates.sort()
1769
+ next_idx, kind = candidates[0]
1770
+
1771
+ if next_idx > 0:
1772
+ _emit_message(text_buffer[:next_idx])
1773
+
1774
+ if kind == "minimax":
1775
+ text_buffer = text_buffer[next_idx + len(_MINIMAX_OPEN):]
1776
+ in_minimax_tool_call = True
1777
+ elif kind == "legacy":
1778
+ text_buffer = text_buffer[next_idx + len(_OPEN_MARKER):]
1779
+ in_tool_call = True
1780
+ elif kind in ("think", "think_short"):
1781
+ tag_len = len("<thinking>") if kind == "think" else len("<think>")
1782
+ text_buffer = text_buffer[next_idx + tag_len:]
1783
+ in_thinking = True
1784
+ else:
1785
+ # Bare legacy: peek whether the closing brace is
1786
+ # already in the buffer.
1787
+ m = _BARE_LEGACY_RE.match(text_buffer, next_idx)
1788
+ args_open = text_buffer.find("{", m.end() - 1) if m else -1
1789
+ args_close = (
1790
+ _scan_balanced_braces(text_buffer, args_open)
1791
+ if args_open >= 0 else None
1792
+ )
1793
+ outer_close = (
1794
+ _scan_balanced_braces(text_buffer, next_idx)
1795
+ if args_close is not None else None
1796
+ )
1797
+ if (
1798
+ m is not None and args_open >= 0
1799
+ and args_close is not None
1800
+ and outer_close is not None
1801
+ and outer_close > args_close
1802
+ ):
1803
+ text_buffer = text_buffer[outer_close + 1:]
1804
+ else:
1805
+ in_bare_tool_call = True
1806
+ bare_tool_start = next_idx
1807
+ break
1808
+
1809
+ return on_chunk
1810
+
1811
+ async def session_prompt(self, prompt: list, session_id: str):
1812
+ """Send prompt to agent and stream results."""
1813
+ if self.agent is None:
1814
+ debug_log("session_prompt called but self.agent is None")
1815
+ return {"stopReason": "error", "message": "No agent initialized"}
1816
+
1817
+ debug_log(
1818
+ "session_prompt start session=%s prompt_blocks=%d",
1819
+ session_id, len(prompt),
1820
+ )
1821
+
1822
+ # Collect ALL content blocks from the prompt (text, resource, etc.)
1823
+ # instead of only extracting the text portion.
1824
+ user_content: list[dict] = []
1825
+ for block in prompt:
1826
+ btype = block.get("type", "text")
1827
+ if btype == "text":
1828
+ user_content.append(block)
1829
+ elif btype == "resource":
1830
+ user_content.append(block)
1831
+ elif btype == "image":
1832
+ user_content.append(block)
1833
+ else:
1834
+ # Pass through unknown types, the context layer will handle them
1835
+ user_content.append(block)
1836
+
1837
+ # ── Slash-command handling ─────────────────────────────
1838
+ if user_content and user_content[0].get("type") == "text":
1839
+ text = user_content[0].get("text", "").strip()
1840
+ if text == "/permission":
1841
+ return self._handle_reset_permission()
1842
+ if text.startswith("/permission "):
1843
+ key = text.split("/permission ", 1)[1].strip()
1844
+ return self._handle_reset_permission(key)
1845
+
1846
+ # Fresh per-turn tracking (tool_calls accumulates across one turn only)
1847
+ self.tool_calls = {}
1848
+ self.agent._cancelled = False
1849
+ self._subagent_fwd_count = {}
1850
+ self._subagent_fwd_bytes = {}
1851
+ self._subagent_tool_calls = {}
1852
+ self._agent_text_output = ""
1853
+ self._phase_skill_detected = ""
1854
+ info_log(
1855
+ "[ACP-PROMPT] entering chat_stream loop; agent._cancelled reset to "
1856
+ "False; session=%s", session_id,
1857
+ )
1858
+
1859
+ # Create chunkers for agent_message_chunk and agent_thought_chunk
1860
+ self._text_chunker = TextChunker(
1861
+ send_fn=lambda text: self.send_session_update({
1862
+ "sessionUpdate": "agent_message_chunk",
1863
+ "content": {"type": "text", "text": text},
1864
+ }),
1865
+ mode="auto",
1866
+ words=5,
1867
+ )
1868
+ self._thought_chunker = TextChunker(
1869
+ send_fn=lambda text: self.send_session_update({
1870
+ "sessionUpdate": "agent_thought_chunk",
1871
+ "content": {"type": "text", "text": text},
1872
+ }),
1873
+ mode="auto",
1874
+ words=5,
1875
+ )
1876
+ self.agent.on_text_chunk = self._make_stream_callback()
1877
+ # Persist thinking blocks into the engine's context so they survive
1878
+ # session reload (otherwise the TUI only sees them during streaming).
1879
+ self.agent.on_thinking = lambda text: self.agent._pending_thinking_blocks.append(text)
1880
+
1881
+ # Track in-progress tool call args for incremental display
1882
+ _incremental_args: dict[str, str] = {}
1883
+ _last_sent_len: dict[str, int] = {}
1884
+ _ARGS_THROTTLE_CHARS = 50
1885
+
1886
+ def _on_tool_call_delta(call_id: str, name: str, args_delta: str) -> None:
1887
+ # Skip Spawn — SubAgent widget handles display via subagent_* events
1888
+ if name == "Spawn":
1889
+ return
1890
+ if args_delta:
1891
+ _incremental_args[call_id] = _incremental_args.get(call_id, "") + args_delta
1892
+ display_args = _incremental_args[call_id]
1893
+ # Throttle: only send when accumulated enough new chars
1894
+ if len(display_args) - _last_sent_len.get(call_id, 0) < _ARGS_THROTTLE_CHARS:
1895
+ return
1896
+ _last_sent_len[call_id] = len(display_args)
1897
+ try:
1898
+ parsed = json.loads(display_args)
1899
+ display_text = json.dumps(parsed, indent=2, ensure_ascii=False)
1900
+ except (json.JSONDecodeError, TypeError):
1901
+ display_text = display_args
1902
+ self.send_session_update({
1903
+ "sessionUpdate": "tool_call_update",
1904
+ "toolCallId": call_id,
1905
+ "title": name or "Tool call",
1906
+ "status": "in_progress",
1907
+ "content": [{
1908
+ "type": "content",
1909
+ "content": {"type": "text", "text": f"```json\n{display_text}\n```"},
1910
+ }] if display_text else [],
1911
+ })
1912
+
1913
+ self.agent.on_tool_call_delta = _on_tool_call_delta
1914
+ try:
1915
+ async for event in self.agent.chat_stream(user_content):
1916
+ if event.type == StreamEventType.TEXT_DELTA:
1917
+ self._text_chunker.append(event.text)
1918
+ self._agent_text_output += event.text
1919
+ elif event.type == StreamEventType.THINKING:
1920
+ self._thought_chunker.append(event.thinking)
1921
+ elif event.type == StreamEventType.TOOL_CALL_START:
1922
+ self._text_chunker.flush()
1923
+ self._thought_chunker.flush()
1924
+ tool_kind = _kind_for_category(event.tool_category)
1925
+ existing = self.tool_calls.get(event.tool_id, {})
1926
+ self.tool_calls[event.tool_id] = {
1927
+ "sessionUpdate": "tool_call",
1928
+ "toolCallId": event.tool_id,
1929
+ "title": event.tool_name,
1930
+ "kind": tool_kind,
1931
+ "status": "in_progress",
1932
+ "content": existing.get("content", []),
1933
+ "_tool_name": event.tool_name,
1934
+ }
1935
+ # Skip sending tool_call for Spawn tools — SubAgent widget
1936
+ # handles the display via subagent_start events.
1937
+ if event.tool_name != "Spawn":
1938
+ self.send_session_update(self.tool_calls[event.tool_id])
1939
+ elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
1940
+ # Track AIDLC phase skill calls for auto-advance
1941
+ if event.tool_name == "Skill" and event.tool_args:
1942
+ skill_name = event.tool_args.get("name", "")
1943
+ if skill_name and skill_name.startswith("ai-dlc-"):
1944
+ phase = skill_name.replace("ai-dlc-", "")
1945
+ if phase in CdhProjectLoader._PHASE_SEQUENCE:
1946
+ self._phase_skill_detected = phase
1947
+ self.send_session_event({
1948
+ "type": "tool_executed",
1949
+ "tool_name": event.tool_name,
1950
+ "tool_category": str(event.tool_category),
1951
+ })
1952
+ if event.tool_id in self.tool_calls:
1953
+ title = event.tool_name
1954
+ if event.tool_args:
1955
+ if path := event.tool_args.get("path", ""):
1956
+ title = f"{event.tool_name}: {_short_path(path, self.agent._project_dir)}"
1957
+ elif event.tool_name == "Bash":
1958
+ cmd = str(event.tool_args.get("command", ""))
1959
+ title = f"Bash: {cmd}"
1960
+ elif event.tool_name == "TodoCreate":
1961
+ subject = str(event.tool_args.get("subject", ""))
1962
+ if subject:
1963
+ title = f"TodoCreate: {subject}"
1964
+ elif event.tool_name == "TodoUpdate":
1965
+ subject = str(event.tool_args.get("subject", ""))
1966
+ if subject:
1967
+ title = f"TodoUpdate: {subject}"
1968
+ elif event.tool_name in ("TodoGet", "TodoStop", "TodoOutput"):
1969
+ tid = str(event.tool_args.get("taskId", event.tool_args.get("task_id", "")))
1970
+ if tid:
1971
+ title = f"{event.tool_name}: {tid}"
1972
+ elif event.tool_name == "Glob":
1973
+ pattern = str(event.tool_args.get("pattern", ""))
1974
+ if pattern:
1975
+ title = f"Glob: {pattern}"
1976
+ elif event.tool_name == "Grep":
1977
+ pattern = str(event.tool_args.get("pattern", ""))
1978
+ if pattern:
1979
+ title = f"Grep: {pattern}"
1980
+ elif event.tool_name == "ToolSearch":
1981
+ query = str(event.tool_args.get("query", ""))
1982
+ if query:
1983
+ title = f"ToolSearch: {query}"
1984
+ else:
1985
+ for key in ("query", "command", "pattern", "name", "subject"):
1986
+ val = str(event.tool_args.get(key, ""))
1987
+ if val:
1988
+ title = f"{event.tool_name}: {val}"
1989
+ break
1990
+ content = _build_tool_call_content(
1991
+ event.tool_name, event.tool_args
1992
+ )
1993
+ debug_log(
1994
+ "TOOL_CALL_COMPLETE %s id=%s args_keys=%s title=%s content_blocks=%d",
1995
+ event.tool_name, event.tool_id,
1996
+ list(event.tool_args.keys()), title,
1997
+ len(content),
1998
+ )
1999
+ else:
2000
+ content = []
2001
+ debug_log(
2002
+ "TOOL_CALL_COMPLETE %s id=%s NO args",
2003
+ event.tool_name, event.tool_id,
2004
+ )
2005
+ self.tool_calls[event.tool_id].update({
2006
+ "sessionUpdate": "tool_call_update",
2007
+ "toolCallId": event.tool_id,
2008
+ "title": title,
2009
+ # Keep in_progress through execution — the tool is
2010
+ # still running until TOOL_RESULT flips it to
2011
+ # completed/failed (no pending/⌛ intermediate).
2012
+ "status": "in_progress",
2013
+ "content": content,
2014
+ "_tool_name": event.tool_name,
2015
+ "_tool_args": event.tool_args,
2016
+ })
2017
+ # Skip sending tool_call_update for Spawn — SubAgent widget
2018
+ # handles the display via subagent_start/chunk/end events.
2019
+ if event.tool_name != "Spawn":
2020
+ self.send_session_update(self.tool_calls[event.tool_id])
2021
+ elif event.type == StreamEventType.TOOL_RESULT:
2022
+ self.send_session_event({
2023
+ "type": "tool_result",
2024
+ "tool_id": event.tool_id,
2025
+ "is_error": event.result_is_error,
2026
+ })
2027
+ # Spawn tool results: subagent_start/chunk/end events were
2028
+ # already sent during subagent execution — just skip the
2029
+ # tool_call_update (no duplicate SubAgent widget needed).
2030
+ tname = self.tool_calls.get(event.tool_id, {}).get("_tool_name", "")
2031
+ if tname == "Spawn":
2032
+ try:
2033
+ self.agent.save_session()
2034
+ except Exception:
2035
+ debug_log("Failed to save session after subagent", exc_info=True)
2036
+ continue
2037
+ status = "failed" if event.result_is_error else "completed"
2038
+ display_text = _format_tui_display_text(
2039
+ event.result_content or "", tname,
2040
+ )
2041
+
2042
+ # For edit/write tools the diff/code block + header ✔ status is
2043
+ # sufficient — skip the redundant "✓ /path" result text.
2044
+ if display_text and self.tool_calls.get(event.tool_id, {}).get("kind") == "edit":
2045
+ display_text = ""
2046
+
2047
+ # Update title with path/subject from result if not already set
2048
+ if event.result_content and event.tool_id in self.tool_calls:
2049
+ try:
2050
+ parsed = json.loads(event.result_content)
2051
+ except json.JSONDecodeError:
2052
+ pass
2053
+ else:
2054
+ if isinstance(parsed, dict) and parsed.get("success") is True:
2055
+ current_title = self.tool_calls[event.tool_id].get("title", "")
2056
+ if ":" not in current_title:
2057
+ tname = self.tool_calls[event.tool_id].get("_tool_name", "")
2058
+ if tname in ("TodoCreate", "TodoUpdate"):
2059
+ task_info = parsed.get("task", {})
2060
+ if isinstance(task_info, dict):
2061
+ if subject := task_info.get("subject", ""):
2062
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {subject}"
2063
+ elif path := parsed.get("path"):
2064
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
2065
+ elif path := parsed.get("path"):
2066
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
2067
+
2068
+ # Add "denied" hint for reject-always overrides
2069
+ if event.result_is_error and display_text and "denied" in display_text:
2070
+ display_text += "\n\n💡 Use `/permission` to clear a 'reject always' override."
2071
+
2072
+ # TUI now renders all text content as Markdown.
2073
+ # Wrap multi-line results in a fenced code block so they
2074
+ # display as a code block rather than a raw paragraph.
2075
+ # For Read results, detect language from the file path.
2076
+ # For Bash/Glob/Grep/List, use bash-style fence.
2077
+ # Skip if already contains a code fence to avoid nesting.
2078
+ if display_text and "\n" in display_text and "```" not in display_text and tname != "ToolSearch":
2079
+ lang = ""
2080
+ tool_info = self.tool_calls.get(event.tool_id, {})
2081
+ tname = tool_info.get("_tool_name", "")
2082
+ targs = tool_info.get("_tool_args", {})
2083
+ if tname in ("Bash", "Glob", "Grep", "List"):
2084
+ lang = "bash"
2085
+ elif tname == "Read" and isinstance(targs, dict):
2086
+ path = str(targs.get("path", ""))
2087
+ if path:
2088
+ lang = _language_for_path(path)
2089
+ if lang:
2090
+ display_text = f"```{lang}\n{display_text}\n```"
2091
+ else:
2092
+ display_text = f"```\n{display_text}\n```"
2093
+ content_block = [{
2094
+ "type": "content",
2095
+ "content": {"type": "text", "text": display_text},
2096
+ }] if display_text else []
2097
+ existing_content = self.tool_calls.get(event.tool_id, {}).get("content", [])
2098
+ if not isinstance(existing_content, list):
2099
+ existing_content = []
2100
+ debug_log(
2101
+ "TOOL_RESULT id=%s status=%s result_len=%d display_len=%d existing_blocks=%d new_blocks=%d",
2102
+ event.tool_id, status,
2103
+ len(event.result_content or ""), len(display_text),
2104
+ len(existing_content), len(content_block),
2105
+ )
2106
+ update = {
2107
+ "sessionUpdate": "tool_call_update",
2108
+ "toolCallId": event.tool_id,
2109
+ "status": status,
2110
+ "content": existing_content + content_block,
2111
+ }
2112
+ if event.tool_id in self.tool_calls:
2113
+ self.tool_calls[event.tool_id].update(update)
2114
+ else:
2115
+ self.tool_calls[event.tool_id] = {
2116
+ "sessionUpdate": "tool_call",
2117
+ "toolCallId": event.tool_id,
2118
+ "title": "Tool call",
2119
+ "kind": "other",
2120
+ "status": status,
2121
+ **update,
2122
+ }
2123
+ self.send_session_update(self.tool_calls[event.tool_id])
2124
+ try:
2125
+ self.agent.save_session()
2126
+ except Exception:
2127
+ debug_log("Failed to save session after tool result", exc_info=True)
2128
+ elif event.type == StreamEventType.ERROR:
2129
+ self._text_chunker.flush()
2130
+ self._thought_chunker.flush()
2131
+ self.send_session_update({
2132
+ "sessionUpdate": "agent_message_chunk",
2133
+ "content": {"type": "text", "text": f"Error: {event.error_message}"},
2134
+ })
2135
+ try:
2136
+ self.agent.save_session()
2137
+ except Exception:
2138
+ debug_log("Failed to save session on error", exc_info=True)
2139
+ return {"stopReason": "error", "message": event.error_message}
2140
+ elif event.type == StreamEventType.SUBAGENT_START:
2141
+ self._text_chunker.flush()
2142
+ self._thought_chunker.flush()
2143
+ info_log(
2144
+ "[ACP-SUBagent] SUBAGENT_START id=%s type=%s prompt_len=%d",
2145
+ event.subagent_id, event.subagent_type,
2146
+ len(event.subagent_prompt or ""),
2147
+ )
2148
+ self.send_session_update({
2149
+ "sessionUpdate": "subagent_start",
2150
+ "subagentId": event.subagent_id,
2151
+ "agentType": event.subagent_type,
2152
+ "prompt": event.subagent_prompt,
2153
+ })
2154
+ self._subagent_fwd_count[event.subagent_id] = 0
2155
+ self._subagent_fwd_bytes[event.subagent_id] = 0
2156
+ elif event.type == StreamEventType.SUBAGENT_CHUNK:
2157
+ _cnt = self._subagent_fwd_count.get(event.subagent_id, 0) + 1
2158
+ self._subagent_fwd_count[event.subagent_id] = _cnt
2159
+ _by = self._subagent_fwd_bytes.get(event.subagent_id, 0) + len(event.subagent_text or "")
2160
+ self._subagent_fwd_bytes[event.subagent_id] = _by
2161
+ if _cnt <= 5 or _cnt % 50 == 0:
2162
+ debug_log(
2163
+ "[ACP-SUBagent] SUBAGENT_CHUNK id=%s chunk#%d bytes=%d total=%d",
2164
+ event.subagent_id, _cnt, len(event.subagent_text or ""),
2165
+ _by,
2166
+ )
2167
+ self.send_session_update({
2168
+ "sessionUpdate": "subagent_chunk",
2169
+ "subagentId": event.subagent_id,
2170
+ "text": event.subagent_text,
2171
+ })
2172
+ elif event.type == StreamEventType.SUBAGENT_THINKING:
2173
+ debug_log(
2174
+ "[ACP-SUBagent] SUBAGENT_THINKING id=%s bytes=%d",
2175
+ event.subagent_id, len(event.subagent_thinking_text or ""),
2176
+ )
2177
+ self.send_session_update({
2178
+ "sessionUpdate": "subagent_thinking",
2179
+ "subagentId": event.subagent_id,
2180
+ "text": event.subagent_thinking_text,
2181
+ })
2182
+ elif event.type == StreamEventType.SUBAGENT_END:
2183
+ chunk_count = self._subagent_fwd_count.pop(event.subagent_id, None)
2184
+ self._subagent_fwd_bytes.pop(event.subagent_id, None)
2185
+ info_log(
2186
+ "[ACP-SUBagent] SUBAGENT_END id=%s type=%s status=%s "
2187
+ "err=%r chunk_count=%s",
2188
+ event.subagent_id, event.subagent_type,
2189
+ event.subagent_status, (event.subagent_error or "")[:100],
2190
+ chunk_count,
2191
+ )
2192
+ # If there's no active subagent for this id, the engine
2193
+ # rejected a nested Spawn (depth exceeded). Drop the
2194
+ # orphan end event so the TUI doesn't get a stale
2195
+ # subagent_end without a matching subagent_start.
2196
+ if chunk_count is None:
2197
+ debug_log(
2198
+ "[ACP-SUBagent] DROP orphan SUBAGENT_END id=%s "
2199
+ "(no matching start — likely nested Spawn rejection)",
2200
+ event.subagent_id,
2201
+ )
2202
+ continue
2203
+ self.send_session_update({
2204
+ "sessionUpdate": "subagent_end",
2205
+ "subagentId": event.subagent_id,
2206
+ "agentType": event.subagent_type,
2207
+ "status": event.subagent_status,
2208
+ "error": event.subagent_error,
2209
+ })
2210
+ elif event.type == StreamEventType.SUBAGENT_TOOL_CALL:
2211
+ self._handle_subagent_tool_call(event)
2212
+ elif event.type == StreamEventType.SUBAGENT_TOOL_RESULT:
2213
+ self._handle_subagent_tool_result(event)
2214
+ elif event.type == StreamEventType.VERIFICATION_PASSED:
2215
+ self.send_session_event({
2216
+ "type": "verification_passed",
2217
+ "tool_name": event.tool_name,
2218
+ "summary": event.text,
2219
+ })
2220
+ elif event.type == StreamEventType.VERIFICATION_FAILED:
2221
+ self.send_session_event({
2222
+ "type": "verification_failed",
2223
+ "tool_name": event.tool_name,
2224
+ "failed_gates": event.verification_failed_gates,
2225
+ "summary": event.text,
2226
+ })
2227
+ elif event.type == StreamEventType.PLAN:
2228
+ self.send_session_update({
2229
+ "sessionUpdate": "plan",
2230
+ "entries": event.plan_entries,
2231
+ })
2232
+ elif event.type == StreamEventType.ASK_USER:
2233
+ self._text_chunker.flush()
2234
+ self._thought_chunker.flush()
2235
+
2236
+ # Handle AskUser tool — show question inline via session/update notification
2237
+ if event.ask_action == "AskUser":
2238
+ if event.ask_questions:
2239
+ self.send_session_update({
2240
+ "sessionUpdate": "ask_user",
2241
+ "questions": event.ask_questions,
2242
+ "context": event.ask_context or "",
2243
+ "toolId": event.tool_id,
2244
+ "checkpointId": getattr(event, "ask_checkpoint_id", "") or "",
2245
+ })
2246
+ else:
2247
+ self.send_session_update({
2248
+ "sessionUpdate": "ask_user",
2249
+ "question": event.ask_question,
2250
+ "context": event.ask_context or "",
2251
+ "options": event.ask_options,
2252
+ "toolId": event.tool_id,
2253
+ "checkpointId": getattr(event, "ask_checkpoint_id", "") or "",
2254
+ })
2255
+
2256
+ is_plan_submit = getattr(event, "ask_plan_submit", False)
2257
+ answer, cancelled = await self._wait_for_ask_user_response(
2258
+ event, event.tool_id,
2259
+ is_plan_submit=is_plan_submit,
2260
+ )
2261
+
2262
+ result = await self.agent.resolve_approval(
2263
+ approved=not cancelled, answer=answer,
2264
+ )
2265
+ if is_plan_submit and cancelled:
2266
+ self.agent._cancelled = False
2267
+ self.send_session_update({
2268
+ "sessionUpdate": "agent_message_chunk",
2269
+ "content": {"type": "text", "text": "计划已取消。会话结束。"},
2270
+ })
2271
+ return {"stopReason": "cancelled"}
2272
+ if result:
2273
+ tid = result.get("tool_use_id", "") or event.tool_id
2274
+ rstr = result.get("content", "") or ""
2275
+ ierr = result.get("is_error", False)
2276
+ self.agent.context.add_message(
2277
+ "tool",
2278
+ [{"type": "tool_result", "tool_use_id": tid,
2279
+ "content": rstr, "is_error": ierr}],
2280
+ name=tid,
2281
+ )
2282
+ status = "failed" if ierr else "completed"
2283
+ content_block = [{
2284
+ "type": "content",
2285
+ "content": {"type": "text", "text": rstr},
2286
+ }] if rstr else []
2287
+ self.send_session_update({
2288
+ "sessionUpdate": "tool_call_update",
2289
+ "toolCallId": tid,
2290
+ "title": "AskUser",
2291
+ "status": status,
2292
+ "content": content_block,
2293
+ })
2294
+ continue
2295
+
2296
+ # Build tool call content for the permission request
2297
+ tool_kind = _kind_for_category(get_tool_category(event.ask_action))
2298
+ pending = (self.agent._pending_approval or {}).get("tool_call", {})
2299
+ tool_args = pending.get("input", {}) if pending else {}
2300
+ tool_content = _build_tool_call_content(
2301
+ event.ask_action, tool_args
2302
+ ) if tool_args else []
2303
+
2304
+ # Prepend the question as a visible content block so the TUI
2305
+ # permission dialog shows *what* the agent wants to do instead
2306
+ # of an empty ``_meta`` that the TUI ignores.
2307
+ question_block = {
2308
+ "type": "content",
2309
+ "content": {"type": "text", "text": f"❓ {event.ask_question}"},
2310
+ }
2311
+ tool_content = [question_block] + tool_content
2312
+
2313
+ # Guard against orphan permission requests: if the engine
2314
+ # didn't supply a tool id, fall back to the pending approval
2315
+ # id; if that's also empty, log and skip the request so the
2316
+ # TUI doesn't render a dialog with no associated tool widget.
2317
+ tool_call_id = event.tool_id or pending.get("id", "")
2318
+ if not tool_call_id:
2319
+ debug_log(
2320
+ "ASK_USER skipped: no tool_id (action=%s question=%s)",
2321
+ event.ask_action, event.ask_question,
2322
+ )
2323
+ continue
2324
+
2325
+ permission_params = {
2326
+ "sessionId": session_id,
2327
+ "options": [
2328
+ {"name": "Allow once", "optionId": "allow_once", "kind": "allow_once"},
2329
+ {"name": "Allow always", "optionId": "allow_always", "kind": "allow_always"},
2330
+ {"name": "Reject once", "optionId": "reject_once", "kind": "reject_once"},
2331
+ {"name": "Reject always", "optionId": "reject_always", "kind": "reject_always"},
2332
+ ],
2333
+ "toolCall": {
2334
+ "toolCallId": tool_call_id,
2335
+ "title": event.ask_action,
2336
+ "kind": tool_kind,
2337
+ "content": tool_content,
2338
+ "status": "pending",
2339
+ },
2340
+ }
2341
+ try:
2342
+ perm_future = self.send_request(
2343
+ "session/request_permission", permission_params
2344
+ )
2345
+ perm_response = await perm_future
2346
+ outcome = perm_response.get("outcome", {})
2347
+ option_id = outcome.get("optionId", "reject_once")
2348
+ approved = option_id in ("allow_once", "allow_always")
2349
+
2350
+ if option_id == "allow_always":
2351
+ from onecode.agent.agents.types import AgentPermission
2352
+ perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
2353
+ if perm_key:
2354
+ attr_name = f"permission_{perm_key}"
2355
+ setattr(self.agent.current_agent, attr_name, AgentPermission.ALLOW)
2356
+ self._perm_store.set_override(perm_key, AgentPermission.ALLOW)
2357
+ self._perm_store_save()
2358
+ elif option_id == "reject_always":
2359
+ from onecode.agent.agents.types import AgentPermission
2360
+ perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
2361
+ if perm_key:
2362
+ attr_name = f"permission_{perm_key}"
2363
+ setattr(self.agent.current_agent, attr_name, AgentPermission.DENY)
2364
+ self._perm_store.set_override(perm_key, AgentPermission.DENY)
2365
+ self._perm_store_save()
2366
+ except Exception:
2367
+ approved = False
2368
+
2369
+ result = await self.agent.resolve_approval(approved)
2370
+ if result:
2371
+ result_str = result.get("content", "") or ""
2372
+ is_error = result.get("is_error", False)
2373
+ tid = result.get("tool_use_id", "") or tool_call_id
2374
+ status = "failed" if is_error else "completed"
2375
+ content_block = [{
2376
+ "type": "content",
2377
+ "content": {"type": "text", "text": result_str},
2378
+ }] if result_str else []
2379
+ self.send_session_update({
2380
+ "sessionUpdate": "tool_call_update",
2381
+ "toolCallId": tid,
2382
+ "status": status,
2383
+ "content": content_block,
2384
+ })
2385
+ # Add result to LLM context
2386
+ self.agent.context.add_message(
2387
+ "tool",
2388
+ [{"type": "tool_result", "tool_use_id": tid,
2389
+ "content": result_str, "is_error": is_error}],
2390
+ name=tid,
2391
+ )
2392
+ # Sync plan after approval — the tool may have mutated todos
2393
+ self._emit_plan_update_to_tui()
2394
+ self._emit_aidlc_state_to_tui()
2395
+ verb = "Approved" if approved else "Rejected"
2396
+ self.send_session_update({
2397
+ "sessionUpdate": "agent_message_chunk",
2398
+ "content": {
2399
+ "type": "text",
2400
+ "text": f"⚡ {verb}: {event.ask_action} — {event.ask_question}",
2401
+ },
2402
+ })
2403
+
2404
+ except Exception:
2405
+ import traceback
2406
+ tb = traceback.format_exc()
2407
+ _dump_crash("chat_stream")
2408
+ debug_log("Unhandled exception in chat_stream:\n%s", tb, exc_info=True)
2409
+ print(f"[cdh-agent-acp] ERROR in chat_stream:\n{tb}", file=sys.stderr, flush=True)
2410
+ self._text_chunker.flush()
2411
+ self._thought_chunker.flush()
2412
+ self.send_session_update({
2413
+ "sessionUpdate": "agent_message_chunk",
2414
+ "content": {"type": "text", "text": "Error: internal error"},
2415
+ })
2416
+ try:
2417
+ self.agent.save_session()
2418
+ except Exception:
2419
+ pass
2420
+ try:
2421
+ self.agent.save_todos_to_project()
2422
+ except Exception:
2423
+ pass
2424
+ return {"stopReason": "error", "message": "Internal agent error"}
2425
+
2426
+ self._text_chunker.flush()
2427
+ self._thought_chunker.flush()
2428
+
2429
+ self.send_awaiting_user_input()
2430
+
2431
+ try:
2432
+ self.agent.save_session()
2433
+ except Exception:
2434
+ debug_log("Failed to save session at turn end", exc_info=True)
2435
+
2436
+ # Persist tasks to project .cdh/ so they survive Ctrl+C
2437
+ try:
2438
+ self.agent.save_todos_to_project()
2439
+ except Exception:
2440
+ debug_log("Failed to save tasks to .cdh at turn end", exc_info=True)
2441
+
2442
+ # Sync plan to TUI so sidebar shows final todo state
2443
+ self._emit_plan_update_to_tui()
2444
+ self._emit_aidlc_state_to_tui()
2445
+
2446
+ # Send context usage stats to TUI sidebar
2447
+ ctx = self.agent.context
2448
+ used = ctx._token_count if ctx else 0
2449
+ size = ctx.config.max_tokens if ctx else 0
2450
+ self.send_session_update({
2451
+ "sessionUpdate": "usage_update",
2452
+ "used": used,
2453
+ "size": size,
2454
+ })
2455
+
2456
+ # Tool call summary suppressed — internal step tracking
2457
+ # is not shown to avoid cluttering conversation output.
2458
+
2459
+ stop_reason = "cancelled" if self.agent._cancelled else "end_turn"
2460
+ usage = self._build_session_usage()
2461
+ self._turn_count += 1
2462
+ self.send_session_event(
2463
+ event={
2464
+ "type": "session_ended",
2465
+ "stop_reason": stop_reason,
2466
+ "turn_count": self._turn_count,
2467
+ },
2468
+ metrics={
2469
+ "tool_calls": len(self.tool_calls),
2470
+ "subagent_calls": sum(self._subagent_fwd_count.values()),
2471
+ "usage": usage,
2472
+ },
2473
+ )
2474
+ return {
2475
+ "sessionId": self.session_id,
2476
+ "stopReason": stop_reason,
2477
+ "usage": usage,
2478
+ }
2479
+
2480
+ def _pick_default_ask_user_answer(self, event) -> str:
2481
+ """Pick the default answer for a timed-out AskUser prompt.
2482
+
2483
+ Heuristic:
2484
+ - If a question has an option with ``default: true``, use it.
2485
+ - Otherwise pick the first option.
2486
+ - Multi-question prompts return a JSON string of ``{idx: value}``.
2487
+ - Single-question prompts return the option value as a plain string.
2488
+ - If the question has no options (free-text), return an empty string
2489
+ so the agent treats it as a no-op.
2490
+ """
2491
+ questions = getattr(event, "ask_questions", None) or []
2492
+ options = getattr(event, "ask_options", None) or []
2493
+
2494
+ def _pick(opt_list: list) -> str:
2495
+ if not opt_list:
2496
+ return ""
2497
+ for opt in opt_list:
2498
+ if opt.get("default"):
2499
+ return str(opt.get("value", ""))
2500
+ return str(opt_list[0].get("value", ""))
2501
+
2502
+ if questions:
2503
+ answers = {str(i): _pick(q.get("options", [])) for i, q in enumerate(questions)}
2504
+ return json.dumps(answers)
2505
+ if options:
2506
+ return _pick(options)
2507
+ return ""
2508
+
2509
+ async def _wait_for_ask_user_response(
2510
+ self,
2511
+ event,
2512
+ tool_id: str,
2513
+ timeout_seconds: int | None = None,
2514
+ use_default_on_timeout: bool | None = None,
2515
+ is_plan_submit: bool = False,
2516
+ ) -> tuple[str, bool]:
2517
+ """Wait for AskUser response with configurable timeout and behavior.
2518
+
2519
+ Returns (answer, cancelled).
2520
+
2521
+ Non-multi-question AskUser (auto-ask, single question): stops session
2522
+ immediately so user can type directly without waiting for timeout.
2523
+
2524
+ Multi-question AskUser (questions list): waits for user response since
2525
+ the AskUser component will be invoked.
2526
+
2527
+ For plan_submit questions (is_plan_submit=True), timeout or cancellation
2528
+ is treated as rejection — no default answer is used.
2529
+ """
2530
+ questions = getattr(event, "ask_questions", None)
2531
+ if questions is None:
2532
+ return "", True
2533
+
2534
+ ask_cfg = getattr(self.agent.app.config, "ask_user", None)
2535
+ if timeout_seconds is None:
2536
+ timeout_seconds = getattr(ask_cfg, "timeout_seconds", 10) if ask_cfg else 10
2537
+ if use_default_on_timeout is None:
2538
+ use_default_on_timeout = (
2539
+ getattr(ask_cfg, "timeout_behavior", "use_default") == "use_default"
2540
+ ) if ask_cfg else True
2541
+
2542
+ answer = ""
2543
+ cancelled = False
2544
+ _no_response = False
2545
+
2546
+ for _attempt in range(2):
2547
+ try:
2548
+ self._ask_user_future = asyncio.get_event_loop().create_future()
2549
+ ask_response = await asyncio.wait_for(
2550
+ self._ask_user_future,
2551
+ timeout=timeout_seconds,
2552
+ )
2553
+ answer = ask_response.get("answer", "")
2554
+ cancelled = ask_response.get("cancelled", False)
2555
+ _no_response = False
2556
+ break
2557
+ except asyncio.TimeoutError:
2558
+ if _attempt == 0:
2559
+ self.send_session_update({
2560
+ "sessionUpdate": "ask_user_remind",
2561
+ "text": "请确认?",
2562
+ "toolId": tool_id,
2563
+ })
2564
+ _no_response = True
2565
+ continue
2566
+ # Second timeout: use default or cancel
2567
+ _no_response = True
2568
+ if is_plan_submit:
2569
+ cancelled = True
2570
+ elif use_default_on_timeout:
2571
+ questions = getattr(event, "ask_questions", None) or []
2572
+ options = getattr(event, "ask_options", None) or []
2573
+ if questions or options:
2574
+ answer = self._pick_default_ask_user_answer(event)
2575
+ cancelled = False
2576
+ else:
2577
+ cancelled = True
2578
+ else:
2579
+ cancelled = True
2580
+ finally:
2581
+ self._ask_user_future = None
2582
+
2583
+ if _no_response and not cancelled and answer:
2584
+ self.send_session_update({
2585
+ "sessionUpdate": "ask_user_default_used",
2586
+ "answer": answer,
2587
+ "toolId": tool_id,
2588
+ })
2589
+
2590
+ return answer, cancelled
2591
+
2592
+ def _build_session_usage(self) -> dict:
2593
+ """Aggregate per-turn usage from the engine into a single Usage dict.
2594
+
2595
+ The TUI's ``tui/acp/protocol.py:SessionPromptResponse.usage`` is a
2596
+ free-form ``Usage`` object. We sum the per-turn input/output/total
2597
+ tokens tracked by the engine; missing fields fall back to 0.
2598
+ """
2599
+ turn_usages = getattr(self.agent, "_turn_usages", None) or []
2600
+ if not turn_usages:
2601
+ return {
2602
+ "total_tokens": int(getattr(self.agent, "total_tokens", 0) or 0),
2603
+ "input_tokens": 0,
2604
+ "output_tokens": 0,
2605
+ }
2606
+ total_in = sum(int(u.get("input_tokens", 0) or 0) for u in turn_usages)
2607
+ total_out = sum(int(u.get("output_tokens", 0) or 0) for u in turn_usages)
2608
+ total_all = sum(int(u.get("total_tokens", 0) or 0) for u in turn_usages)
2609
+ if total_all == 0:
2610
+ total_all = total_in + total_out
2611
+ return {
2612
+ "total_tokens": total_all,
2613
+ "input_tokens": total_in,
2614
+ "output_tokens": total_out,
2615
+ }
2616
+
2617
+ async def session_save(self, session_id: str, _meta: dict):
2618
+ """Save current session state."""
2619
+ if self.agent:
2620
+ try:
2621
+ self.agent.save_session()
2622
+ except Exception:
2623
+ debug_log("Failed to save session on save", exc_info=True)
2624
+ return {}
2625
+
2626
+ async def session_cancel(self, session_id: str, _meta: dict):
2627
+ """Cancel current session."""
2628
+ if self.agent:
2629
+ await self.agent.cancel()
2630
+ await self.agent.shutdown()
2631
+ try:
2632
+ self.agent.save_session()
2633
+ except Exception:
2634
+ debug_log("Failed to save session on cancel", exc_info=True)
2635
+ # Persist tasks to .cdh so they survive Ctrl+C and re-entry
2636
+ try:
2637
+ self.agent.save_todos_to_project()
2638
+ except Exception:
2639
+ debug_log("Failed to save tasks to .cdh on cancel", exc_info=True)
2640
+ return {}
2641
+
2642
+ async def session_set_mode(self, session_id: str, mode_id: str):
2643
+ """Set session mode — propagates to engine and notifies TUI."""
2644
+ if self.agent:
2645
+ self.agent.set_agent(mode_id)
2646
+ self._perm_store.apply_to(self.agent.current_agent)
2647
+ self.send_session_update({
2648
+ "sessionUpdate": "current_mode_update",
2649
+ "currentModeId": mode_id,
2650
+ })
2651
+ self._send_available_commands()
2652
+ return {"modeId": mode_id}
2653
+
2654
+ async def session_clear_todos(self, session_id: str):
2655
+ """Clear all todos in the current session."""
2656
+ if self.agent:
2657
+ tm = getattr(self.agent, "_todo_manager", None)
2658
+ if tm:
2659
+ tm.clear_todos()
2660
+ self.agent.save_todos_to_project()
2661
+ self._emit_plan_update_to_tui()
2662
+ self._emit_aidlc_state_to_tui()
2663
+ return {"cleared": True}
2664
+
2665
+ async def session_compact(self, session_id: str):
2666
+ """Compact the agent context to reduce token usage."""
2667
+ if self.agent is None:
2668
+ return {"level": "none", "tokens_before": 0, "tokens_after": 0}
2669
+ tokens_before = self.agent.context._token_count
2670
+ level = self.agent.context.compact()
2671
+ tokens_after = self.agent.context._token_count
2672
+ return {"level": level, "tokens_before": tokens_before, "tokens_after": tokens_after}
2673
+
2674
+ # ── Terminal RPC stubs ──────────────────────────────────────────
2675
+ async def terminal_create(
2676
+ self, session_id: str, terminal_id: str, command: str, args: list[str] | None = None,
2677
+ cwd: str | None = None, env: dict | None = None, output_byte_limit: int | None = None,
2678
+ ) -> dict:
2679
+ return {"terminalId": terminal_id}
2680
+
2681
+ async def terminal_kill(self, session_id: str, terminal_id: str) -> dict:
2682
+ return {}
2683
+
2684
+ async def terminal_output(self, session_id: str, terminal_id: str) -> dict:
2685
+ return {"output": "", "truncated": False, "exitStatus": None}
2686
+
2687
+ async def terminal_release(self, session_id: str, terminal_id: str) -> dict:
2688
+ return {}
2689
+
2690
+ async def terminal_wait_for_exit(self, session_id: str, terminal_id: str) -> dict:
2691
+ return {"exitCode": 0, "signal": None}
2692
+
2693
+
2694
+ class JSONRPCServer:
2695
+ def __init__(self, adapter: CDHACPAdapter):
2696
+ self.adapter = adapter
2697
+ self.methods = {
2698
+ "initialize": self._handle_initialize,
2699
+ "session/new": self._handle_session_new,
2700
+ "session/load": self._handle_session_load,
2701
+ "session/prompt": self._handle_session_prompt,
2702
+ "session/cancel": self._handle_session_cancel,
2703
+ "session/save": self._handle_session_save,
2704
+ "session/set_mode": self._handle_session_set_mode,
2705
+ "session/clear_todos": self._handle_session_clear_todos,
2706
+ "session/compact": self._handle_session_compact,
2707
+ "session/load_earlier": self._handle_session_load_earlier,
2708
+ "session/ask_user_answer": self._handle_ask_user_answer,
2709
+ "terminal/create": self._handle_terminal_create,
2710
+ "terminal/kill": self._handle_terminal_kill,
2711
+ "terminal/output": self._handle_terminal_output,
2712
+ "terminal/release": self._handle_terminal_release,
2713
+ "terminal/wait_for_exit": self._handle_terminal_wait_for_exit,
2714
+ }
2715
+
2716
+ async def handle_request(self, request: dict):
2717
+ """Handle a JSONRPC request."""
2718
+ method = request.get("method")
2719
+ params = request.get("params", {})
2720
+ req_id = request.get("id")
2721
+
2722
+ handler = self.methods.get(method)
2723
+ if handler is None:
2724
+ return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Method not found: {method}"}, "id": req_id}
2725
+
2726
+ try:
2727
+ result = await handler(params)
2728
+ return {"jsonrpc": "2.0", "result": result, "id": req_id}
2729
+ except Exception as e:
2730
+ return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_id}
2731
+
2732
+ async def _handle_initialize(self, params: dict):
2733
+ return await self.adapter.initialize(
2734
+ params.get("protocolVersion"),
2735
+ params.get("clientCapabilities", {}),
2736
+ params.get("clientInfo", {}),
2737
+ )
2738
+
2739
+ async def _handle_session_new(self, params: dict):
2740
+ return await self.adapter.session_new(
2741
+ params.get("cwd", "."),
2742
+ params.get("mcpServers", []),
2743
+ )
2744
+
2745
+ async def _handle_session_load(self, params: dict):
2746
+ return await self.adapter.session_load(
2747
+ params.get("cwd", "."),
2748
+ params.get("mcpServers", []),
2749
+ params.get("sessionId"),
2750
+ )
2751
+
2752
+ async def _handle_session_save(self, params: dict):
2753
+ return await self.adapter.session_save(
2754
+ params.get("sessionId"),
2755
+ params.get("_meta", {}),
2756
+ )
2757
+
2758
+ async def _handle_session_prompt(self, params: dict):
2759
+ return await self.adapter.session_prompt(
2760
+ params.get("prompt", []),
2761
+ params.get("sessionId"),
2762
+ )
2763
+
2764
+ async def _handle_session_cancel(self, params: dict):
2765
+ return await self.adapter.session_cancel(
2766
+ params.get("sessionId"),
2767
+ params.get("_meta", {}),
2768
+ )
2769
+
2770
+ async def _handle_ask_user_answer(self, params: dict):
2771
+ return self.adapter.resolve_ask_user(
2772
+ params.get("answer", ""),
2773
+ params.get("cancelled", True),
2774
+ params.get("rollback", False),
2775
+ )
2776
+
2777
+ async def _handle_session_set_mode(self, params: dict):
2778
+ return await self.adapter.session_set_mode(
2779
+ params.get("sessionId"),
2780
+ params.get("modeId"),
2781
+ )
2782
+
2783
+ async def _handle_session_clear_todos(self, params: dict):
2784
+ return await self.adapter.session_clear_todos(
2785
+ params.get("sessionId"),
2786
+ )
2787
+
2788
+ async def _handle_session_compact(self, params: dict):
2789
+ return await self.adapter.session_compact(
2790
+ params.get("sessionId"),
2791
+ )
2792
+
2793
+ async def _handle_session_load_earlier(self, params: dict):
2794
+ return await self.adapter.session_load_earlier(
2795
+ params.get("sessionId"),
2796
+ params.get("offset", 0),
2797
+ params.get("limit", 50),
2798
+ )
2799
+
2800
+ async def _handle_terminal_create(self, params: dict):
2801
+ return await self.adapter.terminal_create(
2802
+ params.get("sessionId"),
2803
+ params.get("terminalId"),
2804
+ params.get("command"),
2805
+ params.get("args"),
2806
+ params.get("cwd"),
2807
+ params.get("env"),
2808
+ params.get("outputByteLimit"),
2809
+ )
2810
+
2811
+ async def _handle_terminal_kill(self, params: dict):
2812
+ return await self.adapter.terminal_kill(
2813
+ params.get("sessionId"),
2814
+ params.get("terminalId"),
2815
+ )
2816
+
2817
+ async def _handle_terminal_output(self, params: dict):
2818
+ return await self.adapter.terminal_output(
2819
+ params.get("sessionId"),
2820
+ params.get("terminalId"),
2821
+ )
2822
+
2823
+ async def _handle_terminal_release(self, params: dict):
2824
+ return await self.adapter.terminal_release(
2825
+ params.get("sessionId"),
2826
+ params.get("terminalId"),
2827
+ )
2828
+
2829
+ async def _handle_terminal_wait_for_exit(self, params: dict):
2830
+ return await self.adapter.terminal_wait_for_exit(
2831
+ params.get("sessionId"),
2832
+ params.get("terminalId"),
2833
+ )
2834
+
2835
+
2836
+ async def _main():
2837
+ adapter = CDHACPAdapter()
2838
+ server = JSONRPCServer(adapter)
2839
+
2840
+ # Track in-flight prompt tasks by request id so that:
2841
+ # - session/cancel can target the correct task
2842
+ # - EOF can cancel & await all remaining tasks
2843
+ prompt_tasks: dict[str | int, asyncio.Task] = {}
2844
+
2845
+ async def _run_prompt(req: dict):
2846
+ try:
2847
+ result = await server._handle_session_prompt(req.get("params", {}))
2848
+ return {"jsonrpc": "2.0", "result": result, "id": req.get("id")}
2849
+ except asyncio.CancelledError:
2850
+ raise
2851
+ except Exception as e:
2852
+ import traceback
2853
+ tb = traceback.format_exc()
2854
+ _dump_crash("_run_prompt")
2855
+ print(f"[cdh-agent-acp] ERROR in _run_prompt:\n{tb}", file=sys.stderr, flush=True)
2856
+ debug_log("_run_prompt failed: %s\n%s", e, tb, exc_info=True)
2857
+ return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req.get("id")}
2858
+
2859
+ while True:
2860
+ line = await asyncio.to_thread(sys.stdin.readline)
2861
+ if not line:
2862
+ # EOF: cancel all in-flight prompts and exit
2863
+ for p_req_id, p_task in prompt_tasks.items():
2864
+ if not p_task.done():
2865
+ p_task.cancel()
2866
+ if prompt_tasks:
2867
+ await asyncio.gather(*prompt_tasks.values(), return_exceptions=True)
2868
+ prompt_tasks.clear()
2869
+ break
2870
+
2871
+ line = line.strip()
2872
+ if not line:
2873
+ continue
2874
+
2875
+ try:
2876
+ request = json.loads(line)
2877
+ except json.JSONDecodeError:
2878
+ continue
2879
+
2880
+ requests = request if isinstance(request, list) else [request]
2881
+
2882
+ for req in requests:
2883
+ if not isinstance(req, dict):
2884
+ continue
2885
+
2886
+ # Check if this is a response to a pending server→client request
2887
+ if "id" in req and ("result" in req or "error" in req):
2888
+ req_id = req.get("id")
2889
+ if isinstance(req_id, str) and req_id in adapter._pending_requests:
2890
+ adapter.resolve_pending_request(req_id, req.get("result", {}))
2891
+ continue
2892
+
2893
+ method = req.get("method", "")
2894
+ req_id = req.get("id")
2895
+
2896
+ if method == "session/prompt":
2897
+ # Run prompt in background so main loop stays responsive
2898
+ # to cancel notifications on stdin
2899
+ task = asyncio.create_task(_run_prompt(req))
2900
+ prompt_tasks[req_id] = task
2901
+
2902
+ def _on_prompt_done(t, req=req, rid=req_id):
2903
+ prompt_tasks.pop(rid, None)
2904
+ try:
2905
+ resp = t.result()
2906
+ except asyncio.CancelledError:
2907
+ # Prompt was cancelled (session/cancel). Send a
2908
+ # stopReason=cancelled response so the TUI's pending
2909
+ # session/prompt request resolves and the turn ends
2910
+ # cleanly — otherwise the TUI hangs forever waiting
2911
+ # for a response that never comes.
2912
+ info_log(
2913
+ "[ACP-CANCEL] _on_prompt_done CancelledError raised; "
2914
+ "req_id=%s subagent_fwd=%s",
2915
+ req.get("id"), dict(adapter._subagent_fwd_count),
2916
+ )
2917
+ if req.get("id") is not None:
2918
+ try:
2919
+ print(json.dumps({
2920
+ "jsonrpc": "2.0",
2921
+ "result": {"stopReason": "cancelled"},
2922
+ "id": req["id"],
2923
+ }), flush=True)
2924
+ except (TypeError, ValueError, OSError):
2925
+ pass
2926
+ return
2927
+ except Exception as exc:
2928
+ _dump_crash("_on_prompt_done")
2929
+ debug_log(f"Prompt task raised unhandled exception: {exc}", exc_info=True)
2930
+ resp = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": req.get("id")}
2931
+ if resp.get("id") is not None:
2932
+ try:
2933
+ print(json.dumps(resp), flush=True)
2934
+ except (TypeError, ValueError, OSError) as e:
2935
+ _dump_crash("_on_prompt_done_send")
2936
+ debug_log("_on_prompt_done send failed: %s", e)
2937
+ task.add_done_callback(_on_prompt_done)
2938
+ elif method == "session/cancel":
2939
+ # session/cancel is a notification (no id), so req_id is None.
2940
+ # prompt_tasks is keyed by request id; find any in-flight task.
2941
+ if req_id is not None:
2942
+ target_task = prompt_tasks.get(req_id)
2943
+ else:
2944
+ target_task = next(
2945
+ (t for t in prompt_tasks.values() if not t.done()),
2946
+ None,
2947
+ )
2948
+ info_log(
2949
+ "[ACP-CANCEL] received session/cancel; "
2950
+ "req_id=%s task_exists=%s task_done=%s",
2951
+ req_id, "yes" if target_task else "no",
2952
+ target_task.done() if target_task else "n/a",
2953
+ )
2954
+ if target_task and not target_task.done():
2955
+ adapter.cancel_prompt()
2956
+ info_log("[ACP-CANCEL] calling prompt_task.cancel() now")
2957
+ target_task.cancel()
2958
+ info_log("[ACP-CANCEL] prompt_task.cancel() returned")
2959
+ else:
2960
+ info_log("[ACP-CANCEL] no in-flight prompt_task to cancel")
2961
+ # Notification (no id) — nothing to respond with
2962
+ else:
2963
+ response = await server.handle_request(req)
2964
+ if response.get("id") is not None:
2965
+ try:
2966
+ print(json.dumps(response), flush=True)
2967
+ except (TypeError, ValueError, OSError) as e:
2968
+ _dump_crash("main_loop_send")
2969
+ debug_log("main loop send failed: %s", e)
2970
+
2971
+
2972
+ def main():
2973
+ # Initialise file logging ONCE at adapter entry. Previously the ACP
2974
+ # subprocess never called ``setup_logging`` (only the ``cli`` command
2975
+ # path did), so every ``logger.debug(...)`` / ``debug_log(...)`` call
2976
+ # in the engine/provider/ACP layers was silently dropped and
2977
+ # ``~/.onecode/logs/onecode.log`` stayed empty during live reproductions.
2978
+ # Honour the same ``CDH_LOG_LEVEL`` env var as the CLI so users can
2979
+ # turn on DEBUG diagnostics without code changes.
2980
+ import os
2981
+ from onecode.cli import setup_logging
2982
+ setup_logging(os.environ.get("CDH_LOG_LEVEL", "INFO"))
2983
+ # Sync optimizer params from ~/.cdh/agent_config.yaml before starting
2984
+ from onecode.config import sync_agent_config
2985
+ sync_agent_config()
2986
+ info_log(
2987
+ "[ACP-INIT] cdh_acp adapter starting; log_level=%s pid=%d",
2988
+ os.environ.get("CDH_LOG_LEVEL", "INFO"), os.getpid(),
2989
+ )
2990
+ asyncio.run(_main())
2991
+
2992
+ if __name__ == "__main__":
2993
+ main()