termdock 1.4.121 → 1.4.123

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 (73) hide show
  1. package/dist/client/assets/{ModelPreview-DsQchw3K.js → ModelPreview-DGvy0ViD.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-CFyMTXyL.js → abnfDiagram-VRR7QNED-BRUTM7Ij.js} +1 -1
  3. package/dist/client/assets/{arc-DWhW0i2g.js → arc-DeKie3_7.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-qs8HU17x.js → architectureDiagram-ZJ3FMSHR-CAorViVK.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-Ctu5j2R5.js → blockDiagram-677ZJIJ3-DHq1C73O.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BZeokwNX.js → c4Diagram-LMCZKHZV-DluWWKuh.js} +1 -1
  7. package/dist/client/assets/channel-DXiU4T4d.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-3kNs1Ku0.js → chunk-2Q5K7J3B-BPSQsAYp.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-rAdLwMim.js → chunk-32BRIVSS-g93sHdJF.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-DiCSy2sF.js → chunk-5VM5RSS4-CvtNtBa-.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-CqgAS6mE.js → chunk-EX3LRPZG-DWnR2mqn.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-CfP2DkTP.js → chunk-JWPE2WC7-Dzbpe3Xo.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-SOwz_D-F.js → chunk-MOJQB5TN-Du2Jw6Ga.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-BtWKkfPB.js → chunk-RYQCIY6F-DIqqwReI.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-SbCtu541.js → chunk-V7JOEXUC-BDK2KnFE.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-LwUGLXML.js → chunk-VR4S4FIN-BncB3kMk.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-sebs-LeT.js → chunk-XXDRQBXY-CRx51cPn.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-CreSS-cc.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CreSS-cc.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-B0MKXBxr.js → cose-bilkent-JH36ORCC-C4eOHhIl.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-CX1iiD9m.js → cynefin-VYW2F7L2-BZuh5fJN.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BFWs1VnK.js → cynefinDiagram-TSTJHNR4-BdyBy9fD.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-BtfTTYnV.js → dagre-VKFMJZFB-tlpcqDkv.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-CtH95Yjx.js → diagram-FQU43EPY-DmryDLBx.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-xJ1CuJKa.js → diagram-G47NLZAW-DyaVVj3y.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-CtTo9GYq.js → diagram-NH7WQ7WH-DfwqogJh.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-D5v1FAZ9.js → diagram-OA4YK3LP-BHGytgzq.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-BAxQcj8H.js → diagram-WEI45ONY-BW70jDhO.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DZ2dIXxg.js → ebnfDiagram-CCIWWBDH-BkvYsVdX.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-Bl9DOZNu.js → erDiagram-Q63AITRT-B-qynR5w.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-DdfDxdBo.js → flowDiagram-23GEKE2U-C0uDIjVs.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DxnT1ZVH.js → ganttDiagram-NO4QXBWP-jHMmeGdm.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BY_SrTTK.js → gitGraphDiagram-IHSO6WYX-Cyp33RGh.js} +1 -1
  34. package/dist/client/assets/index-BCdd_adt.css +1 -0
  35. package/dist/client/assets/index-DPxgzRe_.js +457 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-DFAXdG0t.js → infoDiagram-FWYZ7A6U-BX1T5ZRV.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Cza6z98S.js → ishikawaDiagram-FXEZZL3T-BehchXvq.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-6Mq1Yp_I.js → journeyDiagram-5HDEW3XC-Bdy1ed1k.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-CvTaNPB1.js → kanban-definition-HUTT4EX6-C7n-XF4G.js} +1 -1
  40. package/dist/client/assets/{linear-BxvamTpC.js → linear-Cj0QkFq-.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CvGjmRiA.js → mermaid.core-kFgOOXNm.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-P5s5VwkA.js → mindmap-definition-LN4V7U3C-X3xTk9oE.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-CcgERS4b.js → pegDiagram-2B236MQR-DHr3LWfT.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-lu_uZMjY.js → pieDiagram-ENE6RG2P-Cgk4qJXy.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CixHZa8E.js → quadrantDiagram-ABIIQ3AL-Db452gS6.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-Bs4rI4Co.js → railroadDiagram-RFXS5EU6-Ch79_eAD.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-COMua6-x.js → requirementDiagram-TGXJPOKE-WeVLc483.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CHjivcTz.js → sankeyDiagram-HTMAVEWB-2ClPBKFi.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-kP1sT3bG.js → sequenceDiagram-DBY2YBRQ-DMOUB-GA.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-CSyesB4l.js → sizeCapture-X5ZJPWSS-QASuTy1-.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-Dv9tX_RU.js → stateDiagram-2N3HPSRC-BNeDpvX-.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DUPNitKK.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-CdvxFNFu.js → swimlanes-5IMT3BWC-DvuVVUtW.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BhOFXgln.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-BT-dDyTO.js → timeline-definition-FHXFAJF6-DuTo7eYA.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-CbffJ-gd.js → vennDiagram-L72KCM5P-Do7RZnUg.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-Cu5Yvx2k.js → wardleyDiagram-EHGQE667-BSvQIlfU.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-NVbSc9iU.js → xychartDiagram-FW5EYKEG-BONZO9-F.js} +1 -1
  59. package/dist/client/index.html +2 -2
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/routes/filesystem.js +233 -59
  62. package/dist/server/routes/terminal.js +29 -3
  63. package/dist/server/utils/fileWatchPolicy.js +136 -0
  64. package/dist/server/utils/settings.js +29 -0
  65. package/package.json +1 -1
  66. package/runtime-manifest.json +2 -2
  67. package/dist/client/assets/channel-CgVUhEh9.js +0 -1
  68. package/dist/client/assets/classDiagram-OUVF2IWQ-A60TxAHA.js +0 -1
  69. package/dist/client/assets/classDiagram-v2-EOCWNBFH-A60TxAHA.js +0 -1
  70. package/dist/client/assets/index-UhZwBu7l.css +0 -1
  71. package/dist/client/assets/index-rRxD1BhF.js +0 -457
  72. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DmZzXEeI.js +0 -1
  73. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CfgiHnJ1.js +0 -8
@@ -18,6 +18,7 @@ import { deleteFilesystemFile } from '../utils/deleteFilesystemFile.js';
18
18
  import { inspectKicadBoardPoint } from '../utils/kicadBoardInspection.js';
19
19
  import { EdaPreviewCache, requestAcceptsEtag } from '../utils/edaPreviewCache.js';
20
20
  import { convertHeicPreview, HEIC_PREVIEW_MIME_TYPES, isHeicPreviewPath } from '../utils/heicPreview.js';
21
+ import { getWatchErrorCode, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion, minimizeRecursiveWatchRoots, WATCH_IGNORED_NAMES, WATCH_RESOURCE_BACKOFF_MS, } from '../utils/fileWatchPolicy.js';
21
22
  const router = Router();
22
23
  const MAX_FILE_SIZE = 1024 * 1024; // 1MB
23
24
  const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
@@ -59,7 +60,77 @@ const FS_IO_LOG_NAME = 'fs-io.log';
59
60
  const activeDiffSlots = new Map();
60
61
  const activeIoSlots = new Map();
61
62
  const sharedWatchRegistry = new Map();
62
- const SHARED_WATCH_EVICT_DELAY_MS = 60_000;
63
+ const SHARED_WATCH_EVICT_DELAY_MS = 5_000;
64
+ const WATCH_STAT_CONCURRENCY = 8;
65
+ let watchAdmissionBlockedUntil = 0;
66
+ function countActiveSharedWatches() {
67
+ let count = 0;
68
+ for (const entry of sharedWatchRegistry.values()) {
69
+ if (entry.subscription || entry.inFlight)
70
+ count += 1;
71
+ }
72
+ return count;
73
+ }
74
+ function broadcastSharedWatchEvent(entry, event) {
75
+ for (const client of entry.clients) {
76
+ if (!client.closed)
77
+ client.enqueue(event);
78
+ }
79
+ }
80
+ function drainSharedWatchEvents(entry) {
81
+ if (entry.failure || entry.clients.size === 0)
82
+ return;
83
+ while (entry.statWorkers < WATCH_STAT_CONCURRENCY && entry.pendingEvents.size > 0) {
84
+ const next = entry.pendingEvents.entries().next().value;
85
+ if (!next)
86
+ return;
87
+ const [changedPath, eventType] = next;
88
+ entry.pendingEvents.delete(changedPath);
89
+ if (eventType === 'delete') {
90
+ broadcastSharedWatchEvent(entry, { type: 'deleted', path: changedPath });
91
+ continue;
92
+ }
93
+ const generation = entry.eventGeneration;
94
+ entry.statWorkers += 1;
95
+ void fs.promises.lstat(changedPath)
96
+ .then((stat) => {
97
+ if (entry.failure || generation !== entry.eventGeneration)
98
+ return;
99
+ broadcastSharedWatchEvent(entry, {
100
+ type: eventType === 'create' ? 'created' : 'updated',
101
+ path: changedPath,
102
+ entry: toFileEntry(changedPath, stat),
103
+ });
104
+ })
105
+ .catch(() => {
106
+ if (entry.failure || generation !== entry.eventGeneration)
107
+ return;
108
+ broadcastSharedWatchEvent(entry, { type: 'deleted', path: changedPath });
109
+ })
110
+ .finally(() => {
111
+ entry.statWorkers -= 1;
112
+ drainSharedWatchEvents(entry);
113
+ });
114
+ }
115
+ }
116
+ function enqueueSharedNativeEvents(rootPath, entry, events) {
117
+ if (entry.clients.size === 0)
118
+ return;
119
+ for (const event of events) {
120
+ const changedPath = path.resolve(event.path);
121
+ if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
122
+ continue;
123
+ if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, event.type, WATCH_EVENT_STORM_LIMIT) === 'overflow') {
124
+ entry.pendingEvents.clear();
125
+ entry.eventGeneration += 1;
126
+ broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
127
+ return;
128
+ }
129
+ // The latest event for one path wins. This turns save bursts and atomic
130
+ // editor renames into one stat operation instead of one operation/event.
131
+ }
132
+ drainSharedWatchEvents(entry);
133
+ }
63
134
  function sharedWatchCallback(rootPath, entry) {
64
135
  return (error, events) => {
65
136
  if (entry.failure)
@@ -67,42 +138,35 @@ function sharedWatchCallback(rootPath, entry) {
67
138
  if (error) {
68
139
  entry.failure = error.message || 'watch-error';
69
140
  const reason = entry.failure;
70
- for (const client of entry.clients) {
141
+ entry.pendingEvents.clear();
142
+ entry.eventGeneration += 1;
143
+ const resourceExhausted = isWatchResourceExhaustion(error);
144
+ if (resourceExhausted)
145
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
146
+ console.warn('[file-watch] Native subscription callback failed; degrading to manual refresh', {
147
+ rootPath,
148
+ code: getWatchErrorCode(error),
149
+ activeSubscriptions: countActiveSharedWatches(),
150
+ registrySize: sharedWatchRegistry.size,
151
+ resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
152
+ error: reason,
153
+ });
154
+ const failedSubscription = entry.subscription;
155
+ entry.subscription = null;
156
+ void failedSubscription?.unsubscribe().catch(() => undefined);
157
+ if (resourceExhausted) {
158
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
159
+ if (diagnostics)
160
+ console.warn('[file-watch] Linux inotify usage after callback failure', diagnostics);
161
+ }).catch(() => undefined);
162
+ }
163
+ for (const client of [...entry.clients]) {
71
164
  if (!client.closed)
72
- client.enqueue({ type: 'rescan-required', path: rootPath, reason });
165
+ client.degrade(reason);
73
166
  }
74
167
  return;
75
168
  }
76
- for (const event of events) {
77
- const changedPath = path.resolve(event.path);
78
- if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
79
- continue;
80
- if (event.type === 'delete') {
81
- for (const client of entry.clients) {
82
- if (!client.closed)
83
- client.enqueue({ type: 'deleted', path: changedPath });
84
- }
85
- continue;
86
- }
87
- fs.promises.lstat(changedPath)
88
- .then((stat) => {
89
- const payload = {
90
- type: event.type === 'create' ? 'created' : 'updated',
91
- path: changedPath,
92
- entry: toFileEntry(changedPath, stat),
93
- };
94
- for (const client of entry.clients) {
95
- if (!client.closed)
96
- client.enqueue(payload);
97
- }
98
- })
99
- .catch(() => {
100
- for (const client of entry.clients) {
101
- if (!client.closed)
102
- client.enqueue({ type: 'deleted', path: changedPath });
103
- }
104
- });
105
- }
169
+ enqueueSharedNativeEvents(rootPath, entry, events);
106
170
  };
107
171
  }
108
172
  async function acquireSharedWatch(rootPath, client) {
@@ -115,6 +179,10 @@ async function acquireSharedWatch(rootPath, client) {
115
179
  clients: new Set(),
116
180
  inFlight: null,
117
181
  evictTimer: null,
182
+ pendingEvents: new Map(),
183
+ statWorkers: 0,
184
+ eventGeneration: 0,
185
+ needsRescanOnAttach: false,
118
186
  };
119
187
  sharedWatchRegistry.set(rootPath, entry);
120
188
  }
@@ -124,11 +192,32 @@ async function acquireSharedWatch(rootPath, client) {
124
192
  }
125
193
  entry.refs += 1;
126
194
  entry.clients.add(client);
195
+ if (entry.needsRescanOnAttach) {
196
+ entry.needsRescanOnAttach = false;
197
+ client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
198
+ }
127
199
  if (entry.failure)
128
200
  return null;
129
201
  if (entry.subscription)
130
202
  return rootPath;
131
203
  if (!entry.inFlight) {
204
+ const now = Date.now();
205
+ const activeSubscriptions = countActiveSharedWatches();
206
+ if (now < watchAdmissionBlockedUntil) {
207
+ entry.failure = `native watcher admission paused for ${Math.ceil((watchAdmissionBlockedUntil - now) / 1000)}s after resource exhaustion`;
208
+ console.warn('[file-watch] Subscription skipped while resource backoff is active', {
209
+ rootPath,
210
+ activeSubscriptions,
211
+ registrySize: sharedWatchRegistry.size,
212
+ });
213
+ return null;
214
+ }
215
+ const startedAt = Date.now();
216
+ console.info('[file-watch] Starting native subscription', {
217
+ rootPath,
218
+ activeSubscriptions: activeSubscriptions + 1,
219
+ registrySize: sharedWatchRegistry.size,
220
+ });
132
221
  entry.inFlight = watcher.subscribe(rootPath, sharedWatchCallback(rootPath, entry), {
133
222
  ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
134
223
  })
@@ -141,12 +230,36 @@ async function acquireSharedWatch(rootPath, client) {
141
230
  return null;
142
231
  }
143
232
  current.subscription = subscription;
233
+ console.info('[file-watch] Native subscription ready', {
234
+ rootPath,
235
+ scanDurationMs: Date.now() - startedAt,
236
+ activeSubscriptions: countActiveSharedWatches(),
237
+ registrySize: sharedWatchRegistry.size,
238
+ });
144
239
  return subscription;
145
240
  })
146
241
  .catch((error) => {
147
242
  const current = sharedWatchRegistry.get(rootPath);
148
243
  if (current)
149
244
  current.failure = error instanceof Error ? error.message : String(error);
245
+ const resourceExhausted = isWatchResourceExhaustion(error);
246
+ if (resourceExhausted)
247
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
248
+ console.warn('[file-watch] Native subscription failed; degrading to manual refresh', {
249
+ rootPath,
250
+ code: getWatchErrorCode(error),
251
+ scanDurationMs: Date.now() - startedAt,
252
+ activeSubscriptions: countActiveSharedWatches(),
253
+ registrySize: sharedWatchRegistry.size,
254
+ resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
255
+ error: error instanceof Error ? error.message : String(error),
256
+ });
257
+ if (resourceExhausted) {
258
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
259
+ if (diagnostics)
260
+ console.warn('[file-watch] Linux inotify usage after resource exhaustion', diagnostics);
261
+ }).catch(() => undefined);
262
+ }
150
263
  return null;
151
264
  })
152
265
  .finally(() => {
@@ -168,6 +281,13 @@ function releaseSharedWatch(rootPath, client) {
168
281
  entry.refs -= 1;
169
282
  if (entry.refs > 0)
170
283
  return;
284
+ entry.needsRescanOnAttach = true;
285
+ entry.pendingEvents.clear();
286
+ entry.eventGeneration += 1;
287
+ if (!entry.subscription && !entry.inFlight) {
288
+ sharedWatchRegistry.delete(rootPath);
289
+ return;
290
+ }
171
291
  if (entry.evictTimer)
172
292
  clearTimeout(entry.evictTimer);
173
293
  entry.evictTimer = setTimeout(() => {
@@ -414,9 +534,6 @@ function compareDirents(a, b) {
414
534
  return 1;
415
535
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
416
536
  }
417
- const WATCH_IGNORED_NAMES = new Set([
418
- '.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
419
- ]);
420
537
  const NESTED_GIT_DISCOVERY_IGNORED_NAMES = new Set([
421
538
  '.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
422
539
  '.cache', '.parcel-cache', '.yarn', '.pnpm-store', 'vendor',
@@ -1353,11 +1470,12 @@ function toFileEntry(entryPath, stat, isSymlink = false) {
1353
1470
  path: entryPath,
1354
1471
  type: stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file',
1355
1472
  isSymlink,
1473
+ modified: stat.mtime.toISOString(),
1356
1474
  };
1357
1475
  }
1358
- async function toDirectoryEntry(dir, dirent) {
1476
+ async function toDirectoryEntry(dir, dirent, includeModified = false) {
1359
1477
  const entryPath = path.join(dir, dirent.name);
1360
- if (!dirent.isSymbolicLink()) {
1478
+ if (!dirent.isSymbolicLink() && !includeModified) {
1361
1479
  return {
1362
1480
  name: dirent.name,
1363
1481
  path: entryPath,
@@ -1369,19 +1487,39 @@ async function toDirectoryEntry(dir, dirent) {
1369
1487
  return {
1370
1488
  name: dirent.name,
1371
1489
  path: entryPath,
1372
- type: stat.isDirectory() ? 'directory' : 'symlink',
1373
- isSymlink: true,
1490
+ type: stat.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
1491
+ isSymlink: dirent.isSymbolicLink() || undefined,
1492
+ modified: includeModified ? stat.mtime.toISOString() : undefined,
1374
1493
  };
1375
1494
  }
1376
1495
  catch {
1377
1496
  return {
1378
1497
  name: dirent.name,
1379
1498
  path: entryPath,
1380
- type: 'symlink',
1381
- isSymlink: true,
1499
+ type: dirent.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
1500
+ isSymlink: dirent.isSymbolicLink() || undefined,
1382
1501
  };
1383
1502
  }
1384
1503
  }
1504
+ async function loadDirectoryEntriesWithModified(dir, dirents, signal) {
1505
+ const entries = [];
1506
+ const batchSize = 64;
1507
+ for (let offset = 0; offset < dirents.length; offset += batchSize) {
1508
+ throwIfAborted(signal, 'fs.list');
1509
+ entries.push(...await Promise.all(dirents.slice(offset, offset + batchSize).map((dirent) => toDirectoryEntry(dir, dirent, true))));
1510
+ }
1511
+ return entries;
1512
+ }
1513
+ function compareDirectoryEntriesByModified(a, b) {
1514
+ if (a.type === 'directory' && b.type !== 'directory')
1515
+ return -1;
1516
+ if (a.type !== 'directory' && b.type === 'directory')
1517
+ return 1;
1518
+ const modifiedDelta = Date.parse(b.modified ?? '') - Date.parse(a.modified ?? '');
1519
+ if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0)
1520
+ return modifiedDelta;
1521
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1522
+ }
1385
1523
  function isIgnoredWatchPath(rootPath, candidatePath) {
1386
1524
  const relative = path.relative(rootPath, candidatePath);
1387
1525
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
@@ -2378,13 +2516,18 @@ router.get('/list', async (req, res) => {
2378
2516
  throw new Error('Path is not a directory');
2379
2517
  }
2380
2518
  const showHidden = req.query.showHidden === 'true';
2519
+ const sortMode = req.query.sort === 'modified' ? 'modified' : 'name';
2381
2520
  const allEntries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
2382
2521
  throwIfAborted(controller.signal, 'fs.list');
2383
2522
  const visibleDirents = allEntries.filter(dirent => showHidden || !dirent.name.startsWith('.'));
2384
- const returnedDirents = visibleDirents
2385
- .sort(compareDirents)
2386
- .slice(0, MAX_DIRECTORY_ENTRIES);
2387
- const entries = await Promise.all(returnedDirents.map((dirent) => toDirectoryEntry(resolvedPath, dirent)));
2523
+ const entries = sortMode === 'modified'
2524
+ ? (await loadDirectoryEntriesWithModified(resolvedPath, visibleDirents, controller.signal))
2525
+ .sort(compareDirectoryEntriesByModified)
2526
+ .slice(0, MAX_DIRECTORY_ENTRIES)
2527
+ : await Promise.all(visibleDirents
2528
+ .sort(compareDirents)
2529
+ .slice(0, MAX_DIRECTORY_ENTRIES)
2530
+ .map((dirent) => toDirectoryEntry(resolvedPath, dirent)));
2388
2531
  throwIfAborted(controller.signal, 'fs.list');
2389
2532
  return { resolvedPath, entries, total: visibleDirents.length };
2390
2533
  })(), FS_ROUTE_TIMEOUT_MS, 'Directory listing took too long. The folder may be on a slow disk or network mount.', 'FS_LIST_TIMEOUT');
@@ -2410,20 +2553,21 @@ router.get('/watch', async (req, res) => {
2410
2553
  const rootsParam = req.query.roots;
2411
2554
  const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
2412
2555
  .filter((root) => typeof root === 'string');
2413
- const roots = [];
2556
+ const validatedRoots = [];
2414
2557
  for (const rawRoot of rawRoots) {
2415
2558
  if (!rawRoot)
2416
2559
  continue;
2417
2560
  try {
2418
2561
  const resolved = await pathValidator.validatePathAsync(rawRoot);
2419
2562
  const stat = await fs.promises.stat(resolved);
2420
- if (stat.isDirectory() && !roots.includes(resolved))
2421
- roots.push(resolved);
2563
+ if (stat.isDirectory() && !validatedRoots.includes(resolved))
2564
+ validatedRoots.push(resolved);
2422
2565
  }
2423
2566
  catch {
2424
2567
  // Ignore invalid watch roots; the visible tree will still work via manual list requests.
2425
2568
  }
2426
2569
  }
2570
+ const roots = minimizeRecursiveWatchRoots(validatedRoots);
2427
2571
  if (roots.length === 0) {
2428
2572
  res.status(400).json({ error: 'No valid roots to watch' });
2429
2573
  return;
@@ -2435,6 +2579,21 @@ router.get('/watch', async (req, res) => {
2435
2579
  let pending = [];
2436
2580
  let flushTimer = null;
2437
2581
  const attachedRoots = new Map();
2582
+ // Register cleanup before the first native subscription starts. A recursive
2583
+ // initial scan can take seconds on a large repository; the browser may abort
2584
+ // during that await when the user switches files or roots. Registering this
2585
+ // only after attachRoot() leaked the pending subscription and its reference.
2586
+ const closeWatchStream = () => {
2587
+ if (closed)
2588
+ return;
2589
+ closed = true;
2590
+ if (flushTimer)
2591
+ clearTimeout(flushTimer);
2592
+ for (const [rootPath, client] of attachedRoots) {
2593
+ releaseSharedWatch(rootPath, client);
2594
+ }
2595
+ };
2596
+ res.once('close', closeWatchStream);
2438
2597
  const writeEvent = (type, payload) => {
2439
2598
  if (closed || res.destroyed)
2440
2599
  return;
@@ -2463,28 +2622,43 @@ router.get('/watch', async (req, res) => {
2463
2622
  }
2464
2623
  };
2465
2624
  writeEvent('ready', { roots });
2625
+ console.info('[file-watch] Stream ready', {
2626
+ requestedRoots: rawRoots.length,
2627
+ validatedRoots: validatedRoots.length,
2628
+ subscribedRoots: roots.length,
2629
+ collapsedOverlappingRoots: validatedRoots.length - roots.length,
2630
+ activeSubscriptions: countActiveSharedWatches(),
2631
+ registrySize: sharedWatchRegistry.size,
2632
+ });
2466
2633
  const attachRoot = async (rootPath) => {
2467
- const client = { closed: false, enqueue };
2634
+ if (closed)
2635
+ return;
2636
+ const client = {
2637
+ closed: false,
2638
+ enqueue,
2639
+ degrade: (reason) => {
2640
+ if (closed)
2641
+ return;
2642
+ enqueue({ type: 'rescan-required', path: rootPath, reason });
2643
+ flush();
2644
+ if (!res.writableEnded)
2645
+ res.end();
2646
+ },
2647
+ };
2468
2648
  attachedRoots.set(rootPath, client);
2469
2649
  const ok = await acquireSharedWatch(rootPath, client);
2470
2650
  if (closed)
2471
2651
  return;
2472
2652
  if (!ok) {
2473
2653
  const reason = sharedWatchRegistry.get(rootPath)?.failure ?? 'watch-unavailable';
2474
- enqueue({ type: 'rescan-required', path: rootPath, reason });
2654
+ client.degrade(reason);
2475
2655
  }
2476
2656
  };
2477
2657
  for (const rootPath of roots) {
2658
+ if (closed)
2659
+ break;
2478
2660
  await attachRoot(rootPath);
2479
2661
  }
2480
- req.on('close', () => {
2481
- closed = true;
2482
- if (flushTimer)
2483
- clearTimeout(flushTimer);
2484
- for (const [rootPath, client] of attachedRoots) {
2485
- releaseSharedWatch(rootPath, client);
2486
- }
2487
- });
2488
2662
  });
2489
2663
  router.get('/cancel-slot', (req, res) => {
2490
2664
  const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
@@ -14,9 +14,10 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
14
14
  import { pathValidator } from '../utils/pathValidator.js';
15
15
  import { TERMINAL, TMUX } from '../config.js';
16
16
  import { localAccessManager } from '../utils/localAccess.js';
17
- import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, getRunningSessionButtonEnabledSetting, setRunningSessionButtonEnabledSetting, } from '../utils/settings.js';
17
+ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, getRunningSessionButtonEnabledSetting, setRunningSessionButtonEnabledSetting, getFileSortModesSetting, setFileSortModesSetting, setFileSortModeSetting, } from '../utils/settings.js';
18
18
  import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
19
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
20
+ import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fileWatchPolicy.js';
20
21
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
21
22
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
22
23
  import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
@@ -457,6 +458,15 @@ function scheduleReloadGlobalSessionState() {
457
458
  globalSessionStateReloadTimer.unref?.();
458
459
  }
459
460
  async function watchGlobalSessionStateFile() {
461
+ const reportFailure = (message, error) => {
462
+ console.warn(message, getErrorMessage(error));
463
+ if (!isWatchResourceExhaustion(error))
464
+ return;
465
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
466
+ if (diagnostics)
467
+ console.warn('[session-persist] Linux inotify usage after watcher failure:', diagnostics);
468
+ }).catch(() => undefined);
469
+ };
460
470
  try {
461
471
  const dir = path.dirname(GLOBAL_SESSION_STATE_FILE);
462
472
  await fs.promises.mkdir(dir, { recursive: true });
@@ -468,11 +478,11 @@ async function watchGlobalSessionStateFile() {
468
478
  scheduleReloadGlobalSessionState();
469
479
  });
470
480
  globalSessionStateWatcher.on('error', (error) => {
471
- console.warn('[session-persist] Global state watcher failed:', getErrorMessage(error));
481
+ reportFailure('[session-persist] Global state watcher failed:', error);
472
482
  });
473
483
  }
474
484
  catch (error) {
475
- console.warn('[session-persist] Failed to watch global state file:', getErrorMessage(error));
485
+ reportFailure('[session-persist] Failed to watch global state file:', error);
476
486
  }
477
487
  }
478
488
  function schedulePersistGlobalState() {
@@ -5566,6 +5576,7 @@ async function getSettingsPayload() {
5566
5576
  autoRenamePromptPayloadChars: getAutoRenamePromptPayloadCharsSetting(),
5567
5577
  newSessionAgentSlug: getNewSessionAgentSlugSetting(),
5568
5578
  runningSessionButtonEnabled: getRunningSessionButtonEnabledSetting(),
5579
+ fileSortModes: getFileSortModesSetting(),
5569
5580
  localAccess: {
5570
5581
  ...localAccess,
5571
5582
  interfaces,
@@ -5677,6 +5688,21 @@ router.put('/settings', async (req, res) => {
5677
5688
  if (typeof body.runningSessionButtonEnabled === 'boolean') {
5678
5689
  setRunningSessionButtonEnabledSetting(body.runningSessionButtonEnabled);
5679
5690
  }
5691
+ if (body.fileSortModes && typeof body.fileSortModes === 'object' && !Array.isArray(body.fileSortModes)) {
5692
+ setFileSortModesSetting(body.fileSortModes);
5693
+ }
5694
+ if (body.fileSortMode && typeof body.fileSortMode === 'object') {
5695
+ const preference = body.fileSortMode;
5696
+ const validPath = typeof preference.path === 'string'
5697
+ && preference.path.length > 0
5698
+ && preference.path.length <= 4096
5699
+ && (preference.path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(preference.path));
5700
+ if (!validPath || (preference.mode !== 'name' && preference.mode !== 'modified')) {
5701
+ res.status(400).json({ error: 'Invalid file sort preference', code: 'FILE_SORT_MODE_INVALID' });
5702
+ return;
5703
+ }
5704
+ setFileSortModeSetting(preference.path, preference.mode);
5705
+ }
5680
5706
  if (body.localAccess && typeof body.localAccess === 'object') {
5681
5707
  const localAccessBody = body.localAccess;
5682
5708
  if (localAccessBody.reset === true) {
@@ -0,0 +1,136 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ export const WATCH_IGNORED_NAMES = new Set([
4
+ '.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
5
+ ]);
6
+ export const WATCH_RESOURCE_BACKOFF_MS = 60_000;
7
+ export function enqueueLatestWatchEvent(pending, eventPath, eventType, maxPending) {
8
+ if (!pending.has(eventPath) && pending.size >= maxPending)
9
+ return 'overflow';
10
+ pending.set(eventPath, eventType);
11
+ return 'queued';
12
+ }
13
+ function isSameOrDescendant(parentPath, candidatePath) {
14
+ const relative = path.relative(parentPath, candidatePath);
15
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
16
+ }
17
+ /**
18
+ * Recursive watchers already cover every descendant. Keep only the shallowest
19
+ * roots so expanding a folder inside an already watched folder cannot allocate
20
+ * another overlapping native watcher.
21
+ */
22
+ export function minimizeRecursiveWatchRoots(roots) {
23
+ const uniqueRoots = [...new Set(roots.map((root) => path.resolve(root)))];
24
+ uniqueRoots.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
25
+ const minimal = [];
26
+ for (const root of uniqueRoots) {
27
+ if (!minimal.some((parent) => isSameOrDescendant(parent, root)))
28
+ minimal.push(root);
29
+ }
30
+ return minimal;
31
+ }
32
+ export function getWatchErrorCode(error) {
33
+ if (!error || typeof error !== 'object')
34
+ return null;
35
+ const code = error.code;
36
+ return typeof code === 'string' ? code.toUpperCase() : null;
37
+ }
38
+ export function isWatchResourceExhaustion(error) {
39
+ const code = getWatchErrorCode(error);
40
+ if (code === 'EMFILE' || code === 'ENFILE' || code === 'ENOSPC')
41
+ return true;
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return /\b(?:EMFILE|ENFILE|ENOSPC)\b|too many open files|inotify/i.test(message);
44
+ }
45
+ export function countInotifyWatchDescriptors(fdInfo) {
46
+ return fdInfo.split('\n').filter((line) => line.startsWith('inotify wd:')).length;
47
+ }
48
+ /** Best-effort Linux-only snapshot. Call only on watcher failures, never on the hot path. */
49
+ export async function inspectLinuxInotifyUsage() {
50
+ if (process.platform !== 'linux' || typeof process.getuid !== 'function')
51
+ return null;
52
+ const uid = process.getuid();
53
+ let partial = false;
54
+ let maxUserInstances = null;
55
+ let maxUserWatches = null;
56
+ try {
57
+ const raw = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_instances', 'utf8');
58
+ const parsed = Number.parseInt(raw.trim(), 10);
59
+ if (Number.isFinite(parsed))
60
+ maxUserInstances = parsed;
61
+ }
62
+ catch {
63
+ partial = true;
64
+ }
65
+ try {
66
+ const raw = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_watches', 'utf8');
67
+ const parsed = Number.parseInt(raw.trim(), 10);
68
+ if (Number.isFinite(parsed))
69
+ maxUserWatches = parsed;
70
+ }
71
+ catch {
72
+ partial = true;
73
+ }
74
+ let procEntries;
75
+ try {
76
+ procEntries = await fs.promises.readdir('/proc', { withFileTypes: true });
77
+ }
78
+ catch {
79
+ return { maxUserInstances, maxUserWatches, observedUserInstances: 0, observedUserWatches: 0, topConsumers: [], partial: true };
80
+ }
81
+ const consumers = [];
82
+ for (const procEntry of procEntries) {
83
+ if (!procEntry.isDirectory() || !/^\d+$/.test(procEntry.name))
84
+ continue;
85
+ const pid = Number(procEntry.name);
86
+ const procPath = `/proc/${procEntry.name}`;
87
+ try {
88
+ if ((await fs.promises.stat(procPath)).uid !== uid)
89
+ continue;
90
+ const fdEntries = await fs.promises.readdir(`${procPath}/fd`);
91
+ let instances = 0;
92
+ let watches = 0;
93
+ for (const fd of fdEntries) {
94
+ try {
95
+ if ((await fs.promises.readlink(`${procPath}/fd/${fd}`)) !== 'anon_inode:inotify')
96
+ continue;
97
+ instances += 1;
98
+ try {
99
+ watches += countInotifyWatchDescriptors(await fs.promises.readFile(`${procPath}/fdinfo/${fd}`, 'utf8'));
100
+ }
101
+ catch {
102
+ partial = true;
103
+ }
104
+ }
105
+ catch {
106
+ // Processes and descriptors can disappear while /proc is sampled.
107
+ }
108
+ }
109
+ if (instances === 0)
110
+ continue;
111
+ let command = procEntry.name;
112
+ try {
113
+ const rawCommand = await fs.promises.readFile(`${procPath}/comm`, 'utf8');
114
+ command = rawCommand.trim() || command;
115
+ }
116
+ catch {
117
+ partial = true;
118
+ }
119
+ consumers.push({ pid, command, instances, watches });
120
+ }
121
+ catch (error) {
122
+ const code = getWatchErrorCode(error);
123
+ if (code !== 'ENOENT' && code !== 'ESRCH')
124
+ partial = true;
125
+ }
126
+ }
127
+ consumers.sort((a, b) => b.watches - a.watches || b.instances - a.instances || a.pid - b.pid);
128
+ return {
129
+ maxUserInstances,
130
+ maxUserWatches,
131
+ observedUserInstances: consumers.reduce((total, item) => total + item.instances, 0),
132
+ observedUserWatches: consumers.reduce((total, item) => total + item.watches, 0),
133
+ topConsumers: consumers.slice(0, 8),
134
+ partial,
135
+ };
136
+ }