@threadbase-sh/streamer 1.52.4 → 1.52.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.cjs CHANGED
@@ -607,7 +607,8 @@ function loadOrCreateApiKey() {
607
607
  const key = generateApiKey();
608
608
  (0, import_fs.mkdirSync)(configDir(), { recursive: true });
609
609
  (0, import_fs.writeFileSync)(configFile(), `api_key: ${key}
610
- `, "utf-8");
610
+ `, { encoding: "utf-8", mode: 384 });
611
+ (0, import_fs.chmodSync)(configFile(), 384);
611
612
  return key;
612
613
  }
613
614
  function loadBrowseRoot() {
@@ -3916,8 +3917,8 @@ var import_events = require("events");
3916
3917
  var import_fs27 = require("fs");
3917
3918
  var import_promises7 = require("fs/promises");
3918
3919
  var import_http = require("http");
3919
- var import_os12 = require("os");
3920
- var import_path22 = require("path");
3920
+ var import_os13 = require("os");
3921
+ var import_path23 = require("path");
3921
3922
 
3922
3923
  // src/api/app.ts
3923
3924
  var import_hono18 = require("hono");
@@ -4053,6 +4054,8 @@ var DevicesRepository = class {
4053
4054
  listStmt;
4054
4055
  revokeStmt;
4055
4056
  touchStmt;
4057
+ deleteStmt;
4058
+ deleteRevokedStmt;
4056
4059
  constructor(db) {
4057
4060
  this.insertStmt = db.prepare(`
4058
4061
  INSERT INTO devices (
@@ -4066,6 +4069,8 @@ var DevicesRepository = class {
4066
4069
  this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
4067
4070
  this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
4068
4071
  this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
4072
+ this.deleteStmt = db.prepare("DELETE FROM devices WHERE device_id = ?");
4073
+ this.deleteRevokedStmt = db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL");
4069
4074
  }
4070
4075
  /**
4071
4076
  * Record a newly paired device and mint its token.
@@ -4112,6 +4117,33 @@ var DevicesRepository = class {
4112
4117
  revoke(deviceId, now = Date.now()) {
4113
4118
  return this.revokeStmt.run(now, deviceId).changes > 0;
4114
4119
  }
4120
+ /**
4121
+ * Erase one device's record outright.
4122
+ *
4123
+ * Deliberately separate from `revoke`, which is a soft delete that keeps the
4124
+ * row so `list()` can show what happened. That audit trail is the right
4125
+ * default — but it meant a `devices` row, including the user-supplied `name`
4126
+ * ("Ronen's iPhone"), had no removal path at all once the registry moved to
4127
+ * runtime.db, which no command deletes. This is that path.
4128
+ *
4129
+ * Erasure is NOT revocation: deleting a row frees its `token_hash`, so a
4130
+ * device whose token is still on a phone somewhere stops being *known* rather
4131
+ * than being *refused*. Revoke first, delete second, is the safe order, and
4132
+ * `deleteRevoked()` exists so that is the easy thing to do.
4133
+ */
4134
+ delete(deviceId) {
4135
+ return this.deleteStmt.run(deviceId).changes > 0;
4136
+ }
4137
+ /**
4138
+ * Erase every already-revoked device. The bulk companion to `delete`, and the
4139
+ * one that is safe by construction: a revoked device is already refused, so
4140
+ * removing its row cannot restore access to anything.
4141
+ *
4142
+ * Returns the number of rows removed.
4143
+ */
4144
+ deleteRevoked() {
4145
+ return this.deleteRevokedStmt.run().changes;
4146
+ }
4115
4147
  touch(deviceId, now = Date.now()) {
4116
4148
  this.touchStmt.run(now, deviceId);
4117
4149
  }
@@ -4638,6 +4670,34 @@ var createDeviceRoutes = (deps) => {
4638
4670
  repo.revoke(id);
4639
4671
  return c.json({ ok: true, alreadyRevoked: false });
4640
4672
  });
4673
+ app.delete("/:id", (c) => {
4674
+ const repo = deps.devicesRepo();
4675
+ if (!repo) {
4676
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4677
+ }
4678
+ const id = c.req.param("id");
4679
+ const existing = repo.get(id);
4680
+ if (!existing) return c.json({ ok: true, alreadyDeleted: true });
4681
+ const force = c.req.query("force") === "1" || c.req.query("force") === "true";
4682
+ if (existing.revoked_at == null && !force) {
4683
+ return c.json(
4684
+ {
4685
+ error: "Revoke the device before deleting it, or pass ?force=1",
4686
+ code: "DEVICE_ACTIVE"
4687
+ },
4688
+ 409
4689
+ );
4690
+ }
4691
+ repo.delete(id);
4692
+ return c.json({ ok: true, alreadyDeleted: false });
4693
+ });
4694
+ app.delete("/", (c) => {
4695
+ const repo = deps.devicesRepo();
4696
+ if (!repo) {
4697
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4698
+ }
4699
+ return c.json({ ok: true, deleted: repo.deleteRevoked() });
4700
+ });
4641
4701
  return app;
4642
4702
  };
4643
4703
 
@@ -5554,6 +5614,13 @@ var createMiscRoutes = (deps) => {
5554
5614
  // Same contract: this server serves GET /api/projects/summary, which the
5555
5615
  // Hub's grouped views need before they can draw a tree.
5556
5616
  projectSummary: true,
5617
+ // The paired-device registry lives in runtime.db, so it survives
5618
+ // `tb-streamer cache clear` and the integrity monitor's reset-and-rescan.
5619
+ // A client may only prefer its scoped device token over the shared API
5620
+ // key when this is true: on an older server the registry is inside
5621
+ // cache.db, where a documented troubleshooting step deletes it and every
5622
+ // device token with it. Absent means "old server, assume not durable".
5623
+ devicesDurable: true,
5557
5624
  // Delivery capability, not endpoint support: whether this server can
5558
5625
  // actually send a push, so mobile can hide an affordance instead of
5559
5626
  // registering tokens nothing will ever send to. Absent on older servers,
@@ -5964,7 +6031,13 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5964
6031
  const app = new import_hono17.Hono();
5965
6032
  app.get(
5966
6033
  "/ws",
5967
- upgradeWebSocket(() => {
6034
+ // The principal is read here, at the upgrade, and captured for the life of
6035
+ // the socket. authMiddleware sets it because /ws is classified
6036
+ // `history:read`, but it only ever reaches the HTTP request — without
6037
+ // capturing it the socket has no principal at all, so every frame after
6038
+ // the upgrade is unauthorized-by-omission.
6039
+ upgradeWebSocket((c) => {
6040
+ const principal = c.get("principal") ?? null;
5968
6041
  let openWs = null;
5969
6042
  return {
5970
6043
  onOpen(_evt, ws) {
@@ -5974,7 +6047,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5974
6047
  deps.handleWsOpen(raw);
5975
6048
  },
5976
6049
  onMessage(evt, _ws) {
5977
- if (openWs) deps.handleWsMessage(openWs, evt.data);
6050
+ if (openWs) deps.handleWsMessage(openWs, evt.data, principal);
5978
6051
  },
5979
6052
  onClose(_evt, _ws) {
5980
6053
  if (openWs) deps.handleWsClose(openWs);
@@ -10696,6 +10769,11 @@ var SessionsRepository = class {
10696
10769
 
10697
10770
  // src/db/runtime-store.ts
10698
10771
  var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
10772
+ var import_os7 = require("os");
10773
+ var import_path16 = require("path");
10774
+ function resolveRuntimeDbPath(override) {
10775
+ return override ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path16.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path16.join)((0, import_os7.homedir)(), ".threadbase"), "runtime.db");
10776
+ }
10699
10777
  var RuntimeStore = class _RuntimeStore {
10700
10778
  constructor(db) {
10701
10779
  this.db = db;
@@ -10713,23 +10791,64 @@ var RuntimeStore = class _RuntimeStore {
10713
10791
  /**
10714
10792
  * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
10715
10793
  *
10716
- * Non-destructive by design: the source table is left in place so an older
10717
- * streamer rolled back onto the same machine still finds its registry. Runs
10718
- * only when this file's table is empty, so a second boot is a no-op rather
10719
- * than a re-copy that would resurrect rows deleted since.
10720
- *
10721
10794
  * Returns the number of rows copied.
10722
10795
  */
10723
10796
  importLegacyManagedSessions(source) {
10724
- const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
10797
+ return this.importLegacyTable(source, "managed_sessions");
10798
+ }
10799
+ /**
10800
+ * One-time move of `devices` rows out of `cache.db`, where the registry used
10801
+ * to live (migration `011_create_devices.sql`).
10802
+ *
10803
+ * Losing this table invalidates every device token ever issued, and cache.db
10804
+ * is the file `tb-streamer cache clear` deletes and the integrity monitor
10805
+ * rebuilds — see `runtime-migrations/003_create_devices.sql`.
10806
+ *
10807
+ * Unlike `managed_sessions`, this one MOVES rather than copies: the source
10808
+ * rows are deleted once the copy is verified. A `devices` row carries a
10809
+ * user-supplied label ("Ronen's iPhone"), and leaving a second copy of that
10810
+ * on disk indefinitely — in the one file the user is told to delete when
10811
+ * something goes wrong — is more retained personal data than the rollback
10812
+ * path is worth. Recovering from a rollback is re-scanning a pairing QR.
10813
+ *
10814
+ * The delete is conditional on the copy being complete: `INSERT OR IGNORE`
10815
+ * can silently skip a row, so the destination count must match what was read
10816
+ * before anything is removed. A mismatch keeps the source and reports
10817
+ * `purged: false` rather than throwing — the import itself still succeeded,
10818
+ * and keeping data is the safe direction to fail in.
10819
+ */
10820
+ importLegacyDevices(source) {
10821
+ const copied = this.importLegacyTable(source, "devices");
10822
+ if (copied === 0) return { copied: 0, purged: false };
10823
+ const landed = this.db.prepare("SELECT COUNT(*) AS n FROM devices").get().n;
10824
+ if (landed !== copied) return { copied, purged: false };
10825
+ source.prepare("DELETE FROM devices").run();
10826
+ return { copied, purged: true };
10827
+ }
10828
+ /**
10829
+ * Copy a whole table out of a pre-split `cache.db` into this file.
10830
+ *
10831
+ * The copy itself is non-destructive — the source is left in place, so an
10832
+ * older streamer rolled back onto the same machine still finds its data.
10833
+ * `importLegacyDevices` deletes the source afterwards for its own reasons;
10834
+ * `managed_sessions` does not. Runs only when this file's table is empty, so
10835
+ * a second boot is a no-op rather than a re-copy that would resurrect rows
10836
+ * deleted since.
10837
+ *
10838
+ * The table name is interpolated into SQL, so it is typed as a closed union
10839
+ * rather than `string` — the set of tables that can ever be lifted is known
10840
+ * at compile time, and that is what keeps a caller from making this a hole.
10841
+ */
10842
+ importLegacyTable(source, table) {
10843
+ const existing = this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
10725
10844
  if (existing.n > 0) return 0;
10726
- const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
10845
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
10727
10846
  if (!hasTable) return 0;
10728
- const rows = source.prepare("SELECT * FROM managed_sessions").all();
10847
+ const rows = source.prepare(`SELECT * FROM ${table}`).all();
10729
10848
  if (rows.length === 0) return 0;
10730
10849
  const columns = Object.keys(rows[0]);
10731
10850
  const insert = this.db.prepare(
10732
- `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
10851
+ `INSERT OR IGNORE INTO ${table} (${columns.join(", ")})
10733
10852
  VALUES (${columns.map((c) => `@${c}`).join(", ")})`
10734
10853
  );
10735
10854
  this.db.transaction((batch) => {
@@ -11051,8 +11170,8 @@ function spawnDetachedHost(socketPath, entryPoint) {
11051
11170
  // src/scanner-manager.ts
11052
11171
  var import_scanner4 = require("@threadbase-sh/scanner");
11053
11172
  var import_fs17 = require("fs");
11054
- var import_os8 = require("os");
11055
- var import_path17 = require("path");
11173
+ var import_os9 = require("os");
11174
+ var import_path18 = require("path");
11056
11175
 
11057
11176
  // src/services/cache/cacheMetadata.ts
11058
11177
  function getCacheMetadata(repo, key) {
@@ -11152,9 +11271,9 @@ function refreshConversationCache(deps) {
11152
11271
 
11153
11272
  // src/services/conversations/shouldRefreshProjectsFromHdd.ts
11154
11273
  var import_fs16 = require("fs");
11155
- var import_os7 = require("os");
11156
- var import_path16 = require("path");
11157
- var DEFAULT_PROJECTS_DIR = (0, import_path16.join)((0, import_os7.homedir)(), ".claude", "projects");
11274
+ var import_os8 = require("os");
11275
+ var import_path17 = require("path");
11276
+ var DEFAULT_PROJECTS_DIR = (0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects");
11158
11277
  function maxProjectsTreeMtimeMs(projectsDir) {
11159
11278
  let maxMs;
11160
11279
  try {
@@ -11166,7 +11285,7 @@ function maxProjectsTreeMtimeMs(projectsDir) {
11166
11285
  for (const ent of (0, import_fs16.readdirSync)(projectsDir, { withFileTypes: true })) {
11167
11286
  if (!ent.isDirectory()) continue;
11168
11287
  try {
11169
- const childMs = (0, import_fs16.statSync)((0, import_path16.join)(projectsDir, ent.name)).mtimeMs;
11288
+ const childMs = (0, import_fs16.statSync)((0, import_path17.join)(projectsDir, ent.name)).mtimeMs;
11170
11289
  if (childMs > maxMs) maxMs = childMs;
11171
11290
  } catch {
11172
11291
  }
@@ -11338,9 +11457,9 @@ var ScannerManager = class {
11338
11457
  projectsDirs() {
11339
11458
  const profiles = this.deps.scanProfiles;
11340
11459
  if (profiles && profiles.length > 0) {
11341
- return profiles.filter((p) => p.enabled).map((p) => (0, import_path17.join)(p.configDir, "projects"));
11460
+ return profiles.filter((p) => p.enabled).map((p) => (0, import_path18.join)(p.configDir, "projects"));
11342
11461
  }
11343
- return [(0, import_path17.join)((0, import_os8.homedir)(), ".claude", "projects")];
11462
+ return [(0, import_path18.join)((0, import_os9.homedir)(), ".claude", "projects")];
11344
11463
  }
11345
11464
  // ─── staleness ────────────────────────────────────────────────────
11346
11465
  // Drain the stale set and disarm the flag together. The caller owns the
@@ -11621,8 +11740,8 @@ var import_fs19 = require("fs");
11621
11740
 
11622
11741
  // src/handlers/handleListProjects.ts
11623
11742
  var import_fs18 = require("fs");
11624
- var import_os9 = require("os");
11625
- var import_path18 = require("path");
11743
+ var import_os10 = require("os");
11744
+ var import_path19 = require("path");
11626
11745
  var HEAD_BYTES = 64 * 1024;
11627
11746
  var MAX_FILES_PROBED = 3;
11628
11747
  function readRecordedCwd(dir) {
@@ -11635,7 +11754,7 @@ function readRecordedCwd(dir) {
11635
11754
  for (const file of files.slice(0, MAX_FILES_PROBED)) {
11636
11755
  let fd;
11637
11756
  try {
11638
- fd = (0, import_fs18.openSync)((0, import_path18.join)(dir, file), "r");
11757
+ fd = (0, import_fs18.openSync)((0, import_path19.join)(dir, file), "r");
11639
11758
  const buf = Buffer.alloc(HEAD_BYTES);
11640
11759
  const bytes = (0, import_fs18.readSync)(fd, buf, 0, HEAD_BYTES, 0);
11641
11760
  for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
@@ -11659,11 +11778,11 @@ function decodeProjectPath(dirName) {
11659
11778
  function handleListProjects(url, res) {
11660
11779
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
11661
11780
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
11662
- const projectsDir = (0, import_path18.join)((0, import_os9.homedir)(), ".claude", "projects");
11781
+ const projectsDir = (0, import_path19.join)((0, import_os10.homedir)(), ".claude", "projects");
11663
11782
  let entries;
11664
11783
  try {
11665
11784
  entries = (0, import_fs18.readdirSync)(projectsDir).map((dirName) => {
11666
- const fullPath = (0, import_path18.join)(projectsDir, dirName);
11785
+ const fullPath = (0, import_path19.join)(projectsDir, dirName);
11667
11786
  let mtime = 0;
11668
11787
  try {
11669
11788
  mtime = (0, import_fs18.statSync)(fullPath).mtimeMs;
@@ -11678,7 +11797,7 @@ function handleListProjects(url, res) {
11678
11797
  }
11679
11798
  const total = entries.length;
11680
11799
  const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
11681
- const path = readRecordedCwd((0, import_path18.join)(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
11800
+ const path = readRecordedCwd((0, import_path19.join)(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
11682
11801
  const name = path.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
11683
11802
  return { name, path, dirName };
11684
11803
  });
@@ -11687,6 +11806,9 @@ function handleListProjects(url, res) {
11687
11806
  }
11688
11807
 
11689
11808
  // src/server-wiring.ts
11809
+ function wsAllows(principal, required) {
11810
+ return principal === null || hasCapability(principal, required);
11811
+ }
11690
11812
  function createConversationWatcherEvents(deps) {
11691
11813
  return {
11692
11814
  onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
@@ -11968,7 +12090,15 @@ function createApiDeps(deps) {
11968
12090
  const alertMsg = deps.cacheMonitor()?.wsMessage();
11969
12091
  if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
11970
12092
  },
11971
- handleWsMessage: async (ws, raw) => {
12093
+ handleWsMessage: async (ws, raw, principal) => {
12094
+ const deny = (type, required) => {
12095
+ deps.log().warn(`[ws.capability_denied] ${type} requires ${required}`, {
12096
+ event: "ws.capability_denied",
12097
+ type,
12098
+ required,
12099
+ ...principal?.deviceId ? { deviceId: principal.deviceId } : {}
12100
+ });
12101
+ };
11972
12102
  try {
11973
12103
  const msg = JSON.parse(String(raw));
11974
12104
  if (msg.type === "register" && typeof msg.clientId === "string") {
@@ -11978,6 +12108,10 @@ function createApiDeps(deps) {
11978
12108
  deps.wsToClientId.set(ws, msg.clientId);
11979
12109
  }
11980
12110
  if (msg.type === "subscribe_session" && typeof msg.sessionId === "string") {
12111
+ if (!wsAllows(principal, "history:read")) {
12112
+ deny(msg.type, "history:read");
12113
+ return;
12114
+ }
11981
12115
  deps.addSessionSubscriber(msg.sessionId, ws);
11982
12116
  if (deps.ptyManager.hasSession(msg.sessionId)) {
11983
12117
  const lines = await deps.ptyManager.getOutputLines(msg.sessionId, 200);
@@ -12026,6 +12160,10 @@ function createApiDeps(deps) {
12026
12160
  }
12027
12161
  }
12028
12162
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
12163
+ if (!wsAllows(principal, "session:control")) {
12164
+ deny(msg.type, "session:control");
12165
+ return;
12166
+ }
12029
12167
  deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
12030
12168
  }
12031
12169
  } catch {
@@ -12053,11 +12191,11 @@ var import_fs22 = require("fs");
12053
12191
 
12054
12192
  // src/services/cache-integrity/alertStore.ts
12055
12193
  var import_fs20 = require("fs");
12056
- var import_os10 = require("os");
12057
- var import_path19 = require("path");
12194
+ var import_os11 = require("os");
12195
+ var import_path20 = require("path");
12058
12196
  function alertStatePath() {
12059
- const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path19.join)((0, import_os10.homedir)(), ".threadbase");
12060
- return (0, import_path19.join)(dir, "cache-alert.json");
12197
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path20.join)((0, import_os11.homedir)(), ".threadbase");
12198
+ return (0, import_path20.join)(dir, "cache-alert.json");
12061
12199
  }
12062
12200
  function loadAlertState() {
12063
12201
  try {
@@ -12069,14 +12207,14 @@ function loadAlertState() {
12069
12207
  }
12070
12208
  function saveAlertState(state) {
12071
12209
  const path = alertStatePath();
12072
- (0, import_fs20.mkdirSync)((0, import_path19.dirname)(path), { recursive: true });
12210
+ (0, import_fs20.mkdirSync)((0, import_path20.dirname)(path), { recursive: true });
12073
12211
  (0, import_fs20.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
12074
12212
  `);
12075
12213
  }
12076
12214
 
12077
12215
  // src/services/cache-integrity/backup.ts
12078
12216
  var import_fs21 = require("fs");
12079
- var import_path20 = require("path");
12217
+ var import_path21 = require("path");
12080
12218
  var DEFAULT_RETAIN = 3;
12081
12219
  function retainCount() {
12082
12220
  const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
@@ -12087,13 +12225,13 @@ function timestamp(d) {
12087
12225
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
12088
12226
  }
12089
12227
  async function backupCacheDb(db, cacheDir) {
12090
- const backupsDir = (0, import_path20.join)(cacheDir, "backups");
12228
+ const backupsDir = (0, import_path21.join)(cacheDir, "backups");
12091
12229
  (0, import_fs21.mkdirSync)(backupsDir, { recursive: true });
12092
- const destPath = (0, import_path20.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
12230
+ const destPath = (0, import_path21.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
12093
12231
  await db.backup(destPath);
12094
12232
  const retain = retainCount();
12095
12233
  const backups = (0, import_fs21.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
12096
- const full = (0, import_path20.join)(backupsDir, f);
12234
+ const full = (0, import_path21.join)(backupsDir, f);
12097
12235
  return { full, mtime: (0, import_fs21.statSync)(full).mtimeMs };
12098
12236
  }).sort((a, b) => b.mtime - a.mtime);
12099
12237
  for (const stale of backups.slice(retain)) {
@@ -13282,6 +13420,7 @@ function autoResumeSkipReason(row, opts) {
13282
13420
  if (opts.now - row.status_updated_at > AUTO_RESUME_WINDOW_MS) return "too_old";
13283
13421
  if (!opts.projectExists(row.project_path)) return "project_missing";
13284
13422
  if (resumeIdForRow(row) == null) return "resume_identity_missing";
13423
+ if (!opts.historyExists(row)) return "history_missing";
13285
13424
  return null;
13286
13425
  }
13287
13426
  function planAutoResume(rows, opts) {
@@ -13543,7 +13682,34 @@ var SessionRegistryBoot = class {
13543
13682
  /** Resume only the recent sessions the user explicitly allowed us to start at boot. */
13544
13683
  async autoResumePreviousSessions(rows) {
13545
13684
  if (!this.autoResumeOnBoot) return;
13546
- const plan = planAutoResume(rows, { now: Date.now(), projectExists: import_fs25.existsSync });
13685
+ const now = Date.now();
13686
+ const baseOptions = { now, projectExists: import_fs25.existsSync, historyExists: () => true };
13687
+ const historyExists = /* @__PURE__ */ new Map();
13688
+ const preflights = /* @__PURE__ */ new Set();
13689
+ for (const row of rows) {
13690
+ if (autoResumeSkipReason(row, baseOptions) != null) {
13691
+ historyExists.set(row.session_id, false);
13692
+ continue;
13693
+ }
13694
+ while (preflights.size >= AUTO_RESUME_CONCURRENCY) {
13695
+ await Promise.race(preflights);
13696
+ }
13697
+ const preflight = (async () => {
13698
+ try {
13699
+ const target = await this.deps.resolveConversationTarget(row.session_id);
13700
+ historyExists.set(row.session_id, target.ok || target.reason !== "history_file_missing");
13701
+ } catch {
13702
+ historyExists.set(row.session_id, true);
13703
+ }
13704
+ })();
13705
+ preflights.add(preflight);
13706
+ void preflight.then(() => preflights.delete(preflight));
13707
+ }
13708
+ await Promise.all(preflights);
13709
+ const plan = planAutoResume(rows, {
13710
+ ...baseOptions,
13711
+ historyExists: (row) => historyExists.get(row.session_id) ?? false
13712
+ });
13547
13713
  const skippedBy = {};
13548
13714
  for (const { row, reason } of plan.skipped) {
13549
13715
  skippedBy[reason] = (skippedBy[reason] ?? 0) + 1;
@@ -14016,8 +14182,8 @@ function discoveredToResponse(d, conversationId) {
14016
14182
 
14017
14183
  // src/session-watchers.ts
14018
14184
  var import_fs26 = require("fs");
14019
- var import_os11 = require("os");
14020
- var import_path21 = require("path");
14185
+ var import_os12 = require("os");
14186
+ var import_path22 = require("path");
14021
14187
  var SessionWatchers = class {
14022
14188
  constructor(deps) {
14023
14189
  this.deps = deps;
@@ -14098,9 +14264,9 @@ var SessionWatchers = class {
14098
14264
  // was passed to Claude via --session-id so the filename matches from the start.
14099
14265
  watchForJsonl(sessionId, projectPath) {
14100
14266
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
14101
- const projectsDir = (0, import_path21.join)((0, import_os11.homedir)(), ".claude", "projects", encoded);
14267
+ const projectsDir = (0, import_path22.join)((0, import_os12.homedir)(), ".claude", "projects", encoded);
14102
14268
  const expectedFile = `${sessionId}.jsonl`;
14103
- const filePath = (0, import_path21.join)(projectsDir, expectedFile);
14269
+ const filePath = (0, import_path22.join)(projectsDir, expectedFile);
14104
14270
  const deadline = Date.now() + 12e4;
14105
14271
  let watcher = null;
14106
14272
  const cleanup = () => {
@@ -14122,10 +14288,10 @@ var SessionWatchers = class {
14122
14288
  if (!resolvedFilePath && (0, import_fs26.existsSync)(projectsDir)) {
14123
14289
  try {
14124
14290
  const now = Date.now();
14125
- const match = (0, import_fs26.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs26.statSync)((0, import_path21.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
14126
- ({ f }) => (0, import_path21.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path21.join)(projectsDir, f)) === sessionId
14291
+ const match = (0, import_fs26.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs26.statSync)((0, import_path22.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
14292
+ ({ f }) => (0, import_path22.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path22.join)(projectsDir, f)) === sessionId
14127
14293
  ).sort((a, b) => b.mtime - a.mtime)[0];
14128
- if (match) resolvedFilePath = (0, import_path21.join)(projectsDir, match.f);
14294
+ if (match) resolvedFilePath = (0, import_path22.join)(projectsDir, match.f);
14129
14295
  } catch {
14130
14296
  }
14131
14297
  }
@@ -14169,7 +14335,7 @@ var SessionWatchers = class {
14169
14335
  watchForCodexRollout(sessionId, projectPath) {
14170
14336
  const deadline = Date.now() + 12e4;
14171
14337
  const now = /* @__PURE__ */ new Date();
14172
- const dateDir = (0, import_path21.join)(
14338
+ const dateDir = (0, import_path22.join)(
14173
14339
  String(now.getFullYear()),
14174
14340
  String(now.getMonth() + 1).padStart(2, "0"),
14175
14341
  String(now.getDate()).padStart(2, "0")
@@ -14210,7 +14376,7 @@ var SessionWatchers = class {
14210
14376
  this.deps.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
14211
14377
  );
14212
14378
  for (const root of this.deps.codexRoots) {
14213
- const sessionsDir = (0, import_path21.join)(root, dateDir);
14379
+ const sessionsDir = (0, import_path22.join)(root, dateDir);
14214
14380
  if (!(0, import_fs26.existsSync)(sessionsDir)) continue;
14215
14381
  let candidateFiles;
14216
14382
  try {
@@ -14219,9 +14385,9 @@ var SessionWatchers = class {
14219
14385
  continue;
14220
14386
  }
14221
14387
  const nowMs = Date.now();
14222
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs26.statSync)((0, import_path21.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
14388
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs26.statSync)((0, import_path22.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
14223
14389
  for (const { f } of recentCandidates) {
14224
- const candidatePath = (0, import_path21.join)(sessionsDir, f);
14390
+ const candidatePath = (0, import_path22.join)(sessionsDir, f);
14225
14391
  const match = matchesProjectPath(candidatePath);
14226
14392
  if (!match) continue;
14227
14393
  if (boundElsewhere.has(match.id)) continue;
@@ -14625,7 +14791,7 @@ var StreamerServer = class {
14625
14791
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
14626
14792
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
14627
14793
  this.scanProfiles = config.scanProfiles;
14628
- this.codexRoots = config.codexRoots ?? [(0, import_path22.join)((0, import_os12.homedir)(), ".codex", "sessions")];
14794
+ this.codexRoots = config.codexRoots ?? [(0, import_path23.join)((0, import_os13.homedir)(), ".codex", "sessions")];
14629
14795
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
14630
14796
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
14631
14797
  const flagResolution = resolveFeatureFlags({
@@ -14642,8 +14808,8 @@ var StreamerServer = class {
14642
14808
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
14643
14809
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
14644
14810
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
14645
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path22.join)((0, import_os12.homedir)(), ".threadbase", "cache");
14646
- this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path22.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path22.join)((0, import_os12.homedir)(), ".threadbase"), "runtime.db");
14811
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path23.join)((0, import_os13.homedir)(), ".threadbase", "cache");
14812
+ this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
14647
14813
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
14648
14814
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
14649
14815
  this.scannerManager = new ScannerManager({
@@ -14675,7 +14841,8 @@ var StreamerServer = class {
14675
14841
  selfPtyEndedAt: this.selfPtyEndedAt,
14676
14842
  resumeSession: (opts) => this.sessionHandlers.resumeSession(opts),
14677
14843
  watchConversationFile: (sessionId, historyId) => this.sessionWatchers.watchConversationFile(sessionId, historyId),
14678
- broadcastSessionList: () => this.wsHub.broadcast(this.sessionListPayload())
14844
+ broadcastSessionList: () => this.wsHub.broadcast(this.sessionListPayload()),
14845
+ resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId)
14679
14846
  });
14680
14847
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
14681
14848
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -14818,7 +14985,7 @@ var StreamerServer = class {
14818
14985
  temporalClient,
14819
14986
  taskQueue: agentConfig.temporal.taskQueue
14820
14987
  });
14821
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path22.join)((0, import_path22.dirname)(this.cacheDir), "conversations");
14988
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path23.join)((0, import_path23.dirname)(this.cacheDir), "conversations");
14822
14989
  conversationWriter = createConversationWriter({
14823
14990
  baseDir: conversationsBaseDir
14824
14991
  });
@@ -15070,14 +15237,14 @@ var StreamerServer = class {
15070
15237
  }
15071
15238
  this.apnsClient = new ApnsClient(creds);
15072
15239
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
15073
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)();
15074
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os12.hostname)());
15240
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)();
15241
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os13.hostname)());
15075
15242
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
15076
15243
  repo: pushRepo,
15077
15244
  sender,
15078
15245
  sessionStore: this.sessionStore,
15079
15246
  serverId,
15080
- serverLabel: (0, import_os12.hostname)()
15247
+ serverLabel: (0, import_os13.hostname)()
15081
15248
  });
15082
15249
  this.liveActivityRenewal.start();
15083
15250
  this.log.info("Live Activity push enabled", {
@@ -15097,7 +15264,7 @@ var StreamerServer = class {
15097
15264
  */
15098
15265
  initWaitingInputPush(pushRepo) {
15099
15266
  const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
15100
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)();
15267
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)();
15101
15268
  this.waitingInputNotifier = new WaitingInputNotifier(
15102
15269
  sender,
15103
15270
  serverId,
@@ -15234,7 +15401,7 @@ var StreamerServer = class {
15234
15401
  let sessions = null;
15235
15402
  for (let attempt = 0; attempt < 2; attempt += 1) {
15236
15403
  const transport = await connectOrSpawnHost({
15237
- instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)()
15404
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)()
15238
15405
  });
15239
15406
  try {
15240
15407
  sessions = await this.ptyManager.useRemoteRunner(transport);
@@ -15291,6 +15458,7 @@ var StreamerServer = class {
15291
15458
  try {
15292
15459
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15293
15460
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
15461
+ this.devicesRepo = new DevicesRepository(this.runtimeStore.getDatabase());
15294
15462
  } catch (err) {
15295
15463
  const message = err instanceof Error ? err.message : String(err);
15296
15464
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -15310,7 +15478,7 @@ var StreamerServer = class {
15310
15478
  }
15311
15479
  try {
15312
15480
  this.cache = ConversationCache.open(
15313
- (0, import_path22.join)(this.cacheDir, "cache.db"),
15481
+ (0, import_path23.join)(this.cacheDir, "cache.db"),
15314
15482
  this.tailSize,
15315
15483
  void 0,
15316
15484
  {
@@ -15340,18 +15508,39 @@ var StreamerServer = class {
15340
15508
  if (copied > 0) {
15341
15509
  this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
15342
15510
  copied,
15511
+ table: "managed_sessions",
15343
15512
  event: "runtime.legacy_import"
15344
15513
  });
15345
15514
  }
15346
15515
  } catch (err) {
15347
15516
  this.log.warn("[registry] legacy managed_sessions copy failed", {
15348
15517
  event: "runtime.legacy_import_failed",
15518
+ table: "managed_sessions",
15519
+ err
15520
+ });
15521
+ }
15522
+ try {
15523
+ const result = this.runtimeStore?.importLegacyDevices(db);
15524
+ if (result && result.copied > 0) {
15525
+ this.log.info(
15526
+ `Moved ${result.copied} device row(s) from cache.db to runtime.db` + (result.purged ? "; removed the cache-side copy" : "; KEPT the cache-side copy (row count did not match after copy)"),
15527
+ {
15528
+ copied: result.copied,
15529
+ purged: result.purged,
15530
+ table: "devices",
15531
+ event: "runtime.legacy_import"
15532
+ }
15533
+ );
15534
+ }
15535
+ } catch (err) {
15536
+ this.log.warn("[registry] legacy devices move failed", {
15537
+ event: "runtime.legacy_import_failed",
15538
+ table: "devices",
15349
15539
  err
15350
15540
  });
15351
15541
  }
15352
15542
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
15353
15543
  this.pushRepo = new PushRepository(db);
15354
- this.devicesRepo = new DevicesRepository(db);
15355
15544
  this.initLiveActivityPush(this.pushRepo);
15356
15545
  this.initWaitingInputPush(this.pushRepo);
15357
15546
  this.cacheMonitor = new CacheIntegrityMonitor(
@@ -15677,7 +15866,7 @@ var StreamerServer = class {
15677
15866
  nonce: sealed.nonce,
15678
15867
  ephemeralPublicKey: sealed.ephemeralPublicKey,
15679
15868
  publicUrl: this.publicUrl,
15680
- machineName: (0, import_os12.hostname)(),
15869
+ machineName: (0, import_os13.hostname)(),
15681
15870
  ...device && {
15682
15871
  deviceId: device.deviceId,
15683
15872
  deviceToken: device.deviceToken,
@@ -15884,13 +16073,17 @@ var StreamerServer = class {
15884
16073
  conv = await this.conversationHandlers.findConversationByUuid(boundId);
15885
16074
  }
15886
16075
  }
16076
+ const cachedConvMeta = this.cache?.getMetaById(historyId);
16077
+ const cachedPath = cachedConvMeta?.filePath ? toNativeFilePath(cachedConvMeta.filePath) : null;
16078
+ const cachedCodexPath = (registryProvider === CODEX_CLI_PROVIDER || cachedConvMeta?.provider === CODEX_CLI_PROVIDER) && cachedPath != null && (0, import_fs27.existsSync)(cachedPath) ? cachedPath : null;
15887
16079
  const jsonlCwd = jsonlPath ? await this.conversationHandlers.readCwdFromJsonl(jsonlPath) : null;
15888
- const projectPath = jsonlCwd ?? conv?.projectPath;
16080
+ const projectPath = jsonlCwd ?? conv?.projectPath ?? (cachedCodexPath ? cachedConvMeta?.projectPath : null);
15889
16081
  if (!projectPath) {
15890
- if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
16082
+ if (!conv && !jsonlPath && !cachedCodexPath) {
16083
+ return { ok: false, reason: "history_file_missing" };
16084
+ }
15891
16085
  return { ok: false, reason: "no_project_path" };
15892
16086
  }
15893
- const cachedConvMeta = this.cache?.getMetaById(historyId);
15894
16087
  const provider = coerceProviderForRunner(
15895
16088
  conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
15896
16089
  );
@@ -15904,7 +16097,7 @@ var StreamerServer = class {
15904
16097
  // conversation. Kept separate from `jsonlPath` deliberately: feeding it to
15905
16098
  // conversationBusy() would newly arm the mtime heuristic for Codex, which
15906
16099
  // is exactly the over-broad signal the report ruled out.
15907
- historyPath: jsonlPath ?? conv?.filePath ?? null,
16100
+ historyPath: jsonlPath ?? conv?.filePath ?? cachedCodexPath ?? null,
15908
16101
  conv,
15909
16102
  projectPath,
15910
16103
  provider