@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/README.md CHANGED
@@ -112,7 +112,7 @@ Three layers: **core engine** (`src/*.ts`) → **API layer** (`src/api/` + `src/
112
112
  - `POST /api/sessions/start` / `resume` spawns `claude` in a PTY; output streams to WebSocket clients as `terminal_output`, with a `terminal_replay` snapshot on subscribe.
113
113
  - `SessionStore` tracks both PTY-managed sessions and externally-running `claude` processes discovered on disk.
114
114
  - A chokidar-backed watcher tails conversation JSONL files into the SQLite cache, so list/search endpoints don't scan the filesystem.
115
- - When the last WebSocket subscriber disconnects, a grace timer (default 4.5 min) puts the PTY on hold history stays intact and it's resumable anytime.
115
+ - A WebSocket subscriber disconnecting never stops the agent sessions outlive a sleeping phone or a dropped connection. A PTY is put on hold (history intact, resumable anytime) only on an explicit `hold_session` message, or by the idle reaper after 6 h of agent silence.
116
116
 
117
117
  More detail: [docs/how-it-works.md](docs/how-it-works.md) and [docs/architecture/](docs/architecture/README.md).
118
118
 
package/dist/cli.cjs CHANGED
@@ -6213,6 +6213,12 @@ var init_feature_flags = __esm({
6213
6213
  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.",
6214
6214
  default: false,
6215
6215
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
6216
+ },
6217
+ {
6218
+ id: "sessionRehydration",
6219
+ 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.",
6220
+ default: true,
6221
+ env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
6216
6222
  }
6217
6223
  ];
6218
6224
  }
@@ -124077,8 +124083,8 @@ function isAbiMismatch(message) {
124077
124083
  }
124078
124084
  function checkSqliteAbi() {
124079
124085
  try {
124080
- const Database3 = require("better-sqlite3");
124081
- const db = new Database3(":memory:");
124086
+ const Database4 = require("better-sqlite3");
124087
+ const db = new Database4(":memory:");
124082
124088
  db.close();
124083
124089
  } catch (err) {
124084
124090
  const message = err instanceof Error ? err.message : String(err);
@@ -139971,6 +139977,9 @@ function getMigrationsDir() {
139971
139977
  }
139972
139978
  return __dirname;
139973
139979
  }
139980
+ function resolveMigrationsDir(name = "migrations") {
139981
+ return (0, import_path15.join)(getMigrationsDir(), name);
139982
+ }
139974
139983
  var SCHEMA_MIGRATIONS_SQL = `
139975
139984
  CREATE TABLE IF NOT EXISTS schema_migrations (
139976
139985
  id TEXT PRIMARY KEY,
@@ -139979,7 +139988,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
139979
139988
  `;
139980
139989
  function runSqliteMigrations(db, migrationsDir) {
139981
139990
  db.exec(SCHEMA_MIGRATIONS_SQL);
139982
- const dir = migrationsDir ?? (0, import_path15.join)(getMigrationsDir(), "migrations");
139991
+ const dir = migrationsDir ?? resolveMigrationsDir();
139983
139992
  const files = (0, import_fs15.readdirSync)(dir).filter((f2) => f2.endsWith(".sql")).sort();
139984
139993
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
139985
139994
  const appliedSet = new Set(appliedRows.map((r) => r.id));
@@ -141480,6 +141489,7 @@ var ManagedSessionsRepository = class {
141480
141489
  updateStatusStmt;
141481
141490
  getStmt;
141482
141491
  listNonTerminalStmt;
141492
+ listRecoverableStmt;
141483
141493
  deleteStmt;
141484
141494
  constructor(db) {
141485
141495
  this.upsertStmt = db.prepare(`
@@ -141532,6 +141542,13 @@ var ManagedSessionsRepository = class {
141532
141542
  WHERE completed_at IS NULL
141533
141543
  ORDER BY started_at ASC
141534
141544
  `);
141545
+ this.listRecoverableStmt = db.prepare(`
141546
+ SELECT * FROM managed_sessions
141547
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
141548
+ AND status_updated_at >= @since
141549
+ ORDER BY status_updated_at DESC
141550
+ LIMIT @limit
141551
+ `);
141535
141552
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
141536
141553
  }
141537
141554
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -141583,6 +141600,14 @@ var ManagedSessionsRepository = class {
141583
141600
  listNonTerminal() {
141584
141601
  return this.listNonTerminalStmt.all();
141585
141602
  }
141603
+ /**
141604
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
141605
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
141606
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
141607
+ */
141608
+ listRecoverable({ sinceMs, limit }) {
141609
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
141610
+ }
141586
141611
  delete(sessionId) {
141587
141612
  this.deleteStmt.run(sessionId);
141588
141613
  }
@@ -141718,6 +141743,54 @@ var SessionsRepository = class {
141718
141743
  }
141719
141744
  };
141720
141745
 
141746
+ // src/db/runtime-store.ts
141747
+ var import_better_sqlite33 = __toESM(require("better-sqlite3"), 1);
141748
+ var RuntimeStore = class _RuntimeStore {
141749
+ constructor(db) {
141750
+ this.db = db;
141751
+ }
141752
+ db;
141753
+ static open(dbPath, migrationsDir) {
141754
+ const db = new import_better_sqlite33.default(dbPath);
141755
+ db.pragma("journal_mode = WAL");
141756
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
141757
+ return new _RuntimeStore(db);
141758
+ }
141759
+ getDatabase() {
141760
+ return this.db;
141761
+ }
141762
+ /**
141763
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
141764
+ *
141765
+ * Non-destructive by design: the source table is left in place so an older
141766
+ * streamer rolled back onto the same machine still finds its registry. Runs
141767
+ * only when this file's table is empty, so a second boot is a no-op rather
141768
+ * than a re-copy that would resurrect rows deleted since.
141769
+ *
141770
+ * Returns the number of rows copied.
141771
+ */
141772
+ importLegacyManagedSessions(source) {
141773
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
141774
+ if (existing.n > 0) return 0;
141775
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
141776
+ if (!hasTable) return 0;
141777
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
141778
+ if (rows.length === 0) return 0;
141779
+ const columns = Object.keys(rows[0]);
141780
+ const insert = this.db.prepare(
141781
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
141782
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
141783
+ );
141784
+ this.db.transaction((batch) => {
141785
+ for (const row of batch) insert.run(row);
141786
+ })(rows);
141787
+ return rows.length;
141788
+ }
141789
+ close() {
141790
+ this.db.close();
141791
+ }
141792
+ };
141793
+
141721
141794
  // src/db/upload-records.ts
141722
141795
  async function recordUpload(pool2, instanceId, row) {
141723
141796
  if (!pool2) return;
@@ -142739,6 +142812,12 @@ function detectShellPrompt(lines) {
142739
142812
  return null;
142740
142813
  }
142741
142814
 
142815
+ // src/utils/deriveSessionName.ts
142816
+ function deriveSessionName(firstMessageText) {
142817
+ const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
142818
+ return firstLine.slice(0, 80);
142819
+ }
142820
+
142742
142821
  // src/pty-manager.ts
142743
142822
  var OUTPUT_BUFFER_MAX2 = 65536;
142744
142823
  var INPUT_HISTORY_MAX2 = 50;
@@ -143212,6 +143291,10 @@ var PTYManager = class {
143212
143291
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
143213
143292
  session.inputHistory.shift();
143214
143293
  }
143294
+ if (session.firstMessageText === void 0) {
143295
+ session.firstMessageText = text;
143296
+ session.sessionName = deriveSessionName(text);
143297
+ }
143215
143298
  this.onUserMessage?.(session.id, text, ts2);
143216
143299
  }
143217
143300
  getSession(sessionId) {
@@ -143477,7 +143560,9 @@ function toPublicSession2(s3) {
143477
143560
  ...s3.lastActivityAt != null && { lastActivityAt: s3.lastActivityAt },
143478
143561
  ...s3.statusSource != null && { statusSource: s3.statusSource },
143479
143562
  ...s3.statusUpdatedAt != null && { statusUpdatedAt: s3.statusUpdatedAt },
143480
- ...s3.filePath != null && { filePath: s3.filePath }
143563
+ ...s3.filePath != null && { filePath: s3.filePath },
143564
+ ...s3.sessionName != null && { sessionName: s3.sessionName },
143565
+ ...s3.firstMessageText != null && { firstMessageText: s3.firstMessageText }
143481
143566
  };
143482
143567
  }
143483
143568
  function stripAnsi2(str) {
@@ -145035,7 +145120,8 @@ function contentStateForSession(args) {
145035
145120
  status,
145036
145121
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
145037
145122
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
145038
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
145123
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
145124
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
145039
145125
  };
145040
145126
  }
145041
145127
  var LiveActivityNotifier = class {
@@ -145048,14 +145134,17 @@ var LiveActivityNotifier = class {
145048
145134
  serverId;
145049
145135
  serverLabel;
145050
145136
  /**
145051
- * Last status pushed per session.
145137
+ * Sessions with a currently open (pushed) activity.
145052
145138
  *
145053
- * Live Activity pushes are rate-limited by iOS and the surface only renders
145054
- * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
145055
- * budget spend for no visible change. This is what makes the notifier
145056
- * edge-triggered rather than level-triggered.
145139
+ * An activity opens on a `waiting_input running` edge (the user sent a
145140
+ * prompt) and closes on the matching `running waiting_input` edge (the
145141
+ * response, including any sub-agents, finished) so this set is what makes
145142
+ * the notifier per-turn rather than per-session. A session's very first
145143
+ * `running` (right after spawn, before any user prompt) has no prior
145144
+ * `waiting_input` and therefore no edge, so it never opens an activity —
145145
+ * this is what keeps a fresh/idle session from pushing anything.
145057
145146
  */
145058
- lastPushed = /* @__PURE__ */ new Map();
145147
+ openActivity = /* @__PURE__ */ new Map();
145059
145148
  /**
145060
145149
  * React to a session status change.
145061
145150
  *
@@ -145063,34 +145152,22 @@ var LiveActivityNotifier = class {
145063
145152
  * transition, so this returns a promise the caller may ignore and every error
145064
145153
  * is logged rather than propagated.
145065
145154
  */
145066
- async onStatusChange(session) {
145155
+ async onStatusChange(session, previousStatus) {
145067
145156
  const status = toLiveActivityStatus(session.status);
145068
145157
  try {
145069
145158
  if (!status) {
145070
- await this.endFor(session);
145159
+ if (this.openActivity.has(session.id)) await this.endFor(session);
145071
145160
  return;
145072
145161
  }
145073
- if (this.lastPushed.get(session.id) === status) return;
145074
- const contentState = contentStateForSession({
145075
- session,
145076
- serverId: this.serverId,
145077
- serverLabel: this.serverLabel
145078
- });
145079
- if (!contentState) return;
145080
- const outcome = await this.sender.send({
145081
- sessionId: session.id,
145082
- event: "update",
145083
- contentState
145084
- });
145085
- this.lastPushed.set(session.id, status);
145086
- if (outcome.attempted > 0) {
145087
- log4.info("live_activity.updated", {
145088
- event: "live_activity.updated",
145089
- sessionId: session.id,
145090
- status,
145091
- ...outcome
145092
- });
145162
+ if (status === "running" && previousStatus === "waiting_input") {
145163
+ await this.startTurn(session);
145164
+ return;
145165
+ }
145166
+ if (status === "waiting_input" && previousStatus === "running") {
145167
+ if (this.openActivity.has(session.id)) await this.endFor(session);
145168
+ return;
145093
145169
  }
145170
+ await this.maybeSendName(session);
145094
145171
  } catch (err) {
145095
145172
  log4.error("live_activity.notify_failed", {
145096
145173
  event: "live_activity.notify_failed",
@@ -145100,14 +145177,57 @@ var LiveActivityNotifier = class {
145100
145177
  });
145101
145178
  }
145102
145179
  }
145180
+ async startTurn(session) {
145181
+ const contentState = contentStateForSession({
145182
+ session,
145183
+ serverId: this.serverId,
145184
+ serverLabel: this.serverLabel
145185
+ });
145186
+ if (!contentState) return;
145187
+ const outcome = await this.sender.send({
145188
+ sessionId: session.id,
145189
+ event: "update",
145190
+ contentState
145191
+ });
145192
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
145193
+ if (outcome.attempted > 0) {
145194
+ log4.info("live_activity.updated", {
145195
+ event: "live_activity.updated",
145196
+ sessionId: session.id,
145197
+ status: contentState.status,
145198
+ ...outcome
145199
+ });
145200
+ }
145201
+ }
145202
+ async maybeSendName(session) {
145203
+ const open3 = this.openActivity.get(session.id);
145204
+ if (!open3 || open3.sessionNameSent || session.sessionName == null) return;
145205
+ const contentState = contentStateForSession({
145206
+ session,
145207
+ serverId: this.serverId,
145208
+ serverLabel: this.serverLabel
145209
+ });
145210
+ if (!contentState) return;
145211
+ const outcome = await this.sender.send({
145212
+ sessionId: session.id,
145213
+ event: "update",
145214
+ contentState
145215
+ });
145216
+ open3.sessionNameSent = true;
145217
+ if (outcome.attempted > 0) {
145218
+ log4.info("live_activity.updated", {
145219
+ event: "live_activity.updated",
145220
+ sessionId: session.id,
145221
+ status: contentState.status,
145222
+ ...outcome
145223
+ });
145224
+ }
145225
+ }
145103
145226
  async endFor(session) {
145104
- const lastStatus = this.lastPushed.get(session.id);
145105
- this.lastPushed.delete(session.id);
145227
+ this.openActivity.delete(session.id);
145228
+ const status = toLiveActivityStatus(session.status);
145106
145229
  const contentState = contentStateForSession({
145107
- session: {
145108
- ...session,
145109
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
145110
- },
145230
+ session: { ...session, status: status ?? "waiting_input" },
145111
145231
  serverId: this.serverId,
145112
145232
  serverLabel: this.serverLabel
145113
145233
  });
@@ -145121,9 +145241,9 @@ var LiveActivityNotifier = class {
145121
145241
  });
145122
145242
  }
145123
145243
  }
145124
- /** Drop cached state for a session, so a resume re-pushes its first status. */
145244
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
145125
145245
  forget(sessionId) {
145126
- this.lastPushed.delete(sessionId);
145246
+ this.openActivity.delete(sessionId);
145127
145247
  }
145128
145248
  };
145129
145249
 
@@ -145358,7 +145478,8 @@ var LiveActivityRenewalScheduler = class {
145358
145478
  status,
145359
145479
  startedAt,
145360
145480
  lastOutput: session.lastOutput ?? "",
145361
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
145481
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
145482
+ ...session.sessionName != null && { sessionName: session.sessionName }
145362
145483
  };
145363
145484
  try {
145364
145485
  await this.deps.sender.send({
@@ -145418,7 +145539,8 @@ var LiveActivityRenewalScheduler = class {
145418
145539
  // Carried through unchanged — the whole point of the renewal.
145419
145540
  startedAt: args.startedAt,
145420
145541
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
145421
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
145542
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
145543
+ ...session.sessionName != null && { sessionName: session.sessionName }
145422
145544
  },
145423
145545
  now: args.now,
145424
145546
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -145805,6 +145927,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
145805
145927
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
145806
145928
  }
145807
145929
 
145930
+ // src/services/sessions/rehydrateSessions.ts
145931
+ var REHYDRATE_MAX = 25;
145932
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
145933
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
145934
+ function shouldRehydrate(row, opts) {
145935
+ if (!opts.projectExists(row.project_path)) return false;
145936
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
145937
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
145938
+ return true;
145939
+ }
145940
+ function rowToStubSession(row) {
145941
+ return {
145942
+ id: row.session_id,
145943
+ provider: row.provider,
145944
+ projectPath: row.project_path,
145945
+ projectName: row.project_name,
145946
+ branch: row.branch,
145947
+ // No PTY exists for a stub, so this is the only truthful status.
145948
+ status: "idle",
145949
+ startedAt: new Date(row.started_at),
145950
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
145951
+ promptCount: row.prompt_count,
145952
+ lastOutput: "",
145953
+ rehydrated: true,
145954
+ ...row.session_name != null && { sessionName: row.session_name },
145955
+ ...row.project_id != null && { projectId: row.project_id },
145956
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
145957
+ ...row.resumed_from_conversation_id != null && {
145958
+ resumedFromConversationId: row.resumed_from_conversation_id
145959
+ },
145960
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
145961
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
145962
+ // Only `shutdown` crosses over. It is the one registry source that is also a
145963
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
145964
+ // streamer stopped this session. A crashed row still says `transition` over
145965
+ // a `running` status, and copying that here would attach observed-confidence
145966
+ // provenance to a status we derived at boot, so leave it unset instead.
145967
+ ...row.status_source === "shutdown" && {
145968
+ statusSource: "shutdown",
145969
+ statusUpdatedAt: new Date(row.status_updated_at)
145970
+ }
145971
+ };
145972
+ }
145973
+
145808
145974
  // src/agent/dedupe.ts
145809
145975
  function createProgressDedupeLRU(capacity) {
145810
145976
  if (!Number.isFinite(capacity) || capacity < 1) {
@@ -145978,14 +146144,15 @@ function managedToResponse(s3, ptyAttached) {
145978
146144
  // Lifecycle for a session this run knows about. `attached` while we hold
145979
146145
  // its PTY; once the PTY is gone the session is terminal from this run's
145980
146146
  // perspective — `failed` when it recorded a reason, else `completed`.
145981
- // Sessions left by *previous* runs never reach here: they aren't in the
145982
- // in-memory store, and the boot reconciler classifies them instead
145983
- // (docs/architecture/2026-07-24-durable-session-runtime.md).
145984
- lifecycle: ptyAttached ? "attached" : s3.failureReason != null ? "failed" : "completed",
145985
- lifecycleSource: ptyAttached ? "spawn" : "exit",
146147
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
146148
+ // the durable registry, so it is a previous run's session with no process
146149
+ // behind it — `resumable`, and `historical` rather than `managed`
146150
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
146151
+ lifecycle: ptyAttached ? "attached" : s3.rehydrated ? "resumable" : s3.failureReason != null ? "failed" : "completed",
146152
+ lifecycleSource: ptyAttached ? "spawn" : s3.rehydrated ? "reconcile" : "exit",
145986
146153
  // We spawned it, so `status` is the authoritative signal — no inferred
145987
146154
  // `activity` is attached for managed sessions.
145988
- ownership: "managed",
146155
+ ownership: s3.rehydrated ? "historical" : "managed",
145989
146156
  projectPath: s3.projectPath,
145990
146157
  projectName: s3.projectName,
145991
146158
  branch: s3.branch,
@@ -146575,10 +146742,13 @@ var StreamerServer = class {
146575
146742
  projectsRepo = null;
146576
146743
  conversationsRepo = null;
146577
146744
  sessionsRepo = null;
146578
- // Durable session registry (C1 Phase 2). Null when the cache DB failed to
146579
- // open — persistence degrades to today's in-memory-only behaviour rather than
146580
- // taking the server down with it, so every write goes through `?.`.
146745
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
146746
+ // — persistence degrades to today's in-memory-only behaviour rather than
146747
+ // taking the server down with it, so every write goes through `?.`. Note the
146748
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
146749
+ // null this repo and silently disable all session persistence.
146581
146750
  managedSessionsRepo = null;
146751
+ runtimeStore = null;
146582
146752
  // Identifies this streamer run. A registry row carrying a different id is a
146583
146753
  // session that outlived the process that started it.
146584
146754
  streamerInstanceId = (0, import_crypto13.randomUUID)();
@@ -146597,6 +146767,7 @@ var StreamerServer = class {
146597
146767
  liveActivityRenewal = null;
146598
146768
  discoveryCache = null;
146599
146769
  cacheDir;
146770
+ runtimeDbPath;
146600
146771
  tailSize;
146601
146772
  directoryDebounceMs;
146602
146773
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -146643,6 +146814,7 @@ var StreamerServer = class {
146643
146814
  this.claudeFlags = config2.claudeFlags ?? loadClaudeFlags();
146644
146815
  this.claudeExtraArgs = config2.claudeExtraArgs ?? loadClaudeExtraArgs();
146645
146816
  this.cacheDir = config2.cacheDir ?? loadCacheDir() ?? (0, import_path29.join)((0, import_os13.homedir)(), ".threadbase", "cache");
146817
+ this.runtimeDbPath = config2.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path29.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path29.join)((0, import_os13.homedir)(), ".threadbase"), "runtime.db");
146646
146818
  this.tailSize = config2.tailSize ?? loadTailSize() ?? 10;
146647
146819
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
146648
146820
  this.markScannerStaleDebounced = debounce(() => {
@@ -146813,6 +146985,7 @@ var StreamerServer = class {
146813
146985
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
146814
146986
  },
146815
146987
  onStatusChange: (session) => {
146988
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
146816
146989
  this.sessionStore.updateManaged(session.id, {
146817
146990
  status: session.status,
146818
146991
  completedAt: session.completedAt,
@@ -146867,7 +147040,7 @@ var StreamerServer = class {
146867
147040
  if (resp) {
146868
147041
  this.wsHub.broadcast({ type: "session_update", session: resp });
146869
147042
  }
146870
- void this.liveActivityNotifier?.onStatusChange(session);
147043
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
146871
147044
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
146872
147045
  }
146873
147046
  });
@@ -146918,6 +147091,7 @@ var StreamerServer = class {
146918
147091
  conversationsRepo: () => this.conversationsRepo,
146919
147092
  sessionsRepo: () => this.sessionsRepo,
146920
147093
  cacheMetadataRepo: () => this.cacheMetadataRepo,
147094
+ runtimeStore: () => this.runtimeStore,
146921
147095
  ptyAttachedIds: () => this.ptyAttachedIds(),
146922
147096
  handleListSessions: (url2, res) => this.handleListSessions(url2, res),
146923
147097
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -147217,6 +147391,58 @@ var StreamerServer = class {
147217
147391
  }
147218
147392
  return verdicts;
147219
147393
  }
147394
+ /**
147395
+ * Seed the session list with what previous runs left behind (persistence plan
147396
+ * Phase 1, gaps G1/G2/G8).
147397
+ *
147398
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
147399
+ * SessionResponse that already exists, and after a clean restart none does —
147400
+ * `SessionStore` starts empty. So the user's session did not become
147401
+ * `resumable`, it became *absent*. This is the half that puts it back.
147402
+ *
147403
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
147404
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
147405
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
147406
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
147407
+ * by id rather than duplicating it.
147408
+ */
147409
+ rehydratePreviousSessions(verdicts) {
147410
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
147411
+ try {
147412
+ const now = Date.now();
147413
+ const rows = this.managedSessionsRepo.listRecoverable({
147414
+ sinceMs: now - REHYDRATE_WINDOW_MS,
147415
+ limit: REHYDRATE_MAX + 1
147416
+ });
147417
+ const truncated = rows.length > REHYDRATE_MAX;
147418
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
147419
+ if (candidates.length === 0) return;
147420
+ const lifecycleByVerdict = new Map(verdicts.map((v2) => [v2.sessionId, v2.lifecycle]));
147421
+ let rehydrated = 0;
147422
+ for (const row of candidates) {
147423
+ if (this.sessionStore.getManaged(row.session_id)) continue;
147424
+ if (!shouldRehydrate(row, { now, projectExists: import_fs30.existsSync })) continue;
147425
+ this.sessionStore.addManaged(rowToStubSession(row));
147426
+ this.sessionLifecycles.set(
147427
+ row.session_id,
147428
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
147429
+ );
147430
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
147431
+ rehydrated++;
147432
+ }
147433
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
147434
+ event: "sessions.rehydrated",
147435
+ rehydrated,
147436
+ skipped: candidates.length - rehydrated,
147437
+ truncated
147438
+ });
147439
+ } catch (err) {
147440
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
147441
+ event: "sessions.rehydrate_failed",
147442
+ err
147443
+ });
147444
+ }
147445
+ }
147220
147446
  /**
147221
147447
  * Pick a token guaranteed to appear in the spawned process's argv, for the
147222
147448
  * reconciler's pid-reuse guard.
@@ -147450,6 +147676,17 @@ var StreamerServer = class {
147450
147676
  port,
147451
147677
  event: "server.listening"
147452
147678
  });
147679
+ try {
147680
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
147681
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
147682
+ } catch (err) {
147683
+ const message = err instanceof Error ? err.message : String(err);
147684
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
147685
+ this.log.error(
147686
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
147687
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
147688
+ );
147689
+ }
147453
147690
  try {
147454
147691
  this.cache = ConversationCache.open(
147455
147692
  (0, import_path29.join)(this.cacheDir, "cache.db"),
@@ -147477,8 +147714,20 @@ var StreamerServer = class {
147477
147714
  this.projectsRepo = new ProjectsRepository(db);
147478
147715
  this.conversationsRepo = new ConversationsRepository(this.cache);
147479
147716
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
147480
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
147481
- void this.reconcilePreviousSessions();
147717
+ try {
147718
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
147719
+ if (copied > 0) {
147720
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
147721
+ copied,
147722
+ event: "runtime.legacy_import"
147723
+ });
147724
+ }
147725
+ } catch (err) {
147726
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
147727
+ event: "runtime.legacy_import_failed",
147728
+ err
147729
+ });
147730
+ }
147482
147731
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
147483
147732
  this.pushRepo = new PushRepository(db);
147484
147733
  this.devicesRepo = new DevicesRepository(db);
@@ -147514,6 +147763,7 @@ var StreamerServer = class {
147514
147763
  );
147515
147764
  this.scannerPersistenceDisabled = true;
147516
147765
  }
147766
+ void this.reconcilePreviousSessions().then((v2) => this.rehydratePreviousSessions(v2));
147517
147767
  if (this.skipStartupWarmup) {
147518
147768
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
147519
147769
  event: "cache.warmup_skipped"
@@ -147721,6 +147971,7 @@ var StreamerServer = class {
147721
147971
  this.allScanners.clear();
147722
147972
  this.scanner = null;
147723
147973
  this.cache?.close();
147974
+ this.runtimeStore?.close();
147724
147975
  this.ptyManager.dispose();
147725
147976
  this.fileWatcher.dispose();
147726
147977
  this.externalTails.clear();
@@ -148168,7 +148419,8 @@ var StreamerServer = class {
148168
148419
  }
148169
148420
  handleSessionsCount(res) {
148170
148421
  if (this.rejectIfWarmingUp(res)) return;
148171
- json2(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
148422
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s3) => s3.ownership !== "historical").length;
148423
+ json2(res, 200, { total });
148172
148424
  }
148173
148425
  handleGetRecentSessions(url2, res) {
148174
148426
  if (this.rejectIfWarmingUp(res)) return;