ai-or-die 0.1.64 → 0.1.66

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
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
- const resolvedBase = this._canonicalizePathSync(this.baseFolder);
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
- const debounceMs = parseInt(process.env.FS_WATCHER_DEBOUNCE_MS, 10) || 100;
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
 
@@ -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
- // Only emit on actual change OSC 7 fires every prompt, including
203
- // the no-cd common case. Suppressing same-value emits keeps 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 100, ADR §Coalescing).
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
- * - includeHash: emit hash on change events (default true).
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 : 100;
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._includeHash = opts.includeHash !== false;
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. The path does not
274
- * need to exist at subscribe time — chokidar already watches the
275
- * watchRoot subtree, so add events fire when the file is created.
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 is treated as a
280
- * directory-recursive match: events for `absPath` AND
281
- * any descendant fire. Used by FileBrowserPanel for
282
- * the "auto-refresh listing on agent-create" case
283
- * where the new file's path isn't known at subscribe
284
- * time. Default false (exact-path match).
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
- if (opts && opts.recursive) {
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 (chokidar continues to fire them
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
- if (opts && opts.recursive) {
316
- this._dirSubscriptions.delete(canonical + path.sep);
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._subscriptions.delete(canonical);
424
+ this._dirRefcount.set(key, cur - 1);
319
425
  }
320
426
  }
321
427