@bivy/bivy 0.9.1-staging.128 → 0.9.1-staging.129

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.
@@ -1457,17 +1457,46 @@ export class ClaudeCodeRuntime {
1457
1457
  const projectDir = path.join(root, "projects", projectSlug);
1458
1458
  fs.mkdirSync(projectDir, { recursive: true });
1459
1459
  let parentUuid = null;
1460
- const lines = history.map((message) => {
1460
+ const model = ctx.model?.provider === "anthropic"
1461
+ ? ctx.model.id
1462
+ : this.options.defaultModel ?? FALLBACK_MODELS.find((candidate) => candidate.id.includes("sonnet")).id;
1463
+ const timestamp = Date.now();
1464
+ const lines = history.map((turn, index) => {
1461
1465
  const uuid = randomUUID();
1466
+ // Claude's store is permissive when read for display, but `--resume` / the
1467
+ // Agent SDK expects provider-native assistant envelopes. Keep the portable
1468
+ // history generic until this final serializer, then emit the same minimum
1469
+ // shape as a real Claude transcript (content blocks, model/id, stop reason,
1470
+ // usage and timestamps). This is the Claude counterpart to pi's native
1471
+ // serializer and prevents a replay that paints correctly but fails on the
1472
+ // first continued prompt.
1473
+ const message = turn.role === "user"
1474
+ ? { role: "user", content: turn.text }
1475
+ : {
1476
+ model,
1477
+ id: `msg_bivy_${uuid.replace(/-/g, "")}`,
1478
+ type: "message",
1479
+ role: "assistant",
1480
+ content: [{ type: "text", text: turn.text }],
1481
+ stop_reason: "end_turn",
1482
+ stop_sequence: null,
1483
+ usage: {
1484
+ input_tokens: 0,
1485
+ cache_creation_input_tokens: 0,
1486
+ cache_read_input_tokens: 0,
1487
+ output_tokens: 0,
1488
+ },
1489
+ };
1462
1490
  const entry = {
1463
1491
  parentUuid,
1464
1492
  isSidechain: false,
1465
1493
  userType: "external",
1466
1494
  cwd,
1467
1495
  sessionId: newId,
1468
- type: message.role,
1469
- message: { role: message.role, content: message.text },
1496
+ type: turn.role,
1497
+ message,
1470
1498
  uuid,
1499
+ timestamp: new Date(timestamp + index).toISOString(),
1471
1500
  };
1472
1501
  parentUuid = uuid;
1473
1502
  return JSON.stringify(entry);
@@ -12,7 +12,7 @@ import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { completeSimple } from "@earendil-works/pi-ai/compat";
15
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, } from "@earendil-works/pi-coding-agent";
15
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
16
16
  import { createPiModelRuntime } from "./pi-oauth.js";
17
17
  import { toModelInfo as sharedToModelInfo } from "./normalize.js";
18
18
  import { provisionPiAuthJson } from "./credential-provisioning.js";
@@ -539,9 +539,48 @@ export class PiRuntime {
539
539
  * reconstruct provider-specific tool blocks. Never touches the source.
540
540
  */
541
541
  async importHistoryForFork(history, ctx) {
542
- const sessionManager = SessionManager.create(ctx.cwd || ctx.workspace, this.options.sessionsDir);
543
- for (const message of history) {
544
- sessionManager.appendMessage({ role: message.role, content: message.text });
542
+ const cwd = ctx.cwd || ctx.workspace;
543
+ const sessionManager = SessionManager.create(cwd, this.options.sessionsDir);
544
+ const settings = SettingsManager.create(cwd, this.options.piDir);
545
+ const defaultProvider = settings.getDefaultProvider();
546
+ const defaultModel = settings.getDefaultModel();
547
+ const targetModel = ctx.model ?? (defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined);
548
+ const timestamp = Date.now();
549
+ for (const [index, message] of history.entries()) {
550
+ // SessionManager deliberately persists unvalidated objects. A bare
551
+ // `{role, content: string}` looks readable in the UI, but is NOT a valid
552
+ // pi AssistantMessage: the first real prompt later reaches provider and
553
+ // usage code that expects block-array content plus model/usage metadata.
554
+ // That was the codex→pi fork crash ("reading 'length'"). Materialise the
555
+ // portable prose as complete pi-ai messages instead.
556
+ const imported = message.role === "user"
557
+ ? {
558
+ role: "user",
559
+ content: [{ type: "text", text: message.text }],
560
+ timestamp: timestamp + index,
561
+ }
562
+ : {
563
+ role: "assistant",
564
+ content: [{ type: "text", text: message.text }],
565
+ // The target model is enough for pi to restore the requested model
566
+ // before Bivy applies it again after stand-up. The synthetic API id
567
+ // intentionally prevents provider-specific reasoning replay rules;
568
+ // this history contains portable plain text only.
569
+ api: "bivy-fork",
570
+ provider: targetModel?.provider ?? "bivy-fork",
571
+ model: targetModel?.id ?? "portable-history",
572
+ usage: {
573
+ input: 0,
574
+ output: 0,
575
+ cacheRead: 0,
576
+ cacheWrite: 0,
577
+ totalTokens: 0,
578
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
579
+ },
580
+ stopReason: "stop",
581
+ timestamp: timestamp + index,
582
+ };
583
+ sessionManager.appendMessage(imported);
545
584
  }
546
585
  const sessionFile = sessionManager.getSessionFile();
547
586
  if (!sessionFile)
package/dist/server.js CHANGED
@@ -3598,27 +3598,12 @@ const RELAY_COMMANDS = {
3598
3598
  // changes into an E2E bundle the client carries to the destination node.
3599
3599
  const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
3600
3600
  const rec = resolveSession(msg.sessionId);
3601
- if (!rec || !rec.sessionFile) {
3602
- relay?.sendEvent({ type: "session.fork.error", requestId, error: "Session not found or not resumable on this node." });
3601
+ if (!rec) {
3602
+ relay?.sendEvent({ type: "session.fork.error", requestId, error: "Session not found on this node." });
3603
3603
  return;
3604
3604
  }
3605
3605
  try {
3606
- const src = rec.source ?? "";
3607
- const repoSlug = src.startsWith("repo:") ? src.slice(5) : src.startsWith("issue:") ? src.slice(6).replace(/#\d+$/, "") : undefined;
3608
- const forkRecord = {
3609
- sourceSessionId: rec.id,
3610
- runtimeId: rec.runtimeId,
3611
- workspace: rec.workspace,
3612
- cwd: rec.session.cwd || rec.worktree?.path || rec.workspace,
3613
- worktree: rec.worktree?.path,
3614
- branch: rec.worktree?.branch,
3615
- repoSlug,
3616
- prUrl: rec.prUrl,
3617
- source: rec.source,
3618
- title: rec.session.getName(),
3619
- model: rec.session.getCurrentModel()?.name,
3620
- sandbox: rec.sandbox,
3621
- };
3606
+ const forkRecord = forkRecordFor(rec);
3622
3607
  let dirtyPatch;
3623
3608
  if (rec.worktree) {
3624
3609
  try {
@@ -3673,6 +3658,12 @@ const RELAY_COMMANDS = {
3673
3658
  // so cut an independent fork branch instead of trying to adopt it.
3674
3659
  worktree: msg.sameNode === true ? "fresh" : "adopt",
3675
3660
  detectPrereqs: true,
3661
+ // A same-node cross-agent fork can safely retain a non-repo workspace.
3662
+ // A cross-node path belongs to the source machine, so standUpFork keeps
3663
+ // its destination default unless repo metadata lets it reconstruct one.
3664
+ ...(msg.sameNode === true
3665
+ ? { fallback: { workspace: bundle.record.workspace, cwd: bundle.record.cwd } }
3666
+ : {}),
3676
3667
  });
3677
3668
  if (!outcome.ok) {
3678
3669
  relay?.sendEvent({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
@@ -3694,28 +3685,13 @@ const RELAY_COMMANDS = {
3694
3685
  // (model may still change); any node/agent change goes through import above.
3695
3686
  const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
3696
3687
  const rec = resolveSession(msg.sessionId);
3697
- if (!rec || !rec.sessionFile) {
3698
- relay?.sendEvent({ type: "session.fork.error", requestId, error: "Session not found or not resumable on this node." });
3688
+ if (!rec) {
3689
+ relay?.sendEvent({ type: "session.fork.error", requestId, error: "Session not found on this node." });
3699
3690
  return;
3700
3691
  }
3701
3692
  try {
3702
3693
  const runtime = getRuntime(rec.runtimeId);
3703
- const src = rec.source ?? "";
3704
- const repoSlug = src.startsWith("repo:") ? src.slice(5) : src.startsWith("issue:") ? src.slice(6).replace(/#\d+$/, "") : undefined;
3705
- const forkRecord = {
3706
- sourceSessionId: rec.id,
3707
- runtimeId: rec.runtimeId,
3708
- workspace: rec.workspace,
3709
- cwd: rec.session.cwd || rec.worktree?.path || rec.workspace,
3710
- worktree: rec.worktree?.path,
3711
- branch: rec.worktree?.branch,
3712
- repoSlug,
3713
- prUrl: rec.prUrl,
3714
- source: rec.source,
3715
- title: rec.session.getName(),
3716
- model: rec.session.getCurrentModel()?.name,
3717
- sandbox: rec.sandbox,
3718
- };
3694
+ const forkRecord = forkRecordFor(rec);
3719
3695
  // Carry uncommitted work: capture from the SOURCE worktree; standUpFork
3720
3696
  // re-applies it into the fork's fresh worktree. Local git ops only.
3721
3697
  let dirtyPatch;
@@ -6066,6 +6042,33 @@ async function withRepoLock(key, fn) {
6066
6042
  repoWorktreeLocks.set(key, run.then(() => { }, () => { }));
6067
6043
  return run;
6068
6044
  }
6045
+ /** Build the runtime-neutral metadata carried by both local and remote forks. */
6046
+ function forkRecordFor(rec) {
6047
+ const src = rec.source ?? "";
6048
+ const repoSlug = src.startsWith("repo:")
6049
+ ? src.slice(5)
6050
+ : src.startsWith("issue:")
6051
+ ? src.slice(6).replace(/#\d+$/, "")
6052
+ : undefined;
6053
+ const currentModel = rec.session.getCurrentModel();
6054
+ return {
6055
+ sourceSessionId: rec.id,
6056
+ runtimeId: rec.runtimeId,
6057
+ workspace: rec.workspace,
6058
+ cwd: rec.session.cwd || rec.worktree?.path || rec.workspace,
6059
+ worktree: rec.worktree?.path,
6060
+ branch: rec.worktree?.branch,
6061
+ repoSlug,
6062
+ prUrl: rec.prUrl,
6063
+ source: rec.source,
6064
+ title: rec.session.getName(),
6065
+ model: currentModel?.name,
6066
+ ...(currentModel?.provider && currentModel.id
6067
+ ? { modelRef: { provider: String(currentModel.provider), id: String(currentModel.id) } }
6068
+ : {}),
6069
+ sandbox: rec.sandbox,
6070
+ };
6071
+ }
6069
6072
  /**
6070
6073
  * Best-effort push of a fork SOURCE's branch to origin before the bundle leaves
6071
6074
  * the node, so a cross-node fork's committed work travels via origin (the
@@ -6198,7 +6201,13 @@ async function standUpFork(opts) {
6198
6201
  }
6199
6202
  // Materialise the transcript, then stand the session up — resume the imported
6200
6203
  // transcript (full) or a fresh session the caller seeds with plan.seedPrompt.
6201
- const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd }, seed: { transcriptUrl: opts.transcriptUrl } });
6204
+ // Preserve the source model only when the runtime itself is unchanged. A
6205
+ // cross-agent fork must let the destination choose its own default unless the
6206
+ // caller explicitly supplied a model valid for that target. Thread the final
6207
+ // choice through the generic import context: adapters that need native model
6208
+ // metadata (Pi/Claude) can serialize it; the others simply ignore it.
6209
+ const targetModel = opts.model ?? (targetRuntimeId === bundle.record.runtimeId ? bundle.record.modelRef : undefined);
6210
+ const plan = await materializeFork({ bundle, targetRuntime, ctx: { workspace, cwd, model: targetModel }, seed: { transcriptUrl: opts.transcriptUrl } });
6202
6211
  const record = plan.kind === "resume"
6203
6212
  ? await createSession(cwd, plan.sessionFile, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false })
6204
6213
  : await createSession(cwd, undefined, { runtimeId: targetRuntimeId, source: bundle.record.source, sandbox: forkSandbox, makeActive: false });
@@ -6224,7 +6233,7 @@ async function standUpFork(opts) {
6224
6233
  // cleanly, so the fork isn't silently missing work-in-progress.
6225
6234
  if (dirtyWarning)
6226
6235
  broadcast({ type: "session.notice", sessionId: record.id, message: dirtyWarning });
6227
- await applyRequestedModel(record, opts.model ?? nodeDefaultModel() ?? undefined);
6236
+ await applyRequestedModel(record, targetModel);
6228
6237
  persistSessionMetadata(record);
6229
6238
  scheduleAdvertise();
6230
6239
  const missing = opts.detectPrereqs
@@ -12,7 +12,17 @@ export function buildForkBundle(opts) {
12
12
  // Prefer the build-free readMessages fast path (pi/Claude); fall back to the
13
13
  // live session's transcript for runtimes without one (the generic CLI runtime),
14
14
  // so a fork *from* any agent still carries its real history.
15
- const messages = runtime.readMessages?.(sessionFile) ?? opts.liveMessages;
15
+ let messages = opts.liveMessages;
16
+ if (sessionFile && runtime.readMessages) {
17
+ try {
18
+ messages = runtime.readMessages(sessionFile) ?? messages;
19
+ }
20
+ catch {
21
+ // Runtime-native readers are best-effort. The live transcript is the
22
+ // universal source fallback and keeps one broken adapter from blocking a
23
+ // fork out to every other agent.
24
+ }
25
+ }
16
26
  const normalized = normalizeMessages(messages, {
17
27
  sourceRuntimeId: runtime.id,
18
28
  model: record.model,
@@ -21,9 +31,16 @@ export function buildForkBundle(opts) {
21
31
  });
22
32
  // Only worth capturing when the target is the same runtime (or not yet known).
23
33
  const nativeCouldReplay = !opts.targetRuntimeId || opts.targetRuntimeId === runtime.id;
24
- const native = nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork
25
- ? runtime.exportForFork(sessionFile)
26
- : undefined;
34
+ let native;
35
+ if (sessionFile && nativeCouldReplay && runtime.capabilities.forkTransport && runtime.exportForFork) {
36
+ try {
37
+ native = runtime.exportForFork(sessionFile);
38
+ }
39
+ catch {
40
+ // A native export is an optimization, never the only route. The portable
41
+ // transcript below still supports replay or a seeded continuation.
42
+ }
43
+ }
27
44
  return { record, normalized, ...(native ? { native } : {}), ...(opts.dirtyPatch ? { dirtyPatch: opts.dirtyPatch } : {}) };
28
45
  }
29
46
  /**
@@ -47,6 +64,7 @@ export function resolveForkFidelity(bundle, targetRuntime) {
47
64
  return "full";
48
65
  const canReplayHistory = !!targetRuntime.capabilities.forkHistoryImport &&
49
66
  typeof targetRuntime.importHistoryForFork === "function" &&
67
+ Array.isArray(bundle.normalized?.turns) &&
50
68
  bundle.normalized.turns.length > 0;
51
69
  return canReplayHistory ? "replayed" : "seeded";
52
70
  }
@@ -60,25 +78,38 @@ export function resolveForkFidelity(bundle, targetRuntime) {
60
78
  */
61
79
  export async function materializeFork(opts) {
62
80
  const { bundle, targetRuntime, ctx } = opts;
63
- const fidelity = resolveForkFidelity(bundle, targetRuntime);
64
- if (fidelity === "full" && bundle.native && targetRuntime.importForFork) {
65
- const { sessionFile, id } = await targetRuntime.importForFork(bundle.native, ctx);
66
- return { kind: "resume", fidelity: "full", sessionFile, id };
81
+ const intended = resolveForkFidelity(bundle, targetRuntime);
82
+ const validImport = (result) => typeof result?.sessionFile === "string" && result.sessionFile.trim().length > 0 &&
83
+ typeof result.id === "string" && result.id.trim().length > 0;
84
+ // Native transport is the preferred same-runtime route, but never a single
85
+ // point of failure. An adapter upgrade, stale native store, or malformed
86
+ // payload degrades through the exact same portable path all agents share.
87
+ if (intended === "full" && bundle.native && targetRuntime.importForFork) {
88
+ try {
89
+ const imported = await targetRuntime.importForFork(bundle.native, ctx);
90
+ if (validImport(imported))
91
+ return { kind: "resume", fidelity: "full", ...imported };
92
+ }
93
+ catch {
94
+ // Continue to portable replay (if supported), then the universal seed.
95
+ }
67
96
  }
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) {
97
+ // Write the runtime-neutral conversation into any destination that advertises
98
+ // a native history serializer. This path also rescues a failed same-runtime
99
+ // native import, so Pi/Claude/Codex all share one degradation ladder.
100
+ const canReplay = !!targetRuntime.capabilities.forkHistoryImport &&
101
+ typeof targetRuntime.importHistoryForFork === "function";
102
+ if (canReplay) {
73
103
  try {
74
104
  const history = buildForkHistory(bundle.normalized);
75
105
  if (history.length > 0) {
76
- const { sessionFile, id } = await targetRuntime.importHistoryForFork(history, ctx);
77
- return { kind: "resume", fidelity: "replayed", sessionFile, id };
106
+ const imported = await targetRuntime.importHistoryForFork(history, ctx);
107
+ if (validImport(imported))
108
+ return { kind: "resume", fidelity: "replayed", ...imported };
78
109
  }
79
110
  }
80
111
  catch {
81
- // fall through to the seeded continuation below
112
+ // Fall through to the agent-independent seeded continuation below.
82
113
  }
83
114
  }
84
115
  const seedPrompt = buildSeedPrompt(bundle.normalized, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.9.1-staging.128",
3
+ "version": "0.9.1-staging.129",
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.",