@scemoon/cdh 1.0.6 → 1.0.8

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