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.
Files changed (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. 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
+ }