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,742 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sessions API Routes - Session CRUD, search, fork, threads
|
|
3
|
+
|
|
4
|
+
These endpoints manage:
|
|
5
|
+
- Session listing, creation, deletion, renaming
|
|
6
|
+
- Session search (keyword and AI-powered)
|
|
7
|
+
- Session preview, thinking tokens
|
|
8
|
+
- Session fork, graduate, threads
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from painapple_code import paths
|
|
19
|
+
from painapple_code.session_store import SessionStore
|
|
20
|
+
from painapple_code.utils.file_paths import safe_resolve
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["sessions"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
28
|
+
# Session CRUD
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
@router.get("/api/sessions")
|
|
32
|
+
async def list_sessions():
|
|
33
|
+
"""List all stored sessions (filters out ungraduated discussion threads)."""
|
|
34
|
+
all_sessions = SessionStore.list_all()
|
|
35
|
+
visible_sessions = [
|
|
36
|
+
s for s in all_sessions
|
|
37
|
+
if not s.get("isCommentThread") or s.get("graduated")
|
|
38
|
+
]
|
|
39
|
+
return {"sessions": visible_sessions}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/api/sessions")
|
|
43
|
+
async def create_session(request: Request, cwd: str, name: str = None, provider: str = None):
|
|
44
|
+
"""Create a new session.
|
|
45
|
+
|
|
46
|
+
`provider` picks the engine for this session (recorded in meta, permanent —
|
|
47
|
+
the session's provider_session_id only means anything to that engine).
|
|
48
|
+
Unknown names 400 — REST callers asked for it explicitly, so failing loud
|
|
49
|
+
beats a silent claude-sdk fallback. Omitted → the same effective default a
|
|
50
|
+
WS-created session gets (--default-provider flag / `default_provider`
|
|
51
|
+
config key), so REST- and WS-created sessions no longer diverge.
|
|
52
|
+
"""
|
|
53
|
+
from painapple_code.providers import get_provider, provider_names
|
|
54
|
+
if provider is not None and provider not in provider_names():
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=400,
|
|
57
|
+
detail=f"Unknown provider: {provider}. Valid: {provider_names()}",
|
|
58
|
+
)
|
|
59
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
60
|
+
session_data = SessionStore.create(resolved_cwd, name)
|
|
61
|
+
agents = getattr(request.app.state, "agents", None)
|
|
62
|
+
provider_name = (provider
|
|
63
|
+
or getattr(agents, "default_provider", None)
|
|
64
|
+
or paths.load_global_config().get("default_provider"))
|
|
65
|
+
if provider_name:
|
|
66
|
+
SessionStore.update_metadata(session_data["id"], provider=provider_name)
|
|
67
|
+
session_data["provider"] = provider_name
|
|
68
|
+
# Bind-time permission anchoring — same contract as the WS create
|
|
69
|
+
# path: a global default level this engine doesn't speak is replaced
|
|
70
|
+
# by the engine's own default so meta, UI, and launch agree.
|
|
71
|
+
from painapple_code.routes.dependencies import bind_permission_level
|
|
72
|
+
anchored = bind_permission_level(None, get_provider(provider_name))
|
|
73
|
+
if anchored:
|
|
74
|
+
SessionStore.update_metadata(
|
|
75
|
+
session_data["id"], permission_level=anchored)
|
|
76
|
+
session_data["permission_level"] = anchored
|
|
77
|
+
return session_data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/api/session/{session_id}")
|
|
81
|
+
async def get_session(session_id: str):
|
|
82
|
+
"""Get a session by ID (includes messages)."""
|
|
83
|
+
data = SessionStore.load(session_id)
|
|
84
|
+
if not data:
|
|
85
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.delete("/api/session/{session_id}")
|
|
90
|
+
async def delete_session(session_id: str):
|
|
91
|
+
"""Delete a session's ON-DISK store (rmtree of the session directory).
|
|
92
|
+
|
|
93
|
+
Deliberately narrow, and as of 2026-08 called by NO UI surface — the
|
|
94
|
+
Active Sessions widget's Stop button pointed here for 8 months as an
|
|
95
|
+
explicit "for now" shortcut (b8b36bda) and silently destroyed session
|
|
96
|
+
data; it now uses /stop below. This endpoint does NOT stop a live agent
|
|
97
|
+
process, NOT remove the in-memory session, and NOT touch DuckDB turns or
|
|
98
|
+
shadow-git commits (retained by design — see SECURITY.md). If a real
|
|
99
|
+
"delete session" UI is ever added, it must not point here as-is: stop the
|
|
100
|
+
agent first and say what is retained.
|
|
101
|
+
"""
|
|
102
|
+
if SessionStore.delete(session_id):
|
|
103
|
+
return {"deleted": True, "id": session_id}
|
|
104
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/api/session/{session_id}/stop")
|
|
108
|
+
async def stop_session(session_id: str):
|
|
109
|
+
"""Interrupt a session's in-flight response. Data is untouched.
|
|
110
|
+
|
|
111
|
+
REST twin of the WebSocket `stop` message (`_handle_stop` in ws_chat.py),
|
|
112
|
+
for surfaces that hold no socket to the session — the Active Sessions
|
|
113
|
+
widget lists every live session but is attached to none of them. Same
|
|
114
|
+
engine-aware path (`interrupt_agent`): JSON-RPC turn/interrupt or SDK
|
|
115
|
+
control-plane where the provider supports it (process stays warm), SIGINT
|
|
116
|
+
otherwise. Precedent: f513748a gave worktrees a stop endpoint after its
|
|
117
|
+
widget's stop button was found calling DELETE — the same bug shape this
|
|
118
|
+
endpoint exists to close.
|
|
119
|
+
|
|
120
|
+
404: no live session by that id (stored-but-not-running sessions have no
|
|
121
|
+
process to interrupt). 200 {stopped: false}: session is live but idle —
|
|
122
|
+
reachable by racing a turn's natural end, so it is a state, not an error.
|
|
123
|
+
The idle check happens HERE, not in interrupt_agent: a warm-idle claude-sdk
|
|
124
|
+
process acks a control-plane interrupt even with nothing in flight, so
|
|
125
|
+
without this guard the race would report "stopped: true" for a no-op.
|
|
126
|
+
"""
|
|
127
|
+
from painapple_code.server import agents
|
|
128
|
+
|
|
129
|
+
session = agents.sessions.get(session_id) if agents else None
|
|
130
|
+
if session is None:
|
|
131
|
+
raise HTTPException(status_code=404, detail="No live session with that id")
|
|
132
|
+
|
|
133
|
+
if session.is_idle:
|
|
134
|
+
return {"stopped": False, "id": session_id}
|
|
135
|
+
|
|
136
|
+
stopped = await agents.interrupt_agent(session)
|
|
137
|
+
return {"stopped": stopped, "id": session_id}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.patch("/api/sessions/{session_id}/meta")
|
|
141
|
+
async def update_session_meta(session_id: str, request: Request):
|
|
142
|
+
"""Update session metadata (name)."""
|
|
143
|
+
meta = SessionStore.load_meta(session_id)
|
|
144
|
+
if not meta:
|
|
145
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
146
|
+
|
|
147
|
+
body = await request.json()
|
|
148
|
+
|
|
149
|
+
if "name" in body:
|
|
150
|
+
SessionStore.update_metadata(session_id, name=body["name"])
|
|
151
|
+
elif "description" in body:
|
|
152
|
+
SessionStore.update_metadata(session_id, name=body["description"])
|
|
153
|
+
|
|
154
|
+
updated_meta = SessionStore.load_meta(session_id)
|
|
155
|
+
return {"id": session_id, "meta": updated_meta}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RenameRequest(BaseModel):
|
|
159
|
+
name: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/api/sessions/{session_id}/rename")
|
|
163
|
+
async def rename_session(session_id: str, request: RenameRequest):
|
|
164
|
+
"""Rename a session. Sets manual_name flag to prevent summary auto-overwrite."""
|
|
165
|
+
data = SessionStore.load(session_id)
|
|
166
|
+
if not data:
|
|
167
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
168
|
+
|
|
169
|
+
SessionStore.save(session_id, {
|
|
170
|
+
"name": request.name,
|
|
171
|
+
"manual_name": True,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return {"success": True, "session_id": session_id, "name": request.name}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
178
|
+
# Session Thinking Tokens
|
|
179
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
180
|
+
|
|
181
|
+
def _get_max_thinking_tokens() -> int:
|
|
182
|
+
"""Get the max thinking tokens from config or default."""
|
|
183
|
+
config = paths.load_global_config()
|
|
184
|
+
return config.get("max_thinking_tokens", 31999)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.get("/api/session/{session_id}/thinking-tokens")
|
|
188
|
+
async def get_session_thinking_tokens(session_id: str):
|
|
189
|
+
"""Get the max thinking tokens for a specific session."""
|
|
190
|
+
meta = SessionStore.load_meta(session_id)
|
|
191
|
+
if not meta:
|
|
192
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
193
|
+
|
|
194
|
+
session_tokens = meta.get("max_thinking_tokens")
|
|
195
|
+
global_default = _get_max_thinking_tokens()
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"max_thinking_tokens": session_tokens if session_tokens is not None else global_default,
|
|
199
|
+
"is_session_override": session_tokens is not None,
|
|
200
|
+
"global_default": global_default,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.put("/api/session/{session_id}/thinking-tokens")
|
|
205
|
+
async def set_session_thinking_tokens(session_id: str, request: Request):
|
|
206
|
+
"""Set the max thinking tokens for a specific session."""
|
|
207
|
+
meta = SessionStore.load_meta(session_id)
|
|
208
|
+
if not meta:
|
|
209
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
210
|
+
|
|
211
|
+
body = await request.json()
|
|
212
|
+
value = body.get("max_thinking_tokens")
|
|
213
|
+
|
|
214
|
+
if value is not None:
|
|
215
|
+
try:
|
|
216
|
+
value = int(value)
|
|
217
|
+
except (ValueError, TypeError):
|
|
218
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens must be an integer or null")
|
|
219
|
+
|
|
220
|
+
if value < 0:
|
|
221
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens cannot be negative")
|
|
222
|
+
if value > 63999:
|
|
223
|
+
raise HTTPException(status_code=400, detail="max_thinking_tokens cannot exceed 63999")
|
|
224
|
+
|
|
225
|
+
meta["max_thinking_tokens"] = value
|
|
226
|
+
SessionStore.update_metadata(session_id, max_thinking_tokens=value)
|
|
227
|
+
|
|
228
|
+
logger.info(f"Session {session_id} thinking tokens set to: {value}")
|
|
229
|
+
|
|
230
|
+
return await get_session_thinking_tokens(session_id)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
234
|
+
# Session Permission Level
|
|
235
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
236
|
+
|
|
237
|
+
def _get_default_permission_level() -> str:
|
|
238
|
+
"""Get the default permission level from config, falling back to the default
|
|
239
|
+
provider's own default (no hardcoded engine vocabulary)."""
|
|
240
|
+
from painapple_code.providers import get_provider, DEFAULT_PROVIDER
|
|
241
|
+
config = paths.load_global_config()
|
|
242
|
+
return config.get("default_permission_level") or get_provider(DEFAULT_PROVIDER).default_permission_mode()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.get("/api/session/{session_id}/permission-mode")
|
|
246
|
+
async def get_session_permission_mode(session_id: str, request: Request):
|
|
247
|
+
"""Get the permission mode for a specific session, plus the mode vocabulary
|
|
248
|
+
of the engine that session runs on (drives the client's permission picker —
|
|
249
|
+
each provider self-describes its modes, e.g. claude-sdk adds "Ask")."""
|
|
250
|
+
meta = SessionStore.load_meta(session_id)
|
|
251
|
+
if not meta:
|
|
252
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
253
|
+
|
|
254
|
+
session_level = meta.get("permission_level")
|
|
255
|
+
global_default = _get_default_permission_level()
|
|
256
|
+
|
|
257
|
+
from painapple_code.providers import get_provider
|
|
258
|
+
from painapple_code.routes.dependencies import effective_default_provider
|
|
259
|
+
provider = (get_provider(meta["provider"]) if meta.get("provider")
|
|
260
|
+
else effective_default_provider(request.app))
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"permission_level": session_level if session_level is not None else global_default,
|
|
264
|
+
"is_session_override": session_level is not None,
|
|
265
|
+
"global_default": global_default,
|
|
266
|
+
"modes": provider.permission_modes(),
|
|
267
|
+
"provider_default": provider.default_permission_mode(),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.put("/api/session/{session_id}/permission-mode")
|
|
272
|
+
async def set_session_permission_mode(session_id: str, request: Request):
|
|
273
|
+
"""Set the permission mode for a specific session."""
|
|
274
|
+
meta = SessionStore.load_meta(session_id)
|
|
275
|
+
if not meta:
|
|
276
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
277
|
+
|
|
278
|
+
body = await request.json()
|
|
279
|
+
value = body.get("permission_level")
|
|
280
|
+
|
|
281
|
+
from painapple_code.providers import valid_permission_values
|
|
282
|
+
if value is not None and value not in valid_permission_values():
|
|
283
|
+
raise HTTPException(status_code=400, detail=f"Invalid permission level: {value}")
|
|
284
|
+
|
|
285
|
+
SessionStore.update_metadata(session_id, permission_level=value)
|
|
286
|
+
logger.info(f"Session {session_id} permission level set to: {value}")
|
|
287
|
+
|
|
288
|
+
return await get_session_permission_mode(session_id, request)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
292
|
+
# Session Token Profile
|
|
293
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
294
|
+
|
|
295
|
+
@router.get("/api/session/{session_id}/token-profile")
|
|
296
|
+
async def get_session_token_profile(session_id: str):
|
|
297
|
+
"""Get the token profile for a specific session."""
|
|
298
|
+
meta = SessionStore.load_meta(session_id)
|
|
299
|
+
if not meta:
|
|
300
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
301
|
+
|
|
302
|
+
from painapple_code.providers import get_provider
|
|
303
|
+
session_profile = meta.get("token_profile")
|
|
304
|
+
# The default shown/used is the SESSION ENGINE's configured profile.
|
|
305
|
+
global_default = paths.engine_default_token_profile(
|
|
306
|
+
get_provider(meta.get("provider")))
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"token_profile": session_profile if session_profile is not None else global_default,
|
|
310
|
+
"is_session_override": session_profile is not None,
|
|
311
|
+
"global_default": global_default,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.put("/api/session/{session_id}/token-profile")
|
|
316
|
+
async def set_session_token_profile(session_id: str, request: Request):
|
|
317
|
+
"""Set the token profile for a specific session."""
|
|
318
|
+
meta = SessionStore.load_meta(session_id)
|
|
319
|
+
if not meta:
|
|
320
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
321
|
+
|
|
322
|
+
body = await request.json()
|
|
323
|
+
value = body.get("token_profile") # string or null
|
|
324
|
+
|
|
325
|
+
# Validate profile exists
|
|
326
|
+
if value is not None:
|
|
327
|
+
from painapple_code.utils.token_profiles import list_profiles
|
|
328
|
+
profile_names = {p["name"] for p in list_profiles()}
|
|
329
|
+
if value not in profile_names:
|
|
330
|
+
raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
|
|
331
|
+
|
|
332
|
+
SessionStore.update_metadata(session_id, token_profile=value)
|
|
333
|
+
|
|
334
|
+
# Update live session and kill idle process so next turn uses new token
|
|
335
|
+
from painapple_code.server import agents
|
|
336
|
+
if agents:
|
|
337
|
+
live_session = agents.get_session(session_id)
|
|
338
|
+
if live_session:
|
|
339
|
+
live_session.token_profile = value
|
|
340
|
+
if live_session.is_idle and live_session.process:
|
|
341
|
+
live_session._interrupting = True # suppress session_ended WS msg
|
|
342
|
+
await agents.stop_session(live_session)
|
|
343
|
+
logger.info(f"Killed idle process for session {session_id} (token profile changed)")
|
|
344
|
+
|
|
345
|
+
logger.info(f"Session {session_id} token profile set to: {value}")
|
|
346
|
+
|
|
347
|
+
return await get_session_token_profile(session_id)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
351
|
+
# Session Preferred Model
|
|
352
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
353
|
+
|
|
354
|
+
@router.get("/api/session/{session_id}/model")
|
|
355
|
+
async def get_session_model(session_id: str):
|
|
356
|
+
"""Get the preferred model for a specific session."""
|
|
357
|
+
meta = SessionStore.load_meta(session_id)
|
|
358
|
+
if not meta:
|
|
359
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
360
|
+
|
|
361
|
+
from painapple_code.providers import get_provider
|
|
362
|
+
session_model = meta.get("preferred_model")
|
|
363
|
+
# The default shown by the chip is the SESSION ENGINE's configured model
|
|
364
|
+
# (models_key-scoped map, legacy flat key as fallback).
|
|
365
|
+
global_default = paths.engine_default_model(
|
|
366
|
+
get_provider(meta.get("provider")))
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
"preferred_model": session_model if session_model is not None else global_default,
|
|
370
|
+
"is_session_override": session_model is not None,
|
|
371
|
+
"global_default": global_default,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@router.put("/api/session/{session_id}/model")
|
|
376
|
+
async def set_session_model(session_id: str, request: Request):
|
|
377
|
+
"""Set the preferred model for a specific session."""
|
|
378
|
+
meta = SessionStore.load_meta(session_id)
|
|
379
|
+
if not meta:
|
|
380
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
381
|
+
|
|
382
|
+
body = await request.json()
|
|
383
|
+
value = body.get("preferred_model") # string or null
|
|
384
|
+
|
|
385
|
+
SessionStore.update_metadata(session_id, preferred_model=value)
|
|
386
|
+
|
|
387
|
+
# Update live session and kill idle process so next turn uses new model
|
|
388
|
+
from painapple_code.server import agents
|
|
389
|
+
if agents:
|
|
390
|
+
live_session = agents.get_session(session_id)
|
|
391
|
+
if live_session:
|
|
392
|
+
live_session.preferred_model = value
|
|
393
|
+
if live_session.is_idle and live_session.process:
|
|
394
|
+
live_session._interrupting = True
|
|
395
|
+
await agents.stop_session(live_session)
|
|
396
|
+
logger.info(f"Killed idle process for session {session_id} (model changed to: {value})")
|
|
397
|
+
|
|
398
|
+
logger.info(f"Session {session_id} preferred model set to: {value}")
|
|
399
|
+
|
|
400
|
+
return await get_session_model(session_id)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
404
|
+
# Session Effort Level
|
|
405
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
406
|
+
|
|
407
|
+
VALID_EFFORT_LEVELS = {"low", "medium", "high", "xhigh", "max"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@router.get("/api/session/{session_id}/effort")
|
|
411
|
+
async def get_session_effort(session_id: str):
|
|
412
|
+
"""Get the effort level for a specific session."""
|
|
413
|
+
meta = SessionStore.load_meta(session_id)
|
|
414
|
+
if not meta:
|
|
415
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
416
|
+
|
|
417
|
+
from painapple_code.providers import get_provider
|
|
418
|
+
session_effort = meta.get("effort_level")
|
|
419
|
+
# The default is the SESSION ENGINE's configured effort (vocab-gated).
|
|
420
|
+
global_default = paths.engine_default_effort(
|
|
421
|
+
get_provider(meta.get("provider"))) or "high"
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
"effort_level": session_effort if session_effort is not None else global_default,
|
|
425
|
+
"is_session_override": session_effort is not None,
|
|
426
|
+
"global_default": global_default,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@router.put("/api/session/{session_id}/effort")
|
|
431
|
+
async def set_session_effort(session_id: str, request: Request):
|
|
432
|
+
"""Set the effort level for a specific session."""
|
|
433
|
+
meta = SessionStore.load_meta(session_id)
|
|
434
|
+
if not meta:
|
|
435
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
436
|
+
|
|
437
|
+
body = await request.json()
|
|
438
|
+
value = body.get("effort_level") # string or null
|
|
439
|
+
|
|
440
|
+
if value is not None:
|
|
441
|
+
# Validate against the SESSION ENGINE's own effort vocabulary — Codex
|
|
442
|
+
# speaks a richer scale (xhigh/max/ultra, per its models cache) than the
|
|
443
|
+
# Claude 5-level fallback, so a hardcoded set would reject valid levels.
|
|
444
|
+
from painapple_code.providers import get_provider
|
|
445
|
+
provider = get_provider(meta.get("provider"))
|
|
446
|
+
valid = set(provider.effort_levels() or VALID_EFFORT_LEVELS)
|
|
447
|
+
if value not in valid:
|
|
448
|
+
raise HTTPException(status_code=400, detail=f"Invalid effort level: {value}. Valid: {sorted(valid)}")
|
|
449
|
+
|
|
450
|
+
SessionStore.update_metadata(session_id, effort_level=value)
|
|
451
|
+
|
|
452
|
+
# Update live session and kill idle process so next turn uses new effort
|
|
453
|
+
from painapple_code.server import agents
|
|
454
|
+
if agents:
|
|
455
|
+
live_session = agents.get_session(session_id)
|
|
456
|
+
if live_session:
|
|
457
|
+
live_session.effort_level = value
|
|
458
|
+
if live_session.is_idle and live_session.process:
|
|
459
|
+
live_session._interrupting = True
|
|
460
|
+
await agents.stop_session(live_session)
|
|
461
|
+
logger.info(f"Killed idle process for session {session_id} (effort changed to: {value})")
|
|
462
|
+
|
|
463
|
+
logger.info(f"Session {session_id} effort level set to: {value}")
|
|
464
|
+
|
|
465
|
+
return await get_session_effort(session_id)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
469
|
+
# Session Fork
|
|
470
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
471
|
+
|
|
472
|
+
@router.get("/api/session/{session_id}/provider")
|
|
473
|
+
async def get_session_provider(session_id: str, request: Request):
|
|
474
|
+
"""This session's engine + whether it can still be changed.
|
|
475
|
+
|
|
476
|
+
`locked` flips permanently once the session has run a turn (or holds an
|
|
477
|
+
upstream provider_session_id) — the conversation lives in that engine's
|
|
478
|
+
own on-disk format and can't transfer."""
|
|
479
|
+
from painapple_code.routes.dependencies import effective_default_provider, provider_is_locked
|
|
480
|
+
meta = SessionStore.load_meta(session_id)
|
|
481
|
+
if not meta:
|
|
482
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
483
|
+
configured = meta.get("provider")
|
|
484
|
+
default_name = effective_default_provider(request.app).name
|
|
485
|
+
return {
|
|
486
|
+
"provider": configured or default_name,
|
|
487
|
+
"is_session_override": bool(configured),
|
|
488
|
+
"global_default": default_name,
|
|
489
|
+
"locked": provider_is_locked(meta),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@router.put("/api/session/{session_id}/provider")
|
|
494
|
+
async def set_session_provider(session_id: str, request: Request):
|
|
495
|
+
"""Switch an EMPTY session to another engine (409 once locked).
|
|
496
|
+
|
|
497
|
+
Also hot-swaps a live in-memory session: the provider object and its cost
|
|
498
|
+
state are replaced in place, and an idle warm process (spawned for the old
|
|
499
|
+
engine) is stopped so the next message launches on the new one."""
|
|
500
|
+
from painapple_code.providers import get_provider, provider_names
|
|
501
|
+
from painapple_code.routes.dependencies import provider_is_locked
|
|
502
|
+
meta = SessionStore.load_meta(session_id)
|
|
503
|
+
if not meta:
|
|
504
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
505
|
+
|
|
506
|
+
body = await request.json()
|
|
507
|
+
value = body.get("provider") # string, or null to clear back to default
|
|
508
|
+
|
|
509
|
+
if value is not None and value not in provider_names():
|
|
510
|
+
raise HTTPException(
|
|
511
|
+
status_code=400,
|
|
512
|
+
detail=f"Unknown provider: {value}. Valid: {provider_names()}",
|
|
513
|
+
)
|
|
514
|
+
if provider_is_locked(meta):
|
|
515
|
+
raise HTTPException(
|
|
516
|
+
status_code=409,
|
|
517
|
+
detail="Session already ran on its engine — provider is locked. Start a new session to switch.",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
SessionStore.update_metadata(session_id, provider=value)
|
|
521
|
+
|
|
522
|
+
# Re-anchor the permission level to the new engine's vocabulary: a level
|
|
523
|
+
# from the old engine (Claude's dontAsk on a switch to Codex, a sandbox
|
|
524
|
+
# tier on the way back) would launch through a silent back-compat mapping
|
|
525
|
+
# the UI can't label. Stamping the new engine's own default keeps meta,
|
|
526
|
+
# UI, and launch agreeing. A level both engines speak survives.
|
|
527
|
+
from painapple_code.routes.dependencies import (
|
|
528
|
+
bind_permission_level,
|
|
529
|
+
preferred_model_survives,
|
|
530
|
+
)
|
|
531
|
+
anchored = bind_permission_level(
|
|
532
|
+
meta.get("permission_level"), get_provider(value))
|
|
533
|
+
if anchored:
|
|
534
|
+
SessionStore.update_metadata(session_id, permission_level=anchored)
|
|
535
|
+
|
|
536
|
+
# Same contract for the model pick: a preferred_model from the old
|
|
537
|
+
# engine's catalog (a Claude id on a switch to Codex, a gpt id on the way
|
|
538
|
+
# back) is cleared so the session falls back to the new engine's own
|
|
539
|
+
# default instead of launching a model the engine drops or rejects.
|
|
540
|
+
model_cleared = not preferred_model_survives(
|
|
541
|
+
meta.get("preferred_model"), get_provider(value))
|
|
542
|
+
if model_cleared:
|
|
543
|
+
SessionStore.update_metadata(session_id, preferred_model=None)
|
|
544
|
+
|
|
545
|
+
agents = getattr(request.app.state, "agents", None)
|
|
546
|
+
if agents:
|
|
547
|
+
live_session = agents.get_session(session_id)
|
|
548
|
+
if live_session:
|
|
549
|
+
live_session.provider = get_provider(value)
|
|
550
|
+
live_session._cost_state = live_session.provider.new_cost_state()
|
|
551
|
+
# Mirror the anchoring into the in-memory overrides — they win
|
|
552
|
+
# over meta at launch, so a stale old-engine value here would
|
|
553
|
+
# undo the re-stamp above.
|
|
554
|
+
if anchored:
|
|
555
|
+
live_session.permission_mode = anchored
|
|
556
|
+
if model_cleared:
|
|
557
|
+
live_session.preferred_model = None
|
|
558
|
+
if live_session.is_idle and live_session.process:
|
|
559
|
+
live_session._interrupting = True
|
|
560
|
+
await agents.stop_session(live_session)
|
|
561
|
+
|
|
562
|
+
return await get_session_provider(session_id, request)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@router.post("/api/session/{session_id}/fork")
|
|
566
|
+
async def fork_session(session_id: str, comment_thread: bool = False, thread_anchor: str = None):
|
|
567
|
+
"""Fork a session - create a new session that branches from the source."""
|
|
568
|
+
source_meta = SessionStore.load_meta(session_id)
|
|
569
|
+
if not source_meta:
|
|
570
|
+
raise HTTPException(status_code=404, detail="Source session not found")
|
|
571
|
+
|
|
572
|
+
provider_session_id = source_meta.get("provider_session_id")
|
|
573
|
+
if not provider_session_id:
|
|
574
|
+
raise HTTPException(
|
|
575
|
+
status_code=400,
|
|
576
|
+
detail="Cannot fork: source session has no Claude conversation"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Engines that can't branch a conversation (capabilities.fork=False, e.g.
|
|
580
|
+
# ephemeral `codex exec`) fail loud here rather than minting a fork meta
|
|
581
|
+
# that would error on its first turn.
|
|
582
|
+
from painapple_code.providers import get_provider
|
|
583
|
+
source_provider = get_provider(source_meta.get("provider"))
|
|
584
|
+
if not source_provider.capabilities.fork:
|
|
585
|
+
raise HTTPException(
|
|
586
|
+
status_code=409,
|
|
587
|
+
detail=f"{source_provider.display_name} does not support forking sessions",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
cwd = source_meta.get("cwd", ".")
|
|
591
|
+
source_name = source_meta.get("name", "Session")
|
|
592
|
+
|
|
593
|
+
if comment_thread:
|
|
594
|
+
new_name = f"\U0001f4ac Thread from {source_name}"
|
|
595
|
+
else:
|
|
596
|
+
new_name = f"Fork of {source_name}"
|
|
597
|
+
|
|
598
|
+
new_session = SessionStore.create(cwd, name=new_name)
|
|
599
|
+
|
|
600
|
+
store, meta = SessionStore._find_session(new_session["id"])
|
|
601
|
+
if store and meta:
|
|
602
|
+
meta["forked_from"] = {
|
|
603
|
+
"store_id": session_id,
|
|
604
|
+
"provider_session_id": provider_session_id,
|
|
605
|
+
}
|
|
606
|
+
if comment_thread:
|
|
607
|
+
meta["isCommentThread"] = True
|
|
608
|
+
if thread_anchor:
|
|
609
|
+
try:
|
|
610
|
+
meta["threadAnchor"] = json.loads(thread_anchor)
|
|
611
|
+
except json.JSONDecodeError:
|
|
612
|
+
meta["threadAnchor"] = thread_anchor
|
|
613
|
+
# Inherit token_profile from source session
|
|
614
|
+
source_token_profile = source_meta.get("token_profile")
|
|
615
|
+
if source_token_profile:
|
|
616
|
+
meta["token_profile"] = source_token_profile
|
|
617
|
+
# Inherit the source's engine + launch settings so the branch continues
|
|
618
|
+
# on the same provider/model/effort/permissions rather than the app
|
|
619
|
+
# defaults. `provider` is essential for correctness: without it the fork
|
|
620
|
+
# falls back to the default engine (Claude) and can't resume the source's
|
|
621
|
+
# thread — e.g. a Codex thread id handed to Claude resumes nothing and the
|
|
622
|
+
# turn errors. (Native forking only happens within one engine.)
|
|
623
|
+
for key in ("provider", "permission_mode", "model", "effort"):
|
|
624
|
+
val = source_meta.get(key)
|
|
625
|
+
if val is not None:
|
|
626
|
+
meta[key] = val
|
|
627
|
+
store._write_meta(new_session["id"], meta)
|
|
628
|
+
|
|
629
|
+
logger.info(f"Created fork session {new_session['id']} from {session_id} (claude: {provider_session_id}, comment_thread={comment_thread})")
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
"id": new_session["id"],
|
|
633
|
+
"cwd": cwd,
|
|
634
|
+
"forked_from": session_id,
|
|
635
|
+
"provider_session_id": provider_session_id,
|
|
636
|
+
"isCommentThread": comment_thread,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
641
|
+
# Session Graduate (promote thread to session)
|
|
642
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
643
|
+
|
|
644
|
+
@router.post("/api/session/{session_id}/graduate")
|
|
645
|
+
async def graduate_session(session_id: str):
|
|
646
|
+
"""Graduate a discussion thread to a full session."""
|
|
647
|
+
store, meta = SessionStore._find_session(session_id)
|
|
648
|
+
if not store or not meta:
|
|
649
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
650
|
+
|
|
651
|
+
if not meta.get("isCommentThread"):
|
|
652
|
+
raise HTTPException(status_code=400, detail="Session is not a discussion thread")
|
|
653
|
+
|
|
654
|
+
if meta.get("graduated"):
|
|
655
|
+
return {
|
|
656
|
+
"id": session_id,
|
|
657
|
+
"graduated": True,
|
|
658
|
+
"graduated_at": meta.get("graduated_at"),
|
|
659
|
+
"already_graduated": True
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
graduated_at = datetime.now(timezone.utc).isoformat()
|
|
663
|
+
|
|
664
|
+
meta["graduated"] = True
|
|
665
|
+
meta["graduated_at"] = graduated_at
|
|
666
|
+
|
|
667
|
+
original_name = meta.get("name", "Thread")
|
|
668
|
+
if original_name.startswith("\U0001f4ac Thread"):
|
|
669
|
+
meta["name"] = original_name.replace("\U0001f4ac Thread", "\U0001f4ac\u2192 Discussion")
|
|
670
|
+
|
|
671
|
+
store._write_meta(session_id, meta)
|
|
672
|
+
|
|
673
|
+
logger.info(f"Graduated discussion thread {session_id} to full session")
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
"id": session_id,
|
|
677
|
+
"graduated": True,
|
|
678
|
+
"graduated_at": graduated_at,
|
|
679
|
+
"name": meta.get("name"),
|
|
680
|
+
"cwd": meta.get("cwd"),
|
|
681
|
+
"parent_session_id": meta.get("forked_from", {}).get("store_id")
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
686
|
+
# Session Threads
|
|
687
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
688
|
+
|
|
689
|
+
@router.get("/api/session/{session_id}/threads")
|
|
690
|
+
async def get_session_threads(session_id: str):
|
|
691
|
+
"""Get all comment threads for a parent session."""
|
|
692
|
+
all_sessions = SessionStore.list_all()
|
|
693
|
+
threads = []
|
|
694
|
+
|
|
695
|
+
for session in all_sessions:
|
|
696
|
+
if not session.get("isCommentThread"):
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
forked_from = session.get("forked_from")
|
|
700
|
+
if not forked_from:
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
parent_id = forked_from.get("store_id") if isinstance(forked_from, dict) else forked_from
|
|
704
|
+
if parent_id != session_id:
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
store, _ = SessionStore._find_session(session["id"])
|
|
708
|
+
messages = []
|
|
709
|
+
if store:
|
|
710
|
+
messages_path = store._messages_path(session["id"])
|
|
711
|
+
if messages_path.exists():
|
|
712
|
+
try:
|
|
713
|
+
with open(messages_path, "r", encoding="utf-8") as f:
|
|
714
|
+
for line in f:
|
|
715
|
+
line = line.strip()
|
|
716
|
+
if line:
|
|
717
|
+
try:
|
|
718
|
+
msg = json.loads(line)
|
|
719
|
+
if msg.get("role") in ("user", "assistant"):
|
|
720
|
+
messages.append({
|
|
721
|
+
"role": msg.get("role"),
|
|
722
|
+
"content": msg.get("content", ""),
|
|
723
|
+
"timestamp": msg.get("timestamp"),
|
|
724
|
+
})
|
|
725
|
+
except json.JSONDecodeError:
|
|
726
|
+
pass
|
|
727
|
+
except Exception as e:
|
|
728
|
+
logger.warning(f"Error reading thread messages: {e}")
|
|
729
|
+
|
|
730
|
+
threads.append({
|
|
731
|
+
"id": session["id"],
|
|
732
|
+
"anchor": session.get("threadAnchor"),
|
|
733
|
+
"status": "resolved" if session.get("resolved") else "active",
|
|
734
|
+
"messages": messages,
|
|
735
|
+
"total_cost": session.get("total_cost", 0),
|
|
736
|
+
"created_at": session.get("created_at"),
|
|
737
|
+
"last_activity": session.get("last_activity"),
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
threads.sort(key=lambda t: t.get("created_at", ""), reverse=True)
|
|
741
|
+
|
|
742
|
+
return {"threads": threads, "count": len(threads)}
|