@threadbase-sh/streamer 1.36.2 → 1.36.4

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.cjs CHANGED
@@ -1449,6 +1449,9 @@ var import_fs6 = require("fs");
1449
1449
  var import_path6 = require("path");
1450
1450
 
1451
1451
  // src/services/questions/detectPermissionGate.ts
1452
+ function permissionContentKey(gate) {
1453
+ return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
1454
+ }
1452
1455
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
1453
1456
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
1454
1457
  function hasPermissionOsc(rawData) {
@@ -6884,6 +6887,11 @@ var StreamerServer = class {
6884
6887
  // to pendingQuestions; mobile answers it by sending the option index via
6885
6888
  // /input { keys }. Cleared when the gate closes.
6886
6889
  pendingPermission = /* @__PURE__ */ new Map();
6890
+ // Content key (prompt + detail + options + cursor) of the permission gate
6891
+ // currently broadcast for a session — mirrors pendingQuestionKey so a PTY
6892
+ // repaint of the same gate doesn't re-broadcast on every tick. Cleared
6893
+ // alongside pendingPermission.
6894
+ pendingPermissionKey = /* @__PURE__ */ new Map();
6887
6895
  scanner = null;
6888
6896
  // Set when better-sqlite3 is unusable (e.g. node ABI mismatch made
6889
6897
  // ConversationCache.open throw), or when config.scannerPersistent is false
@@ -6899,6 +6907,10 @@ var StreamerServer = class {
6899
6907
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
6900
6908
  // a single rescan after the current one completes instead of restarting it.
6901
6909
  scannerStale = false;
6910
+ // Single-flight guard for the background disk reconcile: a burst of list
6911
+ // polls during active session writes shares one rescan instead of queueing
6912
+ // a full rescan per request.
6913
+ conversationReconcileInFlight = null;
6902
6914
  // Single-flight + TTL guard around scanner.refreshFile (see refreshFileGuarded).
6903
6915
  // A live file's mtime is always newer than the snapshot, so an unguarded
6904
6916
  // refresh fires on every request and re-parses the whole file from byte 0.
@@ -7177,6 +7189,7 @@ var StreamerServer = class {
7177
7189
  this.cancelPendingQuestion(session.id);
7178
7190
  }
7179
7191
  this.pendingPermission.delete(session.id);
7192
+ this.pendingPermissionKey.delete(session.id);
7180
7193
  this.contendedSessions.delete(session.id);
7181
7194
  this.rememberSelfPtyEnded(session.id);
7182
7195
  }
@@ -7916,9 +7929,9 @@ var StreamerServer = class {
7916
7929
  * the automatic freshness path when the directory watcher marked the scanner
7917
7930
  * stale or shouldRefreshProjectsFromHdd detected disk drift.
7918
7931
  */
7919
- async reconcileConversationsCacheFromDisk() {
7932
+ async reconcileConversationsCacheFromDisk(onProgress) {
7920
7933
  if (!this.cache) return;
7921
- const scanner = await this.rescanForRefresh();
7934
+ const scanner = await this.rescanForRefresh(onProgress);
7922
7935
  const metas = [...scanner.getMetadataCache().values()];
7923
7936
  try {
7924
7937
  this.cache.upsertFromScannerMeta(metas);
@@ -7946,6 +7959,18 @@ var StreamerServer = class {
7946
7959
  );
7947
7960
  }
7948
7961
  }
7962
+ // Reconcile the cache from disk without blocking the caller. Single-flighted
7963
+ // so a burst of list polls during active session writes shares one rescan
7964
+ // rather than queueing a full rescan each; tracked so close() awaits the
7965
+ // in-flight cache write before shutting the DB.
7966
+ startBackgroundConversationReconcile() {
7967
+ if (this.conversationReconcileInFlight) return;
7968
+ const task = this.reconcileConversationsCacheFromDisk().finally(() => {
7969
+ this.conversationReconcileInFlight = null;
7970
+ });
7971
+ this.conversationReconcileInFlight = task;
7972
+ this.trackCacheWrite(task);
7973
+ }
7949
7974
  shouldAutoReconcileConversationList() {
7950
7975
  if (!this.cache) return false;
7951
7976
  if (this.scannerStale) return true;
@@ -7963,13 +7988,19 @@ var StreamerServer = class {
7963
7988
  const providerFilter = url.searchParams.get("provider") ?? void 0;
7964
7989
  const bustCache = url.searchParams.get("refresh") === "1";
7965
7990
  if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
7966
- if (bustCache) {
7991
+ const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
7992
+ if (canServeStale) {
7993
+ this.startBackgroundConversationReconcile();
7994
+ } else {
7995
+ const shouldEmitProgress = createScanProgressThrottle();
7967
7996
  await this.withWarmup(
7968
7997
  "conversation_refresh",
7969
- () => this.reconcileConversationsCacheFromDisk()
7998
+ () => this.reconcileConversationsCacheFromDisk((scanned, total2) => {
7999
+ if (shouldEmitProgress(scanned, total2)) {
8000
+ this.wsHub.broadcast({ type: "scan_progress", scanned, total: total2 });
8001
+ }
8002
+ })
7970
8003
  );
7971
- } else {
7972
- await this.reconcileConversationsCacheFromDisk();
7973
8004
  }
7974
8005
  }
7975
8006
  if (this.cache) {
@@ -8205,7 +8236,7 @@ var StreamerServer = class {
8205
8236
  // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
8206
8237
  // only ever reassigned to a live scan promise (never nulled mid-scan), so the
8207
8238
  // getScanner() anti-infinite-loop guard is preserved.
8208
- async rescanForRefresh() {
8239
+ async rescanForRefresh(onProgress) {
8209
8240
  if (this.scannerReady) await this.scannerReady;
8210
8241
  this.scannerStale = false;
8211
8242
  if (!this.scanner) {
@@ -8216,7 +8247,8 @@ var StreamerServer = class {
8216
8247
  this.scannerReady = scanner.scan({
8217
8248
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
8218
8249
  ...this.codexScanOpts(),
8219
- fullRescan: true
8250
+ fullRescan: true,
8251
+ ...onProgress ? { onProgress } : {}
8220
8252
  });
8221
8253
  await this.scannerReady;
8222
8254
  return scanner;
@@ -9194,10 +9226,14 @@ var StreamerServer = class {
9194
9226
  if (gate === null) {
9195
9227
  if (!this.pendingPermission.has(sessionId)) return;
9196
9228
  this.pendingPermission.delete(sessionId);
9229
+ this.pendingPermissionKey.delete(sessionId);
9197
9230
  this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
9198
9231
  return;
9199
9232
  }
9233
+ const key = permissionContentKey(gate);
9234
+ if (this.pendingPermissionKey.get(sessionId) === key) return;
9200
9235
  this.pendingPermission.set(sessionId, gate);
9236
+ this.pendingPermissionKey.set(sessionId, key);
9201
9237
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
9202
9238
  this.log.info(
9203
9239
  `[ws.broadcast_permission] ${sessionId.slice(0, 8)} subscribers=${subscriberCount}`,