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,139 @@
1
+ """
2
+ Claude Code via the Claude Agent SDK — SDK-backed provider.
3
+
4
+ Same engine as `ClaudeProvider`, but the subprocess the server spawns is a
5
+ thin Python *driver* (`providers/claude_sdk/driver.py`) that runs
6
+ `ClaudeSDKClient` in-process instead of exec'ing `claude -p` directly:
7
+
8
+ server ──stream-json lines──> driver ──SDK control protocol──> claude CLI
9
+
10
+ The driver tees the CLI's raw stream-json verbatim onto its own stdout and
11
+ forwards the server's canonical user messages from stdin into the SDK, so
12
+ from the session layer's point of view this provider is wire-identical to
13
+ `ClaudeProvider`: same "lines" transport, same events, same stderr strings,
14
+ same signal semantics (SIGINT/SIGTERM → clean exit). Everything Claude-shaped
15
+ (cost normalization, stderr classification, summary fork, /context probe,
16
+ models/accounts/permission vocab) is inherited unchanged.
17
+
18
+ What the extra hop buys is the SDK's control plane — a first-class seam for
19
+ interactive permissions (`can_use_tool`), native interrupt, and mid-session
20
+ mode/model changes. See docs-ai/plans/2026-07-07-agent-sdk-migration.md.
21
+
22
+ Selection: this is the default provider (DEFAULT_PROVIDER) — new sessions adopt
23
+ it, and sessions that record no provider fall back to it. Sessions persist
24
+ `provider: "claude-sdk"` in meta. A tier can pin the line-protocol `claude`
25
+ provider instead via the `default_provider` config key or the
26
+ `--default-provider` server flag.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+ from dataclasses import replace
33
+
34
+ from painapple_code.providers.base import LaunchOptions
35
+ from painapple_code.providers.claude import ClaudeProvider
36
+
37
+
38
+ class ClaudeSdkProvider(ClaudeProvider):
39
+ """Claude Code driven through `claude-agent-sdk` in a driver subprocess."""
40
+
41
+ name = "claude-sdk"
42
+ display_name = "Claude Code (SDK)"
43
+ description = "Anthropic Claude Code via the Agent SDK — live controls, ask-mode cards"
44
+ # The preferred Claude driver — in the picker out of the box (ClaudeProvider
45
+ # sets False to keep the plainer CLI variant hidden; undo that here).
46
+ default_enabled = True
47
+ # Same engine, same feature surface as Claude — plus the SDK's
48
+ # can_use_tool permission round-trip (driver ⇄ server ⇄ client) and its
49
+ # control plane (live permission-mode/model switches, warm-process
50
+ # interrupt) via control_request/control_done frames on the same pipe.
51
+ capabilities = replace(ClaudeProvider.capabilities,
52
+ interactive_permissions=True,
53
+ live_controls=True)
54
+
55
+ def permission_modes(self) -> list[dict]:
56
+ # Claude's modes plus "Ask" (CLI mode `default`) — the manual-accept
57
+ # mode: reads are auto-allowed, everything else surfaces a permission
58
+ # card. Only offered here: on the line-protocol provider `default`
59
+ # just auto-denies (nothing can answer the prompt), so exposing it
60
+ # there would be a trap.
61
+ modes = ClaudeProvider.permission_modes(self)
62
+ ask = {"value": "default", "label": "Ask",
63
+ "desc": "Approve each edit/command via permission card",
64
+ "color": "#2dd4bf"}
65
+ modes.insert(1, ask) # right after Plan
66
+ # The base `claude` provider's Accept Edits desc says "deny others" —
67
+ # accurate for line-protocol `claude -p`, where there's no can_use_tool
68
+ # gate so out-of-workspace ops hard-deny (surface as is_error results).
69
+ # Here the SDK's can_use_tool gate is wired for acceptEdits too
70
+ # (driver.py), so those ops surface an approve/deny card instead of
71
+ # denying. Patch the one field the interactive-permissions seam made
72
+ # stale, leaving the rest of the inherited vocab untouched.
73
+ for m in modes:
74
+ if m["value"] == "acceptEdits":
75
+ m["desc"] = "Auto-approve workspace edits; others prompt for approval"
76
+ return modes
77
+
78
+ def default_permission_mode(self) -> str:
79
+ # Ask, not the base "dontAsk". The SDK engine can actually answer a
80
+ # prompt (can_use_tool → permission card), so the sane out-of-the-box
81
+ # default is manual-accept — reads flow, edits/commands surface a card —
82
+ # rather than auto-denying. (On the line-protocol provider "default"
83
+ # would be a trap: nothing can answer, so every ask becomes a denial;
84
+ # that's why the base stays "dontAsk".)
85
+ return "default"
86
+
87
+ def build_command(self, opts: LaunchOptions) -> list[str]:
88
+ # The server's venv python, not "python" off PATH — the driver imports
89
+ # both painapple_code and claude_agent_sdk from this environment.
90
+ cmd = [
91
+ sys.executable, "-u", "-m",
92
+ "painapple_code.providers.claude_sdk.driver",
93
+ ]
94
+ # win32 + bare default: omit --cli-path so the SDK's _find_cli()
95
+ # discovers the CLI — it prefers a native claude.exe (bundled or
96
+ # installed) and refuses npm's claude.cmd shim with a curated
97
+ # remediation (CVE-2024-27980 class); which-resolving "claude" here
98
+ # would hand the SDK exactly the shim it's designed to reject.
99
+ # POSIX keeps passing the resolved system binary unconditionally —
100
+ # omitting it would let the SDK prefer its _bundled/ CLI over the
101
+ # user's installed claude, a silent version switch.
102
+ if sys.platform != "win32" or self.binary() != self.default_binary:
103
+ cmd.extend(["--cli-path", self.binary()])
104
+
105
+ if opts.model:
106
+ cmd.extend(["--model", opts.model])
107
+
108
+ if opts.fallback_model:
109
+ cmd.extend(["--fallback-model", opts.fallback_model])
110
+
111
+ # "high" is the CLI default; skip to avoid noise (mirrors launch.py).
112
+ if opts.effort and opts.effort != "high":
113
+ cmd.extend(["--effort", opts.effort])
114
+
115
+ cmd.extend(["--permission-mode", opts.permission_mode or self.default_permission_mode()])
116
+
117
+ if opts.fork_from_session_id:
118
+ cmd.extend(["--resume", opts.fork_from_session_id, "--fork-session"])
119
+ elif opts.session_id:
120
+ cmd.extend(["--resume", opts.session_id])
121
+
122
+ return cmd
123
+
124
+ def binary_not_found_hint(self) -> str:
125
+ return ("The SDK driver spawns the Claude CLI; install it and make "
126
+ "sure it's on the server's PATH (or set claude_path in config).")
127
+
128
+ def is_available(self) -> tuple[bool, str | None]:
129
+ try:
130
+ import claude_agent_sdk # noqa: F401
131
+ except ImportError:
132
+ return False, ("claude-agent-sdk not installed in the server venv "
133
+ "(pip install claude-agent-sdk)")
134
+ # SDK present — availability then hinges on the claude CLI, same as
135
+ # the line-protocol provider.
136
+ return super().is_available()
137
+
138
+
139
+ PROVIDERS = [ClaudeSdkProvider()]
@@ -0,0 +1,412 @@
1
+ """
2
+ Agent-SDK driver subprocess for the `claude-sdk` provider.
3
+
4
+ The server spawns this module (`python -u -m painapple_code.providers.claude_sdk.driver`)
5
+ exactly where it would spawn `claude -p`, and speaks the same wire protocol to
6
+ it: canonical stream-json user messages in on stdin, canonical stream-json
7
+ events out on stdout, CLI diagnostics on stderr. Internally it drives the
8
+ Claude Code CLI through `ClaudeSDKClient` and *tees the raw stream-json dicts
9
+ off the SDK transport verbatim*, so the bytes the server reads are the CLI's
10
+ own output, not a lossy reconstruction from the SDK's typed objects — event
11
+ parity with the line-protocol provider holds by construction.
12
+
13
+ Signal contract (matches what the session layer expects of the CLI):
14
+ SIGINT → abort the turn, shut the SDK client down, exit 130
15
+ SIGTERM → shut down, exit 143
16
+ stdin EOF (server died/closed) → shut down, exit 0
17
+ Closing the client terminates the CLI child, so no orphan is left behind.
18
+
19
+ stdout is protocol-only — never print anything else to it. Driver-side
20
+ diagnostics go to stderr, where the server's stderr reader forwards/classifies
21
+ them like any CLI stderr line.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import dataclasses
29
+ import itertools
30
+ import json
31
+ import os
32
+ import shutil
33
+ import signal
34
+ import sys
35
+ import threading
36
+ from collections.abc import AsyncIterator
37
+ from typing import Any
38
+
39
+ # Matches the server reader's MAX_LINE_SIZE ceiling (utils/agent_cli.py); the
40
+ # stdin limit is higher because pasted/uploaded base64 images ride in the user
41
+ # message envelope.
42
+ MAX_EVENT_SIZE = 10 * 1024 * 1024
43
+ MAX_STDIN_LINE = 64 * 1024 * 1024
44
+
45
+ # Stream event types forwarded to the server. Everything else on the transport
46
+ # (control_request/control_response handshakes, keep-alives) is SDK plumbing
47
+ # the server must not see.
48
+ _STREAM_TYPES = frozenset({"system", "assistant", "user", "result"})
49
+
50
+ # Tools whose "ask" the server already handles with its own chat-UI flow
51
+ # (question wizard / plan-approval card + SIGINT auto-stop). Deny them here
52
+ # with a steering message instead of surfacing a permission card, so both
53
+ # providers keep the identical UX for these two.
54
+ _UI_FLOW_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"})
55
+ _UI_FLOW_DENY = ("The user will respond through the chat UI. Stop and wait "
56
+ "for their reply instead of retrying this tool.")
57
+
58
+
59
+ def _jsonable(obj: Any) -> Any:
60
+ """Best-effort conversion of SDK dataclasses/enums for the wire."""
61
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
62
+ return {k: _jsonable(v) for k, v in dataclasses.asdict(obj).items() if v is not None}
63
+ if isinstance(obj, dict):
64
+ return {k: _jsonable(v) for k, v in obj.items()}
65
+ if isinstance(obj, (list, tuple)):
66
+ return [_jsonable(v) for v in obj]
67
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
68
+ return obj
69
+ return str(obj)
70
+
71
+
72
+ def _emit(msg: dict) -> None:
73
+ """Write one canonical event line to the server."""
74
+ sys.stdout.write(json.dumps(msg, ensure_ascii=False, separators=(",", ":")) + "\n")
75
+ sys.stdout.flush()
76
+
77
+
78
+ def _note(text: str) -> None:
79
+ """Driver-side diagnostic → server stderr reader."""
80
+ print(f"claude-sdk driver: {text}", file=sys.stderr, flush=True)
81
+
82
+
83
+ def _make_tee_transport(options):
84
+ """Subclass the SDK's subprocess transport to tee raw dicts to stdout.
85
+
86
+ Built lazily inside a function so importing this module never hard-requires
87
+ the SDK internals; version drift in `SubprocessCLITransport` surfaces here
88
+ as a loud startup error, not a wedged session.
89
+ """
90
+ from claude_agent_sdk._internal.transport.subprocess_cli import (
91
+ SubprocessCLITransport,
92
+ )
93
+
94
+ class _TeeTransport(SubprocessCLITransport):
95
+ def read_messages(self) -> AsyncIterator[dict[str, Any]]:
96
+ return self._tee(super().read_messages())
97
+
98
+ @staticmethod
99
+ async def _tee(inner: AsyncIterator[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
100
+ async for msg in inner:
101
+ if msg.get("type") in _STREAM_TYPES:
102
+ _emit(msg)
103
+ yield msg
104
+
105
+ async def _pending() -> AsyncIterator[dict[str, Any]]:
106
+ # Never yields; marks streaming-input mode so the CLI's stdin stays
107
+ # open for the queries we write per server message.
108
+ return
109
+ yield {} # pragma: no cover
110
+
111
+ return _TeeTransport(prompt=_pending(), options=options)
112
+
113
+
114
+ async def _amain(args: argparse.Namespace) -> int:
115
+ from claude_agent_sdk import (
116
+ ClaudeAgentOptions,
117
+ ClaudeSDKClient,
118
+ PermissionResultAllow,
119
+ PermissionResultDeny,
120
+ )
121
+
122
+ loop = asyncio.get_running_loop()
123
+
124
+ # Interactive permissions: `can_use_tool` fires whenever the CLI's
125
+ # permission flow resolves to a prompt. We round-trip the decision to the
126
+ # server as a `permission_request` stdout line and await the matching
127
+ # `permission_response` stdin line (routed by the stdin loop below).
128
+ pending_permissions: dict[str, asyncio.Future] = {}
129
+ request_ids = (f"perm-{n}" for n in itertools.count(1))
130
+
131
+ async def on_permission(tool_name: str, tool_input: dict, context) -> Any:
132
+ if tool_name in _UI_FLOW_TOOLS:
133
+ return PermissionResultDeny(message=_UI_FLOW_DENY)
134
+ req_id = next(request_ids)
135
+ fut: asyncio.Future = loop.create_future()
136
+ pending_permissions[req_id] = fut
137
+ suggestions = list(getattr(context, "suggestions", []) or [])
138
+ _emit({
139
+ "type": "permission_request",
140
+ "request_id": req_id,
141
+ "tool_name": tool_name,
142
+ "input": _jsonable(tool_input),
143
+ "tool_use_id": getattr(context, "tool_use_id", None),
144
+ "title": getattr(context, "title", None),
145
+ "description": getattr(context, "description", None),
146
+ "display_name": getattr(context, "display_name", None),
147
+ "decision_reason": _jsonable(getattr(context, "decision_reason", None)),
148
+ "blocked_path": getattr(context, "blocked_path", None),
149
+ "suggestions": _jsonable(suggestions),
150
+ })
151
+ try:
152
+ resp = await fut
153
+ finally:
154
+ pending_permissions.pop(req_id, None)
155
+ if resp.get("behavior") == "allow":
156
+ # "Always allow": the client picked one of the CLI's own permission
157
+ # suggestions (addRules / addDirectories / setMode). Hand the
158
+ # original PermissionUpdate object back and the CLI applies it —
159
+ # rule persistence is entirely engine-side, nothing reimplemented.
160
+ updated_permissions = None
161
+ idx = resp.get("suggestion_index")
162
+ if isinstance(idx, int) and 0 <= idx < len(suggestions):
163
+ updated_permissions = [suggestions[idx]]
164
+ return PermissionResultAllow(
165
+ updated_input=resp.get("updated_input"),
166
+ updated_permissions=updated_permissions,
167
+ )
168
+ # Attribute the guidance to the user explicitly — a bare denial string
169
+ # in an error-shaped tool result reads like injected tool output and
170
+ # the model may distrust it.
171
+ guidance = resp.get("message")
172
+ return PermissionResultDeny(
173
+ message=(f"The user denied this tool call and said: {guidance}"
174
+ if guidance else "The user denied this tool call."),
175
+ interrupt=bool(resp.get("interrupt")),
176
+ )
177
+
178
+ # bypassPermissions (YOLO) auto-approves every tool call before can_use_tool
179
+ # is ever consulted, so wiring the callback does nothing except trip the
180
+ # SDK's CanUseToolShadowedWarning — which the server's stderr reader surfaces
181
+ # as a scary (but harmless) error line. Only attach the permission gate for
182
+ # modes that can actually prompt.
183
+ gate = None if args.permission_mode == "bypassPermissions" else on_permission
184
+
185
+ options = ClaudeAgentOptions(
186
+ can_use_tool=gate,
187
+ model=args.model,
188
+ fallback_model=args.fallback_model,
189
+ effort=args.effort,
190
+ permission_mode=args.permission_mode,
191
+ resume=args.resume,
192
+ fork_session=args.fork_session,
193
+ # The server already sets cwd on the driver process; pin the CLI to it.
194
+ cwd=os.getcwd(),
195
+ # None → the SDK's _find_cli() discovers (and on win32 vets) the CLI;
196
+ # an explicit config value is which-resolved as before.
197
+ cli_path=(shutil.which(args.cli_path) or args.cli_path) if args.cli_path else None,
198
+ # Restore thinking summaries on all models (see providers/claude/launch.py).
199
+ extra_args={"thinking-display": "summarized"},
200
+ # Mirror CLI stderr onto ours so the server's classifier sees the exact
201
+ # strings it matches today (stale session, API 5xx, compaction, …).
202
+ stderr=lambda line: print(line, file=sys.stderr, flush=True),
203
+ max_buffer_size=MAX_EVENT_SIZE,
204
+ )
205
+
206
+ stop = asyncio.Event()
207
+ exit_code = 0
208
+
209
+ def _shutdown(code: int) -> None:
210
+ nonlocal exit_code
211
+ exit_code = code
212
+ stop.set()
213
+
214
+ if sys.platform == "win32":
215
+ # Proactor has no add_signal_handler (NotImplementedError — a Windows 11
216
+ # probe). signal.signal works: CPython's wakeup channel rouses the
217
+ # loop, and call_soon_threadsafe hops back onto it. The server's
218
+ # interrupt_process() sends CTRL_BREAK → SIGBREAK here (SIGTERM is
219
+ # undeliverable on win32; parent terminate() covers that path).
220
+ def _sig_handler(signum, frame):
221
+ loop.call_soon_threadsafe(
222
+ _shutdown, 130 if signum == signal.SIGINT else 143
223
+ )
224
+ signal.signal(signal.SIGINT, _sig_handler)
225
+ signal.signal(signal.SIGBREAK, _sig_handler)
226
+ else:
227
+ loop.add_signal_handler(signal.SIGINT, _shutdown, 130)
228
+ loop.add_signal_handler(signal.SIGTERM, _shutdown, 143)
229
+
230
+ # With a custom transport, ClaudeSDKClient skips its own
231
+ # `permission_prompt_tool_name="stdio"` injection (it only applies it to
232
+ # the transport it would have built itself), so set it on the *transport's*
233
+ # options copy here. It must NOT go on the client's options — the client
234
+ # rejects it alongside `can_use_tool`. Pair it with the gate: in bypass mode
235
+ # there's nothing to route to stdio, so keep that path a clean pass-through.
236
+ transport_opts = dataclasses.replace(options, can_use_tool=None)
237
+ if gate is not None:
238
+ transport_opts = dataclasses.replace(transport_opts, permission_prompt_tool_name="stdio")
239
+ transport = _make_tee_transport(transport_opts)
240
+ client = ClaudeSDKClient(options=options, transport=transport)
241
+ try:
242
+ await client.connect()
243
+ except Exception as e:
244
+ _note(f"failed to start Claude CLI via SDK: {e}")
245
+ return 1
246
+
247
+ async def stdin_loop() -> None:
248
+ """Forward each canonical user-message line from the server into the SDK."""
249
+ if sys.platform == "win32":
250
+ # connect_read_pipe on an inherited stdin HALF-works under
251
+ # Proactor: it connects, then the first read dies with
252
+ # OSError WinError 6 and leaves stdin unusable (a Windows 11 probe
253
+ # 3). Don't attempt it — a daemon thread doing blocking
254
+ # readline feeds the loop through an asyncio queue instead.
255
+ queue: asyncio.Queue = asyncio.Queue()
256
+
257
+ def _pump() -> None:
258
+ try:
259
+ while True:
260
+ raw = sys.stdin.buffer.readline()
261
+ loop.call_soon_threadsafe(queue.put_nowait, raw)
262
+ if not raw: # EOF — server closed our stdin
263
+ return
264
+ except Exception:
265
+ loop.call_soon_threadsafe(queue.put_nowait, b"")
266
+
267
+ threading.Thread(target=_pump, daemon=True, name="stdin-pump").start()
268
+
269
+ async def _readline() -> bytes:
270
+ return await queue.get()
271
+ else:
272
+ reader = asyncio.StreamReader(limit=MAX_STDIN_LINE)
273
+ await loop.connect_read_pipe(
274
+ lambda: asyncio.StreamReaderProtocol(reader), sys.stdin
275
+ )
276
+
277
+ async def _readline() -> bytes:
278
+ return await reader.readline()
279
+
280
+ while True:
281
+ line = await _readline()
282
+ if not line:
283
+ # Server closed stdin. Mirror `claude -p` one-shot semantics:
284
+ # close the CLI's stdin and let it finish in-flight work; the
285
+ # drain loop shuts us down when the CLI exits.
286
+ try:
287
+ await transport.end_input()
288
+ except Exception:
289
+ _shutdown(0)
290
+ return
291
+ line = line.strip()
292
+ if not line:
293
+ continue
294
+ try:
295
+ msg = json.loads(line)
296
+ except json.JSONDecodeError:
297
+ _note("dropping non-JSON stdin line")
298
+ continue
299
+
300
+ # Server frames ride the same pipe: permission decisions resolve
301
+ # the future the matching can_use_tool callback is awaiting.
302
+ if msg.get("type") == "permission_response":
303
+ fut = pending_permissions.get(msg.get("request_id"))
304
+ if fut and not fut.done():
305
+ fut.set_result(msg)
306
+ else:
307
+ _note(f"permission_response for unknown request "
308
+ f"{msg.get('request_id')!r}")
309
+ continue
310
+
311
+ # Control plane (Capabilities.live_controls): live mode/model
312
+ # switches and warm-process interrupt via the SDK client. Every
313
+ # request is acked with `control_done` — the server treats a
314
+ # nack/timeout as "fall back to kill+respawn", so failing loudly
315
+ # here is always safe. Ordering with user messages is enforced
316
+ # server-side (it awaits the ack before writing the message).
317
+ if msg.get("type") == "control_request":
318
+ action = msg.get("action")
319
+ ok, err = True, None
320
+ try:
321
+ if action == "interrupt":
322
+ # Unblock pending can_use_tool callbacks first so they
323
+ # unwind as clean user denials instead of being
324
+ # cancelled mid-await by the abort.
325
+ for fut in list(pending_permissions.values()):
326
+ if not fut.done():
327
+ fut.set_result({"behavior": "deny", "interrupt": True})
328
+ await client.interrupt()
329
+ elif action == "set_permission_mode":
330
+ await client.set_permission_mode(msg.get("mode"))
331
+ elif action == "set_model":
332
+ # None = revert to the CLI/account default model.
333
+ await client.set_model(msg.get("model"))
334
+ else:
335
+ ok, err = False, f"unknown control action {action!r}"
336
+ except Exception as e:
337
+ ok, err = False, str(e)
338
+ _note(f"control {action} failed: {e}")
339
+ _emit({
340
+ "type": "control_done",
341
+ "control_id": msg.get("control_id"),
342
+ "action": action,
343
+ "ok": ok,
344
+ "error": err,
345
+ })
346
+ continue
347
+
348
+ async def _one(m=msg) -> AsyncIterator[dict[str, Any]]:
349
+ yield m
350
+
351
+ await client.query(_one())
352
+
353
+ async def drain_loop() -> None:
354
+ """Keep the SDK's parsed-message queue drained (the tee already emitted
355
+ the raw dicts); surface transport/process failures as an exit."""
356
+ try:
357
+ async for _ in client.receive_messages():
358
+ pass
359
+ _shutdown(0)
360
+ except Exception as e:
361
+ _note(str(e))
362
+ code = getattr(e, "exit_code", None)
363
+ _shutdown(code if isinstance(code, int) and code > 0 else 1)
364
+
365
+ tasks = [asyncio.create_task(stdin_loop()), asyncio.create_task(drain_loop())]
366
+ try:
367
+ await stop.wait()
368
+ finally:
369
+ for t in tasks:
370
+ t.cancel()
371
+ await asyncio.gather(*tasks, return_exceptions=True)
372
+ try:
373
+ await client.disconnect() # terminates the CLI child
374
+ except Exception as e:
375
+ _note(f"error during shutdown: {e}")
376
+ return exit_code
377
+
378
+
379
+ def main() -> None:
380
+ if sys.platform == "win32":
381
+ # stdout is the wire to the server and emit() uses ensure_ascii=False —
382
+ # a cp1252 pipe would corrupt the first non-ASCII assistant token.
383
+ from painapple_code.utils.proc import force_utf8_stdio
384
+ force_utf8_stdio()
385
+ parser = argparse.ArgumentParser(description="painapple-code Agent SDK driver")
386
+ parser.add_argument("--cli-path", default=None) # None → SDK _find_cli()
387
+ parser.add_argument("--model", default=None)
388
+ parser.add_argument("--fallback-model", default=None)
389
+ parser.add_argument("--effort", default=None)
390
+ parser.add_argument("--permission-mode", default=None)
391
+ parser.add_argument("--resume", default=None)
392
+ parser.add_argument("--fork-session", action="store_true")
393
+ args = parser.parse_args()
394
+ code = asyncio.run(_amain(args))
395
+ if sys.platform == "win32":
396
+ # The win32 stdin pump is a daemon thread parked in a blocking
397
+ # readline(), so it owns the <stdin> BufferedReader lock at exit.
398
+ # Normal interpreter finalization waits for that lock and instead
399
+ # aborts with "Fatal Python error: _enter_buffered_busy" on stderr —
400
+ # which the server forwards verbatim, painting three red error
401
+ # bubbles into the chat on every AskUserQuestion auto-stop.
402
+ # _amain's finally already disconnected the CLI child and cancelled
403
+ # the loop tasks, so finalization has nothing left to do; skip it
404
+ # rather than lose a race it cannot win. os._exit does not flush.
405
+ sys.stdout.flush()
406
+ sys.stderr.flush()
407
+ os._exit(code)
408
+ sys.exit(code)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()
@@ -0,0 +1,87 @@
1
+ """
2
+ OpenAI Codex CLI provider (`codex exec --json`).
3
+
4
+ Unlike Claude (one persistent `claude -p` process streaming stream-json over
5
+ stdin/stdout across many turns), Codex runs **one `codex exec` process per
6
+ turn**: the prompt is a positional argv, the run emits newline-delimited JSON
7
+ events to stdout, and the process exits when the turn ends. Continuation is a
8
+ fresh `codex exec resume <thread_id> "<prompt>"`. So this provider declares
9
+ `persistent_process=False` and the session layer drives a per-turn spawn loop.
10
+
11
+ The adapter is assembled from focused mixins (this file is the combiner — see
12
+ `session_store.py` / `shadow_git.py` for the same pattern in this codebase):
13
+
14
+ capabilities.py → _CapabilitiesMixin binary, availability, effort/perm
15
+ vocabularies, skill/agent/plugin
16
+ roots, context metering
17
+ launch.py → _LaunchMixin build_command + image materialization
18
+ translate.py → _TranslateMixin Codex events → canonical Claude shape
19
+ summary.py → _SummaryMixin rich-commit summary fork (rollout copy)
20
+ errors.py → _ErrorsMixin stderr classification / retry policy
21
+
22
+ Codex reports token counts but no per-turn USD cost, so result cost is left at 0
23
+ (see the step-two plan: tokens-only, cost null). Token usage is per-turn already,
24
+ so no cumulative→delta conversion is needed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from painapple_code.providers.base import Provider, Capabilities
30
+ from painapple_code.providers.codex.capabilities import _CapabilitiesMixin
31
+ from painapple_code.providers.codex.launch import _LaunchMixin
32
+ from painapple_code.providers.codex.translate import _TranslateMixin
33
+ from painapple_code.providers.codex.summary import _SummaryMixin
34
+ from painapple_code.providers.codex.errors import _ErrorsMixin
35
+
36
+
37
+ class CodexProvider(
38
+ _CapabilitiesMixin,
39
+ _LaunchMixin,
40
+ _TranslateMixin,
41
+ _SummaryMixin,
42
+ _ErrorsMixin,
43
+ Provider,
44
+ ):
45
+ """Adapter for the OpenAI Codex CLI (`codex exec --json`).
46
+
47
+ The behaviour lives in the mixins above; this class only carries the static
48
+ identity + capability flags and the MRO that composes them (mixins first, so
49
+ their overrides win over the `Provider` defaults, with `super()` calls — e.g.
50
+ `is_available` — still reaching the base implementation).
51
+ """
52
+
53
+ name = "codex"
54
+ display_name = "Codex CLI"
55
+ description = "OpenAI Codex CLI — one exec process per turn"
56
+ # Same engine as codex-app-server but the plainer per-turn exec driver
57
+ # (no native fork, no live interrupt) — kept out of the picker by default
58
+ # so Codex appears once. Flip it on in Settings → Engines.
59
+ default_enabled = False
60
+ capabilities = Capabilities(
61
+ resume=True,
62
+ fork=False, # no --fork-session equivalent
63
+ permission_modes=True,
64
+ effort=True,
65
+ thinking_display=True, # native `reasoning` items
66
+ context_command=False, # no /context equivalent in exec mode
67
+ cumulative_cost=False, # per-turn usage, and no USD figure at all
68
+ # The post-turn fork summarizes the turn with Codex itself (see
69
+ # summary.py / build_summary_fork). Codex has no `--fork-session`, and
70
+ # `codex exec resume` writes in place, so the fork copies the thread's
71
+ # rollout under a fresh id and resumes the copy — branching like Claude's
72
+ # --fork-session without polluting the user's real thread.
73
+ rich_commit_summaries=True,
74
+ persistent_process=False, # one `codex exec` per turn
75
+ forward_plain_stderr=False, # codex prints human progress to stderr
76
+ # `codex exec` has no stdin protocol — frame_input() raises to say so —
77
+ # so build_command appends the prompt as a positional arg. Nothing here
78
+ # can route around it; the sibling codex-app-server driver speaks stdio
79
+ # and is the engine to prefer on a shared host.
80
+ prompt_in_argv=True,
81
+ )
82
+
83
+
84
+ # Registry contribution — see providers/claude.py for the convention. The
85
+ # package scanner in providers/__init__.py discovers this exactly like a flat
86
+ # module (pkgutil.iter_modules lists subpackages too).
87
+ PROVIDERS = [CodexProvider()]