@bivy/bivy 0.17.2-staging.3 → 0.17.2-staging.5

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.
@@ -134,3 +134,16 @@ export function mergeProviderCatalog(base, catalog) {
134
134
  }
135
135
  return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
136
136
  }
137
+ /**
138
+ * Reply to a models.list query. A catalog read failure must never answer with
139
+ * silence: the client (notably a Cloud launch validating its saved model) could
140
+ * only time out without a cause. Name the real reason on the session instead.
141
+ */
142
+ export async function catalogReplyEvent(load, sessionId) {
143
+ try {
144
+ return await load();
145
+ }
146
+ catch (error) {
147
+ return { type: "session.error", sessionId, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` };
148
+ }
149
+ }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import { createNodeUpdateChecker, updateRegistryUrl } from "./node-update.js";
5
5
  import { createRemoteSessionAdmission, RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
6
+ import { requiresExistingSession, resolveTargetSession } from "./session/target-session.js";
6
7
  import path from "node:path";
7
8
  import os from "node:os";
8
9
  import { spawn, spawnSync } from "node:child_process";
@@ -33,7 +34,7 @@ import { AppService } from "./apps/service.js";
33
34
  import { createAppCommands } from "./controllers/app-commands.js";
34
35
  import { bindClientCommandRoutes } from "./http/client-command-routes.js";
35
36
  import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
36
- import { aggregateModelCatalog, mergeProviderCatalog } from "./runtime/model-catalog.js";
37
+ import { aggregateModelCatalog, catalogReplyEvent, mergeProviderCatalog } from "./runtime/model-catalog.js";
37
38
  import { RuntimeHost, enforcementLevelFor, remoteRuntimeEnabled } from "./runtime/host.js";
38
39
  import { RemoteRuntime, RemoteRuntimeSession } from "./runtime/remote.js";
39
40
  import { InMemorySessionLocationRegistry } from "./runtime/session-location.js";
@@ -1743,8 +1744,10 @@ function referencedAttachmentHashes() {
1743
1744
  const ids = new Set(metadata.listSessions().map((session) => session.id));
1744
1745
  for (const record of new Set(openSessions.values()))
1745
1746
  ids.add(record.id);
1747
+ // `scan`, not `entries`: this visits every session ever created, and must not pin
1748
+ // each one's full log in the event-log cache.
1746
1749
  for (const id of ids) {
1747
- for (const entry of eventLog.entries(id)) {
1750
+ for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image"])) {
1748
1751
  if (entry.bivyKind === "attachment")
1749
1752
  for (const ref of entry.refs)
1750
1753
  hashes.add(ref.hash);
@@ -2453,18 +2456,8 @@ const RELAY_COMMANDS = {
2453
2456
  // this a models.list answered for one agent (e.g. Codex's models) could
2454
2457
  // linger on the composer/picker after the user switched to another agent
2455
2458
  // (e.g. Claude) — the "Claude shows Codex models" bug.
2456
- let event;
2457
- try {
2458
- event = await modelsListEventFor(record);
2459
- }
2460
- catch (error) {
2461
- // A catalog read failure must never answer with silence: the generic
2462
- // relay catch would swallow the rejection and the client (notably a
2463
- // Cloud launch waiting to validate its saved model) could only guess
2464
- // via timeout. Name the real reason on the session instead.
2465
- relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId ?? record.id, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` });
2466
- return;
2467
- }
2459
+ // A catalog read failure answers with the real error, never silence.
2460
+ const event = await catalogReplyEvent(() => modelsListEventFor(record), requestedSessionId ?? record.id);
2468
2461
  relay?.sendEvent(event);
2469
2462
  },
2470
2463
  "models.prefetch"(msg) {
@@ -4915,31 +4908,13 @@ function assertSessionAccounts(record, labels) {
4915
4908
  }
4916
4909
  }
4917
4910
  async function continueCorrelatedSession(item, prompt, report, opts) {
4918
- if (item.targetKind !== "existing_session" || !item.targetSessionId)
4919
- return false;
4920
- let record = openSessions.get(item.targetSessionId);
4921
- if (!record && opts?.resumeOnMissing) {
4922
- // Same-node resume: reopen a closed session from its durable metadata +
4923
- // transcript — exactly how a prompt to an old session from the app is
4924
- // handled (resolveOrResumeSession). Best-effort: an unresolvable session
4925
- // falls through to the snapshot restore / fail handling below.
4926
- record = await resolveOrResumeSession(item.targetSessionId).catch(() => undefined);
4927
- }
4928
- if (!record) {
4929
- const restored = await restoreSessionFromSnapshot(item.targetSessionId);
4930
- if (restored)
4931
- record = await resolveOrResumeSession(item.targetSessionId).catch(() => undefined);
4932
- }
4933
- if (!record) {
4934
- // Strict mode (a scheduled message to a specific session): the session must
4935
- // be resumed in place, so a genuinely-unavailable one is surfaced as a
4936
- // failed run rather than silently starting a new session. Everything else
4937
- // keeps the established best-effort fall-through to a fresh pickup.
4938
- if (opts?.resumeOnMissing) {
4939
- throw new Error(`This Run could not continue session ${item.targetSessionId}: the session is not available on this Machine`);
4940
- }
4911
+ const record = await resolveTargetSession(item, {
4912
+ open: (id) => openSessions.get(id),
4913
+ resume: (id) => resolveOrResumeSession(id),
4914
+ restoreSnapshot: async (id) => Boolean(await restoreSessionFromSnapshot(id)),
4915
+ }, opts?.resumeOnMissing === true);
4916
+ if (!record)
4941
4917
  return false;
4942
- }
4943
4918
  assertSessionAccounts(record, opts?.credentialLabels);
4944
4919
  const branch = record.worktree?.branch;
4945
4920
  if (opts?.resumeOnMissing) {
@@ -5189,7 +5164,7 @@ async function executeWorkItem(item, report, signal) {
5189
5164
  // Scheduled runs targeting an existing session are STRICT: the message must
5190
5165
  // land in that session (resumed from disk if needed), never silently in a new
5191
5166
  // one — so a session that can't be resumed fails the run instead.
5192
- if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule" || item.targetKind === "existing_session", isMessage, signal, credentialLabels }))
5167
+ if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: requiresExistingSession(item), isMessage, signal, credentialLabels }))
5193
5168
  return;
5194
5169
  const requestedSandbox = normalizeSandboxTier(item.sandbox);
5195
5170
  // Prepare an explicit repository before resolving its policy. Otherwise a
@@ -397,12 +397,12 @@ export function baseReplay(entries) {
397
397
  }
398
398
  return base;
399
399
  }
400
- function parseLogDetailed(body) {
400
+ function parseLogDetailed(body, keep = () => true) {
401
401
  const records = [];
402
402
  let malformedLines = 0;
403
403
  for (const line of body.split("\n")) {
404
404
  const trimmed = line.trim();
405
- if (!trimmed)
405
+ if (!trimmed || !keep(trimmed))
406
406
  continue;
407
407
  try {
408
408
  const value = JSON.parse(trimmed);
@@ -429,6 +429,10 @@ export function parseLog(body) {
429
429
  * `throttleMs` per session (a burst of same-id deltas within a window collapses to
430
430
  * one appended line). Durability at boundaries is explicit `flush()` (turn-end,
431
431
  * close, delete, shutdown), mirroring `SidecarStore`.
432
+ *
433
+ * The in-memory copy of flushed records is an LRU capped at `maxCachedBytes`
434
+ * (measured as log-file size): an evicted session simply re-reads its file on next
435
+ * use, so memory tracks the sessions in use, not the node's whole history.
432
436
  */
433
437
  export class EventLog {
434
438
  dir;
@@ -436,7 +440,10 @@ export class EventLog {
436
440
  redact;
437
441
  throttleMs;
438
442
  onIssue;
443
+ maxCachedBytes;
439
444
  disk = new Map();
445
+ diskBytes = new Map();
446
+ cachedBytes = 0;
440
447
  pending = new Map();
441
448
  timers = new Map();
442
449
  lastFlush = new Map();
@@ -446,12 +453,13 @@ export class EventLog {
446
453
  // on first use of a session after a restart.
447
454
  baseKeys = new Map();
448
455
  lastIssue;
449
- constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`)) {
456
+ constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`), maxCachedBytes = 64 * 1024 * 1024) {
450
457
  this.dir = dir;
451
458
  this.pathFor = pathFor;
452
459
  this.redact = redact;
453
460
  this.throttleMs = throttleMs;
454
461
  this.onIssue = onIssue;
462
+ this.maxCachedBytes = maxCachedBytes;
455
463
  }
456
464
  report(id, operation, error) {
457
465
  const issue = {
@@ -493,23 +501,64 @@ export class EventLog {
493
501
  }
494
502
  return { files, bytes };
495
503
  }
496
- load(id) {
497
- const cached = this.disk.get(id);
498
- if (cached)
499
- return cached;
500
- let data = [];
504
+ /** Read + parse a session's log file (lines passing `keep`), reporting problems. */
505
+ readLog(id, keep) {
501
506
  try {
502
- const parsed = parseLogDetailed(fs.readFileSync(this.pathFor(id), "utf8"));
503
- data = parsed.records;
507
+ const body = fs.readFileSync(this.pathFor(id), "utf8");
508
+ const parsed = parseLogDetailed(body, keep);
504
509
  if (parsed.malformedLines > 0)
505
510
  this.report(id, "parse", new Error(`${parsed.malformedLines} malformed record(s); valid history was recovered`));
511
+ return { records: parsed.records, bytes: body.length };
506
512
  }
507
513
  catch (error) {
508
514
  if (error?.code !== "ENOENT")
509
515
  this.report(id, "read", error);
516
+ return { records: [], bytes: 0 };
517
+ }
518
+ }
519
+ load(id) {
520
+ const cached = this.disk.get(id);
521
+ if (cached) {
522
+ // Re-insert to mark most-recently-used (Map iterates in insertion order).
523
+ this.disk.delete(id);
524
+ this.disk.set(id, cached);
525
+ return cached;
510
526
  }
511
- this.disk.set(id, data);
512
- return data;
527
+ const { records, bytes } = this.readLog(id);
528
+ this.cache(id, records, bytes);
529
+ return records;
530
+ }
531
+ /** Store a session's flushed records, then evict least-recently-used others past the cap. */
532
+ cache(id, records, bytes) {
533
+ this.uncache(id);
534
+ this.disk.set(id, records);
535
+ this.diskBytes.set(id, bytes);
536
+ this.cachedBytes += bytes;
537
+ for (const oldest of this.disk.keys()) {
538
+ if (this.cachedBytes <= this.maxCachedBytes || oldest === id)
539
+ break;
540
+ this.uncache(oldest);
541
+ }
542
+ }
543
+ uncache(id) {
544
+ this.cachedBytes -= this.diskBytes.get(id) ?? 0;
545
+ this.diskBytes.delete(id);
546
+ this.disk.delete(id);
547
+ }
548
+ /**
549
+ * The session's records of the given kinds, read WITHOUT caching the log. For
550
+ * whole-store sweeps (attachment GC) that visit every session, where `load` would
551
+ * pin every log in memory. Uses the cached copy when there is one, and always
552
+ * includes pending records so unflushed writes are never missed. Off the cache only
553
+ * candidate lines are parsed: those naming a wanted kind, plus torn lines (not
554
+ * ending in `}`) so a truncated write is still reported and callers can fail closed.
555
+ */
556
+ scan(id, kinds) {
557
+ const wanted = new Set(kinds);
558
+ const markers = kinds.map((kind) => `"bivyKind":${JSON.stringify(kind)}`);
559
+ const flushed = this.disk.get(id) ?? this.readLog(id, (line) => !line.endsWith("}") || markers.some((m) => line.includes(m))).records;
560
+ const pending = this.pending.get(id)?.values() ?? [];
561
+ return [...flushed, ...pending].filter((record) => wanted.has(record.bivyKind));
513
562
  }
514
563
  /** Allocate a unique synthetic coalescing key (for id-less records). */
515
564
  syntheticKey(id) {
@@ -681,9 +730,16 @@ export class EventLog {
681
730
  const lines = [...batch.values()];
682
731
  try {
683
732
  fs.mkdirSync(this.dir, { recursive: true });
684
- fs.appendFileSync(this.pathFor(id), this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n"));
685
- const disk = this.load(id);
686
- disk.push(...lines);
733
+ const body = this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n");
734
+ fs.appendFileSync(this.pathFor(id), body);
735
+ // Extend the cached copy only if it is still cached: an evicted session re-reads
736
+ // the file (which now holds these lines) on next use, so loading here would
737
+ // double-count them.
738
+ const cached = this.disk.get(id);
739
+ if (cached) {
740
+ cached.push(...lines);
741
+ this.cache(id, cached, (this.diskBytes.get(id) ?? 0) + body.length);
742
+ }
687
743
  batch.clear();
688
744
  this.lastFlush.set(id, Date.now());
689
745
  }
@@ -708,10 +764,10 @@ export class EventLog {
708
764
  }
709
765
  this.pending.delete(id);
710
766
  this.baseKeys.delete(id);
711
- this.disk.set(id, copy);
767
+ const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
768
+ this.cache(id, copy, body.length);
712
769
  try {
713
770
  fs.mkdirSync(this.dir, { recursive: true });
714
- const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
715
771
  fs.writeFileSync(this.pathFor(id), this.redact(body));
716
772
  this.lastFlush.set(id, Date.now());
717
773
  }
@@ -726,7 +782,7 @@ export class EventLog {
726
782
  clearTimeout(timer);
727
783
  this.timers.delete(id);
728
784
  }
729
- this.disk.delete(id);
785
+ this.uncache(id);
730
786
  this.pending.delete(id);
731
787
  this.lastFlush.delete(id);
732
788
  this.counters.delete(id);
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Resolving the Session a durable Run targets. A Run aimed at an existing
5
+ // Session (or any scheduled Run) is strict: it continues that Session, resumed
6
+ // from disk or a snapshot if needed, or fails visibly. It never silently cold-
7
+ // starts a new Session without the context the user pointed it at.
8
+ /** Scheduled Runs and Runs aimed at an existing Session must land in that Session. */
9
+ export function requiresExistingSession(item) {
10
+ return item.source === "schedule" || item.targetKind === "existing_session";
11
+ }
12
+ /**
13
+ * The targeted Session, or null when the item targets none or (non-strict) it is
14
+ * unavailable, in which case the caller falls through to a fresh pickup. Throws
15
+ * when a strict target cannot be continued.
16
+ */
17
+ export async function resolveTargetSession(item, resolvers, strict) {
18
+ if (item.targetKind !== "existing_session" || !item.targetSessionId)
19
+ return null;
20
+ const id = item.targetSessionId;
21
+ let record = resolvers.open(id);
22
+ if (!record && strict)
23
+ record = await resolvers.resume(id).catch(() => undefined);
24
+ if (!record && await resolvers.restoreSnapshot(id))
25
+ record = await resolvers.resume(id).catch(() => undefined);
26
+ if (record)
27
+ return record;
28
+ if (strict)
29
+ throw new Error(`This Run could not continue session ${id}: the session is not available on this Machine`);
30
+ return null;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.2-staging.3",
3
+ "version": "0.17.2-staging.5",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",