@scemoon/cdh 1.0.0

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 (528) hide show
  1. package/ai-dlc-skill/SKILL.md +75 -0
  2. package/ai-dlc-skill/agents/deliver-agent.md +24 -0
  3. package/ai-dlc-skill/agents/master.md +44 -0
  4. package/ai-dlc-skill/agents/plan-agent.md +23 -0
  5. package/ai-dlc-skill/agents/understand-agent.md +23 -0
  6. package/ai-dlc-skill/agents/verify-agent.md +27 -0
  7. package/ai-dlc-skill/architecture/project-structure.md +78 -0
  8. package/ai-dlc-skill/brownfield/README.md +30 -0
  9. package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
  10. package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
  11. package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
  12. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
  13. package/ai-dlc-skill/components/backend.md +34 -0
  14. package/ai-dlc-skill/components/desktop.md +30 -0
  15. package/ai-dlc-skill/components/mya.md +29 -0
  16. package/ai-dlc-skill/components/native.md +30 -0
  17. package/ai-dlc-skill/components/tta.md +29 -0
  18. package/ai-dlc-skill/components/web.md +31 -0
  19. package/ai-dlc-skill/components/wxa.md +29 -0
  20. package/ai-dlc-skill/contracts/README.md +25 -0
  21. package/ai-dlc-skill/core/adaptive-flow.md +36 -0
  22. package/ai-dlc-skill/core/security.md +29 -0
  23. package/ai-dlc-skill/cross-tool/cline/export.sh +31 -0
  24. package/ai-dlc-skill/cross-tool/copilot/export.sh +47 -0
  25. package/ai-dlc-skill/cross-tool/cursor/export.sh +32 -0
  26. package/ai-dlc-skill/cross-tool/export.sh +22 -0
  27. package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +17 -0
  28. package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +31 -0
  29. package/ai-dlc-skill/cross-tool/onecode/export.sh +48 -0
  30. package/ai-dlc-skill/phases/deliver/entry.md +26 -0
  31. package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
  32. package/ai-dlc-skill/phases/deliver/prompt.md +40 -0
  33. package/ai-dlc-skill/phases/deliver/rules.md +61 -0
  34. package/ai-dlc-skill/phases/plan/entry.md +25 -0
  35. package/ai-dlc-skill/phases/plan/lifecycle.md +204 -0
  36. package/ai-dlc-skill/phases/plan/prompt.md +38 -0
  37. package/ai-dlc-skill/phases/plan/rules.md +25 -0
  38. package/ai-dlc-skill/phases/understand/entry.md +26 -0
  39. package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
  40. package/ai-dlc-skill/phases/understand/prompt.md +41 -0
  41. package/ai-dlc-skill/phases/understand/rules.md +37 -0
  42. package/ai-dlc-skill/phases/verify/entry.md +26 -0
  43. package/ai-dlc-skill/phases/verify/lifecycle.md +192 -0
  44. package/ai-dlc-skill/phases/verify/prompt.md +43 -0
  45. package/ai-dlc-skill/phases/verify/rules.md +73 -0
  46. package/ai-dlc-skill/practices/bdd.md +91 -0
  47. package/ai-dlc-skill/practices/sdd.md +84 -0
  48. package/ai-dlc-skill/practices/tdd.md +95 -0
  49. package/ai-dlc-skill/providers/README.md +114 -0
  50. package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
  51. package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
  52. package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
  53. package/ai-dlc-skill/providers/tcb/deployment.yaml +69 -0
  54. package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
  55. package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
  56. package/ai-dlc-skill/requirements.md +140 -0
  57. package/ai-dlc-skill/skill.yaml +147 -0
  58. package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
  59. package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
  60. package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
  61. package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
  62. package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
  63. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
  64. package/ai-dlc-skill/templates/plan/design.md +49 -0
  65. package/ai-dlc-skill/templates/plan/task-list.md +32 -0
  66. package/ai-dlc-skill/templates/project/README.md +127 -0
  67. package/ai-dlc-skill/templates/project/template.md +119 -0
  68. package/ai-dlc-skill/templates/understand/feature.feature +23 -0
  69. package/ai-dlc-skill/templates/understand/intent.md +29 -0
  70. package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
  71. package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
  72. package/ai-dlc-skill/walkthrough/collect.sh +50 -0
  73. package/ai-dlc-skill/walkthrough/template.md +54 -0
  74. package/ai-dlc-skill/workflows/ai-dlc.yaml +110 -0
  75. package/ai-dlc-skill/workflows/deployment.yaml +199 -0
  76. package/cdh/__init__.py +0 -0
  77. package/cdh/__main__.py +4 -0
  78. package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  81. package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
  82. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  83. package/cdh/cli.py +361 -0
  84. package/cdh/scaffold.py +312 -0
  85. package/cli.js +2 -0
  86. package/onecode/__init__.py +1 -0
  87. package/onecode/__main__.py +4 -0
  88. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  90. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  91. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  92. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  93. package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
  94. package/onecode/agent/__init__.py +0 -0
  95. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
  97. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
  98. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  99. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  100. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  101. package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
  102. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  103. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  104. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  105. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  106. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  107. package/onecode/agent/agents/__init__.py +29 -0
  108. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  109. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  110. package/onecode/agent/agents/types.py +662 -0
  111. package/onecode/agent/attachments.py +113 -0
  112. package/onecode/agent/cdh_loader.py +262 -0
  113. package/onecode/agent/context.py +338 -0
  114. package/onecode/agent/engine.py +1894 -0
  115. package/onecode/agent/hooks.py +86 -0
  116. package/onecode/agent/onecode_agent_acp.py +2203 -0
  117. package/onecode/agent/permissions.py +151 -0
  118. package/onecode/agent/permissions_store.py +62 -0
  119. package/onecode/agent/session.py +186 -0
  120. package/onecode/agent/snapshot.py +145 -0
  121. package/onecode/agent/tools/__init__.py +17 -0
  122. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  123. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  124. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  125. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  126. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  127. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  128. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  129. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  130. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  131. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  132. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  133. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  134. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  135. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  136. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  137. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  138. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  139. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  140. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  141. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  142. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  143. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  144. package/onecode/agent/tools/agent_tools.py +134 -0
  145. package/onecode/agent/tools/apply_patch_tool.py +144 -0
  146. package/onecode/agent/tools/bash_tool.py +91 -0
  147. package/onecode/agent/tools/communication_tools.py +149 -0
  148. package/onecode/agent/tools/config_tool.py +130 -0
  149. package/onecode/agent/tools/cron_tools.py +179 -0
  150. package/onecode/agent/tools/errors.py +17 -0
  151. package/onecode/agent/tools/file_ops.py +187 -0
  152. package/onecode/agent/tools/file_tools.py +226 -0
  153. package/onecode/agent/tools/git_tools.py +88 -0
  154. package/onecode/agent/tools/lsp_tools.py +404 -0
  155. package/onecode/agent/tools/mcp_tools.py +129 -0
  156. package/onecode/agent/tools/permission_handler.py +46 -0
  157. package/onecode/agent/tools/permissions.py +77 -0
  158. package/onecode/agent/tools/protocol.py +20 -0
  159. package/onecode/agent/tools/registry.py +134 -0
  160. package/onecode/agent/tools/sandbox.py +264 -0
  161. package/onecode/agent/tools/schema_validation.py +100 -0
  162. package/onecode/agent/tools/skill_tools.py +52 -0
  163. package/onecode/agent/tools/task_tools.py +249 -0
  164. package/onecode/agent/tools/web_tools.py +170 -0
  165. package/onecode/builtin_skills/git/SKILL.md +64 -0
  166. package/onecode/builtin_skills/git/skill.yaml +7 -0
  167. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  168. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  169. package/onecode/cli.py +755 -0
  170. package/onecode/cloud/__init__.py +0 -0
  171. package/onecode/cloud/base.py +20 -0
  172. package/onecode/codebase/__init__.py +69 -0
  173. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  174. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  175. package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
  176. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  177. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  178. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  179. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  180. package/onecode/codebase/chunker.py +130 -0
  181. package/onecode/codebase/indexer.py +136 -0
  182. package/onecode/codebase/retriever.py +192 -0
  183. package/onecode/codebase/storage.py +130 -0
  184. package/onecode/codebase/tools.py +59 -0
  185. package/onecode/commands.py +276 -0
  186. package/onecode/config.py +285 -0
  187. package/onecode/config_screen.py +594 -0
  188. package/onecode/cron/__init__.py +3 -0
  189. package/onecode/lsp/__init__.py +3 -0
  190. package/onecode/mcp/__init__.py +4 -0
  191. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  192. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  193. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  194. package/onecode/mcp/client.py +193 -0
  195. package/onecode/mcp/manager.py +191 -0
  196. package/onecode/memory/__init__.py +95 -0
  197. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  198. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  199. package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
  200. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  201. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  202. package/onecode/memory/__pycache__/symbolic.cpython-314.pyc +0 -0
  203. package/onecode/memory/backend.py +185 -0
  204. package/onecode/memory/offload.py +114 -0
  205. package/onecode/memory/pyramid.py +202 -0
  206. package/onecode/memory/recall.py +168 -0
  207. package/onecode/memory/symbolic.py +164 -0
  208. package/onecode/models/__init__.py +4 -0
  209. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  210. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  211. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  212. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  213. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  214. package/onecode/models/errors.py +94 -0
  215. package/onecode/models/messages.py +662 -0
  216. package/onecode/models/provider.py +450 -0
  217. package/onecode/models/providers/__init__.py +17 -0
  218. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  219. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  220. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  221. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  222. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  223. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  224. package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
  225. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  226. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  227. package/onecode/models/providers/anthropic_provider.py +224 -0
  228. package/onecode/models/providers/deepseek_provider.py +210 -0
  229. package/onecode/models/providers/glm_provider.py +154 -0
  230. package/onecode/models/providers/minimax_provider.py +154 -0
  231. package/onecode/models/providers/minimaxi_provider.py +363 -0
  232. package/onecode/models/providers/ollama_provider.py +96 -0
  233. package/onecode/models/providers/openai_provider.py +233 -0
  234. package/onecode/models/registry.py +212 -0
  235. package/onecode/server/__init__.py +3 -0
  236. package/onecode/server/app.py +224 -0
  237. package/onecode/skills/__init__.py +11 -0
  238. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  239. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  240. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  241. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  242. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  243. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  244. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  245. package/onecode/skills/argument_substitution.py +39 -0
  246. package/onecode/skills/create.py +56 -0
  247. package/onecode/skills/frontmatter.py +33 -0
  248. package/onecode/skills/loader.py +157 -0
  249. package/onecode/skills/manager.py +75 -0
  250. package/onecode/skills/model.py +43 -0
  251. package/onecode/storage/__init__.py +0 -0
  252. package/onecode/storage/activity.py +64 -0
  253. package/onecode/storage/project.py +54 -0
  254. package/onecode/storage/session.py +113 -0
  255. package/onecode/tasks/__init__.py +26 -0
  256. package/onecode/tasks/manager.py +219 -0
  257. package/onecode/tasks/models.py +240 -0
  258. package/onecode/trace/__init__.py +0 -0
  259. package/onecode/trace/tracer.py +174 -0
  260. package/onecode/utils/__init__.py +0 -0
  261. package/onecode/utils/helpers.py +20 -0
  262. package/onecode-cli.js +2 -0
  263. package/package.json +32 -0
  264. package/pyproject.toml +60 -0
  265. package/run.js +86 -0
  266. package/tui/__init__.py +46 -0
  267. package/tui/__main__.py +4 -0
  268. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  269. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  270. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  271. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  272. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  273. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  274. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  275. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  276. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  277. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  278. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  279. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  280. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  281. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  282. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  283. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  284. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  285. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  286. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  287. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  288. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  289. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  290. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  291. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  292. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  293. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  294. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  295. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  296. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  297. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  298. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  299. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  300. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  301. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  302. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  303. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  304. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  305. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  306. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  307. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  308. package/tui/_loop.py +86 -0
  309. package/tui/_path_fuzzy_search.py +48 -0
  310. package/tui/_path_match.py +141 -0
  311. package/tui/about.py +95 -0
  312. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  313. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  314. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  315. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  316. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  317. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  318. package/tui/acp/agent.py +886 -0
  319. package/tui/acp/api.py +55 -0
  320. package/tui/acp/encode_tool_call_id.py +12 -0
  321. package/tui/acp/messages.py +190 -0
  322. package/tui/acp/prompt.py +110 -0
  323. package/tui/acp/protocol.py +462 -0
  324. package/tui/agent.py +70 -0
  325. package/tui/agent_schema.py +76 -0
  326. package/tui/agents.py +45 -0
  327. package/tui/ansi/__init__.py +1 -0
  328. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  329. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  330. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  331. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  332. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  333. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  334. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  335. package/tui/ansi/_ansi.py +1689 -0
  336. package/tui/ansi/_ansi_colors.py +264 -0
  337. package/tui/ansi/_control_codes.py +37 -0
  338. package/tui/ansi/_keys.py +251 -0
  339. package/tui/ansi/_sgr_styles.py +64 -0
  340. package/tui/ansi/_stream_parser.py +418 -0
  341. package/tui/answer.py +12 -0
  342. package/tui/app.py +1236 -0
  343. package/tui/atomic.py +37 -0
  344. package/tui/cli.py +313 -0
  345. package/tui/code_analyze.py +27 -0
  346. package/tui/complete.py +38 -0
  347. package/tui/constants.py +61 -0
  348. package/tui/conversation_markdown.py +30 -0
  349. package/tui/danger.py +372 -0
  350. package/tui/data/agents/ampcode.com.toml +51 -0
  351. package/tui/data/agents/augmentcode.com.toml +40 -0
  352. package/tui/data/agents/cdh.cloud-dev-harness.toml +22 -0
  353. package/tui/data/agents/claude.com.toml +41 -0
  354. package/tui/data/agents/copilot.github.com.toml +29 -0
  355. package/tui/data/agents/crowai.dev.toml +35 -0
  356. package/tui/data/agents/cursor.com.toml +38 -0
  357. package/tui/data/agents/deepagents.langchain.com +33 -0
  358. package/tui/data/agents/docker.com.toml +59 -0
  359. package/tui/data/agents/geminicli.com.toml +28 -0
  360. package/tui/data/agents/goose.ai.toml +51 -0
  361. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  362. package/tui/data/agents/kimi.com.toml +35 -0
  363. package/tui/data/agents/kiro.dev.toml +42 -0
  364. package/tui/data/agents/openai.com.toml +53 -0
  365. package/tui/data/agents/openclaw.ai.toml +37 -0
  366. package/tui/data/agents/opencode.ai.toml +39 -0
  367. package/tui/data/agents/openhands.dev.toml +45 -0
  368. package/tui/data/agents/stakpak.dev.toml +61 -0
  369. package/tui/data/agents/vibe.mistral.ai.toml +30 -0
  370. package/tui/data/agents/vtcode.dev.toml +62 -0
  371. package/tui/data/images/frog.png +0 -0
  372. package/tui/data/sounds/question.wav +0 -0
  373. package/tui/data/sounds/turn-over.wav +0 -0
  374. package/tui/db.py +190 -0
  375. package/tui/dec.py +332 -0
  376. package/tui/directory.py +234 -0
  377. package/tui/directory_suggester.py +93 -0
  378. package/tui/directory_watcher.py +197 -0
  379. package/tui/format_path.py +25 -0
  380. package/tui/fuzzy.py +140 -0
  381. package/tui/fuzzy_index.py +275 -0
  382. package/tui/gist.py +33 -0
  383. package/tui/history.py +138 -0
  384. package/tui/jsonrpc.py +578 -0
  385. package/tui/menus.py +14 -0
  386. package/tui/message_log.py +69 -0
  387. package/tui/messages.py +139 -0
  388. package/tui/option_content.py +51 -0
  389. package/tui/os.py +0 -0
  390. package/tui/path_complete.py +217 -0
  391. package/tui/path_filter.py +124 -0
  392. package/tui/paths.py +71 -0
  393. package/tui/pill.py +23 -0
  394. package/tui/platform_commands.py +52 -0
  395. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  396. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  397. package/tui/prompt/extract.py +19 -0
  398. package/tui/prompt/resource.py +68 -0
  399. package/tui/protocol.py +28 -0
  400. package/tui/screens/__init__.py +0 -0
  401. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  402. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  403. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  404. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  405. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  406. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  407. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  408. package/tui/screens/action_modal.py +94 -0
  409. package/tui/screens/agent_modal.py +175 -0
  410. package/tui/screens/command_edit_modal.py +74 -0
  411. package/tui/screens/log.py +553 -0
  412. package/tui/screens/main.py +411 -0
  413. package/tui/screens/main.tcss +655 -0
  414. package/tui/screens/permissions.py +404 -0
  415. package/tui/screens/permissions.tcss +72 -0
  416. package/tui/screens/projects.tcss +66 -0
  417. package/tui/screens/projects_app.py +319 -0
  418. package/tui/screens/projects_screen.py +142 -0
  419. package/tui/screens/session_resume_modal.py +172 -0
  420. package/tui/screens/session_resume_modal.tcss +35 -0
  421. package/tui/screens/sessions.py +166 -0
  422. package/tui/screens/sessions.tcss +119 -0
  423. package/tui/screens/settings.py +254 -0
  424. package/tui/screens/settings.tcss +101 -0
  425. package/tui/screens/store.py +500 -0
  426. package/tui/screens/store.tcss +274 -0
  427. package/tui/screens/tool_content_screen.py +59 -0
  428. package/tui/session_tracker.py +120 -0
  429. package/tui/settings.py +353 -0
  430. package/tui/settings_schema.py +431 -0
  431. package/tui/shell.py +304 -0
  432. package/tui/shell_read.py +42 -0
  433. package/tui/slash_command.py +34 -0
  434. package/tui/tui.tcss +246 -0
  435. package/tui/twitter.py +38 -0
  436. package/tui/version.py +80 -0
  437. package/tui/visuals/__init__.py +0 -0
  438. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  439. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  440. package/tui/visuals/columns.py +273 -0
  441. package/tui/widgets/__init__.py +0 -0
  442. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  443. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  444. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  445. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  446. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  447. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  448. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  449. package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
  450. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  451. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  452. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  453. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  454. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  455. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  456. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  457. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  458. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  459. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  460. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  461. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  462. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  463. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  464. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  465. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  466. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  467. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  468. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  469. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  470. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  471. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  472. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  473. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  474. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  475. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  476. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  477. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  478. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  479. package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
  480. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  481. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  482. package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
  483. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  484. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  485. package/tui/widgets/acp_content.py +70 -0
  486. package/tui/widgets/agent_response.py +77 -0
  487. package/tui/widgets/agent_thought.py +168 -0
  488. package/tui/widgets/ask_user.py +264 -0
  489. package/tui/widgets/command_pane.py +223 -0
  490. package/tui/widgets/condensed_path.py +115 -0
  491. package/tui/widgets/context_stats.py +66 -0
  492. package/tui/widgets/conversation.py +2441 -0
  493. package/tui/widgets/danger_warning.py +65 -0
  494. package/tui/widgets/diff_view.py +59 -0
  495. package/tui/widgets/directory_input.py +8 -0
  496. package/tui/widgets/flash.py +81 -0
  497. package/tui/widgets/future_text.py +126 -0
  498. package/tui/widgets/grid_select.py +234 -0
  499. package/tui/widgets/highlighted_textarea.py +180 -0
  500. package/tui/widgets/mandelbrot.py +294 -0
  501. package/tui/widgets/markdown_note.py +13 -0
  502. package/tui/widgets/menu.py +147 -0
  503. package/tui/widgets/modified_files.py +106 -0
  504. package/tui/widgets/non_selectable_label.py +5 -0
  505. package/tui/widgets/note.py +18 -0
  506. package/tui/widgets/path_search.py +492 -0
  507. package/tui/widgets/plan.py +263 -0
  508. package/tui/widgets/project_directory_tree.py +90 -0
  509. package/tui/widgets/project_grid_select.py +35 -0
  510. package/tui/widgets/project_summary.py +33 -0
  511. package/tui/widgets/prompt.py +795 -0
  512. package/tui/widgets/question.py +360 -0
  513. package/tui/widgets/session_grid_select.py +224 -0
  514. package/tui/widgets/session_summary.py +115 -0
  515. package/tui/widgets/session_tabs.py +188 -0
  516. package/tui/widgets/shell_result.py +35 -0
  517. package/tui/widgets/shell_terminal.py +40 -0
  518. package/tui/widgets/side_bar.py +114 -0
  519. package/tui/widgets/slash_complete.py +235 -0
  520. package/tui/widgets/strike_text.py +70 -0
  521. package/tui/widgets/subagent.py +87 -0
  522. package/tui/widgets/terminal.py +577 -0
  523. package/tui/widgets/terminal_tool.py +351 -0
  524. package/tui/widgets/throbber.py +100 -0
  525. package/tui/widgets/tool_call.py +368 -0
  526. package/tui/widgets/user_input.py +23 -0
  527. package/tui/widgets/version.py +5 -0
  528. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,2203 @@
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
+
18
+ logger = logging.getLogger("onecode.agent.cdh_acp")
19
+
20
+
21
+ def _short_path(p: str, project_dir: Path | None = None) -> str:
22
+ """Return *p* relative to *project_dir* if it is an absolute path under it."""
23
+ if not p or not project_dir:
24
+ return p
25
+ try:
26
+ resolved = Path(p).resolve()
27
+ return str(resolved.relative_to(project_dir.resolve()))
28
+ except (ValueError, OSError):
29
+ return p
30
+
31
+
32
+ def debug_log(*args, **kwargs):
33
+ """Debug log via the ``onecode.agent.onecode_acp`` logger.
34
+
35
+ Falls back to stderr if the logging system has not been configured
36
+ yet (e.g. during very early adapter import when ``setup_logging`` has
37
+ not been called). This keeps the function safe to call from any
38
+ module-import-time path without breaking the JSON-RPC stream on stdout.
39
+ """
40
+ if logger.handlers or logging.getLogger().handlers:
41
+ logger.debug(" ".join(str(a) for a in args), **kwargs)
42
+ else:
43
+ print("[onecode]", *args, file=sys.stderr, flush=True)
44
+
45
+ from onecode.agent.cdh_loader import CdhProjectLoader
46
+ from onecode.agent.engine import AgentEngine
47
+ from onecode.agent.permissions_store import PermissionStore
48
+ from onecode.agent.session import AgentSession
49
+ from onecode.config import load_config
50
+ from onecode.models.provider import ProviderRegistry
51
+ from onecode.models.registry import ModelRegistry
52
+ from onecode.models.messages import (
53
+ StreamEventType,
54
+ ToolCategory,
55
+ AgentMessage,
56
+ TextBlock,
57
+ ThinkBlock,
58
+ ToolCall as MsgToolCall,
59
+ ToolResult,
60
+ SubAgentBlock,
61
+ LifecycleStatus,
62
+ get_tool_category,
63
+ )
64
+
65
+ from onecode.models.providers.minimaxi_provider import MiniMaxiProvider
66
+ from onecode.models.providers.minimax_provider import MiniMaxProvider
67
+ from onecode.models.providers.anthropic_provider import AnthropicProvider
68
+ from onecode.models.providers.openai_provider import OpenAIProvider
69
+ from onecode.models.providers.deepseek_provider import DeepSeekProvider
70
+ from onecode.models.providers.glm_provider import GLMProvider
71
+ from onecode.models.providers.ollama_provider import OllamaProvider
72
+
73
+
74
+ _CATEGORY_TO_ACP_KIND: dict[str, str] = {
75
+ ToolCategory.FILE_READ: "read",
76
+ ToolCategory.FILE_WRITE: "edit",
77
+ ToolCategory.FILE_EDIT: "edit",
78
+ ToolCategory.FILE_LIST: "read",
79
+ ToolCategory.FILE_GLOB: "search",
80
+ ToolCategory.FILE_GREP: "search",
81
+ ToolCategory.BASH: "execute",
82
+ ToolCategory.WEB_FETCH: "fetch",
83
+ ToolCategory.WEB_SEARCH: "search",
84
+ ToolCategory.TASK: "other",
85
+ ToolCategory.TASK_MGMT: "other",
86
+ ToolCategory.INTERACTION: "other",
87
+ ToolCategory.UNKNOWN: "other",
88
+ }
89
+
90
+
91
+ def _kind_for_category(cat: ToolCategory) -> str:
92
+ """Map internal ``ToolCategory`` enum to ACP wire-format ``kind`` string.
93
+
94
+ The TUI expects ``"read"``, ``"edit"``, ``"search"``, ``"execute"``,
95
+ ``"fetch"`` or ``"other"`` (see ``tui/acp/protocol.py:ToolCall``).
96
+ """
97
+ return _CATEGORY_TO_ACP_KIND.get(cat, "other")
98
+
99
+
100
+ _DEFAULT_MODES = {
101
+ "currentModeId": "build",
102
+ "availableModes": [
103
+ {"id": "build", "name": "Build", "description": "Full development agent. Edits and shell commands require user approval."},
104
+ {"id": "plan", "name": "Plan", "description": "Read-only planning and analysis. Edits and shell commands require approval."},
105
+ {"id": "solo", "name": "Solo", "description": "Independent mode with plan-first workflow. Edits allowed, shell commands require approval."},
106
+ ],
107
+ }
108
+
109
+ _OPEN_MARKER = "[TOOL_CALL]"
110
+ _CLOSE_MARKER = "[/TOOL_CALL]"
111
+
112
+ # Structured tool call markers (see engine.py MINIMAX_TOOL_CALL_RE).
113
+ # The adapter's stream callback strips these from the user-visible text so
114
+ # raw ``<minimax:tool_call>`` XML never reaches the chat.
115
+ _MINIMAX_OPEN = "<minimax:tool_call>"
116
+ _MINIMAX_CLOSE = "</minimax:tool_call>"
117
+
118
+
119
+ def _extract_text_from_blocks(content: list) -> str:
120
+ """Concatenate the `text` field of each `text` block in a content list."""
121
+ parts: list[str] = []
122
+ for item in content:
123
+ if isinstance(item, dict) and item.get("type") == "text":
124
+ parts.append(item.get("text", ""))
125
+ return "".join(parts)
126
+
127
+
128
+ def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
129
+ """Return position of the matching '}' for the '{' at open_pos.
130
+
131
+ Handles nested braces, single/double-quoted strings, and
132
+ single-line comments. Returns None if no matching brace is found.
133
+ """
134
+ if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
135
+ return None
136
+ depth = 0
137
+ i = open_pos
138
+ n = len(text)
139
+ while i < n:
140
+ c = text[i]
141
+ if c == "{":
142
+ depth += 1
143
+ i += 1
144
+ elif c == "}":
145
+ depth -= 1
146
+ if depth == 0:
147
+ return i
148
+ i += 1
149
+ elif c == '"':
150
+ i += 1
151
+ while i < n and text[i] != '"':
152
+ if text[i] == "\\" and i + 1 < n:
153
+ i += 2
154
+ else:
155
+ i += 1
156
+ i += 1
157
+ elif c == "'":
158
+ i += 1
159
+ while i < n and text[i] != "'":
160
+ if text[i] == "\\" and i + 1 < n:
161
+ i += 2
162
+ else:
163
+ i += 1
164
+ i += 1
165
+ elif c == "/" and i + 1 < n and text[i + 1] == "/":
166
+ while i < n and text[i] != "\n":
167
+ i += 1
168
+ else:
169
+ i += 1
170
+ return None
171
+
172
+
173
+ def _parse_tool_call_body(body: str) -> dict:
174
+ """Parse the body of a [TOOL_CALL]...[/TOOL_CALL] block.
175
+
176
+ Returns a dict with 'name' (str | None) and 'arguments' (dict).
177
+ """
178
+ body = body.strip()
179
+ if not body or not body.startswith("{"):
180
+ return {"name": None, "arguments": {}}
181
+
182
+ body_close = _scan_balanced_braces(body, 0)
183
+ if body_close is None:
184
+ return {"name": None, "arguments": {}}
185
+ inner = body[1:body_close]
186
+
187
+ name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
188
+ name = name_match.group(1) if name_match else None
189
+
190
+ arguments: dict = {}
191
+ args_match = re.search(r'args\s*=>\s*\{', inner)
192
+ if args_match:
193
+ args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
194
+ if args_outer is not None:
195
+ args_body = inner[args_match.end():args_outer]
196
+ for am in re.finditer(
197
+ r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
198
+ ):
199
+ arguments[am.group(1)] = am.group(2)
200
+
201
+ return {"name": name, "arguments": arguments}
202
+
203
+
204
+ def _extract_legacy_tool_call(text: str) -> dict | None:
205
+ """Parse a complete [TOOL_CALL]...[/TOOL_CALL] block from text.
206
+
207
+ Returns a dict with:
208
+ - name: tool name
209
+ - arguments: dict of argument name -> value
210
+ - span: (start, end) of the full [TOOL_CALL]...[/TOOL_CALL]
211
+ Returns None if no complete block is found.
212
+ """
213
+ open_idx = text.find(_OPEN_MARKER)
214
+ if open_idx < 0:
215
+ return None
216
+ body_start = open_idx + len(_OPEN_MARKER)
217
+ close_idx = text.find(_CLOSE_MARKER, body_start)
218
+ if close_idx < 0:
219
+ return None
220
+ body = text[body_start:close_idx]
221
+ span_end = close_idx + len(_CLOSE_MARKER)
222
+
223
+ parsed = _parse_tool_call_body(body)
224
+ parsed["span"] = (open_idx, span_end)
225
+ return parsed
226
+
227
+
228
+ _LANG_BY_EXT: dict[str, str] = {
229
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
230
+ ".jsx": "jsx", ".tsx": "tsx", ".json": "json", ".yml": "yaml",
231
+ ".yaml": "yaml", ".toml": "toml", ".md": "markdown", ".sh": "bash",
232
+ ".html": "html", ".css": "css", ".rs": "rust", ".go": "go",
233
+ ".java": "java", ".kt": "kotlin", ".swift": "swift", ".rb": "ruby",
234
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".sql": "sql",
235
+ }
236
+
237
+
238
+ def _language_for_path(path: str) -> str:
239
+ """Return a Markdown code-fence language hint based on file extension."""
240
+ if not path:
241
+ return ""
242
+ dot = path.rfind(".")
243
+ slash = max(path.rfind("/"), path.rfind("\\"))
244
+ if dot > slash and dot >= 0:
245
+ return _LANG_BY_EXT.get(path[dot:].lower(), "")
246
+ return ""
247
+
248
+
249
+ _STATUS_TO_WIRE: dict[str, str] = {
250
+ "pending": "pending",
251
+ "in_progress": "in_progress",
252
+ "running": "in_progress",
253
+ "complete": "completed",
254
+ "completed": "completed",
255
+ "failed": "failed",
256
+ "cancelled": "failed",
257
+ }
258
+
259
+
260
+ def _wire_status(value: str | None) -> str:
261
+ """Map an internal LifecycleStatus value to the ACP wire literal.
262
+
263
+ The ACP protocol (TUI side) expects ``ToolCallStatus = Literal["pending",
264
+ "in_progress", "completed", "failed"]``; the internal enum uses ``complete``
265
+ and ``cancelled``. This helper normalises everything to a wire-valid value
266
+ and defaults to ``"completed"`` for any unknown value.
267
+ """
268
+ if not value:
269
+ return "completed"
270
+ return _STATUS_TO_WIRE.get(value, "completed")
271
+
272
+
273
+ def _try_pretty_print_json(text: str) -> str:
274
+ """If *text* looks like JSON, pretty-print it; otherwise return as-is.
275
+
276
+ Handles both raw JSON and JSON that was embedded as a string inside
277
+ another JSON value (i.e. with ``\\n``, ``\\"`` etc. as literal escapes).
278
+ """
279
+ stripped = text.strip()
280
+ if not (stripped.startswith("{") or stripped.startswith("[")):
281
+ return text
282
+
283
+ # Direct parse
284
+ try:
285
+ obj = json.loads(stripped)
286
+ return json.dumps(obj, indent=2, ensure_ascii=False)
287
+ except (json.JSONDecodeError, ValueError):
288
+ pass
289
+
290
+ # The text may contain literal escape sequences (e.g. ``\n``, ``\"``)
291
+ # from being nested inside another JSON string. Unescape and retry.
292
+ try:
293
+ unescaped = stripped.encode("utf-8").decode("unicode_escape")
294
+ obj = json.loads(unescaped)
295
+ return json.dumps(obj, indent=2, ensure_ascii=False)
296
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
297
+ return text
298
+
299
+
300
+ def _build_tool_call_content(name: str | None, arguments: dict) -> list:
301
+ """Convert a tool's args into the ACP content blocks the TUI expects.
302
+
303
+ The TUI has dedicated widgets for `diff` (DiffView) and structured text
304
+ (Markdown / Static). JSON-fence fallbacks only kick in for tools we
305
+ don't recognise, so known tools render as their intended visual.
306
+
307
+ Write emits a Markdown block (`path` header + code fence) so the
308
+ content reads as code rather than a noisy all-additions diff.
309
+ Edit keeps the diff block because there are real before/after changes.
310
+ """
311
+ if not arguments:
312
+ return []
313
+
314
+ if name == "Read":
315
+ return []
316
+
317
+ _HEADER_ONLY_TOOLS = frozenset({
318
+ "TaskCreate", "TaskGet", "TaskList", "TaskUpdate", "TaskOutput", "TaskStop",
319
+ "TodoCreate", "TodoList", "TodoComplete", "Glob",
320
+ })
321
+ if name in _HEADER_ONLY_TOOLS:
322
+ return []
323
+
324
+ if name == "Write":
325
+ path = str(arguments.get("path", ""))
326
+ content = str(arguments.get("content", ""))
327
+ if path:
328
+ lang = _language_for_path(path)
329
+ return [{
330
+ "type": "content",
331
+ "content": {
332
+ "type": "text",
333
+ "text": f"📄 {path}\n```{lang}\n{content}\n```",
334
+ },
335
+ }]
336
+
337
+ if name == "Edit":
338
+ path = str(arguments.get("path", ""))
339
+ old = str(arguments.get("old_string", arguments.get("oldText", "")))
340
+ new = str(arguments.get("new_string", arguments.get("newText", "")))
341
+ if path:
342
+ return [{
343
+ "type": "diff",
344
+ "path": path,
345
+ "oldText": old,
346
+ "newText": new,
347
+ }]
348
+
349
+ if name == "Bash":
350
+ cmd = str(arguments.get("command", arguments.get("cmd", "")))
351
+ if cmd:
352
+ return [{
353
+ "type": "content",
354
+ "content": {
355
+ "type": "text",
356
+ "text": f"```bash\n$ {cmd}\n```",
357
+ },
358
+ }]
359
+
360
+ if name == "ApplyPatch":
361
+ patch = str(arguments.get("patch", ""))
362
+ if patch:
363
+ return [{
364
+ "type": "content",
365
+ "content": {"type": "text", "text": f"```diff\n{patch}\n```"},
366
+ }]
367
+
368
+ if name == "Insert":
369
+ path = str(arguments.get("path", ""))
370
+ line = arguments.get("line", -1)
371
+ text = str(arguments.get("text", ""))
372
+ if path:
373
+ lang = _language_for_path(path)
374
+ return [{
375
+ "type": "content",
376
+ "content": {
377
+ "type": "text",
378
+ "text": f"📝 {path} (line {line})\n```{lang}\n{text}\n```",
379
+ },
380
+ }]
381
+
382
+ if name == "UndoEdit":
383
+ path = str(arguments.get("path", ""))
384
+ if path:
385
+ return [{
386
+ "type": "content",
387
+ "content": {"type": "text", "text": f"↩️ Undo edit on {path}"},
388
+ }]
389
+
390
+ if name == "AskUser":
391
+ q = str(arguments.get("question", ""))
392
+ opts = arguments.get("options", [])
393
+ lines = [f"❓ {q}"]
394
+ for o in opts:
395
+ label = o.get("label", "")
396
+ desc = o.get("description", "")
397
+ if desc:
398
+ lines.append(f" • {label} — {desc}")
399
+ else:
400
+ lines.append(f" • {label}")
401
+ return [{
402
+ "type": "content",
403
+ "content": {"type": "text", "text": "\n".join(lines)},
404
+ }] if lines else []
405
+
406
+ args_text = json.dumps(arguments, indent=2, ensure_ascii=False)
407
+ return [{
408
+ "type": "content",
409
+ "content": {"type": "text", "text": f"```json\n{args_text}\n```"},
410
+ }]
411
+
412
+
413
+ def _format_tui_display_text(result_text: str, tool_name: str = "") -> str:
414
+ """Convert internal tool result JSON to user-visible text for TUI.
415
+
416
+ Tools return dicts like ``{"success": true, "path": "..."}`` that are
417
+ meaningful to the LLM but noisy for the user. This function strips
418
+ internal JSON wrappers and returns only what the user should see.
419
+
420
+ Success results that have a ``path`` field render as ``✓ /path``;
421
+ other success results fall through to a compact view of the rest
422
+ of the dict.
423
+
424
+ Task / Todo tools (TaskCreate, TaskUpdate, TaskList, …) are special-
425
+ cased: their verbose ``{"task": {...}}`` / ``{"tasks": [...]}`` results
426
+ are collapsed to a single concise marker (``✓ updated``) since the
427
+ LLM's tool-call args already show what the agent did, and re-printing
428
+ the full task object after every call clutters the conversation view.
429
+ """
430
+ if not result_text:
431
+ return ""
432
+ try:
433
+ parsed = json.loads(result_text)
434
+ except json.JSONDecodeError:
435
+ return result_text
436
+ if not isinstance(parsed, dict):
437
+ return result_text
438
+ if "error" in parsed:
439
+ return str(parsed["error"])
440
+ # Read: show actual file content (code fence + language wrapping in _emit_tool_result)
441
+ if tool_name == "Read":
442
+ return parsed.get("content", "")
443
+ # Task / Todo tools: collapse verbose state echoes to one-liner.
444
+ if tool_name in _STATUS_ONLY_TOOLS:
445
+ return ""
446
+ if parsed.get("success") is True:
447
+ if path := parsed.get("path"):
448
+ return f"✓ {path}"
449
+ visible = {k: v for k, v in parsed.items() if k != "success"}
450
+ if not visible:
451
+ return "✓ done"
452
+ try:
453
+ return json.dumps(visible, ensure_ascii=False)
454
+ except (TypeError, ValueError):
455
+ return str(visible)
456
+ return result_text
457
+
458
+
459
+ _STATUS_ONLY_TOOLS = frozenset({
460
+ "TaskCreate",
461
+ "TaskUpdate",
462
+ "TaskStop",
463
+ "TodoCreate",
464
+ "TodoComplete",
465
+ })
466
+
467
+
468
+ _BARE_LEGACY_RE = re.compile(
469
+ r"\{[^{}]*tool\s*=>\s*\"(?P<name>\w+)\"[^{}]*args\s*=>\s*\{",
470
+ re.DOTALL,
471
+ )
472
+
473
+
474
+ def _find_bare_legacy_tool_call(text: str) -> int | None:
475
+ """Find the start of a bare `{tool => "X", args => { ... }}` body.
476
+
477
+ Unlike `[TOOL_CALL]...[/TOOL_CALL]`, this pattern has no enclosing
478
+ markers — we have to scan to the matching `}` ourselves.
479
+ """
480
+ m = _BARE_LEGACY_RE.search(text)
481
+ if m is None:
482
+ return None
483
+ # Reject matches that look like the inside of a fenced code block.
484
+ if "```" in text[: m.start()]:
485
+ last_fence = text.rfind("```", 0, m.start())
486
+ if last_fence >= 0 and text.count("```", last_fence, m.start()) == 1:
487
+ return None
488
+ return m.start()
489
+
490
+
491
+ def _filter_tool_call_text(text: str) -> str:
492
+ """Remove complete ``[TOOL_CALL]...[/TOOL_CALL]`` and
493
+ ``<minimax:tool_call>...</minimax:tool_call>`` blocks from text.
494
+
495
+ Incomplete blocks (no closing marker) are left in place so the caller
496
+ can hold the buffer and wait for more input.
497
+ """
498
+ out: list[str] = []
499
+ pos = 0
500
+ while pos < len(text):
501
+ parsed = _extract_legacy_tool_call(text[pos:])
502
+ minimax_start = text.find(_MINIMAX_OPEN, pos)
503
+ minimax_end = (
504
+ text.find(_MINIMAX_CLOSE, pos) if minimax_start >= 0 else -1
505
+ )
506
+ minimax_span = (
507
+ (minimax_start, minimax_end + len(_MINIMAX_CLOSE))
508
+ if minimax_start >= 0 and minimax_end >= 0
509
+ else None
510
+ )
511
+
512
+ candidates: list[tuple[int, tuple[int, int]]] = []
513
+ if parsed is not None:
514
+ candidates.append((parsed["span"][0], parsed["span"]))
515
+ if minimax_span is not None:
516
+ candidates.append((minimax_span[0], minimax_span))
517
+
518
+ if not candidates:
519
+ out.append(text[pos:])
520
+ break
521
+
522
+ candidates.sort(key=lambda c: c[0])
523
+ rel_start, span = candidates[0]
524
+ rel_end = span[1]
525
+ abs_start = pos + rel_start
526
+ abs_end = pos + rel_end
527
+ before = text[pos:abs_start]
528
+ if before:
529
+ out.append(before)
530
+ pos = abs_end
531
+ return "".join(out)
532
+
533
+
534
+ def _create_engine(cwd: str, perm_store: PermissionStore | None = None) -> AgentEngine:
535
+ cfg = load_config()
536
+ ModelRegistry.initialize()
537
+ provider_cls = ProviderRegistry.get(cfg.default_provider)
538
+ if provider_cls is None:
539
+ provider_cls = ProviderRegistry.get("minimaxi")
540
+
541
+ class MinimalApp:
542
+ config = cfg
543
+ current_provider = cfg.default_provider
544
+ current_model = cfg.default_model
545
+
546
+ project_dir = Path(cwd).resolve() if cwd else Path.cwd()
547
+ return AgentEngine(MinimalApp(), project_dir=project_dir, perm_store=perm_store)
548
+
549
+
550
+ class TextChunker:
551
+ """对文本流按语义边界分组后发送,减少 ACP 消息频率。
552
+
553
+ 三种模式:
554
+ - "words": 每 N 个分隔符(空格/换行/tab)切一次
555
+ - "line": 每遇到换行切一次
556
+ - "auto": 自动检测 ``` 代码块:代码块内用 line,外部用 words
557
+
558
+ Args:
559
+ send_fn: 收到完整文本块时的回调
560
+ mode: "words" | "line" | "auto"
561
+ words: words 模式下的分隔符计数阈值
562
+ line_max: line 模式下无换行时的强制截断长度
563
+ word_max: words 模式下无分隔符时的强制截断长度
564
+ """
565
+
566
+ def __init__(
567
+ self,
568
+ send_fn: Callable[[str], None],
569
+ mode: str = "auto",
570
+ words: int = 5,
571
+ line_max: int = 500,
572
+ word_max: int = 100,
573
+ ):
574
+ self._buf = ""
575
+ self._send = send_fn
576
+ self._mode = mode
577
+ self._words = words
578
+ self._line_max = line_max
579
+ self._word_max = word_max
580
+
581
+ def append(self, text: str) -> None:
582
+ if not text:
583
+ return
584
+ self._buf += text
585
+ self._process()
586
+
587
+ def flush(self) -> None:
588
+ if self._buf:
589
+ self._send(self._buf)
590
+ self._buf = ""
591
+
592
+ def _process(self) -> None:
593
+ while self._buf:
594
+ in_code = False
595
+ if self._mode == "auto":
596
+ in_code = (self._buf.count("```") % 2 == 1)
597
+
598
+ if in_code or self._mode == "line":
599
+ n = self._buf.find("\n")
600
+ if n >= 0:
601
+ n += 1
602
+ elif len(self._buf) >= self._line_max:
603
+ n = self._line_max
604
+ else:
605
+ break
606
+ else:
607
+ n = self._buf.find("\n")
608
+ if n >= 0:
609
+ n += 1
610
+ else:
611
+ sep_count = 0
612
+ for i, ch in enumerate(self._buf):
613
+ if ch in (' ', '\n', '\t'):
614
+ sep_count += 1
615
+ if sep_count >= self._words:
616
+ n = i + 1
617
+ break
618
+ if n < 0:
619
+ if len(self._buf) >= self._word_max:
620
+ n = self._word_max
621
+ else:
622
+ break
623
+
624
+ chunk = self._buf[:n]
625
+ self._buf = self._buf[n:].lstrip()
626
+ if chunk:
627
+ self._send(chunk)
628
+
629
+
630
+ class CDHACPAdapter:
631
+ """Adapter that translates ACP protocol to CDH AgentEngine."""
632
+
633
+ def __init__(self):
634
+ self.agent = None
635
+ self.session_id = None
636
+ self.tool_calls = {}
637
+ self.in_thinking = False
638
+ self._pending_requests: dict[str, asyncio.Future[dict]] = {}
639
+ self._request_seq = 0
640
+ self._perm_store = PermissionStore()
641
+ self._ask_user_future: asyncio.Future[dict] | None = None
642
+
643
+ def _perm_store_load(self, cwd: str | None = None) -> None:
644
+ """Load permission overrides from ``.cdh/permissions.json``."""
645
+ project_dir = Path(cwd).resolve() if cwd else (self.agent._project_dir if self.agent else Path.cwd())
646
+ cdh_dir = CdhProjectLoader.find_cdh_dir(project_dir)
647
+ if cdh_dir:
648
+ data = CdhProjectLoader.load_permissions(cdh_dir)
649
+ if data:
650
+ self._perm_store = PermissionStore.from_dict(data)
651
+
652
+ def _perm_store_save(self) -> None:
653
+ """Save permission overrides to ``.cdh/permissions.json``."""
654
+ if not self.agent:
655
+ return
656
+ cdh_dir = CdhProjectLoader.find_cdh_dir(self.agent._project_dir)
657
+ if cdh_dir:
658
+ CdhProjectLoader.save_permissions(cdh_dir, self._perm_store.to_dict())
659
+
660
+ def _handle_reset_permission(self, key: str = "") -> dict:
661
+ """Handle /permission slash command.
662
+
663
+ Without argument: clears all overrides.
664
+ With argument (e.g. ``bash``): clears that specific override.
665
+ """
666
+ if key:
667
+ old = self._perm_store.get_override(key)
668
+ if old:
669
+ self._perm_store.clear_override(key)
670
+ setattr(self.agent.current_agent, f"permission_{key}", None)
671
+ msg = f"Permission override `{key}` ({old.value}) cleared."
672
+ else:
673
+ msg = f"No override found for `{key}`."
674
+ else:
675
+ count = len(self._perm_store._overrides)
676
+ self._perm_store.clear_all()
677
+ msg = f"All {count} permission override(s) cleared."
678
+ self._perm_store_save()
679
+ self.send_session_update({
680
+ "sessionUpdate": "agent_message_chunk",
681
+ "content": {"type": "text", "text": f"✅ {msg}"},
682
+ })
683
+ return {"stopReason": "end_turn"}
684
+
685
+ @staticmethod
686
+ def _content_to_blocks(content: str | list) -> list:
687
+ """Convert old-format message content to list of AgentBlock objects."""
688
+ blocks: list = []
689
+ if isinstance(content, str):
690
+ blocks = CDHACPAdapter._text_to_blocks(content)
691
+ elif isinstance(content, list):
692
+ for item in content:
693
+ if not isinstance(item, dict):
694
+ continue
695
+ item_type = item.get("type", "")
696
+ if item_type == "text":
697
+ blocks.append(TextBlock(content=item.get("text", "")))
698
+ elif item_type == "thinking":
699
+ blocks.append(ThinkBlock(content=item.get("thinking", "")))
700
+ elif item_type == "tool_use":
701
+ blocks.append(MsgToolCall(
702
+ id=item.get("id", ""),
703
+ name=item.get("name", ""),
704
+ arguments=item.get("input", {}),
705
+ status=LifecycleStatus(item.get("status", "complete")),
706
+ ))
707
+ elif item_type == "tool_result":
708
+ blocks.append(ToolResult(
709
+ tool_use_id=item.get("tool_use_id", ""),
710
+ content=item.get("content", ""),
711
+ is_error=item.get("is_error", False),
712
+ ))
713
+ elif item_type == "subagent":
714
+ blocks.append(SubAgentBlock(
715
+ id=item.get("id", ""),
716
+ agent_type=item.get("agent_type", "default"),
717
+ prompt=item.get("prompt", ""),
718
+ result=item.get("result", ""),
719
+ status=item.get("status", "complete"),
720
+ ))
721
+ return blocks
722
+
723
+ @staticmethod
724
+ def _text_to_blocks(text: str) -> list:
725
+ """Convert text containing legacy [TOOL_CALL] / <thinking> patterns to blocks.
726
+
727
+ Parses text that may contain:
728
+ - [TOOL_CALL] {tool => "Name", args => { ... }} [/TOOL_CALL] patterns
729
+ - <thinking>...</thinking> tags
730
+ Returns a list of TextBlock, ThinkBlock, and MsgToolCall blocks.
731
+ """
732
+ THINKING_RE = re.compile(r'<think(?:ing)?>(.*?)</think(?:ing)?>', re.DOTALL)
733
+
734
+ blocks: list = []
735
+ remaining = text
736
+ legacy_id = 0
737
+
738
+ while remaining:
739
+ parsed_tool = _extract_legacy_tool_call(remaining)
740
+ tool_start = parsed_tool["span"][0] if parsed_tool else -1
741
+ think_match = THINKING_RE.search(remaining)
742
+
743
+ candidates: list[tuple[int, str]] = []
744
+ if parsed_tool is not None:
745
+ candidates.append((tool_start, "tool"))
746
+ if think_match is not None:
747
+ candidates.append((think_match.start(), "think"))
748
+
749
+ if not candidates:
750
+ break
751
+
752
+ candidates.sort()
753
+ next_start, match_type = candidates[0]
754
+
755
+ before = remaining[:next_start]
756
+ if before.strip():
757
+ blocks.append(TextBlock(content=before))
758
+
759
+ if match_type == "tool":
760
+ assert parsed_tool is not None
761
+ tool_name = parsed_tool["name"]
762
+ span_end = parsed_tool["span"][1]
763
+ if tool_name:
764
+ blocks.append(MsgToolCall(
765
+ id=f"legacy-{legacy_id}",
766
+ name=tool_name,
767
+ arguments=parsed_tool["arguments"],
768
+ status=LifecycleStatus.COMPLETE,
769
+ ))
770
+ legacy_id += 1
771
+ remaining = remaining[span_end:]
772
+ else:
773
+ assert think_match is not None
774
+ blocks.append(ThinkBlock(content=think_match.group(1)))
775
+ remaining = remaining[think_match.end():]
776
+
777
+ if remaining.strip():
778
+ blocks.append(TextBlock(content=remaining))
779
+
780
+ return blocks
781
+
782
+ def send_notification(self, method: str, params: dict):
783
+ """Send a JSONRPC notification to A2TUI."""
784
+ notification = {"jsonrpc": "2.0", "method": method, "params": params}
785
+ print(json.dumps(notification), flush=True)
786
+
787
+ def send_request(self, method: str, params: dict) -> asyncio.Future[dict]:
788
+ """Send a JSONRPC request to A2TUI and return a Future for the response."""
789
+ self._request_seq += 1
790
+ req_id = f"svr_{self._request_seq}"
791
+ future: asyncio.Future[dict] = asyncio.Future()
792
+ self._pending_requests[req_id] = future
793
+ request = {"jsonrpc": "2.0", "method": method, "params": params, "id": req_id}
794
+ print(json.dumps(request), flush=True)
795
+ return future
796
+
797
+ def resolve_pending_request(self, req_id: str, result: dict) -> bool:
798
+ """Resolve a pending request with the given result. Returns True if found."""
799
+ future = self._pending_requests.pop(req_id, None)
800
+ if future is not None and not future.done():
801
+ future.set_result(result)
802
+ return True
803
+ return False
804
+
805
+ def cancel_prompt(self):
806
+ """Synchronously cancel the current prompt (called from main loop)."""
807
+ if self.agent:
808
+ self.agent._cancelled = True
809
+ for child in self.agent._child_engines:
810
+ child._cancelled = True
811
+
812
+ def resolve_ask_user(self, answer: str, cancelled: bool) -> dict:
813
+ """Resolve the pending ask_user request with the user's answer.
814
+
815
+ Called from the main loop when a ``session/ask_user_answer`` request arrives.
816
+ """
817
+ if self._ask_user_future is not None and not self._ask_user_future.done():
818
+ self._ask_user_future.set_result({"answer": answer, "cancelled": cancelled})
819
+ return {"ok": True}
820
+
821
+ def send_session_update(self, update: dict):
822
+ """Send a session/update notification with proper ACP protocol format."""
823
+ self.send_notification("session/update", {
824
+ "sessionId": self.session_id,
825
+ "update": update,
826
+ })
827
+
828
+ def _emit_tool_result(self, block: ToolResult) -> None:
829
+ """Send a tool_result as a tool_call_update, accumulating with existing content."""
830
+ # Look up tool name from the tracked tool_calls entry
831
+ tool_name = self.tool_calls.get(block.tool_use_id, {}).get("title", "")
832
+ # Title may be like "Bash: ls -la" — keep just the leading tool name
833
+ if tool_name and ":" in tool_name:
834
+ tool_name = tool_name.split(":", 1)[0].strip()
835
+ display_text = _format_tui_display_text(block.content, tool_name)
836
+
837
+ # Update title with path/subject from result if not already set
838
+ if block.content and block.tool_use_id in self.tool_calls:
839
+ try:
840
+ parsed = json.loads(block.content)
841
+ except json.JSONDecodeError:
842
+ pass
843
+ else:
844
+ if isinstance(parsed, dict) and parsed.get("success") is True:
845
+ current_title = self.tool_calls[block.tool_use_id].get("title", "")
846
+ if ":" not in current_title:
847
+ if tool_name in ("TaskCreate", "TaskUpdate"):
848
+ task_info = parsed.get("task", {})
849
+ if isinstance(task_info, dict):
850
+ if subject := task_info.get("subject", ""):
851
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {subject}"
852
+ elif path := parsed.get("path"):
853
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
854
+ elif path := parsed.get("path"):
855
+ self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
856
+
857
+ # Wrap multi-line results in a fenced code block for proper rendering.
858
+ # Use bash-style fence for Bash/Glob, detect language for Read.
859
+ if display_text and "\n" in display_text and "```" not in display_text:
860
+ lang = ""
861
+ tool_info = self.tool_calls.get(block.tool_use_id, {})
862
+ tname = tool_info.get("_tool_name", "")
863
+ targs = tool_info.get("_tool_args", {})
864
+ if tname in ("Bash", "Glob"):
865
+ lang = "bash"
866
+ elif tname == "Read" and isinstance(targs, dict):
867
+ path = str(targs.get("path", ""))
868
+ if path:
869
+ lang = _language_for_path(path)
870
+ if lang:
871
+ display_text = f"```{lang}\n{display_text}\n```"
872
+ else:
873
+ display_text = f"```\n{display_text}\n```"
874
+
875
+ content_block = [{
876
+ "type": "content",
877
+ "content": {"type": "text", "text": display_text},
878
+ }] if display_text else []
879
+ existing = self.tool_calls.get(block.tool_use_id, {}).get("content", [])
880
+ update = {
881
+ "sessionUpdate": "tool_call_update",
882
+ "toolCallId": block.tool_use_id,
883
+ "status": "failed" if block.is_error else "completed",
884
+ "content": existing + content_block,
885
+ }
886
+ if block.tool_use_id in self.tool_calls:
887
+ self.tool_calls[block.tool_use_id].update(update)
888
+ else:
889
+ self.tool_calls[block.tool_use_id] = {
890
+ "sessionUpdate": "tool_call",
891
+ "toolCallId": block.tool_use_id,
892
+ "title": "Tool call",
893
+ "kind": "other",
894
+ **update,
895
+ }
896
+ self.send_session_update(self.tool_calls[block.tool_use_id])
897
+
898
+ async def initialize(self, protocol_version: int, client_capabilities: dict, client_info: dict):
899
+ """Handle ACP initialize."""
900
+ return {
901
+ "protocolVersion": 1,
902
+ "agentCapabilities": {
903
+ "loadSession": True,
904
+ "promptCapabilities": {
905
+ "audio": False,
906
+ "embeddedContent": True,
907
+ "image": True,
908
+ },
909
+ },
910
+ "authMethods": [],
911
+ "serverInfo": {
912
+ "name": "cdh-agent",
913
+ "title": "CDH Agent",
914
+ "version": "1.0.0",
915
+ },
916
+ }
917
+
918
+ def _send_available_commands(self) -> None:
919
+ """Send slash commands available in the current agent mode."""
920
+ self.send_session_update({
921
+ "sessionUpdate": "available_commands_update",
922
+ "availableCommands": [
923
+ {"name": "exit", "description": "Exit A2TUI", "input": None},
924
+ {"name": "permission", "description": "Clear permission overrides (use `/permission edit` for specific key)", "input": None},
925
+ ],
926
+ })
927
+
928
+ async def session_new(self, cwd: str, mcp_servers: list):
929
+ """Create new session."""
930
+ cfg = load_config()
931
+ self.agent = _create_engine(cwd, perm_store=self._perm_store)
932
+ self.agent.set_agent(cfg.default_mode)
933
+ self._perm_store_load()
934
+ self._perm_store.apply_to(self.agent.current_agent)
935
+
936
+ session = AgentSession()
937
+ session.name = "New Session"
938
+ session.mode = cfg.default_mode
939
+ session.project = str(Path(cwd).resolve() if cwd else Path.cwd())
940
+ session.model = cfg.default_model
941
+ session.provider = cfg.default_provider
942
+ session.save()
943
+ self.agent.attach_session(session)
944
+ self.session_id = session.id
945
+
946
+ # Restore tasks from project .cdh/ if available
947
+ self.agent.load_tasks_from_project()
948
+
949
+ self._send_available_commands()
950
+
951
+ return {
952
+ "sessionId": self.session_id,
953
+ "modes": {
954
+ "currentModeId": cfg.default_mode,
955
+ "availableModes": _DEFAULT_MODES["availableModes"],
956
+ },
957
+ }
958
+
959
+ async def session_load(self, cwd: str, mcp_servers: list, session_id: str):
960
+ """Load existing session."""
961
+ self._perm_store_load(cwd)
962
+ self.agent = _create_engine(cwd, perm_store=self._perm_store)
963
+ self.session_id = session_id
964
+
965
+ loaded = self.agent.load_session(session_id)
966
+ cfg = load_config()
967
+ self.agent.set_agent(cfg.default_mode)
968
+ self._perm_store.apply_to(self.agent.current_agent)
969
+ self._send_available_commands()
970
+
971
+ # Also restore tasks from project .cdh/ as fallback / supplement
972
+ self.agent.load_tasks_from_project()
973
+
974
+ if not loaded:
975
+ return {"modes": _DEFAULT_MODES}
976
+
977
+ # Walk the in-memory context (preserves list-of-blocks structure)
978
+ # instead of the raw _session.messages dicts, which only carry
979
+ # `content: str`. Engine.chat_stream() stores tool_use / tool_result
980
+ # blocks in context.Message.content, and we need them at replay time.
981
+ for ctx_msg in self.agent.context.messages:
982
+ role = ctx_msg.role
983
+ content = ctx_msg.content
984
+ if role == "user":
985
+ user_text = content if isinstance(content, str) else _extract_text_from_blocks(content)
986
+ self.send_session_update({
987
+ "sessionUpdate": "user_message_chunk",
988
+ "content": {"type": "text", "text": user_text},
989
+ })
990
+ elif role == "assistant":
991
+ if isinstance(content, str):
992
+ blocks = self._content_to_blocks(content)
993
+ else:
994
+ blocks = self._content_to_blocks(content)
995
+
996
+ for block in blocks:
997
+ if isinstance(block, TextBlock):
998
+ filtered_text = _filter_tool_call_text(block.content)
999
+ if filtered_text.strip():
1000
+ self.send_session_update({
1001
+ "sessionUpdate": "agent_message_chunk",
1002
+ "content": {"type": "text", "text": filtered_text},
1003
+ })
1004
+ elif isinstance(block, ThinkBlock):
1005
+ filtered_thought = _filter_tool_call_text(block.content)
1006
+ if filtered_thought.strip():
1007
+ self.send_session_update({
1008
+ "sessionUpdate": "agent_thought_chunk",
1009
+ "content": {"type": "text", "text": filtered_thought},
1010
+ })
1011
+ elif isinstance(block, MsgToolCall):
1012
+ tool_kind = _kind_for_category(get_tool_category(block.name))
1013
+ content_blocks = _build_tool_call_content(
1014
+ block.name, block.arguments or {}
1015
+ )
1016
+ title = block.name
1017
+ args = block.arguments or {}
1018
+ if path := args.get("path", ""):
1019
+ title = f"{block.name}: {_short_path(path, self.agent._project_dir)}"
1020
+ elif block.name == "Bash":
1021
+ cmd = str(args.get("command", ""))
1022
+ title = f"Bash: {cmd}"
1023
+ elif block.name == "TaskCreate":
1024
+ subject = str(args.get("subject", ""))
1025
+ if subject:
1026
+ title = f"TaskCreate: {subject}"
1027
+ elif block.name == "TaskUpdate":
1028
+ subject = str(args.get("subject", ""))
1029
+ if subject:
1030
+ title = f"TaskUpdate: {subject}"
1031
+ elif block.name in ("TaskGet", "TaskStop", "TaskOutput"):
1032
+ tid = str(args.get("taskId", args.get("task_id", "")))
1033
+ if tid:
1034
+ title = f"{block.name}: {tid}"
1035
+ elif block.name == "TodoCreate":
1036
+ text = str(args.get("text", ""))
1037
+ if text:
1038
+ title = f"TodoCreate: {text[:40]}"
1039
+ elif block.name == "Glob":
1040
+ pattern = str(args.get("pattern", ""))
1041
+ if pattern:
1042
+ title = f"Glob: {pattern}"
1043
+ self.tool_calls[block.id] = {
1044
+ "sessionUpdate": "tool_call",
1045
+ "toolCallId": block.id,
1046
+ "title": title,
1047
+ "kind": tool_kind,
1048
+ "status": _wire_status(block.status.value),
1049
+ "content": content_blocks,
1050
+ "_tool_name": block.name,
1051
+ "_tool_args": block.arguments or {},
1052
+ }
1053
+ self.send_session_update(self.tool_calls[block.id])
1054
+ elif isinstance(block, ToolResult):
1055
+ self._emit_tool_result(block)
1056
+ elif isinstance(block, SubAgentBlock):
1057
+ content_block = [{
1058
+ "type": "content",
1059
+ "content": {"type": "text", "text": block.result},
1060
+ }] if block.result else []
1061
+ self.send_session_update({
1062
+ "sessionUpdate": "subagent_start",
1063
+ "subagentId": block.id,
1064
+ "agentType": block.agent_type,
1065
+ })
1066
+ if block.result:
1067
+ self.send_session_update({
1068
+ "sessionUpdate": "subagent_chunk",
1069
+ "subagentId": block.id,
1070
+ "text": block.result,
1071
+ })
1072
+ self.send_session_update({
1073
+ "sessionUpdate": "subagent_end",
1074
+ "subagentId": block.id,
1075
+ "agentType": block.agent_type,
1076
+ })
1077
+ elif role == "tool":
1078
+ if isinstance(content, list):
1079
+ for item in content:
1080
+ if isinstance(item, dict) and item.get("type") == "tool_result":
1081
+ tr = ToolResult(
1082
+ tool_use_id=item.get("tool_use_id", ""),
1083
+ content=item.get("content", ""),
1084
+ is_error=item.get("is_error", False),
1085
+ )
1086
+ self._emit_tool_result(tr)
1087
+ elif isinstance(content, dict) and content.get("type") == "tool_result":
1088
+ tr = ToolResult(
1089
+ tool_use_id=content.get("tool_use_id", ""),
1090
+ content=content.get("content", ""),
1091
+ is_error=content.get("is_error", False),
1092
+ )
1093
+ self._emit_tool_result(tr)
1094
+
1095
+ return {
1096
+ "modes": {
1097
+ "currentModeId": cfg.default_mode,
1098
+ "availableModes": _DEFAULT_MODES["availableModes"],
1099
+ },
1100
+ }
1101
+
1102
+ def _make_stream_callback(self):
1103
+ """Create a thinking-aware streaming callback for real-time token output.
1104
+
1105
+ Buffers text deltas from the provider, detects <thinking>...</thinking>,
1106
+ [TOOL_CALL]...[/TOOL_CALL], <minimax:tool_call>...</minimax:tool_call>,
1107
+ and bare `{tool => "X", args => { ... }}` boundaries (all cross-chunk
1108
+ tolerant), strips them from the text buffer so they don't appear as
1109
+ raw agent messages, and routes remaining content to the appropriate
1110
+ ACP session update type.
1111
+
1112
+ Tool call notifications are sent by TOOL_CALL_START/TOOL_CALL_COMPLETE
1113
+ events from the engine, not from text parsing here.
1114
+ """
1115
+ # Safety cap on the held buffer: if a model emits an unclosed
1116
+ # ``<thinking>`` / ``[TOOL_CALL]`` / ``<minimax:tool_call>`` tag
1117
+ # and then the stream is truncated (network drop, cancel,
1118
+ # provider error before the close marker arrives), the buffer
1119
+ # would otherwise grow forever and every subsequent chunk
1120
+ # would be silently swallowed. When the buffer exceeds this
1121
+ # cap we give up on detecting markers and flush the held text
1122
+ # as a plain message so the user still sees *something*.
1123
+ _MAX_HELD_BYTES = 64 * 1024
1124
+
1125
+ chunker = getattr(self, "_text_chunker", None)
1126
+ _direct_send = self.send_session_update # fallback when chunker not configured
1127
+ text_buffer = ""
1128
+ in_thinking = False
1129
+ in_tool_call = False
1130
+ in_minimax_tool_call = False
1131
+ in_bare_tool_call = False
1132
+ bare_tool_start = 0
1133
+ thinking_sent_len = 0 # how much of text_buffer has been sent to TUI
1134
+
1135
+ def _flush_held_buffer() -> None:
1136
+ """Force-emit the held buffer as a plain message and reset
1137
+ all "in marker" flags. Used by the watchdog below and by
1138
+ the early-exit path when a turn ends without a close
1139
+ marker.
1140
+ """
1141
+ nonlocal text_buffer, thinking_sent_len
1142
+ nonlocal in_thinking, in_tool_call
1143
+ nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
1144
+ if chunker:
1145
+ chunker.flush()
1146
+ if text_buffer.strip():
1147
+ _direct_send({
1148
+ "sessionUpdate": "agent_message_chunk",
1149
+ "content": {"type": "text", "text": text_buffer},
1150
+ })
1151
+ text_buffer = ""
1152
+ thinking_sent_len = 0
1153
+ in_thinking = in_tool_call = False
1154
+ in_minimax_tool_call = in_bare_tool_call = False
1155
+ bare_tool_start = 0
1156
+
1157
+ def _emit_message(text: str) -> None:
1158
+ if not text:
1159
+ return
1160
+ if chunker:
1161
+ chunker.append(text)
1162
+ else:
1163
+ _direct_send({
1164
+ "sessionUpdate": "agent_message_chunk",
1165
+ "content": {"type": "text", "text": text},
1166
+ })
1167
+
1168
+ def _safe_start(s: str) -> str:
1169
+ """Return text before any partial <thinking / <think or [TOOL_CALL] or
1170
+ <minimax:tool_call> tag at buffer end."""
1171
+ tags = ("<thinking>", "<think>", "[TOOL_CALL]", _MINIMAX_OPEN)
1172
+ cut = len(s)
1173
+ for tag in tags:
1174
+ for i in range(len(tag) - 1, 0, -1):
1175
+ if s.endswith(tag[:i]):
1176
+ cut = min(cut, len(s) - i)
1177
+ break
1178
+ return s[:cut]
1179
+
1180
+ def on_chunk(text: str):
1181
+ nonlocal text_buffer, thinking_sent_len, in_thinking, in_tool_call
1182
+ nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
1183
+ text_buffer += text
1184
+
1185
+ # Watchdog: if a marker opened but the close never arrived
1186
+ # and the buffer has grown past the safety cap, give up
1187
+ # and emit the held text as a plain message. The model
1188
+ # is either malformed or the stream was truncated; either
1189
+ # way the user should see *something* rather than a
1190
+ # silently held buffer that never resolves.
1191
+ if (
1192
+ in_thinking
1193
+ or in_tool_call
1194
+ or in_minimax_tool_call
1195
+ or in_bare_tool_call
1196
+ ) and len(text_buffer) > _MAX_HELD_BYTES:
1197
+ _flush_held_buffer()
1198
+ # After flushing, fall through into the normal
1199
+ # scanning loop on the now-empty buffer.
1200
+
1201
+ while text_buffer:
1202
+ if in_thinking:
1203
+ close_thinking = text_buffer.find("</thinking>")
1204
+ close_think = (
1205
+ text_buffer.find("</think>")
1206
+ if close_thinking < 0 else -1
1207
+ )
1208
+ idx = close_thinking if close_thinking >= 0 else close_think
1209
+ close_len = (
1210
+ len("</thinking>") if close_thinking >= 0
1211
+ else len("</think>") if close_think >= 0 else 0
1212
+ )
1213
+ if idx >= 0:
1214
+ thinking = text_buffer[:idx]
1215
+ if thinking:
1216
+ # Send any remaining partial before the close
1217
+ if thinking_sent_len < len(thinking):
1218
+ partial = thinking[thinking_sent_len:]
1219
+ self.send_session_update({
1220
+ "sessionUpdate": "agent_thought_chunk",
1221
+ "content": {"type": "text", "text": partial},
1222
+ })
1223
+ # Persist COMPLETE thinking to engine context
1224
+ on_thinking = getattr(self.agent, "on_thinking", None)
1225
+ if on_thinking is not None:
1226
+ try:
1227
+ on_thinking(thinking)
1228
+ except Exception:
1229
+ debug_log("on_thinking callback failed", exc_info=True)
1230
+ text_buffer = text_buffer[idx + close_len:]
1231
+ thinking_sent_len = 0
1232
+ in_thinking = False
1233
+ else:
1234
+ # Stream partial thinking content incrementally to TUI
1235
+ if thinking_sent_len < len(text_buffer):
1236
+ partial = text_buffer[thinking_sent_len:]
1237
+ self.send_session_update({
1238
+ "sessionUpdate": "agent_thought_chunk",
1239
+ "content": {"type": "text", "text": partial},
1240
+ })
1241
+ thinking_sent_len = len(text_buffer)
1242
+ break
1243
+ elif in_tool_call:
1244
+ close_idx = text_buffer.find(_CLOSE_MARKER)
1245
+ if close_idx < 0:
1246
+ # No close marker yet — hold the whole buffer.
1247
+ break
1248
+ # Strip [TOOL_CALL]...[/TOOL_CALL] from text buffer.
1249
+ # The engine emits TOOL_CALL_START/COMPLETE separately.
1250
+ text_buffer = text_buffer[close_idx + len(_CLOSE_MARKER):]
1251
+ in_tool_call = False
1252
+ elif in_minimax_tool_call:
1253
+ close_idx = text_buffer.find(_MINIMAX_CLOSE)
1254
+ if close_idx < 0:
1255
+ # No close marker yet — hold the whole buffer.
1256
+ break
1257
+ # Strip <minimax:tool_call>...</minimax:tool_call> from
1258
+ # the text buffer. The engine emits the structured
1259
+ # TOOL_CALL_START/COMPLETE events separately.
1260
+ text_buffer = text_buffer[close_idx + len(_MINIMAX_CLOSE):]
1261
+ in_minimax_tool_call = False
1262
+ elif in_bare_tool_call:
1263
+ m = _BARE_LEGACY_RE.match(text_buffer, bare_tool_start)
1264
+ if m is None:
1265
+ # Pattern got invalidated — discard the held span.
1266
+ text_buffer = text_buffer[bare_tool_start:]
1267
+ in_bare_tool_call = False
1268
+ bare_tool_start = 0
1269
+ break
1270
+ args_open = text_buffer.find("{", m.end() - 1)
1271
+ if args_open < 0:
1272
+ break
1273
+ args_close = _scan_balanced_braces(text_buffer, args_open)
1274
+ if args_close is None:
1275
+ # Need more chunks to close the inner args.
1276
+ break
1277
+ outer_close = _scan_balanced_braces(text_buffer, bare_tool_start)
1278
+ if outer_close is None or outer_close <= args_close:
1279
+ break
1280
+ text_buffer = text_buffer[outer_close + 1:]
1281
+ in_bare_tool_call = False
1282
+ bare_tool_start = 0
1283
+ else:
1284
+ tc_idx = text_buffer.find(_OPEN_MARKER)
1285
+ minimax_idx = text_buffer.find(_MINIMAX_OPEN)
1286
+ think_idx = text_buffer.find("<thinking>")
1287
+ if think_idx < 0:
1288
+ think_short_idx = text_buffer.find("<think>")
1289
+ else:
1290
+ think_short_idx = -1
1291
+ bare_idx = _find_bare_legacy_tool_call(text_buffer)
1292
+
1293
+ # Pick the earliest opener among all known markers.
1294
+ candidates: list[tuple[int, str]] = []
1295
+ if tc_idx >= 0:
1296
+ candidates.append((tc_idx, "legacy"))
1297
+ if minimax_idx >= 0:
1298
+ candidates.append((minimax_idx, "minimax"))
1299
+ if think_idx >= 0:
1300
+ candidates.append((think_idx, "think"))
1301
+ if think_short_idx >= 0:
1302
+ candidates.append((think_short_idx, "think_short"))
1303
+ if bare_idx is not None:
1304
+ candidates.append((bare_idx, "bare"))
1305
+
1306
+ if not candidates:
1307
+ safe = _safe_start(text_buffer)
1308
+ if safe:
1309
+ _emit_message(safe)
1310
+ text_buffer = text_buffer[len(safe):]
1311
+ if not _safe_start(text_buffer):
1312
+ text_buffer = ""
1313
+ continue
1314
+
1315
+ candidates.sort()
1316
+ next_idx, kind = candidates[0]
1317
+
1318
+ if next_idx > 0:
1319
+ _emit_message(text_buffer[:next_idx])
1320
+
1321
+ if kind == "minimax":
1322
+ text_buffer = text_buffer[next_idx + len(_MINIMAX_OPEN):]
1323
+ in_minimax_tool_call = True
1324
+ elif kind == "legacy":
1325
+ text_buffer = text_buffer[next_idx + len(_OPEN_MARKER):]
1326
+ in_tool_call = True
1327
+ elif kind in ("think", "think_short"):
1328
+ tag_len = len("<thinking>") if kind == "think" else len("<think>")
1329
+ text_buffer = text_buffer[next_idx + tag_len:]
1330
+ in_thinking = True
1331
+ else:
1332
+ # Bare legacy: peek whether the closing brace is
1333
+ # already in the buffer.
1334
+ m = _BARE_LEGACY_RE.match(text_buffer, next_idx)
1335
+ args_open = text_buffer.find("{", m.end() - 1) if m else -1
1336
+ args_close = (
1337
+ _scan_balanced_braces(text_buffer, args_open)
1338
+ if args_open >= 0 else None
1339
+ )
1340
+ outer_close = (
1341
+ _scan_balanced_braces(text_buffer, next_idx)
1342
+ if args_close is not None else None
1343
+ )
1344
+ if (
1345
+ m is not None and args_open >= 0
1346
+ and args_close is not None
1347
+ and outer_close is not None
1348
+ and outer_close > args_close
1349
+ ):
1350
+ text_buffer = text_buffer[outer_close + 1:]
1351
+ else:
1352
+ in_bare_tool_call = True
1353
+ bare_tool_start = next_idx
1354
+ break
1355
+
1356
+ return on_chunk
1357
+
1358
+ async def session_prompt(self, prompt: list, session_id: str):
1359
+ """Send prompt to agent and stream results."""
1360
+ if self.agent is None:
1361
+ debug_log("session_prompt called but self.agent is None")
1362
+ return {"stopReason": "error", "message": "No agent initialized"}
1363
+
1364
+ debug_log(
1365
+ "session_prompt start session=%s prompt_blocks=%d",
1366
+ session_id, len(prompt),
1367
+ )
1368
+
1369
+ # Collect ALL content blocks from the prompt (text, resource, etc.)
1370
+ # instead of only extracting the text portion.
1371
+ user_content: list[dict] = []
1372
+ for block in prompt:
1373
+ btype = block.get("type", "text")
1374
+ if btype == "text":
1375
+ user_content.append(block)
1376
+ elif btype == "resource":
1377
+ user_content.append(block)
1378
+ elif btype == "image":
1379
+ user_content.append(block)
1380
+ else:
1381
+ # Pass through unknown types, the context layer will handle them
1382
+ user_content.append(block)
1383
+
1384
+ # ── Slash-command handling ─────────────────────────────
1385
+ if user_content and user_content[0].get("type") == "text":
1386
+ text = user_content[0].get("text", "").strip()
1387
+ if text == "/permission":
1388
+ return self._handle_reset_permission()
1389
+ if text.startswith("/permission "):
1390
+ key = text.split("/permission ", 1)[1].strip()
1391
+ return self._handle_reset_permission(key)
1392
+
1393
+ # Fresh per-turn tracking (tool_calls accumulates across one turn only)
1394
+ self.tool_calls = {}
1395
+ self.agent._cancelled = False
1396
+
1397
+ # Create chunkers for agent_message_chunk and agent_thought_chunk
1398
+ self._text_chunker = TextChunker(
1399
+ send_fn=lambda text: self.send_session_update({
1400
+ "sessionUpdate": "agent_message_chunk",
1401
+ "content": {"type": "text", "text": text},
1402
+ }),
1403
+ mode="auto",
1404
+ words=5,
1405
+ )
1406
+ self._thought_chunker = TextChunker(
1407
+ send_fn=lambda text: self.send_session_update({
1408
+ "sessionUpdate": "agent_thought_chunk",
1409
+ "content": {"type": "text", "text": text},
1410
+ }),
1411
+ mode="auto",
1412
+ words=5,
1413
+ )
1414
+ self.agent.on_text_chunk = self._make_stream_callback()
1415
+ # Persist thinking blocks into the engine's context so they survive
1416
+ # session reload (otherwise the TUI only sees them during streaming).
1417
+ self.agent.on_thinking = lambda text: self.agent._pending_thinking_blocks.append(text)
1418
+
1419
+ # Track in-progress tool call args for incremental display
1420
+ _incremental_args: dict[str, str] = {}
1421
+ _last_sent_len: dict[str, int] = {}
1422
+ _ARGS_THROTTLE_CHARS = 50
1423
+
1424
+ def _on_tool_call_delta(call_id: str, name: str, args_delta: str) -> None:
1425
+ if args_delta:
1426
+ _incremental_args[call_id] = _incremental_args.get(call_id, "") + args_delta
1427
+ display_args = _incremental_args[call_id]
1428
+ # Throttle: only send when accumulated enough new chars
1429
+ if len(display_args) - _last_sent_len.get(call_id, 0) < _ARGS_THROTTLE_CHARS:
1430
+ return
1431
+ _last_sent_len[call_id] = len(display_args)
1432
+ try:
1433
+ parsed = json.loads(display_args)
1434
+ display_text = json.dumps(parsed, indent=2, ensure_ascii=False)
1435
+ except (json.JSONDecodeError, TypeError):
1436
+ display_text = display_args
1437
+ self.send_session_update({
1438
+ "sessionUpdate": "tool_call_update",
1439
+ "toolCallId": call_id,
1440
+ "title": name or "Tool call",
1441
+ "status": "in_progress",
1442
+ "content": [{
1443
+ "type": "content",
1444
+ "content": {"type": "text", "text": f"```json\n{display_text}\n```"},
1445
+ }] if display_text else [],
1446
+ })
1447
+
1448
+ self.agent.on_tool_call_delta = _on_tool_call_delta
1449
+ try:
1450
+ async for event in self.agent.chat_stream(user_content):
1451
+ if event.type == StreamEventType.TEXT_DELTA:
1452
+ self._text_chunker.append(event.text)
1453
+ elif event.type == StreamEventType.THINKING:
1454
+ self._thought_chunker.append(event.thinking)
1455
+ elif event.type == StreamEventType.TOOL_CALL_START:
1456
+ self._text_chunker.flush()
1457
+ self._thought_chunker.flush()
1458
+ tool_kind = _kind_for_category(event.tool_category)
1459
+ existing = self.tool_calls.get(event.tool_id, {})
1460
+ self.tool_calls[event.tool_id] = {
1461
+ "sessionUpdate": "tool_call",
1462
+ "toolCallId": event.tool_id,
1463
+ "title": event.tool_name,
1464
+ "kind": tool_kind,
1465
+ "status": "in_progress",
1466
+ "content": existing.get("content", []),
1467
+ "_tool_name": event.tool_name,
1468
+ }
1469
+ self.send_session_update(self.tool_calls[event.tool_id])
1470
+ elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
1471
+ if event.tool_id in self.tool_calls:
1472
+ title = event.tool_name
1473
+ if event.tool_args:
1474
+ if path := event.tool_args.get("path", ""):
1475
+ title = f"{event.tool_name}: {_short_path(path, self.agent._project_dir)}"
1476
+ elif event.tool_name == "Bash":
1477
+ cmd = str(event.tool_args.get("command", ""))
1478
+ title = f"Bash: {cmd}"
1479
+ elif event.tool_name == "TaskCreate":
1480
+ subject = str(event.tool_args.get("subject", ""))
1481
+ if subject:
1482
+ title = f"TaskCreate: {subject}"
1483
+ elif event.tool_name == "TaskUpdate":
1484
+ subject = str(event.tool_args.get("subject", ""))
1485
+ if subject:
1486
+ title = f"TaskUpdate: {subject}"
1487
+ elif event.tool_name in ("TaskGet", "TaskStop", "TaskOutput"):
1488
+ tid = str(event.tool_args.get("taskId", event.tool_args.get("task_id", "")))
1489
+ if tid:
1490
+ title = f"{event.tool_name}: {tid}"
1491
+ elif event.tool_name == "TodoCreate":
1492
+ text = str(event.tool_args.get("text", ""))
1493
+ if text:
1494
+ title = f"TodoCreate: {text[:40]}"
1495
+ elif event.tool_name == "Glob":
1496
+ pattern = str(event.tool_args.get("pattern", ""))
1497
+ if pattern:
1498
+ title = f"Glob: {pattern}"
1499
+ content = _build_tool_call_content(
1500
+ event.tool_name, event.tool_args
1501
+ )
1502
+ debug_log(
1503
+ "TOOL_CALL_COMPLETE %s id=%s args_keys=%s title=%s content_blocks=%d",
1504
+ event.tool_name, event.tool_id,
1505
+ list(event.tool_args.keys()), title,
1506
+ len(content),
1507
+ )
1508
+ else:
1509
+ content = []
1510
+ debug_log(
1511
+ "TOOL_CALL_COMPLETE %s id=%s NO args",
1512
+ event.tool_name, event.tool_id,
1513
+ )
1514
+ self.tool_calls[event.tool_id].update({
1515
+ "sessionUpdate": "tool_call_update",
1516
+ "toolCallId": event.tool_id,
1517
+ "title": title,
1518
+ "status": "pending",
1519
+ "content": content,
1520
+ "_tool_name": event.tool_name,
1521
+ "_tool_args": event.tool_args,
1522
+ })
1523
+ self.send_session_update(self.tool_calls[event.tool_id])
1524
+ elif event.type == StreamEventType.TOOL_RESULT:
1525
+ status = "failed" if event.result_is_error else "completed"
1526
+ tname = self.tool_calls.get(event.tool_id, {}).get("_tool_name", "")
1527
+ display_text = _format_tui_display_text(
1528
+ event.result_content or "", tname,
1529
+ )
1530
+
1531
+ # Update title with path/subject from result if not already set
1532
+ if event.result_content and event.tool_id in self.tool_calls:
1533
+ try:
1534
+ parsed = json.loads(event.result_content)
1535
+ except json.JSONDecodeError:
1536
+ pass
1537
+ else:
1538
+ if isinstance(parsed, dict) and parsed.get("success") is True:
1539
+ current_title = self.tool_calls[event.tool_id].get("title", "")
1540
+ if ":" not in current_title:
1541
+ tname = self.tool_calls[event.tool_id].get("_tool_name", "")
1542
+ if tname in ("TaskCreate", "TaskUpdate"):
1543
+ task_info = parsed.get("task", {})
1544
+ if isinstance(task_info, dict):
1545
+ if subject := task_info.get("subject", ""):
1546
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {subject}"
1547
+ elif path := parsed.get("path"):
1548
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
1549
+ elif path := parsed.get("path"):
1550
+ self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
1551
+
1552
+ # Add "denied" hint for reject-always overrides
1553
+ if event.result_is_error and display_text and "denied" in display_text:
1554
+ display_text += "\n\n💡 Use `/permission` to clear a 'reject always' override."
1555
+
1556
+ # TUI now renders all text content as Markdown.
1557
+ # Wrap multi-line results in a fenced code block so they
1558
+ # display as a code block rather than a raw paragraph.
1559
+ # For Read results, detect language from the file path.
1560
+ # For Bash/Glob, use bash-style fence.
1561
+ # Skip if already contains a code fence to avoid nesting.
1562
+ if display_text and "\n" in display_text and "```" not in display_text:
1563
+ lang = ""
1564
+ tool_info = self.tool_calls.get(event.tool_id, {})
1565
+ tname = tool_info.get("_tool_name", "")
1566
+ targs = tool_info.get("_tool_args", {})
1567
+ if tname in ("Bash", "Glob"):
1568
+ lang = "bash"
1569
+ elif tname == "Read" and isinstance(targs, dict):
1570
+ path = str(targs.get("path", ""))
1571
+ if path:
1572
+ lang = _language_for_path(path)
1573
+ if lang:
1574
+ display_text = f"```{lang}\n{display_text}\n```"
1575
+ else:
1576
+ display_text = f"```\n{display_text}\n```"
1577
+ content_block = [{
1578
+ "type": "content",
1579
+ "content": {"type": "text", "text": display_text},
1580
+ }] if display_text else []
1581
+ if event.tool_id in self.tool_calls:
1582
+ existing_content = self.tool_calls[event.tool_id].get("content", [])
1583
+ else:
1584
+ existing_content = []
1585
+ debug_log(
1586
+ "TOOL_RESULT id=%s status=%s result_len=%d display_len=%d existing_blocks=%d new_blocks=%d",
1587
+ event.tool_id, status,
1588
+ len(event.result_content or ""), len(display_text),
1589
+ len(existing_content), len(content_block),
1590
+ )
1591
+ update = {
1592
+ "sessionUpdate": "tool_call_update",
1593
+ "toolCallId": event.tool_id,
1594
+ "status": status,
1595
+ "content": existing_content + content_block,
1596
+ }
1597
+ if event.tool_id in self.tool_calls:
1598
+ self.tool_calls[event.tool_id].update(update)
1599
+ else:
1600
+ self.tool_calls[event.tool_id] = {
1601
+ "sessionUpdate": "tool_call",
1602
+ "toolCallId": event.tool_id,
1603
+ "title": "Tool call",
1604
+ "kind": "other",
1605
+ "status": status,
1606
+ **update,
1607
+ }
1608
+ self.send_session_update(self.tool_calls[event.tool_id])
1609
+ try:
1610
+ self.agent.save_session()
1611
+ except Exception:
1612
+ debug_log("Failed to save session after tool result", exc_info=True)
1613
+ elif event.type == StreamEventType.ERROR:
1614
+ self._text_chunker.flush()
1615
+ self._thought_chunker.flush()
1616
+ self.send_session_update({
1617
+ "sessionUpdate": "agent_message_chunk",
1618
+ "content": {"type": "text", "text": f"Error: {event.error_message}"},
1619
+ })
1620
+ try:
1621
+ self.agent.save_session()
1622
+ except Exception:
1623
+ debug_log("Failed to save session on error", exc_info=True)
1624
+ return {"stopReason": "error", "message": event.error_message}
1625
+ elif event.type == StreamEventType.SUBAGENT_START:
1626
+ self._text_chunker.flush()
1627
+ self._thought_chunker.flush()
1628
+ self.send_session_update({
1629
+ "sessionUpdate": "subagent_start",
1630
+ "subagentId": event.subagent_id,
1631
+ "agentType": event.subagent_type,
1632
+ })
1633
+ elif event.type == StreamEventType.SUBAGENT_CHUNK:
1634
+ self.send_session_update({
1635
+ "sessionUpdate": "subagent_chunk",
1636
+ "subagentId": event.subagent_id,
1637
+ "text": event.subagent_text,
1638
+ })
1639
+ elif event.type == StreamEventType.SUBAGENT_END:
1640
+ self.send_session_update({
1641
+ "sessionUpdate": "subagent_end",
1642
+ "subagentId": event.subagent_id,
1643
+ "agentType": event.subagent_type,
1644
+ })
1645
+ elif event.type == StreamEventType.PLAN:
1646
+ self.send_session_update({
1647
+ "sessionUpdate": "plan",
1648
+ "entries": event.plan_entries,
1649
+ })
1650
+ elif event.type == StreamEventType.ASK_USER:
1651
+ self._text_chunker.flush()
1652
+ self._thought_chunker.flush()
1653
+
1654
+ # Handle AskUser tool — show question inline via session/update notification
1655
+ if event.ask_action == "AskUser":
1656
+ if event.ask_questions:
1657
+ self.send_session_update({
1658
+ "sessionUpdate": "ask_user",
1659
+ "questions": event.ask_questions,
1660
+ "context": event.ask_context or "",
1661
+ "toolId": event.tool_id,
1662
+ })
1663
+ else:
1664
+ self.send_session_update({
1665
+ "sessionUpdate": "ask_user",
1666
+ "question": event.ask_question,
1667
+ "context": event.ask_context or "",
1668
+ "options": event.ask_options,
1669
+ "toolId": event.tool_id,
1670
+ })
1671
+ answer = ""
1672
+ cancelled = False
1673
+ # AskUser re-ask flow:
1674
+ # 1) send full question, wait up to 60s for response
1675
+ # 2) on timeout: send brief "请确认" reminder, wait 60s
1676
+ # 3) on second timeout: use the default/best option
1677
+ # instead of cancelling, so the agent can keep going
1678
+ # with a sensible default choice.
1679
+ _ASK_USER_REASK_TIMEOUT = 60
1680
+ _no_response = False
1681
+ for _attempt in range(2):
1682
+ try:
1683
+ self._ask_user_future = asyncio.get_event_loop().create_future()
1684
+ ask_response = await asyncio.wait_for(
1685
+ self._ask_user_future,
1686
+ timeout=_ASK_USER_REASK_TIMEOUT,
1687
+ )
1688
+ answer = ask_response.get("answer", "")
1689
+ cancelled = ask_response.get("cancelled", False)
1690
+ _no_response = False
1691
+ break
1692
+ except (asyncio.TimeoutError, Exception):
1693
+ if _attempt == 0:
1694
+ # Brief re-ask: do NOT resend the full question
1695
+ self.send_session_update({
1696
+ "sessionUpdate": "ask_user_remind",
1697
+ "text": "请确认?",
1698
+ "toolId": event.tool_id,
1699
+ })
1700
+ _no_response = True
1701
+ continue
1702
+ # Second timeout: pick the default option
1703
+ answer = self._pick_default_ask_user_answer(event)
1704
+ cancelled = False
1705
+ _no_response = True
1706
+ finally:
1707
+ self._ask_user_future = None
1708
+ if _no_response and not cancelled and answer:
1709
+ self.send_session_update({
1710
+ "sessionUpdate": "ask_user_default_used",
1711
+ "answer": answer,
1712
+ "toolId": event.tool_id,
1713
+ })
1714
+ result = await self.agent.resolve_approval(
1715
+ approved=not cancelled, answer=answer,
1716
+ )
1717
+ if result:
1718
+ tid = result.get("tool_use_id", "") or event.tool_id
1719
+ rstr = result.get("content", "") or ""
1720
+ ierr = result.get("is_error", False)
1721
+ self.agent.context.add_message(
1722
+ "tool",
1723
+ [{"type": "tool_result", "tool_use_id": tid,
1724
+ "content": rstr, "is_error": ierr}],
1725
+ name=tid,
1726
+ )
1727
+ # Update TUI tool call status
1728
+ status = "failed" if ierr else "completed"
1729
+ content_block = [{
1730
+ "type": "content",
1731
+ "content": {"type": "text", "text": rstr},
1732
+ }] if rstr else []
1733
+ self.send_session_update({
1734
+ "sessionUpdate": "tool_call_update",
1735
+ "toolCallId": tid,
1736
+ "status": status,
1737
+ "content": content_block,
1738
+ })
1739
+ continue
1740
+
1741
+ # Build tool call content for the permission request
1742
+ tool_kind = _kind_for_category(get_tool_category(event.ask_action))
1743
+ pending = (self.agent._pending_approval or {}).get("tool_call", {})
1744
+ tool_args = pending.get("input", {}) if pending else {}
1745
+ tool_content = _build_tool_call_content(
1746
+ event.ask_action, tool_args
1747
+ ) if tool_args else []
1748
+
1749
+ # Prepend the question as a visible content block so the TUI
1750
+ # permission dialog shows *what* the agent wants to do instead
1751
+ # of an empty ``_meta`` that the TUI ignores.
1752
+ question_block = {
1753
+ "type": "content",
1754
+ "content": {"type": "text", "text": f"❓ {event.ask_question}"},
1755
+ }
1756
+ tool_content = [question_block] + tool_content
1757
+
1758
+ # Guard against orphan permission requests: if the engine
1759
+ # didn't supply a tool id, fall back to the pending approval
1760
+ # id; if that's also empty, log and skip the request so the
1761
+ # TUI doesn't render a dialog with no associated tool widget.
1762
+ tool_call_id = event.tool_id or pending.get("id", "")
1763
+ if not tool_call_id:
1764
+ debug_log(
1765
+ "ASK_USER skipped: no tool_id (action=%s question=%s)",
1766
+ event.ask_action, event.ask_question,
1767
+ )
1768
+ continue
1769
+
1770
+ permission_params = {
1771
+ "sessionId": session_id,
1772
+ "options": [
1773
+ {"name": "Allow once", "optionId": "allow_once", "kind": "allow_once"},
1774
+ {"name": "Allow always", "optionId": "allow_always", "kind": "allow_always"},
1775
+ {"name": "Reject once", "optionId": "reject_once", "kind": "reject_once"},
1776
+ {"name": "Reject always", "optionId": "reject_always", "kind": "reject_always"},
1777
+ ],
1778
+ "toolCall": {
1779
+ "toolCallId": tool_call_id,
1780
+ "title": event.ask_action,
1781
+ "kind": tool_kind,
1782
+ "content": tool_content,
1783
+ "status": "pending",
1784
+ },
1785
+ }
1786
+ try:
1787
+ perm_future = self.send_request(
1788
+ "session/request_permission", permission_params
1789
+ )
1790
+ perm_response = await perm_future
1791
+ outcome = perm_response.get("outcome", {})
1792
+ option_id = outcome.get("optionId", "reject_once")
1793
+ approved = option_id in ("allow_once", "allow_always")
1794
+
1795
+ if option_id == "allow_always":
1796
+ from onecode.agent.agents.types import AgentPermission
1797
+ perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
1798
+ if perm_key:
1799
+ attr_name = f"permission_{perm_key}"
1800
+ setattr(self.agent.current_agent, attr_name, AgentPermission.ALLOW)
1801
+ self._perm_store.set_override(perm_key, AgentPermission.ALLOW)
1802
+ self._perm_store_save()
1803
+ elif option_id == "reject_always":
1804
+ from onecode.agent.agents.types import AgentPermission
1805
+ perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
1806
+ if perm_key:
1807
+ attr_name = f"permission_{perm_key}"
1808
+ setattr(self.agent.current_agent, attr_name, AgentPermission.DENY)
1809
+ self._perm_store.set_override(perm_key, AgentPermission.DENY)
1810
+ self._perm_store_save()
1811
+ except Exception:
1812
+ approved = False
1813
+
1814
+ result = await self.agent.resolve_approval(approved)
1815
+ if result:
1816
+ result_str = result.get("content", "") or ""
1817
+ is_error = result.get("is_error", False)
1818
+ tid = result.get("tool_use_id", "") or tool_call_id
1819
+ status = "failed" if is_error else "completed"
1820
+ content_block = [{
1821
+ "type": "content",
1822
+ "content": {"type": "text", "text": result_str},
1823
+ }] if result_str else []
1824
+ self.send_session_update({
1825
+ "sessionUpdate": "tool_call_update",
1826
+ "toolCallId": tid,
1827
+ "status": status,
1828
+ "content": content_block,
1829
+ })
1830
+ # Add result to LLM context
1831
+ self.agent.context.add_message(
1832
+ "tool",
1833
+ [{"type": "tool_result", "tool_use_id": tid,
1834
+ "content": result_str, "is_error": is_error}],
1835
+ name=tid,
1836
+ )
1837
+ verb = "Approved" if approved else "Rejected"
1838
+ self.send_session_update({
1839
+ "sessionUpdate": "agent_message_chunk",
1840
+ "content": {
1841
+ "type": "text",
1842
+ "text": f"⚡ {verb}: {event.ask_action} — {event.ask_question}",
1843
+ },
1844
+ })
1845
+
1846
+ except Exception:
1847
+ import traceback
1848
+ tb = traceback.format_exc()
1849
+ debug_log("Unhandled exception in chat_stream:\n%s", tb, exc_info=True)
1850
+ print(f"[cdh-agent-acp] ERROR in chat_stream:\n{tb}", file=sys.stderr, flush=True)
1851
+ self._text_chunker.flush()
1852
+ self._thought_chunker.flush()
1853
+ self.send_session_update({
1854
+ "sessionUpdate": "agent_message_chunk",
1855
+ "content": {"type": "text", "text": f"Error: internal error"},
1856
+ })
1857
+ try:
1858
+ self.agent.save_session()
1859
+ except Exception:
1860
+ pass
1861
+ try:
1862
+ self.agent.save_tasks_to_project()
1863
+ except Exception:
1864
+ pass
1865
+ return {"stopReason": "error", "message": "Internal agent error"}
1866
+
1867
+ self._text_chunker.flush()
1868
+ self._thought_chunker.flush()
1869
+
1870
+ try:
1871
+ self.agent.save_session()
1872
+ except Exception:
1873
+ debug_log("Failed to save session at turn end", exc_info=True)
1874
+
1875
+ # Persist tasks to project .cdh/ so they survive Ctrl+C
1876
+ try:
1877
+ self.agent.save_tasks_to_project()
1878
+ except Exception:
1879
+ debug_log("Failed to save tasks to .cdh at turn end", exc_info=True)
1880
+
1881
+ # Send context usage stats to TUI sidebar
1882
+ ctx = self.agent.context
1883
+ used = ctx._token_count if ctx else 0
1884
+ size = ctx.config.max_tokens if ctx else 0
1885
+ self.send_session_update({
1886
+ "sessionUpdate": "usage_update",
1887
+ "used": used,
1888
+ "size": size,
1889
+ })
1890
+
1891
+ # Send final action summary if tool calls were made this turn
1892
+ if self.tool_calls:
1893
+ summaries: list[str] = []
1894
+ for tid, tc in self.tool_calls.items():
1895
+ action = tc.get("title", "")
1896
+ status = tc.get("status", "")
1897
+ if action:
1898
+ emoji = "✅" if status == "completed" else "❌" if status == "failed" else "⏳"
1899
+ summaries.append(f"{emoji} {action}")
1900
+ if summaries:
1901
+ summary_text = "\n".join(summaries)
1902
+ self.send_session_update({
1903
+ "sessionUpdate": "agent_message_chunk",
1904
+ "content": {"type": "text", "text": f"```output\n{summary_text}\n```"},
1905
+ })
1906
+
1907
+ stop_reason = "cancelled" if self.agent._cancelled else "end_turn"
1908
+ usage = self._build_session_usage()
1909
+ return {
1910
+ "sessionId": self.session_id,
1911
+ "stopReason": stop_reason,
1912
+ "usage": usage,
1913
+ }
1914
+
1915
+ def _pick_default_ask_user_answer(self, event) -> str:
1916
+ """Pick the default answer for a timed-out AskUser prompt.
1917
+
1918
+ Heuristic:
1919
+ - If a question has an option with ``default: true``, use it.
1920
+ - Otherwise pick the first option.
1921
+ - Multi-question prompts return a JSON string of ``{idx: value}``.
1922
+ - Single-question prompts return the option value as a plain string.
1923
+ - If the question has no options (free-text), return an empty string
1924
+ so the agent treats it as a no-op.
1925
+ """
1926
+ questions = getattr(event, "ask_questions", None) or []
1927
+ options = getattr(event, "ask_options", None) or []
1928
+
1929
+ def _pick(opt_list: list) -> str:
1930
+ if not opt_list:
1931
+ return ""
1932
+ for opt in opt_list:
1933
+ if opt.get("default"):
1934
+ return str(opt.get("value", ""))
1935
+ return str(opt_list[0].get("value", ""))
1936
+
1937
+ if questions:
1938
+ answers = {str(i): _pick(q.get("options", [])) for i, q in enumerate(questions)}
1939
+ return json.dumps(answers)
1940
+ if options:
1941
+ return _pick(options)
1942
+ return ""
1943
+
1944
+ def _build_session_usage(self) -> dict:
1945
+ """Aggregate per-turn usage from the engine into a single Usage dict.
1946
+
1947
+ The TUI's ``tui/acp/protocol.py:SessionPromptResponse.usage`` is a
1948
+ free-form ``Usage`` object. We sum the per-turn input/output/total
1949
+ tokens tracked by the engine; missing fields fall back to 0.
1950
+ """
1951
+ turn_usages = getattr(self.agent, "_turn_usages", None) or []
1952
+ if not turn_usages:
1953
+ return {
1954
+ "total_tokens": int(getattr(self.agent, "total_tokens", 0) or 0),
1955
+ "input_tokens": 0,
1956
+ "output_tokens": 0,
1957
+ }
1958
+ total_in = sum(int(u.get("input_tokens", 0) or 0) for u in turn_usages)
1959
+ total_out = sum(int(u.get("output_tokens", 0) or 0) for u in turn_usages)
1960
+ total_all = sum(int(u.get("total_tokens", 0) or 0) for u in turn_usages)
1961
+ if total_all == 0:
1962
+ total_all = total_in + total_out
1963
+ return {
1964
+ "total_tokens": total_all,
1965
+ "input_tokens": total_in,
1966
+ "output_tokens": total_out,
1967
+ }
1968
+
1969
+ async def session_cancel(self, session_id: str, _meta: dict):
1970
+ """Cancel current session."""
1971
+ if self.agent:
1972
+ await self.agent.cancel()
1973
+ try:
1974
+ self.agent.save_session()
1975
+ except Exception:
1976
+ debug_log("Failed to save session on cancel", exc_info=True)
1977
+ # Persist tasks to .cdh so they survive Ctrl+C and re-entry
1978
+ try:
1979
+ self.agent.save_tasks_to_project()
1980
+ except Exception:
1981
+ debug_log("Failed to save tasks to .cdh on cancel", exc_info=True)
1982
+ return {}
1983
+
1984
+ async def session_set_mode(self, session_id: str, mode_id: str):
1985
+ """Set session mode — propagates to engine and notifies TUI."""
1986
+ if self.agent:
1987
+ self.agent.set_agent(mode_id)
1988
+ self._perm_store.apply_to(self.agent.current_agent)
1989
+ self.send_session_update({
1990
+ "sessionUpdate": "current_mode_update",
1991
+ "currentModeId": mode_id,
1992
+ })
1993
+ self._send_available_commands()
1994
+ return {"modeId": mode_id}
1995
+
1996
+ # ── Terminal RPC stubs ──────────────────────────────────────────
1997
+ async def terminal_create(
1998
+ self, session_id: str, terminal_id: str, command: str, args: list[str] | None = None,
1999
+ cwd: str | None = None, env: dict | None = None, output_byte_limit: int | None = None,
2000
+ ) -> dict:
2001
+ return {"terminalId": terminal_id}
2002
+
2003
+ async def terminal_kill(self, session_id: str, terminal_id: str) -> dict:
2004
+ return {}
2005
+
2006
+ async def terminal_output(self, session_id: str, terminal_id: str) -> dict:
2007
+ return {"output": "", "truncated": False, "exitStatus": None}
2008
+
2009
+ async def terminal_release(self, session_id: str, terminal_id: str) -> dict:
2010
+ return {}
2011
+
2012
+ async def terminal_wait_for_exit(self, session_id: str, terminal_id: str) -> dict:
2013
+ return {"exitCode": 0, "signal": None}
2014
+
2015
+
2016
+ class JSONRPCServer:
2017
+ def __init__(self, adapter: CDHACPAdapter):
2018
+ self.adapter = adapter
2019
+ self.methods = {
2020
+ "initialize": self._handle_initialize,
2021
+ "session/new": self._handle_session_new,
2022
+ "session/load": self._handle_session_load,
2023
+ "session/prompt": self._handle_session_prompt,
2024
+ "session/cancel": self._handle_session_cancel,
2025
+ "session/set_mode": self._handle_session_set_mode,
2026
+ "session/ask_user_answer": self._handle_ask_user_answer,
2027
+ "terminal/create": self._handle_terminal_create,
2028
+ "terminal/kill": self._handle_terminal_kill,
2029
+ "terminal/output": self._handle_terminal_output,
2030
+ "terminal/release": self._handle_terminal_release,
2031
+ "terminal/wait_for_exit": self._handle_terminal_wait_for_exit,
2032
+ }
2033
+
2034
+ async def handle_request(self, request: dict):
2035
+ """Handle a JSONRPC request."""
2036
+ method = request.get("method")
2037
+ params = request.get("params", {})
2038
+ req_id = request.get("id")
2039
+
2040
+ handler = self.methods.get(method)
2041
+ if handler is None:
2042
+ return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Method not found: {method}"}, "id": req_id}
2043
+
2044
+ try:
2045
+ result = await handler(params)
2046
+ return {"jsonrpc": "2.0", "result": result, "id": req_id}
2047
+ except Exception as e:
2048
+ return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_id}
2049
+
2050
+ async def _handle_initialize(self, params: dict):
2051
+ return await self.adapter.initialize(
2052
+ params.get("protocolVersion"),
2053
+ params.get("clientCapabilities", {}),
2054
+ params.get("clientInfo", {}),
2055
+ )
2056
+
2057
+ async def _handle_session_new(self, params: dict):
2058
+ return await self.adapter.session_new(
2059
+ params.get("cwd", "."),
2060
+ params.get("mcpServers", []),
2061
+ )
2062
+
2063
+ async def _handle_session_load(self, params: dict):
2064
+ return await self.adapter.session_load(
2065
+ params.get("cwd", "."),
2066
+ params.get("mcpServers", []),
2067
+ params.get("sessionId"),
2068
+ )
2069
+
2070
+ async def _handle_session_prompt(self, params: dict):
2071
+ return await self.adapter.session_prompt(
2072
+ params.get("prompt", []),
2073
+ params.get("sessionId"),
2074
+ )
2075
+
2076
+ async def _handle_session_cancel(self, params: dict):
2077
+ return await self.adapter.session_cancel(
2078
+ params.get("sessionId"),
2079
+ params.get("_meta", {}),
2080
+ )
2081
+
2082
+ async def _handle_ask_user_answer(self, params: dict):
2083
+ return self.adapter.resolve_ask_user(
2084
+ params.get("answer", ""),
2085
+ params.get("cancelled", True),
2086
+ )
2087
+
2088
+ async def _handle_session_set_mode(self, params: dict):
2089
+ return await self.adapter.session_set_mode(
2090
+ params.get("sessionId"),
2091
+ params.get("modeId"),
2092
+ )
2093
+
2094
+ async def _handle_terminal_create(self, params: dict):
2095
+ return await self.adapter.terminal_create(
2096
+ params.get("sessionId"),
2097
+ params.get("terminalId"),
2098
+ params.get("command"),
2099
+ params.get("args"),
2100
+ params.get("cwd"),
2101
+ params.get("env"),
2102
+ params.get("outputByteLimit"),
2103
+ )
2104
+
2105
+ async def _handle_terminal_kill(self, params: dict):
2106
+ return await self.adapter.terminal_kill(
2107
+ params.get("sessionId"),
2108
+ params.get("terminalId"),
2109
+ )
2110
+
2111
+ async def _handle_terminal_output(self, params: dict):
2112
+ return await self.adapter.terminal_output(
2113
+ params.get("sessionId"),
2114
+ params.get("terminalId"),
2115
+ )
2116
+
2117
+ async def _handle_terminal_release(self, params: dict):
2118
+ return await self.adapter.terminal_release(
2119
+ params.get("sessionId"),
2120
+ params.get("terminalId"),
2121
+ )
2122
+
2123
+ async def _handle_terminal_wait_for_exit(self, params: dict):
2124
+ return await self.adapter.terminal_wait_for_exit(
2125
+ params.get("sessionId"),
2126
+ params.get("terminalId"),
2127
+ )
2128
+
2129
+
2130
+ async def _main():
2131
+ adapter = CDHACPAdapter()
2132
+ server = JSONRPCServer(adapter)
2133
+ prompt_task: asyncio.Task | None = None
2134
+
2135
+ async def _run_prompt(req: dict):
2136
+ try:
2137
+ result = await server._handle_session_prompt(req.get("params", {}))
2138
+ return {"jsonrpc": "2.0", "result": result, "id": req.get("id")}
2139
+ except Exception as e:
2140
+ import traceback
2141
+ tb = traceback.format_exc()
2142
+ print(f"[cdh-agent-acp] ERROR in _run_prompt:\n{tb}", file=sys.stderr, flush=True)
2143
+ debug_log("_run_prompt failed: %s\n%s", e, tb, exc_info=True)
2144
+ return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req.get("id")}
2145
+
2146
+ while True:
2147
+ line = await asyncio.to_thread(sys.stdin.readline)
2148
+ if not line:
2149
+ break
2150
+
2151
+ line = line.strip()
2152
+ if not line:
2153
+ continue
2154
+
2155
+ try:
2156
+ request = json.loads(line)
2157
+ except json.JSONDecodeError:
2158
+ continue
2159
+
2160
+ requests = request if isinstance(request, list) else [request]
2161
+
2162
+ for req in requests:
2163
+ if not isinstance(req, dict):
2164
+ continue
2165
+
2166
+ # Check if this is a response to a pending server→client request
2167
+ if "id" in req and ("result" in req or "error" in req):
2168
+ req_id = req.get("id")
2169
+ if isinstance(req_id, str) and req_id in adapter._pending_requests:
2170
+ adapter.resolve_pending_request(req_id, req.get("result", {}))
2171
+ continue
2172
+
2173
+ method = req.get("method", "")
2174
+ req_id = req.get("id")
2175
+
2176
+ if method == "session/prompt":
2177
+ # Run prompt in background so main loop stays responsive
2178
+ # to cancel notifications on stdin
2179
+ prompt_task = asyncio.create_task(_run_prompt(req))
2180
+ def _on_prompt_done(t, req=req):
2181
+ try:
2182
+ resp = t.result()
2183
+ except Exception as exc:
2184
+ debug_log(f"Prompt task raised unhandled exception: {exc}", exc_info=True)
2185
+ resp = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": req.get("id")}
2186
+ if resp.get("id") is not None:
2187
+ print(json.dumps(resp), flush=True)
2188
+ prompt_task.add_done_callback(_on_prompt_done)
2189
+ elif method == "session/cancel":
2190
+ if prompt_task and not prompt_task.done():
2191
+ adapter.cancel_prompt()
2192
+ # Notification (no id) — nothing to respond with
2193
+ else:
2194
+ response = await server.handle_request(req)
2195
+ if response.get("id") is not None:
2196
+ print(json.dumps(response), flush=True)
2197
+
2198
+
2199
+ def main():
2200
+ asyncio.run(_main())
2201
+
2202
+ if __name__ == "__main__":
2203
+ main()