@threadbase-sh/streamer 1.24.4 → 1.24.6

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/dist/index.d.cts CHANGED
@@ -515,6 +515,34 @@ declare class ConversationCache {
515
515
  * JSONL has been deleted.
516
516
  */
517
517
  pruneGhostFiles(exists?: (filePath: string) => boolean): string[];
518
+ /**
519
+ * Reconcile the cache against the authoritative set of conversation file
520
+ * paths a fresh scan surfaced: drop any cached row whose `file_path` is not
521
+ * in `livePaths` (removed from disk, or now filtered out — e.g. became an
522
+ * agent JSONL). This is the "removed conversations" half of a ?refresh=1
523
+ * reconcile; the additions/updates half is upsertFromScannerMeta.
524
+ *
525
+ * Skip semantics depend on whether the file still exists on disk:
526
+ * - File GONE from disk → always removed, tail or not. This matches the old
527
+ * invalidate()+rebuild behavior (a deleted conversation must disappear on
528
+ * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
529
+ * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
530
+ * so their cached history stays viewable on a background prune. refresh=1
531
+ * has the opposite contract (mobile relies on removals being reflected), so
532
+ * do not "unify" the two — they serve different purposes.
533
+ * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
534
+ * the scan snapshot predates a just-created (and now live-tailed) file.
535
+ * A tailed row here is actively maintained from real content, so it is
536
+ * kept — dropping it would flicker the active conversation out of
537
+ * /api/conversations. An untailed on-disk row not in the snapshot is a
538
+ * transient scan/discovery gap; it is left alone (not removed) and the
539
+ * next reconcile picks it up, rather than risk removing a real file the
540
+ * scan simply hasn't surfaced yet.
541
+ * Returns the removed IDs.
542
+ */
543
+ reconcileDeletions(livePaths: Set<string>, opts?: {
544
+ exists?: (filePath: string) => boolean;
545
+ }): string[];
518
546
  }
519
547
 
520
548
  type CacheMetadataKey = "last_conversation_id" | "last_conversation_created_at" | "projects_last_indexed_at" | "conversations_last_indexed_at" | "conversations_dirty";
@@ -840,6 +868,7 @@ declare class StreamerServer {
840
868
  private scannerStale;
841
869
  private binding;
842
870
  private cacheReady;
871
+ private inFlightCacheWrites;
843
872
  private apiKey;
844
873
  private apiKeySource;
845
874
  private localNoAuth;
@@ -897,6 +926,7 @@ declare class StreamerServer {
897
926
  }): Promise<void>;
898
927
  private bindWithRetry;
899
928
  private bindWithRetryLoop;
929
+ private trackCacheWrite;
900
930
  close(): Promise<void>;
901
931
  private handleRequest;
902
932
  private handlePairStart;
@@ -917,6 +947,7 @@ declare class StreamerServer {
917
947
  private newScanner;
918
948
  private getScanner;
919
949
  private getFreshScanner;
950
+ private rescanForRefresh;
920
951
  private findJsonlPath;
921
952
  private readCwdFromJsonl;
922
953
  private findConversationByUuid;
package/dist/index.d.ts CHANGED
@@ -515,6 +515,34 @@ declare class ConversationCache {
515
515
  * JSONL has been deleted.
516
516
  */
517
517
  pruneGhostFiles(exists?: (filePath: string) => boolean): string[];
518
+ /**
519
+ * Reconcile the cache against the authoritative set of conversation file
520
+ * paths a fresh scan surfaced: drop any cached row whose `file_path` is not
521
+ * in `livePaths` (removed from disk, or now filtered out — e.g. became an
522
+ * agent JSONL). This is the "removed conversations" half of a ?refresh=1
523
+ * reconcile; the additions/updates half is upsertFromScannerMeta.
524
+ *
525
+ * Skip semantics depend on whether the file still exists on disk:
526
+ * - File GONE from disk → always removed, tail or not. This matches the old
527
+ * invalidate()+rebuild behavior (a deleted conversation must disappear on
528
+ * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
529
+ * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
530
+ * so their cached history stays viewable on a background prune. refresh=1
531
+ * has the opposite contract (mobile relies on removals being reflected), so
532
+ * do not "unify" the two — they serve different purposes.
533
+ * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
534
+ * the scan snapshot predates a just-created (and now live-tailed) file.
535
+ * A tailed row here is actively maintained from real content, so it is
536
+ * kept — dropping it would flicker the active conversation out of
537
+ * /api/conversations. An untailed on-disk row not in the snapshot is a
538
+ * transient scan/discovery gap; it is left alone (not removed) and the
539
+ * next reconcile picks it up, rather than risk removing a real file the
540
+ * scan simply hasn't surfaced yet.
541
+ * Returns the removed IDs.
542
+ */
543
+ reconcileDeletions(livePaths: Set<string>, opts?: {
544
+ exists?: (filePath: string) => boolean;
545
+ }): string[];
518
546
  }
519
547
 
520
548
  type CacheMetadataKey = "last_conversation_id" | "last_conversation_created_at" | "projects_last_indexed_at" | "conversations_last_indexed_at" | "conversations_dirty";
@@ -840,6 +868,7 @@ declare class StreamerServer {
840
868
  private scannerStale;
841
869
  private binding;
842
870
  private cacheReady;
871
+ private inFlightCacheWrites;
843
872
  private apiKey;
844
873
  private apiKeySource;
845
874
  private localNoAuth;
@@ -897,6 +926,7 @@ declare class StreamerServer {
897
926
  }): Promise<void>;
898
927
  private bindWithRetry;
899
928
  private bindWithRetryLoop;
929
+ private trackCacheWrite;
900
930
  close(): Promise<void>;
901
931
  private handleRequest;
902
932
  private handlePairStart;
@@ -917,6 +947,7 @@ declare class StreamerServer {
917
947
  private newScanner;
918
948
  private getScanner;
919
949
  private getFreshScanner;
950
+ private rescanForRefresh;
920
951
  private findJsonlPath;
921
952
  private readCwdFromJsonl;
922
953
  private findConversationByUuid;
package/dist/index.js CHANGED
@@ -3768,6 +3768,61 @@ var ConversationCache = class _ConversationCache {
3768
3768
  }
3769
3769
  return ghosts;
3770
3770
  }
3771
+ /**
3772
+ * Reconcile the cache against the authoritative set of conversation file
3773
+ * paths a fresh scan surfaced: drop any cached row whose `file_path` is not
3774
+ * in `livePaths` (removed from disk, or now filtered out — e.g. became an
3775
+ * agent JSONL). This is the "removed conversations" half of a ?refresh=1
3776
+ * reconcile; the additions/updates half is upsertFromScannerMeta.
3777
+ *
3778
+ * Skip semantics depend on whether the file still exists on disk:
3779
+ * - File GONE from disk → always removed, tail or not. This matches the old
3780
+ * invalidate()+rebuild behavior (a deleted conversation must disappear on
3781
+ * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
3782
+ * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
3783
+ * so their cached history stays viewable on a background prune. refresh=1
3784
+ * has the opposite contract (mobile relies on removals being reflected), so
3785
+ * do not "unify" the two — they serve different purposes.
3786
+ * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
3787
+ * the scan snapshot predates a just-created (and now live-tailed) file.
3788
+ * A tailed row here is actively maintained from real content, so it is
3789
+ * kept — dropping it would flicker the active conversation out of
3790
+ * /api/conversations. An untailed on-disk row not in the snapshot is a
3791
+ * transient scan/discovery gap; it is left alone (not removed) and the
3792
+ * next reconcile picks it up, rather than risk removing a real file the
3793
+ * scan simply hasn't surfaced yet.
3794
+ * Returns the removed IDs.
3795
+ */
3796
+ reconcileDeletions(livePaths, opts) {
3797
+ const exists = opts?.exists ?? existsSync5;
3798
+ const rows = this.stmts.allFilePaths.all();
3799
+ const removed = [];
3800
+ const drop = this.db.transaction((ids) => {
3801
+ for (const id of ids) {
3802
+ this.stmts.deleteTailById.run(id);
3803
+ this.stmts.deleteById.run(id);
3804
+ }
3805
+ });
3806
+ for (const row of rows) {
3807
+ if (livePaths.has(row.file_path)) continue;
3808
+ if (exists(row.file_path)) continue;
3809
+ removed.push(row.id);
3810
+ }
3811
+ if (removed.length > 0) {
3812
+ drop(removed);
3813
+ if (this.fileIndexLoaded) {
3814
+ for (const id of removed) {
3815
+ for (const [fp, cid] of this.fileIndex) {
3816
+ if (cid === id) {
3817
+ this.fileIndex.delete(fp);
3818
+ break;
3819
+ }
3820
+ }
3821
+ }
3822
+ }
3823
+ }
3824
+ return removed;
3825
+ }
3771
3826
  };
3772
3827
 
3773
3828
  // src/db/repositories/cacheMetadata.repository.ts
@@ -4845,6 +4900,14 @@ var StreamerServer = class {
4845
4900
  // window so the self-healing kickstart-relaunch race doesn't spam warn.
4846
4901
  binding = false;
4847
4902
  cacheReady = false;
4903
+ // Every fire-and-forget task that runs a scan and then writes to this.cache
4904
+ // in an async continuation (startup warm-up, background count refresh, …).
4905
+ // close() awaits all of them before closing this.cache, so a scan's post-scan
4906
+ // cache writes (upsertFromScannerMeta / populateTailFromFile / pruneGhostFiles
4907
+ // / reconcileDeletions) can never hit a cache.db that was already closed
4908
+ // ("database connection is not open"), which would otherwise leave the cache
4909
+ // empty. Register via trackCacheWrite(); each entry removes itself on settle.
4910
+ inFlightCacheWrites = /* @__PURE__ */ new Set();
4848
4911
  apiKey;
4849
4912
  apiKeySource;
4850
4913
  localNoAuth;
@@ -5413,6 +5476,7 @@ var StreamerServer = class {
5413
5476
  });
5414
5477
  }
5415
5478
  });
5479
+ this.trackCacheWrite(warmUp);
5416
5480
  if (opts?.awaitReady) await warmUp;
5417
5481
  }
5418
5482
  // Bind the HTTP listener, retrying on a transient EADDRINUSE. See the call
@@ -5460,11 +5524,23 @@ var StreamerServer = class {
5460
5524
  }
5461
5525
  }
5462
5526
  }
5527
+ // Register a fire-and-forget task that writes to this.cache after a scan, so
5528
+ // close() can await it before closing cache.db. Removes itself on settle. The
5529
+ // caller keeps its own error handling; this wrapper swallows rejections so a
5530
+ // failed task never rejects close()'s Promise.all.
5531
+ trackCacheWrite(task) {
5532
+ const guarded = task.catch(() => void 0);
5533
+ this.inFlightCacheWrites.add(guarded);
5534
+ void guarded.finally(() => {
5535
+ this.inFlightCacheWrites.delete(guarded);
5536
+ });
5537
+ }
5463
5538
  async close() {
5464
5539
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
5465
5540
  this.ptyGraceTimers.clear();
5466
5541
  this.markScannerStaleDebounced.cancel();
5467
- for (const s of this.allScanners) s.close();
5542
+ await Promise.all([...this.inFlightCacheWrites]);
5543
+ await Promise.all([...this.allScanners].map((s) => s.close()));
5468
5544
  this.allScanners.clear();
5469
5545
  this.scanner = null;
5470
5546
  this.cache?.close();
@@ -5604,12 +5680,23 @@ var StreamerServer = class {
5604
5680
  const project = url.searchParams.get("project") ?? void 0;
5605
5681
  const providerFilter = url.searchParams.get("provider") ?? void 0;
5606
5682
  const bustCache = url.searchParams.get("refresh") === "1";
5607
- if (bustCache) {
5608
- this.cache?.invalidate();
5609
- this.scanner = null;
5610
- this.scannerReady = null;
5683
+ if (bustCache && this.cache) {
5684
+ const scanner2 = await this.rescanForRefresh();
5685
+ const metas2 = [...scanner2.getMetadataCache().values()];
5686
+ try {
5687
+ this.cache.upsertFromScannerMeta(metas2);
5688
+ const livePaths = new Set(
5689
+ metas2.map((m) => m.filePath).filter((p) => Boolean(p))
5690
+ );
5691
+ this.cache.reconcileDeletions(livePaths);
5692
+ } catch (err) {
5693
+ this.log.warn(
5694
+ `refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
5695
+ { event: "conversations.reconcile_failed" }
5696
+ );
5697
+ }
5611
5698
  }
5612
- if (this.cache && !bustCache) {
5699
+ if (this.cache) {
5613
5700
  const { conversations, total: total2 } = this.cache.listConversations({
5614
5701
  project,
5615
5702
  provider: providerFilter,
@@ -5674,12 +5761,6 @@ var StreamerServer = class {
5674
5761
  };
5675
5762
  });
5676
5763
  json(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
5677
- if (this.cache && bustCache) {
5678
- try {
5679
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5680
- } catch {
5681
- }
5682
- }
5683
5764
  }
5684
5765
  async handleConversationsCount(url, res) {
5685
5766
  const project = url.searchParams.get("project") ?? void 0;
@@ -5708,19 +5789,21 @@ var StreamerServer = class {
5708
5789
  // later count reflects new/removed conversations. Never awaited by the request
5709
5790
  // path — refresh=1 returns the cached total synchronously and this catches up.
5710
5791
  refreshCountInBackground() {
5711
- void (async () => {
5712
- try {
5713
- const scanner = await this.getFreshScanner();
5714
- if (this.cache) {
5715
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5792
+ this.trackCacheWrite(
5793
+ (async () => {
5794
+ try {
5795
+ const scanner = await this.getFreshScanner();
5796
+ if (this.cache) {
5797
+ this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5798
+ }
5799
+ } catch (err) {
5800
+ this.log.warn(
5801
+ `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
5802
+ { event: "count.refresh_failed" }
5803
+ );
5716
5804
  }
5717
- } catch (err) {
5718
- this.log.warn(
5719
- `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
5720
- { event: "count.refresh_failed" }
5721
- );
5722
- }
5723
- })();
5805
+ })()
5806
+ );
5724
5807
  }
5725
5808
  handleSessionsCount(res) {
5726
5809
  json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
@@ -5833,6 +5916,30 @@ var StreamerServer = class {
5833
5916
  this.scannerReady = null;
5834
5917
  return this.getScanner();
5835
5918
  }
5919
+ // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
5920
+ // survive, so classify() still skips unchanged files) and re-run its scan
5921
+ // with fullRescan:true — the escape hatch that bypasses the scanner's
5922
+ // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
5923
+ // the "don't trust the gate, check disk for real" signal. Unlike
5924
+ // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
5925
+ // only ever reassigned to a live scan promise (never nulled mid-scan), so the
5926
+ // getScanner() anti-infinite-loop guard is preserved.
5927
+ async rescanForRefresh() {
5928
+ if (this.scannerReady) await this.scannerReady;
5929
+ this.scannerStale = false;
5930
+ if (!this.scanner) {
5931
+ this.scanner = new ConversationScanner();
5932
+ this.allScanners.add(this.scanner);
5933
+ }
5934
+ const scanner = this.scanner;
5935
+ this.scannerReady = scanner.scan({
5936
+ ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
5937
+ ...this.codexScanOpts(),
5938
+ fullRescan: true
5939
+ });
5940
+ await this.scannerReady;
5941
+ return scanner;
5942
+ }
5836
5943
  findJsonlPath(uuid) {
5837
5944
  const projectsDir = join12(homedir5(), ".claude", "projects");
5838
5945
  if (!existsSync7(projectsDir)) return null;