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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bivy.mjs CHANGED
@@ -35,7 +35,7 @@ import { spawn, spawnSync } from "node:child_process";
35
35
  import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash, verify as cryptoVerify } from "node:crypto";
36
36
  import { fileURLToPath, pathToFileURL } from "node:url";
37
37
  import vm from "node:vm";
38
- import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
38
+ import { linkSessionWorktrees, selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
39
39
  import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
40
40
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
41
41
  import { removeInstallAndState } from "./uninstall-paths.mjs";
@@ -2466,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
- const terminal = (run) => ["succeeded", "failed", "cancelled"].includes(run?.status);
2514
+ // needs_attention = parked by the machine's policy; it won't move on its own.
2515
+ const terminal = (run) => ["succeeded", "failed", "cancelled", "needs_attention"].includes(run?.status);
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
- if (doSessions) {
3239
- // Sessions live in the metadata index (all agents) plus each agent's own
3240
- // transcript store — not just .bivy/pi/sessions. Select from metadata so
3241
- // shim/native (Claude Code, Codex, …) sessions are actually covered; the old
3242
- // pi-sessions-only scan silently no-oped for every terminal-started session.
3243
- const staleSessions = selectStaleSessions(loadMetadataSessions(dataDir).sessions, keepFor("sessions"), ageMs, now);
3249
+ // Sessions live in the metadata index (all agents) plus each agent's own
3250
+ // transcript store — not just .bivy/pi/sessions. Select from metadata so
3251
+ // shim/native (Claude Code, Codex, …) sessions are actually covered; the old
3252
+ // pi-sessions-only scan silently no-oped for every terminal-started session.
3253
+ const allSessions = loadMetadataSessions(dataDir).sessions;
3254
+ const selectedSessions = doSessions ? selectStaleSessions(allSessions, keepFor("sessions"), ageMs, now) : [];
3255
+ // Worktrees: keep the newest N overall (across all roots), matching the node
3256
+ // prune script. Scan roots: the data dir plus the configured workspace repo.
3257
+ const worktreeRoots = findWorktreeRoots([dataDir, workspace].filter(Boolean));
3258
+ const staleWorktrees = doWorktrees ? selectStale(worktreeRoots.flatMap((r) => pruneListEntries(r, "dir")), keepFor("worktrees"), ageMs, now) : [];
3259
+ // Guard: never delete a worktree a live agent is using. Ask the daemon what's
3260
+ // live (it owns every runtime and PTY); protected worktrees drop out of the
3261
+ // removal set entirely, so an aggressive --older-than can't nuke a running
3262
+ // agent's checkout. Node down ⇒ nothing live ⇒ policy alone is safe.
3263
+ const live = await liveWorktreePaths(config);
3264
+ const isLive = (p) => isLiveWorktree(p, live.paths);
3265
+ const idleWorktrees = staleWorktrees.filter((e) => !isLive(e.path));
3266
+ worktreeGuard = { reachable: live.reachable, protected: staleWorktrees.length - idleWorktrees.length };
3267
+ // A session and its worktree go together: removing one without the other left
3268
+ // sessions pointing at a deleted checkout (or checkouts nobody can reach).
3269
+ const linked = linkSessionWorktrees({
3270
+ sessions: allSessions,
3271
+ staleSessions: selectedSessions,
3272
+ staleWorktrees: idleWorktrees.map((e) => e.path),
3273
+ isLive,
3274
+ exists: (p) => fs.existsSync(p),
3275
+ });
3276
+ const staleSessions = linked.sessions;
3277
+ const worktreeMtime = new Map(idleWorktrees.map((e) => [path.resolve(e.path), e.mtimeMs]));
3278
+ const worktreesToRemove = linked.worktrees.map((p) => {
3279
+ let mtimeMs = worktreeMtime.get(p);
3280
+ if (mtimeMs === undefined) { try { mtimeMs = fs.statSync(p).mtimeMs; } catch { mtimeMs = now; } }
3281
+ return { path: p, mtimeMs };
3282
+ }).sort((a, b) => b.mtimeMs - a.mtimeMs);
3283
+ if (doSessions || staleSessions.length) {
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
- // Keep the newest N worktrees overall (across all roots), matching the node
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
 
@@ -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 (TERMINAL.has(value.status) && this.backend.answer) {
115
+ if (SETTLED.has(value.status) && this.backend.answer) {
113
116
  // Best-effort: an unreachable machine still leaves status + references.
114
117
  const answer = cached?.value.answer ?? await this.backend.answer(raw).catch(() => undefined);
115
118
  if (answer)
@@ -132,7 +135,9 @@ export class RunDelegationService {
132
135
  const existing = expectedSource ? recent.find((r) => r.source === expectedSource) : undefined;
133
136
  if (existing)
134
137
  return safeRun(existing, provenance);
135
- const active = recent.filter((r) => parseDelegationSource(r.source)?.parentSessionId === sessionId && !TERMINAL.has(r.status)).length;
138
+ // A parked child (needs_attention) runs nothing until a person acts, so it
139
+ // doesn't hold one of the parent's concurrent slots.
140
+ const active = recent.filter((r) => parseDelegationSource(r.source)?.parentSessionId === sessionId && !SETTLED.has(r.status)).length;
136
141
  if (active >= RUN_TOOL_LIMITS.maxConcurrentChildren)
137
142
  throw new Error(`concurrent child Run limit (${RUN_TOOL_LIMITS.maxConcurrentChildren}) reached`);
138
143
  const raw = await this.backend.start(sessionId, input, provenance);
@@ -155,7 +160,7 @@ export class RunDelegationService {
155
160
  if (signal?.aborted)
156
161
  throw signal.reason ?? new Error("wait cancelled; the child Run may still be running");
157
162
  const result = await this.authorized(sessionId, runId, true);
158
- if (TERMINAL.has(result.status))
163
+ if (SETTLED.has(result.status))
159
164
  return result;
160
165
  if (this.now() >= deadline)
161
166
  return { ...result, timedOut: true, wait: { timedOut: true, childContinues: true } };
package/dist/server.js CHANGED
@@ -527,7 +527,7 @@ const runDelegation = new RunDelegationService({
527
527
  throw error;
528
528
  }
529
529
  },
530
- start: async (_sessionId, input, provenance) => {
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: `bivy-room-v1:${targetId}:${seal(pairingStore.roomKey(), input.instructions)}`,
548
+ // Only the target machine can open its Run body, so seal with ITS room
549
+ // key: this machine's own for local work, the sibling's (learned over
550
+ // the sealed pairing handshake) for another machine.
551
+ body: `bivy-room-v1:${targetId}:${seal(targetId === identity.nodeId ? pairingStore.roomKey() : await siblingRoomKey(targetId), input.instructions)}`,
549
552
  repo: input.repo,
550
553
  node: machine,
551
554
  runtimeId: input.agent,
552
555
  model: input.model,
553
- approvalMode: input.safety?.approval,
554
- sandbox: input.safety?.sandbox,
556
+ // A child acts for the same user in the same context as its parent, so
557
+ // it inherits the parent session's safety unless the caller set one. The
558
+ // queue's own default ("risky" with nobody to approve) denied ordinary
559
+ // commands, leaving children that could only describe what they'd do.
560
+ approvalMode: input.safety?.approval ?? openSessions.get(sessionId)?.approvalMode,
561
+ sandbox: input.safety?.sandbox ?? openSessions.get(sessionId)?.sandbox,
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
- const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel : identity.name;
578
+ // The queue routes by label `bivy/<machine>`.
579
+ const machine = typeof routing.nodeLabel === "string" && routing.nodeLabel ? routing.nodeLabel.replace(/^bivy\//, "") : identity.name;
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
- try {
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
- try {
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
- sessionNamer.setSessionName(record, item.title);
5600
+ // A delegated child's queue title is generic ("Delegated Run": the task is
5601
+ // sealed, the queue never sees it). Name it after the task itself, which
5602
+ // only this machine can read, so it's recognizable in the session list.
5603
+ sessionNamer.setSessionName(record, delegatedProvenance ? delegatedSessionTitle(item.body) ?? item.title : item.title);
5567
5604
  persistSessionMetadata(record);
5568
5605
  if (item.model) {
5569
- try {
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
- await this.paired;
84
+ // A sibling that never answers pairing (offline behind a stale "online",
85
+ // or an incompatible build) must fail, not hang its caller forever.
86
+ const timeoutMs = this.opts.pairTimeoutMs ?? 20_000;
87
+ let timer;
88
+ try {
89
+ await Promise.race([
90
+ this.paired,
91
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("sibling did not answer pairing")), timeoutMs); }),
92
+ ]);
93
+ }
94
+ catch (err) {
95
+ this.close();
96
+ throw err;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
84
101
  }
85
102
  async onMessage(raw, grant) {
86
103
  let msg;
@@ -94,11 +111,23 @@ export class SiblingClient {
94
111
  // Begin account pairing to obtain the sibling's room key.
95
112
  // `ephemeral`: a sibling replica is a node↔node bridge, not a user device —
96
113
  // authorized for the room key but never listed as a signed-in device.
97
- this.sendControl({ t: "pair", p: { k: "pair.account", sessionToken: grant, devicePublicKeyB64: this.keypair.publicKeyB64, label: this.opts.label ?? "Bivy replica", ephemeral: true } });
114
+ // Pair payloads travel as a JSON string, as the node's relay client
115
+ // expects (it ignores any other shape) and as it replies.
116
+ this.sendControl({ t: "pair", p: JSON.stringify({ k: "pair.account", sessionToken: grant, devicePublicKeyB64: this.keypair.publicKeyB64, label: this.opts.label ?? "Bivy replica", ephemeral: true }) });
98
117
  return;
99
118
  }
100
119
  if (msg.t === "pair") {
101
- this.handlePair(msg.p);
120
+ let pair = msg.p;
121
+ if (typeof pair === "string") {
122
+ try {
123
+ pair = JSON.parse(pair);
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ }
129
+ if (pair && typeof pair === "object")
130
+ this.handlePair(pair);
102
131
  return;
103
132
  }
104
133
  if (msg.t === "frame" && typeof msg.p === "string" && this.cipher) {
@@ -119,6 +148,7 @@ export class SiblingClient {
119
148
  if (k === "pair.welcome") {
120
149
  try {
121
150
  const roomKey = acceptWelcome(this.keypair, { nodePublicKeyB64: String(p.nodePublicKeyB64 ?? ""), wrapped: String(p.wrapped ?? "") });
151
+ this.siblingRoomKey = roomKey;
122
152
  this.cipher = new RoomCipher(roomKey);
123
153
  this.resolvePaired?.();
124
154
  }
@@ -141,6 +171,13 @@ export class SiblingClient {
141
171
  }
142
172
  this.opts.onEvent?.(event);
143
173
  }
174
+ /** The sibling's room key, once paired: what seals content only it can read
175
+ * (e.g. a delegated Run's instructions queued for that machine). */
176
+ roomKey() {
177
+ if (!this.siblingRoomKey)
178
+ throw new Error("sibling client not paired");
179
+ return this.siblingRoomKey;
180
+ }
144
181
  /** Send a command and await its correlated reply event (by requestId). */
145
182
  request(command, timeoutMs = 30_000) {
146
183
  const requestId = `repl-${++this.reqSeq}-${Date.now()}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.11-staging.9",
3
+ "version": "0.19.0-staging.1",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",