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,996 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """The one place settings are assembled from their sources.
4
+
5
+ ``Settings`` is a plain value object; this module is what turns the layered
6
+ sources into one: the user document, the dotenv files beside it, the process
7
+ environment as it stood before bootstrap, the project trust domain under the
8
+ caller's ``project_root``, and the pins a frontend passes in.
9
+
10
+ The load runs in two phases. Phase one reads every file whole and audits the
11
+ project layers completely — policy filter, per-key coercion, tighten/loosen
12
+ verdicts — whether or not anything above will shadow them, because a rejected
13
+ project value must warn even when it would have lost anyway. Phase two picks
14
+ each key's winner from the highest layer down; it is the only phase
15
+ ``InvalidPolicy.SAFE_DEFAULT`` terminates. The non-project layers keep their
16
+ established warning scope (a layer below the winner stays unexamined), so
17
+ effective values and existing warnings are unchanged by the split.
18
+
19
+ Callers get three things back and are expected to keep all of them: the
20
+ settings, where each value came from, and what was rejected on the way. A
21
+ caller that only wants the settings can say so with ``.settings``, but it
22
+ cannot pretend the other two do not exist — that is how the panel ends up
23
+ unable to explain why the value a user just typed has no effect.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ from collections.abc import Callable, Iterable, Iterator, Mapping
31
+ from dataclasses import asdict, dataclass, replace
32
+ from pathlib import Path
33
+ from types import MappingProxyType
34
+ from typing import Any, Final
35
+
36
+ from chrys.foundation.config.coercion import Coerced, CoerceReason, CoerceStatus, invalid
37
+ from chrys.foundation.config.context import EvalContext
38
+ from chrys.foundation.config.env_file import config_env_path
39
+ from chrys.foundation.config.env_layers import (
40
+ POINTER_ENV_NAMES,
41
+ canonical_env_name,
42
+ process_env_snapshot,
43
+ read_dotenv_layer,
44
+ )
45
+ from chrys.foundation.config.runtime_pointer import get_model_pointer
46
+ from chrys.foundation.config.settings import DEFAULT_MAX_TRANSIENT_RETRIES, Settings
47
+ from chrys.foundation.config.spec import (
48
+ FILE_SOURCES,
49
+ InvalidPolicy,
50
+ Kind,
51
+ ProjectMerge,
52
+ SettingOrigin,
53
+ SettingSpec,
54
+ Source,
55
+ kind_accepts,
56
+ specs_by_field,
57
+ )
58
+ from chrys.foundation.config.user_settings import apply_settings_patch, flatten_user_doc, user_settings_path
59
+ from chrys.foundation.config.yaml_store import (
60
+ LOCK_TIMEOUT_SECONDS,
61
+ read_yaml_doc,
62
+ read_yaml_doc_readonly,
63
+ update_yaml_doc,
64
+ )
65
+ from chrys.foundation.platform.runtime_paths import same_path
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+ DEFAULT_EVAL_CONTEXT = EvalContext(frontend_default_max_transient_retries=DEFAULT_MAX_TRANSIENT_RETRIES)
70
+ """Interactive-frontend policy. ``chrys run`` passes its own (15)."""
71
+
72
+ _FRONTEND_DEFAULT_FIELD = "frontend_default_max_transient_retries"
73
+
74
+ _EMPTY_MAP: Final[Mapping[str, Any]] = MappingProxyType({})
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class SettingsWarning:
79
+ """One value a layer offered and the loader refused, or had to clamp."""
80
+
81
+ key: str
82
+ origin: SettingOrigin
83
+ outcome: Coerced
84
+ """The verdict itself, carried whole.
85
+
86
+ Copying ``raw``/``reason``/``limit`` out field by field is how the two
87
+ representations start disagreeing — a clamp reports both the bound it hit
88
+ and the value it landed on, and only one of those is ``outcome.value``.
89
+ """
90
+
91
+ @property
92
+ def rejected(self) -> bool:
93
+ """Whether the value was dropped entirely rather than adjusted."""
94
+ return self.outcome.status is CoerceStatus.INVALID
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class DormantProjectConfig:
99
+ """Project-layer configuration found on disk while the gate was off.
100
+
101
+ Reported as a whole file rather than key by key: the user's decision here
102
+ is a single one — enable ``project.config_enabled`` or leave it — and a
103
+ warning per key would say the same thing as many times as the repository
104
+ has opinions.
105
+ """
106
+
107
+ path: Path
108
+ keys: tuple[str, ...]
109
+ """The dotted keys that would be considered if the layer were enabled."""
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class DormantProjectSource:
114
+ """Project hooks or skills found on disk while their own setting was off."""
115
+
116
+ key: str
117
+ """The setting that loads them: ``project.hooks_enabled`` or ``project.skills_enabled``."""
118
+
119
+ path: Path
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class LoadedSettings:
124
+ """Settings plus the two things a caller must not silently drop."""
125
+
126
+ settings: Settings
127
+ provenance: Mapping[str, SettingOrigin]
128
+ warnings: tuple[SettingsWarning, ...] = ()
129
+ sealed_keys: frozenset[str] = frozenset()
130
+ """Keys forced to their built-in default by :attr:`InvalidPolicy.SAFE_DEFAULT`.
131
+
132
+ A sealed key is the one case where a value the user can see in their own
133
+ ``settings.yaml`` is deliberately not in effect, so the panel has to be able
134
+ to say why instead of showing a saved value that does nothing.
135
+ """
136
+
137
+ unknown_keys: tuple[str, ...] = ()
138
+ """Dotted keys the user document holds that no field declares.
139
+
140
+ Reported once, not dropped: the keys survive every write untouched (a
141
+ patch never rewrites what it did not name), so a downgrade to the version
142
+ that understood them loses nothing — but silence here would leave a typo'd
143
+ key looking configured forever.
144
+ """
145
+
146
+ dormant_project: tuple[DormantProjectConfig, ...] = ()
147
+ """Project configuration discovered while ``project.config_enabled`` is off.
148
+
149
+ Carried rather than dropped for the same reason warnings are: a repository
150
+ shipped configuration, the user never authorised it, and silence would
151
+ leave them believing it is in force. The caller says so once per file.
152
+ """
153
+
154
+ dormant_project_sources: tuple[DormantProjectSource, ...] = ()
155
+ """Project hooks and skills discovered while their setting is off, reported the same way."""
156
+
157
+ def overlay(self, layer: Source, **values: Any) -> LoadedSettings:
158
+ """Return a copy carrying *values*, credited to *layer*.
159
+
160
+ The only sanctioned way to change settings after a load. A bare
161
+ ``dataclasses.replace`` on ``.settings`` — which is what every runtime
162
+ override did — leaves provenance describing the value the field used to
163
+ hold, and provenance that is right only some of the time is worse for
164
+ the panel than none: it would offer to remove an override the user never
165
+ set, and hide the one they did.
166
+
167
+ Values are checked exactly as pins are, for the same reason: they arrive
168
+ already typed, from our own code, and never meet a coercer otherwise.
169
+
170
+ Only the layers without a file can be spelled this way. A file layer's
171
+ origin must name the file it came from, which a caller holding one value
172
+ does not know.
173
+ """
174
+ if layer in FILE_SOURCES:
175
+ msg = f"{layer.name} values come from a file, so they cannot be overlaid"
176
+ raise ValueError(msg)
177
+ specs = specs_by_field(Settings)
178
+ unknown = sorted(set(values) - set(specs))
179
+ if unknown:
180
+ msg = f"Unknown settings fields: {', '.join(unknown)}"
181
+ raise TypeError(msg)
182
+ origin = SettingOrigin(layer=layer)
183
+ canonical = {name: _pinned(specs[name], value).value for name, value in values.items()}
184
+ overlaid = {specs[name].key for name in canonical}
185
+ return replace(
186
+ self,
187
+ settings=replace(self.settings, **canonical),
188
+ provenance={**self.provenance, **dict.fromkeys(overlaid, origin)},
189
+ # A sealed key is one nothing was allowed to write, so a key that
190
+ # has just been written is no longer one. Leaving it in would have
191
+ # the panel explain a built-in default that is not what is in use.
192
+ sealed_keys=self.sealed_keys - overlaid,
193
+ )
194
+
195
+ def source_for(self, key: str) -> SettingOrigin:
196
+ """Where *key*'s effective value came from, including which file.
197
+
198
+ Scoped to this instance on purpose: with per-session project roots and
199
+ model pins, a process-global ``effective_source(key)`` could not answer
200
+ honestly for more than one session at a time.
201
+ """
202
+ return self.provenance.get(key, SettingOrigin(layer=Source.DEFAULT))
203
+
204
+
205
+ @dataclass(frozen=True, slots=True, kw_only=True)
206
+ class PreparedSettings:
207
+ """A layered load and its already computed runtime overlay."""
208
+
209
+ base: LoadedSettings
210
+ effective: LoadedSettings
211
+
212
+
213
+ class SettingsHandle:
214
+ """The one settings value that everything sharing a session points at.
215
+
216
+ :class:`LoadedSettings` is replaced whole rather than written into, which
217
+ keeps a single holder honest and lets two holders drift: the TUI and the
218
+ engine start from the same load, and a live write rebinds one of them. The
219
+ holders share this instead, so a change is installed once and everyone
220
+ reading through it sees the same value, the same provenance and the same
221
+ seals — the three that only mean something together.
222
+
223
+ It is the mutable half of the split. ``LoadedSettings`` stays immutable so
224
+ a caller can hold one across a rebuild and know it did not move underneath
225
+ them; this names the place where moving is the point.
226
+
227
+ Per session, never process-global. ACP runs many sessions in one process
228
+ with their own project roots and model pins, so a shared cell above the
229
+ session is a cell where one session's pin becomes another's.
230
+ """
231
+
232
+ __slots__ = ("_base", "_effective", "_runtime")
233
+
234
+ def __init__(self, loaded: LoadedSettings) -> None:
235
+ self._base = loaded
236
+ self._runtime: dict[str, Any] = {}
237
+ self._effective = loaded
238
+
239
+ @property
240
+ def loaded(self) -> LoadedSettings:
241
+ """Current settings together with where each value came from."""
242
+ return self._effective
243
+
244
+ @property
245
+ def settings(self) -> Settings:
246
+ """Current values, for the readers that have no use for provenance."""
247
+ return self._effective.settings
248
+
249
+ def install(self, loaded: LoadedSettings) -> None:
250
+ """Replace the layered load. Live choices stay on top of the new one.
251
+
252
+ A reload re-reads the files and the environment, which is every layer
253
+ *except* the one a live choice lives in — ``Source.RUNTIME`` is defined
254
+ as the layer nothing loads from. Dropping those values here would undo
255
+ the user's theme the next time anything triggered a reload, and the TUI
256
+ would not even follow it back: Textual keeps showing the theme that was
257
+ chosen, so the settings would simply stop describing the screen.
258
+
259
+ The window where that is not merely a misattribution is real, because
260
+ ``persist_theme`` and its siblings log and swallow their failures: when
261
+ the write to disk did not land, this overlay is the only record that the
262
+ choice was ever made.
263
+ """
264
+ self.install_prepared(self.prepare(loaded))
265
+
266
+ def prepare(self, loaded: LoadedSettings) -> PreparedSettings:
267
+ """Compute a candidate overlay without changing the installed settings."""
268
+ effective = loaded.overlay(Source.RUNTIME, **self._runtime) if self._runtime else loaded
269
+ return PreparedSettings(base=loaded, effective=effective)
270
+
271
+ def install_prepared(self, prepared: PreparedSettings) -> None:
272
+ """Install values whose runtime overlay has already succeeded."""
273
+ self._base = prepared.base
274
+ self._effective = prepared.effective
275
+
276
+ def override(self, **values: Any) -> None:
277
+ """Record a live choice, credited to ``Source.RUNTIME``.
278
+
279
+ Kept apart from the load rather than folded into it so that reloading
280
+ and rolling back — both of which replace the whole layered value — can
281
+ do so without deciding what to do about choices they know nothing about.
282
+ """
283
+ self._runtime.update(values)
284
+ self._reapply()
285
+
286
+ def persist_approval_default(self, mode: str, *, persist: Callable[[str], None]) -> str:
287
+ """Persist a global choice and mirror its safe launch default in the runtime overlay."""
288
+ persist(mode)
289
+ default = "auto" if mode == "bypass" else mode
290
+ self.override(default_approval_mode=default)
291
+ return default
292
+
293
+ def _reapply(self) -> None:
294
+ """Rebuild the effective value: the load, then live choices on top.
295
+
296
+ Recomputed from ``_base`` every time rather than accumulated, so a
297
+ rollback that restores an older load cannot resurrect an override that
298
+ was replaced in the meantime, and repeating one is a no-op.
299
+ """
300
+ self._effective = self._base.overlay(Source.RUNTIME, **self._runtime) if self._runtime else self._base
301
+
302
+
303
+ def load_settings(
304
+ *,
305
+ project_root: Path | None = None,
306
+ eval_context: EvalContext = DEFAULT_EVAL_CONTEXT,
307
+ env: Mapping[str, str] | None = None,
308
+ **pins: Any,
309
+ ) -> LoadedSettings:
310
+ """Assemble settings from every source, resolving each key downwards.
311
+
312
+ Args:
313
+ project_root: Workspace root whose project layer applies —
314
+ ``<root>/.chrys/settings.yaml``, behind ``project.config_enabled``.
315
+ ``None`` loads without a project layer, which is what a caller
316
+ that serves no particular root (the ACP manager base) means by it.
317
+ The root a caller passes is a trust decision, not a discovery
318
+ hint: there is deliberately no walk upwards from it.
319
+ eval_context: Frontend policy in force. Must be passed at load time —
320
+ the project layer is evaluated *during* the load, so substituting
321
+ it afterwards would arrive after the verdicts it informs.
322
+ env: Environment to read instead of ``os.environ``. Tests inject here;
323
+ production passes nothing.
324
+ **pins: Explicit per-caller values that outrank every source, e.g. a
325
+ session's restored model profile or a frontend's own default.
326
+ """
327
+ if env is not None:
328
+ # An injected environment is the whole environment: hermetic, and not
329
+ # this process's, so nothing about the real one may leak into it.
330
+ source: Mapping[str, str] = env
331
+ real_process = False
332
+ else:
333
+ # The environment layer is the environment *as bootstrap found it*.
334
+ # Reading it live would let anything that writes ``os.environ`` mid-run
335
+ # mint an ENV-layer setting that outranks the user's document and is
336
+ # then reported as a shell export they never made — and would make two
337
+ # loads of the same files disagree. Before bootstrap there is no
338
+ # snapshot and the live environment is still the real one.
339
+ snapshot = process_env_snapshot()
340
+ source = snapshot.values if snapshot is not None else os.environ
341
+ real_process = True
342
+
343
+ if _FRONTEND_DEFAULT_FIELD in pins:
344
+ # It is an input to the load, not a value the load produces, and the
345
+ # project layer's tighten/loosen verdicts are evaluated against it. A
346
+ # pin would arrive after those verdicts and be silently overwritten
347
+ # here, so say so instead.
348
+ msg = f"{_FRONTEND_DEFAULT_FIELD} is set through eval_context, not as a pin"
349
+ raise TypeError(msg)
350
+
351
+ values: dict[str, Any] = {}
352
+ provenance: dict[str, SettingOrigin] = {}
353
+ specs = specs_by_field(Settings)
354
+ # An injected environment asks for a hermetic load, so it turns the file
355
+ # layers off along with the real environment — a test passing ``env={}``
356
+ # must not read the developer's own ``~/.chrys`` *or* the project files:
357
+ # hermetic means hermetic, whatever root was passed alongside.
358
+ file_layers = _read_file_layers(specs, project_root) if env is None else None
359
+ # Phase one: the project trust domain is audited completely before any
360
+ # winner is chosen, so a project value a higher layer shadows still gets
361
+ # its policy verdict and its warning.
362
+ project = _project_layers(specs, file_layers, eval_context) if file_layers is not None else _NO_PROJECT_LAYERS
363
+
364
+ warnings: list[SettingsWarning] = list(project.warnings)
365
+ sealed: set[str] = set()
366
+
367
+ for name, entry in specs.items():
368
+ for coerced, origin in _layers(name, entry, pins, source, real_process, file_layers, project):
369
+ if coerced.status is CoerceStatus.INVALID:
370
+ # Project verdicts were already reported whole in phase one
371
+ # (shadowed-still-warns); consulting one here only decides.
372
+ if origin.layer is not Source.PROJECT:
373
+ warnings.append(SettingsWarning(key=entry.key, origin=origin, outcome=coerced))
374
+ if entry.invalid_policy is InvalidPolicy.SAFE_DEFAULT:
375
+ # Stop the walk. The built-in default is the safe landing
376
+ # spot this policy names, and no lower layer may replace it
377
+ # — which is also what makes "sealed" imply "source is
378
+ # DEFAULT": nothing below ever gets to write the key.
379
+ sealed.add(entry.key)
380
+ break
381
+ continue
382
+ if coerced.status is CoerceStatus.CLAMPED:
383
+ warnings.append(SettingsWarning(key=entry.key, origin=origin, outcome=coerced))
384
+ values[name] = coerced.value
385
+ provenance[entry.key] = origin
386
+ break
387
+
388
+ # The frontend's retry policy is a property of how Chrys was launched, not
389
+ # a configured value, so it never appears in any file and is never saved.
390
+ values[_FRONTEND_DEFAULT_FIELD] = eval_context.frontend_default_max_transient_retries
391
+ provenance["llm.retry.frontend_default"] = SettingOrigin(layer=Source.RUNTIME)
392
+
393
+ # A pin naming a field that carries no spec still has to reach the instance;
394
+ # an unknown name falls out of ``Settings(**values)`` as a ``TypeError``.
395
+ values.update({name: value for name, value in pins.items() if name not in specs})
396
+
397
+ settings = Settings(**values)
398
+ return LoadedSettings(
399
+ settings=settings,
400
+ provenance=provenance,
401
+ warnings=tuple(warnings),
402
+ sealed_keys=frozenset(sealed),
403
+ unknown_keys=file_layers.unknown_keys if file_layers is not None else (),
404
+ dormant_project=project.dormant,
405
+ dormant_project_sources=(
406
+ _dormant_project_sources(settings, project_root)
407
+ if file_layers is not None and project_root is not None
408
+ else ()
409
+ ),
410
+ )
411
+
412
+
413
+ _PROJECT_HOOKS_FILE_NAMES: Final = ("hooks.yaml", "hooks.yml", "hooks.json")
414
+
415
+
416
+ def _holds_skill(directory: Path, depth: int = 0) -> bool:
417
+ """Whether *directory* has a ``SKILL.md`` as deep as skill discovery looks.
418
+
419
+ Mirrors ``service/skills/loader.py::discover_skill_directories``: the
420
+ folder itself and two levels below it.
421
+ """
422
+ if (directory / "SKILL.md").is_file():
423
+ return True
424
+ if depth >= 2 or not directory.is_dir():
425
+ return False
426
+ try:
427
+ children = [child for child in directory.iterdir() if child.is_dir()]
428
+ except OSError:
429
+ return False # Discovery skips a folder it cannot list, and goes on with the rest.
430
+ return any(_holds_skill(child, depth + 1) for child in children)
431
+
432
+
433
+ def _is_same_entry(left: Path, right: Path) -> bool:
434
+ """Whether two spellings name one file or folder; on a caseless disk they may differ in case."""
435
+ try:
436
+ return left.samefile(right)
437
+ except OSError:
438
+ return same_path(left, right)
439
+
440
+
441
+ def _dormant_project_sources(settings: Settings, project_root: Path) -> tuple[DormantProjectSource, ...]:
442
+ """The project hooks and skills *project_root* holds that their setting keeps unloaded.
443
+
444
+ A workspace rooted at the user's home names the user's own hooks and skills
445
+ folders again; those load as the user's and are never reported.
446
+ """
447
+ found: list[DormantProjectSource] = []
448
+ try:
449
+ if not settings.project_hooks_enabled:
450
+ hooks_dir = project_root / ".chrys" / "hooks"
451
+ if not _is_same_entry(hooks_dir, user_settings_path().parent / "hooks"):
452
+ hooks_file = next(
453
+ (hooks_dir / name for name in _PROJECT_HOOKS_FILE_NAMES if (hooks_dir / name).is_file()), None
454
+ )
455
+ if hooks_file is not None:
456
+ found.append(DormantProjectSource(key="project.hooks_enabled", path=hooks_file))
457
+ if not settings.project_skills_enabled:
458
+ skills_dir = project_root / ".agents" / "skills"
459
+ if not _is_same_entry(skills_dir, Path.home() / ".agents" / "skills") and _holds_skill(skills_dir):
460
+ found.append(DormantProjectSource(key="project.skills_enabled", path=skills_dir))
461
+ except OSError:
462
+ # A notice, never a reason to fail a settings load.
463
+ logger.debug("Could not check %s for project hooks or skills", project_root, exc_info=True)
464
+ return tuple(found)
465
+
466
+
467
+ @dataclass(frozen=True, slots=True)
468
+ class _FileLayers:
469
+ """The file-borne layers, read once per load, whole.
470
+
471
+ Reading happens here and not in :func:`_layers` because a settings document
472
+ is parsed once for every key it mentions — one pinned field cannot excuse
473
+ the loader from reading the rest of the file.
474
+ """
475
+
476
+ user_yaml: Mapping[str, Any]
477
+ """Dotted key → raw value, from ``~/.chrys/settings.yaml``."""
478
+
479
+ user_yaml_path: Path
480
+ user_env: Mapping[str, str]
481
+ """Canonically folded env name → value, from ``~/.chrys/.env``."""
482
+
483
+ user_env_path: Path
484
+ unknown_keys: tuple[str, ...]
485
+
486
+ project_yaml: Mapping[str, Any] = _EMPTY_MAP
487
+ """Dotted key → raw value, from ``<root>/.chrys/settings.yaml``.
488
+
489
+ The project layer's only file. A repository's ``.env`` is deliberately
490
+ not read: dotenv is the legacy channel on its way out, so its ``CHRYS_*``
491
+ lines stay inert rather than becoming a second project entrance.
492
+ """
493
+
494
+ project_yaml_unknown: tuple[str, ...] = ()
495
+ project_yaml_path: Path | None = None
496
+ """``None`` when no distinct project document applies."""
497
+
498
+
499
+ def _read_file_layers(specs: Mapping[str, SettingSpec], project_root: Path | None) -> _FileLayers | None:
500
+ """Read the file-borne layers, or ``None`` before bootstrap froze the env.
501
+
502
+ The snapshot gate is the hermeticity gate: a process that never called
503
+ :func:`freeze_process_env` — every test that constructs settings directly —
504
+ has not opted into this machine's home directory, so the files stay
505
+ unread rather than leaking one developer's ``~/.chrys`` into assertions.
506
+ It is also what the dotenv layer resolves against: values interpolate over
507
+ the environment as it was at bootstrap, never the mutated live one.
508
+ """
509
+ snapshot = process_env_snapshot()
510
+ if snapshot is None:
511
+ return None
512
+ yaml_path = user_settings_path()
513
+ # ``None`` covers both "no document yet" and "nothing parseable", and an
514
+ # unparseable document must load as empty rather than not at all: the
515
+ # environment layers above it still apply, and the panel still opens.
516
+ try:
517
+ doc = read_yaml_doc(yaml_path) or {}
518
+ except TimeoutError:
519
+ # Another process was inside the document's lock for the whole wait.
520
+ # The same rule as an unparseable file, for a stronger reason: this
521
+ # read is on the startup path of every entry point, so raising here
522
+ # means a second Chrys cannot start at all while the first is stalled
523
+ # mid-write. The values genuinely are not in force this launch, which
524
+ # is exactly what provenance will report, and the dotenv layer below
525
+ # needs no lock and still applies.
526
+ logger.warning("Settings document was busy; loading without it: %s", yaml_path, exc_info=True)
527
+ doc = {}
528
+ known_keys = frozenset(entry.key for entry in specs.values())
529
+ values, unknown = flatten_user_doc(doc, known_keys)
530
+ env_path = config_env_path()
531
+ layer = read_dotenv_layer(env_path, base=snapshot.values)
532
+ # Fold names the way the OS does, so on Windows a lowercase spelling in
533
+ # the file still answers for the uppercase alias. Later spellings win the
534
+ # collision, matching dotenv's own later-assignment-wins rule.
535
+ user_env = {canonical_env_name(env_name): value for env_name, value in layer.items()}
536
+
537
+ project_yaml: Mapping[str, Any] = _EMPTY_MAP
538
+ project_yaml_unknown: tuple[str, ...] = ()
539
+ project_yaml_path: Path | None = None
540
+ if project_root is not None:
541
+ candidate = project_root / ".chrys" / "settings.yaml"
542
+ # A workspace rooted at the user's home names the user document again.
543
+ # It remains the trusted USER layer; reading it a second time as an
544
+ # untrusted PROJECT layer would reject preferences and, with the gate
545
+ # off, report the user's own settings as dormant project configuration.
546
+ if not _is_same_entry(candidate, yaml_path):
547
+ project_yaml_path = candidate
548
+ # Same empty-on-unparseable rule as the user document, read without
549
+ # lock, backup or repair — the file lives in someone's working tree.
550
+ project_doc = read_yaml_doc_readonly(project_yaml_path) or {}
551
+ project_yaml, project_yaml_unknown = flatten_user_doc(project_doc, known_keys)
552
+
553
+ return _FileLayers(
554
+ user_yaml=values,
555
+ user_yaml_path=yaml_path,
556
+ user_env=user_env,
557
+ user_env_path=env_path,
558
+ unknown_keys=unknown,
559
+ project_yaml=project_yaml,
560
+ project_yaml_unknown=project_yaml_unknown,
561
+ project_yaml_path=project_yaml_path,
562
+ )
563
+
564
+
565
+ @dataclass(frozen=True, slots=True)
566
+ class _ProjectLayers:
567
+ """Phase-one verdicts for the project trust domain, taken whole.
568
+
569
+ ``values`` holds only what survived the policy — already coerced to
570
+ canonical, so phase two contributes it without a second verdict and a
571
+ shadowed value never re-warns. Everything that did not survive is in
572
+ ``warnings`` (or ``dormant``, when the gate was off and no per-key
573
+ question was ever asked).
574
+ """
575
+
576
+ values: Mapping[str, Any] = _EMPTY_MAP
577
+ """Field name → canonical value accepted from ``<root>/.chrys/settings.yaml``."""
578
+
579
+ rejected: Mapping[str, Coerced] = _EMPTY_MAP
580
+ """Field name → the phase-one verdict on a value the project may set but wrote wrong.
581
+
582
+ Only genuine value invalids — a policy rejection
583
+ (:attr:`CoerceReason.NOT_ALLOWED_IN_PROJECT`,
584
+ :attr:`CoerceReason.LOOSENS_USER_BASELINE`) means the layer had no standing
585
+ for the key at all and stays mute in phase two. A garbage value on a key the
586
+ whitelist *does* grant is a different thing: the layer had standing and
587
+ spoke unintelligibly, and phase two must see that so
588
+ :attr:`InvalidPolicy.SAFE_DEFAULT` can land the key on the built-in default
589
+ instead of letting the bad value uncap whatever a lower layer holds.
590
+ """
591
+
592
+ warnings: tuple[SettingsWarning, ...] = ()
593
+ dormant: tuple[DormantProjectConfig, ...] = ()
594
+
595
+
596
+ _NO_PROJECT_LAYERS = _ProjectLayers()
597
+
598
+
599
+ def _user_doc_verdict(entry: SettingSpec, raw: Any) -> Coerced:
600
+ """One user-document value's verdict, including the written-``null`` case."""
601
+ coerced = entry.coerce(raw)
602
+ if coerced.status is CoerceStatus.MISSING and raw is None and kind_accepts(entry.kind, raw):
603
+ # A written ``null`` is a value, not a blank. The document is typed
604
+ # — unlike an env string, which is why the coercers read a blank as
605
+ # "say nothing" — and :func:`persist` writes exactly this spelling
606
+ # for an ``OPTIONAL_INT`` the user cleared. Dropping it here would
607
+ # make "no timeout" unstorable: the panel writes ``null``, the load
608
+ # ignores it, and the field snaps back to its default with no
609
+ # warning. Only ``null`` is read back this way, and
610
+ # :func:`kind_accepts` is what makes that exact: it accepts ``None``
611
+ # for no other kind. ``""`` deliberately stays a blank — persisting
612
+ # an empty value removes the key instead of writing one, so an
613
+ # empty string in the document is a hand-edit that most likely
614
+ # means "unset", which is what falling through already gives.
615
+ coerced = Coerced(status=CoerceStatus.VALID, value=raw)
616
+ return coerced
617
+
618
+
619
+ def _default_user_baseline(specs: Mapping[str, SettingSpec], file_layers: _FileLayers) -> Settings:
620
+ """``DEFAULT + USER`` merged — what every project verdict compares against.
621
+
622
+ The *semantic* baseline, never the effective value: the layers above the
623
+ project (env, CLI, pins) are other people's decisions for this process,
624
+ and measuring a repository's tightening against them would let the
625
+ environment move the fence the user set. This is also where the gate is
626
+ read — ``project.config_enabled`` counts only from here, because reading
627
+ it from any layer a project file feeds would let the layer authorise
628
+ itself.
629
+ """
630
+ values: dict[str, Any] = {}
631
+ for name, entry in specs.items():
632
+ if not entry.persist or entry.key not in file_layers.user_yaml:
633
+ continue
634
+ coerced = _user_doc_verdict(entry, file_layers.user_yaml[entry.key])
635
+ if coerced.usable:
636
+ values[name] = coerced.value
637
+ return Settings(**values)
638
+
639
+
640
+ _PROJECT_POLICY_REASONS = frozenset({CoerceReason.NOT_ALLOWED_IN_PROJECT, CoerceReason.LOOSENS_USER_BASELINE})
641
+ """Rejections that mean "the project has no standing here", not "bad value"."""
642
+
643
+
644
+ def _project_verdict(
645
+ name: str,
646
+ entry: SettingSpec,
647
+ raw: Any,
648
+ *,
649
+ baseline: Settings,
650
+ baseline_values: Mapping[str, Any],
651
+ eval_context: EvalContext,
652
+ ) -> Coerced:
653
+ """One project value through the whole gauntlet, in the fixed order.
654
+
655
+ Policy first (a denied key's value is not worth a grammar opinion), then
656
+ the coercer to a canonical value, then the direction check against the
657
+ user baseline — on the *canonical* value, so ``60`` clamped to ``50`` is
658
+ judged as the ``50`` that would actually apply. ``MISSING`` means the
659
+ layer says nothing (``null`` and blanks are absences here, never values:
660
+ the written-``null`` reading belongs to the document the panel writes,
661
+ and nothing writes these).
662
+ """
663
+ if entry.project_merge is ProjectMerge.DENY or not entry.persist:
664
+ return invalid(raw, CoerceReason.NOT_ALLOWED_IN_PROJECT)
665
+ coerced = entry.coerce(raw)
666
+ if not coerced.usable:
667
+ return coerced
668
+ if not _within_project_bounds(name, entry, coerced.value, baseline, baseline_values, eval_context):
669
+ return invalid(raw, CoerceReason.LOOSENS_USER_BASELINE)
670
+ return coerced
671
+
672
+
673
+ def _within_project_bounds(
674
+ name: str,
675
+ entry: SettingSpec,
676
+ value: Any,
677
+ baseline: Settings,
678
+ baseline_values: Mapping[str, Any],
679
+ eval_context: EvalContext,
680
+ ) -> bool:
681
+ """Whether *value* respects the field's merge direction over the baseline."""
682
+ merge = entry.project_merge
683
+ if merge is ProjectMerge.FREE:
684
+ return True
685
+ current = baseline_values[name]
686
+ if merge is ProjectMerge.ENABLE_ONLY:
687
+ return bool(value) or not bool(current)
688
+ if merge is ProjectMerge.DISABLE_ONLY:
689
+ return not bool(value) or bool(current)
690
+ # TIGHTEN_ONLY — the spec's ``__post_init__`` guarantees the comparator.
691
+ semantic = entry.semantic_value
692
+ if semantic is None:
693
+ raise RuntimeError("A tighten-only setting requires a semantic comparator.")
694
+ return semantic(replace(baseline, **{name: value}), eval_context) <= semantic(baseline, eval_context)
695
+
696
+
697
+ def _project_layers(
698
+ specs: Mapping[str, SettingSpec],
699
+ file_layers: _FileLayers,
700
+ eval_context: EvalContext,
701
+ ) -> _ProjectLayers:
702
+ """Audit the project document completely — phase one of the load."""
703
+ yaml_path = file_layers.project_yaml_path
704
+ if yaml_path is None:
705
+ return _NO_PROJECT_LAYERS
706
+
707
+ baseline = _default_user_baseline(specs, file_layers)
708
+ if not baseline.project_config_enabled:
709
+ keys = (*file_layers.project_yaml, *file_layers.project_yaml_unknown)
710
+ if not keys:
711
+ return _NO_PROJECT_LAYERS
712
+ return _ProjectLayers(dormant=(DormantProjectConfig(path=yaml_path, keys=keys),))
713
+
714
+ baseline_values = asdict(baseline)
715
+ warnings: list[SettingsWarning] = []
716
+ origin = SettingOrigin(layer=Source.PROJECT, path=yaml_path)
717
+
718
+ # An unknown key in the *user* document is tolerated as a downgrade
719
+ # artefact; in a project file it is simply not something a repository
720
+ # may configure, and the warning has to name which file said so.
721
+ warnings.extend(
722
+ SettingsWarning(key=dotted, origin=origin, outcome=invalid("", CoerceReason.NOT_ALLOWED_IN_PROJECT))
723
+ for dotted in file_layers.project_yaml_unknown
724
+ )
725
+
726
+ by_key = {entry.key: (name, entry) for name, entry in specs.items()}
727
+ values: dict[str, Any] = {}
728
+ rejected: dict[str, Coerced] = {}
729
+ for dotted, raw in file_layers.project_yaml.items():
730
+ name, entry = by_key[dotted]
731
+ coerced = _project_verdict(
732
+ name,
733
+ entry,
734
+ raw,
735
+ baseline=baseline,
736
+ baseline_values=baseline_values,
737
+ eval_context=eval_context,
738
+ )
739
+ if coerced.status is CoerceStatus.MISSING:
740
+ continue
741
+ if not coerced.usable or coerced.status is CoerceStatus.CLAMPED:
742
+ warnings.append(SettingsWarning(key=dotted, origin=origin, outcome=coerced))
743
+ if coerced.usable:
744
+ values[name] = coerced.value
745
+ elif coerced.reason not in _PROJECT_POLICY_REASONS:
746
+ rejected[name] = coerced
747
+
748
+ return _ProjectLayers(values=values, rejected=rejected, warnings=tuple(warnings))
749
+
750
+
751
+ def _layers(
752
+ name: str,
753
+ entry: SettingSpec,
754
+ pins: Mapping[str, Any],
755
+ source: Mapping[str, str],
756
+ real_process: bool,
757
+ file_layers: _FileLayers | None,
758
+ project: _ProjectLayers,
759
+ ) -> Iterator[tuple[Coerced, SettingOrigin]]:
760
+ """Yield each layer's verdict for one field, **highest precedence first**.
761
+
762
+ Resolving downwards is what makes a rejected value local to its own layer.
763
+ Sweeping upwards instead — the shape this had while there was only one
764
+ layer — a high layer's typo could seal a key that a pin above it had
765
+ already answered, so the pin would be recorded as the effective source
766
+ while the value in force was the built-in default.
767
+
768
+ A generator, so a layer that nothing asks about costs nothing to skip.
769
+
770
+ Note what this is *not*: it is not where a file gets read. A settings
771
+ document is parsed once, whole, for every key it mentions — one pinned
772
+ field cannot excuse the loader from reading the rest of the file — and the
773
+ project layer's values must be policy-checked and their rejections reported
774
+ even when a higher layer shadows them. The reading happens in
775
+ :func:`_read_file_layers` before this function is ever called, and it keeps
776
+ the one job it has: picking the winner.
777
+ """
778
+ if name in pins:
779
+ yield _pinned(entry, pins[name]), SettingOrigin(layer=Source.SESSION)
780
+
781
+ if entry.env is not None:
782
+ # The model-profile pointer is the one environment name that is *not* a
783
+ # bootstrap fact: activation, ``--model`` and session restore write it
784
+ # mid-run, and the reload that immediately follows has to see what was
785
+ # just written rather than the value the process started with. Its
786
+ # value and its origin are taken in a single read, because they are one
787
+ # state: fetched separately, a write landing between the two hands this
788
+ # layer one writer's value under another writer's origin, and naming
789
+ # the writer is the only thing the layer adds over the carrier. An
790
+ # unregistered value is a real shell export and stays ``ENV``. Injected
791
+ # test environments are hermetic, so the process-global registration
792
+ # only speaks for the process environment.
793
+ if real_process and entry.env in POINTER_ENV_NAMES:
794
+ raw, registered = get_model_pointer()
795
+ origin = registered if registered is not None else SettingOrigin(layer=Source.ENV)
796
+ else:
797
+ raw = source.get(entry.env, "")
798
+ origin = SettingOrigin(layer=Source.ENV)
799
+ # Presence is read off the value because the coercers already fold a
800
+ # blank string into ``MISSING``; an exported empty name never spoke.
801
+ if raw:
802
+ coerced = (entry.env_coerce or entry.coerce)(raw)
803
+ if coerced.status is not CoerceStatus.MISSING:
804
+ yield coerced, origin
805
+
806
+ if file_layers is None:
807
+ return
808
+
809
+ if entry.env is not None and (folded := canonical_env_name(entry.env)) in file_layers.user_env:
810
+ coerced = (entry.env_coerce or entry.coerce)(file_layers.user_env[folded])
811
+ if coerced.status is not CoerceStatus.MISSING:
812
+ yield coerced, SettingOrigin(layer=Source.USER_ENV, path=file_layers.user_env_path)
813
+
814
+ # The project layer contributes what phase one already judged, at the
815
+ # verdict it judged it — a shadowed project value was audited there, so
816
+ # nothing here may warn a second time. Accepted values arrive canonical;
817
+ # a value invalid arrives as its ``INVALID`` verdict, because the walk is
818
+ # where ``SAFE_DEFAULT`` turns "this layer wrote garbage on a dangerous
819
+ # key" into a seal, and a verdict that never enters the walk can never
820
+ # seal (the exact uncap-the-backstop path the policy exists to close).
821
+ if name in project.values:
822
+ yield (
823
+ Coerced(status=CoerceStatus.VALID, value=project.values[name]),
824
+ SettingOrigin(layer=Source.PROJECT, path=file_layers.project_yaml_path),
825
+ )
826
+ elif name in project.rejected:
827
+ yield project.rejected[name], SettingOrigin(layer=Source.PROJECT, path=file_layers.project_yaml_path)
828
+
829
+ # ``persist=False`` marks a field runtime-only: :func:`persist` refuses to
830
+ # write it, and for the same reason a hand-written spelling does not load.
831
+ if entry.persist and entry.key in file_layers.user_yaml:
832
+ coerced = _user_doc_verdict(entry, file_layers.user_yaml[entry.key])
833
+ if coerced.status is not CoerceStatus.MISSING:
834
+ yield coerced, SettingOrigin(layer=Source.USER, path=file_layers.user_yaml_path)
835
+
836
+
837
+ def _pinned(entry: SettingSpec, value: Any) -> Coerced:
838
+ """Accept one caller-supplied value for *entry*, or raise.
839
+
840
+ Pins come from our own code — a restored session, a frontend's own default
841
+ — so a wrong one is a bug no user can fix and no warning can route around.
842
+ They are also the one layer that reaches ``Settings`` without passing a
843
+ coercer, which is why the check has to be made here rather than trusted:
844
+ ``tools.result.ceiling_tokens`` declares ``SAFE_DEFAULT`` precisely so a bad
845
+ value cannot uncap the backstop, and a pinned ``-1`` would do exactly that,
846
+ because the consumer reads any non-positive ceiling as "no ceiling".
847
+
848
+ Two checks, because neither alone is enough. :func:`kind_accepts` answers
849
+ the type question a coercer cannot (``None`` and ``""`` are real values for
850
+ the fields that declare them, not blanks to be dropped); the coercer answers
851
+ the domain question a type cannot. A value the coercer has nothing to say
852
+ about — those spellings, which come back ``MISSING`` — is taken verbatim.
853
+
854
+ ``Kind.ENUM`` is the exception, because it is the one kind whose domain is
855
+ written down in full: ``choices`` *is* the domain, so a value outside it is
856
+ outside the domain by construction, and there is no unset spelling left over
857
+ to mean anything. Everywhere else the blank carries meaning the coercer
858
+ cannot see — ``model_profile=""`` is "no pinned profile", and ACP's
859
+ ``ask_user_timeout_seconds=None`` is "no timeout, the client owns timing" —
860
+ so those stay verbatim. Without the exception a blank sails past the
861
+ canonical check below and lands in ``Settings`` as a value no ``choices``
862
+ list contains, and, because a rejected value is what seals a dangerous key,
863
+ it takes the seal with it: pinning ``default_approval_mode=""`` over an
864
+ ``ENV`` value that failed its coercer leaves the key holding ``""``,
865
+ unsealed, credited to the pin.
866
+ """
867
+ if not kind_accepts(entry.kind, value):
868
+ msg = f"{entry.key}: {type(value).__name__} is not a valid {entry.kind.name} value"
869
+ raise TypeError(msg)
870
+ coerced = entry.coerce(value)
871
+ if coerced.status is CoerceStatus.MISSING:
872
+ if entry.kind is Kind.ENUM:
873
+ # ``choices`` may be a ChoiceProvider, whose members only a higher
874
+ # layer can enumerate — name it rather than pretending to list it.
875
+ offered = list(entry.choices) if isinstance(entry.choices, tuple) else entry.choices
876
+ msg = f"{entry.key}: {value!r} is not one of {offered}"
877
+ raise TypeError(msg)
878
+ return Coerced(status=CoerceStatus.VALID, value=value)
879
+ if coerced.status is not CoerceStatus.VALID:
880
+ msg = f"{entry.key}: {value!r} is not a valid value ({coerced.status.name.lower()})"
881
+ raise TypeError(msg)
882
+ if coerced.value != value:
883
+ # A pin that only survives normalisation is still a caller passing the
884
+ # wrong thing: it would be stored as one value and echoed back to the
885
+ # panel as another, and the caller would never learn which won.
886
+ msg = f"{entry.key}: {value!r} is not canonical; pass {coerced.value!r}"
887
+ raise TypeError(msg)
888
+ return coerced
889
+
890
+
891
+ _APPROVAL_MODE_KEY: Final = "approval.default_mode"
892
+
893
+ _KIND_REASONS: Final = {
894
+ Kind.BOOL: CoerceReason.EXPECTED_BOOL,
895
+ Kind.INT: CoerceReason.EXPECTED_INT,
896
+ Kind.OPTIONAL_INT: CoerceReason.EXPECTED_INT,
897
+ Kind.FLOAT: CoerceReason.EXPECTED_NUMBER,
898
+ Kind.TEXT: CoerceReason.EXPECTED_TEXT,
899
+ Kind.ENUM: CoerceReason.NOT_A_CHOICE,
900
+ Kind.PATH: CoerceReason.EXPECTED_TEXT,
901
+ }
902
+ """How to word the rejection when a value's *type* is what disqualified it."""
903
+
904
+
905
+ @dataclass(frozen=True, slots=True)
906
+ class PersistResult:
907
+ """What one :func:`persist` call did, in full.
908
+
909
+ Mirrors :class:`LoadedSettings`' contract: a caller gets the outcome and
910
+ the rejections together, and the panel that ignores ``rejected`` is the
911
+ panel that cannot say why a field snapped back to its old value.
912
+ """
913
+
914
+ written: Mapping[str, Any]
915
+ """Dotted key → the canonical value actually stored (post-clamp)."""
916
+
917
+ rejected: Mapping[str, Coerced]
918
+ """Dotted key → the verdict that kept the whole batch out of the file."""
919
+
920
+ @property
921
+ def ok(self) -> bool:
922
+ return not self.rejected
923
+
924
+
925
+ def persist(
926
+ updates: Mapping[str, Any],
927
+ *,
928
+ remove: Iterable[str] = (),
929
+ lock_timeout: float = LOCK_TIMEOUT_SECONDS,
930
+ ) -> PersistResult:
931
+ """Write dotted-key values into the user settings document, all or nothing.
932
+
933
+ Validation happens first, against the same coercers the loader applies on
934
+ the way back in, and one rejection keeps the entire batch out of the file:
935
+ a partial write would leave the document describing a state no caller ever
936
+ asked for. Clamped values are stored at their clamped canonical — the file
937
+ holds what will be in effect, not what was typed. ``remove`` deletes keys;
938
+ a batch of only removals is a legal write.
939
+
940
+ An unknown key or a runtime-only (``persist=False``) key raises instead of
941
+ rejecting: both are our-code bugs no user input can produce, exactly like
942
+ a bad pin.
943
+
944
+ ``approval.default_mode`` is the one key with a store-time rule of its
945
+ own: ``bypass`` is a per-launch decision, so persisting it downgrades to
946
+ ``auto`` rather than arming every future session.
947
+ """
948
+ specs = specs_by_field(Settings)
949
+ by_key = {entry.key: entry for entry in specs.values()}
950
+ removals = tuple(dict.fromkeys(remove))
951
+ path = user_settings_path()
952
+
953
+ unknown = sorted(key for key in {*updates, *removals} if key not in by_key)
954
+ if unknown:
955
+ msg = f"Unknown settings keys: {', '.join(unknown)}"
956
+ raise TypeError(msg)
957
+ runtime_only = sorted(key for key in {*updates, *removals} if not by_key[key].persist)
958
+ if runtime_only:
959
+ msg = f"Runtime-only settings keys cannot be persisted: {', '.join(runtime_only)}"
960
+ raise TypeError(msg)
961
+
962
+ written: dict[str, Any] = {}
963
+ rejected: dict[str, Coerced] = {}
964
+ for key, value in updates.items():
965
+ entry = by_key[key]
966
+ coerced = entry.coerce(value)
967
+ if coerced.status is CoerceStatus.MISSING:
968
+ # The unset spellings are real values for the fields that declare
969
+ # them (``""`` is "no pinned profile"), same as for a pin — and
970
+ # ``ENUM`` is the same exception, its domain being written in full.
971
+ if kind_accepts(entry.kind, value) and entry.kind is not Kind.ENUM:
972
+ written[key] = value
973
+ else:
974
+ rejected[key] = _kind_rejection(entry, value)
975
+ continue
976
+ if coerced.status is CoerceStatus.INVALID:
977
+ rejected[key] = coerced
978
+ continue
979
+ written[key] = coerced.value
980
+
981
+ if written.get(_APPROVAL_MODE_KEY) == "bypass":
982
+ written[_APPROVAL_MODE_KEY] = "auto"
983
+
984
+ if rejected:
985
+ return PersistResult(written={}, rejected=rejected)
986
+ if written or removals:
987
+ update_yaml_doc(path, lambda doc: apply_settings_patch(doc, written, removals), lock_timeout=lock_timeout)
988
+ return PersistResult(written=written, rejected={})
989
+
990
+
991
+ def _kind_rejection(entry: SettingSpec, value: Any) -> Coerced:
992
+ """Word the refusal of a value whose type no coercer had a verdict for."""
993
+ if entry.kind is Kind.ENUM:
994
+ choices = entry.choices if isinstance(entry.choices, tuple) else ()
995
+ return invalid(value, CoerceReason.NOT_A_CHOICE, choices=choices)
996
+ return invalid(value, _KIND_REASONS[entry.kind])