@bivy/bivy 0.17.3-staging.20 → 0.17.3-staging.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bivy.mjs +11 -4
- package/dist/apps/preview-shell.js +3 -2
- package/dist/apps/registry.js +16 -3
- package/dist/apps/review.js +109 -0
- package/dist/apps/service.js +171 -11
- package/dist/apps/styles.css +19 -0
- package/dist/apps/types.js +16 -0
- package/dist/controllers/app-commands.js +6 -2
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +5 -1
- package/dist/server.js +81 -10
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -2431,6 +2431,7 @@ async function cmdApp(args = []) {
|
|
|
2431
2431
|
bivy app list [--session <id>]
|
|
2432
2432
|
bivy app remove <app-id> [--session <id>]
|
|
2433
2433
|
bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
|
|
2434
|
+
bivy app present [view] [--path /page] [--note "…"] [--session <id>]
|
|
2434
2435
|
bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
|
|
2435
2436
|
|
|
2436
2437
|
Manifest: {"version":1,"name":"My app","views":[
|
|
@@ -2453,10 +2454,14 @@ Terminal commands run with the node user's permissions, not in a new sandbox.
|
|
|
2453
2454
|
Chrome/Chromium (desktop apps: straight from their display) and prints the PNG paths — look at them before saying the UI is
|
|
2454
2455
|
done. It is off by default: turn it on in Bivy → Settings → this machine, or
|
|
2455
2456
|
run: bivy config set sessions.appScreenshots true
|
|
2457
|
+
"present" says a change is ready for the user to look at: they get a card in
|
|
2458
|
+
the chat with the app at phone width (and before/after), and it opens the live
|
|
2459
|
+
preview. Use it when you finish a visible change, or when the user asks to see
|
|
2460
|
+
it. [view] is an app or view name or ID (default: the one opened last).
|
|
2456
2461
|
Web previews require operator setup; see docs/apps.md.`);
|
|
2457
2462
|
return;
|
|
2458
2463
|
}
|
|
2459
|
-
if (!["publish", "list", "remove", "shot", "run"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2464
|
+
if (!["publish", "list", "remove", "shot", "run", "present"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2460
2465
|
// `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
|
|
2461
2466
|
let runManifest;
|
|
2462
2467
|
if (action === "run") {
|
|
@@ -2471,14 +2476,15 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2471
2476
|
runManifest = { version: 1, name: name.slice(0, 100), views: [{ kind: "display", name: "Window", command, args: commandArgs, ...(restartIndex >= 0 ? { restartOnChange: true } : {}) }] };
|
|
2472
2477
|
}
|
|
2473
2478
|
const shot = {};
|
|
2474
|
-
if (action === "shot") {
|
|
2475
|
-
for (const flag of ["--widths", "--themes", "--path"]) {
|
|
2479
|
+
if (action === "shot" || action === "present") {
|
|
2480
|
+
for (const flag of action === "shot" ? ["--widths", "--themes", "--path"] : ["--path", "--note"]) {
|
|
2476
2481
|
const i = rest.indexOf(flag);
|
|
2477
2482
|
if (i < 0) continue;
|
|
2478
2483
|
const value = rest[i + 1];
|
|
2479
2484
|
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value.`);
|
|
2480
2485
|
if (flag === "--widths") shot.widths = value.split(",").map((w) => Number(w.trim()));
|
|
2481
2486
|
else if (flag === "--themes") shot.themes = value.split(",").map((t) => t.trim());
|
|
2487
|
+
else if (flag === "--note") shot.note = value;
|
|
2482
2488
|
else shot.path = value;
|
|
2483
2489
|
rest.splice(i, 2);
|
|
2484
2490
|
}
|
|
@@ -2488,9 +2494,10 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2488
2494
|
const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
|
|
2489
2495
|
if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
|
|
2490
2496
|
const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
|
|
2491
|
-
if (positional.some((a) => a.startsWith("-")) || (action === "shot" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
|
|
2497
|
+
if (positional.some((a) => a.startsWith("-")) || (action === "shot" || action === "present" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
|
|
2492
2498
|
const body = { sessionId, ...shot };
|
|
2493
2499
|
if (action === "shot" && positional[0]) body.appId = positional[0];
|
|
2500
|
+
if (action === "present" && positional[0]) body.target = positional[0];
|
|
2494
2501
|
if (action === "publish") {
|
|
2495
2502
|
const file = path.resolve(positional[0]);
|
|
2496
2503
|
if (fs.statSync(file).size > 64 * 1024) throw new Error("App manifest exceeds 64 KiB.");
|
|
@@ -88,7 +88,8 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
|
|
|
88
88
|
<script nonce="${nonce}">
|
|
89
89
|
const $=id=>document.getElementById(id);
|
|
90
90
|
const frame=$('app'),status=$('status'),back=$('back'),reload=$('reload'),stage=$('stage');
|
|
91
|
-
|
|
91
|
+
// "#ticket~page": a review card opens the preview on the page it shows.
|
|
92
|
+
const [ticket,startPage='']=location.hash.slice(1).split('~');history.replaceState(null,'',location.pathname);
|
|
92
93
|
let metadata,currentPath='/';
|
|
93
94
|
// Peek: framed by a Bivy client, which owns closing and the composer.
|
|
94
95
|
const embedded=parent!==window;
|
|
@@ -109,7 +110,7 @@ function show(data,launch){
|
|
|
109
110
|
// (Set before navigating: the policy is fixed when the frame loads.)
|
|
110
111
|
for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
|
|
111
112
|
if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
|
|
112
|
-
frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
|
|
113
|
+
frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
|
|
113
114
|
back.hidden=embedded&&Boolean(data.returnTo);
|
|
114
115
|
for(const b of [reload,$('point'),$('errors')])b.disabled=false;
|
|
115
116
|
status.textContent='Loading app…';
|
package/dist/apps/registry.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
|
+
import { REVIEW_CARD_MODES } from "./types.js";
|
|
7
8
|
const MAX_BYTES = 25 * 1024 * 1024;
|
|
8
9
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
9
10
|
const MAX_FILES = 2000;
|
|
@@ -116,7 +117,7 @@ export class AppRegistry extends EventEmitter {
|
|
|
116
117
|
continue;
|
|
117
118
|
// A workspace that moved or a build that vanished drops that app, not the rest.
|
|
118
119
|
try {
|
|
119
|
-
this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt });
|
|
120
|
+
this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt, reviewMode: record.reviewMode });
|
|
120
121
|
}
|
|
121
122
|
catch { /* skipped */ }
|
|
122
123
|
}
|
|
@@ -148,7 +149,7 @@ export class AppRegistry extends EventEmitter {
|
|
|
148
149
|
if (this.apps.size >= 50)
|
|
149
150
|
throw new Error("Remove an app before publishing more (limit: 50).");
|
|
150
151
|
const id = (i) => (i === undefined ? restore?.id : restore?.viewIds[i]) ?? randomBytes(16).toString("hex");
|
|
151
|
-
const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now() };
|
|
152
|
+
const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now(), ...(restore?.reviewMode && REVIEW_CARD_MODES.includes(restore.reviewMode) ? { reviewMode: restore.reviewMode } : {}) };
|
|
152
153
|
const entries = [];
|
|
153
154
|
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
154
155
|
for (const [index, spec] of input.views.entries()) {
|
|
@@ -192,7 +193,7 @@ export class AppRegistry extends EventEmitter {
|
|
|
192
193
|
this.apps.set(app.id, app);
|
|
193
194
|
for (const entry of entries)
|
|
194
195
|
this.views.set(entry.view.id, entry);
|
|
195
|
-
this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt });
|
|
196
|
+
this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt, ...(app.reviewMode ? { reviewMode: app.reviewMode } : {}) });
|
|
196
197
|
if (!restore)
|
|
197
198
|
this.save();
|
|
198
199
|
return structuredClone(app);
|
|
@@ -253,6 +254,18 @@ export class AppRegistry extends EventEmitter {
|
|
|
253
254
|
throw new Error("View not found in this app.");
|
|
254
255
|
return view;
|
|
255
256
|
}
|
|
257
|
+
/** Review cards for this app: remembered across sessions' restarts. */
|
|
258
|
+
setReviewMode(sessionId, id, mode) {
|
|
259
|
+
if (!REVIEW_CARD_MODES.includes(mode))
|
|
260
|
+
throw new Error("Preview cards are ready, every or off.");
|
|
261
|
+
const app = this.require(sessionId, id);
|
|
262
|
+
app.reviewMode = mode;
|
|
263
|
+
const record = this.persisted.get(id);
|
|
264
|
+
if (record)
|
|
265
|
+
record.reviewMode = mode;
|
|
266
|
+
this.save();
|
|
267
|
+
}
|
|
268
|
+
reviewMode(id) { return this.apps.get(id)?.reviewMode ?? "ready"; }
|
|
256
269
|
remove(sessionId, id) {
|
|
257
270
|
const app = this.require(sessionId, id);
|
|
258
271
|
for (const view of app.views)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { inflateSync } from "node:zlib";
|
|
4
|
+
/** What each review-card mode lets through. `present`: the agent's
|
|
5
|
+
* `bivy app present` makes a card. `minChange`: at the end of a run whose
|
|
6
|
+
* preview changed, the share of pixels that must differ from the page before
|
|
7
|
+
* the run; undefined = never on its own. A new mode is a new row. */
|
|
8
|
+
export const REVIEW_MODES = {
|
|
9
|
+
// ~8×8 CSS px at 2× on a phone-sized page: a moved button or changed
|
|
10
|
+
// label, not a blinking caret (~0.01%).
|
|
11
|
+
ready: { present: true, minChange: 0.0002 },
|
|
12
|
+
every: { present: true, minChange: 0 },
|
|
13
|
+
off: { present: false },
|
|
14
|
+
};
|
|
15
|
+
/** Whether a moment gets a review card. Show me ("asked") is the user's own
|
|
16
|
+
* request, so it always does. Without a baseline, a run can't prove a visible
|
|
17
|
+
* change, so it gets no card: a backend-only run must never produce one. */
|
|
18
|
+
export function shouldReview(input) {
|
|
19
|
+
if (input.trigger === "asked")
|
|
20
|
+
return true;
|
|
21
|
+
if (input.muted)
|
|
22
|
+
return false;
|
|
23
|
+
const rule = REVIEW_MODES[input.mode];
|
|
24
|
+
if (input.trigger === "present")
|
|
25
|
+
return rule.present;
|
|
26
|
+
return input.revisionChanged && rule.minChange !== undefined && input.change !== undefined && input.change > rule.minChange;
|
|
27
|
+
}
|
|
28
|
+
/** What the "session finished" notification carries about a review: IDs only.
|
|
29
|
+
* The device fetches the screenshot over the encrypted session channel. */
|
|
30
|
+
export function reviewHint(review) {
|
|
31
|
+
return {
|
|
32
|
+
review: { appId: review.appId, viewId: review.viewId, reviewId: review.id },
|
|
33
|
+
body: `${review.name} ${review.trigger === "present" ? "is ready to review" : "changed"} — tap to see it.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Decodes the 8-bit, non-interlaced RGB(A) PNGs Chrome and rfb.ts write. */
|
|
37
|
+
export function decodePng(png) {
|
|
38
|
+
if (png.readUInt32BE(0) !== 0x89504e47)
|
|
39
|
+
throw new Error("Not a PNG.");
|
|
40
|
+
let offset = 8, width = 0, height = 0, channels = 0;
|
|
41
|
+
const data = [];
|
|
42
|
+
while (offset < png.length) {
|
|
43
|
+
const length = png.readUInt32BE(offset);
|
|
44
|
+
const type = png.toString("latin1", offset + 4, offset + 8);
|
|
45
|
+
const chunk = png.subarray(offset + 8, offset + 8 + length);
|
|
46
|
+
if (type === "IHDR") {
|
|
47
|
+
width = chunk.readUInt32BE(0);
|
|
48
|
+
height = chunk.readUInt32BE(4);
|
|
49
|
+
const [depth, color, , , interlace] = chunk.subarray(8, 13);
|
|
50
|
+
channels = color === 6 ? 4 : color === 2 ? 3 : 0;
|
|
51
|
+
if (depth !== 8 || !channels || interlace)
|
|
52
|
+
throw new Error("Unsupported PNG.");
|
|
53
|
+
}
|
|
54
|
+
else if (type === "IDAT")
|
|
55
|
+
data.push(chunk);
|
|
56
|
+
else if (type === "IEND")
|
|
57
|
+
break;
|
|
58
|
+
offset += 12 + length;
|
|
59
|
+
}
|
|
60
|
+
const raw = inflateSync(Buffer.concat(data));
|
|
61
|
+
const stride = width * channels;
|
|
62
|
+
const pixels = Buffer.alloc(stride * height);
|
|
63
|
+
for (let y = 0; y < height; y++) {
|
|
64
|
+
const filter = raw[y * (stride + 1)];
|
|
65
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
66
|
+
const out = y * stride;
|
|
67
|
+
for (let x = 0; x < stride; x++) {
|
|
68
|
+
const a = x >= channels ? pixels[out + x - channels] : 0;
|
|
69
|
+
const b = y ? pixels[out - stride + x] : 0;
|
|
70
|
+
const c = y && x >= channels ? pixels[out - stride + x - channels] : 0;
|
|
71
|
+
let value = line[x];
|
|
72
|
+
if (filter === 1)
|
|
73
|
+
value += a;
|
|
74
|
+
else if (filter === 2)
|
|
75
|
+
value += b;
|
|
76
|
+
else if (filter === 3)
|
|
77
|
+
value += (a + b) >> 1;
|
|
78
|
+
else if (filter === 4) {
|
|
79
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
80
|
+
value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
81
|
+
}
|
|
82
|
+
pixels[out + x] = value & 0xff;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { width, height, channels, pixels };
|
|
86
|
+
}
|
|
87
|
+
/** Share of pixels that visibly differ (0–1). Different sizes count as a
|
|
88
|
+
* full change: the page grew or shrank. */
|
|
89
|
+
export function visualChange(before, after) {
|
|
90
|
+
if (before.equals(after))
|
|
91
|
+
return 0;
|
|
92
|
+
const a = decodePng(before), b = decodePng(after);
|
|
93
|
+
if (a.width !== b.width || a.height !== b.height)
|
|
94
|
+
return 1;
|
|
95
|
+
let changed = 0;
|
|
96
|
+
for (let i = 0, p = 0; p < a.width * a.height; p++, i += a.channels) {
|
|
97
|
+
const j = p * b.channels;
|
|
98
|
+
const delta = Math.abs(a.pixels[i] - b.pixels[j]) + Math.abs(a.pixels[i + 1] - b.pixels[j + 1]) + Math.abs(a.pixels[i + 2] - b.pixels[j + 2]);
|
|
99
|
+
// Re-renders of the same page are pixel-identical; this only skips
|
|
100
|
+
// sub-pixel antialiasing shifts, not a light grey on white.
|
|
101
|
+
if (delta > 12)
|
|
102
|
+
changed++;
|
|
103
|
+
}
|
|
104
|
+
return changed / (a.width * a.height);
|
|
105
|
+
}
|
|
106
|
+
/** PNG dimensions, from the header. */
|
|
107
|
+
export function pngSize(png) {
|
|
108
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
109
|
+
}
|
package/dist/apps/service.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { REVIEW_MODES, shouldReview, visualChange } from "./review.js";
|
|
1
3
|
import fs from "node:fs";
|
|
2
4
|
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { scanListeners } from "./listeners.js";
|
|
5
7
|
import { takeShots } from "./screenshot.js";
|
|
8
|
+
const NO_REVIEWS = { publish: (review) => review, expire: () => { } };
|
|
6
9
|
const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
|
|
7
10
|
/** How long a screenshot waits for a desktop app's first window. */
|
|
8
11
|
const WINDOW_WAIT_MS = 15_000;
|
|
@@ -26,6 +29,13 @@ export class AppService {
|
|
|
26
29
|
screenshots;
|
|
27
30
|
displays;
|
|
28
31
|
shooting = Promise.resolve();
|
|
32
|
+
reviews;
|
|
33
|
+
settleMs;
|
|
34
|
+
runs = new Map();
|
|
35
|
+
/** The latest card per view: a newer one expires its images. */
|
|
36
|
+
latest = new Map();
|
|
37
|
+
/** Screenshots in progress, so a card, Compare and a baseline share one browser run. */
|
|
38
|
+
inflight = new Map();
|
|
29
39
|
constructor(registry, gateway, terminals, options = {}) {
|
|
30
40
|
this.registry = registry;
|
|
31
41
|
this.gateway = gateway;
|
|
@@ -34,6 +44,8 @@ export class AppService {
|
|
|
34
44
|
this.serverWatchMs = options.serverWatchMs ?? 5_000;
|
|
35
45
|
this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
|
|
36
46
|
this.displays = options.displays ?? NO_DISPLAYS;
|
|
47
|
+
this.reviews = options.reviews ?? NO_REVIEWS;
|
|
48
|
+
this.settleMs = options.settleMs ?? COMPARE_SETTLE_MS;
|
|
37
49
|
}
|
|
38
50
|
/** A session's apps, or every app on this machine when no session is given. */
|
|
39
51
|
list(sessionId) {
|
|
@@ -75,29 +87,173 @@ export class AppService {
|
|
|
75
87
|
void this.ensureServer(entry).catch(() => { });
|
|
76
88
|
}
|
|
77
89
|
if (bumped.length && this.screenshots.enabled())
|
|
78
|
-
setTimeout(() => void this.capture(bumped),
|
|
90
|
+
setTimeout(() => void this.capture(bumped), this.settleMs).unref?.();
|
|
79
91
|
return bumped;
|
|
80
92
|
}
|
|
81
93
|
/** One phone-width shot per view of the page last viewed, kept for Compare. */
|
|
82
94
|
async capture(viewIds) {
|
|
83
95
|
for (const id of viewIds) {
|
|
84
96
|
const entry = this.registry.getView(id);
|
|
85
|
-
if (
|
|
86
|
-
|
|
97
|
+
if (entry?.view.kind === "web")
|
|
98
|
+
await this.shotOf(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A phone-width screenshot of a page at the view's current revision: one
|
|
102
|
+
* already taken (Compare's after-turn shot), one in progress, or a new one.
|
|
103
|
+
* Undefined when it fails; a card then goes without a picture. */
|
|
104
|
+
shotOf(entry, page = entry.lastPath ?? "/") {
|
|
105
|
+
const revision = entry.revision;
|
|
106
|
+
const taken = [...entry.shots ?? []].reverse().find((shot) => shot.revision === revision && (shot.path ?? "/") === page);
|
|
107
|
+
if (taken)
|
|
108
|
+
return Promise.resolve(taken.png);
|
|
109
|
+
const key = `${entry.view.id}:${revision}:${page}`;
|
|
110
|
+
let pending = this.inflight.get(key);
|
|
111
|
+
if (pending)
|
|
112
|
+
return pending;
|
|
113
|
+
pending = (async () => {
|
|
87
114
|
await this.windowShown(entry).catch(() => { });
|
|
88
|
-
const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
|
|
89
|
-
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path:
|
|
115
|
+
const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", entry.view.id);
|
|
116
|
+
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: page }, outDir));
|
|
90
117
|
this.shooting = run;
|
|
91
118
|
try {
|
|
92
119
|
const [shot] = await run;
|
|
93
120
|
if (!shot)
|
|
94
|
-
|
|
121
|
+
return undefined;
|
|
95
122
|
const png = fs.readFileSync(shot.file);
|
|
96
123
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
97
|
-
entry.shots = [...(entry.shots ?? []), { revision
|
|
124
|
+
entry.shots = [...(entry.shots ?? []), { revision, at: Date.now(), png, path: page }].slice(-COMPARE_KEEP);
|
|
125
|
+
return png;
|
|
98
126
|
}
|
|
99
|
-
catch {
|
|
127
|
+
catch {
|
|
128
|
+
return undefined; /* a failed shot just leaves Compare without this turn */
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
this.inflight.delete(key);
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
this.inflight.set(key, pending);
|
|
135
|
+
return pending;
|
|
136
|
+
}
|
|
137
|
+
/** Web views of a session whose app wants review cards on their own. */
|
|
138
|
+
reviewable(sessionId) {
|
|
139
|
+
return this.registry.list(sessionId).filter((app) => REVIEW_MODES[this.registry.reviewMode(app.id)].minChange !== undefined)
|
|
140
|
+
.flatMap((app) => app.views.filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id))).filter(Boolean);
|
|
141
|
+
}
|
|
142
|
+
/** An agent run started. Remembers each view's revision, and the page as
|
|
143
|
+
* it is now, so the end of the run can tell whether anything visibly
|
|
144
|
+
* changed. A baseline is only taken when no screenshot of this revision
|
|
145
|
+
* exists yet — after the first run, the last run's screenshot serves. */
|
|
146
|
+
runStarted(sessionId) {
|
|
147
|
+
const views = this.reviewable(sessionId);
|
|
148
|
+
const run = { start: new Map(views.map((entry) => [entry.view.id, entry.revision])), baseline: new Map(), reviews: new Map(), muted: false, ended: false };
|
|
149
|
+
this.runs.set(sessionId, run);
|
|
150
|
+
if (!this.screenshots.enabled())
|
|
151
|
+
return;
|
|
152
|
+
for (const entry of views)
|
|
153
|
+
void this.shotOf(entry).then((png) => { if (png && entry.revision === run.start.get(entry.view.id))
|
|
154
|
+
run.baseline.set(entry.view.id, png); });
|
|
155
|
+
}
|
|
156
|
+
/** The run ended (done, or waiting for the user). Views whose revision
|
|
157
|
+
* changed are screenshotted and compared with the page before the run; a
|
|
158
|
+
* visible change makes (or updates) this run's card. Returns the run's
|
|
159
|
+
* latest card, for the "session finished" notification. */
|
|
160
|
+
async runEnded(sessionId) {
|
|
161
|
+
const run = this.runs.get(sessionId);
|
|
162
|
+
if (!run || run.ended)
|
|
163
|
+
return undefined;
|
|
164
|
+
run.ended = true;
|
|
165
|
+
let latest;
|
|
166
|
+
const changed = [...run.start].map(([id, revision]) => ({ entry: this.registry.getView(id), revision })).filter(({ entry, revision }) => entry && entry.revision !== revision);
|
|
167
|
+
if (changed.length && this.screenshots.enabled() && !run.muted)
|
|
168
|
+
await new Promise((r) => setTimeout(r, this.settleMs));
|
|
169
|
+
for (const { entry } of changed) {
|
|
170
|
+
if (!entry || !this.screenshots.enabled())
|
|
171
|
+
continue;
|
|
172
|
+
const mode = this.registry.reviewMode(entry.app.id);
|
|
173
|
+
if (run.muted || REVIEW_MODES[mode].minChange === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
const before = run.baseline.get(entry.view.id);
|
|
176
|
+
const after = await this.shotOf(entry);
|
|
177
|
+
const change = before && after ? visualChange(before, after) : undefined;
|
|
178
|
+
// A card the agent presented earlier in the run is refreshed if the page changed since.
|
|
179
|
+
const current = this.latest.get(entry.view.id);
|
|
180
|
+
const presented = current && run.reviews.get(entry.app.id) === current.id;
|
|
181
|
+
if (!shouldReview({ trigger: "run", mode, muted: run.muted, revisionChanged: true, change }) && !(presented && after))
|
|
182
|
+
continue;
|
|
183
|
+
latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, path: entry.lastPath ?? "/", shot: after, before });
|
|
184
|
+
}
|
|
185
|
+
if (!latest) {
|
|
186
|
+
const id = [...run.reviews.values()].at(-1);
|
|
187
|
+
latest = [...this.latest.values()].find((review) => review.id === id);
|
|
100
188
|
}
|
|
189
|
+
return latest;
|
|
190
|
+
}
|
|
191
|
+
/** `bivy app present`, or Show me ("asked"): a card for one view now. */
|
|
192
|
+
async present(sessionId, input) {
|
|
193
|
+
const trigger = input.trigger ?? "present";
|
|
194
|
+
const page = input.path;
|
|
195
|
+
if (page !== undefined && (typeof page !== "string" || !page.startsWith("/") || page.startsWith("//") || page.length > 2048))
|
|
196
|
+
throw new Error("Path must start with /.");
|
|
197
|
+
const note = typeof input.note === "string" ? input.note.trim().slice(0, 500) : undefined;
|
|
198
|
+
const entry = this.pickView(sessionId, input.target);
|
|
199
|
+
const mode = this.registry.reviewMode(entry.app.id);
|
|
200
|
+
const active = this.runs.get(sessionId);
|
|
201
|
+
const run = active && !active.ended ? active : undefined;
|
|
202
|
+
if (!shouldReview({ trigger, mode, muted: run?.muted ?? false, revisionChanged: true })) {
|
|
203
|
+
return { message: run?.muted ? "The user muted preview cards for this run. They can still open the preview from the chat." : `Preview cards are off for ${entry.app.name}. The user can still open the preview from the chat.` };
|
|
204
|
+
}
|
|
205
|
+
// Presenting means the change is ready: pick up files written since the
|
|
206
|
+
// turn began, so the picture (and any open preview) shows them.
|
|
207
|
+
if (trigger === "present" && this.turnChanged(sessionId).includes(entry.view.id) && this.screenshots.enabled())
|
|
208
|
+
await new Promise((r) => setTimeout(r, this.settleMs));
|
|
209
|
+
const enabled = this.screenshots.enabled();
|
|
210
|
+
const path = page ?? entry.lastPath ?? "/";
|
|
211
|
+
const shot = enabled ? await this.shotOf(entry, path) : undefined;
|
|
212
|
+
const baseline = run?.baseline.get(entry.view.id);
|
|
213
|
+
const before = baseline && shot && !baseline.equals(shot) && path === (entry.lastPath ?? "/") ? baseline : undefined;
|
|
214
|
+
const review = this.review(entry, run, { trigger, note, path, shot, before, screenshotsOff: !enabled });
|
|
215
|
+
return { review, message: enabled ? (shot ? "The user sees it in the chat." : "The user sees a card in the chat, but the screenshot failed.") : "The user sees a card in the chat. Screenshots are off on this machine, so it has no picture." };
|
|
216
|
+
}
|
|
217
|
+
/** No more cards for the rest of the session's current run. */
|
|
218
|
+
mute(sessionId) {
|
|
219
|
+
const run = this.runs.get(sessionId);
|
|
220
|
+
if (run && !run.ended)
|
|
221
|
+
run.muted = true;
|
|
222
|
+
return { ok: true };
|
|
223
|
+
}
|
|
224
|
+
setReviewMode(sessionId, appId, mode) {
|
|
225
|
+
this.registry.setReviewMode(sessionId, appId, mode);
|
|
226
|
+
return { ok: true };
|
|
227
|
+
}
|
|
228
|
+
/** A view by app or view ID or name; by default the one opened last, else the newest app's first. */
|
|
229
|
+
pickView(sessionId, target) {
|
|
230
|
+
const views = this.registry.list(sessionId).flatMap((app) => app.views.filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id))).filter(Boolean);
|
|
231
|
+
if (!views.length)
|
|
232
|
+
throw new Error("This session has no web views to present. Publish one with bivy app publish, or preview a detected server.");
|
|
233
|
+
if (target) {
|
|
234
|
+
const wanted = target.trim().toLowerCase();
|
|
235
|
+
const found = views.find((entry) => [entry.app.id, entry.view.id].includes(wanted))
|
|
236
|
+
?? views.find((entry) => entry.view.name.toLowerCase() === wanted) ?? views.find((entry) => entry.app.name.toLowerCase() === wanted);
|
|
237
|
+
if (!found)
|
|
238
|
+
throw new Error(`No web view called "${target}" in this session. Run bivy app list to see them.`);
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
return views.reduce((best, entry) => (entry.openedAt ?? 0) > (best.openedAt ?? 0) || ((entry.openedAt ?? 0) === (best.openedAt ?? 0) && entry.app.createdAt > best.app.createdAt) ? entry : best);
|
|
242
|
+
}
|
|
243
|
+
/** Makes or updates a card, keeping each view's images to the latest card. */
|
|
244
|
+
review(entry, run, fields) {
|
|
245
|
+
const id = (run && run.reviews.get(entry.app.id)) ?? `review-${randomBytes(8).toString("hex")}`;
|
|
246
|
+
const { shot, before, ...rest } = fields;
|
|
247
|
+
const review = this.reviews.publish({
|
|
248
|
+
id, sessionId: entry.app.sessionId, appId: entry.app.id, viewId: entry.view.id, name: entry.app.name, view: entry.view.name,
|
|
249
|
+
at: Date.now(), ...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined && value !== false)),
|
|
250
|
+
}, { shot, before });
|
|
251
|
+
const previous = this.latest.get(entry.view.id);
|
|
252
|
+
if (previous && previous.id !== review.id)
|
|
253
|
+
this.reviews.expire(previous, new Set([review.shot?.hash, review.before?.hash].filter((hash) => Boolean(hash))));
|
|
254
|
+
this.latest.set(entry.view.id, review);
|
|
255
|
+
run?.reviews.set(entry.app.id, review.id);
|
|
256
|
+
return review;
|
|
101
257
|
}
|
|
102
258
|
/** Servers running in the workspace that this session doesn't preview yet. */
|
|
103
259
|
async offers(sessionId, workspace) {
|
|
@@ -114,7 +270,8 @@ export class AppService {
|
|
|
114
270
|
/** `direct` opens the app origin itself (no shell): a signed-in device
|
|
115
271
|
* returning to a view's stable address. */
|
|
116
272
|
/** `scale`: the opening device's pixel density, used if this starts a display. */
|
|
117
|
-
|
|
273
|
+
/** `page`: start on this page instead of the app's root (a review card's page). */
|
|
274
|
+
async open(sessionId, appId, viewId, returnTo, direct = false, scale, page) {
|
|
118
275
|
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
119
276
|
if (entry.target.kind === "display" && !entry.display)
|
|
120
277
|
entry.displayScale = scale === 2 ? 2 : 1;
|
|
@@ -123,15 +280,18 @@ export class AppService {
|
|
|
123
280
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
124
281
|
if (direct && !this.gateway.openDirect)
|
|
125
282
|
throw new Error("This machine can't open previews directly.");
|
|
126
|
-
|
|
283
|
+
let url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
|
|
284
|
+
if (page && page.startsWith("/") && !page.startsWith("//") && page.length <= 2048)
|
|
285
|
+
url += `~${encodeURIComponent(page).replace(/~/g, "%7E")}`;
|
|
127
286
|
// Don't wait for a managed server to boot: the preview shows it starting
|
|
128
287
|
// and reloads itself once it answers.
|
|
129
288
|
if (this.servers.get(viewId))
|
|
130
289
|
this.servers.get(viewId).restarts = [];
|
|
131
290
|
void this.ensureServer(entry).catch(() => { });
|
|
291
|
+
entry.openedAt = Date.now();
|
|
132
292
|
// Compare needs a "before": take a baseline the first time it's opened.
|
|
133
293
|
if (!entry.shots?.length && this.screenshots.enabled())
|
|
134
|
-
setTimeout(() => void this.capture([viewId]),
|
|
294
|
+
setTimeout(() => void this.capture([viewId]), this.settleMs).unref?.();
|
|
135
295
|
return { kind: "web", url };
|
|
136
296
|
}
|
|
137
297
|
if (entry.target.kind !== "terminal")
|
package/dist/apps/styles.css
CHANGED
|
@@ -116,6 +116,10 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
|
|
|
116
116
|
}
|
|
117
117
|
.menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
|
|
118
118
|
.menu-item.danger { color: var(--danger); }
|
|
119
|
+
.menu-item-label { flex: 1; min-width: 0; }
|
|
120
|
+
.menu-item.separated { margin-top: var(--space-1); border-top: 1px solid var(--line); border-radius: 0 0 var(--radius-sm) var(--radius-sm); }
|
|
121
|
+
/* A section title inside a menu (e.g. "Preview cards" above its choices). */
|
|
122
|
+
.menu-heading { padding: var(--space-2) var(--space-3) var(--space-1); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
|
|
119
123
|
|
|
120
124
|
/* ---- Feedback ---- */
|
|
121
125
|
.toast {
|
|
@@ -3038,6 +3042,21 @@ select.picker-search { appearance: auto; }
|
|
|
3038
3042
|
.app-row-meta { color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
|
|
3039
3043
|
.app-row-action { flex: none; display: flex; align-items: center; gap: var(--space-1); }
|
|
3040
3044
|
.app-message { margin-block: var(--space-2); }
|
|
3045
|
+
/* Review card (ReviewCard.tsx): the app at a moment worth judging. The
|
|
3046
|
+
screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
|
|
3047
|
+
canonical .segmented switch; one primary action. */
|
|
3048
|
+
.review-card { margin-block: var(--space-2); max-inline-size: 440px; }
|
|
3049
|
+
.review-stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); margin: 0 var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--surface-2); }
|
|
3050
|
+
.review-shot { display: grid; place-items: center; padding: 0; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); overflow: hidden; cursor: zoom-in; box-shadow: var(--shadow-sm); }
|
|
3051
|
+
.review-shot img { display: block; inline-size: auto; block-size: auto; max-inline-size: 100%; max-block-size: min(300px, 40vh); }
|
|
3052
|
+
.review-loading { display: grid; place-items: center; inline-size: 139px; block-size: min(300px, 40vh); max-inline-size: 100%; }
|
|
3053
|
+
.review-gone { margin: 0; padding: var(--space-6) var(--space-3); color: var(--muted); font-size: var(--text-sm); text-align: center; }
|
|
3054
|
+
.review-side .seg-btn { min-block-size: 44px; min-inline-size: 72px; }
|
|
3055
|
+
.review-banner { margin: 0 var(--space-3); }
|
|
3056
|
+
.review-note { margin: var(--space-3) var(--space-3) 0; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); color: var(--ink); font-size: var(--text-sm); white-space: pre-wrap; }
|
|
3057
|
+
.review-status { margin: var(--space-2) var(--space-3) 0; color: var(--muted); font-size: var(--text-sm); }
|
|
3058
|
+
.review-actions { display: flex; padding: var(--space-3); }
|
|
3059
|
+
.review-actions .btn { flex: 1; min-block-size: 44px; }
|
|
3041
3060
|
|
|
3042
3061
|
/* Apps sheet (AppsSheet.tsx): one card per app, its views nested, one primary
|
|
3043
3062
|
action per view; secondary tools as quiet icon buttons; rare or destructive
|
package/dist/apps/types.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
export const REVIEW_CARD_MODES = ["ready", "every", "off"];
|
|
4
|
+
export const APP_REVIEW_BLOCK = "bivy_app_review";
|
|
5
|
+
const isShot = (value) => {
|
|
6
|
+
const shot = value;
|
|
7
|
+
return !!shot && typeof shot.hash === "string" && /^[0-9a-f]{64}$/.test(shot.hash) && [shot.size, shot.width, shot.height].every((n) => typeof n === "number" && n >= 0);
|
|
8
|
+
};
|
|
9
|
+
export function isAppReview(value) {
|
|
10
|
+
if (!value || typeof value !== "object")
|
|
11
|
+
return false;
|
|
12
|
+
const review = value;
|
|
13
|
+
return typeof review.id === "string" && typeof review.sessionId === "string" && typeof review.appId === "string" && /^[a-f0-9]{32}$/.test(review.appId)
|
|
14
|
+
&& typeof review.viewId === "string" && typeof review.name === "string" && typeof review.view === "string" && typeof review.path === "string"
|
|
15
|
+
&& (review.trigger === "present" || review.trigger === "run" || review.trigger === "asked") && typeof review.at === "number"
|
|
16
|
+
&& (review.shot === undefined || isShot(review.shot)) && (review.before === undefined || isShot(review.before))
|
|
17
|
+
&& (review.note === undefined || typeof review.note === "string");
|
|
18
|
+
}
|
|
3
19
|
export const APP_PUBLICATION_BLOCK = "bivy_app";
|
|
4
20
|
export function isAppReference(value) {
|
|
5
21
|
if (!value || typeof value !== "object")
|
|
@@ -20,8 +20,12 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
20
20
|
published(app);
|
|
21
21
|
return { app };
|
|
22
22
|
},
|
|
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, typeof msg.scale === "number" ? msg.scale : 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, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined),
|
|
24
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.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined }),
|
|
26
|
+
"apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
|
|
27
|
+
"apps.mute": (msg) => service.mute(String(msg.sessionId)),
|
|
28
|
+
"apps.reviewMode": (msg) => service.setReviewMode(String(msg.sessionId), String(msg.appId), msg.mode),
|
|
25
29
|
"apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
26
30
|
"apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
27
31
|
"apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
@@ -31,7 +35,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
31
35
|
return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
|
|
32
36
|
try {
|
|
33
37
|
ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
|
|
34
|
-
if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
|
|
38
|
+
if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove" || kind === "apps.reviewMode")
|
|
35
39
|
ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
|
|
36
40
|
}
|
|
37
41
|
catch (error) {
|
|
@@ -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", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "present", "showMe", "mute", "reviewMode", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
9
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
@@ -13,8 +13,12 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
13
13
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
14
14
|
"apps.offers": Type.Object({ ...request, ...session }),
|
|
15
15
|
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
16
|
-
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })) }),
|
|
16
|
+
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
17
17
|
"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 })) }),
|
|
18
|
+
"apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })) }),
|
|
19
|
+
"apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
|
|
20
|
+
"apps.mute": Type.Object({ ...request, ...session }),
|
|
21
|
+
"apps.reviewMode": Type.Object({ ...request, ...session, appId: Type.String(), mode: Type.Union([Type.Literal("ready"), Type.Literal("every"), Type.Literal("off")]) }),
|
|
18
22
|
"apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
19
23
|
"apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
20
24
|
"apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
package/dist/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { AppRegistry } from "./apps/registry.js";
|
|
|
32
32
|
import { AppGateway } from "./apps/gateway.js";
|
|
33
33
|
import { RemotePreview } from "./apps/remote-preview.js";
|
|
34
34
|
import { AppService } from "./apps/service.js";
|
|
35
|
+
import { pngSize, reviewHint } from "./apps/review.js";
|
|
35
36
|
import { DisplayHost } from "./apps/display.js";
|
|
36
37
|
import { createAppCommands } from "./controllers/app-commands.js";
|
|
37
38
|
import { bindClientCommandRoutes } from "./http/client-command-routes.js";
|
|
@@ -921,6 +922,9 @@ function harnessDirFor(record) {
|
|
|
921
922
|
* changes card — acceptable for a best-effort diff, and the prompt no longer
|
|
922
923
|
* waits on git. Never throws into the prompt path. */
|
|
923
924
|
function harnessBeginTurn(record) {
|
|
925
|
+
// A run starts: review cards measure what the app looked like before it.
|
|
926
|
+
appService.runStarted(record.id);
|
|
927
|
+
record.runReview = undefined;
|
|
924
928
|
const dir = harnessDirFor(record);
|
|
925
929
|
record.harnessTurnReady = undefined;
|
|
926
930
|
record.harnessTurnFinished = false;
|
|
@@ -952,9 +956,12 @@ function finishHarnessTurn(record) {
|
|
|
952
956
|
if (record.harnessTurnFinished)
|
|
953
957
|
return;
|
|
954
958
|
record.harnessTurnFinished = true;
|
|
955
|
-
|
|
959
|
+
const ended = harnessEndTurn(record);
|
|
960
|
+
void ended.finally(() => {
|
|
956
961
|
void replication.onTurnComplete(record.id);
|
|
957
962
|
});
|
|
963
|
+
// After the diff has bumped preview revisions: decide on this run's review card.
|
|
964
|
+
record.runReview = ended.then(() => appService.runEnded(record.id)).catch(() => undefined);
|
|
958
965
|
}
|
|
959
966
|
async function harnessEndTurn(record) {
|
|
960
967
|
const previous = record.workspaceState === "dirty" ? "dirty" : "clean";
|
|
@@ -1776,10 +1783,15 @@ function referencedAttachmentHashes() {
|
|
|
1776
1783
|
// `scan`, not `entries`: this visits every session ever created, and must not pin
|
|
1777
1784
|
// each one's full log in the event-log cache.
|
|
1778
1785
|
for (const id of ids) {
|
|
1779
|
-
for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image"])) {
|
|
1786
|
+
for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image", "app-review"])) {
|
|
1780
1787
|
if (entry.bivyKind === "attachment")
|
|
1781
1788
|
for (const ref of entry.refs)
|
|
1782
1789
|
hashes.add(ref.hash);
|
|
1790
|
+
else if (entry.bivyKind === "app-review") {
|
|
1791
|
+
for (const shot of [entry.review.shot, entry.review.before])
|
|
1792
|
+
if (shot)
|
|
1793
|
+
hashes.add(shot.hash);
|
|
1794
|
+
}
|
|
1783
1795
|
else if (entry.bivyKind === "outbound-attachment" || entry.bivyKind === "inline-image")
|
|
1784
1796
|
hashes.add(entry.ref.hash);
|
|
1785
1797
|
}
|
|
@@ -1976,6 +1988,55 @@ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, pr
|
|
|
1976
1988
|
const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
|
|
1977
1989
|
// Desktop app views: one private display per view, started on first open.
|
|
1978
1990
|
const appDisplays = new DisplayHost();
|
|
1991
|
+
/** Review cards: screenshots become encrypted attachments (fetched by hash
|
|
1992
|
+
* like any image), and the card is logged so it survives a reload. A card
|
|
1993
|
+
* that updates moves to the end of the run; an expired one stays put. */
|
|
1994
|
+
function createReviewSink() {
|
|
1995
|
+
const anchors = new Map();
|
|
1996
|
+
const emit = (review, anchor) => {
|
|
1997
|
+
const record = resolveSession(review.sessionId);
|
|
1998
|
+
if (!record)
|
|
1999
|
+
return;
|
|
2000
|
+
const at = anchor ?? { afterMessageCount: record.session.getMessages().length, createdAt: Date.now() };
|
|
2001
|
+
anchors.set(review.id, at);
|
|
2002
|
+
eventLog.appendAppReview(record.id, { ...at, review });
|
|
2003
|
+
broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "app_review", id: review.id, review } }));
|
|
2004
|
+
};
|
|
2005
|
+
const store = (png, name) => {
|
|
2006
|
+
if (!png)
|
|
2007
|
+
return undefined;
|
|
2008
|
+
try {
|
|
2009
|
+
const ref = attachmentStore.put(png, { name, mimeType: "image/png", kind: "image" });
|
|
2010
|
+
return { hash: ref.hash, size: ref.size, ...pngSize(png) };
|
|
2011
|
+
}
|
|
2012
|
+
catch {
|
|
2013
|
+
return undefined;
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
return {
|
|
2017
|
+
publish(review, images) {
|
|
2018
|
+
const card = { ...review };
|
|
2019
|
+
const shot = store(images.shot, `${review.name} ${review.path}.png`);
|
|
2020
|
+
const before = store(images.before, `${review.name} ${review.path} before.png`);
|
|
2021
|
+
if (shot)
|
|
2022
|
+
card.shot = shot;
|
|
2023
|
+
if (before)
|
|
2024
|
+
card.before = before;
|
|
2025
|
+
emit(card);
|
|
2026
|
+
return card;
|
|
2027
|
+
},
|
|
2028
|
+
expire(review, keep) {
|
|
2029
|
+
const record = resolveSession(review.sessionId);
|
|
2030
|
+
for (const hash of [review.shot?.hash, review.before?.hash]) {
|
|
2031
|
+
// Never delete bytes anything else in the chat points at.
|
|
2032
|
+
if (hash && !keep.has(hash) && !record?.seenAttachmentHashes?.has(hash))
|
|
2033
|
+
attachmentStore.remove(hash);
|
|
2034
|
+
}
|
|
2035
|
+
const { shot: _shot, before: _before, ...rest } = review;
|
|
2036
|
+
emit({ ...rest, expired: true }, anchors.get(review.id));
|
|
2037
|
+
},
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
1979
2040
|
const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
|
|
1980
2041
|
start: async (spec) => {
|
|
1981
2042
|
let failure = "Could not start the app terminal.";
|
|
@@ -1990,7 +2051,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
|
|
|
1990
2051
|
},
|
|
1991
2052
|
has: (id) => terminals.has(id),
|
|
1992
2053
|
close: (id) => { terminals.close(id); },
|
|
1993
|
-
}, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays });
|
|
2054
|
+
}, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays, reviews: createReviewSink() });
|
|
1994
2055
|
// A reviewer's note shows up in an open Apps sheet without reopening it.
|
|
1995
2056
|
appRegistry.on("notes", (viewId) => {
|
|
1996
2057
|
const entry = appRegistry.getView(viewId);
|
|
@@ -3999,6 +4060,19 @@ function sessionNotifyLabel(record, fallback = "A session") {
|
|
|
3999
4060
|
return `${agent} · ${branch}`;
|
|
4000
4061
|
return agent || branch || fallback;
|
|
4001
4062
|
}
|
|
4063
|
+
/** How long the "finished" notification waits for a review card. */
|
|
4064
|
+
const DONE_REVIEW_WAIT_MS = 30_000;
|
|
4065
|
+
async function doneNotification(record) {
|
|
4066
|
+
const label = sessionNotifyLabel(record);
|
|
4067
|
+
const review = await Promise.race([record.runReview ?? Promise.resolve(undefined), new Promise((r) => setTimeout(r, DONE_REVIEW_WAIT_MS))]);
|
|
4068
|
+
const base = { kind: "session_done", sessionId: record.id, targetSessionId: record.id, title: "Session finished" };
|
|
4069
|
+
// No card, but the session has a preview: the notification offers Show me.
|
|
4070
|
+
const showMe = appRegistry.list(record.id).some((app) => app.views.some((view) => view.kind === "web"));
|
|
4071
|
+
if (!review || review.expired)
|
|
4072
|
+
return { ...base, body: `${label} finished — tap to review the result.`, ...(showMe ? { showMe } : {}) };
|
|
4073
|
+
const hint = reviewHint(review);
|
|
4074
|
+
return { ...base, title: `${label} finished`, body: hint.body, review: hint.review };
|
|
4075
|
+
}
|
|
4002
4076
|
async function sendNotificationHint(input) {
|
|
4003
4077
|
if (!sessionAdvertiseTarget)
|
|
4004
4078
|
return;
|
|
@@ -7762,13 +7836,10 @@ function attachSessionListeners(record) {
|
|
|
7762
7836
|
});
|
|
7763
7837
|
}
|
|
7764
7838
|
else if (!record.isWorking && !record.remoteActive && clients.size === 0 && (relay?.clientCount ?? 0) === 0 && (record.backgroundTaskCount ?? 0) === 0) {
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
title: "Session finished",
|
|
7770
|
-
body: `${sessionNotifyLabel(record)} finished — tap to review the result.`,
|
|
7771
|
-
});
|
|
7839
|
+
// Nobody is watching: the one "finished" notification also says when
|
|
7840
|
+
// the app changed, and opens its review card. Waits (bounded) for the
|
|
7841
|
+
// card; the hint carries IDs only, never the screenshot.
|
|
7842
|
+
void doneNotification(record).then((hint) => sendNotificationHint(hint));
|
|
7772
7843
|
}
|
|
7773
7844
|
// Any turn that didn't schedule another resume broke the limit streak —
|
|
7774
7845
|
// clear the durable counter so a future limit starts with a full budget
|
|
@@ -195,4 +195,19 @@ export class AttachmentStore {
|
|
|
195
195
|
return null;
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
/** Delete one blob now, for a caller that knows nothing else references it
|
|
199
|
+
* (an expired review card's screenshot). Best-effort. */
|
|
200
|
+
remove(hash) {
|
|
201
|
+
const p = this.getPath(hash);
|
|
202
|
+
if (!p)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
fs.unlinkSync(p);
|
|
206
|
+
}
|
|
207
|
+
catch { /* already gone */ }
|
|
208
|
+
try {
|
|
209
|
+
fs.unlinkSync(this.metaPath(hash));
|
|
210
|
+
}
|
|
211
|
+
catch { /* sidecar optional */ }
|
|
212
|
+
}
|
|
198
213
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
|
|
32
|
-
import { APP_PUBLICATION_BLOCK, isAppReference } from "../apps/types.js";
|
|
32
|
+
import { APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppReference, isAppReview } from "../apps/types.js";
|
|
33
33
|
/**
|
|
34
34
|
* Decompose a transcript message into serialization-independent "atoms": the
|
|
35
35
|
* agent's own tool ids (unique + identical across serializations) and its text
|
|
@@ -241,8 +241,14 @@ function isAppPublication(value) {
|
|
|
241
241
|
const entry = value;
|
|
242
242
|
return entry.bivyKind === "app-publication" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReference(entry.app);
|
|
243
243
|
}
|
|
244
|
+
function isAppReviewEntry(value) {
|
|
245
|
+
if (!value || typeof value !== "object")
|
|
246
|
+
return false;
|
|
247
|
+
const entry = value;
|
|
248
|
+
return entry.bivyKind === "app-review" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppReview(entry.review);
|
|
249
|
+
}
|
|
244
250
|
function isRecord(value) {
|
|
245
|
-
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value);
|
|
251
|
+
return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value);
|
|
246
252
|
}
|
|
247
253
|
/**
|
|
248
254
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -353,7 +359,18 @@ export function replayExtras(entries) {
|
|
|
353
359
|
role: "assistant", content: [{ type: APP_PUBLICATION_BLOCK, app: entry.app }],
|
|
354
360
|
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
355
361
|
}));
|
|
356
|
-
|
|
362
|
+
// Last write wins: a card that updated in place shows its latest state, at its latest position.
|
|
363
|
+
const reviews = new Map();
|
|
364
|
+
for (const entry of entries)
|
|
365
|
+
if (entry.bivyKind === "app-review") {
|
|
366
|
+
reviews.delete(entry.id);
|
|
367
|
+
reviews.set(entry.id, entry);
|
|
368
|
+
}
|
|
369
|
+
const cards = [...reviews.values()].map((entry) => ({
|
|
370
|
+
role: "assistant", content: [{ type: APP_REVIEW_BLOCK, review: entry.review }],
|
|
371
|
+
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
372
|
+
}));
|
|
373
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards];
|
|
357
374
|
}
|
|
358
375
|
/**
|
|
359
376
|
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
@@ -648,6 +665,11 @@ export class EventLog {
|
|
|
648
665
|
this.load(id);
|
|
649
666
|
this.enqueue(id, `app:${entry.id}`, { ...entry, app: { ...entry.app }, bivyKind: "app-publication", createdAt: Date.now() });
|
|
650
667
|
}
|
|
668
|
+
/** `createdAt`/`afterMessageCount` place the card; an expired card keeps its original place. */
|
|
669
|
+
appendAppReview(id, entry) {
|
|
670
|
+
this.load(id);
|
|
671
|
+
this.enqueue(id, `review:${entry.review.id}`, { bivyKind: "app-review", createdAt: entry.createdAt ?? Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.review.id, review: structuredClone(entry.review) });
|
|
672
|
+
}
|
|
651
673
|
appendOutboundAttachment(id, entry) {
|
|
652
674
|
this.load(id);
|
|
653
675
|
const record = {
|
package/package.json
CHANGED