@bivy/bivy 0.6.0-staging.83 → 0.6.0-staging.85

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.
@@ -164,6 +164,25 @@ export async function resolveBranchBaseRef(repoDir, branch) {
164
164
  throw new Error(`Branch "${branch}" was not found on the remote.`);
165
165
  }
166
166
  }
167
+ /**
168
+ * Base ref for ADOPTING a source branch onto a fresh clone on another node (a
169
+ * cross-node fork). Prefers the pushed `origin/<branch>` so the source's
170
+ * committed work travels; falls back to the repo's default branch when the
171
+ * source branch was never pushed (best-effort — any uncommitted work still
172
+ * arrives via the fork's dirty patch). Fetches first so `origin/<branch>` is
173
+ * current. Contrast with `resolveBranchBaseRef`, which is user-facing and throws
174
+ * on a missing branch; a fork must degrade rather than fail.
175
+ */
176
+ export async function resolveAdoptBaseRef(repoDir, branch) {
177
+ await fetchOrigin(repoDir);
178
+ try {
179
+ await exec("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd: repoDir });
180
+ return `origin/${branch}`;
181
+ }
182
+ catch {
183
+ return resolveDefaultBaseRef(repoDir);
184
+ }
185
+ }
167
186
  /**
168
187
  * Whether an existing Bivy-owned checkout at `dest` can be reused as-is, i.e. it
169
188
  * has a `.git` entry AND `git rev-parse` accepts it as a real repository. A
package/dist/server.js CHANGED
@@ -60,7 +60,7 @@ import { checkDiskAdmission } from "./harness/disk-admission.js";
60
60
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
61
61
  import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
62
62
  import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
63
- 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";
64
64
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
65
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";
66
66
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -3084,6 +3084,7 @@ const RELAY_COMMANDS = {
3084
3084
  },
3085
3085
  async "models.list"(msg) {
3086
3086
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
3087
+ const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
3087
3088
  let record;
3088
3089
  try {
3089
3090
  record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
@@ -3096,7 +3097,12 @@ const RELAY_COMMANDS = {
3096
3097
  relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
3097
3098
  return;
3098
3099
  }
3099
- 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);
3100
3106
  const session = record.session;
3101
3107
  const current = session.getCurrentModel();
3102
3108
  const models = await publicModelsList(session, current);
@@ -3108,6 +3114,17 @@ const RELAY_COMMANDS = {
3108
3114
  // (e.g. Claude) — the "Claude shows Codex models" bug.
3109
3115
  relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
3110
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
+ },
3111
3128
  async "model.select"(msg) {
3112
3129
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
3113
3130
  let record;
@@ -3560,6 +3577,7 @@ const RELAY_COMMANDS = {
3560
3577
  source: rec.source,
3561
3578
  title: rec.session.getName(),
3562
3579
  model: rec.session.getCurrentModel()?.name,
3580
+ sandbox: rec.sandbox,
3563
3581
  };
3564
3582
  let dirtyPatch;
3565
3583
  if (rec.worktree) {
@@ -3568,6 +3586,14 @@ const RELAY_COMMANDS = {
3568
3586
  }
3569
3587
  catch { /* best effort — omit dirty state */ }
3570
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);
3571
3597
  // Refresh the account model-auth vault so the destination node can pull
3572
3598
  // this session's model credentials during import (fork credential-move,
3573
3599
  // docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
@@ -3648,6 +3674,7 @@ const RELAY_COMMANDS = {
3648
3674
  source: rec.source,
3649
3675
  title: rec.session.getName(),
3650
3676
  model: rec.session.getCurrentModel()?.name,
3677
+ sandbox: rec.sandbox,
3651
3678
  };
3652
3679
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
3653
3680
  // re-applies it into the fork's fresh worktree. Local git ops only.
@@ -5859,6 +5886,48 @@ async function applyRequestedModel(record, model) {
5859
5886
  broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
5860
5887
  }
5861
5888
  }
5889
+ // Serialize clone + worktree work per repo directory. Two forks (or a fork and
5890
+ // a GitHub pickup) hitting the same shared clone concurrently race on
5891
+ // `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
5892
+ // see "already exists"/"already checked out" or, worse, `createWorktree`'s
5893
+ // adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
5894
+ // mutex removes the race without a filesystem lock.
5895
+ const repoWorktreeLocks = new Map();
5896
+ async function withRepoLock(key, fn) {
5897
+ const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
5898
+ // Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
5899
+ // failing fork doesn't poison the next waiter's gate. Each caller still awaits
5900
+ // its own `run` and gets its own result/exception. Bounded by repo count.
5901
+ const gate = prev.then(() => { }, () => { });
5902
+ const run = gate.then(fn);
5903
+ repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
5904
+ return run;
5905
+ }
5906
+ /**
5907
+ * Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
5908
+ * the node, so a cross-node fork's committed work travels via origin (the
5909
+ * destination bases its adopted worktree on `origin/<branch>` — see
5910
+ * `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
5911
+ * means the destination falls back to the default base and the dirty patch.
5912
+ */
5913
+ async function pushForkSourceBranch(rec) {
5914
+ const parts = repoSessionParts(rec);
5915
+ if (!parts)
5916
+ return;
5917
+ const { wt, parsed } = parts;
5918
+ try {
5919
+ const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5920
+ if (!token)
5921
+ return;
5922
+ const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
5923
+ await pushBranch(cfg, wt.path, wt.branch);
5924
+ rec.branchPushed = true;
5925
+ }
5926
+ catch {
5927
+ // offline / no rights / protected branch — committed work may not reach a
5928
+ // cross-node destination, but the fork still proceeds from the best base.
5929
+ }
5930
+ }
5862
5931
  /**
5863
5932
  * Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
5864
5933
  * (optional) prerequisite detection, repo/worktree reconstruction, transcript
@@ -5869,8 +5938,10 @@ async function applyRequestedModel(record, model) {
5869
5938
  */
5870
5939
  async function standUpFork(opts) {
5871
5940
  const { bundle, targetRuntimeId } = opts;
5872
- const targetRuntime = getRuntime(targetRuntimeId);
5873
5941
  const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
5942
+ // Carry the source's sandbox tier so a sandboxed session forks into a
5943
+ // sandboxed one, rather than defaulting to this node's tier (fork.ts).
5944
+ const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
5874
5945
  // Credential-move: if the chosen model's provider isn't logged in on this node,
5875
5946
  // pull the account model-auth vault (a login done on another node carries over),
5876
5947
  // then re-check. Best-effort — a local-only node just skips it.
@@ -5882,41 +5953,64 @@ async function standUpFork(opts) {
5882
5953
  modelConfigured = await providerConfigured();
5883
5954
  }
5884
5955
  // Prerequisite detection. A missing AGENT is a hard blocker — stop before any
5885
- // clone/worktree work. Skipped for a same-node local fork.
5956
+ // clone/worktree work. Skipped for a same-node local fork. Read the agent's
5957
+ // availability + display name from the runtime REGISTRY (which never throws)
5958
+ // rather than resolving the runtime up front: `getRuntime` throws for a
5959
+ // known-but-not-installed agent, which — called eagerly — surfaced a raw
5960
+ // "not available" string with an empty `missing[]` instead of this friendly
5961
+ // install checklist. An unknown id (no registry entry) is treated as
5962
+ // unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
5886
5963
  const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
5887
- const agentAvailable = agentInfo ? agentInfo.status === "available" : true;
5964
+ const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
5965
+ const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
5888
5966
  const prereqInput = {
5889
- agent: { id: targetRuntimeId, displayName: targetRuntime.displayName, available: agentAvailable },
5967
+ agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
5890
5968
  ...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
5891
5969
  };
5892
5970
  if (opts.detectPrereqs) {
5893
5971
  const early = evaluateForkPrereqs(prereqInput);
5894
5972
  if (blockingForkPrereqs(early).length > 0) {
5895
- return { ok: false, error: `${targetRuntime.displayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5973
+ return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5896
5974
  }
5897
5975
  }
5976
+ // Safe now: the agent is available (or this is a same-node local fork whose
5977
+ // agent is self-evidently present). The per-session sandbox tier bakes into
5978
+ // the runtime's launch flags.
5979
+ const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
5898
5980
  // Reconstruct repo + worktree when the source was repo-backed.
5899
5981
  let workspace = fallback.workspace;
5900
5982
  let cwd = fallback.cwd;
5901
5983
  let repoReachable;
5902
5984
  let worktree;
5985
+ let dirtyWarning;
5903
5986
  const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
5904
5987
  if (parsed) {
5905
5988
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5906
5989
  repoReachable = Boolean(token);
5907
5990
  const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
5908
5991
  const srcBranch = bundle.record.branch;
5909
- let wt;
5910
- if (opts.worktree === "fresh") {
5911
- // Cut a new branch from the source branch (or the repo's default base).
5912
- const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5913
- wt = await createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5914
- }
5915
- else {
5916
- // Adopt the source branch, or a fresh random worktree when it had none.
5917
- wt = await createWorktree({ repoDir, id: srcBranch ?? `fork-${randomBytes(6).toString("hex")}`, branch: srcBranch, base: srcBranch ? undefined : await resolveDefaultBaseRef(repoDir) });
5918
- }
5919
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
5992
+ // Serialize clone-adjacent worktree ops on this repo so concurrent forks /
5993
+ // pickups don't race on `git worktree add` or clobber each other's trees.
5994
+ const wt = await withRepoLock(repoDir, async () => {
5995
+ if (opts.worktree === "fresh") {
5996
+ // Same-node fork: cut a NEW branch from the source's LOCAL branch (which
5997
+ // holds its latest, possibly-unpushed commits) or the repo default.
5998
+ const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5999
+ return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
6000
+ }
6001
+ // Cross-node adopt: the source branch has no LOCAL ref here. Base the
6002
+ // adopted branch on the pushed `origin/<branch>` so committed work travels
6003
+ // (was: undefined → the destination's DEFAULT branch, silently dropping
6004
+ // every commit). Give the worktree DIR a unique suffix so a same-branch
6005
+ // adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
6006
+ // — another live session's tree.
6007
+ const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
6008
+ const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
6009
+ return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
6010
+ });
6011
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6012
+ if (applied.warning)
6013
+ dirtyWarning = applied.warning;
5920
6014
  workspace = repoDir;
5921
6015
  cwd = wt.path;
5922
6016
  worktree = wt;
@@ -5930,8 +6024,10 @@ async function standUpFork(opts) {
5930
6024
  const forkRepoRoot = await gitRepoRoot(cwd);
5931
6025
  if (forkRepoRoot) {
5932
6026
  const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
5933
- const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
5934
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
6027
+ const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
6028
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6029
+ if (applied.warning)
6030
+ dirtyWarning = applied.warning;
5935
6031
  workspace = forkRepoRoot;
5936
6032
  cwd = wt.path;
5937
6033
  worktree = wt;
@@ -5941,8 +6037,8 @@ async function standUpFork(opts) {
5941
6037
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5942
6038
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
5943
6039
  const record = plan.kind === "resume"
5944
- ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false })
5945
- : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false });
6040
+ ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
6041
+ : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
5946
6042
  // Mark the new session as a fork of its source, so the run card can show
5947
6043
  // "Forked from …" and the lineage survives a reload (persisted below). Just
5948
6044
  // the parent's session id — an identifier, not content, so it's safe to
@@ -5961,6 +6057,10 @@ async function standUpFork(opts) {
5961
6057
  }
5962
6058
  if (bundle.record.title && !record.session.getName())
5963
6059
  record.session.setName(bundle.record.title);
6060
+ // Surface a non-fatal note when the source's uncommitted changes didn't apply
6061
+ // cleanly, so the fork isn't silently missing work-in-progress.
6062
+ if (dirtyWarning)
6063
+ broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
5964
6064
  await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
5965
6065
  persistSessionMetadata(record);
5966
6066
  scheduleAdvertise();
@@ -7972,32 +8072,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
7972
8072
  // races the runtime.select that switches the default agent, pin the pill to the
7973
8073
  // *previous* runtime (the reported agent-switching bug). Mirror how session.new/
7974
8074
  // session.open already refuse to touch `active` for remote clients: reuse a
7975
- // single non-active scratch session on the current default runtime instead of
7976
- // spawning a fresh runtime process on every picker read.
7977
- let modelQueryScratch;
7978
- let modelQueryScratchPending;
7979
- async function sessionForModelQuery() {
7980
- if (active)
8075
+ // non-active scratch session per runtime instead of spawning a fresh runtime
8076
+ // process on every picker read.
8077
+ //
8078
+ // Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
8079
+ // Claude) used to evict and re-spawn the one scratch on every switch — the
8080
+ // "switching agent takes a long time before models appear" bug. A map keeps one
8081
+ // warm scratch per runtime so a switch back to an agent already viewed this
8082
+ // session answers from the live session with no re-spawn, and `prefetchModels`
8083
+ // can warm several ahead of the first pick.
8084
+ const modelQueryScratch = new Map();
8085
+ const modelQueryScratchPending = new Map();
8086
+ async function sessionForModelQuery(runtimeId) {
8087
+ const wanted = resolveRuntimeId(runtimeId);
8088
+ // A live active session answers for itself — but only when it IS the runtime
8089
+ // being queried, so a prefetch/draft read for a *different* agent doesn't get
8090
+ // the active session's (wrong-runtime) model list.
8091
+ if (active && active.runtimeId === wanted)
7981
8092
  return active;
7982
- const wanted = resolveRuntimeId();
7983
- if (modelQueryScratch &&
7984
- openSessions.has(modelQueryScratch.id) &&
7985
- modelQueryScratch.runtimeId === wanted &&
7986
- !sessionBusy(modelQueryScratch)) {
7987
- touchSession(modelQueryScratch);
7988
- return modelQueryScratch;
7989
- }
7990
- // De-dupe concurrent picker reads. Without this, a WS models.list and an HTTP
7991
- // GET /api/models fired together on page load both miss the reuse guard above
7992
- // (the scratch assignment only lands after createSession resolves ~0.3s later)
7993
- // and each stand up a session, leaving two empty rows a fraction of a second
7994
- // apart. Collapse concurrent builds onto one promise, mirroring resumingSessions.
7995
- if (modelQueryScratchPending)
7996
- return modelQueryScratchPending;
7997
- modelQueryScratchPending = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true })
7998
- .then((rec) => { modelQueryScratch = rec; return rec; })
7999
- .finally(() => { modelQueryScratchPending = undefined; });
8000
- return modelQueryScratchPending;
8093
+ const cached = modelQueryScratch.get(wanted);
8094
+ if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
8095
+ touchSession(cached);
8096
+ return cached;
8097
+ }
8098
+ // De-dupe concurrent picker reads per runtime. Without this, a WS models.list
8099
+ // and an HTTP GET /api/models fired together on page load both miss the reuse
8100
+ // guard above (the scratch assignment only lands after createSession resolves
8101
+ // ~0.3s later) and each stand up a session, leaving two empty rows a fraction
8102
+ // of a second apart. Collapse concurrent builds onto one promise per runtime,
8103
+ // mirroring resumingSessions.
8104
+ const inflight = modelQueryScratchPending.get(wanted);
8105
+ if (inflight)
8106
+ return inflight;
8107
+ const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
8108
+ .then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
8109
+ .finally(() => { modelQueryScratchPending.delete(wanted); });
8110
+ modelQueryScratchPending.set(wanted, build);
8111
+ return build;
8112
+ }
8113
+ /**
8114
+ * Warm the model-query scratch for one or more runtimes in the background so the
8115
+ * first agent switch to any of them answers instantly instead of paying the
8116
+ * runtime spin-up on the critical path. Fired when the agent picker opens (see
8117
+ * the `models.prefetch` command). Best-effort and de-duped: a runtime already
8118
+ * warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
8119
+ * normal models.list path will surface any real error when the user picks it.
8120
+ */
8121
+ function prefetchModels(runtimeIds) {
8122
+ const wanted = [];
8123
+ for (const id of runtimeIds) {
8124
+ let resolved;
8125
+ try {
8126
+ resolved = resolveRuntimeId(id);
8127
+ }
8128
+ catch {
8129
+ continue; // unknown/uninstalled agent — nothing to warm
8130
+ }
8131
+ if (wanted.includes(resolved))
8132
+ continue;
8133
+ const cached = modelQueryScratch.get(resolved);
8134
+ if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
8135
+ continue;
8136
+ if (modelQueryScratchPending.has(resolved))
8137
+ continue;
8138
+ wanted.push(resolved);
8139
+ }
8140
+ // Warm serially, not in a burst: spinning up every agent subprocess at once
8141
+ // would spike a small node's memory/CPU right as the user is interacting. Each
8142
+ // build is cached (and de-duped) so this cost is paid at most once per runtime.
8143
+ void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
8001
8144
  }
8002
8145
  async function createRepoSession(parsed, opts = {}) {
8003
8146
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
@@ -8888,10 +9031,13 @@ app.get("/api/models", async (req, res, next) => {
8888
9031
  try {
8889
9032
  const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
8890
9033
  const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
9034
+ const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
8891
9035
  let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
8892
9036
  if (requestedSessionId && !record)
8893
9037
  return res.status(404).json({ error: "Session not found" });
8894
- record ??= await sessionForModelQuery();
9038
+ if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
9039
+ record = undefined;
9040
+ record ??= await sessionForModelQuery(wantedRuntimeId);
8895
9041
  const session = record.session;
8896
9042
  const current = session.getCurrentModel();
8897
9043
  const models = await publicModelsList(session, current);
@@ -8902,6 +9048,17 @@ app.get("/api/models", async (req, res, next) => {
8902
9048
  next(error);
8903
9049
  }
8904
9050
  });
9051
+ // Warm the per-runtime model-query scratch ahead of the first agent switch (see
9052
+ // prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
9053
+ // up in the background, so the picker never blocks on it.
9054
+ app.post("/api/models/prefetch", (req, res) => {
9055
+ const ids = Array.isArray(req.body?.runtimeIds)
9056
+ ? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
9057
+ : [];
9058
+ if (ids.length)
9059
+ prefetchModels(ids);
9060
+ res.json({ ok: true });
9061
+ });
8905
9062
  app.post("/api/models/select", async (req, res, next) => {
8906
9063
  try {
8907
9064
  const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -53,14 +53,52 @@ export function captureDirtyPatch(repoDir, opts = {}) {
53
53
  * source pushed the branch instead (`pushedInstead`) or the working tree was
54
54
  * clean (empty patch). Uses `git apply` so both tracked hunks and untracked
55
55
  * new-file hunks (produced via `--no-index`) land correctly.
56
+ *
57
+ * NEVER throws: a fork's uncommitted changes are best-effort, and the source's
58
+ * base commit frequently differs from what the destination cloned (a diverged
59
+ * default branch, an unpushed source branch), so a strict `git apply` fails on
60
+ * hunk-context mismatch and previously took the whole fork down with it. Instead
61
+ * we fall back to `git apply --3way` (which reconstructs the hunks from the blob
62
+ * SHAs the patch carries and merges what it can) and, when even that fails,
63
+ * surface a warning and leave the tree as the clone left it — the fork still
64
+ * succeeds, minus the un-appliable working-tree edits.
56
65
  */
57
66
  export function applyDirtyPatch(repoDir, dirty) {
58
67
  if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
59
- return;
68
+ return { applied: false };
60
69
  const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
61
70
  fs.writeFileSync(tmp, dirty.patch);
62
71
  try {
63
- execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
72
+ try {
73
+ execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
74
+ return { applied: true };
75
+ }
76
+ catch {
77
+ // Clean apply failed — the destination's base drifted from the source's.
78
+ // Retry with a 3-way merge, which reconstructs the pre-image from the blob
79
+ // SHAs the patch carries (present because the destination cloned the same
80
+ // repo) and merges what it can. `git apply --3way` exits non-zero BOTH for
81
+ // an un-appliable patch (nothing lands) AND for a conflicting one (it lands
82
+ // the non-conflicting hunks and writes conflict markers) — so read the exit
83
+ // status/stderr with spawnSync rather than treating every non-zero as a
84
+ // total failure that drops all the WIP.
85
+ const res = spawnSync("git", ["-C", repoDir, "apply", "--3way", "--whitespace=nowarn", tmp], { encoding: "utf8" });
86
+ if (res.status === 0)
87
+ return { applied: true }; // merged cleanly onto the diverged base
88
+ const stderr = (res.stderr || "").toString();
89
+ if (/with conflicts/i.test(stderr)) {
90
+ return {
91
+ applied: true,
92
+ conflicted: true,
93
+ warning: "Some uncommitted changes from the source didn't apply cleanly and were merged with conflict markers — review and resolve them in the fork.",
94
+ };
95
+ }
96
+ const detail = stderr.split("\n").find((l) => l.trim()) || "patch did not apply";
97
+ return {
98
+ applied: false,
99
+ warning: `Couldn't re-apply the source's uncommitted changes (${detail}); they were left behind. Re-make them in the fork if you still need them.`,
100
+ };
101
+ }
64
102
  }
65
103
  finally {
66
104
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.6.0-staging.83",
3
+ "version": "0.6.0-staging.85",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",