@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.
- package/bin/acp-shim.mjs +142 -16
- package/bin/agent-manifest.json +2 -2
- package/bin/bivy.mjs +183 -27
- package/bin/prune-sessions.mjs +38 -0
- package/dist/agent-instructions.js +4 -0
- package/dist/agents/claude-code/integration.js +1 -1
- package/dist/agents/claude-code/runtime.js +25 -2
- package/dist/agents/codex/integration.js +1 -1
- package/dist/agents/profiles.js +3 -3
- package/dist/apps/gateway.js +53 -17
- package/dist/apps/preview-shell.js +2 -0
- package/dist/apps/remote-preview.js +4 -2
- package/dist/apps/service.js +18 -3
- package/dist/apps/styles.css +29 -1
- package/dist/apps/types.js +8 -0
- package/dist/certification/generated.js +4 -4
- package/dist/controllers/app-commands.js +7 -3
- package/dist/controllers/fork-commands.js +20 -20
- package/dist/exec.js +41 -4
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +4 -1
- package/dist/run-tools.js +8 -0
- package/dist/runtime/auth-errors.js +3 -2
- package/dist/runtime/model-ref.js +33 -0
- package/dist/runtime/protocol.js +60 -4
- package/dist/runtime/tool-call-map.js +24 -0
- package/dist/server.js +235 -5
- package/dist/session/delegation-cards.js +28 -0
- package/dist/session/delegations.js +21 -0
- package/dist/session/event-log.js +61 -3
- package/dist/session/fork-standup.js +5 -0
- package/dist/session/fork.js +5 -1
- package/dist/session/transcript-merge.js +29 -1
- package/dist/session/transcript-persistence.js +4 -0
- package/package.json +2 -2
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
|
|
@@ -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.
|
|
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.
|
|
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
|
}
|
package/dist/agents/profiles.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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)
|
|
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 = !
|
|
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 (!
|
|
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,
|
|
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
|
|
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
|
|
919
|
+
const session = entry && this.session(req, entry.view.id);
|
|
884
920
|
const display = entry?.target.kind === "display";
|
|
885
|
-
if (!entry || !
|
|
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,
|
|
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,
|
|
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) {
|
package/dist/apps/service.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/apps/styles.css
CHANGED
|
@@ -354,6 +354,9 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
|
|
|
354
354
|
@keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
|
355
355
|
.session-row { position: relative; display: flex; align-items: center; }
|
|
356
356
|
.session-row .session-item { flex: 1; min-width: 0; }
|
|
357
|
+
/* A delegated child under its parent session (sessionNesting.ts). */
|
|
358
|
+
.session-row.nested { padding-inline-start: var(--space-4); }
|
|
359
|
+
.session-row.nested::before { content: ""; position: absolute; inset-block: var(--space-2); inset-inline-start: var(--space-2); border-inline-start: 1px solid var(--line-strong); }
|
|
357
360
|
.pending-launch-actions { display: flex; flex: none; gap: 4px; padding-right: 6px; }
|
|
358
361
|
.pending-launch-actions button { min-height: var(--space-7); min-width: var(--space-7); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--ink); padding: 5px 8px; font-size: 12px; cursor: pointer; }
|
|
359
362
|
.pending-launch-actions button:last-child { color: var(--muted); background: transparent; }
|
|
@@ -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
|
-
|
|
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
|
package/dist/apps/types.js
CHANGED
|
@@ -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.
|
|
7
|
-
{ id: "codex-approvals", status: "active", executionMode: "protocol", pinnedVersion: "0.
|
|
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.
|
|
10
|
-
{ id: "grok", status: "active", executionMode: "protocol", pinnedVersion: "1.0.
|
|
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) =>
|
|
38
|
-
|
|
39
|
-
|
|
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
|
};
|