@bivy/bivy 0.18.11-staging.2 → 0.18.11-staging.4
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 +111 -12
- package/bin/bivy.mjs +17 -5
- package/dist/apps/gateway.js +53 -17
- package/dist/apps/preview-shell.js +2 -0
- package/dist/apps/remote-preview.js +4 -2
- package/dist/apps/service.js +18 -3
- package/dist/apps/styles.css +3 -0
- package/dist/apps/types.js +8 -0
- package/dist/controllers/app-commands.js +7 -3
- package/dist/controllers/fork-commands.js +20 -20
- package/dist/exec.js +41 -4
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +4 -1
- package/dist/runtime/model-ref.js +33 -0
- package/dist/runtime/protocol.js +17 -4
- package/dist/server.js +37 -1
- package/dist/session/event-log.js +6 -2
- package/dist/session/transcript-merge.js +29 -1
- package/dist/session/transcript-persistence.js +4 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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 } });
|
package/bin/bivy.mjs
CHANGED
|
@@ -2472,7 +2472,7 @@ async function cmdApp(args = []) {
|
|
|
2472
2472
|
bivy app remove <app-id> [--session <id>]
|
|
2473
2473
|
bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
|
|
2474
2474
|
bivy app present [view] [--path /page] [--note "…"] [--session <id>]
|
|
2475
|
-
bivy app share [app-id] [--view <view>] [--session <id>]
|
|
2475
|
+
bivy app share [app-id] [--view <view>] [--for 1h|1d|7d] [--view-only] [--session <id>]
|
|
2476
2476
|
bivy app notes [app-id] [--view <view>] [--since <time>] [--session <id>]
|
|
2477
2477
|
bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
|
|
2478
2478
|
bivy app click <x> <y> [--right|--middle] [--double] [--app <app>]
|
|
@@ -2518,9 +2518,10 @@ the chat with the app at phone width (and before/after), and it opens the live
|
|
|
2518
2518
|
preview. Use it when you finish a visible change, or when the user asks to see
|
|
2519
2519
|
it. [view] is an app or view name or ID (default: the one opened last).
|
|
2520
2520
|
"share" mints a reusable link to one web view and prints its URL and expiry
|
|
2521
|
-
(
|
|
2522
|
-
restarts). [app-id] is an app ID or name, --view
|
|
2523
|
-
|
|
2521
|
+
(--for: 1h, 1d or 7d, default 1d; sooner if the user stops sharing in Apps, the
|
|
2522
|
+
app is removed or the machine restarts). [app-id] is an app ID or name, --view
|
|
2523
|
+
a view ID or name (default: the one opened last). --view-only leaves out the
|
|
2524
|
+
reviewer tools (Point, Draw, notes): people see just the app. Anyone who has the link can use the app, including a live
|
|
2524
2525
|
server's backend, until it expires or is revoked: only post it where everyone
|
|
2525
2526
|
who can read it may use the app.
|
|
2526
2527
|
"notes" prints the notes people left through share links (Leave a note), as
|
|
@@ -2557,6 +2558,17 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2557
2558
|
rest.splice(i, 2);
|
|
2558
2559
|
}
|
|
2559
2560
|
}
|
|
2561
|
+
if (action === "share") {
|
|
2562
|
+
const i = rest.indexOf("--for");
|
|
2563
|
+
if (i >= 0) {
|
|
2564
|
+
const value = rest[i + 1];
|
|
2565
|
+
if (!["1h", "1d", "7d"].includes(value)) throw new Error("--for takes 1h, 1d or 7d.");
|
|
2566
|
+
shot.duration = value;
|
|
2567
|
+
rest.splice(i, 2);
|
|
2568
|
+
}
|
|
2569
|
+
const viewOnly = rest.indexOf("--view-only");
|
|
2570
|
+
if (viewOnly >= 0) { shot.controls = false; rest.splice(viewOnly, 1); }
|
|
2571
|
+
}
|
|
2560
2572
|
if (action === "notes") {
|
|
2561
2573
|
const i = rest.indexOf("--since");
|
|
2562
2574
|
if (i >= 0) {
|
|
@@ -2656,7 +2668,7 @@ async function appRequest(action, body, { print = true } = {}) {
|
|
|
2656
2668
|
if (action === "share") {
|
|
2657
2669
|
const { type: _type, requestId: _requestId, ...link } = result;
|
|
2658
2670
|
console.log(JSON.stringify({ ...link, expires: new Date(link.expiresAt).toISOString() }, null, 2));
|
|
2659
|
-
console.error("Anyone with this link can use the app, including its live backend, until it expires or
|
|
2671
|
+
console.error("Anyone with this link can use the app, including its live backend, until it expires or the user stops sharing (Apps → Share → Stop sharing).");
|
|
2660
2672
|
return;
|
|
2661
2673
|
}
|
|
2662
2674
|
if (print) console.log(JSON.stringify(result, null, 2));
|
package/dist/apps/gateway.js
CHANGED
|
@@ -132,6 +132,8 @@ const poll=async()=>{try{const r=await fetch(location.href,{method:'HEAD',cache:
|
|
|
132
132
|
}
|
|
133
133
|
/** Copied links are reusable until revoked, capped so a forgotten one lapses. */
|
|
134
134
|
export const SHARE_TTL = 24 * HOUR;
|
|
135
|
+
/** The longest a copied link may be asked to last. */
|
|
136
|
+
export const SHARE_TTL_MAX = 7 * 24 * HOUR;
|
|
135
137
|
/** Bivy's packaged apps load the client from their own scheme, so a preview
|
|
136
138
|
* framed there has this ancestor instead of a web origin. (Node's URL gives
|
|
137
139
|
* these "null" origins, so they cannot be configured as return origins.) */
|
|
@@ -204,9 +206,13 @@ export class AppGateway {
|
|
|
204
206
|
["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
|
|
205
207
|
]);
|
|
206
208
|
/** `reviewer`: the browser session came from a copied (shared) link.
|
|
209
|
+
* `viewOnly`: that link came without the reviewer tools.
|
|
207
210
|
* `embedded`: it runs framed inside a Bivy client (a third-party frame). */
|
|
208
211
|
sessions = new Map();
|
|
209
212
|
sockets = new Map();
|
|
213
|
+
/** Connections opened by people with a copied link, so Stop sharing can end
|
|
214
|
+
* theirs and leave the owner's. */
|
|
215
|
+
shared = new WeakSet();
|
|
210
216
|
captureAfter = 0;
|
|
211
217
|
capturing = false;
|
|
212
218
|
displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
|
|
@@ -255,11 +261,33 @@ export class AppGateway {
|
|
|
255
261
|
return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
|
|
256
262
|
}
|
|
257
263
|
/** Public reviewers use the same isolated controls as the owner, but can
|
|
258
|
-
* only submit notes
|
|
259
|
-
|
|
264
|
+
* only submit notes; without `controls` they only see the app. Valid until
|
|
265
|
+
* revoked, sharing stops, or `ttl` (SHARE_TTL by default). */
|
|
266
|
+
share(id, { ttl = SHARE_TTL, controls = true } = {}) {
|
|
260
267
|
this.requireWeb(id);
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > SHARE_TTL_MAX)
|
|
269
|
+
throw new Error("A share link can last at most 7 days.");
|
|
270
|
+
const expiresAt = Date.now() + ttl;
|
|
271
|
+
return { url: `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true, ...(controls ? {} : { viewOnly: true }) })}`, expiresAt, controls };
|
|
272
|
+
}
|
|
273
|
+
/** Copied links that still work: how many, and when the last one lapses. */
|
|
274
|
+
sharing(id) {
|
|
275
|
+
this.sweep();
|
|
276
|
+
const links = [...this.tickets.values()].filter((grant) => grant.appId === id && grant.reusable);
|
|
277
|
+
return links.length ? { links: links.length, expiresAt: Math.max(...links.map((grant) => grant.expires)) } : undefined;
|
|
278
|
+
}
|
|
279
|
+
/** Ends every copied link and the browser sessions and connections opened
|
|
280
|
+
* from them. The owner's own previews keep working. */
|
|
281
|
+
unshare(id) {
|
|
282
|
+
for (const [key, grant] of this.tickets)
|
|
283
|
+
if (grant.appId === id && grant.reusable)
|
|
284
|
+
this.tickets.delete(key);
|
|
285
|
+
for (const [key, session] of this.sessions)
|
|
286
|
+
if (session.appId === id && session.reviewer)
|
|
287
|
+
this.sessions.delete(key);
|
|
288
|
+
for (const socket of this.sockets.get(id) ?? [])
|
|
289
|
+
if (this.shared.has(socket))
|
|
290
|
+
socket.destroy();
|
|
263
291
|
}
|
|
264
292
|
revoke(id) {
|
|
265
293
|
for (const map of [this.tickets, this.sessions])
|
|
@@ -313,13 +341,15 @@ export class AppGateway {
|
|
|
313
341
|
const session = token ? this.sessions.get(token) : undefined;
|
|
314
342
|
return session?.appId === id ? session : undefined;
|
|
315
343
|
}
|
|
316
|
-
|
|
317
|
-
|
|
344
|
+
track(id, socket, session) {
|
|
345
|
+
const expires = session.expires;
|
|
318
346
|
let sockets = this.sockets.get(id);
|
|
319
347
|
if (!sockets) {
|
|
320
348
|
sockets = new Set();
|
|
321
349
|
this.sockets.set(id, sockets);
|
|
322
350
|
}
|
|
351
|
+
if (session.reviewer)
|
|
352
|
+
this.shared.add(socket);
|
|
323
353
|
if (sockets.has(socket))
|
|
324
354
|
return;
|
|
325
355
|
sockets.add(socket);
|
|
@@ -369,7 +399,7 @@ export class AppGateway {
|
|
|
369
399
|
res.setHeader("Content-Type", "application/json");
|
|
370
400
|
// Someone opening a shared link sees "Made with Bivy" under the app; the owner doesn't.
|
|
371
401
|
const badge = grant.reusable && this.badge ? await this.badge().catch(() => true) : false;
|
|
372
|
-
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(badge ? { badge: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
|
|
402
|
+
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(grant.viewOnly ? { controls: false } : {}), ...(badge ? { badge: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
|
|
373
403
|
return;
|
|
374
404
|
}
|
|
375
405
|
res.writeHead(404);
|
|
@@ -433,7 +463,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
433
463
|
// A browser session never outlives the link that created it.
|
|
434
464
|
const expires = Math.min(Date.now() + HOUR, grant.expires);
|
|
435
465
|
const token = randomBytes(32).toString("hex");
|
|
436
|
-
this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true, ...(embedded ? { embedded } : {}) });
|
|
466
|
+
this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true, ...(grant.viewOnly ? { viewOnly: true } : {}), ...(embedded ? { embedded } : {}) });
|
|
437
467
|
// Framed inside Bivy, the cookie is third-party: it must be SameSite=None,
|
|
438
468
|
// and Partitioned keys it to Bivy's top-level site so no other site can use it.
|
|
439
469
|
const scope = embedded ? "SameSite=None; Partitioned" : "SameSite=Lax";
|
|
@@ -442,16 +472,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
442
472
|
res.end();
|
|
443
473
|
return;
|
|
444
474
|
}
|
|
445
|
-
const session = this.session(req, id)
|
|
475
|
+
const session = this.session(req, id);
|
|
446
476
|
// A home-screen visit without access goes through Bivy to sign in, then
|
|
447
477
|
// comes back to the same page. Framed loads keep the plain message.
|
|
448
|
-
const signIn = !
|
|
478
|
+
const signIn = !session && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
|
|
449
479
|
if (signIn) {
|
|
450
480
|
res.writeHead(303, { location: signIn });
|
|
451
481
|
res.end();
|
|
452
482
|
return;
|
|
453
483
|
}
|
|
454
|
-
if (!
|
|
484
|
+
if (!session) {
|
|
455
485
|
res.writeHead(401);
|
|
456
486
|
res.end("Preview access expired. Open a new preview link from Bivy.");
|
|
457
487
|
return;
|
|
@@ -472,7 +502,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
472
502
|
res.end();
|
|
473
503
|
return;
|
|
474
504
|
}
|
|
475
|
-
this.track(id, req.socket,
|
|
505
|
+
this.track(id, req.socket, session);
|
|
476
506
|
if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
|
|
477
507
|
this.revision(req, res, entry);
|
|
478
508
|
return;
|
|
@@ -483,10 +513,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
483
513
|
}
|
|
484
514
|
if (req.url === INSPECTOR_PATH && req.method === "GET") {
|
|
485
515
|
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
|
|
486
|
-
res.end(inspectorScript(this.shellOrigin(id), session
|
|
516
|
+
res.end(inspectorScript(this.shellOrigin(id), session.reviewer === true && !session.viewOnly, session.embedded === true));
|
|
487
517
|
return;
|
|
488
518
|
}
|
|
489
519
|
if (req.url === NOTES_PATH && req.method === "POST") {
|
|
520
|
+
// A view-only link shows the app and nothing else.
|
|
521
|
+
if (session.viewOnly) {
|
|
522
|
+
res.writeHead(403);
|
|
523
|
+
res.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
490
526
|
await this.note(req, res, entry);
|
|
491
527
|
return;
|
|
492
528
|
}
|
|
@@ -880,13 +916,13 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
880
916
|
}
|
|
881
917
|
upgrade(req, socket, head) {
|
|
882
918
|
const entry = this.entry(req);
|
|
883
|
-
const
|
|
919
|
+
const session = entry && this.session(req, entry.view.id);
|
|
884
920
|
const display = entry?.target.kind === "display";
|
|
885
|
-
if (!entry || !
|
|
921
|
+
if (!entry || !session || (entry.target.kind !== "service" && !(display && req.url === DISPLAY_SOCKET_PATH)) || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
|
|
886
922
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
887
923
|
return;
|
|
888
924
|
}
|
|
889
|
-
this.track(entry.view.id, socket,
|
|
925
|
+
this.track(entry.view.id, socket, session);
|
|
890
926
|
if (display) {
|
|
891
927
|
this.displaySocket(req, socket, head, entry);
|
|
892
928
|
return;
|
|
@@ -898,7 +934,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
898
934
|
upstream.setTimeout(10_000, () => upstream.destroy());
|
|
899
935
|
upstream.on("upgrade", (response, peer, upstreamHead) => {
|
|
900
936
|
peer.setTimeout(0);
|
|
901
|
-
this.track(entry.view.id, peer,
|
|
937
|
+
this.track(entry.view.id, peer, session);
|
|
902
938
|
const headers = cleanHeaders(response.headers);
|
|
903
939
|
delete headers["set-cookie"];
|
|
904
940
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n${Object.entries(headers).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}: ${value}\r\n`).join("")}\r\n`);
|
|
@@ -162,6 +162,8 @@ function show(data,launch){
|
|
|
162
162
|
$('draft-text').maxLength=1000;
|
|
163
163
|
$('send-errors').textContent='Leave a note…';
|
|
164
164
|
}
|
|
165
|
+
// A view-only share link shows the app without Bivy's tools.
|
|
166
|
+
$('dock').hidden=data.controls===false;
|
|
165
167
|
$('made-with').hidden=!data.badge;
|
|
166
168
|
document.body.classList.toggle('badged',Boolean(data.badge));
|
|
167
169
|
$('title').textContent=data.name;
|
|
@@ -65,11 +65,13 @@ export class RemotePreview {
|
|
|
65
65
|
throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
|
|
66
66
|
return this.gateway.open(id, returnTo);
|
|
67
67
|
}
|
|
68
|
-
share(id) {
|
|
68
|
+
share(id, options) {
|
|
69
69
|
if (!this.available)
|
|
70
70
|
throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
|
|
71
|
-
return this.gateway.share(id);
|
|
71
|
+
return this.gateway.share(id, options);
|
|
72
72
|
}
|
|
73
|
+
sharing(id) { return this.gateway?.sharing(id); }
|
|
74
|
+
unshare(id) { this.gateway?.unshare(id); }
|
|
73
75
|
revoke(id) { this.gateway?.revoke(id); }
|
|
74
76
|
address(id) { return this.available ? this.gateway.address(id) : undefined; }
|
|
75
77
|
openDirect(id) {
|
package/dist/apps/service.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { SHARE_DURATIONS } from "./types.js";
|
|
1
4
|
import { randomBytes } from "node:crypto";
|
|
2
5
|
import { REVIEW_MODES, shouldReview, visualChange } from "./review.js";
|
|
3
6
|
import fs from "node:fs";
|
|
@@ -73,6 +76,9 @@ export class AppService {
|
|
|
73
76
|
view.stats = structuredClone(entry.stats);
|
|
74
77
|
if (entry?.lastPath)
|
|
75
78
|
view.lastPath = entry.lastPath;
|
|
79
|
+
const sharing = this.gateway?.sharing?.(view.id);
|
|
80
|
+
if (sharing)
|
|
81
|
+
view.sharing = sharing;
|
|
76
82
|
}
|
|
77
83
|
return { apps, previewAvailable: available };
|
|
78
84
|
}
|
|
@@ -577,18 +583,21 @@ export class AppService {
|
|
|
577
583
|
void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
|
|
578
584
|
this.servers.delete(id);
|
|
579
585
|
}
|
|
580
|
-
share(sessionId, appId, viewId) {
|
|
586
|
+
share(sessionId, appId, viewId, options = {}) {
|
|
581
587
|
if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
|
|
582
588
|
throw new Error("Only web views have preview links.");
|
|
583
589
|
if (!this.gateway)
|
|
584
590
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
585
|
-
|
|
591
|
+
const duration = options.duration === undefined ? undefined : SHARE_DURATIONS.find((row) => row.id === options.duration);
|
|
592
|
+
if (options.duration !== undefined && !duration)
|
|
593
|
+
throw new Error(`A share link lasts ${SHARE_DURATIONS.map((row) => row.id).join(", ")}.`);
|
|
594
|
+
return this.gateway.share(viewId, { ...(duration ? { ttl: duration.ms } : {}), controls: options.controls !== false });
|
|
586
595
|
}
|
|
587
596
|
/** `bivy app share`: mints a share link for a web view picked by app and/or
|
|
588
597
|
* view ID or name, like `present` (default: the one opened last). */
|
|
589
598
|
shareView(sessionId, input) {
|
|
590
599
|
const entry = this.pickTarget(sessionId, input);
|
|
591
|
-
return { ...this.share(sessionId, entry.app.id, entry.view.id), appId: entry.app.id, viewId: entry.view.id, app: entry.app.name, view: entry.view.name };
|
|
600
|
+
return { ...this.share(sessionId, entry.app.id, entry.view.id, input), appId: entry.app.id, viewId: entry.view.id, app: entry.app.name, view: entry.view.name };
|
|
592
601
|
}
|
|
593
602
|
clearNotes(sessionId, appId, viewId) {
|
|
594
603
|
this.registry.requireView(sessionId, appId, viewId);
|
|
@@ -623,6 +632,12 @@ export class AppService {
|
|
|
623
632
|
throw new Error(`No web view called "${input.view}"${input.app ? ` in "${input.app}"` : ""}. Run bivy app list to see them.`);
|
|
624
633
|
return found;
|
|
625
634
|
}
|
|
635
|
+
/** Ends a view's share links and what people opened with them; the owner's previews and the app stay. */
|
|
636
|
+
unshare(sessionId, appId, viewId) {
|
|
637
|
+
this.registry.requireView(sessionId, appId, viewId);
|
|
638
|
+
this.gateway?.unshare?.(viewId);
|
|
639
|
+
return { ok: true };
|
|
640
|
+
}
|
|
626
641
|
/** Ends every link, browser session and open connection for one view; the app stays. */
|
|
627
642
|
revoke(sessionId, appId, viewId) {
|
|
628
643
|
this.registry.requireView(sessionId, appId, viewId);
|
package/dist/apps/styles.css
CHANGED
|
@@ -3147,6 +3147,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3147
3147
|
.apps-empty strong { color: var(--ink); font-size: var(--text-base); }
|
|
3148
3148
|
.apps-empty p { margin: 0; max-inline-size: 34ch; }
|
|
3149
3149
|
.apps-foot { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
|
|
3150
|
+
/* Share sheet (AppAccess.tsx): the sharing status sits in the sheet, not at page edge. */
|
|
3151
|
+
.apps-sheet > .banner { border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); }
|
|
3152
|
+
.apps-sheet > .settings-toggle-row { padding-inline: var(--space-1); }
|
|
3150
3153
|
/* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
|
|
3151
3154
|
.preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
|
|
3152
3155
|
/* Share-sheet landing (ShareDestinationSheet.tsx): what was shared, then one
|
package/dist/apps/types.js
CHANGED
|
@@ -24,3 +24,11 @@ export function isAppReference(value) {
|
|
|
24
24
|
const ref = value;
|
|
25
25
|
return typeof ref.appId === "string" && /^[a-f0-9]{32}$/.test(ref.appId) && typeof ref.sessionId === "string" && ref.sessionId.length > 0 && typeof ref.name === "string" && ref.name.length <= 100;
|
|
26
26
|
}
|
|
27
|
+
/** How long a new share link works. Links live in the machine's memory, so a
|
|
28
|
+
* restart ends them sooner. Adding a choice means adding a row. */
|
|
29
|
+
export const SHARE_DURATIONS = [
|
|
30
|
+
{ id: "1h", label: "1 hour", ms: 3_600_000 },
|
|
31
|
+
{ id: "1d", label: "1 day", ms: 86_400_000 },
|
|
32
|
+
{ id: "7d", label: "7 days", ms: 7 * 86_400_000 },
|
|
33
|
+
];
|
|
34
|
+
export const DEFAULT_SHARE_DURATION = "1d";
|
|
@@ -34,9 +34,13 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
34
34
|
"apps.notes": (msg) => service.notes(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, since: typeof msg.since === "number" ? msg.since : undefined }),
|
|
35
35
|
"apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
36
36
|
// The app UI passes exact IDs; `bivy app share` passes an app and/or view by ID or name.
|
|
37
|
-
"apps.share": (msg) =>
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
"apps.share": (msg) => {
|
|
38
|
+
const options = { duration: msg.duration, controls: msg.controls !== false };
|
|
39
|
+
return typeof msg.viewId === "string" && typeof msg.appId === "string"
|
|
40
|
+
? service.share(String(msg.sessionId), msg.appId, msg.viewId, options)
|
|
41
|
+
: service.shareView(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, ...options });
|
|
42
|
+
},
|
|
43
|
+
"apps.unshare": (msg) => service.unshare(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
40
44
|
"apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
41
45
|
"apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
|
|
42
46
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
+
ctx.reply({ type: "session.fork.retired", requestId, sourceSessionId, newSessionId, alreadyGone: outcome.alreadyGone });
|
|
31
31
|
}
|
|
32
32
|
catch (error) {
|
|
33
|
-
|
|
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
|
-
|
|
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:
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
ctx.reply({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
ctx.reply(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
|
|
119
119
|
}
|
|
120
120
|
catch (error) {
|
|
121
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
165
|
+
ctx.reply({ type: "session.fork.error", requestId, error: outcome.error, missing: outcome.missing });
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
|
-
|
|
168
|
+
ctx.reply(deps.forkDoneEvent(requestId, outcome.record, outcome.plan, outcome.missing));
|
|
169
169
|
}
|
|
170
170
|
catch (error) {
|
|
171
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Direct-mode adapters for operations whose canonical implementation is the
|
|
4
4
|
* client command registry. Relay and HTTP now differ only in framing. */
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
|
-
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
9
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
+
import { SHARE_DURATIONS } from "../apps/types.js";
|
|
4
5
|
const request = { requestId: Type.Optional(Type.String()) };
|
|
5
6
|
const session = { sessionId: Type.String() };
|
|
6
7
|
const optionalSession = { sessionId: Type.Optional(Type.String()) };
|
|
@@ -31,7 +32,9 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
31
32
|
"apps.agentNotes": Type.Object({ ...request, ...session, appId: Type.String(), enabled: Type.Boolean() }),
|
|
32
33
|
"apps.notes": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), view: Type.Optional(Type.String({ maxLength: 200 })), since: Type.Optional(Type.Number()) }),
|
|
33
34
|
"apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
34
|
-
"apps.share": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String()), view: Type.Optional(Type.String({ maxLength: 200 }))
|
|
35
|
+
"apps.share": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String()), view: Type.Optional(Type.String({ maxLength: 200 })),
|
|
36
|
+
duration: Type.Optional(Type.Union(SHARE_DURATIONS.map((row) => Type.Literal(row.id)))), controls: Type.Optional(Type.Boolean()) }),
|
|
37
|
+
"apps.unshare": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
35
38
|
"apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
36
39
|
"apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
|
|
37
40
|
"credentials.native.preview": Type.Object({ ...request, label: Type.Optional(Type.String({ maxLength: 100 })) }),
|
|
@@ -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
|
+
}
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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