ai-or-die 0.1.64 → 0.1.65
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.
- package/package.json +1 -1
- package/src/server.js +25 -2
- package/src/terminal-bridge.js +29 -3
- package/src/utils/file-watcher.js +125 -19
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -352,7 +352,15 @@ class ClaudeCodeWebServer {
|
|
|
352
352
|
// .native form); .native is only used HERE, where it can't leak
|
|
353
353
|
// into response paths or watcher-subscription keys.
|
|
354
354
|
const resolvedTarget = this._canonicalizePathSync(targetPath);
|
|
355
|
-
|
|
355
|
+
// Memoized — baseFolder is constant for the process lifetime, but
|
|
356
|
+
// _canonicalizePathSync calls fs.realpathSync.native which is a real
|
|
357
|
+
// syscall (10–50ms on a SUBST/network drive). isPathWithinBase runs
|
|
358
|
+
// on every OSC 7 emission via validatePath; without this cache, each
|
|
359
|
+
// emission paid a redundant baseFolder realpath round-trip.
|
|
360
|
+
if (!this._canonicalizedBaseFolder) {
|
|
361
|
+
this._canonicalizedBaseFolder = this._canonicalizePathSync(this.baseFolder);
|
|
362
|
+
}
|
|
363
|
+
const resolvedBase = this._canonicalizedBaseFolder;
|
|
356
364
|
// Use path.relative instead of startsWith to avoid prefix-matching false positives
|
|
357
365
|
// (e.g. /home/user-admin would match /home/user with startsWith)
|
|
358
366
|
const relative = path.relative(resolvedBase, resolvedTarget);
|
|
@@ -2253,7 +2261,12 @@ class ClaudeCodeWebServer {
|
|
|
2253
2261
|
|
|
2254
2262
|
let watcher;
|
|
2255
2263
|
let watcherClosed = false;
|
|
2256
|
-
|
|
2264
|
+
// Per the diagnosed Windows hang on Q:\src with multi-worktree + Claude
|
|
2265
|
+
// bulk edits: the 100ms default barely coalesced — a single bulk-edit
|
|
2266
|
+
// wave produced thousands of debounce timers. 500ms is still well below
|
|
2267
|
+
// the 5s "stale buffer" UX threshold from ADR-0017 but cuts emitted
|
|
2268
|
+
// event rate by ~5x under bulk activity.
|
|
2269
|
+
const debounceMs = parseInt(process.env.FS_WATCHER_DEBOUNCE_MS, 10) || 500;
|
|
2257
2270
|
// FS_WATCHER_STABILITY_MS env: defaults to 80ms (the tuned-down
|
|
2258
2271
|
// value per ADR-0017 §Coalescing). Setting to 0 DISABLES chokidar's
|
|
2259
2272
|
// awaitWriteFinish entirely — useful in tests where sync
|
|
@@ -2335,6 +2348,16 @@ class ClaudeCodeWebServer {
|
|
|
2335
2348
|
// FS_WATCHER_USE_POLLING=1 → chokidar uses fs.stat-loop backend.
|
|
2336
2349
|
usePolling: usePolling,
|
|
2337
2350
|
ignoreDirs: ignoreFromEnv.length ? ignoreFromEnv : undefined,
|
|
2351
|
+
// Narrow scope: chokidar only watches direct children of every
|
|
2352
|
+
// subscribed path (vs. the prior recursive watch of the entire
|
|
2353
|
+
// watchRoot tree). This is the load-bearing knob that bounds
|
|
2354
|
+
// active_handles on large/multi-worktree trees — the symptom
|
|
2355
|
+
// that prompted the unhang work. The client's existing soft-
|
|
2356
|
+
// filter subscription model (current dir + open tabs) drives
|
|
2357
|
+
// what chokidar actually watches via add()/unwatch() inside
|
|
2358
|
+
// FileWatcher. Subscriptions for paths NOT in the displayed
|
|
2359
|
+
// dir or an open tab don't allocate watch handles at all.
|
|
2360
|
+
depth: 0,
|
|
2338
2361
|
});
|
|
2339
2362
|
sessionEntry.watcher = watcher;
|
|
2340
2363
|
|
package/src/terminal-bridge.js
CHANGED
|
@@ -42,6 +42,19 @@ class TerminalBridge extends BaseBridge {
|
|
|
42
42
|
* @type {Map<string, string|null>}
|
|
43
43
|
*/
|
|
44
44
|
this._liveCwd = new Map();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Raw OSC 7 path most recently seen for each session — used to skip the
|
|
48
|
+
* entire validate-and-emit chain when the shell re-emits the same path
|
|
49
|
+
* (every prompt redraw on pwsh/oh-my-posh/Starship). Without this, each
|
|
50
|
+
* redraw fires 3–4 fs.realpathSync syscalls per session; on a SUBST or
|
|
51
|
+
* mapped Windows drive (e.g. Q:\), those syscalls are 10–50ms each and
|
|
52
|
+
* pending requests pile up faster than they complete, blocking the event
|
|
53
|
+
* loop. The dedupe at line ~205 below only saves the *broadcast* — this
|
|
54
|
+
* one saves the syscalls.
|
|
55
|
+
* @type {Map<string, string|null>}
|
|
56
|
+
*/
|
|
57
|
+
this._lastRawOsc7 = new Map();
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
// Override async command discovery — use default shell instead of searching PATH
|
|
@@ -156,6 +169,7 @@ class TerminalBridge extends BaseBridge {
|
|
|
156
169
|
this._osc7Parsers.set(sessionId, new Osc7Parser());
|
|
157
170
|
this._osc7Hooks.set(sessionId, hooks);
|
|
158
171
|
this._liveCwd.set(sessionId, null);
|
|
172
|
+
this._lastRawOsc7.set(sessionId, null);
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/**
|
|
@@ -169,6 +183,7 @@ class TerminalBridge extends BaseBridge {
|
|
|
169
183
|
this._osc7Parsers.delete(sessionId);
|
|
170
184
|
this._osc7Hooks.delete(sessionId);
|
|
171
185
|
this._liveCwd.delete(sessionId);
|
|
186
|
+
this._lastRawOsc7.delete(sessionId);
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
/**
|
|
@@ -186,6 +201,18 @@ class TerminalBridge extends BaseBridge {
|
|
|
186
201
|
if (!decoded.length) return;
|
|
187
202
|
|
|
188
203
|
for (const raw of decoded) {
|
|
204
|
+
// Fast-path: skip the entire validate-and-emit chain when the shell
|
|
205
|
+
// re-emits the same raw path. pwsh + oh-my-posh/Starship redraws on
|
|
206
|
+
// every keystroke, so the same `file:///Q:/src` arrives N times per
|
|
207
|
+
// second. validatePath does 3–4 fs.realpathSync syscalls per call;
|
|
208
|
+
// on a SUBST/mapped/network drive each syscall is 10–50ms, and
|
|
209
|
+
// pending requests pile up faster than they complete. The dedupe
|
|
210
|
+
// at the cwd-level below (line 207) only saves the BROADCAST — it
|
|
211
|
+
// can't save the syscalls because they happen during validation.
|
|
212
|
+
const lastRaw = this._lastRawOsc7.get(sessionId);
|
|
213
|
+
if (raw === lastRaw) continue;
|
|
214
|
+
this._lastRawOsc7.set(sessionId, raw);
|
|
215
|
+
|
|
189
216
|
let validated;
|
|
190
217
|
try {
|
|
191
218
|
validated = hooks.validatePath(raw);
|
|
@@ -199,9 +226,8 @@ class TerminalBridge extends BaseBridge {
|
|
|
199
226
|
const cwd = validated.path || raw;
|
|
200
227
|
const prev = this._liveCwd.get(sessionId) || null;
|
|
201
228
|
|
|
202
|
-
//
|
|
203
|
-
// the
|
|
204
|
-
// WebSocket frame count bounded by user activity, not prompt rate.
|
|
229
|
+
// Defence-in-depth: even if validation collapses two different raw
|
|
230
|
+
// strings to the same canonical cwd, don't broadcast a no-op change.
|
|
205
231
|
if (cwd === prev) continue;
|
|
206
232
|
|
|
207
233
|
this._liveCwd.set(sessionId, cwd);
|
|
@@ -98,7 +98,12 @@ class FileWatcher extends EventEmitter {
|
|
|
98
98
|
* Subscribed paths are typically children of this root, but
|
|
99
99
|
* relPath is computed regardless (may include `..` segments
|
|
100
100
|
* if a subscription is outside the root).
|
|
101
|
-
* - debounceMs: per-path debounce window (default
|
|
101
|
+
* - debounceMs: per-path debounce window (default 500, ADR §Coalescing).
|
|
102
|
+
* Bumped from 100 to 500 when the "Windows + multi-worktree
|
|
103
|
+
* + Claude bulk edits" hang was diagnosed — under bulk edits
|
|
104
|
+
* a 100ms window barely coalesces, and combined with the
|
|
105
|
+
* narrow-scope `depth: 0` path below the longer window
|
|
106
|
+
* substantially cuts event rate without harming UX.
|
|
102
107
|
* - addChangeDedupMs: window within which add+change collapses to change
|
|
103
108
|
* (default 50, ADR §Coalescing layer 3).
|
|
104
109
|
* - renameDetectMs: window within which same-inode unlink+add collapses
|
|
@@ -108,7 +113,19 @@ class FileWatcher extends EventEmitter {
|
|
|
108
113
|
* layer 1; tunable for tests).
|
|
109
114
|
* - ignoreDirs: directory-name list for ignore patterns; default
|
|
110
115
|
* DEFAULT_IGNORE_DIRS.
|
|
111
|
-
* -
|
|
116
|
+
* - depth: passed through to chokidar. `0` confines the watcher to direct
|
|
117
|
+
* children of every watched path — used by the file-browser SSE
|
|
118
|
+
* endpoint to eliminate the recursive-tree handle explosion on
|
|
119
|
+
* Windows + large worktree trees. Default `undefined`
|
|
120
|
+
* (chokidar's recursive default, for backward compat).
|
|
121
|
+
* - includeHash: emit MD5 hash on change events. Default `true` UNLESS
|
|
122
|
+
* `depth: 0` is set, in which case the default flips to
|
|
123
|
+
* `false` because the sync `fs.readFileSync` inside
|
|
124
|
+
* `_flush()` can block the event loop under bulk edits
|
|
125
|
+
* (e.g. an agent generating many files in the displayed
|
|
126
|
+
* directory). The `file-tabs.js` hash short-circuit falls
|
|
127
|
+
* through gracefully when hash is absent (always refresh
|
|
128
|
+
* via HTTP), so functionality is preserved.
|
|
112
129
|
*/
|
|
113
130
|
constructor(opts) {
|
|
114
131
|
super();
|
|
@@ -124,14 +141,24 @@ class FileWatcher extends EventEmitter {
|
|
|
124
141
|
let resolvedRoot = path.resolve(opts.watchRoot);
|
|
125
142
|
try { resolvedRoot = fs.realpathSync(resolvedRoot); } catch (_) {}
|
|
126
143
|
this._watchRoot = resolvedRoot;
|
|
127
|
-
this._debounceMs = typeof opts.debounceMs === 'number' ? opts.debounceMs :
|
|
144
|
+
this._debounceMs = typeof opts.debounceMs === 'number' ? opts.debounceMs : 500;
|
|
128
145
|
this._addChangeDedupMs = typeof opts.addChangeDedupMs === 'number' ? opts.addChangeDedupMs : 50;
|
|
129
146
|
this._renameDetectMs = typeof opts.renameDetectMs === 'number' ? opts.renameDetectMs : 50;
|
|
130
147
|
this._stabilityMs = typeof opts.stabilityMs === 'number' ? opts.stabilityMs : 80;
|
|
131
148
|
this._pollIntervalMs = typeof opts.pollIntervalMs === 'number' ? opts.pollIntervalMs : 30;
|
|
132
149
|
this._ignoreDirs = Array.isArray(opts.ignoreDirs) ? opts.ignoreDirs : DEFAULT_IGNORE_DIRS;
|
|
133
150
|
this._ignoreRegexes = buildIgnoreRegexes(this._ignoreDirs);
|
|
134
|
-
this.
|
|
151
|
+
this._depth = typeof opts.depth === 'number' ? opts.depth : undefined;
|
|
152
|
+
// When the caller opted into narrow-scope (depth: 0) and didn't explicitly
|
|
153
|
+
// ask for hashes, default-off to keep _flush() from doing sync MD5 reads
|
|
154
|
+
// under bulk-edit storms. Explicit `includeHash: true` still wins.
|
|
155
|
+
if (typeof opts.includeHash === 'boolean') {
|
|
156
|
+
this._includeHash = opts.includeHash;
|
|
157
|
+
} else if (this._depth === 0) {
|
|
158
|
+
this._includeHash = false;
|
|
159
|
+
} else {
|
|
160
|
+
this._includeHash = true;
|
|
161
|
+
}
|
|
135
162
|
// Allow callers to disable awaitWriteFinish entirely (for tests where
|
|
136
163
|
// sync writeFileSync races chokidar's poll cycle). Default = on with
|
|
137
164
|
// tuned-down values per ADR-0017.
|
|
@@ -211,6 +238,15 @@ class FileWatcher extends EventEmitter {
|
|
|
211
238
|
usePolling: this._usePolling,
|
|
212
239
|
interval: this._usePolling ? 50 : undefined,
|
|
213
240
|
atomic: false,
|
|
241
|
+
// depth: 0 confines chokidar to direct children of every watched
|
|
242
|
+
// path (the watchRoot + anything later added via subscribe). The
|
|
243
|
+
// server passes this for the file-browser SSE path; subscriptions
|
|
244
|
+
// drive what's actually watched beyond the watchRoot via
|
|
245
|
+
// chokidar.add(). This is the load-bearing knob that bounds the
|
|
246
|
+
// active_handles cost on large/multi-worktree trees. `undefined`
|
|
247
|
+
// (the default) restores chokidar's recursive behaviour for
|
|
248
|
+
// backward compat in callers that haven't migrated.
|
|
249
|
+
depth: this._depth,
|
|
214
250
|
});
|
|
215
251
|
|
|
216
252
|
this._watcher.on('add', (p, stat) => this._onChokidar('add', p, stat));
|
|
@@ -270,24 +306,38 @@ class FileWatcher extends EventEmitter {
|
|
|
270
306
|
|
|
271
307
|
/**
|
|
272
308
|
* Add a path to the active subscription set. Events for this path will
|
|
273
|
-
* now flow through the consumer's 'event' listener.
|
|
274
|
-
*
|
|
275
|
-
*
|
|
309
|
+
* now flow through the consumer's 'event' listener.
|
|
310
|
+
*
|
|
311
|
+
* When the watcher was constructed with `depth: 0`, the chokidar watch
|
|
312
|
+
* scope is dynamically managed: subscribing to a file watches its parent
|
|
313
|
+
* directory (chokidar emits events for direct children of the watched
|
|
314
|
+
* dir); subscribing to a directory recursively watches that directory.
|
|
315
|
+
* A physical-watch-target refcount means we only call chokidar.add()
|
|
316
|
+
* for the first subscription that needs a given dir, and chokidar.unwatch()
|
|
317
|
+
* for the last. Without this, two tabs in the same dir would race the
|
|
318
|
+
* underlying watch and a tab-close could kill another tab's events.
|
|
319
|
+
*
|
|
320
|
+
* For constructors NOT using depth: 0 (legacy callers), chokidar already
|
|
321
|
+
* watches the full subtree of watchRoot recursively, so subscribe is
|
|
322
|
+
* pure soft-filter bookkeeping (no chokidar.add).
|
|
276
323
|
*
|
|
277
324
|
* @param {string} absPath
|
|
278
325
|
* @param {object} [opts]
|
|
279
|
-
* - recursive: boolean — if true, the subscription
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
326
|
+
* - recursive: boolean — if true, the subscription matches the dir AND
|
|
327
|
+
* its descendants (in the recursive-watch model). Under
|
|
328
|
+
* depth: 0 the soft-filter set still uses this for event
|
|
329
|
+
* matching, but chokidar itself only watches direct
|
|
330
|
+
* children of the dir — descendant events would not
|
|
331
|
+
* arrive (file-browser listing already filters to direct
|
|
332
|
+
* children, so this is invisible to current consumers).
|
|
333
|
+
* Default false (exact-path match).
|
|
285
334
|
*/
|
|
286
335
|
async subscribe(absPath, opts) {
|
|
287
336
|
if (this._closed) throw new Error('FileWatcher: cannot subscribe on a closed watcher');
|
|
288
337
|
if (!this._watcher) throw new Error('FileWatcher: must call start() before subscribe()');
|
|
289
338
|
const canonical = this._canonicalize(absPath);
|
|
290
|
-
|
|
339
|
+
const isRecursive = !!(opts && opts.recursive);
|
|
340
|
+
if (isRecursive) {
|
|
291
341
|
// Store with a trailing path separator so the prefix-match in
|
|
292
342
|
// _onChokidar via `startsWith(dir + sep)` works without false
|
|
293
343
|
// positives (e.g. /a/b should NOT match /a/bc).
|
|
@@ -295,14 +345,21 @@ class FileWatcher extends EventEmitter {
|
|
|
295
345
|
} else {
|
|
296
346
|
this._subscriptions.add(canonical);
|
|
297
347
|
}
|
|
348
|
+
if (this._depth === 0) {
|
|
349
|
+
const target = isRecursive ? canonical : path.dirname(canonical);
|
|
350
|
+
this._refWatchTarget(target);
|
|
351
|
+
}
|
|
298
352
|
}
|
|
299
353
|
|
|
300
354
|
/**
|
|
301
355
|
* Remove a path from the active subscription set. Subsequent events for
|
|
302
|
-
* this path will be dropped on emit
|
|
303
|
-
* internally — cheap to discard). Idempotent: removing a non-subscribed
|
|
356
|
+
* this path will be dropped on emit. Idempotent: removing a non-subscribed
|
|
304
357
|
* path is a no-op.
|
|
305
358
|
*
|
|
359
|
+
* Under `depth: 0`, also drops the chokidar watch on the corresponding
|
|
360
|
+
* target directory when no other subscription still references it (see
|
|
361
|
+
* `_refWatchTarget`).
|
|
362
|
+
*
|
|
306
363
|
* The `recursive` opt must match the flavour the path was subscribed
|
|
307
364
|
* with — calling unsubscribe(path, {recursive:true}) on an exact-
|
|
308
365
|
* subscribed path is a no-op (and vice versa). Mismatched-flavour
|
|
@@ -312,10 +369,59 @@ class FileWatcher extends EventEmitter {
|
|
|
312
369
|
async unsubscribe(absPath, opts) {
|
|
313
370
|
if (this._closed) return;
|
|
314
371
|
const canonical = this._canonicalize(absPath);
|
|
315
|
-
|
|
316
|
-
|
|
372
|
+
const isRecursive = !!(opts && opts.recursive);
|
|
373
|
+
let hadSub = false;
|
|
374
|
+
if (isRecursive) {
|
|
375
|
+
hadSub = this._dirSubscriptions.delete(canonical + path.sep);
|
|
376
|
+
} else {
|
|
377
|
+
hadSub = this._subscriptions.delete(canonical);
|
|
378
|
+
}
|
|
379
|
+
if (!hadSub) return; // idempotent — nothing to release
|
|
380
|
+
if (this._depth === 0) {
|
|
381
|
+
const target = isRecursive ? canonical : path.dirname(canonical);
|
|
382
|
+
this._unrefWatchTarget(target);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Refcount-key normalization for the watch-target map. On Windows the
|
|
388
|
+
* filesystem is case-insensitive but path strings carry whatever casing
|
|
389
|
+
* the caller used (or whatever realpathSync returned, which is not
|
|
390
|
+
* always the on-disk form for paths it can't resolve). Lower-casing the
|
|
391
|
+
* key means `subscribe('Q:\\src\\file.js')` and `unsubscribe('q:\\SRC\\
|
|
392
|
+
* FILE.JS')` resolve to the same refcount slot. Forward-slash
|
|
393
|
+
* normalization is a separate cross-platform concern handled by
|
|
394
|
+
* normalizePath above.
|
|
395
|
+
*/
|
|
396
|
+
_watchKeyNorm(p) {
|
|
397
|
+
const n = normalizePath(p);
|
|
398
|
+
return process.platform === 'win32' ? n.toLowerCase() : n;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_refWatchTarget(target) {
|
|
402
|
+
// The watchRoot is already watched at start() time — never add/unwatch it
|
|
403
|
+
// dynamically here, or we'd risk closing the root chokidar handle.
|
|
404
|
+
if (this._watchKeyNorm(target) === this._watchKeyNorm(this._watchRoot)) return;
|
|
405
|
+
const key = this._watchKeyNorm(target);
|
|
406
|
+
const cur = this._dirRefcount.get(key) || 0;
|
|
407
|
+
this._dirRefcount.set(key, cur + 1);
|
|
408
|
+
if (cur === 0) {
|
|
409
|
+
this._watchedDirs.add(key);
|
|
410
|
+
try { this._watcher.add(target); } catch (_) { /* chokidar will surface via 'error' if real */ }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_unrefWatchTarget(target) {
|
|
415
|
+
if (this._watchKeyNorm(target) === this._watchKeyNorm(this._watchRoot)) return;
|
|
416
|
+
const key = this._watchKeyNorm(target);
|
|
417
|
+
const cur = this._dirRefcount.get(key) || 0;
|
|
418
|
+
if (cur === 0) return; // defensive — refcount should never go negative
|
|
419
|
+
if (cur === 1) {
|
|
420
|
+
this._dirRefcount.delete(key);
|
|
421
|
+
this._watchedDirs.delete(key);
|
|
422
|
+
try { this._watcher.unwatch(target); } catch (_) {}
|
|
317
423
|
} else {
|
|
318
|
-
this.
|
|
424
|
+
this._dirRefcount.set(key, cur - 1);
|
|
319
425
|
}
|
|
320
426
|
}
|
|
321
427
|
|