aicodeman 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/README.md +12 -3
  2. package/dist/config/cli-registry/schema.d.ts +1 -0
  3. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  4. package/dist/config/cli-registry/schema.js +5 -1
  5. package/dist/config/cli-registry/schema.js.map +1 -1
  6. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  7. package/dist/config/cli-registry/stock.js +71 -5
  8. package/dist/config/cli-registry/stock.js.map +1 -1
  9. package/dist/config/cli-registry/types.d.ts +4 -4
  10. package/dist/config/cli-registry/types.d.ts.map +1 -1
  11. package/dist/config/foreign-tmux.d.ts +48 -0
  12. package/dist/config/foreign-tmux.d.ts.map +1 -0
  13. package/dist/config/foreign-tmux.js +48 -0
  14. package/dist/config/foreign-tmux.js.map +1 -0
  15. package/dist/custom-model-injection-apply.d.ts +4 -2
  16. package/dist/custom-model-injection-apply.d.ts.map +1 -1
  17. package/dist/custom-model-injection-apply.js +35 -21
  18. package/dist/custom-model-injection-apply.js.map +1 -1
  19. package/dist/docker-hosts.d.ts +8 -1
  20. package/dist/docker-hosts.d.ts.map +1 -1
  21. package/dist/docker-hosts.js +5 -5
  22. package/dist/docker-hosts.js.map +1 -1
  23. package/dist/foreign-tmux-discovery.d.ts +140 -0
  24. package/dist/foreign-tmux-discovery.d.ts.map +1 -0
  25. package/dist/foreign-tmux-discovery.js +366 -0
  26. package/dist/foreign-tmux-discovery.js.map +1 -0
  27. package/dist/foreign-tmux.d.ts +381 -0
  28. package/dist/foreign-tmux.d.ts.map +1 -0
  29. package/dist/foreign-tmux.js +775 -0
  30. package/dist/foreign-tmux.js.map +1 -0
  31. package/dist/git-fetch-scheduler.d.ts +2 -0
  32. package/dist/git-fetch-scheduler.d.ts.map +1 -1
  33. package/dist/git-fetch-scheduler.js +1 -1
  34. package/dist/git-fetch-scheduler.js.map +1 -1
  35. package/dist/git-fetch.d.ts +1 -1
  36. package/dist/git-fetch.d.ts.map +1 -1
  37. package/dist/git-workspace-status.d.ts +2 -2
  38. package/dist/git-workspace-status.d.ts.map +1 -1
  39. package/dist/hooks-config.d.ts +2 -1
  40. package/dist/hooks-config.d.ts.map +1 -1
  41. package/dist/hooks-config.js +2 -1
  42. package/dist/hooks-config.js.map +1 -1
  43. package/dist/live-worktree.d.ts +53 -0
  44. package/dist/live-worktree.d.ts.map +1 -0
  45. package/dist/live-worktree.js +97 -0
  46. package/dist/live-worktree.js.map +1 -0
  47. package/dist/mux-interface.d.ts +12 -1
  48. package/dist/mux-interface.d.ts.map +1 -1
  49. package/dist/reboot-restore.d.ts +1 -1
  50. package/dist/reboot-restore.d.ts.map +1 -1
  51. package/dist/reboot-restore.js +9 -0
  52. package/dist/reboot-restore.js.map +1 -1
  53. package/dist/remote-files.d.ts +150 -5
  54. package/dist/remote-files.d.ts.map +1 -1
  55. package/dist/remote-files.js +505 -7
  56. package/dist/remote-files.js.map +1 -1
  57. package/dist/remote-hosts.d.ts +15 -9
  58. package/dist/remote-hosts.d.ts.map +1 -1
  59. package/dist/remote-hosts.js +42 -20
  60. package/dist/remote-hosts.js.map +1 -1
  61. package/dist/session-cli-registry-bridge.d.ts.map +1 -1
  62. package/dist/session-cli-registry-bridge.js +19 -8
  63. package/dist/session-cli-registry-bridge.js.map +1 -1
  64. package/dist/session-env-clamp.d.ts +1 -1
  65. package/dist/session-env-clamp.d.ts.map +1 -1
  66. package/dist/session-env-clamp.js +4 -3
  67. package/dist/session-env-clamp.js.map +1 -1
  68. package/dist/session.d.ts +91 -21
  69. package/dist/session.d.ts.map +1 -1
  70. package/dist/session.js +190 -33
  71. package/dist/session.js.map +1 -1
  72. package/dist/tmux-manager.d.ts +74 -12
  73. package/dist/tmux-manager.d.ts.map +1 -1
  74. package/dist/tmux-manager.js +256 -41
  75. package/dist/tmux-manager.js.map +1 -1
  76. package/dist/types/foreign-tmux.d.ts +101 -0
  77. package/dist/types/foreign-tmux.d.ts.map +1 -0
  78. package/dist/types/foreign-tmux.js +27 -0
  79. package/dist/types/foreign-tmux.js.map +1 -0
  80. package/dist/types/index.d.ts +1 -0
  81. package/dist/types/index.d.ts.map +1 -1
  82. package/dist/types/index.js +1 -0
  83. package/dist/types/index.js.map +1 -1
  84. package/dist/types/session.d.ts +106 -2
  85. package/dist/types/session.d.ts.map +1 -1
  86. package/dist/types/session.js.map +1 -1
  87. package/dist/types/update.d.ts +4 -1
  88. package/dist/types/update.d.ts.map +1 -1
  89. package/dist/web/edit-history-sync.d.ts +27 -7
  90. package/dist/web/edit-history-sync.d.ts.map +1 -1
  91. package/dist/web/edit-history-sync.js +35 -19
  92. package/dist/web/edit-history-sync.js.map +1 -1
  93. package/dist/web/launch-defaults.d.ts +1 -1
  94. package/dist/web/launch-defaults.js +2 -2
  95. package/dist/web/launch-defaults.js.map +1 -1
  96. package/dist/web/linked-cases-writer.d.ts +43 -0
  97. package/dist/web/linked-cases-writer.d.ts.map +1 -0
  98. package/dist/web/linked-cases-writer.js +107 -0
  99. package/dist/web/linked-cases-writer.js.map +1 -0
  100. package/dist/web/paste-image-gc.d.ts +4 -1
  101. package/dist/web/paste-image-gc.d.ts.map +1 -1
  102. package/dist/web/paste-image-gc.js +4 -2
  103. package/dist/web/paste-image-gc.js.map +1 -1
  104. package/dist/web/public/admin-ui.js.gz +0 -0
  105. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  106. package/dist/web/public/{app.963b5ba8.js → app.9ea37070.js} +6 -6
  107. package/dist/web/public/app.9ea37070.js.br +0 -0
  108. package/dist/web/public/app.9ea37070.js.gz +0 -0
  109. package/dist/web/public/approvals-ui.js.gz +0 -0
  110. package/dist/web/public/constants.782170e1.js.gz +0 -0
  111. package/dist/web/public/cron-ui.js.gz +0 -0
  112. package/dist/web/public/edit-history-ui.js +27 -14
  113. package/dist/web/public/edit-history-ui.js.br +0 -0
  114. package/dist/web/public/edit-history-ui.js.gz +0 -0
  115. package/dist/web/public/entrance-animations.js.gz +0 -0
  116. package/dist/web/public/foreign-sessions.js +476 -0
  117. package/dist/web/public/foreign-sessions.js.br +0 -0
  118. package/dist/web/public/foreign-sessions.js.gz +0 -0
  119. package/dist/web/public/git-status-ui.js +5 -3
  120. package/dist/web/public/git-status-ui.js.br +0 -0
  121. package/dist/web/public/git-status-ui.js.gz +0 -0
  122. package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
  123. package/dist/web/public/home-sessions.js.gz +0 -0
  124. package/dist/web/public/host-wake-ui.js.gz +0 -0
  125. package/dist/web/public/i18n.a5e82c95.js +2 -0
  126. package/dist/web/public/i18n.a5e82c95.js.br +0 -0
  127. package/dist/web/public/i18n.a5e82c95.js.gz +0 -0
  128. package/dist/web/public/image-input.a5d0f8f3.js.gz +0 -0
  129. package/dist/web/public/index.html +36 -17
  130. package/dist/web/public/index.html.br +0 -0
  131. package/dist/web/public/index.html.gz +0 -0
  132. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  133. package/dist/web/public/{keyboard-accessory.d4ec5957.js → keyboard-accessory.b941676c.js} +3 -1
  134. package/dist/web/public/keyboard-accessory.b941676c.js.br +0 -0
  135. package/dist/web/public/{keyboard-accessory.d4ec5957.js.gz → keyboard-accessory.b941676c.js.gz} +0 -0
  136. package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
  137. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  138. package/dist/web/public/mobile-overview.js +10 -0
  139. package/dist/web/public/mobile-overview.js.br +0 -0
  140. package/dist/web/public/mobile-overview.js.gz +0 -0
  141. package/dist/web/public/{mobile.5ef2ce85.css → mobile.67c8eb5b.css} +1 -1
  142. package/dist/web/public/mobile.67c8eb5b.css.br +0 -0
  143. package/dist/web/public/{mobile.5ef2ce85.css.gz → mobile.67c8eb5b.css.gz} +0 -0
  144. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  145. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  146. package/dist/web/public/{panels-ui.b100727d.js → panels-ui.8bcd4473.js} +4 -4
  147. package/dist/web/public/panels-ui.8bcd4473.js.br +0 -0
  148. package/dist/web/public/panels-ui.8bcd4473.js.gz +0 -0
  149. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  150. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  151. package/dist/web/public/readmymind-ui.js.gz +0 -0
  152. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  153. package/dist/web/public/respawn-ui.304ba0d4.js.gz +0 -0
  154. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  155. package/dist/web/public/session-lineage.js.gz +0 -0
  156. package/dist/web/public/{session-ui.5ac82f2c.js → session-ui.a7c8c095.js} +11 -11
  157. package/dist/web/public/session-ui.a7c8c095.js.br +0 -0
  158. package/dist/web/public/session-ui.a7c8c095.js.gz +0 -0
  159. package/dist/web/public/{settings-ui.d1d4fb99.js → settings-ui.18f92052.js} +3 -3
  160. package/dist/web/public/settings-ui.18f92052.js.br +0 -0
  161. package/dist/web/public/settings-ui.18f92052.js.gz +0 -0
  162. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  163. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  164. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  165. package/dist/web/public/{styles.77dc538f.css → styles.6a718331.css} +1 -1
  166. package/dist/web/public/styles.6a718331.css.br +0 -0
  167. package/dist/web/public/{styles.77dc538f.css.gz → styles.6a718331.css.gz} +0 -0
  168. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  169. package/dist/web/public/sw.js +2 -2
  170. package/dist/web/public/sw.js.br +0 -0
  171. package/dist/web/public/sw.js.gz +0 -0
  172. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  173. package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
  174. package/dist/web/public/terminal-keycode229-recovery.98843069.js.gz +0 -0
  175. package/dist/web/public/terminal-split.js.gz +0 -0
  176. package/dist/web/public/terminal-tile.js +2 -2
  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.d7c75ce8.js → terminal-ui.a7883640.js} +2 -2
  180. package/dist/web/public/terminal-ui.a7883640.js.br +0 -0
  181. package/dist/web/public/terminal-ui.a7883640.js.gz +0 -0
  182. package/dist/web/public/tile-grid.js.gz +0 -0
  183. package/dist/web/public/ultracode-panel.js.gz +0 -0
  184. package/dist/web/public/ultracode-windows.js.gz +0 -0
  185. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  186. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  187. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  188. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  189. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  190. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  191. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  192. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  193. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
  194. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
  195. package/dist/web/public/vendor/xterm.css.gz +0 -0
  196. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  197. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  198. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  199. package/dist/web/public/webview-tabs.js.gz +0 -0
  200. package/dist/web/public/whats-new.json +9 -9
  201. package/dist/web/routes/case-routes.d.ts.map +1 -1
  202. package/dist/web/routes/case-routes.js +72 -53
  203. package/dist/web/routes/case-routes.js.map +1 -1
  204. package/dist/web/routes/discord-routes.js +1 -1
  205. package/dist/web/routes/discord-routes.js.map +1 -1
  206. package/dist/web/routes/edit-history-routes.d.ts +2 -2
  207. package/dist/web/routes/edit-history-routes.d.ts.map +1 -1
  208. package/dist/web/routes/edit-history-routes.js +4 -3
  209. package/dist/web/routes/edit-history-routes.js.map +1 -1
  210. package/dist/web/routes/file-routes.d.ts.map +1 -1
  211. package/dist/web/routes/file-routes.js +213 -102
  212. package/dist/web/routes/file-routes.js.map +1 -1
  213. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  214. package/dist/web/routes/git-status-routes.js +7 -2
  215. package/dist/web/routes/git-status-routes.js.map +1 -1
  216. package/dist/web/routes/mux-routes.d.ts +10 -3
  217. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  218. package/dist/web/routes/mux-routes.js +59 -1
  219. package/dist/web/routes/mux-routes.js.map +1 -1
  220. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  221. package/dist/web/routes/ralph-routes.js +22 -0
  222. package/dist/web/routes/ralph-routes.js.map +1 -1
  223. package/dist/web/routes/readmymind-routes.d.ts.map +1 -1
  224. package/dist/web/routes/readmymind-routes.js +3 -2
  225. package/dist/web/routes/readmymind-routes.js.map +1 -1
  226. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  227. package/dist/web/routes/respawn-routes.js +24 -0
  228. package/dist/web/routes/respawn-routes.js.map +1 -1
  229. package/dist/web/routes/session-routes.d.ts +11 -8
  230. package/dist/web/routes/session-routes.d.ts.map +1 -1
  231. package/dist/web/routes/session-routes.js +262 -29
  232. package/dist/web/routes/session-routes.js.map +1 -1
  233. package/dist/web/routes/system-routes.d.ts.map +1 -1
  234. package/dist/web/routes/system-routes.js +3 -2
  235. package/dist/web/routes/system-routes.js.map +1 -1
  236. package/dist/web/schemas.d.ts +24 -1
  237. package/dist/web/schemas.d.ts.map +1 -1
  238. package/dist/web/schemas.js +34 -9
  239. package/dist/web/schemas.js.map +1 -1
  240. package/dist/web/self-update.d.ts +9 -0
  241. package/dist/web/self-update.d.ts.map +1 -1
  242. package/dist/web/self-update.js +19 -3
  243. package/dist/web/self-update.js.map +1 -1
  244. package/dist/web/server.d.ts +16 -3
  245. package/dist/web/server.d.ts.map +1 -1
  246. package/dist/web/server.js +84 -24
  247. package/dist/web/server.js.map +1 -1
  248. package/dist/web/session-wait-registry.d.ts +13 -0
  249. package/dist/web/session-wait-registry.d.ts.map +1 -1
  250. package/dist/web/session-wait-registry.js +16 -8
  251. package/dist/web/session-wait-registry.js.map +1 -1
  252. package/package.json +2 -2
  253. package/skills/codeman/SKILL.md +20 -4
  254. package/dist/web/public/app.963b5ba8.js.br +0 -0
  255. package/dist/web/public/app.963b5ba8.js.gz +0 -0
  256. package/dist/web/public/i18n.33f930d7.js +0 -2
  257. package/dist/web/public/i18n.33f930d7.js.br +0 -0
  258. package/dist/web/public/i18n.33f930d7.js.gz +0 -0
  259. package/dist/web/public/keyboard-accessory.d4ec5957.js.br +0 -0
  260. package/dist/web/public/mobile.5ef2ce85.css.br +0 -0
  261. package/dist/web/public/panels-ui.b100727d.js.br +0 -0
  262. package/dist/web/public/panels-ui.b100727d.js.gz +0 -0
  263. package/dist/web/public/session-ui.5ac82f2c.js.br +0 -0
  264. package/dist/web/public/session-ui.5ac82f2c.js.gz +0 -0
  265. package/dist/web/public/settings-ui.d1d4fb99.js.br +0 -0
  266. package/dist/web/public/settings-ui.d1d4fb99.js.gz +0 -0
  267. package/dist/web/public/styles.77dc538f.css.br +0 -0
  268. package/dist/web/public/terminal-ui.d7c75ce8.js.br +0 -0
  269. package/dist/web/public/terminal-ui.d7c75ce8.js.gz +0 -0
@@ -523,6 +523,85 @@ export function isPathWithinRoot(root, candidate) {
523
523
  const rel = relative(root, candidate);
524
524
  return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
525
525
  }
526
+ /**
527
+ * Folder entries one Files panel listing may read, summed over the folders it opens. The budget of
528
+ * the walk caps what a listing RETURNS, but taking turns means opening every folder found so far,
529
+ * so this is what caps the reading: past it, folders not opened yet stay unread and are reported as
530
+ * unfinished. It also bounds what the walk holds, since a folder keeps only entries it has read. A
531
+ * folder is read whole, so the one that crosses this line overshoots by its own size.
532
+ */
533
+ const MAX_WALK_ENTRIES_READ = 250_000;
534
+ /**
535
+ * First turn of a folder: reads it and sets `cursor.entries` to the part the listing could use,
536
+ * which is the entries it lists, sorted, up to what is left of the budget. Leaves `entries` unset
537
+ * for a folder it could not read, and sets `unanswered` when that was a probe that established
538
+ * nothing. Answers the number of entries read, for the walk's read ceiling.
539
+ */
540
+ async function readWalkFolder(cursor, budgetLeft, isListed) {
541
+ // The walk's root is the session's own folder. Below it any folder can be a mount that stopped
542
+ // answering, and a bulk scan keeps the stall cap (#516): an unbounded readdir there would hold a
543
+ // threadpool worker for good. `unknown` is never `absent`, so only a folder the filesystem says
544
+ // is gone drops out quietly: one that did not answer goes on the walk's unfinished list.
545
+ if (cursor.depth > 1) {
546
+ const kind = await probePathKind(cursor.dirPath);
547
+ cursor.unanswered = kind === 'unknown';
548
+ if (kind !== 'directory')
549
+ return 0;
550
+ }
551
+ let read;
552
+ try {
553
+ read = await fs.readdir(cursor.dirPath, { withFileTypes: true });
554
+ }
555
+ catch {
556
+ // Can't read directory (permission denied, etc.).
557
+ return 0;
558
+ }
559
+ const listed = read.filter(isListed).sort((a, b) => {
560
+ if (a.isDirectory() && !b.isDirectory())
561
+ return -1;
562
+ if (!a.isDirectory() && b.isDirectory())
563
+ return 1;
564
+ return a.name.localeCompare(b.name);
565
+ });
566
+ cursor.more = listed.length > budgetLeft;
567
+ cursor.entries = cursor.more ? listed.slice(0, budgetLeft) : listed;
568
+ return read.length;
569
+ }
570
+ /**
571
+ * Where a folder's turn ends, given a `share` of the budget. Folders sort first, and each one found
572
+ * here waits for a later round, by which time a folder that spent the rest of its share on its own
573
+ * files has left nothing to read it with. So those files count as one more folder: they take an
574
+ * equal part of what the subfolders left and the remainder stays in the budget. A turn that found
575
+ * no subfolder has nothing to hold back for, and a turn always covers at least one entry.
576
+ */
577
+ function walkTurnEnd(entries, start, share) {
578
+ const end = Math.min(start + share, entries.length);
579
+ let firstFile = start;
580
+ while (firstFile < end && entries[firstFile].isDirectory())
581
+ firstFile++;
582
+ return firstFile + Math.floor((end - firstFile) / (firstFile - start + 1));
583
+ }
584
+ /**
585
+ * Whether a `root=live` listing may start at a session's `liveWorktree` (#765). `findLiveWorktree()`
586
+ * judged that path lexically when the pane moved, and a symlink can take its place, or a parent's,
587
+ * without moving the pane. The probe and `readdir` both follow one, and the walk's `path`s only look
588
+ * relative to `workingDir`, so this is the request-time half of the rule, the same one
589
+ * `validateSessionFilePath()` applies to a file: resolved, the worktree must lie strictly inside the
590
+ * resolved `workingDir`. Only the CHECK uses the resolved paths: the walk keeps the session's spelling,
591
+ * or a workspace reached through a symlink would stop producing paths relative to `workingDir`.
592
+ * Probed first (#516); both realpaths then walk the components that probe has just answered for.
593
+ */
594
+ async function canWalkLiveWorktree(workingDir, worktreePath) {
595
+ if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory')
596
+ return false;
597
+ try {
598
+ const [realRoot, realWorktree] = await Promise.all([fs.realpath(workingDir), fs.realpath(worktreePath)]);
599
+ return realWorktree !== realRoot && isPathWithinRoot(realRoot, realWorktree);
600
+ }
601
+ catch {
602
+ return false;
603
+ }
604
+ }
526
605
  function findMatchingPickerRoot(roots, candidate) {
527
606
  return roots
528
607
  .filter((root) => isPathWithinRoot(root.path, candidate))
@@ -1130,11 +1209,14 @@ export function registerFileRoutes(app, ctx) {
1130
1209
  }
1131
1210
  // The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
1132
1211
  // local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
1133
- // deleting would take the linked folder with. A case there is made through Add Case > New Case.
1212
+ // deleting would take the linked folder with. A case there is made through Add Case > Create New with no
1213
+ // custom folder. The refusal has to read right from every picker that offers New folder: a Create New or
1214
+ // Clone Repo user is already on that tab, with the custom folder ticked, and this is where Browse opens
1215
+ // while the field is empty.
1134
1216
  const casesDirs = await Promise.all([...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) => fs.realpath(dir).catch(() => resolve(dir))));
1135
1217
  if (casesDirs.includes(resolvedPath)) {
1136
1218
  reply.code(400);
1137
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.');
1219
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This is a cases folder: every folder in it is a case. Pick a folder outside it, or make the case with Add Case > Create New or Clone Repo (no custom folder).');
1138
1220
  }
1139
1221
  // Like browse, a file is not a place to create in.
1140
1222
  try {
@@ -1216,11 +1298,20 @@ export function registerFileRoutes(app, ctx) {
1216
1298
  // File tree listing
1217
1299
  app.get('/api/sessions/:id/files', async (req) => {
1218
1300
  const { id } = req.params;
1219
- const { depth, showHidden, q } = req.query;
1301
+ const { depth, showHidden, q, root } = req.query;
1220
1302
  const session = findSessionOrFail(ctx, id, req);
1221
1303
  const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
1222
1304
  const includeHidden = showHidden === 'true';
1223
1305
  const workingDir = session.workingDir;
1306
+ // `root=live` starts the walk at the linked worktree the pane is in (#765), once that folder
1307
+ // proves to be inside `workingDir` right now, and every `path` below stays relative to
1308
+ // `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
1309
+ // paths against the same root as any other. Only the search predicate reads a path relative
1310
+ // to the listing, since that is the tree the person is looking at.
1311
+ const liveWorktree = session.liveWorktree;
1312
+ const walkRoot = root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
1313
+ ? liveWorktree.path
1314
+ : workingDir;
1224
1315
  // null for an empty/whitespace query, which is what keeps the default
1225
1316
  // tree response byte-identical when no search is requested.
1226
1317
  const matcher = compileFileQuery(q ?? '');
@@ -1245,6 +1336,8 @@ export function registerFileRoutes(app, ctx) {
1245
1336
  let totalDirectories = 0;
1246
1337
  let truncated = false;
1247
1338
  const maxFiles = 5000;
1339
+ let entriesRead = 0;
1340
+ const isListed = (entry) => (includeHidden || !entry.name.startsWith('.')) && !(entry.isDirectory() && excludeDirs.has(entry.name));
1248
1341
  // ===== Search mode =====
1249
1342
  // A query turns this endpoint into a FLAT match list rather than a nested
1250
1343
  // tree. It recurses past non-matching directories on purpose — the whole
@@ -1254,71 +1347,84 @@ export function registerFileRoutes(app, ctx) {
1254
1347
  if (matcher) {
1255
1348
  const matches = [];
1256
1349
  const maxMatches = 1000;
1257
- const searchDirectory = async (dirPath, currentDepth) => {
1258
- if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
1259
- truncated = true;
1260
- return;
1261
- }
1262
- let entries;
1263
- try {
1264
- entries = await fs.readdir(dirPath, { withFileTypes: true });
1265
- }
1266
- catch {
1267
- // Can't read directory (permission denied, etc.)
1268
- return;
1269
- }
1270
- entries.sort((a, b) => {
1271
- if (a.isDirectory() && !b.isDirectory())
1272
- return -1;
1273
- if (!a.isDirectory() && b.isDirectory())
1274
- return 1;
1275
- return a.name.localeCompare(b.name);
1276
- });
1277
- for (const entry of entries) {
1278
- if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
1279
- truncated = true;
1350
+ const queue = [{ dirPath: walkRoot, depth: 1, index: 0 }];
1351
+ // Folders the read ceiling kept shut or that did not answer. What was already read still gets its turns.
1352
+ const unread = [];
1353
+ while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
1354
+ // Per-directory round-robin (breadth-first): each queued directory gets an equal share of what
1355
+ // is left before any of them gets more, so results arrive shallow-first. Fair per directory,
1356
+ // not per subtree: a folder of thousands of small subfolders queues thousands of directories
1357
+ // and can still take most of the budget one level down. Reading a folder costs the same
1358
+ // whatever its share, so this never bounds the reading: MAX_WALK_ENTRIES_READ does.
1359
+ const directoriesInRound = queue.length;
1360
+ const nodesPerDirectory = Math.max(1, Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound));
1361
+ for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
1362
+ // Spent part way through a round: the rest stay queued unread rather than sorted for nothing.
1363
+ if (totalFiles + totalDirectories >= maxFiles || matches.length >= maxMatches)
1280
1364
  break;
1281
- }
1282
- if (!includeHidden && entry.name.startsWith('.'))
1283
- continue;
1284
- if (entry.isDirectory() && excludeDirs.has(entry.name))
1365
+ const cursor = queue.shift();
1366
+ if (!cursor)
1367
+ break;
1368
+ if (cursor.depth > maxDepth) {
1369
+ truncated = true;
1285
1370
  continue;
1286
- const fullPath = join(dirPath, entry.name);
1287
- const relativePath = relative(workingDir, fullPath);
1288
- if (entry.isDirectory()) {
1289
- totalDirectories++;
1290
- if (matcher(entry.name, relativePath)) {
1291
- matches.push({ name: entry.name, path: relativePath, type: 'directory' });
1371
+ }
1372
+ if (!cursor.entries) {
1373
+ if (entriesRead >= MAX_WALK_ENTRIES_READ) {
1374
+ unread.push(cursor);
1375
+ continue;
1292
1376
  }
1293
- // Always recurse, even when this directory does not match.
1294
- await searchDirectory(fullPath, currentDepth + 1);
1377
+ entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
1378
+ if (cursor.unanswered)
1379
+ unread.push(cursor);
1380
+ if (!cursor.entries)
1381
+ continue;
1295
1382
  }
1296
- else {
1297
- totalFiles++;
1298
- if (matcher(entry.name, relativePath)) {
1299
- let size;
1300
- try {
1301
- size = (await fs.stat(fullPath)).size;
1383
+ const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
1384
+ while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
1385
+ const entry = cursor.entries[cursor.index++];
1386
+ const fullPath = join(cursor.dirPath, entry.name);
1387
+ const relativePath = relative(workingDir, fullPath);
1388
+ const listedPath = relative(walkRoot, fullPath);
1389
+ if (entry.isDirectory()) {
1390
+ totalDirectories++;
1391
+ if (matcher(entry.name, listedPath)) {
1392
+ matches.push({ name: entry.name, path: relativePath, type: 'directory' });
1302
1393
  }
1303
- catch {
1304
- // Skip size if we can't stat the match.
1394
+ // Queued, not entered: it waits for a later round with every other directory found.
1395
+ queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 });
1396
+ }
1397
+ else {
1398
+ totalFiles++;
1399
+ if (matcher(entry.name, listedPath)) {
1400
+ let size;
1401
+ try {
1402
+ size = (await fs.stat(fullPath)).size;
1403
+ }
1404
+ catch {
1405
+ // Skip size if we can't stat the match.
1406
+ }
1407
+ matches.push({
1408
+ name: entry.name,
1409
+ path: relativePath,
1410
+ type: 'file',
1411
+ size,
1412
+ extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
1413
+ });
1305
1414
  }
1306
- matches.push({
1307
- name: entry.name,
1308
- path: relativePath,
1309
- type: 'file',
1310
- size,
1311
- extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
1312
- });
1313
1415
  }
1314
1416
  }
1417
+ if (cursor.index < cursor.entries.length || cursor.more)
1418
+ queue.push(cursor);
1315
1419
  }
1316
- };
1317
- await searchDirectory(workingDir, 1);
1420
+ }
1421
+ if (queue.length > 0 || unread.length > 0)
1422
+ truncated = true;
1318
1423
  return {
1319
1424
  success: true,
1320
1425
  data: {
1321
- root: workingDir,
1426
+ root: walkRoot,
1427
+ ...(liveWorktree && { worktree: liveWorktree }),
1322
1428
  tree: [],
1323
1429
  matches,
1324
1430
  totalFiles,
@@ -1330,44 +1436,50 @@ export function registerFileRoutes(app, ctx) {
1330
1436
  },
1331
1437
  };
1332
1438
  }
1333
- const scanDirectory = async (dirPath, currentDepth) => {
1334
- if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
1335
- truncated = true;
1336
- return [];
1337
- }
1338
- try {
1339
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
1340
- const nodes = [];
1341
- // Sort: directories first, then alphabetically
1342
- entries.sort((a, b) => {
1343
- if (a.isDirectory() && !b.isDirectory())
1344
- return -1;
1345
- if (!a.isDirectory() && b.isDirectory())
1346
- return 1;
1347
- return a.name.localeCompare(b.name);
1348
- });
1349
- for (const entry of entries) {
1350
- if (totalFiles + totalDirectories > maxFiles) {
1351
- truncated = true;
1352
- break;
1353
- }
1354
- // Skip hidden files unless requested
1355
- if (!includeHidden && entry.name.startsWith('.'))
1439
+ const tree = [];
1440
+ const queue = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }];
1441
+ const unread = [];
1442
+ while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) {
1443
+ // The same per-directory round-robin as the search walk above, with the same limits.
1444
+ const directoriesInRound = queue.length;
1445
+ const nodesPerDirectory = Math.max(1, Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound));
1446
+ for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
1447
+ // Before the shift, so a cursor this skips stays queued and is flagged below.
1448
+ if (totalFiles + totalDirectories >= maxFiles)
1449
+ break;
1450
+ const cursor = queue.shift();
1451
+ if (!cursor)
1452
+ break;
1453
+ if (cursor.depth > maxDepth) {
1454
+ truncated = true;
1455
+ continue;
1456
+ }
1457
+ if (!cursor.entries) {
1458
+ if (entriesRead >= MAX_WALK_ENTRIES_READ) {
1459
+ unread.push(cursor);
1356
1460
  continue;
1357
- // Skip excluded directories
1358
- if (entry.isDirectory() && excludeDirs.has(entry.name))
1461
+ }
1462
+ entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
1463
+ if (cursor.unanswered)
1464
+ unread.push(cursor);
1465
+ if (!cursor.entries)
1359
1466
  continue;
1360
- const fullPath = join(dirPath, entry.name);
1467
+ }
1468
+ const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
1469
+ while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
1470
+ const entry = cursor.entries[cursor.index++];
1471
+ const fullPath = join(cursor.dirPath, entry.name);
1361
1472
  const relativePath = fullPath.slice(workingDir.length + 1);
1362
1473
  if (entry.isDirectory()) {
1363
1474
  totalDirectories++;
1364
- const children = await scanDirectory(fullPath, currentDepth + 1);
1365
- nodes.push({
1475
+ const node = {
1366
1476
  name: entry.name,
1367
1477
  path: relativePath,
1368
1478
  type: 'directory',
1369
- children,
1370
- });
1479
+ children: [],
1480
+ };
1481
+ cursor.nodes.push(node);
1482
+ queue.push({ dirPath: fullPath, depth: cursor.depth + 1, node, nodes: node.children, index: 0 });
1371
1483
  }
1372
1484
  else {
1373
1485
  totalFiles++;
@@ -1378,29 +1490,28 @@ export function registerFileRoutes(app, ctx) {
1378
1490
  size = stat.size;
1379
1491
  }
1380
1492
  catch {
1381
- // Skip if can't stat
1493
+ // Skip if can't stat.
1382
1494
  }
1383
- nodes.push({
1384
- name: entry.name,
1385
- path: relativePath,
1386
- type: 'file',
1387
- size,
1388
- extension: ext,
1389
- });
1495
+ cursor.nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext });
1390
1496
  }
1391
1497
  }
1392
- return nodes;
1393
- }
1394
- catch {
1395
- // Can't read directory (permission denied, etc.)
1396
- return [];
1498
+ if (cursor.index < cursor.entries.length || cursor.more)
1499
+ queue.push(cursor);
1397
1500
  }
1398
- };
1399
- const tree = await scanDirectory(workingDir, 1);
1501
+ }
1502
+ // Every directory still queued is unread or half read, and an empty `children` would read as an
1503
+ // empty folder. The root has no node to flag: `truncated` says the same for it.
1504
+ for (const cursor of [...queue, ...unread]) {
1505
+ if (cursor.node)
1506
+ cursor.node.incomplete = true;
1507
+ }
1508
+ if (queue.length > 0 || unread.length > 0)
1509
+ truncated = true;
1400
1510
  return {
1401
1511
  success: true,
1402
1512
  data: {
1403
- root: workingDir,
1513
+ root: walkRoot,
1514
+ ...(liveWorktree && { worktree: liveWorktree }),
1404
1515
  tree,
1405
1516
  totalFiles,
1406
1517
  totalDirectories,