@threadbase-sh/streamer 1.40.0 → 1.41.1

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;
@@ -1943,8 +1955,8 @@ var PTY_COLS2 = 120;
1943
1955
  var PTY_ROWS2 = 40;
1944
1956
  var SCREEN_SCROLLBACK2 = 1e3;
1945
1957
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1946
- var PROMPT_MARKER_FALLBACK_MS = 1e4;
1947
1958
  var QUIET_DETECT_MS2 = 500;
1959
+ var CLAUDE_READY_FALLBACK_MS = 8e3;
1948
1960
  function buildPasteBytes(input) {
1949
1961
  return `\x1B[200~${input}\x1B[201~`;
1950
1962
  }
@@ -2020,8 +2032,8 @@ var PTYManager = class {
2020
2032
  // silently lost — the "dot bug".
2021
2033
  queuedInputs = /* @__PURE__ */ new Map();
2022
2034
  log;
2023
- // Timestamp of first PTY chunk per session; drives the prompt-marker fallback
2024
- // when neither nor shows up within PROMPT_MARKER_FALLBACK_MS.
2035
+ // Timestamp of first PTY chunk per session; used for the [pty.ready] elapsed
2036
+ // measurement so a slow boot is visible in the logs.
2025
2037
  firstChunkAt = /* @__PURE__ */ new Map();
2026
2038
  // Per-session chunk counter and last-chunk timestamp. Diagnostic-only,
2027
2039
  // feeds the [pty.chunk] log lines so we can trace whether Claude responded
@@ -2032,6 +2044,8 @@ var PTYManager = class {
2032
2044
  // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
2033
2045
  // wait for another chunk that may never arrive (Claude blocked on input).
2034
2046
  quietCheckers = /* @__PURE__ */ new Map();
2047
+ // Per-session flat backstop from the first chunk (CLAUDE_READY_FALLBACK_MS).
2048
+ readyFallbackTimers = /* @__PURE__ */ new Map();
2035
2049
  // In-flight start()/startFresh() calls keyed by sessionId. A second
2036
2050
  // concurrent resume for the same session (double-tap, client retry) awaits
2037
2051
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -2117,6 +2131,7 @@ var PTYManager = class {
2117
2131
  };
2118
2132
  this.sessions.set(sessionId, session);
2119
2133
  this.pendingReady.add(sessionId);
2134
+ this.armReadyFallback(sessionId);
2120
2135
  proc.onData((data) => {
2121
2136
  this.handleOutput(sessionId, data);
2122
2137
  });
@@ -2177,6 +2192,7 @@ var PTYManager = class {
2177
2192
  };
2178
2193
  this.sessions.set(sessionId, session);
2179
2194
  this.pendingReady.add(sessionId);
2195
+ this.armReadyFallback(sessionId);
2180
2196
  proc.onData((data) => {
2181
2197
  this.handleOutput(sessionId, data);
2182
2198
  });
@@ -2351,6 +2367,7 @@ var PTYManager = class {
2351
2367
  this.shellPromptOpen.delete(sessionId);
2352
2368
  this.quietCheckers.get(sessionId)?.cancel();
2353
2369
  this.quietCheckers.delete(sessionId);
2370
+ this.clearReadyFallback(sessionId);
2354
2371
  try {
2355
2372
  session.process.kill("SIGINT");
2356
2373
  } catch {
@@ -2409,6 +2426,10 @@ var PTYManager = class {
2409
2426
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2410
2427
  session.inputHistory.shift();
2411
2428
  }
2429
+ if (session.firstMessageText === void 0) {
2430
+ session.firstMessageText = text;
2431
+ session.sessionName = deriveSessionName(text);
2432
+ }
2412
2433
  this.onUserMessage?.(session.id, text, ts);
2413
2434
  }
2414
2435
  getSession(sessionId) {
@@ -2435,6 +2456,8 @@ var PTYManager = class {
2435
2456
  this.lastChunkAt.clear();
2436
2457
  for (const quiet of this.quietCheckers.values()) quiet.cancel();
2437
2458
  this.quietCheckers.clear();
2459
+ for (const timer of this.readyFallbackTimers.values()) clearTimeout(timer);
2460
+ this.readyFallbackTimers.clear();
2438
2461
  this.permissionOpen.clear();
2439
2462
  this.lastScreenQuestionKey.clear();
2440
2463
  this.shellPromptOpen.clear();
@@ -2477,8 +2500,6 @@ var PTYManager = class {
2477
2500
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
2478
2501
  if (session.status === "running" && matchedMarker) {
2479
2502
  this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
2480
- } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
2481
- this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2482
2503
  }
2483
2504
  this.onOutput?.(sessionId, data);
2484
2505
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -2578,7 +2599,13 @@ var PTYManager = class {
2578
2599
  const session = this.sessions.get(sessionId);
2579
2600
  if (session?.status !== "running") return;
2580
2601
  if (this.pendingReady.has(sessionId)) {
2581
- this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
2602
+ this.recheckReadyFromScreen(sessionId).catch((err) => {
2603
+ this.log.warn("[pty.ready] boot screen recheck failed", {
2604
+ event: "pty.ready_recheck_failed",
2605
+ sessionId,
2606
+ err
2607
+ });
2608
+ });
2582
2609
  } else {
2583
2610
  this.recheckReadyFromScreen(sessionId).catch((err) => {
2584
2611
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -2610,9 +2637,32 @@ var PTYManager = class {
2610
2637
  this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
2611
2638
  }
2612
2639
  }
2640
+ // Flat backstop from the first chunk: if neither a prompt marker nor the
2641
+ // screen recheck settles the session within CLAUDE_READY_FALLBACK_MS, mark it
2642
+ // ready anyway so start requests resolve and queued input is not held
2643
+ // forever. This is what makes the quiet-checker safe to be strict — a boot
2644
+ // variant whose marker we cannot see still recovers, just 8s later instead of
2645
+ // 500ms sooner and wrong. unref() so it never holds the process open.
2646
+ armReadyFallback(sessionId) {
2647
+ const timer = setTimeout(() => {
2648
+ this.readyFallbackTimers.delete(sessionId);
2649
+ const session = this.sessions.get(sessionId);
2650
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
2651
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2652
+ }
2653
+ }, CLAUDE_READY_FALLBACK_MS);
2654
+ timer.unref?.();
2655
+ this.readyFallbackTimers.set(sessionId, timer);
2656
+ }
2657
+ clearReadyFallback(sessionId) {
2658
+ const timer = this.readyFallbackTimers.get(sessionId);
2659
+ if (timer) clearTimeout(timer);
2660
+ this.readyFallbackTimers.delete(sessionId);
2661
+ }
2613
2662
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2614
2663
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2615
2664
  markReady(sessionId, session, source, reason) {
2665
+ this.clearReadyFallback(sessionId);
2616
2666
  session.lastActivityAt = /* @__PURE__ */ new Date();
2617
2667
  session.status = "waiting_input";
2618
2668
  session.statusSource = source;
@@ -2656,6 +2706,7 @@ var PTYManager = class {
2656
2706
  this.shellPromptOpen.delete(sessionId);
2657
2707
  this.quietCheckers.get(sessionId)?.cancel();
2658
2708
  this.quietCheckers.delete(sessionId);
2709
+ this.clearReadyFallback(sessionId);
2659
2710
  }
2660
2711
  };
2661
2712
  function toPublicSession2(s) {
@@ -2674,7 +2725,9 @@ function toPublicSession2(s) {
2674
2725
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2675
2726
  ...s.statusSource != null && { statusSource: s.statusSource },
2676
2727
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2677
- ...s.filePath != null && { filePath: s.filePath }
2728
+ ...s.filePath != null && { filePath: s.filePath },
2729
+ ...s.sessionName != null && { sessionName: s.sessionName },
2730
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
2678
2731
  };
2679
2732
  }
2680
2733
  function stripAnsi2(str) {
@@ -5178,6 +5231,9 @@ function getMigrationsDir2() {
5178
5231
  }
5179
5232
  return __dirname;
5180
5233
  }
5234
+ function resolveMigrationsDir(name = "migrations") {
5235
+ return (0, import_path10.join)(getMigrationsDir2(), name);
5236
+ }
5181
5237
  var SCHEMA_MIGRATIONS_SQL = `
5182
5238
  CREATE TABLE IF NOT EXISTS schema_migrations (
5183
5239
  id TEXT PRIMARY KEY,
@@ -5186,7 +5242,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
5186
5242
  `;
5187
5243
  function runSqliteMigrations(db, migrationsDir) {
5188
5244
  db.exec(SCHEMA_MIGRATIONS_SQL);
5189
- const dir = migrationsDir ?? (0, import_path10.join)(getMigrationsDir2(), "migrations");
5245
+ const dir = migrationsDir ?? resolveMigrationsDir();
5190
5246
  const files = (0, import_fs8.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
5191
5247
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
5192
5248
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -6597,6 +6653,7 @@ var ManagedSessionsRepository = class {
6597
6653
  updateStatusStmt;
6598
6654
  getStmt;
6599
6655
  listNonTerminalStmt;
6656
+ listRecoverableStmt;
6600
6657
  deleteStmt;
6601
6658
  constructor(db) {
6602
6659
  this.upsertStmt = db.prepare(`
@@ -6640,7 +6697,8 @@ var ManagedSessionsRepository = class {
6640
6697
  completed_at = @completed_at,
6641
6698
  last_activity_at = @last_activity_at,
6642
6699
  prompt_count = @prompt_count,
6643
- failure_reason = COALESCE(@failure_reason, failure_reason)
6700
+ failure_reason = COALESCE(@failure_reason, failure_reason),
6701
+ session_name = COALESCE(@session_name, session_name)
6644
6702
  WHERE session_id = @session_id
6645
6703
  `);
6646
6704
  this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
@@ -6649,6 +6707,13 @@ var ManagedSessionsRepository = class {
6649
6707
  WHERE completed_at IS NULL
6650
6708
  ORDER BY started_at ASC
6651
6709
  `);
6710
+ this.listRecoverableStmt = db.prepare(`
6711
+ SELECT * FROM managed_sessions
6712
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
6713
+ AND status_updated_at >= @since
6714
+ ORDER BY status_updated_at DESC
6715
+ LIMIT @limit
6716
+ `);
6652
6717
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6653
6718
  }
6654
6719
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6690,7 +6755,8 @@ var ManagedSessionsRepository = class {
6690
6755
  completed_at: fields.completedAt?.getTime() ?? null,
6691
6756
  last_activity_at: fields.lastActivityAt?.getTime() ?? null,
6692
6757
  prompt_count: fields.promptCount ?? 0,
6693
- failure_reason: fields.failureReason ?? null
6758
+ failure_reason: fields.failureReason ?? null,
6759
+ session_name: fields.sessionName ?? null
6694
6760
  });
6695
6761
  }
6696
6762
  get(sessionId) {
@@ -6700,6 +6766,14 @@ var ManagedSessionsRepository = class {
6700
6766
  listNonTerminal() {
6701
6767
  return this.listNonTerminalStmt.all();
6702
6768
  }
6769
+ /**
6770
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
6771
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
6772
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
6773
+ */
6774
+ listRecoverable({ sinceMs, limit }) {
6775
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
6776
+ }
6703
6777
  delete(sessionId) {
6704
6778
  this.deleteStmt.run(sessionId);
6705
6779
  }
@@ -6835,6 +6909,54 @@ var SessionsRepository = class {
6835
6909
  }
6836
6910
  };
6837
6911
 
6912
+ // src/db/runtime-store.ts
6913
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
6914
+ var RuntimeStore = class _RuntimeStore {
6915
+ constructor(db) {
6916
+ this.db = db;
6917
+ }
6918
+ db;
6919
+ static open(dbPath, migrationsDir) {
6920
+ const db = new import_better_sqlite32.default(dbPath);
6921
+ db.pragma("journal_mode = WAL");
6922
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
6923
+ return new _RuntimeStore(db);
6924
+ }
6925
+ getDatabase() {
6926
+ return this.db;
6927
+ }
6928
+ /**
6929
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
6930
+ *
6931
+ * Non-destructive by design: the source table is left in place so an older
6932
+ * streamer rolled back onto the same machine still finds its registry. Runs
6933
+ * only when this file's table is empty, so a second boot is a no-op rather
6934
+ * than a re-copy that would resurrect rows deleted since.
6935
+ *
6936
+ * Returns the number of rows copied.
6937
+ */
6938
+ importLegacyManagedSessions(source) {
6939
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
6940
+ if (existing.n > 0) return 0;
6941
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
6942
+ if (!hasTable) return 0;
6943
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
6944
+ if (rows.length === 0) return 0;
6945
+ const columns = Object.keys(rows[0]);
6946
+ const insert = this.db.prepare(
6947
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
6948
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
6949
+ );
6950
+ this.db.transaction((batch) => {
6951
+ for (const row of batch) insert.run(row);
6952
+ })(rows);
6953
+ return rows.length;
6954
+ }
6955
+ close() {
6956
+ this.db.close();
6957
+ }
6958
+ };
6959
+
6838
6960
  // src/db/upload-records.ts
6839
6961
  async function recordUpload(pool2, instanceId, row) {
6840
6962
  if (!pool2) return;
@@ -7852,7 +7974,8 @@ function contentStateForSession(args) {
7852
7974
  status,
7853
7975
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7854
7976
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7855
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7977
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7978
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7856
7979
  };
7857
7980
  }
7858
7981
  var LiveActivityNotifier = class {
@@ -7865,14 +7988,17 @@ var LiveActivityNotifier = class {
7865
7988
  serverId;
7866
7989
  serverLabel;
7867
7990
  /**
7868
- * Last status pushed per session.
7991
+ * Sessions with a currently open (pushed) activity.
7869
7992
  *
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.
7993
+ * An activity opens on a `waiting_input running` edge (the user sent a
7994
+ * prompt) and closes on the matching `running waiting_input` edge (the
7995
+ * response, including any sub-agents, finished) so this set is what makes
7996
+ * the notifier per-turn rather than per-session. A session's very first
7997
+ * `running` (right after spawn, before any user prompt) has no prior
7998
+ * `waiting_input` and therefore no edge, so it never opens an activity —
7999
+ * this is what keeps a fresh/idle session from pushing anything.
7874
8000
  */
7875
- lastPushed = /* @__PURE__ */ new Map();
8001
+ openActivity = /* @__PURE__ */ new Map();
7876
8002
  /**
7877
8003
  * React to a session status change.
7878
8004
  *
@@ -7880,34 +8006,22 @@ var LiveActivityNotifier = class {
7880
8006
  * transition, so this returns a promise the caller may ignore and every error
7881
8007
  * is logged rather than propagated.
7882
8008
  */
7883
- async onStatusChange(session) {
8009
+ async onStatusChange(session, previousStatus) {
7884
8010
  const status = toLiveActivityStatus(session.status);
7885
8011
  try {
7886
8012
  if (!status) {
7887
- await this.endFor(session);
8013
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7888
8014
  return;
7889
8015
  }
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
- });
8016
+ if (status === "running" && previousStatus === "waiting_input") {
8017
+ await this.startTurn(session);
8018
+ return;
8019
+ }
8020
+ if (status === "waiting_input" && previousStatus === "running") {
8021
+ if (this.openActivity.has(session.id)) await this.endFor(session);
8022
+ return;
7910
8023
  }
8024
+ await this.maybeSendName(session);
7911
8025
  } catch (err) {
7912
8026
  log4.error("live_activity.notify_failed", {
7913
8027
  event: "live_activity.notify_failed",
@@ -7917,14 +8031,57 @@ var LiveActivityNotifier = class {
7917
8031
  });
7918
8032
  }
7919
8033
  }
8034
+ async startTurn(session) {
8035
+ const contentState = contentStateForSession({
8036
+ session,
8037
+ serverId: this.serverId,
8038
+ serverLabel: this.serverLabel
8039
+ });
8040
+ if (!contentState) return;
8041
+ const outcome = await this.sender.send({
8042
+ sessionId: session.id,
8043
+ event: "update",
8044
+ contentState
8045
+ });
8046
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
8047
+ if (outcome.attempted > 0) {
8048
+ log4.info("live_activity.updated", {
8049
+ event: "live_activity.updated",
8050
+ sessionId: session.id,
8051
+ status: contentState.status,
8052
+ ...outcome
8053
+ });
8054
+ }
8055
+ }
8056
+ async maybeSendName(session) {
8057
+ const open2 = this.openActivity.get(session.id);
8058
+ if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
8059
+ const contentState = contentStateForSession({
8060
+ session,
8061
+ serverId: this.serverId,
8062
+ serverLabel: this.serverLabel
8063
+ });
8064
+ if (!contentState) return;
8065
+ const outcome = await this.sender.send({
8066
+ sessionId: session.id,
8067
+ event: "update",
8068
+ contentState
8069
+ });
8070
+ open2.sessionNameSent = true;
8071
+ if (outcome.attempted > 0) {
8072
+ log4.info("live_activity.updated", {
8073
+ event: "live_activity.updated",
8074
+ sessionId: session.id,
8075
+ status: contentState.status,
8076
+ ...outcome
8077
+ });
8078
+ }
8079
+ }
7920
8080
  async endFor(session) {
7921
- const lastStatus = this.lastPushed.get(session.id);
7922
- this.lastPushed.delete(session.id);
8081
+ this.openActivity.delete(session.id);
8082
+ const status = toLiveActivityStatus(session.status);
7923
8083
  const contentState = contentStateForSession({
7924
- session: {
7925
- ...session,
7926
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7927
- },
8084
+ session: { ...session, status: status ?? "waiting_input" },
7928
8085
  serverId: this.serverId,
7929
8086
  serverLabel: this.serverLabel
7930
8087
  });
@@ -7938,9 +8095,9 @@ var LiveActivityNotifier = class {
7938
8095
  });
7939
8096
  }
7940
8097
  }
7941
- /** Drop cached state for a session, so a resume re-pushes its first status. */
8098
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
7942
8099
  forget(sessionId) {
7943
- this.lastPushed.delete(sessionId);
8100
+ this.openActivity.delete(sessionId);
7944
8101
  }
7945
8102
  };
7946
8103
 
@@ -8171,7 +8328,8 @@ var LiveActivityRenewalScheduler = class {
8171
8328
  status,
8172
8329
  startedAt,
8173
8330
  lastOutput: session.lastOutput ?? "",
8174
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8331
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8332
+ ...session.sessionName != null && { sessionName: session.sessionName }
8175
8333
  };
8176
8334
  try {
8177
8335
  await this.deps.sender.send({
@@ -8231,7 +8389,8 @@ var LiveActivityRenewalScheduler = class {
8231
8389
  // Carried through unchanged — the whole point of the renewal.
8232
8390
  startedAt: args.startedAt,
8233
8391
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
8234
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8392
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8393
+ ...session.sessionName != null && { sessionName: session.sessionName }
8235
8394
  },
8236
8395
  now: args.now,
8237
8396
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8618,6 +8777,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8618
8777
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8619
8778
  }
8620
8779
 
8780
+ // src/services/sessions/rehydrateSessions.ts
8781
+ var REHYDRATE_MAX = 25;
8782
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8783
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8784
+ function shouldRehydrate(row, opts) {
8785
+ if (!opts.projectExists(row.project_path)) return false;
8786
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8787
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8788
+ return true;
8789
+ }
8790
+ function rowToStubSession(row) {
8791
+ return {
8792
+ id: row.session_id,
8793
+ provider: row.provider,
8794
+ projectPath: row.project_path,
8795
+ projectName: row.project_name,
8796
+ branch: row.branch,
8797
+ // No PTY exists for a stub, so this is the only truthful status.
8798
+ status: "idle",
8799
+ startedAt: new Date(row.started_at),
8800
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8801
+ promptCount: row.prompt_count,
8802
+ lastOutput: "",
8803
+ rehydrated: true,
8804
+ ...row.session_name != null && { sessionName: row.session_name },
8805
+ ...row.project_id != null && { projectId: row.project_id },
8806
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8807
+ ...row.resumed_from_conversation_id != null && {
8808
+ resumedFromConversationId: row.resumed_from_conversation_id
8809
+ },
8810
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
8811
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8812
+ // Only `shutdown` crosses over. It is the one registry source that is also a
8813
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
8814
+ // streamer stopped this session. A crashed row still says `transition` over
8815
+ // a `running` status, and copying that here would attach observed-confidence
8816
+ // provenance to a status we derived at boot, so leave it unset instead.
8817
+ ...row.status_source === "shutdown" && {
8818
+ statusSource: "shutdown",
8819
+ statusUpdatedAt: new Date(row.status_updated_at)
8820
+ }
8821
+ };
8822
+ }
8823
+
8621
8824
  // src/types.ts
8622
8825
  function confidenceForSource(source) {
8623
8826
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8765,14 +8968,15 @@ function managedToResponse(s, ptyAttached) {
8765
8968
  // Lifecycle for a session this run knows about. `attached` while we hold
8766
8969
  // its PTY; once the PTY is gone the session is terminal from this run's
8767
8970
  // 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",
8971
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
8972
+ // the durable registry, so it is a previous run's session with no process
8973
+ // behind it — `resumable`, and `historical` rather than `managed`
8974
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
8975
+ lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
8976
+ lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
8773
8977
  // We spawned it, so `status` is the authoritative signal — no inferred
8774
8978
  // `activity` is attached for managed sessions.
8775
- ownership: "managed",
8979
+ ownership: s.rehydrated ? "historical" : "managed",
8776
8980
  projectPath: s.projectPath,
8777
8981
  projectName: s.projectName,
8778
8982
  branch: s.branch,
@@ -9362,10 +9566,13 @@ var StreamerServer = class {
9362
9566
  projectsRepo = null;
9363
9567
  conversationsRepo = null;
9364
9568
  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 `?.`.
9569
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
9570
+ // — persistence degrades to today's in-memory-only behaviour rather than
9571
+ // taking the server down with it, so every write goes through `?.`. Note the
9572
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
9573
+ // null this repo and silently disable all session persistence.
9368
9574
  managedSessionsRepo = null;
9575
+ runtimeStore = null;
9369
9576
  // Identifies this streamer run. A registry row carrying a different id is a
9370
9577
  // session that outlived the process that started it.
9371
9578
  streamerInstanceId = (0, import_crypto11.randomUUID)();
@@ -9384,6 +9591,7 @@ var StreamerServer = class {
9384
9591
  liveActivityRenewal = null;
9385
9592
  discoveryCache = null;
9386
9593
  cacheDir;
9594
+ runtimeDbPath;
9387
9595
  tailSize;
9388
9596
  directoryDebounceMs;
9389
9597
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9430,6 +9638,7 @@ var StreamerServer = class {
9430
9638
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9431
9639
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9432
9640
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase", "cache");
9641
+ 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
9642
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9434
9643
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9435
9644
  this.markScannerStaleDebounced = debounce(() => {
@@ -9600,6 +9809,7 @@ var StreamerServer = class {
9600
9809
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9601
9810
  },
9602
9811
  onStatusChange: (session) => {
9812
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9603
9813
  this.sessionStore.updateManaged(session.id, {
9604
9814
  status: session.status,
9605
9815
  completedAt: session.completedAt,
@@ -9613,7 +9823,11 @@ var StreamerServer = class {
9613
9823
  completedAt: session.completedAt,
9614
9824
  lastActivityAt: session.lastActivityAt ?? null,
9615
9825
  promptCount: session.promptCount,
9616
- failureReason: session.failureReason ?? null
9826
+ failureReason: session.failureReason ?? null,
9827
+ // Derived from the first user message, so it does not exist yet at
9828
+ // recordSpawn. The input that produces it also flips
9829
+ // waiting_input→running, which lands here.
9830
+ sessionName: session.sessionName ?? null
9617
9831
  }
9618
9832
  );
9619
9833
  if (session.status === "waiting_input" || session.status === "idle") {
@@ -9654,7 +9868,7 @@ var StreamerServer = class {
9654
9868
  if (resp) {
9655
9869
  this.wsHub.broadcast({ type: "session_update", session: resp });
9656
9870
  }
9657
- void this.liveActivityNotifier?.onStatusChange(session);
9871
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9658
9872
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9659
9873
  }
9660
9874
  });
@@ -9705,6 +9919,7 @@ var StreamerServer = class {
9705
9919
  conversationsRepo: () => this.conversationsRepo,
9706
9920
  sessionsRepo: () => this.sessionsRepo,
9707
9921
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9922
+ runtimeStore: () => this.runtimeStore,
9708
9923
  ptyAttachedIds: () => this.ptyAttachedIds(),
9709
9924
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9710
9925
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -10004,6 +10219,58 @@ var StreamerServer = class {
10004
10219
  }
10005
10220
  return verdicts;
10006
10221
  }
10222
+ /**
10223
+ * Seed the session list with what previous runs left behind (persistence plan
10224
+ * Phase 1, gaps G1/G2/G8).
10225
+ *
10226
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
10227
+ * SessionResponse that already exists, and after a clean restart none does —
10228
+ * `SessionStore` starts empty. So the user's session did not become
10229
+ * `resumable`, it became *absent*. This is the half that puts it back.
10230
+ *
10231
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
10232
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
10233
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
10234
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
10235
+ * by id rather than duplicating it.
10236
+ */
10237
+ rehydratePreviousSessions(verdicts) {
10238
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
10239
+ try {
10240
+ const now = Date.now();
10241
+ const rows = this.managedSessionsRepo.listRecoverable({
10242
+ sinceMs: now - REHYDRATE_WINDOW_MS,
10243
+ limit: REHYDRATE_MAX + 1
10244
+ });
10245
+ const truncated = rows.length > REHYDRATE_MAX;
10246
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10247
+ if (candidates.length === 0) return;
10248
+ const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10249
+ let rehydrated = 0;
10250
+ for (const row of candidates) {
10251
+ if (this.sessionStore.getManaged(row.session_id)) continue;
10252
+ if (!shouldRehydrate(row, { now, projectExists: import_fs19.existsSync })) continue;
10253
+ this.sessionStore.addManaged(rowToStubSession(row));
10254
+ this.sessionLifecycles.set(
10255
+ row.session_id,
10256
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
10257
+ );
10258
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10259
+ rehydrated++;
10260
+ }
10261
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
10262
+ event: "sessions.rehydrated",
10263
+ rehydrated,
10264
+ skipped: candidates.length - rehydrated,
10265
+ truncated
10266
+ });
10267
+ } catch (err) {
10268
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10269
+ event: "sessions.rehydrate_failed",
10270
+ err
10271
+ });
10272
+ }
10273
+ }
10007
10274
  /**
10008
10275
  * Pick a token guaranteed to appear in the spawned process's argv, for the
10009
10276
  * reconciler's pid-reuse guard.
@@ -10237,6 +10504,17 @@ var StreamerServer = class {
10237
10504
  port,
10238
10505
  event: "server.listening"
10239
10506
  });
10507
+ try {
10508
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
10509
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
10510
+ } catch (err) {
10511
+ const message = err instanceof Error ? err.message : String(err);
10512
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
10513
+ this.log.error(
10514
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
10515
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10516
+ );
10517
+ }
10240
10518
  try {
10241
10519
  this.cache = ConversationCache.open(
10242
10520
  (0, import_path18.join)(this.cacheDir, "cache.db"),
@@ -10264,8 +10542,20 @@ var StreamerServer = class {
10264
10542
  this.projectsRepo = new ProjectsRepository(db);
10265
10543
  this.conversationsRepo = new ConversationsRepository(this.cache);
10266
10544
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
10267
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
10268
- void this.reconcilePreviousSessions();
10545
+ try {
10546
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
10547
+ if (copied > 0) {
10548
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
10549
+ copied,
10550
+ event: "runtime.legacy_import"
10551
+ });
10552
+ }
10553
+ } catch (err) {
10554
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
10555
+ event: "runtime.legacy_import_failed",
10556
+ err
10557
+ });
10558
+ }
10269
10559
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
10270
10560
  this.pushRepo = new PushRepository(db);
10271
10561
  this.devicesRepo = new DevicesRepository(db);
@@ -10301,6 +10591,7 @@ var StreamerServer = class {
10301
10591
  );
10302
10592
  this.scannerPersistenceDisabled = true;
10303
10593
  }
10594
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10304
10595
  if (this.skipStartupWarmup) {
10305
10596
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10306
10597
  event: "cache.warmup_skipped"
@@ -10508,6 +10799,7 @@ var StreamerServer = class {
10508
10799
  this.allScanners.clear();
10509
10800
  this.scanner = null;
10510
10801
  this.cache?.close();
10802
+ this.runtimeStore?.close();
10511
10803
  this.ptyManager.dispose();
10512
10804
  this.fileWatcher.dispose();
10513
10805
  this.externalTails.clear();
@@ -10955,7 +11247,8 @@ var StreamerServer = class {
10955
11247
  }
10956
11248
  handleSessionsCount(res) {
10957
11249
  if (this.rejectIfWarmingUp(res)) return;
10958
- json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
11250
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
11251
+ json(res, 200, { total });
10959
11252
  }
10960
11253
  handleGetRecentSessions(url, res) {
10961
11254
  if (this.rejectIfWarmingUp(res)) return;