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,997 @@
|
|
|
1
|
+
"""
|
|
2
|
+
App Config API Routes - app-wide settings.
|
|
3
|
+
|
|
4
|
+
These endpoints manage:
|
|
5
|
+
- Quick Action Presets (~/.painapple-code/presets/*.json)
|
|
6
|
+
- Engine CLI binary paths (per-provider, self-described config keys)
|
|
7
|
+
- Max thinking tokens
|
|
8
|
+
- API auto-retry max
|
|
9
|
+
- Default permission levels
|
|
10
|
+
- Token profiles
|
|
11
|
+
- Models (selectable list + summary_model)
|
|
12
|
+
- Default effort level
|
|
13
|
+
- Session timeout settings
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
23
|
+
|
|
24
|
+
from painapple_code import paths
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
router = APIRouter(tags=["app:config"])
|
|
29
|
+
|
|
30
|
+
# Default max thinking tokens (31999 to stay under limit, 63999 is Opus 4.5 max)
|
|
31
|
+
DEFAULT_MAX_THINKING_TOKENS = 31999
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
35
|
+
# Quick Action Presets (~/.painapple-code/presets/*.json)
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
@router.get("/api/app/presets")
|
|
39
|
+
async def get_presets():
|
|
40
|
+
"""List all quick-action presets (one JSON file per preset)."""
|
|
41
|
+
return paths.load_all_presets()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.put("/api/app/presets/{preset_id}")
|
|
45
|
+
async def save_preset(preset_id: str, data: dict):
|
|
46
|
+
"""Create or update a preset."""
|
|
47
|
+
try:
|
|
48
|
+
paths.save_preset(preset_id, data)
|
|
49
|
+
except ValueError as e:
|
|
50
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
51
|
+
return paths.load_all_presets()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.delete("/api/app/presets/{preset_id}")
|
|
55
|
+
async def delete_preset(preset_id: str):
|
|
56
|
+
"""Delete a preset file."""
|
|
57
|
+
try:
|
|
58
|
+
deleted = paths.delete_preset(preset_id)
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
61
|
+
return {"deleted": deleted, "presets": paths.load_all_presets()}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
65
|
+
# Engine CLI paths (per-provider binary override, self-described key)
|
|
66
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
def _provider_or_404(name: str):
|
|
69
|
+
from painapple_code.providers import get_provider, provider_names
|
|
70
|
+
if name not in provider_names():
|
|
71
|
+
raise HTTPException(status_code=404, detail=f"Unknown provider: {name}")
|
|
72
|
+
return get_provider(name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _engine_path_payload(p) -> dict:
|
|
76
|
+
"""Current binary override + resolution/version probe for one engine.
|
|
77
|
+
|
|
78
|
+
``path`` is the raw config value (None = unset → ``default_binary`` from
|
|
79
|
+
PATH). Same-engine driver variants share a config key (both providers
|
|
80
|
+
self-describe the same one), so the payload is identical across them.
|
|
81
|
+
"""
|
|
82
|
+
if not p.path_config_key:
|
|
83
|
+
return {"provider": p.name, "configurable": False}
|
|
84
|
+
config = paths.load_global_config()
|
|
85
|
+
configured = config.get(p.path_config_key)
|
|
86
|
+
current = configured or p.default_binary
|
|
87
|
+
resolved = current if Path(current).is_absolute() else shutil.which(current)
|
|
88
|
+
|
|
89
|
+
version = None
|
|
90
|
+
if resolved:
|
|
91
|
+
try:
|
|
92
|
+
# Off the event loop — a hung `<cli> --version` would otherwise
|
|
93
|
+
# block every other request for up to 5s.
|
|
94
|
+
result = await asyncio.to_thread(
|
|
95
|
+
subprocess.run,
|
|
96
|
+
# `resolved`, not `current`: on win32 a bare npm-shim name
|
|
97
|
+
# ("claude" → claude.cmd) isn't spawnable, but the full
|
|
98
|
+
# path shutil.which returned is.
|
|
99
|
+
[resolved, "--version"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True, encoding="utf-8", errors="replace",
|
|
102
|
+
timeout=5,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode == 0:
|
|
105
|
+
version = result.stdout.strip()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"provider": p.name,
|
|
111
|
+
"configurable": True,
|
|
112
|
+
"path": configured,
|
|
113
|
+
"default_binary": p.default_binary,
|
|
114
|
+
"resolved": resolved,
|
|
115
|
+
"version": version,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get("/api/app/engine-path/{provider_name}")
|
|
120
|
+
async def get_engine_path(provider_name: str):
|
|
121
|
+
"""Configured CLI binary for one engine (provider self-describes the
|
|
122
|
+
config key — nothing per-engine is hardcoded here)."""
|
|
123
|
+
return await _engine_path_payload(_provider_or_404(provider_name))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.put("/api/app/engine-path/{provider_name}")
|
|
127
|
+
async def set_engine_path(provider_name: str, request: Request):
|
|
128
|
+
"""Set (or clear with null/empty) an engine's CLI binary path.
|
|
129
|
+
|
|
130
|
+
Explicit paths must exist and be files; clearing always succeeds (falls
|
|
131
|
+
back to ``default_binary`` on PATH — availability just reflects reality,
|
|
132
|
+
so a stale override can be removed even with the CLI uninstalled).
|
|
133
|
+
"""
|
|
134
|
+
p = _provider_or_404(provider_name)
|
|
135
|
+
if not p.path_config_key:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=400,
|
|
138
|
+
detail=f"{p.display_name} has no configurable CLI path")
|
|
139
|
+
|
|
140
|
+
body = await request.json()
|
|
141
|
+
new_path = (body.get("path") or "").strip()
|
|
142
|
+
|
|
143
|
+
if new_path and new_path != p.default_binary:
|
|
144
|
+
if not Path(new_path).exists():
|
|
145
|
+
raise HTTPException(status_code=400, detail=f"Path does not exist: {new_path}")
|
|
146
|
+
if not Path(new_path).is_file():
|
|
147
|
+
raise HTTPException(status_code=400, detail=f"Path is not a file: {new_path}")
|
|
148
|
+
|
|
149
|
+
config = paths.load_global_config()
|
|
150
|
+
if not new_path or new_path == p.default_binary:
|
|
151
|
+
config.pop(p.path_config_key, None)
|
|
152
|
+
else:
|
|
153
|
+
config[p.path_config_key] = new_path
|
|
154
|
+
|
|
155
|
+
paths.save_global_config(config)
|
|
156
|
+
logger.info(f"Engine path for {p.name} ({p.path_config_key}) updated to: {new_path or '(default)'}")
|
|
157
|
+
|
|
158
|
+
return await _engine_path_payload(p)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
162
|
+
# Engine model visibility (per-model show/hide, self-described namespace)
|
|
163
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
164
|
+
|
|
165
|
+
def _engine_models_payload(p) -> dict:
|
|
166
|
+
"""One engine's FULL catalog with per-model `enabled` flags merged in.
|
|
167
|
+
|
|
168
|
+
Settings needs the unfiltered list (hidden models render with their
|
|
169
|
+
toggle off) — unlike /api/providers, which serves `enabled_models()`
|
|
170
|
+
so pickers only see what the user kept listed. `disabled` is the raw
|
|
171
|
+
stored set: it may carry ids not in the current catalog (CLI-owned
|
|
172
|
+
catalogs churn); those are preserved, not pruned.
|
|
173
|
+
"""
|
|
174
|
+
disabled = p.disabled_model_ids()
|
|
175
|
+
return {
|
|
176
|
+
"provider": p.name,
|
|
177
|
+
"models_key": p.models_key or p.name,
|
|
178
|
+
"editable": p.models_editable,
|
|
179
|
+
"models": [
|
|
180
|
+
{**m, "enabled": m.get("id") not in disabled} for m in p.models()
|
|
181
|
+
],
|
|
182
|
+
"disabled": sorted(disabled),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.get("/api/app/engine-models/{provider_name}")
|
|
187
|
+
async def get_engine_models(provider_name: str):
|
|
188
|
+
"""Full model catalog + visibility flags for one engine."""
|
|
189
|
+
return _engine_models_payload(_provider_or_404(provider_name))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.put("/api/app/engine-models/{provider_name}")
|
|
193
|
+
async def set_engine_models(provider_name: str, request: Request):
|
|
194
|
+
"""Replace the hidden-model set for one engine: ``{disabled: [ids]}``.
|
|
195
|
+
|
|
196
|
+
Stored under the provider's `models_key` namespace, so driver variants
|
|
197
|
+
sharing a catalog (claude/claude-sdk, codex/codex-app-server) share the
|
|
198
|
+
curation. An empty list clears the key. Ids are stored verbatim — no
|
|
199
|
+
catalog-membership pruning, so a preference survives a CLI catalog
|
|
200
|
+
refresh that temporarily drops the model.
|
|
201
|
+
"""
|
|
202
|
+
p = _provider_or_404(provider_name)
|
|
203
|
+
body = await request.json()
|
|
204
|
+
disabled = body.get("disabled")
|
|
205
|
+
if not isinstance(disabled, list) or not all(isinstance(x, str) for x in disabled):
|
|
206
|
+
raise HTTPException(status_code=400, detail="disabled must be a list of model ids")
|
|
207
|
+
|
|
208
|
+
key = p.models_key or p.name
|
|
209
|
+
cleaned = sorted({x.strip() for x in disabled if x.strip()})
|
|
210
|
+
|
|
211
|
+
config = paths.load_global_config()
|
|
212
|
+
overrides = dict(config.get("models_disabled") or {})
|
|
213
|
+
if cleaned:
|
|
214
|
+
overrides[key] = cleaned
|
|
215
|
+
else:
|
|
216
|
+
overrides.pop(key, None)
|
|
217
|
+
if overrides:
|
|
218
|
+
config["models_disabled"] = overrides
|
|
219
|
+
else:
|
|
220
|
+
config.pop("models_disabled", None)
|
|
221
|
+
|
|
222
|
+
paths.save_global_config(config)
|
|
223
|
+
logger.info(f"Hidden models for {key}: {cleaned or '(none)'}")
|
|
224
|
+
|
|
225
|
+
return _engine_models_payload(p)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
229
|
+
# Engine CLI auth (login-status probe, self-described commands)
|
|
230
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
231
|
+
|
|
232
|
+
@router.get("/api/app/engine-auth/{provider_name}")
|
|
233
|
+
async def get_engine_auth(provider_name: str):
|
|
234
|
+
"""Login status for one engine's CLI (Settings → Engines auth row).
|
|
235
|
+
|
|
236
|
+
Runs the provider's self-described `auth_status_args` probe against the
|
|
237
|
+
configured binary and returns its parsed verdict plus `login_command` —
|
|
238
|
+
the full interactive login invocation the client drops into a PTY
|
|
239
|
+
terminal tab. `logged_in: null` = probe couldn't run (binary missing,
|
|
240
|
+
timeout); `supported: false` = the provider describes no auth probe.
|
|
241
|
+
"""
|
|
242
|
+
from painapple_code.utils.proc import shell_join
|
|
243
|
+
|
|
244
|
+
p = _provider_or_404(provider_name)
|
|
245
|
+
if not p.auth_status_args:
|
|
246
|
+
return {"provider": p.name, "supported": False}
|
|
247
|
+
|
|
248
|
+
config = paths.load_global_config()
|
|
249
|
+
configured = config.get(p.path_config_key) if p.path_config_key else None
|
|
250
|
+
current = configured or p.default_binary
|
|
251
|
+
resolved = current if Path(current).is_absolute() else shutil.which(current)
|
|
252
|
+
login_command = (
|
|
253
|
+
shell_join([current, *p.auth_login_args]) if p.auth_login_args else None
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
logged_in, detail = None, ""
|
|
257
|
+
if resolved:
|
|
258
|
+
try:
|
|
259
|
+
# Off the event loop — a hung status probe must not block requests.
|
|
260
|
+
result = await asyncio.to_thread(
|
|
261
|
+
subprocess.run,
|
|
262
|
+
# `resolved` for the same reason as the version probe:
|
|
263
|
+
# bare .cmd shim names aren't spawnable on win32.
|
|
264
|
+
[resolved, *p.auth_status_args],
|
|
265
|
+
capture_output=True,
|
|
266
|
+
text=True, encoding="utf-8", errors="replace",
|
|
267
|
+
timeout=10,
|
|
268
|
+
)
|
|
269
|
+
parsed = p.parse_auth_status(
|
|
270
|
+
result.returncode, result.stdout or "", result.stderr or "")
|
|
271
|
+
logged_in = parsed.get("logged_in")
|
|
272
|
+
detail = parsed.get("detail") or ""
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"provider": p.name,
|
|
278
|
+
"supported": True,
|
|
279
|
+
"logged_in": logged_in,
|
|
280
|
+
"detail": detail,
|
|
281
|
+
"login_command": login_command,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
286
|
+
# Engine session defaults (per-engine model / effort / account / journal)
|
|
287
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
288
|
+
#
|
|
289
|
+
# Maps in the global config keyed by the provider's `models_key` namespace
|
|
290
|
+
# (driver pairs share, same as `models_disabled`):
|
|
291
|
+
# default_models / default_efforts / default_token_profiles
|
|
292
|
+
# The flat legacy keys survive as read fallbacks; the first PUT folds them
|
|
293
|
+
# into map entries for every engine that can speak the value, then drops
|
|
294
|
+
# them — so clearing a per-engine default actually sticks.
|
|
295
|
+
|
|
296
|
+
_DEFAULTS_FIELDS = [
|
|
297
|
+
# (request/response field, config map key, legacy flat key)
|
|
298
|
+
("default_model", "default_models", "default_model"),
|
|
299
|
+
("default_effort", "default_efforts", "default_effort"),
|
|
300
|
+
("token_profile", "default_token_profiles", "default_token_profile"),
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _engine_speaks_default(p, field: str, value: str) -> bool:
|
|
305
|
+
"""Whether an engine can hold `value` as its default for `field`."""
|
|
306
|
+
if field == "default_model":
|
|
307
|
+
return any(
|
|
308
|
+
value.startswith(m["id"])
|
|
309
|
+
for m in p.enabled_models() if m.get("id"))
|
|
310
|
+
if field == "default_effort":
|
|
311
|
+
levels = p.effort_levels()
|
|
312
|
+
return not levels or value in levels
|
|
313
|
+
if field == "token_profile":
|
|
314
|
+
return bool(p.accounts())
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _migrate_legacy_default(config: dict, field: str, map_key: str, legacy_key: str) -> None:
|
|
319
|
+
"""Fold a legacy flat default into per-engine map entries, then drop it.
|
|
320
|
+
|
|
321
|
+
Seeds every engine namespace that can speak the value and has no
|
|
322
|
+
explicit entry — behavior is unchanged the moment the flat key
|
|
323
|
+
disappears, and per-engine clears stop resurrecting it."""
|
|
324
|
+
legacy = config.get(legacy_key)
|
|
325
|
+
if not isinstance(legacy, str) or not legacy:
|
|
326
|
+
config.pop(legacy_key, None)
|
|
327
|
+
return
|
|
328
|
+
from painapple_code.providers import get_provider, provider_names
|
|
329
|
+
overrides = dict(config.get(map_key) or {})
|
|
330
|
+
seen = set()
|
|
331
|
+
for name in provider_names():
|
|
332
|
+
p = get_provider(name)
|
|
333
|
+
ns = p.models_key or p.name
|
|
334
|
+
if ns in seen:
|
|
335
|
+
continue
|
|
336
|
+
seen.add(ns)
|
|
337
|
+
if ns not in overrides and _engine_speaks_default(p, field, legacy):
|
|
338
|
+
overrides[ns] = legacy
|
|
339
|
+
if overrides:
|
|
340
|
+
config[map_key] = overrides
|
|
341
|
+
config.pop(legacy_key, None)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _engine_defaults_payload(p) -> dict:
|
|
345
|
+
return {
|
|
346
|
+
"provider": p.name,
|
|
347
|
+
"models_key": p.models_key or p.name,
|
|
348
|
+
"default_model": paths.engine_default_model(p),
|
|
349
|
+
"default_effort": paths.engine_default_effort(p),
|
|
350
|
+
"token_profile": paths.engine_default_token_profile(p),
|
|
351
|
+
"efforts": p.effort_levels(),
|
|
352
|
+
"accounts": p.accounts(),
|
|
353
|
+
"summary_supported": p.summary_model_editable(),
|
|
354
|
+
"summary_model": (
|
|
355
|
+
p.get_summary_model_override() if p.summary_model_editable() else None),
|
|
356
|
+
"summary_placeholder": p.summary_model_placeholder or "",
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@router.get("/api/app/engine-defaults/{provider_name}")
|
|
361
|
+
async def get_engine_defaults(provider_name: str):
|
|
362
|
+
"""One engine's new-session defaults + auto-journal model."""
|
|
363
|
+
return _engine_defaults_payload(_provider_or_404(provider_name))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@router.put("/api/app/engine-defaults/{provider_name}")
|
|
367
|
+
async def set_engine_defaults(provider_name: str, request: Request):
|
|
368
|
+
"""Set any subset of one engine's defaults.
|
|
369
|
+
|
|
370
|
+
Body: ``{default_model?, default_effort?, token_profile?, summary_model?}``
|
|
371
|
+
— null/empty clears a key (model/effort/profile fall back to the engine's
|
|
372
|
+
own default; the journal override falls back per engine: Codex inherits
|
|
373
|
+
the session model, Claude resets to the shipped summary model).
|
|
374
|
+
"""
|
|
375
|
+
p = _provider_or_404(provider_name)
|
|
376
|
+
body = await request.json()
|
|
377
|
+
ns = p.models_key or p.name
|
|
378
|
+
|
|
379
|
+
config = paths.load_global_config()
|
|
380
|
+
maps_changed = False
|
|
381
|
+
for field, map_key, legacy_key in _DEFAULTS_FIELDS:
|
|
382
|
+
if field not in body:
|
|
383
|
+
continue
|
|
384
|
+
raw = body.get(field)
|
|
385
|
+
if raw is not None and not isinstance(raw, str):
|
|
386
|
+
raise HTTPException(status_code=400, detail=f"{field} must be a string or null")
|
|
387
|
+
value = (raw or "").strip()
|
|
388
|
+
|
|
389
|
+
if value and field == "default_effort":
|
|
390
|
+
levels = p.effort_levels()
|
|
391
|
+
if levels and value not in levels:
|
|
392
|
+
raise HTTPException(
|
|
393
|
+
status_code=400,
|
|
394
|
+
detail=f"Invalid effort for {p.display_name}: {value}. Valid: {levels}")
|
|
395
|
+
if value and field == "token_profile":
|
|
396
|
+
from painapple_code.utils.token_profiles import list_profiles
|
|
397
|
+
if not p.accounts():
|
|
398
|
+
raise HTTPException(
|
|
399
|
+
status_code=400,
|
|
400
|
+
detail=f"{p.display_name} has no selectable accounts")
|
|
401
|
+
if value not in {x["name"] for x in list_profiles()}:
|
|
402
|
+
raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
|
|
403
|
+
|
|
404
|
+
_migrate_legacy_default(config, field, map_key, legacy_key)
|
|
405
|
+
overrides = dict(config.get(map_key) or {})
|
|
406
|
+
if value:
|
|
407
|
+
overrides[ns] = value
|
|
408
|
+
else:
|
|
409
|
+
overrides.pop(ns, None)
|
|
410
|
+
if overrides:
|
|
411
|
+
config[map_key] = overrides
|
|
412
|
+
else:
|
|
413
|
+
config.pop(map_key, None)
|
|
414
|
+
maps_changed = True
|
|
415
|
+
|
|
416
|
+
if maps_changed:
|
|
417
|
+
paths.save_global_config(config)
|
|
418
|
+
logger.info(f"Engine defaults for {ns} updated")
|
|
419
|
+
|
|
420
|
+
# After the config save — the provider setter re-reads/writes its own
|
|
421
|
+
# store (global config for Codex, models.yaml for Claude).
|
|
422
|
+
if "summary_model" in body:
|
|
423
|
+
raw = body.get("summary_model")
|
|
424
|
+
if raw is not None and not isinstance(raw, str):
|
|
425
|
+
raise HTTPException(status_code=400, detail="summary_model must be a string or null")
|
|
426
|
+
if not p.summary_model_editable():
|
|
427
|
+
raise HTTPException(
|
|
428
|
+
status_code=400,
|
|
429
|
+
detail=f"{p.display_name} has no journal-model setting")
|
|
430
|
+
p.set_summary_model_override(raw)
|
|
431
|
+
logger.info(f"Journal model for {p.name} set to: {(raw or '').strip() or '(engine default)'}")
|
|
432
|
+
|
|
433
|
+
return _engine_defaults_payload(p)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@router.get("/api/app/max-thinking-tokens")
|
|
437
|
+
async def get_max_thinking_tokens_api():
|
|
438
|
+
"""Get the max thinking tokens setting."""
|
|
439
|
+
config = paths.load_global_config()
|
|
440
|
+
current = config.get("max_thinking_tokens", DEFAULT_MAX_THINKING_TOKENS)
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
"max_thinking_tokens": current,
|
|
444
|
+
"default": DEFAULT_MAX_THINKING_TOKENS,
|
|
445
|
+
"max": 63999,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@router.put("/api/app/max-thinking-tokens")
|
|
450
|
+
async def set_max_thinking_tokens_api(request: Request):
|
|
451
|
+
"""Set the max thinking tokens."""
|
|
452
|
+
body = await request.json()
|
|
453
|
+
value = body.get("max_thinking_tokens")
|
|
454
|
+
|
|
455
|
+
if value is None:
|
|
456
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens is required")
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
value = int(value)
|
|
460
|
+
except (ValueError, TypeError):
|
|
461
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens must be an integer")
|
|
462
|
+
|
|
463
|
+
if value < 0:
|
|
464
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens cannot be negative")
|
|
465
|
+
|
|
466
|
+
if value > 63999:
|
|
467
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens cannot exceed 63999 (Opus 4.5 limit)")
|
|
468
|
+
|
|
469
|
+
config = paths.load_global_config()
|
|
470
|
+
if value == DEFAULT_MAX_THINKING_TOKENS:
|
|
471
|
+
config.pop("max_thinking_tokens", None)
|
|
472
|
+
else:
|
|
473
|
+
config["max_thinking_tokens"] = value
|
|
474
|
+
|
|
475
|
+
paths.save_global_config(config)
|
|
476
|
+
logger.info(f"Max thinking tokens updated to: {value}")
|
|
477
|
+
|
|
478
|
+
return await get_max_thinking_tokens_api()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
482
|
+
# API Auto-Retry
|
|
483
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
484
|
+
|
|
485
|
+
DEFAULT_API_RETRY_MAX = 3
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@router.get("/api/app/api-retry-max")
|
|
489
|
+
async def get_api_retry_max():
|
|
490
|
+
"""Get the API auto-retry max setting."""
|
|
491
|
+
config = paths.load_global_config()
|
|
492
|
+
return {
|
|
493
|
+
"api_retry_max": config.get("api_retry_max", DEFAULT_API_RETRY_MAX),
|
|
494
|
+
"default": DEFAULT_API_RETRY_MAX,
|
|
495
|
+
"min": 0,
|
|
496
|
+
"max": 10,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@router.put("/api/app/api-retry-max")
|
|
501
|
+
async def set_api_retry_max(request: Request):
|
|
502
|
+
"""Set the API auto-retry max."""
|
|
503
|
+
body = await request.json()
|
|
504
|
+
value = body.get("api_retry_max")
|
|
505
|
+
|
|
506
|
+
if value is None:
|
|
507
|
+
raise HTTPException(status_code=400, detail="api_retry_max is required")
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
value = int(value)
|
|
511
|
+
except (ValueError, TypeError):
|
|
512
|
+
raise HTTPException(status_code=400, detail="api_retry_max must be an integer")
|
|
513
|
+
|
|
514
|
+
if value < 0 or value > 10:
|
|
515
|
+
raise HTTPException(status_code=400, detail="api_retry_max must be between 0 and 10")
|
|
516
|
+
|
|
517
|
+
config = paths.load_global_config()
|
|
518
|
+
if value == DEFAULT_API_RETRY_MAX:
|
|
519
|
+
config.pop("api_retry_max", None)
|
|
520
|
+
else:
|
|
521
|
+
config["api_retry_max"] = value
|
|
522
|
+
|
|
523
|
+
paths.save_global_config(config)
|
|
524
|
+
logger.info(f"API retry max updated to: {value}")
|
|
525
|
+
|
|
526
|
+
return await get_api_retry_max()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
530
|
+
# Stop Claude on AskUserQuestion (SIGINT)
|
|
531
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
532
|
+
|
|
533
|
+
SIGINT_ON_ASK_DEFAULT = True
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@router.get("/api/app/sigint-on-ask")
|
|
537
|
+
async def get_sigint_on_ask():
|
|
538
|
+
"""Get whether Claude is SIGINT-stopped when it calls AskUserQuestion."""
|
|
539
|
+
config = paths.load_global_config()
|
|
540
|
+
return {
|
|
541
|
+
"sigint_on_ask": bool(config.get("sigint_on_ask", SIGINT_ON_ASK_DEFAULT)),
|
|
542
|
+
"default": SIGINT_ON_ASK_DEFAULT,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@router.put("/api/app/sigint-on-ask")
|
|
547
|
+
async def set_sigint_on_ask(request: Request):
|
|
548
|
+
"""Toggle SIGINT-on-AskUserQuestion.
|
|
549
|
+
|
|
550
|
+
Persists to the global config and updates the live AgentManager so the
|
|
551
|
+
change takes effect on the next turn without a restart.
|
|
552
|
+
"""
|
|
553
|
+
body = await request.json()
|
|
554
|
+
value = body.get("sigint_on_ask")
|
|
555
|
+
|
|
556
|
+
if not isinstance(value, bool):
|
|
557
|
+
raise HTTPException(status_code=400, detail="sigint_on_ask must be a boolean")
|
|
558
|
+
|
|
559
|
+
config = paths.load_global_config()
|
|
560
|
+
# Store only when it differs from the default; pop to fall back to default.
|
|
561
|
+
if value == SIGINT_ON_ASK_DEFAULT:
|
|
562
|
+
config.pop("sigint_on_ask", None)
|
|
563
|
+
else:
|
|
564
|
+
config["sigint_on_ask"] = value
|
|
565
|
+
paths.save_global_config(config)
|
|
566
|
+
|
|
567
|
+
# Apply to the running server so the toggle is live, not restart-gated.
|
|
568
|
+
agents = getattr(request.app.state, "agents", None)
|
|
569
|
+
if agents is not None:
|
|
570
|
+
agents.sigint_on_ask = value
|
|
571
|
+
|
|
572
|
+
logger.info(f"sigint_on_ask updated to: {value}")
|
|
573
|
+
return {"sigint_on_ask": value, "default": SIGINT_ON_ASK_DEFAULT}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
577
|
+
# Default Permission Levels
|
|
578
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
579
|
+
|
|
580
|
+
@router.get("/api/app/default-permissions")
|
|
581
|
+
async def get_default_permissions(request: Request, provider: str = None):
|
|
582
|
+
"""Get the global default permission level, plus the mode vocabulary of the
|
|
583
|
+
engine new sessions run on (the effective default provider — the
|
|
584
|
+
--default-provider flag / `default_provider` config key, not always Claude).
|
|
585
|
+
|
|
586
|
+
`provider` overrides which engine's vocabulary is returned — the picker
|
|
587
|
+
uses it so a pre-connect tab that chose a different engine shows that
|
|
588
|
+
engine's modes, not the box default's. The stored global level only
|
|
589
|
+
applies when it's valid in the resolved engine's own vocabulary (a Claude
|
|
590
|
+
`acceptEdits` default means nothing to Codex's sandbox tiers)."""
|
|
591
|
+
from painapple_code.providers import get_provider, provider_names
|
|
592
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
593
|
+
if provider and provider in provider_names():
|
|
594
|
+
dp = get_provider(provider)
|
|
595
|
+
else:
|
|
596
|
+
dp = effective_default_provider(request.app)
|
|
597
|
+
config = paths.load_global_config()
|
|
598
|
+
valid = {m["value"] for m in dp.permission_modes()}
|
|
599
|
+
level = config.get("default_permission_level")
|
|
600
|
+
if level not in valid:
|
|
601
|
+
level = dp.default_permission_mode()
|
|
602
|
+
return {
|
|
603
|
+
"default_level": level,
|
|
604
|
+
"modes": dp.permission_modes(),
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@router.put("/api/app/default-permissions")
|
|
609
|
+
async def set_default_permissions(request: Request):
|
|
610
|
+
"""Set the global default permission level for normal sessions."""
|
|
611
|
+
body = await request.json()
|
|
612
|
+
value = body.get("permission_level")
|
|
613
|
+
|
|
614
|
+
# The global default feeds the effective default engine's sessions, so
|
|
615
|
+
# validate against that provider's own modes (not a hardcoded set) and
|
|
616
|
+
# treat its own default as the "unset" sentinel.
|
|
617
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
618
|
+
dp = effective_default_provider(request.app)
|
|
619
|
+
valid = {m["value"] for m in dp.permission_modes()}
|
|
620
|
+
if value not in valid:
|
|
621
|
+
raise HTTPException(status_code=400, detail=f"Invalid permission level: {value}. Valid: {valid}")
|
|
622
|
+
|
|
623
|
+
config = paths.load_global_config()
|
|
624
|
+
if value == dp.default_permission_mode():
|
|
625
|
+
config.pop("default_permission_level", None)
|
|
626
|
+
else:
|
|
627
|
+
config["default_permission_level"] = value
|
|
628
|
+
|
|
629
|
+
paths.save_global_config(config)
|
|
630
|
+
logger.info(f"Default permission level updated to: {value}")
|
|
631
|
+
|
|
632
|
+
return await get_default_permissions(request)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
636
|
+
# Token Profiles
|
|
637
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
638
|
+
|
|
639
|
+
@router.get("/api/app/token-profiles")
|
|
640
|
+
async def get_token_profiles(request: Request):
|
|
641
|
+
"""List available token profiles and the default engine's default."""
|
|
642
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
643
|
+
from painapple_code.utils.token_profiles import list_profiles
|
|
644
|
+
profiles = list_profiles()
|
|
645
|
+
# Legacy shape; the default shown is the DEFAULT ENGINE's configured
|
|
646
|
+
# profile (per-engine `default_token_profiles` map, flat key fallback).
|
|
647
|
+
default = paths.engine_default_token_profile(
|
|
648
|
+
effective_default_provider(request.app))
|
|
649
|
+
return {
|
|
650
|
+
"profiles": profiles,
|
|
651
|
+
"default_profile": default,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@router.put("/api/app/default-token-profile")
|
|
656
|
+
async def set_default_token_profile(request: Request):
|
|
657
|
+
"""Legacy wrapper: set the DEFAULT ENGINE's default token profile."""
|
|
658
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
659
|
+
from painapple_code.utils.token_profiles import list_profiles
|
|
660
|
+
p = effective_default_provider(request.app)
|
|
661
|
+
body = await request.json()
|
|
662
|
+
value = body.get("token_profile")
|
|
663
|
+
|
|
664
|
+
# Validate profile exists (or null to clear)
|
|
665
|
+
if value is not None:
|
|
666
|
+
profile_names = {x["name"] for x in list_profiles()}
|
|
667
|
+
if value not in profile_names:
|
|
668
|
+
raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
|
|
669
|
+
|
|
670
|
+
config = paths.load_global_config()
|
|
671
|
+
_migrate_legacy_default(
|
|
672
|
+
config, "token_profile", "default_token_profiles", "default_token_profile")
|
|
673
|
+
overrides = dict(config.get("default_token_profiles") or {})
|
|
674
|
+
ns = p.models_key or p.name
|
|
675
|
+
if value is None:
|
|
676
|
+
overrides.pop(ns, None)
|
|
677
|
+
else:
|
|
678
|
+
overrides[ns] = value
|
|
679
|
+
if overrides:
|
|
680
|
+
config["default_token_profiles"] = overrides
|
|
681
|
+
else:
|
|
682
|
+
config.pop("default_token_profiles", None)
|
|
683
|
+
|
|
684
|
+
paths.save_global_config(config)
|
|
685
|
+
logger.info(f"Default token profile for {ns} updated to: {value}")
|
|
686
|
+
|
|
687
|
+
return await get_token_profiles(request)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
691
|
+
# Models
|
|
692
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
693
|
+
|
|
694
|
+
@router.get("/api/app/models")
|
|
695
|
+
async def get_models():
|
|
696
|
+
"""Get available models from models.yaml."""
|
|
697
|
+
return {
|
|
698
|
+
"selectable": paths.get_selectable_models(),
|
|
699
|
+
"summary_model": paths.get_summary_model(),
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@router.put("/api/app/models")
|
|
704
|
+
async def put_models(request: Request):
|
|
705
|
+
"""Replace models.yaml with the posted config. Body: {selectable: [...], summary_model: "..."}"""
|
|
706
|
+
body = await request.json()
|
|
707
|
+
selectable = body.get("selectable", [])
|
|
708
|
+
# Back-compat: older clients sent the key as `haiku`.
|
|
709
|
+
summary_model = body.get("summary_model") or body.get("haiku", "")
|
|
710
|
+
|
|
711
|
+
if not isinstance(selectable, list):
|
|
712
|
+
raise HTTPException(status_code=400, detail="selectable must be a list")
|
|
713
|
+
if not isinstance(summary_model, str) or not summary_model.strip():
|
|
714
|
+
raise HTTPException(status_code=400, detail="summary_model must be a non-empty string")
|
|
715
|
+
|
|
716
|
+
# Validate each selectable entry has required fields
|
|
717
|
+
cleaned = []
|
|
718
|
+
for i, m in enumerate(selectable):
|
|
719
|
+
if not isinstance(m, dict) or not m.get("id"):
|
|
720
|
+
raise HTTPException(status_code=400, detail=f"selectable[{i}] must have an id")
|
|
721
|
+
cleaned.append({
|
|
722
|
+
"id": str(m["id"]).strip(),
|
|
723
|
+
"label": str(m.get("label", "")).strip() or str(m["id"]).strip(),
|
|
724
|
+
"desc": str(m.get("desc", "")).strip(),
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
paths.save_models_config({"selectable": cleaned, "summary_model": summary_model.strip()})
|
|
728
|
+
logger.info(f"Models config updated: {len(cleaned)} selectable, summary_model={summary_model}")
|
|
729
|
+
return {"selectable": cleaned, "summary_model": summary_model.strip()}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
@router.post("/api/app/models/reset")
|
|
733
|
+
async def reset_models():
|
|
734
|
+
"""Restore models.yaml to the shipped defaults."""
|
|
735
|
+
defaults = paths.get_default_models_config()
|
|
736
|
+
paths.save_models_config(defaults)
|
|
737
|
+
logger.info(f"Models config reset to defaults: {len(defaults.get('selectable', []))} selectable")
|
|
738
|
+
return {
|
|
739
|
+
"selectable": paths.get_selectable_models(),
|
|
740
|
+
"summary_model": paths.get_summary_model(),
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
745
|
+
# Default Provider (engine for new sessions)
|
|
746
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
747
|
+
|
|
748
|
+
@router.get("/api/app/default-provider")
|
|
749
|
+
async def get_default_provider(request: Request):
|
|
750
|
+
"""The engine NEW sessions get when the picker isn't touched.
|
|
751
|
+
|
|
752
|
+
`effective` is the resolved answer (--default-provider flag →
|
|
753
|
+
`default_provider` config key → registry default). `configured` is the
|
|
754
|
+
raw config-key value (None = unset). `pinned_by_flag` tells the UI the
|
|
755
|
+
server flag overrides whatever it writes via PUT."""
|
|
756
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
757
|
+
agents = getattr(request.app.state, "agents", None)
|
|
758
|
+
config = paths.load_global_config()
|
|
759
|
+
return {
|
|
760
|
+
"effective": effective_default_provider(request.app).name,
|
|
761
|
+
"configured": config.get("default_provider"),
|
|
762
|
+
"pinned_by_flag": bool(getattr(agents, "default_provider", None)),
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@router.put("/api/app/default-provider")
|
|
767
|
+
async def set_default_provider(request: Request):
|
|
768
|
+
"""Set (or clear with null) the `default_provider` config key. Validated
|
|
769
|
+
against the live registry; note a --default-provider server flag still
|
|
770
|
+
wins over this key until the server restarts without it."""
|
|
771
|
+
from painapple_code.providers import provider_names
|
|
772
|
+
body = await request.json()
|
|
773
|
+
value = body.get("default_provider") # string or null to clear
|
|
774
|
+
|
|
775
|
+
if value is not None and value not in provider_names():
|
|
776
|
+
raise HTTPException(
|
|
777
|
+
status_code=400,
|
|
778
|
+
detail=f"Unknown provider: {value}. Valid: {provider_names()}",
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
config = paths.load_global_config()
|
|
782
|
+
if value is None:
|
|
783
|
+
config.pop("default_provider", None)
|
|
784
|
+
else:
|
|
785
|
+
config["default_provider"] = value
|
|
786
|
+
|
|
787
|
+
paths.save_global_config(config)
|
|
788
|
+
logger.info(f"Default provider updated to: {value}")
|
|
789
|
+
|
|
790
|
+
return await get_default_provider(request)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
794
|
+
# Providers enabled (which engines the picker offers)
|
|
795
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
796
|
+
|
|
797
|
+
@router.get("/api/app/providers-enabled")
|
|
798
|
+
async def get_providers_enabled(request: Request):
|
|
799
|
+
"""Per-engine picker visibility.
|
|
800
|
+
|
|
801
|
+
`effective` is what the picker uses ({name: bool}; config override →
|
|
802
|
+
provider's own `default_enabled`, with the default engine forced on).
|
|
803
|
+
`configured` is the raw `providers_enabled` config map (only names the
|
|
804
|
+
user has explicitly toggled)."""
|
|
805
|
+
from painapple_code.routes.dependencies import provider_enabled_map
|
|
806
|
+
config = paths.load_global_config()
|
|
807
|
+
return {
|
|
808
|
+
"effective": provider_enabled_map(request.app),
|
|
809
|
+
"configured": config.get("providers_enabled") or {},
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@router.put("/api/app/providers-enabled")
|
|
814
|
+
async def set_provider_enabled(request: Request):
|
|
815
|
+
"""Toggle one engine in/out of the picker: `{provider, enabled}`.
|
|
816
|
+
|
|
817
|
+
`enabled: null` clears the override (back to the provider's own
|
|
818
|
+
`default_enabled`). Disabling affects only the picker listing — bound
|
|
819
|
+
sessions and explicit API selection keep working; the effective default
|
|
820
|
+
engine reads back enabled regardless."""
|
|
821
|
+
from painapple_code.providers import provider_names
|
|
822
|
+
body = await request.json()
|
|
823
|
+
name = body.get("provider")
|
|
824
|
+
enabled = body.get("enabled") # bool, or null to clear the override
|
|
825
|
+
|
|
826
|
+
if name not in provider_names():
|
|
827
|
+
raise HTTPException(
|
|
828
|
+
status_code=400,
|
|
829
|
+
detail=f"Unknown provider: {name}. Valid: {provider_names()}",
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
config = paths.load_global_config()
|
|
833
|
+
overrides = dict(config.get("providers_enabled") or {})
|
|
834
|
+
if enabled is None:
|
|
835
|
+
overrides.pop(name, None)
|
|
836
|
+
else:
|
|
837
|
+
overrides[name] = bool(enabled)
|
|
838
|
+
|
|
839
|
+
if overrides:
|
|
840
|
+
config["providers_enabled"] = overrides
|
|
841
|
+
else:
|
|
842
|
+
config.pop("providers_enabled", None)
|
|
843
|
+
|
|
844
|
+
paths.save_global_config(config)
|
|
845
|
+
logger.info(f"Provider {name} picker visibility set to: {enabled}")
|
|
846
|
+
|
|
847
|
+
return await get_providers_enabled(request)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@router.get("/api/app/default-model")
|
|
851
|
+
async def get_default_model(request: Request):
|
|
852
|
+
"""Legacy wrapper: the DEFAULT ENGINE's configured new-session model.
|
|
853
|
+
|
|
854
|
+
Storage moved to the per-engine `default_models` map (see
|
|
855
|
+
engine-defaults); this endpoint stays for old callers and reads/writes
|
|
856
|
+
through the default engine's entry.
|
|
857
|
+
"""
|
|
858
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
859
|
+
p = effective_default_provider(request.app)
|
|
860
|
+
return {"default_model": paths.engine_default_model(p)}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@router.put("/api/app/default-model")
|
|
864
|
+
async def set_default_model(request: Request):
|
|
865
|
+
"""Legacy wrapper: set the DEFAULT ENGINE's new-session model."""
|
|
866
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
867
|
+
p = effective_default_provider(request.app)
|
|
868
|
+
body = await request.json()
|
|
869
|
+
value = body.get("default_model") # string or null
|
|
870
|
+
|
|
871
|
+
config = paths.load_global_config()
|
|
872
|
+
_migrate_legacy_default(config, "default_model", "default_models", "default_model")
|
|
873
|
+
overrides = dict(config.get("default_models") or {})
|
|
874
|
+
ns = p.models_key or p.name
|
|
875
|
+
if value:
|
|
876
|
+
overrides[ns] = value
|
|
877
|
+
else:
|
|
878
|
+
overrides.pop(ns, None)
|
|
879
|
+
if overrides:
|
|
880
|
+
config["default_models"] = overrides
|
|
881
|
+
else:
|
|
882
|
+
config.pop("default_models", None)
|
|
883
|
+
|
|
884
|
+
paths.save_global_config(config)
|
|
885
|
+
logger.info(f"Default model for {ns} updated to: {value}")
|
|
886
|
+
|
|
887
|
+
return await get_default_model(request)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
891
|
+
# Default Effort Level
|
|
892
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
893
|
+
|
|
894
|
+
VALID_EFFORT_LEVELS = {"low", "medium", "high", "xhigh", "max"}
|
|
895
|
+
DEFAULT_EFFORT = "high"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@router.get("/api/app/default-effort")
|
|
899
|
+
async def get_default_effort(request: Request):
|
|
900
|
+
"""Legacy wrapper: the DEFAULT ENGINE's configured default effort.
|
|
901
|
+
|
|
902
|
+
Storage moved to the per-engine `default_efforts` map (see
|
|
903
|
+
engine-defaults); this stays for old callers (effort popup fallback).
|
|
904
|
+
"""
|
|
905
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
906
|
+
p = effective_default_provider(request.app)
|
|
907
|
+
return {
|
|
908
|
+
"default_effort": paths.engine_default_effort(p) or DEFAULT_EFFORT,
|
|
909
|
+
"valid_levels": p.effort_levels() or sorted(VALID_EFFORT_LEVELS),
|
|
910
|
+
"default": DEFAULT_EFFORT,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
@router.put("/api/app/default-effort")
|
|
915
|
+
async def set_default_effort(request: Request):
|
|
916
|
+
"""Legacy wrapper: set the DEFAULT ENGINE's default effort."""
|
|
917
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
918
|
+
p = effective_default_provider(request.app)
|
|
919
|
+
body = await request.json()
|
|
920
|
+
value = body.get("default_effort")
|
|
921
|
+
|
|
922
|
+
valid = set(p.effort_levels() or VALID_EFFORT_LEVELS)
|
|
923
|
+
if value not in valid:
|
|
924
|
+
raise HTTPException(status_code=400, detail=f"Invalid effort level: {value}. Valid: {sorted(valid)}")
|
|
925
|
+
|
|
926
|
+
config = paths.load_global_config()
|
|
927
|
+
_migrate_legacy_default(config, "default_effort", "default_efforts", "default_effort")
|
|
928
|
+
overrides = dict(config.get("default_efforts") or {})
|
|
929
|
+
ns = p.models_key or p.name
|
|
930
|
+
if value == DEFAULT_EFFORT:
|
|
931
|
+
overrides.pop(ns, None)
|
|
932
|
+
else:
|
|
933
|
+
overrides[ns] = value
|
|
934
|
+
if overrides:
|
|
935
|
+
config["default_efforts"] = overrides
|
|
936
|
+
else:
|
|
937
|
+
config.pop("default_efforts", None)
|
|
938
|
+
|
|
939
|
+
paths.save_global_config(config)
|
|
940
|
+
logger.info(f"Default effort for {ns} updated to: {value}")
|
|
941
|
+
|
|
942
|
+
return await get_default_effort(request)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
946
|
+
# Session Timeout Settings
|
|
947
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
948
|
+
|
|
949
|
+
@router.get("/api/app/session-timeouts")
|
|
950
|
+
async def get_session_timeouts():
|
|
951
|
+
"""Get session timeout settings (in minutes)."""
|
|
952
|
+
from painapple_code.services.agent_session import AgentManager
|
|
953
|
+
config = paths.load_global_config()
|
|
954
|
+
return {
|
|
955
|
+
"session_idle_timeout_minutes": config.get(
|
|
956
|
+
"session_idle_timeout_minutes",
|
|
957
|
+
AgentManager.DEFAULT_SESSION_IDLE_TIMEOUT // 60
|
|
958
|
+
),
|
|
959
|
+
"orphan_process_timeout_minutes": config.get(
|
|
960
|
+
"orphan_process_timeout_minutes",
|
|
961
|
+
AgentManager.DEFAULT_ORPHAN_PROCESS_TIMEOUT // 60
|
|
962
|
+
),
|
|
963
|
+
"defaults": {
|
|
964
|
+
"session_idle_timeout_minutes": AgentManager.DEFAULT_SESSION_IDLE_TIMEOUT // 60,
|
|
965
|
+
"orphan_process_timeout_minutes": AgentManager.DEFAULT_ORPHAN_PROCESS_TIMEOUT // 60,
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
@router.put("/api/app/session-timeouts")
|
|
971
|
+
async def set_session_timeouts(request: Request):
|
|
972
|
+
"""Update session timeout settings (in minutes)."""
|
|
973
|
+
from painapple_code.services.agent_session import AgentManager
|
|
974
|
+
body = await request.json()
|
|
975
|
+
config = paths.load_global_config()
|
|
976
|
+
|
|
977
|
+
for key in ("session_idle_timeout_minutes", "orphan_process_timeout_minutes"):
|
|
978
|
+
if key in body:
|
|
979
|
+
value = body[key]
|
|
980
|
+
try:
|
|
981
|
+
value = int(value)
|
|
982
|
+
except (ValueError, TypeError):
|
|
983
|
+
raise HTTPException(status_code=400, detail=f"{key} must be an integer")
|
|
984
|
+
if value < 1:
|
|
985
|
+
raise HTTPException(status_code=400, detail=f"{key} must be at least 1 minute")
|
|
986
|
+
if value > 1440:
|
|
987
|
+
raise HTTPException(status_code=400, detail=f"{key} cannot exceed 1440 minutes (24 hours)")
|
|
988
|
+
|
|
989
|
+
default_minutes = getattr(AgentManager, f"DEFAULT_{key.upper().replace('_MINUTES', '')}") // 60
|
|
990
|
+
if value == default_minutes:
|
|
991
|
+
config.pop(key, None)
|
|
992
|
+
else:
|
|
993
|
+
config[key] = value
|
|
994
|
+
|
|
995
|
+
paths.save_global_config(config)
|
|
996
|
+
logger.info(f"Session timeouts updated: {body}")
|
|
997
|
+
return await get_session_timeouts()
|