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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Workspace baselines, external-change detection, and notice rendering."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import errno
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import PurePath
|
|
19
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
20
|
+
|
|
21
|
+
from chrys.foundation.platform.paths import display_relpath
|
|
22
|
+
from chrys.service.mutations.git_state import (
|
|
23
|
+
GIT_PATH_LIMIT,
|
|
24
|
+
GitDeltaEntry,
|
|
25
|
+
canonical_git_pathspecs,
|
|
26
|
+
canonical_path,
|
|
27
|
+
entry_display_path,
|
|
28
|
+
entry_path,
|
|
29
|
+
lexical_display_path,
|
|
30
|
+
read_git_head,
|
|
31
|
+
read_git_head_delta,
|
|
32
|
+
read_git_status,
|
|
33
|
+
resolve_git_root,
|
|
34
|
+
)
|
|
35
|
+
from chrys.service.mutations.scanner import DEFAULT_EXCLUDES, WorkspaceScanner
|
|
36
|
+
from chrys.service.mutations.store import SnapshotStore
|
|
37
|
+
from chrys.service.mutations.types import FileHashDiff, MutationOp, MutationProvenance, SnapshotSkipReason
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from chrys.foundation.models.workspace import Workspace
|
|
41
|
+
from chrys.service.mutations.tracker import MutationTracker
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_MAX_CAPTURE_ROOTS = 32
|
|
46
|
+
_CAPTURE_DEADLINE_SECONDS = 15.0
|
|
47
|
+
_SCAN_OVERFLOW_SENTINEL = 5_001
|
|
48
|
+
_DIAGNOSTIC_LINE_LIMIT = 8
|
|
49
|
+
_SERIALIZATION_VERSION = 1
|
|
50
|
+
_NOTICE_TIMEOUT_WARNING = (
|
|
51
|
+
"Workspace change detection could not be completed for this turn. Files may "
|
|
52
|
+
"have been changed outside this session without being reported, and later "
|
|
53
|
+
"notices will not include them. Re-read externally relevant files before "
|
|
54
|
+
"relying on their remembered contents."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaselineMode(Enum):
|
|
59
|
+
"""How a workspace root is observed."""
|
|
60
|
+
|
|
61
|
+
GIT = "git"
|
|
62
|
+
SCAN = "scan"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DegradedReason(Enum):
|
|
66
|
+
"""Why a complete comparable root baseline is unavailable."""
|
|
67
|
+
|
|
68
|
+
TOO_MANY_FILES = "too_many_files"
|
|
69
|
+
CAPTURE_FAILED = "capture_failed"
|
|
70
|
+
CAPTURE_DEADLINE = "capture_deadline"
|
|
71
|
+
MISSING_BASELINE = "missing_baseline"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ExternalOp(Enum):
|
|
75
|
+
"""Operation shown in the external-change section."""
|
|
76
|
+
|
|
77
|
+
CREATED = "created"
|
|
78
|
+
MODIFIED = "modified"
|
|
79
|
+
DELETED = "deleted"
|
|
80
|
+
RECONCILED = "reconciled"
|
|
81
|
+
TRACKING_CHANGED = "tracking_changed"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class BaselineFileState:
|
|
86
|
+
"""Filesystem and Git state for one observed path."""
|
|
87
|
+
|
|
88
|
+
exists: bool
|
|
89
|
+
mtime_ns: int
|
|
90
|
+
size: int
|
|
91
|
+
mode: int | None
|
|
92
|
+
statuses: tuple[str, ...]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class RootBaseline:
|
|
97
|
+
"""One complete root baseline."""
|
|
98
|
+
|
|
99
|
+
root: str
|
|
100
|
+
mode: BaselineMode
|
|
101
|
+
head: str | None
|
|
102
|
+
branch: str | None
|
|
103
|
+
scope: tuple[str, ...]
|
|
104
|
+
files: Mapping[str, BaselineFileState]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class DegradedBaseline:
|
|
109
|
+
"""One incomplete root capture."""
|
|
110
|
+
|
|
111
|
+
reason: DegradedReason
|
|
112
|
+
mode: BaselineMode | None
|
|
113
|
+
scope: tuple[str, ...]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class WorkspaceBaseline:
|
|
118
|
+
"""Complete published workspace state for a turn boundary."""
|
|
119
|
+
|
|
120
|
+
turn_id: int
|
|
121
|
+
roots: Mapping[str, RootBaseline]
|
|
122
|
+
degraded: Mapping[str, DegradedBaseline]
|
|
123
|
+
root_limit_omitted: int = 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class AgentChange:
|
|
128
|
+
"""One agent-attributed change ready for rendering."""
|
|
129
|
+
|
|
130
|
+
path: str
|
|
131
|
+
operation: MutationOp
|
|
132
|
+
content_not_compared: bool = False
|
|
133
|
+
contested: bool = False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class PeerChange:
|
|
138
|
+
"""One peer-session write observed by this session's mutation log.
|
|
139
|
+
|
|
140
|
+
Peer writes land before the end-of-turn baseline capture, so the
|
|
141
|
+
next external diff cannot see them — the turn log is the only
|
|
142
|
+
witness and these rows must be carried into the notice directly.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
path: str
|
|
146
|
+
operation: MutationOp
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class ExternalChange:
|
|
151
|
+
"""One externally observed change ready for rendering."""
|
|
152
|
+
|
|
153
|
+
root: str
|
|
154
|
+
path: str
|
|
155
|
+
operation: ExternalOp
|
|
156
|
+
agent_overlap: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class HeadMove:
|
|
161
|
+
"""One scoped repository HEAD movement."""
|
|
162
|
+
|
|
163
|
+
root: str
|
|
164
|
+
old_head: str | None
|
|
165
|
+
new_head: str | None
|
|
166
|
+
uncertain: bool = False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class WorkspaceChangeNotice:
|
|
171
|
+
"""Bounded notice payload independent of rendering cwd."""
|
|
172
|
+
|
|
173
|
+
agent_changes: tuple[AgentChange, ...] = ()
|
|
174
|
+
agent_omitted: int = 0
|
|
175
|
+
external_changes: tuple[ExternalChange, ...] = ()
|
|
176
|
+
external_omitted: int = 0
|
|
177
|
+
peer_changes: tuple[PeerChange, ...] = ()
|
|
178
|
+
peer_omitted: int = 0
|
|
179
|
+
head_moves: tuple[HeadMove, ...] = ()
|
|
180
|
+
degraded: tuple[tuple[str, DegradedBaseline], ...] = ()
|
|
181
|
+
root_limit_omitted: int = 0
|
|
182
|
+
agent_detection_truncated: bool = False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class _CaptureRoot:
|
|
187
|
+
root: str
|
|
188
|
+
mode: BaselineMode | None
|
|
189
|
+
scope: tuple[str, ...]
|
|
190
|
+
exclude_roots: frozenset[str] = frozenset()
|
|
191
|
+
unavailable_reason: DegradedReason | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class _ResolvedScope:
|
|
196
|
+
roots: tuple[_CaptureRoot, ...]
|
|
197
|
+
root_limit_omitted: int = 0
|
|
198
|
+
display_roots: tuple[tuple[str, str], ...] = ()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
202
|
+
class WorkspaceRetarget:
|
|
203
|
+
"""Resolved workspace values ready to install without filesystem access."""
|
|
204
|
+
|
|
205
|
+
scope: _ResolvedScope
|
|
206
|
+
resolve_scope: bool
|
|
207
|
+
new_cwd: str | None
|
|
208
|
+
canonical_new_cwd: str | None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class _SafetyNotice:
|
|
213
|
+
"""A queued safety notice plus the cwd its relative paths were rendered
|
|
214
|
+
against; ``None`` once the base is unknown or already annotated."""
|
|
215
|
+
|
|
216
|
+
text: str
|
|
217
|
+
cwd: str | None = None
|
|
218
|
+
canonical_cwd: str | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class WorkspaceChangeTracker:
|
|
222
|
+
"""Generation-guarded workspace state owned by one engine."""
|
|
223
|
+
|
|
224
|
+
def __init__(self) -> None:
|
|
225
|
+
self._lock = threading.RLock()
|
|
226
|
+
self._capture_gate = threading.Lock()
|
|
227
|
+
self._generation = 0
|
|
228
|
+
self._scope = _ResolvedScope(roots=())
|
|
229
|
+
self._baseline: WorkspaceBaseline | None = None
|
|
230
|
+
self._pending_boundary_notice: str | None = None
|
|
231
|
+
self._pending_safety: list[_SafetyNotice] = []
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def baseline(self) -> WorkspaceBaseline | None:
|
|
235
|
+
"""Return the immutable current baseline."""
|
|
236
|
+
with self._lock:
|
|
237
|
+
return self._baseline
|
|
238
|
+
|
|
239
|
+
def retarget_roots(self, workspace: Workspace | None, *, resolve_scope: bool = True) -> None:
|
|
240
|
+
"""Resolve a new desired scope and retain only comparable baseline roots.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
resolve_scope: ``False`` when the change notice is disabled: the
|
|
244
|
+
scope is then left inactive (empty) without probing the roots
|
|
245
|
+
at all, and the baseline is kept as it is rather than
|
|
246
|
+
reconciled against that empty scope. Resolution asks Git about
|
|
247
|
+
every root — a synchronous subprocess per root, on the
|
|
248
|
+
caller's thread — and nothing reads the scope while the
|
|
249
|
+
notice is off; re-enabling the setting rebuilds the runtime,
|
|
250
|
+
which retargets again with resolution on and reconciles the
|
|
251
|
+
kept baseline then. Safety notices are rebased regardless:
|
|
252
|
+
they are delivered whether or not the notice is enabled.
|
|
253
|
+
"""
|
|
254
|
+
self.apply_retarget(self.resolve_retarget(workspace, resolve_scope=resolve_scope))
|
|
255
|
+
|
|
256
|
+
def resolve_retarget(self, workspace: Workspace | None, *, resolve_scope: bool = True) -> WorkspaceRetarget:
|
|
257
|
+
"""Resolve candidate scope and directory identity without writing live state."""
|
|
258
|
+
scope = _resolve_workspace_scope(workspace) if resolve_scope else _ResolvedScope(roots=())
|
|
259
|
+
new_cwd = os.path.normpath(os.path.abspath(workspace.primary_cwd)) if workspace is not None else None
|
|
260
|
+
return WorkspaceRetarget(
|
|
261
|
+
scope=scope,
|
|
262
|
+
resolve_scope=resolve_scope,
|
|
263
|
+
new_cwd=new_cwd,
|
|
264
|
+
canonical_new_cwd=canonical_path(new_cwd) if new_cwd is not None else None,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def apply_retarget(self, retarget: WorkspaceRetarget) -> None:
|
|
268
|
+
"""Install the resolved workspace values and rebase queued notices in memory."""
|
|
269
|
+
with self._lock:
|
|
270
|
+
old_baseline = self._baseline
|
|
271
|
+
self._generation += 1
|
|
272
|
+
self._scope = retarget.scope
|
|
273
|
+
self._pending_boundary_notice = None
|
|
274
|
+
# Safety notices deliberately survive a workspace switch, but
|
|
275
|
+
# their relative paths were rendered against the old cwd —
|
|
276
|
+
# pin the base explicitly so the model does not resolve them
|
|
277
|
+
# against the new workspace.
|
|
278
|
+
self._pending_safety = [_rebase_safety_notice(chunk, retarget) for chunk in self._pending_safety]
|
|
279
|
+
if old_baseline is None or not retarget.resolve_scope:
|
|
280
|
+
return
|
|
281
|
+
self._baseline = _reconcile_baseline(old_baseline, retarget.scope)
|
|
282
|
+
|
|
283
|
+
def reset_for_restart(self) -> None:
|
|
284
|
+
"""Clear session-owned state while preserving the tracker instance."""
|
|
285
|
+
with self._lock:
|
|
286
|
+
self._generation += 1
|
|
287
|
+
self._scope = _ResolvedScope(roots=())
|
|
288
|
+
self._baseline = None
|
|
289
|
+
self._pending_boundary_notice = None
|
|
290
|
+
self._pending_safety = []
|
|
291
|
+
|
|
292
|
+
def invalidate(self) -> None:
|
|
293
|
+
"""Invalidate the external baseline without clearing safety delivery."""
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._generation += 1
|
|
296
|
+
self._baseline = None
|
|
297
|
+
self._pending_boundary_notice = None
|
|
298
|
+
|
|
299
|
+
def capture_baseline(self, turn_id: int) -> WorkspaceBaseline:
|
|
300
|
+
"""Capture and publish a complete turn-end baseline."""
|
|
301
|
+
if not self._capture_gate.acquire(blocking=False):
|
|
302
|
+
return self._install_capture_deadline(turn_id)
|
|
303
|
+
try:
|
|
304
|
+
with self._lock:
|
|
305
|
+
generation = self._generation
|
|
306
|
+
scope = self._scope
|
|
307
|
+
result = _capture_workspace(turn_id, scope, deadline=time.monotonic() + _CAPTURE_DEADLINE_SECONDS)
|
|
308
|
+
with self._lock:
|
|
309
|
+
if generation == self._generation and scope == self._scope:
|
|
310
|
+
self._baseline = result
|
|
311
|
+
return result
|
|
312
|
+
finally:
|
|
313
|
+
self._capture_gate.release()
|
|
314
|
+
|
|
315
|
+
def capture_timed_out(self, turn_id: int) -> None:
|
|
316
|
+
"""Supersede an uninterruptible capture worker after its async deadline."""
|
|
317
|
+
self._install_capture_deadline(turn_id)
|
|
318
|
+
|
|
319
|
+
def capture_cancelled(self) -> None:
|
|
320
|
+
"""Reject a capture result whose awaiting lifecycle was cancelled."""
|
|
321
|
+
self.invalidate()
|
|
322
|
+
|
|
323
|
+
def compute_turn_notice(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
turn_id: int,
|
|
327
|
+
mutation_tracker: MutationTracker | None,
|
|
328
|
+
agent_turn_id: int | None,
|
|
329
|
+
overlap_turn_id: int | None,
|
|
330
|
+
cwd: str,
|
|
331
|
+
max_entries: int,
|
|
332
|
+
recovered: bool = False,
|
|
333
|
+
latest_agent_turn: int | None = None,
|
|
334
|
+
) -> str | None:
|
|
335
|
+
"""Compute and publish the next one-shot boundary notice."""
|
|
336
|
+
with self._lock:
|
|
337
|
+
generation = self._generation
|
|
338
|
+
scope = self._scope
|
|
339
|
+
baseline = self._baseline
|
|
340
|
+
|
|
341
|
+
agent_changes, agent_truncated = _agent_changes(mutation_tracker, agent_turn_id)
|
|
342
|
+
# Retry omits the agent section (``agent_turn_id`` is None) but must
|
|
343
|
+
# not drop the interrupted turn's truncation flag with it — the
|
|
344
|
+
# unobserved writes it stands for get absorbed by the baseline, so
|
|
345
|
+
# this warning is their only surface.
|
|
346
|
+
agent_truncated = agent_truncated or _detection_truncated(mutation_tracker, latest_agent_turn)
|
|
347
|
+
overlap = _mutation_paths(mutation_tracker, overlap_turn_id, overlap_turn_id)
|
|
348
|
+
external: list[ExternalChange] = []
|
|
349
|
+
head_moves: list[HeadMove] = []
|
|
350
|
+
degraded: dict[str, DegradedBaseline] = {}
|
|
351
|
+
root_limit_omitted = baseline.root_limit_omitted if baseline is not None else 0
|
|
352
|
+
|
|
353
|
+
if baseline is not None:
|
|
354
|
+
if not self._capture_gate.acquire(blocking=False):
|
|
355
|
+
degraded = {
|
|
356
|
+
root.root: DegradedBaseline(DegradedReason.CAPTURE_DEADLINE, root.mode, root.scope)
|
|
357
|
+
for root in scope.roots
|
|
358
|
+
}
|
|
359
|
+
with self._lock:
|
|
360
|
+
self._generation += 1
|
|
361
|
+
generation = self._generation
|
|
362
|
+
else:
|
|
363
|
+
deadline = time.monotonic() + _CAPTURE_DEADLINE_SECONDS
|
|
364
|
+
try:
|
|
365
|
+
current = _capture_workspace(turn_id, scope, deadline=deadline)
|
|
366
|
+
external, head_moves, degraded = _diff_workspace(baseline, current, deadline=deadline)
|
|
367
|
+
root_limit_omitted = current.root_limit_omitted
|
|
368
|
+
finally:
|
|
369
|
+
self._capture_gate.release()
|
|
370
|
+
|
|
371
|
+
if recovered:
|
|
372
|
+
if mutation_tracker is None:
|
|
373
|
+
degraded["recovery"] = DegradedBaseline(
|
|
374
|
+
DegradedReason.MISSING_BASELINE,
|
|
375
|
+
None,
|
|
376
|
+
(),
|
|
377
|
+
)
|
|
378
|
+
external = []
|
|
379
|
+
else:
|
|
380
|
+
# Subtract only rows the recovered log fully explains: a
|
|
381
|
+
# recovered mutation names the path AND current disk
|
|
382
|
+
# provably matches its recorded endpoint. Anything else —
|
|
383
|
+
# a post-crash edit on top of the agent's write, or an
|
|
384
|
+
# unverifiable endpoint — stays visible as external.
|
|
385
|
+
endpoints = _recovered_endpoints(
|
|
386
|
+
mutation_tracker,
|
|
387
|
+
baseline.turn_id + 1,
|
|
388
|
+
latest_agent_turn,
|
|
389
|
+
)
|
|
390
|
+
max_verify_bytes = mutation_tracker.store.policy.max_file_bytes
|
|
391
|
+
external = [
|
|
392
|
+
entry
|
|
393
|
+
for entry in external
|
|
394
|
+
if not _matches_recovered_endpoint(
|
|
395
|
+
endpoints.get(entry_path(entry.path)), entry.path, max_verify_bytes
|
|
396
|
+
)
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
agent_changes.sort(key=lambda entry: canonical_path(entry.path))
|
|
400
|
+
bounded_agent = tuple(agent_changes[:max_entries])
|
|
401
|
+
|
|
402
|
+
peer_changes = _peer_witnessed_changes(mutation_tracker, latest_agent_turn)
|
|
403
|
+
if peer_changes:
|
|
404
|
+
# The current diff (fresher state) and the agent rows that will
|
|
405
|
+
# actually be displayed (whose contested badge already names the
|
|
406
|
+
# peer) both outrank a raw peer row for the same path. Rows folded
|
|
407
|
+
# into the "...and N more" overflow never render their badge, so
|
|
408
|
+
# they must not suppress the peer fallback.
|
|
409
|
+
covered = {entry_path(entry.path) for entry in external}
|
|
410
|
+
covered.update(entry_path(entry.path) for entry in bounded_agent)
|
|
411
|
+
peer_changes = [change for change in peer_changes if entry_path(change.path) not in covered]
|
|
412
|
+
peer_changes = [replace(change, path=_display_path(change.path, scope)) for change in peer_changes]
|
|
413
|
+
peer_changes.sort(key=lambda change: canonical_path(change.path))
|
|
414
|
+
|
|
415
|
+
external = [replace(entry, agent_overlap=entry_path(entry.path) in overlap) for entry in external]
|
|
416
|
+
external = [
|
|
417
|
+
replace(
|
|
418
|
+
entry,
|
|
419
|
+
root=_display_path(entry.root, scope),
|
|
420
|
+
path=_display_path(entry.path, scope),
|
|
421
|
+
)
|
|
422
|
+
for entry in external
|
|
423
|
+
]
|
|
424
|
+
head_moves = [replace(move, root=_display_path(move.root, scope)) for move in head_moves]
|
|
425
|
+
degraded = {_display_path(root, scope): value for root, value in degraded.items()}
|
|
426
|
+
external.sort(key=lambda entry: (entry.root, canonical_path(entry.path)))
|
|
427
|
+
bounded_external = tuple(external[:max_entries])
|
|
428
|
+
bounded_peer = tuple(peer_changes[:max_entries])
|
|
429
|
+
notice = WorkspaceChangeNotice(
|
|
430
|
+
agent_changes=tuple(replace(entry, path=_display_path(entry.path, scope)) for entry in bounded_agent),
|
|
431
|
+
agent_omitted=max(0, len(agent_changes) - len(bounded_agent)),
|
|
432
|
+
external_changes=bounded_external,
|
|
433
|
+
external_omitted=max(0, len(external) - len(bounded_external)),
|
|
434
|
+
peer_changes=bounded_peer,
|
|
435
|
+
peer_omitted=max(0, len(peer_changes) - len(bounded_peer)),
|
|
436
|
+
head_moves=tuple(head_moves),
|
|
437
|
+
degraded=tuple(sorted(degraded.items())),
|
|
438
|
+
root_limit_omitted=root_limit_omitted,
|
|
439
|
+
agent_detection_truncated=agent_truncated,
|
|
440
|
+
)
|
|
441
|
+
rendered = format_workspace_change_notice(notice, cwd=cwd)
|
|
442
|
+
with self._lock:
|
|
443
|
+
if generation == self._generation and scope == self._scope:
|
|
444
|
+
self._pending_boundary_notice = rendered
|
|
445
|
+
return rendered
|
|
446
|
+
|
|
447
|
+
def notice_cancelled(self) -> None:
|
|
448
|
+
"""Reject a late notice worker while retaining the saved baseline."""
|
|
449
|
+
with self._lock:
|
|
450
|
+
self._generation += 1
|
|
451
|
+
self._pending_boundary_notice = None
|
|
452
|
+
|
|
453
|
+
def notice_timed_out(self) -> None:
|
|
454
|
+
"""Reject a late notice worker and warn that the comparison is lost.
|
|
455
|
+
|
|
456
|
+
Unlike cancellation — where the turn unwinds and the retained
|
|
457
|
+
baseline gets compared again at the next boundary — a timed-out
|
|
458
|
+
turn continues to finalization, whose fresh capture absorbs
|
|
459
|
+
everything the comparison would have reported. Queue a
|
|
460
|
+
persistent safety caveat so that absorption is never silent.
|
|
461
|
+
"""
|
|
462
|
+
self.notice_cancelled()
|
|
463
|
+
self.queue_safety_notice(_NOTICE_TIMEOUT_WARNING)
|
|
464
|
+
|
|
465
|
+
def clear_boundary_notice(self) -> None:
|
|
466
|
+
"""Clear only ordinary boundary state after an advisory failure."""
|
|
467
|
+
with self._lock:
|
|
468
|
+
self._pending_boundary_notice = None
|
|
469
|
+
|
|
470
|
+
def queue_safety_notice(self, notice: str, *, cwd: str | None = None) -> None:
|
|
471
|
+
"""Queue an explicit safety warning independently of boundary compute.
|
|
472
|
+
|
|
473
|
+
``cwd`` records the base its relative paths were rendered against,
|
|
474
|
+
so a later workspace switch can pin the base instead of letting
|
|
475
|
+
the paths silently re-anchor to the new workspace.
|
|
476
|
+
"""
|
|
477
|
+
if not notice:
|
|
478
|
+
return
|
|
479
|
+
base = os.path.normpath(os.path.abspath(cwd)) if cwd else None
|
|
480
|
+
chunk = _resolve_safety_notice(_SafetyNotice(notice, base))
|
|
481
|
+
with self._lock:
|
|
482
|
+
self._pending_safety.append(chunk)
|
|
483
|
+
|
|
484
|
+
def requeue_notice(self, notice: str, *, cwd: str | None = None) -> None:
|
|
485
|
+
"""Preserve a notice drained for a pre-executor run that never reached the model."""
|
|
486
|
+
self.queue_safety_notice(notice, cwd=cwd)
|
|
487
|
+
|
|
488
|
+
def take_pending_notice(self) -> str | None:
|
|
489
|
+
"""Atomically drain safety first and boundary second exactly once."""
|
|
490
|
+
with self._lock:
|
|
491
|
+
safety_chunks = self._pending_safety
|
|
492
|
+
boundary = self._pending_boundary_notice
|
|
493
|
+
self._pending_safety = []
|
|
494
|
+
self._pending_boundary_notice = None
|
|
495
|
+
safety: str | None = None
|
|
496
|
+
for chunk in safety_chunks:
|
|
497
|
+
safety = _join_notices(safety, chunk.text)
|
|
498
|
+
return _join_notices(safety, boundary)
|
|
499
|
+
|
|
500
|
+
def serialize(self) -> dict[str, Any] | None:
|
|
501
|
+
"""Return the versioned payload: baseline plus undelivered safety notices.
|
|
502
|
+
|
|
503
|
+
Boundary notices never persist — the next boundary recomputes them
|
|
504
|
+
from the saved baseline. Safety notices are not recomputable (their
|
|
505
|
+
source data is gone once queued: the rollback invalidated the
|
|
506
|
+
baseline, or the requeued boundary's changes were already absorbed),
|
|
507
|
+
so they ride the payload until a model request delivers them.
|
|
508
|
+
"""
|
|
509
|
+
with self._lock:
|
|
510
|
+
baseline = self._baseline
|
|
511
|
+
pending_safety = list(self._pending_safety)
|
|
512
|
+
if baseline is None and not pending_safety:
|
|
513
|
+
return None
|
|
514
|
+
payload = _serialize_baseline(baseline) if baseline is not None else {"version": _SERIALIZATION_VERSION}
|
|
515
|
+
if pending_safety:
|
|
516
|
+
payload["pending_safety"] = [{"text": chunk.text, "cwd": chunk.cwd} for chunk in pending_safety]
|
|
517
|
+
return payload
|
|
518
|
+
|
|
519
|
+
def restore(self, value: object, workspace: Workspace | None = None, *, resolve_scope: bool = True) -> None:
|
|
520
|
+
"""Restore a persisted payload and reconcile it with the freshly resolved scope.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
resolve_scope: Same contract as :meth:`retarget_roots` — ``False``
|
|
524
|
+
skips the Git probes, installs the inactive (empty) scope in
|
|
525
|
+
place of whatever the previous session left behind, and keeps
|
|
526
|
+
the restored baseline exactly as persisted for the next
|
|
527
|
+
enabled retarget to reconcile.
|
|
528
|
+
"""
|
|
529
|
+
baseline = _deserialize_baseline(value)
|
|
530
|
+
pending_safety = [_resolve_safety_notice(chunk) for chunk in _deserialize_pending_safety(value)]
|
|
531
|
+
if not resolve_scope:
|
|
532
|
+
scope: _ResolvedScope | None = _ResolvedScope(roots=())
|
|
533
|
+
elif workspace is not None:
|
|
534
|
+
scope = _resolve_workspace_scope(workspace)
|
|
535
|
+
else:
|
|
536
|
+
scope = None
|
|
537
|
+
new_cwd = os.path.normpath(os.path.abspath(workspace.primary_cwd)) if workspace is not None else None
|
|
538
|
+
retarget = WorkspaceRetarget(
|
|
539
|
+
scope=scope if scope is not None else _ResolvedScope(roots=()),
|
|
540
|
+
resolve_scope=resolve_scope,
|
|
541
|
+
new_cwd=new_cwd,
|
|
542
|
+
canonical_new_cwd=canonical_path(new_cwd) if new_cwd is not None else None,
|
|
543
|
+
)
|
|
544
|
+
with self._lock:
|
|
545
|
+
self._generation += 1
|
|
546
|
+
self._pending_boundary_notice = None
|
|
547
|
+
# Undelivered safety notices are the sole record of their
|
|
548
|
+
# warnings; rehydrate them, pinning the path base exactly as a
|
|
549
|
+
# workspace switch would when the restored cwd differs from
|
|
550
|
+
# queue time.
|
|
551
|
+
self._pending_safety = [_rebase_safety_notice(chunk, retarget) for chunk in pending_safety]
|
|
552
|
+
if scope is not None:
|
|
553
|
+
self._scope = scope
|
|
554
|
+
self._baseline = (
|
|
555
|
+
_reconcile_baseline(baseline, self._scope)
|
|
556
|
+
if resolve_scope and baseline is not None and self._scope.roots
|
|
557
|
+
else baseline
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _install_capture_deadline(self, turn_id: int) -> WorkspaceBaseline:
|
|
561
|
+
with self._lock:
|
|
562
|
+
self._generation += 1
|
|
563
|
+
scope = self._scope
|
|
564
|
+
result = WorkspaceBaseline(
|
|
565
|
+
turn_id=turn_id,
|
|
566
|
+
roots={},
|
|
567
|
+
degraded={
|
|
568
|
+
root.root: DegradedBaseline(DegradedReason.CAPTURE_DEADLINE, root.mode, root.scope)
|
|
569
|
+
for root in scope.roots
|
|
570
|
+
},
|
|
571
|
+
root_limit_omitted=scope.root_limit_omitted,
|
|
572
|
+
)
|
|
573
|
+
self._baseline = result
|
|
574
|
+
return result
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def format_workspace_change_notice(notice: WorkspaceChangeNotice, *, cwd: str) -> str | None:
|
|
578
|
+
"""Render a safe one-shot reminder, returning ``None`` for an empty payload."""
|
|
579
|
+
has_content = bool(
|
|
580
|
+
notice.agent_changes
|
|
581
|
+
or notice.agent_omitted
|
|
582
|
+
or notice.external_changes
|
|
583
|
+
or notice.external_omitted
|
|
584
|
+
or notice.peer_changes
|
|
585
|
+
or notice.peer_omitted
|
|
586
|
+
or notice.head_moves
|
|
587
|
+
or notice.degraded
|
|
588
|
+
or notice.root_limit_omitted
|
|
589
|
+
or notice.agent_detection_truncated
|
|
590
|
+
)
|
|
591
|
+
if not has_content:
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
lines = ["Workspace changes since the previous turn (contents not shown):"]
|
|
595
|
+
if notice.agent_changes or notice.agent_omitted:
|
|
596
|
+
lines.extend(("", "Changed by you or your sub-agents:"))
|
|
597
|
+
for entry in notice.agent_changes:
|
|
598
|
+
suffix = " [content not compared]" if entry.content_not_compared else ""
|
|
599
|
+
if entry.contested:
|
|
600
|
+
suffix += " [another session also wrote this; re-read it]"
|
|
601
|
+
lines.append(f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{suffix}")
|
|
602
|
+
if notice.agent_omitted:
|
|
603
|
+
lines.append(f"- ...and {notice.agent_omitted} more")
|
|
604
|
+
|
|
605
|
+
if notice.agent_detection_truncated:
|
|
606
|
+
lines.extend(("", "Some agent file changes could not be determined completely."))
|
|
607
|
+
|
|
608
|
+
if notice.external_changes or notice.external_omitted:
|
|
609
|
+
lines.extend(("", "Changed outside this session:"))
|
|
610
|
+
for entry in notice.external_changes:
|
|
611
|
+
overlap = " [you also edited this; re-read it]" if entry.agent_overlap else ""
|
|
612
|
+
lines.append(f"- {_external_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{overlap}")
|
|
613
|
+
if notice.external_omitted:
|
|
614
|
+
lines.append(f"- ...and {notice.external_omitted} more")
|
|
615
|
+
|
|
616
|
+
if notice.peer_changes or notice.peer_omitted:
|
|
617
|
+
lines.extend(("", "Changed by another session working in this workspace:"))
|
|
618
|
+
lines.extend(
|
|
619
|
+
f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}" for entry in notice.peer_changes
|
|
620
|
+
)
|
|
621
|
+
if notice.peer_omitted:
|
|
622
|
+
lines.append(f"- ...and {notice.peer_omitted} more")
|
|
623
|
+
|
|
624
|
+
diagnostics: list[str] = []
|
|
625
|
+
for move in notice.head_moves:
|
|
626
|
+
old = move.old_head[:8] if move.old_head else "unborn"
|
|
627
|
+
new = move.new_head[:8] if move.new_head else "unborn"
|
|
628
|
+
suffix = "; scoped files may differ" if move.uncertain else " (scoped files listed above)"
|
|
629
|
+
diagnostics.append(f"git HEAD moved: {old} -> {new}{suffix}")
|
|
630
|
+
for root, degraded in notice.degraded:
|
|
631
|
+
diagnostics.append(
|
|
632
|
+
f"Workspace state unavailable for {_quoted_path(root, cwd)}: {_degraded_label(degraded.reason)}."
|
|
633
|
+
)
|
|
634
|
+
root_limit_line = (
|
|
635
|
+
f"{notice.root_limit_omitted} additional workspace root(s) were not inspected."
|
|
636
|
+
if notice.root_limit_omitted
|
|
637
|
+
else None
|
|
638
|
+
)
|
|
639
|
+
if diagnostics or root_limit_line:
|
|
640
|
+
lines.append("")
|
|
641
|
+
lines.extend(diagnostics[:_DIAGNOSTIC_LINE_LIMIT])
|
|
642
|
+
if len(diagnostics) > _DIAGNOSTIC_LINE_LIMIT:
|
|
643
|
+
lines.append(f"...and {len(diagnostics) - _DIAGNOSTIC_LINE_LIMIT} more workspace diagnostics")
|
|
644
|
+
if root_limit_line:
|
|
645
|
+
# The exact root-limit aggregate never competes with the capped
|
|
646
|
+
# HEAD/degraded diagnostics.
|
|
647
|
+
lines.append(root_limit_line)
|
|
648
|
+
|
|
649
|
+
if notice.external_changes or notice.peer_changes:
|
|
650
|
+
lines.extend(("", "Re-read externally changed files before relying on their contents."))
|
|
651
|
+
return "\n".join(lines)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def format_retained_changes_notice(
|
|
655
|
+
mutation_tracker: MutationTracker | None,
|
|
656
|
+
*,
|
|
657
|
+
cwd: str,
|
|
658
|
+
max_entries: int,
|
|
659
|
+
) -> str | None:
|
|
660
|
+
"""Build the target-zero conversation-only rollback safety notice."""
|
|
661
|
+
if mutation_tracker is None:
|
|
662
|
+
return "Files retained from the discarded conversation:\n- Conversation history was reset without reverting the workspace."
|
|
663
|
+
summary = mutation_tracker.get_session_file_summary()
|
|
664
|
+
entries = [
|
|
665
|
+
AgentChange(
|
|
666
|
+
path=path,
|
|
667
|
+
operation=_operation_from_summary(diff),
|
|
668
|
+
content_not_compared=diff.content_unavailable,
|
|
669
|
+
contested=diff.contested,
|
|
670
|
+
)
|
|
671
|
+
for path, diff in summary.items()
|
|
672
|
+
]
|
|
673
|
+
entries.sort(key=lambda entry: canonical_path(entry.path))
|
|
674
|
+
turns = mutation_tracker.get_all_turns()
|
|
675
|
+
unverified = any(
|
|
676
|
+
mutation.before_skip is not None or mutation.after_skip is not None
|
|
677
|
+
for turn in turns
|
|
678
|
+
for mutation in turn.mutations
|
|
679
|
+
if mutation.provenance is not MutationProvenance.FOREIGN
|
|
680
|
+
)
|
|
681
|
+
# Truncated implicit detection may have missed changes entirely — the
|
|
682
|
+
# safety notice must still warn even when zero rows were recorded.
|
|
683
|
+
truncated = any(turn.detection_truncated for turn in turns)
|
|
684
|
+
if not entries and not unverified and not truncated:
|
|
685
|
+
return None
|
|
686
|
+
lines = ["Files retained from the discarded conversation:"]
|
|
687
|
+
for entry in entries[:max_entries]:
|
|
688
|
+
suffix = " [content not compared]" if entry.content_not_compared else ""
|
|
689
|
+
if entry.contested:
|
|
690
|
+
suffix += " [another session also wrote this; re-read it]"
|
|
691
|
+
lines.append(f"- {_agent_label(entry.operation)}: {_quoted_path(entry.path, cwd)}{suffix}")
|
|
692
|
+
omitted = max(0, len(entries) - max_entries)
|
|
693
|
+
if omitted:
|
|
694
|
+
lines.append(f"- ...and {omitted} more")
|
|
695
|
+
if unverified:
|
|
696
|
+
lines.append("Some retained changes could not be verified because file content was not compared.")
|
|
697
|
+
if truncated:
|
|
698
|
+
lines.append("Some file changes from the discarded conversation could not be determined completely.")
|
|
699
|
+
return "\n".join(lines)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def format_partial_revert_notice(
|
|
703
|
+
retained_paths: Sequence[str],
|
|
704
|
+
*,
|
|
705
|
+
cwd: str,
|
|
706
|
+
max_entries: int,
|
|
707
|
+
detection_incomplete: bool = False,
|
|
708
|
+
) -> str | None:
|
|
709
|
+
"""Warn about rollback candidates that were not actually restored.
|
|
710
|
+
|
|
711
|
+
A reverting rollback can retain files (a partial path selection, a
|
|
712
|
+
peer-modified exclusion, a failed restore); the conversation no
|
|
713
|
+
longer mentions those edits, so without this notice the model has no
|
|
714
|
+
way to learn the files still carry them. ``detection_incomplete``
|
|
715
|
+
means a rolled-back turn had truncated implicit detection: writes
|
|
716
|
+
that were never recorded could not become candidates, so the notice
|
|
717
|
+
must warn even when every known candidate restored.
|
|
718
|
+
"""
|
|
719
|
+
if not retained_paths and not detection_incomplete:
|
|
720
|
+
return None
|
|
721
|
+
ordered = sorted(retained_paths, key=canonical_path)
|
|
722
|
+
lines = ["Files not reverted by the rollback (they keep changes from the rolled-back conversation):"]
|
|
723
|
+
lines.extend(f"- {_quoted_path(path, cwd)}" for path in ordered[:max_entries])
|
|
724
|
+
omitted = max(0, len(ordered) - max_entries)
|
|
725
|
+
if omitted:
|
|
726
|
+
lines.append(f"- ...and {omitted} more")
|
|
727
|
+
if detection_incomplete:
|
|
728
|
+
lines.append(
|
|
729
|
+
"Some file changes from the rolled-back conversation could not be determined completely, "
|
|
730
|
+
"so the rollback may not have reverted all of them."
|
|
731
|
+
)
|
|
732
|
+
return "\n".join(lines)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _resolve_workspace_scope(workspace: Workspace | None) -> _ResolvedScope:
|
|
736
|
+
if workspace is None:
|
|
737
|
+
return _ResolvedScope(roots=())
|
|
738
|
+
raw_inputs = [workspace.primary_cwd, *(working_dir.path for working_dir in workspace.working_dirs)]
|
|
739
|
+
inputs: list[tuple[str, str]] = []
|
|
740
|
+
seen: set[str] = set()
|
|
741
|
+
seen_stat_ids: set[tuple[int, int]] = set()
|
|
742
|
+
for raw in raw_inputs:
|
|
743
|
+
display = os.path.normpath(os.path.abspath(raw))
|
|
744
|
+
physical = canonical_path(display)
|
|
745
|
+
if physical in seen:
|
|
746
|
+
continue
|
|
747
|
+
seen.add(physical)
|
|
748
|
+
# realpath keeps case aliases distinct on case-insensitive volumes
|
|
749
|
+
# (normcase folds only on Windows) — stat identity is the physical
|
|
750
|
+
# dedup key whenever the root exists.
|
|
751
|
+
try:
|
|
752
|
+
probe = os.stat(physical)
|
|
753
|
+
except OSError:
|
|
754
|
+
stat_id = None
|
|
755
|
+
else:
|
|
756
|
+
stat_id = (probe.st_dev, probe.st_ino)
|
|
757
|
+
if stat_id is not None:
|
|
758
|
+
if stat_id in seen_stat_ids:
|
|
759
|
+
continue
|
|
760
|
+
seen_stat_ids.add(stat_id)
|
|
761
|
+
inputs.append((display, physical))
|
|
762
|
+
omitted = max(0, len(inputs) - _MAX_CAPTURE_ROOTS)
|
|
763
|
+
inputs = inputs[:_MAX_CAPTURE_ROOTS]
|
|
764
|
+
deadline = time.monotonic() + _CAPTURE_DEADLINE_SECONDS
|
|
765
|
+
|
|
766
|
+
git_groups: dict[str, list[str]] = {}
|
|
767
|
+
git_root_by_input: dict[str, str] = {}
|
|
768
|
+
non_git: list[str] = []
|
|
769
|
+
unavailable: dict[str, DegradedReason] = {}
|
|
770
|
+
for _display, physical in inputs:
|
|
771
|
+
if not os.path.isdir(physical):
|
|
772
|
+
unavailable[physical] = DegradedReason.CAPTURE_FAILED
|
|
773
|
+
continue
|
|
774
|
+
remaining = min(10.0, deadline - time.monotonic())
|
|
775
|
+
if remaining <= 0:
|
|
776
|
+
unavailable[physical] = DegradedReason.CAPTURE_DEADLINE
|
|
777
|
+
continue
|
|
778
|
+
git_root = resolve_git_root(physical, timeout=remaining)
|
|
779
|
+
if git_root is None:
|
|
780
|
+
if time.monotonic() >= deadline:
|
|
781
|
+
unavailable[physical] = DegradedReason.CAPTURE_DEADLINE
|
|
782
|
+
continue
|
|
783
|
+
non_git.append(physical)
|
|
784
|
+
continue
|
|
785
|
+
rel = PurePath(os.path.relpath(physical, git_root)).as_posix()
|
|
786
|
+
git_groups.setdefault(git_root, []).append("." if rel == "." else rel)
|
|
787
|
+
git_root_by_input[physical] = git_root
|
|
788
|
+
|
|
789
|
+
managed_git_roots = tuple(git_groups)
|
|
790
|
+
scan_roots: list[str] = []
|
|
791
|
+
for root in non_git:
|
|
792
|
+
if any(_contains(existing, root) for existing in scan_roots):
|
|
793
|
+
continue
|
|
794
|
+
scan_roots = [existing for existing in scan_roots if not _contains(root, existing)]
|
|
795
|
+
scan_roots.append(root)
|
|
796
|
+
|
|
797
|
+
roots: list[_CaptureRoot] = []
|
|
798
|
+
emitted: set[str] = set()
|
|
799
|
+
for _display, physical in inputs:
|
|
800
|
+
if physical in emitted:
|
|
801
|
+
continue
|
|
802
|
+
if physical in unavailable:
|
|
803
|
+
roots.append(
|
|
804
|
+
_CaptureRoot(
|
|
805
|
+
physical,
|
|
806
|
+
None,
|
|
807
|
+
(),
|
|
808
|
+
unavailable_reason=unavailable[physical],
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
emitted.add(physical)
|
|
812
|
+
continue
|
|
813
|
+
matching_git = git_root_by_input.get(physical)
|
|
814
|
+
if matching_git is not None:
|
|
815
|
+
if matching_git not in emitted:
|
|
816
|
+
roots.append(
|
|
817
|
+
_CaptureRoot(
|
|
818
|
+
matching_git,
|
|
819
|
+
BaselineMode.GIT,
|
|
820
|
+
canonical_git_pathspecs(git_groups[matching_git]),
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
emitted.add(matching_git)
|
|
824
|
+
continue
|
|
825
|
+
scan_root = next((root for root in scan_roots if _contains(root, physical)), None)
|
|
826
|
+
if scan_root is not None and scan_root not in emitted:
|
|
827
|
+
exclusions = frozenset(
|
|
828
|
+
root for root in managed_git_roots if root != scan_root and _contains(scan_root, root)
|
|
829
|
+
)
|
|
830
|
+
roots.append(
|
|
831
|
+
_CaptureRoot(
|
|
832
|
+
scan_root,
|
|
833
|
+
BaselineMode.SCAN,
|
|
834
|
+
tuple(sorted(exclusions)),
|
|
835
|
+
exclusions,
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
emitted.add(scan_root)
|
|
839
|
+
display_roots = [(physical, display) for display, physical in inputs]
|
|
840
|
+
for git_root in git_groups:
|
|
841
|
+
for display, physical in inputs:
|
|
842
|
+
if git_root_by_input.get(physical) != git_root:
|
|
843
|
+
continue
|
|
844
|
+
# Ownership guarantees a shared ancestor, but the build path must
|
|
845
|
+
# never break on a pathological spelling — skip instead of raising.
|
|
846
|
+
try:
|
|
847
|
+
ascent = os.path.relpath(git_root, physical)
|
|
848
|
+
except ValueError:
|
|
849
|
+
continue
|
|
850
|
+
display_roots.append((git_root, os.path.normpath(os.path.join(display, ascent))))
|
|
851
|
+
break
|
|
852
|
+
return _ResolvedScope(
|
|
853
|
+
roots=tuple(roots),
|
|
854
|
+
root_limit_omitted=omitted,
|
|
855
|
+
display_roots=tuple(display_roots),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _capture_workspace(turn_id: int, scope: _ResolvedScope, *, deadline: float) -> WorkspaceBaseline:
|
|
860
|
+
roots: dict[str, RootBaseline] = {}
|
|
861
|
+
degraded: dict[str, DegradedBaseline] = {}
|
|
862
|
+
for index, capture_root in enumerate(scope.roots):
|
|
863
|
+
if time.monotonic() >= deadline:
|
|
864
|
+
for unfinished in scope.roots[index:]:
|
|
865
|
+
degraded[unfinished.root] = DegradedBaseline(
|
|
866
|
+
DegradedReason.CAPTURE_DEADLINE,
|
|
867
|
+
unfinished.mode,
|
|
868
|
+
unfinished.scope,
|
|
869
|
+
)
|
|
870
|
+
break
|
|
871
|
+
if capture_root.mode is None or not os.path.isdir(capture_root.root):
|
|
872
|
+
degraded[capture_root.root] = DegradedBaseline(
|
|
873
|
+
capture_root.unavailable_reason or DegradedReason.CAPTURE_FAILED,
|
|
874
|
+
capture_root.mode,
|
|
875
|
+
capture_root.scope,
|
|
876
|
+
)
|
|
877
|
+
continue
|
|
878
|
+
try:
|
|
879
|
+
if capture_root.mode is BaselineMode.GIT:
|
|
880
|
+
root_baseline, reason = _capture_git_root(capture_root, deadline)
|
|
881
|
+
else:
|
|
882
|
+
root_baseline, reason = _capture_scan_root(capture_root, deadline)
|
|
883
|
+
except Exception:
|
|
884
|
+
logger.debug("Workspace root capture failed for %s", capture_root.root, exc_info=True)
|
|
885
|
+
root_baseline, reason = None, DegradedReason.CAPTURE_FAILED
|
|
886
|
+
if root_baseline is not None:
|
|
887
|
+
roots[capture_root.root] = root_baseline
|
|
888
|
+
else:
|
|
889
|
+
degraded[capture_root.root] = DegradedBaseline(reason, capture_root.mode, capture_root.scope)
|
|
890
|
+
return WorkspaceBaseline(
|
|
891
|
+
turn_id=turn_id,
|
|
892
|
+
roots=roots,
|
|
893
|
+
degraded=degraded,
|
|
894
|
+
root_limit_omitted=scope.root_limit_omitted,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _capture_git_root(
|
|
899
|
+
capture_root: _CaptureRoot,
|
|
900
|
+
deadline: float,
|
|
901
|
+
) -> tuple[RootBaseline | None, DegradedReason]:
|
|
902
|
+
for _attempt in range(2):
|
|
903
|
+
remaining = min(10.0, deadline - time.monotonic())
|
|
904
|
+
if remaining <= 0:
|
|
905
|
+
return None, DegradedReason.CAPTURE_DEADLINE
|
|
906
|
+
before_head = read_git_head(capture_root.root, timeout=remaining)
|
|
907
|
+
remaining = min(10.0, deadline - time.monotonic())
|
|
908
|
+
if remaining <= 0:
|
|
909
|
+
return None, DegradedReason.CAPTURE_DEADLINE
|
|
910
|
+
status_result = read_git_status(
|
|
911
|
+
capture_root.root,
|
|
912
|
+
capture_root.scope,
|
|
913
|
+
timeout=remaining,
|
|
914
|
+
max_paths=GIT_PATH_LIMIT,
|
|
915
|
+
)
|
|
916
|
+
if status_result.failed:
|
|
917
|
+
return None, DegradedReason.CAPTURE_FAILED
|
|
918
|
+
if status_result.truncated:
|
|
919
|
+
return None, DegradedReason.TOO_MANY_FILES
|
|
920
|
+
files: dict[str, BaselineFileState] = {}
|
|
921
|
+
for rel_path, statuses in status_result.statuses.items():
|
|
922
|
+
# Lexical: the entry may be a tracked symlink whose identity
|
|
923
|
+
# (and lstat) must be the link itself, not its destination.
|
|
924
|
+
# Case-preserving so the notice can render the on-disk spelling.
|
|
925
|
+
path = lexical_display_path(os.path.join(capture_root.root, rel_path))
|
|
926
|
+
# Git already applied the pathspecs; reject only entries whose
|
|
927
|
+
# normalized filesystem identity escapes the repository.
|
|
928
|
+
if not _path_in_git_scope(capture_root.root, path, (".",)):
|
|
929
|
+
continue
|
|
930
|
+
files[path] = _stat_baseline_file(path, statuses)
|
|
931
|
+
remaining = min(10.0, deadline - time.monotonic())
|
|
932
|
+
if remaining <= 0:
|
|
933
|
+
return None, DegradedReason.CAPTURE_DEADLINE
|
|
934
|
+
after_head = read_git_head(capture_root.root, timeout=remaining)
|
|
935
|
+
if before_head.head == after_head.head:
|
|
936
|
+
return (
|
|
937
|
+
RootBaseline(
|
|
938
|
+
root=capture_root.root,
|
|
939
|
+
mode=BaselineMode.GIT,
|
|
940
|
+
head=after_head.head,
|
|
941
|
+
branch=after_head.branch,
|
|
942
|
+
scope=capture_root.scope,
|
|
943
|
+
files=files,
|
|
944
|
+
),
|
|
945
|
+
DegradedReason.CAPTURE_FAILED,
|
|
946
|
+
)
|
|
947
|
+
return None, DegradedReason.CAPTURE_FAILED
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _capture_scan_root(
|
|
951
|
+
capture_root: _CaptureRoot,
|
|
952
|
+
deadline: float,
|
|
953
|
+
) -> tuple[RootBaseline | None, DegradedReason]:
|
|
954
|
+
scanner = WorkspaceScanner(
|
|
955
|
+
capture_root.root,
|
|
956
|
+
excludes=DEFAULT_EXCLUDES,
|
|
957
|
+
max_depth=sys.maxsize,
|
|
958
|
+
max_files=_SCAN_OVERFLOW_SENTINEL,
|
|
959
|
+
exclude_roots=capture_root.exclude_roots,
|
|
960
|
+
deadline=deadline,
|
|
961
|
+
)
|
|
962
|
+
files = scanner.scan()
|
|
963
|
+
if time.monotonic() >= deadline:
|
|
964
|
+
return None, DegradedReason.CAPTURE_DEADLINE
|
|
965
|
+
if len(files) >= _SCAN_OVERFLOW_SENTINEL:
|
|
966
|
+
return None, DegradedReason.TOO_MANY_FILES
|
|
967
|
+
states = {
|
|
968
|
+
lexical_display_path(path): BaselineFileState(True, value.mtime_ns, value.size, value.mode, ())
|
|
969
|
+
for path, value in files.items()
|
|
970
|
+
}
|
|
971
|
+
return (
|
|
972
|
+
RootBaseline(
|
|
973
|
+
root=capture_root.root,
|
|
974
|
+
mode=BaselineMode.SCAN,
|
|
975
|
+
head=None,
|
|
976
|
+
branch=None,
|
|
977
|
+
scope=capture_root.scope,
|
|
978
|
+
files=states,
|
|
979
|
+
),
|
|
980
|
+
DegradedReason.CAPTURE_FAILED,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _stat_baseline_file(path: str, statuses: tuple[str, ...]) -> BaselineFileState:
|
|
985
|
+
try:
|
|
986
|
+
value = os.lstat(path)
|
|
987
|
+
except OSError as exc:
|
|
988
|
+
if exc.errno in (errno.ENOENT, errno.ENOTDIR):
|
|
989
|
+
return BaselineFileState(False, 0, 0, None, statuses)
|
|
990
|
+
raise
|
|
991
|
+
return BaselineFileState(
|
|
992
|
+
True,
|
|
993
|
+
value.st_mtime_ns,
|
|
994
|
+
value.st_size,
|
|
995
|
+
stat.S_IMODE(value.st_mode),
|
|
996
|
+
statuses,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _diff_workspace(
|
|
1001
|
+
old: WorkspaceBaseline,
|
|
1002
|
+
new: WorkspaceBaseline,
|
|
1003
|
+
*,
|
|
1004
|
+
deadline: float,
|
|
1005
|
+
) -> tuple[list[ExternalChange], list[HeadMove], dict[str, DegradedBaseline]]:
|
|
1006
|
+
external: list[ExternalChange] = []
|
|
1007
|
+
head_moves: list[HeadMove] = []
|
|
1008
|
+
degraded = {**old.degraded, **new.degraded}
|
|
1009
|
+
for root, old_root in old.roots.items():
|
|
1010
|
+
new_root = new.roots.get(root)
|
|
1011
|
+
if new_root is None or old_root.mode is not new_root.mode or old_root.scope != new_root.scope:
|
|
1012
|
+
degraded.setdefault(
|
|
1013
|
+
root,
|
|
1014
|
+
DegradedBaseline(DegradedReason.MISSING_BASELINE, new_root.mode if new_root else None, ()),
|
|
1015
|
+
)
|
|
1016
|
+
continue
|
|
1017
|
+
status_paths: set[str] = set()
|
|
1018
|
+
if old_root.mode is BaselineMode.GIT:
|
|
1019
|
+
status_entries = _diff_git_status(root, old_root.files, new_root.files)
|
|
1020
|
+
external.extend(status_entries)
|
|
1021
|
+
status_paths = {entry.path for entry in status_entries}
|
|
1022
|
+
if old_root.head != new_root.head:
|
|
1023
|
+
remaining = min(10.0, deadline - time.monotonic())
|
|
1024
|
+
delta = read_git_head_delta(
|
|
1025
|
+
root,
|
|
1026
|
+
old_root.head,
|
|
1027
|
+
new_root.head,
|
|
1028
|
+
new_root.scope,
|
|
1029
|
+
timeout=remaining,
|
|
1030
|
+
max_paths=GIT_PATH_LIMIT,
|
|
1031
|
+
)
|
|
1032
|
+
if delta.failed or delta.truncated:
|
|
1033
|
+
head_moves.append(HeadMove(root, old_root.head, new_root.head, uncertain=True))
|
|
1034
|
+
elif delta.entries:
|
|
1035
|
+
head_moves.append(HeadMove(root, old_root.head, new_root.head))
|
|
1036
|
+
external.extend(
|
|
1037
|
+
entry for entry in _external_from_delta(root, delta.entries) if entry.path not in status_paths
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
external.extend(_diff_scan(root, old_root.files, new_root.files))
|
|
1041
|
+
return external, head_moves, degraded
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _diff_git_status(
|
|
1045
|
+
root: str,
|
|
1046
|
+
old: Mapping[str, BaselineFileState],
|
|
1047
|
+
new: Mapping[str, BaselineFileState],
|
|
1048
|
+
) -> list[ExternalChange]:
|
|
1049
|
+
result: list[ExternalChange] = []
|
|
1050
|
+
for path in new.keys() - old.keys():
|
|
1051
|
+
state = new[path]
|
|
1052
|
+
status_chars = "".join(state.statuses)
|
|
1053
|
+
staged_delete_untracked = "D " in state.statuses and "??" in state.statuses and state.exists
|
|
1054
|
+
if not state.exists and "D" in status_chars:
|
|
1055
|
+
operation = ExternalOp.DELETED
|
|
1056
|
+
elif staged_delete_untracked:
|
|
1057
|
+
operation = ExternalOp.TRACKING_CHANGED
|
|
1058
|
+
elif (state.exists and ("R" in status_chars or "C" in status_chars or "A" in status_chars)) or (
|
|
1059
|
+
state.statuses and set(state.statuses) == {"??"}
|
|
1060
|
+
):
|
|
1061
|
+
operation = ExternalOp.CREATED
|
|
1062
|
+
else:
|
|
1063
|
+
operation = ExternalOp.MODIFIED
|
|
1064
|
+
result.append(ExternalChange(root, path, operation))
|
|
1065
|
+
for path in old.keys() & new.keys():
|
|
1066
|
+
old_state = old[path]
|
|
1067
|
+
new_state = new[path]
|
|
1068
|
+
if old_state.exists and not new_state.exists:
|
|
1069
|
+
result.append(ExternalChange(root, path, ExternalOp.DELETED))
|
|
1070
|
+
elif (
|
|
1071
|
+
old_state.exists != new_state.exists
|
|
1072
|
+
or old_state.mtime_ns != new_state.mtime_ns
|
|
1073
|
+
or old_state.size != new_state.size
|
|
1074
|
+
or old_state.mode != new_state.mode
|
|
1075
|
+
):
|
|
1076
|
+
result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
|
|
1077
|
+
elif old_state.statuses != new_state.statuses:
|
|
1078
|
+
# An index-only transition (git add/reset/restore --staged)
|
|
1079
|
+
# leaves every filesystem field untouched; the status tuple is
|
|
1080
|
+
# the only observable.
|
|
1081
|
+
result.append(ExternalChange(root, path, ExternalOp.TRACKING_CHANGED))
|
|
1082
|
+
for path in old.keys() - new.keys():
|
|
1083
|
+
old_state = old[path]
|
|
1084
|
+
if os.path.lexists(path) or (not old_state.exists and any("D" in code for code in old_state.statuses)):
|
|
1085
|
+
operation = ExternalOp.RECONCILED
|
|
1086
|
+
else:
|
|
1087
|
+
operation = ExternalOp.DELETED
|
|
1088
|
+
result.append(ExternalChange(root, path, operation))
|
|
1089
|
+
return result
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _diff_scan(
|
|
1093
|
+
root: str,
|
|
1094
|
+
old: Mapping[str, BaselineFileState],
|
|
1095
|
+
new: Mapping[str, BaselineFileState],
|
|
1096
|
+
) -> list[ExternalChange]:
|
|
1097
|
+
result = [ExternalChange(root, path, ExternalOp.CREATED) for path in new.keys() - old.keys()]
|
|
1098
|
+
result.extend(ExternalChange(root, path, ExternalOp.DELETED) for path in old.keys() - new.keys())
|
|
1099
|
+
for path in old.keys() & new.keys():
|
|
1100
|
+
before = old[path]
|
|
1101
|
+
after = new[path]
|
|
1102
|
+
if (before.mtime_ns, before.size, before.mode) != (after.mtime_ns, after.size, after.mode):
|
|
1103
|
+
result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
|
|
1104
|
+
return result
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _external_from_delta(root: str, entries: tuple[GitDeltaEntry, ...]) -> list[ExternalChange]:
|
|
1108
|
+
result: list[ExternalChange] = []
|
|
1109
|
+
for entry in entries:
|
|
1110
|
+
status = entry.status[:1]
|
|
1111
|
+
path = lexical_display_path(os.path.join(root, entry.path))
|
|
1112
|
+
if status == "R" and entry.old_path is not None:
|
|
1113
|
+
result.append(
|
|
1114
|
+
ExternalChange(root, lexical_display_path(os.path.join(root, entry.old_path)), ExternalOp.DELETED)
|
|
1115
|
+
)
|
|
1116
|
+
result.append(ExternalChange(root, path, ExternalOp.CREATED))
|
|
1117
|
+
elif status in {"A", "C"}:
|
|
1118
|
+
result.append(ExternalChange(root, path, ExternalOp.CREATED))
|
|
1119
|
+
elif status == "D":
|
|
1120
|
+
result.append(ExternalChange(root, path, ExternalOp.DELETED))
|
|
1121
|
+
else:
|
|
1122
|
+
result.append(ExternalChange(root, path, ExternalOp.MODIFIED))
|
|
1123
|
+
return result
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _agent_changes(
|
|
1127
|
+
tracker: MutationTracker | None,
|
|
1128
|
+
turn_id: int | None,
|
|
1129
|
+
) -> tuple[list[AgentChange], bool]:
|
|
1130
|
+
if tracker is None or turn_id is None:
|
|
1131
|
+
return [], False
|
|
1132
|
+
summary = tracker.get_turn_file_summary(turn_id)
|
|
1133
|
+
changes = [
|
|
1134
|
+
AgentChange(path, _operation_from_summary(diff), diff.content_unavailable, contested=diff.contested)
|
|
1135
|
+
for path, diff in summary.items()
|
|
1136
|
+
if not diff.is_net_zero or diff.content_unavailable
|
|
1137
|
+
]
|
|
1138
|
+
turn = tracker.get_turn_mutations(turn_id)
|
|
1139
|
+
if turn is None:
|
|
1140
|
+
return changes, False
|
|
1141
|
+
for index, mutation in enumerate(turn.mutations):
|
|
1142
|
+
if mutation.provenance is MutationProvenance.FOREIGN or mutation.old_path is None:
|
|
1143
|
+
continue
|
|
1144
|
+
recreated = any(
|
|
1145
|
+
later.path == mutation.old_path
|
|
1146
|
+
and later.operation is not MutationOp.DELETE
|
|
1147
|
+
and later.provenance is not MutationProvenance.FOREIGN
|
|
1148
|
+
for later in turn.mutations[index + 1 :]
|
|
1149
|
+
)
|
|
1150
|
+
if recreated:
|
|
1151
|
+
continue
|
|
1152
|
+
# An earlier edit of the source leaves a stale summary entry; the
|
|
1153
|
+
# move's final endpoint for that path is the deletion.
|
|
1154
|
+
changes = [entry for entry in changes if entry.path != mutation.old_path]
|
|
1155
|
+
changes.append(AgentChange(mutation.old_path, MutationOp.DELETE, contested=mutation.contested))
|
|
1156
|
+
return changes, turn.detection_truncated
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _detection_truncated(tracker: MutationTracker | None, turn_id: int | None) -> bool:
|
|
1160
|
+
if tracker is None or turn_id is None:
|
|
1161
|
+
return False
|
|
1162
|
+
turn = tracker.get_turn_mutations(turn_id)
|
|
1163
|
+
return turn is not None and turn.detection_truncated
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _peer_witnessed_changes(
|
|
1167
|
+
tracker: MutationTracker | None,
|
|
1168
|
+
turn_id: int | None,
|
|
1169
|
+
) -> list[PeerChange]:
|
|
1170
|
+
"""Collect paths whose peer-session write only the turn log witnessed.
|
|
1171
|
+
|
|
1172
|
+
``FOREIGN`` rows are excluded from every net-change fold, and the
|
|
1173
|
+
end-of-turn baseline absorbs the peer's on-disk effect, so this raw
|
|
1174
|
+
pass over the turn log is the only surface that can report them. A
|
|
1175
|
+
``contested`` local row also records a peer's overlapping write;
|
|
1176
|
+
when its own agent row is not displayed (Retry/Resume omits the
|
|
1177
|
+
section, net-zero folding drops the entry) this harvest is the only
|
|
1178
|
+
remaining witness — the caller's dedup against displayed agent rows
|
|
1179
|
+
keeps the contested badge as the primary channel elsewhere. Later
|
|
1180
|
+
rows win per path; a move contributes a deletion at its source.
|
|
1181
|
+
"""
|
|
1182
|
+
if tracker is None or turn_id is None:
|
|
1183
|
+
return []
|
|
1184
|
+
turn = tracker.get_turn_mutations(turn_id)
|
|
1185
|
+
if turn is None:
|
|
1186
|
+
return []
|
|
1187
|
+
ops: dict[str, MutationOp] = {}
|
|
1188
|
+
for mutation in turn.mutations:
|
|
1189
|
+
if mutation.provenance is not MutationProvenance.FOREIGN and not mutation.contested:
|
|
1190
|
+
continue
|
|
1191
|
+
if mutation.operation is MutationOp.MOVE and mutation.old_path:
|
|
1192
|
+
ops[mutation.old_path] = MutationOp.DELETE
|
|
1193
|
+
ops[mutation.path] = mutation.operation
|
|
1194
|
+
return [PeerChange(path=path, operation=operation) for path, operation in ops.items()]
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _mutation_paths(
|
|
1198
|
+
tracker: MutationTracker | None,
|
|
1199
|
+
first_turn: int | None,
|
|
1200
|
+
last_turn: int | None,
|
|
1201
|
+
) -> set[str]:
|
|
1202
|
+
if tracker is None or first_turn is None or last_turn is None:
|
|
1203
|
+
return set()
|
|
1204
|
+
result: set[str] = set()
|
|
1205
|
+
for turn in tracker.get_all_turns():
|
|
1206
|
+
if turn.turn_id < first_turn or turn.turn_id > last_turn:
|
|
1207
|
+
continue
|
|
1208
|
+
for mutation in turn.mutations:
|
|
1209
|
+
if mutation.provenance is MutationProvenance.FOREIGN:
|
|
1210
|
+
continue
|
|
1211
|
+
result.add(entry_path(mutation.path))
|
|
1212
|
+
if mutation.old_path:
|
|
1213
|
+
result.add(entry_path(mutation.old_path))
|
|
1214
|
+
return result
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
@dataclass(frozen=True, slots=True)
|
|
1218
|
+
class _RecoveredEndpoint:
|
|
1219
|
+
"""Last recorded endpoint for one path in the recovered turn range."""
|
|
1220
|
+
|
|
1221
|
+
after_hash: str | None = None
|
|
1222
|
+
exists: bool = True
|
|
1223
|
+
verifiable: bool = True
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _recovered_endpoints(
|
|
1227
|
+
tracker: MutationTracker | None,
|
|
1228
|
+
first_turn: int | None,
|
|
1229
|
+
last_turn: int | None,
|
|
1230
|
+
) -> dict[str, _RecoveredEndpoint]:
|
|
1231
|
+
if tracker is None or first_turn is None or last_turn is None:
|
|
1232
|
+
return {}
|
|
1233
|
+
result: dict[str, _RecoveredEndpoint] = {}
|
|
1234
|
+
for turn in tracker.get_all_turns():
|
|
1235
|
+
if turn.turn_id < first_turn or turn.turn_id > last_turn:
|
|
1236
|
+
continue
|
|
1237
|
+
for mutation in turn.mutations:
|
|
1238
|
+
if mutation.provenance is MutationProvenance.FOREIGN:
|
|
1239
|
+
continue
|
|
1240
|
+
if mutation.old_path:
|
|
1241
|
+
result[entry_path(mutation.old_path)] = _RecoveredEndpoint(exists=False)
|
|
1242
|
+
if mutation.operation is MutationOp.DELETE:
|
|
1243
|
+
result[entry_path(mutation.path)] = _RecoveredEndpoint(exists=False)
|
|
1244
|
+
elif mutation.after_hash is not None:
|
|
1245
|
+
result[entry_path(mutation.path)] = _RecoveredEndpoint(after_hash=mutation.after_hash)
|
|
1246
|
+
else:
|
|
1247
|
+
# Skip-marked content or a row that never saw its
|
|
1248
|
+
# ``record_after`` (crash mid-command): the recorded
|
|
1249
|
+
# endpoint proves nothing about current disk.
|
|
1250
|
+
result[entry_path(mutation.path)] = _RecoveredEndpoint(verifiable=False)
|
|
1251
|
+
return result
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _matches_recovered_endpoint(expected: _RecoveredEndpoint | None, path: str, max_bytes: int) -> bool:
|
|
1255
|
+
"""Whether current disk provably matches a recovered mutation endpoint."""
|
|
1256
|
+
if expected is None or not expected.verifiable:
|
|
1257
|
+
return False
|
|
1258
|
+
if not expected.exists:
|
|
1259
|
+
return not os.path.lexists(path)
|
|
1260
|
+
data = _read_entry_bytes(path, max_bytes)
|
|
1261
|
+
return data is not None and SnapshotStore.content_hash(data) == expected.after_hash
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _read_entry_bytes(path: str, max_bytes: int) -> bytes | None:
|
|
1265
|
+
"""Entry content for endpoint verification; a symlink is its target text."""
|
|
1266
|
+
try:
|
|
1267
|
+
if os.path.islink(path):
|
|
1268
|
+
return os.fsencode(os.readlink(path))
|
|
1269
|
+
if not os.path.isfile(path):
|
|
1270
|
+
return None
|
|
1271
|
+
if max_bytes > 0 and os.path.getsize(path) > max_bytes:
|
|
1272
|
+
return None
|
|
1273
|
+
with open(path, "rb") as handle:
|
|
1274
|
+
return handle.read()
|
|
1275
|
+
except OSError:
|
|
1276
|
+
return None
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _operation_from_summary(diff: FileHashDiff) -> MutationOp:
|
|
1280
|
+
if SnapshotSkipReason.UNREADABLE in (diff.before_skip, diff.after_skip):
|
|
1281
|
+
return MutationOp.MODIFY
|
|
1282
|
+
if not diff.before_exists and diff.after_exists:
|
|
1283
|
+
return MutationOp.CREATE
|
|
1284
|
+
if diff.before_exists and not diff.after_exists:
|
|
1285
|
+
return MutationOp.DELETE
|
|
1286
|
+
return MutationOp.MODIFY
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _reconcile_baseline(baseline: WorkspaceBaseline, scope: _ResolvedScope) -> WorkspaceBaseline:
|
|
1290
|
+
roots: dict[str, RootBaseline] = {}
|
|
1291
|
+
degraded: dict[str, DegradedBaseline] = {}
|
|
1292
|
+
for capture_root in scope.roots:
|
|
1293
|
+
old_root = baseline.roots.get(capture_root.root)
|
|
1294
|
+
old_degraded = baseline.degraded.get(capture_root.root)
|
|
1295
|
+
if old_root is not None and old_root.mode is capture_root.mode:
|
|
1296
|
+
if old_root.mode is BaselineMode.GIT and _git_scope_narrow_or_equal(old_root.scope, capture_root.scope):
|
|
1297
|
+
roots[capture_root.root] = replace(
|
|
1298
|
+
old_root,
|
|
1299
|
+
scope=capture_root.scope,
|
|
1300
|
+
files={
|
|
1301
|
+
path: state
|
|
1302
|
+
for path, state in old_root.files.items()
|
|
1303
|
+
if _path_in_git_scope(capture_root.root, path, capture_root.scope)
|
|
1304
|
+
},
|
|
1305
|
+
)
|
|
1306
|
+
continue
|
|
1307
|
+
if old_root.mode is BaselineMode.SCAN and old_root.scope == capture_root.scope:
|
|
1308
|
+
roots[capture_root.root] = old_root
|
|
1309
|
+
continue
|
|
1310
|
+
if (
|
|
1311
|
+
old_degraded is not None
|
|
1312
|
+
and old_degraded.mode is not None
|
|
1313
|
+
and old_degraded.mode is capture_root.mode
|
|
1314
|
+
and old_degraded.scope == capture_root.scope
|
|
1315
|
+
):
|
|
1316
|
+
degraded[capture_root.root] = old_degraded
|
|
1317
|
+
else:
|
|
1318
|
+
degraded[capture_root.root] = DegradedBaseline(
|
|
1319
|
+
DegradedReason.MISSING_BASELINE,
|
|
1320
|
+
capture_root.mode,
|
|
1321
|
+
capture_root.scope,
|
|
1322
|
+
)
|
|
1323
|
+
return WorkspaceBaseline(
|
|
1324
|
+
turn_id=baseline.turn_id,
|
|
1325
|
+
roots=roots,
|
|
1326
|
+
degraded=degraded,
|
|
1327
|
+
root_limit_omitted=scope.root_limit_omitted,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _git_scope_narrow_or_equal(old: tuple[str, ...], new: tuple[str, ...]) -> bool:
|
|
1332
|
+
if old == new or old == (".",):
|
|
1333
|
+
return True
|
|
1334
|
+
if new == (".",):
|
|
1335
|
+
return False
|
|
1336
|
+
return all(any(item == parent or item.startswith(f"{parent}/") for parent in old) for item in new)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _path_in_git_scope(root: str, path: str, scope: tuple[str, ...]) -> bool:
|
|
1340
|
+
# Persisted entries can name Windows devices (e.g. \\.\nul) or a
|
|
1341
|
+
# different drive. They cannot belong to this repository, even when
|
|
1342
|
+
# the whole repository is selected. Keep the comparison lexical so
|
|
1343
|
+
# symlinks and deleted entries retain their own identities.
|
|
1344
|
+
try:
|
|
1345
|
+
rel = PurePath(os.path.relpath(path, root)).as_posix()
|
|
1346
|
+
except ValueError:
|
|
1347
|
+
return False
|
|
1348
|
+
if rel == ".." or rel.startswith("../"):
|
|
1349
|
+
return False
|
|
1350
|
+
if scope == (".",):
|
|
1351
|
+
return True
|
|
1352
|
+
return any(rel == item or rel.startswith(f"{item}/") for item in scope)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def _serialize_baseline(baseline: WorkspaceBaseline) -> dict[str, Any]:
|
|
1356
|
+
return {
|
|
1357
|
+
"version": _SERIALIZATION_VERSION,
|
|
1358
|
+
"turn_id": baseline.turn_id,
|
|
1359
|
+
"roots": {
|
|
1360
|
+
key: {
|
|
1361
|
+
"root": value.root,
|
|
1362
|
+
"mode": value.mode.value,
|
|
1363
|
+
"head": value.head,
|
|
1364
|
+
"branch": value.branch,
|
|
1365
|
+
"scope": list(value.scope),
|
|
1366
|
+
"files": {
|
|
1367
|
+
path: {
|
|
1368
|
+
"exists": state.exists,
|
|
1369
|
+
"mtime_ns": state.mtime_ns,
|
|
1370
|
+
"size": state.size,
|
|
1371
|
+
"mode": state.mode,
|
|
1372
|
+
"statuses": list(state.statuses),
|
|
1373
|
+
}
|
|
1374
|
+
for path, state in value.files.items()
|
|
1375
|
+
},
|
|
1376
|
+
}
|
|
1377
|
+
for key, value in baseline.roots.items()
|
|
1378
|
+
},
|
|
1379
|
+
"degraded": {
|
|
1380
|
+
key: {
|
|
1381
|
+
"reason": value.reason.value,
|
|
1382
|
+
"mode": value.mode.value if value.mode is not None else None,
|
|
1383
|
+
"scope": list(value.scope),
|
|
1384
|
+
}
|
|
1385
|
+
for key, value in baseline.degraded.items()
|
|
1386
|
+
},
|
|
1387
|
+
"root_limit_omitted": baseline.root_limit_omitted,
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _deserialize_baseline(value: object) -> WorkspaceBaseline | None:
|
|
1392
|
+
if not isinstance(value, Mapping) or value.get("version") != _SERIALIZATION_VERSION:
|
|
1393
|
+
return None
|
|
1394
|
+
try:
|
|
1395
|
+
payload = cast("Mapping[str, object]", value)
|
|
1396
|
+
raw_roots = payload.get("roots", {})
|
|
1397
|
+
raw_degraded = payload.get("degraded", {})
|
|
1398
|
+
if not isinstance(raw_roots, Mapping) or not isinstance(raw_degraded, Mapping):
|
|
1399
|
+
return None
|
|
1400
|
+
roots: dict[str, RootBaseline] = {}
|
|
1401
|
+
for key, raw_value in raw_roots.items():
|
|
1402
|
+
raw = cast("Mapping[str, object]", raw_value)
|
|
1403
|
+
if not isinstance(key, str) or not isinstance(raw, Mapping):
|
|
1404
|
+
return None
|
|
1405
|
+
raw_files = raw.get("files", {})
|
|
1406
|
+
if not isinstance(raw_files, Mapping):
|
|
1407
|
+
return None
|
|
1408
|
+
files: dict[str, BaselineFileState] = {}
|
|
1409
|
+
for path, state_value in raw_files.items():
|
|
1410
|
+
if not isinstance(path, str) or not isinstance(state_value, Mapping):
|
|
1411
|
+
return None
|
|
1412
|
+
state = cast("Mapping[str, object]", state_value)
|
|
1413
|
+
mode_value = state.get("mode")
|
|
1414
|
+
files[path] = BaselineFileState(
|
|
1415
|
+
exists=_serialized_bool(state["exists"]),
|
|
1416
|
+
mtime_ns=_serialized_int(state["mtime_ns"]),
|
|
1417
|
+
size=_serialized_int(state["size"]),
|
|
1418
|
+
mode=_serialized_int(mode_value) if mode_value is not None else None,
|
|
1419
|
+
statuses=_serialized_strings(state.get("statuses", [])),
|
|
1420
|
+
)
|
|
1421
|
+
roots[key] = RootBaseline(
|
|
1422
|
+
root=_serialized_str(raw["root"]),
|
|
1423
|
+
mode=BaselineMode(_serialized_str(raw["mode"])),
|
|
1424
|
+
head=_serialized_str(raw["head"]) if raw.get("head") is not None else None,
|
|
1425
|
+
branch=_serialized_str(raw["branch"]) if raw.get("branch") is not None else None,
|
|
1426
|
+
scope=_serialized_strings(raw.get("scope", [])),
|
|
1427
|
+
files=files,
|
|
1428
|
+
)
|
|
1429
|
+
degraded: dict[str, DegradedBaseline] = {}
|
|
1430
|
+
for key, raw_value in raw_degraded.items():
|
|
1431
|
+
if not isinstance(key, str) or not isinstance(raw_value, Mapping):
|
|
1432
|
+
return None
|
|
1433
|
+
raw = cast("Mapping[str, object]", raw_value)
|
|
1434
|
+
mode_value = raw.get("mode")
|
|
1435
|
+
degraded[key] = DegradedBaseline(
|
|
1436
|
+
reason=DegradedReason(_serialized_str(raw["reason"])),
|
|
1437
|
+
mode=BaselineMode(_serialized_str(mode_value)) if mode_value is not None else None,
|
|
1438
|
+
scope=_serialized_strings(raw.get("scope", [])),
|
|
1439
|
+
)
|
|
1440
|
+
return WorkspaceBaseline(
|
|
1441
|
+
turn_id=_serialized_int(payload["turn_id"]),
|
|
1442
|
+
roots=roots,
|
|
1443
|
+
degraded=degraded,
|
|
1444
|
+
root_limit_omitted=_serialized_int(payload.get("root_limit_omitted", 0)),
|
|
1445
|
+
)
|
|
1446
|
+
except KeyError, TypeError, ValueError:
|
|
1447
|
+
logger.debug("Dropping malformed workspace baseline", exc_info=True)
|
|
1448
|
+
return None
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _deserialize_pending_safety(value: object) -> list[_SafetyNotice]:
|
|
1452
|
+
"""Rehydrate undelivered safety notices; malformed entries are dropped."""
|
|
1453
|
+
if not isinstance(value, Mapping) or value.get("version") != _SERIALIZATION_VERSION:
|
|
1454
|
+
return []
|
|
1455
|
+
raw = value.get("pending_safety", [])
|
|
1456
|
+
if not isinstance(raw, Sequence) or isinstance(raw, str | bytes):
|
|
1457
|
+
return []
|
|
1458
|
+
result: list[_SafetyNotice] = []
|
|
1459
|
+
for entry in raw:
|
|
1460
|
+
if not isinstance(entry, Mapping):
|
|
1461
|
+
continue
|
|
1462
|
+
text = entry.get("text")
|
|
1463
|
+
if not isinstance(text, str) or not text:
|
|
1464
|
+
continue
|
|
1465
|
+
cwd = entry.get("cwd")
|
|
1466
|
+
result.append(_SafetyNotice(text, cwd if isinstance(cwd, str) else None))
|
|
1467
|
+
return result
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _serialized_bool(value: object) -> bool:
|
|
1471
|
+
if not isinstance(value, bool):
|
|
1472
|
+
raise TypeError
|
|
1473
|
+
return value
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _serialized_int(value: object) -> int:
|
|
1477
|
+
if not isinstance(value, int):
|
|
1478
|
+
raise TypeError
|
|
1479
|
+
return value
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _serialized_str(value: object) -> str:
|
|
1483
|
+
if not isinstance(value, str):
|
|
1484
|
+
raise TypeError
|
|
1485
|
+
return value
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _serialized_strings(value: object) -> tuple[str, ...]:
|
|
1489
|
+
if not isinstance(value, list):
|
|
1490
|
+
raise TypeError
|
|
1491
|
+
result: list[str] = []
|
|
1492
|
+
for item in value:
|
|
1493
|
+
if not isinstance(item, str):
|
|
1494
|
+
raise TypeError
|
|
1495
|
+
result.append(item)
|
|
1496
|
+
return tuple(result)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _quoted_path(path: str, cwd: str) -> str:
|
|
1500
|
+
return json.dumps(display_relpath(path, cwd), ensure_ascii=True)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _external_label(operation: ExternalOp) -> str:
|
|
1504
|
+
return {
|
|
1505
|
+
ExternalOp.CREATED: "created",
|
|
1506
|
+
ExternalOp.MODIFIED: "modified",
|
|
1507
|
+
ExternalOp.DELETED: "deleted",
|
|
1508
|
+
ExternalOp.RECONCILED: "reconciled (committed or reverted)",
|
|
1509
|
+
ExternalOp.TRACKING_CHANGED: "Git tracking changed",
|
|
1510
|
+
}[operation]
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _agent_label(operation: MutationOp) -> str:
|
|
1514
|
+
return {
|
|
1515
|
+
MutationOp.CREATE: "created",
|
|
1516
|
+
MutationOp.MODIFY: "modified",
|
|
1517
|
+
MutationOp.DELETE: "deleted",
|
|
1518
|
+
MutationOp.MOVE: "moved",
|
|
1519
|
+
}[operation]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _degraded_label(reason: DegradedReason) -> str:
|
|
1523
|
+
return {
|
|
1524
|
+
DegradedReason.TOO_MANY_FILES: "too many files",
|
|
1525
|
+
DegradedReason.CAPTURE_FAILED: "capture failed",
|
|
1526
|
+
DegradedReason.CAPTURE_DEADLINE: "capture deadline exceeded",
|
|
1527
|
+
DegradedReason.MISSING_BASELINE: "no comparable previous baseline",
|
|
1528
|
+
}[reason]
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def _contains(parent: str, child: str) -> bool:
|
|
1532
|
+
try:
|
|
1533
|
+
return os.path.commonpath([parent, child]) == parent
|
|
1534
|
+
except ValueError:
|
|
1535
|
+
return False
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _display_path(path: str, scope: _ResolvedScope) -> str:
|
|
1539
|
+
# Canonicalize only the directory (mapping root aliases onto display
|
|
1540
|
+
# roots): the final component may be a symlink whose identity is the
|
|
1541
|
+
# link itself — resolving it would rename the entry to its
|
|
1542
|
+
# destination, possibly outside every display root. The relative tail
|
|
1543
|
+
# is taken from the case-preserving spelling: relpath folds case only
|
|
1544
|
+
# while matching the prefix, so Windows notices keep the on-disk case
|
|
1545
|
+
# the comparison identity discards.
|
|
1546
|
+
physical_path = entry_path(path)
|
|
1547
|
+
for physical, display in sorted(scope.display_roots, key=lambda item: len(item[0]), reverse=True):
|
|
1548
|
+
if _contains(physical, physical_path):
|
|
1549
|
+
return os.path.normpath(os.path.join(display, os.path.relpath(entry_display_path(path), physical)))
|
|
1550
|
+
return path
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _resolve_safety_notice(chunk: _SafetyNotice) -> _SafetyNotice:
|
|
1554
|
+
"""Capture the notice's directory identity, retaining text if resolution fails."""
|
|
1555
|
+
if chunk.cwd is None:
|
|
1556
|
+
return chunk
|
|
1557
|
+
try:
|
|
1558
|
+
return replace(chunk, canonical_cwd=canonical_path(chunk.cwd))
|
|
1559
|
+
except OSError, UnicodeError:
|
|
1560
|
+
return _pin_safety_notice(chunk)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _rebase_safety_notice(chunk: _SafetyNotice, retarget: WorkspaceRetarget) -> _SafetyNotice:
|
|
1564
|
+
"""Pin a pending safety notice's path base when the workspace moves.
|
|
1565
|
+
|
|
1566
|
+
The notice text cannot be re-rendered (its source data is gone), so a
|
|
1567
|
+
switch to a different primary cwd prepends the original base once;
|
|
1568
|
+
after that the chunk is base-independent and never annotated again.
|
|
1569
|
+
"""
|
|
1570
|
+
if chunk.cwd is None or retarget.new_cwd is None or chunk.canonical_cwd == retarget.canonical_new_cwd:
|
|
1571
|
+
return chunk
|
|
1572
|
+
return _pin_safety_notice(chunk)
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _pin_safety_notice(chunk: _SafetyNotice) -> _SafetyNotice:
|
|
1576
|
+
"""Annotate the original directory once while preserving all notice text."""
|
|
1577
|
+
header = f"Note: relative paths below are against the previous workspace directory {json.dumps(chunk.cwd, ensure_ascii=True)}."
|
|
1578
|
+
return _SafetyNotice(f"{header}\n{chunk.text}", cwd=None)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _join_notices(first: str | None, second: str | None) -> str | None:
|
|
1582
|
+
parts = [part for part in (first, second) if part]
|
|
1583
|
+
return "\n\n".join(parts) if parts else None
|