@vibedeckx/linux-x64 0.3.34 → 0.3.36

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.
Files changed (2) hide show
  1. package/dist/bin.js +732 -161
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -186887,7 +186887,8 @@ var mapLocalActivity = (row) => {
186887
186887
  parseActivityTimestamp(row.created_at) ?? 0
186888
186888
  ),
186889
186889
  lastUserMessageAt: row.last_user_message_at,
186890
- lastCompletedAt: row.last_completed_at
186890
+ lastCompletedAt: row.last_completed_at,
186891
+ favoritedAt: row.favorited_at
186891
186892
  };
186892
186893
  };
186893
186894
  var observeLocalActivity = (consumer, row) => {
@@ -186915,6 +186916,7 @@ var localActivityBase = (kdb, projectId) => {
186915
186916
  "s.updated_at",
186916
186917
  "s.last_user_message_at",
186917
186918
  "s.last_completed_at",
186919
+ "s.favorited_at",
186918
186920
  "checkout.worktree_path",
186919
186921
  "checkout.deleted_at as checkout_deleted_at",
186920
186922
  "checkout.status as checkout_status",
@@ -187196,6 +187198,12 @@ var createAgentSessionRepos = (kdb, h) => ({
187196
187198
  await observeDanglingLocalScope(kdb, consumer, projectId);
187197
187199
  return rows.map(mapLocalActivity);
187198
187200
  },
187201
+ listFavoritedActivityByProject: async (projectId, limit, consumer) => {
187202
+ const rows = await localActivityBase(kdb, projectId).where(visibleLifecycleOf("s")).where("s.favorited_at", "is not", null).orderBy("s.favorited_at", "desc").orderBy("s.id", "desc").limit(limit).execute();
187203
+ rows.forEach((row) => observeLocalActivity(consumer, row));
187204
+ await observeDanglingLocalScope(kdb, consumer, projectId);
187205
+ return rows.map(mapLocalActivity);
187206
+ },
187199
187207
  countRunningByProject: async (projectId) => {
187200
187208
  const row = await kdb.selectFrom("agent_sessions").select(kdb.fn.countAll().as("count")).where("project_id", "=", projectId).where("status", "=", "running").where(visibleLifecycle).executeTakeFirstOrThrow();
187201
187209
  return Number(row.count);
@@ -187296,12 +187304,25 @@ var createAgentSessionRepos = (kdb, h) => ({
187296
187304
  getEntries: async (sessionId) => {
187297
187305
  return kdb.selectFrom("agent_session_entries").select(["entry_index", "data"]).where("session_id", "=", sessionId).orderBy("entry_index", "asc").execute();
187298
187306
  },
187307
+ getEntriesBefore: async (sessionId, beforeIndex, limit) => {
187308
+ let query = kdb.selectFrom("agent_session_entries").select(["entry_index", "data"]).where("session_id", "=", sessionId);
187309
+ if (beforeIndex !== null) query = query.where("entry_index", "<", beforeIndex);
187310
+ return query.orderBy("entry_index", "desc").limit(limit).execute();
187311
+ },
187299
187312
  deleteEntries: async (sessionId) => {
187300
187313
  await kdb.deleteFrom("agent_session_entries").where("session_id", "=", sessionId).execute();
187301
187314
  },
187302
187315
  countEntries: async () => {
187303
187316
  return kdb.selectFrom("agent_session_entries").select("session_id").select(kdb.fn.countAll().as("cnt")).groupBy("session_id").execute();
187304
187317
  },
187318
+ getEntryMetaAll: async () => {
187319
+ const rows = await kdb.selectFrom("agent_session_entries").select("session_id").select(kdb.fn.countAll().as("cnt")).select(kdb.fn.max("entry_index").as("max_index")).groupBy("session_id").execute();
187320
+ return rows.map((row) => ({
187321
+ session_id: row.session_id,
187322
+ cnt: Number(row.cnt),
187323
+ max_index: row.max_index ?? -1
187324
+ }));
187325
+ },
187305
187326
  listRetentionCandidates: async ({ cutoff, limit, after }) => {
187306
187327
  let query = kdb.selectFrom("agent_sessions").select(["id", "project_id", "branch", "activity_at"]).where(retentionPredicate(cutoff));
187307
187328
  if (after) {
@@ -188166,7 +188187,8 @@ var mapRemoteActivity = (row) => {
188166
188187
  model: row.model,
188167
188188
  lastActiveAt: row.last_active_at,
188168
188189
  lastUserMessageAt: row.last_user_message_at,
188169
- lastCompletedAt: row.last_completed_at
188190
+ lastCompletedAt: row.last_completed_at,
188191
+ favoritedAt: row.favorited_at
188170
188192
  };
188171
188193
  };
188172
188194
  var observeRemoteActivity = (consumer, row) => {
@@ -188234,6 +188256,7 @@ var remoteActivityBase = (kdb, projectId) => remoteSessionScope(kdb, projectId).
188234
188256
  "c.model",
188235
188257
  "c.last_user_message_at",
188236
188258
  "c.last_completed_at",
188259
+ "c.favorited_at",
188237
188260
  "checkout.worktree_path",
188238
188261
  "checkout.deleted_at as checkout_deleted_at",
188239
188262
  "checkout.status as checkout_status",
@@ -188269,6 +188292,12 @@ var createSearchCacheRepos = (kdb, _h) => ({
188269
188292
  await observeDanglingRemoteActivity(kdb, consumer, projectId);
188270
188293
  return rows.map(mapRemoteActivity);
188271
188294
  },
188295
+ listRemoteSessionFavoritesByProject: async (projectId, limit, consumer) => {
188296
+ const rows = await remoteActivityBase(kdb, projectId).where("c.favorited_at", "is not", null).orderBy("c.favorited_at", "desc").orderBy("c.local_session_id", "asc").limit(limit).execute();
188297
+ rows.forEach((row) => observeRemoteActivity(consumer, row));
188298
+ await observeDanglingRemoteActivity(kdb, consumer, projectId);
188299
+ return rows.map(mapRemoteActivity);
188300
+ },
188272
188301
  countRemoteSessionActivityByProject: async (projectId) => {
188273
188302
  const row = await remoteSessionScope(kdb, projectId).select([
188274
188303
  sql`coalesce(sum(case when c.status = 'running' then 1 else 0 end), 0)`.as("running")
@@ -188450,6 +188479,39 @@ var createSearchCacheRepos = (kdb, _h) => ({
188450
188479
  updateCachedSessionTitle: async (localSessionId, title) => {
188451
188480
  await kdb.updateTable("session_search_cache").set({ title, written_at: Date.now() }).where("local_session_id", "=", localSessionId).execute();
188452
188481
  },
188482
+ // Star write-through. Unlike the title and delete write-throughs this one
188483
+ // may CREATE the row: the remote favorite PATCH has already succeeded on
188484
+ // the worker, so the session provably exists, and the path that lists a
188485
+ // remote's sessions (where the star button lives) binds only a mapping —
188486
+ // no cache row. Before that target's first catalog snapshot there would be
188487
+ // nothing to update and the star would sit invisible, which is the exact
188488
+ // latency this write-through exists to remove. Only starring creates a
188489
+ // row; an unstar with no row has nothing to hide (see noteSessionDeleted).
188490
+ updateCachedSessionFavorited: async (localSessionId, favoritedAt) => {
188491
+ const now3 = Date.now();
188492
+ const updated = await kdb.updateTable("session_search_cache").set({ favorited_at: favoritedAt, written_at: now3 }).where("local_session_id", "=", localSessionId).executeTakeFirst();
188493
+ if (Number(updated?.numUpdatedRows ?? 0) > 0 || favoritedAt === null) return;
188494
+ const mapping = await kdb.selectFrom("remote_session_mappings").select(["project_id", "remote_server_id", "branch"]).where("local_session_id", "=", localSessionId).executeTakeFirst();
188495
+ if (!mapping || mapping.remote_server_id === "local") return;
188496
+ await kdb.insertInto("session_search_cache").values({
188497
+ local_session_id: localSessionId,
188498
+ project_id: mapping.project_id,
188499
+ target_id: mapping.remote_server_id,
188500
+ branch: toDbBranch(mapping.branch),
188501
+ title: null,
188502
+ last_active_at: null,
188503
+ favorited_at: favoritedAt,
188504
+ entry_count: 0,
188505
+ status: "unknown",
188506
+ agent_type: null,
188507
+ model: null,
188508
+ last_user_message_at: null,
188509
+ last_completed_at: null,
188510
+ generation: 0,
188511
+ deleted_at: null,
188512
+ written_at: now3
188513
+ }).onConflict((oc) => oc.column("local_session_id").doUpdateSet({ favorited_at: favoritedAt, written_at: now3 })).execute();
188514
+ },
188453
188515
  // Create write-through: called where a remote session's creation transits
188454
188516
  // the server (UI create proxy, commander spawn, branch-from-history). The
188455
188517
  // written_at stamp keeps the row exempt from snapshot reconciliation until
@@ -208493,6 +208555,111 @@ var EntryTracker = class {
208493
208555
  }
208494
208556
  };
208495
208557
 
208558
+ // src/session-history-window.ts
208559
+ function historyHead(entries, historyEpoch) {
208560
+ let latestEntryIndex = null;
208561
+ let lastTurnEndEntryIndex = null;
208562
+ for (let index = entries.length - 1; index >= 0; index--) {
208563
+ const message = entries[index];
208564
+ if (!message) continue;
208565
+ latestEntryIndex ??= index;
208566
+ if (lastTurnEndEntryIndex === null && message.type === "turn_end") {
208567
+ lastTurnEndEntryIndex = index;
208568
+ }
208569
+ if (latestEntryIndex !== null && lastTurnEndEntryIndex !== null) break;
208570
+ }
208571
+ return { historyEpoch, latestEntryIndex, lastTurnEndEntryIndex };
208572
+ }
208573
+ function buildHistoryWindow(entries, historyEpoch, opts = {}) {
208574
+ const head = historyHead(entries, historyEpoch);
208575
+ const endExclusive = Math.max(0, Math.min(opts.before ?? entries.length, entries.length));
208576
+ const requestedTurns = Math.max(1, Math.min(opts.turns ?? 5, 20));
208577
+ const boundaries = [];
208578
+ for (let index = endExclusive - 1; index >= 0; index--) {
208579
+ if (entries[index]?.type === "turn_end") boundaries.push(index);
208580
+ if (boundaries.length >= requestedTurns + 2) break;
208581
+ }
208582
+ const startIndex = boundaries.length > requestedTurns + 1 ? boundaries[requestedTurns + 1] + 1 : 0;
208583
+ const dense = [];
208584
+ for (let index = startIndex; index < endExclusive; index++) {
208585
+ const message = entries[index];
208586
+ if (message) dense.push({ entryIndex: index, message });
208587
+ }
208588
+ const hasMore = startIndex > 0;
208589
+ return {
208590
+ ...head,
208591
+ entries: dense,
208592
+ previousCursor: hasMore ? startIndex : null,
208593
+ hasMore
208594
+ };
208595
+ }
208596
+
208597
+ // src/session-history-reader.ts
208598
+ var SessionHistoryReader = class {
208599
+ storage;
208600
+ constructor(storage2) {
208601
+ this.storage = storage2;
208602
+ }
208603
+ /**
208604
+ * The whole transcript as a SPARSE array indexed by entry index — the exact
208605
+ * shape `MessageStore.entries` has, so callers can treat hot and cold
208606
+ * sessions identically. Unparsable rows become holes, matching
208607
+ * `rebuildStoreFromRows`.
208608
+ */
208609
+ async readAll(sessionId) {
208610
+ const rows = await this.storage.agentSessions.getEntries(sessionId);
208611
+ return parseRows(rows, sessionId);
208612
+ }
208613
+ /** `readAll` with holes dropped — the `getMessages` shape. */
208614
+ async readDense(sessionId) {
208615
+ return (await this.readAll(sessionId)).filter(Boolean);
208616
+ }
208617
+ /**
208618
+ * Phase 1 reads the whole transcript and slices it in memory: correctness
208619
+ * first, and the slicing logic stays the single implementation shared with
208620
+ * hot sessions. Phase 2 replaces the body with paged `getEntriesBefore`
208621
+ * queries plus one turn_end count — the signature is chosen to allow that
208622
+ * without touching a single caller.
208623
+ */
208624
+ async readWindow(sessionId, historyEpoch, opts = {}) {
208625
+ return buildHistoryWindow(await this.readAll(sessionId), historyEpoch, opts);
208626
+ }
208627
+ async readHead(sessionId, historyEpoch) {
208628
+ return historyHead(await this.readAll(sessionId), historyEpoch);
208629
+ }
208630
+ /**
208631
+ * One page of entries in descending index order, strictly before
208632
+ * `beforeIndex` (null = the tail). The backward walk crash repair uses to
208633
+ * find a turn boundary without reading the whole transcript.
208634
+ */
208635
+ async readBefore(sessionId, beforeIndex, limit) {
208636
+ const rows = await this.storage.agentSessions.getEntriesBefore(sessionId, beforeIndex, limit);
208637
+ return rows.map((row) => ({
208638
+ entryIndex: row.entry_index,
208639
+ message: parseRow(row.data)
208640
+ }));
208641
+ }
208642
+ };
208643
+ function parseRow(data) {
208644
+ try {
208645
+ return JSON.parse(data);
208646
+ } catch {
208647
+ return void 0;
208648
+ }
208649
+ }
208650
+ function parseRows(rows, sessionIdForLog) {
208651
+ const entries = [];
208652
+ for (const row of rows) {
208653
+ const message = parseRow(row.data);
208654
+ if (message === void 0) {
208655
+ console.error(`[SessionHistoryReader] Failed to parse entry ${row.entry_index} for session ${sessionIdForLog}`);
208656
+ continue;
208657
+ }
208658
+ entries[row.entry_index] = message;
208659
+ }
208660
+ return entries;
208661
+ }
208662
+
208496
208663
  // src/utils/worktree-paths.ts
208497
208664
  import path7 from "path";
208498
208665
  import { createHash } from "crypto";
@@ -230246,6 +230413,7 @@ ${details}`;
230246
230413
  return msg;
230247
230414
  }
230248
230415
  var SUMMARY_TEXT_CAP = 1500;
230416
+ var REPAIR_SCAN_BATCH = 64;
230249
230417
  function extractLastAssistantText(entries) {
230250
230418
  for (let i = entries.length - 1; i >= 0; i--) {
230251
230419
  const entry = entries[i];
@@ -230256,6 +230424,55 @@ function extractLastAssistantText(entries) {
230256
230424
  }
230257
230425
  return void 0;
230258
230426
  }
230427
+ var WS_OPEN = 1;
230428
+ function isSocketOpen(ws) {
230429
+ return ws.readyState === void 0 || ws.readyState === WS_OPEN;
230430
+ }
230431
+ function replayPatchesFor(entries) {
230432
+ const patches = [];
230433
+ entries.forEach((message, index) => {
230434
+ if (message !== void 0) patches.push(ConversationPatch.addEntry(index, message));
230435
+ });
230436
+ return patches;
230437
+ }
230438
+ function denseMessagesFromRows(rows) {
230439
+ const messages = [];
230440
+ for (const row of rows) {
230441
+ try {
230442
+ messages.push(JSON.parse(row.data));
230443
+ } catch {
230444
+ }
230445
+ }
230446
+ return messages;
230447
+ }
230448
+ function metaFromStore(store) {
230449
+ let entryCount = 0;
230450
+ let maxEntryIndex = -1;
230451
+ store.entries.forEach((entry, index) => {
230452
+ if (entry === void 0) return;
230453
+ entryCount++;
230454
+ if (index > maxEntryIndex) maxEntryIndex = index;
230455
+ });
230456
+ return { entryCount, maxEntryIndex };
230457
+ }
230458
+ function coldStore(maxEntryIndex) {
230459
+ const indexProvider = new EntryIndexProvider(maxEntryIndex + 1);
230460
+ return {
230461
+ patches: [],
230462
+ entries: [],
230463
+ indexProvider,
230464
+ toolTracker: new EntryTracker(indexProvider),
230465
+ currentAssistantIndex: null
230466
+ };
230467
+ }
230468
+ var SpawnSupersededError = class extends Error {
230469
+ code = "spawn_superseded";
230470
+ statusCode = 409;
230471
+ constructor(sessionId) {
230472
+ super(`Session ${sessionId} was restarted while its history was loading`);
230473
+ this.name = "SpawnSupersededError";
230474
+ }
230475
+ };
230259
230476
  var WorkspaceCheckoutUnavailableError = class extends Error {
230260
230477
  code = "workspace_checkout_unavailable";
230261
230478
  statusCode = 409;
@@ -230313,8 +230530,11 @@ var AgentSessionManager = class {
230313
230530
  /** Bound on a parked completion (injectable for tests). */
230314
230531
  parkTimeoutMs;
230315
230532
  workflowSuppressionCheck = null;
230533
+ /** Every read of a cold session's transcript goes through here. */
230534
+ reader;
230316
230535
  constructor(storage2, opts) {
230317
230536
  this.storage = storage2;
230537
+ this.reader = new SessionHistoryReader(storage2);
230318
230538
  this.completionGraceMs = opts?.completionGraceMs ?? COMPLETION_GRACE_MS;
230319
230539
  this.parkTimeoutMs = opts?.parkTimeoutMs ?? PARK_TIMEOUT_MS;
230320
230540
  }
@@ -230471,6 +230691,154 @@ var AgentSessionManager = class {
230471
230691
  const session = this.sessions.get(sessionId);
230472
230692
  if (session) this.emitSessionTitle(session.projectId, session.branch, sessionId, title);
230473
230693
  }
230694
+ // ============ History hydration (plan B §2) ============
230695
+ /**
230696
+ * Guard for the paths that may only run against an in-memory transcript:
230697
+ * everything on the streaming write side (`stageEntry` and the stdout
230698
+ * parsing chain behind it) plus the turn-boundary scans that read
230699
+ * `store.entries` directly.
230700
+ *
230701
+ * They all sit behind invariant B1 — a session with a process is hot — so
230702
+ * this should be unreachable. It throws rather than silently degrading
230703
+ * because the failure mode it guards against is invisible: a cold session
230704
+ * reads as a transcript of length zero, which would make a turn boundary
230705
+ * resolve to a fabricated disposition instead of blowing up.
230706
+ */
230707
+ assertHot(session, operation) {
230708
+ if (session.hot) return;
230709
+ throw new Error(
230710
+ `[AgentSession] ${operation} requires a hydrated session but ${session.id} is cold`
230711
+ );
230712
+ }
230713
+ /**
230714
+ * Load a cold session's transcript into memory ahead of spawning a process
230715
+ * for it. The ONLY hydration path.
230716
+ *
230717
+ * @throws SpawnSupersededError if a restart overtook the caller's operation.
230718
+ * The caller must abort: not spawn, not append, not touch the session.
230719
+ */
230720
+ async hydrateForSpawn(session) {
230721
+ const generation = session.clearGeneration;
230722
+ if (!session.hot) {
230723
+ if (!session.hydrating) {
230724
+ session.hydrating = this.runOnHistoryChain(session, async () => {
230725
+ if (session.hot) return;
230726
+ const generationAtRead = session.clearGeneration;
230727
+ const rows = await this.storage.agentSessions.getEntries(session.id);
230728
+ if (session.clearGeneration !== generationAtRead || session.hot) return;
230729
+ const store = this.rebuildStoreFromRows(rows, session.id);
230730
+ session.store = store;
230731
+ session.historyMeta = metaFromStore(store);
230732
+ session.hot = true;
230733
+ }).finally(() => {
230734
+ session.hydrating = null;
230735
+ });
230736
+ }
230737
+ await session.hydrating;
230738
+ }
230739
+ if (session.clearGeneration !== generation) throw new SpawnSupersededError(session.id);
230740
+ }
230741
+ /**
230742
+ * Drop a session's transcript back to the database. Called at every point
230743
+ * where a session stops owning a process — the four in plan §2.3 (process
230744
+ * exit, Stop, hibernate, agent switch) plus `setModel` retiring an idle
230745
+ * process. This is the whole of the memory-reclaim story: no sweeper and no
230746
+ * idle threshold, because "does it have a process" is not a policy.
230747
+ *
230748
+ * A no-op for sessions that are already cold (a second Stop, a Stop on a
230749
+ * session restored from disk) and for `skipDb` mirrors, which have no rows
230750
+ * to read back.
230751
+ */
230752
+ unloadHistory(session, reason) {
230753
+ if (!session.hot || session.skipDb) return;
230754
+ if (session.chainPending > 0) {
230755
+ this.enqueueSessionWork(
230756
+ session,
230757
+ async () => this.unloadHistoryNow(session, reason),
230758
+ `unload:${reason}`
230759
+ );
230760
+ return;
230761
+ }
230762
+ this.unloadHistoryNow(session, reason);
230763
+ }
230764
+ unloadHistoryNow(session, reason) {
230765
+ if (!session.hot || session.skipDb) return;
230766
+ if (session.process !== null) {
230767
+ console.warn(`[AgentSession] Refusing to unload ${session.id} (${reason}): process still attached`);
230768
+ return;
230769
+ }
230770
+ if (session.processStartsInFlight > 0) {
230771
+ return;
230772
+ }
230773
+ const meta3 = metaFromStore(session.store);
230774
+ session.historyMeta = meta3;
230775
+ session.store = coldStore(Math.max(meta3.maxEntryIndex, session.store.indexProvider.current() - 1));
230776
+ session.hot = false;
230777
+ }
230778
+ /**
230779
+ * Release the transcript when a spawn attempt ends without attaching a
230780
+ * process — `ensureResidentCapacity` refusing at the cap, a checkout that
230781
+ * vanished, a spawn that could not start.
230782
+ *
230783
+ * Without this, every rejected send on a dormant session loads a transcript
230784
+ * that nothing will ever unload (the four unload points all hang off a
230785
+ * process going away, and no process ever arrived). A user retrying against
230786
+ * a full resident pool would walk the memory bound back up one session at a
230787
+ * time — the exact failure this design exists to prevent.
230788
+ *
230789
+ * Must run AFTER the caller releases its `processStartsInFlight` claim: a
230790
+ * second, still-committed start means the transcript has a new owner.
230791
+ */
230792
+ unloadIfSpawnAbandoned(session) {
230793
+ if (session.process !== null || session.processStartsInFlight > 0) return;
230794
+ this.unloadHistory(session, "spawn-abandoned");
230795
+ }
230796
+ /**
230797
+ * Aggregate hydration counters for `memory-stats`. Aggregate-only and
230798
+ * name-free by construction, like everything else on that endpoint.
230799
+ *
230800
+ * `hot_entries` is the number of transcript entries actually resident. It is
230801
+ * the curve this whole design exists to flatten: before it, that number was
230802
+ * every entry in the database; after it, it should track the number of live
230803
+ * agent processes. No byte estimate is reported — measuring it means
230804
+ * serializing the very heap we are trying not to touch, twelve times an hour.
230805
+ */
230806
+ hydrationStats() {
230807
+ let hot = 0;
230808
+ let hotEntries = 0;
230809
+ for (const session of this.sessions.values()) {
230810
+ if (!session.hot) continue;
230811
+ hot++;
230812
+ hotEntries += session.store.entries.filter(Boolean).length;
230813
+ }
230814
+ return { total: this.sessions.size, hot, cold: this.sessions.size - hot, hot_entries: hotEntries };
230815
+ }
230816
+ /** Dense messages (holes dropped), from memory when hot and storage when cold. */
230817
+ async loadMessages(sessionId) {
230818
+ const session = this.sessions.get(sessionId);
230819
+ if (!session) return [];
230820
+ if (session.hot) return session.store.entries.filter(Boolean);
230821
+ return this.reader.readDense(sessionId);
230822
+ }
230823
+ /** Sparse entries (holes preserved) — index space matches entry indices. */
230824
+ async loadRawMessages(sessionId) {
230825
+ const session = this.sessions.get(sessionId);
230826
+ if (!session) return [];
230827
+ if (session.hot) return session.store.entries;
230828
+ return this.reader.readAll(sessionId);
230829
+ }
230830
+ async loadHistoryWindow(sessionId, opts = {}) {
230831
+ const session = this.sessions.get(sessionId);
230832
+ if (!session) return null;
230833
+ if (session.hot) return buildHistoryWindow(session.store.entries, session.historyEpoch, opts);
230834
+ return this.reader.readWindow(sessionId, session.historyEpoch, opts);
230835
+ }
230836
+ async loadHistoryHead(sessionId) {
230837
+ const session = this.sessions.get(sessionId);
230838
+ if (!session) return null;
230839
+ if (session.hot) return historyHead(session.store.entries, session.historyEpoch);
230840
+ return this.reader.readHead(sessionId, session.historyEpoch);
230841
+ }
230474
230842
  isProcessAlive(session) {
230475
230843
  return !!session.process && session.process.exitCode === null && !session.dormant;
230476
230844
  }
@@ -230644,7 +231012,7 @@ var AgentSessionManager = class {
230644
231012
  }
230645
231013
  for (const session of this.sessions.values()) {
230646
231014
  if (session.projectId === projectId && session.branch === branch) {
230647
- console.log(`[findExisting] skipDb in-memory match: ${session.id} (entries=${session.store.entries.filter(Boolean).length})`);
231015
+ console.log(`[findExisting] skipDb in-memory match: ${session.id} (entries=${this.historyEntryCount(session)})`);
230648
231016
  return this.reuseExistingSession(session, projectPath);
230649
231017
  }
230650
231018
  }
@@ -230838,6 +231206,11 @@ var AgentSessionManager = class {
230838
231206
  processStartsInFlight: 0,
230839
231207
  historyEpoch: stored?.history_epoch ?? 0,
230840
231208
  store,
231209
+ // Born hot: a process is spawned for it a few lines below (B1).
231210
+ hot: true,
231211
+ historyMeta: metaFromStore(store),
231212
+ hydrating: null,
231213
+ clearGeneration: 0,
230841
231214
  subscribers: /* @__PURE__ */ new Set(),
230842
231215
  status: "running",
230843
231216
  buffer: "",
@@ -230851,6 +231224,8 @@ var AgentSessionManager = class {
230851
231224
  graceTimer: null,
230852
231225
  parkTimer: null,
230853
231226
  eventChain: Promise.resolve(),
231227
+ chainPending: 0,
231228
+ historyChain: Promise.resolve(),
230854
231229
  bgSpawnHintsThisTurn: 0,
230855
231230
  taskStartedThisTurn: 0,
230856
231231
  lastActiveAt: Date.now(),
@@ -230907,7 +231282,7 @@ var AgentSessionManager = class {
230907
231282
  * switch-mode route, which carries actual user intent.
230908
231283
  */
230909
231284
  async reuseExistingSession(session, projectPath) {
230910
- const entriesCount = session.store.entries.filter(Boolean).length;
231285
+ const entriesCount = this.historyEntryCount(session);
230911
231286
  this.touchSession(session);
230912
231287
  if (session.dormant) {
230913
231288
  console.log(`[AgentSession] Returning dormant session ${session.id} (entries=${entriesCount})`);
@@ -230939,9 +231314,10 @@ var AgentSessionManager = class {
230939
231314
  * Uses negative PID to signal the process group (requires detached: true at spawn).
230940
231315
  */
230941
231316
  killProcess(proc, signal = "SIGTERM") {
230942
- if (!proc?.pid) return;
231317
+ const pid = proc?.pid;
231318
+ if (!proc || pid === void 0 || !Number.isSafeInteger(pid) || pid <= 1) return;
230943
231319
  try {
230944
- process.kill(-proc.pid, signal);
231320
+ process.kill(-pid, signal);
230945
231321
  } catch {
230946
231322
  try {
230947
231323
  proc.kill(signal);
@@ -231073,6 +231449,7 @@ var AgentSessionManager = class {
231073
231449
  this.broadcastPatch(session.id, ConversationPatch.updateStatus(session.status));
231074
231450
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: session.status });
231075
231451
  this.broadcastRaw(session.id, { finished: true });
231452
+ this.unloadHistory(session, "process-exit");
231076
231453
  }, "process-close");
231077
231454
  });
231078
231455
  childProcess2.on("error", (error48) => {
@@ -231100,20 +231477,42 @@ var AgentSessionManager = class {
231100
231477
  * runs through here so steps never interleave across await points.
231101
231478
  */
231102
231479
  enqueueSessionWork(session, work, label) {
231480
+ session.chainPending += 1;
231103
231481
  session.eventChain = session.eventChain.then(work).catch((err) => {
231104
231482
  console.error(`[AgentSession] Error in ${label} handler for ${session.id}:`, err);
231483
+ }).then(() => {
231484
+ session.chainPending -= 1;
231105
231485
  });
231106
231486
  }
231487
+ /**
231488
+ * Run `work` with exclusive access to the session's history, i.e. serialized
231489
+ * against every other hydration and cold append for that session.
231490
+ *
231491
+ * Separate from `eventChain` on purpose — see `historyChain`'s declaration.
231492
+ */
231493
+ runOnHistoryChain(session, work) {
231494
+ const result = session.historyChain.then(work);
231495
+ session.historyChain = result.then(
231496
+ () => void 0,
231497
+ () => void 0
231498
+ );
231499
+ return result;
231500
+ }
231107
231501
  /**
231108
231502
  * Same serial chain as `enqueueSessionWork`, for a caller that needs the
231109
231503
  * result back. The chain itself absorbs the outcome (success or failure) so
231110
231504
  * one queued step can never break the next; the caller gets the real promise.
231111
231505
  */
231112
231506
  runSerialForResult(session, work) {
231507
+ session.chainPending += 1;
231113
231508
  const result = session.eventChain.then(work);
231114
231509
  session.eventChain = result.then(
231115
- () => void 0,
231116
- () => void 0
231510
+ () => {
231511
+ session.chainPending -= 1;
231512
+ },
231513
+ () => {
231514
+ session.chainPending -= 1;
231515
+ }
231117
231516
  );
231118
231517
  return result;
231119
231518
  }
@@ -231343,6 +231742,7 @@ var AgentSessionManager = class {
231343
231742
  }
231344
231743
  if (session.turnOpenSince === null && !outOfTurn && (event.type === "turn_started" || event.type === "text" || event.type === "thinking" || event.type === "tool_use" || event.type === "tool_result" || event.type === "approval_request")) {
231345
231744
  session.turnOpenSince = timestamp;
231745
+ this.assertHot(session, "turn-open disposition");
231346
231746
  session.turnDisposition = resolveNotificationDisposition(findLatestUserEntry(session.store.entries));
231347
231747
  }
231348
231748
  if (session.status !== "running" && !outOfTurn && (event.type === "text" || event.type === "thinking" || event.type === "tool_use" || event.type === "tool_result" || event.type === "approval_request")) {
@@ -231606,15 +232006,40 @@ var AgentSessionManager = class {
231606
232006
  * the two persistence paths can't drift on index allocation or patch shape.
231607
232007
  */
231608
232008
  stageEntry(session, message) {
232009
+ this.assertHot(session, "stageEntry");
231609
232010
  const index = session.store.indexProvider.next();
231610
232011
  session.store.entries[index] = message;
231611
232012
  const patch = ConversationPatch.addEntry(index, message);
231612
232013
  session.store.patches.push(patch);
231613
232014
  return { index, patch };
231614
232015
  }
232016
+ /**
232017
+ * Does this session have any transcript at all?
232018
+ *
232019
+ * Reads the store when hot and the metadata when cold, so it is correct in
232020
+ * both states AND — the load-bearing half — never triggers a read. Retention
232021
+ * and the discard compensator ask this of every candidate they consider; if
232022
+ * the question could hydrate, one sweep would pull the whole database back
232023
+ * into the heap that this design just emptied (§3.2).
232024
+ *
232025
+ * Deliberately NOT "historyMeta.entryCount > 0" in both states: keeping the
232026
+ * counter exact through the streaming write paths (which allocate indices
232027
+ * via `toolTracker.getOrCreate`, not only `stageEntry`) would be a new
232028
+ * invariant spread across a dozen sites. While hot, the store is already the
232029
+ * authority; `historyMeta` only has to be right at the moments it is read,
232030
+ * which are all cold.
232031
+ */
232032
+ hasHistory(session) {
232033
+ return session.hot ? session.store.entries.some((entry) => entry !== void 0) : session.historyMeta.entryCount > 0;
232034
+ }
232035
+ /** Entry count for logging — same hot/cold split as `hasHistory`, never reads. */
232036
+ historyEntryCount(session) {
232037
+ return session.hot ? session.store.entries.filter(Boolean).length : session.historyMeta.entryCount;
232038
+ }
231615
232039
  async pushEntry(sessionId, message, broadcast = true, userId = "local", pushOpts) {
231616
232040
  const session = this.sessions.get(sessionId);
231617
232041
  if (!session) return -1;
232042
+ if (!session.hot) return this.pushColdEntry(session, message, broadcast, userId, pushOpts);
231618
232043
  const { index, patch } = this.stageEntry(session, message);
231619
232044
  if (!session.skipDb && message.type !== "assistant") {
231620
232045
  await this.persistEntry(session, index, message, userId, { strict: pushOpts?.strictPersist });
@@ -231624,6 +232049,48 @@ var AgentSessionManager = class {
231624
232049
  }
231625
232050
  return index;
231626
232051
  }
232052
+ /**
232053
+ * Append to a session whose transcript is not in memory: allocate an index,
232054
+ * write the row, advance the metadata, broadcast the patch. No store is
232055
+ * built — a Stop note or an agent-switch note must not be a reason to pull a
232056
+ * whole transcript into the heap.
232057
+ *
232058
+ * Callers do not choose this path; `pushEntry` picks it. That is what makes
232059
+ * "stop a session restored from disk" and "switch the agent on a dormant
232060
+ * session" work unchanged, which the previous design missed by giving only
232061
+ * `switchAgentType` a bespoke cold-append helper.
232062
+ *
232063
+ * Runs on the session's history chain so it cannot interleave with a
232064
+ * `hydrateForSpawn` and be dropped when that installs its snapshot (§3.3).
232065
+ * It must NOT run on `eventChain`: an unload can land while a stdout chunk
232066
+ * is mid-flight on that chain, so the chunk resumes against a session that
232067
+ * went cold underneath it and appends from *inside* the chain. Queuing there
232068
+ * would make that task wait for itself — the chain wedges, and because
232069
+ * `hydrateForSpawn` used to queue on it too, the session could then never be
232070
+ * woken again.
232071
+ */
232072
+ pushColdEntry(session, message, broadcast, userId, pushOpts) {
232073
+ return this.runOnHistoryChain(session, async () => {
232074
+ if (session.hot) {
232075
+ const { index: index2, patch } = this.stageEntry(session, message);
232076
+ if (!session.skipDb && message.type !== "assistant") {
232077
+ await this.persistEntry(session, index2, message, userId, { strict: pushOpts?.strictPersist });
232078
+ }
232079
+ if (broadcast) this.broadcastPatch(session.id, patch);
232080
+ return index2;
232081
+ }
232082
+ const index = session.store.indexProvider.next();
232083
+ if (!session.skipDb && message.type !== "assistant") {
232084
+ await this.persistEntry(session, index, message, userId, { strict: pushOpts?.strictPersist });
232085
+ }
232086
+ session.historyMeta = {
232087
+ entryCount: session.historyMeta.entryCount + 1,
232088
+ maxEntryIndex: Math.max(session.historyMeta.maxEntryIndex, index)
232089
+ };
232090
+ if (broadcast) this.broadcastPatch(session.id, ConversationPatch.addEntry(index, message));
232091
+ return index;
232092
+ });
232093
+ }
231627
232094
  /**
231628
232095
  * Persist a `turn_end` together with the attention milestone it earns.
231629
232096
  *
@@ -231649,8 +232116,15 @@ var AgentSessionManager = class {
231649
232116
  if (entryIndexOverride !== void 0) {
231650
232117
  index = entryIndexOverride;
231651
232118
  patch = ConversationPatch.addEntry(index, message);
231652
- } else {
232119
+ } else if (session.hot) {
231653
232120
  ({ index, patch } = this.stageEntry(session, message));
232121
+ } else {
232122
+ index = session.store.indexProvider.next();
232123
+ session.historyMeta = {
232124
+ entryCount: session.historyMeta.entryCount + 1,
232125
+ maxEntryIndex: Math.max(session.historyMeta.maxEntryIndex, index)
232126
+ };
232127
+ patch = ConversationPatch.addEntry(index, message);
231654
232128
  }
231655
232129
  if (!session.skipDb) {
231656
232130
  const activityReader = this.storage.agentSessions.getActivityById;
@@ -231736,9 +232210,12 @@ var AgentSessionManager = class {
231736
232210
  if (session.turnOpenSince === null) return null;
231737
232211
  const endedAt = Date.now();
231738
232212
  const durationMs = endedAt - session.turnOpenSince;
231739
- const disposition = session.turnDisposition ?? resolveNotificationDisposition(
231740
- findTurnOpeningUserEntry(session.store.entries, session.store.entries.length)
231741
- );
232213
+ let disposition = session.turnDisposition;
232214
+ if (disposition === null) {
232215
+ const entries = session.hot ? session.store.entries : await this.reader.readAll(session.id);
232216
+ const beforeIndex = session.hot ? entries.length : session.historyMeta.maxEntryIndex + 1;
232217
+ disposition = resolveNotificationDisposition(findTurnOpeningUserEntry(entries, beforeIndex));
232218
+ }
231742
232219
  const index = await this.pushTurnEnd(session, outcome, disposition, endedAt, durationMs);
231743
232220
  session.turnOpenSince = null;
231744
232221
  session.turnDisposition = null;
@@ -231897,48 +232374,83 @@ var AgentSessionManager = class {
231897
232374
  }
231898
232375
  }
231899
232376
  /**
231900
- * Subscribe to session updates (WebSocket connection)
232377
+ * Attach a client to a session's live stream and replay its history to it.
232378
+ *
232379
+ * Async since lazy hydration: a dormant session's transcript is read from
232380
+ * storage here. The caller MUST register its `close` handler before
232381
+ * awaiting — see `websocket-routes.ts` — or a user who closes the tab during
232382
+ * that read leaves a dead socket in `subscribers` and a heartbeat running.
231901
232383
  */
231902
- subscribe(sessionId, ws, opts = {}) {
232384
+ async subscribe(sessionId, ws, opts = {}) {
231903
232385
  const session = this.sessions.get(sessionId);
231904
232386
  if (!session) {
231905
232387
  return null;
231906
232388
  }
232389
+ if (!isSocketOpen(ws)) return null;
231907
232390
  session.subscribers.add(ws);
231908
- const after = opts.historyEpoch === void 0 || opts.historyEpoch === session.historyEpoch ? opts.afterEntryIndex ?? -1 : -1;
231909
- ws.send(JSON.stringify({
231910
- HistorySync: {
231911
- historyEpoch: session.historyEpoch,
231912
- reset: opts.historyEpoch !== void 0 && opts.historyEpoch !== session.historyEpoch
231913
- }
231914
- }));
231915
- ws.send(JSON.stringify(this.backgroundTasksMessage(session)));
231916
- for (const patch of session.store.patches) {
231917
- const entryIndices = patch.flatMap((op) => {
231918
- const match2 = op.path.match(/^\/entries\/(\d+)$/);
231919
- return match2 ? [Number(match2[1])] : [];
231920
- });
231921
- if (entryIndices.length > 0 && entryIndices.every((index) => index <= after)) continue;
231922
- const msg = { JsonPatch: patch };
231923
- ws.send(JSON.stringify(msg));
231924
- }
231925
- ws.send(JSON.stringify({ Ready: true, historyEpoch: session.historyEpoch }));
231926
- const statusPatch = ConversationPatch.updateStatus(session.status);
231927
- ws.send(JSON.stringify({ JsonPatch: statusPatch }));
231928
- return () => {
232391
+ const unsubscribe = () => {
231929
232392
  session.subscribers.delete(ws);
231930
232393
  };
232394
+ for (let attempt = 0; ; attempt++) {
232395
+ if (attempt >= 5) {
232396
+ console.error(`[AgentSession] subscribe to ${sessionId} kept being invalidated; giving up`);
232397
+ unsubscribe();
232398
+ return null;
232399
+ }
232400
+ const generation = session.clearGeneration;
232401
+ const after = opts.historyEpoch === void 0 || opts.historyEpoch === session.historyEpoch ? opts.afterEntryIndex ?? -1 : -1;
232402
+ ws.send(JSON.stringify({
232403
+ HistorySync: {
232404
+ historyEpoch: session.historyEpoch,
232405
+ reset: opts.historyEpoch !== void 0 && opts.historyEpoch !== session.historyEpoch
232406
+ }
232407
+ }));
232408
+ ws.send(JSON.stringify(this.backgroundTasksMessage(session)));
232409
+ const patches = session.hot ? session.store.patches : replayPatchesFor(await this.reader.readAll(sessionId));
232410
+ if (!isSocketOpen(ws)) {
232411
+ unsubscribe();
232412
+ return null;
232413
+ }
232414
+ if (session.clearGeneration !== generation) continue;
232415
+ for (const patch of patches) {
232416
+ const entryIndices = patch.flatMap((op) => {
232417
+ const match2 = op.path.match(/^\/entries\/(\d+)$/);
232418
+ return match2 ? [Number(match2[1])] : [];
232419
+ });
232420
+ if (entryIndices.length > 0 && entryIndices.every((index) => index <= after)) continue;
232421
+ const msg = { JsonPatch: patch };
232422
+ ws.send(JSON.stringify(msg));
232423
+ }
232424
+ ws.send(JSON.stringify({ Ready: true, historyEpoch: session.historyEpoch }));
232425
+ const statusPatch = ConversationPatch.updateStatus(session.status);
232426
+ ws.send(JSON.stringify({ JsonPatch: statusPatch }));
232427
+ return unsubscribe;
232428
+ }
231931
232429
  }
231932
232430
  /**
231933
- * Get all messages for a session (reconstructed from patches)
232431
+ * Get all messages for a session (reconstructed from patches).
232432
+ *
232433
+ * HOT SESSIONS ONLY — use `loadMessages` unless you know a process is
232434
+ * attached. Throwing beats returning `[]` for a cold session: an empty
232435
+ * transcript is a plausible-looking answer that would quietly corrupt
232436
+ * whatever the caller does next.
231934
232437
  */
231935
232438
  getMessages(sessionId) {
231936
232439
  const session = this.sessions.get(sessionId);
231937
- return session?.store.entries.filter(Boolean) ?? [];
232440
+ if (!session) return [];
232441
+ this.assertHot(session, "getMessages");
232442
+ return session.store.entries.filter(Boolean);
231938
232443
  }
231939
- /** Raw sparse entries (holes preserved) — index space matches entry indices. */
232444
+ /**
232445
+ * Raw sparse entries (holes preserved) — index space matches entry indices.
232446
+ * Hot sessions only, same as `getMessages`; cold callers want
232447
+ * `loadRawMessages`.
232448
+ */
231940
232449
  getRawMessages(sessionId) {
231941
- return this.sessions.get(sessionId)?.store.entries ?? [];
232450
+ const session = this.sessions.get(sessionId);
232451
+ if (!session) return [];
232452
+ this.assertHot(session, "getRawMessages");
232453
+ return session.store.entries;
231942
232454
  }
231943
232455
  getHistoryEpoch(sessionId) {
231944
232456
  return this.sessions.get(sessionId)?.historyEpoch;
@@ -232043,6 +232555,7 @@ var AgentSessionManager = class {
232043
232555
  });
232044
232556
  }
232045
232557
  }
232558
+ this.unloadHistory(session, "stop");
232046
232559
  return true;
232047
232560
  } catch (error48) {
232048
232561
  console.error(`[AgentSession] Failed to stop session:`, error48);
@@ -232113,6 +232626,7 @@ var AgentSessionManager = class {
232113
232626
  sessionId: session.id,
232114
232627
  status: "stopped"
232115
232628
  });
232629
+ this.unloadHistory(session, "hibernate");
232116
232630
  return true;
232117
232631
  } catch (error48) {
232118
232632
  console.error(`[AgentSession] Failed to hibernate session:`, error48);
@@ -232202,7 +232716,7 @@ var AgentSessionManager = class {
232202
232716
  if (session?.skipDb) return retained("retained_skip_db");
232203
232717
  if ((this.userMessagesInFlight.get(sessionId) ?? 0) > 0) return retained("retained_in_flight");
232204
232718
  if (this.retentionDeleting.has(sessionId)) return retained("retained_deleting");
232205
- if (session?.store.entries.some((entry) => entry !== void 0)) return retained("retained_has_entries");
232719
+ if (session && this.hasHistory(session)) return retained("retained_has_entries");
232206
232720
  this.retentionDeleting.add(sessionId);
232207
232721
  try {
232208
232722
  const deleted = await this.storage.agentSessions.deleteIfEmpty(sessionId);
@@ -232251,19 +232765,18 @@ var AgentSessionManager = class {
232251
232765
  this.killProcess(proc);
232252
232766
  this.emitProcessAlive(session, false);
232253
232767
  this.resetCompletion(session);
232768
+ session.clearGeneration += 1;
232769
+ session.store = coldStore(-1);
232770
+ session.historyMeta = { entryCount: 0, maxEntryIndex: -1 };
232771
+ session.hot = true;
232772
+ session.buffer = "";
232773
+ session.dormant = false;
232254
232774
  if (!session.skipDb) {
232255
232775
  await this.storage.agentSessions.deleteEntries(sessionId);
232256
232776
  session.historyEpoch = await this.storage.agentSessions.incrementHistoryEpoch(sessionId);
232257
232777
  } else {
232258
232778
  session.historyEpoch += 1;
232259
232779
  }
232260
- session.store.patches = [];
232261
- session.store.entries = [];
232262
- session.store.indexProvider.reset();
232263
- session.store.toolTracker.clear();
232264
- session.store.currentAssistantIndex = null;
232265
- session.buffer = "";
232266
- session.dormant = false;
232267
232780
  session.turnOpenSince = null;
232268
232781
  this.touchSession(session);
232269
232782
  this.broadcastRaw(sessionId, {
@@ -232304,8 +232817,7 @@ var AgentSessionManager = class {
232304
232817
  const session = this.sessions.get(sessionId);
232305
232818
  if (!session) return "not_found";
232306
232819
  if (session.agentType === agentType) return "ok";
232307
- const hasHistory = session.store.entries.some(Boolean);
232308
- if (session.status === "running" && hasHistory) return "busy";
232820
+ if (session.status === "running" && this.hasHistory(session)) return "busy";
232309
232821
  console.log(`[AgentSession] Switching session ${sessionId} agent ${session.agentType} \u2192 ${agentType} (dormant, history preserved)`);
232310
232822
  const proc = session.process;
232311
232823
  session.process = null;
@@ -232337,6 +232849,7 @@ var AgentSessionManager = class {
232337
232849
  this.broadcastPatch(sessionId, ConversationPatch.updateStatus("stopped"));
232338
232850
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: "stopped" });
232339
232851
  }
232852
+ this.unloadHistory(session, "agent-switch");
232340
232853
  return "ok";
232341
232854
  }
232342
232855
  /**
@@ -232406,6 +232919,7 @@ var AgentSessionManager = class {
232406
232919
  this.broadcastPatch(sessionId, ConversationPatch.updateStatus("stopped"));
232407
232920
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: "stopped" });
232408
232921
  }
232922
+ this.unloadHistory(session, "model-change");
232409
232923
  }
232410
232924
  return "ok";
232411
232925
  });
@@ -232417,7 +232931,7 @@ var AgentSessionManager = class {
232417
232931
  * chip and the agent chip lock at different moments in the same header row.
232418
232932
  */
232419
232933
  isModelChangeTooLate(session) {
232420
- return session.status === "running" && session.store.entries.some(Boolean);
232934
+ return session.status === "running" && this.hasHistory(session);
232421
232935
  }
232422
232936
  /**
232423
232937
  * Switch permission mode for a session (preserves conversation history)
@@ -232427,7 +232941,21 @@ var AgentSessionManager = class {
232427
232941
  if (!session) {
232428
232942
  return false;
232429
232943
  }
232430
- const absoluteWorktreePath = await this.resolveSessionWorktreePath(session, projectPath);
232944
+ const release = this.beginProcessStart(session);
232945
+ if (!release) {
232946
+ console.log(`[AgentSession] Refusing to switch mode on ${sessionId}: retention is deleting it`);
232947
+ return false;
232948
+ }
232949
+ try {
232950
+ const absoluteWorktreePath = await this.resolveSessionWorktreePath(session, projectPath);
232951
+ await this.hydrateForSpawn(session);
232952
+ return await this.switchModeInner(session, sessionId, absoluteWorktreePath, newMode, initialMessage);
232953
+ } finally {
232954
+ release();
232955
+ this.unloadIfSpawnAbandoned(session);
232956
+ }
232957
+ }
232958
+ async switchModeInner(session, sessionId, absoluteWorktreePath, newMode, initialMessage) {
232431
232959
  console.log(`[AgentSession] Switching session ${sessionId} from ${session.permissionMode} to ${newMode}`);
232432
232960
  this.resetCompletion(session);
232433
232961
  const proc = session.process;
@@ -232561,10 +233089,12 @@ var AgentSessionManager = class {
232561
233089
  return true;
232562
233090
  } finally {
232563
233091
  release();
233092
+ this.unloadIfSpawnAbandoned(session);
232564
233093
  }
232565
233094
  }
232566
233095
  async wakeDormantSessionInner(session, projectPath, userMessage, userId, origin, notificationDisposition) {
232567
233096
  const absoluteWorktreePath = await this.resolveSessionWorktreePath(session, projectPath);
233097
+ await this.hydrateForSpawn(session);
232568
233098
  await this.ensureResidentCapacity(
232569
233099
  { projectId: session.projectId, branch: session.branch },
232570
233100
  { excludeSessionId: session.id }
@@ -232642,9 +233172,10 @@ var AgentSessionManager = class {
232642
233172
  * Crash repair (restore path): if the previous process died mid-turn, the
232643
233173
  * history has no closing turn_end — append one with outcome
232644
233174
  * "server_restart" and no duration (the crash time is unknown; the UI
232645
- * shows "interrupted" instead of a fabricated number). Runs BEFORE
232646
- * rebuildStoreFromRows so the store is built from the repaired rows.
232647
- * The other constructor of turn_end entries is endActiveTurn (live paths).
233175
+ * shows "interrupted" instead of a fabricated number). Returns the session's
233176
+ * updated history metadata, so restore accounts for the entry it wrote
233177
+ * without re-reading. The other constructor of turn_end entries is
233178
+ * endActiveTurn (live paths).
232648
233179
  *
232649
233180
  * Also records a turn_snapshots row at the repair index (mirrors
232650
233181
  * endActiveTurn's hook), capturing the worktree exactly as the crash left
@@ -232654,32 +233185,39 @@ var AgentSessionManager = class {
232654
233185
  * this runs on server boot for every restored session and must never
232655
233186
  * throw into the restore path.
232656
233187
  */
232657
- async repairInterruptedTurn(dbSession, rows) {
233188
+ async repairInterruptedTurn(dbSession, meta3) {
232658
233189
  const sessionId = dbSession.id;
232659
- let landingType = null;
232660
- for (let i = rows.length - 1; i >= 0; i--) {
232661
- try {
232662
- const msg = JSON.parse(rows[i].data);
232663
- if (msg.type === "system") continue;
232664
- landingType = msg.type;
232665
- } catch {
232666
- landingType = "unparsable";
232667
- }
232668
- break;
232669
- }
232670
- if (landingType === null || landingType === "turn_end") return rows;
232671
- const maxIndex = rows.reduce((m2, r) => Math.max(m2, r.entry_index), -1);
232672
- const repairIndex = maxIndex + 1;
232673
- const entries = [];
232674
- for (const row of rows) {
232675
- try {
232676
- entries[row.entry_index] = JSON.parse(row.data);
232677
- } catch {
233190
+ if (meta3.entryCount === 0) return meta3;
233191
+ let landingType;
233192
+ let opening;
233193
+ let cursor = null;
233194
+ let atBoundary = false;
233195
+ while (!atBoundary) {
233196
+ const batch = await this.reader.readBefore(sessionId, cursor, REPAIR_SCAN_BATCH);
233197
+ if (batch.length === 0) break;
233198
+ for (const { message } of batch) {
233199
+ if (message === void 0) {
233200
+ landingType ??= "unparsable";
233201
+ continue;
233202
+ }
233203
+ if (landingType === void 0) {
233204
+ if (message.type === "system") continue;
233205
+ landingType = message.type;
233206
+ if (landingType === "turn_end") return meta3;
233207
+ if (message.type === "user") opening = message;
233208
+ continue;
233209
+ }
233210
+ if (message.type === "turn_end") {
233211
+ atBoundary = true;
233212
+ break;
233213
+ }
233214
+ if (message.type === "user") opening = message;
232678
233215
  }
233216
+ cursor = batch[batch.length - 1].entryIndex;
232679
233217
  }
232680
- const disposition = resolveNotificationDisposition(
232681
- findTurnOpeningUserEntry(entries, repairIndex)
232682
- );
233218
+ if (landingType === void 0) return meta3;
233219
+ const repairIndex = meta3.maxEntryIndex + 1;
233220
+ const disposition = resolveNotificationDisposition(opening);
232683
233221
  const repair = {
232684
233222
  type: "turn_end",
232685
233223
  timestamp: Date.now(),
@@ -232719,27 +233257,42 @@ var AgentSessionManager = class {
232719
233257
  } catch (error48) {
232720
233258
  console.warn(`[AgentSession] Turn snapshot lookup failed for ${sessionId}@${repairIndex}:`, error48);
232721
233259
  }
232722
- return [...rows, { entry_index: repairIndex, data }];
233260
+ return { entryCount: meta3.entryCount + 1, maxEntryIndex: repairIndex };
232723
233261
  }
232724
233262
  /**
232725
233263
  * Restore sessions from database on startup.
232726
233264
  * Creates dormant RunningSession objects with process=null for sessions that have entries.
233265
+ *
233266
+ * Restores IDENTITY AND METADATA ONLY — no transcripts. One aggregate query
233267
+ * gives every session its entry count and highest index; the entries
233268
+ * themselves stay in the database until something actually spawns a process
233269
+ * for the session (`hydrateForSpawn`) or reads its history
233270
+ * (`SessionHistoryReader`). That is what turns startup from O(all history)
233271
+ * into O(session count): on the worker this plan was written for, 1385
233272
+ * sessions were carrying 474 MiB of transcript that boot used to parse into
233273
+ * the heap before the server would answer its first request.
232727
233274
  */
232728
233275
  async restoreSessionsFromDb() {
232729
233276
  const allSessions = await this.storage.agentSessions.getAll();
233277
+ const entryMeta = new Map(
233278
+ (await this.storage.agentSessions.getEntryMetaAll()).map((row) => [
233279
+ row.session_id,
233280
+ { entryCount: row.cnt, maxEntryIndex: row.max_index }
233281
+ ])
233282
+ );
232730
233283
  let restoredCount = 0;
232731
233284
  let zeroEntryRows = 0;
232732
233285
  for (const dbSession of allSessions) {
232733
233286
  if (this.sessions.has(dbSession.id)) continue;
232734
- let entries = await this.storage.agentSessions.getEntries(dbSession.id);
232735
- if (entries.length === 0) {
233287
+ let meta3 = entryMeta.get(dbSession.id);
233288
+ if (!meta3 || meta3.entryCount === 0) {
232736
233289
  zeroEntryRows++;
232737
233290
  continue;
232738
233291
  }
232739
233292
  if (dbSession.status === "running") {
232740
- entries = await this.repairInterruptedTurn(dbSession, entries);
233293
+ meta3 = await this.repairInterruptedTurn(dbSession, meta3);
232741
233294
  }
232742
- const store = this.rebuildStoreFromRows(entries, dbSession.id);
233295
+ const store = coldStore(meta3.maxEntryIndex);
232743
233296
  const permissionMode = dbSession.permission_mode === "plan" ? "plan" : "edit";
232744
233297
  const restoredCheckout = dbSession.workspace_checkout_id ? await this.storage.workspaceRegistry.getCheckoutById(dbSession.workspace_checkout_id) : void 0;
232745
233298
  const activityReader = this.storage.agentSessions.getActivityById;
@@ -232758,6 +233311,11 @@ var AgentSessionManager = class {
232758
233311
  processStartsInFlight: 0,
232759
233312
  historyEpoch: dbSession.history_epoch ?? 0,
232760
233313
  store,
233314
+ // Restored cold: no process, so no transcript in memory (B1).
233315
+ hot: false,
233316
+ historyMeta: meta3,
233317
+ hydrating: null,
233318
+ clearGeneration: 0,
232761
233319
  subscribers: /* @__PURE__ */ new Set(),
232762
233320
  status: "stopped",
232763
233321
  buffer: "",
@@ -232771,6 +233329,8 @@ var AgentSessionManager = class {
232771
233329
  graceTimer: null,
232772
233330
  parkTimer: null,
232773
233331
  eventChain: Promise.resolve(),
233332
+ chainPending: 0,
233333
+ historyChain: Promise.resolve(),
232774
233334
  bgSpawnHintsThisTurn: 0,
232775
233335
  taskStartedThisTurn: 0,
232776
233336
  lastActiveAt: Date.now(),
@@ -232902,7 +233462,7 @@ var AgentSessionManager = class {
232902
233462
  existingRuntime.branchedFromEntryIndex = repairedEntryIndex;
232903
233463
  }
232904
233464
  }
232905
- return { ok: true, sessionId: newId };
233465
+ return { ok: true, sessionId: newId, messages: denseMessagesFromRows(entryRows) };
232906
233466
  }
232907
233467
  }
232908
233468
  let inheritedCheckoutId = source?.workspaceCheckoutId ?? sourceRow?.workspace_checkout_id ?? void 0;
@@ -232952,7 +233512,11 @@ var AgentSessionManager = class {
232952
233512
  }
232953
233513
  await this.storage.agentSessions.updateTitle(newId, `Branch - ${baseTitle || "Conversation"}`);
232954
233514
  this.markTitleResolved(newId);
232955
- const store = this.rebuildStoreFromRows(entryRows, newId);
233515
+ const branchedMeta = {
233516
+ entryCount: entryRows.length,
233517
+ maxEntryIndex: entryRows[entryRows.length - 1].entry_index
233518
+ };
233519
+ const store = coldStore(branchedMeta.maxEntryIndex);
232956
233520
  const branched = {
232957
233521
  id: newId,
232958
233522
  projectId,
@@ -232964,6 +233528,10 @@ var AgentSessionManager = class {
232964
233528
  processStartsInFlight: 0,
232965
233529
  historyEpoch: 0,
232966
233530
  store,
233531
+ hot: false,
233532
+ historyMeta: branchedMeta,
233533
+ hydrating: null,
233534
+ clearGeneration: 0,
232967
233535
  subscribers: /* @__PURE__ */ new Set(),
232968
233536
  status: "stopped",
232969
233537
  buffer: "",
@@ -232975,6 +233543,8 @@ var AgentSessionManager = class {
232975
233543
  graceTimer: null,
232976
233544
  parkTimer: null,
232977
233545
  eventChain: Promise.resolve(),
233546
+ chainPending: 0,
233547
+ historyChain: Promise.resolve(),
232978
233548
  bgSpawnHintsThisTurn: 0,
232979
233549
  taskStartedThisTurn: 0,
232980
233550
  lastActiveAt: Date.now(),
@@ -232988,7 +233558,7 @@ var AgentSessionManager = class {
232988
233558
  this.sessions.set(newId, branched);
232989
233559
  await this.emitDerivedBranchActivity(projectId, branch);
232990
233560
  console.log(`[AgentSession] branchSession: ${sourceSessionId} \u2192 ${newId} (entries=${entryRows.length}, agentType=${agentType})`);
232991
- return { ok: true, sessionId: newId };
233561
+ return { ok: true, sessionId: newId, messages: denseMessagesFromRows(entryRows) };
232992
233562
  }
232993
233563
  /**
232994
233564
  * Kill all active session processes and clear state for graceful shutdown
@@ -235755,7 +236325,7 @@ var ChatSessionManager = class {
235755
236325
  agentSession = projectSessions.find((s3) => s3.status === "running") ?? projectSessions[0] ?? null;
235756
236326
  }
235757
236327
  if (agentSession) {
235758
- const allMessages = agentSessionManager.getMessages(agentSession.id);
236328
+ const allMessages = await agentSessionManager.loadMessages(agentSession.id);
235759
236329
  const recent = allMessages.slice(-tailMessages);
235760
236330
  localResult = {
235761
236331
  sessionId: agentSession.id,
@@ -238231,7 +238801,7 @@ async function createProjectChatTools(options) {
238231
238801
  agentType: nullablePreview(local.agent_type, ENUM_CHAR_LIMIT),
238232
238802
  model: nullablePreview(local.model, MODEL_CHAR_LIMIT),
238233
238803
  processAlive: agentSessionManager.getSessionProcessAlive(local.id),
238234
- transcript: transcriptPreview(agentSessionManager.getMessages(local.id))
238804
+ transcript: transcriptPreview(await agentSessionManager.loadMessages(local.id))
238235
238805
  };
238236
238806
  await touch("agent_session", local.id);
238237
238807
  return detail2;
@@ -240761,7 +241331,7 @@ var WorkflowEngine = class {
240761
241331
  if (sourceSession?.status === "running") {
240762
241332
  throw new WorkflowError("source-running", "source session \u6B63\u5728\u8FD0\u884C\uFF0C\u8BF7\u7B49\u5F85\u5F53\u524D turn \u5B8C\u6210\u540E\u518D\u53D1\u8D77 review");
240763
241333
  }
240764
- const entries = this.agentOps.getRawMessages(opts.sourceSessionId);
241334
+ const entries = await this.agentOps.getRawMessages(opts.sourceSessionId);
240765
241335
  const turnEndIndex = opts.sourceTurnEndIndex ?? extractLatestTurnEndIndex(entries);
240766
241336
  if (turnEndIndex === null) {
240767
241337
  throw new WorkflowError("no-completed-turn", "source session \u8FD8\u6CA1\u6709\u5DF2\u5B8C\u6210\u7684 turn \u53EF\u4F9B review");
@@ -240945,7 +241515,7 @@ var WorkflowEngine = class {
240945
241515
  const pending = this.pendingActivations.get(runId) ?? parsePreparedContext(run2.prepared_context);
240946
241516
  let outcome;
240947
241517
  try {
240948
- const entries = pending ? null : this.agentOps.getRawMessages(run2.source_session_id);
241518
+ const entries = pending ? null : await this.agentOps.getRawMessages(run2.source_session_id);
240949
241519
  const prompt = buildReviewerPrompt({
240950
241520
  taskContext: pending ? pending.taskContext : extractTaskContextBefore(entries, run2.source_turn_end_index),
240951
241521
  originalIntent: pending ? pending.originalIntent : extractFirstUserMessage(entries),
@@ -241002,7 +241572,7 @@ var WorkflowEngine = class {
241002
241572
  if (!p2 || p2.role !== "reviewer") return;
241003
241573
  const run2 = await this.storage.workflowRuns.getById(p2.runId);
241004
241574
  if (!run2 || run2.status !== "waiting_reviewer") return;
241005
- const entries = this.agentOps.getRawMessages(event.sessionId);
241575
+ const entries = await this.agentOps.getRawMessages(event.sessionId);
241006
241576
  const boundary = event.turnEndEntryIndex ?? extractLatestTurnEndIndex(entries) ?? entries.length;
241007
241577
  const feedback = extractLastAssistantInTurn(entries, boundary) ?? "(reviewer \u6CA1\u6709\u8F93\u51FA\u53EF\u7528\u7684\u53CD\u9988\u6587\u672C)";
241008
241578
  let driftNote = null;
@@ -245234,7 +245804,8 @@ function collectMemoryStats(deps) {
245234
245804
  uptime_s: Math.round(process.uptime())
245235
245805
  },
245236
245806
  patch_cache: deps.remotePatchCache.stats(),
245237
- process_manager: deps.processManager.logBufferStats()
245807
+ process_manager: deps.processManager.logBufferStats(),
245808
+ ...deps.sessionHydration ? { agent_sessions: deps.sessionHydration.hydrationStats() } : {}
245238
245809
  };
245239
245810
  }
245240
245811
  var MemoryStatsReporter = class {
@@ -245617,7 +246188,7 @@ var sharedServices = async (fastify2, opts) => {
245617
246188
  sendUserMessage: (...args) => agentSessionManager.sendUserMessage(...args),
245618
246189
  setFinalSessionTitle: (sessionId, title) => agentSessionManager.setFinalSessionTitle(sessionId, title),
245619
246190
  switchMode: (sessionId, projectPath, mode) => agentSessionManager.switchMode(sessionId, projectPath, mode),
245620
- getRawMessages: (sessionId) => agentSessionManager.getRawMessages(sessionId),
246191
+ getRawMessages: (sessionId) => agentSessionManager.loadRawMessages(sessionId),
245621
246192
  broadcastRawToSession: (sessionId, payload) => agentSessionManager.broadcastRawToSession(sessionId, payload)
245622
246193
  };
245623
246194
  const workflowEngine = new WorkflowEngine(opts.storage, reviewAgentOps);
@@ -245675,7 +246246,8 @@ var sharedServices = async (fastify2, opts) => {
245675
246246
  remoteNotificationSync.enqueue(() => remoteNotificationSync.syncAll({ includeExpired: true }));
245676
246247
  const memoryStatsReporter = new MemoryStatsReporter({
245677
246248
  remotePatchCache,
245678
- processManager
246249
+ processManager,
246250
+ sessionHydration: agentSessionManager
245679
246251
  });
245680
246252
  memoryStatsReporter.start();
245681
246253
  fastify2.addHook("onClose", async () => {
@@ -246267,7 +246839,8 @@ var routes4 = async (fastify2) => {
246267
246839
  if (!isOperatorRequest(request)) return reply.code(404).send({ error: "Not found" });
246268
246840
  return reply.send(collectMemoryStats({
246269
246841
  remotePatchCache: fastify2.remotePatchCache,
246270
- processManager: fastify2.processManager
246842
+ processManager: fastify2.processManager,
246843
+ sessionHydration: fastify2.agentSessionManager
246271
246844
  }));
246272
246845
  });
246273
246846
  fastify2.get("/api/admin/worker-version-stats", async (request, reply) => {
@@ -249401,48 +249974,9 @@ import { createHash as createHash7, randomUUID as randomUUID16 } from "crypto";
249401
249974
  // src/protocol/model-suggestions.ts
249402
249975
  var MODEL_SUGGESTIONS = {
249403
249976
  "claude-code": ["opus", "sonnet", "haiku", "fable"],
249404
- codex: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"]
249977
+ codex: ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"]
249405
249978
  };
249406
249979
 
249407
- // src/session-history-window.ts
249408
- function historyHead(entries, historyEpoch) {
249409
- let latestEntryIndex = null;
249410
- let lastTurnEndEntryIndex = null;
249411
- for (let index = entries.length - 1; index >= 0; index--) {
249412
- const message = entries[index];
249413
- if (!message) continue;
249414
- latestEntryIndex ??= index;
249415
- if (lastTurnEndEntryIndex === null && message.type === "turn_end") {
249416
- lastTurnEndEntryIndex = index;
249417
- }
249418
- if (latestEntryIndex !== null && lastTurnEndEntryIndex !== null) break;
249419
- }
249420
- return { historyEpoch, latestEntryIndex, lastTurnEndEntryIndex };
249421
- }
249422
- function buildHistoryWindow(entries, historyEpoch, opts = {}) {
249423
- const head = historyHead(entries, historyEpoch);
249424
- const endExclusive = Math.max(0, Math.min(opts.before ?? entries.length, entries.length));
249425
- const requestedTurns = Math.max(1, Math.min(opts.turns ?? 5, 20));
249426
- const boundaries = [];
249427
- for (let index = endExclusive - 1; index >= 0; index--) {
249428
- if (entries[index]?.type === "turn_end") boundaries.push(index);
249429
- if (boundaries.length >= requestedTurns + 2) break;
249430
- }
249431
- const startIndex = boundaries.length > requestedTurns + 1 ? boundaries[requestedTurns + 1] + 1 : 0;
249432
- const dense = [];
249433
- for (let index = startIndex; index < endExclusive; index++) {
249434
- const message = entries[index];
249435
- if (message) dense.push({ entryIndex: index, message });
249436
- }
249437
- const hasMore = startIndex > 0;
249438
- return {
249439
- ...head,
249440
- entries: dense,
249441
- previousCursor: hasMore ? startIndex : null,
249442
- hasMore
249443
- };
249444
- }
249445
-
249446
249980
  // src/routes/agent-session-routes.ts
249447
249981
  async function resolveProjectPath(projectId, storage2) {
249448
249982
  if (projectId.startsWith("path:")) {
@@ -249621,7 +250155,7 @@ var routes11 = async (fastify2) => {
249621
250155
  }
249622
250156
  const newSessionId = result.sessionId;
249623
250157
  const session = fastify2.agentSessionManager.getSession(newSessionId);
249624
- const messages = fastify2.agentSessionManager.getMessages(newSessionId);
250158
+ const messages = result.messages;
249625
250159
  const dbRow = await fastify2.storage.agentSessions.getById(newSessionId);
249626
250160
  return {
249627
250161
  ok: true,
@@ -249698,9 +250232,10 @@ var routes11 = async (fastify2) => {
249698
250232
  return reply.code(200).send({ session: null, messages: [] });
249699
250233
  }
249700
250234
  const session = fastify2.agentSessionManager.getSession(sessionId);
249701
- const messages = fastify2.agentSessionManager.getMessages(sessionId);
249702
250235
  const epoch = fastify2.agentSessionManager.getHistoryEpoch(sessionId) ?? 0;
249703
- const historyWindow = historyTurns ? buildHistoryWindow(fastify2.agentSessionManager.getRawMessages(sessionId), epoch, { turns: historyTurns }) : void 0;
250236
+ const rawMessages = await fastify2.agentSessionManager.loadRawMessages(sessionId);
250237
+ const messages = rawMessages.filter(Boolean);
250238
+ const historyWindow = historyTurns ? buildHistoryWindow(rawMessages, epoch, { turns: historyTurns }) : void 0;
249704
250239
  const projection = await fastify2.storage.agentSessions.getActivityById(sessionId, "session-detail");
249705
250240
  if (session?.workspaceCheckoutId && !projection) {
249706
250241
  return reply.code(409).send({
@@ -249866,7 +250401,7 @@ var routes11 = async (fastify2) => {
249866
250401
  worktreePath: recovered?.checkoutPath ?? null,
249867
250402
  processAlive: recovered ? fastify2.agentSessionManager.getSessionProcessAlive(recoveredSessionId) : false
249868
250403
  },
249869
- messages: fastify2.agentSessionManager.getMessages(recoveredSessionId)
250404
+ messages: await fastify2.agentSessionManager.loadMessages(recoveredSessionId)
249870
250405
  });
249871
250406
  }
249872
250407
  return reply.code(200).send({
@@ -249883,7 +250418,7 @@ var routes11 = async (fastify2) => {
249883
250418
  processAlive: fastify2.agentSessionManager.getSessionProcessAlive(sessionId),
249884
250419
  ...sendBackFields(active)
249885
250420
  },
249886
- messages: fastify2.agentSessionManager.getMessages(sessionId)
250421
+ messages: await fastify2.agentSessionManager.loadMessages(sessionId)
249887
250422
  });
249888
250423
  }
249889
250424
  }
@@ -250234,9 +250769,10 @@ var routes11 = async (fastify2) => {
250234
250769
  return reply.code(200).send({ session: null, messages: [] });
250235
250770
  }
250236
250771
  const session = fastify2.agentSessionManager.getSession(sessionId);
250237
- const messages = fastify2.agentSessionManager.getMessages(sessionId);
250238
250772
  const epoch = fastify2.agentSessionManager.getHistoryEpoch(sessionId) ?? 0;
250239
- const historyWindow = historyTurns ? buildHistoryWindow(fastify2.agentSessionManager.getRawMessages(sessionId), epoch, { turns: historyTurns }) : void 0;
250773
+ const rawMessages = await fastify2.agentSessionManager.loadRawMessages(sessionId);
250774
+ const messages = rawMessages.filter(Boolean);
250775
+ const historyWindow = historyTurns ? buildHistoryWindow(rawMessages, epoch, { turns: historyTurns }) : void 0;
250240
250776
  const effectiveStatus = session?.status || "stopped";
250241
250777
  return reply.code(200).send({
250242
250778
  session: {
@@ -250420,7 +250956,7 @@ var routes11 = async (fastify2) => {
250420
250956
  error: "The workspace checkout binding for this session is unavailable"
250421
250957
  });
250422
250958
  }
250423
- const messages = fastify2.agentSessionManager.getMessages(req.params.sessionId);
250959
+ const messages = await fastify2.agentSessionManager.loadMessages(req.params.sessionId);
250424
250960
  return reply.code(200).send({
250425
250961
  session: {
250426
250962
  id: session.id,
@@ -250492,9 +251028,10 @@ var routes11 = async (fastify2) => {
250492
251028
  }
250493
251029
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250494
251030
  if (!session) return reply.code(404).send({ error: "Session not found" });
250495
- const epoch = fastify2.agentSessionManager.getHistoryEpoch(req.params.sessionId) ?? 0;
251031
+ const window2 = await fastify2.agentSessionManager.loadHistoryWindow(req.params.sessionId, { before, turns });
251032
+ if (!window2) return reply.code(404).send({ error: "Session not found" });
250496
251033
  return reply.code(200).send({
250497
- ...buildHistoryWindow(fastify2.agentSessionManager.getRawMessages(req.params.sessionId), epoch, { before, turns }),
251034
+ ...window2,
250498
251035
  status: session.status,
250499
251036
  session: {
250500
251037
  id: session.id,
@@ -250560,8 +251097,8 @@ var routes11 = async (fastify2) => {
250560
251097
  }
250561
251098
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250562
251099
  if (!session) return reply.code(404).send({ error: "Session not found" });
250563
- const epoch = fastify2.agentSessionManager.getHistoryEpoch(req.params.sessionId) ?? 0;
250564
- const head = buildHistoryWindow(fastify2.agentSessionManager.getRawMessages(req.params.sessionId), epoch, { turns: 1 });
251100
+ const head = await fastify2.agentSessionManager.loadHistoryHead(req.params.sessionId);
251101
+ if (!head) return reply.code(404).send({ error: "Session not found" });
250565
251102
  return reply.code(200).send({
250566
251103
  historyEpoch: head.historyEpoch,
250567
251104
  latestEntryIndex: head.latestEntryIndex,
@@ -250576,7 +251113,7 @@ var routes11 = async (fastify2) => {
250576
251113
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250577
251114
  if (!session) return reply.code(404).send({ error: "Session not found" });
250578
251115
  return reply.code(200).send({
250579
- messages: projectMessagesForBrief(fastify2.agentSessionManager.getMessages(req.params.sessionId))
251116
+ messages: projectMessagesForBrief(await fastify2.agentSessionManager.loadMessages(req.params.sessionId))
250580
251117
  });
250581
251118
  }
250582
251119
  );
@@ -251385,6 +251922,16 @@ var routes11 = async (fastify2) => {
251385
251922
  `/api/agent-sessions/${remoteInfo.remoteSessionId}/favorite`,
251386
251923
  { favorited }
251387
251924
  );
251925
+ if (result.ok) {
251926
+ try {
251927
+ await fastify2.storage.searchCache.updateCachedSessionFavorited(
251928
+ req.params.sessionId,
251929
+ favorited ? Date.now() : null
251930
+ );
251931
+ } catch (err) {
251932
+ console.error("[API] searchCache.updateCachedSessionFavorited failed:", err);
251933
+ }
251934
+ }
251388
251935
  return reply.code(proxyStatus(result)).send(result.data);
251389
251936
  }
251390
251937
  const session = await fastify2.storage.agentSessions.getById(req.params.sessionId);
@@ -252467,17 +253014,19 @@ var RECENT_SESSION_LIMIT = 8;
252467
253014
  var RECENT_RUN_LIMIT = 5;
252468
253015
  var PRIORITY_TASK_LIMIT = 5;
252469
253016
  var ATTENTION_LIMIT = 10;
253017
+ var STARRED_LIMIT = 50;
252470
253018
  var parseDbTimestamp4 = (value) => {
252471
253019
  if (!value) return null;
252472
253020
  const explicitZone = /(?:Z|[+-]\d\d:\d\d)$/i.test(value);
252473
253021
  const parsed = Date.parse(explicitZone ? value : `${value.replace(" ", "T")}Z`);
252474
253022
  return Number.isNaN(parsed) ? null : parsed;
252475
253023
  };
252476
- var mergeActivity = (local, remote, limit) => {
253024
+ var mergeBy = (local, remote, limit, rank) => {
252477
253025
  const byId = /* @__PURE__ */ new Map();
252478
253026
  for (const row of [...local, ...remote]) if (!byId.has(row.id)) byId.set(row.id, row);
252479
- return [...byId.values()].sort((left, right) => (right.lastActiveAt ?? 0) - (left.lastActiveAt ?? 0) || left.id.localeCompare(right.id)).slice(0, limit);
253027
+ return [...byId.values()].sort((left, right) => rank(right) - rank(left) || left.id.localeCompare(right.id)).slice(0, limit);
252480
253028
  };
253029
+ var mergeActivity = (local, remote, limit) => mergeBy(local, remote, limit, (row) => row.lastActiveAt ?? 0);
252481
253030
  async function getProjectActivity(storage2, projectId, userId) {
252482
253031
  const [
252483
253032
  recentThreads,
@@ -252487,6 +253036,8 @@ async function getProjectActivity(storage2, projectId, userId) {
252487
253036
  priorityTasks,
252488
253037
  localAttentionSessions,
252489
253038
  remoteAttentionSessions,
253039
+ localStarredSessions,
253040
+ remoteStarredSessions,
252490
253041
  attentionRuns,
252491
253042
  runningSessions,
252492
253043
  runningRuns,
@@ -252500,6 +253051,8 @@ async function getProjectActivity(storage2, projectId, userId) {
252500
253051
  storage2.tasks.listPriorityByProject(projectId, PRIORITY_TASK_LIMIT),
252501
253052
  storage2.agentSessions.listAttentionActivityByProject(projectId, ATTENTION_LIMIT, "project-activity"),
252502
253053
  storage2.searchCache.listRemoteSessionAttentionByProject(projectId, ATTENTION_LIMIT, "project-activity"),
253054
+ storage2.agentSessions.listFavoritedActivityByProject(projectId, STARRED_LIMIT + 1, "project-activity"),
253055
+ storage2.searchCache.listRemoteSessionFavoritesByProject(projectId, STARRED_LIMIT + 1, "project-activity"),
252503
253056
  storage2.scheduledTaskRuns.getAttentionByProject(projectId, ATTENTION_LIMIT),
252504
253057
  storage2.agentSessions.countRunningActivityByProject(projectId),
252505
253058
  storage2.scheduledTaskRuns.countByProjectStatuses(projectId, ["starting", "running"]),
@@ -252516,6 +253069,14 @@ async function getProjectActivity(storage2, projectId, userId) {
252516
253069
  remoteAttentionSessions,
252517
253070
  ATTENTION_LIMIT
252518
253071
  );
253072
+ const starredProbe = mergeBy(
253073
+ localStarredSessions,
253074
+ remoteStarredSessions,
253075
+ STARRED_LIMIT + 1,
253076
+ (row) => row.favoritedAt ?? 0
253077
+ );
253078
+ const starredHasMore = starredProbe.length > STARRED_LIMIT;
253079
+ const starredSessions = starredProbe.slice(0, STARRED_LIMIT);
252519
253080
  const attention = [
252520
253081
  ...attentionSessions.map((session) => ({
252521
253082
  type: "agent_session",
@@ -252540,6 +253101,8 @@ async function getProjectActivity(storage2, projectId, userId) {
252540
253101
  return {
252541
253102
  recentThreads,
252542
253103
  recentAgentSessions,
253104
+ starredSessions,
253105
+ starredHasMore,
252543
253106
  recentScheduleRuns,
252544
253107
  priorityTasks,
252545
253108
  attention,
@@ -253113,7 +253676,7 @@ async function routes21(fastify2) {
253113
253676
  if (!historyResult.ok) return void 0;
253114
253677
  messages = historyResult.data.messages ?? [];
253115
253678
  } else {
253116
- messages = fastify2.agentSessionManager.getMessages(sourceSessionId);
253679
+ messages = await fastify2.agentSessionManager.loadMessages(sourceSessionId);
253117
253680
  }
253118
253681
  return await generateIntentBrief(fastify2.storage, resolveUserId(userId), messages) ?? void 0;
253119
253682
  } catch (err) {
@@ -254854,14 +255417,12 @@ var routes24 = async (fastify2) => {
254854
255417
  });
254855
255418
  return;
254856
255419
  }
254857
- const unsubscribe = fastify2.agentSessionManager.subscribe(sessionId, socket, { afterEntryIndex, historyEpoch });
254858
- if (!unsubscribe) {
254859
- console.log(`[AgentWS] Session ${sessionId} not found`);
255420
+ let unsubscribe = null;
255421
+ socket.on("close", () => {
255422
+ console.log(`[AgentWS] Client disconnected from session ${sessionId}`);
254860
255423
  stopHeartbeat();
254861
- socket.send(JSON.stringify({ error: "Session not found" }));
254862
- socket.close();
254863
- return;
254864
- }
255424
+ unsubscribe?.();
255425
+ });
254865
255426
  socket.on("message", (data) => {
254866
255427
  try {
254867
255428
  const message = JSON.parse(data.toString());
@@ -254874,11 +255435,21 @@ var routes24 = async (fastify2) => {
254874
255435
  console.error("[AgentWS] Failed to parse message:", error48);
254875
255436
  }
254876
255437
  });
254877
- socket.on("close", () => {
254878
- console.log(`[AgentWS] Client disconnected from session ${sessionId}`);
255438
+ unsubscribe = await fastify2.agentSessionManager.subscribe(
255439
+ sessionId,
255440
+ socket,
255441
+ { afterEntryIndex, historyEpoch }
255442
+ );
255443
+ if (!unsubscribe) {
255444
+ console.log(`[AgentWS] Session ${sessionId} unavailable for subscribe`);
254879
255445
  stopHeartbeat();
254880
- unsubscribe?.();
254881
- });
255446
+ try {
255447
+ socket.send(JSON.stringify({ error: "Session not found" }));
255448
+ socket.close();
255449
+ } catch {
255450
+ }
255451
+ return;
255452
+ }
254882
255453
  }
254883
255454
  );
254884
255455
  fastify2.get(