@ohmaseclaro/fleetwatch 0.1.5 → 0.1.7

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.
@@ -108,6 +108,10 @@ export class Watcher extends BaseProvider {
108
108
  historyState = null;
109
109
  projectByPath = new Map();
110
110
  excludePathPrefixes;
111
+ /** Polling reconciliation timer — catches fs events chokidar misses. */
112
+ pollTimer = null;
113
+ /** Directory rescan timer — catches new session files chokidar misses. */
114
+ rescanTimer = null;
111
115
  constructor(opts) {
112
116
  super(opts);
113
117
  this.excludePathPrefixes = opts.excludePathPrefixes ?? [];
@@ -249,16 +253,102 @@ export class Watcher extends BaseProvider {
249
253
  }
250
254
  // 1s tick for status recompute (handles idle / awaiting-user transitions)
251
255
  setInterval(() => this.registry.recomputeAll(), 1000).unref();
256
+ // 2s tick: poll tracked files. chokidar / fsevents on macOS sometimes
257
+ // misses appends on deep directory trees or when editors write via atomic
258
+ // rename. This is a cheap safety net — just an `fs.stat` per file and a
259
+ // size/inode comparison. Catches missed change events without re-reading
260
+ // any data unless something actually changed.
261
+ this.pollTimer = setInterval(() => this.reconcileTrackedFiles(), 2000);
262
+ this.pollTimer.unref?.();
263
+ // 10s tick: scan watched directories for NEW *.jsonl files chokidar's
264
+ // `add` event might have missed. Less frequent because directory walks
265
+ // are heavier than per-file stats.
266
+ this.rescanTimer = setInterval(() => this.rescanWatchedDirs(), 10_000);
267
+ this.rescanTimer.unref?.();
252
268
  // If we found absolutely nothing, surface that clearly to the operator.
253
269
  if (!this.claudeDir && !this.coworkDir) {
254
270
  this.skipStartup("no Claude or Cowork data found in any candidate location");
255
271
  }
256
272
  }
257
273
  async onStop() {
274
+ if (this.pollTimer) {
275
+ clearInterval(this.pollTimer);
276
+ this.pollTimer = null;
277
+ }
278
+ if (this.rescanTimer) {
279
+ clearInterval(this.rescanTimer);
280
+ this.rescanTimer = null;
281
+ }
258
282
  await this.projectWatcher?.close();
259
283
  await this.coworkWatcher?.close();
260
284
  await this.historyWatcher?.close();
261
285
  }
286
+ /**
287
+ * Stat every tracked file and re-read any whose on-disk size or inode has
288
+ * drifted past our cached TailState. Catches `change` events chokidar misses
289
+ * (macOS fsevents on deep directories, atomic-rename writes, etc.).
290
+ */
291
+ async reconcileTrackedFiles() {
292
+ if (this.files.size === 0)
293
+ return;
294
+ // Snapshot entries up front because onFileChange may mutate this.files.
295
+ const entries = Array.from(this.files.values());
296
+ for (const entry of entries) {
297
+ if (entry.pending || entry.queued)
298
+ continue;
299
+ if (!entry.state)
300
+ continue; // not yet initially-consumed; chokidar add will handle it
301
+ let st = null;
302
+ try {
303
+ st = (await fs.stat(entry.filePath));
304
+ }
305
+ catch {
306
+ // File vanished — chokidar's unlink handler will clean up; skip.
307
+ continue;
308
+ }
309
+ if (!st)
310
+ continue;
311
+ const rotated = st.ino !== entry.state.inode;
312
+ const grew = st.size > entry.state.offset;
313
+ const shrank = st.size < entry.state.offset;
314
+ if (rotated || grew || shrank) {
315
+ // Reuse the normal change pipeline so dedupe / queueing still apply.
316
+ this.onFileChange(entry.filePath, entry.source, "poll").catch((err) => {
317
+ this.log(`[watcher poll] ${entry.filePath}: ${err.message}`);
318
+ });
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Walk the watched roots for any *.jsonl files we don't already have in
324
+ * `this.files`. Catches `add` events chokidar misses.
325
+ */
326
+ async rescanWatchedDirs() {
327
+ const roots = [];
328
+ if (this.claudeDir)
329
+ roots.push([this.claudeDir, "claude-code"]);
330
+ if (this.coworkDir)
331
+ roots.push([this.coworkDir, "cowork"]);
332
+ for (const [root, source] of roots) {
333
+ const found = [];
334
+ try {
335
+ await walk(root, found);
336
+ }
337
+ catch {
338
+ continue;
339
+ }
340
+ for (const file of found) {
341
+ if (!file.endsWith(".jsonl"))
342
+ continue;
343
+ if (this.files.has(file))
344
+ continue;
345
+ // New file we missed — feed it through the normal pipeline.
346
+ this.onFileChange(file, source, "rescan").catch((err) => {
347
+ this.log(`[watcher rescan] ${file}: ${err.message}`);
348
+ });
349
+ }
350
+ }
351
+ }
262
352
  async initialScan(root, source) {
263
353
  const found = [];
264
354
  await walk(root, found);