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,879 @@
1
+ """Session Storage V2: directory-based, per-project.
2
+
3
+ The core class lives here; the public-API facade is in session_store.py.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import secrets
10
+ import shutil
11
+ import tempfile
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional, Iterator
15
+
16
+ from painapple_code import paths
17
+
18
+ logger = logging.getLogger("painapple-code.store")
19
+
20
+
21
+ class SessionStoreV2:
22
+ """
23
+ Directory-based session storage.
24
+
25
+ Each session is a directory containing:
26
+ - meta.json: Small metadata file (id, name, cwd, cost, timestamps)
27
+ - messages.jsonl: Append-only message log (one JSON per line)
28
+ - tools/: Directory for tool output files
29
+
30
+ Storage location: ~/.painapple-code/projects/{project-hash}/sessions/
31
+ """
32
+
33
+ # Tool output size threshold - outputs larger than this go to separate files
34
+ TOOL_OUTPUT_THRESHOLD = 500 # chars
35
+
36
+ def __init__(self, project_path: str):
37
+ """
38
+ Initialize session store.
39
+
40
+ Args:
41
+ project_path: Project directory (cwd). Sessions are stored
42
+ in ~/.painapple-code/projects/{hash}/sessions/.
43
+ """
44
+ self.project_path = project_path
45
+ paths.ensure_project_dir(project_path)
46
+ self.base_dir = paths.get_sessions_dir(project_path)
47
+ self.base_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ @staticmethod
50
+ def generate_id() -> str:
51
+ """Generate a short unique session ID."""
52
+ return secrets.token_urlsafe(8) # ~11 chars, URL-safe
53
+
54
+ def _session_dir(self, session_id: str) -> Path:
55
+ """Get the directory path for a session."""
56
+ return self.base_dir / session_id
57
+
58
+ def _meta_path(self, session_id: str) -> Path:
59
+ """Get the meta.json path for a session."""
60
+ return self._session_dir(session_id) / "meta.json"
61
+
62
+ def _messages_path(self, session_id: str) -> Path:
63
+ """Get the messages.jsonl path for a session."""
64
+ return self._session_dir(session_id) / "messages.jsonl"
65
+
66
+ def _raw_log_path(self, session_id: str) -> Path:
67
+ """Get the raw.jsonl path for a session (raw Claude output log)."""
68
+ return self._session_dir(session_id) / "raw.jsonl"
69
+
70
+ def _tools_dir(self, session_id: str) -> Path:
71
+ """Get the tools/ directory path for a session."""
72
+ return self._session_dir(session_id) / "tools"
73
+
74
+ def _uploads_dir(self, session_id: str) -> Path:
75
+ """Get the uploads/ directory path for a session."""
76
+ return self._session_dir(session_id) / "uploads"
77
+
78
+ def _stash_path(self, session_id: str) -> Path:
79
+ """Get the stash.json path for a session."""
80
+ return self._session_dir(session_id) / "stash.json"
81
+
82
+ def _tool_filename(self, tool_name: str, tool_id: str) -> str:
83
+ """Generate filename for a tool output file."""
84
+ # Strip 'toolu_' prefix if present
85
+ suffix = tool_id.replace("toolu_", "") if tool_id.startswith("toolu_") else tool_id
86
+ # Sanitize tool name (remove any problematic chars)
87
+ safe_name = "".join(c for c in tool_name if c.isalnum() or c in "-_")
88
+ return f"{safe_name}_{suffix}.txt"
89
+
90
+ # ─────────────────────────────────────────────────────────────────
91
+ # Session CRUD
92
+ # ─────────────────────────────────────────────────────────────────
93
+
94
+ def create_pending(self, cwd: str, name: str = None) -> dict:
95
+ """
96
+ Create session metadata WITHOUT persisting to disk.
97
+ Use persist() to actually write to disk when first message arrives.
98
+ This avoids cluttering history with empty sessions.
99
+ """
100
+ session_id = self.generate_id()
101
+ now = datetime.now(timezone.utc).isoformat()
102
+
103
+ meta = {
104
+ "id": session_id,
105
+ "name": name or (Path(cwd).name if cwd else "") or "New Session",
106
+ "cwd": cwd,
107
+ "provider": None, # None → resolved to the box-wide default at launch
108
+ "provider_session_id": None,
109
+ "model": None,
110
+ "total_cost": 0,
111
+ "message_count": 0,
112
+ "slash_commands": [],
113
+ "created_at": now,
114
+ "last_activity": now,
115
+ "_pending": True, # Flag indicating not yet persisted
116
+ }
117
+
118
+ # Include project hash for reverse lookup (new storage format)
119
+ if self.project_path:
120
+ meta["project_hash"] = paths.get_project_hash(self.project_path)
121
+
122
+ return meta
123
+
124
+ def persist(self, meta: dict) -> dict:
125
+ """
126
+ Persist a pending session to disk.
127
+ Call this when first message arrives or Claude sends init.
128
+ Returns the meta dict with _pending flag removed.
129
+ """
130
+ if not meta.get("_pending"):
131
+ return meta # Already persisted
132
+
133
+ session_id = meta["id"]
134
+ session_dir = self._session_dir(session_id)
135
+
136
+ # Create directory structure
137
+ session_dir.mkdir(parents=True, exist_ok=True)
138
+ self._tools_dir(session_id).mkdir(exist_ok=True)
139
+
140
+ # Remove pending flag and write
141
+ meta = {k: v for k, v in meta.items() if k != "_pending"}
142
+ self._write_meta(session_id, meta)
143
+
144
+ # Create empty log files
145
+ self._messages_path(session_id).touch()
146
+ self._raw_log_path(session_id).touch()
147
+
148
+ logger.info(f"Persisted session directory: {session_id}")
149
+ return meta
150
+
151
+ def create(self, cwd: str, name: str = None) -> dict:
152
+ """Create a new session with directory structure (immediate persistence)."""
153
+ session_id = self.generate_id()
154
+ session_dir = self._session_dir(session_id)
155
+
156
+ # Create directory structure
157
+ session_dir.mkdir(parents=True, exist_ok=True)
158
+ self._tools_dir(session_id).mkdir(exist_ok=True)
159
+
160
+ # Initialize metadata
161
+ now = datetime.now(timezone.utc).isoformat()
162
+ meta = {
163
+ "id": session_id,
164
+ "name": name or (Path(cwd).name if cwd else "") or "New Session",
165
+ "cwd": cwd,
166
+ "provider_session_id": None,
167
+ "model": None,
168
+ "total_cost": 0,
169
+ "message_count": 0,
170
+ "slash_commands": [], # Claude slash commands for autocomplete
171
+ "created_at": now,
172
+ "last_activity": now,
173
+ }
174
+
175
+ # Include project hash for reverse lookup (new storage format)
176
+ if self.project_path:
177
+ meta["project_hash"] = paths.get_project_hash(self.project_path)
178
+
179
+ # Write meta.json
180
+ self._write_meta(session_id, meta)
181
+
182
+ # Create empty messages.jsonl and raw.jsonl
183
+ self._messages_path(session_id).touch()
184
+ self._raw_log_path(session_id).touch()
185
+
186
+ logger.info(f"Created session directory: {session_id}")
187
+ return meta
188
+
189
+ def exists(self, session_id: str) -> bool:
190
+ """Check if a session exists."""
191
+ return self._meta_path(session_id).exists()
192
+
193
+ def load_meta(self, session_id: str) -> Optional[dict]:
194
+ """Load only session metadata (fast, for listings).
195
+
196
+ If meta.json is empty/corrupt (e.g. from disk-full), attempts to
197
+ reconstruct minimal metadata from raw.jsonl and re-persist it.
198
+ """
199
+ meta_path = self._meta_path(session_id)
200
+ if not meta_path.exists():
201
+ return None
202
+ try:
203
+ text = meta_path.read_text(encoding="utf-8")
204
+ if not text.strip():
205
+ raise ValueError("empty meta.json")
206
+ meta = json.loads(text)
207
+ # Back-compat: the provider conversation-id key was claude_session_id
208
+ # before the provider-neutral rename. Normalize on read so existing
209
+ # sessions still resume; the old key is dropped on the next write.
210
+ if "claude_session_id" in meta and "provider_session_id" not in meta:
211
+ meta["provider_session_id"] = meta.pop("claude_session_id")
212
+ return meta
213
+ except Exception as e:
214
+ logger.warning(f"Corrupt meta for {session_id}: {e}, attempting recovery")
215
+ meta = self._recover_meta(session_id)
216
+ if meta:
217
+ self._write_meta(session_id, meta)
218
+ logger.info(f"Recovered meta for {session_id} from raw.jsonl")
219
+ return meta
220
+
221
+ def load_full(self, session_id: str) -> Optional[dict]:
222
+ """Load full session data including all messages (for restore)."""
223
+ meta = self.load_meta(session_id)
224
+ if not meta:
225
+ return None
226
+
227
+ # Load messages from JSONL
228
+ messages = list(self._read_messages(session_id))
229
+
230
+ # Hydrate tool outputs from files
231
+ for msg in messages:
232
+ self._hydrate_tool_output(session_id, msg)
233
+
234
+ return {**meta, "messages": messages}
235
+
236
+ def _write_meta(self, session_id: str, meta: dict):
237
+ """Write metadata to meta.json atomically (temp file + rename).
238
+
239
+ Avoids data loss on disk-full: writes to temp file first, then
240
+ atomic rename. If write fails, the original meta.json is untouched.
241
+ """
242
+ meta_path = self._meta_path(session_id)
243
+ tmp_path = None
244
+ try:
245
+ data = json.dumps(meta, indent=2).encode()
246
+ fd, tmp_path = tempfile.mkstemp(
247
+ dir=meta_path.parent, prefix=".meta_", suffix=".tmp"
248
+ )
249
+ try:
250
+ os.write(fd, data)
251
+ os.fsync(fd)
252
+ finally:
253
+ os.close(fd)
254
+ os.replace(tmp_path, meta_path)
255
+ except Exception as e:
256
+ logger.error(f"Failed to write meta for {session_id}: {e}")
257
+ if tmp_path:
258
+ try:
259
+ os.unlink(tmp_path)
260
+ except OSError:
261
+ pass
262
+
263
+ def _recover_meta(self, session_id: str) -> Optional[dict]:
264
+ """Reconstruct minimal meta from raw.jsonl when meta.json is corrupt."""
265
+ raw_path = self._raw_log_path(session_id)
266
+ if not raw_path.exists() or raw_path.stat().st_size == 0:
267
+ logger.warning(f"Cannot recover {session_id}: no raw.jsonl")
268
+ return None
269
+
270
+ try:
271
+ cwd = None
272
+ provider_session_id = None
273
+ model = None
274
+ first_ts = None
275
+ last_ts = None
276
+ msg_count = 0
277
+
278
+ with open(raw_path, encoding="utf-8") as f:
279
+ for line in f:
280
+ line = line.strip()
281
+ if not line:
282
+ continue
283
+ try:
284
+ entry = json.loads(line)
285
+ except json.JSONDecodeError:
286
+ continue
287
+
288
+ ts = entry.get("ts")
289
+ if ts and not first_ts:
290
+ first_ts = ts
291
+ if ts:
292
+ last_ts = ts
293
+
294
+ # Extract init data
295
+ if entry.get("subtype") == "init" and entry.get("data"):
296
+ try:
297
+ init = json.loads(entry["data"])
298
+ cwd = cwd or init.get("cwd")
299
+ provider_session_id = provider_session_id or init.get("session_id")
300
+ model = model or init.get("model")
301
+ except json.JSONDecodeError:
302
+ pass
303
+
304
+ # Count user messages
305
+ if entry.get("dir") == "in" and entry.get("type") == "user":
306
+ msg_count += 1
307
+
308
+ if not cwd:
309
+ logger.warning(f"Cannot recover {session_id}: no cwd in raw.jsonl")
310
+ return None
311
+
312
+ now = datetime.now(timezone.utc).isoformat()
313
+ meta = {
314
+ "id": session_id,
315
+ "name": (Path(cwd).name if cwd else "") or "Recovered Session",
316
+ "cwd": cwd,
317
+ "provider_session_id": provider_session_id,
318
+ "model": model,
319
+ "total_cost": 0,
320
+ "message_count": msg_count,
321
+ "slash_commands": [],
322
+ "created_at": first_ts or now,
323
+ "last_activity": last_ts or now,
324
+ "_recovered": True,
325
+ }
326
+
327
+ if self.project_path:
328
+ meta["project_hash"] = paths.get_project_hash(self.project_path)
329
+
330
+ return meta
331
+
332
+ except Exception as e:
333
+ logger.error(f"Recovery failed for {session_id}: {e}")
334
+ return None
335
+
336
+ def update_meta(self, session_id: str, **kwargs):
337
+ """Update specific metadata fields (allows adding new fields)."""
338
+ meta = self.load_meta(session_id)
339
+ if meta:
340
+ # Allow known fields to be added/updated
341
+ allowed_fields = {
342
+ "name", "description", "cwd", "provider_session_id", "model",
343
+ "total_cost", "message_count", "slash_commands", "max_thinking_tokens",
344
+ "token_profile", "preferred_model", "permission_level",
345
+ "effort_level", "provider",
346
+ }
347
+ for key, value in kwargs.items():
348
+ if key in meta or key in allowed_fields:
349
+ meta[key] = value
350
+ meta["last_activity"] = datetime.now(timezone.utc).isoformat()
351
+ self._write_meta(session_id, meta)
352
+
353
+ def delete(self, session_id: str) -> bool:
354
+ """Delete a session and all its data."""
355
+ session_dir = self._session_dir(session_id)
356
+ if session_dir.exists():
357
+ shutil.rmtree(session_dir)
358
+ logger.info(f"Deleted session: {session_id}")
359
+ return True
360
+ return False
361
+
362
+ def list_all(self) -> list[dict]:
363
+ """List all sessions (metadata only, sorted by last_activity)."""
364
+ sessions = []
365
+
366
+ for entry in self.base_dir.iterdir():
367
+ if entry.is_dir() and (entry / "meta.json").exists():
368
+ meta = self.load_meta(entry.name)
369
+ if meta:
370
+ sessions.append(meta)
371
+
372
+ # Sort by last_activity descending
373
+ sessions.sort(key=lambda s: s.get("last_activity", ""), reverse=True)
374
+ return sessions
375
+
376
+ # ─────────────────────────────────────────────────────────────────
377
+ # Message Storage (append-only JSONL)
378
+ # ─────────────────────────────────────────────────────────────────
379
+
380
+ def _read_messages(self, session_id: str) -> Iterator[dict]:
381
+ """Read all messages from JSONL file."""
382
+ messages_path = self._messages_path(session_id)
383
+ if not messages_path.exists():
384
+ return
385
+
386
+ try:
387
+ with open(messages_path, 'r', encoding="utf-8") as f:
388
+ for line_num, line in enumerate(f, 1):
389
+ line = line.strip()
390
+ if line:
391
+ try:
392
+ yield json.loads(line)
393
+ except json.JSONDecodeError as e:
394
+ logger.warning(f"Skipping invalid JSON at line {line_num} in {session_id}: {e}")
395
+ except Exception as e:
396
+ logger.error(f"Failed to read messages for {session_id}: {e}")
397
+
398
+ def _append_message(self, session_id: str, message: dict) -> int:
399
+ """Append a single message to JSONL file. Returns new message count."""
400
+ messages_path = self._messages_path(session_id)
401
+ try:
402
+ with open(messages_path, 'a', encoding="utf-8") as f:
403
+ f.write(json.dumps(message) + '\n')
404
+
405
+ # Update message count in meta
406
+ meta = self.load_meta(session_id)
407
+ if meta:
408
+ new_count = meta.get("message_count", 0) + 1
409
+ meta["message_count"] = new_count
410
+ meta["last_activity"] = datetime.now(timezone.utc).isoformat()
411
+ self._write_meta(session_id, meta)
412
+ return new_count
413
+
414
+ except Exception as e:
415
+ logger.error(f"Failed to append message to {session_id}: {e}")
416
+ return -1
417
+
418
+ def add_message(self, session_id: str, message: dict) -> int:
419
+ """
420
+ Add a message to the session.
421
+
422
+ Returns:
423
+ Line number (1-indexed) of the added message, or -1 on failure
424
+ """
425
+ # Add timestamp if not present
426
+ if "timestamp" not in message:
427
+ message["timestamp"] = datetime.now(timezone.utc).isoformat()
428
+
429
+ return self._append_message(session_id, message)
430
+
431
+ # ─────────────────────────────────────────────────────────────────
432
+ # Raw Output Logging (append-only, captures everything from Claude)
433
+ # ─────────────────────────────────────────────────────────────────
434
+
435
+ def log_raw(self, session_id: str, direction: str, data: str, parsed: dict = None):
436
+ """
437
+ Log raw Claude I/O to raw.jsonl for debugging/audit.
438
+
439
+ Args:
440
+ session_id: Session ID
441
+ direction: "in" (to Claude), "out" (from Claude), or "event" (lifecycle events)
442
+ data: Raw string data
443
+ parsed: Parsed JSON if available (for type extraction)
444
+ """
445
+ raw_path = self._raw_log_path(session_id)
446
+ if not raw_path.exists():
447
+ return # Session doesn't exist or not initialized
448
+
449
+ entry = {
450
+ "ts": datetime.now(timezone.utc).isoformat(),
451
+ "dir": direction,
452
+ }
453
+
454
+ # Add type info if parsed
455
+ if parsed and isinstance(parsed, dict):
456
+ entry["type"] = parsed.get("type", "unknown")
457
+ if parsed.get("subtype"):
458
+ entry["subtype"] = parsed["subtype"]
459
+
460
+ # Handle different directions
461
+ if direction == "out":
462
+ # Claude output - include raw data (truncated if huge)
463
+ if len(data) > 10000:
464
+ entry["data"] = data[:10000] + f"...[truncated, total {len(data)} chars]"
465
+ entry["truncated"] = True
466
+ else:
467
+ entry["data"] = data
468
+ elif direction == "in":
469
+ # Input to Claude - just log size (content in messages.jsonl)
470
+ entry["size"] = len(data)
471
+ elif direction == "event":
472
+ # Lifecycle events (process start/stop, etc.)
473
+ entry["event"] = data
474
+ else:
475
+ entry["data"] = data
476
+
477
+ try:
478
+ with open(raw_path, 'a', encoding="utf-8") as f:
479
+ f.write(json.dumps(entry) + '\n')
480
+ except Exception as e:
481
+ logger.error(f"Failed to write raw log for {session_id}: {e}")
482
+
483
+ def log_raw_error(self, session_id: str, error: str, context: str = None):
484
+ """Log an error/anomaly to raw.jsonl."""
485
+ raw_path = self._raw_log_path(session_id)
486
+ if not raw_path.exists():
487
+ return
488
+
489
+ entry = {
490
+ "ts": datetime.now(timezone.utc).isoformat(),
491
+ "dir": "error",
492
+ "error": error,
493
+ }
494
+ if context:
495
+ entry["context"] = context[:1000] # Truncate context
496
+
497
+ try:
498
+ with open(raw_path, 'a', encoding="utf-8") as f:
499
+ f.write(json.dumps(entry) + '\n')
500
+ except Exception as e:
501
+ logger.error(f"Failed to write raw error log for {session_id}: {e}")
502
+
503
+ # ─────────────────────────────────────────────────────────────────
504
+ # Tool Output Storage (separate files for large outputs)
505
+ # ─────────────────────────────────────────────────────────────────
506
+
507
+ def _save_tool_output(self, session_id: str, tool_name: str, tool_id: str, output: str) -> str:
508
+ """
509
+ Save tool output to a separate file.
510
+ Returns the filename for reference in the message.
511
+ """
512
+ tools_dir = self._tools_dir(session_id)
513
+ tools_dir.mkdir(exist_ok=True)
514
+
515
+ filename = self._tool_filename(tool_name, tool_id)
516
+ filepath = tools_dir / filename
517
+
518
+ try:
519
+ filepath.write_text(output, encoding="utf-8")
520
+ logger.debug(f"Saved tool output: {filename} ({len(output)} chars)")
521
+ except Exception as e:
522
+ logger.error(f"Failed to save tool output {filename}: {e}")
523
+
524
+ return filename
525
+
526
+ def _load_tool_output(self, session_id: str, filename: str) -> Optional[str]:
527
+ """Load tool output from file."""
528
+ filepath = self._tools_dir(session_id) / filename
529
+ if filepath.exists():
530
+ try:
531
+ return filepath.read_text(encoding="utf-8")
532
+ except Exception as e:
533
+ logger.error(f"Failed to load tool output {filename}: {e}")
534
+ return None
535
+
536
+ def _hydrate_tool_output(self, session_id: str, message: dict):
537
+ """
538
+ Hydrate tool output from file if stored externally.
539
+ Modifies message in-place.
540
+ """
541
+ # Handle regular tool messages
542
+ if message.get("role") == "tool" and message.get("tool_output_file"):
543
+ output = self._load_tool_output(session_id, message["tool_output_file"])
544
+ if output is not None:
545
+ message["tool_output"] = output
546
+
547
+ # Handle thinking messages with nested tools
548
+ if message.get("role") == "thinking" and message.get("tools"):
549
+ for tool in message["tools"]:
550
+ if tool.get("toolOutputFile"):
551
+ output = self._load_tool_output(session_id, tool["toolOutputFile"])
552
+ if output is not None:
553
+ tool["toolOutput"] = output
554
+
555
+ def add_tool_message(self, session_id: str, tool_name: str, tool_id: str, tool_input: dict):
556
+ """Add a tool invocation message."""
557
+ message = {
558
+ "role": "tool",
559
+ "tool_name": tool_name,
560
+ "tool_id": tool_id,
561
+ "tool_input": tool_input,
562
+ "tool_completed": False,
563
+ "timestamp": datetime.now(timezone.utc).isoformat(),
564
+ }
565
+ self._append_message(session_id, message)
566
+
567
+ def update_tool_result(self, session_id: str, tool_id: str, output: str, error: str = "", start_line: int = None):
568
+ """
569
+ Update a tool message with its result.
570
+ Large outputs are stored in separate files.
571
+ """
572
+ # Read all messages to find and update the tool
573
+ messages = list(self._read_messages(session_id))
574
+ updated = False
575
+
576
+ for msg in reversed(messages):
577
+ if msg.get("role") == "tool" and msg.get("tool_id") == tool_id:
578
+ msg["tool_completed"] = True
579
+ msg["tool_error"] = error
580
+
581
+ # Store startLine for Edit tools (from structuredPatch)
582
+ if start_line:
583
+ msg["startLine"] = start_line
584
+
585
+ # Store large outputs in separate file
586
+ if len(output) > self.TOOL_OUTPUT_THRESHOLD:
587
+ filename = self._save_tool_output(
588
+ session_id,
589
+ msg.get("tool_name", "Unknown"),
590
+ tool_id,
591
+ output
592
+ )
593
+ msg["tool_output_file"] = filename
594
+ msg["tool_output"] = f"[Stored in {filename}]"
595
+ else:
596
+ msg["tool_output"] = output
597
+
598
+ updated = True
599
+ break
600
+
601
+ if updated:
602
+ # Rewrite messages file (necessary for updates)
603
+ self._rewrite_messages(session_id, messages)
604
+
605
+ def _rewrite_messages(self, session_id: str, messages: list):
606
+ """Rewrite all messages (used for updates/imports)."""
607
+ messages_path = self._messages_path(session_id)
608
+ try:
609
+ with open(messages_path, 'w', encoding="utf-8") as f:
610
+ for msg in messages:
611
+ f.write(json.dumps(msg) + '\n')
612
+
613
+ # Update message count in meta
614
+ self.update_meta(session_id, message_count=len(messages))
615
+ except Exception as e:
616
+ logger.error(f"Failed to rewrite messages for {session_id}: {e}")
617
+
618
+ # ─────────────────────────────────────────────────────────────────
619
+ # Thinking Message Support
620
+ # ─────────────────────────────────────────────────────────────────
621
+
622
+ def add_thinking_message(self, session_id: str, content: str, timestamp: str = None) -> int:
623
+ """Add a thinking message. Returns the message index."""
624
+ messages_path = self._messages_path(session_id)
625
+
626
+ # Count existing messages to get index
627
+ msg_count = sum(1 for _ in self._read_messages(session_id))
628
+
629
+ message = {
630
+ "role": "thinking",
631
+ "content": content,
632
+ "tools": [],
633
+ "timestamp": timestamp or (datetime.now(timezone.utc).isoformat()),
634
+ }
635
+ self._append_message(session_id, message)
636
+ return msg_count
637
+
638
+ def add_tool_to_thinking(self, session_id: str, thinking_index: int, tool_data: dict):
639
+ """Add a tool to an existing thinking message."""
640
+ messages = list(self._read_messages(session_id))
641
+
642
+ if 0 <= thinking_index < len(messages):
643
+ msg = messages[thinking_index]
644
+ if msg.get("role") == "thinking":
645
+ msg["tools"].append({
646
+ "toolName": tool_data.get("name"),
647
+ "toolId": tool_data.get("id"),
648
+ "toolInput": tool_data.get("input"),
649
+ "toolOutput": None,
650
+ "toolCompleted": False,
651
+ })
652
+ self._rewrite_messages(session_id, messages)
653
+
654
+ def update_thinking_tool_result(self, session_id: str, tool_id: str, output: str, start_line: int = None):
655
+ """Update a tool result within a thinking message."""
656
+ messages = list(self._read_messages(session_id))
657
+ updated = False
658
+
659
+ for msg in reversed(messages):
660
+ if msg.get("role") == "thinking" and msg.get("tools"):
661
+ for tool in msg["tools"]:
662
+ if tool.get("toolId") == tool_id:
663
+ tool["toolCompleted"] = True
664
+
665
+ # Store large outputs in separate file
666
+ if len(output) > self.TOOL_OUTPUT_THRESHOLD:
667
+ filename = self._save_tool_output(
668
+ session_id,
669
+ tool.get("toolName", "Unknown"),
670
+ tool_id,
671
+ output
672
+ )
673
+ tool["toolOutputFile"] = filename
674
+ tool["toolOutput"] = f"[Stored in {filename}]"
675
+ else:
676
+ tool["toolOutput"] = output
677
+
678
+ # Store startLine for Edit tools (from structuredPatch)
679
+ if start_line:
680
+ tool["startLine"] = start_line
681
+
682
+ updated = True
683
+ break
684
+ if updated:
685
+ break
686
+
687
+ if updated:
688
+ self._rewrite_messages(session_id, messages)
689
+
690
+ # ─────────────────────────────────────────────────────────────────
691
+ # Clear / Reset
692
+ # ─────────────────────────────────────────────────────────────────
693
+
694
+ def clear_conversation(self, session_id: str):
695
+ """Clear conversation history and reset for fresh start."""
696
+ # Clear messages file
697
+ messages_path = self._messages_path(session_id)
698
+ if messages_path.exists():
699
+ messages_path.write_text("", encoding="utf-8")
700
+
701
+ # Clear tools directory
702
+ tools_dir = self._tools_dir(session_id)
703
+ if tools_dir.exists():
704
+ shutil.rmtree(tools_dir)
705
+ tools_dir.mkdir()
706
+
707
+ # Reset metadata
708
+ self.update_meta(
709
+ session_id,
710
+ provider_session_id=None,
711
+ message_count=0,
712
+ )
713
+
714
+ logger.info(f"Cleared conversation for {session_id}")
715
+
716
+ # ─────────────────────────────────────────────────────────────────
717
+ # Project-level Command Storage
718
+ # ─────────────────────────────────────────────────────────────────
719
+ # Slash commands are per-project (CWD), not per-session.
720
+ # This allows new sessions to show commands before Claude starts.
721
+
722
+ def _project_data_path(self, cwd: str) -> Path:
723
+ """Get the project-data.json path for storing per-project data."""
724
+ if self.project_path:
725
+ # New: store in project's data directory
726
+ return paths.get_project_dir(cwd) / "project-data.json"
727
+ else:
728
+ # Legacy: store in sessions directory
729
+ return self.base_dir / "projects.json"
730
+
731
+ def _load_project_data(self, cwd: str) -> dict:
732
+ """Load project data."""
733
+ path = self._project_data_path(cwd)
734
+ if path.exists():
735
+ try:
736
+ return json.loads(path.read_text(encoding="utf-8"))
737
+ except Exception as e:
738
+ logger.error(f"Failed to load project data: {e}")
739
+ return {}
740
+
741
+ def _save_project_data(self, cwd: str, data: dict):
742
+ """Save project data."""
743
+ path = self._project_data_path(cwd)
744
+ try:
745
+ path.parent.mkdir(parents=True, exist_ok=True)
746
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
747
+ except Exception as e:
748
+ logger.error(f"Failed to save project data: {e}")
749
+
750
+ def save_project_commands(self, cwd: str, commands: list):
751
+ """Save slash commands for a project (CWD)."""
752
+ data = self._load_project_data(cwd)
753
+ data["slash_commands"] = commands
754
+ data["updated_at"] = datetime.now(timezone.utc).isoformat()
755
+ self._save_project_data(cwd, data)
756
+ logger.debug(f"Saved {len(commands)} commands for project {cwd}")
757
+
758
+ def get_project_commands(self, cwd: str) -> list:
759
+ """Get slash commands for a project (CWD)."""
760
+ data = self._load_project_data(cwd)
761
+ return data.get("slash_commands", [])
762
+
763
+ # ─────────────────────────────────────────────────────────────────
764
+ # Stash (context references)
765
+ # ─────────────────────────────────────────────────────────────────
766
+
767
+ def get_stash(self, session_id: str) -> list:
768
+ """Get all stash items for a session."""
769
+ stash_path = self._stash_path(session_id)
770
+ if not stash_path.exists():
771
+ return []
772
+ try:
773
+ data = json.loads(stash_path.read_text(encoding="utf-8"))
774
+ return data.get("items", [])
775
+ except Exception as e:
776
+ logger.warning(f"Error reading stash for {session_id}: {e}")
777
+ return []
778
+
779
+ def _save_stash(self, session_id: str, items: list) -> bool:
780
+ """Save stash items for a session. Returns True on success."""
781
+ stash_path = self._stash_path(session_id)
782
+ try:
783
+ # Ensure session directory exists (may not if session is pending or different user)
784
+ stash_path.parent.mkdir(parents=True, exist_ok=True)
785
+ stash_path.write_text(json.dumps({"items": items}, indent=2), encoding="utf-8")
786
+ return True
787
+ except Exception as e:
788
+ logger.error(f"Error saving stash for {session_id}: {e}")
789
+ return False
790
+
791
+ def add_stash_item(self, session_id: str, item: dict) -> bool:
792
+ """Add an item to the session stash. Returns True on success."""
793
+ items = self.get_stash(session_id)
794
+ items.insert(0, item) # Newest first
795
+ return self._save_stash(session_id, items)
796
+
797
+ def remove_stash_item(self, session_id: str, item_id: str) -> bool:
798
+ """Remove an item from the session stash. Returns True on success."""
799
+ items = self.get_stash(session_id)
800
+ items = [i for i in items if i.get("id") != item_id]
801
+ return self._save_stash(session_id, items)
802
+
803
+ def update_stash_item(self, session_id: str, item_id: str, updates: dict) -> tuple[bool, bool]:
804
+ """Update a stash item (toggle enabled, edit note, etc).
805
+
806
+ Returns (found, saved) tuple:
807
+ - found: whether the item was found
808
+ - saved: whether the save succeeded (only meaningful if found)
809
+ """
810
+ items = self.get_stash(session_id)
811
+ for item in items:
812
+ if item.get("id") == item_id:
813
+ item.update(updates)
814
+ saved = self._save_stash(session_id, items)
815
+ return (True, saved)
816
+ return (False, False)
817
+
818
+ def clear_stash(self, session_id: str) -> bool:
819
+ """Clear all items from a session stash."""
820
+ stash_path = self._stash_path(session_id)
821
+ if stash_path.exists():
822
+ stash_path.unlink()
823
+ return True
824
+
825
+ # Sent-history cap per session — trimmed oldest-first on every mark-sent
826
+ # so stash.json can't grow unbounded
827
+ STASH_HISTORY_LIMIT = 50
828
+
829
+ def mark_stash_sent(self, session_id: str, item_ids: list,
830
+ message_id: str = None, sent_at: str = None,
831
+ sent_session_id: str = None) -> tuple[int, bool]:
832
+ """Mark stash items as sent (history) instead of deleting them.
833
+
834
+ Sets status/sentAt/sentWithMessageId/sentInSessionId and disables
835
+ the items, then trims sent history beyond STASH_HISTORY_LIMIT
836
+ (oldest sentAt dropped first).
837
+
838
+ Returns (marked_count, saved) tuple.
839
+ """
840
+ items = self.get_stash(session_id)
841
+ ids = set(item_ids or [])
842
+ now = sent_at or (datetime.now(timezone.utc).isoformat())
843
+
844
+ marked = 0
845
+ for item in items:
846
+ if item.get("id") in ids:
847
+ item["status"] = "sent"
848
+ item["enabled"] = False
849
+ item["sentAt"] = now
850
+ item["sentWithMessageId"] = message_id
851
+ item["sentInSessionId"] = sent_session_id
852
+ marked += 1
853
+
854
+ if marked == 0:
855
+ return (0, True)
856
+
857
+ # Trim oldest sent entries beyond the cap
858
+ sent = [i for i in items if i.get("status") == "sent"]
859
+ if len(sent) > self.STASH_HISTORY_LIMIT:
860
+ sent.sort(key=lambda i: i.get("sentAt") or "", reverse=True)
861
+ keep_ids = {i.get("id") for i in sent[:self.STASH_HISTORY_LIMIT]}
862
+ items = [i for i in items
863
+ if i.get("status") != "sent" or i.get("id") in keep_ids]
864
+
865
+ return (marked, self._save_stash(session_id, items))
866
+
867
+ def clear_stash_scope(self, session_id: str, scope: str = "all") -> bool:
868
+ """Clear stash items by scope: 'pending', 'history', or 'all'."""
869
+ if scope == "all":
870
+ return self.clear_stash(session_id)
871
+
872
+ items = self.get_stash(session_id)
873
+ if scope == "pending":
874
+ items = [i for i in items if i.get("status") == "sent"]
875
+ elif scope == "history":
876
+ items = [i for i in items if i.get("status") != "sent"]
877
+ else:
878
+ return False
879
+ return self._save_stash(session_id, items)