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,837 @@
1
+ """
2
+ WebSocket /chat endpoint — agent (provider) interaction protocol.
3
+
4
+ Handles connection setup, session resolution (load existing or create
5
+ pending), the per-message-type dispatch (user_message / ping / stop /
6
+ clear_session / tool_answer / set_permission_mode), and disconnect
7
+ cleanup. Per-message-type handlers are private async helpers in this
8
+ module so the route function stays a thin connection lifecycle + dispatch.
9
+
10
+ Public surface:
11
+ - `router` — APIRouter included by `server.py`.
12
+ """
13
+
14
+ import base64
15
+ import json
16
+ import logging
17
+ import time
18
+ import traceback
19
+ import uuid
20
+ from dataclasses import asdict
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
25
+
26
+ from painapple_code import paths
27
+ from painapple_code.auth_middleware import (
28
+ _client_identity,
29
+ check_websocket_auth,
30
+ check_websocket_origin,
31
+ record_auth_identity,
32
+ )
33
+ from painapple_code.providers import get_provider, provider_names
34
+ from painapple_code.routes.dependencies import provider_is_locked
35
+ from painapple_code.server_logging import log_websocket_event
36
+ from painapple_code.session_store import SessionStore
37
+ from painapple_code.utils.agent_cli import get_max_thinking_tokens
38
+ from painapple_code.utils.file_paths import extract_file_links, safe_resolve
39
+
40
+ logger = logging.getLogger(__name__)
41
+ # Grab the singleton only — setup_logging() in main() attaches the file handler.
42
+ # Calling get_access_logger() here would create the log dir + open access.log as
43
+ # an import side effect (e.g. on `--help`).
44
+ access_logger = logging.getLogger("painapple-code.access")
45
+
46
+ router = APIRouter(tags=["chat"])
47
+
48
+
49
+ @router.websocket("/chat")
50
+ async def websocket_chat(websocket: WebSocket, cwd: str = None, session: str = None,
51
+ provider: str = None):
52
+ """
53
+ WebSocket endpoint for agent (provider) interaction.
54
+
55
+ Sessions are bound to store_id and persist beyond WebSocket connections.
56
+ If you reconnect to a session where the agent is still running, you
57
+ resume receiving output. If the agent finished, it will be restarted.
58
+
59
+ Protocol:
60
+ - Client sends: {"type": "user_message", "content": "..."}
61
+ - Server sends: {"type": "agent_message", "data": {...}}
62
+ - Server sends: {"type": "session_ended", "reason": "..."}
63
+
64
+ Query params:
65
+ - cwd: Working directory for Claude (used when creating new session)
66
+ - session: Server-side session ID (to join/resume existing session)
67
+ - provider: Engine for a NEW session (picker choice). Ignored on resume —
68
+ a session is bound to its engine at creation (its provider_session_id
69
+ only means anything to that engine). Unknown names fall back to the
70
+ default rather than failing the connect.
71
+ """
72
+ # Read the manager from app.state — `from server import agents` would grab
73
+ # a second copy of the server module where `agents` stays None (server.py
74
+ # runs as __main__, so a `server` re-import re-runs module-level code
75
+ # without main() and never sets it).
76
+ await websocket.accept()
77
+ agents = websocket.app.state.agents
78
+
79
+ state = websocket.app.state
80
+ # Origin boundary: WS bypasses CORS, so a cross-origin browser page could
81
+ # otherwise open this socket with the user's ambient cookie.
82
+ if not check_websocket_origin(websocket, getattr(state, "allowed_origins", None)):
83
+ await websocket.close(code=1008, reason="forbidden origin")
84
+ return
85
+ cookie_token = getattr(state, "auth_cookie_token", None)
86
+ api_token = getattr(state, "auth_api_token", None)
87
+ if not cookie_token or not api_token or not check_websocket_auth(websocket, cookie_token, api_token):
88
+ await websocket.close(code=1008, reason="unauthorized")
89
+ return
90
+ record_auth_identity("ws", *_client_identity(websocket.scope))
91
+
92
+ # Get client IP for logging
93
+ client_ip = websocket.client.host if websocket.client else "unknown"
94
+ log_websocket_event(access_logger, "CONNECT", session_id=session, client_ip=client_ip)
95
+
96
+ # Step 1: Get or create the store (persistent storage)
97
+ # For new sessions, we create metadata but DON'T persist to disk yet.
98
+ # Session is only persisted when first message is sent (avoids empty sessions in history).
99
+ store_data = None
100
+ already_persisted = False
101
+ if session:
102
+ store_data = SessionStore.load(session)
103
+ if not store_data:
104
+ await websocket.send_json({
105
+ "type": "error",
106
+ "message": f"Session not found: {session}"
107
+ })
108
+ await websocket.close()
109
+ return
110
+ resolved_cwd = store_data["cwd"]
111
+ provider_session_id = store_data.get("provider_session_id")
112
+ # Check if this is a forked session that hasn't been started yet
113
+ forked_from = store_data.get("forked_from")
114
+ fork_from_session_id = None
115
+ if forked_from and not provider_session_id:
116
+ # This is a fresh fork - use the source session ID for --fork-session
117
+ # (back-compat: nested forked_from predates the provider_session_id rename)
118
+ fork_from_session_id = forked_from.get("provider_session_id") or forked_from.get("claude_session_id")
119
+ logger.info(f"Session {session} is a fork, will use --fork-session from {fork_from_session_id}")
120
+ # Check if this is a comment thread (skip shadow git)
121
+ is_comment_thread = store_data.get("isCommentThread", False)
122
+ token_profile = store_data.get("token_profile")
123
+ provider_name = store_data.get("provider") # None → default (Claude)
124
+ already_persisted = True # Loaded from disk
125
+ else:
126
+ # Create pending session (not persisted to disk yet)
127
+ resolved_cwd = agents.default_cwd
128
+ if cwd:
129
+ resolved_cwd = str(safe_resolve(cwd))
130
+ store_data = SessionStore.create_pending(resolved_cwd)
131
+ provider_session_id = None
132
+ fork_from_session_id = None
133
+ is_comment_thread = False # New sessions are never comment threads
134
+ token_profile = None # Set via API before first message
135
+ # Engine selection, most specific wins: the client's picker choice
136
+ # (?provider= on this connect), then the box-wide default
137
+ # (--default-provider flag / `default_provider` config key). The result
138
+ # is recorded in meta so the session stays on that engine across
139
+ # reconnects; when nothing is set, provider_name stays None and the
140
+ # session falls back to DEFAULT_PROVIDER (claude-sdk) at spawn. An
141
+ # unknown picker value degrades to the default flow instead of failing
142
+ # the connect (mirrors get_provider()'s stale-meta tolerance).
143
+ requested_provider = None
144
+ if provider:
145
+ if provider in provider_names():
146
+ requested_provider = provider
147
+ else:
148
+ logger.warning(f"Ignoring unknown provider {provider!r} on session create")
149
+ provider_name = (requested_provider
150
+ or agents.default_provider
151
+ or paths.load_global_config().get("default_provider"))
152
+ if provider_name:
153
+ store_data["provider"] = provider_name
154
+ # Bind-time permission anchoring: when the app-wide default level
155
+ # isn't in this engine's vocabulary, stamp the engine's own
156
+ # default so meta, UI, and launch agree from the first frame.
157
+ from painapple_code.routes.dependencies import bind_permission_level
158
+ anchored = bind_permission_level(None, get_provider(provider_name))
159
+ if anchored:
160
+ store_data["permission_level"] = anchored
161
+ already_persisted = False
162
+
163
+ store_id = store_data["id"]
164
+
165
+ # Step 2: Get or create the runtime session (keyed by store_id)
166
+ existing_session = agents.get_session(store_id)
167
+
168
+ # Determine if this is a reconnect:
169
+ # - If session param was provided in URL, user is returning to existing session
170
+ # - OR if session exists in memory registry
171
+ # This handles both page refresh (registry hit) and server restart (URL param)
172
+ is_reconnect = session is not None or existing_session is not None
173
+
174
+ if existing_session:
175
+ agent_session = existing_session
176
+ # Attach additively — every connected client receives the broadcast
177
+ # stream. The old takeover (close the previous socket, error toast
178
+ # "Another client connected") silently starved whichever tab/device/
179
+ # token lost the slot: it kept its dead view, missed the turn's result
180
+ # frame, and stayed stuck on "working" with no reason to reconnect.
181
+ agent_session.attach_websocket(websocket)
182
+ else:
183
+ # Create new session (with pending metadata if not persisted)
184
+ agent_session = agents.get_or_create_session(
185
+ store_id=store_id,
186
+ cwd=resolved_cwd,
187
+ store_meta=store_data if not already_persisted else None,
188
+ already_persisted=already_persisted,
189
+ session_id=provider_session_id,
190
+ fork_from_session_id=fork_from_session_id,
191
+ is_comment_thread=is_comment_thread,
192
+ token_profile=token_profile,
193
+ provider_name=provider_name,
194
+ )
195
+ agent_session.attach_websocket(websocket)
196
+
197
+ # Persist new sessions to disk immediately on WebSocket connect.
198
+ # This ensures they appear on the welcome screen, survive refreshes,
199
+ # and the browser gets a storeId to save in localStorage.
200
+ if not already_persisted:
201
+ agent_session.ensure_persisted()
202
+ already_persisted = True
203
+
204
+ logger.info(f"WebSocket {'reconnected' if is_reconnect else 'connected'}, "
205
+ f"store_id={store_id}, cwd={agent_session.cwd}, "
206
+ f"is_running={agent_session.is_running}, process={agent_session.process is not None}")
207
+
208
+ try:
209
+ # Step 3: DON'T start Claude automatically for new sessions
210
+ # Claude starts when the user sends first message.
211
+
212
+ # Step 4: Send connection status
213
+ is_actively_processing = agent_session.is_running and not agent_session.is_idle
214
+ if is_reconnect and is_actively_processing:
215
+ status_msg = "Reconnected to running session"
216
+ elif is_reconnect:
217
+ status_msg = "Reconnected to session"
218
+ else:
219
+ status_msg = "Ready" # New session, Claude will start on first message
220
+
221
+ try:
222
+ workspace_path = str(safe_resolve(agents.default_cwd))
223
+ except Exception:
224
+ workspace_path = agents.default_cwd
225
+ # The session's engine identity + capabilities ride the connected
226
+ # payload so the client can badge the tab and gate per-engine chrome
227
+ # (model chip, fork/Discuss, USD cost, /context) without a second
228
+ # round-trip. agent_session.provider is set at runtime-session
229
+ # creation; the get_provider fallback covers the theoretical gap.
230
+ session_provider = agent_session.provider or get_provider(provider_name)
231
+ connected_msg = {
232
+ "type": "connected",
233
+ "message": status_msg,
234
+ "cwd": agent_session.cwd,
235
+ "home": str(Path.home()),
236
+ "workspace": workspace_path,
237
+ "session_id": store_id,
238
+ "is_reconnect": is_reconnect,
239
+ "agent_running": agent_session.is_running and not agent_session.is_idle,
240
+ "is_compacting": agent_session.is_compacting,
241
+ "provider": session_provider.name,
242
+ "provider_display_name": session_provider.display_name,
243
+ "provider_caps": asdict(session_provider.capabilities),
244
+ # Engine still switchable? Locks permanently after the first turn.
245
+ "provider_locked": provider_is_locked(store_data or {}),
246
+ }
247
+ # Include turn start time so client can show accurate elapsed timer on reconnect
248
+ if is_actively_processing and agent_session.turn_tracker and agent_session.turn_tracker.turn_start:
249
+ connected_msg["turn_start"] = agent_session.turn_tracker.turn_start
250
+ if store_data:
251
+ connected_msg["name"] = store_data.get("name")
252
+ await websocket.send_json(connected_msg)
253
+
254
+ # Replay any permission ask the provider is still blocked on, so a
255
+ # reconnecting client re-renders the approve/deny card (the original
256
+ # frame died with the previous WebSocket).
257
+ for pending_req in list(agent_session._pending_permission_requests.values()):
258
+ await websocket.send_json({
259
+ "type": "permission_request",
260
+ **{k: v for k, v in pending_req.items() if k != "type"},
261
+ "replay": True,
262
+ })
263
+
264
+ # Step 5: Main message loop
265
+ while True:
266
+ try:
267
+ data = await websocket.receive_json()
268
+ msg_type = data.get("type")
269
+ agent_session.touch() # Update activity timestamp
270
+
271
+ if msg_type == "user_message":
272
+ await _handle_user_message(websocket, agent_session, store_id, data, agents)
273
+ elif msg_type == "ping":
274
+ await websocket.send_json({"type": "pong"})
275
+ elif msg_type == "stop":
276
+ await _handle_stop(websocket, agent_session, agents)
277
+ elif msg_type == "clear_session":
278
+ await _handle_clear_session(websocket, agent_session, store_id, agents)
279
+ elif msg_type == "tool_answer":
280
+ await _handle_tool_answer(websocket, agent_session, store_id, data, agents)
281
+ elif msg_type == "permission_response":
282
+ await _handle_permission_response(websocket, agent_session, data, agents)
283
+ elif msg_type == "set_permission_mode":
284
+ await _handle_set_permission_mode(websocket, agent_session, store_id, data, agents)
285
+ else:
286
+ logger.warning(f"Unknown message type: {msg_type}")
287
+
288
+ except json.JSONDecodeError:
289
+ await websocket.send_json({
290
+ "type": "error",
291
+ "message": "Invalid JSON"
292
+ })
293
+
294
+ except WebSocketDisconnect:
295
+ logger.info(f"WebSocket disconnected for session {store_id}")
296
+ log_websocket_event(access_logger, "DISCONNECT", session_id=store_id, client_ip=client_ip)
297
+ except Exception as e:
298
+ logger.error(f"WebSocket error for session {store_id}: {e}")
299
+ log_websocket_event(access_logger, "ERROR", session_id=store_id, client_ip=client_ip, details=str(e))
300
+ # Log to session's raw.jsonl with full traceback
301
+ if store_id:
302
+ SessionStore.log_raw_error(
303
+ store_id,
304
+ f"server_error: WebSocket handler error: {e}",
305
+ traceback.format_exc()
306
+ )
307
+ finally:
308
+ # Just detach WebSocket - don't stop Claude!
309
+ # Claude will continue running and output will be saved to store.
310
+ # Client can reconnect and resume receiving output.
311
+ # Pass our specific websocket so a takeover's new ws isn't nuked.
312
+ agent_session.detach_websocket(websocket)
313
+ logger.info(f"WebSocket detached from session {store_id}, "
314
+ f"Claude still running: {agent_session.is_running}")
315
+
316
+
317
+ # ───────────────────────────────────────────────────────────────────────
318
+ # Per-message-type handlers
319
+ # ───────────────────────────────────────────────────────────────────────
320
+
321
+ async def _handle_user_message(websocket, agent_session, store_id, data, agents) -> None:
322
+ """Handle a `user_message` from the client.
323
+
324
+ Records the prompt in SessionStore + Shadow DB, applies any token-profile
325
+ or model overrides delivered with this turn, ensures Claude is running,
326
+ and forwards the message (text + images) to the subprocess.
327
+ """
328
+ # Lazy imports to keep module-load cheap and avoid pulling DuckDB if
329
+ # the chat WS is never opened.
330
+ from painapple_code.shadow_db import get_shadow_db
331
+
332
+ content = data.get("content", "")
333
+ images = data.get("images", []) # List of image objects from /api/upload-image
334
+ # For storage: use displayContent if provided (original without stash prefix)
335
+ display_content = data.get("displayContent", content)
336
+ stash_refs = data.get("stashRefs") # Array of stash items for display
337
+ file_paths = data.get("files") or [] # Uploaded file paths attached to this prompt
338
+ plan_mode = data.get("planMode", False) # True if sent in plan mode
339
+ mark_as_favorite = data.get("markAsFavorite", False) # Mark prompt as favorite
340
+
341
+ # Build message content - can be string or array with text+images
342
+ if images:
343
+ # Multi-part message with images
344
+ message_content = []
345
+ if content:
346
+ message_content.append({"type": "text", "text": content})
347
+ for img in images:
348
+ # img should be the "image" object from upload response
349
+ message_content.append(img)
350
+ agent_msg = {
351
+ "type": "user",
352
+ "message": {
353
+ "role": "user",
354
+ "content": message_content
355
+ }
356
+ }
357
+ else:
358
+ # Simple text message
359
+ agent_msg = {
360
+ "type": "user",
361
+ "message": {
362
+ "role": "user",
363
+ "content": content
364
+ }
365
+ }
366
+
367
+ # Save images to disk for persistence
368
+ image_files = []
369
+ if images:
370
+ try:
371
+ uploads_dir = SessionStore.get_uploads_path(store_id)
372
+ for i, img in enumerate(images):
373
+ source = img.get("source", {})
374
+ if source.get("type") == "base64" and source.get("data"):
375
+ media_type = source.get("media_type", "image/jpeg")
376
+ ext = "jpg" if "jpeg" in media_type else media_type.split("/")[-1]
377
+ filename = f"img_{int(time.time()*1000)}_{uuid.uuid4().hex[:6]}.{ext}"
378
+ img_data = base64.b64decode(source["data"])
379
+ (uploads_dir / filename).write_bytes(img_data)
380
+ image_files.append(filename)
381
+ except Exception as e:
382
+ logger.warning(f"Failed to save images for session {store_id}: {e}")
383
+
384
+ # Use display_content for storage (original without stash prefix)
385
+ # Get thinking tokens for this session
386
+ session_meta = SessionStore.load_meta(store_id)
387
+ thinking_tokens = None
388
+ if session_meta:
389
+ thinking_tokens = session_meta.get("max_thinking_tokens")
390
+ if thinking_tokens is None:
391
+ thinking_tokens = get_max_thinking_tokens()
392
+
393
+ # Extract verified file paths for linkification
394
+ verified_files = {}
395
+ if agent_session.cwd and display_content:
396
+ for link in extract_file_links(display_content, agent_session.cwd):
397
+ verified_files[link['path']] = link['resolved']
398
+
399
+ user_msg = {
400
+ "role": "user",
401
+ "content": display_content,
402
+ "has_images": len(images) > 0,
403
+ "image_count": len(images),
404
+ "timestamp": datetime.now(timezone.utc).isoformat(),
405
+ "thinking_tokens": thinking_tokens if thinking_tokens > 0 else None,
406
+ }
407
+ if image_files:
408
+ user_msg["image_files"] = image_files
409
+ # Uploaded file attachments. The paths live in the model-facing `content`
410
+ # only; `display_content` is the user's own text, so without this the
411
+ # reloaded bubble would lose the "[N files attached]" indicator that the
412
+ # live one shows.
413
+ if file_paths:
414
+ user_msg["has_files"] = True
415
+ user_msg["file_count"] = len(file_paths)
416
+ user_msg["file_paths"] = file_paths
417
+ if verified_files:
418
+ user_msg["verifiedFiles"] = verified_files
419
+ # Include stash refs if present (for display in message bubble)
420
+ if stash_refs:
421
+ user_msg["hasRefs"] = True
422
+ user_msg["refCount"] = len(stash_refs)
423
+ user_msg["stashRefs"] = stash_refs
424
+ # Include plan mode flag if message was sent in plan mode
425
+ if plan_mode:
426
+ user_msg["planMode"] = True
427
+ line_number = SessionStore.add_message(store_id, user_msg)
428
+
429
+ # Broadcast the stored prompt to every attached client. Peer tabs/devices
430
+ # on the same session see it live instead of waiting for their next sync
431
+ # (where the old content-window dedup could swallow it entirely — the
432
+ # missing-second-/compact bug). `line` lets clients derive the stable sid
433
+ # ("{store_id}:{line}", same scheme as promptId/api_logs); the sender's
434
+ # own local copy adopts it idempotently instead of duplicating.
435
+ if line_number > 0:
436
+ await agent_session.safe_send({
437
+ "type": "message_stored",
438
+ "message": user_msg,
439
+ "line": line_number,
440
+ })
441
+
442
+ # Construct prompt_id for client-side favorite support
443
+ prompt_id = f"{store_id}:{line_number}" if line_number > 0 else None
444
+
445
+ # Mark as favorite if requested
446
+ if mark_as_favorite and prompt_id:
447
+ paths.add_prompt_favorite(
448
+ prompt_id,
449
+ content_preview=display_content[:100] if display_content else ""
450
+ )
451
+
452
+ # Send prompt_id back to client for favorite button support
453
+ if prompt_id:
454
+ is_favorite = mark_as_favorite or paths.is_prompt_favorite(prompt_id)
455
+ stored_msg = {
456
+ "type": "user_message_stored",
457
+ "promptId": prompt_id,
458
+ "isFavorite": is_favorite,
459
+ }
460
+ if verified_files:
461
+ stored_msg["verifiedFiles"] = verified_files
462
+ await websocket.send_json(stored_msg)
463
+
464
+ # Shadow Git: Set prompt for this turn and reset tracker
465
+ agent_session.turn_tracker.reset()
466
+ agent_session.turn_tracker.set_prompt(content)
467
+ agent_session._ask_tool_id = None # Reset AskUserQuestion retry suppression
468
+
469
+ # Shadow DB: Record turn start (prompt is never lost)
470
+ try:
471
+ if agent_session.cwd:
472
+ db = get_shadow_db()
473
+ # Get git branch cheaply for turn metadata
474
+ git_branch = None
475
+ try:
476
+ import subprocess as _sp
477
+ git_branch = _sp.run(
478
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
479
+ cwd=agent_session.cwd, capture_output=True,
480
+ text=True, encoding="utf-8", errors="replace", timeout=2
481
+ ).stdout.strip() or None
482
+ except Exception:
483
+ pass
484
+ db_turn_id = db.start_turn(
485
+ session_id=store_id,
486
+ project_hash=paths.get_project_hash(agent_session.cwd),
487
+ # display_content, NOT `display_content or content`: the two
488
+ # are different KINDS of value — display_content is what the
489
+ # user typed, content is the composed, model-facing text with
490
+ # the stash block prepended. The falsy fallback only ever fired
491
+ # on a stash-only send (typed text empty), which is exactly the
492
+ # case where it swapped in the composed blob — so messages.jsonl
493
+ # recorded "" while this column recorded the full stash text for
494
+ # the same turn. One rule now: user_prompt is what the user
495
+ # typed. The composed text is still preserved in the shadow-git
496
+ # commit body via the turn_tracker.set_prompt(content) above.
497
+ # (An empty user_prompt is already a normal state here — an
498
+ # image-only send has produced one since long before stash.)
499
+ user_prompt=display_content,
500
+ has_images=len(images) > 0,
501
+ git_branch=git_branch,
502
+ git_repo_hash=paths.get_git_repo_hash(agent_session.cwd),
503
+ is_plan=plan_mode,
504
+ provider=agent_session.provider.name,
505
+ )
506
+ agent_session.turn_tracker.db_turn_id = db_turn_id
507
+ except Exception as e:
508
+ logger.warning(f"Shadow DB start_turn failed: {e}")
509
+
510
+ # Apply token profile from message (sent with every prompt)
511
+ msg_token_profile = data.get("token_profile") # string or absent
512
+ if msg_token_profile != agent_session.token_profile:
513
+ agent_session.token_profile = msg_token_profile
514
+ SessionStore.update_metadata(store_id, token_profile=msg_token_profile)
515
+ # Kill idle process so start_agent respawns with new token
516
+ # Note: is_idle (not `not is_running`) — is_running stays True while
517
+ # the stream-json process is alive between turns. is_idle is set
518
+ # by _finalize_turn after each turn completes.
519
+ if agent_session.is_idle and agent_session.process:
520
+ agent_session._interrupting = True # suppress session_ended WS msg
521
+ await agents.stop_session(agent_session)
522
+ logger.info(f"Killed idle process for {store_id} (token profile changed to: {msg_token_profile})")
523
+
524
+ # Apply preferred model from message (sent with every prompt)
525
+ msg_model = data.get("preferred_model") # string or absent
526
+ if msg_model != agent_session.preferred_model:
527
+ agent_session.preferred_model = msg_model
528
+ SessionStore.update_metadata(store_id, preferred_model=msg_model)
529
+ # Live apply (capabilities.live_controls): switch the warm engine's
530
+ # model in place (None = revert to the CLI/account default). Awaited
531
+ # before send_to_agent below, so this very turn already runs on the
532
+ # new model — and it works mid-turn too, where the old path silently
533
+ # deferred the change to some future respawn. Nack/timeout falls back
534
+ # to the kill+respawn.
535
+ switched_live = False
536
+ if (agent_session.provider.capabilities.live_controls
537
+ and agent_session.process and agent_session.is_running):
538
+ switched_live = await agents.send_control(
539
+ agent_session, "set_model", {"model": msg_model})
540
+ if switched_live:
541
+ logger.info(f"Model live-applied for {store_id}: {msg_model}")
542
+ if not switched_live and agent_session.is_idle and agent_session.process:
543
+ agent_session._interrupting = True
544
+ await agents.stop_session(agent_session)
545
+ logger.info(f"Killed idle process for {store_id} (model changed to: {msg_model})")
546
+
547
+ # One-shot effort override (Ctrl+Shift+' on the client): apply for THIS
548
+ # turn only, stash the prior persistent value, and kill the idle process
549
+ # so the respawn picks up the new --effort flag. _finalize_turn restores
550
+ # the prior value and kills again so the next turn reverts. Distinct
551
+ # from preferred_model above: we do NOT persist to meta.
552
+ msg_effort = data.get("effort_level")
553
+ if msg_effort and msg_effort != agent_session.effort_level:
554
+ # Only stash on the first one-shot of a turn — don't overwrite if
555
+ # somehow re-armed mid-turn (shouldn't happen, but be safe).
556
+ if agent_session._oneshot_revert_effort == "_UNSET":
557
+ agent_session._oneshot_revert_effort = agent_session.effort_level
558
+ agent_session.effort_level = msg_effort
559
+ if agent_session.is_idle and agent_session.process:
560
+ agent_session._interrupting = True
561
+ await agents.stop_session(agent_session)
562
+ logger.info(f"Killed idle process for {store_id} (one-shot effort: {msg_effort})")
563
+
564
+ # Apply a permission mode forwarded with the message. The picker normally
565
+ # persists via PUT /permission-mode + a set_permission_mode WS message, but a
566
+ # brand-new session has no store_id (and no WS) when the user picks, so that
567
+ # path is skipped and the choice rides along on the first user_message
568
+ # instead. Honour it here so the selected mode survives the first turn rather
569
+ # than falling through to the global default. Stored literally (no
570
+ # bypassPermissions→None collapse) so it sticks for the rest of the session.
571
+ msg_permission_mode = data.get("permission_mode") # string or absent
572
+ if msg_permission_mode and msg_permission_mode != agent_session.permission_mode:
573
+ agent_session.permission_mode = msg_permission_mode
574
+ SessionStore.update_metadata(store_id, permission_level=msg_permission_mode)
575
+ logger.info(f"Applied permission mode from message for {store_id}: {msg_permission_mode}")
576
+
577
+ # Apply permission mode: if it changed since the live process launched, kill
578
+ # the idle process so start_agent (below) respawns with the new mode. A
579
+ # running turn is left alone — the new mode takes effect on the next turn.
580
+ if agent_session.permission_mode != agent_session._launched_permission_mode:
581
+ if agent_session.is_idle and agent_session.process:
582
+ agent_session._interrupting = True
583
+ await agents.stop_session(agent_session)
584
+ logger.info(f"Killed idle process for {store_id} (permission mode changed to: {agent_session.permission_mode})")
585
+
586
+ # Ephemeral providers (e.g. Codex) run one subprocess per turn with the
587
+ # prompt in argv — there is no persistent stdin to stream into. Stash the
588
+ # turn's prompt/images for start_agent to embed, then spawn. A fresh spawn
589
+ # also picks up any model/effort/permission change for free, so the lazy
590
+ # "kill idle process" dance above is a no-op for them (process is None
591
+ # between turns).
592
+ if not agent_session.provider.capabilities.persistent_process:
593
+ if agent_session.is_running:
594
+ # One process == one turn; can't inject a prompt mid-turn.
595
+ await websocket.send_json({
596
+ "type": "error",
597
+ "message": f"A {agent_session.provider.display_name} turn is already running"
598
+ })
599
+ return
600
+ agent_session._pending_prompt = content
601
+ agent_session._pending_images = images
602
+ agent_session._last_agent_msg = agent_msg
603
+ if not await agents.start_agent(agent_session):
604
+ await websocket.send_json({
605
+ "type": "error",
606
+ "message": agent_session.start_error
607
+ or f"Failed to start {agent_session.provider.display_name}"
608
+ })
609
+ return
610
+
611
+ # Start Claude if not running (may have finished)
612
+ if not agent_session.is_running:
613
+ if not await agents.start_agent(agent_session):
614
+ await websocket.send_json({
615
+ "type": "error",
616
+ "message": agent_session.start_error or "Failed to start Claude Code"
617
+ })
618
+ return
619
+
620
+ # Send user message to Claude
621
+ if not await agents.send_to_agent(agent_session, agent_msg):
622
+ await websocket.send_json({
623
+ "type": "error",
624
+ "message": "Failed to send message to Claude"
625
+ })
626
+
627
+
628
+ async def _handle_stop(websocket, agent_session, agents) -> None:
629
+ """Handle a `stop` request — interrupt Claude if running."""
630
+ if await agents.interrupt_agent(agent_session):
631
+ logger.info("Claude interrupted by user request")
632
+ await websocket.send_json({
633
+ "type": "stopped",
634
+ "message": "Stopped by user"
635
+ })
636
+ else:
637
+ await websocket.send_json({
638
+ "type": "error",
639
+ "message": "Could not interrupt Claude (not running)"
640
+ })
641
+
642
+
643
+ async def _handle_clear_session(websocket, agent_session, store_id, agents) -> None:
644
+ """Handle a `clear_session` request — wipe conversation, restart fresh."""
645
+ logger.info(f"Clearing session: {store_id}")
646
+
647
+ # Stop current Claude process
648
+ await agents.stop_session(agent_session)
649
+
650
+ # Clear server-side store (removes provider_session_id and messages)
651
+ SessionStore.clear_conversation(store_id)
652
+
653
+ # Reset runtime session
654
+ agent_session.session_id = None
655
+
656
+ # Restart Claude (will start fresh, no --resume)
657
+ if await agents.start_agent(agent_session):
658
+ await websocket.send_json({
659
+ "type": "session_cleared",
660
+ "message": "Session cleared. Starting fresh conversation."
661
+ })
662
+ else:
663
+ await websocket.send_json({
664
+ "type": "error",
665
+ "message": agent_session.start_error or "Failed to restart Claude after clear"
666
+ })
667
+
668
+
669
+ async def _handle_tool_answer(websocket, agent_session, store_id, data, agents) -> None:
670
+ """Handle a `tool_answer` — user responded to AskUserQuestion via the workaround.
671
+
672
+ Headless Claude (`-p` mode) auto-denies AskUserQuestion in every permission
673
+ mode, so we always relay the user's answers as a plain text follow-up
674
+ message rather than injecting a tool_result. The text format mirrors what
675
+ the model would have seen had the tool worked.
676
+ """
677
+ tool_use_id = data.get("tool_use_id")
678
+ answers = data.get("answers", {})
679
+ questions = data.get("questions", []) # Original questions for context
680
+ # Optional free-text comment the user adds alongside (or instead of) the
681
+ # picked options — relayed to Claude regardless of which options were chosen.
682
+ comment = (data.get("comment") or "").strip()
683
+
684
+ if not answers and not comment:
685
+ await websocket.send_json({
686
+ "type": "error",
687
+ "message": "No answers provided"
688
+ })
689
+ return
690
+
691
+ # Format answers as readable text message
692
+ answer_lines = []
693
+ for q in questions:
694
+ header = q.get("header", "Question")
695
+ question_text = q.get("question", "")
696
+ answer_value = answers.get(header, "No answer")
697
+ if question_text and question_text != header:
698
+ answer_lines.append(f"**{header}** ({question_text}): {answer_value}")
699
+ else:
700
+ answer_lines.append(f"**{header}**: {answer_value}")
701
+
702
+ if not answer_lines and answers:
703
+ answer_lines = [f"**{k}**: {v}" for k, v in answers.items()]
704
+
705
+ parts = []
706
+ if answer_lines:
707
+ parts.append("Here are my answers to your questions:\n\n" + "\n".join(answer_lines))
708
+ if comment:
709
+ parts.append(f"Additional comment: {comment}")
710
+ answer_text = "\n\n".join(parts)
711
+
712
+ agent_msg = {
713
+ "type": "user",
714
+ "message": {
715
+ "role": "user",
716
+ "content": answer_text
717
+ }
718
+ }
719
+ logger.info(f"Sending question answers as text: {answer_text[:100]}...")
720
+
721
+ # Start Claude if not running (auto-denial stops process)
722
+ if not agent_session.is_running:
723
+ if not await agents.start_agent(agent_session):
724
+ await websocket.send_json({
725
+ "type": "error",
726
+ "message": agent_session.start_error or "Failed to start Claude Code"
727
+ })
728
+ return
729
+
730
+ # Send to Claude
731
+ if not await agents.send_to_agent(agent_session, agent_msg):
732
+ await websocket.send_json({
733
+ "type": "error",
734
+ "message": "Failed to send answers to Claude"
735
+ })
736
+ return
737
+
738
+ # Log to session store
739
+ if store_id:
740
+ SessionStore.add_message(store_id, {
741
+ "role": "user",
742
+ "content": answer_text,
743
+ "is_question_answer": True,
744
+ "tool_use_id": tool_use_id,
745
+ "answers": answers,
746
+ "comment": comment,
747
+ "timestamp": datetime.now(timezone.utc).isoformat(),
748
+ })
749
+
750
+
751
+ async def _handle_permission_response(websocket, agent_session, data, agents) -> None:
752
+ """Handle a `permission_response` — the user's allow/deny for an
753
+ interactive permission ask (capabilities.interactive_permissions).
754
+
755
+ Relays the decision to the provider process, which is blocked in its
756
+ `can_use_tool` callback. A False return means the request expired (the
757
+ process restarted or died) — tell the client so it retires the card.
758
+ """
759
+ ok = await agents.respond_permission(agent_session, data)
760
+ # Broadcast (not just to the answering socket): with multi-client attach,
761
+ # every peer tab shows the same pending card — all of them must retire it
762
+ # when any one client answers.
763
+ resolved = {
764
+ "type": "permission_resolved",
765
+ "request_id": data.get("request_id"),
766
+ "behavior": data.get("behavior"),
767
+ "ok": ok,
768
+ }
769
+ if not await agent_session.safe_send(resolved):
770
+ await websocket.send_json(resolved)
771
+
772
+
773
+ async def _handle_set_permission_mode(websocket, agent_session, store_id, data, agents) -> None:
774
+ """Handle a `set_permission_mode` request — record the mode and apply it.
775
+
776
+ Records the desired mode and echoes it to the client (so the permission
777
+ button updates immediately). Providers with `capabilities.live_controls`
778
+ (claude-sdk) get the mode applied to the *running* engine in place — even
779
+ mid-turn — via a control frame; the reply carries `applied: "live"`. All
780
+ other cases keep the lazy path: `_handle_user_message` respawns the idle
781
+ process on the next message when the mode differs from what it launched
782
+ with (the same universal path used for token-profile / model changes),
783
+ and the reply says `applied: "next_turn"`.
784
+ """
785
+ mode = data.get("mode") # a Claude mode, a provider-native mode (e.g. Codex
786
+ # workspace-write), or None. Validated against the registry so the active
787
+ # session's engine vocabulary is accepted, not just Claude's.
788
+ from painapple_code.providers import valid_permission_values
789
+ valid_modes = valid_permission_values() | {None}
790
+ if mode not in valid_modes:
791
+ await websocket.send_json({
792
+ "type": "error",
793
+ "message": f"Invalid permission mode: {mode}. Valid: {valid_modes}"
794
+ })
795
+ return
796
+
797
+ logger.info(f"Setting permission mode to: {mode} for session {store_id}")
798
+
799
+ # Set desired permission mode (None = use bypassPermissions default)
800
+ agent_session.permission_mode = mode if mode != "bypassPermissions" else None
801
+
802
+ # Arm the plan-approval SIGINT gate when entering plan mode; disarm otherwise.
803
+ # Without this, ExitPlanMode would never trigger an approval card (or would
804
+ # trigger one stale after the user left plan mode for another mode).
805
+ agent_session._plan_sigint_armed = (mode == "plan")
806
+
807
+ # Live apply (capabilities.live_controls): switch the running engine in
808
+ # place — even mid-turn — instead of waiting for the lazy respawn. The
809
+ # launched-mode bookkeeping is updated ONLY on ack, so a failed control
810
+ # leaves the lazy check armed and the next message respawns exactly as
811
+ # before. Guard: a process launched in bypassPermissions has no
812
+ # can_use_tool gate attached (claude_sdk/driver.py) — a live switch out
813
+ # of bypass would leave an ask-capable mode with nothing to answer
814
+ # prompts, so that direction must take the respawn path.
815
+ applied = "next_turn"
816
+ if (agent_session.provider
817
+ and agent_session.provider.capabilities.live_controls
818
+ and agent_session.process and agent_session.is_running
819
+ and agent_session._launched_resolved_mode != "bypassPermissions"):
820
+ target = mode or "bypassPermissions"
821
+ if await agents.send_control(agent_session, "set_permission_mode",
822
+ {"mode": target}):
823
+ # Only the desired-mode snapshot moves; _launched_resolved_mode
824
+ # stays at the launch value — gate attachment is a spawn-time
825
+ # property, so a live switch INTO bypass must not block a later
826
+ # live switch back out (the gate is still attached).
827
+ agent_session._launched_permission_mode = agent_session.permission_mode
828
+ applied = "live"
829
+ logger.info(f"Permission mode live-applied for {store_id}: {target}")
830
+
831
+ # Notify client of mode change (updates the permission button immediately)
832
+ await websocket.send_json({
833
+ "type": "permission_mode_changed",
834
+ "mode": mode or "bypassPermissions",
835
+ "applied": applied,
836
+ "message": f"Permission mode set to: {mode or 'bypassPermissions'}"
837
+ })