iCode-TUI 0.29.1__py3-none-any.whl

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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,1815 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """ACP session ownership for Chrys."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ import threading
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from copy import deepcopy
16
+ from dataclasses import asdict, dataclass, field, is_dataclass
17
+ from pathlib import Path
18
+ from typing import Any, TypeGuard, TypeVar, cast
19
+
20
+ from acp import schema as acp_schema
21
+
22
+ from chrys.app.features.session_title.updater import SessionTitleUpdater
23
+ from chrys.foundation.branding import APP_DISPLAY_NAME
24
+ from chrys.foundation.config.context import EvalContext
25
+ from chrys.foundation.config.process_settings import reattribute_command_line, route_restart_settings
26
+ from chrys.foundation.config.runtime_pointer import MODEL_POINTER_ENV, set_model_pointer
27
+ from chrys.foundation.config.settings_store import LoadedSettings, load_settings, persist
28
+ from chrys.foundation.config.spec import SettingOrigin, Source
29
+ from chrys.foundation.config.user_settings import flatten_user_doc, user_settings_path
30
+ from chrys.foundation.config.warnings import settings_warning_events
31
+ from chrys.foundation.config.yaml_store import read_yaml_doc
32
+ from chrys.foundation.events.types import (
33
+ AgentLoadFailed,
34
+ AgentProfileSwitch,
35
+ ApprovalModeUpdated,
36
+ Error,
37
+ Event,
38
+ InvocationAbortRequested,
39
+ InvocationRetryRequested,
40
+ ModelProfileSwitched,
41
+ ProfileSwitched,
42
+ RollbackResult,
43
+ SetApprovalMode,
44
+ SetModelProfile,
45
+ SettingsReload,
46
+ SettingsReloaded,
47
+ SleepSkip,
48
+ UserInject,
49
+ UserRollback,
50
+ Warning,
51
+ WorkspaceChange,
52
+ WorkspaceUpdated,
53
+ )
54
+ from chrys.foundation.models.session_surface import SessionSurface
55
+ from chrys.foundation.models.workspace import WorkingDir, Workspace
56
+ from chrys.foundation.platform.files import surrogate_safe_text
57
+ from chrys.foundation.text.yaml_io import dump_yaml
58
+ from chrys.foundation.util.session_ids import SESSION_SHORT_ID_LEN, session_short_id
59
+ from chrys.orchestration.session_host import ChrysSessionHost
60
+ from chrys.service.approval.policy import ApprovalMode
61
+ from chrys.service.mcp.adapter import MCPAdapter
62
+ from chrys.service.profiles.agents.loader import AgentProfileLoadError, is_filename_safe_profile_name
63
+ from chrys.service.profiles.agents.loader import load_profile_from_yaml as load_agent_profile_from_yaml
64
+ from chrys.service.profiles.agents.registry import AgentProfileRegistry
65
+ from chrys.service.profiles.agents.schema import AgentProfile, MCPServerConfig
66
+ from chrys.service.profiles.agents.serializer import delete_profile as delete_agent_profile
67
+ from chrys.service.profiles.agents.serializer import profile_to_dict as agent_profile_to_dict
68
+ from chrys.service.profiles.agents.serializer import save_profile as save_agent_profile
69
+ from chrys.service.profiles.agents.serializer import stored_profile_name as stored_agent_profile_name
70
+ from chrys.service.profiles.models.loader import load_profile_from_yaml as load_model_profile_from_yaml
71
+ from chrys.service.profiles.models.registry import ModelProfileRegistry
72
+ from chrys.service.profiles.models.schema import ModelProfile
73
+ from chrys.service.profiles.models.serializer import delete_profile as delete_model_profile
74
+ from chrys.service.profiles.models.serializer import save_profile as save_model_profile
75
+ from chrys.service.state.store import ChatSessionMeta, JsonFileStateStore, StateStore
76
+ from chrys.service.tools.names import chrys_reserved_tool_names
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+ _RuntimeEventT = TypeVar("_RuntimeEventT", bound=Event)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class AcpConfigOption:
85
+ """One supported config key, under all three of the names it answers to.
86
+
87
+ The wire protocol's logical name predates the settings document and the
88
+ ``CHRYS_*`` aliases predate both, so requests may arrive in either
89
+ spelling; the setting key is where the value actually lives.
90
+ """
91
+
92
+ logical_key: str
93
+ """Wire-protocol name, e.g. ``theme``."""
94
+
95
+ setting_key: str
96
+ """Dotted settings-document key, e.g. ``ui.theme``."""
97
+
98
+ env_alias: str
99
+ """Legacy ``CHRYS_*`` spelling — accepted on input, echoed as ``envKey``."""
100
+
101
+
102
+ _SUPPORTED_CONFIG_OPTIONS: tuple[AcpConfigOption, ...] = (
103
+ AcpConfigOption("default_agent", "agent.default_profile", "CHRYS_DEFAULT_AGENT"),
104
+ AcpConfigOption("model_profile", "model.profile.active", "CHRYS_MODEL_PROFILE"),
105
+ AcpConfigOption("approval_judge_model_profile", "model.role.approval_judge", "CHRYS_MODEL_PROFILE_APPROVAL_JUDGE"),
106
+ AcpConfigOption("default_approval_mode", "approval.default_mode", "CHRYS_DEFAULT_APPROVAL_MODE"),
107
+ AcpConfigOption("theme", "ui.theme", "CHRYS_THEME"),
108
+ AcpConfigOption("rollback_snapshots_keep", "rollback.snapshots_keep", "CHRYS_ROLLBACK_SNAPSHOTS_KEEP"),
109
+ )
110
+
111
+
112
+ class AcpSessionError(RuntimeError):
113
+ """Raised when an ACP session request cannot be fulfilled."""
114
+
115
+
116
+ @dataclass
117
+ class ManagedSession:
118
+ """One active Chrys session exposed over ACP."""
119
+
120
+ session_id: str
121
+ cwd: str
122
+ profile_name: str
123
+ host: ChrysSessionHost
124
+ # Per-session auto-title generator; the bridge forwards its
125
+ # ``SessionTitleUpdated`` events as ACP ``session_info_update``.
126
+ title_updater: SessionTitleUpdater | None = None
127
+ # Serializes runtime-control mutations (model/workspace/profile/settings) so
128
+ # two concurrent requests on one session can't each resolve from the other's
129
+ # completion event — ``_await_runtime_mutation`` correlates only by session id
130
+ # and result type, which is ambiguous under overlap.
131
+ mutation_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
132
+ # Prompts serialize per session, not process-wide. Lifecycle operations
133
+ # acquire this lock before shutdown so a host cannot be torn down while a
134
+ # prompt is still draining its terminal events.
135
+ prompt_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
136
+ closing: bool = False
137
+
138
+ async def shutdown(self) -> None:
139
+ """Shut down the host, then drain the title updater.
140
+
141
+ Order matters: the engine's shutdown drains a still-finalizing run
142
+ whose success callback can schedule one last title task; the updater
143
+ must be torn down after so that task is cancelled and awaited —
144
+ even when the host shutdown itself fails.
145
+ """
146
+ try:
147
+ await self.host.shutdown()
148
+ finally:
149
+ if self.title_updater is not None:
150
+ await self.title_updater.shutdown()
151
+
152
+
153
+ @dataclass
154
+ class ManagedLoadResult:
155
+ """Result of an ACP session load request."""
156
+
157
+ session: ManagedSession
158
+ reused_existing: bool
159
+ recovered_from_sidecar: bool = False
160
+
161
+
162
+ class AcpSessionManager:
163
+ """Own active Chrys hosts for one ACP process."""
164
+
165
+ def __init__(
166
+ self,
167
+ *,
168
+ loaded_settings: LoadedSettings,
169
+ profile_name: str,
170
+ approval_mode: ApprovalMode,
171
+ process_cwd: str | None,
172
+ state_store: StateStore | None = None,
173
+ agent_registry: AgentProfileRegistry | None = None,
174
+ model_registry: ModelProfileRegistry | None = None,
175
+ on_successful_turn: Callable[[], None] | None = None,
176
+ ) -> None:
177
+ self._loaded_settings = loaded_settings
178
+ self._profile_name = profile_name
179
+ self._approval_mode = approval_mode
180
+ self._process_cwd = _resolve_dir(process_cwd) if process_cwd is not None else None
181
+ self._state_store = state_store or JsonFileStateStore()
182
+ self._agent_registry = agent_registry or AgentProfileRegistry()
183
+ self._model_registry = model_registry or ModelProfileRegistry()
184
+ self._on_successful_turn = on_successful_turn
185
+ self._sessions: dict[str, ManagedSession] = {}
186
+ # Guards process-wide session-map/lifecycle mutations only. Prompt
187
+ # turns use ManagedSession.prompt_lock so unrelated sessions never
188
+ # share a turn-length critical section.
189
+ self._lock = asyncio.Lock()
190
+ # Guards the whole global-settings transaction — document write, model
191
+ # pointer, published base settings — so the three can never disagree.
192
+ # A thread lock, not an asyncio one: these run under
193
+ # ``asyncio.to_thread`` because they touch the settings file, so two
194
+ # config requests genuinely execute them in parallel. Reentrant because
195
+ # the refresh takes it for its own callers as well as inside a write.
196
+ self._global_settings_lock = threading.RLock()
197
+
198
+ @property
199
+ def state_store(self) -> StateStore:
200
+ """State store used for ACP session operations."""
201
+ return self._state_store
202
+
203
+ @property
204
+ def process_cwd(self) -> str | None:
205
+ """Default cwd supplied by ``chrys acp --workdir``, if any."""
206
+ return self._process_cwd
207
+
208
+ def load_registries(self) -> None:
209
+ """Load profile registries once for this ACP process."""
210
+ if not self._agent_registry.list_names():
211
+ self._agent_registry.load_all()
212
+ if not self._model_registry.list_ids():
213
+ self._model_registry.load_all()
214
+
215
+ def list_agent_profiles(self) -> list[dict[str, object]]:
216
+ """Return available agent profile summaries."""
217
+ self.load_registries()
218
+ return [
219
+ _agent_profile_info(profile, builtin=self._agent_registry.is_builtin(profile.name))
220
+ for profile in self._agent_registry.list_profiles(include_sub_agent_only=True)
221
+ ]
222
+
223
+ def read_agent_profile(self, name: str) -> dict[str, object]:
224
+ """Return one agent profile as JSON-like data with launch secrets masked."""
225
+ self.load_registries()
226
+ profile = self._agent_registry.resolve_selector(name)
227
+ if profile is None:
228
+ msg = f"Agent profile not found: {name}"
229
+ raise AcpSessionError(msg)
230
+ data = jsonable_dataclass(profile)
231
+ if profile.acp is None:
232
+ data.pop("acp", None)
233
+ # Web patches distinguish absence from explicit null. Use their profile
234
+ # representation so the editable ACP payload round-trips that contract.
235
+ serialized_tools = agent_profile_to_dict(profile).get("tools", {})
236
+ for key in ("web_search", "web_fetch"):
237
+ data["tools"].pop(key, None)
238
+ if key in serialized_tools:
239
+ data["tools"][key] = serialized_tools[key]
240
+ return _redact_agent_profile_secrets(data)
241
+
242
+ @staticmethod
243
+ def _stored_agent_profile_name(name: str) -> str | None:
244
+ """Read disk ownership without trusting the process's cached registry."""
245
+ try:
246
+ return stored_agent_profile_name(name)
247
+ except AgentProfileLoadError as err:
248
+ msg = f"Cannot verify ownership of agent profile file for {name!r}; refusing to modify it. {err}"
249
+ raise AcpSessionError(msg) from err
250
+
251
+ @staticmethod
252
+ def _require_agent_profile_file_owner(name: str, stored_name: str | None) -> None:
253
+ """Reject mutations through a filename that currently belongs to another profile."""
254
+ if stored_name is not None and stored_name != name:
255
+ msg = f"Agent profile file for {name!r} belongs to {stored_name!r}; refusing to modify it."
256
+ raise AcpSessionError(msg)
257
+
258
+ def write_agent_profile(self, data: dict[str, object]) -> dict[str, object]:
259
+ """Validate and persist an agent profile.
260
+
261
+ Masked MCP and ACP values (``"***"``) coming back from a prior read
262
+ are restored from the stored profile so a round-trip never destroys
263
+ real secrets.
264
+ """
265
+ if not data.get("name"):
266
+ msg = "Agent profile name is required."
267
+ raise AcpSessionError(msg)
268
+ self.load_registries()
269
+ profile_data = dict(data)
270
+ profile_data["name"] = _safe_profile_file_stem(profile_data["name"], field_name="agent profile name")
271
+ name = str(profile_data["name"])
272
+ self._require_agent_profile_file_owner(name, self._stored_agent_profile_name(name))
273
+ profile_data.setdefault("id", uuid.uuid4().hex[:12])
274
+ existing = self._agent_registry.get(str(profile_data["name"]))
275
+ ambiguous_identity = False
276
+ if existing is None:
277
+ # ``AgentProfile.id`` is the identity that survives renames: a
278
+ # read → rename → write round-trip keeps the stored id while the
279
+ # name lookup misses, yet the payload's masked values still refer
280
+ # to that stored profile's secrets. Restore from the id match so
281
+ # a rename never persists literal ``"***"`` over real secrets.
282
+ profile_id = profile_data.get("id")
283
+ matches = [
284
+ candidate
285
+ for candidate in self._agent_registry.list_profiles(include_sub_agent_only=True)
286
+ if profile_id and candidate.id == profile_id
287
+ ]
288
+ if len(matches) == 1:
289
+ existing = matches[0]
290
+ elif matches:
291
+ # Twin profiles sharing an id (this CRUD's rename keeps the
292
+ # old file, so copies are reachable): a masked read happened
293
+ # but there is no single source of truth — fail closed and
294
+ # make the caller re-enter the secrets instead of guessing.
295
+ ambiguous_identity = True
296
+ acp_restoration = _AcpMaskRestoration(args_were_masked=ambiguous_identity)
297
+ if existing is not None:
298
+ _restore_masked_mcp_secrets(profile_data, existing)
299
+ acp_restoration = _restore_masked_acp_secrets(profile_data, existing)
300
+ _reject_unresolved_masked_mcp_secrets(profile_data)
301
+ _reject_unresolved_masked_acp_secrets(profile_data, restoration=acp_restoration)
302
+ profile = _agent_profile_from_data(profile_data)
303
+ profile.name = _safe_profile_file_stem(profile.name, field_name="agent profile name")
304
+ path = save_agent_profile(profile)
305
+ self._agent_registry.register(profile)
306
+ return {
307
+ "profile": _agent_profile_info(profile, builtin=self._agent_registry.is_builtin(profile.name)),
308
+ "path": str(path),
309
+ }
310
+
311
+ def delete_agent_profile(self, name: str) -> dict[str, object]:
312
+ """Delete a user agent profile from disk and registry."""
313
+ self.load_registries()
314
+ name = _safe_profile_file_stem(name, field_name="agent profile name")
315
+ if self._agent_registry.is_builtin(name):
316
+ msg = f"Built-in agent profiles cannot be deleted; reset {name!r} instead."
317
+ raise AcpSessionError(msg)
318
+ if self._agent_registry.get(name) is None and (
319
+ builtin_name := self._agent_registry.resolve_builtin_filename(name)
320
+ ):
321
+ # No registered profile owns this spelling, but its filename may
322
+ # address a built-in's shadow on a case-insensitive filesystem.
323
+ msg = f"Built-in agent profiles cannot be deleted; reset {builtin_name!r} instead."
324
+ raise AcpSessionError(msg)
325
+ stored_name = self._stored_agent_profile_name(name)
326
+ self._require_agent_profile_file_owner(name, stored_name)
327
+ if stored_name is None:
328
+ return {"name": name, "deleted": False}
329
+ if self._agent_registry.get(name) is not None:
330
+ # Removal also saves changed user parents. Check the entire write
331
+ # set before deleting the child or changing any registry entries.
332
+ for parent in self._agent_registry.profiles_referencing(name):
333
+ if not self._agent_registry.is_builtin(parent.name):
334
+ parent_owner = self._stored_agent_profile_name(parent.name)
335
+ if parent_owner is None:
336
+ # Do not recreate stale parents: multiple absent paths
337
+ # can alias each other once cascade starts saving them.
338
+ msg = (
339
+ f"Parent agent profile file for {parent.name!r} is missing; "
340
+ "refusing to recreate it during deletion."
341
+ )
342
+ raise AcpSessionError(msg)
343
+ self._require_agent_profile_file_owner(parent.name, parent_owner)
344
+ deleted = delete_agent_profile(name)
345
+ if deleted:
346
+ self._agent_registry.remove(name)
347
+ return {"name": name, "deleted": deleted}
348
+
349
+ def reset_agent_profile(self, name: str) -> dict[str, object]:
350
+ """Reset a built-in agent while retaining Skills, MCP, and Memory settings."""
351
+ self.load_registries()
352
+ name = _safe_profile_file_stem(name, field_name="agent profile name")
353
+ current = self._agent_registry.get(name)
354
+ template = self._agent_registry.get_builtin_template(name)
355
+ if current is None or template is None:
356
+ msg = f"Built-in agent profile not found: {name}"
357
+ raise AcpSessionError(msg)
358
+ stored_name = self._stored_agent_profile_name(name)
359
+ reset = self._agent_registry.build_builtin_reset(name, preserve_from=current)
360
+ if _agent_profile_reset_comparison_dict(reset) == _agent_profile_reset_comparison_dict(template):
361
+ changed = reset != current
362
+ # Only the actual owner may remove the file. A known twin is
363
+ # preserved; unreadable ownership already raised before this point.
364
+ if stored_name == name:
365
+ changed = delete_agent_profile(name) or changed
366
+ else:
367
+ changed = reset != current
368
+ if changed:
369
+ self._require_agent_profile_file_owner(name, stored_name)
370
+ save_agent_profile(reset)
371
+ if changed:
372
+ self._agent_registry.register(reset)
373
+ return {
374
+ "profile": _agent_profile_info(reset, builtin=True),
375
+ "changed": changed,
376
+ }
377
+
378
+ def list_model_profiles(self) -> list[dict[str, object]]:
379
+ """Return available model profile summaries."""
380
+ self.load_registries()
381
+ return [_model_profile_info(profile) for profile in self._model_registry.list_profiles()]
382
+
383
+ def read_model_profile(self, profile_id: str) -> dict[str, object]:
384
+ """Return one model profile as JSON-like data without secrets."""
385
+ self.load_registries()
386
+ profile = self._model_registry.get(profile_id)
387
+ if profile is None:
388
+ msg = f"Model profile not found: {profile_id}"
389
+ raise AcpSessionError(msg)
390
+ data = jsonable_dataclass(profile)
391
+ data["api_key"] = "***" if data.get("api_key") else ""
392
+ # NOTE: only api_key is masked. ``http_headers`` and ``chat_options`` are
393
+ # JSON strings that can also carry auth (e.g. an Authorization header or a
394
+ # provider token) and are returned verbatim to the ACP client. Accepted
395
+ # for now — ACP is a same-user local process — but if this surface is ever
396
+ # exposed to a less-trusted client, mask/redact these too (and add the
397
+ # unresolved-mask write rejection used for MCP secrets).
398
+ return data
399
+
400
+ def write_model_profile(self, data: dict[str, object]) -> dict[str, object]:
401
+ """Validate and persist a model profile."""
402
+ if not data.get("name"):
403
+ msg = "Model profile name is required."
404
+ raise AcpSessionError(msg)
405
+ self.load_registries()
406
+ profile_data = dict(data)
407
+ profile_id = str(profile_data.get("id") or "")
408
+ existing = self._model_registry.get(profile_id) if profile_id else None
409
+ if existing is not None:
410
+ merged = jsonable_dataclass(existing)
411
+ # Skip only absent (None) fields; an explicit "" clears the field (e.g.
412
+ # base_url, http_headers, chat_options) so stale endpoints/auth don't linger.
413
+ # api_key is the sole exception: ""/"***"/None all preserve the stored secret.
414
+ merged.update({key: value for key, value in profile_data.items() if value is not None})
415
+ if profile_data.get("api_key") in {None, "", "***"}:
416
+ merged["api_key"] = existing.api_key
417
+ profile_data = merged
418
+ else:
419
+ # No profile to restore the secret from (e.g. read-copy-write with a new
420
+ # id), so a still-masked api_key would persist literally. Reject it, like
421
+ # the MCP secret handling in _reject_unresolved_masked_mcp_secrets.
422
+ if profile_data.get("api_key") == _MASK:
423
+ msg = (
424
+ "Model profile api_key is still masked; re-enter the secret after renaming or copying the profile."
425
+ )
426
+ raise AcpSessionError(msg)
427
+ profile_data.setdefault("id", uuid.uuid4().hex[:12])
428
+ profile_data["id"] = _safe_profile_file_stem(profile_data["id"], field_name="model profile id")
429
+ profile = _model_profile_from_data(profile_data)
430
+ profile.id = _safe_profile_file_stem(profile.id, field_name="model profile id")
431
+ path = save_model_profile(profile)
432
+ self._model_registry.register(profile)
433
+ return {"profile": _model_profile_info(profile), "path": str(path)}
434
+
435
+ def delete_model_profile(self, profile_id: str) -> dict[str, object]:
436
+ """Delete a model profile from disk and registry."""
437
+ self.load_registries()
438
+ profile_id = _safe_profile_file_stem(profile_id, field_name="model profile id")
439
+ deleted = delete_model_profile(profile_id)
440
+ if deleted:
441
+ self._model_registry.remove(profile_id)
442
+ return {"id": profile_id, "deleted": deleted}
443
+
444
+ def set_config_option(self, key: str, value: object) -> dict[str, object]:
445
+ """Persist a supported Chrys config option to the user settings document.
446
+
447
+ Validation is the settings store's own: the same coercer that will
448
+ read the value back judges it on the way in, and a rejected value
449
+ never reaches the file. ``bypass`` never persists —
450
+ :func:`chrys.foundation.config.settings_store.persist` downgrades it
451
+ to ``auto`` so the next launch cannot start in unattended
452
+ auto-approval mode.
453
+
454
+ No ``os.environ`` mirror: a mirrored value would come back as the
455
+ ``ENV`` layer and outrank the very document this just wrote. The one
456
+ exception is the model pointer, whose environment entry is this
457
+ process's live selection channel, not a configuration source.
458
+
459
+ Document, pointer and published settings move together under one lock.
460
+ Concurrent requests reach this from separate worker threads, and the
461
+ pointer outranks the document it was written from — so two clients
462
+ setting a model interleaved can otherwise commit one profile to disk
463
+ and install the other as the live pointer, leaving a process whose
464
+ model changes when it restarts.
465
+ """
466
+ option = _config_option(key)
467
+ text_value = "" if value is None else str(value)
468
+ with self._global_settings_lock:
469
+ if text_value:
470
+ result = persist({option.setting_key: text_value})
471
+ if not result.ok:
472
+ reason = result.rejected[option.setting_key].reason
473
+ detail = reason.name.lower() if reason is not None else "rejected"
474
+ msg = f"Invalid value for config option {key!r}: {text_value!r} ({detail})."
475
+ raise AcpSessionError(msg)
476
+ stored = result.written[option.setting_key]
477
+ else:
478
+ persist({}, remove=(option.setting_key,))
479
+ stored = ""
480
+ if option.env_alias == MODEL_POINTER_ENV:
481
+ pointer = str(stored) if stored else None
482
+ set_model_pointer(pointer, origin=SettingOrigin(layer=Source.PROCESS_RUNTIME))
483
+ self._refresh_default_settings_from_env()
484
+ return {
485
+ "key": key,
486
+ "envKey": option.env_alias,
487
+ "settingKey": option.setting_key,
488
+ "value": _config_display_value(stored),
489
+ }
490
+
491
+ def get_config_options(self, session_id: str | None = None) -> dict[str, object]:
492
+ """Return supported config options: durable values plus provenance.
493
+
494
+ ``value`` is what the user document stores. The effective pair is
495
+ named ``baseValue``/``baseSource`` because without a ``session_id``
496
+ the manager — which holds many sessions — can only answer for its
497
+ base settings, not claim any one session's view. Passing a
498
+ ``session_id`` adds ``sessionValue``/``sessionSource`` read from that
499
+ session's own loaded settings.
500
+
501
+ The whole snapshot is taken inside the same transaction lock the
502
+ writers hold. Without it this read falls into the gap a write leaves
503
+ between committing the document and republishing the base settings —
504
+ the document's file lock is released at the first of those, not the
505
+ second — and the reply pairs the newly stored ``value`` with the
506
+ superseded ``baseValue``/``baseSource``, which is exactly the
507
+ contradiction the three fields exist to rule out. Taking the lock
508
+ before the document's own is the order every writer uses.
509
+ """
510
+ with self._global_settings_lock:
511
+ session_loaded = self.get(session_id).host.engine.loaded_settings if session_id else None
512
+ doc = read_yaml_doc(user_settings_path()) or {}
513
+ keys = frozenset(option.setting_key for option in _SUPPORTED_CONFIG_OPTIONS)
514
+ values, _ = flatten_user_doc(doc, keys)
515
+ base = self._loaded_settings
516
+ base_values = asdict(base.settings)
517
+ session_values = asdict(session_loaded.settings) if session_loaded is not None else {}
518
+ fields = _config_setting_fields()
519
+
520
+ options: list[dict[str, object]] = []
521
+ for option in _SUPPORTED_CONFIG_OPTIONS:
522
+ entry: dict[str, object] = {
523
+ "key": option.logical_key,
524
+ "envKey": option.env_alias,
525
+ "settingKey": option.setting_key,
526
+ "value": _config_display_value(values.get(option.setting_key, "")),
527
+ "baseValue": _config_display_value(base_values[fields[option.setting_key]]),
528
+ "baseSource": base.source_for(option.setting_key).layer.name.lower(),
529
+ }
530
+ if session_loaded is not None:
531
+ entry["sessionValue"] = _config_display_value(session_values[fields[option.setting_key]])
532
+ entry["sessionSource"] = session_loaded.source_for(option.setting_key).layer.name.lower()
533
+ options.append(entry)
534
+ payload: dict[str, object] = {"options": options}
535
+ if session_id:
536
+ payload["sessionId"] = session_id
537
+ return payload
538
+
539
+ async def test_mcp_server(self, data: dict[str, object]) -> dict[str, object]:
540
+ """Run a one-shot MCP connection test for a config payload."""
541
+ config = _mcp_config_from_data(data)
542
+ adapter = MCPAdapter(reserved_tool_names=chrys_reserved_tool_names())
543
+ try:
544
+ await adapter.test_connection(config)
545
+ except Exception as exc:
546
+ return {
547
+ "ok": False,
548
+ "name": config.name,
549
+ "message": str(exc),
550
+ "bannerLines": list(getattr(exc, "banner_lines", []) or []),
551
+ }
552
+ return {"ok": True, "name": config.name, "message": "Connected."}
553
+
554
+ def _build_host(
555
+ self,
556
+ *,
557
+ profile_name: str,
558
+ session_id: str | None = None,
559
+ cwd: str,
560
+ loaded_settings: LoadedSettings,
561
+ workspace: Workspace | None,
562
+ mcp_overlay: list[MCPServerConfig],
563
+ ) -> tuple[ChrysSessionHost, SessionTitleUpdater]:
564
+ """Construct a session host plus its per-session auto-title updater.
565
+
566
+ The updater's turn callbacks must be wired at engine construction,
567
+ but the updater itself needs the constructed host's bus and engine —
568
+ hence the late-bound slot.
569
+ """
570
+ updater_slot: list[SessionTitleUpdater] = []
571
+ process_on_successful_turn = self._on_successful_turn
572
+
573
+ def _on_successful_turn() -> None:
574
+ if process_on_successful_turn is not None:
575
+ process_on_successful_turn()
576
+ if updater_slot:
577
+ updater_slot[0].on_turn_finished()
578
+
579
+ def _on_turn_started() -> None:
580
+ if updater_slot:
581
+ updater_slot[0].on_turn_started()
582
+
583
+ host = ChrysSessionHost(
584
+ profile_name=profile_name,
585
+ session_id=session_id,
586
+ loaded_settings=loaded_settings,
587
+ agent_registry=self._agent_registry,
588
+ model_registry=self._model_registry,
589
+ state_store=self._state_store,
590
+ approval_mode=self._approval_mode,
591
+ cwd=cwd,
592
+ workspace=workspace,
593
+ mcp_overlay=mcp_overlay,
594
+ allow_user_interaction=True,
595
+ on_successful_turn=_on_successful_turn,
596
+ on_turn_started=_on_turn_started,
597
+ surface=SessionSurface.ACP,
598
+ )
599
+ updater = SessionTitleUpdater(host.event_bus, self._state_store, engine_getter=lambda: host.engine)
600
+ updater_slot.append(updater)
601
+ return host, updater
602
+
603
+ async def new_session(
604
+ self,
605
+ *,
606
+ cwd: str | None,
607
+ additional_directories: list[str] | None = None,
608
+ mcp_servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
609
+ warnings: list[Warning] | None = None,
610
+ ) -> ManagedSession:
611
+ """Create and start a new Chrys session.
612
+
613
+ Args:
614
+ warnings: Collects what this session's settings load had to
615
+ reject — a denied or dormant project key, for one. Session
616
+ creation runs outside any prompt turn, so nothing forwards bus
617
+ events; a caller that does not collect these has no other way
618
+ to learn the cwd it named carries settings that did not apply.
619
+ """
620
+ async with self._lock:
621
+ self.load_registries()
622
+ profile = self._agent_registry.resolve_selector(self._profile_name)
623
+ if profile is None:
624
+ msg = f"Agent profile not found: {self._profile_name}"
625
+ raise AcpSessionError(msg)
626
+ mcp_overlay = _mcp_overlay(mcp_servers)
627
+ resolved_cwd = self._resolve_requested_cwd(cwd)
628
+ # Off-thread: the load reads settings files. Derived per session
629
+ # because each session's cwd names its own project trust domain.
630
+ session_loaded = await asyncio.to_thread(self._session_loaded_settings, resolved_cwd)
631
+ if warnings is not None:
632
+ warnings.extend(settings_warning_events(session_loaded))
633
+ host, title_updater = self._build_host(
634
+ profile_name=profile.name,
635
+ cwd=resolved_cwd,
636
+ loaded_settings=session_loaded,
637
+ workspace=_workspace_from_dirs(resolved_cwd, additional_directories)
638
+ if additional_directories
639
+ else None,
640
+ mcp_overlay=mcp_overlay,
641
+ )
642
+ # ACP owns the ask_user lifetime, so its injected timeout survives a
643
+ # settings reload instead of reverting to the env default.
644
+ host.engine.pin_ask_user_timeout()
645
+ try:
646
+ await host.start()
647
+ except BaseException:
648
+ await _shutdown_after_failed_start(host, title_updater)
649
+ raise
650
+ session_id = host.session_id
651
+ if session_id is None:
652
+ msg = f"{APP_DISPLAY_NAME} did not create a session id."
653
+ await _shutdown_after_failed_start(host, title_updater)
654
+ raise AcpSessionError(msg)
655
+ managed = ManagedSession(
656
+ session_id=session_id,
657
+ cwd=resolved_cwd,
658
+ profile_name=profile.name,
659
+ host=host,
660
+ title_updater=title_updater,
661
+ )
662
+ self._sessions[session_id] = managed
663
+ return managed
664
+
665
+ async def load_session(
666
+ self,
667
+ *,
668
+ cwd: str | None,
669
+ session_id: str,
670
+ additional_directories: list[str] | None = None,
671
+ mcp_servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
672
+ warnings: list[Warning] | None = None,
673
+ ) -> ManagedLoadResult:
674
+ """Restore an existing Chrys session.
675
+
676
+ Args:
677
+ warnings: Same contract as :meth:`new_session`; empty when an
678
+ already-active session is reused, because nothing was loaded.
679
+ """
680
+ async with self._lock:
681
+ self.load_registries()
682
+ resolved_cwd = self._resolve_requested_cwd(cwd)
683
+ mcp_overlay = _mcp_overlay(mcp_servers)
684
+ existing = self._resolve_active_session(session_id)
685
+ if existing is not None:
686
+ self._validate_active_cwd(existing, resolved_cwd)
687
+ # Overlays (MCP servers / extra directories) cannot be applied to a
688
+ # running host, so reject rather than silently dropping them.
689
+ if mcp_overlay:
690
+ msg = (
691
+ f"Session '{session_short_id(existing.session_id)}' is already active. "
692
+ "Close it before reloading with MCP server overlays."
693
+ )
694
+ raise AcpSessionError(msg)
695
+ if additional_directories is not None:
696
+ # Changing roots (including clearing them) means rebuilding the
697
+ # workspace, which a running host can't do — reject rather than
698
+ # silently keeping the old scope.
699
+ msg = (
700
+ f"Session '{session_short_id(existing.session_id)}' is already active. "
701
+ "Close it before reloading with additional directories."
702
+ )
703
+ raise AcpSessionError(msg)
704
+ return ManagedLoadResult(
705
+ session=existing,
706
+ reused_existing=True,
707
+ recovered_from_sidecar=existing.host.engine.recovered_from_sidecar,
708
+ )
709
+ canonical_id, meta = await self._resolve_session_meta(session_id)
710
+ self._validate_saved_cwd(meta, resolved_cwd)
711
+ # Off-thread: the load reads settings files. The restore itself
712
+ # re-derives against the saved cwd, but the host must not exist —
713
+ # even pre-restore — under another project's trust domain.
714
+ session_loaded = await asyncio.to_thread(self._session_loaded_settings, resolved_cwd)
715
+ host, title_updater = self._build_host(
716
+ profile_name="",
717
+ session_id=canonical_id,
718
+ cwd=resolved_cwd,
719
+ loaded_settings=session_loaded,
720
+ # ``None`` keeps the saved roots; a list (even empty) is authoritative,
721
+ # so pass a workspace whenever the client specified the field at all.
722
+ workspace=_workspace_from_dirs(resolved_cwd, additional_directories)
723
+ if additional_directories is not None
724
+ else None,
725
+ mcp_overlay=mcp_overlay,
726
+ )
727
+ # ACP owns the ask_user lifetime, so its injected timeout survives a
728
+ # settings reload instead of reverting to the env default.
729
+ host.engine.pin_ask_user_timeout()
730
+ try:
731
+ await host.start()
732
+ except BaseException:
733
+ await _shutdown_after_failed_start(host, title_updater)
734
+ raise
735
+ if warnings is not None:
736
+ # Composed from the restore's committed load, not the
737
+ # pre-restore snapshot above: the restore re-reads the same
738
+ # root, and an edit landing between the two reads would
739
+ # otherwise be reported stale — or a rejection only the
740
+ # committed load hit would go unheard, since its bus warnings
741
+ # have no subscriber here.
742
+ warnings.extend(settings_warning_events(host.engine.loaded_settings))
743
+ managed = ManagedSession(
744
+ session_id=canonical_id,
745
+ cwd=resolved_cwd,
746
+ profile_name=host.engine.current_profile_snapshot().to_profile or meta.agent_profile,
747
+ host=host,
748
+ title_updater=title_updater,
749
+ )
750
+ self._sessions[canonical_id] = managed
751
+ return ManagedLoadResult(
752
+ session=managed,
753
+ reused_existing=False,
754
+ recovered_from_sidecar=host.engine.recovered_from_sidecar,
755
+ )
756
+
757
+ async def list_sessions(
758
+ self,
759
+ *,
760
+ cwd: str | None,
761
+ cursor: str | None,
762
+ additional_directories: list[str] | None = None,
763
+ ) -> tuple[list[acp_schema.SessionInfo], str | None]:
764
+ """List saved sessions scoped to this ACP workspace.
765
+
766
+ ``additional_directories`` is an exact, ordered additional-root filter (ACP
767
+ workspace identity). When supplied with at least one root, a session matches
768
+ only if its saved extra roots equal the request exactly; when omitted or
769
+ empty, all sessions for ``cwd`` are returned (their roots are reported back
770
+ via ``additionalDirectories``).
771
+ """
772
+ if cwd is None and self._process_cwd is None:
773
+ return [], None
774
+ requested_cwd = self._resolve_requested_cwd(cwd)
775
+ matches = [
776
+ meta
777
+ for meta in cast(list[ChatSessionMeta], await self._state_store.list_sessions(kind="chat"))
778
+ if _same_dir(meta.primary_cwd, requested_cwd)
779
+ ]
780
+ if additional_directories:
781
+ # Validate filter roots like new/load: absolute, existing dirs. This also
782
+ # normalizes them so the exact-match comparison is apples-to-apples.
783
+ resolved_additional = [_resolve_dir(path) for path in additional_directories]
784
+ matches = [meta for meta in matches if _extra_dirs_match(resolved_additional, meta)]
785
+ sessions = sorted(
786
+ matches,
787
+ key=lambda meta: (meta.updated_at, meta.session_id),
788
+ reverse=True,
789
+ )
790
+ start = _decode_cursor(cursor)
791
+ page_size = 50
792
+ page = sessions[start : start + page_size]
793
+ next_cursor = str(start + page_size) if start + page_size < len(sessions) else None
794
+ return [_session_info(meta) for meta in page], next_cursor
795
+
796
+ def get(self, session_id: str) -> ManagedSession:
797
+ """Return an active managed session."""
798
+ session = self._sessions.get(session_id)
799
+ if session is None or session.closing:
800
+ msg = f"ACP session is not active: {session_id}"
801
+ raise AcpSessionError(msg)
802
+ return session
803
+
804
+ def tool_kind_resolver(self, session_id: str) -> Callable[[str], str]:
805
+ """Return a resolver for live tool kinds in an active session."""
806
+ session = self.get(session_id)
807
+ return session.host.engine.tool_kind_for_name
808
+
809
+ async def cancel(self, session_id: str) -> None:
810
+ """Cancel an active session turn.
811
+
812
+ Deliberately tolerates a session already marked closing: close and
813
+ delete stop prompt admission first and interrupt second, so the
814
+ interrupt must still reach the turn that is being drained.
815
+ """
816
+ session = self._sessions.get(session_id)
817
+ if session is None:
818
+ msg = f"ACP session is not active: {session_id}"
819
+ raise AcpSessionError(msg)
820
+ await session.host.cancel_current_turn()
821
+
822
+ async def inject(self, session_id: str, text: str) -> None:
823
+ """Inject a prompt into the active turn (rejects when none is running)."""
824
+ session = self.get(session_id)
825
+ if not session.host.engine.is_turn_active:
826
+ # Publishing UserMessage here would start a brand-new turn outside any
827
+ # ACP session/prompt stream (no PromptResponse, no standard updates).
828
+ # session/prompt is the path for new turns; injection requires an active one.
829
+ msg = "No active turn to inject into."
830
+ raise AcpSessionError(msg)
831
+ # UserInject (not UserMessage) never starts a turn, so even if the turn ends
832
+ # between the guard above and event delivery, this cannot spawn a stray run.
833
+ await session.host.event_bus.publish(UserInject(text=text, session_id=session.host.session_id))
834
+
835
+ async def rollback(
836
+ self,
837
+ session_id: str,
838
+ *,
839
+ target_turn: int,
840
+ revert_changes: bool,
841
+ selected_paths: list[str] | None,
842
+ ) -> RollbackResult:
843
+ """Request rollback and wait for the backend result."""
844
+ session = self.get(session_id)
845
+ bus = session.host.event_bus
846
+ loop = asyncio.get_running_loop()
847
+ result_future: asyncio.Future[RollbackResult] = loop.create_future()
848
+
849
+ async def _on_result(event: RollbackResult) -> None:
850
+ if event.session_id == session.host.session_id and not result_future.done():
851
+ result_future.set_result(event)
852
+
853
+ async def _on_error(event: Error) -> None:
854
+ if event.session_id == session.host.session_id and not result_future.done():
855
+ result_future.set_exception(AcpSessionError(event.message or event.code or "Rollback failed."))
856
+
857
+ async def _on_warning(event: Warning) -> None:
858
+ # Only rollback refusals (rollback_*) fail the RPC. A successful rollback
859
+ # restores the session first, which can emit unrelated non-fatal warnings
860
+ # (service_session_incompatible, sub_agents_reload_discarded) before the
861
+ # RollbackResult — those must not be mistaken for a refusal.
862
+ if (
863
+ event.session_id == session.host.session_id
864
+ and (event.code or "").startswith("rollback_")
865
+ and not result_future.done()
866
+ ):
867
+ result_future.set_exception(AcpSessionError(event.message or event.code or "Rollback was refused."))
868
+
869
+ await bus.subscribe(RollbackResult, _on_result)
870
+ await bus.subscribe(Error, _on_error)
871
+ await bus.subscribe(Warning, _on_warning)
872
+ try:
873
+ await bus.publish(
874
+ UserRollback(
875
+ target_turn=target_turn,
876
+ revert_changes=revert_changes,
877
+ selected_paths=selected_paths,
878
+ session_id=session.host.session_id,
879
+ )
880
+ )
881
+ return await asyncio.wait_for(result_future, timeout=30)
882
+ except TimeoutError as exc:
883
+ msg = "Timed out waiting for rollback result."
884
+ raise AcpSessionError(msg) from exc
885
+ finally:
886
+ await bus.unsubscribe(RollbackResult, _on_result)
887
+ await bus.unsubscribe(Error, _on_error)
888
+ await bus.unsubscribe(Warning, _on_warning)
889
+
890
+ async def retry_sub_agent(self, session_id: str, invocation_id: str) -> None:
891
+ """Request retry for a paused sub-agent invocation."""
892
+ session = self.get(session_id)
893
+ await session.host.event_bus.publish(
894
+ InvocationRetryRequested(invocation_id=invocation_id, session_id=session.host.session_id)
895
+ )
896
+
897
+ async def abort_sub_agent(self, session_id: str, invocation_id: str) -> None:
898
+ """Request abort for a paused sub-agent invocation."""
899
+ session = self.get(session_id)
900
+ await session.host.event_bus.publish(
901
+ InvocationAbortRequested(invocation_id=invocation_id, session_id=session.host.session_id)
902
+ )
903
+
904
+ async def skip_sleep(self, session_id: str, call_id: str) -> None:
905
+ """Request that an active sleep tool call finish early."""
906
+ if not call_id:
907
+ msg = "call_id is required."
908
+ raise AcpSessionError(msg)
909
+ session = self.get(session_id)
910
+ await session.host.event_bus.publish(SleepSkip(call_id=call_id))
911
+
912
+ async def set_approval_mode(self, session_id: str, mode: str) -> ApprovalModeUpdated:
913
+ """Set the runtime approval mode and wait for backend confirmation."""
914
+ session = self.get(session_id)
915
+ bus = session.host.event_bus
916
+
917
+ async with session.mutation_lock:
918
+ loop = asyncio.get_running_loop()
919
+ result_future: asyncio.Future[ApprovalModeUpdated] = loop.create_future()
920
+
921
+ async def _on_updated(event: ApprovalModeUpdated) -> None:
922
+ if event.session_id == session.host.session_id and event.mode == mode and not result_future.done():
923
+ result_future.set_result(event)
924
+
925
+ await bus.subscribe(ApprovalModeUpdated, _on_updated)
926
+ try:
927
+ await bus.publish(SetApprovalMode(mode=mode, persist=False, session_id=session.host.session_id))
928
+ return await asyncio.wait_for(result_future, timeout=5)
929
+ except TimeoutError as exc:
930
+ msg = f"Timed out waiting for approval mode update: {mode}"
931
+ raise AcpSessionError(msg) from exc
932
+ finally:
933
+ await bus.unsubscribe(ApprovalModeUpdated, _on_updated)
934
+
935
+ async def switch_agent(self, session_id: str, profile_name: str) -> ProfileSwitched:
936
+ """Switch the active session to another agent profile."""
937
+ session = self.get(session_id)
938
+ self.load_registries()
939
+ profile = self._agent_registry.resolve_selector(profile_name)
940
+ if profile is None:
941
+ # Invalid profiles publish an Error rather than ProfileSwitched, so reject
942
+ # here instead of waiting out the 60s timeout for an event that never comes.
943
+ msg = f"Agent profile not found: {profile_name}"
944
+ available = ", ".join(sorted(self._agent_registry.list_names()))
945
+ if available:
946
+ msg = f"{msg}. Available profiles: {available}"
947
+ raise AcpSessionError(msg)
948
+ async with session.mutation_lock:
949
+ if session.profile_name == profile.name:
950
+ return session.host.engine.current_profile_snapshot()
951
+ result = await self._await_runtime_mutation_locked(
952
+ session,
953
+ AgentProfileSwitch(profile_name=profile.name, session_id=session.host.session_id),
954
+ ProfileSwitched,
955
+ timeout_message=f"Timed out waiting for profile switch: {profile.name}",
956
+ failure_message="Profile switch failed.",
957
+ )
958
+ session.profile_name = result.to_profile or profile.name
959
+ return result
960
+
961
+ async def _await_runtime_mutation(
962
+ self,
963
+ session: ManagedSession,
964
+ command: Event,
965
+ result_type: type[_RuntimeEventT],
966
+ *,
967
+ timeout_message: str,
968
+ failure_message: str,
969
+ timeout: float = 60,
970
+ warnings: list[Warning] | None = None,
971
+ ) -> _RuntimeEventT:
972
+ """Publish a runtime-control command and await its result event.
973
+
974
+ Resolves on the success event, or fails fast on ``Error`` /
975
+ ``AgentLoadFailed``. A rebuild failure publishes ``AgentLoadFailed`` and
976
+ then raises inside the bus handler (swallowed by default), so without
977
+ watching it the request would hang until the timeout.
978
+
979
+ Args:
980
+ warnings: Collects the ``Warning`` events this session publishes
981
+ while the mutation runs. Passed as a list rather than returned
982
+ so the caller decides whether the diagnosis is worth carrying —
983
+ only the reload has anything to say.
984
+ """
985
+ # Serialize mutations on this session: two concurrent calls of the same
986
+ # result type would otherwise each be eligible to resolve from whichever
987
+ # completion event fires first, crossing the wires.
988
+ async with session.mutation_lock:
989
+ return await self._await_runtime_mutation_locked(
990
+ session,
991
+ command,
992
+ result_type,
993
+ timeout_message=timeout_message,
994
+ failure_message=failure_message,
995
+ timeout=timeout,
996
+ warnings=warnings,
997
+ )
998
+
999
+ async def _await_runtime_mutation_locked(
1000
+ self,
1001
+ session: ManagedSession,
1002
+ command: Event,
1003
+ result_type: type[_RuntimeEventT],
1004
+ *,
1005
+ timeout_message: str,
1006
+ failure_message: str,
1007
+ timeout: float = 60,
1008
+ warnings: list[Warning] | None = None,
1009
+ ) -> _RuntimeEventT:
1010
+ """Publish a runtime-control command while the session mutation lock is held."""
1011
+ bus = session.host.event_bus
1012
+ sid = session.host.session_id
1013
+ loop = asyncio.get_running_loop()
1014
+
1015
+ future: asyncio.Future[_RuntimeEventT] = loop.create_future()
1016
+
1017
+ async def _on_result(event: _RuntimeEventT) -> None:
1018
+ if event.session_id == sid and not future.done():
1019
+ future.set_result(event)
1020
+
1021
+ async def _on_error(event: Error) -> None:
1022
+ if event.session_id == sid and not future.done():
1023
+ future.set_exception(AcpSessionError(event.message or event.code or failure_message))
1024
+
1025
+ async def _on_load_failed(event: AgentLoadFailed) -> None:
1026
+ if event.session_id == sid and not future.done():
1027
+ future.set_exception(AcpSessionError(event.message or failure_message))
1028
+
1029
+ async def _on_warning(event: Warning) -> None:
1030
+ if event.session_id == sid and warnings is not None:
1031
+ warnings.append(event)
1032
+
1033
+ await bus.subscribe(result_type, _on_result)
1034
+ await bus.subscribe(Error, _on_error)
1035
+ await bus.subscribe(AgentLoadFailed, _on_load_failed)
1036
+ await bus.subscribe(Warning, _on_warning)
1037
+ try:
1038
+ await bus.publish(command)
1039
+ return await asyncio.wait_for(future, timeout=timeout)
1040
+ except TimeoutError as exc:
1041
+ raise AcpSessionError(timeout_message) from exc
1042
+ finally:
1043
+ await bus.unsubscribe(result_type, _on_result)
1044
+ await bus.unsubscribe(Error, _on_error)
1045
+ await bus.unsubscribe(AgentLoadFailed, _on_load_failed)
1046
+ await bus.unsubscribe(Warning, _on_warning)
1047
+
1048
+ async def set_model_profile(self, session_id: str, profile_id: str) -> ModelProfileSwitched:
1049
+ """Switch the active model for this session only (no global .env write)."""
1050
+ self.load_registries()
1051
+ if not profile_id or self._model_registry.get(profile_id) is None:
1052
+ msg = f"Model profile not found: {profile_id}"
1053
+ raise AcpSessionError(msg)
1054
+ session = self.get(session_id)
1055
+ return await self._await_runtime_mutation(
1056
+ session,
1057
+ SetModelProfile(profile_id=profile_id, session_id=session.host.session_id),
1058
+ ModelProfileSwitched,
1059
+ timeout_message=f"Timed out waiting for model switch: {profile_id}",
1060
+ failure_message="Model switch failed.",
1061
+ )
1062
+
1063
+ async def session_history(self, *, cwd: str | None, session_id: str) -> tuple[str, list[dict[str, object]] | None]:
1064
+ """Return raw persisted messages, scoped to the requesting workspace cwd.
1065
+
1066
+ Mirrors ``load``/``delete`` scoping so a client cannot read another
1067
+ workspace's session history just by knowing its id.
1068
+ """
1069
+ resolved_cwd = self._resolve_requested_cwd(cwd)
1070
+ existing = self._resolve_active_session(session_id)
1071
+ if existing is not None:
1072
+ self._validate_active_cwd(existing, resolved_cwd)
1073
+ messages = await self._state_store.load_session_raw(
1074
+ existing.session_id,
1075
+ prefer_recovery=existing.host.engine.recovered_from_sidecar,
1076
+ )
1077
+ return existing.session_id, messages
1078
+ canonical_id, meta = await self._resolve_session_meta(session_id)
1079
+ self._validate_saved_cwd(meta, resolved_cwd)
1080
+ prefer_recovery = await self._meta_resolved_from_recovery(canonical_id, meta)
1081
+ return canonical_id, await self._state_store.load_session_raw(canonical_id, prefer_recovery=prefer_recovery)
1082
+
1083
+ async def reload_settings(self, session_id: str, *, warnings: list[Warning] | None = None) -> SettingsReloaded:
1084
+ """Reload settings/profile files for an active session.
1085
+
1086
+ Awaits the rebuild so a failed soft-restart (``AgentLoadFailed``, which
1087
+ the bus otherwise swallows) surfaces as an error instead of a silent
1088
+ success — mirroring the model/workspace/profile mutations.
1089
+
1090
+ Args:
1091
+ warnings: Collects the settings this reload had to reject. A reload
1092
+ is a request whose whole point is "apply what I just wrote", so
1093
+ a client that is told it succeeded and never told which of its
1094
+ values was dropped has been misinformed. Nothing else on the bus
1095
+ is listening: warnings published outside a prompt turn reach no
1096
+ subscriber, and the bus does not replay them to later ones.
1097
+ """
1098
+ session = self.get(session_id)
1099
+ self._agent_registry.load_all()
1100
+ self._model_registry.load_all()
1101
+ result = await self._await_runtime_mutation(
1102
+ session,
1103
+ SettingsReload(),
1104
+ SettingsReloaded,
1105
+ timeout_message="Timed out waiting for settings reload.",
1106
+ failure_message="Settings reload failed.",
1107
+ warnings=warnings,
1108
+ )
1109
+ await asyncio.to_thread(self._refresh_default_settings_from_env)
1110
+ return result
1111
+
1112
+ async def apply_config_option(
1113
+ self,
1114
+ session_id: str,
1115
+ key: str,
1116
+ value: object,
1117
+ *,
1118
+ warnings: list[Warning] | None = None,
1119
+ ) -> dict[str, object]:
1120
+ """Persist a config option and reload the target session.
1121
+
1122
+ The session is validated *before* the document write so a missing,
1123
+ stale, or inactive ``session_id`` fails the request without leaving the
1124
+ persisted config mutated. The write itself is global — it lands in the
1125
+ user settings document, whose keys are process-wide defaults — and the
1126
+ reload applies it to the live session.
1127
+
1128
+ Args:
1129
+ warnings: Collects what the reload had to reject or clamp, for the
1130
+ same reason ``reload_settings`` does — more so here, because
1131
+ this path *persists* the value first. Without it a client that
1132
+ sets ``rollback_snapshots_keep`` to ``0`` is told it succeeded,
1133
+ the ``0`` is written to disk, and the runtime quietly uses ``1``.
1134
+ """
1135
+ self.get(session_id)
1136
+ # Off-thread: the write takes the settings file's lock and the refresh
1137
+ # reads the file back, and this server answers every other request on
1138
+ # the same event loop.
1139
+ result = await asyncio.to_thread(self.set_config_option, key, value)
1140
+ await self.reload_settings(session_id, warnings=warnings)
1141
+ return result
1142
+
1143
+ async def set_workspace(self, session_id: str, primary_cwd: str) -> WorkspaceUpdated:
1144
+ """Change the session primary workspace cwd."""
1145
+ session = self.get(session_id)
1146
+ # Validate strictly (must exist, be a dir, resolve absolutely) like new/load.
1147
+ # WorkspaceChange → Workspace.from_cwd only absolutizes, so an unvalidated typo
1148
+ # would rebuild/persist a broken workspace or resolve a relative path against the
1149
+ # process cwd instead of the session.
1150
+ resolved_cwd = _resolve_dir(primary_cwd)
1151
+ result = await self._await_runtime_mutation(
1152
+ session,
1153
+ WorkspaceChange(primary_cwd=resolved_cwd, session_id=session.host.session_id),
1154
+ WorkspaceUpdated,
1155
+ timeout_message=f"Timed out waiting for workspace update: {resolved_cwd}",
1156
+ failure_message="Workspace update failed.",
1157
+ )
1158
+ session.cwd = result.primary_cwd or resolved_cwd
1159
+ return result
1160
+
1161
+ async def begin_close(self, session_id: str) -> None:
1162
+ """Stop prompt admission for a session about to be closed.
1163
+
1164
+ Callers release pending client waits between this call and
1165
+ :meth:`close`. Marking the session closing first means a prompt queued
1166
+ on the session's prompt lock fails its post-acquisition re-check
1167
+ instead of being admitted into a fresh turn that the lock-protected
1168
+ teardown would then wedge behind. No-op when the session is not
1169
+ active.
1170
+ """
1171
+ async with self._lock:
1172
+ session = self._sessions.get(session_id)
1173
+ if session is not None:
1174
+ session.closing = True
1175
+
1176
+ async def close(self, session_id: str) -> None:
1177
+ """Close an active session."""
1178
+ async with self._lock:
1179
+ session = self._sessions.get(session_id)
1180
+ if session is not None:
1181
+ session.closing = True
1182
+ async with session.prompt_lock:
1183
+ self._sessions.pop(session_id, None)
1184
+ await session.shutdown()
1185
+
1186
+ async def begin_delete_session(self, *, cwd: str | None, session_id: str) -> str:
1187
+ """Validate a delete request, stop prompt admission, and return the canonical id.
1188
+
1189
+ Applies workspace scoping and short-id canonicalization, then marks an
1190
+ active target closing — validation first, inside one lock critical
1191
+ section, so a rejected request has no side effects. Callers release
1192
+ per-session waits under the canonical id next and finish with
1193
+ :meth:`finish_delete_session`; a prompt queued behind the released
1194
+ turn then fails its re-check instead of starting a turn the teardown
1195
+ would wedge behind.
1196
+ """
1197
+ async with self._lock:
1198
+ resolved_cwd = self._resolve_requested_cwd(cwd)
1199
+ canonical_id, meta = await self._resolve_session_meta(session_id)
1200
+ self._validate_saved_cwd(meta, resolved_cwd)
1201
+ session = self._sessions.get(canonical_id)
1202
+ if session is not None:
1203
+ session.closing = True
1204
+ return canonical_id
1205
+
1206
+ async def finish_delete_session(self, canonical_id: str) -> None:
1207
+ """Tear down and delete a session claimed by :meth:`begin_delete_session`."""
1208
+ async with self._lock:
1209
+ session = self._sessions.get(canonical_id)
1210
+ if session is not None:
1211
+ session.closing = True
1212
+ async with session.prompt_lock:
1213
+ self._sessions.pop(canonical_id, None)
1214
+ await session.shutdown()
1215
+ await self._state_store.delete_session(canonical_id)
1216
+
1217
+ async def delete_session(self, *, cwd: str | None, session_id: str) -> None:
1218
+ """Delete a saved session scoped to this ACP process cwd."""
1219
+ canonical_id = await self.begin_delete_session(cwd=cwd, session_id=session_id)
1220
+ await self.finish_delete_session(canonical_id)
1221
+
1222
+ async def shutdown(self) -> None:
1223
+ """Close every active session."""
1224
+ sessions = list(self._sessions.values())
1225
+ self._sessions.clear()
1226
+ for session in sessions:
1227
+ session.closing = True
1228
+ for session in sessions:
1229
+ async with session.prompt_lock:
1230
+ await session.shutdown()
1231
+
1232
+ def _resolve_requested_cwd(self, cwd: str | None) -> str:
1233
+ if cwd is None:
1234
+ if self._process_cwd is not None:
1235
+ if not os.path.isdir(self._process_cwd):
1236
+ msg = f"working directory no longer exists: {surrogate_safe_text(self._process_cwd)}"
1237
+ raise AcpSessionError(msg)
1238
+ return self._process_cwd
1239
+ msg = "cwd is required when this ACP process was not started with --workdir."
1240
+ raise AcpSessionError(msg)
1241
+ return _resolve_dir(cwd)
1242
+
1243
+ async def _resolve_session_meta(self, session_id: str) -> tuple[str, ChatSessionMeta]:
1244
+ raw = (session_id or "").strip()
1245
+ if not raw:
1246
+ msg = "session_id is required."
1247
+ raise AcpSessionError(msg)
1248
+ sessions = cast(list[ChatSessionMeta], await self._state_store.list_sessions(kind="chat"))
1249
+ normalized = raw.replace("-", "").lower()
1250
+ if len(normalized) <= SESSION_SHORT_ID_LEN:
1251
+ matches = [meta for meta in sessions if session_short_id(meta.session_id).lower() == normalized]
1252
+ else:
1253
+ matches = [
1254
+ meta
1255
+ for meta in sessions
1256
+ if meta.session_id == raw or meta.session_id.replace("-", "").lower() == normalized
1257
+ ]
1258
+ if len(matches) == 1:
1259
+ return matches[0].session_id, matches[0]
1260
+ if len(matches) > 1:
1261
+ raise AcpSessionError(f"Session id '{raw}' is ambiguous.")
1262
+ raise AcpSessionError(f"Session not found: {raw}")
1263
+
1264
+ async def _meta_resolved_from_recovery(self, session_id: str, meta: ChatSessionMeta) -> bool:
1265
+ """Return whether lock-aware listing metadata came from the recovery sidecar."""
1266
+ primary_meta = await self._state_store.load_session_meta(session_id, prefer_recovery=False)
1267
+ if primary_meta is None:
1268
+ return True
1269
+ return meta.updated_at > primary_meta.updated_at
1270
+
1271
+ def _resolve_active_session(self, session_id: str) -> ManagedSession | None:
1272
+ """Resolve an already-active session without consulting persisted session listings."""
1273
+ raw = (session_id or "").strip()
1274
+ if not raw:
1275
+ msg = "session_id is required."
1276
+ raise AcpSessionError(msg)
1277
+ direct = self._sessions.get(raw)
1278
+ if direct is not None:
1279
+ return self._reject_closing(direct)
1280
+ normalized = raw.replace("-", "").lower()
1281
+ if len(normalized) <= SESSION_SHORT_ID_LEN:
1282
+ matches = [
1283
+ session
1284
+ for session in self._sessions.values()
1285
+ if session_short_id(session.session_id).lower() == normalized
1286
+ ]
1287
+ else:
1288
+ matches = [
1289
+ session
1290
+ for session in self._sessions.values()
1291
+ if session.session_id == raw or session.session_id.replace("-", "").lower() == normalized
1292
+ ]
1293
+ if len(matches) == 1:
1294
+ return self._reject_closing(matches[0])
1295
+ if len(matches) > 1:
1296
+ raise AcpSessionError(f"Session id '{raw}' is ambiguous.")
1297
+ return None
1298
+
1299
+ @staticmethod
1300
+ def _reject_closing(session: ManagedSession) -> ManagedSession:
1301
+ """Refuse to hand out a session that a close/delete is tearing down.
1302
+
1303
+ A closing session stays in the map only while pending waits drain.
1304
+ Returning it would report a successful reuse for a session about to
1305
+ shut down — and callers must not treat it as inactive and fall
1306
+ through to a fresh load either: that would overwrite the live map
1307
+ entry the in-flight teardown is about to pop, so the teardown would
1308
+ shut down the replacement. Raising is the only safe answer until the
1309
+ teardown finishes.
1310
+ """
1311
+ if session.closing:
1312
+ msg = f"ACP session is closing: {session.session_id}"
1313
+ raise AcpSessionError(msg)
1314
+ return session
1315
+
1316
+ def _validate_saved_cwd(self, meta: ChatSessionMeta, requested_cwd: str) -> None:
1317
+ if not meta.primary_cwd:
1318
+ msg = (
1319
+ f"Session '{session_short_id(meta.session_id)}' has no saved cwd metadata and cannot be loaded "
1320
+ "over ACP."
1321
+ )
1322
+ raise AcpSessionError(msg)
1323
+ if not os.path.isdir(meta.primary_cwd):
1324
+ msg = (
1325
+ f"working directory of session '{session_short_id(meta.session_id)}' no longer exists: "
1326
+ f"{surrogate_safe_text(meta.primary_cwd)}"
1327
+ )
1328
+ raise AcpSessionError(msg)
1329
+ if not _same_dir(meta.primary_cwd, requested_cwd):
1330
+ logger.warning(
1331
+ "ACP session load cwd mismatch for %s: saved cwd %s requested cwd %s",
1332
+ session_short_id(meta.session_id),
1333
+ meta.primary_cwd,
1334
+ requested_cwd,
1335
+ )
1336
+ msg = f"Session '{session_short_id(meta.session_id)}' belongs to a different workspace."
1337
+ raise AcpSessionError(msg)
1338
+
1339
+ def _validate_active_cwd(self, session: ManagedSession, requested_cwd: str) -> None:
1340
+ if not _same_dir(session.cwd, requested_cwd):
1341
+ logger.warning(
1342
+ "ACP active session load cwd mismatch for %s: active cwd %s requested cwd %s",
1343
+ session_short_id(session.session_id),
1344
+ session.cwd,
1345
+ requested_cwd,
1346
+ )
1347
+ msg = (
1348
+ f"Session '{session_short_id(session.session_id)}' belongs to cwd '{session.cwd}', "
1349
+ f"not requested cwd '{requested_cwd}'."
1350
+ )
1351
+ raise AcpSessionError(msg)
1352
+
1353
+ def _session_loaded_settings(self, cwd: str) -> LoadedSettings:
1354
+ """Derive one session's settings under its own project trust domain.
1355
+
1356
+ The manager's base settings are deliberately project-free — a
1357
+ manager-level project layer would leak one session's trust decisions
1358
+ into every other — so each session re-loads with its resolved cwd as
1359
+ the project root. Everything process-wide still flows from the base:
1360
+ RESTART values are held at what this process is running on, the
1361
+ command line keeps the credit for what it decided, and the launch-time
1362
+ ``--ask-user-timeout`` is carried across as the CLI layer it is, since
1363
+ a re-read of the environment cannot produce it. Under the global
1364
+ settings lock so the base read cannot straddle a concurrent write's
1365
+ document/publish gap.
1366
+ """
1367
+ with self._global_settings_lock:
1368
+ base = self._loaded_settings
1369
+ candidate = load_settings(
1370
+ project_root=Path(cwd),
1371
+ eval_context=EvalContext(
1372
+ frontend_default_max_transient_retries=base.settings.frontend_default_max_transient_retries
1373
+ ),
1374
+ )
1375
+ loaded, _deferred = route_restart_settings(reattribute_command_line(candidate, base), base)
1376
+ return loaded.overlay(Source.CLI, ask_user_timeout_seconds=base.settings.ask_user_timeout_seconds)
1377
+
1378
+ def _refresh_default_settings_from_env(self) -> None:
1379
+ # Read, route and publish as one step. Every writer refreshes after its
1380
+ # own write, so serializing this makes the last refresh to publish also
1381
+ # the last to read — without the lock, two clients configuring two
1382
+ # sessions at once can have the slower one publish a snapshot taken
1383
+ # before the faster one's write, and every session created afterwards
1384
+ # runs on settings that are on disk but not in this manager. Taken here
1385
+ # rather than only at the write sites because a reload refreshes too.
1386
+ with self._global_settings_lock:
1387
+ # The launch-time ``--ask-user-timeout`` is a CLI value, not something a
1388
+ # re-read of the environment can produce, so it is carried across as the
1389
+ # layer it came from rather than folded into the new load.
1390
+ timeout = self._loaded_settings.settings.ask_user_timeout_seconds
1391
+ # RESTART routing applies to future sessions too: one process, one
1392
+ # value, so a host built after this refresh must not act on a value
1393
+ # the already-running hosts cannot. The deferred keys need no report
1394
+ # here — the session that triggered the refresh had its own reload,
1395
+ # which already said so on its bus.
1396
+ loaded, _deferred = route_restart_settings(load_settings(), self._loaded_settings)
1397
+ self._loaded_settings = loaded.overlay(Source.CLI, ask_user_timeout_seconds=timeout)
1398
+
1399
+
1400
+ def _mcp_overlay(
1401
+ servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
1402
+ ) -> list[MCPServerConfig]:
1403
+ configs: list[MCPServerConfig] = []
1404
+ for server in servers or []:
1405
+ if isinstance(server, acp_schema.HttpMcpServer):
1406
+ configs.append(
1407
+ MCPServerConfig(
1408
+ name=server.name,
1409
+ transport="http",
1410
+ url=server.url,
1411
+ headers={header.name: header.value for header in server.headers},
1412
+ resolve_header_templates=False,
1413
+ )
1414
+ )
1415
+ continue
1416
+ if isinstance(server, acp_schema.SseMcpServer):
1417
+ msg = f"SSE MCP servers are not supported by {APP_DISPLAY_NAME} ACP phase 1."
1418
+ raise AcpSessionError(msg)
1419
+ if isinstance(server, acp_schema.McpServerStdio):
1420
+ msg = f"Client-supplied stdio MCP servers are not supported by {APP_DISPLAY_NAME} ACP phase 1."
1421
+ raise AcpSessionError(msg)
1422
+ msg = f"Unsupported MCP server type: {type(server).__name__}"
1423
+ raise AcpSessionError(msg)
1424
+ return configs
1425
+
1426
+
1427
+ async def _shutdown_after_failed_start(
1428
+ host: ChrysSessionHost, title_updater: SessionTitleUpdater | None = None
1429
+ ) -> None:
1430
+ try:
1431
+ await host.shutdown()
1432
+ except Exception:
1433
+ logger.exception("Error shutting down ACP session host after failed startup")
1434
+ if title_updater is not None:
1435
+ with contextlib.suppress(Exception):
1436
+ await title_updater.shutdown()
1437
+
1438
+
1439
+ def _resolve_dir(path: str) -> str:
1440
+ raw = (path or "").strip()
1441
+ if not raw:
1442
+ msg = "cwd is required."
1443
+ raise AcpSessionError(msg)
1444
+ expanded = Path(raw).expanduser()
1445
+ if not expanded.is_absolute():
1446
+ # ACP requires absolute paths. Resolving a relative path against the ACP
1447
+ # server's process cwd would silently bind the session to the wrong
1448
+ # workspace, so reject it instead of guessing.
1449
+ msg = f"path must be absolute: {raw}"
1450
+ raise AcpSessionError(msg)
1451
+ try:
1452
+ resolved = expanded.resolve(strict=True)
1453
+ except FileNotFoundError, NotADirectoryError:
1454
+ msg = f"cwd does not exist: {surrogate_safe_text(raw)}"
1455
+ raise AcpSessionError(msg) from None
1456
+ except OSError:
1457
+ msg = f"cwd is not accessible: {surrogate_safe_text(raw)}"
1458
+ raise AcpSessionError(msg) from None
1459
+ if not resolved.is_dir():
1460
+ msg = f"cwd is not a directory: {resolved}"
1461
+ raise AcpSessionError(msg)
1462
+ return os.fspath(resolved)
1463
+
1464
+
1465
+ def _same_dir(left: str, right: str) -> bool:
1466
+ if not left or not right:
1467
+ return False
1468
+ try:
1469
+ return Path(left).expanduser().resolve(strict=True) == Path(right).expanduser().resolve(strict=True)
1470
+ except OSError:
1471
+ return False
1472
+
1473
+
1474
+ def _workspace_from_dirs(primary_cwd: str, additional_directories: list[str] | None) -> Workspace:
1475
+ dirs = [WorkingDir(path=primary_cwd, is_primary=True)]
1476
+ for path in additional_directories or []:
1477
+ resolved = _resolve_dir(path)
1478
+ if not _same_dir(resolved, primary_cwd):
1479
+ dirs.append(WorkingDir(path=resolved))
1480
+ return Workspace(primary_cwd=primary_cwd, working_dirs=dirs)
1481
+
1482
+
1483
+ def _agent_profile_from_data(data: dict[str, object]) -> AgentProfile:
1484
+ with tempfile.TemporaryDirectory(prefix="chrys-agent-profile-") as temp_dir:
1485
+ stem = _safe_profile_file_stem(data.get("name", "profile"), field_name="agent profile name")
1486
+ path = Path(temp_dir) / f"{stem}.yaml"
1487
+ path.write_text(dump_yaml(data), encoding="utf-8")
1488
+ return load_agent_profile_from_yaml(path)
1489
+
1490
+
1491
+ def _model_profile_from_data(data: dict[str, object]) -> ModelProfile:
1492
+ with tempfile.TemporaryDirectory(prefix="chrys-model-profile-") as temp_dir:
1493
+ stem = _safe_profile_file_stem(data.get("id", "model"), field_name="model profile id")
1494
+ path = Path(temp_dir) / f"{stem}.yaml"
1495
+ path.write_text(dump_yaml(data), encoding="utf-8")
1496
+ return load_model_profile_from_yaml(path)
1497
+
1498
+
1499
+ def _mcp_config_from_data(data: dict[str, object]) -> MCPServerConfig:
1500
+ payload = dict(data)
1501
+ if "name" not in payload:
1502
+ msg = "MCP server name is required."
1503
+ raise AcpSessionError(msg)
1504
+ transport = payload.get("transport")
1505
+ if transport == "stdio":
1506
+ msg = f"Client-supplied stdio MCP servers are not supported by {APP_DISPLAY_NAME} ACP."
1507
+ raise AcpSessionError(msg)
1508
+ if transport != "http":
1509
+ msg = "mcp/test supports HTTP MCP servers only."
1510
+ raise AcpSessionError(msg)
1511
+ # ACP client-supplied headers must stay literal; profile-owned YAML is the
1512
+ # only MCP path that resolves {{ENV_VAR}} templates.
1513
+ payload["resolve_header_templates"] = False
1514
+ agent = _agent_profile_from_data(
1515
+ {
1516
+ "name": "MCP Test",
1517
+ "id": "mcp-test",
1518
+ "tools": {"mcp": [payload]},
1519
+ }
1520
+ )
1521
+ if not agent.tools.mcp:
1522
+ msg = "MCP server config is invalid."
1523
+ raise AcpSessionError(msg)
1524
+ return agent.tools.mcp[0]
1525
+
1526
+
1527
+ def _config_setting_fields() -> dict[str, str]:
1528
+ """Map each supported setting key to its ``Settings`` attribute name."""
1529
+ from chrys.foundation.config.settings import Settings
1530
+ from chrys.foundation.config.spec import field_names_by_key
1531
+
1532
+ names = field_names_by_key(Settings)
1533
+ return {option.setting_key: names[option.setting_key] for option in _SUPPORTED_CONFIG_OPTIONS}
1534
+
1535
+
1536
+ def _config_display_value(stored: object) -> str:
1537
+ """Render a stored value the way the wire protocol always has: as text."""
1538
+ if stored is None or stored == "":
1539
+ return ""
1540
+ if stored is True or stored is False:
1541
+ return "1" if stored else "0"
1542
+ return str(stored)
1543
+
1544
+
1545
+ def _config_option(key: str) -> AcpConfigOption:
1546
+ normalized = key.strip()
1547
+ for option in _SUPPORTED_CONFIG_OPTIONS:
1548
+ if normalized in (option.logical_key, option.env_alias):
1549
+ return option
1550
+ msg = f"Unsupported config option: {key}"
1551
+ raise AcpSessionError(msg)
1552
+
1553
+
1554
+ def _decode_cursor(cursor: str | None) -> int:
1555
+ if not cursor:
1556
+ return 0
1557
+ try:
1558
+ return max(0, int(cursor))
1559
+ except ValueError as exc:
1560
+ msg = f"Invalid cursor: {cursor}"
1561
+ raise AcpSessionError(msg) from exc
1562
+
1563
+
1564
+ def _session_extra_dirs(meta: ChatSessionMeta) -> list[str]:
1565
+ # meta.working_dirs stores the full workspace list, which includes the primary
1566
+ # cwd for ACP multi-dir sessions. ``cwd`` already carries it, so the additional
1567
+ # roots are everything except the primary.
1568
+ return [d for d in meta.working_dirs if not _same_dir(d, meta.primary_cwd)]
1569
+
1570
+
1571
+ def _extra_dirs_match(requested: list[str], meta: ChatSessionMeta) -> bool:
1572
+ """Exact, ordered match of a session's additional roots against a request."""
1573
+ saved = _session_extra_dirs(meta)
1574
+ if len(requested) != len(saved):
1575
+ return False
1576
+ return all(_same_dir(req, have) for req, have in zip(requested, saved, strict=True))
1577
+
1578
+
1579
+ def _session_info(meta: ChatSessionMeta) -> acp_schema.SessionInfo:
1580
+ extra_dirs = _session_extra_dirs(meta)
1581
+ return acp_schema.SessionInfo(
1582
+ sessionId=meta.session_id,
1583
+ cwd=meta.primary_cwd,
1584
+ title=meta.display_title or None,
1585
+ updatedAt=meta.updated_at.isoformat(),
1586
+ additionalDirectories=extra_dirs or None,
1587
+ field_meta={
1588
+ "agent_profile": meta.agent_profile,
1589
+ "agent_display_name": meta.agent_display_name,
1590
+ "message_count": meta.message_count,
1591
+ "model_provider": meta.model_provider,
1592
+ "model_api_style": meta.model_api_style,
1593
+ "model_id": meta.model_id,
1594
+ },
1595
+ )
1596
+
1597
+
1598
+ def _agent_profile_info(profile: AgentProfile, *, builtin: bool) -> dict[str, object]:
1599
+ return {
1600
+ "id": profile.id,
1601
+ "name": profile.name,
1602
+ "displayName": profile.display_name,
1603
+ "description": profile.description,
1604
+ "subAgentOnly": profile.sub_agent_only,
1605
+ "builtin": builtin,
1606
+ }
1607
+
1608
+
1609
+ def _agent_profile_reset_comparison_dict(profile: AgentProfile) -> dict[str, Any]:
1610
+ """Serialize after normalizing the order-insensitive Skills extension list."""
1611
+ normalized = deepcopy(profile)
1612
+ normalized.skills.script_extensions = sorted(normalized.skills.script_extensions)
1613
+ return agent_profile_to_dict(normalized)
1614
+
1615
+
1616
+ _MASK = "***"
1617
+
1618
+
1619
+ def _is_string_object_dict(value: object) -> TypeGuard[dict[str, object]]:
1620
+ """Narrow ACP JSON objects, whose keys are necessarily strings."""
1621
+ return isinstance(value, dict) and all(isinstance(key, str) for key in value)
1622
+
1623
+
1624
+ def _iter_profile_mcp_servers(data: dict[str, object]) -> list[dict[str, object]]:
1625
+ raw_tools = data.get("tools")
1626
+ if not _is_string_object_dict(raw_tools):
1627
+ return []
1628
+ tools = raw_tools
1629
+ servers = tools.get("mcp")
1630
+ if not isinstance(servers, list):
1631
+ return []
1632
+ return [server for server in servers if _is_string_object_dict(server)]
1633
+
1634
+
1635
+ def _redact_agent_profile_secrets(data: dict[str, object]) -> dict[str, object]:
1636
+ """Mask MCP and ACP launch secrets, mirroring model-profile api_key masking."""
1637
+ for server in _iter_profile_mcp_servers(data):
1638
+ for field_name in ("headers", "env"):
1639
+ mapping = server.get(field_name)
1640
+ if isinstance(mapping, dict):
1641
+ server[field_name] = {key: (_MASK if value else value) for key, value in mapping.items()}
1642
+ raw_acp = data.get("acp")
1643
+ if _is_string_object_dict(raw_acp):
1644
+ acp = raw_acp
1645
+ env = acp.get("env")
1646
+ if isinstance(env, dict):
1647
+ acp["env"] = dict.fromkeys(env, _MASK)
1648
+ args = acp.get("args")
1649
+ if isinstance(args, list):
1650
+ acp["args"] = [_MASK for _value in args]
1651
+ options = acp.get("config_options")
1652
+ if isinstance(options, dict):
1653
+ acp["config_options"] = {
1654
+ key: (_MASK if isinstance(value, str) else value) for key, value in options.items()
1655
+ }
1656
+ return data
1657
+
1658
+
1659
+ def _restore_masked_mcp_secrets(data: dict[str, object], existing: AgentProfile) -> None:
1660
+ """Replace masked ("***") MCP header/env values with the stored originals."""
1661
+ prior_by_name = {server.name: server for server in existing.tools.mcp}
1662
+ for server in _iter_profile_mcp_servers(data):
1663
+ prior = prior_by_name.get(server.get("name"))
1664
+ if prior is None:
1665
+ continue
1666
+ _restore_masked_values(server.get("headers"), prior.headers)
1667
+ _restore_masked_values(server.get("env"), prior.env)
1668
+
1669
+
1670
+ def _restore_masked_values(incoming: object, prior: dict[str, str]) -> None:
1671
+ if not _is_string_object_dict(incoming):
1672
+ return
1673
+ incoming_values = incoming
1674
+ for key, value in incoming_values.items():
1675
+ if value == _MASK and key in prior:
1676
+ incoming_values[key] = prior[key]
1677
+
1678
+
1679
+ @dataclass(frozen=True)
1680
+ class _AcpMaskRestoration:
1681
+ """Which masked ACP fields were restored from the stored profile.
1682
+
1683
+ The unresolved-mask rejection must skip restored keys: a restored value can
1684
+ legitimately EQUAL the mask when the stored secret is the literal ``***``.
1685
+ """
1686
+
1687
+ args_restored: bool = False
1688
+ args_were_masked: bool = False
1689
+ """True when the stored profile has a non-empty args list, i.e. the read
1690
+ that produced this write masked every element — any leftover mask in an
1691
+ edited list is then a placeholder, never literal user input."""
1692
+ env_keys: frozenset[str] = frozenset()
1693
+ option_keys: frozenset[str] = frozenset()
1694
+
1695
+
1696
+ def _restore_masked_acp_secrets(data: dict[str, object], existing: AgentProfile) -> _AcpMaskRestoration:
1697
+ """Restore masks only where ACP fields retain an unambiguous identity."""
1698
+ raw_incoming = data.get("acp")
1699
+ prior = existing.acp
1700
+ if not _is_string_object_dict(raw_incoming) or prior is None:
1701
+ return _AcpMaskRestoration()
1702
+ incoming = raw_incoming
1703
+ env_keys: set[str] = set()
1704
+ raw_env = incoming.get("env")
1705
+ if _is_string_object_dict(raw_env):
1706
+ env = raw_env
1707
+ for key, value in env.items():
1708
+ if value == _MASK and key in prior.env:
1709
+ env[key] = prior.env[key]
1710
+ env_keys.add(key)
1711
+ option_keys: set[str] = set()
1712
+ raw_options = incoming.get("config_options")
1713
+ if _is_string_object_dict(raw_options):
1714
+ options = raw_options
1715
+ for key, value in options.items():
1716
+ prior_value = prior.config_options.get(key)
1717
+ if value == _MASK and isinstance(prior_value, str):
1718
+ options[key] = prior_value
1719
+ option_keys.add(key)
1720
+ args = incoming.get("args")
1721
+ args_restored = False
1722
+ if isinstance(args, list) and len(args) == len(prior.args) and all(value == _MASK for value in args):
1723
+ incoming["args"] = list(prior.args)
1724
+ args_restored = True
1725
+ return _AcpMaskRestoration(args_restored, bool(prior.args), frozenset(env_keys), frozenset(option_keys))
1726
+
1727
+
1728
+ def _reject_unresolved_masked_mcp_secrets(data: dict[str, object]) -> None:
1729
+ for server in _iter_profile_mcp_servers(data):
1730
+ name = server.get("name") or "<unnamed>"
1731
+ for field_name in ("headers", "env"):
1732
+ mapping = server.get(field_name)
1733
+ if not isinstance(mapping, dict):
1734
+ continue
1735
+ for key, value in mapping.items():
1736
+ if value == _MASK:
1737
+ msg = (
1738
+ f"MCP server {name!r} {field_name}.{key} is still masked; "
1739
+ "re-enter the secret after renaming or adding the server."
1740
+ )
1741
+ raise AcpSessionError(msg)
1742
+
1743
+
1744
+ def _reject_unresolved_masked_acp_secrets(
1745
+ data: dict[str, object],
1746
+ *,
1747
+ restoration: _AcpMaskRestoration | None = None,
1748
+ ) -> None:
1749
+ restoration = restoration or _AcpMaskRestoration()
1750
+ acp = data.get("acp")
1751
+ if not isinstance(acp, dict):
1752
+ return
1753
+ args = acp.get("args")
1754
+ if isinstance(args, list) and not restoration.args_restored:
1755
+ msg = (
1756
+ "ACP args still contain masked values; after editing the positional list, "
1757
+ "resubmit the complete unmasked argument list."
1758
+ )
1759
+ # After a masked read of a non-empty stored list, positional identity
1760
+ # is lost the moment the list is edited: ANY leftover mask (e.g.
1761
+ # ["***", "***", "--verbose"]) is a placeholder whose acceptance would
1762
+ # silently persist the literal mask over the stored secrets.
1763
+ if restoration.args_were_masked and any(value == _MASK for value in args):
1764
+ raise AcpSessionError(msg)
1765
+ # Without a masked read (new profile, rename, empty stored list) a
1766
+ # mask can only be literal user input; reject only the fully-masked
1767
+ # placeholder shape.
1768
+ if args and all(value == _MASK for value in args):
1769
+ raise AcpSessionError(msg)
1770
+ for field_name, restored_keys in (("env", restoration.env_keys), ("config_options", restoration.option_keys)):
1771
+ mapping = acp.get(field_name)
1772
+ if not isinstance(mapping, dict):
1773
+ continue
1774
+ for key, value in mapping.items():
1775
+ if value == _MASK and key not in restored_keys:
1776
+ msg = f"ACP {field_name}.{key} is still masked; re-enter the secret after renaming or adding it."
1777
+ raise AcpSessionError(msg)
1778
+
1779
+
1780
+ def _safe_profile_file_stem(value: object, *, field_name: str) -> str:
1781
+ if not isinstance(value, str):
1782
+ msg = f"{field_name} must be a string."
1783
+ raise AcpSessionError(msg)
1784
+ stem = value.strip()
1785
+ if not stem:
1786
+ msg = f"{field_name} is required."
1787
+ raise AcpSessionError(msg)
1788
+ if not is_filename_safe_profile_name(stem):
1789
+ msg = f"{field_name} must be a filename-safe value, not a path."
1790
+ raise AcpSessionError(msg)
1791
+ return stem
1792
+
1793
+
1794
+ def _model_profile_info(profile: ModelProfile) -> dict[str, object]:
1795
+ return {
1796
+ "id": profile.id,
1797
+ "name": profile.name,
1798
+ "provider": profile.provider,
1799
+ "apiStyle": profile.api_style,
1800
+ "modelId": profile.model_id,
1801
+ "maxContextTokens": profile.max_context_tokens,
1802
+ "stream": profile.stream,
1803
+ "vision": profile.vision,
1804
+ }
1805
+
1806
+
1807
+ def jsonable_dataclass(value: object) -> dict[Any, Any]:
1808
+ if is_dataclass(value):
1809
+ if isinstance(value, type):
1810
+ # Match dataclasses.asdict's existing class-vs-instance failure.
1811
+ raise TypeError("asdict() should be called on dataclass instances")
1812
+ return asdict(value)
1813
+ if isinstance(value, dict):
1814
+ return value
1815
+ return {}