@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.4

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.
@@ -0,0 +1,181 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import http from "node:http";
7
+ import { once } from "node:events";
8
+ import { spawn, execFileSync } from "node:child_process";
9
+ import { WebSocket } from "ws";
10
+ /** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
11
+ const CANDIDATES = [
12
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
13
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
14
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
15
+ ];
16
+ export function findChrome(env = process.env) {
17
+ if (env.BIVY_CHROME)
18
+ return fs.existsSync(env.BIVY_CHROME) ? env.BIVY_CHROME : undefined;
19
+ for (const candidate of CANDIDATES) {
20
+ if (candidate.startsWith("/")) {
21
+ if (fs.existsSync(candidate))
22
+ return candidate;
23
+ continue;
24
+ }
25
+ try {
26
+ return execFileSync("which", [candidate], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
27
+ }
28
+ catch { /* next */ }
29
+ }
30
+ // A Playwright install (common on dev machines) ships a headless shell.
31
+ const cache = path.join(os.homedir(), ".cache", "ms-playwright");
32
+ for (const dir of fs.existsSync(cache) ? fs.readdirSync(cache).sort().reverse() : []) {
33
+ for (const bin of ["chrome-headless-shell-linux64/chrome-headless-shell", "chrome-linux/chrome"]) {
34
+ const file = path.join(cache, dir, bin);
35
+ if (dir.startsWith("chromium") && fs.existsSync(file))
36
+ return file;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+ /** Serves a static snapshot on loopback for the duration of a shot. */
42
+ async function serveSnapshot(files) {
43
+ const server = http.createServer((req, res) => {
44
+ let file = decodeURIComponent((req.url ?? "/").split("?")[0]);
45
+ if (file.endsWith("/"))
46
+ file += "index.html";
47
+ const data = files.get(file) ?? (path.extname(file) ? undefined : files.get("/index.html"));
48
+ if (!data) {
49
+ res.writeHead(404);
50
+ res.end();
51
+ return;
52
+ }
53
+ res.end(data);
54
+ });
55
+ server.listen(0, "127.0.0.1");
56
+ await once(server, "listening");
57
+ return { origin: `http://127.0.0.1:${server.address().port}`, close: () => { server.close(); server.closeAllConnections(); } };
58
+ }
59
+ /** Minimal CDP client over one browser-level WebSocket (flattened sessions). */
60
+ class Cdp {
61
+ ws;
62
+ next = 0;
63
+ pending = new Map();
64
+ listeners = new Set();
65
+ constructor(ws) {
66
+ this.ws = ws;
67
+ ws.on("message", (raw) => {
68
+ const msg = JSON.parse(raw.toString());
69
+ if (msg.id !== undefined) {
70
+ const call = this.pending.get(msg.id);
71
+ this.pending.delete(msg.id);
72
+ if (msg.error)
73
+ call?.reject(new Error(msg.error.message));
74
+ else
75
+ call?.resolve(msg.result);
76
+ }
77
+ else
78
+ for (const fn of this.listeners)
79
+ fn(msg.method, msg.params, msg.sessionId);
80
+ });
81
+ }
82
+ send(method, params = {}, sessionId) {
83
+ const id = ++this.next;
84
+ return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params, sessionId })); });
85
+ }
86
+ once(method, sessionId, ms) {
87
+ return new Promise((resolve) => {
88
+ const fn = (m, _p, s) => { if (m === method && s === sessionId)
89
+ finish(); };
90
+ const finish = () => { clearTimeout(timer); this.listeners.delete(fn); resolve(); };
91
+ const timer = setTimeout(finish, ms);
92
+ this.listeners.add(fn);
93
+ });
94
+ }
95
+ }
96
+ /** Screenshots web views with one headless browser, one shot at a time, so
97
+ * small machines cope. Service views are loaded straight from loopback;
98
+ * static views from a throwaway loopback server. Files are PNGs in `outDir`. */
99
+ export async function takeShots(views, request, outDir, chrome = findChrome()) {
100
+ if (!chrome)
101
+ throw new Error("No Chrome or Chromium found on this machine. Install one, or set BIVY_CHROME to its path.");
102
+ fs.mkdirSync(outDir, { recursive: true });
103
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), "bivy-shot-"));
104
+ const servers = [];
105
+ let ws;
106
+ let browser;
107
+ const deadline = setTimeout(() => browser?.kill("SIGKILL"), 120_000);
108
+ // Chromium's sandbox is unavailable as root and where AppArmor blocks user
109
+ // namespaces (Ubuntu 23.10+). The pages are the session's own apps, already
110
+ // running as this user, so retrying without it doesn't widen what they can do.
111
+ const launch = (sandbox) => new Promise((resolve, reject) => {
112
+ const args = ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", "--mute-audio", ...(sandbox ? [] : ["--no-sandbox"]), "about:blank"];
113
+ const child = spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
114
+ browser = child;
115
+ let text = "";
116
+ const timer = setTimeout(() => reject(new Error("The browser didn't start in time.")), 20_000);
117
+ child.stderr.on("data", (chunk) => { text += chunk; const m = /DevTools listening on (ws:\/\/\S+)/.exec(text); if (m) {
118
+ clearTimeout(timer);
119
+ resolve(m[1]);
120
+ } });
121
+ child.once("exit", () => { clearTimeout(timer); reject(Object.assign(new Error("The browser exited before it was ready."), { noSandbox: /No usable sandbox|--no-sandbox/i.test(text) })); });
122
+ });
123
+ try {
124
+ const endpoint = await launch(process.getuid?.() !== 0).catch((error) => {
125
+ if (error.noSandbox)
126
+ return launch(false);
127
+ throw error;
128
+ });
129
+ ws = new WebSocket(endpoint, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
130
+ await once(ws, "open");
131
+ const cdp = new Cdp(ws);
132
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
133
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
134
+ await cdp.send("Page.enable", {}, sessionId);
135
+ const shots = [];
136
+ for (const entry of views) {
137
+ let origin;
138
+ if (entry.target.kind === "service")
139
+ origin = `http://127.0.0.1:${entry.target.port}`;
140
+ else if (entry.target.kind === "static") {
141
+ const server = await serveSnapshot(entry.target.files);
142
+ servers.push(server);
143
+ origin = server.origin;
144
+ }
145
+ else
146
+ continue;
147
+ for (const width of request.widths)
148
+ for (const theme of request.themes) {
149
+ const mobile = width < 600;
150
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
151
+ await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
152
+ const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
153
+ await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
154
+ await loaded;
155
+ await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
156
+ const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
157
+ const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
158
+ fs.writeFileSync(file, Buffer.from(data, "base64"));
159
+ shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
160
+ }
161
+ }
162
+ return shots;
163
+ }
164
+ finally {
165
+ clearTimeout(deadline);
166
+ ws?.terminate();
167
+ for (const server of servers)
168
+ server.close();
169
+ // Wait for the browser to exit before removing its profile: a killed
170
+ // Chrome can still be writing into it. Cleanup never fails a shot.
171
+ if (browser && browser.exitCode === null && browser.signalCode === null) {
172
+ const exited = once(browser, "exit").catch(() => { });
173
+ browser.kill("SIGKILL");
174
+ await exited;
175
+ }
176
+ try {
177
+ fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
178
+ }
179
+ catch { /* temp dir; the OS reclaims it */ }
180
+ }
181
+ }
@@ -1,23 +1,110 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { scanListeners } from "./listeners.js";
5
+ import { takeShots } from "./screenshot.js";
1
6
  /** Composes view providers; registry never spawns processes and the gateway
2
7
  * never knows about terminals. New providers can extend open/remove here. */
8
+ /** A managed server that keeps exiting is left down after this many restarts
9
+ * in RESTART_WINDOW; opening the view again tries afresh. */
10
+ const MAX_RESTARTS = 5;
11
+ /** Compare keeps this many shots per view, taken after servers settle. */
12
+ const COMPARE_KEEP = 4;
13
+ const COMPARE_SETTLE_MS = 1_500;
14
+ const RESTART_WINDOW = 10 * 60_000;
3
15
  export class AppService {
4
16
  registry;
5
17
  gateway;
6
18
  terminals;
7
19
  terminalStarts = new Map();
8
- constructor(registry, gateway, terminals) {
20
+ servers = new Map();
21
+ scan;
22
+ serverWatchMs;
23
+ screenshots;
24
+ shooting = Promise.resolve();
25
+ constructor(registry, gateway, terminals, options = {}) {
9
26
  this.registry = registry;
10
27
  this.gateway = gateway;
11
28
  this.terminals = terminals;
29
+ this.scan = options.scan ?? scanListeners;
30
+ this.serverWatchMs = options.serverWatchMs ?? 5_000;
31
+ this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
32
+ }
33
+ list(sessionId) {
34
+ const available = Boolean(this.gateway) && this.gateway?.available !== false;
35
+ const apps = this.registry.list(sessionId);
36
+ for (const app of apps)
37
+ for (const view of app.views) {
38
+ if (view.kind !== "web")
39
+ continue;
40
+ if (available)
41
+ view.address = this.gateway?.address?.(view.id);
42
+ const notes = this.registry.getView(view.id)?.notes;
43
+ if (notes?.length)
44
+ view.notes = structuredClone(notes);
45
+ }
46
+ return { apps, previewAvailable: available };
12
47
  }
13
- list(sessionId) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
14
48
  publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
15
- async open(sessionId, appId, viewId, returnTo) {
49
+ /** An agent turn changed files in this session's workspace. With agent
50
+ * screenshots on, Compare gets an "after" shot once servers have rebuilt. */
51
+ turnChanged(sessionId) {
52
+ const bumped = this.registry.touch(sessionId);
53
+ if (bumped.length && this.screenshots.enabled())
54
+ setTimeout(() => void this.capture(bumped), COMPARE_SETTLE_MS).unref?.();
55
+ return bumped;
56
+ }
57
+ /** One phone-width shot per view of the page last viewed, kept for Compare. */
58
+ async capture(viewIds) {
59
+ for (const id of viewIds) {
60
+ const entry = this.registry.getView(id);
61
+ if (!entry || entry.view.kind !== "web")
62
+ continue;
63
+ const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
64
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: entry.lastPath ?? "/" }, outDir));
65
+ this.shooting = run;
66
+ try {
67
+ const [shot] = await run;
68
+ if (!shot)
69
+ continue;
70
+ const png = fs.readFileSync(shot.file);
71
+ fs.rmSync(outDir, { recursive: true, force: true });
72
+ entry.shots = [...(entry.shots ?? []), { revision: entry.revision, at: Date.now(), png }].slice(-COMPARE_KEEP);
73
+ }
74
+ catch { /* a failed shot just leaves Compare without this turn */ }
75
+ }
76
+ }
77
+ /** Servers running in the workspace that this session doesn't preview yet. */
78
+ async offers(sessionId, workspace) {
79
+ const claimed = this.registry.claimedPorts(sessionId);
80
+ return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
81
+ }
82
+ /** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
83
+ async adopt(sessionId, workspace, port) {
84
+ const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
85
+ if (!offer)
86
+ throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
87
+ return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
88
+ }
89
+ /** `direct` opens the app origin itself (no shell): a signed-in device
90
+ * returning to a view's stable address. */
91
+ async open(sessionId, appId, viewId, returnTo, direct = false) {
16
92
  const entry = this.registry.requireView(sessionId, appId, viewId);
17
93
  if (entry.view.kind === "web") {
18
94
  if (!this.gateway)
19
95
  throw new Error("Bivy's preview service is unavailable on this connection.");
20
- return { kind: "web", url: this.gateway.open(viewId, returnTo) };
96
+ if (direct && !this.gateway.openDirect)
97
+ throw new Error("This machine can't open previews directly.");
98
+ const url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
99
+ // Don't wait for a managed server to boot: the preview shows it starting
100
+ // and reloads itself once it answers.
101
+ if (this.servers.get(viewId))
102
+ this.servers.get(viewId).restarts = [];
103
+ void this.ensureServer(entry).catch(() => { });
104
+ // Compare needs a "before": take a baseline the first time it's opened.
105
+ if (!entry.shots?.length && this.screenshots.enabled())
106
+ setTimeout(() => void this.capture([viewId]), COMPARE_SETTLE_MS).unref?.();
107
+ return { kind: "web", url };
21
108
  }
22
109
  if (entry.target.kind !== "terminal")
23
110
  throw new Error("Unsupported app view provider.");
@@ -48,6 +135,89 @@ export class AppService {
48
135
  throw error;
49
136
  }
50
137
  }
138
+ /** Screenshots of a session's web views (one app, or all), for agents to
139
+ * check their own UI. One browser at a time node-wide; PNGs in a temp dir. */
140
+ async shot(sessionId, appId, input) {
141
+ if (!this.screenshots.enabled())
142
+ throw new Error("Agent screenshots are off on this machine. Turn on “Let agents screenshot their app previews” in Bivy → Settings → this machine, or run: bivy config set sessions.appScreenshots true");
143
+ const widths = input.widths ?? [390, 1280];
144
+ const themes = input.themes ?? ["light"];
145
+ const page = input.path ?? "/";
146
+ if (!Array.isArray(widths) || !widths.length || widths.length > 4 || widths.some((w) => !Number.isInteger(w) || w < 240 || w > 2560))
147
+ throw new Error("Widths must be 1–4 whole numbers between 240 and 2560.");
148
+ if (!Array.isArray(themes) || !themes.length || themes.some((t) => t !== "light" && t !== "dark"))
149
+ throw new Error("Themes must be light and/or dark.");
150
+ if (typeof page !== "string" || !page.startsWith("/") || page.startsWith("//") || page.length > 2048)
151
+ throw new Error("Path must start with /.");
152
+ const apps = appId ? [this.registry.require(sessionId, appId)] : this.registry.list(sessionId);
153
+ const views = apps.flatMap((app) => app.views).filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id)).filter(Boolean);
154
+ if (!views.length)
155
+ throw new Error("This session has no web views to screenshot. Publish one with bivy app publish, or preview a detected server.");
156
+ const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
157
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take(views, { widths, themes: [...new Set(themes)], path: page }, outDir));
158
+ this.shooting = run;
159
+ return { shots: await run };
160
+ }
161
+ /** The managed server's output, as an attachable terminal (starts it if needed). */
162
+ async logs(sessionId, appId, viewId) {
163
+ const entry = this.registry.requireView(sessionId, appId, viewId);
164
+ const termId = await this.ensureServer(entry);
165
+ if (!termId)
166
+ throw new Error("Only views with a start command have server logs.");
167
+ return { kind: "terminal", termId };
168
+ }
169
+ ensureServer(entry) {
170
+ if (entry.target.kind !== "service" || !entry.target.start)
171
+ return Promise.resolve(undefined);
172
+ const id = entry.view.id, start = entry.target.start;
173
+ let state = this.servers.get(id);
174
+ if (!state) {
175
+ state = { restarts: [] };
176
+ this.servers.set(id, state);
177
+ }
178
+ const server = state;
179
+ if (server.termId && this.terminals.has(server.termId))
180
+ return Promise.resolve(server.termId);
181
+ server.pending ??= this.terminals.start({ ...start, name: `${entry.app.name} · ${entry.view.name} server` })
182
+ .then((termId) => {
183
+ if (!this.registry.getView(id)) {
184
+ this.terminals.close(termId);
185
+ throw new Error("App was removed while starting.");
186
+ }
187
+ server.termId = termId;
188
+ return termId;
189
+ })
190
+ .finally(() => { server.pending = undefined; });
191
+ // Watch for exits and restart, backing off a crash loop.
192
+ server.timer ??= setInterval(() => {
193
+ const current = this.registry.getView(id);
194
+ if (!current) {
195
+ this.stopServer(id);
196
+ return;
197
+ }
198
+ if (server.pending || !server.termId || this.terminals.has(server.termId))
199
+ return;
200
+ const now = Date.now();
201
+ server.restarts = server.restarts.filter((at) => now - at < RESTART_WINDOW);
202
+ if (server.restarts.length >= MAX_RESTARTS)
203
+ return;
204
+ server.restarts.push(now);
205
+ server.termId = undefined;
206
+ void this.ensureServer(current).catch(() => { });
207
+ }, this.serverWatchMs);
208
+ server.timer.unref?.();
209
+ return server.pending;
210
+ }
211
+ stopServer(id) {
212
+ const server = this.servers.get(id);
213
+ if (!server)
214
+ return;
215
+ clearInterval(server.timer);
216
+ if (server.termId)
217
+ this.terminals.close(server.termId);
218
+ void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
219
+ this.servers.delete(id);
220
+ }
51
221
  share(sessionId, appId, viewId) {
52
222
  if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
53
223
  throw new Error("Only web views have preview links.");
@@ -55,6 +225,10 @@ export class AppService {
55
225
  throw new Error("Bivy's preview service is unavailable on this connection.");
56
226
  return this.gateway.share(viewId);
57
227
  }
228
+ clearNotes(sessionId, appId, viewId) {
229
+ delete this.registry.requireView(sessionId, appId, viewId).notes;
230
+ return { ok: true };
231
+ }
58
232
  /** Ends every link, browser session and open connection for one view; the app stays. */
59
233
  revoke(sessionId, appId, viewId) {
60
234
  this.registry.requireView(sessionId, appId, viewId);
@@ -66,6 +240,7 @@ export class AppService {
66
240
  this.registry.remove(sessionId, appId);
67
241
  for (const view of app.views) {
68
242
  this.gateway?.revoke(view.id);
243
+ this.stopServer(view.id);
69
244
  const pending = this.terminalStarts.get(view.id);
70
245
  this.terminalStarts.delete(view.id);
71
246
  if (pending)
@@ -2957,6 +2957,9 @@ select.picker-search { appearance: auto; }
2957
2957
  .app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
2958
2958
  .app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
2959
2959
  .app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
2960
+ .app-view-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }
2961
+ /* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
2962
+ .preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
2960
2963
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
2961
2964
  .app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
2962
2965
  .app-view-row .artifact-main { flex-basis: 60%; }
@@ -108,6 +108,7 @@ const ENV_FOR_KEY = {
108
108
  "defaults.sandbox": "BIVY_SANDBOX",
109
109
  "defaults.approval": "BIVY_APPROVAL_MODE",
110
110
  "sessions.autoAttachToolImages": "BIVY_AUTO_ATTACH_TOOL_IMAGES",
111
+ "sessions.appScreenshots": "BIVY_APP_SCREENSHOTS",
111
112
  "automation.checks": "BIVY_AUTOMATION_CHECKS",
112
113
  "automation.checkTimeoutMinutes": "BIVY_AUTOMATION_CHECK_TIMEOUT_MS",
113
114
  };
@@ -122,6 +123,7 @@ const BUILTIN_VALUES = {
122
123
  "sessions.worktreeSync": false,
123
124
  "sessions.resume": "auto",
124
125
  "sessions.autoAttachToolImages": false,
126
+ "sessions.appScreenshots": false,
125
127
  "automation.checks": ["test", "lint", "typecheck"],
126
128
  "automation.checkTimeoutMinutes": 10,
127
129
  };
@@ -1,16 +1,29 @@
1
1
  export function createAppCommands(service, workspaceFor, published = () => { }) {
2
+ const workspaceOf = (sessionId) => {
3
+ const workspace = workspaceFor(sessionId);
4
+ if (!workspace)
5
+ throw new Error("Open the session on this machine before publishing an app.");
6
+ return workspace;
7
+ };
2
8
  const operations = {
3
9
  "apps.list": (msg) => service.list(String(msg.sessionId)),
4
10
  "apps.publish": (msg) => {
5
11
  const sessionId = String(msg.sessionId);
6
- const workspace = workspaceFor(sessionId);
7
- if (!workspace)
8
- throw new Error("Open the session on this machine before publishing an app.");
9
- const app = service.publish(sessionId, workspace, msg.manifest);
12
+ const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
13
+ published(app);
14
+ return { app };
15
+ },
16
+ "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
17
+ "apps.adopt": async (msg) => {
18
+ const sessionId = String(msg.sessionId);
19
+ const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
10
20
  published(app);
11
21
  return { app };
12
22
  },
13
- "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined),
23
+ "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true),
24
+ "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
+ "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
26
+ "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
14
27
  "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
15
28
  "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
16
29
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
@@ -18,7 +31,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
18
31
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
19
32
  try {
20
33
  ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
21
- if (kind === "apps.publish" || kind === "apps.remove")
34
+ if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
22
35
  ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
23
36
  }
24
37
  catch (error) {
@@ -74,7 +74,7 @@ export function validateNodeConfig(value) {
74
74
  const node = section(root, "node", ["workspace", "port", "maxConcurrentAutomations", "capabilities"], errors);
75
75
  const defaults = section(root, "defaults", ["agent", "model", "sandbox", "approval"], errors);
76
76
  const safety = section(root, "safety", ["maxSandbox", "approvalFloor"], errors);
77
- const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
77
+ const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "appScreenshots", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
78
78
  const github = section(root, "github", ["issuePrompt"], errors);
79
79
  const automation = section(root, "automation", ["checks", "checkTimeoutMinutes"], errors);
80
80
  const agentsRaw = section(root, "agents", Object.keys(record(root.agents) ?? {}), errors);
@@ -133,6 +133,7 @@ export function validateNodeConfig(value) {
133
133
  if (resume !== undefined && resume !== "auto" && resume !== "manual")
134
134
  errors.push("sessions.resume must be auto or manual");
135
135
  const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
136
+ const appScreenshots = optionalBoolean(sessions.appScreenshots, "sessions.appScreenshots", errors);
136
137
  const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
137
138
  // Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
138
139
  // would never fire before the cap. 0 disables the band.
@@ -216,7 +217,7 @@ export function validateNodeConfig(value) {
216
217
  ...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
217
218
  ...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
218
219
  ...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
219
- ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
+ ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
221
  ...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
221
222
  ...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
222
223
  ...(Object.keys(agents).length ? { agents } : {}),
@@ -272,6 +273,7 @@ export function configToLegacySettings(config) {
272
273
  ...(config.sessions?.standbyNodeId ? { syncStandbyNodeId: config.sessions.standbyNodeId } : {}),
273
274
  ...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
274
275
  ...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
276
+ ...(config.sessions?.appScreenshots !== undefined ? { appScreenshots: config.sessions.appScreenshots } : {}),
275
277
  ...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
276
278
  };
277
279
  }
@@ -335,6 +337,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
335
337
  standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId : undefined,
336
338
  resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
337
339
  autoAttachToolImages: settings.autoAttachToolImages === true,
340
+ appScreenshots: settings.appScreenshots === true,
338
341
  forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
339
342
  },
340
343
  github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
@@ -361,7 +364,7 @@ export function setConfigValue(config, dotted, value) {
361
364
  "node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
362
365
  "defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
363
366
  "safety.maxSandbox", "safety.approvalFloor",
364
- "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
367
+ "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
365
368
  "github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
366
369
  ]);
367
370
  if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
8
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
9
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -10,7 +10,12 @@ export const CLIENT_COMMAND_SCHEMAS = {
10
10
  ping: Type.Object(request),
11
11
  "apps.list": Type.Object({ ...request, ...session }),
12
12
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
- "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })) }),
13
+ "apps.offers": Type.Object({ ...request, ...session }),
14
+ "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
15
+ "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()) }),
16
+ "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
17
+ "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
18
+ "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
14
19
  "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
15
20
  "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
16
21
  "apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
package/dist/server.js CHANGED
@@ -963,6 +963,8 @@ async function harnessEndTurn(record) {
963
963
  record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
964
964
  if (!result || result.changes.length === 0)
965
965
  return;
966
+ // Open previews of this session reload; static snapshots are re-taken.
967
+ appService.turnChanged(record.id);
966
968
  broadcast({
967
969
  type: "session.changes",
968
970
  sessionId: record.id,
@@ -1332,6 +1334,7 @@ function writeSettings(settings) {
1332
1334
  ...("syncStandbyNodeId" in settings ? { standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId || undefined : undefined } : {}),
1333
1335
  ...(settings.sessionResumeMode === "auto" || settings.sessionResumeMode === "manual" ? { resume: settings.sessionResumeMode } : {}),
1334
1336
  ...(typeof settings.autoAttachToolImages === "boolean" ? { autoAttachToolImages: settings.autoAttachToolImages } : {}),
1337
+ ...(typeof settings.appScreenshots === "boolean" ? { appScreenshots: settings.appScreenshots } : {}),
1335
1338
  ...(Number.isInteger(settings.forkWorkspaceMaxBytes) ? { forkWorkspaceMaxBytes: Number(settings.forkWorkspaceMaxBytes) } : {}),
1336
1339
  };
1337
1340
  next.github = {
@@ -1418,6 +1421,15 @@ function nodeConfiguredDefaultAgent() {
1418
1421
  }
1419
1422
  /** How interactive sessions recover after a restart interrupted them mid-turn.
1420
1423
  * Defaults to "auto" (re-drive the turn); "manual" waits for a user tap. */
1424
+ /** Agents may screenshot app previews: the node setting, or BIVY_APP_SCREENSHOTS. */
1425
+ function appScreenshotsEnabled() {
1426
+ const env = process.env.BIVY_APP_SCREENSHOTS;
1427
+ if (env === "1" || env === "true")
1428
+ return true;
1429
+ if (env === "0" || env === "false")
1430
+ return false;
1431
+ return readSettings().appScreenshots === true;
1432
+ }
1421
1433
  function nodeSessionResumeMode() {
1422
1434
  return readSettings().sessionResumeMode === "manual" ? "manual" : "auto";
1423
1435
  }
@@ -1451,6 +1463,7 @@ function nodeSettingsSnapshot() {
1451
1463
  })(),
1452
1464
  sessionResumeMode: nodeSessionResumeMode(),
1453
1465
  autoAttachToolImages: readSettings().autoAttachToolImages === true,
1466
+ appScreenshots: appScreenshotsEnabled(),
1454
1467
  forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
1455
1468
  };
1456
1469
  }
@@ -1508,6 +1521,8 @@ async function applyNodeSettings(patch) {
1508
1521
  if ("sessionResumeMode" in patch) {
1509
1522
  settings.sessionResumeMode = patch.sessionResumeMode === "manual" ? "manual" : "auto";
1510
1523
  }
1524
+ if ("appScreenshots" in patch)
1525
+ settings.appScreenshots = patch.appScreenshots === true;
1511
1526
  if ("autoAttachToolImages" in patch) {
1512
1527
  settings.autoAttachToolImages = patch.autoAttachToolImages === true;
1513
1528
  setConfiguredAutoAttachToolImages(settings.autoAttachToolImages);
@@ -1906,14 +1921,24 @@ const appPreviewPort = Number(process.env.BIVY_APPS_PORT || 4318);
1906
1921
  if (process.env.BIVY_APPS_ORIGIN && (!Number.isInteger(appPreviewPort) || appPreviewPort < 1024 || appPreviewPort > 65535 || appPreviewPort === port)) {
1907
1922
  throw new Error("BIVY_APPS_PORT must be between 1024 and 65535 and different from the node API port.");
1908
1923
  }
1909
- const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])]);
1924
+ // Apps persist with their IDs, so chat launchers and preview addresses survive
1925
+ // restarts; see AppRegistry for which views are restored.
1926
+ const appRegistry = new AppRegistry([port, ...(process.env.BIVY_APPS_ORIGIN ? [appPreviewPort] : [])], path.join(appDir, "apps.json"));
1910
1927
  const appReturnOrigins = () => {
1911
1928
  const config = loadRelayConfig(appDir);
1912
1929
  return [config?.clientBaseUrl, config?.controlPlaneUrl, ...(process.env.BIVY_APPS_RETURN_ORIGINS ?? "").split(",")]
1913
1930
  .filter((value) => Boolean(value?.trim())).map((value) => new URL(value.trim()).origin);
1914
1931
  };
1915
- const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins) : undefined;
1916
- const remotePreview = new RemotePreview(appRegistry, appReturnOrigins);
1932
+ // A signed-out visit to a preview's stable address goes through the Bivy
1933
+ // client: open the session on this node, then the client re-opens the view.
1934
+ const appSignIn = (view, pagePath) => {
1935
+ const client = loadRelayConfig(appDir)?.clientBaseUrl;
1936
+ if (!client)
1937
+ return undefined;
1938
+ return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
1939
+ };
1940
+ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1941
+ const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1917
1942
  const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1918
1943
  start: async (spec) => {
1919
1944
  let failure = "Could not start the app terminal.";
@@ -1928,7 +1953,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1928
1953
  },
1929
1954
  has: (id) => terminals.has(id),
1930
1955
  close: (id) => { terminals.close(id); },
1931
- });
1956
+ }, { screenshots: { enabled: appScreenshotsEnabled } });
1932
1957
  const RELAY_COMMANDS = {
1933
1958
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
1934
1959
  const record = resolveSession(app.sessionId);