@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/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
- let updateNoticeSentFor = "";
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
- async function maybeNotifyBivyUpdate(record) {
625
- // The daemon creates an initial session during startup before any UI is
626
- // connected. Don't consume the once-per-version notice until someone can see it.
627
- if (clients.size === 0 && !relay)
628
- return;
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 latestVersion = (await res.json()).version;
641
- if (!latestVersion || !isNewerVersion(latestVersion, current))
642
- return;
643
- if (updateNoticeSentFor === latestVersion)
646
+ const latest = (await res.json()).version;
647
+ if (!latest || !isNewerVersion(latest, current))
644
648
  return;
645
- updateNoticeSentFor = latestVersion;
646
- const label = ` ${latestVersion}`;
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
- for (const raw of value.slice(0, 12)) {
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
- const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets);
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 attachmentStore = new AttachmentStore(path.join(appDir, "attachments"));
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
- record ??= await sessionForModelQuery();
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.session.prompt(agentPrompt, record.lastPromptOptions);
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" : stage === "started" ? "branch" : stage === "failed" ? "completed" : undefined;
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: stage === "pr_opened" ? "Pull request opened." : stage === "started" ? "Working branch and session created." : "Execution failed. Detailed diagnostics remain on the node.",
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}; checking for changes.`);
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}; checking for changes.`);
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.session.prompt(prompt);
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: "queue:linear:issue",
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" : true;
6015
+ const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
6016
+ const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
5740
6017
  const prereqInput = {
5741
- agent: { id: targetRuntimeId, displayName: targetRuntime.displayName, available: agentAvailable },
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: `${targetRuntime.displayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
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
- let wt;
5762
- if (opts.worktree === "fresh") {
5763
- // Cut a new branch from the source branch (or the repo's default base).
5764
- const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5765
- wt = await createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5766
- }
5767
- else {
5768
- // Adopt the source branch, or a fresh random worktree when it had none.
5769
- wt = await createWorktree({ repoDir, id: srcBranch ?? `fork-${randomBytes(6).toString("hex")}`, branch: srcBranch, base: srcBranch ? undefined : await resolveDefaultBaseRef(repoDir) });
5770
- }
5771
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
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 record.session.prompt(record.lastPrompt, record.lastPromptOptions);
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(existing);
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(record);
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
- // single non-active scratch session on the current default runtime instead of
7761
- // spawning a fresh runtime process on every picker read.
7762
- let modelQueryScratch;
7763
- let modelQueryScratchPending;
7764
- async function sessionForModelQuery() {
7765
- if (active)
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 wanted = resolveRuntimeId();
7768
- if (modelQueryScratch &&
7769
- openSessions.has(modelQueryScratch.id) &&
7770
- modelQueryScratch.runtimeId === wanted &&
7771
- !sessionBusy(modelQueryScratch)) {
7772
- touchSession(modelQueryScratch);
7773
- return modelQueryScratch;
7774
- }
7775
- // De-dupe concurrent picker reads. Without this, a WS models.list and an HTTP
7776
- // GET /api/models fired together on page load both miss the reuse guard above
7777
- // (the scratch assignment only lands after createSession resolves ~0.3s later)
7778
- // and each stand up a session, leaving two empty rows a fraction of a second
7779
- // apart. Collapse concurrent builds onto one promise, mirroring resumingSessions.
7780
- if (modelQueryScratchPending)
7781
- return modelQueryScratchPending;
7782
- modelQueryScratchPending = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true })
7783
- .then((rec) => { modelQueryScratch = rec; return rec; })
7784
- .finally(() => { modelQueryScratchPending = undefined; });
7785
- return modelQueryScratchPending;
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
- workspaceBoundary: "Writes outside the active workspace/worktree are denied.",
8348
- denyList: "Catastrophic/destructive commands and privilege escalation are blocked or require approval.",
8349
- strictApprovalOptIn: "Set approval mode to risky or always for prompt-heavy review.",
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(active?.runtimeId ?? defaultRuntimeId)),
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: true,
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 ??= await sessionForModelQuery();
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 session.prompt(agentPrompt, promptOptionsFor(record, req.body?.streamingBehavior, images));
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 {