@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,140 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Owner + standby ORCHESTRATION for warm session replication
5
+ // (docs/session-replication.md). This ties the pure decision core
6
+ // (replication.ts), the transcript log (event-log.ts), and the git checkpoint
7
+ // bundle (checkpoint-pack.ts) together behind an INJECTED transport, so the
8
+ // end-to-end flow is unit-testable without a relay, a daemon, or a second node
9
+ // (test/replicator.test.ts) — mirroring adoption.ts / backpressure.ts.
10
+ //
11
+ // Owner: on each turn boundary, build a frame from the transcript delta + (when
12
+ // worktree sync is on) a git bundle of the new checkpoint, and send it to the
13
+ // standby. Advance the per-standby cursor on ack; on a needFull/resync ack, drop
14
+ // the cursor so the next send is a full frame + full bundle (self-healing).
15
+ //
16
+ // Standby: apply each frame both-or-neither — the git bundle lands first (inside
17
+ // applyReplFrame's fetch step, so a missing prerequisite aborts before the
18
+ // transcript is touched), then the transcript, then the working tree is refreshed.
19
+ import { applyReplFrame, buildReplFrame, cursorOf, initialReplState, } from "./replication.js";
20
+ /**
21
+ * Per-session owner replicator. Holds the cursor the standby last acked; produces
22
+ * one wire frame per turn and applies acks. Transport is the caller's job: send
23
+ * `buildTurnFrame(...)` to the standby and feed its reply to `applyAck(...)`.
24
+ */
25
+ export class OwnerReplicator {
26
+ deps;
27
+ cursors = new Map();
28
+ constructor(deps) {
29
+ this.deps = deps;
30
+ }
31
+ /** The cursor the standby has acknowledged for a session (undefined = cold). */
32
+ cursor(sessionId) {
33
+ return this.cursors.get(sessionId);
34
+ }
35
+ /**
36
+ * Build the frame to send the standby for the current state, or `null` when the
37
+ * standby is already up to date (no new transcript records and no new checkpoint).
38
+ */
39
+ async buildTurnFrame(sessionId) {
40
+ const cursor = this.cursors.get(sessionId);
41
+ const [records, epoch] = await Promise.all([
42
+ Promise.resolve(this.deps.readRecords(sessionId)),
43
+ Promise.resolve(this.deps.epochOf(sessionId)),
44
+ ]);
45
+ const head = await this.deps.checkpointHead(sessionId);
46
+ const frame = buildReplFrame({
47
+ sessionId,
48
+ epoch,
49
+ records,
50
+ checkpointCommit: head,
51
+ runtimeSessionRef: this.deps.runtimeSessionRef?.(sessionId),
52
+ cursor,
53
+ });
54
+ if (!frame)
55
+ return null;
56
+ let bundle;
57
+ if (this.deps.worktreeSync() && head && head !== cursor?.checkpointCommit) {
58
+ const buf = await this.deps.bundleCheckpoint(sessionId, cursor?.checkpointCommit);
59
+ if (buf)
60
+ bundle = buf.toString("base64");
61
+ }
62
+ return bundle ? { ...frame, bundle } : frame;
63
+ }
64
+ /**
65
+ * Apply the standby's ack. Returns `true` when the owner should immediately
66
+ * re-send (the standby asked for a full resync / full bundle) — the caller then
67
+ * calls `buildTurnFrame` again, which now produces a full frame because the
68
+ * cursor was dropped.
69
+ */
70
+ applyAck(sessionId, ack) {
71
+ if (ack.status === "applied") {
72
+ this.cursors.set(sessionId, ack.cursor);
73
+ return false;
74
+ }
75
+ if (ack.status === "resync" || ack.status === "needFull") {
76
+ // Drop the cursor so the next frame is a full transcript + full bundle.
77
+ this.cursors.delete(sessionId);
78
+ return true;
79
+ }
80
+ // stale: this owner has been superseded by a promotion — stop replicating.
81
+ return false;
82
+ }
83
+ /** Forget a session (closed/deleted/demoted). */
84
+ forget(sessionId) {
85
+ this.cursors.delete(sessionId);
86
+ }
87
+ }
88
+ /** A sentinel so a missing-prerequisite bundle aborts the frame cleanly. */
89
+ class NeedFullError extends Error {
90
+ }
91
+ /**
92
+ * Per-session standby applier. Holds the replica `ReplState`; applies each wire
93
+ * frame both-or-neither and returns the ack the owner needs. Fencing, gap-resync,
94
+ * and the checkpoint-before-transcript ordering all come from applyReplFrame.
95
+ */
96
+ export class StandbyApplier {
97
+ deps;
98
+ states = new Map();
99
+ constructor(deps) {
100
+ this.deps = deps;
101
+ }
102
+ state(sessionId) {
103
+ return this.states.get(sessionId);
104
+ }
105
+ async receive(frame) {
106
+ const state = this.states.get(frame.sessionId) ?? initialReplState();
107
+ this.states.set(frame.sessionId, state);
108
+ const bundleBuf = frame.bundle ? Buffer.from(frame.bundle, "base64") : undefined;
109
+ try {
110
+ const outcome = await applyReplFrame(state, frame, {
111
+ persist: (sid, records) => this.deps.persistRecords(sid, records),
112
+ // The checkpoint step (before persisting the transcript): land the git
113
+ // objects, then refresh the working tree. A missing prerequisite throws
114
+ // NeedFull so the whole frame aborts without touching the transcript.
115
+ fetchCheckpoint: bundleBuf
116
+ ? async (sid) => {
117
+ const res = await this.deps.applyBundle(sid, bundleBuf);
118
+ if (!res.ok)
119
+ throw new NeedFullError();
120
+ await this.deps.materialize(sid);
121
+ }
122
+ : undefined,
123
+ });
124
+ if (outcome.status === "applied")
125
+ return { status: "applied", cursor: outcome.cursor };
126
+ if (outcome.status === "resync")
127
+ return { status: "resync", cursor: outcome.cursor };
128
+ return { status: "stale", ownerEpoch: outcome.ownerEpoch };
129
+ }
130
+ catch (err) {
131
+ if (err instanceof NeedFullError)
132
+ return { status: "needFull", cursor: cursorOf(state) };
133
+ throw err;
134
+ }
135
+ }
136
+ /** Forget a session's replica state (deleted, or promoted to live locally). */
137
+ forget(sessionId) {
138
+ this.states.delete(sessionId);
139
+ }
140
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Idempotency for `session.new`, keyed by the client's requestId.
5
+ //
6
+ // Mobile Safari can silently drop a backgrounded PWA's WebSocket reply without
7
+ // ever closing the socket, so the `session.history` answer to a `session.new`
8
+ // never reaches the client and its view wedges on the opening spinner. The
9
+ // client recovers by re-firing the SAME `session.new` (same requestId) once it
10
+ // reconnects. To make that retry safe, creation is deduped by requestId: a
11
+ // concurrent or later request carrying a requestId we've already handled adopts
12
+ // the session the first request created instead of spawning a duplicate.
13
+ //
14
+ // The in-flight Promise is stored synchronously (before any await), so a retry
15
+ // that races the original still joins it rather than creating a second session.
16
+ export function createSessionNewDedupe(options = {}) {
17
+ const ttlMs = options.ttlMs ?? 10 * 60_000;
18
+ const schedule = options.schedule ??
19
+ ((fn, ms) => {
20
+ setTimeout(fn, ms).unref();
21
+ });
22
+ const inflight = new Map();
23
+ function run(requestId, create) {
24
+ if (!requestId)
25
+ return create();
26
+ const existing = inflight.get(requestId);
27
+ if (existing)
28
+ return existing;
29
+ const p = create();
30
+ // Register synchronously so a retry racing the original joins this promise
31
+ // rather than starting a second creation.
32
+ inflight.set(requestId, p);
33
+ p.then(() => schedule(() => inflight.delete(requestId), ttlMs),
34
+ // A failed creation must not be cached: drop it immediately so a genuine
35
+ // retry of the same requestId can attempt creation again.
36
+ () => {
37
+ inflight.delete(requestId);
38
+ });
39
+ return p;
40
+ }
41
+ return { run, size: () => inflight.size };
42
+ }
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // A NODE acting as a relay CLIENT of a sibling node it co-owns — the transport
5
+ // that lets an owner daemon stream replication frames to its standby
6
+ // (docs/session-replication.md). Bivy has no node↔node link; nodes reach each
7
+ // other only the way a phone reaches a node: as a client in the sibling's relay
8
+ // room. This is the node-side port of the browser client
9
+ // (packages/core/src/transport-relay.ts), built on the already-unit-tested
10
+ // node crypto core (relay-cli-crypto.ts) + framing (relay-chunk.ts).
11
+ //
12
+ // Handshake (all relay frames opaque to the relay):
13
+ // 1. POST /node/sibling-link-grant {nodeId} (Bearer enrollment token)
14
+ // → a client-scoped grant for the sibling (closes the credential gap).
15
+ // 2. POST /client/relay-ticket {nodeId} (Bearer grant) → single-use ticket.
16
+ // 3. WS <relay>/client?ticket=..&nodeId=sibling
17
+ // 4. pair.account {grant, devicePublicKeyB64} → pair.welcome {nodePub, wrapped}
18
+ // → ECDH-unwrap the sibling's room key (acceptWelcome).
19
+ // 5. sealed request/reply frames, correlated by requestId.
20
+ //
21
+ // NOTE: the live path requires a running relay + control plane + a second node,
22
+ // so it is exercised by the multi-node validation plan in the doc, not by an
23
+ // in-process unit test. The framing/correlation logic is kept small and the
24
+ // crypto it calls is unit-tested in relay-cli-crypto.test.ts.
25
+ import { WebSocket } from "ws";
26
+ import { newDeviceKeypair, acceptWelcome, RoomCipher } from "../relay-cli-crypto.js";
27
+ import { frameMessages, FrameReassembler } from "../relay-chunk.js";
28
+ export class SiblingClient {
29
+ opts;
30
+ ws;
31
+ cipher;
32
+ keypair = newDeviceKeypair();
33
+ reassembler = new FrameReassembler();
34
+ pending = new Map();
35
+ paired;
36
+ resolvePaired;
37
+ rejectPaired;
38
+ closed = false;
39
+ reqSeq = 0;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ }
43
+ get fetchImpl() {
44
+ return this.opts.fetchImpl ?? fetch;
45
+ }
46
+ /** Connect + pair; resolves once the sibling's room key is established. */
47
+ async connect() {
48
+ const cp = this.opts.controlPlaneUrl.replace(/\/$/, "");
49
+ // 1. Mint a client grant for the sibling using our enrollment token.
50
+ const grantRes = await this.postJson(`${cp}/node/sibling-link-grant`, this.opts.enrollmentToken, {
51
+ nodeId: this.opts.siblingNodeId,
52
+ });
53
+ const grant = String(grantRes.grant ?? "");
54
+ if (!grant)
55
+ throw new Error("sibling-link-grant returned no grant");
56
+ const relayFromGrant = typeof grantRes.relayUrl === "string" ? grantRes.relayUrl : undefined;
57
+ // 2. Exchange the grant for a single-use relay ticket.
58
+ const ticketRes = await this.postJson(`${cp}/client/relay-ticket`, grant, { nodeId: this.opts.siblingNodeId });
59
+ const ticket = String(ticketRes.ticket ?? "");
60
+ if (!ticket)
61
+ throw new Error("relay-ticket returned no ticket");
62
+ const relayBase = (typeof ticketRes.relayUrl === "string" ? ticketRes.relayUrl : relayFromGrant || this.opts.relayUrl || "").replace(/\/$/, "");
63
+ if (!relayBase)
64
+ throw new Error("no relay URL for sibling");
65
+ // 3. Open the /client socket in the sibling's room.
66
+ const url = `${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(this.opts.siblingNodeId)}`;
67
+ const WSImpl = this.opts.WebSocketImpl ?? WebSocket;
68
+ const ws = new WSImpl(url);
69
+ this.ws = ws;
70
+ this.paired = new Promise((resolve, reject) => {
71
+ this.resolvePaired = resolve;
72
+ this.rejectPaired = reject;
73
+ });
74
+ ws.on("message", (data) => this.onMessage(String(data), grant));
75
+ ws.on("close", () => this.handleClose());
76
+ ws.on("error", (err) => this.rejectPaired?.(err));
77
+ await this.paired;
78
+ }
79
+ async onMessage(raw, grant) {
80
+ let msg;
81
+ try {
82
+ msg = JSON.parse(raw);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (msg.t === "ready" || msg.t === "peer.online") {
88
+ // Begin account pairing to obtain the sibling's room key.
89
+ // `ephemeral`: a sibling replica is a node↔node bridge, not a user device —
90
+ // authorized for the room key but never listed as a signed-in device.
91
+ this.sendControl({ t: "pair", p: { k: "pair.account", sessionToken: grant, devicePublicKeyB64: this.keypair.publicKeyB64, label: this.opts.label ?? "Bivy replica", ephemeral: true } });
92
+ return;
93
+ }
94
+ if (msg.t === "pair") {
95
+ this.handlePair(msg.p);
96
+ return;
97
+ }
98
+ if (msg.t === "frame" && typeof msg.p === "string" && this.cipher) {
99
+ const full = this.reassembler.accept(msg);
100
+ if (!full)
101
+ return;
102
+ try {
103
+ const frame = this.cipher.open(full);
104
+ this.dispatchEvent(frame.data);
105
+ }
106
+ catch {
107
+ /* undecryptable frame — ignore */
108
+ }
109
+ }
110
+ }
111
+ handlePair(p) {
112
+ const k = String(p?.k ?? "");
113
+ if (k === "pair.welcome") {
114
+ try {
115
+ const roomKey = acceptWelcome(this.keypair, { nodePublicKeyB64: String(p.nodePublicKeyB64 ?? ""), wrapped: String(p.wrapped ?? "") });
116
+ this.cipher = new RoomCipher(roomKey);
117
+ this.resolvePaired?.();
118
+ }
119
+ catch (err) {
120
+ this.rejectPaired?.(err instanceof Error ? err : new Error(String(err)));
121
+ }
122
+ }
123
+ else if (k === "pair.error") {
124
+ this.rejectPaired?.(new Error(String(p.error ?? "pairing rejected by sibling")));
125
+ }
126
+ }
127
+ dispatchEvent(event) {
128
+ const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
129
+ if (requestId && this.pending.has(requestId)) {
130
+ const waiter = this.pending.get(requestId);
131
+ clearTimeout(waiter.timer);
132
+ this.pending.delete(requestId);
133
+ waiter.resolve(event);
134
+ return;
135
+ }
136
+ this.opts.onEvent?.(event);
137
+ }
138
+ /** Send a command and await its correlated reply event (by requestId). */
139
+ request(command, timeoutMs = 30_000) {
140
+ const requestId = `repl-${++this.reqSeq}-${Date.now()}`;
141
+ return new Promise((resolve, reject) => {
142
+ const timer = setTimeout(() => {
143
+ this.pending.delete(requestId);
144
+ reject(new Error("sibling reply timed out"));
145
+ }, timeoutMs);
146
+ this.pending.set(requestId, { resolve, reject, timer });
147
+ try {
148
+ this.sendSealed({ ...command, requestId });
149
+ }
150
+ catch (err) {
151
+ clearTimeout(timer);
152
+ this.pending.delete(requestId);
153
+ reject(err instanceof Error ? err : new Error(String(err)));
154
+ }
155
+ });
156
+ }
157
+ sendSealed(command) {
158
+ if (!this.cipher || this.ws?.readyState !== WebSocket.OPEN)
159
+ throw new Error("sibling client not connected");
160
+ const payload = this.cipher.seal(command);
161
+ for (const frame of frameMessages(payload)) {
162
+ if (this.ws?.readyState !== WebSocket.OPEN)
163
+ throw new Error("sibling client closed mid-send");
164
+ this.ws.send(frame);
165
+ }
166
+ }
167
+ sendControl(obj) {
168
+ if (this.ws?.readyState === WebSocket.OPEN)
169
+ this.ws.send(JSON.stringify(obj));
170
+ }
171
+ async postJson(url, bearer, body) {
172
+ const res = await this.fetchImpl(url, {
173
+ method: "POST",
174
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
175
+ body: JSON.stringify(body),
176
+ });
177
+ if (!res.ok)
178
+ throw new Error(`${url} → ${res.status}`);
179
+ return (await res.json());
180
+ }
181
+ handleClose() {
182
+ if (this.closed)
183
+ return;
184
+ for (const [, waiter] of this.pending) {
185
+ clearTimeout(waiter.timer);
186
+ waiter.reject(new Error("sibling client closed"));
187
+ }
188
+ this.pending.clear();
189
+ this.rejectPaired?.(new Error("closed before pairing"));
190
+ this.opts.onClose?.();
191
+ }
192
+ close() {
193
+ this.closed = true;
194
+ try {
195
+ this.ws?.close();
196
+ }
197
+ catch {
198
+ /* ignore */
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Fold Bivy's per-session sidecars (intermediate reasoning + tool activity) into
5
+ // a runtime's base transcript. Pure and unit-tested (test/transcript-merge.test.ts)
6
+ // so the placement logic is verifiable without the daemon.
7
+ //
8
+ // Why anchoring by TIME, not by an absolute message index:
9
+ // Each sidecar entry recorded `afterMessageCount = getMessages().length` at the
10
+ // moment it happened. That's an absolute index into a list whose length
11
+ // CHANGES — it grows during a turn, and (for runtimes like Claude Code) SHRINKS
12
+ // when the conversation is compacted. After compaction the base transcript is
13
+ // shorter than the counts the sidecar recorded, so every over-the-end entry got
14
+ // clamped to `base.length` and piled up AFTER the final message (a big clump of
15
+ // recent tool cards below the newest reply — the reported bug).
16
+ // Messages and sidecar entries both carry an epoch-ms time (`timestamp` /
17
+ // `createdAt`), and surviving messages keep their timestamps across compaction,
18
+ // so placing each entry after the base messages that precede it IN TIME is
19
+ // stable. We fall back to the old clamped-count placement only when timestamps
20
+ // are unavailable.
21
+ export function normalizedIntermediateText(value) {
22
+ return value.replace(/\s+/g, " ").trim();
23
+ }
24
+ export function thinkingTextFromContent(content) {
25
+ if (!Array.isArray(content))
26
+ return "";
27
+ return content
28
+ .map((part) => {
29
+ if (!part || typeof part !== "object")
30
+ return "";
31
+ const record = part;
32
+ const type = String(record.type || "").toLowerCase();
33
+ if (type === "thinking")
34
+ return typeof record.thinking === "string" ? record.thinking : typeof record.text === "string" ? record.text : "";
35
+ if (type === "reasoning")
36
+ return typeof record.reasoning === "string" ? record.reasoning : typeof record.text === "string" ? record.text : "";
37
+ return "";
38
+ })
39
+ .filter(Boolean)
40
+ .join("\n");
41
+ }
42
+ /** Parse a `timestamp` field (epoch-ms number or ISO string) to epoch ms. */
43
+ function toTs(value) {
44
+ if (typeof value === "number" && Number.isFinite(value))
45
+ return value;
46
+ if (typeof value === "string") {
47
+ const t = Date.parse(value);
48
+ if (!Number.isNaN(t))
49
+ return t;
50
+ }
51
+ return undefined;
52
+ }
53
+ /**
54
+ * Non-decreasing timeline of the base messages' timestamps. Gaps (a message with
55
+ * no timestamp) carry forward the previous value so the array stays monotonic and
56
+ * binary-searchable. `hasTime` is false when NO base message is timestamped, in
57
+ * which case callers fall back to count-based placement.
58
+ */
59
+ function baseTimeline(base) {
60
+ const times = new Array(base.length);
61
+ let last = 0;
62
+ let hasTime = false;
63
+ for (let i = 0; i < base.length; i++) {
64
+ const t = toTs(base[i]?.timestamp);
65
+ if (t !== undefined) {
66
+ hasTime = true;
67
+ if (t > last)
68
+ last = t;
69
+ }
70
+ times[i] = last;
71
+ }
72
+ return { times, hasTime };
73
+ }
74
+ /** Count of `times` entries ≤ `value` (upper bound). `times` is non-decreasing. */
75
+ function countAtOrBefore(times, value) {
76
+ let lo = 0;
77
+ let hi = times.length;
78
+ while (lo < hi) {
79
+ const mid = (lo + hi) >> 1;
80
+ if (times[mid] <= value)
81
+ lo = mid + 1;
82
+ else
83
+ hi = mid;
84
+ }
85
+ return lo;
86
+ }
87
+ function insertionIndex(base, timeline, entry) {
88
+ if (timeline.hasTime && typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)) {
89
+ return countAtOrBefore(timeline.times, entry.createdAt);
90
+ }
91
+ // Legacy / no-timestamp fallback: clamp the absolute count into range.
92
+ return Math.max(0, Math.min(base.length, entry.afterMessageCount));
93
+ }
94
+ /**
95
+ * Merge the `extras` (intermediate + tool-activity sidecar entries) into `base`,
96
+ * each placed by time (see file header). Deduplicates intermediate reasoning that
97
+ * already appears in an adjacent persisted message or was emitted twice.
98
+ */
99
+ export function mergeTranscript(base, extras) {
100
+ if (!extras.length)
101
+ return base;
102
+ const timeline = baseTimeline(base);
103
+ const byIndex = new Map();
104
+ const seenIntermediate = new Set();
105
+ for (const entry of extras) {
106
+ const index = insertionIndex(base, timeline, entry);
107
+ if (entry.bivyKind === "intermediate") {
108
+ const text = normalizedIntermediateText(thinkingTextFromContent(entry.content));
109
+ // Skip a sidecar reasoning copy when the adjacent persisted message already
110
+ // carries the same reasoning, and dedupe repeated stream-final copies.
111
+ const alreadyInTranscript = text && [base[index - 1], base[index]].some((message) => normalizedIntermediateText(thinkingTextFromContent(message?.content)) === text);
112
+ const dedupeKey = `${index}:${text}`;
113
+ if (alreadyInTranscript || seenIntermediate.has(dedupeKey))
114
+ continue;
115
+ if (text)
116
+ seenIntermediate.add(dedupeKey);
117
+ }
118
+ const bucket = byIndex.get(index) ?? [];
119
+ bucket.push(entry);
120
+ byIndex.set(index, bucket);
121
+ }
122
+ const merged = [];
123
+ for (let i = 0; i <= base.length; i++) {
124
+ const bucket = byIndex.get(i);
125
+ if (bucket)
126
+ merged.push(...bucket.sort((a, b) => a.createdAt - b.createdAt));
127
+ if (i < base.length)
128
+ merged.push(base[i]);
129
+ }
130
+ return merged;
131
+ }
@@ -0,0 +1,130 @@
1
+ /** Cap a tool payload down to a short, log-safe one-liner. */
2
+ function compactValue(value, max = 200) {
3
+ let s;
4
+ try {
5
+ s = typeof value === "string" ? value : JSON.stringify(value);
6
+ }
7
+ catch {
8
+ s = String(value);
9
+ }
10
+ s = String(s ?? "").replace(/\s+/g, " ").trim();
11
+ return s.length > max ? `${s.slice(0, Math.max(0, max - 1))}…` : s;
12
+ }
13
+ /** Flatten one runtime message's content into text + tool annotations. */
14
+ function readContent(content) {
15
+ if (typeof content === "string") {
16
+ return { text: content, tools: [], toolResultOnly: false };
17
+ }
18
+ if (!Array.isArray(content)) {
19
+ return { text: "", tools: [], toolResultOnly: false };
20
+ }
21
+ const texts = [];
22
+ const tools = [];
23
+ let sawText = false;
24
+ let sawToolResult = false;
25
+ for (const raw of content) {
26
+ const type = raw?.type;
27
+ if (type === "text" && typeof raw.text === "string") {
28
+ texts.push(raw.text);
29
+ sawText = true;
30
+ }
31
+ else if (type === "tool_use") {
32
+ tools.push({ name: raw.name, summary: `${raw.name ?? "tool"}(${compactValue(raw.input)})` });
33
+ }
34
+ else if (type === "tool_result") {
35
+ sawToolResult = true;
36
+ tools.push({ name: undefined, summary: `→ ${compactValue(raw.content)}` });
37
+ }
38
+ // "thinking" and unknown block types are intentionally dropped: internal
39
+ // reasoning is neither portable across runtimes nor needed for continuity.
40
+ }
41
+ return { text: texts.join("\n").trim(), tools, toolResultOnly: sawToolResult && !sawText };
42
+ }
43
+ function normalizeRole(role, toolResultOnly) {
44
+ if (role === "assistant")
45
+ return "assistant";
46
+ if (role === "system" || role === "error")
47
+ return "error";
48
+ // A "user" message that is purely tool_result is really tool output.
49
+ if (role === "user")
50
+ return toolResultOnly ? "tool" : "user";
51
+ return "user";
52
+ }
53
+ function toTs(value) {
54
+ if (typeof value === "number" && Number.isFinite(value))
55
+ return value;
56
+ if (typeof value === "string") {
57
+ const t = Date.parse(value);
58
+ if (!Number.isNaN(t))
59
+ return t;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Flatten a runtime transcript (`readMessages()` output) into portable turns.
65
+ * Pure and defensive: unknown shapes degrade to empty text rather than throwing.
66
+ */
67
+ export function normalizeMessages(messages, header) {
68
+ const turns = [];
69
+ for (const message of messages ?? []) {
70
+ const m = message;
71
+ const { text, tools, toolResultOnly } = readContent(m.content);
72
+ const role = normalizeRole(m.role, toolResultOnly);
73
+ const namedTool = tools.find((t) => t.name)?.name;
74
+ const toolSummary = tools.length ? tools.map((t) => t.summary).join("; ") : undefined;
75
+ // Skip turns that carry nothing a destination could use.
76
+ if (!text && !toolSummary)
77
+ continue;
78
+ turns.push({
79
+ role,
80
+ text,
81
+ ...(namedTool ? { toolName: namedTool } : {}),
82
+ ...(toolSummary ? { toolSummary } : {}),
83
+ ...(toTs(m.timestamp ?? m.ts) !== undefined ? { ts: toTs(m.timestamp ?? m.ts) } : {}),
84
+ });
85
+ }
86
+ return { header, turns };
87
+ }
88
+ function truncate(text, max) {
89
+ const compact = String(text || "").replace(/\s+/g, " ").trim();
90
+ return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
91
+ }
92
+ /**
93
+ * Render a compact continuation prompt for a **seeded** (cross-runtime) fork.
94
+ *
95
+ * Deliberately small — the last N turns plus a link to the full transcript —
96
+ * rather than the entire history: enough to continue coherently, with the full
97
+ * context one click away (the resolved decision in docs/session-fork-plan.md).
98
+ * A structured superset of the old client-side `sessionHandoffSummary`.
99
+ */
100
+ export function buildSeedPrompt(transcript, opts = {}) {
101
+ const recentTurns = opts.recentTurns ?? 12;
102
+ const perTurnChars = opts.perTurnChars ?? 700;
103
+ const title = transcript.header.title || "Untitled session";
104
+ const targetAgent = opts.targetAgent || "a new agent";
105
+ const recent = transcript.turns
106
+ .filter((t) => t.text || t.toolSummary)
107
+ .slice(-recentTurns)
108
+ .map((t) => {
109
+ const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
110
+ return `- ${t.role}: ${truncate(body, perTurnChars)}`;
111
+ })
112
+ .join("\n");
113
+ const lines = [
114
+ `I am continuing an existing Bivy session (forked from ${transcript.header.sourceRuntimeId} to ${targetAgent}).`,
115
+ `Session: ${title}`,
116
+ opts.transcriptUrl ? `Full original transcript: ${opts.transcriptUrl}` : null,
117
+ transcript.header.model ? `Model before fork: ${transcript.header.model}` : null,
118
+ opts.context?.repoSlug ? `Repository: ${opts.context.repoSlug}` : null,
119
+ opts.context?.branch ? `Branch: ${opts.context.branch}` : null,
120
+ opts.context?.prUrl ? `PR: ${opts.context.prUrl}` : null,
121
+ "",
122
+ "Recent conversation (most recent last):",
123
+ recent || "- (no prior turns were available)",
124
+ "",
125
+ opts.transcriptUrl
126
+ ? "Open the full transcript link above if this summary is missing anything, then continue from here."
127
+ : "Continue from here.",
128
+ ];
129
+ return lines.filter((line) => line != null).join("\n");
130
+ }
@@ -0,0 +1 @@
1
+ export {};