termdock 1.4.151 → 1.4.153

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 (70) hide show
  1. package/dist/client/assets/{ModelPreview-D-Wk-pU3.js → ModelPreview-B4Scg6qI.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-Mi1zHHiH.js → abnfDiagram-VRR7QNED-BKxYTem8.js} +1 -1
  3. package/dist/client/assets/{arc-EGqDv32w.js → arc-QNrFJtVM.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-Ct9QZZEH.js → architectureDiagram-ZJ3FMSHR-C49DATUA.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-Ca50Ru9w.js → blockDiagram-677ZJIJ3-KvCVYAmU.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-i5kHzR7D.js → c4Diagram-LMCZKHZV-DMHaRUAq.js} +1 -1
  7. package/dist/client/assets/channel-IfUCcv80.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-CIM1aS9l.js → chunk-2Q5K7J3B-NP5gy8Wm.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-D_WyFD7b.js → chunk-32BRIVSS-BLTpTLmX.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-D8A1uIh4.js → chunk-5VM5RSS4-BzDfiUPg.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-BY-EsHV1.js → chunk-EX3LRPZG-CSmyg32F.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-BllvB6Kn.js → chunk-JWPE2WC7-DQmy3r-2.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-7UGr_Qhj.js → chunk-MOJQB5TN-BiT0ZT3U.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-Bm4GI-Aw.js → chunk-RYQCIY6F--Eap7GWU.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-DXv6flhu.js → chunk-V7JOEXUC-DawXSnfn.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-Bhx5tYD7.js → chunk-VR4S4FIN-Yy0GSXVy.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-D3tf4DOw.js → chunk-XXDRQBXY-CiUcnZJ9.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-CTnNs1HI.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CTnNs1HI.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-lI9GQDjH.js → cose-bilkent-JH36ORCC-BhXShZCd.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-Cm_6Lfp5.js → cynefin-VYW2F7L2-7JnEXayE.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BW7CGtNJ.js → cynefinDiagram-TSTJHNR4-y_rQrpK6.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-DE4gaR5X.js → dagre-VKFMJZFB-Cza74CPv.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-g1Pvx7c1.js → diagram-FQU43EPY-DCy_e4Kv.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-CK0bbu8M.js → diagram-G47NLZAW-B8GTUDfW.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-DMyfIADQ.js → diagram-NH7WQ7WH-B9ohNkBT.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-Dq7e34Im.js → diagram-OA4YK3LP-BQm0kwc8.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-C1s-Xu2N.js → diagram-WEI45ONY-hs43w1WR.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-D2tNgNck.js → ebnfDiagram-CCIWWBDH-C3oeagge.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-D6atD1NP.js → erDiagram-Q63AITRT-xs6jNwv6.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-DCBHg7Ti.js → flowDiagram-23GEKE2U-j8gMnGkL.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DXQuYslQ.js → ganttDiagram-NO4QXBWP-rhUjTc6Z.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Dwd_0ioG.js → gitGraphDiagram-IHSO6WYX-EAEhIsm0.js} +1 -1
  34. package/dist/client/assets/{index-CS2iJEaF.js → index-BiZD1WJ9.js} +100 -100
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-NiN9DrMH.js → infoDiagram-FWYZ7A6U-C9i2U3Oz.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BMJhICr3.js → ishikawaDiagram-FXEZZL3T-CE0ijWcu.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-CLdmCiM-.js → journeyDiagram-5HDEW3XC-BtScT2V2.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-2IXGOXW3.js → kanban-definition-HUTT4EX6-6cpblqEp.js} +1 -1
  39. package/dist/client/assets/{linear-D_3w4iPe.js → linear-DieXAo56.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-ChWJBBCP.js → mermaid.core-DRr9veKI.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CWECsNJN.js → mindmap-definition-LN4V7U3C-Dngg9Tgg.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-C9cSRovE.js → pegDiagram-2B236MQR-C4sXNX41.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-C-zXtuDA.js → pieDiagram-ENE6RG2P-uJsTiMwN.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CQ7KPXB8.js → quadrantDiagram-ABIIQ3AL-cNsZG5Hs.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHWRdUKV.js → railroadDiagram-RFXS5EU6-D_gQTLhO.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-B6VYiC2A.js → requirementDiagram-TGXJPOKE-CDokTVFx.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-3faNrLBq.js → sankeyDiagram-HTMAVEWB-Ch8GkIcG.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-CQH7a3Ge.js → sequenceDiagram-DBY2YBRQ-DveOot48.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-Dz_XYFOF.js → sizeCapture-X5ZJPWSS-bZQXs6R5.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-DxOPhaqg.js → stateDiagram-2N3HPSRC-DwDglXSo.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-lkFzzYeA.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-DP3zwOsK.js → swimlanes-5IMT3BWC-CLANw92C.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CzAWXOcq.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-CouI6wN1.js → timeline-definition-FHXFAJF6-CdIW-Srj.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-CqylKRdX.js → vennDiagram-L72KCM5P-CkH9lpKn.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-ByhFP--3.js → wardleyDiagram-EHGQE667-CJqtOyWX.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BZs9a8GL.js → xychartDiagram-FW5EYKEG-DwJ1QEPf.js} +1 -1
  58. package/dist/client/index.html +1 -1
  59. package/dist/client/sw.js +1 -1
  60. package/dist/server/routes/filesystem.js +215 -144
  61. package/dist/server/routes/terminal.js +5 -7
  62. package/dist/server/utils/fileWatchPolicy.js +22 -15
  63. package/dist/server/utils/httpResponse.js +14 -0
  64. package/package.json +1 -2
  65. package/runtime-manifest.json +3 -3
  66. package/dist/client/assets/channel-C_ptNcbw.js +0 -1
  67. package/dist/client/assets/classDiagram-OUVF2IWQ-Dn0QXhmh.js +0 -1
  68. package/dist/client/assets/classDiagram-v2-EOCWNBFH-Dn0QXhmh.js +0 -1
  69. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Bu9Vt-0n.js +0 -1
  70. package/dist/client/assets/swimlanesDiagram-G3AALYLV-ClbSzPVW.js +0 -8
@@ -4,7 +4,6 @@ import fs from 'fs';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import { execFile, spawn } from 'child_process';
7
- import watcher from '@parcel/watcher';
8
7
  import busboy from 'busboy';
9
8
  import { pathValidator } from '../utils/pathValidator.js';
10
9
  import { isAuthEnabled, isRequestAuthenticated } from '../utils/authProtection.js';
@@ -19,7 +18,8 @@ import { inspectKicadBoardPoint } from '../utils/kicadBoardInspection.js';
19
18
  import { appendRipgrepExcludeArgs, createExcludeMatcher, normalizeExcludePatterns } from '../utils/fileSearchOptions.js';
20
19
  import { EdaPreviewCache, requestAcceptsEtag } from '../utils/edaPreviewCache.js';
21
20
  import { convertHeicPreview, HEIC_PREVIEW_MIME_TYPES, isHeicPreviewPath } from '../utils/heicPreview.js';
22
- import { getWatchErrorCode, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion, minimizeRecursiveWatchRoots, WATCH_IGNORED_NAMES, WATCH_RESOURCE_BACKOFF_MS, } from '../utils/fileWatchPolicy.js';
21
+ import { getWatchErrorCode, diffWatchSnapshots, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion, normalizeDirectoryWatchRoots, resolveDirectWatchEventPath, WATCH_IGNORED_NAMES, WATCH_RESOURCE_BACKOFF_MS, } from '../utils/fileWatchPolicy.js';
22
+ import { isResponseWritable, writeResponseChunk } from '../utils/httpResponse.js';
23
23
  const router = Router();
24
24
  const MAX_FILE_SIZE = 1024 * 1024; // 1MB
25
25
  const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
@@ -61,13 +61,15 @@ const FS_IO_LOG_NAME = 'fs-io.log';
61
61
  const activeDiffSlots = new Map();
62
62
  const activeIoSlots = new Map();
63
63
  const sharedWatchRegistry = new Map();
64
- const SHARED_WATCH_EVICT_DELAY_MS = 5_000;
64
+ const SHARED_WATCH_EVICT_DELAY_MS = 60_000;
65
65
  const WATCH_STAT_CONCURRENCY = 8;
66
+ const WATCH_POLL_INTERVAL_MS = 4_000;
67
+ const MAX_NATIVE_DIRECTORY_WATCHES = 256;
66
68
  let watchAdmissionBlockedUntil = 0;
67
69
  function countActiveSharedWatches() {
68
70
  let count = 0;
69
71
  for (const entry of sharedWatchRegistry.values()) {
70
- if (entry.subscription || entry.inFlight)
72
+ if (entry.nativeWatcher)
71
73
  count += 1;
72
74
  }
73
75
  return count;
@@ -114,76 +116,123 @@ function drainSharedWatchEvents(entry) {
114
116
  });
115
117
  }
116
118
  }
117
- function enqueueSharedNativeEvents(rootPath, entry, events) {
118
- for (const event of events) {
119
- const changedPath = path.resolve(event.path);
120
- if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
121
- continue;
122
- if (entry.clients.size === 0) {
123
- // The native subscription is kept briefly for cheap stream hand-off.
124
- // Only request a rescan when something actually changed in that gap.
125
- entry.needsRescanOnAttach = true;
126
- return;
127
- }
128
- if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, event.type, WATCH_EVENT_STORM_LIMIT) === 'overflow') {
129
- entry.pendingEvents.clear();
130
- entry.eventGeneration += 1;
131
- broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
132
- return;
133
- }
134
- // The latest event for one path wins. This turns save bursts and atomic
135
- // editor renames into one stat operation instead of one operation/event.
119
+ function enqueueSharedNativeEvent(rootPath, entry, filename) {
120
+ if (!filename) {
121
+ entry.pendingEvents.clear();
122
+ entry.eventGeneration += 1;
123
+ broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'directory-rescan' });
124
+ return;
125
+ }
126
+ const changedPath = resolveDirectWatchEventPath(rootPath, filename);
127
+ if (!changedPath || isIgnoredWatchPath(rootPath, changedPath))
128
+ return;
129
+ if (entry.clients.size === 0) {
130
+ entry.needsRescanOnAttach = true;
131
+ return;
132
+ }
133
+ if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, 'update', WATCH_EVENT_STORM_LIMIT) === 'overflow') {
134
+ entry.pendingEvents.clear();
135
+ entry.eventGeneration += 1;
136
+ broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
137
+ return;
136
138
  }
137
139
  drainSharedWatchEvents(entry);
138
140
  }
139
- function sharedWatchCallback(rootPath, entry) {
140
- return (error, events) => {
141
- if (entry.failure)
142
- return;
143
- if (error) {
144
- entry.failure = error.message || 'watch-error';
145
- const reason = entry.failure;
146
- entry.pendingEvents.clear();
147
- entry.eventGeneration += 1;
148
- const resourceExhausted = isWatchResourceExhaustion(error);
149
- if (resourceExhausted)
150
- watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
151
- console.warn('[file-watch] Native subscription callback failed; degrading to manual refresh', {
152
- rootPath,
153
- code: getWatchErrorCode(error),
154
- activeSubscriptions: countActiveSharedWatches(),
155
- registrySize: sharedWatchRegistry.size,
156
- resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
157
- error: reason,
158
- });
159
- const failedSubscription = entry.subscription;
160
- entry.subscription = null;
161
- void failedSubscription?.unsubscribe().catch(() => undefined);
162
- if (resourceExhausted) {
163
- void inspectLinuxInotifyUsage().then((diagnostics) => {
164
- if (diagnostics)
165
- console.warn('[file-watch] Linux inotify usage after callback failure', diagnostics);
166
- }).catch(() => undefined);
141
+ async function readDirectorySnapshot(rootPath) {
142
+ const dirents = await fs.promises.readdir(rootPath, { withFileTypes: true });
143
+ const visible = dirents.filter((dirent) => !WATCH_IGNORED_NAMES.has(dirent.name));
144
+ const snapshot = new Map();
145
+ let cursor = 0;
146
+ const workers = Array.from({ length: Math.min(WATCH_STAT_CONCURRENCY, visible.length) }, async () => {
147
+ while (cursor < visible.length) {
148
+ const dirent = visible[cursor++];
149
+ const entryPath = path.join(rootPath, dirent.name);
150
+ try {
151
+ const stat = await fs.promises.lstat(entryPath);
152
+ snapshot.set(entryPath, {
153
+ signature: `${stat.mode}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`,
154
+ entry: toFileEntry(entryPath, stat, dirent.isSymbolicLink()),
155
+ });
167
156
  }
168
- for (const client of [...entry.clients]) {
169
- if (!client.closed)
170
- client.degrade(reason);
157
+ catch (error) {
158
+ if (getWatchErrorCode(error) !== 'ENOENT')
159
+ throw error;
171
160
  }
161
+ }
162
+ });
163
+ await Promise.all(workers);
164
+ return snapshot;
165
+ }
166
+ function scheduleSharedWatchPoll(rootPath, entry, delay = WATCH_POLL_INTERVAL_MS) {
167
+ if (entry.pollTimer || entry.mode !== 'polling' || entry.refs === 0)
168
+ return;
169
+ entry.pollTimer = setTimeout(() => {
170
+ entry.pollTimer = null;
171
+ void pollSharedWatch(rootPath, entry);
172
+ }, delay);
173
+ entry.pollTimer.unref?.();
174
+ }
175
+ async function pollSharedWatch(rootPath, entry) {
176
+ if (entry.mode !== 'polling' || entry.refs === 0)
177
+ return;
178
+ try {
179
+ const next = await readDirectorySnapshot(rootPath);
180
+ const previous = entry.pollSnapshot;
181
+ entry.pollSnapshot = next;
182
+ entry.failure = null;
183
+ if (previous) {
184
+ for (const change of diffWatchSnapshots(previous, next)) {
185
+ broadcastSharedWatchEvent(entry, {
186
+ type: change.type === 'delete' ? 'deleted' : change.type === 'create' ? 'created' : 'updated',
187
+ path: change.path,
188
+ entry: change.value?.entry,
189
+ });
190
+ }
191
+ }
192
+ }
193
+ catch (error) {
194
+ entry.failure = error instanceof Error ? error.message : String(error);
195
+ if (getWatchErrorCode(error) === 'ENOENT') {
196
+ for (const client of [...entry.clients])
197
+ if (!client.closed)
198
+ client.degrade(entry.failure);
172
199
  return;
173
200
  }
174
- enqueueSharedNativeEvents(rootPath, entry, events);
175
- };
201
+ console.warn('[file-watch] Polling refresh failed; will retry', { rootPath, error: entry.failure });
202
+ }
203
+ scheduleSharedWatchPoll(rootPath, entry);
204
+ }
205
+ async function startPollingWatch(rootPath, entry, reason) {
206
+ entry.nativeWatcher?.close();
207
+ entry.nativeWatcher = null;
208
+ entry.mode = 'polling';
209
+ entry.pendingEvents.clear();
210
+ entry.eventGeneration += 1;
211
+ try {
212
+ entry.pollSnapshot = await readDirectorySnapshot(rootPath);
213
+ entry.failure = null;
214
+ }
215
+ catch (error) {
216
+ entry.failure = error instanceof Error ? error.message : String(error);
217
+ return false;
218
+ }
219
+ scheduleSharedWatchPoll(rootPath, entry);
220
+ console.warn('[file-watch] Using polling fallback', { rootPath, intervalMs: WATCH_POLL_INTERVAL_MS, reason });
221
+ return true;
176
222
  }
177
223
  async function acquireSharedWatch(rootPath, client) {
178
224
  let entry = sharedWatchRegistry.get(rootPath);
179
225
  if (!entry) {
180
226
  entry = {
181
227
  refs: 0,
182
- subscription: null,
228
+ nativeWatcher: null,
229
+ mode: 'starting',
183
230
  failure: null,
184
231
  clients: new Set(),
185
232
  inFlight: null,
186
233
  evictTimer: null,
234
+ pollTimer: null,
235
+ pollSnapshot: null,
187
236
  pendingEvents: new Map(),
188
237
  statWorkers: 0,
189
238
  eventGeneration: 0,
@@ -201,72 +250,66 @@ async function acquireSharedWatch(rootPath, client) {
201
250
  entry.needsRescanOnAttach = false;
202
251
  client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
203
252
  }
204
- if (entry.failure)
205
- return null;
206
- if (entry.subscription)
253
+ if (entry.mode === 'polling' && entry.pollSnapshot) {
254
+ scheduleSharedWatchPoll(rootPath, entry, 0);
255
+ return rootPath;
256
+ }
257
+ if (entry.nativeWatcher)
207
258
  return rootPath;
208
259
  if (!entry.inFlight) {
209
260
  const now = Date.now();
210
261
  const activeSubscriptions = countActiveSharedWatches();
211
- if (now < watchAdmissionBlockedUntil) {
212
- entry.failure = `native watcher admission paused for ${Math.ceil((watchAdmissionBlockedUntil - now) / 1000)}s after resource exhaustion`;
213
- console.warn('[file-watch] Subscription skipped while resource backoff is active', {
214
- rootPath,
215
- activeSubscriptions,
216
- registrySize: sharedWatchRegistry.size,
217
- });
218
- return null;
219
- }
220
- const startedAt = Date.now();
221
- console.info('[file-watch] Starting native subscription', {
222
- rootPath,
223
- activeSubscriptions: activeSubscriptions + 1,
224
- registrySize: sharedWatchRegistry.size,
225
- });
226
- entry.inFlight = watcher.subscribe(rootPath, sharedWatchCallback(rootPath, entry), {
227
- ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
228
- })
229
- .then((subscription) => {
230
- const current = sharedWatchRegistry.get(rootPath);
231
- if (!current || current.refs === 0) {
232
- // Nobody is watching anymore (client disconnected mid-scan): free
233
- // the freshly built subscription instead of leaking its DirTree.
234
- void subscription.unsubscribe().catch(() => undefined);
235
- return null;
262
+ entry.inFlight = (async () => {
263
+ if (now < watchAdmissionBlockedUntil || activeSubscriptions >= MAX_NATIVE_DIRECTORY_WATCHES) {
264
+ const reason = now < watchAdmissionBlockedUntil ? 'native watcher resource backoff' : 'native watcher process budget reached';
265
+ return startPollingWatch(rootPath, entry, reason);
236
266
  }
237
- current.subscription = subscription;
238
- console.info('[file-watch] Native subscription ready', {
239
- rootPath,
240
- scanDurationMs: Date.now() - startedAt,
241
- activeSubscriptions: countActiveSharedWatches(),
242
- registrySize: sharedWatchRegistry.size,
243
- });
244
- return subscription;
245
- })
246
- .catch((error) => {
247
- const current = sharedWatchRegistry.get(rootPath);
248
- if (current)
249
- current.failure = error instanceof Error ? error.message : String(error);
250
- const resourceExhausted = isWatchResourceExhaustion(error);
251
- if (resourceExhausted)
252
- watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
253
- console.warn('[file-watch] Native subscription failed; degrading to manual refresh', {
254
- rootPath,
255
- code: getWatchErrorCode(error),
256
- scanDurationMs: Date.now() - startedAt,
257
- activeSubscriptions: countActiveSharedWatches(),
258
- registrySize: sharedWatchRegistry.size,
259
- resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
260
- error: error instanceof Error ? error.message : String(error),
261
- });
262
- if (resourceExhausted) {
263
- void inspectLinuxInotifyUsage().then((diagnostics) => {
264
- if (diagnostics)
265
- console.warn('[file-watch] Linux inotify usage after resource exhaustion', diagnostics);
266
- }).catch(() => undefined);
267
+ try {
268
+ const nativeWatcher = fs.watch(rootPath, { persistent: false }, (_eventType, filename) => {
269
+ enqueueSharedNativeEvent(rootPath, entry, filename);
270
+ });
271
+ entry.nativeWatcher = nativeWatcher;
272
+ entry.mode = 'native';
273
+ entry.failure = null;
274
+ nativeWatcher.on('error', (error) => {
275
+ if (entry.mode !== 'native')
276
+ return;
277
+ const resourceExhausted = isWatchResourceExhaustion(error);
278
+ if (resourceExhausted)
279
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
280
+ if (resourceExhausted)
281
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
282
+ if (diagnostics)
283
+ console.warn('[file-watch] Linux inotify usage after watcher failure', diagnostics);
284
+ }).catch(() => undefined);
285
+ void startPollingWatch(rootPath, entry, error.message || 'native watcher error').then((started) => {
286
+ if (!started) {
287
+ const reason = entry.failure ?? 'watch-unavailable';
288
+ for (const client of [...entry.clients])
289
+ if (!client.closed)
290
+ client.degrade(reason);
291
+ }
292
+ });
293
+ });
294
+ console.info('[file-watch] Native directory watch ready', {
295
+ rootPath,
296
+ activeSubscriptions: activeSubscriptions + 1,
297
+ registrySize: sharedWatchRegistry.size,
298
+ });
299
+ return true;
267
300
  }
268
- return null;
269
- })
301
+ catch (error) {
302
+ const resourceExhausted = isWatchResourceExhaustion(error);
303
+ if (resourceExhausted)
304
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
305
+ if (resourceExhausted)
306
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
307
+ if (diagnostics)
308
+ console.warn('[file-watch] Linux inotify usage after watcher admission failure', diagnostics);
309
+ }).catch(() => undefined);
310
+ return startPollingWatch(rootPath, entry, error instanceof Error ? error.message : String(error));
311
+ }
312
+ })()
270
313
  .finally(() => {
271
314
  const current = sharedWatchRegistry.get(rootPath);
272
315
  if (current)
@@ -291,7 +334,11 @@ function releaseSharedWatch(rootPath, client) {
291
334
  || entry.statWorkers > 0;
292
335
  entry.pendingEvents.clear();
293
336
  entry.eventGeneration += 1;
294
- if (!entry.subscription && !entry.inFlight) {
337
+ if (entry.pollTimer) {
338
+ clearTimeout(entry.pollTimer);
339
+ entry.pollTimer = null;
340
+ }
341
+ if (!entry.nativeWatcher && !entry.inFlight && entry.mode !== 'polling') {
295
342
  sharedWatchRegistry.delete(rootPath);
296
343
  return;
297
344
  }
@@ -302,8 +349,10 @@ function releaseSharedWatch(rootPath, client) {
302
349
  if (!current || current.refs > 0)
303
350
  return;
304
351
  sharedWatchRegistry.delete(rootPath);
305
- void current.subscription?.unsubscribe().catch(() => undefined);
306
- current.subscription = null;
352
+ if (current.pollTimer)
353
+ clearTimeout(current.pollTimer);
354
+ current.nativeWatcher?.close();
355
+ current.nativeWatcher = null;
307
356
  }, SHARED_WATCH_EVICT_DELAY_MS);
308
357
  entry.evictTimer.unref?.();
309
358
  }
@@ -1663,13 +1712,17 @@ async function searchWithFallback(rootPath, queryLower, showHidden, excludePatte
1663
1712
  };
1664
1713
  }
1665
1714
  function writeSearchEvent(res, type, payload) {
1666
- res.write(`${JSON.stringify({ type, ...payload })}\n`);
1715
+ writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
1667
1716
  }
1668
1717
  function createSearchBatchEmitter(res) {
1669
1718
  let batch = [];
1670
1719
  const flush = () => {
1671
- if (batch.length === 0 || res.destroyed)
1720
+ if (batch.length === 0)
1672
1721
  return;
1722
+ if (!isResponseWritable(res)) {
1723
+ batch = [];
1724
+ return;
1725
+ }
1673
1726
  writeSearchEvent(res, 'batch', { entries: batch });
1674
1727
  batch = [];
1675
1728
  };
@@ -1807,8 +1860,12 @@ async function streamSearchWithFallback(rootPath, queryLower, showHidden, exclud
1807
1860
  function createContentBatchEmitter(res) {
1808
1861
  let batch = [];
1809
1862
  const flush = () => {
1810
- if (batch.length === 0 || res.destroyed)
1863
+ if (batch.length === 0)
1811
1864
  return;
1865
+ if (!isResponseWritable(res)) {
1866
+ batch = [];
1867
+ return;
1868
+ }
1812
1869
  writeSearchEvent(res, 'content-batch', { contentEntries: batch });
1813
1870
  batch = [];
1814
1871
  };
@@ -2569,8 +2626,17 @@ router.get('/list', async (req, res) => {
2569
2626
  // deep directory listing; it uses the OS watcher and sends small batched events
2570
2627
  // so the client can patch only directories it has already loaded.
2571
2628
  router.get('/watch', async (req, res) => {
2629
+ const repeatedRootsParam = req.query.root;
2572
2630
  const rootsParam = req.query.roots;
2573
- const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
2631
+ const rawRoots = (Array.isArray(repeatedRootsParam)
2632
+ ? repeatedRootsParam
2633
+ : typeof repeatedRootsParam === 'string'
2634
+ ? [repeatedRootsParam]
2635
+ : Array.isArray(rootsParam)
2636
+ ? rootsParam
2637
+ : typeof rootsParam === 'string'
2638
+ ? rootsParam.split('|')
2639
+ : [])
2574
2640
  .filter((root) => typeof root === 'string');
2575
2641
  const validatedRoots = [];
2576
2642
  for (const rawRoot of rawRoots) {
@@ -2586,7 +2652,7 @@ router.get('/watch', async (req, res) => {
2586
2652
  // Ignore invalid watch roots; the visible tree will still work via manual list requests.
2587
2653
  }
2588
2654
  }
2589
- const roots = minimizeRecursiveWatchRoots(validatedRoots);
2655
+ const roots = normalizeDirectoryWatchRoots(validatedRoots);
2590
2656
  if (roots.length === 0) {
2591
2657
  res.status(400).json({ error: 'No valid roots to watch' });
2592
2658
  return;
@@ -2598,10 +2664,8 @@ router.get('/watch', async (req, res) => {
2598
2664
  let pending = [];
2599
2665
  let flushTimer = null;
2600
2666
  const attachedRoots = new Map();
2601
- // Register cleanup before the first native subscription starts. A recursive
2602
- // initial scan can take seconds on a large repository; the browser may abort
2603
- // during that await when the user switches files or roots. Registering this
2604
- // only after attachRoot() leaked the pending subscription and its reference.
2667
+ // Register cleanup before the first native watch or fallback snapshot starts;
2668
+ // the browser may abort while roots are being validated or attached.
2605
2669
  const closeWatchStream = () => {
2606
2670
  if (closed)
2607
2671
  return;
@@ -2614,9 +2678,9 @@ router.get('/watch', async (req, res) => {
2614
2678
  };
2615
2679
  res.once('close', closeWatchStream);
2616
2680
  const writeEvent = (type, payload) => {
2617
- if (closed || res.destroyed)
2618
- return;
2619
- res.write(`${JSON.stringify({ type, ...payload })}\n`);
2681
+ if (closed)
2682
+ return false;
2683
+ return writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
2620
2684
  };
2621
2685
  const flush = () => {
2622
2686
  flushTimer = null;
@@ -2645,7 +2709,7 @@ router.get('/watch', async (req, res) => {
2645
2709
  requestedRoots: rawRoots.length,
2646
2710
  validatedRoots: validatedRoots.length,
2647
2711
  subscribedRoots: roots.length,
2648
- collapsedOverlappingRoots: validatedRoots.length - roots.length,
2712
+ deduplicatedRoots: validatedRoots.length - roots.length,
2649
2713
  activeSubscriptions: countActiveSharedWatches(),
2650
2714
  registrySize: sharedWatchRegistry.size,
2651
2715
  });
@@ -2656,11 +2720,14 @@ router.get('/watch', async (req, res) => {
2656
2720
  closed: false,
2657
2721
  enqueue,
2658
2722
  degrade: (reason) => {
2659
- if (closed)
2723
+ if (closed || !isResponseWritable(res)) {
2724
+ closeWatchStream();
2660
2725
  return;
2726
+ }
2661
2727
  enqueue({ type: 'rescan-required', path: rootPath, reason });
2662
2728
  flush();
2663
- if (!res.writableEnded)
2729
+ closeWatchStream();
2730
+ if (isResponseWritable(res))
2664
2731
  res.end();
2665
2732
  },
2666
2733
  };
@@ -2776,14 +2843,15 @@ router.get('/search', async (req, res) => {
2776
2843
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', mode: 'content', limited: false });
2777
2844
  try {
2778
2845
  const result = await streamContentSearchWithRipgrep(resolvedPath, query, showHidden, excludePatterns, contentOptions, controller.signal, res);
2779
- if (!controller.signal.aborted && !res.destroyed) {
2846
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2780
2847
  writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'rg', mode: 'content' });
2781
2848
  logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'content', engine: 'rg' } });
2782
- res.end();
2849
+ if (isResponseWritable(res))
2850
+ res.end();
2783
2851
  }
2784
2852
  }
2785
2853
  catch (error) {
2786
- if (controller.signal.aborted || res.destroyed) {
2854
+ if (controller.signal.aborted || !isResponseWritable(res)) {
2787
2855
  const payload = getErrorPayload(controller.signal.reason ?? error);
2788
2856
  logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'content', engine: 'rg' } });
2789
2857
  return;
@@ -2791,7 +2859,8 @@ router.get('/search', async (req, res) => {
2791
2859
  const message = error instanceof Error ? error.message : 'Content search failed';
2792
2860
  logSearch('error', { code: 'CONTENT_SEARCH_UNAVAILABLE', error: message, extra: { mode: 'content', engine: 'rg' } });
2793
2861
  writeSearchEvent(res, 'error', { message, code: 'CONTENT_SEARCH_UNAVAILABLE' });
2794
- res.end();
2862
+ if (isResponseWritable(res))
2863
+ res.end();
2795
2864
  }
2796
2865
  return;
2797
2866
  }
@@ -2802,24 +2871,26 @@ router.get('/search', async (req, res) => {
2802
2871
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', limited: false });
2803
2872
  try {
2804
2873
  const total = await streamSearchWithRipgrep(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
2805
- if (!controller.signal.aborted && !res.destroyed) {
2874
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2806
2875
  writeSearchEvent(res, 'done', { total, truncated: false, limited: false, engine: 'rg' });
2807
2876
  logSearch('ok', { count: total, truncated: false, extra: { mode: 'name', engine: 'rg' } });
2808
- res.end();
2877
+ if (isResponseWritable(res))
2878
+ res.end();
2809
2879
  }
2810
2880
  }
2811
2881
  catch (error) {
2812
- if (controller.signal.aborted || res.destroyed) {
2882
+ if (controller.signal.aborted || !isResponseWritable(res)) {
2813
2883
  const payload = getErrorPayload(controller.signal.reason ?? error);
2814
2884
  logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
2815
2885
  return;
2816
2886
  }
2817
2887
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'fallback', limited: false });
2818
2888
  const result = await streamSearchWithFallback(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
2819
- if (!controller.signal.aborted && !res.destroyed) {
2889
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2820
2890
  writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'fallback' });
2821
2891
  logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'name', engine: 'fallback' } });
2822
- res.end();
2892
+ if (isResponseWritable(res))
2893
+ res.end();
2823
2894
  }
2824
2895
  }
2825
2896
  return;
@@ -19,6 +19,7 @@ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContex
19
19
  import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
20
20
  import { getOnboardingServerUrl } from '../onboardingServer.js';
21
21
  import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fileWatchPolicy.js';
22
+ import { writeResponseChunk } from '../utils/httpResponse.js';
22
23
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
23
24
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
24
25
  import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
@@ -3965,7 +3966,7 @@ function shouldRetryShellSpawn(error) {
3965
3966
  return /posix_spawnp failed|ENOENT|EACCES/i.test(errorMessage);
3966
3967
  }
3967
3968
  function writeSse(res, payload) {
3968
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
3969
+ return writeResponseChunk(res, `data: ${JSON.stringify(payload)}\n\n`);
3969
3970
  }
3970
3971
  function getTotalClients(sessionId) {
3971
3972
  let count = 0;
@@ -4006,7 +4007,8 @@ function broadcastEvent(sessionId, payload) {
4006
4007
  if (session) {
4007
4008
  for (const [clientId, client] of session.clients.entries()) {
4008
4009
  try {
4009
- writeSse(client, payload);
4010
+ if (!writeSse(client, payload))
4011
+ closeClient(session, sessionId, clientId);
4010
4012
  }
4011
4013
  catch {
4012
4014
  closeClient(session, sessionId, clientId);
@@ -6514,11 +6516,7 @@ router.get('/:sessionId/stream', async (req, res) => {
6514
6516
  }, ACTIVE_PROGRAM_POLL_INTERVAL);
6515
6517
  }
6516
6518
  const heartbeatInterval = setInterval(() => {
6517
- try {
6518
- res.write(': heartbeat\n\n');
6519
- }
6520
- catch (error) {
6521
- console.error(`Heartbeat failed for client ${clientId}:`, error);
6519
+ if (!writeResponseChunk(res, ': heartbeat\n\n')) {
6522
6520
  clearInterval(heartbeatInterval);
6523
6521
  }
6524
6522
  }, 15000);
@@ -10,24 +10,31 @@ export function enqueueLatestWatchEvent(pending, eventPath, eventType, maxPendin
10
10
  pending.set(eventPath, eventType);
11
11
  return 'queued';
12
12
  }
13
- function isSameOrDescendant(parentPath, candidatePath) {
14
- const relative = path.relative(parentPath, candidatePath);
15
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
13
+ export function resolveDirectWatchEventPath(rootPath, filename) {
14
+ const resolvedRoot = path.resolve(rootPath);
15
+ const candidate = path.resolve(resolvedRoot, filename.toString());
16
+ return path.dirname(candidate) === resolvedRoot ? candidate : null;
17
+ }
18
+ export function diffWatchSnapshots(previous, next) {
19
+ const changes = [];
20
+ for (const entryPath of previous.keys()) {
21
+ if (!next.has(entryPath))
22
+ changes.push({ type: 'delete', path: entryPath });
23
+ }
24
+ for (const [entryPath, value] of next) {
25
+ const old = previous.get(entryPath);
26
+ if (!old || old.signature !== value.signature) {
27
+ changes.push({ type: old ? 'update' : 'create', path: entryPath, value });
28
+ }
29
+ }
30
+ return changes;
16
31
  }
17
32
  /**
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.
33
+ * Non-recursive directory watchers need every visible directory, including
34
+ * nested ones. Normalize and deduplicate without collapsing descendants.
21
35
  */
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;
36
+ export function normalizeDirectoryWatchRoots(roots) {
37
+ return [...new Set(roots.map((root) => path.resolve(root)))].sort((a, b) => a.localeCompare(b));
31
38
  }
32
39
  export function getWatchErrorCode(error) {
33
40
  if (!error || typeof error !== 'object')
@@ -0,0 +1,14 @@
1
+ export function isResponseWritable(response) {
2
+ return !response.destroyed && !response.writableEnded && !response.writableFinished;
3
+ }
4
+ export function writeResponseChunk(response, chunk) {
5
+ if (!isResponseWritable(response))
6
+ return false;
7
+ try {
8
+ response.write(chunk);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }