@threadbase-sh/streamer 1.24.3 → 1.24.5

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/cli.cjs CHANGED
@@ -101524,7 +101524,7 @@ var require_async2 = __commonJS({
101524
101524
  readdirWithFileTypes(directory, settings, callback);
101525
101525
  return;
101526
101526
  }
101527
- readdir6(directory, settings, callback);
101527
+ readdir7(directory, settings, callback);
101528
101528
  }
101529
101529
  exports2.read = read;
101530
101530
  function readdirWithFileTypes(directory, settings, callback) {
@@ -101573,7 +101573,7 @@ var require_async2 = __commonJS({
101573
101573
  });
101574
101574
  };
101575
101575
  }
101576
- function readdir6(directory, settings, callback) {
101576
+ function readdir7(directory, settings, callback) {
101577
101577
  settings.fs.readdir(directory, (readdirError, names) => {
101578
101578
  if (readdirError !== null) {
101579
101579
  callFailureCallback(callback, readdirError);
@@ -101608,7 +101608,7 @@ var require_async2 = __commonJS({
101608
101608
  });
101609
101609
  });
101610
101610
  }
101611
- exports2.readdir = readdir6;
101611
+ exports2.readdir = readdir7;
101612
101612
  function callFailureCallback(callback, error51) {
101613
101613
  callback(error51);
101614
101614
  }
@@ -101632,7 +101632,7 @@ var require_sync2 = __commonJS({
101632
101632
  if (!settings.stats && constants_1.IS_SUPPORT_READDIR_WITH_FILE_TYPES) {
101633
101633
  return readdirWithFileTypes(directory, settings);
101634
101634
  }
101635
- return readdir6(directory, settings);
101635
+ return readdir7(directory, settings);
101636
101636
  }
101637
101637
  exports2.read = read;
101638
101638
  function readdirWithFileTypes(directory, settings) {
@@ -101657,7 +101657,7 @@ var require_sync2 = __commonJS({
101657
101657
  });
101658
101658
  }
101659
101659
  exports2.readdirWithFileTypes = readdirWithFileTypes;
101660
- function readdir6(directory, settings) {
101660
+ function readdir7(directory, settings) {
101661
101661
  const names = settings.fs.readdirSync(directory);
101662
101662
  return names.map((name) => {
101663
101663
  const entryPath = common.joinPathSegments(directory, name, settings.pathSegmentSeparator);
@@ -101673,7 +101673,7 @@ var require_sync2 = __commonJS({
101673
101673
  return entry;
101674
101674
  });
101675
101675
  }
101676
- exports2.readdir = readdir6;
101676
+ exports2.readdir = readdir7;
101677
101677
  }
101678
101678
  });
101679
101679
 
@@ -124915,14 +124915,14 @@ var prompt_exports = {};
124915
124915
  __export(prompt_exports, {
124916
124916
  interactivePrompt: () => interactivePrompt
124917
124917
  });
124918
- var import_node_process2, import_promises17, interactivePrompt;
124918
+ var import_node_process2, import_promises18, interactivePrompt;
124919
124919
  var init_prompt = __esm({
124920
124920
  "src/lifecycle/prompt.ts"() {
124921
124921
  "use strict";
124922
124922
  import_node_process2 = require("process");
124923
- import_promises17 = require("readline/promises");
124923
+ import_promises18 = require("readline/promises");
124924
124924
  interactivePrompt = async ({ prodPort, suggestedAltPort }) => {
124925
- const rl = (0, import_promises17.createInterface)({ input: import_node_process2.stdin, output: import_node_process2.stdout });
124925
+ const rl = (0, import_promises18.createInterface)({ input: import_node_process2.stdin, output: import_node_process2.stdout });
124926
124926
  try {
124927
124927
  import_node_process2.stdout.write(
124928
124928
  `
@@ -129481,6 +129481,7 @@ var import_readline3 = require("readline");
129481
129481
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
129482
129482
  var import_fs11 = require("fs");
129483
129483
  var import_path8 = require("path");
129484
+ var import_promises7 = require("fs/promises");
129484
129485
  var import_fs12 = require("fs");
129485
129486
  var import_fs13 = require("fs");
129486
129487
  var import_path9 = require("path");
@@ -132302,30 +132303,33 @@ function fingerprint(filePath, size) {
132302
132303
  return hash2.digest("hex");
132303
132304
  }
132304
132305
  function classify(filePath, existing) {
132305
- let stat32;
132306
+ let stat42;
132306
132307
  try {
132307
132308
  const s3 = (0, import_fs9.statSync)(filePath);
132308
- stat32 = { size: s3.size, mtimeMs: s3.mtimeMs };
132309
+ stat42 = { size: s3.size, mtimeMs: s3.mtimeMs };
132309
132310
  } catch {
132310
132311
  return { change: "vanished" };
132311
132312
  }
132312
132313
  if (!existing || existing.status !== "active" || existing.last_indexed_offset === 0) {
132313
- return { change: "reindex", stat: stat32 };
132314
+ return { change: "reindex", stat: stat42 };
132314
132315
  }
132315
- if (stat32.size < existing.last_indexed_offset) {
132316
- return { change: "reindex", stat: stat32 };
132316
+ if (stat42.size < existing.last_indexed_offset) {
132317
+ return { change: "reindex", stat: stat42 };
132317
132318
  }
132318
- if (stat32.size === existing.size_bytes && stat32.mtimeMs === existing.mtime_ms) {
132319
- return { change: "unchanged", stat: stat32 };
132319
+ if (stat42.size === existing.size_bytes && stat42.mtimeMs === existing.mtime_ms) {
132320
+ return { change: "unchanged", stat: stat42 };
132320
132321
  }
132321
- if (stat32.size === existing.last_indexed_offset) {
132322
- const fp = fingerprint(filePath, stat32.size);
132322
+ if (stat42.size === existing.last_indexed_offset) {
132323
+ const fp = fingerprint(filePath, stat42.size);
132323
132324
  if (existing.content_fingerprint && fp !== existing.content_fingerprint) {
132324
- return { change: "reindex", stat: stat32 };
132325
+ return { change: "reindex", stat: stat42 };
132325
132326
  }
132326
- return { change: "unchanged", stat: stat32 };
132327
+ return { change: "unchanged", stat: stat42 };
132328
+ }
132329
+ if (existing.content_fingerprint && existing.size_bytes === existing.last_indexed_offset && fingerprint(filePath, existing.last_indexed_offset) === existing.content_fingerprint) {
132330
+ return { change: "appended", stat: stat42 };
132327
132331
  }
132328
- return { change: "appended", stat: stat32 };
132332
+ return { change: "reindex", stat: stat42 };
132329
132333
  }
132330
132334
  async function parseMetaWithProvider(provider, filePath, account, tier) {
132331
132335
  const log8 = getLogger2();
@@ -132368,7 +132372,7 @@ function resolveTier(tierName, customTiers) {
132368
132372
  }
132369
132373
  return tier;
132370
132374
  }
132371
- var SCHEMA_VERSION = 3;
132375
+ var SCHEMA_VERSION = 4;
132372
132376
  var SCHEMA_SQL = `
132373
132377
  CREATE TABLE IF NOT EXISTS conversation_files (
132374
132378
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -132502,6 +132506,24 @@ CREATE TABLE IF NOT EXISTS message_checkpoints (
132502
132506
 
132503
132507
  CREATE INDEX IF NOT EXISTS idx_message_checkpoints_lookup
132504
132508
  ON message_checkpoints(source_path, message_index);
132509
+
132510
+ -- Per-directory mtime watermark for the discovery dir-mtime gate (skips the
132511
+ -- glob for a directory whose file/subdir SET hasn't changed; never skips
132512
+ -- per-file classify()). One row for a profile's projectsDir itself (parent_root
132513
+ -- IS NULL) and one row per immediate project subdirectory discovered under it
132514
+ -- (parent_root = the projectsDir path). has_nested marks a project dir known to
132515
+ -- contain files below its own top level (e.g. subagents/) \u2014 those always
132516
+ -- re-glob regardless of mtime, since a project-dir-level watermark can't see a
132517
+ -- change two levels down.
132518
+ CREATE TABLE IF NOT EXISTS scanned_dirs (
132519
+ path TEXT PRIMARY KEY,
132520
+ parent_root TEXT,
132521
+ mtime_ms INTEGER NOT NULL,
132522
+ has_nested INTEGER NOT NULL DEFAULT 0,
132523
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
132524
+ );
132525
+
132526
+ CREATE INDEX IF NOT EXISTS idx_scanned_dirs_parent_root ON scanned_dirs(parent_root);
132505
132527
  `;
132506
132528
  function runMigrations(db) {
132507
132529
  const current = db.pragma("user_version", { simple: true });
@@ -132547,6 +132569,82 @@ function openDatabase(dbPath) {
132547
132569
  getLogger2().debug({ dbPath }, "db: opened");
132548
132570
  return db;
132549
132571
  }
132572
+ var FULL_RECONCILE_EVERY_N_SCANS = 20;
132573
+ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
132574
+ const log8 = getLogger2();
132575
+ if (options.fullRescan) {
132576
+ return discoverJsonlFiles(dirs);
132577
+ }
132578
+ const results = [];
132579
+ for (const rawDir of dirs) {
132580
+ const { account } = rawDir;
132581
+ const projectsDir = rawDir.projectsDir.replace(/\\/g, "/");
132582
+ const resolved = await resolveProjectDirs(projectsDir, scannedDirs);
132583
+ if (resolved === null) continue;
132584
+ for (const projectDir of resolved.entries) {
132585
+ let dirStat;
132586
+ try {
132587
+ dirStat = await (0, import_promises7.stat)(projectDir);
132588
+ } catch {
132589
+ scannedDirs.remove(projectDir);
132590
+ continue;
132591
+ }
132592
+ const watermark = scannedDirs.get(projectDir);
132593
+ const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
132594
+ if (canReuse) {
132595
+ const known = files.activePathsByParentDir(projectDir);
132596
+ if (known.length > 0) {
132597
+ for (const row of known) {
132598
+ results.push({ filePath: row.absolute_path, account: row.account });
132599
+ }
132600
+ continue;
132601
+ }
132602
+ }
132603
+ const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
132604
+ const hasNested = found.some((f2) => dirnameOf(f2.filePath) !== projectDir);
132605
+ scannedDirs.upsert(projectDir, projectsDir, dirStat.mtimeMs, hasNested);
132606
+ results.push(...found);
132607
+ }
132608
+ if (resolved.commitRoot) {
132609
+ scannedDirs.upsert(projectsDir, null, resolved.commitRoot.mtimeMs, false);
132610
+ const seen = new Set(resolved.entries);
132611
+ for (const known of scannedDirs.childrenOf(projectsDir)) {
132612
+ if (!seen.has(known.path)) scannedDirs.remove(known.path);
132613
+ }
132614
+ }
132615
+ }
132616
+ log8.debug(
132617
+ { totalFiles: results.length, dirs: dirs.length },
132618
+ "dir-watermark: gated discovery complete"
132619
+ );
132620
+ return results;
132621
+ }
132622
+ async function resolveProjectDirs(projectsDir, scannedDirs) {
132623
+ let rootStat;
132624
+ try {
132625
+ rootStat = await (0, import_promises7.stat)(projectsDir);
132626
+ } catch {
132627
+ return null;
132628
+ }
132629
+ const rootWatermark = scannedDirs.get(projectsDir);
132630
+ if (rootWatermark !== void 0 && rootWatermark.mtime_ms === rootStat.mtimeMs) {
132631
+ return { entries: scannedDirs.childrenOf(projectsDir).map((row) => row.path) };
132632
+ }
132633
+ let entries;
132634
+ try {
132635
+ entries = (await (0, import_promises7.readdir)(projectsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => joinPath(projectsDir, e.name));
132636
+ } catch {
132637
+ return null;
132638
+ }
132639
+ return { entries, commitRoot: { mtimeMs: rootStat.mtimeMs } };
132640
+ }
132641
+ function dirnameOf(filePath) {
132642
+ const idx = filePath.lastIndexOf("/");
132643
+ return idx === -1 ? filePath : filePath.slice(0, idx);
132644
+ }
132645
+ function joinPath(dir, name) {
132646
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
132647
+ }
132550
132648
  async function tailReduce(filePath, startOffset, startLine, state, tier) {
132551
132649
  const stream = (0, import_fs12.createReadStream)(filePath, { start: startOffset, encoding: "utf8" });
132552
132650
  let buffer = "";
@@ -132743,6 +132841,27 @@ var ConversationFilesRepo = class {
132743
132841
  const rows = this.db.prepare("SELECT absolute_path FROM conversation_files WHERE status != 'deleted'").all();
132744
132842
  return rows.map((r) => r.absolute_path);
132745
132843
  }
132844
+ // Active file paths belonging to any of the given accounts. Backs the
132845
+ // deletion-reconcile so a scan that covered only some accounts can't mark
132846
+ // another account's files deleted (they live in the same shared index.db but
132847
+ // a different profile owns them). Returns [] for an empty account list.
132848
+ activePathsByAccounts(accounts) {
132849
+ if (accounts.length === 0) return [];
132850
+ const placeholders = accounts.map(() => "?").join(", ");
132851
+ const rows = this.db.prepare(
132852
+ `SELECT absolute_path FROM conversation_files
132853
+ WHERE status != 'deleted' AND account IN (${placeholders})`
132854
+ ).all(...accounts);
132855
+ return rows.map((r) => r.absolute_path);
132856
+ }
132857
+ // Active files whose immediate parent is exactly parentDir (no nested
132858
+ // subdirectories). Backs the dir-mtime gate's reuse path: a project dir with
132859
+ // an unchanged mtime and no nested files can skip the glob entirely.
132860
+ activePathsByParentDir(parentDir) {
132861
+ return this.db.prepare(
132862
+ "SELECT absolute_path, account FROM conversation_files WHERE parent_dir = ? AND status != 'deleted'"
132863
+ ).all(parentDir);
132864
+ }
132746
132865
  };
132747
132866
  function rowToMeta(row) {
132748
132867
  return {
@@ -132986,6 +133105,34 @@ function toMatchQuery(query) {
132986
133105
  if (terms.length === 0) return "";
132987
133106
  return terms.map((t2) => `"${t2}"*`).join(" AND ");
132988
133107
  }
133108
+ var ScannedDirsRepo = class {
133109
+ constructor(db) {
133110
+ this.db = db;
133111
+ }
133112
+ db;
133113
+ get(path2) {
133114
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE path = ?").get(path2);
133115
+ }
133116
+ // Known project subdirectories under a root, path ascending (stable order).
133117
+ childrenOf(parentRoot) {
133118
+ return this.db.prepare("SELECT * FROM scanned_dirs WHERE parent_root = ? ORDER BY path ASC").all(parentRoot);
133119
+ }
133120
+ upsert(path2, parentRoot, mtimeMs, hasNested) {
133121
+ this.db.prepare(
133122
+ `INSERT INTO scanned_dirs (path, parent_root, mtime_ms, has_nested, updated_at)
133123
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
133124
+ ON CONFLICT(path) DO UPDATE SET
133125
+ parent_root = excluded.parent_root,
133126
+ mtime_ms = excluded.mtime_ms,
133127
+ has_nested = excluded.has_nested,
133128
+ updated_at = CURRENT_TIMESTAMP`
133129
+ ).run(path2, parentRoot, mtimeMs, hasNested ? 1 : 0);
133130
+ }
133131
+ // Drop a project subdirectory's watermark row (it vanished from disk).
133132
+ remove(path2) {
133133
+ this.db.prepare("DELETE FROM scanned_dirs WHERE path = ?").run(path2);
133134
+ }
133135
+ };
132989
133136
  var BATCH_SIZE = 12;
132990
133137
  var PersistentEngine = class {
132991
133138
  db;
@@ -132993,15 +133140,22 @@ var PersistentEngine = class {
132993
133140
  conversations;
132994
133141
  fts;
132995
133142
  checkpoints;
133143
+ scannedDirs;
132996
133144
  // When true, write a portable <file>.idx.json sidecar next to each indexed
132997
133145
  // JSONL. Off by default.
132998
133146
  sidecar;
133147
+ // Counts indexAll() passes so the dir-mtime gate's full-reconcile backstop
133148
+ // (FULL_RECONCILE_EVERY_N_SCANS) can fire periodically. In-memory only: a
133149
+ // restart just means the first few post-restart scans don't force an early
133150
+ // backstop pass, which is harmless (watermarks themselves persist in the DB).
133151
+ scanCount = 0;
132999
133152
  constructor(dbPath, options = {}) {
133000
133153
  this.db = openDatabase(dbPath);
133001
133154
  this.files = new ConversationFilesRepo(this.db);
133002
133155
  this.conversations = new ConversationsRepo(this.db);
133003
133156
  this.fts = new FtsRepo(this.db);
133004
133157
  this.checkpoints = new CheckpointsRepo(this.db);
133158
+ this.scannedDirs = new ScannedDirsRepo(this.db);
133005
133159
  this.sidecar = options.sidecar ?? false;
133006
133160
  }
133007
133161
  close() {
@@ -133020,7 +133174,12 @@ var PersistentEngine = class {
133020
133174
  projectsDir: getProjectsDir(p2),
133021
133175
  account: p2.id
133022
133176
  }));
133023
- for (const f2 of await discoverJsonlFiles(configDirs)) discovered.push(f2);
133177
+ this.scanCount++;
133178
+ const forceFullGlob = options.fullRescan === true || this.scanCount % FULL_RECONCILE_EVERY_N_SCANS === 0;
133179
+ const gated = await discoverJsonlFilesGated(configDirs, this.files, this.scannedDirs, {
133180
+ fullRescan: forceFullGlob
133181
+ });
133182
+ for (const f2 of gated) discovered.push(f2);
133024
133183
  }
133025
133184
  const codex = new CodexCliProvider();
133026
133185
  if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
@@ -133059,8 +133218,15 @@ var PersistentEngine = class {
133059
133218
  scanned += batch.length;
133060
133219
  options.onProgress?.(scanned, discovered.length);
133061
133220
  }
133221
+ const coveredAccounts = /* @__PURE__ */ new Set();
133222
+ if (enabled.includes(CLAUDE_CODE_PROVIDER)) {
133223
+ for (const p2 of activeProfiles) coveredAccounts.add(p2.id);
133224
+ }
133225
+ if (enabled.includes(CODEX_CLI_PROVIDER) && (options.codexRoots?.length ?? 0) > 0) {
133226
+ coveredAccounts.add("codex");
133227
+ }
133062
133228
  const seen = new Set(discovered.map((d) => d.filePath));
133063
- for (const path2 of this.files.allActivePaths()) {
133229
+ for (const path2 of this.files.activePathsByAccounts([...coveredAccounts])) {
133064
133230
  if (!seen.has(path2)) this.markDeleted(path2);
133065
133231
  }
133066
133232
  log8.info({ scanned, indexed: this.conversations.count() }, "persistent: indexAll complete");
@@ -133075,8 +133241,8 @@ var PersistentEngine = class {
133075
133241
  const log8 = getLogger2();
133076
133242
  const tier = resolveTier(tierName, customTiers);
133077
133243
  const existing = this.files.getByPath(filePath);
133078
- const { change, stat: stat32 } = classify(filePath, existing);
133079
- if (change === "vanished" || !stat32) {
133244
+ const { change, stat: stat42 } = classify(filePath, existing);
133245
+ if (change === "vanished" || !stat42) {
133080
133246
  this.markDeleted(filePath);
133081
133247
  return null;
133082
133248
  }
@@ -133084,7 +133250,7 @@ var PersistentEngine = class {
133084
133250
  return this.conversations.getBySourcePath(filePath);
133085
133251
  }
133086
133252
  if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
133087
- return this.indexFileWithProvider(provider, filePath, account, tier, stat32, resolveGitBranch);
133253
+ return this.indexFileWithProvider(provider, filePath, account, tier, stat42, resolveGitBranch);
133088
133254
  }
133089
133255
  const resume = change === "appended" && !force && existing?.reducer_state;
133090
133256
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
@@ -133103,15 +133269,15 @@ var PersistentEngine = class {
133103
133269
  return null;
133104
133270
  }
133105
133271
  meta3.gitBranch = resolveGitBranch(meta3.projectPath);
133106
- const fp = stat32.size > 0 ? fingerprint(filePath, stat32.size) : null;
133272
+ const fp = stat42.size > 0 ? fingerprint(filePath, stat42.size) : null;
133107
133273
  const fileId = this.files.ensure(filePath, account);
133108
133274
  const upsert = this.db.transaction(() => {
133109
133275
  this.conversations.upsert(fileId, meta3, state.pageMessageCount);
133110
133276
  this.fts.upsert(meta3);
133111
133277
  this.checkpoints.remove(filePath);
133112
133278
  this.files.updateCursor(fileId, {
133113
- sizeBytes: stat32.size,
133114
- mtimeMs: stat32.mtimeMs,
133279
+ sizeBytes: stat42.size,
133280
+ mtimeMs: stat42.mtimeMs,
133115
133281
  // Advance only to the last fully-parsed line; a trailing partial line
133116
133282
  // is left for the next pass.
133117
133283
  offset: result.newOffset,
@@ -133128,8 +133294,8 @@ var PersistentEngine = class {
133128
133294
  buildSidecar(
133129
133295
  meta3,
133130
133296
  {
133131
- sizeBytes: stat32.size,
133132
- mtimeMs: stat32.mtimeMs,
133297
+ sizeBytes: stat42.size,
133298
+ mtimeMs: stat42.mtimeMs,
133133
133299
  offset: result.newOffset,
133134
133300
  line: result.newLine
133135
133301
  },
@@ -133148,7 +133314,7 @@ var PersistentEngine = class {
133148
133314
  // the Threadbase path uses. The cursor records size/mtime (and offset = size)
133149
133315
  // so the next pass classifies an unchanged file as "unchanged" and skips it;
133150
133316
  // any change reparses from 0 again. No reducer_state is persisted.
133151
- async indexFileWithProvider(provider, filePath, account, tier, stat32, resolveGitBranch) {
133317
+ async indexFileWithProvider(provider, filePath, account, tier, stat42, resolveGitBranch) {
133152
133318
  const log8 = getLogger2();
133153
133319
  const meta3 = await parseMetaWithProvider(provider, filePath, account, tier);
133154
133320
  if (!meta3) {
@@ -133158,18 +133324,18 @@ var PersistentEngine = class {
133158
133324
  if (meta3.gitBranch === null && meta3.projectPath) {
133159
133325
  meta3.gitBranch = resolveGitBranch(meta3.projectPath);
133160
133326
  }
133161
- const fp = stat32.size > 0 ? fingerprint(filePath, stat32.size) : null;
133327
+ const fp = stat42.size > 0 ? fingerprint(filePath, stat42.size) : null;
133162
133328
  const fileId = this.files.ensure(filePath, account);
133163
133329
  const upsert = this.db.transaction(() => {
133164
133330
  this.conversations.upsert(fileId, meta3, meta3.messageCount);
133165
133331
  this.fts.upsert(meta3);
133166
133332
  this.checkpoints.remove(filePath);
133167
133333
  this.files.updateCursor(fileId, {
133168
- sizeBytes: stat32.size,
133169
- mtimeMs: stat32.mtimeMs,
133334
+ sizeBytes: stat42.size,
133335
+ mtimeMs: stat42.mtimeMs,
133170
133336
  // offset = size marks the file fully consumed (non-zero so the next pass
133171
133337
  // can classify it "unchanged"); no resumable reducer state is kept.
133172
- offset: stat32.size,
133338
+ offset: stat42.size,
133173
133339
  line: 0,
133174
133340
  reducerState: null,
133175
133341
  fingerprint: fp,
@@ -133363,10 +133529,21 @@ var ConversationScanner = class {
133363
133529
  dbPath;
133364
133530
  sidecarEnabled;
133365
133531
  engineInstance = null;
133532
+ // The most recent scan()'s promise while it is still running, or null when
133533
+ // idle. close() awaits it before closing the SQLite handle so a fire-and-
133534
+ // forget scan can't have its DB shut mid-indexAll ("database connection is
133535
+ // not open"). Tracks the latest scan only — concurrent scans on one instance
133536
+ // aren't a supported pattern (a single writer DB), and the latest resolving
133537
+ // implies earlier ones already settled in practice.
133538
+ inFlightScan = null;
133366
133539
  emitter = new import_events2.EventEmitter();
133367
133540
  watcher = null;
133368
133541
  queue = null;
133369
133542
  periodicTimer = null;
133543
+ // The in-flight periodicReconcile() job, if one is mid-run. unwatch() awaits
133544
+ // it before dropping the queue/engine so its post-await engine.files access
133545
+ // can't hit a closed DB (the watch-mode half of Bug #4).
133546
+ inFlightReconcile = null;
133370
133547
  constructor(options) {
133371
133548
  this.conversationLRU = new LRUCache(options?.conversationCacheSize ?? 5);
133372
133549
  if (options?.persistent === false) {
@@ -133389,14 +133566,31 @@ var ConversationScanner = class {
133389
133566
  return this.engineInstance;
133390
133567
  }
133391
133568
  // Release the SQLite connection. No-op in legacy mode. Safe to call
133392
- // repeatedly. Stops the watcher first if one is running; call unwatch()
133393
- // explicitly beforehand if you need to await watcher teardown.
133394
- close() {
133395
- if (this.watcher || this.queue || this.periodicTimer) void this.unwatch();
133569
+ // repeatedly. Awaits watcher teardown AND any in-flight scan before closing
133570
+ // the DB handle, so a fire-and-forget scan() can never have its connection
133571
+ // shut mid-indexAll() ("database connection is not open"). This is why close()
133572
+ // is async callers should await it during shutdown. (Scanner review Bug #4.)
133573
+ async close() {
133574
+ if (this.watcher || this.queue || this.periodicTimer) await this.unwatch();
133575
+ if (this.inFlightScan) {
133576
+ try {
133577
+ await this.inFlightScan;
133578
+ } catch {
133579
+ }
133580
+ }
133396
133581
  this.engineInstance?.close();
133397
133582
  this.engineInstance = null;
133398
133583
  }
133399
133584
  async scan(options = {}) {
133585
+ const promise2 = this.runScan(options);
133586
+ this.inFlightScan = promise2;
133587
+ try {
133588
+ return await promise2;
133589
+ } finally {
133590
+ if (this.inFlightScan === promise2) this.inFlightScan = null;
133591
+ }
133592
+ }
133593
+ async runScan(options) {
133400
133594
  const profiles = await this.resolveProfiles(options.profiles);
133401
133595
  const activeProfiles = profiles.filter((p2) => p2.enabled && p2.scanHistory !== false);
133402
133596
  this.lastTier = resolveTier(options.tier ?? "standard", options.tiers);
@@ -133710,7 +133904,7 @@ var ConversationScanner = class {
133710
133904
  this.lastTier.name,
133711
133905
  void 0,
133712
133906
  readGitBranch,
133713
- true,
133907
+ false,
133714
133908
  provider
133715
133909
  );
133716
133910
  evict2(meta22);
@@ -133832,7 +134026,10 @@ var ConversationScanner = class {
133832
134026
  const periodicMs = options.periodicMs ?? 6e4;
133833
134027
  if (periodicMs > 0) {
133834
134028
  this.periodicTimer = setInterval(() => {
133835
- void this.periodicReconcile(activeProfiles);
134029
+ const job = this.periodicReconcile(activeProfiles).finally(() => {
134030
+ if (this.inFlightReconcile === job) this.inFlightReconcile = null;
134031
+ });
134032
+ this.inFlightReconcile = job;
133836
134033
  }, periodicMs);
133837
134034
  this.periodicTimer.unref?.();
133838
134035
  }
@@ -133876,6 +134073,9 @@ var ConversationScanner = class {
133876
134073
  clearInterval(this.periodicTimer);
133877
134074
  this.periodicTimer = null;
133878
134075
  }
134076
+ if (this.inFlightReconcile) {
134077
+ await this.inFlightReconcile;
134078
+ }
133879
134079
  if (this.watcher) {
133880
134080
  await this.watcher.stop();
133881
134081
  this.watcher = null;
@@ -134018,7 +134218,7 @@ async function search(query, options, scanner) {
134018
134218
  // src/server.ts
134019
134219
  var import_events3 = require("events");
134020
134220
  var import_fs24 = require("fs");
134021
- var import_promises14 = require("fs/promises");
134221
+ var import_promises15 = require("fs/promises");
134022
134222
  var import_http = require("http");
134023
134223
  var import_os10 = require("os");
134024
134224
  var import_path21 = require("path");
@@ -134110,7 +134310,7 @@ function readAgentConfig(env = process.env) {
134110
134310
  }
134111
134311
 
134112
134312
  // src/agent/conversation-writer.ts
134113
- var import_promises7 = require("fs/promises");
134313
+ var import_promises8 = require("fs/promises");
134114
134314
  var import_node_path6 = require("path");
134115
134315
  function createConversationWriter(opts) {
134116
134316
  const { baseDir } = opts;
@@ -134120,7 +134320,7 @@ function createConversationWriter(opts) {
134120
134320
  throw new Error("ConversationWriter: refusing to write empty assistant turn");
134121
134321
  }
134122
134322
  const file2 = (0, import_node_path6.join)(baseDir, `${args.sessionId}.jsonl`);
134123
- await (0, import_promises7.mkdir)((0, import_node_path6.dirname)(file2), { recursive: true });
134323
+ await (0, import_promises8.mkdir)((0, import_node_path6.dirname)(file2), { recursive: true });
134124
134324
  const record2 = {
134125
134325
  role: "assistant",
134126
134326
  turnId: args.turnId,
@@ -134130,7 +134330,7 @@ function createConversationWriter(opts) {
134130
134330
  };
134131
134331
  const line = `${JSON.stringify(record2)}
134132
134332
  `;
134133
- await (0, import_promises7.appendFile)(file2, line, { encoding: "utf8" });
134333
+ await (0, import_promises8.appendFile)(file2, line, { encoding: "utf8" });
134134
134334
  }
134135
134335
  };
134136
134336
  }
@@ -137048,7 +137248,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
137048
137248
  init_auth();
137049
137249
 
137050
137250
  // src/browse.ts
137051
- var import_promises8 = require("fs/promises");
137251
+ var import_promises9 = require("fs/promises");
137052
137252
  var import_path10 = require("path");
137053
137253
  var BrowsePathNotFoundError = class extends Error {
137054
137254
  constructor(message) {
@@ -137070,7 +137270,7 @@ async function resolveBrowsePath(browseRoot, relativePath) {
137070
137270
  throw new Error("Path outside browse root");
137071
137271
  }
137072
137272
  try {
137073
- await (0, import_promises8.realpath)(target);
137273
+ await (0, import_promises9.realpath)(target);
137074
137274
  } catch (err) {
137075
137275
  if (err?.code === "ENOENT") {
137076
137276
  throw new BrowsePathNotFoundError(`Path not found: ${target}`);
@@ -137080,7 +137280,7 @@ async function resolveBrowsePath(browseRoot, relativePath) {
137080
137280
  return target;
137081
137281
  }
137082
137282
  async function listDirectories(absolutePath) {
137083
- const entries = await (0, import_promises8.readdir)(absolutePath, { withFileTypes: true });
137283
+ const entries = await (0, import_promises9.readdir)(absolutePath, { withFileTypes: true });
137084
137284
  return entries.filter((e) => e.isDirectory()).map((e) => ({ name: e.name })).sort((a, b2) => a.name.localeCompare(b2.name));
137085
137285
  }
137086
137286
  async function createDirectory(parentAbsolutePath, name) {
@@ -137089,12 +137289,12 @@ async function createDirectory(parentAbsolutePath, name) {
137089
137289
  }
137090
137290
  const target = (0, import_path10.join)(parentAbsolutePath, name);
137091
137291
  try {
137092
- const s3 = await (0, import_promises8.stat)(target);
137292
+ const s3 = await (0, import_promises9.stat)(target);
137093
137293
  if (s3.isDirectory()) throw new Error("Directory already exists");
137094
137294
  } catch (err) {
137095
137295
  if (err.code !== "ENOENT") throw err;
137096
137296
  }
137097
- await (0, import_promises8.mkdir)(target);
137297
+ await (0, import_promises9.mkdir)(target);
137098
137298
  return target;
137099
137299
  }
137100
137300
 
@@ -137904,6 +138104,61 @@ var ConversationCache = class _ConversationCache {
137904
138104
  }
137905
138105
  return ghosts;
137906
138106
  }
138107
+ /**
138108
+ * Reconcile the cache against the authoritative set of conversation file
138109
+ * paths a fresh scan surfaced: drop any cached row whose `file_path` is not
138110
+ * in `livePaths` (removed from disk, or now filtered out — e.g. became an
138111
+ * agent JSONL). This is the "removed conversations" half of a ?refresh=1
138112
+ * reconcile; the additions/updates half is upsertFromScannerMeta.
138113
+ *
138114
+ * Skip semantics depend on whether the file still exists on disk:
138115
+ * - File GONE from disk → always removed, tail or not. This matches the old
138116
+ * invalidate()+rebuild behavior (a deleted conversation must disappear on
138117
+ * refresh) and keeps refresh=1 truthful about removals. NOTE: this is an
138118
+ * INTENTIONAL divergence from pruneGhostFiles(), which KEEPS tailed ghosts
138119
+ * so their cached history stays viewable on a background prune. refresh=1
138120
+ * has the opposite contract (mobile relies on removals being reflected), so
138121
+ * do not "unify" the two — they serve different purposes.
138122
+ * - File STILL on disk but absent from `livePaths` → the CRITICAL #2 race:
138123
+ * the scan snapshot predates a just-created (and now live-tailed) file.
138124
+ * A tailed row here is actively maintained from real content, so it is
138125
+ * kept — dropping it would flicker the active conversation out of
138126
+ * /api/conversations. An untailed on-disk row not in the snapshot is a
138127
+ * transient scan/discovery gap; it is left alone (not removed) and the
138128
+ * next reconcile picks it up, rather than risk removing a real file the
138129
+ * scan simply hasn't surfaced yet.
138130
+ * Returns the removed IDs.
138131
+ */
138132
+ reconcileDeletions(livePaths, opts) {
138133
+ const exists = opts?.exists ?? import_fs16.existsSync;
138134
+ const rows = this.stmts.allFilePaths.all();
138135
+ const removed = [];
138136
+ const drop = this.db.transaction((ids) => {
138137
+ for (const id of ids) {
138138
+ this.stmts.deleteTailById.run(id);
138139
+ this.stmts.deleteById.run(id);
138140
+ }
138141
+ });
138142
+ for (const row of rows) {
138143
+ if (livePaths.has(row.file_path)) continue;
138144
+ if (exists(row.file_path)) continue;
138145
+ removed.push(row.id);
138146
+ }
138147
+ if (removed.length > 0) {
138148
+ drop(removed);
138149
+ if (this.fileIndexLoaded) {
138150
+ for (const id of removed) {
138151
+ for (const [fp, cid] of this.fileIndex) {
138152
+ if (cid === id) {
138153
+ this.fileIndex.delete(fp);
138154
+ break;
138155
+ }
138156
+ }
138157
+ }
138158
+ }
138159
+ }
138160
+ return removed;
138161
+ }
137907
138162
  };
137908
138163
 
137909
138164
  // src/db/config.ts
@@ -139932,11 +140187,11 @@ function seal(plaintext, recipientPublicKeyBase64) {
139932
140187
  // node_modules/chokidar/index.js
139933
140188
  var import_node_events2 = require("events");
139934
140189
  var import_node_fs7 = require("fs");
139935
- var import_promises11 = require("fs/promises");
140190
+ var import_promises12 = require("fs/promises");
139936
140191
  var sp2 = __toESM(require("path"), 1);
139937
140192
 
139938
140193
  // node_modules/readdirp/index.js
139939
- var import_promises9 = require("fs/promises");
140194
+ var import_promises10 = require("fs/promises");
139940
140195
  var import_node_path9 = require("path");
139941
140196
  var import_node_stream2 = require("stream");
139942
140197
  var EntryTypes2 = {
@@ -140017,7 +140272,7 @@ var ReaddirpStream2 = class extends import_node_stream2.Readable {
140017
140272
  const { root, type } = opts;
140018
140273
  this._fileFilter = normalizeFilter2(opts.fileFilter);
140019
140274
  this._directoryFilter = normalizeFilter2(opts.directoryFilter);
140020
- const statMethod = opts.lstat ? import_promises9.lstat : import_promises9.stat;
140275
+ const statMethod = opts.lstat ? import_promises10.lstat : import_promises10.stat;
140021
140276
  if (wantBigintFsStats2) {
140022
140277
  this._stat = (path2) => statMethod(path2, { bigint: true });
140023
140278
  } else {
@@ -140088,7 +140343,7 @@ var ReaddirpStream2 = class extends import_node_stream2.Readable {
140088
140343
  async _exploreDir(path2, depth) {
140089
140344
  let files;
140090
140345
  try {
140091
- files = await (0, import_promises9.readdir)(path2, this._rdOptions);
140346
+ files = await (0, import_promises10.readdir)(path2, this._rdOptions);
140092
140347
  } catch (error51) {
140093
140348
  this._onError(error51);
140094
140349
  }
@@ -140126,8 +140381,8 @@ var ReaddirpStream2 = class extends import_node_stream2.Readable {
140126
140381
  if (stats && stats.isSymbolicLink()) {
140127
140382
  const full = entry.fullPath;
140128
140383
  try {
140129
- const entryRealPath = await (0, import_promises9.realpath)(full);
140130
- const entryRealPathStats = await (0, import_promises9.lstat)(entryRealPath);
140384
+ const entryRealPath = await (0, import_promises10.realpath)(full);
140385
+ const entryRealPathStats = await (0, import_promises10.lstat)(entryRealPath);
140131
140386
  if (entryRealPathStats.isFile()) {
140132
140387
  return "file";
140133
140388
  }
@@ -140170,7 +140425,7 @@ function readdirp2(root, options = {}) {
140170
140425
 
140171
140426
  // node_modules/chokidar/handler.js
140172
140427
  var import_node_fs6 = require("fs");
140173
- var import_promises10 = require("fs/promises");
140428
+ var import_promises11 = require("fs/promises");
140174
140429
  var import_node_os3 = require("os");
140175
140430
  var sp = __toESM(require("path"), 1);
140176
140431
  var STR_DATA2 = "data";
@@ -140197,7 +140452,7 @@ var EVENTS2 = {
140197
140452
  };
140198
140453
  var EV2 = EVENTS2;
140199
140454
  var THROTTLE_MODE_WATCH2 = "watch";
140200
- var statMethods2 = { lstat: import_promises10.lstat, stat: import_promises10.stat };
140455
+ var statMethods2 = { lstat: import_promises11.lstat, stat: import_promises11.stat };
140201
140456
  var KEY_LISTENERS2 = "listeners";
140202
140457
  var KEY_ERR2 = "errHandlers";
140203
140458
  var KEY_RAW2 = "rawEmitters";
@@ -140554,7 +140809,7 @@ var setFsWatchListener2 = (path2, fullPath, options, handlers) => {
140554
140809
  cont.watcherUnusable = true;
140555
140810
  if (isWindows3 && error51.code === "EPERM") {
140556
140811
  try {
140557
- const fd = await (0, import_promises10.open)(path2, "r");
140812
+ const fd = await (0, import_promises11.open)(path2, "r");
140558
140813
  await fd.close();
140559
140814
  broadcastErr(error51);
140560
140815
  } catch (err) {
@@ -140685,7 +140940,7 @@ var NodeFsHandler2 = class {
140685
140940
  return;
140686
140941
  if (!newStats || newStats.mtimeMs === 0) {
140687
140942
  try {
140688
- const newStats2 = await (0, import_promises10.stat)(file2);
140943
+ const newStats2 = await (0, import_promises11.stat)(file2);
140689
140944
  if (this.fsw.closed)
140690
140945
  return;
140691
140946
  const at2 = newStats2.atimeMs;
@@ -140740,7 +140995,7 @@ var NodeFsHandler2 = class {
140740
140995
  this.fsw._incrReadyCount();
140741
140996
  let linkPath;
140742
140997
  try {
140743
- linkPath = await (0, import_promises10.realpath)(path2);
140998
+ linkPath = await (0, import_promises11.realpath)(path2);
140744
140999
  } catch (e) {
140745
141000
  this.fsw._emitReady();
140746
141001
  return true;
@@ -140889,7 +141144,7 @@ var NodeFsHandler2 = class {
140889
141144
  let closer;
140890
141145
  if (stats.isDirectory()) {
140891
141146
  const absPath = sp.resolve(path2);
140892
- const targetPath = follow ? await (0, import_promises10.realpath)(path2) : path2;
141147
+ const targetPath = follow ? await (0, import_promises11.realpath)(path2) : path2;
140893
141148
  if (this.fsw.closed)
140894
141149
  return;
140895
141150
  closer = await this._handleDir(wh.watchPath, stats, initialAdd, depth, target, wh, targetPath);
@@ -140899,7 +141154,7 @@ var NodeFsHandler2 = class {
140899
141154
  this.fsw._symlinkPaths.set(absPath, targetPath);
140900
141155
  }
140901
141156
  } else if (stats.isSymbolicLink()) {
140902
- const targetPath = follow ? await (0, import_promises10.realpath)(path2) : path2;
141157
+ const targetPath = follow ? await (0, import_promises11.realpath)(path2) : path2;
140903
141158
  if (this.fsw.closed)
140904
141159
  return;
140905
141160
  const parent = sp.dirname(wh.watchPath);
@@ -141059,7 +141314,7 @@ var DirEntry2 = class {
141059
141314
  return;
141060
141315
  const dir = this.path;
141061
141316
  try {
141062
- await (0, import_promises11.readdir)(dir);
141317
+ await (0, import_promises12.readdir)(dir);
141063
141318
  } catch (err) {
141064
141319
  if (this._removeWatcher) {
141065
141320
  this._removeWatcher(sp2.dirname(dir), sp2.basename(dir));
@@ -141409,7 +141664,7 @@ var FSWatcher2 = class extends import_node_events2.EventEmitter {
141409
141664
  const fullPath = opts.cwd ? sp2.join(opts.cwd, path2) : path2;
141410
141665
  let stats2;
141411
141666
  try {
141412
- stats2 = await (0, import_promises11.stat)(fullPath);
141667
+ stats2 = await (0, import_promises12.stat)(fullPath);
141413
141668
  } catch (err) {
141414
141669
  }
141415
141670
  if (!stats2 || this.closed)
@@ -141663,7 +141918,7 @@ var chokidar_default = { watch: watch2, FSWatcher: FSWatcher2 };
141663
141918
 
141664
141919
  // src/services/conversations/conversationWatcher.ts
141665
141920
  var import_fs22 = require("fs");
141666
- var import_promises12 = require("fs/promises");
141921
+ var import_promises13 = require("fs/promises");
141667
141922
  var ConversationWatcher = class {
141668
141923
  files = /* @__PURE__ */ new Map();
141669
141924
  directories = /* @__PURE__ */ new Map();
@@ -141710,6 +141965,19 @@ var ConversationWatcher = class {
141710
141965
  void entry.watcher.close();
141711
141966
  this.files.delete(filePath);
141712
141967
  }
141968
+ /**
141969
+ * Re-drive the tail read for a file that's already being tailed. A per-file
141970
+ * chokidar handle can die silently (fs.watch stops firing after inode churn)
141971
+ * while the coarser directory watcher keeps reporting changes — calling this
141972
+ * from the directory-event path makes the tail self-healing. Reads are
141973
+ * offset-based and coalesced, so a redundant poke after a normal change
141974
+ * event is a cheap stat + no-op. Returns false for untailed paths.
141975
+ */
141976
+ poke(filePath) {
141977
+ if (!this.files.has(filePath)) return false;
141978
+ void this.readNewLines(filePath);
141979
+ return true;
141980
+ }
141713
141981
  /**
141714
141982
  * Watch a directory of conversation JSONL files. Fires
141715
141983
  * onConversationChanged for any add/change/unlink event so the caller
@@ -141754,12 +142022,12 @@ var ConversationWatcher = class {
141754
142022
  entry.reading = true;
141755
142023
  try {
141756
142024
  for (; ; ) {
141757
- const st2 = await (0, import_promises12.stat)(filePath);
142025
+ const st2 = await (0, import_promises13.stat)(filePath);
141758
142026
  if (st2.size <= entry.offset) break;
141759
142027
  const readFrom = entry.offset;
141760
142028
  const bytesToRead = st2.size - readFrom;
141761
142029
  const buf = Buffer.alloc(bytesToRead);
141762
- const fh = await (0, import_promises12.open)(filePath, "r");
142030
+ const fh = await (0, import_promises13.open)(filePath, "r");
141763
142031
  try {
141764
142032
  await fh.read(buf, 0, bytesToRead, readFrom);
141765
142033
  } finally {
@@ -142159,7 +142427,7 @@ function discoveredToResponse(d, conversationId) {
142159
142427
 
142160
142428
  // src/uploads.ts
142161
142429
  var import_crypto8 = require("crypto");
142162
- var import_promises13 = require("fs/promises");
142430
+ var import_promises14 = require("fs/promises");
142163
142431
  var import_heic_convert = __toESM(require_heic_convert(), 1);
142164
142432
  var import_path20 = require("path");
142165
142433
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
@@ -142195,9 +142463,9 @@ async function saveUploadFile(input) {
142195
142463
  const id = `up_${(0, import_crypto8.randomBytes)(8).toString("hex")}`;
142196
142464
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
142197
142465
  const dir = (0, import_path20.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
142198
- await (0, import_promises13.mkdir)(dir, { recursive: true });
142466
+ await (0, import_promises14.mkdir)(dir, { recursive: true });
142199
142467
  const filePath = (0, import_path20.join)(dir, `${Date.now()}-${id}-${safeName}`);
142200
- await (0, import_promises13.writeFile)(filePath, buffer);
142468
+ await (0, import_promises14.writeFile)(filePath, buffer);
142201
142469
  return {
142202
142470
  id,
142203
142471
  filePath,
@@ -142653,6 +142921,14 @@ var StreamerServer = class {
142653
142921
  // window so the self-healing kickstart-relaunch race doesn't spam warn.
142654
142922
  binding = false;
142655
142923
  cacheReady = false;
142924
+ // Every fire-and-forget task that runs a scan and then writes to this.cache
142925
+ // in an async continuation (startup warm-up, background count refresh, …).
142926
+ // close() awaits all of them before closing this.cache, so a scan's post-scan
142927
+ // cache writes (upsertFromScannerMeta / populateTailFromFile / pruneGhostFiles
142928
+ // / reconcileDeletions) can never hit a cache.db that was already closed
142929
+ // ("database connection is not open"), which would otherwise leave the cache
142930
+ // empty. Register via trackCacheWrite(); each entry removes itself on settle.
142931
+ inFlightCacheWrites = /* @__PURE__ */ new Set();
142656
142932
  apiKey;
142657
142933
  apiKeySource;
142658
142934
  localNoAuth;
@@ -142728,7 +143004,7 @@ var StreamerServer = class {
142728
143004
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
142729
143005
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config2.browseRoot;
142730
143006
  if (rawRoot) {
142731
- (0, import_promises14.realpath)(rawRoot).then((resolved) => {
143007
+ (0, import_promises15.realpath)(rawRoot).then((resolved) => {
142732
143008
  this.browseRoot = resolved;
142733
143009
  if (this.verbose) this.log.info(`Browse root: ${resolved}`, { browseRoot: resolved });
142734
143010
  }).catch(() => {
@@ -142784,6 +143060,7 @@ var StreamerServer = class {
142784
143060
  }
142785
143061
  },
142786
143062
  onConversationChanged: (filePath) => {
143063
+ this.fileWatcher.poke(filePath);
142787
143064
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
142788
143065
  this.markScannerStaleDebounced();
142789
143066
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
@@ -143220,6 +143497,7 @@ var StreamerServer = class {
143220
143497
  });
143221
143498
  }
143222
143499
  });
143500
+ this.trackCacheWrite(warmUp);
143223
143501
  if (opts?.awaitReady) await warmUp;
143224
143502
  }
143225
143503
  // Bind the HTTP listener, retrying on a transient EADDRINUSE. See the call
@@ -143267,11 +143545,23 @@ var StreamerServer = class {
143267
143545
  }
143268
143546
  }
143269
143547
  }
143548
+ // Register a fire-and-forget task that writes to this.cache after a scan, so
143549
+ // close() can await it before closing cache.db. Removes itself on settle. The
143550
+ // caller keeps its own error handling; this wrapper swallows rejections so a
143551
+ // failed task never rejects close()'s Promise.all.
143552
+ trackCacheWrite(task) {
143553
+ const guarded = task.catch(() => void 0);
143554
+ this.inFlightCacheWrites.add(guarded);
143555
+ void guarded.finally(() => {
143556
+ this.inFlightCacheWrites.delete(guarded);
143557
+ });
143558
+ }
143270
143559
  async close() {
143271
143560
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
143272
143561
  this.ptyGraceTimers.clear();
143273
143562
  this.markScannerStaleDebounced.cancel();
143274
- for (const s3 of this.allScanners) s3.close();
143563
+ await Promise.all([...this.inFlightCacheWrites]);
143564
+ await Promise.all([...this.allScanners].map((s3) => s3.close()));
143275
143565
  this.allScanners.clear();
143276
143566
  this.scanner = null;
143277
143567
  this.cache?.close();
@@ -143411,12 +143701,23 @@ var StreamerServer = class {
143411
143701
  const project = url2.searchParams.get("project") ?? void 0;
143412
143702
  const providerFilter = url2.searchParams.get("provider") ?? void 0;
143413
143703
  const bustCache = url2.searchParams.get("refresh") === "1";
143414
- if (bustCache) {
143415
- this.cache?.invalidate();
143416
- this.scanner = null;
143417
- this.scannerReady = null;
143704
+ if (bustCache && this.cache) {
143705
+ const scanner2 = await this.rescanForRefresh();
143706
+ const metas2 = [...scanner2.getMetadataCache().values()];
143707
+ try {
143708
+ this.cache.upsertFromScannerMeta(metas2);
143709
+ const livePaths = new Set(
143710
+ metas2.map((m2) => m2.filePath).filter((p2) => Boolean(p2))
143711
+ );
143712
+ this.cache.reconcileDeletions(livePaths);
143713
+ } catch (err) {
143714
+ this.log.warn(
143715
+ `refresh reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
143716
+ { event: "conversations.reconcile_failed" }
143717
+ );
143718
+ }
143418
143719
  }
143419
- if (this.cache && !bustCache) {
143720
+ if (this.cache) {
143420
143721
  const { conversations, total: total2 } = this.cache.listConversations({
143421
143722
  project,
143422
143723
  provider: providerFilter,
@@ -143481,12 +143782,6 @@ var StreamerServer = class {
143481
143782
  };
143482
143783
  });
143483
143784
  json2(res, 200, { conversations: adapted, hasMore: offset + limit < total, offset, total });
143484
- if (this.cache && bustCache) {
143485
- try {
143486
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
143487
- } catch {
143488
- }
143489
- }
143490
143785
  }
143491
143786
  async handleConversationsCount(url2, res) {
143492
143787
  const project = url2.searchParams.get("project") ?? void 0;
@@ -143515,19 +143810,21 @@ var StreamerServer = class {
143515
143810
  // later count reflects new/removed conversations. Never awaited by the request
143516
143811
  // path — refresh=1 returns the cached total synchronously and this catches up.
143517
143812
  refreshCountInBackground() {
143518
- void (async () => {
143519
- try {
143520
- const scanner = await this.getFreshScanner();
143521
- if (this.cache) {
143522
- this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
143813
+ this.trackCacheWrite(
143814
+ (async () => {
143815
+ try {
143816
+ const scanner = await this.getFreshScanner();
143817
+ if (this.cache) {
143818
+ this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
143819
+ }
143820
+ } catch (err) {
143821
+ this.log.warn(
143822
+ `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
143823
+ { event: "count.refresh_failed" }
143824
+ );
143523
143825
  }
143524
- } catch (err) {
143525
- this.log.warn(
143526
- `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
143527
- { event: "count.refresh_failed" }
143528
- );
143529
- }
143530
- })();
143826
+ })()
143827
+ );
143531
143828
  }
143532
143829
  handleSessionsCount(res) {
143533
143830
  json2(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
@@ -143640,6 +143937,30 @@ var StreamerServer = class {
143640
143937
  this.scannerReady = null;
143641
143938
  return this.getScanner();
143642
143939
  }
143940
+ // refresh=1's scan: reuse the WARM persistent scanner (its index.db + cursors
143941
+ // survive, so classify() still skips unchanged files) and re-run its scan
143942
+ // with fullRescan:true — the escape hatch that bypasses the scanner's
143943
+ // dir-mtime discovery gate, since an explicit user pull-to-refresh is exactly
143944
+ // the "don't trust the gate, check disk for real" signal. Unlike
143945
+ // getFreshScanner() this does NOT discard the warm scanner. scannerReady is
143946
+ // only ever reassigned to a live scan promise (never nulled mid-scan), so the
143947
+ // getScanner() anti-infinite-loop guard is preserved.
143948
+ async rescanForRefresh() {
143949
+ if (this.scannerReady) await this.scannerReady;
143950
+ this.scannerStale = false;
143951
+ if (!this.scanner) {
143952
+ this.scanner = new ConversationScanner();
143953
+ this.allScanners.add(this.scanner);
143954
+ }
143955
+ const scanner = this.scanner;
143956
+ this.scannerReady = scanner.scan({
143957
+ ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
143958
+ ...this.codexScanOpts(),
143959
+ fullRescan: true
143960
+ });
143961
+ await this.scannerReady;
143962
+ return scanner;
143963
+ }
143643
143964
  findJsonlPath(uuid3) {
143644
143965
  const projectsDir = (0, import_path21.join)((0, import_os10.homedir)(), ".claude", "projects");
143645
143966
  if (!(0, import_fs24.existsSync)(projectsDir)) return null;
@@ -145052,7 +145373,7 @@ function isBrewInstall(scriptPath = process.argv[1] ?? "") {
145052
145373
  var import_node_crypto4 = require("crypto");
145053
145374
  var import_node_fs8 = require("fs");
145054
145375
  var import_node_path10 = require("path");
145055
- var import_promises15 = require("stream/promises");
145376
+ var import_promises16 = require("stream/promises");
145056
145377
 
145057
145378
  // src/updater/manifest.ts
145058
145379
  init_zod();
@@ -145127,7 +145448,7 @@ async function downloadAndVerify(opts) {
145127
145448
  }
145128
145449
  });
145129
145450
  try {
145130
- await (0, import_promises15.pipeline)(res.body.pipeThrough(measured), out);
145451
+ await (0, import_promises16.pipeline)(res.body.pipeThrough(measured), out);
145131
145452
  } catch (err) {
145132
145453
  (0, import_node_fs8.rmSync)(targetPath, { force: true });
145133
145454
  throw err;
@@ -145341,7 +145662,7 @@ var import_fs28 = __toESM(require("fs"), 1);
145341
145662
  var import_node_fs14 = __toESM(require("fs"), 1);
145342
145663
  var import_node_path19 = __toESM(require("path"), 1);
145343
145664
  var import_node_fs15 = __toESM(require("fs"), 1);
145344
- var import_promises16 = __toESM(require("fs/promises"), 1);
145665
+ var import_promises17 = __toESM(require("fs/promises"), 1);
145345
145666
  var import_node_path20 = __toESM(require("path"), 1);
145346
145667
  var import_node_path21 = require("path");
145347
145668
  var import_node_fs16 = __toESM(require("fs"), 1);
@@ -147573,7 +147894,7 @@ var gr = (s3, t2, e) => {
147573
147894
  E2 ? e(E2) : x2 && a ? Es(x2, o, h, (Le) => S2(Le)) : n ? import_node_fs15.default.chmod(s3, r, e) : e();
147574
147895
  };
147575
147896
  if (s3 === d) return no(s3, S2);
147576
- if (l) return import_promises16.default.mkdir(s3, { mode: r, recursive: true }).then((E2) => S2(null, E2 ?? void 0), S2);
147897
+ if (l) return import_promises17.default.mkdir(s3, { mode: r, recursive: true }).then((E2) => S2(null, E2 ?? void 0), S2);
147577
147898
  let D2 = f(import_node_path20.default.relative(d, s3)).split("/");
147578
147899
  Ss(d, D2, r, c, d, void 0, S2);
147579
147900
  };