@threadbase-sh/streamer 1.39.1 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -500,6 +500,12 @@ var FEATURE_FLAGS = [
500
500
  description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
501
501
  default: false,
502
502
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
503
+ },
504
+ {
505
+ id: "sessionRehydration",
506
+ description: "Seed the session list at boot with sessions a previous streamer run left behind, so a restart leaves them one tap from resuming instead of silently gone. On by default, with a kill switch: it changes what GET /api/sessions contains.",
507
+ default: true,
508
+ env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
503
509
  }
504
510
  ];
505
511
  function findFeatureFlag(id) {
@@ -1936,6 +1942,12 @@ function detectShellPrompt(lines) {
1936
1942
  return null;
1937
1943
  }
1938
1944
 
1945
+ // src/utils/deriveSessionName.ts
1946
+ function deriveSessionName(firstMessageText) {
1947
+ const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
1948
+ return firstLine.slice(0, 80);
1949
+ }
1950
+
1939
1951
  // src/pty-manager.ts
1940
1952
  var OUTPUT_BUFFER_MAX2 = 65536;
1941
1953
  var INPUT_HISTORY_MAX2 = 50;
@@ -2409,6 +2421,10 @@ var PTYManager = class {
2409
2421
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2410
2422
  session.inputHistory.shift();
2411
2423
  }
2424
+ if (session.firstMessageText === void 0) {
2425
+ session.firstMessageText = text;
2426
+ session.sessionName = deriveSessionName(text);
2427
+ }
2412
2428
  this.onUserMessage?.(session.id, text, ts);
2413
2429
  }
2414
2430
  getSession(sessionId) {
@@ -2674,7 +2690,9 @@ function toPublicSession2(s) {
2674
2690
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2675
2691
  ...s.statusSource != null && { statusSource: s.statusSource },
2676
2692
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2677
- ...s.filePath != null && { filePath: s.filePath }
2693
+ ...s.filePath != null && { filePath: s.filePath },
2694
+ ...s.sessionName != null && { sessionName: s.sessionName },
2695
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
2678
2696
  };
2679
2697
  }
2680
2698
  function stripAnsi2(str) {
@@ -3061,10 +3079,10 @@ var import_client = require("@temporalio/client");
3061
3079
  var import_scanner3 = require("@threadbase-sh/scanner");
3062
3080
  var import_crypto11 = require("crypto");
3063
3081
  var import_events = require("events");
3064
- var import_fs18 = require("fs");
3082
+ var import_fs19 = require("fs");
3065
3083
  var import_promises7 = require("fs/promises");
3066
3084
  var import_http = require("http");
3067
- var import_os9 = require("os");
3085
+ var import_os10 = require("os");
3068
3086
  var import_path18 = require("path");
3069
3087
  var import_readline = require("readline");
3070
3088
 
@@ -3324,7 +3342,7 @@ async function handleStartAgentSession(body, deps) {
3324
3342
  }
3325
3343
 
3326
3344
  // src/api/app.ts
3327
- var import_hono16 = require("hono");
3345
+ var import_hono18 = require("hono");
3328
3346
 
3329
3347
  // src/db/repositories/devices.repository.ts
3330
3348
  var import_crypto5 = require("crypto");
@@ -3644,12 +3662,222 @@ var errorMiddleware = (err, c) => {
3644
3662
  return c.json({ error: message }, 500);
3645
3663
  };
3646
3664
 
3647
- // src/api/routes/browse.routes.ts
3665
+ // src/api/routes/backup.routes.ts
3648
3666
  var import_hono2 = require("hono");
3667
+ var import_os5 = require("os");
3668
+
3669
+ // src/services/backup/backup.ts
3670
+ var BACKUP_FORMAT_VERSION = 1;
3671
+ var BackupError = class extends Error {
3672
+ constructor(message, code) {
3673
+ super(message);
3674
+ this.code = code;
3675
+ }
3676
+ code;
3677
+ };
3678
+ function validateArchive(input) {
3679
+ if (!input || typeof input !== "object") {
3680
+ throw new BackupError("Backup is not an object", "INVALID_ARCHIVE");
3681
+ }
3682
+ const archive = input;
3683
+ const manifest = archive.manifest;
3684
+ if (!manifest || typeof manifest !== "object") {
3685
+ throw new BackupError("Backup is missing its manifest", "INVALID_ARCHIVE");
3686
+ }
3687
+ if (manifest.formatVersion !== BACKUP_FORMAT_VERSION) {
3688
+ throw new BackupError(
3689
+ `Unsupported backup format version ${String(manifest.formatVersion)}; this build reads version ${BACKUP_FORMAT_VERSION}`,
3690
+ "UNSUPPORTED_VERSION"
3691
+ );
3692
+ }
3693
+ if (!Array.isArray(archive.projects)) {
3694
+ throw new BackupError("Backup is missing its projects array", "INVALID_ARCHIVE");
3695
+ }
3696
+ for (const [i, p] of archive.projects.entries()) {
3697
+ if (!p || typeof p !== "object") {
3698
+ throw new BackupError(`Project at index ${i} is not an object`, "INVALID_ARCHIVE");
3699
+ }
3700
+ if (typeof p.id !== "string" || p.id.length === 0) {
3701
+ throw new BackupError(`Project at index ${i} has no id`, "INVALID_ARCHIVE");
3702
+ }
3703
+ if (typeof p.path !== "string" || p.path.length === 0) {
3704
+ throw new BackupError(`Project at index ${i} has no path`, "INVALID_ARCHIVE");
3705
+ }
3706
+ }
3707
+ const ids = new Set(archive.projects.map((p) => p.id));
3708
+ if (ids.size !== archive.projects.length) {
3709
+ throw new BackupError("Backup contains duplicate project ids", "INVALID_ARCHIVE");
3710
+ }
3711
+ return archive;
3712
+ }
3713
+ function remapPaths(projects, rules) {
3714
+ const ordered = [...rules].sort((a, b) => b.from.length - a.from.length);
3715
+ return projects.map((p) => {
3716
+ const rule = ordered.find((r) => p.path === r.from || p.path.startsWith(`${r.from}/`));
3717
+ if (!rule) return p;
3718
+ return { ...p, path: `${rule.to}${p.path.slice(rule.from.length)}` };
3719
+ });
3720
+ }
3721
+ function planRestore(incoming, existing) {
3722
+ const byId = new Map(existing.map((e) => [e.id, e]));
3723
+ const byPath = new Map(existing.map((e) => [e.path, e]));
3724
+ const plan = { create: [], update: [], conflict: [] };
3725
+ for (const p of incoming) {
3726
+ const sameId = byId.get(p.id);
3727
+ if (sameId) {
3728
+ if (sameId.path !== p.path) plan.update.push(p);
3729
+ continue;
3730
+ }
3731
+ const samePath = byPath.get(p.path);
3732
+ if (samePath) {
3733
+ plan.conflict.push({ incoming: p, existingId: samePath.id });
3734
+ continue;
3735
+ }
3736
+ plan.create.push(p);
3737
+ }
3738
+ return plan;
3739
+ }
3740
+
3741
+ // src/version.ts
3742
+ var import_node_fs2 = require("fs");
3743
+ var import_node_path3 = require("path");
3744
+ var cached;
3745
+ function getVersion() {
3746
+ if (cached !== void 0) return cached;
3747
+ cached = resolveVersion();
3748
+ return cached;
3749
+ }
3750
+ function resolveVersion() {
3751
+ const scriptPath = process.argv[1] ?? "";
3752
+ const here = scriptPath ? (0, import_node_path3.dirname)(scriptPath) : process.cwd();
3753
+ let realHere = here;
3754
+ try {
3755
+ realHere = (0, import_node_path3.dirname)((0, import_node_fs2.realpathSync)(scriptPath));
3756
+ } catch {
3757
+ }
3758
+ const searchDirs = realHere === here ? [here, (0, import_node_path3.join)(here, "..")] : [here, (0, import_node_path3.join)(here, ".."), realHere, (0, import_node_path3.join)(realHere, "..")];
3759
+ for (const dir of searchDirs) {
3760
+ try {
3761
+ const v = (0, import_node_fs2.readFileSync)((0, import_node_path3.join)(dir, "version.txt"), "utf8").trim();
3762
+ if (v) return v;
3763
+ } catch {
3764
+ }
3765
+ }
3766
+ try {
3767
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path3.join)(here, "..", "package.json"), "utf8"));
3768
+ if (pkg.version) return `${pkg.version}+source`;
3769
+ } catch {
3770
+ }
3771
+ return "0.0.0+unknown";
3772
+ }
3773
+
3774
+ // src/api/routes/backup.routes.ts
3775
+ function readBody(c) {
3776
+ return new Promise((resolve2, reject) => {
3777
+ const chunks = [];
3778
+ c.env.incoming.on("data", (chunk) => chunks.push(chunk));
3779
+ c.env.incoming.on("end", () => {
3780
+ try {
3781
+ const raw = Buffer.concat(chunks).toString("utf-8");
3782
+ resolve2(raw ? JSON.parse(raw) : {});
3783
+ } catch {
3784
+ reject(new Error("Invalid JSON body"));
3785
+ }
3786
+ });
3787
+ c.env.incoming.on("error", reject);
3788
+ });
3789
+ }
3790
+ var createBackupRoutes = (deps) => {
3791
+ const app = new import_hono2.Hono();
3792
+ app.get("/export", (c) => {
3793
+ const repo = deps.projectsRepo();
3794
+ if (!repo) {
3795
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3796
+ }
3797
+ const projects = repo.listProjects().map((p) => ({
3798
+ id: p.id,
3799
+ path: p.path,
3800
+ name: p.name ?? null,
3801
+ createdAt: p.createdAt,
3802
+ updatedAt: p.updatedAt
3803
+ }));
3804
+ return c.json({
3805
+ manifest: {
3806
+ formatVersion: BACKUP_FORMAT_VERSION,
3807
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3808
+ streamerVersion: getVersion(),
3809
+ sourceHost: (0, import_os5.hostname)(),
3810
+ // No endpoint here exports the API key. The flag is recorded so an
3811
+ // archive is self-describing about its own sensitivity rather than
3812
+ // requiring a reader to infer it.
3813
+ includesSecrets: false,
3814
+ counts: { projects: projects.length }
3815
+ },
3816
+ projects
3817
+ });
3818
+ });
3819
+ app.post("/restore", async (c) => {
3820
+ const repo = deps.projectsRepo();
3821
+ if (!repo) {
3822
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3823
+ }
3824
+ let body;
3825
+ try {
3826
+ body = await readBody(c);
3827
+ } catch {
3828
+ return c.json({ error: "Invalid JSON body", code: "INVALID_BODY" }, 400);
3829
+ }
3830
+ let archive;
3831
+ try {
3832
+ archive = validateArchive(body.archive);
3833
+ } catch (err) {
3834
+ if (err instanceof BackupError) {
3835
+ return c.json({ error: err.message, code: err.code }, 400);
3836
+ }
3837
+ throw err;
3838
+ }
3839
+ 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 })) : [];
3840
+ const incoming = rules.length > 0 ? remapPaths(archive.projects, rules) : archive.projects;
3841
+ const existing = repo.listProjects().map((p) => ({ id: p.id, path: p.path }));
3842
+ const plan = planRestore(incoming, existing);
3843
+ const summary = {
3844
+ create: plan.create.length,
3845
+ update: plan.update.length,
3846
+ conflict: plan.conflict.length
3847
+ };
3848
+ if (body.apply !== true) {
3849
+ return c.json({ applied: false, summary, plan });
3850
+ }
3851
+ if (plan.conflict.length > 0) {
3852
+ return c.json(
3853
+ {
3854
+ error: "Restore has unresolved conflicts",
3855
+ code: "RESTORE_CONFLICT",
3856
+ summary,
3857
+ plan
3858
+ },
3859
+ 409
3860
+ );
3861
+ }
3862
+ let applied = 0;
3863
+ for (const p of [...plan.create, ...plan.update]) {
3864
+ try {
3865
+ repo.upsertProjectByPath(p.path, { name: p.name });
3866
+ applied++;
3867
+ } catch {
3868
+ }
3869
+ }
3870
+ return c.json({ applied: true, summary, appliedCount: applied });
3871
+ });
3872
+ return app;
3873
+ };
3874
+
3875
+ // src/api/routes/browse.routes.ts
3876
+ var import_hono3 = require("hono");
3649
3877
  var ALREADY_HANDLED = 597;
3650
3878
  var alreadyHandled = () => new Response(null, { status: ALREADY_HANDLED });
3651
3879
  var createBrowseRoutes = (deps) => {
3652
- const app = new import_hono2.Hono();
3880
+ const app = new import_hono3.Hono();
3653
3881
  app.get("/browse", async (c) => {
3654
3882
  const url = new URL(c.req.url);
3655
3883
  await deps.handleBrowse(url, c.env.outgoing);
@@ -3663,7 +3891,7 @@ var createBrowseRoutes = (deps) => {
3663
3891
  };
3664
3892
 
3665
3893
  // src/api/routes/cacheAlert.routes.ts
3666
- var import_hono3 = require("hono");
3894
+ var import_hono4 = require("hono");
3667
3895
 
3668
3896
  // src/schemas/cacheAlert.schema.ts
3669
3897
  var import_zod = require("zod");
@@ -3686,7 +3914,7 @@ function readRawBody2(req) {
3686
3914
  });
3687
3915
  }
3688
3916
  var createCacheAlertRoutes = (deps) => {
3689
- const app = new import_hono3.Hono();
3917
+ const app = new import_hono4.Hono();
3690
3918
  app.get("/", (c) => {
3691
3919
  const monitor = deps.cacheMonitor();
3692
3920
  return c.json({ pending: monitor?.pending ?? null });
@@ -3723,7 +3951,7 @@ var createCacheAlertRoutes = (deps) => {
3723
3951
  };
3724
3952
 
3725
3953
  // src/api/routes/config.routes.ts
3726
- var import_hono4 = require("hono");
3954
+ var import_hono5 = require("hono");
3727
3955
 
3728
3956
  // src/schemas/claudeFlags.schema.ts
3729
3957
  var import_zod2 = require("zod");
@@ -3744,7 +3972,7 @@ function readRawBody3(req) {
3744
3972
  });
3745
3973
  }
3746
3974
  var createConfigRoutes = (deps) => {
3747
- const app = new import_hono4.Hono();
3975
+ const app = new import_hono5.Hono();
3748
3976
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3749
3977
  app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3750
3978
  app.put("/claude-flags", async (c) => {
@@ -3779,11 +4007,11 @@ var createConfigRoutes = (deps) => {
3779
4007
  };
3780
4008
 
3781
4009
  // src/api/routes/conversations.routes.ts
3782
- var import_hono5 = require("hono");
4010
+ var import_hono6 = require("hono");
3783
4011
  var ALREADY_HANDLED2 = 597;
3784
4012
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3785
4013
  var createConversationRoutes = (deps) => {
3786
- const app = new import_hono5.Hono();
4014
+ const app = new import_hono6.Hono();
3787
4015
  app.get("/count", async (c) => {
3788
4016
  const url = new URL(c.req.url);
3789
4017
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3810,9 +4038,9 @@ var createConversationRoutes = (deps) => {
3810
4038
  };
3811
4039
 
3812
4040
  // src/api/routes/devices.routes.ts
3813
- var import_hono6 = require("hono");
4041
+ var import_hono7 = require("hono");
3814
4042
  var createDeviceRoutes = (deps) => {
3815
- const app = new import_hono6.Hono();
4043
+ const app = new import_hono7.Hono();
3816
4044
  app.get("/", (c) => {
3817
4045
  const repo = deps.devicesRepo();
3818
4046
  if (!repo) return c.json({ devices: [], available: false });
@@ -3835,45 +4063,134 @@ var createDeviceRoutes = (deps) => {
3835
4063
  return app;
3836
4064
  };
3837
4065
 
3838
- // src/api/routes/health.routes.ts
3839
- var import_hono7 = require("hono");
4066
+ // src/api/routes/diagnostics.routes.ts
4067
+ var import_fs7 = require("fs");
4068
+ var import_hono8 = require("hono");
3840
4069
 
3841
- // src/version.ts
3842
- var import_node_fs2 = require("fs");
3843
- var import_node_path3 = require("path");
3844
- var cached;
3845
- function getVersion() {
3846
- if (cached !== void 0) return cached;
3847
- cached = resolveVersion();
3848
- return cached;
4070
+ // src/services/diagnostics/diagnostics.ts
4071
+ var DIAGNOSTICS_CONTRACT_VERSION = 1;
4072
+ function redactPath(path) {
4073
+ if (!path) return null;
4074
+ const parts = path.split(/[/\\]/).filter(Boolean);
4075
+ if (parts.length <= 2) return parts.join("/");
4076
+ return `\u2026/${parts.slice(-2).join("/")}`;
4077
+ }
4078
+ function worstStatus(checks) {
4079
+ const rank = { ok: 0, unknown: 1, degraded: 2, failed: 3 };
4080
+ return checks.reduce(
4081
+ (worst, c) => rank[c.status] > rank[worst] ? c.status : worst,
4082
+ "ok"
4083
+ );
3849
4084
  }
3850
- function resolveVersion() {
3851
- const scriptPath = process.argv[1] ?? "";
3852
- const here = scriptPath ? (0, import_node_path3.dirname)(scriptPath) : process.cwd();
3853
- let realHere = here;
3854
- try {
3855
- realHere = (0, import_node_path3.dirname)((0, import_node_fs2.realpathSync)(scriptPath));
3856
- } catch {
4085
+ function buildReport(checks, now = /* @__PURE__ */ new Date()) {
4086
+ return {
4087
+ contractVersion: DIAGNOSTICS_CONTRACT_VERSION,
4088
+ generatedAt: now.toISOString(),
4089
+ overall: worstStatus(checks),
4090
+ checks
4091
+ };
4092
+ }
4093
+ var SECRET_KEY_RE = /(key|token|secret|password|passwd|credential|authorization|cookie)/i;
4094
+ function redactValue(value) {
4095
+ if (Array.isArray(value)) {
4096
+ return value.map((v) => redactValue(v));
3857
4097
  }
3858
- const searchDirs = realHere === here ? [here, (0, import_node_path3.join)(here, "..")] : [here, (0, import_node_path3.join)(here, ".."), realHere, (0, import_node_path3.join)(realHere, "..")];
3859
- for (const dir of searchDirs) {
3860
- try {
3861
- const v = (0, import_node_fs2.readFileSync)((0, import_node_path3.join)(dir, "version.txt"), "utf8").trim();
3862
- if (v) return v;
3863
- } catch {
4098
+ if (value && typeof value === "object") {
4099
+ const out = {};
4100
+ for (const [k, v] of Object.entries(value)) {
4101
+ out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : redactValue(v);
3864
4102
  }
4103
+ return out;
3865
4104
  }
4105
+ return value;
4106
+ }
4107
+
4108
+ // src/api/routes/diagnostics.routes.ts
4109
+ function providerCheck(name, resolve2) {
3866
4110
  try {
3867
- const pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path3.join)(here, "..", "package.json"), "utf8"));
3868
- if (pkg.version) return `${pkg.version}+source`;
4111
+ const exe = resolve2();
4112
+ return {
4113
+ id: `provider:${name}`,
4114
+ status: "ok",
4115
+ summary: `${name} CLI is installed.`,
4116
+ remediation: "NONE",
4117
+ detail: { location: redactPath(exe) }
4118
+ };
3869
4119
  } catch {
4120
+ return {
4121
+ id: `provider:${name}`,
4122
+ status: "failed",
4123
+ summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
4124
+ remediation: "PROVIDER_NOT_INSTALLED"
4125
+ };
3870
4126
  }
3871
- return "0.0.0+unknown";
3872
4127
  }
4128
+ var createDiagnosticsRoutes = (deps) => {
4129
+ const app = new import_hono8.Hono();
4130
+ app.get("/", (c) => {
4131
+ const checks = [];
4132
+ checks.push({
4133
+ id: "streamer",
4134
+ status: "ok",
4135
+ summary: "Streamer is running.",
4136
+ remediation: "NONE",
4137
+ detail: { version: getVersion(), uptimeSeconds: Math.floor(process.uptime()) }
4138
+ });
4139
+ checks.push(providerCheck("claude-code", resolveClaudeExe));
4140
+ checks.push(providerCheck("codex-cli", resolveCodexExe));
4141
+ const cacheAlert = deps.cacheMonitor()?.healthzField();
4142
+ checks.push(
4143
+ cacheAlert ? {
4144
+ id: "cache",
4145
+ status: "degraded",
4146
+ summary: "Conversation cache reported an integrity alert.",
4147
+ remediation: "CACHE_DEGRADED"
4148
+ } : {
4149
+ id: "cache",
4150
+ status: "ok",
4151
+ summary: "Conversation cache is healthy.",
4152
+ remediation: "NONE"
4153
+ }
4154
+ );
4155
+ let ptyOk = true;
4156
+ try {
4157
+ require.resolve("node-pty");
4158
+ } catch {
4159
+ ptyOk = false;
4160
+ }
4161
+ checks.push(
4162
+ ptyOk ? { id: "pty", status: "ok", summary: "PTY subsystem is available.", remediation: "NONE" } : {
4163
+ id: "pty",
4164
+ status: "failed",
4165
+ summary: "node-pty failed to load, so no managed session can start.",
4166
+ remediation: "PTY_UNAVAILABLE"
4167
+ }
4168
+ );
4169
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
4170
+ const claudeProjects = home ? `${home}/.claude/projects` : "";
4171
+ checks.push(
4172
+ claudeProjects && (0, import_fs7.existsSync)(claudeProjects) ? {
4173
+ id: "filesystem",
4174
+ status: "ok",
4175
+ summary: "Provider history directory is present.",
4176
+ remediation: "NONE",
4177
+ detail: { location: redactPath(claudeProjects) }
4178
+ } : {
4179
+ id: "filesystem",
4180
+ status: "degraded",
4181
+ summary: "Provider history directory was not found; history may be unavailable.",
4182
+ remediation: "FS_SCOPE_MISSING"
4183
+ }
4184
+ );
4185
+ return c.json(redactValue(buildReport(checks)));
4186
+ });
4187
+ return app;
4188
+ };
3873
4189
 
3874
4190
  // src/api/routes/health.routes.ts
4191
+ var import_hono9 = require("hono");
3875
4192
  var createHealthRoutes = (deps) => {
3876
- const app = new import_hono7.Hono();
4193
+ const app = new import_hono9.Hono();
3877
4194
  app.get("/", (c) => {
3878
4195
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3879
4196
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3884,7 +4201,7 @@ var createHealthRoutes = (deps) => {
3884
4201
  // src/api/routes/logs.routes.ts
3885
4202
  var import_node_fs3 = require("fs");
3886
4203
  var import_node_path5 = require("path");
3887
- var import_hono8 = require("hono");
4204
+ var import_hono10 = require("hono");
3888
4205
 
3889
4206
  // src/lifecycle/constants.ts
3890
4207
  var import_node_os = require("os");
@@ -3942,7 +4259,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3942
4259
  }
3943
4260
  }
3944
4261
  function createLogsRoutes() {
3945
- const app = new import_hono8.Hono();
4262
+ const app = new import_hono10.Hono();
3946
4263
  app.get("/", (c) => {
3947
4264
  try {
3948
4265
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -4013,8 +4330,8 @@ function createLogsRoutes() {
4013
4330
  // src/api/routes/misc.routes.ts
4014
4331
  var import_node_child_process = require("child_process");
4015
4332
  var import_node_crypto2 = require("crypto");
4016
- var import_hono9 = require("hono");
4017
- var import_os5 = require("os");
4333
+ var import_hono11 = require("hono");
4334
+ var import_os6 = require("os");
4018
4335
 
4019
4336
  // src/config/update-config.ts
4020
4337
  var import_node_fs4 = require("fs");
@@ -4344,12 +4661,12 @@ function verifyWebhookSignature(body, header, secret) {
4344
4661
  }
4345
4662
  var clientLog = getLogger("client");
4346
4663
  var createMiscRoutes = (deps) => {
4347
- const app = new import_hono9.Hono();
4664
+ const app = new import_hono11.Hono();
4348
4665
  app.get("/api/info", (c) => {
4349
4666
  const ptyIds = deps.ptyAttachedIds();
4350
4667
  return c.json({
4351
4668
  version: getVersion(),
4352
- machineName: (0, import_os5.hostname)(),
4669
+ machineName: (0, import_os6.hostname)(),
4353
4670
  platform: process.platform,
4354
4671
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
4355
4672
  publicUrl: deps.publicUrl,
@@ -4475,11 +4792,11 @@ var createMiscRoutes = (deps) => {
4475
4792
  };
4476
4793
 
4477
4794
  // src/api/routes/pair.routes.ts
4478
- var import_hono10 = require("hono");
4795
+ var import_hono12 = require("hono");
4479
4796
  var ALREADY_HANDLED3 = 597;
4480
4797
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
4481
4798
  var createPairRoutes = (deps) => {
4482
- const app = new import_hono10.Hono();
4799
+ const app = new import_hono12.Hono();
4483
4800
  app.post("/start", (c) => {
4484
4801
  deps.handlePairStart(c.env.outgoing);
4485
4802
  return alreadyHandled3();
@@ -4492,11 +4809,11 @@ var createPairRoutes = (deps) => {
4492
4809
  };
4493
4810
 
4494
4811
  // src/api/routes/projects.routes.ts
4495
- var import_hono11 = require("hono");
4812
+ var import_hono13 = require("hono");
4496
4813
  var ALREADY_HANDLED4 = 597;
4497
4814
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
4498
4815
  var createProjectRoutes = (deps) => {
4499
- const app = new import_hono11.Hono();
4816
+ const app = new import_hono13.Hono();
4500
4817
  app.get("/", (c) => {
4501
4818
  const url = new URL(c.req.url);
4502
4819
  deps.handleListProjects(url, c.env.outgoing);
@@ -4511,7 +4828,7 @@ var createProjectRoutes = (deps) => {
4511
4828
  };
4512
4829
 
4513
4830
  // src/api/routes/providers.routes.ts
4514
- var import_hono12 = require("hono");
4831
+ var import_hono14 = require("hono");
4515
4832
 
4516
4833
  // src/services/providers/providerHealth.ts
4517
4834
  var import_child_process3 = require("child_process");
@@ -4634,7 +4951,7 @@ async function providerHealth(name, resolveExe, detect = runVersion) {
4634
4951
 
4635
4952
  // src/api/routes/providers.routes.ts
4636
4953
  var createProviderRoutes = () => {
4637
- const app = new import_hono12.Hono();
4954
+ const app = new import_hono14.Hono();
4638
4955
  app.get("/", async (c) => {
4639
4956
  const providers = await Promise.all([
4640
4957
  providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
@@ -4646,11 +4963,11 @@ var createProviderRoutes = () => {
4646
4963
  };
4647
4964
 
4648
4965
  // src/api/routes/scanner.routes.ts
4649
- var import_hono13 = require("hono");
4966
+ var import_hono15 = require("hono");
4650
4967
  var ALREADY_HANDLED5 = 597;
4651
4968
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
4652
4969
  var createScannerRoutes = (deps) => {
4653
- const app = new import_hono13.Hono();
4970
+ const app = new import_hono15.Hono();
4654
4971
  app.get("/api/search", async (c) => {
4655
4972
  const url = new URL(c.req.url);
4656
4973
  await deps.handleSearch(url, c.env.outgoing);
@@ -4660,11 +4977,11 @@ var createScannerRoutes = (deps) => {
4660
4977
  };
4661
4978
 
4662
4979
  // src/api/routes/sessions.routes.ts
4663
- var import_hono14 = require("hono");
4980
+ var import_hono16 = require("hono");
4664
4981
  var ALREADY_HANDLED6 = 597;
4665
4982
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
4666
4983
  var createSessionRoutes = (deps) => {
4667
- const app = new import_hono14.Hono();
4984
+ const app = new import_hono16.Hono();
4668
4985
  app.get("/count", (c) => {
4669
4986
  deps.handleSessionsCount(c.env.outgoing);
4670
4987
  return alreadyHandled6();
@@ -4739,9 +5056,9 @@ var createSessionRoutes = (deps) => {
4739
5056
  };
4740
5057
 
4741
5058
  // src/api/routes/ws.routes.ts
4742
- var import_hono15 = require("hono");
5059
+ var import_hono17 = require("hono");
4743
5060
  var createWsRoutes = (deps, upgradeWebSocket) => {
4744
- const app = new import_hono15.Hono();
5061
+ const app = new import_hono17.Hono();
4745
5062
  app.get(
4746
5063
  "/ws",
4747
5064
  upgradeWebSocket(() => {
@@ -4767,7 +5084,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
4767
5084
 
4768
5085
  // src/api/app.ts
4769
5086
  var createHonoApp = (deps, upgradeWebSocket) => {
4770
- const app = new import_hono16.Hono();
5087
+ const app = new import_hono18.Hono();
4771
5088
  const httpLog = getLogger("http");
4772
5089
  app.use("*", async (c, next) => {
4773
5090
  const start = Date.now();
@@ -4788,6 +5105,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4788
5105
  app.use("*", authMiddleware(deps));
4789
5106
  app.onError(errorMiddleware);
4790
5107
  app.route("/healthz", createHealthRoutes(deps));
5108
+ app.route("/api/diagnostics", createDiagnosticsRoutes(deps));
4791
5109
  app.route("/", createMiscRoutes(deps));
4792
5110
  app.route("/api/sessions", createSessionRoutes(deps));
4793
5111
  app.route("/api/conversations", createConversationRoutes(deps));
@@ -4796,6 +5114,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4796
5114
  app.route("/api/projects", createProjectRoutes(deps));
4797
5115
  app.route("/api/providers", createProviderRoutes());
4798
5116
  app.route("/api/devices", createDeviceRoutes(deps));
5117
+ app.route("/api/backup", createBackupRoutes(deps));
4799
5118
  app.route("/api/pair", createPairRoutes(deps));
4800
5119
  app.route("/api", createBrowseRoutes(deps));
4801
5120
  app.route("/", createScannerRoutes(deps));
@@ -4861,13 +5180,13 @@ async function createDirectory(parentAbsolutePath, name) {
4861
5180
  // src/conversation-cache.ts
4862
5181
  var import_scanner2 = require("@threadbase-sh/scanner");
4863
5182
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
4864
- var import_fs9 = require("fs");
5183
+ var import_fs10 = require("fs");
4865
5184
  var import_promises3 = require("fs/promises");
4866
5185
  var import_path12 = require("path");
4867
5186
  var import_promises4 = require("timers/promises");
4868
5187
 
4869
5188
  // src/db/sqlite-migrate.ts
4870
- var import_fs7 = require("fs");
5189
+ var import_fs8 = require("fs");
4871
5190
  var import_path10 = require("path");
4872
5191
  var import_url2 = require("url");
4873
5192
  var import_meta2 = {};
@@ -4877,6 +5196,9 @@ function getMigrationsDir2() {
4877
5196
  }
4878
5197
  return __dirname;
4879
5198
  }
5199
+ function resolveMigrationsDir(name = "migrations") {
5200
+ return (0, import_path10.join)(getMigrationsDir2(), name);
5201
+ }
4880
5202
  var SCHEMA_MIGRATIONS_SQL = `
4881
5203
  CREATE TABLE IF NOT EXISTS schema_migrations (
4882
5204
  id TEXT PRIMARY KEY,
@@ -4885,8 +5207,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
4885
5207
  `;
4886
5208
  function runSqliteMigrations(db, migrationsDir) {
4887
5209
  db.exec(SCHEMA_MIGRATIONS_SQL);
4888
- const dir = migrationsDir ?? (0, import_path10.join)(getMigrationsDir2(), "migrations");
4889
- const files = (0, import_fs7.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
5210
+ const dir = migrationsDir ?? resolveMigrationsDir();
5211
+ const files = (0, import_fs8.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
4890
5212
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
4891
5213
  const appliedSet = new Set(appliedRows.map((r) => r.id));
4892
5214
  const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
@@ -4897,7 +5219,7 @@ function runSqliteMigrations(db, migrationsDir) {
4897
5219
  skipped.push(file);
4898
5220
  continue;
4899
5221
  }
4900
- const sql = (0, import_fs7.readFileSync)((0, import_path10.join)(dir, file), "utf-8");
5222
+ const sql = (0, import_fs8.readFileSync)((0, import_path10.join)(dir, file), "utf-8");
4901
5223
  const tx = db.transaction(() => {
4902
5224
  db.exec(sql);
4903
5225
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -4909,7 +5231,7 @@ function runSqliteMigrations(db, migrationsDir) {
4909
5231
  }
4910
5232
 
4911
5233
  // src/services/conversations/isAgentConversation.ts
4912
- var import_fs8 = require("fs");
5234
+ var import_fs9 = require("fs");
4913
5235
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
4914
5236
  var CHUNK_BYTES = 64 * 1024;
4915
5237
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -4931,12 +5253,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
4931
5253
  if (cached2 !== void 0) return cached2;
4932
5254
  let fd;
4933
5255
  try {
4934
- fd = (0, import_fs8.openSync)(filePath, "r");
5256
+ fd = (0, import_fs9.openSync)(filePath, "r");
4935
5257
  } catch {
4936
5258
  return false;
4937
5259
  }
4938
5260
  try {
4939
- const fileSize = (0, import_fs8.statSync)(filePath).size;
5261
+ const fileSize = (0, import_fs9.statSync)(filePath).size;
4940
5262
  if (fileSize === 0) {
4941
5263
  fileDecisionCache.set(key, false);
4942
5264
  return false;
@@ -4947,7 +5269,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
4947
5269
  let carry = "";
4948
5270
  while (offset < fileSize) {
4949
5271
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
4950
- const got = (0, import_fs8.readSync)(fd, buf, 0, toRead, offset);
5272
+ const got = (0, import_fs9.readSync)(fd, buf, 0, toRead, offset);
4951
5273
  if (got <= 0) break;
4952
5274
  const chunk = carry + buf.toString("utf8", 0, got);
4953
5275
  for (const marker of markers) {
@@ -4968,7 +5290,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
4968
5290
  } catch {
4969
5291
  return false;
4970
5292
  } finally {
4971
- (0, import_fs8.closeSync)(fd);
5293
+ (0, import_fs9.closeSync)(fd);
4972
5294
  }
4973
5295
  }
4974
5296
  function parseAgentEntrypointsEnv(raw) {
@@ -5543,7 +5865,7 @@ var ConversationCache = class _ConversationCache {
5543
5865
  if (!fileState) return null;
5544
5866
  let stat3;
5545
5867
  try {
5546
- stat3 = (0, import_fs9.statSync)(filePath);
5868
+ stat3 = (0, import_fs10.statSync)(filePath);
5547
5869
  } catch {
5548
5870
  return null;
5549
5871
  }
@@ -5564,17 +5886,17 @@ var ConversationCache = class _ConversationCache {
5564
5886
  );
5565
5887
  if (rows.length === 0) return { messages: [], total, fromIndex: from };
5566
5888
  const messages = [];
5567
- const fd = (0, import_fs9.openSync)(filePath, "r");
5889
+ const fd = (0, import_fs10.openSync)(filePath, "r");
5568
5890
  try {
5569
5891
  const state = (0, import_scanner2.createJsonlParseState)();
5570
5892
  for (const row of rows) {
5571
5893
  const buf = Buffer.alloc(row.byte_length);
5572
- (0, import_fs9.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
5894
+ (0, import_fs10.readSync)(fd, buf, 0, row.byte_length, row.byte_offset);
5573
5895
  const msg = (0, import_scanner2.parseJsonlLine)(buf.toString("utf-8"), state);
5574
5896
  if (msg) messages.push(msg);
5575
5897
  }
5576
5898
  } finally {
5577
- (0, import_fs9.closeSync)(fd);
5899
+ (0, import_fs10.closeSync)(fd);
5578
5900
  }
5579
5901
  return { messages, total, fromIndex: from };
5580
5902
  }
@@ -5602,14 +5924,14 @@ var ConversationCache = class _ConversationCache {
5602
5924
  isAgentFileCached(filePath) {
5603
5925
  let s;
5604
5926
  try {
5605
- s = (0, import_fs9.statSync)(filePath);
5927
+ s = (0, import_fs10.statSync)(filePath);
5606
5928
  } catch {
5607
5929
  return false;
5608
5930
  }
5609
5931
  return this.classifyAgentFile(filePath, s.mtimeMs, s.size);
5610
5932
  }
5611
5933
  static open(dbPath, tailSize = 10, migrationsDir, options) {
5612
- (0, import_fs9.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
5934
+ (0, import_fs10.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
5613
5935
  const db = new import_better_sqlite3.default(dbPath);
5614
5936
  db.pragma("journal_mode = WAL");
5615
5937
  db.pragma("foreign_keys = ON");
@@ -5796,7 +6118,7 @@ var ConversationCache = class _ConversationCache {
5796
6118
  let mtimeMs = null;
5797
6119
  let fileSize = null;
5798
6120
  try {
5799
- const s = (0, import_fs9.statSync)(m.filePath);
6121
+ const s = (0, import_fs10.statSync)(m.filePath);
5800
6122
  mtimeMs = s.mtimeMs;
5801
6123
  fileSize = s.size;
5802
6124
  } catch {
@@ -5856,8 +6178,8 @@ var ConversationCache = class _ConversationCache {
5856
6178
  let fileSize;
5857
6179
  let fd;
5858
6180
  try {
5859
- fileSize = (0, import_fs9.statSync)(filePath).size;
5860
- fd = (0, import_fs9.openSync)(filePath, "r");
6181
+ fileSize = (0, import_fs10.statSync)(filePath).size;
6182
+ fd = (0, import_fs10.openSync)(filePath, "r");
5861
6183
  } catch {
5862
6184
  return false;
5863
6185
  }
@@ -5870,7 +6192,7 @@ var ConversationCache = class _ConversationCache {
5870
6192
  while (pos > 0 && lines.length < this.tailSize * 4) {
5871
6193
  const toRead = Math.min(CHUNK, pos);
5872
6194
  pos -= toRead;
5873
- (0, import_fs9.readSync)(fd, buf, 0, toRead, pos);
6195
+ (0, import_fs10.readSync)(fd, buf, 0, toRead, pos);
5874
6196
  const chunk = buf.subarray(0, toRead).toString("utf8");
5875
6197
  const combined = chunk + partial;
5876
6198
  const parts = combined.split("\n");
@@ -5881,7 +6203,7 @@ var ConversationCache = class _ConversationCache {
5881
6203
  }
5882
6204
  if (partial) lines.push(partial);
5883
6205
  } finally {
5884
- (0, import_fs9.closeSync)(fd);
6206
+ (0, import_fs10.closeSync)(fd);
5885
6207
  }
5886
6208
  const msgs = [];
5887
6209
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -6094,7 +6416,7 @@ var ConversationCache = class _ConversationCache {
6094
6416
  * `handleGetConversation` can still serve the cached tail even when the
6095
6417
  * JSONL has been deleted.
6096
6418
  */
6097
- pruneGhostFiles(exists = import_fs9.existsSync) {
6419
+ pruneGhostFiles(exists = import_fs10.existsSync) {
6098
6420
  const rows = this.stmts.allFilePaths.all();
6099
6421
  const ghosts = [];
6100
6422
  const prune = this.db.transaction((ids) => {
@@ -6149,7 +6471,7 @@ var ConversationCache = class _ConversationCache {
6149
6471
  * Returns the removed IDs.
6150
6472
  */
6151
6473
  reconcileDeletions(livePaths, opts) {
6152
- const exists = opts?.exists ?? import_fs9.existsSync;
6474
+ const exists = opts?.exists ?? import_fs10.existsSync;
6153
6475
  const rows = this.stmts.allFilePaths.all();
6154
6476
  const removed = [];
6155
6477
  const drop = this.db.transaction((ids) => {
@@ -6184,7 +6506,7 @@ var ConversationCache = class _ConversationCache {
6184
6506
  * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
6185
6507
  * rows that still have cached history (which pruneGhostFiles would keep).
6186
6508
  */
6187
- listMissingFiles(exists = import_fs9.existsSync) {
6509
+ listMissingFiles(exists = import_fs10.existsSync) {
6188
6510
  const rows = this.stmts.allFilePathsWithTitle.all();
6189
6511
  const missing = [];
6190
6512
  for (const row of rows) {
@@ -6296,6 +6618,7 @@ var ManagedSessionsRepository = class {
6296
6618
  updateStatusStmt;
6297
6619
  getStmt;
6298
6620
  listNonTerminalStmt;
6621
+ listRecoverableStmt;
6299
6622
  deleteStmt;
6300
6623
  constructor(db) {
6301
6624
  this.upsertStmt = db.prepare(`
@@ -6348,6 +6671,13 @@ var ManagedSessionsRepository = class {
6348
6671
  WHERE completed_at IS NULL
6349
6672
  ORDER BY started_at ASC
6350
6673
  `);
6674
+ this.listRecoverableStmt = db.prepare(`
6675
+ SELECT * FROM managed_sessions
6676
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
6677
+ AND status_updated_at >= @since
6678
+ ORDER BY status_updated_at DESC
6679
+ LIMIT @limit
6680
+ `);
6351
6681
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6352
6682
  }
6353
6683
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6399,6 +6729,14 @@ var ManagedSessionsRepository = class {
6399
6729
  listNonTerminal() {
6400
6730
  return this.listNonTerminalStmt.all();
6401
6731
  }
6732
+ /**
6733
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
6734
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
6735
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
6736
+ */
6737
+ listRecoverable({ sinceMs, limit }) {
6738
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
6739
+ }
6402
6740
  delete(sessionId) {
6403
6741
  this.deleteStmt.run(sessionId);
6404
6742
  }
@@ -6534,6 +6872,54 @@ var SessionsRepository = class {
6534
6872
  }
6535
6873
  };
6536
6874
 
6875
+ // src/db/runtime-store.ts
6876
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
6877
+ var RuntimeStore = class _RuntimeStore {
6878
+ constructor(db) {
6879
+ this.db = db;
6880
+ }
6881
+ db;
6882
+ static open(dbPath, migrationsDir) {
6883
+ const db = new import_better_sqlite32.default(dbPath);
6884
+ db.pragma("journal_mode = WAL");
6885
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
6886
+ return new _RuntimeStore(db);
6887
+ }
6888
+ getDatabase() {
6889
+ return this.db;
6890
+ }
6891
+ /**
6892
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
6893
+ *
6894
+ * Non-destructive by design: the source table is left in place so an older
6895
+ * streamer rolled back onto the same machine still finds its registry. Runs
6896
+ * only when this file's table is empty, so a second boot is a no-op rather
6897
+ * than a re-copy that would resurrect rows deleted since.
6898
+ *
6899
+ * Returns the number of rows copied.
6900
+ */
6901
+ importLegacyManagedSessions(source) {
6902
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
6903
+ if (existing.n > 0) return 0;
6904
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
6905
+ if (!hasTable) return 0;
6906
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
6907
+ if (rows.length === 0) return 0;
6908
+ const columns = Object.keys(rows[0]);
6909
+ const insert = this.db.prepare(
6910
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
6911
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
6912
+ );
6913
+ this.db.transaction((batch) => {
6914
+ for (const row of batch) insert.run(row);
6915
+ })(rows);
6916
+ return rows.length;
6917
+ }
6918
+ close() {
6919
+ this.db.close();
6920
+ }
6921
+ };
6922
+
6537
6923
  // src/db/upload-records.ts
6538
6924
  async function recordUpload(pool2, instanceId, row) {
6539
6925
  if (!pool2) return;
@@ -6554,8 +6940,8 @@ async function recordUpload(pool2, instanceId, row) {
6554
6940
  }
6555
6941
 
6556
6942
  // src/handlers/handleListProjects.ts
6557
- var import_fs10 = require("fs");
6558
- var import_os6 = require("os");
6943
+ var import_fs11 = require("fs");
6944
+ var import_os7 = require("os");
6559
6945
  var import_path13 = require("path");
6560
6946
  function decodeProjectPath(dirName) {
6561
6947
  return dirName.replace(/-/g, "/");
@@ -6563,14 +6949,14 @@ function decodeProjectPath(dirName) {
6563
6949
  function handleListProjects(url, res) {
6564
6950
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
6565
6951
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
6566
- const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
6952
+ const projectsDir = (0, import_path13.join)((0, import_os7.homedir)(), ".claude", "projects");
6567
6953
  let entries;
6568
6954
  try {
6569
- entries = (0, import_fs10.readdirSync)(projectsDir).map((dirName) => {
6955
+ entries = (0, import_fs11.readdirSync)(projectsDir).map((dirName) => {
6570
6956
  const fullPath = (0, import_path13.join)(projectsDir, dirName);
6571
6957
  let mtime = 0;
6572
6958
  try {
6573
- mtime = (0, import_fs10.statSync)(fullPath).mtimeMs;
6959
+ mtime = (0, import_fs11.statSync)(fullPath).mtimeMs;
6574
6960
  } catch {
6575
6961
  }
6576
6962
  const path = decodeProjectPath(dirName);
@@ -6685,19 +7071,19 @@ function setCacheMetadata(repo, key, value) {
6685
7071
 
6686
7072
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
6687
7073
  var import_crypto9 = require("crypto");
6688
- var import_fs13 = require("fs");
7074
+ var import_fs14 = require("fs");
6689
7075
 
6690
7076
  // src/services/cache-integrity/alertStore.ts
6691
- var import_fs11 = require("fs");
6692
- var import_os7 = require("os");
7077
+ var import_fs12 = require("fs");
7078
+ var import_os8 = require("os");
6693
7079
  var import_path14 = require("path");
6694
7080
  function alertStatePath() {
6695
- const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path14.join)((0, import_os7.homedir)(), ".threadbase");
7081
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_path14.join)((0, import_os8.homedir)(), ".threadbase");
6696
7082
  return (0, import_path14.join)(dir, "cache-alert.json");
6697
7083
  }
6698
7084
  function loadAlertState() {
6699
7085
  try {
6700
- const parsed = JSON.parse((0, import_fs11.readFileSync)(alertStatePath(), "utf-8"));
7086
+ const parsed = JSON.parse((0, import_fs12.readFileSync)(alertStatePath(), "utf-8"));
6701
7087
  return parsed && typeof parsed === "object" ? parsed : {};
6702
7088
  } catch {
6703
7089
  return {};
@@ -6705,13 +7091,13 @@ function loadAlertState() {
6705
7091
  }
6706
7092
  function saveAlertState(state) {
6707
7093
  const path = alertStatePath();
6708
- (0, import_fs11.mkdirSync)((0, import_path14.dirname)(path), { recursive: true });
6709
- (0, import_fs11.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
7094
+ (0, import_fs12.mkdirSync)((0, import_path14.dirname)(path), { recursive: true });
7095
+ (0, import_fs12.writeFileSync)(path, `${JSON.stringify(state, null, 2)}
6710
7096
  `);
6711
7097
  }
6712
7098
 
6713
7099
  // src/services/cache-integrity/backup.ts
6714
- var import_fs12 = require("fs");
7100
+ var import_fs13 = require("fs");
6715
7101
  var import_path15 = require("path");
6716
7102
  var DEFAULT_RETAIN = 3;
6717
7103
  function retainCount() {
@@ -6724,16 +7110,16 @@ function timestamp(d) {
6724
7110
  }
6725
7111
  async function backupCacheDb(db, cacheDir) {
6726
7112
  const backupsDir = (0, import_path15.join)(cacheDir, "backups");
6727
- (0, import_fs12.mkdirSync)(backupsDir, { recursive: true });
7113
+ (0, import_fs13.mkdirSync)(backupsDir, { recursive: true });
6728
7114
  const destPath = (0, import_path15.join)(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
6729
7115
  await db.backup(destPath);
6730
7116
  const retain = retainCount();
6731
- const backups = (0, import_fs12.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
7117
+ const backups = (0, import_fs13.readdirSync)(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
6732
7118
  const full = (0, import_path15.join)(backupsDir, f);
6733
- return { full, mtime: (0, import_fs12.statSync)(full).mtimeMs };
7119
+ return { full, mtime: (0, import_fs13.statSync)(full).mtimeMs };
6734
7120
  }).sort((a, b) => b.mtime - a.mtime);
6735
7121
  for (const stale of backups.slice(retain)) {
6736
- if ((0, import_fs12.existsSync)(stale.full)) (0, import_fs12.unlinkSync)(stale.full);
7122
+ if ((0, import_fs13.existsSync)(stale.full)) (0, import_fs13.unlinkSync)(stale.full);
6737
7123
  }
6738
7124
  return destPath;
6739
7125
  }
@@ -6830,7 +7216,7 @@ var CacheIntegrityMonitor = class {
6830
7216
  * the pending record, back up on high severity, and broadcast the alert.
6831
7217
  */
6832
7218
  async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
6833
- const all = this.cache.listMissingFiles(import_fs13.existsSync);
7219
+ const all = this.cache.listMissingFiles(import_fs14.existsSync);
6834
7220
  const missing = all.filter((m) => !this.ignoredIds.has(m.id));
6835
7221
  if (missing.length === 0) {
6836
7222
  if (this._pending) {
@@ -6934,7 +7320,7 @@ var CacheIntegrityMonitor = class {
6934
7320
  case "prune_all": {
6935
7321
  await this.ensureBackup(pending);
6936
7322
  const backupPath = pending.backupPath;
6937
- const stillMissing = pending.missing.filter((m) => !(0, import_fs13.existsSync)(m.filePath)).map((m) => m.id);
7323
+ const stillMissing = pending.missing.filter((m) => !(0, import_fs14.existsSync)(m.filePath)).map((m) => m.id);
6938
7324
  const pruned = this.cache.dropRowsById(stillMissing);
6939
7325
  this.applyDeferredUnlinks();
6940
7326
  this.clearPending();
@@ -6996,7 +7382,7 @@ var CacheIntegrityMonitor = class {
6996
7382
 
6997
7383
  // src/services/conversations/conversationWatcher.ts
6998
7384
  var import_chokidar = __toESM(require("chokidar"), 1);
6999
- var import_fs14 = require("fs");
7385
+ var import_fs15 = require("fs");
7000
7386
  var import_promises5 = require("fs/promises");
7001
7387
  var ConversationWatcher = class {
7002
7388
  files = /* @__PURE__ */ new Map();
@@ -7022,7 +7408,7 @@ var ConversationWatcher = class {
7022
7408
  if (this.files.has(key)) return;
7023
7409
  let offset;
7024
7410
  try {
7025
- offset = (0, import_fs14.statSync)(filePath).size;
7411
+ offset = (0, import_fs15.statSync)(filePath).size;
7026
7412
  } catch {
7027
7413
  offset = 0;
7028
7414
  }
@@ -7204,14 +7590,14 @@ function findSearchTarget(messages, query) {
7204
7590
  }
7205
7591
 
7206
7592
  // src/services/conversations/pruneAgentConversations.ts
7207
- var import_fs15 = require("fs");
7593
+ var import_fs16 = require("fs");
7208
7594
  function pruneAgentConversations(cache) {
7209
7595
  const db = cache.getDatabase();
7210
7596
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
7211
7597
  let pruned = 0;
7212
7598
  let missing = 0;
7213
7599
  for (const row of rows) {
7214
- if (!(0, import_fs15.existsSync)(row.file_path)) {
7600
+ if (!(0, import_fs16.existsSync)(row.file_path)) {
7215
7601
  missing += 1;
7216
7602
  continue;
7217
7603
  }
@@ -7312,22 +7698,22 @@ function refreshConversationCache(deps) {
7312
7698
  }
7313
7699
 
7314
7700
  // src/services/conversations/shouldRefreshProjectsFromHdd.ts
7315
- var import_fs16 = require("fs");
7316
- var import_os8 = require("os");
7701
+ var import_fs17 = require("fs");
7702
+ var import_os9 = require("os");
7317
7703
  var import_path16 = require("path");
7318
- var DEFAULT_PROJECTS_DIR = (0, import_path16.join)((0, import_os8.homedir)(), ".claude", "projects");
7704
+ var DEFAULT_PROJECTS_DIR = (0, import_path16.join)((0, import_os9.homedir)(), ".claude", "projects");
7319
7705
  function maxProjectsTreeMtimeMs(projectsDir) {
7320
7706
  let maxMs;
7321
7707
  try {
7322
- maxMs = (0, import_fs16.statSync)(projectsDir).mtimeMs;
7708
+ maxMs = (0, import_fs17.statSync)(projectsDir).mtimeMs;
7323
7709
  } catch {
7324
7710
  return null;
7325
7711
  }
7326
7712
  try {
7327
- for (const ent of (0, import_fs16.readdirSync)(projectsDir, { withFileTypes: true })) {
7713
+ for (const ent of (0, import_fs17.readdirSync)(projectsDir, { withFileTypes: true })) {
7328
7714
  if (!ent.isDirectory()) continue;
7329
7715
  try {
7330
- const childMs = (0, import_fs16.statSync)((0, import_path16.join)(projectsDir, ent.name)).mtimeMs;
7716
+ const childMs = (0, import_fs17.statSync)((0, import_path16.join)(projectsDir, ent.name)).mtimeMs;
7331
7717
  if (childMs > maxMs) maxMs = childMs;
7332
7718
  } catch {
7333
7719
  }
@@ -7551,7 +7937,8 @@ function contentStateForSession(args) {
7551
7937
  status,
7552
7938
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7553
7939
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7554
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7940
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7941
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7555
7942
  };
7556
7943
  }
7557
7944
  var LiveActivityNotifier = class {
@@ -7564,14 +7951,17 @@ var LiveActivityNotifier = class {
7564
7951
  serverId;
7565
7952
  serverLabel;
7566
7953
  /**
7567
- * Last status pushed per session.
7954
+ * Sessions with a currently open (pushed) activity.
7568
7955
  *
7569
- * Live Activity pushes are rate-limited by iOS and the surface only renders
7570
- * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7571
- * budget spend for no visible change. This is what makes the notifier
7572
- * edge-triggered rather than level-triggered.
7956
+ * An activity opens on a `waiting_input running` edge (the user sent a
7957
+ * prompt) and closes on the matching `running waiting_input` edge (the
7958
+ * response, including any sub-agents, finished) so this set is what makes
7959
+ * the notifier per-turn rather than per-session. A session's very first
7960
+ * `running` (right after spawn, before any user prompt) has no prior
7961
+ * `waiting_input` and therefore no edge, so it never opens an activity —
7962
+ * this is what keeps a fresh/idle session from pushing anything.
7573
7963
  */
7574
- lastPushed = /* @__PURE__ */ new Map();
7964
+ openActivity = /* @__PURE__ */ new Map();
7575
7965
  /**
7576
7966
  * React to a session status change.
7577
7967
  *
@@ -7579,34 +7969,22 @@ var LiveActivityNotifier = class {
7579
7969
  * transition, so this returns a promise the caller may ignore and every error
7580
7970
  * is logged rather than propagated.
7581
7971
  */
7582
- async onStatusChange(session) {
7972
+ async onStatusChange(session, previousStatus) {
7583
7973
  const status = toLiveActivityStatus(session.status);
7584
7974
  try {
7585
7975
  if (!status) {
7586
- await this.endFor(session);
7976
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7587
7977
  return;
7588
7978
  }
7589
- if (this.lastPushed.get(session.id) === status) return;
7590
- const contentState = contentStateForSession({
7591
- session,
7592
- serverId: this.serverId,
7593
- serverLabel: this.serverLabel
7594
- });
7595
- if (!contentState) return;
7596
- const outcome = await this.sender.send({
7597
- sessionId: session.id,
7598
- event: "update",
7599
- contentState
7600
- });
7601
- this.lastPushed.set(session.id, status);
7602
- if (outcome.attempted > 0) {
7603
- log4.info("live_activity.updated", {
7604
- event: "live_activity.updated",
7605
- sessionId: session.id,
7606
- status,
7607
- ...outcome
7608
- });
7979
+ if (status === "running" && previousStatus === "waiting_input") {
7980
+ await this.startTurn(session);
7981
+ return;
7982
+ }
7983
+ if (status === "waiting_input" && previousStatus === "running") {
7984
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7985
+ return;
7609
7986
  }
7987
+ await this.maybeSendName(session);
7610
7988
  } catch (err) {
7611
7989
  log4.error("live_activity.notify_failed", {
7612
7990
  event: "live_activity.notify_failed",
@@ -7616,14 +7994,57 @@ var LiveActivityNotifier = class {
7616
7994
  });
7617
7995
  }
7618
7996
  }
7997
+ async startTurn(session) {
7998
+ const contentState = contentStateForSession({
7999
+ session,
8000
+ serverId: this.serverId,
8001
+ serverLabel: this.serverLabel
8002
+ });
8003
+ if (!contentState) return;
8004
+ const outcome = await this.sender.send({
8005
+ sessionId: session.id,
8006
+ event: "update",
8007
+ contentState
8008
+ });
8009
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
8010
+ if (outcome.attempted > 0) {
8011
+ log4.info("live_activity.updated", {
8012
+ event: "live_activity.updated",
8013
+ sessionId: session.id,
8014
+ status: contentState.status,
8015
+ ...outcome
8016
+ });
8017
+ }
8018
+ }
8019
+ async maybeSendName(session) {
8020
+ const open2 = this.openActivity.get(session.id);
8021
+ if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
8022
+ const contentState = contentStateForSession({
8023
+ session,
8024
+ serverId: this.serverId,
8025
+ serverLabel: this.serverLabel
8026
+ });
8027
+ if (!contentState) return;
8028
+ const outcome = await this.sender.send({
8029
+ sessionId: session.id,
8030
+ event: "update",
8031
+ contentState
8032
+ });
8033
+ open2.sessionNameSent = true;
8034
+ if (outcome.attempted > 0) {
8035
+ log4.info("live_activity.updated", {
8036
+ event: "live_activity.updated",
8037
+ sessionId: session.id,
8038
+ status: contentState.status,
8039
+ ...outcome
8040
+ });
8041
+ }
8042
+ }
7619
8043
  async endFor(session) {
7620
- const lastStatus = this.lastPushed.get(session.id);
7621
- this.lastPushed.delete(session.id);
8044
+ this.openActivity.delete(session.id);
8045
+ const status = toLiveActivityStatus(session.status);
7622
8046
  const contentState = contentStateForSession({
7623
- session: {
7624
- ...session,
7625
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7626
- },
8047
+ session: { ...session, status: status ?? "waiting_input" },
7627
8048
  serverId: this.serverId,
7628
8049
  serverLabel: this.serverLabel
7629
8050
  });
@@ -7637,9 +8058,9 @@ var LiveActivityNotifier = class {
7637
8058
  });
7638
8059
  }
7639
8060
  }
7640
- /** Drop cached state for a session, so a resume re-pushes its first status. */
8061
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
7641
8062
  forget(sessionId) {
7642
- this.lastPushed.delete(sessionId);
8063
+ this.openActivity.delete(sessionId);
7643
8064
  }
7644
8065
  };
7645
8066
 
@@ -7870,7 +8291,8 @@ var LiveActivityRenewalScheduler = class {
7870
8291
  status,
7871
8292
  startedAt,
7872
8293
  lastOutput: session.lastOutput ?? "",
7873
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8294
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8295
+ ...session.sessionName != null && { sessionName: session.sessionName }
7874
8296
  };
7875
8297
  try {
7876
8298
  await this.deps.sender.send({
@@ -7930,7 +8352,8 @@ var LiveActivityRenewalScheduler = class {
7930
8352
  // Carried through unchanged — the whole point of the renewal.
7931
8353
  startedAt: args.startedAt,
7932
8354
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
7933
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8355
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8356
+ ...session.sessionName != null && { sessionName: session.sessionName }
7934
8357
  },
7935
8358
  now: args.now,
7936
8359
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8088,8 +8511,90 @@ function resolveAnswer(pending, body) {
8088
8511
  }
8089
8512
  }
8090
8513
 
8514
+ // src/services/search/searchQuery.ts
8515
+ var DEFAULT_SEARCH_LIMIT = 50;
8516
+ var MAX_SEARCH_LIMIT = 200;
8517
+ var MAX_QUERY_LENGTH = 256;
8518
+ var SearchQueryError = class extends Error {
8519
+ constructor(message, code) {
8520
+ super(message);
8521
+ this.code = code;
8522
+ }
8523
+ code;
8524
+ };
8525
+ function intOr(raw, fallback) {
8526
+ if (raw === null) return fallback;
8527
+ const n = Number.parseInt(raw, 10);
8528
+ return Number.isFinite(n) ? n : fallback;
8529
+ }
8530
+ function parseSearchQuery(params) {
8531
+ const q = (params.get("q") ?? "").trim();
8532
+ if (!q) {
8533
+ throw new SearchQueryError("Missing query parameter: q", "invalid_query");
8534
+ }
8535
+ if (q.length > MAX_QUERY_LENGTH) {
8536
+ throw new SearchQueryError(`Query exceeds ${MAX_QUERY_LENGTH} characters`, "query_too_long");
8537
+ }
8538
+ const limit = Math.min(
8539
+ Math.max(intOr(params.get("limit"), DEFAULT_SEARCH_LIMIT), 1),
8540
+ MAX_SEARCH_LIMIT
8541
+ );
8542
+ const offset = Math.max(intOr(params.get("offset"), 0), 0);
8543
+ const filters = {};
8544
+ const provider = params.get("provider");
8545
+ if (provider !== null) {
8546
+ if (!isProviderName(provider)) {
8547
+ throw new SearchQueryError(`Unknown provider: ${provider}`, "invalid_filter");
8548
+ }
8549
+ filters.provider = provider;
8550
+ }
8551
+ const projectPath = params.get("projectPath");
8552
+ if (projectPath) filters.projectPath = projectPath;
8553
+ const branch = params.get("branch");
8554
+ if (branch) filters.branch = branch;
8555
+ for (const [key, field] of [
8556
+ ["since", "since"],
8557
+ ["until", "until"]
8558
+ ]) {
8559
+ const raw = params.get(key);
8560
+ if (raw === null) continue;
8561
+ const ms = Date.parse(raw);
8562
+ if (Number.isNaN(ms)) {
8563
+ throw new SearchQueryError(`Invalid ${key}: expected an ISO 8601 date`, "invalid_filter");
8564
+ }
8565
+ filters[field] = ms;
8566
+ }
8567
+ if (filters.since != null && filters.until != null && filters.since > filters.until) {
8568
+ throw new SearchQueryError("`since` must not be after `until`", "invalid_filter");
8569
+ }
8570
+ return { q, limit, offset, filters };
8571
+ }
8572
+ function applyFilters(results, filters) {
8573
+ return results.filter((r) => {
8574
+ if (filters.provider && r.provider !== filters.provider) return false;
8575
+ if (filters.projectPath && r.projectPath !== filters.projectPath) return false;
8576
+ if (filters.branch && r.branch !== filters.branch) return false;
8577
+ if (filters.since != null || filters.until != null) {
8578
+ const ts = r.lastActivity == null ? Number.NaN : new Date(r.lastActivity).getTime();
8579
+ if (Number.isNaN(ts)) return false;
8580
+ if (filters.since != null && ts < filters.since) return false;
8581
+ if (filters.until != null && ts > filters.until) return false;
8582
+ }
8583
+ return true;
8584
+ });
8585
+ }
8586
+ function paginate(results, offset, limit) {
8587
+ const items = results.slice(offset, offset + limit);
8588
+ return {
8589
+ items,
8590
+ total: results.length,
8591
+ offset,
8592
+ hasMore: offset + items.length < results.length
8593
+ };
8594
+ }
8595
+
8091
8596
  // src/services/sessions/conversationBusy.ts
8092
- var import_fs17 = require("fs");
8597
+ var import_fs18 = require("fs");
8093
8598
  var RESUME_BUSY_WINDOW_MS = 12e4;
8094
8599
  function resolveResumeBusyWindowMs(env = process.env) {
8095
8600
  const raw = env.THREADBASE_RESUME_BUSY_WINDOW_MS;
@@ -8106,7 +8611,7 @@ function conversationBusy(input) {
8106
8611
  let lastActivityMs = null;
8107
8612
  if (input.jsonlPath) {
8108
8613
  try {
8109
- const mtimeMs = (0, import_fs17.statSync)(input.jsonlPath).mtimeMs;
8614
+ const mtimeMs = (0, import_fs18.statSync)(input.jsonlPath).mtimeMs;
8110
8615
  const age = now - mtimeMs;
8111
8616
  lastActivityMs = Math.max(0, age);
8112
8617
  const isSelfEcho = input.selfPtyEndedAt != null && mtimeMs <= input.selfPtyEndedAt + SELF_ACTIVITY_SKEW_MS;
@@ -8235,6 +8740,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8235
8740
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8236
8741
  }
8237
8742
 
8743
+ // src/services/sessions/rehydrateSessions.ts
8744
+ var REHYDRATE_MAX = 25;
8745
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8746
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8747
+ function shouldRehydrate(row, opts) {
8748
+ if (!opts.projectExists(row.project_path)) return false;
8749
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8750
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8751
+ return true;
8752
+ }
8753
+ function rowToStubSession(row) {
8754
+ return {
8755
+ id: row.session_id,
8756
+ provider: row.provider,
8757
+ projectPath: row.project_path,
8758
+ projectName: row.project_name,
8759
+ branch: row.branch,
8760
+ // No PTY exists for a stub, so this is the only truthful status.
8761
+ status: "idle",
8762
+ startedAt: new Date(row.started_at),
8763
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8764
+ promptCount: row.prompt_count,
8765
+ lastOutput: "",
8766
+ rehydrated: true,
8767
+ ...row.session_name != null && { sessionName: row.session_name },
8768
+ ...row.project_id != null && { projectId: row.project_id },
8769
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8770
+ ...row.resumed_from_conversation_id != null && {
8771
+ resumedFromConversationId: row.resumed_from_conversation_id
8772
+ },
8773
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
8774
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8775
+ // Only `shutdown` crosses over. It is the one registry source that is also a
8776
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
8777
+ // streamer stopped this session. A crashed row still says `transition` over
8778
+ // a `running` status, and copying that here would attach observed-confidence
8779
+ // provenance to a status we derived at boot, so leave it unset instead.
8780
+ ...row.status_source === "shutdown" && {
8781
+ statusSource: "shutdown",
8782
+ statusUpdatedAt: new Date(row.status_updated_at)
8783
+ }
8784
+ };
8785
+ }
8786
+
8238
8787
  // src/types.ts
8239
8788
  function confidenceForSource(source) {
8240
8789
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8382,14 +8931,15 @@ function managedToResponse(s, ptyAttached) {
8382
8931
  // Lifecycle for a session this run knows about. `attached` while we hold
8383
8932
  // its PTY; once the PTY is gone the session is terminal from this run's
8384
8933
  // perspective — `failed` when it recorded a reason, else `completed`.
8385
- // Sessions left by *previous* runs never reach here: they aren't in the
8386
- // in-memory store, and the boot reconciler classifies them instead
8387
- // (docs/architecture/2026-07-24-durable-session-runtime.md).
8388
- lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
8389
- lifecycleSource: ptyAttached ? "spawn" : "exit",
8934
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
8935
+ // the durable registry, so it is a previous run's session with no process
8936
+ // behind it — `resumable`, and `historical` rather than `managed`
8937
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
8938
+ lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
8939
+ lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
8390
8940
  // We spawned it, so `status` is the authoritative signal — no inferred
8391
8941
  // `activity` is attached for managed sessions.
8392
- ownership: "managed",
8942
+ ownership: s.rehydrated ? "historical" : "managed",
8393
8943
  projectPath: s.projectPath,
8394
8944
  projectName: s.projectName,
8395
8945
  branch: s.branch,
@@ -8790,6 +9340,8 @@ var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
8790
9340
  var GRACE_MAX_DEFERS = 4;
8791
9341
  var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
8792
9342
  var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
9343
+ var SEARCH_OVERFETCH = 4;
9344
+ var SEARCH_MAX_SCAN = 1e3;
8793
9345
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
8794
9346
  var DISCOVERY_TTL_MS = 15e3;
8795
9347
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -8977,10 +9529,13 @@ var StreamerServer = class {
8977
9529
  projectsRepo = null;
8978
9530
  conversationsRepo = null;
8979
9531
  sessionsRepo = null;
8980
- // Durable session registry (C1 Phase 2). Null when the cache DB failed to
8981
- // open — persistence degrades to today's in-memory-only behaviour rather than
8982
- // taking the server down with it, so every write goes through `?.`.
9532
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
9533
+ // — persistence degrades to today's in-memory-only behaviour rather than
9534
+ // taking the server down with it, so every write goes through `?.`. Note the
9535
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
9536
+ // null this repo and silently disable all session persistence.
8983
9537
  managedSessionsRepo = null;
9538
+ runtimeStore = null;
8984
9539
  // Identifies this streamer run. A registry row carrying a different id is a
8985
9540
  // session that outlived the process that started it.
8986
9541
  streamerInstanceId = (0, import_crypto11.randomUUID)();
@@ -8999,6 +9554,7 @@ var StreamerServer = class {
8999
9554
  liveActivityRenewal = null;
9000
9555
  discoveryCache = null;
9001
9556
  cacheDir;
9557
+ runtimeDbPath;
9002
9558
  tailSize;
9003
9559
  directoryDebounceMs;
9004
9560
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9030,7 +9586,7 @@ var StreamerServer = class {
9030
9586
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
9031
9587
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
9032
9588
  this.scanProfiles = config.scanProfiles;
9033
- this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
9589
+ this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os10.homedir)(), ".codex", "sessions")];
9034
9590
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
9035
9591
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
9036
9592
  this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
@@ -9044,7 +9600,8 @@ var StreamerServer = class {
9044
9600
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
9045
9601
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9046
9602
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9047
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os9.homedir)(), ".threadbase", "cache");
9603
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase", "cache");
9604
+ this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path18.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase"), "runtime.db");
9048
9605
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9049
9606
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9050
9607
  this.markScannerStaleDebounced = debounce(() => {
@@ -9095,7 +9652,7 @@ var StreamerServer = class {
9095
9652
  const seqs = cache.extendMessageIndex(
9096
9653
  filePath,
9097
9654
  spans,
9098
- (0, import_fs18.statSync)(filePath),
9655
+ (0, import_fs19.statSync)(filePath),
9099
9656
  readFrom,
9100
9657
  endOffset
9101
9658
  );
@@ -9215,6 +9772,7 @@ var StreamerServer = class {
9215
9772
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9216
9773
  },
9217
9774
  onStatusChange: (session) => {
9775
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9218
9776
  this.sessionStore.updateManaged(session.id, {
9219
9777
  status: session.status,
9220
9778
  completedAt: session.completedAt,
@@ -9269,7 +9827,7 @@ var StreamerServer = class {
9269
9827
  if (resp) {
9270
9828
  this.wsHub.broadcast({ type: "session_update", session: resp });
9271
9829
  }
9272
- void this.liveActivityNotifier?.onStatusChange(session);
9830
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9273
9831
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9274
9832
  }
9275
9833
  });
@@ -9320,6 +9878,7 @@ var StreamerServer = class {
9320
9878
  conversationsRepo: () => this.conversationsRepo,
9321
9879
  sessionsRepo: () => this.sessionsRepo,
9322
9880
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9881
+ runtimeStore: () => this.runtimeStore,
9323
9882
  ptyAttachedIds: () => this.ptyAttachedIds(),
9324
9883
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9325
9884
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -9558,14 +10117,14 @@ var StreamerServer = class {
9558
10117
  }
9559
10118
  this.apnsClient = new ApnsClient(creds);
9560
10119
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9561
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os9.hostname)();
9562
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os9.hostname)());
10120
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os10.hostname)();
10121
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os10.hostname)());
9563
10122
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9564
10123
  repo: pushRepo,
9565
10124
  sender,
9566
10125
  sessionStore: this.sessionStore,
9567
10126
  serverId,
9568
- serverLabel: (0, import_os9.hostname)()
10127
+ serverLabel: (0, import_os10.hostname)()
9569
10128
  });
9570
10129
  this.liveActivityRenewal.start();
9571
10130
  this.log.info("Live Activity push enabled", {
@@ -9619,6 +10178,58 @@ var StreamerServer = class {
9619
10178
  }
9620
10179
  return verdicts;
9621
10180
  }
10181
+ /**
10182
+ * Seed the session list with what previous runs left behind (persistence plan
10183
+ * Phase 1, gaps G1/G2/G8).
10184
+ *
10185
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
10186
+ * SessionResponse that already exists, and after a clean restart none does —
10187
+ * `SessionStore` starts empty. So the user's session did not become
10188
+ * `resumable`, it became *absent*. This is the half that puts it back.
10189
+ *
10190
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
10191
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
10192
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
10193
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
10194
+ * by id rather than duplicating it.
10195
+ */
10196
+ rehydratePreviousSessions(verdicts) {
10197
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
10198
+ try {
10199
+ const now = Date.now();
10200
+ const rows = this.managedSessionsRepo.listRecoverable({
10201
+ sinceMs: now - REHYDRATE_WINDOW_MS,
10202
+ limit: REHYDRATE_MAX + 1
10203
+ });
10204
+ const truncated = rows.length > REHYDRATE_MAX;
10205
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10206
+ if (candidates.length === 0) return;
10207
+ const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10208
+ let rehydrated = 0;
10209
+ for (const row of candidates) {
10210
+ if (this.sessionStore.getManaged(row.session_id)) continue;
10211
+ if (!shouldRehydrate(row, { now, projectExists: import_fs19.existsSync })) continue;
10212
+ this.sessionStore.addManaged(rowToStubSession(row));
10213
+ this.sessionLifecycles.set(
10214
+ row.session_id,
10215
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
10216
+ );
10217
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10218
+ rehydrated++;
10219
+ }
10220
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
10221
+ event: "sessions.rehydrated",
10222
+ rehydrated,
10223
+ skipped: candidates.length - rehydrated,
10224
+ truncated
10225
+ });
10226
+ } catch (err) {
10227
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10228
+ event: "sessions.rehydrate_failed",
10229
+ err
10230
+ });
10231
+ }
10232
+ }
9622
10233
  /**
9623
10234
  * Pick a token guaranteed to appear in the spawned process's argv, for the
9624
10235
  * reconciler's pid-reuse guard.
@@ -9852,6 +10463,17 @@ var StreamerServer = class {
9852
10463
  port,
9853
10464
  event: "server.listening"
9854
10465
  });
10466
+ try {
10467
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
10468
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
10469
+ } catch (err) {
10470
+ const message = err instanceof Error ? err.message : String(err);
10471
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
10472
+ this.log.error(
10473
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
10474
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10475
+ );
10476
+ }
9855
10477
  try {
9856
10478
  this.cache = ConversationCache.open(
9857
10479
  (0, import_path18.join)(this.cacheDir, "cache.db"),
@@ -9879,8 +10501,20 @@ var StreamerServer = class {
9879
10501
  this.projectsRepo = new ProjectsRepository(db);
9880
10502
  this.conversationsRepo = new ConversationsRepository(this.cache);
9881
10503
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
9882
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
9883
- void this.reconcilePreviousSessions();
10504
+ try {
10505
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
10506
+ if (copied > 0) {
10507
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
10508
+ copied,
10509
+ event: "runtime.legacy_import"
10510
+ });
10511
+ }
10512
+ } catch (err) {
10513
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
10514
+ event: "runtime.legacy_import_failed",
10515
+ err
10516
+ });
10517
+ }
9884
10518
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
9885
10519
  this.pushRepo = new PushRepository(db);
9886
10520
  this.devicesRepo = new DevicesRepository(db);
@@ -9904,7 +10538,7 @@ var StreamerServer = class {
9904
10538
  this.fileWatcher.watchDirectory(dir);
9905
10539
  }
9906
10540
  for (const dir of this.codexRoots) {
9907
- if (!(0, import_fs18.existsSync)(dir)) continue;
10541
+ if (!(0, import_fs19.existsSync)(dir)) continue;
9908
10542
  this.fileWatcher.watchDirectory(dir);
9909
10543
  }
9910
10544
  } catch (err) {
@@ -9916,6 +10550,7 @@ var StreamerServer = class {
9916
10550
  );
9917
10551
  this.scannerPersistenceDisabled = true;
9918
10552
  }
10553
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
9919
10554
  if (this.skipStartupWarmup) {
9920
10555
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9921
10556
  event: "cache.warmup_skipped"
@@ -10123,6 +10758,7 @@ var StreamerServer = class {
10123
10758
  this.allScanners.clear();
10124
10759
  this.scanner = null;
10125
10760
  this.cache?.close();
10761
+ this.runtimeStore?.close();
10126
10762
  this.ptyManager.dispose();
10127
10763
  this.fileWatcher.dispose();
10128
10764
  this.externalTails.clear();
@@ -10177,7 +10813,7 @@ var StreamerServer = class {
10177
10813
  }
10178
10814
  let body;
10179
10815
  try {
10180
- body = await readBody(req);
10816
+ body = await readBody2(req);
10181
10817
  } catch (err) {
10182
10818
  const message = err instanceof Error ? err.message : "Invalid body";
10183
10819
  json(res, 400, { error: message });
@@ -10223,7 +10859,7 @@ var StreamerServer = class {
10223
10859
  nonce: sealed.nonce,
10224
10860
  ephemeralPublicKey: sealed.ephemeralPublicKey,
10225
10861
  publicUrl: this.publicUrl,
10226
- machineName: (0, import_os9.hostname)(),
10862
+ machineName: (0, import_os10.hostname)(),
10227
10863
  ...device && {
10228
10864
  deviceId: device.deviceId,
10229
10865
  deviceToken: device.deviceToken,
@@ -10351,7 +10987,7 @@ var StreamerServer = class {
10351
10987
  if (this.scanProfiles && this.scanProfiles.length > 0) {
10352
10988
  return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path18.join)(p.configDir, "projects"));
10353
10989
  }
10354
- return [(0, import_path18.join)((0, import_os9.homedir)(), ".claude", "projects")];
10990
+ return [(0, import_path18.join)((0, import_os10.homedir)(), ".claude", "projects")];
10355
10991
  }
10356
10992
  /**
10357
10993
  * Full-glob scan + cache upsert/delete reconcile. Used by ?refresh=1 and by
@@ -10570,7 +11206,8 @@ var StreamerServer = class {
10570
11206
  }
10571
11207
  handleSessionsCount(res) {
10572
11208
  if (this.rejectIfWarmingUp(res)) return;
10573
- json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
11209
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
11210
+ json(res, 200, { total });
10574
11211
  }
10575
11212
  handleGetRecentSessions(url, res) {
10576
11213
  if (this.rejectIfWarmingUp(res)) return;
@@ -10753,20 +11390,20 @@ var StreamerServer = class {
10753
11390
  if (this.scanProfiles && this.scanProfiles.length > 0) {
10754
11391
  return this.scanProfiles.filter((p) => p.enabled).map((p) => (0, import_path18.join)(p.configDir, "projects"));
10755
11392
  }
10756
- return [(0, import_path18.join)((0, import_os9.homedir)(), ".claude", "projects")];
11393
+ return [(0, import_path18.join)((0, import_os10.homedir)(), ".claude", "projects")];
10757
11394
  }
10758
11395
  findJsonlPath(uuid) {
10759
11396
  const filename = `${uuid}.jsonl`;
10760
11397
  for (const projectsDir of this.projectsDirs()) {
10761
- if (!(0, import_fs18.existsSync)(projectsDir)) continue;
10762
- for (const dir of (0, import_fs18.readdirSync)(projectsDir)) {
11398
+ if (!(0, import_fs19.existsSync)(projectsDir)) continue;
11399
+ for (const dir of (0, import_fs19.readdirSync)(projectsDir)) {
10763
11400
  const fp = (0, import_path18.join)(projectsDir, dir, filename);
10764
- if ((0, import_fs18.existsSync)(fp)) return fp;
11401
+ if ((0, import_fs19.existsSync)(fp)) return fp;
10765
11402
  const projectDir = (0, import_path18.join)(projectsDir, dir);
10766
11403
  try {
10767
- for (const sub of (0, import_fs18.readdirSync)(projectDir)) {
11404
+ for (const sub of (0, import_fs19.readdirSync)(projectDir)) {
10768
11405
  const subagentPath = (0, import_path18.join)(projectDir, sub, "subagents", filename);
10769
- if ((0, import_fs18.existsSync)(subagentPath)) return subagentPath;
11406
+ if ((0, import_fs19.existsSync)(subagentPath)) return subagentPath;
10770
11407
  }
10771
11408
  } catch {
10772
11409
  }
@@ -10776,7 +11413,7 @@ var StreamerServer = class {
10776
11413
  }
10777
11414
  async readCwdFromJsonl(filePath) {
10778
11415
  return new Promise((resolve2) => {
10779
- const rl = (0, import_readline.createInterface)({ input: (0, import_fs18.createReadStream)(filePath), crlfDelay: Infinity });
11416
+ const rl = (0, import_readline.createInterface)({ input: (0, import_fs19.createReadStream)(filePath), crlfDelay: Infinity });
10780
11417
  let found = false;
10781
11418
  rl.on("line", (line) => {
10782
11419
  if (found) return;
@@ -10866,7 +11503,7 @@ var StreamerServer = class {
10866
11503
  if (this.isManagedTailPath(key)) return;
10867
11504
  let mtimeMs;
10868
11505
  try {
10869
- mtimeMs = (0, import_fs18.statSync)(filePath).mtimeMs;
11506
+ mtimeMs = (0, import_fs19.statSync)(filePath).mtimeMs;
10870
11507
  } catch {
10871
11508
  return;
10872
11509
  }
@@ -11048,7 +11685,7 @@ var StreamerServer = class {
11048
11685
  if (!conv.filePath) return false;
11049
11686
  let mtimeMs = null;
11050
11687
  try {
11051
- mtimeMs = (0, import_fs18.statSync)(conv.filePath).mtimeMs;
11688
+ mtimeMs = (0, import_fs19.statSync)(conv.filePath).mtimeMs;
11052
11689
  } catch {
11053
11690
  return false;
11054
11691
  }
@@ -11303,7 +11940,7 @@ var StreamerServer = class {
11303
11940
  }
11304
11941
  let body;
11305
11942
  try {
11306
- body = await readBody(req);
11943
+ body = await readBody2(req);
11307
11944
  } catch {
11308
11945
  res.setHeader("Accept-Query", "application/json");
11309
11946
  json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
@@ -11341,17 +11978,27 @@ var StreamerServer = class {
11341
11978
  });
11342
11979
  }
11343
11980
  async handleSearch(url, res) {
11344
- const q = url.searchParams.get("q") ?? "";
11345
- if (!q) {
11346
- json(res, 400, { error: "Missing query parameter: q" });
11347
- return;
11981
+ let parsed;
11982
+ try {
11983
+ parsed = parseSearchQuery(url.searchParams);
11984
+ } catch (err) {
11985
+ if (err instanceof SearchQueryError) {
11986
+ json(res, 400, { error: err.message, code: err.code });
11987
+ return;
11988
+ }
11989
+ throw err;
11348
11990
  }
11349
- const limit = intParam(url, "limit", 50);
11991
+ const { q, limit, offset, filters } = parsed;
11992
+ const startedAt = Date.now();
11350
11993
  const scanner = await this.getScanner();
11351
11994
  const results = await (0, import_scanner3.search)(
11352
11995
  q,
11353
11996
  {
11354
- limit,
11997
+ // Fetch beyond the requested page: filters below are applied AFTER the
11998
+ // scanner returns, so slicing at `limit` here would drop results that a
11999
+ // later page should contain. Bounded so a broad query cannot pull an
12000
+ // unbounded set into memory.
12001
+ limit: Math.min(offset + limit * SEARCH_OVERFETCH, SEARCH_MAX_SCAN),
11355
12002
  include: "conversations",
11356
12003
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
11357
12004
  ...this.codexScanOpts()
@@ -11375,13 +12022,24 @@ var StreamerServer = class {
11375
12022
  lastActivity: r.meta.timestamp,
11376
12023
  firstMessage: r.meta.firstMessage ?? void 0,
11377
12024
  lastMessage: r.meta.lastMessage ?? void 0,
11378
- provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER
12025
+ provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER,
12026
+ // The scanner already computes relevance and match snippets; the previous
12027
+ // adapter discarded both, so results arrived in an unexplained order with
12028
+ // no indication of WHY anything matched.
12029
+ score: r.score,
12030
+ matches: Array.isArray(r.matches) ? r.matches.map((m) => ({
12031
+ field: m.field,
12032
+ snippet: m.snippet
12033
+ })) : []
11379
12034
  }));
12035
+ const page = paginate(applyFilters(adapted, filters), offset, limit);
11380
12036
  json(res, 200, {
11381
- conversations: adapted,
11382
- hasMore: false,
11383
- offset: 0,
11384
- total: adapted.length
12037
+ conversations: page.items,
12038
+ hasMore: page.hasMore,
12039
+ offset: page.offset,
12040
+ total: page.total,
12041
+ // Query timing, so a slow search is diagnosable rather than merely felt.
12042
+ tookMs: Date.now() - startedAt
11385
12043
  });
11386
12044
  }
11387
12045
  async handleListSessions(url, res) {
@@ -11427,7 +12085,7 @@ var StreamerServer = class {
11427
12085
  if (this.rejectIfWarmingUp(res)) return;
11428
12086
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
11429
12087
  if (session) {
11430
- if (!(0, import_fs18.existsSync)(session.projectPath)) {
12088
+ if (!(0, import_fs19.existsSync)(session.projectPath)) {
11431
12089
  session.failureReason = `Project directory not found: ${session.projectPath}`;
11432
12090
  }
11433
12091
  const reconciled = this.withReconciledLifecycle([session])[0];
@@ -11454,7 +12112,7 @@ var StreamerServer = class {
11454
12112
  json(res, 404, { error: "Session not found" });
11455
12113
  }
11456
12114
  async handleResume(req, res) {
11457
- const body = await readBody(req);
12115
+ const body = await readBody2(req);
11458
12116
  const sessionId = body.sessionId ?? body.conversationId;
11459
12117
  if (!sessionId) {
11460
12118
  json(res, 400, { error: "Missing sessionId" });
@@ -11586,7 +12244,7 @@ var StreamerServer = class {
11586
12244
  return;
11587
12245
  }
11588
12246
  if (this.agentConfig.enabled) {
11589
- const body2 = await readBody(req);
12247
+ const body2 = await readBody2(req);
11590
12248
  const cache = this.cache;
11591
12249
  if (!cache) {
11592
12250
  json(res, 503, {
@@ -11605,7 +12263,7 @@ var StreamerServer = class {
11605
12263
  json(res, result.status, result.body);
11606
12264
  return;
11607
12265
  }
11608
- const body = await readBody(req);
12266
+ const body = await readBody2(req);
11609
12267
  const { input, keys } = body;
11610
12268
  let idempotencyKey;
11611
12269
  try {
@@ -11769,7 +12427,7 @@ var StreamerServer = class {
11769
12427
  });
11770
12428
  }
11771
12429
  async handleSendAnswer(sessionId, req, res) {
11772
- const body = await readBody(req);
12430
+ const body = await readBody2(req);
11773
12431
  const pending = this.pendingQuestions.get(sessionId);
11774
12432
  const resolution = resolveAnswer(pending, body);
11775
12433
  if (!resolution.ok) {
@@ -11798,7 +12456,7 @@ var StreamerServer = class {
11798
12456
  json(res, 400, { error: "Session has no project path" });
11799
12457
  return;
11800
12458
  }
11801
- const body = await readBody(req);
12459
+ const body = await readBody2(req);
11802
12460
  const { filename, mimeType, dataBase64 } = body ?? {};
11803
12461
  if (typeof filename !== "string" || typeof mimeType !== "string" || typeof dataBase64 !== "string") {
11804
12462
  json(res, 400, { error: "Missing filename, mimeType, or dataBase64" });
@@ -12000,7 +12658,7 @@ var StreamerServer = class {
12000
12658
  return;
12001
12659
  }
12002
12660
  if (this.agentConfig.enabled) {
12003
- const body2 = await readBody(req);
12661
+ const body2 = await readBody2(req);
12004
12662
  const result = await handleStartAgentSession(body2, {
12005
12663
  sessionStore: this.sessionStore,
12006
12664
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
@@ -12014,7 +12672,7 @@ var StreamerServer = class {
12014
12672
  }
12015
12673
  return;
12016
12674
  }
12017
- const body = await readBody(req);
12675
+ const body = await readBody2(req);
12018
12676
  const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
12019
12677
  if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
12020
12678
  json(res, 400, { error: "Invalid provider" });
@@ -12151,7 +12809,7 @@ var StreamerServer = class {
12151
12809
  // file isn't slurped in full.
12152
12810
  readFirstLineSessionId(filePath) {
12153
12811
  try {
12154
- const content = (0, import_fs18.readFileSync)(filePath, "utf8");
12812
+ const content = (0, import_fs19.readFileSync)(filePath, "utf8");
12155
12813
  const nl = content.indexOf("\n");
12156
12814
  const firstLine = nl === -1 ? content : content.slice(0, nl);
12157
12815
  if (!firstLine.trim()) return null;
@@ -12166,7 +12824,7 @@ var StreamerServer = class {
12166
12824
  // was passed to Claude via --session-id so the filename matches from the start.
12167
12825
  watchForJsonl(sessionId, projectPath) {
12168
12826
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
12169
- const projectsDir = (0, import_path18.join)((0, import_os9.homedir)(), ".claude", "projects", encoded);
12827
+ const projectsDir = (0, import_path18.join)((0, import_os10.homedir)(), ".claude", "projects", encoded);
12170
12828
  const expectedFile = `${sessionId}.jsonl`;
12171
12829
  const filePath = (0, import_path18.join)(projectsDir, expectedFile);
12172
12830
  const deadline = Date.now() + 12e4;
@@ -12186,11 +12844,11 @@ var StreamerServer = class {
12186
12844
  cleanup();
12187
12845
  return;
12188
12846
  }
12189
- let resolvedFilePath = (0, import_fs18.existsSync)(filePath) ? filePath : null;
12190
- if (!resolvedFilePath && (0, import_fs18.existsSync)(projectsDir)) {
12847
+ let resolvedFilePath = (0, import_fs19.existsSync)(filePath) ? filePath : null;
12848
+ if (!resolvedFilePath && (0, import_fs19.existsSync)(projectsDir)) {
12191
12849
  try {
12192
12850
  const now = Date.now();
12193
- const match = (0, import_fs18.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs18.statSync)((0, import_path18.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
12851
+ const match = (0, import_fs19.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs19.statSync)((0, import_path18.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
12194
12852
  ({ f }) => (0, import_path18.basename)(f, ".jsonl") === sessionId || this.readFirstLineSessionId((0, import_path18.join)(projectsDir, f)) === sessionId
12195
12853
  ).sort((a, b) => b.mtime - a.mtime)[0];
12196
12854
  if (match) resolvedFilePath = (0, import_path18.join)(projectsDir, match.f);
@@ -12201,7 +12859,7 @@ var StreamerServer = class {
12201
12859
  cleanup();
12202
12860
  this.sessionFileMap.set(sessionId, resolvedFilePath);
12203
12861
  try {
12204
- const existing = (0, import_fs18.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
12862
+ const existing = (0, import_fs19.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
12205
12863
  if (existing.length > 0) {
12206
12864
  this.broadcastConversationLines(sessionId, existing);
12207
12865
  }
@@ -12225,7 +12883,7 @@ var StreamerServer = class {
12225
12883
  if (this.sessionFileMap.has(sessionId)) return;
12226
12884
  try {
12227
12885
  require("fs").mkdirSync(projectsDir, { recursive: true });
12228
- watcher = (0, import_fs18.watch)(projectsDir, tryWire);
12886
+ watcher = (0, import_fs19.watch)(projectsDir, tryWire);
12229
12887
  watcher.on("error", cleanup);
12230
12888
  } catch {
12231
12889
  }
@@ -12254,7 +12912,7 @@ var StreamerServer = class {
12254
12912
  };
12255
12913
  const matchesProjectPath = (candidatePath) => {
12256
12914
  try {
12257
- const firstLine = (0, import_fs18.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
12915
+ const firstLine = (0, import_fs19.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
12258
12916
  if (!firstLine) return null;
12259
12917
  const parsed = JSON.parse(firstLine);
12260
12918
  if (parsed?.type !== "session_meta") return null;
@@ -12283,15 +12941,15 @@ var StreamerServer = class {
12283
12941
  );
12284
12942
  for (const root of this.codexRoots) {
12285
12943
  const sessionsDir = (0, import_path18.join)(root, dateDir);
12286
- if (!(0, import_fs18.existsSync)(sessionsDir)) continue;
12944
+ if (!(0, import_fs19.existsSync)(sessionsDir)) continue;
12287
12945
  let candidateFiles;
12288
12946
  try {
12289
- candidateFiles = (0, import_fs18.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
12947
+ candidateFiles = (0, import_fs19.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
12290
12948
  } catch {
12291
12949
  continue;
12292
12950
  }
12293
12951
  const nowMs = Date.now();
12294
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs18.statSync)((0, import_path18.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
12952
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs19.statSync)((0, import_path18.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
12295
12953
  for (const { f } of recentCandidates) {
12296
12954
  const candidatePath = (0, import_path18.join)(sessionsDir, f);
12297
12955
  const match = matchesProjectPath(candidatePath);
@@ -12303,7 +12961,7 @@ var StreamerServer = class {
12303
12961
  this.sessionFileMap.set(sessionId, candidatePath);
12304
12962
  this.fileWatcher.watch(candidatePath);
12305
12963
  try {
12306
- const existing = (0, import_fs18.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
12964
+ const existing = (0, import_fs19.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
12307
12965
  if (existing.length > 0) {
12308
12966
  this.broadcastConversationLines(sessionId, existing);
12309
12967
  }
@@ -12372,7 +13030,7 @@ var StreamerServer = class {
12372
13030
  });
12373
13031
  return;
12374
13032
  }
12375
- const body = await readBody(req);
13033
+ const body = await readBody2(req);
12376
13034
  const { path: relativePath, name } = body;
12377
13035
  if (!name || typeof name !== "string") {
12378
13036
  json(res, 400, { error: "Missing name field" });
@@ -12402,7 +13060,7 @@ var StreamerServer = class {
12402
13060
  }
12403
13061
  let parsed;
12404
13062
  try {
12405
- parsed = await readBody(req);
13063
+ parsed = await readBody2(req);
12406
13064
  } catch {
12407
13065
  json(res, 400, { error: "Invalid JSON" });
12408
13066
  return;
@@ -12459,7 +13117,7 @@ var StreamerServer = class {
12459
13117
  }
12460
13118
  let parsed;
12461
13119
  try {
12462
- parsed = await readBody(req);
13120
+ parsed = await readBody2(req);
12463
13121
  } catch {
12464
13122
  json(res, 400, { error: "Invalid JSON" });
12465
13123
  return;
@@ -12518,7 +13176,7 @@ async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
12518
13176
  }
12519
13177
  function classifyResumability(cwd) {
12520
13178
  if (!cwd) return { resumable: true };
12521
- if ((0, import_fs18.existsSync)(cwd)) return { resumable: true };
13179
+ if ((0, import_fs19.existsSync)(cwd)) return { resumable: true };
12522
13180
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
12523
13181
  return {
12524
13182
  resumable: false,
@@ -12636,7 +13294,7 @@ function parseSessionListQuery(url) {
12636
13294
  const cursor = url.searchParams.get("cursor") ?? void 0;
12637
13295
  return { query: { limit, sortBy, order, status, cursor } };
12638
13296
  }
12639
- function readBody(req) {
13297
+ function readBody2(req) {
12640
13298
  return new Promise((resolve2, reject) => {
12641
13299
  const chunks = [];
12642
13300
  req.on("data", (chunk) => chunks.push(chunk));