@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
package/dist/server.js
CHANGED
|
@@ -45,20 +45,22 @@ import { RelayConnector, loadRelayConfig } from "./relay-client.js";
|
|
|
45
45
|
import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
46
46
|
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
47
47
|
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
|
|
48
|
+
import { configuredTurnTimeoutMs } from "./session/turn-watchdog.js";
|
|
49
|
+
import { runRequiredAutomationChecks } from "./automation-checks.js";
|
|
48
50
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
49
51
|
import { TerminalManager } from "./terminal.js";
|
|
50
52
|
import { commandLaunch } from "./command-launch.js";
|
|
51
53
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
52
54
|
import { createWorktree, removeWorktree, branchSlug, gitRepoRoot } from "./worktree.js";
|
|
53
55
|
import { HarnessManager } from "./harness/manager.js";
|
|
54
|
-
import { startEgressProxyIfEnabled } from "./harness/egress.js";
|
|
56
|
+
import { startEgressProxyIfEnabled, applySessionSandboxEgress, stopSessionEgress } from "./harness/egress.js";
|
|
55
57
|
import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
|
|
56
58
|
import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
|
|
57
59
|
import { checkDiskAdmission } from "./harness/disk-admission.js";
|
|
58
60
|
import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
|
|
59
61
|
import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
|
|
60
62
|
import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
|
|
61
|
-
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
63
|
+
import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
|
|
62
64
|
import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
|
|
63
65
|
import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
|
|
64
66
|
import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
|
|
@@ -73,6 +75,8 @@ import { thinkingTextFromContent } from "./session/transcript-merge.js";
|
|
|
73
75
|
import { normalizeMessages } from "./session/transcript-normal.js";
|
|
74
76
|
import { buildNativeImportSeedPrompt } from "./session/native-import.js";
|
|
75
77
|
import { EventLog } from "./session/event-log.js";
|
|
78
|
+
import { revertFile } from "./session/revert-file.js";
|
|
79
|
+
import { buildDiagnosticsReport, activationRecord } from "./diagnostics.js";
|
|
76
80
|
import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
|
|
77
81
|
import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
|
|
78
82
|
import { extractInlineImageUrls, assistantTextForImageScan, fetchInlineImage, isFetchImageError, inlineImageDisplayName, } from "./session/inline-image-fetch.js";
|
|
@@ -531,7 +535,9 @@ if (sessionRunPolicy) {
|
|
|
531
535
|
console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
|
|
532
536
|
}
|
|
533
537
|
let lastUpdateCheckAt = 0;
|
|
534
|
-
|
|
538
|
+
// The most recent "this node is behind" finding, so a client that connects after
|
|
539
|
+
// the check already ran still gets the banner (replayed on connect below).
|
|
540
|
+
let pendingBivyUpdate = null;
|
|
535
541
|
function runtimeSummary(rt) {
|
|
536
542
|
return runtimeHost.summary(rt);
|
|
537
543
|
}
|
|
@@ -621,11 +627,11 @@ function readJsonFile(file) {
|
|
|
621
627
|
return undefined;
|
|
622
628
|
}
|
|
623
629
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
630
|
+
// Poll npm for a newer release (throttled to every 6h). On finding one, remember
|
|
631
|
+
// it and push a dedicated `node.update` event so every connected app can show a
|
|
632
|
+
// banner with a one-tap "Update this node" button (see runBivyUpdate). Safe to
|
|
633
|
+
// call from anywhere — never throws, never interrupts a session.
|
|
634
|
+
async function checkBivyUpdate() {
|
|
629
635
|
const now = Date.now();
|
|
630
636
|
if (now - lastUpdateCheckAt < 6 * 60 * 60 * 1000)
|
|
631
637
|
return;
|
|
@@ -637,25 +643,48 @@ async function maybeNotifyBivyUpdate(record) {
|
|
|
637
643
|
const res = await fetch(updateRegistryUrl, { signal: AbortSignal.timeout(5000) });
|
|
638
644
|
if (!res.ok)
|
|
639
645
|
return;
|
|
640
|
-
const
|
|
641
|
-
if (!
|
|
642
|
-
return;
|
|
643
|
-
if (updateNoticeSentFor === latestVersion)
|
|
646
|
+
const latest = (await res.json()).version;
|
|
647
|
+
if (!latest || !isNewerVersion(latest, current))
|
|
644
648
|
return;
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
broadcast({
|
|
648
|
-
type: "session.notice",
|
|
649
|
-
sessionId: record.id,
|
|
650
|
-
level: "info",
|
|
651
|
-
message: `A newer Bivy version${label} is available. Run \`bivy update\` in your terminal to update.`,
|
|
652
|
-
action: "bivy update",
|
|
653
|
-
});
|
|
649
|
+
pendingBivyUpdate = { current, latest };
|
|
650
|
+
broadcast({ type: "node.update", current, latest });
|
|
654
651
|
}
|
|
655
652
|
catch {
|
|
656
653
|
// Best-effort update checks should never interrupt a session.
|
|
657
654
|
}
|
|
658
655
|
}
|
|
656
|
+
async function maybeNotifyBivyUpdate() {
|
|
657
|
+
// The daemon creates an initial session during startup before any UI is
|
|
658
|
+
// connected. Don't spend a check until someone can see the banner.
|
|
659
|
+
if (clients.size === 0 && !relay)
|
|
660
|
+
return;
|
|
661
|
+
await checkBivyUpdate();
|
|
662
|
+
}
|
|
663
|
+
// Run `bivy update` on this node, the same command a user would type. The CLI
|
|
664
|
+
// re-spawns itself detached, waits for any in-flight turn, updates, and restarts
|
|
665
|
+
// the service (logging to update.log), so we just fire-and-forget it here. The
|
|
666
|
+
// bin ships next to this server bundle in both the git checkout (src/server.ts)
|
|
667
|
+
// and the published package (dist/server.js), so repoRoot/bin/bivy.mjs resolves
|
|
668
|
+
// in both. Returns a friendly error instead of throwing when it can't be found
|
|
669
|
+
// (e.g. an unusual layout), so the banner can fall back to the manual command.
|
|
670
|
+
function runBivyUpdate() {
|
|
671
|
+
const script = path.join(repoRoot, "bin", "bivy.mjs");
|
|
672
|
+
if (!fs.existsSync(script)) {
|
|
673
|
+
return { ok: false, error: "Could not locate the bivy CLI on this node — run `bivy update` in a terminal." };
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const child = spawn(process.execPath, [script, "update"], {
|
|
677
|
+
detached: true,
|
|
678
|
+
stdio: "ignore",
|
|
679
|
+
env: process.env,
|
|
680
|
+
});
|
|
681
|
+
child.unref();
|
|
682
|
+
return { ok: true };
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
659
688
|
function runtimeInstallSpec(requested) {
|
|
660
689
|
let id = String(requested ?? "").trim().toLowerCase();
|
|
661
690
|
// Normalize a few historical aliases to their canonical runtime id.
|
|
@@ -921,6 +950,8 @@ function safeAttachmentName(value) {
|
|
|
921
950
|
* with its normal file tools. Any file type is supported;
|
|
922
951
|
* binary files arrive as base64 `data`.
|
|
923
952
|
*/
|
|
953
|
+
const MAX_PROMPT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
954
|
+
const MAX_PROMPT_ATTACHMENTS_BYTES = 40 * 1024 * 1024;
|
|
924
955
|
function attachmentsFrom(value) {
|
|
925
956
|
if (!Array.isArray(value))
|
|
926
957
|
return { images: [], imageNotes: [], imageRefs: [], files: [] };
|
|
@@ -928,13 +959,24 @@ function attachmentsFrom(value) {
|
|
|
928
959
|
const imageNotes = [];
|
|
929
960
|
const imageRefs = [];
|
|
930
961
|
const files = [];
|
|
931
|
-
|
|
962
|
+
let totalBytes = 0;
|
|
963
|
+
if (value.length > 12)
|
|
964
|
+
throw new Error("A message can include at most 12 attachments");
|
|
965
|
+
for (const raw of value) {
|
|
932
966
|
if (!raw || typeof raw !== "object")
|
|
933
967
|
continue;
|
|
934
968
|
const attachment = raw;
|
|
935
969
|
const name = safeAttachmentName(attachment.name);
|
|
936
970
|
const size = Number(attachment.size || 0);
|
|
937
971
|
const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
|
|
972
|
+
const encodedBytes = typeof attachment.data === "string" ? Math.floor(attachment.data.length * 3 / 4) : 0;
|
|
973
|
+
const textBytes = attachment.kind === "file" && typeof attachment.text === "string" ? Buffer.byteLength(attachment.text) : 0;
|
|
974
|
+
const actualBytes = encodedBytes || textBytes;
|
|
975
|
+
if (actualBytes > MAX_PROMPT_ATTACHMENT_BYTES)
|
|
976
|
+
throw new Error(`${name} exceeds the 10 MiB attachment limit`);
|
|
977
|
+
totalBytes += actualBytes;
|
|
978
|
+
if (totalBytes > MAX_PROMPT_ATTACHMENTS_BYTES)
|
|
979
|
+
throw new Error("Attachments exceed the 40 MiB per-message limit");
|
|
938
980
|
if (attachment.kind === "image" && typeof attachment.data === "string") {
|
|
939
981
|
const imgMime = mimeType ?? "image/png";
|
|
940
982
|
images.push({ type: "image", data: attachment.data, mimeType: imgMime });
|
|
@@ -2179,14 +2221,58 @@ function eventLogPath(sessionId) {
|
|
|
2179
2221
|
// whole history: overlay detail (reasoning + tool activity) AND the base transcript,
|
|
2180
2222
|
// the latter as bounded delta/reset records. Written on every event; read via
|
|
2181
2223
|
// eventLog.deriveHistory. `redactSecrets` scrubs credentials at the single flush
|
|
2182
|
-
// choke point before anything lands on the synced-to-PWA disk.
|
|
2183
|
-
|
|
2224
|
+
// choke point before anything lands on the synced-to-PWA disk. I/O/corruption
|
|
2225
|
+
// failures are never silently converted into empty history: keep a diagnostic,
|
|
2226
|
+
// log loudly, and notify the owning live session while pending appends remain
|
|
2227
|
+
// queued for retry.
|
|
2228
|
+
const eventLogIssues = new Map();
|
|
2229
|
+
const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets, 500, (issue) => {
|
|
2230
|
+
eventLogIssues.set(issue.sessionId, { operation: issue.operation, message: issue.message, at: issue.at });
|
|
2231
|
+
console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`);
|
|
2232
|
+
const record = openSessions.get(issue.sessionId);
|
|
2233
|
+
if (!record)
|
|
2234
|
+
return;
|
|
2235
|
+
const warning = `Session history storage problem (${issue.operation}): ${issue.message}`;
|
|
2236
|
+
if (record.warning === warning)
|
|
2237
|
+
return;
|
|
2238
|
+
record.warning = warning;
|
|
2239
|
+
broadcast({ type: "session.notice", sessionId: record.id, level: "error", message: warning });
|
|
2240
|
+
});
|
|
2184
2241
|
// Global content-addressed store for message attachments (images + files). Unlike
|
|
2185
2242
|
// the per-session `.bivy-attachments/` worktree copy (kept so the agent can open
|
|
2186
2243
|
// files with its tools), this is durable, session-independent, and re-findable:
|
|
2187
2244
|
// the transcript references blobs by hash, and clients rehydrate thumbnails by
|
|
2188
2245
|
// hash after a reload or on another device. See src/session/attachment-store.ts.
|
|
2189
|
-
const
|
|
2246
|
+
const positiveEnvNumber = (name, fallback) => {
|
|
2247
|
+
const value = Number(process.env[name]);
|
|
2248
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
2249
|
+
};
|
|
2250
|
+
const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
|
|
2251
|
+
maxFileBytes: positiveEnvNumber("BIVY_ATTACHMENT_MAX_FILE_BYTES", 25 * 1024 * 1024),
|
|
2252
|
+
maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
|
|
2253
|
+
retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
|
|
2254
|
+
});
|
|
2255
|
+
let attachmentGcStats = attachmentStore.stats();
|
|
2256
|
+
function referencedAttachmentHashes() {
|
|
2257
|
+
// If transcript history is unreadable, collecting nothing would make its
|
|
2258
|
+
// still-referenced blobs look orphaned. Fail closed and skip destructive GC.
|
|
2259
|
+
if (!eventLog.health().ok)
|
|
2260
|
+
return null;
|
|
2261
|
+
const hashes = new Set();
|
|
2262
|
+
const ids = new Set(metadata.listSessions().map((session) => session.id));
|
|
2263
|
+
for (const record of new Set(openSessions.values()))
|
|
2264
|
+
ids.add(record.id);
|
|
2265
|
+
for (const id of ids) {
|
|
2266
|
+
for (const entry of eventLog.entries(id)) {
|
|
2267
|
+
if (entry.bivyKind === "attachment")
|
|
2268
|
+
for (const ref of entry.refs)
|
|
2269
|
+
hashes.add(ref.hash);
|
|
2270
|
+
else if (entry.bivyKind === "outbound-attachment" || entry.bivyKind === "inline-image")
|
|
2271
|
+
hashes.add(entry.ref.hash);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
return eventLog.health().ok ? hashes : null;
|
|
2275
|
+
}
|
|
2190
2276
|
// --- Warm session replication (docs/session-replication.md) -----------------
|
|
2191
2277
|
// A standby's replica repo lives under appDir/replicas/<id>: a self-contained git
|
|
2192
2278
|
// repo that receives checkpoint bundles and is checked out on promotion. Created
|
|
@@ -2536,6 +2622,14 @@ const RELAY_COMMANDS = {
|
|
|
2536
2622
|
ping(msg, ctx) {
|
|
2537
2623
|
ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
|
|
2538
2624
|
},
|
|
2625
|
+
// Kick off `bivy update` on this node from the app's version-mismatch banner
|
|
2626
|
+
// (see runBivyUpdate). The node restarts itself when the update lands, so the
|
|
2627
|
+
// client just sees the socket reconnect on the new build; a failure to even
|
|
2628
|
+
// start reports back so the banner can show the manual command.
|
|
2629
|
+
"node.update"(_msg, ctx) {
|
|
2630
|
+
const result = runBivyUpdate();
|
|
2631
|
+
ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
|
|
2632
|
+
},
|
|
2539
2633
|
// Fetch a stored attachment's bytes by content hash. The relay client (a phone
|
|
2540
2634
|
// not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
|
|
2541
2635
|
// over the encrypted tunnel instead; the relay framing chunks the base64 payload
|
|
@@ -2601,6 +2695,30 @@ const RELAY_COMMANDS = {
|
|
|
2601
2695
|
ctx.reply({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
|
|
2602
2696
|
}
|
|
2603
2697
|
},
|
|
2698
|
+
async "session.revert_file"(msg, ctx) {
|
|
2699
|
+
// C3d — revert ONE changed file to its pre-turn content without rewinding the
|
|
2700
|
+
// whole turn. `content` is the file's pre-turn text (or null when the turn
|
|
2701
|
+
// added it). Path-confined to the session's worktree by revertFile.
|
|
2702
|
+
const record = resolveSession(msg.sessionId);
|
|
2703
|
+
const relPath = String(msg.path ?? "").trim();
|
|
2704
|
+
if (!record || !relPath)
|
|
2705
|
+
return;
|
|
2706
|
+
if (sessionBusy(record)) {
|
|
2707
|
+
ctx.reply({ type: "session.error", sessionId: record.id, error: "Stop the current turn before reverting a file." });
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
const content = typeof msg.content === "string" ? msg.content : null;
|
|
2711
|
+
const result = revertFile(harnessDirFor(record), relPath, content);
|
|
2712
|
+
if (!result.ok) {
|
|
2713
|
+
ctx.reply({ type: "session.error", sessionId: record.id, error: `Could not revert ${relPath}: ${result.error ?? "unknown error"}` });
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
// Recompute the turn's diff against the (unchanged) baseline so the review
|
|
2717
|
+
// surface drops the reverted file immediately.
|
|
2718
|
+
const event = { type: "session.file_reverted", sessionId: record.id, path: relPath, status: result.status };
|
|
2719
|
+
ctx.reply(event);
|
|
2720
|
+
ctx.broadcast(event);
|
|
2721
|
+
},
|
|
2604
2722
|
async "session.pr.refresh"(msg, ctx) {
|
|
2605
2723
|
// Force a refresh regardless of live/attached state — resume the session if
|
|
2606
2724
|
// the node dropped it from memory, so a finished/detached session can still
|
|
@@ -2966,6 +3084,7 @@ const RELAY_COMMANDS = {
|
|
|
2966
3084
|
},
|
|
2967
3085
|
async "models.list"(msg) {
|
|
2968
3086
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
3087
|
+
const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
|
|
2969
3088
|
let record;
|
|
2970
3089
|
try {
|
|
2971
3090
|
record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
|
|
@@ -2978,7 +3097,12 @@ const RELAY_COMMANDS = {
|
|
|
2978
3097
|
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
|
|
2979
3098
|
return;
|
|
2980
3099
|
}
|
|
2981
|
-
|
|
3100
|
+
// On a draft (no session id), a runtime hint from the composer takes
|
|
3101
|
+
// precedence so an agent switch previews *that* agent's models even if a
|
|
3102
|
+
// stale `active` on another runtime lingers on the node.
|
|
3103
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
3104
|
+
record = null;
|
|
3105
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
2982
3106
|
const session = record.session;
|
|
2983
3107
|
const current = session.getCurrentModel();
|
|
2984
3108
|
const models = await publicModelsList(session, current);
|
|
@@ -2990,6 +3114,17 @@ const RELAY_COMMANDS = {
|
|
|
2990
3114
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
2991
3115
|
relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
|
|
2992
3116
|
},
|
|
3117
|
+
"models.prefetch"(msg) {
|
|
3118
|
+
// The composer's agent picker opened: warm the scratch session for each
|
|
3119
|
+
// offered agent in the background so the first switch to any of them answers
|
|
3120
|
+
// instantly. Fire-and-forget — no reply; the follow-up models.list carries
|
|
3121
|
+
// the result. Ignore anything but a bounded string[] of runtime ids.
|
|
3122
|
+
const ids = Array.isArray(msg.runtimeIds)
|
|
3123
|
+
? msg.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
3124
|
+
: [];
|
|
3125
|
+
if (ids.length)
|
|
3126
|
+
prefetchModels(ids);
|
|
3127
|
+
},
|
|
2993
3128
|
async "model.select"(msg) {
|
|
2994
3129
|
const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
|
|
2995
3130
|
let record;
|
|
@@ -3407,7 +3542,7 @@ const RELAY_COMMANDS = {
|
|
|
3407
3542
|
record.lastPrompt = agentPrompt;
|
|
3408
3543
|
record.lastPromptOptions = promptOptionsFor(record, msg.streamingBehavior, images);
|
|
3409
3544
|
record.reroute?.beginTurn();
|
|
3410
|
-
await record
|
|
3545
|
+
await promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
|
|
3411
3546
|
}).catch((error) => {
|
|
3412
3547
|
// Mirror the HTTP path (see the /prompt route): a rejected turn after
|
|
3413
3548
|
// the runtime marked the session working emits no agent_end, so without
|
|
@@ -3442,6 +3577,7 @@ const RELAY_COMMANDS = {
|
|
|
3442
3577
|
source: rec.source,
|
|
3443
3578
|
title: rec.session.getName(),
|
|
3444
3579
|
model: rec.session.getCurrentModel()?.name,
|
|
3580
|
+
sandbox: rec.sandbox,
|
|
3445
3581
|
};
|
|
3446
3582
|
let dirtyPatch;
|
|
3447
3583
|
if (rec.worktree) {
|
|
@@ -3450,6 +3586,14 @@ const RELAY_COMMANDS = {
|
|
|
3450
3586
|
}
|
|
3451
3587
|
catch { /* best effort — omit dirty state */ }
|
|
3452
3588
|
}
|
|
3589
|
+
// Publish the source branch so a cross-node fork's COMMITTED work travels
|
|
3590
|
+
// via origin (the destination adopts `origin/<branch>`; see
|
|
3591
|
+
// resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
|
|
3592
|
+
// for a genuine cross-node fork — a same-node cross-agent fork adopts the
|
|
3593
|
+
// LOCAL branch and needs no push. Best-effort: a no-token/offline node just
|
|
3594
|
+
// falls back to the default base downstream.
|
|
3595
|
+
if (msg.crossNode === true)
|
|
3596
|
+
await pushForkSourceBranch(rec);
|
|
3453
3597
|
// Refresh the account model-auth vault so the destination node can pull
|
|
3454
3598
|
// this session's model credentials during import (fork credential-move,
|
|
3455
3599
|
// docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
|
|
@@ -3530,6 +3674,7 @@ const RELAY_COMMANDS = {
|
|
|
3530
3674
|
source: rec.source,
|
|
3531
3675
|
title: rec.session.getName(),
|
|
3532
3676
|
model: rec.session.getCurrentModel()?.name,
|
|
3677
|
+
sandbox: rec.sandbox,
|
|
3533
3678
|
};
|
|
3534
3679
|
// Carry uncommitted work: capture from the SOURCE worktree; standUpFork
|
|
3535
3680
|
// re-applies it into the fork's fresh worktree. Local git ops only.
|
|
@@ -4481,16 +4626,26 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4481
4626
|
// evidence trail — the branch/PR references and a bounded summary only,
|
|
4482
4627
|
// never file lists or error details (those stay in `message`/`extra`,
|
|
4483
4628
|
// which are broadcast to the live session but never sent to onEvidence).
|
|
4484
|
-
const kind = stage === "pr_opened" ? "pull_request"
|
|
4629
|
+
const kind = stage === "pr_opened" ? "pull_request"
|
|
4630
|
+
: stage === "started" || stage === "pushed" ? "branch"
|
|
4631
|
+
: stage === "failed" || stage === "checks_failed" || stage === "no_changes" ? "completed"
|
|
4632
|
+
: undefined;
|
|
4485
4633
|
if (kind) {
|
|
4634
|
+
const summary = stage === "pr_opened" ? "Pull request opened."
|
|
4635
|
+
: stage === "started" ? "Working branch and session created."
|
|
4636
|
+
: stage === "pushed" ? "Changes pushed; no pull request is open."
|
|
4637
|
+
: stage === "no_changes" ? "Run completed with no file changes."
|
|
4638
|
+
: stage === "checks_failed" ? "Deterministic validation checks failed."
|
|
4639
|
+
: "Execution failed. Detailed diagnostics remain on the node.";
|
|
4486
4640
|
void overrides.onEvidence?.({
|
|
4487
4641
|
output: { sessionId: record.id, branch, prUrl: typeof extra.prUrl === "string" ? extra.prUrl : undefined },
|
|
4488
4642
|
events: [{
|
|
4489
4643
|
at: new Date().toISOString(),
|
|
4490
4644
|
kind,
|
|
4491
|
-
summary
|
|
4645
|
+
summary,
|
|
4492
4646
|
ref: branch,
|
|
4493
4647
|
url: typeof extra.prUrl === "string" ? extra.prUrl : undefined,
|
|
4648
|
+
...(stage === "checks_failed" || stage === "failed" ? { status: "failed" } : {}),
|
|
4494
4649
|
}],
|
|
4495
4650
|
});
|
|
4496
4651
|
}
|
|
@@ -4503,7 +4658,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4503
4658
|
// which now adopts the existing remote branch rather than colliding.
|
|
4504
4659
|
const existing = findIssueSession(source);
|
|
4505
4660
|
if (existing?.worktree && fs.existsSync(existing.worktree.path)) {
|
|
4506
|
-
return runIssueFollowUp(cfg, issue, existing, emit);
|
|
4661
|
+
return runIssueFollowUp(cfg, issue, existing, emit, overrides);
|
|
4507
4662
|
}
|
|
4508
4663
|
// Idempotency guard against the duplicate-PR regression: if this issue's
|
|
4509
4664
|
// deterministic branch already produced a *merged* pull request, the change has
|
|
@@ -4585,8 +4740,8 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4585
4740
|
try {
|
|
4586
4741
|
emit(record, "started", `Started work on ${cfg.owner}/${cfg.repo}#${issue.number}.`);
|
|
4587
4742
|
await runSessionTurn(record, buildTaskPrompt(issue, nodeGithubIssuePrompt()));
|
|
4588
|
-
emit(record, "agent_done", `Agent finished issue #${issue.number};
|
|
4589
|
-
await reportIssueOutcome(cfg, issue, record, emit, { followUp: false });
|
|
4743
|
+
emit(record, "agent_done", `Agent finished issue #${issue.number}; running deterministic checks.`);
|
|
4744
|
+
await reportIssueOutcome(cfg, issue, record, emit, { followUp: false, onEvidence: overrides.onEvidence });
|
|
4590
4745
|
}
|
|
4591
4746
|
catch (error) {
|
|
4592
4747
|
emit(record, "failed", `GitHub issue #${issue.number} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -4599,7 +4754,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4599
4754
|
* the new comment as another turn in the same worktree, then report the outcome
|
|
4600
4755
|
* the same way a fresh pickup does.
|
|
4601
4756
|
*/
|
|
4602
|
-
async function runIssueFollowUp(cfg, issue, record, emit) {
|
|
4757
|
+
async function runIssueFollowUp(cfg, issue, record, emit, overrides = {}) {
|
|
4603
4758
|
const wt = record.worktree;
|
|
4604
4759
|
if (!wt)
|
|
4605
4760
|
throw new Error("issue session has no worktree");
|
|
@@ -4628,8 +4783,8 @@ async function runIssueFollowUp(cfg, issue, record, emit) {
|
|
|
4628
4783
|
}
|
|
4629
4784
|
}
|
|
4630
4785
|
await runSessionTurn(record, buildFollowUpPrompt(issue));
|
|
4631
|
-
emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number};
|
|
4632
|
-
await reportIssueOutcome(cfg, issue, record, emit, { followUp: true });
|
|
4786
|
+
emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number}; running deterministic checks.`);
|
|
4787
|
+
await reportIssueOutcome(cfg, issue, record, emit, { followUp: true, onEvidence: overrides.onEvidence });
|
|
4633
4788
|
}
|
|
4634
4789
|
catch (error) {
|
|
4635
4790
|
emit(record, "failed", `GitHub issue #${issue.number} follow-up failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -4655,6 +4810,26 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
|
|
|
4655
4810
|
const wt = record.worktree;
|
|
4656
4811
|
if (!wt)
|
|
4657
4812
|
throw new Error("issue session has no worktree");
|
|
4813
|
+
// Customer success is not `agent_end`. Run the repository's declared standard
|
|
4814
|
+
// checks under local time/output bounds and report only privacy-safe metadata
|
|
4815
|
+
// (name/hash/status/exit), never command text or output, to the control plane.
|
|
4816
|
+
const checks = runRequiredAutomationChecks(wt.path);
|
|
4817
|
+
if (checks.length > 0) {
|
|
4818
|
+
const failed = checks.filter((check) => check.status === "failed");
|
|
4819
|
+
await opts.onEvidence?.({
|
|
4820
|
+
checks,
|
|
4821
|
+
events: [{
|
|
4822
|
+
at: new Date().toISOString(),
|
|
4823
|
+
kind: "completed",
|
|
4824
|
+
summary: failed.length ? `${failed.length} deterministic check(s) failed.` : `${checks.length} deterministic check(s) passed.`,
|
|
4825
|
+
status: failed.length ? "failed" : "passed",
|
|
4826
|
+
}],
|
|
4827
|
+
});
|
|
4828
|
+
if (failed.length) {
|
|
4829
|
+
emit(record, "checks_failed", `${failed.map((check) => check.name).join(", ")} failed; the run needs review.`);
|
|
4830
|
+
throw new Error(`Required checks failed: ${failed.map((check) => check.name).join(", ")}`);
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4658
4833
|
const commitMessage = opts.followUp ? `Follow-up on #${issue.number}` : `${issue.title} (#${issue.number})`;
|
|
4659
4834
|
await commitAll(wt.path, commitMessage);
|
|
4660
4835
|
await fetchOrigin(wt.path);
|
|
@@ -4740,7 +4915,7 @@ async function runSessionTurn(record, prompt) {
|
|
|
4740
4915
|
}
|
|
4741
4916
|
});
|
|
4742
4917
|
});
|
|
4743
|
-
await record
|
|
4918
|
+
await promptWithWatchdog(record, prompt);
|
|
4744
4919
|
await finished;
|
|
4745
4920
|
}
|
|
4746
4921
|
/** Set + persist + broadcast a session's display name (used by issue pickup). */
|
|
@@ -5050,6 +5225,47 @@ async function resolveTokenForRepo(owner, repo) {
|
|
|
5050
5225
|
}
|
|
5051
5226
|
return (await resolveGitHubToken()) ?? (await hostedMintToken());
|
|
5052
5227
|
}
|
|
5228
|
+
/** The session source a Linear-issue pickup advertises, keyed by the issue's
|
|
5229
|
+
* provider-native id so the control plane can correlate a re-dispatch to it
|
|
5230
|
+
* (findSessionByExternalId → "linear:<externalId>"). The Linear analogue of the
|
|
5231
|
+
* GitHub `issue:owner/repo#N` source. */
|
|
5232
|
+
function linearSessionSource(externalId) {
|
|
5233
|
+
return `linear:${externalId}`;
|
|
5234
|
+
}
|
|
5235
|
+
/**
|
|
5236
|
+
* Case B for a queued follow-up the control plane correlated to an existing
|
|
5237
|
+
* session (`targetKind === "existing_session"`): if that session is still live on
|
|
5238
|
+
* this node, continue it as a normal chat — run `prompt` as a follow-up turn and
|
|
5239
|
+
* re-publish its branch/PR — so a channel reply lands in the same thread. The
|
|
5240
|
+
* provider-agnostic analogue of the GitHub issue follow-up (`runIssueFollowUp`);
|
|
5241
|
+
* used by both the Linear and the generic (Slack) pickup paths. When the session
|
|
5242
|
+
* isn't live here (its machine was torn down), best-effort restore its snapshot so
|
|
5243
|
+
* the caller's fresh pickup continues its branch/transcript instead of cold-
|
|
5244
|
+
* starting, and return false so the caller falls through. Returns true only when
|
|
5245
|
+
* it fully handled the item.
|
|
5246
|
+
*/
|
|
5247
|
+
async function continueCorrelatedSession(item, prompt, report) {
|
|
5248
|
+
if (item.targetKind !== "existing_session" || !item.targetSessionId)
|
|
5249
|
+
return false;
|
|
5250
|
+
const record = openSessions.get(item.targetSessionId);
|
|
5251
|
+
if (!record) {
|
|
5252
|
+
await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message));
|
|
5253
|
+
return false;
|
|
5254
|
+
}
|
|
5255
|
+
const branch = record.worktree?.branch;
|
|
5256
|
+
await runSessionTurn(record, prompt);
|
|
5257
|
+
if (record.worktree) {
|
|
5258
|
+
await maybePushWorktreeBranch(record);
|
|
5259
|
+
await maybeDetectPullRequest(record);
|
|
5260
|
+
}
|
|
5261
|
+
await report({
|
|
5262
|
+
output: { sessionId: record.id, branch, prUrl: record.prUrl },
|
|
5263
|
+
events: record.prUrl
|
|
5264
|
+
? [{ at: new Date().toISOString(), kind: "pull_request", summary: "Pull request updated.", ref: branch, url: record.prUrl }]
|
|
5265
|
+
: undefined,
|
|
5266
|
+
});
|
|
5267
|
+
return true;
|
|
5268
|
+
}
|
|
5053
5269
|
async function runWorkItem(item, report) {
|
|
5054
5270
|
if ((item.source === "schedule" || item.source === "manual") && item.body?.startsWith("bivy-room-v1:")) {
|
|
5055
5271
|
const [, nodeId, ...payload] = item.body.split(":");
|
|
@@ -5128,6 +5344,10 @@ async function runWorkItem(item, report) {
|
|
|
5128
5344
|
const parsed = parseRepo(repoSlug);
|
|
5129
5345
|
if (!parsed)
|
|
5130
5346
|
throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
|
|
5347
|
+
// Case B: a re-dispatch the control plane correlated to an existing session
|
|
5348
|
+
// continues it as a normal chat instead of starting cold (mirrors GitHub).
|
|
5349
|
+
if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report))
|
|
5350
|
+
return;
|
|
5131
5351
|
const githubToken = await resolveGitHubToken();
|
|
5132
5352
|
if (!githubToken)
|
|
5133
5353
|
throw new Error("no GitHub token available to clone the Linear issue repository");
|
|
@@ -5138,7 +5358,7 @@ async function runWorkItem(item, report) {
|
|
|
5138
5358
|
const record = await createSession(repoDir, undefined, {
|
|
5139
5359
|
worktree: { branch, base },
|
|
5140
5360
|
makeActive: false,
|
|
5141
|
-
source:
|
|
5361
|
+
source: linearSessionSource(item.externalId),
|
|
5142
5362
|
runtimeId: item.runtimeId || nodeConfiguredDefaultAgent(),
|
|
5143
5363
|
sandbox: normalizeSandboxTier(item.sandbox),
|
|
5144
5364
|
approvalMode: approvalModeFrom(item.approvalMode),
|
|
@@ -5164,6 +5384,13 @@ async function runWorkItem(item, report) {
|
|
|
5164
5384
|
const parsedRepo = item.repo ? parseRepo(item.repo) : undefined;
|
|
5165
5385
|
if (item.repo && !parsedRepo)
|
|
5166
5386
|
throw new Error(`work item ${item.id} has an invalid repo "${item.repo}"`);
|
|
5387
|
+
const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
|
|
5388
|
+
// Case B (provider-agnostic): a follow-up the control plane correlated to an
|
|
5389
|
+
// existing session continues it as a normal chat. Reached by Slack the moment a
|
|
5390
|
+
// reply carries a thread identity the control plane can correlate; a one-shot
|
|
5391
|
+
// slash command has none, so it simply falls through to a fresh session.
|
|
5392
|
+
if (await continueCorrelatedSession(item, request, report))
|
|
5393
|
+
return;
|
|
5167
5394
|
const sessionOpts = {
|
|
5168
5395
|
makeActive: false,
|
|
5169
5396
|
title: item.title,
|
|
@@ -5184,7 +5411,6 @@ async function runWorkItem(item, report) {
|
|
|
5184
5411
|
}
|
|
5185
5412
|
catch { }
|
|
5186
5413
|
}
|
|
5187
|
-
const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
|
|
5188
5414
|
const prompt = parsedRepo || record.worktree
|
|
5189
5415
|
? [
|
|
5190
5416
|
request,
|
|
@@ -5711,6 +5937,48 @@ async function applyRequestedModel(record, model) {
|
|
|
5711
5937
|
broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
|
|
5712
5938
|
}
|
|
5713
5939
|
}
|
|
5940
|
+
// Serialize clone + worktree work per repo directory. Two forks (or a fork and
|
|
5941
|
+
// a GitHub pickup) hitting the same shared clone concurrently race on
|
|
5942
|
+
// `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
|
|
5943
|
+
// see "already exists"/"already checked out" or, worse, `createWorktree`'s
|
|
5944
|
+
// adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
|
|
5945
|
+
// mutex removes the race without a filesystem lock.
|
|
5946
|
+
const repoWorktreeLocks = new Map();
|
|
5947
|
+
async function withRepoLock(key, fn) {
|
|
5948
|
+
const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
|
|
5949
|
+
// Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
|
|
5950
|
+
// failing fork doesn't poison the next waiter's gate. Each caller still awaits
|
|
5951
|
+
// its own `run` and gets its own result/exception. Bounded by repo count.
|
|
5952
|
+
const gate = prev.then(() => { }, () => { });
|
|
5953
|
+
const run = gate.then(fn);
|
|
5954
|
+
repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
|
|
5955
|
+
return run;
|
|
5956
|
+
}
|
|
5957
|
+
/**
|
|
5958
|
+
* Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
|
|
5959
|
+
* the node, so a cross-node fork's committed work travels via origin (the
|
|
5960
|
+
* destination bases its adopted worktree on `origin/<branch>` — see
|
|
5961
|
+
* `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
|
|
5962
|
+
* means the destination falls back to the default base and the dirty patch.
|
|
5963
|
+
*/
|
|
5964
|
+
async function pushForkSourceBranch(rec) {
|
|
5965
|
+
const parts = repoSessionParts(rec);
|
|
5966
|
+
if (!parts)
|
|
5967
|
+
return;
|
|
5968
|
+
const { wt, parsed } = parts;
|
|
5969
|
+
try {
|
|
5970
|
+
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5971
|
+
if (!token)
|
|
5972
|
+
return;
|
|
5973
|
+
const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
|
|
5974
|
+
await pushBranch(cfg, wt.path, wt.branch);
|
|
5975
|
+
rec.branchPushed = true;
|
|
5976
|
+
}
|
|
5977
|
+
catch {
|
|
5978
|
+
// offline / no rights / protected branch — committed work may not reach a
|
|
5979
|
+
// cross-node destination, but the fork still proceeds from the best base.
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5714
5982
|
/**
|
|
5715
5983
|
* Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
|
|
5716
5984
|
* (optional) prerequisite detection, repo/worktree reconstruction, transcript
|
|
@@ -5721,8 +5989,10 @@ async function applyRequestedModel(record, model) {
|
|
|
5721
5989
|
*/
|
|
5722
5990
|
async function standUpFork(opts) {
|
|
5723
5991
|
const { bundle, targetRuntimeId } = opts;
|
|
5724
|
-
const targetRuntime = getRuntime(targetRuntimeId);
|
|
5725
5992
|
const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
|
|
5993
|
+
// Carry the source's sandbox tier so a sandboxed session forks into a
|
|
5994
|
+
// sandboxed one, rather than defaulting to this node's tier (fork.ts).
|
|
5995
|
+
const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
|
|
5726
5996
|
// Credential-move: if the chosen model's provider isn't logged in on this node,
|
|
5727
5997
|
// pull the account model-auth vault (a login done on another node carries over),
|
|
5728
5998
|
// then re-check. Best-effort — a local-only node just skips it.
|
|
@@ -5734,41 +6004,64 @@ async function standUpFork(opts) {
|
|
|
5734
6004
|
modelConfigured = await providerConfigured();
|
|
5735
6005
|
}
|
|
5736
6006
|
// Prerequisite detection. A missing AGENT is a hard blocker — stop before any
|
|
5737
|
-
// clone/worktree work. Skipped for a same-node local fork.
|
|
6007
|
+
// clone/worktree work. Skipped for a same-node local fork. Read the agent's
|
|
6008
|
+
// availability + display name from the runtime REGISTRY (which never throws)
|
|
6009
|
+
// rather than resolving the runtime up front: `getRuntime` throws for a
|
|
6010
|
+
// known-but-not-installed agent, which — called eagerly — surfaced a raw
|
|
6011
|
+
// "not available" string with an empty `missing[]` instead of this friendly
|
|
6012
|
+
// install checklist. An unknown id (no registry entry) is treated as
|
|
6013
|
+
// unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
|
|
5738
6014
|
const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
|
|
5739
|
-
const agentAvailable = agentInfo ? agentInfo.status === "available" :
|
|
6015
|
+
const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
|
|
6016
|
+
const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
|
|
5740
6017
|
const prereqInput = {
|
|
5741
|
-
agent: { id: targetRuntimeId, displayName:
|
|
6018
|
+
agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
|
|
5742
6019
|
...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
|
|
5743
6020
|
};
|
|
5744
6021
|
if (opts.detectPrereqs) {
|
|
5745
6022
|
const early = evaluateForkPrereqs(prereqInput);
|
|
5746
6023
|
if (blockingForkPrereqs(early).length > 0) {
|
|
5747
|
-
return { ok: false, error: `${
|
|
6024
|
+
return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
|
|
5748
6025
|
}
|
|
5749
6026
|
}
|
|
6027
|
+
// Safe now: the agent is available (or this is a same-node local fork whose
|
|
6028
|
+
// agent is self-evidently present). The per-session sandbox tier bakes into
|
|
6029
|
+
// the runtime's launch flags.
|
|
6030
|
+
const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
|
|
5750
6031
|
// Reconstruct repo + worktree when the source was repo-backed.
|
|
5751
6032
|
let workspace = fallback.workspace;
|
|
5752
6033
|
let cwd = fallback.cwd;
|
|
5753
6034
|
let repoReachable;
|
|
5754
6035
|
let worktree;
|
|
6036
|
+
let dirtyWarning;
|
|
5755
6037
|
const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
|
|
5756
6038
|
if (parsed) {
|
|
5757
6039
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
5758
6040
|
repoReachable = Boolean(token);
|
|
5759
6041
|
const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
|
|
5760
6042
|
const srcBranch = bundle.record.branch;
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
6043
|
+
// Serialize clone-adjacent worktree ops on this repo so concurrent forks /
|
|
6044
|
+
// pickups don't race on `git worktree add` or clobber each other's trees.
|
|
6045
|
+
const wt = await withRepoLock(repoDir, async () => {
|
|
6046
|
+
if (opts.worktree === "fresh") {
|
|
6047
|
+
// Same-node fork: cut a NEW branch from the source's LOCAL branch (which
|
|
6048
|
+
// holds its latest, possibly-unpushed commits) or the repo default.
|
|
6049
|
+
const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
|
|
6050
|
+
return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
|
|
6051
|
+
}
|
|
6052
|
+
// Cross-node adopt: the source branch has no LOCAL ref here. Base the
|
|
6053
|
+
// adopted branch on the pushed `origin/<branch>` so committed work travels
|
|
6054
|
+
// (was: undefined → the destination's DEFAULT branch, silently dropping
|
|
6055
|
+
// every commit). Give the worktree DIR a unique suffix so a same-branch
|
|
6056
|
+
// adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
|
|
6057
|
+
// — another live session's tree.
|
|
6058
|
+
const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
|
|
6059
|
+
const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
|
|
6060
|
+
return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
|
|
6061
|
+
});
|
|
6062
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6063
|
+
if (applied.warning)
|
|
6064
|
+
dirtyWarning = applied.warning;
|
|
5772
6065
|
workspace = repoDir;
|
|
5773
6066
|
cwd = wt.path;
|
|
5774
6067
|
worktree = wt;
|
|
@@ -5782,8 +6075,10 @@ async function standUpFork(opts) {
|
|
|
5782
6075
|
const forkRepoRoot = await gitRepoRoot(cwd);
|
|
5783
6076
|
if (forkRepoRoot) {
|
|
5784
6077
|
const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
|
|
5785
|
-
const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
|
|
5786
|
-
applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6078
|
+
const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
|
|
6079
|
+
const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
6080
|
+
if (applied.warning)
|
|
6081
|
+
dirtyWarning = applied.warning;
|
|
5787
6082
|
workspace = forkRepoRoot;
|
|
5788
6083
|
cwd = wt.path;
|
|
5789
6084
|
worktree = wt;
|
|
@@ -5793,8 +6088,8 @@ async function standUpFork(opts) {
|
|
|
5793
6088
|
// transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
|
|
5794
6089
|
const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
|
|
5795
6090
|
const record = plan.kind === "resume"
|
|
5796
|
-
? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false })
|
|
5797
|
-
: await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false });
|
|
6091
|
+
? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
|
|
6092
|
+
: await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
|
|
5798
6093
|
// Mark the new session as a fork of its source, so the run card can show
|
|
5799
6094
|
// "Forked from …" and the lineage survives a reload (persisted below). Just
|
|
5800
6095
|
// the parent's session id — an identifier, not content, so it's safe to
|
|
@@ -5813,6 +6108,10 @@ async function standUpFork(opts) {
|
|
|
5813
6108
|
}
|
|
5814
6109
|
if (bundle.record.title && !record.session.getName())
|
|
5815
6110
|
record.session.setName(bundle.record.title);
|
|
6111
|
+
// Surface a non-fatal note when the source's uncommitted changes didn't apply
|
|
6112
|
+
// cleanly, so the fork isn't silently missing work-in-progress.
|
|
6113
|
+
if (dirtyWarning)
|
|
6114
|
+
broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
|
|
5816
6115
|
await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
|
|
5817
6116
|
persistSessionMetadata(record);
|
|
5818
6117
|
scheduleAdvertise();
|
|
@@ -6324,6 +6623,14 @@ async function sweepDiskGuardrails() {
|
|
|
6324
6623
|
await cleanupOldWorktrees();
|
|
6325
6624
|
evictSharedDepCacheIfNeeded();
|
|
6326
6625
|
warnOversizedWorktrees();
|
|
6626
|
+
const attachmentRefs = referencedAttachmentHashes();
|
|
6627
|
+
if (attachmentRefs)
|
|
6628
|
+
attachmentGcStats = attachmentStore.gc(attachmentRefs);
|
|
6629
|
+
else
|
|
6630
|
+
console.warn("[attachments] skipping garbage collection because event-log references are not healthy");
|
|
6631
|
+
if ((attachmentGcStats.overCapBytes ?? 0) > 0) {
|
|
6632
|
+
console.warn(`[attachments] store remains ${attachmentGcStats.overCapBytes} bytes over cap because referenced history is retained`);
|
|
6633
|
+
}
|
|
6327
6634
|
}
|
|
6328
6635
|
/**
|
|
6329
6636
|
* Prune "ghost" sessions: metadata rows for a path-based runtime (pi) whose
|
|
@@ -6465,6 +6772,9 @@ function closeSessionRecord(record, reason = "closed") {
|
|
|
6465
6772
|
sessionEvents.clear(record.id);
|
|
6466
6773
|
record.session.dispose();
|
|
6467
6774
|
harness.detach(record.id);
|
|
6775
|
+
// Tear down this session's own egress proxy, if it started one (read-only /
|
|
6776
|
+
// workflow network policy). No-op for the default path.
|
|
6777
|
+
void stopSessionEgress(record.id);
|
|
6468
6778
|
record.mcpRestore?.();
|
|
6469
6779
|
openSessions.delete(record.id);
|
|
6470
6780
|
if (record.sessionFile)
|
|
@@ -6699,6 +7009,64 @@ setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
|
|
|
6699
7009
|
// One sweep shortly after boot clears ghosts left by a previous run before any
|
|
6700
7010
|
// client paints its sidebar; the idle timer keeps it clean thereafter.
|
|
6701
7011
|
setTimeout(pruneGhostSessions, 10_000).unref?.();
|
|
7012
|
+
const turnTimeoutMs = configuredTurnTimeoutMs();
|
|
7013
|
+
if (turnTimeoutMs > 0)
|
|
7014
|
+
console.log(`[turn-watchdog] armed: timeout=${turnTimeoutMs}ms`);
|
|
7015
|
+
else
|
|
7016
|
+
console.warn("[turn-watchdog] disabled by BIVY_TURN_TIMEOUT_MS=0");
|
|
7017
|
+
function turnTimeoutMessage() {
|
|
7018
|
+
return `Agent turn timed out after ${Math.round(turnTimeoutMs / 60_000)} minutes and was stopped.`;
|
|
7019
|
+
}
|
|
7020
|
+
function clearTurnWatchdog(record) {
|
|
7021
|
+
if (record.turnWatchdog)
|
|
7022
|
+
clearTimeout(record.turnWatchdog);
|
|
7023
|
+
record.turnWatchdog = undefined;
|
|
7024
|
+
record.turnTimeoutSignal = undefined;
|
|
7025
|
+
record.turnTimeoutResolve = undefined;
|
|
7026
|
+
}
|
|
7027
|
+
function armTurnWatchdog(record) {
|
|
7028
|
+
clearTurnWatchdog(record);
|
|
7029
|
+
record.turnTimedOut = false;
|
|
7030
|
+
if (turnTimeoutMs <= 0)
|
|
7031
|
+
return;
|
|
7032
|
+
record.turnTimeoutSignal = new Promise((resolve) => { record.turnTimeoutResolve = resolve; });
|
|
7033
|
+
record.turnWatchdog = setTimeout(() => {
|
|
7034
|
+
record.turnWatchdog = undefined;
|
|
7035
|
+
record.turnTimedOut = true;
|
|
7036
|
+
record.lastFailureAt = Date.now();
|
|
7037
|
+
const message = turnTimeoutMessage();
|
|
7038
|
+
record.turnTimeoutResolve?.();
|
|
7039
|
+
record.turnTimeoutResolve = undefined;
|
|
7040
|
+
// Clear/persist first so the session and an ephemeral runner cannot remain
|
|
7041
|
+
// pinned in a false working state if the runtime's abort path fails to emit
|
|
7042
|
+
// agent_end. abort() is still invoked to kill the underlying process group.
|
|
7043
|
+
clearSessionWorking(record);
|
|
7044
|
+
metadata.touchSession(record.id, "failed");
|
|
7045
|
+
broadcast({ type: "session.outcome", sessionId: record.id, status: "timed_out", completedAt: new Date().toISOString(), error: message });
|
|
7046
|
+
broadcast({ type: "session.error", sessionId: record.id, error: message });
|
|
7047
|
+
void record.session.abort().catch((error) => {
|
|
7048
|
+
console.error(`[turn-watchdog] abort failed for ${record.id}:`, error);
|
|
7049
|
+
}).finally(() => evaluateEphemeralTeardown());
|
|
7050
|
+
}, turnTimeoutMs);
|
|
7051
|
+
record.turnWatchdog.unref?.();
|
|
7052
|
+
}
|
|
7053
|
+
async function promptWithWatchdog(record, prompt, options) {
|
|
7054
|
+
armTurnWatchdog(record);
|
|
7055
|
+
const timeoutSignal = record.turnTimeoutSignal;
|
|
7056
|
+
try {
|
|
7057
|
+
await Promise.race([
|
|
7058
|
+
record.session.prompt(prompt, options),
|
|
7059
|
+
...(timeoutSignal ? [timeoutSignal.then(() => { throw new Error(turnTimeoutMessage()); })] : []),
|
|
7060
|
+
]);
|
|
7061
|
+
}
|
|
7062
|
+
catch (error) {
|
|
7063
|
+
// The timeout callback already cleared/persisted the session. For an ordinary
|
|
7064
|
+
// prompt failure, disarm here and let the caller publish its actionable error.
|
|
7065
|
+
if (!record.turnTimedOut)
|
|
7066
|
+
clearTurnWatchdog(record);
|
|
7067
|
+
throw error;
|
|
7068
|
+
}
|
|
7069
|
+
}
|
|
6702
7070
|
function markSessionWorking(record, activity) {
|
|
6703
7071
|
touchSession(record);
|
|
6704
7072
|
const wasWorking = record.isWorking;
|
|
@@ -6712,6 +7080,7 @@ function markSessionWorking(record, activity) {
|
|
|
6712
7080
|
scheduleAdvertise(); // idle → working transition
|
|
6713
7081
|
}
|
|
6714
7082
|
function clearSessionWorking(record) {
|
|
7083
|
+
clearTurnWatchdog(record);
|
|
6715
7084
|
touchSession(record);
|
|
6716
7085
|
record.isWorking = false;
|
|
6717
7086
|
record.lastActivity = undefined;
|
|
@@ -6950,7 +7319,7 @@ function attachSessionListeners(record) {
|
|
|
6950
7319
|
getCurrentModelName: () => record.session.getCurrentModel()?.name,
|
|
6951
7320
|
setModel: (p, i) => record.session.setModel(p, i),
|
|
6952
7321
|
reprompt: async () => {
|
|
6953
|
-
await
|
|
7322
|
+
await promptWithWatchdog(record, record.lastPrompt, record.lastPromptOptions);
|
|
6954
7323
|
},
|
|
6955
7324
|
});
|
|
6956
7325
|
}
|
|
@@ -7458,7 +7827,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7458
7827
|
if (makeActive)
|
|
7459
7828
|
active = existing;
|
|
7460
7829
|
broadcast({ type: "session.created", sessionId: existing.id, name: existing.session.getName(), workspace: existing.workspace, sessionFile: existing.sessionFile, source: existing.source, branch: existing.worktree?.branch, prUrl: existing.prUrl, runtimeId: existing.runtimeId, agentName: getRuntime(existing.runtimeId).displayName, bivySession: bivySessionEnvelope(existing), capabilities: capabilitiesWithCommands(existing.runtimeId, existing.session) });
|
|
7461
|
-
void maybeNotifyBivyUpdate(
|
|
7830
|
+
void maybeNotifyBivyUpdate();
|
|
7462
7831
|
return existing;
|
|
7463
7832
|
}
|
|
7464
7833
|
// Pick the agent for this session (fixed for its life). Resuming a tagged
|
|
@@ -7585,6 +7954,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7585
7954
|
// session legitimately starts "active now".
|
|
7586
7955
|
const resumedLastActive = requestedSessionFile ? metaLastActiveMs(storedMeta) : undefined;
|
|
7587
7956
|
const record = { id: sessionId, session, runtimeId: rt.id, sandbox: sessionSandbox, approvalMode: opts.approvalMode, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral };
|
|
7957
|
+
// Apply this session's sandbox network policy as a per-session egress proxy
|
|
7958
|
+
// (its own proxy/decider, never the node-global one). Opt-in via BIVY_SANDBOX_NET:
|
|
7959
|
+
// a read-only session then actually blocks outbound network even for a CLI agent
|
|
7960
|
+
// whose own sandbox doesn't (opencode/aider/goose). No-op otherwise. Fire-and-
|
|
7961
|
+
// forget — a slow proxy listen never delays session creation.
|
|
7962
|
+
void applySessionSandboxEgress(record.id, sessionSandbox, (event) => broadcast({ type: "node.egress", event }));
|
|
7588
7963
|
// Stage 2 slice 4: a re-attached session recovers its still-running TUI
|
|
7589
7964
|
// terminal link (the PTY survives a detach) from the session→terminal registry.
|
|
7590
7965
|
if (attached) {
|
|
@@ -7647,7 +8022,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
7647
8022
|
if (makeActive)
|
|
7648
8023
|
active = record;
|
|
7649
8024
|
broadcast({ type: "session.created", sessionId, name: record.session.getName(), workspace: sessionWorkspace, sessionFile: record.sessionFile, source: record.source, branch: worktree?.branch, prUrl: record.prUrl, runtimeId: rt.id, agentName: rt.displayName, modelFallbackMessage, bivySession: bivySessionEnvelope(record), capabilities: capabilitiesWithCommands(rt.id, record.session) });
|
|
7650
|
-
void maybeNotifyBivyUpdate(
|
|
8025
|
+
void maybeNotifyBivyUpdate();
|
|
7651
8026
|
scheduleAdvertise();
|
|
7652
8027
|
return record;
|
|
7653
8028
|
}
|
|
@@ -7757,32 +8132,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
|
|
|
7757
8132
|
// races the runtime.select that switches the default agent, pin the pill to the
|
|
7758
8133
|
// *previous* runtime (the reported agent-switching bug). Mirror how session.new/
|
|
7759
8134
|
// session.open already refuse to touch `active` for remote clients: reuse a
|
|
7760
|
-
//
|
|
7761
|
-
//
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
8135
|
+
// non-active scratch session per runtime instead of spawning a fresh runtime
|
|
8136
|
+
// process on every picker read.
|
|
8137
|
+
//
|
|
8138
|
+
// Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
|
|
8139
|
+
// Claude) used to evict and re-spawn the one scratch on every switch — the
|
|
8140
|
+
// "switching agent takes a long time before models appear" bug. A map keeps one
|
|
8141
|
+
// warm scratch per runtime so a switch back to an agent already viewed this
|
|
8142
|
+
// session answers from the live session with no re-spawn, and `prefetchModels`
|
|
8143
|
+
// can warm several ahead of the first pick.
|
|
8144
|
+
const modelQueryScratch = new Map();
|
|
8145
|
+
const modelQueryScratchPending = new Map();
|
|
8146
|
+
async function sessionForModelQuery(runtimeId) {
|
|
8147
|
+
const wanted = resolveRuntimeId(runtimeId);
|
|
8148
|
+
// A live active session answers for itself — but only when it IS the runtime
|
|
8149
|
+
// being queried, so a prefetch/draft read for a *different* agent doesn't get
|
|
8150
|
+
// the active session's (wrong-runtime) model list.
|
|
8151
|
+
if (active && active.runtimeId === wanted)
|
|
7766
8152
|
return active;
|
|
7767
|
-
const
|
|
7768
|
-
if (
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
//
|
|
7776
|
-
//
|
|
7777
|
-
//
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
.
|
|
7784
|
-
|
|
7785
|
-
return
|
|
8153
|
+
const cached = modelQueryScratch.get(wanted);
|
|
8154
|
+
if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
|
|
8155
|
+
touchSession(cached);
|
|
8156
|
+
return cached;
|
|
8157
|
+
}
|
|
8158
|
+
// De-dupe concurrent picker reads per runtime. Without this, a WS models.list
|
|
8159
|
+
// and an HTTP GET /api/models fired together on page load both miss the reuse
|
|
8160
|
+
// guard above (the scratch assignment only lands after createSession resolves
|
|
8161
|
+
// ~0.3s later) and each stand up a session, leaving two empty rows a fraction
|
|
8162
|
+
// of a second apart. Collapse concurrent builds onto one promise per runtime,
|
|
8163
|
+
// mirroring resumingSessions.
|
|
8164
|
+
const inflight = modelQueryScratchPending.get(wanted);
|
|
8165
|
+
if (inflight)
|
|
8166
|
+
return inflight;
|
|
8167
|
+
const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
|
|
8168
|
+
.then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
|
|
8169
|
+
.finally(() => { modelQueryScratchPending.delete(wanted); });
|
|
8170
|
+
modelQueryScratchPending.set(wanted, build);
|
|
8171
|
+
return build;
|
|
8172
|
+
}
|
|
8173
|
+
/**
|
|
8174
|
+
* Warm the model-query scratch for one or more runtimes in the background so the
|
|
8175
|
+
* first agent switch to any of them answers instantly instead of paying the
|
|
8176
|
+
* runtime spin-up on the critical path. Fired when the agent picker opens (see
|
|
8177
|
+
* the `models.prefetch` command). Best-effort and de-duped: a runtime already
|
|
8178
|
+
* warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
|
|
8179
|
+
* normal models.list path will surface any real error when the user picks it.
|
|
8180
|
+
*/
|
|
8181
|
+
function prefetchModels(runtimeIds) {
|
|
8182
|
+
const wanted = [];
|
|
8183
|
+
for (const id of runtimeIds) {
|
|
8184
|
+
let resolved;
|
|
8185
|
+
try {
|
|
8186
|
+
resolved = resolveRuntimeId(id);
|
|
8187
|
+
}
|
|
8188
|
+
catch {
|
|
8189
|
+
continue; // unknown/uninstalled agent — nothing to warm
|
|
8190
|
+
}
|
|
8191
|
+
if (wanted.includes(resolved))
|
|
8192
|
+
continue;
|
|
8193
|
+
const cached = modelQueryScratch.get(resolved);
|
|
8194
|
+
if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
|
|
8195
|
+
continue;
|
|
8196
|
+
if (modelQueryScratchPending.has(resolved))
|
|
8197
|
+
continue;
|
|
8198
|
+
wanted.push(resolved);
|
|
8199
|
+
}
|
|
8200
|
+
// Warm serially, not in a burst: spinning up every agent subprocess at once
|
|
8201
|
+
// would spike a small node's memory/CPU right as the user is interacting. Each
|
|
8202
|
+
// build is cached (and de-duped) so this cost is paid at most once per runtime.
|
|
8203
|
+
void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
|
|
7786
8204
|
}
|
|
7787
8205
|
async function createRepoSession(parsed, opts = {}) {
|
|
7788
8206
|
const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
|
|
@@ -8334,6 +8752,9 @@ app.delete("/api/devices/:id", (req, res) => {
|
|
|
8334
8752
|
res.json({ ok: true, devices: pairingStore.listDevices() });
|
|
8335
8753
|
});
|
|
8336
8754
|
app.get("/api/node/info", (_req, res) => {
|
|
8755
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
8756
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
8757
|
+
const structuredControls = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
|
|
8337
8758
|
res.json({
|
|
8338
8759
|
nodeId: identity.nodeId,
|
|
8339
8760
|
name: identity.name,
|
|
@@ -8344,15 +8765,31 @@ app.get("/api/node/info", (_req, res) => {
|
|
|
8344
8765
|
guardrails: {
|
|
8345
8766
|
mode: approvalMode,
|
|
8346
8767
|
defaultAllow: approvalMode === "autonomous" || approvalMode === "never",
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
|
|
8768
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
8769
|
+
protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
|
|
8770
|
+
workspaceBoundary: structuredControls
|
|
8771
|
+
? "Structured file tools are checked against the active workspace; shell commands are not an OS isolation boundary."
|
|
8772
|
+
: "Not guaranteed by Bivy for this runtime. Run it in a container/VM when isolation is required.",
|
|
8773
|
+
denyList: structuredControls
|
|
8774
|
+
? "Known catastrophic shell commands are heuristically blocked; this catches accidents, not adversarial bypasses."
|
|
8775
|
+
: "No universal Bivy command interception is available for this runtime.",
|
|
8776
|
+
strictApprovalOptIn: "Set approval mode to risky or always for prompt-heavy review where this runtime exposes tool controls.",
|
|
8350
8777
|
},
|
|
8351
|
-
runtime: runtimeSummary(getRuntime(
|
|
8778
|
+
runtime: { ...runtimeSummary(getRuntime(selectedRuntimeId)), ...runtimeInfo },
|
|
8352
8779
|
defaultRuntimeId,
|
|
8353
8780
|
sandbox: sandboxInfo(),
|
|
8354
8781
|
});
|
|
8355
8782
|
});
|
|
8783
|
+
// One-tap "Update this node" from the app's version-mismatch banner, for
|
|
8784
|
+
// direct/LAN clients (the relay path uses the RELAY_COMMANDS "node.update"
|
|
8785
|
+
// handler). Both call the same runBivyUpdate.
|
|
8786
|
+
app.post("/api/node/update", (_req, res) => {
|
|
8787
|
+
const result = runBivyUpdate();
|
|
8788
|
+
if (result.ok)
|
|
8789
|
+
res.json({ ok: true });
|
|
8790
|
+
else
|
|
8791
|
+
res.status(500).json({ ok: false, error: result.error });
|
|
8792
|
+
});
|
|
8356
8793
|
// Build collectNodeStats() options, resolving the optional session so the panel
|
|
8357
8794
|
// can attribute a session-scoped tier (its live agent process + workspace size).
|
|
8358
8795
|
function nodeStatsOptsFor(sessionId) {
|
|
@@ -8386,8 +8823,38 @@ function sandboxInfo() {
|
|
|
8386
8823
|
tier: sandboxTier(),
|
|
8387
8824
|
};
|
|
8388
8825
|
}
|
|
8826
|
+
// Redacted diagnostics bundle (B4d) — a shareable support export with no secrets,
|
|
8827
|
+
// prompts, transcripts, diffs, or repo content: versions, health counters, a
|
|
8828
|
+
// whitelisted set of config flags, and the activation stage record.
|
|
8829
|
+
app.get("/api/diagnostics", (_req, res) => {
|
|
8830
|
+
const relayConfig = loadRelayConfig(appDir);
|
|
8831
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
8832
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
8833
|
+
const report = buildDiagnosticsReport({
|
|
8834
|
+
version: currentVersion() ?? undefined,
|
|
8835
|
+
platform: process.platform,
|
|
8836
|
+
nodeVersion: process.version,
|
|
8837
|
+
relayConfigured: Boolean(relayConfig),
|
|
8838
|
+
health: {
|
|
8839
|
+
sessionsOpen: new Set(openSessions.values()).size,
|
|
8840
|
+
sessionsIndexed: metadata.listSessions().length,
|
|
8841
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
8842
|
+
approvalMode,
|
|
8843
|
+
relayConnected: Boolean(relay?.connected),
|
|
8844
|
+
},
|
|
8845
|
+
env: process.env,
|
|
8846
|
+
// The node knows it is online and which runtime is selectable; the client's
|
|
8847
|
+
// setup readiness fills the rest. This baseline still records the golden path.
|
|
8848
|
+
activation: activationRecord({ nodeOnline: true, runtimeReady: Boolean(runtimeInfo) }),
|
|
8849
|
+
generatedAt: new Date().toISOString(),
|
|
8850
|
+
});
|
|
8851
|
+
res.json(report);
|
|
8852
|
+
});
|
|
8389
8853
|
app.get("/api/status", (_req, res) => {
|
|
8390
8854
|
const relayConfig = loadRelayConfig(appDir);
|
|
8855
|
+
const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
|
|
8856
|
+
const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
|
|
8857
|
+
const workspaceBoundary = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
|
|
8391
8858
|
res.json({
|
|
8392
8859
|
ok: true,
|
|
8393
8860
|
nodeId: identity.nodeId,
|
|
@@ -8402,7 +8869,9 @@ app.get("/api/status", (_req, res) => {
|
|
|
8402
8869
|
approvalMode,
|
|
8403
8870
|
guardrails: {
|
|
8404
8871
|
autonomousDefault: approvalMode === "autonomous",
|
|
8405
|
-
workspaceBoundary
|
|
8872
|
+
workspaceBoundary,
|
|
8873
|
+
enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
|
|
8874
|
+
protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
|
|
8406
8875
|
strictApprovalOptIn: true,
|
|
8407
8876
|
},
|
|
8408
8877
|
relay: {
|
|
@@ -8424,6 +8893,9 @@ app.get("/api/status", (_req, res) => {
|
|
|
8424
8893
|
},
|
|
8425
8894
|
devices: { paired: pairingStore.listDevices().length, localTokens: identity.listDevices().length },
|
|
8426
8895
|
approvals: { pending: approvals.list().filter((a) => a.status === "pending").length, recent: metadata.listApprovals(20) },
|
|
8896
|
+
eventLog: { ...eventLog.diskUsage(), ...eventLog.health(), affectedSessions: eventLogIssues.size },
|
|
8897
|
+
attachments: attachmentGcStats,
|
|
8898
|
+
turnWatchdog: { enabled: turnTimeoutMs > 0, timeoutMs: turnTimeoutMs },
|
|
8427
8899
|
updatedAt: new Date().toISOString(),
|
|
8428
8900
|
});
|
|
8429
8901
|
});
|
|
@@ -8619,10 +9091,13 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8619
9091
|
try {
|
|
8620
9092
|
const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
|
|
8621
9093
|
const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
|
|
9094
|
+
const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
|
|
8622
9095
|
let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
|
|
8623
9096
|
if (requestedSessionId && !record)
|
|
8624
9097
|
return res.status(404).json({ error: "Session not found" });
|
|
8625
|
-
record
|
|
9098
|
+
if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
|
|
9099
|
+
record = undefined;
|
|
9100
|
+
record ??= await sessionForModelQuery(wantedRuntimeId);
|
|
8626
9101
|
const session = record.session;
|
|
8627
9102
|
const current = session.getCurrentModel();
|
|
8628
9103
|
const models = await publicModelsList(session, current);
|
|
@@ -8633,6 +9108,17 @@ app.get("/api/models", async (req, res, next) => {
|
|
|
8633
9108
|
next(error);
|
|
8634
9109
|
}
|
|
8635
9110
|
});
|
|
9111
|
+
// Warm the per-runtime model-query scratch ahead of the first agent switch (see
|
|
9112
|
+
// prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
|
|
9113
|
+
// up in the background, so the picker never blocks on it.
|
|
9114
|
+
app.post("/api/models/prefetch", (req, res) => {
|
|
9115
|
+
const ids = Array.isArray(req.body?.runtimeIds)
|
|
9116
|
+
? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
|
|
9117
|
+
: [];
|
|
9118
|
+
if (ids.length)
|
|
9119
|
+
prefetchModels(ids);
|
|
9120
|
+
res.json({ ok: true });
|
|
9121
|
+
});
|
|
8636
9122
|
app.post("/api/models/select", async (req, res, next) => {
|
|
8637
9123
|
try {
|
|
8638
9124
|
const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
|
|
@@ -9934,7 +10420,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
|
|
|
9934
10420
|
broadcast({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: req.body?.clientMessageId });
|
|
9935
10421
|
void maybeNameSession(record, promptText);
|
|
9936
10422
|
harnessBeginTurn(record);
|
|
9937
|
-
await
|
|
10423
|
+
await promptWithWatchdog(record, agentPrompt, promptOptionsFor(record, req.body?.streamingBehavior, images));
|
|
9938
10424
|
}).catch((error) => {
|
|
9939
10425
|
clearSessionWorking(record);
|
|
9940
10426
|
broadcast({ type: "session.error", sessionId: record.id, error: String(error?.stack ?? error) });
|
|
@@ -10218,6 +10704,14 @@ wss.on("connection", (socket, req) => {
|
|
|
10218
10704
|
// sharing a PTY size it to their min (see TerminalManager.setClientSize).
|
|
10219
10705
|
const clientTerminalId = `sock-${randomUUID()}`;
|
|
10220
10706
|
socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
|
|
10707
|
+
// Authoritative version status on every connect: `latest` set means this node
|
|
10708
|
+
// is behind (banner shows); absent means up to date (banner + any "Updating…"
|
|
10709
|
+
// state clear — this is how the banner disappears after an update lands and
|
|
10710
|
+
// the socket reconnects on the new build). Then (re)run the throttled check so
|
|
10711
|
+
// a freshly-opened app surfaces a newly-available update without waiting for a
|
|
10712
|
+
// session turn.
|
|
10713
|
+
socket.send(JSON.stringify({ type: "node.update", current: currentVersion() ?? "", latest: pendingBivyUpdate?.latest }));
|
|
10714
|
+
void checkBivyUpdate();
|
|
10221
10715
|
socket.on("message", (raw) => {
|
|
10222
10716
|
let msg;
|
|
10223
10717
|
try {
|