@bivy/bivy 0.6.0-staging.82 → 0.6.0-staging.84

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";
@@ -535,7 +535,9 @@ if (sessionRunPolicy) {
535
535
  console.log(`[policy] in-session model reroute enabled: ${process.env.BIVY_SESSION_MODEL_FALLBACK}`);
536
536
  }
537
537
  let lastUpdateCheckAt = 0;
538
- let updateNoticeSentFor = "";
538
+ // The most recent "this node is behind" finding, so a client that connects after
539
+ // the check already ran still gets the banner (replayed on connect below).
540
+ let pendingBivyUpdate = null;
539
541
  function runtimeSummary(rt) {
540
542
  return runtimeHost.summary(rt);
541
543
  }
@@ -625,11 +627,11 @@ function readJsonFile(file) {
625
627
  return undefined;
626
628
  }
627
629
  }
628
- async function maybeNotifyBivyUpdate(record) {
629
- // The daemon creates an initial session during startup before any UI is
630
- // connected. Don't consume the once-per-version notice until someone can see it.
631
- if (clients.size === 0 && !relay)
632
- return;
630
+ // Poll npm for a newer release (throttled to every 6h). On finding one, remember
631
+ // it and push a dedicated `node.update` event so every connected app can show a
632
+ // banner with a one-tap "Update this node" button (see runBivyUpdate). Safe to
633
+ // call from anywhere never throws, never interrupts a session.
634
+ async function checkBivyUpdate() {
633
635
  const now = Date.now();
634
636
  if (now - lastUpdateCheckAt < 6 * 60 * 60 * 1000)
635
637
  return;
@@ -641,25 +643,48 @@ async function maybeNotifyBivyUpdate(record) {
641
643
  const res = await fetch(updateRegistryUrl, { signal: AbortSignal.timeout(5000) });
642
644
  if (!res.ok)
643
645
  return;
644
- const latestVersion = (await res.json()).version;
645
- if (!latestVersion || !isNewerVersion(latestVersion, current))
646
+ const latest = (await res.json()).version;
647
+ if (!latest || !isNewerVersion(latest, current))
646
648
  return;
647
- if (updateNoticeSentFor === latestVersion)
648
- return;
649
- updateNoticeSentFor = latestVersion;
650
- const label = ` ${latestVersion}`;
651
- broadcast({
652
- type: "session.notice",
653
- sessionId: record.id,
654
- level: "info",
655
- message: `A newer Bivy version${label} is available. Run \`bivy update\` in your terminal to update.`,
656
- action: "bivy update",
657
- });
649
+ pendingBivyUpdate = { current, latest };
650
+ broadcast({ type: "node.update", current, latest });
658
651
  }
659
652
  catch {
660
653
  // Best-effort update checks should never interrupt a session.
661
654
  }
662
655
  }
656
+ async function maybeNotifyBivyUpdate() {
657
+ // The daemon creates an initial session during startup before any UI is
658
+ // connected. Don't spend a check until someone can see the banner.
659
+ if (clients.size === 0 && !relay)
660
+ return;
661
+ await checkBivyUpdate();
662
+ }
663
+ // Run `bivy update` on this node, the same command a user would type. The CLI
664
+ // re-spawns itself detached, waits for any in-flight turn, updates, and restarts
665
+ // the service (logging to update.log), so we just fire-and-forget it here. The
666
+ // bin ships next to this server bundle in both the git checkout (src/server.ts)
667
+ // and the published package (dist/server.js), so repoRoot/bin/bivy.mjs resolves
668
+ // in both. Returns a friendly error instead of throwing when it can't be found
669
+ // (e.g. an unusual layout), so the banner can fall back to the manual command.
670
+ function runBivyUpdate() {
671
+ const script = path.join(repoRoot, "bin", "bivy.mjs");
672
+ if (!fs.existsSync(script)) {
673
+ return { ok: false, error: "Could not locate the bivy CLI on this node — run `bivy update` in a terminal." };
674
+ }
675
+ try {
676
+ const child = spawn(process.execPath, [script, "update"], {
677
+ detached: true,
678
+ stdio: "ignore",
679
+ env: process.env,
680
+ });
681
+ child.unref();
682
+ return { ok: true };
683
+ }
684
+ catch (error) {
685
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
686
+ }
687
+ }
663
688
  function runtimeInstallSpec(requested) {
664
689
  let id = String(requested ?? "").trim().toLowerCase();
665
690
  // Normalize a few historical aliases to their canonical runtime id.
@@ -2597,6 +2622,14 @@ const RELAY_COMMANDS = {
2597
2622
  ping(msg, ctx) {
2598
2623
  ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
2599
2624
  },
2625
+ // Kick off `bivy update` on this node from the app's version-mismatch banner
2626
+ // (see runBivyUpdate). The node restarts itself when the update lands, so the
2627
+ // client just sees the socket reconnect on the new build; a failure to even
2628
+ // start reports back so the banner can show the manual command.
2629
+ "node.update"(_msg, ctx) {
2630
+ const result = runBivyUpdate();
2631
+ ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
2632
+ },
2600
2633
  // Fetch a stored attachment's bytes by content hash. The relay client (a phone
2601
2634
  // not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
2602
2635
  // over the encrypted tunnel instead; the relay framing chunks the base64 payload
@@ -3527,6 +3560,7 @@ const RELAY_COMMANDS = {
3527
3560
  source: rec.source,
3528
3561
  title: rec.session.getName(),
3529
3562
  model: rec.session.getCurrentModel()?.name,
3563
+ sandbox: rec.sandbox,
3530
3564
  };
3531
3565
  let dirtyPatch;
3532
3566
  if (rec.worktree) {
@@ -3535,6 +3569,14 @@ const RELAY_COMMANDS = {
3535
3569
  }
3536
3570
  catch { /* best effort — omit dirty state */ }
3537
3571
  }
3572
+ // Publish the source branch so a cross-node fork's COMMITTED work travels
3573
+ // via origin (the destination adopts `origin/<branch>`; see
3574
+ // resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
3575
+ // for a genuine cross-node fork — a same-node cross-agent fork adopts the
3576
+ // LOCAL branch and needs no push. Best-effort: a no-token/offline node just
3577
+ // falls back to the default base downstream.
3578
+ if (msg.crossNode === true)
3579
+ await pushForkSourceBranch(rec);
3538
3580
  // Refresh the account model-auth vault so the destination node can pull
3539
3581
  // this session's model credentials during import (fork credential-move,
3540
3582
  // docs/session-fork-plan.md). Best-effort: local-only nodes just skip it.
@@ -3615,6 +3657,7 @@ const RELAY_COMMANDS = {
3615
3657
  source: rec.source,
3616
3658
  title: rec.session.getName(),
3617
3659
  model: rec.session.getCurrentModel()?.name,
3660
+ sandbox: rec.sandbox,
3618
3661
  };
3619
3662
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
3620
3663
  // re-applies it into the fork's fresh worktree. Local git ops only.
@@ -5826,6 +5869,48 @@ async function applyRequestedModel(record, model) {
5826
5869
  broadcast({ type: "session.error", sessionId: record.id, error: error instanceof Error ? error.message : "Selected model is not available on this node." });
5827
5870
  }
5828
5871
  }
5872
+ // Serialize clone + worktree work per repo directory. Two forks (or a fork and
5873
+ // a GitHub pickup) hitting the same shared clone concurrently race on
5874
+ // `git worktree add`/`remove` and the `.bivy/worktrees` dir — the loser used to
5875
+ // see "already exists"/"already checked out" or, worse, `createWorktree`'s
5876
+ // adopt-path `rmSync` clearing a sibling's tree. A lightweight per-key async
5877
+ // mutex removes the race without a filesystem lock.
5878
+ const repoWorktreeLocks = new Map();
5879
+ async function withRepoLock(key, fn) {
5880
+ const prev = repoWorktreeLocks.get(key) ?? Promise.resolve();
5881
+ // Chain the map's tail on the PREVIOUS holder settling (never rejecting), so a
5882
+ // failing fork doesn't poison the next waiter's gate. Each caller still awaits
5883
+ // its own `run` and gets its own result/exception. Bounded by repo count.
5884
+ const gate = prev.then(() => { }, () => { });
5885
+ const run = gate.then(fn);
5886
+ repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
5887
+ return run;
5888
+ }
5889
+ /**
5890
+ * Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
5891
+ * the node, so a cross-node fork's committed work travels via origin (the
5892
+ * destination bases its adopted worktree on `origin/<branch>` — see
5893
+ * `resolveAdoptBaseRef`). Guarded by a token + repo backing; a failure just
5894
+ * means the destination falls back to the default base and the dirty patch.
5895
+ */
5896
+ async function pushForkSourceBranch(rec) {
5897
+ const parts = repoSessionParts(rec);
5898
+ if (!parts)
5899
+ return;
5900
+ const { wt, parsed } = parts;
5901
+ try {
5902
+ const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5903
+ if (!token)
5904
+ return;
5905
+ const cfg = { token, owner: parsed.owner, repo: parsed.repo, repoDir: wt.repoRoot, label: "bivy", claimLabel: "bivy:in-progress", pollMs: 60_000 };
5906
+ await pushBranch(cfg, wt.path, wt.branch);
5907
+ rec.branchPushed = true;
5908
+ }
5909
+ catch {
5910
+ // offline / no rights / protected branch — committed work may not reach a
5911
+ // cross-node destination, but the fork still proceeds from the best base.
5912
+ }
5913
+ }
5829
5914
  /**
5830
5915
  * Stand a forked session up on THIS node from a `ForkBundle`: credential-move,
5831
5916
  * (optional) prerequisite detection, repo/worktree reconstruction, transcript
@@ -5836,8 +5921,10 @@ async function applyRequestedModel(record, model) {
5836
5921
  */
5837
5922
  async function standUpFork(opts) {
5838
5923
  const { bundle, targetRuntimeId } = opts;
5839
- const targetRuntime = getRuntime(targetRuntimeId);
5840
5924
  const fallback = opts.fallback ?? { workspace: defaultWorkspace, cwd: defaultWorkspace };
5925
+ // Carry the source's sandbox tier so a sandboxed session forks into a
5926
+ // sandboxed one, rather than defaulting to this node's tier (fork.ts).
5927
+ const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
5841
5928
  // Credential-move: if the chosen model's provider isn't logged in on this node,
5842
5929
  // pull the account model-auth vault (a login done on another node carries over),
5843
5930
  // then re-check. Best-effort — a local-only node just skips it.
@@ -5849,41 +5936,64 @@ async function standUpFork(opts) {
5849
5936
  modelConfigured = await providerConfigured();
5850
5937
  }
5851
5938
  // Prerequisite detection. A missing AGENT is a hard blocker — stop before any
5852
- // clone/worktree work. Skipped for a same-node local fork.
5939
+ // clone/worktree work. Skipped for a same-node local fork. Read the agent's
5940
+ // availability + display name from the runtime REGISTRY (which never throws)
5941
+ // rather than resolving the runtime up front: `getRuntime` throws for a
5942
+ // known-but-not-installed agent, which — called eagerly — surfaced a raw
5943
+ // "not available" string with an empty `missing[]` instead of this friendly
5944
+ // install checklist. An unknown id (no registry entry) is treated as
5945
+ // unavailable so it, too, degrades to the checklist rather than a getRuntime throw.
5853
5946
  const agentInfo = listRuntimes().find((r) => r.id === targetRuntimeId);
5854
- const agentAvailable = agentInfo ? agentInfo.status === "available" : true;
5947
+ const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
5948
+ const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
5855
5949
  const prereqInput = {
5856
- agent: { id: targetRuntimeId, displayName: targetRuntime.displayName, available: agentAvailable },
5950
+ agent: { id: targetRuntimeId, displayName: agentDisplayName, available: agentAvailable },
5857
5951
  ...(modelProvider ? { model: { provider: modelProvider, configured: Boolean(modelConfigured) } } : {}),
5858
5952
  };
5859
5953
  if (opts.detectPrereqs) {
5860
5954
  const early = evaluateForkPrereqs(prereqInput);
5861
5955
  if (blockingForkPrereqs(early).length > 0) {
5862
- return { ok: false, error: `${targetRuntime.displayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5956
+ return { ok: false, error: `${agentDisplayName} is not installed on the destination node.`, missing: missingForkPrereqs(early) };
5863
5957
  }
5864
5958
  }
5959
+ // Safe now: the agent is available (or this is a same-node local fork whose
5960
+ // agent is self-evidently present). The per-session sandbox tier bakes into
5961
+ // the runtime's launch flags.
5962
+ const targetRuntime = getRuntime(targetRuntimeId, forkSandbox);
5865
5963
  // Reconstruct repo + worktree when the source was repo-backed.
5866
5964
  let workspace = fallback.workspace;
5867
5965
  let cwd = fallback.cwd;
5868
5966
  let repoReachable;
5869
5967
  let worktree;
5968
+ let dirtyWarning;
5870
5969
  const parsed = bundle.record.repoSlug ? parseRepo(bundle.record.repoSlug) : undefined;
5871
5970
  if (parsed) {
5872
5971
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
5873
5972
  repoReachable = Boolean(token);
5874
5973
  const repoDir = await cloneOrUpdateRepo({ owner: parsed.owner, repo: parsed.repo, token, root: reposRoot });
5875
5974
  const srcBranch = bundle.record.branch;
5876
- let wt;
5877
- if (opts.worktree === "fresh") {
5878
- // Cut a new branch from the source branch (or the repo's default base).
5879
- const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5880
- wt = await createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5881
- }
5882
- else {
5883
- // Adopt the source branch, or a fresh random worktree when it had none.
5884
- wt = await createWorktree({ repoDir, id: srcBranch ?? `fork-${randomBytes(6).toString("hex")}`, branch: srcBranch, base: srcBranch ? undefined : await resolveDefaultBaseRef(repoDir) });
5885
- }
5886
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
5975
+ // Serialize clone-adjacent worktree ops on this repo so concurrent forks /
5976
+ // pickups don't race on `git worktree add` or clobber each other's trees.
5977
+ const wt = await withRepoLock(repoDir, async () => {
5978
+ if (opts.worktree === "fresh") {
5979
+ // Same-node fork: cut a NEW branch from the source's LOCAL branch (which
5980
+ // holds its latest, possibly-unpushed commits) or the repo default.
5981
+ const forkBranch = `${srcBranch ?? "fork"}-fork-${randomBytes(4).toString("hex")}`;
5982
+ return createWorktree({ repoDir, id: forkBranch, branch: forkBranch, base: srcBranch ?? await resolveDefaultBaseRef(repoDir) });
5983
+ }
5984
+ // Cross-node adopt: the source branch has no LOCAL ref here. Base the
5985
+ // adopted branch on the pushed `origin/<branch>` so committed work travels
5986
+ // (was: undefined → the destination's DEFAULT branch, silently dropping
5987
+ // every commit). Give the worktree DIR a unique suffix so a same-branch
5988
+ // adopt never reuses — or, via createWorktree's stale-dir cleanup, deletes
5989
+ // — another live session's tree.
5990
+ const dirId = `${srcBranch ?? "fork"}-${randomBytes(4).toString("hex")}`;
5991
+ const base = srcBranch ? await resolveAdoptBaseRef(repoDir, srcBranch) : await resolveDefaultBaseRef(repoDir);
5992
+ return createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
5993
+ });
5994
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
5995
+ if (applied.warning)
5996
+ dirtyWarning = applied.warning;
5887
5997
  workspace = repoDir;
5888
5998
  cwd = wt.path;
5889
5999
  worktree = wt;
@@ -5897,8 +6007,10 @@ async function standUpFork(opts) {
5897
6007
  const forkRepoRoot = await gitRepoRoot(cwd);
5898
6008
  if (forkRepoRoot) {
5899
6009
  const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
5900
- const wt = await createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch });
5901
- applyDirtyPatch(wt.path, bundle.dirtyPatch);
6010
+ const wt = await withRepoLock(forkRepoRoot, () => createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
6011
+ const applied = applyDirtyPatch(wt.path, bundle.dirtyPatch);
6012
+ if (applied.warning)
6013
+ dirtyWarning = applied.warning;
5902
6014
  workspace = forkRepoRoot;
5903
6015
  cwd = wt.path;
5904
6016
  worktree = wt;
@@ -5908,8 +6020,8 @@ async function standUpFork(opts) {
5908
6020
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
5909
6021
  const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
5910
6022
  const record = plan.kind === "resume"
5911
- ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false })
5912
- : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, makeActive: false });
6023
+ ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
6024
+ : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
5913
6025
  // Mark the new session as a fork of its source, so the run card can show
5914
6026
  // "Forked from …" and the lineage survives a reload (persisted below). Just
5915
6027
  // the parent's session id — an identifier, not content, so it's safe to
@@ -5928,6 +6040,10 @@ async function standUpFork(opts) {
5928
6040
  }
5929
6041
  if (bundle.record.title && !record.session.getName())
5930
6042
  record.session.setName(bundle.record.title);
6043
+ // Surface a non-fatal note when the source's uncommitted changes didn't apply
6044
+ // cleanly, so the fork isn't silently missing work-in-progress.
6045
+ if (dirtyWarning)
6046
+ broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
5931
6047
  await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
5932
6048
  persistSessionMetadata(record);
5933
6049
  scheduleAdvertise();
@@ -7640,7 +7756,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7640
7756
  if (makeActive)
7641
7757
  active = existing;
7642
7758
  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) });
7643
- void maybeNotifyBivyUpdate(existing);
7759
+ void maybeNotifyBivyUpdate();
7644
7760
  return existing;
7645
7761
  }
7646
7762
  // Pick the agent for this session (fixed for its life). Resuming a tagged
@@ -7829,7 +7945,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7829
7945
  if (makeActive)
7830
7946
  active = record;
7831
7947
  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) });
7832
- void maybeNotifyBivyUpdate(record);
7948
+ void maybeNotifyBivyUpdate();
7833
7949
  scheduleAdvertise();
7834
7950
  return record;
7835
7951
  }
@@ -8544,6 +8660,16 @@ app.get("/api/node/info", (_req, res) => {
8544
8660
  sandbox: sandboxInfo(),
8545
8661
  });
8546
8662
  });
8663
+ // One-tap "Update this node" from the app's version-mismatch banner, for
8664
+ // direct/LAN clients (the relay path uses the RELAY_COMMANDS "node.update"
8665
+ // handler). Both call the same runBivyUpdate.
8666
+ app.post("/api/node/update", (_req, res) => {
8667
+ const result = runBivyUpdate();
8668
+ if (result.ok)
8669
+ res.json({ ok: true });
8670
+ else
8671
+ res.status(500).json({ ok: false, error: result.error });
8672
+ });
8547
8673
  // Build collectNodeStats() options, resolving the optional session so the panel
8548
8674
  // can attribute a session-scoped tier (its live agent process + workspace size).
8549
8675
  function nodeStatsOptsFor(sessionId) {
@@ -10444,6 +10570,14 @@ wss.on("connection", (socket, req) => {
10444
10570
  // sharing a PTY size it to their min (see TerminalManager.setClientSize).
10445
10571
  const clientTerminalId = `sock-${randomUUID()}`;
10446
10572
  socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
10573
+ // Authoritative version status on every connect: `latest` set means this node
10574
+ // is behind (banner shows); absent means up to date (banner + any "Updating…"
10575
+ // state clear — this is how the banner disappears after an update lands and
10576
+ // the socket reconnects on the new build). Then (re)run the throttled check so
10577
+ // a freshly-opened app surfaces a newly-available update without waiting for a
10578
+ // session turn.
10579
+ socket.send(JSON.stringify({ type: "node.update", current: currentVersion() ?? "", latest: pendingBivyUpdate?.latest }));
10580
+ void checkBivyUpdate();
10447
10581
  socket.on("message", (raw) => {
10448
10582
  let msg;
10449
10583
  try {
@@ -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.82",
3
+ "version": "0.6.0-staging.84",
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.",