@bivy/bivy 0.18.11-staging.8 → 0.18.11-staging.9

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/bivy.mjs CHANGED
@@ -2467,16 +2467,20 @@ async function cmdSuggest(args = []) {
2467
2467
  // account machine, and get its answer back. Agent-agnostic — any agent with a
2468
2468
  // shell can call it — and meant for when the user asks for another agent/machine.
2469
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]
2470
2472
  bivy delegate status <run-id> [--json]
2471
2473
  bivy delegate wait <run-id> [--timeout <seconds>] [--json]
2472
2474
 
2473
2475
  Hands a self-contained task to another agent (and/or another machine on your account) as a
2474
2476
  delegated Run tied to this session ($BIVY_SESSION_ID). With --wait (or 'wait'), prints the
2475
- child's final answer and any branch/PR it produced once it finishes. 'bivy nodes' lists machines.`;
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.`;
2476
2480
 
2477
2481
  async function cmdDelegate(args = []) {
2478
2482
  if (!args.length || args.some((a) => a === "-h" || a === "--help")) { console.log(DELEGATE_USAGE); return; }
2479
- const flagsWithValue = new Set(["--session", "--agent", "--machine", "--model", "--repo", "--timeout"]);
2483
+ const flagsWithValue = new Set(["--session", "--agent", "--machine", "--model", "--repo", "--timeout", "--to"]);
2480
2484
  const flag = (name) => {
2481
2485
  const i = args.indexOf(name);
2482
2486
  return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
@@ -2525,11 +2529,46 @@ async function cmdDelegate(args = []) {
2525
2529
  };
2526
2530
  try {
2527
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
+ }
2528
2543
  if (action === "status" && runId) return print(await call(`/${encodeURIComponent(runId)}`));
2529
2544
  if (action === "wait" && runId) return print(await waitFor(runId, Number(flag("--timeout") ?? 600)));
2530
2545
  const instructions = positional.join(" ").trim();
2531
2546
  if (!instructions) { console.error(c.red(DELEGATE_USAGE)); process.exit(1); return; }
2532
- const started = await call("", { instructions, agent: flag("--agent"), machine: flag("--machine"), model: flag("--model"), repo: flag("--repo") });
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") });
2533
2572
  if (waitIndex < 0) {
2534
2573
  if (!json) console.log(`Delegated as run ${started.runId} (${started.status}). Follow it with 'bivy delegate wait ${started.runId}'.`);
2535
2574
  else print(started);
@@ -44,7 +44,8 @@ export const BIVY_AGENT_NOTE = [
44
44
  "write it to stand on its own, with paths relative to the project root.",
45
45
  "- Only when the user asks for another agent or another of their machines to take part: " +
46
46
  '`bivy delegate "<self-contained task>" --agent <id> [--machine <name>] --wait` runs it there and prints ' +
47
- "its answer and any branch/PR (`bivy nodes` lists machines). Use your own sub-agents for everything else.",
47
+ "its answer and any branch/PR; `--to codex,grok@<machine>` asks several to compare, `bivy delegate machines` " +
48
+ "lists machines and their agents. Use your own sub-agents for everything else.",
48
49
  ].join("\n");
49
50
  const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
50
51
  "instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
@@ -354,6 +354,9 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
354
354
  @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
355
355
  .session-row { position: relative; display: flex; align-items: center; }
356
356
  .session-row .session-item { flex: 1; min-width: 0; }
357
+ /* A delegated child under its parent session (sessionNesting.ts). */
358
+ .session-row.nested { padding-inline-start: var(--space-4); }
359
+ .session-row.nested::before { content: ""; position: absolute; inset-block: var(--space-2); inset-inline-start: var(--space-2); border-inline-start: 1px solid var(--line-strong); }
357
360
  .pending-launch-actions { display: flex; flex: none; gap: 4px; padding-right: 6px; }
358
361
  .pending-launch-actions button { min-height: var(--space-7); min-width: var(--space-7); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--ink); padding: 5px 8px; font-size: 12px; cursor: pointer; }
359
362
  .pending-launch-actions button:last-child { color: var(--muted); background: transparent; }
@@ -3107,6 +3110,26 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3107
3110
  .suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
3108
3111
  .suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
3109
3112
  .suggestion-status { margin: var(--space-2) 0 0; display: flex; align-items: center; gap: var(--space-2); color: var(--ok); font-size: var(--text-sm); font-weight: var(--weight-medium); }
3113
+ /* Delegation (DelegationCard.tsx): a task handed to another agent/machine.
3114
+ Shell from .card, status from .badge; layout only here. A fan-out renders as
3115
+ one group whose members sit side by side where there is room. */
3116
+ .delegation-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
3117
+ .delegation-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
3118
+ .delegation-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
3119
+ .delegation-head { align-items: flex-start; }
3120
+ .delegation-target { display: flex; flex-direction: column; font-weight: var(--weight-semibold); overflow-wrap: anywhere; min-inline-size: 0; }
3121
+ .delegation-machine { color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-normal); }
3122
+ .delegation-task { margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
3123
+ .delegation-failure { margin: 0; color: var(--danger); font-size: var(--text-sm); overflow-wrap: anywhere; }
3124
+ .delegation-answer summary { cursor: pointer; color: var(--muted); font-size: var(--text-sm); }
3125
+ .delegation-answer p { margin: var(--space-1) 0 0; font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 18em; overflow-y: auto; }
3126
+ .delegation-refs { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
3127
+ .delegation-refs code { overflow-wrap: anywhere; }
3128
+ .delegation-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
3129
+ .delegation-group { display: flex; flex-direction: column; gap: var(--space-1); margin-block: var(--space-2); }
3130
+ .delegation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-2); }
3131
+ .delegation-member { display: flex; flex-direction: column; gap: var(--space-1); min-inline-size: 0; }
3132
+ .delegation-member > .btn { align-self: flex-start; }
3110
3133
  /* Review card (ReviewCard.tsx): the app at a moment worth judging. The
3111
3134
  screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
3112
3135
  canonical .segmented switch; one primary action. */
@@ -111,7 +111,7 @@ const PATH_KEYS = ["path", "file_path", "filePath", "filename", "fileName", "fil
111
111
  */
112
112
  function bivyDelegation(command) {
113
113
  const match = /^\s*bivy\s+delegate\b(.*)$/s.exec(command);
114
- if (!match || /^\s+(status|wait|--help|-h)\b/.test(match[1]))
114
+ if (!match || /^\s+(status|wait|machines|--help|-h)\b/.test(match[1]))
115
115
  return undefined;
116
116
  const rest = match[1];
117
117
  const flag = (name) => new RegExp(`--${name}(?:=|\\s+)("([^"]*)"|'([^']*)'|(\\S+))`).exec(rest);
@@ -120,7 +120,8 @@ function bivyDelegation(command) {
120
120
  const machine = value(flag("machine"));
121
121
  const quoted = /(?:^|\s)("([^"]+)"|'([^']+)')/.exec(rest.replace(/--[a-z-]+(?:=|\s+)("[^"]*"|'[^']*'|\S+)/g, ""));
122
122
  const description = quoted ? (quoted[2] ?? quoted[3]) : undefined;
123
- const label = [agent, machine && `@ ${machine}`].filter(Boolean).join(" ") || undefined;
123
+ const to = value(flag("to"))?.split(",").map((t) => t.trim().replace("@", " @ ")).filter(Boolean).join(", ");
124
+ const label = to || [agent, machine && `@ ${machine}`].filter(Boolean).join(" ") || undefined;
124
125
  return { ...(label ? { label } : {}), ...(description ? { description } : {}) };
125
126
  }
126
127
  /**
package/dist/server.js CHANGED
@@ -161,6 +161,8 @@ import { synthesizeOpenAiSpeech } from "./tts.js";
161
161
  import { seal, open } from "./e2e.js";
162
162
  import { recoverModelAuthKey } from "./model-auth-key-recovery.js";
163
163
  import { RunDelegationService, parseDelegationSource } from "./run-tools.js";
164
+ import { delegationCard } from "./session/delegation-cards.js";
165
+ import { TERMINAL_DELEGATION, isDelegatedFrom } from "./session/delegations.js";
164
166
  import { ControlPlaneTaskPoller, resolveControlPlaneTaskConfig, } from "./control-plane-tasks.js";
165
167
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
166
168
  const repoRoot = path.resolve(__dirname, "..");
@@ -532,6 +534,13 @@ const runDelegation = new RunDelegationService({
532
534
  const targetId = typeof target?.id === "string" ? target.id : machine === identity.name ? identity.nodeId : undefined;
533
535
  if (!targetId)
534
536
  throw new Error(`Machine not found on this account: ${machine}`);
537
+ // Fail up front, with what IS there, rather than when the machine claims the Run.
538
+ if (input.agent) {
539
+ const agents = targetId === identity.nodeId ? localAgents() : await siblingAgents(targetId).catch(() => undefined);
540
+ if (agents && !agents.some((agent) => agent.id === input.agent)) {
541
+ throw new Error(`Agent "${input.agent}" is not installed on ${machine} (it has: ${agents.map((agent) => agent.id).join(", ") || "none"})`);
542
+ }
543
+ }
535
544
  return delegatedRunRequest("/node/automation-runs", {
536
545
  method: "POST",
537
546
  body: JSON.stringify({
@@ -562,21 +571,113 @@ const runDelegation = new RunDelegationService({
562
571
  const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel : identity.name;
563
572
  if (machine === identity.name)
564
573
  return delegationAnswer(sessionId);
565
- const nodes = await delegatedRunRequest("/nodes");
566
- const target = Array.isArray(nodes) ? nodes.find((node) => node && typeof node === "object" && node.name === machine) : undefined;
567
- if (typeof target?.id !== "string" || !sessionAdvertiseTarget)
574
+ const target = (await accountMachines()).find((m) => m.name === machine);
575
+ if (!target)
568
576
  return undefined;
569
- const client = new SiblingClient({ controlPlaneUrl: sessionAdvertiseTarget.controlPlaneUrl, enrollmentToken: sessionAdvertiseTarget.enrollmentToken, siblingNodeId: target.id, label: "Bivy delegation" });
577
+ const reply = await siblingRequest(target.id, { kind: "delegation.answer", sessionId });
578
+ return typeof reply.answer === "string" ? reply.answer : undefined;
579
+ },
580
+ });
581
+ /** The account's machines (id, name, online), briefly cached. */
582
+ let machinesCache;
583
+ async function accountMachines() {
584
+ if (machinesCache && Date.now() - machinesCache.at < 20_000)
585
+ return machinesCache.machines;
586
+ const nodes = await delegatedRunRequest("/nodes");
587
+ const machines = (Array.isArray(nodes) ? nodes : []).flatMap((node) => {
588
+ const n = node && typeof node === "object" ? node : {};
589
+ return typeof n.id === "string" && typeof n.name === "string" ? [{ id: n.id, name: n.name, online: n.online === true }] : [];
590
+ });
591
+ machinesCache = { at: Date.now(), machines };
592
+ return machines;
593
+ }
594
+ /** One sealed request to a co-owned machine over the relay (node as client). */
595
+ async function siblingRequest(nodeId, command, timeoutMs = 20_000) {
596
+ if (!sessionAdvertiseTarget)
597
+ throw new Error("This machine is not signed in to an account.");
598
+ const client = new SiblingClient({ controlPlaneUrl: sessionAdvertiseTarget.controlPlaneUrl, enrollmentToken: sessionAdvertiseTarget.enrollmentToken, siblingNodeId: nodeId, label: "Bivy delegation" });
599
+ try {
600
+ await client.connect();
601
+ return await client.request(command, timeoutMs);
602
+ }
603
+ finally {
604
+ client.close();
605
+ }
606
+ }
607
+ /** Agents this machine can run a delegated task with (installed ones). */
608
+ function localAgents() {
609
+ return runtimeList().filter((rt) => rt.status === "available").map((rt) => ({ id: rt.id, name: String(rt.displayName || rt.id) }));
610
+ }
611
+ async function siblingAgents(nodeId) {
612
+ const reply = await siblingRequest(nodeId, { kind: "agents.available" }, 10_000);
613
+ return Array.isArray(reply.agents) ? reply.agents.flatMap((a) => {
614
+ const agent = a && typeof a === "object" ? a : {};
615
+ return typeof agent.id === "string" ? [{ id: agent.id, name: typeof agent.name === "string" ? agent.name : agent.id }] : [];
616
+ }) : [];
617
+ }
618
+ async function machinesWithAgents() {
619
+ const machines = await accountMachines();
620
+ return Promise.all(machines.map(async (m) => {
621
+ const self = m.id === identity.nodeId;
622
+ if (self)
623
+ return { ...m, online: true, self, agents: localAgents() };
624
+ if (!m.online)
625
+ return { ...m, self };
570
626
  try {
571
- await client.connect();
572
- const reply = await client.request({ kind: "delegation.answer", sessionId }, 20_000);
573
- return typeof reply.answer === "string" ? reply.answer : undefined;
627
+ return { ...m, self, agents: await siblingAgents(m.id) };
574
628
  }
575
- finally {
576
- client.close();
629
+ catch (error) {
630
+ return { ...m, self, error: error instanceof Error ? error.message : String(error) };
577
631
  }
578
- },
579
- });
632
+ }));
633
+ }
634
+ /** Fold a delegated Run's latest status into its card in the parent session. */
635
+ function recordDelegation(parentSessionId, run, start) {
636
+ const record = resolveSession(parentSessionId);
637
+ if (!record)
638
+ return run;
639
+ const prev = eventLog.delegations(record.id).find((card) => card.id === run.runId);
640
+ const card = delegationCard(prev, run, start);
641
+ if (prev && JSON.stringify(prev) === JSON.stringify(card))
642
+ return run;
643
+ eventLog.appendDelegation(record.id, { afterMessageCount: record.session.getMessages().length, delegation: card });
644
+ broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "delegation", delegation: card } }));
645
+ if (card.childSessionId && !prev?.childSessionId)
646
+ void linkDelegatedChild(record, card).catch(() => { });
647
+ return run;
648
+ }
649
+ /** Tell the child session where it came from — locally, or on its machine. */
650
+ async function linkDelegatedChild(parent, card) {
651
+ const from = { sessionId: parent.id, nodeId: identity.nodeId, machine: identity.name, ...(parent.session.getName() ? { title: parent.session.getName().slice(0, 256) } : {}) };
652
+ if (!card.nodeId || card.nodeId === identity.nodeId) {
653
+ applyDelegationLink(card.childSessionId, from);
654
+ return;
655
+ }
656
+ await siblingRequest(card.nodeId, { kind: "delegation.link", childSessionId: card.childSessionId, parent: from }, 10_000);
657
+ }
658
+ function applyDelegationLink(childSessionId, from) {
659
+ const record = resolveSession(childSessionId);
660
+ if (!record && !metadata.getSession(childSessionId))
661
+ return false;
662
+ if (record)
663
+ record.delegatedFrom = from;
664
+ metadata.upsertSession({ id: childSessionId, delegatedFrom: from });
665
+ if (record)
666
+ broadcast({ type: "session.updated", sessionId: record.id, sessionFile: record.sessionFile, bivySession: bivySessionEnvelope(record) });
667
+ scheduleAdvertise();
668
+ return true;
669
+ }
670
+ // Children rarely finish while someone is polling them: keep the parent's open
671
+ // cards current in the background (cheap: cached, and only non-terminal ones).
672
+ setInterval(() => {
673
+ for (const record of new Set(openSessions.values())) {
674
+ for (const card of eventLog.delegations(record.id)) {
675
+ if (TERMINAL_DELEGATION.has(card.status))
676
+ continue;
677
+ void runDelegation.getRunStatus(record.id, card.id).then((run) => recordDelegation(record.id, run)).catch(() => { });
678
+ }
679
+ }
680
+ }, 15_000).unref();
580
681
  // Delegated Runs remain available through the explicit Session API, but are not
581
682
  // exposed as agent tools. Coding agents should use their own native sub-agent
582
683
  // facilities; advertising Runs here caused routine work to create noisy,
@@ -2434,6 +2535,16 @@ const RELAY_COMMANDS = {
2434
2535
  },
2435
2536
  // A co-owned node asking for a delegated child's answer over the sealed
2436
2537
  // node-to-node channel (see runDelegation.answer). Replies to the requester.
2538
+ // Discovery: which agents this machine can run (docs/agent-delegation.md).
2539
+ "agents.available"(msg, ctx) {
2540
+ ctx.reply({ type: "agents.available", requestId: msg.requestId, machine: identity.name, agents: localAgents() });
2541
+ },
2542
+ // The parent of a delegated child session on this machine introduces itself,
2543
+ // so the child can show and link where it came from.
2544
+ "delegation.link"(msg, ctx) {
2545
+ const linked = typeof msg.childSessionId === "string" && isDelegatedFrom(msg.parent) ? applyDelegationLink(msg.childSessionId, msg.parent) : false;
2546
+ ctx.reply({ type: "delegation.link", requestId: msg.requestId, linked });
2547
+ },
2437
2548
  async "delegation.answer"(msg, ctx) {
2438
2549
  const sessionId = typeof msg.sessionId === "string" ? msg.sessionId : "";
2439
2550
  const answer = sessionId ? await delegationAnswer(sessionId).catch(() => undefined) : undefined;
@@ -6384,6 +6495,7 @@ async function sessionListRows() {
6384
6495
  source: rec?.source ?? meta?.source,
6385
6496
  bivyCreated: Boolean(meta),
6386
6497
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
6498
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
6387
6499
  branch: rec?.worktree?.branch ?? meta?.branch,
6388
6500
  sandbox: rec?.sandbox ?? normalizeSandboxTier(meta?.sandbox),
6389
6501
  approvalMode: rec?.approvalMode,
@@ -6669,6 +6781,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6669
6781
  workspace: record.workspace,
6670
6782
  source: record.source ?? "manual",
6671
6783
  forkedFrom: record.forkedFrom,
6784
+ delegatedFrom: record.delegatedFrom,
6672
6785
  automationRunId: record.automationRunId,
6673
6786
  delegationDepth: record.delegationDepth,
6674
6787
  runtimeId: record.runtimeId,
@@ -8493,7 +8606,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8493
8606
  // Rehydrating (rather than leaving this undefined for a resumed session)
8494
8607
  // also avoids a persistSessionMetadata call later silently clobbering the
8495
8608
  // stored contract with undefined via its `{...prev, ...input}` merge.
8496
- const record = { id: sessionId, session, runtimeId: rt.id, credentialLabels, sandbox: sessionSandbox, approvalMode: sessionSafety.approval, automationRunId: storedMeta?.automationRunId, delegationDepth: storedMeta?.delegationDepth, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, contract: storedMeta?.contract, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral, workspaceState: runGit(["status", "--porcelain", "--untracked-files=normal"], sessionWorkspace) ? "dirty" : "clean" };
8609
+ const record = { id: sessionId, session, runtimeId: rt.id, credentialLabels, sandbox: sessionSandbox, approvalMode: sessionSafety.approval, automationRunId: storedMeta?.automationRunId, delegationDepth: storedMeta?.delegationDepth, workspace: sessionWorkspace, sessionFile: session.sessionFile, agentServiceAddress: attachedAddress ?? rt.agentServiceAddress, worktree, source, prUrl: storedMeta?.prUrl, prs: storedMeta?.prs, delegatedFrom: storedMeta?.delegatedFrom, contract: storedMeta?.contract, lastTouchedAt: resumedLastActive ?? Date.now(), warning: modelFallbackMessage, ephemeral: opts.ephemeral, workspaceState: runGit(["status", "--porcelain", "--untracked-files=normal"], sessionWorkspace) ? "dirty" : "clean" };
8497
8610
  // Migration: a session resumed/reopened from before this feature (or from a
8498
8611
  // node that predates it) has no stored contract. Stamp an honest one now
8499
8612
  // from currently-observed facts rather than leaving it blank forever or
@@ -10423,6 +10536,7 @@ app.get("/api/sessions", async (_req, res, next) => {
10423
10536
  source: rec?.source ?? meta?.source,
10424
10537
  bivyCreated: Boolean(meta),
10425
10538
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
10539
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
10426
10540
  branch: rec?.worktree?.branch ?? meta?.branch,
10427
10541
  prUrl: rec?.prUrl ?? meta?.prUrl,
10428
10542
  prs: rec?.prs ?? meta?.prs,
@@ -11339,7 +11453,12 @@ app.post("/api/session/:id/suggest", (req, res) => {
11339
11453
  // authorizes every status lookup against this parent Session's provenance.
11340
11454
  app.post("/api/session/:id/delegated-runs", async (req, res) => {
11341
11455
  try {
11342
- res.status(201).json(await runDelegation.startRun(String(req.params.id), req.body));
11456
+ const input = req.body;
11457
+ const result = await runDelegation.startRun(String(req.params.id), input);
11458
+ const machine = input.machine?.trim() || identity.name;
11459
+ const nodeId = (await accountMachines().catch(() => [])).find((m) => m.name === machine)?.id ?? (machine === identity.name ? identity.nodeId : undefined);
11460
+ recordDelegation(String(req.params.id), result, { instructions: input.instructions, agent: input.agent, machine, nodeId, group: typeof input.group === "string" ? input.group.slice(0, 64) : undefined });
11461
+ res.status(201).json(result);
11343
11462
  }
11344
11463
  catch (error) {
11345
11464
  res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11347,7 +11466,7 @@ app.post("/api/session/:id/delegated-runs", async (req, res) => {
11347
11466
  });
11348
11467
  app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11349
11468
  try {
11350
- res.json(await runDelegation.getRunStatus(String(req.params.id), String(req.params.runId)));
11469
+ res.json(recordDelegation(String(req.params.id), await runDelegation.getRunStatus(String(req.params.id), String(req.params.runId))));
11351
11470
  }
11352
11471
  catch (error) {
11353
11472
  res.status(404).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11355,7 +11474,18 @@ app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11355
11474
  });
11356
11475
  app.post("/api/session/:id/delegated-runs/:runId/wait", async (req, res) => {
11357
11476
  try {
11358
- res.json(await runDelegation.waitForRun(String(req.params.id), String(req.params.runId), Number(req.body?.timeoutSeconds), AbortSignal.timeout(305_000)));
11477
+ res.json(recordDelegation(String(req.params.id), await runDelegation.waitForRun(String(req.params.id), String(req.params.runId), Number(req.body?.timeoutSeconds), AbortSignal.timeout(305_000))));
11478
+ }
11479
+ catch (error) {
11480
+ res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
11481
+ }
11482
+ });
11483
+ // The account's machines and the agents installed on each (asked of every
11484
+ // online machine over the sealed node-to-node channel), for `bivy delegate
11485
+ // machines` and for picking a valid agent @ machine up front.
11486
+ app.get("/api/machines", async (_req, res) => {
11487
+ try {
11488
+ res.json({ machines: await machinesWithAgents() });
11359
11489
  }
11360
11490
  catch (error) {
11361
11491
  res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Pure: fold a delegated Run's latest status into its card in the parent
5
+ // session (docs/agent-delegation.md). The server logs and broadcasts the result.
6
+ import { MAX_DELEGATION_ANSWER, MAX_DELEGATION_TASK } from "./delegations.js";
7
+ const bound = (value, max) => value && value.length > max ? `${value.slice(0, max - 1)}…` : value;
8
+ export function delegationCard(prev, run, start) {
9
+ const refs = run.references ?? {};
10
+ const task = start ? bound(start.instructions.trim().split(/\r?\n/, 1)[0] ?? "", MAX_DELEGATION_TASK) : prev?.task ?? "";
11
+ const pick = (key, next) => (next ?? prev?.[key]);
12
+ const card = { id: run.runId, status: run.status, task };
13
+ const fields = {
14
+ agent: pick("agent", start?.agent),
15
+ machine: pick("machine", start?.machine),
16
+ nodeId: pick("nodeId", start?.nodeId),
17
+ group: pick("group", start?.group),
18
+ childSessionId: pick("childSessionId", refs.sessionId),
19
+ answer: pick("answer", bound(run.answer, MAX_DELEGATION_ANSWER)),
20
+ branch: pick("branch", refs.branch),
21
+ prUrl: pick("prUrl", refs.prUrl),
22
+ failure: pick("failure", refs.failure),
23
+ };
24
+ for (const [key, value] of Object.entries(fields))
25
+ if (value)
26
+ card[key] = value;
27
+ return card;
28
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ export const DELEGATION_BLOCK = "bivy_delegation";
4
+ export const MAX_DELEGATION_TASK = 200;
5
+ export const MAX_DELEGATION_ANSWER = 8000;
6
+ export const TERMINAL_DELEGATION = new Set(["succeeded", "failed", "cancelled"]);
7
+ const optionalString = (v) => v === undefined || typeof v === "string";
8
+ export function isDelegationCard(value) {
9
+ if (!value || typeof value !== "object")
10
+ return false;
11
+ const d = value;
12
+ return typeof d.id === "string" && d.id.length > 0 && typeof d.status === "string" && typeof d.task === "string"
13
+ && [d.agent, d.machine, d.nodeId, d.group, d.childSessionId, d.answer, d.branch, d.prUrl, d.failure].every(optionalString);
14
+ }
15
+ export function isDelegatedFrom(value) {
16
+ if (!value || typeof value !== "object")
17
+ return false;
18
+ const d = value;
19
+ return typeof d.sessionId === "string" && d.sessionId.length > 0 && d.sessionId.length <= 256
20
+ && [d.nodeId, d.machine, d.title].every((v) => v === undefined || (typeof v === "string" && v.length <= 256));
21
+ }
@@ -29,6 +29,7 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
32
+ import { DELEGATION_BLOCK, isDelegationCard } from "./delegations.js";
32
33
  import { APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppReference, isAppReview } from "../apps/types.js";
33
34
  import { SUGGESTION_BLOCK, isTaskSuggestion } from "./suggestions.js";
34
35
  /**
@@ -254,6 +255,12 @@ function isSuggestionEntry(value) {
254
255
  const entry = value;
255
256
  return entry.bivyKind === "suggestion" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isTaskSuggestion(entry.suggestion);
256
257
  }
258
+ function isDelegationEntry(value) {
259
+ if (!value || typeof value !== "object")
260
+ return false;
261
+ const entry = value;
262
+ return entry.bivyKind === "delegation" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isDelegationCard(entry.delegation);
263
+ }
257
264
  function isAppReviewEntry(value) {
258
265
  if (!value || typeof value !== "object")
259
266
  return false;
@@ -261,7 +268,7 @@ function isAppReviewEntry(value) {
261
268
  return entry.bivyKind === "app-review" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReview(entry.review);
262
269
  }
263
270
  function isRecord(value) {
264
- return isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isSuggestionEntry(value);
271
+ return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isSuggestionEntry(value);
265
272
  }
266
273
  /**
267
274
  * Fold attachment records into a text→refs list: last write wins per text (a
@@ -383,11 +390,23 @@ export function replayExtras(entries) {
383
390
  role: "assistant", content: [{ type: APP_REVIEW_BLOCK, review: entry.review }],
384
391
  id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
385
392
  }));
393
+ // A delegation card keeps the place it was created and shows its latest state.
394
+ const delegations = new Map();
395
+ for (const entry of entries) {
396
+ if (entry.bivyKind !== "delegation")
397
+ continue;
398
+ const first = delegations.get(entry.id);
399
+ delegations.set(entry.id, first ? { ...first, delegation: entry.delegation } : entry);
400
+ }
401
+ const delegationCards = [...delegations.values()].map((entry) => ({
402
+ role: "assistant", content: [{ type: DELEGATION_BLOCK, delegation: entry.delegation }],
403
+ id: `delegation-${entry.id}`, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
404
+ }));
386
405
  const suggestions = entries.filter((entry) => entry.bivyKind === "suggestion").map((entry) => ({
387
406
  role: "assistant", content: [{ type: SUGGESTION_BLOCK, suggestion: entry.suggestion }],
388
407
  id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
389
408
  }));
390
- return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...suggestions];
409
+ return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...delegationCards, ...suggestions];
391
410
  }
392
411
  /**
393
412
  * Fold the outbound (agent-sent) attachment records into time-anchored synthetic
@@ -683,6 +702,19 @@ export class EventLog {
683
702
  this.enqueue(id, `app:${entry.id}`, { ...entry, app: { ...entry.app }, bivyKind: "app-publication", createdAt: Date.now() });
684
703
  }
685
704
  /** `createdAt`/`afterMessageCount` place the card; an expired card keeps its original place. */
705
+ /** Record a delegation card's latest state; replay keeps its first position. */
706
+ appendDelegation(id, entry) {
707
+ this.load(id);
708
+ this.enqueue(id, this.syntheticKey(id), { bivyKind: "delegation", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.delegation.id, delegation: structuredClone(entry.delegation) });
709
+ }
710
+ /** The delegation cards this session holds, latest state each. */
711
+ delegations(id) {
712
+ const byId = new Map();
713
+ for (const entry of this.entries(id))
714
+ if (entry.bivyKind === "delegation")
715
+ byId.set(entry.id, entry.delegation);
716
+ return [...byId.values()];
717
+ }
686
718
  appendAppReview(id, entry) {
687
719
  this.load(id);
688
720
  this.enqueue(id, `review:${entry.review.id}`, { bivyKind: "app-review", createdAt: entry.createdAt ?? Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.review.id, review: structuredClone(entry.review) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.11-staging.8",
3
+ "version": "0.18.11-staging.9",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",