@bivy/bivy 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,17 +30,16 @@ const HASH_RE = /^[0-9a-f]{64}$/;
30
30
  export function isValidAttachmentHash(value) {
31
31
  return typeof value === "string" && HASH_RE.test(value);
32
32
  }
33
- /**
34
- * A global, content-addressed attachment store rooted at a single directory.
35
- * All methods are best-effort and synchronous, matching the surrounding
36
- * server code (EventLog, SidecarStore) — attachment persistence must never sink
37
- * a turn, so a write failure surfaces as a thrown error the caller degrades to a
38
- * text note rather than a crash.
39
- */
40
33
  export class AttachmentStore {
41
34
  dir;
42
- constructor(dir) {
35
+ maxFileBytes;
36
+ maxStoreBytes;
37
+ retentionMs;
38
+ constructor(dir, options = {}) {
43
39
  this.dir = dir;
40
+ this.maxFileBytes = options.maxFileBytes ?? 25 * 1024 * 1024;
41
+ this.maxStoreBytes = options.maxStoreBytes ?? 2 * 1024 * 1024 * 1024;
42
+ this.retentionMs = options.retentionMs ?? 30 * 24 * 60 * 60 * 1000;
44
43
  }
45
44
  /** `<dir>/ab/cd` for a hash beginning `abcd…`. */
46
45
  shardDir(hash) {
@@ -59,16 +58,23 @@ export class AttachmentStore {
59
58
  * sidecar is written only the first time so the earliest name/mime wins.
60
59
  */
61
60
  put(bytes, opts) {
61
+ if (bytes.length > this.maxFileBytes)
62
+ throw new Error(`Attachment exceeds the ${this.maxFileBytes}-byte node limit`);
62
63
  const hash = crypto.createHash("sha256").update(bytes).digest("hex");
63
64
  const ref = { hash, name: opts.name, mimeType: opts.mimeType, size: bytes.length, kind: opts.kind };
64
65
  fs.mkdirSync(this.shardDir(hash), { recursive: true });
65
66
  const blob = this.blobPath(hash);
66
- if (!fs.existsSync(blob))
67
- fs.writeFileSync(blob, bytes);
67
+ if (!fs.existsSync(blob)) {
68
+ const usage = this.stats();
69
+ if (usage.bytes + bytes.length > this.maxStoreBytes) {
70
+ throw new Error(`Attachment store would exceed its ${this.maxStoreBytes}-byte capacity`);
71
+ }
72
+ this.atomicWrite(blob, bytes);
73
+ }
68
74
  if (!fs.existsSync(this.metaPath(hash))) {
69
75
  const meta = { ...ref, createdAt: Date.now() };
70
76
  try {
71
- fs.writeFileSync(this.metaPath(hash), JSON.stringify(meta));
77
+ this.atomicWrite(this.metaPath(hash), Buffer.from(JSON.stringify(meta)));
72
78
  }
73
79
  catch {
74
80
  // A missing sidecar only costs us the remembered name/mime — the blob is
@@ -77,6 +83,88 @@ export class AttachmentStore {
77
83
  }
78
84
  return ref;
79
85
  }
86
+ atomicWrite(destination, bytes) {
87
+ const tmp = `${destination}.tmp-${process.pid}-${crypto.randomUUID()}`;
88
+ try {
89
+ fs.writeFileSync(tmp, bytes, { flag: "wx" });
90
+ fs.renameSync(tmp, destination);
91
+ }
92
+ finally {
93
+ try {
94
+ fs.unlinkSync(tmp);
95
+ }
96
+ catch { /* rename succeeded or cleanup best effort */ }
97
+ }
98
+ }
99
+ blobEntries() {
100
+ if (!fs.existsSync(this.dir))
101
+ return [];
102
+ const entries = [];
103
+ const walk = (dir) => {
104
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
105
+ const full = path.join(dir, entry.name);
106
+ if (entry.isDirectory())
107
+ walk(full);
108
+ else if (entry.isFile() && isValidAttachmentHash(entry.name)) {
109
+ try {
110
+ const stat = fs.statSync(full);
111
+ entries.push({ hash: entry.name, path: full, size: stat.size, mtimeMs: stat.mtimeMs });
112
+ }
113
+ catch { /* raced deletion */ }
114
+ }
115
+ else if (entry.isFile() && entry.name.includes(".tmp-")) {
116
+ // Repair an interrupted atomic write after a conservative grace period.
117
+ try {
118
+ const stat = fs.statSync(full);
119
+ if (Date.now() - stat.mtimeMs > 60 * 60 * 1000)
120
+ fs.unlinkSync(full);
121
+ }
122
+ catch { /* best effort */ }
123
+ }
124
+ }
125
+ };
126
+ walk(this.dir);
127
+ return entries;
128
+ }
129
+ stats() {
130
+ const entries = this.blobEntries();
131
+ return { blobs: entries.length, bytes: entries.reduce((sum, entry) => sum + entry.size, 0) };
132
+ }
133
+ /** Delete only unreferenced blobs. Retention removes old orphans first; when
134
+ * over the global cap, oldest remaining orphans are removed until under cap.
135
+ * Referenced history is never sacrificed to satisfy the soft cap. */
136
+ gc(referenced, now = Date.now()) {
137
+ const entries = this.blobEntries().sort((a, b) => a.mtimeMs - b.mtimeMs);
138
+ let bytes = entries.reduce((sum, entry) => sum + entry.size, 0);
139
+ let removedBlobs = 0;
140
+ let removedBytes = 0;
141
+ const remove = (entry) => {
142
+ try {
143
+ fs.unlinkSync(entry.path);
144
+ try {
145
+ fs.unlinkSync(this.metaPath(entry.hash));
146
+ }
147
+ catch { /* sidecar optional */ }
148
+ bytes -= entry.size;
149
+ removedBlobs += 1;
150
+ removedBytes += entry.size;
151
+ }
152
+ catch { /* best-effort sweep */ }
153
+ };
154
+ for (const entry of entries) {
155
+ if (!referenced.has(entry.hash) && now - entry.mtimeMs >= this.retentionMs)
156
+ remove(entry);
157
+ }
158
+ if (bytes > this.maxStoreBytes) {
159
+ for (const entry of entries) {
160
+ if (bytes <= this.maxStoreBytes)
161
+ break;
162
+ if (!referenced.has(entry.hash) && fs.existsSync(entry.path))
163
+ remove(entry);
164
+ }
165
+ }
166
+ return { blobs: entries.length - removedBlobs, bytes, removedBlobs, removedBytes, overCapBytes: Math.max(0, bytes - this.maxStoreBytes) };
167
+ }
80
168
  /** The blob's metadata, or null if the hash is unknown/malformed. */
81
169
  readMeta(hash) {
82
170
  if (!isValidAttachmentHash(hash))
@@ -27,6 +27,7 @@
27
27
  // The two projections are independent: base records and overlay records may be
28
28
  // interleaved in any order on disk; each replay reads only its own kind.
29
29
  import fs from "node:fs";
30
+ import path from "node:path";
30
31
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
31
32
  /** Content-block type carried by a folded outbound attachment. MUST match
32
33
  * `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
@@ -219,9 +220,9 @@ export function baseReplay(entries) {
219
220
  }
220
221
  return base;
221
222
  }
222
- /** Parse a JSONL log body into valid records, skipping malformed/blank lines. */
223
- export function parseLog(body) {
224
- const out = [];
223
+ function parseLogDetailed(body) {
224
+ const records = [];
225
+ let malformedLines = 0;
225
226
  for (const line of body.split("\n")) {
226
227
  const trimmed = line.trim();
227
228
  if (!trimmed)
@@ -229,11 +230,20 @@ export function parseLog(body) {
229
230
  try {
230
231
  const value = JSON.parse(trimmed);
231
232
  if (isRecord(value))
232
- out.push(value);
233
+ records.push(value);
234
+ else
235
+ malformedLines += 1;
236
+ }
237
+ catch {
238
+ malformedLines += 1;
233
239
  }
234
- catch { }
235
240
  }
236
- return out;
241
+ return { records, malformedLines };
242
+ }
243
+ /** Parse a JSONL log body into valid records. Callers that need corruption
244
+ * diagnostics use EventLog.load, which reports malformed lines via onIssue. */
245
+ export function parseLog(body) {
246
+ return parseLogDetailed(body).records;
237
247
  }
238
248
  /**
239
249
  * Append-only, coalesced, throttled JSONL log store — the write+read companion to
@@ -248,6 +258,7 @@ export class EventLog {
248
258
  pathFor;
249
259
  redact;
250
260
  throttleMs;
261
+ onIssue;
251
262
  disk = new Map();
252
263
  pending = new Map();
253
264
  timers = new Map();
@@ -257,11 +268,53 @@ export class EventLog {
257
268
  // snapshot can be diffed (prefix-compared) into a bounded delta. Seeded from disk
258
269
  // on first use of a session after a restart.
259
270
  baseKeys = new Map();
260
- constructor(dir, pathFor, redact = (t) => t, throttleMs = 500) {
271
+ lastIssue;
272
+ constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`)) {
261
273
  this.dir = dir;
262
274
  this.pathFor = pathFor;
263
275
  this.redact = redact;
264
276
  this.throttleMs = throttleMs;
277
+ this.onIssue = onIssue;
278
+ }
279
+ report(id, operation, error) {
280
+ const issue = {
281
+ sessionId: id,
282
+ operation,
283
+ message: error instanceof Error ? error.message : String(error),
284
+ at: Date.now(),
285
+ };
286
+ this.lastIssue = issue;
287
+ this.onIssue(issue);
288
+ }
289
+ health() {
290
+ return { ok: !this.lastIssue, ...(this.lastIssue ? { lastIssue: { ...this.lastIssue } } : {}), pendingSessions: this.pending.size };
291
+ }
292
+ diskUsage() {
293
+ if (!fs.existsSync(this.dir))
294
+ return { files: 0, bytes: 0 };
295
+ let files = 0;
296
+ let bytes = 0;
297
+ const walk = (dir) => {
298
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
299
+ const full = path.join(dir, entry.name);
300
+ if (entry.isDirectory())
301
+ walk(full);
302
+ else if (entry.isFile()) {
303
+ try {
304
+ files += 1;
305
+ bytes += fs.statSync(full).size;
306
+ }
307
+ catch { /* raced cleanup */ }
308
+ }
309
+ }
310
+ };
311
+ try {
312
+ walk(this.dir);
313
+ }
314
+ catch (error) {
315
+ this.report("*", "read", error);
316
+ }
317
+ return { files, bytes };
265
318
  }
266
319
  load(id) {
267
320
  const cached = this.disk.get(id);
@@ -269,9 +322,15 @@ export class EventLog {
269
322
  return cached;
270
323
  let data = [];
271
324
  try {
272
- data = parseLog(fs.readFileSync(this.pathFor(id), "utf8"));
325
+ const parsed = parseLogDetailed(fs.readFileSync(this.pathFor(id), "utf8"));
326
+ data = parsed.records;
327
+ if (parsed.malformedLines > 0)
328
+ this.report(id, "parse", new Error(`${parsed.malformedLines} malformed record(s); valid history was recovered`));
329
+ }
330
+ catch (error) {
331
+ if (error?.code !== "ENOENT")
332
+ this.report(id, "read", error);
273
333
  }
274
- catch { }
275
334
  this.disk.set(id, data);
276
335
  return data;
277
336
  }
@@ -437,7 +496,10 @@ export class EventLog {
437
496
  batch.clear();
438
497
  this.lastFlush.set(id, Date.now());
439
498
  }
440
- catch { }
499
+ catch (error) {
500
+ // Do not clear the batch: a later explicit/timer flush can retry it.
501
+ this.report(id, "append", error);
502
+ }
441
503
  }
442
504
  /**
443
505
  * Overwrite a session's log with a known-complete set of records. Used by the
@@ -462,7 +524,9 @@ export class EventLog {
462
524
  fs.writeFileSync(this.pathFor(id), this.redact(body));
463
525
  this.lastFlush.set(id, Date.now());
464
526
  }
465
- catch { }
527
+ catch (error) {
528
+ this.report(id, "rewrite", error);
529
+ }
466
530
  }
467
531
  /** Cancel any pending write and forget the session (used when it's deleted). */
468
532
  drop(id) {
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -53,14 +53,52 @@ export function captureDirtyPatch(repoDir, opts = {}) {
53
53
  * source pushed the branch instead (`pushedInstead`) or the working tree was
54
54
  * clean (empty patch). Uses `git apply` so both tracked hunks and untracked
55
55
  * new-file hunks (produced via `--no-index`) land correctly.
56
+ *
57
+ * NEVER throws: a fork's uncommitted changes are best-effort, and the source's
58
+ * base commit frequently differs from what the destination cloned (a diverged
59
+ * default branch, an unpushed source branch), so a strict `git apply` fails on
60
+ * hunk-context mismatch and previously took the whole fork down with it. Instead
61
+ * we fall back to `git apply --3way` (which reconstructs the hunks from the blob
62
+ * SHAs the patch carries and merges what it can) and, when even that fails,
63
+ * surface a warning and leave the tree as the clone left it — the fork still
64
+ * succeeds, minus the un-appliable working-tree edits.
56
65
  */
57
66
  export function applyDirtyPatch(repoDir, dirty) {
58
67
  if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
59
- return;
68
+ return { applied: false };
60
69
  const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
61
70
  fs.writeFileSync(tmp, dirty.patch);
62
71
  try {
63
- execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
72
+ try {
73
+ execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
74
+ return { applied: true };
75
+ }
76
+ catch {
77
+ // Clean apply failed — the destination's base drifted from the source's.
78
+ // Retry with a 3-way merge, which reconstructs the pre-image from the blob
79
+ // SHAs the patch carries (present because the destination cloned the same
80
+ // repo) and merges what it can. `git apply --3way` exits non-zero BOTH for
81
+ // an un-appliable patch (nothing lands) AND for a conflicting one (it lands
82
+ // the non-conflicting hunks and writes conflict markers) — so read the exit
83
+ // status/stderr with spawnSync rather than treating every non-zero as a
84
+ // total failure that drops all the WIP.
85
+ const res = spawnSync("git", ["-C", repoDir, "apply", "--3way", "--whitespace=nowarn", tmp], { encoding: "utf8" });
86
+ if (res.status === 0)
87
+ return { applied: true }; // merged cleanly onto the diverged base
88
+ const stderr = (res.stderr || "").toString();
89
+ if (/with conflicts/i.test(stderr)) {
90
+ return {
91
+ applied: true,
92
+ conflicted: true,
93
+ warning: "Some uncommitted changes from the source didn't apply cleanly and were merged with conflict markers — review and resolve them in the fork.",
94
+ };
95
+ }
96
+ const detail = stderr.split("\n").find((l) => l.trim()) || "patch did not apply";
97
+ return {
98
+ applied: false,
99
+ warning: `Couldn't re-apply the source's uncommitted changes (${detail}); they were left behind. Re-make them in the fork if you still need them.`,
100
+ };
101
+ }
64
102
  }
65
103
  finally {
66
104
  try {
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Safe per-file revert for the review surface (C3d). Restores ONE changed file
5
+ // to its pre-turn content, so a reviewer can undo a single unwanted edit without
6
+ // rewinding the whole turn. The pre-turn content is the diff's own `oldText`
7
+ // (computed and sent by the node), passed back with the request; a file the turn
8
+ // ADDED reverts to removal (content === null). Path-confined to the worktree — no
9
+ // traversal, no absolute escape — and never writes outside it.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ /** Resolve `relPath` inside `worktreeDir`, rejecting anything that escapes it. */
13
+ export function confineToWorktree(worktreeDir, relPath) {
14
+ const root = path.resolve(worktreeDir);
15
+ const abs = path.resolve(root, relPath);
16
+ if (abs !== root && !abs.startsWith(root + path.sep))
17
+ return null;
18
+ return abs;
19
+ }
20
+ /**
21
+ * Revert a single changed file to its pre-turn state. `content` is the file's
22
+ * pre-turn text (restore it), or `null` when the turn added the file (remove it).
23
+ * A path outside the worktree is rejected outright.
24
+ */
25
+ export function revertFile(worktreeDir, relPath, content, io = {}) {
26
+ const abs = confineToWorktree(worktreeDir, relPath);
27
+ if (!abs || !relPath)
28
+ return { ok: false, status: "rejected", error: "path outside the worktree" };
29
+ const writeFile = io.writeFile ?? ((p, data) => fs.writeFileSync(p, data));
30
+ const rm = io.rm ?? ((p) => fs.rmSync(p, { force: true }));
31
+ const mkdir = io.mkdir ?? ((p) => fs.mkdirSync(p, { recursive: true }));
32
+ try {
33
+ if (content === null) {
34
+ rm(abs);
35
+ return { ok: true, status: "removed" };
36
+ }
37
+ mkdir(path.dirname(abs));
38
+ writeFile(abs, content);
39
+ return { ok: true, status: "reverted" };
40
+ }
41
+ catch (error) {
42
+ return { ok: false, status: "rejected", error: error instanceof Error ? error.message : String(error) };
43
+ }
44
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** One hour keeps legitimate deep coding turns viable while bounding the
4
+ * default failure/cost window. Automations may choose a lower timeout. */
5
+ export const DEFAULT_TURN_TIMEOUT_MS = 60 * 60 * 1000;
6
+ export const MAX_TURN_TIMEOUT_MS = 24 * 60 * 60 * 1000;
7
+ /** Parse BIVY_TURN_TIMEOUT_MS. Explicit 0 is the documented trusted-workflow
8
+ * escape hatch; malformed/negative values fall back safely instead of silently
9
+ * disabling the watchdog. Very large values are capped to one day. */
10
+ export function configuredTurnTimeoutMs(value = process.env.BIVY_TURN_TIMEOUT_MS) {
11
+ if (value === undefined || value.trim() === "")
12
+ return DEFAULT_TURN_TIMEOUT_MS;
13
+ const parsed = Number(value);
14
+ if (parsed === 0)
15
+ return 0;
16
+ if (!Number.isFinite(parsed) || parsed < 0)
17
+ return DEFAULT_TURN_TIMEOUT_MS;
18
+ return Math.min(MAX_TURN_TIMEOUT_MS, Math.max(1_000, Math.floor(parsed)));
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",
@@ -37,6 +37,7 @@
37
37
  "express": "^5.2.1",
38
38
  "node-pty": "^1.1.0",
39
39
  "typebox": "^1.3.6",
40
+ "undici": "8.9.0",
40
41
  "ws": "^8.21.1",
41
42
  "zod": "^4.0.0"
42
43
  },
@@ -44,8 +45,10 @@
44
45
  "@hono/node-server": "2.0.12",
45
46
  "@modelcontextprotocol/sdk": "1.30.0",
46
47
  "@earendil-works/pi-coding-agent": {
47
- "brace-expansion": "5.0.9"
48
+ "brace-expansion": "5.0.9",
49
+ "undici": "8.9.0"
48
50
  },
49
- "brace-expansion": "5.0.9"
51
+ "brace-expansion": "5.0.9",
52
+ "undici": "8.9.0"
50
53
  }
51
54
  }