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,649 @@
1
+ """
2
+ Session Logs API Routes - Log explorer and file changes tracking
3
+
4
+ These endpoints provide:
5
+ - Session log overview (file sizes, message counts)
6
+ - Parsed message retrieval with pagination and filtering
7
+ - Raw I/O log access for debugging
8
+ - Tool output file management
9
+ - File changes tracking (Edit/Write tool usage analysis)
10
+ """
11
+
12
+ import asyncio
13
+ import difflib
14
+ import json
15
+ import logging
16
+ import re
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from fastapi import APIRouter, Depends, HTTPException
21
+ from fastapi.responses import FileResponse
22
+
23
+ from painapple_code import paths
24
+ from painapple_code.session_store import SessionStore
25
+ from painapple_code.routes.dependencies import get_session_store
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ router = APIRouter(tags=["logs"])
30
+
31
+ # Raster image types safe to serve inline (needed for <img> preview / image
32
+ # re-attach). These cannot execute script. Everything else — notably .svg
33
+ # (image-shaped but scriptable) and .html/.js — is served as an inert
34
+ # attachment so an uploaded file can never run in the authenticated origin.
35
+ _INLINE_IMAGE_TYPES = {
36
+ ".png": "image/png",
37
+ ".jpg": "image/jpeg",
38
+ ".jpeg": "image/jpeg",
39
+ ".gif": "image/gif",
40
+ ".webp": "image/webp",
41
+ }
42
+
43
+
44
+ # ═══════════════════════════════════════════════════════════════════
45
+ # Session Log Overview
46
+ # ═══════════════════════════════════════════════════════════════════
47
+
48
+ @router.get("/api/sessions/{session_id}/logs")
49
+ async def get_session_logs_overview(session_id: str):
50
+ """Get overview of session logs - files and their sizes."""
51
+ store, meta = SessionStore._find_session(session_id)
52
+ if not store:
53
+ raise HTTPException(status_code=404, detail="Session not found")
54
+
55
+ session_dir = store._session_dir(session_id)
56
+
57
+ messages_path = store._messages_path(session_id)
58
+ raw_path = store._raw_log_path(session_id)
59
+ tools_dir = store._tools_dir(session_id)
60
+
61
+ def count_lines(path):
62
+ if not path.exists():
63
+ return 0
64
+ with open(path, encoding="utf-8") as f:
65
+ return sum(1 for line in f if line.strip())
66
+
67
+ def count_user_lines(path):
68
+ """Count lines where role is 'user' in messages.jsonl."""
69
+ if not path.exists():
70
+ return 0
71
+ count = 0
72
+ with open(path, encoding="utf-8") as f:
73
+ for line in f:
74
+ line = line.strip()
75
+ if line and ('"role": "user"' in line or '"role":"user"' in line):
76
+ count += 1
77
+ return count
78
+
79
+ tool_files = []
80
+ if tools_dir.exists():
81
+ for f in sorted(tools_dir.iterdir()):
82
+ if f.is_file():
83
+ tool_files.append({
84
+ "name": f.name,
85
+ "size": f.stat().st_size,
86
+ "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat() + "Z"
87
+ })
88
+
89
+ return {
90
+ "session_id": session_id,
91
+ "meta": meta,
92
+ "files": {
93
+ "messages": {
94
+ "path": str(messages_path),
95
+ "exists": messages_path.exists(),
96
+ "size": messages_path.stat().st_size if messages_path.exists() else 0,
97
+ "lines": count_lines(messages_path),
98
+ "user_lines": count_user_lines(messages_path)
99
+ },
100
+ "raw": {
101
+ "path": str(raw_path),
102
+ "exists": raw_path.exists(),
103
+ "size": raw_path.stat().st_size if raw_path.exists() else 0,
104
+ "lines": count_lines(raw_path)
105
+ },
106
+ "tools": {
107
+ "path": str(tools_dir),
108
+ "exists": tools_dir.exists(),
109
+ "count": len(tool_files),
110
+ "files": tool_files
111
+ }
112
+ }
113
+ }
114
+
115
+
116
+ # ═══════════════════════════════════════════════════════════════════
117
+ # Session Messages
118
+ # ═══════════════════════════════════════════════════════════════════
119
+
120
+ @router.get("/api/sessions/{session_id}/logs/messages")
121
+ async def get_session_messages(
122
+ session_id: str,
123
+ offset: int = 0,
124
+ limit: int = 50,
125
+ role: str = None,
126
+ sort: str = "asc",
127
+ since: str = None,
128
+ store: SessionStore = Depends(get_session_store),
129
+ ):
130
+ """Get parsed messages with pagination, filtering, and timestamp filtering."""
131
+ messages_path = store._messages_path(session_id)
132
+ if not messages_path.exists():
133
+ return {"messages": [], "total": 0, "offset": offset, "limit": limit, "since": since}
134
+
135
+ # Load favorites set for quick lookup
136
+ favorites_data = paths.load_prompt_favorites()
137
+ favorite_ids = set(favorites_data.get("prompts", {}).keys())
138
+
139
+ all_messages = []
140
+ line_number = 0
141
+ with open(messages_path, 'r', encoding="utf-8") as f:
142
+ for line in f:
143
+ line_number += 1
144
+ line = line.strip()
145
+ if line:
146
+ try:
147
+ msg = json.loads(line)
148
+ if since and msg.get("timestamp", "") < since:
149
+ continue
150
+ if role is None or msg.get("role") == role:
151
+ # Stable server identity for client-side dedup: an explicit
152
+ # stored id wins (e.g. compact-<uuid> on boundary rows);
153
+ # otherwise derive from the append-only line number — the
154
+ # same "{session}:{line}" scheme promptId already uses, so
155
+ # every historical message gets identity retroactively.
156
+ # Client matching/dedup keys on this (`sid`), never on
157
+ # content+time heuristics when both sides carry one.
158
+ msg["sid"] = msg.get("id") or f"{session_id}:{line_number}"
159
+ if msg.get("role") == "user":
160
+ prompt_id = f"{session_id}:{line_number}"
161
+ msg["promptId"] = prompt_id
162
+ msg["isFavorite"] = prompt_id in favorite_ids
163
+ all_messages.append(msg)
164
+ except json.JSONDecodeError:
165
+ continue
166
+
167
+ # Hydrate tool outputs
168
+ for msg in all_messages:
169
+ store._hydrate_tool_output(session_id, msg)
170
+
171
+ total = len(all_messages)
172
+
173
+ if sort == "desc":
174
+ all_messages = all_messages[::-1]
175
+
176
+ paginated = all_messages[offset:offset + limit]
177
+
178
+ return {
179
+ "messages": paginated,
180
+ "total": total,
181
+ "offset": offset,
182
+ "limit": limit,
183
+ "has_more": offset + limit < total,
184
+ "since": since
185
+ }
186
+
187
+
188
+ # ═══════════════════════════════════════════════════════════════════
189
+ # Raw Log
190
+ # ═══════════════════════════════════════════════════════════════════
191
+
192
+ @router.get("/api/sessions/{session_id}/logs/raw")
193
+ async def get_session_raw_log(
194
+ session_id: str,
195
+ offset: int = 0,
196
+ limit: int = 100,
197
+ direction: str = None,
198
+ errors_only: bool = False,
199
+ sort: str = "asc",
200
+ store: SessionStore = Depends(get_session_store),
201
+ ):
202
+ """Get raw Claude I/O log with filtering."""
203
+ raw_path = store._raw_log_path(session_id)
204
+ if not raw_path.exists():
205
+ return {"entries": [], "total": 0, "offset": offset, "limit": limit}
206
+
207
+ all_entries = []
208
+ with open(raw_path, 'r', encoding="utf-8") as f:
209
+ for line in f:
210
+ line = line.strip()
211
+ if line:
212
+ try:
213
+ entry = json.loads(line)
214
+ if errors_only and entry.get("dir") != "error":
215
+ continue
216
+ if direction and entry.get("dir") != direction:
217
+ continue
218
+ all_entries.append(entry)
219
+ except json.JSONDecodeError:
220
+ continue
221
+
222
+ total = len(all_entries)
223
+
224
+ if sort == "desc":
225
+ all_entries = all_entries[::-1]
226
+
227
+ paginated = all_entries[offset:offset + limit]
228
+
229
+ return {
230
+ "entries": paginated,
231
+ "total": total,
232
+ "offset": offset,
233
+ "limit": limit,
234
+ "has_more": offset + limit < total
235
+ }
236
+
237
+
238
+ # ═══════════════════════════════════════════════════════════════════
239
+ # Tool Output Files
240
+ # ═══════════════════════════════════════════════════════════════════
241
+
242
+ @router.get("/api/sessions/{session_id}/logs/tools")
243
+ async def get_session_tool_files(session_id: str, store: SessionStore = Depends(get_session_store)):
244
+ """List all tool output files for a session."""
245
+ tools_dir = store._tools_dir(session_id)
246
+ if not tools_dir.exists():
247
+ return {"files": []}
248
+
249
+ files = []
250
+ for f in sorted(tools_dir.iterdir()):
251
+ if f.is_file():
252
+ name_parts = f.stem.rsplit("_", 1)
253
+ tool_name = name_parts[0] if len(name_parts) > 1 else f.stem
254
+
255
+ files.append({
256
+ "name": f.name,
257
+ "tool_name": tool_name,
258
+ "size": f.stat().st_size,
259
+ "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat() + "Z"
260
+ })
261
+
262
+ return {"files": files, "count": len(files)}
263
+
264
+
265
+ @router.get("/api/sessions/{session_id}/logs/tools/{filename}")
266
+ async def get_session_tool_output(session_id: str, filename: str, store: SessionStore = Depends(get_session_store)):
267
+ """Get content of a specific tool output file."""
268
+ tools_dir = store._tools_dir(session_id)
269
+ file_path = tools_dir / filename
270
+
271
+ try:
272
+ file_path = file_path.resolve()
273
+ # is_relative_to, not startswith: a string prefix test would also accept
274
+ # a sibling directory whose name merely starts with the same characters
275
+ # (".../tools" is a prefix of ".../tools_extra").
276
+ if not file_path.is_relative_to(tools_dir.resolve()):
277
+ raise HTTPException(status_code=403, detail="Access denied")
278
+ except Exception:
279
+ raise HTTPException(status_code=400, detail="Invalid filename")
280
+
281
+ if not file_path.exists():
282
+ raise HTTPException(status_code=404, detail="File not found")
283
+
284
+ try:
285
+ content = file_path.read_text(encoding="utf-8", errors='replace')
286
+ return {
287
+ "filename": filename,
288
+ "size": len(content),
289
+ "content": content
290
+ }
291
+ except Exception as e:
292
+ raise HTTPException(status_code=500, detail=str(e))
293
+
294
+
295
+ IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'}
296
+
297
+
298
+ @router.get("/api/sessions/{session_id}/uploads")
299
+ async def list_session_uploads(session_id: str, scope: str = "session", store: SessionStore = Depends(get_session_store)):
300
+ """List uploaded files. scope='session' (default) lists this session only;
301
+ scope='project' lists uploads across every session in the same project."""
302
+ def _entry(path, sid: str) -> dict:
303
+ stat = path.stat()
304
+ return {
305
+ "name": path.name,
306
+ "session_id": sid,
307
+ # Absolute path — non-image uploads re-attach to the chat input by
308
+ # path (the prompt carries "Uploaded file: <path>"), so the browser
309
+ # needs it without a second round-trip.
310
+ "path": str(path.resolve()),
311
+ "size": stat.st_size,
312
+ "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + "Z",
313
+ "is_image": path.suffix.lower() in IMAGE_EXTENSIONS,
314
+ }
315
+
316
+ if scope == "project":
317
+ files = []
318
+ for sess_dir in store.base_dir.iterdir():
319
+ if not sess_dir.is_dir():
320
+ continue
321
+ uploads_dir = sess_dir / "uploads"
322
+ if not uploads_dir.exists():
323
+ continue
324
+ for f in uploads_dir.iterdir():
325
+ if f.is_file():
326
+ files.append(_entry(f, sess_dir.name))
327
+ files.sort(key=lambda x: x["modified"], reverse=True)
328
+ return {"files": files, "count": len(files), "scope": "project"}
329
+
330
+ uploads_dir = store._uploads_dir(session_id)
331
+ if not uploads_dir.exists():
332
+ return {"files": [], "count": 0, "scope": "session"}
333
+
334
+ files = [_entry(f, session_id) for f in sorted(uploads_dir.iterdir()) if f.is_file()]
335
+ return {"files": files, "count": len(files), "scope": "session"}
336
+
337
+
338
+ @router.get("/api/sessions/{session_id}/uploads/{filename}")
339
+ async def get_session_upload(session_id: str, filename: str, base64_encode: bool = False, store: SessionStore = Depends(get_session_store)):
340
+ """Serve an uploaded file (image/file) from a session's uploads directory.
341
+ With ?base64_encode=true, returns JSON with base64-encoded data (for image restore)."""
342
+ uploads_dir = store._uploads_dir(session_id)
343
+ file_path = (uploads_dir / filename).resolve()
344
+
345
+ # Path traversal protection. is_relative_to, not startswith: a string prefix
346
+ # test would also accept a sibling directory whose name merely starts with
347
+ # the same characters (".../uploads" is a prefix of ".../uploads_old").
348
+ if not file_path.is_relative_to(uploads_dir.resolve()):
349
+ raise HTTPException(status_code=403, detail="Access denied")
350
+
351
+ if not file_path.exists():
352
+ raise HTTPException(status_code=404, detail="File not found")
353
+
354
+ if not base64_encode:
355
+ ext = file_path.suffix.lower()
356
+ # Never cache authenticated user content in shared/proxy caches.
357
+ headers = {
358
+ "Cache-Control": "private, no-store",
359
+ "X-Content-Type-Options": "nosniff",
360
+ }
361
+ inline_type = _INLINE_IMAGE_TYPES.get(ext)
362
+ if inline_type:
363
+ # Raster image: safe to render inline (can't execute).
364
+ return FileResponse(file_path, media_type=inline_type, headers=headers)
365
+ # Anything else (.svg, .html, .js, arbitrary uploads): force an inert
366
+ # download so it cannot execute in the app origin.
367
+ return FileResponse(
368
+ file_path,
369
+ media_type="application/octet-stream",
370
+ filename=file_path.name,
371
+ headers=headers,
372
+ )
373
+
374
+ # Return base64-encoded data for image restoration after page refresh
375
+ import base64 as b64
376
+ content = file_path.read_bytes()
377
+ ext = file_path.suffix.lower()
378
+ media_type = _INLINE_IMAGE_TYPES.get(ext, 'application/octet-stream')
379
+ encoded = b64.standard_b64encode(content).decode('utf-8')
380
+ return {"filename": filename, "media_type": media_type, "size": len(content), "data": encoded}
381
+
382
+
383
+ # ═══════════════════════════════════════════════════════════════════
384
+ # Session Changes (Edit/Write tracking)
385
+ # ═══════════════════════════════════════════════════════════════════
386
+
387
+ @router.get("/api/sessions/{session_id}/changes")
388
+ async def get_session_changes(session_id: str, store: SessionStore = Depends(get_session_store)):
389
+ """Get file changes made by Claude during this session."""
390
+ return await asyncio.to_thread(compute_session_changes, store, session_id)
391
+
392
+
393
+ def compute_session_changes(store, session_id: str) -> dict:
394
+ """Compute file changes from messages.jsonl. Reusable helper.
395
+
396
+ Returns: {"files": [...], "summary": {...}}
397
+ """
398
+ messages_path = store._messages_path(session_id)
399
+ tools_dir = store._session_dir(session_id) / "tools"
400
+ if not messages_path.exists():
401
+ return {"files": [], "summary": {"totalFiles": 0, "modified": 0, "created": 0, "linesAdded": 0, "linesRemoved": 0}}
402
+
403
+ changes_by_file = {}
404
+
405
+ def count_lines(text):
406
+ if not text:
407
+ return 0
408
+ return len([line for line in text.split('\n') if line.strip()])
409
+
410
+ def count_diff_changes(old_string, new_string):
411
+ old_lines = old_string.split('\n') if old_string else []
412
+ new_lines = new_string.split('\n') if new_string else []
413
+
414
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
415
+
416
+ added = 0
417
+ removed = 0
418
+
419
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
420
+ if tag == 'replace':
421
+ removed += (i2 - i1)
422
+ added += (j2 - j1)
423
+ elif tag == 'delete':
424
+ removed += (i2 - i1)
425
+ elif tag == 'insert':
426
+ added += (j2 - j1)
427
+
428
+ return added, removed
429
+
430
+ def load_tool_output(tool_output, tool_output_file):
431
+ if tool_output and tool_output.startswith('[Stored in ') and tool_output.endswith(']'):
432
+ ref_file = tool_output[11:-1]
433
+ file_path = tools_dir / ref_file
434
+ if file_path.exists():
435
+ try:
436
+ return file_path.read_text(encoding="utf-8")
437
+ except Exception:
438
+ pass
439
+ if tool_output_file:
440
+ file_path = tools_dir / tool_output_file
441
+ if file_path.exists():
442
+ try:
443
+ return file_path.read_text(encoding="utf-8")
444
+ except Exception:
445
+ pass
446
+ return tool_output or ''
447
+
448
+ def parse_line_number(tool_output, new_string):
449
+ if not tool_output or not new_string:
450
+ return None
451
+ line_pattern = re.compile(r'^\s*(\d+)(?:\u2192|\t)(.*)$', re.MULTILINE)
452
+ first_new_line = new_string.split('\n')[0] if new_string else ''
453
+
454
+ for match in line_pattern.finditer(tool_output):
455
+ line_num, content = match.groups()
456
+ if content.strip() == first_new_line.strip():
457
+ return int(line_num)
458
+ return None
459
+
460
+ def process_tool(tool_name, tool_id, tool_input, tool_output, tool_output_file, timestamp):
461
+ if tool_name not in ('Edit', 'Write'):
462
+ return
463
+
464
+ file_path = tool_input.get('file_path', '')
465
+ if not file_path:
466
+ return
467
+
468
+ if file_path not in changes_by_file:
469
+ changes_by_file[file_path] = {
470
+ 'filePath': file_path,
471
+ 'fileName': Path(file_path).name,
472
+ 'fileType': Path(file_path).suffix.lstrip('.'),
473
+ 'status': 'created' if tool_name == 'Write' else 'modified',
474
+ 'edits': [],
475
+ 'firstChange': timestamp,
476
+ 'lastChange': timestamp,
477
+ 'linesAdded': 0,
478
+ 'linesRemoved': 0,
479
+ }
480
+
481
+ file_entry = changes_by_file[file_path]
482
+ file_entry['lastChange'] = timestamp
483
+
484
+ if tool_name == 'Edit':
485
+ old_string = tool_input.get('old_string', '')
486
+ new_string = tool_input.get('new_string', '')
487
+
488
+ lines_added, lines_removed = count_diff_changes(old_string, new_string)
489
+
490
+ actual_output = load_tool_output(tool_output, tool_output_file)
491
+ start_line = parse_line_number(actual_output, new_string)
492
+
493
+ edit = {
494
+ 'toolId': tool_id,
495
+ 'timestamp': timestamp,
496
+ 'type': 'edit',
497
+ 'oldString': old_string,
498
+ 'newString': new_string,
499
+ 'linesAdded': lines_added,
500
+ 'linesRemoved': lines_removed,
501
+ 'startLine': start_line,
502
+ }
503
+ file_entry['edits'].append(edit)
504
+ file_entry['linesAdded'] += edit['linesAdded']
505
+ file_entry['linesRemoved'] += edit['linesRemoved']
506
+ if len(file_entry['edits']) == 1 and file_entry['status'] == 'created':
507
+ file_entry['status'] = 'modified'
508
+
509
+ elif tool_name == 'Write':
510
+ content = tool_input.get('content', '')
511
+ new_lines = count_lines(content)
512
+
513
+ edit = {
514
+ 'toolId': tool_id,
515
+ 'timestamp': timestamp,
516
+ 'type': 'write',
517
+ 'content': content,
518
+ 'linesAdded': new_lines,
519
+ 'linesRemoved': 0,
520
+ 'startLine': 1,
521
+ }
522
+ file_entry['edits'].append(edit)
523
+ file_entry['linesAdded'] += new_lines
524
+ file_entry['status'] = 'created'
525
+
526
+ # Read messages and extract changes
527
+ with open(messages_path, 'r', encoding="utf-8") as f:
528
+ for line in f:
529
+ line = line.strip()
530
+ if not line:
531
+ continue
532
+ try:
533
+ msg = json.loads(line)
534
+ timestamp = msg.get('timestamp', '')
535
+
536
+ if msg.get('role') == 'tool':
537
+ process_tool(
538
+ msg.get('tool_name', ''),
539
+ msg.get('tool_id', ''),
540
+ msg.get('tool_input', {}),
541
+ msg.get('tool_output', ''),
542
+ msg.get('tool_output_file', ''),
543
+ timestamp
544
+ )
545
+
546
+ if msg.get('role') == 'thinking' and msg.get('tools'):
547
+ for tool in msg['tools']:
548
+ process_tool(
549
+ tool.get('toolName', ''),
550
+ tool.get('toolId', ''),
551
+ tool.get('toolInput', {}),
552
+ tool.get('toolOutput', ''),
553
+ tool.get('toolOutputFile', ''),
554
+ timestamp
555
+ )
556
+
557
+ except json.JSONDecodeError:
558
+ continue
559
+
560
+ files = list(changes_by_file.values())
561
+ files.sort(key=lambda f: f['lastChange'], reverse=True)
562
+
563
+ for f in files:
564
+ f['editCount'] = len(f['edits'])
565
+
566
+ summary = {
567
+ 'totalFiles': len(files),
568
+ 'modified': sum(1 for f in files if f['status'] == 'modified'),
569
+ 'created': sum(1 for f in files if f['status'] == 'created'),
570
+ 'linesAdded': sum(f['linesAdded'] for f in files),
571
+ 'linesRemoved': sum(f['linesRemoved'] for f in files),
572
+ }
573
+
574
+ return {"files": files, "summary": summary}
575
+
576
+
577
+ # ═══════════════════════════════════════════════════════════════════
578
+ # Session Reads (Read tool tracking)
579
+ # ═══════════════════════════════════════════════════════════════════
580
+
581
+ @router.get("/api/sessions/{session_id}/read-files")
582
+ async def get_session_read_files(session_id: str, store: SessionStore = Depends(get_session_store)):
583
+ """Get files Claude opened with the Read tool during this session.
584
+
585
+ Unlike /changes (Edit/Write), reads aren't persisted anywhere queryable,
586
+ so we reconstruct them by scanning messages.jsonl for Read tool calls.
587
+ Returns files most-recently-read first.
588
+ """
589
+ return await asyncio.to_thread(compute_session_read_files, store, session_id)
590
+
591
+
592
+ def compute_session_read_files(store, session_id: str) -> dict:
593
+ """Scan messages.jsonl for Read tool calls → unique files, recent first.
594
+
595
+ Returns: {"files": [{filePath, fileName, readCount, firstRead, lastRead}], "summary": {...}}
596
+ """
597
+ messages_path = store._messages_path(session_id)
598
+ if not messages_path.exists():
599
+ return {"files": [], "summary": {"totalFiles": 0, "totalReads": 0}}
600
+
601
+ reads_by_file = {}
602
+
603
+ def process_tool(tool_name, tool_input, timestamp):
604
+ if tool_name != 'Read':
605
+ return
606
+ file_path = (tool_input or {}).get('file_path', '')
607
+ if not file_path:
608
+ return
609
+ entry = reads_by_file.get(file_path)
610
+ if entry is None:
611
+ reads_by_file[file_path] = {
612
+ 'filePath': file_path,
613
+ 'fileName': Path(file_path).name,
614
+ 'fileType': Path(file_path).suffix.lstrip('.'),
615
+ 'readCount': 1,
616
+ 'firstRead': timestamp,
617
+ 'lastRead': timestamp,
618
+ }
619
+ else:
620
+ entry['readCount'] += 1
621
+ if timestamp:
622
+ entry['lastRead'] = timestamp
623
+
624
+ with open(messages_path, 'r', encoding="utf-8") as f:
625
+ for line in f:
626
+ line = line.strip()
627
+ if not line:
628
+ continue
629
+ try:
630
+ msg = json.loads(line)
631
+ except json.JSONDecodeError:
632
+ continue
633
+ timestamp = msg.get('timestamp', '')
634
+
635
+ if msg.get('role') == 'tool':
636
+ process_tool(msg.get('tool_name', ''), msg.get('tool_input', {}), timestamp)
637
+
638
+ if msg.get('role') == 'thinking' and msg.get('tools'):
639
+ for tool in msg['tools']:
640
+ process_tool(tool.get('toolName', ''), tool.get('toolInput', {}), timestamp)
641
+
642
+ files = list(reads_by_file.values())
643
+ files.sort(key=lambda f: f['lastRead'], reverse=True)
644
+
645
+ summary = {
646
+ 'totalFiles': len(files),
647
+ 'totalReads': sum(f['readCount'] for f in files),
648
+ }
649
+ return {"files": files, "summary": summary}