aicodeman 1.41.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 (287) hide show
  1. package/README.md +58 -8
  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 +12 -8
  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 +8 -0
  19. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  20. package/dist/config/cli-registry/schema.js +12 -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 +42 -0
  24. package/dist/config/cli-registry/stock.js.map +1 -1
  25. package/dist/config/cli-registry/types.d.ts +35 -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/hooks-config.d.ts +36 -16
  32. package/dist/hooks-config.d.ts.map +1 -1
  33. package/dist/hooks-config.js +45 -20
  34. package/dist/hooks-config.js.map +1 -1
  35. package/dist/html-view-capabilities.d.ts +54 -0
  36. package/dist/html-view-capabilities.d.ts.map +1 -0
  37. package/dist/html-view-capabilities.js +103 -0
  38. package/dist/html-view-capabilities.js.map +1 -0
  39. package/dist/mcp-server.d.ts +114 -0
  40. package/dist/mcp-server.d.ts.map +1 -0
  41. package/dist/mcp-server.js +917 -0
  42. package/dist/mcp-server.js.map +1 -0
  43. package/dist/mcp-sync.d.ts +5 -0
  44. package/dist/mcp-sync.d.ts.map +1 -1
  45. package/dist/mcp-sync.js +51 -5
  46. package/dist/mcp-sync.js.map +1 -1
  47. package/dist/mux-interface.d.ts +36 -1
  48. package/dist/mux-interface.d.ts.map +1 -1
  49. package/dist/remote-files.d.ts +37 -0
  50. package/dist/remote-files.d.ts.map +1 -1
  51. package/dist/remote-files.js +103 -0
  52. package/dist/remote-files.js.map +1 -1
  53. package/dist/remote-pane-history.d.ts +103 -0
  54. package/dist/remote-pane-history.d.ts.map +1 -0
  55. package/dist/remote-pane-history.js +258 -0
  56. package/dist/remote-pane-history.js.map +1 -0
  57. package/dist/remote-ssh-limiter.d.ts +16 -3
  58. package/dist/remote-ssh-limiter.d.ts.map +1 -1
  59. package/dist/remote-ssh-limiter.js +37 -6
  60. package/dist/remote-ssh-limiter.js.map +1 -1
  61. package/dist/session.d.ts +29 -1
  62. package/dist/session.d.ts.map +1 -1
  63. package/dist/session.js +53 -0
  64. package/dist/session.js.map +1 -1
  65. package/dist/tab-layout-legacy-order.d.ts.map +1 -1
  66. package/dist/tab-layout-legacy-order.js +72 -13
  67. package/dist/tab-layout-legacy-order.js.map +1 -1
  68. package/dist/tab-layout-service.d.ts +33 -2
  69. package/dist/tab-layout-service.d.ts.map +1 -1
  70. package/dist/tab-layout-service.js +102 -7
  71. package/dist/tab-layout-service.js.map +1 -1
  72. package/dist/tab-layout.d.ts +9 -0
  73. package/dist/tab-layout.d.ts.map +1 -1
  74. package/dist/tab-layout.js +15 -2
  75. package/dist/tab-layout.js.map +1 -1
  76. package/dist/tmux-manager.d.ts +71 -2
  77. package/dist/tmux-manager.d.ts.map +1 -1
  78. package/dist/tmux-manager.js +169 -18
  79. package/dist/tmux-manager.js.map +1 -1
  80. package/dist/types/session.d.ts +53 -0
  81. package/dist/types/session.d.ts.map +1 -1
  82. package/dist/types/session.js.map +1 -1
  83. package/dist/usage-telemetry.d.ts +44 -7
  84. package/dist/usage-telemetry.d.ts.map +1 -1
  85. package/dist/usage-telemetry.js +72 -7
  86. package/dist/usage-telemetry.js.map +1 -1
  87. package/dist/web/approval-inbox.d.ts +12 -0
  88. package/dist/web/approval-inbox.d.ts.map +1 -1
  89. package/dist/web/approval-inbox.js +1 -0
  90. package/dist/web/approval-inbox.js.map +1 -1
  91. package/dist/web/middleware/auth.d.ts.map +1 -1
  92. package/dist/web/middleware/auth.js +33 -0
  93. package/dist/web/middleware/auth.js.map +1 -1
  94. package/dist/web/ports/session-port.d.ts +8 -1
  95. package/dist/web/ports/session-port.d.ts.map +1 -1
  96. package/dist/web/public/admin-ui.js.gz +0 -0
  97. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  98. package/dist/web/public/app.24689381.js +43 -0
  99. package/dist/web/public/app.24689381.js.br +0 -0
  100. package/dist/web/public/app.24689381.js.gz +0 -0
  101. package/dist/web/public/approvals-ui.js +4 -0
  102. package/dist/web/public/approvals-ui.js.br +0 -0
  103. package/dist/web/public/approvals-ui.js.gz +0 -0
  104. package/dist/web/public/{constants.bc9c1f63.js → constants.17d77b65.js} +395 -7
  105. package/dist/web/public/constants.17d77b65.js.br +0 -0
  106. package/dist/web/public/constants.17d77b65.js.gz +0 -0
  107. package/dist/web/public/cron-ui.js.gz +0 -0
  108. package/dist/web/public/entrance-animations.js.gz +0 -0
  109. package/dist/web/public/gesture/gesture-codeman.js +112 -6
  110. package/dist/web/public/git-status-ui.js.gz +0 -0
  111. package/dist/web/public/home-sessions.js.gz +0 -0
  112. package/dist/web/public/host-wake-ui.js.gz +0 -0
  113. package/dist/web/public/i18n.9afd8e4d.js +2 -0
  114. package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
  115. package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
  116. package/dist/web/public/{image-input.e00471c6.js → image-input.9ed1d0d2.js} +186 -16
  117. package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
  118. package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
  119. package/dist/web/public/index.html +105 -23
  120. package/dist/web/public/index.html.br +0 -0
  121. package/dist/web/public/index.html.gz +0 -0
  122. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  123. package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
  124. package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
  125. package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
  126. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  127. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  128. package/dist/web/public/mobile-overview.js +11 -2
  129. package/dist/web/public/mobile-overview.js.br +0 -0
  130. package/dist/web/public/mobile-overview.js.gz +0 -0
  131. package/dist/web/public/mobile.2dff1580.css +1 -0
  132. package/dist/web/public/mobile.2dff1580.css.br +0 -0
  133. package/dist/web/public/mobile.2dff1580.css.gz +0 -0
  134. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  135. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  136. package/dist/web/public/{panels-ui.71846fcb.js → panels-ui.73ec0137.js} +87 -87
  137. package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
  138. package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
  139. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  140. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  141. package/dist/web/public/readmymind-ui.js.gz +0 -0
  142. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  143. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  144. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  145. package/dist/web/public/session-lineage.js.gz +0 -0
  146. package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
  147. package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
  148. package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
  149. package/dist/web/public/{settings-ui.4e33390f.js → settings-ui.81ac264e.js} +9 -9
  150. package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
  151. package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
  152. package/dist/web/public/spreadsheet-preview-worker.js +11 -0
  153. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  154. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  155. package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
  156. package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
  157. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  158. package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
  159. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  160. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  161. package/dist/web/public/styles.900a28e4.css +1 -0
  162. package/dist/web/public/styles.900a28e4.css.br +0 -0
  163. package/dist/web/public/styles.900a28e4.css.gz +0 -0
  164. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  165. package/dist/web/public/sw.js +6 -2
  166. package/dist/web/public/sw.js.br +0 -0
  167. package/dist/web/public/sw.js.gz +0 -0
  168. package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
  169. package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
  170. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  171. package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
  172. package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
  173. package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
  174. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  175. package/dist/web/public/terminal-split.js.gz +0 -0
  176. package/dist/web/public/terminal-tile.js +44 -24
  177. package/dist/web/public/terminal-tile.js.br +0 -0
  178. package/dist/web/public/terminal-tile.js.gz +0 -0
  179. package/dist/web/public/terminal-ui.858f5da5.js +2 -0
  180. package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
  181. package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
  182. package/dist/web/public/tile-grid.js +439 -18
  183. package/dist/web/public/tile-grid.js.br +0 -0
  184. package/dist/web/public/tile-grid.js.gz +0 -0
  185. package/dist/web/public/ultracode-panel.js.gz +0 -0
  186. package/dist/web/public/ultracode-windows.js.gz +0 -0
  187. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  188. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  189. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  190. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  191. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  192. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  193. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  194. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  195. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  196. package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
  197. package/dist/web/public/vendor/xterm.css.gz +0 -0
  198. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  199. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  200. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  201. package/dist/web/public/webview-tabs.js +12 -0
  202. package/dist/web/public/webview-tabs.js.br +0 -0
  203. package/dist/web/public/webview-tabs.js.gz +0 -0
  204. package/dist/web/route-helpers.d.ts +8 -0
  205. package/dist/web/route-helpers.d.ts.map +1 -1
  206. package/dist/web/route-helpers.js +8 -0
  207. package/dist/web/route-helpers.js.map +1 -1
  208. package/dist/web/routes/admin-routes.d.ts.map +1 -1
  209. package/dist/web/routes/admin-routes.js +4 -1
  210. package/dist/web/routes/admin-routes.js.map +1 -1
  211. package/dist/web/routes/approval-routes.d.ts.map +1 -1
  212. package/dist/web/routes/approval-routes.js +6 -8
  213. package/dist/web/routes/approval-routes.js.map +1 -1
  214. package/dist/web/routes/case-routes.js +1 -1
  215. package/dist/web/routes/case-routes.js.map +1 -1
  216. package/dist/web/routes/file-routes.d.ts +46 -1
  217. package/dist/web/routes/file-routes.d.ts.map +1 -1
  218. package/dist/web/routes/file-routes.js +214 -27
  219. package/dist/web/routes/file-routes.js.map +1 -1
  220. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  221. package/dist/web/routes/hook-event-routes.js +8 -1
  222. package/dist/web/routes/hook-event-routes.js.map +1 -1
  223. package/dist/web/routes/html-view-routes.d.ts +60 -0
  224. package/dist/web/routes/html-view-routes.d.ts.map +1 -0
  225. package/dist/web/routes/html-view-routes.js +288 -0
  226. package/dist/web/routes/html-view-routes.js.map +1 -0
  227. package/dist/web/routes/index.d.ts +1 -0
  228. package/dist/web/routes/index.d.ts.map +1 -1
  229. package/dist/web/routes/index.js +1 -0
  230. package/dist/web/routes/index.js.map +1 -1
  231. package/dist/web/routes/reboot-restore-routes.js +2 -2
  232. package/dist/web/routes/reboot-restore-routes.js.map +1 -1
  233. package/dist/web/routes/session-routes.d.ts +1 -0
  234. package/dist/web/routes/session-routes.d.ts.map +1 -1
  235. package/dist/web/routes/session-routes.js +278 -56
  236. package/dist/web/routes/session-routes.js.map +1 -1
  237. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  238. package/dist/web/routes/status-telemetry-routes.js +16 -2
  239. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  240. package/dist/web/schemas.d.ts +29 -0
  241. package/dist/web/schemas.d.ts.map +1 -1
  242. package/dist/web/schemas.js +65 -0
  243. package/dist/web/schemas.js.map +1 -1
  244. package/dist/web/server.d.ts +6 -1
  245. package/dist/web/server.d.ts.map +1 -1
  246. package/dist/web/server.js +62 -14
  247. package/dist/web/server.js.map +1 -1
  248. package/dist/web/sse-events.d.ts +5 -2
  249. package/dist/web/sse-events.d.ts.map +1 -1
  250. package/dist/web/sse-events.js +5 -2
  251. package/dist/web/sse-events.js.map +1 -1
  252. package/package.json +2 -1
  253. package/skills/codeman/SKILL.md +29 -12
  254. package/dist/web/public/app.6f8552e1.js +0 -43
  255. package/dist/web/public/app.6f8552e1.js.br +0 -0
  256. package/dist/web/public/app.6f8552e1.js.gz +0 -0
  257. package/dist/web/public/constants.bc9c1f63.js.br +0 -0
  258. package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
  259. package/dist/web/public/i18n.a9840ea2.js +0 -2
  260. package/dist/web/public/i18n.a9840ea2.js.br +0 -0
  261. package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
  262. package/dist/web/public/image-input.e00471c6.js.br +0 -0
  263. package/dist/web/public/image-input.e00471c6.js.gz +0 -0
  264. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
  265. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  266. package/dist/web/public/mobile.111b187d.css +0 -1
  267. package/dist/web/public/mobile.111b187d.css.br +0 -0
  268. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  269. package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
  270. package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
  271. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  272. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  273. package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
  274. package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
  275. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  276. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  277. package/dist/web/public/styles.81a72906.css +0 -1
  278. package/dist/web/public/styles.81a72906.css.br +0 -0
  279. package/dist/web/public/styles.81a72906.css.gz +0 -0
  280. package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
  281. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  282. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  283. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  284. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  285. package/dist/web/public/terminal-ui.b7267005.js +0 -2
  286. package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
  287. package/dist/web/public/terminal-ui.b7267005.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';
@@ -219,6 +223,19 @@ export function imageMagicMatchesExt(data, ext) {
219
223
  const brand = data.subarray(8, 12).toString('ascii');
220
224
  return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
221
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;
222
239
  default:
223
240
  return false;
224
241
  }
@@ -235,7 +252,9 @@ export function imageMagicMatchesExt(data, ext) {
235
252
  * touched, and a file already there is theirs and stays as it is.
236
253
  */
237
254
  async function ensureUploadDir(workingDir) {
238
- // workingDir is guaranteed to exist (live session).
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.
239
258
  const uploadDir = join(workingDir, UPLOADS_DIR);
240
259
  try {
241
260
  const dirStat = await fs.lstat(uploadDir);
@@ -275,37 +294,59 @@ async function ensureUploadDir(workingDir) {
275
294
  }
276
295
  return uploadDir;
277
296
  }
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.
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.
281
313
  const PASTE_RATE_TOKENS = 30;
282
- const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
314
+ const VIDEO_RATE_TOKENS = 3;
283
315
  const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
284
316
  const PASTE_BUCKET_GC_THRESHOLD = 1000;
285
317
  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) {
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) {
289
322
  if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
290
- pasteRateBuckets.delete(k);
323
+ buckets.delete(k);
291
324
  }
292
325
  }
293
- let b = pasteRateBuckets.get(key);
326
+ let b = buckets.get(key);
294
327
  if (!b) {
295
- b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
296
- pasteRateBuckets.set(key, b);
328
+ b = { tokens: capacity, lastRefill: now };
329
+ buckets.set(key, b);
297
330
  }
298
- const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
299
- b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
331
+ b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
300
332
  b.lastRefill = now;
301
333
  if (b.tokens < 1)
302
334
  return false;
303
335
  b.tokens -= 1;
304
336
  return true;
305
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
+ }
306
346
  // Test hook: reset between runs.
307
347
  export function _resetPasteRateBuckets() {
308
348
  pasteRateBuckets.clear();
349
+ videoRateBuckets.clear();
309
350
  }
310
351
  /**
311
352
  * Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
@@ -626,8 +667,8 @@ function abortOnClientHangUp(reply) {
626
667
  * Injection is best-effort and stays that way: neither a refusal nor a thrown error
627
668
  * may fail the create.
628
669
  */
629
- async function injectAgentSkill(casePath) {
630
- const skillDir = join(casePath, '.claude', 'skills', 'codeman');
670
+ async function injectAgentSkill(casePath, dir) {
671
+ const skillDir = agentSkillDirFor(casePath, dir);
631
672
  try {
632
673
  // Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
633
674
  // written once by `codeman skill install`) over the case copy injected below, so a
@@ -636,7 +677,7 @@ async function injectAgentSkill(casePath) {
636
677
  // current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
637
678
  // since a foreign user copy is the user's own authored skill, not a config error.
638
679
  await refreshUserAgentSkill();
639
- const result = await applyAgentSkill(casePath, true);
680
+ const result = await applyAgentSkill(casePath, true, dir);
640
681
  if (result === 'foreign') {
641
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.`);
642
683
  }
@@ -759,6 +800,7 @@ options = {}) {
759
800
  // logout has to retire them too or every dashboard URL opened during this
760
801
  // login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
761
802
  webviewCapabilities.revokeOwner(ownerFor(req));
803
+ htmlViewCapabilities.revokeOwner(ownerFor(req));
762
804
  reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
763
805
  return {};
764
806
  });
@@ -920,13 +962,6 @@ options = {}) {
920
962
  // materializing in ~/.claude/settings.local.json was never asked for.
921
963
  if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
922
964
  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
965
  }
931
966
  // Refuse up front if the requested CLI cannot start, rather than spawning a pane that
932
967
  // dies on `command not found`. The message comes from the resolver, so it names where
@@ -945,6 +980,17 @@ options = {}) {
945
980
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
946
981
  }
947
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
+ }
948
994
  // Pre-validate resumeSessionId: check that the conversation file actually exists
949
995
  // in Claude's projects directory. If not, skip resume to avoid confusing
950
996
  // "No conversation found" errors from Claude CLI.
@@ -1033,13 +1079,16 @@ options = {}) {
1033
1079
  owner,
1034
1080
  parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
1035
1081
  });
1036
- await ctx.addSession(session);
1082
+ const tabLayout = body.tabGroupId
1083
+ ? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
1084
+ : await ctx.addSession(session);
1037
1085
  ctx.store.incrementSessionsCreated();
1038
1086
  ctx.persistSessionState(session);
1039
1087
  await ctx.setupSessionListeners(session);
1040
1088
  // 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())) {
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())) {
1043
1092
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
1044
1093
  }
1045
1094
  getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
@@ -1047,7 +1096,9 @@ options = {}) {
1047
1096
  // Avoids serializing 2-3MB of terminal+text buffers per session creation.
1048
1097
  const lightState = ctx.getSessionStateWithRespawn(session);
1049
1098
  ctx.broadcast(SseEvent.SessionCreated, lightState);
1050
- 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 } : {}) };
1051
1102
  });
1052
1103
  // ========== Rename Session ==========
1053
1104
  app.put('/api/sessions/:id/name', async (req) => {
@@ -2672,6 +2723,8 @@ options = {}) {
2672
2723
  // Query params:
2673
2724
  // tail=<bytes> - Only return last N bytes (faster initial load)
2674
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
2675
2728
  app.get('/api/sessions/:id/terminal', async (req, reply) => {
2676
2729
  const routeStartedAt = performance.now();
2677
2730
  const { id } = req.params;
@@ -2682,7 +2735,7 @@ options = {}) {
2682
2735
  // Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
2683
2736
  // no-param callers — keep the fast visible-frame capture.
2684
2737
  const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
2685
- const isFullReload = query.full === '1' || query.full === 'true';
2738
+ let isFullReload = query.full === '1' || query.full === 'true';
2686
2739
  const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
2687
2740
  // Prepend the live tmux pane buffer so tab-switch replay shows the current
2688
2741
  // on-screen frame, not just the accumulated byte history. This matters for
@@ -2699,16 +2752,48 @@ options = {}) {
2699
2752
  // for. See PaneCaptureOptions.capturedGeometry.
2700
2753
  // `lines` bounds only the history a FULL capture reads; the visible-frame
2701
2754
  // path reads no history and is untouched by it.
2702
- const captureOpts = isFullReload
2755
+ let captureOpts = isFullReload
2703
2756
  ? {
2704
2757
  fullHistory: true,
2705
2758
  historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
2706
2759
  maxCaptureBytes: terminalBufferMaxBytes,
2707
2760
  }
2708
2761
  : {};
2709
- const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
2710
- ? 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)
2711
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
+ }
2712
2797
  const captureFinishedAt = performance.now();
2713
2798
  const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
2714
2799
  const source = hasLiveMuxBuffer
@@ -2749,6 +2834,29 @@ options = {}) {
2749
2834
  // why the UI could only ever say "truncated for performance".
2750
2835
  let truncationReason = null;
2751
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
+ }
2752
2860
  // Cap the payload EARLY — before the regex normalization passes below run
2753
2861
  // over it. A full-history tmux capture can be tens of MB of scrollback;
2754
2862
  // normalizing all of it would stall the event loop only to discard most
@@ -2828,6 +2936,9 @@ options = {}) {
2828
2936
  fullSize,
2829
2937
  truncated,
2830
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,
2831
2942
  // `retainedBytes` is what this response actually carries; `fullSize` is
2832
2943
  // what existed before the cut. The gap is what the indicator reports.
2833
2944
  retainedBytes: cleanBuffer.length,
@@ -2851,6 +2962,15 @@ options = {}) {
2851
2962
  // damage that does not exist.
2852
2963
  captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
2853
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,
2854
2974
  };
2855
2975
  });
2856
2976
  // ═══════════════════════════════════════════════════════════════
@@ -3053,7 +3173,7 @@ options = {}) {
3053
3173
  const capMsg = sessionCapacityMessage(ctx.sessions, owner);
3054
3174
  if (capMsg)
3055
3175
  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);
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);
3057
3177
  // Resolved ONCE here: the same value labels a case directory this request creates
3058
3178
  // (agent-case-marker.ts) and draws the tab lineage line on the session below.
3059
3179
  const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
@@ -3341,12 +3461,14 @@ options = {}) {
3341
3461
  }
3342
3462
  }
3343
3463
  // 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);
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);
3350
3472
  }
3351
3473
  // Docker cases: the workspace is a REAL host dir bind-mounted into the container.
3352
3474
  // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
@@ -3600,13 +3722,14 @@ options = {}) {
3600
3722
  session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
3601
3723
  }
3602
3724
  }
3603
- await ctx.addSession(session);
3725
+ const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
3604
3726
  ctx.store.incrementSessionsCreated();
3605
3727
  ctx.persistSessionState(session);
3606
3728
  await ctx.setupSessionListeners(session);
3607
3729
  // 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())) {
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())) {
3610
3733
  await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
3611
3734
  }
3612
3735
  getLifecycleLog().log({
@@ -3679,6 +3802,7 @@ options = {}) {
3679
3802
  casePath: resolvedCasePath,
3680
3803
  caseName,
3681
3804
  ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
3805
+ ...(tabGroupId ? { tabLayout } : {}),
3682
3806
  };
3683
3807
  }
3684
3808
  catch (err) {
@@ -4565,6 +4689,17 @@ options = {}) {
4565
4689
  // Paste Image (clipboard / drag-drop upload)
4566
4690
  // ═══════════════════════════════════════════════════════════════
4567
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
+ };
4568
4703
  // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
4569
4704
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
4570
4705
  // CSRF defense: state-changing routes must come from same origin.
@@ -4602,12 +4737,19 @@ options = {}) {
4602
4737
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
4603
4738
  }
4604
4739
  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}`)) {
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))) {
4608
4748
  reply.code(429);
4609
4749
  reply.header('Retry-After', '60');
4610
- 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)');
4611
4753
  }
4612
4754
  const session = findSessionOrFail(ctx, id, req);
4613
4755
  // The file lands on THIS host under the session's working directory, which
@@ -4620,6 +4762,16 @@ options = {}) {
4620
4762
  reply.code(400);
4621
4763
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
4622
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
+ }
4623
4775
  if (!req.isMultipart()) {
4624
4776
  reply.code(400);
4625
4777
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -4630,9 +4782,15 @@ options = {}) {
4630
4782
  // boundary scanner with several bugs: literal boundary matches anywhere in
4631
4783
  // body, LF-only clients silently corrupted the last byte (hard-coded \r\n
4632
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.
4633
4791
  let part;
4634
4792
  try {
4635
- part = await req.file();
4793
+ part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
4636
4794
  }
4637
4795
  catch (err) {
4638
4796
  reply.code(413);
@@ -4646,6 +4804,76 @@ options = {}) {
4646
4804
  reply.code(400);
4647
4805
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
4648
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
+ }
4649
4877
  let imageBytes;
4650
4878
  try {
4651
4879
  imageBytes = await part.toBuffer();
@@ -4715,13 +4943,7 @@ options = {}) {
4715
4943
  reply.code(403);
4716
4944
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
4717
4945
  }
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);
4946
+ const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
4725
4947
  try {
4726
4948
  await fh.writeFile(imageBytes);
4727
4949
  }