@bivy/bivy 0.6.0 → 0.7.0-staging.100

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";
@@ -497,11 +501,11 @@ const terminals = new TerminalManager();
497
501
  // and fixed for that session's life; switching agents in the UI starts a new one.
498
502
  let defaultRuntimeId = (process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
499
503
  const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir, attachToChat: attachToChatForSession });
500
- // In-session model reroute (docs/rulesets.md). Opt-in: set
501
- // BIVY_SESSION_MODEL_FALLBACK to a comma-separated model list and a session that
502
- // hits an exhausted-credits / rate-limit turn error swaps down the list (via the
503
- // runtime's live setModel) and retries, instead of surfacing the error. Absent =
504
- // inert, session behavior unchanged.
504
+ // A built-in in-session model-fallback ruleset from BIVY_SESSION_MODEL_FALLBACK
505
+ // (docs/rulesets.md). Opt-in: set it to a comma-separated model list and a
506
+ // session that hits an exhausted-credits / rate-limit turn error swaps down the
507
+ // list (via the runtime's live setModel) and retries. Used only when the user
508
+ // hasn't authored their own session-scoped ruleset in the UI.
505
509
  function sessionModelFallbackRuleset() {
506
510
  const models = (process.env.BIVY_SESSION_MODEL_FALLBACK ?? "")
507
511
  .split(",")
@@ -525,13 +529,27 @@ function sessionModelFallbackRuleset() {
525
529
  ],
526
530
  };
527
531
  }
528
- const sessionRuleset = sessionModelFallbackRuleset();
529
- const sessionRunPolicy = sessionRuleset ? createRunPolicy({ ruleset: sessionRuleset, context: "session" }) : undefined;
530
- if (sessionRunPolicy) {
532
+ /** The ruleset in-session recovery runs under right now: the user's active
533
+ * ruleset if it applies to sessions, else the env model-fallback ruleset, else
534
+ * undefined (→ built-in DEFAULT_RULESET). Read lazily on each turn error so UI
535
+ * edits take effect without a restart, mirroring activeQueueRuleset. */
536
+ function activeSessionRuleset() {
537
+ return activeRulesetFor(rulesetsDir, "session") ?? sessionModelFallbackRuleset();
538
+ }
539
+ // The in-session recovery effector's policy. Always available: an interactive
540
+ // session can wait out a provider usage/rate limit and resume when it resets
541
+ // (planResume), or swap models down a fallback chain (planReroute). Thin wrapper
542
+ // so a freshly-saved active ruleset is picked up on the next turn error.
543
+ const sessionRunPolicy = {
544
+ decide: (ctx) => createRunPolicy({ context: "session", ruleset: activeSessionRuleset() }).decide(ctx),
545
+ };
546
+ if (process.env.BIVY_SESSION_MODEL_FALLBACK) {
531
547
  console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
532
548
  }
533
549
  let lastUpdateCheckAt = 0;
534
- let updateNoticeSentFor = "";
550
+ // The most recent "this node is behind" finding, so a client that connects after
551
+ // the check already ran still gets the banner (replayed on connect below).
552
+ let pendingBivyUpdate = null;
535
553
  function runtimeSummary(rt) {
536
554
  return runtimeHost.summary(rt);
537
555
  }
@@ -621,11 +639,11 @@ function readJsonFile(file) {
621
639
  return undefined;
622
640
  }
623
641
  }
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;
642
+ // Poll npm for a newer release (throttled to every 6h). On finding one, remember
643
+ // it and push a dedicated `node.update` event so every connected app can show a
644
+ // banner with a one-tap "Update this node" button (see runBivyUpdate). Safe to
645
+ // call from anywhere — never throws, never interrupts a session.
646
+ async function checkBivyUpdate() {
629
647
  const now = Date.now();
630
648
  if (now - lastUpdateCheckAt < 6 * 60 * 60 * 1000)
631
649
  return;
@@ -637,25 +655,48 @@ async function maybeNotifyBivyUpdate(record) {
637
655
  const res = await fetch(updateRegistryUrl, { signal: AbortSignal.timeout(5000) });
638
656
  if (!res.ok)
639
657
  return;
640
- const latestVersion = (await res.json()).version;
641
- if (!latestVersion || !isNewerVersion(latestVersion, current))
642
- return;
643
- if (updateNoticeSentFor === latestVersion)
658
+ const latest = (await res.json()).version;
659
+ if (!latest || !isNewerVersion(latest, current))
644
660
  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
- });
661
+ pendingBivyUpdate = { current, latest };
662
+ broadcast({ type: "node.update", current, latest });
654
663
  }
655
664
  catch {
656
665
  // Best-effort update checks should never interrupt a session.
657
666
  }
658
667
  }
668
+ async function maybeNotifyBivyUpdate() {
669
+ // The daemon creates an initial session during startup before any UI is
670
+ // connected. Don't spend a check until someone can see the banner.
671
+ if (clients.size === 0 && !relay)
672
+ return;
673
+ await checkBivyUpdate();
674
+ }
675
+ // Run `bivy update` on this node, the same command a user would type. The CLI
676
+ // re-spawns itself detached, waits for any in-flight turn, updates, and restarts
677
+ // the service (logging to update.log), so we just fire-and-forget it here. The
678
+ // bin ships next to this server bundle in both the git checkout (src/server.ts)
679
+ // and the published package (dist/server.js), so repoRoot/bin/bivy.mjs resolves
680
+ // in both. Returns a friendly error instead of throwing when it can't be found
681
+ // (e.g. an unusual layout), so the banner can fall back to the manual command.
682
+ function runBivyUpdate() {
683
+ const script = path.join(repoRoot, "bin", "bivy.mjs");
684
+ if (!fs.existsSync(script)) {
685
+ return { ok: false, error: "Could not locate the bivy CLI on this node — run `bivy update` in a terminal." };
686
+ }
687
+ try {
688
+ const child = spawn(process.execPath, [script, "update"], {
689
+ detached: true,
690
+ stdio: "ignore",
691
+ env: process.env,
692
+ });
693
+ child.unref();
694
+ return { ok: true };
695
+ }
696
+ catch (error) {
697
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
698
+ }
699
+ }
659
700
  function runtimeInstallSpec(requested) {
660
701
  let id = String(requested ?? "").trim().toLowerCase();
661
702
  // Normalize a few historical aliases to their canonical runtime id.
@@ -921,6 +962,8 @@ function safeAttachmentName(value) {
921
962
  * with its normal file tools. Any file type is supported;
922
963
  * binary files arrive as base64 `data`.
923
964
  */
965
+ const MAX_PROMPT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
966
+ const MAX_PROMPT_ATTACHMENTS_BYTES = 40 * 1024 * 1024;
924
967
  function attachmentsFrom(value) {
925
968
  if (!Array.isArray(value))
926
969
  return { images: [], imageNotes: [], imageRefs: [], files: [] };
@@ -928,13 +971,24 @@ function attachmentsFrom(value) {
928
971
  const imageNotes = [];
929
972
  const imageRefs = [];
930
973
  const files = [];
931
- for (const raw of value.slice(0, 12)) {
974
+ let totalBytes = 0;
975
+ if (value.length > 12)
976
+ throw new Error("A message can include at most 12 attachments");
977
+ for (const raw of value) {
932
978
  if (!raw || typeof raw !== "object")
933
979
  continue;
934
980
  const attachment = raw;
935
981
  const name = safeAttachmentName(attachment.name);
936
982
  const size = Number(attachment.size || 0);
937
983
  const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
984
+ const encodedBytes = typeof attachment.data === "string" ? Math.floor(attachment.data.length * 3 / 4) : 0;
985
+ const textBytes = attachment.kind === "file" && typeof attachment.text === "string" ? Buffer.byteLength(attachment.text) : 0;
986
+ const actualBytes = encodedBytes || textBytes;
987
+ if (actualBytes > MAX_PROMPT_ATTACHMENT_BYTES)
988
+ throw new Error(`${name} exceeds the 10 MiB attachment limit`);
989
+ totalBytes += actualBytes;
990
+ if (totalBytes > MAX_PROMPT_ATTACHMENTS_BYTES)
991
+ throw new Error("Attachments exceed the 40 MiB per-message limit");
938
992
  if (attachment.kind === "image" && typeof attachment.data === "string") {
939
993
  const imgMime = mimeType ?? "image/png";
940
994
  images.push({ type: "image", data: attachment.data, mimeType: imgMime });
@@ -2179,14 +2233,58 @@ function eventLogPath(sessionId) {
2179
2233
  // whole history: overlay detail (reasoning + tool activity) AND the base transcript,
2180
2234
  // the latter as bounded delta/reset records. Written on every event; read via
2181
2235
  // 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);
2236
+ // choke point before anything lands on the synced-to-PWA disk. I/O/corruption
2237
+ // failures are never silently converted into empty history: keep a diagnostic,
2238
+ // log loudly, and notify the owning live session while pending appends remain
2239
+ // queued for retry.
2240
+ const eventLogIssues = new Map();
2241
+ const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets, 500, (issue) => {
2242
+ eventLogIssues.set(issue.sessionId, { operation: issue.operation, message: issue.message, at: issue.at });
2243
+ console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`);
2244
+ const record = openSessions.get(issue.sessionId);
2245
+ if (!record)
2246
+ return;
2247
+ const warning = `Session history storage problem (${issue.operation}): ${issue.message}`;
2248
+ if (record.warning === warning)
2249
+ return;
2250
+ record.warning = warning;
2251
+ broadcast({ type: "session.notice", sessionId: record.id, level: "error", message: warning });
2252
+ });
2184
2253
  // Global content-addressed store for message attachments (images + files). Unlike
2185
2254
  // the per-session `.bivy-attachments/` worktree copy (kept so the agent can open
2186
2255
  // files with its tools), this is durable, session-independent, and re-findable:
2187
2256
  // the transcript references blobs by hash, and clients rehydrate thumbnails by
2188
2257
  // hash after a reload or on another device. See src/session/attachment-store.ts.
2189
- const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"));
2258
+ const positiveEnvNumber = (name, fallback) => {
2259
+ const value = Number(process.env[name]);
2260
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
2261
+ };
2262
+ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
2263
+ maxFileBytes: positiveEnvNumber("BIVY_ATTACHMENT_MAX_FILE_BYTES", 25 * 1024 * 1024),
2264
+ maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
2265
+ retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
2266
+ });
2267
+ let attachmentGcStats = attachmentStore.stats();
2268
+ function referencedAttachmentHashes() {
2269
+ // If transcript history is unreadable, collecting nothing would make its
2270
+ // still-referenced blobs look orphaned. Fail closed and skip destructive GC.
2271
+ if (!eventLog.health().ok)
2272
+ return null;
2273
+ const hashes = new Set();
2274
+ const ids = new Set(metadata.listSessions().map((session) => session.id));
2275
+ for (const record of new Set(openSessions.values()))
2276
+ ids.add(record.id);
2277
+ for (const id of ids) {
2278
+ for (const entry of eventLog.entries(id)) {
2279
+ if (entry.bivyKind === "attachment")
2280
+ for (const ref of entry.refs)
2281
+ hashes.add(ref.hash);
2282
+ else if (entry.bivyKind === "outbound-attachment" || entry.bivyKind === "inline-image")
2283
+ hashes.add(entry.ref.hash);
2284
+ }
2285
+ }
2286
+ return eventLog.health().ok ? hashes : null;
2287
+ }
2190
2288
  // --- Warm session replication (docs/session-replication.md) -----------------
2191
2289
  // A standby's replica repo lives under appDir/replicas/<id>: a self-contained git
2192
2290
  // repo that receives checkpoint bundles and is checked out on promotion. Created
@@ -2536,6 +2634,14 @@ const RELAY_COMMANDS = {
2536
2634
  ping(msg, ctx) {
2537
2635
  ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
2538
2636
  },
2637
+ // Kick off `bivy update` on this node from the app's version-mismatch banner
2638
+ // (see runBivyUpdate). The node restarts itself when the update lands, so the
2639
+ // client just sees the socket reconnect on the new build; a failure to even
2640
+ // start reports back so the banner can show the manual command.
2641
+ "node.update"(_msg, ctx) {
2642
+ const result = runBivyUpdate();
2643
+ ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
2644
+ },
2539
2645
  // Fetch a stored attachment's bytes by content hash. The relay client (a phone
2540
2646
  // not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
2541
2647
  // over the encrypted tunnel instead; the relay framing chunks the base64 payload
@@ -2601,6 +2707,30 @@ const RELAY_COMMANDS = {
2601
2707
  ctx.reply({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
2602
2708
  }
2603
2709
  },
2710
+ async "session.revert_file"(msg, ctx) {
2711
+ // C3d — revert ONE changed file to its pre-turn content without rewinding the
2712
+ // whole turn. `content` is the file's pre-turn text (or null when the turn
2713
+ // added it). Path-confined to the session's worktree by revertFile.
2714
+ const record = resolveSession(msg.sessionId);
2715
+ const relPath = String(msg.path ?? "").trim();
2716
+ if (!record || !relPath)
2717
+ return;
2718
+ if (sessionBusy(record)) {
2719
+ ctx.reply({ type: "session.error", sessionId: record.id, error: "Stop the current turn before reverting a file." });
2720
+ return;
2721
+ }
2722
+ const content = typeof msg.content === "string" ? msg.content : null;
2723
+ const result = revertFile(harnessDirFor(record), relPath, content);
2724
+ if (!result.ok) {
2725
+ ctx.reply({ type: "session.error", sessionId: record.id, error: `Could not revert ${relPath}: ${result.error ?? "unknown error"}` });
2726
+ return;
2727
+ }
2728
+ // Recompute the turn's diff against the (unchanged) baseline so the review
2729
+ // surface drops the reverted file immediately.
2730
+ const event = { type: "session.file_reverted", sessionId: record.id, path: relPath, status: result.status };
2731
+ ctx.reply(event);
2732
+ ctx.broadcast(event);
2733
+ },
2604
2734
  async "session.pr.refresh"(msg, ctx) {
2605
2735
  // Force a refresh regardless of live/attached state — resume the session if
2606
2736
  // the node dropped it from memory, so a finished/detached session can still
@@ -2966,6 +3096,7 @@ const RELAY_COMMANDS = {
2966
3096
  },
2967
3097
  async "models.list"(msg) {
2968
3098
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
3099
+ const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
2969
3100
  let record;
2970
3101
  try {
2971
3102
  record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
@@ -2978,7 +3109,12 @@ const RELAY_COMMANDS = {
2978
3109
  relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
2979
3110
  return;
2980
3111
  }
2981
- record ??= await sessionForModelQuery();
3112
+ // On a draft (no session id), a runtime hint from the composer takes
3113
+ // precedence so an agent switch previews *that* agent's models even if a
3114
+ // stale `active` on another runtime lingers on the node.
3115
+ if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
3116
+ record = null;
3117
+ record ??= await sessionForModelQuery(wantedRuntimeId);
2982
3118
  const session = record.session;
2983
3119
  const current = session.getCurrentModel();
2984
3120
  const models = await publicModelsList(session, current);
@@ -2990,6 +3126,17 @@ const RELAY_COMMANDS = {
2990
3126
  // (e.g. Claude) — the "Claude shows Codex models" bug.
2991
3127
  relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
2992
3128
  },
3129
+ "models.prefetch"(msg) {
3130
+ // The composer's agent picker opened: warm the scratch session for each
3131
+ // offered agent in the background so the first switch to any of them answers
3132
+ // instantly. Fire-and-forget — no reply; the follow-up models.list carries
3133
+ // the result. Ignore anything but a bounded string[] of runtime ids.
3134
+ const ids = Array.isArray(msg.runtimeIds)
3135
+ ? msg.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
3136
+ : [];
3137
+ if (ids.length)
3138
+ prefetchModels(ids);
3139
+ },
2993
3140
  async "model.select"(msg) {
2994
3141
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
2995
3142
  let record;
@@ -3407,7 +3554,10 @@ const RELAY_COMMANDS = {
3407
3554
  record.lastPrompt = agentPrompt;
3408
3555
  record.lastPromptOptions = promptOptionsFor(record, msg.streamingBehavior, images);
3409
3556
  record.reroute?.beginTurn();
3410
- await record.session.prompt(agentPrompt, record.lastPromptOptions);
3557
+ // The user is driving this turn manually — supersede any pending auto-resume
3558
+ // that was scheduled after a prior limit so it can't re-fire on top of them.
3559
+ clearSessionResume(record.id);
3560
+ await promptWithWatchdog(record, agentPrompt, record.lastPromptOptions);
3411
3561
  }).catch((error) => {
3412
3562
  // Mirror the HTTP path (see the /prompt route): a rejected turn after
3413
3563
  // the runtime marked the session working emits no agent_end, so without
@@ -3442,6 +3592,7 @@ const RELAY_COMMANDS = {
3442
3592
  source: rec.source,
3443
3593
  title: rec.session.getName(),
3444
3594
  model: rec.session.getCurrentModel()?.name,
3595
+ sandbox: rec.sandbox,
3445
3596
  };
3446
3597
  let dirtyPatch;
3447
3598
  if (rec.worktree) {
@@ -3450,6 +3601,14 @@ const RELAY_COMMANDS = {
3450
3601
  }
3451
3602
  catch { /* best effort — omit dirty state */ }
3452
3603
  }
3604
+ // Publish the source branch so a cross-node fork's COMMITTED work travels
3605
+ // via origin (the destination adopts `origin/<branch>`; see
3606
+ // resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
3607
+ // for a genuine cross-node fork — a same-node cross-agent fork adopts the
3608
+ // LOCAL branch and needs no push. Best-effort: a no-token/offline node just
3609
+ // falls back to the default base downstream.
3610
+ if (msg.crossNode === true)
3611
+ await pushForkSourceBranch(rec);
3453
3612
  // Refresh the account model-auth vault so the destination node can pull
3454
3613
  // this session's model credentials during import (fork credential-move,
3455
3614
  // docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
@@ -3530,6 +3689,7 @@ const RELAY_COMMANDS = {
3530
3689
  source: rec.source,
3531
3690
  title: rec.session.getName(),
3532
3691
  model: rec.session.getCurrentModel()?.name,
3692
+ sandbox: rec.sandbox,
3533
3693
  };
3534
3694
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
3535
3695
  // re-applies it into the fork's fresh worktree. Local git ops only.
@@ -4481,16 +4641,26 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4481
4641
  // evidence trail — the branch/PR references and a bounded summary only,
4482
4642
  // never file lists or error details (those stay in `message`/`extra`,
4483
4643
  // 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;
4644
+ const kind = stage === "pr_opened" ? "pull_request"
4645
+ : stage === "started" || stage === "pushed" ? "branch"
4646
+ : stage === "failed" || stage === "checks_failed" || stage === "no_changes" ? "completed"
4647
+ : undefined;
4485
4648
  if (kind) {
4649
+ const summary = stage === "pr_opened" ? "Pull request opened."
4650
+ : stage === "started" ? "Working branch and session created."
4651
+ : stage === "pushed" ? "Changes pushed; no pull request is open."
4652
+ : stage === "no_changes" ? "Run completed with no file changes."
4653
+ : stage === "checks_failed" ? "Deterministic validation checks failed."
4654
+ : "Execution failed. Detailed diagnostics remain on the node.";
4486
4655
  void overrides.onEvidence?.({
4487
4656
  output: { sessionId: record.id, branch, prUrl: typeof extra.prUrl === "string" ? extra.prUrl : undefined },
4488
4657
  events: [{
4489
4658
  at: new Date().toISOString(),
4490
4659
  kind,
4491
- summary: stage === "pr_opened" ? "Pull request opened." : stage === "started" ? "Working branch and session created." : "Execution failed. Detailed diagnostics remain on the node.",
4660
+ summary,
4492
4661
  ref: branch,
4493
4662
  url: typeof extra.prUrl === "string" ? extra.prUrl : undefined,
4663
+ ...(stage === "checks_failed" || stage === "failed" ? { status: "failed" } : {}),
4494
4664
  }],
4495
4665
  });
4496
4666
  }
@@ -4503,7 +4673,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4503
4673
  // which now adopts the existing remote branch rather than colliding.
4504
4674
  const existing = findIssueSession(source);
4505
4675
  if (existing?.worktree && fs.existsSync(existing.worktree.path)) {
4506
- return runIssueFollowUp(cfg, issue, existing, emit);
4676
+ return runIssueFollowUp(cfg, issue, existing, emit, overrides);
4507
4677
  }
4508
4678
  // Idempotency guard against the duplicate-PR regression: if this issue's
4509
4679
  // deterministic branch already produced a *merged* pull request, the change has
@@ -4585,8 +4755,8 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4585
4755
  try {
4586
4756
  emit(record, "started", `Started work on ${cfg.owner}/${cfg.repo}#${issue.number}.`);
4587
4757
  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 });
4758
+ emit(record, "agent_done", `Agent finished issue #${issue.number}; running deterministic checks.`);
4759
+ await reportIssueOutcome(cfg, issue, record, emit, { followUp: false, onEvidence: overrides.onEvidence });
4590
4760
  }
4591
4761
  catch (error) {
4592
4762
  emit(record, "failed", `GitHub issue #${issue.number} failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -4599,7 +4769,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4599
4769
  * the new comment as another turn in the same worktree, then report the outcome
4600
4770
  * the same way a fresh pickup does.
4601
4771
  */
4602
- async function runIssueFollowUp(cfg, issue, record, emit) {
4772
+ async function runIssueFollowUp(cfg, issue, record, emit, overrides = {}) {
4603
4773
  const wt = record.worktree;
4604
4774
  if (!wt)
4605
4775
  throw new Error("issue session has no worktree");
@@ -4628,8 +4798,8 @@ async function runIssueFollowUp(cfg, issue, record, emit) {
4628
4798
  }
4629
4799
  }
4630
4800
  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 });
4801
+ emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number}; running deterministic checks.`);
4802
+ await reportIssueOutcome(cfg, issue, record, emit, { followUp: true, onEvidence: overrides.onEvidence });
4633
4803
  }
4634
4804
  catch (error) {
4635
4805
  emit(record, "failed", `GitHub issue #${issue.number} follow-up failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -4655,6 +4825,26 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
4655
4825
  const wt = record.worktree;
4656
4826
  if (!wt)
4657
4827
  throw new Error("issue session has no worktree");
4828
+ // Customer success is not `agent_end`. Run the repository's declared standard
4829
+ // checks under local time/output bounds and report only privacy-safe metadata
4830
+ // (name/hash/status/exit), never command text or output, to the control plane.
4831
+ const checks = runRequiredAutomationChecks(wt.path);
4832
+ if (checks.length > 0) {
4833
+ const failed = checks.filter((check) => check.status === "failed");
4834
+ await opts.onEvidence?.({
4835
+ checks,
4836
+ events: [{
4837
+ at: new Date().toISOString(),
4838
+ kind: "completed",
4839
+ summary: failed.length ? `${failed.length} deterministic check(s) failed.` : `${checks.length} deterministic check(s) passed.`,
4840
+ status: failed.length ? "failed" : "passed",
4841
+ }],
4842
+ });
4843
+ if (failed.length) {
4844
+ emit(record, "checks_failed", `${failed.map((check) => check.name).join(", ")} failed; the run needs review.`);
4845
+ throw new Error(`Required checks failed: ${failed.map((check) => check.name).join(", ")}`);
4846
+ }
4847
+ }
4658
4848
  const commitMessage = opts.followUp ? `Follow-up on #${issue.number}` : `${issue.title} (#${issue.number})`;
4659
4849
  await commitAll(wt.path, commitMessage);
4660
4850
  await fetchOrigin(wt.path);
@@ -4740,7 +4930,7 @@ async function runSessionTurn(record, prompt) {
4740
4930
  }
4741
4931
  });
4742
4932
  });
4743
- await record.session.prompt(prompt);
4933
+ await promptWithWatchdog(record, prompt);
4744
4934
  await finished;
4745
4935
  }
4746
4936
  /** Set + persist + broadcast a session's display name (used by issue pickup). */
@@ -5050,6 +5240,47 @@ async function resolveTokenForRepo(owner, repo) {
5050
5240
  }
5051
5241
  return (await resolveGitHubToken()) ?? (await hostedMintToken());
5052
5242
  }
5243
+ /** The session source a Linear-issue pickup advertises, keyed by the issue's
5244
+ * provider-native id so the control plane can correlate a re-dispatch to it
5245
+ * (findSessionByExternalId → "linear:<externalId>"). The Linear analogue of the
5246
+ * GitHub `issue:owner/repo#N` source. */
5247
+ function linearSessionSource(externalId) {
5248
+ return `linear:${externalId}`;
5249
+ }
5250
+ /**
5251
+ * Case B for a queued follow-up the control plane correlated to an existing
5252
+ * session (`targetKind === "existing_session"`): if that session is still live on
5253
+ * this node, continue it as a normal chat — run `prompt` as a follow-up turn and
5254
+ * re-publish its branch/PR — so a channel reply lands in the same thread. The
5255
+ * provider-agnostic analogue of the GitHub issue follow-up (`runIssueFollowUp`);
5256
+ * used by both the Linear and the generic (Slack) pickup paths. When the session
5257
+ * isn't live here (its machine was torn down), best-effort restore its snapshot so
5258
+ * the caller's fresh pickup continues its branch/transcript instead of cold-
5259
+ * starting, and return false so the caller falls through. Returns true only when
5260
+ * it fully handled the item.
5261
+ */
5262
+ async function continueCorrelatedSession(item, prompt, report) {
5263
+ if (item.targetKind !== "existing_session" || !item.targetSessionId)
5264
+ return false;
5265
+ const record = openSessions.get(item.targetSessionId);
5266
+ if (!record) {
5267
+ await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message));
5268
+ return false;
5269
+ }
5270
+ const branch = record.worktree?.branch;
5271
+ await runSessionTurn(record, prompt);
5272
+ if (record.worktree) {
5273
+ await maybePushWorktreeBranch(record);
5274
+ await maybeDetectPullRequest(record);
5275
+ }
5276
+ await report({
5277
+ output: { sessionId: record.id, branch, prUrl: record.prUrl },
5278
+ events: record.prUrl
5279
+ ? [{ at: new Date().toISOString(), kind: "pull_request", summary: "Pull request updated.", ref: branch, url: record.prUrl }]
5280
+ : undefined,
5281
+ });
5282
+ return true;
5283
+ }
5053
5284
  async function runWorkItem(item, report) {
5054
5285
  if ((item.source === "schedule" || item.source === "manual") && item.body?.startsWith("bivy-room-v1:")) {
5055
5286
  const [, nodeId, ...payload] = item.body.split(":");
@@ -5128,6 +5359,10 @@ async function runWorkItem(item, report) {
5128
5359
  const parsed = parseRepo(repoSlug);
5129
5360
  if (!parsed)
5130
5361
  throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
5362
+ // Case B: a re-dispatch the control plane correlated to an existing session
5363
+ // continues it as a normal chat instead of starting cold (mirrors GitHub).
5364
+ if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report))
5365
+ return;
5131
5366
  const githubToken = await resolveGitHubToken();
5132
5367
  if (!githubToken)
5133
5368
  throw new Error("no GitHub token available to clone the Linear issue repository");
@@ -5138,7 +5373,7 @@ async function runWorkItem(item, report) {
5138
5373
  const record = await createSession(repoDir, undefined, {
5139
5374
  worktree: { branch, base },
5140
5375
  makeActive: false,
5141
- source: "queue:linear:issue",
5376
+ source: linearSessionSource(item.externalId),
5142
5377
  runtimeId: item.runtimeId || nodeConfiguredDefaultAgent(),
5143
5378
  sandbox: normalizeSandboxTier(item.sandbox),
5144
5379
  approvalMode: approvalModeFrom(item.approvalMode),
@@ -5164,6 +5399,13 @@ async function runWorkItem(item, report) {
5164
5399
  const parsedRepo = item.repo ? parseRepo(item.repo) : undefined;
5165
5400
  if (item.repo && !parsedRepo)
5166
5401
  throw new Error(`work item ${item.id} has an invalid repo "${item.repo}"`);
5402
+ const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
5403
+ // Case B (provider-agnostic): a follow-up the control plane correlated to an
5404
+ // existing session continues it as a normal chat. Reached by Slack the moment a
5405
+ // reply carries a thread identity the control plane can correlate; a one-shot
5406
+ // slash command has none, so it simply falls through to a fresh session.
5407
+ if (await continueCorrelatedSession(item, request, report))
5408
+ return;
5167
5409
  const sessionOpts = {
5168
5410
  makeActive: false,
5169
5411
  title: item.title,
@@ -5184,7 +5426,6 @@ async function runWorkItem(item, report) {
5184
5426
  }
5185
5427
  catch { }
5186
5428
  }
5187
- const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
5188
5429
  const prompt = parsedRepo || record.worktree
5189
5430
  ? [
5190
5431
  request,
@@ -5711,6 +5952,48 @@ async function applyRequestedModel(record, model) {
5711
5952
  broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
5712
5953
  }
5713
5954
  }
5955
+ // Serialize clone + worktree work per repo directory. Two forks (or a fork and
5956
+ // a GitHub pickup) hitting the same shared clone concurrently race on
5957
+ // `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
5958
+ // see "already exists"/"already checked out" or, worse, `createWorktree`'s
5959
+ // adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
5960
+ // mutex removes the race without a filesystem lock.
5961
+ const repoWorktreeLocks = new Map();
5962
+ async function withRepoLock(key, fn) {
5963
+ const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
5964
+ // Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
5965
+ // failing fork doesn't poison the next waiter's gate. Each caller still awaits
5966
+ // its own `run` and gets its own result/exception. Bounded by repo count.
5967
+ const gate = prev.then(() => { }, () => { });
5968
+ const run = gate.then(fn);
5969
+ repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
5970
+ return run;
5971
+ }
5972
+ /**
5973
+ * Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
5974
+ * the node, so a cross-node fork's committed work travels via origin (the
5975
+ * destination bases its adopted worktree on `origin/<branch>` — see
5976
+ * `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
5977
+ * means the destination falls back to the default base and the dirty patch.
5978
+ */
5979
+ async function pushForkSourceBranch(rec) {
5980
+ const parts = repoSessionParts(rec);
5981
+ if (!parts)
5982
+ return;
5983
+ const { wt, parsed } = parts;
5984
+ try {
5985
+ const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5986
+ if (!token)
5987
+ return;
5988
+ const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
5989
+ await pushBranch(cfg, wt.path, wt.branch);
5990
+ rec.branchPushed = true;
5991
+ }
5992
+ catch {
5993
+ // offline / no rights / protected branch — committed work may not reach a
5994
+ // cross-node destination, but the fork still proceeds from the best base.
5995
+ }
5996
+ }
5714
5997
  /**
5715
5998
  * Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
5716
5999
  * (optional) prerequisite detection, repo/worktree reconstruction, transcript
@@ -5721,8 +6004,10 @@ async function applyRequestedModel(record, model) {
5721
6004
  */
5722
6005
  async function standUpFork(opts) {
5723
6006
  const { bundle, targetRuntimeId } = opts;
5724
- const targetRuntime = getRuntime(targetRuntimeId);
5725
6007
  const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
6008
+ // Carry the source's sandbox tier so a sandboxed session forks into a
6009
+ // sandboxed one, rather than defaulting to this node's tier (fork.ts).
6010
+ const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
5726
6011
  // Credential-move: if the chosen model's provider isn't logged in on this node,
5727
6012
  // pull the account model-auth vault (a login done on another node carries over),
5728
6013
  // then re-check. Best-effort — a local-only node just skips it.
@@ -5734,41 +6019,64 @@ async function standUpFork(opts) {
5734
6019
  modelConfigured = await providerConfigured();
5735
6020
  }
5736
6021
  // Prerequisite detection. A missing AGENT is a hard blocker — stop before any
5737
- // clone/worktree work. Skipped for a same-node local fork.
6022
+ // clone/worktree work. Skipped for a same-node local fork. Read the agent's
6023
+ // availability + display name from the runtime REGISTRY (which never throws)
6024
+ // rather than resolving the runtime up front: `getRuntime` throws for a
6025
+ // known-but-not-installed agent, which — called eagerly — surfaced a raw
6026
+ // "not available" string with an empty `missing[]` instead of this friendly
6027
+ // install checklist. An unknown id (no registry entry) is treated as
6028
+ // unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
5738
6029
  const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
5739
- const agentAvailable = agentInfo ? agentInfo.status === "available" : true;
6030
+ const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
6031
+ const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
5740
6032
  const prereqInput = {
5741
- agent: { id: targetRuntimeId, displayName: targetRuntime.displayName, available: agentAvailable },
6033
+ agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
5742
6034
  ...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
5743
6035
  };
5744
6036
  if (opts.detectPrereqs) {
5745
6037
  const early = evaluateForkPrereqs(prereqInput);
5746
6038
  if (blockingForkPrereqs(early).length > 0) {
5747
- return { ok: false, error: `${targetRuntime.displayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
6039
+ return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5748
6040
  }
5749
6041
  }
6042
+ // Safe now: the agent is available (or this is a same-node local fork whose
6043
+ // agent is self-evidently present). The per-session sandbox tier bakes into
6044
+ // the runtime's launch flags.
6045
+ const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
5750
6046
  // Reconstruct repo + worktree when the source was repo-backed.
5751
6047
  let workspace = fallback.workspace;
5752
6048
  let cwd = fallback.cwd;
5753
6049
  let repoReachable;
5754
6050
  let worktree;
6051
+ let dirtyWarning;
5755
6052
  const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
5756
6053
  if (parsed) {
5757
6054
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5758
6055
  repoReachable = Boolean(token);
5759
6056
  const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
5760
6057
  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);
6058
+ // Serialize clone-adjacent worktree ops on this repo so concurrent forks /
6059
+ // pickups don't race on `git worktree add` or clobber each other's trees.
6060
+ const wt = await withRepoLock(repoDir, async () => {
6061
+ if (opts.worktree === "fresh") {
6062
+ // Same-node fork: cut a NEW branch from the source's LOCAL branch (which
6063
+ // holds its latest, possibly-unpushed commits) or the repo default.
6064
+ const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
6065
+ return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
6066
+ }
6067
+ // Cross-node adopt: the source branch has no LOCAL ref here. Base the
6068
+ // adopted branch on the pushed `origin/<branch>` so committed work travels
6069
+ // (was: undefined → the destination's DEFAULT branch, silently dropping
6070
+ // every commit). Give the worktree DIR a unique suffix so a same-branch
6071
+ // adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
6072
+ // — another live session's tree.
6073
+ const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
6074
+ const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
6075
+ return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
6076
+ });
6077
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6078
+ if (applied.warning)
6079
+ dirtyWarning = applied.warning;
5772
6080
  workspace = repoDir;
5773
6081
  cwd = wt.path;
5774
6082
  worktree = wt;
@@ -5782,8 +6090,10 @@ async function standUpFork(opts) {
5782
6090
  const forkRepoRoot = await gitRepoRoot(cwd);
5783
6091
  if (forkRepoRoot) {
5784
6092
  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);
6093
+ const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
6094
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6095
+ if (applied.warning)
6096
+ dirtyWarning = applied.warning;
5787
6097
  workspace = forkRepoRoot;
5788
6098
  cwd = wt.path;
5789
6099
  worktree = wt;
@@ -5793,8 +6103,8 @@ async function standUpFork(opts) {
5793
6103
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5794
6104
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
5795
6105
  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 });
6106
+ ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
6107
+ : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
5798
6108
  // Mark the new session as a fork of its source, so the run card can show
5799
6109
  // "Forked from …" and the lineage survives a reload (persisted below). Just
5800
6110
  // the parent's session id — an identifier, not content, so it's safe to
@@ -5813,6 +6123,10 @@ async function standUpFork(opts) {
5813
6123
  }
5814
6124
  if (bundle.record.title && !record.session.getName())
5815
6125
  record.session.setName(bundle.record.title);
6126
+ // Surface a non-fatal note when the source's uncommitted changes didn't apply
6127
+ // cleanly, so the fork isn't silently missing work-in-progress.
6128
+ if (dirtyWarning)
6129
+ broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
5816
6130
  await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
5817
6131
  persistSessionMetadata(record);
5818
6132
  scheduleAdvertise();
@@ -6324,6 +6638,14 @@ async function sweepDiskGuardrails() {
6324
6638
  await cleanupOldWorktrees();
6325
6639
  evictSharedDepCacheIfNeeded();
6326
6640
  warnOversizedWorktrees();
6641
+ const attachmentRefs = referencedAttachmentHashes();
6642
+ if (attachmentRefs)
6643
+ attachmentGcStats = attachmentStore.gc(attachmentRefs);
6644
+ else
6645
+ console.warn("[attachments] skipping garbage collection because event-log references are not healthy");
6646
+ if ((attachmentGcStats.overCapBytes ?? 0) > 0) {
6647
+ console.warn(`[attachments] store remains ${attachmentGcStats.overCapBytes} bytes over cap because referenced history is retained`);
6648
+ }
6327
6649
  }
6328
6650
  /**
6329
6651
  * Prune "ghost" sessions: metadata rows for a path-based runtime (pi) whose
@@ -6465,6 +6787,9 @@ function closeSessionRecord(record, reason = "closed") {
6465
6787
  sessionEvents.clear(record.id);
6466
6788
  record.session.dispose();
6467
6789
  harness.detach(record.id);
6790
+ // Tear down this session's own egress proxy, if it started one (read-only /
6791
+ // workflow network policy). No-op for the default path.
6792
+ void stopSessionEgress(record.id);
6468
6793
  record.mcpRestore?.();
6469
6794
  openSessions.delete(record.id);
6470
6795
  if (record.sessionFile)
@@ -6552,6 +6877,20 @@ const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessio
6552
6877
  idleCloseTimer.unref?.();
6553
6878
  const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
6554
6879
  worktreeCleanupTimer.unref?.();
6880
+ // In-session auto-resume tunables (see the resume helpers below). setTimeout
6881
+ // can't be trusted past ~24.8 days and we don't want one timer owning a
6882
+ // multi-hour wait a restart would drop, so each timer is capped and the periodic
6883
+ // sweep re-arms the remainder from the persisted resumeAt.
6884
+ const SESSION_RESUME_MAX_TIMER_MS = 30 * 60_000;
6885
+ const SESSION_RESUME_SWEEP_MS = 60_000;
6886
+ /** Slack around "due": a capped timer may fire a touch early — drive only when
6887
+ * within this of the target, else re-arm. */
6888
+ const SESSION_RESUME_TICK_MS = 15_000;
6889
+ const sessionResumeTimers = new Map();
6890
+ // Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
6891
+ // tail of long waits whose in-process timer was capped or lost to a restart.
6892
+ const sessionResumeTimer = setInterval(() => sessionResumeSweep(), SESSION_RESUME_SWEEP_MS);
6893
+ sessionResumeTimer.unref?.();
6555
6894
  // --- server-side ephemeral teardown ----------------------------------------
6556
6895
  // On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
6557
6896
  // machine ITSELF once it goes idle, so teardown no longer needs the launching
@@ -6699,6 +7038,64 @@ setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
6699
7038
  // One sweep shortly after boot clears ghosts left by a previous run before any
6700
7039
  // client paints its sidebar; the idle timer keeps it clean thereafter.
6701
7040
  setTimeout(pruneGhostSessions, 10_000).unref?.();
7041
+ const turnTimeoutMs = configuredTurnTimeoutMs();
7042
+ if (turnTimeoutMs > 0)
7043
+ console.log(`[turn-watchdog] armed: timeout=${turnTimeoutMs}ms`);
7044
+ else
7045
+ console.warn("[turn-watchdog] disabled by BIVY_TURN_TIMEOUT_MS=0");
7046
+ function turnTimeoutMessage() {
7047
+ return `Agent turn timed out after ${Math.round(turnTimeoutMs / 60_000)} minutes and was stopped.`;
7048
+ }
7049
+ function clearTurnWatchdog(record) {
7050
+ if (record.turnWatchdog)
7051
+ clearTimeout(record.turnWatchdog);
7052
+ record.turnWatchdog = undefined;
7053
+ record.turnTimeoutSignal = undefined;
7054
+ record.turnTimeoutResolve = undefined;
7055
+ }
7056
+ function armTurnWatchdog(record) {
7057
+ clearTurnWatchdog(record);
7058
+ record.turnTimedOut = false;
7059
+ if (turnTimeoutMs <= 0)
7060
+ return;
7061
+ record.turnTimeoutSignal = new Promise((resolve) => { record.turnTimeoutResolve = resolve; });
7062
+ record.turnWatchdog = setTimeout(() => {
7063
+ record.turnWatchdog = undefined;
7064
+ record.turnTimedOut = true;
7065
+ record.lastFailureAt = Date.now();
7066
+ const message = turnTimeoutMessage();
7067
+ record.turnTimeoutResolve?.();
7068
+ record.turnTimeoutResolve = undefined;
7069
+ // Clear/persist first so the session and an ephemeral runner cannot remain
7070
+ // pinned in a false working state if the runtime's abort path fails to emit
7071
+ // agent_end. abort() is still invoked to kill the underlying process group.
7072
+ clearSessionWorking(record);
7073
+ metadata.touchSession(record.id, "failed");
7074
+ broadcast({ type: "session.outcome", sessionId: record.id, status: "timed_out", completedAt: new Date().toISOString(), error: message });
7075
+ broadcast({ type: "session.error", sessionId: record.id, error: message });
7076
+ void record.session.abort().catch((error) => {
7077
+ console.error(`[turn-watchdog] abort failed for ${record.id}:`, error);
7078
+ }).finally(() => evaluateEphemeralTeardown());
7079
+ }, turnTimeoutMs);
7080
+ record.turnWatchdog.unref?.();
7081
+ }
7082
+ async function promptWithWatchdog(record, prompt, options) {
7083
+ armTurnWatchdog(record);
7084
+ const timeoutSignal = record.turnTimeoutSignal;
7085
+ try {
7086
+ await Promise.race([
7087
+ record.session.prompt(prompt, options),
7088
+ ...(timeoutSignal ? [timeoutSignal.then(() => { throw new Error(turnTimeoutMessage()); })] : []),
7089
+ ]);
7090
+ }
7091
+ catch (error) {
7092
+ // The timeout callback already cleared/persisted the session. For an ordinary
7093
+ // prompt failure, disarm here and let the caller publish its actionable error.
7094
+ if (!record.turnTimedOut)
7095
+ clearTurnWatchdog(record);
7096
+ throw error;
7097
+ }
7098
+ }
6702
7099
  function markSessionWorking(record, activity) {
6703
7100
  touchSession(record);
6704
7101
  const wasWorking = record.isWorking;
@@ -6712,6 +7109,7 @@ function markSessionWorking(record, activity) {
6712
7109
  scheduleAdvertise(); // idle → working transition
6713
7110
  }
6714
7111
  function clearSessionWorking(record) {
7112
+ clearTurnWatchdog(record);
6715
7113
  touchSession(record);
6716
7114
  record.isWorking = false;
6717
7115
  record.lastActivity = undefined;
@@ -6742,6 +7140,125 @@ async function refreshSessionUsage(record) {
6742
7140
  // Usage reporting must never affect the session it's reporting on.
6743
7141
  }
6744
7142
  }
7143
+ // ── In-session auto-resume after a usage/rate limit ─────────────────────────
7144
+ // When a turn ends because a provider window is exhausted ("you've hit your
7145
+ // weekly limit · resets 12am (UTC)") and the session's ruleset says retry, we
7146
+ // wait out the window and re-send the same prompt when it resets — instead of
7147
+ // leaving a dead error bubble. Durable: the due time is persisted (metadata
7148
+ // resumeAt) so a daemon restart re-arms it (sessionResumeSweep); an in-process
7149
+ // timer fires it promptly while the daemon is up. (Tunables + timer map are
7150
+ // declared up by the timer cluster so the sweep interval can reference them.)
7151
+ /** The authoritative reset time for the limit a session just hit: the soonest
7152
+ * future reset among its most-utilized usage windows (the binding one), from
7153
+ * the last snapshot the runtime reported. Essential for a multi-day "weekly"
7154
+ * window, whose error text states only a time-of-day. Undefined when unknown. */
7155
+ function limitResetHint(record, nowMs) {
7156
+ const windows = record.usage?.plan?.windows ?? [];
7157
+ let best;
7158
+ for (const w of windows) {
7159
+ if (!w.resetsAt)
7160
+ continue;
7161
+ const at = Date.parse(w.resetsAt);
7162
+ if (!Number.isFinite(at) || at <= nowMs)
7163
+ continue;
7164
+ const util = w.utilizationPct ?? 0;
7165
+ // Prefer the most-utilized window (the one being hit); tie-break on soonest reset.
7166
+ if (!best || util > best.util || (util === best.util && at < best.at))
7167
+ best = { at, util };
7168
+ }
7169
+ return best ? new Date(best.at).toISOString() : undefined;
7170
+ }
7171
+ /** Cancel a pending in-process resume timer (leaves the durable marker alone). */
7172
+ function cancelSessionResumeTimer(id) {
7173
+ const timer = sessionResumeTimers.get(id);
7174
+ if (timer) {
7175
+ clearTimeout(timer);
7176
+ sessionResumeTimers.delete(id);
7177
+ }
7178
+ }
7179
+ /** Clear both the durable resume marker and any armed timer — the session moved
7180
+ * on (a new user turn, or the resume itself started). */
7181
+ function clearSessionResume(id) {
7182
+ cancelSessionResumeTimer(id);
7183
+ metadata.setResumeAt(id, null);
7184
+ }
7185
+ function armSessionResumeTimer(id, dueMs) {
7186
+ cancelSessionResumeTimer(id);
7187
+ const delay = Math.min(Math.max(0, dueMs - Date.now()), SESSION_RESUME_MAX_TIMER_MS);
7188
+ const timer = setTimeout(() => {
7189
+ sessionResumeTimers.delete(id);
7190
+ void driveSessionResume(id);
7191
+ }, delay);
7192
+ timer.unref?.();
7193
+ sessionResumeTimers.set(id, timer);
7194
+ }
7195
+ /** Persist + arm an auto-resume decided by the session policy. Synchronous so
7196
+ * the caller can atomically suppress the turn's error toast. */
7197
+ function scheduleSessionResume(record, plan) {
7198
+ metadata.setResumeAt(record.id, plan.resumeAt);
7199
+ const when = Date.parse(plan.resumeAt);
7200
+ const cond = plan.condition.replace(/_/g, " ");
7201
+ broadcast({
7202
+ type: "session.notice",
7203
+ sessionId: record.id,
7204
+ level: "info",
7205
+ message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
7206
+ });
7207
+ armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
7208
+ }
7209
+ /** Fire a due auto-resume: re-open the session if needed and re-send the turn's
7210
+ * last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
7211
+ * can't loop. Best-effort — never throws into a timer/sweep. */
7212
+ async function driveSessionResume(id) {
7213
+ const meta = metadata.getSession(id);
7214
+ if (!meta?.resumeAt)
7215
+ return; // cancelled or already resumed
7216
+ const due = Date.parse(meta.resumeAt);
7217
+ if (Number.isFinite(due) && due - Date.now() > SESSION_RESUME_TICK_MS) {
7218
+ // A capped timer fired before the real due time — re-arm for the remainder.
7219
+ armSessionResumeTimer(id, due);
7220
+ return;
7221
+ }
7222
+ clearSessionResume(id);
7223
+ try {
7224
+ const live = openSessions.get(id);
7225
+ if (live?.isWorking)
7226
+ return; // a user turn is already running — don't pile on
7227
+ const record = live ?? (await resolveOrResumeSession(id, meta.path));
7228
+ if (!record)
7229
+ return; // transcript gone / unresolvable
7230
+ if (record.isWorking)
7231
+ return;
7232
+ // In-memory lastPrompt is the exact user turn to retry; after a restart it's
7233
+ // gone, so fall back to the generic interrupted-turn continuation nudge.
7234
+ const prompt = record.lastPrompt ?? buildInteractiveResumePrompt();
7235
+ console.log(`[resume] auto-resuming session ${id} — provider limit has reset`);
7236
+ broadcast({ type: "session.notice", sessionId: id, level: "info", message: "The limit has reset — resuming now." });
7237
+ await promptWithWatchdog(record, prompt, record.lastPromptOptions);
7238
+ }
7239
+ catch (error) {
7240
+ console.warn(`[resume] auto-resume after a provider limit failed for ${id}`, error);
7241
+ }
7242
+ }
7243
+ /** Re-arm (or immediately fire) durable auto-resume markers. Runs once at boot
7244
+ * and on an interval, so a wait survives a restart and a capped timer's tail
7245
+ * still fires. */
7246
+ function sessionResumeSweep() {
7247
+ const now = Date.now();
7248
+ for (const meta of metadata.sessionsWithResumeAt()) {
7249
+ const due = Date.parse(meta.resumeAt);
7250
+ if (!Number.isFinite(due)) {
7251
+ metadata.setResumeAt(meta.id, null);
7252
+ continue;
7253
+ }
7254
+ if (sessionResumeTimers.has(meta.id))
7255
+ continue; // already armed this run
7256
+ if (due <= now + SESSION_RESUME_TICK_MS)
7257
+ void driveSessionResume(meta.id);
7258
+ else
7259
+ armSessionResumeTimer(meta.id, due);
7260
+ }
7261
+ }
6745
7262
  /**
6746
7263
  * Turn a raw provider/runtime error string into something a human can read.
6747
7264
  * Model APIs commonly return `<status> {json}` (e.g. `400 {"error":{"message":
@@ -6815,9 +7332,12 @@ function maybeSignalAuthRequired(record, errorText) {
6815
7332
  }
6816
7333
  function attachSessionListeners(record) {
6817
7334
  record.unsubscribe?.();
6818
- // In-session model reroute controller (inert unless BIVY_SESSION_MODEL_FALLBACK
6819
- // is set). One per session; its per-turn budget resets on each user prompt.
6820
- if (sessionRunPolicy && !record.reroute) {
7335
+ // In-session recovery controller — waits out a usage/rate limit and resumes
7336
+ // (planResume), or swaps models down a fallback chain (planReroute). One per
7337
+ // session; its per-turn budget resets on each user prompt. The policy reads
7338
+ // the active session ruleset lazily, so it's inert until one authorizes a
7339
+ // retry/reroute for the failing condition.
7340
+ if (!record.reroute) {
6821
7341
  record.reroute = new SessionRerouteController({
6822
7342
  policy: sessionRunPolicy,
6823
7343
  onNotice: (n) => broadcast({ type: "session.notice", sessionId: record.id, level: n.level, message: n.message }),
@@ -6937,7 +7457,18 @@ function attachSessionListeners(record) {
6937
7457
  // credential or a 4xx from the API) otherwise vanished: working cleared,
6938
7458
  // no reply, no signal. Surface it as a session-scoped error so the client
6939
7459
  // can show it *inline in that chat*, and notify instead of "done".
6940
- const turnError = terminalTurnError(event);
7460
+ // A terminal turn error reaches us two ways. pi-ai puts it on the last
7461
+ // assistant message (stopReason:"error" → terminalTurnError), and the
7462
+ // server owns surfacing it. Claude Code instead throws inside the SDK
7463
+ // query: it emits its OWN session.error to the client AND carries the raw
7464
+ // text on agent_end.error (e.g. "you've hit your weekly limit · resets 12am
7465
+ // (UTC)"). We read that too — but only to DRIVE recovery, since the runtime
7466
+ // already surfaced it; re-broadcasting would double the error bubble.
7467
+ const messageError = terminalTurnError(event);
7468
+ const agentEndError = typeof event.error === "string"
7469
+ ? humanizeAgentError(event.error)
7470
+ : undefined;
7471
+ const turnError = messageError ?? (agentEndError?.trim() ? agentEndError : undefined);
6941
7472
  // Before surfacing a turn error, see if the session's run policy can recover
6942
7473
  // it in place by swapping to a fallback model and retrying the same prompt.
6943
7474
  // planReroute is synchronous, so we can atomically suppress the error toast
@@ -6945,23 +7476,40 @@ function attachSessionListeners(record) {
6945
7476
  const reroutePlan = turnError && record.lastPrompt !== undefined
6946
7477
  ? record.reroute?.planReroute(turnError, record.session.getCurrentModel()?.name) ?? null
6947
7478
  : null;
7479
+ // If a reroute doesn't apply, a usage/rate limit that gave a reset time can
7480
+ // instead be waited out and resumed when the window clears (planResume is
7481
+ // synchronous too, so this stays atomic with suppressing the error toast).
7482
+ const resumePlan = !reroutePlan && turnError && record.lastPrompt !== undefined
7483
+ ? record.reroute?.planResume(turnError, record.session.getCurrentModel()?.name, {
7484
+ resetsAtHint: limitResetHint(record, Date.now()),
7485
+ }) ?? null
7486
+ : null;
6948
7487
  if (reroutePlan) {
6949
7488
  void record.reroute.applyReroute(reroutePlan, {
6950
7489
  getCurrentModelName: () => record.session.getCurrentModel()?.name,
6951
7490
  setModel: (p, i) => record.session.setModel(p, i),
6952
7491
  reprompt: async () => {
6953
- await record.session.prompt(record.lastPrompt, record.lastPromptOptions);
7492
+ await promptWithWatchdog(record, record.lastPrompt, record.lastPromptOptions);
6954
7493
  },
6955
7494
  });
6956
7495
  }
6957
- else if (turnError) {
7496
+ else if (resumePlan) {
7497
+ // Charge the attempt budget so a limit that re-fires after the reset can
7498
+ // eventually exhaust (→ surface) instead of looping, then park the turn
7499
+ // as a scheduled resume rather than a dead error.
7500
+ record.reroute.noteResumeApplied();
7501
+ scheduleSessionResume(record, resumePlan);
7502
+ }
7503
+ else if (messageError) {
7504
+ // Only the server-owned (pi-ai) path surfaces here; a Claude Code error
7505
+ // the runtime already broadcast falls through to avoid a duplicate bubble.
6958
7506
  record.lastFailureAt = Date.now();
6959
7507
  metadata.touchSession(record.id, "failed");
6960
7508
  scheduleAdvertise();
6961
- broadcast({ type: "session.error", sessionId: record.id, error: turnError });
7509
+ broadcast({ type: "session.error", sessionId: record.id, error: messageError });
6962
7510
  // If the terminal error is an auth failure (expired key/token → 4xx),
6963
7511
  // also raise the sign-in sheet for the failing provider.
6964
- maybeSignalAuthRequired(record, turnError);
7512
+ maybeSignalAuthRequired(record, messageError);
6965
7513
  void sendNotificationHint({
6966
7514
  kind: "session_error",
6967
7515
  sessionId: record.id,
@@ -7458,7 +8006,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7458
8006
  if (makeActive)
7459
8007
  active = existing;
7460
8008
  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);
8009
+ void maybeNotifyBivyUpdate();
7462
8010
  return existing;
7463
8011
  }
7464
8012
  // Pick the agent for this session (fixed for its life). Resuming a tagged
@@ -7585,6 +8133,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7585
8133
  // session legitimately starts "active now".
7586
8134
  const resumedLastActive = requestedSessionFile ? metaLastActiveMs(storedMeta) : undefined;
7587
8135
  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 };
8136
+ // Apply this session's sandbox network policy as a per-session egress proxy
8137
+ // (its own proxy/decider, never the node-global one). Opt-in via BIVY_SANDBOX_NET:
8138
+ // a read-only session then actually blocks outbound network even for a CLI agent
8139
+ // whose own sandbox doesn't (opencode/aider/goose). No-op otherwise. Fire-and-
8140
+ // forget — a slow proxy listen never delays session creation.
8141
+ void applySessionSandboxEgress(record.id, sessionSandbox, (event) => broadcast({ type: "node.egress", event }));
7588
8142
  // Stage 2 slice 4: a re-attached session recovers its still-running TUI
7589
8143
  // terminal link (the PTY survives a detach) from the session→terminal registry.
7590
8144
  if (attached) {
@@ -7647,7 +8201,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7647
8201
  if (makeActive)
7648
8202
  active = record;
7649
8203
  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);
8204
+ void maybeNotifyBivyUpdate();
7651
8205
  scheduleAdvertise();
7652
8206
  return record;
7653
8207
  }
@@ -7757,32 +8311,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
7757
8311
  // races the runtime.select that switches the default agent, pin the pill to the
7758
8312
  // *previous* runtime (the reported agent-switching bug). Mirror how session.new/
7759
8313
  // 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)
8314
+ // non-active scratch session per runtime instead of spawning a fresh runtime
8315
+ // process on every picker read.
8316
+ //
8317
+ // Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
8318
+ // Claude) used to evict and re-spawn the one scratch on every switch — the
8319
+ // "switching agent takes a long time before models appear" bug. A map keeps one
8320
+ // warm scratch per runtime so a switch back to an agent already viewed this
8321
+ // session answers from the live session with no re-spawn, and `prefetchModels`
8322
+ // can warm several ahead of the first pick.
8323
+ const modelQueryScratch = new Map();
8324
+ const modelQueryScratchPending = new Map();
8325
+ async function sessionForModelQuery(runtimeId) {
8326
+ const wanted = resolveRuntimeId(runtimeId);
8327
+ // A live active session answers for itself — but only when it IS the runtime
8328
+ // being queried, so a prefetch/draft read for a *different* agent doesn't get
8329
+ // the active session's (wrong-runtime) model list.
8330
+ if (active && active.runtimeId === wanted)
7766
8331
  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;
8332
+ const cached = modelQueryScratch.get(wanted);
8333
+ if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
8334
+ touchSession(cached);
8335
+ return cached;
8336
+ }
8337
+ // De-dupe concurrent picker reads per runtime. Without this, a WS models.list
8338
+ // and an HTTP GET /api/models fired together on page load both miss the reuse
8339
+ // guard above (the scratch assignment only lands after createSession resolves
8340
+ // ~0.3s later) and each stand up a session, leaving two empty rows a fraction
8341
+ // of a second apart. Collapse concurrent builds onto one promise per runtime,
8342
+ // mirroring resumingSessions.
8343
+ const inflight = modelQueryScratchPending.get(wanted);
8344
+ if (inflight)
8345
+ return inflight;
8346
+ const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
8347
+ .then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
8348
+ .finally(() => { modelQueryScratchPending.delete(wanted); });
8349
+ modelQueryScratchPending.set(wanted, build);
8350
+ return build;
8351
+ }
8352
+ /**
8353
+ * Warm the model-query scratch for one or more runtimes in the background so the
8354
+ * first agent switch to any of them answers instantly instead of paying the
8355
+ * runtime spin-up on the critical path. Fired when the agent picker opens (see
8356
+ * the `models.prefetch` command). Best-effort and de-duped: a runtime already
8357
+ * warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
8358
+ * normal models.list path will surface any real error when the user picks it.
8359
+ */
8360
+ function prefetchModels(runtimeIds) {
8361
+ const wanted = [];
8362
+ for (const id of runtimeIds) {
8363
+ let resolved;
8364
+ try {
8365
+ resolved = resolveRuntimeId(id);
8366
+ }
8367
+ catch {
8368
+ continue; // unknown/uninstalled agent — nothing to warm
8369
+ }
8370
+ if (wanted.includes(resolved))
8371
+ continue;
8372
+ const cached = modelQueryScratch.get(resolved);
8373
+ if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
8374
+ continue;
8375
+ if (modelQueryScratchPending.has(resolved))
8376
+ continue;
8377
+ wanted.push(resolved);
8378
+ }
8379
+ // Warm serially, not in a burst: spinning up every agent subprocess at once
8380
+ // would spike a small node's memory/CPU right as the user is interacting. Each
8381
+ // build is cached (and de-duped) so this cost is paid at most once per runtime.
8382
+ void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
7786
8383
  }
7787
8384
  async function createRepoSession(parsed, opts = {}) {
7788
8385
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
@@ -8334,6 +8931,9 @@ app.delete("/api/devices/:id", (req, res) => {
8334
8931
  res.json({ ok: true, devices: pairingStore.listDevices() });
8335
8932
  });
8336
8933
  app.get("/api/node/info", (_req, res) => {
8934
+ const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
8935
+ const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
8936
+ const structuredControls = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
8337
8937
  res.json({
8338
8938
  nodeId: identity.nodeId,
8339
8939
  name: identity.name,
@@ -8344,15 +8944,31 @@ app.get("/api/node/info", (_req, res) => {
8344
8944
  guardrails: {
8345
8945
  mode: approvalMode,
8346
8946
  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.",
8947
+ enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
8948
+ protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
8949
+ workspaceBoundary: structuredControls
8950
+ ? "Structured file tools are checked against the active workspace; shell commands are not an OS isolation boundary."
8951
+ : "Not guaranteed by Bivy for this runtime. Run it in a container/VM when isolation is required.",
8952
+ denyList: structuredControls
8953
+ ? "Known catastrophic shell commands are heuristically blocked; this catches accidents, not adversarial bypasses."
8954
+ : "No universal Bivy command interception is available for this runtime.",
8955
+ strictApprovalOptIn: "Set approval mode to risky or always for prompt-heavy review where this runtime exposes tool controls.",
8350
8956
  },
8351
- runtime: runtimeSummary(getRuntime(active?.runtimeId ?? defaultRuntimeId)),
8957
+ runtime: { ...runtimeSummary(getRuntime(selectedRuntimeId)), ...runtimeInfo },
8352
8958
  defaultRuntimeId,
8353
8959
  sandbox: sandboxInfo(),
8354
8960
  });
8355
8961
  });
8962
+ // One-tap "Update this node" from the app's version-mismatch banner, for
8963
+ // direct/LAN clients (the relay path uses the RELAY_COMMANDS "node.update"
8964
+ // handler). Both call the same runBivyUpdate.
8965
+ app.post("/api/node/update", (_req, res) => {
8966
+ const result = runBivyUpdate();
8967
+ if (result.ok)
8968
+ res.json({ ok: true });
8969
+ else
8970
+ res.status(500).json({ ok: false, error: result.error });
8971
+ });
8356
8972
  // Build collectNodeStats() options, resolving the optional session so the panel
8357
8973
  // can attribute a session-scoped tier (its live agent process + workspace size).
8358
8974
  function nodeStatsOptsFor(sessionId) {
@@ -8386,8 +9002,38 @@ function sandboxInfo() {
8386
9002
  tier: sandboxTier(),
8387
9003
  };
8388
9004
  }
9005
+ // Redacted diagnostics bundle (B4d) — a shareable support export with no secrets,
9006
+ // prompts, transcripts, diffs, or repo content: versions, health counters, a
9007
+ // whitelisted set of config flags, and the activation stage record.
9008
+ app.get("/api/diagnostics", (_req, res) => {
9009
+ const relayConfig = loadRelayConfig(appDir);
9010
+ const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
9011
+ const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
9012
+ const report = buildDiagnosticsReport({
9013
+ version: currentVersion() ?? undefined,
9014
+ platform: process.platform,
9015
+ nodeVersion: process.version,
9016
+ relayConfigured: Boolean(relayConfig),
9017
+ health: {
9018
+ sessionsOpen: new Set(openSessions.values()).size,
9019
+ sessionsIndexed: metadata.listSessions().length,
9020
+ enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
9021
+ approvalMode,
9022
+ relayConnected: Boolean(relay?.connected),
9023
+ },
9024
+ env: process.env,
9025
+ // The node knows it is online and which runtime is selectable; the client's
9026
+ // setup readiness fills the rest. This baseline still records the golden path.
9027
+ activation: activationRecord({ nodeOnline: true, runtimeReady: Boolean(runtimeInfo) }),
9028
+ generatedAt: new Date().toISOString(),
9029
+ });
9030
+ res.json(report);
9031
+ });
8389
9032
  app.get("/api/status", (_req, res) => {
8390
9033
  const relayConfig = loadRelayConfig(appDir);
9034
+ const selectedRuntimeId = active?.runtimeId ?? defaultRuntimeId;
9035
+ const runtimeInfo = runtimeList(selectedRuntimeId).find((runtime) => runtime.id === selectedRuntimeId);
9036
+ const workspaceBoundary = runtimeInfo?.protectionLevel === "native-sandbox" || runtimeInfo?.protectionLevel === "tool-controls";
8391
9037
  res.json({
8392
9038
  ok: true,
8393
9039
  nodeId: identity.nodeId,
@@ -8402,7 +9048,9 @@ app.get("/api/status", (_req, res) => {
8402
9048
  approvalMode,
8403
9049
  guardrails: {
8404
9050
  autonomousDefault: approvalMode === "autonomous",
8405
- workspaceBoundary: true,
9051
+ workspaceBoundary,
9052
+ enforcementLevel: runtimeInfo?.protectionLevel ?? "user-permissions",
9053
+ protection: runtimeInfo?.protectionLabel ?? "Runs as your user",
8406
9054
  strictApprovalOptIn: true,
8407
9055
  },
8408
9056
  relay: {
@@ -8424,6 +9072,9 @@ app.get("/api/status", (_req, res) => {
8424
9072
  },
8425
9073
  devices: { paired: pairingStore.listDevices().length, localTokens: identity.listDevices().length },
8426
9074
  approvals: { pending: approvals.list().filter((a) => a.status === "pending").length, recent: metadata.listApprovals(20) },
9075
+ eventLog: { ...eventLog.diskUsage(), ...eventLog.health(), affectedSessions: eventLogIssues.size },
9076
+ attachments: attachmentGcStats,
9077
+ turnWatchdog: { enabled: turnTimeoutMs > 0, timeoutMs: turnTimeoutMs },
8427
9078
  updatedAt: new Date().toISOString(),
8428
9079
  });
8429
9080
  });
@@ -8619,10 +9270,13 @@ app.get("/api/models", async (req, res, next) => {
8619
9270
  try {
8620
9271
  const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
8621
9272
  const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
9273
+ const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
8622
9274
  let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
8623
9275
  if (requestedSessionId && !record)
8624
9276
  return res.status(404).json({ error: "Session not found" });
8625
- record ??= await sessionForModelQuery();
9277
+ if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
9278
+ record = undefined;
9279
+ record ??= await sessionForModelQuery(wantedRuntimeId);
8626
9280
  const session = record.session;
8627
9281
  const current = session.getCurrentModel();
8628
9282
  const models = await publicModelsList(session, current);
@@ -8633,6 +9287,17 @@ app.get("/api/models", async (req, res, next) => {
8633
9287
  next(error);
8634
9288
  }
8635
9289
  });
9290
+ // Warm the per-runtime model-query scratch ahead of the first agent switch (see
9291
+ // prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
9292
+ // up in the background, so the picker never blocks on it.
9293
+ app.post("/api/models/prefetch", (req, res) => {
9294
+ const ids = Array.isArray(req.body?.runtimeIds)
9295
+ ? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
9296
+ : [];
9297
+ if (ids.length)
9298
+ prefetchModels(ids);
9299
+ res.json({ ok: true });
9300
+ });
8636
9301
  app.post("/api/models/select", async (req, res, next) => {
8637
9302
  try {
8638
9303
  const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
@@ -9934,7 +10599,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
9934
10599
  broadcast({ type: "session.user_message", sessionId: record.id, text: promptText, clientMessageId: req.body?.clientMessageId });
9935
10600
  void maybeNameSession(record, promptText);
9936
10601
  harnessBeginTurn(record);
9937
- await session.prompt(agentPrompt, promptOptionsFor(record, req.body?.streamingBehavior, images));
10602
+ await promptWithWatchdog(record, agentPrompt, promptOptionsFor(record, req.body?.streamingBehavior, images));
9938
10603
  }).catch((error) => {
9939
10604
  clearSessionWorking(record);
9940
10605
  broadcast({ type: "session.error", sessionId: record.id, error: String(error?.stack ?? error) });
@@ -10169,6 +10834,14 @@ const server = app.listen(port, host, async () => {
10169
10834
  // Recover interactive sessions a restart interrupted mid-turn (auto-continue, or
10170
10835
  // flag for a one-tap manual Resume) per the node's sessionResumeMode setting.
10171
10836
  void reconcileInterruptedSessions().catch((error) => console.warn("[resume] interrupted-session reconciliation failed", error));
10837
+ // Re-arm (or fire) durable auto-resume markers a limit-hit turn left behind,
10838
+ // so a session waiting out a usage/rate window still resumes after a restart.
10839
+ try {
10840
+ sessionResumeSweep();
10841
+ }
10842
+ catch (error) {
10843
+ console.warn("[resume] auto-resume sweep failed at boot", error);
10844
+ }
10172
10845
  // Universal Agent Harness — network effect boundary (opt-in via
10173
10846
  // BIVY_EGRESS_PROXY). Governs/logs outbound traffic of CLI agents, which
10174
10847
  // inherit the proxy env from process.ts.
@@ -10218,6 +10891,14 @@ wss.on("connection", (socket, req) => {
10218
10891
  // sharing a PTY size it to their min (see TerminalManager.setClientSize).
10219
10892
  const clientTerminalId = `sock-${randomUUID()}`;
10220
10893
  socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
10894
+ // Authoritative version status on every connect: `latest` set means this node
10895
+ // is behind (banner shows); absent means up to date (banner + any "Updating…"
10896
+ // state clear — this is how the banner disappears after an update lands and
10897
+ // the socket reconnects on the new build). Then (re)run the throttled check so
10898
+ // a freshly-opened app surfaces a newly-available update without waiting for a
10899
+ // session turn.
10900
+ socket.send(JSON.stringify({ type: "node.update", current: currentVersion() ?? "", latest: pendingBivyUpdate?.latest }));
10901
+ void checkBivyUpdate();
10221
10902
  socket.on("message", (raw) => {
10222
10903
  let msg;
10223
10904
  try {