@threadbase-sh/streamer 1.39.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3017,7 +3017,7 @@ import { randomUUID as randomUUID5 } from "crypto";
3017
3017
  import { EventEmitter } from "events";
3018
3018
  import {
3019
3019
  createReadStream,
3020
- existsSync as existsSync10,
3020
+ existsSync as existsSync11,
3021
3021
  watch as fsWatch,
3022
3022
  readdirSync as readdirSync6,
3023
3023
  readFileSync as readFileSync8,
@@ -3025,7 +3025,7 @@ import {
3025
3025
  } from "fs";
3026
3026
  import { realpath as realpath2 } from "fs/promises";
3027
3027
  import { createServer } from "http";
3028
- import { homedir as homedir9, hostname as hostname2 } from "os";
3028
+ import { homedir as homedir9, hostname as hostname3 } from "os";
3029
3029
  import { basename as basename5, dirname as dirname9, join as join18 } from "path";
3030
3030
  import { createInterface } from "readline";
3031
3031
 
@@ -3285,7 +3285,7 @@ async function handleStartAgentSession(body, deps) {
3285
3285
  }
3286
3286
 
3287
3287
  // src/api/app.ts
3288
- import { Hono as Hono16 } from "hono";
3288
+ import { Hono as Hono18 } from "hono";
3289
3289
 
3290
3290
  // src/db/repositories/devices.repository.ts
3291
3291
  import { createHash, randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual2 } from "crypto";
@@ -3605,12 +3605,222 @@ var errorMiddleware = (err, c) => {
3605
3605
  return c.json({ error: message }, 500);
3606
3606
  };
3607
3607
 
3608
- // src/api/routes/browse.routes.ts
3608
+ // src/api/routes/backup.routes.ts
3609
3609
  import { Hono as Hono2 } from "hono";
3610
+ import { hostname } from "os";
3611
+
3612
+ // src/services/backup/backup.ts
3613
+ var BACKUP_FORMAT_VERSION = 1;
3614
+ var BackupError = class extends Error {
3615
+ constructor(message, code) {
3616
+ super(message);
3617
+ this.code = code;
3618
+ }
3619
+ code;
3620
+ };
3621
+ function validateArchive(input) {
3622
+ if (!input || typeof input !== "object") {
3623
+ throw new BackupError("Backup is not an object", "INVALID_ARCHIVE");
3624
+ }
3625
+ const archive = input;
3626
+ const manifest = archive.manifest;
3627
+ if (!manifest || typeof manifest !== "object") {
3628
+ throw new BackupError("Backup is missing its manifest", "INVALID_ARCHIVE");
3629
+ }
3630
+ if (manifest.formatVersion !== BACKUP_FORMAT_VERSION) {
3631
+ throw new BackupError(
3632
+ `Unsupported backup format version ${String(manifest.formatVersion)}; this build reads version ${BACKUP_FORMAT_VERSION}`,
3633
+ "UNSUPPORTED_VERSION"
3634
+ );
3635
+ }
3636
+ if (!Array.isArray(archive.projects)) {
3637
+ throw new BackupError("Backup is missing its projects array", "INVALID_ARCHIVE");
3638
+ }
3639
+ for (const [i, p] of archive.projects.entries()) {
3640
+ if (!p || typeof p !== "object") {
3641
+ throw new BackupError(`Project at index ${i} is not an object`, "INVALID_ARCHIVE");
3642
+ }
3643
+ if (typeof p.id !== "string" || p.id.length === 0) {
3644
+ throw new BackupError(`Project at index ${i} has no id`, "INVALID_ARCHIVE");
3645
+ }
3646
+ if (typeof p.path !== "string" || p.path.length === 0) {
3647
+ throw new BackupError(`Project at index ${i} has no path`, "INVALID_ARCHIVE");
3648
+ }
3649
+ }
3650
+ const ids = new Set(archive.projects.map((p) => p.id));
3651
+ if (ids.size !== archive.projects.length) {
3652
+ throw new BackupError("Backup contains duplicate project ids", "INVALID_ARCHIVE");
3653
+ }
3654
+ return archive;
3655
+ }
3656
+ function remapPaths(projects, rules) {
3657
+ const ordered = [...rules].sort((a, b) => b.from.length - a.from.length);
3658
+ return projects.map((p) => {
3659
+ const rule = ordered.find((r) => p.path === r.from || p.path.startsWith(`${r.from}/`));
3660
+ if (!rule) return p;
3661
+ return { ...p, path: `${rule.to}${p.path.slice(rule.from.length)}` };
3662
+ });
3663
+ }
3664
+ function planRestore(incoming, existing) {
3665
+ const byId = new Map(existing.map((e) => [e.id, e]));
3666
+ const byPath = new Map(existing.map((e) => [e.path, e]));
3667
+ const plan = { create: [], update: [], conflict: [] };
3668
+ for (const p of incoming) {
3669
+ const sameId = byId.get(p.id);
3670
+ if (sameId) {
3671
+ if (sameId.path !== p.path) plan.update.push(p);
3672
+ continue;
3673
+ }
3674
+ const samePath = byPath.get(p.path);
3675
+ if (samePath) {
3676
+ plan.conflict.push({ incoming: p, existingId: samePath.id });
3677
+ continue;
3678
+ }
3679
+ plan.create.push(p);
3680
+ }
3681
+ return plan;
3682
+ }
3683
+
3684
+ // src/version.ts
3685
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
3686
+ import { dirname as dirname5, join as join7 } from "path";
3687
+ var cached;
3688
+ function getVersion() {
3689
+ if (cached !== void 0) return cached;
3690
+ cached = resolveVersion();
3691
+ return cached;
3692
+ }
3693
+ function resolveVersion() {
3694
+ const scriptPath = process.argv[1] ?? "";
3695
+ const here = scriptPath ? dirname5(scriptPath) : process.cwd();
3696
+ let realHere = here;
3697
+ try {
3698
+ realHere = dirname5(realpathSync(scriptPath));
3699
+ } catch {
3700
+ }
3701
+ const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
3702
+ for (const dir of searchDirs) {
3703
+ try {
3704
+ const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
3705
+ if (v) return v;
3706
+ } catch {
3707
+ }
3708
+ }
3709
+ try {
3710
+ const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
3711
+ if (pkg.version) return `${pkg.version}+source`;
3712
+ } catch {
3713
+ }
3714
+ return "0.0.0+unknown";
3715
+ }
3716
+
3717
+ // src/api/routes/backup.routes.ts
3718
+ function readBody(c) {
3719
+ return new Promise((resolve2, reject) => {
3720
+ const chunks = [];
3721
+ c.env.incoming.on("data", (chunk) => chunks.push(chunk));
3722
+ c.env.incoming.on("end", () => {
3723
+ try {
3724
+ const raw = Buffer.concat(chunks).toString("utf-8");
3725
+ resolve2(raw ? JSON.parse(raw) : {});
3726
+ } catch {
3727
+ reject(new Error("Invalid JSON body"));
3728
+ }
3729
+ });
3730
+ c.env.incoming.on("error", reject);
3731
+ });
3732
+ }
3733
+ var createBackupRoutes = (deps) => {
3734
+ const app = new Hono2();
3735
+ app.get("/export", (c) => {
3736
+ const repo = deps.projectsRepo();
3737
+ if (!repo) {
3738
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3739
+ }
3740
+ const projects = repo.listProjects().map((p) => ({
3741
+ id: p.id,
3742
+ path: p.path,
3743
+ name: p.name ?? null,
3744
+ createdAt: p.createdAt,
3745
+ updatedAt: p.updatedAt
3746
+ }));
3747
+ return c.json({
3748
+ manifest: {
3749
+ formatVersion: BACKUP_FORMAT_VERSION,
3750
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3751
+ streamerVersion: getVersion(),
3752
+ sourceHost: hostname(),
3753
+ // No endpoint here exports the API key. The flag is recorded so an
3754
+ // archive is self-describing about its own sensitivity rather than
3755
+ // requiring a reader to infer it.
3756
+ includesSecrets: false,
3757
+ counts: { projects: projects.length }
3758
+ },
3759
+ projects
3760
+ });
3761
+ });
3762
+ app.post("/restore", async (c) => {
3763
+ const repo = deps.projectsRepo();
3764
+ if (!repo) {
3765
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3766
+ }
3767
+ let body;
3768
+ try {
3769
+ body = await readBody(c);
3770
+ } catch {
3771
+ return c.json({ error: "Invalid JSON body", code: "INVALID_BODY" }, 400);
3772
+ }
3773
+ let archive;
3774
+ try {
3775
+ archive = validateArchive(body.archive);
3776
+ } catch (err) {
3777
+ if (err instanceof BackupError) {
3778
+ return c.json({ error: err.message, code: err.code }, 400);
3779
+ }
3780
+ throw err;
3781
+ }
3782
+ const rules = Array.isArray(body.pathMap) ? body.pathMap.filter((r) => typeof r?.from === "string" && typeof r?.to === "string").map((r) => ({ from: r.from, to: r.to })) : [];
3783
+ const incoming = rules.length > 0 ? remapPaths(archive.projects, rules) : archive.projects;
3784
+ const existing = repo.listProjects().map((p) => ({ id: p.id, path: p.path }));
3785
+ const plan = planRestore(incoming, existing);
3786
+ const summary = {
3787
+ create: plan.create.length,
3788
+ update: plan.update.length,
3789
+ conflict: plan.conflict.length
3790
+ };
3791
+ if (body.apply !== true) {
3792
+ return c.json({ applied: false, summary, plan });
3793
+ }
3794
+ if (plan.conflict.length > 0) {
3795
+ return c.json(
3796
+ {
3797
+ error: "Restore has unresolved conflicts",
3798
+ code: "RESTORE_CONFLICT",
3799
+ summary,
3800
+ plan
3801
+ },
3802
+ 409
3803
+ );
3804
+ }
3805
+ let applied = 0;
3806
+ for (const p of [...plan.create, ...plan.update]) {
3807
+ try {
3808
+ repo.upsertProjectByPath(p.path, { name: p.name });
3809
+ applied++;
3810
+ } catch {
3811
+ }
3812
+ }
3813
+ return c.json({ applied: true, summary, appliedCount: applied });
3814
+ });
3815
+ return app;
3816
+ };
3817
+
3818
+ // src/api/routes/browse.routes.ts
3819
+ import { Hono as Hono3 } from "hono";
3610
3820
  var ALREADY_HANDLED = 597;
3611
3821
  var alreadyHandled = () => new Response(null, { status: ALREADY_HANDLED });
3612
3822
  var createBrowseRoutes = (deps) => {
3613
- const app = new Hono2();
3823
+ const app = new Hono3();
3614
3824
  app.get("/browse", async (c) => {
3615
3825
  const url = new URL(c.req.url);
3616
3826
  await deps.handleBrowse(url, c.env.outgoing);
@@ -3624,7 +3834,7 @@ var createBrowseRoutes = (deps) => {
3624
3834
  };
3625
3835
 
3626
3836
  // src/api/routes/cacheAlert.routes.ts
3627
- import { Hono as Hono3 } from "hono";
3837
+ import { Hono as Hono4 } from "hono";
3628
3838
 
3629
3839
  // src/schemas/cacheAlert.schema.ts
3630
3840
  import { z } from "zod";
@@ -3647,7 +3857,7 @@ function readRawBody2(req) {
3647
3857
  });
3648
3858
  }
3649
3859
  var createCacheAlertRoutes = (deps) => {
3650
- const app = new Hono3();
3860
+ const app = new Hono4();
3651
3861
  app.get("/", (c) => {
3652
3862
  const monitor = deps.cacheMonitor();
3653
3863
  return c.json({ pending: monitor?.pending ?? null });
@@ -3684,7 +3894,7 @@ var createCacheAlertRoutes = (deps) => {
3684
3894
  };
3685
3895
 
3686
3896
  // src/api/routes/config.routes.ts
3687
- import { Hono as Hono4 } from "hono";
3897
+ import { Hono as Hono5 } from "hono";
3688
3898
 
3689
3899
  // src/schemas/claudeFlags.schema.ts
3690
3900
  import { z as z2 } from "zod";
@@ -3705,7 +3915,7 @@ function readRawBody3(req) {
3705
3915
  });
3706
3916
  }
3707
3917
  var createConfigRoutes = (deps) => {
3708
- const app = new Hono4();
3918
+ const app = new Hono5();
3709
3919
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3710
3920
  app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3711
3921
  app.put("/claude-flags", async (c) => {
@@ -3740,11 +3950,11 @@ var createConfigRoutes = (deps) => {
3740
3950
  };
3741
3951
 
3742
3952
  // src/api/routes/conversations.routes.ts
3743
- import { Hono as Hono5 } from "hono";
3953
+ import { Hono as Hono6 } from "hono";
3744
3954
  var ALREADY_HANDLED2 = 597;
3745
3955
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3746
3956
  var createConversationRoutes = (deps) => {
3747
- const app = new Hono5();
3957
+ const app = new Hono6();
3748
3958
  app.get("/count", async (c) => {
3749
3959
  const url = new URL(c.req.url);
3750
3960
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3771,9 +3981,9 @@ var createConversationRoutes = (deps) => {
3771
3981
  };
3772
3982
 
3773
3983
  // src/api/routes/devices.routes.ts
3774
- import { Hono as Hono6 } from "hono";
3984
+ import { Hono as Hono7 } from "hono";
3775
3985
  var createDeviceRoutes = (deps) => {
3776
- const app = new Hono6();
3986
+ const app = new Hono7();
3777
3987
  app.get("/", (c) => {
3778
3988
  const repo = deps.devicesRepo();
3779
3989
  if (!repo) return c.json({ devices: [], available: false });
@@ -3796,45 +4006,134 @@ var createDeviceRoutes = (deps) => {
3796
4006
  return app;
3797
4007
  };
3798
4008
 
3799
- // src/api/routes/health.routes.ts
3800
- import { Hono as Hono7 } from "hono";
4009
+ // src/api/routes/diagnostics.routes.ts
4010
+ import { existsSync as existsSync5 } from "fs";
4011
+ import { Hono as Hono8 } from "hono";
3801
4012
 
3802
- // src/version.ts
3803
- import { readFileSync as readFileSync4, realpathSync } from "fs";
3804
- import { dirname as dirname5, join as join7 } from "path";
3805
- var cached;
3806
- function getVersion() {
3807
- if (cached !== void 0) return cached;
3808
- cached = resolveVersion();
3809
- return cached;
4013
+ // src/services/diagnostics/diagnostics.ts
4014
+ var DIAGNOSTICS_CONTRACT_VERSION = 1;
4015
+ function redactPath(path) {
4016
+ if (!path) return null;
4017
+ const parts = path.split(/[/\\]/).filter(Boolean);
4018
+ if (parts.length <= 2) return parts.join("/");
4019
+ return `\u2026/${parts.slice(-2).join("/")}`;
4020
+ }
4021
+ function worstStatus(checks) {
4022
+ const rank = { ok: 0, unknown: 1, degraded: 2, failed: 3 };
4023
+ return checks.reduce(
4024
+ (worst, c) => rank[c.status] > rank[worst] ? c.status : worst,
4025
+ "ok"
4026
+ );
3810
4027
  }
3811
- function resolveVersion() {
3812
- const scriptPath = process.argv[1] ?? "";
3813
- const here = scriptPath ? dirname5(scriptPath) : process.cwd();
3814
- let realHere = here;
3815
- try {
3816
- realHere = dirname5(realpathSync(scriptPath));
3817
- } catch {
4028
+ function buildReport(checks, now = /* @__PURE__ */ new Date()) {
4029
+ return {
4030
+ contractVersion: DIAGNOSTICS_CONTRACT_VERSION,
4031
+ generatedAt: now.toISOString(),
4032
+ overall: worstStatus(checks),
4033
+ checks
4034
+ };
4035
+ }
4036
+ var SECRET_KEY_RE = /(key|token|secret|password|passwd|credential|authorization|cookie)/i;
4037
+ function redactValue(value) {
4038
+ if (Array.isArray(value)) {
4039
+ return value.map((v) => redactValue(v));
3818
4040
  }
3819
- const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
3820
- for (const dir of searchDirs) {
3821
- try {
3822
- const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
3823
- if (v) return v;
3824
- } catch {
4041
+ if (value && typeof value === "object") {
4042
+ const out = {};
4043
+ for (const [k, v] of Object.entries(value)) {
4044
+ out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : redactValue(v);
3825
4045
  }
4046
+ return out;
3826
4047
  }
4048
+ return value;
4049
+ }
4050
+
4051
+ // src/api/routes/diagnostics.routes.ts
4052
+ function providerCheck(name, resolve2) {
3827
4053
  try {
3828
- const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
3829
- if (pkg.version) return `${pkg.version}+source`;
4054
+ const exe = resolve2();
4055
+ return {
4056
+ id: `provider:${name}`,
4057
+ status: "ok",
4058
+ summary: `${name} CLI is installed.`,
4059
+ remediation: "NONE",
4060
+ detail: { location: redactPath(exe) }
4061
+ };
3830
4062
  } catch {
4063
+ return {
4064
+ id: `provider:${name}`,
4065
+ status: "failed",
4066
+ summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
4067
+ remediation: "PROVIDER_NOT_INSTALLED"
4068
+ };
3831
4069
  }
3832
- return "0.0.0+unknown";
3833
4070
  }
4071
+ var createDiagnosticsRoutes = (deps) => {
4072
+ const app = new Hono8();
4073
+ app.get("/", (c) => {
4074
+ const checks = [];
4075
+ checks.push({
4076
+ id: "streamer",
4077
+ status: "ok",
4078
+ summary: "Streamer is running.",
4079
+ remediation: "NONE",
4080
+ detail: { version: getVersion(), uptimeSeconds: Math.floor(process.uptime()) }
4081
+ });
4082
+ checks.push(providerCheck("claude-code", resolveClaudeExe));
4083
+ checks.push(providerCheck("codex-cli", resolveCodexExe));
4084
+ const cacheAlert = deps.cacheMonitor()?.healthzField();
4085
+ checks.push(
4086
+ cacheAlert ? {
4087
+ id: "cache",
4088
+ status: "degraded",
4089
+ summary: "Conversation cache reported an integrity alert.",
4090
+ remediation: "CACHE_DEGRADED"
4091
+ } : {
4092
+ id: "cache",
4093
+ status: "ok",
4094
+ summary: "Conversation cache is healthy.",
4095
+ remediation: "NONE"
4096
+ }
4097
+ );
4098
+ let ptyOk = true;
4099
+ try {
4100
+ __require.resolve("node-pty");
4101
+ } catch {
4102
+ ptyOk = false;
4103
+ }
4104
+ checks.push(
4105
+ ptyOk ? { id: "pty", status: "ok", summary: "PTY subsystem is available.", remediation: "NONE" } : {
4106
+ id: "pty",
4107
+ status: "failed",
4108
+ summary: "node-pty failed to load, so no managed session can start.",
4109
+ remediation: "PTY_UNAVAILABLE"
4110
+ }
4111
+ );
4112
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
4113
+ const claudeProjects = home ? `${home}/.claude/projects` : "";
4114
+ checks.push(
4115
+ claudeProjects && existsSync5(claudeProjects) ? {
4116
+ id: "filesystem",
4117
+ status: "ok",
4118
+ summary: "Provider history directory is present.",
4119
+ remediation: "NONE",
4120
+ detail: { location: redactPath(claudeProjects) }
4121
+ } : {
4122
+ id: "filesystem",
4123
+ status: "degraded",
4124
+ summary: "Provider history directory was not found; history may be unavailable.",
4125
+ remediation: "FS_SCOPE_MISSING"
4126
+ }
4127
+ );
4128
+ return c.json(redactValue(buildReport(checks)));
4129
+ });
4130
+ return app;
4131
+ };
3834
4132
 
3835
4133
  // src/api/routes/health.routes.ts
4134
+ import { Hono as Hono9 } from "hono";
3836
4135
  var createHealthRoutes = (deps) => {
3837
- const app = new Hono7();
4136
+ const app = new Hono9();
3838
4137
  app.get("/", (c) => {
3839
4138
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3840
4139
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3843,9 +4142,9 @@ var createHealthRoutes = (deps) => {
3843
4142
  };
3844
4143
 
3845
4144
  // src/api/routes/logs.routes.ts
3846
- import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
4145
+ import { closeSync, existsSync as existsSync6, fstatSync, openSync, readSync, statSync } from "fs";
3847
4146
  import { join as join9 } from "path";
3848
- import { Hono as Hono8 } from "hono";
4147
+ import { Hono as Hono10 } from "hono";
3849
4148
 
3850
4149
  // src/lifecycle/constants.ts
3851
4150
  import { homedir as homedir4 } from "os";
@@ -3863,12 +4162,12 @@ function resolveLogPath(source) {
3863
4162
  function pickDefaultSource() {
3864
4163
  for (const source of ["stdout", "stderr", "dev"]) {
3865
4164
  const p = resolveLogPath(source);
3866
- if (existsSync5(p) && statSync(p).size > 0) return source;
4165
+ if (existsSync6(p) && statSync(p).size > 0) return source;
3867
4166
  }
3868
4167
  return "stdout";
3869
4168
  }
3870
4169
  function readLogLines(filePath, sinceOffset, limit) {
3871
- if (!existsSync5(filePath)) {
4170
+ if (!existsSync6(filePath)) {
3872
4171
  return { lines: [], offset: 0, total: 0 };
3873
4172
  }
3874
4173
  const fd = openSync(filePath, "r");
@@ -3903,7 +4202,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3903
4202
  }
3904
4203
  }
3905
4204
  function createLogsRoutes() {
3906
- const app = new Hono8();
4205
+ const app = new Hono10();
3907
4206
  app.get("/", (c) => {
3908
4207
  try {
3909
4208
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3911,7 +4210,7 @@ function createLogsRoutes() {
3911
4210
  const logPath = resolveLogPath(source);
3912
4211
  const sinceOffset = parseInt(c.req.query("since") || "0", 10);
3913
4212
  const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
3914
- if (!existsSync5(logPath)) {
4213
+ if (!existsSync6(logPath)) {
3915
4214
  return c.json({
3916
4215
  logs: [],
3917
4216
  message: `No log file found for source=${source}`,
@@ -3948,7 +4247,7 @@ function createLogsRoutes() {
3948
4247
  try {
3949
4248
  const sources = ["stdout", "stderr", "dev"].map((source) => {
3950
4249
  const logPath = resolveLogPath(source);
3951
- if (!existsSync5(logPath)) {
4250
+ if (!existsSync6(logPath)) {
3952
4251
  return { source, exists: false, total: 0, fileSize: 0 };
3953
4252
  }
3954
4253
  const stats = statSync(logPath);
@@ -3974,8 +4273,8 @@ function createLogsRoutes() {
3974
4273
  // src/api/routes/misc.routes.ts
3975
4274
  import { spawn } from "child_process";
3976
4275
  import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
3977
- import { Hono as Hono9 } from "hono";
3978
- import { hostname } from "os";
4276
+ import { Hono as Hono11 } from "hono";
4277
+ import { hostname as hostname2 } from "os";
3979
4278
 
3980
4279
  // src/config/update-config.ts
3981
4280
  import { readFileSync as readFileSync5 } from "fs";
@@ -4305,12 +4604,12 @@ function verifyWebhookSignature(body, header, secret) {
4305
4604
  }
4306
4605
  var clientLog = getLogger("client");
4307
4606
  var createMiscRoutes = (deps) => {
4308
- const app = new Hono9();
4607
+ const app = new Hono11();
4309
4608
  app.get("/api/info", (c) => {
4310
4609
  const ptyIds = deps.ptyAttachedIds();
4311
4610
  return c.json({
4312
4611
  version: getVersion(),
4313
- machineName: hostname(),
4612
+ machineName: hostname2(),
4314
4613
  platform: process.platform,
4315
4614
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
4316
4615
  publicUrl: deps.publicUrl,
@@ -4436,11 +4735,11 @@ var createMiscRoutes = (deps) => {
4436
4735
  };
4437
4736
 
4438
4737
  // src/api/routes/pair.routes.ts
4439
- import { Hono as Hono10 } from "hono";
4738
+ import { Hono as Hono12 } from "hono";
4440
4739
  var ALREADY_HANDLED3 = 597;
4441
4740
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
4442
4741
  var createPairRoutes = (deps) => {
4443
- const app = new Hono10();
4742
+ const app = new Hono12();
4444
4743
  app.post("/start", (c) => {
4445
4744
  deps.handlePairStart(c.env.outgoing);
4446
4745
  return alreadyHandled3();
@@ -4453,11 +4752,11 @@ var createPairRoutes = (deps) => {
4453
4752
  };
4454
4753
 
4455
4754
  // src/api/routes/projects.routes.ts
4456
- import { Hono as Hono11 } from "hono";
4755
+ import { Hono as Hono13 } from "hono";
4457
4756
  var ALREADY_HANDLED4 = 597;
4458
4757
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
4459
4758
  var createProjectRoutes = (deps) => {
4460
- const app = new Hono11();
4759
+ const app = new Hono13();
4461
4760
  app.get("/", (c) => {
4462
4761
  const url = new URL(c.req.url);
4463
4762
  deps.handleListProjects(url, c.env.outgoing);
@@ -4472,7 +4771,7 @@ var createProjectRoutes = (deps) => {
4472
4771
  };
4473
4772
 
4474
4773
  // src/api/routes/providers.routes.ts
4475
- import { Hono as Hono12 } from "hono";
4774
+ import { Hono as Hono14 } from "hono";
4476
4775
 
4477
4776
  // src/services/providers/providerHealth.ts
4478
4777
  import { execFile as execFile2 } from "child_process";
@@ -4595,7 +4894,7 @@ async function providerHealth(name, resolveExe, detect = runVersion) {
4595
4894
 
4596
4895
  // src/api/routes/providers.routes.ts
4597
4896
  var createProviderRoutes = () => {
4598
- const app = new Hono12();
4897
+ const app = new Hono14();
4599
4898
  app.get("/", async (c) => {
4600
4899
  const providers = await Promise.all([
4601
4900
  providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
@@ -4607,11 +4906,11 @@ var createProviderRoutes = () => {
4607
4906
  };
4608
4907
 
4609
4908
  // src/api/routes/scanner.routes.ts
4610
- import { Hono as Hono13 } from "hono";
4909
+ import { Hono as Hono15 } from "hono";
4611
4910
  var ALREADY_HANDLED5 = 597;
4612
4911
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
4613
4912
  var createScannerRoutes = (deps) => {
4614
- const app = new Hono13();
4913
+ const app = new Hono15();
4615
4914
  app.get("/api/search", async (c) => {
4616
4915
  const url = new URL(c.req.url);
4617
4916
  await deps.handleSearch(url, c.env.outgoing);
@@ -4621,11 +4920,11 @@ var createScannerRoutes = (deps) => {
4621
4920
  };
4622
4921
 
4623
4922
  // src/api/routes/sessions.routes.ts
4624
- import { Hono as Hono14 } from "hono";
4923
+ import { Hono as Hono16 } from "hono";
4625
4924
  var ALREADY_HANDLED6 = 597;
4626
4925
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
4627
4926
  var createSessionRoutes = (deps) => {
4628
- const app = new Hono14();
4927
+ const app = new Hono16();
4629
4928
  app.get("/count", (c) => {
4630
4929
  deps.handleSessionsCount(c.env.outgoing);
4631
4930
  return alreadyHandled6();
@@ -4700,9 +4999,9 @@ var createSessionRoutes = (deps) => {
4700
4999
  };
4701
5000
 
4702
5001
  // src/api/routes/ws.routes.ts
4703
- import { Hono as Hono15 } from "hono";
5002
+ import { Hono as Hono17 } from "hono";
4704
5003
  var createWsRoutes = (deps, upgradeWebSocket) => {
4705
- const app = new Hono15();
5004
+ const app = new Hono17();
4706
5005
  app.get(
4707
5006
  "/ws",
4708
5007
  upgradeWebSocket(() => {
@@ -4728,7 +5027,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
4728
5027
 
4729
5028
  // src/api/app.ts
4730
5029
  var createHonoApp = (deps, upgradeWebSocket) => {
4731
- const app = new Hono16();
5030
+ const app = new Hono18();
4732
5031
  const httpLog = getLogger("http");
4733
5032
  app.use("*", async (c, next) => {
4734
5033
  const start = Date.now();
@@ -4749,6 +5048,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4749
5048
  app.use("*", authMiddleware(deps));
4750
5049
  app.onError(errorMiddleware);
4751
5050
  app.route("/healthz", createHealthRoutes(deps));
5051
+ app.route("/api/diagnostics", createDiagnosticsRoutes(deps));
4752
5052
  app.route("/", createMiscRoutes(deps));
4753
5053
  app.route("/api/sessions", createSessionRoutes(deps));
4754
5054
  app.route("/api/conversations", createConversationRoutes(deps));
@@ -4757,6 +5057,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4757
5057
  app.route("/api/projects", createProjectRoutes(deps));
4758
5058
  app.route("/api/providers", createProviderRoutes());
4759
5059
  app.route("/api/devices", createDeviceRoutes(deps));
5060
+ app.route("/api/backup", createBackupRoutes(deps));
4760
5061
  app.route("/api/pair", createPairRoutes(deps));
4761
5062
  app.route("/api", createBrowseRoutes(deps));
4762
5063
  app.route("/", createScannerRoutes(deps));
@@ -4825,7 +5126,7 @@ import {
4825
5126
  parseJsonlLine
4826
5127
  } from "@threadbase-sh/scanner";
4827
5128
  import Database from "better-sqlite3";
4828
- import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
5129
+ import { closeSync as closeSync3, existsSync as existsSync7, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
4829
5130
  import { open as openAsync } from "fs/promises";
4830
5131
  import { dirname as dirname7 } from "path";
4831
5132
  import { setImmediate as yieldToEventLoop } from "timers/promises";
@@ -6057,7 +6358,7 @@ var ConversationCache = class _ConversationCache {
6057
6358
  * `handleGetConversation` can still serve the cached tail even when the
6058
6359
  * JSONL has been deleted.
6059
6360
  */
6060
- pruneGhostFiles(exists = existsSync6) {
6361
+ pruneGhostFiles(exists = existsSync7) {
6061
6362
  const rows = this.stmts.allFilePaths.all();
6062
6363
  const ghosts = [];
6063
6364
  const prune = this.db.transaction((ids) => {
@@ -6112,7 +6413,7 @@ var ConversationCache = class _ConversationCache {
6112
6413
  * Returns the removed IDs.
6113
6414
  */
6114
6415
  reconcileDeletions(livePaths, opts) {
6115
- const exists = opts?.exists ?? existsSync6;
6416
+ const exists = opts?.exists ?? existsSync7;
6116
6417
  const rows = this.stmts.allFilePaths.all();
6117
6418
  const removed = [];
6118
6419
  const drop = this.db.transaction((ids) => {
@@ -6147,7 +6448,7 @@ var ConversationCache = class _ConversationCache {
6147
6448
  * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
6148
6449
  * rows that still have cached history (which pruneGhostFiles would keep).
6149
6450
  */
6150
- listMissingFiles(exists = existsSync6) {
6451
+ listMissingFiles(exists = existsSync7) {
6151
6452
  const rows = this.stmts.allFilePathsWithTitle.all();
6152
6453
  const missing = [];
6153
6454
  for (const row of rows) {
@@ -6648,7 +6949,7 @@ function setCacheMetadata(repo, key, value) {
6648
6949
 
6649
6950
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
6650
6951
  import { createHash as createHash3 } from "crypto";
6651
- import { existsSync as existsSync8 } from "fs";
6952
+ import { existsSync as existsSync9 } from "fs";
6652
6953
 
6653
6954
  // src/services/cache-integrity/alertStore.ts
6654
6955
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
@@ -6674,7 +6975,7 @@ function saveAlertState(state) {
6674
6975
  }
6675
6976
 
6676
6977
  // src/services/cache-integrity/backup.ts
6677
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
6978
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
6678
6979
  import { join as join15 } from "path";
6679
6980
  var DEFAULT_RETAIN = 3;
6680
6981
  function retainCount() {
@@ -6696,7 +6997,7 @@ async function backupCacheDb(db, cacheDir) {
6696
6997
  return { full, mtime: statSync5(full).mtimeMs };
6697
6998
  }).sort((a, b) => b.mtime - a.mtime);
6698
6999
  for (const stale of backups.slice(retain)) {
6699
- if (existsSync7(stale.full)) unlinkSync(stale.full);
7000
+ if (existsSync8(stale.full)) unlinkSync(stale.full);
6700
7001
  }
6701
7002
  return destPath;
6702
7003
  }
@@ -6793,7 +7094,7 @@ var CacheIntegrityMonitor = class {
6793
7094
  * the pending record, back up on high severity, and broadcast the alert.
6794
7095
  */
6795
7096
  async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
6796
- const all = this.cache.listMissingFiles(existsSync8);
7097
+ const all = this.cache.listMissingFiles(existsSync9);
6797
7098
  const missing = all.filter((m) => !this.ignoredIds.has(m.id));
6798
7099
  if (missing.length === 0) {
6799
7100
  if (this._pending) {
@@ -6897,7 +7198,7 @@ var CacheIntegrityMonitor = class {
6897
7198
  case "prune_all": {
6898
7199
  await this.ensureBackup(pending);
6899
7200
  const backupPath = pending.backupPath;
6900
- const stillMissing = pending.missing.filter((m) => !existsSync8(m.filePath)).map((m) => m.id);
7201
+ const stillMissing = pending.missing.filter((m) => !existsSync9(m.filePath)).map((m) => m.id);
6901
7202
  const pruned = this.cache.dropRowsById(stillMissing);
6902
7203
  this.applyDeferredUnlinks();
6903
7204
  this.clearPending();
@@ -7167,14 +7468,14 @@ function findSearchTarget(messages, query) {
7167
7468
  }
7168
7469
 
7169
7470
  // src/services/conversations/pruneAgentConversations.ts
7170
- import { existsSync as existsSync9 } from "fs";
7471
+ import { existsSync as existsSync10 } from "fs";
7171
7472
  function pruneAgentConversations(cache) {
7172
7473
  const db = cache.getDatabase();
7173
7474
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
7174
7475
  let pruned = 0;
7175
7476
  let missing = 0;
7176
7477
  for (const row of rows) {
7177
- if (!existsSync9(row.file_path)) {
7478
+ if (!existsSync10(row.file_path)) {
7178
7479
  missing += 1;
7179
7480
  continue;
7180
7481
  }
@@ -8051,6 +8352,88 @@ function resolveAnswer(pending, body) {
8051
8352
  }
8052
8353
  }
8053
8354
 
8355
+ // src/services/search/searchQuery.ts
8356
+ var DEFAULT_SEARCH_LIMIT = 50;
8357
+ var MAX_SEARCH_LIMIT = 200;
8358
+ var MAX_QUERY_LENGTH = 256;
8359
+ var SearchQueryError = class extends Error {
8360
+ constructor(message, code) {
8361
+ super(message);
8362
+ this.code = code;
8363
+ }
8364
+ code;
8365
+ };
8366
+ function intOr(raw, fallback) {
8367
+ if (raw === null) return fallback;
8368
+ const n = Number.parseInt(raw, 10);
8369
+ return Number.isFinite(n) ? n : fallback;
8370
+ }
8371
+ function parseSearchQuery(params) {
8372
+ const q = (params.get("q") ?? "").trim();
8373
+ if (!q) {
8374
+ throw new SearchQueryError("Missing query parameter: q", "invalid_query");
8375
+ }
8376
+ if (q.length > MAX_QUERY_LENGTH) {
8377
+ throw new SearchQueryError(`Query exceeds ${MAX_QUERY_LENGTH} characters`, "query_too_long");
8378
+ }
8379
+ const limit = Math.min(
8380
+ Math.max(intOr(params.get("limit"), DEFAULT_SEARCH_LIMIT), 1),
8381
+ MAX_SEARCH_LIMIT
8382
+ );
8383
+ const offset = Math.max(intOr(params.get("offset"), 0), 0);
8384
+ const filters = {};
8385
+ const provider = params.get("provider");
8386
+ if (provider !== null) {
8387
+ if (!isProviderName(provider)) {
8388
+ throw new SearchQueryError(`Unknown provider: ${provider}`, "invalid_filter");
8389
+ }
8390
+ filters.provider = provider;
8391
+ }
8392
+ const projectPath = params.get("projectPath");
8393
+ if (projectPath) filters.projectPath = projectPath;
8394
+ const branch = params.get("branch");
8395
+ if (branch) filters.branch = branch;
8396
+ for (const [key, field] of [
8397
+ ["since", "since"],
8398
+ ["until", "until"]
8399
+ ]) {
8400
+ const raw = params.get(key);
8401
+ if (raw === null) continue;
8402
+ const ms = Date.parse(raw);
8403
+ if (Number.isNaN(ms)) {
8404
+ throw new SearchQueryError(`Invalid ${key}: expected an ISO 8601 date`, "invalid_filter");
8405
+ }
8406
+ filters[field] = ms;
8407
+ }
8408
+ if (filters.since != null && filters.until != null && filters.since > filters.until) {
8409
+ throw new SearchQueryError("`since` must not be after `until`", "invalid_filter");
8410
+ }
8411
+ return { q, limit, offset, filters };
8412
+ }
8413
+ function applyFilters(results, filters) {
8414
+ return results.filter((r) => {
8415
+ if (filters.provider && r.provider !== filters.provider) return false;
8416
+ if (filters.projectPath && r.projectPath !== filters.projectPath) return false;
8417
+ if (filters.branch && r.branch !== filters.branch) return false;
8418
+ if (filters.since != null || filters.until != null) {
8419
+ const ts = r.lastActivity == null ? Number.NaN : new Date(r.lastActivity).getTime();
8420
+ if (Number.isNaN(ts)) return false;
8421
+ if (filters.since != null && ts < filters.since) return false;
8422
+ if (filters.until != null && ts > filters.until) return false;
8423
+ }
8424
+ return true;
8425
+ });
8426
+ }
8427
+ function paginate(results, offset, limit) {
8428
+ const items = results.slice(offset, offset + limit);
8429
+ return {
8430
+ items,
8431
+ total: results.length,
8432
+ offset,
8433
+ hasMore: offset + items.length < results.length
8434
+ };
8435
+ }
8436
+
8054
8437
  // src/services/sessions/conversationBusy.ts
8055
8438
  import { statSync as statSync8 } from "fs";
8056
8439
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -8753,6 +9136,8 @@ var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
8753
9136
  var GRACE_MAX_DEFERS = 4;
8754
9137
  var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
8755
9138
  var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
9139
+ var SEARCH_OVERFETCH = 4;
9140
+ var SEARCH_MAX_SCAN = 1e3;
8756
9141
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
8757
9142
  var DISCOVERY_TTL_MS = 15e3;
8758
9143
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -8830,6 +9215,20 @@ var StreamerServer = class {
8830
9215
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
8831
9216
  // a single rescan after the current one completes instead of restarting it.
8832
9217
  scannerStale = false;
9218
+ // WHICH files scannerStale is about. A directory event names exactly one
9219
+ // JSONL, and the only correct response is refreshFile() on that one file —
9220
+ // but scannerStale alone carries no identity, so honoring it used to mean a
9221
+ // full-tree rescan. On the non-persistent scanner this server actually runs
9222
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
9223
+ // clearing metadataCache AND conversationLRU — so one live session appending
9224
+ // to its own transcript threw away every OTHER conversation's parsed
9225
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
9226
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
9227
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
9228
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
9229
+ // an EMPTY set means "stale, source unknown" and still falls back to the
9230
+ // full rescan.
9231
+ staleFiles = /* @__PURE__ */ new Set();
8833
9232
  // Single-flight guard for the background disk reconcile: a burst of list
8834
9233
  // polls during active session writes shares one rescan instead of queueing
8835
9234
  // a full rescan per request.
@@ -8998,7 +9397,10 @@ var StreamerServer = class {
8998
9397
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
8999
9398
  this.markScannerStaleDebounced = debounce(() => {
9000
9399
  if (this.scannerReady) this.scannerStale = true;
9001
- else this.scanner = null;
9400
+ else {
9401
+ this.scanner = null;
9402
+ this.staleFiles.clear();
9403
+ }
9002
9404
  }, this.directoryDebounceMs);
9003
9405
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
9004
9406
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -9091,6 +9493,7 @@ var StreamerServer = class {
9091
9493
  if (!tailed) this.maybeAttachExternalTail(filePath);
9092
9494
  this.sweepIdleExternalTails();
9093
9495
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
9496
+ this.staleFiles.add(filePath);
9094
9497
  this.markScannerStaleDebounced();
9095
9498
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
9096
9499
  filePath,
@@ -9503,14 +9906,14 @@ var StreamerServer = class {
9503
9906
  }
9504
9907
  this.apnsClient = new ApnsClient(creds);
9505
9908
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9506
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname2();
9507
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname2());
9909
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname3();
9910
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname3());
9508
9911
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9509
9912
  repo: pushRepo,
9510
9913
  sender,
9511
9914
  sessionStore: this.sessionStore,
9512
9915
  serverId,
9513
- serverLabel: hostname2()
9916
+ serverLabel: hostname3()
9514
9917
  });
9515
9918
  this.liveActivityRenewal.start();
9516
9919
  this.log.info("Live Activity push enabled", {
@@ -9849,7 +10252,7 @@ var StreamerServer = class {
9849
10252
  this.fileWatcher.watchDirectory(dir);
9850
10253
  }
9851
10254
  for (const dir of this.codexRoots) {
9852
- if (!existsSync10(dir)) continue;
10255
+ if (!existsSync11(dir)) continue;
9853
10256
  this.fileWatcher.watchDirectory(dir);
9854
10257
  }
9855
10258
  } catch (err) {
@@ -10122,7 +10525,7 @@ var StreamerServer = class {
10122
10525
  }
10123
10526
  let body;
10124
10527
  try {
10125
- body = await readBody(req);
10528
+ body = await readBody2(req);
10126
10529
  } catch (err) {
10127
10530
  const message = err instanceof Error ? err.message : "Invalid body";
10128
10531
  json(res, 400, { error: message });
@@ -10168,7 +10571,7 @@ var StreamerServer = class {
10168
10571
  nonce: sealed.nonce,
10169
10572
  ephemeralPublicKey: sealed.ephemeralPublicKey,
10170
10573
  publicUrl: this.publicUrl,
10171
- machineName: hostname2(),
10574
+ machineName: hostname3(),
10172
10575
  ...device && {
10173
10576
  deviceId: device.deviceId,
10174
10577
  deviceToken: device.deviceToken,
@@ -10337,21 +10740,46 @@ var StreamerServer = class {
10337
10740
  // so a burst of list polls during active session writes shares one rescan
10338
10741
  // rather than queueing a full rescan each; tracked so close() awaits the
10339
10742
  // in-flight cache write before shutting the DB.
10340
- startBackgroundConversationReconcile() {
10743
+ startBackgroundConversationReconcile(mode = "full") {
10341
10744
  if (this.conversationReconcileInFlight) return;
10342
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
10745
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
10746
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
10343
10747
  this.conversationReconcileInFlight = null;
10344
10748
  });
10345
10749
  this.conversationReconcileInFlight = task;
10346
10750
  this.trackCacheWrite(task);
10347
10751
  }
10348
- shouldAutoReconcileConversationList() {
10349
- if (!this.cache) return false;
10350
- if (this.scannerStale) return true;
10351
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
10752
+ // "files": a directory event named specific JSONLs, so refresh only those.
10753
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
10754
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
10755
+ // check short-circuits first so the HDD freshness probe stays off the hot
10756
+ // poll path, exactly as it did when this returned a boolean.
10757
+ conversationReconcileMode() {
10758
+ if (!this.cache) return null;
10759
+ if (this.scannerStale) return "files";
10760
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
10352
10761
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
10353
10762
  projectsDirs: this.projectsDirsForFreshnessCheck()
10354
- });
10763
+ }) ? "full" : null;
10764
+ }
10765
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
10766
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
10767
+ // needs the whole live-path set, and deletions already have their own path
10768
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
10769
+ // HDD-freshness "full" mode.
10770
+ async reconcileStaleFilesFromDisk(paths) {
10771
+ if (!this.cache) return;
10772
+ const scanner = await this.getScanner(true);
10773
+ const metas = await this.refreshStaleFiles(scanner, paths);
10774
+ if (metas.length === 0) return;
10775
+ try {
10776
+ this.cache.upsertFromScannerMeta(metas);
10777
+ } catch (err) {
10778
+ this.log.warn(
10779
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
10780
+ { event: "conversations.reconcile_failed" }
10781
+ );
10782
+ }
10355
10783
  }
10356
10784
  async handleListConversations(url, res) {
10357
10785
  if (this.rejectIfWarmingUp(res)) return;
@@ -10361,10 +10789,11 @@ var StreamerServer = class {
10361
10789
  const project = url.searchParams.get("project") ?? void 0;
10362
10790
  const providerFilter = url.searchParams.get("provider") ?? void 0;
10363
10791
  const bustCache = url.searchParams.get("refresh") === "1";
10364
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
10792
+ const reconcileMode = this.conversationReconcileMode();
10793
+ if (this.cache && (bustCache || reconcileMode)) {
10365
10794
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
10366
10795
  if (canServeStale) {
10367
- this.startBackgroundConversationReconcile();
10796
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
10368
10797
  } else {
10369
10798
  const shouldEmitProgress = createScanProgressThrottle();
10370
10799
  await this.withWarmup(
@@ -10564,21 +10993,54 @@ var StreamerServer = class {
10564
10993
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
10565
10994
  );
10566
10995
  }
10996
+ // Drain the stale set and disarm the flag together. The caller owns the
10997
+ // returned paths: clearing before the refresh means events that land DURING
10998
+ // it re-arm the flag and get their own pass instead of being swallowed.
10999
+ takeStaleFiles() {
11000
+ const paths = [...this.staleFiles];
11001
+ this.staleFiles.clear();
11002
+ this.scannerStale = false;
11003
+ return paths;
11004
+ }
11005
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
11006
+ // and swallowed per file: one unreadable transcript must not abort the
11007
+ // others, and the file simply stays on its previous snapshot until the next
11008
+ // event — the same outcome the full rescan gave on a parse failure.
11009
+ async refreshStaleFiles(scanner, paths) {
11010
+ const metas = await Promise.all(
11011
+ paths.map(
11012
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
11013
+ this.log.warn("scanner.refreshFile: failed", {
11014
+ event: "scanner.refresh_failed",
11015
+ filePath,
11016
+ trigger: "directory-event",
11017
+ err
11018
+ });
11019
+ return null;
11020
+ })
11021
+ )
11022
+ );
11023
+ return metas.filter((m) => m !== null);
11024
+ }
10567
11025
  async getScanner(skipStaleRescan = false) {
10568
11026
  if (this.scannerReady) {
10569
11027
  await this.scannerReady;
10570
11028
  if (this.scanner) {
10571
11029
  if (skipStaleRescan) return this.scanner;
10572
11030
  if (this.scannerStale) {
10573
- this.scannerStale = false;
10574
- this.scanner = null;
10575
- this.scannerReady = null;
10576
- return this.getScanner();
11031
+ const paths = this.takeStaleFiles();
11032
+ if (paths.length === 0) {
11033
+ this.scanner = null;
11034
+ this.scannerReady = null;
11035
+ return this.getScanner();
11036
+ }
11037
+ await this.refreshStaleFiles(this.scanner, paths);
11038
+ return this.scanner ?? this.getScanner();
10577
11039
  }
10578
11040
  return this.scanner;
10579
11041
  }
10580
11042
  }
10581
- this.scannerStale = false;
11043
+ this.takeStaleFiles();
10582
11044
  const statCache = this.buildStatCache(this.scanner);
10583
11045
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
10584
11046
  this.allScanners.add(this.scanner);
@@ -10612,7 +11074,7 @@ var StreamerServer = class {
10612
11074
  // getScanner() anti-infinite-loop guard is preserved.
10613
11075
  async rescanForRefresh(onProgress) {
10614
11076
  if (this.scannerReady) await this.scannerReady;
10615
- this.scannerStale = false;
11077
+ this.takeStaleFiles();
10616
11078
  if (!this.scanner) {
10617
11079
  this.scanner = new ConversationScanner();
10618
11080
  this.allScanners.add(this.scanner);
@@ -10644,15 +11106,15 @@ var StreamerServer = class {
10644
11106
  findJsonlPath(uuid) {
10645
11107
  const filename = `${uuid}.jsonl`;
10646
11108
  for (const projectsDir of this.projectsDirs()) {
10647
- if (!existsSync10(projectsDir)) continue;
11109
+ if (!existsSync11(projectsDir)) continue;
10648
11110
  for (const dir of readdirSync6(projectsDir)) {
10649
11111
  const fp = join18(projectsDir, dir, filename);
10650
- if (existsSync10(fp)) return fp;
11112
+ if (existsSync11(fp)) return fp;
10651
11113
  const projectDir = join18(projectsDir, dir);
10652
11114
  try {
10653
11115
  for (const sub of readdirSync6(projectDir)) {
10654
11116
  const subagentPath = join18(projectDir, sub, "subagents", filename);
10655
- if (existsSync10(subagentPath)) return subagentPath;
11117
+ if (existsSync11(subagentPath)) return subagentPath;
10656
11118
  }
10657
11119
  } catch {
10658
11120
  }
@@ -11189,7 +11651,7 @@ var StreamerServer = class {
11189
11651
  }
11190
11652
  let body;
11191
11653
  try {
11192
- body = await readBody(req);
11654
+ body = await readBody2(req);
11193
11655
  } catch {
11194
11656
  res.setHeader("Accept-Query", "application/json");
11195
11657
  json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
@@ -11227,17 +11689,27 @@ var StreamerServer = class {
11227
11689
  });
11228
11690
  }
11229
11691
  async handleSearch(url, res) {
11230
- const q = url.searchParams.get("q") ?? "";
11231
- if (!q) {
11232
- json(res, 400, { error: "Missing query parameter: q" });
11233
- return;
11692
+ let parsed;
11693
+ try {
11694
+ parsed = parseSearchQuery(url.searchParams);
11695
+ } catch (err) {
11696
+ if (err instanceof SearchQueryError) {
11697
+ json(res, 400, { error: err.message, code: err.code });
11698
+ return;
11699
+ }
11700
+ throw err;
11234
11701
  }
11235
- const limit = intParam(url, "limit", 50);
11702
+ const { q, limit, offset, filters } = parsed;
11703
+ const startedAt = Date.now();
11236
11704
  const scanner = await this.getScanner();
11237
11705
  const results = await search(
11238
11706
  q,
11239
11707
  {
11240
- limit,
11708
+ // Fetch beyond the requested page: filters below are applied AFTER the
11709
+ // scanner returns, so slicing at `limit` here would drop results that a
11710
+ // later page should contain. Bounded so a broad query cannot pull an
11711
+ // unbounded set into memory.
11712
+ limit: Math.min(offset + limit * SEARCH_OVERFETCH, SEARCH_MAX_SCAN),
11241
11713
  include: "conversations",
11242
11714
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
11243
11715
  ...this.codexScanOpts()
@@ -11261,13 +11733,24 @@ var StreamerServer = class {
11261
11733
  lastActivity: r.meta.timestamp,
11262
11734
  firstMessage: r.meta.firstMessage ?? void 0,
11263
11735
  lastMessage: r.meta.lastMessage ?? void 0,
11264
- provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER
11736
+ provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER,
11737
+ // The scanner already computes relevance and match snippets; the previous
11738
+ // adapter discarded both, so results arrived in an unexplained order with
11739
+ // no indication of WHY anything matched.
11740
+ score: r.score,
11741
+ matches: Array.isArray(r.matches) ? r.matches.map((m) => ({
11742
+ field: m.field,
11743
+ snippet: m.snippet
11744
+ })) : []
11265
11745
  }));
11746
+ const page = paginate(applyFilters(adapted, filters), offset, limit);
11266
11747
  json(res, 200, {
11267
- conversations: adapted,
11268
- hasMore: false,
11269
- offset: 0,
11270
- total: adapted.length
11748
+ conversations: page.items,
11749
+ hasMore: page.hasMore,
11750
+ offset: page.offset,
11751
+ total: page.total,
11752
+ // Query timing, so a slow search is diagnosable rather than merely felt.
11753
+ tookMs: Date.now() - startedAt
11271
11754
  });
11272
11755
  }
11273
11756
  async handleListSessions(url, res) {
@@ -11313,7 +11796,7 @@ var StreamerServer = class {
11313
11796
  if (this.rejectIfWarmingUp(res)) return;
11314
11797
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
11315
11798
  if (session) {
11316
- if (!existsSync10(session.projectPath)) {
11799
+ if (!existsSync11(session.projectPath)) {
11317
11800
  session.failureReason = `Project directory not found: ${session.projectPath}`;
11318
11801
  }
11319
11802
  const reconciled = this.withReconciledLifecycle([session])[0];
@@ -11340,7 +11823,7 @@ var StreamerServer = class {
11340
11823
  json(res, 404, { error: "Session not found" });
11341
11824
  }
11342
11825
  async handleResume(req, res) {
11343
- const body = await readBody(req);
11826
+ const body = await readBody2(req);
11344
11827
  const sessionId = body.sessionId ?? body.conversationId;
11345
11828
  if (!sessionId) {
11346
11829
  json(res, 400, { error: "Missing sessionId" });
@@ -11472,7 +11955,7 @@ var StreamerServer = class {
11472
11955
  return;
11473
11956
  }
11474
11957
  if (this.agentConfig.enabled) {
11475
- const body2 = await readBody(req);
11958
+ const body2 = await readBody2(req);
11476
11959
  const cache = this.cache;
11477
11960
  if (!cache) {
11478
11961
  json(res, 503, {
@@ -11491,7 +11974,7 @@ var StreamerServer = class {
11491
11974
  json(res, result.status, result.body);
11492
11975
  return;
11493
11976
  }
11494
- const body = await readBody(req);
11977
+ const body = await readBody2(req);
11495
11978
  const { input, keys } = body;
11496
11979
  let idempotencyKey;
11497
11980
  try {
@@ -11655,7 +12138,7 @@ var StreamerServer = class {
11655
12138
  });
11656
12139
  }
11657
12140
  async handleSendAnswer(sessionId, req, res) {
11658
- const body = await readBody(req);
12141
+ const body = await readBody2(req);
11659
12142
  const pending = this.pendingQuestions.get(sessionId);
11660
12143
  const resolution = resolveAnswer(pending, body);
11661
12144
  if (!resolution.ok) {
@@ -11684,7 +12167,7 @@ var StreamerServer = class {
11684
12167
  json(res, 400, { error: "Session has no project path" });
11685
12168
  return;
11686
12169
  }
11687
- const body = await readBody(req);
12170
+ const body = await readBody2(req);
11688
12171
  const { filename, mimeType, dataBase64 } = body ?? {};
11689
12172
  if (typeof filename !== "string" || typeof mimeType !== "string" || typeof dataBase64 !== "string") {
11690
12173
  json(res, 400, { error: "Missing filename, mimeType, or dataBase64" });
@@ -11886,7 +12369,7 @@ var StreamerServer = class {
11886
12369
  return;
11887
12370
  }
11888
12371
  if (this.agentConfig.enabled) {
11889
- const body2 = await readBody(req);
12372
+ const body2 = await readBody2(req);
11890
12373
  const result = await handleStartAgentSession(body2, {
11891
12374
  sessionStore: this.sessionStore,
11892
12375
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
@@ -11900,7 +12383,7 @@ var StreamerServer = class {
11900
12383
  }
11901
12384
  return;
11902
12385
  }
11903
- const body = await readBody(req);
12386
+ const body = await readBody2(req);
11904
12387
  const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
11905
12388
  if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
11906
12389
  json(res, 400, { error: "Invalid provider" });
@@ -12072,8 +12555,8 @@ var StreamerServer = class {
12072
12555
  cleanup();
12073
12556
  return;
12074
12557
  }
12075
- let resolvedFilePath = existsSync10(filePath) ? filePath : null;
12076
- if (!resolvedFilePath && existsSync10(projectsDir)) {
12558
+ let resolvedFilePath = existsSync11(filePath) ? filePath : null;
12559
+ if (!resolvedFilePath && existsSync11(projectsDir)) {
12077
12560
  try {
12078
12561
  const now = Date.now();
12079
12562
  const match = readdirSync6(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync9(join18(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
@@ -12169,7 +12652,7 @@ var StreamerServer = class {
12169
12652
  );
12170
12653
  for (const root of this.codexRoots) {
12171
12654
  const sessionsDir = join18(root, dateDir);
12172
- if (!existsSync10(sessionsDir)) continue;
12655
+ if (!existsSync11(sessionsDir)) continue;
12173
12656
  let candidateFiles;
12174
12657
  try {
12175
12658
  candidateFiles = readdirSync6(sessionsDir).filter((f) => f.endsWith(".jsonl"));
@@ -12258,7 +12741,7 @@ var StreamerServer = class {
12258
12741
  });
12259
12742
  return;
12260
12743
  }
12261
- const body = await readBody(req);
12744
+ const body = await readBody2(req);
12262
12745
  const { path: relativePath, name } = body;
12263
12746
  if (!name || typeof name !== "string") {
12264
12747
  json(res, 400, { error: "Missing name field" });
@@ -12288,7 +12771,7 @@ var StreamerServer = class {
12288
12771
  }
12289
12772
  let parsed;
12290
12773
  try {
12291
- parsed = await readBody(req);
12774
+ parsed = await readBody2(req);
12292
12775
  } catch {
12293
12776
  json(res, 400, { error: "Invalid JSON" });
12294
12777
  return;
@@ -12345,7 +12828,7 @@ var StreamerServer = class {
12345
12828
  }
12346
12829
  let parsed;
12347
12830
  try {
12348
- parsed = await readBody(req);
12831
+ parsed = await readBody2(req);
12349
12832
  } catch {
12350
12833
  json(res, 400, { error: "Invalid JSON" });
12351
12834
  return;
@@ -12404,7 +12887,7 @@ async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
12404
12887
  }
12405
12888
  function classifyResumability(cwd) {
12406
12889
  if (!cwd) return { resumable: true };
12407
- if (existsSync10(cwd)) return { resumable: true };
12890
+ if (existsSync11(cwd)) return { resumable: true };
12408
12891
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
12409
12892
  return {
12410
12893
  resumable: false,
@@ -12522,7 +13005,7 @@ function parseSessionListQuery(url) {
12522
13005
  const cursor = url.searchParams.get("cursor") ?? void 0;
12523
13006
  return { query: { limit, sortBy, order, status, cursor } };
12524
13007
  }
12525
- function readBody(req) {
13008
+ function readBody2(req) {
12526
13009
  return new Promise((resolve2, reject) => {
12527
13010
  const chunks = [];
12528
13011
  req.on("data", (chunk) => chunks.push(chunk));