@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.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;
@@ -1890,8 +1902,8 @@ var PTY_COLS2 = 120;
1890
1902
  var PTY_ROWS2 = 40;
1891
1903
  var SCREEN_SCROLLBACK2 = 1e3;
1892
1904
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
1893
- var PROMPT_MARKER_FALLBACK_MS = 1e4;
1894
1905
  var QUIET_DETECT_MS2 = 500;
1906
+ var CLAUDE_READY_FALLBACK_MS = 8e3;
1895
1907
  function buildPasteBytes(input) {
1896
1908
  return `\x1B[200~${input}\x1B[201~`;
1897
1909
  }
@@ -1967,8 +1979,8 @@ var PTYManager = class {
1967
1979
  // silently lost — the "dot bug".
1968
1980
  queuedInputs = /* @__PURE__ */ new Map();
1969
1981
  log;
1970
- // Timestamp of first PTY chunk per session; drives the prompt-marker fallback
1971
- // when neither nor shows up within PROMPT_MARKER_FALLBACK_MS.
1982
+ // Timestamp of first PTY chunk per session; used for the [pty.ready] elapsed
1983
+ // measurement so a slow boot is visible in the logs.
1972
1984
  firstChunkAt = /* @__PURE__ */ new Map();
1973
1985
  // Per-session chunk counter and last-chunk timestamp. Diagnostic-only,
1974
1986
  // feeds the [pty.chunk] log lines so we can trace whether Claude responded
@@ -1979,6 +1991,8 @@ var PTYManager = class {
1979
1991
  // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
1980
1992
  // wait for another chunk that may never arrive (Claude blocked on input).
1981
1993
  quietCheckers = /* @__PURE__ */ new Map();
1994
+ // Per-session flat backstop from the first chunk (CLAUDE_READY_FALLBACK_MS).
1995
+ readyFallbackTimers = /* @__PURE__ */ new Map();
1982
1996
  // In-flight start()/startFresh() calls keyed by sessionId. A second
1983
1997
  // concurrent resume for the same session (double-tap, client retry) awaits
1984
1998
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -2064,6 +2078,7 @@ var PTYManager = class {
2064
2078
  };
2065
2079
  this.sessions.set(sessionId, session);
2066
2080
  this.pendingReady.add(sessionId);
2081
+ this.armReadyFallback(sessionId);
2067
2082
  proc.onData((data) => {
2068
2083
  this.handleOutput(sessionId, data);
2069
2084
  });
@@ -2124,6 +2139,7 @@ var PTYManager = class {
2124
2139
  };
2125
2140
  this.sessions.set(sessionId, session);
2126
2141
  this.pendingReady.add(sessionId);
2142
+ this.armReadyFallback(sessionId);
2127
2143
  proc.onData((data) => {
2128
2144
  this.handleOutput(sessionId, data);
2129
2145
  });
@@ -2298,6 +2314,7 @@ var PTYManager = class {
2298
2314
  this.shellPromptOpen.delete(sessionId);
2299
2315
  this.quietCheckers.get(sessionId)?.cancel();
2300
2316
  this.quietCheckers.delete(sessionId);
2317
+ this.clearReadyFallback(sessionId);
2301
2318
  try {
2302
2319
  session.process.kill("SIGINT");
2303
2320
  } catch {
@@ -2356,6 +2373,10 @@ var PTYManager = class {
2356
2373
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
2357
2374
  session.inputHistory.shift();
2358
2375
  }
2376
+ if (session.firstMessageText === void 0) {
2377
+ session.firstMessageText = text;
2378
+ session.sessionName = deriveSessionName(text);
2379
+ }
2359
2380
  this.onUserMessage?.(session.id, text, ts);
2360
2381
  }
2361
2382
  getSession(sessionId) {
@@ -2382,6 +2403,8 @@ var PTYManager = class {
2382
2403
  this.lastChunkAt.clear();
2383
2404
  for (const quiet of this.quietCheckers.values()) quiet.cancel();
2384
2405
  this.quietCheckers.clear();
2406
+ for (const timer of this.readyFallbackTimers.values()) clearTimeout(timer);
2407
+ this.readyFallbackTimers.clear();
2385
2408
  this.permissionOpen.clear();
2386
2409
  this.lastScreenQuestionKey.clear();
2387
2410
  this.shellPromptOpen.clear();
@@ -2424,8 +2447,6 @@ var PTYManager = class {
2424
2447
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
2425
2448
  if (session.status === "running" && matchedMarker) {
2426
2449
  this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
2427
- } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
2428
- this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2429
2450
  }
2430
2451
  this.onOutput?.(sessionId, data);
2431
2452
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -2525,7 +2546,13 @@ var PTYManager = class {
2525
2546
  const session = this.sessions.get(sessionId);
2526
2547
  if (session?.status !== "running") return;
2527
2548
  if (this.pendingReady.has(sessionId)) {
2528
- this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
2549
+ this.recheckReadyFromScreen(sessionId).catch((err) => {
2550
+ this.log.warn("[pty.ready] boot screen recheck failed", {
2551
+ event: "pty.ready_recheck_failed",
2552
+ sessionId,
2553
+ err
2554
+ });
2555
+ });
2529
2556
  } else {
2530
2557
  this.recheckReadyFromScreen(sessionId).catch((err) => {
2531
2558
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -2557,9 +2584,32 @@ var PTYManager = class {
2557
2584
  this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
2558
2585
  }
2559
2586
  }
2587
+ // Flat backstop from the first chunk: if neither a prompt marker nor the
2588
+ // screen recheck settles the session within CLAUDE_READY_FALLBACK_MS, mark it
2589
+ // ready anyway so start requests resolve and queued input is not held
2590
+ // forever. This is what makes the quiet-checker safe to be strict — a boot
2591
+ // variant whose marker we cannot see still recovers, just 8s later instead of
2592
+ // 500ms sooner and wrong. unref() so it never holds the process open.
2593
+ armReadyFallback(sessionId) {
2594
+ const timer = setTimeout(() => {
2595
+ this.readyFallbackTimers.delete(sessionId);
2596
+ const session = this.sessions.get(sessionId);
2597
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
2598
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
2599
+ }
2600
+ }, CLAUDE_READY_FALLBACK_MS);
2601
+ timer.unref?.();
2602
+ this.readyFallbackTimers.set(sessionId, timer);
2603
+ }
2604
+ clearReadyFallback(sessionId) {
2605
+ const timer = this.readyFallbackTimers.get(sessionId);
2606
+ if (timer) clearTimeout(timer);
2607
+ this.readyFallbackTimers.delete(sessionId);
2608
+ }
2560
2609
  // Transition a session from "running" to "waiting_input", clear pendingReady,
2561
2610
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
2562
2611
  markReady(sessionId, session, source, reason) {
2612
+ this.clearReadyFallback(sessionId);
2563
2613
  session.lastActivityAt = /* @__PURE__ */ new Date();
2564
2614
  session.status = "waiting_input";
2565
2615
  session.statusSource = source;
@@ -2603,6 +2653,7 @@ var PTYManager = class {
2603
2653
  this.shellPromptOpen.delete(sessionId);
2604
2654
  this.quietCheckers.get(sessionId)?.cancel();
2605
2655
  this.quietCheckers.delete(sessionId);
2656
+ this.clearReadyFallback(sessionId);
2606
2657
  }
2607
2658
  };
2608
2659
  function toPublicSession2(s) {
@@ -2621,7 +2672,9 @@ function toPublicSession2(s) {
2621
2672
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2622
2673
  ...s.statusSource != null && { statusSource: s.statusSource },
2623
2674
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2624
- ...s.filePath != null && { filePath: s.filePath }
2675
+ ...s.filePath != null && { filePath: s.filePath },
2676
+ ...s.sessionName != null && { sessionName: s.sessionName },
2677
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
2625
2678
  };
2626
2679
  }
2627
2680
  function stripAnsi2(str) {
@@ -5141,6 +5194,9 @@ function getMigrationsDir2() {
5141
5194
  }
5142
5195
  return __dirname;
5143
5196
  }
5197
+ function resolveMigrationsDir(name = "migrations") {
5198
+ return join12(getMigrationsDir2(), name);
5199
+ }
5144
5200
  var SCHEMA_MIGRATIONS_SQL = `
5145
5201
  CREATE TABLE IF NOT EXISTS schema_migrations (
5146
5202
  id TEXT PRIMARY KEY,
@@ -5149,7 +5205,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
5149
5205
  `;
5150
5206
  function runSqliteMigrations(db, migrationsDir) {
5151
5207
  db.exec(SCHEMA_MIGRATIONS_SQL);
5152
- const dir = migrationsDir ?? join12(getMigrationsDir2(), "migrations");
5208
+ const dir = migrationsDir ?? resolveMigrationsDir();
5153
5209
  const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
5154
5210
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
5155
5211
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -6560,6 +6616,7 @@ var ManagedSessionsRepository = class {
6560
6616
  updateStatusStmt;
6561
6617
  getStmt;
6562
6618
  listNonTerminalStmt;
6619
+ listRecoverableStmt;
6563
6620
  deleteStmt;
6564
6621
  constructor(db) {
6565
6622
  this.upsertStmt = db.prepare(`
@@ -6603,7 +6660,8 @@ var ManagedSessionsRepository = class {
6603
6660
  completed_at = @completed_at,
6604
6661
  last_activity_at = @last_activity_at,
6605
6662
  prompt_count = @prompt_count,
6606
- failure_reason = COALESCE(@failure_reason, failure_reason)
6663
+ failure_reason = COALESCE(@failure_reason, failure_reason),
6664
+ session_name = COALESCE(@session_name, session_name)
6607
6665
  WHERE session_id = @session_id
6608
6666
  `);
6609
6667
  this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
@@ -6612,6 +6670,13 @@ var ManagedSessionsRepository = class {
6612
6670
  WHERE completed_at IS NULL
6613
6671
  ORDER BY started_at ASC
6614
6672
  `);
6673
+ this.listRecoverableStmt = db.prepare(`
6674
+ SELECT * FROM managed_sessions
6675
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
6676
+ AND status_updated_at >= @since
6677
+ ORDER BY status_updated_at DESC
6678
+ LIMIT @limit
6679
+ `);
6615
6680
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
6616
6681
  }
6617
6682
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -6653,7 +6718,8 @@ var ManagedSessionsRepository = class {
6653
6718
  completed_at: fields.completedAt?.getTime() ?? null,
6654
6719
  last_activity_at: fields.lastActivityAt?.getTime() ?? null,
6655
6720
  prompt_count: fields.promptCount ?? 0,
6656
- failure_reason: fields.failureReason ?? null
6721
+ failure_reason: fields.failureReason ?? null,
6722
+ session_name: fields.sessionName ?? null
6657
6723
  });
6658
6724
  }
6659
6725
  get(sessionId) {
@@ -6663,6 +6729,14 @@ var ManagedSessionsRepository = class {
6663
6729
  listNonTerminal() {
6664
6730
  return this.listNonTerminalStmt.all();
6665
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
+ }
6666
6740
  delete(sessionId) {
6667
6741
  this.deleteStmt.run(sessionId);
6668
6742
  }
@@ -6798,6 +6872,54 @@ var SessionsRepository = class {
6798
6872
  }
6799
6873
  };
6800
6874
 
6875
+ // src/db/runtime-store.ts
6876
+ import Database2 from "better-sqlite3";
6877
+ var RuntimeStore = class _RuntimeStore {
6878
+ constructor(db) {
6879
+ this.db = db;
6880
+ }
6881
+ db;
6882
+ static open(dbPath, migrationsDir) {
6883
+ const db = new Database2(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
+
6801
6923
  // src/db/upload-records.ts
6802
6924
  async function recordUpload(pool2, instanceId, row) {
6803
6925
  if (!pool2) return;
@@ -7815,7 +7937,8 @@ function contentStateForSession(args) {
7815
7937
  status,
7816
7938
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7817
7939
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7818
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
7940
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
7941
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
7819
7942
  };
7820
7943
  }
7821
7944
  var LiveActivityNotifier = class {
@@ -7828,14 +7951,17 @@ var LiveActivityNotifier = class {
7828
7951
  serverId;
7829
7952
  serverLabel;
7830
7953
  /**
7831
- * Last status pushed per session.
7954
+ * Sessions with a currently open (pushed) activity.
7832
7955
  *
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.
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.
7837
7963
  */
7838
- lastPushed = /* @__PURE__ */ new Map();
7964
+ openActivity = /* @__PURE__ */ new Map();
7839
7965
  /**
7840
7966
  * React to a session status change.
7841
7967
  *
@@ -7843,34 +7969,22 @@ var LiveActivityNotifier = class {
7843
7969
  * transition, so this returns a promise the caller may ignore and every error
7844
7970
  * is logged rather than propagated.
7845
7971
  */
7846
- async onStatusChange(session) {
7972
+ async onStatusChange(session, previousStatus) {
7847
7973
  const status = toLiveActivityStatus(session.status);
7848
7974
  try {
7849
7975
  if (!status) {
7850
- await this.endFor(session);
7976
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7851
7977
  return;
7852
7978
  }
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
- });
7979
+ if (status === "running" && previousStatus === "waiting_input") {
7980
+ await this.startTurn(session);
7981
+ return;
7982
+ }
7983
+ if (status === "waiting_input" && previousStatus === "running") {
7984
+ if (this.openActivity.has(session.id)) await this.endFor(session);
7985
+ return;
7873
7986
  }
7987
+ await this.maybeSendName(session);
7874
7988
  } catch (err) {
7875
7989
  log4.error("live_activity.notify_failed", {
7876
7990
  event: "live_activity.notify_failed",
@@ -7880,14 +7994,57 @@ var LiveActivityNotifier = class {
7880
7994
  });
7881
7995
  }
7882
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
+ }
7883
8043
  async endFor(session) {
7884
- const lastStatus = this.lastPushed.get(session.id);
7885
- this.lastPushed.delete(session.id);
8044
+ this.openActivity.delete(session.id);
8045
+ const status = toLiveActivityStatus(session.status);
7886
8046
  const contentState = contentStateForSession({
7887
- session: {
7888
- ...session,
7889
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7890
- },
8047
+ session: { ...session, status: status ?? "waiting_input" },
7891
8048
  serverId: this.serverId,
7892
8049
  serverLabel: this.serverLabel
7893
8050
  });
@@ -7901,9 +8058,9 @@ var LiveActivityNotifier = class {
7901
8058
  });
7902
8059
  }
7903
8060
  }
7904
- /** 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. */
7905
8062
  forget(sessionId) {
7906
- this.lastPushed.delete(sessionId);
8063
+ this.openActivity.delete(sessionId);
7907
8064
  }
7908
8065
  };
7909
8066
 
@@ -8134,7 +8291,8 @@ var LiveActivityRenewalScheduler = class {
8134
8291
  status,
8135
8292
  startedAt,
8136
8293
  lastOutput: session.lastOutput ?? "",
8137
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8294
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8295
+ ...session.sessionName != null && { sessionName: session.sessionName }
8138
8296
  };
8139
8297
  try {
8140
8298
  await this.deps.sender.send({
@@ -8194,7 +8352,8 @@ var LiveActivityRenewalScheduler = class {
8194
8352
  // Carried through unchanged — the whole point of the renewal.
8195
8353
  startedAt: args.startedAt,
8196
8354
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
8197
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
8355
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
8356
+ ...session.sessionName != null && { sessionName: session.sessionName }
8198
8357
  },
8199
8358
  now: args.now,
8200
8359
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -8581,6 +8740,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
8581
8740
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8582
8741
  }
8583
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
+
8584
8787
  // src/types.ts
8585
8788
  function confidenceForSource(source) {
8586
8789
  return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
@@ -8728,14 +8931,15 @@ function managedToResponse(s, ptyAttached) {
8728
8931
  // Lifecycle for a session this run knows about. `attached` while we hold
8729
8932
  // its PTY; once the PTY is gone the session is terminal from this run's
8730
8933
  // 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",
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",
8736
8940
  // We spawned it, so `status` is the authoritative signal — no inferred
8737
8941
  // `activity` is attached for managed sessions.
8738
- ownership: "managed",
8942
+ ownership: s.rehydrated ? "historical" : "managed",
8739
8943
  projectPath: s.projectPath,
8740
8944
  projectName: s.projectName,
8741
8945
  branch: s.branch,
@@ -9325,10 +9529,13 @@ var StreamerServer = class {
9325
9529
  projectsRepo = null;
9326
9530
  conversationsRepo = null;
9327
9531
  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 `?.`.
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.
9331
9537
  managedSessionsRepo = null;
9538
+ runtimeStore = null;
9332
9539
  // Identifies this streamer run. A registry row carrying a different id is a
9333
9540
  // session that outlived the process that started it.
9334
9541
  streamerInstanceId = randomUUID5();
@@ -9347,6 +9554,7 @@ var StreamerServer = class {
9347
9554
  liveActivityRenewal = null;
9348
9555
  discoveryCache = null;
9349
9556
  cacheDir;
9557
+ runtimeDbPath;
9350
9558
  tailSize;
9351
9559
  directoryDebounceMs;
9352
9560
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -9393,6 +9601,7 @@ var StreamerServer = class {
9393
9601
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9394
9602
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9395
9603
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
9604
+ this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join18(process.env.THREADBASE_CONFIG_DIR ?? join18(homedir9(), ".threadbase"), "runtime.db");
9396
9605
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9397
9606
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9398
9607
  this.markScannerStaleDebounced = debounce(() => {
@@ -9563,6 +9772,7 @@ var StreamerServer = class {
9563
9772
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
9564
9773
  },
9565
9774
  onStatusChange: (session) => {
9775
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
9566
9776
  this.sessionStore.updateManaged(session.id, {
9567
9777
  status: session.status,
9568
9778
  completedAt: session.completedAt,
@@ -9576,7 +9786,11 @@ var StreamerServer = class {
9576
9786
  completedAt: session.completedAt,
9577
9787
  lastActivityAt: session.lastActivityAt ?? null,
9578
9788
  promptCount: session.promptCount,
9579
- failureReason: session.failureReason ?? null
9789
+ failureReason: session.failureReason ?? null,
9790
+ // Derived from the first user message, so it does not exist yet at
9791
+ // recordSpawn. The input that produces it also flips
9792
+ // waiting_input→running, which lands here.
9793
+ sessionName: session.sessionName ?? null
9580
9794
  }
9581
9795
  );
9582
9796
  if (session.status === "waiting_input" || session.status === "idle") {
@@ -9617,7 +9831,7 @@ var StreamerServer = class {
9617
9831
  if (resp) {
9618
9832
  this.wsHub.broadcast({ type: "session_update", session: resp });
9619
9833
  }
9620
- void this.liveActivityNotifier?.onStatusChange(session);
9834
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
9621
9835
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
9622
9836
  }
9623
9837
  });
@@ -9668,6 +9882,7 @@ var StreamerServer = class {
9668
9882
  conversationsRepo: () => this.conversationsRepo,
9669
9883
  sessionsRepo: () => this.sessionsRepo,
9670
9884
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9885
+ runtimeStore: () => this.runtimeStore,
9671
9886
  ptyAttachedIds: () => this.ptyAttachedIds(),
9672
9887
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9673
9888
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -9967,6 +10182,58 @@ var StreamerServer = class {
9967
10182
  }
9968
10183
  return verdicts;
9969
10184
  }
10185
+ /**
10186
+ * Seed the session list with what previous runs left behind (persistence plan
10187
+ * Phase 1, gaps G1/G2/G8).
10188
+ *
10189
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
10190
+ * SessionResponse that already exists, and after a clean restart none does —
10191
+ * `SessionStore` starts empty. So the user's session did not become
10192
+ * `resumable`, it became *absent*. This is the half that puts it back.
10193
+ *
10194
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
10195
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
10196
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
10197
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
10198
+ * by id rather than duplicating it.
10199
+ */
10200
+ rehydratePreviousSessions(verdicts) {
10201
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
10202
+ try {
10203
+ const now = Date.now();
10204
+ const rows = this.managedSessionsRepo.listRecoverable({
10205
+ sinceMs: now - REHYDRATE_WINDOW_MS,
10206
+ limit: REHYDRATE_MAX + 1
10207
+ });
10208
+ const truncated = rows.length > REHYDRATE_MAX;
10209
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10210
+ if (candidates.length === 0) return;
10211
+ const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10212
+ let rehydrated = 0;
10213
+ for (const row of candidates) {
10214
+ if (this.sessionStore.getManaged(row.session_id)) continue;
10215
+ if (!shouldRehydrate(row, { now, projectExists: existsSync11 })) continue;
10216
+ this.sessionStore.addManaged(rowToStubSession(row));
10217
+ this.sessionLifecycles.set(
10218
+ row.session_id,
10219
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
10220
+ );
10221
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10222
+ rehydrated++;
10223
+ }
10224
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
10225
+ event: "sessions.rehydrated",
10226
+ rehydrated,
10227
+ skipped: candidates.length - rehydrated,
10228
+ truncated
10229
+ });
10230
+ } catch (err) {
10231
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10232
+ event: "sessions.rehydrate_failed",
10233
+ err
10234
+ });
10235
+ }
10236
+ }
9970
10237
  /**
9971
10238
  * Pick a token guaranteed to appear in the spawned process's argv, for the
9972
10239
  * reconciler's pid-reuse guard.
@@ -10200,6 +10467,17 @@ var StreamerServer = class {
10200
10467
  port,
10201
10468
  event: "server.listening"
10202
10469
  });
10470
+ try {
10471
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
10472
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
10473
+ } catch (err) {
10474
+ const message = err instanceof Error ? err.message : String(err);
10475
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
10476
+ this.log.error(
10477
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
10478
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10479
+ );
10480
+ }
10203
10481
  try {
10204
10482
  this.cache = ConversationCache.open(
10205
10483
  join18(this.cacheDir, "cache.db"),
@@ -10227,8 +10505,20 @@ var StreamerServer = class {
10227
10505
  this.projectsRepo = new ProjectsRepository(db);
10228
10506
  this.conversationsRepo = new ConversationsRepository(this.cache);
10229
10507
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
10230
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
10231
- void this.reconcilePreviousSessions();
10508
+ try {
10509
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
10510
+ if (copied > 0) {
10511
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
10512
+ copied,
10513
+ event: "runtime.legacy_import"
10514
+ });
10515
+ }
10516
+ } catch (err) {
10517
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
10518
+ event: "runtime.legacy_import_failed",
10519
+ err
10520
+ });
10521
+ }
10232
10522
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
10233
10523
  this.pushRepo = new PushRepository(db);
10234
10524
  this.devicesRepo = new DevicesRepository(db);
@@ -10264,6 +10554,7 @@ var StreamerServer = class {
10264
10554
  );
10265
10555
  this.scannerPersistenceDisabled = true;
10266
10556
  }
10557
+ void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10267
10558
  if (this.skipStartupWarmup) {
10268
10559
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10269
10560
  event: "cache.warmup_skipped"
@@ -10471,6 +10762,7 @@ var StreamerServer = class {
10471
10762
  this.allScanners.clear();
10472
10763
  this.scanner = null;
10473
10764
  this.cache?.close();
10765
+ this.runtimeStore?.close();
10474
10766
  this.ptyManager.dispose();
10475
10767
  this.fileWatcher.dispose();
10476
10768
  this.externalTails.clear();
@@ -10918,7 +11210,8 @@ var StreamerServer = class {
10918
11210
  }
10919
11211
  handleSessionsCount(res) {
10920
11212
  if (this.rejectIfWarmingUp(res)) return;
10921
- json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
11213
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
11214
+ json(res, 200, { total });
10922
11215
  }
10923
11216
  handleGetRecentSessions(url, res) {
10924
11217
  if (this.rejectIfWarmingUp(res)) return;