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,2132 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """StateStore — protocol and implementations for session persistence."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import json
10
+ import logging
11
+ import os as os
12
+ import threading
13
+ import time
14
+ from dataclasses import replace
15
+ from datetime import UTC, datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable, Collection
21
+
22
+ from chrys.foundation.config.settings import resolve_sessions_dir
23
+ from chrys.foundation.models.session_surface import SessionSurface
24
+ from chrys.foundation.platform.files import atomic_write_text as _common_atomic_write_text
25
+ from chrys.foundation.trajectory.ids import new_analytics_id
26
+ from chrys.service.state._fork import SessionForkMixin
27
+ from chrys.service.state._session_files import _MRU_SWEEP_SLACK as _MRU_SWEEP_SLACK
28
+ from chrys.service.state._session_files import RAW_HTTP_LOG_FILE_NAME as RAW_HTTP_LOG_FILE_NAME
29
+ from chrys.service.state._session_files import (
30
+ SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS as SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS,
31
+ )
32
+ from chrys.service.state._session_files import SESSION_BACKUP_FILE_NAME as SESSION_BACKUP_FILE_NAME
33
+ from chrys.service.state._session_files import SESSION_CHECKPOINT_ID_KEY as SESSION_CHECKPOINT_ID_KEY
34
+ from chrys.service.state._session_files import SESSION_FILE_NAME as SESSION_FILE_NAME
35
+ from chrys.service.state._session_files import SESSION_FORK_MAX_ID_ATTEMPTS as SESSION_FORK_MAX_ID_ATTEMPTS
36
+ from chrys.service.state._session_files import SESSION_RECOVERY_FILE_NAME as SESSION_RECOVERY_FILE_NAME
37
+ from chrys.service.state._session_files import SESSION_SCHEMA_VERSION as SESSION_SCHEMA_VERSION
38
+ from chrys.service.state._session_files import SESSION_WRITE_LOCK_TIMEOUT_SECONDS as SESSION_WRITE_LOCK_TIMEOUT_SECONDS
39
+ from chrys.service.state._session_files import FileLock as FileLock
40
+ from chrys.service.state._session_files import SessionCheckpoint as SessionCheckpoint
41
+ from chrys.service.state._session_files import SessionForkError as SessionForkError
42
+ from chrys.service.state._session_files import SessionNotFoundError as SessionNotFoundError
43
+ from chrys.service.state._session_files import _atomic_write_text as _atomic_write_text
44
+ from chrys.service.state._session_files import _ensure_lock_parent as _ensure_lock_parent
45
+ from chrys.service.state._session_files import _is_string_keyed_dict as _is_string_keyed_dict
46
+ from chrys.service.state._session_files import _session_short_id as _session_short_id
47
+ from chrys.service.state._session_files import atomic_copy_file as atomic_copy_file
48
+ from chrys.service.state._session_files import legacy_session_files as legacy_session_files
49
+ from chrys.service.state._session_files import make_junction_dropping_ignore as make_junction_dropping_ignore
50
+ from chrys.service.state._session_files import parse_snapshot_turn as parse_snapshot_turn
51
+ from chrys.service.state._session_files import session_active_lock_path as session_active_lock_path
52
+ from chrys.service.state._session_files import session_active_owner_path as session_active_owner_path
53
+ from chrys.service.state._session_files import session_checkpoint_of as session_checkpoint_of
54
+ from chrys.service.state._session_files import session_dir_candidates as session_dir_candidates
55
+ from chrys.service.state._session_files import session_dir_has_artifacts as session_dir_has_artifacts
56
+ from chrys.service.state._session_files import session_write_lock_path as session_write_lock_path
57
+ from chrys.service.state._session_meta import ChatSessionMeta as ChatSessionMeta
58
+ from chrys.service.state._session_meta import SessionMeta as SessionMeta
59
+ from chrys.service.state._session_meta import SessionMetaMixin, copy_session_meta, resolve_session_kind
60
+ from chrys.service.state._session_meta import WorkflowSessionMeta as WorkflowSessionMeta
61
+ from chrys.service.state._session_meta import _earliest_history_created_at as _earliest_history_created_at
62
+ from chrys.service.state._session_meta import _extract_title as _extract_title
63
+ from chrys.service.state._session_meta import _first_message_created_at as _first_message_created_at
64
+ from chrys.service.state._session_meta import _is_visible_message as _is_visible_message
65
+ from chrys.service.state._session_meta import _message_created_at as _message_created_at
66
+ from chrys.service.state._session_meta import _parse_session_timestamp as _parse_session_timestamp
67
+ from chrys.service.state._session_meta import recorded_surface as recorded_surface
68
+ from chrys.service.state.serializers import deserialize_state, serialize_state
69
+ from chrys.service.state.session_catalog import (
70
+ CatalogEntry,
71
+ FileSignature,
72
+ RunListing,
73
+ RunListingKey,
74
+ SessionCatalogFile,
75
+ file_signature,
76
+ is_recordable,
77
+ )
78
+ from chrys.service.state.session_listing import (
79
+ SESSION_PAGE_SIZE,
80
+ SessionListing,
81
+ SessionListingEntry,
82
+ SessionPage,
83
+ page_slice,
84
+ )
85
+ from chrys.service.state.session_mru import SessionMruEntry, SessionMruIndex, coerce_utc, sort_entries
86
+ from chrys.service.state.workflow import WorkflowSessionState
87
+ from chrys.service.workflows.history import read_workflow_meta, read_workflow_run
88
+ from chrys.service.workflows.layout import EVENTS_FILE, HEADER_FILE, run_dir
89
+
90
+ logger = logging.getLogger(__name__)
91
+
92
+ # The catalog is rewritten whole, so after its first merge a process merges a
93
+ # trickle of new entries (the session a turn just changed) at most this often;
94
+ # a batch worth sharing (a cold listing) merges at once. Unmerged entries are
95
+ # only a peer's cache miss. Pruning a gone session's entry is never deferred:
96
+ # it holds prompt excerpts.
97
+ SESSION_CATALOG_COMMIT_INTERVAL_SECONDS = 60.0
98
+ SESSION_CATALOG_EAGER_COMMIT_ENTRIES = 16
99
+
100
+
101
+ @runtime_checkable
102
+ class StateStore(Protocol):
103
+ """Protocol for session state persistence."""
104
+
105
+ def session_dir(self, session_id: str) -> Path: ...
106
+ async def save_session(
107
+ self,
108
+ session_id: str,
109
+ state: dict[str, Any],
110
+ *,
111
+ agent_profile: str = "",
112
+ agent_display_name: str = "",
113
+ agent_profile_id: str = "",
114
+ agent_profile_fingerprint: str = "",
115
+ primary_cwd: str = "",
116
+ working_dirs: list[str] | None = None,
117
+ title: str = "",
118
+ agent_profile_history: list[str] | None = None,
119
+ model_provider: str = "",
120
+ model_api_style: str | None = None,
121
+ model_id: str = "",
122
+ model_profile_id: str = "",
123
+ model_base_url: str = "",
124
+ model_profile_fingerprint: str | None = None,
125
+ service_session_id: str | None = None,
126
+ parent_session_id: str = "",
127
+ last_surface: SessionSurface | None = None,
128
+ ) -> SessionCheckpoint | None: ...
129
+ async def save_workflow_session(
130
+ self, session_id: str, state: WorkflowSessionState, *, title: str = ""
131
+ ) -> SessionCheckpoint | None: ...
132
+
133
+ def save_recovery_session(
134
+ self,
135
+ session_id: str,
136
+ state: dict[str, Any],
137
+ *,
138
+ agent_profile: str = "",
139
+ agent_display_name: str = "",
140
+ agent_profile_id: str = "",
141
+ agent_profile_fingerprint: str = "",
142
+ primary_cwd: str = "",
143
+ working_dirs: list[str] | None = None,
144
+ title: str = "",
145
+ agent_profile_history: list[str] | None = None,
146
+ model_provider: str = "",
147
+ model_api_style: str | None = None,
148
+ model_id: str = "",
149
+ model_profile_id: str = "",
150
+ model_base_url: str = "",
151
+ model_profile_fingerprint: str | None = None,
152
+ parent_session_id: str = "",
153
+ last_surface: SessionSurface | None = None,
154
+ ) -> None: ...
155
+ async def load_recovery_session(self, session_id: str) -> dict[str, Any] | None: ...
156
+ async def load_recovery_session_meta(self, session_id: str) -> SessionMeta | None: ...
157
+ def delete_recovery_session(self, session_id: str) -> None: ...
158
+ async def recovery_session_wins(self, session_id: str) -> bool: ...
159
+ async def load_workflow_session(self, session_id: str) -> WorkflowSessionState | None: ...
160
+ async def load_session(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None: ...
161
+ async def load_session_raw(
162
+ self,
163
+ session_id: str,
164
+ *,
165
+ prefer_recovery: bool = False,
166
+ ) -> list[dict[str, Any]] | None: ...
167
+ async def load_session_meta(
168
+ self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
169
+ ) -> SessionMeta | None: ...
170
+
171
+ async def list_sessions(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]: ...
172
+ async def load_latest_session_id(self, *, chat_only: bool = False) -> str | None: ...
173
+ async def open_session_listing(self, *, kind: Literal["chat", "workflow"]) -> SessionListing: ...
174
+ async def load_session_page(
175
+ self,
176
+ listing: SessionListing,
177
+ *,
178
+ surfaces: Collection[SessionSurface],
179
+ page: int = 1,
180
+ page_size: int = SESSION_PAGE_SIZE,
181
+ ) -> SessionPage: ...
182
+ async def update_session_titles(
183
+ self,
184
+ session_id: str,
185
+ *,
186
+ custom_title: str | None = None,
187
+ generated_title: str | None = None,
188
+ ) -> SessionMeta | None: ...
189
+ def fork_session(self, parent_session_id: str, *, last_surface: SessionSurface | None = None) -> str: ...
190
+ async def delete_session(self, session_id: str, *, allow_active: bool = False) -> None: ...
191
+
192
+
193
+ def _dir_size(path: Path) -> int:
194
+ """Total bytes of the regular files under *path*; unreadable parts count as empty.
195
+
196
+ An explicit ``os.scandir`` stack: entry types come from the directory
197
+ listing, so only files cost a ``stat``, and links and junctions are
198
+ never followed.
199
+ """
200
+ total = 0
201
+ pending = [os.fspath(path)]
202
+ while pending:
203
+ try:
204
+ with os.scandir(pending.pop()) as entries:
205
+ for entry in entries:
206
+ try:
207
+ if entry.is_junction() or entry.is_symlink():
208
+ continue
209
+ if entry.is_dir(follow_symlinks=False):
210
+ pending.append(entry.path)
211
+ elif entry.is_file(follow_symlinks=False):
212
+ total += entry.stat(follow_symlinks=False).st_size
213
+ except OSError:
214
+ continue
215
+ except OSError:
216
+ continue
217
+ return total
218
+
219
+
220
+ def _is_run_name(run_id: str) -> bool:
221
+ """Run ids come from storage; a corrupt envelope must not escape its session folder."""
222
+ return bool(run_id) and Path(run_id).name == run_id and run_id not in (".", "..")
223
+
224
+
225
+ def _newer_of(current: SessionMruEntry | None, candidate: SessionMruEntry) -> SessionMruEntry:
226
+ return candidate if current is None else sort_entries([current, candidate])[0]
227
+
228
+
229
+ class JsonFileStateStore(SessionForkMixin, SessionMetaMixin):
230
+ """JSON file-based state store.
231
+
232
+ Stores sessions as ``{short_session_id}/session.json`` folders with a
233
+ sidecar backup and root-level locks for cross-process coordination.
234
+ """
235
+
236
+ def __init__(self, directory: str | Path | None = None) -> None:
237
+ self._dir = resolve_sessions_dir() if directory is None else Path(directory)
238
+ self._dir.mkdir(parents=True, exist_ok=True)
239
+ # Custom titles set before a session's first save. A never-saved
240
+ # session must not be materialized on disk just for its title (it
241
+ # would list as a ghost entry and may still be discarded), so the
242
+ # title waits here until the first full save merges it.
243
+ self._pending_custom_titles: dict[str, str] = {}
244
+ # Listing cache: folder name -> catalog entry (``session.json``
245
+ # signature + listing meta without size). Holds ONLY the small
246
+ # ``SessionMeta`` dataclasses — the multi-MB envelope payloads are
247
+ # parsed transiently and dropped — so memory stays bounded even with
248
+ # thousands of sessions. Entries also come from, and are merged back
249
+ # into, the persisted catalog (``session_catalog.py``); ``_catalog_pending``
250
+ # names the ones not yet written there, ``_catalog_removals_owed`` the
251
+ # deleted or vanished sessions not yet removed from it. Legacy flat files are cached
252
+ # separately and never persisted. Accessed from ``asyncio.to_thread``
253
+ # workers, so all reads/writes/evictions take ``_meta_cache_lock``; a
254
+ # lost check-then-set race costs at most one redundant re-parse.
255
+ self._meta_cache: dict[str, CatalogEntry] = {}
256
+ self._legacy_meta_cache: dict[str, tuple[FileSignature, SessionMeta]] = {}
257
+ self._meta_cache_lock = threading.Lock()
258
+ self._catalog = SessionCatalogFile(self._dir)
259
+ self._catalog_signature: FileSignature | None = None
260
+ self._catalog_pending: set[str] = set()
261
+ self._catalog_removals_owed: set[str] = set()
262
+ self._catalog_committed_at: float | None = None
263
+ # The surface-carry fields of each sidecar this store wrote, keyed by
264
+ # its folder (the short id: callers pass either id form) and matched
265
+ # on the file's signature so checkpoints never re-parse their own
266
+ # sidecar; one another process wrote (or rewrote) is parsed once.
267
+ # Only read and written under the session's write lock.
268
+ self._written_recovery_meta: dict[str, tuple[FileSignature, dict[str, Any]]] = {}
269
+ # Derived per-root MRU index (``session_mru.json``) so ``/resume``
270
+ # can find the newest session without parsing every envelope.
271
+ # Session files stay authoritative; every index update is
272
+ # best-effort via ``_note_mru`` and never fails a session operation.
273
+ # Writers record *before* committing the envelope (inside the
274
+ # session write lock) so a crash can only leave the index ahead of
275
+ # disk — a state the lookup's verification tolerates — never behind.
276
+ # Whatever still bypasses the index (an older chrys, a copied
277
+ # folder, a record that failed) is caught by the lookup's sweep of
278
+ # folders modified after the indexed winner.
279
+ self._mru = SessionMruIndex(self._dir)
280
+ self._sweep_trajectory_tombstones()
281
+
282
+ def _sweep_trajectory_tombstones(self) -> None:
283
+ """Finish logical deletes whose writer has since released its lease."""
284
+ from chrys.service.trajectory.tombstone import sweep_tombstones
285
+
286
+ try:
287
+ removed = sweep_tombstones(self._dir)
288
+ except Exception:
289
+ logger.debug("Trajectory tombstone sweep failed under %s", self._dir, exc_info=True)
290
+ return
291
+ if removed:
292
+ logger.info("Removed %d tombstoned session director%s", removed, "y" if removed == 1 else "ies")
293
+
294
+ def session_dir(self, session_id: str) -> Path:
295
+ """Return the folder for a session.
296
+
297
+ Delegates folder-name derivation to :func:`chrys.foundation.util.session_ids.session_short_id`
298
+ so callers that don't hold a store instance (engine fallback,
299
+ agent_builder fallback) produce bit-identical paths.
300
+ """
301
+ return self._dir / _session_short_id(session_id)
302
+
303
+ # Keep private alias for internal callers.
304
+ _session_dir = session_dir
305
+
306
+ def _session_file(self, session_id: str) -> Path:
307
+ """Return the session.json path inside the session folder."""
308
+ return self._session_dir(session_id) / SESSION_FILE_NAME
309
+
310
+ def _backup_file(self, session_id: str) -> Path:
311
+ """Return the session.json recovery backup path."""
312
+ return self._session_dir(session_id) / SESSION_BACKUP_FILE_NAME
313
+
314
+ def _recovery_file(self, session_id: str) -> Path:
315
+ """Return the crash-recovery sidecar path."""
316
+ return self._session_dir(session_id) / SESSION_RECOVERY_FILE_NAME
317
+
318
+ def _write_lock_path(self, session_id: str) -> Path:
319
+ """Return the write lock path for this store/session."""
320
+ path = session_write_lock_path(self._dir, session_id)
321
+ _ensure_lock_parent(path)
322
+ return path
323
+
324
+ def active_lock_path(self, session_id: str) -> Path:
325
+ """Return the long-lived active-session lock path."""
326
+ path = session_active_lock_path(self._dir, session_id)
327
+ _ensure_lock_parent(path)
328
+ return path
329
+
330
+ def active_owner_path(self, session_id: str) -> Path:
331
+ """Return the active-session owner metadata path."""
332
+ path = session_active_owner_path(self._dir, session_id)
333
+ _ensure_lock_parent(path)
334
+ return path
335
+
336
+ def _legacy_path(self, session_id: str) -> Path:
337
+ """Return the old flat-file path for migration fallback."""
338
+ return self._dir / f"{_session_short_id(session_id)}.json"
339
+
340
+ def _legacy_session_files(self) -> list[Path]:
341
+ """Root-level ``*.json`` legacy flat-file sessions (never the MRU index)."""
342
+ return legacy_session_files(self._dir)
343
+
344
+ def _note_mru[T](self, update: Callable[[], T]) -> T | None:
345
+ """Apply a best-effort MRU index update; never fails the session operation.
346
+
347
+ On an I/O error the index is invalidated so the next ``/resume``
348
+ rebuilds it from a full scan. A lock timeout means a stuck peer:
349
+ invalidating would only wait on the same lock again inside the
350
+ session write lock, so the update is just dropped — the lookup's
351
+ modified-folder sweep still finds the committed session. ``None``
352
+ is returned in place of the update's result either way.
353
+ """
354
+ try:
355
+ return update()
356
+ except TimeoutError:
357
+ logger.warning("Session MRU index %s is locked; skipping update", self._mru.path)
358
+ return None
359
+ except Exception:
360
+ logger.warning("Failed to update session MRU index %s", self._mru.path, exc_info=True)
361
+ with contextlib.suppress(Exception):
362
+ self._mru.invalidate()
363
+ return None
364
+
365
+ def _record_mru(self, session_id: str, updated_at: datetime | None) -> None:
366
+ """Index *session_id* at *updated_at*; call this before the envelope commit."""
367
+ if updated_at is None:
368
+ return
369
+ self._note_mru(lambda: self._mru.record(session_id, updated_at))
370
+
371
+ def _migrate_if_needed_unlocked(self, session_id: str) -> None:
372
+ """Move a legacy flat-file session into folder format on first access."""
373
+ legacy = self._legacy_path(session_id)
374
+ session_file = self._session_file(session_id)
375
+ if legacy.exists() and not session_file.exists():
376
+ session_file.parent.mkdir(parents=True, exist_ok=True)
377
+ # Record before the rename like every other commit: a backfill
378
+ # scan racing this migration can miss the file in both its
379
+ # folder and its legacy enumeration, and only the index entry
380
+ # keeps the session from vanishing behind a complete index.
381
+ self._record_mru(session_id, self._envelope_updated_at(self._read_json_file(legacy)))
382
+ legacy.rename(session_file)
383
+
384
+ def _migrate_if_needed(self, session_id: str) -> None:
385
+ """Move a legacy flat-file session into folder format on first access."""
386
+ if not self._legacy_path(session_id).exists():
387
+ return
388
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
389
+ self._migrate_if_needed_unlocked(session_id)
390
+
391
+ def _resolve_session_file(self, session_id: str) -> Path | None:
392
+ """Return the session.json path, migrating from legacy if needed."""
393
+ self._migrate_if_needed(session_id)
394
+ path = self._session_file(session_id)
395
+ return path if path.exists() else None
396
+
397
+ def resolve_session_file(self, session_id: str) -> Path | None:
398
+ """The session's ``session.json`` path; None when it has none.
399
+
400
+ A session still in the legacy flat layout is migrated first, so this
401
+ may write to disk and raise the I/O and lock errors migration raises.
402
+ """
403
+ return self._resolve_session_file(session_id)
404
+
405
+ @staticmethod
406
+ def _read_json_file(path: Path) -> dict[str, Any] | None:
407
+ """Read a JSON object from *path*, returning ``None`` on invalid input."""
408
+ try:
409
+ raw = json.loads(path.read_text(encoding="utf-8"))
410
+ except json.JSONDecodeError:
411
+ logger.warning("Invalid JSON in session file %s", path)
412
+ return None
413
+ except OSError:
414
+ return None
415
+ return raw if isinstance(raw, dict) else None
416
+
417
+ def _snapshot_recovery_files(self, session_id: str) -> list[Path]:
418
+ """Return rollback snapshots newest-first for last-ditch session recovery."""
419
+ snap_dir = self._session_dir(session_id) / "snapshots"
420
+ if not snap_dir.is_dir():
421
+ return []
422
+
423
+ return sorted(
424
+ (p for p in snap_dir.glob("*.json") if parse_snapshot_turn(p) >= 1),
425
+ key=parse_snapshot_turn,
426
+ reverse=True,
427
+ )
428
+
429
+ def _read_session_envelope(
430
+ self,
431
+ session_id: str,
432
+ *,
433
+ heal: bool = True,
434
+ migrate: bool = True,
435
+ include_snapshots: bool = True,
436
+ ) -> dict[str, Any] | None:
437
+ """Read a session envelope, falling back to backup/snapshots if needed."""
438
+ envelope, _source = self._read_session_envelope_with_source(
439
+ session_id, heal=heal, migrate=migrate, include_snapshots=include_snapshots
440
+ )
441
+ return envelope
442
+
443
+ def _read_session_envelope_with_source(
444
+ self,
445
+ session_id: str,
446
+ *,
447
+ heal: bool = True,
448
+ migrate: bool = True,
449
+ include_snapshots: bool = True,
450
+ ) -> tuple[dict[str, Any] | None, Literal["primary", "backup", "snapshot"]]:
451
+ """Read a session envelope and name the file it came from."""
452
+ if migrate:
453
+ path = self._resolve_session_file(session_id)
454
+ else:
455
+ session_file = self._session_file(session_id)
456
+ path = session_file if session_file.exists() else None
457
+ backup = self._backup_file(session_id)
458
+
459
+ candidates: list[tuple[Path, Literal["primary", "backup", "snapshot"]]] = []
460
+ if path is not None:
461
+ candidates.append((path, "primary"))
462
+ if backup.exists():
463
+ candidates.append((backup, "backup"))
464
+ if include_snapshots:
465
+ candidates.extend((snapshot, "snapshot") for snapshot in self._snapshot_recovery_files(session_id))
466
+ if not candidates:
467
+ return None, "primary"
468
+
469
+ for candidate, source in candidates:
470
+ envelope = self._read_json_file(candidate)
471
+ if envelope is None:
472
+ continue
473
+ if heal and candidate != path:
474
+ self._heal_session_file_from_candidate(session_id, self._session_file(session_id), envelope, candidate)
475
+ return envelope, source
476
+ return None, "primary"
477
+
478
+ def _heal_session_file_from_candidate(
479
+ self,
480
+ session_id: str,
481
+ path: Path,
482
+ envelope: dict[str, Any],
483
+ candidate: Path,
484
+ ) -> None:
485
+ """Best-effort repair of a corrupt primary session file."""
486
+ try:
487
+ payload = json.dumps(envelope, indent=2, ensure_ascii=False)
488
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
489
+ current = self._read_json_file(path)
490
+ if current is None:
491
+ _atomic_write_text(path, payload)
492
+ _atomic_write_text(self._backup_file(session_id), payload)
493
+ logger.warning("Recovered corrupt session %s from %s", session_id, candidate)
494
+ except Exception:
495
+ logger.warning("Failed to heal session %s from %s", session_id, candidate, exc_info=True)
496
+
497
+ @staticmethod
498
+ def _inheritable_meta(
499
+ session_id: str,
500
+ envelope: dict[str, Any] | None,
501
+ ) -> dict[str, Any] | None:
502
+ """Return metadata when the live envelope can be attributed to the session."""
503
+ if envelope is None:
504
+ return None
505
+ meta = envelope.get("meta")
506
+ if not isinstance(meta, dict) or meta.get("session_id") != session_id:
507
+ return None
508
+ return meta
509
+
510
+ @classmethod
511
+ def _title_patch_meta(
512
+ cls,
513
+ session_id: str,
514
+ envelope: dict[str, Any] | None,
515
+ ) -> dict[str, Any] | None:
516
+ """Return attributable metadata whose required timestamps are parseable."""
517
+ meta = cls._inheritable_meta(session_id, envelope)
518
+ if meta is None:
519
+ return None
520
+ created_at = _parse_session_timestamp(meta.get("created_at"))
521
+ updated_at = _parse_session_timestamp(meta.get("updated_at"))
522
+ if created_at is None or updated_at is None:
523
+ return None
524
+ return meta
525
+
526
+ def _read_inherited_live_meta_unlocked(
527
+ self,
528
+ session_id: str,
529
+ ) -> tuple[dict[str, Any], str | None, bool]:
530
+ """Return live metadata, creation time, and whether primary supplied the meta.
531
+
532
+ This is deliberately separate from ``_read_session_envelope``: save
533
+ inheritance must continue past a parseable but malformed primary to a
534
+ healthy backup, while a timestamp defect repairs only that field. The
535
+ immutable creation time may come from the backup when the selected
536
+ primary meta lacks it. Rollback snapshots are never consulted.
537
+ """
538
+ inherited_meta: dict[str, Any] | None = None
539
+ inherited_from_primary = False
540
+ created_at: str | None = None
541
+ primary_file = self._session_file(session_id)
542
+ for path in (primary_file, self._backup_file(session_id)):
543
+ envelope = self._read_json_file(path)
544
+ meta = self._inheritable_meta(session_id, envelope)
545
+ if meta is None:
546
+ continue
547
+ if inherited_meta is None:
548
+ inherited_meta = meta
549
+ inherited_from_primary = path == primary_file
550
+ value = meta.get("created_at")
551
+ if created_at is None and isinstance(value, str) and _parse_session_timestamp(value) is not None:
552
+ created_at = value
553
+ if inherited_meta is meta and created_at is not None:
554
+ break
555
+ return inherited_meta or {}, created_at, inherited_from_primary
556
+
557
+ def _read_recovery_meta_unlocked(self, session_id: str) -> dict[str, Any]:
558
+ """Return current crash-recovery sidecar metadata, best-effort."""
559
+ envelope = self._read_json_file(self._recovery_file(session_id))
560
+ return self._inheritable_meta(session_id, envelope) or {}
561
+
562
+ def _effective_title_unlocked(
563
+ self,
564
+ session_id: str,
565
+ existing_meta: dict[str, Any],
566
+ recovery_meta: dict[str, Any],
567
+ *,
568
+ title_key: str,
569
+ prefer_recovery: bool,
570
+ ) -> str:
571
+ """Resolve one title without making healthy saves parse the sidecar.
572
+
573
+ Pending custom titles are explicit user actions made after the live
574
+ metadata was read, so they always win until a primary write consumes
575
+ them. Recovery titles outrank live titles only when the primary was
576
+ rejected and the live metadata came from backup (or did not exist).
577
+ """
578
+ if title_key == "custom_title" and session_id in self._pending_custom_titles:
579
+ return self._pending_custom_titles[session_id]
580
+
581
+ existing_value = existing_meta.get(title_key)
582
+ recovery_value = recovery_meta.get(title_key)
583
+ candidates = (recovery_value, existing_value) if prefer_recovery else (existing_value, recovery_value)
584
+ return next((value for value in candidates if isinstance(value, str)), "")
585
+
586
+ def _carried_surface_unlocked(
587
+ self,
588
+ session_id: str,
589
+ existing_meta: dict[str, Any],
590
+ recovery_meta: dict[str, Any],
591
+ *,
592
+ inherited_from_primary: bool,
593
+ ) -> str | None:
594
+ """The recorded surface an unmarked save keeps; ``None`` when none was ever recorded.
595
+
596
+ Only a turn names the surface, so every other save carries the stored
597
+ value verbatim (one written by a newer version survives), as
598
+ ``recorded_surface`` picks it; consulting the sidecar parses only one
599
+ this store did not write itself.
600
+ """
601
+ if inherited_from_primary and not recovery_meta:
602
+ recovery_meta = self._recovery_carry_meta_unlocked(session_id)
603
+ return recorded_surface(existing_meta, recovery_meta, sidecar_first=not inherited_from_primary)
604
+
605
+ def _recovery_carry_meta_unlocked(self, session_id: str) -> dict[str, Any]:
606
+ """The sidecar's ``updated_at``/``last_surface``; empty when there is no sidecar."""
607
+ signature = file_signature(self._recovery_file(session_id))
608
+ if signature is None:
609
+ return {}
610
+ written = self._written_recovery_meta.get(_session_short_id(session_id))
611
+ if written is not None and written[0] == signature:
612
+ return written[1]
613
+ return self._read_recovery_meta_unlocked(session_id)
614
+
615
+ def _remember_written_recovery_meta_unlocked(self, session_id: str, meta: dict[str, Any]) -> None:
616
+ signature = file_signature(self._recovery_file(session_id))
617
+ if signature is None:
618
+ self._written_recovery_meta.pop(_session_short_id(session_id), None)
619
+ return
620
+ carried = {key: meta[key] for key in ("updated_at", "last_surface") if key in meta}
621
+ self._written_recovery_meta[_session_short_id(session_id)] = (signature, carried)
622
+
623
+ @staticmethod
624
+ def _resolve_created_at(
625
+ live_created_at: str | None,
626
+ recovery_meta: dict[str, Any],
627
+ state: dict[str, Any],
628
+ *,
629
+ now: str,
630
+ ) -> str:
631
+ """Resolve the immutable session creation timestamp for a full save."""
632
+ if live_created_at is not None:
633
+ return live_created_at
634
+
635
+ recovery_created_at = _parse_session_timestamp(recovery_meta.get("created_at"))
636
+ history_created_at = _earliest_history_created_at(state)
637
+ fallback_candidates = [
638
+ timestamp for timestamp in (recovery_created_at, history_created_at) if timestamp is not None
639
+ ]
640
+ if fallback_candidates:
641
+ return min(fallback_candidates).isoformat()
642
+ return now
643
+
644
+ def _build_session_envelope(
645
+ self,
646
+ session_id: str,
647
+ state: dict[str, Any],
648
+ *,
649
+ agent_profile: str = "",
650
+ agent_display_name: str = "",
651
+ agent_profile_id: str = "",
652
+ agent_profile_fingerprint: str = "",
653
+ primary_cwd: str = "",
654
+ working_dirs: list[str] | None = None,
655
+ title: str = "",
656
+ agent_profile_history: list[str] | None = None,
657
+ model_provider: str = "",
658
+ model_api_style: str | None = None,
659
+ model_id: str = "",
660
+ model_profile_id: str = "",
661
+ model_base_url: str = "",
662
+ model_profile_fingerprint: str | None = None,
663
+ service_session_id: str | None = None,
664
+ parent_session_id: str = "",
665
+ last_surface: SessionSurface | None = None,
666
+ kind: Literal["chat", "workflow"] = "chat",
667
+ force_updated_at: bool = False,
668
+ ) -> dict[str, Any]:
669
+ """Build the persisted session envelope shared by primary and recovery saves."""
670
+ now = datetime.now(UTC).isoformat()
671
+ existing_meta, live_created_at, inherited_from_primary = self._read_inherited_live_meta_unlocked(session_id)
672
+
673
+ if existing_meta and resolve_session_kind(existing_meta) != kind:
674
+ raise ValueError("A session cannot change between chat and workflow.")
675
+
676
+ # Healthy primary metadata already owns both title fields and the
677
+ # immutable creation time, so the common checkpoint path must not
678
+ # parse a potentially multi-megabyte recovery envelope. Consult it
679
+ # only for fallback data. A pending custom title always wins until a primary save.
680
+ needs_recovery_meta = (
681
+ not inherited_from_primary
682
+ or live_created_at is None
683
+ or "custom_title" not in existing_meta
684
+ or "generated_title" not in existing_meta
685
+ )
686
+ recovery_meta = self._read_recovery_meta_unlocked(session_id) if needs_recovery_meta else {}
687
+ carry_custom_title = self._effective_title_unlocked(
688
+ session_id,
689
+ existing_meta,
690
+ recovery_meta,
691
+ title_key="custom_title",
692
+ prefer_recovery=not inherited_from_primary,
693
+ )
694
+ carry_generated_title = self._effective_title_unlocked(
695
+ session_id,
696
+ existing_meta,
697
+ recovery_meta,
698
+ title_key="generated_title",
699
+ prefer_recovery=not inherited_from_primary,
700
+ )
701
+
702
+ messages = state.get("messages", [])
703
+ new_count = sum(1 for m in messages if _is_visible_message(m))
704
+ old_count = existing_meta.get("message_count", 0)
705
+ created_at = self._resolve_created_at(
706
+ live_created_at,
707
+ recovery_meta,
708
+ state,
709
+ now=now,
710
+ )
711
+ # Normal saves preserve updated_at when visible count is unchanged.
712
+ # Recovery saves always advance it so recency can arbitrate sidecars.
713
+ existing_updated_at = existing_meta.get("updated_at")
714
+ parsed_existing_updated_at = _parse_session_timestamp(existing_updated_at)
715
+ preserve_updated_at = (
716
+ not force_updated_at
717
+ and kind == "chat"
718
+ and new_count == old_count
719
+ and isinstance(existing_updated_at, str)
720
+ and parsed_existing_updated_at is not None
721
+ )
722
+ updated_at = existing_updated_at if preserve_updated_at else now
723
+ parsed_created_at = _parse_session_timestamp(created_at)
724
+ parsed_updated_at = _parse_session_timestamp(updated_at)
725
+ updated_at_floor = parsed_created_at
726
+ if force_updated_at and parsed_existing_updated_at is not None:
727
+ # Recovery arbitration requires a strict ``>`` and stale-sidecar
728
+ # GC deletes on ``<=``. The 1µs advance is therefore intentional:
729
+ # do not simplify this to max(now, parsed_existing_updated_at),
730
+ # because equality would discard the new recovery checkpoint.
731
+ recovery_floor = parsed_existing_updated_at + timedelta(microseconds=1)
732
+ updated_at_floor = max(updated_at_floor, recovery_floor) if updated_at_floor is not None else recovery_floor
733
+ if updated_at_floor is not None and parsed_updated_at is not None and parsed_updated_at < updated_at_floor:
734
+ updated_at = updated_at_floor.isoformat()
735
+ # Import lazily to avoid a circular import at module load:
736
+ # ``chrys/__init__.py`` resolves the installed package version,
737
+ # which would otherwise be pulled in at state-store import time.
738
+ from chrys import __version__ as _app_version
739
+ from chrys.foundation.platform import get_platform
740
+
741
+ # Platform snapshot is derived here (not taken from the caller)
742
+ # so every save stamps the *actual* runtime the file was written
743
+ # on — the cached ``get_platform()`` is cheap and consistent
744
+ # across saves within a process.
745
+ plat = get_platform()
746
+
747
+ meta = {
748
+ "schema_version": SESSION_SCHEMA_VERSION,
749
+ "app_version": _app_version,
750
+ "os_name": plat.os_name,
751
+ "arch": plat.arch,
752
+ "session_id": session_id,
753
+ "created_at": created_at,
754
+ "updated_at": updated_at,
755
+ "kind": kind,
756
+ "primary_cwd": primary_cwd or existing_meta.get("primary_cwd", ""),
757
+ "working_dirs": working_dirs if working_dirs is not None else existing_meta.get("working_dirs", []),
758
+ "title": title or _extract_title(messages) or existing_meta.get("title", ""),
759
+ "custom_title": carry_custom_title,
760
+ "generated_title": carry_generated_title,
761
+ }
762
+ if kind == "chat":
763
+ meta.update(
764
+ {
765
+ "model_provider": model_provider or existing_meta.get("model_provider", ""),
766
+ "model_api_style": model_api_style
767
+ if model_api_style is not None
768
+ else existing_meta.get("model_api_style", ""),
769
+ "model_id": model_id or existing_meta.get("model_id", ""),
770
+ "model_profile_id": model_profile_id,
771
+ "model_base_url": model_base_url or existing_meta.get("model_base_url", ""),
772
+ "model_profile_fingerprint": model_profile_fingerprint
773
+ if model_profile_fingerprint is not None
774
+ else existing_meta.get("model_profile_fingerprint", ""),
775
+ "service_session_id": service_session_id
776
+ if service_session_id is not None
777
+ else existing_meta.get("service_session_id", ""),
778
+ "agent_profile": agent_profile or existing_meta.get("agent_profile", ""),
779
+ "agent_display_name": agent_display_name
780
+ or existing_meta.get("agent_display_name")
781
+ or existing_meta.get("display_name", ""),
782
+ "agent_profile_id": agent_profile_id,
783
+ "agent_profile_fingerprint": agent_profile_fingerprint
784
+ or existing_meta.get("agent_profile_fingerprint", ""),
785
+ "message_count": new_count,
786
+ "agent_profile_history": agent_profile_history
787
+ if agent_profile_history is not None
788
+ else existing_meta.get("agent_profile_history", existing_meta.get("profile_history", [])),
789
+ "parent_session_id": parent_session_id or existing_meta.get("parent_session_id", ""),
790
+ }
791
+ )
792
+ surface = (
793
+ last_surface.value
794
+ if last_surface is not None
795
+ else self._carried_surface_unlocked(
796
+ session_id, existing_meta, recovery_meta, inherited_from_primary=inherited_from_primary
797
+ )
798
+ )
799
+ if surface is not None:
800
+ meta["last_surface"] = surface
801
+ return {
802
+ "meta": meta,
803
+ "state": state if kind == "workflow" else serialize_state(state),
804
+ # A derived trajectory summary names the revision via this id and content hash.
805
+ SESSION_CHECKPOINT_ID_KEY: new_analytics_id(),
806
+ }
807
+
808
+ def _delete_recovery_session_unlocked(self, session_id: str) -> None:
809
+ """Best-effort deletion of the recovery sidecar."""
810
+ self._written_recovery_meta.pop(_session_short_id(session_id), None)
811
+ with contextlib.suppress(OSError):
812
+ self._recovery_file(session_id).unlink()
813
+
814
+ def _delete_recovery_session_if_not_newer(
815
+ self,
816
+ session_id: str,
817
+ *,
818
+ primary_updated_at: datetime | None,
819
+ ) -> None:
820
+ """Best-effort stale sidecar GC, rechecking under the write lock."""
821
+ try:
822
+ with FileLock(self._write_lock_path(session_id), timeout=0.0):
823
+ current = self._read_json_file(self._recovery_file(session_id))
824
+ current_updated_at = self._envelope_updated_at(current)
825
+ if (
826
+ current is None
827
+ or current_updated_at is None
828
+ or (primary_updated_at is not None and current_updated_at <= primary_updated_at)
829
+ ):
830
+ self._delete_recovery_session_unlocked(session_id)
831
+ except TimeoutError:
832
+ logger.debug("Timed out deleting stale recovery sidecar for %s", session_id, exc_info=True)
833
+ except OSError:
834
+ logger.debug("Failed to delete stale recovery sidecar for %s", session_id, exc_info=True)
835
+
836
+ def _write_session_envelope_unlocked(self, session_id: str, data: dict[str, Any]) -> SessionCheckpoint:
837
+ """Write primary + backup session envelopes atomically.
838
+
839
+ The primary write is authoritative: failures propagate so callers know
840
+ the save did not complete. The backup is best-effort and can lag by one
841
+ successful write, but is itself updated atomically when it succeeds.
842
+ Returns the checkpoint identity of the bytes written.
843
+ """
844
+ payload = json.dumps(data, indent=2, ensure_ascii=False)
845
+ written = _atomic_write_text(self._session_file(session_id), payload)
846
+ self._delete_recovery_session_unlocked(session_id)
847
+ try:
848
+ _atomic_write_text(self._backup_file(session_id), payload)
849
+ except OSError:
850
+ logger.warning("Failed to update session backup for %s", session_id, exc_info=True)
851
+ return session_checkpoint_of(data, written)
852
+
853
+ def _save_session_sync(
854
+ self,
855
+ session_id: str,
856
+ state: dict[str, Any] | WorkflowSessionState,
857
+ *,
858
+ agent_profile: str = "",
859
+ agent_display_name: str = "",
860
+ agent_profile_id: str = "",
861
+ agent_profile_fingerprint: str = "",
862
+ primary_cwd: str = "",
863
+ working_dirs: list[str] | None = None,
864
+ title: str = "",
865
+ agent_profile_history: list[str] | None = None,
866
+ model_provider: str = "",
867
+ model_api_style: str | None = None,
868
+ model_id: str = "",
869
+ model_profile_id: str = "",
870
+ model_base_url: str = "",
871
+ model_profile_fingerprint: str | None = None,
872
+ service_session_id: str | None = None,
873
+ parent_session_id: str = "",
874
+ last_surface: SessionSurface | None = None,
875
+ kind: Literal["chat", "workflow"] = "chat",
876
+ ) -> SessionCheckpoint:
877
+ """Sync implementation of session save (runs in a thread)."""
878
+ if isinstance(state, WorkflowSessionState):
879
+ state = state.encode()
880
+ elif kind == "workflow":
881
+ raise TypeError("Workflow saves require WorkflowSessionState.")
882
+ path = self._session_file(session_id)
883
+ path.parent.mkdir(parents=True, exist_ok=True)
884
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
885
+ self._migrate_if_needed_unlocked(session_id)
886
+ data = self._build_session_envelope(
887
+ session_id,
888
+ state,
889
+ agent_profile=agent_profile,
890
+ agent_display_name=agent_display_name,
891
+ agent_profile_id=agent_profile_id,
892
+ agent_profile_fingerprint=agent_profile_fingerprint,
893
+ primary_cwd=primary_cwd,
894
+ working_dirs=working_dirs,
895
+ title=title,
896
+ agent_profile_history=agent_profile_history,
897
+ model_provider=model_provider,
898
+ model_api_style=model_api_style,
899
+ model_id=model_id,
900
+ model_profile_id=model_profile_id,
901
+ model_base_url=model_base_url,
902
+ model_profile_fingerprint=model_profile_fingerprint,
903
+ service_session_id=service_session_id,
904
+ parent_session_id=parent_session_id,
905
+ last_surface=last_surface,
906
+ kind=kind,
907
+ force_updated_at=self._recovery_file(session_id).exists(),
908
+ )
909
+
910
+ # Index first, commit second: a save that kept ``updated_at``
911
+ # (unchanged visible count) is a no-op for the index.
912
+ self._record_mru(session_id, self._envelope_updated_at(data))
913
+ checkpoint = self._write_session_envelope_unlocked(session_id, data)
914
+ # The primary envelope now owns the title; a stale pending entry
915
+ # must not resurrect it after a later clear.
916
+ self._pending_custom_titles.pop(session_id, None)
917
+ return checkpoint
918
+
919
+ async def save_session(
920
+ self,
921
+ session_id: str,
922
+ state: dict[str, Any],
923
+ *,
924
+ agent_profile: str = "",
925
+ agent_display_name: str = "",
926
+ agent_profile_id: str = "",
927
+ agent_profile_fingerprint: str = "",
928
+ primary_cwd: str = "",
929
+ working_dirs: list[str] | None = None,
930
+ title: str = "",
931
+ agent_profile_history: list[str] | None = None,
932
+ model_provider: str = "",
933
+ model_api_style: str | None = None,
934
+ model_id: str = "",
935
+ model_profile_id: str = "",
936
+ model_base_url: str = "",
937
+ model_profile_fingerprint: str | None = None,
938
+ service_session_id: str | None = None,
939
+ parent_session_id: str = "",
940
+ last_surface: SessionSurface | None = None,
941
+ ) -> SessionCheckpoint:
942
+ """Checkpoint Chat history and model metadata into the session folder.
943
+
944
+ The session kind is fixed on its first save.
945
+ """
946
+ return await asyncio.to_thread(
947
+ self._save_session_sync,
948
+ session_id,
949
+ state,
950
+ agent_profile=agent_profile,
951
+ agent_display_name=agent_display_name,
952
+ agent_profile_id=agent_profile_id,
953
+ agent_profile_fingerprint=agent_profile_fingerprint,
954
+ primary_cwd=primary_cwd,
955
+ working_dirs=working_dirs,
956
+ title=title,
957
+ agent_profile_history=agent_profile_history,
958
+ model_provider=model_provider,
959
+ model_api_style=model_api_style,
960
+ model_id=model_id,
961
+ model_profile_id=model_profile_id,
962
+ model_base_url=model_base_url,
963
+ model_profile_fingerprint=model_profile_fingerprint,
964
+ service_session_id=service_session_id,
965
+ parent_session_id=parent_session_id,
966
+ last_surface=last_surface,
967
+ )
968
+
969
+ async def save_workflow_session(
970
+ self, session_id: str, state: WorkflowSessionState, *, title: str = ""
971
+ ) -> SessionCheckpoint:
972
+ """Checkpoint a Workflow's typed state without Chat metadata or a second workspace input."""
973
+ return await asyncio.to_thread(
974
+ self._save_session_sync,
975
+ session_id,
976
+ state,
977
+ primary_cwd=state.workspace.primary_cwd,
978
+ working_dirs=[directory.path for directory in state.workspace.working_dirs],
979
+ title=title,
980
+ kind="workflow",
981
+ )
982
+
983
+ def save_recovery_session(
984
+ self,
985
+ session_id: str,
986
+ state: dict[str, Any],
987
+ *,
988
+ agent_profile: str = "",
989
+ agent_display_name: str = "",
990
+ agent_profile_id: str = "",
991
+ agent_profile_fingerprint: str = "",
992
+ primary_cwd: str = "",
993
+ working_dirs: list[str] | None = None,
994
+ title: str = "",
995
+ agent_profile_history: list[str] | None = None,
996
+ model_provider: str = "",
997
+ model_api_style: str | None = None,
998
+ model_id: str = "",
999
+ model_profile_id: str = "",
1000
+ model_base_url: str = "",
1001
+ model_profile_fingerprint: str | None = None,
1002
+ parent_session_id: str = "",
1003
+ last_surface: SessionSurface | None = None,
1004
+ ) -> None:
1005
+ """Synchronously write a crash-recovery sidecar for an in-flight turn."""
1006
+ recovery_file = self._recovery_file(session_id)
1007
+ recovery_file.parent.mkdir(parents=True, exist_ok=True)
1008
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
1009
+ self._migrate_if_needed_unlocked(session_id)
1010
+ data = self._build_session_envelope(
1011
+ session_id,
1012
+ state,
1013
+ agent_profile=agent_profile,
1014
+ agent_display_name=agent_display_name,
1015
+ agent_profile_id=agent_profile_id,
1016
+ agent_profile_fingerprint=agent_profile_fingerprint,
1017
+ primary_cwd=primary_cwd,
1018
+ working_dirs=working_dirs,
1019
+ title=title,
1020
+ agent_profile_history=agent_profile_history,
1021
+ model_provider=model_provider,
1022
+ model_api_style=model_api_style,
1023
+ model_id=model_id,
1024
+ model_profile_id=model_profile_id,
1025
+ model_base_url=model_base_url,
1026
+ model_profile_fingerprint=model_profile_fingerprint,
1027
+ service_session_id="",
1028
+ parent_session_id=parent_session_id,
1029
+ last_surface=last_surface,
1030
+ force_updated_at=True,
1031
+ )
1032
+ payload = json.dumps(data, indent=2, ensure_ascii=False)
1033
+ self._record_mru(session_id, self._envelope_updated_at(data))
1034
+ _common_atomic_write_text(recovery_file, payload)
1035
+ self._remember_written_recovery_meta_unlocked(session_id, data["meta"])
1036
+
1037
+ @staticmethod
1038
+ def _envelope_updated_at(envelope: dict[str, Any] | None) -> datetime | None:
1039
+ """Return the envelope updated_at timestamp when it is parseable."""
1040
+ if envelope is None:
1041
+ return None
1042
+ meta = envelope.get("meta", {})
1043
+ if not isinstance(meta, dict):
1044
+ return None
1045
+ return _parse_session_timestamp(meta.get("updated_at"))
1046
+
1047
+ def _resolve_effective_envelope_with_source(
1048
+ self,
1049
+ session_id: str,
1050
+ *,
1051
+ prefer_recovery: bool = False,
1052
+ ) -> tuple[dict[str, Any] | None, Literal["recovery", "primary", "backup", "snapshot"]]:
1053
+ """Return the effective envelope and the file it came from, without healing recovery into primary."""
1054
+ self._migrate_if_needed(session_id)
1055
+ recovery_path = self._recovery_file(session_id)
1056
+ if prefer_recovery and recovery_path.exists():
1057
+ recovery = self._read_json_file(recovery_path)
1058
+ if recovery is not None:
1059
+ primary_path = self._session_file(session_id)
1060
+ primary = self._read_json_file(primary_path) if primary_path.exists() else None
1061
+ recovery_updated_at = self._envelope_updated_at(recovery)
1062
+ primary_updated_at = self._envelope_updated_at(primary)
1063
+ if primary is None:
1064
+ return recovery, "recovery"
1065
+ if recovery_updated_at is not None and (
1066
+ primary_updated_at is None or recovery_updated_at > primary_updated_at
1067
+ ):
1068
+ return recovery, "recovery"
1069
+ self._delete_recovery_session_if_not_newer(session_id, primary_updated_at=primary_updated_at)
1070
+ else:
1071
+ self._delete_recovery_session_if_not_newer(session_id, primary_updated_at=None)
1072
+ return self._read_session_envelope_with_source(session_id)
1073
+
1074
+ def _resolve_effective_envelope(
1075
+ self,
1076
+ session_id: str,
1077
+ *,
1078
+ prefer_recovery: bool = False,
1079
+ ) -> dict[str, Any] | None:
1080
+ """Return the effective envelope, optionally preferring a fresh recovery sidecar."""
1081
+ envelope, _source = self._resolve_effective_envelope_with_source(
1082
+ session_id,
1083
+ prefer_recovery=prefer_recovery,
1084
+ )
1085
+ return envelope
1086
+
1087
+ def _recovery_session_wins_sync(self, session_id: str) -> bool:
1088
+ """Return whether the crash-recovery sidecar is the winning effective source."""
1089
+ _envelope, source = self._resolve_effective_envelope_with_source(session_id, prefer_recovery=True)
1090
+ return source == "recovery"
1091
+
1092
+ async def recovery_session_wins(self, session_id: str) -> bool:
1093
+ """Return whether the crash-recovery sidecar would win when recovery is allowed."""
1094
+ return await asyncio.to_thread(self._recovery_session_wins_sync, session_id)
1095
+
1096
+ def _active_lock_is_held(self, session_id: str) -> bool:
1097
+ """Return True when another live owner, or this process, holds the active-session lock."""
1098
+ lock = FileLock(self.active_lock_path(session_id), timeout=0.0)
1099
+ try:
1100
+ lock.acquire()
1101
+ except TimeoutError:
1102
+ return True
1103
+ else:
1104
+ lock.release()
1105
+ return False
1106
+
1107
+ def _load_recovery_session_sync(self, session_id: str) -> dict[str, Any] | None:
1108
+ """Sync implementation of direct recovery sidecar load."""
1109
+ try:
1110
+ raw = self._read_json_file(self._recovery_file(session_id))
1111
+ if raw is None:
1112
+ return None
1113
+ if resolve_session_kind(raw["meta"]) != "chat":
1114
+ raise ValueError("Chat loading requires a Chat session.")
1115
+ return deserialize_state(raw.get("state", {}))
1116
+ except KeyError:
1117
+ return None
1118
+
1119
+ async def load_recovery_session(self, session_id: str) -> dict[str, Any] | None:
1120
+ """Load only the recovery sidecar state, if present."""
1121
+ return await asyncio.to_thread(self._load_recovery_session_sync, session_id)
1122
+
1123
+ def _load_recovery_session_meta_sync(self, session_id: str) -> SessionMeta | None:
1124
+ """Sync implementation of direct recovery sidecar metadata load."""
1125
+ try:
1126
+ raw = self._read_json_file(self._recovery_file(session_id))
1127
+ if raw is None or "meta" not in raw:
1128
+ return None
1129
+ directory = self._session_dir(session_id)
1130
+ return self._session_meta_from_envelope(
1131
+ raw,
1132
+ size_bytes=_dir_size(directory),
1133
+ )
1134
+ except KeyError, OSError:
1135
+ return None
1136
+
1137
+ async def load_recovery_session_meta(self, session_id: str) -> SessionMeta | None:
1138
+ """Load only the recovery sidecar metadata, if present."""
1139
+ return await asyncio.to_thread(self._load_recovery_session_meta_sync, session_id)
1140
+
1141
+ def delete_recovery_session(self, session_id: str) -> None:
1142
+ """Delete the recovery sidecar, if present."""
1143
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
1144
+ self._delete_recovery_session_unlocked(session_id)
1145
+
1146
+ def _load_workflow_session_sync(self, session_id: str) -> WorkflowSessionState | None:
1147
+ raw = self._resolve_effective_envelope(session_id, prefer_recovery=False)
1148
+ if raw is None:
1149
+ return None
1150
+ if resolve_session_kind(raw["meta"]) != "workflow":
1151
+ raise ValueError("Workflow loading requires a Workflow session.")
1152
+ return WorkflowSessionState.decode(raw.get("state"))
1153
+
1154
+ async def load_workflow_session(self, session_id: str) -> WorkflowSessionState | None:
1155
+ """Validate the envelope once, then return typed workflow state directly."""
1156
+ return await asyncio.to_thread(self._load_workflow_session_sync, session_id)
1157
+
1158
+ def _load_session_sync(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None:
1159
+ """Sync implementation of session load (runs in a thread)."""
1160
+ try:
1161
+ raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
1162
+ if raw is None:
1163
+ return None
1164
+ if resolve_session_kind(raw["meta"]) != "chat":
1165
+ raise ValueError("Chat loading requires a Chat session.")
1166
+ return deserialize_state(raw.get("state", {}))
1167
+ except KeyError:
1168
+ return None
1169
+
1170
+ async def load_session(self, session_id: str, *, prefer_recovery: bool = False) -> dict[str, Any] | None:
1171
+ """Load session state from the session folder."""
1172
+ return await asyncio.to_thread(self._load_session_sync, session_id, prefer_recovery=prefer_recovery)
1173
+
1174
+ def _load_session_raw_sync(
1175
+ self,
1176
+ session_id: str,
1177
+ *,
1178
+ prefer_recovery: bool = False,
1179
+ ) -> list[dict[str, Any]] | None:
1180
+ """Sync implementation of raw session load (runs in a thread)."""
1181
+ try:
1182
+ raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
1183
+ if raw is None:
1184
+ return None
1185
+ state = raw.get("state", {})
1186
+ messages = state.get("messages", [])
1187
+ # Normalize: ensure each message has role + contents as dicts.
1188
+ # Skip every message compaction excluded, deliberately: tool
1189
+ # calls it summarized or removed, a dropped current turn's
1190
+ # in-between text, and turns folded into a compressed block.
1191
+ # Tool summaries stay in this raw list as the model's context;
1192
+ # transcript replays drop them too (``is_compaction_tool_summary``),
1193
+ # so the reopened session transcript shows neither a summary nor
1194
+ # the messages it replaced. Compressed blocks keep their own
1195
+ # archived copy and render it separately.
1196
+ result = []
1197
+ for msg in messages:
1198
+ ap = msg.get("additional_properties", {})
1199
+ if ap.get("_excluded", False):
1200
+ continue
1201
+ contents = []
1202
+ for c in msg.get("contents", []):
1203
+ if isinstance(c, str):
1204
+ # Legacy plain-string format
1205
+ if c.startswith("Content(type="):
1206
+ contents.append({"type": "legacy", "raw": c})
1207
+ else:
1208
+ contents.append({"type": "text", "text": c})
1209
+ elif isinstance(c, dict):
1210
+ contents.append(c)
1211
+ entry: dict[str, Any] = {"role": msg.get("role", ""), "contents": contents}
1212
+ if msg.get("additional_properties"):
1213
+ entry["additional_properties"] = msg["additional_properties"]
1214
+ result.append(entry)
1215
+ return result
1216
+ except KeyError:
1217
+ return None
1218
+
1219
+ async def load_session_raw(
1220
+ self,
1221
+ session_id: str,
1222
+ *,
1223
+ prefer_recovery: bool = False,
1224
+ ) -> list[dict[str, Any]] | None:
1225
+ """Load raw serialized messages for replay.
1226
+
1227
+ Returns:
1228
+ A list of message dicts or ``None`` if the session does not
1229
+ exist. Intermediate text (agent text returned alongside tool
1230
+ calls) is embedded per-message in
1231
+ ``additional_properties["_intermediate_text"]``.
1232
+ """
1233
+ return await asyncio.to_thread(self._load_session_raw_sync, session_id, prefer_recovery=prefer_recovery)
1234
+
1235
+ def _load_session_meta_sync(
1236
+ self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
1237
+ ) -> SessionMeta | None:
1238
+ """Sync implementation of single-session meta load (runs in a thread)."""
1239
+ try:
1240
+ raw = self._resolve_effective_envelope(session_id, prefer_recovery=prefer_recovery)
1241
+ if raw is None:
1242
+ if strict and session_dir_has_artifacts(self._session_dir(session_id)):
1243
+ raise ValueError(f"Session {session_id!r} has no readable checkpoint.")
1244
+ return None
1245
+ if "meta" not in raw:
1246
+ raise ValueError(f"Session {session_id!r} is missing metadata.")
1247
+ directory = self._session_dir(session_id)
1248
+ return self._session_meta_from_envelope(
1249
+ raw,
1250
+ size_bytes=_dir_size(directory),
1251
+ )
1252
+ except KeyError, OSError, ValueError, TypeError:
1253
+ if strict:
1254
+ raise
1255
+ return None
1256
+
1257
+ async def load_session_meta(
1258
+ self, session_id: str, *, prefer_recovery: bool = False, strict: bool = False
1259
+ ) -> SessionMeta | None:
1260
+ """Load metadata for one specific session by id.
1261
+
1262
+ Unlike :py:meth:`list_sessions`, this resolves the session file
1263
+ directly via :py:meth:`pathlib.Path.exists`, avoiding the Windows
1264
+ ``iterdir`` directory cache that can briefly hide a just-written
1265
+ session folder.
1266
+
1267
+ Explicit restore can request strict validation: missing sessions return
1268
+ None, while unreadable checkpoints and invalid state retain their error.
1269
+ Lists and legacy Chat callers keep their tolerant behavior.
1270
+ """
1271
+ return await asyncio.to_thread(
1272
+ self._load_session_meta_sync, session_id, prefer_recovery=prefer_recovery, strict=strict
1273
+ )
1274
+
1275
+ def _patch_recovery_titles_unlocked(
1276
+ self,
1277
+ session_id: str,
1278
+ *,
1279
+ custom_title: str | None,
1280
+ generated_title: str | None,
1281
+ ) -> None:
1282
+ """Mirror title patches into a live crash-recovery sidecar, best-effort."""
1283
+ recovery_file = self._recovery_file(session_id)
1284
+ if not recovery_file.exists():
1285
+ return
1286
+ try:
1287
+ envelope = self._read_json_file(recovery_file)
1288
+ if envelope is None:
1289
+ return
1290
+ meta = self._inheritable_meta(session_id, envelope)
1291
+ if meta is None:
1292
+ return
1293
+ if custom_title is not None:
1294
+ meta["custom_title"] = custom_title
1295
+ if generated_title is not None:
1296
+ meta["generated_title"] = generated_title
1297
+ payload = json.dumps(envelope, indent=2, ensure_ascii=False)
1298
+ _common_atomic_write_text(recovery_file, payload)
1299
+ self._remember_written_recovery_meta_unlocked(session_id, meta)
1300
+ except OSError:
1301
+ logger.debug("Failed to mirror titles into recovery sidecar for %s", session_id, exc_info=True)
1302
+
1303
+ def _update_session_titles_sync(
1304
+ self,
1305
+ session_id: str,
1306
+ *,
1307
+ custom_title: str | None = None,
1308
+ generated_title: str | None = None,
1309
+ ) -> SessionMeta | None:
1310
+ """Sync implementation of the meta-only title patch (runs in a thread)."""
1311
+ session_file = self._session_file(session_id)
1312
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
1313
+ self._migrate_if_needed_unlocked(session_id)
1314
+ envelope = self._read_session_envelope(
1315
+ session_id,
1316
+ heal=False,
1317
+ migrate=False,
1318
+ include_snapshots=False,
1319
+ )
1320
+ meta = self._title_patch_meta(session_id, envelope)
1321
+ if envelope is None or meta is None:
1322
+ # Never materialize or rewrite a session file just for a title
1323
+ # patch when no semantically valid live envelope is available.
1324
+ # Auto-generated titles are simply dropped (the next turn
1325
+ # regenerates one); a custom title is held in memory until
1326
+ # a later full save can merge it — or discarded with the
1327
+ # session if it never saves. A recovery-only session
1328
+ # additionally gets the patch mirrored into its sidecar so
1329
+ # the edit survives the process (the envelope builder reads
1330
+ # it back on the next save).
1331
+ if custom_title is not None:
1332
+ has_persisted_target = any(
1333
+ path.exists()
1334
+ for path in (
1335
+ session_file,
1336
+ self._backup_file(session_id),
1337
+ self._recovery_file(session_id),
1338
+ )
1339
+ )
1340
+ if custom_title or has_persisted_target:
1341
+ self._pending_custom_titles[session_id] = custom_title
1342
+ else:
1343
+ self._pending_custom_titles.pop(session_id, None)
1344
+ self._patch_recovery_titles_unlocked(
1345
+ session_id,
1346
+ custom_title=custom_title,
1347
+ generated_title=None,
1348
+ )
1349
+ return None
1350
+ session_file.parent.mkdir(parents=True, exist_ok=True)
1351
+ effective_custom = self._effective_title_unlocked(
1352
+ session_id,
1353
+ meta,
1354
+ {},
1355
+ title_key="custom_title",
1356
+ prefer_recovery=False,
1357
+ )
1358
+ effective_generated = self._effective_title_unlocked(
1359
+ session_id,
1360
+ meta,
1361
+ {},
1362
+ title_key="generated_title",
1363
+ prefer_recovery=False,
1364
+ )
1365
+ if custom_title is None and generated_title is not None and effective_custom:
1366
+ # A custom title permanently disables auto-generated updates.
1367
+ return None
1368
+ if custom_title is None and generated_title is None:
1369
+ return self._meta_from_patched_envelope(session_id, envelope)
1370
+
1371
+ final_custom = effective_custom
1372
+ if custom_title is not None and custom_title != effective_custom:
1373
+ final_custom = custom_title
1374
+ final_generated = effective_generated
1375
+ if generated_title is not None and generated_title != effective_generated:
1376
+ final_generated = generated_title
1377
+
1378
+ custom_needs_write = meta.get("custom_title") != final_custom
1379
+ generated_needs_write = meta.get("generated_title") != final_generated
1380
+ if not custom_needs_write and not generated_needs_write:
1381
+ # The durable envelope already holds the effective winners;
1382
+ # any pending custom candidate is therefore stale or synced.
1383
+ self._pending_custom_titles.pop(session_id, None)
1384
+ return self._meta_from_patched_envelope(session_id, envelope)
1385
+ if custom_needs_write:
1386
+ meta["custom_title"] = final_custom
1387
+ if generated_needs_write:
1388
+ meta["generated_title"] = final_generated
1389
+ payload = json.dumps(envelope, indent=2, ensure_ascii=False)
1390
+ _atomic_write_text(session_file, payload)
1391
+ try:
1392
+ _atomic_write_text(self._backup_file(session_id), payload)
1393
+ except OSError:
1394
+ logger.warning("Failed to update session backup for %s", session_id, exc_info=True)
1395
+ self._patch_recovery_titles_unlocked(
1396
+ session_id,
1397
+ custom_title=final_custom if custom_needs_write else None,
1398
+ generated_title=final_generated if generated_needs_write else None,
1399
+ )
1400
+ # The primary now durably contains the effective custom winner,
1401
+ # so no in-memory candidate may override a later full save.
1402
+ self._pending_custom_titles.pop(session_id, None)
1403
+ return self._meta_from_patched_envelope(session_id, envelope)
1404
+
1405
+ def _meta_from_patched_envelope(self, session_id: str, envelope: dict[str, Any]) -> SessionMeta | None:
1406
+ """Best-effort SessionMeta for a title patch's return value.
1407
+
1408
+ The patch itself has already succeeded (or been skipped as a no-op)
1409
+ by the time this runs, so malformed meta must degrade to ``None``
1410
+ rather than escape as a false failure. ``TypeError`` covers
1411
+ ``datetime.fromisoformat`` on non-string timestamps.
1412
+ """
1413
+ try:
1414
+ directory = self._session_dir(session_id)
1415
+ return self._session_meta_from_envelope(
1416
+ envelope,
1417
+ size_bytes=_dir_size(directory),
1418
+ )
1419
+ except KeyError, ValueError, TypeError:
1420
+ return None
1421
+
1422
+ async def update_session_titles(
1423
+ self,
1424
+ session_id: str,
1425
+ *,
1426
+ custom_title: str | None = None,
1427
+ generated_title: str | None = None,
1428
+ ) -> SessionMeta | None:
1429
+ """Patch the session's title overlay fields without touching state.
1430
+
1431
+ ``None`` leaves a field unchanged; empty string clears it. A
1432
+ ``generated_title``-only patch is refused (returns ``None``) when a
1433
+ custom title is already set. This never creates a session file: for
1434
+ a session that has not been saved yet a custom title is held in
1435
+ memory (and ``None`` is returned) until the first full save merges
1436
+ it, while a generated title is dropped. Returns the updated
1437
+ ``SessionMeta`` on success.
1438
+ """
1439
+ return await asyncio.to_thread(
1440
+ self._update_session_titles_sync,
1441
+ session_id,
1442
+ custom_title=custom_title,
1443
+ generated_title=generated_title,
1444
+ )
1445
+
1446
+ def _session_dir_candidates(self) -> list[Path]:
1447
+ """Session folders that plausibly hold a restorable session."""
1448
+ from chrys.service.trajectory.tombstone import pending_delete_intents
1449
+
1450
+ candidates = session_dir_candidates(self._dir)
1451
+ # A folder a delete could not finish removing (its trajectory file was
1452
+ # still open) is already gone as far as the user is concerned; listing
1453
+ # its remains would resurrect a deleted session.
1454
+ pending = pending_delete_intents(self._dir)
1455
+ if not pending:
1456
+ return candidates
1457
+ return [candidate for candidate in candidates if candidate.name not in pending]
1458
+
1459
+ @staticmethod
1460
+ def _has_session_artifacts(session_dir: Path) -> bool:
1461
+ """Whether *session_dir* holds anything a restore could start from."""
1462
+ return session_dir_has_artifacts(session_dir)
1463
+
1464
+ @staticmethod
1465
+ def _file_signature(path: Path) -> tuple[int, int]:
1466
+ """(mtime_ns, size) change-signature for *path*; ``(-1, -1)`` if absent."""
1467
+ try:
1468
+ stat = path.stat()
1469
+ except OSError:
1470
+ return (-1, -1)
1471
+ return (stat.st_mtime_ns, stat.st_size)
1472
+
1473
+ def _meta_for_session_dir(self, session_dir: Path) -> SessionMeta | None:
1474
+ """One folder-format session's listing meta (``size_bytes`` unset), via the listing caches."""
1475
+ try:
1476
+ resolved = self._listing_meta(session_dir)
1477
+ except KeyError, ValueError, TypeError, OSError:
1478
+ return None
1479
+ return resolved[0] if resolved is not None else None
1480
+
1481
+ def _listing_meta(self, session_dir: Path) -> tuple[SessionMeta, CatalogEntry | None] | None:
1482
+ """Resolve listing meta and, when it is cacheable, its catalog entry.
1483
+
1484
+ Only a stable read of a valid primary with no recovery sidecar is
1485
+ cached: the sidecar's eligibility depends on the active lock, and a
1486
+ backup or snapshot fallback on files outside the signature. Those
1487
+ sessions are resolved live, probing the lock only when a sidecar
1488
+ exists.
1489
+ """
1490
+ short_id = session_dir.name
1491
+ primary = session_dir / SESSION_FILE_NAME
1492
+ recovery = session_dir / SESSION_RECOVERY_FILE_NAME
1493
+ signature = file_signature(primary)
1494
+ recovery_present = file_signature(recovery) is not None
1495
+ if signature is not None and not recovery_present:
1496
+ with self._meta_cache_lock:
1497
+ cached = self._meta_cache.get(short_id)
1498
+ if cached is not None and cached.signature == signature:
1499
+ return copy_session_meta(cached.meta), cached
1500
+ lock_held = recovery_present and self._active_lock_is_held(short_id)
1501
+ # A sidecar that appears after the probe is a live writer's checkpoint: never read it.
1502
+ prefer_recovery = recovery_present and not lock_held
1503
+ raw, source = self._resolve_effective_envelope_with_source(short_id, prefer_recovery=prefer_recovery)
1504
+ if raw is None:
1505
+ return None
1506
+ meta = self._session_meta_from_envelope(raw, size_bytes=0)
1507
+ stable = (
1508
+ source == "primary"
1509
+ and signature is not None
1510
+ and not recovery_present
1511
+ and file_signature(primary) == signature
1512
+ and file_signature(recovery) is None
1513
+ )
1514
+ if not stable or signature is None:
1515
+ return meta, None
1516
+ entry = CatalogEntry(signature, meta)
1517
+ with self._meta_cache_lock:
1518
+ self._meta_cache[short_id] = entry
1519
+ # A malformed envelope's meta would be dropped on every load: keep it in memory only.
1520
+ if is_recordable(entry):
1521
+ self._catalog_pending.add(short_id)
1522
+ return copy_session_meta(meta), entry
1523
+
1524
+ def _with_workflow_status(self, meta: SessionMeta) -> SessionMeta:
1525
+ if meta.kind != "workflow" or not _is_run_name(meta.latest_run_id):
1526
+ return meta
1527
+ return replace(
1528
+ meta,
1529
+ latest_run=read_workflow_meta(
1530
+ run_dir(self._session_dir(meta.session_id), meta.latest_run_id),
1531
+ active=self._active_lock_is_held(meta.session_id),
1532
+ ),
1533
+ )
1534
+
1535
+ def _workflow_listed_at(
1536
+ self, session_dir: Path, meta: WorkflowSessionMeta, entry: CatalogEntry | None
1537
+ ) -> datetime | None:
1538
+ """The latest run's listing time (``None``: no displayable run), cached on its run files.
1539
+
1540
+ Matches ``_with_workflow_status``: the time does not depend on the
1541
+ live status, so it can be kept without probing the active lock.
1542
+ """
1543
+ run_id = meta.latest_run_id
1544
+ if not _is_run_name(run_id):
1545
+ return None
1546
+ directory = run_dir(session_dir, run_id)
1547
+
1548
+ def run_key() -> RunListingKey:
1549
+ return RunListingKey(
1550
+ run_id, file_signature(directory / HEADER_FILE), file_signature(directory / EVENTS_FILE)
1551
+ )
1552
+
1553
+ key = run_key()
1554
+ if entry is not None and entry.run is not None and entry.run.key == key:
1555
+ return entry.run.listed_at
1556
+ read = read_workflow_run(directory, active=False)
1557
+ listed_at = read.meta.updated_at if read.meta is not None else None
1558
+ # A read an I/O error shaped would stick until the run files change.
1559
+ if read.settled and entry is not None and run_key() == key:
1560
+ listed = replace(entry, run=RunListing(key, listed_at))
1561
+ recordable = is_recordable(listed)
1562
+ with self._meta_cache_lock:
1563
+ if self._meta_cache.get(session_dir.name) is entry:
1564
+ self._meta_cache[session_dir.name] = listed
1565
+ # As in ``_listing_meta``: an entry no load would read back stays in memory.
1566
+ if recordable:
1567
+ self._catalog_pending.add(session_dir.name)
1568
+ return listed_at
1569
+
1570
+ # ------------------------------------------------------------------ #
1571
+ # Persisted listing catalog
1572
+ # ------------------------------------------------------------------ #
1573
+
1574
+ def _refresh_catalog(self) -> None:
1575
+ """Adopt the persisted catalog's entries when its file changed since the last look."""
1576
+ signature = file_signature(self._catalog.path)
1577
+ with self._meta_cache_lock:
1578
+ if signature == self._catalog_signature:
1579
+ return
1580
+ loaded = self._catalog.load() if signature is not None else {}
1581
+ if loaded is None:
1582
+ return # Unreadable right now: look again next time.
1583
+ with self._meta_cache_lock:
1584
+ self._catalog_signature = signature
1585
+ for short_id, entry in loaded.items():
1586
+ current = self._meta_cache.get(short_id)
1587
+ # A differing in-memory entry not yet persisted was derived
1588
+ # here after the peer's write, or will simply miss and re-parse.
1589
+ if current is None or (current.signature != entry.signature and short_id not in self._catalog_pending):
1590
+ self._meta_cache[short_id] = entry
1591
+
1592
+ def _commit_catalog(self) -> None:
1593
+ """Settle owed removals, then merge newly derived entries into the persisted catalog; best-effort.
1594
+
1595
+ Removals never wait for the commit interval. A skipped or failed
1596
+ merge keeps the entries pending for a later listing.
1597
+ """
1598
+ self._settle_catalog_removals()
1599
+ now = time.monotonic()
1600
+ with self._meta_cache_lock:
1601
+ self._catalog_pending.intersection_update(self._meta_cache.keys())
1602
+ updates = {short_id: self._meta_cache[short_id] for short_id in self._catalog_pending}
1603
+ if not updates:
1604
+ return
1605
+ last = self._catalog_committed_at
1606
+ if (
1607
+ len(updates) < SESSION_CATALOG_EAGER_COMMIT_ENTRIES
1608
+ and last is not None
1609
+ and now - last < SESSION_CATALOG_COMMIT_INTERVAL_SECONDS
1610
+ ):
1611
+ return
1612
+ self._catalog_committed_at = now
1613
+ outcome = self._catalog.commit(
1614
+ updates, still_valid=self._catalog_entry_still_valid, is_live=self._catalog_liveness()
1615
+ )
1616
+ if not outcome.settled:
1617
+ return
1618
+ with self._meta_cache_lock:
1619
+ for short_id, entry in updates.items():
1620
+ if self._meta_cache.get(short_id) is entry:
1621
+ self._catalog_pending.discard(short_id)
1622
+ if outcome.signature is not None:
1623
+ # Our own write needs no reload; peers' entries it merged are
1624
+ # only a missed cache hit away.
1625
+ self._catalog_signature = outcome.signature
1626
+
1627
+ def _catalog_entry_still_valid(self, short_id: str, entry: CatalogEntry) -> bool:
1628
+ session_dir = self._dir / short_id
1629
+ return (
1630
+ file_signature(session_dir / SESSION_FILE_NAME) == entry.signature
1631
+ and file_signature(session_dir / SESSION_RECOVERY_FILE_NAME) is None
1632
+ )
1633
+
1634
+ def _catalog_liveness(self) -> Callable[[str], bool]:
1635
+ """Whether the session an entry describes still exists.
1636
+
1637
+ It needs its primary file (a reset keeps the folder) and no recorded
1638
+ delete intent (a logical delete may leave the primary pinned). The
1639
+ intents are read on first use: a delete records its intent before it
1640
+ removes its entry under the catalog lock, so a check made under that
1641
+ lock sees every delete whose removal went first.
1642
+ """
1643
+ from chrys.service.trajectory.tombstone import pending_delete_intents
1644
+
1645
+ deleted: frozenset[str] | None = None
1646
+
1647
+ def is_live(short_id: str) -> bool:
1648
+ nonlocal deleted
1649
+ if not short_id or short_id.startswith(".") or "/" in short_id or "\\" in short_id:
1650
+ return False
1651
+ if deleted is None:
1652
+ deleted = pending_delete_intents(self._dir)
1653
+ return short_id not in deleted and file_signature(self._dir / short_id / SESSION_FILE_NAME) is not None
1654
+
1655
+ return is_live
1656
+
1657
+ def _forget_catalog_entry(self, session_id: str) -> None:
1658
+ """Drop a deleted session's listing entry from memory and from the persisted catalog.
1659
+
1660
+ A removal the catalog lock or a write failure stops stays owed and is
1661
+ retried before each later commit, until it lands (as is one for a
1662
+ session a scan found gone).
1663
+ """
1664
+ short_id = _session_short_id(session_id)
1665
+ with self._meta_cache_lock:
1666
+ self._meta_cache.pop(short_id, None)
1667
+ self._catalog_pending.discard(short_id)
1668
+ self._catalog_removals_owed.add(short_id)
1669
+ self._settle_catalog_removals()
1670
+
1671
+ def _settle_catalog_removals(self) -> None:
1672
+ with self._meta_cache_lock:
1673
+ owed = frozenset(self._catalog_removals_owed)
1674
+ if not owed or not self._catalog.remove(owed):
1675
+ return
1676
+ with self._meta_cache_lock:
1677
+ self._catalog_removals_owed.difference_update(owed)
1678
+
1679
+ def _legacy_session_metas(self, seen_session_ids: set[str]) -> list[SessionMeta]:
1680
+ """Metas for legacy flat-file sessions not yet migrated to folders."""
1681
+ return [meta for _path, meta in self._legacy_session_metas_with_paths(seen_session_ids)]
1682
+
1683
+ def _legacy_session_metas_with_paths(self, seen_session_ids: set[str]) -> list[tuple[Path, SessionMeta]]:
1684
+ """Legacy flat-file sessions not yet migrated to folders, with their files.
1685
+
1686
+ Duplicate ``meta.session_id`` values — across flat files, or against
1687
+ the folder-format ids in *seen_session_ids* — keep only the first
1688
+ occurrence, matching the pre-cache listing behavior.
1689
+ """
1690
+ sessions: list[tuple[Path, SessionMeta]] = []
1691
+ seen = set(seen_session_ids)
1692
+ for path in self._legacy_session_files():
1693
+ meta = self._legacy_meta_for_file(path)
1694
+ if meta is None or meta.session_id in seen:
1695
+ continue
1696
+ seen.add(meta.session_id)
1697
+ sessions.append((path, meta))
1698
+ return sessions
1699
+
1700
+ def _legacy_meta_for_file(self, path: Path) -> SessionMeta | None:
1701
+ """Load one legacy flat-file session's meta, via its in-process cache."""
1702
+ try:
1703
+ signature = file_signature(path)
1704
+ with self._meta_cache_lock:
1705
+ cached = self._legacy_meta_cache.get(path.name)
1706
+ if signature is not None and cached is not None and cached[0] == signature:
1707
+ return copy_session_meta(cached[1])
1708
+ raw = json.loads(path.read_text(encoding="utf-8"))
1709
+ meta = self._session_meta_from_envelope(raw, size_bytes=path.stat().st_size)
1710
+ if signature is not None and file_signature(path) == signature:
1711
+ with self._meta_cache_lock:
1712
+ self._legacy_meta_cache[path.name] = (signature, meta)
1713
+ return copy_session_meta(meta)
1714
+ return meta
1715
+ except json.JSONDecodeError, KeyError, ValueError, TypeError, OSError:
1716
+ return None
1717
+
1718
+ def _evict_stale_meta_cache_sync(self) -> None:
1719
+ """Drop cache entries whose session file is gone (deleted, or reset in place).
1720
+
1721
+ An evicted folder entry is owed its catalog removal: once out of
1722
+ memory, no later scan would find it again.
1723
+ """
1724
+ with self._meta_cache_lock:
1725
+ cached = list(self._meta_cache)
1726
+ is_live = self._catalog_liveness()
1727
+ gone = [key for key in cached if not is_live(key)]
1728
+ try:
1729
+ live_legacy: set[str] | None = {p.name for p in self._legacy_session_files()}
1730
+ except OSError:
1731
+ live_legacy = None
1732
+ with self._meta_cache_lock:
1733
+ dead = [key for key in gone if self._meta_cache.pop(key, None) is not None]
1734
+ self._catalog_pending.difference_update(dead)
1735
+ self._catalog_removals_owed.update(dead)
1736
+ if live_legacy is not None:
1737
+ for key in [key for key in self._legacy_meta_cache if key not in live_legacy]:
1738
+ del self._legacy_meta_cache[key]
1739
+
1740
+ def _settle_listing_caches(self) -> None:
1741
+ """After a scan: evict gone sessions, then remove them from, and merge new entries into, the catalog."""
1742
+ self._evict_stale_meta_cache_sync()
1743
+ self._commit_catalog()
1744
+
1745
+ def _folder_metas(self) -> list[tuple[Path, SessionMeta]]:
1746
+ """Every session folder's listing meta, without size or run status."""
1747
+ self._refresh_catalog()
1748
+ return [
1749
+ (session_dir, meta)
1750
+ for session_dir in self._session_dir_candidates()
1751
+ if (meta := self._meta_for_session_dir(session_dir)) is not None
1752
+ ]
1753
+
1754
+ def _scan_session_metas_sync(self) -> list[SessionMeta]:
1755
+ """Every listed session's meta without folder size or run status (for ranking by time)."""
1756
+ folders = self._folder_metas()
1757
+ sessions = [meta for _session_dir, meta in folders]
1758
+ sessions.extend(self._legacy_session_metas({meta.session_id for meta in sessions}))
1759
+ self._settle_listing_caches()
1760
+ return sessions
1761
+
1762
+ def _list_sessions_sync(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]:
1763
+ """Sync implementation of session listing (runs in a thread)."""
1764
+ folders = self._folder_metas()
1765
+ sessions = [
1766
+ replace(meta, size_bytes=_dir_size(session_dir))
1767
+ for session_dir, meta in folders
1768
+ if kind is None or meta.kind == kind
1769
+ ]
1770
+ legacy = self._legacy_session_metas({meta.session_id for _session_dir, meta in folders})
1771
+ sessions.extend(meta for meta in legacy if kind is None or meta.kind == kind)
1772
+ self._settle_listing_caches()
1773
+ return [self._with_workflow_status(meta) for meta in sessions]
1774
+
1775
+ async def list_sessions(self, *, kind: Literal["chat", "workflow"] | None = None) -> list[SessionMeta]:
1776
+ """List all saved sessions with metadata."""
1777
+ return await asyncio.to_thread(self._list_sessions_sync, kind=kind)
1778
+
1779
+ # ------------------------------------------------------------------ #
1780
+ # Latest-session lookup via the MRU index
1781
+ # ------------------------------------------------------------------ #
1782
+
1783
+ def _rescan_latest_session_id(self) -> str | None:
1784
+ """Full listing scan; rebuilds the MRU index and returns the newest id.
1785
+
1786
+ The rebuild keeps index entries newer than the scan (a save whose
1787
+ pre-commit record beat our read of its envelope, or a stale entry the
1788
+ scan already contradicts). The merged ranking — with the scan's top
1789
+ as the floor — goes through the same verification as an indexed
1790
+ lookup: a matching or newer envelope means the save has landed and
1791
+ wins; an unavailable (or, racing a delete, vanished) or older one is
1792
+ skipped/re-ranked and the next candidate stands. The modified-folder
1793
+ sweep then covers whatever committed during the scan without leaving
1794
+ a rankable record behind, including a first session that landed
1795
+ after its in-flight record failed verification.
1796
+ """
1797
+ scan_started = datetime.now(UTC)
1798
+ sessions = self._scan_session_metas_sync()
1799
+ entries = sort_entries(SessionMruEntry(m.session_id, coerce_utc(m.updated_at)) for m in sessions)
1800
+ scanned = entries[0] if entries else None
1801
+ merged = self._note_mru(lambda: self._mru.rebuild(entries))
1802
+ candidates = list(merged) if merged else entries
1803
+ if scanned is not None and all(entry.session_id != scanned.session_id for entry in candidates):
1804
+ candidates.append(scanned) # ranked below every record: keep it as the floor
1805
+ winner = self._verify_ranked(sort_entries(candidates))
1806
+ since = scan_started if winner is None else min(winner.last_updated_at, scan_started)
1807
+ best = self._newest_modified_after(winner, since=since)
1808
+ return best.session_id if best is not None else None
1809
+
1810
+ def _verify_ranked(self, entries: list[SessionMruEntry]) -> SessionMruEntry | None:
1811
+ """Verify *entries* (sorted newest first) against disk; return the winner.
1812
+
1813
+ The top entry is checked against its envelope: an unavailable session
1814
+ is dropped, a differing on-disk stamp re-ranks it in memory (only a
1815
+ newer stamp is written back — an older one is either transient (a
1816
+ live owner keeping the primary ahead of a sidecar, a save between its
1817
+ record and its commit) or a rollback that the session's next save
1818
+ overwrites, and persisting it would break the crash-recovery case
1819
+ where the sidecar must win) and the loop repeats until the top has
1820
+ been verified. ``None`` when every entry is unavailable.
1821
+ """
1822
+ verified: set[str] = set()
1823
+ while entries:
1824
+ top = entries[0]
1825
+ if top.session_id in verified:
1826
+ return top
1827
+ actual = self._mru_verify(top.session_id)
1828
+ if actual is None:
1829
+ entries.pop(0)
1830
+ continue
1831
+ verified.add(top.session_id)
1832
+ if actual != top.last_updated_at:
1833
+ entries[0] = SessionMruEntry(top.session_id, actual)
1834
+ self._record_mru(top.session_id, actual)
1835
+ entries = sort_entries(entries)
1836
+ return None
1837
+
1838
+ def _mru_verify(self, session_id: str) -> datetime | None:
1839
+ """Return the on-disk ``updated_at`` for an indexed session, or ``None``.
1840
+
1841
+ Uses ``_meta_for_session_dir`` so active-lock, recovery-sidecar and
1842
+ backup/snapshot fallback rules match ``list_sessions()`` exactly. A
1843
+ session with nothing restorable on disk — no folder, or a folder a
1844
+ pre-commit crash left without envelope/backup/sidecar/snapshots — is
1845
+ dropped from the index (see ``_prune_ghost_entry``); one that has
1846
+ artifacts but cannot be read right now is only skipped in memory.
1847
+ """
1848
+ session_dir = self._session_dir(session_id)
1849
+ if not self._has_session_artifacts(session_dir) and not self._legacy_path(session_id).exists():
1850
+ legacy = self._legacy_meta_for_id(session_id)
1851
+ if legacy is not None: # a legacy flat file under a non-canonical name
1852
+ return coerce_utc(legacy.updated_at)
1853
+ self._prune_ghost_entry(session_id)
1854
+ return None
1855
+ self._migrate_if_needed(session_id)
1856
+ meta = self._meta_for_session_dir(session_dir)
1857
+ if meta is None: # unreadable folder: the listing may still attribute a legacy file to the id
1858
+ meta = self._legacy_meta_for_id(session_id)
1859
+ return None if meta is None else coerce_utc(meta.updated_at)
1860
+
1861
+ def _legacy_meta_for_id(self, session_id: str) -> SessionMeta | None:
1862
+ """The legacy flat file the listing would attribute to *session_id*, if any.
1863
+
1864
+ Legacy files are discovered by ``*.json``, not by name, so a session
1865
+ may live under a file that is not ``<id>.json``; the listing takes
1866
+ the first sorted readable file embedding the id, and so does this.
1867
+ """
1868
+ for path in self._legacy_session_files():
1869
+ meta = self._legacy_meta_for_file(path)
1870
+ if meta is not None and meta.session_id == session_id:
1871
+ return meta
1872
+ return None
1873
+
1874
+ def _prune_ghost_entry(self, session_id: str) -> None:
1875
+ """Drop an indexed session with nothing restorable — decided under its write lock.
1876
+
1877
+ Saves and forks record their id before the commit that makes the
1878
+ envelope (or the directory) appear, so "nothing on disk" alone is
1879
+ ambiguous: a busy write lock means a writer is committing it right
1880
+ now, and the absence must be re-checked while holding the lock so a
1881
+ commit that lands between the first look and this one is not
1882
+ mistaken for a ghost. What survives is a real ghost: a folder a
1883
+ pre-commit crash left empty, or a session deleted behind the index.
1884
+ """
1885
+ try:
1886
+ lock = FileLock(self._write_lock_path(session_id), timeout=0.0)
1887
+ lock.acquire()
1888
+ except TimeoutError:
1889
+ return
1890
+ except OSError: # unwritable/odd lock leaf: pruning is optional, the lookup is not
1891
+ logger.debug("Skipping ghost prune of %s: write lock unavailable", session_id, exc_info=True)
1892
+ return
1893
+ try:
1894
+ if (
1895
+ not self._has_session_artifacts(self._session_dir(session_id))
1896
+ and not self._legacy_path(session_id).exists()
1897
+ and self._legacy_meta_for_id(session_id) is None
1898
+ ):
1899
+ self._note_mru(lambda: self._mru.remove(session_id))
1900
+ finally:
1901
+ lock.release()
1902
+
1903
+ def _newest_modified_after(self, winner: SessionMruEntry | None, *, since: datetime) -> SessionMruEntry | None:
1904
+ """Safety net for writers that bypass the index: re-rank *winner* against
1905
+ every session whose folder (or legacy file) was modified after *since*
1906
+ (the winner's stamp, or the start of a scan that found no winner).
1907
+
1908
+ A session that outranks the winner was necessarily written after the
1909
+ winner's ``updated_at``, and writing it touches its folder's mtime —
1910
+ so an older chrys, a copied folder, a save whose record failed, or a
1911
+ recovery-only session that was hidden behind its owner's active lock
1912
+ during the backfill scan all surface here at the cost of one stat per
1913
+ folder plus a parse for the (normally zero) recently touched ones.
1914
+ Folders are ranked one by one; a recently touched legacy flat file
1915
+ defers to the full listing so duplicate-id precedence matches it.
1916
+ """
1917
+ try:
1918
+ cutoff_ns = int((since - _MRU_SWEEP_SLACK).timestamp() * 1_000_000_000)
1919
+ except OverflowError, OSError, ValueError:
1920
+ cutoff_ns = -1 # absurd stamp (datetime.min/max): parse everything rather than guess
1921
+ winner_dir = self._session_dir(winner.session_id) if winner is not None else None
1922
+ best = winner
1923
+ try:
1924
+ candidates = self._session_dir_candidates()
1925
+ except OSError:
1926
+ return best
1927
+ for session_dir in candidates:
1928
+ if session_dir == winner_dir or self._file_signature(session_dir)[0] <= cutoff_ns:
1929
+ continue
1930
+ meta = self._meta_for_session_dir(session_dir)
1931
+ if meta is not None:
1932
+ best = _newer_of(best, SessionMruEntry(meta.session_id, coerce_utc(meta.updated_at)))
1933
+ if any(self._file_signature(legacy)[0] > cutoff_ns for legacy in self._legacy_session_files()):
1934
+ # A freshly touched legacy flat file: which copy of a session id
1935
+ # counts (folder over legacy, first sorted legacy otherwise) is the
1936
+ # listing's rule, so let the listing rank it — legacy roots are
1937
+ # transitional and the meta cache makes the repeat cheap.
1938
+ for meta in self._scan_session_metas_sync():
1939
+ best = _newer_of(best, SessionMruEntry(meta.session_id, coerce_utc(meta.updated_at)))
1940
+ if best is not None and best != winner:
1941
+ self._record_mru(best.session_id, best.last_updated_at)
1942
+ return best
1943
+
1944
+ def _load_latest_session_id_sync(self) -> str | None:
1945
+ """Sync implementation of the MRU-backed latest-session lookup."""
1946
+ snapshot = self._mru.load()
1947
+ if snapshot is None or not snapshot.complete:
1948
+ return self._rescan_latest_session_id()
1949
+ winner = self._verify_ranked(list(snapshot.sessions))
1950
+ if winner is None:
1951
+ # Every indexed entry is gone (or the index is empty): older
1952
+ # sessions may still exist below the index horizon, so scan once
1953
+ # and rebuild.
1954
+ return self._rescan_latest_session_id()
1955
+ if snapshot.horizon is not None and winner.last_updated_at < snapshot.horizon:
1956
+ # A downgrade dropped the winner below sessions that were trimmed
1957
+ # out of the index; only a full scan can rank those.
1958
+ return self._rescan_latest_session_id()
1959
+ best = self._newest_modified_after(winner, since=winner.last_updated_at)
1960
+ return best.session_id if best is not None else None
1961
+
1962
+ async def load_latest_session_id(self, *, chat_only: bool = False) -> str | None:
1963
+ """Return the id of the most recently updated restorable session.
1964
+
1965
+ Backed by the ``session_mru.json`` index; a valid index costs one
1966
+ small read, verification of the newest session's envelope and a
1967
+ stat sweep of the session folders (parsing only those modified after
1968
+ the winner), while a missing/corrupt/incomplete index falls back to
1969
+ a full listing scan that also rebuilds the index.
1970
+ """
1971
+ latest_id = await asyncio.to_thread(self._load_latest_session_id_sync)
1972
+ if latest_id is None or not chat_only:
1973
+ return latest_id
1974
+ latest = await asyncio.to_thread(self._meta_for_session_dir, self._session_dir(latest_id))
1975
+ if latest is not None and latest.kind == "chat":
1976
+ return latest_id
1977
+ # Keep the MRU fast path for ordinary chat use; only a workflow-only
1978
+ # winner requires finding the newest eligible chat among older sessions.
1979
+ latest_chat = max(
1980
+ (meta for meta in await self.list_sessions(kind="chat")),
1981
+ key=lambda meta: (coerce_utc(meta.updated_at), meta.session_id),
1982
+ default=None,
1983
+ )
1984
+ return latest_chat.session_id if latest_chat is not None else None
1985
+
1986
+ # ------------------------------------------------------------------ #
1987
+ # Paged listing
1988
+ # ------------------------------------------------------------------ #
1989
+
1990
+ def _listed_at(self, source: Path, meta: SessionMeta, entry: CatalogEntry | None) -> datetime | None:
1991
+ """A session's listing time; ``None`` for a workflow session with no displayable run."""
1992
+ if isinstance(meta, WorkflowSessionMeta):
1993
+ return self._workflow_listed_at(source, meta, entry)
1994
+ return meta.updated_at
1995
+
1996
+ def _open_session_listing_sync(self, kind: Literal["chat", "workflow"]) -> SessionListing:
1997
+ self._refresh_catalog()
1998
+ entries: list[SessionListingEntry] = []
1999
+ seen: set[str] = set()
2000
+ for session_dir in self._session_dir_candidates():
2001
+ try:
2002
+ resolved = self._listing_meta(session_dir)
2003
+ except KeyError, ValueError, TypeError, OSError:
2004
+ continue
2005
+ if resolved is None:
2006
+ continue
2007
+ meta, entry = resolved
2008
+ if meta.session_id in seen:
2009
+ continue # a copied folder: the first one lists, as when streaming
2010
+ seen.add(meta.session_id)
2011
+ if meta.kind == kind and (listed_at := self._listed_at(session_dir, meta, entry)) is not None:
2012
+ entries.append(
2013
+ SessionListingEntry(
2014
+ meta.session_id, coerce_utc(listed_at), meta.last_surface or SessionSurface.TUI, session_dir
2015
+ )
2016
+ )
2017
+ for path, meta in self._legacy_session_metas_with_paths(seen):
2018
+ if meta.kind != kind:
2019
+ continue
2020
+ # A legacy file is migrated to its folder on first load; until then
2021
+ # any workflow run lives under the folder its id names.
2022
+ listed_at = self._listed_at(self._session_dir(meta.session_id), meta, None)
2023
+ if listed_at is not None:
2024
+ entries.append(
2025
+ SessionListingEntry(
2026
+ meta.session_id,
2027
+ coerce_utc(listed_at),
2028
+ meta.last_surface or SessionSurface.TUI,
2029
+ path,
2030
+ legacy=True,
2031
+ )
2032
+ )
2033
+ self._settle_listing_caches()
2034
+ entries.sort(key=lambda item: (item.listed_at, item.session_id), reverse=True)
2035
+ return SessionListing(kind, tuple(entries))
2036
+
2037
+ async def open_session_listing(self, *, kind: Literal["chat", "workflow"]) -> SessionListing:
2038
+ """Snapshot the displayable sessions of *kind*, newest first, for paging.
2039
+
2040
+ Costs a ``stat`` per session folder plus a parse for each session
2041
+ changed since it was last listed (by any process, through the
2042
+ persisted catalog); folder sizes and run status are left to
2043
+ :meth:`load_session_page`.
2044
+ """
2045
+ return await asyncio.to_thread(self._open_session_listing_sync, kind)
2046
+
2047
+ def _load_session_page_sync(
2048
+ self, listing: SessionListing, surfaces: Collection[SessionSurface], page: int, page_size: int
2049
+ ) -> SessionPage:
2050
+ from chrys.service.trajectory.tombstone import pending_delete_intents
2051
+
2052
+ selected, current, page_count, total = page_slice(listing, surfaces, page, page_size=page_size)
2053
+ deleted = pending_delete_intents(self._dir) if selected else frozenset()
2054
+ metas: list[SessionMeta] = []
2055
+ for item in selected:
2056
+ meta = self._legacy_meta_for_file(item.source) if item.legacy else None
2057
+ # Loading a legacy session since the snapshot moved it into its folder.
2058
+ folder = self._session_dir(item.session_id) if item.legacy else item.source
2059
+ # A logical delete may leave the primary pinned.
2060
+ if meta is None and folder.name not in deleted:
2061
+ meta = self._meta_for_session_dir(folder)
2062
+ if meta is not None:
2063
+ meta = replace(meta, size_bytes=_dir_size(folder))
2064
+ # A session deleted, or replaced by another, since the snapshot drops out of its page.
2065
+ if meta is not None and meta.session_id == item.session_id and meta.kind == listing.kind:
2066
+ metas.append(self._with_workflow_status(meta))
2067
+ self._commit_catalog()
2068
+ return SessionPage(tuple(metas), current, page_count, total)
2069
+
2070
+ async def load_session_page(
2071
+ self,
2072
+ listing: SessionListing,
2073
+ *,
2074
+ surfaces: Collection[SessionSurface],
2075
+ page: int = 1,
2076
+ page_size: int = SESSION_PAGE_SIZE,
2077
+ ) -> SessionPage:
2078
+ """Load one page of *listing* filtered to *surfaces*: fresh metas with sizes and run status.
2079
+
2080
+ Rows keep the snapshot's order and page; their contents (title,
2081
+ surface, run status) are read now, so a session changed since the
2082
+ snapshot shows its current state where the snapshot placed it.
2083
+ """
2084
+ return await asyncio.to_thread(self._load_session_page_sync, listing, surfaces, page, page_size)
2085
+
2086
+ def _delete_session_sync(self, session_id: str, *, allow_active: bool = False) -> None:
2087
+ """Sync implementation of session delete (runs in a thread)."""
2088
+ session_dir = self._session_dir(session_id)
2089
+ legacy = self._legacy_path(session_id)
2090
+ if not session_dir.exists() and not legacy.exists():
2091
+ return
2092
+
2093
+ active_lock: FileLock | None = None
2094
+ if not allow_active:
2095
+ active_lock = FileLock(self.active_lock_path(session_id), timeout=SESSION_ACTIVE_LOCK_TIMEOUT_SECONDS)
2096
+ active_lock.acquire()
2097
+ try:
2098
+ with FileLock(self._write_lock_path(session_id), timeout=SESSION_WRITE_LOCK_TIMEOUT_SECONDS):
2099
+ if session_dir.is_dir():
2100
+ # A live trajectory writer (a stuck worker here, a writer
2101
+ # in another process) turns this into a logical delete:
2102
+ # the directory moves to a tombstone now and is removed
2103
+ # once the writer's lease is free.
2104
+ from chrys.service.trajectory.tombstone import DeleteOutcome, delete_session_directory
2105
+
2106
+ delete_result = delete_session_directory(session_dir, sessions_root=self._dir)
2107
+ if delete_result.outcome == DeleteOutcome.INTENT_FAILED and session_dir.exists():
2108
+ # The rename failed, the durable delete intent failed,
2109
+ # and best-effort removal left something behind. No
2110
+ # sweeper owns that directory, so reporting success
2111
+ # would let it reappear after its MRU entry was removed.
2112
+ raise OSError(f"Session directory could not be deleted or scheduled: {session_dir}")
2113
+ # Also clean up legacy flat file if it exists
2114
+ if legacy.exists():
2115
+ legacy.unlink()
2116
+ with contextlib.suppress(OSError):
2117
+ self.active_owner_path(session_id).unlink()
2118
+ # Still under the write lock: a same-id save recreating the
2119
+ # session records before it can commit, so it cannot slip in
2120
+ # between the deletion and this removal and lose its entry.
2121
+ self._note_mru(lambda: self._mru.remove(session_id))
2122
+ # The catalog holds prompt excerpts: they go with the session.
2123
+ self._forget_catalog_entry(session_id)
2124
+ self._pending_custom_titles.pop(session_id, None)
2125
+ self._written_recovery_meta.pop(_session_short_id(session_id), None)
2126
+ finally:
2127
+ if active_lock is not None:
2128
+ active_lock.release()
2129
+
2130
+ async def delete_session(self, session_id: str, *, allow_active: bool = False) -> None:
2131
+ """Delete a saved session (folder or legacy file)."""
2132
+ await asyncio.to_thread(self._delete_session_sync, session_id, allow_active=allow_active)