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,1514 @@
1
+ """
2
+ Paths - Centralized path management for ~/.painapple-code/
3
+
4
+ All Painapple Code metadata is stored in ~/.painapple-code/:
5
+ ~/.painapple-code/
6
+ ├── config.json # Global settings
7
+ └── projects/
8
+ └── {project-hash}/ # SHA256(absolute path)[:12]
9
+ ├── path # Original path for reverse lookup
10
+ ├── config.json # Project-specific overrides
11
+ ├── shadow-git/ # File recovery repo
12
+ ├── sessions/ # Session data
13
+ └── cache/ # Cached data
14
+
15
+ Identity model: one project per local working-copy path. Two clones of the
16
+ same repo at different paths are intentionally separate identities — the
17
+ shadow-git and DuckDB rows for one are not entangled with the other. A
18
+ future widget will let users manually merge/connect related projects.
19
+ """
20
+
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import os
25
+ import stat
26
+ import subprocess
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from painapple_code import PACKAGE_DIR
33
+
34
+ logger = logging.getLogger("painapple-code.paths")
35
+
36
+ # Allow override via environment variable
37
+ DATA_HOME = Path(os.environ.get('PAINAPPLE_CODE_HOME', Path.home() / '.painapple-code'))
38
+
39
+ # Credentials/config home (XDG-style). Holds auth config and OAuth tokens.
40
+ CONFIG_HOME = Path(os.environ.get('PAINAPPLE_CODE_CONFIG', Path.home() / '.config' / 'painapple-code'))
41
+
42
+
43
+ def detect_environment() -> str:
44
+ """Best-effort detection of the runtime environment.
45
+
46
+ Returns one of: 'codespaces', 'devcontainer', 'kubernetes', 'docker',
47
+ 'podman', 'container', 'local'. Used by the login page to show an
48
+ environment-appropriate "reveal password" command.
49
+
50
+ Detection is env-var-only on a normal host. The filesystem probes that tell
51
+ docker from podman are gated behind PAINAPPLE_IN_CONTAINER=1, the marker
52
+ baked into our own image — so on a user's bare-metal machine we never
53
+ stat() /.dockerenv or /run/.containerenv. No surprising filesystem access;
54
+ we only probe once our own marker already says we're containerized.
55
+
56
+ Ordering:
57
+ - Codespaces / dev containers / Kubernetes match first via their definitive
58
+ env vars (they *are* containers; the probes below would swallow them).
59
+ - Then, only inside our image, /.dockerenv vs /run/.containerenv pick the
60
+ engine. They're written at *runtime*, so they distinguish docker from
61
+ podman even though the same image runs on both — a baked ENV cannot.
62
+ Neither present (an exotic runtime) falls back to a generic 'container'.
63
+ - A foreign container without our marker reports 'local' on purpose: the
64
+ bare `awk` we then show works in that container's own shell, whereas a
65
+ host-side `docker exec painapple-code …` would assume our image's name.
66
+ - cgroup parsing (/proc/1/cgroup) is avoided: on cgroup v2 it is just
67
+ "0::/", unreliable on modern/rootless hosts.
68
+ """
69
+ env = os.environ
70
+ if env.get('CODESPACES') == 'true' or env.get('CODESPACE_NAME'):
71
+ return 'codespaces'
72
+ if env.get('REMOTE_CONTAINERS') == 'true' or env.get('DEVCONTAINER') == 'true':
73
+ return 'devcontainer'
74
+ if env.get('KUBERNETES_SERVICE_HOST'):
75
+ return 'kubernetes'
76
+ # Filesystem probes only when our own image says we're containerized.
77
+ if env.get('PAINAPPLE_IN_CONTAINER') == '1':
78
+ if Path('/.dockerenv').exists():
79
+ return 'docker'
80
+ if Path('/run/.containerenv').exists():
81
+ return 'podman'
82
+ return 'container'
83
+ return 'local'
84
+
85
+ # Per-tier suffix for UI-state files (tab-state, shortcuts, presets, favorites,
86
+ # global config). Empty for the default (stable) tier. Co-located tiers — dev,
87
+ # test — run under the same OS user and $HOME, so without a suffix they'd share
88
+ # all of this state. Set once at startup via the server's --state-suffix flag,
89
+ # mirroring how --shadow-db / --log-dir already isolate the DuckDB and logs.
90
+ # Per-project state (projects/{hash}/...: sessions + shadow-git) deliberately
91
+ # stays shared and is NOT suffixed.
92
+ STATE_SUFFIX = ""
93
+
94
+
95
+ def init_state_suffix(suffix: Optional[str]) -> None:
96
+ """Set the per-tier UI-state suffix. Call once at startup, before any
97
+ state file is read or written.
98
+
99
+ A leading separator is optional: 'dev' and '-dev' both yield
100
+ tab-state-dev.json. Accepting the bare 'dev' form lets callers write
101
+ `--state-suffix dev` instead of `--state-suffix=-dev` — a plain `-dev`
102
+ value trips argparse, which reads the leading dash as a new option."""
103
+ global STATE_SUFFIX
104
+ if not suffix:
105
+ STATE_SUFFIX = ""
106
+ elif suffix[0] in "-_.":
107
+ STATE_SUFFIX = suffix
108
+ else:
109
+ STATE_SUFFIX = "-" + suffix
110
+
111
+
112
+ def _state_file(stem: str, ext: str = "") -> Path:
113
+ """DATA_HOME / f'{stem}{STATE_SUFFIX}{ext}' — applies the per-tier suffix
114
+ to a UI-state file or directory name."""
115
+ return DATA_HOME / f"{stem}{STATE_SUFFIX}{ext}"
116
+
117
+
118
+ def _path_hash(path: str) -> str:
119
+ """First 12 hex chars of SHA256 of the resolved absolute path. 48 bits
120
+ of entropy — collisions are not a concern at single-user/single-host
121
+ scale, and short ids stay readable in paths and DB rows."""
122
+ abs_path = str(Path(path).resolve())
123
+ return hashlib.sha256(abs_path.encode()).hexdigest()[:12]
124
+
125
+
126
+ def get_project_hash(project_path: str) -> str:
127
+ """SHA256 of the resolved absolute project path. Stable per-host."""
128
+ return _path_hash(project_path)
129
+
130
+
131
+ def get_git_repo_hash(project_path: str) -> Optional[str]:
132
+ """Stable hash identifying the git repository, resolving worktrees.
133
+
134
+ All worktrees of the same repo return the same hash. Non-git directories
135
+ return None.
136
+ """
137
+ try:
138
+ result = subprocess.run(
139
+ ["git", "rev-parse", "--git-common-dir"],
140
+ capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(project_path), timeout=5
141
+ )
142
+ if result.returncode != 0:
143
+ return None
144
+ common = (Path(project_path) / result.stdout.strip()).resolve()
145
+ except Exception:
146
+ return None
147
+ return _path_hash(str(common))
148
+
149
+
150
+ def get_project_dir(project_path: str) -> Path:
151
+ """
152
+ Get the ~/.painapple-code/projects/{hash}/ directory for a project.
153
+
154
+ Args:
155
+ project_path: Project directory path
156
+
157
+ Returns:
158
+ Path to project's data directory
159
+ """
160
+ return DATA_HOME / "projects" / get_project_hash(project_path)
161
+
162
+
163
+ def get_project_dir_by_hash(project_hash: str) -> Path:
164
+ """
165
+ Get the ~/.painapple-code/projects/{hash}/ directory by hash directly.
166
+
167
+ Args:
168
+ project_hash: SHA256 of the resolved project path
169
+
170
+ Returns:
171
+ Path to project's data directory
172
+ """
173
+ return DATA_HOME / "projects" / project_hash
174
+
175
+
176
+ def get_sessions_dir(project_path: str) -> Path:
177
+ """
178
+ Get sessions directory for a project.
179
+
180
+ Args:
181
+ project_path: Project directory path
182
+
183
+ Returns:
184
+ Path to ~/.painapple-code/projects/{hash}/sessions/
185
+ """
186
+ return get_project_dir(project_path) / "sessions"
187
+
188
+
189
+ def get_shadow_git_dir(project_path: str) -> Path:
190
+ """
191
+ Get shadow-git directory for a project.
192
+
193
+ Args:
194
+ project_path: Project directory path
195
+
196
+ Returns:
197
+ Path to ~/.painapple-code/projects/{hash}/shadow-git/
198
+ """
199
+ return get_project_dir(project_path) / "shadow-git"
200
+
201
+
202
+ def get_project_config_path(project_path: str) -> Path:
203
+ """
204
+ Get project-specific config file path.
205
+
206
+ Args:
207
+ project_path: Project directory path
208
+
209
+ Returns:
210
+ Path to ~/.painapple-code/projects/{hash}/config.json
211
+ """
212
+ return get_project_dir(project_path) / "config.json"
213
+
214
+
215
+ def get_global_config_path() -> Path:
216
+ """
217
+ Get global config file path.
218
+
219
+ Returns:
220
+ Path to ~/.painapple-code/config{suffix}.json (per-tier)
221
+ """
222
+ return _state_file("config", ".json")
223
+
224
+
225
+ def get_tab_state_path() -> Path:
226
+ """Return the per-tier tab-state file path (open tabs + active tab)."""
227
+ return _state_file("tab-state", ".json")
228
+
229
+
230
+ def get_shortcuts_path() -> Path:
231
+ """Return the per-tier keyboard-shortcut overrides file path."""
232
+ return _state_file("shortcuts", ".json")
233
+
234
+
235
+ def get_presets_dir() -> Path:
236
+ """Return the per-tier ~/.painapple-code/presets{suffix}/ directory path."""
237
+ return _state_file("presets")
238
+
239
+
240
+ def _load_default_presets() -> dict:
241
+ """Load default presets from presets.defaults.json (ships with the project)."""
242
+ defaults_file = PACKAGE_DIR / "data" / "presets.defaults.json"
243
+ try:
244
+ return json.loads(defaults_file.read_text(encoding="utf-8"))
245
+ except Exception as e:
246
+ logger.error(f"Failed to load {defaults_file}: {e}")
247
+ return {}
248
+
249
+
250
+ def ensure_presets_dir() -> Path:
251
+ """Ensure ~/.painapple-code/presets/ exists, seed missing defaults from presets.defaults.json."""
252
+ presets_dir = get_presets_dir()
253
+ presets_dir.mkdir(parents=True, exist_ok=True)
254
+ # Seed any missing default presets
255
+ defaults = _load_default_presets()
256
+ seeded = 0
257
+ for preset_id, preset in defaults.items():
258
+ path = presets_dir / f"{preset_id}.json"
259
+ if not path.exists():
260
+ path.write_text(json.dumps(preset, indent=2) + "\n", encoding="utf-8")
261
+ seeded += 1
262
+ if seeded:
263
+ logger.info(f"Seeded {seeded} new default presets in {presets_dir}")
264
+ return presets_dir
265
+
266
+
267
+ def load_all_presets() -> dict:
268
+ """Load all presets from ~/.painapple-code/presets/*.json"""
269
+ presets_dir = ensure_presets_dir()
270
+ presets = {}
271
+ for path in sorted(presets_dir.glob("*.json")):
272
+ try:
273
+ data = json.loads(path.read_text(encoding="utf-8"))
274
+ preset_id = path.stem
275
+ presets[preset_id] = data
276
+ except Exception as e:
277
+ logger.warning(f"Failed to load preset {path.name}: {e}")
278
+ return presets
279
+
280
+
281
+ def _preset_file(preset_id: str) -> Path:
282
+ """Resolve a preset id to its JSON file, contained to the presets dir.
283
+
284
+ `preset_id` reaches the route as a single-segment FastAPI path param, so it
285
+ can't hold a `/` — but that is a POSIX-only guarantee: on Windows a
286
+ backslash survives the `[^/]+` convertor, so `..\\..\\evil` would land
287
+ `<traversal>.json` outside the presets dir on a `write_text`/`unlink`. This
288
+ is the one preset sink the WP-08 audit found with no explicit guard.
289
+
290
+ Validate lexically (reject any separator, traversal, or NUL — checked for
291
+ BOTH separators regardless of host OS, so the POSIX CI catches a
292
+ Windows-only escape) and then confirm containment with `is_relative_to`
293
+ after resolve() as the platform-correct backstop. Raises ValueError on a
294
+ bad id; callers surface it as 400.
295
+ """
296
+ if (not preset_id
297
+ or preset_id in (".", "..")
298
+ or "/" in preset_id
299
+ or "\\" in preset_id
300
+ or "\x00" in preset_id
301
+ or preset_id != Path(preset_id).name):
302
+ raise ValueError(f"Invalid preset id: {preset_id!r}")
303
+ presets_dir = get_presets_dir()
304
+ resolved = (presets_dir / f"{preset_id}.json").resolve()
305
+ if not resolved.is_relative_to(presets_dir.resolve()):
306
+ raise ValueError(f"Preset id escapes the presets directory: {preset_id!r}")
307
+ return resolved
308
+
309
+
310
+ def save_preset(preset_id: str, data: dict):
311
+ """Save a single preset to ~/.painapple-code/presets/{id}.json"""
312
+ ensure_presets_dir()
313
+ path = _preset_file(preset_id)
314
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
315
+
316
+
317
+ def delete_preset(preset_id: str) -> bool:
318
+ """Delete a preset file. Returns True if deleted."""
319
+ path = _preset_file(preset_id)
320
+ if path.exists():
321
+ path.unlink()
322
+ return True
323
+ return False
324
+
325
+
326
+ def ensure_data_home() -> Path:
327
+ """
328
+ Ensure ~/.painapple-code/ exists.
329
+
330
+ Returns:
331
+ Path to ~/.painapple-code/
332
+ """
333
+ DATA_HOME.mkdir(parents=True, exist_ok=True)
334
+ return DATA_HOME
335
+
336
+
337
+ def ensure_project_dir(project_path: str) -> Path:
338
+ """
339
+ Create project directory structure and write path file for reverse lookup.
340
+
341
+ Args:
342
+ project_path: Project directory path
343
+
344
+ Returns:
345
+ Path to the created/existing project directory
346
+ """
347
+ project_dir = get_project_dir(project_path)
348
+ project_dir.mkdir(parents=True, exist_ok=True)
349
+
350
+ # Write reverse lookup file
351
+ path_file = project_dir / "path"
352
+ abs_path = str(Path(project_path).resolve())
353
+
354
+ if not path_file.exists():
355
+ path_file.write_text(abs_path, encoding="utf-8")
356
+ logger.debug(f"Created project dir: {project_dir} -> {abs_path}")
357
+ elif path_file.read_text(encoding="utf-8").strip() != abs_path:
358
+ # Path changed (shouldn't happen with hash, but handle gracefully)
359
+ logger.warning(f"Project path mismatch: {path_file.read_text(encoding="utf-8").strip()} vs {abs_path}")
360
+ path_file.write_text(abs_path, encoding="utf-8")
361
+
362
+ return project_dir
363
+
364
+
365
+ def get_project_path_from_hash(project_hash: str) -> Optional[str]:
366
+ """
367
+ Reverse lookup: get original project path from hash.
368
+
369
+ Args:
370
+ project_hash: SHA256 of the resolved project path
371
+
372
+ Returns:
373
+ Original project path or None if not found
374
+ """
375
+ path_file = DATA_HOME / "projects" / project_hash / "path"
376
+ if path_file.exists():
377
+ return path_file.read_text(encoding="utf-8").strip()
378
+ return None
379
+
380
+
381
+ def list_projects(include_unreachable: bool = False) -> list[dict]:
382
+ """
383
+ List all tracked projects.
384
+
385
+ By default, projects whose original `path` is not a directory on the
386
+ current host are skipped. This matters in Docker: ~/.painapple-code/
387
+ survives across runs but the host paths it references may not be
388
+ mounted into the current container.
389
+
390
+ Args:
391
+ include_unreachable: If True, include projects whose path no longer
392
+ exists. Useful for archaeology/cleanup; the result dict's
393
+ `reachable` field tells callers which ones are missing.
394
+
395
+ Returns:
396
+ List of dicts with: hash, path, sessions_dir, shadow_git_dir,
397
+ session_count, has_sessions, has_shadow_git, reachable.
398
+ """
399
+ projects_dir = DATA_HOME / "projects"
400
+ if not projects_dir.exists():
401
+ return []
402
+
403
+ result = []
404
+ for hash_dir in projects_dir.iterdir():
405
+ if not hash_dir.is_dir():
406
+ continue
407
+ path_file = hash_dir / "path"
408
+ if not path_file.exists():
409
+ continue
410
+
411
+ project_path = path_file.read_text(encoding="utf-8").strip()
412
+ reachable = Path(project_path).is_dir()
413
+
414
+ if not reachable and not include_unreachable:
415
+ continue
416
+
417
+ sessions_dir = hash_dir / "sessions"
418
+ shadow_git_dir = hash_dir / "shadow-git"
419
+
420
+ # Count sessions
421
+ session_count = 0
422
+ if sessions_dir.exists():
423
+ session_count = sum(1 for d in sessions_dir.iterdir()
424
+ if d.is_dir() and (d / "meta.json").exists())
425
+
426
+ # Custom color, if the user assigned one (read straight from the
427
+ # project's config.json — no global merge needed for a display field).
428
+ custom_color = None
429
+ cfg_file = hash_dir / "config.json"
430
+ if cfg_file.exists():
431
+ try:
432
+ cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
433
+ custom_color = _normalize_project_color(
434
+ cfg.get("display", {}).get("color")
435
+ )
436
+ except Exception:
437
+ pass
438
+
439
+ result.append({
440
+ "hash": hash_dir.name,
441
+ "path": project_path,
442
+ "sessions_dir": str(sessions_dir),
443
+ "shadow_git_dir": str(shadow_git_dir),
444
+ "session_count": session_count,
445
+ "has_sessions": sessions_dir.exists(),
446
+ "has_shadow_git": shadow_git_dir.exists(),
447
+ "reachable": reachable,
448
+ "color": custom_color,
449
+ })
450
+
451
+ return result
452
+
453
+
454
+ # Don't auto-scan these dangerous parents — they're almost never what the user
455
+ # means by "workspace folder" and would surface huge noisy lists.
456
+ _WORKSPACE_ROOT_DENYLIST = {"/", "/home", "/Users", "/root", "/mnt", "/srv"}
457
+
458
+ # Windows equivalents. The exact-string set above can't cover these: a
459
+ # drive root is "C:\\" (any letter) and the user container is
460
+ # "C:\\Users" — so a `--workspace C:\` would have enumerated every
461
+ # top-level directory into the welcome screen, exactly what this guard
462
+ # exists to prevent.
463
+ _WIN_DENY_NAMES = {"users", "windows", "program files", "program files (x86)",
464
+ "programdata"}
465
+
466
+
467
+ def _is_denied_workspace_root(root_path: Path) -> bool:
468
+ if str(root_path) in _WORKSPACE_ROOT_DENYLIST:
469
+ return True
470
+ if sys.platform != "win32":
471
+ return False
472
+ # A drive or UNC root is its own parent.
473
+ if root_path.parent == root_path:
474
+ return True
475
+ return (root_path.parent == Path(root_path.anchor)
476
+ and root_path.name.lower() in _WIN_DENY_NAMES)
477
+
478
+
479
+ _PROJECT_MARKERS = (
480
+ ".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
481
+ "setup.py", "requirements.txt", "Gemfile", "pom.xml", "build.gradle",
482
+ )
483
+
484
+
485
+ def _looks_like_project(child: Path) -> bool:
486
+ """Heuristic: does this dir look like the root of a project?
487
+
488
+ Cheap signal — any common manifest at the top level. .git matches both
489
+ a normal repo (dir) and a git worktree (file pointing at the main repo).
490
+ """
491
+ for marker in _PROJECT_MARKERS:
492
+ if (child / marker).exists():
493
+ return True
494
+ return False
495
+
496
+
497
+ def list_workspace_dirs(root: str, exclude_paths: Optional[list[str]] = None,
498
+ limit: int = 100) -> list[dict]:
499
+ """List immediate sub-directories of a workspace root, excluding ones
500
+ that are already tracked projects.
501
+
502
+ Used by the welcome screen to surface sibling folders the user hasn't
503
+ started a session in yet. Sorted with project-looking dirs (anything
504
+ with a top-level .git, package.json, pyproject.toml, …) first, then
505
+ by directory mtime descending so recently touched folders bubble up.
506
+
507
+ Hidden directories (leading dot) and symlinks are skipped. The
508
+ `_WORKSPACE_ROOT_DENYLIST` short-circuits dangerous roots like `/` so
509
+ a misconfigured `--workspace` can't surface every top-level dir.
510
+
511
+ Args:
512
+ root: Absolute path to scan.
513
+ exclude_paths: Resolved absolute paths to skip (e.g. existing
514
+ project paths from list_projects()).
515
+ limit: Max entries to return.
516
+
517
+ Returns:
518
+ List of {path, name, mtime, looks_like_project} sorted with
519
+ project-looking dirs first, then by mtime descending.
520
+ """
521
+ try:
522
+ root_path = Path(root).expanduser().resolve()
523
+ except (OSError, RuntimeError):
524
+ return []
525
+
526
+ if not root_path.is_dir() or _is_denied_workspace_root(root_path):
527
+ return []
528
+
529
+ exclude_set: set[str] = set()
530
+ for p in exclude_paths or []:
531
+ try:
532
+ exclude_set.add(str(Path(p).expanduser().resolve()))
533
+ except (OSError, RuntimeError):
534
+ continue
535
+
536
+ candidates = []
537
+ try:
538
+ entries = list(root_path.iterdir())
539
+ except (OSError, PermissionError):
540
+ return []
541
+
542
+ for child in entries:
543
+ if child.name.startswith("."):
544
+ continue
545
+ # is_dir() follows symlinks; is_symlink() lets us reject those first.
546
+ if child.is_symlink() or not child.is_dir():
547
+ continue
548
+ try:
549
+ resolved = str(child.resolve())
550
+ except (OSError, RuntimeError):
551
+ continue
552
+ if resolved in exclude_set:
553
+ continue
554
+ try:
555
+ mtime = child.stat().st_mtime
556
+ except OSError:
557
+ continue
558
+ candidates.append({
559
+ "path": resolved,
560
+ "name": child.name,
561
+ "mtime": mtime,
562
+ "looks_like_project": _looks_like_project(child),
563
+ })
564
+
565
+ # Project-looking dirs first (True > False), then most-recently-touched.
566
+ candidates.sort(key=lambda x: (x["looks_like_project"], x["mtime"]), reverse=True)
567
+ return candidates[:limit]
568
+
569
+
570
+ def load_global_config() -> dict:
571
+ """
572
+ Load global config from ~/.painapple-code/config.json.
573
+
574
+ Returns:
575
+ Config dict (empty dict if file doesn't exist)
576
+ """
577
+ config_path = get_global_config_path()
578
+ if config_path.exists():
579
+ try:
580
+ return json.loads(config_path.read_text(encoding="utf-8"))
581
+ except Exception as e:
582
+ logger.error(f"Failed to load global config: {e}")
583
+ return {}
584
+
585
+
586
+ def save_global_config(config: dict):
587
+ """
588
+ Save global config to ~/.painapple-code/config.json.
589
+
590
+ Args:
591
+ config: Config dict to save
592
+ """
593
+ ensure_data_home()
594
+ config_path = get_global_config_path()
595
+ try:
596
+ config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
597
+ except Exception as e:
598
+ logger.error(f"Failed to save global config: {e}")
599
+
600
+
601
+ # ─── Shadow-git-helper subagent model ─────────────────────────────────────────
602
+
603
+ # The Claude Code subagent `model:` frontmatter field accepts "inherit" (reuse
604
+ # the main thread's model) or a full model ID — the real `claude -p` CLI honors
605
+ # full IDs (verified: a subagent pinned to claude-opus-4-8 ran on opus while the
606
+ # main thread ran Fable). So we offer the same picks as the main model selector
607
+ # (models.yaml `selectable`) plus "inherit", rather than the tier aliases —
608
+ # that's the only way to reach models like Fable, which has no `fable` alias.
609
+ # Persisted in the global config so it survives helper reinstalls
610
+ # (install-helpers.sh cp -f's the bundled default over the top; we re-apply this).
611
+ HELPER_AGENT_INHERIT = "inherit"
612
+ DEFAULT_HELPER_AGENT_MODEL = "claude-sonnet-5"
613
+
614
+
615
+ def get_helper_agent_options() -> list[str]:
616
+ """Valid subagent-model picks: 'inherit' + the selectable model IDs."""
617
+ ids = [m.get("id") for m in get_selectable_models() if m.get("id")]
618
+ return [HELPER_AGENT_INHERIT] + ids
619
+
620
+
621
+ def get_helper_agent_model() -> str:
622
+ """Return the configured shadow-git-helper subagent model (full ID or 'inherit').
623
+
624
+ Reads leniently — a value stored before a model was removed from the
625
+ selectable list is still honored; only writes are validated.
626
+ """
627
+ cfg = load_global_config()
628
+ val = str(cfg.get("helper_agent_model", "") or "").strip()
629
+ return val or DEFAULT_HELPER_AGENT_MODEL
630
+
631
+
632
+ def set_helper_agent_model(model: str) -> str:
633
+ """Persist the shadow-git-helper subagent model.
634
+
635
+ Accepts 'inherit' or a model ID present in the selectable list. Returns the
636
+ normalized value. Raises ValueError for anything else.
637
+ """
638
+ val = str(model or "").strip()
639
+ options = get_helper_agent_options()
640
+ if val not in options:
641
+ raise ValueError(
642
+ f"Invalid helper agent model {model!r}; expected one of {options}"
643
+ )
644
+ cfg = load_global_config()
645
+ cfg["helper_agent_model"] = val
646
+ save_global_config(cfg)
647
+ return val
648
+
649
+
650
+ # ─── Models config (models.yaml) ──────────────────────────────────────────────
651
+
652
+ _models_cache: dict = {}
653
+ _models_mtime: float = 0.0
654
+
655
+ def load_models_config() -> dict:
656
+ """Load models.yaml from package data. Cached by file mtime."""
657
+ global _models_cache, _models_mtime
658
+ yaml_path = PACKAGE_DIR / "data" / "models.yaml"
659
+ if not yaml_path.exists():
660
+ return {"selectable": [], "summary_model": "claude-haiku-4-5"}
661
+ mtime = yaml_path.stat().st_mtime
662
+ if mtime == _models_mtime and _models_cache:
663
+ return _models_cache
664
+ import yaml
665
+ with open(yaml_path, encoding="utf-8") as f:
666
+ _models_cache = yaml.safe_load(f) or {}
667
+ _models_mtime = mtime
668
+ return _models_cache
669
+
670
+
671
+ def get_summary_model() -> str:
672
+ """Model ID for shadow-git summary / auto-journal forks (Claude → Haiku)."""
673
+ cfg = load_models_config()
674
+ # Back-compat: the models.yaml key was `haiku:` before the rename.
675
+ return cfg.get("summary_model") or cfg.get("haiku") or "claude-haiku-4-5"
676
+
677
+
678
+ def get_selectable_models() -> list:
679
+ """Get the list of user-selectable models."""
680
+ return load_models_config().get("selectable", [])
681
+
682
+
683
+ def get_default_models_config() -> dict:
684
+ """Load the pristine default model list from models.defaults.yaml."""
685
+ yaml_path = PACKAGE_DIR / "data" / "models.defaults.yaml"
686
+ if not yaml_path.exists():
687
+ return {"selectable": [], "summary_model": "claude-haiku-4-5"}
688
+ import yaml
689
+ with open(yaml_path, encoding="utf-8") as f:
690
+ return yaml.safe_load(f) or {}
691
+
692
+
693
+ def save_models_config(config: dict) -> None:
694
+ """Write models.yaml with the given config. Invalidates the cache."""
695
+ global _models_cache, _models_mtime
696
+ import yaml
697
+ yaml_path = PACKAGE_DIR / "data" / "models.yaml"
698
+ header = (
699
+ "# models.yaml - Available Claude models (single source of truth)\n"
700
+ "# Server serves this via GET /api/app/models\n"
701
+ "# Edit via Settings → Engines (Claude tab), or by hand (both work).\n\n"
702
+ )
703
+ body = yaml.safe_dump(config, sort_keys=False, allow_unicode=True)
704
+ yaml_path.write_text(header + body, encoding="utf-8")
705
+ # Invalidate cache so next read picks up the change
706
+ _models_cache = {}
707
+ _models_mtime = 0.0
708
+
709
+
710
+ # ─── Per-engine new-session defaults ──────────────────────────────────────────
711
+ #
712
+ # Session defaults are per ENGINE: each map is keyed by the provider's
713
+ # `models_key` namespace (driver pairs share — same convention as
714
+ # `models_disabled`). The flat legacy keys (`default_model`, `default_effort`,
715
+ # `default_token_profile`) remain as read fallbacks for configs written before
716
+ # the split; the engine-defaults PUT migrates them into the maps on first
717
+ # write and drops them.
718
+
719
+ def _engine_scoped_default(map_key: str, legacy_key: str, provider):
720
+ cfg = load_global_config()
721
+ scoped_map = cfg.get(map_key)
722
+ ns = getattr(provider, "models_key", None) or provider.name
723
+ scoped = scoped_map.get(ns) if isinstance(scoped_map, dict) else None
724
+ if isinstance(scoped, str) and scoped:
725
+ return scoped
726
+ legacy = cfg.get(legacy_key)
727
+ return legacy if isinstance(legacy, str) and legacy else None
728
+
729
+
730
+ def engine_default_model(provider):
731
+ """The engine's default new-session model — always CONCRETE when the
732
+ engine has a catalog.
733
+
734
+ Resolution: per-engine map → legacy app-wide flat → **the engine's top
735
+ enabled catalog model** (priority-first). The app owns the default; we no
736
+ longer defer to the engine's own CLI config (the "Default" placeholder is
737
+ gone). A configured value the engine can't serve — a legacy Claude flat
738
+ key read by Codex, or a Settings-hidden id — is treated as unset and
739
+ falls through to the top catalog model, so every engine with a catalog
740
+ presents a real, in-catalog default.
741
+
742
+ Membership is prefix-matched against `enabled_models()` (same as
743
+ `preferred_model_survives`; catalog ids are the short canonical form,
744
+ stored ids may carry date suffixes). Returns None only for an engine with
745
+ NO catalog and no configured value — then the launch path lets the CLI
746
+ use its own configured model (e.g. Codex with no models cache)."""
747
+ value = _engine_scoped_default("default_models", "default_model", provider)
748
+ enabled_ids = [m.get("id") for m in provider.enabled_models() if m.get("id")]
749
+ if not enabled_ids:
750
+ # No catalog to validate against — trust the value; launch decides.
751
+ return value or None
752
+ if value and any(value.startswith(mid) for mid in enabled_ids):
753
+ return value
754
+ # Unset / foreign / hidden → the engine's top (highest-priority) model.
755
+ return enabled_ids[0]
756
+
757
+
758
+ def engine_default_effort(provider):
759
+ """The engine's configured default effort, gated to its own vocabulary —
760
+ a legacy `max` must not leak into an engine that caps at `high`."""
761
+ value = _engine_scoped_default("default_efforts", "default_effort", provider)
762
+ levels = provider.effort_levels() or []
763
+ if value and levels and value not in levels:
764
+ return None
765
+ return value
766
+
767
+
768
+ def engine_default_token_profile(provider):
769
+ """The engine's configured default account/token profile. None for
770
+ engines without selectable accounts (profiles are meaningless there)."""
771
+ if not provider.accounts():
772
+ return None
773
+ return _engine_scoped_default(
774
+ "default_token_profiles", "default_token_profile", provider)
775
+
776
+
777
+ def get_shadow_git_defaults(global_config: dict = None) -> dict:
778
+ """
779
+ Get shadow git defaults from global config.
780
+
781
+ Args:
782
+ global_config: Optional pre-loaded global config
783
+
784
+ Returns:
785
+ Dict with enabled and rich_commits defaults
786
+ """
787
+ if global_config is None:
788
+ global_config = load_global_config()
789
+
790
+ defaults = global_config.get("shadow_git_defaults", {})
791
+ return {
792
+ "enabled": defaults.get("enabled", True),
793
+ "rich_commits": defaults.get("rich_commits", True),
794
+ # Files larger than this (MB) are never committed to shadow git;
795
+ # 0 disables the cap. Overridable per project via shadow_git config.
796
+ "max_file_size_mb": defaults.get("max_file_size_mb", 50),
797
+ }
798
+
799
+
800
+ def load_project_config(project_path: str) -> dict:
801
+ """
802
+ Load project-specific config, merged with global config.
803
+ Project config overrides global config.
804
+
805
+ For shadow_git settings, applies defaults from shadow_git_defaults
806
+ in global config, then project-specific overrides.
807
+
808
+ Args:
809
+ project_path: Project directory path
810
+
811
+ Returns:
812
+ Merged config dict with shadow_git settings resolved
813
+ """
814
+ global_config = load_global_config()
815
+
816
+ # Start with global config (excluding shadow_git_defaults which is meta)
817
+ result = {k: v for k, v in global_config.items() if k != "shadow_git_defaults"}
818
+
819
+ # Apply shadow_git defaults
820
+ sg_defaults = get_shadow_git_defaults(global_config)
821
+ result["shadow_git"] = sg_defaults.copy()
822
+
823
+ # Load project-specific config
824
+ project_config_path = get_project_config_path(project_path)
825
+ if project_config_path.exists():
826
+ try:
827
+ project_config = json.loads(project_config_path.read_text(encoding="utf-8"))
828
+
829
+ # Deep merge shadow_git if present
830
+ if "shadow_git" in project_config:
831
+ result["shadow_git"].update(project_config["shadow_git"])
832
+
833
+ # Merge other project config (overrides global)
834
+ for k, v in project_config.items():
835
+ if k != "shadow_git":
836
+ result[k] = v
837
+
838
+ except Exception as e:
839
+ logger.error(f"Failed to load project config: {e}")
840
+
841
+ return result
842
+
843
+
844
+ def save_project_config(project_path: str, config: dict):
845
+ """
846
+ Save project-specific config.
847
+
848
+ Args:
849
+ project_path: Project directory path
850
+ config: Config dict to save
851
+ """
852
+ ensure_project_dir(project_path)
853
+ config_path = get_project_config_path(project_path)
854
+ try:
855
+ config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
856
+ except Exception as e:
857
+ logger.error(f"Failed to save project config: {e}")
858
+
859
+
860
+ def get_project_display_name(project_path: str) -> str:
861
+ """
862
+ Get human-friendly display name for a project.
863
+
864
+ Returns custom name from config if set, otherwise directory name.
865
+
866
+ Args:
867
+ project_path: Project directory path
868
+
869
+ Returns:
870
+ Display name string
871
+ """
872
+ config = load_project_config(project_path)
873
+ custom_name = config.get("display", {}).get("name")
874
+ if custom_name:
875
+ return custom_name
876
+ return Path(project_path).name
877
+
878
+
879
+ def set_project_display_name(project_path: str, name: str):
880
+ """
881
+ Set human-friendly display name for a project.
882
+
883
+ Args:
884
+ project_path: Project directory path
885
+ name: Display name (empty string to clear)
886
+ """
887
+ config_path = get_project_config_path(project_path)
888
+
889
+ # Load existing config
890
+ existing = {}
891
+ if config_path.exists():
892
+ try:
893
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
894
+ except Exception:
895
+ pass
896
+
897
+ # Update display.name
898
+ if "display" not in existing:
899
+ existing["display"] = {}
900
+
901
+ if name:
902
+ existing["display"]["name"] = name
903
+ else:
904
+ existing["display"].pop("name", None)
905
+ # Clean up empty display dict
906
+ if not existing["display"]:
907
+ existing.pop("display", None)
908
+
909
+ save_project_config(project_path, existing)
910
+
911
+
912
+ def _normalize_project_color(color: str) -> Optional[str]:
913
+ """Validate + normalize a project color to a #rrggbb hex string.
914
+
915
+ Accepts 3- or 6-digit hex with or without a leading '#'. Returns the
916
+ lowercased #rrggbb form, or None if the value is empty/invalid (so
917
+ callers can treat "invalid" the same as "clear").
918
+ """
919
+ if not color:
920
+ return None
921
+ c = str(color).strip().lstrip("#").lower()
922
+ if len(c) == 3 and all(ch in "0123456789abcdef" for ch in c):
923
+ c = "".join(ch * 2 for ch in c)
924
+ if len(c) == 6 and all(ch in "0123456789abcdef" for ch in c):
925
+ return f"#{c}"
926
+ return None
927
+
928
+
929
+ def get_project_color(project_path: str) -> Optional[str]:
930
+ """Get the user-assigned custom color for a project.
931
+
932
+ Returns a normalized #rrggbb hex string, or None when no custom color
933
+ is set (callers fall back to the deterministic client-side hash color).
934
+ """
935
+ config = load_project_config(project_path)
936
+ return _normalize_project_color(config.get("display", {}).get("color"))
937
+
938
+
939
+ def set_project_color(project_path: str, color: str) -> Optional[str]:
940
+ """Set (or clear) the custom color for a project.
941
+
942
+ Stores under ``display.color`` to mirror ``display.name``. Pass an empty
943
+ string / invalid value to clear the override. Returns the normalized
944
+ color that was stored, or None when cleared.
945
+ """
946
+ config_path = get_project_config_path(project_path)
947
+
948
+ existing = {}
949
+ if config_path.exists():
950
+ try:
951
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
952
+ except Exception:
953
+ pass
954
+
955
+ if "display" not in existing:
956
+ existing["display"] = {}
957
+
958
+ normalized = _normalize_project_color(color)
959
+ if normalized:
960
+ existing["display"]["color"] = normalized
961
+ else:
962
+ existing["display"].pop("color", None)
963
+
964
+ # Clean up an empty display dict so config stays tidy.
965
+ if not existing.get("display"):
966
+ existing.pop("display", None)
967
+
968
+ save_project_config(project_path, existing)
969
+ return normalized
970
+
971
+
972
+ def list_project_colors() -> dict:
973
+ """Map of project path → custom color for every project that has one set.
974
+
975
+ Powers a single client-side fetch so the synchronous ``getProjectColor``
976
+ lookups across the UI can consult overrides without per-project requests.
977
+ """
978
+ return {
979
+ p["path"]: p["color"]
980
+ for p in list_projects(include_unreachable=True)
981
+ if p.get("color")
982
+ }
983
+
984
+
985
+ # ═══════════════════════════════════════════════════════════════════════════
986
+ # ═══════════════════════════════════════════════════════════════════════════
987
+ # Favorites System
988
+ # ═══════════════════════════════════════════════════════════════════════════
989
+
990
+ def get_favorites_path() -> Path:
991
+ """
992
+ Get favorites file path.
993
+
994
+ Returns:
995
+ Path to ~/.painapple-code/favorites{suffix}.json (per-tier)
996
+ """
997
+ return _state_file("favorites", ".json")
998
+
999
+
1000
+ def get_drafts_path() -> Path:
1001
+ """
1002
+ Get prompt-drafts file path (explicit "save for later" prompts).
1003
+
1004
+ Returns:
1005
+ Path to ~/.painapple-code/drafts{suffix}.json (per-tier)
1006
+ """
1007
+ return _state_file("drafts", ".json")
1008
+
1009
+
1010
+ def load_drafts() -> dict:
1011
+ """
1012
+ Load prompt drafts from ~/.painapple-code/drafts.json.
1013
+
1014
+ Returns:
1015
+ Dict with version and drafts list (newest first)
1016
+ """
1017
+ drafts_path = get_drafts_path()
1018
+ if drafts_path.exists():
1019
+ try:
1020
+ return json.loads(drafts_path.read_text(encoding="utf-8"))
1021
+ except Exception as e:
1022
+ logger.error(f"Failed to load drafts: {e}")
1023
+ return {"version": 1, "drafts": []}
1024
+
1025
+
1026
+ def save_drafts(data: dict) -> bool:
1027
+ """
1028
+ Save prompt drafts to ~/.painapple-code/drafts.json.
1029
+
1030
+ Args:
1031
+ data: Drafts dict with version and drafts list
1032
+
1033
+ Returns:
1034
+ True on success (drafts are user data — callers should surface failure)
1035
+ """
1036
+ ensure_data_home()
1037
+ drafts_path = get_drafts_path()
1038
+ try:
1039
+ drafts_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
1040
+ return True
1041
+ except Exception as e:
1042
+ logger.error(f"Failed to save drafts: {e}")
1043
+ return False
1044
+
1045
+
1046
+ def load_favorites() -> dict:
1047
+ """
1048
+ Load favorites from ~/.painapple-code/favorites.json.
1049
+
1050
+ Returns:
1051
+ Dict with version and favorites list
1052
+ """
1053
+ fav_path = get_favorites_path()
1054
+ if fav_path.exists():
1055
+ try:
1056
+ return json.loads(fav_path.read_text(encoding="utf-8"))
1057
+ except Exception as e:
1058
+ logger.error(f"Failed to load favorites: {e}")
1059
+ return {"version": 1, "favorites": []}
1060
+
1061
+
1062
+ def save_favorites(data: dict):
1063
+ """
1064
+ Save favorites to ~/.painapple-code/favorites.json.
1065
+
1066
+ Args:
1067
+ data: Favorites dict with version and favorites list
1068
+ """
1069
+ ensure_data_home()
1070
+ fav_path = get_favorites_path()
1071
+ try:
1072
+ fav_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
1073
+ except Exception as e:
1074
+ logger.error(f"Failed to save favorites: {e}")
1075
+
1076
+
1077
+ def add_favorite(session_id: str, project_hash: str, note: str = None) -> bool:
1078
+ """
1079
+ Add a session to favorites.
1080
+
1081
+ Args:
1082
+ session_id: Session ID to favorite
1083
+ project_hash: Project hash the session belongs to
1084
+ note: Optional note explaining why it's favorited
1085
+
1086
+ Returns:
1087
+ True if added, False if already exists
1088
+ """
1089
+
1090
+
1091
+ data = load_favorites()
1092
+ favorites = data.get("favorites", [])
1093
+
1094
+ # Check if already favorited
1095
+ for fav in favorites:
1096
+ if fav["session_id"] == session_id:
1097
+ return False
1098
+
1099
+ # Add new favorite
1100
+ favorites.append({
1101
+ "session_id": session_id,
1102
+ "project_hash": project_hash,
1103
+ "note": note,
1104
+ "added_at": datetime.now(timezone.utc).isoformat(),
1105
+ "updated_at": datetime.now(timezone.utc).isoformat(),
1106
+ })
1107
+
1108
+ data["favorites"] = favorites
1109
+ save_favorites(data)
1110
+ return True
1111
+
1112
+
1113
+ def remove_favorite(session_id: str) -> bool:
1114
+ """
1115
+ Remove a session from favorites.
1116
+
1117
+ Args:
1118
+ session_id: Session ID to unfavorite
1119
+
1120
+ Returns:
1121
+ True if removed, False if not found
1122
+ """
1123
+ data = load_favorites()
1124
+ favorites = data.get("favorites", [])
1125
+
1126
+ original_len = len(favorites)
1127
+ favorites = [f for f in favorites if f["session_id"] != session_id]
1128
+
1129
+ if len(favorites) == original_len:
1130
+ return False
1131
+
1132
+ data["favorites"] = favorites
1133
+ save_favorites(data)
1134
+ return True
1135
+
1136
+
1137
+ def update_favorite_note(session_id: str, note: str) -> bool:
1138
+ """
1139
+ Update the note on a favorite.
1140
+
1141
+ Args:
1142
+ session_id: Session ID to update
1143
+ note: New note (can be empty string to clear)
1144
+
1145
+ Returns:
1146
+ True if updated, False if not found
1147
+ """
1148
+
1149
+
1150
+ data = load_favorites()
1151
+ favorites = data.get("favorites", [])
1152
+
1153
+ for fav in favorites:
1154
+ if fav["session_id"] == session_id:
1155
+ fav["note"] = note
1156
+ fav["updated_at"] = datetime.now(timezone.utc).isoformat()
1157
+ data["favorites"] = favorites
1158
+ save_favorites(data)
1159
+ return True
1160
+
1161
+ return False
1162
+
1163
+
1164
+ def is_favorite(session_id: str) -> bool:
1165
+ """
1166
+ Check if a session is favorited.
1167
+
1168
+ Args:
1169
+ session_id: Session ID to check
1170
+
1171
+ Returns:
1172
+ True if favorited, False otherwise
1173
+ """
1174
+ data = load_favorites()
1175
+ favorites = data.get("favorites", [])
1176
+ return any(f["session_id"] == session_id for f in favorites)
1177
+
1178
+
1179
+ def get_favorite(session_id: str) -> Optional[dict]:
1180
+ """
1181
+ Get favorite data for a session.
1182
+
1183
+ Args:
1184
+ session_id: Session ID to look up
1185
+
1186
+ Returns:
1187
+ Favorite dict if found, None otherwise
1188
+ """
1189
+ data = load_favorites()
1190
+ favorites = data.get("favorites", [])
1191
+ for fav in favorites:
1192
+ if fav["session_id"] == session_id:
1193
+ return fav
1194
+ return None
1195
+
1196
+
1197
+ # ═══════════════════════════════════════════════════════════════════════════
1198
+ # Prompt Favorites System
1199
+ # ═══════════════════════════════════════════════════════════════════════════
1200
+
1201
+ def get_prompt_favorites_path() -> Path:
1202
+ """
1203
+ Get prompt favorites file path.
1204
+
1205
+ Returns:
1206
+ Path to ~/.painapple-code/prompt-favorites{suffix}.json (per-tier)
1207
+ """
1208
+ return _state_file("prompt-favorites", ".json")
1209
+
1210
+
1211
+ def load_prompt_favorites() -> dict:
1212
+ """
1213
+ Load prompt favorites from ~/.painapple-code/prompt-favorites.json.
1214
+
1215
+ Returns:
1216
+ Dict with version and prompts dict (prompt_id -> metadata)
1217
+ """
1218
+ fav_path = get_prompt_favorites_path()
1219
+ if fav_path.exists():
1220
+ try:
1221
+ return json.loads(fav_path.read_text(encoding="utf-8"))
1222
+ except Exception as e:
1223
+ logger.error(f"Failed to load prompt favorites: {e}")
1224
+ return {"version": 1, "prompts": {}}
1225
+
1226
+
1227
+ def save_prompt_favorites(data: dict):
1228
+ """
1229
+ Save prompt favorites to ~/.painapple-code/prompt-favorites.json.
1230
+
1231
+ Args:
1232
+ data: Favorites dict with version and prompts
1233
+ """
1234
+ ensure_data_home()
1235
+ fav_path = get_prompt_favorites_path()
1236
+ try:
1237
+ fav_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
1238
+ except Exception as e:
1239
+ logger.error(f"Failed to save prompt favorites: {e}")
1240
+
1241
+
1242
+ def add_prompt_favorite(prompt_id: str, content_preview: str = "", note: str = "") -> bool:
1243
+ """
1244
+ Add a prompt to favorites.
1245
+
1246
+ Args:
1247
+ prompt_id: Prompt ID (format: session_id:line_number)
1248
+ content_preview: First ~100 chars of prompt for display
1249
+ note: Optional user note
1250
+
1251
+ Returns:
1252
+ True if added, False if already exists
1253
+ """
1254
+
1255
+
1256
+ data = load_prompt_favorites()
1257
+ prompts = data.get("prompts", {})
1258
+
1259
+ if prompt_id in prompts:
1260
+ return False
1261
+
1262
+ prompts[prompt_id] = {
1263
+ "content_preview": content_preview[:100] if content_preview else "",
1264
+ "note": note,
1265
+ "added_at": datetime.now(timezone.utc).isoformat(),
1266
+ }
1267
+
1268
+ data["prompts"] = prompts
1269
+ save_prompt_favorites(data)
1270
+ return True
1271
+
1272
+
1273
+ def remove_prompt_favorite(prompt_id: str) -> bool:
1274
+ """
1275
+ Remove a prompt from favorites.
1276
+
1277
+ Args:
1278
+ prompt_id: Prompt ID to unfavorite
1279
+
1280
+ Returns:
1281
+ True if removed, False if not found
1282
+ """
1283
+ data = load_prompt_favorites()
1284
+ prompts = data.get("prompts", {})
1285
+
1286
+ if prompt_id not in prompts:
1287
+ return False
1288
+
1289
+ del prompts[prompt_id]
1290
+ data["prompts"] = prompts
1291
+ save_prompt_favorites(data)
1292
+ return True
1293
+
1294
+
1295
+ def is_prompt_favorite(prompt_id: str) -> bool:
1296
+ """
1297
+ Check if a prompt is favorited.
1298
+
1299
+ Args:
1300
+ prompt_id: Prompt ID to check
1301
+
1302
+ Returns:
1303
+ True if favorited, False otherwise
1304
+ """
1305
+ data = load_prompt_favorites()
1306
+ return prompt_id in data.get("prompts", {})
1307
+
1308
+
1309
+ def get_prompt_favorite(prompt_id: str) -> Optional[dict]:
1310
+ """
1311
+ Get favorite metadata for a prompt.
1312
+
1313
+ Args:
1314
+ prompt_id: Prompt ID to look up
1315
+
1316
+ Returns:
1317
+ Favorite metadata dict if found, None otherwise
1318
+ """
1319
+ data = load_prompt_favorites()
1320
+ return data.get("prompts", {}).get(prompt_id)
1321
+
1322
+
1323
+ def get_all_prompt_favorites() -> dict:
1324
+ """
1325
+ Get all favorited prompt IDs.
1326
+
1327
+ Returns:
1328
+ Dict of prompt_id -> metadata
1329
+ """
1330
+ data = load_prompt_favorites()
1331
+ return data.get("prompts", {})
1332
+
1333
+
1334
+ # One warning per path per process — a failed ACL tightening is worth
1335
+ # saying loudly once, not on every ensure_config_file() call.
1336
+ _ACL_WARNED: set = set()
1337
+
1338
+
1339
+ def _current_user_sid() -> Optional[str]:
1340
+ """The calling process's user SID (``S-1-5-21-…``), or None if unreadable.
1341
+
1342
+ icacls names a principal either by account name or, with a ``*`` prefix,
1343
+ by SID. Account names are the wrong currency here. The obvious spelling,
1344
+ ``%USERDOMAIN%\\%USERNAME%``, is actively wrong on a workgroup machine:
1345
+ USERDOMAIN is the literal string "WORKGROUP", which maps to no SID at
1346
+ all, so icacls fails with 1332 ("No mapping between account names and
1347
+ security IDs was done") and the hardening silently degrades to a warning.
1348
+ A bare username usually resolves, but not on a localized install where
1349
+ the account was renamed, and it can be ambiguous when a local and a
1350
+ domain account share a name.
1351
+
1352
+ The SID sidesteps all of it: we read it straight off our own process
1353
+ token, so there is no name to resolve and no locale to get wrong.
1354
+ """
1355
+ import ctypes
1356
+ from ctypes import wintypes
1357
+
1358
+ TOKEN_QUERY = 0x0008
1359
+ TOKEN_USER_CLASS = 1
1360
+
1361
+ try:
1362
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
1363
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1364
+
1365
+ # Declare signatures explicitly: ctypes defaults every return type to
1366
+ # C int, which truncates HANDLEs and pointers on 64-bit Windows.
1367
+ advapi32.OpenProcessToken.argtypes = [
1368
+ wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
1369
+ advapi32.OpenProcessToken.restype = wintypes.BOOL
1370
+ advapi32.GetTokenInformation.argtypes = [
1371
+ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
1372
+ ctypes.POINTER(wintypes.DWORD)]
1373
+ advapi32.GetTokenInformation.restype = wintypes.BOOL
1374
+ advapi32.ConvertSidToStringSidW.argtypes = [
1375
+ ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
1376
+ advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
1377
+ kernel32.GetCurrentProcess.restype = wintypes.HANDLE
1378
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
1379
+ kernel32.LocalFree.restype = ctypes.c_void_p
1380
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
1381
+ kernel32.CloseHandle.restype = wintypes.BOOL
1382
+
1383
+ token = wintypes.HANDLE()
1384
+ if not advapi32.OpenProcessToken(
1385
+ kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
1386
+ return None
1387
+ try:
1388
+ # First call sizes the buffer; it's expected to "fail" with
1389
+ # ERROR_INSUFFICIENT_BUFFER, so only the second return matters.
1390
+ size = wintypes.DWORD()
1391
+ advapi32.GetTokenInformation(
1392
+ token, TOKEN_USER_CLASS, None, 0, ctypes.byref(size))
1393
+ if not size.value:
1394
+ return None
1395
+ buf = ctypes.create_string_buffer(size.value)
1396
+ if not advapi32.GetTokenInformation(
1397
+ token, TOKEN_USER_CLASS, buf, size, ctypes.byref(size)):
1398
+ return None
1399
+ # TOKEN_USER is a SID_AND_ATTRIBUTES, whose first pointer-sized
1400
+ # field is the PSID itself (the SID lives past the struct).
1401
+ sid = ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p)).contents
1402
+ out = ctypes.c_wchar_p()
1403
+ if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(out)):
1404
+ return None
1405
+ try:
1406
+ return out.value
1407
+ finally:
1408
+ kernel32.LocalFree(out)
1409
+ finally:
1410
+ kernel32.CloseHandle(token)
1411
+ except (OSError, AttributeError, ValueError):
1412
+ return None
1413
+
1414
+
1415
+ def _acl_principal() -> Optional[str]:
1416
+ """The icacls principal for the current user — SID first, name as backstop."""
1417
+ sid = _current_user_sid()
1418
+ if sid:
1419
+ return f"*{sid}"
1420
+ # Deliberately the BARE username, never %USERDOMAIN%\%USERNAME%: on a
1421
+ # workgroup machine that domain part is unresolvable (see above).
1422
+ return os.environ.get("USERNAME") or None
1423
+
1424
+
1425
+ def _lock_mode_windows(path: Path, mode: int) -> None:
1426
+ """Owner-only DACL via icacls — the NTFS stand-in for chmod 0600/0700.
1427
+
1428
+ On Windows `os.chmod` only toggles FILE_ATTRIBUTE_READONLY and
1429
+ RETURNS SUCCESS, so the POSIX path here was a silent no-op: the
1430
+ server password and the TLS private key were protected by nothing but
1431
+ whatever the user's profile happened to inherit, and the `except
1432
+ OSError` warning could never fire to say so. (Perversely, chmod 0600
1433
+ on an already-read-only file LOOSENS it by clearing that attribute.)
1434
+
1435
+ icacls ships in every Windows install, so this needs no pywin32:
1436
+ /inheritance:r drop inherited ACEs (the whole point — otherwise
1437
+ a permissive parent keeps granting access)
1438
+ /grant:r USER:F full control for just this user, replacing any
1439
+ existing grant for them
1440
+ Modes with any group/other bits set (0o644) aren't "owner only" and
1441
+ are left alone rather than silently over-tightened.
1442
+ """
1443
+ if mode & 0o077:
1444
+ return
1445
+ key = str(path)
1446
+ principal = _acl_principal()
1447
+ if not principal:
1448
+ if key not in _ACL_WARNED:
1449
+ _ACL_WARNED.add(key)
1450
+ logger.warning(
1451
+ f"Cannot restrict {path}: the current user's SID and USERNAME "
1452
+ "are both unreadable, so no owner-only ACL was applied. This "
1453
+ "file is readable by any account that inherits access to its "
1454
+ "parent directory."
1455
+ )
1456
+ return
1457
+ try:
1458
+ result = subprocess.run(
1459
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{principal}:F"],
1460
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1461
+ timeout=15,
1462
+ )
1463
+ except (OSError, subprocess.SubprocessError) as e:
1464
+ result = None
1465
+ detail = f"{type(e).__name__}: {e}"
1466
+ else:
1467
+ detail = (result.stderr or result.stdout or "").strip()[:200]
1468
+ if result is None or result.returncode != 0:
1469
+ if key not in _ACL_WARNED:
1470
+ _ACL_WARNED.add(key)
1471
+ logger.warning(
1472
+ f"Could not restrict {path} to {principal} only ({detail}). "
1473
+ "Continuing — check it isn't readable by other accounts on "
1474
+ "this machine."
1475
+ )
1476
+
1477
+
1478
+ def lock_mode(path, mode: int) -> None:
1479
+ """Tighten `path` to `mode`, tolerating a filesystem that won't have it.
1480
+
1481
+ chmod needs OWNERSHIP, not write permission — it fails with EPERM,
1482
+ not EACCES, when you merely have write access. So a config dir
1483
+ bind-mounted from the host into a container raises here whenever the
1484
+ container's user isn't its owner, which used to take the whole server
1485
+ down on boot before it had served a single request.
1486
+
1487
+ These modes are hardening on files the host user already controls and
1488
+ can chmod themselves, so a refusal is worth a loud warning rather
1489
+ than a dead server. The no-op case (already correct, by far the most
1490
+ common) doesn't call chmod at all, so it can't fail there either.
1491
+
1492
+ On Windows the POSIX bits are meaningless, so this delegates to an
1493
+ icacls DACL — see _lock_mode_windows for why a no-op there was worse
1494
+ than it looks.
1495
+ """
1496
+ path = Path(path)
1497
+ if sys.platform == "win32":
1498
+ _lock_mode_windows(path, mode)
1499
+ return
1500
+ try:
1501
+ current = stat.S_IMODE(path.stat().st_mode)
1502
+ except OSError:
1503
+ current = None
1504
+ if current == mode:
1505
+ return
1506
+ try:
1507
+ os.chmod(path, mode)
1508
+ except OSError as e:
1509
+ logger.warning(
1510
+ f"Could not set permissions on {path} to {mode:#o} "
1511
+ f"({type(e).__name__}: {e.strerror})"
1512
+ + (f"; it is {current:#o}" if current is not None else "")
1513
+ + ". Continuing — check it isn't readable by other users."
1514
+ )