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,710 @@
1
+ """
2
+ Provider interface + the data types it exchanges with the session layer.
3
+
4
+ A `Provider` owns everything CLI-shaped: how to build the launch argv, how to
5
+ frame a user message for stdin, how to parse a stdout line into a canonical
6
+ event, how to normalize cost, and how to classify stderr. The generic session
7
+ lifecycle (subprocess spawn, registry, turn tracking, WebSocket plumbing) lives
8
+ in `services/agent_session.py` and delegates to `session.provider`.
9
+
10
+ The canonical event shape is Claude's stream-json schema (see the package
11
+ docstring). `ClaudeProvider` is therefore a near-identity adapter; other
12
+ providers translate their native output into this shape inside `parse_line`.
13
+
14
+ Providers must be stateless — any per-subprocess running state (e.g. Claude's
15
+ cumulative cost) is held in a per-session `CostState` passed in by the caller,
16
+ so a single provider instance can be shared across all sessions.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Optional
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Capabilities:
30
+ """Declarative feature flags a provider's CLI supports.
31
+
32
+ Generic code branches on these instead of assuming Claude semantics — e.g.
33
+ the shadow-git rich-commit summary fork only runs when `rich_commit_summaries`
34
+ is set.
35
+ """
36
+ resume: bool = False # supports resuming a prior session
37
+ fork: bool = False # supports forking a session (--fork-session)
38
+ permission_modes: bool = False # has a permission-mode concept
39
+ effort: bool = False # has an effort/thinking-budget knob
40
+ thinking_display: bool = False # streams thinking blocks
41
+ context_command: bool = False # can report context-window usage
42
+ cumulative_cost: bool = False # reports cost as a per-process running total
43
+ # Supports the post-turn background fork that generates AI rich-commit
44
+ # sections (summary, tags, work_done, …). The fork resumes the provider's
45
+ # own on-disk session, so today only Claude can do it; named for what it
46
+ # gates, not the Haiku model it happens to use.
47
+ rich_commit_summaries: bool = False
48
+ # Process model. True (Claude): one long-lived subprocess reads successive
49
+ # user messages from stdin across many turns. False (Codex): a fresh
50
+ # subprocess per turn with the prompt in argv that exits when the turn ends —
51
+ # a normal exit, not "session ended". The session layer branches on this.
52
+ persistent_process: bool = True
53
+ # Wire protocol the session layer speaks to the subprocess. "lines" (default):
54
+ # newline-delimited JSON — read events off stdout, write user messages to
55
+ # stdin (the only shape Claude/Codex-exec need). "jsonrpc": the provider
56
+ # supplies a transport (see `make_transport`) that owns a bidirectional
57
+ # JSON-RPC conversation — a multi-step turn handshake, id-correlated
58
+ # responses, and server-initiated requests — none of which the one-way line
59
+ # protocol can express. Implies `persistent_process=True`; the session layer
60
+ # delegates the send/intake/interrupt side to the transport but still reads
61
+ # stdout with the same loop (so `parse_line` + `translate_events` apply).
62
+ transport: str = "lines"
63
+ # Whether ordinary (StderrClass.NONE) stderr lines are surfaced to the
64
+ # client. Codex prints human progress to stderr even with --json, so its
65
+ # adapter sets this False (the lines are still logged to raw.jsonl).
66
+ forward_plain_stderr: bool = True
67
+ # Interactive permission prompts: the provider's process emits
68
+ # `permission_request` events on stdout and accepts `permission_response`
69
+ # frames on stdin (claude-sdk driver via `can_use_tool`). The session layer
70
+ # round-trips these to the WebSocket client instead of relying on headless
71
+ # auto-deny. Providers without it keep the is_error-tool-result behavior.
72
+ interactive_permissions: bool = False
73
+ # Control-plane frames over the same stdin/stdout pipe: the process accepts
74
+ # `control_request` frames (set_permission_mode / set_model / interrupt)
75
+ # and acks each with a `control_done` line (claude-sdk driver via the SDK
76
+ # client's control methods). The session layer then applies mode/model
77
+ # changes live instead of lazy kill+respawn, and Stop interrupts the turn
78
+ # without killing the process. Distinct from the jsonrpc transport's native
79
+ # interrupt (Codex), which rides the transport object. Acks are mandatory:
80
+ # on nack/timeout callers fall back to the kill+respawn path, so a failed
81
+ # control is never worse than not having the capability at all.
82
+ live_controls: bool = False
83
+ # The turn's prompt reaches the CLI as a command-line ARGUMENT rather than
84
+ # over stdin/stdio, so it sits in `ps` and `/proc/<pid>/cmdline` — readable
85
+ # by every other local account — for as long as the turn runs. Declared, not
86
+ # inferred: `persistent_process=False` happens to imply it today, but the two
87
+ # are independent (a long-lived process can still take its first prompt in
88
+ # argv, and an ephemeral one can read stdin). Surfaced through `describe()`
89
+ # so Settings → Engines can warn at the point of choice; see SECURITY.md
90
+ # "Prompts reach `ps` on the `codex` engine".
91
+ prompt_in_argv: bool = False
92
+
93
+
94
+ @dataclass
95
+ class LaunchOptions:
96
+ """Fully-resolved values for one subprocess launch.
97
+
98
+ The session layer resolves model/effort/permission from its hierarchy
99
+ (in-memory override → session meta → global config) and passes the resolved
100
+ values here; the provider only turns them into argv. `None` means "let the
101
+ CLI use its own default".
102
+ """
103
+ model: Optional[str] = None
104
+ # Automatic fallback when the primary model is overloaded/unavailable
105
+ # (`--fallback-model`). None = no fallback, the CLI default.
106
+ fallback_model: Optional[str] = None
107
+ effort: Optional[str] = None
108
+ permission_mode: Optional[str] = None
109
+ session_id: Optional[str] = None # resume target
110
+ fork_from_session_id: Optional[str] = None # fork source (takes precedence)
111
+ # Ephemeral providers (persistent_process=False) embed the turn's prompt and
112
+ # images in the launch argv rather than streaming them over stdin. Ignored by
113
+ # persistent providers, which frame the message via `frame_input` instead.
114
+ prompt: Optional[str] = None
115
+ images: Optional[list] = None
116
+
117
+
118
+ @dataclass
119
+ class CostState:
120
+ """Per-subprocess cumulative cost/usage tracker.
121
+
122
+ Only meaningful for providers whose CLI reports running totals across the
123
+ subprocess lifetime (Claude). One instance per subprocess; reset by the
124
+ session layer on each (re)start via `provider.new_cost_state()`.
125
+ """
126
+ cum_cost: float = 0.0
127
+ cum_usage: dict = field(default_factory=dict)
128
+
129
+
130
+ @dataclass
131
+ class SummaryForkPlan:
132
+ """How to spawn a provider's post-turn rich-commit summarizer fork.
133
+
134
+ The shadow-git layer owns the generic plumbing (subprocess registry,
135
+ timeout, cancellation, markdown rendering); a provider only supplies the
136
+ argv + any temp files it created (via `build_summary_fork`) and later turns
137
+ the raw output back into structured data (via `parse_summary_fork`). The
138
+ fork must summarize the turn *without* mutating the user's real session —
139
+ Claude branches with `--fork-session`; Codex copies its rollout under a
140
+ fresh id first (`codex exec resume` writes in place).
141
+ """
142
+ argv: list[str]
143
+ env: Optional[dict] = None
144
+ model: str = "" # label for the Active Sessions widget
145
+ output_file: Optional[str] = None # file the structured JSON lands in (None → stdout)
146
+ cleanup_paths: list = field(default_factory=list) # temp copies/files to remove after
147
+ # Prompt to feed the fork over stdin instead of an argv element. Keeps a
148
+ # large journey/tools prompt out of `ps` and clear of ARG_MAX. When set, the
149
+ # spawner opens stdin as a PIPE and writes this before reading stdout; the
150
+ # provider MUST omit the prompt from argv (e.g. Claude's bare `-p`, no
151
+ # positional). None → the prompt rides in argv as before.
152
+ stdin_input: Optional[str] = None
153
+ # Provider-private data for a transport-driven summary (see
154
+ # `drive_summary_fork`). Line/exec providers leave this None and use the
155
+ # spawned argv's stdout via `parse_summary_fork`; a JSON-RPC provider stashes
156
+ # here what its drive step needs (source thread id, prompt, output schema, …).
157
+ payload: Optional[dict] = None
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class PluginBackend:
162
+ """How to drive an engine's plugin-manager CLI for the plugins browser.
163
+
164
+ Both Claude (`claude plugins …`) and Codex (`codex plugin …`) expose a
165
+ ``list --json`` returning ``{installed, available}`` and install/uninstall
166
+ verbs, so the route stays generic and only the verbs/paths differ. Engines
167
+ without an enable/disable concept leave those ``None`` (the UI hides the
168
+ toggle). ``marketplace_root`` is the on-disk dir used for the component
169
+ inventory; ``None`` skips inventory for that engine.
170
+ """
171
+ binary: str
172
+ list_installed_args: list # args yielding {installed, available} for installed only
173
+ list_all_args: list # same but including the marketplace catalog
174
+ install_verb: list # e.g. ["plugins","install"] / ["plugin","add"]
175
+ uninstall_verb: list
176
+ enable_verb: Optional[list] = None # None → engine has no enable/disable
177
+ disable_verb: Optional[list] = None
178
+ marketplace_root: Optional[object] = None # Path for component inventory; None → skip
179
+
180
+
181
+ class StderrClass(str, Enum):
182
+ """Classification of a single stderr line. The session layer applies its
183
+ own state gates (retry budget, etc.) on top of this pure-text verdict."""
184
+ NONE = "none" # ordinary diagnostic — forward to client
185
+ STALE_SESSION = "stale_session" # resume target no longer exists
186
+ COMPACTING = "compacting" # context auto-compaction in progress
187
+ RETRYABLE = "retryable" # transient API error worth retrying
188
+
189
+
190
+ class Provider(ABC):
191
+ """Adapter for one CLI AI agent. See module docstring for the contract."""
192
+
193
+ name: str = "base"
194
+ display_name: str = "Base"
195
+ # One-line, user-facing "what is this engine" for the picker row. Each
196
+ # provider self-describes (nothing is hardcoded per engine in the UI).
197
+ description: str = ""
198
+ # Whether this engine is offered in the picker out of the box. The user
199
+ # flips engines on/off in Settings (`providers_enabled` config overrides);
200
+ # this is only the pre-override default, self-described so nothing is
201
+ # hardcoded per engine elsewhere. Defaults True so a drop-in third-party
202
+ # provider shows up without extra steps; in-tree "same engine, plainer
203
+ # driver" variants (claude CLI, codex exec) ship False to keep the picker
204
+ # down to one entry per engine. Disabled ≠ unavailable: existing sessions
205
+ # bound to a disabled engine keep working, and explicit API selection
206
+ # (?provider=, PUT) still accepts it — this gates only the UI listing.
207
+ default_enabled: bool = True
208
+ # Global-config key holding a user override for `binary()` (e.g.
209
+ # "claude_path"), and the bare command it falls back to when unset.
210
+ # Drives the generic Settings "CLI path" row + /api/app/engine-path
211
+ # endpoint; None → this engine has no path setting (row hidden).
212
+ # Same-engine driver variants share one key (both Claude drivers run the
213
+ # same binary), so the setting edits the engine, not the driver.
214
+ path_config_key: Optional[str] = None
215
+ default_binary: str = ""
216
+ # Copy-paste CLI hint for the "Continue in CLI" quick action: a shell
217
+ # command template that resumes a session by id ("{id}" placeholder). Self-
218
+ # described so the client never hardcodes an engine's resume verb (Claude
219
+ # "claude -r {id}", Codex "codex exec resume {id}"). None → the action is
220
+ # hidden for this engine.
221
+ cli_resume_template: Optional[str] = None
222
+ # Whether the app owns this engine's model catalog (Settings shows an
223
+ # editor writing models.yaml). False → `models()` is read-only, sourced
224
+ # from the engine's own tooling (e.g. Codex's models_cache.json), and
225
+ # Settings renders the definitions without edit affordances (per-model
226
+ # show/hide toggles still apply — see `enabled_models()`).
227
+ models_editable: bool = False
228
+ # Namespace for per-model visibility prefs (the `models_disabled` config
229
+ # map: {key: [hidden ids]}). Driver variants that surface the SAME
230
+ # catalog (claude/claude-sdk share models.yaml; codex/codex-app-server
231
+ # share models_cache.json) set one key so hiding a model hides it on
232
+ # both. None → this provider's own name (a drop-in provider gets its own
233
+ # namespace with no extra steps).
234
+ models_key: Optional[str] = None
235
+ # CLI login surface (Settings → Engines auth row). `auth_status_args` are
236
+ # appended to the resolved binary for a fast non-interactive login-status
237
+ # probe (drives GET /api/app/engine-auth/{name}); None → the engine
238
+ # shows no login row. `auth_login_args` is the CLI's own interactive
239
+ # login flow — the client runs it in a PTY terminal tab, so pick a
240
+ # variant whose prompts survive a remote box (device-code flows beat
241
+ # open-a-browser flows that bind localhost on the server).
242
+ auth_status_args: Optional[list] = None
243
+ auth_login_args: Optional[list] = None
244
+ # Auto-journal (rich-commit summary) model. The journal fork always runs
245
+ # on the session's OWN engine — only the engine that owns a conversation
246
+ # can fork it — so the summarizer model is inherently a per-engine
247
+ # setting. Providers storing the override in the global config set the
248
+ # key here (driver pairs share one, like `path_config_key`); providers
249
+ # with their own storage override the get/set methods instead (Claude →
250
+ # models.yaml `summary_model`). Neither → no journal-model knob in
251
+ # Settings. `summary_model_placeholder` names what an EMPTY override
252
+ # means for this engine (e.g. Codex inherits the session's model).
253
+ summary_model_config_key: Optional[str] = None
254
+ summary_model_placeholder: str = ""
255
+ capabilities: Capabilities = Capabilities()
256
+
257
+ # --- launch -----------------------------------------------------------
258
+
259
+ @abstractmethod
260
+ def binary(self) -> str:
261
+ """Resolve the CLI executable (name on PATH or absolute path)."""
262
+
263
+ def binary_not_found_hint(self) -> str:
264
+ """User-facing remediation appended when the executable can't be spawned.
265
+
266
+ Providers with a configurable path setting should point at it here.
267
+ """
268
+ return "Install the CLI and make sure it's on the server's PATH."
269
+
270
+ @abstractmethod
271
+ def build_command(self, opts: LaunchOptions) -> list[str]:
272
+ """Build the full subprocess argv for a streaming session."""
273
+
274
+ @abstractmethod
275
+ def frame_input(self, message: dict) -> dict:
276
+ """Envelope a user message for the CLI's stdin protocol.
277
+
278
+ Only called on the stdin line path (persistent ``"lines"`` providers).
279
+ The argument is the canonical user message; return the exact dict to
280
+ JSON-encode onto the process's stdin. Providers that never read turns
281
+ from stdin — ephemeral argv prompts (`persistent_process=False`) or a
282
+ JSON-RPC transport — should raise `NotImplementedError` so a mis-wired
283
+ send fails loudly instead of silently writing garbage.
284
+ """
285
+
286
+ def make_transport(self, process, opts: "LaunchOptions", session) -> Optional[object]:
287
+ """Build the JSON-RPC transport driver for a freshly spawned process.
288
+
289
+ Only called when ``capabilities.transport == "jsonrpc"``; returns the
290
+ object the session layer drives instead of the stdin/stdout line
291
+ protocol (``None`` falls back to lines). It is intentionally duck-typed
292
+ — kept off the seam to avoid an asyncio dependency here — and must
293
+ provide:
294
+
295
+ async initialize() -> None
296
+ Run the post-spawn handshake (e.g. JSON-RPC ``initialize``).
297
+ intake(native: dict) -> bool
298
+ Inspect one parsed stdout message *before* translation: resolve
299
+ pending responses, answer server-initiated requests. Return True
300
+ to let it continue to ``translate_events`` (it's a stream event),
301
+ False to swallow it (protocol bookkeeping).
302
+ async send_turn(message: dict) -> bool
303
+ Send one user turn (the canonical user message), running any lazy
304
+ thread start/resume first.
305
+ async interrupt() -> None
306
+ Abort the in-flight turn without killing the process.
307
+
308
+ ``process`` is the live `asyncio` subprocess, ``opts`` the resolved
309
+ `LaunchOptions` for this launch, and ``session`` the owning
310
+ `AgentSession` (for `cwd`, `session_id`, store id, metadata writes).
311
+ """
312
+ return None
313
+
314
+ # --- output parsing ---------------------------------------------------
315
+
316
+ def parse_line(self, raw: str) -> dict:
317
+ """Parse one raw stdout line into a canonical event dict.
318
+
319
+ Default is `json.loads` (identity for Claude's stream-json). Raises
320
+ `json.JSONDecodeError` on a non-event line, which the caller forwards as
321
+ `raw_output` — preserve that contract when overriding.
322
+ """
323
+ return json.loads(raw)
324
+
325
+ def translate_events(self, event: dict, state: dict) -> list[dict]:
326
+ """Translate one native event into zero or more canonical events.
327
+
328
+ The canonical shape is Claude's stream-json schema
329
+ (`system`/`assistant`/`user`/`result`), which the session handlers
330
+ consume. Claude is already canonical, so the default is identity. A
331
+ single native line may fan out (e.g. a Codex command result becomes an
332
+ assistant `tool_use` *and* a user `tool_result`), hence the list return.
333
+
334
+ `state` is a per-subprocess scratch dict owned and reset by the session
335
+ layer — providers stay stateless by stashing per-turn translation state
336
+ (item-id → tool-id maps, etc.) there rather than on `self`.
337
+ """
338
+ return [event]
339
+
340
+ @abstractmethod
341
+ def session_id_from_event(self, event: dict) -> Optional[str]:
342
+ """Extract the CLI's session id from a *native* (pre-translation)
343
+ event, or None when this event doesn't carry one.
344
+
345
+ Every engine names this differently (Claude: `session_id` on each
346
+ stream-json line; Codex: `thread_id` on `thread.started`), so there is
347
+ no neutral default — each provider reads its own wire.
348
+ """
349
+
350
+ def new_cost_state(self) -> CostState:
351
+ """Fresh per-subprocess cost tracker (reset on each (re)start)."""
352
+ return CostState()
353
+
354
+ def normalize_result(self, msg: dict, state: CostState) -> None:
355
+ """Normalize a `result` event in place (e.g. cumulative→delta).
356
+
357
+ Default is a no-op — the provider already reports per-turn figures.
358
+ Providers with `capabilities.cumulative_cost` rewrite `msg` using
359
+ `state` and update `state` for the next result.
360
+ """
361
+ return None
362
+
363
+ # --- error / lifecycle classification ---------------------------------
364
+
365
+ def classify_stderr(self, line: str) -> StderrClass:
366
+ """Classify a stderr line. Default: ordinary diagnostic."""
367
+ return StderrClass.NONE
368
+
369
+ def is_retryable_api_error(self, text: str) -> bool:
370
+ """Whether an error string is a transient API error worth retrying."""
371
+ return False
372
+
373
+ def is_auth_error(self, text: str, api_error_status: Optional[int] = None) -> bool:
374
+ """Whether a failed turn is an authentication failure the user can fix
375
+ by re-logging in (expired token, invalid key, 401). The session layer
376
+ surfaces a one-click re-login affordance when this is True.
377
+
378
+ `text` is the turn's result/error string; `api_error_status` is the
379
+ CLI's numeric HTTP status when present (Claude reports `api_error_status`
380
+ on the result event). Default: never an auth error.
381
+ """
382
+ return False
383
+
384
+ def is_usage_limit(self, text: str) -> bool:
385
+ """Whether `text` reports a usage/rate/session limit ("You've hit your
386
+ limit · resets 5pm", "hit your session limit", …).
387
+
388
+ The session layer checks this against MORE than the result string,
389
+ because engines don't deliver limits uniformly: the Claude CLI can
390
+ surface one as a synthetic assistant bubble in a turn whose result is
391
+ `is_error:false` (e.g. a /compact continuation), or in a compaction
392
+ settle frame's `compact_error`. When True the turn is marked
393
+ rate-limited: the turn bar shows it and the /context probe is skipped.
394
+
395
+ Implementations should require SHORT text (genuine limit reports are
396
+ one-liners) so long prose that merely quotes a limit phrase — an
397
+ assistant reply discussing limits, pasted logs — can never match.
398
+ Default: never a limit.
399
+ """
400
+ return False
401
+
402
+ @property
403
+ def normal_termination_codes(self) -> frozenset:
404
+ """Exit codes that mean "stopped normally", not "crashed". Defaults to
405
+ the signal-based set shared with the Claude CLI."""
406
+ from painapple_code.utils.agent_cli import NORMAL_TERMINATION_CODES
407
+ return NORMAL_TERMINATION_CODES
408
+
409
+ # --- rich-commit summary fork -----------------------------------------
410
+
411
+ def build_summary_fork(
412
+ self,
413
+ *,
414
+ session_id: str,
415
+ fork_prompt: str,
416
+ schema_json: str,
417
+ cwd: str,
418
+ token_profile: Optional[str] = None,
419
+ session_model: Optional[str] = None,
420
+ ) -> Optional["SummaryForkPlan"]:
421
+ """Plan the post-turn fork that generates rich-commit sections.
422
+
423
+ Returns a `SummaryForkPlan` (argv + temp files), or None when the
424
+ provider can't fork this turn (no session id, rollout not found, …) — in
425
+ which case the turn still gets a basic shadow commit, just without
426
+ AI-generated sections. Only called when
427
+ `capabilities.rich_commit_summaries` is set.
428
+ """
429
+ return None
430
+
431
+ def parse_summary_fork(
432
+ self,
433
+ *,
434
+ plan: "SummaryForkPlan",
435
+ returncode: int,
436
+ stdout: bytes,
437
+ stderr: bytes,
438
+ ) -> tuple[Optional[dict], Optional[dict]]:
439
+ """Parse the fork's output into (structured_data, cost).
440
+
441
+ `structured_data` is the section_id→value dict (or None on failure);
442
+ `cost` is `{"cost", "input_tokens", "output_tokens"}` (or None). The
443
+ shadow-git layer renders the dict to markdown and records the cost.
444
+ """
445
+ return None, None
446
+
447
+ async def drive_summary_fork(
448
+ self,
449
+ proc,
450
+ plan: "SummaryForkPlan",
451
+ ) -> tuple[Optional[dict], Optional[dict]]:
452
+ """Conduct a transport-driven summary over an already-spawned process.
453
+
454
+ The shadow-git layer always spawns ``plan.argv``; for a provider whose
455
+ ``capabilities.transport`` isn't the default ``"lines"`` it then hands the
456
+ live process here (stdin piped) instead of reading its stdout. The
457
+ provider runs whatever multi-step conversation its wire needs (e.g. a
458
+ JSON-RPC ``thread/fork`` + ``turn/start``) and returns the same
459
+ ``(structured_data, cost)`` pair as `parse_summary_fork`, leaving the
460
+ process terminated. The shadow-git layer keeps owning the registry entry,
461
+ the 300s timeout, and cleanup.
462
+
463
+ Only called for non-``"lines"`` providers; the default raises so a
464
+ mis-declared provider fails loudly rather than silently producing no
465
+ sections.
466
+ """
467
+ raise NotImplementedError(
468
+ f"{self.display_name} declares a non-line transport but has no "
469
+ "drive_summary_fork")
470
+
471
+ # --- introspection ----------------------------------------------------
472
+
473
+ async def fetch_context(
474
+ self,
475
+ cwd: str,
476
+ session_id: Optional[str] = None,
477
+ token_profile: Optional[str] = None,
478
+ model: Optional[str] = None,
479
+ ) -> Optional[dict]:
480
+ """Report context-window usage for a session, or None if unsupported.
481
+
482
+ Used by providers whose CLI has a dedicated context probe
483
+ (`capabilities.context_command`) — e.g. Claude forks a `/context` run.
484
+ Spawns a subprocess, so the session layer calls it in the background.
485
+ """
486
+ return None
487
+
488
+ def context_from_result(
489
+ self,
490
+ result_msg: dict,
491
+ model: Optional[str] = None,
492
+ ) -> Optional[dict]:
493
+ """Synthesize a context-window snapshot from a finished turn's result.
494
+
495
+ For engines with no `/context` command (`context_command=False`) that
496
+ nonetheless report per-turn token usage, the session layer calls this
497
+ right after a turn instead of `fetch_context` — deriving the meter from
498
+ the turn's own usage costs no extra subprocess. Returns the same shape
499
+ `fetch_context` does (``{contextTokens, contextWindow, percentage?,
500
+ breakdown?, memoryFiles?}``) or None when no usage is available.
501
+
502
+ Default None — providers that probe via `fetch_context` (Claude) leave
503
+ this unimplemented; the two paths are mutually exclusive per provider.
504
+ """
505
+ return None
506
+
507
+ # --- selection metadata (drives the engine picker UI) -----------------
508
+
509
+ def is_available(self) -> tuple[bool, Optional[str]]:
510
+ """Whether this provider's CLI is installed and usable.
511
+
512
+ Returns ``(available, reason_if_not)``. The default probes `binary()`
513
+ on PATH; providers add auth/login checks by overriding. The UI greys
514
+ out unavailable providers and shows `reason` as the fix hint.
515
+ """
516
+ import shutil
517
+ from pathlib import Path
518
+ try:
519
+ binary = self.binary()
520
+ except NotImplementedError as e:
521
+ return False, str(e)
522
+ except Exception as e: # pragma: no cover - defensive
523
+ return False, f"{self.display_name} unavailable: {e}"
524
+ found = Path(binary).exists() if Path(binary).is_absolute() else shutil.which(binary)
525
+ if not found:
526
+ return False, f"{binary} not found on PATH"
527
+ return True, None
528
+
529
+ def parse_auth_status(self, returncode: int, stdout: str, stderr: str) -> dict:
530
+ """Interpret the `auth_status_args` probe → ``{logged_in, detail}``.
531
+
532
+ Default: exit 0 = logged in, first non-empty output line as the
533
+ user-facing detail (scans both streams — CLIs disagree on where
534
+ status text goes). Providers with structured status output override.
535
+ """
536
+ line = next(
537
+ (ln.strip() for ln in f"{stdout or ''}\n{stderr or ''}".splitlines() if ln.strip()),
538
+ "")
539
+ return {"logged_in": returncode == 0, "detail": line[:160]}
540
+
541
+ def accounts(self) -> list[dict]:
542
+ """Selectable identities/accounts for this provider.
543
+
544
+ The session "account" leaf under a provider: each entry is
545
+ ``{"id": str, "label": str}`` where ``id`` is the value stored on the
546
+ session (empty string = the provider's default/ambient login). Claude
547
+ maps these to token profiles; Codex uses ambient `codex login` and so
548
+ returns none. Empty list = the UI shows no account sub-menu.
549
+ """
550
+ return []
551
+
552
+ def models(self) -> list[dict]:
553
+ """In-app selectable models for this provider: ``{id, label, desc}``.
554
+
555
+ Empty = the app doesn't manage this engine's model (e.g. Codex uses the
556
+ model configured by `codex login`), and the UI hides the model chip.
557
+
558
+ This is the FULL catalog (definitions). What pickers actually offer is
559
+ `enabled_models()` — the same list minus ids the user hid in Settings.
560
+ """
561
+ return []
562
+
563
+ def disabled_model_ids(self) -> set:
564
+ """Model ids the user hid for this engine (Settings → Engines toggles).
565
+
566
+ Read from the global config's ``models_disabled`` map under this
567
+ provider's `models_key` namespace. May contain ids not in the current
568
+ catalog (e.g. a CLI-owned catalog changed underneath) — kept as-is so
569
+ the preference survives catalog churn.
570
+ """
571
+ from painapple_code import paths
572
+ raw = (paths.load_global_config().get("models_disabled") or {})
573
+ ids = raw.get(self.models_key or self.name)
574
+ if not isinstance(ids, list):
575
+ return set()
576
+ return {x for x in ids if isinstance(x, str) and x}
577
+
578
+ def summary_model_editable(self) -> bool:
579
+ """Whether Settings shows an auto-journal model knob for this engine."""
580
+ return bool(self.summary_model_config_key)
581
+
582
+ def get_summary_model_override(self) -> Optional[str]:
583
+ """The configured journal-model override; None/empty = engine default
584
+ (for Codex that means the summary fork inherits the thread's model)."""
585
+ if not self.summary_model_config_key:
586
+ return None
587
+ from painapple_code import paths
588
+ value = paths.load_global_config().get(self.summary_model_config_key)
589
+ return value if isinstance(value, str) and value else None
590
+
591
+ def set_summary_model_override(self, value: Optional[str]) -> None:
592
+ """Persist (or clear with None/empty) the journal-model override."""
593
+ if not self.summary_model_config_key:
594
+ raise ValueError(f"{self.display_name} has no journal-model setting")
595
+ from painapple_code import paths
596
+ config = paths.load_global_config()
597
+ cleaned = (value or "").strip()
598
+ if cleaned:
599
+ config[self.summary_model_config_key] = cleaned
600
+ else:
601
+ config.pop(self.summary_model_config_key, None)
602
+ paths.save_global_config(config)
603
+
604
+ def enabled_models(self) -> list[dict]:
605
+ """`models()` minus the user's hidden set — the catalog pickers offer.
606
+
607
+ Everything that treats the catalog as an *offering* consumes this
608
+ (describe() → /api/providers → chip/popup/setup panel/default-model
609
+ select, and `preferred_model_survives`). Launch-side foreign-id guards
610
+ keep using the raw `models()` so a session bound to a since-hidden
611
+ model keeps working.
612
+ """
613
+ disabled = self.disabled_model_ids()
614
+ if not disabled:
615
+ return self.models()
616
+ return [m for m in self.models() if m.get("id") not in disabled]
617
+
618
+ def effort_levels(self) -> list[str]:
619
+ """Effort levels this engine meaningfully distinguishes, low→high.
620
+
621
+ Empty when `capabilities.effort` is False. Claude exposes the full
622
+ low→max scale; Codex caps at high, so it lists only what changes the
623
+ outcome (the UI hides the rest).
624
+ """
625
+ return []
626
+
627
+ @abstractmethod
628
+ def permission_modes(self) -> list[dict]:
629
+ """Permission options for this engine: ``{value, label, desc, color}``.
630
+
631
+ ``value`` is this provider's OWN native vocabulary, stored verbatim on
632
+ the session and passed through at launch (e.g. Codex →
633
+ read-only/workspace-write/danger-full-access). ``color`` lets the UI
634
+ render the tier without knowing the provider's scheme. Every provider
635
+ must self-describe its modes — there is no shared fallback vocabulary
636
+ (the permission endpoints and the engine picker read this list, and
637
+ `valid_permission_values()` unions it across providers).
638
+ """
639
+
640
+ def default_permission_mode(self) -> Optional[str]:
641
+ """This provider's own default permission value (one of
642
+ ``permission_modes()``), used for a fresh session instead of inheriting
643
+ the app's global (Claude) default. ``None`` → use the global default.
644
+ """
645
+ return None
646
+
647
+ # --- customization surfaces (skills / agents browsers) ----------------
648
+
649
+ def skill_roots(self, cwd: str) -> list[dict]:
650
+ """Editable skill-search roots for this engine, highest-priority first.
651
+
652
+ Each entry is ``{"scope": str, "dir": Path}`` for a directory holding
653
+ folder-form skills (``<name>/SKILL.md`` — the open Agent Skills format
654
+ every engine shares). The generic skills route walks them in order; the
655
+ first entry for a given scope is also where a new skill of that scope is
656
+ created. An engine may list several dirs under one scope (e.g. a
657
+ preferred location plus a legacy one). Empty → no skills for this engine.
658
+ """
659
+ return []
660
+
661
+ def plugin_skill_dirs(self) -> list[dict]:
662
+ """Read-only plugin-provided skill roots: ``[{"label": str, "dir": Path}]``.
663
+
664
+ Each ``dir`` holds folder-form skills like `skill_roots`, sourced from
665
+ this engine's installed plugins/marketplaces (so the provider owns the
666
+ marketplace layout). Empty → no plugin skills surfaced.
667
+ """
668
+ return []
669
+
670
+ def agent_roots(self, cwd: str) -> list[dict]:
671
+ """Agent-definition search roots for the agents browser, highest-priority
672
+ first. Each entry is ``{"scope": str, "dir": Path, "fmt": str,
673
+ "writable": bool}``.
674
+
675
+ ``fmt`` selects the parser: ``"markdown"`` (Claude's flat ``<name>.md``
676
+ with YAML frontmatter) or ``"toml"`` (Codex's ``<name>.toml``). The route
677
+ normalizes both into the same ``{frontmatter, body}`` the widget renders.
678
+ ``writable=False`` makes the browser show those agents read-only (no
679
+ create/edit/delete). Empty → no agents for this engine.
680
+ """
681
+ return []
682
+
683
+ def plugin_backend(self) -> Optional["PluginBackend"]:
684
+ """How to drive this engine's plugin-manager CLI, or None if it has no
685
+ plugin system. The plugins route uses this instead of assuming Claude."""
686
+ return None
687
+
688
+ def describe(self) -> dict:
689
+ """Serialize provider metadata (capabilities, availability, accounts)."""
690
+ from dataclasses import asdict
691
+ available, reason = self.is_available()
692
+ return {
693
+ "name": self.name,
694
+ "display_name": self.display_name,
695
+ "description": self.description,
696
+ "default_enabled": self.default_enabled,
697
+ "capabilities": asdict(self.capabilities),
698
+ "available": available,
699
+ "unavailable_reason": reason,
700
+ "accounts": self.accounts(),
701
+ "models": self.enabled_models(),
702
+ "models_editable": self.models_editable,
703
+ "models_key": self.models_key or self.name,
704
+ "path_configurable": bool(self.path_config_key),
705
+ "default_binary": self.default_binary,
706
+ "efforts": self.effort_levels(),
707
+ "permission_modes": self.permission_modes(),
708
+ "default_permission_mode": self.default_permission_mode(),
709
+ "cli_resume_template": self.cli_resume_template,
710
+ }