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,1289 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """MutationTracker — records file mutations and manages per-execution snapshots.
4
+
5
+ Created once per session. Passed to tool implementations so they can
6
+ record mutations. Each file is snapshotted at most once per turn
7
+ (first mutation wins for that turn).
8
+
9
+ Typical lifecycle::
10
+
11
+ tracker = MutationTracker(SnapshotStore(session_dir))
12
+
13
+ # --- turn 1 ---
14
+ tracker.start_turn(1)
15
+ # Called BEFORE the actual write — snapshots the pre-mutation file
16
+ tracker.record("foo.py", MODIFY, EDIT_FILE, "call-001")
17
+ tracker.record("foo.py", MODIFY, EDIT_FILE, "call-002") # no re-snapshot
18
+
19
+ # --- turn 2 (no file changes) ---
20
+ tracker.start_turn(2) # empty turn, still recorded
21
+
22
+ # --- turn 3 ---
23
+ tracker.start_turn(3)
24
+ tracker.record("foo.py", MODIFY, EDIT_FILE, "call-005") # re-snapshots
25
+
26
+ # Multi-step rollback: undo turns 3+2 -> foo.py restored to turn-1 state
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import os
34
+ import time
35
+ from collections.abc import Callable
36
+ from threading import RLock
37
+ from typing import Any, TypeVar
38
+
39
+ from chrys.foundation.models.mutation_scope import MutationScope, WorkflowRunScope
40
+ from chrys.service.mutations.git_state import entry_display_path
41
+ from chrys.service.mutations.store import SnapshotStore
42
+ from chrys.service.mutations.types import (
43
+ FileHashDiff,
44
+ FileMutation,
45
+ FileSnapshot,
46
+ MutationLog,
47
+ MutationOp,
48
+ MutationPeriod,
49
+ MutationProvenance,
50
+ MutationSource,
51
+ RestoreResult,
52
+ RollbackExclusionReason,
53
+ RollbackPlan,
54
+ RunMutations,
55
+ SnapshotSkipReason,
56
+ TurnMutations,
57
+ )
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ _T = TypeVar("_T")
62
+
63
+
64
+ class _ProvenanceBadges:
65
+ """Accumulates per-path provenance badge state while folding summaries.
66
+
67
+ Shared by the turn and session summary folds so both apply the same
68
+ rules: a ``FOREIGN`` row never contributes endpoints (and a path
69
+ with only foreign rows is absent); a path that mixes foreign rows
70
+ with ours renders contested (disk may differ from "our" after);
71
+ ``inferred`` marks folds whose net change includes window-diff
72
+ inference. MOVE rows badge both ``path`` and ``old_path``,
73
+ mirroring rollback's two-path treatment.
74
+ """
75
+
76
+ def __init__(self) -> None:
77
+ self._foreign: set[str] = set()
78
+ self._contested: set[str] = set()
79
+ self._inferred: set[str] = set()
80
+
81
+ def observe(self, m: FileMutation) -> bool:
82
+ """Register one row; True when it is FOREIGN (caller skips folding)."""
83
+ targets = (m.path, m.old_path) if m.old_path else (m.path,)
84
+ if m.provenance is MutationProvenance.FOREIGN:
85
+ self._foreign.update(targets)
86
+ return True
87
+ if m.contested:
88
+ self._contested.update(targets)
89
+ if m.provenance is MutationProvenance.ASSUMED:
90
+ self._inferred.update(targets)
91
+ return False
92
+
93
+ def apply(self, result: dict[str, FileHashDiff]) -> dict[str, FileHashDiff]:
94
+ """Set folded badges on entries (foreign-only paths are already absent)."""
95
+ from dataclasses import replace
96
+
97
+ for path, diff in result.items():
98
+ contested = path in self._contested or path in self._foreign
99
+ inferred = path in self._inferred
100
+ if contested != diff.contested or inferred != diff.inferred:
101
+ result[path] = replace(diff, contested=contested, inferred=inferred)
102
+ return result
103
+
104
+
105
+ class MutationTracker:
106
+ """Records file mutations and manages per-execution snapshots.
107
+
108
+ Two layers of state tracking:
109
+
110
+ 1. **Turn-level snapshots** (``_ensure_snapshot``): captured once per
111
+ (path, turn) at the first mutation. Used for rollback — represents
112
+ the file state at the *start* of the turn.
113
+
114
+ 2. **Last-known hash** (``_last_known_hash``): tracks the most recent
115
+ observed content hash for each path *within the current turn*.
116
+ Updated after every ``record_after()`` and shell ``record()``.
117
+ Used to set accurate ``before_hash`` on each ``FileMutation`` so
118
+ per-mutation DiffView shows the *incremental* change, not a diff
119
+ against the turn-start state.
120
+
121
+ Live write/lock inputs resolve their parent directories once, keeping
122
+ the final entry (including symlinks) distinct. All producers then share
123
+ the earliest snapshot and hash chain. Persisted paths and snapshot keys
124
+ remain lexical: later link retargeting must not reinterpret history.
125
+ """
126
+
127
+ def __init__(self, store: SnapshotStore) -> None:
128
+ self._store = store
129
+ self._log = MutationLog()
130
+ # Tracks which (path, turn_id) pairs have been snapshotted
131
+ self._snapshotted: set[str] = set() # uses MutationLog.snapshot_key
132
+ # Last-known content hash per normalized path within the current turn.
133
+ # Cleared on start_turn(). Enables accurate per-mutation before_hash
134
+ # when the same file is mutated multiple times in one turn.
135
+ self._last_known_hash: dict[str, str | None] = {}
136
+ # Companion to _last_known_hash: why the last-known content backup
137
+ # was withheld (SnapshotPolicy skip), or None when the hash is real
138
+ # or the file was absent. Keyed/cleared identically.
139
+ self._last_known_skip: dict[str, SnapshotSkipReason | None] = {}
140
+ # Per-file asyncio locks for serializing concurrent mutations to the
141
+ # same file. The Chrys tool loop dispatches parallel tool calls via
142
+ # asyncio.gather(); these locks ensure that record() → call_next() →
143
+ # record_after() for the same file execute sequentially, keeping the
144
+ # _last_known_hash chain accurate. Acquired/released from the event
145
+ # loop thread only (never from run_in_executor threads).
146
+ self._file_locks: dict[str, asyncio.Lock] = {}
147
+ # Serializes shell / implicit-detection windows inside a session so
148
+ # before/after observers do not attribute another concurrent shell or
149
+ # skill subprocess to the current tool call.
150
+ self._implicit_window_lock = asyncio.Lock()
151
+ # Protects tracker state because mutation finalization still performs
152
+ # some recording work from executor threads. Keep asyncio locks above
153
+ # event-loop-only; this lock guards plain Python state and blob refs.
154
+ self._state_lock = RLock()
155
+
156
+ @property
157
+ def log(self) -> MutationLog:
158
+ """The underlying mutation log (read-only access)."""
159
+ return self._log
160
+
161
+ @property
162
+ def store(self) -> SnapshotStore:
163
+ """The snapshot store backing this tracker."""
164
+ return self._store
165
+
166
+ def with_locked_log(self, fn: Callable[[MutationLog], _T]) -> _T:
167
+ """Run ``fn`` on the log under the tracker's state lock.
168
+
169
+ Generic synchronization seam for components that read or
170
+ re-attribute log rows without the tracker knowing about them
171
+ (cross-session coordination reclassifies provenance this way —
172
+ the tracker itself stays coordination-unaware). Re-entering
173
+ tracker methods from ``fn`` is safe (RLock), but ``fn`` must
174
+ not block on I/O while holding the lock.
175
+ """
176
+ with self._state_lock:
177
+ return fn(self._log)
178
+
179
+ # -- Turn lifecycle --
180
+
181
+ def start_turn(self, turn_id: int) -> None:
182
+ """Begin tracking a new agent turn.
183
+
184
+ Must be called at the start of each ``agent.run()`` cycle,
185
+ even if no file mutations are expected. This ensures the
186
+ turn timeline is continuous for multi-step rollback.
187
+
188
+ Clears ``_last_known_hash`` so each turn starts fresh —
189
+ the first mutation per file reads from disk.
190
+ """
191
+ with self._state_lock:
192
+ self._log.require_chat() # Validate the Chat-only lifecycle boundary.
193
+ self._log.periods.append(TurnMutations(turn_id=turn_id))
194
+ self._last_known_hash.clear()
195
+ self._last_known_skip.clear()
196
+
197
+ def start_workflow_run(self, run_id: str) -> None:
198
+ """Open one mutation period shared by every node and retry in this run.
199
+
200
+ The ordinal belongs to the snapshot ledger, not to a Chat turn. Persist
201
+ the run identity beside it so history can select a run without guessing
202
+ from the position of a node or an attempt.
203
+ """
204
+ with self._state_lock:
205
+ if not run_id or any(isinstance(period, TurnMutations) for period in self._log.periods):
206
+ raise ValueError("A workflow run requires a Workflow mutation ledger and a nonempty run_id")
207
+ if any(period.scope == WorkflowRunScope(run_id) for period in self._log.periods):
208
+ raise ValueError(f"Mutation period already exists for run {run_id}")
209
+ ordinal = max((period.period_index for period in self._log.periods), default=0) + 1
210
+ self._log.periods.append(RunMutations(run_id=run_id, period_index=ordinal))
211
+ self._last_known_hash.clear()
212
+ self._last_known_skip.clear()
213
+
214
+ def reset_file_cache(self) -> None:
215
+ """Clear per-file hash cache without starting a new turn.
216
+
217
+ Used by retry/resume so subsequent mutations read fresh content
218
+ from disk while still recording under the same turn.
219
+ """
220
+ with self._state_lock:
221
+ self._last_known_hash.clear()
222
+ self._last_known_skip.clear()
223
+
224
+ def mark_detection_truncated(self) -> None:
225
+ """Record that the active turn's implicit detection was incomplete."""
226
+ with self._state_lock:
227
+ if self._log.periods:
228
+ self._log.periods[-1].detection_truncated = True
229
+
230
+ @property
231
+ def current_period(self) -> MutationPeriod | None:
232
+ """The active execution period, shared by all its tools and nodes."""
233
+ with self._state_lock:
234
+ return self._log.periods[-1] if self._log.periods else None
235
+
236
+ @property
237
+ def current_turn(self) -> TurnMutations | None:
238
+ """Chat-only accessor; Workflow callers use current_period."""
239
+ with self._state_lock:
240
+ turns = self._log.turns
241
+ return turns[-1] if turns else None
242
+
243
+ def get_file_lock(self, path: str) -> asyncio.Lock:
244
+ """Get an asyncio lock for serializing concurrent mutations to a file.
245
+
246
+ The Chrys tool loop dispatches parallel tool calls via
247
+ ``asyncio.gather()``. Callers (middleware) acquire this lock
248
+ before ``record()`` and release after ``record_after()`` to
249
+ ensure the ``_last_known_hash`` chain stays accurate.
250
+
251
+ Must be called from the event loop thread, never from
252
+ ``run_in_executor`` threads.
253
+ """
254
+ norm = entry_display_path(path)
255
+ with self._state_lock:
256
+ if norm not in self._file_locks:
257
+ self._file_locks[norm] = asyncio.Lock()
258
+ return self._file_locks[norm]
259
+
260
+ def get_implicit_window_lock(self) -> asyncio.Lock:
261
+ """Lock for serializing shell and implicit mutation observation windows."""
262
+ return self._implicit_window_lock
263
+
264
+ # -- Recording mutations --
265
+
266
+ def record(
267
+ self,
268
+ path: str,
269
+ operation: MutationOp,
270
+ source: MutationSource,
271
+ tool_call_id: str,
272
+ *,
273
+ old_path: str | None = None,
274
+ provenance: MutationProvenance | None = None,
275
+ ) -> FileMutation | None:
276
+ """Record a file mutation.
277
+
278
+ **Calling convention**:
279
+
280
+ - For file tools (``write_file``, ``edit_file``): call BEFORE
281
+ the actual write, then call :meth:`record_after` when done.
282
+ - For shell commands: call :meth:`pre_snapshot` BEFORE execution,
283
+ then call ``record()`` AFTER execution for each detected change
284
+ (no ``record_after`` needed).
285
+
286
+ The turn-level snapshot (for rollback) is taken once per (path,
287
+ turn) via ``_ensure_snapshot``. The per-mutation ``before_hash``
288
+ reflects the *actual current state* of the file — using
289
+ ``_last_known_hash`` if the file was already mutated earlier in
290
+ this turn, or falling back to the turn-level snapshot hash.
291
+
292
+ ``provenance`` refines attribution beyond the source-derived
293
+ default (``None``) — e.g. a trace-confirmed shell write records
294
+ as ``PROVEN``.
295
+
296
+ Returns the :class:`FileMutation` or ``None`` if no active turn.
297
+ """
298
+ with self._state_lock:
299
+ turn = self._log.periods[-1] if self._log.periods else None
300
+ if turn is None:
301
+ logger.warning("MutationTracker.record() called with no active turn")
302
+ return None
303
+
304
+ norm = entry_display_path(path)
305
+
306
+ # Shell mutations call ``record()`` AFTER execution — reading from
307
+ # disk now (via ``_ensure_snapshot``) would capture post-exec state
308
+ # as the turn-start snapshot. For CREATEs the scanner discovered
309
+ # without a pre-snapshot (e.g. a file inside a newly-created dir),
310
+ # synthesise the correct ``existed=False`` turn-start directly;
311
+ # otherwise the mutation's before_hash == after_hash and the entry
312
+ # drops out of rollback-diff aggregation as net-zero.
313
+ snap_key = MutationLog.snapshot_key(norm, turn.period_index)
314
+ chain_severed = snap_key in self._snapshotted and norm not in self._last_known_hash
315
+ if source == MutationSource.SHELL and operation == MutationOp.CREATE and snap_key not in self._snapshotted:
316
+ snap = self._register_snapshot(
317
+ FileSnapshot(path=norm, period_index=turn.period_index, existed=False), key=snap_key
318
+ )
319
+ else:
320
+ snap = self._ensure_snapshot(norm, turn.period_index, key=snap_key)
321
+
322
+ # before_hash: use last known hash if the file was already mutated
323
+ # earlier in this turn; otherwise fall back to the turn-level snapshot.
324
+ if norm in self._last_known_hash:
325
+ before_hash = self._last_known_hash[norm]
326
+ before_skip = self._last_known_skip.get(norm)
327
+ elif chain_severed and source != MutationSource.SHELL:
328
+ # Snapshotted this turn but the content chain was cleared
329
+ # (retry/resume) or never completed (an interrupted write):
330
+ # the snapshot may predate earlier same-turn mutations.
331
+ # File-tool records run pre-write, so the true before
332
+ # endpoint is the current disk content. Shell records run
333
+ # post-execution — too late to observe it — and rely on
334
+ # pre_snapshot having reseeded the chain instead.
335
+ current = self._store.save_blob(norm)
336
+ before_hash = current.content_hash
337
+ before_skip = current.skip_reason
338
+ else:
339
+ before_hash = snap.content_hash if snap else None
340
+ before_skip = snap.skip_reason if snap else None
341
+
342
+ # For MOVE, also snapshot the old path
343
+ if old_path is not None:
344
+ old_norm = entry_display_path(old_path)
345
+ self._ensure_snapshot(old_norm, turn.period_index)
346
+ else:
347
+ old_norm = None
348
+
349
+ mutation = FileMutation(
350
+ path=norm,
351
+ operation=operation,
352
+ source=source,
353
+ tool_call_id=tool_call_id,
354
+ timestamp=time.time(),
355
+ old_path=old_norm,
356
+ before_hash=before_hash,
357
+ before_skip=before_skip,
358
+ provenance=provenance,
359
+ )
360
+ turn.mutations.append(mutation)
361
+
362
+ # For shell mutations (recorded post-execution): capture the
363
+ # post-execution state as after_hash and update last known hash.
364
+ # File-tool mutations set after_hash via record_after() instead.
365
+ if source == MutationSource.SHELL:
366
+ self.record_after(mutation)
367
+
368
+ return mutation
369
+
370
+ def record_after(self, mutation: FileMutation) -> None:
371
+ """Snapshot the file AFTER mutation and store the hash.
372
+
373
+ Called after a tool (write_file, edit_file) completes to
374
+ capture the post-mutation content for session replay diffs.
375
+
376
+ Also updates ``_last_known_hash`` so subsequent mutations to
377
+ the same file within this turn get an accurate ``before_hash``.
378
+ """
379
+ with self._state_lock:
380
+ after = self._store.save_blob(mutation.path)
381
+ mutation.after_hash = after.content_hash
382
+ mutation.after_skip = after.skip_reason
383
+ # A detector's DELETE is a candidate, not proof of absence:
384
+ # refs can move without touching the worktree, and filesystem
385
+ # probes can fail. Only an observed missing endpoint is deleted.
386
+ if mutation.operation is MutationOp.DELETE and (
387
+ after.content_hash is not None or after.skip_reason is not None
388
+ ):
389
+ mutation.operation = MutationOp.MODIFY
390
+ if SnapshotSkipReason.UNREADABLE in (mutation.before_skip, mutation.after_skip):
391
+ self.mark_detection_truncated()
392
+ # Update last known state for this file
393
+ norm = os.path.normpath(os.path.abspath(mutation.path))
394
+ self._last_known_hash[norm] = after.content_hash
395
+ self._last_known_skip[norm] = after.skip_reason
396
+
397
+ def pre_snapshot(self, paths: list[str]) -> None:
398
+ """Snapshot files before a shell command that may modify them.
399
+
400
+ Called with paths identified by :class:`ShellMutationDetector` or
401
+ :class:`WorkspaceScanner` before shell execution. The actual
402
+ mutation recording happens after execution via :meth:`record`.
403
+
404
+ Also initializes ``_last_known_hash`` for files not yet tracked
405
+ in this turn, so that post-execution ``record()`` calls get
406
+ accurate ``before_hash`` values.
407
+ """
408
+ with self._state_lock:
409
+ turn = self._log.periods[-1] if self._log.periods else None
410
+ if turn is None:
411
+ return
412
+ for path in paths:
413
+ norm = entry_display_path(path)
414
+ snap_key = MutationLog.snapshot_key(norm, turn.period_index)
415
+ chain_severed = snap_key in self._snapshotted and norm not in self._last_known_hash
416
+ snap = self._ensure_snapshot(norm, turn.period_index, key=snap_key)
417
+ # Initialize last known hash if this is the first time we see
418
+ # this file in the current turn. Uses the snapshot hash
419
+ # (avoids a redundant file read since _ensure_snapshot already
420
+ # read and hashed the content).
421
+ if norm not in self._last_known_hash:
422
+ if chain_severed:
423
+ # Snapshotted this turn but the content chain was
424
+ # cleared (retry/resume) or never completed (an
425
+ # interrupted write): the snapshot may predate
426
+ # earlier same-turn mutations, so the chain must
427
+ # restart from what is on disk right now. The
428
+ # snapshot stays the rollback anchor untouched.
429
+ current = self._store.save_blob(norm)
430
+ self._last_known_hash[norm] = current.content_hash
431
+ self._last_known_skip[norm] = current.skip_reason
432
+ else:
433
+ self._last_known_hash[norm] = snap.content_hash if snap else None
434
+ self._last_known_skip[norm] = snap.skip_reason if snap else None
435
+
436
+ def record_calibrated(
437
+ self,
438
+ path: str,
439
+ operation: MutationOp,
440
+ before_data: bytes | None,
441
+ tool_call_id: str = "git_calibration",
442
+ *,
443
+ provenance: MutationProvenance | None = None,
444
+ before_skip: SnapshotSkipReason | None = None,
445
+ before_size: int | None = None,
446
+ before_symlink: bool = False,
447
+ before_symlink_dir: bool | None = None,
448
+ ) -> FileMutation | None:
449
+ """Record an implicit mutation detected by git calibration at turn end.
450
+
451
+ Unlike :meth:`record`, this is called *after* the turn completes
452
+ with explicit before-content (typically from ``git show HEAD:path``).
453
+ The before endpoint is resolved in priority order:
454
+
455
+ - the ``_last_known_hash`` chain (content already tracked this
456
+ turn) — same as :meth:`record` — so a path edited by a tool and
457
+ then by an opaque shell command does not re-attribute the tool
458
+ edit to this command;
459
+ - fresh *before_data*: after a retry severs the chain, the old-HEAD
460
+ blob is this mutation's true before endpoint, even when the path
461
+ already has a turn snapshot from the earlier attempt;
462
+ - *before_skip* (policy withheld the oversized old blob): the event
463
+ is recorded with the skip reason, only the backup is missing;
464
+ - the existing turn snapshot as post-hoc fallback; and
465
+ - the nonexistent endpoint of a Git-confirmed CREATE.
466
+
467
+ The turn-start snapshot stays the rollback anchor in every branch:
468
+ it is registered only when the (path, turn) key has none yet, never
469
+ rewritten. Sets both ``before_hash`` and ``after_hash`` on the
470
+ mutation.
471
+ """
472
+ with self._state_lock:
473
+ turn = self._log.periods[-1] if self._log.periods else None
474
+ if turn is None:
475
+ return None
476
+
477
+ norm = entry_display_path(path)
478
+ snap_key = MutationLog.snapshot_key(norm, turn.period_index)
479
+
480
+ if norm in self._last_known_hash:
481
+ # Content already tracked this turn: chain from the last
482
+ # known state so this row's diff starts where the previous
483
+ # same-turn mutation ended, not at the turn-start snapshot.
484
+ before_hash = self._last_known_hash[norm]
485
+ before_skip = self._last_known_skip.get(norm)
486
+ elif before_data is not None:
487
+ # Fresh Git evidence beats the turn snapshot: with the chain
488
+ # severed by a retry, the old-HEAD blob is this mutation's
489
+ # true before endpoint.
490
+ before_blob = self._store.save_data_as_blob(before_data)
491
+ before_hash = before_blob.content_hash
492
+ before_skip = before_blob.skip_reason
493
+ if snap_key not in self._snapshotted:
494
+ self._register_snapshot(
495
+ FileSnapshot(
496
+ path=norm,
497
+ period_index=turn.period_index,
498
+ existed=True,
499
+ content_hash=before_hash,
500
+ size=len(before_data),
501
+ skip_reason=before_skip,
502
+ is_symlink=before_symlink,
503
+ # The kind is old-tree evidence supplied by the
504
+ # calibrator — the live filesystem is post-change
505
+ # and proves nothing about the old endpoint.
506
+ symlink_target_is_dir=before_symlink_dir if before_symlink else None,
507
+ ),
508
+ key=snap_key,
509
+ )
510
+ elif before_skip is not None:
511
+ # Policy withheld the before content (oversized old blob):
512
+ # record the event with the skip reason; only the content
513
+ # backup is missing.
514
+ before_hash = None
515
+ if snap_key not in self._snapshotted:
516
+ self._register_snapshot(
517
+ FileSnapshot(
518
+ path=norm,
519
+ period_index=turn.period_index,
520
+ existed=True,
521
+ size=before_size or 0,
522
+ skip_reason=before_skip,
523
+ is_symlink=before_symlink,
524
+ ),
525
+ key=snap_key,
526
+ )
527
+ elif snap_key in self._snapshotted:
528
+ # Snapshotted this turn but no fresh evidence for this pass:
529
+ # calibration runs post-hoc, so the snapshot is the only
530
+ # before endpoint left.
531
+ snap = self._log.snapshots.get(snap_key)
532
+ before_hash = snap.content_hash if snap else None
533
+ before_skip = snap.skip_reason if snap else None
534
+ else:
535
+ # No before-data and no pre-snapshot (untracked new file)
536
+ before_hash = None
537
+ before_skip = None
538
+ self._register_snapshot(
539
+ FileSnapshot(path=norm, period_index=turn.period_index, existed=False),
540
+ key=snap_key,
541
+ )
542
+
543
+ mutation = FileMutation(
544
+ path=norm,
545
+ operation=operation,
546
+ source=MutationSource.IMPLICIT,
547
+ tool_call_id=tool_call_id,
548
+ timestamp=time.time(),
549
+ before_hash=before_hash,
550
+ before_skip=before_skip,
551
+ provenance=provenance,
552
+ )
553
+ self.record_after(mutation)
554
+ turn.mutations.append(mutation)
555
+ return mutation
556
+
557
+ def get_changed_files_set(self) -> set[str]:
558
+ """Normalized absolute paths of all files mutated in the current turn.
559
+
560
+ ``FOREIGN`` rows are excluded: a path demoted to a peer session
561
+ must stay eligible for fresh local detection (git calibration's
562
+ already-tracked skip), or a later real local change on it would
563
+ be silently dropped as "already tracked".
564
+ """
565
+ with self._state_lock:
566
+ turn = self._log.periods[-1] if self._log.periods else None
567
+ if turn is None:
568
+ return set()
569
+ paths: set[str] = set()
570
+ for m in turn.mutations:
571
+ if m.provenance is MutationProvenance.FOREIGN:
572
+ continue
573
+ paths.add(m.path)
574
+ if m.old_path:
575
+ paths.add(m.old_path)
576
+ return paths
577
+
578
+ def cleanup_unused_snapshots(self) -> int:
579
+ """Remove snapshots from the current turn that have no corresponding mutations.
580
+
581
+ Pre-snapshots are taken eagerly before shell commands for all git-dirty
582
+ files, in case the command modifies them. If no mutation is recorded
583
+ for a file, its snapshot is wasted. This method removes those orphans
584
+ and deletes any blob files that are no longer referenced.
585
+
586
+ Returns the number of snapshots removed.
587
+ """
588
+ with self._state_lock:
589
+ turn = self._log.periods[-1] if self._log.periods else None
590
+ if turn is None:
591
+ return 0
592
+
593
+ # Paths that have actual mutations in this turn
594
+ mutated_paths: set[str] = set()
595
+ for m in turn.mutations:
596
+ mutated_paths.add(m.path)
597
+ if m.old_path:
598
+ mutated_paths.add(m.old_path)
599
+
600
+ # Find snapshots for this turn whose paths have no mutations
601
+ orphan_keys: list[str] = []
602
+ orphan_hashes: set[str] = set()
603
+ for key, snap in self._log.snapshots.items():
604
+ if snap.period_index == turn.period_index and snap.path not in mutated_paths:
605
+ orphan_keys.append(key)
606
+ if snap.content_hash:
607
+ orphan_hashes.add(snap.content_hash)
608
+
609
+ if not orphan_keys:
610
+ return 0
611
+
612
+ # Remove orphan snapshot entries
613
+ for key in orphan_keys:
614
+ del self._log.snapshots[key]
615
+ self._snapshotted.discard(key)
616
+
617
+ # Only delete blobs not referenced by any remaining snapshot or mutation
618
+ if orphan_hashes:
619
+ still_referenced = self._all_referenced_hashes()
620
+ truly_orphaned = orphan_hashes - still_referenced
621
+ if truly_orphaned:
622
+ self._store.remove_blobs(truly_orphaned)
623
+
624
+ logger.debug(
625
+ "cleanup_unused_snapshots: removed %d orphan snapshots for turn %d", len(orphan_keys), turn.period_index
626
+ )
627
+ return len(orphan_keys)
628
+
629
+ def _ensure_snapshot(self, norm_path: str, turn_id: int, *, key: str | None = None) -> FileSnapshot | None:
630
+ """Snapshot a file if not already done for this (path, turn).
631
+
632
+ ``key`` is an optional caller-computed ``MutationLog.snapshot_key`` —
633
+ pass it through to avoid recomputing the same string on the hot
634
+ path (``record()`` already has it in hand).
635
+ """
636
+ if key is None:
637
+ key = MutationLog.snapshot_key(norm_path, turn_id)
638
+ if key not in self._snapshotted:
639
+ return self._register_snapshot(self._store.save(norm_path, turn_id), key=key)
640
+ return self._log.snapshots.get(key)
641
+
642
+ def _register_snapshot(self, snapshot: FileSnapshot, *, key: str | None = None) -> FileSnapshot:
643
+ """Insert a snapshot into the log and mark its (path, turn) key seen.
644
+
645
+ Single bookkeeping primitive shared by ``_ensure_snapshot`` (disk
646
+ read), the post-exec CREATE guard in ``record()`` (synthetic
647
+ ``existed=False``), and ``record_calibrated`` (git-HEAD blob).
648
+ """
649
+ if key is None:
650
+ key = MutationLog.snapshot_key(snapshot.path, snapshot.period_index)
651
+ self._log.snapshots[key] = snapshot
652
+ self._snapshotted.add(key)
653
+ if snapshot.skip_reason is SnapshotSkipReason.UNREADABLE:
654
+ self.mark_detection_truncated()
655
+ return snapshot
656
+
657
+ # -- Query methods --
658
+
659
+ def get_period(self, scope: MutationScope) -> MutationPeriod:
660
+ """Resolve an exact execution identity, refusing unknown targets."""
661
+ with self._state_lock:
662
+ for period in self._log.periods:
663
+ if period.scope == scope:
664
+ return period
665
+ raise KeyError(f"Unknown mutation scope: {scope}")
666
+
667
+ def get_all_periods(self) -> list[MutationPeriod]:
668
+ """All execution periods in ledger order, including empty periods."""
669
+ with self._state_lock:
670
+ return list(self._log.periods)
671
+
672
+ def get_turn_mutations(self, turn_id: int) -> TurnMutations | None:
673
+ """Chat-only query retained for conversation consumers."""
674
+ with self._state_lock:
675
+ return next((turn for turn in self._log.turns if turn.turn_id == turn_id), None)
676
+
677
+ def get_all_turns(self) -> list[TurnMutations]:
678
+ """Chat-only periods; runs cannot be mistaken for conversation turns."""
679
+ with self._state_lock:
680
+ return list(self._log.turns)
681
+
682
+ def get_changed_files(self) -> list[str]:
683
+ """Unique file paths mutated across all turns (ordered by first mutation)."""
684
+ with self._state_lock:
685
+ seen: dict[str, None] = {}
686
+ for turn in self._log.periods:
687
+ for m in turn.mutations:
688
+ seen.setdefault(m.path, None)
689
+ if m.old_path:
690
+ seen.setdefault(m.old_path, None)
691
+ return list(seen)
692
+
693
+ def get_original_snapshot(self, path: str) -> FileSnapshot | None:
694
+ """Get the *earliest* snapshot for a file — its pre-session state.
695
+
696
+ Used by ``/diff`` to compare original state vs current disk.
697
+ """
698
+ with self._state_lock:
699
+ norm = os.path.normpath(os.path.abspath(path))
700
+ # Prefer an already recorded identity; resolve only a new query
701
+ # route, never rewrite a saved path after directory retargeting.
702
+ if not any(snap.path == norm for snap in self._log.snapshots.values()):
703
+ norm = entry_display_path(path)
704
+ earliest: FileSnapshot | None = None
705
+ for snap in self._log.snapshots.values():
706
+ if snap.path == norm and (earliest is None or snap.period_index < earliest.period_index):
707
+ earliest = snap
708
+ return earliest
709
+
710
+ def get_snapshot(self, path: str, turn_id: int) -> FileSnapshot | None:
711
+ """Chat-only snapshot lookup; runs use get_period_snapshot."""
712
+ with self._state_lock:
713
+ self._log.require_chat()
714
+ return self._snapshot_at_index(path, turn_id)
715
+
716
+ def get_period_snapshot(self, path: str, scope: MutationScope) -> FileSnapshot | None:
717
+ """Get the file's pre-execution snapshot using an exact Turn/Run ID."""
718
+ with self._state_lock:
719
+ return self._snapshot_at_index(path, self.get_period(scope).period_index)
720
+
721
+ def _snapshot_at_index(self, path: str, period_index: int) -> FileSnapshot | None:
722
+ with self._state_lock:
723
+ key = MutationLog.snapshot_key(path, period_index)
724
+ if key not in self._log.snapshots:
725
+ key = MutationLog.snapshot_key(entry_display_path(path), period_index)
726
+ return self._log.snapshots.get(key)
727
+
728
+ def get_period_file_summary(self, scope: MutationScope) -> dict[str, FileHashDiff]:
729
+ """Net changes made by this exact Turn or Run, independent of later edits."""
730
+ with self._state_lock:
731
+ return self._period_file_summary(self.get_period(scope).period_index)
732
+
733
+ def get_turn_file_summary(self, turn_id: int) -> dict[str, FileHashDiff]:
734
+ """Chat-only file summary for the existing conversation interfaces."""
735
+ with self._state_lock:
736
+ turn = self.get_turn_mutations(turn_id)
737
+ return {} if turn is None else self._period_file_summary(turn.period_index)
738
+
739
+ def _period_file_summary(self, period_index: int) -> dict[str, FileHashDiff]:
740
+ """Fold every touched path, including both ends of a move."""
741
+ with self._state_lock:
742
+ periods = [period for period in self._log.periods if period.period_index == period_index]
743
+ return self._summarize_periods(periods)
744
+
745
+ def get_session_file_summary(self) -> dict[str, FileHashDiff]:
746
+ """Return per-file ``{path: FileHashDiff}`` aggregated across all turns.
747
+
748
+ - ``before``: file state at the start of the *first* turn that
749
+ touched the file (i.e. its pre-session state). ``None`` means
750
+ the file did not exist before the session.
751
+ - ``after``: file state after the *last* mutation to that file
752
+ across the entire session. ``None`` means the file was deleted
753
+ (or ``record_after()`` was never called, e.g. for shell
754
+ mutations).
755
+
756
+ MOVE mutations (``m.old_path`` set) contribute an implicit
757
+ DELETE on the old path — consistent with ``get_changed_files``
758
+ and ``get_rollback_plan``.
759
+
760
+ Entries with ``is_net_zero`` (no provable net change — hashes
761
+ match and existence didn't flip) are excluded. This gives the
762
+ true session-wide before/after state for every file that was
763
+ meaningfully modified. Skip-marked creates/deletes ARE included
764
+ (existence changed even though both hashes read ``None``); the
765
+ fully-skipped modify is the one skip case that stays excluded.
766
+
767
+ Turns are iterated in ``turn_id`` order so "earliest before" and
768
+ "latest after" are chronological, not insertion-order — session
769
+ restore / retry paths can append turns with non-monotonic ids.
770
+
771
+ ``FOREIGN`` rows are excluded from the fold — see
772
+ :meth:`get_turn_file_summary` — and folded ``contested`` /
773
+ ``inferred`` badges are set on the surviving entries.
774
+ """
775
+ with self._state_lock:
776
+ return {
777
+ path: diff for path, diff in self._summarize_periods(self._log.periods).items() if not diff.is_net_zero
778
+ }
779
+
780
+ def _summarize_periods(self, periods: list[MutationPeriod]) -> dict[str, FileHashDiff]:
781
+ """One fold for both period and session summaries, including MOVE sources."""
782
+ with self._state_lock:
783
+ result: dict[str, FileHashDiff] = {}
784
+ badges = _ProvenanceBadges()
785
+
786
+ def _set_before_if_first(path: str, turn_id: int) -> tuple[str | None, SnapshotSkipReason | None]:
787
+ """Record earliest before_hash/skip for ``path``; return them."""
788
+ if path not in result:
789
+ snap_key = MutationLog.snapshot_key(path, turn_id)
790
+ snap = self._log.snapshots.get(snap_key)
791
+ before = snap.content_hash if snap else None
792
+ before_skip = snap.skip_reason if snap else None
793
+ result[path] = FileHashDiff(before=before, after=None, before_skip=before_skip)
794
+ return before, before_skip
795
+ return result[path].before, result[path].before_skip
796
+
797
+ for turn in sorted(periods, key=lambda period: period.period_index):
798
+ for m in turn.mutations:
799
+ if badges.observe(m):
800
+ continue # foreign row — never folds into endpoints
801
+ before, before_skip = _set_before_if_first(m.path, turn.period_index)
802
+ result[m.path] = FileHashDiff(
803
+ before=before,
804
+ after=m.after_hash,
805
+ before_skip=before_skip,
806
+ after_skip=m.after_skip,
807
+ )
808
+
809
+ # MOVE: old_path is effectively deleted (its content moved away).
810
+ if m.old_path:
811
+ old_before, old_before_skip = _set_before_if_first(m.old_path, turn.period_index)
812
+ result[m.old_path] = FileHashDiff(before=old_before, after=None, before_skip=old_before_skip)
813
+
814
+ return badges.apply(result)
815
+
816
+ def get_file_edit_snapshots(self) -> list[tuple[str, str]]:
817
+ """Return ``(before_text, after_text)`` for all file-tool mutations, in order.
818
+
819
+ Only includes mutations from ``write_file`` and ``edit_file``
820
+ (shell mutations are excluded). Missing blobs resolve to empty
821
+ strings (e.g. ``before_text=""`` for newly created files).
822
+ Used to build the replay mapping for DiffView rendering.
823
+ """
824
+ from chrys.foundation.text.encoding import decode_bytes
825
+
826
+ with self._state_lock:
827
+ _file_sources = {MutationSource.WRITE_FILE, MutationSource.EDIT_FILE}
828
+ results: list[tuple[str, str]] = []
829
+ for turn in self._log.periods:
830
+ for m in turn.mutations:
831
+ if m.source not in _file_sources:
832
+ continue
833
+ before_bytes = self._store.read_blob(m.before_hash) if m.before_hash else None
834
+ after_bytes = self._store.read_blob(m.after_hash) if m.after_hash else None
835
+ before_text = decode_bytes(before_bytes) if before_bytes else ""
836
+ after_text = decode_bytes(after_bytes) if after_bytes else ""
837
+ results.append((before_text, after_text))
838
+ return results
839
+
840
+ def get_file_edit_snapshot_refs(self) -> list[FileHashDiff]:
841
+ """Return before/after blob hashes for all file-tool mutations, in order."""
842
+ with self._state_lock:
843
+ _file_sources = {MutationSource.WRITE_FILE, MutationSource.EDIT_FILE}
844
+ results: list[FileHashDiff] = []
845
+ for turn in self._log.periods:
846
+ for m in turn.mutations:
847
+ if m.source not in _file_sources:
848
+ continue
849
+ results.append(
850
+ FileHashDiff(
851
+ before=m.before_hash,
852
+ after=m.after_hash,
853
+ before_skip=m.before_skip,
854
+ after_skip=m.after_skip,
855
+ )
856
+ )
857
+ return results
858
+
859
+ # -- Rollback --
860
+
861
+ def _build_rollback_plan(self, rolled_back: list[MutationPeriod]) -> RollbackPlan:
862
+ """Earliest-wins restore plan over ``rolled_back`` turns.
863
+
864
+ Caller must hold ``_state_lock``.
865
+
866
+ Safety filters run AFTER earliest-wins selection; every filtered
867
+ path is reported via :attr:`RollbackPlan.exclusions` (never
868
+ silent):
869
+
870
+ 1. **Provenance** — a path touched by a ``FOREIGN`` row, or by a
871
+ ``contested`` row, is excluded outright (poisoning-style):
872
+ restoring "our" pre-window snapshot over a path a peer
873
+ session also wrote would revert the peer's work.
874
+ 2. **Restorability** — a non-restorable earliest snapshot
875
+ (content withheld by :class:`SnapshotPolicy`) excludes the
876
+ file outright rather than silently restoring a misleading
877
+ mid-window state.
878
+ 3. **Move safety** — a MOVE is atomic for rollback: when the
879
+ source's pre-window content is unrecoverable, deleting (or
880
+ overwriting) the destination would destroy the only surviving
881
+ copy, so the destination is excluded too. Applied through
882
+ the explicit ``old_path`` pairing (with chronological
883
+ propagation across chained moves), plus a conservative
884
+ fallback for scanner-decomposed moves (``mv`` observed as
885
+ independent CREATE + DELETE): if any unrecoverable-origin
886
+ file was vacated in-window (or its after endpoint is unreadable
887
+ and may have been vacated), every plan entry whose current
888
+ on-disk content is skip-marked is left untouched — it may be
889
+ that escaped content.
890
+ """
891
+ earliest: dict[str, FileSnapshot] = {}
892
+ foreign_touched: set[str] = set()
893
+ contested: set[str] = set()
894
+ for turn in rolled_back:
895
+ for m in turn.mutations:
896
+ if m.provenance is MutationProvenance.FOREIGN:
897
+ foreign_touched.add(m.path)
898
+ if m.old_path:
899
+ foreign_touched.add(m.old_path)
900
+ if m.contested:
901
+ contested.add(m.path)
902
+ if m.old_path:
903
+ contested.add(m.old_path)
904
+ key = MutationLog.snapshot_key(m.path, turn.period_index)
905
+ snap = self._log.snapshots.get(key)
906
+ if snap and (m.path not in earliest or turn.period_index < earliest[m.path].period_index):
907
+ earliest[m.path] = snap
908
+ if m.old_path:
909
+ old_key = MutationLog.snapshot_key(m.old_path, turn.period_index)
910
+ old_snap = self._log.snapshots.get(old_key)
911
+ if old_snap and (
912
+ m.old_path not in earliest or turn.period_index < earliest[m.old_path].period_index
913
+ ):
914
+ earliest[m.old_path] = old_snap
915
+
916
+ poisoned: set[str] = set()
917
+ vacated: set[str] = set()
918
+ last_after_skip: dict[str, SnapshotSkipReason | None] = {}
919
+ for turn in sorted(rolled_back, key=lambda t: t.period_index):
920
+ for m in turn.mutations:
921
+ if m.operation is MutationOp.DELETE or m.after_skip is SnapshotSkipReason.UNREADABLE:
922
+ # An unreadable endpoint may be a vacated move source.
923
+ # record_after correctly avoids asserting DELETE, but
924
+ # that uncertainty must not release the last-copy guard.
925
+ vacated.add(m.path)
926
+ last_after_skip[m.path] = m.after_skip
927
+ if m.old_path:
928
+ vacated.add(m.old_path)
929
+ src = earliest.get(m.old_path)
930
+ if src is None or not src.restorable or m.old_path in poisoned:
931
+ poisoned.add(m.path)
932
+
933
+ unrecoverable_escape = any((snap := earliest.get(path)) is not None and not snap.restorable for path in vacated)
934
+ if unrecoverable_escape:
935
+ poisoned.update(path for path in earliest if last_after_skip.get(path) is not None)
936
+
937
+ # Reason precedence (display-only, one per path): strongest
938
+ # attribution first — not-ours beats ours-but-conflicted beats
939
+ # content-availability mechanics.
940
+ entries: list[tuple[str, FileSnapshot]] = []
941
+ exclusions: list[tuple[str, RollbackExclusionReason]] = []
942
+ for path, snap in earliest.items():
943
+ if path in foreign_touched:
944
+ exclusions.append((path, RollbackExclusionReason.FOREIGN))
945
+ elif path in contested:
946
+ exclusions.append((path, RollbackExclusionReason.CONTESTED))
947
+ elif not snap.restorable:
948
+ exclusions.append((path, RollbackExclusionReason.UNRESTORABLE))
949
+ elif path in poisoned:
950
+ exclusions.append((path, RollbackExclusionReason.MOVE_POISONED))
951
+ else:
952
+ entries.append((path, snap))
953
+ exclusions.sort(key=lambda item: item[0])
954
+ return RollbackPlan(entries=entries, exclusions=exclusions)
955
+
956
+ def get_rollback_plan_for_periods(self, scopes: set[MutationScope]) -> RollbackPlan:
957
+ """Build a file-only plan from exact execution identities.
958
+
959
+ No conversation/session restoration is implied. Unknown IDs fail rather
960
+ than silently producing an empty plan. File ownership and unavailable
961
+ snapshot exclusions are shared with Chat rollback.
962
+ """
963
+ with self._state_lock:
964
+ periods = [self.get_period(scope) for scope in scopes]
965
+ return self._build_rollback_plan(sorted(periods, key=lambda period: period.period_index))
966
+
967
+ def restore_files(self, plan: RollbackPlan, only_paths: set[str] | None = None) -> list[RestoreResult]:
968
+ """Apply an already selected file plan, preserving the execution history.
969
+
970
+ The session owner supplies locking, stale-selection checks, attribution
971
+ refresh, persistence and result reporting. Chat's destructive rollback
972
+ methods separately truncate its conversation mutation timeline.
973
+ """
974
+ with self._state_lock:
975
+ entries = plan.entries
976
+ if only_paths is not None:
977
+ entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
978
+ return self._restore_plan(entries)
979
+
980
+ def get_rollback_plan(self, num_turns: int = 1) -> RollbackPlan:
981
+ """Compute which files to restore for rolling back the last N turns.
982
+
983
+ For each file mutated in the rolled-back turns, the snapshot
984
+ from the *earliest* rolled-back turn that touched that file is
985
+ used — representing the file state just before those turns began.
986
+
987
+ Returns:
988
+ A :class:`RollbackPlan`. ``entries`` holds ``(path,
989
+ snapshot)`` pairs: ``existed=False`` snapshots mean the file
990
+ should be deleted (it was created during the rolled-back
991
+ turns), ``existed=True`` means restore from blob. Files
992
+ rollback cannot safely act on are listed in ``exclusions``
993
+ with their reason — see :meth:`_build_rollback_plan` for the
994
+ exact filters.
995
+ """
996
+ with self._state_lock:
997
+ self._log.require_chat() # Chat rollback owns conversation truncation.
998
+ if not self._log.periods or num_turns < 1:
999
+ return RollbackPlan(entries=[])
1000
+
1001
+ n = min(num_turns, len(self._log.periods))
1002
+ return self._build_rollback_plan(self._log.periods[-n:])
1003
+
1004
+ def get_rollback_plan_for_turns(self, turn_ids: set[int]) -> RollbackPlan:
1005
+ """Compute which files to restore for rolling back explicit turn IDs.
1006
+
1007
+ Unlike :meth:`get_rollback_plan`, this is independent of log
1008
+ insertion order. Rollback controller callers use this when they
1009
+ already know the target turn and need to undo every mutation with
1010
+ ``turn_id > target_turn``.
1011
+ """
1012
+ with self._state_lock:
1013
+ self._log.require_chat() # Chat rollback owns conversation truncation.
1014
+ if not self._log.periods or not turn_ids:
1015
+ return RollbackPlan(entries=[])
1016
+
1017
+ return self._build_rollback_plan([turn for turn in self._log.periods if turn.period_index in turn_ids])
1018
+
1019
+ def rollback(self, num_turns: int = 1, only_paths: set[str] | None = None) -> list[RestoreResult]:
1020
+ """Execute a rollback of the last N turns.
1021
+
1022
+ Restores each affected file to its pre-rollback-window state
1023
+ using :meth:`SnapshotStore.restore`, then removes the
1024
+ rolled-back turns and their snapshots from the log.
1025
+
1026
+ CONTRACT (see :meth:`SnapshotStore.restore` for the full text):
1027
+ revert is unconditional — each file in the plan is forced to
1028
+ the snapshot's pre-turn state regardless of its current disk
1029
+ state. Users opt out of a file by de-selecting it in the
1030
+ rollback modal before pressing confirm; by the time we get
1031
+ here the selection IS the user's decision.
1032
+
1033
+ Args:
1034
+ num_turns: How many most-recent turns to remove from the log.
1035
+ only_paths: If not ``None``, restore only the files whose
1036
+ absolute path is in this set. Paths not in the set keep
1037
+ their current on-disk content — the rollback modal's
1038
+ per-file checkbox selection passes the checked paths
1039
+ here. ``None`` preserves the historical "restore
1040
+ everything in the plan" behaviour for non-UI callers.
1041
+
1042
+ Returns:
1043
+ One :class:`RestoreResult` per file in the plan (after
1044
+ ``only_paths`` filtering), carrying the per-path outcome.
1045
+ Use ``r.changed`` to count files whose on-disk state
1046
+ actually changed, ``r.ok`` for "ended up in target state
1047
+ (applied or already matched)", or inspect ``r.reason`` on
1048
+ FAILED entries for the specific failure mode.
1049
+ """
1050
+ with self._state_lock:
1051
+ self._log.require_chat() # Chat rollback owns conversation truncation.
1052
+ entries = self.get_rollback_plan(num_turns).entries
1053
+ if only_paths is not None:
1054
+ entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
1055
+
1056
+ results = self._restore_plan(entries)
1057
+
1058
+ # Remove rolled-back turns from the log + clean up
1059
+ self._remove_last_turns(min(num_turns, len(self._log.periods)))
1060
+
1061
+ return results
1062
+
1063
+ def rollback_turns(
1064
+ self,
1065
+ turn_ids: set[int],
1066
+ only_paths: set[str] | None = None,
1067
+ *,
1068
+ plan: RollbackPlan | None = None,
1069
+ ) -> list[RestoreResult]:
1070
+ """Execute a rollback for explicit turn IDs.
1071
+
1072
+ This is the controller-facing variant used when the target turn
1073
+ is known. It removes all matching turn entries from the mutation
1074
+ log, regardless of their insertion order.
1075
+
1076
+ ``plan`` lets the caller execute from a pre-built
1077
+ :class:`RollbackPlan` — the engine builds the plan *first* so
1078
+ its exclusions/warnings can still be reported after this method
1079
+ removes the turns they were derived from (they cannot be
1080
+ reconstructed afterwards). ``None`` builds the plan here.
1081
+ """
1082
+ with self._state_lock:
1083
+ self._log.require_chat() # Chat rollback owns conversation truncation.
1084
+ if not turn_ids:
1085
+ return []
1086
+
1087
+ if plan is None:
1088
+ plan = self.get_rollback_plan_for_turns(turn_ids)
1089
+ entries = plan.entries
1090
+ if only_paths is not None:
1091
+ entries = [(path, snapshot) for path, snapshot in entries if path in only_paths]
1092
+
1093
+ results = self._restore_plan(entries)
1094
+ self._remove_turn_ids(turn_ids)
1095
+ return results
1096
+
1097
+ def _restore_plan(self, plan: list[tuple[str, FileSnapshot]]) -> list[RestoreResult]:
1098
+ """Restore each snapshot in a rollback plan best-effort."""
1099
+ from chrys.service.mutations.types import RestoreOutcome
1100
+
1101
+ results: list[RestoreResult] = []
1102
+ for _path, snapshot in plan:
1103
+ try:
1104
+ results.append(self._store.restore(snapshot))
1105
+ except Exception as exc:
1106
+ logger.exception("Rollback: unexpected error restoring %s", snapshot.path)
1107
+ results.append(
1108
+ RestoreResult(path=snapshot.path, outcome=RestoreOutcome.FAILED, reason=str(exc)),
1109
+ )
1110
+ return results
1111
+
1112
+ # -- Cleanup --
1113
+
1114
+ def remove_turn(self, turn_id: int) -> set[str]:
1115
+ """Remove a specific turn and its snapshots from the log.
1116
+
1117
+ Deletes blob files that are no longer referenced by any remaining
1118
+ snapshot or mutation. Returns the set of content hashes whose
1119
+ blobs were removed from disk.
1120
+
1121
+ This is a data-management operation — it does NOT restore files.
1122
+ Use :meth:`rollback` to undo mutations.
1123
+ """
1124
+ with self._state_lock:
1125
+ self._log.require_chat() # Chat rollback owns conversation truncation.
1126
+ # Collect all hashes from this turn: snapshots + mutation after_hashes
1127
+ turn_hashes: set[str] = set()
1128
+ keys_to_remove: list[str] = []
1129
+ for key, snap in self._log.snapshots.items():
1130
+ if snap.period_index == turn_id:
1131
+ if snap.content_hash:
1132
+ turn_hashes.add(snap.content_hash)
1133
+ keys_to_remove.append(key)
1134
+ self._snapshotted.discard(key)
1135
+
1136
+ # Collect after_hash values from this turn's mutations
1137
+ removed_turn = None
1138
+ for t in self._log.periods:
1139
+ if t.period_index == turn_id:
1140
+ removed_turn = t
1141
+ break
1142
+ if removed_turn:
1143
+ for m in removed_turn.mutations:
1144
+ if m.before_hash:
1145
+ turn_hashes.add(m.before_hash)
1146
+ if m.after_hash:
1147
+ turn_hashes.add(m.after_hash)
1148
+
1149
+ for key in keys_to_remove:
1150
+ del self._log.snapshots[key]
1151
+
1152
+ # Remove the turn entry
1153
+ self._log.periods = [t for t in self._log.periods if t.period_index != turn_id]
1154
+
1155
+ # Find hashes still referenced by remaining snapshots or mutations
1156
+ still_referenced = self._all_referenced_hashes()
1157
+ orphaned = turn_hashes - still_referenced
1158
+
1159
+ if orphaned:
1160
+ self._store.remove_blobs(orphaned)
1161
+
1162
+ return orphaned
1163
+
1164
+ def clear(self) -> None:
1165
+ """Remove all turns, snapshots, and blob files.
1166
+
1167
+ Called when a session is deleted or fully reset.
1168
+ """
1169
+ with self._state_lock:
1170
+ self._log.periods.clear()
1171
+ self._log.snapshots.clear()
1172
+ self._snapshotted.clear()
1173
+ self._store.remove_all()
1174
+
1175
+ def _all_referenced_hashes(self) -> set[str]:
1176
+ """Collect all content hashes still referenced by remaining data."""
1177
+ hashes: set[str] = set()
1178
+ for s in self._log.snapshots.values():
1179
+ if s.content_hash:
1180
+ hashes.add(s.content_hash)
1181
+ for t in self._log.periods:
1182
+ for m in t.mutations:
1183
+ if m.before_hash:
1184
+ hashes.add(m.before_hash)
1185
+ if m.after_hash:
1186
+ hashes.add(m.after_hash)
1187
+ return hashes
1188
+
1189
+ def _remove_last_turns(self, n: int) -> None:
1190
+ """Remove the last *n* turns and clean up orphaned blobs."""
1191
+ if n <= 0 or not self._log.periods:
1192
+ return
1193
+
1194
+ removed_turns = self._log.periods[-n:]
1195
+ self._log.periods = self._log.periods[:-n]
1196
+
1197
+ removed_hashes: set[str] = set()
1198
+ for turn in removed_turns:
1199
+ for m in turn.mutations:
1200
+ # A calibrated row's fresh before blob is neither the turn
1201
+ # snapshot nor any after endpoint — collect both sides.
1202
+ if m.before_hash:
1203
+ removed_hashes.add(m.before_hash)
1204
+ if m.after_hash:
1205
+ removed_hashes.add(m.after_hash)
1206
+ # Collect snapshot (before) hashes
1207
+ key = MutationLog.snapshot_key(m.path, turn.period_index)
1208
+ snap = self._log.snapshots.pop(key, None)
1209
+ self._snapshotted.discard(key)
1210
+ if snap and snap.content_hash:
1211
+ removed_hashes.add(snap.content_hash)
1212
+ if m.old_path:
1213
+ old_key = MutationLog.snapshot_key(m.old_path, turn.period_index)
1214
+ old_snap = self._log.snapshots.pop(old_key, None)
1215
+ self._snapshotted.discard(old_key)
1216
+ if old_snap and old_snap.content_hash:
1217
+ removed_hashes.add(old_snap.content_hash)
1218
+
1219
+ # Only delete blobs not referenced by remaining snapshots/mutations
1220
+ still_referenced = self._all_referenced_hashes()
1221
+ orphaned = removed_hashes - still_referenced
1222
+ if orphaned:
1223
+ self._store.remove_blobs(orphaned)
1224
+
1225
+ def _remove_turn_ids(self, turn_ids: set[int]) -> None:
1226
+ """Remove explicit turn IDs and clean up orphaned blobs."""
1227
+ if not turn_ids or not self._log.periods:
1228
+ return
1229
+
1230
+ removed_turns = [turn for turn in self._log.periods if turn.period_index in turn_ids]
1231
+ if not removed_turns:
1232
+ return
1233
+ self._log.periods = [turn for turn in self._log.periods if turn.period_index not in turn_ids]
1234
+
1235
+ removed_hashes: set[str] = set()
1236
+ for turn in removed_turns:
1237
+ for m in turn.mutations:
1238
+ if m.before_hash:
1239
+ removed_hashes.add(m.before_hash)
1240
+ if m.after_hash:
1241
+ removed_hashes.add(m.after_hash)
1242
+
1243
+ keys_to_remove = [key for key, snap in self._log.snapshots.items() if snap.period_index in turn_ids]
1244
+ for key in keys_to_remove:
1245
+ snap = self._log.snapshots.pop(key)
1246
+ self._snapshotted.discard(key)
1247
+ if snap.content_hash:
1248
+ removed_hashes.add(snap.content_hash)
1249
+
1250
+ still_referenced = self._all_referenced_hashes()
1251
+ orphaned = removed_hashes - still_referenced
1252
+ if orphaned:
1253
+ self._store.remove_blobs(orphaned)
1254
+
1255
+ # -- Serialization --
1256
+
1257
+ def serialize(self) -> dict[str, Any]:
1258
+ """Serialize for session persistence (JSON-compatible dict)."""
1259
+ with self._state_lock:
1260
+ return {
1261
+ ("runs" if any(isinstance(period, RunMutations) for period in self._log.periods) else "turns"): [
1262
+ period.to_dict() for period in self._log.periods
1263
+ ],
1264
+ "snapshots": {k: s.to_dict() for k, s in self._log.snapshots.items()},
1265
+ }
1266
+
1267
+ @classmethod
1268
+ def deserialize(cls, data: dict[str, Any], store: SnapshotStore) -> MutationTracker:
1269
+ """Restore a MutationTracker from serialized session data."""
1270
+ tracker = cls(store)
1271
+ if "runs" in data and "turns" in data:
1272
+ raise ValueError("A mutation ledger cannot mix Chat turns and Workflow runs")
1273
+ for t_data in data.get("turns", []):
1274
+ if "run_id" in t_data:
1275
+ raise ValueError("Workflow runs require their own mutation ledger")
1276
+ tracker._log.periods.append(TurnMutations.from_dict(t_data))
1277
+ for item in data.get("runs", []):
1278
+ period = RunMutations.from_dict(item)
1279
+ if any(
1280
+ prior.scope == period.scope or prior.period_index >= period.period_index
1281
+ for prior in tracker._log.periods
1282
+ ):
1283
+ raise ValueError("Duplicate or unordered workflow mutation period")
1284
+ tracker._log.periods.append(period)
1285
+ for key, s_data in data.get("snapshots", {}).items():
1286
+ snap = FileSnapshot.from_dict(s_data)
1287
+ tracker._log.snapshots[key] = snap
1288
+ tracker._snapshotted.add(key)
1289
+ return tracker