@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 +31 -4
- package/bin/bivy.mjs +123 -1
- package/dist/agent-instructions.js +4 -0
- package/dist/agents/claude-code/runtime.js +11 -0
- package/dist/apps/styles.css +26 -1
- package/dist/run-tools.js +8 -0
- package/dist/runtime/protocol.js +43 -0
- package/dist/runtime/tool-call-map.js +24 -0
- package/dist/server.js +196 -4
- package/dist/session/delegation-cards.js +28 -0
- package/dist/session/delegations.js +21 -0
- package/dist/session/event-log.js +34 -2
- package/package.json +1 -1
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/apps/styles.css
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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
|
-
|
|
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