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,997 @@
1
+ """
2
+ App Config API Routes - app-wide settings.
3
+
4
+ These endpoints manage:
5
+ - Quick Action Presets (~/.painapple-code/presets/*.json)
6
+ - Engine CLI binary paths (per-provider, self-described config keys)
7
+ - Max thinking tokens
8
+ - API auto-retry max
9
+ - Default permission levels
10
+ - Token profiles
11
+ - Models (selectable list + summary_model)
12
+ - Default effort level
13
+ - Session timeout settings
14
+ """
15
+
16
+ import asyncio
17
+ import logging
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ from fastapi import APIRouter, HTTPException, Request
23
+
24
+ from painapple_code import paths
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter(tags=["app:config"])
29
+
30
+ # Default max thinking tokens (31999 to stay under limit, 63999 is Opus 4.5 max)
31
+ DEFAULT_MAX_THINKING_TOKENS = 31999
32
+
33
+
34
+ # ═══════════════════════════════════════════════════════════════════
35
+ # Quick Action Presets (~/.painapple-code/presets/*.json)
36
+ # ═══════════════════════════════════════════════════════════════════
37
+
38
+ @router.get("/api/app/presets")
39
+ async def get_presets():
40
+ """List all quick-action presets (one JSON file per preset)."""
41
+ return paths.load_all_presets()
42
+
43
+
44
+ @router.put("/api/app/presets/{preset_id}")
45
+ async def save_preset(preset_id: str, data: dict):
46
+ """Create or update a preset."""
47
+ try:
48
+ paths.save_preset(preset_id, data)
49
+ except ValueError as e:
50
+ raise HTTPException(status_code=400, detail=str(e))
51
+ return paths.load_all_presets()
52
+
53
+
54
+ @router.delete("/api/app/presets/{preset_id}")
55
+ async def delete_preset(preset_id: str):
56
+ """Delete a preset file."""
57
+ try:
58
+ deleted = paths.delete_preset(preset_id)
59
+ except ValueError as e:
60
+ raise HTTPException(status_code=400, detail=str(e))
61
+ return {"deleted": deleted, "presets": paths.load_all_presets()}
62
+
63
+
64
+ # ═══════════════════════════════════════════════════════════════════
65
+ # Engine CLI paths (per-provider binary override, self-described key)
66
+ # ═══════════════════════════════════════════════════════════════════
67
+
68
+ def _provider_or_404(name: str):
69
+ from painapple_code.providers import get_provider, provider_names
70
+ if name not in provider_names():
71
+ raise HTTPException(status_code=404, detail=f"Unknown provider: {name}")
72
+ return get_provider(name)
73
+
74
+
75
+ async def _engine_path_payload(p) -> dict:
76
+ """Current binary override + resolution/version probe for one engine.
77
+
78
+ ``path`` is the raw config value (None = unset → ``default_binary`` from
79
+ PATH). Same-engine driver variants share a config key (both providers
80
+ self-describe the same one), so the payload is identical across them.
81
+ """
82
+ if not p.path_config_key:
83
+ return {"provider": p.name, "configurable": False}
84
+ config = paths.load_global_config()
85
+ configured = config.get(p.path_config_key)
86
+ current = configured or p.default_binary
87
+ resolved = current if Path(current).is_absolute() else shutil.which(current)
88
+
89
+ version = None
90
+ if resolved:
91
+ try:
92
+ # Off the event loop — a hung `<cli> --version` would otherwise
93
+ # block every other request for up to 5s.
94
+ result = await asyncio.to_thread(
95
+ subprocess.run,
96
+ # `resolved`, not `current`: on win32 a bare npm-shim name
97
+ # ("claude" → claude.cmd) isn't spawnable, but the full
98
+ # path shutil.which returned is.
99
+ [resolved, "--version"],
100
+ capture_output=True,
101
+ text=True, encoding="utf-8", errors="replace",
102
+ timeout=5,
103
+ )
104
+ if result.returncode == 0:
105
+ version = result.stdout.strip()
106
+ except Exception:
107
+ pass
108
+
109
+ return {
110
+ "provider": p.name,
111
+ "configurable": True,
112
+ "path": configured,
113
+ "default_binary": p.default_binary,
114
+ "resolved": resolved,
115
+ "version": version,
116
+ }
117
+
118
+
119
+ @router.get("/api/app/engine-path/{provider_name}")
120
+ async def get_engine_path(provider_name: str):
121
+ """Configured CLI binary for one engine (provider self-describes the
122
+ config key — nothing per-engine is hardcoded here)."""
123
+ return await _engine_path_payload(_provider_or_404(provider_name))
124
+
125
+
126
+ @router.put("/api/app/engine-path/{provider_name}")
127
+ async def set_engine_path(provider_name: str, request: Request):
128
+ """Set (or clear with null/empty) an engine's CLI binary path.
129
+
130
+ Explicit paths must exist and be files; clearing always succeeds (falls
131
+ back to ``default_binary`` on PATH — availability just reflects reality,
132
+ so a stale override can be removed even with the CLI uninstalled).
133
+ """
134
+ p = _provider_or_404(provider_name)
135
+ if not p.path_config_key:
136
+ raise HTTPException(
137
+ status_code=400,
138
+ detail=f"{p.display_name} has no configurable CLI path")
139
+
140
+ body = await request.json()
141
+ new_path = (body.get("path") or "").strip()
142
+
143
+ if new_path and new_path != p.default_binary:
144
+ if not Path(new_path).exists():
145
+ raise HTTPException(status_code=400, detail=f"Path does not exist: {new_path}")
146
+ if not Path(new_path).is_file():
147
+ raise HTTPException(status_code=400, detail=f"Path is not a file: {new_path}")
148
+
149
+ config = paths.load_global_config()
150
+ if not new_path or new_path == p.default_binary:
151
+ config.pop(p.path_config_key, None)
152
+ else:
153
+ config[p.path_config_key] = new_path
154
+
155
+ paths.save_global_config(config)
156
+ logger.info(f"Engine path for {p.name} ({p.path_config_key}) updated to: {new_path or '(default)'}")
157
+
158
+ return await _engine_path_payload(p)
159
+
160
+
161
+ # ═══════════════════════════════════════════════════════════════════
162
+ # Engine model visibility (per-model show/hide, self-described namespace)
163
+ # ═══════════════════════════════════════════════════════════════════
164
+
165
+ def _engine_models_payload(p) -> dict:
166
+ """One engine's FULL catalog with per-model `enabled` flags merged in.
167
+
168
+ Settings needs the unfiltered list (hidden models render with their
169
+ toggle off) — unlike /api/providers, which serves `enabled_models()`
170
+ so pickers only see what the user kept listed. `disabled` is the raw
171
+ stored set: it may carry ids not in the current catalog (CLI-owned
172
+ catalogs churn); those are preserved, not pruned.
173
+ """
174
+ disabled = p.disabled_model_ids()
175
+ return {
176
+ "provider": p.name,
177
+ "models_key": p.models_key or p.name,
178
+ "editable": p.models_editable,
179
+ "models": [
180
+ {**m, "enabled": m.get("id") not in disabled} for m in p.models()
181
+ ],
182
+ "disabled": sorted(disabled),
183
+ }
184
+
185
+
186
+ @router.get("/api/app/engine-models/{provider_name}")
187
+ async def get_engine_models(provider_name: str):
188
+ """Full model catalog + visibility flags for one engine."""
189
+ return _engine_models_payload(_provider_or_404(provider_name))
190
+
191
+
192
+ @router.put("/api/app/engine-models/{provider_name}")
193
+ async def set_engine_models(provider_name: str, request: Request):
194
+ """Replace the hidden-model set for one engine: ``{disabled: [ids]}``.
195
+
196
+ Stored under the provider's `models_key` namespace, so driver variants
197
+ sharing a catalog (claude/claude-sdk, codex/codex-app-server) share the
198
+ curation. An empty list clears the key. Ids are stored verbatim — no
199
+ catalog-membership pruning, so a preference survives a CLI catalog
200
+ refresh that temporarily drops the model.
201
+ """
202
+ p = _provider_or_404(provider_name)
203
+ body = await request.json()
204
+ disabled = body.get("disabled")
205
+ if not isinstance(disabled, list) or not all(isinstance(x, str) for x in disabled):
206
+ raise HTTPException(status_code=400, detail="disabled must be a list of model ids")
207
+
208
+ key = p.models_key or p.name
209
+ cleaned = sorted({x.strip() for x in disabled if x.strip()})
210
+
211
+ config = paths.load_global_config()
212
+ overrides = dict(config.get("models_disabled") or {})
213
+ if cleaned:
214
+ overrides[key] = cleaned
215
+ else:
216
+ overrides.pop(key, None)
217
+ if overrides:
218
+ config["models_disabled"] = overrides
219
+ else:
220
+ config.pop("models_disabled", None)
221
+
222
+ paths.save_global_config(config)
223
+ logger.info(f"Hidden models for {key}: {cleaned or '(none)'}")
224
+
225
+ return _engine_models_payload(p)
226
+
227
+
228
+ # ═══════════════════════════════════════════════════════════════════
229
+ # Engine CLI auth (login-status probe, self-described commands)
230
+ # ═══════════════════════════════════════════════════════════════════
231
+
232
+ @router.get("/api/app/engine-auth/{provider_name}")
233
+ async def get_engine_auth(provider_name: str):
234
+ """Login status for one engine's CLI (Settings → Engines auth row).
235
+
236
+ Runs the provider's self-described `auth_status_args` probe against the
237
+ configured binary and returns its parsed verdict plus `login_command` —
238
+ the full interactive login invocation the client drops into a PTY
239
+ terminal tab. `logged_in: null` = probe couldn't run (binary missing,
240
+ timeout); `supported: false` = the provider describes no auth probe.
241
+ """
242
+ from painapple_code.utils.proc import shell_join
243
+
244
+ p = _provider_or_404(provider_name)
245
+ if not p.auth_status_args:
246
+ return {"provider": p.name, "supported": False}
247
+
248
+ config = paths.load_global_config()
249
+ configured = config.get(p.path_config_key) if p.path_config_key else None
250
+ current = configured or p.default_binary
251
+ resolved = current if Path(current).is_absolute() else shutil.which(current)
252
+ login_command = (
253
+ shell_join([current, *p.auth_login_args]) if p.auth_login_args else None
254
+ )
255
+
256
+ logged_in, detail = None, ""
257
+ if resolved:
258
+ try:
259
+ # Off the event loop — a hung status probe must not block requests.
260
+ result = await asyncio.to_thread(
261
+ subprocess.run,
262
+ # `resolved` for the same reason as the version probe:
263
+ # bare .cmd shim names aren't spawnable on win32.
264
+ [resolved, *p.auth_status_args],
265
+ capture_output=True,
266
+ text=True, encoding="utf-8", errors="replace",
267
+ timeout=10,
268
+ )
269
+ parsed = p.parse_auth_status(
270
+ result.returncode, result.stdout or "", result.stderr or "")
271
+ logged_in = parsed.get("logged_in")
272
+ detail = parsed.get("detail") or ""
273
+ except Exception:
274
+ pass
275
+
276
+ return {
277
+ "provider": p.name,
278
+ "supported": True,
279
+ "logged_in": logged_in,
280
+ "detail": detail,
281
+ "login_command": login_command,
282
+ }
283
+
284
+
285
+ # ═══════════════════════════════════════════════════════════════════
286
+ # Engine session defaults (per-engine model / effort / account / journal)
287
+ # ═══════════════════════════════════════════════════════════════════
288
+ #
289
+ # Maps in the global config keyed by the provider's `models_key` namespace
290
+ # (driver pairs share, same as `models_disabled`):
291
+ # default_models / default_efforts / default_token_profiles
292
+ # The flat legacy keys survive as read fallbacks; the first PUT folds them
293
+ # into map entries for every engine that can speak the value, then drops
294
+ # them — so clearing a per-engine default actually sticks.
295
+
296
+ _DEFAULTS_FIELDS = [
297
+ # (request/response field, config map key, legacy flat key)
298
+ ("default_model", "default_models", "default_model"),
299
+ ("default_effort", "default_efforts", "default_effort"),
300
+ ("token_profile", "default_token_profiles", "default_token_profile"),
301
+ ]
302
+
303
+
304
+ def _engine_speaks_default(p, field: str, value: str) -> bool:
305
+ """Whether an engine can hold `value` as its default for `field`."""
306
+ if field == "default_model":
307
+ return any(
308
+ value.startswith(m["id"])
309
+ for m in p.enabled_models() if m.get("id"))
310
+ if field == "default_effort":
311
+ levels = p.effort_levels()
312
+ return not levels or value in levels
313
+ if field == "token_profile":
314
+ return bool(p.accounts())
315
+ return False
316
+
317
+
318
+ def _migrate_legacy_default(config: dict, field: str, map_key: str, legacy_key: str) -> None:
319
+ """Fold a legacy flat default into per-engine map entries, then drop it.
320
+
321
+ Seeds every engine namespace that can speak the value and has no
322
+ explicit entry — behavior is unchanged the moment the flat key
323
+ disappears, and per-engine clears stop resurrecting it."""
324
+ legacy = config.get(legacy_key)
325
+ if not isinstance(legacy, str) or not legacy:
326
+ config.pop(legacy_key, None)
327
+ return
328
+ from painapple_code.providers import get_provider, provider_names
329
+ overrides = dict(config.get(map_key) or {})
330
+ seen = set()
331
+ for name in provider_names():
332
+ p = get_provider(name)
333
+ ns = p.models_key or p.name
334
+ if ns in seen:
335
+ continue
336
+ seen.add(ns)
337
+ if ns not in overrides and _engine_speaks_default(p, field, legacy):
338
+ overrides[ns] = legacy
339
+ if overrides:
340
+ config[map_key] = overrides
341
+ config.pop(legacy_key, None)
342
+
343
+
344
+ def _engine_defaults_payload(p) -> dict:
345
+ return {
346
+ "provider": p.name,
347
+ "models_key": p.models_key or p.name,
348
+ "default_model": paths.engine_default_model(p),
349
+ "default_effort": paths.engine_default_effort(p),
350
+ "token_profile": paths.engine_default_token_profile(p),
351
+ "efforts": p.effort_levels(),
352
+ "accounts": p.accounts(),
353
+ "summary_supported": p.summary_model_editable(),
354
+ "summary_model": (
355
+ p.get_summary_model_override() if p.summary_model_editable() else None),
356
+ "summary_placeholder": p.summary_model_placeholder or "",
357
+ }
358
+
359
+
360
+ @router.get("/api/app/engine-defaults/{provider_name}")
361
+ async def get_engine_defaults(provider_name: str):
362
+ """One engine's new-session defaults + auto-journal model."""
363
+ return _engine_defaults_payload(_provider_or_404(provider_name))
364
+
365
+
366
+ @router.put("/api/app/engine-defaults/{provider_name}")
367
+ async def set_engine_defaults(provider_name: str, request: Request):
368
+ """Set any subset of one engine's defaults.
369
+
370
+ Body: ``{default_model?, default_effort?, token_profile?, summary_model?}``
371
+ — null/empty clears a key (model/effort/profile fall back to the engine's
372
+ own default; the journal override falls back per engine: Codex inherits
373
+ the session model, Claude resets to the shipped summary model).
374
+ """
375
+ p = _provider_or_404(provider_name)
376
+ body = await request.json()
377
+ ns = p.models_key or p.name
378
+
379
+ config = paths.load_global_config()
380
+ maps_changed = False
381
+ for field, map_key, legacy_key in _DEFAULTS_FIELDS:
382
+ if field not in body:
383
+ continue
384
+ raw = body.get(field)
385
+ if raw is not None and not isinstance(raw, str):
386
+ raise HTTPException(status_code=400, detail=f"{field} must be a string or null")
387
+ value = (raw or "").strip()
388
+
389
+ if value and field == "default_effort":
390
+ levels = p.effort_levels()
391
+ if levels and value not in levels:
392
+ raise HTTPException(
393
+ status_code=400,
394
+ detail=f"Invalid effort for {p.display_name}: {value}. Valid: {levels}")
395
+ if value and field == "token_profile":
396
+ from painapple_code.utils.token_profiles import list_profiles
397
+ if not p.accounts():
398
+ raise HTTPException(
399
+ status_code=400,
400
+ detail=f"{p.display_name} has no selectable accounts")
401
+ if value not in {x["name"] for x in list_profiles()}:
402
+ raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
403
+
404
+ _migrate_legacy_default(config, field, map_key, legacy_key)
405
+ overrides = dict(config.get(map_key) or {})
406
+ if value:
407
+ overrides[ns] = value
408
+ else:
409
+ overrides.pop(ns, None)
410
+ if overrides:
411
+ config[map_key] = overrides
412
+ else:
413
+ config.pop(map_key, None)
414
+ maps_changed = True
415
+
416
+ if maps_changed:
417
+ paths.save_global_config(config)
418
+ logger.info(f"Engine defaults for {ns} updated")
419
+
420
+ # After the config save — the provider setter re-reads/writes its own
421
+ # store (global config for Codex, models.yaml for Claude).
422
+ if "summary_model" in body:
423
+ raw = body.get("summary_model")
424
+ if raw is not None and not isinstance(raw, str):
425
+ raise HTTPException(status_code=400, detail="summary_model must be a string or null")
426
+ if not p.summary_model_editable():
427
+ raise HTTPException(
428
+ status_code=400,
429
+ detail=f"{p.display_name} has no journal-model setting")
430
+ p.set_summary_model_override(raw)
431
+ logger.info(f"Journal model for {p.name} set to: {(raw or '').strip() or '(engine default)'}")
432
+
433
+ return _engine_defaults_payload(p)
434
+
435
+
436
+ @router.get("/api/app/max-thinking-tokens")
437
+ async def get_max_thinking_tokens_api():
438
+ """Get the max thinking tokens setting."""
439
+ config = paths.load_global_config()
440
+ current = config.get("max_thinking_tokens", DEFAULT_MAX_THINKING_TOKENS)
441
+
442
+ return {
443
+ "max_thinking_tokens": current,
444
+ "default": DEFAULT_MAX_THINKING_TOKENS,
445
+ "max": 63999,
446
+ }
447
+
448
+
449
+ @router.put("/api/app/max-thinking-tokens")
450
+ async def set_max_thinking_tokens_api(request: Request):
451
+ """Set the max thinking tokens."""
452
+ body = await request.json()
453
+ value = body.get("max_thinking_tokens")
454
+
455
+ if value is None:
456
+ raise HTTPException(status_code=400, detail="max_thinking_tokens is required")
457
+
458
+ try:
459
+ value = int(value)
460
+ except (ValueError, TypeError):
461
+ raise HTTPException(status_code=400, detail="max_thinking_tokens must be an integer")
462
+
463
+ if value < 0:
464
+ raise HTTPException(status_code=400, detail="max_thinking_tokens cannot be negative")
465
+
466
+ if value > 63999:
467
+ raise HTTPException(status_code=400, detail="max_thinking_tokens cannot exceed 63999 (Opus 4.5 limit)")
468
+
469
+ config = paths.load_global_config()
470
+ if value == DEFAULT_MAX_THINKING_TOKENS:
471
+ config.pop("max_thinking_tokens", None)
472
+ else:
473
+ config["max_thinking_tokens"] = value
474
+
475
+ paths.save_global_config(config)
476
+ logger.info(f"Max thinking tokens updated to: {value}")
477
+
478
+ return await get_max_thinking_tokens_api()
479
+
480
+
481
+ # ═══════════════════════════════════════════════════════════════════
482
+ # API Auto-Retry
483
+ # ═══════════════════════════════════════════════════════════════════
484
+
485
+ DEFAULT_API_RETRY_MAX = 3
486
+
487
+
488
+ @router.get("/api/app/api-retry-max")
489
+ async def get_api_retry_max():
490
+ """Get the API auto-retry max setting."""
491
+ config = paths.load_global_config()
492
+ return {
493
+ "api_retry_max": config.get("api_retry_max", DEFAULT_API_RETRY_MAX),
494
+ "default": DEFAULT_API_RETRY_MAX,
495
+ "min": 0,
496
+ "max": 10,
497
+ }
498
+
499
+
500
+ @router.put("/api/app/api-retry-max")
501
+ async def set_api_retry_max(request: Request):
502
+ """Set the API auto-retry max."""
503
+ body = await request.json()
504
+ value = body.get("api_retry_max")
505
+
506
+ if value is None:
507
+ raise HTTPException(status_code=400, detail="api_retry_max is required")
508
+
509
+ try:
510
+ value = int(value)
511
+ except (ValueError, TypeError):
512
+ raise HTTPException(status_code=400, detail="api_retry_max must be an integer")
513
+
514
+ if value < 0 or value > 10:
515
+ raise HTTPException(status_code=400, detail="api_retry_max must be between 0 and 10")
516
+
517
+ config = paths.load_global_config()
518
+ if value == DEFAULT_API_RETRY_MAX:
519
+ config.pop("api_retry_max", None)
520
+ else:
521
+ config["api_retry_max"] = value
522
+
523
+ paths.save_global_config(config)
524
+ logger.info(f"API retry max updated to: {value}")
525
+
526
+ return await get_api_retry_max()
527
+
528
+
529
+ # ═══════════════════════════════════════════════════════════════════
530
+ # Stop Claude on AskUserQuestion (SIGINT)
531
+ # ═══════════════════════════════════════════════════════════════════
532
+
533
+ SIGINT_ON_ASK_DEFAULT = True
534
+
535
+
536
+ @router.get("/api/app/sigint-on-ask")
537
+ async def get_sigint_on_ask():
538
+ """Get whether Claude is SIGINT-stopped when it calls AskUserQuestion."""
539
+ config = paths.load_global_config()
540
+ return {
541
+ "sigint_on_ask": bool(config.get("sigint_on_ask", SIGINT_ON_ASK_DEFAULT)),
542
+ "default": SIGINT_ON_ASK_DEFAULT,
543
+ }
544
+
545
+
546
+ @router.put("/api/app/sigint-on-ask")
547
+ async def set_sigint_on_ask(request: Request):
548
+ """Toggle SIGINT-on-AskUserQuestion.
549
+
550
+ Persists to the global config and updates the live AgentManager so the
551
+ change takes effect on the next turn without a restart.
552
+ """
553
+ body = await request.json()
554
+ value = body.get("sigint_on_ask")
555
+
556
+ if not isinstance(value, bool):
557
+ raise HTTPException(status_code=400, detail="sigint_on_ask must be a boolean")
558
+
559
+ config = paths.load_global_config()
560
+ # Store only when it differs from the default; pop to fall back to default.
561
+ if value == SIGINT_ON_ASK_DEFAULT:
562
+ config.pop("sigint_on_ask", None)
563
+ else:
564
+ config["sigint_on_ask"] = value
565
+ paths.save_global_config(config)
566
+
567
+ # Apply to the running server so the toggle is live, not restart-gated.
568
+ agents = getattr(request.app.state, "agents", None)
569
+ if agents is not None:
570
+ agents.sigint_on_ask = value
571
+
572
+ logger.info(f"sigint_on_ask updated to: {value}")
573
+ return {"sigint_on_ask": value, "default": SIGINT_ON_ASK_DEFAULT}
574
+
575
+
576
+ # ═══════════════════════════════════════════════════════════════════
577
+ # Default Permission Levels
578
+ # ═══════════════════════════════════════════════════════════════════
579
+
580
+ @router.get("/api/app/default-permissions")
581
+ async def get_default_permissions(request: Request, provider: str = None):
582
+ """Get the global default permission level, plus the mode vocabulary of the
583
+ engine new sessions run on (the effective default provider — the
584
+ --default-provider flag / `default_provider` config key, not always Claude).
585
+
586
+ `provider` overrides which engine's vocabulary is returned — the picker
587
+ uses it so a pre-connect tab that chose a different engine shows that
588
+ engine's modes, not the box default's. The stored global level only
589
+ applies when it's valid in the resolved engine's own vocabulary (a Claude
590
+ `acceptEdits` default means nothing to Codex's sandbox tiers)."""
591
+ from painapple_code.providers import get_provider, provider_names
592
+ from painapple_code.routes.dependencies import effective_default_provider
593
+ if provider and provider in provider_names():
594
+ dp = get_provider(provider)
595
+ else:
596
+ dp = effective_default_provider(request.app)
597
+ config = paths.load_global_config()
598
+ valid = {m["value"] for m in dp.permission_modes()}
599
+ level = config.get("default_permission_level")
600
+ if level not in valid:
601
+ level = dp.default_permission_mode()
602
+ return {
603
+ "default_level": level,
604
+ "modes": dp.permission_modes(),
605
+ }
606
+
607
+
608
+ @router.put("/api/app/default-permissions")
609
+ async def set_default_permissions(request: Request):
610
+ """Set the global default permission level for normal sessions."""
611
+ body = await request.json()
612
+ value = body.get("permission_level")
613
+
614
+ # The global default feeds the effective default engine's sessions, so
615
+ # validate against that provider's own modes (not a hardcoded set) and
616
+ # treat its own default as the "unset" sentinel.
617
+ from painapple_code.routes.dependencies import effective_default_provider
618
+ dp = effective_default_provider(request.app)
619
+ valid = {m["value"] for m in dp.permission_modes()}
620
+ if value not in valid:
621
+ raise HTTPException(status_code=400, detail=f"Invalid permission level: {value}. Valid: {valid}")
622
+
623
+ config = paths.load_global_config()
624
+ if value == dp.default_permission_mode():
625
+ config.pop("default_permission_level", None)
626
+ else:
627
+ config["default_permission_level"] = value
628
+
629
+ paths.save_global_config(config)
630
+ logger.info(f"Default permission level updated to: {value}")
631
+
632
+ return await get_default_permissions(request)
633
+
634
+
635
+ # ═══════════════════════════════════════════════════════════════════
636
+ # Token Profiles
637
+ # ═══════════════════════════════════════════════════════════════════
638
+
639
+ @router.get("/api/app/token-profiles")
640
+ async def get_token_profiles(request: Request):
641
+ """List available token profiles and the default engine's default."""
642
+ from painapple_code.routes.dependencies import effective_default_provider
643
+ from painapple_code.utils.token_profiles import list_profiles
644
+ profiles = list_profiles()
645
+ # Legacy shape; the default shown is the DEFAULT ENGINE's configured
646
+ # profile (per-engine `default_token_profiles` map, flat key fallback).
647
+ default = paths.engine_default_token_profile(
648
+ effective_default_provider(request.app))
649
+ return {
650
+ "profiles": profiles,
651
+ "default_profile": default,
652
+ }
653
+
654
+
655
+ @router.put("/api/app/default-token-profile")
656
+ async def set_default_token_profile(request: Request):
657
+ """Legacy wrapper: set the DEFAULT ENGINE's default token profile."""
658
+ from painapple_code.routes.dependencies import effective_default_provider
659
+ from painapple_code.utils.token_profiles import list_profiles
660
+ p = effective_default_provider(request.app)
661
+ body = await request.json()
662
+ value = body.get("token_profile")
663
+
664
+ # Validate profile exists (or null to clear)
665
+ if value is not None:
666
+ profile_names = {x["name"] for x in list_profiles()}
667
+ if value not in profile_names:
668
+ raise HTTPException(status_code=400, detail=f"Token profile not found: {value}")
669
+
670
+ config = paths.load_global_config()
671
+ _migrate_legacy_default(
672
+ config, "token_profile", "default_token_profiles", "default_token_profile")
673
+ overrides = dict(config.get("default_token_profiles") or {})
674
+ ns = p.models_key or p.name
675
+ if value is None:
676
+ overrides.pop(ns, None)
677
+ else:
678
+ overrides[ns] = value
679
+ if overrides:
680
+ config["default_token_profiles"] = overrides
681
+ else:
682
+ config.pop("default_token_profiles", None)
683
+
684
+ paths.save_global_config(config)
685
+ logger.info(f"Default token profile for {ns} updated to: {value}")
686
+
687
+ return await get_token_profiles(request)
688
+
689
+
690
+ # ═══════════════════════════════════════════════════════════════════
691
+ # Models
692
+ # ═══════════════════════════════════════════════════════════════════
693
+
694
+ @router.get("/api/app/models")
695
+ async def get_models():
696
+ """Get available models from models.yaml."""
697
+ return {
698
+ "selectable": paths.get_selectable_models(),
699
+ "summary_model": paths.get_summary_model(),
700
+ }
701
+
702
+
703
+ @router.put("/api/app/models")
704
+ async def put_models(request: Request):
705
+ """Replace models.yaml with the posted config. Body: {selectable: [...], summary_model: "..."}"""
706
+ body = await request.json()
707
+ selectable = body.get("selectable", [])
708
+ # Back-compat: older clients sent the key as `haiku`.
709
+ summary_model = body.get("summary_model") or body.get("haiku", "")
710
+
711
+ if not isinstance(selectable, list):
712
+ raise HTTPException(status_code=400, detail="selectable must be a list")
713
+ if not isinstance(summary_model, str) or not summary_model.strip():
714
+ raise HTTPException(status_code=400, detail="summary_model must be a non-empty string")
715
+
716
+ # Validate each selectable entry has required fields
717
+ cleaned = []
718
+ for i, m in enumerate(selectable):
719
+ if not isinstance(m, dict) or not m.get("id"):
720
+ raise HTTPException(status_code=400, detail=f"selectable[{i}] must have an id")
721
+ cleaned.append({
722
+ "id": str(m["id"]).strip(),
723
+ "label": str(m.get("label", "")).strip() or str(m["id"]).strip(),
724
+ "desc": str(m.get("desc", "")).strip(),
725
+ })
726
+
727
+ paths.save_models_config({"selectable": cleaned, "summary_model": summary_model.strip()})
728
+ logger.info(f"Models config updated: {len(cleaned)} selectable, summary_model={summary_model}")
729
+ return {"selectable": cleaned, "summary_model": summary_model.strip()}
730
+
731
+
732
+ @router.post("/api/app/models/reset")
733
+ async def reset_models():
734
+ """Restore models.yaml to the shipped defaults."""
735
+ defaults = paths.get_default_models_config()
736
+ paths.save_models_config(defaults)
737
+ logger.info(f"Models config reset to defaults: {len(defaults.get('selectable', []))} selectable")
738
+ return {
739
+ "selectable": paths.get_selectable_models(),
740
+ "summary_model": paths.get_summary_model(),
741
+ }
742
+
743
+
744
+ # ═══════════════════════════════════════════════════════════════════
745
+ # Default Provider (engine for new sessions)
746
+ # ═══════════════════════════════════════════════════════════════════
747
+
748
+ @router.get("/api/app/default-provider")
749
+ async def get_default_provider(request: Request):
750
+ """The engine NEW sessions get when the picker isn't touched.
751
+
752
+ `effective` is the resolved answer (--default-provider flag →
753
+ `default_provider` config key → registry default). `configured` is the
754
+ raw config-key value (None = unset). `pinned_by_flag` tells the UI the
755
+ server flag overrides whatever it writes via PUT."""
756
+ from painapple_code.routes.dependencies import effective_default_provider
757
+ agents = getattr(request.app.state, "agents", None)
758
+ config = paths.load_global_config()
759
+ return {
760
+ "effective": effective_default_provider(request.app).name,
761
+ "configured": config.get("default_provider"),
762
+ "pinned_by_flag": bool(getattr(agents, "default_provider", None)),
763
+ }
764
+
765
+
766
+ @router.put("/api/app/default-provider")
767
+ async def set_default_provider(request: Request):
768
+ """Set (or clear with null) the `default_provider` config key. Validated
769
+ against the live registry; note a --default-provider server flag still
770
+ wins over this key until the server restarts without it."""
771
+ from painapple_code.providers import provider_names
772
+ body = await request.json()
773
+ value = body.get("default_provider") # string or null to clear
774
+
775
+ if value is not None and value not in provider_names():
776
+ raise HTTPException(
777
+ status_code=400,
778
+ detail=f"Unknown provider: {value}. Valid: {provider_names()}",
779
+ )
780
+
781
+ config = paths.load_global_config()
782
+ if value is None:
783
+ config.pop("default_provider", None)
784
+ else:
785
+ config["default_provider"] = value
786
+
787
+ paths.save_global_config(config)
788
+ logger.info(f"Default provider updated to: {value}")
789
+
790
+ return await get_default_provider(request)
791
+
792
+
793
+ # ═══════════════════════════════════════════════════════════════════
794
+ # Providers enabled (which engines the picker offers)
795
+ # ═══════════════════════════════════════════════════════════════════
796
+
797
+ @router.get("/api/app/providers-enabled")
798
+ async def get_providers_enabled(request: Request):
799
+ """Per-engine picker visibility.
800
+
801
+ `effective` is what the picker uses ({name: bool}; config override →
802
+ provider's own `default_enabled`, with the default engine forced on).
803
+ `configured` is the raw `providers_enabled` config map (only names the
804
+ user has explicitly toggled)."""
805
+ from painapple_code.routes.dependencies import provider_enabled_map
806
+ config = paths.load_global_config()
807
+ return {
808
+ "effective": provider_enabled_map(request.app),
809
+ "configured": config.get("providers_enabled") or {},
810
+ }
811
+
812
+
813
+ @router.put("/api/app/providers-enabled")
814
+ async def set_provider_enabled(request: Request):
815
+ """Toggle one engine in/out of the picker: `{provider, enabled}`.
816
+
817
+ `enabled: null` clears the override (back to the provider's own
818
+ `default_enabled`). Disabling affects only the picker listing — bound
819
+ sessions and explicit API selection keep working; the effective default
820
+ engine reads back enabled regardless."""
821
+ from painapple_code.providers import provider_names
822
+ body = await request.json()
823
+ name = body.get("provider")
824
+ enabled = body.get("enabled") # bool, or null to clear the override
825
+
826
+ if name not in provider_names():
827
+ raise HTTPException(
828
+ status_code=400,
829
+ detail=f"Unknown provider: {name}. Valid: {provider_names()}",
830
+ )
831
+
832
+ config = paths.load_global_config()
833
+ overrides = dict(config.get("providers_enabled") or {})
834
+ if enabled is None:
835
+ overrides.pop(name, None)
836
+ else:
837
+ overrides[name] = bool(enabled)
838
+
839
+ if overrides:
840
+ config["providers_enabled"] = overrides
841
+ else:
842
+ config.pop("providers_enabled", None)
843
+
844
+ paths.save_global_config(config)
845
+ logger.info(f"Provider {name} picker visibility set to: {enabled}")
846
+
847
+ return await get_providers_enabled(request)
848
+
849
+
850
+ @router.get("/api/app/default-model")
851
+ async def get_default_model(request: Request):
852
+ """Legacy wrapper: the DEFAULT ENGINE's configured new-session model.
853
+
854
+ Storage moved to the per-engine `default_models` map (see
855
+ engine-defaults); this endpoint stays for old callers and reads/writes
856
+ through the default engine's entry.
857
+ """
858
+ from painapple_code.routes.dependencies import effective_default_provider
859
+ p = effective_default_provider(request.app)
860
+ return {"default_model": paths.engine_default_model(p)}
861
+
862
+
863
+ @router.put("/api/app/default-model")
864
+ async def set_default_model(request: Request):
865
+ """Legacy wrapper: set the DEFAULT ENGINE's new-session model."""
866
+ from painapple_code.routes.dependencies import effective_default_provider
867
+ p = effective_default_provider(request.app)
868
+ body = await request.json()
869
+ value = body.get("default_model") # string or null
870
+
871
+ config = paths.load_global_config()
872
+ _migrate_legacy_default(config, "default_model", "default_models", "default_model")
873
+ overrides = dict(config.get("default_models") or {})
874
+ ns = p.models_key or p.name
875
+ if value:
876
+ overrides[ns] = value
877
+ else:
878
+ overrides.pop(ns, None)
879
+ if overrides:
880
+ config["default_models"] = overrides
881
+ else:
882
+ config.pop("default_models", None)
883
+
884
+ paths.save_global_config(config)
885
+ logger.info(f"Default model for {ns} updated to: {value}")
886
+
887
+ return await get_default_model(request)
888
+
889
+
890
+ # ═══════════════════════════════════════════════════════════════════
891
+ # Default Effort Level
892
+ # ═══════════════════════════════════════════════════════════════════
893
+
894
+ VALID_EFFORT_LEVELS = {"low", "medium", "high", "xhigh", "max"}
895
+ DEFAULT_EFFORT = "high"
896
+
897
+
898
+ @router.get("/api/app/default-effort")
899
+ async def get_default_effort(request: Request):
900
+ """Legacy wrapper: the DEFAULT ENGINE's configured default effort.
901
+
902
+ Storage moved to the per-engine `default_efforts` map (see
903
+ engine-defaults); this stays for old callers (effort popup fallback).
904
+ """
905
+ from painapple_code.routes.dependencies import effective_default_provider
906
+ p = effective_default_provider(request.app)
907
+ return {
908
+ "default_effort": paths.engine_default_effort(p) or DEFAULT_EFFORT,
909
+ "valid_levels": p.effort_levels() or sorted(VALID_EFFORT_LEVELS),
910
+ "default": DEFAULT_EFFORT,
911
+ }
912
+
913
+
914
+ @router.put("/api/app/default-effort")
915
+ async def set_default_effort(request: Request):
916
+ """Legacy wrapper: set the DEFAULT ENGINE's default effort."""
917
+ from painapple_code.routes.dependencies import effective_default_provider
918
+ p = effective_default_provider(request.app)
919
+ body = await request.json()
920
+ value = body.get("default_effort")
921
+
922
+ valid = set(p.effort_levels() or VALID_EFFORT_LEVELS)
923
+ if value not in valid:
924
+ raise HTTPException(status_code=400, detail=f"Invalid effort level: {value}. Valid: {sorted(valid)}")
925
+
926
+ config = paths.load_global_config()
927
+ _migrate_legacy_default(config, "default_effort", "default_efforts", "default_effort")
928
+ overrides = dict(config.get("default_efforts") or {})
929
+ ns = p.models_key or p.name
930
+ if value == DEFAULT_EFFORT:
931
+ overrides.pop(ns, None)
932
+ else:
933
+ overrides[ns] = value
934
+ if overrides:
935
+ config["default_efforts"] = overrides
936
+ else:
937
+ config.pop("default_efforts", None)
938
+
939
+ paths.save_global_config(config)
940
+ logger.info(f"Default effort for {ns} updated to: {value}")
941
+
942
+ return await get_default_effort(request)
943
+
944
+
945
+ # ═══════════════════════════════════════════════════════════════════
946
+ # Session Timeout Settings
947
+ # ═══════════════════════════════════════════════════════════════════
948
+
949
+ @router.get("/api/app/session-timeouts")
950
+ async def get_session_timeouts():
951
+ """Get session timeout settings (in minutes)."""
952
+ from painapple_code.services.agent_session import AgentManager
953
+ config = paths.load_global_config()
954
+ return {
955
+ "session_idle_timeout_minutes": config.get(
956
+ "session_idle_timeout_minutes",
957
+ AgentManager.DEFAULT_SESSION_IDLE_TIMEOUT // 60
958
+ ),
959
+ "orphan_process_timeout_minutes": config.get(
960
+ "orphan_process_timeout_minutes",
961
+ AgentManager.DEFAULT_ORPHAN_PROCESS_TIMEOUT // 60
962
+ ),
963
+ "defaults": {
964
+ "session_idle_timeout_minutes": AgentManager.DEFAULT_SESSION_IDLE_TIMEOUT // 60,
965
+ "orphan_process_timeout_minutes": AgentManager.DEFAULT_ORPHAN_PROCESS_TIMEOUT // 60,
966
+ }
967
+ }
968
+
969
+
970
+ @router.put("/api/app/session-timeouts")
971
+ async def set_session_timeouts(request: Request):
972
+ """Update session timeout settings (in minutes)."""
973
+ from painapple_code.services.agent_session import AgentManager
974
+ body = await request.json()
975
+ config = paths.load_global_config()
976
+
977
+ for key in ("session_idle_timeout_minutes", "orphan_process_timeout_minutes"):
978
+ if key in body:
979
+ value = body[key]
980
+ try:
981
+ value = int(value)
982
+ except (ValueError, TypeError):
983
+ raise HTTPException(status_code=400, detail=f"{key} must be an integer")
984
+ if value < 1:
985
+ raise HTTPException(status_code=400, detail=f"{key} must be at least 1 minute")
986
+ if value > 1440:
987
+ raise HTTPException(status_code=400, detail=f"{key} cannot exceed 1440 minutes (24 hours)")
988
+
989
+ default_minutes = getattr(AgentManager, f"DEFAULT_{key.upper().replace('_MINUTES', '')}") // 60
990
+ if value == default_minutes:
991
+ config.pop(key, None)
992
+ else:
993
+ config[key] = value
994
+
995
+ paths.save_global_config(config)
996
+ logger.info(f"Session timeouts updated: {body}")
997
+ return await get_session_timeouts()