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,742 @@
1
+ """
2
+ Sessions API Routes - Session CRUD, search, fork, threads
3
+
4
+ These endpoints manage:
5
+ - Session listing, creation, deletion, renaming
6
+ - Session search (keyword and AI-powered)
7
+ - Session preview, thinking tokens
8
+ - Session fork, graduate, threads
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from datetime import datetime, timezone
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from pydantic import BaseModel
17
+
18
+ from painapple_code import paths
19
+ from painapple_code.session_store import SessionStore
20
+ from painapple_code.utils.file_paths import safe_resolve
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(tags=["sessions"])
25
+
26
+
27
+ # ═══════════════════════════════════════════════════════════════════
28
+ # Session CRUD
29
+ # ═══════════════════════════════════════════════════════════════════
30
+
31
+ @router.get("/api/sessions")
32
+ async def list_sessions():
33
+ """List all stored sessions (filters out ungraduated discussion threads)."""
34
+ all_sessions = SessionStore.list_all()
35
+ visible_sessions = [
36
+ s for s in all_sessions
37
+ if not s.get("isCommentThread") or s.get("graduated")
38
+ ]
39
+ return {"sessions": visible_sessions}
40
+
41
+
42
+ @router.post("/api/sessions")
43
+ async def create_session(request: Request, cwd: str, name: str = None, provider: str = None):
44
+ """Create a new session.
45
+
46
+ `provider` picks the engine for this session (recorded in meta, permanent —
47
+ the session's provider_session_id only means anything to that engine).
48
+ Unknown names 400 — REST callers asked for it explicitly, so failing loud
49
+ beats a silent claude-sdk fallback. Omitted → the same effective default a
50
+ WS-created session gets (--default-provider flag / `default_provider`
51
+ config key), so REST- and WS-created sessions no longer diverge.
52
+ """
53
+ from painapple_code.providers import get_provider, provider_names
54
+ if provider is not None and provider not in provider_names():
55
+ raise HTTPException(
56
+ status_code=400,
57
+ detail=f"Unknown provider: {provider}. Valid: {provider_names()}",
58
+ )
59
+ resolved_cwd = str(safe_resolve(cwd))
60
+ session_data = SessionStore.create(resolved_cwd, name)
61
+ agents = getattr(request.app.state, "agents", None)
62
+ provider_name = (provider
63
+ or getattr(agents, "default_provider", None)
64
+ or paths.load_global_config().get("default_provider"))
65
+ if provider_name:
66
+ SessionStore.update_metadata(session_data["id"], provider=provider_name)
67
+ session_data["provider"] = provider_name
68
+ # Bind-time permission anchoring — same contract as the WS create
69
+ # path: a global default level this engine doesn't speak is replaced
70
+ # by the engine's own default so meta, UI, and launch agree.
71
+ from painapple_code.routes.dependencies import bind_permission_level
72
+ anchored = bind_permission_level(None, get_provider(provider_name))
73
+ if anchored:
74
+ SessionStore.update_metadata(
75
+ session_data["id"], permission_level=anchored)
76
+ session_data["permission_level"] = anchored
77
+ return session_data
78
+
79
+
80
+ @router.get("/api/session/{session_id}")
81
+ async def get_session(session_id: str):
82
+ """Get a session by ID (includes messages)."""
83
+ data = SessionStore.load(session_id)
84
+ if not data:
85
+ raise HTTPException(status_code=404, detail="Session not found")
86
+ return data
87
+
88
+
89
+ @router.delete("/api/session/{session_id}")
90
+ async def delete_session(session_id: str):
91
+ """Delete a session's ON-DISK store (rmtree of the session directory).
92
+
93
+ Deliberately narrow, and as of 2026-08 called by NO UI surface — the
94
+ Active Sessions widget's Stop button pointed here for 8 months as an
95
+ explicit "for now" shortcut (b8b36bda) and silently destroyed session
96
+ data; it now uses /stop below. This endpoint does NOT stop a live agent
97
+ process, NOT remove the in-memory session, and NOT touch DuckDB turns or
98
+ shadow-git commits (retained by design — see SECURITY.md). If a real
99
+ "delete session" UI is ever added, it must not point here as-is: stop the
100
+ agent first and say what is retained.
101
+ """
102
+ if SessionStore.delete(session_id):
103
+ return {"deleted": True, "id": session_id}
104
+ raise HTTPException(status_code=404, detail="Session not found")
105
+
106
+
107
+ @router.post("/api/session/{session_id}/stop")
108
+ async def stop_session(session_id: str):
109
+ """Interrupt a session's in-flight response. Data is untouched.
110
+
111
+ REST twin of the WebSocket `stop` message (`_handle_stop` in ws_chat.py),
112
+ for surfaces that hold no socket to the session — the Active Sessions
113
+ widget lists every live session but is attached to none of them. Same
114
+ engine-aware path (`interrupt_agent`): JSON-RPC turn/interrupt or SDK
115
+ control-plane where the provider supports it (process stays warm), SIGINT
116
+ otherwise. Precedent: f513748a gave worktrees a stop endpoint after its
117
+ widget's stop button was found calling DELETE — the same bug shape this
118
+ endpoint exists to close.
119
+
120
+ 404: no live session by that id (stored-but-not-running sessions have no
121
+ process to interrupt). 200 {stopped: false}: session is live but idle —
122
+ reachable by racing a turn's natural end, so it is a state, not an error.
123
+ The idle check happens HERE, not in interrupt_agent: a warm-idle claude-sdk
124
+ process acks a control-plane interrupt even with nothing in flight, so
125
+ without this guard the race would report "stopped: true" for a no-op.
126
+ """
127
+ from painapple_code.server import agents
128
+
129
+ session = agents.sessions.get(session_id) if agents else None
130
+ if session is None:
131
+ raise HTTPException(status_code=404, detail="No live session with that id")
132
+
133
+ if session.is_idle:
134
+ return {"stopped": False, "id": session_id}
135
+
136
+ stopped = await agents.interrupt_agent(session)
137
+ return {"stopped": stopped, "id": session_id}
138
+
139
+
140
+ @router.patch("/api/sessions/{session_id}/meta")
141
+ async def update_session_meta(session_id: str, request: Request):
142
+ """Update session metadata (name)."""
143
+ meta = SessionStore.load_meta(session_id)
144
+ if not meta:
145
+ raise HTTPException(status_code=404, detail="Session not found")
146
+
147
+ body = await request.json()
148
+
149
+ if "name" in body:
150
+ SessionStore.update_metadata(session_id, name=body["name"])
151
+ elif "description" in body:
152
+ SessionStore.update_metadata(session_id, name=body["description"])
153
+
154
+ updated_meta = SessionStore.load_meta(session_id)
155
+ return {"id": session_id, "meta": updated_meta}
156
+
157
+
158
+ class RenameRequest(BaseModel):
159
+ name: str
160
+
161
+
162
+ @router.post("/api/sessions/{session_id}/rename")
163
+ async def rename_session(session_id: str, request: RenameRequest):
164
+ """Rename a session. Sets manual_name flag to prevent summary auto-overwrite."""
165
+ data = SessionStore.load(session_id)
166
+ if not data:
167
+ raise HTTPException(status_code=404, detail="Session not found")
168
+
169
+ SessionStore.save(session_id, {
170
+ "name": request.name,
171
+ "manual_name": True,
172
+ })
173
+
174
+ return {"success": True, "session_id": session_id, "name": request.name}
175
+
176
+
177
+ # ═══════════════════════════════════════════════════════════════════
178
+ # Session Thinking Tokens
179
+ # ═══════════════════════════════════════════════════════════════════
180
+
181
+ def _get_max_thinking_tokens() -> int:
182
+ """Get the max thinking tokens from config or default."""
183
+ config = paths.load_global_config()
184
+ return config.get("max_thinking_tokens", 31999)
185
+
186
+
187
+ @router.get("/api/session/{session_id}/thinking-tokens")
188
+ async def get_session_thinking_tokens(session_id: str):
189
+ """Get the max thinking tokens for a specific session."""
190
+ meta = SessionStore.load_meta(session_id)
191
+ if not meta:
192
+ raise HTTPException(status_code=404, detail="Session not found")
193
+
194
+ session_tokens = meta.get("max_thinking_tokens")
195
+ global_default = _get_max_thinking_tokens()
196
+
197
+ return {
198
+ "max_thinking_tokens": session_tokens if session_tokens is not None else global_default,
199
+ "is_session_override": session_tokens is not None,
200
+ "global_default": global_default,
201
+ }
202
+
203
+
204
+ @router.put("/api/session/{session_id}/thinking-tokens")
205
+ async def set_session_thinking_tokens(session_id: str, request: Request):
206
+ """Set the max thinking tokens for a specific session."""
207
+ meta = SessionStore.load_meta(session_id)
208
+ if not meta:
209
+ raise HTTPException(status_code=404, detail="Session not found")
210
+
211
+ body = await request.json()
212
+ value = body.get("max_thinking_tokens")
213
+
214
+ if value is not None:
215
+ try:
216
+ value = int(value)
217
+ except (ValueError, TypeError):
218
+ raise HTTPException(status_code=400, detail="max_thinking_tokens must be an integer or null")
219
+
220
+ if value < 0:
221
+ raise HTTPException(status_code=400, detail="max_thinking_tokens cannot be negative")
222
+ if value > 63999:
223
+ raise HTTPException(status_code=400, detail="max_thinking_tokens cannot exceed 63999")
224
+
225
+ meta["max_thinking_tokens"] = value
226
+ SessionStore.update_metadata(session_id, max_thinking_tokens=value)
227
+
228
+ logger.info(f"Session {session_id} thinking tokens set to: {value}")
229
+
230
+ return await get_session_thinking_tokens(session_id)
231
+
232
+
233
+ # ═══════════════════════════════════════════════════════════════════
234
+ # Session Permission Level
235
+ # ═══════════════════════════════════════════════════════════════════
236
+
237
+ def _get_default_permission_level() -> str:
238
+ """Get the default permission level from config, falling back to the default
239
+ provider's own default (no hardcoded engine vocabulary)."""
240
+ from painapple_code.providers import get_provider, DEFAULT_PROVIDER
241
+ config = paths.load_global_config()
242
+ return config.get("default_permission_level") or get_provider(DEFAULT_PROVIDER).default_permission_mode()
243
+
244
+
245
+ @router.get("/api/session/{session_id}/permission-mode")
246
+ async def get_session_permission_mode(session_id: str, request: Request):
247
+ """Get the permission mode for a specific session, plus the mode vocabulary
248
+ of the engine that session runs on (drives the client's permission picker —
249
+ each provider self-describes its modes, e.g. claude-sdk adds "Ask")."""
250
+ meta = SessionStore.load_meta(session_id)
251
+ if not meta:
252
+ raise HTTPException(status_code=404, detail="Session not found")
253
+
254
+ session_level = meta.get("permission_level")
255
+ global_default = _get_default_permission_level()
256
+
257
+ from painapple_code.providers import get_provider
258
+ from painapple_code.routes.dependencies import effective_default_provider
259
+ provider = (get_provider(meta["provider"]) if meta.get("provider")
260
+ else effective_default_provider(request.app))
261
+
262
+ return {
263
+ "permission_level": session_level if session_level is not None else global_default,
264
+ "is_session_override": session_level is not None,
265
+ "global_default": global_default,
266
+ "modes": provider.permission_modes(),
267
+ "provider_default": provider.default_permission_mode(),
268
+ }
269
+
270
+
271
+ @router.put("/api/session/{session_id}/permission-mode")
272
+ async def set_session_permission_mode(session_id: str, request: Request):
273
+ """Set the permission mode for a specific session."""
274
+ meta = SessionStore.load_meta(session_id)
275
+ if not meta:
276
+ raise HTTPException(status_code=404, detail="Session not found")
277
+
278
+ body = await request.json()
279
+ value = body.get("permission_level")
280
+
281
+ from painapple_code.providers import valid_permission_values
282
+ if value is not None and value not in valid_permission_values():
283
+ raise HTTPException(status_code=400, detail=f"Invalid permission level: {value}")
284
+
285
+ SessionStore.update_metadata(session_id, permission_level=value)
286
+ logger.info(f"Session {session_id} permission level set to: {value}")
287
+
288
+ return await get_session_permission_mode(session_id, request)
289
+
290
+
291
+ # ═══════════════════════════════════════════════════════════════════
292
+ # Session Token Profile
293
+ # ═══════════════════════════════════════════════════════════════════
294
+
295
+ @router.get("/api/session/{session_id}/token-profile")
296
+ async def get_session_token_profile(session_id: str):
297
+ """Get the token profile for a specific session."""
298
+ meta = SessionStore.load_meta(session_id)
299
+ if not meta:
300
+ raise HTTPException(status_code=404, detail="Session not found")
301
+
302
+ from painapple_code.providers import get_provider
303
+ session_profile = meta.get("token_profile")
304
+ # The default shown/used is the SESSION ENGINE's configured profile.
305
+ global_default = paths.engine_default_token_profile(
306
+ get_provider(meta.get("provider")))
307
+
308
+ return {
309
+ "token_profile": session_profile if session_profile is not None else global_default,
310
+ "is_session_override": session_profile is not None,
311
+ "global_default": global_default,
312
+ }
313
+
314
+
315
+ @router.put("/api/session/{session_id}/token-profile")
316
+ async def set_session_token_profile(session_id: str, request: Request):
317
+ """Set the token profile for a specific session."""
318
+ meta = SessionStore.load_meta(session_id)
319
+ if not meta:
320
+ raise HTTPException(status_code=404, detail="Session not found")
321
+
322
+ body = await request.json()
323
+ value = body.get("token_profile") # string or null
324
+
325
+ # Validate profile exists
326
+ if value is not None:
327
+ from painapple_code.utils.token_profiles import list_profiles
328
+ profile_names = {p["name"] for p in list_profiles()}
329
+ if value not in profile_names:
330
+ raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
331
+
332
+ SessionStore.update_metadata(session_id, token_profile=value)
333
+
334
+ # Update live session and kill idle process so next turn uses new token
335
+ from painapple_code.server import agents
336
+ if agents:
337
+ live_session = agents.get_session(session_id)
338
+ if live_session:
339
+ live_session.token_profile = value
340
+ if live_session.is_idle and live_session.process:
341
+ live_session._interrupting = True # suppress session_ended WS msg
342
+ await agents.stop_session(live_session)
343
+ logger.info(f"Killed idle process for session {session_id} (token profile changed)")
344
+
345
+ logger.info(f"Session {session_id} token profile set to: {value}")
346
+
347
+ return await get_session_token_profile(session_id)
348
+
349
+
350
+ # ═══════════════════════════════════════════════════════════════════
351
+ # Session Preferred Model
352
+ # ═══════════════════════════════════════════════════════════════════
353
+
354
+ @router.get("/api/session/{session_id}/model")
355
+ async def get_session_model(session_id: str):
356
+ """Get the preferred model for a specific session."""
357
+ meta = SessionStore.load_meta(session_id)
358
+ if not meta:
359
+ raise HTTPException(status_code=404, detail="Session not found")
360
+
361
+ from painapple_code.providers import get_provider
362
+ session_model = meta.get("preferred_model")
363
+ # The default shown by the chip is the SESSION ENGINE's configured model
364
+ # (models_key-scoped map, legacy flat key as fallback).
365
+ global_default = paths.engine_default_model(
366
+ get_provider(meta.get("provider")))
367
+
368
+ return {
369
+ "preferred_model": session_model if session_model is not None else global_default,
370
+ "is_session_override": session_model is not None,
371
+ "global_default": global_default,
372
+ }
373
+
374
+
375
+ @router.put("/api/session/{session_id}/model")
376
+ async def set_session_model(session_id: str, request: Request):
377
+ """Set the preferred model for a specific session."""
378
+ meta = SessionStore.load_meta(session_id)
379
+ if not meta:
380
+ raise HTTPException(status_code=404, detail="Session not found")
381
+
382
+ body = await request.json()
383
+ value = body.get("preferred_model") # string or null
384
+
385
+ SessionStore.update_metadata(session_id, preferred_model=value)
386
+
387
+ # Update live session and kill idle process so next turn uses new model
388
+ from painapple_code.server import agents
389
+ if agents:
390
+ live_session = agents.get_session(session_id)
391
+ if live_session:
392
+ live_session.preferred_model = value
393
+ if live_session.is_idle and live_session.process:
394
+ live_session._interrupting = True
395
+ await agents.stop_session(live_session)
396
+ logger.info(f"Killed idle process for session {session_id} (model changed to: {value})")
397
+
398
+ logger.info(f"Session {session_id} preferred model set to: {value}")
399
+
400
+ return await get_session_model(session_id)
401
+
402
+
403
+ # ═══════════════════════════════════════════════════════════════════
404
+ # Session Effort Level
405
+ # ═══════════════════════════════════════════════════════════════════
406
+
407
+ VALID_EFFORT_LEVELS = {"low", "medium", "high", "xhigh", "max"}
408
+
409
+
410
+ @router.get("/api/session/{session_id}/effort")
411
+ async def get_session_effort(session_id: str):
412
+ """Get the effort level for a specific session."""
413
+ meta = SessionStore.load_meta(session_id)
414
+ if not meta:
415
+ raise HTTPException(status_code=404, detail="Session not found")
416
+
417
+ from painapple_code.providers import get_provider
418
+ session_effort = meta.get("effort_level")
419
+ # The default is the SESSION ENGINE's configured effort (vocab-gated).
420
+ global_default = paths.engine_default_effort(
421
+ get_provider(meta.get("provider"))) or "high"
422
+
423
+ return {
424
+ "effort_level": session_effort if session_effort is not None else global_default,
425
+ "is_session_override": session_effort is not None,
426
+ "global_default": global_default,
427
+ }
428
+
429
+
430
+ @router.put("/api/session/{session_id}/effort")
431
+ async def set_session_effort(session_id: str, request: Request):
432
+ """Set the effort level for a specific session."""
433
+ meta = SessionStore.load_meta(session_id)
434
+ if not meta:
435
+ raise HTTPException(status_code=404, detail="Session not found")
436
+
437
+ body = await request.json()
438
+ value = body.get("effort_level") # string or null
439
+
440
+ if value is not None:
441
+ # Validate against the SESSION ENGINE's own effort vocabulary — Codex
442
+ # speaks a richer scale (xhigh/max/ultra, per its models cache) than the
443
+ # Claude 5-level fallback, so a hardcoded set would reject valid levels.
444
+ from painapple_code.providers import get_provider
445
+ provider = get_provider(meta.get("provider"))
446
+ valid = set(provider.effort_levels() or VALID_EFFORT_LEVELS)
447
+ if value not in valid:
448
+ raise HTTPException(status_code=400, detail=f"Invalid effort level: {value}. Valid: {sorted(valid)}")
449
+
450
+ SessionStore.update_metadata(session_id, effort_level=value)
451
+
452
+ # Update live session and kill idle process so next turn uses new effort
453
+ from painapple_code.server import agents
454
+ if agents:
455
+ live_session = agents.get_session(session_id)
456
+ if live_session:
457
+ live_session.effort_level = value
458
+ if live_session.is_idle and live_session.process:
459
+ live_session._interrupting = True
460
+ await agents.stop_session(live_session)
461
+ logger.info(f"Killed idle process for session {session_id} (effort changed to: {value})")
462
+
463
+ logger.info(f"Session {session_id} effort level set to: {value}")
464
+
465
+ return await get_session_effort(session_id)
466
+
467
+
468
+ # ═══════════════════════════════════════════════════════════════════
469
+ # Session Fork
470
+ # ═══════════════════════════════════════════════════════════════════
471
+
472
+ @router.get("/api/session/{session_id}/provider")
473
+ async def get_session_provider(session_id: str, request: Request):
474
+ """This session's engine + whether it can still be changed.
475
+
476
+ `locked` flips permanently once the session has run a turn (or holds an
477
+ upstream provider_session_id) — the conversation lives in that engine's
478
+ own on-disk format and can't transfer."""
479
+ from painapple_code.routes.dependencies import effective_default_provider, provider_is_locked
480
+ meta = SessionStore.load_meta(session_id)
481
+ if not meta:
482
+ raise HTTPException(status_code=404, detail="Session not found")
483
+ configured = meta.get("provider")
484
+ default_name = effective_default_provider(request.app).name
485
+ return {
486
+ "provider": configured or default_name,
487
+ "is_session_override": bool(configured),
488
+ "global_default": default_name,
489
+ "locked": provider_is_locked(meta),
490
+ }
491
+
492
+
493
+ @router.put("/api/session/{session_id}/provider")
494
+ async def set_session_provider(session_id: str, request: Request):
495
+ """Switch an EMPTY session to another engine (409 once locked).
496
+
497
+ Also hot-swaps a live in-memory session: the provider object and its cost
498
+ state are replaced in place, and an idle warm process (spawned for the old
499
+ engine) is stopped so the next message launches on the new one."""
500
+ from painapple_code.providers import get_provider, provider_names
501
+ from painapple_code.routes.dependencies import provider_is_locked
502
+ meta = SessionStore.load_meta(session_id)
503
+ if not meta:
504
+ raise HTTPException(status_code=404, detail="Session not found")
505
+
506
+ body = await request.json()
507
+ value = body.get("provider") # string, or null to clear back to default
508
+
509
+ if value is not None and value not in provider_names():
510
+ raise HTTPException(
511
+ status_code=400,
512
+ detail=f"Unknown provider: {value}. Valid: {provider_names()}",
513
+ )
514
+ if provider_is_locked(meta):
515
+ raise HTTPException(
516
+ status_code=409,
517
+ detail="Session already ran on its engine — provider is locked. Start a new session to switch.",
518
+ )
519
+
520
+ SessionStore.update_metadata(session_id, provider=value)
521
+
522
+ # Re-anchor the permission level to the new engine's vocabulary: a level
523
+ # from the old engine (Claude's dontAsk on a switch to Codex, a sandbox
524
+ # tier on the way back) would launch through a silent back-compat mapping
525
+ # the UI can't label. Stamping the new engine's own default keeps meta,
526
+ # UI, and launch agreeing. A level both engines speak survives.
527
+ from painapple_code.routes.dependencies import (
528
+ bind_permission_level,
529
+ preferred_model_survives,
530
+ )
531
+ anchored = bind_permission_level(
532
+ meta.get("permission_level"), get_provider(value))
533
+ if anchored:
534
+ SessionStore.update_metadata(session_id, permission_level=anchored)
535
+
536
+ # Same contract for the model pick: a preferred_model from the old
537
+ # engine's catalog (a Claude id on a switch to Codex, a gpt id on the way
538
+ # back) is cleared so the session falls back to the new engine's own
539
+ # default instead of launching a model the engine drops or rejects.
540
+ model_cleared = not preferred_model_survives(
541
+ meta.get("preferred_model"), get_provider(value))
542
+ if model_cleared:
543
+ SessionStore.update_metadata(session_id, preferred_model=None)
544
+
545
+ agents = getattr(request.app.state, "agents", None)
546
+ if agents:
547
+ live_session = agents.get_session(session_id)
548
+ if live_session:
549
+ live_session.provider = get_provider(value)
550
+ live_session._cost_state = live_session.provider.new_cost_state()
551
+ # Mirror the anchoring into the in-memory overrides — they win
552
+ # over meta at launch, so a stale old-engine value here would
553
+ # undo the re-stamp above.
554
+ if anchored:
555
+ live_session.permission_mode = anchored
556
+ if model_cleared:
557
+ live_session.preferred_model = None
558
+ if live_session.is_idle and live_session.process:
559
+ live_session._interrupting = True
560
+ await agents.stop_session(live_session)
561
+
562
+ return await get_session_provider(session_id, request)
563
+
564
+
565
+ @router.post("/api/session/{session_id}/fork")
566
+ async def fork_session(session_id: str, comment_thread: bool = False, thread_anchor: str = None):
567
+ """Fork a session - create a new session that branches from the source."""
568
+ source_meta = SessionStore.load_meta(session_id)
569
+ if not source_meta:
570
+ raise HTTPException(status_code=404, detail="Source session not found")
571
+
572
+ provider_session_id = source_meta.get("provider_session_id")
573
+ if not provider_session_id:
574
+ raise HTTPException(
575
+ status_code=400,
576
+ detail="Cannot fork: source session has no Claude conversation"
577
+ )
578
+
579
+ # Engines that can't branch a conversation (capabilities.fork=False, e.g.
580
+ # ephemeral `codex exec`) fail loud here rather than minting a fork meta
581
+ # that would error on its first turn.
582
+ from painapple_code.providers import get_provider
583
+ source_provider = get_provider(source_meta.get("provider"))
584
+ if not source_provider.capabilities.fork:
585
+ raise HTTPException(
586
+ status_code=409,
587
+ detail=f"{source_provider.display_name} does not support forking sessions",
588
+ )
589
+
590
+ cwd = source_meta.get("cwd", ".")
591
+ source_name = source_meta.get("name", "Session")
592
+
593
+ if comment_thread:
594
+ new_name = f"\U0001f4ac Thread from {source_name}"
595
+ else:
596
+ new_name = f"Fork of {source_name}"
597
+
598
+ new_session = SessionStore.create(cwd, name=new_name)
599
+
600
+ store, meta = SessionStore._find_session(new_session["id"])
601
+ if store and meta:
602
+ meta["forked_from"] = {
603
+ "store_id": session_id,
604
+ "provider_session_id": provider_session_id,
605
+ }
606
+ if comment_thread:
607
+ meta["isCommentThread"] = True
608
+ if thread_anchor:
609
+ try:
610
+ meta["threadAnchor"] = json.loads(thread_anchor)
611
+ except json.JSONDecodeError:
612
+ meta["threadAnchor"] = thread_anchor
613
+ # Inherit token_profile from source session
614
+ source_token_profile = source_meta.get("token_profile")
615
+ if source_token_profile:
616
+ meta["token_profile"] = source_token_profile
617
+ # Inherit the source's engine + launch settings so the branch continues
618
+ # on the same provider/model/effort/permissions rather than the app
619
+ # defaults. `provider` is essential for correctness: without it the fork
620
+ # falls back to the default engine (Claude) and can't resume the source's
621
+ # thread — e.g. a Codex thread id handed to Claude resumes nothing and the
622
+ # turn errors. (Native forking only happens within one engine.)
623
+ for key in ("provider", "permission_mode", "model", "effort"):
624
+ val = source_meta.get(key)
625
+ if val is not None:
626
+ meta[key] = val
627
+ store._write_meta(new_session["id"], meta)
628
+
629
+ logger.info(f"Created fork session {new_session['id']} from {session_id} (claude: {provider_session_id}, comment_thread={comment_thread})")
630
+
631
+ return {
632
+ "id": new_session["id"],
633
+ "cwd": cwd,
634
+ "forked_from": session_id,
635
+ "provider_session_id": provider_session_id,
636
+ "isCommentThread": comment_thread,
637
+ }
638
+
639
+
640
+ # ═══════════════════════════════════════════════════════════════════
641
+ # Session Graduate (promote thread to session)
642
+ # ═══════════════════════════════════════════════════════════════════
643
+
644
+ @router.post("/api/session/{session_id}/graduate")
645
+ async def graduate_session(session_id: str):
646
+ """Graduate a discussion thread to a full session."""
647
+ store, meta = SessionStore._find_session(session_id)
648
+ if not store or not meta:
649
+ raise HTTPException(status_code=404, detail="Session not found")
650
+
651
+ if not meta.get("isCommentThread"):
652
+ raise HTTPException(status_code=400, detail="Session is not a discussion thread")
653
+
654
+ if meta.get("graduated"):
655
+ return {
656
+ "id": session_id,
657
+ "graduated": True,
658
+ "graduated_at": meta.get("graduated_at"),
659
+ "already_graduated": True
660
+ }
661
+
662
+ graduated_at = datetime.now(timezone.utc).isoformat()
663
+
664
+ meta["graduated"] = True
665
+ meta["graduated_at"] = graduated_at
666
+
667
+ original_name = meta.get("name", "Thread")
668
+ if original_name.startswith("\U0001f4ac Thread"):
669
+ meta["name"] = original_name.replace("\U0001f4ac Thread", "\U0001f4ac\u2192 Discussion")
670
+
671
+ store._write_meta(session_id, meta)
672
+
673
+ logger.info(f"Graduated discussion thread {session_id} to full session")
674
+
675
+ return {
676
+ "id": session_id,
677
+ "graduated": True,
678
+ "graduated_at": graduated_at,
679
+ "name": meta.get("name"),
680
+ "cwd": meta.get("cwd"),
681
+ "parent_session_id": meta.get("forked_from", {}).get("store_id")
682
+ }
683
+
684
+
685
+ # ═══════════════════════════════════════════════════════════════════
686
+ # Session Threads
687
+ # ═══════════════════════════════════════════════════════════════════
688
+
689
+ @router.get("/api/session/{session_id}/threads")
690
+ async def get_session_threads(session_id: str):
691
+ """Get all comment threads for a parent session."""
692
+ all_sessions = SessionStore.list_all()
693
+ threads = []
694
+
695
+ for session in all_sessions:
696
+ if not session.get("isCommentThread"):
697
+ continue
698
+
699
+ forked_from = session.get("forked_from")
700
+ if not forked_from:
701
+ continue
702
+
703
+ parent_id = forked_from.get("store_id") if isinstance(forked_from, dict) else forked_from
704
+ if parent_id != session_id:
705
+ continue
706
+
707
+ store, _ = SessionStore._find_session(session["id"])
708
+ messages = []
709
+ if store:
710
+ messages_path = store._messages_path(session["id"])
711
+ if messages_path.exists():
712
+ try:
713
+ with open(messages_path, "r", encoding="utf-8") as f:
714
+ for line in f:
715
+ line = line.strip()
716
+ if line:
717
+ try:
718
+ msg = json.loads(line)
719
+ if msg.get("role") in ("user", "assistant"):
720
+ messages.append({
721
+ "role": msg.get("role"),
722
+ "content": msg.get("content", ""),
723
+ "timestamp": msg.get("timestamp"),
724
+ })
725
+ except json.JSONDecodeError:
726
+ pass
727
+ except Exception as e:
728
+ logger.warning(f"Error reading thread messages: {e}")
729
+
730
+ threads.append({
731
+ "id": session["id"],
732
+ "anchor": session.get("threadAnchor"),
733
+ "status": "resolved" if session.get("resolved") else "active",
734
+ "messages": messages,
735
+ "total_cost": session.get("total_cost", 0),
736
+ "created_at": session.get("created_at"),
737
+ "last_activity": session.get("last_activity"),
738
+ })
739
+
740
+ threads.sort(key=lambda t: t.get("created_at", ""), reverse=True)
741
+
742
+ return {"threads": threads, "count": len(threads)}