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