@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.js CHANGED
@@ -555,7 +555,8 @@ function loadOrCreateApiKey() {
555
555
  const key = generateApiKey();
556
556
  mkdirSync(configDir(), { recursive: true });
557
557
  writeFileSync(configFile(), `api_key: ${key}
558
- `, "utf-8");
558
+ `, { encoding: "utf-8", mode: 384 });
559
+ chmodSync(configFile(), 384);
559
560
  return key;
560
561
  }
561
562
  function loadBrowseRoot() {
@@ -3863,8 +3864,8 @@ import { EventEmitter } from "events";
3863
3864
  import { existsSync as existsSync16 } from "fs";
3864
3865
  import { realpath as realpath2 } from "fs/promises";
3865
3866
  import { createServer as createServer2 } from "http";
3866
- import { homedir as homedir12, hostname as hostname3 } from "os";
3867
- import { dirname as dirname10, join as join23 } from "path";
3867
+ import { homedir as homedir13, hostname as hostname3 } from "os";
3868
+ import { dirname as dirname10, join as join24 } from "path";
3868
3869
 
3869
3870
  // src/api/app.ts
3870
3871
  import { Hono as Hono18 } from "hono";
@@ -4000,6 +4001,8 @@ var DevicesRepository = class {
4000
4001
  listStmt;
4001
4002
  revokeStmt;
4002
4003
  touchStmt;
4004
+ deleteStmt;
4005
+ deleteRevokedStmt;
4003
4006
  constructor(db) {
4004
4007
  this.insertStmt = db.prepare(`
4005
4008
  INSERT INTO devices (
@@ -4013,6 +4016,8 @@ var DevicesRepository = class {
4013
4016
  this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
4014
4017
  this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
4015
4018
  this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
4019
+ this.deleteStmt = db.prepare("DELETE FROM devices WHERE device_id = ?");
4020
+ this.deleteRevokedStmt = db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL");
4016
4021
  }
4017
4022
  /**
4018
4023
  * Record a newly paired device and mint its token.
@@ -4059,6 +4064,33 @@ var DevicesRepository = class {
4059
4064
  revoke(deviceId, now = Date.now()) {
4060
4065
  return this.revokeStmt.run(now, deviceId).changes > 0;
4061
4066
  }
4067
+ /**
4068
+ * Erase one device's record outright.
4069
+ *
4070
+ * Deliberately separate from `revoke`, which is a soft delete that keeps the
4071
+ * row so `list()` can show what happened. That audit trail is the right
4072
+ * default — but it meant a `devices` row, including the user-supplied `name`
4073
+ * ("Ronen's iPhone"), had no removal path at all once the registry moved to
4074
+ * runtime.db, which no command deletes. This is that path.
4075
+ *
4076
+ * Erasure is NOT revocation: deleting a row frees its `token_hash`, so a
4077
+ * device whose token is still on a phone somewhere stops being *known* rather
4078
+ * than being *refused*. Revoke first, delete second, is the safe order, and
4079
+ * `deleteRevoked()` exists so that is the easy thing to do.
4080
+ */
4081
+ delete(deviceId) {
4082
+ return this.deleteStmt.run(deviceId).changes > 0;
4083
+ }
4084
+ /**
4085
+ * Erase every already-revoked device. The bulk companion to `delete`, and the
4086
+ * one that is safe by construction: a revoked device is already refused, so
4087
+ * removing its row cannot restore access to anything.
4088
+ *
4089
+ * Returns the number of rows removed.
4090
+ */
4091
+ deleteRevoked() {
4092
+ return this.deleteRevokedStmt.run().changes;
4093
+ }
4062
4094
  touch(deviceId, now = Date.now()) {
4063
4095
  this.touchStmt.run(now, deviceId);
4064
4096
  }
@@ -4585,6 +4617,34 @@ var createDeviceRoutes = (deps) => {
4585
4617
  repo.revoke(id);
4586
4618
  return c.json({ ok: true, alreadyRevoked: false });
4587
4619
  });
4620
+ app.delete("/:id", (c) => {
4621
+ const repo = deps.devicesRepo();
4622
+ if (!repo) {
4623
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4624
+ }
4625
+ const id = c.req.param("id");
4626
+ const existing = repo.get(id);
4627
+ if (!existing) return c.json({ ok: true, alreadyDeleted: true });
4628
+ const force = c.req.query("force") === "1" || c.req.query("force") === "true";
4629
+ if (existing.revoked_at == null && !force) {
4630
+ return c.json(
4631
+ {
4632
+ error: "Revoke the device before deleting it, or pass ?force=1",
4633
+ code: "DEVICE_ACTIVE"
4634
+ },
4635
+ 409
4636
+ );
4637
+ }
4638
+ repo.delete(id);
4639
+ return c.json({ ok: true, alreadyDeleted: false });
4640
+ });
4641
+ app.delete("/", (c) => {
4642
+ const repo = deps.devicesRepo();
4643
+ if (!repo) {
4644
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
4645
+ }
4646
+ return c.json({ ok: true, deleted: repo.deleteRevoked() });
4647
+ });
4588
4648
  return app;
4589
4649
  };
4590
4650
 
@@ -5501,6 +5561,13 @@ var createMiscRoutes = (deps) => {
5501
5561
  // Same contract: this server serves GET /api/projects/summary, which the
5502
5562
  // Hub's grouped views need before they can draw a tree.
5503
5563
  projectSummary: true,
5564
+ // The paired-device registry lives in runtime.db, so it survives
5565
+ // `tb-streamer cache clear` and the integrity monitor's reset-and-rescan.
5566
+ // A client may only prefer its scoped device token over the shared API
5567
+ // key when this is true: on an older server the registry is inside
5568
+ // cache.db, where a documented troubleshooting step deletes it and every
5569
+ // device token with it. Absent means "old server, assume not durable".
5570
+ devicesDurable: true,
5504
5571
  // Delivery capability, not endpoint support: whether this server can
5505
5572
  // actually send a push, so mobile can hide an affordance instead of
5506
5573
  // registering tokens nothing will ever send to. Absent on older servers,
@@ -5911,7 +5978,13 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5911
5978
  const app = new Hono17();
5912
5979
  app.get(
5913
5980
  "/ws",
5914
- upgradeWebSocket(() => {
5981
+ // The principal is read here, at the upgrade, and captured for the life of
5982
+ // the socket. authMiddleware sets it because /ws is classified
5983
+ // `history:read`, but it only ever reaches the HTTP request — without
5984
+ // capturing it the socket has no principal at all, so every frame after
5985
+ // the upgrade is unauthorized-by-omission.
5986
+ upgradeWebSocket((c) => {
5987
+ const principal = c.get("principal") ?? null;
5915
5988
  let openWs = null;
5916
5989
  return {
5917
5990
  onOpen(_evt, ws) {
@@ -5921,7 +5994,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5921
5994
  deps.handleWsOpen(raw);
5922
5995
  },
5923
5996
  onMessage(evt, _ws) {
5924
- if (openWs) deps.handleWsMessage(openWs, evt.data);
5997
+ if (openWs) deps.handleWsMessage(openWs, evt.data, principal);
5925
5998
  },
5926
5999
  onClose(_evt, _ws) {
5927
6000
  if (openWs) deps.handleWsClose(openWs);
@@ -10651,6 +10724,11 @@ var SessionsRepository = class {
10651
10724
 
10652
10725
  // src/db/runtime-store.ts
10653
10726
  import Database2 from "better-sqlite3";
10727
+ import { homedir as homedir6 } from "os";
10728
+ import { join as join16 } from "path";
10729
+ function resolveRuntimeDbPath(override) {
10730
+ return override ?? process.env.THREADBASE_RUNTIME_DB ?? join16(process.env.THREADBASE_CONFIG_DIR ?? join16(homedir6(), ".threadbase"), "runtime.db");
10731
+ }
10654
10732
  var RuntimeStore = class _RuntimeStore {
10655
10733
  constructor(db) {
10656
10734
  this.db = db;
@@ -10668,23 +10746,64 @@ var RuntimeStore = class _RuntimeStore {
10668
10746
  /**
10669
10747
  * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
10670
10748
  *
10671
- * Non-destructive by design: the source table is left in place so an older
10672
- * streamer rolled back onto the same machine still finds its registry. Runs
10673
- * only when this file's table is empty, so a second boot is a no-op rather
10674
- * than a re-copy that would resurrect rows deleted since.
10675
- *
10676
10749
  * Returns the number of rows copied.
10677
10750
  */
10678
10751
  importLegacyManagedSessions(source) {
10679
- const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
10752
+ return this.importLegacyTable(source, "managed_sessions");
10753
+ }
10754
+ /**
10755
+ * One-time move of `devices` rows out of `cache.db`, where the registry used
10756
+ * to live (migration `011_create_devices.sql`).
10757
+ *
10758
+ * Losing this table invalidates every device token ever issued, and cache.db
10759
+ * is the file `tb-streamer cache clear` deletes and the integrity monitor
10760
+ * rebuilds — see `runtime-migrations/003_create_devices.sql`.
10761
+ *
10762
+ * Unlike `managed_sessions`, this one MOVES rather than copies: the source
10763
+ * rows are deleted once the copy is verified. A `devices` row carries a
10764
+ * user-supplied label ("Ronen's iPhone"), and leaving a second copy of that
10765
+ * on disk indefinitely — in the one file the user is told to delete when
10766
+ * something goes wrong — is more retained personal data than the rollback
10767
+ * path is worth. Recovering from a rollback is re-scanning a pairing QR.
10768
+ *
10769
+ * The delete is conditional on the copy being complete: `INSERT OR IGNORE`
10770
+ * can silently skip a row, so the destination count must match what was read
10771
+ * before anything is removed. A mismatch keeps the source and reports
10772
+ * `purged: false` rather than throwing — the import itself still succeeded,
10773
+ * and keeping data is the safe direction to fail in.
10774
+ */
10775
+ importLegacyDevices(source) {
10776
+ const copied = this.importLegacyTable(source, "devices");
10777
+ if (copied === 0) return { copied: 0, purged: false };
10778
+ const landed = this.db.prepare("SELECT COUNT(*) AS n FROM devices").get().n;
10779
+ if (landed !== copied) return { copied, purged: false };
10780
+ source.prepare("DELETE FROM devices").run();
10781
+ return { copied, purged: true };
10782
+ }
10783
+ /**
10784
+ * Copy a whole table out of a pre-split `cache.db` into this file.
10785
+ *
10786
+ * The copy itself is non-destructive — the source is left in place, so an
10787
+ * older streamer rolled back onto the same machine still finds its data.
10788
+ * `importLegacyDevices` deletes the source afterwards for its own reasons;
10789
+ * `managed_sessions` does not. Runs only when this file's table is empty, so
10790
+ * a second boot is a no-op rather than a re-copy that would resurrect rows
10791
+ * deleted since.
10792
+ *
10793
+ * The table name is interpolated into SQL, so it is typed as a closed union
10794
+ * rather than `string` — the set of tables that can ever be lifted is known
10795
+ * at compile time, and that is what keeps a caller from making this a hole.
10796
+ */
10797
+ importLegacyTable(source, table) {
10798
+ const existing = this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
10680
10799
  if (existing.n > 0) return 0;
10681
- const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
10800
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
10682
10801
  if (!hasTable) return 0;
10683
- const rows = source.prepare("SELECT * FROM managed_sessions").all();
10802
+ const rows = source.prepare(`SELECT * FROM ${table}`).all();
10684
10803
  if (rows.length === 0) return 0;
10685
10804
  const columns = Object.keys(rows[0]);
10686
10805
  const insert = this.db.prepare(
10687
- `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
10806
+ `INSERT OR IGNORE INTO ${table} (${columns.join(", ")})
10688
10807
  VALUES (${columns.map((c) => `@${c}`).join(", ")})`
10689
10808
  );
10690
10809
  this.db.transaction((batch) => {
@@ -10926,14 +11045,14 @@ import { spawn as spawn2 } from "child_process";
10926
11045
 
10927
11046
  // src/pty-host/socket.ts
10928
11047
  import { createConnection, createServer } from "net";
10929
- import { homedir as homedir6 } from "os";
10930
- import { join as join16 } from "path";
11048
+ import { homedir as homedir7 } from "os";
11049
+ import { join as join17 } from "path";
10931
11050
  function hostSocketPath(instanceId) {
10932
11051
  if (process.platform === "win32") {
10933
11052
  return `\\\\.\\pipe\\threadbase-pty-host-${instanceId}`;
10934
11053
  }
10935
- const dir = process.env.THREADBASE_CONFIG_DIR ?? join16(homedir6(), ".threadbase");
10936
- return join16(dir, "run", `pty-host-${instanceId}.sock`);
11054
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join17(homedir7(), ".threadbase");
11055
+ return join17(dir, "run", `pty-host-${instanceId}.sock`);
10937
11056
  }
10938
11057
  function socketTransport(socket) {
10939
11058
  socket.setEncoding("utf8");
@@ -11008,8 +11127,8 @@ import {
11008
11127
  ConversationScanner
11009
11128
  } from "@threadbase-sh/scanner";
11010
11129
  import { statSync as statSync7 } from "fs";
11011
- import { homedir as homedir8 } from "os";
11012
- import { join as join18 } from "path";
11130
+ import { homedir as homedir9 } from "os";
11131
+ import { join as join19 } from "path";
11013
11132
 
11014
11133
  // src/services/cache/cacheMetadata.ts
11015
11134
  function getCacheMetadata(repo, key) {
@@ -11109,9 +11228,9 @@ function refreshConversationCache(deps) {
11109
11228
 
11110
11229
  // src/services/conversations/shouldRefreshProjectsFromHdd.ts
11111
11230
  import { readdirSync as readdirSync4, statSync as statSync6 } from "fs";
11112
- import { homedir as homedir7 } from "os";
11113
- import { join as join17 } from "path";
11114
- var DEFAULT_PROJECTS_DIR = join17(homedir7(), ".claude", "projects");
11231
+ import { homedir as homedir8 } from "os";
11232
+ import { join as join18 } from "path";
11233
+ var DEFAULT_PROJECTS_DIR = join18(homedir8(), ".claude", "projects");
11115
11234
  function maxProjectsTreeMtimeMs(projectsDir) {
11116
11235
  let maxMs;
11117
11236
  try {
@@ -11123,7 +11242,7 @@ function maxProjectsTreeMtimeMs(projectsDir) {
11123
11242
  for (const ent of readdirSync4(projectsDir, { withFileTypes: true })) {
11124
11243
  if (!ent.isDirectory()) continue;
11125
11244
  try {
11126
- const childMs = statSync6(join17(projectsDir, ent.name)).mtimeMs;
11245
+ const childMs = statSync6(join18(projectsDir, ent.name)).mtimeMs;
11127
11246
  if (childMs > maxMs) maxMs = childMs;
11128
11247
  } catch {
11129
11248
  }
@@ -11295,9 +11414,9 @@ var ScannerManager = class {
11295
11414
  projectsDirs() {
11296
11415
  const profiles = this.deps.scanProfiles;
11297
11416
  if (profiles && profiles.length > 0) {
11298
- return profiles.filter((p) => p.enabled).map((p) => join18(p.configDir, "projects"));
11417
+ return profiles.filter((p) => p.enabled).map((p) => join19(p.configDir, "projects"));
11299
11418
  }
11300
- return [join18(homedir8(), ".claude", "projects")];
11419
+ return [join19(homedir9(), ".claude", "projects")];
11301
11420
  }
11302
11421
  // ─── staleness ────────────────────────────────────────────────────
11303
11422
  // Drain the stale set and disarm the flag together. The caller owns the
@@ -11578,8 +11697,8 @@ import { statSync as statSync9 } from "fs";
11578
11697
 
11579
11698
  // src/handlers/handleListProjects.ts
11580
11699
  import { closeSync as closeSync4, openSync as openSync4, readdirSync as readdirSync5, readSync as readSync4, statSync as statSync8 } from "fs";
11581
- import { homedir as homedir9 } from "os";
11582
- import { join as join19 } from "path";
11700
+ import { homedir as homedir10 } from "os";
11701
+ import { join as join20 } from "path";
11583
11702
  var HEAD_BYTES = 64 * 1024;
11584
11703
  var MAX_FILES_PROBED = 3;
11585
11704
  function readRecordedCwd(dir) {
@@ -11592,7 +11711,7 @@ function readRecordedCwd(dir) {
11592
11711
  for (const file of files.slice(0, MAX_FILES_PROBED)) {
11593
11712
  let fd;
11594
11713
  try {
11595
- fd = openSync4(join19(dir, file), "r");
11714
+ fd = openSync4(join20(dir, file), "r");
11596
11715
  const buf = Buffer.alloc(HEAD_BYTES);
11597
11716
  const bytes = readSync4(fd, buf, 0, HEAD_BYTES, 0);
11598
11717
  for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
@@ -11616,11 +11735,11 @@ function decodeProjectPath(dirName) {
11616
11735
  function handleListProjects(url, res) {
11617
11736
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
11618
11737
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
11619
- const projectsDir = join19(homedir9(), ".claude", "projects");
11738
+ const projectsDir = join20(homedir10(), ".claude", "projects");
11620
11739
  let entries;
11621
11740
  try {
11622
11741
  entries = readdirSync5(projectsDir).map((dirName) => {
11623
- const fullPath = join19(projectsDir, dirName);
11742
+ const fullPath = join20(projectsDir, dirName);
11624
11743
  let mtime = 0;
11625
11744
  try {
11626
11745
  mtime = statSync8(fullPath).mtimeMs;
@@ -11635,7 +11754,7 @@ function handleListProjects(url, res) {
11635
11754
  }
11636
11755
  const total = entries.length;
11637
11756
  const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
11638
- const path = readRecordedCwd(join19(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
11757
+ const path = readRecordedCwd(join20(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
11639
11758
  const name = path.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
11640
11759
  return { name, path, dirName };
11641
11760
  });
@@ -11644,6 +11763,9 @@ function handleListProjects(url, res) {
11644
11763
  }
11645
11764
 
11646
11765
  // src/server-wiring.ts
11766
+ function wsAllows(principal, required) {
11767
+ return principal === null || hasCapability(principal, required);
11768
+ }
11647
11769
  function createConversationWatcherEvents(deps) {
11648
11770
  return {
11649
11771
  onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
@@ -11925,7 +12047,15 @@ function createApiDeps(deps) {
11925
12047
  const alertMsg = deps.cacheMonitor()?.wsMessage();
11926
12048
  if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
11927
12049
  },
11928
- handleWsMessage: async (ws, raw) => {
12050
+ handleWsMessage: async (ws, raw, principal) => {
12051
+ const deny = (type, required) => {
12052
+ deps.log().warn(`[ws.capability_denied] ${type} requires ${required}`, {
12053
+ event: "ws.capability_denied",
12054
+ type,
12055
+ required,
12056
+ ...principal?.deviceId ? { deviceId: principal.deviceId } : {}
12057
+ });
12058
+ };
11929
12059
  try {
11930
12060
  const msg = JSON.parse(String(raw));
11931
12061
  if (msg.type === "register" && typeof msg.clientId === "string") {
@@ -11935,6 +12065,10 @@ function createApiDeps(deps) {
11935
12065
  deps.wsToClientId.set(ws, msg.clientId);
11936
12066
  }
11937
12067
  if (msg.type === "subscribe_session" && typeof msg.sessionId === "string") {
12068
+ if (!wsAllows(principal, "history:read")) {
12069
+ deny(msg.type, "history:read");
12070
+ return;
12071
+ }
11938
12072
  deps.addSessionSubscriber(msg.sessionId, ws);
11939
12073
  if (deps.ptyManager.hasSession(msg.sessionId)) {
11940
12074
  const lines = await deps.ptyManager.getOutputLines(msg.sessionId, 200);
@@ -11983,6 +12117,10 @@ function createApiDeps(deps) {
11983
12117
  }
11984
12118
  }
11985
12119
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
12120
+ if (!wsAllows(principal, "session:control")) {
12121
+ deny(msg.type, "session:control");
12122
+ return;
12123
+ }
11986
12124
  deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
11987
12125
  }
11988
12126
  } catch {
@@ -12010,11 +12148,11 @@ import { existsSync as existsSync12 } from "fs";
12010
12148
 
12011
12149
  // src/services/cache-integrity/alertStore.ts
12012
12150
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
12013
- import { homedir as homedir10 } from "os";
12014
- import { dirname as dirname9, join as join20 } from "path";
12151
+ import { homedir as homedir11 } from "os";
12152
+ import { dirname as dirname9, join as join21 } from "path";
12015
12153
  function alertStatePath() {
12016
- const dir = process.env.THREADBASE_CONFIG_DIR ?? join20(homedir10(), ".threadbase");
12017
- return join20(dir, "cache-alert.json");
12154
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join21(homedir11(), ".threadbase");
12155
+ return join21(dir, "cache-alert.json");
12018
12156
  }
12019
12157
  function loadAlertState() {
12020
12158
  try {
@@ -12033,7 +12171,7 @@ function saveAlertState(state) {
12033
12171
 
12034
12172
  // src/services/cache-integrity/backup.ts
12035
12173
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync6, statSync as statSync10, unlinkSync } from "fs";
12036
- import { join as join21 } from "path";
12174
+ import { join as join22 } from "path";
12037
12175
  var DEFAULT_RETAIN = 3;
12038
12176
  function retainCount() {
12039
12177
  const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
@@ -12044,13 +12182,13 @@ function timestamp(d) {
12044
12182
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
12045
12183
  }
12046
12184
  async function backupCacheDb(db, cacheDir) {
12047
- const backupsDir = join21(cacheDir, "backups");
12185
+ const backupsDir = join22(cacheDir, "backups");
12048
12186
  mkdirSync5(backupsDir, { recursive: true });
12049
- const destPath = join21(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
12187
+ const destPath = join22(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
12050
12188
  await db.backup(destPath);
12051
12189
  const retain = retainCount();
12052
12190
  const backups = readdirSync6(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
12053
- const full = join21(backupsDir, f);
12191
+ const full = join22(backupsDir, f);
12054
12192
  return { full, mtime: statSync10(full).mtimeMs };
12055
12193
  }).sort((a, b) => b.mtime - a.mtime);
12056
12194
  for (const stale of backups.slice(retain)) {
@@ -13864,9 +14002,15 @@ function getSortValue(s, key) {
13864
14002
  case "projectName":
13865
14003
  return s.projectName;
13866
14004
  case "status":
13867
- return s.status;
14005
+ return statusSortValue(s);
13868
14006
  }
13869
14007
  }
14008
+ function statusSortValue(s) {
14009
+ const rank = s.status === "idle" ? 1 : 0;
14010
+ const activeAt = Date.parse(s.lastActivityAt ?? s.startedAt);
14011
+ const recency = String(1e15 - (Number.isNaN(activeAt) ? 0 : activeAt)).padStart(16, "0");
14012
+ return `${rank}:${recency}`;
14013
+ }
13870
14014
  function compareValues(a, b) {
13871
14015
  if (a === void 0 && b === void 0) return 0;
13872
14016
  if (a === void 0) return 1;
@@ -14001,8 +14145,8 @@ function discoveredToResponse(d, conversationId) {
14001
14145
 
14002
14146
  // src/session-watchers.ts
14003
14147
  import { existsSync as existsSync15, watch as fsWatch, readdirSync as readdirSync7, readFileSync as readFileSync9, statSync as statSync12 } from "fs";
14004
- import { homedir as homedir11 } from "os";
14005
- import { basename as basename6, join as join22 } from "path";
14148
+ import { homedir as homedir12 } from "os";
14149
+ import { basename as basename6, join as join23 } from "path";
14006
14150
  var SessionWatchers = class {
14007
14151
  constructor(deps) {
14008
14152
  this.deps = deps;
@@ -14083,9 +14227,9 @@ var SessionWatchers = class {
14083
14227
  // was passed to Claude via --session-id so the filename matches from the start.
14084
14228
  watchForJsonl(sessionId, projectPath) {
14085
14229
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
14086
- const projectsDir = join22(homedir11(), ".claude", "projects", encoded);
14230
+ const projectsDir = join23(homedir12(), ".claude", "projects", encoded);
14087
14231
  const expectedFile = `${sessionId}.jsonl`;
14088
- const filePath = join22(projectsDir, expectedFile);
14232
+ const filePath = join23(projectsDir, expectedFile);
14089
14233
  const deadline = Date.now() + 12e4;
14090
14234
  let watcher = null;
14091
14235
  const cleanup = () => {
@@ -14107,10 +14251,10 @@ var SessionWatchers = class {
14107
14251
  if (!resolvedFilePath && existsSync15(projectsDir)) {
14108
14252
  try {
14109
14253
  const now = Date.now();
14110
- const match = readdirSync7(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync12(join22(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
14111
- ({ f }) => basename6(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join22(projectsDir, f)) === sessionId
14254
+ const match = readdirSync7(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync12(join23(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
14255
+ ({ f }) => basename6(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join23(projectsDir, f)) === sessionId
14112
14256
  ).sort((a, b) => b.mtime - a.mtime)[0];
14113
- if (match) resolvedFilePath = join22(projectsDir, match.f);
14257
+ if (match) resolvedFilePath = join23(projectsDir, match.f);
14114
14258
  } catch {
14115
14259
  }
14116
14260
  }
@@ -14154,7 +14298,7 @@ var SessionWatchers = class {
14154
14298
  watchForCodexRollout(sessionId, projectPath) {
14155
14299
  const deadline = Date.now() + 12e4;
14156
14300
  const now = /* @__PURE__ */ new Date();
14157
- const dateDir = join22(
14301
+ const dateDir = join23(
14158
14302
  String(now.getFullYear()),
14159
14303
  String(now.getMonth() + 1).padStart(2, "0"),
14160
14304
  String(now.getDate()).padStart(2, "0")
@@ -14195,7 +14339,7 @@ var SessionWatchers = class {
14195
14339
  this.deps.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
14196
14340
  );
14197
14341
  for (const root of this.deps.codexRoots) {
14198
- const sessionsDir = join22(root, dateDir);
14342
+ const sessionsDir = join23(root, dateDir);
14199
14343
  if (!existsSync15(sessionsDir)) continue;
14200
14344
  let candidateFiles;
14201
14345
  try {
@@ -14204,9 +14348,9 @@ var SessionWatchers = class {
14204
14348
  continue;
14205
14349
  }
14206
14350
  const nowMs = Date.now();
14207
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync12(join22(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
14351
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync12(join23(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
14208
14352
  for (const { f } of recentCandidates) {
14209
- const candidatePath = join22(sessionsDir, f);
14353
+ const candidatePath = join23(sessionsDir, f);
14210
14354
  const match = matchesProjectPath(candidatePath);
14211
14355
  if (!match) continue;
14212
14356
  if (boundElsewhere.has(match.id)) continue;
@@ -14610,7 +14754,7 @@ var StreamerServer = class {
14610
14754
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
14611
14755
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
14612
14756
  this.scanProfiles = config.scanProfiles;
14613
- this.codexRoots = config.codexRoots ?? [join23(homedir12(), ".codex", "sessions")];
14757
+ this.codexRoots = config.codexRoots ?? [join24(homedir13(), ".codex", "sessions")];
14614
14758
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
14615
14759
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
14616
14760
  const flagResolution = resolveFeatureFlags({
@@ -14627,8 +14771,8 @@ var StreamerServer = class {
14627
14771
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
14628
14772
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
14629
14773
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
14630
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join23(homedir12(), ".threadbase", "cache");
14631
- this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join23(process.env.THREADBASE_CONFIG_DIR ?? join23(homedir12(), ".threadbase"), "runtime.db");
14774
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join24(homedir13(), ".threadbase", "cache");
14775
+ this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
14632
14776
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
14633
14777
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
14634
14778
  this.scannerManager = new ScannerManager({
@@ -14804,7 +14948,7 @@ var StreamerServer = class {
14804
14948
  temporalClient,
14805
14949
  taskQueue: agentConfig.temporal.taskQueue
14806
14950
  });
14807
- const conversationsBaseDir = agentConfig.conversationsDir || join23(dirname10(this.cacheDir), "conversations");
14951
+ const conversationsBaseDir = agentConfig.conversationsDir || join24(dirname10(this.cacheDir), "conversations");
14808
14952
  conversationWriter = createConversationWriter({
14809
14953
  baseDir: conversationsBaseDir
14810
14954
  });
@@ -15277,6 +15421,7 @@ var StreamerServer = class {
15277
15421
  try {
15278
15422
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
15279
15423
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
15424
+ this.devicesRepo = new DevicesRepository(this.runtimeStore.getDatabase());
15280
15425
  } catch (err) {
15281
15426
  const message = err instanceof Error ? err.message : String(err);
15282
15427
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -15296,7 +15441,7 @@ var StreamerServer = class {
15296
15441
  }
15297
15442
  try {
15298
15443
  this.cache = ConversationCache.open(
15299
- join23(this.cacheDir, "cache.db"),
15444
+ join24(this.cacheDir, "cache.db"),
15300
15445
  this.tailSize,
15301
15446
  void 0,
15302
15447
  {
@@ -15326,18 +15471,39 @@ var StreamerServer = class {
15326
15471
  if (copied > 0) {
15327
15472
  this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
15328
15473
  copied,
15474
+ table: "managed_sessions",
15329
15475
  event: "runtime.legacy_import"
15330
15476
  });
15331
15477
  }
15332
15478
  } catch (err) {
15333
15479
  this.log.warn("[registry] legacy managed_sessions copy failed", {
15334
15480
  event: "runtime.legacy_import_failed",
15481
+ table: "managed_sessions",
15482
+ err
15483
+ });
15484
+ }
15485
+ try {
15486
+ const result = this.runtimeStore?.importLegacyDevices(db);
15487
+ if (result && result.copied > 0) {
15488
+ this.log.info(
15489
+ `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)"),
15490
+ {
15491
+ copied: result.copied,
15492
+ purged: result.purged,
15493
+ table: "devices",
15494
+ event: "runtime.legacy_import"
15495
+ }
15496
+ );
15497
+ }
15498
+ } catch (err) {
15499
+ this.log.warn("[registry] legacy devices move failed", {
15500
+ event: "runtime.legacy_import_failed",
15501
+ table: "devices",
15335
15502
  err
15336
15503
  });
15337
15504
  }
15338
15505
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
15339
15506
  this.pushRepo = new PushRepository(db);
15340
- this.devicesRepo = new DevicesRepository(db);
15341
15507
  this.initLiveActivityPush(this.pushRepo);
15342
15508
  this.initWaitingInputPush(this.pushRepo);
15343
15509
  this.cacheMonitor = new CacheIntegrityMonitor(