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,830 @@
1
+ """
2
+ Shadow DB - DuckDB metadata store for shadow git turns.
3
+
4
+ Turn-centric schema: each turn (user prompt → Claude response → result) is the hero.
5
+ Turns are created immediately on prompt send (never lost), updated on completion.
6
+
7
+ Database: ~/.painapple-code/shadow.duckdb (global, cross-project)
8
+
9
+ Module layout:
10
+ shadow_db.py — ShadowDB core (connection, CRUD, async wrappers, file review)
11
+ shadow_db_schema.py — SCHEMA_SQL, init/migration/FTS helpers (_SchemaMixin)
12
+ shadow_db_queries.py — list_turns / search_turns / query_turns / stats (_QueriesMixin)
13
+ shadow_db_plans.py — plan create/link/list/get/update (_PlansMixin)
14
+
15
+ Usage:
16
+ db = get_shadow_db()
17
+ turn_id = db.start_turn(session_id, project_hash, prompt, git_branch)
18
+ # ... Claude processes ...
19
+ db.complete_turn(turn_id, result_msg, structured_data, commit_hash, ...)
20
+ """
21
+
22
+ import asyncio
23
+ import logging
24
+ import os
25
+ import threading
26
+ import time
27
+ import uuid
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ import duckdb
33
+
34
+ from painapple_code import paths
35
+ from painapple_code.shadow_db_plans import _PlansMixin
36
+ from painapple_code.shadow_db_queries import _QueriesMixin
37
+ from painapple_code.shadow_db_schema import _SchemaMixin
38
+ from painapple_code.utils.agent_cli import short_model_name
39
+
40
+ logger = logging.getLogger("painapple-code.shadow-db")
41
+
42
+ # ═══════════════════════════════════════════════════════════════════════════
43
+ # Database location
44
+ # ═══════════════════════════════════════════════════════════════════════════
45
+
46
+ DB_PATH = paths.DATA_HOME / "shadow.duckdb"
47
+
48
+
49
+ # ═══════════════════════════════════════════════════════════════════════════
50
+ # ShadowDB class
51
+ # ═══════════════════════════════════════════════════════════════════════════
52
+
53
+ class ShadowDB(_SchemaMixin, _QueriesMixin, _PlansMixin):
54
+ """DuckDB metadata store for shadow git turns.
55
+
56
+ Thread-safe: all writes go through _execute() which uses a lock.
57
+ Async-safe: use the async wrappers (astart_turn, acomplete_turn) from async code.
58
+ """
59
+
60
+ # Checkpoint WAL every N completed turns or every CHECKPOINT_INTERVAL_SEC seconds
61
+ CHECKPOINT_EVERY_TURNS = 10
62
+ CHECKPOINT_INTERVAL_SEC = 3600 # 1 hour
63
+
64
+ def __init__(self, db_path: Optional[Path] = None):
65
+ self.db_path = db_path or DB_PATH
66
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
67
+ self._lock = threading.Lock()
68
+ self._con: Optional[duckdb.DuckDBPyConnection] = None
69
+ self._init_done = False
70
+ self._fts_ready = False # FTS indexes built and current
71
+ self._fts_dirty = False # New data since last FTS rebuild
72
+ self._turns_since_checkpoint = 0
73
+ self._last_checkpoint = time.monotonic()
74
+
75
+ def _get_con(self) -> duckdb.DuckDBPyConnection:
76
+ """Get or create DuckDB connection with automatic corruption recovery.
77
+
78
+ Recovery strategies (tried in order on connect or init failure):
79
+ 1. Normal connect + init
80
+ 2. WAL corruption → back up WAL, reconnect
81
+ 3. ART index corruption → drop and recreate all indexes
82
+ """
83
+ if self._con is None:
84
+ self._con = self._open_db()
85
+ if not self._init_done:
86
+ try:
87
+ self._init_schema()
88
+ except (duckdb.FatalException, duckdb.InternalException, duckdb.TransactionException) as e:
89
+ logger.error(f"Schema init hit DB corruption: {e}")
90
+ try:
91
+ self._con.close()
92
+ except Exception:
93
+ pass
94
+ self._con = self._recover_indexes()
95
+ self._init_schema()
96
+ self._init_done = True
97
+ try:
98
+ self._con.execute("CHECKPOINT")
99
+ logger.info("Startup WAL checkpoint")
100
+ except Exception:
101
+ pass
102
+ self._lock_down_external_access()
103
+ return self._con
104
+
105
+ def _lock_down_external_access(self):
106
+ """Disable DuckDB external file access once FTS is installed.
107
+
108
+ The ad-hoc SQL endpoint (POST /api/shadow-db/sql) runs on this same
109
+ connection instance. enable_external_access=false is instance-wide and
110
+ one-way, so it must be set AFTER INSTALL/LOAD fts + index build (those
111
+ need external access on first run). Afterwards FTS queries and index
112
+ rebuilds still work (the extension is already loaded), but read_csv/
113
+ read_parquet, the `FROM 'file'` replacement scan, and `COPY ... TO 'file'`
114
+ are all blocked engine-wide — closing the file-read/exfil vectors that a
115
+ string-level denylist can't fully cover. The server never legitimately
116
+ reads external files through DuckDB, so nothing else is affected.
117
+ """
118
+ try:
119
+ self._con.execute("SET enable_external_access=false")
120
+ logger.info("DuckDB external file access locked down (SQL endpoint hardening)")
121
+ except Exception as e:
122
+ logger.warning(f"Could not lock down DuckDB external access: {e}")
123
+
124
+ def _open_db(self) -> duckdb.DuckDBPyConnection:
125
+ """Open DuckDB, recovering from WAL corruption if needed."""
126
+ import time as _time
127
+
128
+ try:
129
+ con = duckdb.connect(str(self.db_path))
130
+ con.execute("SELECT 1") # triggers WAL replay
131
+ logger.info(f"Connected to shadow DB: {self.db_path}")
132
+ return con
133
+ except duckdb.IOException as e:
134
+ # Lock contention is not recoverable here — re-raise immediately
135
+ if "lock" in str(e).lower():
136
+ raise
137
+ logger.error(f"DuckDB connect failed: {e}")
138
+ try:
139
+ con.close()
140
+ except Exception:
141
+ pass
142
+ except (duckdb.FatalException, duckdb.InternalException, duckdb.TransactionException) as e:
143
+ logger.error(f"DuckDB connect failed: {e}")
144
+ try:
145
+ con.close()
146
+ except Exception:
147
+ pass
148
+
149
+ # WAL corruption → back up and retry without it
150
+ wal_path = Path(f"{self.db_path}.wal")
151
+ if wal_path.exists():
152
+ backup = wal_path.with_suffix(f".wal.corrupt-{int(_time.time())}")
153
+ # os.replace: Path.rename raises on win32 if the dest exists
154
+ # (same-second double recovery would collide on the timestamp).
155
+ os.replace(wal_path, backup)
156
+ logger.warning(f"Moved corrupt WAL to {backup.name} — some recent turns may be lost")
157
+ con = duckdb.connect(str(self.db_path))
158
+ con.execute("SELECT 1")
159
+ logger.info(f"Connected to shadow DB (WAL recovered): {self.db_path}")
160
+ return con
161
+ raise # no WAL to remove, re-raise
162
+
163
+ def _maybe_checkpoint(self):
164
+ """Checkpoint WAL if enough turns or time have elapsed. Call with _lock held."""
165
+ if self._con is None:
166
+ return
167
+ elapsed = time.monotonic() - self._last_checkpoint
168
+ if (self._turns_since_checkpoint >= self.CHECKPOINT_EVERY_TURNS
169
+ or elapsed >= self.CHECKPOINT_INTERVAL_SEC):
170
+ try:
171
+ self._con.execute("CHECKPOINT")
172
+ logger.debug(f"WAL checkpoint ({self._turns_since_checkpoint} turns, {elapsed:.0f}s)")
173
+ except Exception as e:
174
+ logger.warning(f"Checkpoint failed: {e}")
175
+ self._turns_since_checkpoint = 0
176
+ self._last_checkpoint = time.monotonic()
177
+
178
+ def _execute(self, sql: str, params: list = None) -> duckdb.DuckDBPyConnection:
179
+ """Execute SQL with thread lock. For fire-and-forget statements only.
180
+
181
+ WARNING: Do NOT chain .fetchone()/.fetchall() on the result — the lock
182
+ is released on return, so another thread can invalidate the cursor.
183
+ Use _fetch_one/_fetch_all/_fetch_columns instead.
184
+ """
185
+ with self._lock:
186
+ con = self._get_con()
187
+ if params:
188
+ return con.execute(sql, params)
189
+ return con.execute(sql)
190
+
191
+ def _fetch_one(self, sql: str, params: list = None) -> Optional[tuple]:
192
+ """Execute SQL and fetchone() atomically under the lock."""
193
+ with self._lock:
194
+ con = self._get_con()
195
+ result = con.execute(sql, params) if params else con.execute(sql)
196
+ return result.fetchone()
197
+
198
+ def _fetch_all(self, sql: str, params: list = None) -> list:
199
+ """Execute SQL and fetchall() atomically under the lock."""
200
+ with self._lock:
201
+ con = self._get_con()
202
+ result = con.execute(sql, params) if params else con.execute(sql)
203
+ return result.fetchall()
204
+
205
+ def _fetch_columns(self, sql: str, params: list = None) -> tuple[list[str], list]:
206
+ """Execute SQL and return (column_names, rows) atomically under the lock."""
207
+ with self._lock:
208
+ con = self._get_con()
209
+ result = con.execute(sql, params) if params else con.execute(sql)
210
+ columns = [desc[0] for desc in result.description]
211
+ return columns, result.fetchall()
212
+
213
+ # Hard deadline for caller-written SQL. Long enough that no honest query
214
+ # against this schema reaches it; short enough to bound shutdown.
215
+ ADHOC_TIMEOUT_SEC = 30.0
216
+
217
+ def fetch_columns_adhoc(self, sql: str, timeout: float = None) -> tuple[list[str], list]:
218
+ """Run CALLER-SUPPLIED SQL under a hard deadline, off the shared connection.
219
+
220
+ Every other query in this class is one we wrote, with a bounded shape.
221
+ Ad-hoc SQL is the only path where the user picks the query, so it is the
222
+ only one that can run unbounded — a `WITH RECURSIVE` typo is enough, and
223
+ it passes the endpoint's keyword validator because it is a plain SELECT.
224
+ Two deliberate deviations from `_fetch_columns`:
225
+
226
+ - **Its own cursor**, not the shared `_con` under `_lock`. A slow read
227
+ therefore cannot stall turn recording, and — the load-bearing part —
228
+ the watchdog below can only ever interrupt *this* query. Interrupting
229
+ the shared connection would race a turn write that acquired the lock
230
+ just as the deadline fired, and kill the write instead.
231
+ - **A watchdog that calls `interrupt()`** at the deadline. This is the
232
+ only thing that actually ends a runaway query: Python cannot kill a
233
+ thread, and DuckDB has no statement timeout. Without it an in-flight
234
+ request blocks uvicorn's graceful shutdown indefinitely, so SIGTERM
235
+ closes the listening socket but never exits — `pkill` appears to do
236
+ nothing and recovery needs `kill -9`.
237
+
238
+ `interrupt()` is cooperative (DuckDB polls a flag between operators), so
239
+ this is the best available mechanism rather than a guarantee.
240
+ """
241
+ with self._lock:
242
+ cursor = self._get_con().cursor()
243
+ watchdog = threading.Timer(timeout or self.ADHOC_TIMEOUT_SEC, cursor.interrupt)
244
+ watchdog.start()
245
+ try:
246
+ result = cursor.execute(sql)
247
+ columns = [desc[0] for desc in result.description]
248
+ return columns, result.fetchall()
249
+ finally:
250
+ watchdog.cancel()
251
+ cursor.close()
252
+
253
+ def _fetch_one_dict(self, sql: str, params: list = None) -> Optional[dict]:
254
+ """Execute SQL and return the first row as {column: value} (or None).
255
+
256
+ Prefer this over `_fetch_one` + `row[N]` indexing — positional access
257
+ breaks silently when columns are added/reordered.
258
+ """
259
+ columns, rows = self._fetch_columns(sql, params)
260
+ if not rows:
261
+ return None
262
+ return dict(zip(columns, rows[0]))
263
+
264
+ def _executemany(self, sql: str, params_list: list) -> None:
265
+ """Execute SQL with multiple parameter sets."""
266
+ with self._lock:
267
+ con = self._get_con()
268
+ con.executemany(sql, params_list)
269
+
270
+ # ═══════════════════════════════════════════════════════════════════
271
+ # Project identity (one project per local path; SHA256 of abspath)
272
+ # ═══════════════════════════════════════════════════════════════════
273
+
274
+ def get_project(self, project_id: str) -> Optional[dict]:
275
+ """Return project metadata as a dict, or None if not found."""
276
+ return self._fetch_one_dict(
277
+ "SELECT id, name, merged_into, created_at FROM projects WHERE id = ?",
278
+ [project_id]
279
+ )
280
+
281
+ def create_project(self, project_id: str, name: str) -> None:
282
+ """Insert a new project row keyed by the path-derived hash."""
283
+ self._execute(
284
+ "INSERT INTO projects (id, name) VALUES (?, ?) "
285
+ "ON CONFLICT (id) DO NOTHING",
286
+ [project_id, name]
287
+ )
288
+
289
+ def rename_project(self, project_id: str, name: str) -> None:
290
+ """Update the display name."""
291
+ self._execute(
292
+ "UPDATE projects SET name = ? WHERE id = ?", [name, project_id]
293
+ )
294
+
295
+ def merge_project(self, source: str, target: str) -> None:
296
+ """Fold source project into target. Reversible by setting
297
+ merged_into = NULL on the source row.
298
+
299
+ Reserved for the future merge widget — no callers today.
300
+ """
301
+ if source == target:
302
+ return
303
+ self._execute(
304
+ "UPDATE projects SET merged_into = ? WHERE id = ?", [target, source]
305
+ )
306
+
307
+ def list_projects(self, include_merged: bool = False) -> list[dict]:
308
+ """All projects with turn counts."""
309
+ sql = (
310
+ "SELECT p.id, p.name, p.merged_into, p.created_at, "
311
+ " (SELECT COUNT(*) FROM turns t WHERE t.project_hash = p.id) AS turn_count "
312
+ "FROM projects p"
313
+ )
314
+ if not include_merged:
315
+ sql += " WHERE p.merged_into IS NULL"
316
+ sql += " ORDER BY p.created_at DESC"
317
+ rows = self._fetch_all(sql)
318
+ return [
319
+ {
320
+ "id": r[0], "name": r[1], "merged_into": r[2],
321
+ "created_at": r[3], "turn_count": r[4],
322
+ }
323
+ for r in rows
324
+ ]
325
+
326
+ def close(self):
327
+ """Close the database connection, checkpointing WAL first."""
328
+ if self._con:
329
+ try:
330
+ self._con.execute("CHECKPOINT")
331
+ except Exception:
332
+ pass
333
+ self._con.close()
334
+ self._con = None
335
+ self._init_done = False
336
+
337
+ # ═══════════════════════════════════════════════════════════════════
338
+ # Session management
339
+ # ═══════════════════════════════════════════════════════════════════
340
+
341
+ def ensure_session(self, session_id: str, project_hash: str,
342
+ name: Optional[str] = None, model: Optional[str] = None,
343
+ git_repo_hash: Optional[str] = None,
344
+ provider: Optional[str] = None):
345
+ """Create session if it doesn't exist, update if it does."""
346
+ now = datetime.now(timezone.utc).isoformat()
347
+ self._execute(
348
+ """INSERT INTO sessions (id, project_hash, git_repo_hash, name, created_at, updated_at, model, provider)
349
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
350
+ ON CONFLICT (id) DO UPDATE SET
351
+ updated_at = EXCLUDED.updated_at,
352
+ name = COALESCE(EXCLUDED.name, sessions.name),
353
+ model = COALESCE(EXCLUDED.model, sessions.model),
354
+ git_repo_hash = COALESCE(EXCLUDED.git_repo_hash, sessions.git_repo_hash),
355
+ provider = COALESCE(EXCLUDED.provider, sessions.provider)""",
356
+ [session_id, project_hash, git_repo_hash, name, now, now, model,
357
+ provider] # None upserts to NULL only on a fresh row; COALESCE
358
+ # keeps an existing row's label (never downgrades it)
359
+ )
360
+
361
+ # ═══════════════════════════════════════════════════════════════════
362
+ # Turn lifecycle
363
+ # ═══════════════════════════════════════════════════════════════════
364
+
365
+ def start_turn(
366
+ self,
367
+ session_id: str,
368
+ project_hash: str,
369
+ user_prompt: str,
370
+ git_branch: Optional[str] = None,
371
+ shadow_branch: Optional[str] = None,
372
+ has_images: bool = False,
373
+ has_files: bool = False,
374
+ turn_number: Optional[int] = None,
375
+ git_repo_hash: Optional[str] = None,
376
+ is_plan: bool = False,
377
+ provider: Optional[str] = None,
378
+ ) -> str:
379
+ """Record turn start. Returns turn_id (UUID).
380
+
381
+ Called immediately when user sends a message - prompt is never lost.
382
+ """
383
+ turn_id = str(uuid.uuid4())
384
+ now = datetime.now(timezone.utc).isoformat()
385
+
386
+ self._execute(
387
+ """INSERT INTO turns (id, session_id, project_hash, git_repo_hash, turn_number, status,
388
+ user_prompt, has_images, has_files, started_at,
389
+ git_branch, shadow_branch, is_plan, provider)
390
+ VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)""",
391
+ [turn_id, session_id, project_hash, git_repo_hash, turn_number,
392
+ user_prompt, has_images, has_files, now,
393
+ git_branch, shadow_branch, is_plan,
394
+ provider or "claude"] # mirror the column's legacy DEFAULT
395
+ )
396
+
397
+ # Update session
398
+ self.ensure_session(session_id, project_hash, git_repo_hash=git_repo_hash,
399
+ provider=provider)
400
+
401
+ logger.debug(f"Turn started: {turn_id[:8]} session={session_id[:8]} branch={git_branch}")
402
+ return turn_id
403
+
404
+ def complete_turn(
405
+ self,
406
+ turn_id: str,
407
+ *,
408
+ # From result message
409
+ result_msg: Optional[dict] = None,
410
+ # From shadow git
411
+ git_hash: Optional[str] = None,
412
+ git_branch: Optional[str] = None,
413
+ shadow_branch: Optional[str] = None,
414
+ # From the summary fork
415
+ structured_data: Optional[dict] = None,
416
+ summary_cost: float = 0.0,
417
+ # Tracker data
418
+ modified_files: Optional[list[str]] = None,
419
+ tools_summary: Optional[dict[str, int]] = None,
420
+ # Model behind the main chat thread, captured live during the turn
421
+ # (TurnTracker.main_thread_model). Authoritative when present.
422
+ main_thread_model: Optional[str] = None,
423
+ # Override status
424
+ status: str = "completed",
425
+ error_message: Optional[str] = None,
426
+ ):
427
+ """Finalize a turn with result data.
428
+
429
+ Called after shadow git commit completes (has structured_data + git_hash).
430
+ """
431
+ now = datetime.now(timezone.utc).isoformat()
432
+
433
+ # Extract fields from result message
434
+ cost = 0.0
435
+ tokens_in = 0
436
+ tokens_out = 0
437
+ duration_ms = 0
438
+ duration_api_ms = 0
439
+ model = main_thread_model
440
+ provider_session_id = None
441
+ provider_turn_uuid = None
442
+ result_subtype = None
443
+ is_error = False
444
+ num_tool_loops = 0
445
+ service_tier = None
446
+ cache_read = 0
447
+ cache_creation = 0
448
+ web_search = 0
449
+ web_fetch = 0
450
+ permission_denials = None
451
+ model_usage_data = {}
452
+
453
+ if result_msg:
454
+ cost = result_msg.get("total_cost_usd", 0)
455
+ duration_ms = result_msg.get("duration_ms", 0)
456
+ duration_api_ms = result_msg.get("duration_api_ms", 0)
457
+ num_tool_loops = result_msg.get("num_turns", 0)
458
+ result_subtype = result_msg.get("subtype")
459
+ is_error = result_msg.get("is_error", False)
460
+ provider_session_id = result_msg.get("session_id")
461
+ provider_turn_uuid = result_msg.get("uuid")
462
+ permission_denials = result_msg.get("permission_denials") or None
463
+
464
+ usage = result_msg.get("usage", {})
465
+ tokens_in = usage.get("input_tokens", 0)
466
+ tokens_out = usage.get("output_tokens", 0)
467
+ cache_read = usage.get("cache_read_input_tokens", 0)
468
+ cache_creation = usage.get("cache_creation_input_tokens", 0)
469
+ service_tier = usage.get("service_tier")
470
+
471
+ model_usage_data = result_msg.get("modelUsage", {})
472
+ if model_usage_data:
473
+ # Cost is only a fallback: modelUsage aggregates Task-tool
474
+ # subagents with the main thread, so the highest spender is
475
+ # often a subagent, not the model that answered. Prefer the
476
+ # main-thread model captured live during the turn.
477
+ if not model:
478
+ model = max(model_usage_data.keys(),
479
+ key=lambda m: model_usage_data[m].get("costUSD", 0))
480
+ # Aggregate web search/fetch across models
481
+ for mu in model_usage_data.values():
482
+ web_search += mu.get("webSearchRequests", 0)
483
+ web_fetch += mu.get("webFetchRequests", 0)
484
+
485
+ if is_error and not error_message:
486
+ # Try to extract error from result text
487
+ error_message = result_msg.get("result", "")[:500] if result_msg else None
488
+ status = "failed"
489
+
490
+ # Update the turn
491
+ self._execute(
492
+ """UPDATE turns SET
493
+ status = ?,
494
+ completed_at = ?,
495
+ git_hash = ?,
496
+ git_branch = COALESCE(?, git_branch),
497
+ shadow_branch = COALESCE(?, shadow_branch),
498
+ cost = ?,
499
+ summary_cost = ?,
500
+ tokens_in = ?,
501
+ tokens_out = ?,
502
+ duration_ms = ?,
503
+ model = ?,
504
+ provider_session_id = ?,
505
+ provider_turn_uuid = ?,
506
+ result_subtype = ?,
507
+ is_error = ?,
508
+ num_tool_loops = ?,
509
+ duration_api_ms = ?,
510
+ service_tier = ?,
511
+ cache_read_tokens = ?,
512
+ cache_creation_tokens = ?,
513
+ web_search_count = ?,
514
+ web_fetch_count = ?,
515
+ error_message = ?,
516
+ permission_denials = ?
517
+ WHERE id = ?""",
518
+ [status, now, git_hash, git_branch, shadow_branch, cost, summary_cost,
519
+ tokens_in, tokens_out, duration_ms, model,
520
+ provider_session_id, provider_turn_uuid, result_subtype,
521
+ is_error, num_tool_loops, duration_api_ms, service_tier,
522
+ cache_read, cache_creation, web_search, web_fetch,
523
+ error_message, permission_denials, turn_id]
524
+ )
525
+
526
+ # Insert per-model usage
527
+ if model_usage_data:
528
+ for model_id, mu in model_usage_data.items():
529
+ self._execute(
530
+ """INSERT INTO turn_model_usage
531
+ (id, turn_id, model_id, model_name, input_tokens, output_tokens,
532
+ cache_read_tokens, cache_creation_tokens, cost, context_window, web_search_count)
533
+ VALUES (nextval('seq_model_usage'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
534
+ ON CONFLICT (turn_id, model_id) DO NOTHING""",
535
+ [turn_id, model_id, short_model_name(model_id),
536
+ mu.get("inputTokens", 0), mu.get("outputTokens", 0),
537
+ mu.get("cacheReadInputTokens", 0), mu.get("cacheCreationInputTokens", 0),
538
+ mu.get("costUSD", 0), mu.get("contextWindow"),
539
+ mu.get("webSearchRequests", 0)]
540
+ )
541
+
542
+ # Insert structured fields from the summary fork
543
+ if structured_data:
544
+ self._insert_structured_fields(turn_id, structured_data)
545
+
546
+ # Insert files
547
+ if modified_files:
548
+ for fp in modified_files:
549
+ self._execute(
550
+ """INSERT INTO turn_files (id, turn_id, file_path, change_type)
551
+ VALUES (nextval('seq_turn_files'), ?, ?, 'modified')
552
+ ON CONFLICT (turn_id, file_path) DO NOTHING""",
553
+ [turn_id, fp]
554
+ )
555
+
556
+ # Insert tools
557
+ if tools_summary:
558
+ for tool_name, count in tools_summary.items():
559
+ self._execute(
560
+ """INSERT INTO turn_tools (id, turn_id, tool_name, call_count)
561
+ VALUES (nextval('seq_turn_tools'), ?, ?, ?)
562
+ ON CONFLICT (turn_id, tool_name) DO NOTHING""",
563
+ [turn_id, tool_name, count]
564
+ )
565
+
566
+ # Plan detection: if EnterPlanMode/ExitPlanMode used, create plan record
567
+ if tools_summary and {'EnterPlanMode', 'ExitPlanMode'} & set(tools_summary.keys()):
568
+ self._execute("UPDATE turns SET is_plan = true WHERE id = ?", [turn_id])
569
+ self._create_plan_from_turn(turn_id, modified_files, structured_data)
570
+ else:
571
+ # Non-plan turn: link to active plan in same session as implementation
572
+ self._link_turn_to_plan(turn_id)
573
+
574
+ # Update session aggregates
575
+ self._execute(
576
+ """UPDATE sessions SET
577
+ turn_count = turn_count + 1,
578
+ total_cost = total_cost + ?,
579
+ updated_at = ?
580
+ WHERE id = (SELECT session_id FROM turns WHERE id = ?)""",
581
+ [cost, now, turn_id]
582
+ )
583
+
584
+ self._fts_dirty = True # FTS indexes need rebuild
585
+
586
+ logger.debug(f"Turn completed: {turn_id[:8]} git={git_hash[:8] if git_hash else 'none'} "
587
+ f"cost=${cost:.4f}")
588
+
589
+ # Periodic WAL checkpoint
590
+ with self._lock:
591
+ self._turns_since_checkpoint += 1
592
+ self._maybe_checkpoint()
593
+
594
+ def _insert_structured_fields(self, turn_id: str, data: dict):
595
+ """Insert summary-fork structured output as turn_fields rows."""
596
+ # Known non-field keys to skip
597
+ skip_keys = {"session_title", "tags"}
598
+
599
+ for key, value in data.items():
600
+ if key in skip_keys:
601
+ continue
602
+
603
+ value_text = None
604
+ value_list = None
605
+ search_text = ""
606
+
607
+ if isinstance(value, list):
608
+ value_list = value
609
+ search_text = " ".join(str(v) for v in value)
610
+ elif isinstance(value, str):
611
+ value_text = value
612
+ search_text = value
613
+ else:
614
+ value_text = str(value)
615
+ search_text = value_text
616
+
617
+ self._execute(
618
+ """INSERT INTO turn_fields (id, turn_id, field_key, value_text, value_list, search_text)
619
+ VALUES (nextval('seq_turn_fields'), ?, ?, ?, ?, ?)
620
+ ON CONFLICT (turn_id, field_key) DO UPDATE SET
621
+ value_text = EXCLUDED.value_text,
622
+ value_list = EXCLUDED.value_list,
623
+ search_text = EXCLUDED.search_text""",
624
+ [turn_id, key, value_text, value_list, search_text]
625
+ )
626
+
627
+ # Insert tags if present
628
+ tags = data.get("tags", [])
629
+ if isinstance(tags, list):
630
+ for tag in tags:
631
+ if isinstance(tag, str) and tag.strip():
632
+ tag_clean = tag.strip().lstrip("#").lower()
633
+ self._execute(
634
+ """INSERT INTO tags (id, turn_id, tag, source)
635
+ VALUES (nextval('seq_tags'), ?, ?, 'auto')
636
+ ON CONFLICT (turn_id, tag) DO NOTHING""",
637
+ [turn_id, tag_clean]
638
+ )
639
+
640
+ def fail_turn(self, turn_id: str, error_message: str = ""):
641
+ """Mark a turn as failed (crash, abort, etc.)."""
642
+ now = datetime.now(timezone.utc).isoformat()
643
+ self._execute(
644
+ """UPDATE turns SET status = 'failed', completed_at = ?, error_message = ?
645
+ WHERE id = ? AND status = 'pending'""",
646
+ [now, error_message, turn_id]
647
+ )
648
+
649
+ # ═══════════════════════════════════════════════════════════════════
650
+ # Tags (user-editable)
651
+ # ═══════════════════════════════════════════════════════════════════
652
+
653
+ def add_tag(self, turn_id: str, tag: str, source: str = "manual"):
654
+ """Add a tag to a turn."""
655
+ tag = tag.strip().lstrip("#").lower()
656
+ self._execute(
657
+ """INSERT INTO tags (id, turn_id, tag, source)
658
+ VALUES (nextval('seq_tags'), ?, ?, ?)
659
+ ON CONFLICT (turn_id, tag) DO NOTHING""",
660
+ [turn_id, tag, source]
661
+ )
662
+
663
+ def remove_tag(self, turn_id: str, tag: str):
664
+ """Remove a tag from a turn."""
665
+ self._execute("DELETE FROM tags WHERE turn_id = ? AND tag = ?", [turn_id, tag])
666
+
667
+ def rename_tag(self, old_tag: str, new_tag: str):
668
+ """Rename a tag globally."""
669
+ new_tag = new_tag.strip().lstrip("#").lower()
670
+ self._execute("UPDATE tags SET tag = ? WHERE tag = ?", [new_tag, old_tag])
671
+
672
+ # ═══════════════════════════════════════════════════════════════════
673
+ # Async wrappers (for use from async code)
674
+ # ═══════════════════════════════════════════════════════════════════
675
+
676
+ async def astart_turn(self, **kwargs) -> str:
677
+ """Async wrapper for start_turn."""
678
+ return await asyncio.to_thread(self.start_turn, **kwargs)
679
+
680
+ # ═══════════════════════════════════════════════════════════════════
681
+ # File review acceptances
682
+ # ═══════════════════════════════════════════════════════════════════
683
+
684
+ def mark_file_reviewed(
685
+ self,
686
+ session_id: str,
687
+ project_hash: str,
688
+ file_path: str,
689
+ shadow_commit: str,
690
+ status: str = "reviewed",
691
+ note: Optional[str] = None,
692
+ ) -> str:
693
+ """Upsert a file acceptance record. Returns acceptance ID."""
694
+ acceptance_id = str(uuid.uuid4())
695
+ now = datetime.now(timezone.utc).isoformat()
696
+ self._execute(
697
+ """INSERT INTO file_acceptances (id, session_id, project_hash, file_path, shadow_commit, status, note, created_at, updated_at)
698
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
699
+ ON CONFLICT (session_id, file_path) DO UPDATE SET
700
+ shadow_commit = EXCLUDED.shadow_commit,
701
+ status = EXCLUDED.status,
702
+ note = EXCLUDED.note,
703
+ updated_at = EXCLUDED.updated_at""",
704
+ [acceptance_id, session_id, project_hash, file_path, shadow_commit, status, note, now, now]
705
+ )
706
+ return acceptance_id
707
+
708
+ def mark_files_reviewed(
709
+ self,
710
+ session_id: str,
711
+ project_hash: str,
712
+ file_paths: list[str],
713
+ shadow_commit: str,
714
+ ):
715
+ """Batch mark files as reviewed."""
716
+ now = datetime.now(timezone.utc).isoformat()
717
+ rows = [
718
+ (str(uuid.uuid4()), session_id, project_hash, fp, shadow_commit, "reviewed", None, now, now)
719
+ for fp in file_paths
720
+ ]
721
+ self._executemany(
722
+ """INSERT INTO file_acceptances (id, session_id, project_hash, file_path, shadow_commit, status, note, created_at, updated_at)
723
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
724
+ ON CONFLICT (session_id, file_path) DO UPDATE SET
725
+ shadow_commit = EXCLUDED.shadow_commit,
726
+ status = EXCLUDED.status,
727
+ note = EXCLUDED.note,
728
+ updated_at = EXCLUDED.updated_at""",
729
+ rows
730
+ )
731
+
732
+ def get_file_acceptances(self, session_id: str) -> list[dict]:
733
+ """Get all file acceptances for a session."""
734
+ rows = self._fetch_all(
735
+ "SELECT id, file_path, shadow_commit, status, note, created_at, updated_at FROM file_acceptances WHERE session_id = ? ORDER BY file_path",
736
+ [session_id]
737
+ )
738
+ return [
739
+ {"id": r[0], "file_path": r[1], "shadow_commit": r[2], "status": r[3], "note": r[4], "created_at": r[5], "updated_at": r[6]}
740
+ for r in rows
741
+ ]
742
+
743
+ def delete_file_acceptance(self, session_id: str, file_path: str):
744
+ """Remove a file acceptance (undo review)."""
745
+ self._execute(
746
+ "DELETE FROM file_acceptances WHERE session_id = ? AND file_path = ?",
747
+ [session_id, file_path]
748
+ )
749
+
750
+ def get_recent_files(
751
+ self,
752
+ *,
753
+ project_hash: Optional[str] = None,
754
+ git_repo_hash: Optional[str] = None,
755
+ limit: int = 100,
756
+ ) -> list[dict]:
757
+ """Files recently touched in a project/repo, newest first.
758
+
759
+ Aggregates `turn_files` joined to `turns`, returning one row per
760
+ unique `file_path` with last-touched timestamp and touch count.
761
+ """
762
+ conditions = []
763
+ params: list = []
764
+ if git_repo_hash:
765
+ conditions.append("t.git_repo_hash = ?")
766
+ params.append(git_repo_hash)
767
+ elif project_hash:
768
+ conditions.append("t.project_hash = ?")
769
+ params.append(project_hash)
770
+ else:
771
+ return []
772
+
773
+ where = " AND ".join(conditions)
774
+ rows = self._fetch_all(
775
+ f"""SELECT tf.file_path,
776
+ COUNT(DISTINCT tf.turn_id) AS touch_count,
777
+ MAX(COALESCE(t.completed_at, t.started_at)) AS last_touched_at
778
+ FROM turn_files tf
779
+ JOIN turns t ON tf.turn_id = t.id
780
+ WHERE {where}
781
+ GROUP BY tf.file_path
782
+ ORDER BY last_touched_at DESC
783
+ LIMIT ?""",
784
+ params + [limit],
785
+ )
786
+ return [
787
+ {
788
+ "path": r[0],
789
+ "touch_count": int(r[1]) if r[1] is not None else 0,
790
+ "last_touched_at": str(r[2]) if r[2] is not None else None,
791
+ }
792
+ for r in rows
793
+ ]
794
+
795
+ async def acomplete_turn(self, turn_id: str, **kwargs):
796
+ """Async wrapper for complete_turn."""
797
+ return await asyncio.to_thread(self.complete_turn, turn_id, **kwargs)
798
+
799
+ async def afail_turn(self, turn_id: str, error_message: str = ""):
800
+ """Async wrapper for fail_turn."""
801
+ return await asyncio.to_thread(self.fail_turn, turn_id, error_message)
802
+
803
+
804
+ # ═══════════════════════════════════════════════════════════════════════════
805
+ # Singleton
806
+ # ═══════════════════════════════════════════════════════════════════════════
807
+
808
+ _instance: Optional[ShadowDB] = None
809
+ _instance_lock = threading.Lock()
810
+
811
+
812
+ def init_shadow_db(db_path: Path) -> ShadowDB:
813
+ """Initialize the global ShadowDB with a custom path. Must be called before get_shadow_db()."""
814
+ global _instance
815
+ with _instance_lock:
816
+ if _instance is not None:
817
+ logger.warning(f"Re-initializing ShadowDB (was {_instance.db_path}, now {db_path})")
818
+ _instance.close()
819
+ _instance = ShadowDB(db_path=db_path)
820
+ return _instance
821
+
822
+
823
+ def get_shadow_db() -> ShadowDB:
824
+ """Get the global ShadowDB instance (creates on first call)."""
825
+ global _instance
826
+ if _instance is None:
827
+ with _instance_lock:
828
+ if _instance is None:
829
+ _instance = ShadowDB()
830
+ return _instance