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,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background Tasks API - Read output from Claude Code background tasks.
|
|
3
|
+
|
|
4
|
+
Provides endpoints for listing background tasks and streaming their output
|
|
5
|
+
via offset-based polling (like tail -f).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["tasks"])
|
|
19
|
+
|
|
20
|
+
# Match alphanumeric task IDs
|
|
21
|
+
TASK_ID_RE = re.compile(r'^[a-z0-9]+$')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cwd_to_slug(cwd: str) -> str:
|
|
25
|
+
"""Convert CWD path to Claude's tmp directory slug.
|
|
26
|
+
|
|
27
|
+
/home/user/dev/foo → -home-user-dev-foo
|
|
28
|
+
"""
|
|
29
|
+
return cwd.replace('/', '-')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_tasks_dir() -> Path | None:
|
|
33
|
+
"""Get the tasks directory for the current project.
|
|
34
|
+
|
|
35
|
+
Returns None on native Windows: os.getuid doesn't exist there and the
|
|
36
|
+
whole /tmp/claude-{uid} convention is Linux/macOS Claude CLI layout —
|
|
37
|
+
the endpoints degrade to an empty list / 404 instead of a 500.
|
|
38
|
+
"""
|
|
39
|
+
if not hasattr(os, "getuid"):
|
|
40
|
+
return None
|
|
41
|
+
from painapple_code.server import agents
|
|
42
|
+
cwd = (agents.default_cwd if agents else None) or os.getcwd()
|
|
43
|
+
uid = os.getuid()
|
|
44
|
+
slug = _cwd_to_slug(cwd)
|
|
45
|
+
# Claude Code uses dash-prefix slugs; also check tilde-prefix variant
|
|
46
|
+
tasks_dir = Path(f"/tmp/claude-{uid}/{slug}/tasks")
|
|
47
|
+
if not tasks_dir.exists():
|
|
48
|
+
tilde_slug = '~' + slug[1:] # -home-... → ~home-...
|
|
49
|
+
alt = Path(f"/tmp/claude-{uid}/{tilde_slug}/tasks")
|
|
50
|
+
if alt.exists():
|
|
51
|
+
return alt
|
|
52
|
+
return tasks_dir
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_running(mtime: float, threshold: float = 5.0) -> bool:
|
|
56
|
+
"""Heuristic: task is running if file was modified recently."""
|
|
57
|
+
return (time.time() - mtime) < threshold
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_last_lines(path: Path, n: int = 3) -> str:
|
|
61
|
+
"""Read last N lines from a file efficiently."""
|
|
62
|
+
try:
|
|
63
|
+
with open(path, 'rb') as f:
|
|
64
|
+
# Seek near end
|
|
65
|
+
f.seek(0, 2)
|
|
66
|
+
size = f.tell()
|
|
67
|
+
pos = max(0, size - 4096)
|
|
68
|
+
f.seek(pos)
|
|
69
|
+
chunk = f.read().decode('utf-8', errors='replace')
|
|
70
|
+
lines = chunk.strip().split('\n')
|
|
71
|
+
return '\n'.join(lines[-n:])
|
|
72
|
+
except Exception:
|
|
73
|
+
return ''
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/api/tasks")
|
|
77
|
+
async def list_tasks():
|
|
78
|
+
"""List all background tasks for the current project."""
|
|
79
|
+
tasks_dir = _get_tasks_dir()
|
|
80
|
+
|
|
81
|
+
if tasks_dir is None or not tasks_dir.exists():
|
|
82
|
+
return {"tasks": []}
|
|
83
|
+
|
|
84
|
+
tasks = []
|
|
85
|
+
for f in sorted(tasks_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
86
|
+
if not f.name.endswith('.output'):
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
task_id = f.stem
|
|
90
|
+
stat = f.stat()
|
|
91
|
+
running = _is_running(stat.st_mtime)
|
|
92
|
+
preview = _read_last_lines(f.resolve(), 2) if stat.st_size > 0 else ''
|
|
93
|
+
|
|
94
|
+
tasks.append({
|
|
95
|
+
"id": task_id,
|
|
96
|
+
"size": stat.st_size,
|
|
97
|
+
"modified": stat.st_mtime,
|
|
98
|
+
"is_running": running,
|
|
99
|
+
"preview": preview[:200],
|
|
100
|
+
})
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.warning(f"Error reading task {f.name}: {e}")
|
|
103
|
+
|
|
104
|
+
return {"tasks": tasks}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/api/tasks/{task_id}")
|
|
108
|
+
async def get_task_output(task_id: str, offset: int = 0):
|
|
109
|
+
"""Read task output with offset-based incremental polling."""
|
|
110
|
+
if not TASK_ID_RE.match(task_id):
|
|
111
|
+
raise HTTPException(400, "Invalid task ID format")
|
|
112
|
+
|
|
113
|
+
tasks_dir = _get_tasks_dir()
|
|
114
|
+
if tasks_dir is None:
|
|
115
|
+
raise HTTPException(404, "Background tasks not available on this platform")
|
|
116
|
+
file_path = tasks_dir / f"{task_id}.output"
|
|
117
|
+
|
|
118
|
+
if not file_path.exists():
|
|
119
|
+
raise HTTPException(404, "Task not found")
|
|
120
|
+
|
|
121
|
+
# Resolve symlinks but verify we stay in /tmp/claude-*
|
|
122
|
+
real_path = file_path.resolve()
|
|
123
|
+
uid = os.getuid()
|
|
124
|
+
if not str(real_path).startswith(f"/tmp/claude-{uid}/"):
|
|
125
|
+
raise HTTPException(403, "Path traversal denied")
|
|
126
|
+
|
|
127
|
+
stat = real_path.stat()
|
|
128
|
+
running = _is_running(stat.st_mtime)
|
|
129
|
+
|
|
130
|
+
content = ""
|
|
131
|
+
new_offset = offset
|
|
132
|
+
try:
|
|
133
|
+
with open(real_path, 'r', errors='replace', encoding="utf-8") as f:
|
|
134
|
+
if offset > 0:
|
|
135
|
+
f.seek(min(offset, stat.st_size))
|
|
136
|
+
content = f.read()
|
|
137
|
+
new_offset = f.tell()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise HTTPException(500, f"Error reading task output: {e}")
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"id": task_id,
|
|
143
|
+
"content": content,
|
|
144
|
+
"offset": new_offset,
|
|
145
|
+
"size": stat.st_size,
|
|
146
|
+
"is_running": running,
|
|
147
|
+
}
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal API Routes - PTY terminal WebSocket and management
|
|
3
|
+
|
|
4
|
+
These endpoints provide:
|
|
5
|
+
- Interactive PTY terminal via WebSocket
|
|
6
|
+
- Terminal session management (list, kill)
|
|
7
|
+
- Active Claude session listing
|
|
8
|
+
- Claude subprocess instance tracking
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from painapple_code.utils.proc import pid_alive
|
|
18
|
+
from painapple_code.utils.pty_backend import spawn_pty
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
|
|
23
|
+
|
|
24
|
+
from painapple_code.auth_middleware import (
|
|
25
|
+
_client_identity,
|
|
26
|
+
check_websocket_auth,
|
|
27
|
+
check_websocket_origin,
|
|
28
|
+
record_auth_identity,
|
|
29
|
+
)
|
|
30
|
+
from painapple_code.session_store import SessionStore
|
|
31
|
+
from painapple_code.subprocess_registry import agent_subprocesses
|
|
32
|
+
from painapple_code.utils.file_paths import safe_resolve
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
router = APIRouter(tags=["terminal"])
|
|
37
|
+
|
|
38
|
+
# Store active terminal sessions (keyed by session_id)
|
|
39
|
+
terminal_sessions: dict[str, dict] = {}
|
|
40
|
+
|
|
41
|
+
# Maximum scrollback buffer size per terminal (in bytes)
|
|
42
|
+
TERMINAL_SCROLLBACK_SIZE = 100 * 1024 # 100KB
|
|
43
|
+
|
|
44
|
+
# OSC 52 is the clipboard-write sequence (ESC ] 52 ; Ps ; Pt ST) that lets a
|
|
45
|
+
# program inside the terminal — vim's `y`, tmux — put text on the user's
|
|
46
|
+
# system clipboard. It is passed through untouched on the LIVE stream; that
|
|
47
|
+
# is the feature (see static/js/widgets/terminal/osc52.js).
|
|
48
|
+
#
|
|
49
|
+
# It must never survive into a scrollback REPLAY. The ring below is re-sent
|
|
50
|
+
# verbatim on every (re)connect, and iPad PWAs reconnect constantly after
|
|
51
|
+
# suspend — so without this, a yank from an hour ago would silently re-hijack
|
|
52
|
+
# the clipboard every time the tab woke up.
|
|
53
|
+
#
|
|
54
|
+
# Both terminators are matched (BEL and ESC \). The payload is base64 plus
|
|
55
|
+
# ';' plus Ps, so it can contain neither, which also keeps this bounded.
|
|
56
|
+
_OSC52_RE = re.compile(rb'\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.websocket("/ws/terminal")
|
|
60
|
+
async def terminal_websocket(websocket: WebSocket, session: str = None, cwd: str = None):
|
|
61
|
+
"""
|
|
62
|
+
WebSocket endpoint for interactive terminal (PTY).
|
|
63
|
+
|
|
64
|
+
Each session gets its own persistent terminal that survives disconnects.
|
|
65
|
+
"""
|
|
66
|
+
await websocket.accept()
|
|
67
|
+
|
|
68
|
+
state = websocket.app.state
|
|
69
|
+
if not check_websocket_origin(websocket, getattr(state, "allowed_origins", None)):
|
|
70
|
+
await websocket.close(code=1008, reason="forbidden origin")
|
|
71
|
+
return
|
|
72
|
+
cookie_token = getattr(state, "auth_cookie_token", None)
|
|
73
|
+
api_token = getattr(state, "auth_api_token", None)
|
|
74
|
+
if not cookie_token or not api_token or not check_websocket_auth(websocket, cookie_token, api_token):
|
|
75
|
+
await websocket.close(code=1008, reason="unauthorized")
|
|
76
|
+
return
|
|
77
|
+
record_auth_identity("ws", *_client_identity(websocket.scope))
|
|
78
|
+
|
|
79
|
+
session_id = session or "default"
|
|
80
|
+
|
|
81
|
+
# Get CWD from session store if available
|
|
82
|
+
resolved_cwd = None
|
|
83
|
+
if session:
|
|
84
|
+
store_data = SessionStore.load(session)
|
|
85
|
+
if store_data:
|
|
86
|
+
resolved_cwd = store_data.get("cwd")
|
|
87
|
+
|
|
88
|
+
if not resolved_cwd and cwd:
|
|
89
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
90
|
+
|
|
91
|
+
if not resolved_cwd:
|
|
92
|
+
# Fall back to the server's --workspace (stashed on app.state by main()).
|
|
93
|
+
# `from server import agents` looks at a different module instance than
|
|
94
|
+
# the running __main__, so `agents` is None there — Path.home() inside
|
|
95
|
+
# Docker is /home/app which is not the project base the user wants.
|
|
96
|
+
ws = getattr(state, "workspace", None)
|
|
97
|
+
if ws:
|
|
98
|
+
try:
|
|
99
|
+
resolved_cwd = str(safe_resolve(ws))
|
|
100
|
+
except Exception:
|
|
101
|
+
resolved_cwd = ws
|
|
102
|
+
else:
|
|
103
|
+
resolved_cwd = str(Path.home())
|
|
104
|
+
|
|
105
|
+
logger.info(f"Terminal WebSocket connected: session={session_id}, cwd={resolved_cwd}")
|
|
106
|
+
|
|
107
|
+
# Check if we have an existing terminal session
|
|
108
|
+
term_session = terminal_sessions.get(session_id)
|
|
109
|
+
|
|
110
|
+
if term_session:
|
|
111
|
+
term = term_session["pty"]
|
|
112
|
+
pid = term.pid
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if not pid_alive(pid):
|
|
116
|
+
raise ProcessLookupError(f"terminal pid {pid} gone")
|
|
117
|
+
was_inactive = not term_session.get("active")
|
|
118
|
+
logger.info(f"Reconnecting to existing terminal: pid={pid}, was_inactive={was_inactive}")
|
|
119
|
+
|
|
120
|
+
old_read_task = term_session.get("read_task")
|
|
121
|
+
old_heartbeat_task = term_session.get("heartbeat_task")
|
|
122
|
+
if old_read_task and not old_read_task.done():
|
|
123
|
+
old_read_task.cancel()
|
|
124
|
+
try:
|
|
125
|
+
await old_read_task
|
|
126
|
+
except asyncio.CancelledError:
|
|
127
|
+
pass
|
|
128
|
+
if old_heartbeat_task and not old_heartbeat_task.done():
|
|
129
|
+
old_heartbeat_task.cancel()
|
|
130
|
+
try:
|
|
131
|
+
await old_heartbeat_task
|
|
132
|
+
except asyncio.CancelledError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
term_session["active"] = True
|
|
136
|
+
except OSError:
|
|
137
|
+
logger.info(f"Terminal process {pid} died, creating new one")
|
|
138
|
+
term.close()
|
|
139
|
+
del terminal_sessions[session_id]
|
|
140
|
+
term_session = None
|
|
141
|
+
|
|
142
|
+
if not term_session:
|
|
143
|
+
term = spawn_pty(resolved_cwd, rows=24, cols=80)
|
|
144
|
+
|
|
145
|
+
term_session = {
|
|
146
|
+
"pty": term,
|
|
147
|
+
"cwd": resolved_cwd,
|
|
148
|
+
"active": True,
|
|
149
|
+
"scrollback": bytearray(),
|
|
150
|
+
}
|
|
151
|
+
terminal_sessions[session_id] = term_session
|
|
152
|
+
|
|
153
|
+
logger.info(f"Created new terminal: pid={term.pid}, cwd={resolved_cwd}")
|
|
154
|
+
|
|
155
|
+
term = term_session["pty"]
|
|
156
|
+
pid = term.pid
|
|
157
|
+
scrollback = term_session.get("scrollback", bytearray())
|
|
158
|
+
|
|
159
|
+
await websocket.send_json({
|
|
160
|
+
"type": "connected",
|
|
161
|
+
"session": session_id,
|
|
162
|
+
"cwd": resolved_cwd,
|
|
163
|
+
"home": str(Path.home()),
|
|
164
|
+
"pid": pid,
|
|
165
|
+
"has_scrollback": len(scrollback) > 0,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if scrollback:
|
|
169
|
+
try:
|
|
170
|
+
replay = _OSC52_RE.sub(b'', bytes(scrollback))
|
|
171
|
+
await websocket.send_text(replay.decode('utf-8', errors='replace'))
|
|
172
|
+
logger.info(f"Replayed {len(replay)} bytes of scrollback for session {session_id}")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error(f"Failed to replay scrollback: {e}")
|
|
175
|
+
|
|
176
|
+
async def read_pty():
|
|
177
|
+
loop = asyncio.get_event_loop()
|
|
178
|
+
exit_code = None
|
|
179
|
+
ws_disconnected = False
|
|
180
|
+
try:
|
|
181
|
+
while True:
|
|
182
|
+
# Reap first: a shell that exits without closing the pty
|
|
183
|
+
# (or was killed) is noticed here rather than hanging the
|
|
184
|
+
# loop on a read that will never return.
|
|
185
|
+
exit_code = term.poll()
|
|
186
|
+
if exit_code is not None:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
# Backend contract: None = nothing yet, b"" = pty closed.
|
|
190
|
+
# The blocking wait (select on POSIX, queue on Windows)
|
|
191
|
+
# runs off-loop so the event loop stays responsive.
|
|
192
|
+
data = await loop.run_in_executor(None, term.read, 0.1)
|
|
193
|
+
if data is None:
|
|
194
|
+
continue
|
|
195
|
+
if data == b"":
|
|
196
|
+
# Give the child a moment to be reapable, then take
|
|
197
|
+
# whatever poll() reports (0 if it was already reaped).
|
|
198
|
+
exit_code = term.poll()
|
|
199
|
+
if exit_code is None:
|
|
200
|
+
await asyncio.sleep(0.05)
|
|
201
|
+
exit_code = term.poll()
|
|
202
|
+
if exit_code is None:
|
|
203
|
+
exit_code = -1
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
scrollback.extend(data)
|
|
207
|
+
if len(scrollback) > TERMINAL_SCROLLBACK_SIZE:
|
|
208
|
+
scrollback[:] = scrollback[-TERMINAL_SCROLLBACK_SIZE:]
|
|
209
|
+
try:
|
|
210
|
+
await websocket.send_text(data.decode('utf-8', errors='replace'))
|
|
211
|
+
except Exception as ws_err:
|
|
212
|
+
logger.info(f"Terminal WebSocket disconnected (session={session_id}): {ws_err}")
|
|
213
|
+
ws_disconnected = True
|
|
214
|
+
break
|
|
215
|
+
except asyncio.CancelledError:
|
|
216
|
+
pass
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"PTY read error: {e}")
|
|
219
|
+
ws_disconnected = True
|
|
220
|
+
|
|
221
|
+
if exit_code is not None:
|
|
222
|
+
try:
|
|
223
|
+
await websocket.send_json({"type": "exit", "code": exit_code})
|
|
224
|
+
logger.info(f"Terminal exited: session={session_id}, code={exit_code}")
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
if session_id in terminal_sessions:
|
|
228
|
+
term_session["active"] = False
|
|
229
|
+
term.close()
|
|
230
|
+
del terminal_sessions[session_id]
|
|
231
|
+
elif ws_disconnected:
|
|
232
|
+
logger.info(f"Terminal WebSocket gone, PTY kept alive for reconnect: session={session_id}, pid={pid}")
|
|
233
|
+
if session_id in terminal_sessions:
|
|
234
|
+
term_session["active"] = False
|
|
235
|
+
|
|
236
|
+
read_task = asyncio.create_task(read_pty())
|
|
237
|
+
|
|
238
|
+
async def heartbeat():
|
|
239
|
+
# 5s cadence so iOS half-dead WebSockets are detected within ~5s
|
|
240
|
+
# on resume, instead of the previous 30s window where the terminal
|
|
241
|
+
# appeared frozen until the next ping failed.
|
|
242
|
+
try:
|
|
243
|
+
while True:
|
|
244
|
+
await asyncio.sleep(5)
|
|
245
|
+
try:
|
|
246
|
+
await websocket.send_json({"type": "heartbeat"})
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.info(f"Terminal heartbeat failed, WebSocket dead: session={session_id}")
|
|
249
|
+
break
|
|
250
|
+
except asyncio.CancelledError:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
heartbeat_task = asyncio.create_task(heartbeat())
|
|
254
|
+
|
|
255
|
+
term_session["read_task"] = read_task
|
|
256
|
+
term_session["heartbeat_task"] = heartbeat_task
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
while True:
|
|
260
|
+
data = await websocket.receive()
|
|
261
|
+
|
|
262
|
+
if data["type"] == "websocket.disconnect":
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
if data["type"] == "websocket.receive":
|
|
266
|
+
if "text" in data:
|
|
267
|
+
text = data["text"]
|
|
268
|
+
|
|
269
|
+
if text.startswith("{"):
|
|
270
|
+
try:
|
|
271
|
+
msg = json.loads(text)
|
|
272
|
+
msg_type = msg.get("type")
|
|
273
|
+
|
|
274
|
+
if msg_type == "resize":
|
|
275
|
+
rows = msg.get("rows", 24)
|
|
276
|
+
cols = msg.get("cols", 80)
|
|
277
|
+
try:
|
|
278
|
+
term.set_size(rows, cols)
|
|
279
|
+
except (OSError, ValueError) as e:
|
|
280
|
+
logger.debug(f"Terminal resize failed: {e}")
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if msg_type == "ping":
|
|
284
|
+
await websocket.send_json({"type": "pong"})
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
except json.JSONDecodeError:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
term.write(text.encode('utf-8'))
|
|
292
|
+
except EOFError:
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
elif "bytes" in data:
|
|
296
|
+
try:
|
|
297
|
+
term.write(data["bytes"])
|
|
298
|
+
except (EOFError, OSError):
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
except WebSocketDisconnect:
|
|
302
|
+
logger.info(f"Terminal WebSocket disconnected: session={session_id}")
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.error(f"Terminal WebSocket error: {e}")
|
|
305
|
+
finally:
|
|
306
|
+
read_task.cancel()
|
|
307
|
+
heartbeat_task.cancel()
|
|
308
|
+
try:
|
|
309
|
+
await read_task
|
|
310
|
+
except asyncio.CancelledError:
|
|
311
|
+
pass
|
|
312
|
+
try:
|
|
313
|
+
await heartbeat_task
|
|
314
|
+
except asyncio.CancelledError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
logger.info(f"Terminal WebSocket closed, process {pid} still running")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.delete("/api/terminal/{session_id:path}")
|
|
321
|
+
async def kill_terminal(session_id: str):
|
|
322
|
+
"""Kill a terminal session."""
|
|
323
|
+
term_session = terminal_sessions.get(session_id)
|
|
324
|
+
if not term_session:
|
|
325
|
+
raise HTTPException(status_code=404, detail="Terminal session not found")
|
|
326
|
+
|
|
327
|
+
term = term_session.get("pty")
|
|
328
|
+
|
|
329
|
+
if term:
|
|
330
|
+
term.terminate()
|
|
331
|
+
await asyncio.sleep(0.5)
|
|
332
|
+
term.terminate(force=True)
|
|
333
|
+
term.close()
|
|
334
|
+
|
|
335
|
+
del terminal_sessions[session_id]
|
|
336
|
+
logger.info(f"Killed terminal session: {session_id}")
|
|
337
|
+
|
|
338
|
+
return {"killed": True, "session": session_id}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@router.get("/api/terminal-cwd")
|
|
342
|
+
async def get_terminal_cwd(session: str):
|
|
343
|
+
"""Live working directory of a terminal's shell process.
|
|
344
|
+
|
|
345
|
+
Asks the backend where the shell process actually is, so the result
|
|
346
|
+
tracks the user's `cd`s in real time; falls back to the spawn cwd
|
|
347
|
+
where that isn't available (macOS has no /proc, the shell exited,
|
|
348
|
+
or — on Windows — PowerShell's `cd` moves only its provider location
|
|
349
|
+
and never the process CWD). Query param instead of a path param
|
|
350
|
+
because terminal session IDs contain slashes
|
|
351
|
+
(`session:<id>:/path/to/cwd`).
|
|
352
|
+
"""
|
|
353
|
+
term_session = terminal_sessions.get(session)
|
|
354
|
+
if not term_session:
|
|
355
|
+
raise HTTPException(status_code=404, detail="Terminal session not found")
|
|
356
|
+
|
|
357
|
+
term = term_session.get("pty")
|
|
358
|
+
live_cwd = term.live_cwd() if term else None
|
|
359
|
+
|
|
360
|
+
return {"cwd": live_cwd or term_session.get("cwd"), "live": bool(live_cwd)}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.get("/api/terminals")
|
|
364
|
+
async def list_terminals():
|
|
365
|
+
"""List active terminal sessions."""
|
|
366
|
+
result = []
|
|
367
|
+
for session_id, term_session in terminal_sessions.items():
|
|
368
|
+
term = term_session.get("pty")
|
|
369
|
+
pid = term.pid if term else None
|
|
370
|
+
alive = pid_alive(pid) if pid else False
|
|
371
|
+
|
|
372
|
+
result.append({
|
|
373
|
+
"session": session_id,
|
|
374
|
+
"pid": pid,
|
|
375
|
+
"cwd": term_session.get("cwd"),
|
|
376
|
+
"alive": alive,
|
|
377
|
+
})
|
|
378
|
+
return {"terminals": result}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
382
|
+
# Active Sessions
|
|
383
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
384
|
+
|
|
385
|
+
@router.get("/api/active-sessions")
|
|
386
|
+
async def list_active_sessions():
|
|
387
|
+
"""List all currently running Claude sessions with real-time status.
|
|
388
|
+
|
|
389
|
+
Also includes recent stored sessions (not yet connected to this server
|
|
390
|
+
instance) so the widget isn't empty after a server restart.
|
|
391
|
+
"""
|
|
392
|
+
from painapple_code.server import agents
|
|
393
|
+
|
|
394
|
+
result = []
|
|
395
|
+
now = time.time()
|
|
396
|
+
live_store_ids = set()
|
|
397
|
+
|
|
398
|
+
if not agents:
|
|
399
|
+
agent_sessions = {}
|
|
400
|
+
else:
|
|
401
|
+
agent_sessions = agents.sessions
|
|
402
|
+
|
|
403
|
+
for store_id, session in agent_sessions.items():
|
|
404
|
+
live_store_ids.add(store_id)
|
|
405
|
+
|
|
406
|
+
# Validate process state
|
|
407
|
+
if session.is_running:
|
|
408
|
+
process_alive = bool(session.process) and pid_alive(session.process.pid)
|
|
409
|
+
|
|
410
|
+
if not process_alive:
|
|
411
|
+
logger.warning(
|
|
412
|
+
f"Session {store_id} has stale is_running=True but process is dead, fixing"
|
|
413
|
+
)
|
|
414
|
+
session.is_running = False
|
|
415
|
+
session.process = None
|
|
416
|
+
|
|
417
|
+
session_info = {
|
|
418
|
+
"store_id": store_id,
|
|
419
|
+
"cwd": session.cwd,
|
|
420
|
+
"is_running": session.is_running,
|
|
421
|
+
"is_idle": session.is_idle,
|
|
422
|
+
"has_websocket": session.ws_connected,
|
|
423
|
+
"created_at": datetime.fromtimestamp(session.created_at).isoformat() + "Z",
|
|
424
|
+
"last_activity": datetime.fromtimestamp(session.last_activity).isoformat() + "Z",
|
|
425
|
+
"idle_seconds": int(now - session.last_activity),
|
|
426
|
+
"turn_number": session.turn_number,
|
|
427
|
+
"process_pid": None,
|
|
428
|
+
"provider_session_id": session.session_id,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if session.process and session.is_running:
|
|
432
|
+
session_info["process_pid"] = session.process.pid
|
|
433
|
+
|
|
434
|
+
if session.is_running:
|
|
435
|
+
if session.is_idle:
|
|
436
|
+
session_info["status"] = "idle"
|
|
437
|
+
elif session.in_thinking_mode:
|
|
438
|
+
session_info["status"] = "thinking"
|
|
439
|
+
elif session.active_tasks:
|
|
440
|
+
task_names = [t.description for t in session.active_tasks.values() if t.description]
|
|
441
|
+
session_info["status"] = "tasks"
|
|
442
|
+
session_info["active_tasks"] = task_names[:3]
|
|
443
|
+
elif session.turn_tracker and session.turn_tracker.has_activity:
|
|
444
|
+
session_info["status"] = "tools"
|
|
445
|
+
else:
|
|
446
|
+
session_info["status"] = "streaming"
|
|
447
|
+
elif session.is_compacting:
|
|
448
|
+
session_info["status"] = "compacting"
|
|
449
|
+
else:
|
|
450
|
+
session_info["status"] = "idle"
|
|
451
|
+
|
|
452
|
+
if session.turn_tracker:
|
|
453
|
+
tracker = session.turn_tracker
|
|
454
|
+
session_info["current_turn"] = {
|
|
455
|
+
"has_activity": tracker.has_activity,
|
|
456
|
+
"tool_count": len(tracker.tools_used),
|
|
457
|
+
"tools_summary": tracker.get_tools_summary(),
|
|
458
|
+
"modified_files": len(tracker.modified_files),
|
|
459
|
+
"prompt_preview": tracker.user_prompt[:80] if tracker.user_prompt else None,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
meta = SessionStore.load_meta(store_id)
|
|
463
|
+
if meta:
|
|
464
|
+
session_info["name"] = meta.get("name")
|
|
465
|
+
session_info["model"] = meta.get("model")
|
|
466
|
+
session_info["total_cost"] = meta.get("total_cost", 0)
|
|
467
|
+
session_info["message_count"] = meta.get("message_count", 0)
|
|
468
|
+
|
|
469
|
+
result.append(session_info)
|
|
470
|
+
|
|
471
|
+
# Supplement with recent stored sessions not in agents.sessions.
|
|
472
|
+
# This makes the widget useful after server restarts, showing recent
|
|
473
|
+
# sessions as "disconnected" until the browser reconnects.
|
|
474
|
+
try:
|
|
475
|
+
all_stored = SessionStore.list_all()
|
|
476
|
+
for stored in all_stored[:20]: # Check top 20 most recent
|
|
477
|
+
sid = stored.get("id", "")
|
|
478
|
+
if sid in live_store_ids:
|
|
479
|
+
continue
|
|
480
|
+
# Only include sessions active in last 24h
|
|
481
|
+
last_act = stored.get("last_activity", "")
|
|
482
|
+
if not last_act:
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
from datetime import timezone
|
|
486
|
+
act_dt = datetime.fromisoformat(last_act.replace("Z", "+00:00"))
|
|
487
|
+
age_seconds = (datetime.now(timezone.utc) - act_dt).total_seconds()
|
|
488
|
+
if age_seconds > 86400: # Skip sessions older than 24h
|
|
489
|
+
continue
|
|
490
|
+
except (ValueError, TypeError):
|
|
491
|
+
continue
|
|
492
|
+
|
|
493
|
+
result.append({
|
|
494
|
+
"store_id": sid,
|
|
495
|
+
"cwd": stored.get("cwd", ""),
|
|
496
|
+
"is_running": False,
|
|
497
|
+
"is_idle": True,
|
|
498
|
+
"has_websocket": False,
|
|
499
|
+
"created_at": stored.get("created_at", ""),
|
|
500
|
+
"last_activity": last_act,
|
|
501
|
+
"idle_seconds": int(age_seconds),
|
|
502
|
+
"turn_number": 0,
|
|
503
|
+
"process_pid": None,
|
|
504
|
+
"provider_session_id": stored.get("provider_session_id"),
|
|
505
|
+
"status": "disconnected",
|
|
506
|
+
"name": stored.get("name"),
|
|
507
|
+
"model": stored.get("model"),
|
|
508
|
+
"total_cost": stored.get("total_cost", 0),
|
|
509
|
+
"message_count": stored.get("message_count", 0),
|
|
510
|
+
})
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.debug(f"Failed to load stored sessions for active-sessions: {e}")
|
|
513
|
+
|
|
514
|
+
result.sort(key=lambda s: (
|
|
515
|
+
0 if s["is_running"] else (1 if s["status"] != "disconnected" else 2),
|
|
516
|
+
# For live sessions: most recently active first (-idle → ascending)
|
|
517
|
+
# For disconnected/stored: same (most recent first)
|
|
518
|
+
s.get("idle_seconds", 0)
|
|
519
|
+
))
|
|
520
|
+
|
|
521
|
+
running_count = sum(1 for s in result if s["is_running"] and not s.get("is_idle", True))
|
|
522
|
+
|
|
523
|
+
agent_subprocesses.prune_dead_processes()
|
|
524
|
+
subprocess_data = agent_subprocesses.to_api_response()
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
"sessions": result,
|
|
528
|
+
"count": len(result),
|
|
529
|
+
"running_count": running_count,
|
|
530
|
+
"agent_instances": subprocess_data["instances"],
|
|
531
|
+
"agent_instances_count": subprocess_data["count"],
|
|
532
|
+
"agent_instances_by_type": subprocess_data["by_type"],
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@router.get("/api/agent-instances")
|
|
537
|
+
async def get_agent_instances():
|
|
538
|
+
"""Get full Claude subprocess data including history and statistics."""
|
|
539
|
+
agent_subprocesses.prune_dead_processes()
|
|
540
|
+
return agent_subprocesses.to_full_api_response()
|