@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.cjs CHANGED
@@ -500,6 +500,12 @@ var FEATURE_FLAGS = [
500
500
  description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
501
501
  default: false,
502
502
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
503
+ },
504
+ {
505
+ id: "sessionRehydration",
506
+ description: "Seed the session list at boot with sessions a previous streamer run left behind, so a restart leaves them one tap from resuming instead of silently gone. On by default, with a kill switch: it changes what GET /api/sessions contains.",
507
+ default: true,
508
+ env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
503
509
  }
504
510
  ];
505
511
  function findFeatureFlag(id) {
@@ -1936,6 +1942,12 @@ function detectShellPrompt(lines) {
1936
1942
  return null;
1937
1943
  }
1938
1944
 
1945
+ // src/utils/deriveSessionName.ts
1946
+ function deriveSessionName(firstMessageText) {
1947
+ const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
1948
+ return firstLine.slice(0, 80);
1949
+ }
1950
+
1939
1951
  // src/pty-manager.ts
1940
1952
  var OUTPUT_BUFFER_MAX2 = 65536;
1941
1953
  var INPUT_HISTORY_MAX2 = 50;
@@ -2409,6 +2421,10 @@ var PTYManager = class {
2409
2421
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2410
2422
  session.inputHistory.shift();
2411
2423
  }
2424
+ if (session.firstMessageText === void 0) {
2425
+ session.firstMessageText = text;
2426
+ session.sessionName = deriveSessionName(text);
2427
+ }
2412
2428
  this.onUserMessage?.(session.id, text, ts);
2413
2429
  }
2414
2430
  getSession(sessionId) {
@@ -2674,7 +2690,9 @@ function toPublicSession2(s) {
2674
2690
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2675
2691
  ...s.statusSource != null && { statusSource: s.statusSource },
2676
2692
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2677
- ...s.filePath != null && { filePath: s.filePath }
2693
+ ...s.filePath != null && { filePath: s.filePath },
2694
+ ...s.sessionName != null && { sessionName: s.sessionName },
2695
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
2678
2696
  };
2679
2697
  }
2680
2698
  function stripAnsi2(str) {
@@ -5178,6 +5196,9 @@ function getMigrationsDir2() {
5178
5196
  }
5179
5197
  return __dirname;
5180
5198
  }
5199
+ function resolveMigrationsDir(name = "migrations") {
5200
+ return (0, import_path10.join)(getMigrationsDir2(), name);
5201
+ }
5181
5202
  var SCHEMA_MIGRATIONS_SQL = `
5182
5203
  CREATE TABLE IF NOT EXISTS schema_migrations (
5183
5204
  id TEXT PRIMARY KEY,
@@ -5186,7 +5207,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
5186
5207
  `;
5187
5208
  function runSqliteMigrations(db, migrationsDir) {
5188
5209
  db.exec(SCHEMA_MIGRATIONS_SQL);
5189
- const dir = migrationsDir ?? (0, import_path10.join)(getMigrationsDir2(), "migrations");
5210
+ const dir = migrationsDir ?? resolveMigrationsDir();
5190
5211
  const files = (0, import_fs8.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
5191
5212
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
5192
5213
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -6597,6 +6618,7 @@ var ManagedSessionsRepository = class {
6597
6618
  updateStatusStmt;
6598
6619
  getStmt;
6599
6620
  listNonTerminalStmt;
6621
+ listRecoverableStmt;
6600
6622
  deleteStmt;
6601
6623
  constructor(db) {
6602
6624
  this.upsertStmt = db.prepare(`
@@ -6649,6 +6671,13 @@ var ManagedSessionsRepository = class {
6649
6671
  WHERE completed_at IS NULL
6650
6672
  ORDER BY started_at ASC
6651
6673
  `);
6674
+ this.listRecoverableStmt = db.prepare(`
6675
+ SELECT * FROM managed_sessions
6676
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
6677
+ AND status_updated_at >= @since
6678
+ ORDER BY status_updated_at DESC
6679
+ LIMIT @limit
6680
+ `);
6652
6681
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6653
6682
  }
6654
6683
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6700,6 +6729,14 @@ var ManagedSessionsRepository = class {
6700
6729
  listNonTerminal() {
6701
6730
  return this.listNonTerminalStmt.all();
6702
6731
  }
6732
+ /**
6733
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
6734
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
6735
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
6736
+ */
6737
+ listRecoverable({ sinceMs, limit }) {
6738
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
6739
+ }
6703
6740
  delete(sessionId) {
6704
6741
  this.deleteStmt.run(sessionId);
6705
6742
  }
@@ -6835,6 +6872,54 @@ var SessionsRepository = class {
6835
6872
  }
6836
6873
  };
6837
6874
 
6875
+ // src/db/runtime-store.ts
6876
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
6877
+ var RuntimeStore = class _RuntimeStore {
6878
+ constructor(db) {
6879
+ this.db = db;
6880
+ }
6881
+ db;
6882
+ static open(dbPath, migrationsDir) {
6883
+ const db = new import_better_sqlite32.default(dbPath);
6884
+ db.pragma("journal_mode = WAL");
6885
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
6886
+ return new _RuntimeStore(db);
6887
+ }
6888
+ getDatabase() {
6889
+ return this.db;
6890
+ }
6891
+ /**
6892
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
6893
+ *
6894
+ * Non-destructive by design: the source table is left in place so an older
6895
+ * streamer rolled back onto the same machine still finds its registry. Runs
6896
+ * only when this file's table is empty, so a second boot is a no-op rather
6897
+ * than a re-copy that would resurrect rows deleted since.
6898
+ *
6899
+ * Returns the number of rows copied.
6900
+ */
6901
+ importLegacyManagedSessions(source) {
6902
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
6903
+ if (existing.n > 0) return 0;
6904
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
6905
+ if (!hasTable) return 0;
6906
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
6907
+ if (rows.length === 0) return 0;
6908
+ const columns = Object.keys(rows[0]);
6909
+ const insert = this.db.prepare(
6910
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
6911
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
6912
+ );
6913
+ this.db.transaction((batch) => {
6914
+ for (const row of batch) insert.run(row);
6915
+ })(rows);
6916
+ return rows.length;
6917
+ }
6918
+ close() {
6919
+ this.db.close();
6920
+ }
6921
+ };
6922
+
6838
6923
  // src/db/upload-records.ts
6839
6924
  async function recordUpload(pool2, instanceId, row) {
6840
6925
  if (!pool2) return;
@@ -7852,7 +7937,8 @@ function contentStateForSession(args) {
7852
7937
  status,
7853
7938
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7854
7939
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7855
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7940
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7941
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7856
7942
  };
7857
7943
  }
7858
7944
  var LiveActivityNotifier = class {
@@ -7865,14 +7951,17 @@ var LiveActivityNotifier = class {
7865
7951
  serverId;
7866
7952
  serverLabel;
7867
7953
  /**
7868
- * Last status pushed per session.
7954
+ * Sessions with a currently open (pushed) activity.
7869
7955
  *
7870
- * Live Activity pushes are rate-limited by iOS and the surface only renders
7871
- * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7872
- * budget spend for no visible change. This is what makes the notifier
7873
- * edge-triggered rather than level-triggered.
7956
+ * An activity opens on a `waiting_input running` edge (the user sent a
7957
+ * prompt) and closes on the matching `running waiting_input` edge (the
7958
+ * response, including any sub-agents, finished) so this set is what makes
7959
+ * the notifier per-turn rather than per-session. A session's very first
7960
+ * `running` (right after spawn, before any user prompt) has no prior
7961
+ * `waiting_input` and therefore no edge, so it never opens an activity —
7962
+ * this is what keeps a fresh/idle session from pushing anything.
7874
7963
  */
7875
- lastPushed = /* @__PURE__ */ new Map();
7964
+ openActivity = /* @__PURE__ */ new Map();
7876
7965
  /**
7877
7966
  * React to a session status change.
7878
7967
  *
@@ -7880,34 +7969,22 @@ var LiveActivityNotifier = class {
7880
7969
  * transition, so this returns a promise the caller may ignore and every error
7881
7970
  * is logged rather than propagated.
7882
7971
  */
7883
- async onStatusChange(session) {
7972
+ async onStatusChange(session, previousStatus) {
7884
7973
  const status = toLiveActivityStatus(session.status);
7885
7974
  try {
7886
7975
  if (!status) {
7887
- await this.endFor(session);
7976
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7888
7977
  return;
7889
7978
  }
7890
- if (this.lastPushed.get(session.id) === status) return;
7891
- const contentState = contentStateForSession({
7892
- session,
7893
- serverId: this.serverId,
7894
- serverLabel: this.serverLabel
7895
- });
7896
- if (!contentState) return;
7897
- const outcome = await this.sender.send({
7898
- sessionId: session.id,
7899
- event: "update",
7900
- contentState
7901
- });
7902
- this.lastPushed.set(session.id, status);
7903
- if (outcome.attempted > 0) {
7904
- log4.info("live_activity.updated", {
7905
- event: "live_activity.updated",
7906
- sessionId: session.id,
7907
- status,
7908
- ...outcome
7909
- });
7979
+ if (status === "running" && previousStatus === "waiting_input") {
7980
+ await this.startTurn(session);
7981
+ return;
7910
7982
  }
7983
+ if (status === "waiting_input" && previousStatus === "running") {
7984
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7985
+ return;
7986
+ }
7987
+ await this.maybeSendName(session);
7911
7988
  } catch (err) {
7912
7989
  log4.error("live_activity.notify_failed", {
7913
7990
  event: "live_activity.notify_failed",
@@ -7917,14 +7994,57 @@ var LiveActivityNotifier = class {
7917
7994
  });
7918
7995
  }
7919
7996
  }
7997
+ async startTurn(session) {
7998
+ const contentState = contentStateForSession({
7999
+ session,
8000
+ serverId: this.serverId,
8001
+ serverLabel: this.serverLabel
8002
+ });
8003
+ if (!contentState) return;
8004
+ const outcome = await this.sender.send({
8005
+ sessionId: session.id,
8006
+ event: "update",
8007
+ contentState
8008
+ });
8009
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
8010
+ if (outcome.attempted > 0) {
8011
+ log4.info("live_activity.updated", {
8012
+ event: "live_activity.updated",
8013
+ sessionId: session.id,
8014
+ status: contentState.status,
8015
+ ...outcome
8016
+ });
8017
+ }
8018
+ }
8019
+ async maybeSendName(session) {
8020
+ const open2 = this.openActivity.get(session.id);
8021
+ if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
8022
+ const contentState = contentStateForSession({
8023
+ session,
8024
+ serverId: this.serverId,
8025
+ serverLabel: this.serverLabel
8026
+ });
8027
+ if (!contentState) return;
8028
+ const outcome = await this.sender.send({
8029
+ sessionId: session.id,
8030
+ event: "update",
8031
+ contentState
8032
+ });
8033
+ open2.sessionNameSent = true;
8034
+ if (outcome.attempted > 0) {
8035
+ log4.info("live_activity.updated", {
8036
+ event: "live_activity.updated",
8037
+ sessionId: session.id,
8038
+ status: contentState.status,
8039
+ ...outcome
8040
+ });
8041
+ }
8042
+ }
7920
8043
  async endFor(session) {
7921
- const lastStatus = this.lastPushed.get(session.id);
7922
- this.lastPushed.delete(session.id);
8044
+ this.openActivity.delete(session.id);
8045
+ const status = toLiveActivityStatus(session.status);
7923
8046
  const contentState = contentStateForSession({
7924
- session: {
7925
- ...session,
7926
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7927
- },
8047
+ session: { ...session, status: status ?? "waiting_input" },
7928
8048
  serverId: this.serverId,
7929
8049
  serverLabel: this.serverLabel
7930
8050
  });
@@ -7938,9 +8058,9 @@ var LiveActivityNotifier = class {
7938
8058
  });
7939
8059
  }
7940
8060
  }
7941
- /** Drop cached state for a session, so a resume re-pushes its first status. */
8061
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
7942
8062
  forget(sessionId) {
7943
- this.lastPushed.delete(sessionId);
8063
+ this.openActivity.delete(sessionId);
7944
8064
  }
7945
8065
  };
7946
8066
 
@@ -8171,7 +8291,8 @@ var LiveActivityRenewalScheduler = class {
8171
8291
  status,
8172
8292
  startedAt,
8173
8293
  lastOutput: session.lastOutput ?? "",
8174
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8294
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8295
+ ...session.sessionName != null && { sessionName: session.sessionName }
8175
8296
  };
8176
8297
  try {
8177
8298
  await this.deps.sender.send({
@@ -8231,7 +8352,8 @@ var LiveActivityRenewalScheduler = class {
8231
8352
  // Carried through unchanged — the whole point of the renewal.
8232
8353
  startedAt: args.startedAt,
8233
8354
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
8234
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8355
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8356
+ ...session.sessionName != null && { sessionName: session.sessionName }
8235
8357
  },
8236
8358
  now: args.now,
8237
8359
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8618,6 +8740,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8618
8740
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8619
8741
  }
8620
8742
 
8743
+ // src/services/sessions/rehydrateSessions.ts
8744
+ var REHYDRATE_MAX = 25;
8745
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8746
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8747
+ function shouldRehydrate(row, opts) {
8748
+ if (!opts.projectExists(row.project_path)) return false;
8749
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8750
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8751
+ return true;
8752
+ }
8753
+ function rowToStubSession(row) {
8754
+ return {
8755
+ id: row.session_id,
8756
+ provider: row.provider,
8757
+ projectPath: row.project_path,
8758
+ projectName: row.project_name,
8759
+ branch: row.branch,
8760
+ // No PTY exists for a stub, so this is the only truthful status.
8761
+ status: "idle",
8762
+ startedAt: new Date(row.started_at),
8763
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8764
+ promptCount: row.prompt_count,
8765
+ lastOutput: "",
8766
+ rehydrated: true,
8767
+ ...row.session_name != null && { sessionName: row.session_name },
8768
+ ...row.project_id != null && { projectId: row.project_id },
8769
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8770
+ ...row.resumed_from_conversation_id != null && {
8771
+ resumedFromConversationId: row.resumed_from_conversation_id
8772
+ },
8773
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
8774
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8775
+ // Only `shutdown` crosses over. It is the one registry source that is also a
8776
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
8777
+ // streamer stopped this session. A crashed row still says `transition` over
8778
+ // a `running` status, and copying that here would attach observed-confidence
8779
+ // provenance to a status we derived at boot, so leave it unset instead.
8780
+ ...row.status_source === "shutdown" && {
8781
+ statusSource: "shutdown",
8782
+ statusUpdatedAt: new Date(row.status_updated_at)
8783
+ }
8784
+ };
8785
+ }
8786
+
8621
8787
  // src/types.ts
8622
8788
  function confidenceForSource(source) {
8623
8789
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8765,14 +8931,15 @@ function managedToResponse(s, ptyAttached) {
8765
8931
  // Lifecycle for a session this run knows about. `attached` while we hold
8766
8932
  // its PTY; once the PTY is gone the session is terminal from this run's
8767
8933
  // perspective — `failed` when it recorded a reason, else `completed`.
8768
- // Sessions left by *previous* runs never reach here: they aren't in the
8769
- // in-memory store, and the boot reconciler classifies them instead
8770
- // (docs/architecture/2026-07-24-durable-session-runtime.md).
8771
- lifecycle: ptyAttached ? "attached" : s.failureReason != null ? "failed" : "completed",
8772
- lifecycleSource: ptyAttached ? "spawn" : "exit",
8934
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
8935
+ // the durable registry, so it is a previous run's session with no process
8936
+ // behind it — `resumable`, and `historical` rather than `managed`
8937
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
8938
+ lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
8939
+ lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
8773
8940
  // We spawned it, so `status` is the authoritative signal — no inferred
8774
8941
  // `activity` is attached for managed sessions.
8775
- ownership: "managed",
8942
+ ownership: s.rehydrated ? "historical" : "managed",
8776
8943
  projectPath: s.projectPath,
8777
8944
  projectName: s.projectName,
8778
8945
  branch: s.branch,
@@ -9362,10 +9529,13 @@ var StreamerServer = class {
9362
9529
  projectsRepo = null;
9363
9530
  conversationsRepo = null;
9364
9531
  sessionsRepo = null;
9365
- // Durable session registry (C1 Phase 2). Null when the cache DB failed to
9366
- // open — persistence degrades to today's in-memory-only behaviour rather than
9367
- // taking the server down with it, so every write goes through `?.`.
9532
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
9533
+ // — persistence degrades to today's in-memory-only behaviour rather than
9534
+ // taking the server down with it, so every write goes through `?.`. Note the
9535
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
9536
+ // null this repo and silently disable all session persistence.
9368
9537
  managedSessionsRepo = null;
9538
+ runtimeStore = null;
9369
9539
  // Identifies this streamer run. A registry row carrying a different id is a
9370
9540
  // session that outlived the process that started it.
9371
9541
  streamerInstanceId = (0, import_crypto11.randomUUID)();
@@ -9384,6 +9554,7 @@ var StreamerServer = class {
9384
9554
  liveActivityRenewal = null;
9385
9555
  discoveryCache = null;
9386
9556
  cacheDir;
9557
+ runtimeDbPath;
9387
9558
  tailSize;
9388
9559
  directoryDebounceMs;
9389
9560
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9430,6 +9601,7 @@ var StreamerServer = class {
9430
9601
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9431
9602
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9432
9603
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase", "cache");
9604
+ this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path18.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase"), "runtime.db");
9433
9605
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9434
9606
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9435
9607
  this.markScannerStaleDebounced = debounce(() => {
@@ -9600,6 +9772,7 @@ var StreamerServer = class {
9600
9772
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9601
9773
  },
9602
9774
  onStatusChange: (session) => {
9775
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9603
9776
  this.sessionStore.updateManaged(session.id, {
9604
9777
  status: session.status,
9605
9778
  completedAt: session.completedAt,
@@ -9654,7 +9827,7 @@ var StreamerServer = class {
9654
9827
  if (resp) {
9655
9828
  this.wsHub.broadcast({ type: "session_update", session: resp });
9656
9829
  }
9657
- void this.liveActivityNotifier?.onStatusChange(session);
9830
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9658
9831
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9659
9832
  }
9660
9833
  });
@@ -9705,6 +9878,7 @@ var StreamerServer = class {
9705
9878
  conversationsRepo: () => this.conversationsRepo,
9706
9879
  sessionsRepo: () => this.sessionsRepo,
9707
9880
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9881
+ runtimeStore: () => this.runtimeStore,
9708
9882
  ptyAttachedIds: () => this.ptyAttachedIds(),
9709
9883
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9710
9884
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -10004,6 +10178,58 @@ var StreamerServer = class {
10004
10178
  }
10005
10179
  return verdicts;
10006
10180
  }
10181
+ /**
10182
+ * Seed the session list with what previous runs left behind (persistence plan
10183
+ * Phase 1, gaps G1/G2/G8).
10184
+ *
10185
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
10186
+ * SessionResponse that already exists, and after a clean restart none does —
10187
+ * `SessionStore` starts empty. So the user's session did not become
10188
+ * `resumable`, it became *absent*. This is the half that puts it back.
10189
+ *
10190
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
10191
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
10192
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
10193
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
10194
+ * by id rather than duplicating it.
10195
+ */
10196
+ rehydratePreviousSessions(verdicts) {
10197
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
10198
+ try {
10199
+ const now = Date.now();
10200
+ const rows = this.managedSessionsRepo.listRecoverable({
10201
+ sinceMs: now - REHYDRATE_WINDOW_MS,
10202
+ limit: REHYDRATE_MAX + 1
10203
+ });
10204
+ const truncated = rows.length > REHYDRATE_MAX;
10205
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10206
+ if (candidates.length === 0) return;
10207
+ const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10208
+ let rehydrated = 0;
10209
+ for (const row of candidates) {
10210
+ if (this.sessionStore.getManaged(row.session_id)) continue;
10211
+ if (!shouldRehydrate(row, { now, projectExists: import_fs19.existsSync })) continue;
10212
+ this.sessionStore.addManaged(rowToStubSession(row));
10213
+ this.sessionLifecycles.set(
10214
+ row.session_id,
10215
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
10216
+ );
10217
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10218
+ rehydrated++;
10219
+ }
10220
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
10221
+ event: "sessions.rehydrated",
10222
+ rehydrated,
10223
+ skipped: candidates.length - rehydrated,
10224
+ truncated
10225
+ });
10226
+ } catch (err) {
10227
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10228
+ event: "sessions.rehydrate_failed",
10229
+ err
10230
+ });
10231
+ }
10232
+ }
10007
10233
  /**
10008
10234
  * Pick a token guaranteed to appear in the spawned process's argv, for the
10009
10235
  * reconciler's pid-reuse guard.
@@ -10237,6 +10463,17 @@ var StreamerServer = class {
10237
10463
  port,
10238
10464
  event: "server.listening"
10239
10465
  });
10466
+ try {
10467
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
10468
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
10469
+ } catch (err) {
10470
+ const message = err instanceof Error ? err.message : String(err);
10471
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
10472
+ this.log.error(
10473
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
10474
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10475
+ );
10476
+ }
10240
10477
  try {
10241
10478
  this.cache = ConversationCache.open(
10242
10479
  (0, import_path18.join)(this.cacheDir, "cache.db"),
@@ -10264,8 +10501,20 @@ var StreamerServer = class {
10264
10501
  this.projectsRepo = new ProjectsRepository(db);
10265
10502
  this.conversationsRepo = new ConversationsRepository(this.cache);
10266
10503
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
10267
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
10268
- void this.reconcilePreviousSessions();
10504
+ try {
10505
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
10506
+ if (copied > 0) {
10507
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
10508
+ copied,
10509
+ event: "runtime.legacy_import"
10510
+ });
10511
+ }
10512
+ } catch (err) {
10513
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
10514
+ event: "runtime.legacy_import_failed",
10515
+ err
10516
+ });
10517
+ }
10269
10518
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
10270
10519
  this.pushRepo = new PushRepository(db);
10271
10520
  this.devicesRepo = new DevicesRepository(db);
@@ -10301,6 +10550,7 @@ var StreamerServer = class {
10301
10550
  );
10302
10551
  this.scannerPersistenceDisabled = true;
10303
10552
  }
10553
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10304
10554
  if (this.skipStartupWarmup) {
10305
10555
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10306
10556
  event: "cache.warmup_skipped"
@@ -10508,6 +10758,7 @@ var StreamerServer = class {
10508
10758
  this.allScanners.clear();
10509
10759
  this.scanner = null;
10510
10760
  this.cache?.close();
10761
+ this.runtimeStore?.close();
10511
10762
  this.ptyManager.dispose();
10512
10763
  this.fileWatcher.dispose();
10513
10764
  this.externalTails.clear();
@@ -10955,7 +11206,8 @@ var StreamerServer = class {
10955
11206
  }
10956
11207
  handleSessionsCount(res) {
10957
11208
  if (this.rejectIfWarmingUp(res)) return;
10958
- json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
11209
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
11210
+ json(res, 200, { total });
10959
11211
  }
10960
11212
  handleGetRecentSessions(url, res) {
10961
11213
  if (this.rejectIfWarmingUp(res)) return;