@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.
Files changed (36) hide show
  1. package/bin/acp-shim.mjs +142 -16
  2. package/bin/agent-manifest.json +2 -2
  3. package/bin/bivy.mjs +183 -27
  4. package/bin/prune-sessions.mjs +38 -0
  5. package/dist/agent-instructions.js +4 -0
  6. package/dist/agents/claude-code/integration.js +1 -1
  7. package/dist/agents/claude-code/runtime.js +25 -2
  8. package/dist/agents/codex/integration.js +1 -1
  9. package/dist/agents/profiles.js +3 -3
  10. package/dist/apps/gateway.js +53 -17
  11. package/dist/apps/preview-shell.js +2 -0
  12. package/dist/apps/remote-preview.js +4 -2
  13. package/dist/apps/service.js +18 -3
  14. package/dist/apps/styles.css +29 -1
  15. package/dist/apps/types.js +8 -0
  16. package/dist/certification/generated.js +4 -4
  17. package/dist/controllers/app-commands.js +7 -3
  18. package/dist/controllers/fork-commands.js +20 -20
  19. package/dist/exec.js +41 -4
  20. package/dist/protocol/client-command-routes.js +1 -1
  21. package/dist/protocol/client-command-schemas.js +4 -1
  22. package/dist/run-tools.js +8 -0
  23. package/dist/runtime/auth-errors.js +3 -2
  24. package/dist/runtime/model-ref.js +33 -0
  25. package/dist/runtime/protocol.js +60 -4
  26. package/dist/runtime/tool-call-map.js +24 -0
  27. package/dist/server.js +235 -5
  28. package/dist/session/delegation-cards.js +28 -0
  29. package/dist/session/delegations.js +21 -0
  30. package/dist/session/event-log.js +61 -3
  31. package/dist/session/fork-standup.js +5 -0
  32. package/dist/session/fork.js +5 -1
  33. package/dist/session/sibling-client.js +31 -3
  34. package/dist/session/transcript-merge.js +29 -1
  35. package/dist/session/transcript-persistence.js +4 -0
  36. 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
- if (!models.length) return;
295
- modelConfigId = String(modelOption?.id ?? "model");
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
- bivy({ type: "tool.observe", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
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
- bivy({ type: "tool.call", toolCallId, name: toolCallName(state), input: toolCallInput(state) });
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
- bivy({ type: "tool.call", toolCallId, name: "write", input: { path: file } });
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
- const res = await agentRequest("session/load", { sessionId: ref, cwd, mcpServers }, { timeoutMs: 30_000 });
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
- if (!allow) agentReplyError(entry.requestId, -32001, String(msg.reason || "write denied by Bivy policy"));
558
- else {
559
- try { fs.writeFileSync(entry.file, entry.content); agentReply(entry.requestId, {}); }
560
- catch (e) { agentReplyError(entry.requestId, -32000, `write failed: ${e.message}`); }
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.
@@ -22,7 +22,7 @@
22
22
  "hidden": false,
23
23
  "supportTier": "supported",
24
24
  "certification": "release-tested",
25
- "testedVersion": "1.18.32",
25
+ "testedVersion": "1.18.33",
26
26
  "headlessFlags": [
27
27
  "run",
28
28
  "-s"
@@ -191,7 +191,7 @@
191
191
  "hidden": false,
192
192
  "supportTier": "supported",
193
193
  "certification": "release-tested",
194
- "testedVersion": "1.0.41",
194
+ "testedVersion": "1.0.44",
195
195
  "headlessFlags": [
196
196
  "--always-approve",
197
197
  "-p",
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
- (24 hours, or until Revoke access in Apps, the app is removed or the machine
2522
- restarts). [app-id] is an app ID or name, --view a view ID or name (default: the
2523
- one opened last). Anyone who has the link can use the app, including a live
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 you revoke it (Apps → Revoke access).");
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
- if (doSessions) {
3108
- // Sessions live in the metadata index (all agents) plus each agent's own
3109
- // transcript store — not just .bivy/pi/sessions. Select from metadata so
3110
- // shim/native (Claude Code, Codex, …) sessions are actually covered; the old
3111
- // pi-sessions-only scan silently no-oped for every terminal-started session.
3112
- const staleSessions = selectStaleSessions(loadMetadataSessions(dataDir).sessions, keepFor("sessions"), ageMs, now);
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
- // Keep the newest N worktrees overall (across all roots), matching the node
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);