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,710 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider interface + the data types it exchanges with the session layer.
|
|
3
|
+
|
|
4
|
+
A `Provider` owns everything CLI-shaped: how to build the launch argv, how to
|
|
5
|
+
frame a user message for stdin, how to parse a stdout line into a canonical
|
|
6
|
+
event, how to normalize cost, and how to classify stderr. The generic session
|
|
7
|
+
lifecycle (subprocess spawn, registry, turn tracking, WebSocket plumbing) lives
|
|
8
|
+
in `services/agent_session.py` and delegates to `session.provider`.
|
|
9
|
+
|
|
10
|
+
The canonical event shape is Claude's stream-json schema (see the package
|
|
11
|
+
docstring). `ClaudeProvider` is therefore a near-identity adapter; other
|
|
12
|
+
providers translate their native output into this shape inside `parse_line`.
|
|
13
|
+
|
|
14
|
+
Providers must be stateless — any per-subprocess running state (e.g. Claude's
|
|
15
|
+
cumulative cost) is held in a per-session `CostState` passed in by the caller,
|
|
16
|
+
so a single provider instance can be shared across all sessions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Capabilities:
|
|
30
|
+
"""Declarative feature flags a provider's CLI supports.
|
|
31
|
+
|
|
32
|
+
Generic code branches on these instead of assuming Claude semantics — e.g.
|
|
33
|
+
the shadow-git rich-commit summary fork only runs when `rich_commit_summaries`
|
|
34
|
+
is set.
|
|
35
|
+
"""
|
|
36
|
+
resume: bool = False # supports resuming a prior session
|
|
37
|
+
fork: bool = False # supports forking a session (--fork-session)
|
|
38
|
+
permission_modes: bool = False # has a permission-mode concept
|
|
39
|
+
effort: bool = False # has an effort/thinking-budget knob
|
|
40
|
+
thinking_display: bool = False # streams thinking blocks
|
|
41
|
+
context_command: bool = False # can report context-window usage
|
|
42
|
+
cumulative_cost: bool = False # reports cost as a per-process running total
|
|
43
|
+
# Supports the post-turn background fork that generates AI rich-commit
|
|
44
|
+
# sections (summary, tags, work_done, …). The fork resumes the provider's
|
|
45
|
+
# own on-disk session, so today only Claude can do it; named for what it
|
|
46
|
+
# gates, not the Haiku model it happens to use.
|
|
47
|
+
rich_commit_summaries: bool = False
|
|
48
|
+
# Process model. True (Claude): one long-lived subprocess reads successive
|
|
49
|
+
# user messages from stdin across many turns. False (Codex): a fresh
|
|
50
|
+
# subprocess per turn with the prompt in argv that exits when the turn ends —
|
|
51
|
+
# a normal exit, not "session ended". The session layer branches on this.
|
|
52
|
+
persistent_process: bool = True
|
|
53
|
+
# Wire protocol the session layer speaks to the subprocess. "lines" (default):
|
|
54
|
+
# newline-delimited JSON — read events off stdout, write user messages to
|
|
55
|
+
# stdin (the only shape Claude/Codex-exec need). "jsonrpc": the provider
|
|
56
|
+
# supplies a transport (see `make_transport`) that owns a bidirectional
|
|
57
|
+
# JSON-RPC conversation — a multi-step turn handshake, id-correlated
|
|
58
|
+
# responses, and server-initiated requests — none of which the one-way line
|
|
59
|
+
# protocol can express. Implies `persistent_process=True`; the session layer
|
|
60
|
+
# delegates the send/intake/interrupt side to the transport but still reads
|
|
61
|
+
# stdout with the same loop (so `parse_line` + `translate_events` apply).
|
|
62
|
+
transport: str = "lines"
|
|
63
|
+
# Whether ordinary (StderrClass.NONE) stderr lines are surfaced to the
|
|
64
|
+
# client. Codex prints human progress to stderr even with --json, so its
|
|
65
|
+
# adapter sets this False (the lines are still logged to raw.jsonl).
|
|
66
|
+
forward_plain_stderr: bool = True
|
|
67
|
+
# Interactive permission prompts: the provider's process emits
|
|
68
|
+
# `permission_request` events on stdout and accepts `permission_response`
|
|
69
|
+
# frames on stdin (claude-sdk driver via `can_use_tool`). The session layer
|
|
70
|
+
# round-trips these to the WebSocket client instead of relying on headless
|
|
71
|
+
# auto-deny. Providers without it keep the is_error-tool-result behavior.
|
|
72
|
+
interactive_permissions: bool = False
|
|
73
|
+
# Control-plane frames over the same stdin/stdout pipe: the process accepts
|
|
74
|
+
# `control_request` frames (set_permission_mode / set_model / interrupt)
|
|
75
|
+
# and acks each with a `control_done` line (claude-sdk driver via the SDK
|
|
76
|
+
# client's control methods). The session layer then applies mode/model
|
|
77
|
+
# changes live instead of lazy kill+respawn, and Stop interrupts the turn
|
|
78
|
+
# without killing the process. Distinct from the jsonrpc transport's native
|
|
79
|
+
# interrupt (Codex), which rides the transport object. Acks are mandatory:
|
|
80
|
+
# on nack/timeout callers fall back to the kill+respawn path, so a failed
|
|
81
|
+
# control is never worse than not having the capability at all.
|
|
82
|
+
live_controls: bool = False
|
|
83
|
+
# The turn's prompt reaches the CLI as a command-line ARGUMENT rather than
|
|
84
|
+
# over stdin/stdio, so it sits in `ps` and `/proc/<pid>/cmdline` — readable
|
|
85
|
+
# by every other local account — for as long as the turn runs. Declared, not
|
|
86
|
+
# inferred: `persistent_process=False` happens to imply it today, but the two
|
|
87
|
+
# are independent (a long-lived process can still take its first prompt in
|
|
88
|
+
# argv, and an ephemeral one can read stdin). Surfaced through `describe()`
|
|
89
|
+
# so Settings → Engines can warn at the point of choice; see SECURITY.md
|
|
90
|
+
# "Prompts reach `ps` on the `codex` engine".
|
|
91
|
+
prompt_in_argv: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class LaunchOptions:
|
|
96
|
+
"""Fully-resolved values for one subprocess launch.
|
|
97
|
+
|
|
98
|
+
The session layer resolves model/effort/permission from its hierarchy
|
|
99
|
+
(in-memory override → session meta → global config) and passes the resolved
|
|
100
|
+
values here; the provider only turns them into argv. `None` means "let the
|
|
101
|
+
CLI use its own default".
|
|
102
|
+
"""
|
|
103
|
+
model: Optional[str] = None
|
|
104
|
+
# Automatic fallback when the primary model is overloaded/unavailable
|
|
105
|
+
# (`--fallback-model`). None = no fallback, the CLI default.
|
|
106
|
+
fallback_model: Optional[str] = None
|
|
107
|
+
effort: Optional[str] = None
|
|
108
|
+
permission_mode: Optional[str] = None
|
|
109
|
+
session_id: Optional[str] = None # resume target
|
|
110
|
+
fork_from_session_id: Optional[str] = None # fork source (takes precedence)
|
|
111
|
+
# Ephemeral providers (persistent_process=False) embed the turn's prompt and
|
|
112
|
+
# images in the launch argv rather than streaming them over stdin. Ignored by
|
|
113
|
+
# persistent providers, which frame the message via `frame_input` instead.
|
|
114
|
+
prompt: Optional[str] = None
|
|
115
|
+
images: Optional[list] = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CostState:
|
|
120
|
+
"""Per-subprocess cumulative cost/usage tracker.
|
|
121
|
+
|
|
122
|
+
Only meaningful for providers whose CLI reports running totals across the
|
|
123
|
+
subprocess lifetime (Claude). One instance per subprocess; reset by the
|
|
124
|
+
session layer on each (re)start via `provider.new_cost_state()`.
|
|
125
|
+
"""
|
|
126
|
+
cum_cost: float = 0.0
|
|
127
|
+
cum_usage: dict = field(default_factory=dict)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class SummaryForkPlan:
|
|
132
|
+
"""How to spawn a provider's post-turn rich-commit summarizer fork.
|
|
133
|
+
|
|
134
|
+
The shadow-git layer owns the generic plumbing (subprocess registry,
|
|
135
|
+
timeout, cancellation, markdown rendering); a provider only supplies the
|
|
136
|
+
argv + any temp files it created (via `build_summary_fork`) and later turns
|
|
137
|
+
the raw output back into structured data (via `parse_summary_fork`). The
|
|
138
|
+
fork must summarize the turn *without* mutating the user's real session —
|
|
139
|
+
Claude branches with `--fork-session`; Codex copies its rollout under a
|
|
140
|
+
fresh id first (`codex exec resume` writes in place).
|
|
141
|
+
"""
|
|
142
|
+
argv: list[str]
|
|
143
|
+
env: Optional[dict] = None
|
|
144
|
+
model: str = "" # label for the Active Sessions widget
|
|
145
|
+
output_file: Optional[str] = None # file the structured JSON lands in (None → stdout)
|
|
146
|
+
cleanup_paths: list = field(default_factory=list) # temp copies/files to remove after
|
|
147
|
+
# Prompt to feed the fork over stdin instead of an argv element. Keeps a
|
|
148
|
+
# large journey/tools prompt out of `ps` and clear of ARG_MAX. When set, the
|
|
149
|
+
# spawner opens stdin as a PIPE and writes this before reading stdout; the
|
|
150
|
+
# provider MUST omit the prompt from argv (e.g. Claude's bare `-p`, no
|
|
151
|
+
# positional). None → the prompt rides in argv as before.
|
|
152
|
+
stdin_input: Optional[str] = None
|
|
153
|
+
# Provider-private data for a transport-driven summary (see
|
|
154
|
+
# `drive_summary_fork`). Line/exec providers leave this None and use the
|
|
155
|
+
# spawned argv's stdout via `parse_summary_fork`; a JSON-RPC provider stashes
|
|
156
|
+
# here what its drive step needs (source thread id, prompt, output schema, …).
|
|
157
|
+
payload: Optional[dict] = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class PluginBackend:
|
|
162
|
+
"""How to drive an engine's plugin-manager CLI for the plugins browser.
|
|
163
|
+
|
|
164
|
+
Both Claude (`claude plugins …`) and Codex (`codex plugin …`) expose a
|
|
165
|
+
``list --json`` returning ``{installed, available}`` and install/uninstall
|
|
166
|
+
verbs, so the route stays generic and only the verbs/paths differ. Engines
|
|
167
|
+
without an enable/disable concept leave those ``None`` (the UI hides the
|
|
168
|
+
toggle). ``marketplace_root`` is the on-disk dir used for the component
|
|
169
|
+
inventory; ``None`` skips inventory for that engine.
|
|
170
|
+
"""
|
|
171
|
+
binary: str
|
|
172
|
+
list_installed_args: list # args yielding {installed, available} for installed only
|
|
173
|
+
list_all_args: list # same but including the marketplace catalog
|
|
174
|
+
install_verb: list # e.g. ["plugins","install"] / ["plugin","add"]
|
|
175
|
+
uninstall_verb: list
|
|
176
|
+
enable_verb: Optional[list] = None # None → engine has no enable/disable
|
|
177
|
+
disable_verb: Optional[list] = None
|
|
178
|
+
marketplace_root: Optional[object] = None # Path for component inventory; None → skip
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class StderrClass(str, Enum):
|
|
182
|
+
"""Classification of a single stderr line. The session layer applies its
|
|
183
|
+
own state gates (retry budget, etc.) on top of this pure-text verdict."""
|
|
184
|
+
NONE = "none" # ordinary diagnostic — forward to client
|
|
185
|
+
STALE_SESSION = "stale_session" # resume target no longer exists
|
|
186
|
+
COMPACTING = "compacting" # context auto-compaction in progress
|
|
187
|
+
RETRYABLE = "retryable" # transient API error worth retrying
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Provider(ABC):
|
|
191
|
+
"""Adapter for one CLI AI agent. See module docstring for the contract."""
|
|
192
|
+
|
|
193
|
+
name: str = "base"
|
|
194
|
+
display_name: str = "Base"
|
|
195
|
+
# One-line, user-facing "what is this engine" for the picker row. Each
|
|
196
|
+
# provider self-describes (nothing is hardcoded per engine in the UI).
|
|
197
|
+
description: str = ""
|
|
198
|
+
# Whether this engine is offered in the picker out of the box. The user
|
|
199
|
+
# flips engines on/off in Settings (`providers_enabled` config overrides);
|
|
200
|
+
# this is only the pre-override default, self-described so nothing is
|
|
201
|
+
# hardcoded per engine elsewhere. Defaults True so a drop-in third-party
|
|
202
|
+
# provider shows up without extra steps; in-tree "same engine, plainer
|
|
203
|
+
# driver" variants (claude CLI, codex exec) ship False to keep the picker
|
|
204
|
+
# down to one entry per engine. Disabled ≠ unavailable: existing sessions
|
|
205
|
+
# bound to a disabled engine keep working, and explicit API selection
|
|
206
|
+
# (?provider=, PUT) still accepts it — this gates only the UI listing.
|
|
207
|
+
default_enabled: bool = True
|
|
208
|
+
# Global-config key holding a user override for `binary()` (e.g.
|
|
209
|
+
# "claude_path"), and the bare command it falls back to when unset.
|
|
210
|
+
# Drives the generic Settings "CLI path" row + /api/app/engine-path
|
|
211
|
+
# endpoint; None → this engine has no path setting (row hidden).
|
|
212
|
+
# Same-engine driver variants share one key (both Claude drivers run the
|
|
213
|
+
# same binary), so the setting edits the engine, not the driver.
|
|
214
|
+
path_config_key: Optional[str] = None
|
|
215
|
+
default_binary: str = ""
|
|
216
|
+
# Copy-paste CLI hint for the "Continue in CLI" quick action: a shell
|
|
217
|
+
# command template that resumes a session by id ("{id}" placeholder). Self-
|
|
218
|
+
# described so the client never hardcodes an engine's resume verb (Claude
|
|
219
|
+
# "claude -r {id}", Codex "codex exec resume {id}"). None → the action is
|
|
220
|
+
# hidden for this engine.
|
|
221
|
+
cli_resume_template: Optional[str] = None
|
|
222
|
+
# Whether the app owns this engine's model catalog (Settings shows an
|
|
223
|
+
# editor writing models.yaml). False → `models()` is read-only, sourced
|
|
224
|
+
# from the engine's own tooling (e.g. Codex's models_cache.json), and
|
|
225
|
+
# Settings renders the definitions without edit affordances (per-model
|
|
226
|
+
# show/hide toggles still apply — see `enabled_models()`).
|
|
227
|
+
models_editable: bool = False
|
|
228
|
+
# Namespace for per-model visibility prefs (the `models_disabled` config
|
|
229
|
+
# map: {key: [hidden ids]}). Driver variants that surface the SAME
|
|
230
|
+
# catalog (claude/claude-sdk share models.yaml; codex/codex-app-server
|
|
231
|
+
# share models_cache.json) set one key so hiding a model hides it on
|
|
232
|
+
# both. None → this provider's own name (a drop-in provider gets its own
|
|
233
|
+
# namespace with no extra steps).
|
|
234
|
+
models_key: Optional[str] = None
|
|
235
|
+
# CLI login surface (Settings → Engines auth row). `auth_status_args` are
|
|
236
|
+
# appended to the resolved binary for a fast non-interactive login-status
|
|
237
|
+
# probe (drives GET /api/app/engine-auth/{name}); None → the engine
|
|
238
|
+
# shows no login row. `auth_login_args` is the CLI's own interactive
|
|
239
|
+
# login flow — the client runs it in a PTY terminal tab, so pick a
|
|
240
|
+
# variant whose prompts survive a remote box (device-code flows beat
|
|
241
|
+
# open-a-browser flows that bind localhost on the server).
|
|
242
|
+
auth_status_args: Optional[list] = None
|
|
243
|
+
auth_login_args: Optional[list] = None
|
|
244
|
+
# Auto-journal (rich-commit summary) model. The journal fork always runs
|
|
245
|
+
# on the session's OWN engine — only the engine that owns a conversation
|
|
246
|
+
# can fork it — so the summarizer model is inherently a per-engine
|
|
247
|
+
# setting. Providers storing the override in the global config set the
|
|
248
|
+
# key here (driver pairs share one, like `path_config_key`); providers
|
|
249
|
+
# with their own storage override the get/set methods instead (Claude →
|
|
250
|
+
# models.yaml `summary_model`). Neither → no journal-model knob in
|
|
251
|
+
# Settings. `summary_model_placeholder` names what an EMPTY override
|
|
252
|
+
# means for this engine (e.g. Codex inherits the session's model).
|
|
253
|
+
summary_model_config_key: Optional[str] = None
|
|
254
|
+
summary_model_placeholder: str = ""
|
|
255
|
+
capabilities: Capabilities = Capabilities()
|
|
256
|
+
|
|
257
|
+
# --- launch -----------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
@abstractmethod
|
|
260
|
+
def binary(self) -> str:
|
|
261
|
+
"""Resolve the CLI executable (name on PATH or absolute path)."""
|
|
262
|
+
|
|
263
|
+
def binary_not_found_hint(self) -> str:
|
|
264
|
+
"""User-facing remediation appended when the executable can't be spawned.
|
|
265
|
+
|
|
266
|
+
Providers with a configurable path setting should point at it here.
|
|
267
|
+
"""
|
|
268
|
+
return "Install the CLI and make sure it's on the server's PATH."
|
|
269
|
+
|
|
270
|
+
@abstractmethod
|
|
271
|
+
def build_command(self, opts: LaunchOptions) -> list[str]:
|
|
272
|
+
"""Build the full subprocess argv for a streaming session."""
|
|
273
|
+
|
|
274
|
+
@abstractmethod
|
|
275
|
+
def frame_input(self, message: dict) -> dict:
|
|
276
|
+
"""Envelope a user message for the CLI's stdin protocol.
|
|
277
|
+
|
|
278
|
+
Only called on the stdin line path (persistent ``"lines"`` providers).
|
|
279
|
+
The argument is the canonical user message; return the exact dict to
|
|
280
|
+
JSON-encode onto the process's stdin. Providers that never read turns
|
|
281
|
+
from stdin — ephemeral argv prompts (`persistent_process=False`) or a
|
|
282
|
+
JSON-RPC transport — should raise `NotImplementedError` so a mis-wired
|
|
283
|
+
send fails loudly instead of silently writing garbage.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def make_transport(self, process, opts: "LaunchOptions", session) -> Optional[object]:
|
|
287
|
+
"""Build the JSON-RPC transport driver for a freshly spawned process.
|
|
288
|
+
|
|
289
|
+
Only called when ``capabilities.transport == "jsonrpc"``; returns the
|
|
290
|
+
object the session layer drives instead of the stdin/stdout line
|
|
291
|
+
protocol (``None`` falls back to lines). It is intentionally duck-typed
|
|
292
|
+
— kept off the seam to avoid an asyncio dependency here — and must
|
|
293
|
+
provide:
|
|
294
|
+
|
|
295
|
+
async initialize() -> None
|
|
296
|
+
Run the post-spawn handshake (e.g. JSON-RPC ``initialize``).
|
|
297
|
+
intake(native: dict) -> bool
|
|
298
|
+
Inspect one parsed stdout message *before* translation: resolve
|
|
299
|
+
pending responses, answer server-initiated requests. Return True
|
|
300
|
+
to let it continue to ``translate_events`` (it's a stream event),
|
|
301
|
+
False to swallow it (protocol bookkeeping).
|
|
302
|
+
async send_turn(message: dict) -> bool
|
|
303
|
+
Send one user turn (the canonical user message), running any lazy
|
|
304
|
+
thread start/resume first.
|
|
305
|
+
async interrupt() -> None
|
|
306
|
+
Abort the in-flight turn without killing the process.
|
|
307
|
+
|
|
308
|
+
``process`` is the live `asyncio` subprocess, ``opts`` the resolved
|
|
309
|
+
`LaunchOptions` for this launch, and ``session`` the owning
|
|
310
|
+
`AgentSession` (for `cwd`, `session_id`, store id, metadata writes).
|
|
311
|
+
"""
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
# --- output parsing ---------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def parse_line(self, raw: str) -> dict:
|
|
317
|
+
"""Parse one raw stdout line into a canonical event dict.
|
|
318
|
+
|
|
319
|
+
Default is `json.loads` (identity for Claude's stream-json). Raises
|
|
320
|
+
`json.JSONDecodeError` on a non-event line, which the caller forwards as
|
|
321
|
+
`raw_output` — preserve that contract when overriding.
|
|
322
|
+
"""
|
|
323
|
+
return json.loads(raw)
|
|
324
|
+
|
|
325
|
+
def translate_events(self, event: dict, state: dict) -> list[dict]:
|
|
326
|
+
"""Translate one native event into zero or more canonical events.
|
|
327
|
+
|
|
328
|
+
The canonical shape is Claude's stream-json schema
|
|
329
|
+
(`system`/`assistant`/`user`/`result`), which the session handlers
|
|
330
|
+
consume. Claude is already canonical, so the default is identity. A
|
|
331
|
+
single native line may fan out (e.g. a Codex command result becomes an
|
|
332
|
+
assistant `tool_use` *and* a user `tool_result`), hence the list return.
|
|
333
|
+
|
|
334
|
+
`state` is a per-subprocess scratch dict owned and reset by the session
|
|
335
|
+
layer — providers stay stateless by stashing per-turn translation state
|
|
336
|
+
(item-id → tool-id maps, etc.) there rather than on `self`.
|
|
337
|
+
"""
|
|
338
|
+
return [event]
|
|
339
|
+
|
|
340
|
+
@abstractmethod
|
|
341
|
+
def session_id_from_event(self, event: dict) -> Optional[str]:
|
|
342
|
+
"""Extract the CLI's session id from a *native* (pre-translation)
|
|
343
|
+
event, or None when this event doesn't carry one.
|
|
344
|
+
|
|
345
|
+
Every engine names this differently (Claude: `session_id` on each
|
|
346
|
+
stream-json line; Codex: `thread_id` on `thread.started`), so there is
|
|
347
|
+
no neutral default — each provider reads its own wire.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def new_cost_state(self) -> CostState:
|
|
351
|
+
"""Fresh per-subprocess cost tracker (reset on each (re)start)."""
|
|
352
|
+
return CostState()
|
|
353
|
+
|
|
354
|
+
def normalize_result(self, msg: dict, state: CostState) -> None:
|
|
355
|
+
"""Normalize a `result` event in place (e.g. cumulative→delta).
|
|
356
|
+
|
|
357
|
+
Default is a no-op — the provider already reports per-turn figures.
|
|
358
|
+
Providers with `capabilities.cumulative_cost` rewrite `msg` using
|
|
359
|
+
`state` and update `state` for the next result.
|
|
360
|
+
"""
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
# --- error / lifecycle classification ---------------------------------
|
|
364
|
+
|
|
365
|
+
def classify_stderr(self, line: str) -> StderrClass:
|
|
366
|
+
"""Classify a stderr line. Default: ordinary diagnostic."""
|
|
367
|
+
return StderrClass.NONE
|
|
368
|
+
|
|
369
|
+
def is_retryable_api_error(self, text: str) -> bool:
|
|
370
|
+
"""Whether an error string is a transient API error worth retrying."""
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
def is_auth_error(self, text: str, api_error_status: Optional[int] = None) -> bool:
|
|
374
|
+
"""Whether a failed turn is an authentication failure the user can fix
|
|
375
|
+
by re-logging in (expired token, invalid key, 401). The session layer
|
|
376
|
+
surfaces a one-click re-login affordance when this is True.
|
|
377
|
+
|
|
378
|
+
`text` is the turn's result/error string; `api_error_status` is the
|
|
379
|
+
CLI's numeric HTTP status when present (Claude reports `api_error_status`
|
|
380
|
+
on the result event). Default: never an auth error.
|
|
381
|
+
"""
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
def is_usage_limit(self, text: str) -> bool:
|
|
385
|
+
"""Whether `text` reports a usage/rate/session limit ("You've hit your
|
|
386
|
+
limit · resets 5pm", "hit your session limit", …).
|
|
387
|
+
|
|
388
|
+
The session layer checks this against MORE than the result string,
|
|
389
|
+
because engines don't deliver limits uniformly: the Claude CLI can
|
|
390
|
+
surface one as a synthetic assistant bubble in a turn whose result is
|
|
391
|
+
`is_error:false` (e.g. a /compact continuation), or in a compaction
|
|
392
|
+
settle frame's `compact_error`. When True the turn is marked
|
|
393
|
+
rate-limited: the turn bar shows it and the /context probe is skipped.
|
|
394
|
+
|
|
395
|
+
Implementations should require SHORT text (genuine limit reports are
|
|
396
|
+
one-liners) so long prose that merely quotes a limit phrase — an
|
|
397
|
+
assistant reply discussing limits, pasted logs — can never match.
|
|
398
|
+
Default: never a limit.
|
|
399
|
+
"""
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def normal_termination_codes(self) -> frozenset:
|
|
404
|
+
"""Exit codes that mean "stopped normally", not "crashed". Defaults to
|
|
405
|
+
the signal-based set shared with the Claude CLI."""
|
|
406
|
+
from painapple_code.utils.agent_cli import NORMAL_TERMINATION_CODES
|
|
407
|
+
return NORMAL_TERMINATION_CODES
|
|
408
|
+
|
|
409
|
+
# --- rich-commit summary fork -----------------------------------------
|
|
410
|
+
|
|
411
|
+
def build_summary_fork(
|
|
412
|
+
self,
|
|
413
|
+
*,
|
|
414
|
+
session_id: str,
|
|
415
|
+
fork_prompt: str,
|
|
416
|
+
schema_json: str,
|
|
417
|
+
cwd: str,
|
|
418
|
+
token_profile: Optional[str] = None,
|
|
419
|
+
session_model: Optional[str] = None,
|
|
420
|
+
) -> Optional["SummaryForkPlan"]:
|
|
421
|
+
"""Plan the post-turn fork that generates rich-commit sections.
|
|
422
|
+
|
|
423
|
+
Returns a `SummaryForkPlan` (argv + temp files), or None when the
|
|
424
|
+
provider can't fork this turn (no session id, rollout not found, …) — in
|
|
425
|
+
which case the turn still gets a basic shadow commit, just without
|
|
426
|
+
AI-generated sections. Only called when
|
|
427
|
+
`capabilities.rich_commit_summaries` is set.
|
|
428
|
+
"""
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
def parse_summary_fork(
|
|
432
|
+
self,
|
|
433
|
+
*,
|
|
434
|
+
plan: "SummaryForkPlan",
|
|
435
|
+
returncode: int,
|
|
436
|
+
stdout: bytes,
|
|
437
|
+
stderr: bytes,
|
|
438
|
+
) -> tuple[Optional[dict], Optional[dict]]:
|
|
439
|
+
"""Parse the fork's output into (structured_data, cost).
|
|
440
|
+
|
|
441
|
+
`structured_data` is the section_id→value dict (or None on failure);
|
|
442
|
+
`cost` is `{"cost", "input_tokens", "output_tokens"}` (or None). The
|
|
443
|
+
shadow-git layer renders the dict to markdown and records the cost.
|
|
444
|
+
"""
|
|
445
|
+
return None, None
|
|
446
|
+
|
|
447
|
+
async def drive_summary_fork(
|
|
448
|
+
self,
|
|
449
|
+
proc,
|
|
450
|
+
plan: "SummaryForkPlan",
|
|
451
|
+
) -> tuple[Optional[dict], Optional[dict]]:
|
|
452
|
+
"""Conduct a transport-driven summary over an already-spawned process.
|
|
453
|
+
|
|
454
|
+
The shadow-git layer always spawns ``plan.argv``; for a provider whose
|
|
455
|
+
``capabilities.transport`` isn't the default ``"lines"`` it then hands the
|
|
456
|
+
live process here (stdin piped) instead of reading its stdout. The
|
|
457
|
+
provider runs whatever multi-step conversation its wire needs (e.g. a
|
|
458
|
+
JSON-RPC ``thread/fork`` + ``turn/start``) and returns the same
|
|
459
|
+
``(structured_data, cost)`` pair as `parse_summary_fork`, leaving the
|
|
460
|
+
process terminated. The shadow-git layer keeps owning the registry entry,
|
|
461
|
+
the 300s timeout, and cleanup.
|
|
462
|
+
|
|
463
|
+
Only called for non-``"lines"`` providers; the default raises so a
|
|
464
|
+
mis-declared provider fails loudly rather than silently producing no
|
|
465
|
+
sections.
|
|
466
|
+
"""
|
|
467
|
+
raise NotImplementedError(
|
|
468
|
+
f"{self.display_name} declares a non-line transport but has no "
|
|
469
|
+
"drive_summary_fork")
|
|
470
|
+
|
|
471
|
+
# --- introspection ----------------------------------------------------
|
|
472
|
+
|
|
473
|
+
async def fetch_context(
|
|
474
|
+
self,
|
|
475
|
+
cwd: str,
|
|
476
|
+
session_id: Optional[str] = None,
|
|
477
|
+
token_profile: Optional[str] = None,
|
|
478
|
+
model: Optional[str] = None,
|
|
479
|
+
) -> Optional[dict]:
|
|
480
|
+
"""Report context-window usage for a session, or None if unsupported.
|
|
481
|
+
|
|
482
|
+
Used by providers whose CLI has a dedicated context probe
|
|
483
|
+
(`capabilities.context_command`) — e.g. Claude forks a `/context` run.
|
|
484
|
+
Spawns a subprocess, so the session layer calls it in the background.
|
|
485
|
+
"""
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
def context_from_result(
|
|
489
|
+
self,
|
|
490
|
+
result_msg: dict,
|
|
491
|
+
model: Optional[str] = None,
|
|
492
|
+
) -> Optional[dict]:
|
|
493
|
+
"""Synthesize a context-window snapshot from a finished turn's result.
|
|
494
|
+
|
|
495
|
+
For engines with no `/context` command (`context_command=False`) that
|
|
496
|
+
nonetheless report per-turn token usage, the session layer calls this
|
|
497
|
+
right after a turn instead of `fetch_context` — deriving the meter from
|
|
498
|
+
the turn's own usage costs no extra subprocess. Returns the same shape
|
|
499
|
+
`fetch_context` does (``{contextTokens, contextWindow, percentage?,
|
|
500
|
+
breakdown?, memoryFiles?}``) or None when no usage is available.
|
|
501
|
+
|
|
502
|
+
Default None — providers that probe via `fetch_context` (Claude) leave
|
|
503
|
+
this unimplemented; the two paths are mutually exclusive per provider.
|
|
504
|
+
"""
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
# --- selection metadata (drives the engine picker UI) -----------------
|
|
508
|
+
|
|
509
|
+
def is_available(self) -> tuple[bool, Optional[str]]:
|
|
510
|
+
"""Whether this provider's CLI is installed and usable.
|
|
511
|
+
|
|
512
|
+
Returns ``(available, reason_if_not)``. The default probes `binary()`
|
|
513
|
+
on PATH; providers add auth/login checks by overriding. The UI greys
|
|
514
|
+
out unavailable providers and shows `reason` as the fix hint.
|
|
515
|
+
"""
|
|
516
|
+
import shutil
|
|
517
|
+
from pathlib import Path
|
|
518
|
+
try:
|
|
519
|
+
binary = self.binary()
|
|
520
|
+
except NotImplementedError as e:
|
|
521
|
+
return False, str(e)
|
|
522
|
+
except Exception as e: # pragma: no cover - defensive
|
|
523
|
+
return False, f"{self.display_name} unavailable: {e}"
|
|
524
|
+
found = Path(binary).exists() if Path(binary).is_absolute() else shutil.which(binary)
|
|
525
|
+
if not found:
|
|
526
|
+
return False, f"{binary} not found on PATH"
|
|
527
|
+
return True, None
|
|
528
|
+
|
|
529
|
+
def parse_auth_status(self, returncode: int, stdout: str, stderr: str) -> dict:
|
|
530
|
+
"""Interpret the `auth_status_args` probe → ``{logged_in, detail}``.
|
|
531
|
+
|
|
532
|
+
Default: exit 0 = logged in, first non-empty output line as the
|
|
533
|
+
user-facing detail (scans both streams — CLIs disagree on where
|
|
534
|
+
status text goes). Providers with structured status output override.
|
|
535
|
+
"""
|
|
536
|
+
line = next(
|
|
537
|
+
(ln.strip() for ln in f"{stdout or ''}\n{stderr or ''}".splitlines() if ln.strip()),
|
|
538
|
+
"")
|
|
539
|
+
return {"logged_in": returncode == 0, "detail": line[:160]}
|
|
540
|
+
|
|
541
|
+
def accounts(self) -> list[dict]:
|
|
542
|
+
"""Selectable identities/accounts for this provider.
|
|
543
|
+
|
|
544
|
+
The session "account" leaf under a provider: each entry is
|
|
545
|
+
``{"id": str, "label": str}`` where ``id`` is the value stored on the
|
|
546
|
+
session (empty string = the provider's default/ambient login). Claude
|
|
547
|
+
maps these to token profiles; Codex uses ambient `codex login` and so
|
|
548
|
+
returns none. Empty list = the UI shows no account sub-menu.
|
|
549
|
+
"""
|
|
550
|
+
return []
|
|
551
|
+
|
|
552
|
+
def models(self) -> list[dict]:
|
|
553
|
+
"""In-app selectable models for this provider: ``{id, label, desc}``.
|
|
554
|
+
|
|
555
|
+
Empty = the app doesn't manage this engine's model (e.g. Codex uses the
|
|
556
|
+
model configured by `codex login`), and the UI hides the model chip.
|
|
557
|
+
|
|
558
|
+
This is the FULL catalog (definitions). What pickers actually offer is
|
|
559
|
+
`enabled_models()` — the same list minus ids the user hid in Settings.
|
|
560
|
+
"""
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
def disabled_model_ids(self) -> set:
|
|
564
|
+
"""Model ids the user hid for this engine (Settings → Engines toggles).
|
|
565
|
+
|
|
566
|
+
Read from the global config's ``models_disabled`` map under this
|
|
567
|
+
provider's `models_key` namespace. May contain ids not in the current
|
|
568
|
+
catalog (e.g. a CLI-owned catalog changed underneath) — kept as-is so
|
|
569
|
+
the preference survives catalog churn.
|
|
570
|
+
"""
|
|
571
|
+
from painapple_code import paths
|
|
572
|
+
raw = (paths.load_global_config().get("models_disabled") or {})
|
|
573
|
+
ids = raw.get(self.models_key or self.name)
|
|
574
|
+
if not isinstance(ids, list):
|
|
575
|
+
return set()
|
|
576
|
+
return {x for x in ids if isinstance(x, str) and x}
|
|
577
|
+
|
|
578
|
+
def summary_model_editable(self) -> bool:
|
|
579
|
+
"""Whether Settings shows an auto-journal model knob for this engine."""
|
|
580
|
+
return bool(self.summary_model_config_key)
|
|
581
|
+
|
|
582
|
+
def get_summary_model_override(self) -> Optional[str]:
|
|
583
|
+
"""The configured journal-model override; None/empty = engine default
|
|
584
|
+
(for Codex that means the summary fork inherits the thread's model)."""
|
|
585
|
+
if not self.summary_model_config_key:
|
|
586
|
+
return None
|
|
587
|
+
from painapple_code import paths
|
|
588
|
+
value = paths.load_global_config().get(self.summary_model_config_key)
|
|
589
|
+
return value if isinstance(value, str) and value else None
|
|
590
|
+
|
|
591
|
+
def set_summary_model_override(self, value: Optional[str]) -> None:
|
|
592
|
+
"""Persist (or clear with None/empty) the journal-model override."""
|
|
593
|
+
if not self.summary_model_config_key:
|
|
594
|
+
raise ValueError(f"{self.display_name} has no journal-model setting")
|
|
595
|
+
from painapple_code import paths
|
|
596
|
+
config = paths.load_global_config()
|
|
597
|
+
cleaned = (value or "").strip()
|
|
598
|
+
if cleaned:
|
|
599
|
+
config[self.summary_model_config_key] = cleaned
|
|
600
|
+
else:
|
|
601
|
+
config.pop(self.summary_model_config_key, None)
|
|
602
|
+
paths.save_global_config(config)
|
|
603
|
+
|
|
604
|
+
def enabled_models(self) -> list[dict]:
|
|
605
|
+
"""`models()` minus the user's hidden set — the catalog pickers offer.
|
|
606
|
+
|
|
607
|
+
Everything that treats the catalog as an *offering* consumes this
|
|
608
|
+
(describe() → /api/providers → chip/popup/setup panel/default-model
|
|
609
|
+
select, and `preferred_model_survives`). Launch-side foreign-id guards
|
|
610
|
+
keep using the raw `models()` so a session bound to a since-hidden
|
|
611
|
+
model keeps working.
|
|
612
|
+
"""
|
|
613
|
+
disabled = self.disabled_model_ids()
|
|
614
|
+
if not disabled:
|
|
615
|
+
return self.models()
|
|
616
|
+
return [m for m in self.models() if m.get("id") not in disabled]
|
|
617
|
+
|
|
618
|
+
def effort_levels(self) -> list[str]:
|
|
619
|
+
"""Effort levels this engine meaningfully distinguishes, low→high.
|
|
620
|
+
|
|
621
|
+
Empty when `capabilities.effort` is False. Claude exposes the full
|
|
622
|
+
low→max scale; Codex caps at high, so it lists only what changes the
|
|
623
|
+
outcome (the UI hides the rest).
|
|
624
|
+
"""
|
|
625
|
+
return []
|
|
626
|
+
|
|
627
|
+
@abstractmethod
|
|
628
|
+
def permission_modes(self) -> list[dict]:
|
|
629
|
+
"""Permission options for this engine: ``{value, label, desc, color}``.
|
|
630
|
+
|
|
631
|
+
``value`` is this provider's OWN native vocabulary, stored verbatim on
|
|
632
|
+
the session and passed through at launch (e.g. Codex →
|
|
633
|
+
read-only/workspace-write/danger-full-access). ``color`` lets the UI
|
|
634
|
+
render the tier without knowing the provider's scheme. Every provider
|
|
635
|
+
must self-describe its modes — there is no shared fallback vocabulary
|
|
636
|
+
(the permission endpoints and the engine picker read this list, and
|
|
637
|
+
`valid_permission_values()` unions it across providers).
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
def default_permission_mode(self) -> Optional[str]:
|
|
641
|
+
"""This provider's own default permission value (one of
|
|
642
|
+
``permission_modes()``), used for a fresh session instead of inheriting
|
|
643
|
+
the app's global (Claude) default. ``None`` → use the global default.
|
|
644
|
+
"""
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
# --- customization surfaces (skills / agents browsers) ----------------
|
|
648
|
+
|
|
649
|
+
def skill_roots(self, cwd: str) -> list[dict]:
|
|
650
|
+
"""Editable skill-search roots for this engine, highest-priority first.
|
|
651
|
+
|
|
652
|
+
Each entry is ``{"scope": str, "dir": Path}`` for a directory holding
|
|
653
|
+
folder-form skills (``<name>/SKILL.md`` — the open Agent Skills format
|
|
654
|
+
every engine shares). The generic skills route walks them in order; the
|
|
655
|
+
first entry for a given scope is also where a new skill of that scope is
|
|
656
|
+
created. An engine may list several dirs under one scope (e.g. a
|
|
657
|
+
preferred location plus a legacy one). Empty → no skills for this engine.
|
|
658
|
+
"""
|
|
659
|
+
return []
|
|
660
|
+
|
|
661
|
+
def plugin_skill_dirs(self) -> list[dict]:
|
|
662
|
+
"""Read-only plugin-provided skill roots: ``[{"label": str, "dir": Path}]``.
|
|
663
|
+
|
|
664
|
+
Each ``dir`` holds folder-form skills like `skill_roots`, sourced from
|
|
665
|
+
this engine's installed plugins/marketplaces (so the provider owns the
|
|
666
|
+
marketplace layout). Empty → no plugin skills surfaced.
|
|
667
|
+
"""
|
|
668
|
+
return []
|
|
669
|
+
|
|
670
|
+
def agent_roots(self, cwd: str) -> list[dict]:
|
|
671
|
+
"""Agent-definition search roots for the agents browser, highest-priority
|
|
672
|
+
first. Each entry is ``{"scope": str, "dir": Path, "fmt": str,
|
|
673
|
+
"writable": bool}``.
|
|
674
|
+
|
|
675
|
+
``fmt`` selects the parser: ``"markdown"`` (Claude's flat ``<name>.md``
|
|
676
|
+
with YAML frontmatter) or ``"toml"`` (Codex's ``<name>.toml``). The route
|
|
677
|
+
normalizes both into the same ``{frontmatter, body}`` the widget renders.
|
|
678
|
+
``writable=False`` makes the browser show those agents read-only (no
|
|
679
|
+
create/edit/delete). Empty → no agents for this engine.
|
|
680
|
+
"""
|
|
681
|
+
return []
|
|
682
|
+
|
|
683
|
+
def plugin_backend(self) -> Optional["PluginBackend"]:
|
|
684
|
+
"""How to drive this engine's plugin-manager CLI, or None if it has no
|
|
685
|
+
plugin system. The plugins route uses this instead of assuming Claude."""
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
def describe(self) -> dict:
|
|
689
|
+
"""Serialize provider metadata (capabilities, availability, accounts)."""
|
|
690
|
+
from dataclasses import asdict
|
|
691
|
+
available, reason = self.is_available()
|
|
692
|
+
return {
|
|
693
|
+
"name": self.name,
|
|
694
|
+
"display_name": self.display_name,
|
|
695
|
+
"description": self.description,
|
|
696
|
+
"default_enabled": self.default_enabled,
|
|
697
|
+
"capabilities": asdict(self.capabilities),
|
|
698
|
+
"available": available,
|
|
699
|
+
"unavailable_reason": reason,
|
|
700
|
+
"accounts": self.accounts(),
|
|
701
|
+
"models": self.enabled_models(),
|
|
702
|
+
"models_editable": self.models_editable,
|
|
703
|
+
"models_key": self.models_key or self.name,
|
|
704
|
+
"path_configurable": bool(self.path_config_key),
|
|
705
|
+
"default_binary": self.default_binary,
|
|
706
|
+
"efforts": self.effort_levels(),
|
|
707
|
+
"permission_modes": self.permission_modes(),
|
|
708
|
+
"default_permission_mode": self.default_permission_mode(),
|
|
709
|
+
"cli_resume_template": self.cli_resume_template,
|
|
710
|
+
}
|