@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.
@@ -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
- const sessions = await SessionManager.listAll(path.join(piAgentDir(), "sessions"));
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
- let dirtyPatch;
49
- if (rec.worktree) {
50
- try {
51
- dirtyPatch = captureDirtyPatch(rec.worktree.path);
52
- }
53
- catch { /* best effort — omit dirty state */ }
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
- let dirtyPatch;
139
- if (rec.worktree) {
140
- try {
141
- dirtyPatch = captureDirtyPatch(rec.worktree.path);
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
- * than the cap (big or binary churn), we DON'T inline it — `capture` returns
13
- * `pushedInstead: true` and the caller commits & pushes the branch so the
14
- * destination reproduces from the pushed commit instead.
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
- if (Buffer.byteLength(patch, "utf8") > maxBytes) {
47
- return { patch: "", untracked: [], pushedInstead: true };
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`. A no-op when the
53
- * source pushed the branch instead (`pushedInstead`) or the working tree was
54
- * clean (empty patch). Uses `git apply` so both tracked hunks and untracked
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 (!dirty || dirty.pushedInstead || !dirty.patch.trim())
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;
@@ -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
- // Prefer the build-free readMessages fast path (pi/Claude); fall back to the
13
- // live session's transcript for runtimes without one (the generic CLI runtime),
14
- // so a fork *from* any agent still carries its real history.
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) ?? messages;
20
+ messages = runtime.readMessages(sessionFile);
19
21
  }
20
22
  catch {
21
- // Runtime-native readers are best-effort. The live transcript is the
22
- // universal source fallback and keeps one broken adapter from blocking a
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
- return out;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.3-staging.1",
3
+ "version": "0.16.3-staging.3",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",