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,1815 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""ACP session ownership for Chrys."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, TypeGuard, TypeVar, cast
|
|
19
|
+
|
|
20
|
+
from acp import schema as acp_schema
|
|
21
|
+
|
|
22
|
+
from chrys.app.features.session_title.updater import SessionTitleUpdater
|
|
23
|
+
from chrys.foundation.branding import APP_DISPLAY_NAME
|
|
24
|
+
from chrys.foundation.config.context import EvalContext
|
|
25
|
+
from chrys.foundation.config.process_settings import reattribute_command_line, route_restart_settings
|
|
26
|
+
from chrys.foundation.config.runtime_pointer import MODEL_POINTER_ENV, set_model_pointer
|
|
27
|
+
from chrys.foundation.config.settings_store import LoadedSettings, load_settings, persist
|
|
28
|
+
from chrys.foundation.config.spec import SettingOrigin, Source
|
|
29
|
+
from chrys.foundation.config.user_settings import flatten_user_doc, user_settings_path
|
|
30
|
+
from chrys.foundation.config.warnings import settings_warning_events
|
|
31
|
+
from chrys.foundation.config.yaml_store import read_yaml_doc
|
|
32
|
+
from chrys.foundation.events.types import (
|
|
33
|
+
AgentLoadFailed,
|
|
34
|
+
AgentProfileSwitch,
|
|
35
|
+
ApprovalModeUpdated,
|
|
36
|
+
Error,
|
|
37
|
+
Event,
|
|
38
|
+
InvocationAbortRequested,
|
|
39
|
+
InvocationRetryRequested,
|
|
40
|
+
ModelProfileSwitched,
|
|
41
|
+
ProfileSwitched,
|
|
42
|
+
RollbackResult,
|
|
43
|
+
SetApprovalMode,
|
|
44
|
+
SetModelProfile,
|
|
45
|
+
SettingsReload,
|
|
46
|
+
SettingsReloaded,
|
|
47
|
+
SleepSkip,
|
|
48
|
+
UserInject,
|
|
49
|
+
UserRollback,
|
|
50
|
+
Warning,
|
|
51
|
+
WorkspaceChange,
|
|
52
|
+
WorkspaceUpdated,
|
|
53
|
+
)
|
|
54
|
+
from chrys.foundation.models.session_surface import SessionSurface
|
|
55
|
+
from chrys.foundation.models.workspace import WorkingDir, Workspace
|
|
56
|
+
from chrys.foundation.platform.files import surrogate_safe_text
|
|
57
|
+
from chrys.foundation.text.yaml_io import dump_yaml
|
|
58
|
+
from chrys.foundation.util.session_ids import SESSION_SHORT_ID_LEN, session_short_id
|
|
59
|
+
from chrys.orchestration.session_host import ChrysSessionHost
|
|
60
|
+
from chrys.service.approval.policy import ApprovalMode
|
|
61
|
+
from chrys.service.mcp.adapter import MCPAdapter
|
|
62
|
+
from chrys.service.profiles.agents.loader import AgentProfileLoadError, is_filename_safe_profile_name
|
|
63
|
+
from chrys.service.profiles.agents.loader import load_profile_from_yaml as load_agent_profile_from_yaml
|
|
64
|
+
from chrys.service.profiles.agents.registry import AgentProfileRegistry
|
|
65
|
+
from chrys.service.profiles.agents.schema import AgentProfile, MCPServerConfig
|
|
66
|
+
from chrys.service.profiles.agents.serializer import delete_profile as delete_agent_profile
|
|
67
|
+
from chrys.service.profiles.agents.serializer import profile_to_dict as agent_profile_to_dict
|
|
68
|
+
from chrys.service.profiles.agents.serializer import save_profile as save_agent_profile
|
|
69
|
+
from chrys.service.profiles.agents.serializer import stored_profile_name as stored_agent_profile_name
|
|
70
|
+
from chrys.service.profiles.models.loader import load_profile_from_yaml as load_model_profile_from_yaml
|
|
71
|
+
from chrys.service.profiles.models.registry import ModelProfileRegistry
|
|
72
|
+
from chrys.service.profiles.models.schema import ModelProfile
|
|
73
|
+
from chrys.service.profiles.models.serializer import delete_profile as delete_model_profile
|
|
74
|
+
from chrys.service.profiles.models.serializer import save_profile as save_model_profile
|
|
75
|
+
from chrys.service.state.store import ChatSessionMeta, JsonFileStateStore, StateStore
|
|
76
|
+
from chrys.service.tools.names import chrys_reserved_tool_names
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
_RuntimeEventT = TypeVar("_RuntimeEventT", bound=Event)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class AcpConfigOption:
|
|
85
|
+
"""One supported config key, under all three of the names it answers to.
|
|
86
|
+
|
|
87
|
+
The wire protocol's logical name predates the settings document and the
|
|
88
|
+
``CHRYS_*`` aliases predate both, so requests may arrive in either
|
|
89
|
+
spelling; the setting key is where the value actually lives.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
logical_key: str
|
|
93
|
+
"""Wire-protocol name, e.g. ``theme``."""
|
|
94
|
+
|
|
95
|
+
setting_key: str
|
|
96
|
+
"""Dotted settings-document key, e.g. ``ui.theme``."""
|
|
97
|
+
|
|
98
|
+
env_alias: str
|
|
99
|
+
"""Legacy ``CHRYS_*`` spelling — accepted on input, echoed as ``envKey``."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_SUPPORTED_CONFIG_OPTIONS: tuple[AcpConfigOption, ...] = (
|
|
103
|
+
AcpConfigOption("default_agent", "agent.default_profile", "CHRYS_DEFAULT_AGENT"),
|
|
104
|
+
AcpConfigOption("model_profile", "model.profile.active", "CHRYS_MODEL_PROFILE"),
|
|
105
|
+
AcpConfigOption("approval_judge_model_profile", "model.role.approval_judge", "CHRYS_MODEL_PROFILE_APPROVAL_JUDGE"),
|
|
106
|
+
AcpConfigOption("default_approval_mode", "approval.default_mode", "CHRYS_DEFAULT_APPROVAL_MODE"),
|
|
107
|
+
AcpConfigOption("theme", "ui.theme", "CHRYS_THEME"),
|
|
108
|
+
AcpConfigOption("rollback_snapshots_keep", "rollback.snapshots_keep", "CHRYS_ROLLBACK_SNAPSHOTS_KEEP"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AcpSessionError(RuntimeError):
|
|
113
|
+
"""Raised when an ACP session request cannot be fulfilled."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ManagedSession:
|
|
118
|
+
"""One active Chrys session exposed over ACP."""
|
|
119
|
+
|
|
120
|
+
session_id: str
|
|
121
|
+
cwd: str
|
|
122
|
+
profile_name: str
|
|
123
|
+
host: ChrysSessionHost
|
|
124
|
+
# Per-session auto-title generator; the bridge forwards its
|
|
125
|
+
# ``SessionTitleUpdated`` events as ACP ``session_info_update``.
|
|
126
|
+
title_updater: SessionTitleUpdater | None = None
|
|
127
|
+
# Serializes runtime-control mutations (model/workspace/profile/settings) so
|
|
128
|
+
# two concurrent requests on one session can't each resolve from the other's
|
|
129
|
+
# completion event — ``_await_runtime_mutation`` correlates only by session id
|
|
130
|
+
# and result type, which is ambiguous under overlap.
|
|
131
|
+
mutation_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
132
|
+
# Prompts serialize per session, not process-wide. Lifecycle operations
|
|
133
|
+
# acquire this lock before shutdown so a host cannot be torn down while a
|
|
134
|
+
# prompt is still draining its terminal events.
|
|
135
|
+
prompt_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
136
|
+
closing: bool = False
|
|
137
|
+
|
|
138
|
+
async def shutdown(self) -> None:
|
|
139
|
+
"""Shut down the host, then drain the title updater.
|
|
140
|
+
|
|
141
|
+
Order matters: the engine's shutdown drains a still-finalizing run
|
|
142
|
+
whose success callback can schedule one last title task; the updater
|
|
143
|
+
must be torn down after so that task is cancelled and awaited —
|
|
144
|
+
even when the host shutdown itself fails.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
await self.host.shutdown()
|
|
148
|
+
finally:
|
|
149
|
+
if self.title_updater is not None:
|
|
150
|
+
await self.title_updater.shutdown()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class ManagedLoadResult:
|
|
155
|
+
"""Result of an ACP session load request."""
|
|
156
|
+
|
|
157
|
+
session: ManagedSession
|
|
158
|
+
reused_existing: bool
|
|
159
|
+
recovered_from_sidecar: bool = False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AcpSessionManager:
|
|
163
|
+
"""Own active Chrys hosts for one ACP process."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
loaded_settings: LoadedSettings,
|
|
169
|
+
profile_name: str,
|
|
170
|
+
approval_mode: ApprovalMode,
|
|
171
|
+
process_cwd: str | None,
|
|
172
|
+
state_store: StateStore | None = None,
|
|
173
|
+
agent_registry: AgentProfileRegistry | None = None,
|
|
174
|
+
model_registry: ModelProfileRegistry | None = None,
|
|
175
|
+
on_successful_turn: Callable[[], None] | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
self._loaded_settings = loaded_settings
|
|
178
|
+
self._profile_name = profile_name
|
|
179
|
+
self._approval_mode = approval_mode
|
|
180
|
+
self._process_cwd = _resolve_dir(process_cwd) if process_cwd is not None else None
|
|
181
|
+
self._state_store = state_store or JsonFileStateStore()
|
|
182
|
+
self._agent_registry = agent_registry or AgentProfileRegistry()
|
|
183
|
+
self._model_registry = model_registry or ModelProfileRegistry()
|
|
184
|
+
self._on_successful_turn = on_successful_turn
|
|
185
|
+
self._sessions: dict[str, ManagedSession] = {}
|
|
186
|
+
# Guards process-wide session-map/lifecycle mutations only. Prompt
|
|
187
|
+
# turns use ManagedSession.prompt_lock so unrelated sessions never
|
|
188
|
+
# share a turn-length critical section.
|
|
189
|
+
self._lock = asyncio.Lock()
|
|
190
|
+
# Guards the whole global-settings transaction — document write, model
|
|
191
|
+
# pointer, published base settings — so the three can never disagree.
|
|
192
|
+
# A thread lock, not an asyncio one: these run under
|
|
193
|
+
# ``asyncio.to_thread`` because they touch the settings file, so two
|
|
194
|
+
# config requests genuinely execute them in parallel. Reentrant because
|
|
195
|
+
# the refresh takes it for its own callers as well as inside a write.
|
|
196
|
+
self._global_settings_lock = threading.RLock()
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def state_store(self) -> StateStore:
|
|
200
|
+
"""State store used for ACP session operations."""
|
|
201
|
+
return self._state_store
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def process_cwd(self) -> str | None:
|
|
205
|
+
"""Default cwd supplied by ``chrys acp --workdir``, if any."""
|
|
206
|
+
return self._process_cwd
|
|
207
|
+
|
|
208
|
+
def load_registries(self) -> None:
|
|
209
|
+
"""Load profile registries once for this ACP process."""
|
|
210
|
+
if not self._agent_registry.list_names():
|
|
211
|
+
self._agent_registry.load_all()
|
|
212
|
+
if not self._model_registry.list_ids():
|
|
213
|
+
self._model_registry.load_all()
|
|
214
|
+
|
|
215
|
+
def list_agent_profiles(self) -> list[dict[str, object]]:
|
|
216
|
+
"""Return available agent profile summaries."""
|
|
217
|
+
self.load_registries()
|
|
218
|
+
return [
|
|
219
|
+
_agent_profile_info(profile, builtin=self._agent_registry.is_builtin(profile.name))
|
|
220
|
+
for profile in self._agent_registry.list_profiles(include_sub_agent_only=True)
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
def read_agent_profile(self, name: str) -> dict[str, object]:
|
|
224
|
+
"""Return one agent profile as JSON-like data with launch secrets masked."""
|
|
225
|
+
self.load_registries()
|
|
226
|
+
profile = self._agent_registry.resolve_selector(name)
|
|
227
|
+
if profile is None:
|
|
228
|
+
msg = f"Agent profile not found: {name}"
|
|
229
|
+
raise AcpSessionError(msg)
|
|
230
|
+
data = jsonable_dataclass(profile)
|
|
231
|
+
if profile.acp is None:
|
|
232
|
+
data.pop("acp", None)
|
|
233
|
+
# Web patches distinguish absence from explicit null. Use their profile
|
|
234
|
+
# representation so the editable ACP payload round-trips that contract.
|
|
235
|
+
serialized_tools = agent_profile_to_dict(profile).get("tools", {})
|
|
236
|
+
for key in ("web_search", "web_fetch"):
|
|
237
|
+
data["tools"].pop(key, None)
|
|
238
|
+
if key in serialized_tools:
|
|
239
|
+
data["tools"][key] = serialized_tools[key]
|
|
240
|
+
return _redact_agent_profile_secrets(data)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _stored_agent_profile_name(name: str) -> str | None:
|
|
244
|
+
"""Read disk ownership without trusting the process's cached registry."""
|
|
245
|
+
try:
|
|
246
|
+
return stored_agent_profile_name(name)
|
|
247
|
+
except AgentProfileLoadError as err:
|
|
248
|
+
msg = f"Cannot verify ownership of agent profile file for {name!r}; refusing to modify it. {err}"
|
|
249
|
+
raise AcpSessionError(msg) from err
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _require_agent_profile_file_owner(name: str, stored_name: str | None) -> None:
|
|
253
|
+
"""Reject mutations through a filename that currently belongs to another profile."""
|
|
254
|
+
if stored_name is not None and stored_name != name:
|
|
255
|
+
msg = f"Agent profile file for {name!r} belongs to {stored_name!r}; refusing to modify it."
|
|
256
|
+
raise AcpSessionError(msg)
|
|
257
|
+
|
|
258
|
+
def write_agent_profile(self, data: dict[str, object]) -> dict[str, object]:
|
|
259
|
+
"""Validate and persist an agent profile.
|
|
260
|
+
|
|
261
|
+
Masked MCP and ACP values (``"***"``) coming back from a prior read
|
|
262
|
+
are restored from the stored profile so a round-trip never destroys
|
|
263
|
+
real secrets.
|
|
264
|
+
"""
|
|
265
|
+
if not data.get("name"):
|
|
266
|
+
msg = "Agent profile name is required."
|
|
267
|
+
raise AcpSessionError(msg)
|
|
268
|
+
self.load_registries()
|
|
269
|
+
profile_data = dict(data)
|
|
270
|
+
profile_data["name"] = _safe_profile_file_stem(profile_data["name"], field_name="agent profile name")
|
|
271
|
+
name = str(profile_data["name"])
|
|
272
|
+
self._require_agent_profile_file_owner(name, self._stored_agent_profile_name(name))
|
|
273
|
+
profile_data.setdefault("id", uuid.uuid4().hex[:12])
|
|
274
|
+
existing = self._agent_registry.get(str(profile_data["name"]))
|
|
275
|
+
ambiguous_identity = False
|
|
276
|
+
if existing is None:
|
|
277
|
+
# ``AgentProfile.id`` is the identity that survives renames: a
|
|
278
|
+
# read → rename → write round-trip keeps the stored id while the
|
|
279
|
+
# name lookup misses, yet the payload's masked values still refer
|
|
280
|
+
# to that stored profile's secrets. Restore from the id match so
|
|
281
|
+
# a rename never persists literal ``"***"`` over real secrets.
|
|
282
|
+
profile_id = profile_data.get("id")
|
|
283
|
+
matches = [
|
|
284
|
+
candidate
|
|
285
|
+
for candidate in self._agent_registry.list_profiles(include_sub_agent_only=True)
|
|
286
|
+
if profile_id and candidate.id == profile_id
|
|
287
|
+
]
|
|
288
|
+
if len(matches) == 1:
|
|
289
|
+
existing = matches[0]
|
|
290
|
+
elif matches:
|
|
291
|
+
# Twin profiles sharing an id (this CRUD's rename keeps the
|
|
292
|
+
# old file, so copies are reachable): a masked read happened
|
|
293
|
+
# but there is no single source of truth — fail closed and
|
|
294
|
+
# make the caller re-enter the secrets instead of guessing.
|
|
295
|
+
ambiguous_identity = True
|
|
296
|
+
acp_restoration = _AcpMaskRestoration(args_were_masked=ambiguous_identity)
|
|
297
|
+
if existing is not None:
|
|
298
|
+
_restore_masked_mcp_secrets(profile_data, existing)
|
|
299
|
+
acp_restoration = _restore_masked_acp_secrets(profile_data, existing)
|
|
300
|
+
_reject_unresolved_masked_mcp_secrets(profile_data)
|
|
301
|
+
_reject_unresolved_masked_acp_secrets(profile_data, restoration=acp_restoration)
|
|
302
|
+
profile = _agent_profile_from_data(profile_data)
|
|
303
|
+
profile.name = _safe_profile_file_stem(profile.name, field_name="agent profile name")
|
|
304
|
+
path = save_agent_profile(profile)
|
|
305
|
+
self._agent_registry.register(profile)
|
|
306
|
+
return {
|
|
307
|
+
"profile": _agent_profile_info(profile, builtin=self._agent_registry.is_builtin(profile.name)),
|
|
308
|
+
"path": str(path),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
def delete_agent_profile(self, name: str) -> dict[str, object]:
|
|
312
|
+
"""Delete a user agent profile from disk and registry."""
|
|
313
|
+
self.load_registries()
|
|
314
|
+
name = _safe_profile_file_stem(name, field_name="agent profile name")
|
|
315
|
+
if self._agent_registry.is_builtin(name):
|
|
316
|
+
msg = f"Built-in agent profiles cannot be deleted; reset {name!r} instead."
|
|
317
|
+
raise AcpSessionError(msg)
|
|
318
|
+
if self._agent_registry.get(name) is None and (
|
|
319
|
+
builtin_name := self._agent_registry.resolve_builtin_filename(name)
|
|
320
|
+
):
|
|
321
|
+
# No registered profile owns this spelling, but its filename may
|
|
322
|
+
# address a built-in's shadow on a case-insensitive filesystem.
|
|
323
|
+
msg = f"Built-in agent profiles cannot be deleted; reset {builtin_name!r} instead."
|
|
324
|
+
raise AcpSessionError(msg)
|
|
325
|
+
stored_name = self._stored_agent_profile_name(name)
|
|
326
|
+
self._require_agent_profile_file_owner(name, stored_name)
|
|
327
|
+
if stored_name is None:
|
|
328
|
+
return {"name": name, "deleted": False}
|
|
329
|
+
if self._agent_registry.get(name) is not None:
|
|
330
|
+
# Removal also saves changed user parents. Check the entire write
|
|
331
|
+
# set before deleting the child or changing any registry entries.
|
|
332
|
+
for parent in self._agent_registry.profiles_referencing(name):
|
|
333
|
+
if not self._agent_registry.is_builtin(parent.name):
|
|
334
|
+
parent_owner = self._stored_agent_profile_name(parent.name)
|
|
335
|
+
if parent_owner is None:
|
|
336
|
+
# Do not recreate stale parents: multiple absent paths
|
|
337
|
+
# can alias each other once cascade starts saving them.
|
|
338
|
+
msg = (
|
|
339
|
+
f"Parent agent profile file for {parent.name!r} is missing; "
|
|
340
|
+
"refusing to recreate it during deletion."
|
|
341
|
+
)
|
|
342
|
+
raise AcpSessionError(msg)
|
|
343
|
+
self._require_agent_profile_file_owner(parent.name, parent_owner)
|
|
344
|
+
deleted = delete_agent_profile(name)
|
|
345
|
+
if deleted:
|
|
346
|
+
self._agent_registry.remove(name)
|
|
347
|
+
return {"name": name, "deleted": deleted}
|
|
348
|
+
|
|
349
|
+
def reset_agent_profile(self, name: str) -> dict[str, object]:
|
|
350
|
+
"""Reset a built-in agent while retaining Skills, MCP, and Memory settings."""
|
|
351
|
+
self.load_registries()
|
|
352
|
+
name = _safe_profile_file_stem(name, field_name="agent profile name")
|
|
353
|
+
current = self._agent_registry.get(name)
|
|
354
|
+
template = self._agent_registry.get_builtin_template(name)
|
|
355
|
+
if current is None or template is None:
|
|
356
|
+
msg = f"Built-in agent profile not found: {name}"
|
|
357
|
+
raise AcpSessionError(msg)
|
|
358
|
+
stored_name = self._stored_agent_profile_name(name)
|
|
359
|
+
reset = self._agent_registry.build_builtin_reset(name, preserve_from=current)
|
|
360
|
+
if _agent_profile_reset_comparison_dict(reset) == _agent_profile_reset_comparison_dict(template):
|
|
361
|
+
changed = reset != current
|
|
362
|
+
# Only the actual owner may remove the file. A known twin is
|
|
363
|
+
# preserved; unreadable ownership already raised before this point.
|
|
364
|
+
if stored_name == name:
|
|
365
|
+
changed = delete_agent_profile(name) or changed
|
|
366
|
+
else:
|
|
367
|
+
changed = reset != current
|
|
368
|
+
if changed:
|
|
369
|
+
self._require_agent_profile_file_owner(name, stored_name)
|
|
370
|
+
save_agent_profile(reset)
|
|
371
|
+
if changed:
|
|
372
|
+
self._agent_registry.register(reset)
|
|
373
|
+
return {
|
|
374
|
+
"profile": _agent_profile_info(reset, builtin=True),
|
|
375
|
+
"changed": changed,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
def list_model_profiles(self) -> list[dict[str, object]]:
|
|
379
|
+
"""Return available model profile summaries."""
|
|
380
|
+
self.load_registries()
|
|
381
|
+
return [_model_profile_info(profile) for profile in self._model_registry.list_profiles()]
|
|
382
|
+
|
|
383
|
+
def read_model_profile(self, profile_id: str) -> dict[str, object]:
|
|
384
|
+
"""Return one model profile as JSON-like data without secrets."""
|
|
385
|
+
self.load_registries()
|
|
386
|
+
profile = self._model_registry.get(profile_id)
|
|
387
|
+
if profile is None:
|
|
388
|
+
msg = f"Model profile not found: {profile_id}"
|
|
389
|
+
raise AcpSessionError(msg)
|
|
390
|
+
data = jsonable_dataclass(profile)
|
|
391
|
+
data["api_key"] = "***" if data.get("api_key") else ""
|
|
392
|
+
# NOTE: only api_key is masked. ``http_headers`` and ``chat_options`` are
|
|
393
|
+
# JSON strings that can also carry auth (e.g. an Authorization header or a
|
|
394
|
+
# provider token) and are returned verbatim to the ACP client. Accepted
|
|
395
|
+
# for now — ACP is a same-user local process — but if this surface is ever
|
|
396
|
+
# exposed to a less-trusted client, mask/redact these too (and add the
|
|
397
|
+
# unresolved-mask write rejection used for MCP secrets).
|
|
398
|
+
return data
|
|
399
|
+
|
|
400
|
+
def write_model_profile(self, data: dict[str, object]) -> dict[str, object]:
|
|
401
|
+
"""Validate and persist a model profile."""
|
|
402
|
+
if not data.get("name"):
|
|
403
|
+
msg = "Model profile name is required."
|
|
404
|
+
raise AcpSessionError(msg)
|
|
405
|
+
self.load_registries()
|
|
406
|
+
profile_data = dict(data)
|
|
407
|
+
profile_id = str(profile_data.get("id") or "")
|
|
408
|
+
existing = self._model_registry.get(profile_id) if profile_id else None
|
|
409
|
+
if existing is not None:
|
|
410
|
+
merged = jsonable_dataclass(existing)
|
|
411
|
+
# Skip only absent (None) fields; an explicit "" clears the field (e.g.
|
|
412
|
+
# base_url, http_headers, chat_options) so stale endpoints/auth don't linger.
|
|
413
|
+
# api_key is the sole exception: ""/"***"/None all preserve the stored secret.
|
|
414
|
+
merged.update({key: value for key, value in profile_data.items() if value is not None})
|
|
415
|
+
if profile_data.get("api_key") in {None, "", "***"}:
|
|
416
|
+
merged["api_key"] = existing.api_key
|
|
417
|
+
profile_data = merged
|
|
418
|
+
else:
|
|
419
|
+
# No profile to restore the secret from (e.g. read-copy-write with a new
|
|
420
|
+
# id), so a still-masked api_key would persist literally. Reject it, like
|
|
421
|
+
# the MCP secret handling in _reject_unresolved_masked_mcp_secrets.
|
|
422
|
+
if profile_data.get("api_key") == _MASK:
|
|
423
|
+
msg = (
|
|
424
|
+
"Model profile api_key is still masked; re-enter the secret after renaming or copying the profile."
|
|
425
|
+
)
|
|
426
|
+
raise AcpSessionError(msg)
|
|
427
|
+
profile_data.setdefault("id", uuid.uuid4().hex[:12])
|
|
428
|
+
profile_data["id"] = _safe_profile_file_stem(profile_data["id"], field_name="model profile id")
|
|
429
|
+
profile = _model_profile_from_data(profile_data)
|
|
430
|
+
profile.id = _safe_profile_file_stem(profile.id, field_name="model profile id")
|
|
431
|
+
path = save_model_profile(profile)
|
|
432
|
+
self._model_registry.register(profile)
|
|
433
|
+
return {"profile": _model_profile_info(profile), "path": str(path)}
|
|
434
|
+
|
|
435
|
+
def delete_model_profile(self, profile_id: str) -> dict[str, object]:
|
|
436
|
+
"""Delete a model profile from disk and registry."""
|
|
437
|
+
self.load_registries()
|
|
438
|
+
profile_id = _safe_profile_file_stem(profile_id, field_name="model profile id")
|
|
439
|
+
deleted = delete_model_profile(profile_id)
|
|
440
|
+
if deleted:
|
|
441
|
+
self._model_registry.remove(profile_id)
|
|
442
|
+
return {"id": profile_id, "deleted": deleted}
|
|
443
|
+
|
|
444
|
+
def set_config_option(self, key: str, value: object) -> dict[str, object]:
|
|
445
|
+
"""Persist a supported Chrys config option to the user settings document.
|
|
446
|
+
|
|
447
|
+
Validation is the settings store's own: the same coercer that will
|
|
448
|
+
read the value back judges it on the way in, and a rejected value
|
|
449
|
+
never reaches the file. ``bypass`` never persists —
|
|
450
|
+
:func:`chrys.foundation.config.settings_store.persist` downgrades it
|
|
451
|
+
to ``auto`` so the next launch cannot start in unattended
|
|
452
|
+
auto-approval mode.
|
|
453
|
+
|
|
454
|
+
No ``os.environ`` mirror: a mirrored value would come back as the
|
|
455
|
+
``ENV`` layer and outrank the very document this just wrote. The one
|
|
456
|
+
exception is the model pointer, whose environment entry is this
|
|
457
|
+
process's live selection channel, not a configuration source.
|
|
458
|
+
|
|
459
|
+
Document, pointer and published settings move together under one lock.
|
|
460
|
+
Concurrent requests reach this from separate worker threads, and the
|
|
461
|
+
pointer outranks the document it was written from — so two clients
|
|
462
|
+
setting a model interleaved can otherwise commit one profile to disk
|
|
463
|
+
and install the other as the live pointer, leaving a process whose
|
|
464
|
+
model changes when it restarts.
|
|
465
|
+
"""
|
|
466
|
+
option = _config_option(key)
|
|
467
|
+
text_value = "" if value is None else str(value)
|
|
468
|
+
with self._global_settings_lock:
|
|
469
|
+
if text_value:
|
|
470
|
+
result = persist({option.setting_key: text_value})
|
|
471
|
+
if not result.ok:
|
|
472
|
+
reason = result.rejected[option.setting_key].reason
|
|
473
|
+
detail = reason.name.lower() if reason is not None else "rejected"
|
|
474
|
+
msg = f"Invalid value for config option {key!r}: {text_value!r} ({detail})."
|
|
475
|
+
raise AcpSessionError(msg)
|
|
476
|
+
stored = result.written[option.setting_key]
|
|
477
|
+
else:
|
|
478
|
+
persist({}, remove=(option.setting_key,))
|
|
479
|
+
stored = ""
|
|
480
|
+
if option.env_alias == MODEL_POINTER_ENV:
|
|
481
|
+
pointer = str(stored) if stored else None
|
|
482
|
+
set_model_pointer(pointer, origin=SettingOrigin(layer=Source.PROCESS_RUNTIME))
|
|
483
|
+
self._refresh_default_settings_from_env()
|
|
484
|
+
return {
|
|
485
|
+
"key": key,
|
|
486
|
+
"envKey": option.env_alias,
|
|
487
|
+
"settingKey": option.setting_key,
|
|
488
|
+
"value": _config_display_value(stored),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
def get_config_options(self, session_id: str | None = None) -> dict[str, object]:
|
|
492
|
+
"""Return supported config options: durable values plus provenance.
|
|
493
|
+
|
|
494
|
+
``value`` is what the user document stores. The effective pair is
|
|
495
|
+
named ``baseValue``/``baseSource`` because without a ``session_id``
|
|
496
|
+
the manager — which holds many sessions — can only answer for its
|
|
497
|
+
base settings, not claim any one session's view. Passing a
|
|
498
|
+
``session_id`` adds ``sessionValue``/``sessionSource`` read from that
|
|
499
|
+
session's own loaded settings.
|
|
500
|
+
|
|
501
|
+
The whole snapshot is taken inside the same transaction lock the
|
|
502
|
+
writers hold. Without it this read falls into the gap a write leaves
|
|
503
|
+
between committing the document and republishing the base settings —
|
|
504
|
+
the document's file lock is released at the first of those, not the
|
|
505
|
+
second — and the reply pairs the newly stored ``value`` with the
|
|
506
|
+
superseded ``baseValue``/``baseSource``, which is exactly the
|
|
507
|
+
contradiction the three fields exist to rule out. Taking the lock
|
|
508
|
+
before the document's own is the order every writer uses.
|
|
509
|
+
"""
|
|
510
|
+
with self._global_settings_lock:
|
|
511
|
+
session_loaded = self.get(session_id).host.engine.loaded_settings if session_id else None
|
|
512
|
+
doc = read_yaml_doc(user_settings_path()) or {}
|
|
513
|
+
keys = frozenset(option.setting_key for option in _SUPPORTED_CONFIG_OPTIONS)
|
|
514
|
+
values, _ = flatten_user_doc(doc, keys)
|
|
515
|
+
base = self._loaded_settings
|
|
516
|
+
base_values = asdict(base.settings)
|
|
517
|
+
session_values = asdict(session_loaded.settings) if session_loaded is not None else {}
|
|
518
|
+
fields = _config_setting_fields()
|
|
519
|
+
|
|
520
|
+
options: list[dict[str, object]] = []
|
|
521
|
+
for option in _SUPPORTED_CONFIG_OPTIONS:
|
|
522
|
+
entry: dict[str, object] = {
|
|
523
|
+
"key": option.logical_key,
|
|
524
|
+
"envKey": option.env_alias,
|
|
525
|
+
"settingKey": option.setting_key,
|
|
526
|
+
"value": _config_display_value(values.get(option.setting_key, "")),
|
|
527
|
+
"baseValue": _config_display_value(base_values[fields[option.setting_key]]),
|
|
528
|
+
"baseSource": base.source_for(option.setting_key).layer.name.lower(),
|
|
529
|
+
}
|
|
530
|
+
if session_loaded is not None:
|
|
531
|
+
entry["sessionValue"] = _config_display_value(session_values[fields[option.setting_key]])
|
|
532
|
+
entry["sessionSource"] = session_loaded.source_for(option.setting_key).layer.name.lower()
|
|
533
|
+
options.append(entry)
|
|
534
|
+
payload: dict[str, object] = {"options": options}
|
|
535
|
+
if session_id:
|
|
536
|
+
payload["sessionId"] = session_id
|
|
537
|
+
return payload
|
|
538
|
+
|
|
539
|
+
async def test_mcp_server(self, data: dict[str, object]) -> dict[str, object]:
|
|
540
|
+
"""Run a one-shot MCP connection test for a config payload."""
|
|
541
|
+
config = _mcp_config_from_data(data)
|
|
542
|
+
adapter = MCPAdapter(reserved_tool_names=chrys_reserved_tool_names())
|
|
543
|
+
try:
|
|
544
|
+
await adapter.test_connection(config)
|
|
545
|
+
except Exception as exc:
|
|
546
|
+
return {
|
|
547
|
+
"ok": False,
|
|
548
|
+
"name": config.name,
|
|
549
|
+
"message": str(exc),
|
|
550
|
+
"bannerLines": list(getattr(exc, "banner_lines", []) or []),
|
|
551
|
+
}
|
|
552
|
+
return {"ok": True, "name": config.name, "message": "Connected."}
|
|
553
|
+
|
|
554
|
+
def _build_host(
|
|
555
|
+
self,
|
|
556
|
+
*,
|
|
557
|
+
profile_name: str,
|
|
558
|
+
session_id: str | None = None,
|
|
559
|
+
cwd: str,
|
|
560
|
+
loaded_settings: LoadedSettings,
|
|
561
|
+
workspace: Workspace | None,
|
|
562
|
+
mcp_overlay: list[MCPServerConfig],
|
|
563
|
+
) -> tuple[ChrysSessionHost, SessionTitleUpdater]:
|
|
564
|
+
"""Construct a session host plus its per-session auto-title updater.
|
|
565
|
+
|
|
566
|
+
The updater's turn callbacks must be wired at engine construction,
|
|
567
|
+
but the updater itself needs the constructed host's bus and engine —
|
|
568
|
+
hence the late-bound slot.
|
|
569
|
+
"""
|
|
570
|
+
updater_slot: list[SessionTitleUpdater] = []
|
|
571
|
+
process_on_successful_turn = self._on_successful_turn
|
|
572
|
+
|
|
573
|
+
def _on_successful_turn() -> None:
|
|
574
|
+
if process_on_successful_turn is not None:
|
|
575
|
+
process_on_successful_turn()
|
|
576
|
+
if updater_slot:
|
|
577
|
+
updater_slot[0].on_turn_finished()
|
|
578
|
+
|
|
579
|
+
def _on_turn_started() -> None:
|
|
580
|
+
if updater_slot:
|
|
581
|
+
updater_slot[0].on_turn_started()
|
|
582
|
+
|
|
583
|
+
host = ChrysSessionHost(
|
|
584
|
+
profile_name=profile_name,
|
|
585
|
+
session_id=session_id,
|
|
586
|
+
loaded_settings=loaded_settings,
|
|
587
|
+
agent_registry=self._agent_registry,
|
|
588
|
+
model_registry=self._model_registry,
|
|
589
|
+
state_store=self._state_store,
|
|
590
|
+
approval_mode=self._approval_mode,
|
|
591
|
+
cwd=cwd,
|
|
592
|
+
workspace=workspace,
|
|
593
|
+
mcp_overlay=mcp_overlay,
|
|
594
|
+
allow_user_interaction=True,
|
|
595
|
+
on_successful_turn=_on_successful_turn,
|
|
596
|
+
on_turn_started=_on_turn_started,
|
|
597
|
+
surface=SessionSurface.ACP,
|
|
598
|
+
)
|
|
599
|
+
updater = SessionTitleUpdater(host.event_bus, self._state_store, engine_getter=lambda: host.engine)
|
|
600
|
+
updater_slot.append(updater)
|
|
601
|
+
return host, updater
|
|
602
|
+
|
|
603
|
+
async def new_session(
|
|
604
|
+
self,
|
|
605
|
+
*,
|
|
606
|
+
cwd: str | None,
|
|
607
|
+
additional_directories: list[str] | None = None,
|
|
608
|
+
mcp_servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
|
|
609
|
+
warnings: list[Warning] | None = None,
|
|
610
|
+
) -> ManagedSession:
|
|
611
|
+
"""Create and start a new Chrys session.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
warnings: Collects what this session's settings load had to
|
|
615
|
+
reject — a denied or dormant project key, for one. Session
|
|
616
|
+
creation runs outside any prompt turn, so nothing forwards bus
|
|
617
|
+
events; a caller that does not collect these has no other way
|
|
618
|
+
to learn the cwd it named carries settings that did not apply.
|
|
619
|
+
"""
|
|
620
|
+
async with self._lock:
|
|
621
|
+
self.load_registries()
|
|
622
|
+
profile = self._agent_registry.resolve_selector(self._profile_name)
|
|
623
|
+
if profile is None:
|
|
624
|
+
msg = f"Agent profile not found: {self._profile_name}"
|
|
625
|
+
raise AcpSessionError(msg)
|
|
626
|
+
mcp_overlay = _mcp_overlay(mcp_servers)
|
|
627
|
+
resolved_cwd = self._resolve_requested_cwd(cwd)
|
|
628
|
+
# Off-thread: the load reads settings files. Derived per session
|
|
629
|
+
# because each session's cwd names its own project trust domain.
|
|
630
|
+
session_loaded = await asyncio.to_thread(self._session_loaded_settings, resolved_cwd)
|
|
631
|
+
if warnings is not None:
|
|
632
|
+
warnings.extend(settings_warning_events(session_loaded))
|
|
633
|
+
host, title_updater = self._build_host(
|
|
634
|
+
profile_name=profile.name,
|
|
635
|
+
cwd=resolved_cwd,
|
|
636
|
+
loaded_settings=session_loaded,
|
|
637
|
+
workspace=_workspace_from_dirs(resolved_cwd, additional_directories)
|
|
638
|
+
if additional_directories
|
|
639
|
+
else None,
|
|
640
|
+
mcp_overlay=mcp_overlay,
|
|
641
|
+
)
|
|
642
|
+
# ACP owns the ask_user lifetime, so its injected timeout survives a
|
|
643
|
+
# settings reload instead of reverting to the env default.
|
|
644
|
+
host.engine.pin_ask_user_timeout()
|
|
645
|
+
try:
|
|
646
|
+
await host.start()
|
|
647
|
+
except BaseException:
|
|
648
|
+
await _shutdown_after_failed_start(host, title_updater)
|
|
649
|
+
raise
|
|
650
|
+
session_id = host.session_id
|
|
651
|
+
if session_id is None:
|
|
652
|
+
msg = f"{APP_DISPLAY_NAME} did not create a session id."
|
|
653
|
+
await _shutdown_after_failed_start(host, title_updater)
|
|
654
|
+
raise AcpSessionError(msg)
|
|
655
|
+
managed = ManagedSession(
|
|
656
|
+
session_id=session_id,
|
|
657
|
+
cwd=resolved_cwd,
|
|
658
|
+
profile_name=profile.name,
|
|
659
|
+
host=host,
|
|
660
|
+
title_updater=title_updater,
|
|
661
|
+
)
|
|
662
|
+
self._sessions[session_id] = managed
|
|
663
|
+
return managed
|
|
664
|
+
|
|
665
|
+
async def load_session(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
cwd: str | None,
|
|
669
|
+
session_id: str,
|
|
670
|
+
additional_directories: list[str] | None = None,
|
|
671
|
+
mcp_servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
|
|
672
|
+
warnings: list[Warning] | None = None,
|
|
673
|
+
) -> ManagedLoadResult:
|
|
674
|
+
"""Restore an existing Chrys session.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
warnings: Same contract as :meth:`new_session`; empty when an
|
|
678
|
+
already-active session is reused, because nothing was loaded.
|
|
679
|
+
"""
|
|
680
|
+
async with self._lock:
|
|
681
|
+
self.load_registries()
|
|
682
|
+
resolved_cwd = self._resolve_requested_cwd(cwd)
|
|
683
|
+
mcp_overlay = _mcp_overlay(mcp_servers)
|
|
684
|
+
existing = self._resolve_active_session(session_id)
|
|
685
|
+
if existing is not None:
|
|
686
|
+
self._validate_active_cwd(existing, resolved_cwd)
|
|
687
|
+
# Overlays (MCP servers / extra directories) cannot be applied to a
|
|
688
|
+
# running host, so reject rather than silently dropping them.
|
|
689
|
+
if mcp_overlay:
|
|
690
|
+
msg = (
|
|
691
|
+
f"Session '{session_short_id(existing.session_id)}' is already active. "
|
|
692
|
+
"Close it before reloading with MCP server overlays."
|
|
693
|
+
)
|
|
694
|
+
raise AcpSessionError(msg)
|
|
695
|
+
if additional_directories is not None:
|
|
696
|
+
# Changing roots (including clearing them) means rebuilding the
|
|
697
|
+
# workspace, which a running host can't do — reject rather than
|
|
698
|
+
# silently keeping the old scope.
|
|
699
|
+
msg = (
|
|
700
|
+
f"Session '{session_short_id(existing.session_id)}' is already active. "
|
|
701
|
+
"Close it before reloading with additional directories."
|
|
702
|
+
)
|
|
703
|
+
raise AcpSessionError(msg)
|
|
704
|
+
return ManagedLoadResult(
|
|
705
|
+
session=existing,
|
|
706
|
+
reused_existing=True,
|
|
707
|
+
recovered_from_sidecar=existing.host.engine.recovered_from_sidecar,
|
|
708
|
+
)
|
|
709
|
+
canonical_id, meta = await self._resolve_session_meta(session_id)
|
|
710
|
+
self._validate_saved_cwd(meta, resolved_cwd)
|
|
711
|
+
# Off-thread: the load reads settings files. The restore itself
|
|
712
|
+
# re-derives against the saved cwd, but the host must not exist —
|
|
713
|
+
# even pre-restore — under another project's trust domain.
|
|
714
|
+
session_loaded = await asyncio.to_thread(self._session_loaded_settings, resolved_cwd)
|
|
715
|
+
host, title_updater = self._build_host(
|
|
716
|
+
profile_name="",
|
|
717
|
+
session_id=canonical_id,
|
|
718
|
+
cwd=resolved_cwd,
|
|
719
|
+
loaded_settings=session_loaded,
|
|
720
|
+
# ``None`` keeps the saved roots; a list (even empty) is authoritative,
|
|
721
|
+
# so pass a workspace whenever the client specified the field at all.
|
|
722
|
+
workspace=_workspace_from_dirs(resolved_cwd, additional_directories)
|
|
723
|
+
if additional_directories is not None
|
|
724
|
+
else None,
|
|
725
|
+
mcp_overlay=mcp_overlay,
|
|
726
|
+
)
|
|
727
|
+
# ACP owns the ask_user lifetime, so its injected timeout survives a
|
|
728
|
+
# settings reload instead of reverting to the env default.
|
|
729
|
+
host.engine.pin_ask_user_timeout()
|
|
730
|
+
try:
|
|
731
|
+
await host.start()
|
|
732
|
+
except BaseException:
|
|
733
|
+
await _shutdown_after_failed_start(host, title_updater)
|
|
734
|
+
raise
|
|
735
|
+
if warnings is not None:
|
|
736
|
+
# Composed from the restore's committed load, not the
|
|
737
|
+
# pre-restore snapshot above: the restore re-reads the same
|
|
738
|
+
# root, and an edit landing between the two reads would
|
|
739
|
+
# otherwise be reported stale — or a rejection only the
|
|
740
|
+
# committed load hit would go unheard, since its bus warnings
|
|
741
|
+
# have no subscriber here.
|
|
742
|
+
warnings.extend(settings_warning_events(host.engine.loaded_settings))
|
|
743
|
+
managed = ManagedSession(
|
|
744
|
+
session_id=canonical_id,
|
|
745
|
+
cwd=resolved_cwd,
|
|
746
|
+
profile_name=host.engine.current_profile_snapshot().to_profile or meta.agent_profile,
|
|
747
|
+
host=host,
|
|
748
|
+
title_updater=title_updater,
|
|
749
|
+
)
|
|
750
|
+
self._sessions[canonical_id] = managed
|
|
751
|
+
return ManagedLoadResult(
|
|
752
|
+
session=managed,
|
|
753
|
+
reused_existing=False,
|
|
754
|
+
recovered_from_sidecar=host.engine.recovered_from_sidecar,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
async def list_sessions(
|
|
758
|
+
self,
|
|
759
|
+
*,
|
|
760
|
+
cwd: str | None,
|
|
761
|
+
cursor: str | None,
|
|
762
|
+
additional_directories: list[str] | None = None,
|
|
763
|
+
) -> tuple[list[acp_schema.SessionInfo], str | None]:
|
|
764
|
+
"""List saved sessions scoped to this ACP workspace.
|
|
765
|
+
|
|
766
|
+
``additional_directories`` is an exact, ordered additional-root filter (ACP
|
|
767
|
+
workspace identity). When supplied with at least one root, a session matches
|
|
768
|
+
only if its saved extra roots equal the request exactly; when omitted or
|
|
769
|
+
empty, all sessions for ``cwd`` are returned (their roots are reported back
|
|
770
|
+
via ``additionalDirectories``).
|
|
771
|
+
"""
|
|
772
|
+
if cwd is None and self._process_cwd is None:
|
|
773
|
+
return [], None
|
|
774
|
+
requested_cwd = self._resolve_requested_cwd(cwd)
|
|
775
|
+
matches = [
|
|
776
|
+
meta
|
|
777
|
+
for meta in cast(list[ChatSessionMeta], await self._state_store.list_sessions(kind="chat"))
|
|
778
|
+
if _same_dir(meta.primary_cwd, requested_cwd)
|
|
779
|
+
]
|
|
780
|
+
if additional_directories:
|
|
781
|
+
# Validate filter roots like new/load: absolute, existing dirs. This also
|
|
782
|
+
# normalizes them so the exact-match comparison is apples-to-apples.
|
|
783
|
+
resolved_additional = [_resolve_dir(path) for path in additional_directories]
|
|
784
|
+
matches = [meta for meta in matches if _extra_dirs_match(resolved_additional, meta)]
|
|
785
|
+
sessions = sorted(
|
|
786
|
+
matches,
|
|
787
|
+
key=lambda meta: (meta.updated_at, meta.session_id),
|
|
788
|
+
reverse=True,
|
|
789
|
+
)
|
|
790
|
+
start = _decode_cursor(cursor)
|
|
791
|
+
page_size = 50
|
|
792
|
+
page = sessions[start : start + page_size]
|
|
793
|
+
next_cursor = str(start + page_size) if start + page_size < len(sessions) else None
|
|
794
|
+
return [_session_info(meta) for meta in page], next_cursor
|
|
795
|
+
|
|
796
|
+
def get(self, session_id: str) -> ManagedSession:
|
|
797
|
+
"""Return an active managed session."""
|
|
798
|
+
session = self._sessions.get(session_id)
|
|
799
|
+
if session is None or session.closing:
|
|
800
|
+
msg = f"ACP session is not active: {session_id}"
|
|
801
|
+
raise AcpSessionError(msg)
|
|
802
|
+
return session
|
|
803
|
+
|
|
804
|
+
def tool_kind_resolver(self, session_id: str) -> Callable[[str], str]:
|
|
805
|
+
"""Return a resolver for live tool kinds in an active session."""
|
|
806
|
+
session = self.get(session_id)
|
|
807
|
+
return session.host.engine.tool_kind_for_name
|
|
808
|
+
|
|
809
|
+
async def cancel(self, session_id: str) -> None:
|
|
810
|
+
"""Cancel an active session turn.
|
|
811
|
+
|
|
812
|
+
Deliberately tolerates a session already marked closing: close and
|
|
813
|
+
delete stop prompt admission first and interrupt second, so the
|
|
814
|
+
interrupt must still reach the turn that is being drained.
|
|
815
|
+
"""
|
|
816
|
+
session = self._sessions.get(session_id)
|
|
817
|
+
if session is None:
|
|
818
|
+
msg = f"ACP session is not active: {session_id}"
|
|
819
|
+
raise AcpSessionError(msg)
|
|
820
|
+
await session.host.cancel_current_turn()
|
|
821
|
+
|
|
822
|
+
async def inject(self, session_id: str, text: str) -> None:
|
|
823
|
+
"""Inject a prompt into the active turn (rejects when none is running)."""
|
|
824
|
+
session = self.get(session_id)
|
|
825
|
+
if not session.host.engine.is_turn_active:
|
|
826
|
+
# Publishing UserMessage here would start a brand-new turn outside any
|
|
827
|
+
# ACP session/prompt stream (no PromptResponse, no standard updates).
|
|
828
|
+
# session/prompt is the path for new turns; injection requires an active one.
|
|
829
|
+
msg = "No active turn to inject into."
|
|
830
|
+
raise AcpSessionError(msg)
|
|
831
|
+
# UserInject (not UserMessage) never starts a turn, so even if the turn ends
|
|
832
|
+
# between the guard above and event delivery, this cannot spawn a stray run.
|
|
833
|
+
await session.host.event_bus.publish(UserInject(text=text, session_id=session.host.session_id))
|
|
834
|
+
|
|
835
|
+
async def rollback(
|
|
836
|
+
self,
|
|
837
|
+
session_id: str,
|
|
838
|
+
*,
|
|
839
|
+
target_turn: int,
|
|
840
|
+
revert_changes: bool,
|
|
841
|
+
selected_paths: list[str] | None,
|
|
842
|
+
) -> RollbackResult:
|
|
843
|
+
"""Request rollback and wait for the backend result."""
|
|
844
|
+
session = self.get(session_id)
|
|
845
|
+
bus = session.host.event_bus
|
|
846
|
+
loop = asyncio.get_running_loop()
|
|
847
|
+
result_future: asyncio.Future[RollbackResult] = loop.create_future()
|
|
848
|
+
|
|
849
|
+
async def _on_result(event: RollbackResult) -> None:
|
|
850
|
+
if event.session_id == session.host.session_id and not result_future.done():
|
|
851
|
+
result_future.set_result(event)
|
|
852
|
+
|
|
853
|
+
async def _on_error(event: Error) -> None:
|
|
854
|
+
if event.session_id == session.host.session_id and not result_future.done():
|
|
855
|
+
result_future.set_exception(AcpSessionError(event.message or event.code or "Rollback failed."))
|
|
856
|
+
|
|
857
|
+
async def _on_warning(event: Warning) -> None:
|
|
858
|
+
# Only rollback refusals (rollback_*) fail the RPC. A successful rollback
|
|
859
|
+
# restores the session first, which can emit unrelated non-fatal warnings
|
|
860
|
+
# (service_session_incompatible, sub_agents_reload_discarded) before the
|
|
861
|
+
# RollbackResult — those must not be mistaken for a refusal.
|
|
862
|
+
if (
|
|
863
|
+
event.session_id == session.host.session_id
|
|
864
|
+
and (event.code or "").startswith("rollback_")
|
|
865
|
+
and not result_future.done()
|
|
866
|
+
):
|
|
867
|
+
result_future.set_exception(AcpSessionError(event.message or event.code or "Rollback was refused."))
|
|
868
|
+
|
|
869
|
+
await bus.subscribe(RollbackResult, _on_result)
|
|
870
|
+
await bus.subscribe(Error, _on_error)
|
|
871
|
+
await bus.subscribe(Warning, _on_warning)
|
|
872
|
+
try:
|
|
873
|
+
await bus.publish(
|
|
874
|
+
UserRollback(
|
|
875
|
+
target_turn=target_turn,
|
|
876
|
+
revert_changes=revert_changes,
|
|
877
|
+
selected_paths=selected_paths,
|
|
878
|
+
session_id=session.host.session_id,
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
return await asyncio.wait_for(result_future, timeout=30)
|
|
882
|
+
except TimeoutError as exc:
|
|
883
|
+
msg = "Timed out waiting for rollback result."
|
|
884
|
+
raise AcpSessionError(msg) from exc
|
|
885
|
+
finally:
|
|
886
|
+
await bus.unsubscribe(RollbackResult, _on_result)
|
|
887
|
+
await bus.unsubscribe(Error, _on_error)
|
|
888
|
+
await bus.unsubscribe(Warning, _on_warning)
|
|
889
|
+
|
|
890
|
+
async def retry_sub_agent(self, session_id: str, invocation_id: str) -> None:
|
|
891
|
+
"""Request retry for a paused sub-agent invocation."""
|
|
892
|
+
session = self.get(session_id)
|
|
893
|
+
await session.host.event_bus.publish(
|
|
894
|
+
InvocationRetryRequested(invocation_id=invocation_id, session_id=session.host.session_id)
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
async def abort_sub_agent(self, session_id: str, invocation_id: str) -> None:
|
|
898
|
+
"""Request abort for a paused sub-agent invocation."""
|
|
899
|
+
session = self.get(session_id)
|
|
900
|
+
await session.host.event_bus.publish(
|
|
901
|
+
InvocationAbortRequested(invocation_id=invocation_id, session_id=session.host.session_id)
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
async def skip_sleep(self, session_id: str, call_id: str) -> None:
|
|
905
|
+
"""Request that an active sleep tool call finish early."""
|
|
906
|
+
if not call_id:
|
|
907
|
+
msg = "call_id is required."
|
|
908
|
+
raise AcpSessionError(msg)
|
|
909
|
+
session = self.get(session_id)
|
|
910
|
+
await session.host.event_bus.publish(SleepSkip(call_id=call_id))
|
|
911
|
+
|
|
912
|
+
async def set_approval_mode(self, session_id: str, mode: str) -> ApprovalModeUpdated:
|
|
913
|
+
"""Set the runtime approval mode and wait for backend confirmation."""
|
|
914
|
+
session = self.get(session_id)
|
|
915
|
+
bus = session.host.event_bus
|
|
916
|
+
|
|
917
|
+
async with session.mutation_lock:
|
|
918
|
+
loop = asyncio.get_running_loop()
|
|
919
|
+
result_future: asyncio.Future[ApprovalModeUpdated] = loop.create_future()
|
|
920
|
+
|
|
921
|
+
async def _on_updated(event: ApprovalModeUpdated) -> None:
|
|
922
|
+
if event.session_id == session.host.session_id and event.mode == mode and not result_future.done():
|
|
923
|
+
result_future.set_result(event)
|
|
924
|
+
|
|
925
|
+
await bus.subscribe(ApprovalModeUpdated, _on_updated)
|
|
926
|
+
try:
|
|
927
|
+
await bus.publish(SetApprovalMode(mode=mode, persist=False, session_id=session.host.session_id))
|
|
928
|
+
return await asyncio.wait_for(result_future, timeout=5)
|
|
929
|
+
except TimeoutError as exc:
|
|
930
|
+
msg = f"Timed out waiting for approval mode update: {mode}"
|
|
931
|
+
raise AcpSessionError(msg) from exc
|
|
932
|
+
finally:
|
|
933
|
+
await bus.unsubscribe(ApprovalModeUpdated, _on_updated)
|
|
934
|
+
|
|
935
|
+
async def switch_agent(self, session_id: str, profile_name: str) -> ProfileSwitched:
|
|
936
|
+
"""Switch the active session to another agent profile."""
|
|
937
|
+
session = self.get(session_id)
|
|
938
|
+
self.load_registries()
|
|
939
|
+
profile = self._agent_registry.resolve_selector(profile_name)
|
|
940
|
+
if profile is None:
|
|
941
|
+
# Invalid profiles publish an Error rather than ProfileSwitched, so reject
|
|
942
|
+
# here instead of waiting out the 60s timeout for an event that never comes.
|
|
943
|
+
msg = f"Agent profile not found: {profile_name}"
|
|
944
|
+
available = ", ".join(sorted(self._agent_registry.list_names()))
|
|
945
|
+
if available:
|
|
946
|
+
msg = f"{msg}. Available profiles: {available}"
|
|
947
|
+
raise AcpSessionError(msg)
|
|
948
|
+
async with session.mutation_lock:
|
|
949
|
+
if session.profile_name == profile.name:
|
|
950
|
+
return session.host.engine.current_profile_snapshot()
|
|
951
|
+
result = await self._await_runtime_mutation_locked(
|
|
952
|
+
session,
|
|
953
|
+
AgentProfileSwitch(profile_name=profile.name, session_id=session.host.session_id),
|
|
954
|
+
ProfileSwitched,
|
|
955
|
+
timeout_message=f"Timed out waiting for profile switch: {profile.name}",
|
|
956
|
+
failure_message="Profile switch failed.",
|
|
957
|
+
)
|
|
958
|
+
session.profile_name = result.to_profile or profile.name
|
|
959
|
+
return result
|
|
960
|
+
|
|
961
|
+
async def _await_runtime_mutation(
|
|
962
|
+
self,
|
|
963
|
+
session: ManagedSession,
|
|
964
|
+
command: Event,
|
|
965
|
+
result_type: type[_RuntimeEventT],
|
|
966
|
+
*,
|
|
967
|
+
timeout_message: str,
|
|
968
|
+
failure_message: str,
|
|
969
|
+
timeout: float = 60,
|
|
970
|
+
warnings: list[Warning] | None = None,
|
|
971
|
+
) -> _RuntimeEventT:
|
|
972
|
+
"""Publish a runtime-control command and await its result event.
|
|
973
|
+
|
|
974
|
+
Resolves on the success event, or fails fast on ``Error`` /
|
|
975
|
+
``AgentLoadFailed``. A rebuild failure publishes ``AgentLoadFailed`` and
|
|
976
|
+
then raises inside the bus handler (swallowed by default), so without
|
|
977
|
+
watching it the request would hang until the timeout.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
warnings: Collects the ``Warning`` events this session publishes
|
|
981
|
+
while the mutation runs. Passed as a list rather than returned
|
|
982
|
+
so the caller decides whether the diagnosis is worth carrying —
|
|
983
|
+
only the reload has anything to say.
|
|
984
|
+
"""
|
|
985
|
+
# Serialize mutations on this session: two concurrent calls of the same
|
|
986
|
+
# result type would otherwise each be eligible to resolve from whichever
|
|
987
|
+
# completion event fires first, crossing the wires.
|
|
988
|
+
async with session.mutation_lock:
|
|
989
|
+
return await self._await_runtime_mutation_locked(
|
|
990
|
+
session,
|
|
991
|
+
command,
|
|
992
|
+
result_type,
|
|
993
|
+
timeout_message=timeout_message,
|
|
994
|
+
failure_message=failure_message,
|
|
995
|
+
timeout=timeout,
|
|
996
|
+
warnings=warnings,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
async def _await_runtime_mutation_locked(
|
|
1000
|
+
self,
|
|
1001
|
+
session: ManagedSession,
|
|
1002
|
+
command: Event,
|
|
1003
|
+
result_type: type[_RuntimeEventT],
|
|
1004
|
+
*,
|
|
1005
|
+
timeout_message: str,
|
|
1006
|
+
failure_message: str,
|
|
1007
|
+
timeout: float = 60,
|
|
1008
|
+
warnings: list[Warning] | None = None,
|
|
1009
|
+
) -> _RuntimeEventT:
|
|
1010
|
+
"""Publish a runtime-control command while the session mutation lock is held."""
|
|
1011
|
+
bus = session.host.event_bus
|
|
1012
|
+
sid = session.host.session_id
|
|
1013
|
+
loop = asyncio.get_running_loop()
|
|
1014
|
+
|
|
1015
|
+
future: asyncio.Future[_RuntimeEventT] = loop.create_future()
|
|
1016
|
+
|
|
1017
|
+
async def _on_result(event: _RuntimeEventT) -> None:
|
|
1018
|
+
if event.session_id == sid and not future.done():
|
|
1019
|
+
future.set_result(event)
|
|
1020
|
+
|
|
1021
|
+
async def _on_error(event: Error) -> None:
|
|
1022
|
+
if event.session_id == sid and not future.done():
|
|
1023
|
+
future.set_exception(AcpSessionError(event.message or event.code or failure_message))
|
|
1024
|
+
|
|
1025
|
+
async def _on_load_failed(event: AgentLoadFailed) -> None:
|
|
1026
|
+
if event.session_id == sid and not future.done():
|
|
1027
|
+
future.set_exception(AcpSessionError(event.message or failure_message))
|
|
1028
|
+
|
|
1029
|
+
async def _on_warning(event: Warning) -> None:
|
|
1030
|
+
if event.session_id == sid and warnings is not None:
|
|
1031
|
+
warnings.append(event)
|
|
1032
|
+
|
|
1033
|
+
await bus.subscribe(result_type, _on_result)
|
|
1034
|
+
await bus.subscribe(Error, _on_error)
|
|
1035
|
+
await bus.subscribe(AgentLoadFailed, _on_load_failed)
|
|
1036
|
+
await bus.subscribe(Warning, _on_warning)
|
|
1037
|
+
try:
|
|
1038
|
+
await bus.publish(command)
|
|
1039
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
1040
|
+
except TimeoutError as exc:
|
|
1041
|
+
raise AcpSessionError(timeout_message) from exc
|
|
1042
|
+
finally:
|
|
1043
|
+
await bus.unsubscribe(result_type, _on_result)
|
|
1044
|
+
await bus.unsubscribe(Error, _on_error)
|
|
1045
|
+
await bus.unsubscribe(AgentLoadFailed, _on_load_failed)
|
|
1046
|
+
await bus.unsubscribe(Warning, _on_warning)
|
|
1047
|
+
|
|
1048
|
+
async def set_model_profile(self, session_id: str, profile_id: str) -> ModelProfileSwitched:
|
|
1049
|
+
"""Switch the active model for this session only (no global .env write)."""
|
|
1050
|
+
self.load_registries()
|
|
1051
|
+
if not profile_id or self._model_registry.get(profile_id) is None:
|
|
1052
|
+
msg = f"Model profile not found: {profile_id}"
|
|
1053
|
+
raise AcpSessionError(msg)
|
|
1054
|
+
session = self.get(session_id)
|
|
1055
|
+
return await self._await_runtime_mutation(
|
|
1056
|
+
session,
|
|
1057
|
+
SetModelProfile(profile_id=profile_id, session_id=session.host.session_id),
|
|
1058
|
+
ModelProfileSwitched,
|
|
1059
|
+
timeout_message=f"Timed out waiting for model switch: {profile_id}",
|
|
1060
|
+
failure_message="Model switch failed.",
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
async def session_history(self, *, cwd: str | None, session_id: str) -> tuple[str, list[dict[str, object]] | None]:
|
|
1064
|
+
"""Return raw persisted messages, scoped to the requesting workspace cwd.
|
|
1065
|
+
|
|
1066
|
+
Mirrors ``load``/``delete`` scoping so a client cannot read another
|
|
1067
|
+
workspace's session history just by knowing its id.
|
|
1068
|
+
"""
|
|
1069
|
+
resolved_cwd = self._resolve_requested_cwd(cwd)
|
|
1070
|
+
existing = self._resolve_active_session(session_id)
|
|
1071
|
+
if existing is not None:
|
|
1072
|
+
self._validate_active_cwd(existing, resolved_cwd)
|
|
1073
|
+
messages = await self._state_store.load_session_raw(
|
|
1074
|
+
existing.session_id,
|
|
1075
|
+
prefer_recovery=existing.host.engine.recovered_from_sidecar,
|
|
1076
|
+
)
|
|
1077
|
+
return existing.session_id, messages
|
|
1078
|
+
canonical_id, meta = await self._resolve_session_meta(session_id)
|
|
1079
|
+
self._validate_saved_cwd(meta, resolved_cwd)
|
|
1080
|
+
prefer_recovery = await self._meta_resolved_from_recovery(canonical_id, meta)
|
|
1081
|
+
return canonical_id, await self._state_store.load_session_raw(canonical_id, prefer_recovery=prefer_recovery)
|
|
1082
|
+
|
|
1083
|
+
async def reload_settings(self, session_id: str, *, warnings: list[Warning] | None = None) -> SettingsReloaded:
|
|
1084
|
+
"""Reload settings/profile files for an active session.
|
|
1085
|
+
|
|
1086
|
+
Awaits the rebuild so a failed soft-restart (``AgentLoadFailed``, which
|
|
1087
|
+
the bus otherwise swallows) surfaces as an error instead of a silent
|
|
1088
|
+
success — mirroring the model/workspace/profile mutations.
|
|
1089
|
+
|
|
1090
|
+
Args:
|
|
1091
|
+
warnings: Collects the settings this reload had to reject. A reload
|
|
1092
|
+
is a request whose whole point is "apply what I just wrote", so
|
|
1093
|
+
a client that is told it succeeded and never told which of its
|
|
1094
|
+
values was dropped has been misinformed. Nothing else on the bus
|
|
1095
|
+
is listening: warnings published outside a prompt turn reach no
|
|
1096
|
+
subscriber, and the bus does not replay them to later ones.
|
|
1097
|
+
"""
|
|
1098
|
+
session = self.get(session_id)
|
|
1099
|
+
self._agent_registry.load_all()
|
|
1100
|
+
self._model_registry.load_all()
|
|
1101
|
+
result = await self._await_runtime_mutation(
|
|
1102
|
+
session,
|
|
1103
|
+
SettingsReload(),
|
|
1104
|
+
SettingsReloaded,
|
|
1105
|
+
timeout_message="Timed out waiting for settings reload.",
|
|
1106
|
+
failure_message="Settings reload failed.",
|
|
1107
|
+
warnings=warnings,
|
|
1108
|
+
)
|
|
1109
|
+
await asyncio.to_thread(self._refresh_default_settings_from_env)
|
|
1110
|
+
return result
|
|
1111
|
+
|
|
1112
|
+
async def apply_config_option(
|
|
1113
|
+
self,
|
|
1114
|
+
session_id: str,
|
|
1115
|
+
key: str,
|
|
1116
|
+
value: object,
|
|
1117
|
+
*,
|
|
1118
|
+
warnings: list[Warning] | None = None,
|
|
1119
|
+
) -> dict[str, object]:
|
|
1120
|
+
"""Persist a config option and reload the target session.
|
|
1121
|
+
|
|
1122
|
+
The session is validated *before* the document write so a missing,
|
|
1123
|
+
stale, or inactive ``session_id`` fails the request without leaving the
|
|
1124
|
+
persisted config mutated. The write itself is global — it lands in the
|
|
1125
|
+
user settings document, whose keys are process-wide defaults — and the
|
|
1126
|
+
reload applies it to the live session.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
warnings: Collects what the reload had to reject or clamp, for the
|
|
1130
|
+
same reason ``reload_settings`` does — more so here, because
|
|
1131
|
+
this path *persists* the value first. Without it a client that
|
|
1132
|
+
sets ``rollback_snapshots_keep`` to ``0`` is told it succeeded,
|
|
1133
|
+
the ``0`` is written to disk, and the runtime quietly uses ``1``.
|
|
1134
|
+
"""
|
|
1135
|
+
self.get(session_id)
|
|
1136
|
+
# Off-thread: the write takes the settings file's lock and the refresh
|
|
1137
|
+
# reads the file back, and this server answers every other request on
|
|
1138
|
+
# the same event loop.
|
|
1139
|
+
result = await asyncio.to_thread(self.set_config_option, key, value)
|
|
1140
|
+
await self.reload_settings(session_id, warnings=warnings)
|
|
1141
|
+
return result
|
|
1142
|
+
|
|
1143
|
+
async def set_workspace(self, session_id: str, primary_cwd: str) -> WorkspaceUpdated:
|
|
1144
|
+
"""Change the session primary workspace cwd."""
|
|
1145
|
+
session = self.get(session_id)
|
|
1146
|
+
# Validate strictly (must exist, be a dir, resolve absolutely) like new/load.
|
|
1147
|
+
# WorkspaceChange → Workspace.from_cwd only absolutizes, so an unvalidated typo
|
|
1148
|
+
# would rebuild/persist a broken workspace or resolve a relative path against the
|
|
1149
|
+
# process cwd instead of the session.
|
|
1150
|
+
resolved_cwd = _resolve_dir(primary_cwd)
|
|
1151
|
+
result = await self._await_runtime_mutation(
|
|
1152
|
+
session,
|
|
1153
|
+
WorkspaceChange(primary_cwd=resolved_cwd, session_id=session.host.session_id),
|
|
1154
|
+
WorkspaceUpdated,
|
|
1155
|
+
timeout_message=f"Timed out waiting for workspace update: {resolved_cwd}",
|
|
1156
|
+
failure_message="Workspace update failed.",
|
|
1157
|
+
)
|
|
1158
|
+
session.cwd = result.primary_cwd or resolved_cwd
|
|
1159
|
+
return result
|
|
1160
|
+
|
|
1161
|
+
async def begin_close(self, session_id: str) -> None:
|
|
1162
|
+
"""Stop prompt admission for a session about to be closed.
|
|
1163
|
+
|
|
1164
|
+
Callers release pending client waits between this call and
|
|
1165
|
+
:meth:`close`. Marking the session closing first means a prompt queued
|
|
1166
|
+
on the session's prompt lock fails its post-acquisition re-check
|
|
1167
|
+
instead of being admitted into a fresh turn that the lock-protected
|
|
1168
|
+
teardown would then wedge behind. No-op when the session is not
|
|
1169
|
+
active.
|
|
1170
|
+
"""
|
|
1171
|
+
async with self._lock:
|
|
1172
|
+
session = self._sessions.get(session_id)
|
|
1173
|
+
if session is not None:
|
|
1174
|
+
session.closing = True
|
|
1175
|
+
|
|
1176
|
+
async def close(self, session_id: str) -> None:
|
|
1177
|
+
"""Close an active session."""
|
|
1178
|
+
async with self._lock:
|
|
1179
|
+
session = self._sessions.get(session_id)
|
|
1180
|
+
if session is not None:
|
|
1181
|
+
session.closing = True
|
|
1182
|
+
async with session.prompt_lock:
|
|
1183
|
+
self._sessions.pop(session_id, None)
|
|
1184
|
+
await session.shutdown()
|
|
1185
|
+
|
|
1186
|
+
async def begin_delete_session(self, *, cwd: str | None, session_id: str) -> str:
|
|
1187
|
+
"""Validate a delete request, stop prompt admission, and return the canonical id.
|
|
1188
|
+
|
|
1189
|
+
Applies workspace scoping and short-id canonicalization, then marks an
|
|
1190
|
+
active target closing — validation first, inside one lock critical
|
|
1191
|
+
section, so a rejected request has no side effects. Callers release
|
|
1192
|
+
per-session waits under the canonical id next and finish with
|
|
1193
|
+
:meth:`finish_delete_session`; a prompt queued behind the released
|
|
1194
|
+
turn then fails its re-check instead of starting a turn the teardown
|
|
1195
|
+
would wedge behind.
|
|
1196
|
+
"""
|
|
1197
|
+
async with self._lock:
|
|
1198
|
+
resolved_cwd = self._resolve_requested_cwd(cwd)
|
|
1199
|
+
canonical_id, meta = await self._resolve_session_meta(session_id)
|
|
1200
|
+
self._validate_saved_cwd(meta, resolved_cwd)
|
|
1201
|
+
session = self._sessions.get(canonical_id)
|
|
1202
|
+
if session is not None:
|
|
1203
|
+
session.closing = True
|
|
1204
|
+
return canonical_id
|
|
1205
|
+
|
|
1206
|
+
async def finish_delete_session(self, canonical_id: str) -> None:
|
|
1207
|
+
"""Tear down and delete a session claimed by :meth:`begin_delete_session`."""
|
|
1208
|
+
async with self._lock:
|
|
1209
|
+
session = self._sessions.get(canonical_id)
|
|
1210
|
+
if session is not None:
|
|
1211
|
+
session.closing = True
|
|
1212
|
+
async with session.prompt_lock:
|
|
1213
|
+
self._sessions.pop(canonical_id, None)
|
|
1214
|
+
await session.shutdown()
|
|
1215
|
+
await self._state_store.delete_session(canonical_id)
|
|
1216
|
+
|
|
1217
|
+
async def delete_session(self, *, cwd: str | None, session_id: str) -> None:
|
|
1218
|
+
"""Delete a saved session scoped to this ACP process cwd."""
|
|
1219
|
+
canonical_id = await self.begin_delete_session(cwd=cwd, session_id=session_id)
|
|
1220
|
+
await self.finish_delete_session(canonical_id)
|
|
1221
|
+
|
|
1222
|
+
async def shutdown(self) -> None:
|
|
1223
|
+
"""Close every active session."""
|
|
1224
|
+
sessions = list(self._sessions.values())
|
|
1225
|
+
self._sessions.clear()
|
|
1226
|
+
for session in sessions:
|
|
1227
|
+
session.closing = True
|
|
1228
|
+
for session in sessions:
|
|
1229
|
+
async with session.prompt_lock:
|
|
1230
|
+
await session.shutdown()
|
|
1231
|
+
|
|
1232
|
+
def _resolve_requested_cwd(self, cwd: str | None) -> str:
|
|
1233
|
+
if cwd is None:
|
|
1234
|
+
if self._process_cwd is not None:
|
|
1235
|
+
if not os.path.isdir(self._process_cwd):
|
|
1236
|
+
msg = f"working directory no longer exists: {surrogate_safe_text(self._process_cwd)}"
|
|
1237
|
+
raise AcpSessionError(msg)
|
|
1238
|
+
return self._process_cwd
|
|
1239
|
+
msg = "cwd is required when this ACP process was not started with --workdir."
|
|
1240
|
+
raise AcpSessionError(msg)
|
|
1241
|
+
return _resolve_dir(cwd)
|
|
1242
|
+
|
|
1243
|
+
async def _resolve_session_meta(self, session_id: str) -> tuple[str, ChatSessionMeta]:
|
|
1244
|
+
raw = (session_id or "").strip()
|
|
1245
|
+
if not raw:
|
|
1246
|
+
msg = "session_id is required."
|
|
1247
|
+
raise AcpSessionError(msg)
|
|
1248
|
+
sessions = cast(list[ChatSessionMeta], await self._state_store.list_sessions(kind="chat"))
|
|
1249
|
+
normalized = raw.replace("-", "").lower()
|
|
1250
|
+
if len(normalized) <= SESSION_SHORT_ID_LEN:
|
|
1251
|
+
matches = [meta for meta in sessions if session_short_id(meta.session_id).lower() == normalized]
|
|
1252
|
+
else:
|
|
1253
|
+
matches = [
|
|
1254
|
+
meta
|
|
1255
|
+
for meta in sessions
|
|
1256
|
+
if meta.session_id == raw or meta.session_id.replace("-", "").lower() == normalized
|
|
1257
|
+
]
|
|
1258
|
+
if len(matches) == 1:
|
|
1259
|
+
return matches[0].session_id, matches[0]
|
|
1260
|
+
if len(matches) > 1:
|
|
1261
|
+
raise AcpSessionError(f"Session id '{raw}' is ambiguous.")
|
|
1262
|
+
raise AcpSessionError(f"Session not found: {raw}")
|
|
1263
|
+
|
|
1264
|
+
async def _meta_resolved_from_recovery(self, session_id: str, meta: ChatSessionMeta) -> bool:
|
|
1265
|
+
"""Return whether lock-aware listing metadata came from the recovery sidecar."""
|
|
1266
|
+
primary_meta = await self._state_store.load_session_meta(session_id, prefer_recovery=False)
|
|
1267
|
+
if primary_meta is None:
|
|
1268
|
+
return True
|
|
1269
|
+
return meta.updated_at > primary_meta.updated_at
|
|
1270
|
+
|
|
1271
|
+
def _resolve_active_session(self, session_id: str) -> ManagedSession | None:
|
|
1272
|
+
"""Resolve an already-active session without consulting persisted session listings."""
|
|
1273
|
+
raw = (session_id or "").strip()
|
|
1274
|
+
if not raw:
|
|
1275
|
+
msg = "session_id is required."
|
|
1276
|
+
raise AcpSessionError(msg)
|
|
1277
|
+
direct = self._sessions.get(raw)
|
|
1278
|
+
if direct is not None:
|
|
1279
|
+
return self._reject_closing(direct)
|
|
1280
|
+
normalized = raw.replace("-", "").lower()
|
|
1281
|
+
if len(normalized) <= SESSION_SHORT_ID_LEN:
|
|
1282
|
+
matches = [
|
|
1283
|
+
session
|
|
1284
|
+
for session in self._sessions.values()
|
|
1285
|
+
if session_short_id(session.session_id).lower() == normalized
|
|
1286
|
+
]
|
|
1287
|
+
else:
|
|
1288
|
+
matches = [
|
|
1289
|
+
session
|
|
1290
|
+
for session in self._sessions.values()
|
|
1291
|
+
if session.session_id == raw or session.session_id.replace("-", "").lower() == normalized
|
|
1292
|
+
]
|
|
1293
|
+
if len(matches) == 1:
|
|
1294
|
+
return self._reject_closing(matches[0])
|
|
1295
|
+
if len(matches) > 1:
|
|
1296
|
+
raise AcpSessionError(f"Session id '{raw}' is ambiguous.")
|
|
1297
|
+
return None
|
|
1298
|
+
|
|
1299
|
+
@staticmethod
|
|
1300
|
+
def _reject_closing(session: ManagedSession) -> ManagedSession:
|
|
1301
|
+
"""Refuse to hand out a session that a close/delete is tearing down.
|
|
1302
|
+
|
|
1303
|
+
A closing session stays in the map only while pending waits drain.
|
|
1304
|
+
Returning it would report a successful reuse for a session about to
|
|
1305
|
+
shut down — and callers must not treat it as inactive and fall
|
|
1306
|
+
through to a fresh load either: that would overwrite the live map
|
|
1307
|
+
entry the in-flight teardown is about to pop, so the teardown would
|
|
1308
|
+
shut down the replacement. Raising is the only safe answer until the
|
|
1309
|
+
teardown finishes.
|
|
1310
|
+
"""
|
|
1311
|
+
if session.closing:
|
|
1312
|
+
msg = f"ACP session is closing: {session.session_id}"
|
|
1313
|
+
raise AcpSessionError(msg)
|
|
1314
|
+
return session
|
|
1315
|
+
|
|
1316
|
+
def _validate_saved_cwd(self, meta: ChatSessionMeta, requested_cwd: str) -> None:
|
|
1317
|
+
if not meta.primary_cwd:
|
|
1318
|
+
msg = (
|
|
1319
|
+
f"Session '{session_short_id(meta.session_id)}' has no saved cwd metadata and cannot be loaded "
|
|
1320
|
+
"over ACP."
|
|
1321
|
+
)
|
|
1322
|
+
raise AcpSessionError(msg)
|
|
1323
|
+
if not os.path.isdir(meta.primary_cwd):
|
|
1324
|
+
msg = (
|
|
1325
|
+
f"working directory of session '{session_short_id(meta.session_id)}' no longer exists: "
|
|
1326
|
+
f"{surrogate_safe_text(meta.primary_cwd)}"
|
|
1327
|
+
)
|
|
1328
|
+
raise AcpSessionError(msg)
|
|
1329
|
+
if not _same_dir(meta.primary_cwd, requested_cwd):
|
|
1330
|
+
logger.warning(
|
|
1331
|
+
"ACP session load cwd mismatch for %s: saved cwd %s requested cwd %s",
|
|
1332
|
+
session_short_id(meta.session_id),
|
|
1333
|
+
meta.primary_cwd,
|
|
1334
|
+
requested_cwd,
|
|
1335
|
+
)
|
|
1336
|
+
msg = f"Session '{session_short_id(meta.session_id)}' belongs to a different workspace."
|
|
1337
|
+
raise AcpSessionError(msg)
|
|
1338
|
+
|
|
1339
|
+
def _validate_active_cwd(self, session: ManagedSession, requested_cwd: str) -> None:
|
|
1340
|
+
if not _same_dir(session.cwd, requested_cwd):
|
|
1341
|
+
logger.warning(
|
|
1342
|
+
"ACP active session load cwd mismatch for %s: active cwd %s requested cwd %s",
|
|
1343
|
+
session_short_id(session.session_id),
|
|
1344
|
+
session.cwd,
|
|
1345
|
+
requested_cwd,
|
|
1346
|
+
)
|
|
1347
|
+
msg = (
|
|
1348
|
+
f"Session '{session_short_id(session.session_id)}' belongs to cwd '{session.cwd}', "
|
|
1349
|
+
f"not requested cwd '{requested_cwd}'."
|
|
1350
|
+
)
|
|
1351
|
+
raise AcpSessionError(msg)
|
|
1352
|
+
|
|
1353
|
+
def _session_loaded_settings(self, cwd: str) -> LoadedSettings:
|
|
1354
|
+
"""Derive one session's settings under its own project trust domain.
|
|
1355
|
+
|
|
1356
|
+
The manager's base settings are deliberately project-free — a
|
|
1357
|
+
manager-level project layer would leak one session's trust decisions
|
|
1358
|
+
into every other — so each session re-loads with its resolved cwd as
|
|
1359
|
+
the project root. Everything process-wide still flows from the base:
|
|
1360
|
+
RESTART values are held at what this process is running on, the
|
|
1361
|
+
command line keeps the credit for what it decided, and the launch-time
|
|
1362
|
+
``--ask-user-timeout`` is carried across as the CLI layer it is, since
|
|
1363
|
+
a re-read of the environment cannot produce it. Under the global
|
|
1364
|
+
settings lock so the base read cannot straddle a concurrent write's
|
|
1365
|
+
document/publish gap.
|
|
1366
|
+
"""
|
|
1367
|
+
with self._global_settings_lock:
|
|
1368
|
+
base = self._loaded_settings
|
|
1369
|
+
candidate = load_settings(
|
|
1370
|
+
project_root=Path(cwd),
|
|
1371
|
+
eval_context=EvalContext(
|
|
1372
|
+
frontend_default_max_transient_retries=base.settings.frontend_default_max_transient_retries
|
|
1373
|
+
),
|
|
1374
|
+
)
|
|
1375
|
+
loaded, _deferred = route_restart_settings(reattribute_command_line(candidate, base), base)
|
|
1376
|
+
return loaded.overlay(Source.CLI, ask_user_timeout_seconds=base.settings.ask_user_timeout_seconds)
|
|
1377
|
+
|
|
1378
|
+
def _refresh_default_settings_from_env(self) -> None:
|
|
1379
|
+
# Read, route and publish as one step. Every writer refreshes after its
|
|
1380
|
+
# own write, so serializing this makes the last refresh to publish also
|
|
1381
|
+
# the last to read — without the lock, two clients configuring two
|
|
1382
|
+
# sessions at once can have the slower one publish a snapshot taken
|
|
1383
|
+
# before the faster one's write, and every session created afterwards
|
|
1384
|
+
# runs on settings that are on disk but not in this manager. Taken here
|
|
1385
|
+
# rather than only at the write sites because a reload refreshes too.
|
|
1386
|
+
with self._global_settings_lock:
|
|
1387
|
+
# The launch-time ``--ask-user-timeout`` is a CLI value, not something a
|
|
1388
|
+
# re-read of the environment can produce, so it is carried across as the
|
|
1389
|
+
# layer it came from rather than folded into the new load.
|
|
1390
|
+
timeout = self._loaded_settings.settings.ask_user_timeout_seconds
|
|
1391
|
+
# RESTART routing applies to future sessions too: one process, one
|
|
1392
|
+
# value, so a host built after this refresh must not act on a value
|
|
1393
|
+
# the already-running hosts cannot. The deferred keys need no report
|
|
1394
|
+
# here — the session that triggered the refresh had its own reload,
|
|
1395
|
+
# which already said so on its bus.
|
|
1396
|
+
loaded, _deferred = route_restart_settings(load_settings(), self._loaded_settings)
|
|
1397
|
+
self._loaded_settings = loaded.overlay(Source.CLI, ask_user_timeout_seconds=timeout)
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _mcp_overlay(
|
|
1401
|
+
servers: list[acp_schema.HttpMcpServer | acp_schema.SseMcpServer | acp_schema.McpServerStdio] | None,
|
|
1402
|
+
) -> list[MCPServerConfig]:
|
|
1403
|
+
configs: list[MCPServerConfig] = []
|
|
1404
|
+
for server in servers or []:
|
|
1405
|
+
if isinstance(server, acp_schema.HttpMcpServer):
|
|
1406
|
+
configs.append(
|
|
1407
|
+
MCPServerConfig(
|
|
1408
|
+
name=server.name,
|
|
1409
|
+
transport="http",
|
|
1410
|
+
url=server.url,
|
|
1411
|
+
headers={header.name: header.value for header in server.headers},
|
|
1412
|
+
resolve_header_templates=False,
|
|
1413
|
+
)
|
|
1414
|
+
)
|
|
1415
|
+
continue
|
|
1416
|
+
if isinstance(server, acp_schema.SseMcpServer):
|
|
1417
|
+
msg = f"SSE MCP servers are not supported by {APP_DISPLAY_NAME} ACP phase 1."
|
|
1418
|
+
raise AcpSessionError(msg)
|
|
1419
|
+
if isinstance(server, acp_schema.McpServerStdio):
|
|
1420
|
+
msg = f"Client-supplied stdio MCP servers are not supported by {APP_DISPLAY_NAME} ACP phase 1."
|
|
1421
|
+
raise AcpSessionError(msg)
|
|
1422
|
+
msg = f"Unsupported MCP server type: {type(server).__name__}"
|
|
1423
|
+
raise AcpSessionError(msg)
|
|
1424
|
+
return configs
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
async def _shutdown_after_failed_start(
|
|
1428
|
+
host: ChrysSessionHost, title_updater: SessionTitleUpdater | None = None
|
|
1429
|
+
) -> None:
|
|
1430
|
+
try:
|
|
1431
|
+
await host.shutdown()
|
|
1432
|
+
except Exception:
|
|
1433
|
+
logger.exception("Error shutting down ACP session host after failed startup")
|
|
1434
|
+
if title_updater is not None:
|
|
1435
|
+
with contextlib.suppress(Exception):
|
|
1436
|
+
await title_updater.shutdown()
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _resolve_dir(path: str) -> str:
|
|
1440
|
+
raw = (path or "").strip()
|
|
1441
|
+
if not raw:
|
|
1442
|
+
msg = "cwd is required."
|
|
1443
|
+
raise AcpSessionError(msg)
|
|
1444
|
+
expanded = Path(raw).expanduser()
|
|
1445
|
+
if not expanded.is_absolute():
|
|
1446
|
+
# ACP requires absolute paths. Resolving a relative path against the ACP
|
|
1447
|
+
# server's process cwd would silently bind the session to the wrong
|
|
1448
|
+
# workspace, so reject it instead of guessing.
|
|
1449
|
+
msg = f"path must be absolute: {raw}"
|
|
1450
|
+
raise AcpSessionError(msg)
|
|
1451
|
+
try:
|
|
1452
|
+
resolved = expanded.resolve(strict=True)
|
|
1453
|
+
except FileNotFoundError, NotADirectoryError:
|
|
1454
|
+
msg = f"cwd does not exist: {surrogate_safe_text(raw)}"
|
|
1455
|
+
raise AcpSessionError(msg) from None
|
|
1456
|
+
except OSError:
|
|
1457
|
+
msg = f"cwd is not accessible: {surrogate_safe_text(raw)}"
|
|
1458
|
+
raise AcpSessionError(msg) from None
|
|
1459
|
+
if not resolved.is_dir():
|
|
1460
|
+
msg = f"cwd is not a directory: {resolved}"
|
|
1461
|
+
raise AcpSessionError(msg)
|
|
1462
|
+
return os.fspath(resolved)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _same_dir(left: str, right: str) -> bool:
|
|
1466
|
+
if not left or not right:
|
|
1467
|
+
return False
|
|
1468
|
+
try:
|
|
1469
|
+
return Path(left).expanduser().resolve(strict=True) == Path(right).expanduser().resolve(strict=True)
|
|
1470
|
+
except OSError:
|
|
1471
|
+
return False
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _workspace_from_dirs(primary_cwd: str, additional_directories: list[str] | None) -> Workspace:
|
|
1475
|
+
dirs = [WorkingDir(path=primary_cwd, is_primary=True)]
|
|
1476
|
+
for path in additional_directories or []:
|
|
1477
|
+
resolved = _resolve_dir(path)
|
|
1478
|
+
if not _same_dir(resolved, primary_cwd):
|
|
1479
|
+
dirs.append(WorkingDir(path=resolved))
|
|
1480
|
+
return Workspace(primary_cwd=primary_cwd, working_dirs=dirs)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _agent_profile_from_data(data: dict[str, object]) -> AgentProfile:
|
|
1484
|
+
with tempfile.TemporaryDirectory(prefix="chrys-agent-profile-") as temp_dir:
|
|
1485
|
+
stem = _safe_profile_file_stem(data.get("name", "profile"), field_name="agent profile name")
|
|
1486
|
+
path = Path(temp_dir) / f"{stem}.yaml"
|
|
1487
|
+
path.write_text(dump_yaml(data), encoding="utf-8")
|
|
1488
|
+
return load_agent_profile_from_yaml(path)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _model_profile_from_data(data: dict[str, object]) -> ModelProfile:
|
|
1492
|
+
with tempfile.TemporaryDirectory(prefix="chrys-model-profile-") as temp_dir:
|
|
1493
|
+
stem = _safe_profile_file_stem(data.get("id", "model"), field_name="model profile id")
|
|
1494
|
+
path = Path(temp_dir) / f"{stem}.yaml"
|
|
1495
|
+
path.write_text(dump_yaml(data), encoding="utf-8")
|
|
1496
|
+
return load_model_profile_from_yaml(path)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _mcp_config_from_data(data: dict[str, object]) -> MCPServerConfig:
|
|
1500
|
+
payload = dict(data)
|
|
1501
|
+
if "name" not in payload:
|
|
1502
|
+
msg = "MCP server name is required."
|
|
1503
|
+
raise AcpSessionError(msg)
|
|
1504
|
+
transport = payload.get("transport")
|
|
1505
|
+
if transport == "stdio":
|
|
1506
|
+
msg = f"Client-supplied stdio MCP servers are not supported by {APP_DISPLAY_NAME} ACP."
|
|
1507
|
+
raise AcpSessionError(msg)
|
|
1508
|
+
if transport != "http":
|
|
1509
|
+
msg = "mcp/test supports HTTP MCP servers only."
|
|
1510
|
+
raise AcpSessionError(msg)
|
|
1511
|
+
# ACP client-supplied headers must stay literal; profile-owned YAML is the
|
|
1512
|
+
# only MCP path that resolves {{ENV_VAR}} templates.
|
|
1513
|
+
payload["resolve_header_templates"] = False
|
|
1514
|
+
agent = _agent_profile_from_data(
|
|
1515
|
+
{
|
|
1516
|
+
"name": "MCP Test",
|
|
1517
|
+
"id": "mcp-test",
|
|
1518
|
+
"tools": {"mcp": [payload]},
|
|
1519
|
+
}
|
|
1520
|
+
)
|
|
1521
|
+
if not agent.tools.mcp:
|
|
1522
|
+
msg = "MCP server config is invalid."
|
|
1523
|
+
raise AcpSessionError(msg)
|
|
1524
|
+
return agent.tools.mcp[0]
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _config_setting_fields() -> dict[str, str]:
|
|
1528
|
+
"""Map each supported setting key to its ``Settings`` attribute name."""
|
|
1529
|
+
from chrys.foundation.config.settings import Settings
|
|
1530
|
+
from chrys.foundation.config.spec import field_names_by_key
|
|
1531
|
+
|
|
1532
|
+
names = field_names_by_key(Settings)
|
|
1533
|
+
return {option.setting_key: names[option.setting_key] for option in _SUPPORTED_CONFIG_OPTIONS}
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _config_display_value(stored: object) -> str:
|
|
1537
|
+
"""Render a stored value the way the wire protocol always has: as text."""
|
|
1538
|
+
if stored is None or stored == "":
|
|
1539
|
+
return ""
|
|
1540
|
+
if stored is True or stored is False:
|
|
1541
|
+
return "1" if stored else "0"
|
|
1542
|
+
return str(stored)
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def _config_option(key: str) -> AcpConfigOption:
|
|
1546
|
+
normalized = key.strip()
|
|
1547
|
+
for option in _SUPPORTED_CONFIG_OPTIONS:
|
|
1548
|
+
if normalized in (option.logical_key, option.env_alias):
|
|
1549
|
+
return option
|
|
1550
|
+
msg = f"Unsupported config option: {key}"
|
|
1551
|
+
raise AcpSessionError(msg)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _decode_cursor(cursor: str | None) -> int:
|
|
1555
|
+
if not cursor:
|
|
1556
|
+
return 0
|
|
1557
|
+
try:
|
|
1558
|
+
return max(0, int(cursor))
|
|
1559
|
+
except ValueError as exc:
|
|
1560
|
+
msg = f"Invalid cursor: {cursor}"
|
|
1561
|
+
raise AcpSessionError(msg) from exc
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _session_extra_dirs(meta: ChatSessionMeta) -> list[str]:
|
|
1565
|
+
# meta.working_dirs stores the full workspace list, which includes the primary
|
|
1566
|
+
# cwd for ACP multi-dir sessions. ``cwd`` already carries it, so the additional
|
|
1567
|
+
# roots are everything except the primary.
|
|
1568
|
+
return [d for d in meta.working_dirs if not _same_dir(d, meta.primary_cwd)]
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def _extra_dirs_match(requested: list[str], meta: ChatSessionMeta) -> bool:
|
|
1572
|
+
"""Exact, ordered match of a session's additional roots against a request."""
|
|
1573
|
+
saved = _session_extra_dirs(meta)
|
|
1574
|
+
if len(requested) != len(saved):
|
|
1575
|
+
return False
|
|
1576
|
+
return all(_same_dir(req, have) for req, have in zip(requested, saved, strict=True))
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _session_info(meta: ChatSessionMeta) -> acp_schema.SessionInfo:
|
|
1580
|
+
extra_dirs = _session_extra_dirs(meta)
|
|
1581
|
+
return acp_schema.SessionInfo(
|
|
1582
|
+
sessionId=meta.session_id,
|
|
1583
|
+
cwd=meta.primary_cwd,
|
|
1584
|
+
title=meta.display_title or None,
|
|
1585
|
+
updatedAt=meta.updated_at.isoformat(),
|
|
1586
|
+
additionalDirectories=extra_dirs or None,
|
|
1587
|
+
field_meta={
|
|
1588
|
+
"agent_profile": meta.agent_profile,
|
|
1589
|
+
"agent_display_name": meta.agent_display_name,
|
|
1590
|
+
"message_count": meta.message_count,
|
|
1591
|
+
"model_provider": meta.model_provider,
|
|
1592
|
+
"model_api_style": meta.model_api_style,
|
|
1593
|
+
"model_id": meta.model_id,
|
|
1594
|
+
},
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _agent_profile_info(profile: AgentProfile, *, builtin: bool) -> dict[str, object]:
|
|
1599
|
+
return {
|
|
1600
|
+
"id": profile.id,
|
|
1601
|
+
"name": profile.name,
|
|
1602
|
+
"displayName": profile.display_name,
|
|
1603
|
+
"description": profile.description,
|
|
1604
|
+
"subAgentOnly": profile.sub_agent_only,
|
|
1605
|
+
"builtin": builtin,
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def _agent_profile_reset_comparison_dict(profile: AgentProfile) -> dict[str, Any]:
|
|
1610
|
+
"""Serialize after normalizing the order-insensitive Skills extension list."""
|
|
1611
|
+
normalized = deepcopy(profile)
|
|
1612
|
+
normalized.skills.script_extensions = sorted(normalized.skills.script_extensions)
|
|
1613
|
+
return agent_profile_to_dict(normalized)
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
_MASK = "***"
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _is_string_object_dict(value: object) -> TypeGuard[dict[str, object]]:
|
|
1620
|
+
"""Narrow ACP JSON objects, whose keys are necessarily strings."""
|
|
1621
|
+
return isinstance(value, dict) and all(isinstance(key, str) for key in value)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _iter_profile_mcp_servers(data: dict[str, object]) -> list[dict[str, object]]:
|
|
1625
|
+
raw_tools = data.get("tools")
|
|
1626
|
+
if not _is_string_object_dict(raw_tools):
|
|
1627
|
+
return []
|
|
1628
|
+
tools = raw_tools
|
|
1629
|
+
servers = tools.get("mcp")
|
|
1630
|
+
if not isinstance(servers, list):
|
|
1631
|
+
return []
|
|
1632
|
+
return [server for server in servers if _is_string_object_dict(server)]
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _redact_agent_profile_secrets(data: dict[str, object]) -> dict[str, object]:
|
|
1636
|
+
"""Mask MCP and ACP launch secrets, mirroring model-profile api_key masking."""
|
|
1637
|
+
for server in _iter_profile_mcp_servers(data):
|
|
1638
|
+
for field_name in ("headers", "env"):
|
|
1639
|
+
mapping = server.get(field_name)
|
|
1640
|
+
if isinstance(mapping, dict):
|
|
1641
|
+
server[field_name] = {key: (_MASK if value else value) for key, value in mapping.items()}
|
|
1642
|
+
raw_acp = data.get("acp")
|
|
1643
|
+
if _is_string_object_dict(raw_acp):
|
|
1644
|
+
acp = raw_acp
|
|
1645
|
+
env = acp.get("env")
|
|
1646
|
+
if isinstance(env, dict):
|
|
1647
|
+
acp["env"] = dict.fromkeys(env, _MASK)
|
|
1648
|
+
args = acp.get("args")
|
|
1649
|
+
if isinstance(args, list):
|
|
1650
|
+
acp["args"] = [_MASK for _value in args]
|
|
1651
|
+
options = acp.get("config_options")
|
|
1652
|
+
if isinstance(options, dict):
|
|
1653
|
+
acp["config_options"] = {
|
|
1654
|
+
key: (_MASK if isinstance(value, str) else value) for key, value in options.items()
|
|
1655
|
+
}
|
|
1656
|
+
return data
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _restore_masked_mcp_secrets(data: dict[str, object], existing: AgentProfile) -> None:
|
|
1660
|
+
"""Replace masked ("***") MCP header/env values with the stored originals."""
|
|
1661
|
+
prior_by_name = {server.name: server for server in existing.tools.mcp}
|
|
1662
|
+
for server in _iter_profile_mcp_servers(data):
|
|
1663
|
+
prior = prior_by_name.get(server.get("name"))
|
|
1664
|
+
if prior is None:
|
|
1665
|
+
continue
|
|
1666
|
+
_restore_masked_values(server.get("headers"), prior.headers)
|
|
1667
|
+
_restore_masked_values(server.get("env"), prior.env)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _restore_masked_values(incoming: object, prior: dict[str, str]) -> None:
|
|
1671
|
+
if not _is_string_object_dict(incoming):
|
|
1672
|
+
return
|
|
1673
|
+
incoming_values = incoming
|
|
1674
|
+
for key, value in incoming_values.items():
|
|
1675
|
+
if value == _MASK and key in prior:
|
|
1676
|
+
incoming_values[key] = prior[key]
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
@dataclass(frozen=True)
|
|
1680
|
+
class _AcpMaskRestoration:
|
|
1681
|
+
"""Which masked ACP fields were restored from the stored profile.
|
|
1682
|
+
|
|
1683
|
+
The unresolved-mask rejection must skip restored keys: a restored value can
|
|
1684
|
+
legitimately EQUAL the mask when the stored secret is the literal ``***``.
|
|
1685
|
+
"""
|
|
1686
|
+
|
|
1687
|
+
args_restored: bool = False
|
|
1688
|
+
args_were_masked: bool = False
|
|
1689
|
+
"""True when the stored profile has a non-empty args list, i.e. the read
|
|
1690
|
+
that produced this write masked every element — any leftover mask in an
|
|
1691
|
+
edited list is then a placeholder, never literal user input."""
|
|
1692
|
+
env_keys: frozenset[str] = frozenset()
|
|
1693
|
+
option_keys: frozenset[str] = frozenset()
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _restore_masked_acp_secrets(data: dict[str, object], existing: AgentProfile) -> _AcpMaskRestoration:
|
|
1697
|
+
"""Restore masks only where ACP fields retain an unambiguous identity."""
|
|
1698
|
+
raw_incoming = data.get("acp")
|
|
1699
|
+
prior = existing.acp
|
|
1700
|
+
if not _is_string_object_dict(raw_incoming) or prior is None:
|
|
1701
|
+
return _AcpMaskRestoration()
|
|
1702
|
+
incoming = raw_incoming
|
|
1703
|
+
env_keys: set[str] = set()
|
|
1704
|
+
raw_env = incoming.get("env")
|
|
1705
|
+
if _is_string_object_dict(raw_env):
|
|
1706
|
+
env = raw_env
|
|
1707
|
+
for key, value in env.items():
|
|
1708
|
+
if value == _MASK and key in prior.env:
|
|
1709
|
+
env[key] = prior.env[key]
|
|
1710
|
+
env_keys.add(key)
|
|
1711
|
+
option_keys: set[str] = set()
|
|
1712
|
+
raw_options = incoming.get("config_options")
|
|
1713
|
+
if _is_string_object_dict(raw_options):
|
|
1714
|
+
options = raw_options
|
|
1715
|
+
for key, value in options.items():
|
|
1716
|
+
prior_value = prior.config_options.get(key)
|
|
1717
|
+
if value == _MASK and isinstance(prior_value, str):
|
|
1718
|
+
options[key] = prior_value
|
|
1719
|
+
option_keys.add(key)
|
|
1720
|
+
args = incoming.get("args")
|
|
1721
|
+
args_restored = False
|
|
1722
|
+
if isinstance(args, list) and len(args) == len(prior.args) and all(value == _MASK for value in args):
|
|
1723
|
+
incoming["args"] = list(prior.args)
|
|
1724
|
+
args_restored = True
|
|
1725
|
+
return _AcpMaskRestoration(args_restored, bool(prior.args), frozenset(env_keys), frozenset(option_keys))
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _reject_unresolved_masked_mcp_secrets(data: dict[str, object]) -> None:
|
|
1729
|
+
for server in _iter_profile_mcp_servers(data):
|
|
1730
|
+
name = server.get("name") or "<unnamed>"
|
|
1731
|
+
for field_name in ("headers", "env"):
|
|
1732
|
+
mapping = server.get(field_name)
|
|
1733
|
+
if not isinstance(mapping, dict):
|
|
1734
|
+
continue
|
|
1735
|
+
for key, value in mapping.items():
|
|
1736
|
+
if value == _MASK:
|
|
1737
|
+
msg = (
|
|
1738
|
+
f"MCP server {name!r} {field_name}.{key} is still masked; "
|
|
1739
|
+
"re-enter the secret after renaming or adding the server."
|
|
1740
|
+
)
|
|
1741
|
+
raise AcpSessionError(msg)
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _reject_unresolved_masked_acp_secrets(
|
|
1745
|
+
data: dict[str, object],
|
|
1746
|
+
*,
|
|
1747
|
+
restoration: _AcpMaskRestoration | None = None,
|
|
1748
|
+
) -> None:
|
|
1749
|
+
restoration = restoration or _AcpMaskRestoration()
|
|
1750
|
+
acp = data.get("acp")
|
|
1751
|
+
if not isinstance(acp, dict):
|
|
1752
|
+
return
|
|
1753
|
+
args = acp.get("args")
|
|
1754
|
+
if isinstance(args, list) and not restoration.args_restored:
|
|
1755
|
+
msg = (
|
|
1756
|
+
"ACP args still contain masked values; after editing the positional list, "
|
|
1757
|
+
"resubmit the complete unmasked argument list."
|
|
1758
|
+
)
|
|
1759
|
+
# After a masked read of a non-empty stored list, positional identity
|
|
1760
|
+
# is lost the moment the list is edited: ANY leftover mask (e.g.
|
|
1761
|
+
# ["***", "***", "--verbose"]) is a placeholder whose acceptance would
|
|
1762
|
+
# silently persist the literal mask over the stored secrets.
|
|
1763
|
+
if restoration.args_were_masked and any(value == _MASK for value in args):
|
|
1764
|
+
raise AcpSessionError(msg)
|
|
1765
|
+
# Without a masked read (new profile, rename, empty stored list) a
|
|
1766
|
+
# mask can only be literal user input; reject only the fully-masked
|
|
1767
|
+
# placeholder shape.
|
|
1768
|
+
if args and all(value == _MASK for value in args):
|
|
1769
|
+
raise AcpSessionError(msg)
|
|
1770
|
+
for field_name, restored_keys in (("env", restoration.env_keys), ("config_options", restoration.option_keys)):
|
|
1771
|
+
mapping = acp.get(field_name)
|
|
1772
|
+
if not isinstance(mapping, dict):
|
|
1773
|
+
continue
|
|
1774
|
+
for key, value in mapping.items():
|
|
1775
|
+
if value == _MASK and key not in restored_keys:
|
|
1776
|
+
msg = f"ACP {field_name}.{key} is still masked; re-enter the secret after renaming or adding it."
|
|
1777
|
+
raise AcpSessionError(msg)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _safe_profile_file_stem(value: object, *, field_name: str) -> str:
|
|
1781
|
+
if not isinstance(value, str):
|
|
1782
|
+
msg = f"{field_name} must be a string."
|
|
1783
|
+
raise AcpSessionError(msg)
|
|
1784
|
+
stem = value.strip()
|
|
1785
|
+
if not stem:
|
|
1786
|
+
msg = f"{field_name} is required."
|
|
1787
|
+
raise AcpSessionError(msg)
|
|
1788
|
+
if not is_filename_safe_profile_name(stem):
|
|
1789
|
+
msg = f"{field_name} must be a filename-safe value, not a path."
|
|
1790
|
+
raise AcpSessionError(msg)
|
|
1791
|
+
return stem
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _model_profile_info(profile: ModelProfile) -> dict[str, object]:
|
|
1795
|
+
return {
|
|
1796
|
+
"id": profile.id,
|
|
1797
|
+
"name": profile.name,
|
|
1798
|
+
"provider": profile.provider,
|
|
1799
|
+
"apiStyle": profile.api_style,
|
|
1800
|
+
"modelId": profile.model_id,
|
|
1801
|
+
"maxContextTokens": profile.max_context_tokens,
|
|
1802
|
+
"stream": profile.stream,
|
|
1803
|
+
"vision": profile.vision,
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def jsonable_dataclass(value: object) -> dict[Any, Any]:
|
|
1808
|
+
if is_dataclass(value):
|
|
1809
|
+
if isinstance(value, type):
|
|
1810
|
+
# Match dataclasses.asdict's existing class-vs-instance failure.
|
|
1811
|
+
raise TypeError("asdict() should be called on dataclass instances")
|
|
1812
|
+
return asdict(value)
|
|
1813
|
+
if isinstance(value, dict):
|
|
1814
|
+
return value
|
|
1815
|
+
return {}
|