@bivy/bivy 0.0.0 → 0.1.0-staging.2

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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Ship a session's git checkpoint between the owner node's repo and a standby's
5
+ // replica repo as a git BUNDLE (docs/session-replication.md, worktree sync).
6
+ //
7
+ // The harness records each turn's checkpoint as a commit under the private ref
8
+ // `refs/bivy/checkpoints/<slug>` (src/harness/checkpoint.ts). To replicate the
9
+ // workspace we move those commits — nothing else — to the standby:
10
+ //
11
+ // owner: git bundle create <tmp> [<sinceSha>..]<ref> → bytes over the wire
12
+ // standby: git fetch <tmp> <ref>:<ref> → objects land locally
13
+ // git read-tree <ref> && git checkout-index -a -f → materialize worktree
14
+ //
15
+ // A bundle is a self-contained, verifiable pack: when `sinceSha` is given it is a
16
+ // THIN bundle carrying only the new commits (the standby already holds the base),
17
+ // falling back to a FULL bundle when the standby has nothing or its base is gone.
18
+ // If a thin bundle's prerequisite is missing on the standby, `applyCheckpointBundle`
19
+ // reports `needFull` so the owner re-sends a full one — the same self-healing shape
20
+ // as the transcript cursor.
21
+ //
22
+ // Kept dependency-light (just `git` + a temp file) and unit-tested against real
23
+ // repos (test/checkpoint-pack.test.ts).
24
+ import { execFile } from "node:child_process";
25
+ import { promisify } from "node:util";
26
+ import { randomUUID } from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ const exec = promisify(execFile);
31
+ const IDENTITY_ENV = {
32
+ GIT_AUTHOR_NAME: "Bivy Harness",
33
+ GIT_AUTHOR_EMAIL: "harness@bivy.local",
34
+ GIT_COMMITTER_NAME: "Bivy Harness",
35
+ GIT_COMMITTER_EMAIL: "harness@bivy.local",
36
+ };
37
+ /** The private checkpoint ref for a session (mirrors CheckpointStore.slugRef). */
38
+ export function checkpointRef(sessionId) {
39
+ const slug = sessionId.replace(/[^A-Za-z0-9._-]/g, "-");
40
+ return `refs/bivy/checkpoints/${slug}`;
41
+ }
42
+ function git(cwd, args) {
43
+ return exec("git", ["-C", cwd, ...args], {
44
+ env: { ...process.env, ...IDENTITY_ENV },
45
+ maxBuffer: 256 * 1024 * 1024,
46
+ });
47
+ }
48
+ async function withTempFile(fn) {
49
+ const file = path.join(os.tmpdir(), `bivy-ckpt-${randomUUID()}.bundle`);
50
+ try {
51
+ return await fn(file);
52
+ }
53
+ finally {
54
+ await fs.promises.rm(file, { force: true });
55
+ }
56
+ }
57
+ /**
58
+ * Build a bundle carrying the session's checkpoint commit(s). Returns the bundle
59
+ * bytes, or `null` when there is nothing to send (no checkpoint ref yet, or the
60
+ * standby is already at the tip so an incremental range would be empty).
61
+ *
62
+ * @param repoDir the owner's worktree (any path inside the repo)
63
+ * @param sessionId the session whose checkpoint ref to bundle
64
+ * @param sinceSha the standby's last-known checkpoint sha; when it is an ancestor
65
+ * of the current tip, only the delta is bundled (thin)
66
+ */
67
+ export async function createCheckpointBundle(repoDir, sessionId, sinceSha) {
68
+ const ref = checkpointRef(sessionId);
69
+ // No checkpoint ref → nothing to replicate yet.
70
+ const tip = await git(repoDir, ["rev-parse", "--verify", "--quiet", ref]).then((r) => r.stdout.trim()).catch(() => "");
71
+ if (!tip)
72
+ return null;
73
+ // Already current — skip an empty bundle.
74
+ if (sinceSha && sinceSha === tip)
75
+ return null;
76
+ // Use a thin range only when `sinceSha` is a real ancestor we can negate against.
77
+ let range = ref;
78
+ if (sinceSha) {
79
+ const isAncestor = await git(repoDir, ["merge-base", "--is-ancestor", sinceSha, ref]).then(() => true).catch(() => false);
80
+ if (isAncestor)
81
+ range = `${sinceSha}..${ref}`;
82
+ }
83
+ return withTempFile(async (file) => {
84
+ try {
85
+ await git(repoDir, ["bundle", "create", file, range]);
86
+ }
87
+ catch {
88
+ // An empty range ("Refusing to create empty bundle") or any bundle failure
89
+ // → nothing to ship.
90
+ return null;
91
+ }
92
+ return fs.promises.readFile(file);
93
+ });
94
+ }
95
+ /**
96
+ * Apply a checkpoint bundle into the standby's replica repo, advancing its
97
+ * checkpoint ref. Returns `{ ok:false, needFull:true }` when the bundle is thin
98
+ * and its prerequisite base commit is missing locally, so the caller can request
99
+ * a full bundle. Does NOT touch the working tree — call `materializeCheckpoint`
100
+ * for that once the objects are present.
101
+ */
102
+ export async function applyCheckpointBundle(replicaDir, sessionId, bundle) {
103
+ const ref = checkpointRef(sessionId);
104
+ return withTempFile(async (file) => {
105
+ await fs.promises.writeFile(file, bundle);
106
+ // A thin bundle with an absent prerequisite fails verification here.
107
+ const verified = await git(replicaDir, ["bundle", "verify", file]).then(() => true).catch(() => false);
108
+ if (!verified)
109
+ return { ok: false, needFull: true };
110
+ await git(replicaDir, ["fetch", file, `${ref}:${ref}`]);
111
+ return { ok: true };
112
+ });
113
+ }
114
+ /**
115
+ * Materialize the standby's checkpoint ref into its replica working tree, so a
116
+ * promoted session continues from the exact files of the last replicated turn.
117
+ * Uses a throwaway index and force checkout — the replica worktree is ours to own.
118
+ */
119
+ export async function materializeCheckpoint(replicaDir, sessionId) {
120
+ const ref = checkpointRef(sessionId);
121
+ const tmpIndex = path.join(os.tmpdir(), `bivy-ckpt-index-${randomUUID()}`);
122
+ try {
123
+ const withIndex = (args) => exec("git", ["-C", replicaDir, ...args], {
124
+ env: { ...process.env, ...IDENTITY_ENV, GIT_INDEX_FILE: tmpIndex },
125
+ maxBuffer: 256 * 1024 * 1024,
126
+ });
127
+ await withIndex(["read-tree", ref]);
128
+ await withIndex(["checkout-index", "-a", "-f"]);
129
+ }
130
+ finally {
131
+ await fs.promises.rm(tmpIndex, { force: true });
132
+ }
133
+ }
@@ -0,0 +1,340 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Single append-only per-session event log (docs/dramatic-simplification-plan.md,
5
+ // slice 2). A session's transcript detail used to live in THREE whole-file JSON
6
+ // stores — `intermediate-messages/<id>.json`, `tool-activities/<id>.json`, and the
7
+ // base transcript `transcripts/<id>.json` — each rewritten in full on every change
8
+ // and each able to drift from the others. This collapses all of them into one
9
+ // append-only JSONL log (`event-log/<id>.jsonl`): writes append, and history is
10
+ // DERIVED by replaying the log.
11
+ //
12
+ // The log carries two independent projections, distinguished by `bivyKind`:
13
+ // - OVERLAY entries (`intermediate` / `tool`): reasoning + tool-activity detail,
14
+ // folded by `replayExtras` (reproducing the legacy per-kind fold) into the flat
15
+ // `extras` list `mergeTranscript` consumes.
16
+ // - BASE entries (`base`): the runtime's own transcript (user prompts + assistant
17
+ // text). The base is a SNAPSHOT that is overwritten each turn and SHRINKS on
18
+ // compaction, so it cannot be appended naively. Instead each snapshot is stored
19
+ // as a bounded DELTA against the previous one: a `reset:false` record carrying
20
+ // only the new tail when the snapshot merely extends the last (the common case),
21
+ // or a `reset:true` record carrying the full snapshot when it shrinks or a
22
+ // prefix message changed (compaction / in-place mutation). `baseReplay` folds
23
+ // these back — reset replaces, non-reset extends — reproducing the last snapshot
24
+ // exactly, so the fold is behaviour-preserving while keeping the file bounded
25
+ // (~O(total messages)) instead of O(turns × transcript size).
26
+ //
27
+ // The two projections are independent: base records and overlay records may be
28
+ // interleaved in any order on disk; each replay reads only its own kind.
29
+ import fs from "node:fs";
30
+ import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
31
+ function isOverlay(value) {
32
+ if (!value || typeof value !== "object")
33
+ return false;
34
+ const kind = value.bivyKind;
35
+ return (kind === "intermediate" || kind === "tool") && typeof value.afterMessageCount === "number";
36
+ }
37
+ function isBase(value) {
38
+ if (!value || typeof value !== "object")
39
+ return false;
40
+ const record = value;
41
+ return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean";
42
+ }
43
+ function isRecord(value) {
44
+ return isOverlay(value) || isBase(value);
45
+ }
46
+ /**
47
+ * Fold the intermediate-reasoning entries exactly as the legacy incremental
48
+ * upsert did (`upsertIntermediateMessage` in server.ts): last write wins per id;
49
+ * a same-text/same-anchor entry under a DIFFERENT id is merged onto the first
50
+ * (keeping its id and the earlier `createdAt`); the result is ordered by
51
+ * `afterMessageCount` then `createdAt`. Replaying the append log through this
52
+ * reproduces the array the legacy store held.
53
+ */
54
+ export function foldIntermediate(entries) {
55
+ const out = [];
56
+ for (const entry of entries) {
57
+ const entryText = normalizedIntermediateText(thinkingTextFromContent(entry.content));
58
+ const index = out.findIndex((m) => m.id != null && m.id === entry.id);
59
+ if (index >= 0) {
60
+ out[index] = entry;
61
+ }
62
+ else {
63
+ const dup = entryText
64
+ ? out.findIndex((m) => m.id !== entry.id && m.afterMessageCount === entry.afterMessageCount && normalizedIntermediateText(thinkingTextFromContent(m.content)) === entryText)
65
+ : -1;
66
+ if (dup >= 0)
67
+ out[dup] = { ...out[dup], ...entry, id: out[dup].id, createdAt: Math.min(out[dup].createdAt, entry.createdAt) };
68
+ else
69
+ out.push(entry);
70
+ }
71
+ out.sort((a, b) => a.afterMessageCount - b.afterMessageCount || a.createdAt - b.createdAt);
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Fold the tool-activity entries exactly as the legacy `upsertToolActivityMessage`
77
+ * did: merge onto an existing id (`{...existing, ...entry}`), else append; order by
78
+ * `afterMessageCount` then `createdAt`; keep only the most recent 500.
79
+ */
80
+ export function foldTool(entries) {
81
+ let out = [];
82
+ for (const entry of entries) {
83
+ const index = out.findIndex((m) => m.id != null && m.id === entry.id);
84
+ if (index >= 0)
85
+ out[index] = { ...out[index], ...entry };
86
+ else
87
+ out.push(entry);
88
+ out.sort((a, b) => a.afterMessageCount - b.afterMessageCount || a.createdAt - b.createdAt);
89
+ out = out.slice(-500);
90
+ }
91
+ return out;
92
+ }
93
+ /**
94
+ * Replay a session's overlay entries into the flat `extras` list `mergeTranscript`
95
+ * consumes — the intermediate entries folded first, then the tool entries, matching
96
+ * the legacy `[...loadIntermediateMessages, ...loadToolActivityMessages]` order.
97
+ * Base records are ignored (they are folded separately by `baseReplay`).
98
+ */
99
+ export function replayExtras(entries) {
100
+ const intermediate = [];
101
+ const tool = [];
102
+ for (const entry of entries) {
103
+ if (entry.bivyKind === "intermediate")
104
+ intermediate.push(entry);
105
+ else if (entry.bivyKind === "tool")
106
+ tool.push(entry);
107
+ }
108
+ return [...foldIntermediate(intermediate), ...foldTool(tool)];
109
+ }
110
+ /**
111
+ * Replay a session's base records into the base transcript: `reset` replaces the
112
+ * working array, non-reset extends it with its tail. The result equals the last
113
+ * full snapshot the server persisted, exactly. Overlay records are ignored.
114
+ */
115
+ export function baseReplay(entries) {
116
+ let base = [];
117
+ for (const entry of entries) {
118
+ if (entry.bivyKind !== "base")
119
+ continue;
120
+ if (entry.reset)
121
+ base = [...entry.messages];
122
+ else
123
+ base = base.concat(entry.messages);
124
+ }
125
+ return base;
126
+ }
127
+ /** Parse a JSONL log body into valid records, skipping malformed/blank lines. */
128
+ export function parseLog(body) {
129
+ const out = [];
130
+ for (const line of body.split("\n")) {
131
+ const trimmed = line.trim();
132
+ if (!trimmed)
133
+ continue;
134
+ try {
135
+ const value = JSON.parse(trimmed);
136
+ if (isRecord(value))
137
+ out.push(value);
138
+ }
139
+ catch { }
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * Append-only, coalesced, throttled JSONL log store — the write+read companion to
145
+ * the legacy `SidecarStore`, but appending lines instead of rewriting the whole
146
+ * file. In-memory state keeps reads cheap; disk writes coalesce to at most one per
147
+ * `throttleMs` per session (a burst of same-id deltas within a window collapses to
148
+ * one appended line). Durability at boundaries is explicit `flush()` (turn-end,
149
+ * close, delete, shutdown), mirroring `SidecarStore`.
150
+ */
151
+ export class EventLog {
152
+ dir;
153
+ pathFor;
154
+ redact;
155
+ throttleMs;
156
+ disk = new Map();
157
+ pending = new Map();
158
+ timers = new Map();
159
+ lastFlush = new Map();
160
+ counters = new Map();
161
+ // Per-message JSON of the base snapshot the log currently represents, so the next
162
+ // snapshot can be diffed (prefix-compared) into a bounded delta. Seeded from disk
163
+ // on first use of a session after a restart.
164
+ baseKeys = new Map();
165
+ constructor(dir, pathFor, redact = (t) => t, throttleMs = 500) {
166
+ this.dir = dir;
167
+ this.pathFor = pathFor;
168
+ this.redact = redact;
169
+ this.throttleMs = throttleMs;
170
+ }
171
+ load(id) {
172
+ const cached = this.disk.get(id);
173
+ if (cached)
174
+ return cached;
175
+ let data = [];
176
+ try {
177
+ data = parseLog(fs.readFileSync(this.pathFor(id), "utf8"));
178
+ }
179
+ catch { }
180
+ this.disk.set(id, data);
181
+ return data;
182
+ }
183
+ /** Allocate a unique synthetic coalescing key (for id-less records). */
184
+ syntheticKey(id) {
185
+ const next = (this.counters.get(id) ?? 0) + 1;
186
+ this.counters.set(id, next);
187
+ return `#${next}`;
188
+ }
189
+ /** Queue a fully-formed record into the pending batch and schedule a flush. */
190
+ enqueue(id, key, record) {
191
+ let batch = this.pending.get(id);
192
+ if (!batch) {
193
+ batch = new Map();
194
+ this.pending.set(id, batch);
195
+ }
196
+ // Same key within a flush window coalesces to one line (streaming deltas of one
197
+ // logical entry). Id-less records get a unique synthetic key so they never merge.
198
+ batch.set(key, record);
199
+ if (this.timers.has(id))
200
+ return; // a trailing flush is already scheduled
201
+ const elapsed = Date.now() - (this.lastFlush.get(id) ?? 0);
202
+ if (elapsed >= this.throttleMs)
203
+ this.flush(id);
204
+ else
205
+ this.timers.set(id, setTimeout(() => { this.timers.delete(id); this.flush(id); }, this.throttleMs - elapsed));
206
+ }
207
+ /** Coalesce an overlay entry into the pending batch and schedule a flush. */
208
+ append(id, entry) {
209
+ this.load(id);
210
+ // Snapshot the entry: the server reuses one live object for a streaming reasoning
211
+ // block, reassigning `.content` per delta. A shallow copy captures the state at
212
+ // append time so the pending/on-disk record can't change under us.
213
+ this.enqueue(id, entry.id ?? this.syntheticKey(id), { ...entry });
214
+ }
215
+ /**
216
+ * Record the runtime's current base transcript snapshot as a bounded delta. The
217
+ * snapshot is diffed against the base the log already represents: a prefix-extend
218
+ * appends only the new tail (`reset:false`), anything else appends a full replace
219
+ * (`reset:true`). A no-op (identical snapshot) appends nothing. The stored messages
220
+ * are a deep JSON copy, so a later in-place mutation of the runtime's objects can't
221
+ * change what was recorded.
222
+ */
223
+ appendBaseSnapshot(id, messages) {
224
+ this.load(id);
225
+ let prevKeys = this.baseKeys.get(id);
226
+ if (!prevKeys) {
227
+ prevKeys = baseReplay(this.entries(id)).map((m) => JSON.stringify(m));
228
+ this.baseKeys.set(id, prevKeys);
229
+ }
230
+ const nextKeys = messages.map((m) => JSON.stringify(m));
231
+ // Identical snapshot → nothing to record.
232
+ if (nextKeys.length === prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]))
233
+ return;
234
+ // A prefix-EXTEND (strictly longer, every prior message unchanged) appends only
235
+ // the new tail; anything else — a shrink (compaction), a changed prefix message
236
+ // (in-place mutation), or the first snapshot (empty prev, nothing to extend) —
237
+ // appends a full reset. Rebuild the recorded messages from the serialized form:
238
+ // a free deep copy that also matches exactly what will land on disk.
239
+ const extend = prevKeys.length > 0 && nextKeys.length > prevKeys.length && prevKeys.every((k, i) => k === nextKeys[i]);
240
+ const record = extend
241
+ ? { bivyKind: "base", reset: false, createdAt: Date.now(), messages: nextKeys.slice(prevKeys.length).map((s) => JSON.parse(s)) }
242
+ : { bivyKind: "base", reset: true, createdAt: Date.now(), messages: nextKeys.map((s) => JSON.parse(s)) };
243
+ this.baseKeys.set(id, nextKeys);
244
+ this.enqueue(id, this.syntheticKey(id), record);
245
+ }
246
+ /** Replay the overlay entries (disk + pending) into the flat `extras` list. */
247
+ read(id) {
248
+ return replayExtras(this.entries(id));
249
+ }
250
+ /** Replay the base records (disk + pending) into the base transcript. */
251
+ readBase(id) {
252
+ return baseReplay(this.entries(id));
253
+ }
254
+ /**
255
+ * The full derived conversation: overlay detail merged into the base transcript.
256
+ * Prefers the runtime's own live transcript when it has one; otherwise replays the
257
+ * base persisted in the log (a reopened session on a runtime that can't rebuild it).
258
+ * This is the single read path — it absorbs the former `mergeConversation` helper.
259
+ */
260
+ deriveHistory(id, runtimeBase) {
261
+ const base = runtimeBase && runtimeBase.length ? runtimeBase : this.readBase(id);
262
+ return mergeTranscript(base, this.read(id));
263
+ }
264
+ /** Full ordered record list (already-flushed followed by pending). */
265
+ entries(id) {
266
+ const disk = this.load(id);
267
+ const batch = this.pending.get(id);
268
+ return batch && batch.size ? [...disk, ...batch.values()] : disk;
269
+ }
270
+ /** Whether this session has any overlay entries (in memory or on disk). */
271
+ hasEntries(id) {
272
+ return this.entries(id).some((e) => e.bivyKind === "intermediate" || e.bivyKind === "tool");
273
+ }
274
+ /** Whether this session has any base record (in memory or on disk). */
275
+ hasBase(id) {
276
+ return this.entries(id).some((e) => e.bivyKind === "base");
277
+ }
278
+ flush(id) {
279
+ const timer = this.timers.get(id);
280
+ if (timer) {
281
+ clearTimeout(timer);
282
+ this.timers.delete(id);
283
+ }
284
+ const batch = this.pending.get(id);
285
+ if (!batch || !batch.size)
286
+ return;
287
+ const lines = [...batch.values()];
288
+ try {
289
+ fs.mkdirSync(this.dir, { recursive: true });
290
+ fs.appendFileSync(this.pathFor(id), this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n"));
291
+ const disk = this.load(id);
292
+ disk.push(...lines);
293
+ batch.clear();
294
+ this.lastFlush.set(id, Date.now());
295
+ }
296
+ catch { }
297
+ }
298
+ /**
299
+ * Overwrite a session's log with a known-complete set of records. Used by the
300
+ * one-time legacy→log migration to seed the log before it becomes the sole source.
301
+ * Replaces the file and the in-memory state and discards any pending batch, since
302
+ * `entries` is authoritative. The replays re-fold on read, so passing already-folded
303
+ * records is exact and idempotent.
304
+ */
305
+ rewrite(id, entries) {
306
+ const copy = entries.map((e) => ({ ...e }));
307
+ const timer = this.timers.get(id);
308
+ if (timer) {
309
+ clearTimeout(timer);
310
+ this.timers.delete(id);
311
+ }
312
+ this.pending.delete(id);
313
+ this.baseKeys.delete(id);
314
+ this.disk.set(id, copy);
315
+ try {
316
+ fs.mkdirSync(this.dir, { recursive: true });
317
+ const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
318
+ fs.writeFileSync(this.pathFor(id), this.redact(body));
319
+ this.lastFlush.set(id, Date.now());
320
+ }
321
+ catch { }
322
+ }
323
+ /** Cancel any pending write and forget the session (used when it's deleted). */
324
+ drop(id) {
325
+ const timer = this.timers.get(id);
326
+ if (timer) {
327
+ clearTimeout(timer);
328
+ this.timers.delete(id);
329
+ }
330
+ this.disk.delete(id);
331
+ this.pending.delete(id);
332
+ this.lastFlush.delete(id);
333
+ this.counters.delete(id);
334
+ this.baseKeys.delete(id);
335
+ }
336
+ flushAll() {
337
+ for (const id of this.pending.keys())
338
+ this.flush(id);
339
+ }
340
+ }
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Capture / re-apply a session's UNCOMMITTED working-tree changes for a fork
7
+ * (see docs/session-fork-plan.md). The committed branch already travels via
8
+ * origin (the destination re-clones + checks it out); this carries the in-flight
9
+ * edits on top so a fork never silently drops work-in-progress.
10
+ *
11
+ * Resolved decision: 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.
15
+ */
16
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB of patch text
17
+ function git(repoDir, args) {
18
+ try {
19
+ return execFileSync("git", ["-C", repoDir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
20
+ }
21
+ catch (err) {
22
+ // `git diff --no-index` exits 1 when files differ — that's the normal case,
23
+ // and the useful patch is on stdout, so surface it rather than throwing.
24
+ const e = err;
25
+ if (typeof e.stdout === "string")
26
+ return e.stdout;
27
+ throw err;
28
+ }
29
+ }
30
+ /**
31
+ * Snapshot the working tree at `repoDir`: tracked changes vs HEAD plus untracked
32
+ * (non-ignored) files, as a single `git apply`-able patch. Returns
33
+ * `pushedInstead: true` (and an empty patch) when the snapshot exceeds `maxBytes`.
34
+ */
35
+ export function captureDirtyPatch(repoDir, opts = {}) {
36
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
37
+ // Tracked changes relative to HEAD (staged + unstaged), binary-safe.
38
+ const tracked = git(repoDir, ["diff", "HEAD", "--binary"]);
39
+ const untracked = git(repoDir, ["ls-files", "--others", "--exclude-standard"])
40
+ .split(/\r?\n/)
41
+ .map((l) => l.trim())
42
+ .filter(Boolean);
43
+ // Each untracked file becomes a "new file" patch via --no-index against /dev/null.
44
+ const untrackedPatches = untracked.map((rel) => git(repoDir, ["diff", "--no-index", "--binary", "--", "/dev/null", rel]));
45
+ const patch = [tracked, ...untrackedPatches].filter(Boolean).join("");
46
+ if (Buffer.byteLength(patch, "utf8") > maxBytes) {
47
+ return { patch: "", untracked: [], pushedInstead: true };
48
+ }
49
+ return { patch, untracked };
50
+ }
51
+ /**
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
55
+ * new-file hunks (produced via `--no-index`) land correctly.
56
+ */
57
+ export function applyDirtyPatch(repoDir, dirty) {
58
+ if (!dirty || dirty.pushedInstead || !dirty.patch.trim())
59
+ return;
60
+ const tmp = path.join(os.tmpdir(), `bivy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
61
+ fs.writeFileSync(tmp, dirty.patch);
62
+ try {
63
+ execFileSync("git", ["-C", repoDir, "apply", "--whitespace=nowarn", tmp], { stdio: "pipe" });
64
+ }
65
+ finally {
66
+ try {
67
+ fs.unlinkSync(tmp);
68
+ }
69
+ catch {
70
+ // best effort
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Fork prerequisite detection (docs/session-fork-plan.md, acceptance: "Missing
3
+ * prerequisites on the destination node are detected and clearly communicated").
4
+ *
5
+ * The engine reports what the destination is missing to continue the forked
6
+ * session so the UI can guide the user instead of failing opaquely:
7
+ * - agent — the target runtime must be installed/available (a HARD blocker).
8
+ * - model — the model's provider should have auth on this node (soft: the
9
+ * runtime can fall back, or the model-auth vault can supply it).
10
+ * - repo — the source repo should be reachable (soft: it may be public, or
11
+ * the user can connect GitHub afterwards).
12
+ *
13
+ * Pure so the server can gather the raw booleans however it likes and this stays
14
+ * unit-testable.
15
+ */
16
+ export function evaluateForkPrereqs(input) {
17
+ const out = [];
18
+ out.push({
19
+ kind: "agent",
20
+ ok: input.agent.available,
21
+ label: input.agent.displayName,
22
+ detail: input.agent.available
23
+ ? `${input.agent.displayName} is installed on this node.`
24
+ : `${input.agent.displayName} is not installed on this node — install it to continue.`,
25
+ blocking: !input.agent.available,
26
+ ...(input.agent.available ? {} : { fix: "runtime.install" }),
27
+ });
28
+ if (input.model) {
29
+ out.push({
30
+ kind: "model",
31
+ ok: input.model.configured,
32
+ label: input.model.provider,
33
+ detail: input.model.configured
34
+ ? `${input.model.provider} is authenticated on this node.`
35
+ : `${input.model.provider} isn't logged in here — connect it (or the fork will fall back to an available model).`,
36
+ blocking: false,
37
+ ...(input.model.configured ? {} : { fix: "provider.connect" }),
38
+ });
39
+ }
40
+ if (input.repo) {
41
+ out.push({
42
+ kind: "repo",
43
+ ok: input.repo.reachable,
44
+ label: input.repo.slug,
45
+ detail: input.repo.reachable
46
+ ? `${input.repo.slug} is reachable from this node.`
47
+ : `${input.repo.slug} may not be reachable here — connect GitHub if the branch fails to check out.`,
48
+ blocking: false,
49
+ ...(input.repo.reachable ? {} : { fix: "github.connect" }),
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+ /** The subset that should stop a fork from proceeding (missing + blocking). */
55
+ export function blockingForkPrereqs(prereqs) {
56
+ return prereqs.filter((p) => !p.ok && p.blocking);
57
+ }
58
+ /** The subset worth surfacing to the user (anything not satisfied). */
59
+ export function missingForkPrereqs(prereqs) {
60
+ return prereqs.filter((p) => !p.ok);
61
+ }
@@ -0,0 +1,57 @@
1
+ import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
2
+ /**
3
+ * Capture a fork bundle on the source node. Always includes the normalized
4
+ * transcript (the cross-runtime seed, and the fallback when a same-runtime
5
+ * destination can't natively import). Adds the runtime's native payload for a
6
+ * full same-runtime replay — but only when the fork could actually use it: a
7
+ * fork to a known different agent skips it, since the native format never
8
+ * round-trips across runtimes.
9
+ */
10
+ export function buildForkBundle(opts) {
11
+ const { runtime, sessionFile, record } = opts;
12
+ const messages = runtime.readMessages?.(sessionFile);
13
+ const normalized = normalizeMessages(messages, {
14
+ sourceRuntimeId: runtime.id,
15
+ model: record.model,
16
+ title: record.title,
17
+ createdAt: new Date().toISOString(),
18
+ });
19
+ // Only worth capturing when the target is the same runtime (or not yet known).
20
+ const nativeCouldReplay = !opts.targetRuntimeId || opts.targetRuntimeId === runtime.id;
21
+ const native = nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork
22
+ ? runtime.exportForFork(sessionFile)
23
+ : undefined;
24
+ return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
25
+ }
26
+ /**
27
+ * Decide the fidelity a fork of `bundle` into `targetRuntime` can achieve —
28
+ * "full" only when the target is the SAME runtime that produced the native
29
+ * payload and can import it; "seeded" otherwise. Pure; no side effects.
30
+ */
31
+ export function resolveForkFidelity(bundle, targetRuntime) {
32
+ const native = bundle.native;
33
+ const canImport = !!native &&
34
+ native.runtimeId === targetRuntime.id &&
35
+ !!targetRuntime.capabilities.forkTransport &&
36
+ typeof targetRuntime.importForFork === "function";
37
+ return canImport ? "full" : "seeded";
38
+ }
39
+ /**
40
+ * Turn a fork bundle into a concrete stand-up plan on the destination node:
41
+ * either resume a natively imported transcript (full) or a seed prompt for a
42
+ * fresh session (seeded). The server executes the returned plan (worktree +
43
+ * session creation live there); this stays pure of daemon wiring.
44
+ */
45
+ export async function materializeFork(opts) {
46
+ const { bundle, targetRuntime, ctx } = opts;
47
+ if (resolveForkFidelity(bundle, targetRuntime) === "full" && bundle.native && targetRuntime.importForFork) {
48
+ const { sessionFile, id } = await targetRuntime.importForFork(bundle.native, ctx);
49
+ return { kind: "resume", fidelity: "full", sessionFile, id };
50
+ }
51
+ const seedPrompt = buildSeedPrompt(bundle.normalized, {
52
+ targetAgent: targetRuntime.displayName,
53
+ context: { repoSlug: bundle.record.repoSlug, branch: bundle.record.branch, prUrl: bundle.record.prUrl },
54
+ ...opts.seed,
55
+ });
56
+ return { kind: "seed", fidelity: "seeded", seedPrompt };
57
+ }