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,1583 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """Workspace baselines, external-change detection, and notice rendering."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import errno
8
+ import json
9
+ import logging
10
+ import os
11
+ import stat
12
+ import sys
13
+ import threading
14
+ import time
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass, replace
17
+ from enum import Enum
18
+ from pathlib import PurePath
19
+ from typing import TYPE_CHECKING, Any, cast
20
+
21
+ from chrys.foundation.platform.paths import display_relpath
22
+ from chrys.service.mutations.git_state import (
23
+ GIT_PATH_LIMIT,
24
+ GitDeltaEntry,
25
+ canonical_git_pathspecs,
26
+ canonical_path,
27
+ entry_display_path,
28
+ entry_path,
29
+ lexical_display_path,
30
+ read_git_head,
31
+ read_git_head_delta,
32
+ read_git_status,
33
+ resolve_git_root,
34
+ )
35
+ from chrys.service.mutations.scanner import DEFAULT_EXCLUDES, WorkspaceScanner
36
+ from chrys.service.mutations.store import SnapshotStore
37
+ from chrys.service.mutations.types import FileHashDiff, MutationOp, MutationProvenance, SnapshotSkipReason
38
+
39
+ if TYPE_CHECKING:
40
+ from chrys.foundation.models.workspace import Workspace
41
+ from chrys.service.mutations.tracker import MutationTracker
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _MAX_CAPTURE_ROOTS = 32
46
+ _CAPTURE_DEADLINE_SECONDS = 15.0
47
+ _SCAN_OVERFLOW_SENTINEL = 5_001
48
+ _DIAGNOSTIC_LINE_LIMIT = 8
49
+ _SERIALIZATION_VERSION = 1
50
+ _NOTICE_TIMEOUT_WARNING = (
51
+ "Workspace change detection could not be completed for this turn. Files may "
52
+ "have been changed outside this session without being reported, and later "
53
+ "notices will not include them. Re-read externally relevant files before "
54
+ "relying on their remembered contents."
55
+ )
56
+
57
+
58
+ class BaselineMode(Enum):
59
+ """How a workspace root is observed."""
60
+
61
+ GIT = "git"
62
+ SCAN = "scan"
63
+
64
+
65
+ class DegradedReason(Enum):
66
+ """Why a complete comparable root baseline is unavailable."""
67
+
68
+ TOO_MANY_FILES = "too_many_files"
69
+ CAPTURE_FAILED = "capture_failed"
70
+ CAPTURE_DEADLINE = "capture_deadline"
71
+ MISSING_BASELINE = "missing_baseline"
72
+
73
+
74
+ class ExternalOp(Enum):
75
+ """Operation shown in the external-change section."""
76
+
77
+ CREATED = "created"
78
+ MODIFIED = "modified"
79
+ DELETED = "deleted"
80
+ RECONCILED = "reconciled"
81
+ TRACKING_CHANGED = "tracking_changed"
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class BaselineFileState:
86
+ """Filesystem and Git state for one observed path."""
87
+
88
+ exists: bool
89
+ mtime_ns: int
90
+ size: int
91
+ mode: int | None
92
+ statuses: tuple[str, ...]
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class RootBaseline:
97
+ """One complete root baseline."""
98
+
99
+ root: str
100
+ mode: BaselineMode
101
+ head: str | None
102
+ branch: str | None
103
+ scope: tuple[str, ...]
104
+ files: Mapping[str, BaselineFileState]
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class DegradedBaseline:
109
+ """One incomplete root capture."""
110
+
111
+ reason: DegradedReason
112
+ mode: BaselineMode | None
113
+ scope: tuple[str, ...]
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class WorkspaceBaseline:
118
+ """Complete published workspace state for a turn boundary."""
119
+
120
+ turn_id: int
121
+ roots: Mapping[str, RootBaseline]
122
+ degraded: Mapping[str, DegradedBaseline]
123
+ root_limit_omitted: int = 0
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class AgentChange:
128
+ """One agent-attributed change ready for rendering."""
129
+
130
+ path: str
131
+ operation: MutationOp
132
+ content_not_compared: bool = False
133
+ contested: bool = False
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class PeerChange:
138
+ """One peer-session write observed by this session's mutation log.
139
+
140
+ Peer writes land before the end-of-turn baseline capture, so the
141
+ next external diff cannot see them — the turn log is the only
142
+ witness and these rows must be carried into the notice directly.
143
+ """
144
+
145
+ path: str
146
+ operation: MutationOp
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class ExternalChange:
151
+ """One externally observed change ready for rendering."""
152
+
153
+ root: str
154
+ path: str
155
+ operation: ExternalOp
156
+ agent_overlap: bool = False
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class HeadMove:
161
+ """One scoped repository HEAD movement."""
162
+
163
+ root: str
164
+ old_head: str | None
165
+ new_head: str | None
166
+ uncertain: bool = False
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class WorkspaceChangeNotice:
171
+ """Bounded notice payload independent of rendering cwd."""
172
+
173
+ agent_changes: tuple[AgentChange, ...] = ()
174
+ agent_omitted: int = 0
175
+ external_changes: tuple[ExternalChange, ...] = ()
176
+ external_omitted: int = 0
177
+ peer_changes: tuple[PeerChange, ...] = ()
178
+ peer_omitted: int = 0
179
+ head_moves: tuple[HeadMove, ...] = ()
180
+ degraded: tuple[tuple[str, DegradedBaseline], ...] = ()
181
+ root_limit_omitted: int = 0
182
+ agent_detection_truncated: bool = False
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class _CaptureRoot:
187
+ root: str
188
+ mode: BaselineMode | None
189
+ scope: tuple[str, ...]
190
+ exclude_roots: frozenset[str] = frozenset()
191
+ unavailable_reason: DegradedReason | None = None
192
+
193
+
194
+ @dataclass(frozen=True, slots=True)
195
+ class _ResolvedScope:
196
+ roots: tuple[_CaptureRoot, ...]
197
+ root_limit_omitted: int = 0
198
+ display_roots: tuple[tuple[str, str], ...] = ()
199
+
200
+
201
+ @dataclass(frozen=True, slots=True, kw_only=True)
202
+ class WorkspaceRetarget:
203
+ """Resolved workspace values ready to install without filesystem access."""
204
+
205
+ scope: _ResolvedScope
206
+ resolve_scope: bool
207
+ new_cwd: str | None
208
+ canonical_new_cwd: str | None
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class _SafetyNotice:
213
+ """A queued safety notice plus the cwd its relative paths were rendered
214
+ against; ``None`` once the base is unknown or already annotated."""
215
+
216
+ text: str
217
+ cwd: str | None = None
218
+ canonical_cwd: str | None = None
219
+
220
+
221
+ class WorkspaceChangeTracker:
222
+ """Generation-guarded workspace state owned by one engine."""
223
+
224
+ def __init__(self) -> None:
225
+ self._lock = threading.RLock()
226
+ self._capture_gate = threading.Lock()
227
+ self._generation = 0
228
+ self._scope = _ResolvedScope(roots=())
229
+ self._baseline: WorkspaceBaseline | None = None
230
+ self._pending_boundary_notice: str | None = None
231
+ self._pending_safety: list[_SafetyNotice] = []
232
+
233
+ @property
234
+ def baseline(self) -> WorkspaceBaseline | None:
235
+ """Return the immutable current baseline."""
236
+ with self._lock:
237
+ return self._baseline
238
+
239
+ def retarget_roots(self, workspace: Workspace | None, *, resolve_scope: bool = True) -> None:
240
+ """Resolve a new desired scope and retain only comparable baseline roots.
241
+
242
+ Args:
243
+ resolve_scope: ``False`` when the change notice is disabled: the
244
+ scope is then left inactive (empty) without probing the roots
245
+ at all, and the baseline is kept as it is rather than
246
+ reconciled against that empty scope. Resolution asks Git about
247
+ every root — a synchronous subprocess per root, on the
248
+ caller's thread — and nothing reads the scope while the
249
+ notice is off; re-enabling the setting rebuilds the runtime,
250
+ which retargets again with resolution on and reconciles the
251
+ kept baseline then. Safety notices are rebased regardless:
252
+ they are delivered whether or not the notice is enabled.
253
+ """
254
+ self.apply_retarget(self.resolve_retarget(workspace, resolve_scope=resolve_scope))
255
+
256
+ def resolve_retarget(self, workspace: Workspace | None, *, resolve_scope: bool = True) -> WorkspaceRetarget:
257
+ """Resolve candidate scope and directory identity without writing live state."""
258
+ scope = _resolve_workspace_scope(workspace) if resolve_scope else _ResolvedScope(roots=())
259
+ new_cwd = os.path.normpath(os.path.abspath(workspace.primary_cwd)) if workspace is not None else None
260
+ return WorkspaceRetarget(
261
+ scope=scope,
262
+ resolve_scope=resolve_scope,
263
+ new_cwd=new_cwd,
264
+ canonical_new_cwd=canonical_path(new_cwd) if new_cwd is not None else None,
265
+ )
266
+
267
+ def apply_retarget(self, retarget: WorkspaceRetarget) -> None:
268
+ """Install the resolved workspace values and rebase queued notices in memory."""
269
+ with self._lock:
270
+ old_baseline = self._baseline
271
+ self._generation += 1
272
+ self._scope = retarget.scope
273
+ self._pending_boundary_notice = None
274
+ # Safety notices deliberately survive a workspace switch, but
275
+ # their relative paths were rendered against the old cwd —
276
+ # pin the base explicitly so the model does not resolve them
277
+ # against the new workspace.
278
+ self._pending_safety = [_rebase_safety_notice(chunk, retarget) for chunk in self._pending_safety]
279
+ if old_baseline is None or not retarget.resolve_scope:
280
+ return
281
+ self._baseline = _reconcile_baseline(old_baseline, retarget.scope)
282
+
283
+ def reset_for_restart(self) -> None:
284
+ """Clear session-owned state while preserving the tracker instance."""
285
+ with self._lock:
286
+ self._generation += 1
287
+ self._scope = _ResolvedScope(roots=())
288
+ self._baseline = None
289
+ self._pending_boundary_notice = None
290
+ self._pending_safety = []
291
+
292
+ def invalidate(self) -> None:
293
+ """Invalidate the external baseline without clearing safety delivery."""
294
+ with self._lock:
295
+ self._generation += 1
296
+ self._baseline = None
297
+ self._pending_boundary_notice = None
298
+
299
+ def capture_baseline(self, turn_id: int) -> WorkspaceBaseline:
300
+ """Capture and publish a complete turn-end baseline."""
301
+ if not self._capture_gate.acquire(blocking=False):
302
+ return self._install_capture_deadline(turn_id)
303
+ try:
304
+ with self._lock:
305
+ generation = self._generation
306
+ scope = self._scope
307
+ result = _capture_workspace(turn_id, scope, deadline=time.monotonic() + _CAPTURE_DEADLINE_SECONDS)
308
+ with self._lock:
309
+ if generation == self._generation and scope == self._scope:
310
+ self._baseline = result
311
+ return result
312
+ finally:
313
+ self._capture_gate.release()
314
+
315
+ def capture_timed_out(self, turn_id: int) -> None:
316
+ """Supersede an uninterruptible capture worker after its async deadline."""
317
+ self._install_capture_deadline(turn_id)
318
+
319
+ def capture_cancelled(self) -> None:
320
+ """Reject a capture result whose awaiting lifecycle was cancelled."""
321
+ self.invalidate()
322
+
323
+ def compute_turn_notice(
324
+ self,
325
+ *,
326
+ turn_id: int,
327
+ mutation_tracker: MutationTracker | None,
328
+ agent_turn_id: int | None,
329
+ overlap_turn_id: int | None,
330
+ cwd: str,
331
+ max_entries: int,
332
+ recovered: bool = False,
333
+ latest_agent_turn: int | None = None,
334
+ ) -> str | None:
335
+ """Compute and publish the next one-shot boundary notice."""
336
+ with self._lock:
337
+ generation = self._generation
338
+ scope = self._scope
339
+ baseline = self._baseline
340
+
341
+ agent_changes, agent_truncated = _agent_changes(mutation_tracker, agent_turn_id)
342
+ # Retry omits the agent section (``agent_turn_id`` is None) but must
343
+ # not drop the interrupted turn's truncation flag with it — the
344
+ # unobserved writes it stands for get absorbed by the baseline, so
345
+ # this warning is their only surface.
346
+ agent_truncated = agent_truncated or _detection_truncated(mutation_tracker, latest_agent_turn)
347
+ overlap = _mutation_paths(mutation_tracker, overlap_turn_id, overlap_turn_id)
348
+ external: list[ExternalChange] = []
349
+ head_moves: list[HeadMove] = []
350
+ degraded: dict[str, DegradedBaseline] = {}
351
+ root_limit_omitted = baseline.root_limit_omitted if baseline is not None else 0
352
+
353
+ if baseline is not None:
354
+ if not self._capture_gate.acquire(blocking=False):
355
+ degraded = {
356
+ root.root: DegradedBaseline(DegradedReason.CAPTURE_DEADLINE, root.mode, root.scope)
357
+ for root in scope.roots
358
+ }
359
+ with self._lock:
360
+ self._generation += 1
361
+ generation = self._generation
362
+ else:
363
+ deadline = time.monotonic() + _CAPTURE_DEADLINE_SECONDS
364
+ try:
365
+ current = _capture_workspace(turn_id, scope, deadline=deadline)
366
+ external, head_moves, degraded = _diff_workspace(baseline, current, deadline=deadline)
367
+ root_limit_omitted = current.root_limit_omitted
368
+ finally:
369
+ self._capture_gate.release()
370
+
371
+ if recovered:
372
+ if mutation_tracker is None:
373
+ degraded["recovery"] = DegradedBaseline(
374
+ DegradedReason.MISSING_BASELINE,
375
+ None,
376
+ (),
377
+ )
378
+ external = []
379
+ else:
380
+ # Subtract only rows the recovered log fully explains: a
381
+ # recovered mutation names the path AND current disk
382
+ # provably matches its recorded endpoint. Anything else —
383
+ # a post-crash edit on top of the agent's write, or an
384
+ # unverifiable endpoint — stays visible as external.
385
+ endpoints = _recovered_endpoints(
386
+ mutation_tracker,
387
+ baseline.turn_id + 1,
388
+ latest_agent_turn,
389
+ )
390
+ max_verify_bytes = mutation_tracker.store.policy.max_file_bytes
391
+ external = [
392
+ entry
393
+ for entry in external
394
+ if not _matches_recovered_endpoint(
395
+ endpoints.get(entry_path(entry.path)), entry.path, max_verify_bytes
396
+ )
397
+ ]
398
+
399
+ agent_changes.sort(key=lambda entry: canonical_path(entry.path))
400
+ bounded_agent = tuple(agent_changes[:max_entries])
401
+
402
+ peer_changes = _peer_witnessed_changes(mutation_tracker, latest_agent_turn)
403
+ if peer_changes:
404
+ # The current diff (fresher state) and the agent rows that will
405
+ # actually be displayed (whose contested badge already names the
406
+ # peer) both outrank a raw peer row for the same path. Rows folded
407
+ # into the "...and N more" overflow never render their badge, so
408
+ # they must not suppress the peer fallback.
409
+ covered = {entry_path(entry.path) for entry in external}
410
+ covered.update(entry_path(entry.path) for entry in bounded_agent)
411
+ peer_changes = [change for change in peer_changes if entry_path(change.path) not in covered]
412
+ peer_changes = [replace(change, path=_display_path(change.path, scope)) for change in peer_changes]
413
+ peer_changes.sort(key=lambda change: canonical_path(change.path))
414
+
415
+ external = [replace(entry, agent_overlap=entry_path(entry.path) in overlap) for entry in external]
416
+ external = [
417
+ replace(
418
+ entry,
419
+ root=_display_path(entry.root, scope),
420
+ path=_display_path(entry.path, scope),
421
+ )
422
+ for entry in external
423
+ ]
424
+ head_moves = [replace(move, root=_display_path(move.root, scope)) for move in head_moves]
425
+ degraded = {_display_path(root, scope): value for root, value in degraded.items()}
426
+ external.sort(key=lambda entry: (entry.root, canonical_path(entry.path)))
427
+ bounded_external = tuple(external[:max_entries])
428
+ bounded_peer = tuple(peer_changes[:max_entries])
429
+ notice = WorkspaceChangeNotice(
430
+ agent_changes=tuple(replace(entry, path=_display_path(entry.path, scope)) for entry in bounded_agent),
431
+ agent_omitted=max(0, len(agent_changes) - len(bounded_agent)),
432
+ external_changes=bounded_external,
433
+ external_omitted=max(0, len(external) - len(bounded_external)),
434
+ peer_changes=bounded_peer,
435
+ peer_omitted=max(0, len(peer_changes) - len(bounded_peer)),
436
+ head_moves=tuple(head_moves),
437
+ degraded=tuple(sorted(degraded.items())),
438
+ root_limit_omitted=root_limit_omitted,
439
+ agent_detection_truncated=agent_truncated,
440
+ )
441
+ rendered = format_workspace_change_notice(notice, cwd=cwd)
442
+ with self._lock:
443
+ if generation == self._generation and scope == self._scope:
444
+ self._pending_boundary_notice = rendered
445
+ return rendered
446
+
447
+ def notice_cancelled(self) -> None:
448
+ """Reject a late notice worker while retaining the saved baseline."""
449
+ with self._lock:
450
+ self._generation += 1
451
+ self._pending_boundary_notice = None
452
+
453
+ def notice_timed_out(self) -> None:
454
+ """Reject a late notice worker and warn that the comparison is lost.
455
+
456
+ Unlike cancellation — where the turn unwinds and the retained
457
+ baseline gets compared again at the next boundary — a timed-out
458
+ turn continues to finalization, whose fresh capture absorbs
459
+ everything the comparison would have reported. Queue a
460
+ persistent safety caveat so that absorption is never silent.
461
+ """
462
+ self.notice_cancelled()
463
+ self.queue_safety_notice(_NOTICE_TIMEOUT_WARNING)
464
+
465
+ def clear_boundary_notice(self) -> None:
466
+ """Clear only ordinary boundary state after an advisory failure."""
467
+ with self._lock:
468
+ self._pending_boundary_notice = None
469
+
470
+ def queue_safety_notice(self, notice: str, *, cwd: str | None = None) -> None:
471
+ """Queue an explicit safety warning independently of boundary compute.
472
+
473
+ ``cwd`` records the base its relative paths were rendered against,
474
+ so a later workspace switch can pin the base instead of letting
475
+ the paths silently re-anchor to the new workspace.
476
+ """
477
+ if not notice:
478
+ return
479
+ base = os.path.normpath(os.path.abspath(cwd)) if cwd else None
480
+ chunk = _resolve_safety_notice(_SafetyNotice(notice, base))
481
+ with self._lock:
482
+ self._pending_safety.append(chunk)
483
+
484
+ def requeue_notice(self, notice: str, *, cwd: str | None = None) -> None:
485
+ """Preserve a notice drained for a pre-executor run that never reached the model."""
486
+ self.queue_safety_notice(notice, cwd=cwd)
487
+
488
+ def take_pending_notice(self) -> str | None:
489
+ """Atomically drain safety first and boundary second exactly once."""
490
+ with self._lock:
491
+ safety_chunks = self._pending_safety
492
+ boundary = self._pending_boundary_notice
493
+ self._pending_safety = []
494
+ self._pending_boundary_notice = None
495
+ safety: str | None = None
496
+ for chunk in safety_chunks:
497
+ safety = _join_notices(safety, chunk.text)
498
+ return _join_notices(safety, boundary)
499
+
500
+ def serialize(self) -> dict[str, Any] | None:
501
+ """Return the versioned payload: baseline plus undelivered safety notices.
502
+
503
+ Boundary notices never persist — the next boundary recomputes them
504
+ from the saved baseline. Safety notices are not recomputable (their
505
+ source data is gone once queued: the rollback invalidated the
506
+ baseline, or the requeued boundary's changes were already absorbed),
507
+ so they ride the payload until a model request delivers them.
508
+ """
509
+ with self._lock:
510
+ baseline = self._baseline
511
+ pending_safety = list(self._pending_safety)
512
+ if baseline is None and not pending_safety:
513
+ return None
514
+ payload = _serialize_baseline(baseline) if baseline is not None else {"version": _SERIALIZATION_VERSION}
515
+ if pending_safety:
516
+ payload["pending_safety"] = [{"text": chunk.text, "cwd": chunk.cwd} for chunk in pending_safety]
517
+ return payload
518
+
519
+ def restore(self, value: object, workspace: Workspace | None = None, *, resolve_scope: bool = True) -> None:
520
+ """Restore a persisted payload and reconcile it with the freshly resolved scope.
521
+
522
+ Args:
523
+ resolve_scope: Same contract as :meth:`retarget_roots` — ``False``
524
+ skips the Git probes, installs the inactive (empty) scope in
525
+ place of whatever the previous session left behind, and keeps
526
+ the restored baseline exactly as persisted for the next
527
+ enabled retarget to reconcile.
528
+ """
529
+ baseline = _deserialize_baseline(value)
530
+ pending_safety = [_resolve_safety_notice(chunk) for chunk in _deserialize_pending_safety(value)]
531
+ if not resolve_scope:
532
+ scope: _ResolvedScope | None = _ResolvedScope(roots=())
533
+ elif workspace is not None:
534
+ scope = _resolve_workspace_scope(workspace)
535
+ else:
536
+ scope = None
537
+ new_cwd = os.path.normpath(os.path.abspath(workspace.primary_cwd)) if workspace is not None else None
538
+ retarget = WorkspaceRetarget(
539
+ scope=scope if scope is not None else _ResolvedScope(roots=()),
540
+ resolve_scope=resolve_scope,
541
+ new_cwd=new_cwd,
542
+ canonical_new_cwd=canonical_path(new_cwd) if new_cwd is not None else None,
543
+ )
544
+ with self._lock:
545
+ self._generation += 1
546
+ self._pending_boundary_notice = None
547
+ # Undelivered safety notices are the sole record of their
548
+ # warnings; rehydrate them, pinning the path base exactly as a
549
+ # workspace switch would when the restored cwd differs from
550
+ # queue time.
551
+ self._pending_safety = [_rebase_safety_notice(chunk, retarget) for chunk in pending_safety]
552
+ if scope is not None:
553
+ self._scope = scope
554
+ self._baseline = (
555
+ _reconcile_baseline(baseline, self._scope)
556
+ if resolve_scope and baseline is not None and self._scope.roots
557
+ else baseline
558
+ )
559
+
560
+ def _install_capture_deadline(self, turn_id: int) -> WorkspaceBaseline:
561
+ with self._lock:
562
+ self._generation += 1
563
+ scope = self._scope
564
+ result = WorkspaceBaseline(
565
+ turn_id=turn_id,
566
+ roots={},
567
+ degraded={
568
+ root.root: DegradedBaseline(DegradedReason.CAPTURE_DEADLINE, root.mode, root.scope)
569
+ for root in scope.roots
570
+ },
571
+ root_limit_omitted=scope.root_limit_omitted,
572
+ )
573
+ self._baseline = result
574
+ return result
575
+
576
+
577
+ def format_workspace_change_notice(notice: WorkspaceChangeNotice, *, cwd: str) -> str | None:
578
+ """Render a safe one-shot reminder, returning ``None`` for an empty payload."""
579
+ has_content = bool(
580
+ notice.agent_changes
581
+ or notice.agent_omitted
582
+ or notice.external_changes
583
+ or notice.external_omitted
584
+ or notice.peer_changes
585
+ or notice.peer_omitted
586
+ or notice.head_moves
587
+ or notice.degraded
588
+ or notice.root_limit_omitted
589
+ or notice.agent_detection_truncated
590
+ )
591
+ if not has_content:
592
+ return None
593
+
594
+ lines = ["Workspace changes since the previous turn (contents not shown):"]
595
+ if notice.agent_changes or notice.agent_omitted:
596
+ lines.extend(("", "Changed by you or your sub-agents:"))
597
+ for entry in notice.agent_changes:
598
+ suffix = " [content not compared]" if entry.content_not_compared else ""
599
+ if entry.contested:
600
+ suffix += " [another session also wrote this; re-read it]"
601
+ lines.append(f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{suffix}")
602
+ if notice.agent_omitted:
603
+ lines.append(f"- ...and {notice.agent_omitted} more")
604
+
605
+ if notice.agent_detection_truncated:
606
+ lines.extend(("", "Some agent file changes could not be determined completely."))
607
+
608
+ if notice.external_changes or notice.external_omitted:
609
+ lines.extend(("", "Changed outside this session:"))
610
+ for entry in notice.external_changes:
611
+ overlap = " [you also edited this; re-read it]" if entry.agent_overlap else ""
612
+ lines.append(f"- {_external_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{overlap}")
613
+ if notice.external_omitted:
614
+ lines.append(f"- ...and {notice.external_omitted} more")
615
+
616
+ if notice.peer_changes or notice.peer_omitted:
617
+ lines.extend(("", "Changed by another session working in this workspace:"))
618
+ lines.extend(
619
+ f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}" for entry in notice.peer_changes
620
+ )
621
+ if notice.peer_omitted:
622
+ lines.append(f"- ...and {notice.peer_omitted} more")
623
+
624
+ diagnostics: list[str] = []
625
+ for move in notice.head_moves:
626
+ old = move.old_head[:8] if move.old_head else "unborn"
627
+ new = move.new_head[:8] if move.new_head else "unborn"
628
+ suffix = "; scoped files may differ" if move.uncertain else " (scoped files listed above)"
629
+ diagnostics.append(f"git HEAD moved: {old} -> {new}{suffix}")
630
+ for root, degraded in notice.degraded:
631
+ diagnostics.append(
632
+ f"Workspace state unavailable for {_quoted_path(root, cwd)}: {_degraded_label(degraded.reason)}."
633
+ )
634
+ root_limit_line = (
635
+ f"{notice.root_limit_omitted} additional workspace root(s) were not inspected."
636
+ if notice.root_limit_omitted
637
+ else None
638
+ )
639
+ if diagnostics or root_limit_line:
640
+ lines.append("")
641
+ lines.extend(diagnostics[:_DIAGNOSTIC_LINE_LIMIT])
642
+ if len(diagnostics) > _DIAGNOSTIC_LINE_LIMIT:
643
+ lines.append(f"...and {len(diagnostics) - _DIAGNOSTIC_LINE_LIMIT} more workspace diagnostics")
644
+ if root_limit_line:
645
+ # The exact root-limit aggregate never competes with the capped
646
+ # HEAD/degraded diagnostics.
647
+ lines.append(root_limit_line)
648
+
649
+ if notice.external_changes or notice.peer_changes:
650
+ lines.extend(("", "Re-read externally changed files before relying on their contents."))
651
+ return "\n".join(lines)
652
+
653
+
654
+ def format_retained_changes_notice(
655
+ mutation_tracker: MutationTracker | None,
656
+ *,
657
+ cwd: str,
658
+ max_entries: int,
659
+ ) -> str | None:
660
+ """Build the target-zero conversation-only rollback safety notice."""
661
+ if mutation_tracker is None:
662
+ return "Files retained from the discarded conversation:\n- Conversation history was reset without reverting the workspace."
663
+ summary = mutation_tracker.get_session_file_summary()
664
+ entries = [
665
+ AgentChange(
666
+ path=path,
667
+ operation=_operation_from_summary(diff),
668
+ content_not_compared=diff.content_unavailable,
669
+ contested=diff.contested,
670
+ )
671
+ for path, diff in summary.items()
672
+ ]
673
+ entries.sort(key=lambda entry: canonical_path(entry.path))
674
+ turns = mutation_tracker.get_all_turns()
675
+ unverified = any(
676
+ mutation.before_skip is not None or mutation.after_skip is not None
677
+ for turn in turns
678
+ for mutation in turn.mutations
679
+ if mutation.provenance is not MutationProvenance.FOREIGN
680
+ )
681
+ # Truncated implicit detection may have missed changes entirely — the
682
+ # safety notice must still warn even when zero rows were recorded.
683
+ truncated = any(turn.detection_truncated for turn in turns)
684
+ if not entries and not unverified and not truncated:
685
+ return None
686
+ lines = ["Files retained from the discarded conversation:"]
687
+ for entry in entries[:max_entries]:
688
+ suffix = " [content not compared]" if entry.content_not_compared else ""
689
+ if entry.contested:
690
+ suffix += " [another session also wrote this; re-read it]"
691
+ lines.append(f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{suffix}")
692
+ omitted = max(0, len(entries) - max_entries)
693
+ if omitted:
694
+ lines.append(f"- ...and {omitted} more")
695
+ if unverified:
696
+ lines.append("Some retained changes could not be verified because file content was not compared.")
697
+ if truncated:
698
+ lines.append("Some file changes from the discarded conversation could not be determined completely.")
699
+ return "\n".join(lines)
700
+
701
+
702
+ def format_partial_revert_notice(
703
+ retained_paths: Sequence[str],
704
+ *,
705
+ cwd: str,
706
+ max_entries: int,
707
+ detection_incomplete: bool = False,
708
+ ) -> str | None:
709
+ """Warn about rollback candidates that were not actually restored.
710
+
711
+ A reverting rollback can retain files (a partial path selection, a
712
+ peer-modified exclusion, a failed restore); the conversation no
713
+ longer mentions those edits, so without this notice the model has no
714
+ way to learn the files still carry them. ``detection_incomplete``
715
+ means a rolled-back turn had truncated implicit detection: writes
716
+ that were never recorded could not become candidates, so the notice
717
+ must warn even when every known candidate restored.
718
+ """
719
+ if not retained_paths and not detection_incomplete:
720
+ return None
721
+ ordered = sorted(retained_paths, key=canonical_path)
722
+ lines = ["Files not reverted by the rollback (they keep changes from the rolled-back conversation):"]
723
+ lines.extend(f"- {_quoted_path(path, cwd)}" for path in ordered[:max_entries])
724
+ omitted = max(0, len(ordered) - max_entries)
725
+ if omitted:
726
+ lines.append(f"- ...and {omitted} more")
727
+ if detection_incomplete:
728
+ lines.append(
729
+ "Some file changes from the rolled-back conversation could not be determined completely, "
730
+ "so the rollback may not have reverted all of them."
731
+ )
732
+ return "\n".join(lines)
733
+
734
+
735
+ def _resolve_workspace_scope(workspace: Workspace | None) -> _ResolvedScope:
736
+ if workspace is None:
737
+ return _ResolvedScope(roots=())
738
+ raw_inputs = [workspace.primary_cwd, *(working_dir.path for working_dir in workspace.working_dirs)]
739
+ inputs: list[tuple[str, str]] = []
740
+ seen: set[str] = set()
741
+ seen_stat_ids: set[tuple[int, int]] = set()
742
+ for raw in raw_inputs:
743
+ display = os.path.normpath(os.path.abspath(raw))
744
+ physical = canonical_path(display)
745
+ if physical in seen:
746
+ continue
747
+ seen.add(physical)
748
+ # realpath keeps case aliases distinct on case-insensitive volumes
749
+ # (normcase folds only on Windows) — stat identity is the physical
750
+ # dedup key whenever the root exists.
751
+ try:
752
+ probe = os.stat(physical)
753
+ except OSError:
754
+ stat_id = None
755
+ else:
756
+ stat_id = (probe.st_dev, probe.st_ino)
757
+ if stat_id is not None:
758
+ if stat_id in seen_stat_ids:
759
+ continue
760
+ seen_stat_ids.add(stat_id)
761
+ inputs.append((display, physical))
762
+ omitted = max(0, len(inputs) - _MAX_CAPTURE_ROOTS)
763
+ inputs = inputs[:_MAX_CAPTURE_ROOTS]
764
+ deadline = time.monotonic() + _CAPTURE_DEADLINE_SECONDS
765
+
766
+ git_groups: dict[str, list[str]] = {}
767
+ git_root_by_input: dict[str, str] = {}
768
+ non_git: list[str] = []
769
+ unavailable: dict[str, DegradedReason] = {}
770
+ for _display, physical in inputs:
771
+ if not os.path.isdir(physical):
772
+ unavailable[physical] = DegradedReason.CAPTURE_FAILED
773
+ continue
774
+ remaining = min(10.0, deadline - time.monotonic())
775
+ if remaining <= 0:
776
+ unavailable[physical] = DegradedReason.CAPTURE_DEADLINE
777
+ continue
778
+ git_root = resolve_git_root(physical, timeout=remaining)
779
+ if git_root is None:
780
+ if time.monotonic() >= deadline:
781
+ unavailable[physical] = DegradedReason.CAPTURE_DEADLINE
782
+ continue
783
+ non_git.append(physical)
784
+ continue
785
+ rel = PurePath(os.path.relpath(physical, git_root)).as_posix()
786
+ git_groups.setdefault(git_root, []).append("." if rel == "." else rel)
787
+ git_root_by_input[physical] = git_root
788
+
789
+ managed_git_roots = tuple(git_groups)
790
+ scan_roots: list[str] = []
791
+ for root in non_git:
792
+ if any(_contains(existing, root) for existing in scan_roots):
793
+ continue
794
+ scan_roots = [existing for existing in scan_roots if not _contains(root, existing)]
795
+ scan_roots.append(root)
796
+
797
+ roots: list[_CaptureRoot] = []
798
+ emitted: set[str] = set()
799
+ for _display, physical in inputs:
800
+ if physical in emitted:
801
+ continue
802
+ if physical in unavailable:
803
+ roots.append(
804
+ _CaptureRoot(
805
+ physical,
806
+ None,
807
+ (),
808
+ unavailable_reason=unavailable[physical],
809
+ )
810
+ )
811
+ emitted.add(physical)
812
+ continue
813
+ matching_git = git_root_by_input.get(physical)
814
+ if matching_git is not None:
815
+ if matching_git not in emitted:
816
+ roots.append(
817
+ _CaptureRoot(
818
+ matching_git,
819
+ BaselineMode.GIT,
820
+ canonical_git_pathspecs(git_groups[matching_git]),
821
+ )
822
+ )
823
+ emitted.add(matching_git)
824
+ continue
825
+ scan_root = next((root for root in scan_roots if _contains(root, physical)), None)
826
+ if scan_root is not None and scan_root not in emitted:
827
+ exclusions = frozenset(
828
+ root for root in managed_git_roots if root != scan_root and _contains(scan_root, root)
829
+ )
830
+ roots.append(
831
+ _CaptureRoot(
832
+ scan_root,
833
+ BaselineMode.SCAN,
834
+ tuple(sorted(exclusions)),
835
+ exclusions,
836
+ )
837
+ )
838
+ emitted.add(scan_root)
839
+ display_roots = [(physical, display) for display, physical in inputs]
840
+ for git_root in git_groups:
841
+ for display, physical in inputs:
842
+ if git_root_by_input.get(physical) != git_root:
843
+ continue
844
+ # Ownership guarantees a shared ancestor, but the build path must
845
+ # never break on a pathological spelling — skip instead of raising.
846
+ try:
847
+ ascent = os.path.relpath(git_root, physical)
848
+ except ValueError:
849
+ continue
850
+ display_roots.append((git_root, os.path.normpath(os.path.join(display, ascent))))
851
+ break
852
+ return _ResolvedScope(
853
+ roots=tuple(roots),
854
+ root_limit_omitted=omitted,
855
+ display_roots=tuple(display_roots),
856
+ )
857
+
858
+
859
+ def _capture_workspace(turn_id: int, scope: _ResolvedScope, *, deadline: float) -> WorkspaceBaseline:
860
+ roots: dict[str, RootBaseline] = {}
861
+ degraded: dict[str, DegradedBaseline] = {}
862
+ for index, capture_root in enumerate(scope.roots):
863
+ if time.monotonic() >= deadline:
864
+ for unfinished in scope.roots[index:]:
865
+ degraded[unfinished.root] = DegradedBaseline(
866
+ DegradedReason.CAPTURE_DEADLINE,
867
+ unfinished.mode,
868
+ unfinished.scope,
869
+ )
870
+ break
871
+ if capture_root.mode is None or not os.path.isdir(capture_root.root):
872
+ degraded[capture_root.root] = DegradedBaseline(
873
+ capture_root.unavailable_reason or DegradedReason.CAPTURE_FAILED,
874
+ capture_root.mode,
875
+ capture_root.scope,
876
+ )
877
+ continue
878
+ try:
879
+ if capture_root.mode is BaselineMode.GIT:
880
+ root_baseline, reason = _capture_git_root(capture_root, deadline)
881
+ else:
882
+ root_baseline, reason = _capture_scan_root(capture_root, deadline)
883
+ except Exception:
884
+ logger.debug("Workspace root capture failed for %s", capture_root.root, exc_info=True)
885
+ root_baseline, reason = None, DegradedReason.CAPTURE_FAILED
886
+ if root_baseline is not None:
887
+ roots[capture_root.root] = root_baseline
888
+ else:
889
+ degraded[capture_root.root] = DegradedBaseline(reason, capture_root.mode, capture_root.scope)
890
+ return WorkspaceBaseline(
891
+ turn_id=turn_id,
892
+ roots=roots,
893
+ degraded=degraded,
894
+ root_limit_omitted=scope.root_limit_omitted,
895
+ )
896
+
897
+
898
+ def _capture_git_root(
899
+ capture_root: _CaptureRoot,
900
+ deadline: float,
901
+ ) -> tuple[RootBaseline | None, DegradedReason]:
902
+ for _attempt in range(2):
903
+ remaining = min(10.0, deadline - time.monotonic())
904
+ if remaining <= 0:
905
+ return None, DegradedReason.CAPTURE_DEADLINE
906
+ before_head = read_git_head(capture_root.root, timeout=remaining)
907
+ remaining = min(10.0, deadline - time.monotonic())
908
+ if remaining <= 0:
909
+ return None, DegradedReason.CAPTURE_DEADLINE
910
+ status_result = read_git_status(
911
+ capture_root.root,
912
+ capture_root.scope,
913
+ timeout=remaining,
914
+ max_paths=GIT_PATH_LIMIT,
915
+ )
916
+ if status_result.failed:
917
+ return None, DegradedReason.CAPTURE_FAILED
918
+ if status_result.truncated:
919
+ return None, DegradedReason.TOO_MANY_FILES
920
+ files: dict[str, BaselineFileState] = {}
921
+ for rel_path, statuses in status_result.statuses.items():
922
+ # Lexical: the entry may be a tracked symlink whose identity
923
+ # (and lstat) must be the link itself, not its destination.
924
+ # Case-preserving so the notice can render the on-disk spelling.
925
+ path = lexical_display_path(os.path.join(capture_root.root, rel_path))
926
+ # Git already applied the pathspecs; reject only entries whose
927
+ # normalized filesystem identity escapes the repository.
928
+ if not _path_in_git_scope(capture_root.root, path, (".",)):
929
+ continue
930
+ files[path] = _stat_baseline_file(path, statuses)
931
+ remaining = min(10.0, deadline - time.monotonic())
932
+ if remaining <= 0:
933
+ return None, DegradedReason.CAPTURE_DEADLINE
934
+ after_head = read_git_head(capture_root.root, timeout=remaining)
935
+ if before_head.head == after_head.head:
936
+ return (
937
+ RootBaseline(
938
+ root=capture_root.root,
939
+ mode=BaselineMode.GIT,
940
+ head=after_head.head,
941
+ branch=after_head.branch,
942
+ scope=capture_root.scope,
943
+ files=files,
944
+ ),
945
+ DegradedReason.CAPTURE_FAILED,
946
+ )
947
+ return None, DegradedReason.CAPTURE_FAILED
948
+
949
+
950
+ def _capture_scan_root(
951
+ capture_root: _CaptureRoot,
952
+ deadline: float,
953
+ ) -> tuple[RootBaseline | None, DegradedReason]:
954
+ scanner = WorkspaceScanner(
955
+ capture_root.root,
956
+ excludes=DEFAULT_EXCLUDES,
957
+ max_depth=sys.maxsize,
958
+ max_files=_SCAN_OVERFLOW_SENTINEL,
959
+ exclude_roots=capture_root.exclude_roots,
960
+ deadline=deadline,
961
+ )
962
+ files = scanner.scan()
963
+ if time.monotonic() >= deadline:
964
+ return None, DegradedReason.CAPTURE_DEADLINE
965
+ if len(files) >= _SCAN_OVERFLOW_SENTINEL:
966
+ return None, DegradedReason.TOO_MANY_FILES
967
+ states = {
968
+ lexical_display_path(path): BaselineFileState(True, value.mtime_ns, value.size, value.mode, ())
969
+ for path, value in files.items()
970
+ }
971
+ return (
972
+ RootBaseline(
973
+ root=capture_root.root,
974
+ mode=BaselineMode.SCAN,
975
+ head=None,
976
+ branch=None,
977
+ scope=capture_root.scope,
978
+ files=states,
979
+ ),
980
+ DegradedReason.CAPTURE_FAILED,
981
+ )
982
+
983
+
984
+ def _stat_baseline_file(path: str, statuses: tuple[str, ...]) -> BaselineFileState:
985
+ try:
986
+ value = os.lstat(path)
987
+ except OSError as exc:
988
+ if exc.errno in (errno.ENOENT, errno.ENOTDIR):
989
+ return BaselineFileState(False, 0, 0, None, statuses)
990
+ raise
991
+ return BaselineFileState(
992
+ True,
993
+ value.st_mtime_ns,
994
+ value.st_size,
995
+ stat.S_IMODE(value.st_mode),
996
+ statuses,
997
+ )
998
+
999
+
1000
+ def _diff_workspace(
1001
+ old: WorkspaceBaseline,
1002
+ new: WorkspaceBaseline,
1003
+ *,
1004
+ deadline: float,
1005
+ ) -> tuple[list[ExternalChange], list[HeadMove], dict[str, DegradedBaseline]]:
1006
+ external: list[ExternalChange] = []
1007
+ head_moves: list[HeadMove] = []
1008
+ degraded = {**old.degraded, **new.degraded}
1009
+ for root, old_root in old.roots.items():
1010
+ new_root = new.roots.get(root)
1011
+ if new_root is None or old_root.mode is not new_root.mode or old_root.scope != new_root.scope:
1012
+ degraded.setdefault(
1013
+ root,
1014
+ DegradedBaseline(DegradedReason.MISSING_BASELINE, new_root.mode if new_root else None, ()),
1015
+ )
1016
+ continue
1017
+ status_paths: set[str] = set()
1018
+ if old_root.mode is BaselineMode.GIT:
1019
+ status_entries = _diff_git_status(root, old_root.files, new_root.files)
1020
+ external.extend(status_entries)
1021
+ status_paths = {entry.path for entry in status_entries}
1022
+ if old_root.head != new_root.head:
1023
+ remaining = min(10.0, deadline - time.monotonic())
1024
+ delta = read_git_head_delta(
1025
+ root,
1026
+ old_root.head,
1027
+ new_root.head,
1028
+ new_root.scope,
1029
+ timeout=remaining,
1030
+ max_paths=GIT_PATH_LIMIT,
1031
+ )
1032
+ if delta.failed or delta.truncated:
1033
+ head_moves.append(HeadMove(root, old_root.head, new_root.head, uncertain=True))
1034
+ elif delta.entries:
1035
+ head_moves.append(HeadMove(root, old_root.head, new_root.head))
1036
+ external.extend(
1037
+ entry for entry in _external_from_delta(root, delta.entries) if entry.path not in status_paths
1038
+ )
1039
+ else:
1040
+ external.extend(_diff_scan(root, old_root.files, new_root.files))
1041
+ return external, head_moves, degraded
1042
+
1043
+
1044
+ def _diff_git_status(
1045
+ root: str,
1046
+ old: Mapping[str, BaselineFileState],
1047
+ new: Mapping[str, BaselineFileState],
1048
+ ) -> list[ExternalChange]:
1049
+ result: list[ExternalChange] = []
1050
+ for path in new.keys() - old.keys():
1051
+ state = new[path]
1052
+ status_chars = "".join(state.statuses)
1053
+ staged_delete_untracked = "D " in state.statuses and "??" in state.statuses and state.exists
1054
+ if not state.exists and "D" in status_chars:
1055
+ operation = ExternalOp.DELETED
1056
+ elif staged_delete_untracked:
1057
+ operation = ExternalOp.TRACKING_CHANGED
1058
+ elif (state.exists and ("R" in status_chars or "C" in status_chars or "A" in status_chars)) or (
1059
+ state.statuses and set(state.statuses) == {"??"}
1060
+ ):
1061
+ operation = ExternalOp.CREATED
1062
+ else:
1063
+ operation = ExternalOp.MODIFIED
1064
+ result.append(ExternalChange(root, path, operation))
1065
+ for path in old.keys() & new.keys():
1066
+ old_state = old[path]
1067
+ new_state = new[path]
1068
+ if old_state.exists and not new_state.exists:
1069
+ result.append(ExternalChange(root, path, ExternalOp.DELETED))
1070
+ elif (
1071
+ old_state.exists != new_state.exists
1072
+ or old_state.mtime_ns != new_state.mtime_ns
1073
+ or old_state.size != new_state.size
1074
+ or old_state.mode != new_state.mode
1075
+ ):
1076
+ result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
1077
+ elif old_state.statuses != new_state.statuses:
1078
+ # An index-only transition (git add/reset/restore --staged)
1079
+ # leaves every filesystem field untouched; the status tuple is
1080
+ # the only observable.
1081
+ result.append(ExternalChange(root, path, ExternalOp.TRACKING_CHANGED))
1082
+ for path in old.keys() - new.keys():
1083
+ old_state = old[path]
1084
+ if os.path.lexists(path) or (not old_state.exists and any("D" in code for code in old_state.statuses)):
1085
+ operation = ExternalOp.RECONCILED
1086
+ else:
1087
+ operation = ExternalOp.DELETED
1088
+ result.append(ExternalChange(root, path, operation))
1089
+ return result
1090
+
1091
+
1092
+ def _diff_scan(
1093
+ root: str,
1094
+ old: Mapping[str, BaselineFileState],
1095
+ new: Mapping[str, BaselineFileState],
1096
+ ) -> list[ExternalChange]:
1097
+ result = [ExternalChange(root, path, ExternalOp.CREATED) for path in new.keys() - old.keys()]
1098
+ result.extend(ExternalChange(root, path, ExternalOp.DELETED) for path in old.keys() - new.keys())
1099
+ for path in old.keys() & new.keys():
1100
+ before = old[path]
1101
+ after = new[path]
1102
+ if (before.mtime_ns, before.size, before.mode) != (after.mtime_ns, after.size, after.mode):
1103
+ result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
1104
+ return result
1105
+
1106
+
1107
+ def _external_from_delta(root: str, entries: tuple[GitDeltaEntry, ...]) -> list[ExternalChange]:
1108
+ result: list[ExternalChange] = []
1109
+ for entry in entries:
1110
+ status = entry.status[:1]
1111
+ path = lexical_display_path(os.path.join(root, entry.path))
1112
+ if status == "R" and entry.old_path is not None:
1113
+ result.append(
1114
+ ExternalChange(root, lexical_display_path(os.path.join(root, entry.old_path)), ExternalOp.DELETED)
1115
+ )
1116
+ result.append(ExternalChange(root, path, ExternalOp.CREATED))
1117
+ elif status in {"A", "C"}:
1118
+ result.append(ExternalChange(root, path, ExternalOp.CREATED))
1119
+ elif status == "D":
1120
+ result.append(ExternalChange(root, path, ExternalOp.DELETED))
1121
+ else:
1122
+ result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
1123
+ return result
1124
+
1125
+
1126
+ def _agent_changes(
1127
+ tracker: MutationTracker | None,
1128
+ turn_id: int | None,
1129
+ ) -> tuple[list[AgentChange], bool]:
1130
+ if tracker is None or turn_id is None:
1131
+ return [], False
1132
+ summary = tracker.get_turn_file_summary(turn_id)
1133
+ changes = [
1134
+ AgentChange(path, _operation_from_summary(diff), diff.content_unavailable, contested=diff.contested)
1135
+ for path, diff in summary.items()
1136
+ if not diff.is_net_zero or diff.content_unavailable
1137
+ ]
1138
+ turn = tracker.get_turn_mutations(turn_id)
1139
+ if turn is None:
1140
+ return changes, False
1141
+ for index, mutation in enumerate(turn.mutations):
1142
+ if mutation.provenance is MutationProvenance.FOREIGN or mutation.old_path is None:
1143
+ continue
1144
+ recreated = any(
1145
+ later.path == mutation.old_path
1146
+ and later.operation is not MutationOp.DELETE
1147
+ and later.provenance is not MutationProvenance.FOREIGN
1148
+ for later in turn.mutations[index + 1 :]
1149
+ )
1150
+ if recreated:
1151
+ continue
1152
+ # An earlier edit of the source leaves a stale summary entry; the
1153
+ # move's final endpoint for that path is the deletion.
1154
+ changes = [entry for entry in changes if entry.path != mutation.old_path]
1155
+ changes.append(AgentChange(mutation.old_path, MutationOp.DELETE, contested=mutation.contested))
1156
+ return changes, turn.detection_truncated
1157
+
1158
+
1159
+ def _detection_truncated(tracker: MutationTracker | None, turn_id: int | None) -> bool:
1160
+ if tracker is None or turn_id is None:
1161
+ return False
1162
+ turn = tracker.get_turn_mutations(turn_id)
1163
+ return turn is not None and turn.detection_truncated
1164
+
1165
+
1166
+ def _peer_witnessed_changes(
1167
+ tracker: MutationTracker | None,
1168
+ turn_id: int | None,
1169
+ ) -> list[PeerChange]:
1170
+ """Collect paths whose peer-session write only the turn log witnessed.
1171
+
1172
+ ``FOREIGN`` rows are excluded from every net-change fold, and the
1173
+ end-of-turn baseline absorbs the peer's on-disk effect, so this raw
1174
+ pass over the turn log is the only surface that can report them. A
1175
+ ``contested`` local row also records a peer's overlapping write;
1176
+ when its own agent row is not displayed (Retry/Resume omits the
1177
+ section, net-zero folding drops the entry) this harvest is the only
1178
+ remaining witness — the caller's dedup against displayed agent rows
1179
+ keeps the contested badge as the primary channel elsewhere. Later
1180
+ rows win per path; a move contributes a deletion at its source.
1181
+ """
1182
+ if tracker is None or turn_id is None:
1183
+ return []
1184
+ turn = tracker.get_turn_mutations(turn_id)
1185
+ if turn is None:
1186
+ return []
1187
+ ops: dict[str, MutationOp] = {}
1188
+ for mutation in turn.mutations:
1189
+ if mutation.provenance is not MutationProvenance.FOREIGN and not mutation.contested:
1190
+ continue
1191
+ if mutation.operation is MutationOp.MOVE and mutation.old_path:
1192
+ ops[mutation.old_path] = MutationOp.DELETE
1193
+ ops[mutation.path] = mutation.operation
1194
+ return [PeerChange(path=path, operation=operation) for path, operation in ops.items()]
1195
+
1196
+
1197
+ def _mutation_paths(
1198
+ tracker: MutationTracker | None,
1199
+ first_turn: int | None,
1200
+ last_turn: int | None,
1201
+ ) -> set[str]:
1202
+ if tracker is None or first_turn is None or last_turn is None:
1203
+ return set()
1204
+ result: set[str] = set()
1205
+ for turn in tracker.get_all_turns():
1206
+ if turn.turn_id < first_turn or turn.turn_id > last_turn:
1207
+ continue
1208
+ for mutation in turn.mutations:
1209
+ if mutation.provenance is MutationProvenance.FOREIGN:
1210
+ continue
1211
+ result.add(entry_path(mutation.path))
1212
+ if mutation.old_path:
1213
+ result.add(entry_path(mutation.old_path))
1214
+ return result
1215
+
1216
+
1217
+ @dataclass(frozen=True, slots=True)
1218
+ class _RecoveredEndpoint:
1219
+ """Last recorded endpoint for one path in the recovered turn range."""
1220
+
1221
+ after_hash: str | None = None
1222
+ exists: bool = True
1223
+ verifiable: bool = True
1224
+
1225
+
1226
+ def _recovered_endpoints(
1227
+ tracker: MutationTracker | None,
1228
+ first_turn: int | None,
1229
+ last_turn: int | None,
1230
+ ) -> dict[str, _RecoveredEndpoint]:
1231
+ if tracker is None or first_turn is None or last_turn is None:
1232
+ return {}
1233
+ result: dict[str, _RecoveredEndpoint] = {}
1234
+ for turn in tracker.get_all_turns():
1235
+ if turn.turn_id < first_turn or turn.turn_id > last_turn:
1236
+ continue
1237
+ for mutation in turn.mutations:
1238
+ if mutation.provenance is MutationProvenance.FOREIGN:
1239
+ continue
1240
+ if mutation.old_path:
1241
+ result[entry_path(mutation.old_path)] = _RecoveredEndpoint(exists=False)
1242
+ if mutation.operation is MutationOp.DELETE:
1243
+ result[entry_path(mutation.path)] = _RecoveredEndpoint(exists=False)
1244
+ elif mutation.after_hash is not None:
1245
+ result[entry_path(mutation.path)] = _RecoveredEndpoint(after_hash=mutation.after_hash)
1246
+ else:
1247
+ # Skip-marked content or a row that never saw its
1248
+ # ``record_after`` (crash mid-command): the recorded
1249
+ # endpoint proves nothing about current disk.
1250
+ result[entry_path(mutation.path)] = _RecoveredEndpoint(verifiable=False)
1251
+ return result
1252
+
1253
+
1254
+ def _matches_recovered_endpoint(expected: _RecoveredEndpoint | None, path: str, max_bytes: int) -> bool:
1255
+ """Whether current disk provably matches a recovered mutation endpoint."""
1256
+ if expected is None or not expected.verifiable:
1257
+ return False
1258
+ if not expected.exists:
1259
+ return not os.path.lexists(path)
1260
+ data = _read_entry_bytes(path, max_bytes)
1261
+ return data is not None and SnapshotStore.content_hash(data) == expected.after_hash
1262
+
1263
+
1264
+ def _read_entry_bytes(path: str, max_bytes: int) -> bytes | None:
1265
+ """Entry content for endpoint verification; a symlink is its target text."""
1266
+ try:
1267
+ if os.path.islink(path):
1268
+ return os.fsencode(os.readlink(path))
1269
+ if not os.path.isfile(path):
1270
+ return None
1271
+ if max_bytes > 0 and os.path.getsize(path) > max_bytes:
1272
+ return None
1273
+ with open(path, "rb") as handle:
1274
+ return handle.read()
1275
+ except OSError:
1276
+ return None
1277
+
1278
+
1279
+ def _operation_from_summary(diff: FileHashDiff) -> MutationOp:
1280
+ if SnapshotSkipReason.UNREADABLE in (diff.before_skip, diff.after_skip):
1281
+ return MutationOp.MODIFY
1282
+ if not diff.before_exists and diff.after_exists:
1283
+ return MutationOp.CREATE
1284
+ if diff.before_exists and not diff.after_exists:
1285
+ return MutationOp.DELETE
1286
+ return MutationOp.MODIFY
1287
+
1288
+
1289
+ def _reconcile_baseline(baseline: WorkspaceBaseline, scope: _ResolvedScope) -> WorkspaceBaseline:
1290
+ roots: dict[str, RootBaseline] = {}
1291
+ degraded: dict[str, DegradedBaseline] = {}
1292
+ for capture_root in scope.roots:
1293
+ old_root = baseline.roots.get(capture_root.root)
1294
+ old_degraded = baseline.degraded.get(capture_root.root)
1295
+ if old_root is not None and old_root.mode is capture_root.mode:
1296
+ if old_root.mode is BaselineMode.GIT and _git_scope_narrow_or_equal(old_root.scope, capture_root.scope):
1297
+ roots[capture_root.root] = replace(
1298
+ old_root,
1299
+ scope=capture_root.scope,
1300
+ files={
1301
+ path: state
1302
+ for path, state in old_root.files.items()
1303
+ if _path_in_git_scope(capture_root.root, path, capture_root.scope)
1304
+ },
1305
+ )
1306
+ continue
1307
+ if old_root.mode is BaselineMode.SCAN and old_root.scope == capture_root.scope:
1308
+ roots[capture_root.root] = old_root
1309
+ continue
1310
+ if (
1311
+ old_degraded is not None
1312
+ and old_degraded.mode is not None
1313
+ and old_degraded.mode is capture_root.mode
1314
+ and old_degraded.scope == capture_root.scope
1315
+ ):
1316
+ degraded[capture_root.root] = old_degraded
1317
+ else:
1318
+ degraded[capture_root.root] = DegradedBaseline(
1319
+ DegradedReason.MISSING_BASELINE,
1320
+ capture_root.mode,
1321
+ capture_root.scope,
1322
+ )
1323
+ return WorkspaceBaseline(
1324
+ turn_id=baseline.turn_id,
1325
+ roots=roots,
1326
+ degraded=degraded,
1327
+ root_limit_omitted=scope.root_limit_omitted,
1328
+ )
1329
+
1330
+
1331
+ def _git_scope_narrow_or_equal(old: tuple[str, ...], new: tuple[str, ...]) -> bool:
1332
+ if old == new or old == (".",):
1333
+ return True
1334
+ if new == (".",):
1335
+ return False
1336
+ return all(any(item == parent or item.startswith(f"{parent}/") for parent in old) for item in new)
1337
+
1338
+
1339
+ def _path_in_git_scope(root: str, path: str, scope: tuple[str, ...]) -> bool:
1340
+ # Persisted entries can name Windows devices (e.g. \\.\nul) or a
1341
+ # different drive. They cannot belong to this repository, even when
1342
+ # the whole repository is selected. Keep the comparison lexical so
1343
+ # symlinks and deleted entries retain their own identities.
1344
+ try:
1345
+ rel = PurePath(os.path.relpath(path, root)).as_posix()
1346
+ except ValueError:
1347
+ return False
1348
+ if rel == ".." or rel.startswith("../"):
1349
+ return False
1350
+ if scope == (".",):
1351
+ return True
1352
+ return any(rel == item or rel.startswith(f"{item}/") for item in scope)
1353
+
1354
+
1355
+ def _serialize_baseline(baseline: WorkspaceBaseline) -> dict[str, Any]:
1356
+ return {
1357
+ "version": _SERIALIZATION_VERSION,
1358
+ "turn_id": baseline.turn_id,
1359
+ "roots": {
1360
+ key: {
1361
+ "root": value.root,
1362
+ "mode": value.mode.value,
1363
+ "head": value.head,
1364
+ "branch": value.branch,
1365
+ "scope": list(value.scope),
1366
+ "files": {
1367
+ path: {
1368
+ "exists": state.exists,
1369
+ "mtime_ns": state.mtime_ns,
1370
+ "size": state.size,
1371
+ "mode": state.mode,
1372
+ "statuses": list(state.statuses),
1373
+ }
1374
+ for path, state in value.files.items()
1375
+ },
1376
+ }
1377
+ for key, value in baseline.roots.items()
1378
+ },
1379
+ "degraded": {
1380
+ key: {
1381
+ "reason": value.reason.value,
1382
+ "mode": value.mode.value if value.mode is not None else None,
1383
+ "scope": list(value.scope),
1384
+ }
1385
+ for key, value in baseline.degraded.items()
1386
+ },
1387
+ "root_limit_omitted": baseline.root_limit_omitted,
1388
+ }
1389
+
1390
+
1391
+ def _deserialize_baseline(value: object) -> WorkspaceBaseline | None:
1392
+ if not isinstance(value, Mapping) or value.get("version") != _SERIALIZATION_VERSION:
1393
+ return None
1394
+ try:
1395
+ payload = cast("Mapping[str, object]", value)
1396
+ raw_roots = payload.get("roots", {})
1397
+ raw_degraded = payload.get("degraded", {})
1398
+ if not isinstance(raw_roots, Mapping) or not isinstance(raw_degraded, Mapping):
1399
+ return None
1400
+ roots: dict[str, RootBaseline] = {}
1401
+ for key, raw_value in raw_roots.items():
1402
+ raw = cast("Mapping[str, object]", raw_value)
1403
+ if not isinstance(key, str) or not isinstance(raw, Mapping):
1404
+ return None
1405
+ raw_files = raw.get("files", {})
1406
+ if not isinstance(raw_files, Mapping):
1407
+ return None
1408
+ files: dict[str, BaselineFileState] = {}
1409
+ for path, state_value in raw_files.items():
1410
+ if not isinstance(path, str) or not isinstance(state_value, Mapping):
1411
+ return None
1412
+ state = cast("Mapping[str, object]", state_value)
1413
+ mode_value = state.get("mode")
1414
+ files[path] = BaselineFileState(
1415
+ exists=_serialized_bool(state["exists"]),
1416
+ mtime_ns=_serialized_int(state["mtime_ns"]),
1417
+ size=_serialized_int(state["size"]),
1418
+ mode=_serialized_int(mode_value) if mode_value is not None else None,
1419
+ statuses=_serialized_strings(state.get("statuses", [])),
1420
+ )
1421
+ roots[key] = RootBaseline(
1422
+ root=_serialized_str(raw["root"]),
1423
+ mode=BaselineMode(_serialized_str(raw["mode"])),
1424
+ head=_serialized_str(raw["head"]) if raw.get("head") is not None else None,
1425
+ branch=_serialized_str(raw["branch"]) if raw.get("branch") is not None else None,
1426
+ scope=_serialized_strings(raw.get("scope", [])),
1427
+ files=files,
1428
+ )
1429
+ degraded: dict[str, DegradedBaseline] = {}
1430
+ for key, raw_value in raw_degraded.items():
1431
+ if not isinstance(key, str) or not isinstance(raw_value, Mapping):
1432
+ return None
1433
+ raw = cast("Mapping[str, object]", raw_value)
1434
+ mode_value = raw.get("mode")
1435
+ degraded[key] = DegradedBaseline(
1436
+ reason=DegradedReason(_serialized_str(raw["reason"])),
1437
+ mode=BaselineMode(_serialized_str(mode_value)) if mode_value is not None else None,
1438
+ scope=_serialized_strings(raw.get("scope", [])),
1439
+ )
1440
+ return WorkspaceBaseline(
1441
+ turn_id=_serialized_int(payload["turn_id"]),
1442
+ roots=roots,
1443
+ degraded=degraded,
1444
+ root_limit_omitted=_serialized_int(payload.get("root_limit_omitted", 0)),
1445
+ )
1446
+ except KeyError, TypeError, ValueError:
1447
+ logger.debug("Dropping malformed workspace baseline", exc_info=True)
1448
+ return None
1449
+
1450
+
1451
+ def _deserialize_pending_safety(value: object) -> list[_SafetyNotice]:
1452
+ """Rehydrate undelivered safety notices; malformed entries are dropped."""
1453
+ if not isinstance(value, Mapping) or value.get("version") != _SERIALIZATION_VERSION:
1454
+ return []
1455
+ raw = value.get("pending_safety", [])
1456
+ if not isinstance(raw, Sequence) or isinstance(raw, str | bytes):
1457
+ return []
1458
+ result: list[_SafetyNotice] = []
1459
+ for entry in raw:
1460
+ if not isinstance(entry, Mapping):
1461
+ continue
1462
+ text = entry.get("text")
1463
+ if not isinstance(text, str) or not text:
1464
+ continue
1465
+ cwd = entry.get("cwd")
1466
+ result.append(_SafetyNotice(text, cwd if isinstance(cwd, str) else None))
1467
+ return result
1468
+
1469
+
1470
+ def _serialized_bool(value: object) -> bool:
1471
+ if not isinstance(value, bool):
1472
+ raise TypeError
1473
+ return value
1474
+
1475
+
1476
+ def _serialized_int(value: object) -> int:
1477
+ if not isinstance(value, int):
1478
+ raise TypeError
1479
+ return value
1480
+
1481
+
1482
+ def _serialized_str(value: object) -> str:
1483
+ if not isinstance(value, str):
1484
+ raise TypeError
1485
+ return value
1486
+
1487
+
1488
+ def _serialized_strings(value: object) -> tuple[str, ...]:
1489
+ if not isinstance(value, list):
1490
+ raise TypeError
1491
+ result: list[str] = []
1492
+ for item in value:
1493
+ if not isinstance(item, str):
1494
+ raise TypeError
1495
+ result.append(item)
1496
+ return tuple(result)
1497
+
1498
+
1499
+ def _quoted_path(path: str, cwd: str) -> str:
1500
+ return json.dumps(display_relpath(path, cwd), ensure_ascii=True)
1501
+
1502
+
1503
+ def _external_label(operation: ExternalOp) -> str:
1504
+ return {
1505
+ ExternalOp.CREATED: "created",
1506
+ ExternalOp.MODIFIED: "modified",
1507
+ ExternalOp.DELETED: "deleted",
1508
+ ExternalOp.RECONCILED: "reconciled (committed or reverted)",
1509
+ ExternalOp.TRACKING_CHANGED: "Git tracking changed",
1510
+ }[operation]
1511
+
1512
+
1513
+ def _agent_label(operation: MutationOp) -> str:
1514
+ return {
1515
+ MutationOp.CREATE: "created",
1516
+ MutationOp.MODIFY: "modified",
1517
+ MutationOp.DELETE: "deleted",
1518
+ MutationOp.MOVE: "moved",
1519
+ }[operation]
1520
+
1521
+
1522
+ def _degraded_label(reason: DegradedReason) -> str:
1523
+ return {
1524
+ DegradedReason.TOO_MANY_FILES: "too many files",
1525
+ DegradedReason.CAPTURE_FAILED: "capture failed",
1526
+ DegradedReason.CAPTURE_DEADLINE: "capture deadline exceeded",
1527
+ DegradedReason.MISSING_BASELINE: "no comparable previous baseline",
1528
+ }[reason]
1529
+
1530
+
1531
+ def _contains(parent: str, child: str) -> bool:
1532
+ try:
1533
+ return os.path.commonpath([parent, child]) == parent
1534
+ except ValueError:
1535
+ return False
1536
+
1537
+
1538
+ def _display_path(path: str, scope: _ResolvedScope) -> str:
1539
+ # Canonicalize only the directory (mapping root aliases onto display
1540
+ # roots): the final component may be a symlink whose identity is the
1541
+ # link itself — resolving it would rename the entry to its
1542
+ # destination, possibly outside every display root. The relative tail
1543
+ # is taken from the case-preserving spelling: relpath folds case only
1544
+ # while matching the prefix, so Windows notices keep the on-disk case
1545
+ # the comparison identity discards.
1546
+ physical_path = entry_path(path)
1547
+ for physical, display in sorted(scope.display_roots, key=lambda item: len(item[0]), reverse=True):
1548
+ if _contains(physical, physical_path):
1549
+ return os.path.normpath(os.path.join(display, os.path.relpath(entry_display_path(path), physical)))
1550
+ return path
1551
+
1552
+
1553
+ def _resolve_safety_notice(chunk: _SafetyNotice) -> _SafetyNotice:
1554
+ """Capture the notice's directory identity, retaining text if resolution fails."""
1555
+ if chunk.cwd is None:
1556
+ return chunk
1557
+ try:
1558
+ return replace(chunk, canonical_cwd=canonical_path(chunk.cwd))
1559
+ except OSError, UnicodeError:
1560
+ return _pin_safety_notice(chunk)
1561
+
1562
+
1563
+ def _rebase_safety_notice(chunk: _SafetyNotice, retarget: WorkspaceRetarget) -> _SafetyNotice:
1564
+ """Pin a pending safety notice's path base when the workspace moves.
1565
+
1566
+ The notice text cannot be re-rendered (its source data is gone), so a
1567
+ switch to a different primary cwd prepends the original base once;
1568
+ after that the chunk is base-independent and never annotated again.
1569
+ """
1570
+ if chunk.cwd is None or retarget.new_cwd is None or chunk.canonical_cwd == retarget.canonical_new_cwd:
1571
+ return chunk
1572
+ return _pin_safety_notice(chunk)
1573
+
1574
+
1575
+ def _pin_safety_notice(chunk: _SafetyNotice) -> _SafetyNotice:
1576
+ """Annotate the original directory once while preserving all notice text."""
1577
+ header = f"Note: relative paths below are against the previous workspace directory {json.dumps(chunk.cwd, ensure_ascii=True)}."
1578
+ return _SafetyNotice(f"{header}\n{chunk.text}", cwd=None)
1579
+
1580
+
1581
+ def _join_notices(first: str | None, second: str | None) -> str | None:
1582
+ parts = [part for part in (first, second) if part]
1583
+ return "\n\n".join(parts) if parts else None