@bivy/bivy 0.18.10 → 0.18.11-staging.10

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.
@@ -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
@@ -42,6 +42,10 @@ export const BIVY_AGENT_NOTE = [
42
42
  '`bivy suggest "<complete instruction>" [--title "short label"]` instead of only listing them. The user can ' +
43
43
  "start each in one tap, in this session or in a parallel one that works in its own copy of the project, so " +
44
44
  "write it to stand on its own, with paths relative to the project root.",
45
+ "- Only when the user asks for another agent or another of their machines to take part: " +
46
+ '`bivy delegate "<self-contained task>" --agent <id> [--machine <name>] --wait` runs it there and prints ' +
47
+ "its answer and any branch/PR; `--to codex,grok@<machine>` asks several to compare, `bivy delegate machines` " +
48
+ "lists machines and their agents. Use your own sub-agents for everything else.",
45
49
  ].join("\n");
46
50
  const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
47
51
  "instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
@@ -4,7 +4,7 @@ import { defineAgentIntegration } from "../definition.js";
4
4
  import { withExactCapabilitySurface } from "../../runtime/types.js";
5
5
  import { createCredentialStore } from "../../runtime/credentials.js";
6
6
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
7
- export const CLAUDE_TESTED_VERSION = "0.3.281";
7
+ export const CLAUDE_TESTED_VERSION = "0.3.284";
8
8
  const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
9
9
  toolInterception: true,
10
10
  modelSelection: true,
@@ -285,6 +285,11 @@ const INTERRUPT_MARKER = /^\s*\[Request interrupted by user/;
285
285
  function hasMetaFlag(entry) {
286
286
  return entry?.isMeta === true || entry?.isCompactSummary === true || entry?.isSynthetic === true;
287
287
  }
288
+ /** True when an assistant message is the CLI's stand-in for a failed API call
289
+ * (SDK `error`, transcript `isApiErrorMessage`) rather than the model's reply. */
290
+ function isApiErrorMessage(message) {
291
+ return Boolean(message?.error) || message?.isApiErrorMessage === true;
292
+ }
288
293
  /** True when a role:"user" turn is a model-only meta injection (see
289
294
  * DROP_META_TEXT) rather than a real human prompt. Callers must exclude
290
295
  * tool_result-bearing turns first — those carry real tool output, never meta. */
@@ -1084,6 +1089,10 @@ class ClaudeSession {
1084
1089
  if (!images.length)
1085
1090
  return;
1086
1091
  const toolUseId = String(block.tool_use_id ?? "");
1092
+ // A file read returns an image that already exists on disk — most often
1093
+ // one the user just uploaded — so surfacing it would repeat it in chat.
1094
+ if (this.toolDetailsByUseId.get(toolUseId)?.kind === "read")
1095
+ return;
1087
1096
  const toolName = this.toolNamesByUseId.get(toolUseId) ?? "tool";
1088
1097
  for (const image of images) {
1089
1098
  const byteLength = Buffer.byteLength(image.data, "base64");
@@ -1123,9 +1132,12 @@ class ClaudeSession {
1123
1132
  break;
1124
1133
  // Claude Code sometimes reports auth failures as an ordinary assistant
1125
1134
  // text message rather than throwing. Intercept it before it is persisted
1126
- // or shown, refresh, and transparently continue the same prompt.
1135
+ // or shown, refresh, and transparently continue the same prompt. Only a
1136
+ // message the SDK flags as an API error qualifies: matching the model's
1137
+ // own prose (an answer that mentions "no … token") re-drove the prompt
1138
+ // after the turn had already answered it.
1127
1139
  const assistantText = extractText(message.message);
1128
- if (assistantText && await this.recoverFromAuthError(assistantText))
1140
+ if (isApiErrorMessage(message) && assistantText && await this.recoverFromAuthError(assistantText))
1129
1141
  break;
1130
1142
  const model = message.message?.model;
1131
1143
  if (model)
@@ -1282,6 +1294,17 @@ class ClaudeSession {
1282
1294
  // falls through so its capabilities/commands reach the client.)
1283
1295
  if (message?.subtype === "task_notification" || message?.subtype === "compact_boundary")
1284
1296
  break;
1297
+ // Banners Claude Code shows the user: a hook's block reason, slash-command
1298
+ // output, warnings (SDK 0.3.283+). Show them as the chat's system notice
1299
+ // and keep them in the transcript; "info" is transcript-mode-only upstream.
1300
+ if (message?.subtype === "informational") {
1301
+ const text = typeof message.content === "string" ? message.content.trim() : "";
1302
+ if (text && message.level !== "info") {
1303
+ this.messages.push({ role: "system", content: text, timestamp: Date.now() });
1304
+ this.emit({ type: "session.notice", level: message.level === "warning" ? "warning" : "info", message: text });
1305
+ }
1306
+ break;
1307
+ }
1285
1308
  this.emit({ type: String(message?.type ?? "unknown"), raw: message });
1286
1309
  break;
1287
1310
  }
@@ -10,7 +10,7 @@ import { codexCredentialPreflight } from "../../runtime/codex-preflight.js";
10
10
  import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout, exportCodexRollout, importCodexRollout, } from "../../runtime/codex-sessions.js";
11
11
  import { ProtocolRuntime } from "../../runtime/protocol.js";
12
12
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
13
- export const CODEX_TESTED_VERSION = "0.156.1";
13
+ export const CODEX_TESTED_VERSION = "0.159.0";
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
16
16
  }
@@ -43,7 +43,7 @@ export const AGENT_PROFILES = {
43
43
  // Approve/Deny + session/load resume + a real model picker), the same bar Pi,
44
44
  // Claude Code, and Codex clear. See `acp` below for the version fallback.
45
45
  supportTier: "supported",
46
- testedVersion: "1.18.32",
46
+ testedVersion: "1.18.33",
47
47
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
48
48
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
49
49
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -363,7 +363,7 @@ export const AGENT_PROFILES = {
363
363
  // Approve/Deny + native resume + live model state), the same bar Pi, Claude
364
364
  // Code, Codex, and OpenCode clear. See `acp` below for the version fallback.
365
365
  supportTier: "supported",
366
- testedVersion: "1.0.41",
366
+ testedVersion: "1.0.44",
367
367
  authOwner: "mixed",
368
368
  blurb: "xAI's official Grok coding agent (Grok CLI) — SuperGrok/X subscription or API key.",
369
369
  // Official CLI: `grok -p "<prompt>"` (alias `--single`) runs one headless
@@ -424,7 +424,7 @@ export const AGENT_PROFILES = {
424
424
  // (protocolVersion 1: session/new, session/prompt, session/request_permission,
425
425
  // loadSession resume, live model state) — the governed ProtocolRuntime path
426
426
  // that supersedes the `--always-approve` pipe above with real per-tool
427
- // Approve/Deny. Validated against grok 1.0.41, so it is ON by default
427
+ // Approve/Deny. Validated against grok 1.0.44, so it is ON by default
428
428
  // (`preferred`) — gated on the binary actually describing the `agent`
429
429
  // subcommand ("Run Grok without the interactive UI" in `grok --help`), so an
430
430
  // older CLI falls back to the pipe path rather than opening a dead session.
@@ -132,6 +132,8 @@ const poll=async()=>{try{const r=await fetch(location.href,{method:'HEAD',cache:
132
132
  }
133
133
  /** Copied links are reusable until revoked, capped so a forgotten one lapses. */
134
134
  export const SHARE_TTL = 24 * HOUR;
135
+ /** The longest a copied link may be asked to last. */
136
+ export const SHARE_TTL_MAX = 7 * 24 * HOUR;
135
137
  /** Bivy's packaged apps load the client from their own scheme, so a preview
136
138
  * framed there has this ancestor instead of a web origin. (Node's URL gives
137
139
  * these "null" origins, so they cannot be configured as return origins.) */
@@ -204,9 +206,13 @@ export class AppGateway {
204
206
  ["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
205
207
  ]);
206
208
  /** `reviewer`: the browser session came from a copied (shared) link.
209
+ * `viewOnly`: that link came without the reviewer tools.
207
210
  * `embedded`: it runs framed inside a Bivy client (a third-party frame). */
208
211
  sessions = new Map();
209
212
  sockets = new Map();
213
+ /** Connections opened by people with a copied link, so Stop sharing can end
214
+ * theirs and leave the owner's. */
215
+ shared = new WeakSet();
210
216
  captureAfter = 0;
211
217
  capturing = false;
212
218
  displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
@@ -255,11 +261,33 @@ export class AppGateway {
255
261
  return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
256
262
  }
257
263
  /** Public reviewers use the same isolated controls as the owner, but can
258
- * only submit notes. Valid until revoked or SHARE_TTL. */
259
- share(id) {
264
+ * only submit notes; without `controls` they only see the app. Valid until
265
+ * revoked, sharing stops, or `ttl` (SHARE_TTL by default). */
266
+ share(id, { ttl = SHARE_TTL, controls = true } = {}) {
260
267
  this.requireWeb(id);
261
- const expiresAt = Date.now() + SHARE_TTL;
262
- return { url: `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
268
+ if (!Number.isFinite(ttl) || ttl <= 0 || ttl > SHARE_TTL_MAX)
269
+ throw new Error("A share link can last at most 7 days.");
270
+ const expiresAt = Date.now() + ttl;
271
+ return { url: `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true, ...(controls ? {} : { viewOnly: true }) })}`, expiresAt, controls };
272
+ }
273
+ /** Copied links that still work: how many, and when the last one lapses. */
274
+ sharing(id) {
275
+ this.sweep();
276
+ const links = [...this.tickets.values()].filter((grant) => grant.appId === id && grant.reusable);
277
+ return links.length ? { links: links.length, expiresAt: Math.max(...links.map((grant) => grant.expires)) } : undefined;
278
+ }
279
+ /** Ends every copied link and the browser sessions and connections opened
280
+ * from them. The owner's own previews keep working. */
281
+ unshare(id) {
282
+ for (const [key, grant] of this.tickets)
283
+ if (grant.appId === id && grant.reusable)
284
+ this.tickets.delete(key);
285
+ for (const [key, session] of this.sessions)
286
+ if (session.appId === id && session.reviewer)
287
+ this.sessions.delete(key);
288
+ for (const socket of this.sockets.get(id) ?? [])
289
+ if (this.shared.has(socket))
290
+ socket.destroy();
263
291
  }
264
292
  revoke(id) {
265
293
  for (const map of [this.tickets, this.sessions])
@@ -313,13 +341,15 @@ export class AppGateway {
313
341
  const session = token ? this.sessions.get(token) : undefined;
314
342
  return session?.appId === id ? session : undefined;
315
343
  }
316
- authorize(req, id) { return this.session(req, id)?.expires; }
317
- track(id, socket, expires) {
344
+ track(id, socket, session) {
345
+ const expires = session.expires;
318
346
  let sockets = this.sockets.get(id);
319
347
  if (!sockets) {
320
348
  sockets = new Set();
321
349
  this.sockets.set(id, sockets);
322
350
  }
351
+ if (session.reviewer)
352
+ this.shared.add(socket);
323
353
  if (sockets.has(socket))
324
354
  return;
325
355
  sockets.add(socket);
@@ -369,7 +399,7 @@ export class AppGateway {
369
399
  res.setHeader("Content-Type", "application/json");
370
400
  // Someone opening a shared link sees "Made with Bivy" under the app; the owner doesn't.
371
401
  const badge = grant.reusable && this.badge ? await this.badge().catch(() => true) : false;
372
- res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(badge ? { badge: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
402
+ res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(grant.viewOnly ? { controls: false } : {}), ...(badge ? { badge: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
373
403
  return;
374
404
  }
375
405
  res.writeHead(404);
@@ -433,7 +463,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
433
463
  // A browser session never outlives the link that created it.
434
464
  const expires = Math.min(Date.now() + HOUR, grant.expires);
435
465
  const token = randomBytes(32).toString("hex");
436
- this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true, ...(embedded ? { embedded } : {}) });
466
+ this.sessions.set(token, { appId: id, expires, reviewer: grant.reusable === true, ...(grant.viewOnly ? { viewOnly: true } : {}), ...(embedded ? { embedded } : {}) });
437
467
  // Framed inside Bivy, the cookie is third-party: it must be SameSite=None,
438
468
  // and Partitioned keys it to Bivy's top-level site so no other site can use it.
439
469
  const scope = embedded ? "SameSite=None; Partitioned" : "SameSite=Lax";
@@ -442,16 +472,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
442
472
  res.end();
443
473
  return;
444
474
  }
445
- const session = this.session(req, id), expires = session?.expires;
475
+ const session = this.session(req, id);
446
476
  // A home-screen visit without access goes through Bivy to sign in, then
447
477
  // comes back to the same page. Framed loads keep the plain message.
448
- const signIn = !expires && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
478
+ const signIn = !session && req.method === "GET" && req.headers["sec-fetch-dest"] === "document" && req.url?.startsWith("/") ? this.signIn?.(entry, req.url) : undefined;
449
479
  if (signIn) {
450
480
  res.writeHead(303, { location: signIn });
451
481
  res.end();
452
482
  return;
453
483
  }
454
- if (!expires) {
484
+ if (!session) {
455
485
  res.writeHead(401);
456
486
  res.end("Preview access expired. Open a new preview link from Bivy.");
457
487
  return;
@@ -472,7 +502,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
472
502
  res.end();
473
503
  return;
474
504
  }
475
- this.track(id, req.socket, expires);
505
+ this.track(id, req.socket, session);
476
506
  if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
477
507
  this.revision(req, res, entry);
478
508
  return;
@@ -483,10 +513,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
483
513
  }
484
514
  if (req.url === INSPECTOR_PATH && req.method === "GET") {
485
515
  res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
486
- res.end(inspectorScript(this.shellOrigin(id), session?.reviewer === true, session?.embedded === true));
516
+ res.end(inspectorScript(this.shellOrigin(id), session.reviewer === true && !session.viewOnly, session.embedded === true));
487
517
  return;
488
518
  }
489
519
  if (req.url === NOTES_PATH && req.method === "POST") {
520
+ // A view-only link shows the app and nothing else.
521
+ if (session.viewOnly) {
522
+ res.writeHead(403);
523
+ res.end();
524
+ return;
525
+ }
490
526
  await this.note(req, res, entry);
491
527
  return;
492
528
  }
@@ -880,13 +916,13 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
880
916
  }
881
917
  upgrade(req, socket, head) {
882
918
  const entry = this.entry(req);
883
- const expires = entry && this.authorize(req, entry.view.id);
919
+ const session = entry && this.session(req, entry.view.id);
884
920
  const display = entry?.target.kind === "display";
885
- if (!entry || !expires || (entry.target.kind !== "service" && !(display && req.url === DISPLAY_SOCKET_PATH)) || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
921
+ if (!entry || !session || (entry.target.kind !== "service" && !(display && req.url === DISPLAY_SOCKET_PATH)) || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
886
922
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
887
923
  return;
888
924
  }
889
- this.track(entry.view.id, socket, expires);
925
+ this.track(entry.view.id, socket, session);
890
926
  if (display) {
891
927
  this.displaySocket(req, socket, head, entry);
892
928
  return;
@@ -898,7 +934,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
898
934
  upstream.setTimeout(10_000, () => upstream.destroy());
899
935
  upstream.on("upgrade", (response, peer, upstreamHead) => {
900
936
  peer.setTimeout(0);
901
- this.track(entry.view.id, peer, expires);
937
+ this.track(entry.view.id, peer, session);
902
938
  const headers = cleanHeaders(response.headers);
903
939
  delete headers["set-cookie"];
904
940
  socket.write(`HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n${Object.entries(headers).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}: ${value}\r\n`).join("")}\r\n`);
@@ -162,6 +162,8 @@ function show(data,launch){
162
162
  $('draft-text').maxLength=1000;
163
163
  $('send-errors').textContent='Leave a note…';
164
164
  }
165
+ // A view-only share link shows the app without Bivy's tools.
166
+ $('dock').hidden=data.controls===false;
165
167
  $('made-with').hidden=!data.badge;
166
168
  document.body.classList.toggle('badged',Boolean(data.badge));
167
169
  $('title').textContent=data.name;
@@ -65,11 +65,13 @@ export class RemotePreview {
65
65
  throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
66
66
  return this.gateway.open(id, returnTo);
67
67
  }
68
- share(id) {
68
+ share(id, options) {
69
69
  if (!this.available)
70
70
  throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
71
- return this.gateway.share(id);
71
+ return this.gateway.share(id, options);
72
72
  }
73
+ sharing(id) { return this.gateway?.sharing(id); }
74
+ unshare(id) { this.gateway?.unshare(id); }
73
75
  revoke(id) { this.gateway?.revoke(id); }
74
76
  address(id) { return this.available ? this.gateway.address(id) : undefined; }
75
77
  openDirect(id) {
@@ -1,3 +1,6 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { SHARE_DURATIONS } from "./types.js";
1
4
  import { randomBytes } from "node:crypto";
2
5
  import { REVIEW_MODES, shouldReview, visualChange } from "./review.js";
3
6
  import fs from "node:fs";
@@ -73,6 +76,9 @@ export class AppService {
73
76
  view.stats = structuredClone(entry.stats);
74
77
  if (entry?.lastPath)
75
78
  view.lastPath = entry.lastPath;
79
+ const sharing = this.gateway?.sharing?.(view.id);
80
+ if (sharing)
81
+ view.sharing = sharing;
76
82
  }
77
83
  return { apps, previewAvailable: available };
78
84
  }
@@ -577,18 +583,21 @@ export class AppService {
577
583
  void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
578
584
  this.servers.delete(id);
579
585
  }
580
- share(sessionId, appId, viewId) {
586
+ share(sessionId, appId, viewId, options = {}) {
581
587
  if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
582
588
  throw new Error("Only web views have preview links.");
583
589
  if (!this.gateway)
584
590
  throw new Error("Bivy's preview service is unavailable on this connection.");
585
- return this.gateway.share(viewId);
591
+ const duration = options.duration === undefined ? undefined : SHARE_DURATIONS.find((row) => row.id === options.duration);
592
+ if (options.duration !== undefined && !duration)
593
+ throw new Error(`A share link lasts ${SHARE_DURATIONS.map((row) => row.id).join(", ")}.`);
594
+ return this.gateway.share(viewId, { ...(duration ? { ttl: duration.ms } : {}), controls: options.controls !== false });
586
595
  }
587
596
  /** `bivy app share`: mints a share link for a web view picked by app and/or
588
597
  * view ID or name, like `present` (default: the one opened last). */
589
598
  shareView(sessionId, input) {
590
599
  const entry = this.pickTarget(sessionId, input);
591
- return { ...this.share(sessionId, entry.app.id, entry.view.id), appId: entry.app.id, viewId: entry.view.id, app: entry.app.name, view: entry.view.name };
600
+ return { ...this.share(sessionId, entry.app.id, entry.view.id, input), appId: entry.app.id, viewId: entry.view.id, app: entry.app.name, view: entry.view.name };
592
601
  }
593
602
  clearNotes(sessionId, appId, viewId) {
594
603
  this.registry.requireView(sessionId, appId, viewId);
@@ -623,6 +632,12 @@ export class AppService {
623
632
  throw new Error(`No web view called "${input.view}"${input.app ? ` in "${input.app}"` : ""}. Run bivy app list to see them.`);
624
633
  return found;
625
634
  }
635
+ /** Ends a view's share links and what people opened with them; the owner's previews and the app stay. */
636
+ unshare(sessionId, appId, viewId) {
637
+ this.registry.requireView(sessionId, appId, viewId);
638
+ this.gateway?.unshare?.(viewId);
639
+ return { ok: true };
640
+ }
626
641
  /** Ends every link, browser session and open connection for one view; the app stays. */
627
642
  revoke(sessionId, appId, viewId) {
628
643
  this.registry.requireView(sessionId, appId, viewId);
@@ -354,6 +354,9 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
354
354
  @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
355
355
  .session-row { position: relative; display: flex; align-items: center; }
356
356
  .session-row .session-item { flex: 1; min-width: 0; }
357
+ /* A delegated child under its parent session (sessionNesting.ts). */
358
+ .session-row.nested { padding-inline-start: var(--space-4); }
359
+ .session-row.nested::before { content: ""; position: absolute; inset-block: var(--space-2); inset-inline-start: var(--space-2); border-inline-start: 1px solid var(--line-strong); }
357
360
  .pending-launch-actions { display: flex; flex: none; gap: 4px; padding-right: 6px; }
358
361
  .pending-launch-actions button { min-height: var(--space-7); min-width: var(--space-7); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--ink); padding: 5px 8px; font-size: 12px; cursor: pointer; }
359
362
  .pending-launch-actions button:last-child { color: var(--muted); background: transparent; }
@@ -1193,7 +1196,9 @@ button.sheet-action:last-child { border-bottom: 0; }
1193
1196
  margin: 0;
1194
1197
  max-height: calc(var(--app-h, 100dvh) - (2 * var(--space-5)));
1195
1198
  }
1196
- .sheet-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); flex: none; touch-action: none; }
1199
+ /* Lifted above the sheet body so a menu opened from the header (the model
1200
+ picker's reasoning menu) layers over the body's sticky search field. */
1201
+ .sheet-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); flex: none; touch-action: none; position: relative; z-index: calc(var(--z-sticky) + 1); }
1197
1202
  .sheet-title { font-weight: 700; flex: 1; }
1198
1203
  .sheet-close { border: 0; background: transparent; color: var(--muted); font-size: 22px; line-height: 1; min-width: var(--space-7); min-height: var(--space-7); }
1199
1204
  .sheet-close:active { transform: scale(0.9); }
@@ -3105,6 +3110,26 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3105
3110
  .suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
3106
3111
  .suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
3107
3112
  .suggestion-status { margin: var(--space-2) 0 0; display: flex; align-items: center; gap: var(--space-2); color: var(--ok); font-size: var(--text-sm); font-weight: var(--weight-medium); }
3113
+ /* Delegation (DelegationCard.tsx): a task handed to another agent/machine.
3114
+ Shell from .card, status from .badge; layout only here. A fan-out renders as
3115
+ one group whose members sit side by side where there is room. */
3116
+ .delegation-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
3117
+ .delegation-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
3118
+ .delegation-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
3119
+ .delegation-head { align-items: flex-start; }
3120
+ .delegation-target { display: flex; flex-direction: column; font-weight: var(--weight-semibold); overflow-wrap: anywhere; min-inline-size: 0; }
3121
+ .delegation-machine { color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-normal); }
3122
+ .delegation-task { margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
3123
+ .delegation-failure { margin: 0; color: var(--danger); font-size: var(--text-sm); overflow-wrap: anywhere; }
3124
+ .delegation-answer summary { cursor: pointer; color: var(--muted); font-size: var(--text-sm); }
3125
+ .delegation-answer p { margin: var(--space-1) 0 0; font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 18em; overflow-y: auto; }
3126
+ .delegation-refs { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
3127
+ .delegation-refs code { overflow-wrap: anywhere; }
3128
+ .delegation-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
3129
+ .delegation-group { display: flex; flex-direction: column; gap: var(--space-1); margin-block: var(--space-2); }
3130
+ .delegation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-2); }
3131
+ .delegation-member { display: flex; flex-direction: column; gap: var(--space-1); min-inline-size: 0; }
3132
+ .delegation-member > .btn { align-self: flex-start; }
3108
3133
  /* Review card (ReviewCard.tsx): the app at a moment worth judging. The
3109
3134
  screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
3110
3135
  canonical .segmented switch; one primary action. */
@@ -3147,6 +3172,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3147
3172
  .apps-empty strong { color: var(--ink); font-size: var(--text-base); }
3148
3173
  .apps-empty p { margin: 0; max-inline-size: 34ch; }
3149
3174
  .apps-foot { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
3175
+ /* Share sheet (AppAccess.tsx): the sharing status sits in the sheet, not at page edge. */
3176
+ .apps-sheet > .banner { border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); }
3177
+ .apps-sheet > .settings-toggle-row { padding-inline: var(--space-1); }
3150
3178
  /* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
3151
3179
  .preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
3152
3180
  /* Share-sheet landing (ShareDestinationSheet.tsx): what was shared, then one
@@ -24,3 +24,11 @@ export function isAppReference(value) {
24
24
  const ref = value;
25
25
  return typeof ref.appId === "string" && /^[a-f0-9]{32}$/.test(ref.appId) && typeof ref.sessionId === "string" && ref.sessionId.length > 0 && typeof ref.name === "string" && ref.name.length <= 100;
26
26
  }
27
+ /** How long a new share link works. Links live in the machine's memory, so a
28
+ * restart ends them sooner. Adding a choice means adding a row. */
29
+ export const SHARE_DURATIONS = [
30
+ { id: "1h", label: "1 hour", ms: 3_600_000 },
31
+ { id: "1d", label: "1 day", ms: 86_400_000 },
32
+ { id: "7d", label: "7 days", ms: 7 * 86_400_000 },
33
+ ];
34
+ export const DEFAULT_SHARE_DURATION = "1d";
@@ -3,10 +3,10 @@
3
3
  export const CERTIFICATION_MATRIX = {
4
4
  schemaVersion: 1,
5
5
  agents: [
6
- { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.281", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
- { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.156.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
6
+ { id: "claude-code-sdk", status: "active", executionMode: "protocol", pinnedVersion: "0.3.284", capabilities: ["toolInterception", "modelSelection", "resume"] },
7
+ { id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.159.0", capabilities: ["toolInterception", "modelSelection", "resume"] },
8
8
  { id: "pi", status: "active", executionMode: "protocol", pinnedVersion: "0.87.1", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
- { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.32", capabilities: ["toolInterception", "modelSelection", "resume"] },
10
- { id: "grok", status: "active", executionMode: "protocol", pinnedVersion: "1.0.41", capabilities: ["toolInterception", "modelSelection", "resume"] },
9
+ { id: "opencode", status: "active", executionMode: "protocol", pinnedVersion: "1.18.33", capabilities: ["toolInterception", "modelSelection", "resume"] },
10
+ { id: "grok", status: "active", executionMode: "protocol", pinnedVersion: "1.0.44", capabilities: ["toolInterception", "modelSelection", "resume"] },
11
11
  ]
12
12
  };
@@ -34,9 +34,13 @@ export function createAppCommands(service, workspaceFor, published = () => { })
34
34
  "apps.notes": (msg) => service.notes(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, since: typeof msg.since === "number" ? msg.since : undefined }),
35
35
  "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
36
36
  // The app UI passes exact IDs; `bivy app share` passes an app and/or view by ID or name.
37
- "apps.share": (msg) => typeof msg.viewId === "string" && typeof msg.appId === "string"
38
- ? service.share(String(msg.sessionId), msg.appId, msg.viewId)
39
- : service.shareView(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined }),
37
+ "apps.share": (msg) => {
38
+ const options = { duration: msg.duration, controls: msg.controls !== false };
39
+ return typeof msg.viewId === "string" && typeof msg.appId === "string"
40
+ ? service.share(String(msg.sessionId), msg.appId, msg.viewId, options)
41
+ : service.shareView(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, ...options });
42
+ },
43
+ "apps.unshare": (msg) => service.unshare(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
40
44
  "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
41
45
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
42
46
  };