@bivy/bivy 0.18.11-staging.8 → 0.19.0-staging.1

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
@@ -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";
@@ -2466,17 +2466,26 @@ async function cmdSuggest(args = []) {
2466
2466
  // self-contained task from this session to another agent, optionally on another
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
- const DELEGATE_USAGE = `Usage: bivy delegate "<task>" [--agent <id>] [--machine <name>] [--model <model>] [--repo owner/repo] [--wait [seconds]] [--json]
2469
+ const DELEGATE_USAGE = `Usage: bivy delegate "<task>" [--agent <id>] [--machine <name>] [--model <model>] [--repo owner/repo] [--approval never|risky|always|autonomous] [--sandbox <tier>] [--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", "--approval", "--sandbox"]);
2484
+ // Children inherit this session's approval/sandbox unless set here.
2485
+ const safetyFlags = () => {
2486
+ const safety = { approval: flag("--approval"), sandbox: flag("--sandbox") };
2487
+ return safety.approval || safety.sandbox ? { safety } : {};
2488
+ };
2480
2489
  const flag = (name) => {
2481
2490
  const i = args.indexOf(name);
2482
2491
  return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
@@ -2502,7 +2511,8 @@ async function cmdDelegate(args = []) {
2502
2511
  if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
2503
2512
  return data;
2504
2513
  };
2505
- const terminal = (run) => ["succeeded", "failed", "cancelled"].includes(run?.status);
2514
+ // needs_attention = parked by the machine's policy; it won't move on its own.
2515
+ const terminal = (run) => ["succeeded", "failed", "cancelled", "needs_attention"].includes(run?.status);
2506
2516
  // The node caps one wait at 300s; loop until the child ends or our budget does.
2507
2517
  const waitFor = async (runId, seconds) => {
2508
2518
  const deadline = Date.now() + seconds * 1000;
@@ -2525,11 +2535,46 @@ async function cmdDelegate(args = []) {
2525
2535
  };
2526
2536
  try {
2527
2537
  const [action, runId] = positional;
2538
+ if (action === "machines" && positional.length === 1) {
2539
+ const res = await fetch(`${url(config)}/api/machines`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
2540
+ const data = await res.json().catch(() => ({}));
2541
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
2542
+ if (json) { console.log(JSON.stringify(data.machines, null, 2)); return; }
2543
+ for (const m of data.machines || []) {
2544
+ const agents = m.agents ? m.agents.map((a) => a.id).join(", ") || "none" : m.error ? `unknown (${m.error})` : "unknown while offline";
2545
+ console.log(`${m.name}${m.self ? " (this machine)" : ""} — ${m.online ? "online" : "offline"} — agents: ${agents}`);
2546
+ }
2547
+ return;
2548
+ }
2528
2549
  if (action === "status" && runId) return print(await call(`/${encodeURIComponent(runId)}`));
2529
2550
  if (action === "wait" && runId) return print(await waitFor(runId, Number(flag("--timeout") ?? 600)));
2530
2551
  const instructions = positional.join(" ").trim();
2531
2552
  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") });
2553
+ // Fan-out: the same task to several agent@machine targets, one group, so the
2554
+ // parent's transcript shows them side by side to compare.
2555
+ const to = flag("--to");
2556
+ if (to) {
2557
+ const targets = to.split(",").map((t) => t.trim()).filter(Boolean).map((t) => {
2558
+ const [agent, machine] = t.split("@");
2559
+ return { agent: agent ? governedChatAgentId(agent) : undefined, machine: machine || undefined, label: t };
2560
+ });
2561
+ const group = `fanout-${Date.now().toString(36)}`;
2562
+ const runs = [];
2563
+ for (const target of targets) {
2564
+ runs.push({ target, run: await call("", { instructions, agent: target.agent, machine: target.machine, model: flag("--model"), repo: flag("--repo"), ...safetyFlags(), group }) });
2565
+ }
2566
+ if (waitIndex < 0) {
2567
+ if (json) console.log(JSON.stringify(runs.map((r) => r.run), null, 2));
2568
+ else for (const { target, run } of runs) console.log(`${target.label}: run ${run.runId} (${run.status})`);
2569
+ return;
2570
+ }
2571
+ const finished = await Promise.all(runs.map(async ({ target, run }) => ({ target, run: await waitFor(run.runId, Number(waitArg ?? 600)) })));
2572
+ if (json) { console.log(JSON.stringify(finished.map((f) => f.run), null, 2)); return; }
2573
+ for (const { target, run } of finished) { console.log(`\n── ${target.label}`); print(run); }
2574
+ return;
2575
+ }
2576
+ const agent = flag("--agent");
2577
+ const started = await call("", { instructions, agent: agent ? governedChatAgentId(agent) : undefined, machine: flag("--machine"), model: flag("--model"), repo: flag("--repo"), ...safetyFlags() });
2533
2578
  if (waitIndex < 0) {
2534
2579
  if (!json) console.log(`Delegated as run ${started.runId} (${started.status}). Follow it with 'bivy delegate wait ${started.runId}'.`);
2535
2580
  else print(started);
@@ -3129,6 +3174,11 @@ ${c.bold("bivy prune")} — remove old sessions, --clone workspaces, and git wor
3129
3174
  is never pruned while the node is running, regardless of --keep/--older-than —
3130
3175
  only idle worktrees are removed. If the node is down, nothing is live to guard.
3131
3176
 
3177
+ A session and its worktree are removed together: pruning a worktree also
3178
+ removes the sessions that ran in it, pruning a session also removes its
3179
+ worktree (once no remaining session uses it), and sessions whose worktree is
3180
+ already gone are removed — so no session is left pointing at a deleted checkout.
3181
+
3132
3182
  Paths (default to the installed node)
3133
3183
  --data-dir <dir> node data dir (default: this install's .bivy, or $BIVY_DATA_DIR)
3134
3184
  --workspace <dir> also scan this workspace for worktrees (default: configured workspace)
@@ -3196,12 +3246,41 @@ async function cmdPrune(args = []) {
3196
3246
  const now = Date.now();
3197
3247
  const plan = [];
3198
3248
  let worktreeGuard = { reachable: false, protected: 0 };
3199
- if (doSessions) {
3200
- // Sessions live in the metadata index (all agents) plus each agent's own
3201
- // transcript store — not just .bivy/pi/sessions. Select from metadata so
3202
- // shim/native (Claude Code, Codex, …) sessions are actually covered; the old
3203
- // pi-sessions-only scan silently no-oped for every terminal-started session.
3204
- const staleSessions = selectStaleSessions(loadMetadataSessions(dataDir).sessions, keepFor("sessions"), ageMs, now);
3249
+ // Sessions live in the metadata index (all agents) plus each agent's own
3250
+ // transcript store — not just .bivy/pi/sessions. Select from metadata so
3251
+ // shim/native (Claude Code, Codex, …) sessions are actually covered; the old
3252
+ // pi-sessions-only scan silently no-oped for every terminal-started session.
3253
+ const allSessions = loadMetadataSessions(dataDir).sessions;
3254
+ const selectedSessions = doSessions ? selectStaleSessions(allSessions, keepFor("sessions"), ageMs, now) : [];
3255
+ // Worktrees: keep the newest N overall (across all roots), matching the node
3256
+ // prune script. Scan roots: the data dir plus the configured workspace repo.
3257
+ const worktreeRoots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
3258
+ const staleWorktrees = doWorktrees ? selectStale(worktreeRoots.flatMap((r) => pruneListEntries(r, "dir")), keepFor("worktrees"), ageMs, now) : [];
3259
+ // Guard: never delete a worktree a live agent is using. Ask the daemon what's
3260
+ // live (it owns every runtime and PTY); protected worktrees drop out of the
3261
+ // removal set entirely, so an aggressive --older-than can't nuke a running
3262
+ // agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
3263
+ const live = await liveWorktreePaths(config);
3264
+ const isLive = (p) => isLiveWorktree(p, live.paths);
3265
+ const idleWorktrees = staleWorktrees.filter((e) => !isLive(e.path));
3266
+ worktreeGuard = { reachable: live.reachable, protected: staleWorktrees.length - idleWorktrees.length };
3267
+ // A session and its worktree go together: removing one without the other left
3268
+ // sessions pointing at a deleted checkout (or checkouts nobody can reach).
3269
+ const linked = linkSessionWorktrees({
3270
+ sessions: allSessions,
3271
+ staleSessions: selectedSessions,
3272
+ staleWorktrees: idleWorktrees.map((e) => e.path),
3273
+ isLive,
3274
+ exists: (p) => fs.existsSync(p),
3275
+ });
3276
+ const staleSessions = linked.sessions;
3277
+ const worktreeMtime = new Map(idleWorktrees.map((e) => [path.resolve(e.path), e.mtimeMs]));
3278
+ const worktreesToRemove = linked.worktrees.map((p) => {
3279
+ let mtimeMs = worktreeMtime.get(p);
3280
+ if (mtimeMs === undefined) { try { mtimeMs = fs.statSync(p).mtimeMs; } catch { mtimeMs = now; } }
3281
+ return { path: p, mtimeMs };
3282
+ }).sort((a, b) => b.mtimeMs - a.mtimeMs);
3283
+ if (doSessions || staleSessions.length) {
3205
3284
  plan.push({
3206
3285
  kind: "sessions",
3207
3286
  root: metadataFilePath(dataDir),
@@ -3224,20 +3303,8 @@ async function cmdPrune(args = []) {
3224
3303
  const dir = path.join(dataDir, "workspaces");
3225
3304
  plan.push({ kind: "workspaces", root: dir, remove: selectStale(pruneListEntries(dir, "dir"), keepFor("workspaces"), ageMs, now) });
3226
3305
  }
3227
- if (doWorktrees) {
3228
- // Keep the newest N worktrees overall (across all roots), matching the node
3229
- // prune script. Scan roots: the data dir plus the configured workspace repo.
3230
- const roots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
3231
- const all = roots.flatMap((r) => pruneListEntries(r, "dir"));
3232
- const stale = selectStale(all, keepFor("worktrees"), ageMs, now);
3233
- // Guard: never delete a worktree a live agent is using. Ask the daemon what's
3234
- // live (it owns every runtime and PTY); protected worktrees drop out of the
3235
- // removal set entirely, so an aggressive --older-than can't nuke a running
3236
- // agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
3237
- const live = await liveWorktreePaths(config);
3238
- const remove = stale.filter((e) => !isLiveWorktree(e.path, live.paths));
3239
- worktreeGuard = { reachable: live.reachable, protected: stale.length - remove.length };
3240
- plan.push({ kind: "worktrees", root: roots.join(", ") || "(none found)", remove });
3306
+ if (doWorktrees || worktreesToRemove.length) {
3307
+ plan.push({ kind: "worktrees", root: worktreeRoots.join(", ") || "(none found)", remove: worktreesToRemove });
3241
3308
  }
3242
3309
  const total = plan.reduce((n, p) => n + p.remove.length, 0);
3243
3310
 
@@ -9,6 +9,8 @@
9
9
  // not just Pi's `.bivy/pi/sessions`. Prune selects from that index; these helpers
10
10
  // decide which entries are stale. No I/O here on purpose.
11
11
 
12
+ import path from "node:path";
13
+
12
14
  // Mirrors the server's isEmptyUntitledSummary: a session with no real title, no
13
15
  // first message and no messages is the hidden "Untitled" noise the sidebar filters
14
16
  // out. Keep-N protects only *real* sessions; empties are always eligible.
@@ -30,6 +32,42 @@ export function sessionActivityMs(s) {
30
32
  return 0;
31
33
  }
32
34
 
35
+ // A Bivy-managed worktree lives at <repo>/.bivy/worktrees/<slug>. Only those are
36
+ // ever removed on a session's behalf — never an arbitrary path a record names.
37
+ export function isManagedWorktreePath(p) {
38
+ const parts = path.resolve(String(p ?? "")).split(path.sep);
39
+ return parts.length >= 3 && parts[parts.length - 3] === ".bivy" && parts[parts.length - 2] === "worktrees";
40
+ }
41
+
42
+ // Sessions and their worktrees are pruned as one unit, so neither outlives the
43
+ // other. Selection runs per kind (sessions and worktrees have different keep
44
+ // counts), then this links the two removal sets:
45
+ // - a session goes when its worktree goes, or when its worktree is already
46
+ // gone from disk (a session that can no longer open its checkout);
47
+ // - a worktree goes when every session using it goes (and no live agent holds it).
48
+ // Live ("working") sessions are never added, and their worktree is never removed. `isLive(path)` / `exists(path)` are
49
+ // the caller's I/O; paths are compared resolved. Returns the widened sets.
50
+ export function linkSessionWorktrees({ sessions, staleSessions, staleWorktrees, isLive = () => false, exists = () => true }) {
51
+ const removeSessions = new Map(staleSessions.map((s) => [s.id, s]));
52
+ const removeWorktrees = new Set(staleWorktrees.map((p) => path.resolve(p)));
53
+ const byWorktree = new Map();
54
+ for (const s of sessions) {
55
+ if (!s.worktree || !isManagedWorktreePath(s.worktree)) continue;
56
+ const wt = path.resolve(s.worktree);
57
+ byWorktree.set(wt, [...(byWorktree.get(wt) ?? []), s]);
58
+ }
59
+ for (const [wt, owners] of byWorktree) {
60
+ if (owners.some((s) => s.status === "working")) removeWorktrees.delete(wt);
61
+ else if (exists(wt) && owners.every((s) => removeSessions.has(s.id)) && !isLive(wt)) removeWorktrees.add(wt);
62
+ }
63
+ for (const [wt, owners] of byWorktree) {
64
+ if (!removeWorktrees.has(wt) && exists(wt)) continue;
65
+ for (const s of owners) if (s.status !== "working") removeSessions.set(s.id, s);
66
+ }
67
+ const sorted = [...removeSessions.values()].sort((a, b) => sessionActivityMs(b) - sessionActivityMs(a));
68
+ return { sessions: sorted, worktrees: [...removeWorktrees] };
69
+ }
70
+
33
71
  // Removal set for sessions. Live ("working") sessions are never touched. The
34
72
  // newest `keep` NON-EMPTY sessions (across every agent) survive; empty/untitled
35
73
  // sessions are always beyond the keep window. An item is removed only when it is
@@ -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. */
@@ -121,8 +121,13 @@ export async function fetchPendingWork(cfg) {
121
121
  /** Consume the authoritative claim snapshot, not the earlier queue listing. */
122
122
  export async function claimWork(cfg, id) {
123
123
  const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`, randomUUID());
124
- if (!res.ok)
124
+ if (!res.ok) {
125
+ // 409 is the normal "another node won" race; anything else would otherwise
126
+ // leave an item silently pending forever.
127
+ if (res.status !== 409)
128
+ console.warn(`[control-plane-tasks] claim ${id} failed: HTTP ${res.status}`);
125
129
  return undefined;
130
+ }
126
131
  const data = await res.json();
127
132
  return data.item ?? { id }; // compatibility with older control planes
128
133
  }
package/dist/run-tools.js CHANGED
@@ -12,6 +12,9 @@ export const RUN_TOOL_LIMITS = {
12
12
  maxAnswer: 8_000,
13
13
  };
14
14
  const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
15
+ // Parked by the node's policy (usage limit, missing credentials): it won't
16
+ // progress without a person, so waiting ends there and the reason comes back.
17
+ const SETTLED = new Set([...TERMINAL, "needs_attention"]);
15
18
  const STATUSES = new Set(["pending", "claimed", "running", "waiting", "needs_attention", "succeeded", "failed", "cancelled"]);
16
19
  const SOURCE_PREFIX = "agent-delegation:v1:";
17
20
  const bounded = (v, n) => typeof v === "string" && v ? v.slice(0, n) : undefined;
@@ -109,7 +112,7 @@ export class RunDelegationService {
109
112
  if (!raw || provenance?.parentSessionId !== sessionId)
110
113
  throw new Error("delegated Run not found");
111
114
  const value = safeRun(raw, provenance);
112
- if (TERMINAL.has(value.status) && this.backend.answer) {
115
+ if (SETTLED.has(value.status) && this.backend.answer) {
113
116
  // Best-effort: an unreachable machine still leaves status + references.
114
117
  const answer = cached?.value.answer ?? await this.backend.answer(raw).catch(() => undefined);
115
118
  if (answer)
@@ -132,7 +135,9 @@ export class RunDelegationService {
132
135
  const existing = expectedSource ? recent.find((r) => r.source === expectedSource) : undefined;
133
136
  if (existing)
134
137
  return safeRun(existing, provenance);
135
- const active = recent.filter((r) => parseDelegationSource(r.source)?.parentSessionId === sessionId && !TERMINAL.has(r.status)).length;
138
+ // A parked child (needs_attention) runs nothing until a person acts, so it
139
+ // doesn't hold one of the parent's concurrent slots.
140
+ const active = recent.filter((r) => parseDelegationSource(r.source)?.parentSessionId === sessionId && !SETTLED.has(r.status)).length;
136
141
  if (active >= RUN_TOOL_LIMITS.maxConcurrentChildren)
137
142
  throw new Error(`concurrent child Run limit (${RUN_TOOL_LIMITS.maxConcurrentChildren}) reached`);
138
143
  const raw = await this.backend.start(sessionId, input, provenance);
@@ -155,7 +160,7 @@ export class RunDelegationService {
155
160
  if (signal?.aborted)
156
161
  throw signal.reason ?? new Error("wait cancelled; the child Run may still be running");
157
162
  const result = await this.authorized(sessionId, runId, true);
158
- if (TERMINAL.has(result.status))
163
+ if (SETTLED.has(result.status))
159
164
  return result;
160
165
  if (this.now() >= deadline)
161
166
  return { ...result, timedOut: true, wait: { timedOut: true, childContinues: true } };
@@ -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, "..");
@@ -525,24 +527,38 @@ const runDelegation = new RunDelegationService({
525
527
  throw error;
526
528
  }
527
529
  },
528
- start: async (_sessionId, input, provenance) => {
530
+ start: async (sessionId, input, provenance) => {
529
531
  const machine = input.machine?.trim() || identity.name;
530
532
  const nodes = await delegatedRunRequest("/nodes");
531
533
  const target = Array.isArray(nodes) ? nodes.find((node) => node && typeof node === "object" && node.name === machine) : undefined;
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({
538
547
  title: "Delegated Run",
539
- body: `bivy-room-v1:${targetId}:${seal(pairingStore.roomKey(), input.instructions)}`,
548
+ // Only the target machine can open its Run body, so seal with ITS room
549
+ // key: this machine's own for local work, the sibling's (learned over
550
+ // the sealed pairing handshake) for another machine.
551
+ body: `bivy-room-v1:${targetId}:${seal(targetId === identity.nodeId ? pairingStore.roomKey() : await siblingRoomKey(targetId), input.instructions)}`,
540
552
  repo: input.repo,
541
553
  node: machine,
542
554
  runtimeId: input.agent,
543
555
  model: input.model,
544
- approvalMode: input.safety?.approval,
545
- sandbox: input.safety?.sandbox,
556
+ // A child acts for the same user in the same context as its parent, so
557
+ // it inherits the parent session's safety unless the caller set one. The
558
+ // queue's own default ("risky" with nobody to approve) denied ordinary
559
+ // commands, leaving children that could only describe what they'd do.
560
+ approvalMode: input.safety?.approval ?? openSessions.get(sessionId)?.approvalMode,
561
+ sandbox: input.safety?.sandbox ?? openSessions.get(sessionId)?.sandbox,
546
562
  maxAttempts: input.safety?.maxAttempts ?? 2,
547
563
  idempotencyKey: input.idempotencyKey,
548
564
  parentSessionId: provenance.parentSessionId,
@@ -559,24 +575,130 @@ const runDelegation = new RunDelegationService({
559
575
  if (!sessionId)
560
576
  return undefined;
561
577
  const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {};
562
- const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel : identity.name;
578
+ // The queue routes by label `bivy/<machine>`.
579
+ const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel.replace(/^bivy\//, "") : identity.name;
563
580
  if (machine === identity.name)
564
581
  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)
582
+ const target = (await accountMachines()).find((m) => m.name === machine);
583
+ if (!target)
568
584
  return undefined;
569
- const client = new SiblingClient({ controlPlaneUrl: sessionAdvertiseTarget.controlPlaneUrl, enrollmentToken: sessionAdvertiseTarget.enrollmentToken, siblingNodeId: target.id, label: "Bivy delegation" });
585
+ const reply = await siblingRequest(target.id, { kind: "delegation.answer", sessionId });
586
+ return typeof reply.answer === "string" ? reply.answer : undefined;
587
+ },
588
+ });
589
+ /** The account's machines (id, name, online), briefly cached. */
590
+ let machinesCache;
591
+ async function accountMachines() {
592
+ if (machinesCache && Date.now() - machinesCache.at < 20_000)
593
+ return machinesCache.machines;
594
+ const nodes = await delegatedRunRequest("/nodes");
595
+ const machines = (Array.isArray(nodes) ? nodes : []).flatMap((node) => {
596
+ const n = node && typeof node === "object" ? node : {};
597
+ return typeof n.id === "string" && typeof n.name === "string" ? [{ id: n.id, name: n.name, online: n.online === true }] : [];
598
+ });
599
+ machinesCache = { at: Date.now(), machines };
600
+ return machines;
601
+ }
602
+ /** One sealed request to a co-owned machine over the relay (node as client). */
603
+ async function siblingRequest(nodeId, command, timeoutMs = 20_000) {
604
+ if (!sessionAdvertiseTarget)
605
+ throw new Error("This machine is not signed in to an account.");
606
+ const client = new SiblingClient({ controlPlaneUrl: sessionAdvertiseTarget.controlPlaneUrl, enrollmentToken: sessionAdvertiseTarget.enrollmentToken, siblingNodeId: nodeId, label: "Bivy delegation" });
607
+ try {
608
+ await client.connect();
609
+ return await client.request(command, timeoutMs);
610
+ }
611
+ finally {
612
+ client.close();
613
+ }
614
+ }
615
+ /** Another machine's room key, from a sealed pairing with it. */
616
+ async function siblingRoomKey(nodeId) {
617
+ if (!sessionAdvertiseTarget)
618
+ throw new Error("This machine is not signed in to an account.");
619
+ const client = new SiblingClient({ controlPlaneUrl: sessionAdvertiseTarget.controlPlaneUrl, enrollmentToken: sessionAdvertiseTarget.enrollmentToken, siblingNodeId: nodeId, label: "Bivy delegation" });
620
+ try {
621
+ await client.connect();
622
+ return client.roomKey();
623
+ }
624
+ finally {
625
+ client.close();
626
+ }
627
+ }
628
+ /** Agents this machine can run a delegated task with (installed ones). */
629
+ function localAgents() {
630
+ return runtimeList().filter((rt) => rt.status === "available").map((rt) => ({ id: rt.id, name: String(rt.displayName || rt.id) }));
631
+ }
632
+ async function siblingAgents(nodeId) {
633
+ const reply = await siblingRequest(nodeId, { kind: "agents.available" }, 10_000);
634
+ return Array.isArray(reply.agents) ? reply.agents.flatMap((a) => {
635
+ const agent = a && typeof a === "object" ? a : {};
636
+ return typeof agent.id === "string" ? [{ id: agent.id, name: typeof agent.name === "string" ? agent.name : agent.id }] : [];
637
+ }) : [];
638
+ }
639
+ async function machinesWithAgents() {
640
+ const machines = await accountMachines();
641
+ return Promise.all(machines.map(async (m) => {
642
+ const self = m.id === identity.nodeId;
643
+ if (self)
644
+ return { ...m, online: true, self, agents: localAgents() };
645
+ if (!m.online)
646
+ return { ...m, self };
570
647
  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;
648
+ return { ...m, self, agents: await siblingAgents(m.id) };
574
649
  }
575
- finally {
576
- client.close();
650
+ catch (error) {
651
+ return { ...m, self, error: error instanceof Error ? error.message : String(error) };
577
652
  }
578
- },
579
- });
653
+ }));
654
+ }
655
+ /** Fold a delegated Run's latest status into its card in the parent session. */
656
+ function recordDelegation(parentSessionId, run, start) {
657
+ const record = resolveSession(parentSessionId);
658
+ if (!record)
659
+ return run;
660
+ const prev = eventLog.delegations(record.id).find((card) => card.id === run.runId);
661
+ const card = delegationCard(prev, run, start);
662
+ if (prev && JSON.stringify(prev) === JSON.stringify(card))
663
+ return run;
664
+ eventLog.appendDelegation(record.id, { afterMessageCount: record.session.getMessages().length, delegation: card });
665
+ broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "delegation", delegation: card } }));
666
+ if (card.childSessionId && !prev?.childSessionId)
667
+ void linkDelegatedChild(record, card).catch(() => { });
668
+ return run;
669
+ }
670
+ /** Tell the child session where it came from — locally, or on its machine. */
671
+ async function linkDelegatedChild(parent, card) {
672
+ const from = { sessionId: parent.id, nodeId: identity.nodeId, machine: identity.name, ...(parent.session.getName() ? { title: parent.session.getName().slice(0, 256) } : {}) };
673
+ if (!card.nodeId || card.nodeId === identity.nodeId) {
674
+ applyDelegationLink(card.childSessionId, from);
675
+ return;
676
+ }
677
+ await siblingRequest(card.nodeId, { kind: "delegation.link", childSessionId: card.childSessionId, parent: from }, 10_000);
678
+ }
679
+ function applyDelegationLink(childSessionId, from) {
680
+ const record = resolveSession(childSessionId);
681
+ if (!record && !metadata.getSession(childSessionId))
682
+ return false;
683
+ if (record)
684
+ record.delegatedFrom = from;
685
+ metadata.upsertSession({ id: childSessionId, delegatedFrom: from });
686
+ if (record)
687
+ broadcast({ type: "session.updated", sessionId: record.id, sessionFile: record.sessionFile, bivySession: bivySessionEnvelope(record) });
688
+ scheduleAdvertise();
689
+ return true;
690
+ }
691
+ // Children rarely finish while someone is polling them: keep the parent's open
692
+ // cards current in the background (cheap: cached, and only non-terminal ones).
693
+ setInterval(() => {
694
+ for (const record of new Set(openSessions.values())) {
695
+ for (const card of eventLog.delegations(record.id)) {
696
+ if (TERMINAL_DELEGATION.has(card.status))
697
+ continue;
698
+ void runDelegation.getRunStatus(record.id, card.id).then((run) => recordDelegation(record.id, run)).catch(() => { });
699
+ }
700
+ }
701
+ }, 15_000).unref();
580
702
  // Delegated Runs remain available through the explicit Session API, but are not
581
703
  // exposed as agent tools. Coding agents should use their own native sub-agent
582
704
  // facilities; advertising Runs here caused routine work to create noisy,
@@ -2434,6 +2556,16 @@ const RELAY_COMMANDS = {
2434
2556
  },
2435
2557
  // A co-owned node asking for a delegated child's answer over the sealed
2436
2558
  // node-to-node channel (see runDelegation.answer). Replies to the requester.
2559
+ // Discovery: which agents this machine can run (docs/agent-delegation.md).
2560
+ "agents.available"(msg, ctx) {
2561
+ ctx.reply({ type: "agents.available", requestId: msg.requestId, machine: identity.name, agents: localAgents() });
2562
+ },
2563
+ // The parent of a delegated child session on this machine introduces itself,
2564
+ // so the child can show and link where it came from.
2565
+ "delegation.link"(msg, ctx) {
2566
+ const linked = typeof msg.childSessionId === "string" && isDelegatedFrom(msg.parent) ? applyDelegationLink(msg.childSessionId, msg.parent) : false;
2567
+ ctx.reply({ type: "delegation.link", requestId: msg.requestId, linked });
2568
+ },
2437
2569
  async "delegation.answer"(msg, ctx) {
2438
2570
  const sessionId = typeof msg.sessionId === "string" ? msg.sessionId : "";
2439
2571
  const answer = sessionId ? await delegationAnswer(sessionId).catch(() => undefined) : undefined;
@@ -4539,11 +4671,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4539
4671
  sessionNamer.setSessionName(record, issueSessionTitle(issue));
4540
4672
  // Best-effort model selection for runtimes that support it (e.g. claude-code, pi).
4541
4673
  if (directives.model && typeof record.session.setModel === "function") {
4542
- try {
4543
- assertSessionModel(record, directives.model);
4544
- await record.session.setModel("", directives.model);
4545
- }
4546
- catch { }
4674
+ await applyRequestedModel(record, { provider: "", id: directives.model });
4547
4675
  }
4548
4676
  if (!record.worktree)
4549
4677
  throw new Error("worktree was not created for the issue session");
@@ -4738,8 +4866,16 @@ async function runSessionTurn(record, prompt, signal) {
4738
4866
  if (signal?.aborted)
4739
4867
  throw signal.reason ?? new Error("Run cancelled");
4740
4868
  let unsubscribe = () => { };
4869
+ // A turn the agent failed (usage limit, auth, crash) still ends with
4870
+ // agent_end; remember why so the Run fails instead of reporting success.
4871
+ let turnError = "";
4741
4872
  const finished = new Promise((resolve) => {
4742
4873
  unsubscribe = record.session.subscribe((event) => {
4874
+ const e = event;
4875
+ if (e.type === "session.error" && typeof e.error === "string")
4876
+ turnError ||= e.error;
4877
+ if (e.type === "message_end" && e.message?.stopReason === "error" && typeof e.message.errorMessage === "string")
4878
+ turnError ||= e.message.errorMessage;
4743
4879
  if (event.type === "agent_end")
4744
4880
  resolve();
4745
4881
  });
@@ -4754,6 +4890,8 @@ async function runSessionTurn(record, prompt, signal) {
4754
4890
  try {
4755
4891
  await turnWatchdog.promptWithWatchdog(record, prompt);
4756
4892
  await (signal ? Promise.race([finished, cancelled]) : finished);
4893
+ if (turnError)
4894
+ throw new Error(turnError);
4757
4895
  }
4758
4896
  finally {
4759
4897
  signal?.removeEventListener("abort", onAbort);
@@ -5213,12 +5351,23 @@ async function delegationAnswer(sessionId) {
5213
5351
  const message = messages[i];
5214
5352
  if (message?.role !== "assistant")
5215
5353
  continue;
5354
+ // A turn the agent failed (usage limit, auth) ends on an error marker, not
5355
+ // prose: hand the parent the reason instead of an empty answer.
5356
+ if (message.stopReason === "error" && typeof message.errorMessage === "string" && message.errorMessage.trim())
5357
+ return `The delegated agent failed: ${message.errorMessage.trim()}`;
5216
5358
  const text = runtimeContentText(message.content).trim();
5217
5359
  if (text)
5218
5360
  return text;
5219
5361
  }
5220
5362
  return undefined;
5221
5363
  }
5364
+ /** A delegated task's first line, bounded, as its child session's title. */
5365
+ function delegatedSessionTitle(instructions) {
5366
+ const line = instructions?.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
5367
+ if (!line)
5368
+ return undefined;
5369
+ return line.length > 80 ? `${line.slice(0, 79).trimEnd()}…` : line;
5370
+ }
5222
5371
  /** Text of the most recent user message in a session's transcript, if any. */
5223
5372
  function lastUserMessageText(record) {
5224
5373
  const messages = record.session.getMessages();
@@ -5373,12 +5522,8 @@ async function executeWorkItem(item, report, signal) {
5373
5522
  approvalMode: safety.approval,
5374
5523
  });
5375
5524
  sessionNamer.setSessionName(record, `${issue.identifier}: ${issue.title}`);
5376
- if (item.model) {
5377
- try {
5378
- await record.session.setModel("", item.model);
5379
- }
5380
- catch { }
5381
- }
5525
+ if (item.model)
5526
+ await applyRequestedModel(record, { provider: "", id: item.model });
5382
5527
  await report({ output: { sessionId: record.id, branch }, events: [{ at: new Date().toISOString(), kind: "branch", summary: "Linear issue working branch and session created.", ref: branch, url: issue.url }] });
5383
5528
  await runSessionTurn(record, buildLinearTaskPrompt(issue, item.body), signal);
5384
5529
  if (signal.aborted)
@@ -5452,13 +5597,13 @@ async function executeWorkItem(item, report, signal) {
5452
5597
  // sends after the Run is mistaken for the Session's first naming prompt (the
5453
5598
  // internal Run turn does not pass through the interactive prompt handler), so
5454
5599
  // rows named after the Run suddenly become "status", "continue", etc.
5455
- sessionNamer.setSessionName(record, item.title);
5600
+ // A delegated child's queue title is generic ("Delegated Run": the task is
5601
+ // sealed, the queue never sees it). Name it after the task itself, which
5602
+ // only this machine can read, so it's recognizable in the session list.
5603
+ sessionNamer.setSessionName(record, delegatedProvenance ? delegatedSessionTitle(item.body) ?? item.title : item.title);
5456
5604
  persistSessionMetadata(record);
5457
5605
  if (item.model) {
5458
- try {
5459
- await record.session.setModel("", item.model);
5460
- }
5461
- catch { }
5606
+ await applyRequestedModel(record, { provider: "", id: item.model });
5462
5607
  }
5463
5608
  // Record the session id with the control plane BEFORE the (potentially long)
5464
5609
  // turn runs, not just after it completes. A machine restart mid-turn must leave
@@ -6384,6 +6529,7 @@ async function sessionListRows() {
6384
6529
  source: rec?.source ?? meta?.source,
6385
6530
  bivyCreated: Boolean(meta),
6386
6531
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
6532
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
6387
6533
  branch: rec?.worktree?.branch ?? meta?.branch,
6388
6534
  sandbox: rec?.sandbox ?? normalizeSandboxTier(meta?.sandbox),
6389
6535
  approvalMode: rec?.approvalMode,
@@ -6669,6 +6815,7 @@ function persistSessionMetadata(record, status = sessionStatus(record)) {
6669
6815
  workspace: record.workspace,
6670
6816
  source: record.source ?? "manual",
6671
6817
  forkedFrom: record.forkedFrom,
6818
+ delegatedFrom: record.delegatedFrom,
6672
6819
  automationRunId: record.automationRunId,
6673
6820
  delegationDepth: record.delegationDepth,
6674
6821
  runtimeId: record.runtimeId,
@@ -8493,7 +8640,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8493
8640
  // Rehydrating (rather than leaving this undefined for a resumed session)
8494
8641
  // also avoids a persistSessionMetadata call later silently clobbering the
8495
8642
  // 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" };
8643
+ 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
8644
  // Migration: a session resumed/reopened from before this feature (or from a
8498
8645
  // node that predates it) has no stored contract. Stamp an honest one now
8499
8646
  // from currently-observed facts rather than leaving it blank forever or
@@ -10423,6 +10570,7 @@ app.get("/api/sessions", async (_req, res, next) => {
10423
10570
  source: rec?.source ?? meta?.source,
10424
10571
  bivyCreated: Boolean(meta),
10425
10572
  forkedFrom: rec?.forkedFrom ?? meta?.forkedFrom,
10573
+ delegatedFrom: rec?.delegatedFrom ?? meta?.delegatedFrom,
10426
10574
  branch: rec?.worktree?.branch ?? meta?.branch,
10427
10575
  prUrl: rec?.prUrl ?? meta?.prUrl,
10428
10576
  prs: rec?.prs ?? meta?.prs,
@@ -11339,7 +11487,12 @@ app.post("/api/session/:id/suggest", (req, res) => {
11339
11487
  // authorizes every status lookup against this parent Session's provenance.
11340
11488
  app.post("/api/session/:id/delegated-runs", async (req, res) => {
11341
11489
  try {
11342
- res.status(201).json(await runDelegation.startRun(String(req.params.id), req.body));
11490
+ const input = req.body;
11491
+ const result = await runDelegation.startRun(String(req.params.id), input);
11492
+ const machine = input.machine?.trim() || identity.name;
11493
+ const nodeId = (await accountMachines().catch(() => [])).find((m) => m.name === machine)?.id ?? (machine === identity.name ? identity.nodeId : undefined);
11494
+ 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 });
11495
+ res.status(201).json(result);
11343
11496
  }
11344
11497
  catch (error) {
11345
11498
  res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11347,7 +11500,7 @@ app.post("/api/session/:id/delegated-runs", async (req, res) => {
11347
11500
  });
11348
11501
  app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11349
11502
  try {
11350
- res.json(await runDelegation.getRunStatus(String(req.params.id), String(req.params.runId)));
11503
+ res.json(recordDelegation(String(req.params.id), await runDelegation.getRunStatus(String(req.params.id), String(req.params.runId))));
11351
11504
  }
11352
11505
  catch (error) {
11353
11506
  res.status(404).json({ error: error instanceof Error ? error.message : String(error) });
@@ -11355,7 +11508,18 @@ app.get("/api/session/:id/delegated-runs/:runId", async (req, res) => {
11355
11508
  });
11356
11509
  app.post("/api/session/:id/delegated-runs/:runId/wait", async (req, res) => {
11357
11510
  try {
11358
- res.json(await runDelegation.waitForRun(String(req.params.id), String(req.params.runId), Number(req.body?.timeoutSeconds), AbortSignal.timeout(305_000)));
11511
+ 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))));
11512
+ }
11513
+ catch (error) {
11514
+ res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
11515
+ }
11516
+ });
11517
+ // The account's machines and the agents installed on each (asked of every
11518
+ // online machine over the sealed node-to-node channel), for `bivy delegate
11519
+ // machines` and for picking a valid agent @ machine up front.
11520
+ app.get("/api/machines", async (_req, res) => {
11521
+ try {
11522
+ res.json({ machines: await machinesWithAgents() });
11359
11523
  }
11360
11524
  catch (error) {
11361
11525
  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) });
@@ -30,6 +30,7 @@ export class SiblingClient {
30
30
  opts;
31
31
  ws;
32
32
  cipher;
33
+ siblingRoomKey;
33
34
  keypair = newDeviceKeypair();
34
35
  reassembler = new FrameReassembler({ onReject: () => this.ws?.close() });
35
36
  transfers = new EventTransferReceiver({
@@ -80,7 +81,23 @@ export class SiblingClient {
80
81
  ws.on("message", (data) => this.onMessage(String(data), grant));
81
82
  ws.on("close", () => this.handleClose());
82
83
  ws.on("error", (err) => this.rejectPaired?.(err));
83
- await this.paired;
84
+ // A sibling that never answers pairing (offline behind a stale "online",
85
+ // or an incompatible build) must fail, not hang its caller forever.
86
+ const timeoutMs = this.opts.pairTimeoutMs ?? 20_000;
87
+ let timer;
88
+ try {
89
+ await Promise.race([
90
+ this.paired,
91
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("sibling did not answer pairing")), timeoutMs); }),
92
+ ]);
93
+ }
94
+ catch (err) {
95
+ this.close();
96
+ throw err;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
84
101
  }
85
102
  async onMessage(raw, grant) {
86
103
  let msg;
@@ -94,11 +111,23 @@ export class SiblingClient {
94
111
  // Begin account pairing to obtain the sibling's room key.
95
112
  // `ephemeral`: a sibling replica is a node↔node bridge, not a user device —
96
113
  // authorized for the room key but never listed as a signed-in device.
97
- this.sendControl({ t: "pair", p: { k: "pair.account", sessionToken: grant, devicePublicKeyB64: this.keypair.publicKeyB64, label: this.opts.label ?? "Bivy replica", ephemeral: true } });
114
+ // Pair payloads travel as a JSON string, as the node's relay client
115
+ // expects (it ignores any other shape) and as it replies.
116
+ this.sendControl({ t: "pair", p: JSON.stringify({ k: "pair.account", sessionToken: grant, devicePublicKeyB64: this.keypair.publicKeyB64, label: this.opts.label ?? "Bivy replica", ephemeral: true }) });
98
117
  return;
99
118
  }
100
119
  if (msg.t === "pair") {
101
- this.handlePair(msg.p);
120
+ let pair = msg.p;
121
+ if (typeof pair === "string") {
122
+ try {
123
+ pair = JSON.parse(pair);
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ }
129
+ if (pair && typeof pair === "object")
130
+ this.handlePair(pair);
102
131
  return;
103
132
  }
104
133
  if (msg.t === "frame" && typeof msg.p === "string" && this.cipher) {
@@ -119,6 +148,7 @@ export class SiblingClient {
119
148
  if (k === "pair.welcome") {
120
149
  try {
121
150
  const roomKey = acceptWelcome(this.keypair, { nodePublicKeyB64: String(p.nodePublicKeyB64 ?? ""), wrapped: String(p.wrapped ?? "") });
151
+ this.siblingRoomKey = roomKey;
122
152
  this.cipher = new RoomCipher(roomKey);
123
153
  this.resolvePaired?.();
124
154
  }
@@ -141,6 +171,13 @@ export class SiblingClient {
141
171
  }
142
172
  this.opts.onEvent?.(event);
143
173
  }
174
+ /** The sibling's room key, once paired: what seals content only it can read
175
+ * (e.g. a delegated Run's instructions queued for that machine). */
176
+ roomKey() {
177
+ if (!this.siblingRoomKey)
178
+ throw new Error("sibling client not paired");
179
+ return this.siblingRoomKey;
180
+ }
144
181
  /** Send a command and await its correlated reply event (by requestId). */
145
182
  request(command, timeoutMs = 30_000) {
146
183
  const requestId = `repl-${++this.reqSeq}-${Date.now()}`;
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.19.0-staging.1",
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.",