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,147 @@
1
+ """
2
+ Background Tasks API - Read output from Claude Code background tasks.
3
+
4
+ Provides endpoints for listing background tasks and streaming their output
5
+ via offset-based polling (like tail -f).
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from fastapi import APIRouter, HTTPException
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter(tags=["tasks"])
19
+
20
+ # Match alphanumeric task IDs
21
+ TASK_ID_RE = re.compile(r'^[a-z0-9]+$')
22
+
23
+
24
+ def _cwd_to_slug(cwd: str) -> str:
25
+ """Convert CWD path to Claude's tmp directory slug.
26
+
27
+ /home/user/dev/foo → -home-user-dev-foo
28
+ """
29
+ return cwd.replace('/', '-')
30
+
31
+
32
+ def _get_tasks_dir() -> Path | None:
33
+ """Get the tasks directory for the current project.
34
+
35
+ Returns None on native Windows: os.getuid doesn't exist there and the
36
+ whole /tmp/claude-{uid} convention is Linux/macOS Claude CLI layout —
37
+ the endpoints degrade to an empty list / 404 instead of a 500.
38
+ """
39
+ if not hasattr(os, "getuid"):
40
+ return None
41
+ from painapple_code.server import agents
42
+ cwd = (agents.default_cwd if agents else None) or os.getcwd()
43
+ uid = os.getuid()
44
+ slug = _cwd_to_slug(cwd)
45
+ # Claude Code uses dash-prefix slugs; also check tilde-prefix variant
46
+ tasks_dir = Path(f"/tmp/claude-{uid}/{slug}/tasks")
47
+ if not tasks_dir.exists():
48
+ tilde_slug = '~' + slug[1:] # -home-... → ~home-...
49
+ alt = Path(f"/tmp/claude-{uid}/{tilde_slug}/tasks")
50
+ if alt.exists():
51
+ return alt
52
+ return tasks_dir
53
+
54
+
55
+ def _is_running(mtime: float, threshold: float = 5.0) -> bool:
56
+ """Heuristic: task is running if file was modified recently."""
57
+ return (time.time() - mtime) < threshold
58
+
59
+
60
+ def _read_last_lines(path: Path, n: int = 3) -> str:
61
+ """Read last N lines from a file efficiently."""
62
+ try:
63
+ with open(path, 'rb') as f:
64
+ # Seek near end
65
+ f.seek(0, 2)
66
+ size = f.tell()
67
+ pos = max(0, size - 4096)
68
+ f.seek(pos)
69
+ chunk = f.read().decode('utf-8', errors='replace')
70
+ lines = chunk.strip().split('\n')
71
+ return '\n'.join(lines[-n:])
72
+ except Exception:
73
+ return ''
74
+
75
+
76
+ @router.get("/api/tasks")
77
+ async def list_tasks():
78
+ """List all background tasks for the current project."""
79
+ tasks_dir = _get_tasks_dir()
80
+
81
+ if tasks_dir is None or not tasks_dir.exists():
82
+ return {"tasks": []}
83
+
84
+ tasks = []
85
+ for f in sorted(tasks_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
86
+ if not f.name.endswith('.output'):
87
+ continue
88
+ try:
89
+ task_id = f.stem
90
+ stat = f.stat()
91
+ running = _is_running(stat.st_mtime)
92
+ preview = _read_last_lines(f.resolve(), 2) if stat.st_size > 0 else ''
93
+
94
+ tasks.append({
95
+ "id": task_id,
96
+ "size": stat.st_size,
97
+ "modified": stat.st_mtime,
98
+ "is_running": running,
99
+ "preview": preview[:200],
100
+ })
101
+ except Exception as e:
102
+ logger.warning(f"Error reading task {f.name}: {e}")
103
+
104
+ return {"tasks": tasks}
105
+
106
+
107
+ @router.get("/api/tasks/{task_id}")
108
+ async def get_task_output(task_id: str, offset: int = 0):
109
+ """Read task output with offset-based incremental polling."""
110
+ if not TASK_ID_RE.match(task_id):
111
+ raise HTTPException(400, "Invalid task ID format")
112
+
113
+ tasks_dir = _get_tasks_dir()
114
+ if tasks_dir is None:
115
+ raise HTTPException(404, "Background tasks not available on this platform")
116
+ file_path = tasks_dir / f"{task_id}.output"
117
+
118
+ if not file_path.exists():
119
+ raise HTTPException(404, "Task not found")
120
+
121
+ # Resolve symlinks but verify we stay in /tmp/claude-*
122
+ real_path = file_path.resolve()
123
+ uid = os.getuid()
124
+ if not str(real_path).startswith(f"/tmp/claude-{uid}/"):
125
+ raise HTTPException(403, "Path traversal denied")
126
+
127
+ stat = real_path.stat()
128
+ running = _is_running(stat.st_mtime)
129
+
130
+ content = ""
131
+ new_offset = offset
132
+ try:
133
+ with open(real_path, 'r', errors='replace', encoding="utf-8") as f:
134
+ if offset > 0:
135
+ f.seek(min(offset, stat.st_size))
136
+ content = f.read()
137
+ new_offset = f.tell()
138
+ except Exception as e:
139
+ raise HTTPException(500, f"Error reading task output: {e}")
140
+
141
+ return {
142
+ "id": task_id,
143
+ "content": content,
144
+ "offset": new_offset,
145
+ "size": stat.st_size,
146
+ "is_running": running,
147
+ }
@@ -0,0 +1,540 @@
1
+ """
2
+ Terminal API Routes - PTY terminal WebSocket and management
3
+
4
+ These endpoints provide:
5
+ - Interactive PTY terminal via WebSocket
6
+ - Terminal session management (list, kill)
7
+ - Active Claude session listing
8
+ - Claude subprocess instance tracking
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import re
15
+ import time
16
+
17
+ from painapple_code.utils.proc import pid_alive
18
+ from painapple_code.utils.pty_backend import spawn_pty
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
23
+
24
+ from painapple_code.auth_middleware import (
25
+ _client_identity,
26
+ check_websocket_auth,
27
+ check_websocket_origin,
28
+ record_auth_identity,
29
+ )
30
+ from painapple_code.session_store import SessionStore
31
+ from painapple_code.subprocess_registry import agent_subprocesses
32
+ from painapple_code.utils.file_paths import safe_resolve
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ router = APIRouter(tags=["terminal"])
37
+
38
+ # Store active terminal sessions (keyed by session_id)
39
+ terminal_sessions: dict[str, dict] = {}
40
+
41
+ # Maximum scrollback buffer size per terminal (in bytes)
42
+ TERMINAL_SCROLLBACK_SIZE = 100 * 1024 # 100KB
43
+
44
+ # OSC 52 is the clipboard-write sequence (ESC ] 52 ; Ps ; Pt ST) that lets a
45
+ # program inside the terminal — vim's `y`, tmux — put text on the user's
46
+ # system clipboard. It is passed through untouched on the LIVE stream; that
47
+ # is the feature (see static/js/widgets/terminal/osc52.js).
48
+ #
49
+ # It must never survive into a scrollback REPLAY. The ring below is re-sent
50
+ # verbatim on every (re)connect, and iPad PWAs reconnect constantly after
51
+ # suspend — so without this, a yank from an hour ago would silently re-hijack
52
+ # the clipboard every time the tab woke up.
53
+ #
54
+ # Both terminators are matched (BEL and ESC \). The payload is base64 plus
55
+ # ';' plus Ps, so it can contain neither, which also keeps this bounded.
56
+ _OSC52_RE = re.compile(rb'\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)')
57
+
58
+
59
+ @router.websocket("/ws/terminal")
60
+ async def terminal_websocket(websocket: WebSocket, session: str = None, cwd: str = None):
61
+ """
62
+ WebSocket endpoint for interactive terminal (PTY).
63
+
64
+ Each session gets its own persistent terminal that survives disconnects.
65
+ """
66
+ await websocket.accept()
67
+
68
+ state = websocket.app.state
69
+ if not check_websocket_origin(websocket, getattr(state, "allowed_origins", None)):
70
+ await websocket.close(code=1008, reason="forbidden origin")
71
+ return
72
+ cookie_token = getattr(state, "auth_cookie_token", None)
73
+ api_token = getattr(state, "auth_api_token", None)
74
+ if not cookie_token or not api_token or not check_websocket_auth(websocket, cookie_token, api_token):
75
+ await websocket.close(code=1008, reason="unauthorized")
76
+ return
77
+ record_auth_identity("ws", *_client_identity(websocket.scope))
78
+
79
+ session_id = session or "default"
80
+
81
+ # Get CWD from session store if available
82
+ resolved_cwd = None
83
+ if session:
84
+ store_data = SessionStore.load(session)
85
+ if store_data:
86
+ resolved_cwd = store_data.get("cwd")
87
+
88
+ if not resolved_cwd and cwd:
89
+ resolved_cwd = str(safe_resolve(cwd))
90
+
91
+ if not resolved_cwd:
92
+ # Fall back to the server's --workspace (stashed on app.state by main()).
93
+ # `from server import agents` looks at a different module instance than
94
+ # the running __main__, so `agents` is None there — Path.home() inside
95
+ # Docker is /home/app which is not the project base the user wants.
96
+ ws = getattr(state, "workspace", None)
97
+ if ws:
98
+ try:
99
+ resolved_cwd = str(safe_resolve(ws))
100
+ except Exception:
101
+ resolved_cwd = ws
102
+ else:
103
+ resolved_cwd = str(Path.home())
104
+
105
+ logger.info(f"Terminal WebSocket connected: session={session_id}, cwd={resolved_cwd}")
106
+
107
+ # Check if we have an existing terminal session
108
+ term_session = terminal_sessions.get(session_id)
109
+
110
+ if term_session:
111
+ term = term_session["pty"]
112
+ pid = term.pid
113
+
114
+ try:
115
+ if not pid_alive(pid):
116
+ raise ProcessLookupError(f"terminal pid {pid} gone")
117
+ was_inactive = not term_session.get("active")
118
+ logger.info(f"Reconnecting to existing terminal: pid={pid}, was_inactive={was_inactive}")
119
+
120
+ old_read_task = term_session.get("read_task")
121
+ old_heartbeat_task = term_session.get("heartbeat_task")
122
+ if old_read_task and not old_read_task.done():
123
+ old_read_task.cancel()
124
+ try:
125
+ await old_read_task
126
+ except asyncio.CancelledError:
127
+ pass
128
+ if old_heartbeat_task and not old_heartbeat_task.done():
129
+ old_heartbeat_task.cancel()
130
+ try:
131
+ await old_heartbeat_task
132
+ except asyncio.CancelledError:
133
+ pass
134
+
135
+ term_session["active"] = True
136
+ except OSError:
137
+ logger.info(f"Terminal process {pid} died, creating new one")
138
+ term.close()
139
+ del terminal_sessions[session_id]
140
+ term_session = None
141
+
142
+ if not term_session:
143
+ term = spawn_pty(resolved_cwd, rows=24, cols=80)
144
+
145
+ term_session = {
146
+ "pty": term,
147
+ "cwd": resolved_cwd,
148
+ "active": True,
149
+ "scrollback": bytearray(),
150
+ }
151
+ terminal_sessions[session_id] = term_session
152
+
153
+ logger.info(f"Created new terminal: pid={term.pid}, cwd={resolved_cwd}")
154
+
155
+ term = term_session["pty"]
156
+ pid = term.pid
157
+ scrollback = term_session.get("scrollback", bytearray())
158
+
159
+ await websocket.send_json({
160
+ "type": "connected",
161
+ "session": session_id,
162
+ "cwd": resolved_cwd,
163
+ "home": str(Path.home()),
164
+ "pid": pid,
165
+ "has_scrollback": len(scrollback) > 0,
166
+ })
167
+
168
+ if scrollback:
169
+ try:
170
+ replay = _OSC52_RE.sub(b'', bytes(scrollback))
171
+ await websocket.send_text(replay.decode('utf-8', errors='replace'))
172
+ logger.info(f"Replayed {len(replay)} bytes of scrollback for session {session_id}")
173
+ except Exception as e:
174
+ logger.error(f"Failed to replay scrollback: {e}")
175
+
176
+ async def read_pty():
177
+ loop = asyncio.get_event_loop()
178
+ exit_code = None
179
+ ws_disconnected = False
180
+ try:
181
+ while True:
182
+ # Reap first: a shell that exits without closing the pty
183
+ # (or was killed) is noticed here rather than hanging the
184
+ # loop on a read that will never return.
185
+ exit_code = term.poll()
186
+ if exit_code is not None:
187
+ break
188
+
189
+ # Backend contract: None = nothing yet, b"" = pty closed.
190
+ # The blocking wait (select on POSIX, queue on Windows)
191
+ # runs off-loop so the event loop stays responsive.
192
+ data = await loop.run_in_executor(None, term.read, 0.1)
193
+ if data is None:
194
+ continue
195
+ if data == b"":
196
+ # Give the child a moment to be reapable, then take
197
+ # whatever poll() reports (0 if it was already reaped).
198
+ exit_code = term.poll()
199
+ if exit_code is None:
200
+ await asyncio.sleep(0.05)
201
+ exit_code = term.poll()
202
+ if exit_code is None:
203
+ exit_code = -1
204
+ break
205
+
206
+ scrollback.extend(data)
207
+ if len(scrollback) > TERMINAL_SCROLLBACK_SIZE:
208
+ scrollback[:] = scrollback[-TERMINAL_SCROLLBACK_SIZE:]
209
+ try:
210
+ await websocket.send_text(data.decode('utf-8', errors='replace'))
211
+ except Exception as ws_err:
212
+ logger.info(f"Terminal WebSocket disconnected (session={session_id}): {ws_err}")
213
+ ws_disconnected = True
214
+ break
215
+ except asyncio.CancelledError:
216
+ pass
217
+ except Exception as e:
218
+ logger.error(f"PTY read error: {e}")
219
+ ws_disconnected = True
220
+
221
+ if exit_code is not None:
222
+ try:
223
+ await websocket.send_json({"type": "exit", "code": exit_code})
224
+ logger.info(f"Terminal exited: session={session_id}, code={exit_code}")
225
+ except Exception:
226
+ pass
227
+ if session_id in terminal_sessions:
228
+ term_session["active"] = False
229
+ term.close()
230
+ del terminal_sessions[session_id]
231
+ elif ws_disconnected:
232
+ logger.info(f"Terminal WebSocket gone, PTY kept alive for reconnect: session={session_id}, pid={pid}")
233
+ if session_id in terminal_sessions:
234
+ term_session["active"] = False
235
+
236
+ read_task = asyncio.create_task(read_pty())
237
+
238
+ async def heartbeat():
239
+ # 5s cadence so iOS half-dead WebSockets are detected within ~5s
240
+ # on resume, instead of the previous 30s window where the terminal
241
+ # appeared frozen until the next ping failed.
242
+ try:
243
+ while True:
244
+ await asyncio.sleep(5)
245
+ try:
246
+ await websocket.send_json({"type": "heartbeat"})
247
+ except Exception:
248
+ logger.info(f"Terminal heartbeat failed, WebSocket dead: session={session_id}")
249
+ break
250
+ except asyncio.CancelledError:
251
+ pass
252
+
253
+ heartbeat_task = asyncio.create_task(heartbeat())
254
+
255
+ term_session["read_task"] = read_task
256
+ term_session["heartbeat_task"] = heartbeat_task
257
+
258
+ try:
259
+ while True:
260
+ data = await websocket.receive()
261
+
262
+ if data["type"] == "websocket.disconnect":
263
+ break
264
+
265
+ if data["type"] == "websocket.receive":
266
+ if "text" in data:
267
+ text = data["text"]
268
+
269
+ if text.startswith("{"):
270
+ try:
271
+ msg = json.loads(text)
272
+ msg_type = msg.get("type")
273
+
274
+ if msg_type == "resize":
275
+ rows = msg.get("rows", 24)
276
+ cols = msg.get("cols", 80)
277
+ try:
278
+ term.set_size(rows, cols)
279
+ except (OSError, ValueError) as e:
280
+ logger.debug(f"Terminal resize failed: {e}")
281
+ continue
282
+
283
+ if msg_type == "ping":
284
+ await websocket.send_json({"type": "pong"})
285
+ continue
286
+
287
+ except json.JSONDecodeError:
288
+ pass
289
+
290
+ try:
291
+ term.write(text.encode('utf-8'))
292
+ except EOFError:
293
+ break
294
+
295
+ elif "bytes" in data:
296
+ try:
297
+ term.write(data["bytes"])
298
+ except (EOFError, OSError):
299
+ break
300
+
301
+ except WebSocketDisconnect:
302
+ logger.info(f"Terminal WebSocket disconnected: session={session_id}")
303
+ except Exception as e:
304
+ logger.error(f"Terminal WebSocket error: {e}")
305
+ finally:
306
+ read_task.cancel()
307
+ heartbeat_task.cancel()
308
+ try:
309
+ await read_task
310
+ except asyncio.CancelledError:
311
+ pass
312
+ try:
313
+ await heartbeat_task
314
+ except asyncio.CancelledError:
315
+ pass
316
+
317
+ logger.info(f"Terminal WebSocket closed, process {pid} still running")
318
+
319
+
320
+ @router.delete("/api/terminal/{session_id:path}")
321
+ async def kill_terminal(session_id: str):
322
+ """Kill a terminal session."""
323
+ term_session = terminal_sessions.get(session_id)
324
+ if not term_session:
325
+ raise HTTPException(status_code=404, detail="Terminal session not found")
326
+
327
+ term = term_session.get("pty")
328
+
329
+ if term:
330
+ term.terminate()
331
+ await asyncio.sleep(0.5)
332
+ term.terminate(force=True)
333
+ term.close()
334
+
335
+ del terminal_sessions[session_id]
336
+ logger.info(f"Killed terminal session: {session_id}")
337
+
338
+ return {"killed": True, "session": session_id}
339
+
340
+
341
+ @router.get("/api/terminal-cwd")
342
+ async def get_terminal_cwd(session: str):
343
+ """Live working directory of a terminal's shell process.
344
+
345
+ Asks the backend where the shell process actually is, so the result
346
+ tracks the user's `cd`s in real time; falls back to the spawn cwd
347
+ where that isn't available (macOS has no /proc, the shell exited,
348
+ or — on Windows — PowerShell's `cd` moves only its provider location
349
+ and never the process CWD). Query param instead of a path param
350
+ because terminal session IDs contain slashes
351
+ (`session:<id>:/path/to/cwd`).
352
+ """
353
+ term_session = terminal_sessions.get(session)
354
+ if not term_session:
355
+ raise HTTPException(status_code=404, detail="Terminal session not found")
356
+
357
+ term = term_session.get("pty")
358
+ live_cwd = term.live_cwd() if term else None
359
+
360
+ return {"cwd": live_cwd or term_session.get("cwd"), "live": bool(live_cwd)}
361
+
362
+
363
+ @router.get("/api/terminals")
364
+ async def list_terminals():
365
+ """List active terminal sessions."""
366
+ result = []
367
+ for session_id, term_session in terminal_sessions.items():
368
+ term = term_session.get("pty")
369
+ pid = term.pid if term else None
370
+ alive = pid_alive(pid) if pid else False
371
+
372
+ result.append({
373
+ "session": session_id,
374
+ "pid": pid,
375
+ "cwd": term_session.get("cwd"),
376
+ "alive": alive,
377
+ })
378
+ return {"terminals": result}
379
+
380
+
381
+ # ═══════════════════════════════════════════════════════════════════
382
+ # Active Sessions
383
+ # ═══════════════════════════════════════════════════════════════════
384
+
385
+ @router.get("/api/active-sessions")
386
+ async def list_active_sessions():
387
+ """List all currently running Claude sessions with real-time status.
388
+
389
+ Also includes recent stored sessions (not yet connected to this server
390
+ instance) so the widget isn't empty after a server restart.
391
+ """
392
+ from painapple_code.server import agents
393
+
394
+ result = []
395
+ now = time.time()
396
+ live_store_ids = set()
397
+
398
+ if not agents:
399
+ agent_sessions = {}
400
+ else:
401
+ agent_sessions = agents.sessions
402
+
403
+ for store_id, session in agent_sessions.items():
404
+ live_store_ids.add(store_id)
405
+
406
+ # Validate process state
407
+ if session.is_running:
408
+ process_alive = bool(session.process) and pid_alive(session.process.pid)
409
+
410
+ if not process_alive:
411
+ logger.warning(
412
+ f"Session {store_id} has stale is_running=True but process is dead, fixing"
413
+ )
414
+ session.is_running = False
415
+ session.process = None
416
+
417
+ session_info = {
418
+ "store_id": store_id,
419
+ "cwd": session.cwd,
420
+ "is_running": session.is_running,
421
+ "is_idle": session.is_idle,
422
+ "has_websocket": session.ws_connected,
423
+ "created_at": datetime.fromtimestamp(session.created_at).isoformat() + "Z",
424
+ "last_activity": datetime.fromtimestamp(session.last_activity).isoformat() + "Z",
425
+ "idle_seconds": int(now - session.last_activity),
426
+ "turn_number": session.turn_number,
427
+ "process_pid": None,
428
+ "provider_session_id": session.session_id,
429
+ }
430
+
431
+ if session.process and session.is_running:
432
+ session_info["process_pid"] = session.process.pid
433
+
434
+ if session.is_running:
435
+ if session.is_idle:
436
+ session_info["status"] = "idle"
437
+ elif session.in_thinking_mode:
438
+ session_info["status"] = "thinking"
439
+ elif session.active_tasks:
440
+ task_names = [t.description for t in session.active_tasks.values() if t.description]
441
+ session_info["status"] = "tasks"
442
+ session_info["active_tasks"] = task_names[:3]
443
+ elif session.turn_tracker and session.turn_tracker.has_activity:
444
+ session_info["status"] = "tools"
445
+ else:
446
+ session_info["status"] = "streaming"
447
+ elif session.is_compacting:
448
+ session_info["status"] = "compacting"
449
+ else:
450
+ session_info["status"] = "idle"
451
+
452
+ if session.turn_tracker:
453
+ tracker = session.turn_tracker
454
+ session_info["current_turn"] = {
455
+ "has_activity": tracker.has_activity,
456
+ "tool_count": len(tracker.tools_used),
457
+ "tools_summary": tracker.get_tools_summary(),
458
+ "modified_files": len(tracker.modified_files),
459
+ "prompt_preview": tracker.user_prompt[:80] if tracker.user_prompt else None,
460
+ }
461
+
462
+ meta = SessionStore.load_meta(store_id)
463
+ if meta:
464
+ session_info["name"] = meta.get("name")
465
+ session_info["model"] = meta.get("model")
466
+ session_info["total_cost"] = meta.get("total_cost", 0)
467
+ session_info["message_count"] = meta.get("message_count", 0)
468
+
469
+ result.append(session_info)
470
+
471
+ # Supplement with recent stored sessions not in agents.sessions.
472
+ # This makes the widget useful after server restarts, showing recent
473
+ # sessions as "disconnected" until the browser reconnects.
474
+ try:
475
+ all_stored = SessionStore.list_all()
476
+ for stored in all_stored[:20]: # Check top 20 most recent
477
+ sid = stored.get("id", "")
478
+ if sid in live_store_ids:
479
+ continue
480
+ # Only include sessions active in last 24h
481
+ last_act = stored.get("last_activity", "")
482
+ if not last_act:
483
+ continue
484
+ try:
485
+ from datetime import timezone
486
+ act_dt = datetime.fromisoformat(last_act.replace("Z", "+00:00"))
487
+ age_seconds = (datetime.now(timezone.utc) - act_dt).total_seconds()
488
+ if age_seconds > 86400: # Skip sessions older than 24h
489
+ continue
490
+ except (ValueError, TypeError):
491
+ continue
492
+
493
+ result.append({
494
+ "store_id": sid,
495
+ "cwd": stored.get("cwd", ""),
496
+ "is_running": False,
497
+ "is_idle": True,
498
+ "has_websocket": False,
499
+ "created_at": stored.get("created_at", ""),
500
+ "last_activity": last_act,
501
+ "idle_seconds": int(age_seconds),
502
+ "turn_number": 0,
503
+ "process_pid": None,
504
+ "provider_session_id": stored.get("provider_session_id"),
505
+ "status": "disconnected",
506
+ "name": stored.get("name"),
507
+ "model": stored.get("model"),
508
+ "total_cost": stored.get("total_cost", 0),
509
+ "message_count": stored.get("message_count", 0),
510
+ })
511
+ except Exception as e:
512
+ logger.debug(f"Failed to load stored sessions for active-sessions: {e}")
513
+
514
+ result.sort(key=lambda s: (
515
+ 0 if s["is_running"] else (1 if s["status"] != "disconnected" else 2),
516
+ # For live sessions: most recently active first (-idle → ascending)
517
+ # For disconnected/stored: same (most recent first)
518
+ s.get("idle_seconds", 0)
519
+ ))
520
+
521
+ running_count = sum(1 for s in result if s["is_running"] and not s.get("is_idle", True))
522
+
523
+ agent_subprocesses.prune_dead_processes()
524
+ subprocess_data = agent_subprocesses.to_api_response()
525
+
526
+ return {
527
+ "sessions": result,
528
+ "count": len(result),
529
+ "running_count": running_count,
530
+ "agent_instances": subprocess_data["instances"],
531
+ "agent_instances_count": subprocess_data["count"],
532
+ "agent_instances_by_type": subprocess_data["by_type"],
533
+ }
534
+
535
+
536
+ @router.get("/api/agent-instances")
537
+ async def get_agent_instances():
538
+ """Get full Claude subprocess data including history and statistics."""
539
+ agent_subprocesses.prune_dead_processes()
540
+ return agent_subprocesses.to_full_api_response()