aicodeman 1.41.0 → 1.42.0

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 (339) hide show
  1. package/README.md +101 -21
  2. package/dist/attachment-registry.d.ts +2 -0
  3. package/dist/attachment-registry.d.ts.map +1 -1
  4. package/dist/attachment-registry.js +14 -3
  5. package/dist/attachment-registry.js.map +1 -1
  6. package/dist/cli-agent.d.ts +2 -0
  7. package/dist/cli-agent.d.ts.map +1 -1
  8. package/dist/cli-agent.js +13 -9
  9. package/dist/cli-agent.js.map +1 -1
  10. package/dist/cli.d.ts +25 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +97 -11
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config/buffer-limits.d.ts +9 -0
  15. package/dist/config/buffer-limits.d.ts.map +1 -1
  16. package/dist/config/buffer-limits.js +9 -0
  17. package/dist/config/buffer-limits.js.map +1 -1
  18. package/dist/config/cli-registry/schema.d.ts +10 -0
  19. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  20. package/dist/config/cli-registry/schema.js +26 -0
  21. package/dist/config/cli-registry/schema.js.map +1 -1
  22. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  23. package/dist/config/cli-registry/stock.js +89 -6
  24. package/dist/config/cli-registry/stock.js.map +1 -1
  25. package/dist/config/cli-registry/types.d.ts +61 -0
  26. package/dist/config/cli-registry/types.d.ts.map +1 -1
  27. package/dist/folder-listing.d.ts +94 -0
  28. package/dist/folder-listing.d.ts.map +1 -0
  29. package/dist/folder-listing.js +241 -0
  30. package/dist/folder-listing.js.map +1 -0
  31. package/dist/git-worktrees.d.ts +292 -0
  32. package/dist/git-worktrees.d.ts.map +1 -0
  33. package/dist/git-worktrees.js +791 -0
  34. package/dist/git-worktrees.js.map +1 -0
  35. package/dist/hooks-config.d.ts +36 -16
  36. package/dist/hooks-config.d.ts.map +1 -1
  37. package/dist/hooks-config.js +45 -20
  38. package/dist/hooks-config.js.map +1 -1
  39. package/dist/html-view-capabilities.d.ts +54 -0
  40. package/dist/html-view-capabilities.d.ts.map +1 -0
  41. package/dist/html-view-capabilities.js +103 -0
  42. package/dist/html-view-capabilities.js.map +1 -0
  43. package/dist/mcp-server.d.ts +114 -0
  44. package/dist/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp-server.js +918 -0
  46. package/dist/mcp-server.js.map +1 -0
  47. package/dist/mcp-sync.d.ts +5 -0
  48. package/dist/mcp-sync.d.ts.map +1 -1
  49. package/dist/mcp-sync.js +51 -5
  50. package/dist/mcp-sync.js.map +1 -1
  51. package/dist/mux-interface.d.ts +36 -1
  52. package/dist/mux-interface.d.ts.map +1 -1
  53. package/dist/remote-files.d.ts +37 -0
  54. package/dist/remote-files.d.ts.map +1 -1
  55. package/dist/remote-files.js +103 -0
  56. package/dist/remote-files.js.map +1 -1
  57. package/dist/remote-pane-history.d.ts +103 -0
  58. package/dist/remote-pane-history.d.ts.map +1 -0
  59. package/dist/remote-pane-history.js +258 -0
  60. package/dist/remote-pane-history.js.map +1 -0
  61. package/dist/remote-ssh-limiter.d.ts +16 -3
  62. package/dist/remote-ssh-limiter.d.ts.map +1 -1
  63. package/dist/remote-ssh-limiter.js +37 -6
  64. package/dist/remote-ssh-limiter.js.map +1 -1
  65. package/dist/services/unified-session-service.d.ts +8 -0
  66. package/dist/services/unified-session-service.d.ts.map +1 -1
  67. package/dist/services/unified-session-service.js +21 -0
  68. package/dist/services/unified-session-service.js.map +1 -1
  69. package/dist/session-activity.d.ts +51 -0
  70. package/dist/session-activity.d.ts.map +1 -1
  71. package/dist/session-activity.js +62 -0
  72. package/dist/session-activity.js.map +1 -1
  73. package/dist/session-submit-verifier.js +1 -1
  74. package/dist/session.d.ts +90 -2
  75. package/dist/session.d.ts.map +1 -1
  76. package/dist/session.js +247 -9
  77. package/dist/session.js.map +1 -1
  78. package/dist/tab-layout-legacy-order.d.ts.map +1 -1
  79. package/dist/tab-layout-legacy-order.js +72 -13
  80. package/dist/tab-layout-legacy-order.js.map +1 -1
  81. package/dist/tab-layout-service.d.ts +33 -2
  82. package/dist/tab-layout-service.d.ts.map +1 -1
  83. package/dist/tab-layout-service.js +102 -7
  84. package/dist/tab-layout-service.js.map +1 -1
  85. package/dist/tab-layout.d.ts +9 -0
  86. package/dist/tab-layout.d.ts.map +1 -1
  87. package/dist/tab-layout.js +15 -2
  88. package/dist/tab-layout.js.map +1 -1
  89. package/dist/tmux-manager.d.ts +130 -7
  90. package/dist/tmux-manager.d.ts.map +1 -1
  91. package/dist/tmux-manager.js +263 -36
  92. package/dist/tmux-manager.js.map +1 -1
  93. package/dist/types/session.d.ts +53 -0
  94. package/dist/types/session.d.ts.map +1 -1
  95. package/dist/types/session.js.map +1 -1
  96. package/dist/usage-telemetry.d.ts +44 -7
  97. package/dist/usage-telemetry.d.ts.map +1 -1
  98. package/dist/usage-telemetry.js +72 -7
  99. package/dist/usage-telemetry.js.map +1 -1
  100. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  101. package/dist/utils/deepseek-cli-resolver.js +1 -1
  102. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  103. package/dist/web/approval-inbox.d.ts +24 -2
  104. package/dist/web/approval-inbox.d.ts.map +1 -1
  105. package/dist/web/approval-inbox.js +8 -2
  106. package/dist/web/approval-inbox.js.map +1 -1
  107. package/dist/web/middleware/auth.d.ts.map +1 -1
  108. package/dist/web/middleware/auth.js +33 -0
  109. package/dist/web/middleware/auth.js.map +1 -1
  110. package/dist/web/ports/session-port.d.ts +8 -1
  111. package/dist/web/ports/session-port.d.ts.map +1 -1
  112. package/dist/web/public/admin-ui.js.gz +0 -0
  113. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  114. package/dist/web/public/app.f04bb2a5.js +43 -0
  115. package/dist/web/public/app.f04bb2a5.js.br +0 -0
  116. package/dist/web/public/app.f04bb2a5.js.gz +0 -0
  117. package/dist/web/public/approvals-ui.js +4 -0
  118. package/dist/web/public/approvals-ui.js.br +0 -0
  119. package/dist/web/public/approvals-ui.js.gz +0 -0
  120. package/dist/web/public/{constants.bc9c1f63.js → constants.12b0c247.js} +395 -7
  121. package/dist/web/public/constants.12b0c247.js.br +0 -0
  122. package/dist/web/public/constants.12b0c247.js.gz +0 -0
  123. package/dist/web/public/cron-ui.js.gz +0 -0
  124. package/dist/web/public/entrance-animations.js.gz +0 -0
  125. package/dist/web/public/gesture/gesture-codeman.js +112 -6
  126. package/dist/web/public/git-status-ui.js.gz +0 -0
  127. package/dist/web/public/home-sessions.js.gz +0 -0
  128. package/dist/web/public/host-wake-ui.js.gz +0 -0
  129. package/dist/web/public/i18n.06659676.js +2 -0
  130. package/dist/web/public/i18n.06659676.js.br +0 -0
  131. package/dist/web/public/i18n.06659676.js.gz +0 -0
  132. package/dist/web/public/image-input.fcdcb46c.js +462 -0
  133. package/dist/web/public/image-input.fcdcb46c.js.br +0 -0
  134. package/dist/web/public/image-input.fcdcb46c.js.gz +0 -0
  135. package/dist/web/public/index.html +169 -24
  136. package/dist/web/public/index.html.br +0 -0
  137. package/dist/web/public/index.html.gz +0 -0
  138. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  139. package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.b42f539b.js} +124 -44
  140. package/dist/web/public/keyboard-accessory.b42f539b.js.br +0 -0
  141. package/dist/web/public/keyboard-accessory.b42f539b.js.gz +0 -0
  142. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  143. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  144. package/dist/web/public/mobile-overview.js +11 -2
  145. package/dist/web/public/mobile-overview.js.br +0 -0
  146. package/dist/web/public/mobile-overview.js.gz +0 -0
  147. package/dist/web/public/mobile.2dff1580.css +1 -0
  148. package/dist/web/public/mobile.2dff1580.css.br +0 -0
  149. package/dist/web/public/mobile.2dff1580.css.gz +0 -0
  150. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  151. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  152. package/dist/web/public/{panels-ui.71846fcb.js → panels-ui.73ec0137.js} +87 -87
  153. package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
  154. package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
  155. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  156. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  157. package/dist/web/public/readmymind-ui.js.gz +0 -0
  158. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  159. package/dist/web/public/{respawn-ui.ff0dae4c.js → respawn-ui.d7adcf6a.js} +1 -1
  160. package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
  161. package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
  162. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  163. package/dist/web/public/session-lineage.js.gz +0 -0
  164. package/dist/web/public/session-ui.d52ff6aa.js +100 -0
  165. package/dist/web/public/session-ui.d52ff6aa.js.br +0 -0
  166. package/dist/web/public/session-ui.d52ff6aa.js.gz +0 -0
  167. package/dist/web/public/settings-ui.3a22bd2a.js +97 -0
  168. package/dist/web/public/settings-ui.3a22bd2a.js.br +0 -0
  169. package/dist/web/public/settings-ui.3a22bd2a.js.gz +0 -0
  170. package/dist/web/public/spreadsheet-preview-worker.js +11 -0
  171. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  172. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  173. package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
  174. package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
  175. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  176. package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
  177. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  178. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  179. package/dist/web/public/styles.ba6a9b84.css +1 -0
  180. package/dist/web/public/styles.ba6a9b84.css.br +0 -0
  181. package/dist/web/public/styles.ba6a9b84.css.gz +0 -0
  182. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  183. package/dist/web/public/sw.js +6 -2
  184. package/dist/web/public/sw.js.br +0 -0
  185. package/dist/web/public/sw.js.gz +0 -0
  186. package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
  187. package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
  188. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  189. package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
  190. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  191. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  192. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  193. package/dist/web/public/terminal-split.js.gz +0 -0
  194. package/dist/web/public/terminal-tile.js +45 -24
  195. package/dist/web/public/terminal-tile.js.br +0 -0
  196. package/dist/web/public/terminal-tile.js.gz +0 -0
  197. package/dist/web/public/terminal-ui.0a7ccc29.js +2 -0
  198. package/dist/web/public/terminal-ui.0a7ccc29.js.br +0 -0
  199. package/dist/web/public/terminal-ui.0a7ccc29.js.gz +0 -0
  200. package/dist/web/public/tile-grid.js +439 -18
  201. package/dist/web/public/tile-grid.js.br +0 -0
  202. package/dist/web/public/tile-grid.js.gz +0 -0
  203. package/dist/web/public/ultracode-panel.js.gz +0 -0
  204. package/dist/web/public/ultracode-windows.js.gz +0 -0
  205. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  206. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  207. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  208. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  209. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  210. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  211. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  212. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  213. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  214. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  215. package/dist/web/public/vendor/xterm.css.gz +0 -0
  216. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  217. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  218. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  219. package/dist/web/public/webview-tabs.js +12 -0
  220. package/dist/web/public/webview-tabs.js.br +0 -0
  221. package/dist/web/public/webview-tabs.js.gz +0 -0
  222. package/dist/web/public/whats-new.json +13 -0
  223. package/dist/web/route-helpers.d.ts +8 -0
  224. package/dist/web/route-helpers.d.ts.map +1 -1
  225. package/dist/web/route-helpers.js +8 -0
  226. package/dist/web/route-helpers.js.map +1 -1
  227. package/dist/web/routes/admin-routes.d.ts.map +1 -1
  228. package/dist/web/routes/admin-routes.js +4 -1
  229. package/dist/web/routes/admin-routes.js.map +1 -1
  230. package/dist/web/routes/approval-routes.d.ts.map +1 -1
  231. package/dist/web/routes/approval-routes.js +12 -10
  232. package/dist/web/routes/approval-routes.js.map +1 -1
  233. package/dist/web/routes/case-routes.d.ts.map +1 -1
  234. package/dist/web/routes/case-routes.js +10 -3
  235. package/dist/web/routes/case-routes.js.map +1 -1
  236. package/dist/web/routes/file-routes.d.ts +46 -1
  237. package/dist/web/routes/file-routes.d.ts.map +1 -1
  238. package/dist/web/routes/file-routes.js +214 -27
  239. package/dist/web/routes/file-routes.js.map +1 -1
  240. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  241. package/dist/web/routes/hook-event-routes.js +8 -1
  242. package/dist/web/routes/hook-event-routes.js.map +1 -1
  243. package/dist/web/routes/html-view-routes.d.ts +60 -0
  244. package/dist/web/routes/html-view-routes.d.ts.map +1 -0
  245. package/dist/web/routes/html-view-routes.js +288 -0
  246. package/dist/web/routes/html-view-routes.js.map +1 -0
  247. package/dist/web/routes/index.d.ts +2 -0
  248. package/dist/web/routes/index.d.ts.map +1 -1
  249. package/dist/web/routes/index.js +2 -0
  250. package/dist/web/routes/index.js.map +1 -1
  251. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  252. package/dist/web/routes/ralph-routes.js +19 -14
  253. package/dist/web/routes/ralph-routes.js.map +1 -1
  254. package/dist/web/routes/reboot-restore-routes.js +2 -2
  255. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  256. package/dist/web/routes/session-routes.d.ts +8 -5
  257. package/dist/web/routes/session-routes.d.ts.map +1 -1
  258. package/dist/web/routes/session-routes.js +462 -109
  259. package/dist/web/routes/session-routes.js.map +1 -1
  260. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  261. package/dist/web/routes/status-telemetry-routes.js +24 -6
  262. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  263. package/dist/web/routes/system-routes.d.ts +9 -0
  264. package/dist/web/routes/system-routes.d.ts.map +1 -1
  265. package/dist/web/routes/system-routes.js +91 -12
  266. package/dist/web/routes/system-routes.js.map +1 -1
  267. package/dist/web/routes/worktree-routes.d.ts +52 -0
  268. package/dist/web/routes/worktree-routes.d.ts.map +1 -0
  269. package/dist/web/routes/worktree-routes.js +220 -0
  270. package/dist/web/routes/worktree-routes.js.map +1 -0
  271. package/dist/web/schemas.d.ts +45 -0
  272. package/dist/web/schemas.d.ts.map +1 -1
  273. package/dist/web/schemas.js +105 -0
  274. package/dist/web/schemas.js.map +1 -1
  275. package/dist/web/screen-blocked.d.ts +80 -0
  276. package/dist/web/screen-blocked.d.ts.map +1 -0
  277. package/dist/web/screen-blocked.js +106 -0
  278. package/dist/web/screen-blocked.js.map +1 -0
  279. package/dist/web/server.d.ts +6 -1
  280. package/dist/web/server.d.ts.map +1 -1
  281. package/dist/web/server.js +73 -14
  282. package/dist/web/server.js.map +1 -1
  283. package/dist/web/session-listener-wiring.d.ts +1 -0
  284. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  285. package/dist/web/session-listener-wiring.js +20 -0
  286. package/dist/web/session-listener-wiring.js.map +1 -1
  287. package/dist/web/session-wait-registry.d.ts +27 -9
  288. package/dist/web/session-wait-registry.d.ts.map +1 -1
  289. package/dist/web/session-wait-registry.js +36 -13
  290. package/dist/web/session-wait-registry.js.map +1 -1
  291. package/dist/web/sse-events.d.ts +12 -4
  292. package/dist/web/sse-events.d.ts.map +1 -1
  293. package/dist/web/sse-events.js +12 -4
  294. package/dist/web/sse-events.js.map +1 -1
  295. package/package.json +3 -2
  296. package/skills/codeman/SKILL.md +47 -27
  297. package/skills/codeman/preamble.sh +14 -11
  298. package/skills/codeman/reference/endpoints.md +1 -1
  299. package/skills/codeman/reference/recipes.md +12 -9
  300. package/skills/codeman/reference/verbs.md +3 -1
  301. package/dist/web/public/app.6f8552e1.js +0 -43
  302. package/dist/web/public/app.6f8552e1.js.br +0 -0
  303. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  304. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  305. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  306. package/dist/web/public/i18n.a9840ea2.js +0 -2
  307. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  308. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  309. package/dist/web/public/image-input.e00471c6.js +0 -283
  310. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  311. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  312. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  313. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  314. package/dist/web/public/mobile.111b187d.css +0 -1
  315. package/dist/web/public/mobile.111b187d.css.br +0 -0
  316. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  317. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  318. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  319. package/dist/web/public/respawn-ui.ff0dae4c.js.br +0 -0
  320. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  321. package/dist/web/public/session-ui.f731602c.js +0 -100
  322. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  323. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  324. package/dist/web/public/settings-ui.4e33390f.js +0 -97
  325. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  326. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  327. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  328. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  329. package/dist/web/public/styles.81a72906.css +0 -1
  330. package/dist/web/public/styles.81a72906.css.br +0 -0
  331. package/dist/web/public/styles.81a72906.css.gz +0 -0
  332. package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
  333. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  334. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  335. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  336. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  337. package/dist/web/public/terminal-ui.b7267005.js +0 -2
  338. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  339. package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
@@ -8,12 +8,15 @@ import { homedir } from 'node:os';
8
8
  import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
9
9
  import { execFile } from 'node:child_process';
10
10
  import fs from 'node:fs/promises';
11
+ import { finished } from 'node:stream/promises';
11
12
  import { randomBytes, randomUUID } from 'node:crypto';
13
+ import { isUtf8 } from 'node:buffer';
12
14
  import { performance } from 'node:perf_hooks';
13
15
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
14
16
  import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
15
17
  import { SseEvent } from '../sse-events.js';
16
18
  import { webviewCapabilities } from '../../webview-capabilities.js';
19
+ import { htmlViewCapabilities } from '../../html-view-capabilities.js';
17
20
  import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
18
21
  import { readCustomModelHosts } from '../../custom-model-hosts.js';
19
22
  import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
@@ -34,7 +37,7 @@ import { applyLaunchDefaults } from '../launch-defaults.js';
34
37
  import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
35
38
  import { isMultiUserMode } from '../../config/multiuser.js';
36
39
  import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
37
- import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
40
+ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, agentSkillDir, agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
38
41
  import { generateClaudeMd } from '../../templates/claude-md.js';
39
42
  import { imageWatcher } from '../../image-watcher.js';
40
43
  import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
@@ -43,10 +46,13 @@ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified
43
46
  import { buildHistorySessionIndexItems, setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js';
44
47
  import { RunSummaryTracker } from '../../run-summary.js';
45
48
  import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
46
- import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
49
+ import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
50
+ import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
47
51
  import { dataPath, getDataDir } from '../../config/instance.js';
48
52
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
53
+ import { fetchRemoteScrollback } from '../../remote-pane-history.js';
49
54
  import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
55
+ import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
50
56
  import { LRUMap } from '../../utils/lru-map.js';
51
57
  import { describeUnknownPath, probePathKind } from '../../utils/index.js';
52
58
  import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
@@ -179,19 +185,37 @@ export function stripInkRedrawBloat(buffer) {
179
185
  return parts.join('');
180
186
  }
181
187
  /**
182
- * Validate image bytes against a declared extension. Sniffs the first ~12 bytes
183
- * for a known magic-number signature. Defends against polyglots (e.g. HTML or
184
- * SVG disguised under a `Content-Type: image/png` header) and against simple
185
- * extension-only spoofing — both the multipart filename and the Content-Type
186
- * are attacker-controlled, the raw bytes are not.
188
+ * The text half of the document allowlist: these are judged on their encoding
189
+ * (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
190
+ */
191
+ const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
192
+ /**
193
+ * Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
194
+ * for a known magic-number signature (a text file is instead required to be
195
+ * UTF-8 without a NUL byte, the one check plain text allows). Defends against
196
+ * polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
197
+ * header) and against simple extension-only spoofing — both the multipart
198
+ * filename and the Content-Type are attacker-controlled, the raw bytes are not.
199
+ * Fail-closed: an extension this function does not know is never a match.
187
200
  *
188
201
  * Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
189
202
  */
190
203
  export function imageMagicMatchesExt(data, ext) {
204
+ if (TEXT_UPLOAD_EXTS.has(ext))
205
+ return data.length > 0 && !data.includes(0) && isUtf8(data);
191
206
  if (data.length < 12)
192
207
  return false;
193
208
  const u32be = (off) => data.readUInt32BE(off);
194
209
  switch (ext) {
210
+ case '.pdf':
211
+ // "%PDF-"
212
+ return u32be(0) === 0x25504446 && data[4] === 0x2d;
213
+ case '.docx':
214
+ case '.pptx':
215
+ case '.xlsx':
216
+ case '.zip':
217
+ // PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
218
+ return u32be(0) === 0x504b0304;
195
219
  case '.png':
196
220
  return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
197
221
  case '.jpg':
@@ -219,6 +243,19 @@ export function imageMagicMatchesExt(data, ext) {
219
243
  const brand = data.subarray(8, 12).toString('ascii');
220
244
  return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
221
245
  }
246
+ case '.mp4':
247
+ case '.m4v':
248
+ case '.mov':
249
+ // ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
250
+ // vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
251
+ // QuickTime file can open on an atom other than ftyp.
252
+ return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
253
+ case '.webm':
254
+ // EBML header (Matroska's WebM subset)
255
+ return u32be(0) === 0x1a45dfa3;
256
+ case '.ogv':
257
+ // Ogg container ("OggS")
258
+ return u32be(0) === 0x4f676753;
222
259
  default:
223
260
  return false;
224
261
  }
@@ -235,7 +272,9 @@ export function imageMagicMatchesExt(data, ext) {
235
272
  * touched, and a file already there is theirs and stays as it is.
236
273
  */
237
274
  async function ensureUploadDir(workingDir) {
238
- // workingDir is guaranteed to exist (live session).
275
+ // workingDir exists (live session) and the caller bounded-probed its mount, so
276
+ // the lstat/mkdir below normally start on a reachable path; a mount that dies
277
+ // after that probe is the same residual race POST /api/sessions accepts.
239
278
  const uploadDir = join(workingDir, UPLOADS_DIR);
240
279
  try {
241
280
  const dirStat = await fs.lstat(uploadDir);
@@ -275,37 +314,74 @@ async function ensureUploadDir(workingDir) {
275
314
  }
276
315
  return uploadDir;
277
316
  }
278
- // Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
279
- // Bucket map entries are pruned when they drift > 1h stale to bound memory
280
- // against a flood of unique IP keys.
317
+ /**
318
+ * The part of an uploaded document's name that survives into the saved name,
319
+ * so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
320
+ * Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
321
+ * result is capped at 40 chars; nothing in it can be a path separator or a
322
+ * second extension that matters (the allowlisted extension is appended after).
323
+ */
324
+ function sanitizeUploadStem(filename) {
325
+ const stem = basename(filename).replace(/\.[^.]*$/, '');
326
+ return stem
327
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
328
+ .replace(/^[-.]+|[-.]+$/g, '')
329
+ .slice(0, 40);
330
+ }
331
+ /** Open a fresh, uniquely named paste file for writing. */
332
+ async function openPasteFile(imageDir, ext, stem = '') {
333
+ // Date.now() collides on same-ms uploads from two tabs (last-write wins
334
+ // silently). Append 8 hex chars so concurrent pastes get distinct names.
335
+ // The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
336
+ const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
337
+ const filepath = join(imageDir, filename);
338
+ // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
339
+ // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
340
+ const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
341
+ return { filename, filepath, fh };
342
+ }
343
+ // Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
344
+ // a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
345
+ // 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
346
+ // bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
347
+ // memory against a flood of unique IP keys.
281
348
  const PASTE_RATE_TOKENS = 30;
282
- const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
349
+ const VIDEO_RATE_TOKENS = 3;
283
350
  const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
284
351
  const PASTE_BUCKET_GC_THRESHOLD = 1000;
285
352
  const pasteRateBuckets = new Map();
286
- export function consumePasteToken(key, now = Date.now()) {
287
- if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
288
- for (const [k, b] of pasteRateBuckets) {
353
+ const videoRateBuckets = new Map();
354
+ function takeToken(buckets, key, capacity, now) {
355
+ if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
356
+ for (const [k, b] of buckets) {
289
357
  if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
290
- pasteRateBuckets.delete(k);
358
+ buckets.delete(k);
291
359
  }
292
360
  }
293
- let b = pasteRateBuckets.get(key);
361
+ let b = buckets.get(key);
294
362
  if (!b) {
295
- b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
296
- pasteRateBuckets.set(key, b);
363
+ b = { tokens: capacity, lastRefill: now };
364
+ buckets.set(key, b);
297
365
  }
298
- const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
299
- b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
366
+ b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
300
367
  b.lastRefill = now;
301
368
  if (b.tokens < 1)
302
369
  return false;
303
370
  b.tokens -= 1;
304
371
  return true;
305
372
  }
373
+ export function consumePasteToken(key, now = Date.now()) {
374
+ return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
375
+ }
376
+ // Videos stream to disk under a far larger per-file cap, so they draw on their own,
377
+ // much lower bucket (see the comment above the constants).
378
+ export function consumeVideoToken(key, now = Date.now()) {
379
+ return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
380
+ }
306
381
  // Test hook: reset between runs.
307
382
  export function _resetPasteRateBuckets() {
308
383
  pasteRateBuckets.clear();
384
+ videoRateBuckets.clear();
309
385
  }
310
386
  /**
311
387
  * Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
@@ -464,6 +540,23 @@ function currentSignalFor(session, dead) {
464
540
  return 'exit';
465
541
  return signalForStatus(session.status);
466
542
  }
543
+ /**
544
+ * `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
545
+ * its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
546
+ * hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
547
+ * history, but this one is a level the server is still looking at, so a caller that
548
+ * asks after the dialog appeared is answered at once instead of waiting out its timeout
549
+ * for an edge that already passed. Only when `blocked` was asked for: such a pane is also
550
+ * `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
551
+ * `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
552
+ * session's current state.
553
+ */
554
+ function currentSignalForWait(session, dead, until) {
555
+ const signal = currentSignalFor(session, dead);
556
+ if (signal !== 'exit' && session.screenBlocked && until.includes('blocked'))
557
+ return 'blocked';
558
+ return signal;
559
+ }
467
560
  // ── Worker liveness for tmux-backed sessions ────────────────────────────────
468
561
  //
469
562
  // `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -626,8 +719,8 @@ function abortOnClientHangUp(reply) {
626
719
  * Injection is best-effort and stays that way: neither a refusal nor a thrown error
627
720
  * may fail the create.
628
721
  */
629
- async function injectAgentSkill(casePath) {
630
- const skillDir = join(casePath, '.claude', 'skills', 'codeman');
722
+ async function injectAgentSkill(casePath, dir) {
723
+ const skillDir = agentSkillDirFor(casePath, dir);
631
724
  try {
632
725
  // Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
633
726
  // written once by `codeman skill install`) over the case copy injected below, so a
@@ -636,7 +729,7 @@ async function injectAgentSkill(casePath) {
636
729
  // current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
637
730
  // since a foreign user copy is the user's own authored skill, not a config error.
638
731
  await refreshUserAgentSkill();
639
- const result = await applyAgentSkill(casePath, true);
732
+ const result = await applyAgentSkill(casePath, true, dir);
640
733
  if (result === 'foreign') {
641
734
  console.warn(`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`);
642
735
  }
@@ -759,6 +852,7 @@ options = {}) {
759
852
  // logout has to retire them too or every dashboard URL opened during this
760
853
  // login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
761
854
  webviewCapabilities.revokeOwner(ownerFor(req));
855
+ htmlViewCapabilities.revokeOwner(ownerFor(req));
762
856
  reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
763
857
  return {};
764
858
  });
@@ -920,13 +1014,6 @@ options = {}) {
920
1014
  // materializing in ~/.claude/settings.local.json was never asked for.
921
1015
  if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
922
1016
  await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
923
- // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
924
- // .claude rationale as the statusLine above: a create must never remove the
925
- // skill from under other live sessions in the repo. Marker-guarded, so a
926
- // user's own skills/codeman is never touched.
927
- if (await ctx.getAgentSkillEnabled()) {
928
- await injectAgentSkill(workingDir);
929
- }
930
1017
  }
931
1018
  // Refuse up front if the requested CLI cannot start, rather than spawning a pane that
932
1019
  // dies on `command not found`. The message comes from the resolver, so it names where
@@ -945,6 +1032,17 @@ options = {}) {
945
1032
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
946
1033
  }
947
1034
  }
1035
+ // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
1036
+ // workspace rationale as the statusLine above: a create must never remove the
1037
+ // skill from under other live sessions in the repo. Marker-guarded, so a user's
1038
+ // own copy is never touched. Every CLI that takes the skill gets it in the directory
1039
+ // that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
1040
+ // conditions as the hooks above, and only once the launch pre-flight passed, so a
1041
+ // CLI that cannot start leaves nothing in the workspace.
1042
+ const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
1043
+ if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
1044
+ await injectAgentSkill(workingDir, createSkillDir);
1045
+ }
948
1046
  // Pre-validate resumeSessionId: check that the conversation file actually exists
949
1047
  // in Claude's projects directory. If not, skip resume to avoid confusing
950
1048
  // "No conversation found" errors from Claude CLI.
@@ -1033,13 +1131,16 @@ options = {}) {
1033
1131
  owner,
1034
1132
  parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
1035
1133
  });
1036
- await ctx.addSession(session);
1134
+ const tabLayout = body.tabGroupId
1135
+ ? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
1136
+ : await ctx.addSession(session);
1037
1137
  ctx.store.incrementSessionsCreated();
1038
1138
  ctx.persistSessionState(session);
1039
1139
  await ctx.setupSessionListeners(session);
1040
1140
  // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
1041
- // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
1042
- if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
1141
+ // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
1142
+ // skill; best-effort.
1143
+ if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
1043
1144
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
1044
1145
  }
1045
1146
  getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
@@ -1047,7 +1148,9 @@ options = {}) {
1047
1148
  // Avoids serializing 2-3MB of terminal+text buffers per session creation.
1048
1149
  const lightState = ctx.getSessionStateWithRespawn(session);
1049
1150
  ctx.broadcast(SseEvent.SessionCreated, lightState);
1050
- return { session: lightState };
1151
+ // A grouped creation also returns the layout that placed it, so the browser
1152
+ // draws the tab inside its group at once instead of after a re-read.
1153
+ return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
1051
1154
  });
1052
1155
  // ========== Rename Session ==========
1053
1156
  app.put('/api/sessions/:id/name', async (req) => {
@@ -1595,7 +1698,9 @@ options = {}) {
1595
1698
  // A DUPLICATE has no new turn coming, so it answers from the current state
1596
1699
  // instead of blocking for a transition that already happened.
1597
1700
  requireTransition: !duplicate,
1598
- currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
1701
+ // The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
1702
+ // delivery raised is a level, and a retry asking for `blocked` must find it.
1703
+ currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
1599
1704
  });
1600
1705
  }
1601
1706
  catch (err) {
@@ -1612,7 +1717,7 @@ options = {}) {
1612
1717
  }
1613
1718
  const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => { };
1614
1719
  // Write input to PTY. Direct write is synchronous; writeViaMux
1615
- // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
1720
+ // (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
1616
1721
  // Every write here is `fromUser`: this route carries a person's prompt, or an
1617
1722
  // agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
1618
1723
  // approvals write through the session directly and never say so).
@@ -1634,7 +1739,7 @@ options = {}) {
1634
1739
  // A plain prompt (`<text>\r`) goes through the mux even when the caller did not
1635
1740
  // ask for it: written straight into the pane it arrives as one burst, and Claude
1636
1741
  // Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
1637
- // sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
1742
+ // sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
1638
1743
  // separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
1639
1744
  // raw write for a caller that really wants it.
1640
1745
  const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
@@ -1782,7 +1887,7 @@ options = {}) {
1782
1887
  abortSignal: abort.signal,
1783
1888
  requireTransition: query.fresh === '1' || query.fresh === 'true',
1784
1889
  // Read BEFORE awaiting: this is the state the caller is asking about.
1785
- currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
1890
+ currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
1786
1891
  });
1787
1892
  return {
1788
1893
  success: true,
@@ -2672,6 +2777,8 @@ options = {}) {
2672
2777
  // Query params:
2673
2778
  // tail=<bytes> - Only return last N bytes (faster initial load)
2674
2779
  // full=1 - Explicitly request the entire tmux scrollback (COD-47)
2780
+ // alt=recording - With full=1: if the pane is in the alternate screen, load
2781
+ // the visible frame over the byte recording instead
2675
2782
  app.get('/api/sessions/:id/terminal', async (req, reply) => {
2676
2783
  const routeStartedAt = performance.now();
2677
2784
  const { id } = req.params;
@@ -2682,7 +2789,7 @@ options = {}) {
2682
2789
  // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
2683
2790
  // no-param callers — keep the fast visible-frame capture.
2684
2791
  const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
2685
- const isFullReload = query.full === '1' || query.full === 'true';
2792
+ let isFullReload = query.full === '1' || query.full === 'true';
2686
2793
  const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
2687
2794
  // Prepend the live tmux pane buffer so tab-switch replay shows the current
2688
2795
  // on-screen frame, not just the accumulated byte history. This matters for
@@ -2699,16 +2806,48 @@ options = {}) {
2699
2806
  // for. See PaneCaptureOptions.capturedGeometry.
2700
2807
  // `lines` bounds only the history a FULL capture reads; the visible-frame
2701
2808
  // path reads no history and is untouched by it.
2702
- const captureOpts = isFullReload
2809
+ let captureOpts = isFullReload
2703
2810
  ? {
2704
2811
  fullHistory: true,
2705
2812
  historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
2706
2813
  maxCaptureBytes: terminalBufferMaxBytes,
2707
2814
  }
2708
2815
  : {};
2709
- const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
2710
- ? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
2816
+ // A remote session's local pane only sees the remote tmux client's repaints,
2817
+ // so its own scrollback is a fraction of the real one: read that from the
2818
+ // remote tmux and splice it above the local frame. Null (host unreachable,
2819
+ // no remote session, back-off) keeps the local capture as before.
2820
+ if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
2821
+ const remoteScrollback = await fetchRemoteScrollback(session.remote, session.id, captureOpts.historyLimitLines, terminalBufferMaxBytes);
2822
+ if (remoteScrollback !== null) {
2823
+ captureOpts.scrollbackOverride = remoteScrollback.scrollback;
2824
+ captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
2825
+ }
2826
+ }
2827
+ const capture = (opts) => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
2828
+ ? ctx.mux.captureActivePaneBuffer(muxName, opts)
2711
2829
  : null;
2830
+ let liveMuxBuffer = capture(captureOpts);
2831
+ // tmux adds no history while a pane is in the alternate screen, and a
2832
+ // nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
2833
+ // in a shell) holds its pane there for good, so the full capture above is
2834
+ // one screen plus whatever came before. The byte recording still carries
2835
+ // the scrollback, so a caller that opts in (a shell's automatic load,
2836
+ // `alt=recording`) gets the old visible-frame load over it, tailed, rather
2837
+ // than losing everything above the screen. `alternate_on` cannot tell a
2838
+ // nested tmux from vim or less, so those take this path too while open:
2839
+ // the load the shell had before tmux-rendered loads existed. Not when the
2840
+ // history came from the remote tmux (`scrollbackOverride`): a remote pane is
2841
+ // in the alternate screen only because it hosts that tmux's client, and
2842
+ // the fallback would throw the remote scrollback away.
2843
+ if (isFullReload &&
2844
+ query.alt === 'recording' &&
2845
+ captureOpts.scrollbackOverride === undefined &&
2846
+ captureOpts.capturedAlternateOn === true) {
2847
+ isFullReload = false;
2848
+ captureOpts = {};
2849
+ liveMuxBuffer = capture(captureOpts);
2850
+ }
2712
2851
  const captureFinishedAt = performance.now();
2713
2852
  const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
2714
2853
  const source = hasLiveMuxBuffer
@@ -2749,6 +2888,29 @@ options = {}) {
2749
2888
  // why the UI could only ever say "truncated for performance".
2750
2889
  let truncationReason = null;
2751
2890
  let cleanBuffer;
2891
+ // A `lines=` bound shallower than tmux's history left the older lines in
2892
+ // tmux: an intentional partial read, so 'tail', and a later pull with a
2893
+ // deeper (or no) bound recovers them. Without this a bounded window looks
2894
+ // like the whole history to the client. The byte cap below still wins.
2895
+ // Only a bound the CLIENT set and below the configured ceiling counts:
2896
+ // without `lines=` the bound is the ceiling itself, and on tmux before 3.7
2897
+ // a live pane can hold more than a lowered ceiling, which no request can
2898
+ // read, so calling that a recoverable cut would offer a button that can
2899
+ // never return more.
2900
+ // `historyLinesCut` tells the client this happened, apart from any byte
2901
+ // cut below: `fullSize` counts only what tmux returned, so the bytes a
2902
+ // later `tail` cut are not all that is left, and the client must not name
2903
+ // them as the remainder.
2904
+ const capturedHistoryLines = captureOpts.capturedHistoryLines;
2905
+ const historyLinesCut = isFullCapture &&
2906
+ captureOpts.historyLimitLines !== undefined &&
2907
+ captureOpts.historyLimitLines < tmuxHistoryLimit &&
2908
+ capturedHistoryLines !== undefined &&
2909
+ capturedHistoryLines > captureOpts.historyLimitLines;
2910
+ if (historyLinesCut) {
2911
+ truncated = true;
2912
+ truncationReason = 'tail';
2913
+ }
2752
2914
  // Cap the payload EARLY — before the regex normalization passes below run
2753
2915
  // over it. A full-history tmux capture can be tens of MB of scrollback;
2754
2916
  // normalizing all of it would stall the event loop only to discard most
@@ -2828,6 +2990,9 @@ options = {}) {
2828
2990
  fullSize,
2829
2991
  truncated,
2830
2992
  truncationReason,
2993
+ // The `lines=` bound left older lines in tmux (see above): the remainder
2994
+ // is more than `fullSize - retainedBytes`, by an amount unknown here.
2995
+ historyLinesCut,
2831
2996
  // `retainedBytes` is what this response actually carries; `fullSize` is
2832
2997
  // what existed before the cut. The gap is what the indicator reports.
2833
2998
  retainedBytes: cleanBuffer.length,
@@ -2851,6 +3016,15 @@ options = {}) {
2851
3016
  // damage that does not exist.
2852
3017
  captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
2853
3018
  captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
3019
+ // Rows tmux holds above the visible frame, which is the most a `full=1`
3020
+ // pull can add. `truncated` measures the BYTE stream, and for a pane that
3021
+ // keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
3022
+ // a tail cut drops are old repaints that no request can bring back, so a
3023
+ // client must not offer to load them. Absent when the pane was not read,
3024
+ // and for a remote session: its local pane hosts the remote tmux client in
3025
+ // the alternate screen, so it always reports 0, while a `full=1` load reads
3026
+ // the real scrollback from the remote tmux (`fetchRemoteScrollback`).
3027
+ paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
2854
3028
  };
2855
3029
  });
2856
3030
  // ═══════════════════════════════════════════════════════════════
@@ -3053,7 +3227,7 @@ options = {}) {
3053
3227
  const capMsg = sessionCapacityMessage(ctx.sessions, owner);
3054
3228
  if (capMsg)
3055
3229
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
3056
- const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, } = parseBody(QuickStartSchema, req.body);
3230
+ const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, worktree, } = parseBody(QuickStartSchema, req.body);
3057
3231
  // Resolved ONCE here: the same value labels a case directory this request creates
3058
3232
  // (agent-case-marker.ts) and draws the tab lineage line on the session below.
3059
3233
  const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
@@ -3079,6 +3253,13 @@ options = {}) {
3079
3253
  const dockerCase = remoteCase
3080
3254
  ? undefined
3081
3255
  : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
3256
+ // Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
3257
+ // container is touched, since neither branch below could honour it.
3258
+ if (worktree && (remoteCase || dockerCase)) {
3259
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, remoteCase
3260
+ ? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
3261
+ : 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write');
3262
+ }
3082
3263
  if (remoteCase) {
3083
3264
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
3084
3265
  if (!host)
@@ -3283,6 +3464,28 @@ options = {}) {
3283
3464
  if (localCaseState === 'unknown') {
3284
3465
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true }));
3285
3466
  }
3467
+ // Where the session runs: the case folder, or one of its git worktrees. A worktree is only
3468
+ // ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
3469
+ // exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
3470
+ // one, every path component a real directory (src/git-worktrees.ts).
3471
+ let workspacePath = resolvedCasePath;
3472
+ if (worktree) {
3473
+ if (localCaseState === 'absent') {
3474
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
3475
+ }
3476
+ if (localCaseState !== 'directory') {
3477
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
3478
+ }
3479
+ const caseRepo = await resolveCaseRepository(resolvedCasePath, {
3480
+ dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
3481
+ });
3482
+ if (!caseRepo.ok)
3483
+ return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
3484
+ const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
3485
+ if (!managed.ok)
3486
+ return createErrorResponse(ApiErrorCode[managed.code], managed.message);
3487
+ workspacePath = managed.path;
3488
+ }
3286
3489
  // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
3287
3490
  // non-docker cases — docker workspaces are scaffolded in their own block below)
3288
3491
  if (localCaseState === 'absent') {
@@ -3334,19 +3537,21 @@ options = {}) {
3334
3537
  // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
3335
3538
  // doesn't exist on the local filesystem.
3336
3539
  if (getCli(mode)?.capabilities.hooks === 'always') {
3337
- await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
3540
+ await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
3338
3541
  }
3339
3542
  else {
3340
- await refreshStaleCodemanHooks(resolvedCasePath).catch(() => { });
3543
+ await refreshStaleCodemanHooks(workspacePath).catch(() => { });
3341
3544
  }
3342
3545
  }
3343
3546
  // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
3344
- // marker-guarded (a user's own skills/codeman is never touched). Claude mode only
3345
- // (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
3346
- // casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
3347
- // real host dir and the skill crosses the bind mount like the rest of `.claude/`.
3348
- if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
3349
- await injectAgentSkill(resolvedCasePath);
3547
+ // marker-guarded (a user's own copy is never touched). Each CLI that takes the
3548
+ // skill gets it in the directory it scans (`capabilities.agentSkillDir`:
3549
+ // `.claude/skills` for claude, `.agents/skills` for most others); skipped for
3550
+ // remote cases, whose casePath lives on another host. Docker cases qualify:
3551
+ // hostWorkspacePath is a real host dir and the skill crosses the bind mount.
3552
+ const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
3553
+ if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
3554
+ await injectAgentSkill(workspacePath, quickStartSkillDir);
3350
3555
  }
3351
3556
  // Docker cases: the workspace is a REAL host dir bind-mounted into the container.
3352
3557
  // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
@@ -3379,13 +3584,13 @@ options = {}) {
3379
3584
  // docker — the docker workspace is a real host dir, so the settings file crosses
3380
3585
  // the bind mount and the in-container claude reads it). Remote was rejected above.
3381
3586
  if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
3382
- await updateCaseModel(resolvedCasePath, modelOverride || null);
3587
+ await updateCaseModel(workspacePath, modelOverride || null);
3383
3588
  }
3384
3589
  // Strip stale disk entries for keys this request is actively setting (Claude only —
3385
3590
  // see POST /api/sessions for full rationale).
3386
3591
  // Same chain, same replacement as the create path above: byte-identical, drift-proof.
3387
3592
  if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
3388
- await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
3593
+ await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
3389
3594
  }
3390
3595
  // Create a new session with the case as working directory
3391
3596
  // Apply global Nice priority config and model config from settings
@@ -3421,7 +3626,7 @@ options = {}) {
3421
3626
  const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig, antigravityConfig: qsGatedAntigravityConfig, piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner(owner, qsLaunchConfigs.codexConfig, qsLaunchConfigs.geminiConfig, qsLaunchConfigs.antigravityConfig, qsLaunchConfigs.piConfig, qsLaunchConfigs.grokConfig, qsLaunchConfigs.deepSeekConfig);
3422
3627
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
3423
3628
  const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
3424
- const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
3629
+ const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
3425
3630
  // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
3426
3631
  // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
3427
3632
  // model, still what an ALREADY-RUNNING session uses to switch later): computing the
@@ -3505,7 +3710,7 @@ options = {}) {
3505
3710
  // with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
3506
3711
  // exactly this reason.
3507
3712
  qsCustomModelSessionId = randomUUID();
3508
- const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, resolvedCasePath);
3713
+ const cmApplied = applyCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, qsCustomModelSessionId, cmContextLength, workspacePath);
3509
3714
  if (!cmApplied) {
3510
3715
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
3511
3716
  }
@@ -3537,7 +3742,7 @@ options = {}) {
3537
3742
  }
3538
3743
  const qsSessionOptions = {
3539
3744
  id: qsCustomModelSessionId,
3540
- workingDir: resolvedCasePath,
3745
+ workingDir: workspacePath,
3541
3746
  name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
3542
3747
  mux: ctx.mux,
3543
3748
  useMux: true,
@@ -3594,19 +3799,20 @@ options = {}) {
3594
3799
  // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
3595
3800
  // so the initial state already has the phrase configured (only if globally enabled)
3596
3801
  if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
3597
- autoConfigureRalph(session, resolvedCasePath, ctx);
3802
+ autoConfigureRalph(session, workspacePath, ctx);
3598
3803
  if (!session.ralphTracker.enabled) {
3599
3804
  session.ralphTracker.enable();
3600
3805
  session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
3601
3806
  }
3602
3807
  }
3603
- await ctx.addSession(session);
3808
+ const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
3604
3809
  ctx.store.incrementSessionsCreated();
3605
3810
  ctx.persistSessionState(session);
3606
3811
  await ctx.setupSessionListeners(session);
3607
3812
  // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
3608
- // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
3609
- if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
3813
+ // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
3814
+ // skill; best-effort.
3815
+ if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
3610
3816
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
3611
3817
  }
3612
3818
  getLifecycleLog().log({
@@ -3648,37 +3854,44 @@ options = {}) {
3648
3854
  if (docker && mode === 'claude') {
3649
3855
  void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
3650
3856
  }
3651
- // Save lastUsedCase to settings for TUI/web sync
3652
- try {
3653
- const settingsFilePath = SETTINGS_PATH;
3654
- let settings = {};
3857
+ // Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
3858
+ // settings.json, readable by every user through GET /api/settings, so a non-admin's
3859
+ // case name is not written there in multi-user mode (writes are admin-only, like
3860
+ // PUT /api/settings). isAdmin is always true in single-user mode.
3861
+ if (isAdmin(req)) {
3655
3862
  try {
3656
- settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
3863
+ const settingsFilePath = SETTINGS_PATH;
3864
+ let settings = {};
3865
+ try {
3866
+ settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
3867
+ }
3868
+ catch (err) {
3869
+ if (err.code !== 'ENOENT')
3870
+ throw err;
3871
+ }
3872
+ settings.lastUsedCase = caseName;
3873
+ const dir = dirname(settingsFilePath);
3874
+ if (!existsSync(dir)) {
3875
+ mkdirSync(dir, { recursive: true });
3876
+ }
3877
+ // Use async write to avoid blocking event loop
3878
+ fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
3879
+ // Non-critical but log for debugging
3880
+ console.warn('[Server] Failed to save settings (lastUsedCase):', err);
3881
+ });
3657
3882
  }
3658
3883
  catch (err) {
3659
- if (err.code !== 'ENOENT')
3660
- throw err;
3661
- }
3662
- settings.lastUsedCase = caseName;
3663
- const dir = dirname(settingsFilePath);
3664
- if (!existsSync(dir)) {
3665
- mkdirSync(dir, { recursive: true });
3666
- }
3667
- // Use async write to avoid blocking event loop
3668
- fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
3669
3884
  // Non-critical but log for debugging
3670
- console.warn('[Server] Failed to save settings (lastUsedCase):', err);
3671
- });
3672
- }
3673
- catch (err) {
3674
- // Non-critical but log for debugging
3675
- console.warn('[Server] Failed to prepare settings update:', err);
3885
+ console.warn('[Server] Failed to prepare settings update:', err);
3886
+ }
3676
3887
  }
3677
3888
  return {
3678
3889
  sessionId: session.id,
3679
3890
  casePath: resolvedCasePath,
3680
3891
  caseName,
3892
+ ...(worktree ? { worktree, workingDir: workspacePath } : {}),
3681
3893
  ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
3894
+ ...(tabGroupId ? { tabLayout } : {}),
3682
3895
  };
3683
3896
  }
3684
3897
  catch (err) {
@@ -4565,7 +4778,38 @@ options = {}) {
4565
4778
  // Paste Image (clipboard / drag-drop upload)
4566
4779
  // ═══════════════════════════════════════════════════════════════
4567
4780
  const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
4781
+ // Single-sourced from the attachment registry (what the file viewer can also play),
4782
+ // so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
4783
+ // would upload but not preview, and a phone's camera roll produces neither.
4784
+ const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
4785
+ // Documents a user hands an agent to read: the office formats plus the text
4786
+ // family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
4787
+ // EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
4788
+ // to read, not a thing to run or serve. Each is judged on its bytes by
4789
+ // imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
4790
+ const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
4791
+ const VIDEO_MIME_EXT = {
4792
+ mp4: '.mp4',
4793
+ quicktime: '.mov',
4794
+ 'x-m4v': '.m4v',
4795
+ webm: '.webm',
4796
+ ogg: '.ogv',
4797
+ };
4568
4798
  // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
4799
+ /** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
4800
+ async function writeBufferedUpload(workingDir, ext, bytes, stem = '') {
4801
+ const uploadDir = await ensureUploadDir(workingDir);
4802
+ if (!uploadDir)
4803
+ return null;
4804
+ const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
4805
+ try {
4806
+ await fh.writeFile(bytes);
4807
+ }
4808
+ finally {
4809
+ await fh.close();
4810
+ }
4811
+ return { path: filepath, filename };
4812
+ }
4569
4813
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
4570
4814
  // CSRF defense: state-changing routes must come from same origin.
4571
4815
  // Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
@@ -4602,12 +4846,19 @@ options = {}) {
4602
4846
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
4603
4847
  }
4604
4848
  const { id } = req.params;
4605
- // Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
4606
- // — even an authenticated attacker can otherwise loop large image POSTs.
4607
- if (!consumePasteToken(`${req.ip}:${id}`)) {
4849
+ // `?kind=video` selects the disk-streamed path (see below). It is read here so the
4850
+ // rate check draws on the right bucket before any work: videos get 3/min, images 30.
4851
+ const isVideo = req.query.kind === 'video';
4852
+ // Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
4853
+ // authenticated client can otherwise loop large uploads. Videos get their own,
4854
+ // far lower bucket because the per-file cap is 1GB (see the bucket constants).
4855
+ const rateKey = `${req.ip}:${id}`;
4856
+ if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
4608
4857
  reply.code(429);
4609
4858
  reply.header('Retry-After', '60');
4610
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
4859
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, isVideo
4860
+ ? 'Rate limit exceeded (3 video uploads/min per session)'
4861
+ : 'Rate limit exceeded (30 uploads/min per session)');
4611
4862
  }
4612
4863
  const session = findSessionOrFail(ctx, id, req);
4613
4864
  // The file lands on THIS host under the session's working directory, which
@@ -4620,6 +4871,16 @@ options = {}) {
4620
4871
  reply.code(400);
4621
4872
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
4622
4873
  }
4874
+ // The upload is written under session.workingDir (ensureUploadDir below), and
4875
+ // a live session's workspace can sit on a network mount that stopped answering.
4876
+ // Probe it bounded before the first disk touch, as #516 requires for a path the
4877
+ // user named, so one dead mount cannot freeze a libuv threadpool worker per
4878
+ // upload. Only "unknown" (the mount is not answering) is refused here, the way
4879
+ // the create route does; absent / not-a-directory keep ensureUploadDir's own
4880
+ // errors, since a live session's workspace should be neither.
4881
+ if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
4882
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Workspace', session.workingDir, { pastCap: true }));
4883
+ }
4623
4884
  if (!req.isMultipart()) {
4624
4885
  reply.code(400);
4625
4886
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -4630,9 +4891,15 @@ options = {}) {
4630
4891
  // boundary scanner with several bugs: literal boundary matches anywhere in
4631
4892
  // body, LF-only clients silently corrupted the last byte (hard-coded \r\n
4632
4893
  // offsets), no part-count cap.
4894
+ // `?kind=video` (read above for the rate check) is the ONLY way onto the video
4895
+ // path: it selects the larger, disk-streamed budget before the part is seen
4896
+ // (busboy takes its limits up front), and it admits video types alone, so an
4897
+ // image can never ride the video cap through memory. Without it the route is
4898
+ // byte-identical to the image-only version. Truncation is checked on the stream
4899
+ // below rather than thrown, since the file is being written as it arrives.
4633
4900
  let part;
4634
4901
  try {
4635
- part = await req.file();
4902
+ part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
4636
4903
  }
4637
4904
  catch (err) {
4638
4905
  reply.code(413);
@@ -4646,6 +4913,76 @@ options = {}) {
4646
4913
  reply.code(400);
4647
4914
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
4648
4915
  }
4916
+ if (isVideo) {
4917
+ let ext = '';
4918
+ if (part.filename) {
4919
+ const origExt = extname(part.filename).toLowerCase();
4920
+ if (ALLOWED_VIDEO_EXTS.has(origExt))
4921
+ ext = origExt;
4922
+ }
4923
+ const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
4924
+ if (videoMime)
4925
+ ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
4926
+ if (!ext) {
4927
+ await finished(part.file.resume()).catch(() => { });
4928
+ reply.code(400);
4929
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
4930
+ }
4931
+ const uploadDir = await ensureUploadDir(session.workingDir);
4932
+ if (!uploadDir) {
4933
+ await finished(part.file.resume()).catch(() => { });
4934
+ reply.code(403);
4935
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4936
+ }
4937
+ const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
4938
+ // Written as it arrives: a phone video is far too large to buffer the way an
4939
+ // image is. The container is judged on the first 12 bytes as soon as they are
4940
+ // in, so a mislabeled upload stops touching disk at its first chunk; the rest
4941
+ // of the body is drained unwritten (as busboy does past its own size limit),
4942
+ // which is what lets the answer reach the client. A transfer that dies midway
4943
+ // (the phone left the network) leaves no partial file behind.
4944
+ const head = Buffer.alloc(12);
4945
+ let headLen = 0;
4946
+ let mislabeled = false;
4947
+ try {
4948
+ for await (const chunk of part.file) {
4949
+ if (mislabeled)
4950
+ continue;
4951
+ if (headLen < head.length) {
4952
+ headLen += chunk.copy(head, headLen, 0, head.length - headLen);
4953
+ mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
4954
+ if (mislabeled)
4955
+ continue;
4956
+ }
4957
+ // writeFile loops until the whole chunk is on disk; a bare write() may be short.
4958
+ await fh.writeFile(chunk);
4959
+ }
4960
+ }
4961
+ catch (err) {
4962
+ await fs.unlink(filepath).catch(() => { });
4963
+ throw err;
4964
+ }
4965
+ finally {
4966
+ await fh.close();
4967
+ }
4968
+ let failure = null;
4969
+ if (headLen === 0)
4970
+ failure = { status: 400, message: 'Empty file' };
4971
+ else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
4972
+ console.warn(`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`);
4973
+ failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
4974
+ }
4975
+ else if (part.file.truncated) {
4976
+ const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
4977
+ failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
4978
+ }
4979
+ if (failure) {
4980
+ await fs.unlink(filepath).catch(() => { });
4981
+ reply.code(failure.status);
4982
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
4983
+ }
4984
+ return { path: filepath, filename };
4985
+ }
4649
4986
  let imageBytes;
4650
4987
  try {
4651
4988
  imageBytes = await part.toBuffer();
@@ -4659,13 +4996,34 @@ options = {}) {
4659
4996
  reply.code(400);
4660
4997
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
4661
4998
  }
4999
+ // Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
5000
+ // the image path whatever the name says, a document extension (the MIME a
5001
+ // phone gives a .md is anyone's guess) takes the document branch, anything
5002
+ // else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
5003
+ const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
5004
+ const imageMime = /^image\//i.test(part.mimetype || '');
5005
+ if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
5006
+ if (!imageMagicMatchesExt(imageBytes, origExt)) {
5007
+ // The first bytes of a text file are user content, so for text the log
5008
+ // names the failure and never the bytes; a signature is safe to print.
5009
+ const detail = TEXT_UPLOAD_EXTS.has(origExt)
5010
+ ? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
5011
+ : `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
5012
+ console.warn(`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`);
5013
+ reply.code(415);
5014
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
5015
+ }
5016
+ const saved = await writeBufferedUpload(session.workingDir, origExt, imageBytes, sanitizeUploadStem(part.filename));
5017
+ if (!saved) {
5018
+ reply.code(403);
5019
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
5020
+ }
5021
+ return saved;
5022
+ }
4662
5023
  // Determine extension from filename or Content-Type.
4663
5024
  let ext = '.png';
4664
- if (part.filename) {
4665
- const origExt = extname(part.filename).toLowerCase();
4666
- if (ALLOWED_IMAGE_EXTS.has(origExt))
4667
- ext = origExt;
4668
- }
5025
+ if (ALLOWED_IMAGE_EXTS.has(origExt))
5026
+ ext = origExt;
4669
5027
  const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
4670
5028
  if (mimeMatch) {
4671
5029
  const map = {
@@ -4701,6 +5059,15 @@ options = {}) {
4701
5059
  }
4702
5060
  }
4703
5061
  else if (!imageMagicMatchesExt(imageBytes, ext)) {
5062
+ // A foreign extension with no image MIME (a script, an HTML page, a video
5063
+ // without its flag) was never going to be an image: say so, rather than
5064
+ // the ".png" the default extension would name. Decided only HERE, after
5065
+ // the sniff, so anything the image path ever accepted by its bytes (a
5066
+ // HEIC under a generic name) still is.
5067
+ if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
5068
+ reply.code(400);
5069
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
5070
+ }
4704
5071
  // Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
4705
5072
  // serve back with image/png MIME. Log the real header so format
4706
5073
  // mismatches can be pinned down without a reproduce-and-guess loop. The
@@ -4709,26 +5076,12 @@ options = {}) {
4709
5076
  reply.code(415);
4710
5077
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
4711
5078
  }
4712
- // {workingDir}/.codeman-uploads/, see ensureUploadDir.
4713
- const imageDir = await ensureUploadDir(session.workingDir);
4714
- if (!imageDir) {
5079
+ const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
5080
+ if (!saved) {
4715
5081
  reply.code(403);
4716
5082
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4717
5083
  }
4718
- // Date.now() collides on same-ms uploads from two tabs (last-write wins
4719
- // silently). Append 8 hex chars so concurrent pastes get distinct names.
4720
- const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
4721
- const filepath = join(imageDir, filename);
4722
- // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
4723
- // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
4724
- const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
4725
- try {
4726
- await fh.writeFile(imageBytes);
4727
- }
4728
- finally {
4729
- await fh.close();
4730
- }
4731
- return { path: filepath, filename };
5084
+ return saved;
4732
5085
  });
4733
5086
  // Returned so the server can own the registry's LIFETIME (drop state when a session is
4734
5087
  // cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE