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,2013 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Session startup, restore, reset, fork, deletion, and shutdown orchestration."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import copy
10
+ import logging
11
+ import os
12
+ import shutil
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass, replace
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, cast
18
+
19
+ from chrys.foundation.branding import APP_DISPLAY_NAME
20
+ from chrys.foundation.config.context import EvalContext
21
+ from chrys.foundation.config.process_settings import reattribute_command_line, route_restart_settings
22
+ from chrys.foundation.config.runtime_pointer import PointerToken, restore_model_pointer, set_model_pointer
23
+ from chrys.foundation.config.settings_store import load_settings
24
+ from chrys.foundation.config.spec import SettingOrigin, Source
25
+ from chrys.foundation.config.warnings import settings_warning_events
26
+ from chrys.foundation.events.types import (
27
+ Error,
28
+ SessionClear,
29
+ SessionDelete,
30
+ SessionDeleted,
31
+ SessionFork,
32
+ SessionForked,
33
+ SessionNew,
34
+ SessionRestore,
35
+ SessionRestored,
36
+ Warning,
37
+ )
38
+ from chrys.foundation.i18n import DisplayBlock, DisplayPath, DisplaySequence, MessageRef, msg
39
+ from chrys.foundation.i18n.formatting import format_message
40
+ from chrys.foundation.models.history_markers import SUB_AGENT_STATE_DISCARDED_MESSAGE, HistoryMarkerKind
41
+ from chrys.foundation.models.workspace import WorkingDir, Workspace
42
+ from chrys.foundation.platform import safe_getcwd
43
+ from chrys.foundation.platform.files import surrogate_safe_text
44
+ from chrys.foundation.trajectory.event_types import RuntimeFinishReason as TrajectoryRuntimeFinishReason
45
+ from chrys.foundation.util.lock import FileLock
46
+ from chrys.foundation.util.session_ids import session_short_id
47
+ from chrys.orchestration.engine.state import lifecycle_permits
48
+ from chrys.orchestration.engine.state.machine import Trigger
49
+ from chrys.orchestration.invoker.contracts import AbortCause
50
+ from chrys.orchestration.invoker.runtime import restore_phase4_state
51
+ from chrys.service.context.compaction.spill import SpillReconciliationResult, reconcile_spill_storage
52
+ from chrys.service.mutations.store import SnapshotPolicy, SnapshotStore
53
+ from chrys.service.mutations.tracker import MutationTracker
54
+ from chrys.service.mutations.workspace_changes import WorkspaceChangeTracker
55
+ from chrys.service.profiles.models.resolver import loaded_with_active_model_profile
56
+ from chrys.service.profiles.models.schema import API_STYLE_RESPONSES, is_model_profile_selectable
57
+ from chrys.service.session.history import stamp_history_item_ids
58
+ from chrys.service.session.persistence import has_real_messages
59
+ from chrys.service.session.runtime_metadata import SessionRuntimeMetadata
60
+ from chrys.service.session.sub_agent_logs import SubAgentSessionArtifactService
61
+ from chrys.service.state.store import (
62
+ SESSION_BACKUP_FILE_NAME,
63
+ SESSION_FILE_NAME,
64
+ SESSION_RECOVERY_FILE_NAME,
65
+ SESSION_WRITE_LOCK_TIMEOUT_SECONDS,
66
+ ChatSessionMeta,
67
+ SessionForkError,
68
+ SessionNotFoundError,
69
+ parse_snapshot_turn,
70
+ )
71
+ from chrys.service.todos.tracker import TodoTracker
72
+ from chrys.service.trajectory.preparation import PreparationOutcome
73
+
74
+ if TYPE_CHECKING:
75
+ from chrys.foundation.config.settings_store import LoadedSettings, SettingsHandle
76
+ from chrys.foundation.events.bus import EventBus
77
+ from chrys.orchestration.engine.loader import AgentLoader
78
+ from chrys.orchestration.engine.run.turn_state import TurnRuntimeState
79
+ from chrys.orchestration.engine.state.active_session import ActiveSession
80
+ from chrys.orchestration.engine.state.current_agent import CurrentAgent
81
+ from chrys.orchestration.engine.state.lifecycle_permits import LifecyclePermits
82
+ from chrys.orchestration.engine.state.machine import EngineStateMachine
83
+ from chrys.orchestration.engine.state.session_writer import SessionWriter
84
+ from chrys.orchestration.engine.trajectory import TrajectoryRecorder
85
+ from chrys.orchestration.engine.usage import UsagePublisher
86
+ from chrys.orchestration.invoker.kernel import KernelConversation
87
+ from chrys.service.profiles.agents.registry import AgentProfileRegistry
88
+ from chrys.service.profiles.agents.schema import AgentProfile
89
+ from chrys.service.profiles.models.registry import ModelProfileRegistry
90
+ from chrys.service.session.history import SessionHistoryManager
91
+ from chrys.service.session.persistence import SessionPersistence
92
+
93
+
94
+ logger = logging.getLogger(__name__)
95
+ _SHUTDOWN_POST_RUN_TIMEOUT_SECONDS = 2.0
96
+ _SESSION_FORK_AGENT_LOAD_TIMEOUT_SECONDS = 5.0
97
+
98
+ _FORK_AGENT_LOADING = msg(
99
+ "engine.fork_agent_loading",
100
+ fallback="Cannot fork while the agent is still loading.",
101
+ )
102
+ _FORK_NOT_READY = msg(
103
+ "engine.fork_not_ready",
104
+ fallback="Cannot fork before a session is ready.",
105
+ )
106
+ _FORK_SESSION_CHANGED = msg(
107
+ "engine.fork_session_changed",
108
+ fallback="Cannot fork because the active session changed.",
109
+ )
110
+ _FORK_TURN_ACTIVE = msg(
111
+ "engine.fork_turn_active",
112
+ fallback="Cannot fork while a turn is running.",
113
+ )
114
+ _FORK_STATE_STORE_MISSING = msg(
115
+ "engine.fork_state_store_missing",
116
+ fallback="State store not configured.",
117
+ )
118
+ _FORK_RESTORING = msg(
119
+ "engine.fork_restoring",
120
+ fallback="Cannot fork while session state is being restored.",
121
+ )
122
+ _FORK_LOCK_NOT_OWNED = msg(
123
+ "engine.fork_lock_not_owned",
124
+ fallback="Cannot fork because this window does not own the active session lock.",
125
+ )
126
+ _FORK_EMPTY_SESSION = msg(
127
+ "engine.fork_empty_session",
128
+ fallback="Cannot fork an empty session.",
129
+ )
130
+ _FORK_PREPARE_TIMEOUT = msg(
131
+ "engine.fork_prepare_timeout",
132
+ fallback="Timed out preparing session for fork: {detail}",
133
+ multiline=True,
134
+ )
135
+ _FORK_PREPARE_FAILED = msg(
136
+ "engine.fork_prepare_failed",
137
+ fallback="Failed to prepare session for fork: {detail}",
138
+ multiline=True,
139
+ )
140
+ _FORK_SESSION_NOT_FOUND = msg(
141
+ "engine.fork_session_not_found",
142
+ fallback="Session '{session_id}' not found.",
143
+ )
144
+ _FORK_FAILED = msg(
145
+ "engine.fork_failed",
146
+ fallback="Failed to fork session: {detail}",
147
+ multiline=True,
148
+ )
149
+
150
+
151
+ _CONSTRUCTION_TRAJECTORY_ACTIVATION_FAILED = msg(
152
+ "construction.trajectory_activation_failed",
153
+ fallback="Trajectory recording could not start and has been disabled for this session.",
154
+ )
155
+
156
+
157
+ _RESTORE_SERVICE_SESSION_INCOMPATIBLE = msg(
158
+ "restore.service_session_incompatible",
159
+ fallback=(
160
+ "This session was saved with an OpenAI Responses service session. "
161
+ "The active agent/model profile, workspace, service endpoint, or storage mode "
162
+ "is not compatible, so {app} will continue from local history only."
163
+ ),
164
+ )
165
+ _RESTORE_SUB_AGENTS_DISCARDED = msg(
166
+ "restore.sub_agents_discarded",
167
+ fallback="{discarded} paused sub-agent(s) from a previous session were discarded: {names}",
168
+ )
169
+ _RESTORE_AGENT_PROFILE_UNRESOLVED_USING_CURRENT = msg(
170
+ "restore.agent_profile_unresolved_using_current",
171
+ fallback=(
172
+ "The saved agent profile {saved} could not be uniquely resolved. Continuing with the current agent {current}."
173
+ ),
174
+ )
175
+ _RESTORE_AGENT_PROFILE_UNRESOLVED = msg(
176
+ "restore.agent_profile_unresolved",
177
+ fallback="The saved agent profile {saved} could not be uniquely resolved. Session restore was stopped.",
178
+ )
179
+ _RESTORE_SESSION_CWD_MISSING = msg(
180
+ "restore.session_cwd_missing",
181
+ fallback="The working directory of this session no longer exists: {path}",
182
+ )
183
+ _RESTORE_REQUESTED_AGENT_PROFILE_UNRESOLVED = msg(
184
+ "restore.requested_agent_profile_unresolved",
185
+ fallback="The requested agent profile {profile} could not be found. Session restore was stopped.",
186
+ )
187
+
188
+
189
+ def _working_dirs_from_paths(paths: list[str], primary_cwd: str) -> list[WorkingDir]:
190
+ return [WorkingDir(path=path, is_primary=path == primary_cwd) for path in paths]
191
+
192
+
193
+ def _restore_profile_override_switch(
194
+ state: dict | None,
195
+ *,
196
+ from_profile: str,
197
+ from_display: str,
198
+ to_profile: str,
199
+ to_display: str,
200
+ ) -> bool:
201
+ """Record an explicit restore-time profile override in session state."""
202
+ if state is None or not from_profile or from_profile == to_profile:
203
+ return False
204
+ switches = state.setdefault("agent_profile_switches", [])
205
+ switches.append(
206
+ {
207
+ "from": from_profile,
208
+ "to": to_profile,
209
+ "from_display": from_display or from_profile,
210
+ "to_display": to_display or to_profile,
211
+ "at_message_index": len(state.get("messages", [])),
212
+ "timestamp": datetime.now(UTC).isoformat(),
213
+ },
214
+ )
215
+ return True
216
+
217
+
218
+ def _extra_roots(paths: list[str], primary_cwd: str) -> list[str]:
219
+ """Working-dir paths with the primary cwd removed (effective extra roots).
220
+
221
+ Sessions persist ``working_dirs`` inconsistently: a no-extra session stores
222
+ ``[]`` while a multi-root session stores ``[primary, *extras]``. Restore also
223
+ always re-inserts the primary for explicit ``additionalDirectories``. Service-
224
+ session identity only cares about the *effective* extra roots, so strip the
225
+ primary from both sides before comparing — otherwise loading a no-extra session
226
+ with ``additionalDirectories: []`` would discard a still-valid service session.
227
+ """
228
+ return [path for path in paths if path != primary_cwd]
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class _ModelRestoreRollbackToken:
233
+ """State needed to undo a model reapply before the build's commit installs it."""
234
+
235
+ conversation: KernelConversation | None
236
+ pointer: PointerToken
237
+
238
+
239
+ def _rollback_reapplied_model_profile(token: _ModelRestoreRollbackToken) -> None:
240
+ """Undo the eager pointer write after a pre-commit start failure.
241
+
242
+ The settings half needs no undo: the reapplied selection lived only on the
243
+ staged load, and a build that failed before its commit never installed it.
244
+ Value and origin go back together — a bare env write would leave the
245
+ registry saying SESSION about a pointer the rollback just took away.
246
+ A refusal is the registry reporting that someone chose a profile while
247
+ this restore was failing; that choice is newer than the state this token
248
+ describes, so it stands.
249
+ """
250
+ if not restore_model_pointer(token.pointer):
251
+ logger.debug("Model pointer moved during a failed restore; leaving the newer selection in place")
252
+
253
+
254
+ def _reset_restore_history_state(history_state: dict) -> dict:
255
+ """Deep copy of the live history captured before a reset deletes session files.
256
+
257
+ A failed reset restarts the engine from this copy, so it must own every
258
+ layer (messages, contents lists, content objects) — aliasing the live
259
+ objects of the session being shut down would let the restarted session
260
+ share identities with a torn-down state.
261
+ """
262
+ return copy.deepcopy(history_state)
263
+
264
+
265
+ def cleanup_empty_session_dir_path(session_dir: Path | None, *, path_reused: bool = False) -> None:
266
+ """Remove *session_dir* when it has no restorable session files.
267
+
268
+ ``path_reused`` is for a caller that restarts on this very directory (a
269
+ reset keeps the session id).
270
+ """
271
+ if session_dir is None or not session_dir.is_dir():
272
+ return
273
+ if (session_dir / SESSION_FILE_NAME).exists():
274
+ return
275
+ if (session_dir / SESSION_BACKUP_FILE_NAME).exists():
276
+ return
277
+ if (session_dir / SESSION_RECOVERY_FILE_NAME).exists():
278
+ return
279
+ if _has_restorable_rollback_snapshots(session_dir):
280
+ return
281
+ if _has_committed_sub_agent_artifacts(session_dir):
282
+ return
283
+ if _has_recorded_trajectory(session_dir):
284
+ return
285
+ from chrys.service.trajectory.tombstone import delete_session_directory
286
+
287
+ try:
288
+ # Same disposal as an explicit delete: a trajectory writer that is
289
+ # still alive (a stuck worker, a writer in another process) turns this
290
+ # into a logical delete instead of a half-removed folder.
291
+ delete_session_directory(session_dir, sessions_root=session_dir.parent, path_reused=path_reused)
292
+ except OSError:
293
+ logger.debug("Failed to remove empty session directory %s", session_dir, exc_info=True)
294
+
295
+
296
+ def _delete_reset_session_files(session_dir: Path) -> None:
297
+ for filename in (SESSION_FILE_NAME, SESSION_BACKUP_FILE_NAME, SESSION_RECOVERY_FILE_NAME):
298
+ path = session_dir / filename
299
+ if path.exists():
300
+ path.unlink()
301
+
302
+
303
+ def _acquire_session_write_lock(lock_path: Path, session_id: str) -> FileLock | None:
304
+ lock = FileLock(lock_path, timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS)
305
+ try:
306
+ lock.acquire()
307
+ return lock
308
+ except TimeoutError:
309
+ logger.warning("Timed out acquiring session lock for reset %s", session_id, exc_info=True)
310
+ return None
311
+ except OSError:
312
+ logger.warning("Failed to acquire session lock for reset %s", session_id, exc_info=True)
313
+ return None
314
+
315
+
316
+ def _has_restorable_rollback_snapshots(session_dir: Path) -> bool:
317
+ snap_dir = session_dir / "snapshots"
318
+ if not snap_dir.is_dir():
319
+ return False
320
+ return any(parse_snapshot_turn(path) >= 1 for path in snap_dir.glob("*.json"))
321
+
322
+
323
+ def _has_recorded_trajectory(session_dir: Path) -> bool:
324
+ """Whether this directory holds a log that outlives the conversation in it.
325
+
326
+ A rollback only ever appends to the log — including the reset to welcome,
327
+ which discards the session's messages but restarts on this very directory
328
+ and reads its branch from the last line written. Only an explicit clear or
329
+ delete takes a session's trajectory with it, and neither comes through
330
+ here.
331
+ """
332
+ from chrys.service.trajectory.session import trajectory_events_path
333
+
334
+ events = trajectory_events_path(session_dir)
335
+ try:
336
+ return events.stat().st_size > 0
337
+ except FileNotFoundError:
338
+ return False
339
+ except OSError:
340
+ # This is the guard that keeps an audit log from being deleted, so a
341
+ # probe that cannot answer keeps the directory: "I could not look" is
342
+ # not "there is nothing there".
343
+ return True
344
+
345
+
346
+ def _has_committed_sub_agent_artifacts(session_dir: Path) -> bool:
347
+ sub_agents = session_dir / "sub_agents"
348
+ if not sub_agents.is_dir():
349
+ return False
350
+ for path in sub_agents.rglob("*.json"):
351
+ if path.name.endswith(".tmp"):
352
+ continue
353
+ if path.is_file():
354
+ return True
355
+ return False
356
+
357
+
358
+ @dataclass(frozen=True, slots=True)
359
+ class _SessionDeleteFailure:
360
+ """Why a session delete did not happen; the current session is intact."""
361
+
362
+ code: str
363
+ message: str
364
+
365
+
366
+ class SessionLifecycle:
367
+ """Orchestrates session startup, reload, restore, reset, fork, deletion, and shutdown."""
368
+
369
+ def __init__(
370
+ self,
371
+ *,
372
+ session: ActiveSession,
373
+ current: CurrentAgent,
374
+ loader: AgentLoader,
375
+ permits: LifecyclePermits,
376
+ writer: SessionWriter,
377
+ turn_state: TurnRuntimeState,
378
+ usage_publisher: UsagePublisher,
379
+ bus: EventBus,
380
+ fsm: EngineStateMachine,
381
+ history: SessionHistoryManager,
382
+ persistence: SessionPersistence,
383
+ settings_handle: SettingsHandle,
384
+ agent_registry: AgentProfileRegistry | None,
385
+ model_registry: ModelProfileRegistry | None,
386
+ trajectory_recorder: TrajectoryRecorder,
387
+ workspace_change_tracker: WorkspaceChangeTracker,
388
+ unregister_current_engine: Callable[[], None],
389
+ ) -> None:
390
+ self._session = session
391
+ self._current = current
392
+ self._loader = loader
393
+ self._permits = permits
394
+ self._writer = writer
395
+ self._turn_state = turn_state
396
+ self._usage_publisher = usage_publisher
397
+ self._bus = bus
398
+ self._fsm = fsm
399
+ self._history = history
400
+ self._persistence = persistence
401
+ self._settings_handle = settings_handle
402
+ self._agent_registry = agent_registry
403
+ self._model_registry = model_registry
404
+ self._trajectory_recorder = trajectory_recorder
405
+ self._workspace_change_tracker = workspace_change_tracker
406
+ self._unregister_current_engine = unregister_current_engine
407
+
408
+ def reset_turn_runtime_after_session_shutdown(self) -> None:
409
+ """Clear turn runtime state after shutdown has observed the old task."""
410
+ prompt_admission_owner = self._permits.prompt_admission_owner_for_current_task()
411
+ self._turn_state.reset_after_session_shutdown(
412
+ prompt_admission_owner=prompt_admission_owner,
413
+ )
414
+
415
+ async def fire_session_end_hooks(self) -> None:
416
+ """Fire ``session_end`` for the live session and wait for it to finish.
417
+
418
+ Fires at most once per session. ``shutdown()`` is the usual caller;
419
+ deleting the ACTIVE session calls it first, while ``_session_id`` and
420
+ the session files still exist, and the later shutdown then skips the
421
+ duplicate. ``fire()`` only *spawns* ``async``-mode hooks, so this
422
+ also drains them (without closing the manager) — otherwise a delete
423
+ could remove the files before such a hook ran. The next ``start()``
424
+ re-arms it for the session it brings up.
425
+ """
426
+ hook_manager = self._session.hook_manager
427
+ if hook_manager is None or self._session.session_end_fired:
428
+ return
429
+ from chrys.service.hooks.events import HookEvent
430
+
431
+ self._session.mark_session_end_fired()
432
+ profile_name = self._session.agent_profile.name if self._session.agent_profile is not None else ""
433
+ await hook_manager.fire(
434
+ HookEvent.SESSION_END,
435
+ {"session_id": self._session.session_id, "profile": profile_name, "cwd": self._session.workspace_cwd()},
436
+ scope="session",
437
+ )
438
+ await hook_manager.drain_session(close=False)
439
+
440
+ async def close_trajectory_log(self) -> None:
441
+ """Close the trajectory writer so the session directory can be removed.
442
+
443
+ Deleting the ACTIVE session removes its folder while this runtime is
444
+ still up. A live writer holds the log's lease, and a leased directory
445
+ is only tombstoned — swept at the next store startup, so the files a
446
+ user asked to delete would outlive the request for the rest of the
447
+ run. Closing first makes the delete physical.
448
+
449
+ A delete that then fails (the lock is busy) leaves the session
450
+ running unrecorded until the next runtime resumes its log. That is
451
+ the recorder's standing contract — recording never holds a session
452
+ up — and the log it leaves behind is closed, not torn.
453
+ """
454
+ await self._trajectory_recorder.close(reason=TrajectoryRuntimeFinishReason.SESSION_SWITCH)
455
+
456
+ def prepare(self, profile: AgentProfile | None) -> None:
457
+ """Seed the fallback profile used by session restores."""
458
+ if profile is not None:
459
+ self._session.agent_profile = profile
460
+
461
+ async def start(
462
+ self,
463
+ profile: AgentProfile,
464
+ *,
465
+ operation: str = "startup",
466
+ staged_loaded: LoadedSettings | None = None,
467
+ workspace: Workspace | None = None,
468
+ ) -> None:
469
+ """Build and install the agent from a profile under a lifecycle permit."""
470
+ if (
471
+ self._permits.current_task_owns_rebuild_permit()
472
+ or self._permits.current_task_owns_session_transition_permit()
473
+ ):
474
+ await self._start_locked(profile, operation=operation, staged_loaded=staged_loaded, workspace=workspace)
475
+ return
476
+ token = self._permits.capture_control_token()
477
+ permit = await self._permits.acquire_rebuild_permit(token)
478
+ if isinstance(permit, lifecycle_permits.RebuildPermitDenied):
479
+ raise RuntimeError(permit.message)
480
+ try:
481
+ await self._start_locked(profile, operation=operation, staged_loaded=staged_loaded, workspace=workspace)
482
+ finally:
483
+ self._permits.release_rebuild_permit(permit)
484
+
485
+ async def _start_locked(
486
+ self,
487
+ profile: AgentProfile,
488
+ *,
489
+ operation: str = "startup",
490
+ staged_loaded: LoadedSettings | None = None,
491
+ workspace: Workspace | None = None,
492
+ ) -> None:
493
+ """Start the engine while the caller owns the rebuild boundary."""
494
+ # Every session (fresh, restored, reset) starts here: re-arm the
495
+ # once-per-session ``session_end`` hook fired by shutdown/delete.
496
+ self._session.begin(agent_profile=profile, workspace=workspace)
497
+ if self._session.session_id is None:
498
+ raise RuntimeError("Session begin did not establish a session id.")
499
+ staged_workspace = workspace if workspace is not None else self._session.workspace
500
+
501
+ if not self._session.guard.ensure(self._session.session_id):
502
+ message = self._session.guard.conflict_message(self._session.session_id)
503
+ await self._bus.publish(Error(code="session_in_use", message=message, session_id=self._session.session_id))
504
+ raise RuntimeError(message)
505
+
506
+ # Build the hook manager once per engine instance when a hook config
507
+ # file exists. With no config file, hooks stay a true no-op: no
508
+ # manager, no outbox/log/tmp directories, and no recovery task.
509
+ #
510
+ # The manager survives profile switches within the same session —
511
+ # hooks are global, not profile-scoped. A no-executor workspace-change
512
+ # retry can happen after a startup failure, so reload there because
513
+ # project hooks are primary-cwd scoped. The candidate stays staged: it
514
+ # goes live with the build's commit, and until then the old manager
515
+ # keeps running untouched.
516
+ old_hook_manager = self._session.hook_manager
517
+ hook_manager = await self._loader.stage_hook_manager(
518
+ operation=operation, staged_loaded=staged_loaded, workspace=staged_workspace
519
+ )
520
+ if (old_hook_manager is None or operation == "workspace_change") and hook_manager is not None:
521
+ # Session / turn lifecycle hooks fire outside any model run, so
522
+ # they record under the recorder's current scope instead.
523
+ hook_manager.trajectory_context_provider = self._trajectory_recorder.context
524
+
525
+ session_dir = self._session.session_dir if self._persistence.state_store is not None else None
526
+
527
+ from chrys.foundation.observability.sink import get_otel_sink
528
+
529
+ otel_sink = get_otel_sink()
530
+ if otel_sink is not None:
531
+ otel_sink.activate(self._session.session_id, session_dir)
532
+ # The trajectory recorder is bound here and activates (opens the log,
533
+ # takes the writer lease) on its first event, so a session that never
534
+ # records anything never creates ``trajectory/``.
535
+ event_loop = asyncio.get_running_loop()
536
+ trajectory_session_id = self._session.session_id
537
+ trajectory_warning_tasks: set[asyncio.Task[None]] = set()
538
+
539
+ def _report_trajectory_activation_failure(_reason: str) -> None:
540
+ def _publish_warning() -> None:
541
+ task = event_loop.create_task(
542
+ self._bus.publish(
543
+ Warning(
544
+ code="trajectory_activation_failed",
545
+ message="Trajectory recording could not start and has been disabled for this session.",
546
+ display_message=_CONSTRUCTION_TRAJECTORY_ACTIVATION_FAILED.bind(),
547
+ session_id=trajectory_session_id,
548
+ )
549
+ )
550
+ )
551
+ trajectory_warning_tasks.add(task)
552
+ task.add_done_callback(trajectory_warning_tasks.discard)
553
+
554
+ try:
555
+ event_loop.call_soon_threadsafe(_publish_warning)
556
+ except RuntimeError:
557
+ logger.debug("Trajectory activation failure could not be published because the event loop is closed")
558
+
559
+ trajectory = self._trajectory_recorder.bind_session(
560
+ session_id=trajectory_session_id,
561
+ session_dir=session_dir,
562
+ write_lock_path=self._session.session_write_lock_path(trajectory_session_id)
563
+ if session_dir is not None
564
+ else None,
565
+ session_start_info=self._loader.trajectory_session_start_info,
566
+ on_activation_failed=_report_trajectory_activation_failure,
567
+ )
568
+
569
+ await self._loader.load(
570
+ profile,
571
+ operation=operation,
572
+ staged_loaded=staged_loaded,
573
+ workspace=staged_workspace,
574
+ hook_manager=hook_manager,
575
+ trajectory=trajectory,
576
+ old_hook_manager=old_hook_manager,
577
+ )
578
+
579
+ async def start_with_rebuild_permit(
580
+ self,
581
+ permit: lifecycle_permits.RebuildPermit,
582
+ profile: AgentProfile,
583
+ *,
584
+ operation: str = "startup",
585
+ staged_loaded: LoadedSettings | None = None,
586
+ workspace: Workspace | None = None,
587
+ ) -> None:
588
+ """Start using an already-acquired rebuild permit."""
589
+ self._permits.ensure_rebuild_permit(permit)
590
+ await self.start(profile, operation=operation, staged_loaded=staged_loaded, workspace=workspace)
591
+
592
+ async def reload(
593
+ self,
594
+ new_profile: AgentProfile,
595
+ workspace: Workspace | None = None,
596
+ *,
597
+ operation: str = "switch",
598
+ staged_loaded: LoadedSettings | None = None,
599
+ ) -> None:
600
+ """Restart the agent with a new profile/workspace while preserving history."""
601
+ if (
602
+ self._permits.current_task_owns_rebuild_permit()
603
+ or self._permits.current_task_owns_session_transition_permit()
604
+ ):
605
+ await self._reload_locked(new_profile, workspace, operation=operation, staged_loaded=staged_loaded)
606
+ return
607
+ token = self._permits.capture_control_token()
608
+ permit = await self._permits.acquire_rebuild_permit(token)
609
+ if isinstance(permit, lifecycle_permits.RebuildPermitDenied):
610
+ raise RuntimeError(permit.message)
611
+ try:
612
+ await self._reload_locked(new_profile, workspace, operation=operation, staged_loaded=staged_loaded)
613
+ finally:
614
+ self._permits.release_rebuild_permit(permit)
615
+
616
+ async def _reload_locked(
617
+ self,
618
+ new_profile: AgentProfile,
619
+ workspace: Workspace | None = None,
620
+ *,
621
+ operation: str = "switch",
622
+ staged_loaded: LoadedSettings | None = None,
623
+ ) -> None:
624
+ """Soft-restart while the caller owns the rebuild boundary."""
625
+ await self._loader.reload(new_profile, workspace, operation=operation, staged_loaded=staged_loaded)
626
+
627
+ async def reload_with_rebuild_permit(
628
+ self,
629
+ permit: lifecycle_permits.RebuildPermit,
630
+ new_profile: AgentProfile,
631
+ workspace: Workspace | None = None,
632
+ *,
633
+ operation: str = "switch",
634
+ staged_loaded: LoadedSettings | None = None,
635
+ ) -> None:
636
+ """Soft-restart using an already-acquired rebuild permit."""
637
+ self._permits.ensure_rebuild_permit(permit)
638
+ await self.reload(new_profile, workspace=workspace, operation=operation, staged_loaded=staged_loaded)
639
+
640
+ async def close_session(self) -> None:
641
+ """Close the session and release its lock, retaining process resources."""
642
+ await self._teardown(release_lock=True, close_mcp=False)
643
+
644
+ async def close_session_in_place(self) -> None:
645
+ """Close the session while retaining its lock and process resources."""
646
+ await self._teardown(release_lock=False, close_mcp=False)
647
+
648
+ async def shutdown(self) -> None:
649
+ """Close the session and process resources."""
650
+ await self._teardown(release_lock=True, close_mcp=True)
651
+
652
+ async def _teardown(self, *, release_lock: bool, close_mcp: bool) -> None:
653
+ """Gracefully shut down the engine."""
654
+ self._session.mark_closing()
655
+ self._turn_state.shutdown_used_cancel_fallback = False
656
+ suppress_trailing_save = False
657
+ self._unregister_current_engine()
658
+ self._fsm.try_transition(Trigger.SHUTDOWN)
659
+ if self._current.loaded is not None and self._current.loaded.bindings.state.running:
660
+ handle = self._current.loaded.bindings.backend.active_handle
661
+ if handle is not None:
662
+ await self._current.loaded.bindings.backend.abort(handle, AbortCause.OWNER_CLOSE)
663
+ run_task = self._turn_state.lease.run_task
664
+ if run_task is not None and not run_task.done():
665
+ try:
666
+ await asyncio.wait_for(
667
+ asyncio.shield(run_task),
668
+ timeout=_SHUTDOWN_POST_RUN_TIMEOUT_SECONDS,
669
+ )
670
+ except TimeoutError:
671
+ self._turn_state.shutdown_used_cancel_fallback = True
672
+ suppress_trailing_save = True
673
+ run_task.cancel()
674
+ with contextlib.suppress(asyncio.CancelledError):
675
+ await run_task
676
+ except asyncio.CancelledError:
677
+ pass
678
+ self._turn_state.lease.release_run_task()
679
+ await self._turn_state.lease.settle_notifications()
680
+ # A cancelled/timed-out worker can leave a queued retry or active
681
+ # admission behind. Terminalize their preparations while the trajectory
682
+ # writer is still open; the post-shutdown reset is too late to emit.
683
+ self._turn_state.lease.clear_pending_retry(outcome=PreparationOutcome.DROPPED)
684
+ self._turn_state.lease.clear_active_admissions(outcome=PreparationOutcome.OWNER_CHANGED)
685
+ self._turn_state.lease.clear_pre_admission_preparations()
686
+ if self._current.loaded is not None:
687
+ for injection in self._current.loaded.injection.drain_pending():
688
+ if injection.preparation is not None:
689
+ injection.preparation.finished_soon(
690
+ outcome=PreparationOutcome.TARGET_STALE,
691
+ target_turn_id=injection.target_turn_id,
692
+ )
693
+ await self._loader.cancel_outbox_recovery()
694
+ # Drain any queued UsageUpdate publish tasks before the bus is torn
695
+ # down — orphaned tasks otherwise either deliver a stale UsageUpdate
696
+ # into the next session's subscriber set (engine instance is reused
697
+ # across session restore / new-session) or get garbage-collected mid-
698
+ # await, producing "Task was destroyed but it is pending" warnings on
699
+ # process exit.
700
+ await self._usage_publisher.settle()
701
+ # Fire ``session_end`` hooks BEFORE we tear down anything — they
702
+ # may want a live session to inspect. Then drain any in-flight
703
+ # async hooks within the configured shutdown grace. Detached
704
+ # hooks survive this call by design.
705
+ if self._session.hook_manager is not None:
706
+ await self.fire_session_end_hooks()
707
+ await self._session.hook_manager.drain_session()
708
+ # ``drain_session()`` closes the manager. The same engine
709
+ # instance is reused for restore/new-session flows, so force
710
+ # the next ``start()`` to reload config and create a fresh
711
+ # manager instead of carrying a closed no-op instance forward.
712
+ self._session.hook_manager = None
713
+ # Auto-save before shutdown
714
+ with self._session.saves_suppressed() if suppress_trailing_save else contextlib.nullcontext():
715
+ await self._writer.save_current_session()
716
+ # Stamp the coordination registry closed (peers stop warning
717
+ # about us); files are kept for peers' late rollbacks — GC only.
718
+ if self._session.mutation_coordinator is not None:
719
+ try:
720
+ await asyncio.get_running_loop().run_in_executor(None, self._session.mutation_coordinator.close)
721
+ except Exception:
722
+ logger.debug("Mutation coordinator close failed", exc_info=True)
723
+ # The trajectory writer must be closed before the directory it writes
724
+ # into can be judged empty. ``close_mcp=False`` is how the
725
+ # session-switch flows (new/restore/clear/reset) shut the engine down
726
+ # while the process keeps running; every other caller is exiting.
727
+ await self._trajectory_recorder.close(
728
+ reason=(
729
+ TrajectoryRuntimeFinishReason.GRACEFUL_SHUTDOWN
730
+ if close_mcp
731
+ else TrajectoryRuntimeFinishReason.SESSION_SWITCH
732
+ ),
733
+ )
734
+ # Clean up empty session directory (no messages were sent)
735
+ self.cleanup_empty_session_dir()
736
+ await self._loader.release_current()
737
+ self._turn_state.paused_sub_agents.clear()
738
+ if close_mcp:
739
+ await self._loader.close()
740
+ if release_lock:
741
+ self._session.guard.release()
742
+
743
+ async def refresh_mutation_attribution(self, *, force: bool = False) -> bool:
744
+ """Reclassify the live mutation log against peer registry claims.
745
+
746
+ The engine-level read-side entry point for cross-session
747
+ coordination: display
748
+ surfaces call it before building net summaries; the rollback
749
+ path calls it with ``force=True`` as the authoritative last
750
+ check. Returns True when any row changed — the change is
751
+ persisted via the normal session-state save so serialized
752
+ consumers (TUI /diff) observe it too.
753
+ """
754
+ coordinator = self._session.mutation_coordinator
755
+ tracker = self._session.mutation_tracker
756
+ if coordinator is None or tracker is None:
757
+ return False
758
+ loop = asyncio.get_running_loop()
759
+ try:
760
+ changed = await loop.run_in_executor(
761
+ None,
762
+ lambda: coordinator.reclassify(tracker, force=force, fallback_root=self._session.workspace_cwd()),
763
+ )
764
+ except Exception:
765
+ logger.debug("Mutation attribution refresh failed", exc_info=True)
766
+ return False
767
+ # The coordinator's flag, not this call's return value: a
768
+ # finalize-time reclassify may have changed rows earlier with no
769
+ # saver, and its signature update makes this very call report
770
+ # "unchanged". While a run is active the save must not happen here:
771
+ # a primary save deletes the recovery sidecar, which mid-turn is the
772
+ # only durable copy of committed-but-unmerged tool exchanges. The
773
+ # flag stays set, and the turn-end save persists the reclassification.
774
+ if (
775
+ not (self._current.loaded is not None and self._current.loaded.bindings.state.running)
776
+ and self._session.mutation_coordinator is not None
777
+ and self._session.mutation_coordinator.consume_unsaved_reclassification()
778
+ ):
779
+ await self._writer.save_current_session()
780
+ return changed
781
+
782
+ async def on_session_fork(self, event: SessionFork) -> None:
783
+ """Handle session fork requests for the active saved session."""
784
+ try:
785
+ await asyncio.wait_for(
786
+ self._permits.wait_for_agent_load_idle(),
787
+ timeout=_SESSION_FORK_AGENT_LOAD_TIMEOUT_SECONDS,
788
+ )
789
+ except TimeoutError:
790
+ await self._publish_session_fork_error(
791
+ "session_fork_busy",
792
+ "Cannot fork while the agent is still loading.",
793
+ session_id=event.session_id or self._session.session_id or "",
794
+ display_message=_FORK_AGENT_LOADING.bind(),
795
+ )
796
+ return
797
+
798
+ active_session_id = self._session.session_id
799
+ state_store = self._persistence.state_store
800
+ if active_session_id is None or self._current.loaded is None:
801
+ await self._publish_session_fork_error(
802
+ "session_fork_not_ready",
803
+ "Cannot fork before a session is ready.",
804
+ session_id=active_session_id or event.session_id,
805
+ display_message=_FORK_NOT_READY.bind(),
806
+ )
807
+ return
808
+ if event.session_id != active_session_id:
809
+ await self._publish_session_fork_error(
810
+ "session_fork_stale",
811
+ "Cannot fork because the active session changed.",
812
+ session_id=event.session_id or active_session_id,
813
+ display_message=_FORK_SESSION_CHANGED.bind(),
814
+ )
815
+ return
816
+ if self._fsm.is_running():
817
+ await self._publish_session_fork_error(
818
+ "session_fork_busy",
819
+ "Cannot fork while a turn is running.",
820
+ session_id=active_session_id,
821
+ display_message=_FORK_TURN_ACTIVE.bind(),
822
+ )
823
+ return
824
+ if state_store is None:
825
+ await self._publish_session_fork_error(
826
+ "session_fork_failed",
827
+ "State store not configured.",
828
+ session_id=active_session_id,
829
+ display_message=_FORK_STATE_STORE_MISSING.bind(),
830
+ )
831
+ return
832
+ if self._session.suppress_save:
833
+ await self._publish_session_fork_error(
834
+ "session_fork_busy",
835
+ "Cannot fork while session state is being restored.",
836
+ session_id=active_session_id,
837
+ display_message=_FORK_RESTORING.bind(),
838
+ )
839
+ return
840
+ if not self._session.guard.owns(active_session_id):
841
+ await self._publish_session_fork_error(
842
+ "session_fork_busy",
843
+ "Cannot fork because this window does not own the active session lock.",
844
+ session_id=active_session_id,
845
+ display_message=_FORK_LOCK_NOT_OWNED.bind(),
846
+ )
847
+ return
848
+ if not has_real_messages(self._current.loaded.bindings.backend.history_state):
849
+ await self._publish_session_fork_error(
850
+ "session_fork_empty",
851
+ "Cannot fork an empty session.",
852
+ session_id=active_session_id,
853
+ display_message=_FORK_EMPTY_SESSION.bind(),
854
+ )
855
+ return
856
+
857
+ try:
858
+ await self._writer.save_current_session(raise_on_error=True)
859
+ fresh_state = await state_store.load_session(active_session_id)
860
+ except TimeoutError as exc:
861
+ logger.warning("Timed out preparing session %s for fork", active_session_id, exc_info=True)
862
+ await self._publish_session_fork_error(
863
+ "session_fork_busy",
864
+ f"Timed out preparing session for fork: {exc}",
865
+ session_id=active_session_id,
866
+ display_message=_FORK_PREPARE_TIMEOUT.bind(detail=DisplayBlock(str(exc))),
867
+ )
868
+ return
869
+ except Exception as exc:
870
+ logger.warning("Failed to prepare session %s for fork", active_session_id, exc_info=True)
871
+ await self._publish_session_fork_error(
872
+ "session_fork_failed",
873
+ f"Failed to prepare session for fork: {exc}",
874
+ session_id=active_session_id,
875
+ display_message=_FORK_PREPARE_FAILED.bind(detail=DisplayBlock(str(exc))),
876
+ )
877
+ return
878
+ if fresh_state is None or not has_real_messages(fresh_state):
879
+ await self._publish_session_fork_error(
880
+ "session_fork_empty",
881
+ "Cannot fork an empty session.",
882
+ session_id=active_session_id,
883
+ display_message=_FORK_EMPTY_SESSION.bind(),
884
+ )
885
+ return
886
+
887
+ try:
888
+ # Forking is work on the new session, so it starts out on this launch's surface.
889
+ new_session_id = await asyncio.to_thread(
890
+ state_store.fork_session, active_session_id, last_surface=self._session.surface
891
+ )
892
+ except SessionNotFoundError:
893
+ await self._publish_session_fork_error(
894
+ "session_fork_not_found",
895
+ f"Session '{active_session_id}' not found.",
896
+ session_id=active_session_id,
897
+ display_message=_FORK_SESSION_NOT_FOUND.bind(session_id=active_session_id),
898
+ )
899
+ return
900
+ except SessionForkError as exc:
901
+ logger.warning("Failed to fork session %s", active_session_id, exc_info=True)
902
+ await self._publish_session_fork_error(
903
+ "session_fork_failed",
904
+ f"Failed to fork session: {exc}",
905
+ session_id=active_session_id,
906
+ display_message=_FORK_FAILED.bind(detail=DisplayBlock(str(exc))),
907
+ )
908
+ return
909
+
910
+ # The fork copies the conversation, not the parent's trajectory; it
911
+ # gets its own closed opening runtime pointing back at the parent.
912
+ await self._trajectory_recorder.fork(
913
+ origin_session_id=active_session_id,
914
+ fork_session_id=new_session_id,
915
+ fork_session_dir=self._session.session_dir_for(new_session_id),
916
+ fork_write_lock_path=self._session.session_write_lock_path(new_session_id),
917
+ session_start_info=self._loader.trajectory_session_start_info(),
918
+ )
919
+ await self._bus.publish(
920
+ SessionForked(
921
+ session_id=active_session_id,
922
+ parent_session_id=active_session_id,
923
+ new_session_id=new_session_id,
924
+ )
925
+ )
926
+
927
+ async def _publish_session_fork_error(
928
+ self, code: str, message: str, *, session_id: str, display_message: MessageRef | None = None
929
+ ) -> None:
930
+ await self._bus.publish(
931
+ Error(code=code, message=message, session_id=session_id, display_message=display_message)
932
+ )
933
+
934
+ async def _reconcile_existing_spill_storage(self) -> SpillReconciliationResult:
935
+ """Rebuild and account for retained spill storage without creating a session directory."""
936
+ session_dir = self._session.session_dir
937
+ if session_dir is None or not session_dir.is_dir():
938
+ return SpillReconciliationResult(0, 0, frozenset())
939
+ try:
940
+ return await asyncio.to_thread(reconcile_spill_storage, session_dir, self._session.spill_quota)
941
+ except OSError, RuntimeError, UnicodeError:
942
+ # Spill records are auxiliary context. Filesystem damage or permissions
943
+ # must not prevent the authoritative session state from hydrating.
944
+ logger.warning("Unable to reconcile spill storage under %s", session_dir, exc_info=True)
945
+ self._session.spill_quota.disable_storage()
946
+ return SpillReconciliationResult(0, 0, frozenset())
947
+
948
+ def _workspace_cwd(self) -> str:
949
+ """Return the engine workspace cwd, falling back only for legacy unstarted engines."""
950
+ if self._session.workspace is not None:
951
+ return self._session.workspace.primary_cwd
952
+ return safe_getcwd()
953
+
954
+ def _can_restore_service_session(self, meta: ChatSessionMeta) -> bool:
955
+ """Return True when the active model can continue a saved service session id."""
956
+ profile = self._current.manifest.active_profile
957
+ if (
958
+ profile is None
959
+ or self._current.loaded is None
960
+ or meta.model_provider != "openai"
961
+ or meta.model_api_style != API_STYLE_RESPONSES
962
+ or profile.provider != "openai"
963
+ or profile.api_style != API_STYLE_RESPONSES
964
+ or meta.model_id != profile.model_id
965
+ or not meta.model_profile_fingerprint
966
+ or meta.model_profile_fingerprint != self._current.manifest.model_profile_fingerprint
967
+ or not meta.agent_profile_fingerprint
968
+ or meta.agent_profile_fingerprint != self._current.manifest.agent_profile_fingerprint
969
+ or self._session.workspace is None
970
+ or meta.primary_cwd != self._session.workspace.primary_cwd
971
+ or _extra_roots(meta.working_dirs, meta.primary_cwd)
972
+ != _extra_roots(
973
+ [working_dir.path for working_dir in self._session.workspace.working_dirs],
974
+ self._session.workspace.primary_cwd,
975
+ )
976
+ ):
977
+ return False
978
+ from chrys.service.llm.clients import effective_model_base_url
979
+
980
+ if meta.model_base_url != effective_model_base_url(profile):
981
+ return False
982
+ return self._current.loaded.bindings.backend.service_session_storage_enabled
983
+
984
+ def _restore_terminal_fsm_from_history(self) -> None:
985
+ """Rebuild runtime terminal state from persisted history markers."""
986
+ if not self._history.is_bound:
987
+ return
988
+ marker = self._history.trailing_status_marker()
989
+ if marker is None:
990
+ return
991
+ kind, source = marker
992
+ if kind != HistoryMarkerKind.INTERRUPTED:
993
+ return
994
+ self._fsm.restore_terminal_state(failed=source == "error")
995
+
996
+ def _session_pin_overrides(self) -> dict[str, Any]:
997
+ """Snapshot the per-session pins every re-load must carry.
998
+
999
+ Keep this contract in sync with the same-named implementation in
1000
+ state/controls.py; changes to either implementation must update both.
1001
+ Pins travel only while pinned, so an unpinned session picks up a changed
1002
+ env value on its next load.
1003
+ """
1004
+ overrides: dict[str, Any] = {}
1005
+ if self._session.ask_user_timeout_pinned:
1006
+ overrides["ask_user_timeout_seconds"] = self._settings_handle.settings.ask_user_timeout_seconds
1007
+ if self._session.model_profile_pinned:
1008
+ overrides["model_profile"] = self._settings_handle.settings.model_profile
1009
+ overrides["model_profile_override"] = self._settings_handle.settings.model_profile_override
1010
+ overrides["model_profile_override_sub_agents"] = (
1011
+ self._settings_handle.settings.model_profile_override_sub_agents
1012
+ )
1013
+ return overrides
1014
+
1015
+ def _reload_eval_context(self) -> EvalContext:
1016
+ """The launch mode's retry policy, passed *into* the load (as every re-load must)."""
1017
+ return EvalContext(
1018
+ frontend_default_max_transient_retries=self._settings_handle.settings.frontend_default_max_transient_retries
1019
+ )
1020
+
1021
+ def _reapply_saved_model_profile(
1022
+ self,
1023
+ meta: ChatSessionMeta | None,
1024
+ profile: AgentProfile,
1025
+ staged_loaded: LoadedSettings,
1026
+ ) -> tuple[LoadedSettings, _ModelRestoreRollbackToken | None]:
1027
+ """Apply a selectable saved model when the restored agent has no live binding.
1028
+
1029
+ Transforms the staged load rather than installing anything: the selection
1030
+ goes live with the build's commit, exactly when the executor built from it
1031
+ does. Only the process pointer is written eagerly (§7: an intended fork,
1032
+ other hosts do see it) — but as SESSION, never ENV: the panel must not
1033
+ claim the shell exported what this restore wrote. The rollback token
1034
+ carries that one eager write, plus the executor identity that tells a
1035
+ pre-commit failure from a post-commit one.
1036
+ """
1037
+ registry = self._model_registry
1038
+ if meta is None or not meta.model_profile_id or registry is None:
1039
+ return staged_loaded, None
1040
+ saved = registry.get(meta.model_profile_id)
1041
+ if saved is None or not is_model_profile_selectable(saved):
1042
+ return staged_loaded, None
1043
+
1044
+ bound_profile_id = profile.model.profile_id
1045
+ if bound_profile_id and registry.get(bound_profile_id) is not None:
1046
+ return staged_loaded, None
1047
+ if saved.id == staged_loaded.settings.model_profile:
1048
+ return staged_loaded, None
1049
+
1050
+ token = _ModelRestoreRollbackToken(
1051
+ conversation=self._current.loaded.bindings.backend if self._current.loaded is not None else None,
1052
+ pointer=set_model_pointer(saved.id, origin=SettingOrigin(layer=Source.SESSION)),
1053
+ )
1054
+ # Through the overlay, and as the whole selection, not one field of it.
1055
+ # Setting only ``model_profile`` would leave the previous session's pin in
1056
+ # ``model_profile_override``, which outranks it — the restore would claim
1057
+ # the saved model and resolve the old session's.
1058
+ return loaded_with_active_model_profile(staged_loaded, saved, Source.SESSION), token
1059
+
1060
+ async def reset_after_failed_startup_restore(self) -> None:
1061
+ """Discard every partially installed restore field before fresh startup.
1062
+
1063
+ Restore can fail after installing the target session id, workspace, active
1064
+ lock, or a partial runtime. Suppress persistence while tearing those down so
1065
+ fallback startup cannot overwrite the saved target session.
1066
+ """
1067
+ with self._session.saves_suppressed():
1068
+ try:
1069
+ await self.close_session()
1070
+ finally:
1071
+ # ``shutdown`` normally releases this; keep the failure path idempotent
1072
+ # if teardown raised after acquiring/installing the restore lock.
1073
+ self._session.guard.release()
1074
+ self.reset_turn_runtime_after_session_shutdown()
1075
+ self._session.workspace = None
1076
+ self.reset_for_restart(None)
1077
+ # Fallback startup runs in the process cwd, not the failed target's root,
1078
+ # so the settings the next build reads must be re-derived for that root —
1079
+ # whatever the failed restore left installed described the wrong project
1080
+ # trust domain. Best-effort: the reset must never mask the original
1081
+ # failure, and the live settings remain a usable baseline without it.
1082
+ try:
1083
+ old_loaded = self._settings_handle.loaded
1084
+ candidate = await asyncio.to_thread(
1085
+ load_settings,
1086
+ project_root=Path(safe_getcwd()),
1087
+ eval_context=self._reload_eval_context(),
1088
+ **self._session_pin_overrides(),
1089
+ )
1090
+ routed, _ = route_restart_settings(reattribute_command_line(candidate, old_loaded), old_loaded)
1091
+ self._settings_handle.install(routed)
1092
+ except Exception:
1093
+ logger.warning("Settings re-derivation for the fallback startup root failed", exc_info=True)
1094
+
1095
+ def reset_for_restart(self, session_id: str | None) -> None:
1096
+ """Reset per-session engine state in preparation for a fresh ``start()``.
1097
+
1098
+ The approval mode is not per-session state: the mode the user last chose stays in
1099
+ force for every session of this launch, and only the next launch reads the saved default.
1100
+ """
1101
+ self._fsm.reset()
1102
+ self._session.reset(session_id=session_id, workspace=self._session.workspace)
1103
+ self._workspace_change_tracker.reset_for_restart()
1104
+
1105
+ async def reset_session_to_welcome(
1106
+ self,
1107
+ session_id: str,
1108
+ *,
1109
+ write_lock_held: bool = False,
1110
+ after_delete: Callable[[], Awaitable[None]] | None = None,
1111
+ before_restart: Callable[[], None] | None = None,
1112
+ ) -> bool:
1113
+ """Delete ``session.json`` and snapshots, then reload to a welcome state."""
1114
+ transition_owner = (
1115
+ None
1116
+ if self._permits.current_task_owns_session_transition_permit()
1117
+ else await self._permits.begin_session_transition("reset")
1118
+ )
1119
+ reset_lock: FileLock | None = None
1120
+ try:
1121
+ session_dir = None
1122
+ lock_path = None
1123
+ if self._persistence.state_store is not None:
1124
+ session_dir = self._persistence.state_store.session_dir(session_id)
1125
+ if session_dir is not None and session_dir.is_dir():
1126
+ lock_path = self._session.session_write_lock_path(session_id)
1127
+ if not write_lock_held:
1128
+ await self._writer.flush()
1129
+ if lock_path is not None:
1130
+ reset_lock = _acquire_session_write_lock(lock_path, session_id)
1131
+ if reset_lock is None:
1132
+ return False
1133
+
1134
+ profile = self._session.agent_profile
1135
+ restore_state = (
1136
+ _reset_restore_history_state(self._current.loaded.bindings.backend.history_state)
1137
+ if self._current.loaded is not None
1138
+ else None
1139
+ )
1140
+ restore_mutations = (
1141
+ copy.deepcopy(self._session.mutation_tracker.serialize())
1142
+ if self._session.mutation_tracker is not None
1143
+ else None
1144
+ )
1145
+ if restore_state is not None and restore_mutations is not None:
1146
+ restore_state["chrys_mutations"] = copy.deepcopy(restore_mutations)
1147
+ restore_todos = self._session.todo_tracker.serialize() if self._session.todo_tracker is not None else None
1148
+ if restore_state is not None and restore_todos:
1149
+ restore_state["chrys_todos"] = copy.deepcopy(restore_todos)
1150
+ restore_turn_number = self._session.turn_number
1151
+ restore_runtime_meta = copy.deepcopy(self._session.runtime_meta)
1152
+ with self._session.saves_suppressed():
1153
+ await self.close_session_in_place()
1154
+ self.reset_turn_runtime_after_session_shutdown()
1155
+
1156
+ if session_dir is not None and session_dir.is_dir():
1157
+ try:
1158
+ _delete_reset_session_files(session_dir)
1159
+ snap_dir = session_dir / "snapshots"
1160
+ if snap_dir.is_dir():
1161
+ shutil.rmtree(snap_dir, ignore_errors=True)
1162
+ except OSError:
1163
+ logger.warning("Failed to delete session files for reset %s", session_id, exc_info=True)
1164
+ if reset_lock is not None:
1165
+ reset_lock.release()
1166
+ reset_lock = None
1167
+ if before_restart is not None:
1168
+ before_restart()
1169
+ await self._restart_after_failed_reset(
1170
+ session_id=session_id,
1171
+ profile=profile,
1172
+ state=restore_state,
1173
+ mutations=restore_mutations,
1174
+ todos=restore_todos,
1175
+ turn_number=restore_turn_number,
1176
+ runtime_meta=restore_runtime_meta,
1177
+ )
1178
+ return False
1179
+
1180
+ if after_delete is not None:
1181
+ await after_delete()
1182
+ if session_dir is not None and session_dir.is_dir():
1183
+ # The restart below keeps this session id, so the directory is
1184
+ # written to again: a delete that cannot finish now must leave
1185
+ # no intent naming the path the new run lands in.
1186
+ cleanup_empty_session_dir_path(session_dir, path_reused=True)
1187
+
1188
+ # Both the normal directory path and a concurrently removed
1189
+ # directory reach the restart below. The new runtime can activate
1190
+ # trajectory recording from a session_start hook, which reacquires
1191
+ # this same path-keyed lock, so ownership must end before restart
1192
+ # regardless of what happened to the directory entry.
1193
+ if reset_lock is not None:
1194
+ reset_lock.release()
1195
+ reset_lock = None
1196
+ if before_restart is not None:
1197
+ before_restart()
1198
+
1199
+ self.reset_for_restart(session_id)
1200
+ await self._reconcile_existing_spill_storage()
1201
+ if profile is None:
1202
+ await self._bus.publish(
1203
+ Error(
1204
+ code="no_agent_profile",
1205
+ message="Agent profile not configured.",
1206
+ session_id=session_id,
1207
+ ),
1208
+ )
1209
+ return True
1210
+ await self.start(profile, operation="reset")
1211
+ return True
1212
+ finally:
1213
+ if reset_lock is not None:
1214
+ reset_lock.release()
1215
+ if transition_owner is not None:
1216
+ self._permits.finish_session_transition(transition_owner)
1217
+
1218
+ def cleanup_empty_session_dir(self) -> None:
1219
+ """Remove the current session directory if it was never saved."""
1220
+ cleanup_empty_session_dir_path(self._session.session_dir)
1221
+
1222
+ async def _restart_after_failed_reset(
1223
+ self,
1224
+ *,
1225
+ session_id: str,
1226
+ profile: AgentProfile | None,
1227
+ state: dict | None,
1228
+ mutations: dict[str, Any] | None,
1229
+ todos: list[dict[str, str]] | None,
1230
+ turn_number: int,
1231
+ runtime_meta: SessionRuntimeMetadata,
1232
+ ) -> None:
1233
+ self.reset_for_restart(session_id)
1234
+ await self._reconcile_existing_spill_storage()
1235
+ self._session.restore_position(runtime_meta=runtime_meta, turn_number=turn_number)
1236
+ mutation_state = (
1237
+ mutations if mutations is not None else state.get("chrys_mutations") if state is not None else None
1238
+ )
1239
+ if mutation_state is not None:
1240
+ session_dir = self._session.session_dir
1241
+ if session_dir is not None:
1242
+ snapshot_store = SnapshotStore(
1243
+ session_dir, policy=SnapshotPolicy.from_settings(self._settings_handle.settings)
1244
+ )
1245
+ self._session.mutation_tracker = MutationTracker.deserialize(mutation_state, snapshot_store)
1246
+ if state is not None:
1247
+ self._workspace_change_tracker.restore(
1248
+ state.get("chrys_workspace_baseline"),
1249
+ self._session.workspace,
1250
+ resolve_scope=self._settings_handle.settings.workspace_change_notice,
1251
+ )
1252
+ # Rehydrate the todo tracker from the pre-shutdown snapshot: reattaching
1253
+ # ``history_state`` alone is not enough — save reads the TRACKER, so an
1254
+ # empty one would pop ``chrys_todos`` on the next save.
1255
+ todo_state = todos if todos is not None else state.get("chrys_todos") if state is not None else None
1256
+ if todo_state:
1257
+ self._session.todo_tracker = TodoTracker()
1258
+ await self._session.todo_tracker.restore(todo_state)
1259
+ if profile is None:
1260
+ return
1261
+ try:
1262
+ await self.start(profile, operation="reset_failed")
1263
+ except Exception:
1264
+ logger.warning("Failed to restart session after reset failure %s", session_id, exc_info=True)
1265
+ return
1266
+ if state is not None and self._current.loaded is not None:
1267
+ if mutation_state is not None:
1268
+ state["chrys_mutations"] = copy.deepcopy(mutation_state)
1269
+ if todo_state:
1270
+ state["chrys_todos"] = copy.deepcopy(todo_state)
1271
+ self._current.loaded.bindings.backend.history_state = state
1272
+ if self._current.loaded is not None:
1273
+ loaded = self._current.loaded
1274
+ restore_phase4_state(loaded.reminder_middleware, loaded.last_words, state)
1275
+ stamp_history_item_ids(self._current.loaded.bindings.backend.history_state)
1276
+ self._history.bind(self._current.loaded.bindings.backend.history_state)
1277
+
1278
+ async def on_new_session(self, _event: SessionNew) -> None:
1279
+ """Handle new session request: save current session, then start fresh."""
1280
+ transition_owner = await self._permits.begin_session_transition("new_session")
1281
+ try:
1282
+ # Sample the live profile after the transition fence so a queued
1283
+ # profile switch that won the rebuild gate determines the new session.
1284
+ profile = self._session.agent_profile
1285
+ if profile is None:
1286
+ return
1287
+ await self._start_fresh_session(profile)
1288
+ finally:
1289
+ self._permits.finish_session_transition(transition_owner)
1290
+
1291
+ async def _start_fresh_session(self, profile: AgentProfile) -> None:
1292
+ """Shut the current session down and start an empty one under *profile*.
1293
+
1294
+ Callers hold a COMMITTED session transition (``_begin_session_transition``
1295
+ or prepare + ``_commit_session_transition``); the shutdown here relies on
1296
+ the fence to have already invalidated the old session's turn state.
1297
+ """
1298
+ await self.close_session()
1299
+ self.reset_turn_runtime_after_session_shutdown()
1300
+ self.cleanup_empty_session_dir()
1301
+ self.reset_for_restart(None)
1302
+ await self.start(profile, operation="new_session")
1303
+
1304
+ async def on_session_clear(self, event: SessionClear) -> None:
1305
+ """Delete the ACTIVE session and start a fresh one as one fenced transition.
1306
+
1307
+ Prompt admission is closed before the deletion starts and stays closed
1308
+ until the fresh session is ready, so no prompt can be admitted against the
1309
+ detached session in between. The transition is committed only after the
1310
+ delete succeeded: a failed delete leaves the current session and its turn
1311
+ state intact (admission simply reopens), reports
1312
+ ``Error(code="session_clear_failed")``, and never starts a new session.
1313
+ """
1314
+ transition_owner = await self._permits.prepare_session_transition("clear")
1315
+ if transition_owner is None: # No owner filter was given, so this cannot happen.
1316
+ raise RuntimeError("Session transition acquisition unexpectedly failed")
1317
+ failure_message: str | None = None
1318
+ try:
1319
+ # Sample under the fence: a queued restore/new/switch cannot move the
1320
+ # active session while we hold the gate.
1321
+ profile = self._session.agent_profile
1322
+ if not self._session.guard.owns(event.session_id):
1323
+ failure_message = "Only the active session can be cleared"
1324
+ elif profile is None:
1325
+ failure_message = "No agent profile is active"
1326
+ else:
1327
+ failure = await self._delete_session_reporting(event.session_id)
1328
+ if failure is not None:
1329
+ failure_message = failure.message
1330
+ else:
1331
+ self._permits.commit_session_transition(transition_owner)
1332
+ await self._start_fresh_session(profile)
1333
+ finally:
1334
+ self._permits.finish_session_transition(transition_owner)
1335
+ # Published after the fence is released — an Error handler must not find
1336
+ # the gate still held by the failed clear.
1337
+ if failure_message is not None:
1338
+ await self._bus.publish(
1339
+ Error(code="session_clear_failed", message=failure_message, session_id=event.session_id),
1340
+ )
1341
+
1342
+ async def on_session_restore(self, event: SessionRestore) -> None:
1343
+ """Handle session restore by loading saved state and rebuilding the agent."""
1344
+ from chrys.service.state.locks import SESSION_RESTORE_ACTIVE_LOCK_TIMEOUT_SECONDS
1345
+
1346
+ if self._persistence.state_store is None:
1347
+ await self._bus.publish(
1348
+ Error(code="no_state_store", message="State store not configured", session_id=event.session_id)
1349
+ )
1350
+ return
1351
+
1352
+ restoring_current = self._session.guard.owns(event.session_id)
1353
+ target_lock: FileLock | None = None
1354
+ transition_owner: str | None = None
1355
+ if not restoring_current:
1356
+ try:
1357
+ target_lock = await asyncio.to_thread(
1358
+ self._session.guard.acquire_for_restore,
1359
+ event.session_id,
1360
+ timeout=SESSION_RESTORE_ACTIVE_LOCK_TIMEOUT_SECONDS,
1361
+ )
1362
+ except TimeoutError:
1363
+ await self._bus.publish(
1364
+ Error(
1365
+ code="session_in_use",
1366
+ message=self._session.guard.conflict_message(event.session_id),
1367
+ session_id=event.session_id,
1368
+ ),
1369
+ )
1370
+ return
1371
+
1372
+ try:
1373
+ if event.ignore_recovery:
1374
+ try:
1375
+ await asyncio.to_thread(self._persistence.state_store.delete_recovery_session, event.session_id)
1376
+ except Exception:
1377
+ logger.debug("Failed to delete ignored recovery sidecar for %s", event.session_id, exc_info=True)
1378
+
1379
+ prefer_recovery = not event.ignore_recovery and (
1380
+ not restoring_current or self._session.recovered_from_sidecar
1381
+ )
1382
+
1383
+ state = await self._persistence.load_session(event.session_id, prefer_recovery=prefer_recovery)
1384
+ if state is None:
1385
+ if event.ignore_recovery and not restoring_current:
1386
+ cleanup_empty_session_dir_path(self._persistence.state_store.session_dir(event.session_id))
1387
+ await self._bus.publish(
1388
+ Error(
1389
+ code="session_not_found",
1390
+ message=f"Session '{event.session_id}' not found",
1391
+ session_id=event.session_id,
1392
+ )
1393
+ )
1394
+ return
1395
+
1396
+ # Resolve meta by id directly. ``list_sessions`` relies on
1397
+ # ``iterdir`` which can briefly miss a just-written session
1398
+ # folder on Windows; a direct envelope read avoids that race.
1399
+ meta = await self._persistence.load_session_meta(event.session_id, prefer_recovery=prefer_recovery)
1400
+ if meta is not None and meta.kind == "workflow":
1401
+ await self._bus.publish(
1402
+ Error(
1403
+ code="session_kind_mismatch",
1404
+ message="Open this session in Workflow mode.",
1405
+ session_id=event.session_id,
1406
+ )
1407
+ )
1408
+ return
1409
+ recovered_from_sidecar = (
1410
+ await self._persistence.recovery_session_wins(event.session_id) if prefer_recovery else False
1411
+ )
1412
+
1413
+ cwd_warning = ""
1414
+ saved_cwd = meta.primary_cwd if meta and meta.primary_cwd else ""
1415
+ cwd_overridden = bool(event.primary_cwd) and event.primary_cwd != saved_cwd
1416
+ target_cwd = event.primary_cwd or saved_cwd or self._workspace_cwd()
1417
+ target_cwd_exists = os.path.isdir(target_cwd)
1418
+ if target_cwd and not target_cwd_exists:
1419
+ if not restoring_current:
1420
+ # Never switch to a session whose directory is gone; the
1421
+ # frontend lets the user pick another one (``primary_cwd``).
1422
+ # Rolling back the current session keeps it and only warns.
1423
+ await self._bus.publish(
1424
+ Error(
1425
+ code="session_cwd_missing",
1426
+ message=(
1427
+ f"Working directory of session {session_short_id(event.session_id)} "
1428
+ f"no longer exists: {surrogate_safe_text(target_cwd)}"
1429
+ ),
1430
+ display_message=_RESTORE_SESSION_CWD_MISSING.bind(path=DisplayPath(target_cwd)),
1431
+ session_id=event.session_id,
1432
+ )
1433
+ )
1434
+ return
1435
+ cwd_warning = f"Working directory no longer exists: {surrogate_safe_text(target_cwd)}"
1436
+
1437
+ profile_name = event.profile_name or (meta.agent_profile if meta else "")
1438
+ saved_profile_id = meta.agent_profile_id if meta else ""
1439
+ profile_selector = event.profile_name or saved_profile_id or profile_name
1440
+ profile: AgentProfile | None = None
1441
+ profile_switch: tuple[str, str] | None = None
1442
+ profile_resolution_warning: Warning | None = None
1443
+ if profile_selector and self._agent_registry:
1444
+ if event.profile_name:
1445
+ resolved = self._agent_registry.resolve_selector(event.profile_name)
1446
+ elif saved_profile_id:
1447
+ resolved = self._agent_registry.get_by_id(
1448
+ saved_profile_id,
1449
+ disambiguating_name=profile_name,
1450
+ )
1451
+ else:
1452
+ resolved = self._agent_registry.get(profile_name)
1453
+ if resolved is not None:
1454
+ profile = resolved
1455
+ profile_name = resolved.name
1456
+ if event.profile_name and meta and meta.agent_profile and meta.agent_profile != resolved.name:
1457
+ from_display = meta.agent_display_name or meta.agent_profile
1458
+ to_display = resolved.display_name or resolved.name
1459
+ if _restore_profile_override_switch(
1460
+ state,
1461
+ from_profile=meta.agent_profile,
1462
+ from_display=from_display,
1463
+ to_profile=resolved.name,
1464
+ to_display=to_display,
1465
+ ):
1466
+ profile_switch = (from_display, to_display)
1467
+ else:
1468
+ saved_profile_name = profile_name
1469
+ if event.profile_name:
1470
+ logger.warning(
1471
+ "Requested agent profile '%s' could not be resolved; stopping session restore",
1472
+ event.profile_name,
1473
+ )
1474
+ await self._bus.publish(
1475
+ Error(
1476
+ code="requested_agent_profile_unresolved",
1477
+ message=(
1478
+ f"Requested agent profile '{event.profile_name}' could not be resolved; "
1479
+ "session restore was stopped."
1480
+ ),
1481
+ display_message=_RESTORE_REQUESTED_AGENT_PROFILE_UNRESOLVED.bind(
1482
+ profile=event.profile_name
1483
+ ),
1484
+ session_id=event.session_id,
1485
+ )
1486
+ )
1487
+ return
1488
+ unresolved_saved_identity = bool(saved_profile_id)
1489
+ saved_display = (meta.agent_display_name if meta else "") or saved_profile_name or saved_profile_id
1490
+ if self._session.agent_profile is not None:
1491
+ profile_name = self._session.agent_profile.name
1492
+ logger.warning(
1493
+ "Agent profile '%s' not found, keeping current profile '%s'",
1494
+ saved_profile_name,
1495
+ profile_name,
1496
+ )
1497
+ if unresolved_saved_identity:
1498
+ current_display = self._session.agent_profile.display_name or profile_name
1499
+ profile_resolution_warning = Warning(
1500
+ code="saved_agent_profile_unresolved",
1501
+ message=(
1502
+ f"Saved agent profile id '{saved_profile_id}' for '{saved_profile_name}' "
1503
+ f"could not be uniquely resolved; keeping current profile '{profile_name}'."
1504
+ ),
1505
+ display_message=_RESTORE_AGENT_PROFILE_UNRESOLVED_USING_CURRENT.bind(
1506
+ saved=saved_display,
1507
+ current=current_display,
1508
+ ),
1509
+ session_id=event.session_id,
1510
+ )
1511
+ elif unresolved_saved_identity:
1512
+ logger.warning(
1513
+ "Agent profile id '%s' for saved profile '%s' could not be uniquely resolved; "
1514
+ "stopping restore because no current profile is active",
1515
+ saved_profile_id,
1516
+ saved_profile_name,
1517
+ )
1518
+ await self._bus.publish(
1519
+ Error(
1520
+ code="saved_agent_profile_unresolved",
1521
+ message=(
1522
+ f"Saved agent profile id '{saved_profile_id}' for '{saved_profile_name}' "
1523
+ "could not be uniquely resolved, and no current profile is active."
1524
+ ),
1525
+ display_message=_RESTORE_AGENT_PROFILE_UNRESOLVED.bind(saved=saved_display),
1526
+ session_id=event.session_id,
1527
+ )
1528
+ )
1529
+ return
1530
+ else:
1531
+ available = self._agent_registry.list_profiles()
1532
+ if available:
1533
+ profile = available[0]
1534
+ logger.warning(
1535
+ "Agent profile '%s' not found, falling back to '%s'",
1536
+ saved_profile_name,
1537
+ profile.name,
1538
+ )
1539
+ profile_name = profile.name
1540
+
1541
+ if event.working_dirs is not None:
1542
+ # Client-supplied additionalDirectories are authoritative (ACP load
1543
+ # semantics): the list REPLACES the saved roots so a client can narrow,
1544
+ # swap, or clear (empty list) workspace scope. Keep the primary first to
1545
+ # mirror new-session storage; service-session compatibility canonicalizes
1546
+ # away the primary (see _extra_roots), so its inclusion here is cosmetic.
1547
+ merged_dirs = [target_cwd]
1548
+ seen_dirs: set[str] = {target_cwd}
1549
+ for path in event.working_dirs:
1550
+ if path and path not in seen_dirs:
1551
+ seen_dirs.add(path)
1552
+ merged_dirs.append(path)
1553
+ else:
1554
+ # Caller did not specify roots: keep the saved working_dirs verbatim (it may
1555
+ # include the primary cwd, and _can_restore_service_session compares the path
1556
+ # list exactly). A moved cwd invalidates the saved layout, so drop it.
1557
+ merged_dirs = [] if cwd_overridden else list(meta.working_dirs if meta else [])
1558
+ session_ws = Workspace(
1559
+ primary_cwd=target_cwd,
1560
+ working_dirs=_working_dirs_from_paths(merged_dirs, target_cwd),
1561
+ )
1562
+
1563
+ # The transition boundary is taken in two phases. The *prepared* fence
1564
+ # comes first: it shares the rebuild gate with settings reloads and
1565
+ # model switches, so everything read below — loaded settings, eval
1566
+ # context, session pins — is a committed state no concurrent rebuild
1567
+ # can move. Snapshotting before the fence would let a reload commit
1568
+ # during the load's thread hop and then be silently overwritten by a
1569
+ # staged load routed against the stale copy. But the *commit* — which
1570
+ # bumps the session generation and invalidates the old session's turn
1571
+ # state, retries and injection — waits until the load has succeeded:
1572
+ # an unreadable config file must abort the restore with the current
1573
+ # session genuinely intact, not admission-open but generation-dead.
1574
+ if not self._permits.current_task_owns_session_transition_permit():
1575
+ transition_owner = await self._permits.prepare_session_transition("restore")
1576
+ if transition_owner is None: # No owner filter was given, so this cannot happen.
1577
+ raise RuntimeError("Session transition acquisition unexpectedly failed")
1578
+
1579
+ # The restore crosses into the target session's project trust domain:
1580
+ # its settings are re-derived for that root so the build below reads
1581
+ # them, exactly as a workspace change would. Loaded here — before
1582
+ # anything is torn down — so an unreadable config file aborts the
1583
+ # restore with the current session intact.
1584
+ old_loaded = self._settings_handle.loaded
1585
+ candidate = await asyncio.to_thread(
1586
+ load_settings,
1587
+ project_root=Path(target_cwd),
1588
+ eval_context=self._reload_eval_context(),
1589
+ **self._session_pin_overrides(),
1590
+ )
1591
+ staged_loaded, _ = route_restart_settings(reattribute_command_line(candidate, old_loaded), old_loaded)
1592
+ if profile is None:
1593
+ profile = self._session.agent_profile
1594
+ if not profile_name and profile is not None:
1595
+ profile_name = profile.name
1596
+ if transition_owner is not None:
1597
+ self._permits.commit_session_transition(transition_owner)
1598
+ if restoring_current:
1599
+ await self.close_session_in_place()
1600
+ else:
1601
+ await self.close_session()
1602
+ self.reset_turn_runtime_after_session_shutdown()
1603
+ self.cleanup_empty_session_dir()
1604
+
1605
+ self._session.workspace = session_ws
1606
+
1607
+ if target_lock is not None:
1608
+ self._session.guard.install(event.session_id, target_lock)
1609
+ target_lock = None
1610
+
1611
+ await self._hydrate_restored_session(
1612
+ event=event,
1613
+ state=state,
1614
+ meta=meta,
1615
+ profile=profile,
1616
+ profile_name=profile_name,
1617
+ profile_switch=profile_switch,
1618
+ profile_resolution_warning=profile_resolution_warning,
1619
+ recovered_from_sidecar=recovered_from_sidecar,
1620
+ cwd_warning=cwd_warning,
1621
+ target_cwd=target_cwd,
1622
+ staged_loaded=staged_loaded,
1623
+ )
1624
+ except BaseException as exc:
1625
+ if transition_owner is not None:
1626
+ self._permits.finish_session_transition(transition_owner)
1627
+ transition_owner = None
1628
+ if isinstance(exc, Exception):
1629
+ # The interactive callers publish this event with the bus's
1630
+ # default swallow-and-log delivery: without a terminal event the
1631
+ # restore loading UI has nothing to clear it. Published after the
1632
+ # fence is released — an Error handler must not find the gate
1633
+ # still held by the failed restore.
1634
+ await self._bus.publish(
1635
+ Error(code="session_restore_failed", message=str(exc), session_id=event.session_id)
1636
+ )
1637
+ raise
1638
+ finally:
1639
+ if target_lock is not None:
1640
+ target_lock.release()
1641
+ if transition_owner is not None:
1642
+ self._permits.finish_session_transition(transition_owner)
1643
+
1644
+ async def _hydrate_restored_session(
1645
+ self,
1646
+ *,
1647
+ event: SessionRestore,
1648
+ state: dict[str, Any],
1649
+ meta: ChatSessionMeta | None,
1650
+ profile: AgentProfile | None,
1651
+ profile_name: str,
1652
+ profile_switch: tuple[str, str] | None,
1653
+ profile_resolution_warning: Warning | None,
1654
+ recovered_from_sidecar: bool,
1655
+ cwd_warning: str,
1656
+ target_cwd: str,
1657
+ staged_loaded: LoadedSettings,
1658
+ ) -> None:
1659
+ """Hydrate live engine state after restore shutdown while admission remains closed."""
1660
+ self._fsm.reset()
1661
+ self._session.adopt_restore_identity(session_id=event.session_id, recovered_from_sidecar=recovered_from_sidecar)
1662
+ session_dir = self._session.session_dir
1663
+ # The staged settings describe the target session; the live ones still
1664
+ # belong to the previous session until the build below commits them.
1665
+ self._workspace_change_tracker.restore(
1666
+ state.get("chrys_workspace_baseline"),
1667
+ self._session.workspace,
1668
+ resolve_scope=staged_loaded.settings.workspace_change_notice,
1669
+ )
1670
+
1671
+ spill_reconciliation = await self._reconcile_existing_spill_storage()
1672
+
1673
+ # Installing ``event.session_id`` above makes the engine session directory
1674
+ # available for the remainder of successful hydration. The policy reads
1675
+ # the staged settings: they are what the build below commits, and the
1676
+ # tracker hydrated here outlives that commit.
1677
+ snapshot_store = SnapshotStore(
1678
+ cast("Path", session_dir), policy=SnapshotPolicy.from_settings(staged_loaded.settings)
1679
+ )
1680
+ if state and state.get("chrys_mutations"):
1681
+ self._session.mutation_tracker = MutationTracker.deserialize(state["chrys_mutations"], snapshot_store)
1682
+ else:
1683
+ self._session.mutation_tracker = MutationTracker(snapshot_store)
1684
+ # Hydrate todos before ``restore_phase4_state`` below:
1685
+ # ``LastWordsState.restore_last_words`` re-captures the restored note's
1686
+ # todo section from the tracker.
1687
+ self._session.todo_tracker = TodoTracker()
1688
+ if state:
1689
+ await self._session.todo_tracker.restore(state.get("chrys_todos"))
1690
+ # The restored session gets its own coordinator (session id changed;
1691
+ # the previous one was closed during shutdown). Construction builds
1692
+ # it during ``start()`` below.
1693
+ self._session.mutation_coordinator = None
1694
+
1695
+ # Install the target session's runtime metadata BEFORE the build:
1696
+ # construction's ``_publish_results`` hydrates the fresh strategy from
1697
+ # ``engine.session.runtime_meta``, so leaving the previous session's metadata in
1698
+ # place would leak its calibration record into this session's strategy
1699
+ # whenever the build fingerprints happen to match.
1700
+ self._session.runtime_meta = SessionRuntimeMetadata.from_state_dict(state)
1701
+
1702
+ if profile is not None:
1703
+ rollback_token = None
1704
+ if event.apply_saved_model:
1705
+ staged_loaded, rollback_token = self._reapply_saved_model_profile(meta, profile, staged_loaded)
1706
+ try:
1707
+ await self.start(profile, operation="restore", staged_loaded=staged_loaded)
1708
+ except BaseException:
1709
+ if (
1710
+ rollback_token is not None
1711
+ and (self._current.loaded.bindings.backend if self._current.loaded is not None else None)
1712
+ is rollback_token.conversation
1713
+ ):
1714
+ _rollback_reapplied_model_profile(rollback_token)
1715
+ raise
1716
+ if profile_switch is not None and self._current.loaded is not None:
1717
+ self._current.loaded.reminder_middleware.sources.profile_switch.set_profile_switch(*profile_switch)
1718
+ else:
1719
+ # Nothing to build, so no commit will install the staged load; this
1720
+ # degenerate path installs it directly, like a reload with nothing
1721
+ # built.
1722
+ self._settings_handle.install(staged_loaded)
1723
+
1724
+ # The verdicts describe the target root's files — the same report a
1725
+ # reload or workspace change makes — published only now that the staged
1726
+ # load is in force (committed by the build above, or installed by the
1727
+ # degenerate path), under the restored session's id.
1728
+ restore_warnings = settings_warning_events(staged_loaded)
1729
+ if profile_resolution_warning is not None:
1730
+ restore_warnings.insert(0, profile_resolution_warning)
1731
+ for warning in restore_warnings:
1732
+ await self._bus.publish(replace(warning, session_id=self._session.session_id))
1733
+
1734
+ self._session.turn_number = state.get("turn_counter", 0) if state else 0
1735
+
1736
+ if self._current.loaded is not None and state:
1737
+ self._current.loaded.bindings.backend.history_state = state
1738
+ if self._current.loaded is not None:
1739
+ loaded = self._current.loaded
1740
+ restore_phase4_state(
1741
+ loaded.reminder_middleware,
1742
+ loaded.last_words,
1743
+ state,
1744
+ available_relative_paths=spill_reconciliation.available_relative_paths,
1745
+ )
1746
+ if meta and meta.service_session_id:
1747
+ if self._can_restore_service_session(meta):
1748
+ self._current.loaded.bindings.backend.service_session_id = meta.service_session_id
1749
+ else:
1750
+ self._current.loaded.bindings.backend.service_session_id = ""
1751
+ await self._bus.publish(
1752
+ Warning(
1753
+ code="service_session_incompatible",
1754
+ message=(
1755
+ "This session was saved with an OpenAI Responses service session. "
1756
+ "The active agent/model profile, workspace, service endpoint, or storage mode "
1757
+ "is not compatible, "
1758
+ f"so {APP_DISPLAY_NAME} will continue from local history only."
1759
+ ),
1760
+ display_message=_RESTORE_SERVICE_SESSION_INCOMPATIBLE.bind(app=APP_DISPLAY_NAME),
1761
+ session_id=event.session_id,
1762
+ )
1763
+ )
1764
+ stamp_history_item_ids(self._current.loaded.bindings.backend.history_state)
1765
+ self._history.bind(self._current.loaded.bindings.backend.history_state)
1766
+
1767
+ # ``engine.start`` above already hydrated the fresh strategy through
1768
+ # ``_publish_results``; this covers the no-rebuild path (``profile is
1769
+ # None``) where the surviving executor's strategy is the only target.
1770
+ # The provenance gate makes the repeat call idempotent.
1771
+ if (
1772
+ self._session.runtime_meta.context_calibration is not None
1773
+ and self._current.loaded.bindings.backend.compaction_strategy is not None
1774
+ ):
1775
+ self._session.runtime_meta.restore_context_calibration(
1776
+ self._current.loaded.bindings.backend.compaction_strategy,
1777
+ model_profile_fingerprint=self._current.manifest.model_profile_fingerprint,
1778
+ agent_profile_fingerprint=self._current.manifest.agent_profile_fingerprint,
1779
+ )
1780
+
1781
+ restored_history_changed = profile_switch is not None
1782
+ paused_records: list[dict[str, Any]] = []
1783
+ injected_paused_results = 0
1784
+ artifact_service = (
1785
+ SubAgentSessionArtifactService(self._session.session_dir)
1786
+ if self._session.session_dir is not None and self._current.loaded is not None
1787
+ else None
1788
+ )
1789
+ if artifact_service is not None and self._current.loaded is not None:
1790
+ paused_records = artifact_service.drain_paused_records()
1791
+ # Repair dangling sub-agent function_calls even when drain dropped or
1792
+ # quarantined every record (over-cap/corrupt): otherwise the parent
1793
+ # assistant call is left with no tool_result and the next turn is
1794
+ # provider-invalid. The live tool registry resolves dangling calls whose
1795
+ # record did not survive drain — records only enrich the error text.
1796
+ registry_sub_agent_names = (
1797
+ set(self._current.loaded.sub_agent_tools.tool_names())
1798
+ if (self._current.loaded is not None and self._current.loaded.sub_agent_tools is not None)
1799
+ else set()
1800
+ )
1801
+ record_sub_agent_names = {
1802
+ tool_name
1803
+ for record in paused_records
1804
+ if isinstance(tool_name := record.get("tool_name"), str) and tool_name
1805
+ }
1806
+ # Always attempt injection — never gate it on records/registry being
1807
+ # non-empty. A persisted sub-agent function_call self-identifies via its
1808
+ # ``_chrys_tool_kind`` marker, so a dangling call must be repaired even
1809
+ # when drain lost every record AND the restored profile no longer registers
1810
+ # the tool (removed/disabled/depth-skipped). The method self-gates on the
1811
+ # history being bound and does nothing when there is no dangling call.
1812
+ injected_paused_results = self._history.inject_error_results_for_sub_agents(
1813
+ paused_records, registry_sub_agent_names | record_sub_agent_names
1814
+ )
1815
+ restored_history_changed = restored_history_changed or injected_paused_results > 0
1816
+ if paused_records:
1817
+ logger.info(
1818
+ "Session restore: loaded %d paused sub-agent record(s), injected %d error tool_result(s)",
1819
+ len(paused_records),
1820
+ injected_paused_results,
1821
+ )
1822
+ discarded_tools = sorted({r.get("tool_name", "?") for r in paused_records})
1823
+ tool_names = ", ".join(discarded_tools)
1824
+ await self._bus.publish(
1825
+ Warning(
1826
+ code="sub_agents_reload_discarded",
1827
+ message=(
1828
+ f"{len(paused_records)} paused sub-agent(s) from a previous session were discarded: {tool_names}"
1829
+ ),
1830
+ display_message=_RESTORE_SUB_AGENTS_DISCARDED.bind(
1831
+ discarded=len(paused_records),
1832
+ names=DisplaySequence(tuple(discarded_tools)),
1833
+ ),
1834
+ session_id=event.session_id,
1835
+ ),
1836
+ )
1837
+
1838
+ if self._history.is_bound:
1839
+ awaiting_count = sum(
1840
+ 1
1841
+ for message in self._history.messages
1842
+ if message.additional_properties.get(HistoryMarkerKind.KEY) == HistoryMarkerKind.AWAITING_SUB_AGENTS
1843
+ )
1844
+ self._history.remove_awaiting_sub_agents_marker()
1845
+ restored_history_changed = restored_history_changed or awaiting_count > 0
1846
+ if (awaiting_count > 0 or injected_paused_results > 0) and self._history.trailing_status_marker() is None:
1847
+ self._history.insert_interrupted_marker(
1848
+ reason=format_message(SUB_AGENT_STATE_DISCARDED_MESSAGE.bind()),
1849
+ source="error",
1850
+ status_code=HistoryMarkerKind.STATUS_SUB_AGENT_STATE_DISCARDED,
1851
+ )
1852
+ restored_history_changed = True
1853
+ if artifact_service is not None:
1854
+ orphaned = artifact_service.reconcile_orphaned_running_logs(self._history.messages, paused_records)
1855
+ if orphaned:
1856
+ logger.info("Session restore: marked %d sub-agent audit log(s) orphaned", orphaned)
1857
+
1858
+ self._restore_terminal_fsm_from_history()
1859
+
1860
+ if restored_history_changed:
1861
+ saved = False
1862
+ try:
1863
+ saved = await self._writer.save_current_session(raise_on_error=bool(paused_records))
1864
+ except Exception:
1865
+ logger.warning("Session restore: failed to save repaired session; preserving paused sub-agent records")
1866
+ if saved and paused_records and artifact_service is not None:
1867
+ artifact_service.finalize_restored_paused_records(paused_records)
1868
+ elif paused_records and artifact_service is not None:
1869
+ artifact_service.archive_unconsumed_restored_paused_records(paused_records)
1870
+
1871
+ await self._bus.publish(
1872
+ SessionRestored(
1873
+ session_id=event.session_id,
1874
+ agent_profile=profile_name,
1875
+ display_name=profile.display_name if profile else "",
1876
+ initial_agent_profile=(
1877
+ meta.agent_profile_history[0]
1878
+ if meta is not None and meta.agent_profile_history
1879
+ else profile.display_name
1880
+ if profile
1881
+ else profile_name
1882
+ ),
1883
+ message_count=meta.message_count if meta else 0,
1884
+ cwd_warning=cwd_warning,
1885
+ primary_cwd=target_cwd,
1886
+ recovered_from_sidecar=recovered_from_sidecar,
1887
+ working_dirs=(
1888
+ [working_dir.path for working_dir in self._session.workspace.working_dirs]
1889
+ if self._session.workspace is not None
1890
+ else []
1891
+ ),
1892
+ ),
1893
+ )
1894
+ # Restore-time UsageUpdate must follow SessionRestored so the TUI has
1895
+ # already bound the new ``main_usage_source_id`` before classifying it as
1896
+ # the session window — otherwise the chat panel keeps stale window tokens
1897
+ # from the previous session. Route through the ordered chain so any
1898
+ # pending sub-agent UsageUpdate that was in-flight before the switch can't
1899
+ # overtake this restored snapshot.
1900
+ self._usage_publisher.enqueue_usage_event(self._usage_publisher.make_usage_event(session_id=event.session_id))
1901
+ await self._fire_session_restored_hook(restored_session_id=event.session_id, profile_name=profile_name)
1902
+
1903
+ async def _fire_session_restored_hook(
1904
+ self,
1905
+ *,
1906
+ restored_session_id: str,
1907
+ profile_name: str,
1908
+ ) -> None:
1909
+ if self._session.hook_manager is None:
1910
+ return
1911
+ from chrys.service.hooks.events import HookEvent
1912
+
1913
+ if not self._session.hook_manager.has_hooks_for(HookEvent.SESSION_RESTORED):
1914
+ return
1915
+ await self._session.hook_manager.fire(
1916
+ HookEvent.SESSION_RESTORED,
1917
+ {
1918
+ "session_id": self._session.session_id,
1919
+ "profile": profile_name,
1920
+ "cwd": self._workspace_cwd(),
1921
+ "restored_session_id": restored_session_id,
1922
+ },
1923
+ scope="session",
1924
+ )
1925
+
1926
+ async def on_session_delete(self, event: SessionDelete) -> None:
1927
+ """Handle session deletion."""
1928
+ failure = await self._delete_session_reporting(event.session_id)
1929
+ if failure is not None:
1930
+ await self._bus.publish(
1931
+ Error(code=failure.code, message=failure.message, session_id=event.session_id),
1932
+ )
1933
+
1934
+ async def _delete_session_reporting(self, session_id: str) -> _SessionDeleteFailure | None:
1935
+ """Delete *session_id* from disk, detaching the engine first when it is the active session.
1936
+
1937
+ Publishes ``SessionDeleted`` on success and returns ``None``; on failure
1938
+ returns the reason with the engine's session ownership restored, leaving
1939
+ the caller to report it (``on_session_delete`` publishes the raw code,
1940
+ ``on_session_clear`` folds it into ``session_clear_failed``).
1941
+ """
1942
+ if self._persistence.state_store is None:
1943
+ return _SessionDeleteFailure(code="no_state_store", message="State store not configured")
1944
+
1945
+ release_current_lock = self._session.guard.owns(session_id)
1946
+ detached_session_id = self._session.session_id if release_current_lock else None
1947
+ otel_sink = None
1948
+ if release_current_lock:
1949
+ from chrys.foundation.observability.sink import get_otel_sink
1950
+
1951
+ # Deleting the live session is its end: fire ``session_end`` now and
1952
+ # wait for it (async hooks included), while the id and the files still
1953
+ # exist, so hooks keep the "before teardown" contract; the shutdown
1954
+ # that follows (clear / delete-current -> new) skips the duplicate. A
1955
+ # failed delete re-arms it below — the session then lives on and hooks
1956
+ # see one early ``session_end`` plus the real one at shutdown. This
1957
+ # duplicate is accepted by design: exactly-once would need the hook to
1958
+ # run only after the delete is known to succeed, i.e. under the store's
1959
+ # write lock from a thread (rejected — user hooks may touch the store),
1960
+ # and NOT re-arming would instead drop the real ``session_end`` of a
1961
+ # session that keeps going, which loses more than a repeat costs.
1962
+ await self.fire_session_end_hooks()
1963
+ # The trajectory writer holds the log's lease, and a leased directory
1964
+ # is tombstoned instead of removed — close it so the delete below is
1965
+ # physical.
1966
+ await self.close_trajectory_log()
1967
+ otel_sink = get_otel_sink()
1968
+ self._session.detach_for_delete()
1969
+
1970
+ delete_succeeded = False
1971
+ # Not cancellation-safe on purpose: the delete runs in a thread and keeps
1972
+ # going if this task is cancelled, and ``CancelledError`` skips the restore
1973
+ # branches below, leaving the engine detached with the guard held. The
1974
+ # only publisher of ``SessionClear``/``SessionDelete`` is a MainScreen
1975
+ # worker, which Textual cancels solely on app exit — and there a detached
1976
+ # engine is exactly right: ``shutdown()`` must not save (resurrect) a
1977
+ # session whose deletion may have just completed. Restoring ``_session_id``
1978
+ # blindly on cancel would do precisely that; shield-and-reconcile would only
1979
+ # release a guard and publish an ack inside an app that is going away.
1980
+ try:
1981
+ await self._persistence.delete_session(session_id, allow_active=release_current_lock)
1982
+ delete_succeeded = True
1983
+ except TimeoutError as exc:
1984
+ if release_current_lock:
1985
+ self._session.reattach_after_failed_delete(detached_session_id)
1986
+ return _SessionDeleteFailure(
1987
+ code="session_busy",
1988
+ message=f"Timed out waiting for session write lock: {exc}",
1989
+ )
1990
+ return _SessionDeleteFailure(
1991
+ code="session_in_use",
1992
+ message=self._session.guard.conflict_message(session_id),
1993
+ )
1994
+ except Exception as exc:
1995
+ logger.exception("Failed to delete session %s", session_id)
1996
+ if release_current_lock:
1997
+ self._session.reattach_after_failed_delete(detached_session_id)
1998
+ return _SessionDeleteFailure(code="session_delete_failed", message=f"Failed to delete session: {exc}")
1999
+ finally:
2000
+ if release_current_lock and delete_succeeded:
2001
+ self._session.guard.release()
2002
+
2003
+ # Post-delete cleanup runs only after the delete has committed. Keep it out
2004
+ # of the try above: a failure here must not reach the delete-failure handler
2005
+ # (which would wrongly restore engine.session.session_id to a now-deleted session)
2006
+ # nor be reported as a delete failure.
2007
+ if release_current_lock and otel_sink is not None:
2008
+ try:
2009
+ otel_sink.deactivate()
2010
+ except Exception:
2011
+ logger.exception("Failed to deactivate OTel sink after deleting session %s", session_id)
2012
+ await self._bus.publish(SessionDeleted(session_id=session_id))
2013
+ return None