aicodeman 1.40.0 → 1.41.1

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 (351) hide show
  1. package/README.md +81 -13
  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 +227 -0
  7. package/dist/cli-agent.d.ts.map +1 -0
  8. package/dist/cli-agent.js +771 -0
  9. package/dist/cli-agent.js.map +1 -0
  10. package/dist/cli.d.ts +25 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +103 -41
  13. package/dist/cli.js.map +1 -1
  14. package/dist/codeman-credentials.d.ts +27 -0
  15. package/dist/codeman-credentials.d.ts.map +1 -0
  16. package/dist/codeman-credentials.js +66 -0
  17. package/dist/codeman-credentials.js.map +1 -0
  18. package/dist/config/buffer-limits.d.ts +9 -0
  19. package/dist/config/buffer-limits.d.ts.map +1 -1
  20. package/dist/config/buffer-limits.js +9 -0
  21. package/dist/config/buffer-limits.js.map +1 -1
  22. package/dist/config/cli-registry/schema.d.ts +10 -0
  23. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  24. package/dist/config/cli-registry/schema.js +15 -0
  25. package/dist/config/cli-registry/schema.js.map +1 -1
  26. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  27. package/dist/config/cli-registry/stock.js +47 -0
  28. package/dist/config/cli-registry/stock.js.map +1 -1
  29. package/dist/config/cli-registry/types.d.ts +47 -1
  30. package/dist/config/cli-registry/types.d.ts.map +1 -1
  31. package/dist/folder-listing.d.ts +94 -0
  32. package/dist/folder-listing.d.ts.map +1 -0
  33. package/dist/folder-listing.js +241 -0
  34. package/dist/folder-listing.js.map +1 -0
  35. package/dist/git-clone.d.ts.map +1 -1
  36. package/dist/git-clone.js +4 -0
  37. package/dist/git-clone.js.map +1 -1
  38. package/dist/hooks-config.d.ts +36 -16
  39. package/dist/hooks-config.d.ts.map +1 -1
  40. package/dist/hooks-config.js +45 -20
  41. package/dist/hooks-config.js.map +1 -1
  42. package/dist/html-view-capabilities.d.ts +54 -0
  43. package/dist/html-view-capabilities.d.ts.map +1 -0
  44. package/dist/html-view-capabilities.js +103 -0
  45. package/dist/html-view-capabilities.js.map +1 -0
  46. package/dist/image-watcher.d.ts.map +1 -1
  47. package/dist/image-watcher.js +6 -2
  48. package/dist/image-watcher.js.map +1 -1
  49. package/dist/mcp-server.d.ts +114 -0
  50. package/dist/mcp-server.d.ts.map +1 -0
  51. package/dist/mcp-server.js +917 -0
  52. package/dist/mcp-server.js.map +1 -0
  53. package/dist/mcp-sync-targets.d.ts +34 -0
  54. package/dist/mcp-sync-targets.d.ts.map +1 -0
  55. package/dist/mcp-sync-targets.js +55 -0
  56. package/dist/mcp-sync-targets.js.map +1 -0
  57. package/dist/mcp-sync.d.ts +7 -1
  58. package/dist/mcp-sync.d.ts.map +1 -1
  59. package/dist/mcp-sync.js +113 -6
  60. package/dist/mcp-sync.js.map +1 -1
  61. package/dist/mux-interface.d.ts +36 -1
  62. package/dist/mux-interface.d.ts.map +1 -1
  63. package/dist/remote-files.d.ts +37 -0
  64. package/dist/remote-files.d.ts.map +1 -1
  65. package/dist/remote-files.js +103 -0
  66. package/dist/remote-files.js.map +1 -1
  67. package/dist/remote-pane-history.d.ts +103 -0
  68. package/dist/remote-pane-history.d.ts.map +1 -0
  69. package/dist/remote-pane-history.js +258 -0
  70. package/dist/remote-pane-history.js.map +1 -0
  71. package/dist/remote-ssh-limiter.d.ts +16 -3
  72. package/dist/remote-ssh-limiter.d.ts.map +1 -1
  73. package/dist/remote-ssh-limiter.js +37 -6
  74. package/dist/remote-ssh-limiter.js.map +1 -1
  75. package/dist/session.d.ts +29 -1
  76. package/dist/session.d.ts.map +1 -1
  77. package/dist/session.js +63 -4
  78. package/dist/session.js.map +1 -1
  79. package/dist/subagent-watcher.d.ts.map +1 -1
  80. package/dist/subagent-watcher.js +29 -6
  81. package/dist/subagent-watcher.js.map +1 -1
  82. package/dist/tab-layout-legacy-order.d.ts.map +1 -1
  83. package/dist/tab-layout-legacy-order.js +72 -13
  84. package/dist/tab-layout-legacy-order.js.map +1 -1
  85. package/dist/tab-layout-service.d.ts +33 -2
  86. package/dist/tab-layout-service.d.ts.map +1 -1
  87. package/dist/tab-layout-service.js +102 -7
  88. package/dist/tab-layout-service.js.map +1 -1
  89. package/dist/tab-layout.d.ts +9 -0
  90. package/dist/tab-layout.d.ts.map +1 -1
  91. package/dist/tab-layout.js +15 -2
  92. package/dist/tab-layout.js.map +1 -1
  93. package/dist/templates/case-template.md +2 -0
  94. package/dist/tmux-manager.d.ts +76 -2
  95. package/dist/tmux-manager.d.ts.map +1 -1
  96. package/dist/tmux-manager.js +216 -40
  97. package/dist/tmux-manager.js.map +1 -1
  98. package/dist/tui/tui-client.d.ts +3 -17
  99. package/dist/tui/tui-client.d.ts.map +1 -1
  100. package/dist/tui/tui-client.js +3 -44
  101. package/dist/tui/tui-client.js.map +1 -1
  102. package/dist/types/session.d.ts +53 -0
  103. package/dist/types/session.d.ts.map +1 -1
  104. package/dist/types/session.js.map +1 -1
  105. package/dist/usage-telemetry.d.ts +44 -7
  106. package/dist/usage-telemetry.d.ts.map +1 -1
  107. package/dist/usage-telemetry.js +72 -7
  108. package/dist/usage-telemetry.js.map +1 -1
  109. package/dist/utils/index.d.ts +1 -0
  110. package/dist/utils/index.d.ts.map +1 -1
  111. package/dist/utils/index.js +1 -0
  112. package/dist/utils/index.js.map +1 -1
  113. package/dist/utils/process-exit-wait.d.ts +40 -0
  114. package/dist/utils/process-exit-wait.d.ts.map +1 -0
  115. package/dist/utils/process-exit-wait.js +68 -0
  116. package/dist/utils/process-exit-wait.js.map +1 -0
  117. package/dist/web/approval-inbox.d.ts +12 -0
  118. package/dist/web/approval-inbox.d.ts.map +1 -1
  119. package/dist/web/approval-inbox.js +1 -0
  120. package/dist/web/approval-inbox.js.map +1 -1
  121. package/dist/web/middleware/auth.d.ts.map +1 -1
  122. package/dist/web/middleware/auth.js +33 -0
  123. package/dist/web/middleware/auth.js.map +1 -1
  124. package/dist/web/paste-image-gc.d.ts +46 -6
  125. package/dist/web/paste-image-gc.d.ts.map +1 -1
  126. package/dist/web/paste-image-gc.js +106 -32
  127. package/dist/web/paste-image-gc.js.map +1 -1
  128. package/dist/web/ports/session-port.d.ts +8 -1
  129. package/dist/web/ports/session-port.d.ts.map +1 -1
  130. package/dist/web/public/admin-ui.js.gz +0 -0
  131. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  132. package/dist/web/public/app.24689381.js +43 -0
  133. package/dist/web/public/app.24689381.js.br +0 -0
  134. package/dist/web/public/app.24689381.js.gz +0 -0
  135. package/dist/web/public/approvals-ui.js +4 -0
  136. package/dist/web/public/approvals-ui.js.br +0 -0
  137. package/dist/web/public/approvals-ui.js.gz +0 -0
  138. package/dist/web/public/{constants.c0f8f9de.js → constants.17d77b65.js} +690 -35
  139. package/dist/web/public/constants.17d77b65.js.br +0 -0
  140. package/dist/web/public/constants.17d77b65.js.gz +0 -0
  141. package/dist/web/public/cron-ui.js.gz +0 -0
  142. package/dist/web/public/entrance-animations.js +490 -43
  143. package/dist/web/public/entrance-animations.js.br +0 -0
  144. package/dist/web/public/entrance-animations.js.gz +0 -0
  145. package/dist/web/public/gesture/gesture-codeman.js +112 -6
  146. package/dist/web/public/git-status-ui.js.gz +0 -0
  147. package/dist/web/public/home-sessions.js +2 -1
  148. package/dist/web/public/home-sessions.js.br +0 -0
  149. package/dist/web/public/home-sessions.js.gz +0 -0
  150. package/dist/web/public/host-wake-ui.js.gz +0 -0
  151. package/dist/web/public/i18n.9afd8e4d.js +2 -0
  152. package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
  153. package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
  154. package/dist/web/public/image-input.9ed1d0d2.js +453 -0
  155. package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
  156. package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
  157. package/dist/web/public/index.html +223 -46
  158. package/dist/web/public/index.html.br +0 -0
  159. package/dist/web/public/index.html.gz +0 -0
  160. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  161. package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
  162. package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
  163. package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
  164. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  165. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  166. package/dist/web/public/mobile-overview.js +11 -2
  167. package/dist/web/public/mobile-overview.js.br +0 -0
  168. package/dist/web/public/mobile-overview.js.gz +0 -0
  169. package/dist/web/public/mobile.2dff1580.css +1 -0
  170. package/dist/web/public/mobile.2dff1580.css.br +0 -0
  171. package/dist/web/public/mobile.2dff1580.css.gz +0 -0
  172. package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
  173. package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
  174. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  175. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  176. package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.73ec0137.js} +87 -87
  177. package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
  178. package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
  179. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  180. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  181. package/dist/web/public/readmymind-ui.js.gz +0 -0
  182. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  183. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  184. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  185. package/dist/web/public/session-lineage.js.gz +0 -0
  186. package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
  187. package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
  188. package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
  189. package/dist/web/public/settings-ui.81ac264e.js +97 -0
  190. package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
  191. package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
  192. package/dist/web/public/spreadsheet-preview-worker.js +11 -0
  193. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  194. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  195. package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
  196. package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
  197. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  198. package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
  199. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  200. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  201. package/dist/web/public/styles.900a28e4.css +1 -0
  202. package/dist/web/public/styles.900a28e4.css.br +0 -0
  203. package/dist/web/public/styles.900a28e4.css.gz +0 -0
  204. package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
  205. package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
  206. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  207. package/dist/web/public/sw.js +6 -2
  208. package/dist/web/public/sw.js.br +0 -0
  209. package/dist/web/public/sw.js.gz +0 -0
  210. package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
  211. package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
  212. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  213. package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
  214. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  215. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  216. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  217. package/dist/web/public/terminal-split.js.gz +0 -0
  218. package/dist/web/public/terminal-tile.js +135 -38
  219. package/dist/web/public/terminal-tile.js.br +0 -0
  220. package/dist/web/public/terminal-tile.js.gz +0 -0
  221. package/dist/web/public/terminal-ui.858f5da5.js +2 -0
  222. package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
  223. package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
  224. package/dist/web/public/tile-grid.js +769 -137
  225. package/dist/web/public/tile-grid.js.br +0 -0
  226. package/dist/web/public/tile-grid.js.gz +0 -0
  227. package/dist/web/public/ultracode-panel.js.gz +0 -0
  228. package/dist/web/public/ultracode-windows.js +9 -6
  229. package/dist/web/public/ultracode-windows.js.br +0 -0
  230. package/dist/web/public/ultracode-windows.js.gz +0 -0
  231. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  232. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  233. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  234. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  235. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  236. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  237. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  238. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  239. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  240. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
  241. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
  242. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  243. package/dist/web/public/vendor/xterm.css.gz +0 -0
  244. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  245. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  246. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  247. package/dist/web/public/webview-tabs.js +12 -0
  248. package/dist/web/public/webview-tabs.js.br +0 -0
  249. package/dist/web/public/webview-tabs.js.gz +0 -0
  250. package/dist/web/route-helpers.d.ts +8 -0
  251. package/dist/web/route-helpers.d.ts.map +1 -1
  252. package/dist/web/route-helpers.js +8 -0
  253. package/dist/web/route-helpers.js.map +1 -1
  254. package/dist/web/routes/admin-routes.d.ts.map +1 -1
  255. package/dist/web/routes/admin-routes.js +4 -1
  256. package/dist/web/routes/admin-routes.js.map +1 -1
  257. package/dist/web/routes/approval-routes.d.ts.map +1 -1
  258. package/dist/web/routes/approval-routes.js +6 -8
  259. package/dist/web/routes/approval-routes.js.map +1 -1
  260. package/dist/web/routes/case-routes.js +1 -1
  261. package/dist/web/routes/case-routes.js.map +1 -1
  262. package/dist/web/routes/cli-registry-routes.d.ts +23 -4
  263. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  264. package/dist/web/routes/cli-registry-routes.js +92 -7
  265. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  266. package/dist/web/routes/file-routes.d.ts +46 -1
  267. package/dist/web/routes/file-routes.d.ts.map +1 -1
  268. package/dist/web/routes/file-routes.js +214 -27
  269. package/dist/web/routes/file-routes.js.map +1 -1
  270. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  271. package/dist/web/routes/hook-event-routes.js +8 -1
  272. package/dist/web/routes/hook-event-routes.js.map +1 -1
  273. package/dist/web/routes/html-view-routes.d.ts +60 -0
  274. package/dist/web/routes/html-view-routes.d.ts.map +1 -0
  275. package/dist/web/routes/html-view-routes.js +288 -0
  276. package/dist/web/routes/html-view-routes.js.map +1 -0
  277. package/dist/web/routes/index.d.ts +1 -0
  278. package/dist/web/routes/index.d.ts.map +1 -1
  279. package/dist/web/routes/index.js +1 -0
  280. package/dist/web/routes/index.js.map +1 -1
  281. package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
  282. package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
  283. package/dist/web/routes/mcp-sync-routes.js +10 -3
  284. package/dist/web/routes/mcp-sync-routes.js.map +1 -1
  285. package/dist/web/routes/reboot-restore-routes.js +3 -3
  286. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  287. package/dist/web/routes/session-routes.d.ts +1 -0
  288. package/dist/web/routes/session-routes.d.ts.map +1 -1
  289. package/dist/web/routes/session-routes.js +345 -90
  290. package/dist/web/routes/session-routes.js.map +1 -1
  291. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  292. package/dist/web/routes/status-telemetry-routes.js +16 -2
  293. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  294. package/dist/web/schemas.d.ts +32 -0
  295. package/dist/web/schemas.d.ts.map +1 -1
  296. package/dist/web/schemas.js +74 -0
  297. package/dist/web/schemas.js.map +1 -1
  298. package/dist/web/server.d.ts +7 -2
  299. package/dist/web/server.d.ts.map +1 -1
  300. package/dist/web/server.js +77 -24
  301. package/dist/web/server.js.map +1 -1
  302. package/dist/web/sse-events.d.ts +5 -2
  303. package/dist/web/sse-events.d.ts.map +1 -1
  304. package/dist/web/sse-events.js +5 -2
  305. package/dist/web/sse-events.js.map +1 -1
  306. package/package.json +5 -4
  307. package/skills/codeman/SKILL.md +30 -7
  308. package/README.zh-CN.md +0 -1147
  309. package/dist/web/public/app.abadc970.js +0 -43
  310. package/dist/web/public/app.abadc970.js.br +0 -0
  311. package/dist/web/public/app.abadc970.js.gz +0 -0
  312. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  313. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  314. package/dist/web/public/i18n.a31747a9.js +0 -2
  315. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  316. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  317. package/dist/web/public/image-input.898a538b.js +0 -281
  318. package/dist/web/public/image-input.898a538b.js.br +0 -0
  319. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  320. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  321. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  322. package/dist/web/public/mobile.111b187d.css +0 -1
  323. package/dist/web/public/mobile.111b187d.css.br +0 -0
  324. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  325. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  326. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  327. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  328. package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
  329. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  330. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  331. package/dist/web/public/settings-ui.a97ab16f.js +0 -97
  332. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  333. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  334. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  335. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  336. package/dist/web/public/styles.8ee12666.css +0 -1
  337. package/dist/web/public/styles.8ee12666.css.br +0 -0
  338. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  339. package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
  340. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  341. package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
  342. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  343. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  344. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  345. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  346. package/dist/web/public/terminal-ui.5059b30e.js +0 -2
  347. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  348. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  349. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
  350. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
  351. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
@@ -8,12 +8,14 @@ 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';
12
13
  import { performance } from 'node:perf_hooks';
13
14
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
14
15
  import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
15
16
  import { SseEvent } from '../sse-events.js';
16
17
  import { webviewCapabilities } from '../../webview-capabilities.js';
18
+ import { htmlViewCapabilities } from '../../html-view-capabilities.js';
17
19
  import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
18
20
  import { readCustomModelHosts } from '../../custom-model-hosts.js';
19
21
  import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
@@ -34,7 +36,7 @@ import { applyLaunchDefaults } from '../launch-defaults.js';
34
36
  import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
35
37
  import { isMultiUserMode } from '../../config/multiuser.js';
36
38
  import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
37
- import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
39
+ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, agentSkillDir, agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
38
40
  import { generateClaudeMd } from '../../templates/claude-md.js';
39
41
  import { imageWatcher } from '../../image-watcher.js';
40
42
  import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
@@ -43,9 +45,11 @@ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified
43
45
  import { buildHistorySessionIndexItems, setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js';
44
46
  import { RunSummaryTracker } from '../../run-summary.js';
45
47
  import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
46
- import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
48
+ import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
49
+ import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
47
50
  import { dataPath, getDataDir } from '../../config/instance.js';
48
51
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
52
+ import { fetchRemoteScrollback } from '../../remote-pane-history.js';
49
53
  import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
50
54
  import { LRUMap } from '../../utils/lru-map.js';
51
55
  import { describeUnknownPath, probePathKind } from '../../utils/index.js';
@@ -55,6 +59,7 @@ import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-tr
55
59
  import { getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
56
60
  import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
57
61
  import { appendClaudeCustomTitle } from '../../claude-session-title.js';
62
+ import { UPLOADS_DIR } from '../paste-image-gc.js';
58
63
  // Path to linked-cases registry (same file used by case-routes resolveCasePath)
59
64
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
60
65
  const CODEMAN_CONFIG_DIR = getDataDir();
@@ -218,41 +223,130 @@ export function imageMagicMatchesExt(data, ext) {
218
223
  const brand = data.subarray(8, 12).toString('ascii');
219
224
  return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
220
225
  }
226
+ case '.mp4':
227
+ case '.m4v':
228
+ case '.mov':
229
+ // ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
230
+ // vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
231
+ // QuickTime file can open on an atom other than ftyp.
232
+ return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
233
+ case '.webm':
234
+ // EBML header (Matroska's WebM subset)
235
+ return u32be(0) === 0x1a45dfa3;
236
+ case '.ogv':
237
+ // Ogg container ("OggS")
238
+ return u32be(0) === 0x4f676753;
221
239
  default:
222
240
  return false;
223
241
  }
224
242
  }
225
- // Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
226
- // Bucket map entries are pruned when they drift > 1h stale to bound memory
227
- // against a flood of unique IP keys.
243
+ /**
244
+ * Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
245
+ * null when something other than a regular directory sits there. An agent or
246
+ * postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
247
+ * future writes outside workingDir: lstat (not stat) sees the symlink itself,
248
+ * and mkdir without `recursive` does not follow one for the leaf either;
249
+ * O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
250
+ * The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
251
+ * so the user's repository never sees uploads, their own ignore file is never
252
+ * touched, and a file already there is theirs and stays as it is.
253
+ */
254
+ async function ensureUploadDir(workingDir) {
255
+ // workingDir exists (live session) and the caller bounded-probed its mount, so
256
+ // the lstat/mkdir below normally start on a reachable path; a mount that dies
257
+ // after that probe is the same residual race POST /api/sessions accepts.
258
+ const uploadDir = join(workingDir, UPLOADS_DIR);
259
+ try {
260
+ const dirStat = await fs.lstat(uploadDir);
261
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
262
+ return null;
263
+ }
264
+ catch (err) {
265
+ if (err.code !== 'ENOENT')
266
+ throw err;
267
+ try {
268
+ await fs.mkdir(uploadDir);
269
+ }
270
+ catch (mkErr) {
271
+ // Concurrent uploads (a batch of photos) race to create the dir — the
272
+ // losers get EEXIST. Treat an already-present REAL directory as success,
273
+ // but re-verify it isn't a symlink a racing actor planted.
274
+ if (mkErr.code !== 'EEXIST')
275
+ throw mkErr;
276
+ const raceStat = await fs.lstat(uploadDir);
277
+ if (raceStat.isSymbolicLink() || !raceStat.isDirectory())
278
+ return null;
279
+ }
280
+ }
281
+ const ignoreFile = join(uploadDir, '.gitignore');
282
+ try {
283
+ // 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
284
+ await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
285
+ }
286
+ catch (err) {
287
+ if (err.code === 'EEXIST')
288
+ return uploadDir;
289
+ // The create can succeed before the write fails (ENOSPC): an empty ignore
290
+ // file would read as the user's on the next upload, so take it back; its own
291
+ // failure must not replace the cause.
292
+ await fs.rm(ignoreFile, { force: true }).catch(() => { });
293
+ throw err;
294
+ }
295
+ return uploadDir;
296
+ }
297
+ /** Open a fresh, uniquely named paste file for writing. */
298
+ async function openPasteFile(imageDir, ext) {
299
+ // Date.now() collides on same-ms uploads from two tabs (last-write wins
300
+ // silently). Append 8 hex chars so concurrent pastes get distinct names.
301
+ const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
302
+ const filepath = join(imageDir, filename);
303
+ // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
304
+ // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
305
+ const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
306
+ return { filename, filepath, fh };
307
+ }
308
+ // Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
309
+ // a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
310
+ // 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
311
+ // bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
312
+ // memory against a flood of unique IP keys.
228
313
  const PASTE_RATE_TOKENS = 30;
229
- const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
314
+ const VIDEO_RATE_TOKENS = 3;
230
315
  const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
231
316
  const PASTE_BUCKET_GC_THRESHOLD = 1000;
232
317
  const pasteRateBuckets = new Map();
233
- export function consumePasteToken(key, now = Date.now()) {
234
- if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
235
- for (const [k, b] of pasteRateBuckets) {
318
+ const videoRateBuckets = new Map();
319
+ function takeToken(buckets, key, capacity, now) {
320
+ if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
321
+ for (const [k, b] of buckets) {
236
322
  if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
237
- pasteRateBuckets.delete(k);
323
+ buckets.delete(k);
238
324
  }
239
325
  }
240
- let b = pasteRateBuckets.get(key);
326
+ let b = buckets.get(key);
241
327
  if (!b) {
242
- b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
243
- pasteRateBuckets.set(key, b);
328
+ b = { tokens: capacity, lastRefill: now };
329
+ buckets.set(key, b);
244
330
  }
245
- const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
246
- b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
331
+ b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
247
332
  b.lastRefill = now;
248
333
  if (b.tokens < 1)
249
334
  return false;
250
335
  b.tokens -= 1;
251
336
  return true;
252
337
  }
338
+ export function consumePasteToken(key, now = Date.now()) {
339
+ return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
340
+ }
341
+ // Videos stream to disk under a far larger per-file cap, so they draw on their own,
342
+ // much lower bucket (see the comment above the constants).
343
+ export function consumeVideoToken(key, now = Date.now()) {
344
+ return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
345
+ }
253
346
  // Test hook: reset between runs.
254
347
  export function _resetPasteRateBuckets() {
255
348
  pasteRateBuckets.clear();
349
+ videoRateBuckets.clear();
256
350
  }
257
351
  /**
258
352
  * Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
@@ -573,8 +667,8 @@ function abortOnClientHangUp(reply) {
573
667
  * Injection is best-effort and stays that way: neither a refusal nor a thrown error
574
668
  * may fail the create.
575
669
  */
576
- async function injectAgentSkill(casePath) {
577
- const skillDir = join(casePath, '.claude', 'skills', 'codeman');
670
+ async function injectAgentSkill(casePath, dir) {
671
+ const skillDir = agentSkillDirFor(casePath, dir);
578
672
  try {
579
673
  // Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
580
674
  // written once by `codeman skill install`) over the case copy injected below, so a
@@ -583,7 +677,7 @@ async function injectAgentSkill(casePath) {
583
677
  // current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
584
678
  // since a foreign user copy is the user's own authored skill, not a config error.
585
679
  await refreshUserAgentSkill();
586
- const result = await applyAgentSkill(casePath, true);
680
+ const result = await applyAgentSkill(casePath, true, dir);
587
681
  if (result === 'foreign') {
588
682
  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.`);
589
683
  }
@@ -706,6 +800,7 @@ options = {}) {
706
800
  // logout has to retire them too or every dashboard URL opened during this
707
801
  // login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
708
802
  webviewCapabilities.revokeOwner(ownerFor(req));
803
+ htmlViewCapabilities.revokeOwner(ownerFor(req));
709
804
  reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
710
805
  return {};
711
806
  });
@@ -867,13 +962,6 @@ options = {}) {
867
962
  // materializing in ~/.claude/settings.local.json was never asked for.
868
963
  if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
869
964
  await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
870
- // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
871
- // .claude rationale as the statusLine above: a create must never remove the
872
- // skill from under other live sessions in the repo. Marker-guarded, so a
873
- // user's own skills/codeman is never touched.
874
- if (await ctx.getAgentSkillEnabled()) {
875
- await injectAgentSkill(workingDir);
876
- }
877
965
  }
878
966
  // Refuse up front if the requested CLI cannot start, rather than spawning a pane that
879
967
  // dies on `command not found`. The message comes from the resolver, so it names where
@@ -892,6 +980,17 @@ options = {}) {
892
980
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
893
981
  }
894
982
  }
983
+ // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
984
+ // workspace rationale as the statusLine above: a create must never remove the
985
+ // skill from under other live sessions in the repo. Marker-guarded, so a user's
986
+ // own copy is never touched. Every CLI that takes the skill gets it in the directory
987
+ // that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
988
+ // conditions as the hooks above, and only once the launch pre-flight passed, so a
989
+ // CLI that cannot start leaves nothing in the workspace.
990
+ const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
991
+ if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
992
+ await injectAgentSkill(workingDir, createSkillDir);
993
+ }
895
994
  // Pre-validate resumeSessionId: check that the conversation file actually exists
896
995
  // in Claude's projects directory. If not, skip resume to avoid confusing
897
996
  // "No conversation found" errors from Claude CLI.
@@ -980,13 +1079,16 @@ options = {}) {
980
1079
  owner,
981
1080
  parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
982
1081
  });
983
- await ctx.addSession(session);
1082
+ const tabLayout = body.tabGroupId
1083
+ ? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
1084
+ : await ctx.addSession(session);
984
1085
  ctx.store.incrementSessionsCreated();
985
1086
  ctx.persistSessionState(session);
986
1087
  await ctx.setupSessionListeners(session);
987
1088
  // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
988
- // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
989
- if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
1089
+ // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
1090
+ // skill; best-effort.
1091
+ if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
990
1092
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
991
1093
  }
992
1094
  getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
@@ -994,7 +1096,9 @@ options = {}) {
994
1096
  // Avoids serializing 2-3MB of terminal+text buffers per session creation.
995
1097
  const lightState = ctx.getSessionStateWithRespawn(session);
996
1098
  ctx.broadcast(SseEvent.SessionCreated, lightState);
997
- return { session: lightState };
1099
+ // A grouped creation also returns the layout that placed it, so the browser
1100
+ // draws the tab inside its group at once instead of after a re-read.
1101
+ return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
998
1102
  });
999
1103
  // ========== Rename Session ==========
1000
1104
  app.put('/api/sessions/:id/name', async (req) => {
@@ -2619,6 +2723,8 @@ options = {}) {
2619
2723
  // Query params:
2620
2724
  // tail=<bytes> - Only return last N bytes (faster initial load)
2621
2725
  // full=1 - Explicitly request the entire tmux scrollback (COD-47)
2726
+ // alt=recording - With full=1: if the pane is in the alternate screen, load
2727
+ // the visible frame over the byte recording instead
2622
2728
  app.get('/api/sessions/:id/terminal', async (req, reply) => {
2623
2729
  const routeStartedAt = performance.now();
2624
2730
  const { id } = req.params;
@@ -2629,7 +2735,7 @@ options = {}) {
2629
2735
  // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
2630
2736
  // no-param callers — keep the fast visible-frame capture.
2631
2737
  const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
2632
- const isFullReload = query.full === '1' || query.full === 'true';
2738
+ let isFullReload = query.full === '1' || query.full === 'true';
2633
2739
  const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
2634
2740
  // Prepend the live tmux pane buffer so tab-switch replay shows the current
2635
2741
  // on-screen frame, not just the accumulated byte history. This matters for
@@ -2646,16 +2752,48 @@ options = {}) {
2646
2752
  // for. See PaneCaptureOptions.capturedGeometry.
2647
2753
  // `lines` bounds only the history a FULL capture reads; the visible-frame
2648
2754
  // path reads no history and is untouched by it.
2649
- const captureOpts = isFullReload
2755
+ let captureOpts = isFullReload
2650
2756
  ? {
2651
2757
  fullHistory: true,
2652
2758
  historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
2653
2759
  maxCaptureBytes: terminalBufferMaxBytes,
2654
2760
  }
2655
2761
  : {};
2656
- const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
2657
- ? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
2762
+ // A remote session's local pane only sees the remote tmux client's repaints,
2763
+ // so its own scrollback is a fraction of the real one: read that from the
2764
+ // remote tmux and splice it above the local frame. Null (host unreachable,
2765
+ // no remote session, back-off) keeps the local capture as before.
2766
+ if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
2767
+ const remoteScrollback = await fetchRemoteScrollback(session.remote, session.id, captureOpts.historyLimitLines, terminalBufferMaxBytes);
2768
+ if (remoteScrollback !== null) {
2769
+ captureOpts.scrollbackOverride = remoteScrollback.scrollback;
2770
+ captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
2771
+ }
2772
+ }
2773
+ const capture = (opts) => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
2774
+ ? ctx.mux.captureActivePaneBuffer(muxName, opts)
2658
2775
  : null;
2776
+ let liveMuxBuffer = capture(captureOpts);
2777
+ // tmux adds no history while a pane is in the alternate screen, and a
2778
+ // nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
2779
+ // in a shell) holds its pane there for good, so the full capture above is
2780
+ // one screen plus whatever came before. The byte recording still carries
2781
+ // the scrollback, so a caller that opts in (a shell's automatic load,
2782
+ // `alt=recording`) gets the old visible-frame load over it, tailed, rather
2783
+ // than losing everything above the screen. `alternate_on` cannot tell a
2784
+ // nested tmux from vim or less, so those take this path too while open:
2785
+ // the load the shell had before tmux-rendered loads existed. Not when the
2786
+ // history came from the remote tmux (`scrollbackOverride`): a remote pane is
2787
+ // in the alternate screen only because it hosts that tmux's client, and
2788
+ // the fallback would throw the remote scrollback away.
2789
+ if (isFullReload &&
2790
+ query.alt === 'recording' &&
2791
+ captureOpts.scrollbackOverride === undefined &&
2792
+ captureOpts.capturedAlternateOn === true) {
2793
+ isFullReload = false;
2794
+ captureOpts = {};
2795
+ liveMuxBuffer = capture(captureOpts);
2796
+ }
2659
2797
  const captureFinishedAt = performance.now();
2660
2798
  const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
2661
2799
  const source = hasLiveMuxBuffer
@@ -2696,6 +2834,29 @@ options = {}) {
2696
2834
  // why the UI could only ever say "truncated for performance".
2697
2835
  let truncationReason = null;
2698
2836
  let cleanBuffer;
2837
+ // A `lines=` bound shallower than tmux's history left the older lines in
2838
+ // tmux: an intentional partial read, so 'tail', and a later pull with a
2839
+ // deeper (or no) bound recovers them. Without this a bounded window looks
2840
+ // like the whole history to the client. The byte cap below still wins.
2841
+ // Only a bound the CLIENT set and below the configured ceiling counts:
2842
+ // without `lines=` the bound is the ceiling itself, and on tmux before 3.7
2843
+ // a live pane can hold more than a lowered ceiling, which no request can
2844
+ // read, so calling that a recoverable cut would offer a button that can
2845
+ // never return more.
2846
+ // `historyLinesCut` tells the client this happened, apart from any byte
2847
+ // cut below: `fullSize` counts only what tmux returned, so the bytes a
2848
+ // later `tail` cut are not all that is left, and the client must not name
2849
+ // them as the remainder.
2850
+ const capturedHistoryLines = captureOpts.capturedHistoryLines;
2851
+ const historyLinesCut = isFullCapture &&
2852
+ captureOpts.historyLimitLines !== undefined &&
2853
+ captureOpts.historyLimitLines < tmuxHistoryLimit &&
2854
+ capturedHistoryLines !== undefined &&
2855
+ capturedHistoryLines > captureOpts.historyLimitLines;
2856
+ if (historyLinesCut) {
2857
+ truncated = true;
2858
+ truncationReason = 'tail';
2859
+ }
2699
2860
  // Cap the payload EARLY — before the regex normalization passes below run
2700
2861
  // over it. A full-history tmux capture can be tens of MB of scrollback;
2701
2862
  // normalizing all of it would stall the event loop only to discard most
@@ -2775,6 +2936,9 @@ options = {}) {
2775
2936
  fullSize,
2776
2937
  truncated,
2777
2938
  truncationReason,
2939
+ // The `lines=` bound left older lines in tmux (see above): the remainder
2940
+ // is more than `fullSize - retainedBytes`, by an amount unknown here.
2941
+ historyLinesCut,
2778
2942
  // `retainedBytes` is what this response actually carries; `fullSize` is
2779
2943
  // what existed before the cut. The gap is what the indicator reports.
2780
2944
  retainedBytes: cleanBuffer.length,
@@ -2798,6 +2962,15 @@ options = {}) {
2798
2962
  // damage that does not exist.
2799
2963
  captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
2800
2964
  captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
2965
+ // Rows tmux holds above the visible frame, which is the most a `full=1`
2966
+ // pull can add. `truncated` measures the BYTE stream, and for a pane that
2967
+ // keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
2968
+ // a tail cut drops are old repaints that no request can bring back, so a
2969
+ // client must not offer to load them. Absent when the pane was not read,
2970
+ // and for a remote session: its local pane hosts the remote tmux client in
2971
+ // the alternate screen, so it always reports 0, while a `full=1` load reads
2972
+ // the real scrollback from the remote tmux (`fetchRemoteScrollback`).
2973
+ paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
2801
2974
  };
2802
2975
  });
2803
2976
  // ═══════════════════════════════════════════════════════════════
@@ -3000,7 +3173,7 @@ options = {}) {
3000
3173
  const capMsg = sessionCapacityMessage(ctx.sessions, owner);
3001
3174
  if (capMsg)
3002
3175
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
3003
- 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);
3176
+ const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, } = parseBody(QuickStartSchema, req.body);
3004
3177
  // Resolved ONCE here: the same value labels a case directory this request creates
3005
3178
  // (agent-case-marker.ts) and draws the tab lineage line on the session below.
3006
3179
  const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
@@ -3288,12 +3461,14 @@ options = {}) {
3288
3461
  }
3289
3462
  }
3290
3463
  // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
3291
- // marker-guarded (a user's own skills/codeman is never touched). Claude mode only
3292
- // (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
3293
- // casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
3294
- // real host dir and the skill crosses the bind mount like the rest of `.claude/`.
3295
- if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
3296
- await injectAgentSkill(resolvedCasePath);
3464
+ // marker-guarded (a user's own copy is never touched). Each CLI that takes the
3465
+ // skill gets it in the directory it scans (`capabilities.agentSkillDir`:
3466
+ // `.claude/skills` for claude, `.agents/skills` for most others); skipped for
3467
+ // remote cases, whose casePath lives on another host. Docker cases qualify:
3468
+ // hostWorkspacePath is a real host dir and the skill crosses the bind mount.
3469
+ const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
3470
+ if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
3471
+ await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
3297
3472
  }
3298
3473
  // Docker cases: the workspace is a REAL host dir bind-mounted into the container.
3299
3474
  // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
@@ -3547,13 +3722,14 @@ options = {}) {
3547
3722
  session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
3548
3723
  }
3549
3724
  }
3550
- await ctx.addSession(session);
3725
+ const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
3551
3726
  ctx.store.incrementSessionsCreated();
3552
3727
  ctx.persistSessionState(session);
3553
3728
  await ctx.setupSessionListeners(session);
3554
3729
  // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
3555
- // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
3556
- if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
3730
+ // loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
3731
+ // skill; best-effort.
3732
+ if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
3557
3733
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
3558
3734
  }
3559
3735
  getLifecycleLog().log({
@@ -3626,6 +3802,7 @@ options = {}) {
3626
3802
  casePath: resolvedCasePath,
3627
3803
  caseName,
3628
3804
  ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
3805
+ ...(tabGroupId ? { tabLayout } : {}),
3629
3806
  };
3630
3807
  }
3631
3808
  catch (err) {
@@ -4512,6 +4689,17 @@ options = {}) {
4512
4689
  // Paste Image (clipboard / drag-drop upload)
4513
4690
  // ═══════════════════════════════════════════════════════════════
4514
4691
  const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
4692
+ // Single-sourced from the attachment registry (what the file viewer can also play),
4693
+ // so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
4694
+ // would upload but not preview, and a phone's camera roll produces neither.
4695
+ const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
4696
+ const VIDEO_MIME_EXT = {
4697
+ mp4: '.mp4',
4698
+ quicktime: '.mov',
4699
+ 'x-m4v': '.m4v',
4700
+ webm: '.webm',
4701
+ ogg: '.ogv',
4702
+ };
4515
4703
  // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
4516
4704
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
4517
4705
  // CSRF defense: state-changing routes must come from same origin.
@@ -4549,14 +4737,41 @@ options = {}) {
4549
4737
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
4550
4738
  }
4551
4739
  const { id } = req.params;
4552
- // Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
4553
- // — even an authenticated attacker can otherwise loop large image POSTs.
4554
- if (!consumePasteToken(`${req.ip}:${id}`)) {
4740
+ // `?kind=video` selects the disk-streamed path (see below). It is read here so the
4741
+ // rate check draws on the right bucket before any work: videos get 3/min, images 30.
4742
+ const isVideo = req.query.kind === 'video';
4743
+ // Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
4744
+ // authenticated client can otherwise loop large uploads. Videos get their own,
4745
+ // far lower bucket because the per-file cap is 1GB (see the bucket constants).
4746
+ const rateKey = `${req.ip}:${id}`;
4747
+ if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
4555
4748
  reply.code(429);
4556
4749
  reply.header('Retry-After', '60');
4557
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
4750
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, isVideo
4751
+ ? 'Rate limit exceeded (3 video uploads/min per session)'
4752
+ : 'Rate limit exceeded (30 uploads/min per session)');
4558
4753
  }
4559
4754
  const session = findSessionOrFail(ctx, id, req);
4755
+ // The file lands on THIS host under the session's working directory, which
4756
+ // for a remote (SSH) session is the remote path: the agent there could never
4757
+ // read it, and the write would land in a same-named local directory or fail.
4758
+ // An owned Docker case is fine, its workspace is bind-mounted at the same
4759
+ // absolute path; an adopted container (owned: false) mounts nothing, so its
4760
+ // agent reads the file only if the container exposes that host path.
4761
+ if (session.remote) {
4762
+ reply.code(400);
4763
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
4764
+ }
4765
+ // The upload is written under session.workingDir (ensureUploadDir below), and
4766
+ // a live session's workspace can sit on a network mount that stopped answering.
4767
+ // Probe it bounded before the first disk touch, as #516 requires for a path the
4768
+ // user named, so one dead mount cannot freeze a libuv threadpool worker per
4769
+ // upload. Only "unknown" (the mount is not answering) is refused here, the way
4770
+ // the create route does; absent / not-a-directory keep ensureUploadDir's own
4771
+ // errors, since a live session's workspace should be neither.
4772
+ if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
4773
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Workspace', session.workingDir, { pastCap: true }));
4774
+ }
4560
4775
  if (!req.isMultipart()) {
4561
4776
  reply.code(400);
4562
4777
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -4567,9 +4782,15 @@ options = {}) {
4567
4782
  // boundary scanner with several bugs: literal boundary matches anywhere in
4568
4783
  // body, LF-only clients silently corrupted the last byte (hard-coded \r\n
4569
4784
  // offsets), no part-count cap.
4785
+ // `?kind=video` (read above for the rate check) is the ONLY way onto the video
4786
+ // path: it selects the larger, disk-streamed budget before the part is seen
4787
+ // (busboy takes its limits up front), and it admits video types alone, so an
4788
+ // image can never ride the video cap through memory. Without it the route is
4789
+ // byte-identical to the image-only version. Truncation is checked on the stream
4790
+ // below rather than thrown, since the file is being written as it arrives.
4570
4791
  let part;
4571
4792
  try {
4572
- part = await req.file();
4793
+ part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
4573
4794
  }
4574
4795
  catch (err) {
4575
4796
  reply.code(413);
@@ -4583,6 +4804,76 @@ options = {}) {
4583
4804
  reply.code(400);
4584
4805
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
4585
4806
  }
4807
+ if (isVideo) {
4808
+ let ext = '';
4809
+ if (part.filename) {
4810
+ const origExt = extname(part.filename).toLowerCase();
4811
+ if (ALLOWED_VIDEO_EXTS.has(origExt))
4812
+ ext = origExt;
4813
+ }
4814
+ const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
4815
+ if (videoMime)
4816
+ ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
4817
+ if (!ext) {
4818
+ await finished(part.file.resume()).catch(() => { });
4819
+ reply.code(400);
4820
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
4821
+ }
4822
+ const imageDir = await ensureUploadDir(session.workingDir);
4823
+ if (!imageDir) {
4824
+ await finished(part.file.resume()).catch(() => { });
4825
+ reply.code(403);
4826
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4827
+ }
4828
+ const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
4829
+ // Written as it arrives: a phone video is far too large to buffer the way an
4830
+ // image is. The container is judged on the first 12 bytes as soon as they are
4831
+ // in, so a mislabeled upload stops touching disk at its first chunk; the rest
4832
+ // of the body is drained unwritten (as busboy does past its own size limit),
4833
+ // which is what lets the answer reach the client. A transfer that dies midway
4834
+ // (the phone left the network) leaves no partial file behind.
4835
+ const head = Buffer.alloc(12);
4836
+ let headLen = 0;
4837
+ let mislabeled = false;
4838
+ try {
4839
+ for await (const chunk of part.file) {
4840
+ if (mislabeled)
4841
+ continue;
4842
+ if (headLen < head.length) {
4843
+ headLen += chunk.copy(head, headLen, 0, head.length - headLen);
4844
+ mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
4845
+ if (mislabeled)
4846
+ continue;
4847
+ }
4848
+ // writeFile loops until the whole chunk is on disk; a bare write() may be short.
4849
+ await fh.writeFile(chunk);
4850
+ }
4851
+ }
4852
+ catch (err) {
4853
+ await fs.unlink(filepath).catch(() => { });
4854
+ throw err;
4855
+ }
4856
+ finally {
4857
+ await fh.close();
4858
+ }
4859
+ let failure = null;
4860
+ if (headLen === 0)
4861
+ failure = { status: 400, message: 'Empty file' };
4862
+ else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
4863
+ 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')}`);
4864
+ failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
4865
+ }
4866
+ else if (part.file.truncated) {
4867
+ const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
4868
+ failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
4869
+ }
4870
+ if (failure) {
4871
+ await fs.unlink(filepath).catch(() => { });
4872
+ reply.code(failure.status);
4873
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
4874
+ }
4875
+ return { path: filepath, filename };
4876
+ }
4586
4877
  let imageBytes;
4587
4878
  try {
4588
4879
  imageBytes = await part.toBuffer();
@@ -4646,49 +4937,13 @@ options = {}) {
4646
4937
  reply.code(415);
4647
4938
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
4648
4939
  }
4649
- // Save to {workingDir}/.claude-images/
4650
- // Refuse symlinks at imageDir — an agent or postinstall script could plant
4651
- // `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
4652
- // We lstat (not stat) so we see the symlink itself. Use mkdir without
4653
- // `recursive` so the leaf creation does not follow a symlink either, and
4654
- // O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
4655
- const imageDir = join(session.workingDir, '.claude-images');
4656
- try {
4657
- const dirStat = await fs.lstat(imageDir);
4658
- if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
4659
- reply.code(403);
4660
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
4661
- }
4662
- }
4663
- catch (err) {
4664
- if (err.code !== 'ENOENT')
4665
- throw err;
4666
- // Non-recursive mkdir: does not follow symlinks for the leaf.
4667
- // session.workingDir is guaranteed to exist (live session).
4668
- try {
4669
- await fs.mkdir(imageDir);
4670
- }
4671
- catch (mkErr) {
4672
- // Concurrent uploads (a batch of photos) race to create .claude-images —
4673
- // the losers get EEXIST. Treat an already-present REAL directory as
4674
- // success, but re-verify it isn't a symlink a racing actor planted
4675
- // (preserve the symlink-safety guarantee above).
4676
- if (mkErr.code !== 'EEXIST')
4677
- throw mkErr;
4678
- const raceStat = await fs.lstat(imageDir);
4679
- if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
4680
- reply.code(403);
4681
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
4682
- }
4683
- }
4940
+ // {workingDir}/.codeman-uploads/, see ensureUploadDir.
4941
+ const imageDir = await ensureUploadDir(session.workingDir);
4942
+ if (!imageDir) {
4943
+ reply.code(403);
4944
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4684
4945
  }
4685
- // Date.now() collides on same-ms uploads from two tabs (last-write wins
4686
- // silently). Append 8 hex chars so concurrent pastes get distinct names.
4687
- const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
4688
- const filepath = join(imageDir, filename);
4689
- // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
4690
- // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
4691
- const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
4946
+ const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
4692
4947
  try {
4693
4948
  await fh.writeFile(imageBytes);
4694
4949
  }