termdock 1.4.150 → 1.4.152

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-uLSarP4D.js → ModelPreview-eQHIXZSi.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-LRvFux5X.js → abnfDiagram-VRR7QNED-DVpP-Ck_.js} +1 -1
  3. package/dist/client/assets/{arc-DNw2kfZ0.js → arc-BUWNItqM.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DjZ3s7fQ.js → architectureDiagram-ZJ3FMSHR-cE0XflG9.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-Dtd_IKpz.js → blockDiagram-677ZJIJ3-CRUJfqnu.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-mzZ4dVs4.js → c4Diagram-LMCZKHZV-CfYifxuE.js} +1 -1
  7. package/dist/client/assets/channel-DNGAEw-f.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-_SFJXlb5.js → chunk-2Q5K7J3B-BdaPjMEy.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-r_v0waX5.js → chunk-32BRIVSS-D6Eb-pQ6.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-CU1_Nmy8.js → chunk-5VM5RSS4-C-oAudIE.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-DsPxrvQl.js → chunk-EX3LRPZG-BOHtjSTK.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-CRqyMcg1.js → chunk-JWPE2WC7-DG4ba0nk.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-BxJ6_ubT.js → chunk-MOJQB5TN-BlZyZyeN.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-DzqJd87Z.js → chunk-RYQCIY6F-CaucB3jb.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-CjDkrTUB.js → chunk-V7JOEXUC-1pz-6Ccd.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-b6vd8xBy.js → chunk-VR4S4FIN-Cxjlb154.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-V9HlNoo1.js → chunk-XXDRQBXY-B1WP3198.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-CwvsZeN6.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CwvsZeN6.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-CG28xkJk.js → cose-bilkent-JH36ORCC-VJN6hEws.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-DB7EZ1-e.js → cynefin-VYW2F7L2-B6cBvU5_.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-CBnLantQ.js → cynefinDiagram-TSTJHNR4-DjZPoBPi.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-DIX_xZyT.js → dagre-VKFMJZFB-Gn3YE7F6.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-DevjfCup.js → diagram-FQU43EPY-D8A13xZb.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-CsmEsXKw.js → diagram-G47NLZAW-71ML-ld7.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-C3s1JiKg.js → diagram-NH7WQ7WH-B7eGBWWg.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-DMuuXoRh.js → diagram-OA4YK3LP-CbgiA51E.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-BUiXOAtw.js → diagram-WEI45ONY-qsJy_J2A.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BRjVSbcV.js → ebnfDiagram-CCIWWBDH-DXRvMbEO.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-C_WH08Zw.js → erDiagram-Q63AITRT-CObkXi3v.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-DLIcFn5v.js → flowDiagram-23GEKE2U-CL5c6WSh.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-BaI9-Mv5.js → ganttDiagram-NO4QXBWP-r3N66qb4.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-IYSJpCp8.js → gitGraphDiagram-IHSO6WYX-D-ALPOPq.js} +1 -1
  34. package/dist/client/assets/index-BQKH9KWh.css +1 -0
  35. package/dist/client/assets/index-CHsO9fJ1.js +631 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-D2zJK0W5.js → infoDiagram-FWYZ7A6U-TK7ImxMu.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bn3NuQ-o.js → ishikawaDiagram-FXEZZL3T-DC-sjv04.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-C5J5U8wH.js → journeyDiagram-5HDEW3XC-IOU7vr4d.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-DSQy9Zpl.js → kanban-definition-HUTT4EX6-BaXN_VRb.js} +1 -1
  40. package/dist/client/assets/{linear-DwEWVHBG.js → linear-BDDuGH46.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-Cq6MlkF6.js → mermaid.core-BVBeoUSn.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CL652EWv.js → mindmap-definition-LN4V7U3C-B-aOT1JJ.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-Csq-yPnl.js → pegDiagram-2B236MQR-BA0bA9lE.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-Nw4ezF_J.js → pieDiagram-ENE6RG2P-CIO8t_fq.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DoCDVpbT.js → quadrantDiagram-ABIIQ3AL-DXfNHoC-.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-q-BqkN1P.js → railroadDiagram-RFXS5EU6-DEVQtNvi.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-Cnlu8uA8.js → requirementDiagram-TGXJPOKE-x2jq4Aes.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CyqCLIkV.js → sankeyDiagram-HTMAVEWB-qfEuuNtx.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PAF-solb.js → sequenceDiagram-DBY2YBRQ-TcEwE9X8.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DqA34cSw.js → sizeCapture-X5ZJPWSS-BoW8XorN.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-CfHdGfI2.js → stateDiagram-2N3HPSRC-CKUYRngR.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BLo4197v.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-VpPTM-Jz.js → swimlanes-5IMT3BWC-ke9Inr5n.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BatBCy_S.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6--JyCyiUv.js → timeline-definition-FHXFAJF6-C212ZNW1.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-B14FUZwp.js → vennDiagram-L72KCM5P-CsMo_a1c.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-CkCxrnnh.js → wardleyDiagram-EHGQE667-C2QO5XVB.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-C4w2gntS.js → xychartDiagram-FW5EYKEG-DxQc72uf.js} +1 -1
  59. package/dist/client/index.html +8 -4
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/routes/filesystem.js +216 -144
  62. package/dist/server/routes/terminal.js +5 -7
  63. package/dist/server/utils/fileWatchPolicy.js +22 -15
  64. package/dist/server/utils/httpResponse.js +14 -0
  65. package/package.json +1 -2
  66. package/runtime-manifest.json +3 -3
  67. package/dist/client/assets/channel-mKVBJtmM.js +0 -1
  68. package/dist/client/assets/classDiagram-OUVF2IWQ-CAMnLD8c.js +0 -1
  69. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CAMnLD8c.js +0 -1
  70. package/dist/client/assets/index-CFpYppDM.js +0 -631
  71. package/dist/client/assets/index-DZNvUpMj.css +0 -1
  72. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DcUmtjE2.js +0 -1
  73. package/dist/client/assets/swimlanesDiagram-G3AALYLV-pmYcUfvR.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,124 @@ 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
+ broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'polling-fallback' });
220
+ scheduleSharedWatchPoll(rootPath, entry);
221
+ console.warn('[file-watch] Using polling fallback', { rootPath, intervalMs: WATCH_POLL_INTERVAL_MS, reason });
222
+ return true;
176
223
  }
177
224
  async function acquireSharedWatch(rootPath, client) {
178
225
  let entry = sharedWatchRegistry.get(rootPath);
179
226
  if (!entry) {
180
227
  entry = {
181
228
  refs: 0,
182
- subscription: null,
229
+ nativeWatcher: null,
230
+ mode: 'starting',
183
231
  failure: null,
184
232
  clients: new Set(),
185
233
  inFlight: null,
186
234
  evictTimer: null,
235
+ pollTimer: null,
236
+ pollSnapshot: null,
187
237
  pendingEvents: new Map(),
188
238
  statWorkers: 0,
189
239
  eventGeneration: 0,
@@ -201,72 +251,66 @@ async function acquireSharedWatch(rootPath, client) {
201
251
  entry.needsRescanOnAttach = false;
202
252
  client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
203
253
  }
204
- if (entry.failure)
205
- return null;
206
- if (entry.subscription)
254
+ if (entry.mode === 'polling' && entry.pollSnapshot) {
255
+ scheduleSharedWatchPoll(rootPath, entry, 0);
256
+ return rootPath;
257
+ }
258
+ if (entry.nativeWatcher)
207
259
  return rootPath;
208
260
  if (!entry.inFlight) {
209
261
  const now = Date.now();
210
262
  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;
263
+ entry.inFlight = (async () => {
264
+ if (now < watchAdmissionBlockedUntil || activeSubscriptions >= MAX_NATIVE_DIRECTORY_WATCHES) {
265
+ const reason = now < watchAdmissionBlockedUntil ? 'native watcher resource backoff' : 'native watcher process budget reached';
266
+ return startPollingWatch(rootPath, entry, reason);
236
267
  }
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);
268
+ try {
269
+ const nativeWatcher = fs.watch(rootPath, { persistent: false }, (_eventType, filename) => {
270
+ enqueueSharedNativeEvent(rootPath, entry, filename);
271
+ });
272
+ entry.nativeWatcher = nativeWatcher;
273
+ entry.mode = 'native';
274
+ entry.failure = null;
275
+ nativeWatcher.on('error', (error) => {
276
+ if (entry.mode !== 'native')
277
+ return;
278
+ const resourceExhausted = isWatchResourceExhaustion(error);
279
+ if (resourceExhausted)
280
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
281
+ if (resourceExhausted)
282
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
283
+ if (diagnostics)
284
+ console.warn('[file-watch] Linux inotify usage after watcher failure', diagnostics);
285
+ }).catch(() => undefined);
286
+ void startPollingWatch(rootPath, entry, error.message || 'native watcher error').then((started) => {
287
+ if (!started) {
288
+ const reason = entry.failure ?? 'watch-unavailable';
289
+ for (const client of [...entry.clients])
290
+ if (!client.closed)
291
+ client.degrade(reason);
292
+ }
293
+ });
294
+ });
295
+ console.info('[file-watch] Native directory watch ready', {
296
+ rootPath,
297
+ activeSubscriptions: activeSubscriptions + 1,
298
+ registrySize: sharedWatchRegistry.size,
299
+ });
300
+ return true;
267
301
  }
268
- return null;
269
- })
302
+ catch (error) {
303
+ const resourceExhausted = isWatchResourceExhaustion(error);
304
+ if (resourceExhausted)
305
+ watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
306
+ if (resourceExhausted)
307
+ void inspectLinuxInotifyUsage().then((diagnostics) => {
308
+ if (diagnostics)
309
+ console.warn('[file-watch] Linux inotify usage after watcher admission failure', diagnostics);
310
+ }).catch(() => undefined);
311
+ return startPollingWatch(rootPath, entry, error instanceof Error ? error.message : String(error));
312
+ }
313
+ })()
270
314
  .finally(() => {
271
315
  const current = sharedWatchRegistry.get(rootPath);
272
316
  if (current)
@@ -291,7 +335,11 @@ function releaseSharedWatch(rootPath, client) {
291
335
  || entry.statWorkers > 0;
292
336
  entry.pendingEvents.clear();
293
337
  entry.eventGeneration += 1;
294
- if (!entry.subscription && !entry.inFlight) {
338
+ if (entry.pollTimer) {
339
+ clearTimeout(entry.pollTimer);
340
+ entry.pollTimer = null;
341
+ }
342
+ if (!entry.nativeWatcher && !entry.inFlight) {
295
343
  sharedWatchRegistry.delete(rootPath);
296
344
  return;
297
345
  }
@@ -302,8 +350,10 @@ function releaseSharedWatch(rootPath, client) {
302
350
  if (!current || current.refs > 0)
303
351
  return;
304
352
  sharedWatchRegistry.delete(rootPath);
305
- void current.subscription?.unsubscribe().catch(() => undefined);
306
- current.subscription = null;
353
+ if (current.pollTimer)
354
+ clearTimeout(current.pollTimer);
355
+ current.nativeWatcher?.close();
356
+ current.nativeWatcher = null;
307
357
  }, SHARED_WATCH_EVICT_DELAY_MS);
308
358
  entry.evictTimer.unref?.();
309
359
  }
@@ -1663,13 +1713,17 @@ async function searchWithFallback(rootPath, queryLower, showHidden, excludePatte
1663
1713
  };
1664
1714
  }
1665
1715
  function writeSearchEvent(res, type, payload) {
1666
- res.write(`${JSON.stringify({ type, ...payload })}\n`);
1716
+ writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
1667
1717
  }
1668
1718
  function createSearchBatchEmitter(res) {
1669
1719
  let batch = [];
1670
1720
  const flush = () => {
1671
- if (batch.length === 0 || res.destroyed)
1721
+ if (batch.length === 0)
1672
1722
  return;
1723
+ if (!isResponseWritable(res)) {
1724
+ batch = [];
1725
+ return;
1726
+ }
1673
1727
  writeSearchEvent(res, 'batch', { entries: batch });
1674
1728
  batch = [];
1675
1729
  };
@@ -1807,8 +1861,12 @@ async function streamSearchWithFallback(rootPath, queryLower, showHidden, exclud
1807
1861
  function createContentBatchEmitter(res) {
1808
1862
  let batch = [];
1809
1863
  const flush = () => {
1810
- if (batch.length === 0 || res.destroyed)
1864
+ if (batch.length === 0)
1811
1865
  return;
1866
+ if (!isResponseWritable(res)) {
1867
+ batch = [];
1868
+ return;
1869
+ }
1812
1870
  writeSearchEvent(res, 'content-batch', { contentEntries: batch });
1813
1871
  batch = [];
1814
1872
  };
@@ -2569,8 +2627,17 @@ router.get('/list', async (req, res) => {
2569
2627
  // deep directory listing; it uses the OS watcher and sends small batched events
2570
2628
  // so the client can patch only directories it has already loaded.
2571
2629
  router.get('/watch', async (req, res) => {
2630
+ const repeatedRootsParam = req.query.root;
2572
2631
  const rootsParam = req.query.roots;
2573
- const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
2632
+ const rawRoots = (Array.isArray(repeatedRootsParam)
2633
+ ? repeatedRootsParam
2634
+ : typeof repeatedRootsParam === 'string'
2635
+ ? [repeatedRootsParam]
2636
+ : Array.isArray(rootsParam)
2637
+ ? rootsParam
2638
+ : typeof rootsParam === 'string'
2639
+ ? rootsParam.split('|')
2640
+ : [])
2574
2641
  .filter((root) => typeof root === 'string');
2575
2642
  const validatedRoots = [];
2576
2643
  for (const rawRoot of rawRoots) {
@@ -2586,7 +2653,7 @@ router.get('/watch', async (req, res) => {
2586
2653
  // Ignore invalid watch roots; the visible tree will still work via manual list requests.
2587
2654
  }
2588
2655
  }
2589
- const roots = minimizeRecursiveWatchRoots(validatedRoots);
2656
+ const roots = normalizeDirectoryWatchRoots(validatedRoots);
2590
2657
  if (roots.length === 0) {
2591
2658
  res.status(400).json({ error: 'No valid roots to watch' });
2592
2659
  return;
@@ -2598,10 +2665,8 @@ router.get('/watch', async (req, res) => {
2598
2665
  let pending = [];
2599
2666
  let flushTimer = null;
2600
2667
  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.
2668
+ // Register cleanup before the first native watch or fallback snapshot starts;
2669
+ // the browser may abort while roots are being validated or attached.
2605
2670
  const closeWatchStream = () => {
2606
2671
  if (closed)
2607
2672
  return;
@@ -2614,9 +2679,9 @@ router.get('/watch', async (req, res) => {
2614
2679
  };
2615
2680
  res.once('close', closeWatchStream);
2616
2681
  const writeEvent = (type, payload) => {
2617
- if (closed || res.destroyed)
2618
- return;
2619
- res.write(`${JSON.stringify({ type, ...payload })}\n`);
2682
+ if (closed)
2683
+ return false;
2684
+ return writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
2620
2685
  };
2621
2686
  const flush = () => {
2622
2687
  flushTimer = null;
@@ -2645,7 +2710,7 @@ router.get('/watch', async (req, res) => {
2645
2710
  requestedRoots: rawRoots.length,
2646
2711
  validatedRoots: validatedRoots.length,
2647
2712
  subscribedRoots: roots.length,
2648
- collapsedOverlappingRoots: validatedRoots.length - roots.length,
2713
+ deduplicatedRoots: validatedRoots.length - roots.length,
2649
2714
  activeSubscriptions: countActiveSharedWatches(),
2650
2715
  registrySize: sharedWatchRegistry.size,
2651
2716
  });
@@ -2656,11 +2721,14 @@ router.get('/watch', async (req, res) => {
2656
2721
  closed: false,
2657
2722
  enqueue,
2658
2723
  degrade: (reason) => {
2659
- if (closed)
2724
+ if (closed || !isResponseWritable(res)) {
2725
+ closeWatchStream();
2660
2726
  return;
2727
+ }
2661
2728
  enqueue({ type: 'rescan-required', path: rootPath, reason });
2662
2729
  flush();
2663
- if (!res.writableEnded)
2730
+ closeWatchStream();
2731
+ if (isResponseWritable(res))
2664
2732
  res.end();
2665
2733
  },
2666
2734
  };
@@ -2776,14 +2844,15 @@ router.get('/search', async (req, res) => {
2776
2844
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', mode: 'content', limited: false });
2777
2845
  try {
2778
2846
  const result = await streamContentSearchWithRipgrep(resolvedPath, query, showHidden, excludePatterns, contentOptions, controller.signal, res);
2779
- if (!controller.signal.aborted && !res.destroyed) {
2847
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2780
2848
  writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'rg', mode: 'content' });
2781
2849
  logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'content', engine: 'rg' } });
2782
- res.end();
2850
+ if (isResponseWritable(res))
2851
+ res.end();
2783
2852
  }
2784
2853
  }
2785
2854
  catch (error) {
2786
- if (controller.signal.aborted || res.destroyed) {
2855
+ if (controller.signal.aborted || !isResponseWritable(res)) {
2787
2856
  const payload = getErrorPayload(controller.signal.reason ?? error);
2788
2857
  logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'content', engine: 'rg' } });
2789
2858
  return;
@@ -2791,7 +2860,8 @@ router.get('/search', async (req, res) => {
2791
2860
  const message = error instanceof Error ? error.message : 'Content search failed';
2792
2861
  logSearch('error', { code: 'CONTENT_SEARCH_UNAVAILABLE', error: message, extra: { mode: 'content', engine: 'rg' } });
2793
2862
  writeSearchEvent(res, 'error', { message, code: 'CONTENT_SEARCH_UNAVAILABLE' });
2794
- res.end();
2863
+ if (isResponseWritable(res))
2864
+ res.end();
2795
2865
  }
2796
2866
  return;
2797
2867
  }
@@ -2802,24 +2872,26 @@ router.get('/search', async (req, res) => {
2802
2872
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', limited: false });
2803
2873
  try {
2804
2874
  const total = await streamSearchWithRipgrep(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
2805
- if (!controller.signal.aborted && !res.destroyed) {
2875
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2806
2876
  writeSearchEvent(res, 'done', { total, truncated: false, limited: false, engine: 'rg' });
2807
2877
  logSearch('ok', { count: total, truncated: false, extra: { mode: 'name', engine: 'rg' } });
2808
- res.end();
2878
+ if (isResponseWritable(res))
2879
+ res.end();
2809
2880
  }
2810
2881
  }
2811
2882
  catch (error) {
2812
- if (controller.signal.aborted || res.destroyed) {
2883
+ if (controller.signal.aborted || !isResponseWritable(res)) {
2813
2884
  const payload = getErrorPayload(controller.signal.reason ?? error);
2814
2885
  logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
2815
2886
  return;
2816
2887
  }
2817
2888
  writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'fallback', limited: false });
2818
2889
  const result = await streamSearchWithFallback(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
2819
- if (!controller.signal.aborted && !res.destroyed) {
2890
+ if (!controller.signal.aborted && isResponseWritable(res)) {
2820
2891
  writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'fallback' });
2821
2892
  logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'name', engine: 'fallback' } });
2822
- res.end();
2893
+ if (isResponseWritable(res))
2894
+ res.end();
2823
2895
  }
2824
2896
  }
2825
2897
  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
+ }