@threadbase-sh/streamer 1.40.0 → 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) {
@@ -5141,6 +5159,9 @@ function getMigrationsDir2() {
5141
5159
  }
5142
5160
  return __dirname;
5143
5161
  }
5162
+ function resolveMigrationsDir(name = "migrations") {
5163
+ return join12(getMigrationsDir2(), name);
5164
+ }
5144
5165
  var SCHEMA_MIGRATIONS_SQL = `
5145
5166
  CREATE TABLE IF NOT EXISTS schema_migrations (
5146
5167
  id TEXT PRIMARY KEY,
@@ -5149,7 +5170,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
5149
5170
  `;
5150
5171
  function runSqliteMigrations(db, migrationsDir) {
5151
5172
  db.exec(SCHEMA_MIGRATIONS_SQL);
5152
- const dir = migrationsDir ?? join12(getMigrationsDir2(), "migrations");
5173
+ const dir = migrationsDir ?? resolveMigrationsDir();
5153
5174
  const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
5154
5175
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
5155
5176
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -6560,6 +6581,7 @@ var ManagedSessionsRepository = class {
6560
6581
  updateStatusStmt;
6561
6582
  getStmt;
6562
6583
  listNonTerminalStmt;
6584
+ listRecoverableStmt;
6563
6585
  deleteStmt;
6564
6586
  constructor(db) {
6565
6587
  this.upsertStmt = db.prepare(`
@@ -6612,6 +6634,13 @@ var ManagedSessionsRepository = class {
6612
6634
  WHERE completed_at IS NULL
6613
6635
  ORDER BY started_at ASC
6614
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
+ `);
6615
6644
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6616
6645
  }
6617
6646
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6663,6 +6692,14 @@ var ManagedSessionsRepository = class {
6663
6692
  listNonTerminal() {
6664
6693
  return this.listNonTerminalStmt.all();
6665
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
+ }
6666
6703
  delete(sessionId) {
6667
6704
  this.deleteStmt.run(sessionId);
6668
6705
  }
@@ -6798,6 +6835,54 @@ var SessionsRepository = class {
6798
6835
  }
6799
6836
  };
6800
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
+
6801
6886
  // src/db/upload-records.ts
6802
6887
  async function recordUpload(pool2, instanceId, row) {
6803
6888
  if (!pool2) return;
@@ -7815,7 +7900,8 @@ function contentStateForSession(args) {
7815
7900
  status,
7816
7901
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7817
7902
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7818
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7903
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7904
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7819
7905
  };
7820
7906
  }
7821
7907
  var LiveActivityNotifier = class {
@@ -7828,14 +7914,17 @@ var LiveActivityNotifier = class {
7828
7914
  serverId;
7829
7915
  serverLabel;
7830
7916
  /**
7831
- * Last status pushed per session.
7917
+ * Sessions with a currently open (pushed) activity.
7832
7918
  *
7833
- * Live Activity pushes are rate-limited by iOS and the surface only renders
7834
- * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7835
- * budget spend for no visible change. This is what makes the notifier
7836
- * 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.
7837
7926
  */
7838
- lastPushed = /* @__PURE__ */ new Map();
7927
+ openActivity = /* @__PURE__ */ new Map();
7839
7928
  /**
7840
7929
  * React to a session status change.
7841
7930
  *
@@ -7843,34 +7932,22 @@ var LiveActivityNotifier = class {
7843
7932
  * transition, so this returns a promise the caller may ignore and every error
7844
7933
  * is logged rather than propagated.
7845
7934
  */
7846
- async onStatusChange(session) {
7935
+ async onStatusChange(session, previousStatus) {
7847
7936
  const status = toLiveActivityStatus(session.status);
7848
7937
  try {
7849
7938
  if (!status) {
7850
- await this.endFor(session);
7939
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7851
7940
  return;
7852
7941
  }
7853
- if (this.lastPushed.get(session.id) === status) return;
7854
- const contentState = contentStateForSession({
7855
- session,
7856
- serverId: this.serverId,
7857
- serverLabel: this.serverLabel
7858
- });
7859
- if (!contentState) return;
7860
- const outcome = await this.sender.send({
7861
- sessionId: session.id,
7862
- event: "update",
7863
- contentState
7864
- });
7865
- this.lastPushed.set(session.id, status);
7866
- if (outcome.attempted > 0) {
7867
- log4.info("live_activity.updated", {
7868
- event: "live_activity.updated",
7869
- sessionId: session.id,
7870
- status,
7871
- ...outcome
7872
- });
7942
+ if (status === "running" && previousStatus === "waiting_input") {
7943
+ await this.startTurn(session);
7944
+ return;
7873
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);
7874
7951
  } catch (err) {
7875
7952
  log4.error("live_activity.notify_failed", {
7876
7953
  event: "live_activity.notify_failed",
@@ -7880,14 +7957,57 @@ var LiveActivityNotifier = class {
7880
7957
  });
7881
7958
  }
7882
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
+ }
7883
8006
  async endFor(session) {
7884
- const lastStatus = this.lastPushed.get(session.id);
7885
- this.lastPushed.delete(session.id);
8007
+ this.openActivity.delete(session.id);
8008
+ const status = toLiveActivityStatus(session.status);
7886
8009
  const contentState = contentStateForSession({
7887
- session: {
7888
- ...session,
7889
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7890
- },
8010
+ session: { ...session, status: status ?? "waiting_input" },
7891
8011
  serverId: this.serverId,
7892
8012
  serverLabel: this.serverLabel
7893
8013
  });
@@ -7901,9 +8021,9 @@ var LiveActivityNotifier = class {
7901
8021
  });
7902
8022
  }
7903
8023
  }
7904
- /** 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. */
7905
8025
  forget(sessionId) {
7906
- this.lastPushed.delete(sessionId);
8026
+ this.openActivity.delete(sessionId);
7907
8027
  }
7908
8028
  };
7909
8029
 
@@ -8134,7 +8254,8 @@ var LiveActivityRenewalScheduler = class {
8134
8254
  status,
8135
8255
  startedAt,
8136
8256
  lastOutput: session.lastOutput ?? "",
8137
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8257
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8258
+ ...session.sessionName != null && { sessionName: session.sessionName }
8138
8259
  };
8139
8260
  try {
8140
8261
  await this.deps.sender.send({
@@ -8194,7 +8315,8 @@ var LiveActivityRenewalScheduler = class {
8194
8315
  // Carried through unchanged — the whole point of the renewal.
8195
8316
  startedAt: args.startedAt,
8196
8317
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
8197
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8318
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8319
+ ...session.sessionName != null && { sessionName: session.sessionName }
8198
8320
  },
8199
8321
  now: args.now,
8200
8322
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8581,6 +8703,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8581
8703
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8582
8704
  }
8583
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
+
8584
8750
  // src/types.ts
8585
8751
  function confidenceForSource(source) {
8586
8752
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8728,14 +8894,15 @@ function managedToResponse(s, ptyAttached) {
8728
8894
  // Lifecycle for a session this run knows about. `attached` while we hold
8729
8895
  // its PTY; once the PTY is gone the session is terminal from this run's
8730
8896
  // perspective — `failed` when it recorded a reason, else `completed`.
8731
- // Sessions left by *previous* runs never reach here: they aren't in the
8732
- // in-memory store, and the boot reconciler classifies them instead
8733
- // (docs/architecture/2026-07-24-durable-session-runtime.md).
8734
- lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
8735
- 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",
8736
8903
  // We spawned it, so `status` is the authoritative signal — no inferred
8737
8904
  // `activity` is attached for managed sessions.
8738
- ownership: "managed",
8905
+ ownership: s.rehydrated ? "historical" : "managed",
8739
8906
  projectPath: s.projectPath,
8740
8907
  projectName: s.projectName,
8741
8908
  branch: s.branch,
@@ -9325,10 +9492,13 @@ var StreamerServer = class {
9325
9492
  projectsRepo = null;
9326
9493
  conversationsRepo = null;
9327
9494
  sessionsRepo = null;
9328
- // Durable session registry (C1 Phase 2). Null when the cache DB failed to
9329
- // open — persistence degrades to today's in-memory-only behaviour rather than
9330
- // 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.
9331
9500
  managedSessionsRepo = null;
9501
+ runtimeStore = null;
9332
9502
  // Identifies this streamer run. A registry row carrying a different id is a
9333
9503
  // session that outlived the process that started it.
9334
9504
  streamerInstanceId = randomUUID5();
@@ -9347,6 +9517,7 @@ var StreamerServer = class {
9347
9517
  liveActivityRenewal = null;
9348
9518
  discoveryCache = null;
9349
9519
  cacheDir;
9520
+ runtimeDbPath;
9350
9521
  tailSize;
9351
9522
  directoryDebounceMs;
9352
9523
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9393,6 +9564,7 @@ var StreamerServer = class {
9393
9564
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9394
9565
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9395
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");
9396
9568
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9397
9569
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9398
9570
  this.markScannerStaleDebounced = debounce(() => {
@@ -9563,6 +9735,7 @@ var StreamerServer = class {
9563
9735
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9564
9736
  },
9565
9737
  onStatusChange: (session) => {
9738
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9566
9739
  this.sessionStore.updateManaged(session.id, {
9567
9740
  status: session.status,
9568
9741
  completedAt: session.completedAt,
@@ -9617,7 +9790,7 @@ var StreamerServer = class {
9617
9790
  if (resp) {
9618
9791
  this.wsHub.broadcast({ type: "session_update", session: resp });
9619
9792
  }
9620
- void this.liveActivityNotifier?.onStatusChange(session);
9793
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9621
9794
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9622
9795
  }
9623
9796
  });
@@ -9668,6 +9841,7 @@ var StreamerServer = class {
9668
9841
  conversationsRepo: () => this.conversationsRepo,
9669
9842
  sessionsRepo: () => this.sessionsRepo,
9670
9843
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9844
+ runtimeStore: () => this.runtimeStore,
9671
9845
  ptyAttachedIds: () => this.ptyAttachedIds(),
9672
9846
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9673
9847
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -9967,6 +10141,58 @@ var StreamerServer = class {
9967
10141
  }
9968
10142
  return verdicts;
9969
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
+ }
9970
10196
  /**
9971
10197
  * Pick a token guaranteed to appear in the spawned process's argv, for the
9972
10198
  * reconciler's pid-reuse guard.
@@ -10200,6 +10426,17 @@ var StreamerServer = class {
10200
10426
  port,
10201
10427
  event: "server.listening"
10202
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
+ }
10203
10440
  try {
10204
10441
  this.cache = ConversationCache.open(
10205
10442
  join18(this.cacheDir, "cache.db"),
@@ -10227,8 +10464,20 @@ var StreamerServer = class {
10227
10464
  this.projectsRepo = new ProjectsRepository(db);
10228
10465
  this.conversationsRepo = new ConversationsRepository(this.cache);
10229
10466
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
10230
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
10231
- 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
+ }
10232
10481
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
10233
10482
  this.pushRepo = new PushRepository(db);
10234
10483
  this.devicesRepo = new DevicesRepository(db);
@@ -10264,6 +10513,7 @@ var StreamerServer = class {
10264
10513
  );
10265
10514
  this.scannerPersistenceDisabled = true;
10266
10515
  }
10516
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10267
10517
  if (this.skipStartupWarmup) {
10268
10518
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10269
10519
  event: "cache.warmup_skipped"
@@ -10471,6 +10721,7 @@ var StreamerServer = class {
10471
10721
  this.allScanners.clear();
10472
10722
  this.scanner = null;
10473
10723
  this.cache?.close();
10724
+ this.runtimeStore?.close();
10474
10725
  this.ptyManager.dispose();
10475
10726
  this.fileWatcher.dispose();
10476
10727
  this.externalTails.clear();
@@ -10918,7 +11169,8 @@ var StreamerServer = class {
10918
11169
  }
10919
11170
  handleSessionsCount(res) {
10920
11171
  if (this.rejectIfWarmingUp(res)) return;
10921
- 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 });
10922
11174
  }
10923
11175
  handleGetRecentSessions(url, res) {
10924
11176
  if (this.rejectIfWarmingUp(res)) return;