@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/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(`
@@ -141523,7 +141533,8 @@ var ManagedSessionsRepository = class {
141523
141533
  completed_at = @completed_at,
141524
141534
  last_activity_at = @last_activity_at,
141525
141535
  prompt_count = @prompt_count,
141526
- failure_reason = COALESCE(@failure_reason, failure_reason)
141536
+ failure_reason = COALESCE(@failure_reason, failure_reason),
141537
+ session_name = COALESCE(@session_name, session_name)
141527
141538
  WHERE session_id = @session_id
141528
141539
  `);
141529
141540
  this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
@@ -141532,6 +141543,13 @@ var ManagedSessionsRepository = class {
141532
141543
  WHERE completed_at IS NULL
141533
141544
  ORDER BY started_at ASC
141534
141545
  `);
141546
+ this.listRecoverableStmt = db.prepare(`
141547
+ SELECT * FROM managed_sessions
141548
+ WHERE (completed_at IS NULL OR status_source = 'shutdown')
141549
+ AND status_updated_at >= @since
141550
+ ORDER BY status_updated_at DESC
141551
+ LIMIT @limit
141552
+ `);
141535
141553
  this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
141536
141554
  }
141537
141555
  /** Record a session at spawn, or refresh every field of an existing row. */
@@ -141573,7 +141591,8 @@ var ManagedSessionsRepository = class {
141573
141591
  completed_at: fields.completedAt?.getTime() ?? null,
141574
141592
  last_activity_at: fields.lastActivityAt?.getTime() ?? null,
141575
141593
  prompt_count: fields.promptCount ?? 0,
141576
- failure_reason: fields.failureReason ?? null
141594
+ failure_reason: fields.failureReason ?? null,
141595
+ session_name: fields.sessionName ?? null
141577
141596
  });
141578
141597
  }
141579
141598
  get(sessionId) {
@@ -141583,6 +141602,14 @@ var ManagedSessionsRepository = class {
141583
141602
  listNonTerminal() {
141584
141603
  return this.listNonTerminalStmt.all();
141585
141604
  }
141605
+ /**
141606
+ * Rows a restart could bring back: still open, or closed by our own shutdown,
141607
+ * and touched no longer ago than `sinceMs`. Newest first, capped — the caller
141608
+ * decides which of these actually deserve rehydrating (`shouldRehydrate`).
141609
+ */
141610
+ listRecoverable({ sinceMs, limit }) {
141611
+ return this.listRecoverableStmt.all({ since: sinceMs, limit });
141612
+ }
141586
141613
  delete(sessionId) {
141587
141614
  this.deleteStmt.run(sessionId);
141588
141615
  }
@@ -141718,6 +141745,54 @@ var SessionsRepository = class {
141718
141745
  }
141719
141746
  };
141720
141747
 
141748
+ // src/db/runtime-store.ts
141749
+ var import_better_sqlite33 = __toESM(require("better-sqlite3"), 1);
141750
+ var RuntimeStore = class _RuntimeStore {
141751
+ constructor(db) {
141752
+ this.db = db;
141753
+ }
141754
+ db;
141755
+ static open(dbPath, migrationsDir) {
141756
+ const db = new import_better_sqlite33.default(dbPath);
141757
+ db.pragma("journal_mode = WAL");
141758
+ runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
141759
+ return new _RuntimeStore(db);
141760
+ }
141761
+ getDatabase() {
141762
+ return this.db;
141763
+ }
141764
+ /**
141765
+ * One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
141766
+ *
141767
+ * Non-destructive by design: the source table is left in place so an older
141768
+ * streamer rolled back onto the same machine still finds its registry. Runs
141769
+ * only when this file's table is empty, so a second boot is a no-op rather
141770
+ * than a re-copy that would resurrect rows deleted since.
141771
+ *
141772
+ * Returns the number of rows copied.
141773
+ */
141774
+ importLegacyManagedSessions(source) {
141775
+ const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
141776
+ if (existing.n > 0) return 0;
141777
+ const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
141778
+ if (!hasTable) return 0;
141779
+ const rows = source.prepare("SELECT * FROM managed_sessions").all();
141780
+ if (rows.length === 0) return 0;
141781
+ const columns = Object.keys(rows[0]);
141782
+ const insert = this.db.prepare(
141783
+ `INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
141784
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
141785
+ );
141786
+ this.db.transaction((batch) => {
141787
+ for (const row of batch) insert.run(row);
141788
+ })(rows);
141789
+ return rows.length;
141790
+ }
141791
+ close() {
141792
+ this.db.close();
141793
+ }
141794
+ };
141795
+
141721
141796
  // src/db/upload-records.ts
141722
141797
  async function recordUpload(pool2, instanceId, row) {
141723
141798
  if (!pool2) return;
@@ -142739,6 +142814,12 @@ function detectShellPrompt(lines) {
142739
142814
  return null;
142740
142815
  }
142741
142816
 
142817
+ // src/utils/deriveSessionName.ts
142818
+ function deriveSessionName(firstMessageText) {
142819
+ const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
142820
+ return firstLine.slice(0, 80);
142821
+ }
142822
+
142742
142823
  // src/pty-manager.ts
142743
142824
  var OUTPUT_BUFFER_MAX2 = 65536;
142744
142825
  var INPUT_HISTORY_MAX2 = 50;
@@ -142746,8 +142827,8 @@ var PTY_COLS2 = 120;
142746
142827
  var PTY_ROWS2 = 40;
142747
142828
  var SCREEN_SCROLLBACK2 = 1e3;
142748
142829
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
142749
- var PROMPT_MARKER_FALLBACK_MS = 1e4;
142750
142830
  var QUIET_DETECT_MS2 = 500;
142831
+ var CLAUDE_READY_FALLBACK_MS = 8e3;
142751
142832
  function buildPasteBytes(input) {
142752
142833
  return `\x1B[200~${input}\x1B[201~`;
142753
142834
  }
@@ -142823,8 +142904,8 @@ var PTYManager = class {
142823
142904
  // silently lost — the "dot bug".
142824
142905
  queuedInputs = /* @__PURE__ */ new Map();
142825
142906
  log;
142826
- // Timestamp of first PTY chunk per session; drives the prompt-marker fallback
142827
- // when neither nor shows up within PROMPT_MARKER_FALLBACK_MS.
142907
+ // Timestamp of first PTY chunk per session; used for the [pty.ready] elapsed
142908
+ // measurement so a slow boot is visible in the logs.
142828
142909
  firstChunkAt = /* @__PURE__ */ new Map();
142829
142910
  // Per-session chunk counter and last-chunk timestamp. Diagnostic-only,
142830
142911
  // feeds the [pty.chunk] log lines so we can trace whether Claude responded
@@ -142835,6 +142916,8 @@ var PTYManager = class {
142835
142916
  // QUIET_DETECT_MS after the last chunk so ready/prompt detection doesn't
142836
142917
  // wait for another chunk that may never arrive (Claude blocked on input).
142837
142918
  quietCheckers = /* @__PURE__ */ new Map();
142919
+ // Per-session flat backstop from the first chunk (CLAUDE_READY_FALLBACK_MS).
142920
+ readyFallbackTimers = /* @__PURE__ */ new Map();
142838
142921
  // In-flight start()/startFresh() calls keyed by sessionId. A second
142839
142922
  // concurrent resume for the same session (double-tap, client retry) awaits
142840
142923
  // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
@@ -142920,6 +143003,7 @@ var PTYManager = class {
142920
143003
  };
142921
143004
  this.sessions.set(sessionId, session);
142922
143005
  this.pendingReady.add(sessionId);
143006
+ this.armReadyFallback(sessionId);
142923
143007
  proc.onData((data) => {
142924
143008
  this.handleOutput(sessionId, data);
142925
143009
  });
@@ -142980,6 +143064,7 @@ var PTYManager = class {
142980
143064
  };
142981
143065
  this.sessions.set(sessionId, session);
142982
143066
  this.pendingReady.add(sessionId);
143067
+ this.armReadyFallback(sessionId);
142983
143068
  proc.onData((data) => {
142984
143069
  this.handleOutput(sessionId, data);
142985
143070
  });
@@ -143154,6 +143239,7 @@ var PTYManager = class {
143154
143239
  this.shellPromptOpen.delete(sessionId);
143155
143240
  this.quietCheckers.get(sessionId)?.cancel();
143156
143241
  this.quietCheckers.delete(sessionId);
143242
+ this.clearReadyFallback(sessionId);
143157
143243
  try {
143158
143244
  session.process.kill("SIGINT");
143159
143245
  } catch {
@@ -143212,6 +143298,10 @@ var PTYManager = class {
143212
143298
  if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
143213
143299
  session.inputHistory.shift();
143214
143300
  }
143301
+ if (session.firstMessageText === void 0) {
143302
+ session.firstMessageText = text;
143303
+ session.sessionName = deriveSessionName(text);
143304
+ }
143215
143305
  this.onUserMessage?.(session.id, text, ts2);
143216
143306
  }
143217
143307
  getSession(sessionId) {
@@ -143238,6 +143328,8 @@ var PTYManager = class {
143238
143328
  this.lastChunkAt.clear();
143239
143329
  for (const quiet of this.quietCheckers.values()) quiet.cancel();
143240
143330
  this.quietCheckers.clear();
143331
+ for (const timer of this.readyFallbackTimers.values()) clearTimeout(timer);
143332
+ this.readyFallbackTimers.clear();
143241
143333
  this.permissionOpen.clear();
143242
143334
  this.lastScreenQuestionKey.clear();
143243
143335
  this.shellPromptOpen.clear();
@@ -143280,8 +143372,6 @@ var PTYManager = class {
143280
143372
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m2) => stripped.includes(m2));
143281
143373
  if (session.status === "running" && matchedMarker) {
143282
143374
  this.markReady(sessionId, session, "prompt-marker", `marker:${matchedMarker}`);
143283
- } else if (session.status === "running" && this.pendingReady.has(sessionId) && now - (this.firstChunkAt.get(sessionId) ?? now) >= PROMPT_MARKER_FALLBACK_MS) {
143284
- this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
143285
143375
  }
143286
143376
  this.onOutput?.(sessionId, data);
143287
143377
  this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
@@ -143381,7 +143471,13 @@ var PTYManager = class {
143381
143471
  const session = this.sessions.get(sessionId);
143382
143472
  if (session?.status !== "running") return;
143383
143473
  if (this.pendingReady.has(sessionId)) {
143384
- this.markReady(sessionId, session, "quiet-fallback", "quiet:timeout");
143474
+ this.recheckReadyFromScreen(sessionId).catch((err) => {
143475
+ this.log.warn("[pty.ready] boot screen recheck failed", {
143476
+ event: "pty.ready_recheck_failed",
143477
+ sessionId,
143478
+ err
143479
+ });
143480
+ });
143385
143481
  } else {
143386
143482
  this.recheckReadyFromScreen(sessionId).catch((err) => {
143387
143483
  this.log.warn("[pty.ready] screen recheck failed", {
@@ -143413,9 +143509,32 @@ var PTYManager = class {
143413
143509
  this.markReady(sessionId, session, "screen-marker", `quiet:screen-marker:${matchedMarker}`);
143414
143510
  }
143415
143511
  }
143512
+ // Flat backstop from the first chunk: if neither a prompt marker nor the
143513
+ // screen recheck settles the session within CLAUDE_READY_FALLBACK_MS, mark it
143514
+ // ready anyway so start requests resolve and queued input is not held
143515
+ // forever. This is what makes the quiet-checker safe to be strict — a boot
143516
+ // variant whose marker we cannot see still recovers, just 8s later instead of
143517
+ // 500ms sooner and wrong. unref() so it never holds the process open.
143518
+ armReadyFallback(sessionId) {
143519
+ const timer = setTimeout(() => {
143520
+ this.readyFallbackTimers.delete(sessionId);
143521
+ const session = this.sessions.get(sessionId);
143522
+ if (session?.status === "running" && this.pendingReady.has(sessionId)) {
143523
+ this.markReady(sessionId, session, "timeout-fallback", "fallback:timeout");
143524
+ }
143525
+ }, CLAUDE_READY_FALLBACK_MS);
143526
+ timer.unref?.();
143527
+ this.readyFallbackTimers.set(sessionId, timer);
143528
+ }
143529
+ clearReadyFallback(sessionId) {
143530
+ const timer = this.readyFallbackTimers.get(sessionId);
143531
+ if (timer) clearTimeout(timer);
143532
+ this.readyFallbackTimers.delete(sessionId);
143533
+ }
143416
143534
  // Transition a session from "running" to "waiting_input", clear pendingReady,
143417
143535
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
143418
143536
  markReady(sessionId, session, source, reason) {
143537
+ this.clearReadyFallback(sessionId);
143419
143538
  session.lastActivityAt = /* @__PURE__ */ new Date();
143420
143539
  session.status = "waiting_input";
143421
143540
  session.statusSource = source;
@@ -143459,6 +143578,7 @@ var PTYManager = class {
143459
143578
  this.shellPromptOpen.delete(sessionId);
143460
143579
  this.quietCheckers.get(sessionId)?.cancel();
143461
143580
  this.quietCheckers.delete(sessionId);
143581
+ this.clearReadyFallback(sessionId);
143462
143582
  }
143463
143583
  };
143464
143584
  function toPublicSession2(s3) {
@@ -143477,7 +143597,9 @@ function toPublicSession2(s3) {
143477
143597
  ...s3.lastActivityAt != null && { lastActivityAt: s3.lastActivityAt },
143478
143598
  ...s3.statusSource != null && { statusSource: s3.statusSource },
143479
143599
  ...s3.statusUpdatedAt != null && { statusUpdatedAt: s3.statusUpdatedAt },
143480
- ...s3.filePath != null && { filePath: s3.filePath }
143600
+ ...s3.filePath != null && { filePath: s3.filePath },
143601
+ ...s3.sessionName != null && { sessionName: s3.sessionName },
143602
+ ...s3.firstMessageText != null && { firstMessageText: s3.firstMessageText }
143481
143603
  };
143482
143604
  }
143483
143605
  function stripAnsi2(str) {
@@ -145035,7 +145157,8 @@ function contentStateForSession(args) {
145035
145157
  status,
145036
145158
  startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
145037
145159
  lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
145038
- ...args.serverLabel != null && { serverLabel: args.serverLabel }
145160
+ ...args.serverLabel != null && { serverLabel: args.serverLabel },
145161
+ ...args.session.sessionName != null && { sessionName: args.session.sessionName }
145039
145162
  };
145040
145163
  }
145041
145164
  var LiveActivityNotifier = class {
@@ -145048,14 +145171,17 @@ var LiveActivityNotifier = class {
145048
145171
  serverId;
145049
145172
  serverLabel;
145050
145173
  /**
145051
- * Last status pushed per session.
145174
+ * Sessions with a currently open (pushed) activity.
145052
145175
  *
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.
145176
+ * An activity opens on a `waiting_input running` edge (the user sent a
145177
+ * prompt) and closes on the matching `running waiting_input` edge (the
145178
+ * response, including any sub-agents, finished) so this set is what makes
145179
+ * the notifier per-turn rather than per-session. A session's very first
145180
+ * `running` (right after spawn, before any user prompt) has no prior
145181
+ * `waiting_input` and therefore no edge, so it never opens an activity —
145182
+ * this is what keeps a fresh/idle session from pushing anything.
145057
145183
  */
145058
- lastPushed = /* @__PURE__ */ new Map();
145184
+ openActivity = /* @__PURE__ */ new Map();
145059
145185
  /**
145060
145186
  * React to a session status change.
145061
145187
  *
@@ -145063,34 +145189,22 @@ var LiveActivityNotifier = class {
145063
145189
  * transition, so this returns a promise the caller may ignore and every error
145064
145190
  * is logged rather than propagated.
145065
145191
  */
145066
- async onStatusChange(session) {
145192
+ async onStatusChange(session, previousStatus) {
145067
145193
  const status = toLiveActivityStatus(session.status);
145068
145194
  try {
145069
145195
  if (!status) {
145070
- await this.endFor(session);
145196
+ if (this.openActivity.has(session.id)) await this.endFor(session);
145071
145197
  return;
145072
145198
  }
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
- });
145199
+ if (status === "running" && previousStatus === "waiting_input") {
145200
+ await this.startTurn(session);
145201
+ return;
145093
145202
  }
145203
+ if (status === "waiting_input" && previousStatus === "running") {
145204
+ if (this.openActivity.has(session.id)) await this.endFor(session);
145205
+ return;
145206
+ }
145207
+ await this.maybeSendName(session);
145094
145208
  } catch (err) {
145095
145209
  log4.error("live_activity.notify_failed", {
145096
145210
  event: "live_activity.notify_failed",
@@ -145100,14 +145214,57 @@ var LiveActivityNotifier = class {
145100
145214
  });
145101
145215
  }
145102
145216
  }
145217
+ async startTurn(session) {
145218
+ const contentState = contentStateForSession({
145219
+ session,
145220
+ serverId: this.serverId,
145221
+ serverLabel: this.serverLabel
145222
+ });
145223
+ if (!contentState) return;
145224
+ const outcome = await this.sender.send({
145225
+ sessionId: session.id,
145226
+ event: "update",
145227
+ contentState
145228
+ });
145229
+ this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
145230
+ if (outcome.attempted > 0) {
145231
+ log4.info("live_activity.updated", {
145232
+ event: "live_activity.updated",
145233
+ sessionId: session.id,
145234
+ status: contentState.status,
145235
+ ...outcome
145236
+ });
145237
+ }
145238
+ }
145239
+ async maybeSendName(session) {
145240
+ const open3 = this.openActivity.get(session.id);
145241
+ if (!open3 || open3.sessionNameSent || session.sessionName == null) return;
145242
+ const contentState = contentStateForSession({
145243
+ session,
145244
+ serverId: this.serverId,
145245
+ serverLabel: this.serverLabel
145246
+ });
145247
+ if (!contentState) return;
145248
+ const outcome = await this.sender.send({
145249
+ sessionId: session.id,
145250
+ event: "update",
145251
+ contentState
145252
+ });
145253
+ open3.sessionNameSent = true;
145254
+ if (outcome.attempted > 0) {
145255
+ log4.info("live_activity.updated", {
145256
+ event: "live_activity.updated",
145257
+ sessionId: session.id,
145258
+ status: contentState.status,
145259
+ ...outcome
145260
+ });
145261
+ }
145262
+ }
145103
145263
  async endFor(session) {
145104
- const lastStatus = this.lastPushed.get(session.id);
145105
- this.lastPushed.delete(session.id);
145264
+ this.openActivity.delete(session.id);
145265
+ const status = toLiveActivityStatus(session.status);
145106
145266
  const contentState = contentStateForSession({
145107
- session: {
145108
- ...session,
145109
- status: lastStatus === "waiting_input" ? "waiting_input" : "running"
145110
- },
145267
+ session: { ...session, status: status ?? "waiting_input" },
145111
145268
  serverId: this.serverId,
145112
145269
  serverLabel: this.serverLabel
145113
145270
  });
@@ -145121,9 +145278,9 @@ var LiveActivityNotifier = class {
145121
145278
  });
145122
145279
  }
145123
145280
  }
145124
- /** Drop cached state for a session, so a resume re-pushes its first status. */
145281
+ /** Drop cached state for a session, so a resume re-opens on its next turn. */
145125
145282
  forget(sessionId) {
145126
- this.lastPushed.delete(sessionId);
145283
+ this.openActivity.delete(sessionId);
145127
145284
  }
145128
145285
  };
145129
145286
 
@@ -145358,7 +145515,8 @@ var LiveActivityRenewalScheduler = class {
145358
145515
  status,
145359
145516
  startedAt,
145360
145517
  lastOutput: session.lastOutput ?? "",
145361
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
145518
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
145519
+ ...session.sessionName != null && { sessionName: session.sessionName }
145362
145520
  };
145363
145521
  try {
145364
145522
  await this.deps.sender.send({
@@ -145418,7 +145576,8 @@ var LiveActivityRenewalScheduler = class {
145418
145576
  // Carried through unchanged — the whole point of the renewal.
145419
145577
  startedAt: args.startedAt,
145420
145578
  lastOutput: truncateLastOutput(session.lastOutput ?? ""),
145421
- ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
145579
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
145580
+ ...session.sessionName != null && { sessionName: session.sessionName }
145422
145581
  },
145423
145582
  now: args.now,
145424
145583
  staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
@@ -145805,6 +145964,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
145805
145964
  return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
145806
145965
  }
145807
145966
 
145967
+ // src/services/sessions/rehydrateSessions.ts
145968
+ var REHYDRATE_MAX = 25;
145969
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
145970
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
145971
+ function shouldRehydrate(row, opts) {
145972
+ if (!opts.projectExists(row.project_path)) return false;
145973
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
145974
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
145975
+ return true;
145976
+ }
145977
+ function rowToStubSession(row) {
145978
+ return {
145979
+ id: row.session_id,
145980
+ provider: row.provider,
145981
+ projectPath: row.project_path,
145982
+ projectName: row.project_name,
145983
+ branch: row.branch,
145984
+ // No PTY exists for a stub, so this is the only truthful status.
145985
+ status: "idle",
145986
+ startedAt: new Date(row.started_at),
145987
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
145988
+ promptCount: row.prompt_count,
145989
+ lastOutput: "",
145990
+ rehydrated: true,
145991
+ ...row.session_name != null && { sessionName: row.session_name },
145992
+ ...row.project_id != null && { projectId: row.project_id },
145993
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
145994
+ ...row.resumed_from_conversation_id != null && {
145995
+ resumedFromConversationId: row.resumed_from_conversation_id
145996
+ },
145997
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
145998
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
145999
+ // Only `shutdown` crosses over. It is the one registry source that is also a
146000
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
146001
+ // streamer stopped this session. A crashed row still says `transition` over
146002
+ // a `running` status, and copying that here would attach observed-confidence
146003
+ // provenance to a status we derived at boot, so leave it unset instead.
146004
+ ...row.status_source === "shutdown" && {
146005
+ statusSource: "shutdown",
146006
+ statusUpdatedAt: new Date(row.status_updated_at)
146007
+ }
146008
+ };
146009
+ }
146010
+
145808
146011
  // src/agent/dedupe.ts
145809
146012
  function createProgressDedupeLRU(capacity) {
145810
146013
  if (!Number.isFinite(capacity) || capacity < 1) {
@@ -145978,14 +146181,15 @@ function managedToResponse(s3, ptyAttached) {
145978
146181
  // Lifecycle for a session this run knows about. `attached` while we hold
145979
146182
  // its PTY; once the PTY is gone the session is terminal from this run's
145980
146183
  // 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",
146184
+ // A `rehydrated` stub is the exception: the boot rehydrator seeded it from
146185
+ // the durable registry, so it is a previous run's session with no process
146186
+ // behind it — `resumable`, and `historical` rather than `managed`
146187
+ // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
146188
+ lifecycle: ptyAttached ? "attached" : s3.rehydrated ? "resumable" : s3.failureReason != null ? "failed" : "completed",
146189
+ lifecycleSource: ptyAttached ? "spawn" : s3.rehydrated ? "reconcile" : "exit",
145986
146190
  // We spawned it, so `status` is the authoritative signal — no inferred
145987
146191
  // `activity` is attached for managed sessions.
145988
- ownership: "managed",
146192
+ ownership: s3.rehydrated ? "historical" : "managed",
145989
146193
  projectPath: s3.projectPath,
145990
146194
  projectName: s3.projectName,
145991
146195
  branch: s3.branch,
@@ -146575,10 +146779,13 @@ var StreamerServer = class {
146575
146779
  projectsRepo = null;
146576
146780
  conversationsRepo = null;
146577
146781
  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 `?.`.
146782
+ // Durable session registry (C1 Phase 2). Null when runtime.db failed to open
146783
+ // — persistence degrades to today's in-memory-only behaviour rather than
146784
+ // taking the server down with it, so every write goes through `?.`. Note the
146785
+ // handle is runtime.db, NOT the conversation cache: a cache failure used to
146786
+ // null this repo and silently disable all session persistence.
146581
146787
  managedSessionsRepo = null;
146788
+ runtimeStore = null;
146582
146789
  // Identifies this streamer run. A registry row carrying a different id is a
146583
146790
  // session that outlived the process that started it.
146584
146791
  streamerInstanceId = (0, import_crypto13.randomUUID)();
@@ -146597,6 +146804,7 @@ var StreamerServer = class {
146597
146804
  liveActivityRenewal = null;
146598
146805
  discoveryCache = null;
146599
146806
  cacheDir;
146807
+ runtimeDbPath;
146600
146808
  tailSize;
146601
146809
  directoryDebounceMs;
146602
146810
  // Trailing-debounced trigger that flags the scanner stale after a quiet
@@ -146643,6 +146851,7 @@ var StreamerServer = class {
146643
146851
  this.claudeFlags = config2.claudeFlags ?? loadClaudeFlags();
146644
146852
  this.claudeExtraArgs = config2.claudeExtraArgs ?? loadClaudeExtraArgs();
146645
146853
  this.cacheDir = config2.cacheDir ?? loadCacheDir() ?? (0, import_path29.join)((0, import_os13.homedir)(), ".threadbase", "cache");
146854
+ 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
146855
  this.tailSize = config2.tailSize ?? loadTailSize() ?? 10;
146647
146856
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
146648
146857
  this.markScannerStaleDebounced = debounce(() => {
@@ -146813,6 +147022,7 @@ var StreamerServer = class {
146813
147022
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
146814
147023
  },
146815
147024
  onStatusChange: (session) => {
147025
+ const previousStatus = this.sessionStore.getManaged(session.id)?.status;
146816
147026
  this.sessionStore.updateManaged(session.id, {
146817
147027
  status: session.status,
146818
147028
  completedAt: session.completedAt,
@@ -146826,7 +147036,11 @@ var StreamerServer = class {
146826
147036
  completedAt: session.completedAt,
146827
147037
  lastActivityAt: session.lastActivityAt ?? null,
146828
147038
  promptCount: session.promptCount,
146829
- failureReason: session.failureReason ?? null
147039
+ failureReason: session.failureReason ?? null,
147040
+ // Derived from the first user message, so it does not exist yet at
147041
+ // recordSpawn. The input that produces it also flips
147042
+ // waiting_input→running, which lands here.
147043
+ sessionName: session.sessionName ?? null
146830
147044
  }
146831
147045
  );
146832
147046
  if (session.status === "waiting_input" || session.status === "idle") {
@@ -146867,7 +147081,7 @@ var StreamerServer = class {
146867
147081
  if (resp) {
146868
147082
  this.wsHub.broadcast({ type: "session_update", session: resp });
146869
147083
  }
146870
- void this.liveActivityNotifier?.onStatusChange(session);
147084
+ void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
146871
147085
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
146872
147086
  }
146873
147087
  });
@@ -146918,6 +147132,7 @@ var StreamerServer = class {
146918
147132
  conversationsRepo: () => this.conversationsRepo,
146919
147133
  sessionsRepo: () => this.sessionsRepo,
146920
147134
  cacheMetadataRepo: () => this.cacheMetadataRepo,
147135
+ runtimeStore: () => this.runtimeStore,
146921
147136
  ptyAttachedIds: () => this.ptyAttachedIds(),
146922
147137
  handleListSessions: (url2, res) => this.handleListSessions(url2, res),
146923
147138
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -147217,6 +147432,58 @@ var StreamerServer = class {
147217
147432
  }
147218
147433
  return verdicts;
147219
147434
  }
147435
+ /**
147436
+ * Seed the session list with what previous runs left behind (persistence plan
147437
+ * Phase 1, gaps G1/G2/G8).
147438
+ *
147439
+ * Reconciliation classifies rows and stops there; a verdict is overlaid onto a
147440
+ * SessionResponse that already exists, and after a clean restart none does —
147441
+ * `SessionStore` starts empty. So the user's session did not become
147442
+ * `resumable`, it became *absent*. This is the half that puts it back.
147443
+ *
147444
+ * The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
147445
+ * so `reapIdleSessions` and `startGraceTimer` — both of which iterate
147446
+ * `ptyManager.listSessions()` — cannot observe them. A later resume calls
147447
+ * `sessionStore.addManaged` with the real session, which overwrites the stub
147448
+ * by id rather than duplicating it.
147449
+ */
147450
+ rehydratePreviousSessions(verdicts) {
147451
+ if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
147452
+ try {
147453
+ const now = Date.now();
147454
+ const rows = this.managedSessionsRepo.listRecoverable({
147455
+ sinceMs: now - REHYDRATE_WINDOW_MS,
147456
+ limit: REHYDRATE_MAX + 1
147457
+ });
147458
+ const truncated = rows.length > REHYDRATE_MAX;
147459
+ const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
147460
+ if (candidates.length === 0) return;
147461
+ const lifecycleByVerdict = new Map(verdicts.map((v2) => [v2.sessionId, v2.lifecycle]));
147462
+ let rehydrated = 0;
147463
+ for (const row of candidates) {
147464
+ if (this.sessionStore.getManaged(row.session_id)) continue;
147465
+ if (!shouldRehydrate(row, { now, projectExists: import_fs30.existsSync })) continue;
147466
+ this.sessionStore.addManaged(rowToStubSession(row));
147467
+ this.sessionLifecycles.set(
147468
+ row.session_id,
147469
+ lifecycleByVerdict.get(row.session_id) ?? "resumable"
147470
+ );
147471
+ if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
147472
+ rehydrated++;
147473
+ }
147474
+ this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
147475
+ event: "sessions.rehydrated",
147476
+ rehydrated,
147477
+ skipped: candidates.length - rehydrated,
147478
+ truncated
147479
+ });
147480
+ } catch (err) {
147481
+ this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
147482
+ event: "sessions.rehydrate_failed",
147483
+ err
147484
+ });
147485
+ }
147486
+ }
147220
147487
  /**
147221
147488
  * Pick a token guaranteed to appear in the spawned process's argv, for the
147222
147489
  * reconciler's pid-reuse guard.
@@ -147450,6 +147717,17 @@ var StreamerServer = class {
147450
147717
  port,
147451
147718
  event: "server.listening"
147452
147719
  });
147720
+ try {
147721
+ this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
147722
+ this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
147723
+ } catch (err) {
147724
+ const message = err instanceof Error ? err.message : String(err);
147725
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
147726
+ this.log.error(
147727
+ `Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
147728
+ { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
147729
+ );
147730
+ }
147453
147731
  try {
147454
147732
  this.cache = ConversationCache.open(
147455
147733
  (0, import_path29.join)(this.cacheDir, "cache.db"),
@@ -147477,8 +147755,20 @@ var StreamerServer = class {
147477
147755
  this.projectsRepo = new ProjectsRepository(db);
147478
147756
  this.conversationsRepo = new ConversationsRepository(this.cache);
147479
147757
  this.sessionsRepo = new SessionsRepository(this.sessionStore);
147480
- this.managedSessionsRepo = new ManagedSessionsRepository(db);
147481
- void this.reconcilePreviousSessions();
147758
+ try {
147759
+ const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
147760
+ if (copied > 0) {
147761
+ this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
147762
+ copied,
147763
+ event: "runtime.legacy_import"
147764
+ });
147765
+ }
147766
+ } catch (err) {
147767
+ this.log.warn("[registry] legacy managed_sessions copy failed", {
147768
+ event: "runtime.legacy_import_failed",
147769
+ err
147770
+ });
147771
+ }
147482
147772
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
147483
147773
  this.pushRepo = new PushRepository(db);
147484
147774
  this.devicesRepo = new DevicesRepository(db);
@@ -147514,6 +147804,7 @@ var StreamerServer = class {
147514
147804
  );
147515
147805
  this.scannerPersistenceDisabled = true;
147516
147806
  }
147807
+ void this.reconcilePreviousSessions().then((v2) => this.rehydratePreviousSessions(v2));
147517
147808
  if (this.skipStartupWarmup) {
147518
147809
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
147519
147810
  event: "cache.warmup_skipped"
@@ -147721,6 +148012,7 @@ var StreamerServer = class {
147721
148012
  this.allScanners.clear();
147722
148013
  this.scanner = null;
147723
148014
  this.cache?.close();
148015
+ this.runtimeStore?.close();
147724
148016
  this.ptyManager.dispose();
147725
148017
  this.fileWatcher.dispose();
147726
148018
  this.externalTails.clear();
@@ -148168,7 +148460,8 @@ var StreamerServer = class {
148168
148460
  }
148169
148461
  handleSessionsCount(res) {
148170
148462
  if (this.rejectIfWarmingUp(res)) return;
148171
- json2(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
148463
+ const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s3) => s3.ownership !== "historical").length;
148464
+ json2(res, 200, { total });
148172
148465
  }
148173
148466
  handleGetRecentSessions(url2, res) {
148174
148467
  if (this.rejectIfWarmingUp(res)) return;