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