@vibedeckx/linux-x64 0.3.35 → 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 +653 -157
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -187304,12 +187304,25 @@ var createAgentSessionRepos = (kdb, h) => ({
187304
187304
  getEntries: async (sessionId) => {
187305
187305
  return kdb.selectFrom("agent_session_entries").select(["entry_index", "data"]).where("session_id", "=", sessionId).orderBy("entry_index", "asc").execute();
187306
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
+ },
187307
187312
  deleteEntries: async (sessionId) => {
187308
187313
  await kdb.deleteFrom("agent_session_entries").where("session_id", "=", sessionId).execute();
187309
187314
  },
187310
187315
  countEntries: async () => {
187311
187316
  return kdb.selectFrom("agent_session_entries").select("session_id").select(kdb.fn.countAll().as("cnt")).groupBy("session_id").execute();
187312
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
+ },
187313
187326
  listRetentionCandidates: async ({ cutoff, limit, after }) => {
187314
187327
  let query = kdb.selectFrom("agent_sessions").select(["id", "project_id", "branch", "activity_at"]).where(retentionPredicate(cutoff));
187315
187328
  if (after) {
@@ -208542,6 +208555,111 @@ var EntryTracker = class {
208542
208555
  }
208543
208556
  };
208544
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
+
208545
208663
  // src/utils/worktree-paths.ts
208546
208664
  import path7 from "path";
208547
208665
  import { createHash } from "crypto";
@@ -230295,6 +230413,7 @@ ${details}`;
230295
230413
  return msg;
230296
230414
  }
230297
230415
  var SUMMARY_TEXT_CAP = 1500;
230416
+ var REPAIR_SCAN_BATCH = 64;
230298
230417
  function extractLastAssistantText(entries) {
230299
230418
  for (let i = entries.length - 1; i >= 0; i--) {
230300
230419
  const entry = entries[i];
@@ -230305,6 +230424,55 @@ function extractLastAssistantText(entries) {
230305
230424
  }
230306
230425
  return void 0;
230307
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
+ };
230308
230476
  var WorkspaceCheckoutUnavailableError = class extends Error {
230309
230477
  code = "workspace_checkout_unavailable";
230310
230478
  statusCode = 409;
@@ -230362,8 +230530,11 @@ var AgentSessionManager = class {
230362
230530
  /** Bound on a parked completion (injectable for tests). */
230363
230531
  parkTimeoutMs;
230364
230532
  workflowSuppressionCheck = null;
230533
+ /** Every read of a cold session's transcript goes through here. */
230534
+ reader;
230365
230535
  constructor(storage2, opts) {
230366
230536
  this.storage = storage2;
230537
+ this.reader = new SessionHistoryReader(storage2);
230367
230538
  this.completionGraceMs = opts?.completionGraceMs ?? COMPLETION_GRACE_MS;
230368
230539
  this.parkTimeoutMs = opts?.parkTimeoutMs ?? PARK_TIMEOUT_MS;
230369
230540
  }
@@ -230520,6 +230691,154 @@ var AgentSessionManager = class {
230520
230691
  const session = this.sessions.get(sessionId);
230521
230692
  if (session) this.emitSessionTitle(session.projectId, session.branch, sessionId, title);
230522
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
+ }
230523
230842
  isProcessAlive(session) {
230524
230843
  return !!session.process && session.process.exitCode === null && !session.dormant;
230525
230844
  }
@@ -230693,7 +231012,7 @@ var AgentSessionManager = class {
230693
231012
  }
230694
231013
  for (const session of this.sessions.values()) {
230695
231014
  if (session.projectId === projectId && session.branch === branch) {
230696
- 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)})`);
230697
231016
  return this.reuseExistingSession(session, projectPath);
230698
231017
  }
230699
231018
  }
@@ -230887,6 +231206,11 @@ var AgentSessionManager = class {
230887
231206
  processStartsInFlight: 0,
230888
231207
  historyEpoch: stored?.history_epoch ?? 0,
230889
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,
230890
231214
  subscribers: /* @__PURE__ */ new Set(),
230891
231215
  status: "running",
230892
231216
  buffer: "",
@@ -230900,6 +231224,8 @@ var AgentSessionManager = class {
230900
231224
  graceTimer: null,
230901
231225
  parkTimer: null,
230902
231226
  eventChain: Promise.resolve(),
231227
+ chainPending: 0,
231228
+ historyChain: Promise.resolve(),
230903
231229
  bgSpawnHintsThisTurn: 0,
230904
231230
  taskStartedThisTurn: 0,
230905
231231
  lastActiveAt: Date.now(),
@@ -230956,7 +231282,7 @@ var AgentSessionManager = class {
230956
231282
  * switch-mode route, which carries actual user intent.
230957
231283
  */
230958
231284
  async reuseExistingSession(session, projectPath) {
230959
- const entriesCount = session.store.entries.filter(Boolean).length;
231285
+ const entriesCount = this.historyEntryCount(session);
230960
231286
  this.touchSession(session);
230961
231287
  if (session.dormant) {
230962
231288
  console.log(`[AgentSession] Returning dormant session ${session.id} (entries=${entriesCount})`);
@@ -230988,9 +231314,10 @@ var AgentSessionManager = class {
230988
231314
  * Uses negative PID to signal the process group (requires detached: true at spawn).
230989
231315
  */
230990
231316
  killProcess(proc, signal = "SIGTERM") {
230991
- if (!proc?.pid) return;
231317
+ const pid = proc?.pid;
231318
+ if (!proc || pid === void 0 || !Number.isSafeInteger(pid) || pid <= 1) return;
230992
231319
  try {
230993
- process.kill(-proc.pid, signal);
231320
+ process.kill(-pid, signal);
230994
231321
  } catch {
230995
231322
  try {
230996
231323
  proc.kill(signal);
@@ -231122,6 +231449,7 @@ var AgentSessionManager = class {
231122
231449
  this.broadcastPatch(session.id, ConversationPatch.updateStatus(session.status));
231123
231450
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: session.status });
231124
231451
  this.broadcastRaw(session.id, { finished: true });
231452
+ this.unloadHistory(session, "process-exit");
231125
231453
  }, "process-close");
231126
231454
  });
231127
231455
  childProcess2.on("error", (error48) => {
@@ -231149,20 +231477,42 @@ var AgentSessionManager = class {
231149
231477
  * runs through here so steps never interleave across await points.
231150
231478
  */
231151
231479
  enqueueSessionWork(session, work, label) {
231480
+ session.chainPending += 1;
231152
231481
  session.eventChain = session.eventChain.then(work).catch((err) => {
231153
231482
  console.error(`[AgentSession] Error in ${label} handler for ${session.id}:`, err);
231483
+ }).then(() => {
231484
+ session.chainPending -= 1;
231154
231485
  });
231155
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
+ }
231156
231501
  /**
231157
231502
  * Same serial chain as `enqueueSessionWork`, for a caller that needs the
231158
231503
  * result back. The chain itself absorbs the outcome (success or failure) so
231159
231504
  * one queued step can never break the next; the caller gets the real promise.
231160
231505
  */
231161
231506
  runSerialForResult(session, work) {
231507
+ session.chainPending += 1;
231162
231508
  const result = session.eventChain.then(work);
231163
231509
  session.eventChain = result.then(
231164
- () => void 0,
231165
- () => void 0
231510
+ () => {
231511
+ session.chainPending -= 1;
231512
+ },
231513
+ () => {
231514
+ session.chainPending -= 1;
231515
+ }
231166
231516
  );
231167
231517
  return result;
231168
231518
  }
@@ -231392,6 +231742,7 @@ var AgentSessionManager = class {
231392
231742
  }
231393
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")) {
231394
231744
  session.turnOpenSince = timestamp;
231745
+ this.assertHot(session, "turn-open disposition");
231395
231746
  session.turnDisposition = resolveNotificationDisposition(findLatestUserEntry(session.store.entries));
231396
231747
  }
231397
231748
  if (session.status !== "running" && !outOfTurn && (event.type === "text" || event.type === "thinking" || event.type === "tool_use" || event.type === "tool_result" || event.type === "approval_request")) {
@@ -231655,15 +232006,40 @@ var AgentSessionManager = class {
231655
232006
  * the two persistence paths can't drift on index allocation or patch shape.
231656
232007
  */
231657
232008
  stageEntry(session, message) {
232009
+ this.assertHot(session, "stageEntry");
231658
232010
  const index = session.store.indexProvider.next();
231659
232011
  session.store.entries[index] = message;
231660
232012
  const patch = ConversationPatch.addEntry(index, message);
231661
232013
  session.store.patches.push(patch);
231662
232014
  return { index, patch };
231663
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
+ }
231664
232039
  async pushEntry(sessionId, message, broadcast = true, userId = "local", pushOpts) {
231665
232040
  const session = this.sessions.get(sessionId);
231666
232041
  if (!session) return -1;
232042
+ if (!session.hot) return this.pushColdEntry(session, message, broadcast, userId, pushOpts);
231667
232043
  const { index, patch } = this.stageEntry(session, message);
231668
232044
  if (!session.skipDb && message.type !== "assistant") {
231669
232045
  await this.persistEntry(session, index, message, userId, { strict: pushOpts?.strictPersist });
@@ -231673,6 +232049,48 @@ var AgentSessionManager = class {
231673
232049
  }
231674
232050
  return index;
231675
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
+ }
231676
232094
  /**
231677
232095
  * Persist a `turn_end` together with the attention milestone it earns.
231678
232096
  *
@@ -231698,8 +232116,15 @@ var AgentSessionManager = class {
231698
232116
  if (entryIndexOverride !== void 0) {
231699
232117
  index = entryIndexOverride;
231700
232118
  patch = ConversationPatch.addEntry(index, message);
231701
- } else {
232119
+ } else if (session.hot) {
231702
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);
231703
232128
  }
231704
232129
  if (!session.skipDb) {
231705
232130
  const activityReader = this.storage.agentSessions.getActivityById;
@@ -231785,9 +232210,12 @@ var AgentSessionManager = class {
231785
232210
  if (session.turnOpenSince === null) return null;
231786
232211
  const endedAt = Date.now();
231787
232212
  const durationMs = endedAt - session.turnOpenSince;
231788
- const disposition = session.turnDisposition ?? resolveNotificationDisposition(
231789
- findTurnOpeningUserEntry(session.store.entries, session.store.entries.length)
231790
- );
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
+ }
231791
232219
  const index = await this.pushTurnEnd(session, outcome, disposition, endedAt, durationMs);
231792
232220
  session.turnOpenSince = null;
231793
232221
  session.turnDisposition = null;
@@ -231946,48 +232374,83 @@ var AgentSessionManager = class {
231946
232374
  }
231947
232375
  }
231948
232376
  /**
231949
- * 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.
231950
232383
  */
231951
- subscribe(sessionId, ws, opts = {}) {
232384
+ async subscribe(sessionId, ws, opts = {}) {
231952
232385
  const session = this.sessions.get(sessionId);
231953
232386
  if (!session) {
231954
232387
  return null;
231955
232388
  }
232389
+ if (!isSocketOpen(ws)) return null;
231956
232390
  session.subscribers.add(ws);
231957
- const after = opts.historyEpoch === void 0 || opts.historyEpoch === session.historyEpoch ? opts.afterEntryIndex ?? -1 : -1;
231958
- ws.send(JSON.stringify({
231959
- HistorySync: {
231960
- historyEpoch: session.historyEpoch,
231961
- reset: opts.historyEpoch !== void 0 && opts.historyEpoch !== session.historyEpoch
231962
- }
231963
- }));
231964
- ws.send(JSON.stringify(this.backgroundTasksMessage(session)));
231965
- for (const patch of session.store.patches) {
231966
- const entryIndices = patch.flatMap((op) => {
231967
- const match2 = op.path.match(/^\/entries\/(\d+)$/);
231968
- return match2 ? [Number(match2[1])] : [];
231969
- });
231970
- if (entryIndices.length > 0 && entryIndices.every((index) => index <= after)) continue;
231971
- const msg = { JsonPatch: patch };
231972
- ws.send(JSON.stringify(msg));
231973
- }
231974
- ws.send(JSON.stringify({ Ready: true, historyEpoch: session.historyEpoch }));
231975
- const statusPatch = ConversationPatch.updateStatus(session.status);
231976
- ws.send(JSON.stringify({ JsonPatch: statusPatch }));
231977
- return () => {
232391
+ const unsubscribe = () => {
231978
232392
  session.subscribers.delete(ws);
231979
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
+ }
231980
232429
  }
231981
232430
  /**
231982
- * 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.
231983
232437
  */
231984
232438
  getMessages(sessionId) {
231985
232439
  const session = this.sessions.get(sessionId);
231986
- return session?.store.entries.filter(Boolean) ?? [];
232440
+ if (!session) return [];
232441
+ this.assertHot(session, "getMessages");
232442
+ return session.store.entries.filter(Boolean);
231987
232443
  }
231988
- /** 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
+ */
231989
232449
  getRawMessages(sessionId) {
231990
- 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;
231991
232454
  }
231992
232455
  getHistoryEpoch(sessionId) {
231993
232456
  return this.sessions.get(sessionId)?.historyEpoch;
@@ -232092,6 +232555,7 @@ var AgentSessionManager = class {
232092
232555
  });
232093
232556
  }
232094
232557
  }
232558
+ this.unloadHistory(session, "stop");
232095
232559
  return true;
232096
232560
  } catch (error48) {
232097
232561
  console.error(`[AgentSession] Failed to stop session:`, error48);
@@ -232162,6 +232626,7 @@ var AgentSessionManager = class {
232162
232626
  sessionId: session.id,
232163
232627
  status: "stopped"
232164
232628
  });
232629
+ this.unloadHistory(session, "hibernate");
232165
232630
  return true;
232166
232631
  } catch (error48) {
232167
232632
  console.error(`[AgentSession] Failed to hibernate session:`, error48);
@@ -232251,7 +232716,7 @@ var AgentSessionManager = class {
232251
232716
  if (session?.skipDb) return retained("retained_skip_db");
232252
232717
  if ((this.userMessagesInFlight.get(sessionId) ?? 0) > 0) return retained("retained_in_flight");
232253
232718
  if (this.retentionDeleting.has(sessionId)) return retained("retained_deleting");
232254
- 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");
232255
232720
  this.retentionDeleting.add(sessionId);
232256
232721
  try {
232257
232722
  const deleted = await this.storage.agentSessions.deleteIfEmpty(sessionId);
@@ -232300,19 +232765,18 @@ var AgentSessionManager = class {
232300
232765
  this.killProcess(proc);
232301
232766
  this.emitProcessAlive(session, false);
232302
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;
232303
232774
  if (!session.skipDb) {
232304
232775
  await this.storage.agentSessions.deleteEntries(sessionId);
232305
232776
  session.historyEpoch = await this.storage.agentSessions.incrementHistoryEpoch(sessionId);
232306
232777
  } else {
232307
232778
  session.historyEpoch += 1;
232308
232779
  }
232309
- session.store.patches = [];
232310
- session.store.entries = [];
232311
- session.store.indexProvider.reset();
232312
- session.store.toolTracker.clear();
232313
- session.store.currentAssistantIndex = null;
232314
- session.buffer = "";
232315
- session.dormant = false;
232316
232780
  session.turnOpenSince = null;
232317
232781
  this.touchSession(session);
232318
232782
  this.broadcastRaw(sessionId, {
@@ -232353,8 +232817,7 @@ var AgentSessionManager = class {
232353
232817
  const session = this.sessions.get(sessionId);
232354
232818
  if (!session) return "not_found";
232355
232819
  if (session.agentType === agentType) return "ok";
232356
- const hasHistory = session.store.entries.some(Boolean);
232357
- if (session.status === "running" && hasHistory) return "busy";
232820
+ if (session.status === "running" && this.hasHistory(session)) return "busy";
232358
232821
  console.log(`[AgentSession] Switching session ${sessionId} agent ${session.agentType} \u2192 ${agentType} (dormant, history preserved)`);
232359
232822
  const proc = session.process;
232360
232823
  session.process = null;
@@ -232386,6 +232849,7 @@ var AgentSessionManager = class {
232386
232849
  this.broadcastPatch(sessionId, ConversationPatch.updateStatus("stopped"));
232387
232850
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: "stopped" });
232388
232851
  }
232852
+ this.unloadHistory(session, "agent-switch");
232389
232853
  return "ok";
232390
232854
  }
232391
232855
  /**
@@ -232455,6 +232919,7 @@ var AgentSessionManager = class {
232455
232919
  this.broadcastPatch(sessionId, ConversationPatch.updateStatus("stopped"));
232456
232920
  this.eventBus?.emit({ type: "session:status", projectId: session.projectId, branch: session.branch, sessionId: session.id, status: "stopped" });
232457
232921
  }
232922
+ this.unloadHistory(session, "model-change");
232458
232923
  }
232459
232924
  return "ok";
232460
232925
  });
@@ -232466,7 +232931,7 @@ var AgentSessionManager = class {
232466
232931
  * chip and the agent chip lock at different moments in the same header row.
232467
232932
  */
232468
232933
  isModelChangeTooLate(session) {
232469
- return session.status === "running" && session.store.entries.some(Boolean);
232934
+ return session.status === "running" && this.hasHistory(session);
232470
232935
  }
232471
232936
  /**
232472
232937
  * Switch permission mode for a session (preserves conversation history)
@@ -232476,7 +232941,21 @@ var AgentSessionManager = class {
232476
232941
  if (!session) {
232477
232942
  return false;
232478
232943
  }
232479
- 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) {
232480
232959
  console.log(`[AgentSession] Switching session ${sessionId} from ${session.permissionMode} to ${newMode}`);
232481
232960
  this.resetCompletion(session);
232482
232961
  const proc = session.process;
@@ -232610,10 +233089,12 @@ var AgentSessionManager = class {
232610
233089
  return true;
232611
233090
  } finally {
232612
233091
  release();
233092
+ this.unloadIfSpawnAbandoned(session);
232613
233093
  }
232614
233094
  }
232615
233095
  async wakeDormantSessionInner(session, projectPath, userMessage, userId, origin, notificationDisposition) {
232616
233096
  const absoluteWorktreePath = await this.resolveSessionWorktreePath(session, projectPath);
233097
+ await this.hydrateForSpawn(session);
232617
233098
  await this.ensureResidentCapacity(
232618
233099
  { projectId: session.projectId, branch: session.branch },
232619
233100
  { excludeSessionId: session.id }
@@ -232691,9 +233172,10 @@ var AgentSessionManager = class {
232691
233172
  * Crash repair (restore path): if the previous process died mid-turn, the
232692
233173
  * history has no closing turn_end — append one with outcome
232693
233174
  * "server_restart" and no duration (the crash time is unknown; the UI
232694
- * shows "interrupted" instead of a fabricated number). Runs BEFORE
232695
- * rebuildStoreFromRows so the store is built from the repaired rows.
232696
- * 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).
232697
233179
  *
232698
233180
  * Also records a turn_snapshots row at the repair index (mirrors
232699
233181
  * endActiveTurn's hook), capturing the worktree exactly as the crash left
@@ -232703,32 +233185,39 @@ var AgentSessionManager = class {
232703
233185
  * this runs on server boot for every restored session and must never
232704
233186
  * throw into the restore path.
232705
233187
  */
232706
- async repairInterruptedTurn(dbSession, rows) {
233188
+ async repairInterruptedTurn(dbSession, meta3) {
232707
233189
  const sessionId = dbSession.id;
232708
- let landingType = null;
232709
- for (let i = rows.length - 1; i >= 0; i--) {
232710
- try {
232711
- const msg = JSON.parse(rows[i].data);
232712
- if (msg.type === "system") continue;
232713
- landingType = msg.type;
232714
- } catch {
232715
- landingType = "unparsable";
232716
- }
232717
- break;
232718
- }
232719
- if (landingType === null || landingType === "turn_end") return rows;
232720
- const maxIndex = rows.reduce((m2, r) => Math.max(m2, r.entry_index), -1);
232721
- const repairIndex = maxIndex + 1;
232722
- const entries = [];
232723
- for (const row of rows) {
232724
- try {
232725
- entries[row.entry_index] = JSON.parse(row.data);
232726
- } 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;
232727
233215
  }
233216
+ cursor = batch[batch.length - 1].entryIndex;
232728
233217
  }
232729
- const disposition = resolveNotificationDisposition(
232730
- findTurnOpeningUserEntry(entries, repairIndex)
232731
- );
233218
+ if (landingType === void 0) return meta3;
233219
+ const repairIndex = meta3.maxEntryIndex + 1;
233220
+ const disposition = resolveNotificationDisposition(opening);
232732
233221
  const repair = {
232733
233222
  type: "turn_end",
232734
233223
  timestamp: Date.now(),
@@ -232768,27 +233257,42 @@ var AgentSessionManager = class {
232768
233257
  } catch (error48) {
232769
233258
  console.warn(`[AgentSession] Turn snapshot lookup failed for ${sessionId}@${repairIndex}:`, error48);
232770
233259
  }
232771
- return [...rows, { entry_index: repairIndex, data }];
233260
+ return { entryCount: meta3.entryCount + 1, maxEntryIndex: repairIndex };
232772
233261
  }
232773
233262
  /**
232774
233263
  * Restore sessions from database on startup.
232775
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.
232776
233274
  */
232777
233275
  async restoreSessionsFromDb() {
232778
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
+ );
232779
233283
  let restoredCount = 0;
232780
233284
  let zeroEntryRows = 0;
232781
233285
  for (const dbSession of allSessions) {
232782
233286
  if (this.sessions.has(dbSession.id)) continue;
232783
- let entries = await this.storage.agentSessions.getEntries(dbSession.id);
232784
- if (entries.length === 0) {
233287
+ let meta3 = entryMeta.get(dbSession.id);
233288
+ if (!meta3 || meta3.entryCount === 0) {
232785
233289
  zeroEntryRows++;
232786
233290
  continue;
232787
233291
  }
232788
233292
  if (dbSession.status === "running") {
232789
- entries = await this.repairInterruptedTurn(dbSession, entries);
233293
+ meta3 = await this.repairInterruptedTurn(dbSession, meta3);
232790
233294
  }
232791
- const store = this.rebuildStoreFromRows(entries, dbSession.id);
233295
+ const store = coldStore(meta3.maxEntryIndex);
232792
233296
  const permissionMode = dbSession.permission_mode === "plan" ? "plan" : "edit";
232793
233297
  const restoredCheckout = dbSession.workspace_checkout_id ? await this.storage.workspaceRegistry.getCheckoutById(dbSession.workspace_checkout_id) : void 0;
232794
233298
  const activityReader = this.storage.agentSessions.getActivityById;
@@ -232807,6 +233311,11 @@ var AgentSessionManager = class {
232807
233311
  processStartsInFlight: 0,
232808
233312
  historyEpoch: dbSession.history_epoch ?? 0,
232809
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,
232810
233319
  subscribers: /* @__PURE__ */ new Set(),
232811
233320
  status: "stopped",
232812
233321
  buffer: "",
@@ -232820,6 +233329,8 @@ var AgentSessionManager = class {
232820
233329
  graceTimer: null,
232821
233330
  parkTimer: null,
232822
233331
  eventChain: Promise.resolve(),
233332
+ chainPending: 0,
233333
+ historyChain: Promise.resolve(),
232823
233334
  bgSpawnHintsThisTurn: 0,
232824
233335
  taskStartedThisTurn: 0,
232825
233336
  lastActiveAt: Date.now(),
@@ -232951,7 +233462,7 @@ var AgentSessionManager = class {
232951
233462
  existingRuntime.branchedFromEntryIndex = repairedEntryIndex;
232952
233463
  }
232953
233464
  }
232954
- return { ok: true, sessionId: newId };
233465
+ return { ok: true, sessionId: newId, messages: denseMessagesFromRows(entryRows) };
232955
233466
  }
232956
233467
  }
232957
233468
  let inheritedCheckoutId = source?.workspaceCheckoutId ?? sourceRow?.workspace_checkout_id ?? void 0;
@@ -233001,7 +233512,11 @@ var AgentSessionManager = class {
233001
233512
  }
233002
233513
  await this.storage.agentSessions.updateTitle(newId, `Branch - ${baseTitle || "Conversation"}`);
233003
233514
  this.markTitleResolved(newId);
233004
- 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);
233005
233520
  const branched = {
233006
233521
  id: newId,
233007
233522
  projectId,
@@ -233013,6 +233528,10 @@ var AgentSessionManager = class {
233013
233528
  processStartsInFlight: 0,
233014
233529
  historyEpoch: 0,
233015
233530
  store,
233531
+ hot: false,
233532
+ historyMeta: branchedMeta,
233533
+ hydrating: null,
233534
+ clearGeneration: 0,
233016
233535
  subscribers: /* @__PURE__ */ new Set(),
233017
233536
  status: "stopped",
233018
233537
  buffer: "",
@@ -233024,6 +233543,8 @@ var AgentSessionManager = class {
233024
233543
  graceTimer: null,
233025
233544
  parkTimer: null,
233026
233545
  eventChain: Promise.resolve(),
233546
+ chainPending: 0,
233547
+ historyChain: Promise.resolve(),
233027
233548
  bgSpawnHintsThisTurn: 0,
233028
233549
  taskStartedThisTurn: 0,
233029
233550
  lastActiveAt: Date.now(),
@@ -233037,7 +233558,7 @@ var AgentSessionManager = class {
233037
233558
  this.sessions.set(newId, branched);
233038
233559
  await this.emitDerivedBranchActivity(projectId, branch);
233039
233560
  console.log(`[AgentSession] branchSession: ${sourceSessionId} \u2192 ${newId} (entries=${entryRows.length}, agentType=${agentType})`);
233040
- return { ok: true, sessionId: newId };
233561
+ return { ok: true, sessionId: newId, messages: denseMessagesFromRows(entryRows) };
233041
233562
  }
233042
233563
  /**
233043
233564
  * Kill all active session processes and clear state for graceful shutdown
@@ -235804,7 +236325,7 @@ var ChatSessionManager = class {
235804
236325
  agentSession = projectSessions.find((s3) => s3.status === "running") ?? projectSessions[0] ?? null;
235805
236326
  }
235806
236327
  if (agentSession) {
235807
- const allMessages = agentSessionManager.getMessages(agentSession.id);
236328
+ const allMessages = await agentSessionManager.loadMessages(agentSession.id);
235808
236329
  const recent = allMessages.slice(-tailMessages);
235809
236330
  localResult = {
235810
236331
  sessionId: agentSession.id,
@@ -238280,7 +238801,7 @@ async function createProjectChatTools(options) {
238280
238801
  agentType: nullablePreview(local.agent_type, ENUM_CHAR_LIMIT),
238281
238802
  model: nullablePreview(local.model, MODEL_CHAR_LIMIT),
238282
238803
  processAlive: agentSessionManager.getSessionProcessAlive(local.id),
238283
- transcript: transcriptPreview(agentSessionManager.getMessages(local.id))
238804
+ transcript: transcriptPreview(await agentSessionManager.loadMessages(local.id))
238284
238805
  };
238285
238806
  await touch("agent_session", local.id);
238286
238807
  return detail2;
@@ -240810,7 +241331,7 @@ var WorkflowEngine = class {
240810
241331
  if (sourceSession?.status === "running") {
240811
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");
240812
241333
  }
240813
- const entries = this.agentOps.getRawMessages(opts.sourceSessionId);
241334
+ const entries = await this.agentOps.getRawMessages(opts.sourceSessionId);
240814
241335
  const turnEndIndex = opts.sourceTurnEndIndex ?? extractLatestTurnEndIndex(entries);
240815
241336
  if (turnEndIndex === null) {
240816
241337
  throw new WorkflowError("no-completed-turn", "source session \u8FD8\u6CA1\u6709\u5DF2\u5B8C\u6210\u7684 turn \u53EF\u4F9B review");
@@ -240994,7 +241515,7 @@ var WorkflowEngine = class {
240994
241515
  const pending = this.pendingActivations.get(runId) ?? parsePreparedContext(run2.prepared_context);
240995
241516
  let outcome;
240996
241517
  try {
240997
- const entries = pending ? null : this.agentOps.getRawMessages(run2.source_session_id);
241518
+ const entries = pending ? null : await this.agentOps.getRawMessages(run2.source_session_id);
240998
241519
  const prompt = buildReviewerPrompt({
240999
241520
  taskContext: pending ? pending.taskContext : extractTaskContextBefore(entries, run2.source_turn_end_index),
241000
241521
  originalIntent: pending ? pending.originalIntent : extractFirstUserMessage(entries),
@@ -241051,7 +241572,7 @@ var WorkflowEngine = class {
241051
241572
  if (!p2 || p2.role !== "reviewer") return;
241052
241573
  const run2 = await this.storage.workflowRuns.getById(p2.runId);
241053
241574
  if (!run2 || run2.status !== "waiting_reviewer") return;
241054
- const entries = this.agentOps.getRawMessages(event.sessionId);
241575
+ const entries = await this.agentOps.getRawMessages(event.sessionId);
241055
241576
  const boundary = event.turnEndEntryIndex ?? extractLatestTurnEndIndex(entries) ?? entries.length;
241056
241577
  const feedback = extractLastAssistantInTurn(entries, boundary) ?? "(reviewer \u6CA1\u6709\u8F93\u51FA\u53EF\u7528\u7684\u53CD\u9988\u6587\u672C)";
241057
241578
  let driftNote = null;
@@ -245283,7 +245804,8 @@ function collectMemoryStats(deps) {
245283
245804
  uptime_s: Math.round(process.uptime())
245284
245805
  },
245285
245806
  patch_cache: deps.remotePatchCache.stats(),
245286
- process_manager: deps.processManager.logBufferStats()
245807
+ process_manager: deps.processManager.logBufferStats(),
245808
+ ...deps.sessionHydration ? { agent_sessions: deps.sessionHydration.hydrationStats() } : {}
245287
245809
  };
245288
245810
  }
245289
245811
  var MemoryStatsReporter = class {
@@ -245666,7 +246188,7 @@ var sharedServices = async (fastify2, opts) => {
245666
246188
  sendUserMessage: (...args) => agentSessionManager.sendUserMessage(...args),
245667
246189
  setFinalSessionTitle: (sessionId, title) => agentSessionManager.setFinalSessionTitle(sessionId, title),
245668
246190
  switchMode: (sessionId, projectPath, mode) => agentSessionManager.switchMode(sessionId, projectPath, mode),
245669
- getRawMessages: (sessionId) => agentSessionManager.getRawMessages(sessionId),
246191
+ getRawMessages: (sessionId) => agentSessionManager.loadRawMessages(sessionId),
245670
246192
  broadcastRawToSession: (sessionId, payload) => agentSessionManager.broadcastRawToSession(sessionId, payload)
245671
246193
  };
245672
246194
  const workflowEngine = new WorkflowEngine(opts.storage, reviewAgentOps);
@@ -245724,7 +246246,8 @@ var sharedServices = async (fastify2, opts) => {
245724
246246
  remoteNotificationSync.enqueue(() => remoteNotificationSync.syncAll({ includeExpired: true }));
245725
246247
  const memoryStatsReporter = new MemoryStatsReporter({
245726
246248
  remotePatchCache,
245727
- processManager
246249
+ processManager,
246250
+ sessionHydration: agentSessionManager
245728
246251
  });
245729
246252
  memoryStatsReporter.start();
245730
246253
  fastify2.addHook("onClose", async () => {
@@ -246316,7 +246839,8 @@ var routes4 = async (fastify2) => {
246316
246839
  if (!isOperatorRequest(request)) return reply.code(404).send({ error: "Not found" });
246317
246840
  return reply.send(collectMemoryStats({
246318
246841
  remotePatchCache: fastify2.remotePatchCache,
246319
- processManager: fastify2.processManager
246842
+ processManager: fastify2.processManager,
246843
+ sessionHydration: fastify2.agentSessionManager
246320
246844
  }));
246321
246845
  });
246322
246846
  fastify2.get("/api/admin/worker-version-stats", async (request, reply) => {
@@ -249450,48 +249974,9 @@ import { createHash as createHash7, randomUUID as randomUUID16 } from "crypto";
249450
249974
  // src/protocol/model-suggestions.ts
249451
249975
  var MODEL_SUGGESTIONS = {
249452
249976
  "claude-code": ["opus", "sonnet", "haiku", "fable"],
249453
- 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"]
249454
249978
  };
249455
249979
 
249456
- // src/session-history-window.ts
249457
- function historyHead(entries, historyEpoch) {
249458
- let latestEntryIndex = null;
249459
- let lastTurnEndEntryIndex = null;
249460
- for (let index = entries.length - 1; index >= 0; index--) {
249461
- const message = entries[index];
249462
- if (!message) continue;
249463
- latestEntryIndex ??= index;
249464
- if (lastTurnEndEntryIndex === null && message.type === "turn_end") {
249465
- lastTurnEndEntryIndex = index;
249466
- }
249467
- if (latestEntryIndex !== null && lastTurnEndEntryIndex !== null) break;
249468
- }
249469
- return { historyEpoch, latestEntryIndex, lastTurnEndEntryIndex };
249470
- }
249471
- function buildHistoryWindow(entries, historyEpoch, opts = {}) {
249472
- const head = historyHead(entries, historyEpoch);
249473
- const endExclusive = Math.max(0, Math.min(opts.before ?? entries.length, entries.length));
249474
- const requestedTurns = Math.max(1, Math.min(opts.turns ?? 5, 20));
249475
- const boundaries = [];
249476
- for (let index = endExclusive - 1; index >= 0; index--) {
249477
- if (entries[index]?.type === "turn_end") boundaries.push(index);
249478
- if (boundaries.length >= requestedTurns + 2) break;
249479
- }
249480
- const startIndex = boundaries.length > requestedTurns + 1 ? boundaries[requestedTurns + 1] + 1 : 0;
249481
- const dense = [];
249482
- for (let index = startIndex; index < endExclusive; index++) {
249483
- const message = entries[index];
249484
- if (message) dense.push({ entryIndex: index, message });
249485
- }
249486
- const hasMore = startIndex > 0;
249487
- return {
249488
- ...head,
249489
- entries: dense,
249490
- previousCursor: hasMore ? startIndex : null,
249491
- hasMore
249492
- };
249493
- }
249494
-
249495
249980
  // src/routes/agent-session-routes.ts
249496
249981
  async function resolveProjectPath(projectId, storage2) {
249497
249982
  if (projectId.startsWith("path:")) {
@@ -249670,7 +250155,7 @@ var routes11 = async (fastify2) => {
249670
250155
  }
249671
250156
  const newSessionId = result.sessionId;
249672
250157
  const session = fastify2.agentSessionManager.getSession(newSessionId);
249673
- const messages = fastify2.agentSessionManager.getMessages(newSessionId);
250158
+ const messages = result.messages;
249674
250159
  const dbRow = await fastify2.storage.agentSessions.getById(newSessionId);
249675
250160
  return {
249676
250161
  ok: true,
@@ -249747,9 +250232,10 @@ var routes11 = async (fastify2) => {
249747
250232
  return reply.code(200).send({ session: null, messages: [] });
249748
250233
  }
249749
250234
  const session = fastify2.agentSessionManager.getSession(sessionId);
249750
- const messages = fastify2.agentSessionManager.getMessages(sessionId);
249751
250235
  const epoch = fastify2.agentSessionManager.getHistoryEpoch(sessionId) ?? 0;
249752
- 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;
249753
250239
  const projection = await fastify2.storage.agentSessions.getActivityById(sessionId, "session-detail");
249754
250240
  if (session?.workspaceCheckoutId && !projection) {
249755
250241
  return reply.code(409).send({
@@ -249915,7 +250401,7 @@ var routes11 = async (fastify2) => {
249915
250401
  worktreePath: recovered?.checkoutPath ?? null,
249916
250402
  processAlive: recovered ? fastify2.agentSessionManager.getSessionProcessAlive(recoveredSessionId) : false
249917
250403
  },
249918
- messages: fastify2.agentSessionManager.getMessages(recoveredSessionId)
250404
+ messages: await fastify2.agentSessionManager.loadMessages(recoveredSessionId)
249919
250405
  });
249920
250406
  }
249921
250407
  return reply.code(200).send({
@@ -249932,7 +250418,7 @@ var routes11 = async (fastify2) => {
249932
250418
  processAlive: fastify2.agentSessionManager.getSessionProcessAlive(sessionId),
249933
250419
  ...sendBackFields(active)
249934
250420
  },
249935
- messages: fastify2.agentSessionManager.getMessages(sessionId)
250421
+ messages: await fastify2.agentSessionManager.loadMessages(sessionId)
249936
250422
  });
249937
250423
  }
249938
250424
  }
@@ -250283,9 +250769,10 @@ var routes11 = async (fastify2) => {
250283
250769
  return reply.code(200).send({ session: null, messages: [] });
250284
250770
  }
250285
250771
  const session = fastify2.agentSessionManager.getSession(sessionId);
250286
- const messages = fastify2.agentSessionManager.getMessages(sessionId);
250287
250772
  const epoch = fastify2.agentSessionManager.getHistoryEpoch(sessionId) ?? 0;
250288
- 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;
250289
250776
  const effectiveStatus = session?.status || "stopped";
250290
250777
  return reply.code(200).send({
250291
250778
  session: {
@@ -250469,7 +250956,7 @@ var routes11 = async (fastify2) => {
250469
250956
  error: "The workspace checkout binding for this session is unavailable"
250470
250957
  });
250471
250958
  }
250472
- const messages = fastify2.agentSessionManager.getMessages(req.params.sessionId);
250959
+ const messages = await fastify2.agentSessionManager.loadMessages(req.params.sessionId);
250473
250960
  return reply.code(200).send({
250474
250961
  session: {
250475
250962
  id: session.id,
@@ -250541,9 +251028,10 @@ var routes11 = async (fastify2) => {
250541
251028
  }
250542
251029
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250543
251030
  if (!session) return reply.code(404).send({ error: "Session not found" });
250544
- 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" });
250545
251033
  return reply.code(200).send({
250546
- ...buildHistoryWindow(fastify2.agentSessionManager.getRawMessages(req.params.sessionId), epoch, { before, turns }),
251034
+ ...window2,
250547
251035
  status: session.status,
250548
251036
  session: {
250549
251037
  id: session.id,
@@ -250609,8 +251097,8 @@ var routes11 = async (fastify2) => {
250609
251097
  }
250610
251098
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250611
251099
  if (!session) return reply.code(404).send({ error: "Session not found" });
250612
- const epoch = fastify2.agentSessionManager.getHistoryEpoch(req.params.sessionId) ?? 0;
250613
- 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" });
250614
251102
  return reply.code(200).send({
250615
251103
  historyEpoch: head.historyEpoch,
250616
251104
  latestEntryIndex: head.latestEntryIndex,
@@ -250625,7 +251113,7 @@ var routes11 = async (fastify2) => {
250625
251113
  const session = fastify2.agentSessionManager.getSession(req.params.sessionId);
250626
251114
  if (!session) return reply.code(404).send({ error: "Session not found" });
250627
251115
  return reply.code(200).send({
250628
- messages: projectMessagesForBrief(fastify2.agentSessionManager.getMessages(req.params.sessionId))
251116
+ messages: projectMessagesForBrief(await fastify2.agentSessionManager.loadMessages(req.params.sessionId))
250629
251117
  });
250630
251118
  }
250631
251119
  );
@@ -253188,7 +253676,7 @@ async function routes21(fastify2) {
253188
253676
  if (!historyResult.ok) return void 0;
253189
253677
  messages = historyResult.data.messages ?? [];
253190
253678
  } else {
253191
- messages = fastify2.agentSessionManager.getMessages(sourceSessionId);
253679
+ messages = await fastify2.agentSessionManager.loadMessages(sourceSessionId);
253192
253680
  }
253193
253681
  return await generateIntentBrief(fastify2.storage, resolveUserId(userId), messages) ?? void 0;
253194
253682
  } catch (err) {
@@ -254929,14 +255417,12 @@ var routes24 = async (fastify2) => {
254929
255417
  });
254930
255418
  return;
254931
255419
  }
254932
- const unsubscribe = fastify2.agentSessionManager.subscribe(sessionId, socket, { afterEntryIndex, historyEpoch });
254933
- if (!unsubscribe) {
254934
- console.log(`[AgentWS] Session ${sessionId} not found`);
255420
+ let unsubscribe = null;
255421
+ socket.on("close", () => {
255422
+ console.log(`[AgentWS] Client disconnected from session ${sessionId}`);
254935
255423
  stopHeartbeat();
254936
- socket.send(JSON.stringify({ error: "Session not found" }));
254937
- socket.close();
254938
- return;
254939
- }
255424
+ unsubscribe?.();
255425
+ });
254940
255426
  socket.on("message", (data) => {
254941
255427
  try {
254942
255428
  const message = JSON.parse(data.toString());
@@ -254949,11 +255435,21 @@ var routes24 = async (fastify2) => {
254949
255435
  console.error("[AgentWS] Failed to parse message:", error48);
254950
255436
  }
254951
255437
  });
254952
- socket.on("close", () => {
254953
- 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`);
254954
255445
  stopHeartbeat();
254955
- unsubscribe?.();
254956
- });
255446
+ try {
255447
+ socket.send(JSON.stringify({ error: "Session not found" }));
255448
+ socket.close();
255449
+ } catch {
255450
+ }
255451
+ return;
255452
+ }
254957
255453
  }
254958
255454
  );
254959
255455
  fastify2.get(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedeckx/linux-x64",
3
- "version": "0.3.35",
3
+ "version": "0.3.36",
4
4
  "description": "Vibedeckx platform binaries for Linux x64",
5
5
  "os": [
6
6
  "linux"