@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.
- package/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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 {};
|