@scemoon/cdh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (528) hide show
  1. package/ai-dlc-skill/SKILL.md +75 -0
  2. package/ai-dlc-skill/agents/deliver-agent.md +24 -0
  3. package/ai-dlc-skill/agents/master.md +44 -0
  4. package/ai-dlc-skill/agents/plan-agent.md +23 -0
  5. package/ai-dlc-skill/agents/understand-agent.md +23 -0
  6. package/ai-dlc-skill/agents/verify-agent.md +27 -0
  7. package/ai-dlc-skill/architecture/project-structure.md +78 -0
  8. package/ai-dlc-skill/brownfield/README.md +30 -0
  9. package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
  10. package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
  11. package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
  12. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
  13. package/ai-dlc-skill/components/backend.md +34 -0
  14. package/ai-dlc-skill/components/desktop.md +30 -0
  15. package/ai-dlc-skill/components/mya.md +29 -0
  16. package/ai-dlc-skill/components/native.md +30 -0
  17. package/ai-dlc-skill/components/tta.md +29 -0
  18. package/ai-dlc-skill/components/web.md +31 -0
  19. package/ai-dlc-skill/components/wxa.md +29 -0
  20. package/ai-dlc-skill/contracts/README.md +25 -0
  21. package/ai-dlc-skill/core/adaptive-flow.md +36 -0
  22. package/ai-dlc-skill/core/security.md +29 -0
  23. package/ai-dlc-skill/cross-tool/cline/export.sh +31 -0
  24. package/ai-dlc-skill/cross-tool/copilot/export.sh +47 -0
  25. package/ai-dlc-skill/cross-tool/cursor/export.sh +32 -0
  26. package/ai-dlc-skill/cross-tool/export.sh +22 -0
  27. package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +17 -0
  28. package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +31 -0
  29. package/ai-dlc-skill/cross-tool/onecode/export.sh +48 -0
  30. package/ai-dlc-skill/phases/deliver/entry.md +26 -0
  31. package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
  32. package/ai-dlc-skill/phases/deliver/prompt.md +40 -0
  33. package/ai-dlc-skill/phases/deliver/rules.md +61 -0
  34. package/ai-dlc-skill/phases/plan/entry.md +25 -0
  35. package/ai-dlc-skill/phases/plan/lifecycle.md +204 -0
  36. package/ai-dlc-skill/phases/plan/prompt.md +38 -0
  37. package/ai-dlc-skill/phases/plan/rules.md +25 -0
  38. package/ai-dlc-skill/phases/understand/entry.md +26 -0
  39. package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
  40. package/ai-dlc-skill/phases/understand/prompt.md +41 -0
  41. package/ai-dlc-skill/phases/understand/rules.md +37 -0
  42. package/ai-dlc-skill/phases/verify/entry.md +26 -0
  43. package/ai-dlc-skill/phases/verify/lifecycle.md +192 -0
  44. package/ai-dlc-skill/phases/verify/prompt.md +43 -0
  45. package/ai-dlc-skill/phases/verify/rules.md +73 -0
  46. package/ai-dlc-skill/practices/bdd.md +91 -0
  47. package/ai-dlc-skill/practices/sdd.md +84 -0
  48. package/ai-dlc-skill/practices/tdd.md +95 -0
  49. package/ai-dlc-skill/providers/README.md +114 -0
  50. package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
  51. package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
  52. package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
  53. package/ai-dlc-skill/providers/tcb/deployment.yaml +69 -0
  54. package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
  55. package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
  56. package/ai-dlc-skill/requirements.md +140 -0
  57. package/ai-dlc-skill/skill.yaml +147 -0
  58. package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
  59. package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
  60. package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
  61. package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
  62. package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
  63. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
  64. package/ai-dlc-skill/templates/plan/design.md +49 -0
  65. package/ai-dlc-skill/templates/plan/task-list.md +32 -0
  66. package/ai-dlc-skill/templates/project/README.md +127 -0
  67. package/ai-dlc-skill/templates/project/template.md +119 -0
  68. package/ai-dlc-skill/templates/understand/feature.feature +23 -0
  69. package/ai-dlc-skill/templates/understand/intent.md +29 -0
  70. package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
  71. package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
  72. package/ai-dlc-skill/walkthrough/collect.sh +50 -0
  73. package/ai-dlc-skill/walkthrough/template.md +54 -0
  74. package/ai-dlc-skill/workflows/ai-dlc.yaml +110 -0
  75. package/ai-dlc-skill/workflows/deployment.yaml +199 -0
  76. package/cdh/__init__.py +0 -0
  77. package/cdh/__main__.py +4 -0
  78. package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  81. package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
  82. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  83. package/cdh/cli.py +361 -0
  84. package/cdh/scaffold.py +312 -0
  85. package/cli.js +2 -0
  86. package/onecode/__init__.py +1 -0
  87. package/onecode/__main__.py +4 -0
  88. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  90. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  91. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  92. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  93. package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
  94. package/onecode/agent/__init__.py +0 -0
  95. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
  97. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
  98. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  99. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  100. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  101. package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
  102. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  103. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  104. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  105. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  106. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  107. package/onecode/agent/agents/__init__.py +29 -0
  108. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  109. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  110. package/onecode/agent/agents/types.py +662 -0
  111. package/onecode/agent/attachments.py +113 -0
  112. package/onecode/agent/cdh_loader.py +262 -0
  113. package/onecode/agent/context.py +338 -0
  114. package/onecode/agent/engine.py +1894 -0
  115. package/onecode/agent/hooks.py +86 -0
  116. package/onecode/agent/onecode_agent_acp.py +2203 -0
  117. package/onecode/agent/permissions.py +151 -0
  118. package/onecode/agent/permissions_store.py +62 -0
  119. package/onecode/agent/session.py +186 -0
  120. package/onecode/agent/snapshot.py +145 -0
  121. package/onecode/agent/tools/__init__.py +17 -0
  122. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  123. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  124. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  125. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  126. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  127. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  128. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  129. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  130. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  131. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  132. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  133. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  134. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  135. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  136. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  137. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  138. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  139. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  140. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  141. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  142. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  143. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  144. package/onecode/agent/tools/agent_tools.py +134 -0
  145. package/onecode/agent/tools/apply_patch_tool.py +144 -0
  146. package/onecode/agent/tools/bash_tool.py +91 -0
  147. package/onecode/agent/tools/communication_tools.py +149 -0
  148. package/onecode/agent/tools/config_tool.py +130 -0
  149. package/onecode/agent/tools/cron_tools.py +179 -0
  150. package/onecode/agent/tools/errors.py +17 -0
  151. package/onecode/agent/tools/file_ops.py +187 -0
  152. package/onecode/agent/tools/file_tools.py +226 -0
  153. package/onecode/agent/tools/git_tools.py +88 -0
  154. package/onecode/agent/tools/lsp_tools.py +404 -0
  155. package/onecode/agent/tools/mcp_tools.py +129 -0
  156. package/onecode/agent/tools/permission_handler.py +46 -0
  157. package/onecode/agent/tools/permissions.py +77 -0
  158. package/onecode/agent/tools/protocol.py +20 -0
  159. package/onecode/agent/tools/registry.py +134 -0
  160. package/onecode/agent/tools/sandbox.py +264 -0
  161. package/onecode/agent/tools/schema_validation.py +100 -0
  162. package/onecode/agent/tools/skill_tools.py +52 -0
  163. package/onecode/agent/tools/task_tools.py +249 -0
  164. package/onecode/agent/tools/web_tools.py +170 -0
  165. package/onecode/builtin_skills/git/SKILL.md +64 -0
  166. package/onecode/builtin_skills/git/skill.yaml +7 -0
  167. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  168. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  169. package/onecode/cli.py +755 -0
  170. package/onecode/cloud/__init__.py +0 -0
  171. package/onecode/cloud/base.py +20 -0
  172. package/onecode/codebase/__init__.py +69 -0
  173. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  174. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  175. package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
  176. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  177. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  178. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  179. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  180. package/onecode/codebase/chunker.py +130 -0
  181. package/onecode/codebase/indexer.py +136 -0
  182. package/onecode/codebase/retriever.py +192 -0
  183. package/onecode/codebase/storage.py +130 -0
  184. package/onecode/codebase/tools.py +59 -0
  185. package/onecode/commands.py +276 -0
  186. package/onecode/config.py +285 -0
  187. package/onecode/config_screen.py +594 -0
  188. package/onecode/cron/__init__.py +3 -0
  189. package/onecode/lsp/__init__.py +3 -0
  190. package/onecode/mcp/__init__.py +4 -0
  191. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  192. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  193. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  194. package/onecode/mcp/client.py +193 -0
  195. package/onecode/mcp/manager.py +191 -0
  196. package/onecode/memory/__init__.py +95 -0
  197. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  198. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  199. package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
  200. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  201. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  202. package/onecode/memory/__pycache__/symbolic.cpython-314.pyc +0 -0
  203. package/onecode/memory/backend.py +185 -0
  204. package/onecode/memory/offload.py +114 -0
  205. package/onecode/memory/pyramid.py +202 -0
  206. package/onecode/memory/recall.py +168 -0
  207. package/onecode/memory/symbolic.py +164 -0
  208. package/onecode/models/__init__.py +4 -0
  209. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  210. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  211. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  212. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  213. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  214. package/onecode/models/errors.py +94 -0
  215. package/onecode/models/messages.py +662 -0
  216. package/onecode/models/provider.py +450 -0
  217. package/onecode/models/providers/__init__.py +17 -0
  218. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  219. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  220. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  221. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  222. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  223. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  224. package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
  225. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  226. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  227. package/onecode/models/providers/anthropic_provider.py +224 -0
  228. package/onecode/models/providers/deepseek_provider.py +210 -0
  229. package/onecode/models/providers/glm_provider.py +154 -0
  230. package/onecode/models/providers/minimax_provider.py +154 -0
  231. package/onecode/models/providers/minimaxi_provider.py +363 -0
  232. package/onecode/models/providers/ollama_provider.py +96 -0
  233. package/onecode/models/providers/openai_provider.py +233 -0
  234. package/onecode/models/registry.py +212 -0
  235. package/onecode/server/__init__.py +3 -0
  236. package/onecode/server/app.py +224 -0
  237. package/onecode/skills/__init__.py +11 -0
  238. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  239. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  240. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  241. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  242. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  243. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  244. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  245. package/onecode/skills/argument_substitution.py +39 -0
  246. package/onecode/skills/create.py +56 -0
  247. package/onecode/skills/frontmatter.py +33 -0
  248. package/onecode/skills/loader.py +157 -0
  249. package/onecode/skills/manager.py +75 -0
  250. package/onecode/skills/model.py +43 -0
  251. package/onecode/storage/__init__.py +0 -0
  252. package/onecode/storage/activity.py +64 -0
  253. package/onecode/storage/project.py +54 -0
  254. package/onecode/storage/session.py +113 -0
  255. package/onecode/tasks/__init__.py +26 -0
  256. package/onecode/tasks/manager.py +219 -0
  257. package/onecode/tasks/models.py +240 -0
  258. package/onecode/trace/__init__.py +0 -0
  259. package/onecode/trace/tracer.py +174 -0
  260. package/onecode/utils/__init__.py +0 -0
  261. package/onecode/utils/helpers.py +20 -0
  262. package/onecode-cli.js +2 -0
  263. package/package.json +32 -0
  264. package/pyproject.toml +60 -0
  265. package/run.js +86 -0
  266. package/tui/__init__.py +46 -0
  267. package/tui/__main__.py +4 -0
  268. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  269. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  270. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  271. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  272. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  273. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  274. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  275. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  276. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  277. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  278. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  279. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  280. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  281. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  282. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  283. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  284. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  285. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  286. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  287. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  288. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  289. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  290. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  291. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  292. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  293. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  294. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  295. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  296. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  297. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  298. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  299. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  300. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  301. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  302. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  303. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  304. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  305. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  306. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  307. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  308. package/tui/_loop.py +86 -0
  309. package/tui/_path_fuzzy_search.py +48 -0
  310. package/tui/_path_match.py +141 -0
  311. package/tui/about.py +95 -0
  312. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  313. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  314. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  315. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  316. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  317. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  318. package/tui/acp/agent.py +886 -0
  319. package/tui/acp/api.py +55 -0
  320. package/tui/acp/encode_tool_call_id.py +12 -0
  321. package/tui/acp/messages.py +190 -0
  322. package/tui/acp/prompt.py +110 -0
  323. package/tui/acp/protocol.py +462 -0
  324. package/tui/agent.py +70 -0
  325. package/tui/agent_schema.py +76 -0
  326. package/tui/agents.py +45 -0
  327. package/tui/ansi/__init__.py +1 -0
  328. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  329. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  330. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  331. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  332. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  333. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  334. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  335. package/tui/ansi/_ansi.py +1689 -0
  336. package/tui/ansi/_ansi_colors.py +264 -0
  337. package/tui/ansi/_control_codes.py +37 -0
  338. package/tui/ansi/_keys.py +251 -0
  339. package/tui/ansi/_sgr_styles.py +64 -0
  340. package/tui/ansi/_stream_parser.py +418 -0
  341. package/tui/answer.py +12 -0
  342. package/tui/app.py +1236 -0
  343. package/tui/atomic.py +37 -0
  344. package/tui/cli.py +313 -0
  345. package/tui/code_analyze.py +27 -0
  346. package/tui/complete.py +38 -0
  347. package/tui/constants.py +61 -0
  348. package/tui/conversation_markdown.py +30 -0
  349. package/tui/danger.py +372 -0
  350. package/tui/data/agents/ampcode.com.toml +51 -0
  351. package/tui/data/agents/augmentcode.com.toml +40 -0
  352. package/tui/data/agents/cdh.cloud-dev-harness.toml +22 -0
  353. package/tui/data/agents/claude.com.toml +41 -0
  354. package/tui/data/agents/copilot.github.com.toml +29 -0
  355. package/tui/data/agents/crowai.dev.toml +35 -0
  356. package/tui/data/agents/cursor.com.toml +38 -0
  357. package/tui/data/agents/deepagents.langchain.com +33 -0
  358. package/tui/data/agents/docker.com.toml +59 -0
  359. package/tui/data/agents/geminicli.com.toml +28 -0
  360. package/tui/data/agents/goose.ai.toml +51 -0
  361. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  362. package/tui/data/agents/kimi.com.toml +35 -0
  363. package/tui/data/agents/kiro.dev.toml +42 -0
  364. package/tui/data/agents/openai.com.toml +53 -0
  365. package/tui/data/agents/openclaw.ai.toml +37 -0
  366. package/tui/data/agents/opencode.ai.toml +39 -0
  367. package/tui/data/agents/openhands.dev.toml +45 -0
  368. package/tui/data/agents/stakpak.dev.toml +61 -0
  369. package/tui/data/agents/vibe.mistral.ai.toml +30 -0
  370. package/tui/data/agents/vtcode.dev.toml +62 -0
  371. package/tui/data/images/frog.png +0 -0
  372. package/tui/data/sounds/question.wav +0 -0
  373. package/tui/data/sounds/turn-over.wav +0 -0
  374. package/tui/db.py +190 -0
  375. package/tui/dec.py +332 -0
  376. package/tui/directory.py +234 -0
  377. package/tui/directory_suggester.py +93 -0
  378. package/tui/directory_watcher.py +197 -0
  379. package/tui/format_path.py +25 -0
  380. package/tui/fuzzy.py +140 -0
  381. package/tui/fuzzy_index.py +275 -0
  382. package/tui/gist.py +33 -0
  383. package/tui/history.py +138 -0
  384. package/tui/jsonrpc.py +578 -0
  385. package/tui/menus.py +14 -0
  386. package/tui/message_log.py +69 -0
  387. package/tui/messages.py +139 -0
  388. package/tui/option_content.py +51 -0
  389. package/tui/os.py +0 -0
  390. package/tui/path_complete.py +217 -0
  391. package/tui/path_filter.py +124 -0
  392. package/tui/paths.py +71 -0
  393. package/tui/pill.py +23 -0
  394. package/tui/platform_commands.py +52 -0
  395. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  396. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  397. package/tui/prompt/extract.py +19 -0
  398. package/tui/prompt/resource.py +68 -0
  399. package/tui/protocol.py +28 -0
  400. package/tui/screens/__init__.py +0 -0
  401. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  402. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  403. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  404. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  405. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  406. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  407. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  408. package/tui/screens/action_modal.py +94 -0
  409. package/tui/screens/agent_modal.py +175 -0
  410. package/tui/screens/command_edit_modal.py +74 -0
  411. package/tui/screens/log.py +553 -0
  412. package/tui/screens/main.py +411 -0
  413. package/tui/screens/main.tcss +655 -0
  414. package/tui/screens/permissions.py +404 -0
  415. package/tui/screens/permissions.tcss +72 -0
  416. package/tui/screens/projects.tcss +66 -0
  417. package/tui/screens/projects_app.py +319 -0
  418. package/tui/screens/projects_screen.py +142 -0
  419. package/tui/screens/session_resume_modal.py +172 -0
  420. package/tui/screens/session_resume_modal.tcss +35 -0
  421. package/tui/screens/sessions.py +166 -0
  422. package/tui/screens/sessions.tcss +119 -0
  423. package/tui/screens/settings.py +254 -0
  424. package/tui/screens/settings.tcss +101 -0
  425. package/tui/screens/store.py +500 -0
  426. package/tui/screens/store.tcss +274 -0
  427. package/tui/screens/tool_content_screen.py +59 -0
  428. package/tui/session_tracker.py +120 -0
  429. package/tui/settings.py +353 -0
  430. package/tui/settings_schema.py +431 -0
  431. package/tui/shell.py +304 -0
  432. package/tui/shell_read.py +42 -0
  433. package/tui/slash_command.py +34 -0
  434. package/tui/tui.tcss +246 -0
  435. package/tui/twitter.py +38 -0
  436. package/tui/version.py +80 -0
  437. package/tui/visuals/__init__.py +0 -0
  438. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  439. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  440. package/tui/visuals/columns.py +273 -0
  441. package/tui/widgets/__init__.py +0 -0
  442. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  443. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  444. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  445. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  446. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  447. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  448. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  449. package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
  450. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  451. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  452. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  453. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  454. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  455. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  456. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  457. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  458. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  459. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  460. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  461. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  462. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  463. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  464. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  465. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  466. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  467. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  468. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  469. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  470. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  471. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  472. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  473. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  474. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  475. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  476. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  477. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  478. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  479. package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
  480. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  481. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  482. package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
  483. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  484. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  485. package/tui/widgets/acp_content.py +70 -0
  486. package/tui/widgets/agent_response.py +77 -0
  487. package/tui/widgets/agent_thought.py +168 -0
  488. package/tui/widgets/ask_user.py +264 -0
  489. package/tui/widgets/command_pane.py +223 -0
  490. package/tui/widgets/condensed_path.py +115 -0
  491. package/tui/widgets/context_stats.py +66 -0
  492. package/tui/widgets/conversation.py +2441 -0
  493. package/tui/widgets/danger_warning.py +65 -0
  494. package/tui/widgets/diff_view.py +59 -0
  495. package/tui/widgets/directory_input.py +8 -0
  496. package/tui/widgets/flash.py +81 -0
  497. package/tui/widgets/future_text.py +126 -0
  498. package/tui/widgets/grid_select.py +234 -0
  499. package/tui/widgets/highlighted_textarea.py +180 -0
  500. package/tui/widgets/mandelbrot.py +294 -0
  501. package/tui/widgets/markdown_note.py +13 -0
  502. package/tui/widgets/menu.py +147 -0
  503. package/tui/widgets/modified_files.py +106 -0
  504. package/tui/widgets/non_selectable_label.py +5 -0
  505. package/tui/widgets/note.py +18 -0
  506. package/tui/widgets/path_search.py +492 -0
  507. package/tui/widgets/plan.py +263 -0
  508. package/tui/widgets/project_directory_tree.py +90 -0
  509. package/tui/widgets/project_grid_select.py +35 -0
  510. package/tui/widgets/project_summary.py +33 -0
  511. package/tui/widgets/prompt.py +795 -0
  512. package/tui/widgets/question.py +360 -0
  513. package/tui/widgets/session_grid_select.py +224 -0
  514. package/tui/widgets/session_summary.py +115 -0
  515. package/tui/widgets/session_tabs.py +188 -0
  516. package/tui/widgets/shell_result.py +35 -0
  517. package/tui/widgets/shell_terminal.py +40 -0
  518. package/tui/widgets/side_bar.py +114 -0
  519. package/tui/widgets/slash_complete.py +235 -0
  520. package/tui/widgets/strike_text.py +70 -0
  521. package/tui/widgets/subagent.py +87 -0
  522. package/tui/widgets/terminal.py +577 -0
  523. package/tui/widgets/terminal_tool.py +351 -0
  524. package/tui/widgets/throbber.py +100 -0
  525. package/tui/widgets/tool_call.py +368 -0
  526. package/tui/widgets/user_input.py +23 -0
  527. package/tui/widgets/version.py +5 -0
  528. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,1894 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+ from typing import AsyncIterator, Optional
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Callable
12
+
13
+ from onecode.agent.context import ContextManager
14
+ from onecode.agent.permissions_store import PermissionStore
15
+ from onecode.models.provider import ContentBlockType, ProviderRegistry
16
+
17
+ from onecode.agent.session import AgentSession
18
+ from onecode.models.messages import StreamEvent, StreamEventType
19
+
20
+ logger = logging.getLogger("onecode.agent.engine")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ToolEvent:
25
+ """Event emitted during the agent loop lifecycle (Clawd-Code style)."""
26
+ kind: str # "tool_use" | "tool_result" | "tool_error"
27
+ tool_name: str = ""
28
+ tool_input: dict | None = None
29
+ tool_output: Any = None
30
+ tool_use_id: str | None = None
31
+ is_error: bool = False
32
+ error: str | None = None
33
+
34
+
35
+ ToolEventHandler = Callable[[ToolEvent], None]
36
+
37
+ TOOL_CALL_RE = re.compile(
38
+ r'<tool_call\s+name=["\']([^"\']+)["\']\s+id=["\']([^"\']+)["\']>(.*?)</tool_call>',
39
+ re.DOTALL,
40
+ )
41
+
42
+ # Structured tool call format used by the minimaxi / claude / Anthropic-style
43
+ # models. Looks like::
44
+ #
45
+ # <minimax:tool_call>
46
+ # <invoke name="Read">
47
+ # <parameter name="path">SPEC.md</parameter>
48
+ # </invoke>
49
+ # <invoke name="Bash">
50
+ # <parameter name="command">ls -la</parameter>
51
+ # <parameter name="timeout">30</parameter>
52
+ # </invoke>
53
+ # </minimax:tool_call>
54
+ #
55
+ # We match the outer block, then split into individual <invoke> elements
56
+ # inside ``_extract_minimax_tool_uses``.
57
+ MINIMAX_TOOL_CALL_RE = re.compile(
58
+ r'<minimax:tool_call>(.*?)</minimax:tool_call>',
59
+ re.DOTALL,
60
+ )
61
+
62
+ # An individual <invoke name="X">...</invoke> block. ``name`` is captured
63
+ # separately so we can call ``_parse_minimax_invoke_body`` on the inner XML.
64
+ _MINIMAX_INVOKE_RE = re.compile(
65
+ r'<invoke\s+name="([^"]+)">(.*?)</invoke>',
66
+ re.DOTALL,
67
+ )
68
+
69
+ # A single <parameter name="X">value</parameter> element. The value can span
70
+ # multiple lines, may contain entity-escaped angle brackets, and we tolerate
71
+ # either double or single quotes around the name.
72
+ _MINIMAX_PARAM_RE = re.compile(
73
+ r'<parameter\s+name=["\']([^"\']+)["\']>(.*?)</parameter>',
74
+ re.DOTALL,
75
+ )
76
+
77
+ THINKING_RE = re.compile(
78
+ r'<think(?:ing)?>(.*?)</think(?:ing)?>',
79
+ re.DOTALL,
80
+ )
81
+
82
+ _LEGACY_OPEN = "[TOOL_CALL]"
83
+ _LEGACY_CLOSE = "[/TOOL_CALL]"
84
+
85
+
86
+ def _parse_minimax_invoke_body(body: str) -> dict:
87
+ """Parse the inner XML of a single ``<invoke name="X">`` block.
88
+
89
+ Returns a dict ``{"name": str, "arguments": {param_name: value, ...}}``.
90
+ The ``arguments`` dict is empty if no ``<parameter>`` children are found.
91
+ """
92
+ body = body.strip()
93
+ arguments: dict = {}
94
+ for m in _MINIMAX_PARAM_RE.finditer(body):
95
+ param_name = m.group(1)
96
+ raw_value = m.group(2)
97
+ arguments[param_name] = _unescape_xml(raw_value)
98
+ return arguments
99
+
100
+
101
+ def _unescape_xml(s: str) -> str:
102
+ """Reverse the common XML / HTML entity escapes.
103
+
104
+ We keep this conservative — the five predefined entities and the numeric
105
+ ones — so we never accidentally mangle content that legitimately contains
106
+ ampersands.
107
+ """
108
+ return (
109
+ s.replace("&lt;", "<")
110
+ .replace("&gt;", ">")
111
+ .replace("&quot;", '"')
112
+ .replace("&apos;", "'")
113
+ .replace("&amp;", "&")
114
+ )
115
+
116
+
117
+ def _extract_minimax_tool_uses(text: str, id_start: int = 0) -> tuple[list[dict], str, int]:
118
+ """Extract ``<minimax:tool_call>...</minimax:tool_call>`` blocks.
119
+
120
+ Returns ``(tool_uses, cleaned_text, next_id)`` where ``tool_uses`` is a
121
+ list of ``{id, name, input}`` dicts and ``cleaned_text`` has every
122
+ matched block removed. ``id_start`` is the next available tool id
123
+ counter (caller-owned, see ``AgentEngine._tool_id_counter``); the next
124
+ free id is returned in the tuple so callers can pass it to subsequent
125
+ extractions without losing uniqueness.
126
+ """
127
+ tool_uses: list[dict] = []
128
+ cleaned = text
129
+ counter = id_start
130
+ while True:
131
+ m = MINIMAX_TOOL_CALL_RE.search(cleaned)
132
+ if m is None:
133
+ break
134
+ block_body = m.group(1)
135
+ for inv in _MINIMAX_INVOKE_RE.finditer(block_body):
136
+ name = inv.group(1)
137
+ arguments = _parse_minimax_invoke_body(inv.group(2))
138
+ counter += 1
139
+ tool_uses.append({
140
+ "id": f"minimax-{counter}",
141
+ "name": name,
142
+ "input": arguments,
143
+ })
144
+ cleaned = cleaned[:m.start()] + cleaned[m.end():]
145
+ return tool_uses, cleaned, counter
146
+
147
+
148
+ def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
149
+ if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
150
+ return None
151
+ depth = 0
152
+ i = open_pos
153
+ n = len(text)
154
+ while i < n:
155
+ c = text[i]
156
+ if c == "{":
157
+ depth += 1
158
+ i += 1
159
+ elif c == "}":
160
+ depth -= 1
161
+ if depth == 0:
162
+ return i
163
+ i += 1
164
+ elif c in ('"', "'"):
165
+ quote = c
166
+ i += 1
167
+ while i < n and text[i] != quote:
168
+ if text[i] == "\\" and i + 1 < n:
169
+ i += 2
170
+ else:
171
+ i += 1
172
+ i += 1
173
+ elif c == "/" and i + 1 < n and text[i + 1] == "/":
174
+ while i < n and text[i] != "\n":
175
+ i += 1
176
+ else:
177
+ i += 1
178
+ return None
179
+
180
+
181
+ def _parse_legacy_tool_body(body: str) -> dict:
182
+ body = body.strip()
183
+ if not body or not body.startswith("{"):
184
+ return {"name": None, "arguments": {}}
185
+ body_close = _scan_balanced_braces(body, 0)
186
+ if body_close is None:
187
+ return {"name": None, "arguments": {}}
188
+ inner = body[1:body_close]
189
+ name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
190
+ name = name_match.group(1) if name_match else None
191
+ arguments: dict = {}
192
+ args_match = re.search(r'args\s*=>\s*\{', inner)
193
+ if args_match:
194
+ args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
195
+ if args_outer is not None:
196
+ args_body = inner[args_match.end():args_outer]
197
+ for am in re.finditer(
198
+ r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
199
+ ):
200
+ arguments[am.group(1)] = am.group(2)
201
+ return {"name": name, "arguments": arguments}
202
+
203
+
204
+ def _extract_legacy_tool_uses(
205
+ text: str, id_start: int = 0
206
+ ) -> tuple[list[dict], str, int]:
207
+ """Extract all [TOOL_CALL]...[/TOOL_CALL] blocks from text.
208
+
209
+ Returns (tool_uses, cleaned_text, next_id) where tool_uses has
210
+ [{id, name, input}, ...] and cleaned_text has all markers removed.
211
+ ``id_start`` lets the caller share a monotonic counter across multiple
212
+ extraction passes; the next free id is returned so callers can pass it
213
+ forward.
214
+ """
215
+ tool_uses: list[dict] = []
216
+ cleaned = text
217
+ counter = id_start
218
+ while True:
219
+ open_idx = cleaned.find(_LEGACY_OPEN)
220
+ if open_idx < 0:
221
+ break
222
+ body_start = open_idx + len(_LEGACY_OPEN)
223
+ close_idx = cleaned.find(_LEGACY_CLOSE, body_start)
224
+ if close_idx < 0:
225
+ # Unclosed marker — stop looking
226
+ break
227
+ body = cleaned[body_start:close_idx]
228
+ span_end = close_idx + len(_LEGACY_CLOSE)
229
+ parsed = _parse_legacy_tool_body(body)
230
+ name = parsed.get("name")
231
+ if name:
232
+ counter += 1
233
+ tool_uses.append({
234
+ "id": f"legacy-{counter}",
235
+ "name": name,
236
+ "input": parsed.get("arguments", {}),
237
+ })
238
+ cleaned = cleaned[:open_idx] + cleaned[span_end:]
239
+ return tool_uses, cleaned, counter
240
+
241
+
242
+ _TASK_STATUSES = {"pending", "in_progress", "completed"}
243
+
244
+
245
+ class TaskManager:
246
+ """V2 task manager with dependency tracking (Clawd-Code style).
247
+
248
+ Tasks have: id, subject, description, status, owner, blocks, blockedBy, metadata, output.
249
+ Supports dependency tracking for multi-agent coordination.
250
+ """
251
+
252
+ def __init__(self, on_change: Callable[[], None] | None = None):
253
+ self._tasks: dict[str, dict] = {} # id -> task dict
254
+ self._todos: list[dict] = []
255
+ self._plan: list[str] = []
256
+ self._id_counter = 0
257
+ self._on_change = on_change
258
+
259
+ def _mark_dirty(self) -> None:
260
+ if self._on_change:
261
+ self._on_change()
262
+
263
+ def _next_id(self) -> str:
264
+ self._id_counter += 1
265
+ return f"t{self._id_counter}"
266
+
267
+ # ── V2 Task Management ──
268
+
269
+ def create_task(
270
+ self,
271
+ subject: str,
272
+ description: str = "",
273
+ active_form: str = "",
274
+ metadata: dict | None = None,
275
+ ) -> dict:
276
+ """Create a task with dependency tracking support."""
277
+ task_id = self._next_id()
278
+ task = {
279
+ "id": task_id,
280
+ "_order": self._id_counter,
281
+ "subject": subject,
282
+ "description": description,
283
+ "activeForm": active_form,
284
+ "status": "pending",
285
+ "owner": None,
286
+ "blocks": [],
287
+ "blockedBy": [],
288
+ "metadata": dict(metadata or {}),
289
+ "output": "",
290
+ }
291
+ self._tasks[task_id] = task
292
+ self._mark_dirty()
293
+ return task
294
+
295
+ def get_task(self, task_id: str) -> dict | None:
296
+ return self._tasks.get(task_id)
297
+
298
+ def list_tasks(self) -> list[dict]:
299
+ """List all tasks in creation order."""
300
+ return list(self._tasks.values())
301
+
302
+ def update_task(self, task_id: str, **fields) -> dict | None:
303
+ """Update task fields. Supports: subject, description, activeForm, status,
304
+ owner, metadata, addBlocks, addBlockedBy. Returns updated task or None."""
305
+ task = self._tasks.get(task_id)
306
+ if task is None:
307
+ return None
308
+
309
+ updated = []
310
+
311
+ # String fields
312
+ for field in ("subject", "description", "activeForm", "owner"):
313
+ if field in fields:
314
+ v = fields[field]
315
+ if isinstance(v, str) and v != task.get(field):
316
+ task[field] = v
317
+ updated.append(field)
318
+
319
+ # Status field
320
+ if "status" in fields:
321
+ status = fields["status"]
322
+ if status == "deleted":
323
+ self._tasks.pop(task_id, None)
324
+ self._mark_dirty()
325
+ return {"id": task_id, "deleted": True}
326
+ if status in _TASK_STATUSES and status != task.get("status"):
327
+ task["status"] = status
328
+ updated.append("status")
329
+
330
+ # Dependency fields
331
+ for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
332
+ if input_key in fields:
333
+ ids = fields[input_key]
334
+ if isinstance(ids, list):
335
+ cur = list(task.get(rel_field) or [])
336
+ for x in ids:
337
+ if isinstance(x, str) and x not in cur:
338
+ cur.append(x)
339
+ if cur != task.get(rel_field):
340
+ task[rel_field] = cur
341
+ updated.append(rel_field)
342
+
343
+ # Metadata
344
+ if "metadata" in fields:
345
+ md = fields["metadata"]
346
+ if isinstance(md, dict):
347
+ existing = dict(task.get("metadata") or {})
348
+ for k, v in md.items():
349
+ if v is None:
350
+ existing.pop(k, None)
351
+ else:
352
+ existing[k] = v
353
+ task["metadata"] = existing
354
+ updated.append("metadata")
355
+
356
+ # Output
357
+ if "output" in fields:
358
+ v = fields["output"]
359
+ if isinstance(v, str):
360
+ task["output"] = v
361
+ updated.append("output")
362
+
363
+ self._mark_dirty()
364
+ return task
365
+
366
+ def get_task_output(self, task_id: str) -> dict:
367
+ """Get output for a task."""
368
+ task = self._tasks.get(task_id)
369
+ if task is None:
370
+ return {"retrieval_status": "not_found", "task": None}
371
+ output = str(task.get("output") or "")
372
+ return {
373
+ "retrieval_status": "success" if output else "not_ready",
374
+ "task": {
375
+ "task_id": task_id,
376
+ "task_type": "task_list",
377
+ "status": task.get("status"),
378
+ "description": task.get("description"),
379
+ "output": output,
380
+ },
381
+ }
382
+
383
+ def clear_tasks(self) -> None:
384
+ self._tasks.clear()
385
+ self._mark_dirty()
386
+
387
+ # ── Todo Management (unchanged API) ──
388
+
389
+ def add_todo(self, text: str) -> str:
390
+ tid = self._next_id()
391
+ self._todos.append({"text": text, "done": False, "id": tid})
392
+ self._mark_dirty()
393
+ return tid
394
+
395
+ def complete_todo(self, todo_id: str) -> bool:
396
+ for t in self._todos:
397
+ if t["id"] == todo_id:
398
+ t["done"] = True
399
+ self._mark_dirty()
400
+ return True
401
+ return False
402
+
403
+ def remove_todo(self, todo_id: str) -> bool:
404
+ before = len(self._todos)
405
+ self._todos = [t for t in self._todos if t["id"] != todo_id]
406
+ result = len(self._todos) < before
407
+ self._mark_dirty()
408
+ return result
409
+
410
+ def list_todos(self) -> list[dict]:
411
+ return self._todos
412
+
413
+ def clear_todos(self) -> None:
414
+ self._todos = []
415
+ self._mark_dirty()
416
+
417
+ def set_plan(self, plan: list[str]) -> None:
418
+ self._plan = plan
419
+ self._mark_dirty()
420
+
421
+ def get_plan(self) -> list[str]:
422
+ return self._plan
423
+
424
+ # ── Serialization ──
425
+
426
+ def to_dict(self) -> dict:
427
+ """Serialize all tasks and todos for persistence."""
428
+ return {
429
+ "tasks": [dict(t) for t in self._tasks.values()],
430
+ "todos": [dict(t) for t in self._todos],
431
+ "plan": list(self._plan),
432
+ "id_counter": self._id_counter,
433
+ }
434
+
435
+ @classmethod
436
+ def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> TaskManager:
437
+ """Restore task manager from saved dict."""
438
+ tm = cls(on_change=on_change)
439
+ tm._id_counter = data.get("id_counter", 0)
440
+ tm._plan = list(data.get("plan", []))
441
+ for t in data.get("tasks", []):
442
+ tid = t.get("id", tm._next_id())
443
+ tm._tasks[tid] = dict(t)
444
+ for t in data.get("todos", []):
445
+ tm._todos.append(dict(t))
446
+ return tm
447
+
448
+
449
+ # Tools that require a task plan before execution (plan gate)
450
+ _EXECUTION_TOOLS: frozenset[str] = frozenset({
451
+ "Write", "Edit", "Insert", "ApplyPatch", "Bash",
452
+ })
453
+
454
+
455
+ class AgentEngine:
456
+ def __init__(self, app, project_dir: Path | None = None, perm_store: PermissionStore | None = None):
457
+ from onecode.agent.tools.file_ops import ToolFactory
458
+ from onecode.agent.agents.types import BuildAgent
459
+ from onecode.agent.hooks import HookManager
460
+ from onecode.skills.loader import SkillLoader
461
+ from onecode.mcp.manager import MCPManager
462
+ from onecode.agent.tools.cron_tools import CronScheduler
463
+ from onecode.agent.tools.lsp_tools import LSPTool
464
+ from onecode.agent.tools.config_tool import ConfigReadTool, ConfigWriteTool
465
+ from onecode.agent.tools.communication_tools import SendMessageTool
466
+ self.app = app
467
+ self._project_dir = (project_dir or Path.cwd()).resolve()
468
+ self.context = ContextManager()
469
+ self.file_ops = ToolFactory.create_file_ops(self._project_dir)
470
+ self.shell = ToolFactory.create_shell(self._project_dir)
471
+ self.current_agent: AgentConfig = BuildAgent()
472
+ self.iterations = 0
473
+ self.total_tokens = 0
474
+ self._skills_loaded = False
475
+ self._session: Optional[AgentSession] = None
476
+ self._hooks = HookManager()
477
+ self._task_manager = TaskManager(on_change=self._on_task_change)
478
+ self._plan_dirty: bool = False
479
+ self._project_config: dict = {}
480
+ self._project_context_loaded = False
481
+ self._pending_approval: dict | None = None # {tool_call, result_key}
482
+ self._last_user_msg: str | None = None # Last SendMessage visible to user
483
+
484
+ # Clawd-Code subsystems
485
+ self._skill_loader = SkillLoader()
486
+ self._mcp = MCPManager()
487
+ self._cron_scheduler = CronScheduler()
488
+ self._lsp_tool = LSPTool()
489
+ app_config = getattr(app, 'config', None)
490
+ self._config_tool_read = ConfigReadTool(app_config) if app_config else None
491
+ self._config_tool_write = ConfigWriteTool(app_config) if app_config else None
492
+
493
+ # Codebase engine (lazy init)
494
+ self._codebase_engine: Optional["CodebaseEngine"] = None
495
+
496
+ # Tool registry (Clawd-Code pattern)
497
+ self._tool_registry = self._build_tool_registry()
498
+ self._send_message_tool = SendMessageTool()
499
+
500
+ # Per-turn usage tracking (Clawd-Code style)
501
+ self._turn_usages: list[dict[str, int]] = []
502
+
503
+ # Event callbacks
504
+ self.on_event: ToolEventHandler | None = None
505
+ self.on_text_chunk: Callable[[str], None] | None = None
506
+ self.on_tool_call_delta: Callable[[str, str, str], None] | None = None
507
+ self.on_thinking: Callable[[str], None] | None = None
508
+ self._streaming_used: bool = False
509
+ self._pending_thinking_blocks: list[str] = []
510
+
511
+ # Cancellation support
512
+ self._cancelled: bool = False
513
+
514
+ # Monotonic tool-call id counter — shared across every
515
+ # ``chat_stream`` turn so the same id is never reused even when the
516
+ # model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
517
+ # Touched only by ``_extract_*_tool_uses`` through the engine
518
+ # instance, not by the module-level helpers (which take an explicit
519
+ # ``id_start`` for testability).
520
+ self._tool_id_counter: int = 0
521
+
522
+ # Subagent engines spawned by this engine — we track them so
523
+ # cancelling the parent also cancels in-flight subagents.
524
+ self._child_engines: list[AgentEngine] = []
525
+
526
+ # Permission overrides shared across subagents
527
+ self._perm_store: PermissionStore = perm_store or PermissionStore()
528
+
529
+ # Plan gate: mode-aware enforcement based on agent type
530
+ # "hard" (plan mode) rejects execution tools without plan
531
+ # "soft" (build/solo mode) suggests planning but doesn't block
532
+ # "off" (agents with permission_task=DENY) no enforcement
533
+ self._plan_gate_mode: str = "off"
534
+ self._plan_gate_fired: bool = False
535
+
536
+ # ReAct state tracking
537
+ self._react_phase: str = "thought" # "thought" | "action" | "observation"
538
+ self._direct_execution_count: int = 0 # Track direct tool use for Task-first routing
539
+
540
+ def _build_tool_registry(self) -> ToolRegistry:
541
+ from onecode.agent.tools.registry import ToolRegistry
542
+ from onecode.agent.tools.file_tools import ReadTool, WriteTool, EditTool, InsertTool, UndoEditTool, GlobTool, GrepTool, ListTool
543
+ from onecode.agent.tools.apply_patch_tool import ApplyPatchTool
544
+ from onecode.agent.tools.bash_tool import BashTool
545
+ from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
546
+ from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
547
+ from onecode.agent.tools.task_tools import (TaskCreateTool, TaskGetTool, TaskListTool, TaskUpdateTool,
548
+ TaskOutputTool, TaskStopTool, TodoCreateTool, TodoListTool, TodoCompleteTool)
549
+ from onecode.agent.tools.agent_tools import AgentTool, TaskTool
550
+ from onecode.agent.tools.skill_tools import SkillTool
551
+ from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
552
+ from onecode.agent.tools.cron_tools import CronCreateTool, CronListTool, CronRemoveTool
553
+ from onecode.agent.tools.git_tools import WorktreeTool
554
+ registry = ToolRegistry()
555
+ # File tools
556
+ registry.register(ReadTool(self.file_ops))
557
+ registry.register(WriteTool(self.file_ops))
558
+ registry.register(EditTool(self.file_ops))
559
+ registry.register(InsertTool(self.file_ops))
560
+ registry.register(UndoEditTool(self.file_ops))
561
+ registry.register(GlobTool(self.file_ops))
562
+ registry.register(GrepTool(self.file_ops))
563
+ registry.register(ListTool(self.file_ops))
564
+ registry.register(ApplyPatchTool(self.file_ops))
565
+ # Shell
566
+ registry.register(BashTool(self.shell))
567
+ # Web
568
+ registry.register(WebFetchTool())
569
+ registry.register(WebSearchTool())
570
+ # Communication
571
+ self._send_message_tool = SendMessageTool()
572
+ registry.register(self._send_message_tool)
573
+ registry.register(AskUserTool())
574
+ registry.register(ToolSearchTool(registry))
575
+ # Task management
576
+ registry.register(TaskCreateTool(self._task_manager))
577
+ registry.register(TaskGetTool(self._task_manager))
578
+ registry.register(TaskListTool(self._task_manager))
579
+ registry.register(TaskUpdateTool(self._task_manager))
580
+ registry.register(TaskOutputTool(self._task_manager))
581
+ registry.register(TaskStopTool(self._task_manager))
582
+ registry.register(TodoCreateTool(self._task_manager))
583
+ registry.register(TodoListTool(self._task_manager))
584
+ registry.register(TodoCompleteTool(self._task_manager))
585
+ # Agent tools
586
+ registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
587
+ registry.register(TaskTool(self._spawn_subagent_async))
588
+
589
+ # Skill tool (Clawd-Code pattern)
590
+ registry.register(SkillTool(self._skill_loader))
591
+
592
+ # MCP tools (Clawd-Code pattern)
593
+ registry.register(MCPToolTool(self._mcp))
594
+ registry.register(MCPResourcesTool(self._mcp))
595
+
596
+ # LSP tool (Clawd-Code pattern)
597
+ registry.register(self._lsp_tool)
598
+
599
+ # Cron tools (Clawd-Code pattern)
600
+ registry.register(CronCreateTool(self._cron_scheduler))
601
+ registry.register(CronListTool(self._cron_scheduler))
602
+ registry.register(CronRemoveTool(self._cron_scheduler))
603
+
604
+ # Git worktree tool (Clawd-Code pattern)
605
+ registry.register(WorktreeTool(workspace_root=self._project_dir))
606
+
607
+ # Config tools (Clawd-Code pattern)
608
+ if self._config_tool_read:
609
+ registry.register(self._config_tool_read)
610
+ if self._config_tool_write:
611
+ registry.register(self._config_tool_write)
612
+
613
+ # Codebase search tool (lazy — engine initialised on first search)
614
+ from onecode.codebase.tools import CodebaseSearchTool
615
+ registry.register(CodebaseSearchTool(lambda: self._codebase_engine))
616
+ return registry
617
+
618
+ def _resolve_plan_gate_mode(self) -> str:
619
+ """Determine plan gate strictness based on current agent type.
620
+
621
+ Returns "hard" (reject execution tools without plan), "soft" (nudge only),
622
+ or "off" (no enforcement).
623
+ """
624
+ from onecode.agent.agents.types import AgentPermission
625
+ if self.current_agent.permission_task == AgentPermission.DENY:
626
+ return "off"
627
+ if self.current_agent.name == "plan":
628
+ return "hard"
629
+ return "soft"
630
+
631
+ def _plan_gate_first_turn(self) -> bool:
632
+ """Returns True only on the first turn where no plan exists yet.
633
+
634
+ Once tasks are created or the gate has fired, subsequent turns
635
+ proceed without gate intervention.
636
+ """
637
+ if self._plan_gate_fired:
638
+ return False
639
+ self._plan_gate_fired = True
640
+ return True
641
+
642
+ def _notify_event(self, event: ToolEvent) -> None:
643
+ """Emit a ToolEvent to the registered callback (Clawd-Code pattern)."""
644
+ if self.on_event:
645
+ try:
646
+ self.on_event(event)
647
+ except Exception as e:
648
+ logger.warning("ToolEvent callback failed: %s", e)
649
+
650
+ def _emit_text_chunks(self, text: str, chunk_size: int = 12) -> None:
651
+ """Emit user-visible text in small chunks (Clawd-Code pattern)."""
652
+ if self.on_text_chunk is None or not text:
653
+ return
654
+ if chunk_size <= 0:
655
+ chunk_size = len(text)
656
+ for idx in range(0, len(text), chunk_size):
657
+ try:
658
+ self.on_text_chunk(text[idx:idx + chunk_size])
659
+ except Exception as e:
660
+ logger.warning("on_text_chunk failed: %s", e)
661
+ return
662
+
663
+ def _emit_plan_update(self) -> list[StreamEvent]:
664
+ """Emit a plan update event from current tasks and todos."""
665
+ entries = []
666
+ for task in self._task_manager.list_tasks():
667
+ entries.append({
668
+ "content": task.get("subject", task.get("description", "")),
669
+ "status": task.get("status", "pending"),
670
+ "priority": task.get("metadata", {}).get("priority", "medium"),
671
+ })
672
+ for todo in self._task_manager.list_todos():
673
+ entries.append({
674
+ "content": todo.get("text", ""),
675
+ "status": "completed" if todo.get("done") else "pending",
676
+ "priority": "low",
677
+ })
678
+ return [StreamEvent.plan(entries)]
679
+
680
+ def _on_task_change(self) -> None:
681
+ """Callback invoked by TaskManager whenever tasks/todos mutate.
682
+
683
+ Sets a dirty flag so the next opportunity in ``chat_stream`` will
684
+ emit a fresh plan event. Decoupling the callback from the emit
685
+ keeps the public API synchronous and avoids re-entrancy when
686
+ subagents mutate the same TaskManager concurrently.
687
+ """
688
+ self._plan_dirty = True
689
+
690
+ @property
691
+ def _workspace(self) -> Path:
692
+ return self._project_dir
693
+
694
+ def set_agent(self, agent_type: str) -> None:
695
+ from onecode.agent.agents.types import (
696
+ AgentPermission,
697
+ create_agent,
698
+ PLAN_INSTRUCTIONS,
699
+ REACT_CYCLE,
700
+ TOOL_DESCRIPTIONS,
701
+ filter_tool_descriptions,
702
+ )
703
+ self.current_agent = create_agent(agent_type)
704
+ system_parts = [self.current_agent.description]
705
+
706
+ edit_ask = self.current_agent.should_ask_for_edit()
707
+ bash_ask = self.current_agent.should_ask_for_bash()
708
+ if edit_ask or bash_ask:
709
+ restrictions = []
710
+ if edit_ask:
711
+ restrictions.append("- File edits require user approval")
712
+ if bash_ask:
713
+ restrictions.append("- Shell commands require user approval")
714
+ system_parts.append("\n".join(restrictions))
715
+
716
+ if self.current_agent.permission_task != AgentPermission.DENY:
717
+ system_parts.append(REACT_CYCLE)
718
+ system_parts.append(PLAN_INSTRUCTIONS)
719
+
720
+ # Tell the model how to format intermediate reasoning so the TUI
721
+ # can render it as a collapsible "thought" block instead of
722
+ # leaking it into the visible answer. Without these tags, the
723
+ # model's planning prose ("I will now update X…", "X is done,
724
+ # let me verify…") flows through as plain ``agent_message_chunk``
725
+ # and shows up in chat interleaved with tool calls.
726
+ system_parts.append(
727
+ "\n## Response style\n"
728
+ "- **Every turn must start with Chain of Thought reasoning** "
729
+ "inside `<thinking>`. Analyze the current state, determine the "
730
+ "next step, and decide whether to delegate via `Task`.\n"
731
+ "- **Prefer `Task` delegation**: For any multi-step operation, "
732
+ "file modification, or research, use `Task(agent_type, prompt)` "
733
+ "rather than calling execution tools directly.\n"
734
+ "- If you need to reason between tool calls, wrap your "
735
+ "reasoning in `<thinking>...</thinking>`. The TUI will "
736
+ "render the wrapped block as a collapsible thought and "
737
+ "keep it out of the main answer.\n"
738
+ "- Do not narrate your plan in the visible answer. Avoid "
739
+ 'phrases like "I will now…", "Let me first…", "X is done, '
740
+ 'let me verify…" — those should go inside `<thinking>`. '
741
+ "The visible answer is only the final user-facing summary.\n"
742
+ )
743
+
744
+ tool_desc = filter_tool_descriptions(
745
+ allowlist=self.current_agent.tools or None,
746
+ denylist=self.current_agent.disallowed_tools or None,
747
+ )
748
+ system_parts.append(tool_desc)
749
+
750
+ tagged_content = "<!-- AGENT_CONFIG -->\n" + "\n".join(system_parts)
751
+ if not self.context.replace_system_section("AGENT_CONFIG", tagged_content):
752
+ self.context.add_system(tagged_content)
753
+
754
+ # Re-apply user permission overrides (e.g. "allow always")
755
+ self._perm_store.apply_to(self.current_agent)
756
+
757
+ def get_available_tools(self) -> str:
758
+ from onecode.agent.agents.types import TOOL_DESCRIPTIONS, filter_tool_descriptions
759
+ return filter_tool_descriptions(
760
+ allowlist=self.current_agent.tools or None,
761
+ denylist=self.current_agent.disallowed_tools or None,
762
+ )
763
+
764
+ def _load_skills(self, force: bool = False) -> None:
765
+ if self._skills_loaded and not force:
766
+ return
767
+
768
+ # Remove previously loaded skill-tagged messages so they don't
769
+ # accumulate across agent switches or skill re-loads.
770
+ self.context.remove_system_by_marker("<!-- SKILL:")
771
+ self.context.remove_system_by_marker("<!-- CDH_PROJECT -->")
772
+ self._skills_loaded = True
773
+
774
+ for skill in self._skill_loader.get_enabled():
775
+ tagged = f"<!-- SKILL:{skill.name} -->\n{skill.content}"
776
+ self.context.add_system(tagged)
777
+
778
+ # Load project .cdh/ state into context
779
+ from onecode.agent.cdh_loader import CdhProjectLoader
780
+ cdh_content = CdhProjectLoader.load_for_workspace(self._workspace)
781
+ if cdh_content:
782
+ self.context.add_system(f"<!-- CDH_PROJECT -->\n{cdh_content}")
783
+
784
+ def _inject_project_context(self, project_name: str) -> None:
785
+ if not project_name:
786
+ return
787
+ if self._project_context_loaded:
788
+ return
789
+
790
+ self._project_context_loaded = True
791
+ self._project_config = {}
792
+
793
+ context_parts = [
794
+ f"Project: {project_name}",
795
+ f"Platform: {self._project_config.get('platform', 'unknown')}",
796
+ ]
797
+
798
+ env_id = self._project_config.get("cloudbase", {}).get("envId", "")
799
+ if env_id:
800
+ context_parts.append(f"TCB EnvId: {env_id}")
801
+
802
+ agents_md_path = self._workspace / "AGENTS.md"
803
+ if agents_md_path.exists():
804
+ try:
805
+ content = agents_md_path.read_text(encoding="utf-8")
806
+ context_parts.append(f"\n--- AGENTS.md ---\n{content[:2000]}")
807
+ except Exception:
808
+ pass
809
+
810
+ self.context.add_system("\n".join(context_parts))
811
+
812
+ def _should_retrieve_codebase(self) -> bool:
813
+ try:
814
+ cfg = self.app.config.codebase
815
+ return cfg.enabled and cfg.auto_retrieve
816
+ except Exception:
817
+ return False
818
+
819
+ async def _get_codebase_engine(self):
820
+ if self._codebase_engine is not None:
821
+ return self._codebase_engine
822
+ try:
823
+ cfg = self.app.config.codebase
824
+ if not cfg.enabled:
825
+ return None
826
+ from onecode.codebase import CodebaseEngine
827
+ self._codebase_engine = CodebaseEngine(self._project_dir, cfg)
828
+ await self._codebase_engine.ensure_indexed()
829
+ return self._codebase_engine
830
+ except Exception as e:
831
+ logger.warning("Failed to init codebase engine: %s", e)
832
+ return None
833
+
834
+ async def chat(self, user_input: str) -> str:
835
+ self._load_skills()
836
+
837
+ project_name = getattr(self.app, "current_project", None) or ""
838
+ if project_name:
839
+ self._inject_project_context(project_name)
840
+
841
+ self.context.add_user(user_input)
842
+
843
+ self.context.config.model = self.app.current_model
844
+ if self.context.should_compact():
845
+ level = self.context.compact()
846
+ if level not in ("none",):
847
+ logger.info("Context compaction applied: %s", level)
848
+
849
+ provider_cls = ProviderRegistry.get(self.app.current_provider)
850
+ if not provider_cls:
851
+ return f"Provider '{self.app.current_provider}' not available."
852
+
853
+ config = self.app.config.providers.get(self.app.current_provider)
854
+ if config is None:
855
+ return f"Provider '{self.app.current_provider}' not configured."
856
+
857
+ provider = provider_cls(
858
+ api_key=config.api_key or "",
859
+ endpoint=config.endpoint or None,
860
+ )
861
+
862
+ response = await provider.chat(
863
+ self.context.get_context(),
864
+ model=self.app.current_model,
865
+ )
866
+
867
+ self.context.add_assistant(response.content)
868
+ self.iterations += 1
869
+ self.total_tokens += response.usage.get("total_tokens", 0)
870
+ return response.content
871
+
872
+ def _parse_tool_calls(self, text: str) -> list[dict]:
873
+ calls = []
874
+ for match in TOOL_CALL_RE.finditer(text):
875
+ name = match.group(1)
876
+ call_id = match.group(2)
877
+ raw_input = match.group(3).strip()
878
+ try:
879
+ parsed_input = json.loads(raw_input) if raw_input else {}
880
+ except json.JSONDecodeError:
881
+ parsed_input = {"raw": raw_input}
882
+ calls.append({"name": name, "id": call_id, "input": parsed_input})
883
+ return calls
884
+
885
+ def _validate_edit(self, path: str) -> str | None:
886
+ try:
887
+ content = self.file_ops.read(path, 0, 0)
888
+ if content and "error" not in str(content).lower():
889
+ return None
890
+ return f"Warning: File may not have been written correctly: {path}"
891
+ except Exception as e:
892
+ return f"Warning: Could not verify file: {e}"
893
+
894
+ def _tool_category(self, name: str) -> str:
895
+ """Map tool name to category for structured display."""
896
+ from onecode.models.messages import get_tool_category
897
+ return get_tool_category(name).value
898
+
899
+ _TOOL_NAME_TO_PERM_KEY: dict[str, str] = {
900
+ "Read": "read",
901
+ "Write": "edit",
902
+ "Edit": "edit",
903
+ "Insert": "edit",
904
+ "UndoEdit": "edit",
905
+ "ApplyPatch": "edit",
906
+ "Bash": "bash",
907
+ "WebFetch": "webfetch",
908
+ "WebSearch": "websearch",
909
+ "Glob": "glob",
910
+ "Grep": "grep",
911
+ "List": "list",
912
+ "Task": "task",
913
+ "Agent": "task",
914
+ "Skill": "skill",
915
+ }
916
+
917
+ def _check_tool_permission(self, name: str, inp: dict) -> str | None:
918
+ """Unified agent-level permission check for all tools."""
919
+ from onecode.agent.agents.types import AgentPermission
920
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
921
+ if perm_key is None:
922
+ return None
923
+ tools_config = self.current_agent.get_tools_config()
924
+ perm = tools_config.get(perm_key, AgentPermission.ALLOW)
925
+ if perm == AgentPermission.DENY:
926
+ return json.dumps({"success": False, "error": f"{name} denied"})
927
+ if perm == AgentPermission.ASK:
928
+ return json.dumps({"success": False, "error": f"{name} requires approval", "requires_approval": True})
929
+ return None
930
+
931
+ async def _execute_tool(self, tool_call: dict) -> dict:
932
+ from onecode.agent.tools.registry import ToolCall as RegistryToolCall
933
+ name = tool_call["name"]
934
+ tid = tool_call["id"]
935
+ inp = tool_call["input"]
936
+ category = self._tool_category(name)
937
+ base = {"tool_use_id": tid, "is_error": False, "category": category}
938
+ try:
939
+ denied = self._check_tool_permission(name, inp)
940
+ if denied:
941
+ return {**base, "content": denied, "is_error": True}
942
+
943
+ call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
944
+ result = self._tool_registry.dispatch(call)
945
+
946
+ # Handle SendMessage tracking
947
+ if name == "SendMessage":
948
+ msg_output = result.output if isinstance(result.output, dict) else {}
949
+ self._last_user_msg = msg_output.get("message", "")
950
+ return {**base, "content": json.dumps(result.output), "is_error": result.is_error}
951
+
952
+ # Format output for backward compatibility
953
+ output = self._format_tool_output(result)
954
+ return {**base, "content": output, "is_error": result.is_error}
955
+ except Exception as e:
956
+ logger.exception(f"Tool execution error: {e}")
957
+ return {**base, "content": f"Error: {e}", "is_error": True}
958
+
959
+ def _format_tool_output(self, result: RegistryToolResult) -> str:
960
+ import json
961
+ output = result.output
962
+ if isinstance(output, str):
963
+ return output
964
+ try:
965
+ return json.dumps(output)
966
+ except (TypeError, ValueError):
967
+ return str(output)
968
+
969
+ async def cancel(self):
970
+ """Cancel the current chat_stream turn and all in-flight subagents."""
971
+ self._cancelled = True
972
+ for child in self._child_engines:
973
+ child._cancelled = True
974
+
975
+ async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
976
+ self._load_skills()
977
+ preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
978
+ logger.info(f"chat_stream() called with user_input='{preview}'")
979
+
980
+ project_name = getattr(self.app, "current_project", None) or ""
981
+ if project_name:
982
+ was_context_loaded = self._project_context_loaded
983
+ self._inject_project_context(project_name)
984
+ if not was_context_loaded:
985
+ yield StreamEvent.text_delta(
986
+ f"\n📋 项目: {project_name}\n继续开发中...\n\n"
987
+ )
988
+ else:
989
+ pass
990
+
991
+ # Emit initial plan so the TUI Plan widget is mounted (or refreshed
992
+ # to the current snapshot) at the start of every turn. This is
993
+ # required because the Plan widget only renders when an ``entries``
994
+ # value arrives — without this, the widget never appears until the
995
+ # agent happens to call a task tool.
996
+ for event in self._emit_plan_update():
997
+ yield event
998
+ self._plan_dirty = False
999
+
1000
+ # ── Codebase auto-retrieval ──
1001
+ if isinstance(user_input, str) and self._should_retrieve_codebase():
1002
+ try:
1003
+ engine = await self._get_codebase_engine()
1004
+ if engine:
1005
+ chunks = await engine.retrieve(user_input)
1006
+ ctx_text = engine.format_context(
1007
+ chunks, max_tokens=self.app.config.codebase.max_chunk_tokens
1008
+ )
1009
+ if ctx_text:
1010
+ if not self.context.replace_system_section("CODEBASE", ctx_text):
1011
+ self.context.add_system(f"<!-- CODEBASE -->\n{ctx_text}")
1012
+ except Exception as e:
1013
+ logger.warning("Codebase retrieval failed: %s", e)
1014
+
1015
+ self.context.add_user(user_input)
1016
+
1017
+ # Plan gate: activate based on agent mode, not content heuristics.
1018
+ # Gate fires reactively when agent tries execution tools without a plan.
1019
+ self._plan_gate_mode = self._resolve_plan_gate_mode()
1020
+
1021
+ self.context.config.model = self.app.current_model
1022
+ if self.context.should_compact():
1023
+ level = self.context.compact()
1024
+ if level not in ("none",):
1025
+ logger.info("Context compaction at turn start: %s", level)
1026
+
1027
+ provider_name = self.app.current_provider
1028
+ model_name = self.app.current_model
1029
+ logger.info(f"Using provider='{provider_name}', model='{model_name}'")
1030
+ logger.info(
1031
+ "chat_stream engine state: project=%s agent=%s tasks=%d todos=%d context_msgs=%d ctx_tokens=%d",
1032
+ project_name or "(none)",
1033
+ self.current_agent.name if self.current_agent else "None",
1034
+ len(self._task_manager.list_tasks()),
1035
+ len(self._task_manager.list_todos()),
1036
+ len(self.context.messages),
1037
+ self.context._token_count,
1038
+ )
1039
+
1040
+ provider_cls = ProviderRegistry.get(provider_name)
1041
+ if not provider_cls:
1042
+ available = list(ProviderRegistry._registry.keys()) if hasattr(ProviderRegistry, '_registry') else "unknown"
1043
+ error_msg = f"Provider '{provider_name}' not available. Available: {available}"
1044
+ logger.error(error_msg)
1045
+ yield StreamEvent.error(error_msg)
1046
+ return
1047
+
1048
+ config = self.app.config.providers.get(provider_name)
1049
+ if config is None:
1050
+ available = list(self.app.config.providers.keys())
1051
+ error_msg = f"Provider '{provider_name}' not configured. Available providers: {available}"
1052
+ logger.error(error_msg)
1053
+ yield StreamEvent.error(error_msg)
1054
+ return
1055
+
1056
+ logger.info(f"Creating provider instance: {provider_cls.__name__}")
1057
+ provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
1058
+ provider = provider_cls(**provider_kwargs)
1059
+
1060
+ # Reset per-turn usage tracking
1061
+ self._turn_usages = []
1062
+
1063
+ # Reset cancellation flag (adapter also resets it before calling)
1064
+ self._cancelled = False
1065
+
1066
+ # ── Agent loop: CoT + ReAct (思考 → 行动 → 观察) ──
1067
+ is_anthropic = provider.is_anthropic_style()
1068
+ max_turns = self.current_agent.max_turns or 10
1069
+
1070
+ # Initialize ReAct phase section (first turn prompt)
1071
+ cot_phase_init = (
1072
+ "<!-- REACT_PHASE -->\n"
1073
+ "## Turn 1 — Thought Phase (思考)\n"
1074
+ "Begin this turn by reasoning step by step in `<thinking>`:\n"
1075
+ "1. **Current state**: Analyze the user request and what needs to be done.\n"
1076
+ "2. **Goal**: What should I accomplish this turn?\n"
1077
+ "3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
1078
+ "or use a direct tool?\n"
1079
+ "4. **Task-first**: For any multi-step work or file modifications, "
1080
+ "prefer `Task(agent_type, prompt)` over direct execution tools.\n"
1081
+ )
1082
+ if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
1083
+ self.context.add_system(cot_phase_init)
1084
+
1085
+ for turn in range(max_turns):
1086
+ if self._cancelled:
1087
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1088
+ return
1089
+
1090
+ # ── Thought Phase: update CoT reasoning guidance for this turn ──
1091
+ # Cleanup stale reminders that may have accumulated
1092
+ self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
1093
+ if turn > 0:
1094
+ cot_phase = (
1095
+ f"<!-- REACT_PHASE -->\n"
1096
+ f"## Turn {turn + 1} — Thought Phase (思考)\n"
1097
+ "Begin this turn by reasoning step by step in `<thinking>`:\n"
1098
+ "1. **Current state**: What just happened? What results do I have?\n"
1099
+ "2. **Goal**: What should I accomplish this turn?\n"
1100
+ "3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
1101
+ "or use a direct tool?\n"
1102
+ "4. **Task-first**: For any multi-step work or file modifications, "
1103
+ "prefer `Task(agent_type, prompt)` over direct execution tools.\n"
1104
+ )
1105
+ self.context.replace_system_section("REACT_PHASE", cot_phase)
1106
+
1107
+ self._pending_thinking_blocks = []
1108
+
1109
+ self.context.config.model = self.app.current_model
1110
+ if self.context.should_compact():
1111
+ level = self.context.compact()
1112
+ if level not in ("none",):
1113
+ logger.info("Context compaction at turn %d: %s", turn, level)
1114
+
1115
+ turn_usage: dict[str, int] = {}
1116
+ response_text = ""
1117
+ tool_uses: list[dict] = []
1118
+
1119
+ try:
1120
+ context_messages = self.context.get_context()
1121
+ logger.info(f"Calling provider.chat_stream_response (turn {turn+1})")
1122
+ self._streaming_used = False
1123
+ if self.on_text_chunk is not None:
1124
+ _original_cb = self.on_text_chunk
1125
+ def _stream_wrapper(text: str) -> None:
1126
+ self._streaming_used = True
1127
+ _original_cb(text)
1128
+ stream_cb = _stream_wrapper
1129
+ else:
1130
+ stream_cb = None
1131
+ chat_response = await provider.chat_stream_response(
1132
+ context_messages,
1133
+ model=model_name,
1134
+ on_text_chunk=stream_cb,
1135
+ on_tool_call_delta=self.on_tool_call_delta,
1136
+ tools=self._tool_registry.make_openai_schemas(),
1137
+ )
1138
+ response_text = chat_response.content
1139
+ tool_uses = chat_response.tool_uses
1140
+ if not tool_uses and (
1141
+ "<minimax:tool_call>" in response_text
1142
+ or _LEGACY_OPEN in response_text
1143
+ ):
1144
+ parsed_uses, response_text, self._tool_id_counter = (
1145
+ _extract_minimax_tool_uses(
1146
+ response_text, id_start=self._tool_id_counter
1147
+ )
1148
+ )
1149
+ legacy_uses, response_text, self._tool_id_counter = (
1150
+ _extract_legacy_tool_uses(
1151
+ response_text, id_start=self._tool_id_counter
1152
+ )
1153
+ )
1154
+ tool_uses = parsed_uses + legacy_uses
1155
+ if tool_uses:
1156
+ logger.info(
1157
+ "Extracted %d text tool uses (%d minimax, %d legacy) from response",
1158
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1159
+ )
1160
+ if chat_response.usage:
1161
+ turn_usage = chat_response.usage
1162
+ except NotImplementedError:
1163
+ logger.info("chat_stream_response not supported, falling back to non-streaming chat()")
1164
+ try:
1165
+ context_messages = self.context.get_context()
1166
+ model_response = await provider.chat(
1167
+ context_messages,
1168
+ model=model_name,
1169
+ tools=self._tool_registry.make_openai_schemas(),
1170
+ )
1171
+ response_text = model_response.get_text()
1172
+ tool_uses = [
1173
+ {"id": tu.id, "name": tu.name, "input": tu.input}
1174
+ for cb in model_response.content
1175
+ if cb.type == ContentBlockType.TOOL_USE and cb.tool_use
1176
+ for tu in [cb.tool_use]
1177
+ ]
1178
+ if not tool_uses and (
1179
+ "<minimax:tool_call>" in response_text
1180
+ or _LEGACY_OPEN in response_text
1181
+ ):
1182
+ parsed_uses, response_text, self._tool_id_counter = (
1183
+ _extract_minimax_tool_uses(
1184
+ response_text, id_start=self._tool_id_counter
1185
+ )
1186
+ )
1187
+ legacy_uses, response_text, self._tool_id_counter = (
1188
+ _extract_legacy_tool_uses(
1189
+ response_text, id_start=self._tool_id_counter
1190
+ )
1191
+ )
1192
+ tool_uses = parsed_uses + legacy_uses
1193
+ if tool_uses:
1194
+ logger.info(
1195
+ "Extracted %d text tool uses (%d minimax, %d legacy) from chat() fallback",
1196
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1197
+ )
1198
+ if model_response.usage:
1199
+ turn_usage = model_response.usage
1200
+ except Exception as e:
1201
+ logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1202
+ yield StreamEvent.error(str(e))
1203
+ break
1204
+ except Exception as e:
1205
+ ctx_msgs = len(self.context.messages)
1206
+ ctx_tokens = self.context._token_count
1207
+ logger.exception(
1208
+ f"Error during chat_stream_response turn {turn+1}: {e}\n"
1209
+ f" provider={provider_name} model={model_name} "
1210
+ f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
1211
+ )
1212
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {e}")
1213
+ break
1214
+
1215
+ # Track usage
1216
+ self._turn_usages.append(turn_usage)
1217
+ if turn_usage:
1218
+ self.total_tokens += turn_usage.get("total_tokens", 0)
1219
+
1220
+ # Extract thinking blocks from response. In the streaming path
1221
+ # the adapter's _make_stream_callback already stripped <thinking>
1222
+ # markers from the text and routed them through self.on_thinking
1223
+ # (which appends to _pending_thinking_blocks). The non-streaming
1224
+ # fallback still has the markers inline, so we parse them here.
1225
+ thinking_blocks = list(self._pending_thinking_blocks)
1226
+ clean_text = response_text
1227
+ for match in THINKING_RE.finditer(response_text):
1228
+ tb = match.group(1)
1229
+ if tb not in thinking_blocks:
1230
+ thinking_blocks.append(tb)
1231
+ if thinking_blocks or self._pending_thinking_blocks:
1232
+ clean_text = THINKING_RE.sub('', response_text).strip()
1233
+ if not self._streaming_used:
1234
+ for tb in thinking_blocks:
1235
+ yield StreamEvent.thinking(tb)
1236
+
1237
+ # Log the raw model response so developers can verify the
1238
+ # model is actually emitting ``<thinking>`` markers (and not
1239
+ # bleeding planning prose into the visible answer). Goes
1240
+ # to the onecode root logger → ~/.onecode/logs/cdh.log when
1241
+ # ``setup_logging(DEBUG)`` is in effect.
1242
+ logger.debug(
1243
+ "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
1244
+ "thinking_blocks=%d tool_uses=%d streaming_used=%s",
1245
+ turn + 1,
1246
+ len(response_text),
1247
+ response_text[:200],
1248
+ len(thinking_blocks),
1249
+ len(tool_uses),
1250
+ self._streaming_used,
1251
+ )
1252
+
1253
+ # Add assistant response to context with proper content blocks.
1254
+ # Persist thinking blocks so they survive session reload — the
1255
+ # TUI replays them as collapsible Thought widgets.
1256
+ if tool_uses or thinking_blocks:
1257
+ assistant_blocks: list = []
1258
+ for tb in thinking_blocks:
1259
+ assistant_blocks.append({"type": "thinking", "thinking": tb})
1260
+ if clean_text:
1261
+ assistant_blocks.append({"type": "text", "text": clean_text})
1262
+ for tu in tool_uses:
1263
+ assistant_blocks.append({
1264
+ "type": "tool_use",
1265
+ "id": tu["id"],
1266
+ "name": tu["name"],
1267
+ "input": tu["input"],
1268
+ })
1269
+ self.context.add_assistant(assistant_blocks)
1270
+ else:
1271
+ self.context.add_assistant(clean_text)
1272
+
1273
+ self.iterations += 1
1274
+
1275
+ if not tool_uses:
1276
+ if not self._streaming_used:
1277
+ if clean_text.strip():
1278
+ for i in range(0, len(clean_text), 12):
1279
+ yield StreamEvent.text_delta(clean_text[i:i + 12])
1280
+ elif self._last_user_msg:
1281
+ yield StreamEvent.text_delta(self._last_user_msg)
1282
+ break
1283
+
1284
+ # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
1285
+ for tu in tool_uses:
1286
+ self._notify_event(ToolEvent(
1287
+ kind="tool_use",
1288
+ tool_name=tu["name"],
1289
+ tool_input=tu["input"],
1290
+ tool_use_id=tu["id"],
1291
+ ))
1292
+ yield StreamEvent.tool_call_start(tu["name"], tu["id"])
1293
+ for tu in tool_uses:
1294
+ yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
1295
+
1296
+ for tu in tool_uses:
1297
+ logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
1298
+
1299
+ # Task tool: forward subagent text deltas to the TUI as
1300
+ # subagent_chunk events so the SubAgent widget actually has
1301
+ # content to render. The Task tool's spec is (agent_type,
1302
+ # prompt); we read them from the tool input.
1303
+ if tu["name"] == "Task":
1304
+ # Permission check
1305
+ denied = self._check_tool_permission("Task", tu["input"])
1306
+ if denied:
1307
+ result = {
1308
+ "tool_use_id": tu["id"],
1309
+ "is_error": True,
1310
+ "category": "task",
1311
+ "content": denied,
1312
+ }
1313
+ else:
1314
+ subagent_type = tu["input"].get("agent_type", "general")
1315
+ subagent_prompt = tu["input"].get("prompt", "")
1316
+ yield StreamEvent.subagent_start(subagent_type, tu["id"])
1317
+ accumulated: list[str] = []
1318
+ async for sub_event, sub_text in self._spawn_subagent_async_streaming(
1319
+ subagent_type, subagent_prompt
1320
+ ):
1321
+ if sub_text and sub_event.type == StreamEventType.SUBAGENT_END:
1322
+ accumulated.append(sub_text)
1323
+ elif sub_text:
1324
+ accumulated.append(sub_text)
1325
+ yield StreamEvent.subagent_chunk(tu["id"], sub_text)
1326
+ yield StreamEvent.subagent_end(tu["id"])
1327
+ formatted = self._format_subagent_output(
1328
+ subagent_type, subagent_prompt, "".join(accumulated)
1329
+ )
1330
+ result = {
1331
+ "tool_use_id": tu["id"],
1332
+ "is_error": False,
1333
+ "category": "task",
1334
+ "content": formatted,
1335
+ }
1336
+ elif (self._plan_gate_mode != "off"
1337
+ and not self._task_manager.list_tasks()
1338
+ and tu["name"] in _EXECUTION_TOOLS
1339
+ and self._plan_gate_first_turn()):
1340
+ if self._plan_gate_mode == "hard":
1341
+ result = {
1342
+ "tool_use_id": tu["id"],
1343
+ "is_error": True,
1344
+ "category": "task",
1345
+ "content": json.dumps({
1346
+ "success": False,
1347
+ "error": (
1348
+ "Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
1349
+ "You MUST create a task plan using task_create() before using execution tools. "
1350
+ "First Think (analyze/explore), then Act by creating ALL tasks upfront "
1351
+ "with dependencies, present the plan for user review, "
1352
+ "and only then proceed to execute."
1353
+ ),
1354
+ }),
1355
+ }
1356
+ else:
1357
+ # Soft gate: execute but inject a reminder for next turn
1358
+ self.context.add_system(
1359
+ "<!-- PLAN_REMINDER -->\n"
1360
+ "Note: You used a direct execution tool without delegating via Task. "
1361
+ "For any non-trivial operation, prefer `Task(agent_type, prompt)` "
1362
+ "to delegate the work to a focused subagent instead. Task subagents "
1363
+ "encapsulate multi-step logic, keep the main context clean, and "
1364
+ "return structured SUMMARY/CHANGES/EVIDENCE/RISKS/BLOCKERS output."
1365
+ )
1366
+ result = await self._execute_tool(tu)
1367
+ else:
1368
+ result = await self._execute_tool(tu)
1369
+ # Task-first routing: track direct execution tool use
1370
+ if tu["name"] in _EXECUTION_TOOLS:
1371
+ self._direct_execution_count += 1
1372
+ if self._direct_execution_count >= 2:
1373
+ self.context.add_system(
1374
+ "<!-- TASK_FIRST_REMINDER -->\n"
1375
+ "Reminder: You're using direct execution tools repeatedly. "
1376
+ "For multi-step work, delegate via `Task(agent_type, prompt)` instead. "
1377
+ "This keeps the main context focused and work properly encapsulated."
1378
+ )
1379
+ result_str = str(result.get("content", ""))
1380
+ is_error = result.get("is_error", False)
1381
+ category = result.get("category", "unknown")
1382
+
1383
+ # Handle SendMessage — user-visible only, skip from LLM context
1384
+ if tu["name"] == "SendMessage":
1385
+ try:
1386
+ parsed = json.loads(result_str) if result_str else {}
1387
+ msg = parsed.get("message", "")
1388
+ if msg:
1389
+ self._last_user_msg = msg
1390
+ yield StreamEvent.text_delta(f"\n💬 {msg}\n")
1391
+ except (json.JSONDecodeError, ValueError):
1392
+ pass
1393
+ self._notify_event(ToolEvent(
1394
+ kind="tool_result",
1395
+ tool_name="SendMessage",
1396
+ tool_use_id=tu["id"],
1397
+ tool_output={"message": self._last_user_msg},
1398
+ ))
1399
+ continue
1400
+
1401
+ # Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
1402
+ if tu["name"] == "AskUser":
1403
+ parsed = json.loads(result_str) if result_str else {}
1404
+ question = parsed.get("question", "")
1405
+ options = parsed.get("options", [])
1406
+ questions = parsed.get("questions", [])
1407
+
1408
+ # Check for auto-default on single question + single option
1409
+ if not questions and len(options) == 1 and options[0].get("default"):
1410
+ default_val = options[0]["value"]
1411
+ self._pending_approval = None
1412
+ yield StreamEvent.tool_result(
1413
+ call_id=tu["id"],
1414
+ content=json.dumps({"answer": default_val}),
1415
+ )
1416
+ continue
1417
+
1418
+ # Auto-default for multi-question: skip questions that have
1419
+ # a single option with default=true
1420
+ if questions:
1421
+ auto_answers = {}
1422
+ remaining = []
1423
+ for i, q in enumerate(questions):
1424
+ qopts = q.get("options", [])
1425
+ if len(qopts) == 1 and qopts[0].get("default"):
1426
+ auto_answers[str(i)] = qopts[0]["value"]
1427
+ else:
1428
+ remaining.append(q)
1429
+ if not remaining:
1430
+ self._pending_approval = None
1431
+ yield StreamEvent.tool_result(
1432
+ call_id=tu["id"],
1433
+ content=json.dumps({"answers": auto_answers}),
1434
+ )
1435
+ continue
1436
+ questions = remaining
1437
+
1438
+ has_questions = bool(questions)
1439
+ self._pending_approval = {"tool_call": tu, "category": category, "ask_user": True}
1440
+ if has_questions:
1441
+ yield StreamEvent.ask_user(
1442
+ call_id=tu["id"],
1443
+ action="AskUser",
1444
+ question="", # not used when questions is present
1445
+ context=result_str,
1446
+ options=[],
1447
+ questions=questions,
1448
+ action_type="ask_user",
1449
+ )
1450
+ else:
1451
+ yield StreamEvent.ask_user(
1452
+ call_id=tu["id"],
1453
+ action="AskUser",
1454
+ question=question,
1455
+ context=result_str,
1456
+ options=options,
1457
+ action_type="ask_user",
1458
+ )
1459
+ continue
1460
+
1461
+ # Detect ASK permission denial
1462
+ requires_approval = False
1463
+ try:
1464
+ parsed = json.loads(result_str) if result_str else {}
1465
+ requires_approval = parsed.get("requires_approval", False)
1466
+ except (json.JSONDecodeError, ValueError):
1467
+ pass
1468
+
1469
+ if requires_approval:
1470
+ self._pending_approval = {"tool_call": tu, "category": category}
1471
+ self._notify_event(ToolEvent(
1472
+ kind="tool_result",
1473
+ tool_name=tu["name"],
1474
+ tool_use_id=tu["id"],
1475
+ is_error=True,
1476
+ error="Requires approval",
1477
+ ))
1478
+ yield StreamEvent.ask_user(
1479
+ call_id=tu["id"],
1480
+ action=tu["name"],
1481
+ question=f"Approve {tu['name']} operation?",
1482
+ context=result_str,
1483
+ action_type=tu["name"].lower(),
1484
+ path=tu["input"].get("path", ""),
1485
+ command=tu["input"].get("command", "")[:200],
1486
+ )
1487
+ else:
1488
+ from onecode.models.messages import ToolCategory as MsgToolCategory
1489
+ try:
1490
+ result_cat = MsgToolCategory(category)
1491
+ except ValueError:
1492
+ result_cat = MsgToolCategory.UNKNOWN
1493
+ self._notify_event(ToolEvent(
1494
+ kind="tool_result",
1495
+ tool_name=tu["name"],
1496
+ tool_output=result_str,
1497
+ tool_use_id=tu["id"],
1498
+ is_error=is_error,
1499
+ ))
1500
+ yield StreamEvent.tool_result(
1501
+ call_id=tu["id"],
1502
+ content=result_str,
1503
+ is_error=is_error,
1504
+ category=result_cat,
1505
+ )
1506
+ # Add structured tool_result to LLM context
1507
+ if is_anthropic:
1508
+ self.context.add_tool_result(tu["id"], result_str, is_error)
1509
+ else:
1510
+ self.context.add_message(
1511
+ "tool",
1512
+ [{"type": "tool_result", "tool_use_id": tu["id"], "content": result_str, "is_error": is_error}],
1513
+ name=tu["id"],
1514
+ )
1515
+ # Emit plan update when tasks/todos changed. Any
1516
+ # mutating path through TaskManager flips
1517
+ # ``_plan_dirty`` via the ``on_change`` callback, so we
1518
+ # do not need to enumerate tool names here.
1519
+ if self._plan_dirty:
1520
+ for event in self._emit_plan_update():
1521
+ yield event
1522
+ self._plan_dirty = False
1523
+
1524
+ if self._cancelled:
1525
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1526
+ return
1527
+
1528
+ usage_summary = ", ".join(
1529
+ f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
1530
+ for i, u in enumerate(self._turn_usages) if u
1531
+ )
1532
+ logger.info(f"Chat stream complete after {self.iterations} turn(s). Usage: [{usage_summary}]")
1533
+
1534
+ def has_pending_approval(self) -> bool:
1535
+ """Check if there's a pending approval request from ASK permission."""
1536
+ return self._pending_approval is not None
1537
+
1538
+ async def resolve_approval(self, approved: bool, answer: str = "") -> dict | None:
1539
+ """Execute the pending action if approved, or return denial.
1540
+
1541
+ For AskUser, ``answer`` is the user's free-text response.
1542
+
1543
+ Returns the tool result dict, or None if no pending approval.
1544
+ """
1545
+ from onecode.agent.agents.types import AgentPermission
1546
+ if not self._pending_approval:
1547
+ return None
1548
+
1549
+ tc = self._pending_approval["tool_call"]
1550
+ is_ask_user = self._pending_approval.get("ask_user", False)
1551
+ self._pending_approval = None
1552
+
1553
+ # Handle AskUser — return user's answer as tool result
1554
+ if is_ask_user:
1555
+ if not approved:
1556
+ return {
1557
+ "tool_use_id": tc["id"],
1558
+ "content": json.dumps({"answer": "", "error": "User cancelled"}),
1559
+ "is_error": True,
1560
+ "category": tc.get("category", "unknown"),
1561
+ }
1562
+ # answer is a JSON string when multi-question, plain text otherwise
1563
+ try:
1564
+ parsed = json.loads(answer)
1565
+ is_structured = isinstance(parsed, dict) or isinstance(parsed, list)
1566
+ except (json.JSONDecodeError, TypeError):
1567
+ is_structured = False
1568
+ if is_structured:
1569
+ return {
1570
+ "tool_use_id": tc["id"],
1571
+ "content": json.dumps({"answers": parsed}),
1572
+ "is_error": False,
1573
+ "category": tc.get("category", "unknown"),
1574
+ }
1575
+ return {
1576
+ "tool_use_id": tc["id"],
1577
+ "content": json.dumps({"answer": answer}),
1578
+ "is_error": False,
1579
+ "category": tc.get("category", "unknown"),
1580
+ }
1581
+
1582
+ if not approved:
1583
+ return {
1584
+ "tool_use_id": tc["id"],
1585
+ "content": json.dumps({"success": False, "error": "User denied the operation"}),
1586
+ "is_error": True,
1587
+ "category": tc.get("category", "unknown"),
1588
+ }
1589
+
1590
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(tc["name"])
1591
+ if perm_key is None:
1592
+ return await self._execute_tool(tc)
1593
+
1594
+ attr_name = f"permission_{perm_key}"
1595
+ saved = getattr(self.current_agent, attr_name)
1596
+ try:
1597
+ setattr(self.current_agent, attr_name, AgentPermission.ALLOW)
1598
+ return await self._execute_tool(tc)
1599
+ finally:
1600
+ setattr(self.current_agent, attr_name, saved)
1601
+
1602
+ def _reset_react_state(self) -> None:
1603
+ """Reset ReAct loop state for a fresh cycle."""
1604
+ self._react_phase = "thought"
1605
+ self._direct_execution_count = 0
1606
+ self._plan_gate_fired = False
1607
+ self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
1608
+ self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
1609
+
1610
+ def reset(self):
1611
+ self.context.reset()
1612
+ self.iterations = 0
1613
+ self.total_tokens = 0
1614
+ self._turn_usages = []
1615
+ self._skills_loaded = False
1616
+ self._project_context_loaded = False
1617
+ self._skill_loader.invalidate_cache()
1618
+ self._reset_react_state()
1619
+
1620
+ def status(self) -> str:
1621
+ return (
1622
+ f"Agent: {self.current_agent.name}\n"
1623
+ f"Iterations: {self.iterations}\n"
1624
+ f"Total tokens: {self.total_tokens}\n"
1625
+ f"Context: {self.context.info()}"
1626
+ )
1627
+
1628
+ def read_file(self, path: str, offset: int = 0, limit: int = 0) -> str:
1629
+ if self.current_agent.permission_read == AgentPermission.DENY:
1630
+ return "Read denied"
1631
+ return self.file_ops.read(path, offset, limit)
1632
+
1633
+ def write_file(self, path: str, content: str) -> dict:
1634
+ if self.current_agent.permission_edit == AgentPermission.DENY:
1635
+ return {"success": False, "error": "Edit denied"}
1636
+ if self.current_agent.permission_edit == AgentPermission.ASK:
1637
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
1638
+ return self.file_ops.write(path, content)
1639
+
1640
+ def edit_file(self, path: str, old: str, new: str) -> dict:
1641
+ if self.current_agent.permission_edit == AgentPermission.DENY:
1642
+ return {"success": False, "error": "Edit denied"}
1643
+ if self.current_agent.permission_edit == AgentPermission.ASK:
1644
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
1645
+ return self.file_ops.edit(path, old, new)
1646
+
1647
+ def glob_files(self, pattern: str) -> list[str]:
1648
+ return self.file_ops.glob(pattern)
1649
+
1650
+ def grep_files(self, pattern: str, include: str = None) -> list[str]:
1651
+ return self.file_ops.grep(pattern, include)
1652
+
1653
+ def list_dir(self, path: str = ".") -> list[dict]:
1654
+ return self.file_ops.list(path)
1655
+
1656
+ def exec_shell(self, cmd: str, timeout: int = 60) -> dict:
1657
+ if self.current_agent.permission_bash == AgentPermission.DENY:
1658
+ return {"success": False, "error": "Bash denied"}
1659
+ if self.current_agent.permission_bash == AgentPermission.ASK:
1660
+ return {"success": False, "error": "Bash requires approval", "requires_approval": True}
1661
+ return self.shell.exec(cmd, timeout=timeout)
1662
+
1663
+ def web_fetch(self, url: str, prompt: str = None) -> str:
1664
+ if self.current_agent.permission_webfetch == AgentPermission.DENY:
1665
+ return "WebFetch denied"
1666
+ from onecode.agent.tools.web_tools import webfetch
1667
+ return webfetch(url, prompt)
1668
+
1669
+ def web_search(self, query: str, num_results: int = 5) -> str:
1670
+ if self.current_agent.permission_websearch == AgentPermission.DENY:
1671
+ return "WebSearch denied"
1672
+ from onecode.agent.tools.web_tools import websearch
1673
+ return websearch(query, num_results)
1674
+
1675
+ async def _spawn_subagent_async(self, agent_type: str, prompt: str) -> str:
1676
+ """Spawn a sub-agent with streaming-like output.
1677
+
1678
+ Uses chat_stream to get structured output that the ChatPanel
1679
+ can parse for professional rendering.
1680
+ """
1681
+ parts: list[str] = []
1682
+ async for _event, text in self._spawn_subagent_async_streaming(agent_type, prompt):
1683
+ parts.append(text)
1684
+ result = "".join(parts)
1685
+ return self._format_subagent_output(agent_type, prompt, result)
1686
+
1687
+ async def _spawn_subagent_async_streaming(
1688
+ self, agent_type: str, prompt: str
1689
+ ) -> AsyncIterator[tuple[StreamEvent, str]]:
1690
+ """Streaming variant of :meth:`_spawn_subagent_async`.
1691
+
1692
+ Yields ``(event, text)`` pairs so the caller (typically the Task-tool
1693
+ path in :meth:`chat_stream`) can forward each text delta as a
1694
+ ``subagent_chunk`` notification on the ACP wire. The full agent text
1695
+ is also accumulated and emitted in a single ``(subagent_end, text)``
1696
+ payload at the end so callers that only care about the final output
1697
+ can ignore the intermediate chunks.
1698
+
1699
+ Inherits project context and skills from the parent
1700
+ engine so the subagent does not start from a blank slate.
1701
+ """
1702
+ sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
1703
+
1704
+ # Inherit parent context: project info, skills
1705
+ sub_engine._project_context_loaded = self._project_context_loaded
1706
+ sub_engine._project_config = dict(self._project_config)
1707
+ sub_engine._skills_loaded = True
1708
+ for skill in self._skill_loader.get_enabled():
1709
+ if skill.content not in (m.content for m in sub_engine.context.messages if m.role == "system"):
1710
+ sub_engine.context.add_system(skill.content)
1711
+
1712
+ sub_engine.set_agent(agent_type)
1713
+
1714
+ # Track child so parent cancellation cascades
1715
+ self._child_engines.append(sub_engine)
1716
+
1717
+ # Link the subagent's cancellation flag to the parent
1718
+ parts: list[str] = []
1719
+ try:
1720
+ async for chunk in sub_engine.chat_stream(prompt):
1721
+ if isinstance(chunk, StreamEvent):
1722
+ if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
1723
+ parts.append(chunk.text)
1724
+ yield chunk, chunk.text
1725
+ else:
1726
+ if chunk:
1727
+ parts.append(chunk)
1728
+ except Exception as e:
1729
+ logger.exception(f"Subagent error: {e}")
1730
+ err_text = (
1731
+ f"SUMMARY:\nSub-agent failed with error: {e}\n\n"
1732
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
1733
+ f"BLOCKERS:\n{e}"
1734
+ )
1735
+ yield StreamEvent.subagent_end(""), err_text
1736
+ else:
1737
+ yield StreamEvent.subagent_end(""), "".join(parts)
1738
+ finally:
1739
+ # Clean up completed subagent to prevent memory leak
1740
+ if sub_engine in self._child_engines:
1741
+ self._child_engines.remove(sub_engine)
1742
+
1743
+ def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
1744
+ """Format sub-agent output in DeepSeek-TUI structured contract format.
1745
+
1746
+ Output contract:
1747
+ SUMMARY: one paragraph; what you did and what happened
1748
+ CHANGES: files modified, with one-line descriptions; "None." if read-only
1749
+ EVIDENCE: path:line-range citations and key findings; one bullet each
1750
+ RISKS: what could go wrong / what the parent should double-check
1751
+ BLOCKERS: what stopped you; "None." if you finished cleanly
1752
+ """
1753
+ # Try to detect if the result already has structured sections
1754
+ result_lower = result.lower()
1755
+ has_sections = all(
1756
+ tag.lower() in result_lower
1757
+ for tag in ["summary", "changes", "evidence", "risks", "blockers"]
1758
+ )
1759
+
1760
+ if has_sections:
1761
+ # Already structured — just ensure proper formatting
1762
+ return result
1763
+ else:
1764
+ # Add structured wrapper around raw result
1765
+ return (
1766
+ f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
1767
+ f"CHANGES:\nNone detected.\n\n"
1768
+ f"EVIDENCE:\n- See detailed output below\n\n"
1769
+ f"RISKS:\nNone identified.\n\n"
1770
+ f"BLOCKERS:\nNone.\n\n"
1771
+ f"--- Raw Output ---\n{result}"
1772
+ )
1773
+
1774
+ def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
1775
+ if self.current_agent.permission_task == AgentPermission.DENY:
1776
+ return {"success": False, "error": "Subagent denied"}
1777
+ import asyncio
1778
+ import concurrent.futures
1779
+ try:
1780
+ loop = asyncio.get_event_loop()
1781
+ if loop.is_running():
1782
+ future = asyncio.run_coroutine_threadsafe(
1783
+ self._spawn_subagent_async(agent_type, prompt), loop
1784
+ )
1785
+ result = future.result(timeout=300)
1786
+ else:
1787
+ result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
1788
+ except Exception as e:
1789
+ logger.exception(f"Subagent error: {e}")
1790
+ return {"success": False, "error": str(e)}
1791
+ return {
1792
+ "success": True,
1793
+ "agent_type": agent_type,
1794
+ "response": result,
1795
+ "iterations": 1,
1796
+ }
1797
+
1798
+ def tool_task(self, agent_type: str, prompt: str) -> str:
1799
+ result = self.spawn_subagent(agent_type, prompt)
1800
+ if result["success"]:
1801
+ return result["response"]
1802
+ return f"Error: {result.get('error', 'Unknown error')}"
1803
+
1804
+ def attach_session(self, session: AgentSession) -> None:
1805
+ self._session = session
1806
+ if session.messages:
1807
+ self.context.load_from_session(session.messages)
1808
+ if session.tasks or session.todos:
1809
+ self._task_manager = TaskManager.from_dict({
1810
+ "tasks": session.tasks,
1811
+ "todos": session.todos,
1812
+ "plan": [],
1813
+ "id_counter": len(session.tasks) + len(session.todos),
1814
+ }, on_change=self._on_task_change)
1815
+ # Restore context usage stats
1816
+ self._restore_session_stats(session)
1817
+
1818
+ def save_session(self) -> None:
1819
+ if self._session:
1820
+ try:
1821
+ self._session.messages = self.context.to_session_format()
1822
+ tm_data = self._task_manager.to_dict()
1823
+ self._session.tasks = tm_data.get("tasks", [])
1824
+ self._session.todos = tm_data.get("todos", [])
1825
+ # Persist context usage stats so they survive session reload
1826
+ self._session.update_state("stats", {
1827
+ "total_tokens": self.total_tokens,
1828
+ "iterations": self.iterations,
1829
+ "turn_usages": [
1830
+ {k: v for k, v in u.items() if isinstance(v, (int, float))}
1831
+ for u in self._turn_usages
1832
+ ],
1833
+ })
1834
+ self._session.save()
1835
+ except Exception as e:
1836
+ logger.exception("Failed to save session: %s", e)
1837
+
1838
+ def load_session(self, session_id: str) -> bool:
1839
+ session = AgentSession(session_id)
1840
+ if session.load():
1841
+ self._session = session
1842
+ self.context.load_from_session(session.messages)
1843
+ if session.tasks or session.todos:
1844
+ self._task_manager = TaskManager.from_dict({
1845
+ "tasks": session.tasks,
1846
+ "todos": session.todos,
1847
+ "plan": [],
1848
+ "id_counter": len(session.tasks) + len(session.todos),
1849
+ }, on_change=self._on_task_change)
1850
+ # Restore context usage stats
1851
+ self._restore_session_stats(session)
1852
+ return True
1853
+ return False
1854
+
1855
+ def _restore_session_stats(self, session: AgentSession) -> None:
1856
+ """Restore context usage stats from session lifecycle_state."""
1857
+ stats = session.get_state("stats", {})
1858
+ if not stats:
1859
+ return
1860
+ self.total_tokens = int(stats.get("total_tokens", self.total_tokens))
1861
+ self.iterations = int(stats.get("iterations", self.iterations))
1862
+ restored_turns = stats.get("turn_usages", [])
1863
+ if restored_turns and isinstance(restored_turns, list):
1864
+ self._turn_usages = list(restored_turns)
1865
+
1866
+ def save_tasks_to_project(self) -> None:
1867
+ """Save tasks/todos to .cdh/tasks.json in the project directory."""
1868
+ from onecode.agent.cdh_loader import CdhProjectLoader
1869
+ cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
1870
+ if cdh_dir is None:
1871
+ return
1872
+ try:
1873
+ tm_data = self._task_manager.to_dict()
1874
+ CdhProjectLoader.save_tasks(cdh_dir, tm_data)
1875
+ except Exception as e:
1876
+ logger.warning("Failed to save tasks to .cdh: %s", e)
1877
+
1878
+ def load_tasks_from_project(self) -> None:
1879
+ """Restore tasks/todos from .cdh/tasks.json."""
1880
+ from onecode.agent.cdh_loader import CdhProjectLoader
1881
+ cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
1882
+ if cdh_dir is None:
1883
+ return
1884
+ try:
1885
+ data = CdhProjectLoader.load_tasks(cdh_dir)
1886
+ if data and (data.get("tasks") or data.get("todos")):
1887
+ self._task_manager = TaskManager.from_dict(
1888
+ data, on_change=self._on_task_change
1889
+ )
1890
+ except Exception as e:
1891
+ logger.warning("Failed to load tasks from .cdh: %s", e)
1892
+
1893
+ def get_session(self) -> Optional[AgentSession]:
1894
+ return self._session