painapple-code 1.0.0__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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code via the Claude Agent SDK — SDK-backed provider.
|
|
3
|
+
|
|
4
|
+
Same engine as `ClaudeProvider`, but the subprocess the server spawns is a
|
|
5
|
+
thin Python *driver* (`providers/claude_sdk/driver.py`) that runs
|
|
6
|
+
`ClaudeSDKClient` in-process instead of exec'ing `claude -p` directly:
|
|
7
|
+
|
|
8
|
+
server ──stream-json lines──> driver ──SDK control protocol──> claude CLI
|
|
9
|
+
|
|
10
|
+
The driver tees the CLI's raw stream-json verbatim onto its own stdout and
|
|
11
|
+
forwards the server's canonical user messages from stdin into the SDK, so
|
|
12
|
+
from the session layer's point of view this provider is wire-identical to
|
|
13
|
+
`ClaudeProvider`: same "lines" transport, same events, same stderr strings,
|
|
14
|
+
same signal semantics (SIGINT/SIGTERM → clean exit). Everything Claude-shaped
|
|
15
|
+
(cost normalization, stderr classification, summary fork, /context probe,
|
|
16
|
+
models/accounts/permission vocab) is inherited unchanged.
|
|
17
|
+
|
|
18
|
+
What the extra hop buys is the SDK's control plane — a first-class seam for
|
|
19
|
+
interactive permissions (`can_use_tool`), native interrupt, and mid-session
|
|
20
|
+
mode/model changes. See docs-ai/plans/2026-07-07-agent-sdk-migration.md.
|
|
21
|
+
|
|
22
|
+
Selection: this is the default provider (DEFAULT_PROVIDER) — new sessions adopt
|
|
23
|
+
it, and sessions that record no provider fall back to it. Sessions persist
|
|
24
|
+
`provider: "claude-sdk"` in meta. A tier can pin the line-protocol `claude`
|
|
25
|
+
provider instead via the `default_provider` config key or the
|
|
26
|
+
`--default-provider` server flag.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import replace
|
|
33
|
+
|
|
34
|
+
from painapple_code.providers.base import LaunchOptions
|
|
35
|
+
from painapple_code.providers.claude import ClaudeProvider
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ClaudeSdkProvider(ClaudeProvider):
|
|
39
|
+
"""Claude Code driven through `claude-agent-sdk` in a driver subprocess."""
|
|
40
|
+
|
|
41
|
+
name = "claude-sdk"
|
|
42
|
+
display_name = "Claude Code (SDK)"
|
|
43
|
+
description = "Anthropic Claude Code via the Agent SDK — live controls, ask-mode cards"
|
|
44
|
+
# The preferred Claude driver — in the picker out of the box (ClaudeProvider
|
|
45
|
+
# sets False to keep the plainer CLI variant hidden; undo that here).
|
|
46
|
+
default_enabled = True
|
|
47
|
+
# Same engine, same feature surface as Claude — plus the SDK's
|
|
48
|
+
# can_use_tool permission round-trip (driver ⇄ server ⇄ client) and its
|
|
49
|
+
# control plane (live permission-mode/model switches, warm-process
|
|
50
|
+
# interrupt) via control_request/control_done frames on the same pipe.
|
|
51
|
+
capabilities = replace(ClaudeProvider.capabilities,
|
|
52
|
+
interactive_permissions=True,
|
|
53
|
+
live_controls=True)
|
|
54
|
+
|
|
55
|
+
def permission_modes(self) -> list[dict]:
|
|
56
|
+
# Claude's modes plus "Ask" (CLI mode `default`) — the manual-accept
|
|
57
|
+
# mode: reads are auto-allowed, everything else surfaces a permission
|
|
58
|
+
# card. Only offered here: on the line-protocol provider `default`
|
|
59
|
+
# just auto-denies (nothing can answer the prompt), so exposing it
|
|
60
|
+
# there would be a trap.
|
|
61
|
+
modes = ClaudeProvider.permission_modes(self)
|
|
62
|
+
ask = {"value": "default", "label": "Ask",
|
|
63
|
+
"desc": "Approve each edit/command via permission card",
|
|
64
|
+
"color": "#2dd4bf"}
|
|
65
|
+
modes.insert(1, ask) # right after Plan
|
|
66
|
+
# The base `claude` provider's Accept Edits desc says "deny others" —
|
|
67
|
+
# accurate for line-protocol `claude -p`, where there's no can_use_tool
|
|
68
|
+
# gate so out-of-workspace ops hard-deny (surface as is_error results).
|
|
69
|
+
# Here the SDK's can_use_tool gate is wired for acceptEdits too
|
|
70
|
+
# (driver.py), so those ops surface an approve/deny card instead of
|
|
71
|
+
# denying. Patch the one field the interactive-permissions seam made
|
|
72
|
+
# stale, leaving the rest of the inherited vocab untouched.
|
|
73
|
+
for m in modes:
|
|
74
|
+
if m["value"] == "acceptEdits":
|
|
75
|
+
m["desc"] = "Auto-approve workspace edits; others prompt for approval"
|
|
76
|
+
return modes
|
|
77
|
+
|
|
78
|
+
def default_permission_mode(self) -> str:
|
|
79
|
+
# Ask, not the base "dontAsk". The SDK engine can actually answer a
|
|
80
|
+
# prompt (can_use_tool → permission card), so the sane out-of-the-box
|
|
81
|
+
# default is manual-accept — reads flow, edits/commands surface a card —
|
|
82
|
+
# rather than auto-denying. (On the line-protocol provider "default"
|
|
83
|
+
# would be a trap: nothing can answer, so every ask becomes a denial;
|
|
84
|
+
# that's why the base stays "dontAsk".)
|
|
85
|
+
return "default"
|
|
86
|
+
|
|
87
|
+
def build_command(self, opts: LaunchOptions) -> list[str]:
|
|
88
|
+
# The server's venv python, not "python" off PATH — the driver imports
|
|
89
|
+
# both painapple_code and claude_agent_sdk from this environment.
|
|
90
|
+
cmd = [
|
|
91
|
+
sys.executable, "-u", "-m",
|
|
92
|
+
"painapple_code.providers.claude_sdk.driver",
|
|
93
|
+
]
|
|
94
|
+
# win32 + bare default: omit --cli-path so the SDK's _find_cli()
|
|
95
|
+
# discovers the CLI — it prefers a native claude.exe (bundled or
|
|
96
|
+
# installed) and refuses npm's claude.cmd shim with a curated
|
|
97
|
+
# remediation (CVE-2024-27980 class); which-resolving "claude" here
|
|
98
|
+
# would hand the SDK exactly the shim it's designed to reject.
|
|
99
|
+
# POSIX keeps passing the resolved system binary unconditionally —
|
|
100
|
+
# omitting it would let the SDK prefer its _bundled/ CLI over the
|
|
101
|
+
# user's installed claude, a silent version switch.
|
|
102
|
+
if sys.platform != "win32" or self.binary() != self.default_binary:
|
|
103
|
+
cmd.extend(["--cli-path", self.binary()])
|
|
104
|
+
|
|
105
|
+
if opts.model:
|
|
106
|
+
cmd.extend(["--model", opts.model])
|
|
107
|
+
|
|
108
|
+
if opts.fallback_model:
|
|
109
|
+
cmd.extend(["--fallback-model", opts.fallback_model])
|
|
110
|
+
|
|
111
|
+
# "high" is the CLI default; skip to avoid noise (mirrors launch.py).
|
|
112
|
+
if opts.effort and opts.effort != "high":
|
|
113
|
+
cmd.extend(["--effort", opts.effort])
|
|
114
|
+
|
|
115
|
+
cmd.extend(["--permission-mode", opts.permission_mode or self.default_permission_mode()])
|
|
116
|
+
|
|
117
|
+
if opts.fork_from_session_id:
|
|
118
|
+
cmd.extend(["--resume", opts.fork_from_session_id, "--fork-session"])
|
|
119
|
+
elif opts.session_id:
|
|
120
|
+
cmd.extend(["--resume", opts.session_id])
|
|
121
|
+
|
|
122
|
+
return cmd
|
|
123
|
+
|
|
124
|
+
def binary_not_found_hint(self) -> str:
|
|
125
|
+
return ("The SDK driver spawns the Claude CLI; install it and make "
|
|
126
|
+
"sure it's on the server's PATH (or set claude_path in config).")
|
|
127
|
+
|
|
128
|
+
def is_available(self) -> tuple[bool, str | None]:
|
|
129
|
+
try:
|
|
130
|
+
import claude_agent_sdk # noqa: F401
|
|
131
|
+
except ImportError:
|
|
132
|
+
return False, ("claude-agent-sdk not installed in the server venv "
|
|
133
|
+
"(pip install claude-agent-sdk)")
|
|
134
|
+
# SDK present — availability then hinges on the claude CLI, same as
|
|
135
|
+
# the line-protocol provider.
|
|
136
|
+
return super().is_available()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
PROVIDERS = [ClaudeSdkProvider()]
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent-SDK driver subprocess for the `claude-sdk` provider.
|
|
3
|
+
|
|
4
|
+
The server spawns this module (`python -u -m painapple_code.providers.claude_sdk.driver`)
|
|
5
|
+
exactly where it would spawn `claude -p`, and speaks the same wire protocol to
|
|
6
|
+
it: canonical stream-json user messages in on stdin, canonical stream-json
|
|
7
|
+
events out on stdout, CLI diagnostics on stderr. Internally it drives the
|
|
8
|
+
Claude Code CLI through `ClaudeSDKClient` and *tees the raw stream-json dicts
|
|
9
|
+
off the SDK transport verbatim*, so the bytes the server reads are the CLI's
|
|
10
|
+
own output, not a lossy reconstruction from the SDK's typed objects — event
|
|
11
|
+
parity with the line-protocol provider holds by construction.
|
|
12
|
+
|
|
13
|
+
Signal contract (matches what the session layer expects of the CLI):
|
|
14
|
+
SIGINT → abort the turn, shut the SDK client down, exit 130
|
|
15
|
+
SIGTERM → shut down, exit 143
|
|
16
|
+
stdin EOF (server died/closed) → shut down, exit 0
|
|
17
|
+
Closing the client terminates the CLI child, so no orphan is left behind.
|
|
18
|
+
|
|
19
|
+
stdout is protocol-only — never print anything else to it. Driver-side
|
|
20
|
+
diagnostics go to stderr, where the server's stderr reader forwards/classifies
|
|
21
|
+
them like any CLI stderr line.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import asyncio
|
|
28
|
+
import dataclasses
|
|
29
|
+
import itertools
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import signal
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
from collections.abc import AsyncIterator
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
# Matches the server reader's MAX_LINE_SIZE ceiling (utils/agent_cli.py); the
|
|
40
|
+
# stdin limit is higher because pasted/uploaded base64 images ride in the user
|
|
41
|
+
# message envelope.
|
|
42
|
+
MAX_EVENT_SIZE = 10 * 1024 * 1024
|
|
43
|
+
MAX_STDIN_LINE = 64 * 1024 * 1024
|
|
44
|
+
|
|
45
|
+
# Stream event types forwarded to the server. Everything else on the transport
|
|
46
|
+
# (control_request/control_response handshakes, keep-alives) is SDK plumbing
|
|
47
|
+
# the server must not see.
|
|
48
|
+
_STREAM_TYPES = frozenset({"system", "assistant", "user", "result"})
|
|
49
|
+
|
|
50
|
+
# Tools whose "ask" the server already handles with its own chat-UI flow
|
|
51
|
+
# (question wizard / plan-approval card + SIGINT auto-stop). Deny them here
|
|
52
|
+
# with a steering message instead of surfacing a permission card, so both
|
|
53
|
+
# providers keep the identical UX for these two.
|
|
54
|
+
_UI_FLOW_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"})
|
|
55
|
+
_UI_FLOW_DENY = ("The user will respond through the chat UI. Stop and wait "
|
|
56
|
+
"for their reply instead of retrying this tool.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _jsonable(obj: Any) -> Any:
|
|
60
|
+
"""Best-effort conversion of SDK dataclasses/enums for the wire."""
|
|
61
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
62
|
+
return {k: _jsonable(v) for k, v in dataclasses.asdict(obj).items() if v is not None}
|
|
63
|
+
if isinstance(obj, dict):
|
|
64
|
+
return {k: _jsonable(v) for k, v in obj.items()}
|
|
65
|
+
if isinstance(obj, (list, tuple)):
|
|
66
|
+
return [_jsonable(v) for v in obj]
|
|
67
|
+
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
68
|
+
return obj
|
|
69
|
+
return str(obj)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _emit(msg: dict) -> None:
|
|
73
|
+
"""Write one canonical event line to the server."""
|
|
74
|
+
sys.stdout.write(json.dumps(msg, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
75
|
+
sys.stdout.flush()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _note(text: str) -> None:
|
|
79
|
+
"""Driver-side diagnostic → server stderr reader."""
|
|
80
|
+
print(f"claude-sdk driver: {text}", file=sys.stderr, flush=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_tee_transport(options):
|
|
84
|
+
"""Subclass the SDK's subprocess transport to tee raw dicts to stdout.
|
|
85
|
+
|
|
86
|
+
Built lazily inside a function so importing this module never hard-requires
|
|
87
|
+
the SDK internals; version drift in `SubprocessCLITransport` surfaces here
|
|
88
|
+
as a loud startup error, not a wedged session.
|
|
89
|
+
"""
|
|
90
|
+
from claude_agent_sdk._internal.transport.subprocess_cli import (
|
|
91
|
+
SubprocessCLITransport,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
class _TeeTransport(SubprocessCLITransport):
|
|
95
|
+
def read_messages(self) -> AsyncIterator[dict[str, Any]]:
|
|
96
|
+
return self._tee(super().read_messages())
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
async def _tee(inner: AsyncIterator[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
|
|
100
|
+
async for msg in inner:
|
|
101
|
+
if msg.get("type") in _STREAM_TYPES:
|
|
102
|
+
_emit(msg)
|
|
103
|
+
yield msg
|
|
104
|
+
|
|
105
|
+
async def _pending() -> AsyncIterator[dict[str, Any]]:
|
|
106
|
+
# Never yields; marks streaming-input mode so the CLI's stdin stays
|
|
107
|
+
# open for the queries we write per server message.
|
|
108
|
+
return
|
|
109
|
+
yield {} # pragma: no cover
|
|
110
|
+
|
|
111
|
+
return _TeeTransport(prompt=_pending(), options=options)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _amain(args: argparse.Namespace) -> int:
|
|
115
|
+
from claude_agent_sdk import (
|
|
116
|
+
ClaudeAgentOptions,
|
|
117
|
+
ClaudeSDKClient,
|
|
118
|
+
PermissionResultAllow,
|
|
119
|
+
PermissionResultDeny,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
|
|
124
|
+
# Interactive permissions: `can_use_tool` fires whenever the CLI's
|
|
125
|
+
# permission flow resolves to a prompt. We round-trip the decision to the
|
|
126
|
+
# server as a `permission_request` stdout line and await the matching
|
|
127
|
+
# `permission_response` stdin line (routed by the stdin loop below).
|
|
128
|
+
pending_permissions: dict[str, asyncio.Future] = {}
|
|
129
|
+
request_ids = (f"perm-{n}" for n in itertools.count(1))
|
|
130
|
+
|
|
131
|
+
async def on_permission(tool_name: str, tool_input: dict, context) -> Any:
|
|
132
|
+
if tool_name in _UI_FLOW_TOOLS:
|
|
133
|
+
return PermissionResultDeny(message=_UI_FLOW_DENY)
|
|
134
|
+
req_id = next(request_ids)
|
|
135
|
+
fut: asyncio.Future = loop.create_future()
|
|
136
|
+
pending_permissions[req_id] = fut
|
|
137
|
+
suggestions = list(getattr(context, "suggestions", []) or [])
|
|
138
|
+
_emit({
|
|
139
|
+
"type": "permission_request",
|
|
140
|
+
"request_id": req_id,
|
|
141
|
+
"tool_name": tool_name,
|
|
142
|
+
"input": _jsonable(tool_input),
|
|
143
|
+
"tool_use_id": getattr(context, "tool_use_id", None),
|
|
144
|
+
"title": getattr(context, "title", None),
|
|
145
|
+
"description": getattr(context, "description", None),
|
|
146
|
+
"display_name": getattr(context, "display_name", None),
|
|
147
|
+
"decision_reason": _jsonable(getattr(context, "decision_reason", None)),
|
|
148
|
+
"blocked_path": getattr(context, "blocked_path", None),
|
|
149
|
+
"suggestions": _jsonable(suggestions),
|
|
150
|
+
})
|
|
151
|
+
try:
|
|
152
|
+
resp = await fut
|
|
153
|
+
finally:
|
|
154
|
+
pending_permissions.pop(req_id, None)
|
|
155
|
+
if resp.get("behavior") == "allow":
|
|
156
|
+
# "Always allow": the client picked one of the CLI's own permission
|
|
157
|
+
# suggestions (addRules / addDirectories / setMode). Hand the
|
|
158
|
+
# original PermissionUpdate object back and the CLI applies it —
|
|
159
|
+
# rule persistence is entirely engine-side, nothing reimplemented.
|
|
160
|
+
updated_permissions = None
|
|
161
|
+
idx = resp.get("suggestion_index")
|
|
162
|
+
if isinstance(idx, int) and 0 <= idx < len(suggestions):
|
|
163
|
+
updated_permissions = [suggestions[idx]]
|
|
164
|
+
return PermissionResultAllow(
|
|
165
|
+
updated_input=resp.get("updated_input"),
|
|
166
|
+
updated_permissions=updated_permissions,
|
|
167
|
+
)
|
|
168
|
+
# Attribute the guidance to the user explicitly — a bare denial string
|
|
169
|
+
# in an error-shaped tool result reads like injected tool output and
|
|
170
|
+
# the model may distrust it.
|
|
171
|
+
guidance = resp.get("message")
|
|
172
|
+
return PermissionResultDeny(
|
|
173
|
+
message=(f"The user denied this tool call and said: {guidance}"
|
|
174
|
+
if guidance else "The user denied this tool call."),
|
|
175
|
+
interrupt=bool(resp.get("interrupt")),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# bypassPermissions (YOLO) auto-approves every tool call before can_use_tool
|
|
179
|
+
# is ever consulted, so wiring the callback does nothing except trip the
|
|
180
|
+
# SDK's CanUseToolShadowedWarning — which the server's stderr reader surfaces
|
|
181
|
+
# as a scary (but harmless) error line. Only attach the permission gate for
|
|
182
|
+
# modes that can actually prompt.
|
|
183
|
+
gate = None if args.permission_mode == "bypassPermissions" else on_permission
|
|
184
|
+
|
|
185
|
+
options = ClaudeAgentOptions(
|
|
186
|
+
can_use_tool=gate,
|
|
187
|
+
model=args.model,
|
|
188
|
+
fallback_model=args.fallback_model,
|
|
189
|
+
effort=args.effort,
|
|
190
|
+
permission_mode=args.permission_mode,
|
|
191
|
+
resume=args.resume,
|
|
192
|
+
fork_session=args.fork_session,
|
|
193
|
+
# The server already sets cwd on the driver process; pin the CLI to it.
|
|
194
|
+
cwd=os.getcwd(),
|
|
195
|
+
# None → the SDK's _find_cli() discovers (and on win32 vets) the CLI;
|
|
196
|
+
# an explicit config value is which-resolved as before.
|
|
197
|
+
cli_path=(shutil.which(args.cli_path) or args.cli_path) if args.cli_path else None,
|
|
198
|
+
# Restore thinking summaries on all models (see providers/claude/launch.py).
|
|
199
|
+
extra_args={"thinking-display": "summarized"},
|
|
200
|
+
# Mirror CLI stderr onto ours so the server's classifier sees the exact
|
|
201
|
+
# strings it matches today (stale session, API 5xx, compaction, …).
|
|
202
|
+
stderr=lambda line: print(line, file=sys.stderr, flush=True),
|
|
203
|
+
max_buffer_size=MAX_EVENT_SIZE,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
stop = asyncio.Event()
|
|
207
|
+
exit_code = 0
|
|
208
|
+
|
|
209
|
+
def _shutdown(code: int) -> None:
|
|
210
|
+
nonlocal exit_code
|
|
211
|
+
exit_code = code
|
|
212
|
+
stop.set()
|
|
213
|
+
|
|
214
|
+
if sys.platform == "win32":
|
|
215
|
+
# Proactor has no add_signal_handler (NotImplementedError — a Windows 11
|
|
216
|
+
# probe). signal.signal works: CPython's wakeup channel rouses the
|
|
217
|
+
# loop, and call_soon_threadsafe hops back onto it. The server's
|
|
218
|
+
# interrupt_process() sends CTRL_BREAK → SIGBREAK here (SIGTERM is
|
|
219
|
+
# undeliverable on win32; parent terminate() covers that path).
|
|
220
|
+
def _sig_handler(signum, frame):
|
|
221
|
+
loop.call_soon_threadsafe(
|
|
222
|
+
_shutdown, 130 if signum == signal.SIGINT else 143
|
|
223
|
+
)
|
|
224
|
+
signal.signal(signal.SIGINT, _sig_handler)
|
|
225
|
+
signal.signal(signal.SIGBREAK, _sig_handler)
|
|
226
|
+
else:
|
|
227
|
+
loop.add_signal_handler(signal.SIGINT, _shutdown, 130)
|
|
228
|
+
loop.add_signal_handler(signal.SIGTERM, _shutdown, 143)
|
|
229
|
+
|
|
230
|
+
# With a custom transport, ClaudeSDKClient skips its own
|
|
231
|
+
# `permission_prompt_tool_name="stdio"` injection (it only applies it to
|
|
232
|
+
# the transport it would have built itself), so set it on the *transport's*
|
|
233
|
+
# options copy here. It must NOT go on the client's options — the client
|
|
234
|
+
# rejects it alongside `can_use_tool`. Pair it with the gate: in bypass mode
|
|
235
|
+
# there's nothing to route to stdio, so keep that path a clean pass-through.
|
|
236
|
+
transport_opts = dataclasses.replace(options, can_use_tool=None)
|
|
237
|
+
if gate is not None:
|
|
238
|
+
transport_opts = dataclasses.replace(transport_opts, permission_prompt_tool_name="stdio")
|
|
239
|
+
transport = _make_tee_transport(transport_opts)
|
|
240
|
+
client = ClaudeSDKClient(options=options, transport=transport)
|
|
241
|
+
try:
|
|
242
|
+
await client.connect()
|
|
243
|
+
except Exception as e:
|
|
244
|
+
_note(f"failed to start Claude CLI via SDK: {e}")
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
async def stdin_loop() -> None:
|
|
248
|
+
"""Forward each canonical user-message line from the server into the SDK."""
|
|
249
|
+
if sys.platform == "win32":
|
|
250
|
+
# connect_read_pipe on an inherited stdin HALF-works under
|
|
251
|
+
# Proactor: it connects, then the first read dies with
|
|
252
|
+
# OSError WinError 6 and leaves stdin unusable (a Windows 11 probe
|
|
253
|
+
# 3). Don't attempt it — a daemon thread doing blocking
|
|
254
|
+
# readline feeds the loop through an asyncio queue instead.
|
|
255
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
256
|
+
|
|
257
|
+
def _pump() -> None:
|
|
258
|
+
try:
|
|
259
|
+
while True:
|
|
260
|
+
raw = sys.stdin.buffer.readline()
|
|
261
|
+
loop.call_soon_threadsafe(queue.put_nowait, raw)
|
|
262
|
+
if not raw: # EOF — server closed our stdin
|
|
263
|
+
return
|
|
264
|
+
except Exception:
|
|
265
|
+
loop.call_soon_threadsafe(queue.put_nowait, b"")
|
|
266
|
+
|
|
267
|
+
threading.Thread(target=_pump, daemon=True, name="stdin-pump").start()
|
|
268
|
+
|
|
269
|
+
async def _readline() -> bytes:
|
|
270
|
+
return await queue.get()
|
|
271
|
+
else:
|
|
272
|
+
reader = asyncio.StreamReader(limit=MAX_STDIN_LINE)
|
|
273
|
+
await loop.connect_read_pipe(
|
|
274
|
+
lambda: asyncio.StreamReaderProtocol(reader), sys.stdin
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _readline() -> bytes:
|
|
278
|
+
return await reader.readline()
|
|
279
|
+
|
|
280
|
+
while True:
|
|
281
|
+
line = await _readline()
|
|
282
|
+
if not line:
|
|
283
|
+
# Server closed stdin. Mirror `claude -p` one-shot semantics:
|
|
284
|
+
# close the CLI's stdin and let it finish in-flight work; the
|
|
285
|
+
# drain loop shuts us down when the CLI exits.
|
|
286
|
+
try:
|
|
287
|
+
await transport.end_input()
|
|
288
|
+
except Exception:
|
|
289
|
+
_shutdown(0)
|
|
290
|
+
return
|
|
291
|
+
line = line.strip()
|
|
292
|
+
if not line:
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
msg = json.loads(line)
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
_note("dropping non-JSON stdin line")
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Server frames ride the same pipe: permission decisions resolve
|
|
301
|
+
# the future the matching can_use_tool callback is awaiting.
|
|
302
|
+
if msg.get("type") == "permission_response":
|
|
303
|
+
fut = pending_permissions.get(msg.get("request_id"))
|
|
304
|
+
if fut and not fut.done():
|
|
305
|
+
fut.set_result(msg)
|
|
306
|
+
else:
|
|
307
|
+
_note(f"permission_response for unknown request "
|
|
308
|
+
f"{msg.get('request_id')!r}")
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Control plane (Capabilities.live_controls): live mode/model
|
|
312
|
+
# switches and warm-process interrupt via the SDK client. Every
|
|
313
|
+
# request is acked with `control_done` — the server treats a
|
|
314
|
+
# nack/timeout as "fall back to kill+respawn", so failing loudly
|
|
315
|
+
# here is always safe. Ordering with user messages is enforced
|
|
316
|
+
# server-side (it awaits the ack before writing the message).
|
|
317
|
+
if msg.get("type") == "control_request":
|
|
318
|
+
action = msg.get("action")
|
|
319
|
+
ok, err = True, None
|
|
320
|
+
try:
|
|
321
|
+
if action == "interrupt":
|
|
322
|
+
# Unblock pending can_use_tool callbacks first so they
|
|
323
|
+
# unwind as clean user denials instead of being
|
|
324
|
+
# cancelled mid-await by the abort.
|
|
325
|
+
for fut in list(pending_permissions.values()):
|
|
326
|
+
if not fut.done():
|
|
327
|
+
fut.set_result({"behavior": "deny", "interrupt": True})
|
|
328
|
+
await client.interrupt()
|
|
329
|
+
elif action == "set_permission_mode":
|
|
330
|
+
await client.set_permission_mode(msg.get("mode"))
|
|
331
|
+
elif action == "set_model":
|
|
332
|
+
# None = revert to the CLI/account default model.
|
|
333
|
+
await client.set_model(msg.get("model"))
|
|
334
|
+
else:
|
|
335
|
+
ok, err = False, f"unknown control action {action!r}"
|
|
336
|
+
except Exception as e:
|
|
337
|
+
ok, err = False, str(e)
|
|
338
|
+
_note(f"control {action} failed: {e}")
|
|
339
|
+
_emit({
|
|
340
|
+
"type": "control_done",
|
|
341
|
+
"control_id": msg.get("control_id"),
|
|
342
|
+
"action": action,
|
|
343
|
+
"ok": ok,
|
|
344
|
+
"error": err,
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
async def _one(m=msg) -> AsyncIterator[dict[str, Any]]:
|
|
349
|
+
yield m
|
|
350
|
+
|
|
351
|
+
await client.query(_one())
|
|
352
|
+
|
|
353
|
+
async def drain_loop() -> None:
|
|
354
|
+
"""Keep the SDK's parsed-message queue drained (the tee already emitted
|
|
355
|
+
the raw dicts); surface transport/process failures as an exit."""
|
|
356
|
+
try:
|
|
357
|
+
async for _ in client.receive_messages():
|
|
358
|
+
pass
|
|
359
|
+
_shutdown(0)
|
|
360
|
+
except Exception as e:
|
|
361
|
+
_note(str(e))
|
|
362
|
+
code = getattr(e, "exit_code", None)
|
|
363
|
+
_shutdown(code if isinstance(code, int) and code > 0 else 1)
|
|
364
|
+
|
|
365
|
+
tasks = [asyncio.create_task(stdin_loop()), asyncio.create_task(drain_loop())]
|
|
366
|
+
try:
|
|
367
|
+
await stop.wait()
|
|
368
|
+
finally:
|
|
369
|
+
for t in tasks:
|
|
370
|
+
t.cancel()
|
|
371
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
372
|
+
try:
|
|
373
|
+
await client.disconnect() # terminates the CLI child
|
|
374
|
+
except Exception as e:
|
|
375
|
+
_note(f"error during shutdown: {e}")
|
|
376
|
+
return exit_code
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def main() -> None:
|
|
380
|
+
if sys.platform == "win32":
|
|
381
|
+
# stdout is the wire to the server and emit() uses ensure_ascii=False —
|
|
382
|
+
# a cp1252 pipe would corrupt the first non-ASCII assistant token.
|
|
383
|
+
from painapple_code.utils.proc import force_utf8_stdio
|
|
384
|
+
force_utf8_stdio()
|
|
385
|
+
parser = argparse.ArgumentParser(description="painapple-code Agent SDK driver")
|
|
386
|
+
parser.add_argument("--cli-path", default=None) # None → SDK _find_cli()
|
|
387
|
+
parser.add_argument("--model", default=None)
|
|
388
|
+
parser.add_argument("--fallback-model", default=None)
|
|
389
|
+
parser.add_argument("--effort", default=None)
|
|
390
|
+
parser.add_argument("--permission-mode", default=None)
|
|
391
|
+
parser.add_argument("--resume", default=None)
|
|
392
|
+
parser.add_argument("--fork-session", action="store_true")
|
|
393
|
+
args = parser.parse_args()
|
|
394
|
+
code = asyncio.run(_amain(args))
|
|
395
|
+
if sys.platform == "win32":
|
|
396
|
+
# The win32 stdin pump is a daemon thread parked in a blocking
|
|
397
|
+
# readline(), so it owns the <stdin> BufferedReader lock at exit.
|
|
398
|
+
# Normal interpreter finalization waits for that lock and instead
|
|
399
|
+
# aborts with "Fatal Python error: _enter_buffered_busy" on stderr —
|
|
400
|
+
# which the server forwards verbatim, painting three red error
|
|
401
|
+
# bubbles into the chat on every AskUserQuestion auto-stop.
|
|
402
|
+
# _amain's finally already disconnected the CLI child and cancelled
|
|
403
|
+
# the loop tasks, so finalization has nothing left to do; skip it
|
|
404
|
+
# rather than lose a race it cannot win. os._exit does not flush.
|
|
405
|
+
sys.stdout.flush()
|
|
406
|
+
sys.stderr.flush()
|
|
407
|
+
os._exit(code)
|
|
408
|
+
sys.exit(code)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI Codex CLI provider (`codex exec --json`).
|
|
3
|
+
|
|
4
|
+
Unlike Claude (one persistent `claude -p` process streaming stream-json over
|
|
5
|
+
stdin/stdout across many turns), Codex runs **one `codex exec` process per
|
|
6
|
+
turn**: the prompt is a positional argv, the run emits newline-delimited JSON
|
|
7
|
+
events to stdout, and the process exits when the turn ends. Continuation is a
|
|
8
|
+
fresh `codex exec resume <thread_id> "<prompt>"`. So this provider declares
|
|
9
|
+
`persistent_process=False` and the session layer drives a per-turn spawn loop.
|
|
10
|
+
|
|
11
|
+
The adapter is assembled from focused mixins (this file is the combiner — see
|
|
12
|
+
`session_store.py` / `shadow_git.py` for the same pattern in this codebase):
|
|
13
|
+
|
|
14
|
+
capabilities.py → _CapabilitiesMixin binary, availability, effort/perm
|
|
15
|
+
vocabularies, skill/agent/plugin
|
|
16
|
+
roots, context metering
|
|
17
|
+
launch.py → _LaunchMixin build_command + image materialization
|
|
18
|
+
translate.py → _TranslateMixin Codex events → canonical Claude shape
|
|
19
|
+
summary.py → _SummaryMixin rich-commit summary fork (rollout copy)
|
|
20
|
+
errors.py → _ErrorsMixin stderr classification / retry policy
|
|
21
|
+
|
|
22
|
+
Codex reports token counts but no per-turn USD cost, so result cost is left at 0
|
|
23
|
+
(see the step-two plan: tokens-only, cost null). Token usage is per-turn already,
|
|
24
|
+
so no cumulative→delta conversion is needed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from painapple_code.providers.base import Provider, Capabilities
|
|
30
|
+
from painapple_code.providers.codex.capabilities import _CapabilitiesMixin
|
|
31
|
+
from painapple_code.providers.codex.launch import _LaunchMixin
|
|
32
|
+
from painapple_code.providers.codex.translate import _TranslateMixin
|
|
33
|
+
from painapple_code.providers.codex.summary import _SummaryMixin
|
|
34
|
+
from painapple_code.providers.codex.errors import _ErrorsMixin
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodexProvider(
|
|
38
|
+
_CapabilitiesMixin,
|
|
39
|
+
_LaunchMixin,
|
|
40
|
+
_TranslateMixin,
|
|
41
|
+
_SummaryMixin,
|
|
42
|
+
_ErrorsMixin,
|
|
43
|
+
Provider,
|
|
44
|
+
):
|
|
45
|
+
"""Adapter for the OpenAI Codex CLI (`codex exec --json`).
|
|
46
|
+
|
|
47
|
+
The behaviour lives in the mixins above; this class only carries the static
|
|
48
|
+
identity + capability flags and the MRO that composes them (mixins first, so
|
|
49
|
+
their overrides win over the `Provider` defaults, with `super()` calls — e.g.
|
|
50
|
+
`is_available` — still reaching the base implementation).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name = "codex"
|
|
54
|
+
display_name = "Codex CLI"
|
|
55
|
+
description = "OpenAI Codex CLI — one exec process per turn"
|
|
56
|
+
# Same engine as codex-app-server but the plainer per-turn exec driver
|
|
57
|
+
# (no native fork, no live interrupt) — kept out of the picker by default
|
|
58
|
+
# so Codex appears once. Flip it on in Settings → Engines.
|
|
59
|
+
default_enabled = False
|
|
60
|
+
capabilities = Capabilities(
|
|
61
|
+
resume=True,
|
|
62
|
+
fork=False, # no --fork-session equivalent
|
|
63
|
+
permission_modes=True,
|
|
64
|
+
effort=True,
|
|
65
|
+
thinking_display=True, # native `reasoning` items
|
|
66
|
+
context_command=False, # no /context equivalent in exec mode
|
|
67
|
+
cumulative_cost=False, # per-turn usage, and no USD figure at all
|
|
68
|
+
# The post-turn fork summarizes the turn with Codex itself (see
|
|
69
|
+
# summary.py / build_summary_fork). Codex has no `--fork-session`, and
|
|
70
|
+
# `codex exec resume` writes in place, so the fork copies the thread's
|
|
71
|
+
# rollout under a fresh id and resumes the copy — branching like Claude's
|
|
72
|
+
# --fork-session without polluting the user's real thread.
|
|
73
|
+
rich_commit_summaries=True,
|
|
74
|
+
persistent_process=False, # one `codex exec` per turn
|
|
75
|
+
forward_plain_stderr=False, # codex prints human progress to stderr
|
|
76
|
+
# `codex exec` has no stdin protocol — frame_input() raises to say so —
|
|
77
|
+
# so build_command appends the prompt as a positional arg. Nothing here
|
|
78
|
+
# can route around it; the sibling codex-app-server driver speaks stdio
|
|
79
|
+
# and is the engine to prefer on a shared host.
|
|
80
|
+
prompt_in_argv=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Registry contribution — see providers/claude.py for the convention. The
|
|
85
|
+
# package scanner in providers/__init__.py discovers this exactly like a flat
|
|
86
|
+
# module (pkgutil.iter_modules lists subpackages too).
|
|
87
|
+
PROVIDERS = [CodexProvider()]
|