@bivy/bivy 0.18.11-staging.9 → 0.19.0
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 +54 -26
- package/bin/prune-sessions.mjs +38 -0
- package/dist/control-plane-tasks.js +6 -1
- package/dist/run-tools.js +8 -3
- package/dist/server.js +55 -21
- 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,7 +2466,7 @@ 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
2470
|
bivy delegate "<task>" --to <agent>[@<machine>],<agent>[@<machine>],… [--wait [seconds]]
|
|
2471
2471
|
bivy delegate machines [--json]
|
|
2472
2472
|
bivy delegate status <run-id> [--json]
|
|
@@ -2480,7 +2480,12 @@ to several agents/machines at once (up to 3) so the results can be compared.
|
|
|
2480
2480
|
|
|
2481
2481
|
async function cmdDelegate(args = []) {
|
|
2482
2482
|
if (!args.length || args.some((a) => a === "-h" || a === "--help")) { console.log(DELEGATE_USAGE); return; }
|
|
2483
|
-
const flagsWithValue = new Set(["--session", "--agent", "--machine", "--model", "--repo", "--timeout", "--to"]);
|
|
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
|
+
};
|
|
2484
2489
|
const flag = (name) => {
|
|
2485
2490
|
const i = args.indexOf(name);
|
|
2486
2491
|
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
@@ -2506,7 +2511,8 @@ async function cmdDelegate(args = []) {
|
|
|
2506
2511
|
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
2507
2512
|
return data;
|
|
2508
2513
|
};
|
|
2509
|
-
|
|
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);
|
|
2510
2516
|
// The node caps one wait at 300s; loop until the child ends or our budget does.
|
|
2511
2517
|
const waitFor = async (runId, seconds) => {
|
|
2512
2518
|
const deadline = Date.now() + seconds * 1000;
|
|
@@ -2555,7 +2561,7 @@ async function cmdDelegate(args = []) {
|
|
|
2555
2561
|
const group = `fanout-${Date.now().toString(36)}`;
|
|
2556
2562
|
const runs = [];
|
|
2557
2563
|
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 }) });
|
|
2564
|
+
runs.push({ target, run: await call("", { instructions, agent: target.agent, machine: target.machine, model: flag("--model"), repo: flag("--repo"), ...safetyFlags(), group }) });
|
|
2559
2565
|
}
|
|
2560
2566
|
if (waitIndex < 0) {
|
|
2561
2567
|
if (json) console.log(JSON.stringify(runs.map((r) => r.run), null, 2));
|
|
@@ -2568,7 +2574,7 @@ async function cmdDelegate(args = []) {
|
|
|
2568
2574
|
return;
|
|
2569
2575
|
}
|
|
2570
2576
|
const agent = flag("--agent");
|
|
2571
|
-
const started = await call("", { instructions, agent: agent ? governedChatAgentId(agent) : undefined, machine: flag("--machine"), model: flag("--model"), repo: flag("--repo") });
|
|
2577
|
+
const started = await call("", { instructions, agent: agent ? governedChatAgentId(agent) : undefined, machine: flag("--machine"), model: flag("--model"), repo: flag("--repo"), ...safetyFlags() });
|
|
2572
2578
|
if (waitIndex < 0) {
|
|
2573
2579
|
if (!json) console.log(`Delegated as run ${started.runId} (${started.status}). Follow it with 'bivy delegate wait ${started.runId}'.`);
|
|
2574
2580
|
else print(started);
|
|
@@ -3168,6 +3174,11 @@ ${c.bold("bivy prune")} — remove old sessions, --clone workspaces, and git wor
|
|
|
3168
3174
|
is never pruned while the node is running, regardless of --keep/--older-than —
|
|
3169
3175
|
only idle worktrees are removed. If the node is down, nothing is live to guard.
|
|
3170
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
|
+
|
|
3171
3182
|
Paths (default to the installed node)
|
|
3172
3183
|
--data-dir <dir> node data dir (default: this install's .bivy, or $BIVY_DATA_DIR)
|
|
3173
3184
|
--workspace <dir> also scan this workspace for worktrees (default: configured workspace)
|
|
@@ -3235,12 +3246,41 @@ async function cmdPrune(args = []) {
|
|
|
3235
3246
|
const now = Date.now();
|
|
3236
3247
|
const plan = [];
|
|
3237
3248
|
let worktreeGuard = { reachable: false, protected: 0 };
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
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) {
|
|
3244
3284
|
plan.push({
|
|
3245
3285
|
kind: "sessions",
|
|
3246
3286
|
root: metadataFilePath(dataDir),
|
|
@@ -3263,20 +3303,8 @@ async function cmdPrune(args = []) {
|
|
|
3263
3303
|
const dir = path.join(dataDir, "workspaces");
|
|
3264
3304
|
plan.push({ kind: "workspaces", root: dir, remove: selectStale(pruneListEntries(dir, "dir"), keepFor("workspaces"), ageMs, now) });
|
|
3265
3305
|
}
|
|
3266
|
-
if (doWorktrees) {
|
|
3267
|
-
|
|
3268
|
-
// prune script. Scan roots: the data dir plus the configured workspace repo.
|
|
3269
|
-
const roots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
|
|
3270
|
-
const all = roots.flatMap((r) => pruneListEntries(r, "dir"));
|
|
3271
|
-
const stale = selectStale(all, keepFor("worktrees"), ageMs, now);
|
|
3272
|
-
// Guard: never delete a worktree a live agent is using. Ask the daemon what's
|
|
3273
|
-
// live (it owns every runtime and PTY); protected worktrees drop out of the
|
|
3274
|
-
// removal set entirely, so an aggressive --older-than can't nuke a running
|
|
3275
|
-
// agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
|
|
3276
|
-
const live = await liveWorktreePaths(config);
|
|
3277
|
-
const remove = stale.filter((e) => !isLiveWorktree(e.path, live.paths));
|
|
3278
|
-
worktreeGuard = { reachable: live.reachable, protected: stale.length - remove.length };
|
|
3279
|
-
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 });
|
|
3280
3308
|
}
|
|
3281
3309
|
const total = plan.reduce((n, p) => n + p.remove.length, 0);
|
|
3282
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
|
|
@@ -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 } };
|
package/dist/server.js
CHANGED
|
@@ -527,7 +527,7 @@ const runDelegation = new RunDelegationService({
|
|
|
527
527
|
throw error;
|
|
528
528
|
}
|
|
529
529
|
},
|
|
530
|
-
start: async (
|
|
530
|
+
start: async (sessionId, input, provenance) => {
|
|
531
531
|
const machine = input.machine?.trim() || identity.name;
|
|
532
532
|
const nodes = await delegatedRunRequest("/nodes");
|
|
533
533
|
const target = Array.isArray(nodes) ? nodes.find((node) => node && typeof node === "object" && node.name === machine) : undefined;
|
|
@@ -545,13 +545,20 @@ const runDelegation = new RunDelegationService({
|
|
|
545
545
|
method: "POST",
|
|
546
546
|
body: JSON.stringify({
|
|
547
547
|
title: "Delegated Run",
|
|
548
|
-
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)}`,
|
|
549
552
|
repo: input.repo,
|
|
550
553
|
node: machine,
|
|
551
554
|
runtimeId: input.agent,
|
|
552
555
|
model: input.model,
|
|
553
|
-
|
|
554
|
-
|
|
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,
|
|
555
562
|
maxAttempts: input.safety?.maxAttempts ?? 2,
|
|
556
563
|
idempotencyKey: input.idempotencyKey,
|
|
557
564
|
parentSessionId: provenance.parentSessionId,
|
|
@@ -568,7 +575,8 @@ const runDelegation = new RunDelegationService({
|
|
|
568
575
|
if (!sessionId)
|
|
569
576
|
return undefined;
|
|
570
577
|
const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {};
|
|
571
|
-
|
|
578
|
+
// The queue routes by label `bivy/<machine>`.
|
|
579
|
+
const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel.replace(/^bivy\//, "") : identity.name;
|
|
572
580
|
if (machine === identity.name)
|
|
573
581
|
return delegationAnswer(sessionId);
|
|
574
582
|
const target = (await accountMachines()).find((m) => m.name === machine);
|
|
@@ -604,6 +612,19 @@ async function siblingRequest(nodeId, command, timeoutMs = 20_000) {
|
|
|
604
612
|
client.close();
|
|
605
613
|
}
|
|
606
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
|
+
}
|
|
607
628
|
/** Agents this machine can run a delegated task with (installed ones). */
|
|
608
629
|
function localAgents() {
|
|
609
630
|
return runtimeList().filter((rt) => rt.status === "available").map((rt) => ({ id: rt.id, name: String(rt.displayName || rt.id) }));
|
|
@@ -4650,11 +4671,7 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
|
|
|
4650
4671
|
sessionNamer.setSessionName(record, issueSessionTitle(issue));
|
|
4651
4672
|
// Best-effort model selection for runtimes that support it (e.g. claude-code, pi).
|
|
4652
4673
|
if (directives.model && typeof record.session.setModel === "function") {
|
|
4653
|
-
|
|
4654
|
-
assertSessionModel(record, directives.model);
|
|
4655
|
-
await record.session.setModel("", directives.model);
|
|
4656
|
-
}
|
|
4657
|
-
catch { }
|
|
4674
|
+
await applyRequestedModel(record, { provider: "", id: directives.model });
|
|
4658
4675
|
}
|
|
4659
4676
|
if (!record.worktree)
|
|
4660
4677
|
throw new Error("worktree was not created for the issue session");
|
|
@@ -4849,8 +4866,16 @@ async function runSessionTurn(record, prompt, signal) {
|
|
|
4849
4866
|
if (signal?.aborted)
|
|
4850
4867
|
throw signal.reason ?? new Error("Run cancelled");
|
|
4851
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 = "";
|
|
4852
4872
|
const finished = new Promise((resolve) => {
|
|
4853
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;
|
|
4854
4879
|
if (event.type === "agent_end")
|
|
4855
4880
|
resolve();
|
|
4856
4881
|
});
|
|
@@ -4865,6 +4890,8 @@ async function runSessionTurn(record, prompt, signal) {
|
|
|
4865
4890
|
try {
|
|
4866
4891
|
await turnWatchdog.promptWithWatchdog(record, prompt);
|
|
4867
4892
|
await (signal ? Promise.race([finished, cancelled]) : finished);
|
|
4893
|
+
if (turnError)
|
|
4894
|
+
throw new Error(turnError);
|
|
4868
4895
|
}
|
|
4869
4896
|
finally {
|
|
4870
4897
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -5324,12 +5351,23 @@ async function delegationAnswer(sessionId) {
|
|
|
5324
5351
|
const message = messages[i];
|
|
5325
5352
|
if (message?.role !== "assistant")
|
|
5326
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()}`;
|
|
5327
5358
|
const text = runtimeContentText(message.content).trim();
|
|
5328
5359
|
if (text)
|
|
5329
5360
|
return text;
|
|
5330
5361
|
}
|
|
5331
5362
|
return undefined;
|
|
5332
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
|
+
}
|
|
5333
5371
|
/** Text of the most recent user message in a session's transcript, if any. */
|
|
5334
5372
|
function lastUserMessageText(record) {
|
|
5335
5373
|
const messages = record.session.getMessages();
|
|
@@ -5484,12 +5522,8 @@ async function executeWorkItem(item, report, signal) {
|
|
|
5484
5522
|
approvalMode: safety.approval,
|
|
5485
5523
|
});
|
|
5486
5524
|
sessionNamer.setSessionName(record, `${issue.identifier}: ${issue.title}`);
|
|
5487
|
-
if (item.model)
|
|
5488
|
-
|
|
5489
|
-
await record.session.setModel("", item.model);
|
|
5490
|
-
}
|
|
5491
|
-
catch { }
|
|
5492
|
-
}
|
|
5525
|
+
if (item.model)
|
|
5526
|
+
await applyRequestedModel(record, { provider: "", id: item.model });
|
|
5493
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 }] });
|
|
5494
5528
|
await runSessionTurn(record, buildLinearTaskPrompt(issue, item.body), signal);
|
|
5495
5529
|
if (signal.aborted)
|
|
@@ -5563,13 +5597,13 @@ async function executeWorkItem(item, report, signal) {
|
|
|
5563
5597
|
// sends after the Run is mistaken for the Session's first naming prompt (the
|
|
5564
5598
|
// internal Run turn does not pass through the interactive prompt handler), so
|
|
5565
5599
|
// rows named after the Run suddenly become "status", "continue", etc.
|
|
5566
|
-
|
|
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);
|
|
5567
5604
|
persistSessionMetadata(record);
|
|
5568
5605
|
if (item.model) {
|
|
5569
|
-
|
|
5570
|
-
await record.session.setModel("", item.model);
|
|
5571
|
-
}
|
|
5572
|
-
catch { }
|
|
5606
|
+
await applyRequestedModel(record, { provider: "", id: item.model });
|
|
5573
5607
|
}
|
|
5574
5608
|
// Record the session id with the control plane BEFORE the (potentially long)
|
|
5575
5609
|
// turn runs, not just after it completes. A machine restart mid-turn must leave
|
|
@@ -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()}`;
|