@scemoon/cdh 1.0.3 → 1.0.5

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