@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.
- package/dist/repo-workspace.js +19 -0
- package/dist/server.js +176 -42
- package/dist/session/fork-dirty.js +41 -3
- package/package.json +1 -1
package/dist/repo-workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
645
|
-
if (!
|
|
646
|
+
const latest = (await res.json()).version;
|
|
647
|
+
if (!latest || !isNewerVersion(latest, current))
|
|
646
648
|
return;
|
|
647
|
-
|
|
648
|
-
|
|
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" :
|
|
5947
|
+
const agentAvailable = agentInfo ? agentInfo.status === "available" : false;
|
|
5948
|
+
const agentDisplayName = agentInfo?.displayName ?? targetRuntimeId;
|
|
5855
5949
|
const prereqInput = {
|
|
5856
|
-
agent: { id: targetRuntimeId, displayName:
|
|
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: `${
|
|
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
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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