@bivy/bivy 0.16.3-staging.1 → 0.16.3-staging.3
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/agents/codex/integration.js +4 -0
- package/dist/agents/pi/integration.js +6 -1
- package/dist/controllers/fork-commands.js +12 -15
- package/dist/session/fork-dirty.js +27 -10
- package/dist/session/fork-standup.js +28 -0
- package/dist/session/fork.js +9 -8
- package/dist/session/run-terminal.js +4 -1
- package/package.json +1 -1
|
@@ -107,6 +107,9 @@ export function codexAppServerRuntime(tier) {
|
|
|
107
107
|
interactiveTui: codexCommandAvailable(),
|
|
108
108
|
nativeSessionDiscovery: true,
|
|
109
109
|
nativeSessionAdoption: true,
|
|
110
|
+
// `bivy run codex` learns the rollout id from Codex's on-disk store after
|
|
111
|
+
// launch, so an unpinned native terminal can be taken over as chat.
|
|
112
|
+
sessionDiscovery: true,
|
|
110
113
|
},
|
|
111
114
|
resumable: true,
|
|
112
115
|
loadHistory: (sessionId) => loadCodexTranscript(sessionId),
|
|
@@ -147,6 +150,7 @@ export function codexIntegration(origin) {
|
|
|
147
150
|
interactiveTui: installed,
|
|
148
151
|
nativeSessionDiscovery: true,
|
|
149
152
|
nativeSessionAdoption: true,
|
|
153
|
+
sessionDiscovery: true,
|
|
150
154
|
},
|
|
151
155
|
nativeSandbox: true,
|
|
152
156
|
supportTier: "supported",
|
|
@@ -19,7 +19,12 @@ export function piCommand() {
|
|
|
19
19
|
* that native store rather than only the governed one. */
|
|
20
20
|
export async function listNativePiSessions() {
|
|
21
21
|
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
22
|
-
|
|
22
|
+
// Do not pass the sessions root here. Pi's default store is split into one
|
|
23
|
+
// encoded subdirectory per cwd, and SessionManager.listAll(customDir) scans
|
|
24
|
+
// only that directory itself. The no-argument form walks those cwd
|
|
25
|
+
// subdirectories under PI_CODING_AGENT_DIR; passing `<agentDir>/sessions`
|
|
26
|
+
// therefore made every normal native TUI session invisible to takeover.
|
|
27
|
+
const sessions = await SessionManager.listAll();
|
|
23
28
|
return sessions.map((session) => ({
|
|
24
29
|
id: session.id,
|
|
25
30
|
path: session.path,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// server injects its own session lookup, runtime access, event emit, and the fork
|
|
9
9
|
// helpers as composition deps, so this stays free of daemon state and unit-testable.
|
|
10
10
|
import { buildForkBundle } from "../session/fork.js";
|
|
11
|
-
import { captureDirtyPatch } from "../session/fork-dirty.js";
|
|
11
|
+
import { captureDirtyPatch, captureWorkspaceDirtyPatch } from "../session/fork-dirty.js";
|
|
12
12
|
export function createForkCommands(deps) {
|
|
13
13
|
return {
|
|
14
14
|
async "session.fork.retire-source"(msg) {
|
|
@@ -45,13 +45,12 @@ export function createForkCommands(deps) {
|
|
|
45
45
|
}
|
|
46
46
|
try {
|
|
47
47
|
const forkRecord = deps.forkRecordFor(rec);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
48
|
+
// A git workspace must be captured successfully, including sessions that
|
|
49
|
+
// were started in an unmanaged checkout rather than a Bivy worktree.
|
|
50
|
+
// Treating a git/read failure as "clean" could retire the only WIP copy.
|
|
51
|
+
const dirtyPatch = rec.worktree
|
|
52
|
+
? captureDirtyPatch(rec.worktree.path)
|
|
53
|
+
: captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
|
|
55
54
|
// Publish the source branch so a cross-node fork's COMMITTED work travels
|
|
56
55
|
// via origin (the destination adopts `origin/<branch>`; see
|
|
57
56
|
// resolveAdoptBaseRef). Uncommitted work rides the dirtyPatch above. Only
|
|
@@ -135,13 +134,11 @@ export function createForkCommands(deps) {
|
|
|
135
134
|
const forkRecord = deps.forkRecordFor(rec);
|
|
136
135
|
// Carry uncommitted work: capture from the SOURCE worktree; standUpFork
|
|
137
136
|
// re-applies it into the fork's fresh worktree. Local git ops only.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
catch { /* best effort */ }
|
|
144
|
-
}
|
|
137
|
+
// Include unmanaged git workspaces too. standUpFork will cut an isolated
|
|
138
|
+
// worktree for them, so their uncommitted state must ride with it.
|
|
139
|
+
const dirtyPatch = rec.worktree
|
|
140
|
+
? captureDirtyPatch(rec.worktree.path)
|
|
141
|
+
: captureWorkspaceDirtyPatch(rec.session.cwd || rec.workspace);
|
|
145
142
|
// Same runtime → the bundle carries the native payload → full fidelity.
|
|
146
143
|
const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
|
|
147
144
|
// Cut a fresh fork branch (the source still holds its own); skip prereq
|
|
@@ -8,12 +8,21 @@ import path from "node:path";
|
|
|
8
8
|
* checks it out); this carries the in-flight
|
|
9
9
|
* edits on top so a fork never silently drops work-in-progress.
|
|
10
10
|
*
|
|
11
|
-
* The patch is size-capped. When the working tree is larger
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The patch is size-capped. When the working tree is larger than the cap, the
|
|
12
|
+
* capture carries an explicit oversized marker. Fork stand-up rejects that
|
|
13
|
+
* bundle: pushing a branch cannot carry uncommitted files, and silently treating
|
|
14
|
+
* the marker as success would lose work-in-progress.
|
|
15
15
|
*/
|
|
16
16
|
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB of patch text
|
|
17
|
+
/** Find the containing git checkout without throwing for ordinary non-git
|
|
18
|
+
* workspaces. A discovered checkout is captured fail-closed by the caller. */
|
|
19
|
+
export function captureWorkspaceDirtyPatch(cwd, opts = {}) {
|
|
20
|
+
const probe = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
|
|
21
|
+
if (probe.status !== 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
const root = String(probe.stdout || "").trim();
|
|
24
|
+
return root ? captureDirtyPatch(root, opts) : undefined;
|
|
25
|
+
}
|
|
17
26
|
function git(repoDir, args) {
|
|
18
27
|
try {
|
|
19
28
|
return execFileSync("git", ["-C", repoDir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -43,15 +52,17 @@ export function captureDirtyPatch(repoDir, opts = {}) {
|
|
|
43
52
|
// Each untracked file becomes a "new file" patch via --no-index against /dev/null.
|
|
44
53
|
const untrackedPatches = untracked.map((rel) => git(repoDir, ["diff", "--no-index", "--binary", "--", "/dev/null", rel]));
|
|
45
54
|
const patch = [tracked, ...untrackedPatches].filter(Boolean).join("");
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
const byteLength = Buffer.byteLength(patch, "utf8");
|
|
56
|
+
if (byteLength > maxBytes) {
|
|
57
|
+
return { patch: "", untracked, pushedInstead: true, byteLength, maxBytes };
|
|
48
58
|
}
|
|
49
59
|
return { patch, untracked };
|
|
50
60
|
}
|
|
51
61
|
/**
|
|
52
|
-
* Re-apply a captured patch onto a fresh checkout at `repoDir`.
|
|
53
|
-
*
|
|
54
|
-
* clean
|
|
62
|
+
* Re-apply a captured patch onto a fresh checkout at `repoDir`. An oversized
|
|
63
|
+
* marker produces a warning as a final defence; normal fork stand-up rejects it
|
|
64
|
+
* before reaching this function. A clean patch is a no-op. Uses `git apply` so
|
|
65
|
+
* both tracked hunks and untracked
|
|
55
66
|
* new-file hunks (produced via `--no-index`) land correctly.
|
|
56
67
|
*
|
|
57
68
|
* NEVER throws: a fork's uncommitted changes are best-effort, and the source's
|
|
@@ -64,7 +75,13 @@ export function captureDirtyPatch(repoDir, opts = {}) {
|
|
|
64
75
|
* succeeds, minus the un-appliable working-tree edits.
|
|
65
76
|
*/
|
|
66
77
|
export function applyDirtyPatch(repoDir, dirty) {
|
|
67
|
-
if (
|
|
78
|
+
if (dirty?.pushedInstead) {
|
|
79
|
+
return {
|
|
80
|
+
applied: false,
|
|
81
|
+
warning: "The source working tree exceeded the fork transfer limit, so its uncommitted changes were not applied. The fork was stopped to prevent data loss.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (!dirty || !dirty.patch.trim())
|
|
68
85
|
return { applied: false };
|
|
69
86
|
const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
|
70
87
|
fs.writeFileSync(tmp, dirty.patch);
|
|
@@ -43,6 +43,20 @@ export function createForkStandUp(deps) {
|
|
|
43
43
|
async function standUpFork(opts) {
|
|
44
44
|
const { bundle, targetRuntimeId } = opts;
|
|
45
45
|
const fallback = opts.fallback ?? { workspace: deps.defaultWorkspace, cwd: deps.defaultWorkspace };
|
|
46
|
+
// An oversized dirty marker means no WIP bytes are present in the bundle.
|
|
47
|
+
// Never continue and pretend the pushed branch preserved them: git push only
|
|
48
|
+
// transports commits. The source remains untouched, so the user can commit,
|
|
49
|
+
// reduce the change set, or raise the configured transfer limit and retry.
|
|
50
|
+
if (bundle.dirtyPatch?.pushedInstead) {
|
|
51
|
+
const size = bundle.dirtyPatch.byteLength;
|
|
52
|
+
const limit = bundle.dirtyPatch.maxBytes;
|
|
53
|
+
const detail = size && limit ? ` (${Math.ceil(size / 1024)} KiB; limit ${Math.ceil(limit / 1024)} KiB)` : "";
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `The source has too many uncommitted changes to transfer safely${detail}. Commit them or reduce the working-tree changes, then retry; the source was not modified.`,
|
|
57
|
+
missing: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
46
60
|
// Carry the source's sandbox tier so a sandboxed session forks into a
|
|
47
61
|
// sandboxed one, rather than defaulting to this node's tier (fork.ts).
|
|
48
62
|
const forkSandbox = normalizeSandboxTier(bundle.record.sandbox);
|
|
@@ -131,6 +145,13 @@ export function createForkStandUp(deps) {
|
|
|
131
145
|
return deps.createWorktree({ repoDir, id: dirId, branch: srcBranch, base });
|
|
132
146
|
});
|
|
133
147
|
const applied = deps.applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
148
|
+
if (bundle.dirtyPatch?.patch.trim() && applied.applied !== true) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: applied.warning ?? "The source's uncommitted changes could not be applied safely. The source was not modified; retry after committing or reducing the changes.",
|
|
152
|
+
missing: [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
134
155
|
if (applied.warning)
|
|
135
156
|
dirtyWarning = applied.warning;
|
|
136
157
|
workspace = repoDir;
|
|
@@ -148,6 +169,13 @@ export function createForkStandUp(deps) {
|
|
|
148
169
|
const forkBranch = `bivy/fork-${randomBytes(6).toString("hex")}`;
|
|
149
170
|
const wt = await deps.withRepoLock(forkRepoRoot, () => deps.createWorktree({ repoDir: forkRepoRoot, id: forkBranch, branch: forkBranch }));
|
|
150
171
|
const applied = deps.applyDirtyPatch(wt.path, bundle.dirtyPatch);
|
|
172
|
+
if (bundle.dirtyPatch?.patch.trim() && applied.applied !== true) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: applied.warning ?? "The source's uncommitted changes could not be applied safely. The source was not modified; retry after committing or reducing the changes.",
|
|
176
|
+
missing: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
151
179
|
if (applied.warning)
|
|
152
180
|
dirtyWarning = applied.warning;
|
|
153
181
|
workspace = forkRepoRoot;
|
package/dist/session/fork.js
CHANGED
|
@@ -9,18 +9,19 @@ import { normalizeMessages, buildSeedPrompt, buildForkHistory, } from "./transcr
|
|
|
9
9
|
*/
|
|
10
10
|
export function buildForkBundle(opts) {
|
|
11
11
|
const { runtime, sessionFile, record } = opts;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// The live session is authoritative: a persisted native reader can lag the
|
|
13
|
+
// current turn (or return an empty-but-valid snapshot during a flush race).
|
|
14
|
+
// Prefer liveMessages whenever the caller has them, and use readMessages only
|
|
15
|
+
// when no live transcript was supplied. This avoids silently truncating a fork
|
|
16
|
+
// made while the source is open, while still supporting offline/native refs.
|
|
15
17
|
let messages = opts.liveMessages;
|
|
16
|
-
if (sessionFile && runtime.readMessages) {
|
|
18
|
+
if (messages === undefined && sessionFile && runtime.readMessages) {
|
|
17
19
|
try {
|
|
18
|
-
messages = runtime.readMessages(sessionFile)
|
|
20
|
+
messages = runtime.readMessages(sessionFile);
|
|
19
21
|
}
|
|
20
22
|
catch {
|
|
21
|
-
// Runtime-native readers are best-effort.
|
|
22
|
-
//
|
|
23
|
-
// fork out to every other agent.
|
|
23
|
+
// Runtime-native readers are best-effort. An unavailable reader yields an
|
|
24
|
+
// empty portable transcript, which still degrades safely to a seed.
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
const normalized = normalizeMessages(messages, {
|
|
@@ -123,7 +123,10 @@ export function createRunTerminals(deps) {
|
|
|
123
123
|
const { autoName: _autoName, ...publicMeta } = t.meta;
|
|
124
124
|
out.push({ termId: t.id, workspace: t.workspace, createdAt: t.createdAt, lastActivityAt: t.lastActivityAt, pid: terminals.pid(t.id), ...publicMeta, takeoverReady: Boolean(sessionRef) });
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// Discovery/name lookup above is asynchronous. A takeover can close a PTY
|
|
127
|
+
// while this list is being assembled; never let that older list resolve
|
|
128
|
+
// afterward and resurrect the closed terminal in the sidebar.
|
|
129
|
+
return out.filter((terminal) => runTerminals.has(terminal.termId));
|
|
127
130
|
}
|
|
128
131
|
function broadcastRunTerminalList() {
|
|
129
132
|
void runTerminalList().then((listed) => deps.broadcast({ type: "terminal.list", terminals: listed })).catch(() => { });
|
package/package.json
CHANGED