@bivy/bivy 0.18.11-staging.7 → 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/acp-shim.mjs CHANGED
@@ -136,6 +136,11 @@ 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();
@@ -369,19 +374,25 @@ function publishModels(result) {
369
374
  // ACP model ids are `provider/model`; split the provider so Bivy can group and
370
375
  // scope provider-specific settings the same way it does for other runtimes.
371
376
  .map((m) => ({ ...m, provider: m.id.includes("/") ? m.id.split("/")[0] : "agent" }));
372
- if (!models.length) return;
373
- 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);
374
384
  bivy({
375
385
  type: "runtime.models",
376
386
  models,
377
387
  ...(modelOption?.currentValue ? { currentModel: String(modelOption.currentValue) } : {}),
388
+ ...(levels.length ? { thinking: { levels, ...(thoughtOption?.currentValue ? { current: String(thoughtOption.currentValue) } : {}) } } : {}),
378
389
  });
379
390
  }
380
391
 
381
392
  // --- ACP → bivy: streamed session/update notifications ----------------------
382
393
  function onSessionUpdate(params) {
383
394
  const u = params?.update;
384
- if (!u || typeof u !== "object") return;
395
+ if (!u || typeof u !== "object" || replayingHistory) return;
385
396
  // Any update arriving while the turn is draining means the agent is still
386
397
  // emitting the tail of this turn (see the drain note above) — reset the quiet
387
398
  // window so session.done waits for it instead of sealing history short.
@@ -600,7 +611,14 @@ async function onBivyCommand(msg) {
600
611
  // hanging the reopen with no watchdog to recover it (resume isn't a
601
612
  // "working" turn). On timeout/failure we fall back to a fresh session so
602
613
  // the chat still opens instead of spinning on "Fetching transcript…".
603
- 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; }
604
622
  sessionId = res?.sessionId ?? ref;
605
623
  publishModels(res);
606
624
  } catch (error) {
@@ -683,6 +701,15 @@ async function onBivyCommand(msg) {
683
701
  bivy({ replyTo: id, ok: true });
684
702
  return;
685
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
+ }
686
713
  case "session.abort": {
687
714
  // A pending drain must not fire session.done after the user cancelled —
688
715
  // the turn is being torn down, not finishing on its own.
package/bin/bivy.mjs CHANGED
@@ -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 = []) {
@@ -5468,6 +5587,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
5468
5587
  case "suggest":
5469
5588
  await cmdSuggest(args);
5470
5589
  break;
5590
+ case "delegate":
5591
+ await cmdDelegate(args);
5592
+ break;
5471
5593
  case "completions":
5472
5594
  case "completion":
5473
5595
  cmdCompletions(args);
@@ -42,6 +42,10 @@ export const BIVY_AGENT_NOTE = [
42
42
  '`bivy suggest "<complete instruction>" [--title "short label"]` instead of only listing them. The user can ' +
43
43
  "start each in one tap, in this session or in a parallel one that works in its own copy of the project, so " +
44
44
  "write it to stand on its own, with paths relative to the project root.",
45
+ "- Only when the user asks for another agent or another of their machines to take part: " +
46
+ '`bivy delegate "<self-contained task>" --agent <id> [--machine <name>] --wait` runs it there and prints ' +
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.",
45
49
  ].join("\n");
46
50
  const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
47
51
  "instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
@@ -1294,6 +1294,17 @@ class ClaudeSession {
1294
1294
  // falls through so its capabilities/commands reach the client.)
1295
1295
  if (message?.subtype === "task_notification" || message?.subtype === "compact_boundary")
1296
1296
  break;
1297
+ // Banners Claude Code shows the user: a hook's block reason, slash-command
1298
+ // output, warnings (SDK 0.3.283+). Show them as the chat's system notice
1299
+ // and keep them in the transcript; "info" is transcript-mode-only upstream.
1300
+ if (message?.subtype === "informational") {
1301
+ const text = typeof message.content === "string" ? message.content.trim() : "";
1302
+ if (text && message.level !== "info") {
1303
+ this.messages.push({ role: "system", content: text, timestamp: Date.now() });
1304
+ this.emit({ type: "session.notice", level: message.level === "warning" ? "warning" : "info", message: text });
1305
+ }
1306
+ break;
1307
+ }
1297
1308
  this.emit({ type: String(message?.type ?? "unknown"), raw: message });
1298
1309
  break;
1299
1310
  }
@@ -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; }
@@ -1193,7 +1196,9 @@ button.sheet-action:last-child { border-bottom: 0; }
1193
1196
  margin: 0;
1194
1197
  max-height: calc(var(--app-h, 100dvh) - (2 * var(--space-5)));
1195
1198
  }
1196
- .sheet-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); flex: none; touch-action: none; }
1199
+ /* Lifted above the sheet body so a menu opened from the header (the model
1200
+ picker's reasoning menu) layers over the body's sticky search field. */
1201
+ .sheet-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); flex: none; touch-action: none; position: relative; z-index: calc(var(--z-sticky) + 1); }
1197
1202
  .sheet-title { font-weight: 700; flex: 1; }
1198
1203
  .sheet-close { border: 0; background: transparent; color: var(--muted); font-size: 22px; line-height: 1; min-width: var(--space-7); min-height: var(--space-7); }
1199
1204
  .sheet-close:active { transform: scale(0.9); }
@@ -3105,6 +3110,26 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3105
3110
  .suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
3106
3111
  .suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
3107
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; }
3108
3133
  /* Review card (ReviewCard.tsx): the app at a moment worth judging. The
3109
3134
  screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
3110
3135
  canonical .segmented switch; one primary action. */
package/dist/run-tools.js CHANGED
@@ -8,6 +8,8 @@ export const RUN_TOOL_LIMITS = {
8
8
  maxWaitSeconds: 300,
9
9
  minPollMs: 1_000,
10
10
  maxIdempotencyKey: 128,
11
+ /** The child's final answer returned to the parent (characters). */
12
+ maxAnswer: 8_000,
11
13
  };
12
14
  const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
13
15
  const STATUSES = new Set(["pending", "claimed", "running", "waiting", "needs_attention", "succeeded", "failed", "cancelled"]);
@@ -107,6 +109,12 @@ export class RunDelegationService {
107
109
  if (!raw || provenance?.parentSessionId !== sessionId)
108
110
  throw new Error("delegated Run not found");
109
111
  const value = safeRun(raw, provenance);
112
+ if (TERMINAL.has(value.status) && this.backend.answer) {
113
+ // Best-effort: an unreachable machine still leaves status + references.
114
+ const answer = cached?.value.answer ?? await this.backend.answer(raw).catch(() => undefined);
115
+ if (answer)
116
+ value.answer = answer.length > RUN_TOOL_LIMITS.maxAnswer ? `${answer.slice(0, RUN_TOOL_LIMITS.maxAnswer - 1)}…` : answer;
117
+ }
110
118
  this.cache.set(`${sessionId}:${runId}`, { at: this.now(), value });
111
119
  return value;
112
120
  }
@@ -274,6 +274,32 @@ class ProtocolSession {
274
274
  * applied to the spawned child and reused by the per-turn preflight. */
275
275
  prepareEnv = {};
276
276
  getModels() { return this.models; }
277
+ // Reasoning-effort levels published by the shim (see runtime.models); empty
278
+ // when the agent has none, which keeps the picker hidden.
279
+ thinkingLevels = [];
280
+ thinkingLevel;
281
+ thinkingChosen = false;
282
+ supportsThinking() { return this.thinkingLevels.length > 0; }
283
+ getAvailableThinkingLevels() { return this.thinkingLevels; }
284
+ getThinkingLevel() { return this.thinkingLevel; }
285
+ setThinkingLevel(level) {
286
+ const next = level.trim();
287
+ if (!this.thinkingLevels.includes(next))
288
+ return;
289
+ this.thinkingLevel = next;
290
+ this.thinkingChosen = true;
291
+ this.sendThinkingLevel(next);
292
+ }
293
+ /** Fire-and-forget like the pipe runtimes' flag. With no live agent yet the
294
+ * choice is kept and sent once the agent publishes its levels. */
295
+ sendThinkingLevel(level) {
296
+ try {
297
+ void this.command("thinking.set", { sessionId: this.id, level }).catch(() => { });
298
+ }
299
+ catch {
300
+ // Agent not running; runtime.models re-applies the kept choice.
301
+ }
302
+ }
277
303
  getCurrentModel() {
278
304
  if (!this.currentModelId)
279
305
  return undefined;
@@ -622,6 +648,23 @@ class ProtocolSession {
622
648
  if (typeof msg.currentModel === "string")
623
649
  this.currentModelId = msg.currentModel;
624
650
  }
651
+ // Reasoning-effort levels the agent advertises (an ACP `thought_level`
652
+ // config option), driven by the same picker as the pipe runtimes' flags.
653
+ const thinking = msg.thinking;
654
+ const levels = Array.isArray(thinking?.levels) ? thinking.levels.filter((l) => typeof l === "string" && Boolean(l)) : [];
655
+ if (levels.length) {
656
+ this.thinkingLevels = levels;
657
+ // A level picked before the agent was up is applied now; otherwise adopt
658
+ // the agent's own current level.
659
+ if (this.thinkingChosen && this.thinkingLevel && levels.includes(this.thinkingLevel))
660
+ this.sendThinkingLevel(this.thinkingLevel);
661
+ else if (typeof thinking?.current === "string")
662
+ this.thinkingLevel = thinking.current;
663
+ }
664
+ // The catalog arrived after the client asked for it (it lands with the
665
+ // agent's session): let the node push the fresh model/effort picker.
666
+ if (models.length || levels.length)
667
+ this.emit({ type: "runtime.models" });
625
668
  return;
626
669
  }
627
670
  if (type === "message.delta") {
@@ -103,6 +103,27 @@ const DELEGATE = new Set([
103
103
  "subagentactivity",
104
104
  ]);
105
105
  const PATH_KEYS = ["path", "file_path", "filePath", "filename", "fileName", "file", "target_file", "targetFile"];
106
+ /**
107
+ * `bivy delegate "<task>" --agent codex --machine mac` run from any agent's
108
+ * shell hands work to another agent/machine (docs/agent-delegation.md). Show it
109
+ * as the delegation it is — "Delegated · codex @ mac" — not as a shell command.
110
+ * `status` / `wait` follow-ups stay ordinary commands.
111
+ */
112
+ function bivyDelegation(command) {
113
+ const match = /^\s*bivy\s+delegate\b(.*)$/s.exec(command);
114
+ if (!match || /^\s+(status|wait|machines|--help|-h)\b/.test(match[1]))
115
+ return undefined;
116
+ const rest = match[1];
117
+ const flag = (name) => new RegExp(`--${name}(?:=|\\s+)("([^"]*)"|'([^']*)'|(\\S+))`).exec(rest);
118
+ const value = (m) => m ? (m[2] ?? m[3] ?? m[4]) : undefined;
119
+ const agent = value(flag("agent"));
120
+ const machine = value(flag("machine"));
121
+ const quoted = /(?:^|\s)("([^"]+)"|'([^']+)')/.exec(rest.replace(/--[a-z-]+(?:=|\s+)("[^"]*"|'[^']*'|\S+)/g, ""));
122
+ const description = quoted ? (quoted[2] ?? quoted[3]) : 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;
125
+ return { ...(label ? { label } : {}), ...(description ? { description } : {}) };
126
+ }
106
127
  /**
107
128
  * Classify one tool call into a normalized ToolCallDetail, or undefined when it
108
129
  * doesn't map to a known kind (the caller then leaves the tool block opaque).
@@ -114,6 +135,9 @@ export function mapToolCall(toolName, input, context = {}) {
114
135
  const o = asRecord(input);
115
136
  if (inToolSet(SHELL, toolName, key)) {
116
137
  const command = str(o, "command", "cmd", "script", "input", "args");
138
+ const delegated = command ? bivyDelegation(command) : undefined;
139
+ if (delegated)
140
+ return decorate({ kind: "delegation", ...delegated }, toolName, input, context);
117
141
  return command ? decorate({ kind: "shell", command, ...(str(o, "cwd", "workdir", "workingDir", "directory") ? { cwd: str(o, "cwd", "workdir", "workingDir", "directory") } : {}) }, toolName, input, context) : undefined;
118
142
  }
119
143
  if (inToolSet(EDIT, toolName, key)) {
package/dist/server.js CHANGED
@@ -144,6 +144,7 @@ import { attachmentsFrom, materializeAttachments, sanitizeAttachmentFilename } f
144
144
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
145
145
  import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
146
146
  import { ReplicationService } from "./session/replication-service.js";
147
+ import { SiblingClient } from "./session/sibling-client.js";
147
148
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
148
149
  import { computeSessionContract } from "./session/session-contract.js";
149
150
  import { SecretVault, resolveSecret } from "./secrets.js";
@@ -160,6 +161,8 @@ import { synthesizeOpenAiSpeech } from "./tts.js";
160
161
  import { seal, open } from "./e2e.js";
161
162
  import { recoverModelAuthKey } from "./model-auth-key-recovery.js";
162
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";
163
166
  import { ControlPlaneTaskPoller, resolveControlPlaneTaskConfig, } from "./control-plane-tasks.js";
164
167
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
165
168
  const repoRoot = path.resolve(__dirname, "..");
@@ -531,6 +534,13 @@ const runDelegation = new RunDelegationService({
531
534
  const targetId = typeof target?.id === "string" ? target.id : machine === identity.name ? identity.nodeId : undefined;
532
535
  if (!targetId)
533
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
+ }
534
544
  return delegatedRunRequest("/node/automation-runs", {
535
545
  method: "POST",
536
546
  body: JSON.stringify({
@@ -550,7 +560,124 @@ const runDelegation = new RunDelegationService({
550
560
  }),
551
561
  });
552
562
  },
563
+ // The child's reply is read from the machine that ran it — locally, or over
564
+ // the sealed node-to-node relay channel. It never travels through the Run queue.
565
+ answer: async (raw) => {
566
+ const output = raw.output && typeof raw.output === "object" ? raw.output : {};
567
+ const sessionId = typeof output.sessionId === "string" ? output.sessionId : "";
568
+ if (!sessionId)
569
+ return undefined;
570
+ const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {};
571
+ const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel : identity.name;
572
+ if (machine === identity.name)
573
+ return delegationAnswer(sessionId);
574
+ const target = (await accountMachines()).find((m) => m.name === machine);
575
+ if (!target)
576
+ return undefined;
577
+ const reply = await siblingRequest(target.id, { kind: "delegation.answer", sessionId });
578
+ return typeof reply.answer === "string" ? reply.answer : undefined;
579
+ },
553
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 };
626
+ try {
627
+ return { ...m, self, agents: await siblingAgents(m.id) };
628
+ }
629
+ catch (error) {
630
+ return { ...m, self, error: error instanceof Error ? error.message : String(error) };
631
+ }
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();
554
681
  // Delegated Runs remain available through the explicit Session API, but are not
555
682
  // exposed as agent tools. Coding agents should use their own native sub-agent
556
683
  // facilities; advertising Runs here caused routine work to create noisy,
@@ -2406,6 +2533,23 @@ const RELAY_COMMANDS = {
2406
2533
  if (record)
2407
2534
  replayPendingInteractions(record.id);
2408
2535
  },
2536
+ // A co-owned node asking for a delegated child's answer over the sealed
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
+ },
2548
+ async "delegation.answer"(msg, ctx) {
2549
+ const sessionId = typeof msg.sessionId === "string" ? msg.sessionId : "";
2550
+ const answer = sessionId ? await delegationAnswer(sessionId).catch(() => undefined) : undefined;
2551
+ ctx.reply({ type: "delegation.answer", requestId: msg.requestId, sessionId, ...(answer ? { answer } : {}) });
2552
+ },
2409
2553
  async "sessions.list"() {
2410
2554
  // Relay clients request the list on every connect/reconnect. Replay even an
2411
2555
  // empty update state so a banner from the previous daemon is cleared.
@@ -5162,6 +5306,30 @@ async function waitForSessionIdle(record, capMs = 2 * 60 * 60 * 1000) {
5162
5306
  await new Promise((resolve) => setTimeout(resolve, 2_000));
5163
5307
  }
5164
5308
  }
5309
+ /**
5310
+ * A delegated child's result for its parent (docs/agent-delegation.md): the
5311
+ * session's final assistant reply, from the node's own transcript. Opened from
5312
+ * disk when the session isn't live. Undefined for an unknown session.
5313
+ */
5314
+ async function delegationAnswer(sessionId) {
5315
+ let record = resolveSession(sessionId);
5316
+ if (!record) {
5317
+ const summary = (await listAllSessions()).find((s) => s.id === sessionId);
5318
+ if (!summary?.path)
5319
+ return undefined;
5320
+ record = await createSession(defaultWorkspace, summary.path, { runtimeId: summary.agent, makeActive: false });
5321
+ }
5322
+ const messages = transcripts.forkMessages(record);
5323
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
5324
+ const message = messages[i];
5325
+ if (message?.role !== "assistant")
5326
+ continue;
5327
+ const text = runtimeContentText(message.content).trim();
5328
+ if (text)
5329
+ return text;
5330
+ }
5331
+ return undefined;
5332
+ }
5165
5333
  /** Text of the most recent user message in a session's transcript, if any. */
5166
5334
  function lastUserMessageText(record) {
5167
5335
  const messages = record.session.getMessages();
@@ -6327,6 +6495,7 @@ async function sessionListRows() {
6327
6495
  source: rec?.source ?? meta?.source,
6328
6496
  bivyCreated: Boolean(meta),
6329
6497
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
6498
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
6330
6499
  branch: rec?.worktree?.branch ?? meta?.branch,
6331
6500
  sandbox: rec?.sandbox ?? normalizeSandboxTier(meta?.sandbox),
6332
6501
  approvalMode: rec?.approvalMode,
@@ -6612,6 +6781,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6612
6781
  workspace: record.workspace,
6613
6782
  source: record.source ?? "manual",
6614
6783
  forkedFrom: record.forkedFrom,
6784
+ delegatedFrom: record.delegatedFrom,
6615
6785
  automationRunId: record.automationRunId,
6616
6786
  delegationDepth: record.delegationDepth,
6617
6787
  runtimeId: record.runtimeId,
@@ -7789,6 +7959,11 @@ function attachSessionListeners(record) {
7789
7959
  // sheet for the right provider alongside the inline error bubble.
7790
7960
  maybeSignalAuthRequired(record, String(event.error ?? ""));
7791
7961
  }
7962
+ if (event.type === "runtime.models") {
7963
+ // A protocol agent published its model/effort catalog late (with its
7964
+ // session). Push it so an open picker fills in without a refresh.
7965
+ void modelsListEventFor(record).then((listed) => broadcast(listed)).catch(() => { });
7966
+ }
7792
7967
  if (event.type === "runtime.commands") {
7793
7968
  // The agent learned its own slash commands mid-session (e.g. Claude Code's
7794
7969
  // system/init reports slash_commands only after the first turn starts).
@@ -8431,7 +8606,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8431
8606
  // Rehydrating (rather than leaving this undefined for a resumed session)
8432
8607
  // also avoids a persistSessionMetadata call later silently clobbering the
8433
8608
  // stored contract with undefined via its `{...prev, ...input}` merge.
8434
- 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" };
8435
8610
  // Migration: a session resumed/reopened from before this feature (or from a
8436
8611
  // node that predates it) has no stored contract. Stamp an honest one now
8437
8612
  // from currently-observed facts rather than leaving it blank forever or
@@ -10361,6 +10536,7 @@ app.get("/api/sessions", async (_req, res, next) => {
10361
10536
  source: rec?.source ?? meta?.source,
10362
10537
  bivyCreated: Boolean(meta),
10363
10538
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
10539
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
10364
10540
  branch: rec?.worktree?.branch ?? meta?.branch,
10365
10541
  prUrl: rec?.prUrl ?? meta?.prUrl,
10366
10542
  prs: rec?.prs ?? meta?.prs,
@@ -11277,7 +11453,12 @@ app.post("/api/session/:id/suggest", (req, res) => {
11277
11453
  // authorizes every status lookup against this parent Session's provenance.
11278
11454
  app.post("/api/session/:id/delegated-runs", async (req, res) => {
11279
11455
  try {
11280
- 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);
11281
11462
  }
11282
11463
  catch (error) {
11283
11464
  res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11285,7 +11466,7 @@ app.post("/api/session/:id/delegated-runs", async (req, res) => {
11285
11466
  });
11286
11467
  app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11287
11468
  try {
11288
- 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))));
11289
11470
  }
11290
11471
  catch (error) {
11291
11472
  res.status(404).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11293,7 +11474,18 @@ app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11293
11474
  });
11294
11475
  app.post("/api/session/:id/delegated-runs/:runId/wait", async (req, res) => {
11295
11476
  try {
11296
- 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() });
11297
11489
  }
11298
11490
  catch (error) {
11299
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.7",
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.",