@threadbase-sh/streamer 1.52.5 → 1.53.0

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)) {
@@ -13907,9 +14045,15 @@ function getSortValue(s, key) {
13907
14045
  case "projectName":
13908
14046
  return s.projectName;
13909
14047
  case "status":
13910
- return s.status;
14048
+ return statusSortValue(s);
13911
14049
  }
13912
14050
  }
14051
+ function statusSortValue(s) {
14052
+ const rank = s.status === "idle" ? 1 : 0;
14053
+ const activeAt = Date.parse(s.lastActivityAt ?? s.startedAt);
14054
+ const recency = String(1e15 - (Number.isNaN(activeAt) ? 0 : activeAt)).padStart(16, "0");
14055
+ return `${rank}:${recency}`;
14056
+ }
13913
14057
  function compareValues(a, b) {
13914
14058
  if (a === void 0 && b === void 0) return 0;
13915
14059
  if (a === void 0) return 1;
@@ -14044,8 +14188,8 @@ function discoveredToResponse(d, conversationId) {
14044
14188
 
14045
14189
  // src/session-watchers.ts
14046
14190
  var import_fs26 = require("fs");
14047
- var import_os11 = require("os");
14048
- var import_path21 = require("path");
14191
+ var import_os12 = require("os");
14192
+ var import_path22 = require("path");
14049
14193
  var SessionWatchers = class {
14050
14194
  constructor(deps) {
14051
14195
  this.deps = deps;
@@ -14126,9 +14270,9 @@ var SessionWatchers = class {
14126
14270
  // was passed to Claude via --session-id so the filename matches from the start.
14127
14271
  watchForJsonl(sessionId, projectPath) {
14128
14272
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
14129
- const projectsDir = (0, import_path21.join)((0, import_os11.homedir)(), ".claude", "projects", encoded);
14273
+ const projectsDir = (0, import_path22.join)((0, import_os12.homedir)(), ".claude", "projects", encoded);
14130
14274
  const expectedFile = `${sessionId}.jsonl`;
14131
- const filePath = (0, import_path21.join)(projectsDir, expectedFile);
14275
+ const filePath = (0, import_path22.join)(projectsDir, expectedFile);
14132
14276
  const deadline = Date.now() + 12e4;
14133
14277
  let watcher = null;
14134
14278
  const cleanup = () => {
@@ -14150,10 +14294,10 @@ var SessionWatchers = class {
14150
14294
  if (!resolvedFilePath && (0, import_fs26.existsSync)(projectsDir)) {
14151
14295
  try {
14152
14296
  const now = Date.now();
14153
- 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(
14154
- ({ f }) => (0, import_path21.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path21.join)(projectsDir, f)) === sessionId
14297
+ 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(
14298
+ ({ f }) => (0, import_path22.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path22.join)(projectsDir, f)) === sessionId
14155
14299
  ).sort((a, b) => b.mtime - a.mtime)[0];
14156
- if (match) resolvedFilePath = (0, import_path21.join)(projectsDir, match.f);
14300
+ if (match) resolvedFilePath = (0, import_path22.join)(projectsDir, match.f);
14157
14301
  } catch {
14158
14302
  }
14159
14303
  }
@@ -14197,7 +14341,7 @@ var SessionWatchers = class {
14197
14341
  watchForCodexRollout(sessionId, projectPath) {
14198
14342
  const deadline = Date.now() + 12e4;
14199
14343
  const now = /* @__PURE__ */ new Date();
14200
- const dateDir = (0, import_path21.join)(
14344
+ const dateDir = (0, import_path22.join)(
14201
14345
  String(now.getFullYear()),
14202
14346
  String(now.getMonth() + 1).padStart(2, "0"),
14203
14347
  String(now.getDate()).padStart(2, "0")
@@ -14238,7 +14382,7 @@ var SessionWatchers = class {
14238
14382
  this.deps.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
14239
14383
  );
14240
14384
  for (const root of this.deps.codexRoots) {
14241
- const sessionsDir = (0, import_path21.join)(root, dateDir);
14385
+ const sessionsDir = (0, import_path22.join)(root, dateDir);
14242
14386
  if (!(0, import_fs26.existsSync)(sessionsDir)) continue;
14243
14387
  let candidateFiles;
14244
14388
  try {
@@ -14247,9 +14391,9 @@ var SessionWatchers = class {
14247
14391
  continue;
14248
14392
  }
14249
14393
  const nowMs = Date.now();
14250
- 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);
14394
+ 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);
14251
14395
  for (const { f } of recentCandidates) {
14252
- const candidatePath = (0, import_path21.join)(sessionsDir, f);
14396
+ const candidatePath = (0, import_path22.join)(sessionsDir, f);
14253
14397
  const match = matchesProjectPath(candidatePath);
14254
14398
  if (!match) continue;
14255
14399
  if (boundElsewhere.has(match.id)) continue;
@@ -14653,7 +14797,7 @@ var StreamerServer = class {
14653
14797
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
14654
14798
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
14655
14799
  this.scanProfiles = config.scanProfiles;
14656
- this.codexRoots = config.codexRoots ?? [(0, import_path22.join)((0, import_os12.homedir)(), ".codex", "sessions")];
14800
+ this.codexRoots = config.codexRoots ?? [(0, import_path23.join)((0, import_os13.homedir)(), ".codex", "sessions")];
14657
14801
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
14658
14802
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
14659
14803
  const flagResolution = resolveFeatureFlags({
@@ -14670,8 +14814,8 @@ var StreamerServer = class {
14670
14814
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
14671
14815
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
14672
14816
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
14673
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path22.join)((0, import_os12.homedir)(), ".threadbase", "cache");
14674
- 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");
14817
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path23.join)((0, import_os13.homedir)(), ".threadbase", "cache");
14818
+ this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
14675
14819
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
14676
14820
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
14677
14821
  this.scannerManager = new ScannerManager({
@@ -14847,7 +14991,7 @@ var StreamerServer = class {
14847
14991
  temporalClient,
14848
14992
  taskQueue: agentConfig.temporal.taskQueue
14849
14993
  });
14850
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path22.join)((0, import_path22.dirname)(this.cacheDir), "conversations");
14994
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path23.join)((0, import_path23.dirname)(this.cacheDir), "conversations");
14851
14995
  conversationWriter = createConversationWriter({
14852
14996
  baseDir: conversationsBaseDir
14853
14997
  });
@@ -15099,14 +15243,14 @@ var StreamerServer = class {
15099
15243
  }
15100
15244
  this.apnsClient = new ApnsClient(creds);
15101
15245
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
15102
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)();
15103
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os12.hostname)());
15246
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)();
15247
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os13.hostname)());
15104
15248
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
15105
15249
  repo: pushRepo,
15106
15250
  sender,
15107
15251
  sessionStore: this.sessionStore,
15108
15252
  serverId,
15109
- serverLabel: (0, import_os12.hostname)()
15253
+ serverLabel: (0, import_os13.hostname)()
15110
15254
  });
15111
15255
  this.liveActivityRenewal.start();
15112
15256
  this.log.info("Live Activity push enabled", {
@@ -15126,7 +15270,7 @@ var StreamerServer = class {
15126
15270
  */
15127
15271
  initWaitingInputPush(pushRepo) {
15128
15272
  const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
15129
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)();
15273
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)();
15130
15274
  this.waitingInputNotifier = new WaitingInputNotifier(
15131
15275
  sender,
15132
15276
  serverId,
@@ -15263,7 +15407,7 @@ var StreamerServer = class {
15263
15407
  let sessions = null;
15264
15408
  for (let attempt = 0; attempt < 2; attempt += 1) {
15265
15409
  const transport = await connectOrSpawnHost({
15266
- instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os12.hostname)()
15410
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os13.hostname)()
15267
15411
  });
15268
15412
  try {
15269
15413
  sessions = await this.ptyManager.useRemoteRunner(transport);
@@ -15320,6 +15464,7 @@ var StreamerServer = class {
15320
15464
  try {
15321
15465
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15322
15466
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
15467
+ this.devicesRepo = new DevicesRepository(this.runtimeStore.getDatabase());
15323
15468
  } catch (err) {
15324
15469
  const message = err instanceof Error ? err.message : String(err);
15325
15470
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -15339,7 +15484,7 @@ var StreamerServer = class {
15339
15484
  }
15340
15485
  try {
15341
15486
  this.cache = ConversationCache.open(
15342
- (0, import_path22.join)(this.cacheDir, "cache.db"),
15487
+ (0, import_path23.join)(this.cacheDir, "cache.db"),
15343
15488
  this.tailSize,
15344
15489
  void 0,
15345
15490
  {
@@ -15369,18 +15514,39 @@ var StreamerServer = class {
15369
15514
  if (copied > 0) {
15370
15515
  this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
15371
15516
  copied,
15517
+ table: "managed_sessions",
15372
15518
  event: "runtime.legacy_import"
15373
15519
  });
15374
15520
  }
15375
15521
  } catch (err) {
15376
15522
  this.log.warn("[registry] legacy managed_sessions copy failed", {
15377
15523
  event: "runtime.legacy_import_failed",
15524
+ table: "managed_sessions",
15525
+ err
15526
+ });
15527
+ }
15528
+ try {
15529
+ const result = this.runtimeStore?.importLegacyDevices(db);
15530
+ if (result && result.copied > 0) {
15531
+ this.log.info(
15532
+ `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)"),
15533
+ {
15534
+ copied: result.copied,
15535
+ purged: result.purged,
15536
+ table: "devices",
15537
+ event: "runtime.legacy_import"
15538
+ }
15539
+ );
15540
+ }
15541
+ } catch (err) {
15542
+ this.log.warn("[registry] legacy devices move failed", {
15543
+ event: "runtime.legacy_import_failed",
15544
+ table: "devices",
15378
15545
  err
15379
15546
  });
15380
15547
  }
15381
15548
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
15382
15549
  this.pushRepo = new PushRepository(db);
15383
- this.devicesRepo = new DevicesRepository(db);
15384
15550
  this.initLiveActivityPush(this.pushRepo);
15385
15551
  this.initWaitingInputPush(this.pushRepo);
15386
15552
  this.cacheMonitor = new CacheIntegrityMonitor(
@@ -15706,7 +15872,7 @@ var StreamerServer = class {
15706
15872
  nonce: sealed.nonce,
15707
15873
  ephemeralPublicKey: sealed.ephemeralPublicKey,
15708
15874
  publicUrl: this.publicUrl,
15709
- machineName: (0, import_os12.hostname)(),
15875
+ machineName: (0, import_os13.hostname)(),
15710
15876
  ...device && {
15711
15877
  deviceId: device.deviceId,
15712
15878
  deviceToken: device.deviceToken,