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,1296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shadow Git - File recovery & session history using a separate git repository.
|
|
3
|
+
|
|
4
|
+
Tracks all file changes and tool usage during Claude sessions with turn-by-turn
|
|
5
|
+
recovery. Each turn creates a commit on a shadow branch that mirrors the project's
|
|
6
|
+
current git branch (e.g., project's `main` -> shadow's `shadow/main`).
|
|
7
|
+
|
|
8
|
+
Storage: ~/.painapple-code/projects/{hash}/shadow-git/
|
|
9
|
+
Branches: shadow/main, shadow/feature-x, etc. (mirrors project branches)
|
|
10
|
+
|
|
11
|
+
Key features:
|
|
12
|
+
- Per-turn commits with tool tracking
|
|
13
|
+
- Co-modification tracking (multiple sessions editing same files)
|
|
14
|
+
- Rich commit messages via summary fork (optional)
|
|
15
|
+
- Session archiving via annotated tags
|
|
16
|
+
- Undo/redo support
|
|
17
|
+
|
|
18
|
+
This file holds the public `ShadowGit` class plus the singleton accessor and
|
|
19
|
+
`get_commit_sections_for_api`. The big subsystems live in sibling modules:
|
|
20
|
+
- `shadow_git_sections` — `BUILTIN_SECTIONS`, schema/prompt/markdown helpers
|
|
21
|
+
- `shadow_git_frontmatter` — `CostInfo`/`SummaryCost`, YAML parser/formatter, extractors
|
|
22
|
+
- `shadow_git_summary` — `_SummaryMixin` with the rich-commit fork machinery
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from painapple_code.paths import (
|
|
34
|
+
get_shadow_git_dir,
|
|
35
|
+
get_project_dir,
|
|
36
|
+
get_sessions_dir,
|
|
37
|
+
ensure_project_dir,
|
|
38
|
+
load_project_config,
|
|
39
|
+
get_summary_model,
|
|
40
|
+
)
|
|
41
|
+
from painapple_code.turn_tracker import TurnTracker
|
|
42
|
+
from painapple_code.utils.file_lock import FileLock
|
|
43
|
+
|
|
44
|
+
# Public re-exports — kept under shadow_git.* so downstream importers
|
|
45
|
+
# (services/, routes/, shadow_parser.py, welcome_search.py) don't change.
|
|
46
|
+
from painapple_code.shadow_git_frontmatter import (
|
|
47
|
+
CostInfo,
|
|
48
|
+
SummaryCost,
|
|
49
|
+
YAML_FRONTMATTER_PATTERN,
|
|
50
|
+
extract_session_title,
|
|
51
|
+
extract_summary_line,
|
|
52
|
+
format_yaml_frontmatter,
|
|
53
|
+
parse_yaml_frontmatter,
|
|
54
|
+
)
|
|
55
|
+
from painapple_code.shadow_git_sections import (
|
|
56
|
+
BUILTIN_SECTIONS,
|
|
57
|
+
COMMIT_PROMPT_COMPACTION,
|
|
58
|
+
get_sections_config,
|
|
59
|
+
)
|
|
60
|
+
from painapple_code.shadow_git_summary import _SummaryMixin
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"ShadowGit",
|
|
64
|
+
"get_shadow_git",
|
|
65
|
+
"get_commit_sections_for_api",
|
|
66
|
+
"BUILTIN_SECTIONS",
|
|
67
|
+
"CostInfo",
|
|
68
|
+
"SummaryCost",
|
|
69
|
+
"parse_yaml_frontmatter",
|
|
70
|
+
"YAML_FRONTMATTER_PATTERN",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger("painapple-code.shadow-git")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
77
|
+
# Default exclude patterns
|
|
78
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
79
|
+
|
|
80
|
+
DEFAULT_EXCLUDES = """
|
|
81
|
+
# Dependencies
|
|
82
|
+
node_modules/
|
|
83
|
+
.venv/
|
|
84
|
+
venv/
|
|
85
|
+
__pycache__/
|
|
86
|
+
.npm/
|
|
87
|
+
.cache/
|
|
88
|
+
|
|
89
|
+
# Build outputs
|
|
90
|
+
dist/
|
|
91
|
+
build/
|
|
92
|
+
out/
|
|
93
|
+
*.egg-info/
|
|
94
|
+
|
|
95
|
+
# Environment
|
|
96
|
+
.env
|
|
97
|
+
.env.*
|
|
98
|
+
*.local
|
|
99
|
+
|
|
100
|
+
# Logs
|
|
101
|
+
*.log
|
|
102
|
+
logs/
|
|
103
|
+
|
|
104
|
+
# IDE
|
|
105
|
+
.idea/
|
|
106
|
+
.vscode/
|
|
107
|
+
*.swp
|
|
108
|
+
*.swo
|
|
109
|
+
|
|
110
|
+
# Git
|
|
111
|
+
.git/
|
|
112
|
+
|
|
113
|
+
# OS
|
|
114
|
+
.DS_Store
|
|
115
|
+
Thumbs.db
|
|
116
|
+
|
|
117
|
+
# VM / disk images and other huge binary artifacts
|
|
118
|
+
*.img
|
|
119
|
+
*.qcow2
|
|
120
|
+
*.vmdk
|
|
121
|
+
*.vdi
|
|
122
|
+
*.vhd
|
|
123
|
+
*.vhdx
|
|
124
|
+
*.iso
|
|
125
|
+
*.fd
|
|
126
|
+
*.dmg
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Files larger than this are never committed to shadow git. Oversized files
|
|
130
|
+
# are unstaged after `git add -A` and appended to info/exclude (quarantined).
|
|
131
|
+
# Override per project via config: shadow_git.max_file_size_mb (0 disables).
|
|
132
|
+
DEFAULT_MAX_FILE_SIZE_MB = 50
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _escape_exclude_pattern(path: str) -> str:
|
|
136
|
+
"""Escape glob metacharacters so a literal path is safe as a gitignore pattern."""
|
|
137
|
+
return "".join("\\" + ch if ch in "\\*?[]!#" else ch for ch in path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ShadowGit(_SummaryMixin):
|
|
141
|
+
"""
|
|
142
|
+
Manages shadow git repository for a project.
|
|
143
|
+
|
|
144
|
+
Usage:
|
|
145
|
+
shadow = ShadowGit("/path/to/project")
|
|
146
|
+
await shadow.init_repo()
|
|
147
|
+
await shadow.commit_turn(session_id, turn_num, tracker, cost_info)
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, project_path: str):
|
|
151
|
+
"""
|
|
152
|
+
Initialize ShadowGit for a project.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
project_path: Path to the project directory (the working tree)
|
|
156
|
+
"""
|
|
157
|
+
self.project_path = Path(project_path).resolve()
|
|
158
|
+
self.project_dir = get_project_dir(str(self.project_path))
|
|
159
|
+
self.git_dir = get_shadow_git_dir(str(self.project_path))
|
|
160
|
+
self.tracking_file = self.project_dir / "active-modifications.json"
|
|
161
|
+
self.config = load_project_config(str(self.project_path))
|
|
162
|
+
|
|
163
|
+
def reload_config(self):
|
|
164
|
+
"""
|
|
165
|
+
Reload configuration from disk.
|
|
166
|
+
|
|
167
|
+
Call this before operations to pick up config changes made via UI.
|
|
168
|
+
"""
|
|
169
|
+
self.config = load_project_config(str(self.project_path))
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_enabled(self) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Check if shadow git is enabled for this project.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
True if enabled (default), False if explicitly disabled
|
|
178
|
+
"""
|
|
179
|
+
return self.config.get("shadow_git", {}).get("enabled", True)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_rich_commits_enabled(self) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Check if rich commits (summary fork) are enabled for this project.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if rich commits enabled, False otherwise
|
|
188
|
+
"""
|
|
189
|
+
return self.config.get("shadow_git", {}).get("rich_commits", False)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def max_file_size_mb(self) -> float:
|
|
193
|
+
"""
|
|
194
|
+
Size cap for files committed to shadow git, in MB.
|
|
195
|
+
|
|
196
|
+
Files larger than this are quarantined (unstaged + excluded).
|
|
197
|
+
0 or negative disables the cap.
|
|
198
|
+
"""
|
|
199
|
+
return self.config.get("shadow_git", {}).get(
|
|
200
|
+
"max_file_size_mb", DEFAULT_MAX_FILE_SIZE_MB
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _ensure_excludes(self):
|
|
204
|
+
"""
|
|
205
|
+
Sync info/exclude with the current DEFAULT_EXCLUDES.
|
|
206
|
+
|
|
207
|
+
The exclude file is otherwise only written at init time, so shipped
|
|
208
|
+
updates to DEFAULT_EXCLUDES would never reach existing repos. Rewrite
|
|
209
|
+
the default block while preserving extra lines (manual additions and
|
|
210
|
+
size-cap quarantine entries).
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
exclude_file = self.git_dir / "info" / "exclude"
|
|
214
|
+
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
existing = exclude_file.read_text(encoding="utf-8") if exclude_file.exists() else ""
|
|
216
|
+
default_lines = set(DEFAULT_EXCLUDES.splitlines())
|
|
217
|
+
extras = [
|
|
218
|
+
line for line in existing.splitlines()
|
|
219
|
+
if line.strip()
|
|
220
|
+
and not line.lstrip().startswith("#")
|
|
221
|
+
and line not in default_lines
|
|
222
|
+
]
|
|
223
|
+
content = DEFAULT_EXCLUDES
|
|
224
|
+
if extras:
|
|
225
|
+
content += "\n# Auto-excluded / project-specific\n" + "\n".join(extras) + "\n"
|
|
226
|
+
if content != existing:
|
|
227
|
+
exclude_file.write_text(content, encoding="utf-8")
|
|
228
|
+
except OSError as e:
|
|
229
|
+
logger.warning(f"Failed to sync shadow git excludes: {e}")
|
|
230
|
+
|
|
231
|
+
def _add_excludes(self, paths: list[str], reason: str = ""):
|
|
232
|
+
"""Append literal path excludes to info/exclude (skips already-present)."""
|
|
233
|
+
try:
|
|
234
|
+
exclude_file = self.git_dir / "info" / "exclude"
|
|
235
|
+
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
existing = exclude_file.read_text(encoding="utf-8") if exclude_file.exists() else ""
|
|
237
|
+
existing_lines = set(existing.splitlines())
|
|
238
|
+
new_lines = [
|
|
239
|
+
pattern for pattern in
|
|
240
|
+
("/" + _escape_exclude_pattern(p) for p in paths)
|
|
241
|
+
if pattern not in existing_lines
|
|
242
|
+
]
|
|
243
|
+
if not new_lines:
|
|
244
|
+
return
|
|
245
|
+
block = "" if (not existing or existing.endswith("\n")) else "\n"
|
|
246
|
+
if reason:
|
|
247
|
+
block += f"# Auto-excluded ({reason})\n"
|
|
248
|
+
block += "\n".join(new_lines) + "\n"
|
|
249
|
+
exclude_file.write_text(existing + block, encoding="utf-8")
|
|
250
|
+
except OSError as e:
|
|
251
|
+
logger.warning(f"Failed to update shadow git excludes: {e}")
|
|
252
|
+
|
|
253
|
+
async def _quarantine_oversized(self) -> list[str]:
|
|
254
|
+
"""
|
|
255
|
+
Unstage and permanently exclude staged files over the size cap.
|
|
256
|
+
|
|
257
|
+
Runs right after `git add -A`. Oversized files are removed from the
|
|
258
|
+
index (`git rm --cached`, working tree untouched) and appended to
|
|
259
|
+
info/exclude so they are never staged again — this also untracks
|
|
260
|
+
previously-committed huge files so they stop churning new blobs.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of quarantined relative paths.
|
|
264
|
+
"""
|
|
265
|
+
max_mb = self.max_file_size_mb
|
|
266
|
+
if not max_mb or max_mb <= 0:
|
|
267
|
+
return []
|
|
268
|
+
max_bytes = int(max_mb * 1024 * 1024)
|
|
269
|
+
|
|
270
|
+
# On an unborn branch (initial snapshot) HEAD doesn't exist yet —
|
|
271
|
+
# everything staged is new, so sweep the whole index instead.
|
|
272
|
+
_, _, head_rc = await self._run(
|
|
273
|
+
["rev-parse", "--verify", "--quiet", "HEAD"], check=False
|
|
274
|
+
)
|
|
275
|
+
if head_rc == 0:
|
|
276
|
+
stdout, _, rc = await self._run(
|
|
277
|
+
["diff", "--cached", "--name-only", "--diff-filter=AM", "-z"],
|
|
278
|
+
check=False,
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
stdout, _, rc = await self._run(["ls-files", "--cached", "-z"], check=False)
|
|
282
|
+
if rc != 0 or not stdout:
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
oversized: list[str] = []
|
|
286
|
+
for rel_path in stdout.split("\0"):
|
|
287
|
+
if not rel_path:
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
st = (self.project_path / rel_path).lstat()
|
|
291
|
+
except OSError:
|
|
292
|
+
continue
|
|
293
|
+
if st.st_size > max_bytes:
|
|
294
|
+
oversized.append(rel_path)
|
|
295
|
+
|
|
296
|
+
if not oversized:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
# Unstage in batches to stay clear of argv limits
|
|
300
|
+
for i in range(0, len(oversized), 50):
|
|
301
|
+
batch = oversized[i:i + 50]
|
|
302
|
+
await self._run(
|
|
303
|
+
["rm", "--cached", "--quiet", "--force", "--", *batch], check=False
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
self._add_excludes(oversized, reason=f"over {max_mb}MB size cap")
|
|
307
|
+
for p in oversized:
|
|
308
|
+
logger.warning(
|
|
309
|
+
f"Shadow git: skipped oversized file '{p}' (> {max_mb}MB cap), "
|
|
310
|
+
f"added to info/exclude"
|
|
311
|
+
)
|
|
312
|
+
return oversized
|
|
313
|
+
|
|
314
|
+
async def _run(self, args: list[str], check: bool = True) -> tuple[str, str, int]:
|
|
315
|
+
"""
|
|
316
|
+
Run a git command with our shadow git directory.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
args: Git command arguments (without 'git')
|
|
320
|
+
check: Raise exception on non-zero exit
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
(stdout, stderr, exit_code)
|
|
324
|
+
"""
|
|
325
|
+
cmd = [
|
|
326
|
+
"git",
|
|
327
|
+
f"--git-dir={self.git_dir}",
|
|
328
|
+
f"--work-tree={self.project_path}",
|
|
329
|
+
*args
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
proc = await asyncio.create_subprocess_exec(
|
|
333
|
+
*cmd,
|
|
334
|
+
stdout=asyncio.subprocess.PIPE,
|
|
335
|
+
stderr=asyncio.subprocess.PIPE,
|
|
336
|
+
cwd=str(self.project_path)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
stdout, stderr = await proc.communicate()
|
|
340
|
+
stdout_str = stdout.decode().strip()
|
|
341
|
+
stderr_str = stderr.decode().strip()
|
|
342
|
+
|
|
343
|
+
if check and proc.returncode != 0:
|
|
344
|
+
logger.error(f"Git command failed: {' '.join(args)}")
|
|
345
|
+
logger.error(f"stderr: {stderr_str}")
|
|
346
|
+
raise RuntimeError(f"Git command failed: {stderr_str}")
|
|
347
|
+
|
|
348
|
+
return stdout_str, stderr_str, proc.returncode
|
|
349
|
+
|
|
350
|
+
async def init_repo(self) -> bool:
|
|
351
|
+
"""
|
|
352
|
+
Initialize shadow git repository if it doesn't exist.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True if initialized (or already exists), False on error
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
# Ensure project directory exists
|
|
359
|
+
ensure_project_dir(str(self.project_path))
|
|
360
|
+
|
|
361
|
+
if self.git_dir.exists():
|
|
362
|
+
logger.debug(f"Shadow git already exists: {self.git_dir}")
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
# Initialize as a bare repo first (completely separate from project's .git)
|
|
366
|
+
# This ensures we don't inherit any history from the project
|
|
367
|
+
self.git_dir.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
|
|
369
|
+
proc = await asyncio.create_subprocess_exec(
|
|
370
|
+
"git", "init", "--bare", str(self.git_dir),
|
|
371
|
+
stdout=asyncio.subprocess.PIPE,
|
|
372
|
+
stderr=asyncio.subprocess.PIPE,
|
|
373
|
+
)
|
|
374
|
+
await proc.communicate()
|
|
375
|
+
|
|
376
|
+
# Write exclude patterns
|
|
377
|
+
exclude_file = self.git_dir / "info" / "exclude"
|
|
378
|
+
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
exclude_file.write_text(DEFAULT_EXCLUDES, encoding="utf-8")
|
|
380
|
+
|
|
381
|
+
# Configure git
|
|
382
|
+
await self._run(["config", "user.email", "shadow-git@painapple-code"])
|
|
383
|
+
await self._run(["config", "user.name", "Shadow Git"])
|
|
384
|
+
|
|
385
|
+
# Add all existing files and create baseline snapshot
|
|
386
|
+
# This ensures shadow git tracks the full project state, not just Claude's changes
|
|
387
|
+
await self._run(["add", "-A"])
|
|
388
|
+
await self._quarantine_oversized()
|
|
389
|
+
# check=False: an empty project directory stages nothing, so this
|
|
390
|
+
# exits 1 with "nothing to commit" on STDOUT and an empty stderr.
|
|
391
|
+
# That used to raise RuntimeError("Git command failed: ") — a
|
|
392
|
+
# blank, unactionable error that aborted init for every brand-new
|
|
393
|
+
# project. The bare repo is already usable either way; the first
|
|
394
|
+
# real turn writes the first commit.
|
|
395
|
+
out, err, rc = await self._run(
|
|
396
|
+
["commit", "-m", "Initial project snapshot"], check=False)
|
|
397
|
+
if rc != 0:
|
|
398
|
+
logger.debug(f"Baseline snapshot skipped (rc={rc}): {out or err}")
|
|
399
|
+
|
|
400
|
+
logger.info(f"Initialized shadow git: {self.git_dir}")
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.exception(f"Failed to initialize shadow git: {e}")
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
408
|
+
# Modification Tracking
|
|
409
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
410
|
+
|
|
411
|
+
def _load_tracking(self) -> dict:
|
|
412
|
+
"""Load active-modifications.json."""
|
|
413
|
+
if self.tracking_file.exists():
|
|
414
|
+
try:
|
|
415
|
+
return json.loads(self.tracking_file.read_text(encoding="utf-8"))
|
|
416
|
+
except Exception as e:
|
|
417
|
+
logger.warning(f"Failed to load tracking file: {e}")
|
|
418
|
+
return {}
|
|
419
|
+
|
|
420
|
+
def _save_tracking(self, data: dict):
|
|
421
|
+
"""Save active-modifications.json atomically."""
|
|
422
|
+
temp = self.tracking_file.with_suffix(".tmp")
|
|
423
|
+
temp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
424
|
+
# os.replace, not rename: rename over an existing dest raises
|
|
425
|
+
# FileExistsError on Windows — every save after the first.
|
|
426
|
+
os.replace(temp, self.tracking_file)
|
|
427
|
+
|
|
428
|
+
def track_modification(self, file_path: str, session_id: str):
|
|
429
|
+
"""
|
|
430
|
+
Record that a session modified a file.
|
|
431
|
+
|
|
432
|
+
Called on Edit/Write tool results to track which sessions
|
|
433
|
+
have pending modifications to each file.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
file_path: Relative path to the modified file
|
|
437
|
+
session_id: Session that made the modification
|
|
438
|
+
"""
|
|
439
|
+
# Reload config and check if enabled
|
|
440
|
+
self.reload_config()
|
|
441
|
+
if not self.is_enabled:
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Use file locking for concurrent access
|
|
445
|
+
self.project_dir.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
|
|
447
|
+
lock_file = self.tracking_file.with_suffix(".lock")
|
|
448
|
+
with FileLock(lock_file):
|
|
449
|
+
data = self._load_tracking()
|
|
450
|
+
|
|
451
|
+
if file_path not in data:
|
|
452
|
+
data[file_path] = []
|
|
453
|
+
if session_id not in data[file_path]:
|
|
454
|
+
data[file_path].append(session_id)
|
|
455
|
+
|
|
456
|
+
self._save_tracking(data)
|
|
457
|
+
logger.debug(f"Tracked modification: {file_path} by {session_id[:8]}")
|
|
458
|
+
|
|
459
|
+
def _cleanup_tracking(self, data: dict, files: list[str], sessions: set[str]):
|
|
460
|
+
"""Remove sessions from tracking for committed files."""
|
|
461
|
+
for f in files:
|
|
462
|
+
if f in data:
|
|
463
|
+
data[f] = [s for s in data[f] if s not in sessions]
|
|
464
|
+
if not data[f]:
|
|
465
|
+
del data[f]
|
|
466
|
+
|
|
467
|
+
if data:
|
|
468
|
+
self._save_tracking(data)
|
|
469
|
+
elif self.tracking_file.exists():
|
|
470
|
+
self.tracking_file.unlink()
|
|
471
|
+
|
|
472
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
473
|
+
# Committing
|
|
474
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
475
|
+
|
|
476
|
+
async def _get_staged_diff_stats(self) -> str:
|
|
477
|
+
"""Get diff stats for staged changes like '+120 -45'."""
|
|
478
|
+
stdout, _, _ = await self._run(["diff", "--cached", "--shortstat"], check=False)
|
|
479
|
+
if not stdout:
|
|
480
|
+
return ""
|
|
481
|
+
# Parse: " 3 files changed, 120 insertions(+), 45 deletions(-)"
|
|
482
|
+
parts = stdout.split(",")
|
|
483
|
+
insertions = deletions = 0
|
|
484
|
+
for part in parts:
|
|
485
|
+
if "insertion" in part:
|
|
486
|
+
insertions = int(part.split()[0])
|
|
487
|
+
elif "deletion" in part:
|
|
488
|
+
deletions = int(part.split()[0])
|
|
489
|
+
return f"+{insertions} -{deletions}"
|
|
490
|
+
|
|
491
|
+
async def _get_head_hash(self) -> str:
|
|
492
|
+
"""Get current HEAD commit hash."""
|
|
493
|
+
stdout, _, _ = await self._run(["rev-parse", "HEAD"])
|
|
494
|
+
return stdout[:8]
|
|
495
|
+
|
|
496
|
+
async def _get_project_git_hash(self) -> Optional[str]:
|
|
497
|
+
"""
|
|
498
|
+
Get the project's actual git HEAD hash (not shadow git).
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Short hash (8 chars) if project has git, None otherwise
|
|
502
|
+
"""
|
|
503
|
+
project_git_dir = self.project_path / ".git"
|
|
504
|
+
if not project_git_dir.exists():
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
proc = await asyncio.create_subprocess_exec(
|
|
509
|
+
"git", "rev-parse", "HEAD",
|
|
510
|
+
stdout=asyncio.subprocess.PIPE,
|
|
511
|
+
stderr=asyncio.subprocess.PIPE,
|
|
512
|
+
cwd=str(self.project_path)
|
|
513
|
+
)
|
|
514
|
+
stdout, _ = await proc.communicate()
|
|
515
|
+
if proc.returncode == 0:
|
|
516
|
+
return stdout.decode().strip()[:8]
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.debug(f"Failed to get project git hash: {e}")
|
|
519
|
+
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
async def _get_project_branch(self) -> Optional[str]:
|
|
523
|
+
"""
|
|
524
|
+
Get the project's current git branch name.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Branch name if on a branch, "HEAD-{hash}" if detached, None if not a git repo
|
|
528
|
+
"""
|
|
529
|
+
project_git_dir = self.project_path / ".git"
|
|
530
|
+
if not project_git_dir.exists():
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
# First try to get branch name
|
|
535
|
+
proc = await asyncio.create_subprocess_exec(
|
|
536
|
+
"git", "branch", "--show-current",
|
|
537
|
+
stdout=asyncio.subprocess.PIPE,
|
|
538
|
+
stderr=asyncio.subprocess.PIPE,
|
|
539
|
+
cwd=str(self.project_path)
|
|
540
|
+
)
|
|
541
|
+
stdout, _ = await proc.communicate()
|
|
542
|
+
branch = stdout.decode().strip()
|
|
543
|
+
|
|
544
|
+
if branch:
|
|
545
|
+
return branch
|
|
546
|
+
|
|
547
|
+
# Empty output means detached HEAD - use short hash
|
|
548
|
+
proc = await asyncio.create_subprocess_exec(
|
|
549
|
+
"git", "rev-parse", "--short", "HEAD",
|
|
550
|
+
stdout=asyncio.subprocess.PIPE,
|
|
551
|
+
stderr=asyncio.subprocess.PIPE,
|
|
552
|
+
cwd=str(self.project_path)
|
|
553
|
+
)
|
|
554
|
+
stdout, _ = await proc.communicate()
|
|
555
|
+
short_hash = stdout.decode().strip()
|
|
556
|
+
return f"HEAD-{short_hash}"
|
|
557
|
+
|
|
558
|
+
except Exception as e:
|
|
559
|
+
logger.debug(f"Failed to get project branch: {e}")
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
async def _ensure_shadow_branch(self, project_branch: Optional[str]) -> str:
|
|
563
|
+
"""
|
|
564
|
+
Ensure the shadow branch for a project branch exists and switch to it.
|
|
565
|
+
|
|
566
|
+
Creates shadow branches that mirror project branches:
|
|
567
|
+
- main -> shadow/main
|
|
568
|
+
- feature-x -> shadow/feature-x
|
|
569
|
+
- HEAD-abc123 (detached) -> shadow/HEAD-abc123
|
|
570
|
+
|
|
571
|
+
Non-git projects or None branch continue using 'master'.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
project_branch: Project's current branch name (or None for non-git)
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
Shadow branch name that we're now on
|
|
578
|
+
"""
|
|
579
|
+
if not project_branch:
|
|
580
|
+
return "master" # Non-git projects use master
|
|
581
|
+
|
|
582
|
+
shadow_branch = f"shadow/{project_branch}"
|
|
583
|
+
|
|
584
|
+
# Check if branch exists
|
|
585
|
+
_, _, exit_code = await self._run(
|
|
586
|
+
["rev-parse", "--verify", f"refs/heads/{shadow_branch}"],
|
|
587
|
+
check=False
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if exit_code != 0:
|
|
591
|
+
# Branch doesn't exist - create it from current HEAD
|
|
592
|
+
# This ensures the new branch has the project's current file state
|
|
593
|
+
try:
|
|
594
|
+
current_head, _, _ = await self._run(["rev-parse", "HEAD"])
|
|
595
|
+
await self._run(["branch", shadow_branch, current_head.strip()])
|
|
596
|
+
logger.info(f"Created shadow branch: {shadow_branch}")
|
|
597
|
+
except RuntimeError as e:
|
|
598
|
+
logger.warning(f"Failed to create shadow branch {shadow_branch}: {e}")
|
|
599
|
+
return "master" # Fallback to master
|
|
600
|
+
|
|
601
|
+
# Switch HEAD to point to shadow branch
|
|
602
|
+
# In a bare repo with work-tree, we update symbolic-ref
|
|
603
|
+
try:
|
|
604
|
+
await self._run(["symbolic-ref", "HEAD", f"refs/heads/{shadow_branch}"])
|
|
605
|
+
except RuntimeError as e:
|
|
606
|
+
logger.warning(f"Failed to switch to shadow branch {shadow_branch}: {e}")
|
|
607
|
+
return "master"
|
|
608
|
+
|
|
609
|
+
return shadow_branch
|
|
610
|
+
|
|
611
|
+
async def _get_current_shadow_branch(self) -> str:
|
|
612
|
+
"""Get current shadow branch name."""
|
|
613
|
+
try:
|
|
614
|
+
stdout, _, _ = await self._run(["symbolic-ref", "--short", "HEAD"])
|
|
615
|
+
return stdout.strip()
|
|
616
|
+
except RuntimeError:
|
|
617
|
+
return "master"
|
|
618
|
+
|
|
619
|
+
async def list_shadow_branches(self) -> list[dict]:
|
|
620
|
+
"""
|
|
621
|
+
List all shadow branches with commit counts.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
List of dicts with name, project_branch, and commit_count
|
|
625
|
+
"""
|
|
626
|
+
try:
|
|
627
|
+
stdout, _, _ = await self._run(
|
|
628
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads/shadow/"],
|
|
629
|
+
check=False
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
branches = []
|
|
633
|
+
for line in stdout.strip().split("\n"):
|
|
634
|
+
branch = line.strip()
|
|
635
|
+
if not branch:
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
# Get commit count for this branch
|
|
639
|
+
try:
|
|
640
|
+
count_out, _, _ = await self._run(
|
|
641
|
+
["rev-list", "--count", branch],
|
|
642
|
+
check=False
|
|
643
|
+
)
|
|
644
|
+
count = int(count_out.strip()) if count_out.strip() else 0
|
|
645
|
+
except Exception:
|
|
646
|
+
count = 0
|
|
647
|
+
|
|
648
|
+
branches.append({
|
|
649
|
+
"name": branch,
|
|
650
|
+
"project_branch": branch.replace("shadow/", "", 1),
|
|
651
|
+
"commit_count": count
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
return branches
|
|
655
|
+
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.debug(f"Failed to list shadow branches: {e}")
|
|
658
|
+
return []
|
|
659
|
+
|
|
660
|
+
async def commit_turn(
|
|
661
|
+
self,
|
|
662
|
+
session_id: str,
|
|
663
|
+
turn_num: int,
|
|
664
|
+
tracker: TurnTracker,
|
|
665
|
+
cost_info: CostInfo,
|
|
666
|
+
provider_session_id: Optional[str] = None,
|
|
667
|
+
result_msg: Optional[dict] = None,
|
|
668
|
+
token_profile: Optional[str] = None,
|
|
669
|
+
session_model: Optional[str] = None,
|
|
670
|
+
provider=None,
|
|
671
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
672
|
+
"""
|
|
673
|
+
Commit changes from a turn.
|
|
674
|
+
|
|
675
|
+
Handles both file changes and tool-only turns. Tool-only turns
|
|
676
|
+
use --allow-empty commits to preserve the investigation record.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
session_id: Our session ID
|
|
680
|
+
turn_num: Turn number (1-indexed)
|
|
681
|
+
tracker: TurnTracker with modified_files and tools_used
|
|
682
|
+
cost_info: Token and cost information
|
|
683
|
+
provider_session_id: Provider session/thread id for the rich-commit fork
|
|
684
|
+
result_msg: Full Claude result message (for Shadow DB)
|
|
685
|
+
token_profile: OAuth token profile for the rich-commit fork (Claude)
|
|
686
|
+
session_model: Parent session's model ID (to inherit context tier)
|
|
687
|
+
provider: The session's Provider (supplies the fork mechanism);
|
|
688
|
+
defaults to the Claude provider when omitted.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
Tuple of (commit_hash, session_title):
|
|
692
|
+
- commit_hash: Short hash if successful, None otherwise
|
|
693
|
+
- session_title: New session title from the summary fork if generated, None otherwise
|
|
694
|
+
"""
|
|
695
|
+
# Reload config to pick up any UI changes
|
|
696
|
+
self.reload_config()
|
|
697
|
+
|
|
698
|
+
# Check if shadow git is enabled
|
|
699
|
+
if not self.is_enabled:
|
|
700
|
+
logger.debug(f"Shadow git disabled for {self.project_path}")
|
|
701
|
+
return None, None
|
|
702
|
+
|
|
703
|
+
if not tracker.has_activity:
|
|
704
|
+
return None, None # Nothing happened this turn
|
|
705
|
+
|
|
706
|
+
# Ensure repo exists
|
|
707
|
+
if not self.git_dir.exists():
|
|
708
|
+
await self.init_repo()
|
|
709
|
+
|
|
710
|
+
# Sync exclude patterns so shipped DEFAULT_EXCLUDES updates reach
|
|
711
|
+
# repos initialized before those patterns existed
|
|
712
|
+
self._ensure_excludes()
|
|
713
|
+
|
|
714
|
+
has_file_changes = tracker.has_file_changes
|
|
715
|
+
modified_files = list(tracker.modified_files)
|
|
716
|
+
|
|
717
|
+
# Handle co-modification tracking for file changes
|
|
718
|
+
contributing_sessions = {session_id}
|
|
719
|
+
tracking_data = {}
|
|
720
|
+
|
|
721
|
+
if has_file_changes:
|
|
722
|
+
tracking_data = self._load_tracking()
|
|
723
|
+
for f in modified_files:
|
|
724
|
+
if f in tracking_data:
|
|
725
|
+
contributing_sessions.update(tracking_data[f])
|
|
726
|
+
|
|
727
|
+
# Get project's actual git hash and branch early (needed for branch switching and journey)
|
|
728
|
+
project_git_hash = await self._get_project_git_hash()
|
|
729
|
+
project_branch = await self._get_project_branch()
|
|
730
|
+
|
|
731
|
+
# Switch to the appropriate shadow branch BEFORE staging
|
|
732
|
+
# This ensures changes are committed to the correct branch
|
|
733
|
+
shadow_branch = await self._ensure_shadow_branch(project_branch)
|
|
734
|
+
logger.debug(f"Using shadow branch: {shadow_branch}")
|
|
735
|
+
|
|
736
|
+
# Stage ALL changes (not just tracked files)
|
|
737
|
+
# This captures: Edit/Write tools, Bash modifications, automation, etc.
|
|
738
|
+
await self._run(["add", "-A"])
|
|
739
|
+
|
|
740
|
+
# Quarantine anything over the size cap (VM images, media dumps, …)
|
|
741
|
+
# so huge binaries never bloat the shadow object store
|
|
742
|
+
await self._quarantine_oversized()
|
|
743
|
+
|
|
744
|
+
# Build session prefix
|
|
745
|
+
sessions_str = "+".join(sorted(s[:8] for s in contributing_sessions))
|
|
746
|
+
|
|
747
|
+
# Build journey context from previous commits (filtered by branch)
|
|
748
|
+
journey = []
|
|
749
|
+
if self.is_rich_commits_enabled:
|
|
750
|
+
journey = await self._build_journey_context(session_id, project_branch)
|
|
751
|
+
|
|
752
|
+
# Generate message (rich or basic)
|
|
753
|
+
message_body = None
|
|
754
|
+
summary_cost = None
|
|
755
|
+
structured_data = None
|
|
756
|
+
summary_line = ""
|
|
757
|
+
session_title = ""
|
|
758
|
+
|
|
759
|
+
if self.is_rich_commits_enabled and provider_session_id:
|
|
760
|
+
# Default to the Claude provider for callers that predate the seam.
|
|
761
|
+
if provider is None:
|
|
762
|
+
from painapple_code.providers import get_provider
|
|
763
|
+
provider = get_provider("claude")
|
|
764
|
+
message_body, summary_cost, structured_data = await self._generate_rich_commit_message(
|
|
765
|
+
provider,
|
|
766
|
+
provider_session_id,
|
|
767
|
+
tracker.user_prompt or "",
|
|
768
|
+
tracker,
|
|
769
|
+
journey,
|
|
770
|
+
app_session_id=session_id, # For subprocess tracking
|
|
771
|
+
token_profile=token_profile,
|
|
772
|
+
session_model=session_model,
|
|
773
|
+
)
|
|
774
|
+
if message_body:
|
|
775
|
+
# Extract summary line and session title
|
|
776
|
+
# Prefer structured data if available, fallback to regex extraction
|
|
777
|
+
if structured_data:
|
|
778
|
+
summary_line = structured_data.get("summary", "")
|
|
779
|
+
session_title = structured_data.get("session_title", "")
|
|
780
|
+
|
|
781
|
+
# Persist raw summary-fork output to disk before DuckDB write
|
|
782
|
+
# so structured_data is recoverable if acomplete_turn() fails
|
|
783
|
+
try:
|
|
784
|
+
summary_file = get_sessions_dir(str(self.project_path)) / session_id / "summary.jsonl"
|
|
785
|
+
summary_record = {
|
|
786
|
+
"turn_number": turn_num,
|
|
787
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
788
|
+
"db_turn_id": tracker.db_turn_id,
|
|
789
|
+
"summary_cost": {
|
|
790
|
+
"cost": summary_cost.cost,
|
|
791
|
+
"input_tokens": summary_cost.input_tokens,
|
|
792
|
+
"output_tokens": summary_cost.output_tokens,
|
|
793
|
+
"duration": summary_cost.duration,
|
|
794
|
+
} if summary_cost else None,
|
|
795
|
+
"structured_data": structured_data,
|
|
796
|
+
}
|
|
797
|
+
with open(summary_file, "a", encoding="utf-8") as f:
|
|
798
|
+
f.write(json.dumps(summary_record) + "\n")
|
|
799
|
+
except Exception as e:
|
|
800
|
+
logger.warning(f"Failed to save summary.jsonl: {e}")
|
|
801
|
+
else:
|
|
802
|
+
summary_line = extract_summary_line(message_body)
|
|
803
|
+
session_title = extract_session_title(message_body)
|
|
804
|
+
|
|
805
|
+
# Resolve primary model for this turn — pick the model with highest
|
|
806
|
+
# cost share from result_msg.modelUsage (matches shadow_db.py logic),
|
|
807
|
+
# fall back to session_model when modelUsage is absent.
|
|
808
|
+
turn_model: Optional[str] = None
|
|
809
|
+
if result_msg:
|
|
810
|
+
model_usage_data = result_msg.get("modelUsage") or {}
|
|
811
|
+
if model_usage_data:
|
|
812
|
+
turn_model = max(
|
|
813
|
+
model_usage_data.keys(),
|
|
814
|
+
key=lambda m: model_usage_data[m].get("costUSD", 0),
|
|
815
|
+
)
|
|
816
|
+
if not turn_model:
|
|
817
|
+
turn_model = session_model
|
|
818
|
+
|
|
819
|
+
# Build commit message with YAML frontmatter (rich mode) or basic format
|
|
820
|
+
if message_body:
|
|
821
|
+
# Rich mode - include YAML frontmatter with journey
|
|
822
|
+
yaml_header = format_yaml_frontmatter(
|
|
823
|
+
session_id=session_id,
|
|
824
|
+
turn_num=turn_num,
|
|
825
|
+
files=modified_files,
|
|
826
|
+
tools=[t.name for t in tracker.tools_used],
|
|
827
|
+
cost=cost_info.cost,
|
|
828
|
+
input_tokens=cost_info.input_tokens,
|
|
829
|
+
output_tokens=cost_info.output_tokens,
|
|
830
|
+
journey=journey,
|
|
831
|
+
summary=summary_line or self._extract_title(message_body),
|
|
832
|
+
project_git_hash=project_git_hash,
|
|
833
|
+
project_branch=project_branch,
|
|
834
|
+
model=turn_model,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
title = self._extract_title(message_body)
|
|
838
|
+
message = yaml_header
|
|
839
|
+
message += f"[{sessions_str}] Turn {turn_num}: {title}\n\n"
|
|
840
|
+
if tracker.user_prompt:
|
|
841
|
+
message += f"> {tracker.user_prompt[:200]}\n\n"
|
|
842
|
+
message += message_body
|
|
843
|
+
elif has_file_changes:
|
|
844
|
+
# Basic mode with files (no YAML)
|
|
845
|
+
stats = await self._get_staged_diff_stats()
|
|
846
|
+
title = tracker.format_basic_title()
|
|
847
|
+
message = f"[{sessions_str}] Turn {turn_num}: {title} ({stats})\n\n"
|
|
848
|
+
if tracker.user_prompt:
|
|
849
|
+
message += f"> {tracker.user_prompt[:200]}\n\n"
|
|
850
|
+
else:
|
|
851
|
+
# Basic mode tool-only (no YAML)
|
|
852
|
+
title = tracker.format_basic_title()
|
|
853
|
+
message = f"[{sessions_str}] Turn {turn_num}: {title}\n\n"
|
|
854
|
+
if tracker.user_prompt:
|
|
855
|
+
message += f"> {tracker.user_prompt[:200]}\n\n"
|
|
856
|
+
message += "## Tools Used\n"
|
|
857
|
+
for tool in tracker.tools_used[:20]:
|
|
858
|
+
message += f"- {tool.name}: {tool.input_summary} → {tool.output_summary}\n"
|
|
859
|
+
|
|
860
|
+
# Add cost footer
|
|
861
|
+
message += f"\n---\n⏱ {cost_info.duration:.1f}s | 💰 ${cost_info.cost:.4f}"
|
|
862
|
+
message += f" | ↓{cost_info.input_tokens/1000:.1f}k ↑{cost_info.output_tokens/1000:.1f}k"
|
|
863
|
+
if summary_cost:
|
|
864
|
+
# Provider-neutral label: the summarizer fork is Haiku for Claude
|
|
865
|
+
# sessions but Codex for Codex sessions.
|
|
866
|
+
message += f"\n🤖 Summary: ${summary_cost.cost:.4f} ({summary_cost.duration:.1f}s)"
|
|
867
|
+
|
|
868
|
+
# Commit (always --allow-empty to ensure journey tracking even when
|
|
869
|
+
# files were already committed by another session or no changes)
|
|
870
|
+
try:
|
|
871
|
+
await self._run(["commit", "--allow-empty", "-m", message])
|
|
872
|
+
|
|
873
|
+
commit_hash = await self._get_head_hash()
|
|
874
|
+
logger.info(f"Committed turn {turn_num}: {commit_hash}")
|
|
875
|
+
|
|
876
|
+
# Cleanup tracking
|
|
877
|
+
if has_file_changes:
|
|
878
|
+
self._cleanup_tracking(tracking_data, modified_files, contributing_sessions)
|
|
879
|
+
|
|
880
|
+
# Update session metadata (including title from the summary fork if available)
|
|
881
|
+
await self._update_session_meta(
|
|
882
|
+
session_id, commit_hash, tracker, cost_info, summary_cost, session_title
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Shadow DB: complete turn with all data
|
|
886
|
+
if tracker.db_turn_id:
|
|
887
|
+
try:
|
|
888
|
+
from painapple_code.shadow_db import get_shadow_db
|
|
889
|
+
db = get_shadow_db()
|
|
890
|
+
await db.acomplete_turn(
|
|
891
|
+
tracker.db_turn_id,
|
|
892
|
+
result_msg=result_msg,
|
|
893
|
+
git_hash=commit_hash,
|
|
894
|
+
git_branch=project_branch,
|
|
895
|
+
shadow_branch=shadow_branch,
|
|
896
|
+
structured_data=structured_data,
|
|
897
|
+
summary_cost=summary_cost.cost if summary_cost else 0.0,
|
|
898
|
+
modified_files=modified_files,
|
|
899
|
+
tools_summary=tracker.get_tools_summary(),
|
|
900
|
+
main_thread_model=tracker.main_thread_model,
|
|
901
|
+
)
|
|
902
|
+
except Exception as e:
|
|
903
|
+
logger.warning(f"Shadow DB complete_turn failed: {e}")
|
|
904
|
+
|
|
905
|
+
return commit_hash, session_title if session_title else None
|
|
906
|
+
|
|
907
|
+
except RuntimeError as e:
|
|
908
|
+
logger.error(f"Failed to commit turn {turn_num}: {e}")
|
|
909
|
+
# Mark DB turn as failed
|
|
910
|
+
if tracker.db_turn_id:
|
|
911
|
+
try:
|
|
912
|
+
from painapple_code.shadow_db import get_shadow_db
|
|
913
|
+
db = get_shadow_db()
|
|
914
|
+
await db.afail_turn(tracker.db_turn_id, str(e))
|
|
915
|
+
except Exception:
|
|
916
|
+
pass
|
|
917
|
+
return None, None
|
|
918
|
+
|
|
919
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
920
|
+
# Archiving
|
|
921
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
922
|
+
|
|
923
|
+
async def archive_session(self, session_id: str) -> bool:
|
|
924
|
+
"""
|
|
925
|
+
Archive a session by creating an annotated tag at its last commit.
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
session_id: Session to archive
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
True if archived successfully
|
|
932
|
+
"""
|
|
933
|
+
sessions_dir = get_sessions_dir(str(self.project_path))
|
|
934
|
+
meta_file = sessions_dir / session_id / "meta.json"
|
|
935
|
+
|
|
936
|
+
if not meta_file.exists():
|
|
937
|
+
logger.warning(f"Session not found: {session_id}")
|
|
938
|
+
return False
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
942
|
+
shadow = meta.get("shadow", {})
|
|
943
|
+
|
|
944
|
+
if not shadow or shadow.get("archived"):
|
|
945
|
+
return True # Nothing to archive or already archived
|
|
946
|
+
|
|
947
|
+
last_commit = shadow.get("last_commit")
|
|
948
|
+
if not last_commit:
|
|
949
|
+
logger.warning(f"No commits for session: {session_id}")
|
|
950
|
+
return False
|
|
951
|
+
|
|
952
|
+
# Generate tag message
|
|
953
|
+
tag_message = f"""Session {session_id[:8]}: {len(shadow.get('files_touched', []))} files changed
|
|
954
|
+
|
|
955
|
+
Turns: {shadow.get('turn_count', 0)}
|
|
956
|
+
File commits: {shadow.get('file_commits', 0)}
|
|
957
|
+
Tool-only commits: {shadow.get('tool_commits', 0)}
|
|
958
|
+
Cost: ${shadow.get('total_cost', 0):.2f}
|
|
959
|
+
|
|
960
|
+
Files: {', '.join(shadow.get('files_touched', [])[:10])}
|
|
961
|
+
"""
|
|
962
|
+
|
|
963
|
+
# Create annotated tag
|
|
964
|
+
await self._run([
|
|
965
|
+
"tag", "-a", f"sessions/{session_id}",
|
|
966
|
+
"-m", tag_message,
|
|
967
|
+
last_commit
|
|
968
|
+
])
|
|
969
|
+
|
|
970
|
+
# Mark as archived in meta
|
|
971
|
+
meta["shadow"]["archived"] = True
|
|
972
|
+
meta["shadow"]["archive_tag"] = f"sessions/{session_id}"
|
|
973
|
+
meta_file.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
974
|
+
|
|
975
|
+
logger.info(f"Archived session: {session_id}")
|
|
976
|
+
return True
|
|
977
|
+
|
|
978
|
+
except Exception as e:
|
|
979
|
+
logger.exception(f"Failed to archive session: {e}")
|
|
980
|
+
return False
|
|
981
|
+
|
|
982
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
983
|
+
# Querying
|
|
984
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
985
|
+
|
|
986
|
+
async def get_session_log(
|
|
987
|
+
self,
|
|
988
|
+
session_id: str,
|
|
989
|
+
limit: int = 50,
|
|
990
|
+
branch: Optional[str] = None
|
|
991
|
+
) -> list[dict]:
|
|
992
|
+
"""
|
|
993
|
+
Get commits for a specific session.
|
|
994
|
+
|
|
995
|
+
Args:
|
|
996
|
+
session_id: Session to get commits for
|
|
997
|
+
limit: Maximum commits to return
|
|
998
|
+
branch: Filter by project branch (e.g., "main" -> searches shadow/main)
|
|
999
|
+
|
|
1000
|
+
Returns:
|
|
1001
|
+
List of commit dicts with hash, message, timestamp
|
|
1002
|
+
"""
|
|
1003
|
+
try:
|
|
1004
|
+
cmd = ["log"]
|
|
1005
|
+
if branch:
|
|
1006
|
+
# Add branch filter (search specific shadow branch)
|
|
1007
|
+
cmd.append(f"shadow/{branch}")
|
|
1008
|
+
cmd.extend([
|
|
1009
|
+
f"--grep=\\[{session_id[:8]}",
|
|
1010
|
+
f"-n{limit}",
|
|
1011
|
+
"--format=%H|%s|%ai"
|
|
1012
|
+
])
|
|
1013
|
+
stdout, _, _ = await self._run(cmd, check=False)
|
|
1014
|
+
|
|
1015
|
+
commits = []
|
|
1016
|
+
for line in stdout.split("\n"):
|
|
1017
|
+
if not line:
|
|
1018
|
+
continue
|
|
1019
|
+
parts = line.split("|", 2)
|
|
1020
|
+
if len(parts) >= 3:
|
|
1021
|
+
commits.append({
|
|
1022
|
+
"hash": parts[0][:8],
|
|
1023
|
+
"message": parts[1],
|
|
1024
|
+
"timestamp": parts[2]
|
|
1025
|
+
})
|
|
1026
|
+
return commits
|
|
1027
|
+
|
|
1028
|
+
except Exception as e:
|
|
1029
|
+
logger.exception(f"Failed to get session log: {e}")
|
|
1030
|
+
return []
|
|
1031
|
+
|
|
1032
|
+
async def get_full_log(
|
|
1033
|
+
self,
|
|
1034
|
+
limit: int = 100,
|
|
1035
|
+
branch: Optional[str] = None
|
|
1036
|
+
) -> list[dict]:
|
|
1037
|
+
"""
|
|
1038
|
+
Get all commits on a branch.
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
limit: Maximum commits to return
|
|
1042
|
+
branch: Filter by project branch (e.g., "main" -> searches shadow/main)
|
|
1043
|
+
|
|
1044
|
+
Returns:
|
|
1045
|
+
List of commit dicts
|
|
1046
|
+
"""
|
|
1047
|
+
try:
|
|
1048
|
+
cmd = ["log"]
|
|
1049
|
+
if branch:
|
|
1050
|
+
cmd.append(f"shadow/{branch}")
|
|
1051
|
+
cmd.extend([
|
|
1052
|
+
f"-n{limit}",
|
|
1053
|
+
"--format=%H|%s|%ai"
|
|
1054
|
+
])
|
|
1055
|
+
stdout, _, _ = await self._run(cmd, check=False)
|
|
1056
|
+
|
|
1057
|
+
commits = []
|
|
1058
|
+
for line in stdout.split("\n"):
|
|
1059
|
+
if not line:
|
|
1060
|
+
continue
|
|
1061
|
+
parts = line.split("|", 2)
|
|
1062
|
+
if len(parts) >= 3:
|
|
1063
|
+
commits.append({
|
|
1064
|
+
"hash": parts[0][:8],
|
|
1065
|
+
"message": parts[1],
|
|
1066
|
+
"timestamp": parts[2]
|
|
1067
|
+
})
|
|
1068
|
+
return commits
|
|
1069
|
+
|
|
1070
|
+
except Exception as e:
|
|
1071
|
+
logger.exception(f"Failed to get log: {e}")
|
|
1072
|
+
return []
|
|
1073
|
+
|
|
1074
|
+
async def get_file_at_ref(self, ref: str, path: str) -> Optional[str]:
|
|
1075
|
+
"""
|
|
1076
|
+
Get file content at a specific commit.
|
|
1077
|
+
|
|
1078
|
+
Args:
|
|
1079
|
+
ref: Commit hash or reference
|
|
1080
|
+
path: File path
|
|
1081
|
+
|
|
1082
|
+
Returns:
|
|
1083
|
+
File content or None
|
|
1084
|
+
"""
|
|
1085
|
+
try:
|
|
1086
|
+
stdout, _, _ = await self._run(["show", f"{ref}:{path}"])
|
|
1087
|
+
return stdout
|
|
1088
|
+
except RuntimeError:
|
|
1089
|
+
return None
|
|
1090
|
+
|
|
1091
|
+
async def list_archived_sessions(self) -> list[str]:
|
|
1092
|
+
"""List all archived session IDs."""
|
|
1093
|
+
try:
|
|
1094
|
+
stdout, _, _ = await self._run(["tag", "-l", "sessions/*"])
|
|
1095
|
+
return [t.replace("sessions/", "") for t in stdout.split("\n") if t]
|
|
1096
|
+
except Exception:
|
|
1097
|
+
return []
|
|
1098
|
+
|
|
1099
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
1100
|
+
# Undo/Redo
|
|
1101
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
1102
|
+
|
|
1103
|
+
async def undo_turn(self, session_id: str) -> Optional[str]:
|
|
1104
|
+
"""
|
|
1105
|
+
Undo the last turn for a session.
|
|
1106
|
+
|
|
1107
|
+
Uses git revert to create an "undo" commit.
|
|
1108
|
+
|
|
1109
|
+
Args:
|
|
1110
|
+
session_id: Session to undo
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
New commit hash if successful
|
|
1114
|
+
"""
|
|
1115
|
+
# Find last commit for this session
|
|
1116
|
+
commits = await self.get_session_log(session_id, limit=1)
|
|
1117
|
+
if not commits:
|
|
1118
|
+
logger.warning(f"No commits to undo for session: {session_id}")
|
|
1119
|
+
return None
|
|
1120
|
+
|
|
1121
|
+
last_commit = commits[0]["hash"]
|
|
1122
|
+
|
|
1123
|
+
try:
|
|
1124
|
+
await self._run(["revert", "--no-commit", last_commit])
|
|
1125
|
+
await self._run([
|
|
1126
|
+
"commit", "-m",
|
|
1127
|
+
f"[{session_id[:8]}] UNDO: Reverted {commits[0]['message']}"
|
|
1128
|
+
])
|
|
1129
|
+
return await self._get_head_hash()
|
|
1130
|
+
except RuntimeError as e:
|
|
1131
|
+
logger.error(f"Failed to undo: {e}")
|
|
1132
|
+
# Abort if revert failed
|
|
1133
|
+
await self._run(["revert", "--abort"], check=False)
|
|
1134
|
+
return None
|
|
1135
|
+
|
|
1136
|
+
async def restore_file(self, ref: str, path: str) -> bool:
|
|
1137
|
+
"""
|
|
1138
|
+
Restore a file from a specific commit.
|
|
1139
|
+
|
|
1140
|
+
Args:
|
|
1141
|
+
ref: Commit hash
|
|
1142
|
+
path: File path to restore
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
True if restored successfully
|
|
1146
|
+
"""
|
|
1147
|
+
try:
|
|
1148
|
+
await self._run(["checkout", ref, "--", path])
|
|
1149
|
+
return True
|
|
1150
|
+
except RuntimeError as e:
|
|
1151
|
+
logger.error(f"Failed to restore {path} from {ref}: {e}")
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
1155
|
+
# Compaction Checkpoint
|
|
1156
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
1157
|
+
|
|
1158
|
+
async def capture_compaction_checkpoint(
|
|
1159
|
+
self,
|
|
1160
|
+
session_id: str,
|
|
1161
|
+
provider_session_id: str,
|
|
1162
|
+
compact_metadata: dict,
|
|
1163
|
+
token_profile: Optional[str] = None,
|
|
1164
|
+
) -> Optional[str]:
|
|
1165
|
+
"""
|
|
1166
|
+
Capture a checkpoint when compaction is detected.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
session_id: Our session ID
|
|
1170
|
+
provider_session_id: Claude's session ID for --resume
|
|
1171
|
+
compact_metadata: Compaction info with pre_tokens, post_tokens, trigger
|
|
1172
|
+
token_profile: OAuth token profile for the summary fork subprocess
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
Commit hash if successful
|
|
1176
|
+
"""
|
|
1177
|
+
# Reload config and check if enabled
|
|
1178
|
+
self.reload_config()
|
|
1179
|
+
if not self.is_enabled:
|
|
1180
|
+
return None
|
|
1181
|
+
|
|
1182
|
+
pre_tokens = compact_metadata.get("pre_tokens", 0)
|
|
1183
|
+
post_tokens = compact_metadata.get("post_tokens", 0)
|
|
1184
|
+
trigger = compact_metadata.get("trigger", "unknown")
|
|
1185
|
+
|
|
1186
|
+
logger.info(f"Compaction detected: {pre_tokens}→{post_tokens} ({trigger})")
|
|
1187
|
+
|
|
1188
|
+
# Fork to the summary model for comprehensive summary
|
|
1189
|
+
summary = None
|
|
1190
|
+
if provider_session_id:
|
|
1191
|
+
try:
|
|
1192
|
+
from painapple_code.utils.token_profiles import build_env as build_token_env
|
|
1193
|
+
from painapple_code.providers import get_provider
|
|
1194
|
+
subprocess_env = build_token_env(token_profile)
|
|
1195
|
+
|
|
1196
|
+
from painapple_code.utils.proc import resolve_binary
|
|
1197
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1198
|
+
resolve_binary(get_provider("claude").binary()),
|
|
1199
|
+
"--resume", provider_session_id,
|
|
1200
|
+
"--fork-session",
|
|
1201
|
+
"--model", get_summary_model(),
|
|
1202
|
+
"--tools", "",
|
|
1203
|
+
"--no-session-persistence",
|
|
1204
|
+
"-p", COMMIT_PROMPT_COMPACTION,
|
|
1205
|
+
"--output-format", "text",
|
|
1206
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1207
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1208
|
+
cwd=str(self.project_path),
|
|
1209
|
+
env=subprocess_env,
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
stdout, _ = await asyncio.wait_for(
|
|
1213
|
+
proc.communicate(),
|
|
1214
|
+
timeout=60
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
if proc.returncode == 0:
|
|
1218
|
+
summary = stdout.decode().strip()
|
|
1219
|
+
|
|
1220
|
+
except Exception as e:
|
|
1221
|
+
logger.warning(f"Compaction checkpoint fork failed: {e}")
|
|
1222
|
+
|
|
1223
|
+
# Create checkpoint commit
|
|
1224
|
+
message = f"""[{session_id[:8]}] Checkpoint: Context compaction
|
|
1225
|
+
|
|
1226
|
+
Compacted from {pre_tokens:,} to {post_tokens:,} tokens.
|
|
1227
|
+
Trigger: {trigger}
|
|
1228
|
+
|
|
1229
|
+
"""
|
|
1230
|
+
if summary:
|
|
1231
|
+
message += summary
|
|
1232
|
+
else:
|
|
1233
|
+
message += "(Summary generation failed - context already truncated)"
|
|
1234
|
+
|
|
1235
|
+
message += "\n\n---\n🔄 Auto-generated checkpoint after context compaction"
|
|
1236
|
+
|
|
1237
|
+
try:
|
|
1238
|
+
await self._run(["commit", "--allow-empty", "-m", message])
|
|
1239
|
+
commit_hash = await self._get_head_hash()
|
|
1240
|
+
logger.info(f"Created compaction checkpoint: {commit_hash}")
|
|
1241
|
+
return commit_hash
|
|
1242
|
+
except RuntimeError as e:
|
|
1243
|
+
logger.error(f"Failed to create compaction checkpoint: {e}")
|
|
1244
|
+
return None
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1248
|
+
# Convenience function
|
|
1249
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1250
|
+
|
|
1251
|
+
_shadow_git_instances: dict[str, ShadowGit] = {}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def get_shadow_git(project_path: str) -> ShadowGit:
|
|
1255
|
+
"""
|
|
1256
|
+
Get or create ShadowGit instance for a project.
|
|
1257
|
+
|
|
1258
|
+
Caches instances to avoid recreating them.
|
|
1259
|
+
"""
|
|
1260
|
+
path_str = str(Path(project_path).resolve())
|
|
1261
|
+
if path_str not in _shadow_git_instances:
|
|
1262
|
+
_shadow_git_instances[path_str] = ShadowGit(project_path)
|
|
1263
|
+
return _shadow_git_instances[path_str]
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def get_commit_sections_for_api(project_config: dict) -> dict:
|
|
1267
|
+
"""
|
|
1268
|
+
Get sections config formatted for API/UI consumption.
|
|
1269
|
+
|
|
1270
|
+
Returns:
|
|
1271
|
+
{
|
|
1272
|
+
"sections": [
|
|
1273
|
+
{
|
|
1274
|
+
"id": "summary",
|
|
1275
|
+
"title": "Summary",
|
|
1276
|
+
"prompt": "...",
|
|
1277
|
+
"required": true,
|
|
1278
|
+
"enabled": true,
|
|
1279
|
+
"order": 10,
|
|
1280
|
+
"builtin": true,
|
|
1281
|
+
"applies_to": ["file_changes", "tool_only"]
|
|
1282
|
+
},
|
|
1283
|
+
...
|
|
1284
|
+
],
|
|
1285
|
+
"builtin_ids": ["summary", "work_done", ...]
|
|
1286
|
+
}
|
|
1287
|
+
"""
|
|
1288
|
+
sections = get_sections_config(project_config)
|
|
1289
|
+
|
|
1290
|
+
# Convert to sorted list
|
|
1291
|
+
sections_list = sorted(sections.values(), key=lambda s: s.get("order", 50))
|
|
1292
|
+
|
|
1293
|
+
return {
|
|
1294
|
+
"sections": sections_list,
|
|
1295
|
+
"builtin_ids": list(BUILTIN_SECTIONS.keys()),
|
|
1296
|
+
}
|