@bivy/bivy 0.18.11-staging.1 → 0.18.11-staging.11
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 +142 -16
- package/bin/agent-manifest.json +2 -2
- package/bin/bivy.mjs +183 -27
- package/bin/prune-sessions.mjs +38 -0
- package/dist/agent-instructions.js +4 -0
- package/dist/agents/claude-code/integration.js +1 -1
- package/dist/agents/claude-code/runtime.js +25 -2
- package/dist/agents/codex/integration.js +1 -1
- package/dist/agents/profiles.js +3 -3
- 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 +29 -1
- package/dist/apps/types.js +8 -0
- package/dist/certification/generated.js +4 -4
- 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/run-tools.js +8 -0
- package/dist/runtime/auth-errors.js +3 -2
- package/dist/runtime/model-ref.js +33 -0
- package/dist/runtime/protocol.js +60 -4
- package/dist/runtime/tool-call-map.js +24 -0
- package/dist/server.js +235 -5
- package/dist/session/delegation-cards.js +28 -0
- package/dist/session/delegations.js +21 -0
- package/dist/session/event-log.js +61 -3
- package/dist/session/fork-standup.js +5 -0
- package/dist/session/fork.js +5 -1
- package/dist/session/sibling-client.js +31 -3
- package/dist/session/transcript-merge.js +29 -1
- package/dist/session/transcript-persistence.js +4 -0
- package/package.json +2 -2
package/bin/acp-shim.mjs
CHANGED
|
@@ -136,9 +136,31 @@ let initialized = false;
|
|
|
136
136
|
let modelConfigId = "model";
|
|
137
137
|
// A model chosen before the ACP session existed, applied once it does.
|
|
138
138
|
let pendingModel = null;
|
|
139
|
+
// True while session/load replays history (see session.resume).
|
|
140
|
+
let replayingHistory = false;
|
|
141
|
+
// The config-option id carrying reasoning effort (ACP category "thought_level"),
|
|
142
|
+
// when the agent advertises one.
|
|
143
|
+
let thoughtConfigId = null;
|
|
139
144
|
// toolCallId -> { requestId, options } so a later bivy tool.decision answers the
|
|
140
145
|
// right ACP permission request with a concrete optionId.
|
|
141
146
|
const permissionRequests = new Map();
|
|
147
|
+
// Calls the human (or policy) already approved. An agent that performs an
|
|
148
|
+
// approved edit through fs/write_text_file (Grok does) must not be gated — and
|
|
149
|
+
// carded — a second time for the same file.
|
|
150
|
+
const approvedCalls = new Set();
|
|
151
|
+
|
|
152
|
+
/** The approved, still-running call whose inputs name `file`, if any. */
|
|
153
|
+
function approvedCallWriting(file) {
|
|
154
|
+
for (const id of approvedCalls) {
|
|
155
|
+
const state = toolCallState.get(id);
|
|
156
|
+
if (!state) { approvedCalls.delete(id); continue; }
|
|
157
|
+
const input = toolCallInput(state);
|
|
158
|
+
const paths = [input.path, input.file_path, input.filePath, ...(state.locations || []).map((l) => l?.path)].filter(Boolean);
|
|
159
|
+
const root = fs.realpathSync(cwd);
|
|
160
|
+
if (paths.some((p) => path.resolve(root, String(p)) === file || path.resolve(cwd, String(p)) === file)) return id;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
142
164
|
const sandboxTier = process.env.BIVY_ACP_SANDBOX || "workspace-write";
|
|
143
165
|
|
|
144
166
|
function isWithin(root, candidate) {
|
|
@@ -190,6 +212,9 @@ function finishTurnDone() {
|
|
|
190
212
|
turnDrainTimer = null;
|
|
191
213
|
if (!turnDraining) return;
|
|
192
214
|
turnDraining = false;
|
|
215
|
+
ownCalls.clear();
|
|
216
|
+
childParent.clear();
|
|
217
|
+
approvedCalls.clear();
|
|
193
218
|
bivy({ type: "session.status", status: "idle" });
|
|
194
219
|
bivy({ type: "session.done" });
|
|
195
220
|
}
|
|
@@ -222,6 +247,11 @@ function mergeToolCallState(toolCallId, u) {
|
|
|
222
247
|
const next = {
|
|
223
248
|
kind: u.kind ?? prev.kind,
|
|
224
249
|
title: u.title ?? prev.title,
|
|
250
|
+
// Agents open a call titled with the tool's own name ("task",
|
|
251
|
+
// "spawn_subagent", "bash") and later retitle it with prose ("Count README
|
|
252
|
+
// lines"). Keep the first identifier-like title as the stable name, so a
|
|
253
|
+
// delegation still classifies as one after the retitle.
|
|
254
|
+
name: prev.name ?? (typeof u.title === "string" && /^[A-Za-z_][\w.:-]*$/.test(u.title) ? u.title : undefined),
|
|
225
255
|
rawInput: u.rawInput && typeof u.rawInput === "object" && Object.keys(u.rawInput).length ? u.rawInput : prev.rawInput,
|
|
226
256
|
locations: Array.isArray(u.locations) && u.locations.length ? u.locations : prev.locations,
|
|
227
257
|
content: content && content.length ? content : prev.content,
|
|
@@ -259,7 +289,60 @@ function toolCallInput(state) {
|
|
|
259
289
|
* both the node's bucket classifier and the client's own name-based heuristic,
|
|
260
290
|
* which both expect short tool-name-like tokens, not prose. */
|
|
261
291
|
function toolCallName(state) {
|
|
262
|
-
return (state.kind && KIND_TOOL_NAME[state.kind]) || state.title || state.kind || "tool";
|
|
292
|
+
return (state.kind && KIND_TOOL_NAME[state.kind]) || state.name || state.title || state.kind || "tool";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- sub-agent sessions ------------------------------------------------------
|
|
296
|
+
// Agents that run sub-agents as ACP sessions of their own (Grok's
|
|
297
|
+
// spawn_subagent) stream the child's updates over this same connection, tagged
|
|
298
|
+
// with the CHILD's sessionId. Folding those into the parent turn glued the
|
|
299
|
+
// sub-agent's prose onto the parent's answer and listed its tools as the
|
|
300
|
+
// parent's. Route them instead: child tool calls carry `parentToolCallId` (the
|
|
301
|
+
// delegating call, so the transcript nests them) and child prose stays out of
|
|
302
|
+
// the parent reply — the delegation's own tool result carries its answer.
|
|
303
|
+
const childParent = new Map(); // child sessionId -> delegating toolCallId ("" if unknown)
|
|
304
|
+
// This turn's own (non-child) calls, kept after they complete: a background
|
|
305
|
+
// sub-agent's spawn call often finishes before the child's first update lands.
|
|
306
|
+
const ownCalls = new Map(); // toolCallId -> accumulated state
|
|
307
|
+
const DELEGATION_NAME = /agent|task|delegat/i;
|
|
308
|
+
|
|
309
|
+
function isDelegation(state) {
|
|
310
|
+
const input = state?.rawInput || {};
|
|
311
|
+
return DELEGATION_NAME.test(String(state?.name || "")) || typeof input.subagent_type === "string" || typeof input.prompt === "string";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The delegating call for a child session: an explicit link when the agent
|
|
315
|
+
* sent one (see linkChildSession), else the newest delegation-shaped call of
|
|
316
|
+
* this turn that no other child has claimed, else the newest open call. */
|
|
317
|
+
function parentCallFor(childSessionId) {
|
|
318
|
+
if (childParent.has(childSessionId)) return childParent.get(childSessionId);
|
|
319
|
+
const claimed = new Set(childParent.values());
|
|
320
|
+
const calls = [...ownCalls].reverse().filter(([id]) => !claimed.has(id));
|
|
321
|
+
const pick = calls.find(([, st]) => isDelegation(st)) ?? calls.find(([id]) => toolCallState.has(id));
|
|
322
|
+
const parent = pick?.[0] ?? "";
|
|
323
|
+
childParent.set(childSessionId, parent);
|
|
324
|
+
return parent;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** An agent-specific notification that names a child session and its parent
|
|
328
|
+
* (`child_session_id` / `parent_session_id`, plus the tool call id or the
|
|
329
|
+
* delegation's description) pins the link instead of the heuristic above. */
|
|
330
|
+
function linkChildSession(update) {
|
|
331
|
+
const child = update?.child_session_id ?? update?.childSessionId;
|
|
332
|
+
const parentSession = update?.parent_session_id ?? update?.parentSessionId;
|
|
333
|
+
if (!child || (parentSession && parentSession !== sessionId) || childParent.get(child)) return;
|
|
334
|
+
const byId = update.tool_call_id ?? update.toolCallId;
|
|
335
|
+
const match = byId && ownCalls.has(byId)
|
|
336
|
+
? byId
|
|
337
|
+
: [...ownCalls].reverse().find(([, st]) => update.description && (st.title === update.description || st.rawInput?.description === update.description))?.[0];
|
|
338
|
+
if (match) childParent.set(child, match);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Routing for an update from `sid`: null for this session, else the parent
|
|
342
|
+
* call id ("" when it can't be placed). */
|
|
343
|
+
function childRoute(sid) {
|
|
344
|
+
if (!sid || !sessionId || sid === sessionId) return null;
|
|
345
|
+
return parentCallFor(sid);
|
|
263
346
|
}
|
|
264
347
|
|
|
265
348
|
async function ensureInitialized() {
|
|
@@ -291,24 +374,33 @@ function publishModels(result) {
|
|
|
291
374
|
// ACP model ids are `provider/model`; split the provider so Bivy can group and
|
|
292
375
|
// scope provider-specific settings the same way it does for other runtimes.
|
|
293
376
|
.map((m) => ({ ...m, provider: m.id.includes("/") ? m.id.split("/")[0] : "agent" }));
|
|
294
|
-
|
|
295
|
-
|
|
377
|
+
// Reasoning effort rides the same config-option surface under ACP's
|
|
378
|
+
// `thought_level` category (Grok: reasoning_effort xhigh/high/medium/low).
|
|
379
|
+
const thoughtOption = options.find((o) => String(o?.category ?? "") === "thought_level");
|
|
380
|
+
const levels = (Array.isArray(thoughtOption?.options) ? thoughtOption.options : []).map((o) => String(o?.value ?? "")).filter(Boolean);
|
|
381
|
+
if (!models.length && !levels.length) return;
|
|
382
|
+
if (models.length) modelConfigId = String(modelOption?.id ?? "model");
|
|
383
|
+
if (levels.length) thoughtConfigId = String(thoughtOption.id);
|
|
296
384
|
bivy({
|
|
297
385
|
type: "runtime.models",
|
|
298
386
|
models,
|
|
299
387
|
...(modelOption?.currentValue ? { currentModel: String(modelOption.currentValue) } : {}),
|
|
388
|
+
...(levels.length ? { thinking: { levels, ...(thoughtOption?.currentValue ? { current: String(thoughtOption.currentValue) } : {}) } } : {}),
|
|
300
389
|
});
|
|
301
390
|
}
|
|
302
391
|
|
|
303
392
|
// --- ACP → bivy: streamed session/update notifications ----------------------
|
|
304
393
|
function onSessionUpdate(params) {
|
|
305
394
|
const u = params?.update;
|
|
306
|
-
if (!u || typeof u !== "object") return;
|
|
395
|
+
if (!u || typeof u !== "object" || replayingHistory) return;
|
|
307
396
|
// Any update arriving while the turn is draining means the agent is still
|
|
308
397
|
// emitting the tail of this turn (see the drain note above) — reset the quiet
|
|
309
398
|
// window so session.done waits for it instead of sealing history short.
|
|
310
399
|
if (turnDraining) scheduleTurnDone();
|
|
311
400
|
const kind = String(u.sessionUpdate || "");
|
|
401
|
+
const parentCall = childRoute(params?.sessionId);
|
|
402
|
+
const child = parentCall !== null;
|
|
403
|
+
const nest = parentCall ? { parentToolCallId: parentCall } : {};
|
|
312
404
|
const textOf = (content) => {
|
|
313
405
|
if (!content) return "";
|
|
314
406
|
if (typeof content === "string") return content;
|
|
@@ -327,12 +419,12 @@ function onSessionUpdate(params) {
|
|
|
327
419
|
switch (kind) {
|
|
328
420
|
case "agent_message_chunk": {
|
|
329
421
|
const t = textOf(u.content);
|
|
330
|
-
if (t) bivy({ type: "message.delta", text: t });
|
|
422
|
+
if (t && !child) bivy({ type: "message.delta", text: t });
|
|
331
423
|
break;
|
|
332
424
|
}
|
|
333
425
|
case "agent_thought_chunk": {
|
|
334
426
|
const t = textOf(u.content);
|
|
335
|
-
if (t) bivy({ type: "message.reasoning", text: t });
|
|
427
|
+
if (t && !child) bivy({ type: "message.reasoning", text: t });
|
|
336
428
|
break;
|
|
337
429
|
}
|
|
338
430
|
case "tool_call": {
|
|
@@ -340,12 +432,14 @@ function onSessionUpdate(params) {
|
|
|
340
432
|
// shows the action; the result arrives via tool_call_update.
|
|
341
433
|
const toolCallId = String(u.toolCallId ?? u.id ?? "");
|
|
342
434
|
const state = mergeToolCallState(toolCallId, u);
|
|
343
|
-
|
|
435
|
+
if (!child) ownCalls.set(toolCallId, state);
|
|
436
|
+
bivy({ type: "tool.observe", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...nest });
|
|
344
437
|
break;
|
|
345
438
|
}
|
|
346
439
|
case "tool_call_update": {
|
|
347
440
|
const toolCallId = String(u.toolCallId ?? u.id ?? "");
|
|
348
441
|
const state = mergeToolCallState(toolCallId, u);
|
|
442
|
+
if (!child && ownCalls.has(toolCallId)) ownCalls.set(toolCallId, state);
|
|
349
443
|
const status = String(u.status || "");
|
|
350
444
|
if (status === "completed" || status === "failed") {
|
|
351
445
|
toolCallState.delete(toolCallId);
|
|
@@ -362,17 +456,18 @@ function onSessionUpdate(params) {
|
|
|
362
456
|
// the terminal frame's content when it has some, else the accumulated.
|
|
363
457
|
result: textOf(u.content) || textOf(state.content) || status,
|
|
364
458
|
isError: status === "failed",
|
|
459
|
+
...nest,
|
|
365
460
|
});
|
|
366
461
|
} else {
|
|
367
462
|
// Still running: forward the fuller name/input as it fills in so a live
|
|
368
463
|
// tool card isn't stuck with the sparse initial notification.
|
|
369
|
-
bivy({ type: "tool.update", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
|
|
464
|
+
bivy({ type: "tool.update", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...nest });
|
|
370
465
|
}
|
|
371
466
|
break;
|
|
372
467
|
}
|
|
373
468
|
case "plan":
|
|
374
469
|
// 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") });
|
|
470
|
+
if (!child && Array.isArray(u.entries)) bivy({ type: "message.reasoning", text: u.entries.map((e) => `• ${e.content ?? ""}`).join("\n") });
|
|
376
471
|
break;
|
|
377
472
|
default:
|
|
378
473
|
break;
|
|
@@ -391,7 +486,9 @@ async function onAgentRequest(id, method, params) {
|
|
|
391
486
|
const options = Array.isArray(params?.options) ? params.options : [];
|
|
392
487
|
permissionRequests.set(toolCallId, { requestId: id, options });
|
|
393
488
|
const state = mergeToolCallState(toolCallId, tc);
|
|
394
|
-
|
|
489
|
+
const parentCall = childRoute(params?.sessionId);
|
|
490
|
+
if (parentCall === null) ownCalls.set(toolCallId, state);
|
|
491
|
+
bivy({ type: "tool.call", toolCallId, name: toolCallName(state), input: toolCallInput(state), ...(parentCall ? { parentToolCallId: parentCall } : {}) });
|
|
395
492
|
return;
|
|
396
493
|
}
|
|
397
494
|
case "fs/read_text_file": {
|
|
@@ -413,9 +510,15 @@ async function onAgentRequest(id, method, params) {
|
|
|
413
510
|
try {
|
|
414
511
|
if (sandboxTier === "read-only") throw new Error("writes are disabled by the read-only sandbox");
|
|
415
512
|
const file = workspacePath(params?.path, { write: true });
|
|
513
|
+
if (approvedCallWriting(file)) {
|
|
514
|
+
fs.writeFileSync(file, String(params?.content ?? ""));
|
|
515
|
+
agentReply(id, {});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
416
518
|
const toolCallId = `acp-fs-write-${id}`;
|
|
417
519
|
permissionRequests.set(toolCallId, { kind: "fs-write", requestId: id, file, content: String(params?.content ?? "") });
|
|
418
|
-
|
|
520
|
+
const parentCall = childRoute(params?.sessionId);
|
|
521
|
+
bivy({ type: "tool.call", toolCallId, name: "write", input: { path: file }, ...(parentCall ? { parentToolCallId: parentCall } : {}) });
|
|
419
522
|
} catch (e) {
|
|
420
523
|
agentReplyError(id, -32000, `write failed: ${e.message}`);
|
|
421
524
|
}
|
|
@@ -449,6 +552,7 @@ createInterface({ input: agent.stdout }).on("line", (line) => {
|
|
|
449
552
|
if (msg.id !== undefined && msg.method) { void onAgentRequest(msg.id, msg.method, msg.params); return; }
|
|
450
553
|
// Notification (method, no id).
|
|
451
554
|
if (msg.method === "session/update") onSessionUpdate(msg.params);
|
|
555
|
+
else if (msg.params?.update) linkChildSession(msg.params.update);
|
|
452
556
|
});
|
|
453
557
|
|
|
454
558
|
/**
|
|
@@ -507,7 +611,14 @@ async function onBivyCommand(msg) {
|
|
|
507
611
|
// hanging the reopen with no watchdog to recover it (resume isn't a
|
|
508
612
|
// "working" turn). On timeout/failure we fall back to a fresh session so
|
|
509
613
|
// the chat still opens instead of spinning on "Fetching transcript…".
|
|
510
|
-
|
|
614
|
+
// ACP agents replay the whole conversation as session/update during
|
|
615
|
+
// session/load. Bivy already has that history; forwarding the replay
|
|
616
|
+
// re-recorded past thinking (and could fold old text onto the last
|
|
617
|
+
// reply) as if it were live.
|
|
618
|
+
replayingHistory = true;
|
|
619
|
+
let res;
|
|
620
|
+
try { res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers }, { timeoutMs: 30_000 }); }
|
|
621
|
+
finally { replayingHistory = false; }
|
|
511
622
|
sessionId = res?.sessionId ?? ref;
|
|
512
623
|
publishModels(res);
|
|
513
624
|
} catch (error) {
|
|
@@ -554,16 +665,22 @@ async function onBivyCommand(msg) {
|
|
|
554
665
|
permissionRequests.delete(msg.toolCallId);
|
|
555
666
|
const allow = msg.decision !== "deny";
|
|
556
667
|
if (entry.kind === "fs-write") {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
668
|
+
// The write card is the shim's own call, so the shim closes it too —
|
|
669
|
+
// left open it pinned the turn "Working" after the agent finished.
|
|
670
|
+
let error = allow ? "" : String(msg.reason || "write denied by Bivy policy");
|
|
671
|
+
if (allow) {
|
|
672
|
+
try { fs.writeFileSync(entry.file, entry.content); }
|
|
673
|
+
catch (e) { error = `write failed: ${e.message}`; }
|
|
561
674
|
}
|
|
675
|
+
if (error) agentReplyError(entry.requestId, allow ? -32000 : -32001, error);
|
|
676
|
+
else agentReply(entry.requestId, {});
|
|
677
|
+
bivy({ type: "tool.result", toolCallId: msg.toolCallId, name: "write", result: error || `Wrote ${entry.file}`, isError: Boolean(error) });
|
|
562
678
|
return;
|
|
563
679
|
}
|
|
564
680
|
// Pick an ACP option matching the human's choice by its `kind`
|
|
565
681
|
// (allow_once/allow_always vs reject_once/reject_always); fall back to the
|
|
566
682
|
// first option, or a cancelled outcome when nothing fits.
|
|
683
|
+
if (allow) approvedCalls.add(msg.toolCallId);
|
|
567
684
|
const want = allow ? /^allow/ : /^reject/;
|
|
568
685
|
const opt = entry.options.find((o) => want.test(String(o.kind || ""))) ?? entry.options[0];
|
|
569
686
|
if (opt && opt.optionId !== undefined) agentReply(entry.requestId, { outcome: { outcome: "selected", optionId: opt.optionId } });
|
|
@@ -584,6 +701,15 @@ async function onBivyCommand(msg) {
|
|
|
584
701
|
bivy({ replyTo: id, ok: true });
|
|
585
702
|
return;
|
|
586
703
|
}
|
|
704
|
+
case "thinking.set": {
|
|
705
|
+
const level = String(msg.level ?? "").trim();
|
|
706
|
+
if (sessionId && thoughtConfigId && level) {
|
|
707
|
+
try { await agentRequest("session/set_config_option", { sessionId, configId: thoughtConfigId, value: level }, { timeoutMs: 15_000 }); }
|
|
708
|
+
catch (e) { bivy({ type: "runtime.debug", message: `acp set thought level failed: ${e instanceof Error ? e.message : String(e)}` }); }
|
|
709
|
+
}
|
|
710
|
+
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
587
713
|
case "session.abort": {
|
|
588
714
|
// A pending drain must not fire session.done after the user cancelled —
|
|
589
715
|
// the turn is being torn down, not finishing on its own.
|
package/bin/agent-manifest.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"hidden": false,
|
|
23
23
|
"supportTier": "supported",
|
|
24
24
|
"certification": "release-tested",
|
|
25
|
-
"testedVersion": "1.18.
|
|
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.
|
|
194
|
+
"testedVersion": "1.0.44",
|
|
195
195
|
"headlessFlags": [
|
|
196
196
|
"--always-approve",
|
|
197
197
|
"-p",
|
package/bin/bivy.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
35
35
|
import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash, verify as cryptoVerify } from "node:crypto";
|
|
36
36
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
37
37
|
import vm from "node:vm";
|
|
38
|
-
import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
|
|
38
|
+
import { linkSessionWorktrees, selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
|
|
39
39
|
import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
|
|
40
40
|
import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
|
|
41
41
|
import { removeInstallAndState } from "./uninstall-paths.mjs";
|
|
@@ -1827,7 +1827,7 @@ function cmdCompletions(args = []) {
|
|
|
1827
1827
|
const shell = (args[0] || "").toLowerCase();
|
|
1828
1828
|
const commands = [
|
|
1829
1829
|
"run", "runs", "sessions", "ls", "resume", "promote", "rename", "nodes", "agent", "agents", "agents:install", "shim", "takeover", "token", "exec",
|
|
1830
|
-
"send", "attach", "suggest", "kill", "setup", "start", "stop", "restart", "status", "doctor", "diagnostics", "capabilities", "logs", "login", "logout", "signout", "provider", "model",
|
|
1830
|
+
"send", "attach", "suggest", "delegate", "kill", "setup", "start", "stop", "restart", "status", "doctor", "diagnostics", "capabilities", "logs", "login", "logout", "signout", "provider", "model",
|
|
1831
1831
|
"update", "update:log", "audit", "automation", "config", "plugin", "open", "service", "secrets", "voice", "link", "relay:setup",
|
|
1832
1832
|
"github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
|
|
1833
1833
|
];
|
|
@@ -2462,6 +2462,125 @@ async function cmdSuggest(args = []) {
|
|
|
2462
2462
|
console.log(c.green("Suggested in the chat. The user can start it in one tap."));
|
|
2463
2463
|
}
|
|
2464
2464
|
|
|
2465
|
+
// Cross-agent / cross-machine delegation (docs/agent-delegation.md): hand a
|
|
2466
|
+
// self-contained task from this session to another agent, optionally on another
|
|
2467
|
+
// account machine, and get its answer back. Agent-agnostic — any agent with a
|
|
2468
|
+
// shell can call it — and meant for when the user asks for another agent/machine.
|
|
2469
|
+
const DELEGATE_USAGE = `Usage: bivy delegate "<task>" [--agent <id>] [--machine <name>] [--model <model>] [--repo owner/repo] [--wait [seconds]] [--json]
|
|
2470
|
+
bivy delegate "<task>" --to <agent>[@<machine>],<agent>[@<machine>],… [--wait [seconds]]
|
|
2471
|
+
bivy delegate machines [--json]
|
|
2472
|
+
bivy delegate status <run-id> [--json]
|
|
2473
|
+
bivy delegate wait <run-id> [--timeout <seconds>] [--json]
|
|
2474
|
+
|
|
2475
|
+
Hands a self-contained task to another agent (and/or another machine on your account) as a
|
|
2476
|
+
delegated Run tied to this session ($BIVY_SESSION_ID). With --wait (or 'wait'), prints the
|
|
2477
|
+
child's final answer and any branch/PR it produced once it finishes. --to sends the same task
|
|
2478
|
+
to several agents/machines at once (up to 3) so the results can be compared.
|
|
2479
|
+
'machines' lists your machines and the agents installed on each.`;
|
|
2480
|
+
|
|
2481
|
+
async function cmdDelegate(args = []) {
|
|
2482
|
+
if (!args.length || args.some((a) => a === "-h" || a === "--help")) { console.log(DELEGATE_USAGE); return; }
|
|
2483
|
+
const flagsWithValue = new Set(["--session", "--agent", "--machine", "--model", "--repo", "--timeout", "--to"]);
|
|
2484
|
+
const flag = (name) => {
|
|
2485
|
+
const i = args.indexOf(name);
|
|
2486
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
2487
|
+
};
|
|
2488
|
+
const json = args.includes("--json");
|
|
2489
|
+
const waitIndex = args.indexOf("--wait");
|
|
2490
|
+
const waitArg = waitIndex >= 0 && /^\d+$/.test(args[waitIndex + 1] ?? "") ? args[waitIndex + 1] : undefined;
|
|
2491
|
+
const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && flagsWithValue.has(args[i - 1])) && !(waitArg && i === waitIndex + 1));
|
|
2492
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: flag("--session"), env: process.env });
|
|
2493
|
+
if (!sessionId) { console.error(c.red("No session id. Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.")); process.exit(1); return; }
|
|
2494
|
+
const config = loadConfig();
|
|
2495
|
+
if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
|
|
2496
|
+
let token;
|
|
2497
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2498
|
+
const base = `${url(config)}/api/session/${encodeURIComponent(sessionId)}/delegated-runs`;
|
|
2499
|
+
const call = async (path, body) => {
|
|
2500
|
+
const res = await fetch(`${base}${path}`, {
|
|
2501
|
+
method: body ? "POST" : "GET",
|
|
2502
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
2503
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2504
|
+
});
|
|
2505
|
+
const data = await res.json().catch(() => ({}));
|
|
2506
|
+
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
2507
|
+
return data;
|
|
2508
|
+
};
|
|
2509
|
+
const terminal = (run) => ["succeeded", "failed", "cancelled"].includes(run?.status);
|
|
2510
|
+
// The node caps one wait at 300s; loop until the child ends or our budget does.
|
|
2511
|
+
const waitFor = async (runId, seconds) => {
|
|
2512
|
+
const deadline = Date.now() + seconds * 1000;
|
|
2513
|
+
let run = await call(`/${encodeURIComponent(runId)}`);
|
|
2514
|
+
while (!terminal(run) && Date.now() < deadline) {
|
|
2515
|
+
const slice = Math.max(1, Math.min(300, Math.ceil((deadline - Date.now()) / 1000)));
|
|
2516
|
+
run = await call(`/${encodeURIComponent(runId)}/wait`, { timeoutSeconds: slice });
|
|
2517
|
+
}
|
|
2518
|
+
return run;
|
|
2519
|
+
};
|
|
2520
|
+
const print = (run) => {
|
|
2521
|
+
if (json) { console.log(JSON.stringify(run, null, 2)); return; }
|
|
2522
|
+
console.log(`Run ${run.runId}: ${run.status}${terminal(run) ? "" : " (still running — 'bivy delegate wait " + run.runId + "')"}`);
|
|
2523
|
+
const refs = run.references || {};
|
|
2524
|
+
if (refs.failure) console.log(`Failure: ${refs.failure}`);
|
|
2525
|
+
if (refs.branch) console.log(`Branch: ${refs.branch}`);
|
|
2526
|
+
if (refs.prUrl) console.log(`PR: ${refs.prUrl}`);
|
|
2527
|
+
if (refs.sessionId) console.log(`Child session: ${refs.sessionId}`);
|
|
2528
|
+
if (run.answer) console.log(`\nAnswer:\n${run.answer}`);
|
|
2529
|
+
};
|
|
2530
|
+
try {
|
|
2531
|
+
const [action, runId] = positional;
|
|
2532
|
+
if (action === "machines" && positional.length === 1) {
|
|
2533
|
+
const res = await fetch(`${url(config)}/api/machines`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
2534
|
+
const data = await res.json().catch(() => ({}));
|
|
2535
|
+
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
2536
|
+
if (json) { console.log(JSON.stringify(data.machines, null, 2)); return; }
|
|
2537
|
+
for (const m of data.machines || []) {
|
|
2538
|
+
const agents = m.agents ? m.agents.map((a) => a.id).join(", ") || "none" : m.error ? `unknown (${m.error})` : "unknown while offline";
|
|
2539
|
+
console.log(`${m.name}${m.self ? " (this machine)" : ""} — ${m.online ? "online" : "offline"} — agents: ${agents}`);
|
|
2540
|
+
}
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (action === "status" && runId) return print(await call(`/${encodeURIComponent(runId)}`));
|
|
2544
|
+
if (action === "wait" && runId) return print(await waitFor(runId, Number(flag("--timeout") ?? 600)));
|
|
2545
|
+
const instructions = positional.join(" ").trim();
|
|
2546
|
+
if (!instructions) { console.error(c.red(DELEGATE_USAGE)); process.exit(1); return; }
|
|
2547
|
+
// Fan-out: the same task to several agent@machine targets, one group, so the
|
|
2548
|
+
// parent's transcript shows them side by side to compare.
|
|
2549
|
+
const to = flag("--to");
|
|
2550
|
+
if (to) {
|
|
2551
|
+
const targets = to.split(",").map((t) => t.trim()).filter(Boolean).map((t) => {
|
|
2552
|
+
const [agent, machine] = t.split("@");
|
|
2553
|
+
return { agent: agent ? governedChatAgentId(agent) : undefined, machine: machine || undefined, label: t };
|
|
2554
|
+
});
|
|
2555
|
+
const group = `fanout-${Date.now().toString(36)}`;
|
|
2556
|
+
const runs = [];
|
|
2557
|
+
for (const target of targets) {
|
|
2558
|
+
runs.push({ target, run: await call("", { instructions, agent: target.agent, machine: target.machine, model: flag("--model"), repo: flag("--repo"), group }) });
|
|
2559
|
+
}
|
|
2560
|
+
if (waitIndex < 0) {
|
|
2561
|
+
if (json) console.log(JSON.stringify(runs.map((r) => r.run), null, 2));
|
|
2562
|
+
else for (const { target, run } of runs) console.log(`${target.label}: run ${run.runId} (${run.status})`);
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
const finished = await Promise.all(runs.map(async ({ target, run }) => ({ target, run: await waitFor(run.runId, Number(waitArg ?? 600)) })));
|
|
2566
|
+
if (json) { console.log(JSON.stringify(finished.map((f) => f.run), null, 2)); return; }
|
|
2567
|
+
for (const { target, run } of finished) { console.log(`\n── ${target.label}`); print(run); }
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
const agent = flag("--agent");
|
|
2571
|
+
const started = await call("", { instructions, agent: agent ? governedChatAgentId(agent) : undefined, machine: flag("--machine"), model: flag("--model"), repo: flag("--repo") });
|
|
2572
|
+
if (waitIndex < 0) {
|
|
2573
|
+
if (!json) console.log(`Delegated as run ${started.runId} (${started.status}). Follow it with 'bivy delegate wait ${started.runId}'.`);
|
|
2574
|
+
else print(started);
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
print(await waitFor(started.runId, Number(waitArg ?? 600)));
|
|
2578
|
+
} catch (error) {
|
|
2579
|
+
console.error(c.red(`Delegate failed: ${error?.message || String(error)}`));
|
|
2580
|
+
process.exit(1);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2465
2584
|
// Universal app publishing: every agent that can write JSON and run a command
|
|
2466
2585
|
// uses the same contract. Relative static directories are manifest-relative.
|
|
2467
2586
|
async function cmdApp(args = []) {
|
|
@@ -2472,7 +2591,7 @@ async function cmdApp(args = []) {
|
|
|
2472
2591
|
bivy app remove <app-id> [--session <id>]
|
|
2473
2592
|
bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
|
|
2474
2593
|
bivy app present [view] [--path /page] [--note "…"] [--session <id>]
|
|
2475
|
-
bivy app share [app-id] [--view <view>] [--session <id>]
|
|
2594
|
+
bivy app share [app-id] [--view <view>] [--for 1h|1d|7d] [--view-only] [--session <id>]
|
|
2476
2595
|
bivy app notes [app-id] [--view <view>] [--since <time>] [--session <id>]
|
|
2477
2596
|
bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
|
|
2478
2597
|
bivy app click <x> <y> [--right|--middle] [--double] [--app <app>]
|
|
@@ -2518,9 +2637,10 @@ the chat with the app at phone width (and before/after), and it opens the live
|
|
|
2518
2637
|
preview. Use it when you finish a visible change, or when the user asks to see
|
|
2519
2638
|
it. [view] is an app or view name or ID (default: the one opened last).
|
|
2520
2639
|
"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
|
-
|
|
2640
|
+
(--for: 1h, 1d or 7d, default 1d; sooner if the user stops sharing in Apps, the
|
|
2641
|
+
app is removed or the machine restarts). [app-id] is an app ID or name, --view
|
|
2642
|
+
a view ID or name (default: the one opened last). --view-only leaves out the
|
|
2643
|
+
reviewer tools (Point, Draw, notes): people see just the app. Anyone who has the link can use the app, including a live
|
|
2524
2644
|
server's backend, until it expires or is revoked: only post it where everyone
|
|
2525
2645
|
who can read it may use the app.
|
|
2526
2646
|
"notes" prints the notes people left through share links (Leave a note), as
|
|
@@ -2557,6 +2677,17 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2557
2677
|
rest.splice(i, 2);
|
|
2558
2678
|
}
|
|
2559
2679
|
}
|
|
2680
|
+
if (action === "share") {
|
|
2681
|
+
const i = rest.indexOf("--for");
|
|
2682
|
+
if (i >= 0) {
|
|
2683
|
+
const value = rest[i + 1];
|
|
2684
|
+
if (!["1h", "1d", "7d"].includes(value)) throw new Error("--for takes 1h, 1d or 7d.");
|
|
2685
|
+
shot.duration = value;
|
|
2686
|
+
rest.splice(i, 2);
|
|
2687
|
+
}
|
|
2688
|
+
const viewOnly = rest.indexOf("--view-only");
|
|
2689
|
+
if (viewOnly >= 0) { shot.controls = false; rest.splice(viewOnly, 1); }
|
|
2690
|
+
}
|
|
2560
2691
|
if (action === "notes") {
|
|
2561
2692
|
const i = rest.indexOf("--since");
|
|
2562
2693
|
if (i >= 0) {
|
|
@@ -2656,7 +2787,7 @@ async function appRequest(action, body, { print = true } = {}) {
|
|
|
2656
2787
|
if (action === "share") {
|
|
2657
2788
|
const { type: _type, requestId: _requestId, ...link } = result;
|
|
2658
2789
|
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
|
|
2790
|
+
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
2791
|
return;
|
|
2661
2792
|
}
|
|
2662
2793
|
if (print) console.log(JSON.stringify(result, null, 2));
|
|
@@ -3037,6 +3168,11 @@ ${c.bold("bivy prune")} — remove old sessions, --clone workspaces, and git wor
|
|
|
3037
3168
|
is never pruned while the node is running, regardless of --keep/--older-than —
|
|
3038
3169
|
only idle worktrees are removed. If the node is down, nothing is live to guard.
|
|
3039
3170
|
|
|
3171
|
+
A session and its worktree are removed together: pruning a worktree also
|
|
3172
|
+
removes the sessions that ran in it, pruning a session also removes its
|
|
3173
|
+
worktree (once no remaining session uses it), and sessions whose worktree is
|
|
3174
|
+
already gone are removed — so no session is left pointing at a deleted checkout.
|
|
3175
|
+
|
|
3040
3176
|
Paths (default to the installed node)
|
|
3041
3177
|
--data-dir <dir> node data dir (default: this install's .bivy, or $BIVY_DATA_DIR)
|
|
3042
3178
|
--workspace <dir> also scan this workspace for worktrees (default: configured workspace)
|
|
@@ -3104,12 +3240,41 @@ async function cmdPrune(args = []) {
|
|
|
3104
3240
|
const now = Date.now();
|
|
3105
3241
|
const plan = [];
|
|
3106
3242
|
let worktreeGuard = { reachable: false, protected: 0 };
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3243
|
+
// Sessions live in the metadata index (all agents) plus each agent's own
|
|
3244
|
+
// transcript store — not just .bivy/pi/sessions. Select from metadata so
|
|
3245
|
+
// shim/native (Claude Code, Codex, …) sessions are actually covered; the old
|
|
3246
|
+
// pi-sessions-only scan silently no-oped for every terminal-started session.
|
|
3247
|
+
const allSessions = loadMetadataSessions(dataDir).sessions;
|
|
3248
|
+
const selectedSessions = doSessions ? selectStaleSessions(allSessions, keepFor("sessions"), ageMs, now) : [];
|
|
3249
|
+
// Worktrees: keep the newest N overall (across all roots), matching the node
|
|
3250
|
+
// prune script. Scan roots: the data dir plus the configured workspace repo.
|
|
3251
|
+
const worktreeRoots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
|
|
3252
|
+
const staleWorktrees = doWorktrees ? selectStale(worktreeRoots.flatMap((r) => pruneListEntries(r, "dir")), keepFor("worktrees"), ageMs, now) : [];
|
|
3253
|
+
// Guard: never delete a worktree a live agent is using. Ask the daemon what's
|
|
3254
|
+
// live (it owns every runtime and PTY); protected worktrees drop out of the
|
|
3255
|
+
// removal set entirely, so an aggressive --older-than can't nuke a running
|
|
3256
|
+
// agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
|
|
3257
|
+
const live = await liveWorktreePaths(config);
|
|
3258
|
+
const isLive = (p) => isLiveWorktree(p, live.paths);
|
|
3259
|
+
const idleWorktrees = staleWorktrees.filter((e) => !isLive(e.path));
|
|
3260
|
+
worktreeGuard = { reachable: live.reachable, protected: staleWorktrees.length - idleWorktrees.length };
|
|
3261
|
+
// A session and its worktree go together: removing one without the other left
|
|
3262
|
+
// sessions pointing at a deleted checkout (or checkouts nobody can reach).
|
|
3263
|
+
const linked = linkSessionWorktrees({
|
|
3264
|
+
sessions: allSessions,
|
|
3265
|
+
staleSessions: selectedSessions,
|
|
3266
|
+
staleWorktrees: idleWorktrees.map((e) => e.path),
|
|
3267
|
+
isLive,
|
|
3268
|
+
exists: (p) => fs.existsSync(p),
|
|
3269
|
+
});
|
|
3270
|
+
const staleSessions = linked.sessions;
|
|
3271
|
+
const worktreeMtime = new Map(idleWorktrees.map((e) => [path.resolve(e.path), e.mtimeMs]));
|
|
3272
|
+
const worktreesToRemove = linked.worktrees.map((p) => {
|
|
3273
|
+
let mtimeMs = worktreeMtime.get(p);
|
|
3274
|
+
if (mtimeMs === undefined) { try { mtimeMs = fs.statSync(p).mtimeMs; } catch { mtimeMs = now; } }
|
|
3275
|
+
return { path: p, mtimeMs };
|
|
3276
|
+
}).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
3277
|
+
if (doSessions || staleSessions.length) {
|
|
3113
3278
|
plan.push({
|
|
3114
3279
|
kind: "sessions",
|
|
3115
3280
|
root: metadataFilePath(dataDir),
|
|
@@ -3132,20 +3297,8 @@ async function cmdPrune(args = []) {
|
|
|
3132
3297
|
const dir = path.join(dataDir, "workspaces");
|
|
3133
3298
|
plan.push({ kind: "workspaces", root: dir, remove: selectStale(pruneListEntries(dir, "dir"), keepFor("workspaces"), ageMs, now) });
|
|
3134
3299
|
}
|
|
3135
|
-
if (doWorktrees) {
|
|
3136
|
-
|
|
3137
|
-
// prune script. Scan roots: the data dir plus the configured workspace repo.
|
|
3138
|
-
const roots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
|
|
3139
|
-
const all = roots.flatMap((r) => pruneListEntries(r, "dir"));
|
|
3140
|
-
const stale = selectStale(all, keepFor("worktrees"), ageMs, now);
|
|
3141
|
-
// Guard: never delete a worktree a live agent is using. Ask the daemon what's
|
|
3142
|
-
// live (it owns every runtime and PTY); protected worktrees drop out of the
|
|
3143
|
-
// removal set entirely, so an aggressive --older-than can't nuke a running
|
|
3144
|
-
// agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
|
|
3145
|
-
const live = await liveWorktreePaths(config);
|
|
3146
|
-
const remove = stale.filter((e) => !isLiveWorktree(e.path, live.paths));
|
|
3147
|
-
worktreeGuard = { reachable: live.reachable, protected: stale.length - remove.length };
|
|
3148
|
-
plan.push({ kind: "worktrees", root: roots.join(", ") || "(none found)", remove });
|
|
3300
|
+
if (doWorktrees || worktreesToRemove.length) {
|
|
3301
|
+
plan.push({ kind: "worktrees", root: worktreeRoots.join(", ") || "(none found)", remove: worktreesToRemove });
|
|
3149
3302
|
}
|
|
3150
3303
|
const total = plan.reduce((n, p) => n + p.remove.length, 0);
|
|
3151
3304
|
|
|
@@ -5456,6 +5609,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
5456
5609
|
case "suggest":
|
|
5457
5610
|
await cmdSuggest(args);
|
|
5458
5611
|
break;
|
|
5612
|
+
case "delegate":
|
|
5613
|
+
await cmdDelegate(args);
|
|
5614
|
+
break;
|
|
5459
5615
|
case "completions":
|
|
5460
5616
|
case "completion":
|
|
5461
5617
|
cmdCompletions(args);
|