@bivy/bivy 0.18.11-staging.3 → 0.18.11-staging.5

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/acp-shim.mjs CHANGED
@@ -139,6 +139,23 @@ let pendingModel = null;
139
139
  // toolCallId -> { requestId, options } so a later bivy tool.decision answers the
140
140
  // right ACP permission request with a concrete optionId.
141
141
  const permissionRequests = new Map();
142
+ // Calls the human (or policy) already approved. An agent that performs an
143
+ // approved edit through fs/write_text_file (Grok does) must not be gated — and
144
+ // carded — a second time for the same file.
145
+ const approvedCalls = new Set();
146
+
147
+ /** The approved, still-running call whose inputs name `file`, if any. */
148
+ function approvedCallWriting(file) {
149
+ for (const id of approvedCalls) {
150
+ const state = toolCallState.get(id);
151
+ if (!state) { approvedCalls.delete(id); continue; }
152
+ const input = toolCallInput(state);
153
+ const paths = [input.path, input.file_path, input.filePath, ...(state.locations || []).map((l) => l?.path)].filter(Boolean);
154
+ const root = fs.realpathSync(cwd);
155
+ if (paths.some((p) => path.resolve(root, String(p)) === file || path.resolve(cwd, String(p)) === file)) return id;
156
+ }
157
+ return undefined;
158
+ }
142
159
  const sandboxTier = process.env.BIVY_ACP_SANDBOX || "workspace-write";
143
160
 
144
161
  function isWithin(root, candidate) {
@@ -190,6 +207,9 @@ function finishTurnDone() {
190
207
  turnDrainTimer = null;
191
208
  if (!turnDraining) return;
192
209
  turnDraining = false;
210
+ ownCalls.clear();
211
+ childParent.clear();
212
+ approvedCalls.clear();
193
213
  bivy({ type: "session.status", status: "idle" });
194
214
  bivy({ type: "session.done" });
195
215
  }
@@ -222,6 +242,11 @@ function mergeToolCallState(toolCallId, u) {
222
242
  const next = {
223
243
  kind: u.kind ?? prev.kind,
224
244
  title: u.title ?? prev.title,
245
+ // Agents open a call titled with the tool's own name ("task",
246
+ // "spawn_subagent", "bash") and later retitle it with prose ("Count README
247
+ // lines"). Keep the first identifier-like title as the stable name, so a
248
+ // delegation still classifies as one after the retitle.
249
+ name: prev.name ?? (typeof u.title === "string" && /^[A-Za-z_][\w.:-]*$/.test(u.title) ? u.title : undefined),
225
250
  rawInput: u.rawInput && typeof u.rawInput === "object" && Object.keys(u.rawInput).length ? u.rawInput : prev.rawInput,
226
251
  locations: Array.isArray(u.locations) && u.locations.length ? u.locations : prev.locations,
227
252
  content: content && content.length ? content : prev.content,
@@ -259,7 +284,60 @@ function toolCallInput(state) {
259
284
  * both the node's bucket classifier and the client's own name-based heuristic,
260
285
  * which both expect short tool-name-like tokens, not prose. */
261
286
  function toolCallName(state) {
262
- return (state.kind && KIND_TOOL_NAME[state.kind]) || state.title || state.kind || "tool";
287
+ return (state.kind && KIND_TOOL_NAME[state.kind]) || state.name || state.title || state.kind || "tool";
288
+ }
289
+
290
+ // --- sub-agent sessions ------------------------------------------------------
291
+ // Agents that run sub-agents as ACP sessions of their own (Grok's
292
+ // spawn_subagent) stream the child's updates over this same connection, tagged
293
+ // with the CHILD's sessionId. Folding those into the parent turn glued the
294
+ // sub-agent's prose onto the parent's answer and listed its tools as the
295
+ // parent's. Route them instead: child tool calls carry `parentToolCallId` (the
296
+ // delegating call, so the transcript nests them) and child prose stays out of
297
+ // the parent reply — the delegation's own tool result carries its answer.
298
+ const childParent = new Map(); // child sessionId -> delegating toolCallId ("" if unknown)
299
+ // This turn's own (non-child) calls, kept after they complete: a background
300
+ // sub-agent's spawn call often finishes before the child's first update lands.
301
+ const ownCalls = new Map(); // toolCallId -> accumulated state
302
+ const DELEGATION_NAME = /agent|task|delegat/i;
303
+
304
+ function isDelegation(state) {
305
+ const input = state?.rawInput || {};
306
+ return DELEGATION_NAME.test(String(state?.name || "")) || typeof input.subagent_type === "string" || typeof input.prompt === "string";
307
+ }
308
+
309
+ /** The delegating call for a child session: an explicit link when the agent
310
+ * sent one (see linkChildSession), else the newest delegation-shaped call of
311
+ * this turn that no other child has claimed, else the newest open call. */
312
+ function parentCallFor(childSessionId) {
313
+ if (childParent.has(childSessionId)) return childParent.get(childSessionId);
314
+ const claimed = new Set(childParent.values());
315
+ const calls = [...ownCalls].reverse().filter(([id]) => !claimed.has(id));
316
+ const pick = calls.find(([, st]) => isDelegation(st)) ?? calls.find(([id]) => toolCallState.has(id));
317
+ const parent = pick?.[0] ?? "";
318
+ childParent.set(childSessionId, parent);
319
+ return parent;
320
+ }
321
+
322
+ /** An agent-specific notification that names a child session and its parent
323
+ * (`child_session_id` / `parent_session_id`, plus the tool call id or the
324
+ * delegation's description) pins the link instead of the heuristic above. */
325
+ function linkChildSession(update) {
326
+ const child = update?.child_session_id ?? update?.childSessionId;
327
+ const parentSession = update?.parent_session_id ?? update?.parentSessionId;
328
+ if (!child || (parentSession && parentSession !== sessionId) || childParent.get(child)) return;
329
+ const byId = update.tool_call_id ?? update.toolCallId;
330
+ const match = byId && ownCalls.has(byId)
331
+ ? byId
332
+ : [...ownCalls].reverse().find(([, st]) => update.description && (st.title === update.description || st.rawInput?.description === update.description))?.[0];
333
+ if (match) childParent.set(child, match);
334
+ }
335
+
336
+ /** Routing for an update from `sid`: null for this session, else the parent
337
+ * call id ("" when it can't be placed). */
338
+ function childRoute(sid) {
339
+ if (!sid || !sessionId || sid === sessionId) return null;
340
+ return parentCallFor(sid);
263
341
  }
264
342
 
265
343
  async function ensureInitialized() {
@@ -309,6 +387,9 @@ function onSessionUpdate(params) {
309
387
  // window so session.done waits for it instead of sealing history short.
310
388
  if (turnDraining) scheduleTurnDone();
311
389
  const kind = String(u.sessionUpdate || "");
390
+ const parentCall = childRoute(params?.sessionId);
391
+ const child = parentCall !== null;
392
+ const nest = parentCall ? { parentToolCallId: parentCall } : {};
312
393
  const textOf = (content) => {
313
394
  if (!content) return "";
314
395
  if (typeof content === "string") return content;
@@ -327,12 +408,12 @@ function onSessionUpdate(params) {
327
408
  switch (kind) {
328
409
  case "agent_message_chunk": {
329
410
  const t = textOf(u.content);
330
- if (t) bivy({ type: "message.delta", text: t });
411
+ if (t && !child) bivy({ type: "message.delta", text: t });
331
412
  break;
332
413
  }
333
414
  case "agent_thought_chunk": {
334
415
  const t = textOf(u.content);
335
- if (t) bivy({ type: "message.reasoning", text: t });
416
+ if (t && !child) bivy({ type: "message.reasoning", text: t });
336
417
  break;
337
418
  }
338
419
  case "tool_call": {
@@ -340,12 +421,14 @@ function onSessionUpdate(params) {
340
421
  // shows the action; the result arrives via tool_call_update.
341
422
  const toolCallId = String(u.toolCallId ?? u.id ?? "");
342
423
  const state = mergeToolCallState(toolCallId, u);
343
- bivy({ type: "tool.observe", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
424
+ if (!child) ownCalls.set(toolCallId, state);
425
+ bivy({ type: "tool.observe", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...nest });
344
426
  break;
345
427
  }
346
428
  case "tool_call_update": {
347
429
  const toolCallId = String(u.toolCallId ?? u.id ?? "");
348
430
  const state = mergeToolCallState(toolCallId, u);
431
+ if (!child && ownCalls.has(toolCallId)) ownCalls.set(toolCallId, state);
349
432
  const status = String(u.status || "");
350
433
  if (status === "completed" || status === "failed") {
351
434
  toolCallState.delete(toolCallId);
@@ -362,17 +445,18 @@ function onSessionUpdate(params) {
362
445
  // the terminal frame's content when it has some, else the accumulated.
363
446
  result: textOf(u.content) || textOf(state.content) || status,
364
447
  isError: status === "failed",
448
+ ...nest,
365
449
  });
366
450
  } else {
367
451
  // Still running: forward the fuller name/input as it fills in so a live
368
452
  // tool card isn't stuck with the sparse initial notification.
369
- bivy({ type: "tool.update", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
453
+ bivy({ type: "tool.update", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...nest });
370
454
  }
371
455
  break;
372
456
  }
373
457
  case "plan":
374
458
  // Optional planning stream — fold into reasoning so nothing is lost.
375
- if (Array.isArray(u.entries)) bivy({ type: "message.reasoning", text: u.entries.map((e) => `• ${e.content ?? ""}`).join("\n") });
459
+ if (!child && Array.isArray(u.entries)) bivy({ type: "message.reasoning", text: u.entries.map((e) => `• ${e.content ?? ""}`).join("\n") });
376
460
  break;
377
461
  default:
378
462
  break;
@@ -391,7 +475,9 @@ async function onAgentRequest(id, method, params) {
391
475
  const options = Array.isArray(params?.options) ? params.options : [];
392
476
  permissionRequests.set(toolCallId, { requestId: id, options });
393
477
  const state = mergeToolCallState(toolCallId, tc);
394
- bivy({ type: "tool.call", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
478
+ const parentCall = childRoute(params?.sessionId);
479
+ if (parentCall === null) ownCalls.set(toolCallId, state);
480
+ bivy({ type: "tool.call", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...(parentCall ? { parentToolCallId: parentCall } : {}) });
395
481
  return;
396
482
  }
397
483
  case "fs/read_text_file": {
@@ -413,9 +499,15 @@ async function onAgentRequest(id, method, params) {
413
499
  try {
414
500
  if (sandboxTier === "read-only") throw new Error("writes are disabled by the read-only sandbox");
415
501
  const file = workspacePath(params?.path, { write: true });
502
+ if (approvedCallWriting(file)) {
503
+ fs.writeFileSync(file, String(params?.content ?? ""));
504
+ agentReply(id, {});
505
+ return;
506
+ }
416
507
  const toolCallId = `acp-fs-write-${id}`;
417
508
  permissionRequests.set(toolCallId, { kind: "fs-write", requestId: id, file, content: String(params?.content ?? "") });
418
- bivy({ type: "tool.call", toolCallId, name: "write", input: { path: file } });
509
+ const parentCall = childRoute(params?.sessionId);
510
+ bivy({ type: "tool.call", toolCallId, name: "write", input: { path: file }, ...(parentCall ? { parentToolCallId: parentCall } : {}) });
419
511
  } catch (e) {
420
512
  agentReplyError(id, -32000, `write failed: ${e.message}`);
421
513
  }
@@ -449,6 +541,7 @@ createInterface({ input: agent.stdout }).on("line", (line) => {
449
541
  if (msg.id !== undefined && msg.method) { void onAgentRequest(msg.id, msg.method, msg.params); return; }
450
542
  // Notification (method, no id).
451
543
  if (msg.method === "session/update") onSessionUpdate(msg.params);
544
+ else if (msg.params?.update) linkChildSession(msg.params.update);
452
545
  });
453
546
 
454
547
  /**
@@ -554,16 +647,22 @@ async function onBivyCommand(msg) {
554
647
  permissionRequests.delete(msg.toolCallId);
555
648
  const allow = msg.decision !== "deny";
556
649
  if (entry.kind === "fs-write") {
557
- if (!allow) agentReplyError(entry.requestId, -32001, String(msg.reason || "write denied by Bivy policy"));
558
- else {
559
- try { fs.writeFileSync(entry.file, entry.content); agentReply(entry.requestId, {}); }
560
- catch (e) { agentReplyError(entry.requestId, -32000, `write failed: ${e.message}`); }
650
+ // The write card is the shim's own call, so the shim closes it too —
651
+ // left open it pinned the turn "Working" after the agent finished.
652
+ let error = allow ? "" : String(msg.reason || "write denied by Bivy policy");
653
+ if (allow) {
654
+ try { fs.writeFileSync(entry.file, entry.content); }
655
+ catch (e) { error = `write failed: ${e.message}`; }
561
656
  }
657
+ if (error) agentReplyError(entry.requestId, allow ? -32000 : -32001, error);
658
+ else agentReply(entry.requestId, {});
659
+ bivy({ type: "tool.result", toolCallId: msg.toolCallId, name: "write", result: error || `Wrote ${entry.file}`, isError: Boolean(error) });
562
660
  return;
563
661
  }
564
662
  // Pick an ACP option matching the human's choice by its `kind`
565
663
  // (allow_once/allow_always vs reject_once/reject_always); fall back to the
566
664
  // first option, or a cancelled outcome when nothing fits.
665
+ if (allow) approvedCalls.add(msg.toolCallId);
567
666
  const want = allow ? /^allow/ : /^reject/;
568
667
  const opt = entry.options.find((o) => want.test(String(o.kind || ""))) ?? entry.options[0];
569
668
  if (opt && opt.optionId !== undefined) agentReply(entry.requestId, { outcome: { outcome: "selected", optionId: opt.optionId } });
@@ -22,7 +22,7 @@
22
22
  "hidden": false,
23
23
  "supportTier": "supported",
24
24
  "certification": "release-tested",
25
- "testedVersion": "1.18.32",
25
+ "testedVersion": "1.18.33",
26
26
  "headlessFlags": [
27
27
  "run",
28
28
  "-s"
@@ -191,7 +191,7 @@
191
191
  "hidden": false,
192
192
  "supportTier": "supported",
193
193
  "certification": "release-tested",
194
- "testedVersion": "1.0.41",
194
+ "testedVersion": "1.0.44",
195
195
  "headlessFlags": [
196
196
  "--always-approve",
197
197
  "-p",
@@ -4,7 +4,7 @@ import { defineAgentIntegration } from "../definition.js";
4
4
  import { withExactCapabilitySurface } from "../../runtime/types.js";
5
5
  import { createCredentialStore } from "../../runtime/credentials.js";
6
6
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
7
- export const CLAUDE_TESTED_VERSION = "0.3.281";
7
+ export const CLAUDE_TESTED_VERSION = "0.3.284";
8
8
  const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
9
9
  toolInterception: true,
10
10
  modelSelection: true,
@@ -10,7 +10,7 @@ import { codexCredentialPreflight } from "../../runtime/codex-preflight.js";
10
10
  import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout, exportCodexRollout, importCodexRollout, } from "../../runtime/codex-sessions.js";
11
11
  import { ProtocolRuntime } from "../../runtime/protocol.js";
12
12
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
13
- export const CODEX_TESTED_VERSION = "0.156.1";
13
+ export const CODEX_TESTED_VERSION = "0.159.0";
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
16
16
  }
@@ -43,7 +43,7 @@ export const AGENT_PROFILES = {
43
43
  // Approve/Deny + session/load resume + a real model picker), the same bar Pi,
44
44
  // Claude Code, and Codex clear. See `acp` below for the version fallback.
45
45
  supportTier: "supported",
46
- testedVersion: "1.18.32",
46
+ testedVersion: "1.18.33",
47
47
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
48
48
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
49
49
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -363,7 +363,7 @@ export const AGENT_PROFILES = {
363
363
  // Approve/Deny + native resume + live model state), the same bar Pi, Claude
364
364
  // Code, Codex, and OpenCode clear. See `acp` below for the version fallback.
365
365
  supportTier: "supported",
366
- testedVersion: "1.0.41",
366
+ testedVersion: "1.0.44",
367
367
  authOwner: "mixed",
368
368
  blurb: "xAI's official Grok coding agent (Grok CLI) — SuperGrok/X subscription or API key.",
369
369
  // Official CLI: `grok -p "<prompt>"` (alias `--single`) runs one headless
@@ -424,7 +424,7 @@ export const AGENT_PROFILES = {
424
424
  // (protocolVersion 1: session/new, session/prompt, session/request_permission,
425
425
  // loadSession resume, live model state) — the governed ProtocolRuntime path
426
426
  // that supersedes the `--always-approve` pipe above with real per-tool
427
- // Approve/Deny. Validated against grok 1.0.41, so it is ON by default
427
+ // Approve/Deny. Validated against grok 1.0.44, so it is ON by default
428
428
  // (`preferred`) — gated on the binary actually describing the `agent`
429
429
  // subcommand ("Run Grok without the interactive UI" in `grok --help`), so an
430
430
  // older CLI falls back to the pipe path rather than opening a dead session.
@@ -3,10 +3,10 @@
3
3
  export const CERTIFICATION_MATRIX = {
4
4
  schemaVersion: 1,
5
5
  agents: [
6
- { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.281", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
- { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.156.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
6
+ { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.284", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
+ { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.159.0", capabilities: ["toolInterception", "modelSelection", "resume"] },
8
8
  { id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.87.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
- { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.32", capabilities: ["toolInterception", "modelSelection", "resume"] },
10
- { id: "grok", status: "active", executionMode: "protocol", pinnedVersion: "1.0.41", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
+ { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.33", capabilities: ["toolInterception", "modelSelection", "resume"] },
10
+ { id: "grok", status: "active", executionMode: "protocol", pinnedVersion: "1.0.44", capabilities: ["toolInterception", "modelSelection", "resume"] },
11
11
  ]
12
12
  };
@@ -11,7 +11,7 @@ import { buildForkBundle } from "../session/fork.js";
11
11
  import { captureDirtyPatch, captureWorkspaceDirtyPatch, captureWorkspaceSnapshot } from "../session/fork-dirty.js";
12
12
  export function createForkCommands(deps) {
13
13
  return {
14
- async "session.fork.retire-source"(msg) {
14
+ async "session.fork.retire-source"(msg, ctx) {
15
15
  // Retire a MOVE's source once its destination is confirmed (1A). Gated (won't
16
16
  // retire without newSessionId) and idempotent (safe for the client to retry),
17
17
  // so a crashed-mid-move client can't orphan the source or lose it. Emits the
@@ -22,25 +22,25 @@ export function createForkCommands(deps) {
22
22
  try {
23
23
  const outcome = await deps.retireSource({ sourceSessionId, newSessionId });
24
24
  if (!outcome.ok) {
25
- deps.sendEvent({ type: "session.fork.error", requestId, sessionId: sourceSessionId || undefined, error: outcome.error });
25
+ ctx.reply({ type: "session.fork.error", requestId, sessionId: sourceSessionId || undefined, error: outcome.error });
26
26
  return;
27
27
  }
28
28
  if (outcome.retired)
29
29
  deps.broadcast({ type: "session.deleted", sessionId: sourceSessionId });
30
- deps.sendEvent({ type: "session.fork.retired", requestId, sourceSessionId, newSessionId, alreadyGone: outcome.alreadyGone });
30
+ ctx.reply({ type: "session.fork.retired", requestId, sourceSessionId, newSessionId, alreadyGone: outcome.alreadyGone });
31
31
  }
32
32
  catch (error) {
33
- deps.sendEvent({ type: "session.fork.error", requestId, sessionId: sourceSessionId || undefined, error: error instanceof Error ? error.message : String(error) });
33
+ ctx.reply({ type: "session.fork.error", requestId, sessionId: sourceSessionId || undefined, error: error instanceof Error ? error.message : String(error) });
34
34
  }
35
35
  },
36
- async "session.fork.export"(msg) {
36
+ async "session.fork.export"(msg, ctx) {
37
37
  // Source side of a session fork: package the session's transcript +
38
38
  // portable metadata + any uncommitted worktree changes into an E2E
39
39
  // bundle the client carries to the destination node.
40
40
  const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
41
41
  const rec = deps.resolveSession(msg.sessionId);
42
42
  if (!rec) {
43
- deps.sendEvent({ type: "session.fork.error", requestId, error: "Session not found on this node." });
43
+ ctx.reply({ type: "session.fork.error", requestId, error: "Session not found on this node." });
44
44
  return;
45
45
  }
46
46
  try {
@@ -72,14 +72,14 @@ export function createForkCommands(deps) {
72
72
  // When the client has already picked a target agent, pass it so the
73
73
  // bundle omits the native payload for a cross-runtime fork (it could
74
74
  // never be replayed there — see buildForkBundle). Unset => keep it.
75
- const bundle = buildForkBundle({ runtime: deps.getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: deps.agentFrom(msg), liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
76
- deps.sendEvent({ type: "session.fork.bundle", requestId, bundle });
75
+ const bundle = buildForkBundle({ runtime: deps.getRuntime(rec.runtimeId), sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: deps.agentFrom(msg), liveMessages: deps.forkMessages(rec), state: deps.forkInFlightState(rec) });
76
+ ctx.reply({ type: "session.fork.bundle", requestId, bundle });
77
77
  }
78
78
  catch (error) {
79
- deps.sendEvent({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
79
+ ctx.reply({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
80
80
  }
81
81
  },
82
- async "session.fork.import"(msg) {
82
+ async "session.fork.import"(msg, ctx) {
83
83
  // Destination side of a fork: rebuild the repo/worktree, materialise the
84
84
  // transcript into the (possibly different) target runtime — full fidelity
85
85
  // for a same-runtime fork, a seeded continuation prompt otherwise — and
@@ -88,7 +88,7 @@ export function createForkCommands(deps) {
88
88
  const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
89
89
  const bundle = msg.bundle;
90
90
  if (!bundle?.record || !bundle.normalized) {
91
- deps.sendEvent({ type: "session.fork.error", requestId, error: "Malformed fork bundle." });
91
+ ctx.reply({ type: "session.fork.error", requestId, error: "Malformed fork bundle." });
92
92
  return;
93
93
  }
94
94
  try {
@@ -112,16 +112,16 @@ export function createForkCommands(deps) {
112
112
  : {}),
113
113
  });
114
114
  if (!outcome.ok) {
115
- deps.sendEvent({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
115
+ ctx.reply({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
116
116
  return;
117
117
  }
118
- deps.sendEvent(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
118
+ ctx.reply(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
119
119
  }
120
120
  catch (error) {
121
- deps.sendEvent({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
121
+ ctx.reply({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
122
122
  }
123
123
  },
124
- async "session.fork.local"(msg) {
124
+ async "session.fork.local"(msg, ctx) {
125
125
  // Fast path: fork a session on the SAME node and SAME runtime WITHOUT
126
126
  // round-tripping the transcript out to the client and back. We build the
127
127
  // fork bundle in-process and stand the new session
@@ -132,7 +132,7 @@ export function createForkCommands(deps) {
132
132
  const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
133
133
  const rec = deps.resolveSession(msg.sessionId);
134
134
  if (!rec) {
135
- deps.sendEvent({ type: "session.fork.error", requestId, error: "Session not found on this node." });
135
+ ctx.reply({ type: "session.fork.error", requestId, error: "Session not found on this node." });
136
136
  return;
137
137
  }
138
138
  try {
@@ -150,7 +150,7 @@ export function createForkCommands(deps) {
150
150
  ? captureWorkspaceSnapshot(sourceCwd, { maxBytes: deps.forkWorkspaceMaxBytes() })
151
151
  : undefined;
152
152
  // Same runtime → the bundle carries the native payload → full fidelity.
153
- const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: rec.runtimeId, liveMessages: rec.session.getMessages(), state: deps.forkInFlightState(rec) });
153
+ const bundle = buildForkBundle({ runtime, sessionFile: rec.sessionFile, record: forkRecord, dirtyPatch, workspaceSnapshot, targetRuntimeId: rec.runtimeId, liveMessages: deps.forkMessages(rec), state: deps.forkInFlightState(rec) });
154
154
  // Cut a fresh fork branch (the source still holds its own); skip prereq
155
155
  // detection (same node + same runtime ⇒ agent and repo are present).
156
156
  const outcome = await deps.standUpFork({
@@ -162,13 +162,13 @@ export function createForkCommands(deps) {
162
162
  fallback: { workspace: rec.workspace, cwd: rec.session.cwd || rec.worktree?.path || rec.workspace },
163
163
  });
164
164
  if (!outcome.ok) {
165
- deps.sendEvent({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
165
+ ctx.reply({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
166
166
  return;
167
167
  }
168
- deps.sendEvent(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
168
+ ctx.reply(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
169
169
  }
170
170
  catch (error) {
171
- deps.sendEvent({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
171
+ ctx.reply({ type: "session.fork.error", requestId, error: error instanceof Error ? error.message : String(error) });
172
172
  }
173
173
  },
174
174
  };
package/dist/exec.js CHANGED
@@ -15,6 +15,19 @@
15
15
  // unauthenticated CLI) exits non-zero with the error on stderr, rather than
16
16
  // reporting a silent empty success.
17
17
  import { WebSocket } from "ws";
18
+ const USAGE = `Usage: bivy exec [options] "<prompt>" (or pipe the prompt on stdin, or pass -)
19
+
20
+ One-shot headless turn: prints the final answer to stdout, progress to stderr.
21
+
22
+ -a, --agent <id> Agent to run (default: the node's default agent)
23
+ -m, --model <model> Model id, bare or provider-qualified (gpt-5.6-sol, openai-codex/gpt-5.6-sol)
24
+ -n, --name <label> Session name
25
+ -w, --workspace <dir> Workspace directory
26
+ --session <ref> Continue an existing session instead of starting one
27
+ --json Print {"sessionId","answer"} instead of plain text
28
+ --timeout <sec> Give up after this many seconds (default 600)
29
+ -h, --help Show this help
30
+ `;
18
31
  const err = (s) => process.stderr.write(s);
19
32
  function parseArgs(argv) {
20
33
  let url = process.env.BIVY_URL || `http://localhost:${process.env.PORT || "4317"}`;
@@ -25,6 +38,7 @@ function parseArgs(argv) {
25
38
  let name;
26
39
  let workspace;
27
40
  let json = false;
41
+ let help = false;
28
42
  let timeoutMs = Number(process.env.BIVY_EXEC_TIMEOUT_MS) || 10 * 60 * 1000;
29
43
  const prompt = [];
30
44
  for (let i = 0; i < argv.length; i++) {
@@ -59,12 +73,14 @@ function parseArgs(argv) {
59
73
  workspace = arg.slice("--workspace=".length);
60
74
  else if (arg === "--json")
61
75
  json = true;
76
+ else if (arg === "-h" || arg === "--help")
77
+ help = true;
62
78
  else if (arg === "--timeout" && argv[i + 1])
63
79
  timeoutMs = Number(argv[++i]) * 1000;
64
80
  else
65
81
  prompt.push(arg);
66
82
  }
67
- return { url: url.replace(/\/+$/, ""), token, agent, model, session, name, workspace, prompt: prompt.join(" "), json, timeoutMs };
83
+ return { url: url.replace(/\/+$/, ""), token, agent, model, session, name, workspace, prompt: prompt.join(" "), json, timeoutMs, help };
68
84
  }
69
85
  function authHeaders(token) {
70
86
  return { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) };
@@ -116,11 +132,15 @@ async function readStdin() {
116
132
  }
117
133
  async function main() {
118
134
  const args = parseArgs(process.argv.slice(2));
135
+ if (args.help) {
136
+ process.stdout.write(USAGE);
137
+ return;
138
+ }
119
139
  let prompt = args.prompt.trim();
120
140
  if (prompt === "-" || (!prompt && !process.stdin.isTTY))
121
141
  prompt = (await readStdin()).trim();
122
142
  if (!prompt) {
123
- err('Usage: bivy exec "<prompt>" (or pipe the prompt on stdin)\n');
143
+ err(USAGE);
124
144
  process.exit(2);
125
145
  }
126
146
  // Establish the session — resume by ref or create fresh with the chosen agent.
@@ -149,6 +169,15 @@ async function main() {
149
169
  }),
150
170
  });
151
171
  sessionId = created.id;
172
+ // The node binds the model best-effort and keeps the agent's default when
173
+ // it can't; say so instead of silently answering on another model.
174
+ const model = created.model;
175
+ if (args.model && model?.id) {
176
+ const qualified = !model.provider || model.id.startsWith(`${model.provider}/`) ? model.id : `${model.provider}/${model.id}`;
177
+ if (![model.id, qualified].some((id) => id === args.model || id.endsWith(`/${args.model}`))) {
178
+ err(`Model "${args.model}" is not available for this agent; using ${qualified}.\n`);
179
+ }
180
+ }
152
181
  }
153
182
  }
154
183
  catch (error) {
@@ -163,6 +192,9 @@ async function main() {
163
192
  // Last stderr the agent surfaced (auth failures, crashes). Carried so a turn
164
193
  // that ends with no assistant text can explain WHY instead of printing empty.
165
194
  let lastStderr = "";
195
+ // A runtime that fails a turn (usage limit, provider error) seals it with an
196
+ // assistant message marked stopReason "error" before a normal agent_end.
197
+ let turnError = "";
166
198
  const finish = (code, errorText) => {
167
199
  if (settled)
168
200
  return;
@@ -224,6 +256,9 @@ async function main() {
224
256
  const text = assistantText(ev);
225
257
  if (text)
226
258
  answer = text; // events carry the full message text so far
259
+ const message = ev.message;
260
+ if (message?.stopReason === "error" && typeof message.errorMessage === "string")
261
+ turnError = message.errorMessage.trim();
227
262
  }
228
263
  else if (ev.type === "tool_execution_update" && ev.toolName === "agent_output") {
229
264
  const input = (ev.input || {});
@@ -238,10 +273,12 @@ async function main() {
238
273
  // non-zero). Printing an empty answer with exit 0 would report a silent
239
274
  // false success to a script. Surface the failure instead: prefer an
240
275
  // explicit error string, then the last stderr line, then the exit code.
241
- const errText = typeof ev.error === "string" && ev.error.trim() ? ev.error.trim() : undefined;
276
+ const errText = typeof ev.error === "string" && ev.error.trim() ? ev.error.trim() : turnError || undefined;
242
277
  const code = typeof ev.code === "number" ? ev.code : undefined;
243
278
  const failed = !!errText || (code !== undefined && code !== 0);
244
- if (!answer.trim() && failed) {
279
+ // A turn the runtime itself marked failed is a failure even when some
280
+ // prose streamed before the error.
281
+ if (turnError || (!answer.trim() && failed)) {
245
282
  finish(1, errText || lastStderr || `The agent produced no output (exit code ${code ?? "unknown"}).`);
246
283
  }
247
284
  else {
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Bind a loosely written model reference to one entry of a session's catalog.
4
+ //
5
+ // The CLI (`bivy run --model`, `bivy exec --model`) and the `bivy-model:`
6
+ // directive send `{ provider: "", id }`, where `id` may be bare ("gpt-5.6-sol")
7
+ // or provider-qualified ("openai-codex/gpt-5.6-sol"). Runtimes key models
8
+ // differently — Pi by (provider, id), ACP agents by a slash id whose prefix is
9
+ // the provider ("opencode/gpt-5.6-sol") — so a runtime that needs the provider
10
+ // (Pi) rejected the bare form and the session silently kept its default model.
11
+ // Resolving against the session's own catalog makes every spelling work for any
12
+ // runtime without per-agent code.
13
+ /**
14
+ * Resolve `ref` against `models`. An explicit provider is kept as-is. Otherwise
15
+ * match, in order: the exact id, a `provider/id` split, then an id that ends in
16
+ * `/<ref.id>` (a bare name for an ACP slash id). Ties prefer the current
17
+ * model's provider. Unknown refs are returned unchanged so the runtime reports
18
+ * its own error.
19
+ */
20
+ export function resolveModelRef(models, ref, current) {
21
+ if (ref.provider)
22
+ return ref;
23
+ const id = ref.id.trim();
24
+ const slash = id.indexOf("/");
25
+ const tiers = [
26
+ models.filter((m) => m.id === id),
27
+ slash > 0 ? models.filter((m) => m.provider === id.slice(0, slash) && m.id === id.slice(slash + 1)) : [],
28
+ models.filter((m) => m.id.endsWith(`/${id}`)),
29
+ ];
30
+ const candidates = tiers.find((tier) => tier.length) ?? [];
31
+ const pick = candidates.find((m) => m.provider === current?.provider) ?? candidates[0];
32
+ return pick ? { provider: pick.provider, id: pick.id } : ref;
33
+ }
@@ -10,6 +10,11 @@ import { mergeAgentCommands } from "./slash-commands.js";
10
10
  import { withExactCapabilitySurface } from "./types.js";
11
11
  import { extractTokenUsage } from "./cli-parsers.js";
12
12
  import { mapToolCall, mapToolResult } from "./tool-call-map.js";
13
+ /** A tool message's sub-agent parent (`parentToolCallId`), spread into the
14
+ * runtime event and persisted block so delegated work nests under its call. */
15
+ function parentOf(msg) {
16
+ return typeof msg.parentToolCallId === "string" && msg.parentToolCallId ? { parentToolUseId: msg.parentToolCallId } : {};
17
+ }
13
18
  /** A protocol `usage` message → UsageSnapshot (reuses the CLI token-key scan). */
14
19
  function parseProtocolUsage(raw) {
15
20
  if (!raw || typeof raw !== "object")
@@ -774,6 +779,7 @@ class ProtocolSession {
774
779
  const detail = mapToolCall(toolName, msg.input, { provider: this.runtimeOptions.id || "acp", protocol: "protocol" });
775
780
  if (detail)
776
781
  this.toolDetailsByCallId.set(toolCallId, detail);
782
+ const parent = parentOf(msg);
777
783
  // `tool.observe` is for activity an upstream runtime reports after it has
778
784
  // already begun (Codex read-only MCP and sub-agent items, for example).
779
785
  // It must render and persist exactly like a call, but must never open a
@@ -789,12 +795,19 @@ class ProtocolSession {
789
795
  }
790
796
  else {
791
797
  this.flushPendingTurnText();
792
- this.turnContent.push({ type: "tool_use", id: toolCallId, name: toolName, input: msg.input ?? {}, ...(detail ? { detail } : {}) });
798
+ this.turnContent.push({ type: "tool_use", id: toolCallId, name: toolName, input: msg.input ?? {}, ...(detail ? { detail } : {}), ...parent });
799
+ // Prose after a tool is a new item: separate it in the cumulative live
800
+ // text too, or "…the README.Added `sub`…" reads as one sentence.
801
+ if (this.assistantText)
802
+ this.assistantItemBoundary = true;
803
+ // Reasoning after a tool is a new thinking item as well; accumulating it
804
+ // re-showed the earlier thoughts as the prefix of the next block.
805
+ this.reasoningText = "";
793
806
  }
794
807
  // Stream the live tool card for every protocol agent. Interception is an
795
808
  // additional round-trip only for a real `tool.call`; observed activity is
796
809
  // informational because the upstream runtime is already executing it.
797
- this.emit({ type: existing ? "tool_execution_update" : "tool_call", toolName, input: msg.input, toolCallId, ...(detail ? { detail } : {}) });
810
+ this.emit({ type: existing ? "tool_execution_update" : "tool_call", toolName, input: msg.input, toolCallId, ...(detail ? { detail } : {}), ...parent });
798
811
  if (type === "tool.call" && this.capabilitiesRef.toolInterception && this.toolInterceptor) {
799
812
  const decision = await this.toolInterceptor({ sessionId: this.id, toolName, input: msg.input });
800
813
  try {
@@ -827,7 +840,7 @@ class ProtocolSession {
827
840
  if (detail)
828
841
  block.detail = detail;
829
842
  }
830
- this.emit({ type: "tool_execution_update", toolName, input: msg.input, toolCallId, ...(detail ? { detail } : {}) });
843
+ this.emit({ type: "tool_execution_update", toolName, input: msg.input, toolCallId, ...(detail ? { detail } : {}), ...parentOf(msg) });
831
844
  return;
832
845
  }
833
846
  if (type === "tool.result") {
@@ -842,7 +855,7 @@ class ProtocolSession {
842
855
  });
843
856
  const priorDetail = this.toolDetailsByCallId.get(toolCallId);
844
857
  const detail = priorDetail ? { ...priorDetail, result: mapToolResult(result, isError) } : undefined;
845
- this.emit({ type: "tool_result", toolName: String(msg.name || "tool"), toolCallId, result, ...(detail ? { detail } : {}) });
858
+ this.emit({ type: "tool_result", toolName: String(msg.name || "tool"), toolCallId, result, ...(detail ? { detail } : {}), ...parentOf(msg) });
846
859
  return;
847
860
  }
848
861
  this.emit({ type, ...msg });
package/dist/server.js CHANGED
@@ -40,6 +40,7 @@ import { createAppCommands } from "./controllers/app-commands.js";
40
40
  import { bindClientCommandRoutes } from "./http/client-command-routes.js";
41
41
  import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
42
42
  import { aggregateModelCatalog, catalogReplyEvent, mergeProviderCatalog } from "./runtime/model-catalog.js";
43
+ import { resolveModelRef } from "./runtime/model-ref.js";
43
44
  import { RuntimeHost, enforcementLevelFor, remoteRuntimeEnabled } from "./runtime/host.js";
44
45
  import { RemoteRuntime, RemoteRuntimeSession } from "./runtime/remote.js";
45
46
  import { InMemorySessionLocationRegistry } from "./runtime/session-location.js";
@@ -2102,8 +2103,8 @@ const RELAY_COMMANDS = {
2102
2103
  // (server.ts decomposition). Late-bound singletons (forkStandUp/forkRetire/
2103
2104
  // branchPublish, declared below) are wrapped in thunks resolved at dispatch time.
2104
2105
  ...createForkCommands({
2105
- sendEvent: (event) => relay?.sendEvent(event),
2106
2106
  broadcast,
2107
+ forkMessages: (rec) => transcripts.forkMessages(rec),
2107
2108
  resolveSession: (sessionId) => resolveSession(sessionId),
2108
2109
  getRuntime: (runtimeId) => getRuntime(runtimeId),
2109
2110
  forkRecordFor,
@@ -6096,6 +6097,17 @@ async function applyRequestedModel(record, model) {
6096
6097
  if (!model)
6097
6098
  return;
6098
6099
  try {
6100
+ // A provider-less id (CLI --model, `bivy-model:`) binds against the
6101
+ // session's catalog. Protocol agents only publish theirs once running, so
6102
+ // warm it when the first lookup can't place the id.
6103
+ if (!model.provider) {
6104
+ const resolve = async () => resolveModelRef(await record.session.getModels(), model, record.session.getCurrentModel());
6105
+ model = await resolve();
6106
+ if (!model.provider && record.session.warmModels) {
6107
+ await record.session.warmModels().catch(() => { });
6108
+ model = await resolve();
6109
+ }
6110
+ }
6099
6111
  assertSessionModel(record, model.id);
6100
6112
  await record.session.setModel(model.provider, model.id);
6101
6113
  broadcast({ type: "model.updated", sessionId: record.id, model: publicModel(record.session.getCurrentModel(), record.session.getCurrentModel()) });
@@ -6604,6 +6616,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6604
6616
  runtimeId: record.runtimeId,
6605
6617
  sandbox: record.sandbox,
6606
6618
  approvalMode: record.approvalMode,
6619
+ ...sessionModelRef(record),
6607
6620
  credentialLabels: record.credentialLabels,
6608
6621
  agentName: getRuntime(record.runtimeId).displayName,
6609
6622
  contract: record.contract,
@@ -6671,6 +6684,12 @@ function bivySessionEnvelopeFromSummary(s, rec, meta) {
6671
6684
  prUrl: meta?.prUrl ?? s.prUrl,
6672
6685
  };
6673
6686
  }
6687
+ /** The session's current model as a metadata patch (empty when unknown, so a
6688
+ * reopened session that hasn't reported one yet keeps the stored value). */
6689
+ function sessionModelRef(record) {
6690
+ const model = record.session.getCurrentModel();
6691
+ return model?.id ? { model: { provider: String(model.provider ?? ""), id: model.id } } : {};
6692
+ }
6674
6693
  function touchSession(record) {
6675
6694
  record.lastTouchedAt = Date.now();
6676
6695
  metadata.touchSession(record.id, sessionStatus(record));
@@ -8378,6 +8397,13 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8378
8397
  // session" in the sidebar.
8379
8398
  if (requestedSessionFile && storedMeta?.name && !session.getName())
8380
8399
  session.setName(storedMeta.name);
8400
+ // Runtimes that learn their model from the next reply (Claude Code) reopen
8401
+ // with none, so the picker showed the default and the next turn ran on it.
8402
+ // Restore the model the session last used. Best-effort: a model this node no
8403
+ // longer offers keeps the runtime default.
8404
+ if (requestedSessionFile && storedMeta?.model && !session.getCurrentModel()) {
8405
+ await session.setModel(storedMeta.model.provider, storedMeta.model.id).catch(() => { });
8406
+ }
8381
8407
  const sessionWorkspace = session.cwd || runtimeWorkspace;
8382
8408
  // Best-effort here (unlike createWorkspaceSession, which must fail loudly):
8383
8409
  // this only decides whether to ADOPT an already-checked-out branch as the
@@ -11662,6 +11688,16 @@ wss.on("connection", (socket, req) => {
11662
11688
  socket.send(JSON.stringify({ type: "pong", requestId: typeof requestId === "string" ? requestId : undefined }));
11663
11689
  return;
11664
11690
  }
11691
+ if (typeof msg?.kind === "string" && msg.kind.startsWith("session.fork.")) {
11692
+ // Fork/move is a request/reply exchange with no REST route, so direct
11693
+ // (loopback/LAN) clients send it on this authenticated socket and get the
11694
+ // reply here — the same handlers the relay dispatches.
11695
+ const reply = (event) => { if (socket.readyState === WebSocket.OPEN)
11696
+ socket.send(JSON.stringify(event)); };
11697
+ void clientCommands.dispatch(msg.kind, msg, { reply, broadcast })
11698
+ .catch((error) => reply({ type: "session.fork.error", requestId: msg.requestId, error: error instanceof Error ? error.message : String(error) }));
11699
+ return;
11700
+ }
11665
11701
  if (typeof msg?.kind === "string" && msg.kind.startsWith("terminal.")) {
11666
11702
  // A terminal client (`bivy run`/`bivy resume`) names its device once; typing
11667
11703
  // into a session's terminal then makes it that session's driver.
@@ -737,9 +737,13 @@ export class EventLog {
737
737
  * `mergeConversation` helper.
738
738
  */
739
739
  deriveHistory(id, runtimeBase) {
740
+ return mergeTranscript(this.deriveBase(id, runtimeBase), this.read(id));
741
+ }
742
+ /** The conversation itself (runtime transcript over the logged base), without
743
+ * the display overlays — what a fork carries to another agent. */
744
+ deriveBase(id, runtimeBase) {
740
745
  const logged = this.readBase(id);
741
- const base = runtimeBase && runtimeBase.length ? mergeBases(logged, runtimeBase) : logged;
742
- return mergeTranscript(base, this.read(id));
746
+ return runtimeBase && runtimeBase.length ? mergeBases(logged, runtimeBase) : logged;
743
747
  }
744
748
  /** Full ordered record list (already-flushed followed by pending). */
745
749
  entries(id) {
@@ -91,6 +91,28 @@ function insertionIndex(base, timeline, entry) {
91
91
  // Legacy / no-timestamp fallback: clamp the absolute count into range.
92
92
  return Math.max(0, Math.min(base.length, entry.afterMessageCount));
93
93
  }
94
+ /** Tool-call ids a message references (tool_use/toolCall ids, tool_result targets). */
95
+ function toolIds(content) {
96
+ if (!Array.isArray(content))
97
+ return [];
98
+ return content.flatMap((part) => {
99
+ if (!part || typeof part !== "object")
100
+ return [];
101
+ const record = part;
102
+ const id = record.tool_use_id ?? record.toolUseId ?? record.toolCallId ?? (/^(tool_use|toolCall)$/.test(String(record.type)) ? record.id : undefined);
103
+ return typeof id === "string" && id ? [id] : [];
104
+ });
105
+ }
106
+ /** base index of the first message referencing each tool id. */
107
+ function toolIdIndex(base) {
108
+ const index = new Map();
109
+ base.forEach((message, i) => {
110
+ for (const id of toolIds(message.content))
111
+ if (!index.has(id))
112
+ index.set(id, i);
113
+ });
114
+ return index;
115
+ }
94
116
  /**
95
117
  * Merge the `extras` (intermediate + tool-activity sidecar entries) into `base`,
96
118
  * each placed by time (see file header). Deduplicates intermediate reasoning that
@@ -100,10 +122,16 @@ export function mergeTranscript(base, extras) {
100
122
  if (!extras.length)
101
123
  return base;
102
124
  const timeline = baseTimeline(base);
125
+ const baseToolIndex = toolIdIndex(base);
103
126
  const byIndex = new Map();
104
127
  const seenIntermediate = new Set();
105
128
  for (const entry of extras) {
106
- const index = insertionIndex(base, timeline, entry);
129
+ // A tool row whose call is already in the base transcript sits right after
130
+ // that message. Protocol runtimes persist a whole turn as one message when
131
+ // it ends, so time placement put every tool row ahead of the prose that
132
+ // preceded it in the turn (clients keep the first copy of a tool id).
133
+ const anchored = entry.bivyKind === "tool" ? toolIds(entry.content).map((id) => baseToolIndex.get(id)).find((i) => i !== undefined) : undefined;
134
+ const index = anchored !== undefined ? anchored + 1 : insertionIndex(base, timeline, entry);
107
135
  if (entry.bivyKind === "intermediate") {
108
136
  const text = normalizedIntermediateText(thinkingTextFromContent(entry.content));
109
137
  // Skip a sidecar reasoning copy when the adjacent persisted message already
@@ -205,6 +205,9 @@ export function createTranscriptPersistence(deps) {
205
205
  function conversationMessages(record) {
206
206
  return eventLog.deriveHistory(record.id, record.session.getMessages());
207
207
  }
208
+ function forkMessages(record) {
209
+ return eventLog.deriveBase(record.id, record.session.getMessages());
210
+ }
208
211
  function buildHistoryEvent(opts) {
209
212
  const messages = opts.sessionId ? project(opts.sessionId, opts.messages) : opts.messages;
210
213
  const delta = historyDelta(messages, opts.cursor);
@@ -256,6 +259,7 @@ export function createTranscriptPersistence(deps) {
256
259
  persistIntermediateFromEvent,
257
260
  resolveInlineImages,
258
261
  conversationMessages,
262
+ forkMessages,
259
263
  buildHistoryEvent,
260
264
  buildReplayEvent,
261
265
  clearLiveIntermediate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.11-staging.3",
3
+ "version": "0.18.11-staging.5",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
@@ -68,7 +68,7 @@
68
68
  "readme": "# Bivy\n\n[![npm](https://img.shields.io/npm/v/@bivy/bivy?color=2b6cb0&label=%40bivy%2Fbivy)](https://www.npmjs.com/package/@bivy/bivy)\n[![license: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-2b6cb0)](LICENSE)\n[![node](https://img.shields.io/badge/node-%E2%89%A520-2b6cb0)](https://nodejs.org)\n\n**Build, try, and review apps from your phone. Your coding agent and dev\nenvironment stay on your own machines.**\n\nBivy is an open-source remote development environment built around coding\nagents. Ask for a change, try the running app, and point to or draw on what\nneeds fixing. Send that feedback to the agent and try again—all in the same\nsession, without going back to your computer.\n\nLive previews bring your app to your phone or browser without deploying it to\nRender, building a Docker image, or setting up a separate preview server.\nShare a preview link so someone else can try it and leave notes, too.\n\nKeep Claude Code, Codex, Pi, OpenCode, or another supported agent. Keep your\nrepos, tools, databases, and development environment. Bivy connects them to\nchat, a terminal, app previews, and code review wherever you are. Start work\nyourself, or let an issue, CI failure, message, schedule, or webhook start it.\n\n**[Start free on Bivy Cloud](https://app.bivy.sh)** ·\n**[Quickstart](docs/quickstart.md)** ·\n**[Documentation](docs/README.md)** ·\n**[Self-host](docs/deploy-images.md)** ·\n**[Website](https://bivy.sh)**\n\n**Recommended:** sign in at [app.bivy.sh](https://app.bivy.sh), then copy your\npersonalized **Connect a Machine** command into a terminal on your Mac or Linux\ncomputer. It installs and enrolls the machine without another Bivy login. The\nbrowser connects automatically; choose a repository and send your first task\nright there.\n\nPrefer starting from the terminal?\n\n```bash\ncurl -fsSL https://bivy.sh/install.sh | bash # install + guided setup\ncd your-repo\nbivy run claude # or codex, pi, opencode\nbivy open # continue in the web app (needs remote setup)\n```\n\nBivy Cloud hosts the app, control plane, and relay—not the machines running your\nagents. Connect a Mac, Linux computer, or existing server and bring your own\nagent subscription, model API key, or local model. You can also self-host the\nentire remote-access stack.\n\n> **Bivy is 0.x software.** Claude Code, Codex, Pi, and OpenCode are the\n> release-tested paths. Credential sync, resume, handoffs, approvals, and\n> sandboxing depend on the runtime. See the\n> [runtime support matrix](docs/runtime-support-matrix.md).\n\n## Build → preview → mark up → iterate\n\nRemote chat lets you ask an agent for a change. Bivy also lets you **use what\nit built and show it what to fix**, without a separate deployment step.\n\n1. **Ask for a change.** The agent works in your repo with your existing tools\n and local services.\n2. **Try the app.** Open a live preview from the session. Use the running app,\n not just a screenshot of what the agent says it finished. Bivy can discover\n dev servers running in the session workspace; tap **Preview** to open one.\n3. **Mark up what needs work.** Point at an element or circle and draw on the\n preview. Add a note, then send the draft to the agent with the element and\n page context. Marked-up screenshots and before/after comparison are available\n when agent screenshots are enabled.\n4. **Iterate and review.** Try the next version, inspect the diff, review checks,\n and decide when the work is ready—not just when the agent stops.\n5. **Bring someone else into the review.** Copy a public preview link so they\n can use the app without a Bivy account and leave notes on specific elements.\n Bring their notes into the session when you're ready to act on them.\n\nOn Bivy Cloud, preview delivery is built in: no per-app domains, certificates,\npublic ports, or tunnels to configure. Self-hosters configure preview delivery\nonce for their Bivy deployment, not for every app. The app still needs its normal\nbuild or dev-server setup, and **the machine serving it must stay awake and\nonline**. These are development previews, not production hosting.\n\n**Share deliberately:** anyone with a preview link can use that app, including\nits live backend. Links expire after 24 hours and can be revoked sooner.\nReviewer notes aren't sent to the agent automatically; you send them or explicitly\nallow agent access. Preview traffic uses HTTPS through the preview relay, not\nsession end-to-end encryption; the relay operator can see it.\n\n[App previews, visual feedback, and sharing →](docs/apps.md)\n\n## Your agents and machines, one workspace\n\n| Capability | What it means for you |\n|---|---|\n| **One workspace, multiple agents** | Use different agents and models for different tasks without maintaining a separate workflow for each. |\n| **Your machines and environment** | Work beside your existing repos, dev servers, databases, private networks, toolchains, and GPUs. |\n| **Automations and triggers** | Let issues, failed CI, messages, schedules, and webhooks start work instead of copying requests into a chat. |\n| **Encrypted key and OAuth sync** | Reuse Bivy-managed provider credentials across enrolled machines and compatible runtimes, with less repeated setup. |\n| **Live sessions from anywhere** | Build and review from your phone, browser, or terminal; answer questions and approve supported actions in the same session. |\n| **Live app previews** | Try the running app without deploying it to a separate hosting service. |\n| **Visual feedback and sharing** | Point, draw, and send specific feedback to the agent; share a preview link for outside review. |\n| **Reviewable results** | Inspect the app, changes, declared checks, artifacts, and pull requests—not just an agent's claim that it finished. |\n| **Hosted convenience or self-hosting** | Use Bivy Cloud for managed remote access, or run the same open-source core yourself. |\n\n## One workflow, from trigger to review\n\n```text\nPrompt · GitHub issue · CI failure · Linear · Slack · Schedule · Webhook\n │\n ▼\n Choose machine + agent + model\n + supported credentials\n │\n ▼\n Live agent session\n Join · steer · approve · stop\n │\n ▼\n Try app · inspect changes · checks\n │\n ▼\n Mark up · send feedback · iterate\n │\n ▼\n Share preview · review PR\n```\n\nA **Machine** is a computer or server you connect. A **Session** is live agent\nwork on that machine. A **Run** is delegated background work that creates a\nsession and tracks its outcome. An **Automation** is a reusable definition that\ncreates runs when an event matches.\n\nManual and automated work use the same kind of live session. You can join a run\nwhen it needs help rather than wait for a black-box job to finish.\n\n### Work in the environment you already have\n\nA clean cloud sandbox isn't always enough. Your agent may need the database\nrunning on localhost, an uncommitted change, an internal API behind your VPN,\nor a model running on your GPU. Bivy runs the agent where those things already\nexist, subject to that machine's permissions and the runtime's protection.\n\nConnect several machines to the same account: a laptop for interactive work,\na Linux server for background jobs, or a GPU box for local inference. Choose\nthe machine for each session or pin it in an automation. Repository runs can\nuse isolated Git worktrees without rebuilding the whole development environment.\n\n**The execution machine must stay awake and online.** To close your laptop and\nleave work running, run the agent on a different, always-on machine.\n\n[Environment and multi-machine recipes →](docs/capability-recipes.md)\n\n### Use multiple agents, not multiple disconnected workflows\n\nRun Claude Code for one task, Codex for another, and Pi or OpenCode where they\nfit. Bivy supplies the shared session, remote-access, automation, and review\nsurfaces; your chosen agent still does the coding and uses your model provider.\n\n- Choose an agent and, where supported, a model for each session or run.\n- Import existing Claude Code and Codex sessions.\n- Fork or move work to another agent or machine when a different setup fits\n better. Continuation fidelity varies: some paths preserve native history,\n while others replay portable turns or seed the destination with context.\n- Use agent-native logins, Bivy-managed credentials, or local inference.\n Bivy's custom OpenAI-compatible endpoint registry currently feeds Pi;\n other agents may need their own provider configuration.\n- Register your own ACP or headless process agent with `bivy agent add`.\n\nBivy does not replace your agent, provide model inference, or make every agent's\nfeatures identical. Consult the [support matrix](docs/runtime-support-matrix.md)\nand [handoff recipes](docs/capability-recipes.md#fork-or-move-a-session).\n\n### Less signing in. Less copying secrets.\n\nBivy syncs **Bivy-managed API keys and supported OAuth credentials** across\nenrolled machines for compatible runtimes. Connect supported credentials once\nand reuse them where you run work, rather than manually distributing keys to\neach machine.\n\nFor ordinary account sync, credentials are encrypted on the node before upload.\nThe control plane stores ciphertext and wrapped-key metadata; enrolled nodes\nshare access by wrapping the vault key to one another. Bivy Cloud does not\nreceive plaintext credentials through this sync path.\n\nYou can also keep credentials local, use labeled keys and project presets, or\nreference environment variables and 1Password instead of embedding secrets in\nconfiguration:\n\n```bash\nbivy provider login\nbivy credentials add anthropic work\nbivy secrets ref github.repo-token op://Bivy/GitHub/repo-token\n```\n\n**Not every CLI login syncs.** Native agent logins may still be per-machine;\nGitHub App private-key sync is separately opt-in. If you lose every node and\ndevice able to unwrap a vault, you must sign in to providers again. Explicit\nhosted-provisioning custody grants are separate from ordinary encrypted sync.\n\n[Credential sync and runtime coverage →](docs/credential-sync.md) ·\n[Credentials guide →](docs/credentials-guide.md) ·\n[Key storage →](docs/key-management.md)\n\n### Let events start the work\n\nAutomations turn recurring or incoming work into sessions you can join,\nsupervise, and review. Choose the repository, machine, agent, model, approval\nmode, sandbox setting, and maximum attempts.\n\n| Trigger | Example workflow |\n|---|---|\n| **GitHub issues and mentions** | Label an issue `bivy` or `bivy/<machine>`, or mention your Bivy GitHub App, to work toward a pull request. |\n| **Failed CI** | Match a failed workflow, ask the agent to reproduce it, make a fix, and run the affected checks. |\n| **Linear** | Label an issue to start work without copying its description into an agent. |\n| **Slack** | Send a request from the conversation where the work came up. |\n| **Schedules** | Run a weekly dependency review, recurring maintenance, or a one-time task. |\n| **Signed webhooks** | Connect alerts, internal tools, or your own event sources. |\n\nConfigure automations in the app or version them with your repository in\n`.bivy/automations.yaml`:\n\n```bash\nbivy automation init\n# Edit the generated definition for your repository and workflow.\nbivy automation validate\nbivy automation test --event .bivy/events/failed-ci.yaml # supply a local event fixture\nbivy automation apply\n```\n\nOr delegate a one-off job without creating an automation:\n\n```bash\nbivy runs start \"Review outdated dependencies and propose a small, tested update.\"\nbivy runs wait <id>\n```\n\nRuns keep routing and lifecycle evidence, check results, and output references\nin a reviewable Receipt. For unattended issue work, Bivy runs declared repository\nchecks after the agent's turn; failed required checks fail the run even if the\nagent reports success. A completed process alone is not proof that the task\nsucceeded.\n\n[Automation recipes →](docs/capability-recipes.md#let-events-start-runs) ·\n[Automations as code →](docs/automations-as-code.md) ·\n[Run outcomes and reliability limits →](docs/automation-runs.md)\n\n### Start at your desk. Continue anywhere.\n\nOpen the same session in the browser, phone PWA, or terminal. Watch work live,\nanswer questions, approve supported tool calls, or stop the agent.\n\n- Send screenshots, images, logs, and other files from your phone.\n- Download reports and artifacts the agent creates.\n- Open [session apps](docs/apps.md) from chat or the **Apps** menu: live web\n previews, desktop app views, and CLI/TUI tools. Agents can publish views with\n `bivy app publish bivy.app.json` and create share links with `bivy app share`.\n- Use voice input and read-aloud where supported; provider-backed voice may\n send audio or text to the selected provider.\n- Keep a native terminal workflow or use structured chat, depending on the agent.\n\n```bash\nbivy run claude --no-follow # start without attaching\nbivy open # open the web app\nbivy resume # return to the session in your terminal\nbivy link # pair a device directly via QR\n```\n\nNo phone app installation is required. Open [app.bivy.sh](https://app.bivy.sh)\nin your browser; adding it to your home screen is optional.\n\n[Remote access →](docs/remote-access.md) ·\n[Voice, files, and terminal recipes →](docs/capability-recipes.md)\n\n## Get started\n\n### Install\n\nBivy supports **macOS and Linux with Node.js 20+**. The installer installs the\n`@bivy/bivy` package, runs guided setup, and starts a launchd or systemd service:\n\n```bash\ncurl -fsSL https://bivy.sh/install.sh | bash\n```\n\nSetup helps you choose an agent and configure remote access. Existing agents\nkeep their command, login, and configuration. The installer may use `sudo` to\ninstall a missing Node.js, but never for `npm install`. To inspect it first,\ndownload it with `curl -fsSL https://bivy.sh/install.sh -o install.sh`.\n\nAlready have Node.js and want to avoid sudo?\n\n```bash\nnpm install -g @bivy/bivy\nbivy setup\n```\n\nThen try one small task:\n\n```bash\ncd your-repo\nbivy run claude\n# Ask: \"Explain this repo and make one small, safe improvement. Run the relevant checks.\"\nbivy open\n```\n\nOpen that same session on your phone while it runs. For a web app, ask the agent\nto start its dev server, then open **Apps → Running in this workspace → Preview**.\nTry a page, point to something you'd change, and send the feedback back to the\nagent. No separate preview deployment needed.\n\nOnce that works, share a preview for review, connect another machine, or add\nyour first automation.\n\n**Local-only works too.** `bivy run`, `bivy resume`, and `bivy sessions` need no\naccount or server. Choose **local only for now** during setup; use `bivy login`\nlater. Browser and phone access need a hosted or self-hosted control plane;\nthe node itself does not serve a web UI.\n\n[Full quickstart →](docs/quickstart.md) ·\n[Installer options, service management, and uninstall →](docs/install.md)\n\n### Choose hosted or self-hosted\n\n| Option | What you get |\n|---|---|\n| **Free Cloud — $0** | Every launch feature, including automations; 10 new remote sessions per rolling seven days. No credit card required. |\n| **Cloud — $15/month** | The same features with unlimited remote sessions. |\n| **Self-hosted Core** | Operate the app, control plane, and relay yourself, with no Bivy usage limits. |\n\nManual and automated sessions share the Cloud allowance. Resuming existing\nsessions and viewing history do not consume it. Agent subscriptions and model\nprovider charges are separate. See [current pricing](https://bivy.sh#pricing).\n\n**Self-host anywhere:** deploy the public control-plane (including the web app)\nand relay images with Postgres and [a small set of environment variables](docs/deploy-images.md).\nYour server or container platform handles HTTPS. Set up owner access in the\nbrowser—no SSH, external authentication provider, or Bivy Cloud account required.\nFor a bare VPS, the [Compose installer](docs/self-host-quickstart.md) automates the\nsame stack. These onboarding features require a release containing them.\n\nStart on Cloud and self-host later if you prefer. Deploy the stack, reconnect\nmachines with `bivy relay:setup`, and pair devices to your server. This is not a\none-click migration of your Cloud account; your local repos and agent\nconfiguration stay in place.\n\n```bash\nbivy relay:setup \\\n --control-plane https://bivy.example.com \\\n --relay wss://relay.example.com\n```\n\nSelf-hosting is community-supported: you own TLS, backups, upgrades, and\nhardening. Public multi-architecture images are available as\n`ghcr.io/bivysh/bivy-control-plane` and `ghcr.io/bivysh/bivy-relay`; pin a release\nversion or full commit SHA.\n\n[Deploy the images anywhere →](docs/deploy-images.md) ·\n[Optional VPS installer →](docs/self-host-quickstart.md) ·\n[Operations reference →](docs/self-host.md)\n\n## Architecture\n\nYour environment, with clear security boundaries:\n\n```text\nYour machine Hosted or self-hosted\n┌──────────────────────┐ ┌──────────────────────┐\n│ Node daemon │──outbound──▶│ Relay │\n│ Agents, repos, tools │ │ Encrypted frames │\n│ Local credentials │ └──────────┬───────────┘\n└──────────────────────┘ │\n Browser / phone\n + control plane\n (app, accounts, metadata)\n```\n\n- **Execution stays on your machine.** Bivy Cloud does not run your agents.\n Your model provider still sees whatever the agent sends it.\n- **Interactive session traffic is end-to-end encrypted** between the node and\n paired devices. The relay forwards opaque session frames; your node dials out,\n so no inbound public port is required.\n- **App previews have a separate security boundary.** Preview traffic uses HTTPS\n and an outbound tunnel, not session E2E encryption. The preview ingress/relay\n operator can see it. Public preview links grant anyone holding them access to\n that view and its live backend until expiry or revocation. See\n [preview security and sharing](docs/apps.md#runtime-and-security-boundaries).\n- **Ordinary credential sync uploads ciphertext, not plaintext keys.**\n Supported credentials and recovery limits are documented separately.\n- **Encryption is not universal across integrations.** Slack commands and\n generic webhook instructions reach the control plane in plaintext. Do not\n put secrets in them. Routing and bounded run metadata are also visible there.\n- **Device authorization matters.** QR pairing authorizes a device directly\n through the node. Hosted account pairing trusts the control plane to authorize\n devices and serve the web app that holds client keys.\n- **Bivy is not an OS-level sandbox.** The default approval mode is\n `autonomous`; protection depends on the runtime. Some agents enforce sandbox\n tiers, while process agents may run with your full user permissions.\n Heuristic tool checks help prevent accidents but are not isolation.\n\nReview the runtime's Protection label and configure approval/sandbox settings\nfor the task, especially before enabling unattended work.\n\n[Security model and known limitations →](docs/security-model.md) ·\n[Runtime protection matrix →](docs/runtime-support-matrix.md) ·\n[Configuration →](docs/configuration.md)\n\n## Agents and everyday commands\n\n**Claude Code, Codex, Pi, and OpenCode are release-tested.** Additional adapters\ninclude Gemini CLI, Qwen Code, Goose, Aider, Cline, Crush, Cursor, GitHub Copilot,\nGrok, Amp, Auggie, Droid, Continue, Kilo Code, and Rovo Dev. Installation,\nresume, model selection, and tool protection vary—see the\n[support matrix](docs/runtime-support-matrix.md) and [agent guides](docs/agents/README.md).\n\nRun an arbitrary command with `bivy run -- ./your-agent --flags`, register a\nreusable entry with `bivy agent add`, or package a declarative integration with\nexperimental [plugins](docs/plugins.md).\n\n```bash\nbivy run claude # launch a durable session; also codex, pi, opencode\nbivy sessions # list live and saved sessions\nbivy resume # resume the most recent session\nbivy open # open the web app (requires remote setup)\nbivy nodes # list connected account machines\nbivy runs list # inspect delegated work\nbivy automation init # scaffold repo-owned automations\nbivy provider login # connect supported model credentials\nbivy agent add # register an ACP or process agent\nbivy doctor # check installation and connectivity\nbivy logs -f # follow node logs\nbivy update # update and restart the service\n```\n\n`bivy update` uses your original installation method and waits for an active\nturn to finish before restarting. Use `--force` to skip that wait.\n\n[CLI reference →](docs/cli-reference.md) ·\n[Node and project configuration →](docs/config-as-code.md) ·\n[GitHub setup →](docs/github-setup.md) ·\n[Linear setup →](docs/linear-work-queue.md)\n\n## Development and contributions\n\n```bash\npnpm install\npnpm run dev # node daemon on http://localhost:4317\npnpm run dev:web # web client dev server\n```\n\n| Directory | Contents |\n|---|---|\n| `src/`, `bin/` | Node daemon, CLI, runtime adapters, sessions, approvals, secrets |\n| `packages/core/` | Shared protocol, pairing, and wire format |\n| `packages/web/`, `packages/ui/` | React PWA and shared design system |\n| `services/relay/` | Self-hostable encrypted relay |\n| `services/control-plane/` | Self-hostable control plane |\n| `deploy/` | Deployment examples |\n\n```bash\npnpm run typecheck\npnpm run typecheck:web\npnpm run lint\npnpm run test:unit\npnpm run test:core\npnpm run check:licenses\npnpm run check:secrets\n```\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow. Releases\nare published from CI with provenance attestations; see\n[release verification](docs/releasing.md).\n\n**Found a security issue?** Use\n[GitHub private vulnerability reporting](https://github.com/bivysh/bivy/security/advisories/new),\nnot a public issue. See [SECURITY.md](SECURITY.md).\n\n### In development—not available at launch\n\nAutomatically provisioned, short-lived **ephemeral machines** are in development\nfor hosted and bring-your-own-cloud deployments. Neither path is ready or\nsupported for this launch. Use an existing computer or server you operate.\nExperimental provisioning has different credential-custody and encryption\nboundaries; see the [provisioning trust model](docs/hosted-provisioning-trust-model.md).\n\n## License\n\nEverything in this repository—node, CLI, web/PWA, relay, and control plane—is\nfree and open-source **AGPL-3.0-only Core**, with no Bivy usage limits. You may\nuse, modify, and self-host it under that license. If users interact with your\nmodified version over a network, section 13 requires you to offer its\ncorresponding source. See [LICENSE](LICENSE).\n\nBivy Cloud is the hosted operation of that stack plus billing and plans, in a\nseparate private repository. Contributions use the\n[DCO](CONTRIBUTING.md#certificate-of-origin); there is no CLA.\n",
69
69
  "readmeFilename": "README.md",
70
70
  "agentBridges": {
71
- "@anthropic-ai/claude-agent-sdk": "0.3.281",
71
+ "@anthropic-ai/claude-agent-sdk": "0.3.284",
72
72
  "@earendil-works/pi-ai": "0.87.1",
73
73
  "@earendil-works/pi-coding-agent": "0.87.1"
74
74
  }