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
@@ -110,9 +110,27 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
110
110
  const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
111
111
  const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
112
112
  const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
113
+ const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
114
+ const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
115
+ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
113
116
  const THROTTLE_DELAY_MS = 100; // General UI throttle delay
114
117
  const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
115
118
  const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
119
+ // A shell's automatic loads (tab select, drop recovery, a tile's load) read
120
+ // tmux's RENDERED history, never the raw byte recording: that recording holds
121
+ // full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
122
+ // the moment, and replayed into a terminal of another height each repaint
123
+ // scrolls its top row into scrollback, so one line shows up dozens of times.
124
+ // `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
125
+ // so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
126
+ // the older lines as before. `alt=recording`: a pane in the alternate screen (a
127
+ // nested tmux, as in remote SSH and Docker cases, but also vim or less while
128
+ // open) gains no tmux history, so the server loads it the old way, visible
129
+ // frame over the tailed byte recording.
130
+ // A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
131
+ const SHELL_LOAD_HISTORY_LINES = 10000;
132
+ const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
133
+ const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
116
134
  const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
117
135
  const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
118
136
  const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
@@ -706,6 +724,45 @@ function computeConnectionLossUi(input) {
706
724
  };
707
725
  }
708
726
 
727
+ // Session Options → Respawn → "Prompt cache": one sentence of the main
728
+ // conversation's cache state as Claude Code reports it on the statusline
729
+ // (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
730
+ // because the row refreshes on SSE, not on a timer, and a countdown would sit
731
+ // stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
732
+ // session's `statusLineExporter`: a launch without the exporter never reports, so
733
+ // it says why instead of "not reported yet" forever.
734
+ const PROMPT_CACHE_NOT_COLLECTED = {
735
+ 'collection-off':
736
+ 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
737
+ 'workspace-statusline':
738
+ "Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
739
+ remote: 'Not collected for remote and Docker sessions.',
740
+ };
741
+ function formatPromptCacheStatus(cache, now, statusLine) {
742
+ if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
743
+ if (!cache || typeof cache.warm !== 'boolean') {
744
+ return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
745
+ }
746
+ const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
747
+ const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
748
+ const stake = cache.recacheTokensIfCold;
749
+ const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
750
+ const parts = [];
751
+ if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
752
+ const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
753
+ parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
754
+ if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
755
+ } else {
756
+ parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
757
+ }
758
+ // A compaction or a tool-result clearing leaves no re-cache stake to show until the next
759
+ // request; naming when it happened explains the gap. Never an alert.
760
+ if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
761
+ if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
762
+ if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
763
+ return parts.join(' ');
764
+ }
765
+
709
766
  // SSE staleness policy: is this stream a zombie?
710
767
  //
711
768
  // An EventSource that stops delivering does not always error. A proxy that
@@ -1017,6 +1074,56 @@ function tabClusterNameSplit(name, label) {
1017
1074
  return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
1018
1075
  }
1019
1076
 
1077
+ /**
1078
+ * Session-list search: the vertical rail's search box and the sidebar's filter
1079
+ * box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
1080
+ * Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
1081
+ * Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
1082
+ * @param {unknown} query
1083
+ * @returns {string} the needle, '' when there is nothing to search for
1084
+ */
1085
+ function tabSearchNeedle(query) {
1086
+ return typeof query === 'string' ? query.trim().toLowerCase() : '';
1087
+ }
1088
+
1089
+ /**
1090
+ * Which rows a search hides. Pure: the caller reads the rows off the list it
1091
+ * rendered and applies the result as classes, so the list itself (grouping,
1092
+ * order, Alt+N badges) is never rebuilt or reordered by a search.
1093
+ *
1094
+ * A row flagged `keep: true` is never hidden, matching or not (the caller keeps
1095
+ * a tab with an alert on screen: a prompt waiting on you is never hidden by a
1096
+ * view filter). It counts toward its section, so its group stays on screen with
1097
+ * it, but not toward `matchCount`.
1098
+ *
1099
+ * @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
1100
+ * in list order; `section` is the row's group or case box, null/undefined for none.
1101
+ * @param {unknown} query
1102
+ * @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
1103
+ * `counts` is the rows left showing per section (kept rows included), every
1104
+ * section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
1105
+ * number of rows whose TEXT matched, so it can be 0 above a lone kept row.
1106
+ */
1107
+ function filterTabSearchRows(rows, query) {
1108
+ const needle = tabSearchNeedle(query);
1109
+ const hidden = new Set();
1110
+ const counts = new Map();
1111
+ let matchCount = 0;
1112
+ for (const row of Array.isArray(rows) ? rows : []) {
1113
+ const hasSection = row.section !== null && row.section !== undefined;
1114
+ if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
1115
+ const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
1116
+ const matches = !needle || text.includes(needle);
1117
+ if (!matches && row.keep !== true) {
1118
+ hidden.add(row.key);
1119
+ continue;
1120
+ }
1121
+ if (matches) matchCount++;
1122
+ if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
1123
+ }
1124
+ return { active: needle.length > 0, hidden, counts, matchCount };
1125
+ }
1126
+
1020
1127
  // Terminal font stack — the single source for every xterm surface (the main
1021
1128
  // terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
1022
1129
  // "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1298,6 +1405,63 @@ function cleanCopiedSelection(text, options) {
1298
1405
  return lines.join('\n');
1299
1406
  }
1300
1407
 
1408
+ // ── Markdown heading anchors ────────────────────────────────────────────────
1409
+ // marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
1410
+ // nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
1411
+ // root, not at the page, so letting the browser follow it navigates the app away. The click delegate
1412
+ // (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
1413
+ // below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
1414
+ // the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
1415
+
1416
+ /**
1417
+ * GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
1418
+ * dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
1419
+ */
1420
+ function markdownHeadingSlug(text) {
1421
+ return String(text ?? '')
1422
+ .trim()
1423
+ .toLowerCase()
1424
+ .replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
1425
+ .replace(/ /g, '-');
1426
+ }
1427
+
1428
+ /** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
1429
+ function assignMarkdownHeadingAnchors(root) {
1430
+ const used = new Set();
1431
+ for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
1432
+ const base = markdownHeadingSlug(heading.textContent);
1433
+ let slug = base;
1434
+ for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
1435
+ used.add(slug);
1436
+ heading.dataset.mdAnchor = slug;
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
1442
+ * points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
1443
+ * slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
1444
+ * only (never `document.getElementById`, which could find an app element of the same name).
1445
+ */
1446
+ function findMarkdownAnchorTarget(root, href) {
1447
+ let fragment = String(href ?? '').replace(/^#/, '');
1448
+ try {
1449
+ fragment = decodeURIComponent(fragment);
1450
+ } catch {
1451
+ /* a malformed escape: use it as written */
1452
+ }
1453
+ if (!fragment) return root;
1454
+ assignMarkdownHeadingAnchors(root);
1455
+ const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
1456
+ for (const heading of root.querySelectorAll('[data-md-anchor]')) {
1457
+ if (wanted.includes(heading.dataset.mdAnchor)) return heading;
1458
+ }
1459
+ for (const el of root.querySelectorAll('[id]')) {
1460
+ if (el.id === fragment) return el;
1461
+ }
1462
+ return null;
1463
+ }
1464
+
1301
1465
  if (typeof window !== 'undefined') {
1302
1466
  window.WEBGL_FALLBACK = WEBGL_FALLBACK;
1303
1467
  window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1337,6 +1501,9 @@ if (typeof window !== 'undefined') {
1337
1501
  compute: computeSseStale,
1338
1502
  TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
1339
1503
  };
1504
+ window.CodemanPromptCache = {
1505
+ format: formatPromptCacheStatus,
1506
+ };
1340
1507
  window.CodemanSessionOrder = {
1341
1508
  RANK: SESSION_ACTIVITY_RANK,
1342
1509
  anchor: sessionActivityAnchor,
@@ -1355,6 +1522,10 @@ if (typeof window !== 'undefined') {
1355
1522
  groupFor: tabTriageGroupFor,
1356
1523
  layout: computeTabTriageLayout,
1357
1524
  };
1525
+ window.CodemanTabSearch = {
1526
+ needle: tabSearchNeedle,
1527
+ filter: filterTabSearchRows,
1528
+ };
1358
1529
  window.CodemanInputLimit = {
1359
1530
  FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
1360
1531
  PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1367,6 +1538,11 @@ if (typeof window !== 'undefined') {
1367
1538
  window.CodemanCopySelection = {
1368
1539
  clean: cleanCopiedSelection,
1369
1540
  };
1541
+ window.CodemanMarkdownAnchors = {
1542
+ slug: markdownHeadingSlug,
1543
+ assign: assignMarkdownHeadingAnchors,
1544
+ find: findMarkdownAnchorTarget,
1545
+ };
1370
1546
  window.CodemanTerminalFont = {
1371
1547
  DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
1372
1548
  resolve: resolveTerminalFontFamily,
@@ -1388,6 +1564,15 @@ function scheduleBackground(fn) {
1388
1564
  // but server-sent terminal buffers may still contain markers from Claude CLI.
1389
1565
  const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
1390
1566
 
1567
+ // OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
1568
+ // xterm parses it after chunkedTerminalWrite has already released the load
1569
+ // gate, so an OSC 52 left in would re-copy old text on every tab switch. The
1570
+ // 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
1571
+ const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
1572
+ // The same OSC 52 text arriving again within this window is one copy delivered
1573
+ // several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
1574
+ const OSC52_DEDUPE_MS = 1000;
1575
+
1391
1576
  // Built-in respawn configuration presets
1392
1577
  const BUILTIN_RESPAWN_PRESETS = [
1393
1578
  {
@@ -1513,6 +1698,7 @@ const SSE_EVENTS = {
1513
1698
  SESSION_INTERACTIVE: 'session:interactive',
1514
1699
  SESSION_RUNNING: 'session:running',
1515
1700
  SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
1701
+ SESSION_PROMPT_CACHE: 'session:promptCache',
1516
1702
 
1517
1703
  // Scheduled runs
1518
1704
  SCHEDULED_CREATED: 'scheduled:created',
@@ -1741,7 +1927,10 @@ function escapeHtml(text) {
1741
1927
  function formatHistoryBytes(bytes) {
1742
1928
  const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
1743
1929
  if (n < 1024) return 'less than 1 KB';
1744
- if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
1930
+ // Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
1931
+ // just under 1 MiB and used to print as "1024 KB".
1932
+ const kb = Math.round(n / 1024);
1933
+ if (kb < 1024) return `${kb} KB`;
1745
1934
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1746
1935
  }
1747
1936
 
@@ -1755,12 +1944,27 @@ function formatHistoryBytes(bytes) {
1755
1944
  * - atCeiling: the FULL capture itself hit the byte ceiling
1756
1945
  * - exhausted: a full pull was refused as a downgrade, so this is all there is
1757
1946
  *
1947
+ * ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
1948
+ * return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
1949
+ * pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
1950
+ * whose transcript lives in the CLI and is scrolled there), so the bytes a tail
1951
+ * cut dropped are old repaint frames that `full=1` cannot bring back. Measured
1952
+ * on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
1953
+ * and the button only ever ended in the downgrade refusal. Nothing to offer,
1954
+ * nothing to say. Absent means unknown and keeps the byte-based behaviour.
1955
+ *
1758
1956
  * @param {{truncated?: boolean, reason?: string|null, source?: string|null,
1759
- * fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
1957
+ * fullSize?: number, retainedBytes?: number, exhausted?: boolean,
1958
+ * paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
1760
1959
  * @returns {{visible: boolean, message: string, canLoadMore: boolean}}
1761
1960
  */
1762
1961
  function computeHistoryTruncationNotice(state = {}) {
1763
1962
  if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
1963
+ const historyLines =
1964
+ typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
1965
+ ? Math.max(0, state.paneHistoryLines)
1966
+ : null;
1967
+ if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
1764
1968
 
1765
1969
  const retained = Math.max(0, state.retainedBytes || 0);
1766
1970
  const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1783,9 +1987,22 @@ function computeHistoryTruncationNotice(state = {}) {
1783
1987
  canLoadMore: false,
1784
1988
  };
1785
1989
  }
1990
+ // Name what a pull can actually return when the server said: the byte gap
1991
+ // counts repaints and redraw bloat, and overstates it many times over.
1992
+ // Without that count, a `lines=`-bounded capture is cut by tmux before any
1993
+ // bytes are counted, so its fullSize is the window itself and the remainder's
1994
+ // size is unknown: say that more exists without inventing an amount. That
1995
+ // holds even when `tail` also cut bytes off the window (`dropped`), since tmux
1996
+ // still holds more beyond it (`historyLinesCut`, set by the server).
1997
+ const remainder =
1998
+ dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
1999
+ const more =
2000
+ historyLines !== null
2001
+ ? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
2002
+ : `${remainder} may still be retained.`;
1786
2003
  return {
1787
2004
  visible: true,
1788
- message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
2005
+ message: `Showing the most recent ${shown} of this session. ${more}`,
1789
2006
  canLoadMore: true,
1790
2007
  };
1791
2008
  }
@@ -1830,7 +2047,7 @@ function computeRewriteScrollLine(input) {
1830
2047
  * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
1831
2048
  */
1832
2049
  const FILE_PATH_LINK_PATTERN =
1833
- /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
2050
+ /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
1834
2051
 
1835
2052
  /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
1836
2053
  function absoluteFilePathPattern() {
@@ -1842,13 +2059,18 @@ function absoluteFilePathPattern() {
1842
2059
  * points at goes to the tail/log viewer, which is the right home for a growing
1843
2060
  * text file and the wrong one for bytes (tailing a PNG shows binary noise).
1844
2061
  *
2062
+ * Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
2063
+ * document to read, so the preview renders it (its MD pill gives the source)
2064
+ * instead of the log viewer streaming it as raw text. Other text (.log, .txt,
2065
+ * .json, code) keeps the log viewer, which follows a file still being written.
2066
+ *
1845
2067
  * The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
1846
2068
  * (src/attachment-registry.ts, the single source) — they diverged once and an
1847
2069
  * in-workspace `.m4a` opened as binary noise in the log viewer while the same
1848
2070
  * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
1849
2071
  */
1850
2072
  const FILE_PREVIEW_EXTENSIONS = new Set(
1851
- ('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
2073
+ ('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
1852
2074
  );
1853
2075
 
1854
2076
  /** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1857,6 +2079,155 @@ function previewsInFileViewer(filePath) {
1857
2079
  return FILE_PREVIEW_EXTENSIONS.has(ext);
1858
2080
  }
1859
2081
 
2082
+ /**
2083
+ * Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
2084
+ * echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
2085
+ * or a bare `look_montage.png`. Same extension list and body as
2086
+ * {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
2087
+ * equal).
2088
+ *
2089
+ * Without a known root to anchor on, the START is what has to be unambiguous:
2090
+ * group 1 is the boundary in front of the path (line start, whitespace, a quote,
2091
+ * an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
2092
+ * a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
2093
+ * a URL. The path itself is group 2. Its first character is never `/`: absolute
2094
+ * paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
2095
+ * token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
2096
+ * relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
2097
+ * the file, not the punctuation around it.
2098
+ *
2099
+ * ⚠ Like its sibling, never share an instance (`lastIndex`): use
2100
+ * {@link findFilePathLinks}.
2101
+ */
2102
+ const RELATIVE_FILE_PATH_LINK_PATTERN =
2103
+ /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
2104
+
2105
+ /** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
2106
+ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
2107
+
2108
+ /**
2109
+ * Every linkable file path in `text`, in order: absolute (rooted) paths first,
2110
+ * then `~/` and relative ones, then folder paths — each only where it overlaps
2111
+ * neither an earlier one nor a URL.
2112
+ *
2113
+ * The one entry point for both consumers (terminal link provider, response
2114
+ * viewer), so a path form is clickable in both or in neither.
2115
+ *
2116
+ * @param {string} text
2117
+ * @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
2118
+ * `text`; `folder` marks a folder path (folder grid) rather than a file.
2119
+ */
2120
+ function findFilePathLinks(text) {
2121
+ const value = String(text || '');
2122
+ const found = [];
2123
+ const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
2124
+ // Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
2125
+ // `?img=b.png` belong to the URL link, not to a second, overlapping file link.
2126
+ // (`file:///home/x.png` is not one of them: its path is the file.)
2127
+ const urls = [];
2128
+ const urlPattern = /\bhttps?:\/\/\S+/gi;
2129
+ let match;
2130
+ while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
2131
+ const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
2132
+
2133
+ const absolute = absoluteFilePathPattern();
2134
+ while ((match = absolute.exec(value)) !== null) {
2135
+ if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
2136
+ }
2137
+ const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
2138
+ while ((match = relative.exec(value)) !== null) {
2139
+ const path = match[2];
2140
+ const index = match.index + match[1].length;
2141
+ if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
2142
+ found.push({ path, index });
2143
+ }
2144
+ const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
2145
+ while ((match = folders.exec(value)) !== null) {
2146
+ const path = folderLinkPath(match[2]);
2147
+ const index = match.index + match[1].length;
2148
+ if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
2149
+ found.push({ path, index, folder: true });
2150
+ }
2151
+ return found.sort((a, b) => a.index - b.index);
2152
+ }
2153
+
2154
+ /**
2155
+ * Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
2156
+ * (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
2157
+ * trailing slash (`builds/captures/`). Same boundary in front as the relative
2158
+ * file pattern (group 1), the candidate itself is group 2 and goes through
2159
+ * {@link folderLinkPath}, which decides whether it reads as a folder at all.
2160
+ * File links are found first and win any overlap. A folder path ends at `:`,
2161
+ * which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
2162
+ * the `://` lookahead stops at the next colon instead of rescanning the token from
2163
+ * every one (quadratic on `a:a:a:…//`).
2164
+ *
2165
+ * ⚠ Like its siblings, never share an instance (`lastIndex`): use
2166
+ * {@link findFilePathLinks}.
2167
+ */
2168
+ const FOLDER_PATH_LINK_PATTERN =
2169
+ /(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
2170
+
2171
+ /**
2172
+ * A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
2173
+ *
2174
+ * Sentence punctuation after the path is not part of it. A path ending in `/` is
2175
+ * a folder; otherwise only a rooted or `~/` path whose last segment has no
2176
+ * extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
2177
+ * without a slash at the end is prose, not a folder.
2178
+ */
2179
+ function folderLinkPath(candidate) {
2180
+ // `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
2181
+ // its cwd with one. A folder whose name really ends in one links one short.
2182
+ const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
2183
+ if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
2184
+ if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
2185
+ if (!path.startsWith('/') && !path.startsWith('~/')) return null;
2186
+ const last = path.slice(path.lastIndexOf('/') + 1);
2187
+ return /^\.?[^.]+$/.test(last) ? path : null;
2188
+ }
2189
+
2190
+ /**
2191
+ * Where a clicked path points, as the preview routes need it.
2192
+ *
2193
+ * Absolute and `~/` paths pass through unchanged — `~` is the home of the
2194
+ * session's HOST, which only the server knows (a remote case's home is on the
2195
+ * remote machine), so the server expands it. A relative path is resolved
2196
+ * against the session's working directory, `.` and `..` collapsed, so a
2197
+ * `../other/x.png` ends up outside the workspace and routes like any other
2198
+ * external path. Without a working directory it is returned as is.
2199
+ *
2200
+ * @param {string} filePath
2201
+ * @param {string} [workingDir]
2202
+ * @returns {string}
2203
+ */
2204
+ function resolveLinkedFilePath(filePath, workingDir) {
2205
+ const path = String(filePath || '');
2206
+ if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
2207
+ const parts = [];
2208
+ for (const part of `${workingDir}/${path}`.split('/')) {
2209
+ if (!part || part === '.') continue;
2210
+ if (part === '..') parts.pop();
2211
+ else parts.push(part);
2212
+ }
2213
+ return `/${parts.join('/')}`;
2214
+ }
2215
+
2216
+ /**
2217
+ * Extensions the file preview renders as a PAGE, from the sandboxed
2218
+ * `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
2219
+ * HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
2220
+ * the two equal). The terminal sends a click on one to the preview, never to
2221
+ * the log viewer, and the preview renders it with a Page pill back to source.
2222
+ */
2223
+ const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
2224
+
2225
+ /** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
2226
+ function rendersAsPage(filePath) {
2227
+ const ext = String(filePath || '').split('.').pop().toLowerCase();
2228
+ return HTML_PAGE_EXTENSIONS.has(ext);
2229
+ }
2230
+
1860
2231
 
1861
2232
  /**
1862
2233
  * The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -1961,6 +2332,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
1961
2332
  return { startRow, endRow, text, offsetToCell, cellToOffset };
1962
2333
  }
1963
2334
 
2335
+ /** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
2336
+ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
2337
+ const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
2338
+ const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
2339
+ const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
2340
+ /** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
2341
+ const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
2342
+ /** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
2343
+ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
2344
+
2345
+ /**
2346
+ * Whether the line `nextText` carries on a file path `prevText` broke off.
2347
+ *
2348
+ * Answers `'join'` when the path ends (reaches a known extension) on the next
2349
+ * line, `'chain'` when the next line is nothing but more of the path (a path
2350
+ * spanning three or more lines; only worth keeping once a later line ends it),
2351
+ * and `null` otherwise. The broken-off token must already look like a path in
2352
+ * progress: it contains a `/`, ends on a path character and does not yet end
2353
+ * on an extension. A next line that opens a NEW path is never glued on: a rooted
2354
+ * one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
2355
+ * top directory the broken token did (the next entry of a listing).
2356
+ */
2357
+ function pathContinuation(prevText, nextText) {
2358
+ const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
2359
+ if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
2360
+ const next = String(nextText || '').replace(/^\s+/, '');
2361
+ const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
2362
+ if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
2363
+ // A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
2364
+ // followed by `src/index.ts` is two entries, not one path broken after `src/components`.
2365
+ // A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
2366
+ if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
2367
+ if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
2368
+ return head === next.replace(/\s+$/, '') ? 'chain' : null;
2369
+ }
2370
+
2371
+ /**
2372
+ * {@link terminalLogicalLine}, extended across the line breaks a program puts
2373
+ * INSIDE a file path. Used by the link provider only; touch selection keeps the
2374
+ * plain logical line.
2375
+ *
2376
+ * Claude Code wraps its tool output itself, a few columns short of the terminal
2377
+ * edge, so neither continuation signal of terminalLogicalLine fires (no
2378
+ * `isWrapped`, the row does not reach the last column) and a long path was only
2379
+ * linked up to the break:
2380
+ *
2381
+ * ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
2382
+ * 026-10-10/ghost1b_gameplay.png err=0 …
2383
+ *
2384
+ * A following logical line is glued on (its indent dropped, cell mapping kept
2385
+ * exact) when {@link pathContinuation} says it carries the path on and the row it
2386
+ * broke off is at least as long as the row it continues on. Bounded by the same
2387
+ * `maxRows` as the logical line.
2388
+ *
2389
+ * @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
2390
+ */
2391
+ function terminalPathLine(buffer, row, cols, maxRows) {
2392
+ const base = terminalLogicalLine(buffer, row, cols, maxRows);
2393
+ if (!base) return null;
2394
+ const bound = Math.max(1, maxRows || 12);
2395
+ const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
2396
+ const rowsOf = (line) => line.endRow - line.startRow + 1;
2397
+ // A program breaking a long token fills the row to its wrap width, and no
2398
+ // later row of that block is wider. So the row a path broke off is at least as
2399
+ // long as the row it continues on — which keeps `cd /mnt/foo` from being glued
2400
+ // to a longer ` file.png written` below it.
2401
+ const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
2402
+ const continues = (prev, next, prevText) =>
2403
+ rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
2404
+
2405
+ /** `first` and the lines a path carries on into, stopping at the last confirmed join. */
2406
+ const extend = (first) => {
2407
+ const parts = [first];
2408
+ let text = first.text;
2409
+ let rows = rowsOf(first);
2410
+ let confirmed = 1;
2411
+ for (;;) {
2412
+ const next = lineAt(parts[parts.length - 1].endRow + 1);
2413
+ if (!next || rows + rowsOf(next) > bound) break;
2414
+ const kind = continues(parts[parts.length - 1], next, text);
2415
+ if (!kind) break;
2416
+ parts.push(next);
2417
+ text += next.text.replace(/^\s+/, '');
2418
+ rows += rowsOf(next);
2419
+ if (kind === 'join') confirmed = parts.length;
2420
+ }
2421
+ return parts.slice(0, confirmed);
2422
+ };
2423
+
2424
+ // The hovered row may be the second or third line of a broken path: walk back
2425
+ // to where it could start, then let the forward pass decide what really joins.
2426
+ let first = base;
2427
+ let rows = rowsOf(base);
2428
+ while (first.startRow > 0) {
2429
+ const prev = lineAt(first.startRow - 1);
2430
+ if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
2431
+ first = prev;
2432
+ rows += rowsOf(prev);
2433
+ }
2434
+ let parts = extend(first);
2435
+ if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
2436
+ if (parts.length === 1) return parts[0];
2437
+
2438
+ const segments = [];
2439
+ let text = '';
2440
+ for (const [i, part] of parts.entries()) {
2441
+ const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
2442
+ segments.push({ part, textStart: text.length, skip });
2443
+ text += part.text.slice(skip);
2444
+ }
2445
+ const offsetToCell = (offset) => {
2446
+ for (let i = segments.length - 1; i >= 0; i--) {
2447
+ const seg = segments[i];
2448
+ if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
2449
+ }
2450
+ return segments[0].part.offsetToCell(offset);
2451
+ };
2452
+ const cellToOffset = (targetRow, targetCol) => {
2453
+ for (const seg of segments) {
2454
+ if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
2455
+ const offset = seg.part.cellToOffset(targetRow, targetCol);
2456
+ return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
2457
+ }
2458
+ return -1;
2459
+ };
2460
+ return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
2461
+ }
2462
+
1964
2463
  // ═══════════════════════════════════════════════════════════════
1965
2464
  // Split-Pane Sessions — pure helpers (divider math, picker list)
1966
2465
  // ═══════════════════════════════════════════════════════════════
@@ -2083,11 +2582,11 @@ function tileGridCapacity({ width, height }) {
2083
2582
  }
2084
2583
 
2085
2584
  /**
2086
- * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
2087
- * deleted, detached and duplicate ids are dropped, the list is capped at
2088
- * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
2089
- * must be 1 to 3 finite positive numbers. Anything that is not a v1 object
2090
- * (or its JSON) gives null.
2585
+ * The stored grid (`codeman:tile-grid`: session ids and the layout, never
2586
+ * content) made safe to apply: unknown, deleted, detached and duplicate ids
2587
+ * are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
2588
+ * name a kept id, and track fractions must be 1 to 3 finite positive numbers.
2589
+ * Anything that is not a v1 object (or its JSON) gives null.
2091
2590
  *
2092
2591
  * The stored `ids` are the grid's CELLS in reading order, `null` for an empty
2093
2592
  * one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2095,11 +2594,20 @@ function tileGridCapacity({ width, height }) {
2095
2594
  * every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
2096
2595
  * detached, a duplicate, past the cap) becomes `null` there, never a shift.
2097
2596
  *
2597
+ * `freed` names the cells whose session no longer exists (or was popped out
2598
+ * to its own window) since the grid was stored: the ranking fills those first
2599
+ * when the grid comes back (restoreTileGridCells). A hole the user left empty
2600
+ * is not freed. `count` is how many tiles the grid had after the user's own
2601
+ * last change (a session that went away by itself does not lower it), so the
2602
+ * grid comes back to that many when there are sessions to fill it; a value
2603
+ * stored before it existed, or a malformed one, reads as the number of
2604
+ * sessions the stored cells name.
2605
+ *
2098
2606
  * @param {unknown} raw - the parsed value, or the stored JSON string
2099
2607
  * @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
2100
2608
  * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
2101
- * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
2102
- * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
2609
+ * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
2610
+ * focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
2103
2611
  */
2104
2612
  function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2105
2613
  let value = raw;
@@ -2110,11 +2618,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2110
2618
  const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
2111
2619
  const ids = [];
2112
2620
  const cells = [];
2621
+ const freed = [];
2622
+ const named = [];
2113
2623
  for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
2114
- const keep =
2115
- typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
2116
- ids.length < TILE_GRID_MAX;
2624
+ const isId = typeof id === 'string' && id !== '';
2625
+ const present = isId && live.has(id) && !detachedIds?.has?.(id);
2626
+ const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
2117
2627
  if (keep) ids.push(id);
2628
+ // Its session went away since: the cell is freed for the ranking to fill.
2629
+ if (isId && !present && !named.includes(id)) freed.push(cells.length);
2630
+ if (isId && !named.includes(id)) named.push(id);
2118
2631
  // A malformed entry (not a string, not null) is a hole too.
2119
2632
  cells.push(keep ? id : null);
2120
2633
  }
@@ -2122,11 +2635,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2122
2635
  if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
2123
2636
  return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
2124
2637
  };
2638
+ const count =
2639
+ Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
2640
+ ? value.count
2641
+ : Math.min(named.length, TILE_GRID_MAX);
2125
2642
  return {
2126
2643
  v: 1,
2127
2644
  open: value.open === true && ids.length > 0,
2128
2645
  ids,
2129
2646
  cells,
2647
+ freed,
2648
+ count,
2130
2649
  focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
2131
2650
  zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
2132
2651
  colFr: fractions(value.colFr),
@@ -2134,6 +2653,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
2134
2653
  };
2135
2654
  }
2136
2655
 
2656
+ /**
2657
+ * A stored grid as it comes back (the Tiles button, a page reload): its cells
2658
+ * exactly as stored, holes the user left included, and its focus (or the tile
2659
+ * it had zoomed). Only when it holds fewer tiles than its `count` (sessions
2660
+ * that went away since, or by themselves while it was open) does it fill, from
2661
+ * `ranked` (best first, never a session already in it): the freed cells first,
2662
+ * then the other empty cells, in reading order; more than the cells hold join
2663
+ * after them (the shape grows when the grid lays them out, reformTileCells).
2664
+ * A cell stays empty only when no other session is left to place. Never
2665
+ * trimmed to the window: a grid larger than the window fits shows its focused
2666
+ * tile alone until the window fits it again, and the arrangement stays.
2667
+ *
2668
+ * @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
2669
+ * focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
2670
+ * @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
2671
+ * @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
2672
+ */
2673
+ function restoreTileGridCells(stored, ranked) {
2674
+ const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
2675
+ const cells = [];
2676
+ for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
2677
+ const tiles = cells.filter(Boolean);
2678
+ if (tiles.length === 0) return null;
2679
+ const focusedId =
2680
+ [stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
2681
+ const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
2682
+ const fillers = [];
2683
+ for (const id of ranked || []) {
2684
+ if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
2685
+ }
2686
+ const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
2687
+ const empty = [];
2688
+ cells.forEach((id, k) => {
2689
+ if (id === null) empty.push(k);
2690
+ });
2691
+ // Freed cells first, each group in reading order.
2692
+ empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
2693
+ let count = tiles.length;
2694
+ for (const k of empty) {
2695
+ if (count >= target || fillers.length === 0) break;
2696
+ cells[k] = fillers.shift();
2697
+ count++;
2698
+ }
2699
+ const extra = fillers.slice(0, Math.max(0, target - count));
2700
+ return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
2701
+ }
2702
+
2137
2703
  /**
2138
2704
  * New track fractions after a divider drag (grid-template `fr` values): the two
2139
2705
  * tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2166,7 +2732,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
2166
2732
 
2167
2733
  /**
2168
2734
  * The sessions the Tiles button can open (case c of tileGridOpenSet, and the
2169
- * ones a count fills a grid with), in tab order: live ones only, never a session popped out to
2735
+ * ones a count fills a grid with), in the order given (tab order, or the
2736
+ * ranking): live ones only, never a session popped out to
2170
2737
  * its own window (that window owns its PTY size). A session with no PTY
2171
2738
  * attached IS offered: its tile shows the Attach overlay.
2172
2739
  *
@@ -2180,44 +2747,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
2180
2747
  for (const id of sessionOrder) {
2181
2748
  if (detachedIds?.has?.(id)) continue;
2182
2749
  const session = sessions.get(id);
2183
- if (!session) continue;
2750
+ if (!session || result.some((r) => r.id === id)) continue;
2184
2751
  result.push({ id, label: session.name || 'Session' });
2185
2752
  }
2186
2753
  return result;
2187
2754
  }
2188
2755
 
2756
+ // Ranking groups (rankTileSessions): working first, then the sessions waiting
2757
+ // on the user, then everything else.
2758
+ const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
2759
+
2760
+ /**
2761
+ * The stamp a session is ranked by inside its group. A WORKING session keys
2762
+ * off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
2763
+ * about once a second, so its last-activity stamp is always "now", and one
2764
+ * that never submitted would otherwise claim the head of the group. 0 means
2765
+ * unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
2766
+ * the last byte the pane printed, i.e. when it went quiet).
2767
+ */
2768
+ function tileRankStamp(row) {
2769
+ if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
2770
+ return sessionActivityAnchor(row);
2771
+ }
2772
+
2773
+ /**
2774
+ * Which open sessions the tile grid shows when nobody said which (the Tiles
2775
+ * button with no stored grid to bring back, and every place the grid fills a
2776
+ * tile on its own: a count picked in its menu, a freed cell), best first
2777
+ * (owner request: "prefer to load in tiles that are working and then the most
2778
+ * recent, so the oldest don't get opened"):
2779
+ * 1. WORKING, the most recently started turn first;
2780
+ * 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
2781
+ * permission or question dialog; `waiting`: a finished turn not seen
2782
+ * yet), most recent first;
2783
+ * 3. then every other one (idle, done, error), most recently active first.
2784
+ * Inside a group the newest stamp wins (tileRankStamp) and a session with no
2785
+ * stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
2786
+ * the result never shuffles. The states are the home screens' own
2787
+ * (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
2788
+ * order differs: the home screens put the longest-running turn first, the grid
2789
+ * the most recent.
2790
+ *
2791
+ * Pure. Unit-tested in test/tile-grid-ranking.test.ts.
2792
+ *
2793
+ * @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
2794
+ * @returns {string[]} the ids, best first, each once
2795
+ */
2796
+ function rankTileSessions(rows) {
2797
+ const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
2798
+ const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
2799
+ list.sort((a, b) => {
2800
+ const byGroup = group(a) - group(b);
2801
+ if (byGroup !== 0) return byGroup;
2802
+ const atA = tileRankStamp(a);
2803
+ const atB = tileRankStamp(b);
2804
+ if (atA !== atB) {
2805
+ if (!atA) return 1;
2806
+ if (!atB) return -1;
2807
+ return atB - atA;
2808
+ }
2809
+ const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
2810
+ const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
2811
+ return orderA - orderB;
2812
+ });
2813
+ const ids = [];
2814
+ for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
2815
+ return ids;
2816
+ }
2817
+
2189
2818
  /**
2190
2819
  * What the Tiles button and Ctrl+Shift+G open, at once and without asking
2191
2820
  * (owner decision 8). In order:
2192
2821
  * a. the grid this tab last had (`stored`, already sanitized: live, not
2193
- * detached, at most the cap), if any of its sessions survive;
2822
+ * detached, at most the cap), if any of its sessions survive, exactly
2823
+ * as it was (restoreTileGridCells: its cells and holes, a cell its
2824
+ * session freed filled from the ranking);
2194
2825
  * b. else an open split's two sessions, Pane A focused;
2195
- * c. else the open sessions in tab order (buildTilePickerSessions: no
2196
- * detached ones), up to `limit`, the active session always among them and focused
2197
- * (when it sits past the limit, the first `limit - 1` others come with it).
2826
+ * c. else the open sessions in `ranked` order (rankTileSessions: working,
2827
+ * then needing input, then the most recent; tab order when no ranking is
2828
+ * given), detached ones never, up to `limit`, the active session always
2829
+ * among them and focused (when it ranks past the limit, the first
2830
+ * `limit - 1` others come with it).
2198
2831
  * Null when there is nothing to open.
2199
2832
  *
2200
- * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
2201
- * split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
2833
+ * @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
2834
+ * focused: string|null, zoomed: string|null}|null,
2835
+ * split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
2202
2836
  * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
2203
- * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
2837
+ * @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
2838
+ * focusedId: string|null}|null}
2204
2839
  */
2205
- function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
2206
- if (stored?.ids?.length) {
2207
- const focus = stored.zoomed || stored.focused;
2208
- return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
2209
- }
2840
+ function tileGridOpenSet({
2841
+ stored = null,
2842
+ split = null,
2843
+ ranked = null,
2844
+ sessions,
2845
+ sessionOrder,
2846
+ detachedIds,
2847
+ activeId = null,
2848
+ limit,
2849
+ }) {
2850
+ const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
2851
+ (c) => c.id
2852
+ );
2853
+ const restored = stored ? restoreTileGridCells(stored, all) : null;
2854
+ if (restored) return { source: 'stored', ...restored };
2210
2855
  const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
2211
2856
  const pair = (split || []).filter(usable);
2212
2857
  if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
2213
2858
  const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
2214
- const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
2215
2859
  if (all.length === 0) return null;
2216
2860
  let ids = all.slice(0, max);
2217
2861
  if (all.includes(activeId) && !ids.includes(activeId)) {
2218
2862
  ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
2219
2863
  }
2220
- return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2864
+ return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
2221
2865
  }
2222
2866
 
2223
2867
  /**
@@ -2237,8 +2881,9 @@ function sanitizeTileCount(raw) {
2237
2881
  * `base` (what the grid would open, or what an open grid shows, in its order)
2238
2882
  * trimmed or filled to `n` tiles: trimmed from the end, the session to focus
2239
2883
  * (`keepId`) always kept (it takes the last place when it sat past `n`, as in
2240
- * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
2241
- * with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
2884
+ * tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
2885
+ * the app passes the ranking, rankTileSessions) with the ones not in it yet.
2886
+ * Fewer sessions than `n` give fewer tiles.
2242
2887
  *
2243
2888
  * @param {string[]} base
2244
2889
  * @param {string[]} all
@@ -2727,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
2727
3372
  if (typeof window !== 'undefined') {
2728
3373
  window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
2729
3374
  window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
2730
- window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
2731
- window.CodemanTerminalLines = { terminalLogicalLine };
3375
+ window.CodemanFilePaths = {
3376
+ absoluteFilePathPattern,
3377
+ findFilePathLinks,
3378
+ resolveLinkedFilePath,
3379
+ previewsInFileViewer,
3380
+ FILE_PREVIEW_EXTENSIONS,
3381
+ rendersAsPage,
3382
+ HTML_PAGE_EXTENSIONS,
3383
+ };
3384
+ window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
2732
3385
  window.CodemanUrlSession = { sessionIdFromFragment };
2733
3386
  window.CodemanSplitPane = {
2734
3387
  clampDividerPercent,
@@ -2747,6 +3400,8 @@ if (typeof window !== 'undefined') {
2747
3400
  fitTileCells,
2748
3401
  cycleTile,
2749
3402
  tileGridOpenSet,
3403
+ restoreTileGridCells,
3404
+ rankTileSessions,
2750
3405
  sanitizeTileCount,
2751
3406
  tileGridSetForCount,
2752
3407
  tileCellCols,