@bivy/bivy 0.1.0-staging.4 → 0.1.0-staging.6

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/bin/bivy.mjs CHANGED
@@ -3265,15 +3265,47 @@ async function cmdStatus(args = []) {
3265
3265
  try { status = await localApi(config, "/api/status"); } catch {}
3266
3266
  }
3267
3267
  if (json) {
3268
- console.log(JSON.stringify({ reachable, url: url(config), workspace: status?.workspace || config.workspace, service: serviceStatusLine(), remoteConfigured: Boolean(status?.relay?.configured || fs.existsSync(relayConfigPath)), status }, null, 2));
3268
+ const relayCfgJson = loadRelayConfig();
3269
+ console.log(JSON.stringify({
3270
+ reachable,
3271
+ url: url(config),
3272
+ workspace: status?.workspace || config.workspace,
3273
+ service: serviceStatusLine(),
3274
+ remoteConfigured: Boolean(status?.relay?.configured || relayCfgJson),
3275
+ relay: {
3276
+ configured: Boolean(status?.relay?.configured || relayCfgJson),
3277
+ connected: status?.relay?.connected ?? null,
3278
+ app: status?.relay?.controlPlaneUrl || relayCfgJson?.controlPlaneUrl || null,
3279
+ relay: status?.relay?.relayUrl || relayCfgJson?.url || null,
3280
+ lastError: status?.relay?.lastError || null,
3281
+ },
3282
+ status,
3283
+ }, null, 2));
3269
3284
  return;
3270
3285
  }
3271
3286
  console.log(c.bold("\n Bivy node\n"));
3272
3287
  console.log(` url: ${url(config)} ${reachable ? c.green("● reachable") : c.dim("○ not reachable")}`);
3273
3288
  console.log(` workspace: ${status?.workspace || config.workspace}`);
3274
3289
  console.log(` ${serviceStatusLine()}`);
3275
- console.log(` remote: ${status?.relay?.configured || fs.existsSync(relayConfigPath) ? c.green("relay configured") : "local only"}`);
3276
3290
  const relay = loadRelayConfig();
3291
+ const relaySt = status?.relay;
3292
+ const relayConfigured = Boolean(relaySt?.configured || relay);
3293
+ if (!relayConfigured) {
3294
+ console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
3295
+ } else {
3296
+ // The live link state is only knowable when the node is running; if it's
3297
+ // down we can say it's configured but not whether it's currently connected.
3298
+ let state;
3299
+ if (!reachable) state = c.yellow("configured (node not running)");
3300
+ else if (relaySt?.connected) state = c.green("● connected");
3301
+ else state = c.yellow("○ configured, not connected");
3302
+ const relErr = relaySt?.lastError ? c.dim(` (${relaySt.lastError})`) : "";
3303
+ console.log(` remote: ${state}${relErr}`);
3304
+ const cpUrl = relaySt?.controlPlaneUrl || relay?.controlPlaneUrl;
3305
+ const rlUrl = relaySt?.relayUrl || relay?.url;
3306
+ if (cpUrl) console.log(` app: ${cpUrl}`);
3307
+ if (rlUrl) console.log(` relay: ${rlUrl}`);
3308
+ }
3277
3309
  if (relay?.controlPlaneUrl && relay?.enrollmentToken) {
3278
3310
  try {
3279
3311
  const acct = await controlPlaneNodeApi(relay, "/node/account");
@@ -3331,7 +3363,15 @@ async function cmdDoctor(args = []) {
3331
3363
  console.log(` ${mark(agentAvailable, true)} agent ${runtimeInfo?.displayName || defaultAgent}${agentAvailable ? "" : c.dim(" not available — install it or run 'bivy setup'")}`);
3332
3364
  console.log(` ${mark(hasModelConfig(config), authOwner !== "bivy")} model ${hasModelConfig(config) ? "configured" : authOwner === "bivy" ? c.dim("not configured — run 'bivy login'") : c.dim("agent-native auth — use the agent's CLI login if needed")}`);
3333
3365
  const relayConfigured = Boolean(status?.relay?.configured || fs.existsSync(relayConfigPath));
3334
- console.log(` ${relayConfigured ? ok : c.dim("○")} remote ${relayConfigured ? (status?.relay?.connected ? c.green("relay connected") : "relay configured") : c.dim("local only — 'bivy relay:setup' to enable")}`);
3366
+ const relayConnected = Boolean(status?.relay?.connected);
3367
+ const relayApp = status?.relay?.controlPlaneUrl;
3368
+ const relayErr = status?.relay?.lastError;
3369
+ const relayLine = !relayConfigured
3370
+ ? c.dim("local only — 'bivy relay:setup' to enable")
3371
+ : relayConnected
3372
+ ? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
3373
+ : c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
3374
+ console.log(` ${relayConfigured ? (relayConnected ? ok : warn) : c.dim("○")} remote ${relayLine}`);
3335
3375
  // Derived from BUILTIN_TERMINAL_AGENTS (the same list 'bivy agents'/'bivy run'
3336
3376
  // use) rather than a hand-maintained list, so it can't drift out of sync (#113).
3337
3377
  const agentCommands = [...BUILTIN_TERMINAL_AGENTS.values()].filter((a) => a.type === "command").map((a) => a.command);
@@ -51,6 +51,14 @@ export class RelayConnector {
51
51
  heartbeatTimer;
52
52
  stableTimer;
53
53
  lastPongAt = 0;
54
+ // True only between the relay's `ready` message (auth + entitlement passed)
55
+ // and the socket closing. This — not "a connector object exists" — is what
56
+ // "connected" means to the control plane, so it's what `bivy status` reports.
57
+ ready = false;
58
+ // Most recent relay-side failure (ticket mint, socket error, or an `error`
59
+ // frame), surfaced by `bivy status`/`doctor` so a node that never connects
60
+ // explains why instead of silently showing "configured".
61
+ lastErrorMessage;
54
62
  replay = new ReplayGuard();
55
63
  reassembler = new FrameReassembler();
56
64
  pairing;
@@ -71,12 +79,26 @@ export class RelayConnector {
71
79
  return;
72
80
  this.ws.send(JSON.stringify({ t: "pair", p: JSON.stringify({ k: "key.rotate", deliveries }) }));
73
81
  }
82
+ /**
83
+ * True only while the relay link is live AND the relay has sent `ready`
84
+ * (auth/entitlement checks passed) — i.e. the node is actually reachable from
85
+ * the control plane. A socket that opened but was rejected, or one still
86
+ * reconnecting, reads false.
87
+ */
88
+ get connected() {
89
+ return this.ready && this.ws?.readyState === WebSocket.OPEN;
90
+ }
91
+ /** Most recent relay-side failure, if any — for status/diagnostics. */
92
+ get lastError() {
93
+ return this.lastErrorMessage;
94
+ }
74
95
  start() {
75
96
  this.closed = false;
76
97
  void this.connect();
77
98
  }
78
99
  stop() {
79
100
  this.closed = true;
101
+ this.ready = false;
80
102
  this.stopHeartbeat();
81
103
  this.clearBackoffReset();
82
104
  this.ws?.close();
@@ -250,6 +272,7 @@ export class RelayConnector {
250
272
  ({ ticket, relayUrl } = await this.mintTicket());
251
273
  }
252
274
  catch (error) {
275
+ this.lastErrorMessage = `ticket mint failed: ${error.message}`;
253
276
  console.warn("[relay] could not mint relay ticket:", error.message);
254
277
  this.scheduleReconnect();
255
278
  return;
@@ -276,6 +299,8 @@ export class RelayConnector {
276
299
  return;
277
300
  }
278
301
  if (env.t === "ready") {
302
+ this.ready = true;
303
+ this.lastErrorMessage = undefined;
279
304
  this.startHeartbeat(ws);
280
305
  this.scheduleBackoffReset(ws);
281
306
  console.log("[relay] connected");
@@ -311,6 +336,7 @@ export class RelayConnector {
311
336
  }
312
337
  if (env.t === "error") {
313
338
  const message = env.error || "Relay error";
339
+ this.lastErrorMessage = message;
314
340
  console.warn("[relay] error:", message);
315
341
  if (isFatalRelayError(message)) {
316
342
  console.warn("[relay] disabling connector; fix relay setup/plan and restart the node dev server");
@@ -323,11 +349,13 @@ export class RelayConnector {
323
349
  this.lastPongAt = Date.now();
324
350
  });
325
351
  ws.on("close", () => {
352
+ this.ready = false;
326
353
  this.stopHeartbeat();
327
354
  this.clearBackoffReset();
328
355
  this.scheduleReconnect();
329
356
  });
330
357
  ws.on("error", (error) => {
358
+ this.lastErrorMessage = error.message;
331
359
  console.warn("[relay] socket error:", error.message);
332
360
  });
333
361
  }
@@ -1164,6 +1164,10 @@ export class ClaudeCodeRuntime {
1164
1164
  // The on-disk jsonl transcript can be exported and re-materialised on another
1165
1165
  // node under a fresh session id, so a claude->claude fork is full fidelity.
1166
1166
  forkTransport: true,
1167
+ // Claude can also synthesise a resumable jsonl from portable {role,text}
1168
+ // history, so a fork FROM another agent INTO claude is a true replay of the
1169
+ // whole transcript rather than a seeded summary (see importHistoryForFork).
1170
+ forkHistoryImport: true,
1167
1171
  // Claude Code ignores the streamingBehavior hint entirely — a mid-turn
1168
1172
  // prompt always re-enters the live input queue and behaves like an
1169
1173
  // immediate steer, regardless of what's asked for. There is no real
@@ -1281,6 +1285,46 @@ export class ClaudeCodeRuntime {
1281
1285
  fs.writeFileSync(path.join(projectDir, `${newId}.jsonl`), rewritten ? `${rewritten}\n` : "");
1282
1286
  return { sessionFile: newId, id: newId };
1283
1287
  }
1288
+ /**
1289
+ * Stand up a claude session from a **cross-runtime** fork's portable history:
1290
+ * synthesise a resumable jsonl transcript from the `{role, text}` turns and
1291
+ * write it under the destination cwd's project dir with a fresh session id, so
1292
+ * `--resume <id>` opens on a copy of the whole conversation (fidelity
1293
+ * "replayed"). Each turn becomes one claude jsonl entry whose `message` is a
1294
+ * plain-text user/assistant message — the same shape loadClaudeTranscript reads
1295
+ * back — chained by `uuid`/`parentUuid` exactly as claude's own transcripts are.
1296
+ * Tool activity is already inlined as text upstream, so no `tool_use`/
1297
+ * `tool_result` blocks (whose ids would dangle) are ever emitted. The source is
1298
+ * never touched.
1299
+ */
1300
+ async importHistoryForFork(history, ctx) {
1301
+ const newId = randomUUID();
1302
+ const cwd = ctx.cwd || ctx.workspace;
1303
+ // Claude encodes the cwd into the project-dir name by replacing every
1304
+ // non-alphanumeric char with "-" (matches importForFork above).
1305
+ const projectSlug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
1306
+ const root = claudeProjectDirs()[0] ?? path.join(os.homedir(), ".claude");
1307
+ const projectDir = path.join(root, "projects", projectSlug);
1308
+ fs.mkdirSync(projectDir, { recursive: true });
1309
+ let parentUuid = null;
1310
+ const lines = history.map((message) => {
1311
+ const uuid = randomUUID();
1312
+ const entry = {
1313
+ parentUuid,
1314
+ isSidechain: false,
1315
+ userType: "external",
1316
+ cwd,
1317
+ sessionId: newId,
1318
+ type: message.role,
1319
+ message: { role: message.role, content: message.text },
1320
+ uuid,
1321
+ };
1322
+ parentUuid = uuid;
1323
+ return JSON.stringify(entry);
1324
+ });
1325
+ fs.writeFileSync(path.join(projectDir, `${newId}.jsonl`), lines.length ? `${lines.join("\n")}\n` : "");
1326
+ return { sessionFile: newId, id: newId };
1327
+ }
1284
1328
  async listSessions() {
1285
1329
  const dir = this.options.sessionsDir;
1286
1330
  if (dir) {
@@ -21,6 +21,7 @@
21
21
  import fs from "node:fs";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
+ import { randomUUID } from "node:crypto";
24
25
  import { hasLiveProcessForCwd } from "./native-process-scan.js";
25
26
  /** Binary names a live Codex process could be running under (see
26
27
  * native-process-scan.ts's best-effort cwd match). */
@@ -132,6 +133,55 @@ export function loadCodexTranscriptFile(file) {
132
133
  }
133
134
  return messages;
134
135
  }
136
+ /**
137
+ * Materialise a **cross-runtime** fork's portable history as a fresh Codex
138
+ * rollout so `codex ... resume <id>` (the app-server's `thread/resume`) opens on
139
+ * a copy of the whole conversation — the write-side counterpart to
140
+ * `loadCodexTranscript`, and Codex's `importHistoryForFork` (fidelity
141
+ * "replayed"). The rollout is written in the current wrapped layout — a
142
+ * `session_meta` line then one `response_item` per turn — under the id-addressed
143
+ * date path Codex uses (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`), the
144
+ * same shape `loadCodexTranscriptFile` reads back and `discoverCodexSessionForCwd`
145
+ * locates. Message turns carry a Responses-API `message` item (`input_text` for
146
+ * the user, `output_text` for the assistant), which reads back through `textOf`.
147
+ *
148
+ * IMPORTANT — best-effort, and NOT verified against a live Codex resume here (see
149
+ * this module's header): Codex's rollout schema is version-variable, so whether a
150
+ * *synthesised* rollout is fully honored by `thread/resume` depends on the
151
+ * installed Codex. The fork engine calls this only as its "replayed" tier and
152
+ * falls back to a seeded continuation prompt if it throws; a node can force that
153
+ * fallback outright with `BIVY_CODEX_NO_FORK_REPLAY=1` when its Codex build
154
+ * doesn't accept synthesised rollouts.
155
+ */
156
+ export function writeCodexRollout(history, cwd) {
157
+ if (process.env.BIVY_CODEX_NO_FORK_REPLAY === "1") {
158
+ throw new Error("Codex fork replay disabled (BIVY_CODEX_NO_FORK_REPLAY=1)");
159
+ }
160
+ const id = randomUUID();
161
+ const now = new Date();
162
+ const iso = now.toISOString();
163
+ const yyyy = String(now.getUTCFullYear());
164
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
165
+ const dd = String(now.getUTCDate()).padStart(2, "0");
166
+ const dir = path.join(codexSessionsDir(), yyyy, mm, dd);
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ const stamp = iso.replace(/[:.]/g, "-").replace(/Z$/, "");
169
+ const file = path.join(dir, `rollout-${stamp}-${id}.jsonl`);
170
+ const records = [
171
+ { type: "session_meta", timestamp: iso, payload: { id, timestamp: iso, cwd, cli_version: "bivy-fork" } },
172
+ ...history.map((message) => ({
173
+ type: "response_item",
174
+ timestamp: iso,
175
+ payload: {
176
+ type: "message",
177
+ role: message.role,
178
+ content: [{ type: message.role === "user" ? "input_text" : "output_text", text: message.text }],
179
+ },
180
+ })),
181
+ ];
182
+ fs.writeFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n");
183
+ return { sessionFile: id, id };
184
+ }
135
185
  /** Enumerate Codex sessions on disk, newest first. Best-effort. */
136
186
  export function listCodexSessions() {
137
187
  const sessions = [];
@@ -7,7 +7,7 @@ import { spawn, spawnSync } from "node:child_process";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "./claude-code.js";
10
- import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript } from "./codex-sessions.js";
10
+ import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout } from "./codex-sessions.js";
11
11
  import { createCredentialStore } from "./credentials.js";
12
12
  // Args that continue an existing Codex session each prompt. Codex assigns its own
13
13
  // session id (no launch-time pin), so a resumed run threads it via `exec resume`.
@@ -1076,6 +1076,10 @@ function codexAppServerRuntime(credsDir, tier) {
1076
1076
  resumable: true,
1077
1077
  loadHistory: (sessionId) => loadCodexTranscript(sessionId),
1078
1078
  deleteHistory: (sessionId) => void deleteCodexSession(sessionId),
1079
+ // True cross-runtime replay INTO Codex: synthesise a resumable rollout from
1080
+ // portable history so a fork from another agent opens on a copy of the whole
1081
+ // conversation instead of a seeded summary (best-effort — see writeCodexRollout).
1082
+ writeHistory: (history, ctx) => writeCodexRollout(history, ctx.cwd || ctx.workspace),
1079
1083
  suggestName: suggestCodexSessionName,
1080
1084
  // Native discovery (issue #156): enumerate Codex rollouts on this node that
1081
1085
  // Bivy didn't start, so a pre-existing `codex` session can be adopted here
@@ -366,6 +366,9 @@ export class PiRuntime {
366
366
  // pi transcripts are structured messages that round-trip through the session
367
367
  // store, so a pi->pi fork is full fidelity (see exportForFork/importForFork).
368
368
  forkTransport: true,
369
+ // pi can also stand up a session from portable {role,text} history, so a fork
370
+ // FROM another agent INTO pi is a true replay, not a seeded summary.
371
+ forkHistoryImport: true,
369
372
  // The pi-coding-agent SDK implements both explicitly: prompting mid-turn
370
373
  // with no streamingBehavior hint throws, forcing every caller to choose.
371
374
  streamingBehaviors: ["steer", "followUp"],
@@ -515,4 +518,22 @@ export class PiRuntime {
515
518
  throw new Error("pi.importForFork: session file was not persisted");
516
519
  return { sessionFile, id: sessionManager.getSessionId() };
517
520
  }
521
+ /**
522
+ * Stand up a pi session from a **cross-runtime** fork's portable history: open
523
+ * a fresh session in the destination workspace and append each `{role, text}`
524
+ * turn as a plain-text message, so the new session resumes on a copy of the
525
+ * whole conversation (fidelity "replayed"). The turns already have any tool
526
+ * activity inlined as text (see buildForkHistory), so nothing here needs to
527
+ * reconstruct provider-specific tool blocks. Never touches the source.
528
+ */
529
+ async importHistoryForFork(history, ctx) {
530
+ const sessionManager = SessionManager.create(ctx.cwd || ctx.workspace, this.options.sessionsDir);
531
+ for (const message of history) {
532
+ sessionManager.appendMessage({ role: message.role, content: message.text });
533
+ }
534
+ const sessionFile = sessionManager.getSessionFile();
535
+ if (!sessionFile)
536
+ throw new Error("pi.importHistoryForFork: session file was not persisted");
537
+ return { sessionFile, id: sessionManager.getSessionId() };
538
+ }
518
539
  }
@@ -553,6 +553,10 @@ export class ProtocolRuntime {
553
553
  // and takeover treat it as resumable up front (the ProcessRuntime convention).
554
554
  if (options.resumable)
555
555
  this.capabilities.resume = true;
556
+ // A runtime that can write its own resumable store from portable history
557
+ // (Codex's rollout) supports true cross-runtime replay forks INTO it.
558
+ if (options.writeHistory)
559
+ this.capabilities.forkHistoryImport = true;
556
560
  if (options.capabilities)
557
561
  Object.assign(this.capabilities, options.capabilities);
558
562
  }
@@ -580,6 +584,18 @@ export class ProtocolRuntime {
580
584
  readMessages(sessionFile) {
581
585
  return this.options.loadHistory?.(sessionFile);
582
586
  }
587
+ /**
588
+ * Materialise a cross-runtime fork's portable history into this agent's own
589
+ * resumable store (fidelity "replayed"), delegating to the runtime-specific
590
+ * `writeHistory` hook (Codex's `writeCodexRollout`). Only present in effect
591
+ * when configured; the fork engine gates on `capabilities.forkHistoryImport`
592
+ * and falls back to a seeded prompt if this throws.
593
+ */
594
+ async importHistoryForFork(history, ctx) {
595
+ if (!this.options.writeHistory)
596
+ throw new Error(`${this.displayName} does not support history import.`);
597
+ return this.options.writeHistory(history, ctx);
598
+ }
583
599
  /** See ProtocolRuntimeOptions.discoverNativeSessions (issue #156). */
584
600
  async discoverNativeSessions() {
585
601
  try {
package/dist/server.js CHANGED
@@ -3141,7 +3141,7 @@ const RELAY_COMMANDS = {
3141
3141
  // When the client has already picked a target agent, pass it so the
3142
3142
  // bundle omits the native payload for a cross-runtime fork (it could
3143
3143
  // never be replayed there — see buildForkBundle). Unset => keep it.
3144
- const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg) });
3144
+ const bundle = buildForkBundle({ runtime: getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: agentFrom(msg), liveMessages: rec.session.getMessages() });
3145
3145
  relay?.sendEvent({ type: "session.fork.bundle", requestId, bundle });
3146
3146
  }
3147
3147
  catch (error) {
@@ -3225,7 +3225,7 @@ const RELAY_COMMANDS = {
3225
3225
  catch { /* best effort */ }
3226
3226
  }
3227
3227
  // Same runtime → the bundle carries the native payload → full fidelity.
3228
- const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId });
3228
+ const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages() });
3229
3229
  // Cut a fresh fork branch (the source still holds its own); skip prereq
3230
3230
  // detection (same node + same runtime ⇒ agent and repo are present).
3231
3231
  const outcome = await standUpFork({
@@ -7577,7 +7577,14 @@ app.get("/api/status", (_req, res) => {
7577
7577
  workspaceBoundary: true,
7578
7578
  strictApprovalOptIn: true,
7579
7579
  },
7580
- relay: { configured: Boolean(relayConfig), connected: Boolean(relay) },
7580
+ relay: {
7581
+ configured: Boolean(relayConfig),
7582
+ // Real link state (relay sent `ready`), not merely "a connector exists".
7583
+ connected: Boolean(relay?.connected),
7584
+ controlPlaneUrl: relayConfig?.controlPlaneUrl,
7585
+ relayUrl: relayConfig?.url,
7586
+ ...(relay?.lastError ? { lastError: relay.lastError } : {}),
7587
+ },
7581
7588
  sessions: {
7582
7589
  open: new Set(openSessions.values()).size,
7583
7590
  indexed: metadata.listSessions().length,
@@ -1,4 +1,4 @@
1
- import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
1
+ import { normalizeMessages, buildSeedPrompt, buildForkHistory, } from "./transcript-normal.js";
2
2
  /**
3
3
  * Capture a fork bundle on the source node. Always includes the normalized
4
4
  * transcript (the cross-runtime seed, and the fallback when a same-runtime
@@ -9,7 +9,10 @@ import { normalizeMessages, buildSeedPrompt, } from "./transcript-normal.js";
9
9
  */
10
10
  export function buildForkBundle(opts) {
11
11
  const { runtime, sessionFile, record } = opts;
12
- const messages = runtime.readMessages?.(sessionFile);
12
+ // Prefer the build-free readMessages fast path (pi/Claude); fall back to the
13
+ // live session's transcript for runtimes without one (the generic CLI runtime),
14
+ // so a fork *from* any agent still carries its real history.
15
+ const messages = runtime.readMessages?.(sessionFile) ?? opts.liveMessages;
13
16
  const normalized = normalizeMessages(messages, {
14
17
  sourceRuntimeId: runtime.id,
15
18
  model: record.model,
@@ -24,30 +27,60 @@ export function buildForkBundle(opts) {
24
27
  return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
25
28
  }
26
29
  /**
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
+ * Decide the best fidelity a fork of `bundle` into `targetRuntime` can achieve:
31
+ * - "full" when the target is the SAME runtime that produced the native
32
+ * payload and can import it (byte-exact resume);
33
+ * - "replayed" when a *different* target can import portable history
34
+ * (`forkHistoryImport`) and there is history to replay — a true
35
+ * fork onto a copy of the transcript;
36
+ * - "seeded" otherwise.
37
+ * Pure; no side effects. `materializeFork` degrades "replayed"→"seeded" if the
38
+ * import fails at run time, so this only reports the *intended* fidelity.
30
39
  */
31
40
  export function resolveForkFidelity(bundle, targetRuntime) {
32
41
  const native = bundle.native;
33
- const canImport = !!native &&
42
+ const canImportNative = !!native &&
34
43
  native.runtimeId === targetRuntime.id &&
35
44
  !!targetRuntime.capabilities.forkTransport &&
36
45
  typeof targetRuntime.importForFork === "function";
37
- return canImport ? "full" : "seeded";
46
+ if (canImportNative)
47
+ return "full";
48
+ const canReplayHistory = !!targetRuntime.capabilities.forkHistoryImport &&
49
+ typeof targetRuntime.importHistoryForFork === "function" &&
50
+ bundle.normalized.turns.length > 0;
51
+ return canReplayHistory ? "replayed" : "seeded";
38
52
  }
39
53
  /**
40
54
  * 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.
55
+ * resume a natively imported transcript (full), replay the portable transcript
56
+ * as real history in the target's own store (replayed — a true cross-runtime
57
+ * fork), or a seed prompt for a fresh session (seeded). The server executes the
58
+ * returned plan (worktree + session creation live there); this stays pure of
59
+ * daemon wiring.
44
60
  */
45
61
  export async function materializeFork(opts) {
46
62
  const { bundle, targetRuntime, ctx } = opts;
47
- if (resolveForkFidelity(bundle, targetRuntime) === "full" && bundle.native && targetRuntime.importForFork) {
63
+ const fidelity = resolveForkFidelity(bundle, targetRuntime);
64
+ if (fidelity === "full" && bundle.native && targetRuntime.importForFork) {
48
65
  const { sessionFile, id } = await targetRuntime.importForFork(bundle.native, ctx);
49
66
  return { kind: "resume", fidelity: "full", sessionFile, id };
50
67
  }
68
+ // True cross-runtime fork: write the whole transcript as real prior turns into
69
+ // the target runtime's own store and resume it. Best-effort — if the runtime's
70
+ // history import throws (a malformed store, an unwritable dir), fall through to
71
+ // a seeded prompt so the fork still succeeds rather than erroring outright.
72
+ if (fidelity === "replayed" && targetRuntime.importHistoryForFork) {
73
+ try {
74
+ const history = buildForkHistory(bundle.normalized);
75
+ if (history.length > 0) {
76
+ const { sessionFile, id } = await targetRuntime.importHistoryForFork(history, ctx);
77
+ return { kind: "resume", fidelity: "replayed", sessionFile, id };
78
+ }
79
+ }
80
+ catch {
81
+ // fall through to the seeded continuation below
82
+ }
83
+ }
51
84
  const seedPrompt = buildSeedPrompt(bundle.normalized, {
52
85
  targetAgent: targetRuntime.displayName,
53
86
  context: { repoSlug: bundle.record.repoSlug, branch: bundle.record.branch, prUrl: bundle.record.prUrl },
@@ -90,26 +90,45 @@ function truncate(text, max) {
90
90
  return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
91
91
  }
92
92
  /**
93
- * Render a compact continuation prompt for a **seeded** (cross-runtime) fork.
93
+ * Render a continuation prompt for a **seeded** (cross-runtime) fork — the
94
+ * fallback when the target runtime can't replay history into its own store.
94
95
  *
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`.
96
+ * The recent-conversation block is **budget-adaptive**, not a fixed tail: it
97
+ * walks backward from the latest turn packing verbatim turns until `charBudget`
98
+ * is reached (or the optional `recentTurns` count cap is hit), so a long run of
99
+ * short turns carries far more context than the old fixed 12, while a few
100
+ * verbose turns still stay bounded for the target's context window and cost. Any
101
+ * turns that don't fit are summarised as an omission count that points at the
102
+ * full transcript — the complete history is one link away. A structured superset
103
+ * of the old client-side `sessionHandoffSummary`.
99
104
  */
100
105
  export function buildSeedPrompt(transcript, opts = {}) {
101
- const recentTurns = opts.recentTurns ?? 12;
102
106
  const perTurnChars = opts.perTurnChars ?? 700;
107
+ const charBudget = opts.charBudget ?? 12000;
108
+ const maxCount = opts.recentTurns ?? Number.POSITIVE_INFINITY;
103
109
  const title = transcript.header.title || "Untitled session";
104
110
  const targetAgent = opts.targetAgent || "a new agent";
105
- const recent = transcript.turns
111
+ const formatted = transcript.turns
106
112
  .filter((t) => t.text || t.toolSummary)
107
- .slice(-recentTurns)
108
113
  .map((t) => {
109
114
  const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
110
115
  return `- ${t.role}: ${truncate(body, perTurnChars)}`;
111
- })
112
- .join("\n");
116
+ });
117
+ // Pack the newest turns first, within both the char budget and the count cap.
118
+ // The most recent turn is always kept, even if it alone exceeds the budget, so
119
+ // the seed is never empty.
120
+ const picked = [];
121
+ let used = 0;
122
+ for (let i = formatted.length - 1; i >= 0 && picked.length < maxCount; i -= 1) {
123
+ const cost = formatted[i].length + 1;
124
+ if (picked.length > 0 && used + cost > charBudget)
125
+ break;
126
+ picked.push(formatted[i]);
127
+ used += cost;
128
+ }
129
+ picked.reverse();
130
+ const omitted = formatted.length - picked.length;
131
+ const recent = picked.length ? picked.join("\n") : "- (no prior turns were available)";
113
132
  const lines = [
114
133
  `I am continuing an existing Bivy session (forked from ${transcript.header.sourceRuntimeId} to ${targetAgent}).`,
115
134
  `Session: ${title}`,
@@ -119,8 +138,10 @@ export function buildSeedPrompt(transcript, opts = {}) {
119
138
  opts.context?.branch ? `Branch: ${opts.context.branch}` : null,
120
139
  opts.context?.prUrl ? `PR: ${opts.context.prUrl}` : null,
121
140
  "",
122
- "Recent conversation (most recent last):",
123
- recent || "- (no prior turns were available)",
141
+ omitted > 0
142
+ ? `Recent conversation (most recent last; ${omitted} earlier turn${omitted === 1 ? "" : "s"} omitted see the full transcript${opts.transcriptUrl ? " linked above" : ""}):`
143
+ : "Recent conversation (most recent last):",
144
+ recent,
124
145
  "",
125
146
  opts.transcriptUrl
126
147
  ? "Open the full transcript link above if this summary is missing anything, then continue from here."
@@ -128,3 +149,47 @@ export function buildSeedPrompt(transcript, opts = {}) {
128
149
  ];
129
150
  return lines.filter((line) => line != null).join("\n");
130
151
  }
152
+ /**
153
+ * Render a normalized transcript as portable `{role, text}` turns for a
154
+ * **replayed** ("true fork") cross-runtime fork — the target runtime writes
155
+ * these as real prior conversation into its own store and resumes, so the new
156
+ * agent opens on a copy of the whole history instead of a seeded summary.
157
+ *
158
+ * Unlike `buildSeedPrompt` this keeps EVERY turn, not just the tail, and keeps
159
+ * each turn's own role instead of flattening the conversation into one user
160
+ * prompt. Two deliberate shaping rules keep the result valid on any target model:
161
+ * - Tool activity is inlined as plain text (`[ran X] …`, `[tool result] …`),
162
+ * never as provider-specific `tool_use`/`tool_result` blocks whose ids/schemas
163
+ * would dangle or mismatch in a different runtime.
164
+ * - Non-conversational roles fold into the model's voice: a pure tool-result
165
+ * turn and a system/error notice both attach as `assistant` text (the agent's
166
+ * own work), so only the human's turns ever carry the `user` role.
167
+ * Consecutive same-role turns are merged so the resumed history reads as clean
168
+ * alternating turns. Tool payloads inherit `normalizeMessages`' compaction, so a
169
+ * replayed fork is faithful in its prose but summarised in raw tool I/O — the
170
+ * working tree (carried separately as a dirty patch) holds the real file state.
171
+ */
172
+ export function buildForkHistory(transcript) {
173
+ const history = [];
174
+ for (const turn of transcript.turns) {
175
+ const role = turn.role === "user" ? "user" : "assistant";
176
+ const parts = [];
177
+ if (turn.role === "error" && turn.text)
178
+ parts.push(`[system] ${turn.text}`);
179
+ else if (turn.text)
180
+ parts.push(turn.text);
181
+ if (turn.toolSummary) {
182
+ const label = turn.role === "tool" ? "tool result" : turn.toolName ? `ran ${turn.toolName}` : "tool";
183
+ parts.push(`[${label}] ${turn.toolSummary}`);
184
+ }
185
+ const text = parts.join("\n\n").trim();
186
+ if (!text)
187
+ continue;
188
+ const last = history[history.length - 1];
189
+ if (last && last.role === role)
190
+ last.text = `${last.text}\n\n${text}`;
191
+ else
192
+ history.push({ role, text });
193
+ }
194
+ return history;
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.1.0-staging.4",
3
+ "version": "0.1.0-staging.6",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",