@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.
- package/README.md +15 -6
- package/bin/agent-manifest.json +38 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/net-proxy.js +28 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +83 -3
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +49 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +587 -93
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
233
|
+
records.push(value);
|
|
234
|
+
else
|
|
235
|
+
malformedLines += 1;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
malformedLines += 1;
|
|
233
239
|
}
|
|
234
|
-
catch { }
|
|
235
240
|
}
|
|
236
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|