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,837 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket /chat endpoint — agent (provider) interaction protocol.
|
|
3
|
+
|
|
4
|
+
Handles connection setup, session resolution (load existing or create
|
|
5
|
+
pending), the per-message-type dispatch (user_message / ping / stop /
|
|
6
|
+
clear_session / tool_answer / set_permission_mode), and disconnect
|
|
7
|
+
cleanup. Per-message-type handlers are private async helpers in this
|
|
8
|
+
module so the route function stays a thin connection lifecycle + dispatch.
|
|
9
|
+
|
|
10
|
+
Public surface:
|
|
11
|
+
- `router` — APIRouter included by `server.py`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import asdict
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
25
|
+
|
|
26
|
+
from painapple_code import paths
|
|
27
|
+
from painapple_code.auth_middleware import (
|
|
28
|
+
_client_identity,
|
|
29
|
+
check_websocket_auth,
|
|
30
|
+
check_websocket_origin,
|
|
31
|
+
record_auth_identity,
|
|
32
|
+
)
|
|
33
|
+
from painapple_code.providers import get_provider, provider_names
|
|
34
|
+
from painapple_code.routes.dependencies import provider_is_locked
|
|
35
|
+
from painapple_code.server_logging import log_websocket_event
|
|
36
|
+
from painapple_code.session_store import SessionStore
|
|
37
|
+
from painapple_code.utils.agent_cli import get_max_thinking_tokens
|
|
38
|
+
from painapple_code.utils.file_paths import extract_file_links, safe_resolve
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
# Grab the singleton only — setup_logging() in main() attaches the file handler.
|
|
42
|
+
# Calling get_access_logger() here would create the log dir + open access.log as
|
|
43
|
+
# an import side effect (e.g. on `--help`).
|
|
44
|
+
access_logger = logging.getLogger("painapple-code.access")
|
|
45
|
+
|
|
46
|
+
router = APIRouter(tags=["chat"])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.websocket("/chat")
|
|
50
|
+
async def websocket_chat(websocket: WebSocket, cwd: str = None, session: str = None,
|
|
51
|
+
provider: str = None):
|
|
52
|
+
"""
|
|
53
|
+
WebSocket endpoint for agent (provider) interaction.
|
|
54
|
+
|
|
55
|
+
Sessions are bound to store_id and persist beyond WebSocket connections.
|
|
56
|
+
If you reconnect to a session where the agent is still running, you
|
|
57
|
+
resume receiving output. If the agent finished, it will be restarted.
|
|
58
|
+
|
|
59
|
+
Protocol:
|
|
60
|
+
- Client sends: {"type": "user_message", "content": "..."}
|
|
61
|
+
- Server sends: {"type": "agent_message", "data": {...}}
|
|
62
|
+
- Server sends: {"type": "session_ended", "reason": "..."}
|
|
63
|
+
|
|
64
|
+
Query params:
|
|
65
|
+
- cwd: Working directory for Claude (used when creating new session)
|
|
66
|
+
- session: Server-side session ID (to join/resume existing session)
|
|
67
|
+
- provider: Engine for a NEW session (picker choice). Ignored on resume —
|
|
68
|
+
a session is bound to its engine at creation (its provider_session_id
|
|
69
|
+
only means anything to that engine). Unknown names fall back to the
|
|
70
|
+
default rather than failing the connect.
|
|
71
|
+
"""
|
|
72
|
+
# Read the manager from app.state — `from server import agents` would grab
|
|
73
|
+
# a second copy of the server module where `agents` stays None (server.py
|
|
74
|
+
# runs as __main__, so a `server` re-import re-runs module-level code
|
|
75
|
+
# without main() and never sets it).
|
|
76
|
+
await websocket.accept()
|
|
77
|
+
agents = websocket.app.state.agents
|
|
78
|
+
|
|
79
|
+
state = websocket.app.state
|
|
80
|
+
# Origin boundary: WS bypasses CORS, so a cross-origin browser page could
|
|
81
|
+
# otherwise open this socket with the user's ambient cookie.
|
|
82
|
+
if not check_websocket_origin(websocket, getattr(state, "allowed_origins", None)):
|
|
83
|
+
await websocket.close(code=1008, reason="forbidden origin")
|
|
84
|
+
return
|
|
85
|
+
cookie_token = getattr(state, "auth_cookie_token", None)
|
|
86
|
+
api_token = getattr(state, "auth_api_token", None)
|
|
87
|
+
if not cookie_token or not api_token or not check_websocket_auth(websocket, cookie_token, api_token):
|
|
88
|
+
await websocket.close(code=1008, reason="unauthorized")
|
|
89
|
+
return
|
|
90
|
+
record_auth_identity("ws", *_client_identity(websocket.scope))
|
|
91
|
+
|
|
92
|
+
# Get client IP for logging
|
|
93
|
+
client_ip = websocket.client.host if websocket.client else "unknown"
|
|
94
|
+
log_websocket_event(access_logger, "CONNECT", session_id=session, client_ip=client_ip)
|
|
95
|
+
|
|
96
|
+
# Step 1: Get or create the store (persistent storage)
|
|
97
|
+
# For new sessions, we create metadata but DON'T persist to disk yet.
|
|
98
|
+
# Session is only persisted when first message is sent (avoids empty sessions in history).
|
|
99
|
+
store_data = None
|
|
100
|
+
already_persisted = False
|
|
101
|
+
if session:
|
|
102
|
+
store_data = SessionStore.load(session)
|
|
103
|
+
if not store_data:
|
|
104
|
+
await websocket.send_json({
|
|
105
|
+
"type": "error",
|
|
106
|
+
"message": f"Session not found: {session}"
|
|
107
|
+
})
|
|
108
|
+
await websocket.close()
|
|
109
|
+
return
|
|
110
|
+
resolved_cwd = store_data["cwd"]
|
|
111
|
+
provider_session_id = store_data.get("provider_session_id")
|
|
112
|
+
# Check if this is a forked session that hasn't been started yet
|
|
113
|
+
forked_from = store_data.get("forked_from")
|
|
114
|
+
fork_from_session_id = None
|
|
115
|
+
if forked_from and not provider_session_id:
|
|
116
|
+
# This is a fresh fork - use the source session ID for --fork-session
|
|
117
|
+
# (back-compat: nested forked_from predates the provider_session_id rename)
|
|
118
|
+
fork_from_session_id = forked_from.get("provider_session_id") or forked_from.get("claude_session_id")
|
|
119
|
+
logger.info(f"Session {session} is a fork, will use --fork-session from {fork_from_session_id}")
|
|
120
|
+
# Check if this is a comment thread (skip shadow git)
|
|
121
|
+
is_comment_thread = store_data.get("isCommentThread", False)
|
|
122
|
+
token_profile = store_data.get("token_profile")
|
|
123
|
+
provider_name = store_data.get("provider") # None → default (Claude)
|
|
124
|
+
already_persisted = True # Loaded from disk
|
|
125
|
+
else:
|
|
126
|
+
# Create pending session (not persisted to disk yet)
|
|
127
|
+
resolved_cwd = agents.default_cwd
|
|
128
|
+
if cwd:
|
|
129
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
130
|
+
store_data = SessionStore.create_pending(resolved_cwd)
|
|
131
|
+
provider_session_id = None
|
|
132
|
+
fork_from_session_id = None
|
|
133
|
+
is_comment_thread = False # New sessions are never comment threads
|
|
134
|
+
token_profile = None # Set via API before first message
|
|
135
|
+
# Engine selection, most specific wins: the client's picker choice
|
|
136
|
+
# (?provider= on this connect), then the box-wide default
|
|
137
|
+
# (--default-provider flag / `default_provider` config key). The result
|
|
138
|
+
# is recorded in meta so the session stays on that engine across
|
|
139
|
+
# reconnects; when nothing is set, provider_name stays None and the
|
|
140
|
+
# session falls back to DEFAULT_PROVIDER (claude-sdk) at spawn. An
|
|
141
|
+
# unknown picker value degrades to the default flow instead of failing
|
|
142
|
+
# the connect (mirrors get_provider()'s stale-meta tolerance).
|
|
143
|
+
requested_provider = None
|
|
144
|
+
if provider:
|
|
145
|
+
if provider in provider_names():
|
|
146
|
+
requested_provider = provider
|
|
147
|
+
else:
|
|
148
|
+
logger.warning(f"Ignoring unknown provider {provider!r} on session create")
|
|
149
|
+
provider_name = (requested_provider
|
|
150
|
+
or agents.default_provider
|
|
151
|
+
or paths.load_global_config().get("default_provider"))
|
|
152
|
+
if provider_name:
|
|
153
|
+
store_data["provider"] = provider_name
|
|
154
|
+
# Bind-time permission anchoring: when the app-wide default level
|
|
155
|
+
# isn't in this engine's vocabulary, stamp the engine's own
|
|
156
|
+
# default so meta, UI, and launch agree from the first frame.
|
|
157
|
+
from painapple_code.routes.dependencies import bind_permission_level
|
|
158
|
+
anchored = bind_permission_level(None, get_provider(provider_name))
|
|
159
|
+
if anchored:
|
|
160
|
+
store_data["permission_level"] = anchored
|
|
161
|
+
already_persisted = False
|
|
162
|
+
|
|
163
|
+
store_id = store_data["id"]
|
|
164
|
+
|
|
165
|
+
# Step 2: Get or create the runtime session (keyed by store_id)
|
|
166
|
+
existing_session = agents.get_session(store_id)
|
|
167
|
+
|
|
168
|
+
# Determine if this is a reconnect:
|
|
169
|
+
# - If session param was provided in URL, user is returning to existing session
|
|
170
|
+
# - OR if session exists in memory registry
|
|
171
|
+
# This handles both page refresh (registry hit) and server restart (URL param)
|
|
172
|
+
is_reconnect = session is not None or existing_session is not None
|
|
173
|
+
|
|
174
|
+
if existing_session:
|
|
175
|
+
agent_session = existing_session
|
|
176
|
+
# Attach additively — every connected client receives the broadcast
|
|
177
|
+
# stream. The old takeover (close the previous socket, error toast
|
|
178
|
+
# "Another client connected") silently starved whichever tab/device/
|
|
179
|
+
# token lost the slot: it kept its dead view, missed the turn's result
|
|
180
|
+
# frame, and stayed stuck on "working" with no reason to reconnect.
|
|
181
|
+
agent_session.attach_websocket(websocket)
|
|
182
|
+
else:
|
|
183
|
+
# Create new session (with pending metadata if not persisted)
|
|
184
|
+
agent_session = agents.get_or_create_session(
|
|
185
|
+
store_id=store_id,
|
|
186
|
+
cwd=resolved_cwd,
|
|
187
|
+
store_meta=store_data if not already_persisted else None,
|
|
188
|
+
already_persisted=already_persisted,
|
|
189
|
+
session_id=provider_session_id,
|
|
190
|
+
fork_from_session_id=fork_from_session_id,
|
|
191
|
+
is_comment_thread=is_comment_thread,
|
|
192
|
+
token_profile=token_profile,
|
|
193
|
+
provider_name=provider_name,
|
|
194
|
+
)
|
|
195
|
+
agent_session.attach_websocket(websocket)
|
|
196
|
+
|
|
197
|
+
# Persist new sessions to disk immediately on WebSocket connect.
|
|
198
|
+
# This ensures they appear on the welcome screen, survive refreshes,
|
|
199
|
+
# and the browser gets a storeId to save in localStorage.
|
|
200
|
+
if not already_persisted:
|
|
201
|
+
agent_session.ensure_persisted()
|
|
202
|
+
already_persisted = True
|
|
203
|
+
|
|
204
|
+
logger.info(f"WebSocket {'reconnected' if is_reconnect else 'connected'}, "
|
|
205
|
+
f"store_id={store_id}, cwd={agent_session.cwd}, "
|
|
206
|
+
f"is_running={agent_session.is_running}, process={agent_session.process is not None}")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Step 3: DON'T start Claude automatically for new sessions
|
|
210
|
+
# Claude starts when the user sends first message.
|
|
211
|
+
|
|
212
|
+
# Step 4: Send connection status
|
|
213
|
+
is_actively_processing = agent_session.is_running and not agent_session.is_idle
|
|
214
|
+
if is_reconnect and is_actively_processing:
|
|
215
|
+
status_msg = "Reconnected to running session"
|
|
216
|
+
elif is_reconnect:
|
|
217
|
+
status_msg = "Reconnected to session"
|
|
218
|
+
else:
|
|
219
|
+
status_msg = "Ready" # New session, Claude will start on first message
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
workspace_path = str(safe_resolve(agents.default_cwd))
|
|
223
|
+
except Exception:
|
|
224
|
+
workspace_path = agents.default_cwd
|
|
225
|
+
# The session's engine identity + capabilities ride the connected
|
|
226
|
+
# payload so the client can badge the tab and gate per-engine chrome
|
|
227
|
+
# (model chip, fork/Discuss, USD cost, /context) without a second
|
|
228
|
+
# round-trip. agent_session.provider is set at runtime-session
|
|
229
|
+
# creation; the get_provider fallback covers the theoretical gap.
|
|
230
|
+
session_provider = agent_session.provider or get_provider(provider_name)
|
|
231
|
+
connected_msg = {
|
|
232
|
+
"type": "connected",
|
|
233
|
+
"message": status_msg,
|
|
234
|
+
"cwd": agent_session.cwd,
|
|
235
|
+
"home": str(Path.home()),
|
|
236
|
+
"workspace": workspace_path,
|
|
237
|
+
"session_id": store_id,
|
|
238
|
+
"is_reconnect": is_reconnect,
|
|
239
|
+
"agent_running": agent_session.is_running and not agent_session.is_idle,
|
|
240
|
+
"is_compacting": agent_session.is_compacting,
|
|
241
|
+
"provider": session_provider.name,
|
|
242
|
+
"provider_display_name": session_provider.display_name,
|
|
243
|
+
"provider_caps": asdict(session_provider.capabilities),
|
|
244
|
+
# Engine still switchable? Locks permanently after the first turn.
|
|
245
|
+
"provider_locked": provider_is_locked(store_data or {}),
|
|
246
|
+
}
|
|
247
|
+
# Include turn start time so client can show accurate elapsed timer on reconnect
|
|
248
|
+
if is_actively_processing and agent_session.turn_tracker and agent_session.turn_tracker.turn_start:
|
|
249
|
+
connected_msg["turn_start"] = agent_session.turn_tracker.turn_start
|
|
250
|
+
if store_data:
|
|
251
|
+
connected_msg["name"] = store_data.get("name")
|
|
252
|
+
await websocket.send_json(connected_msg)
|
|
253
|
+
|
|
254
|
+
# Replay any permission ask the provider is still blocked on, so a
|
|
255
|
+
# reconnecting client re-renders the approve/deny card (the original
|
|
256
|
+
# frame died with the previous WebSocket).
|
|
257
|
+
for pending_req in list(agent_session._pending_permission_requests.values()):
|
|
258
|
+
await websocket.send_json({
|
|
259
|
+
"type": "permission_request",
|
|
260
|
+
**{k: v for k, v in pending_req.items() if k != "type"},
|
|
261
|
+
"replay": True,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
# Step 5: Main message loop
|
|
265
|
+
while True:
|
|
266
|
+
try:
|
|
267
|
+
data = await websocket.receive_json()
|
|
268
|
+
msg_type = data.get("type")
|
|
269
|
+
agent_session.touch() # Update activity timestamp
|
|
270
|
+
|
|
271
|
+
if msg_type == "user_message":
|
|
272
|
+
await _handle_user_message(websocket, agent_session, store_id, data, agents)
|
|
273
|
+
elif msg_type == "ping":
|
|
274
|
+
await websocket.send_json({"type": "pong"})
|
|
275
|
+
elif msg_type == "stop":
|
|
276
|
+
await _handle_stop(websocket, agent_session, agents)
|
|
277
|
+
elif msg_type == "clear_session":
|
|
278
|
+
await _handle_clear_session(websocket, agent_session, store_id, agents)
|
|
279
|
+
elif msg_type == "tool_answer":
|
|
280
|
+
await _handle_tool_answer(websocket, agent_session, store_id, data, agents)
|
|
281
|
+
elif msg_type == "permission_response":
|
|
282
|
+
await _handle_permission_response(websocket, agent_session, data, agents)
|
|
283
|
+
elif msg_type == "set_permission_mode":
|
|
284
|
+
await _handle_set_permission_mode(websocket, agent_session, store_id, data, agents)
|
|
285
|
+
else:
|
|
286
|
+
logger.warning(f"Unknown message type: {msg_type}")
|
|
287
|
+
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
await websocket.send_json({
|
|
290
|
+
"type": "error",
|
|
291
|
+
"message": "Invalid JSON"
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
except WebSocketDisconnect:
|
|
295
|
+
logger.info(f"WebSocket disconnected for session {store_id}")
|
|
296
|
+
log_websocket_event(access_logger, "DISCONNECT", session_id=store_id, client_ip=client_ip)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(f"WebSocket error for session {store_id}: {e}")
|
|
299
|
+
log_websocket_event(access_logger, "ERROR", session_id=store_id, client_ip=client_ip, details=str(e))
|
|
300
|
+
# Log to session's raw.jsonl with full traceback
|
|
301
|
+
if store_id:
|
|
302
|
+
SessionStore.log_raw_error(
|
|
303
|
+
store_id,
|
|
304
|
+
f"server_error: WebSocket handler error: {e}",
|
|
305
|
+
traceback.format_exc()
|
|
306
|
+
)
|
|
307
|
+
finally:
|
|
308
|
+
# Just detach WebSocket - don't stop Claude!
|
|
309
|
+
# Claude will continue running and output will be saved to store.
|
|
310
|
+
# Client can reconnect and resume receiving output.
|
|
311
|
+
# Pass our specific websocket so a takeover's new ws isn't nuked.
|
|
312
|
+
agent_session.detach_websocket(websocket)
|
|
313
|
+
logger.info(f"WebSocket detached from session {store_id}, "
|
|
314
|
+
f"Claude still running: {agent_session.is_running}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ───────────────────────────────────────────────────────────────────────
|
|
318
|
+
# Per-message-type handlers
|
|
319
|
+
# ───────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
async def _handle_user_message(websocket, agent_session, store_id, data, agents) -> None:
|
|
322
|
+
"""Handle a `user_message` from the client.
|
|
323
|
+
|
|
324
|
+
Records the prompt in SessionStore + Shadow DB, applies any token-profile
|
|
325
|
+
or model overrides delivered with this turn, ensures Claude is running,
|
|
326
|
+
and forwards the message (text + images) to the subprocess.
|
|
327
|
+
"""
|
|
328
|
+
# Lazy imports to keep module-load cheap and avoid pulling DuckDB if
|
|
329
|
+
# the chat WS is never opened.
|
|
330
|
+
from painapple_code.shadow_db import get_shadow_db
|
|
331
|
+
|
|
332
|
+
content = data.get("content", "")
|
|
333
|
+
images = data.get("images", []) # List of image objects from /api/upload-image
|
|
334
|
+
# For storage: use displayContent if provided (original without stash prefix)
|
|
335
|
+
display_content = data.get("displayContent", content)
|
|
336
|
+
stash_refs = data.get("stashRefs") # Array of stash items for display
|
|
337
|
+
file_paths = data.get("files") or [] # Uploaded file paths attached to this prompt
|
|
338
|
+
plan_mode = data.get("planMode", False) # True if sent in plan mode
|
|
339
|
+
mark_as_favorite = data.get("markAsFavorite", False) # Mark prompt as favorite
|
|
340
|
+
|
|
341
|
+
# Build message content - can be string or array with text+images
|
|
342
|
+
if images:
|
|
343
|
+
# Multi-part message with images
|
|
344
|
+
message_content = []
|
|
345
|
+
if content:
|
|
346
|
+
message_content.append({"type": "text", "text": content})
|
|
347
|
+
for img in images:
|
|
348
|
+
# img should be the "image" object from upload response
|
|
349
|
+
message_content.append(img)
|
|
350
|
+
agent_msg = {
|
|
351
|
+
"type": "user",
|
|
352
|
+
"message": {
|
|
353
|
+
"role": "user",
|
|
354
|
+
"content": message_content
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else:
|
|
358
|
+
# Simple text message
|
|
359
|
+
agent_msg = {
|
|
360
|
+
"type": "user",
|
|
361
|
+
"message": {
|
|
362
|
+
"role": "user",
|
|
363
|
+
"content": content
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# Save images to disk for persistence
|
|
368
|
+
image_files = []
|
|
369
|
+
if images:
|
|
370
|
+
try:
|
|
371
|
+
uploads_dir = SessionStore.get_uploads_path(store_id)
|
|
372
|
+
for i, img in enumerate(images):
|
|
373
|
+
source = img.get("source", {})
|
|
374
|
+
if source.get("type") == "base64" and source.get("data"):
|
|
375
|
+
media_type = source.get("media_type", "image/jpeg")
|
|
376
|
+
ext = "jpg" if "jpeg" in media_type else media_type.split("/")[-1]
|
|
377
|
+
filename = f"img_{int(time.time()*1000)}_{uuid.uuid4().hex[:6]}.{ext}"
|
|
378
|
+
img_data = base64.b64decode(source["data"])
|
|
379
|
+
(uploads_dir / filename).write_bytes(img_data)
|
|
380
|
+
image_files.append(filename)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.warning(f"Failed to save images for session {store_id}: {e}")
|
|
383
|
+
|
|
384
|
+
# Use display_content for storage (original without stash prefix)
|
|
385
|
+
# Get thinking tokens for this session
|
|
386
|
+
session_meta = SessionStore.load_meta(store_id)
|
|
387
|
+
thinking_tokens = None
|
|
388
|
+
if session_meta:
|
|
389
|
+
thinking_tokens = session_meta.get("max_thinking_tokens")
|
|
390
|
+
if thinking_tokens is None:
|
|
391
|
+
thinking_tokens = get_max_thinking_tokens()
|
|
392
|
+
|
|
393
|
+
# Extract verified file paths for linkification
|
|
394
|
+
verified_files = {}
|
|
395
|
+
if agent_session.cwd and display_content:
|
|
396
|
+
for link in extract_file_links(display_content, agent_session.cwd):
|
|
397
|
+
verified_files[link['path']] = link['resolved']
|
|
398
|
+
|
|
399
|
+
user_msg = {
|
|
400
|
+
"role": "user",
|
|
401
|
+
"content": display_content,
|
|
402
|
+
"has_images": len(images) > 0,
|
|
403
|
+
"image_count": len(images),
|
|
404
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
405
|
+
"thinking_tokens": thinking_tokens if thinking_tokens > 0 else None,
|
|
406
|
+
}
|
|
407
|
+
if image_files:
|
|
408
|
+
user_msg["image_files"] = image_files
|
|
409
|
+
# Uploaded file attachments. The paths live in the model-facing `content`
|
|
410
|
+
# only; `display_content` is the user's own text, so without this the
|
|
411
|
+
# reloaded bubble would lose the "[N files attached]" indicator that the
|
|
412
|
+
# live one shows.
|
|
413
|
+
if file_paths:
|
|
414
|
+
user_msg["has_files"] = True
|
|
415
|
+
user_msg["file_count"] = len(file_paths)
|
|
416
|
+
user_msg["file_paths"] = file_paths
|
|
417
|
+
if verified_files:
|
|
418
|
+
user_msg["verifiedFiles"] = verified_files
|
|
419
|
+
# Include stash refs if present (for display in message bubble)
|
|
420
|
+
if stash_refs:
|
|
421
|
+
user_msg["hasRefs"] = True
|
|
422
|
+
user_msg["refCount"] = len(stash_refs)
|
|
423
|
+
user_msg["stashRefs"] = stash_refs
|
|
424
|
+
# Include plan mode flag if message was sent in plan mode
|
|
425
|
+
if plan_mode:
|
|
426
|
+
user_msg["planMode"] = True
|
|
427
|
+
line_number = SessionStore.add_message(store_id, user_msg)
|
|
428
|
+
|
|
429
|
+
# Broadcast the stored prompt to every attached client. Peer tabs/devices
|
|
430
|
+
# on the same session see it live instead of waiting for their next sync
|
|
431
|
+
# (where the old content-window dedup could swallow it entirely — the
|
|
432
|
+
# missing-second-/compact bug). `line` lets clients derive the stable sid
|
|
433
|
+
# ("{store_id}:{line}", same scheme as promptId/api_logs); the sender's
|
|
434
|
+
# own local copy adopts it idempotently instead of duplicating.
|
|
435
|
+
if line_number > 0:
|
|
436
|
+
await agent_session.safe_send({
|
|
437
|
+
"type": "message_stored",
|
|
438
|
+
"message": user_msg,
|
|
439
|
+
"line": line_number,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
# Construct prompt_id for client-side favorite support
|
|
443
|
+
prompt_id = f"{store_id}:{line_number}" if line_number > 0 else None
|
|
444
|
+
|
|
445
|
+
# Mark as favorite if requested
|
|
446
|
+
if mark_as_favorite and prompt_id:
|
|
447
|
+
paths.add_prompt_favorite(
|
|
448
|
+
prompt_id,
|
|
449
|
+
content_preview=display_content[:100] if display_content else ""
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Send prompt_id back to client for favorite button support
|
|
453
|
+
if prompt_id:
|
|
454
|
+
is_favorite = mark_as_favorite or paths.is_prompt_favorite(prompt_id)
|
|
455
|
+
stored_msg = {
|
|
456
|
+
"type": "user_message_stored",
|
|
457
|
+
"promptId": prompt_id,
|
|
458
|
+
"isFavorite": is_favorite,
|
|
459
|
+
}
|
|
460
|
+
if verified_files:
|
|
461
|
+
stored_msg["verifiedFiles"] = verified_files
|
|
462
|
+
await websocket.send_json(stored_msg)
|
|
463
|
+
|
|
464
|
+
# Shadow Git: Set prompt for this turn and reset tracker
|
|
465
|
+
agent_session.turn_tracker.reset()
|
|
466
|
+
agent_session.turn_tracker.set_prompt(content)
|
|
467
|
+
agent_session._ask_tool_id = None # Reset AskUserQuestion retry suppression
|
|
468
|
+
|
|
469
|
+
# Shadow DB: Record turn start (prompt is never lost)
|
|
470
|
+
try:
|
|
471
|
+
if agent_session.cwd:
|
|
472
|
+
db = get_shadow_db()
|
|
473
|
+
# Get git branch cheaply for turn metadata
|
|
474
|
+
git_branch = None
|
|
475
|
+
try:
|
|
476
|
+
import subprocess as _sp
|
|
477
|
+
git_branch = _sp.run(
|
|
478
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
479
|
+
cwd=agent_session.cwd, capture_output=True,
|
|
480
|
+
text=True, encoding="utf-8", errors="replace", timeout=2
|
|
481
|
+
).stdout.strip() or None
|
|
482
|
+
except Exception:
|
|
483
|
+
pass
|
|
484
|
+
db_turn_id = db.start_turn(
|
|
485
|
+
session_id=store_id,
|
|
486
|
+
project_hash=paths.get_project_hash(agent_session.cwd),
|
|
487
|
+
# display_content, NOT `display_content or content`: the two
|
|
488
|
+
# are different KINDS of value — display_content is what the
|
|
489
|
+
# user typed, content is the composed, model-facing text with
|
|
490
|
+
# the stash block prepended. The falsy fallback only ever fired
|
|
491
|
+
# on a stash-only send (typed text empty), which is exactly the
|
|
492
|
+
# case where it swapped in the composed blob — so messages.jsonl
|
|
493
|
+
# recorded "" while this column recorded the full stash text for
|
|
494
|
+
# the same turn. One rule now: user_prompt is what the user
|
|
495
|
+
# typed. The composed text is still preserved in the shadow-git
|
|
496
|
+
# commit body via the turn_tracker.set_prompt(content) above.
|
|
497
|
+
# (An empty user_prompt is already a normal state here — an
|
|
498
|
+
# image-only send has produced one since long before stash.)
|
|
499
|
+
user_prompt=display_content,
|
|
500
|
+
has_images=len(images) > 0,
|
|
501
|
+
git_branch=git_branch,
|
|
502
|
+
git_repo_hash=paths.get_git_repo_hash(agent_session.cwd),
|
|
503
|
+
is_plan=plan_mode,
|
|
504
|
+
provider=agent_session.provider.name,
|
|
505
|
+
)
|
|
506
|
+
agent_session.turn_tracker.db_turn_id = db_turn_id
|
|
507
|
+
except Exception as e:
|
|
508
|
+
logger.warning(f"Shadow DB start_turn failed: {e}")
|
|
509
|
+
|
|
510
|
+
# Apply token profile from message (sent with every prompt)
|
|
511
|
+
msg_token_profile = data.get("token_profile") # string or absent
|
|
512
|
+
if msg_token_profile != agent_session.token_profile:
|
|
513
|
+
agent_session.token_profile = msg_token_profile
|
|
514
|
+
SessionStore.update_metadata(store_id, token_profile=msg_token_profile)
|
|
515
|
+
# Kill idle process so start_agent respawns with new token
|
|
516
|
+
# Note: is_idle (not `not is_running`) — is_running stays True while
|
|
517
|
+
# the stream-json process is alive between turns. is_idle is set
|
|
518
|
+
# by _finalize_turn after each turn completes.
|
|
519
|
+
if agent_session.is_idle and agent_session.process:
|
|
520
|
+
agent_session._interrupting = True # suppress session_ended WS msg
|
|
521
|
+
await agents.stop_session(agent_session)
|
|
522
|
+
logger.info(f"Killed idle process for {store_id} (token profile changed to: {msg_token_profile})")
|
|
523
|
+
|
|
524
|
+
# Apply preferred model from message (sent with every prompt)
|
|
525
|
+
msg_model = data.get("preferred_model") # string or absent
|
|
526
|
+
if msg_model != agent_session.preferred_model:
|
|
527
|
+
agent_session.preferred_model = msg_model
|
|
528
|
+
SessionStore.update_metadata(store_id, preferred_model=msg_model)
|
|
529
|
+
# Live apply (capabilities.live_controls): switch the warm engine's
|
|
530
|
+
# model in place (None = revert to the CLI/account default). Awaited
|
|
531
|
+
# before send_to_agent below, so this very turn already runs on the
|
|
532
|
+
# new model — and it works mid-turn too, where the old path silently
|
|
533
|
+
# deferred the change to some future respawn. Nack/timeout falls back
|
|
534
|
+
# to the kill+respawn.
|
|
535
|
+
switched_live = False
|
|
536
|
+
if (agent_session.provider.capabilities.live_controls
|
|
537
|
+
and agent_session.process and agent_session.is_running):
|
|
538
|
+
switched_live = await agents.send_control(
|
|
539
|
+
agent_session, "set_model", {"model": msg_model})
|
|
540
|
+
if switched_live:
|
|
541
|
+
logger.info(f"Model live-applied for {store_id}: {msg_model}")
|
|
542
|
+
if not switched_live and agent_session.is_idle and agent_session.process:
|
|
543
|
+
agent_session._interrupting = True
|
|
544
|
+
await agents.stop_session(agent_session)
|
|
545
|
+
logger.info(f"Killed idle process for {store_id} (model changed to: {msg_model})")
|
|
546
|
+
|
|
547
|
+
# One-shot effort override (Ctrl+Shift+' on the client): apply for THIS
|
|
548
|
+
# turn only, stash the prior persistent value, and kill the idle process
|
|
549
|
+
# so the respawn picks up the new --effort flag. _finalize_turn restores
|
|
550
|
+
# the prior value and kills again so the next turn reverts. Distinct
|
|
551
|
+
# from preferred_model above: we do NOT persist to meta.
|
|
552
|
+
msg_effort = data.get("effort_level")
|
|
553
|
+
if msg_effort and msg_effort != agent_session.effort_level:
|
|
554
|
+
# Only stash on the first one-shot of a turn — don't overwrite if
|
|
555
|
+
# somehow re-armed mid-turn (shouldn't happen, but be safe).
|
|
556
|
+
if agent_session._oneshot_revert_effort == "_UNSET":
|
|
557
|
+
agent_session._oneshot_revert_effort = agent_session.effort_level
|
|
558
|
+
agent_session.effort_level = msg_effort
|
|
559
|
+
if agent_session.is_idle and agent_session.process:
|
|
560
|
+
agent_session._interrupting = True
|
|
561
|
+
await agents.stop_session(agent_session)
|
|
562
|
+
logger.info(f"Killed idle process for {store_id} (one-shot effort: {msg_effort})")
|
|
563
|
+
|
|
564
|
+
# Apply a permission mode forwarded with the message. The picker normally
|
|
565
|
+
# persists via PUT /permission-mode + a set_permission_mode WS message, but a
|
|
566
|
+
# brand-new session has no store_id (and no WS) when the user picks, so that
|
|
567
|
+
# path is skipped and the choice rides along on the first user_message
|
|
568
|
+
# instead. Honour it here so the selected mode survives the first turn rather
|
|
569
|
+
# than falling through to the global default. Stored literally (no
|
|
570
|
+
# bypassPermissions→None collapse) so it sticks for the rest of the session.
|
|
571
|
+
msg_permission_mode = data.get("permission_mode") # string or absent
|
|
572
|
+
if msg_permission_mode and msg_permission_mode != agent_session.permission_mode:
|
|
573
|
+
agent_session.permission_mode = msg_permission_mode
|
|
574
|
+
SessionStore.update_metadata(store_id, permission_level=msg_permission_mode)
|
|
575
|
+
logger.info(f"Applied permission mode from message for {store_id}: {msg_permission_mode}")
|
|
576
|
+
|
|
577
|
+
# Apply permission mode: if it changed since the live process launched, kill
|
|
578
|
+
# the idle process so start_agent (below) respawns with the new mode. A
|
|
579
|
+
# running turn is left alone — the new mode takes effect on the next turn.
|
|
580
|
+
if agent_session.permission_mode != agent_session._launched_permission_mode:
|
|
581
|
+
if agent_session.is_idle and agent_session.process:
|
|
582
|
+
agent_session._interrupting = True
|
|
583
|
+
await agents.stop_session(agent_session)
|
|
584
|
+
logger.info(f"Killed idle process for {store_id} (permission mode changed to: {agent_session.permission_mode})")
|
|
585
|
+
|
|
586
|
+
# Ephemeral providers (e.g. Codex) run one subprocess per turn with the
|
|
587
|
+
# prompt in argv — there is no persistent stdin to stream into. Stash the
|
|
588
|
+
# turn's prompt/images for start_agent to embed, then spawn. A fresh spawn
|
|
589
|
+
# also picks up any model/effort/permission change for free, so the lazy
|
|
590
|
+
# "kill idle process" dance above is a no-op for them (process is None
|
|
591
|
+
# between turns).
|
|
592
|
+
if not agent_session.provider.capabilities.persistent_process:
|
|
593
|
+
if agent_session.is_running:
|
|
594
|
+
# One process == one turn; can't inject a prompt mid-turn.
|
|
595
|
+
await websocket.send_json({
|
|
596
|
+
"type": "error",
|
|
597
|
+
"message": f"A {agent_session.provider.display_name} turn is already running"
|
|
598
|
+
})
|
|
599
|
+
return
|
|
600
|
+
agent_session._pending_prompt = content
|
|
601
|
+
agent_session._pending_images = images
|
|
602
|
+
agent_session._last_agent_msg = agent_msg
|
|
603
|
+
if not await agents.start_agent(agent_session):
|
|
604
|
+
await websocket.send_json({
|
|
605
|
+
"type": "error",
|
|
606
|
+
"message": agent_session.start_error
|
|
607
|
+
or f"Failed to start {agent_session.provider.display_name}"
|
|
608
|
+
})
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
# Start Claude if not running (may have finished)
|
|
612
|
+
if not agent_session.is_running:
|
|
613
|
+
if not await agents.start_agent(agent_session):
|
|
614
|
+
await websocket.send_json({
|
|
615
|
+
"type": "error",
|
|
616
|
+
"message": agent_session.start_error or "Failed to start Claude Code"
|
|
617
|
+
})
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
# Send user message to Claude
|
|
621
|
+
if not await agents.send_to_agent(agent_session, agent_msg):
|
|
622
|
+
await websocket.send_json({
|
|
623
|
+
"type": "error",
|
|
624
|
+
"message": "Failed to send message to Claude"
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
async def _handle_stop(websocket, agent_session, agents) -> None:
|
|
629
|
+
"""Handle a `stop` request — interrupt Claude if running."""
|
|
630
|
+
if await agents.interrupt_agent(agent_session):
|
|
631
|
+
logger.info("Claude interrupted by user request")
|
|
632
|
+
await websocket.send_json({
|
|
633
|
+
"type": "stopped",
|
|
634
|
+
"message": "Stopped by user"
|
|
635
|
+
})
|
|
636
|
+
else:
|
|
637
|
+
await websocket.send_json({
|
|
638
|
+
"type": "error",
|
|
639
|
+
"message": "Could not interrupt Claude (not running)"
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
async def _handle_clear_session(websocket, agent_session, store_id, agents) -> None:
|
|
644
|
+
"""Handle a `clear_session` request — wipe conversation, restart fresh."""
|
|
645
|
+
logger.info(f"Clearing session: {store_id}")
|
|
646
|
+
|
|
647
|
+
# Stop current Claude process
|
|
648
|
+
await agents.stop_session(agent_session)
|
|
649
|
+
|
|
650
|
+
# Clear server-side store (removes provider_session_id and messages)
|
|
651
|
+
SessionStore.clear_conversation(store_id)
|
|
652
|
+
|
|
653
|
+
# Reset runtime session
|
|
654
|
+
agent_session.session_id = None
|
|
655
|
+
|
|
656
|
+
# Restart Claude (will start fresh, no --resume)
|
|
657
|
+
if await agents.start_agent(agent_session):
|
|
658
|
+
await websocket.send_json({
|
|
659
|
+
"type": "session_cleared",
|
|
660
|
+
"message": "Session cleared. Starting fresh conversation."
|
|
661
|
+
})
|
|
662
|
+
else:
|
|
663
|
+
await websocket.send_json({
|
|
664
|
+
"type": "error",
|
|
665
|
+
"message": agent_session.start_error or "Failed to restart Claude after clear"
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
async def _handle_tool_answer(websocket, agent_session, store_id, data, agents) -> None:
|
|
670
|
+
"""Handle a `tool_answer` — user responded to AskUserQuestion via the workaround.
|
|
671
|
+
|
|
672
|
+
Headless Claude (`-p` mode) auto-denies AskUserQuestion in every permission
|
|
673
|
+
mode, so we always relay the user's answers as a plain text follow-up
|
|
674
|
+
message rather than injecting a tool_result. The text format mirrors what
|
|
675
|
+
the model would have seen had the tool worked.
|
|
676
|
+
"""
|
|
677
|
+
tool_use_id = data.get("tool_use_id")
|
|
678
|
+
answers = data.get("answers", {})
|
|
679
|
+
questions = data.get("questions", []) # Original questions for context
|
|
680
|
+
# Optional free-text comment the user adds alongside (or instead of) the
|
|
681
|
+
# picked options — relayed to Claude regardless of which options were chosen.
|
|
682
|
+
comment = (data.get("comment") or "").strip()
|
|
683
|
+
|
|
684
|
+
if not answers and not comment:
|
|
685
|
+
await websocket.send_json({
|
|
686
|
+
"type": "error",
|
|
687
|
+
"message": "No answers provided"
|
|
688
|
+
})
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
# Format answers as readable text message
|
|
692
|
+
answer_lines = []
|
|
693
|
+
for q in questions:
|
|
694
|
+
header = q.get("header", "Question")
|
|
695
|
+
question_text = q.get("question", "")
|
|
696
|
+
answer_value = answers.get(header, "No answer")
|
|
697
|
+
if question_text and question_text != header:
|
|
698
|
+
answer_lines.append(f"**{header}** ({question_text}): {answer_value}")
|
|
699
|
+
else:
|
|
700
|
+
answer_lines.append(f"**{header}**: {answer_value}")
|
|
701
|
+
|
|
702
|
+
if not answer_lines and answers:
|
|
703
|
+
answer_lines = [f"**{k}**: {v}" for k, v in answers.items()]
|
|
704
|
+
|
|
705
|
+
parts = []
|
|
706
|
+
if answer_lines:
|
|
707
|
+
parts.append("Here are my answers to your questions:\n\n" + "\n".join(answer_lines))
|
|
708
|
+
if comment:
|
|
709
|
+
parts.append(f"Additional comment: {comment}")
|
|
710
|
+
answer_text = "\n\n".join(parts)
|
|
711
|
+
|
|
712
|
+
agent_msg = {
|
|
713
|
+
"type": "user",
|
|
714
|
+
"message": {
|
|
715
|
+
"role": "user",
|
|
716
|
+
"content": answer_text
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
logger.info(f"Sending question answers as text: {answer_text[:100]}...")
|
|
720
|
+
|
|
721
|
+
# Start Claude if not running (auto-denial stops process)
|
|
722
|
+
if not agent_session.is_running:
|
|
723
|
+
if not await agents.start_agent(agent_session):
|
|
724
|
+
await websocket.send_json({
|
|
725
|
+
"type": "error",
|
|
726
|
+
"message": agent_session.start_error or "Failed to start Claude Code"
|
|
727
|
+
})
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
# Send to Claude
|
|
731
|
+
if not await agents.send_to_agent(agent_session, agent_msg):
|
|
732
|
+
await websocket.send_json({
|
|
733
|
+
"type": "error",
|
|
734
|
+
"message": "Failed to send answers to Claude"
|
|
735
|
+
})
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
# Log to session store
|
|
739
|
+
if store_id:
|
|
740
|
+
SessionStore.add_message(store_id, {
|
|
741
|
+
"role": "user",
|
|
742
|
+
"content": answer_text,
|
|
743
|
+
"is_question_answer": True,
|
|
744
|
+
"tool_use_id": tool_use_id,
|
|
745
|
+
"answers": answers,
|
|
746
|
+
"comment": comment,
|
|
747
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
async def _handle_permission_response(websocket, agent_session, data, agents) -> None:
|
|
752
|
+
"""Handle a `permission_response` — the user's allow/deny for an
|
|
753
|
+
interactive permission ask (capabilities.interactive_permissions).
|
|
754
|
+
|
|
755
|
+
Relays the decision to the provider process, which is blocked in its
|
|
756
|
+
`can_use_tool` callback. A False return means the request expired (the
|
|
757
|
+
process restarted or died) — tell the client so it retires the card.
|
|
758
|
+
"""
|
|
759
|
+
ok = await agents.respond_permission(agent_session, data)
|
|
760
|
+
# Broadcast (not just to the answering socket): with multi-client attach,
|
|
761
|
+
# every peer tab shows the same pending card — all of them must retire it
|
|
762
|
+
# when any one client answers.
|
|
763
|
+
resolved = {
|
|
764
|
+
"type": "permission_resolved",
|
|
765
|
+
"request_id": data.get("request_id"),
|
|
766
|
+
"behavior": data.get("behavior"),
|
|
767
|
+
"ok": ok,
|
|
768
|
+
}
|
|
769
|
+
if not await agent_session.safe_send(resolved):
|
|
770
|
+
await websocket.send_json(resolved)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
async def _handle_set_permission_mode(websocket, agent_session, store_id, data, agents) -> None:
|
|
774
|
+
"""Handle a `set_permission_mode` request — record the mode and apply it.
|
|
775
|
+
|
|
776
|
+
Records the desired mode and echoes it to the client (so the permission
|
|
777
|
+
button updates immediately). Providers with `capabilities.live_controls`
|
|
778
|
+
(claude-sdk) get the mode applied to the *running* engine in place — even
|
|
779
|
+
mid-turn — via a control frame; the reply carries `applied: "live"`. All
|
|
780
|
+
other cases keep the lazy path: `_handle_user_message` respawns the idle
|
|
781
|
+
process on the next message when the mode differs from what it launched
|
|
782
|
+
with (the same universal path used for token-profile / model changes),
|
|
783
|
+
and the reply says `applied: "next_turn"`.
|
|
784
|
+
"""
|
|
785
|
+
mode = data.get("mode") # a Claude mode, a provider-native mode (e.g. Codex
|
|
786
|
+
# workspace-write), or None. Validated against the registry so the active
|
|
787
|
+
# session's engine vocabulary is accepted, not just Claude's.
|
|
788
|
+
from painapple_code.providers import valid_permission_values
|
|
789
|
+
valid_modes = valid_permission_values() | {None}
|
|
790
|
+
if mode not in valid_modes:
|
|
791
|
+
await websocket.send_json({
|
|
792
|
+
"type": "error",
|
|
793
|
+
"message": f"Invalid permission mode: {mode}. Valid: {valid_modes}"
|
|
794
|
+
})
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
logger.info(f"Setting permission mode to: {mode} for session {store_id}")
|
|
798
|
+
|
|
799
|
+
# Set desired permission mode (None = use bypassPermissions default)
|
|
800
|
+
agent_session.permission_mode = mode if mode != "bypassPermissions" else None
|
|
801
|
+
|
|
802
|
+
# Arm the plan-approval SIGINT gate when entering plan mode; disarm otherwise.
|
|
803
|
+
# Without this, ExitPlanMode would never trigger an approval card (or would
|
|
804
|
+
# trigger one stale after the user left plan mode for another mode).
|
|
805
|
+
agent_session._plan_sigint_armed = (mode == "plan")
|
|
806
|
+
|
|
807
|
+
# Live apply (capabilities.live_controls): switch the running engine in
|
|
808
|
+
# place — even mid-turn — instead of waiting for the lazy respawn. The
|
|
809
|
+
# launched-mode bookkeeping is updated ONLY on ack, so a failed control
|
|
810
|
+
# leaves the lazy check armed and the next message respawns exactly as
|
|
811
|
+
# before. Guard: a process launched in bypassPermissions has no
|
|
812
|
+
# can_use_tool gate attached (claude_sdk/driver.py) — a live switch out
|
|
813
|
+
# of bypass would leave an ask-capable mode with nothing to answer
|
|
814
|
+
# prompts, so that direction must take the respawn path.
|
|
815
|
+
applied = "next_turn"
|
|
816
|
+
if (agent_session.provider
|
|
817
|
+
and agent_session.provider.capabilities.live_controls
|
|
818
|
+
and agent_session.process and agent_session.is_running
|
|
819
|
+
and agent_session._launched_resolved_mode != "bypassPermissions"):
|
|
820
|
+
target = mode or "bypassPermissions"
|
|
821
|
+
if await agents.send_control(agent_session, "set_permission_mode",
|
|
822
|
+
{"mode": target}):
|
|
823
|
+
# Only the desired-mode snapshot moves; _launched_resolved_mode
|
|
824
|
+
# stays at the launch value — gate attachment is a spawn-time
|
|
825
|
+
# property, so a live switch INTO bypass must not block a later
|
|
826
|
+
# live switch back out (the gate is still attached).
|
|
827
|
+
agent_session._launched_permission_mode = agent_session.permission_mode
|
|
828
|
+
applied = "live"
|
|
829
|
+
logger.info(f"Permission mode live-applied for {store_id}: {target}")
|
|
830
|
+
|
|
831
|
+
# Notify client of mode change (updates the permission button immediately)
|
|
832
|
+
await websocket.send_json({
|
|
833
|
+
"type": "permission_mode_changed",
|
|
834
|
+
"mode": mode or "bypassPermissions",
|
|
835
|
+
"applied": applied,
|
|
836
|
+
"message": f"Permission mode set to: {mode or 'bypassPermissions'}"
|
|
837
|
+
})
|