@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/cli.cjs CHANGED
@@ -3608,7 +3608,7 @@ var require_thread_stream = __commonJS({
3608
3608
  var { version: version2 } = require_package();
3609
3609
  var { EventEmitter: EventEmitter5 } = require("events");
3610
3610
  var { Worker } = require("worker_threads");
3611
- var { join: join34 } = require("path");
3611
+ var { join: join35 } = require("path");
3612
3612
  var { pathToFileURL } = require("url");
3613
3613
  var { wait } = require_wait();
3614
3614
  var {
@@ -3659,7 +3659,7 @@ var require_thread_stream = __commonJS({
3659
3659
  function createWorker(stream, opts) {
3660
3660
  const { filename, workerData } = opts;
3661
3661
  const bundlerOverrides = "__bundlerPathsOverrides" in globalThis ? globalThis.__bundlerPathsOverrides : {};
3662
- const toExecute = bundlerOverrides["thread-stream-worker"] || join34(__dirname, "lib", "worker.js");
3662
+ const toExecute = bundlerOverrides["thread-stream-worker"] || join35(__dirname, "lib", "worker.js");
3663
3663
  const worker = new Worker(toExecute, {
3664
3664
  ...opts.workerOpts,
3665
3665
  name: opts.workerOpts?.name || "thread-stream",
@@ -4127,7 +4127,7 @@ var require_transport = __commonJS({
4127
4127
  var { createRequire } = require("module");
4128
4128
  var { existsSync: existsSync21 } = require("fs");
4129
4129
  var getCallers = require_caller();
4130
- var { join: join34, isAbsolute: isAbsolute2, sep: sep3 } = require("path");
4130
+ var { join: join35, isAbsolute: isAbsolute2, sep: sep3 } = require("path");
4131
4131
  var { fileURLToPath: fileURLToPath3 } = require("url");
4132
4132
  var sleep = require_atomic_sleep();
4133
4133
  var onExit = require_on_exit_leak_free();
@@ -4280,7 +4280,7 @@ var require_transport = __commonJS({
4280
4280
  throw new Error("only one of target or targets can be specified");
4281
4281
  }
4282
4282
  if (targets) {
4283
- target = bundlerOverrides["pino-worker"] || join34(__dirname, "worker.js");
4283
+ target = bundlerOverrides["pino-worker"] || join35(__dirname, "worker.js");
4284
4284
  options.targets = targets.filter((dest) => dest.target).map((dest) => {
4285
4285
  return {
4286
4286
  ...dest,
@@ -4298,7 +4298,7 @@ var require_transport = __commonJS({
4298
4298
  });
4299
4299
  });
4300
4300
  } else if (pipeline2) {
4301
- target = bundlerOverrides["pino-worker"] || join34(__dirname, "worker.js");
4301
+ target = bundlerOverrides["pino-worker"] || join35(__dirname, "worker.js");
4302
4302
  options.pipelines = [pipeline2.map((dest) => {
4303
4303
  return {
4304
4304
  ...dest,
@@ -4321,7 +4321,7 @@ var require_transport = __commonJS({
4321
4321
  return origin;
4322
4322
  }
4323
4323
  if (origin === "pino/file") {
4324
- return join34(__dirname, "..", "file.js");
4324
+ return join35(__dirname, "..", "file.js");
4325
4325
  }
4326
4326
  let fixTarget2;
4327
4327
  for (const filePath of callers) {
@@ -5302,7 +5302,7 @@ var require_safe_stable_stringify = __commonJS({
5302
5302
  return circularValue;
5303
5303
  }
5304
5304
  let res = "";
5305
- let join34 = ",";
5305
+ let join35 = ",";
5306
5306
  const originalIndentation = indentation;
5307
5307
  if (Array.isArray(value)) {
5308
5308
  if (value.length === 0) {
@@ -5316,7 +5316,7 @@ var require_safe_stable_stringify = __commonJS({
5316
5316
  indentation += spacer;
5317
5317
  res += `
5318
5318
  ${indentation}`;
5319
- join34 = `,
5319
+ join35 = `,
5320
5320
  ${indentation}`;
5321
5321
  }
5322
5322
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -5324,13 +5324,13 @@ ${indentation}`;
5324
5324
  for (; i < maximumValuesToStringify - 1; i++) {
5325
5325
  const tmp2 = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
5326
5326
  res += tmp2 !== void 0 ? tmp2 : "null";
5327
- res += join34;
5327
+ res += join35;
5328
5328
  }
5329
5329
  const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation);
5330
5330
  res += tmp !== void 0 ? tmp : "null";
5331
5331
  if (value.length - 1 > maximumBreadth) {
5332
5332
  const removedKeys = value.length - maximumBreadth - 1;
5333
- res += `${join34}"... ${getItemCount(removedKeys)} not stringified"`;
5333
+ res += `${join35}"... ${getItemCount(removedKeys)} not stringified"`;
5334
5334
  }
5335
5335
  if (spacer !== "") {
5336
5336
  res += `
@@ -5351,7 +5351,7 @@ ${originalIndentation}`;
5351
5351
  let separator = "";
5352
5352
  if (spacer !== "") {
5353
5353
  indentation += spacer;
5354
- join34 = `,
5354
+ join35 = `,
5355
5355
  ${indentation}`;
5356
5356
  whitespace = " ";
5357
5357
  }
@@ -5365,13 +5365,13 @@ ${indentation}`;
5365
5365
  const tmp = stringifyFnReplacer(key2, value, stack, replacer, spacer, indentation);
5366
5366
  if (tmp !== void 0) {
5367
5367
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
5368
- separator = join34;
5368
+ separator = join35;
5369
5369
  }
5370
5370
  }
5371
5371
  if (keyLength > maximumBreadth) {
5372
5372
  const removedKeys = keyLength - maximumBreadth;
5373
5373
  res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"`;
5374
- separator = join34;
5374
+ separator = join35;
5375
5375
  }
5376
5376
  if (spacer !== "" && separator.length > 1) {
5377
5377
  res = `
@@ -5412,7 +5412,7 @@ ${originalIndentation}`;
5412
5412
  }
5413
5413
  const originalIndentation = indentation;
5414
5414
  let res = "";
5415
- let join34 = ",";
5415
+ let join35 = ",";
5416
5416
  if (Array.isArray(value)) {
5417
5417
  if (value.length === 0) {
5418
5418
  return "[]";
@@ -5425,7 +5425,7 @@ ${originalIndentation}`;
5425
5425
  indentation += spacer;
5426
5426
  res += `
5427
5427
  ${indentation}`;
5428
- join34 = `,
5428
+ join35 = `,
5429
5429
  ${indentation}`;
5430
5430
  }
5431
5431
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
@@ -5433,13 +5433,13 @@ ${indentation}`;
5433
5433
  for (; i < maximumValuesToStringify - 1; i++) {
5434
5434
  const tmp2 = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
5435
5435
  res += tmp2 !== void 0 ? tmp2 : "null";
5436
- res += join34;
5436
+ res += join35;
5437
5437
  }
5438
5438
  const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation);
5439
5439
  res += tmp !== void 0 ? tmp : "null";
5440
5440
  if (value.length - 1 > maximumBreadth) {
5441
5441
  const removedKeys = value.length - maximumBreadth - 1;
5442
- res += `${join34}"... ${getItemCount(removedKeys)} not stringified"`;
5442
+ res += `${join35}"... ${getItemCount(removedKeys)} not stringified"`;
5443
5443
  }
5444
5444
  if (spacer !== "") {
5445
5445
  res += `
@@ -5452,7 +5452,7 @@ ${originalIndentation}`;
5452
5452
  let whitespace = "";
5453
5453
  if (spacer !== "") {
5454
5454
  indentation += spacer;
5455
- join34 = `,
5455
+ join35 = `,
5456
5456
  ${indentation}`;
5457
5457
  whitespace = " ";
5458
5458
  }
@@ -5461,7 +5461,7 @@ ${indentation}`;
5461
5461
  const tmp = stringifyArrayReplacer(key2, value[key2], stack, replacer, spacer, indentation);
5462
5462
  if (tmp !== void 0) {
5463
5463
  res += `${separator}${strEscape(key2)}:${whitespace}${tmp}`;
5464
- separator = join34;
5464
+ separator = join35;
5465
5465
  }
5466
5466
  }
5467
5467
  if (spacer !== "" && separator.length > 1) {
@@ -5519,20 +5519,20 @@ ${originalIndentation}`;
5519
5519
  indentation += spacer;
5520
5520
  let res2 = `
5521
5521
  ${indentation}`;
5522
- const join35 = `,
5522
+ const join36 = `,
5523
5523
  ${indentation}`;
5524
5524
  const maximumValuesToStringify = Math.min(value.length, maximumBreadth);
5525
5525
  let i = 0;
5526
5526
  for (; i < maximumValuesToStringify - 1; i++) {
5527
5527
  const tmp2 = stringifyIndent(String(i), value[i], stack, spacer, indentation);
5528
5528
  res2 += tmp2 !== void 0 ? tmp2 : "null";
5529
- res2 += join35;
5529
+ res2 += join36;
5530
5530
  }
5531
5531
  const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation);
5532
5532
  res2 += tmp !== void 0 ? tmp : "null";
5533
5533
  if (value.length - 1 > maximumBreadth) {
5534
5534
  const removedKeys = value.length - maximumBreadth - 1;
5535
- res2 += `${join35}"... ${getItemCount(removedKeys)} not stringified"`;
5535
+ res2 += `${join36}"... ${getItemCount(removedKeys)} not stringified"`;
5536
5536
  }
5537
5537
  res2 += `
5538
5538
  ${originalIndentation}`;
@@ -5548,16 +5548,16 @@ ${originalIndentation}`;
5548
5548
  return '"[Object]"';
5549
5549
  }
5550
5550
  indentation += spacer;
5551
- const join34 = `,
5551
+ const join35 = `,
5552
5552
  ${indentation}`;
5553
5553
  let res = "";
5554
5554
  let separator = "";
5555
5555
  let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth);
5556
5556
  if (isTypedArrayWithEntries(value)) {
5557
- res += stringifyTypedArray(value, join34, maximumBreadth);
5557
+ res += stringifyTypedArray(value, join35, maximumBreadth);
5558
5558
  keys = keys.slice(value.length);
5559
5559
  maximumPropertiesToStringify -= value.length;
5560
- separator = join34;
5560
+ separator = join35;
5561
5561
  }
5562
5562
  if (deterministic) {
5563
5563
  keys = sort(keys, comparator);
@@ -5568,13 +5568,13 @@ ${indentation}`;
5568
5568
  const tmp = stringifyIndent(key2, value[key2], stack, spacer, indentation);
5569
5569
  if (tmp !== void 0) {
5570
5570
  res += `${separator}${strEscape(key2)}: ${tmp}`;
5571
- separator = join34;
5571
+ separator = join35;
5572
5572
  }
5573
5573
  }
5574
5574
  if (keyLength > maximumBreadth) {
5575
5575
  const removedKeys = keyLength - maximumBreadth;
5576
5576
  res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"`;
5577
- separator = join34;
5577
+ separator = join35;
5578
5578
  }
5579
5579
  if (separator !== "") {
5580
5580
  res = `
@@ -6285,7 +6285,8 @@ function loadOrCreateApiKey() {
6285
6285
  const key = generateApiKey();
6286
6286
  (0, import_fs.mkdirSync)(configDir(), { recursive: true });
6287
6287
  (0, import_fs.writeFileSync)(configFile(), `api_key: ${key}
6288
- `, "utf-8");
6288
+ `, { encoding: "utf-8", mode: 384 });
6289
+ (0, import_fs.chmodSync)(configFile(), 384);
6289
6290
  return key;
6290
6291
  }
6291
6292
  function loadBrowseRoot() {
@@ -96870,6 +96871,252 @@ var require_lib3 = __commonJS({
96870
96871
  }
96871
96872
  });
96872
96873
 
96874
+ // src/services/security/capabilities.ts
96875
+ function isCapability(value) {
96876
+ return typeof value === "string" && CAPABILITIES.includes(value);
96877
+ }
96878
+ function capabilitiesForPreset(preset) {
96879
+ return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
96880
+ }
96881
+ function legacyPrincipal() {
96882
+ return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
96883
+ }
96884
+ function hasCapability(principal, required2) {
96885
+ return principal.capabilities.includes(required2);
96886
+ }
96887
+ function requiredCapability(path2, method) {
96888
+ if (path2.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
96889
+ return "history:read";
96890
+ }
96891
+ let best = null;
96892
+ for (const [prefix, cap] of ROUTE_CAPABILITIES) {
96893
+ if (path2.startsWith(prefix) && (best === null || prefix.length > best.len)) {
96894
+ best = { len: prefix.length, cap };
96895
+ }
96896
+ }
96897
+ return best?.cap ?? null;
96898
+ }
96899
+ var CAPABILITIES, FULL_CAPABILITIES, READ_ONLY_CAPABILITIES, ROUTE_CAPABILITIES;
96900
+ var init_capabilities = __esm({
96901
+ "src/services/security/capabilities.ts"() {
96902
+ "use strict";
96903
+ CAPABILITIES = [
96904
+ "history:read",
96905
+ // read conversations, search
96906
+ "session:control",
96907
+ // start, resume, send input, interrupt
96908
+ "fs:browse",
96909
+ // browse the project tree
96910
+ "fs:upload",
96911
+ // upload files into a project
96912
+ "notifications",
96913
+ // register for push
96914
+ "admin"
96915
+ // rotate keys, manage devices
96916
+ ];
96917
+ FULL_CAPABILITIES = [
96918
+ "history:read",
96919
+ "session:control",
96920
+ "fs:browse",
96921
+ "fs:upload",
96922
+ "notifications"
96923
+ ];
96924
+ READ_ONLY_CAPABILITIES = ["history:read"];
96925
+ ROUTE_CAPABILITIES = [
96926
+ // Most specific first for readability; matching sorts by length anyway.
96927
+ ["/api/sessions/", "session:control"],
96928
+ ["/api/sessions", "history:read"],
96929
+ // listing sessions is a read
96930
+ ["/api/conversations", "history:read"],
96931
+ ["/api/projects", "history:read"],
96932
+ ["/api/search", "history:read"],
96933
+ ["/api/providers", "history:read"],
96934
+ ["/api/browse", "fs:browse"],
96935
+ ["/api/upload", "fs:upload"],
96936
+ ["/api/push", "notifications"],
96937
+ ["/api/devices", "admin"],
96938
+ ["/api/config", "admin"],
96939
+ ["/api/auth/rotate", "admin"],
96940
+ ["/api/backup", "admin"],
96941
+ // Server identity and capability discovery. A read-only device must be able
96942
+ // to see WHICH server it is talking to and what it supports, or it cannot
96943
+ // render anything at all.
96944
+ ["/api/info", "history:read"],
96945
+ ["/api/profiles", "history:read"],
96946
+ ["/api/diagnostics", "history:read"],
96947
+ ["/api/cache/alert", "history:read"],
96948
+ // Client log shipping: any authenticated client may report its own errors.
96949
+ // Gating this behind a capability would silence diagnostics from exactly the
96950
+ // devices most likely to be misbehaving.
96951
+ ["/api/__client-log", "history:read"],
96952
+ // Logs viewer is localhost-only and already bypasses this middleware; the
96953
+ // mapping exists so a remote request is classified rather than denied as
96954
+ // unclassified.
96955
+ ["/api/logs", "admin"],
96956
+ // Pairing routes other than the public exchange (e.g. minting a token).
96957
+ ["/api/pair", "admin"],
96958
+ // The live WebSocket. Subscribing is a read — terminal output, session
96959
+ // updates, conversation events. Control still flows through the HTTP input
96960
+ // routes, which carry their own capability check, so a read-only device can
96961
+ // watch a session stream without being able to drive it.
96962
+ ["/ws", "history:read"],
96963
+ // Progress webhook (multi-agent). Authenticated by HMAC in the handler and
96964
+ // already skipped by the middleware; classified so a stray request is denied
96965
+ // by rule rather than as "unclassified".
96966
+ ["/internal/sessions", "admin"]
96967
+ ];
96968
+ }
96969
+ });
96970
+
96971
+ // src/db/repositories/devices.repository.ts
96972
+ var devices_repository_exports = {};
96973
+ __export(devices_repository_exports, {
96974
+ DevicesRepository: () => DevicesRepository,
96975
+ generateDeviceToken: () => generateDeviceToken,
96976
+ hashDeviceToken: () => hashDeviceToken,
96977
+ parseCapabilities: () => parseCapabilities,
96978
+ safeHashEquals: () => safeHashEquals,
96979
+ toDeviceView: () => toDeviceView
96980
+ });
96981
+ function generateDeviceToken() {
96982
+ return (0, import_crypto3.randomBytes)(32).toString("base64url");
96983
+ }
96984
+ function hashDeviceToken(token) {
96985
+ return (0, import_crypto3.createHash)("sha256").update(token).digest("hex");
96986
+ }
96987
+ function safeHashEquals(a, b2) {
96988
+ if (a.length !== b2.length) return false;
96989
+ return (0, import_crypto3.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b2, "utf8"));
96990
+ }
96991
+ function parseCapabilities(raw2) {
96992
+ try {
96993
+ const parsed = JSON.parse(raw2);
96994
+ if (!Array.isArray(parsed)) return [];
96995
+ return parsed.filter(isCapability);
96996
+ } catch {
96997
+ return [];
96998
+ }
96999
+ }
97000
+ function toDeviceView(row) {
97001
+ return {
97002
+ deviceId: row.device_id,
97003
+ name: row.name,
97004
+ capabilities: parseCapabilities(row.capabilities),
97005
+ createdAt: row.created_at,
97006
+ lastSeenAt: row.last_seen_at,
97007
+ revokedAt: row.revoked_at
97008
+ };
97009
+ }
97010
+ var import_crypto3, DevicesRepository;
97011
+ var init_devices_repository = __esm({
97012
+ "src/db/repositories/devices.repository.ts"() {
97013
+ "use strict";
97014
+ import_crypto3 = require("crypto");
97015
+ init_capabilities();
97016
+ DevicesRepository = class {
97017
+ insertStmt;
97018
+ byTokenHashStmt;
97019
+ byIdStmt;
97020
+ listStmt;
97021
+ revokeStmt;
97022
+ touchStmt;
97023
+ deleteStmt;
97024
+ deleteRevokedStmt;
97025
+ constructor(db) {
97026
+ this.insertStmt = db.prepare(`
97027
+ INSERT INTO devices (
97028
+ device_id, public_key, token_hash, name, capabilities, created_at
97029
+ ) VALUES (
97030
+ @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
97031
+ )
97032
+ `);
97033
+ this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
97034
+ this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
97035
+ this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
97036
+ this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
97037
+ this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
97038
+ this.deleteStmt = db.prepare("DELETE FROM devices WHERE device_id = ?");
97039
+ this.deleteRevokedStmt = db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL");
97040
+ }
97041
+ /**
97042
+ * Record a newly paired device and mint its token.
97043
+ *
97044
+ * The raw token is returned to the caller and never stored — this is the only
97045
+ * moment it exists outside the client.
97046
+ */
97047
+ register(args) {
97048
+ const deviceId = (0, import_crypto3.randomUUID)();
97049
+ const deviceToken = generateDeviceToken();
97050
+ const capabilities = capabilitiesForPreset(args.preset ?? "full");
97051
+ this.insertStmt.run({
97052
+ device_id: deviceId,
97053
+ public_key: args.publicKey,
97054
+ token_hash: hashDeviceToken(deviceToken),
97055
+ name: args.name ?? null,
97056
+ capabilities: JSON.stringify(capabilities),
97057
+ created_at: args.now ?? Date.now()
97058
+ });
97059
+ return { deviceId, deviceToken, capabilities };
97060
+ }
97061
+ /**
97062
+ * Resolve a presented token to a device, or null.
97063
+ *
97064
+ * Returns null for a revoked device, so revocation takes effect on the very
97065
+ * next request with no cache to go stale.
97066
+ */
97067
+ authenticate(token) {
97068
+ const hash2 = hashDeviceToken(token);
97069
+ const row = this.byTokenHashStmt.get(hash2);
97070
+ if (!row) return null;
97071
+ if (!safeHashEquals(row.token_hash, hash2)) return null;
97072
+ if (row.revoked_at != null) return null;
97073
+ return row;
97074
+ }
97075
+ get(deviceId) {
97076
+ return this.byIdStmt.get(deviceId) ?? null;
97077
+ }
97078
+ /** All devices, including revoked ones — an audit surface needs the history. */
97079
+ list() {
97080
+ return this.listStmt.all().map(toDeviceView);
97081
+ }
97082
+ /** Revoke one device. Others are untouched — no key rotation, no collateral. */
97083
+ revoke(deviceId, now = Date.now()) {
97084
+ return this.revokeStmt.run(now, deviceId).changes > 0;
97085
+ }
97086
+ /**
97087
+ * Erase one device's record outright.
97088
+ *
97089
+ * Deliberately separate from `revoke`, which is a soft delete that keeps the
97090
+ * row so `list()` can show what happened. That audit trail is the right
97091
+ * default — but it meant a `devices` row, including the user-supplied `name`
97092
+ * ("Ronen's iPhone"), had no removal path at all once the registry moved to
97093
+ * runtime.db, which no command deletes. This is that path.
97094
+ *
97095
+ * Erasure is NOT revocation: deleting a row frees its `token_hash`, so a
97096
+ * device whose token is still on a phone somewhere stops being *known* rather
97097
+ * than being *refused*. Revoke first, delete second, is the safe order, and
97098
+ * `deleteRevoked()` exists so that is the easy thing to do.
97099
+ */
97100
+ delete(deviceId) {
97101
+ return this.deleteStmt.run(deviceId).changes > 0;
97102
+ }
97103
+ /**
97104
+ * Erase every already-revoked device. The bulk companion to `delete`, and the
97105
+ * one that is safe by construction: a revoked device is already refused, so
97106
+ * removing its row cannot restore access to anything.
97107
+ *
97108
+ * Returns the number of rows removed.
97109
+ */
97110
+ deleteRevoked() {
97111
+ return this.deleteRevokedStmt.run().changes;
97112
+ }
97113
+ touch(deviceId, now = Date.now()) {
97114
+ this.touchStmt.run(now, deviceId);
97115
+ }
97116
+ };
97117
+ }
97118
+ });
97119
+
96873
97120
  // src/platform.ts
96874
97121
  function isWindowsExecutablePath(path2) {
96875
97122
  const dot = path2.lastIndexOf(".");
@@ -102777,6 +103024,132 @@ var require_out4 = __commonJS({
102777
103024
  }
102778
103025
  });
102779
103026
 
103027
+ // src/db/query-timing.ts
103028
+ function deriveLabel(sql) {
103029
+ const verb = /^\s*(\w+)/.exec(sql)?.[1]?.toLowerCase() ?? "sql";
103030
+ const table = /(?:from|into|update)\s+([A-Za-z_]\w*)/i.exec(sql)?.[1] ?? "?";
103031
+ return `${verb}:${table}`;
103032
+ }
103033
+ function resolveSlowMs() {
103034
+ const raw2 = process.env.THREADBASE_DB_SLOW_QUERY_MS;
103035
+ if (raw2 === void 0 || raw2 === "") return DEFAULT_SLOW_QUERY_MS;
103036
+ const parsed = Number(raw2);
103037
+ return Number.isFinite(parsed) ? parsed : DEFAULT_SLOW_QUERY_MS;
103038
+ }
103039
+ function record2(label, ms2, rows, slowMs) {
103040
+ if (slowMs > 0 && ms2 >= slowMs) {
103041
+ log2.warn(
103042
+ `[db] slow query ${label} ${ms2.toFixed(1)}ms rows=${rows}`,
103043
+ { event: "db.slow_query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
103044
+ "pino"
103045
+ );
103046
+ return;
103047
+ }
103048
+ if (log2.pino.isLevelEnabled("debug")) {
103049
+ log2.debug(
103050
+ `[db] ${label} ${ms2.toFixed(2)}ms rows=${rows}`,
103051
+ { event: "db.query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
103052
+ "pino"
103053
+ );
103054
+ }
103055
+ }
103056
+ function rowsOf(method, result) {
103057
+ if (method === "all") return Array.isArray(result) ? result.length : 0;
103058
+ if (method === "run") return result?.changes ?? 0;
103059
+ return result === void 0 ? 0 : 1;
103060
+ }
103061
+ function instrumentDatabase(db, options = {}) {
103062
+ const slowMs = options.slowMs ?? resolveSlowMs();
103063
+ const prepare = db.prepare.bind(db);
103064
+ db.prepare = ((sql) => {
103065
+ const stmt = prepare(sql);
103066
+ const box = { label: deriveLabel(sql) };
103067
+ Object.defineProperty(stmt, LABEL, { value: box, configurable: true });
103068
+ for (const method of ["get", "all", "run"]) {
103069
+ const original = stmt[method].bind(stmt);
103070
+ Object.defineProperty(stmt, method, {
103071
+ configurable: true,
103072
+ writable: true,
103073
+ value: (...args) => {
103074
+ const started = performance.now();
103075
+ const result = original(...args);
103076
+ record2(box.label, performance.now() - started, rowsOf(method, result), slowMs);
103077
+ return result;
103078
+ }
103079
+ });
103080
+ }
103081
+ return stmt;
103082
+ });
103083
+ return db;
103084
+ }
103085
+ function labelStatements(statements) {
103086
+ for (const [name, stmt] of Object.entries(statements)) {
103087
+ const box = stmt?.[LABEL];
103088
+ if (box) box.label = name;
103089
+ }
103090
+ }
103091
+ var log2, DEFAULT_SLOW_QUERY_MS, LABEL;
103092
+ var init_query_timing = __esm({
103093
+ "src/db/query-timing.ts"() {
103094
+ "use strict";
103095
+ init_logger();
103096
+ log2 = getLogger("db");
103097
+ DEFAULT_SLOW_QUERY_MS = 35;
103098
+ LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
103099
+ }
103100
+ });
103101
+
103102
+ // src/db/sqlite-migrate.ts
103103
+ function getMigrationsDir() {
103104
+ if (typeof import_meta2 !== "undefined" && import_meta2.url) {
103105
+ return (0, import_path14.dirname)((0, import_url6.fileURLToPath)(import_meta2.url));
103106
+ }
103107
+ return __dirname;
103108
+ }
103109
+ function resolveMigrationsDir(name = "migrations") {
103110
+ return (0, import_path14.join)(getMigrationsDir(), name);
103111
+ }
103112
+ function runSqliteMigrations(db, migrationsDir) {
103113
+ db.exec(SCHEMA_MIGRATIONS_SQL);
103114
+ const dir = migrationsDir ?? resolveMigrationsDir();
103115
+ const files = (0, import_fs15.readdirSync)(dir).filter((f2) => f2.endsWith(".sql")).sort();
103116
+ const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
103117
+ const appliedSet = new Set(appliedRows.map((r) => r.id));
103118
+ const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
103119
+ const applied = [];
103120
+ const skipped = [];
103121
+ for (const file2 of files) {
103122
+ if (appliedSet.has(file2)) {
103123
+ skipped.push(file2);
103124
+ continue;
103125
+ }
103126
+ const sql = (0, import_fs15.readFileSync)((0, import_path14.join)(dir, file2), "utf-8");
103127
+ const tx = db.transaction(() => {
103128
+ db.exec(sql);
103129
+ recordApplied.run(file2, (/* @__PURE__ */ new Date()).toISOString());
103130
+ });
103131
+ tx();
103132
+ applied.push(file2);
103133
+ }
103134
+ return { applied, skipped };
103135
+ }
103136
+ var import_fs15, import_path14, import_url6, import_meta2, SCHEMA_MIGRATIONS_SQL;
103137
+ var init_sqlite_migrate = __esm({
103138
+ "src/db/sqlite-migrate.ts"() {
103139
+ "use strict";
103140
+ import_fs15 = require("fs");
103141
+ import_path14 = require("path");
103142
+ import_url6 = require("url");
103143
+ import_meta2 = {};
103144
+ SCHEMA_MIGRATIONS_SQL = `
103145
+ CREATE TABLE IF NOT EXISTS schema_migrations (
103146
+ id TEXT PRIMARY KEY,
103147
+ applied_at TEXT NOT NULL
103148
+ );
103149
+ `;
103150
+ }
103151
+ });
103152
+
102780
103153
  // src/services/questions/codexScreen.ts
102781
103154
  function parseCodexNumberedOptions(lines) {
102782
103155
  const options = [];
@@ -114874,6 +115247,113 @@ var init_esm2 = __esm({
114874
115247
  }
114875
115248
  });
114876
115249
 
115250
+ // src/db/runtime-store.ts
115251
+ var runtime_store_exports = {};
115252
+ __export(runtime_store_exports, {
115253
+ RuntimeStore: () => RuntimeStore,
115254
+ resolveRuntimeDbPath: () => resolveRuntimeDbPath
115255
+ });
115256
+ function resolveRuntimeDbPath(override) {
115257
+ return override ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path23.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path23.join)((0, import_os9.homedir)(), ".threadbase"), "runtime.db");
115258
+ }
115259
+ var import_better_sqlite33, import_os9, import_path23, RuntimeStore;
115260
+ var init_runtime_store = __esm({
115261
+ "src/db/runtime-store.ts"() {
115262
+ "use strict";
115263
+ import_better_sqlite33 = __toESM(require("better-sqlite3"), 1);
115264
+ import_os9 = require("os");
115265
+ import_path23 = require("path");
115266
+ init_query_timing();
115267
+ init_sqlite_migrate();
115268
+ RuntimeStore = class _RuntimeStore {
115269
+ constructor(db) {
115270
+ this.db = db;
115271
+ }
115272
+ db;
115273
+ static open(dbPath, migrationsDir) {
115274
+ const db = instrumentDatabase(new import_better_sqlite33.default(dbPath));
115275
+ db.pragma("journal_mode = WAL");
115276
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
115277
+ return new _RuntimeStore(db);
115278
+ }
115279
+ getDatabase() {
115280
+ return this.db;
115281
+ }
115282
+ /**
115283
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
115284
+ *
115285
+ * Returns the number of rows copied.
115286
+ */
115287
+ importLegacyManagedSessions(source) {
115288
+ return this.importLegacyTable(source, "managed_sessions");
115289
+ }
115290
+ /**
115291
+ * One-time move of `devices` rows out of `cache.db`, where the registry used
115292
+ * to live (migration `011_create_devices.sql`).
115293
+ *
115294
+ * Losing this table invalidates every device token ever issued, and cache.db
115295
+ * is the file `tb-streamer cache clear` deletes and the integrity monitor
115296
+ * rebuilds — see `runtime-migrations/003_create_devices.sql`.
115297
+ *
115298
+ * Unlike `managed_sessions`, this one MOVES rather than copies: the source
115299
+ * rows are deleted once the copy is verified. A `devices` row carries a
115300
+ * user-supplied label ("Ronen's iPhone"), and leaving a second copy of that
115301
+ * on disk indefinitely — in the one file the user is told to delete when
115302
+ * something goes wrong — is more retained personal data than the rollback
115303
+ * path is worth. Recovering from a rollback is re-scanning a pairing QR.
115304
+ *
115305
+ * The delete is conditional on the copy being complete: `INSERT OR IGNORE`
115306
+ * can silently skip a row, so the destination count must match what was read
115307
+ * before anything is removed. A mismatch keeps the source and reports
115308
+ * `purged: false` rather than throwing — the import itself still succeeded,
115309
+ * and keeping data is the safe direction to fail in.
115310
+ */
115311
+ importLegacyDevices(source) {
115312
+ const copied = this.importLegacyTable(source, "devices");
115313
+ if (copied === 0) return { copied: 0, purged: false };
115314
+ const landed = this.db.prepare("SELECT COUNT(*) AS n FROM devices").get().n;
115315
+ if (landed !== copied) return { copied, purged: false };
115316
+ source.prepare("DELETE FROM devices").run();
115317
+ return { copied, purged: true };
115318
+ }
115319
+ /**
115320
+ * Copy a whole table out of a pre-split `cache.db` into this file.
115321
+ *
115322
+ * The copy itself is non-destructive — the source is left in place, so an
115323
+ * older streamer rolled back onto the same machine still finds its data.
115324
+ * `importLegacyDevices` deletes the source afterwards for its own reasons;
115325
+ * `managed_sessions` does not. Runs only when this file's table is empty, so
115326
+ * a second boot is a no-op rather than a re-copy that would resurrect rows
115327
+ * deleted since.
115328
+ *
115329
+ * The table name is interpolated into SQL, so it is typed as a closed union
115330
+ * rather than `string` — the set of tables that can ever be lifted is known
115331
+ * at compile time, and that is what keeps a caller from making this a hole.
115332
+ */
115333
+ importLegacyTable(source, table) {
115334
+ const existing = this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
115335
+ if (existing.n > 0) return 0;
115336
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
115337
+ if (!hasTable) return 0;
115338
+ const rows = source.prepare(`SELECT * FROM ${table}`).all();
115339
+ if (rows.length === 0) return 0;
115340
+ const columns = Object.keys(rows[0]);
115341
+ const insert = this.db.prepare(
115342
+ `INSERT OR IGNORE INTO ${table} (${columns.join(", ")})
115343
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
115344
+ );
115345
+ this.db.transaction((batch) => {
115346
+ for (const row of batch) insert.run(row);
115347
+ })(rows);
115348
+ return rows.length;
115349
+ }
115350
+ close() {
115351
+ this.db.close();
115352
+ }
115353
+ };
115354
+ }
115355
+ });
115356
+
114877
115357
  // node_modules/@xterm/headless/lib-headless/xterm-headless.js
114878
115358
  var require_xterm_headless = __commonJS({
114879
115359
  "node_modules/@xterm/headless/lib-headless/xterm-headless.js"(exports2) {
@@ -119964,8 +120444,8 @@ var init_pty_shared = __esm({
119964
120444
 
119965
120445
  // src/services/questions/codexGateAnswers.ts
119966
120446
  function gateAnswersPath() {
119967
- const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path23.join)((0, import_os9.homedir)(), ".threadbase");
119968
- return (0, import_path23.join)(dir, "gate-answers.json");
120447
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path24.join)((0, import_os10.homedir)(), ".threadbase");
120448
+ return (0, import_path24.join)(dir, "gate-answers.json");
119969
120449
  }
119970
120450
  function loadGateAnswers() {
119971
120451
  try {
@@ -119977,7 +120457,7 @@ function loadGateAnswers() {
119977
120457
  }
119978
120458
  function saveGateAnswer(key, value) {
119979
120459
  const path2 = gateAnswersPath();
119980
- (0, import_fs24.mkdirSync)((0, import_path23.dirname)(path2), { recursive: true });
120460
+ (0, import_fs24.mkdirSync)((0, import_path24.dirname)(path2), { recursive: true });
119981
120461
  (0, import_fs24.writeFileSync)(path2, `${JSON.stringify({ ...loadGateAnswers(), [key]: value }, null, 2)}
119982
120462
  `);
119983
120463
  }
@@ -119990,13 +120470,13 @@ function rememberedGateDigit(gate) {
119990
120470
  }
119991
120471
  return answers.codexTrustGate === "yes" ? "1" : null;
119992
120472
  }
119993
- var import_fs24, import_os9, import_path23;
120473
+ var import_fs24, import_os10, import_path24;
119994
120474
  var init_codexGateAnswers = __esm({
119995
120475
  "src/services/questions/codexGateAnswers.ts"() {
119996
120476
  "use strict";
119997
120477
  import_fs24 = require("fs");
119998
- import_os9 = require("os");
119999
- import_path23 = require("path");
120478
+ import_os10 = require("os");
120479
+ import_path24 = require("path");
120000
120480
  }
120001
120481
  });
120002
120482
 
@@ -120057,13 +120537,13 @@ function toPublicSession(s3) {
120057
120537
  ...s3.filePath != null && { filePath: s3.filePath }
120058
120538
  };
120059
120539
  }
120060
- var import_crypto9, import_fs25, import_path24, OUTPUT_BUFFER_MAX, INPUT_HISTORY_MAX, QUIET_DETECT_MS, CODEX_READY_FALLBACK_MS, SUBMIT_BYTES, CODEX_SUBMIT_DELAY_MS, CODEX_SUBMIT_MAX_WAIT_MS, CODEX_SUBMIT_STALE_MS, CodexPtyRunner;
120540
+ var import_crypto9, import_fs25, import_path25, OUTPUT_BUFFER_MAX, INPUT_HISTORY_MAX, QUIET_DETECT_MS, CODEX_READY_FALLBACK_MS, SUBMIT_BYTES, CODEX_SUBMIT_DELAY_MS, CODEX_SUBMIT_MAX_WAIT_MS, CODEX_SUBMIT_STALE_MS, CodexPtyRunner;
120061
120541
  var init_codex_pty_runner = __esm({
120062
120542
  "src/codex-pty-runner.ts"() {
120063
120543
  "use strict";
120064
120544
  import_crypto9 = require("crypto");
120065
120545
  import_fs25 = require("fs");
120066
- import_path24 = require("path");
120546
+ import_path25 = require("path");
120067
120547
  init_logger();
120068
120548
  init_platform();
120069
120549
  init_providers();
@@ -120164,7 +120644,7 @@ var init_codex_pty_runner = __esm({
120164
120644
  // resume, fresh and fork is argv.
120165
120645
  async launch(sessionId, args, options) {
120166
120646
  const nodePty = await loadPty();
120167
- const projectName = options.projectName ?? (0, import_path24.basename)(options.projectPath);
120647
+ const projectName = options.projectName ?? (0, import_path25.basename)(options.projectPath);
120168
120648
  let proc;
120169
120649
  try {
120170
120650
  proc = nodePty.spawn(resolveCodexExe(), args, {
@@ -121407,13 +121887,13 @@ function toPublicSession2(s3) {
121407
121887
  ...s3.firstMessageText != null && { firstMessageText: s3.firstMessageText }
121408
121888
  };
121409
121889
  }
121410
- var import_crypto10, import_fs26, import_path25, OUTPUT_BUFFER_MAX2, INPUT_HISTORY_MAX2, CLAUDE_PROMPT_MARKERS, QUIET_DETECT_MS2, OSC_TAIL_CHARS, SCRAPE_THROTTLE_MS, CLAUDE_READY_FALLBACK_MS, SUBMIT_BYTES2, SUBMIT_DELAY_MS, SUBMIT_MAX_WAIT_MS, PTYManager;
121890
+ var import_crypto10, import_fs26, import_path26, OUTPUT_BUFFER_MAX2, INPUT_HISTORY_MAX2, CLAUDE_PROMPT_MARKERS, QUIET_DETECT_MS2, OSC_TAIL_CHARS, SCRAPE_THROTTLE_MS, CLAUDE_READY_FALLBACK_MS, SUBMIT_BYTES2, SUBMIT_DELAY_MS, SUBMIT_MAX_WAIT_MS, PTYManager;
121411
121891
  var init_pty_manager = __esm({
121412
121892
  "src/pty-manager.ts"() {
121413
121893
  "use strict";
121414
121894
  import_crypto10 = require("crypto");
121415
121895
  import_fs26 = require("fs");
121416
- import_path25 = require("path");
121896
+ import_path26 = require("path");
121417
121897
  init_claude_flags();
121418
121898
  init_logger();
121419
121899
  init_platform();
@@ -121535,7 +122015,7 @@ var init_pty_manager = __esm({
121535
122015
  }
121536
122016
  async doStart(sessionId, options) {
121537
122017
  const nodePty = await loadPty();
121538
- const projectName = options.projectName ?? (0, import_path25.basename)(options.projectPath);
122018
+ const projectName = options.projectName ?? (0, import_path26.basename)(options.projectPath);
121539
122019
  const permissionMode = options.permissionMode ?? "acceptEdits";
121540
122020
  const args = [
121541
122021
  "--permission-mode",
@@ -121599,7 +122079,7 @@ var init_pty_manager = __esm({
121599
122079
  async startFresh(options) {
121600
122080
  const nodePty = await loadPty();
121601
122081
  const sessionId = (0, import_crypto10.randomUUID)();
121602
- const projectName = options.projectName ?? (0, import_path25.basename)(options.projectPath);
122082
+ const projectName = options.projectName ?? (0, import_path26.basename)(options.projectPath);
121603
122083
  const permissionMode = options.permissionMode ?? "acceptEdits";
121604
122084
  const args = [
121605
122085
  "--permission-mode",
@@ -122205,11 +122685,11 @@ var init_pty_manager = __esm({
122205
122685
  });
122206
122686
 
122207
122687
  // src/live-session-manager.ts
122208
- var import_path26, LiveSessionManager;
122688
+ var import_path27, LiveSessionManager;
122209
122689
  var init_live_session_manager = __esm({
122210
122690
  "src/live-session-manager.ts"() {
122211
122691
  "use strict";
122212
- import_path26 = require("path");
122692
+ import_path27 = require("path");
122213
122693
  init_codex_pty_runner();
122214
122694
  init_providers();
122215
122695
  init_remote_session_runner();
@@ -122352,7 +122832,7 @@ var init_live_session_manager = __esm({
122352
122832
  const runner = this.runners.get(provider);
122353
122833
  if (runner) return runner;
122354
122834
  const err = new Error(
122355
- `Live ${provider} sessions are not implemented yet for ${(0, import_path26.basename)(projectPath)}`
122835
+ `Live ${provider} sessions are not implemented yet for ${(0, import_path27.basename)(projectPath)}`
122356
122836
  );
122357
122837
  err.statusCode = 501;
122358
122838
  throw err;
@@ -131471,8 +131951,8 @@ var import_events2 = require("events");
131471
131951
  var import_fs38 = require("fs");
131472
131952
  var import_promises16 = require("fs/promises");
131473
131953
  var import_http = require("http");
131474
- var import_os15 = require("os");
131475
- var import_path33 = require("path");
131954
+ var import_os16 = require("os");
131955
+ var import_path34 = require("path");
131476
131956
 
131477
131957
  // src/agent/agent-client.ts
131478
131958
  var userInputSignal = { type: "signal", name: "userInput" };
@@ -133697,203 +134177,8 @@ init_logger();
133697
134177
 
133698
134178
  // src/api/middleware/auth.middleware.ts
133699
134179
  init_auth();
133700
-
133701
- // src/db/repositories/devices.repository.ts
133702
- var import_crypto3 = require("crypto");
133703
-
133704
- // src/services/security/capabilities.ts
133705
- var CAPABILITIES = [
133706
- "history:read",
133707
- // read conversations, search
133708
- "session:control",
133709
- // start, resume, send input, interrupt
133710
- "fs:browse",
133711
- // browse the project tree
133712
- "fs:upload",
133713
- // upload files into a project
133714
- "notifications",
133715
- // register for push
133716
- "admin"
133717
- // rotate keys, manage devices
133718
- ];
133719
- function isCapability(value) {
133720
- return typeof value === "string" && CAPABILITIES.includes(value);
133721
- }
133722
- var FULL_CAPABILITIES = [
133723
- "history:read",
133724
- "session:control",
133725
- "fs:browse",
133726
- "fs:upload",
133727
- "notifications"
133728
- ];
133729
- var READ_ONLY_CAPABILITIES = ["history:read"];
133730
- function capabilitiesForPreset(preset) {
133731
- return preset === "read-only" ? [...READ_ONLY_CAPABILITIES] : [...FULL_CAPABILITIES];
133732
- }
133733
- function legacyPrincipal() {
133734
- return { kind: "legacy", capabilities: [...FULL_CAPABILITIES, "admin"] };
133735
- }
133736
- function hasCapability(principal, required2) {
133737
- return principal.capabilities.includes(required2);
133738
- }
133739
- var ROUTE_CAPABILITIES = [
133740
- // Most specific first for readability; matching sorts by length anyway.
133741
- ["/api/sessions/", "session:control"],
133742
- ["/api/sessions", "history:read"],
133743
- // listing sessions is a read
133744
- ["/api/conversations", "history:read"],
133745
- ["/api/projects", "history:read"],
133746
- ["/api/search", "history:read"],
133747
- ["/api/providers", "history:read"],
133748
- ["/api/browse", "fs:browse"],
133749
- ["/api/upload", "fs:upload"],
133750
- ["/api/push", "notifications"],
133751
- ["/api/devices", "admin"],
133752
- ["/api/config", "admin"],
133753
- ["/api/auth/rotate", "admin"],
133754
- ["/api/backup", "admin"],
133755
- // Server identity and capability discovery. A read-only device must be able
133756
- // to see WHICH server it is talking to and what it supports, or it cannot
133757
- // render anything at all.
133758
- ["/api/info", "history:read"],
133759
- ["/api/profiles", "history:read"],
133760
- ["/api/diagnostics", "history:read"],
133761
- ["/api/cache/alert", "history:read"],
133762
- // Client log shipping: any authenticated client may report its own errors.
133763
- // Gating this behind a capability would silence diagnostics from exactly the
133764
- // devices most likely to be misbehaving.
133765
- ["/api/__client-log", "history:read"],
133766
- // Logs viewer is localhost-only and already bypasses this middleware; the
133767
- // mapping exists so a remote request is classified rather than denied as
133768
- // unclassified.
133769
- ["/api/logs", "admin"],
133770
- // Pairing routes other than the public exchange (e.g. minting a token).
133771
- ["/api/pair", "admin"],
133772
- // The live WebSocket. Subscribing is a read — terminal output, session
133773
- // updates, conversation events. Control still flows through the HTTP input
133774
- // routes, which carry their own capability check, so a read-only device can
133775
- // watch a session stream without being able to drive it.
133776
- ["/ws", "history:read"],
133777
- // Progress webhook (multi-agent). Authenticated by HMAC in the handler and
133778
- // already skipped by the middleware; classified so a stray request is denied
133779
- // by rule rather than as "unclassified".
133780
- ["/internal/sessions", "admin"]
133781
- ];
133782
- function requiredCapability(path2, method) {
133783
- if (path2.startsWith("/api/sessions") && (method === "GET" || method === "HEAD")) {
133784
- return "history:read";
133785
- }
133786
- let best = null;
133787
- for (const [prefix, cap] of ROUTE_CAPABILITIES) {
133788
- if (path2.startsWith(prefix) && (best === null || prefix.length > best.len)) {
133789
- best = { len: prefix.length, cap };
133790
- }
133791
- }
133792
- return best?.cap ?? null;
133793
- }
133794
-
133795
- // src/db/repositories/devices.repository.ts
133796
- function generateDeviceToken() {
133797
- return (0, import_crypto3.randomBytes)(32).toString("base64url");
133798
- }
133799
- function hashDeviceToken(token) {
133800
- return (0, import_crypto3.createHash)("sha256").update(token).digest("hex");
133801
- }
133802
- function safeHashEquals(a, b2) {
133803
- if (a.length !== b2.length) return false;
133804
- return (0, import_crypto3.timingSafeEqual)(Buffer.from(a, "utf8"), Buffer.from(b2, "utf8"));
133805
- }
133806
- function parseCapabilities(raw2) {
133807
- try {
133808
- const parsed = JSON.parse(raw2);
133809
- if (!Array.isArray(parsed)) return [];
133810
- return parsed.filter(isCapability);
133811
- } catch {
133812
- return [];
133813
- }
133814
- }
133815
- function toDeviceView(row) {
133816
- return {
133817
- deviceId: row.device_id,
133818
- name: row.name,
133819
- capabilities: parseCapabilities(row.capabilities),
133820
- createdAt: row.created_at,
133821
- lastSeenAt: row.last_seen_at,
133822
- revokedAt: row.revoked_at
133823
- };
133824
- }
133825
- var DevicesRepository = class {
133826
- insertStmt;
133827
- byTokenHashStmt;
133828
- byIdStmt;
133829
- listStmt;
133830
- revokeStmt;
133831
- touchStmt;
133832
- constructor(db) {
133833
- this.insertStmt = db.prepare(`
133834
- INSERT INTO devices (
133835
- device_id, public_key, token_hash, name, capabilities, created_at
133836
- ) VALUES (
133837
- @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
133838
- )
133839
- `);
133840
- this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
133841
- this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
133842
- this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
133843
- this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
133844
- this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
133845
- }
133846
- /**
133847
- * Record a newly paired device and mint its token.
133848
- *
133849
- * The raw token is returned to the caller and never stored — this is the only
133850
- * moment it exists outside the client.
133851
- */
133852
- register(args) {
133853
- const deviceId = (0, import_crypto3.randomUUID)();
133854
- const deviceToken = generateDeviceToken();
133855
- const capabilities = capabilitiesForPreset(args.preset ?? "full");
133856
- this.insertStmt.run({
133857
- device_id: deviceId,
133858
- public_key: args.publicKey,
133859
- token_hash: hashDeviceToken(deviceToken),
133860
- name: args.name ?? null,
133861
- capabilities: JSON.stringify(capabilities),
133862
- created_at: args.now ?? Date.now()
133863
- });
133864
- return { deviceId, deviceToken, capabilities };
133865
- }
133866
- /**
133867
- * Resolve a presented token to a device, or null.
133868
- *
133869
- * Returns null for a revoked device, so revocation takes effect on the very
133870
- * next request with no cache to go stale.
133871
- */
133872
- authenticate(token) {
133873
- const hash2 = hashDeviceToken(token);
133874
- const row = this.byTokenHashStmt.get(hash2);
133875
- if (!row) return null;
133876
- if (!safeHashEquals(row.token_hash, hash2)) return null;
133877
- if (row.revoked_at != null) return null;
133878
- return row;
133879
- }
133880
- get(deviceId) {
133881
- return this.byIdStmt.get(deviceId) ?? null;
133882
- }
133883
- /** All devices, including revoked ones — an audit surface needs the history. */
133884
- list() {
133885
- return this.listStmt.all().map(toDeviceView);
133886
- }
133887
- /** Revoke one device. Others are untouched — no key rotation, no collateral. */
133888
- revoke(deviceId, now = Date.now()) {
133889
- return this.revokeStmt.run(now, deviceId).changes > 0;
133890
- }
133891
- touch(deviceId, now = Date.now()) {
133892
- this.touchStmt.run(now, deviceId);
133893
- }
133894
- };
133895
-
133896
- // src/api/middleware/auth.middleware.ts
134180
+ init_devices_repository();
134181
+ init_capabilities();
133897
134182
  function isLocalRequest(remoteAddr) {
133898
134183
  const addr = remoteAddr ?? "";
133899
134184
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
@@ -134404,6 +134689,34 @@ var createDeviceRoutes = (deps) => {
134404
134689
  repo.revoke(id);
134405
134690
  return c.json({ ok: true, alreadyRevoked: false });
134406
134691
  });
134692
+ app.delete("/:id", (c) => {
134693
+ const repo = deps.devicesRepo();
134694
+ if (!repo) {
134695
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
134696
+ }
134697
+ const id = c.req.param("id");
134698
+ const existing = repo.get(id);
134699
+ if (!existing) return c.json({ ok: true, alreadyDeleted: true });
134700
+ const force = c.req.query("force") === "1" || c.req.query("force") === "true";
134701
+ if (existing.revoked_at == null && !force) {
134702
+ return c.json(
134703
+ {
134704
+ error: "Revoke the device before deleting it, or pass ?force=1",
134705
+ code: "DEVICE_ACTIVE"
134706
+ },
134707
+ 409
134708
+ );
134709
+ }
134710
+ repo.delete(id);
134711
+ return c.json({ ok: true, alreadyDeleted: false });
134712
+ });
134713
+ app.delete("/", (c) => {
134714
+ const repo = deps.devicesRepo();
134715
+ if (!repo) {
134716
+ return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
134717
+ }
134718
+ return c.json({ ok: true, deleted: repo.deleteRevoked() });
134719
+ });
134407
134720
  return app;
134408
134721
  };
134409
134722
 
@@ -135278,6 +135591,13 @@ var createMiscRoutes = (deps) => {
135278
135591
  // Same contract: this server serves GET /api/projects/summary, which the
135279
135592
  // Hub's grouped views need before they can draw a tree.
135280
135593
  projectSummary: true,
135594
+ // The paired-device registry lives in runtime.db, so it survives
135595
+ // `tb-streamer cache clear` and the integrity monitor's reset-and-rescan.
135596
+ // A client may only prefer its scoped device token over the shared API
135597
+ // key when this is true: on an older server the registry is inside
135598
+ // cache.db, where a documented troubleshooting step deletes it and every
135599
+ // device token with it. Absent means "old server, assume not durable".
135600
+ devicesDurable: true,
135281
135601
  // Delivery capability, not endpoint support: whether this server can
135282
135602
  // actually send a push, so mobile can hide an affordance instead of
135283
135603
  // registering tokens nothing will ever send to. Absent on older servers,
@@ -135817,7 +136137,13 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
135817
136137
  const app = new Hono2();
135818
136138
  app.get(
135819
136139
  "/ws",
135820
- upgradeWebSocket(() => {
136140
+ // The principal is read here, at the upgrade, and captured for the life of
136141
+ // the socket. authMiddleware sets it because /ws is classified
136142
+ // `history:read`, but it only ever reaches the HTTP request — without
136143
+ // capturing it the socket has no principal at all, so every frame after
136144
+ // the upgrade is unauthorized-by-omission.
136145
+ upgradeWebSocket((c) => {
136146
+ const principal = c.get("principal") ?? null;
135821
136147
  let openWs = null;
135822
136148
  return {
135823
136149
  onOpen(_evt, ws2) {
@@ -135827,7 +136153,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
135827
136153
  deps.handleWsOpen(raw2);
135828
136154
  },
135829
136155
  onMessage(evt, _ws) {
135830
- if (openWs) deps.handleWsMessage(openWs, evt.data);
136156
+ if (openWs) deps.handleWsMessage(openWs, evt.data, principal);
135831
136157
  },
135832
136158
  onClose(_evt, _ws) {
135833
136159
  if (openWs) deps.handleWsClose(openWs);
@@ -143257,122 +143583,8 @@ var import_fs17 = require("fs");
143257
143583
  var import_promises11 = require("fs/promises");
143258
143584
  var import_path16 = require("path");
143259
143585
  var import_promises12 = require("timers/promises");
143260
-
143261
- // src/db/query-timing.ts
143262
- init_logger();
143263
- var log2 = getLogger("db");
143264
- var DEFAULT_SLOW_QUERY_MS = 35;
143265
- var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
143266
- function deriveLabel(sql) {
143267
- const verb = /^\s*(\w+)/.exec(sql)?.[1]?.toLowerCase() ?? "sql";
143268
- const table = /(?:from|into|update)\s+([A-Za-z_]\w*)/i.exec(sql)?.[1] ?? "?";
143269
- return `${verb}:${table}`;
143270
- }
143271
- function resolveSlowMs() {
143272
- const raw2 = process.env.THREADBASE_DB_SLOW_QUERY_MS;
143273
- if (raw2 === void 0 || raw2 === "") return DEFAULT_SLOW_QUERY_MS;
143274
- const parsed = Number(raw2);
143275
- return Number.isFinite(parsed) ? parsed : DEFAULT_SLOW_QUERY_MS;
143276
- }
143277
- function record2(label, ms2, rows, slowMs) {
143278
- if (slowMs > 0 && ms2 >= slowMs) {
143279
- log2.warn(
143280
- `[db] slow query ${label} ${ms2.toFixed(1)}ms rows=${rows}`,
143281
- { event: "db.slow_query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
143282
- "pino"
143283
- );
143284
- return;
143285
- }
143286
- if (log2.pino.isLevelEnabled("debug")) {
143287
- log2.debug(
143288
- `[db] ${label} ${ms2.toFixed(2)}ms rows=${rows}`,
143289
- { event: "db.query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
143290
- "pino"
143291
- );
143292
- }
143293
- }
143294
- function rowsOf(method, result) {
143295
- if (method === "all") return Array.isArray(result) ? result.length : 0;
143296
- if (method === "run") return result?.changes ?? 0;
143297
- return result === void 0 ? 0 : 1;
143298
- }
143299
- function instrumentDatabase(db, options = {}) {
143300
- const slowMs = options.slowMs ?? resolveSlowMs();
143301
- const prepare = db.prepare.bind(db);
143302
- db.prepare = ((sql) => {
143303
- const stmt = prepare(sql);
143304
- const box = { label: deriveLabel(sql) };
143305
- Object.defineProperty(stmt, LABEL, { value: box, configurable: true });
143306
- for (const method of ["get", "all", "run"]) {
143307
- const original = stmt[method].bind(stmt);
143308
- Object.defineProperty(stmt, method, {
143309
- configurable: true,
143310
- writable: true,
143311
- value: (...args) => {
143312
- const started = performance.now();
143313
- const result = original(...args);
143314
- record2(box.label, performance.now() - started, rowsOf(method, result), slowMs);
143315
- return result;
143316
- }
143317
- });
143318
- }
143319
- return stmt;
143320
- });
143321
- return db;
143322
- }
143323
- function labelStatements(statements) {
143324
- for (const [name, stmt] of Object.entries(statements)) {
143325
- const box = stmt?.[LABEL];
143326
- if (box) box.label = name;
143327
- }
143328
- }
143329
-
143330
- // src/db/sqlite-migrate.ts
143331
- var import_fs15 = require("fs");
143332
- var import_path14 = require("path");
143333
- var import_url6 = require("url");
143334
- var import_meta2 = {};
143335
- function getMigrationsDir() {
143336
- if (typeof import_meta2 !== "undefined" && import_meta2.url) {
143337
- return (0, import_path14.dirname)((0, import_url6.fileURLToPath)(import_meta2.url));
143338
- }
143339
- return __dirname;
143340
- }
143341
- function resolveMigrationsDir(name = "migrations") {
143342
- return (0, import_path14.join)(getMigrationsDir(), name);
143343
- }
143344
- var SCHEMA_MIGRATIONS_SQL = `
143345
- CREATE TABLE IF NOT EXISTS schema_migrations (
143346
- id TEXT PRIMARY KEY,
143347
- applied_at TEXT NOT NULL
143348
- );
143349
- `;
143350
- function runSqliteMigrations(db, migrationsDir) {
143351
- db.exec(SCHEMA_MIGRATIONS_SQL);
143352
- const dir = migrationsDir ?? resolveMigrationsDir();
143353
- const files = (0, import_fs15.readdirSync)(dir).filter((f2) => f2.endsWith(".sql")).sort();
143354
- const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
143355
- const appliedSet = new Set(appliedRows.map((r) => r.id));
143356
- const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
143357
- const applied = [];
143358
- const skipped = [];
143359
- for (const file2 of files) {
143360
- if (appliedSet.has(file2)) {
143361
- skipped.push(file2);
143362
- continue;
143363
- }
143364
- const sql = (0, import_fs15.readFileSync)((0, import_path14.join)(dir, file2), "utf-8");
143365
- const tx = db.transaction(() => {
143366
- db.exec(sql);
143367
- recordApplied.run(file2, (/* @__PURE__ */ new Date()).toISOString());
143368
- });
143369
- tx();
143370
- applied.push(file2);
143371
- }
143372
- return { applied, skipped };
143373
- }
143374
-
143375
- // src/conversation-cache.ts
143586
+ init_query_timing();
143587
+ init_sqlite_migrate();
143376
143588
  init_logger();
143377
143589
  init_providers();
143378
143590
 
@@ -147942,6 +148154,9 @@ var ConversationsRepository = class {
147942
148154
  }
147943
148155
  };
147944
148156
 
148157
+ // src/server.ts
148158
+ init_devices_repository();
148159
+
147945
148160
  // src/db/repositories/managed-sessions.repository.ts
147946
148161
  var PROBE_SET_MAX = 200;
147947
148162
  var DIAGNOSTICS_MAX = 200;
@@ -148257,53 +148472,8 @@ var SessionsRepository = class {
148257
148472
  }
148258
148473
  };
148259
148474
 
148260
- // src/db/runtime-store.ts
148261
- var import_better_sqlite33 = __toESM(require("better-sqlite3"), 1);
148262
- var RuntimeStore = class _RuntimeStore {
148263
- constructor(db) {
148264
- this.db = db;
148265
- }
148266
- db;
148267
- static open(dbPath, migrationsDir) {
148268
- const db = instrumentDatabase(new import_better_sqlite33.default(dbPath));
148269
- db.pragma("journal_mode = WAL");
148270
- runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
148271
- return new _RuntimeStore(db);
148272
- }
148273
- getDatabase() {
148274
- return this.db;
148275
- }
148276
- /**
148277
- * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
148278
- *
148279
- * Non-destructive by design: the source table is left in place so an older
148280
- * streamer rolled back onto the same machine still finds its registry. Runs
148281
- * only when this file's table is empty, so a second boot is a no-op rather
148282
- * than a re-copy that would resurrect rows deleted since.
148283
- *
148284
- * Returns the number of rows copied.
148285
- */
148286
- importLegacyManagedSessions(source) {
148287
- const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
148288
- if (existing.n > 0) return 0;
148289
- const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
148290
- if (!hasTable) return 0;
148291
- const rows = source.prepare("SELECT * FROM managed_sessions").all();
148292
- if (rows.length === 0) return 0;
148293
- const columns = Object.keys(rows[0]);
148294
- const insert = this.db.prepare(
148295
- `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
148296
- VALUES (${columns.map((c) => `@${c}`).join(", ")})`
148297
- );
148298
- this.db.transaction((batch) => {
148299
- for (const row of batch) insert.run(row);
148300
- })(rows);
148301
- return rows.length;
148302
- }
148303
- close() {
148304
- this.db.close();
148305
- }
148306
- };
148475
+ // src/server.ts
148476
+ init_runtime_store();
148307
148477
 
148308
148478
  // src/external-tails.ts
148309
148479
  var import_fs23 = require("fs");
@@ -148576,8 +148746,8 @@ function spawnDetachedHost(socketPath, entryPoint) {
148576
148746
 
148577
148747
  // src/scanner-manager.ts
148578
148748
  var import_fs28 = require("fs");
148579
- var import_os11 = require("os");
148580
- var import_path28 = require("path");
148749
+ var import_os12 = require("os");
148750
+ var import_path29 = require("path");
148581
148751
  init_logger();
148582
148752
  init_providers();
148583
148753
 
@@ -148899,9 +149069,9 @@ function refreshConversationCache(deps) {
148899
149069
 
148900
149070
  // src/services/conversations/shouldRefreshProjectsFromHdd.ts
148901
149071
  var import_fs27 = require("fs");
148902
- var import_os10 = require("os");
148903
- var import_path27 = require("path");
148904
- var DEFAULT_PROJECTS_DIR = (0, import_path27.join)((0, import_os10.homedir)(), ".claude", "projects");
149072
+ var import_os11 = require("os");
149073
+ var import_path28 = require("path");
149074
+ var DEFAULT_PROJECTS_DIR = (0, import_path28.join)((0, import_os11.homedir)(), ".claude", "projects");
148905
149075
  function maxProjectsTreeMtimeMs(projectsDir) {
148906
149076
  let maxMs;
148907
149077
  try {
@@ -148913,7 +149083,7 @@ function maxProjectsTreeMtimeMs(projectsDir) {
148913
149083
  for (const ent of (0, import_fs27.readdirSync)(projectsDir, { withFileTypes: true })) {
148914
149084
  if (!ent.isDirectory()) continue;
148915
149085
  try {
148916
- const childMs = (0, import_fs27.statSync)((0, import_path27.join)(projectsDir, ent.name)).mtimeMs;
149086
+ const childMs = (0, import_fs27.statSync)((0, import_path28.join)(projectsDir, ent.name)).mtimeMs;
148917
149087
  if (childMs > maxMs) maxMs = childMs;
148918
149088
  } catch {
148919
149089
  }
@@ -149088,9 +149258,9 @@ var ScannerManager = class {
149088
149258
  projectsDirs() {
149089
149259
  const profiles = this.deps.scanProfiles;
149090
149260
  if (profiles && profiles.length > 0) {
149091
- return profiles.filter((p2) => p2.enabled).map((p2) => (0, import_path28.join)(p2.configDir, "projects"));
149261
+ return profiles.filter((p2) => p2.enabled).map((p2) => (0, import_path29.join)(p2.configDir, "projects"));
149092
149262
  }
149093
- return [(0, import_path28.join)((0, import_os11.homedir)(), ".claude", "projects")];
149263
+ return [(0, import_path29.join)((0, import_os12.homedir)(), ".claude", "projects")];
149094
149264
  }
149095
149265
  // ─── staleness ────────────────────────────────────────────────────
149096
149266
  // Drain the stale set and disarm the flag together. The caller owns the
@@ -149371,8 +149541,8 @@ var import_fs30 = require("fs");
149371
149541
 
149372
149542
  // src/handlers/handleListProjects.ts
149373
149543
  var import_fs29 = require("fs");
149374
- var import_os12 = require("os");
149375
- var import_path29 = require("path");
149544
+ var import_os13 = require("os");
149545
+ var import_path30 = require("path");
149376
149546
  var HEAD_BYTES = 64 * 1024;
149377
149547
  var MAX_FILES_PROBED = 3;
149378
149548
  function readRecordedCwd(dir) {
@@ -149385,7 +149555,7 @@ function readRecordedCwd(dir) {
149385
149555
  for (const file2 of files.slice(0, MAX_FILES_PROBED)) {
149386
149556
  let fd;
149387
149557
  try {
149388
- fd = (0, import_fs29.openSync)((0, import_path29.join)(dir, file2), "r");
149558
+ fd = (0, import_fs29.openSync)((0, import_path30.join)(dir, file2), "r");
149389
149559
  const buf = Buffer.alloc(HEAD_BYTES);
149390
149560
  const bytes = (0, import_fs29.readSync)(fd, buf, 0, HEAD_BYTES, 0);
149391
149561
  for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
@@ -149409,11 +149579,11 @@ function decodeProjectPath(dirName) {
149409
149579
  function handleListProjects(url2, res) {
149410
149580
  const limit = Math.max(1, parseInt(url2.searchParams.get("limit") ?? "50", 10) || 50);
149411
149581
  const offset = Math.max(0, parseInt(url2.searchParams.get("offset") ?? "0", 10) || 0);
149412
- const projectsDir = (0, import_path29.join)((0, import_os12.homedir)(), ".claude", "projects");
149582
+ const projectsDir = (0, import_path30.join)((0, import_os13.homedir)(), ".claude", "projects");
149413
149583
  let entries;
149414
149584
  try {
149415
149585
  entries = (0, import_fs29.readdirSync)(projectsDir).map((dirName) => {
149416
- const fullPath = (0, import_path29.join)(projectsDir, dirName);
149586
+ const fullPath = (0, import_path30.join)(projectsDir, dirName);
149417
149587
  let mtime = 0;
149418
149588
  try {
149419
149589
  mtime = (0, import_fs29.statSync)(fullPath).mtimeMs;
@@ -149428,7 +149598,7 @@ function handleListProjects(url2, res) {
149428
149598
  }
149429
149599
  const total = entries.length;
149430
149600
  const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
149431
- const path2 = readRecordedCwd((0, import_path29.join)(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
149601
+ const path2 = readRecordedCwd((0, import_path30.join)(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
149432
149602
  const name = path2.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
149433
149603
  return { name, path: path2, dirName };
149434
149604
  });
@@ -149438,6 +149608,10 @@ function handleListProjects(url2, res) {
149438
149608
 
149439
149609
  // src/server-wiring.ts
149440
149610
  init_logger();
149611
+ init_capabilities();
149612
+ function wsAllows(principal, required2) {
149613
+ return principal === null || hasCapability(principal, required2);
149614
+ }
149441
149615
  function createConversationWatcherEvents(deps) {
149442
149616
  return {
149443
149617
  onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
@@ -149719,7 +149893,15 @@ function createApiDeps(deps) {
149719
149893
  const alertMsg = deps.cacheMonitor()?.wsMessage();
149720
149894
  if (alertMsg) deps.wsHub.unicast(ws2, alertMsg);
149721
149895
  },
149722
- handleWsMessage: async (ws2, raw2) => {
149896
+ handleWsMessage: async (ws2, raw2, principal) => {
149897
+ const deny = (type, required2) => {
149898
+ deps.log().warn(`[ws.capability_denied] ${type} requires ${required2}`, {
149899
+ event: "ws.capability_denied",
149900
+ type,
149901
+ required: required2,
149902
+ ...principal?.deviceId ? { deviceId: principal.deviceId } : {}
149903
+ });
149904
+ };
149723
149905
  try {
149724
149906
  const msg = JSON.parse(String(raw2));
149725
149907
  if (msg.type === "register" && typeof msg.clientId === "string") {
@@ -149729,6 +149911,10 @@ function createApiDeps(deps) {
149729
149911
  deps.wsToClientId.set(ws2, msg.clientId);
149730
149912
  }
149731
149913
  if (msg.type === "subscribe_session" && typeof msg.sessionId === "string") {
149914
+ if (!wsAllows(principal, "history:read")) {
149915
+ deny(msg.type, "history:read");
149916
+ return;
149917
+ }
149732
149918
  deps.addSessionSubscriber(msg.sessionId, ws2);
149733
149919
  if (deps.ptyManager.hasSession(msg.sessionId)) {
149734
149920
  const lines = await deps.ptyManager.getOutputLines(msg.sessionId, 200);
@@ -149777,6 +149963,10 @@ function createApiDeps(deps) {
149777
149963
  }
149778
149964
  }
149779
149965
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
149966
+ if (!wsAllows(principal, "session:control")) {
149967
+ deny(msg.type, "session:control");
149968
+ return;
149969
+ }
149780
149970
  deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
149781
149971
  }
149782
149972
  } catch {
@@ -149804,11 +149994,11 @@ var import_fs33 = require("fs");
149804
149994
 
149805
149995
  // src/services/cache-integrity/alertStore.ts
149806
149996
  var import_fs31 = require("fs");
149807
- var import_os13 = require("os");
149808
- var import_path30 = require("path");
149997
+ var import_os14 = require("os");
149998
+ var import_path31 = require("path");
149809
149999
  function alertStatePath() {
149810
- const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path30.join)((0, import_os13.homedir)(), ".threadbase");
149811
- return (0, import_path30.join)(dir, "cache-alert.json");
150000
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path31.join)((0, import_os14.homedir)(), ".threadbase");
150001
+ return (0, import_path31.join)(dir, "cache-alert.json");
149812
150002
  }
149813
150003
  function loadAlertState() {
149814
150004
  try {
@@ -149820,14 +150010,14 @@ function loadAlertState() {
149820
150010
  }
149821
150011
  function saveAlertState(state) {
149822
150012
  const path2 = alertStatePath();
149823
- (0, import_fs31.mkdirSync)((0, import_path30.dirname)(path2), { recursive: true });
150013
+ (0, import_fs31.mkdirSync)((0, import_path31.dirname)(path2), { recursive: true });
149824
150014
  (0, import_fs31.writeFileSync)(path2, `${JSON.stringify(state, null, 2)}
149825
150015
  `);
149826
150016
  }
149827
150017
 
149828
150018
  // src/services/cache-integrity/backup.ts
149829
150019
  var import_fs32 = require("fs");
149830
- var import_path31 = require("path");
150020
+ var import_path32 = require("path");
149831
150021
  var DEFAULT_RETAIN = 3;
149832
150022
  function retainCount() {
149833
150023
  const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
@@ -149838,13 +150028,13 @@ function timestamp(d) {
149838
150028
  return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
149839
150029
  }
149840
150030
  async function backupCacheDb(db, cacheDir) {
149841
- const backupsDir = (0, import_path31.join)(cacheDir, "backups");
150031
+ const backupsDir = (0, import_path32.join)(cacheDir, "backups");
149842
150032
  (0, import_fs32.mkdirSync)(backupsDir, { recursive: true });
149843
- const destPath = (0, import_path31.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
150033
+ const destPath = (0, import_path32.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
149844
150034
  await db.backup(destPath);
149845
150035
  const retain = retainCount();
149846
150036
  const backups = (0, import_fs32.readdirSync)(backupsDir).filter((f2) => f2.startsWith("cache-") && f2.endsWith(".db")).map((f2) => {
149847
- const full = (0, import_path31.join)(backupsDir, f2);
150037
+ const full = (0, import_path32.join)(backupsDir, f2);
149848
150038
  return { full, mtime: (0, import_fs32.statSync)(full).mtimeMs };
149849
150039
  }).sort((a, b2) => b2.mtime - a.mtime);
149850
150040
  for (const stale of backups.slice(retain)) {
@@ -151701,9 +151891,15 @@ function getSortValue(s3, key) {
151701
151891
  case "projectName":
151702
151892
  return s3.projectName;
151703
151893
  case "status":
151704
- return s3.status;
151894
+ return statusSortValue(s3);
151705
151895
  }
151706
151896
  }
151897
+ function statusSortValue(s3) {
151898
+ const rank = s3.status === "idle" ? 1 : 0;
151899
+ const activeAt = Date.parse(s3.lastActivityAt ?? s3.startedAt);
151900
+ const recency = String(1e15 - (Number.isNaN(activeAt) ? 0 : activeAt)).padStart(16, "0");
151901
+ return `${rank}:${recency}`;
151902
+ }
151707
151903
  function compareValues(a, b2) {
151708
151904
  if (a === void 0 && b2 === void 0) return 0;
151709
151905
  if (a === void 0) return 1;
@@ -151838,8 +152034,8 @@ function discoveredToResponse(d, conversationId) {
151838
152034
 
151839
152035
  // src/session-watchers.ts
151840
152036
  var import_fs37 = require("fs");
151841
- var import_os14 = require("os");
151842
- var import_path32 = require("path");
152037
+ var import_os15 = require("os");
152038
+ var import_path33 = require("path");
151843
152039
  init_logger();
151844
152040
  var SessionWatchers = class {
151845
152041
  constructor(deps) {
@@ -151921,9 +152117,9 @@ var SessionWatchers = class {
151921
152117
  // was passed to Claude via --session-id so the filename matches from the start.
151922
152118
  watchForJsonl(sessionId, projectPath) {
151923
152119
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
151924
- const projectsDir = (0, import_path32.join)((0, import_os14.homedir)(), ".claude", "projects", encoded);
152120
+ const projectsDir = (0, import_path33.join)((0, import_os15.homedir)(), ".claude", "projects", encoded);
151925
152121
  const expectedFile = `${sessionId}.jsonl`;
151926
- const filePath = (0, import_path32.join)(projectsDir, expectedFile);
152122
+ const filePath = (0, import_path33.join)(projectsDir, expectedFile);
151927
152123
  const deadline = Date.now() + 12e4;
151928
152124
  let watcher = null;
151929
152125
  const cleanup = () => {
@@ -151945,10 +152141,10 @@ var SessionWatchers = class {
151945
152141
  if (!resolvedFilePath && (0, import_fs37.existsSync)(projectsDir)) {
151946
152142
  try {
151947
152143
  const now = Date.now();
151948
- const match2 = (0, import_fs37.readdirSync)(projectsDir).filter((f2) => f2.endsWith(".jsonl")).map((f2) => ({ f: f2, mtime: (0, import_fs37.statSync)((0, import_path32.join)(projectsDir, f2)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
151949
- ({ f: f2 }) => (0, import_path32.basename)(f2, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path32.join)(projectsDir, f2)) === sessionId
152144
+ const match2 = (0, import_fs37.readdirSync)(projectsDir).filter((f2) => f2.endsWith(".jsonl")).map((f2) => ({ f: f2, mtime: (0, import_fs37.statSync)((0, import_path33.join)(projectsDir, f2)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
152145
+ ({ f: f2 }) => (0, import_path33.basename)(f2, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path33.join)(projectsDir, f2)) === sessionId
151950
152146
  ).sort((a, b2) => b2.mtime - a.mtime)[0];
151951
- if (match2) resolvedFilePath = (0, import_path32.join)(projectsDir, match2.f);
152147
+ if (match2) resolvedFilePath = (0, import_path33.join)(projectsDir, match2.f);
151952
152148
  } catch {
151953
152149
  }
151954
152150
  }
@@ -151992,7 +152188,7 @@ var SessionWatchers = class {
151992
152188
  watchForCodexRollout(sessionId, projectPath) {
151993
152189
  const deadline = Date.now() + 12e4;
151994
152190
  const now = /* @__PURE__ */ new Date();
151995
- const dateDir = (0, import_path32.join)(
152191
+ const dateDir = (0, import_path33.join)(
151996
152192
  String(now.getFullYear()),
151997
152193
  String(now.getMonth() + 1).padStart(2, "0"),
151998
152194
  String(now.getDate()).padStart(2, "0")
@@ -152033,7 +152229,7 @@ var SessionWatchers = class {
152033
152229
  this.deps.sessionStore.listManaged().filter((s3) => s3.id !== sessionId && s3.boundConversationId != null).map((s3) => s3.boundConversationId)
152034
152230
  );
152035
152231
  for (const root of this.deps.codexRoots) {
152036
- const sessionsDir = (0, import_path32.join)(root, dateDir);
152232
+ const sessionsDir = (0, import_path33.join)(root, dateDir);
152037
152233
  if (!(0, import_fs37.existsSync)(sessionsDir)) continue;
152038
152234
  let candidateFiles;
152039
152235
  try {
@@ -152042,9 +152238,9 @@ var SessionWatchers = class {
152042
152238
  continue;
152043
152239
  }
152044
152240
  const nowMs = Date.now();
152045
- const recentCandidates = candidateFiles.map((f2) => ({ f: f2, mtime: (0, import_fs37.statSync)((0, import_path32.join)(sessionsDir, f2)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b2) => b2.mtime - a.mtime);
152241
+ const recentCandidates = candidateFiles.map((f2) => ({ f: f2, mtime: (0, import_fs37.statSync)((0, import_path33.join)(sessionsDir, f2)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b2) => b2.mtime - a.mtime);
152046
152242
  for (const { f: f2 } of recentCandidates) {
152047
- const candidatePath = (0, import_path32.join)(sessionsDir, f2);
152243
+ const candidatePath = (0, import_path33.join)(sessionsDir, f2);
152048
152244
  const match2 = matchesProjectPath(candidatePath);
152049
152245
  if (!match2) continue;
152050
152246
  if (boundElsewhere.has(match2.id)) continue;
@@ -152448,7 +152644,7 @@ var StreamerServer = class {
152448
152644
  this.skipStartupWarmup = config2.skipStartupWarmup ?? false;
152449
152645
  this.autoResumeOnBoot = config2.autoResumeOnBoot ?? false;
152450
152646
  this.scanProfiles = config2.scanProfiles;
152451
- this.codexRoots = config2.codexRoots ?? [(0, import_path33.join)((0, import_os15.homedir)(), ".codex", "sessions")];
152647
+ this.codexRoots = config2.codexRoots ?? [(0, import_path34.join)((0, import_os16.homedir)(), ".codex", "sessions")];
152452
152648
  this.ptyGracePeriodMs = config2.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
152453
152649
  this.defaultSystemPrompt = config2.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
152454
152650
  const flagResolution = resolveFeatureFlags({
@@ -152465,8 +152661,8 @@ var StreamerServer = class {
152465
152661
  this.claudeFlagsPersistable = config2.claudeFlags === void 0;
152466
152662
  this.claudeFlags = config2.claudeFlags ?? loadClaudeFlags();
152467
152663
  this.claudeExtraArgs = config2.claudeExtraArgs ?? loadClaudeExtraArgs();
152468
- this.cacheDir = config2.cacheDir ?? loadCacheDir() ?? (0, import_path33.join)((0, import_os15.homedir)(), ".threadbase", "cache");
152469
- this.runtimeDbPath = config2.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path33.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path33.join)((0, import_os15.homedir)(), ".threadbase"), "runtime.db");
152664
+ this.cacheDir = config2.cacheDir ?? loadCacheDir() ?? (0, import_path34.join)((0, import_os16.homedir)(), ".threadbase", "cache");
152665
+ this.runtimeDbPath = resolveRuntimeDbPath(config2.runtimeDbPath);
152470
152666
  this.tailSize = config2.tailSize ?? loadTailSize() ?? 10;
152471
152667
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
152472
152668
  this.scannerManager = new ScannerManager({
@@ -152642,7 +152838,7 @@ var StreamerServer = class {
152642
152838
  temporalClient,
152643
152839
  taskQueue: agentConfig.temporal.taskQueue
152644
152840
  });
152645
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path33.join)((0, import_path33.dirname)(this.cacheDir), "conversations");
152841
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path34.join)((0, import_path34.dirname)(this.cacheDir), "conversations");
152646
152842
  conversationWriter = createConversationWriter({
152647
152843
  baseDir: conversationsBaseDir
152648
152844
  });
@@ -152894,14 +153090,14 @@ var StreamerServer = class {
152894
153090
  }
152895
153091
  this.apnsClient = new ApnsClient(creds);
152896
153092
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
152897
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
152898
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os15.hostname)());
153093
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os16.hostname)();
153094
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os16.hostname)());
152899
153095
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
152900
153096
  repo: pushRepo,
152901
153097
  sender,
152902
153098
  sessionStore: this.sessionStore,
152903
153099
  serverId,
152904
- serverLabel: (0, import_os15.hostname)()
153100
+ serverLabel: (0, import_os16.hostname)()
152905
153101
  });
152906
153102
  this.liveActivityRenewal.start();
152907
153103
  this.log.info("Live Activity push enabled", {
@@ -152921,7 +153117,7 @@ var StreamerServer = class {
152921
153117
  */
152922
153118
  initWaitingInputPush(pushRepo) {
152923
153119
  const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
152924
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
153120
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os16.hostname)();
152925
153121
  this.waitingInputNotifier = new WaitingInputNotifier(
152926
153122
  sender,
152927
153123
  serverId,
@@ -153058,7 +153254,7 @@ var StreamerServer = class {
153058
153254
  let sessions = null;
153059
153255
  for (let attempt = 0; attempt < 2; attempt += 1) {
153060
153256
  const transport = await connectOrSpawnHost({
153061
- instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)()
153257
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os16.hostname)()
153062
153258
  });
153063
153259
  try {
153064
153260
  sessions = await this.ptyManager.useRemoteRunner(transport);
@@ -153115,6 +153311,7 @@ var StreamerServer = class {
153115
153311
  try {
153116
153312
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
153117
153313
  this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
153314
+ this.devicesRepo = new DevicesRepository(this.runtimeStore.getDatabase());
153118
153315
  } catch (err) {
153119
153316
  const message = err instanceof Error ? err.message : String(err);
153120
153317
  const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
@@ -153134,7 +153331,7 @@ var StreamerServer = class {
153134
153331
  }
153135
153332
  try {
153136
153333
  this.cache = ConversationCache.open(
153137
- (0, import_path33.join)(this.cacheDir, "cache.db"),
153334
+ (0, import_path34.join)(this.cacheDir, "cache.db"),
153138
153335
  this.tailSize,
153139
153336
  void 0,
153140
153337
  {
@@ -153164,18 +153361,39 @@ var StreamerServer = class {
153164
153361
  if (copied > 0) {
153165
153362
  this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
153166
153363
  copied,
153364
+ table: "managed_sessions",
153167
153365
  event: "runtime.legacy_import"
153168
153366
  });
153169
153367
  }
153170
153368
  } catch (err) {
153171
153369
  this.log.warn("[registry] legacy managed_sessions copy failed", {
153172
153370
  event: "runtime.legacy_import_failed",
153371
+ table: "managed_sessions",
153372
+ err
153373
+ });
153374
+ }
153375
+ try {
153376
+ const result = this.runtimeStore?.importLegacyDevices(db);
153377
+ if (result && result.copied > 0) {
153378
+ this.log.info(
153379
+ `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)"),
153380
+ {
153381
+ copied: result.copied,
153382
+ purged: result.purged,
153383
+ table: "devices",
153384
+ event: "runtime.legacy_import"
153385
+ }
153386
+ );
153387
+ }
153388
+ } catch (err) {
153389
+ this.log.warn("[registry] legacy devices move failed", {
153390
+ event: "runtime.legacy_import_failed",
153391
+ table: "devices",
153173
153392
  err
153174
153393
  });
153175
153394
  }
153176
153395
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
153177
153396
  this.pushRepo = new PushRepository(db);
153178
- this.devicesRepo = new DevicesRepository(db);
153179
153397
  this.initLiveActivityPush(this.pushRepo);
153180
153398
  this.initWaitingInputPush(this.pushRepo);
153181
153399
  this.cacheMonitor = new CacheIntegrityMonitor(
@@ -153501,7 +153719,7 @@ var StreamerServer = class {
153501
153719
  nonce: sealed.nonce,
153502
153720
  ephemeralPublicKey: sealed.ephemeralPublicKey,
153503
153721
  publicUrl: this.publicUrl,
153504
- machineName: (0, import_os15.hostname)(),
153722
+ machineName: (0, import_os16.hostname)(),
153505
153723
  ...device && {
153506
153724
  deviceId: device.deviceId,
153507
153725
  deviceToken: device.deviceToken,
@@ -154568,7 +154786,7 @@ var import_node_stream2 = __toESM(require("stream"), 1);
154568
154786
  var import_node_string_decoder = require("string_decoder");
154569
154787
  var import_node_path15 = __toESM(require("path"), 1);
154570
154788
  var import_node_fs13 = __toESM(require("fs"), 1);
154571
- var import_path34 = require("path");
154789
+ var import_path35 = require("path");
154572
154790
  var import_events4 = require("events");
154573
154791
  var import_assert = __toESM(require("assert"), 1);
154574
154792
  var import_buffer2 = require("buffer");
@@ -154578,9 +154796,9 @@ var import_node_path16 = require("path");
154578
154796
  var import_node_path17 = require("path");
154579
154797
  var import_fs40 = __toESM(require("fs"), 1);
154580
154798
  var import_fs41 = __toESM(require("fs"), 1);
154581
- var import_path35 = __toESM(require("path"), 1);
154582
- var import_node_path18 = require("path");
154583
154799
  var import_path36 = __toESM(require("path"), 1);
154800
+ var import_node_path18 = require("path");
154801
+ var import_path37 = __toESM(require("path"), 1);
154584
154802
  var import_node_fs14 = __toESM(require("fs"), 1);
154585
154803
  var import_node_assert = __toESM(require("assert"), 1);
154586
154804
  var import_node_crypto6 = require("crypto");
@@ -155952,11 +156170,11 @@ var vn = (s3) => {
155952
156170
  var Qi = (s3, t) => {
155953
156171
  let e = new Map(t.map((o) => [mt(o), true])), i = s3.filter, r = 100, n = (o, h = "", a = 0) => {
155954
156172
  if (a >= r) return e.set(o, false), false;
155955
- let l = h || (0, import_path34.parse)(o).root || ".", c;
156173
+ let l = h || (0, import_path35.parse)(o).root || ".", c;
155956
156174
  if (o === l) c = false;
155957
156175
  else {
155958
156176
  let d = e.get(o);
155959
- c = d !== void 0 ? d : n((0, import_path34.dirname)(o), l, a + 1);
156177
+ c = d !== void 0 ? d : n((0, import_path35.dirname)(o), l, a + 1);
155960
156178
  }
155961
156179
  return e.set(o, c), c;
155962
156180
  };
@@ -156076,7 +156294,7 @@ var de = class extends A {
156076
156294
  let [o, h] = ce(this.path);
156077
156295
  o && typeof h == "string" && (this.path = h, r = o);
156078
156296
  }
156079
- this.win32 = !!i.win32 || process.platform === "win32", this.win32 && (this.path = Qs(this.path.replaceAll(/\\/g, "/")), t = t.replaceAll(/\\/g, "/")), this.absolute = f(i.absolute || import_path35.default.resolve(this.cwd, t)), this.path === "" && (this.path = "./"), r && this.warn("TAR_ENTRY_INFO", `stripping ${r} from absolute path`, { entry: this, path: r + this.path });
156297
+ this.win32 = !!i.win32 || process.platform === "win32", this.win32 && (this.path = Qs(this.path.replaceAll(/\\/g, "/")), t = t.replaceAll(/\\/g, "/")), this.absolute = f(i.absolute || import_path36.default.resolve(this.cwd, t)), this.path === "" && (this.path = "./"), r && this.warn("TAR_ENTRY_INFO", `stripping ${r} from absolute path`, { entry: this, path: r + this.path });
156080
156298
  let n = this.statCache.get(this.absolute);
156081
156299
  n ? this[si](n) : this[ss]();
156082
156300
  }
@@ -156135,7 +156353,7 @@ var de = class extends A {
156135
156353
  }
156136
156354
  [sr](t) {
156137
156355
  if (!this.stat) throw new Error("cannot create link entry without stat");
156138
- this.type = "Link", this.linkpath = f(import_path35.default.relative(this.cwd, t)), this.stat.size = 0, this[fe](), this.end();
156356
+ this.type = "Link", this.linkpath = f(import_path36.default.relative(this.cwd, t)), this.stat.size = 0, this[fe](), this.end();
156139
156357
  }
156140
156358
  [er]() {
156141
156359
  if (!this.stat) throw new Error("cannot create file entry without stat");
@@ -156549,7 +156767,7 @@ var wt = class extends A {
156549
156767
  return typeof t == "string" ? this[ci](t) : this[or](t), this.flowing;
156550
156768
  }
156551
156769
  [or](t) {
156552
- let e = f(import_path36.default.resolve(this.cwd, t.path));
156770
+ let e = f(import_path37.default.resolve(this.cwd, t.path));
156553
156771
  if (!this.filter(t.path, t)) t.resume();
156554
156772
  else {
156555
156773
  let i = new pi(t.path, e);
@@ -156558,7 +156776,7 @@ var wt = class extends A {
156558
156776
  this[Ft]();
156559
156777
  }
156560
156778
  [ci](t) {
156561
- let e = f(import_path36.default.resolve(this.cwd, t));
156779
+ let e = f(import_path37.default.resolve(this.cwd, t));
156562
156780
  this[W2].push(new pi(t, e)), this[Ft]();
156563
156781
  }
156564
156782
  [ds](t) {
@@ -158213,8 +158431,8 @@ program2.command("cache").description("Manage the local SQLite conversation cach
158213
158431
  `${process.env.HOME}/.threadbase/cache`
158214
158432
  ).action((opts) => {
158215
158433
  const { rmSync: rmSync6, existsSync: existsSync21 } = require("fs");
158216
- const { join: join34 } = require("path");
158217
- const dbPath = join34(opts.cacheDir, "cache.db");
158434
+ const { join: join35 } = require("path");
158435
+ const dbPath = join35(opts.cacheDir, "cache.db");
158218
158436
  for (const suffix of ["", "-shm", "-wal"]) {
158219
158437
  const f2 = dbPath + suffix;
158220
158438
  if (existsSync21(f2)) {
@@ -158225,6 +158443,75 @@ program2.command("cache").description("Manage the local SQLite conversation cach
158225
158443
  log14.info("Cache cleared. Restart the server to rebuild.", void 0, "console");
158226
158444
  })
158227
158445
  );
158446
+ program2.command("devices").description("List, revoke and erase paired devices").addCommand(
158447
+ new Command("list").description("Show every paired device, including revoked ones").option("--db <path>", "runtime.db path (default: ~/.threadbase/runtime.db)").action(async (opts) => {
158448
+ const { RuntimeStore: RuntimeStore2, resolveRuntimeDbPath: resolveRuntimeDbPath2 } = await Promise.resolve().then(() => (init_runtime_store(), runtime_store_exports));
158449
+ const { DevicesRepository: DevicesRepository2 } = await Promise.resolve().then(() => (init_devices_repository(), devices_repository_exports));
158450
+ const store = RuntimeStore2.open(resolveRuntimeDbPath2(opts.db));
158451
+ const devices = new DevicesRepository2(store.getDatabase()).list();
158452
+ store.close();
158453
+ if (devices.length === 0) {
158454
+ log14.info("No paired devices.", void 0, "console");
158455
+ return;
158456
+ }
158457
+ for (const d of devices) {
158458
+ const state = d.revokedAt ? "revoked" : "active";
158459
+ const seen = d.lastSeenAt ? new Date(d.lastSeenAt).toISOString() : "never";
158460
+ log14.info(
158461
+ `${d.deviceId} ${state.padEnd(7)} ${d.name ?? "(unnamed)"} last seen ${seen} [${d.capabilities.join(", ")}]`,
158462
+ void 0,
158463
+ "console"
158464
+ );
158465
+ }
158466
+ })
158467
+ ).addCommand(
158468
+ new Command("revoke").argument("<deviceId>").description("Refuse this device's token, keeping its record for the audit trail").option("--db <path>", "runtime.db path (default: ~/.threadbase/runtime.db)").action(async (deviceId, opts) => {
158469
+ const { RuntimeStore: RuntimeStore2, resolveRuntimeDbPath: resolveRuntimeDbPath2 } = await Promise.resolve().then(() => (init_runtime_store(), runtime_store_exports));
158470
+ const { DevicesRepository: DevicesRepository2 } = await Promise.resolve().then(() => (init_devices_repository(), devices_repository_exports));
158471
+ const store = RuntimeStore2.open(resolveRuntimeDbPath2(opts.db));
158472
+ const ok = new DevicesRepository2(store.getDatabase()).revoke(deviceId);
158473
+ store.close();
158474
+ log14.info(ok ? `Revoked ${deviceId}.` : `No such device: ${deviceId}`, void 0, "console");
158475
+ if (!ok) process.exitCode = 1;
158476
+ })
158477
+ ).addCommand(
158478
+ new Command("delete").argument("[deviceId]").description("Erase a device record. With --revoked, erase every revoked device instead").option("--revoked", "Erase all revoked devices rather than one by id").option("--force", "Erase an ACTIVE device (revoking first is the safe order)").option("--db <path>", "runtime.db path (default: ~/.threadbase/runtime.db)").action(async (deviceId, opts) => {
158479
+ const { RuntimeStore: RuntimeStore2, resolveRuntimeDbPath: resolveRuntimeDbPath2 } = await Promise.resolve().then(() => (init_runtime_store(), runtime_store_exports));
158480
+ const { DevicesRepository: DevicesRepository2 } = await Promise.resolve().then(() => (init_devices_repository(), devices_repository_exports));
158481
+ const store = RuntimeStore2.open(resolveRuntimeDbPath2(opts.db));
158482
+ const repo = new DevicesRepository2(store.getDatabase());
158483
+ if (opts.revoked) {
158484
+ const n = repo.deleteRevoked();
158485
+ store.close();
158486
+ log14.info(`Erased ${n} revoked device record(s).`, void 0, "console");
158487
+ return;
158488
+ }
158489
+ if (!deviceId) {
158490
+ store.close();
158491
+ log14.error("Pass a device id, or --revoked to erase all revoked devices.");
158492
+ process.exitCode = 1;
158493
+ return;
158494
+ }
158495
+ const existing = repo.get(deviceId);
158496
+ if (!existing) {
158497
+ store.close();
158498
+ log14.info(`No such device: ${deviceId}`, void 0, "console");
158499
+ process.exitCode = 1;
158500
+ return;
158501
+ }
158502
+ if (existing.revoked_at == null && !opts.force) {
158503
+ store.close();
158504
+ log14.error(
158505
+ `${deviceId} is still active. Run 'devices revoke ${deviceId}' first, or pass --force.`
158506
+ );
158507
+ process.exitCode = 1;
158508
+ return;
158509
+ }
158510
+ repo.delete(deviceId);
158511
+ store.close();
158512
+ log14.info(`Erased ${deviceId}.`, void 0, "console");
158513
+ })
158514
+ );
158228
158515
  program2.command("pair").description("Print a pairing QR code (server must already be running)").option("-p, --port <number>", "Port the server is listening on", "8766").action(async (opts) => {
158229
158516
  const port = Number.parseInt(opts.port, 10);
158230
158517
  const apiKey = loadOrCreateApiKey();