termdock 1.4.122 → 1.4.124

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 (75) hide show
  1. package/dist/client/assets/{ModelPreview-CJe-PWxc.js → ModelPreview-Be2smem8.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-CZGYYTkL.js → abnfDiagram-VRR7QNED-BlflMbiO.js} +1 -1
  3. package/dist/client/assets/{arc-C9ovRRCB.js → arc-BLcH6XEC.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CfijFgFE.js → architectureDiagram-ZJ3FMSHR-CnBbD-1o.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-_ewP5xzy.js → blockDiagram-677ZJIJ3-DPF-Ts3E.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BdFNzfaB.js → c4Diagram-LMCZKHZV-bj_qgr87.js} +1 -1
  7. package/dist/client/assets/channel-CwqZRIbt.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-Bk-Zrlca.js → chunk-2Q5K7J3B-BLPLyR9K.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-CXmu1n-P.js → chunk-32BRIVSS-DWlXbXJt.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-D1ewEFAt.js → chunk-5VM5RSS4-CEM0N0ye.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-Nb-EMdx-.js → chunk-EX3LRPZG-Dty9y4gw.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-BMh6fTUm.js → chunk-JWPE2WC7-dlwhK_i1.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-DGTq8m1i.js → chunk-MOJQB5TN-CX08FiG7.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-DnIXl6Ku.js → chunk-RYQCIY6F-CoYfVCEL.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-d6x1hwlS.js → chunk-V7JOEXUC-C_nJAEZ6.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-Cst36NFV.js → chunk-VR4S4FIN-CxTZ4h0g.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-DDzcV3AA.js → chunk-XXDRQBXY-CRcElLUQ.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-CsBWdSSp.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CsBWdSSp.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-DPcjHu-a.js → cose-bilkent-JH36ORCC-DopCQZMr.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-DLWGJOKu.js → cynefin-VYW2F7L2-C5lwo5rG.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-ChwiuhwW.js → cynefinDiagram-TSTJHNR4-BqE-HnNf.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-DMn68BCd.js → dagre-VKFMJZFB-Qnuvs3u5.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-BWzt083-.js → diagram-FQU43EPY-T6rXZ6sy.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-1Ifm4uV0.js → diagram-G47NLZAW-BlKH1yFL.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-Bo_SF-cE.js → diagram-NH7WQ7WH-BsAh_h83.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-DLX-LCJB.js → diagram-OA4YK3LP-DFFBJ56F.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-Bf5qmb8B.js → diagram-WEI45ONY-BAt9TFgr.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-jmsp3qMg.js → ebnfDiagram-CCIWWBDH-CZqTgzyZ.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-BSJ9O_Uj.js → erDiagram-Q63AITRT-Des9G2E2.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-BhcIFwIa.js → flowDiagram-23GEKE2U-D-GCzapP.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-Dl4sz2ED.js → ganttDiagram-NO4QXBWP-BGH_iM7_.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BpXwoKi_.js → gitGraphDiagram-IHSO6WYX-dheKFUpw.js} +1 -1
  34. package/dist/client/assets/index-BCdd_adt.css +1 -0
  35. package/dist/client/assets/index-DjYnI5kP.js +457 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-1n8vVZDv.js → infoDiagram-FWYZ7A6U-BZYW2clU.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BOoubbH7.js → ishikawaDiagram-FXEZZL3T-Di9GvVpK.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-C4gMgc1B.js → journeyDiagram-5HDEW3XC-Bm_oN9-6.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-BHIXqAjV.js → kanban-definition-HUTT4EX6-tj1z1JQG.js} +1 -1
  40. package/dist/client/assets/{linear-CpIhx6Gc.js → linear-QXJ-Ae7F.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CiIhxnwD.js → mermaid.core-C2fDWTc4.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-YQsnBsmN.js → mindmap-definition-LN4V7U3C-BilCFNik.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-CwVOSvS2.js → pegDiagram-2B236MQR-Cedi8jLg.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-BwKxsCxJ.js → pieDiagram-ENE6RG2P-u8doKeCT.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BQxwGIv5.js → quadrantDiagram-ABIIQ3AL-CTksDMFb.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-Cx9SFycV.js → railroadDiagram-RFXS5EU6-a3hmKcli.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-39fPSSwK.js → requirementDiagram-TGXJPOKE-0CbosJM9.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-DZCrgI-t.js → sankeyDiagram-HTMAVEWB-DM9MM942.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-gNACCCge.js → sequenceDiagram-DBY2YBRQ-DJYOI_VF.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-YBXDWHbB.js → sizeCapture-X5ZJPWSS-CayWxIRi.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-CTCeQOUM.js → stateDiagram-2N3HPSRC-BktX6ilb.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DK505RoL.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-BjOcSMgp.js → swimlanes-5IMT3BWC-Duvbluwj.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CZTzB1q3.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-xMu52AIk.js → timeline-definition-FHXFAJF6-TdPH8RG8.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-CxJiaNeN.js → vennDiagram-L72KCM5P-MQSsBdGx.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-BDyiY3dI.js → wardleyDiagram-EHGQE667-BcsapLTw.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-CA_fqKyA.js → xychartDiagram-FW5EYKEG-BSUPwtFh.js} +1 -1
  59. package/dist/client/index.html +2 -2
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/cli.js +17 -2
  62. package/dist/server/routes/filesystem.js +239 -59
  63. package/dist/server/routes/terminal.js +47 -5
  64. package/dist/server/utils/fileWatchPolicy.js +136 -0
  65. package/dist/server/utils/settings.js +29 -0
  66. package/dist/server/utils/terminalColorEnvironment.js +46 -0
  67. package/package.json +1 -1
  68. package/runtime-manifest.json +2 -2
  69. package/dist/client/assets/channel-WCBYqzX4.js +0 -1
  70. package/dist/client/assets/classDiagram-OUVF2IWQ-NZpzVwgO.js +0 -1
  71. package/dist/client/assets/classDiagram-v2-EOCWNBFH-NZpzVwgO.js +0 -1
  72. package/dist/client/assets/index-CoDf9qRl.js +0 -457
  73. package/dist/client/assets/index-UhZwBu7l.css +0 -1
  74. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-B_dCwMIY.js +0 -1
  75. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DHr2qtGf.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,81 @@ 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
+ for (const event of events) {
118
+ const changedPath = path.resolve(event.path);
119
+ if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
120
+ continue;
121
+ if (entry.clients.size === 0) {
122
+ // The native subscription is kept briefly for cheap stream hand-off.
123
+ // Only request a rescan when something actually changed in that gap.
124
+ entry.needsRescanOnAttach = true;
125
+ return;
126
+ }
127
+ if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, event.type, WATCH_EVENT_STORM_LIMIT) === 'overflow') {
128
+ entry.pendingEvents.clear();
129
+ entry.eventGeneration += 1;
130
+ broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
131
+ return;
132
+ }
133
+ // The latest event for one path wins. This turns save bursts and atomic
134
+ // editor renames into one stat operation instead of one operation/event.
135
+ }
136
+ drainSharedWatchEvents(entry);
137
+ }
63
138
  function sharedWatchCallback(rootPath, entry) {
64
139
  return (error, events) => {
65
140
  if (entry.failure)
@@ -67,42 +142,35 @@ function sharedWatchCallback(rootPath, entry) {
67
142
  if (error) {
68
143
  entry.failure = error.message || 'watch-error';
69
144
  const reason = entry.failure;
70
- for (const client of entry.clients) {
145
+ entry.pendingEvents.clear();
146
+ entry.eventGeneration += 1;
147
+ const resourceExhausted = isWatchResourceExhaustion(error);
148
+ if (resourceExhausted)
149
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
150
+ console.warn('[file-watch] Native subscription callback failed; degrading to manual refresh', {
151
+ rootPath,
152
+ code: getWatchErrorCode(error),
153
+ activeSubscriptions: countActiveSharedWatches(),
154
+ registrySize: sharedWatchRegistry.size,
155
+ resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
156
+ error: reason,
157
+ });
158
+ const failedSubscription = entry.subscription;
159
+ entry.subscription = null;
160
+ void failedSubscription?.unsubscribe().catch(() => undefined);
161
+ if (resourceExhausted) {
162
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
163
+ if (diagnostics)
164
+ console.warn('[file-watch] Linux inotify usage after callback failure', diagnostics);
165
+ }).catch(() => undefined);
166
+ }
167
+ for (const client of [...entry.clients]) {
71
168
  if (!client.closed)
72
- client.enqueue({ type: 'rescan-required', path: rootPath, reason });
169
+ client.degrade(reason);
73
170
  }
74
171
  return;
75
172
  }
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
- }
173
+ enqueueSharedNativeEvents(rootPath, entry, events);
106
174
  };
107
175
  }
108
176
  async function acquireSharedWatch(rootPath, client) {
@@ -115,6 +183,10 @@ async function acquireSharedWatch(rootPath, client) {
115
183
  clients: new Set(),
116
184
  inFlight: null,
117
185
  evictTimer: null,
186
+ pendingEvents: new Map(),
187
+ statWorkers: 0,
188
+ eventGeneration: 0,
189
+ needsRescanOnAttach: false,
118
190
  };
119
191
  sharedWatchRegistry.set(rootPath, entry);
120
192
  }
@@ -124,11 +196,32 @@ async function acquireSharedWatch(rootPath, client) {
124
196
  }
125
197
  entry.refs += 1;
126
198
  entry.clients.add(client);
199
+ if (entry.needsRescanOnAttach) {
200
+ entry.needsRescanOnAttach = false;
201
+ client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
202
+ }
127
203
  if (entry.failure)
128
204
  return null;
129
205
  if (entry.subscription)
130
206
  return rootPath;
131
207
  if (!entry.inFlight) {
208
+ const now = Date.now();
209
+ const activeSubscriptions = countActiveSharedWatches();
210
+ if (now < watchAdmissionBlockedUntil) {
211
+ entry.failure = `native watcher admission paused for ${Math.ceil((watchAdmissionBlockedUntil - now) / 1000)}s after resource exhaustion`;
212
+ console.warn('[file-watch] Subscription skipped while resource backoff is active', {
213
+ rootPath,
214
+ activeSubscriptions,
215
+ registrySize: sharedWatchRegistry.size,
216
+ });
217
+ return null;
218
+ }
219
+ const startedAt = Date.now();
220
+ console.info('[file-watch] Starting native subscription', {
221
+ rootPath,
222
+ activeSubscriptions: activeSubscriptions + 1,
223
+ registrySize: sharedWatchRegistry.size,
224
+ });
132
225
  entry.inFlight = watcher.subscribe(rootPath, sharedWatchCallback(rootPath, entry), {
133
226
  ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
134
227
  })
@@ -141,12 +234,36 @@ async function acquireSharedWatch(rootPath, client) {
141
234
  return null;
142
235
  }
143
236
  current.subscription = subscription;
237
+ console.info('[file-watch] Native subscription ready', {
238
+ rootPath,
239
+ scanDurationMs: Date.now() - startedAt,
240
+ activeSubscriptions: countActiveSharedWatches(),
241
+ registrySize: sharedWatchRegistry.size,
242
+ });
144
243
  return subscription;
145
244
  })
146
245
  .catch((error) => {
147
246
  const current = sharedWatchRegistry.get(rootPath);
148
247
  if (current)
149
248
  current.failure = error instanceof Error ? error.message : String(error);
249
+ const resourceExhausted = isWatchResourceExhaustion(error);
250
+ if (resourceExhausted)
251
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
252
+ console.warn('[file-watch] Native subscription failed; degrading to manual refresh', {
253
+ rootPath,
254
+ code: getWatchErrorCode(error),
255
+ scanDurationMs: Date.now() - startedAt,
256
+ activeSubscriptions: countActiveSharedWatches(),
257
+ registrySize: sharedWatchRegistry.size,
258
+ resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
259
+ error: error instanceof Error ? error.message : String(error),
260
+ });
261
+ if (resourceExhausted) {
262
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
263
+ if (diagnostics)
264
+ console.warn('[file-watch] Linux inotify usage after resource exhaustion', diagnostics);
265
+ }).catch(() => undefined);
266
+ }
150
267
  return null;
151
268
  })
152
269
  .finally(() => {
@@ -168,6 +285,15 @@ function releaseSharedWatch(rootPath, client) {
168
285
  entry.refs -= 1;
169
286
  if (entry.refs > 0)
170
287
  return;
288
+ entry.needsRescanOnAttach = entry.needsRescanOnAttach
289
+ || entry.pendingEvents.size > 0
290
+ || entry.statWorkers > 0;
291
+ entry.pendingEvents.clear();
292
+ entry.eventGeneration += 1;
293
+ if (!entry.subscription && !entry.inFlight) {
294
+ sharedWatchRegistry.delete(rootPath);
295
+ return;
296
+ }
171
297
  if (entry.evictTimer)
172
298
  clearTimeout(entry.evictTimer);
173
299
  entry.evictTimer = setTimeout(() => {
@@ -414,9 +540,6 @@ function compareDirents(a, b) {
414
540
  return 1;
415
541
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
416
542
  }
417
- const WATCH_IGNORED_NAMES = new Set([
418
- '.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
419
- ]);
420
543
  const NESTED_GIT_DISCOVERY_IGNORED_NAMES = new Set([
421
544
  '.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
422
545
  '.cache', '.parcel-cache', '.yarn', '.pnpm-store', 'vendor',
@@ -1353,11 +1476,12 @@ function toFileEntry(entryPath, stat, isSymlink = false) {
1353
1476
  path: entryPath,
1354
1477
  type: stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file',
1355
1478
  isSymlink,
1479
+ modified: stat.mtime.toISOString(),
1356
1480
  };
1357
1481
  }
1358
- async function toDirectoryEntry(dir, dirent) {
1482
+ async function toDirectoryEntry(dir, dirent, includeModified = false) {
1359
1483
  const entryPath = path.join(dir, dirent.name);
1360
- if (!dirent.isSymbolicLink()) {
1484
+ if (!dirent.isSymbolicLink() && !includeModified) {
1361
1485
  return {
1362
1486
  name: dirent.name,
1363
1487
  path: entryPath,
@@ -1369,19 +1493,39 @@ async function toDirectoryEntry(dir, dirent) {
1369
1493
  return {
1370
1494
  name: dirent.name,
1371
1495
  path: entryPath,
1372
- type: stat.isDirectory() ? 'directory' : 'symlink',
1373
- isSymlink: true,
1496
+ type: stat.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
1497
+ isSymlink: dirent.isSymbolicLink() || undefined,
1498
+ modified: includeModified ? stat.mtime.toISOString() : undefined,
1374
1499
  };
1375
1500
  }
1376
1501
  catch {
1377
1502
  return {
1378
1503
  name: dirent.name,
1379
1504
  path: entryPath,
1380
- type: 'symlink',
1381
- isSymlink: true,
1505
+ type: dirent.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
1506
+ isSymlink: dirent.isSymbolicLink() || undefined,
1382
1507
  };
1383
1508
  }
1384
1509
  }
1510
+ async function loadDirectoryEntriesWithModified(dir, dirents, signal) {
1511
+ const entries = [];
1512
+ const batchSize = 64;
1513
+ for (let offset = 0; offset < dirents.length; offset += batchSize) {
1514
+ throwIfAborted(signal, 'fs.list');
1515
+ entries.push(...await Promise.all(dirents.slice(offset, offset + batchSize).map((dirent) => toDirectoryEntry(dir, dirent, true))));
1516
+ }
1517
+ return entries;
1518
+ }
1519
+ function compareDirectoryEntriesByModified(a, b) {
1520
+ if (a.type === 'directory' && b.type !== 'directory')
1521
+ return -1;
1522
+ if (a.type !== 'directory' && b.type === 'directory')
1523
+ return 1;
1524
+ const modifiedDelta = Date.parse(b.modified ?? '') - Date.parse(a.modified ?? '');
1525
+ if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0)
1526
+ return modifiedDelta;
1527
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1528
+ }
1385
1529
  function isIgnoredWatchPath(rootPath, candidatePath) {
1386
1530
  const relative = path.relative(rootPath, candidatePath);
1387
1531
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
@@ -2378,13 +2522,18 @@ router.get('/list', async (req, res) => {
2378
2522
  throw new Error('Path is not a directory');
2379
2523
  }
2380
2524
  const showHidden = req.query.showHidden === 'true';
2525
+ const sortMode = req.query.sort === 'modified' ? 'modified' : 'name';
2381
2526
  const allEntries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
2382
2527
  throwIfAborted(controller.signal, 'fs.list');
2383
2528
  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)));
2529
+ const entries = sortMode === 'modified'
2530
+ ? (await loadDirectoryEntriesWithModified(resolvedPath, visibleDirents, controller.signal))
2531
+ .sort(compareDirectoryEntriesByModified)
2532
+ .slice(0, MAX_DIRECTORY_ENTRIES)
2533
+ : await Promise.all(visibleDirents
2534
+ .sort(compareDirents)
2535
+ .slice(0, MAX_DIRECTORY_ENTRIES)
2536
+ .map((dirent) => toDirectoryEntry(resolvedPath, dirent)));
2388
2537
  throwIfAborted(controller.signal, 'fs.list');
2389
2538
  return { resolvedPath, entries, total: visibleDirents.length };
2390
2539
  })(), 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 +2559,21 @@ router.get('/watch', async (req, res) => {
2410
2559
  const rootsParam = req.query.roots;
2411
2560
  const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
2412
2561
  .filter((root) => typeof root === 'string');
2413
- const roots = [];
2562
+ const validatedRoots = [];
2414
2563
  for (const rawRoot of rawRoots) {
2415
2564
  if (!rawRoot)
2416
2565
  continue;
2417
2566
  try {
2418
2567
  const resolved = await pathValidator.validatePathAsync(rawRoot);
2419
2568
  const stat = await fs.promises.stat(resolved);
2420
- if (stat.isDirectory() && !roots.includes(resolved))
2421
- roots.push(resolved);
2569
+ if (stat.isDirectory() && !validatedRoots.includes(resolved))
2570
+ validatedRoots.push(resolved);
2422
2571
  }
2423
2572
  catch {
2424
2573
  // Ignore invalid watch roots; the visible tree will still work via manual list requests.
2425
2574
  }
2426
2575
  }
2576
+ const roots = minimizeRecursiveWatchRoots(validatedRoots);
2427
2577
  if (roots.length === 0) {
2428
2578
  res.status(400).json({ error: 'No valid roots to watch' });
2429
2579
  return;
@@ -2435,6 +2585,21 @@ router.get('/watch', async (req, res) => {
2435
2585
  let pending = [];
2436
2586
  let flushTimer = null;
2437
2587
  const attachedRoots = new Map();
2588
+ // Register cleanup before the first native subscription starts. A recursive
2589
+ // initial scan can take seconds on a large repository; the browser may abort
2590
+ // during that await when the user switches files or roots. Registering this
2591
+ // only after attachRoot() leaked the pending subscription and its reference.
2592
+ const closeWatchStream = () => {
2593
+ if (closed)
2594
+ return;
2595
+ closed = true;
2596
+ if (flushTimer)
2597
+ clearTimeout(flushTimer);
2598
+ for (const [rootPath, client] of attachedRoots) {
2599
+ releaseSharedWatch(rootPath, client);
2600
+ }
2601
+ };
2602
+ res.once('close', closeWatchStream);
2438
2603
  const writeEvent = (type, payload) => {
2439
2604
  if (closed || res.destroyed)
2440
2605
  return;
@@ -2463,28 +2628,43 @@ router.get('/watch', async (req, res) => {
2463
2628
  }
2464
2629
  };
2465
2630
  writeEvent('ready', { roots });
2631
+ console.info('[file-watch] Stream ready', {
2632
+ requestedRoots: rawRoots.length,
2633
+ validatedRoots: validatedRoots.length,
2634
+ subscribedRoots: roots.length,
2635
+ collapsedOverlappingRoots: validatedRoots.length - roots.length,
2636
+ activeSubscriptions: countActiveSharedWatches(),
2637
+ registrySize: sharedWatchRegistry.size,
2638
+ });
2466
2639
  const attachRoot = async (rootPath) => {
2467
- const client = { closed: false, enqueue };
2640
+ if (closed)
2641
+ return;
2642
+ const client = {
2643
+ closed: false,
2644
+ enqueue,
2645
+ degrade: (reason) => {
2646
+ if (closed)
2647
+ return;
2648
+ enqueue({ type: 'rescan-required', path: rootPath, reason });
2649
+ flush();
2650
+ if (!res.writableEnded)
2651
+ res.end();
2652
+ },
2653
+ };
2468
2654
  attachedRoots.set(rootPath, client);
2469
2655
  const ok = await acquireSharedWatch(rootPath, client);
2470
2656
  if (closed)
2471
2657
  return;
2472
2658
  if (!ok) {
2473
2659
  const reason = sharedWatchRegistry.get(rootPath)?.failure ?? 'watch-unavailable';
2474
- enqueue({ type: 'rescan-required', path: rootPath, reason });
2660
+ client.degrade(reason);
2475
2661
  }
2476
2662
  };
2477
2663
  for (const rootPath of roots) {
2664
+ if (closed)
2665
+ break;
2478
2666
  await attachRoot(rootPath);
2479
2667
  }
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
2668
  });
2489
2669
  router.get('/cancel-slot', (req, res) => {
2490
2670
  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';
@@ -31,7 +32,7 @@ import { SessionSearchStore } from '../agent/sessionSearchStore.js';
31
32
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
32
33
  import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
33
34
  import { TmuxLifecycleCoordinator } from '../utils/tmuxLifecycle.js';
34
- import { buildInteractiveColorEnvironment, buildTmuxColorEnvironmentCommands, } from '../utils/terminalColorEnvironment.js';
35
+ import { buildInteractiveColorEnvironment, buildTmuxColorEnvironmentCommands, ensureTmuxColorCapabilities, resolveTmuxInnerTerm, } from '../utils/terminalColorEnvironment.js';
35
36
  import { detectTmuxRecoveryIncident, normalizeTmuxRecoveryIncident, } from '../utils/tmuxRecovery.js';
36
37
  import { checkPluginPackageUpdate, commitPreparedPlugin, preparePluginPackage, } from '../agent/pluginPackages.js';
37
38
  import { clearPluginAgents, registerPluginAgents } 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() {
@@ -1780,6 +1790,10 @@ async function ensureTmuxColorEnvironment(sessionName) {
1780
1790
  await runTmux(args);
1781
1791
  }
1782
1792
  }
1793
+ async function ensureManagedTmuxColorCapabilities(sessionName) {
1794
+ const innerTerm = await resolveTmuxInnerTerm();
1795
+ await ensureTmuxColorCapabilities(runTmux, sessionName, innerTerm);
1796
+ }
1783
1797
  async function listLiveTmuxInventorySessions() {
1784
1798
  const format = [
1785
1799
  '#{session_name}',
@@ -3487,7 +3501,12 @@ async function ensureTmuxSessionExists(sessionName, cwd) {
3487
3501
  if (!serverWasMissing) {
3488
3502
  await ensureTmuxColorEnvironment();
3489
3503
  }
3490
- const args = ['new-session', '-d', '-s', sessionName, '-e', 'COLORTERM=truecolor'];
3504
+ const innerTerm = await resolveTmuxInnerTerm();
3505
+ const args = [
3506
+ 'new-session', '-d', '-s', sessionName,
3507
+ '-e', 'COLORTERM=truecolor',
3508
+ '-e', `TERM=${innerTerm}`,
3509
+ ];
3491
3510
  if (process.env.TERMDOCK_FORCE_COLOR === '1') {
3492
3511
  args.push('-e', 'FORCE_COLOR=1');
3493
3512
  }
@@ -3582,6 +3601,7 @@ async function enableTmuxExtendedKeys() {
3582
3601
  }
3583
3602
  async function ensureSharedTmuxServerReady() {
3584
3603
  await ensureTmuxColorEnvironment();
3604
+ await ensureManagedTmuxColorCapabilities();
3585
3605
  await applyTmuxScrollbackProfile();
3586
3606
  await disableTmuxFocusEventsForSafety();
3587
3607
  await enableTmuxExtendedKeys();
@@ -3606,6 +3626,12 @@ async function ensureManagedTmuxSessionReady(sessionName) {
3606
3626
  catch (error) {
3607
3627
  console.warn(`Failed to set tmux color environment for ${sessionName}: ${getErrorMessage(error)}`);
3608
3628
  }
3629
+ try {
3630
+ await ensureManagedTmuxColorCapabilities(sessionName);
3631
+ }
3632
+ catch (error) {
3633
+ console.warn(`Failed to set tmux color capabilities for ${sessionName}: ${getErrorMessage(error)}`);
3634
+ }
3609
3635
  try {
3610
3636
  await disableTmuxStatus(sessionName);
3611
3637
  }
@@ -5566,6 +5592,7 @@ async function getSettingsPayload() {
5566
5592
  autoRenamePromptPayloadChars: getAutoRenamePromptPayloadCharsSetting(),
5567
5593
  newSessionAgentSlug: getNewSessionAgentSlugSetting(),
5568
5594
  runningSessionButtonEnabled: getRunningSessionButtonEnabledSetting(),
5595
+ fileSortModes: getFileSortModesSetting(),
5569
5596
  localAccess: {
5570
5597
  ...localAccess,
5571
5598
  interfaces,
@@ -5677,6 +5704,21 @@ router.put('/settings', async (req, res) => {
5677
5704
  if (typeof body.runningSessionButtonEnabled === 'boolean') {
5678
5705
  setRunningSessionButtonEnabledSetting(body.runningSessionButtonEnabled);
5679
5706
  }
5707
+ if (body.fileSortModes && typeof body.fileSortModes === 'object' && !Array.isArray(body.fileSortModes)) {
5708
+ setFileSortModesSetting(body.fileSortModes);
5709
+ }
5710
+ if (body.fileSortMode && typeof body.fileSortMode === 'object') {
5711
+ const preference = body.fileSortMode;
5712
+ const validPath = typeof preference.path === 'string'
5713
+ && preference.path.length > 0
5714
+ && preference.path.length <= 4096
5715
+ && (preference.path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(preference.path));
5716
+ if (!validPath || (preference.mode !== 'name' && preference.mode !== 'modified')) {
5717
+ res.status(400).json({ error: 'Invalid file sort preference', code: 'FILE_SORT_MODE_INVALID' });
5718
+ return;
5719
+ }
5720
+ setFileSortModeSetting(preference.path, preference.mode);
5721
+ }
5680
5722
  if (body.localAccess && typeof body.localAccess === 'object') {
5681
5723
  const localAccessBody = body.localAccess;
5682
5724
  if (localAccessBody.reset === true) {