@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.js CHANGED
@@ -448,6 +448,12 @@ var FEATURE_FLAGS = [
448
448
  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.",
449
449
  default: false,
450
450
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
451
+ },
452
+ {
453
+ id: "sessionRehydration",
454
+ 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.",
455
+ default: true,
456
+ env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
451
457
  }
452
458
  ];
453
459
  function findFeatureFlag(id) {
@@ -1883,6 +1889,12 @@ function detectShellPrompt(lines) {
1883
1889
  return null;
1884
1890
  }
1885
1891
 
1892
+ // src/utils/deriveSessionName.ts
1893
+ function deriveSessionName(firstMessageText) {
1894
+ const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
1895
+ return firstLine.slice(0, 80);
1896
+ }
1897
+
1886
1898
  // src/pty-manager.ts
1887
1899
  var OUTPUT_BUFFER_MAX2 = 65536;
1888
1900
  var INPUT_HISTORY_MAX2 = 50;
@@ -2356,6 +2368,10 @@ var PTYManager = class {
2356
2368
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2357
2369
  session.inputHistory.shift();
2358
2370
  }
2371
+ if (session.firstMessageText === void 0) {
2372
+ session.firstMessageText = text;
2373
+ session.sessionName = deriveSessionName(text);
2374
+ }
2359
2375
  this.onUserMessage?.(session.id, text, ts);
2360
2376
  }
2361
2377
  getSession(sessionId) {
@@ -2621,7 +2637,9 @@ function toPublicSession2(s) {
2621
2637
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2622
2638
  ...s.statusSource != null && { statusSource: s.statusSource },
2623
2639
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2624
- ...s.filePath != null && { filePath: s.filePath }
2640
+ ...s.filePath != null && { filePath: s.filePath },
2641
+ ...s.sessionName != null && { sessionName: s.sessionName },
2642
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
2625
2643
  };
2626
2644
  }
2627
2645
  function stripAnsi2(str) {
@@ -3017,7 +3035,7 @@ import { randomUUID as randomUUID5 } from "crypto";
3017
3035
  import { EventEmitter } from "events";
3018
3036
  import {
3019
3037
  createReadStream,
3020
- existsSync as existsSync10,
3038
+ existsSync as existsSync11,
3021
3039
  watch as fsWatch,
3022
3040
  readdirSync as readdirSync6,
3023
3041
  readFileSync as readFileSync8,
@@ -3025,7 +3043,7 @@ import {
3025
3043
  } from "fs";
3026
3044
  import { realpath as realpath2 } from "fs/promises";
3027
3045
  import { createServer } from "http";
3028
- import { homedir as homedir9, hostname as hostname2 } from "os";
3046
+ import { homedir as homedir9, hostname as hostname3 } from "os";
3029
3047
  import { basename as basename5, dirname as dirname9, join as join18 } from "path";
3030
3048
  import { createInterface } from "readline";
3031
3049
 
@@ -3285,7 +3303,7 @@ async function handleStartAgentSession(body, deps) {
3285
3303
  }
3286
3304
 
3287
3305
  // src/api/app.ts
3288
- import { Hono as Hono16 } from "hono";
3306
+ import { Hono as Hono18 } from "hono";
3289
3307
 
3290
3308
  // src/db/repositories/devices.repository.ts
3291
3309
  import { createHash, randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual as timingSafeEqual2 } from "crypto";
@@ -3605,12 +3623,222 @@ var errorMiddleware = (err, c) => {
3605
3623
  return c.json({ error: message }, 500);
3606
3624
  };
3607
3625
 
3608
- // src/api/routes/browse.routes.ts
3626
+ // src/api/routes/backup.routes.ts
3609
3627
  import { Hono as Hono2 } from "hono";
3628
+ import { hostname } from "os";
3629
+
3630
+ // src/services/backup/backup.ts
3631
+ var BACKUP_FORMAT_VERSION = 1;
3632
+ var BackupError = class extends Error {
3633
+ constructor(message, code) {
3634
+ super(message);
3635
+ this.code = code;
3636
+ }
3637
+ code;
3638
+ };
3639
+ function validateArchive(input) {
3640
+ if (!input || typeof input !== "object") {
3641
+ throw new BackupError("Backup is not an object", "INVALID_ARCHIVE");
3642
+ }
3643
+ const archive = input;
3644
+ const manifest = archive.manifest;
3645
+ if (!manifest || typeof manifest !== "object") {
3646
+ throw new BackupError("Backup is missing its manifest", "INVALID_ARCHIVE");
3647
+ }
3648
+ if (manifest.formatVersion !== BACKUP_FORMAT_VERSION) {
3649
+ throw new BackupError(
3650
+ `Unsupported backup format version ${String(manifest.formatVersion)}; this build reads version ${BACKUP_FORMAT_VERSION}`,
3651
+ "UNSUPPORTED_VERSION"
3652
+ );
3653
+ }
3654
+ if (!Array.isArray(archive.projects)) {
3655
+ throw new BackupError("Backup is missing its projects array", "INVALID_ARCHIVE");
3656
+ }
3657
+ for (const [i, p] of archive.projects.entries()) {
3658
+ if (!p || typeof p !== "object") {
3659
+ throw new BackupError(`Project at index ${i} is not an object`, "INVALID_ARCHIVE");
3660
+ }
3661
+ if (typeof p.id !== "string" || p.id.length === 0) {
3662
+ throw new BackupError(`Project at index ${i} has no id`, "INVALID_ARCHIVE");
3663
+ }
3664
+ if (typeof p.path !== "string" || p.path.length === 0) {
3665
+ throw new BackupError(`Project at index ${i} has no path`, "INVALID_ARCHIVE");
3666
+ }
3667
+ }
3668
+ const ids = new Set(archive.projects.map((p) => p.id));
3669
+ if (ids.size !== archive.projects.length) {
3670
+ throw new BackupError("Backup contains duplicate project ids", "INVALID_ARCHIVE");
3671
+ }
3672
+ return archive;
3673
+ }
3674
+ function remapPaths(projects, rules) {
3675
+ const ordered = [...rules].sort((a, b) => b.from.length - a.from.length);
3676
+ return projects.map((p) => {
3677
+ const rule = ordered.find((r) => p.path === r.from || p.path.startsWith(`${r.from}/`));
3678
+ if (!rule) return p;
3679
+ return { ...p, path: `${rule.to}${p.path.slice(rule.from.length)}` };
3680
+ });
3681
+ }
3682
+ function planRestore(incoming, existing) {
3683
+ const byId = new Map(existing.map((e) => [e.id, e]));
3684
+ const byPath = new Map(existing.map((e) => [e.path, e]));
3685
+ const plan = { create: [], update: [], conflict: [] };
3686
+ for (const p of incoming) {
3687
+ const sameId = byId.get(p.id);
3688
+ if (sameId) {
3689
+ if (sameId.path !== p.path) plan.update.push(p);
3690
+ continue;
3691
+ }
3692
+ const samePath = byPath.get(p.path);
3693
+ if (samePath) {
3694
+ plan.conflict.push({ incoming: p, existingId: samePath.id });
3695
+ continue;
3696
+ }
3697
+ plan.create.push(p);
3698
+ }
3699
+ return plan;
3700
+ }
3701
+
3702
+ // src/version.ts
3703
+ import { readFileSync as readFileSync4, realpathSync } from "fs";
3704
+ import { dirname as dirname5, join as join7 } from "path";
3705
+ var cached;
3706
+ function getVersion() {
3707
+ if (cached !== void 0) return cached;
3708
+ cached = resolveVersion();
3709
+ return cached;
3710
+ }
3711
+ function resolveVersion() {
3712
+ const scriptPath = process.argv[1] ?? "";
3713
+ const here = scriptPath ? dirname5(scriptPath) : process.cwd();
3714
+ let realHere = here;
3715
+ try {
3716
+ realHere = dirname5(realpathSync(scriptPath));
3717
+ } catch {
3718
+ }
3719
+ const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
3720
+ for (const dir of searchDirs) {
3721
+ try {
3722
+ const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
3723
+ if (v) return v;
3724
+ } catch {
3725
+ }
3726
+ }
3727
+ try {
3728
+ const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
3729
+ if (pkg.version) return `${pkg.version}+source`;
3730
+ } catch {
3731
+ }
3732
+ return "0.0.0+unknown";
3733
+ }
3734
+
3735
+ // src/api/routes/backup.routes.ts
3736
+ function readBody(c) {
3737
+ return new Promise((resolve2, reject) => {
3738
+ const chunks = [];
3739
+ c.env.incoming.on("data", (chunk) => chunks.push(chunk));
3740
+ c.env.incoming.on("end", () => {
3741
+ try {
3742
+ const raw = Buffer.concat(chunks).toString("utf-8");
3743
+ resolve2(raw ? JSON.parse(raw) : {});
3744
+ } catch {
3745
+ reject(new Error("Invalid JSON body"));
3746
+ }
3747
+ });
3748
+ c.env.incoming.on("error", reject);
3749
+ });
3750
+ }
3751
+ var createBackupRoutes = (deps) => {
3752
+ const app = new Hono2();
3753
+ app.get("/export", (c) => {
3754
+ const repo = deps.projectsRepo();
3755
+ if (!repo) {
3756
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3757
+ }
3758
+ const projects = repo.listProjects().map((p) => ({
3759
+ id: p.id,
3760
+ path: p.path,
3761
+ name: p.name ?? null,
3762
+ createdAt: p.createdAt,
3763
+ updatedAt: p.updatedAt
3764
+ }));
3765
+ return c.json({
3766
+ manifest: {
3767
+ formatVersion: BACKUP_FORMAT_VERSION,
3768
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3769
+ streamerVersion: getVersion(),
3770
+ sourceHost: hostname(),
3771
+ // No endpoint here exports the API key. The flag is recorded so an
3772
+ // archive is self-describing about its own sensitivity rather than
3773
+ // requiring a reader to infer it.
3774
+ includesSecrets: false,
3775
+ counts: { projects: projects.length }
3776
+ },
3777
+ projects
3778
+ });
3779
+ });
3780
+ app.post("/restore", async (c) => {
3781
+ const repo = deps.projectsRepo();
3782
+ if (!repo) {
3783
+ return c.json({ error: "Project store is unavailable", code: "STORE_UNAVAILABLE" }, 503);
3784
+ }
3785
+ let body;
3786
+ try {
3787
+ body = await readBody(c);
3788
+ } catch {
3789
+ return c.json({ error: "Invalid JSON body", code: "INVALID_BODY" }, 400);
3790
+ }
3791
+ let archive;
3792
+ try {
3793
+ archive = validateArchive(body.archive);
3794
+ } catch (err) {
3795
+ if (err instanceof BackupError) {
3796
+ return c.json({ error: err.message, code: err.code }, 400);
3797
+ }
3798
+ throw err;
3799
+ }
3800
+ 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 })) : [];
3801
+ const incoming = rules.length > 0 ? remapPaths(archive.projects, rules) : archive.projects;
3802
+ const existing = repo.listProjects().map((p) => ({ id: p.id, path: p.path }));
3803
+ const plan = planRestore(incoming, existing);
3804
+ const summary = {
3805
+ create: plan.create.length,
3806
+ update: plan.update.length,
3807
+ conflict: plan.conflict.length
3808
+ };
3809
+ if (body.apply !== true) {
3810
+ return c.json({ applied: false, summary, plan });
3811
+ }
3812
+ if (plan.conflict.length > 0) {
3813
+ return c.json(
3814
+ {
3815
+ error: "Restore has unresolved conflicts",
3816
+ code: "RESTORE_CONFLICT",
3817
+ summary,
3818
+ plan
3819
+ },
3820
+ 409
3821
+ );
3822
+ }
3823
+ let applied = 0;
3824
+ for (const p of [...plan.create, ...plan.update]) {
3825
+ try {
3826
+ repo.upsertProjectByPath(p.path, { name: p.name });
3827
+ applied++;
3828
+ } catch {
3829
+ }
3830
+ }
3831
+ return c.json({ applied: true, summary, appliedCount: applied });
3832
+ });
3833
+ return app;
3834
+ };
3835
+
3836
+ // src/api/routes/browse.routes.ts
3837
+ import { Hono as Hono3 } from "hono";
3610
3838
  var ALREADY_HANDLED = 597;
3611
3839
  var alreadyHandled = () => new Response(null, { status: ALREADY_HANDLED });
3612
3840
  var createBrowseRoutes = (deps) => {
3613
- const app = new Hono2();
3841
+ const app = new Hono3();
3614
3842
  app.get("/browse", async (c) => {
3615
3843
  const url = new URL(c.req.url);
3616
3844
  await deps.handleBrowse(url, c.env.outgoing);
@@ -3624,7 +3852,7 @@ var createBrowseRoutes = (deps) => {
3624
3852
  };
3625
3853
 
3626
3854
  // src/api/routes/cacheAlert.routes.ts
3627
- import { Hono as Hono3 } from "hono";
3855
+ import { Hono as Hono4 } from "hono";
3628
3856
 
3629
3857
  // src/schemas/cacheAlert.schema.ts
3630
3858
  import { z } from "zod";
@@ -3647,7 +3875,7 @@ function readRawBody2(req) {
3647
3875
  });
3648
3876
  }
3649
3877
  var createCacheAlertRoutes = (deps) => {
3650
- const app = new Hono3();
3878
+ const app = new Hono4();
3651
3879
  app.get("/", (c) => {
3652
3880
  const monitor = deps.cacheMonitor();
3653
3881
  return c.json({ pending: monitor?.pending ?? null });
@@ -3684,7 +3912,7 @@ var createCacheAlertRoutes = (deps) => {
3684
3912
  };
3685
3913
 
3686
3914
  // src/api/routes/config.routes.ts
3687
- import { Hono as Hono4 } from "hono";
3915
+ import { Hono as Hono5 } from "hono";
3688
3916
 
3689
3917
  // src/schemas/claudeFlags.schema.ts
3690
3918
  import { z as z2 } from "zod";
@@ -3705,7 +3933,7 @@ function readRawBody3(req) {
3705
3933
  });
3706
3934
  }
3707
3935
  var createConfigRoutes = (deps) => {
3708
- const app = new Hono4();
3936
+ const app = new Hono5();
3709
3937
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3710
3938
  app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3711
3939
  app.put("/claude-flags", async (c) => {
@@ -3740,11 +3968,11 @@ var createConfigRoutes = (deps) => {
3740
3968
  };
3741
3969
 
3742
3970
  // src/api/routes/conversations.routes.ts
3743
- import { Hono as Hono5 } from "hono";
3971
+ import { Hono as Hono6 } from "hono";
3744
3972
  var ALREADY_HANDLED2 = 597;
3745
3973
  var alreadyHandled2 = () => new Response(null, { status: ALREADY_HANDLED2 });
3746
3974
  var createConversationRoutes = (deps) => {
3747
- const app = new Hono5();
3975
+ const app = new Hono6();
3748
3976
  app.get("/count", async (c) => {
3749
3977
  const url = new URL(c.req.url);
3750
3978
  await deps.handleConversationsCount(url, c.env.outgoing);
@@ -3771,9 +3999,9 @@ var createConversationRoutes = (deps) => {
3771
3999
  };
3772
4000
 
3773
4001
  // src/api/routes/devices.routes.ts
3774
- import { Hono as Hono6 } from "hono";
4002
+ import { Hono as Hono7 } from "hono";
3775
4003
  var createDeviceRoutes = (deps) => {
3776
- const app = new Hono6();
4004
+ const app = new Hono7();
3777
4005
  app.get("/", (c) => {
3778
4006
  const repo = deps.devicesRepo();
3779
4007
  if (!repo) return c.json({ devices: [], available: false });
@@ -3796,45 +4024,134 @@ var createDeviceRoutes = (deps) => {
3796
4024
  return app;
3797
4025
  };
3798
4026
 
3799
- // src/api/routes/health.routes.ts
3800
- import { Hono as Hono7 } from "hono";
4027
+ // src/api/routes/diagnostics.routes.ts
4028
+ import { existsSync as existsSync5 } from "fs";
4029
+ import { Hono as Hono8 } from "hono";
3801
4030
 
3802
- // src/version.ts
3803
- import { readFileSync as readFileSync4, realpathSync } from "fs";
3804
- import { dirname as dirname5, join as join7 } from "path";
3805
- var cached;
3806
- function getVersion() {
3807
- if (cached !== void 0) return cached;
3808
- cached = resolveVersion();
3809
- return cached;
4031
+ // src/services/diagnostics/diagnostics.ts
4032
+ var DIAGNOSTICS_CONTRACT_VERSION = 1;
4033
+ function redactPath(path) {
4034
+ if (!path) return null;
4035
+ const parts = path.split(/[/\\]/).filter(Boolean);
4036
+ if (parts.length <= 2) return parts.join("/");
4037
+ return `\u2026/${parts.slice(-2).join("/")}`;
4038
+ }
4039
+ function worstStatus(checks) {
4040
+ const rank = { ok: 0, unknown: 1, degraded: 2, failed: 3 };
4041
+ return checks.reduce(
4042
+ (worst, c) => rank[c.status] > rank[worst] ? c.status : worst,
4043
+ "ok"
4044
+ );
3810
4045
  }
3811
- function resolveVersion() {
3812
- const scriptPath = process.argv[1] ?? "";
3813
- const here = scriptPath ? dirname5(scriptPath) : process.cwd();
3814
- let realHere = here;
3815
- try {
3816
- realHere = dirname5(realpathSync(scriptPath));
3817
- } catch {
4046
+ function buildReport(checks, now = /* @__PURE__ */ new Date()) {
4047
+ return {
4048
+ contractVersion: DIAGNOSTICS_CONTRACT_VERSION,
4049
+ generatedAt: now.toISOString(),
4050
+ overall: worstStatus(checks),
4051
+ checks
4052
+ };
4053
+ }
4054
+ var SECRET_KEY_RE = /(key|token|secret|password|passwd|credential|authorization|cookie)/i;
4055
+ function redactValue(value) {
4056
+ if (Array.isArray(value)) {
4057
+ return value.map((v) => redactValue(v));
3818
4058
  }
3819
- const searchDirs = realHere === here ? [here, join7(here, "..")] : [here, join7(here, ".."), realHere, join7(realHere, "..")];
3820
- for (const dir of searchDirs) {
3821
- try {
3822
- const v = readFileSync4(join7(dir, "version.txt"), "utf8").trim();
3823
- if (v) return v;
3824
- } catch {
4059
+ if (value && typeof value === "object") {
4060
+ const out = {};
4061
+ for (const [k, v] of Object.entries(value)) {
4062
+ out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : redactValue(v);
3825
4063
  }
4064
+ return out;
3826
4065
  }
4066
+ return value;
4067
+ }
4068
+
4069
+ // src/api/routes/diagnostics.routes.ts
4070
+ function providerCheck(name, resolve2) {
3827
4071
  try {
3828
- const pkg = JSON.parse(readFileSync4(join7(here, "..", "package.json"), "utf8"));
3829
- if (pkg.version) return `${pkg.version}+source`;
4072
+ const exe = resolve2();
4073
+ return {
4074
+ id: `provider:${name}`,
4075
+ status: "ok",
4076
+ summary: `${name} CLI is installed.`,
4077
+ remediation: "NONE",
4078
+ detail: { location: redactPath(exe) }
4079
+ };
3830
4080
  } catch {
4081
+ return {
4082
+ id: `provider:${name}`,
4083
+ status: "failed",
4084
+ summary: `${name} CLI could not be located. Sessions for this provider cannot start.`,
4085
+ remediation: "PROVIDER_NOT_INSTALLED"
4086
+ };
3831
4087
  }
3832
- return "0.0.0+unknown";
3833
4088
  }
4089
+ var createDiagnosticsRoutes = (deps) => {
4090
+ const app = new Hono8();
4091
+ app.get("/", (c) => {
4092
+ const checks = [];
4093
+ checks.push({
4094
+ id: "streamer",
4095
+ status: "ok",
4096
+ summary: "Streamer is running.",
4097
+ remediation: "NONE",
4098
+ detail: { version: getVersion(), uptimeSeconds: Math.floor(process.uptime()) }
4099
+ });
4100
+ checks.push(providerCheck("claude-code", resolveClaudeExe));
4101
+ checks.push(providerCheck("codex-cli", resolveCodexExe));
4102
+ const cacheAlert = deps.cacheMonitor()?.healthzField();
4103
+ checks.push(
4104
+ cacheAlert ? {
4105
+ id: "cache",
4106
+ status: "degraded",
4107
+ summary: "Conversation cache reported an integrity alert.",
4108
+ remediation: "CACHE_DEGRADED"
4109
+ } : {
4110
+ id: "cache",
4111
+ status: "ok",
4112
+ summary: "Conversation cache is healthy.",
4113
+ remediation: "NONE"
4114
+ }
4115
+ );
4116
+ let ptyOk = true;
4117
+ try {
4118
+ __require.resolve("node-pty");
4119
+ } catch {
4120
+ ptyOk = false;
4121
+ }
4122
+ checks.push(
4123
+ ptyOk ? { id: "pty", status: "ok", summary: "PTY subsystem is available.", remediation: "NONE" } : {
4124
+ id: "pty",
4125
+ status: "failed",
4126
+ summary: "node-pty failed to load, so no managed session can start.",
4127
+ remediation: "PTY_UNAVAILABLE"
4128
+ }
4129
+ );
4130
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
4131
+ const claudeProjects = home ? `${home}/.claude/projects` : "";
4132
+ checks.push(
4133
+ claudeProjects && existsSync5(claudeProjects) ? {
4134
+ id: "filesystem",
4135
+ status: "ok",
4136
+ summary: "Provider history directory is present.",
4137
+ remediation: "NONE",
4138
+ detail: { location: redactPath(claudeProjects) }
4139
+ } : {
4140
+ id: "filesystem",
4141
+ status: "degraded",
4142
+ summary: "Provider history directory was not found; history may be unavailable.",
4143
+ remediation: "FS_SCOPE_MISSING"
4144
+ }
4145
+ );
4146
+ return c.json(redactValue(buildReport(checks)));
4147
+ });
4148
+ return app;
4149
+ };
3834
4150
 
3835
4151
  // src/api/routes/health.routes.ts
4152
+ import { Hono as Hono9 } from "hono";
3836
4153
  var createHealthRoutes = (deps) => {
3837
- const app = new Hono7();
4154
+ const app = new Hono9();
3838
4155
  app.get("/", (c) => {
3839
4156
  const cacheAlert = deps.cacheMonitor()?.healthzField();
3840
4157
  return c.json({ ok: true, version: getVersion(), ...cacheAlert ? { cacheAlert } : {} });
@@ -3843,9 +4160,9 @@ var createHealthRoutes = (deps) => {
3843
4160
  };
3844
4161
 
3845
4162
  // src/api/routes/logs.routes.ts
3846
- import { closeSync, existsSync as existsSync5, fstatSync, openSync, readSync, statSync } from "fs";
4163
+ import { closeSync, existsSync as existsSync6, fstatSync, openSync, readSync, statSync } from "fs";
3847
4164
  import { join as join9 } from "path";
3848
- import { Hono as Hono8 } from "hono";
4165
+ import { Hono as Hono10 } from "hono";
3849
4166
 
3850
4167
  // src/lifecycle/constants.ts
3851
4168
  import { homedir as homedir4 } from "os";
@@ -3863,12 +4180,12 @@ function resolveLogPath(source) {
3863
4180
  function pickDefaultSource() {
3864
4181
  for (const source of ["stdout", "stderr", "dev"]) {
3865
4182
  const p = resolveLogPath(source);
3866
- if (existsSync5(p) && statSync(p).size > 0) return source;
4183
+ if (existsSync6(p) && statSync(p).size > 0) return source;
3867
4184
  }
3868
4185
  return "stdout";
3869
4186
  }
3870
4187
  function readLogLines(filePath, sinceOffset, limit) {
3871
- if (!existsSync5(filePath)) {
4188
+ if (!existsSync6(filePath)) {
3872
4189
  return { lines: [], offset: 0, total: 0 };
3873
4190
  }
3874
4191
  const fd = openSync(filePath, "r");
@@ -3903,7 +4220,7 @@ function readLogLines(filePath, sinceOffset, limit) {
3903
4220
  }
3904
4221
  }
3905
4222
  function createLogsRoutes() {
3906
- const app = new Hono8();
4223
+ const app = new Hono10();
3907
4224
  app.get("/", (c) => {
3908
4225
  try {
3909
4226
  const sourceParam = (c.req.query("source") || "").toLowerCase();
@@ -3911,7 +4228,7 @@ function createLogsRoutes() {
3911
4228
  const logPath = resolveLogPath(source);
3912
4229
  const sinceOffset = parseInt(c.req.query("since") || "0", 10);
3913
4230
  const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
3914
- if (!existsSync5(logPath)) {
4231
+ if (!existsSync6(logPath)) {
3915
4232
  return c.json({
3916
4233
  logs: [],
3917
4234
  message: `No log file found for source=${source}`,
@@ -3948,7 +4265,7 @@ function createLogsRoutes() {
3948
4265
  try {
3949
4266
  const sources = ["stdout", "stderr", "dev"].map((source) => {
3950
4267
  const logPath = resolveLogPath(source);
3951
- if (!existsSync5(logPath)) {
4268
+ if (!existsSync6(logPath)) {
3952
4269
  return { source, exists: false, total: 0, fileSize: 0 };
3953
4270
  }
3954
4271
  const stats = statSync(logPath);
@@ -3974,8 +4291,8 @@ function createLogsRoutes() {
3974
4291
  // src/api/routes/misc.routes.ts
3975
4292
  import { spawn } from "child_process";
3976
4293
  import { createHmac, timingSafeEqual as timingSafeEqual3 } from "crypto";
3977
- import { Hono as Hono9 } from "hono";
3978
- import { hostname } from "os";
4294
+ import { Hono as Hono11 } from "hono";
4295
+ import { hostname as hostname2 } from "os";
3979
4296
 
3980
4297
  // src/config/update-config.ts
3981
4298
  import { readFileSync as readFileSync5 } from "fs";
@@ -4305,12 +4622,12 @@ function verifyWebhookSignature(body, header, secret) {
4305
4622
  }
4306
4623
  var clientLog = getLogger("client");
4307
4624
  var createMiscRoutes = (deps) => {
4308
- const app = new Hono9();
4625
+ const app = new Hono11();
4309
4626
  app.get("/api/info", (c) => {
4310
4627
  const ptyIds = deps.ptyAttachedIds();
4311
4628
  return c.json({
4312
4629
  version: getVersion(),
4313
- machineName: hostname(),
4630
+ machineName: hostname2(),
4314
4631
  platform: process.platform,
4315
4632
  activeSessions: deps.sessionStore.list(ptyIds).filter((s) => s.status === "running").length,
4316
4633
  publicUrl: deps.publicUrl,
@@ -4436,11 +4753,11 @@ var createMiscRoutes = (deps) => {
4436
4753
  };
4437
4754
 
4438
4755
  // src/api/routes/pair.routes.ts
4439
- import { Hono as Hono10 } from "hono";
4756
+ import { Hono as Hono12 } from "hono";
4440
4757
  var ALREADY_HANDLED3 = 597;
4441
4758
  var alreadyHandled3 = () => new Response(null, { status: ALREADY_HANDLED3 });
4442
4759
  var createPairRoutes = (deps) => {
4443
- const app = new Hono10();
4760
+ const app = new Hono12();
4444
4761
  app.post("/start", (c) => {
4445
4762
  deps.handlePairStart(c.env.outgoing);
4446
4763
  return alreadyHandled3();
@@ -4453,11 +4770,11 @@ var createPairRoutes = (deps) => {
4453
4770
  };
4454
4771
 
4455
4772
  // src/api/routes/projects.routes.ts
4456
- import { Hono as Hono11 } from "hono";
4773
+ import { Hono as Hono13 } from "hono";
4457
4774
  var ALREADY_HANDLED4 = 597;
4458
4775
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
4459
4776
  var createProjectRoutes = (deps) => {
4460
- const app = new Hono11();
4777
+ const app = new Hono13();
4461
4778
  app.get("/", (c) => {
4462
4779
  const url = new URL(c.req.url);
4463
4780
  deps.handleListProjects(url, c.env.outgoing);
@@ -4472,7 +4789,7 @@ var createProjectRoutes = (deps) => {
4472
4789
  };
4473
4790
 
4474
4791
  // src/api/routes/providers.routes.ts
4475
- import { Hono as Hono12 } from "hono";
4792
+ import { Hono as Hono14 } from "hono";
4476
4793
 
4477
4794
  // src/services/providers/providerHealth.ts
4478
4795
  import { execFile as execFile2 } from "child_process";
@@ -4595,7 +4912,7 @@ async function providerHealth(name, resolveExe, detect = runVersion) {
4595
4912
 
4596
4913
  // src/api/routes/providers.routes.ts
4597
4914
  var createProviderRoutes = () => {
4598
- const app = new Hono12();
4915
+ const app = new Hono14();
4599
4916
  app.get("/", async (c) => {
4600
4917
  const providers = await Promise.all([
4601
4918
  providerHealth(CLAUDE_CODE_PROVIDER, resolveClaudeExe),
@@ -4607,11 +4924,11 @@ var createProviderRoutes = () => {
4607
4924
  };
4608
4925
 
4609
4926
  // src/api/routes/scanner.routes.ts
4610
- import { Hono as Hono13 } from "hono";
4927
+ import { Hono as Hono15 } from "hono";
4611
4928
  var ALREADY_HANDLED5 = 597;
4612
4929
  var alreadyHandled5 = () => new Response(null, { status: ALREADY_HANDLED5 });
4613
4930
  var createScannerRoutes = (deps) => {
4614
- const app = new Hono13();
4931
+ const app = new Hono15();
4615
4932
  app.get("/api/search", async (c) => {
4616
4933
  const url = new URL(c.req.url);
4617
4934
  await deps.handleSearch(url, c.env.outgoing);
@@ -4621,11 +4938,11 @@ var createScannerRoutes = (deps) => {
4621
4938
  };
4622
4939
 
4623
4940
  // src/api/routes/sessions.routes.ts
4624
- import { Hono as Hono14 } from "hono";
4941
+ import { Hono as Hono16 } from "hono";
4625
4942
  var ALREADY_HANDLED6 = 597;
4626
4943
  var alreadyHandled6 = () => new Response(null, { status: ALREADY_HANDLED6 });
4627
4944
  var createSessionRoutes = (deps) => {
4628
- const app = new Hono14();
4945
+ const app = new Hono16();
4629
4946
  app.get("/count", (c) => {
4630
4947
  deps.handleSessionsCount(c.env.outgoing);
4631
4948
  return alreadyHandled6();
@@ -4700,9 +5017,9 @@ var createSessionRoutes = (deps) => {
4700
5017
  };
4701
5018
 
4702
5019
  // src/api/routes/ws.routes.ts
4703
- import { Hono as Hono15 } from "hono";
5020
+ import { Hono as Hono17 } from "hono";
4704
5021
  var createWsRoutes = (deps, upgradeWebSocket) => {
4705
- const app = new Hono15();
5022
+ const app = new Hono17();
4706
5023
  app.get(
4707
5024
  "/ws",
4708
5025
  upgradeWebSocket(() => {
@@ -4728,7 +5045,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
4728
5045
 
4729
5046
  // src/api/app.ts
4730
5047
  var createHonoApp = (deps, upgradeWebSocket) => {
4731
- const app = new Hono16();
5048
+ const app = new Hono18();
4732
5049
  const httpLog = getLogger("http");
4733
5050
  app.use("*", async (c, next) => {
4734
5051
  const start = Date.now();
@@ -4749,6 +5066,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4749
5066
  app.use("*", authMiddleware(deps));
4750
5067
  app.onError(errorMiddleware);
4751
5068
  app.route("/healthz", createHealthRoutes(deps));
5069
+ app.route("/api/diagnostics", createDiagnosticsRoutes(deps));
4752
5070
  app.route("/", createMiscRoutes(deps));
4753
5071
  app.route("/api/sessions", createSessionRoutes(deps));
4754
5072
  app.route("/api/conversations", createConversationRoutes(deps));
@@ -4757,6 +5075,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
4757
5075
  app.route("/api/projects", createProjectRoutes(deps));
4758
5076
  app.route("/api/providers", createProviderRoutes());
4759
5077
  app.route("/api/devices", createDeviceRoutes(deps));
5078
+ app.route("/api/backup", createBackupRoutes(deps));
4760
5079
  app.route("/api/pair", createPairRoutes(deps));
4761
5080
  app.route("/api", createBrowseRoutes(deps));
4762
5081
  app.route("/", createScannerRoutes(deps));
@@ -4825,7 +5144,7 @@ import {
4825
5144
  parseJsonlLine
4826
5145
  } from "@threadbase-sh/scanner";
4827
5146
  import Database from "better-sqlite3";
4828
- import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
5147
+ import { closeSync as closeSync3, existsSync as existsSync7, mkdirSync as mkdirSync3, openSync as openSync3, readSync as readSync3, statSync as statSync3 } from "fs";
4829
5148
  import { open as openAsync } from "fs/promises";
4830
5149
  import { dirname as dirname7 } from "path";
4831
5150
  import { setImmediate as yieldToEventLoop } from "timers/promises";
@@ -4840,6 +5159,9 @@ function getMigrationsDir2() {
4840
5159
  }
4841
5160
  return __dirname;
4842
5161
  }
5162
+ function resolveMigrationsDir(name = "migrations") {
5163
+ return join12(getMigrationsDir2(), name);
5164
+ }
4843
5165
  var SCHEMA_MIGRATIONS_SQL = `
4844
5166
  CREATE TABLE IF NOT EXISTS schema_migrations (
4845
5167
  id TEXT PRIMARY KEY,
@@ -4848,7 +5170,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
4848
5170
  `;
4849
5171
  function runSqliteMigrations(db, migrationsDir) {
4850
5172
  db.exec(SCHEMA_MIGRATIONS_SQL);
4851
- const dir = migrationsDir ?? join12(getMigrationsDir2(), "migrations");
5173
+ const dir = migrationsDir ?? resolveMigrationsDir();
4852
5174
  const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
4853
5175
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
4854
5176
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -6057,7 +6379,7 @@ var ConversationCache = class _ConversationCache {
6057
6379
  * `handleGetConversation` can still serve the cached tail even when the
6058
6380
  * JSONL has been deleted.
6059
6381
  */
6060
- pruneGhostFiles(exists = existsSync6) {
6382
+ pruneGhostFiles(exists = existsSync7) {
6061
6383
  const rows = this.stmts.allFilePaths.all();
6062
6384
  const ghosts = [];
6063
6385
  const prune = this.db.transaction((ids) => {
@@ -6112,7 +6434,7 @@ var ConversationCache = class _ConversationCache {
6112
6434
  * Returns the removed IDs.
6113
6435
  */
6114
6436
  reconcileDeletions(livePaths, opts) {
6115
- const exists = opts?.exists ?? existsSync6;
6437
+ const exists = opts?.exists ?? existsSync7;
6116
6438
  const rows = this.stmts.allFilePaths.all();
6117
6439
  const removed = [];
6118
6440
  const drop = this.db.transaction((ids) => {
@@ -6147,7 +6469,7 @@ var ConversationCache = class _ConversationCache {
6147
6469
  * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
6148
6470
  * rows that still have cached history (which pruneGhostFiles would keep).
6149
6471
  */
6150
- listMissingFiles(exists = existsSync6) {
6472
+ listMissingFiles(exists = existsSync7) {
6151
6473
  const rows = this.stmts.allFilePathsWithTitle.all();
6152
6474
  const missing = [];
6153
6475
  for (const row of rows) {
@@ -6259,6 +6581,7 @@ var ManagedSessionsRepository = class {
6259
6581
  updateStatusStmt;
6260
6582
  getStmt;
6261
6583
  listNonTerminalStmt;
6584
+ listRecoverableStmt;
6262
6585
  deleteStmt;
6263
6586
  constructor(db) {
6264
6587
  this.upsertStmt = db.prepare(`
@@ -6311,6 +6634,13 @@ var ManagedSessionsRepository = class {
6311
6634
  WHERE completed_at IS NULL
6312
6635
  ORDER BY started_at ASC
6313
6636
  `);
6637
+ this.listRecoverableStmt = db.prepare(`
6638
+ SELECT * FROM managed_sessions
6639
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
6640
+ AND status_updated_at >= @since
6641
+ ORDER BY status_updated_at DESC
6642
+ LIMIT @limit
6643
+ `);
6314
6644
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6315
6645
  }
6316
6646
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6362,6 +6692,14 @@ var ManagedSessionsRepository = class {
6362
6692
  listNonTerminal() {
6363
6693
  return this.listNonTerminalStmt.all();
6364
6694
  }
6695
+ /**
6696
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
6697
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
6698
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
6699
+ */
6700
+ listRecoverable({ sinceMs, limit }) {
6701
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
6702
+ }
6365
6703
  delete(sessionId) {
6366
6704
  this.deleteStmt.run(sessionId);
6367
6705
  }
@@ -6497,6 +6835,54 @@ var SessionsRepository = class {
6497
6835
  }
6498
6836
  };
6499
6837
 
6838
+ // src/db/runtime-store.ts
6839
+ import Database2 from "better-sqlite3";
6840
+ var RuntimeStore = class _RuntimeStore {
6841
+ constructor(db) {
6842
+ this.db = db;
6843
+ }
6844
+ db;
6845
+ static open(dbPath, migrationsDir) {
6846
+ const db = new Database2(dbPath);
6847
+ db.pragma("journal_mode = WAL");
6848
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
6849
+ return new _RuntimeStore(db);
6850
+ }
6851
+ getDatabase() {
6852
+ return this.db;
6853
+ }
6854
+ /**
6855
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
6856
+ *
6857
+ * Non-destructive by design: the source table is left in place so an older
6858
+ * streamer rolled back onto the same machine still finds its registry. Runs
6859
+ * only when this file's table is empty, so a second boot is a no-op rather
6860
+ * than a re-copy that would resurrect rows deleted since.
6861
+ *
6862
+ * Returns the number of rows copied.
6863
+ */
6864
+ importLegacyManagedSessions(source) {
6865
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
6866
+ if (existing.n > 0) return 0;
6867
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
6868
+ if (!hasTable) return 0;
6869
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
6870
+ if (rows.length === 0) return 0;
6871
+ const columns = Object.keys(rows[0]);
6872
+ const insert = this.db.prepare(
6873
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
6874
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
6875
+ );
6876
+ this.db.transaction((batch) => {
6877
+ for (const row of batch) insert.run(row);
6878
+ })(rows);
6879
+ return rows.length;
6880
+ }
6881
+ close() {
6882
+ this.db.close();
6883
+ }
6884
+ };
6885
+
6500
6886
  // src/db/upload-records.ts
6501
6887
  async function recordUpload(pool2, instanceId, row) {
6502
6888
  if (!pool2) return;
@@ -6648,7 +7034,7 @@ function setCacheMetadata(repo, key, value) {
6648
7034
 
6649
7035
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
6650
7036
  import { createHash as createHash3 } from "crypto";
6651
- import { existsSync as existsSync8 } from "fs";
7037
+ import { existsSync as existsSync9 } from "fs";
6652
7038
 
6653
7039
  // src/services/cache-integrity/alertStore.ts
6654
7040
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
@@ -6674,7 +7060,7 @@ function saveAlertState(state) {
6674
7060
  }
6675
7061
 
6676
7062
  // src/services/cache-integrity/backup.ts
6677
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
7063
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
6678
7064
  import { join as join15 } from "path";
6679
7065
  var DEFAULT_RETAIN = 3;
6680
7066
  function retainCount() {
@@ -6696,7 +7082,7 @@ async function backupCacheDb(db, cacheDir) {
6696
7082
  return { full, mtime: statSync5(full).mtimeMs };
6697
7083
  }).sort((a, b) => b.mtime - a.mtime);
6698
7084
  for (const stale of backups.slice(retain)) {
6699
- if (existsSync7(stale.full)) unlinkSync(stale.full);
7085
+ if (existsSync8(stale.full)) unlinkSync(stale.full);
6700
7086
  }
6701
7087
  return destPath;
6702
7088
  }
@@ -6793,7 +7179,7 @@ var CacheIntegrityMonitor = class {
6793
7179
  * the pending record, back up on high severity, and broadcast the alert.
6794
7180
  */
6795
7181
  async runDetection(detectedAt = (/* @__PURE__ */ new Date()).toISOString()) {
6796
- const all = this.cache.listMissingFiles(existsSync8);
7182
+ const all = this.cache.listMissingFiles(existsSync9);
6797
7183
  const missing = all.filter((m) => !this.ignoredIds.has(m.id));
6798
7184
  if (missing.length === 0) {
6799
7185
  if (this._pending) {
@@ -6897,7 +7283,7 @@ var CacheIntegrityMonitor = class {
6897
7283
  case "prune_all": {
6898
7284
  await this.ensureBackup(pending);
6899
7285
  const backupPath = pending.backupPath;
6900
- const stillMissing = pending.missing.filter((m) => !existsSync8(m.filePath)).map((m) => m.id);
7286
+ const stillMissing = pending.missing.filter((m) => !existsSync9(m.filePath)).map((m) => m.id);
6901
7287
  const pruned = this.cache.dropRowsById(stillMissing);
6902
7288
  this.applyDeferredUnlinks();
6903
7289
  this.clearPending();
@@ -7167,14 +7553,14 @@ function findSearchTarget(messages, query) {
7167
7553
  }
7168
7554
 
7169
7555
  // src/services/conversations/pruneAgentConversations.ts
7170
- import { existsSync as existsSync9 } from "fs";
7556
+ import { existsSync as existsSync10 } from "fs";
7171
7557
  function pruneAgentConversations(cache) {
7172
7558
  const db = cache.getDatabase();
7173
7559
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
7174
7560
  let pruned = 0;
7175
7561
  let missing = 0;
7176
7562
  for (const row of rows) {
7177
- if (!existsSync9(row.file_path)) {
7563
+ if (!existsSync10(row.file_path)) {
7178
7564
  missing += 1;
7179
7565
  continue;
7180
7566
  }
@@ -7514,7 +7900,8 @@ function contentStateForSession(args) {
7514
7900
  status,
7515
7901
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7516
7902
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7517
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7903
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7904
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7518
7905
  };
7519
7906
  }
7520
7907
  var LiveActivityNotifier = class {
@@ -7527,14 +7914,17 @@ var LiveActivityNotifier = class {
7527
7914
  serverId;
7528
7915
  serverLabel;
7529
7916
  /**
7530
- * Last status pushed per session.
7917
+ * Sessions with a currently open (pushed) activity.
7531
7918
  *
7532
- * Live Activity pushes are rate-limited by iOS and the surface only renders
7533
- * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7534
- * budget spend for no visible change. This is what makes the notifier
7535
- * edge-triggered rather than level-triggered.
7919
+ * An activity opens on a `waiting_input running` edge (the user sent a
7920
+ * prompt) and closes on the matching `running waiting_input` edge (the
7921
+ * response, including any sub-agents, finished) so this set is what makes
7922
+ * the notifier per-turn rather than per-session. A session's very first
7923
+ * `running` (right after spawn, before any user prompt) has no prior
7924
+ * `waiting_input` and therefore no edge, so it never opens an activity —
7925
+ * this is what keeps a fresh/idle session from pushing anything.
7536
7926
  */
7537
- lastPushed = /* @__PURE__ */ new Map();
7927
+ openActivity = /* @__PURE__ */ new Map();
7538
7928
  /**
7539
7929
  * React to a session status change.
7540
7930
  *
@@ -7542,34 +7932,22 @@ var LiveActivityNotifier = class {
7542
7932
  * transition, so this returns a promise the caller may ignore and every error
7543
7933
  * is logged rather than propagated.
7544
7934
  */
7545
- async onStatusChange(session) {
7935
+ async onStatusChange(session, previousStatus) {
7546
7936
  const status = toLiveActivityStatus(session.status);
7547
7937
  try {
7548
7938
  if (!status) {
7549
- await this.endFor(session);
7939
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7550
7940
  return;
7551
7941
  }
7552
- if (this.lastPushed.get(session.id) === status) return;
7553
- const contentState = contentStateForSession({
7554
- session,
7555
- serverId: this.serverId,
7556
- serverLabel: this.serverLabel
7557
- });
7558
- if (!contentState) return;
7559
- const outcome = await this.sender.send({
7560
- sessionId: session.id,
7561
- event: "update",
7562
- contentState
7563
- });
7564
- this.lastPushed.set(session.id, status);
7565
- if (outcome.attempted > 0) {
7566
- log4.info("live_activity.updated", {
7567
- event: "live_activity.updated",
7568
- sessionId: session.id,
7569
- status,
7570
- ...outcome
7571
- });
7942
+ if (status === "running" && previousStatus === "waiting_input") {
7943
+ await this.startTurn(session);
7944
+ return;
7572
7945
  }
7946
+ if (status === "waiting_input" && previousStatus === "running") {
7947
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7948
+ return;
7949
+ }
7950
+ await this.maybeSendName(session);
7573
7951
  } catch (err) {
7574
7952
  log4.error("live_activity.notify_failed", {
7575
7953
  event: "live_activity.notify_failed",
@@ -7579,14 +7957,57 @@ var LiveActivityNotifier = class {
7579
7957
  });
7580
7958
  }
7581
7959
  }
7960
+ async startTurn(session) {
7961
+ const contentState = contentStateForSession({
7962
+ session,
7963
+ serverId: this.serverId,
7964
+ serverLabel: this.serverLabel
7965
+ });
7966
+ if (!contentState) return;
7967
+ const outcome = await this.sender.send({
7968
+ sessionId: session.id,
7969
+ event: "update",
7970
+ contentState
7971
+ });
7972
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
7973
+ if (outcome.attempted > 0) {
7974
+ log4.info("live_activity.updated", {
7975
+ event: "live_activity.updated",
7976
+ sessionId: session.id,
7977
+ status: contentState.status,
7978
+ ...outcome
7979
+ });
7980
+ }
7981
+ }
7982
+ async maybeSendName(session) {
7983
+ const open2 = this.openActivity.get(session.id);
7984
+ if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
7985
+ const contentState = contentStateForSession({
7986
+ session,
7987
+ serverId: this.serverId,
7988
+ serverLabel: this.serverLabel
7989
+ });
7990
+ if (!contentState) return;
7991
+ const outcome = await this.sender.send({
7992
+ sessionId: session.id,
7993
+ event: "update",
7994
+ contentState
7995
+ });
7996
+ open2.sessionNameSent = true;
7997
+ if (outcome.attempted > 0) {
7998
+ log4.info("live_activity.updated", {
7999
+ event: "live_activity.updated",
8000
+ sessionId: session.id,
8001
+ status: contentState.status,
8002
+ ...outcome
8003
+ });
8004
+ }
8005
+ }
7582
8006
  async endFor(session) {
7583
- const lastStatus = this.lastPushed.get(session.id);
7584
- this.lastPushed.delete(session.id);
8007
+ this.openActivity.delete(session.id);
8008
+ const status = toLiveActivityStatus(session.status);
7585
8009
  const contentState = contentStateForSession({
7586
- session: {
7587
- ...session,
7588
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7589
- },
8010
+ session: { ...session, status: status ?? "waiting_input" },
7590
8011
  serverId: this.serverId,
7591
8012
  serverLabel: this.serverLabel
7592
8013
  });
@@ -7600,9 +8021,9 @@ var LiveActivityNotifier = class {
7600
8021
  });
7601
8022
  }
7602
8023
  }
7603
- /** Drop cached state for a session, so a resume re-pushes its first status. */
8024
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
7604
8025
  forget(sessionId) {
7605
- this.lastPushed.delete(sessionId);
8026
+ this.openActivity.delete(sessionId);
7606
8027
  }
7607
8028
  };
7608
8029
 
@@ -7833,7 +8254,8 @@ var LiveActivityRenewalScheduler = class {
7833
8254
  status,
7834
8255
  startedAt,
7835
8256
  lastOutput: session.lastOutput ?? "",
7836
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8257
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8258
+ ...session.sessionName != null && { sessionName: session.sessionName }
7837
8259
  };
7838
8260
  try {
7839
8261
  await this.deps.sender.send({
@@ -7893,7 +8315,8 @@ var LiveActivityRenewalScheduler = class {
7893
8315
  // Carried through unchanged — the whole point of the renewal.
7894
8316
  startedAt: args.startedAt,
7895
8317
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
7896
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8318
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8319
+ ...session.sessionName != null && { sessionName: session.sessionName }
7897
8320
  },
7898
8321
  now: args.now,
7899
8322
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8051,6 +8474,88 @@ function resolveAnswer(pending, body) {
8051
8474
  }
8052
8475
  }
8053
8476
 
8477
+ // src/services/search/searchQuery.ts
8478
+ var DEFAULT_SEARCH_LIMIT = 50;
8479
+ var MAX_SEARCH_LIMIT = 200;
8480
+ var MAX_QUERY_LENGTH = 256;
8481
+ var SearchQueryError = class extends Error {
8482
+ constructor(message, code) {
8483
+ super(message);
8484
+ this.code = code;
8485
+ }
8486
+ code;
8487
+ };
8488
+ function intOr(raw, fallback) {
8489
+ if (raw === null) return fallback;
8490
+ const n = Number.parseInt(raw, 10);
8491
+ return Number.isFinite(n) ? n : fallback;
8492
+ }
8493
+ function parseSearchQuery(params) {
8494
+ const q = (params.get("q") ?? "").trim();
8495
+ if (!q) {
8496
+ throw new SearchQueryError("Missing query parameter: q", "invalid_query");
8497
+ }
8498
+ if (q.length > MAX_QUERY_LENGTH) {
8499
+ throw new SearchQueryError(`Query exceeds ${MAX_QUERY_LENGTH} characters`, "query_too_long");
8500
+ }
8501
+ const limit = Math.min(
8502
+ Math.max(intOr(params.get("limit"), DEFAULT_SEARCH_LIMIT), 1),
8503
+ MAX_SEARCH_LIMIT
8504
+ );
8505
+ const offset = Math.max(intOr(params.get("offset"), 0), 0);
8506
+ const filters = {};
8507
+ const provider = params.get("provider");
8508
+ if (provider !== null) {
8509
+ if (!isProviderName(provider)) {
8510
+ throw new SearchQueryError(`Unknown provider: ${provider}`, "invalid_filter");
8511
+ }
8512
+ filters.provider = provider;
8513
+ }
8514
+ const projectPath = params.get("projectPath");
8515
+ if (projectPath) filters.projectPath = projectPath;
8516
+ const branch = params.get("branch");
8517
+ if (branch) filters.branch = branch;
8518
+ for (const [key, field] of [
8519
+ ["since", "since"],
8520
+ ["until", "until"]
8521
+ ]) {
8522
+ const raw = params.get(key);
8523
+ if (raw === null) continue;
8524
+ const ms = Date.parse(raw);
8525
+ if (Number.isNaN(ms)) {
8526
+ throw new SearchQueryError(`Invalid ${key}: expected an ISO 8601 date`, "invalid_filter");
8527
+ }
8528
+ filters[field] = ms;
8529
+ }
8530
+ if (filters.since != null && filters.until != null && filters.since > filters.until) {
8531
+ throw new SearchQueryError("`since` must not be after `until`", "invalid_filter");
8532
+ }
8533
+ return { q, limit, offset, filters };
8534
+ }
8535
+ function applyFilters(results, filters) {
8536
+ return results.filter((r) => {
8537
+ if (filters.provider && r.provider !== filters.provider) return false;
8538
+ if (filters.projectPath && r.projectPath !== filters.projectPath) return false;
8539
+ if (filters.branch && r.branch !== filters.branch) return false;
8540
+ if (filters.since != null || filters.until != null) {
8541
+ const ts = r.lastActivity == null ? Number.NaN : new Date(r.lastActivity).getTime();
8542
+ if (Number.isNaN(ts)) return false;
8543
+ if (filters.since != null && ts < filters.since) return false;
8544
+ if (filters.until != null && ts > filters.until) return false;
8545
+ }
8546
+ return true;
8547
+ });
8548
+ }
8549
+ function paginate(results, offset, limit) {
8550
+ const items = results.slice(offset, offset + limit);
8551
+ return {
8552
+ items,
8553
+ total: results.length,
8554
+ offset,
8555
+ hasMore: offset + items.length < results.length
8556
+ };
8557
+ }
8558
+
8054
8559
  // src/services/sessions/conversationBusy.ts
8055
8560
  import { statSync as statSync8 } from "fs";
8056
8561
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -8198,6 +8703,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8198
8703
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8199
8704
  }
8200
8705
 
8706
+ // src/services/sessions/rehydrateSessions.ts
8707
+ var REHYDRATE_MAX = 25;
8708
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8709
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8710
+ function shouldRehydrate(row, opts) {
8711
+ if (!opts.projectExists(row.project_path)) return false;
8712
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8713
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8714
+ return true;
8715
+ }
8716
+ function rowToStubSession(row) {
8717
+ return {
8718
+ id: row.session_id,
8719
+ provider: row.provider,
8720
+ projectPath: row.project_path,
8721
+ projectName: row.project_name,
8722
+ branch: row.branch,
8723
+ // No PTY exists for a stub, so this is the only truthful status.
8724
+ status: "idle",
8725
+ startedAt: new Date(row.started_at),
8726
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8727
+ promptCount: row.prompt_count,
8728
+ lastOutput: "",
8729
+ rehydrated: true,
8730
+ ...row.session_name != null && { sessionName: row.session_name },
8731
+ ...row.project_id != null && { projectId: row.project_id },
8732
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8733
+ ...row.resumed_from_conversation_id != null && {
8734
+ resumedFromConversationId: row.resumed_from_conversation_id
8735
+ },
8736
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
8737
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8738
+ // Only `shutdown` crosses over. It is the one registry source that is also a
8739
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
8740
+ // streamer stopped this session. A crashed row still says `transition` over
8741
+ // a `running` status, and copying that here would attach observed-confidence
8742
+ // provenance to a status we derived at boot, so leave it unset instead.
8743
+ ...row.status_source === "shutdown" && {
8744
+ statusSource: "shutdown",
8745
+ statusUpdatedAt: new Date(row.status_updated_at)
8746
+ }
8747
+ };
8748
+ }
8749
+
8201
8750
  // src/types.ts
8202
8751
  function confidenceForSource(source) {
8203
8752
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8345,14 +8894,15 @@ function managedToResponse(s, ptyAttached) {
8345
8894
  // Lifecycle for a session this run knows about. `attached` while we hold
8346
8895
  // its PTY; once the PTY is gone the session is terminal from this run's
8347
8896
  // perspective — `failed` when it recorded a reason, else `completed`.
8348
- // Sessions left by *previous* runs never reach here: they aren't in the
8349
- // in-memory store, and the boot reconciler classifies them instead
8350
- // (docs/architecture/2026-07-24-durable-session-runtime.md).
8351
- lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
8352
- lifecycleSource: ptyAttached ? "spawn" : "exit",
8897
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
8898
+ // the durable registry, so it is a previous run's session with no process
8899
+ // behind it — `resumable`, and `historical` rather than `managed`
8900
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
8901
+ lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
8902
+ lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
8353
8903
  // We spawned it, so `status` is the authoritative signal — no inferred
8354
8904
  // `activity` is attached for managed sessions.
8355
- ownership: "managed",
8905
+ ownership: s.rehydrated ? "historical" : "managed",
8356
8906
  projectPath: s.projectPath,
8357
8907
  projectName: s.projectName,
8358
8908
  branch: s.branch,
@@ -8753,6 +9303,8 @@ var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
8753
9303
  var GRACE_MAX_DEFERS = 4;
8754
9304
  var IDLE_REAP_AFTER_MS = 6 * 60 * 60 * 1e3;
8755
9305
  var IDLE_REAP_SWEEP_MS = 5 * 60 * 1e3;
9306
+ var SEARCH_OVERFETCH = 4;
9307
+ var SEARCH_MAX_SCAN = 1e3;
8756
9308
  var RESUME_DISCOVERY_TIMEOUT_MS = 750;
8757
9309
  var DISCOVERY_TTL_MS = 15e3;
8758
9310
  var ADOPT_KILL_TIMEOUT_MS = 5e3;
@@ -8940,10 +9492,13 @@ var StreamerServer = class {
8940
9492
  projectsRepo = null;
8941
9493
  conversationsRepo = null;
8942
9494
  sessionsRepo = null;
8943
- // Durable session registry (C1 Phase 2). Null when the cache DB failed to
8944
- // open — persistence degrades to today's in-memory-only behaviour rather than
8945
- // taking the server down with it, so every write goes through `?.`.
9495
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
9496
+ // — persistence degrades to today's in-memory-only behaviour rather than
9497
+ // taking the server down with it, so every write goes through `?.`. Note the
9498
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
9499
+ // null this repo and silently disable all session persistence.
8946
9500
  managedSessionsRepo = null;
9501
+ runtimeStore = null;
8947
9502
  // Identifies this streamer run. A registry row carrying a different id is a
8948
9503
  // session that outlived the process that started it.
8949
9504
  streamerInstanceId = randomUUID5();
@@ -8962,6 +9517,7 @@ var StreamerServer = class {
8962
9517
  liveActivityRenewal = null;
8963
9518
  discoveryCache = null;
8964
9519
  cacheDir;
9520
+ runtimeDbPath;
8965
9521
  tailSize;
8966
9522
  directoryDebounceMs;
8967
9523
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9008,6 +9564,7 @@ var StreamerServer = class {
9008
9564
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9009
9565
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9010
9566
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
9567
+ this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join18(process.env.THREADBASE_CONFIG_DIR ?? join18(homedir9(), ".threadbase"), "runtime.db");
9011
9568
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9012
9569
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9013
9570
  this.markScannerStaleDebounced = debounce(() => {
@@ -9178,6 +9735,7 @@ var StreamerServer = class {
9178
9735
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9179
9736
  },
9180
9737
  onStatusChange: (session) => {
9738
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9181
9739
  this.sessionStore.updateManaged(session.id, {
9182
9740
  status: session.status,
9183
9741
  completedAt: session.completedAt,
@@ -9232,7 +9790,7 @@ var StreamerServer = class {
9232
9790
  if (resp) {
9233
9791
  this.wsHub.broadcast({ type: "session_update", session: resp });
9234
9792
  }
9235
- void this.liveActivityNotifier?.onStatusChange(session);
9793
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9236
9794
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9237
9795
  }
9238
9796
  });
@@ -9283,6 +9841,7 @@ var StreamerServer = class {
9283
9841
  conversationsRepo: () => this.conversationsRepo,
9284
9842
  sessionsRepo: () => this.sessionsRepo,
9285
9843
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9844
+ runtimeStore: () => this.runtimeStore,
9286
9845
  ptyAttachedIds: () => this.ptyAttachedIds(),
9287
9846
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9288
9847
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -9521,14 +10080,14 @@ var StreamerServer = class {
9521
10080
  }
9522
10081
  this.apnsClient = new ApnsClient(creds);
9523
10082
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9524
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname2();
9525
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname2());
10083
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname3();
10084
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname3());
9526
10085
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9527
10086
  repo: pushRepo,
9528
10087
  sender,
9529
10088
  sessionStore: this.sessionStore,
9530
10089
  serverId,
9531
- serverLabel: hostname2()
10090
+ serverLabel: hostname3()
9532
10091
  });
9533
10092
  this.liveActivityRenewal.start();
9534
10093
  this.log.info("Live Activity push enabled", {
@@ -9582,6 +10141,58 @@ var StreamerServer = class {
9582
10141
  }
9583
10142
  return verdicts;
9584
10143
  }
10144
+ /**
10145
+ * Seed the session list with what previous runs left behind (persistence plan
10146
+ * Phase 1, gaps G1/G2/G8).
10147
+ *
10148
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
10149
+ * SessionResponse that already exists, and after a clean restart none does —
10150
+ * `SessionStore` starts empty. So the user's session did not become
10151
+ * `resumable`, it became *absent*. This is the half that puts it back.
10152
+ *
10153
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
10154
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
10155
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
10156
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
10157
+ * by id rather than duplicating it.
10158
+ */
10159
+ rehydratePreviousSessions(verdicts) {
10160
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
10161
+ try {
10162
+ const now = Date.now();
10163
+ const rows = this.managedSessionsRepo.listRecoverable({
10164
+ sinceMs: now - REHYDRATE_WINDOW_MS,
10165
+ limit: REHYDRATE_MAX + 1
10166
+ });
10167
+ const truncated = rows.length > REHYDRATE_MAX;
10168
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10169
+ if (candidates.length === 0) return;
10170
+ const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10171
+ let rehydrated = 0;
10172
+ for (const row of candidates) {
10173
+ if (this.sessionStore.getManaged(row.session_id)) continue;
10174
+ if (!shouldRehydrate(row, { now, projectExists: existsSync11 })) continue;
10175
+ this.sessionStore.addManaged(rowToStubSession(row));
10176
+ this.sessionLifecycles.set(
10177
+ row.session_id,
10178
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
10179
+ );
10180
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10181
+ rehydrated++;
10182
+ }
10183
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
10184
+ event: "sessions.rehydrated",
10185
+ rehydrated,
10186
+ skipped: candidates.length - rehydrated,
10187
+ truncated
10188
+ });
10189
+ } catch (err) {
10190
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10191
+ event: "sessions.rehydrate_failed",
10192
+ err
10193
+ });
10194
+ }
10195
+ }
9585
10196
  /**
9586
10197
  * Pick a token guaranteed to appear in the spawned process's argv, for the
9587
10198
  * reconciler's pid-reuse guard.
@@ -9815,6 +10426,17 @@ var StreamerServer = class {
9815
10426
  port,
9816
10427
  event: "server.listening"
9817
10428
  });
10429
+ try {
10430
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
10431
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
10432
+ } catch (err) {
10433
+ const message = err instanceof Error ? err.message : String(err);
10434
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
10435
+ this.log.error(
10436
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
10437
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10438
+ );
10439
+ }
9818
10440
  try {
9819
10441
  this.cache = ConversationCache.open(
9820
10442
  join18(this.cacheDir, "cache.db"),
@@ -9842,8 +10464,20 @@ var StreamerServer = class {
9842
10464
  this.projectsRepo = new ProjectsRepository(db);
9843
10465
  this.conversationsRepo = new ConversationsRepository(this.cache);
9844
10466
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
9845
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
9846
- void this.reconcilePreviousSessions();
10467
+ try {
10468
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
10469
+ if (copied > 0) {
10470
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
10471
+ copied,
10472
+ event: "runtime.legacy_import"
10473
+ });
10474
+ }
10475
+ } catch (err) {
10476
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
10477
+ event: "runtime.legacy_import_failed",
10478
+ err
10479
+ });
10480
+ }
9847
10481
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
9848
10482
  this.pushRepo = new PushRepository(db);
9849
10483
  this.devicesRepo = new DevicesRepository(db);
@@ -9867,7 +10501,7 @@ var StreamerServer = class {
9867
10501
  this.fileWatcher.watchDirectory(dir);
9868
10502
  }
9869
10503
  for (const dir of this.codexRoots) {
9870
- if (!existsSync10(dir)) continue;
10504
+ if (!existsSync11(dir)) continue;
9871
10505
  this.fileWatcher.watchDirectory(dir);
9872
10506
  }
9873
10507
  } catch (err) {
@@ -9879,6 +10513,7 @@ var StreamerServer = class {
9879
10513
  );
9880
10514
  this.scannerPersistenceDisabled = true;
9881
10515
  }
10516
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
9882
10517
  if (this.skipStartupWarmup) {
9883
10518
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9884
10519
  event: "cache.warmup_skipped"
@@ -10086,6 +10721,7 @@ var StreamerServer = class {
10086
10721
  this.allScanners.clear();
10087
10722
  this.scanner = null;
10088
10723
  this.cache?.close();
10724
+ this.runtimeStore?.close();
10089
10725
  this.ptyManager.dispose();
10090
10726
  this.fileWatcher.dispose();
10091
10727
  this.externalTails.clear();
@@ -10140,7 +10776,7 @@ var StreamerServer = class {
10140
10776
  }
10141
10777
  let body;
10142
10778
  try {
10143
- body = await readBody(req);
10779
+ body = await readBody2(req);
10144
10780
  } catch (err) {
10145
10781
  const message = err instanceof Error ? err.message : "Invalid body";
10146
10782
  json(res, 400, { error: message });
@@ -10186,7 +10822,7 @@ var StreamerServer = class {
10186
10822
  nonce: sealed.nonce,
10187
10823
  ephemeralPublicKey: sealed.ephemeralPublicKey,
10188
10824
  publicUrl: this.publicUrl,
10189
- machineName: hostname2(),
10825
+ machineName: hostname3(),
10190
10826
  ...device && {
10191
10827
  deviceId: device.deviceId,
10192
10828
  deviceToken: device.deviceToken,
@@ -10533,7 +11169,8 @@ var StreamerServer = class {
10533
11169
  }
10534
11170
  handleSessionsCount(res) {
10535
11171
  if (this.rejectIfWarmingUp(res)) return;
10536
- json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
11172
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
11173
+ json(res, 200, { total });
10537
11174
  }
10538
11175
  handleGetRecentSessions(url, res) {
10539
11176
  if (this.rejectIfWarmingUp(res)) return;
@@ -10721,15 +11358,15 @@ var StreamerServer = class {
10721
11358
  findJsonlPath(uuid) {
10722
11359
  const filename = `${uuid}.jsonl`;
10723
11360
  for (const projectsDir of this.projectsDirs()) {
10724
- if (!existsSync10(projectsDir)) continue;
11361
+ if (!existsSync11(projectsDir)) continue;
10725
11362
  for (const dir of readdirSync6(projectsDir)) {
10726
11363
  const fp = join18(projectsDir, dir, filename);
10727
- if (existsSync10(fp)) return fp;
11364
+ if (existsSync11(fp)) return fp;
10728
11365
  const projectDir = join18(projectsDir, dir);
10729
11366
  try {
10730
11367
  for (const sub of readdirSync6(projectDir)) {
10731
11368
  const subagentPath = join18(projectDir, sub, "subagents", filename);
10732
- if (existsSync10(subagentPath)) return subagentPath;
11369
+ if (existsSync11(subagentPath)) return subagentPath;
10733
11370
  }
10734
11371
  } catch {
10735
11372
  }
@@ -11266,7 +11903,7 @@ var StreamerServer = class {
11266
11903
  }
11267
11904
  let body;
11268
11905
  try {
11269
- body = await readBody(req);
11906
+ body = await readBody2(req);
11270
11907
  } catch {
11271
11908
  res.setHeader("Accept-Query", "application/json");
11272
11909
  json(res, 422, { error: "Malformed JSON body", code: "invalid_query" });
@@ -11304,17 +11941,27 @@ var StreamerServer = class {
11304
11941
  });
11305
11942
  }
11306
11943
  async handleSearch(url, res) {
11307
- const q = url.searchParams.get("q") ?? "";
11308
- if (!q) {
11309
- json(res, 400, { error: "Missing query parameter: q" });
11310
- return;
11944
+ let parsed;
11945
+ try {
11946
+ parsed = parseSearchQuery(url.searchParams);
11947
+ } catch (err) {
11948
+ if (err instanceof SearchQueryError) {
11949
+ json(res, 400, { error: err.message, code: err.code });
11950
+ return;
11951
+ }
11952
+ throw err;
11311
11953
  }
11312
- const limit = intParam(url, "limit", 50);
11954
+ const { q, limit, offset, filters } = parsed;
11955
+ const startedAt = Date.now();
11313
11956
  const scanner = await this.getScanner();
11314
11957
  const results = await search(
11315
11958
  q,
11316
11959
  {
11317
- limit,
11960
+ // Fetch beyond the requested page: filters below are applied AFTER the
11961
+ // scanner returns, so slicing at `limit` here would drop results that a
11962
+ // later page should contain. Bounded so a broad query cannot pull an
11963
+ // unbounded set into memory.
11964
+ limit: Math.min(offset + limit * SEARCH_OVERFETCH, SEARCH_MAX_SCAN),
11318
11965
  include: "conversations",
11319
11966
  ...this.scanProfiles ? { profiles: this.scanProfiles } : {},
11320
11967
  ...this.codexScanOpts()
@@ -11338,13 +11985,24 @@ var StreamerServer = class {
11338
11985
  lastActivity: r.meta.timestamp,
11339
11986
  firstMessage: r.meta.firstMessage ?? void 0,
11340
11987
  lastMessage: r.meta.lastMessage ?? void 0,
11341
- provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER
11988
+ provider: r.meta.provider ?? CLAUDE_CODE_PROVIDER,
11989
+ // The scanner already computes relevance and match snippets; the previous
11990
+ // adapter discarded both, so results arrived in an unexplained order with
11991
+ // no indication of WHY anything matched.
11992
+ score: r.score,
11993
+ matches: Array.isArray(r.matches) ? r.matches.map((m) => ({
11994
+ field: m.field,
11995
+ snippet: m.snippet
11996
+ })) : []
11342
11997
  }));
11998
+ const page = paginate(applyFilters(adapted, filters), offset, limit);
11343
11999
  json(res, 200, {
11344
- conversations: adapted,
11345
- hasMore: false,
11346
- offset: 0,
11347
- total: adapted.length
12000
+ conversations: page.items,
12001
+ hasMore: page.hasMore,
12002
+ offset: page.offset,
12003
+ total: page.total,
12004
+ // Query timing, so a slow search is diagnosable rather than merely felt.
12005
+ tookMs: Date.now() - startedAt
11348
12006
  });
11349
12007
  }
11350
12008
  async handleListSessions(url, res) {
@@ -11390,7 +12048,7 @@ var StreamerServer = class {
11390
12048
  if (this.rejectIfWarmingUp(res)) return;
11391
12049
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
11392
12050
  if (session) {
11393
- if (!existsSync10(session.projectPath)) {
12051
+ if (!existsSync11(session.projectPath)) {
11394
12052
  session.failureReason = `Project directory not found: ${session.projectPath}`;
11395
12053
  }
11396
12054
  const reconciled = this.withReconciledLifecycle([session])[0];
@@ -11417,7 +12075,7 @@ var StreamerServer = class {
11417
12075
  json(res, 404, { error: "Session not found" });
11418
12076
  }
11419
12077
  async handleResume(req, res) {
11420
- const body = await readBody(req);
12078
+ const body = await readBody2(req);
11421
12079
  const sessionId = body.sessionId ?? body.conversationId;
11422
12080
  if (!sessionId) {
11423
12081
  json(res, 400, { error: "Missing sessionId" });
@@ -11549,7 +12207,7 @@ var StreamerServer = class {
11549
12207
  return;
11550
12208
  }
11551
12209
  if (this.agentConfig.enabled) {
11552
- const body2 = await readBody(req);
12210
+ const body2 = await readBody2(req);
11553
12211
  const cache = this.cache;
11554
12212
  if (!cache) {
11555
12213
  json(res, 503, {
@@ -11568,7 +12226,7 @@ var StreamerServer = class {
11568
12226
  json(res, result.status, result.body);
11569
12227
  return;
11570
12228
  }
11571
- const body = await readBody(req);
12229
+ const body = await readBody2(req);
11572
12230
  const { input, keys } = body;
11573
12231
  let idempotencyKey;
11574
12232
  try {
@@ -11732,7 +12390,7 @@ var StreamerServer = class {
11732
12390
  });
11733
12391
  }
11734
12392
  async handleSendAnswer(sessionId, req, res) {
11735
- const body = await readBody(req);
12393
+ const body = await readBody2(req);
11736
12394
  const pending = this.pendingQuestions.get(sessionId);
11737
12395
  const resolution = resolveAnswer(pending, body);
11738
12396
  if (!resolution.ok) {
@@ -11761,7 +12419,7 @@ var StreamerServer = class {
11761
12419
  json(res, 400, { error: "Session has no project path" });
11762
12420
  return;
11763
12421
  }
11764
- const body = await readBody(req);
12422
+ const body = await readBody2(req);
11765
12423
  const { filename, mimeType, dataBase64 } = body ?? {};
11766
12424
  if (typeof filename !== "string" || typeof mimeType !== "string" || typeof dataBase64 !== "string") {
11767
12425
  json(res, 400, { error: "Missing filename, mimeType, or dataBase64" });
@@ -11963,7 +12621,7 @@ var StreamerServer = class {
11963
12621
  return;
11964
12622
  }
11965
12623
  if (this.agentConfig.enabled) {
11966
- const body2 = await readBody(req);
12624
+ const body2 = await readBody2(req);
11967
12625
  const result = await handleStartAgentSession(body2, {
11968
12626
  sessionStore: this.sessionStore,
11969
12627
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
@@ -11977,7 +12635,7 @@ var StreamerServer = class {
11977
12635
  }
11978
12636
  return;
11979
12637
  }
11980
- const body = await readBody(req);
12638
+ const body = await readBody2(req);
11981
12639
  const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
11982
12640
  if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
11983
12641
  json(res, 400, { error: "Invalid provider" });
@@ -12149,8 +12807,8 @@ var StreamerServer = class {
12149
12807
  cleanup();
12150
12808
  return;
12151
12809
  }
12152
- let resolvedFilePath = existsSync10(filePath) ? filePath : null;
12153
- if (!resolvedFilePath && existsSync10(projectsDir)) {
12810
+ let resolvedFilePath = existsSync11(filePath) ? filePath : null;
12811
+ if (!resolvedFilePath && existsSync11(projectsDir)) {
12154
12812
  try {
12155
12813
  const now = Date.now();
12156
12814
  const match = readdirSync6(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync9(join18(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
@@ -12246,7 +12904,7 @@ var StreamerServer = class {
12246
12904
  );
12247
12905
  for (const root of this.codexRoots) {
12248
12906
  const sessionsDir = join18(root, dateDir);
12249
- if (!existsSync10(sessionsDir)) continue;
12907
+ if (!existsSync11(sessionsDir)) continue;
12250
12908
  let candidateFiles;
12251
12909
  try {
12252
12910
  candidateFiles = readdirSync6(sessionsDir).filter((f) => f.endsWith(".jsonl"));
@@ -12335,7 +12993,7 @@ var StreamerServer = class {
12335
12993
  });
12336
12994
  return;
12337
12995
  }
12338
- const body = await readBody(req);
12996
+ const body = await readBody2(req);
12339
12997
  const { path: relativePath, name } = body;
12340
12998
  if (!name || typeof name !== "string") {
12341
12999
  json(res, 400, { error: "Missing name field" });
@@ -12365,7 +13023,7 @@ var StreamerServer = class {
12365
13023
  }
12366
13024
  let parsed;
12367
13025
  try {
12368
- parsed = await readBody(req);
13026
+ parsed = await readBody2(req);
12369
13027
  } catch {
12370
13028
  json(res, 400, { error: "Invalid JSON" });
12371
13029
  return;
@@ -12422,7 +13080,7 @@ var StreamerServer = class {
12422
13080
  }
12423
13081
  let parsed;
12424
13082
  try {
12425
- parsed = await readBody(req);
13083
+ parsed = await readBody2(req);
12426
13084
  } catch {
12427
13085
  json(res, 400, { error: "Invalid JSON" });
12428
13086
  return;
@@ -12481,7 +13139,7 @@ async function waitForProcessExit(pid, timeoutMs, pollMs = ADOPT_KILL_POLL_MS) {
12481
13139
  }
12482
13140
  function classifyResumability(cwd) {
12483
13141
  if (!cwd) return { resumable: true };
12484
- if (existsSync10(cwd)) return { resumable: true };
13142
+ if (existsSync11(cwd)) return { resumable: true };
12485
13143
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
12486
13144
  return {
12487
13145
  resumable: false,
@@ -12599,7 +13257,7 @@ function parseSessionListQuery(url) {
12599
13257
  const cursor = url.searchParams.get("cursor") ?? void 0;
12600
13258
  return { query: { limit, sortBy, order, status, cursor } };
12601
13259
  }
12602
- function readBody(req) {
13260
+ function readBody2(req) {
12603
13261
  return new Promise((resolve2, reject) => {
12604
13262
  const chunks = [];
12605
13263
  req.on("data", (chunk) => chunks.push(chunk));