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,1468 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Async subprocess utilities.
|
|
4
|
+
|
|
5
|
+
Provides ``managed_subprocess`` for proper transport cleanup on Windows
|
|
6
|
+
(CPython issue #43884) and ``decode_subprocess_output`` for robust
|
|
7
|
+
decoding of subprocess byte output with a strong UTF-8 preference.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import codecs
|
|
14
|
+
import contextlib
|
|
15
|
+
import ctypes
|
|
16
|
+
import errno
|
|
17
|
+
import functools
|
|
18
|
+
import ntpath
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import AsyncIterator, Awaitable, Iterable, Iterator
|
|
25
|
+
from ctypes import wintypes
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Never, cast
|
|
28
|
+
|
|
29
|
+
from chrys.foundation.platform.c_api import declare_functions, struct_fields
|
|
30
|
+
from chrys.foundation.platform.child_reap import install_stopped_child_reap_fix
|
|
31
|
+
from chrys.foundation.text.encoding import decode_bytes, is_mostly_text
|
|
32
|
+
|
|
33
|
+
# Every subprocess in the app is spawned through this module, and the shim has to
|
|
34
|
+
# be in place before the first one starts, so install it on import. See
|
|
35
|
+
# chrys.foundation.platform.child_reap for what it works around.
|
|
36
|
+
install_stopped_child_reap_fix()
|
|
37
|
+
|
|
38
|
+
_CP_UTF8 = 65001
|
|
39
|
+
_STOPPED_PROCESS_POLL_INTERVAL = 1.0
|
|
40
|
+
_PROCESS_WAIT_CLEANUP_TIMEOUT = 2.0
|
|
41
|
+
_WINDOWS_TREE_KILL_TIMEOUT = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SubprocessStoppedError(RuntimeError):
|
|
45
|
+
"""Raised when a POSIX subprocess group/session enters job-control stopped state."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingWorkingDirectoryError(OSError):
|
|
49
|
+
"""A child could not start because its working directory no longer exists.
|
|
50
|
+
|
|
51
|
+
A spawn into a deleted cwd fails with the same ``FileNotFoundError`` as a
|
|
52
|
+
missing executable on POSIX (``NotADirectoryError``/``WinError 267`` on
|
|
53
|
+
Windows). This is deliberately NOT a ``FileNotFoundError`` subclass, so
|
|
54
|
+
"executable not found" handlers do not swallow it.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str) -> None:
|
|
58
|
+
super().__init__(errno.ENOENT, "working directory no longer exists", path)
|
|
59
|
+
self.path = path
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return f"working directory no longer exists: {self.path}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def raise_if_missing_cwd(cwd: object, error: OSError) -> None:
|
|
66
|
+
"""Re-raise a failed spawn as :class:`MissingWorkingDirectoryError` when *cwd* is gone.
|
|
67
|
+
|
|
68
|
+
Called from an ``except OSError`` around a spawn. Checking the directory
|
|
69
|
+
after the failure covers both platforms without parsing errno, winerror
|
|
70
|
+
or ``filename``; when *cwd* still exists this returns and the caller
|
|
71
|
+
re-raises the original error.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(error, MissingWorkingDirectoryError) or not isinstance(cwd, str | os.PathLike):
|
|
74
|
+
return
|
|
75
|
+
path = os.fspath(cwd)
|
|
76
|
+
if isinstance(path, str) and path and not os.path.isdir(path):
|
|
77
|
+
raise MissingWorkingDirectoryError(path) from error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class _ProcessStatus:
|
|
82
|
+
pid: int
|
|
83
|
+
state: str
|
|
84
|
+
pgid: int
|
|
85
|
+
session_id: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class _WindowsProcessAPI:
|
|
90
|
+
"""Typed handle for the lazily loaded Win32 process API."""
|
|
91
|
+
|
|
92
|
+
kernel32: ctypes.CDLL
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _StartupInfoW(ctypes.Structure):
|
|
96
|
+
"""``STARTUPINFOW``."""
|
|
97
|
+
|
|
98
|
+
_fields_ = struct_fields(
|
|
99
|
+
(wintypes.DWORD, "cb"),
|
|
100
|
+
(wintypes.LPWSTR, "lpReserved lpDesktop lpTitle"),
|
|
101
|
+
(wintypes.DWORD, "dwX dwY dwXSize dwYSize dwXCountChars dwYCountChars dwFillAttribute dwFlags"),
|
|
102
|
+
(wintypes.WORD, "wShowWindow cbReserved2"),
|
|
103
|
+
(ctypes.POINTER(ctypes.c_ubyte), "lpReserved2"),
|
|
104
|
+
(wintypes.HANDLE, "hStdInput hStdOutput hStdError"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _StartupInfoExW(ctypes.Structure):
|
|
109
|
+
"""``STARTUPINFOEXW``: the startup info plus a process/thread attribute list."""
|
|
110
|
+
|
|
111
|
+
_fields_ = struct_fields((_StartupInfoW, "StartupInfo"), (wintypes.LPVOID, "lpAttributeList"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _ProcessInformation(ctypes.Structure):
|
|
115
|
+
"""``PROCESS_INFORMATION``, filled in by ``CreateProcessW``."""
|
|
116
|
+
|
|
117
|
+
_fields_ = struct_fields((wintypes.HANDLE, "hProcess hThread"), (wintypes.DWORD, "dwProcessId dwThreadId"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _IoCounters(ctypes.Structure):
|
|
121
|
+
"""``IO_COUNTERS``."""
|
|
122
|
+
|
|
123
|
+
_fields_ = struct_fields(
|
|
124
|
+
(ctypes.c_ulonglong, "ReadOperationCount WriteOperationCount OtherOperationCount"),
|
|
125
|
+
(ctypes.c_ulonglong, "ReadTransferCount WriteTransferCount OtherTransferCount"),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _BasicLimitInformation(ctypes.Structure):
|
|
130
|
+
"""``JOBOBJECT_BASIC_LIMIT_INFORMATION``."""
|
|
131
|
+
|
|
132
|
+
_fields_ = struct_fields(
|
|
133
|
+
(ctypes.c_longlong, "PerProcessUserTimeLimit PerJobUserTimeLimit"),
|
|
134
|
+
(wintypes.DWORD, "LimitFlags"),
|
|
135
|
+
(ctypes.c_size_t, "MinimumWorkingSetSize MaximumWorkingSetSize"),
|
|
136
|
+
(wintypes.DWORD, "ActiveProcessLimit"),
|
|
137
|
+
(ctypes.c_size_t, "Affinity"),
|
|
138
|
+
(wintypes.DWORD, "PriorityClass SchedulingClass"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _ExtendedLimitInformation(ctypes.Structure):
|
|
143
|
+
"""``JOBOBJECT_EXTENDED_LIMIT_INFORMATION``, the job's kill-on-close setting among them."""
|
|
144
|
+
|
|
145
|
+
_fields_ = struct_fields(
|
|
146
|
+
(_BasicLimitInformation, "BasicLimitInformation"),
|
|
147
|
+
(_IoCounters, "IoInfo"),
|
|
148
|
+
(ctypes.c_size_t, "ProcessMemoryLimit JobMemoryLimit PeakProcessMemoryUsed PeakJobMemoryUsed"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@functools.cache
|
|
153
|
+
def _windows_uses_utf8() -> bool:
|
|
154
|
+
"""Return True if the Windows console output code page is UTF-8.
|
|
155
|
+
|
|
156
|
+
Uses ``GetConsoleOutputCP`` (the OEM output code page) rather than
|
|
157
|
+
``locale.getencoding`` (the ANSI code page) because subprocess
|
|
158
|
+
output follows the console code page, not the ANSI one.
|
|
159
|
+
"""
|
|
160
|
+
if sys.platform != "win32":
|
|
161
|
+
return True
|
|
162
|
+
try:
|
|
163
|
+
return ctypes.windll.kernel32.GetConsoleOutputCP() == _CP_UTF8
|
|
164
|
+
except AttributeError, OSError:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@functools.cache
|
|
169
|
+
def _windows_console_encoding() -> str | None:
|
|
170
|
+
"""Return the Python codec name for the Windows console output code page.
|
|
171
|
+
|
|
172
|
+
E.g. ``"cp936"`` for GBK (Simplified Chinese), ``"cp932"`` for
|
|
173
|
+
Shift-JIS (Japanese), ``"cp949"`` for EUC-KR (Korean).
|
|
174
|
+
|
|
175
|
+
Returns ``None`` on non-Windows platforms, when the code page is
|
|
176
|
+
UTF-8, or when the code page cannot be determined.
|
|
177
|
+
"""
|
|
178
|
+
if sys.platform != "win32":
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
|
182
|
+
if cp == _CP_UTF8 or cp == 0:
|
|
183
|
+
return None
|
|
184
|
+
return f"cp{cp}"
|
|
185
|
+
except AttributeError, OSError:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_CREATE_NEW_CONSOLE = 0x00000010
|
|
190
|
+
_WINDOWS_BATCH_SUFFIXES = {".bat", ".cmd"}
|
|
191
|
+
_WINDOWS_BATCH_FORBIDDEN = {'"', "%", "!", "^", "\r", "\n"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def windows_hidden_subprocess_kwargs() -> dict[str, Any]:
|
|
195
|
+
"""Return subprocess kwargs that spawn a hidden child with its *own* console.
|
|
196
|
+
|
|
197
|
+
Intended for synchronous ``subprocess.run`` callers — unpack with
|
|
198
|
+
``**windows_hidden_subprocess_kwargs()``. Returns ``{}`` off Windows.
|
|
199
|
+
|
|
200
|
+
On Windows we pair:
|
|
201
|
+
|
|
202
|
+
- ``creationflags=CREATE_NEW_CONSOLE`` — the child gets its own
|
|
203
|
+
console, so it cannot call ``SetConsoleMode()`` on the parent's
|
|
204
|
+
console (which would reset mouse / keyboard flags and break the
|
|
205
|
+
TUI). ``CREATE_NO_WINDOW`` is **not** safe here: it makes the
|
|
206
|
+
child share the parent's console.
|
|
207
|
+
- ``startupinfo`` with ``STARTF_USESHOWWINDOW | SW_HIDE`` — suppress
|
|
208
|
+
the new console window so it doesn't flash on screen.
|
|
209
|
+
"""
|
|
210
|
+
if sys.platform != "win32":
|
|
211
|
+
return {}
|
|
212
|
+
import subprocess as _sp
|
|
213
|
+
|
|
214
|
+
si = _sp.STARTUPINFO()
|
|
215
|
+
si.dwFlags |= _sp.STARTF_USESHOWWINDOW
|
|
216
|
+
si.wShowWindow = _sp.SW_HIDE
|
|
217
|
+
return {"creationflags": _CREATE_NEW_CONSOLE, "startupinfo": si}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_windows_hidden_subprocess_kwargs = windows_hidden_subprocess_kwargs
|
|
221
|
+
"""Backward-compatible alias for callers predating the public helper."""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def finalize_subprocess_transport(proc: asyncio.subprocess.Process) -> None:
|
|
225
|
+
"""Close an asyncio subprocess transport, including Proactor pipe transports."""
|
|
226
|
+
transport = getattr(proc, "_transport", None)
|
|
227
|
+
if transport is not None:
|
|
228
|
+
with contextlib.suppress(OSError):
|
|
229
|
+
transport.close()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_windows_comspec(parent_env: dict[str, str] | None = None) -> str:
|
|
233
|
+
"""Resolve a trusted absolute ``cmd.exe`` from the parent environment."""
|
|
234
|
+
source = os.environ if parent_env is None else parent_env
|
|
235
|
+
casefolded = {key.casefold(): value for key, value in source.items()}
|
|
236
|
+
candidate = casefolded.get("comspec", "")
|
|
237
|
+
if (
|
|
238
|
+
type(candidate) is str
|
|
239
|
+
and ntpath.isabs(candidate)
|
|
240
|
+
and not any(character in candidate for character in _WINDOWS_BATCH_FORBIDDEN | {"\x00"})
|
|
241
|
+
):
|
|
242
|
+
return ntpath.normpath(candidate)
|
|
243
|
+
system_root = casefolded.get("systemroot", r"C:\Windows")
|
|
244
|
+
if (
|
|
245
|
+
type(system_root) is not str
|
|
246
|
+
or not ntpath.isabs(system_root)
|
|
247
|
+
or any(character in system_root for character in _WINDOWS_BATCH_FORBIDDEN | {"\x00"})
|
|
248
|
+
):
|
|
249
|
+
system_root = r"C:\Windows"
|
|
250
|
+
return ntpath.normpath(ntpath.join(system_root, "System32", "cmd.exe"))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def serialize_windows_batch_command(shim_path: str, args: Iterable[str]) -> str:
|
|
254
|
+
"""Serialize the representable subset accepted by the ACP batch shim boundary."""
|
|
255
|
+
values = [shim_path, *args]
|
|
256
|
+
for value in values:
|
|
257
|
+
if type(value) is not str:
|
|
258
|
+
raise TypeError("Windows batch command values must be exact strings.")
|
|
259
|
+
if "\x00" in value:
|
|
260
|
+
raise ValueError("Windows batch command values cannot contain NUL.")
|
|
261
|
+
if any(character in value for character in _WINDOWS_BATCH_FORBIDDEN):
|
|
262
|
+
raise ValueError("Windows batch command contains characters that cannot be represented safely.")
|
|
263
|
+
if value.endswith("\\"):
|
|
264
|
+
raise ValueError("Windows batch command cannot safely represent a trailing backslash.")
|
|
265
|
+
if not ntpath.isabs(shim_path):
|
|
266
|
+
raise ValueError("Windows batch shim path must be absolute.")
|
|
267
|
+
return " ".join(f'"{value}"' for value in values)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class ManagedStdioProcess:
|
|
272
|
+
"""Owned subprocess with real asyncio streams and process-tree termination."""
|
|
273
|
+
|
|
274
|
+
stdin: asyncio.StreamWriter
|
|
275
|
+
stdout: asyncio.StreamReader
|
|
276
|
+
stderr: asyncio.StreamReader
|
|
277
|
+
pid: int
|
|
278
|
+
process_group_id: int | None = None
|
|
279
|
+
_process: asyncio.subprocess.Process | None = None
|
|
280
|
+
_windows_process_handle: int | None = None
|
|
281
|
+
_windows_job_handle: int | None = None
|
|
282
|
+
_windows_returncode: int | None = None
|
|
283
|
+
_windows_stdout_transport: asyncio.BaseTransport | None = None
|
|
284
|
+
_windows_stderr_transport: asyncio.BaseTransport | None = None
|
|
285
|
+
_windows_wait_task: asyncio.Task[int] | None = None
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def returncode(self) -> int | None:
|
|
289
|
+
if self._process is not None:
|
|
290
|
+
return self._process.returncode
|
|
291
|
+
return self._windows_returncode
|
|
292
|
+
|
|
293
|
+
async def wait(self) -> int:
|
|
294
|
+
"""Wait for the retained process and return its exit code."""
|
|
295
|
+
if self._process is not None:
|
|
296
|
+
return await self._process.wait()
|
|
297
|
+
if self._windows_returncode is not None:
|
|
298
|
+
return self._windows_returncode
|
|
299
|
+
waiter = self._windows_wait_task
|
|
300
|
+
if waiter is None:
|
|
301
|
+
if self._windows_process_handle is None:
|
|
302
|
+
raise RuntimeError("Windows process handle has already been released.")
|
|
303
|
+
# One registered wait per process: the loop's completion port completes
|
|
304
|
+
# it when the process exits, with no thread blocked in WaitForSingleObject,
|
|
305
|
+
# and every wait() call awaits that same task.
|
|
306
|
+
waiter = asyncio.create_task(
|
|
307
|
+
_windows_process_exit(self._windows_process_handle),
|
|
308
|
+
name="chrys.platform.windows-process-wait",
|
|
309
|
+
)
|
|
310
|
+
waiter.add_done_callback(_consume_task_exception)
|
|
311
|
+
self._windows_wait_task = waiter
|
|
312
|
+
# asyncio.wait never cancels the watched task: a caller-scoped timeout
|
|
313
|
+
# interrupts this wait() while the registered wait stays put for the
|
|
314
|
+
# next caller to resume awaiting.
|
|
315
|
+
await asyncio.wait({waiter})
|
|
316
|
+
self._windows_returncode = waiter.result()
|
|
317
|
+
return self._windows_returncode
|
|
318
|
+
|
|
319
|
+
def close_stdin(self) -> None:
|
|
320
|
+
"""Send EOF to the child without waiting for its process exit."""
|
|
321
|
+
try:
|
|
322
|
+
self.stdin.write_eof()
|
|
323
|
+
except AttributeError, NotImplementedError, OSError, RuntimeError:
|
|
324
|
+
self.stdin.close()
|
|
325
|
+
|
|
326
|
+
def terminate_tree(self) -> None:
|
|
327
|
+
"""Terminate the owned process tree using retained identities only."""
|
|
328
|
+
if self._process is not None:
|
|
329
|
+
if self.process_group_id is not None:
|
|
330
|
+
terminate_process_group(self.process_group_id)
|
|
331
|
+
elif self._process.returncode is None:
|
|
332
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
333
|
+
self._process.terminate()
|
|
334
|
+
return
|
|
335
|
+
_terminate_windows_retained_process(self._windows_job_handle, self._windows_process_handle, exit_code=1)
|
|
336
|
+
|
|
337
|
+
def kill_tree(self) -> None:
|
|
338
|
+
"""Kill the owned process tree using retained identities only."""
|
|
339
|
+
if self._process is not None:
|
|
340
|
+
if self.process_group_id is not None:
|
|
341
|
+
kill_process_group(self.process_group_id)
|
|
342
|
+
elif self._process.returncode is None:
|
|
343
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
344
|
+
self._process.kill()
|
|
345
|
+
return
|
|
346
|
+
_terminate_windows_retained_process(self._windows_job_handle, self._windows_process_handle, exit_code=9)
|
|
347
|
+
|
|
348
|
+
def close_transports(self) -> None:
|
|
349
|
+
"""Close owned stream transports and release retained process handles."""
|
|
350
|
+
self.stdin.close()
|
|
351
|
+
if self._process is not None:
|
|
352
|
+
finalize_subprocess_transport(self._process)
|
|
353
|
+
return
|
|
354
|
+
if self._windows_stdout_transport is not None:
|
|
355
|
+
self._windows_stdout_transport.close()
|
|
356
|
+
self._windows_stdout_transport = None
|
|
357
|
+
if self._windows_stderr_transport is not None:
|
|
358
|
+
self._windows_stderr_transport.close()
|
|
359
|
+
self._windows_stderr_transport = None
|
|
360
|
+
process_handle = self._windows_process_handle
|
|
361
|
+
job_handle = self._windows_job_handle
|
|
362
|
+
self._windows_process_handle = None
|
|
363
|
+
self._windows_job_handle = None
|
|
364
|
+
# The job handle is never waited on and KILL_ON_JOB_CLOSE makes closing
|
|
365
|
+
# it the final kill switch, so it always closes inline. The process
|
|
366
|
+
# handle may still be registered with the completion port — closing a
|
|
367
|
+
# handle under a registered wait is undefined — so its close waits for
|
|
368
|
+
# the wait task to finish: the proactor unregisters the wait before the
|
|
369
|
+
# task can complete, whether the process exited or loop shutdown
|
|
370
|
+
# cancelled the task. A wait the loop never resumes (loop closed) leaks
|
|
371
|
+
# the handle until process exit; leak beats undefined.
|
|
372
|
+
_close_windows_handle(job_handle)
|
|
373
|
+
waiter = self._windows_wait_task
|
|
374
|
+
if waiter is None or waiter.done():
|
|
375
|
+
_close_windows_handle(process_handle)
|
|
376
|
+
return
|
|
377
|
+
waiter.add_done_callback(lambda _task: _close_windows_handle(process_handle))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def spawn_managed_stdio_process(
|
|
381
|
+
command: str,
|
|
382
|
+
*args: str,
|
|
383
|
+
cwd: str,
|
|
384
|
+
env: dict[str, str],
|
|
385
|
+
limit: int,
|
|
386
|
+
parent_env: dict[str, str] | None = None,
|
|
387
|
+
) -> ManagedStdioProcess:
|
|
388
|
+
"""Spawn an owned stdio child with POSIX-session or Windows-Job semantics."""
|
|
389
|
+
if type(command) is not str or type(cwd) is not str:
|
|
390
|
+
raise TypeError("Subprocess command and cwd must be exact strings.")
|
|
391
|
+
if "\x00" in command or "\x00" in cwd:
|
|
392
|
+
raise ValueError("Subprocess command and cwd cannot contain NUL.")
|
|
393
|
+
if any(type(arg) is not str for arg in args):
|
|
394
|
+
raise TypeError("Subprocess arguments must be exact strings.")
|
|
395
|
+
if any("\x00" in arg for arg in args):
|
|
396
|
+
raise ValueError("Subprocess arguments cannot contain NUL.")
|
|
397
|
+
|
|
398
|
+
from chrys.foundation.platform import get_platform
|
|
399
|
+
|
|
400
|
+
if get_platform().is_windows:
|
|
401
|
+
try:
|
|
402
|
+
return await _spawn_windows_managed_stdio_process(
|
|
403
|
+
command,
|
|
404
|
+
args,
|
|
405
|
+
cwd=cwd,
|
|
406
|
+
env=env,
|
|
407
|
+
limit=limit,
|
|
408
|
+
parent_env=parent_env,
|
|
409
|
+
)
|
|
410
|
+
except OSError as exc:
|
|
411
|
+
raise_if_missing_cwd(cwd, exc)
|
|
412
|
+
raise
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
process = await asyncio.create_subprocess_exec(
|
|
416
|
+
command,
|
|
417
|
+
*args,
|
|
418
|
+
stdin=asyncio.subprocess.PIPE,
|
|
419
|
+
stdout=asyncio.subprocess.PIPE,
|
|
420
|
+
stderr=asyncio.subprocess.PIPE,
|
|
421
|
+
cwd=cwd,
|
|
422
|
+
env=env,
|
|
423
|
+
limit=limit,
|
|
424
|
+
start_new_session=True,
|
|
425
|
+
)
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
raise_if_missing_cwd(cwd, exc)
|
|
428
|
+
raise
|
|
429
|
+
if process.stdin is None or process.stdout is None or process.stderr is None or process.pid is None:
|
|
430
|
+
with contextlib.suppress(ProcessLookupError):
|
|
431
|
+
process.kill()
|
|
432
|
+
await process.wait()
|
|
433
|
+
raise RuntimeError("Managed subprocess did not expose complete stdio pipes.")
|
|
434
|
+
return ManagedStdioProcess(
|
|
435
|
+
stdin=process.stdin,
|
|
436
|
+
stdout=process.stdout,
|
|
437
|
+
stderr=process.stderr,
|
|
438
|
+
pid=process.pid,
|
|
439
|
+
process_group_id=process.pid,
|
|
440
|
+
_process=process,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def terminate_process_group(process_group_id: int) -> bool:
|
|
445
|
+
"""Send SIGTERM to a POSIX process group without signaling Chrys's own group."""
|
|
446
|
+
from chrys.foundation.platform import get_platform
|
|
447
|
+
|
|
448
|
+
if get_platform().is_windows or process_group_id <= 0:
|
|
449
|
+
return False
|
|
450
|
+
posix_os = cast(Any, os)
|
|
451
|
+
with contextlib.suppress(OSError):
|
|
452
|
+
if process_group_id == posix_os.getpgrp():
|
|
453
|
+
return False
|
|
454
|
+
try:
|
|
455
|
+
posix_os.killpg(process_group_id, signal.SIGTERM)
|
|
456
|
+
except ProcessLookupError, PermissionError, OSError:
|
|
457
|
+
return False
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@functools.cache
|
|
462
|
+
def _windows_process_api() -> _WindowsProcessAPI:
|
|
463
|
+
"""Load the Win32 process, Job Object, and attribute-list functions once."""
|
|
464
|
+
kernel32 = cast(Any, ctypes).WinDLL("kernel32", use_last_error=True)
|
|
465
|
+
declare_functions(
|
|
466
|
+
kernel32,
|
|
467
|
+
{
|
|
468
|
+
"CreateJobObjectW": (wintypes.HANDLE, [wintypes.LPVOID, wintypes.LPCWSTR]),
|
|
469
|
+
"SetInformationJobObject": (
|
|
470
|
+
wintypes.BOOL,
|
|
471
|
+
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD],
|
|
472
|
+
),
|
|
473
|
+
"InitializeProcThreadAttributeList": (
|
|
474
|
+
wintypes.BOOL,
|
|
475
|
+
[wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)],
|
|
476
|
+
),
|
|
477
|
+
# List, flags, attribute, value and its size, then the reserved previous-value and return-size slots.
|
|
478
|
+
"UpdateProcThreadAttribute": (
|
|
479
|
+
wintypes.BOOL,
|
|
480
|
+
[
|
|
481
|
+
wintypes.LPVOID,
|
|
482
|
+
wintypes.DWORD,
|
|
483
|
+
ctypes.c_size_t,
|
|
484
|
+
wintypes.LPVOID,
|
|
485
|
+
ctypes.c_size_t,
|
|
486
|
+
wintypes.LPVOID,
|
|
487
|
+
wintypes.LPVOID,
|
|
488
|
+
],
|
|
489
|
+
),
|
|
490
|
+
"DeleteProcThreadAttributeList": (None, [wintypes.LPVOID]),
|
|
491
|
+
"CreateProcessW": (
|
|
492
|
+
wintypes.BOOL,
|
|
493
|
+
[
|
|
494
|
+
wintypes.LPCWSTR,
|
|
495
|
+
wintypes.LPWSTR,
|
|
496
|
+
wintypes.LPVOID,
|
|
497
|
+
wintypes.LPVOID,
|
|
498
|
+
wintypes.BOOL,
|
|
499
|
+
wintypes.DWORD,
|
|
500
|
+
wintypes.LPVOID,
|
|
501
|
+
wintypes.LPCWSTR,
|
|
502
|
+
ctypes.POINTER(_StartupInfoExW),
|
|
503
|
+
ctypes.POINTER(_ProcessInformation),
|
|
504
|
+
],
|
|
505
|
+
),
|
|
506
|
+
"SetHandleInformation": (wintypes.BOOL, [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD]),
|
|
507
|
+
"TerminateJobObject": (wintypes.BOOL, [wintypes.HANDLE, wintypes.UINT]),
|
|
508
|
+
"TerminateProcess": (wintypes.BOOL, [wintypes.HANDLE, wintypes.UINT]),
|
|
509
|
+
"GetExitCodeProcess": (wintypes.BOOL, [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]),
|
|
510
|
+
"CloseHandle": (wintypes.BOOL, [wintypes.HANDLE]),
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
return _WindowsProcessAPI(kernel32=kernel32)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _raise_windows_process_error(api: _WindowsProcessAPI, message: str) -> None:
|
|
517
|
+
error = cast(Any, ctypes).get_last_error()
|
|
518
|
+
raise OSError(error, message, None, error)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _windows_create_job(api: _WindowsProcessAPI) -> int:
|
|
522
|
+
job = api.kernel32.CreateJobObjectW(None, None)
|
|
523
|
+
if not job:
|
|
524
|
+
_raise_windows_process_error(api, "Unable to create a Windows Job Object.")
|
|
525
|
+
info = _ExtendedLimitInformation()
|
|
526
|
+
info.BasicLimitInformation.LimitFlags = 0x00002000
|
|
527
|
+
if not api.kernel32.SetInformationJobObject(
|
|
528
|
+
job,
|
|
529
|
+
9,
|
|
530
|
+
ctypes.byref(info),
|
|
531
|
+
ctypes.sizeof(info),
|
|
532
|
+
):
|
|
533
|
+
api.kernel32.CloseHandle(job)
|
|
534
|
+
_raise_windows_process_error(api, "Unable to configure a Windows Job Object.")
|
|
535
|
+
return int(job)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _windows_environment_block(env: dict[str, str]) -> object:
|
|
539
|
+
values = [f"{key}={value}" for key, value in sorted(env.items(), key=lambda item: item[0].casefold())]
|
|
540
|
+
return ctypes.create_unicode_buffer("\x00".join(values) + "\x00\x00")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
_WINDOWS_DEFAULT_PATHEXT = (".COM", ".EXE", ".BAT", ".CMD")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _windows_resolve_application(command: str, env: dict[str, str], cwd: str) -> str:
|
|
547
|
+
"""Resolve a Windows command against the child environment only.
|
|
548
|
+
|
|
549
|
+
``shutil.which`` is deliberately avoided: on Windows it prepends the
|
|
550
|
+
*parent* process's current directory and reads ``PATHEXT`` from the parent
|
|
551
|
+
environment, either of which can resolve a different program than the
|
|
552
|
+
child's configured ``PATH`` names.
|
|
553
|
+
"""
|
|
554
|
+
if ntpath.dirname(command):
|
|
555
|
+
# Never ntpath.abspath here: on Windows it resolves drive-relative
|
|
556
|
+
# paths ("C:tools\\agent.exe") through the PARENT process's per-drive
|
|
557
|
+
# cwd (_getfullpathname). ntpath.join anchors same-drive and
|
|
558
|
+
# driveless paths at the child cwd; a cross-drive drive-relative
|
|
559
|
+
# result stays non-absolute and the spawn path rejects it.
|
|
560
|
+
candidate = command if ntpath.isabs(command) else ntpath.join(cwd, command)
|
|
561
|
+
return ntpath.normpath(candidate)
|
|
562
|
+
# The child env is casefold-deduplicated but keeps the base env's original
|
|
563
|
+
# key casing, so PATH/PATHEXT must be looked up case-insensitively.
|
|
564
|
+
lookup = {key.casefold(): value for key, value in env.items()}
|
|
565
|
+
raw_pathext = lookup.get("pathext", "")
|
|
566
|
+
extensions = (
|
|
567
|
+
tuple(
|
|
568
|
+
extension if extension.startswith(".") else f".{extension}"
|
|
569
|
+
for extension in (part.strip() for part in raw_pathext.split(";"))
|
|
570
|
+
if extension
|
|
571
|
+
)
|
|
572
|
+
or _WINDOWS_DEFAULT_PATHEXT
|
|
573
|
+
)
|
|
574
|
+
has_extension = bool(ntpath.splitext(command)[1])
|
|
575
|
+
for raw_directory in (lookup.get("path") or "").split(";"):
|
|
576
|
+
directory = raw_directory.strip().strip('"')
|
|
577
|
+
if not directory:
|
|
578
|
+
continue
|
|
579
|
+
# Relative PATH entries belong to the child: anchor them at the child
|
|
580
|
+
# cwd, never at the Chrys process's own current directory.
|
|
581
|
+
directory = ntpath.normpath(ntpath.join(cwd, directory))
|
|
582
|
+
if not ntpath.isabs(directory):
|
|
583
|
+
# A cross-drive drive-relative entry ("C:bin") is only resolvable
|
|
584
|
+
# through the parent's per-drive cwd state; skip it entirely so
|
|
585
|
+
# not even the isfile probe consults parent state.
|
|
586
|
+
continue
|
|
587
|
+
base = ntpath.join(directory, command)
|
|
588
|
+
candidates = (base,) if has_extension else tuple(base + extension for extension in extensions)
|
|
589
|
+
for candidate in candidates:
|
|
590
|
+
if os.path.isfile(candidate):
|
|
591
|
+
return ntpath.normpath(candidate)
|
|
592
|
+
# Unresolved bare names are returned as-is so the spawn path fails closed
|
|
593
|
+
# with a deterministic not-found error instead of searching implicit
|
|
594
|
+
# locations such as the current directory.
|
|
595
|
+
return ntpath.normpath(command)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _make_windows_pipe_pairs() -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]:
|
|
599
|
+
from asyncio import windows_utils
|
|
600
|
+
|
|
601
|
+
pairs: list[tuple[int, int]] = []
|
|
602
|
+
try:
|
|
603
|
+
while len(pairs) < 3:
|
|
604
|
+
pairs.append(cast(Any, windows_utils).pipe(duplex=True, overlapped=(True, False)))
|
|
605
|
+
except BaseException:
|
|
606
|
+
for pair in pairs:
|
|
607
|
+
for handle in pair:
|
|
608
|
+
_close_windows_handle(handle)
|
|
609
|
+
raise
|
|
610
|
+
return pairs[0], pairs[1], pairs[2]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
async def _wrap_windows_parent_pipes(
|
|
614
|
+
stdin_parent: int,
|
|
615
|
+
stdout_parent: int,
|
|
616
|
+
stderr_parent: int,
|
|
617
|
+
*,
|
|
618
|
+
limit: int,
|
|
619
|
+
) -> tuple[
|
|
620
|
+
asyncio.StreamWriter,
|
|
621
|
+
asyncio.StreamReader,
|
|
622
|
+
asyncio.StreamReader,
|
|
623
|
+
asyncio.BaseTransport,
|
|
624
|
+
asyncio.BaseTransport,
|
|
625
|
+
]:
|
|
626
|
+
from asyncio import windows_utils
|
|
627
|
+
|
|
628
|
+
loop = asyncio.get_running_loop()
|
|
629
|
+
stdin_pipe = cast(Any, windows_utils).PipeHandle(stdin_parent)
|
|
630
|
+
stdout_pipe = cast(Any, windows_utils).PipeHandle(stdout_parent)
|
|
631
|
+
stderr_pipe = cast(Any, windows_utils).PipeHandle(stderr_parent)
|
|
632
|
+
stdin_transport = None
|
|
633
|
+
stdout_transport = None
|
|
634
|
+
stderr_transport = None
|
|
635
|
+
try:
|
|
636
|
+
stdout = asyncio.StreamReader(limit=limit)
|
|
637
|
+
stdout_protocol = asyncio.StreamReaderProtocol(stdout)
|
|
638
|
+
stdout_transport, _ = await loop.connect_read_pipe(lambda: stdout_protocol, stdout_pipe)
|
|
639
|
+
|
|
640
|
+
stderr = asyncio.StreamReader(limit=limit)
|
|
641
|
+
stderr_protocol = asyncio.StreamReaderProtocol(stderr)
|
|
642
|
+
stderr_transport, _ = await loop.connect_read_pipe(lambda: stderr_protocol, stderr_pipe)
|
|
643
|
+
|
|
644
|
+
stdin_protocol = asyncio.streams.FlowControlMixin(loop=loop)
|
|
645
|
+
stdin_transport, _ = await loop.connect_write_pipe(
|
|
646
|
+
lambda: stdin_protocol,
|
|
647
|
+
stdin_pipe,
|
|
648
|
+
)
|
|
649
|
+
stdin = asyncio.StreamWriter(stdin_transport, stdin_protocol, None, loop)
|
|
650
|
+
return stdin, stdout, stderr, stdout_transport, stderr_transport
|
|
651
|
+
except BaseException:
|
|
652
|
+
for transport in (stdin_transport, stdout_transport, stderr_transport):
|
|
653
|
+
if transport is not None:
|
|
654
|
+
transport.close()
|
|
655
|
+
for pipe, transport in (
|
|
656
|
+
(stdin_pipe, stdin_transport),
|
|
657
|
+
(stdout_pipe, stdout_transport),
|
|
658
|
+
(stderr_pipe, stderr_transport),
|
|
659
|
+
):
|
|
660
|
+
if transport is None:
|
|
661
|
+
pipe.close()
|
|
662
|
+
raise
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
async def _spawn_windows_managed_stdio_process(
|
|
666
|
+
command: str,
|
|
667
|
+
args: tuple[str, ...],
|
|
668
|
+
*,
|
|
669
|
+
cwd: str,
|
|
670
|
+
env: dict[str, str],
|
|
671
|
+
limit: int,
|
|
672
|
+
parent_env: dict[str, str] | None,
|
|
673
|
+
) -> ManagedStdioProcess:
|
|
674
|
+
api = _windows_process_api()
|
|
675
|
+
job_handle = _windows_create_job(api)
|
|
676
|
+
pipe_pairs: tuple[tuple[int, int], tuple[int, int], tuple[int, int]] | None = None
|
|
677
|
+
attribute_buffer = None
|
|
678
|
+
attribute_list = None
|
|
679
|
+
process_handle: int | None = None
|
|
680
|
+
try:
|
|
681
|
+
pipe_pairs = _make_windows_pipe_pairs()
|
|
682
|
+
(stdin_parent, stdin_child), (stdout_parent, stdout_child), (stderr_parent, stderr_child) = pipe_pairs
|
|
683
|
+
HANDLE_FLAG_INHERIT = 0x00000001
|
|
684
|
+
for child_handle in (stdin_child, stdout_child, stderr_child):
|
|
685
|
+
if not api.kernel32.SetHandleInformation(child_handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT):
|
|
686
|
+
_raise_windows_process_error(api, "Unable to make a child stdio handle inheritable.")
|
|
687
|
+
for parent_handle in (stdin_parent, stdout_parent, stderr_parent):
|
|
688
|
+
if not api.kernel32.SetHandleInformation(parent_handle, HANDLE_FLAG_INHERIT, 0):
|
|
689
|
+
_raise_windows_process_error(api, "Unable to protect a parent stdio handle.")
|
|
690
|
+
|
|
691
|
+
# The child joins the job as it is created and inherits only its three stdio handles.
|
|
692
|
+
# The attribute list points into these arrays, so they stay referenced until
|
|
693
|
+
# DeleteProcThreadAttributeList in the finally below.
|
|
694
|
+
PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D
|
|
695
|
+
PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
|
|
696
|
+
job_array = (wintypes.HANDLE * 1)(job_handle)
|
|
697
|
+
handle_array = (wintypes.HANDLE * 3)(stdin_child, stdout_child, stderr_child)
|
|
698
|
+
attributes = (
|
|
699
|
+
(PROC_THREAD_ATTRIBUTE_JOB_LIST, job_array, "Unable to attach the Windows Job Object atomically."),
|
|
700
|
+
(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handle_array, "Unable to restrict inherited Windows handles."),
|
|
701
|
+
)
|
|
702
|
+
size = ctypes.c_size_t()
|
|
703
|
+
api.kernel32.InitializeProcThreadAttributeList(None, len(attributes), 0, ctypes.byref(size))
|
|
704
|
+
attribute_buffer = ctypes.create_string_buffer(size.value)
|
|
705
|
+
candidate = ctypes.cast(attribute_buffer, wintypes.LPVOID)
|
|
706
|
+
if not api.kernel32.InitializeProcThreadAttributeList(candidate, len(attributes), 0, ctypes.byref(size)):
|
|
707
|
+
_raise_windows_process_error(api, "Unable to initialize Windows process attributes.")
|
|
708
|
+
# Named only once initialized: the finally below deletes whatever attribute_list names.
|
|
709
|
+
attribute_list = candidate
|
|
710
|
+
for attribute, value, failure in attributes:
|
|
711
|
+
if not api.kernel32.UpdateProcThreadAttribute(
|
|
712
|
+
attribute_list,
|
|
713
|
+
0,
|
|
714
|
+
attribute,
|
|
715
|
+
ctypes.cast(value, wintypes.LPVOID),
|
|
716
|
+
ctypes.sizeof(value),
|
|
717
|
+
None,
|
|
718
|
+
None,
|
|
719
|
+
):
|
|
720
|
+
_raise_windows_process_error(api, failure)
|
|
721
|
+
|
|
722
|
+
application = _windows_resolve_application(command, env, cwd)
|
|
723
|
+
if not ntpath.isabs(application):
|
|
724
|
+
# A relative name would make the batch-shim existence check and
|
|
725
|
+
# CreateProcessW itself resolve against the parent's current
|
|
726
|
+
# directory instead of the child's configured environment.
|
|
727
|
+
raise FileNotFoundError(errno.ENOENT, "Windows command was not found on the configured PATH.")
|
|
728
|
+
if ntpath.splitext(application)[1].casefold() in _WINDOWS_BATCH_SUFFIXES:
|
|
729
|
+
if not os.path.exists(application):
|
|
730
|
+
raise FileNotFoundError(errno.ENOENT, "Windows batch shim does not exist.")
|
|
731
|
+
if not os.path.isfile(application):
|
|
732
|
+
raise IsADirectoryError(errno.EISDIR, "Windows batch shim is not a regular file.")
|
|
733
|
+
shim = ntpath.abspath(application)
|
|
734
|
+
batch_command = serialize_windows_batch_command(shim, args)
|
|
735
|
+
application = resolve_windows_comspec(parent_env)
|
|
736
|
+
command_line_text = f'"{application}" /d /s /v:off /c "{batch_command}"'
|
|
737
|
+
else:
|
|
738
|
+
argv = [application, *args]
|
|
739
|
+
command_line_text = subprocess.list2cmdline(argv)
|
|
740
|
+
command_line = ctypes.create_unicode_buffer(command_line_text)
|
|
741
|
+
environment = _windows_environment_block(env)
|
|
742
|
+
|
|
743
|
+
startup = _StartupInfoExW(
|
|
744
|
+
_StartupInfoW(
|
|
745
|
+
cb=ctypes.sizeof(_StartupInfoExW),
|
|
746
|
+
dwFlags=0x00000101, # STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES
|
|
747
|
+
wShowWindow=0, # SW_HIDE
|
|
748
|
+
hStdInput=stdin_child,
|
|
749
|
+
hStdOutput=stdout_child,
|
|
750
|
+
hStdError=stderr_child,
|
|
751
|
+
),
|
|
752
|
+
attribute_list,
|
|
753
|
+
)
|
|
754
|
+
process_info = _ProcessInformation()
|
|
755
|
+
creation_flags = _CREATE_NEW_CONSOLE | 0x00000400 | 0x00080000
|
|
756
|
+
if not api.kernel32.CreateProcessW(
|
|
757
|
+
application,
|
|
758
|
+
command_line,
|
|
759
|
+
None,
|
|
760
|
+
None,
|
|
761
|
+
True,
|
|
762
|
+
creation_flags,
|
|
763
|
+
environment,
|
|
764
|
+
cwd,
|
|
765
|
+
ctypes.byref(startup),
|
|
766
|
+
ctypes.byref(process_info),
|
|
767
|
+
):
|
|
768
|
+
_raise_windows_process_error(api, "Unable to create the Windows subprocess.")
|
|
769
|
+
process_handle = int(process_info.hProcess)
|
|
770
|
+
api.kernel32.CloseHandle(process_info.hThread)
|
|
771
|
+
for child_handle in (stdin_child, stdout_child, stderr_child):
|
|
772
|
+
api.kernel32.CloseHandle(child_handle)
|
|
773
|
+
# _wrap_windows_parent_pipes owns the parent handles from here, including on failure.
|
|
774
|
+
pipe_pairs = None
|
|
775
|
+
stdin, stdout, stderr, stdout_transport, stderr_transport = await _wrap_windows_parent_pipes(
|
|
776
|
+
stdin_parent,
|
|
777
|
+
stdout_parent,
|
|
778
|
+
stderr_parent,
|
|
779
|
+
limit=limit,
|
|
780
|
+
)
|
|
781
|
+
return ManagedStdioProcess(
|
|
782
|
+
stdin=stdin,
|
|
783
|
+
stdout=stdout,
|
|
784
|
+
stderr=stderr,
|
|
785
|
+
pid=int(process_info.dwProcessId),
|
|
786
|
+
_windows_process_handle=process_handle,
|
|
787
|
+
_windows_job_handle=job_handle,
|
|
788
|
+
_windows_stdout_transport=stdout_transport,
|
|
789
|
+
_windows_stderr_transport=stderr_transport,
|
|
790
|
+
)
|
|
791
|
+
except BaseException:
|
|
792
|
+
_terminate_windows_retained_process(job_handle, process_handle, exit_code=1)
|
|
793
|
+
_close_windows_handle(process_handle)
|
|
794
|
+
_close_windows_handle(job_handle)
|
|
795
|
+
if pipe_pairs is not None:
|
|
796
|
+
for pair in pipe_pairs:
|
|
797
|
+
for handle in pair:
|
|
798
|
+
_close_windows_handle(handle)
|
|
799
|
+
raise
|
|
800
|
+
finally:
|
|
801
|
+
if attribute_list is not None:
|
|
802
|
+
api.kernel32.DeleteProcThreadAttributeList(attribute_list)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
async def _windows_process_exit(process_handle: int) -> int:
|
|
806
|
+
"""Complete when the process handle is signalled, then read its exit code."""
|
|
807
|
+
await _register_windows_process_wait(process_handle)
|
|
808
|
+
return _windows_process_exit_code(process_handle)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _register_windows_process_wait(process_handle: int) -> asyncio.Future[bool]:
|
|
812
|
+
"""Register the process handle with the running loop's completion port.
|
|
813
|
+
|
|
814
|
+
The Windows spawn path connects its pipes through ``ProactorEventLoop``, whose
|
|
815
|
+
``IocpProactor.wait_for_handle`` is the wait asyncio uses for its own subprocess
|
|
816
|
+
exits: a kernel wait thread posts to the port when the process ends and the loop
|
|
817
|
+
completes the future itself. Cancelling the future unregisters the wait.
|
|
818
|
+
"""
|
|
819
|
+
loop = asyncio.get_running_loop()
|
|
820
|
+
proactor = getattr(loop, "_proactor", None)
|
|
821
|
+
if proactor is None:
|
|
822
|
+
raise RuntimeError("Windows subprocess waits need the proactor event loop that connected the pipes.")
|
|
823
|
+
return proactor.wait_for_handle(process_handle)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _windows_process_exit_code(process_handle: int) -> int:
|
|
827
|
+
api = _windows_process_api()
|
|
828
|
+
exit_code = wintypes.DWORD()
|
|
829
|
+
if not api.kernel32.GetExitCodeProcess(process_handle, ctypes.byref(exit_code)):
|
|
830
|
+
_raise_windows_process_error(api, "Unable to read the Windows subprocess exit code.")
|
|
831
|
+
return int(exit_code.value)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _terminate_windows_retained_process(
|
|
835
|
+
job_handle: int | None,
|
|
836
|
+
process_handle: int | None,
|
|
837
|
+
*,
|
|
838
|
+
exit_code: int,
|
|
839
|
+
) -> None:
|
|
840
|
+
if not job_handle and not process_handle:
|
|
841
|
+
return
|
|
842
|
+
api = _windows_process_api()
|
|
843
|
+
terminated = bool(job_handle and api.kernel32.TerminateJobObject(job_handle, exit_code))
|
|
844
|
+
if not terminated and process_handle:
|
|
845
|
+
api.kernel32.TerminateProcess(process_handle, exit_code)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _consume_task_exception(task: asyncio.Task[Any]) -> None:
|
|
849
|
+
"""Mark abandoned waiter failures retrieved without changing await semantics."""
|
|
850
|
+
if not task.cancelled():
|
|
851
|
+
task.exception()
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _close_windows_handle(handle: int | None) -> None:
|
|
855
|
+
if not handle:
|
|
856
|
+
return
|
|
857
|
+
api = _windows_process_api()
|
|
858
|
+
api.kernel32.CloseHandle(handle)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
async def _managed_subprocess_gen(*args: Any, **kwargs: Any) -> AsyncIterator[asyncio.subprocess.Process]:
|
|
862
|
+
"""Generator backing :func:`managed_subprocess`."""
|
|
863
|
+
if (
|
|
864
|
+
sys.platform != "win32"
|
|
865
|
+
and "start_new_session" not in kwargs
|
|
866
|
+
and "process_group" not in kwargs
|
|
867
|
+
and "preexec_fn" not in kwargs
|
|
868
|
+
):
|
|
869
|
+
# Own a POSIX process group so cleanup reaches grandchildren that
|
|
870
|
+
# inherit pipes and outlive the wrapper process.
|
|
871
|
+
kwargs["start_new_session"] = True
|
|
872
|
+
|
|
873
|
+
# On Windows, give the child its own console so it cannot call
|
|
874
|
+
# SetConsoleMode() on the *parent's* console (which would reset
|
|
875
|
+
# mouse-input flags and break TUI interactions).
|
|
876
|
+
#
|
|
877
|
+
# CREATE_NEW_CONSOLE — child gets a separate console, so cmd.exe,
|
|
878
|
+
# pwsh, and powershell still work (they need a console for internal
|
|
879
|
+
# commands like echo, dir, etc.) but cannot touch the parent's.
|
|
880
|
+
# STARTF_USESHOWWINDOW + SW_HIDE — suppress the new console window
|
|
881
|
+
# so it doesn't flash on screen.
|
|
882
|
+
#
|
|
883
|
+
# Why not the alternatives?
|
|
884
|
+
# - CREATE_NO_WINDOW: child shares the parent's console → can modify it.
|
|
885
|
+
# - DETACHED_PROCESS: child has NO console → cmd.exe / pwsh / powershell fail.
|
|
886
|
+
if sys.platform == "win32" and "creationflags" not in kwargs:
|
|
887
|
+
for k, v in windows_hidden_subprocess_kwargs().items():
|
|
888
|
+
kwargs.setdefault(k, v)
|
|
889
|
+
|
|
890
|
+
# Detach from the parent's stdin unless a caller asked for something else.
|
|
891
|
+
# Under ACP that descriptor carries the JSON-RPC stream, and a child that
|
|
892
|
+
# inherits it can consume protocol frames. Defaulting here rather than at
|
|
893
|
+
# each call site means a new caller is safe by omission, which is also what
|
|
894
|
+
# lets the static sweep accept the ``**kwargs`` splat below as proof.
|
|
895
|
+
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
proc = await asyncio.create_subprocess_exec(*args, **kwargs)
|
|
899
|
+
except OSError as exc:
|
|
900
|
+
raise_if_missing_cwd(kwargs.get("cwd"), exc)
|
|
901
|
+
raise
|
|
902
|
+
process_group_id = _infer_process_group_id(proc, kwargs)
|
|
903
|
+
body_raised = False
|
|
904
|
+
try:
|
|
905
|
+
yield proc
|
|
906
|
+
except BaseException:
|
|
907
|
+
body_raised = True
|
|
908
|
+
raise
|
|
909
|
+
finally:
|
|
910
|
+
should_kill_owned_tree = body_raised or proc.returncode is None
|
|
911
|
+
if process_group_id is not None and should_kill_owned_tree:
|
|
912
|
+
kill_process_group(process_group_id)
|
|
913
|
+
try:
|
|
914
|
+
if proc.pid is not None and should_kill_owned_tree:
|
|
915
|
+
# Returns at once off Windows, where the group kill above reached the tree.
|
|
916
|
+
await kill_windows_process_tree(proc.pid)
|
|
917
|
+
finally:
|
|
918
|
+
if proc.returncode is None:
|
|
919
|
+
# Keep the direct child moving if process-group cleanup was
|
|
920
|
+
# unavailable or a custom setup kept the child outside the group.
|
|
921
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
922
|
+
proc.kill()
|
|
923
|
+
# Close the subprocess transport BEFORE wait() so pipe
|
|
924
|
+
# transports are released immediately. Without this,
|
|
925
|
+
# wait() blocks until all inherited pipe handles close —
|
|
926
|
+
# which hangs when a wrapper process (e.g. `uv run python`)
|
|
927
|
+
# spawns a child that inherits the pipes and outlives the
|
|
928
|
+
# killed parent.
|
|
929
|
+
finalize_subprocess_transport(proc)
|
|
930
|
+
with contextlib.suppress(ProcessLookupError, OSError, asyncio.CancelledError, TimeoutError):
|
|
931
|
+
await asyncio.wait_for(proc.wait(), timeout=_PROCESS_WAIT_CLEANUP_TIMEOUT)
|
|
932
|
+
else:
|
|
933
|
+
# Process already exited — just release the transport.
|
|
934
|
+
finalize_subprocess_transport(proc)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
managed_subprocess = contextlib.asynccontextmanager(_managed_subprocess_gen)
|
|
938
|
+
"""Create a subprocess with guaranteed transport cleanup.
|
|
939
|
+
|
|
940
|
+
Wraps ``asyncio.create_subprocess_exec`` and ensures that on exit:
|
|
941
|
+
|
|
942
|
+
1. The POSIX process group is killed on abnormal exit or unfinished child
|
|
943
|
+
2. The direct child is killed if it hasn't exited yet
|
|
944
|
+
3. The direct child is waited/reaped (prevents zombie processes)
|
|
945
|
+
4. The subprocess transport is closed (prevents Windows Proactor
|
|
946
|
+
``ResourceWarning`` during interpreter shutdown)
|
|
947
|
+
|
|
948
|
+
Usage::
|
|
949
|
+
|
|
950
|
+
async with managed_subprocess("rg", "--files", stdout=PIPE) as proc:
|
|
951
|
+
stdout, stderr = await proc.communicate()
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
async def wait_for_subprocess[T](
|
|
956
|
+
awaitable: Awaitable[T],
|
|
957
|
+
*,
|
|
958
|
+
timeout: float | None,
|
|
959
|
+
process_group_id: int | None = None,
|
|
960
|
+
process_session_id: int | None = None,
|
|
961
|
+
) -> T:
|
|
962
|
+
"""Await subprocess work with timeout and stopped-process detection.
|
|
963
|
+
|
|
964
|
+
``asyncio`` only completes ``Process.wait()`` when the direct child exits;
|
|
965
|
+
a POSIX child or grandchild can enter job-control stopped state and keep
|
|
966
|
+
pipes or a PTY slave open forever. When a process group or session id is
|
|
967
|
+
supplied, this helper polls for stopped members and raises
|
|
968
|
+
:class:`SubprocessStoppedError` as soon as one is found so callers can kill
|
|
969
|
+
the owned process set.
|
|
970
|
+
"""
|
|
971
|
+
if process_group_id is None and process_session_id is None:
|
|
972
|
+
return await asyncio.wait_for(awaitable, timeout=timeout)
|
|
973
|
+
if sys.platform == "win32":
|
|
974
|
+
return await asyncio.wait_for(awaitable, timeout=timeout)
|
|
975
|
+
|
|
976
|
+
operation = asyncio.ensure_future(awaitable)
|
|
977
|
+
if process_session_id is None:
|
|
978
|
+
monitor = asyncio.create_task(_monitor_process_group_stopped(cast(int, process_group_id)))
|
|
979
|
+
else:
|
|
980
|
+
monitor = asyncio.create_task(_monitor_processes_stopped(process_group_id, process_session_id))
|
|
981
|
+
try:
|
|
982
|
+
done, _pending = await asyncio.wait(
|
|
983
|
+
{operation, monitor},
|
|
984
|
+
timeout=timeout,
|
|
985
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
986
|
+
)
|
|
987
|
+
if not done:
|
|
988
|
+
await _cancel_subprocess_wait(operation)
|
|
989
|
+
raise TimeoutError
|
|
990
|
+
if operation in done:
|
|
991
|
+
return await operation
|
|
992
|
+
|
|
993
|
+
await _cancel_subprocess_wait(operation)
|
|
994
|
+
return await monitor
|
|
995
|
+
except BaseException:
|
|
996
|
+
if not operation.done():
|
|
997
|
+
await _cancel_subprocess_wait(operation)
|
|
998
|
+
raise
|
|
999
|
+
finally:
|
|
1000
|
+
monitor.cancel()
|
|
1001
|
+
with contextlib.suppress(asyncio.CancelledError, SubprocessStoppedError):
|
|
1002
|
+
await monitor
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
async def _cancel_subprocess_wait(task: asyncio.Future[Any]) -> None:
|
|
1006
|
+
task.cancel()
|
|
1007
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
1008
|
+
await asyncio.wait_for(task, timeout=_PROCESS_WAIT_CLEANUP_TIMEOUT)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def kill_process_group(process_group_id: int) -> bool:
|
|
1012
|
+
"""Kill a POSIX process group without signaling Chrys's own group."""
|
|
1013
|
+
if sys.platform == "win32" or process_group_id <= 0:
|
|
1014
|
+
return False
|
|
1015
|
+
with contextlib.suppress(OSError):
|
|
1016
|
+
if process_group_id == os.getpgrp():
|
|
1017
|
+
return False
|
|
1018
|
+
try:
|
|
1019
|
+
os.killpg(process_group_id, signal.SIGKILL)
|
|
1020
|
+
except ProcessLookupError:
|
|
1021
|
+
return False
|
|
1022
|
+
except PermissionError:
|
|
1023
|
+
return False
|
|
1024
|
+
except OSError:
|
|
1025
|
+
return False
|
|
1026
|
+
return True
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def kill_process_session(process_session_id: int) -> bool:
|
|
1030
|
+
"""Kill all known POSIX process groups in a session owned by Chrys."""
|
|
1031
|
+
if sys.platform == "win32" or process_session_id <= 0:
|
|
1032
|
+
return False
|
|
1033
|
+
with contextlib.suppress(OSError):
|
|
1034
|
+
if process_session_id == os.getsid(0):
|
|
1035
|
+
return False
|
|
1036
|
+
|
|
1037
|
+
killed = False
|
|
1038
|
+
for process_group_id in sorted(_process_groups_in_session(process_session_id)):
|
|
1039
|
+
killed = kill_process_group(process_group_id) or killed
|
|
1040
|
+
return killed
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
async def kill_windows_process_tree(pid: int) -> bool:
|
|
1044
|
+
"""Best-effort Windows subtree kill using the platform ``taskkill`` utility.
|
|
1045
|
+
|
|
1046
|
+
``taskkill`` runs as an asyncio subprocess, whose exit the loop awaits on its
|
|
1047
|
+
completion port (``IocpProactor.wait_for_handle``, as for every asyncio
|
|
1048
|
+
subprocess), so the loop keeps serving other work meanwhile. A blocking run
|
|
1049
|
+
stalled the whole TUI on every hook timeout, and concurrent timeouts queued
|
|
1050
|
+
behind one another.
|
|
1051
|
+
|
|
1052
|
+
A cancelled caller waits for the bounded kill to end before its cancellation
|
|
1053
|
+
goes on. Stopping ``taskkill`` part-way would leave the caller to kill only the
|
|
1054
|
+
direct child, and the descendants ``taskkill`` had yet to reach would outlive it.
|
|
1055
|
+
"""
|
|
1056
|
+
argv = _windows_tree_kill_argv(pid)
|
|
1057
|
+
if argv is None:
|
|
1058
|
+
return False
|
|
1059
|
+
kill = asyncio.ensure_future(_run_windows_tree_kill(argv))
|
|
1060
|
+
try:
|
|
1061
|
+
return await asyncio.shield(kill)
|
|
1062
|
+
except asyncio.CancelledError:
|
|
1063
|
+
while not kill.done():
|
|
1064
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
1065
|
+
await asyncio.shield(kill)
|
|
1066
|
+
raise
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
async def _run_windows_tree_kill(argv: list[str]) -> bool:
|
|
1070
|
+
try:
|
|
1071
|
+
killer = await asyncio.create_subprocess_exec(
|
|
1072
|
+
*argv,
|
|
1073
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
1074
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
1075
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
1076
|
+
**windows_hidden_subprocess_kwargs(),
|
|
1077
|
+
)
|
|
1078
|
+
except OSError:
|
|
1079
|
+
return False
|
|
1080
|
+
try:
|
|
1081
|
+
return await asyncio.wait_for(killer.wait(), timeout=_WINDOWS_TREE_KILL_TIMEOUT) == 0
|
|
1082
|
+
except TimeoutError:
|
|
1083
|
+
return False
|
|
1084
|
+
finally:
|
|
1085
|
+
if killer.returncode is None:
|
|
1086
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
1087
|
+
killer.kill()
|
|
1088
|
+
finalize_subprocess_transport(killer)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _windows_tree_kill_argv(pid: int) -> list[str] | None:
|
|
1092
|
+
if sys.platform != "win32" or pid <= 0:
|
|
1093
|
+
return None
|
|
1094
|
+
taskkill = shutil.which("taskkill")
|
|
1095
|
+
if taskkill is None:
|
|
1096
|
+
return None
|
|
1097
|
+
return [taskkill, "/PID", str(pid), "/T", "/F"]
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _infer_process_group_id(proc: asyncio.subprocess.Process, kwargs: dict[str, Any]) -> int | None:
|
|
1101
|
+
if sys.platform == "win32" or proc.pid is None:
|
|
1102
|
+
return None
|
|
1103
|
+
if kwargs.get("start_new_session") is True:
|
|
1104
|
+
return proc.pid
|
|
1105
|
+
process_group = kwargs.get("process_group")
|
|
1106
|
+
if process_group == 0:
|
|
1107
|
+
return proc.pid
|
|
1108
|
+
if isinstance(process_group, int) and process_group > 0:
|
|
1109
|
+
return process_group
|
|
1110
|
+
return None
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
async def _monitor_process_group_stopped(process_group_id: int) -> Never:
|
|
1114
|
+
while True:
|
|
1115
|
+
await asyncio.sleep(_STOPPED_PROCESS_POLL_INTERVAL)
|
|
1116
|
+
if await asyncio.to_thread(_process_group_has_stopped_member, process_group_id):
|
|
1117
|
+
msg = f"Subprocess process group {process_group_id} entered stopped state."
|
|
1118
|
+
raise SubprocessStoppedError(msg)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
async def _monitor_processes_stopped(process_group_id: int | None, process_session_id: int) -> Never:
|
|
1122
|
+
while True:
|
|
1123
|
+
await asyncio.sleep(_STOPPED_PROCESS_POLL_INTERVAL)
|
|
1124
|
+
if await asyncio.to_thread(_processes_have_stopped_member, process_group_id, process_session_id):
|
|
1125
|
+
msg = f"Subprocess process session {process_session_id} entered stopped state."
|
|
1126
|
+
raise SubprocessStoppedError(msg)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _processes_have_stopped_member(process_group_id: int | None, process_session_id: int) -> bool:
|
|
1130
|
+
if _process_session_has_stopped_member(process_session_id):
|
|
1131
|
+
return True
|
|
1132
|
+
return process_group_id is not None and _process_group_has_stopped_member(process_group_id)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _process_group_has_stopped_member(process_group_id: int) -> bool:
|
|
1136
|
+
if sys.platform == "win32" or process_group_id <= 0:
|
|
1137
|
+
return False
|
|
1138
|
+
ps = shutil.which("ps")
|
|
1139
|
+
if ps is None:
|
|
1140
|
+
return False
|
|
1141
|
+
try:
|
|
1142
|
+
result = subprocess.run( # noqa: S603
|
|
1143
|
+
[ps, "-e", "-o", "state=", "-o", "pgid="],
|
|
1144
|
+
stdin=subprocess.DEVNULL,
|
|
1145
|
+
capture_output=True,
|
|
1146
|
+
check=False,
|
|
1147
|
+
timeout=1,
|
|
1148
|
+
)
|
|
1149
|
+
except OSError, subprocess.SubprocessError:
|
|
1150
|
+
return False
|
|
1151
|
+
return _process_listing_has_stopped_group_member(result.stdout, process_group_id)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _process_listing_has_stopped_group_member(raw: bytes, process_group_id: int) -> bool:
|
|
1155
|
+
for line in raw.decode("ascii", errors="ignore").splitlines():
|
|
1156
|
+
stripped = line.strip()
|
|
1157
|
+
if not stripped:
|
|
1158
|
+
continue
|
|
1159
|
+
parts = stripped.rsplit(None, 1)
|
|
1160
|
+
if len(parts) != 2:
|
|
1161
|
+
continue
|
|
1162
|
+
state, pgid_text = parts
|
|
1163
|
+
try:
|
|
1164
|
+
pgid = int(pgid_text)
|
|
1165
|
+
except ValueError:
|
|
1166
|
+
continue
|
|
1167
|
+
if pgid == process_group_id and state[:1] in {"T", "t"}:
|
|
1168
|
+
return True
|
|
1169
|
+
return False
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _process_session_has_stopped_member(process_session_id: int) -> bool:
|
|
1173
|
+
if sys.platform == "win32" or process_session_id <= 0:
|
|
1174
|
+
return False
|
|
1175
|
+
return _process_statuses_have_stopped_session_member(_iter_posix_process_statuses(), process_session_id)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _process_groups_in_session(process_session_id: int) -> set[int]:
|
|
1179
|
+
if sys.platform == "win32" or process_session_id <= 0:
|
|
1180
|
+
return set()
|
|
1181
|
+
return _process_statuses_session_groups(_iter_posix_process_statuses(), process_session_id)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _process_statuses_have_stopped_session_member(statuses: Iterable[_ProcessStatus], process_session_id: int) -> bool:
|
|
1185
|
+
return any(status.session_id == process_session_id and status.state[:1] in {"T", "t"} for status in statuses)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _process_statuses_session_groups(statuses: Iterable[_ProcessStatus], process_session_id: int) -> set[int]:
|
|
1189
|
+
return {status.pgid for status in statuses if status.session_id == process_session_id and status.pgid > 0}
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _iter_posix_process_statuses() -> Iterator[_ProcessStatus]:
|
|
1193
|
+
if sys.platform == "win32":
|
|
1194
|
+
return
|
|
1195
|
+
if sys.platform == "linux":
|
|
1196
|
+
yielded = False
|
|
1197
|
+
for status in _iter_linux_process_statuses():
|
|
1198
|
+
yielded = True
|
|
1199
|
+
yield status
|
|
1200
|
+
if yielded:
|
|
1201
|
+
return
|
|
1202
|
+
yield from _iter_ps_process_statuses()
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _iter_linux_process_statuses() -> Iterator[_ProcessStatus]:
|
|
1206
|
+
if sys.platform != "linux":
|
|
1207
|
+
return
|
|
1208
|
+
with contextlib.suppress(OSError), os.scandir("/proc") as entries:
|
|
1209
|
+
for entry in entries:
|
|
1210
|
+
if not entry.name.isdecimal():
|
|
1211
|
+
continue
|
|
1212
|
+
with contextlib.suppress(OSError, UnicodeDecodeError):
|
|
1213
|
+
with open(f"/proc/{entry.name}/stat", encoding="utf-8") as stat_file:
|
|
1214
|
+
status = _parse_linux_proc_stat(stat_file.read())
|
|
1215
|
+
if status is not None:
|
|
1216
|
+
yield status
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _parse_linux_proc_stat(raw: str) -> _ProcessStatus | None:
|
|
1220
|
+
close_paren = raw.rfind(")")
|
|
1221
|
+
if close_paren < 0:
|
|
1222
|
+
return None
|
|
1223
|
+
pid_text = raw[:close_paren].partition("(")[0]
|
|
1224
|
+
values = raw[close_paren + 1 :].split()
|
|
1225
|
+
if len(values) < 4:
|
|
1226
|
+
return None
|
|
1227
|
+
try:
|
|
1228
|
+
return _ProcessStatus(
|
|
1229
|
+
pid=int(pid_text.strip()),
|
|
1230
|
+
state=values[0],
|
|
1231
|
+
pgid=int(values[2]),
|
|
1232
|
+
session_id=int(values[3]),
|
|
1233
|
+
)
|
|
1234
|
+
except ValueError:
|
|
1235
|
+
return None
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _iter_ps_process_statuses() -> Iterator[_ProcessStatus]:
|
|
1239
|
+
ps = shutil.which("ps")
|
|
1240
|
+
if ps is None:
|
|
1241
|
+
return
|
|
1242
|
+
try:
|
|
1243
|
+
result = subprocess.run( # noqa: S603
|
|
1244
|
+
[ps, "-e", "-o", "pid=", "-o", "state="],
|
|
1245
|
+
stdin=subprocess.DEVNULL,
|
|
1246
|
+
capture_output=True,
|
|
1247
|
+
check=False,
|
|
1248
|
+
timeout=1,
|
|
1249
|
+
)
|
|
1250
|
+
except OSError, subprocess.SubprocessError:
|
|
1251
|
+
return
|
|
1252
|
+
if result.returncode != 0:
|
|
1253
|
+
return
|
|
1254
|
+
for pid, state in _parse_ps_pid_state_listing(result.stdout):
|
|
1255
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1256
|
+
posix_os = cast(Any, os)
|
|
1257
|
+
pgid = posix_os.getpgid(pid)
|
|
1258
|
+
session_id = posix_os.getsid(pid)
|
|
1259
|
+
yield _ProcessStatus(pid=pid, state=state, pgid=pgid, session_id=session_id)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _parse_ps_pid_state_listing(raw: bytes) -> Iterator[tuple[int, str]]:
|
|
1263
|
+
for line in raw.decode("ascii", errors="ignore").splitlines():
|
|
1264
|
+
stripped = line.strip()
|
|
1265
|
+
if not stripped:
|
|
1266
|
+
continue
|
|
1267
|
+
parts = stripped.split(None, 1)
|
|
1268
|
+
if len(parts) != 2:
|
|
1269
|
+
continue
|
|
1270
|
+
try:
|
|
1271
|
+
yield int(parts[0]), parts[1]
|
|
1272
|
+
except ValueError:
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def decode_subprocess_output(raw: bytes | bytearray) -> str:
|
|
1277
|
+
"""Decode subprocess output bytes to str, strongly preferring UTF-8.
|
|
1278
|
+
|
|
1279
|
+
The generic ``decode_bytes`` runs a multi-encoding detection pipeline
|
|
1280
|
+
that can misidentify ANSI-heavy terminal output as UTF-16: ESC bytes
|
|
1281
|
+
(0x1b) inflate the control-character ratio past the text-quality
|
|
1282
|
+
threshold, causing the UTF-8 fast-path to be rejected, while ASCII
|
|
1283
|
+
byte pairs happen to decode as valid CJK under UTF-16.
|
|
1284
|
+
|
|
1285
|
+
Strategy (aligned with Ansible / CPython / xterm.js best practice):
|
|
1286
|
+
|
|
1287
|
+
1. **UTF-16 probe on Windows** -- catches PowerShell / ``cmd /u``
|
|
1288
|
+
output that otherwise looks like valid UTF-8 with embedded NULs.
|
|
1289
|
+
2. **Strict UTF-8** -- fast path for the overwhelming common case.
|
|
1290
|
+
3. **Tolerant UTF-8** -- handles PTY reads that split multi-byte
|
|
1291
|
+
characters at buffer boundaries. Uses ``surrogateescape`` to
|
|
1292
|
+
count bad bytes accurately (each maps 1:1 to a surrogate), then
|
|
1293
|
+
re-encodes and decodes with ``replace`` so the returned string
|
|
1294
|
+
is safe for JSON serialization and terminal rendering.
|
|
1295
|
+
4. **Full detection (Windows only)** -- falls back to ``decode_bytes``
|
|
1296
|
+
for legacy ANSI code page output (GBK, Shift-JIS, etc.). On
|
|
1297
|
+
Unix/macOS the locale is always UTF-8, so detection adds risk
|
|
1298
|
+
of misidentification with no benefit.
|
|
1299
|
+
"""
|
|
1300
|
+
if not raw:
|
|
1301
|
+
return ""
|
|
1302
|
+
if sys.platform == "win32":
|
|
1303
|
+
utf16_text = _decode_utf16_output(raw)
|
|
1304
|
+
if utf16_text is not None:
|
|
1305
|
+
return utf16_text
|
|
1306
|
+
try:
|
|
1307
|
+
return raw.decode("utf-8")
|
|
1308
|
+
except UnicodeDecodeError:
|
|
1309
|
+
# On Windows with a legacy code page (GBK, Shift-JIS, cp1252, …),
|
|
1310
|
+
# non-UTF-8 bytes are likely valid in the system encoding. Try
|
|
1311
|
+
# the known console code page first (fast, accurate) before
|
|
1312
|
+
# falling back to statistical detection (charset_normalizer).
|
|
1313
|
+
# Without this, mostly-ASCII output with a few CJK characters
|
|
1314
|
+
# falls below the surrogate threshold and gets garbled by
|
|
1315
|
+
# ``errors="replace"`` before the code-page fallback is reached.
|
|
1316
|
+
if sys.platform == "win32" and not _windows_uses_utf8():
|
|
1317
|
+
cp_enc = _windows_console_encoding()
|
|
1318
|
+
if cp_enc:
|
|
1319
|
+
try:
|
|
1320
|
+
decoded = raw.decode(cp_enc)
|
|
1321
|
+
if _is_subprocess_text(decoded):
|
|
1322
|
+
return decoded
|
|
1323
|
+
except UnicodeDecodeError, LookupError:
|
|
1324
|
+
pass
|
|
1325
|
+
return decode_bytes(raw)
|
|
1326
|
+
# Unix/macOS: locale is always UTF-8, so non-UTF-8 bytes are
|
|
1327
|
+
# buffer-boundary splits, not a different encoding. Use
|
|
1328
|
+
# surrogateescape to count bad bytes; replace if few enough.
|
|
1329
|
+
probed = raw.decode("utf-8", errors="surrogateescape")
|
|
1330
|
+
surrogates = sum(1 for ch in probed if "\udc80" <= ch <= "\udcff")
|
|
1331
|
+
if surrogates / max(len(probed), 1) < 0.1:
|
|
1332
|
+
return raw.decode("utf-8", errors="replace")
|
|
1333
|
+
return raw.decode("utf-8", errors="replace")
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _is_subprocess_text(decoded: str) -> bool:
|
|
1337
|
+
"""Return True for regular text or NUL-delimited text records."""
|
|
1338
|
+
if is_mostly_text(decoded):
|
|
1339
|
+
return True
|
|
1340
|
+
if "\x00" not in decoded:
|
|
1341
|
+
return False
|
|
1342
|
+
|
|
1343
|
+
parts = decoded.split("\x00")
|
|
1344
|
+
if parts and parts[-1] == "":
|
|
1345
|
+
parts.pop()
|
|
1346
|
+
if not parts or any(part == "" for part in parts):
|
|
1347
|
+
return False
|
|
1348
|
+
|
|
1349
|
+
return is_mostly_text("".join(parts))
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _decode_utf16_output(raw: bytes | bytearray) -> str | None:
|
|
1353
|
+
"""Decode subprocess output that is clearly UTF-16 text."""
|
|
1354
|
+
data = bytes(raw)
|
|
1355
|
+
layout = _utf16_layout(data)
|
|
1356
|
+
if layout is None:
|
|
1357
|
+
return None
|
|
1358
|
+
codec, bom_length = layout
|
|
1359
|
+
try:
|
|
1360
|
+
decoded = data[bom_length:].decode(codec)
|
|
1361
|
+
except UnicodeDecodeError:
|
|
1362
|
+
return None
|
|
1363
|
+
# ASCII-only UTF-16 without a BOM is indistinguishable from NUL-delimited
|
|
1364
|
+
# UTF-8/ASCII output. Preserve the bytes in that ambiguous case.
|
|
1365
|
+
if not bom_length and decoded.isascii():
|
|
1366
|
+
return None
|
|
1367
|
+
return decoded if is_mostly_text(decoded) else None
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _utf16_layout(data: bytes) -> tuple[str, int] | None:
|
|
1371
|
+
"""Return the UTF-16 codec *data*'s BOM or NUL layout shows, and the BOM's length."""
|
|
1372
|
+
if data.startswith(codecs.BOM_UTF16_LE):
|
|
1373
|
+
return "utf-16-le", len(codecs.BOM_UTF16_LE)
|
|
1374
|
+
if data.startswith(codecs.BOM_UTF16_BE):
|
|
1375
|
+
return "utf-16-be", len(codecs.BOM_UTF16_BE)
|
|
1376
|
+
if len(data) < 4 or b"\x00" not in data:
|
|
1377
|
+
return None
|
|
1378
|
+
|
|
1379
|
+
even = data[0::2]
|
|
1380
|
+
odd = data[1::2]
|
|
1381
|
+
even_nul_ratio = even.count(0) / len(even)
|
|
1382
|
+
odd_nul_ratio = odd.count(0) / len(odd)
|
|
1383
|
+
if odd_nul_ratio >= 0.20 and odd_nul_ratio >= max(even_nul_ratio * 4, 0.20):
|
|
1384
|
+
return "utf-16-le", 0
|
|
1385
|
+
if even_nul_ratio >= 0.20 and even_nul_ratio >= max(odd_nul_ratio * 4, 0.20):
|
|
1386
|
+
return "utf-16-be", 0
|
|
1387
|
+
return None
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def decode_split_output(head: bytes, tail: bytes, tail_offset: int) -> tuple[str, str]:
|
|
1391
|
+
"""Decode the kept head and tail of an output stream whose middle was dropped.
|
|
1392
|
+
|
|
1393
|
+
*tail_offset* is where *tail* started in the stream. One codec, chosen
|
|
1394
|
+
from both parts so the cut cannot flip the choice, decodes both, and only
|
|
1395
|
+
the edges at the cut are repaired: a character the cut left incomplete at
|
|
1396
|
+
the end of *head* is dropped, and *tail* starts at its first whole
|
|
1397
|
+
character. When no one codec fits both parts, each is decoded on its own
|
|
1398
|
+
with :func:`decode_subprocess_output`.
|
|
1399
|
+
"""
|
|
1400
|
+
if sys.platform == "win32":
|
|
1401
|
+
utf16 = _decode_utf16_split(head, tail, tail_offset)
|
|
1402
|
+
if utf16 is not None:
|
|
1403
|
+
return utf16
|
|
1404
|
+
skipped = _utf8_continuation_prefix_length(tail)
|
|
1405
|
+
utf8_tail = tail[skipped:]
|
|
1406
|
+
if sys.platform != "win32":
|
|
1407
|
+
return _decode_split_with("utf-8", head, utf8_tail)
|
|
1408
|
+
|
|
1409
|
+
code_page = None if _windows_uses_utf8() else _windows_console_encoding()
|
|
1410
|
+
utf8 = _strict_split("utf-8", head, utf8_tail)
|
|
1411
|
+
if utf8 is not None:
|
|
1412
|
+
head_text, tail_text = utf8
|
|
1413
|
+
repaired = skipped > 0 or len(head_text.encode("utf-8")) < len(head)
|
|
1414
|
+
# Bytes dropped as the pieces of a character the cut split are no
|
|
1415
|
+
# evidence for UTF-8 when every character kept is ASCII: the console
|
|
1416
|
+
# code page may read those bytes as text.
|
|
1417
|
+
if not (code_page and repaired and head_text.isascii() and tail_text.isascii()):
|
|
1418
|
+
return utf8
|
|
1419
|
+
if code_page:
|
|
1420
|
+
with contextlib.suppress(LookupError):
|
|
1421
|
+
# A cut inside a double-byte character leaves its trail byte first.
|
|
1422
|
+
parts = next(
|
|
1423
|
+
(parts for k in range(4) if (parts := _strict_split(code_page, head, tail[k:])) is not None), None
|
|
1424
|
+
)
|
|
1425
|
+
if parts is not None and all(_is_subprocess_text(part) for part in parts):
|
|
1426
|
+
return parts
|
|
1427
|
+
if utf8 is not None:
|
|
1428
|
+
return utf8
|
|
1429
|
+
return decode_subprocess_output(head), decode_subprocess_output(tail)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _utf8_continuation_prefix_length(data: bytes) -> int:
|
|
1433
|
+
"""Count the UTF-8 continuation bytes, at most three, that start *data*."""
|
|
1434
|
+
length = 0
|
|
1435
|
+
while length < min(3, len(data)) and data[length] & 0xC0 == 0x80:
|
|
1436
|
+
length += 1
|
|
1437
|
+
return length
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _strict_split(codec: str, head: bytes, tail: bytes) -> tuple[str, str] | None:
|
|
1441
|
+
"""Decode both parts with *codec*, dropping a cut character at the end of *head*; None when either fails."""
|
|
1442
|
+
try:
|
|
1443
|
+
head_text = codecs.getincrementaldecoder(codec)("strict").decode(head, final=False)
|
|
1444
|
+
return head_text, codecs.getincrementaldecoder(codec)("strict").decode(tail, final=True)
|
|
1445
|
+
except UnicodeDecodeError:
|
|
1446
|
+
return None
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _decode_split_with(codec: str, head: bytes, tail: bytes) -> tuple[str, str]:
|
|
1450
|
+
head_text = codecs.getincrementaldecoder(codec)("replace").decode(head, final=False)
|
|
1451
|
+
return head_text, codecs.getincrementaldecoder(codec)("replace").decode(tail, final=True)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _decode_utf16_split(head: bytes, tail: bytes, tail_offset: int) -> tuple[str, str] | None:
|
|
1455
|
+
"""Decode both parts as UTF-16 when *head* clearly is UTF-16 text."""
|
|
1456
|
+
layout = _utf16_layout(head[: len(head) - len(head) % 2])
|
|
1457
|
+
if layout is None:
|
|
1458
|
+
return None
|
|
1459
|
+
codec, bom_length = layout
|
|
1460
|
+
tail = tail[tail_offset % 2 :]
|
|
1461
|
+
# The cut may split a surrogate pair; its low half cannot start a character.
|
|
1462
|
+
high_byte = 1 if codec == "utf-16-le" else 0
|
|
1463
|
+
if len(tail) >= 2 and 0xDC <= tail[high_byte] <= 0xDF:
|
|
1464
|
+
tail = tail[2:]
|
|
1465
|
+
head_text, tail_text = _decode_split_with(codec, head[bom_length:], tail)
|
|
1466
|
+
if not bom_length and head_text.isascii() and tail_text.isascii():
|
|
1467
|
+
return None
|
|
1468
|
+
return (head_text, tail_text) if is_mostly_text(head_text + tail_text) else None
|