@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.
- package/bin/bivy.mjs +25 -4
- package/dist/apps/gateway.js +257 -23
- package/dist/apps/inspector.js +97 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +198 -17
- package/dist/apps/registry.js +117 -19
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/screenshot.js +181 -0
- package/dist/apps/service.js +179 -4
- package/dist/apps/styles.css +3 -0
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +19 -6
- package/dist/node-config.js +6 -3
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +6 -1
- package/dist/server.js +29 -4
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileP = promisify(execFile);
|
|
8
|
+
/** Loopback or wildcard TCP listeners whose process runs inside a workspace.
|
|
9
|
+
* Agent-neutral by design: every framework and agent starts servers the same
|
|
10
|
+
* way, so the process table is the one hook that works for all of them. A
|
|
11
|
+
* listener is only an offer — access still needs a user's tap and a grant. */
|
|
12
|
+
export async function scanListeners(workspace, platform = process.platform) {
|
|
13
|
+
let root;
|
|
14
|
+
try {
|
|
15
|
+
root = await fs.realpath(workspace);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
|
|
21
|
+
const byPort = new Map();
|
|
22
|
+
for (const offer of found)
|
|
23
|
+
if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
|
|
24
|
+
byPort.set(offer.port, offer);
|
|
25
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
26
|
+
}
|
|
27
|
+
const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
|
|
28
|
+
/** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
|
|
29
|
+
export function listenPort(line) {
|
|
30
|
+
const cols = line.trim().split(/\s+/);
|
|
31
|
+
if (cols.length < 10 || cols[3] !== "0A")
|
|
32
|
+
return undefined; // 0A = LISTEN
|
|
33
|
+
const [address, portHex] = cols[1].split(":");
|
|
34
|
+
const words = address.match(/.{8}/g) ?? [];
|
|
35
|
+
const last = words[words.length - 1] ?? "";
|
|
36
|
+
const zero = /^0+$/.test(address);
|
|
37
|
+
const v4Loopback = last.endsWith("7F") && (words.length === 1 || words.slice(0, 3).join("") === "0000000000000000FFFF0000");
|
|
38
|
+
const v6Loopback = address === "00000000000000000000000001000000";
|
|
39
|
+
if (!zero && !v4Loopback && !v6Loopback)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { inode: cols[9], port: parseInt(portHex, 16) };
|
|
42
|
+
}
|
|
43
|
+
async function scanProc(root) {
|
|
44
|
+
const sockets = new Map();
|
|
45
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
46
|
+
const text = await fs.readFile(file, "utf8").catch(() => "");
|
|
47
|
+
for (const line of text.split("\n").slice(1)) {
|
|
48
|
+
const hit = listenPort(line);
|
|
49
|
+
if (hit && hit.inode !== "0")
|
|
50
|
+
sockets.set(hit.inode, hit.port);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!sockets.size)
|
|
54
|
+
return [];
|
|
55
|
+
const offers = [];
|
|
56
|
+
const pids = (await fs.readdir("/proc").catch(() => [])).filter((name) => /^\d+$/.test(name));
|
|
57
|
+
// cwd first: other users' processes fail here, and only workspace processes
|
|
58
|
+
// pay for an fd walk.
|
|
59
|
+
await Promise.all(pids.map(async (pid) => {
|
|
60
|
+
const cwd = await fs.readlink(`/proc/${pid}/cwd`).catch(() => "");
|
|
61
|
+
if (!cwd || !inside(root, cwd))
|
|
62
|
+
return;
|
|
63
|
+
const fds = await fs.readdir(`/proc/${pid}/fd`).catch(() => []);
|
|
64
|
+
const ports = new Set();
|
|
65
|
+
for (const fd of fds) {
|
|
66
|
+
const target = await fs.readlink(`/proc/${pid}/fd/${fd}`).catch(() => "");
|
|
67
|
+
const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1];
|
|
68
|
+
const port = inode ? sockets.get(inode) : undefined;
|
|
69
|
+
if (port)
|
|
70
|
+
ports.add(port);
|
|
71
|
+
}
|
|
72
|
+
if (!ports.size)
|
|
73
|
+
return;
|
|
74
|
+
const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
|
|
75
|
+
for (const port of ports)
|
|
76
|
+
offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
|
|
77
|
+
}));
|
|
78
|
+
return offers;
|
|
79
|
+
}
|
|
80
|
+
/** `lsof -F` field output: p<pid>, c<command>, n<name> records. */
|
|
81
|
+
export function parseLsof(stdout) {
|
|
82
|
+
const records = [];
|
|
83
|
+
for (const line of stdout.split("\n")) {
|
|
84
|
+
if (line.startsWith("p"))
|
|
85
|
+
records.push({ pid: Number(line.slice(1)), values: [] });
|
|
86
|
+
else if (line.startsWith("n") && records.length)
|
|
87
|
+
records[records.length - 1].values.push(line.slice(1));
|
|
88
|
+
}
|
|
89
|
+
return records;
|
|
90
|
+
}
|
|
91
|
+
async function scanLsof(root) {
|
|
92
|
+
const run = (args) => execFileP("lsof", args, { timeout: 4000, maxBuffer: 4 * 1024 * 1024 }).then((r) => r.stdout, (e) => e.stdout ?? "");
|
|
93
|
+
const listening = parseLsof(await run(["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]));
|
|
94
|
+
if (!listening.length)
|
|
95
|
+
return [];
|
|
96
|
+
const cwds = new Map(parseLsof(await run(["-a", "-d", "cwd", "-Fpn", "-p", listening.map((r) => r.pid).join(",")])).map((r) => [r.pid, r.values[0] ?? ""]));
|
|
97
|
+
const offers = [];
|
|
98
|
+
for (const { pid, values } of listening) {
|
|
99
|
+
const cwd = await fs.realpath(cwds.get(pid) ?? "").catch(() => "");
|
|
100
|
+
if (!cwd || !inside(root, cwd))
|
|
101
|
+
continue;
|
|
102
|
+
const args = (await execFileP("ps", ["-p", String(pid), "-o", "args="], { timeout: 4000 }).then((r) => r.stdout, () => "")).trim();
|
|
103
|
+
for (const name of values) {
|
|
104
|
+
const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
|
|
105
|
+
if (match)
|
|
106
|
+
offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return offers;
|
|
110
|
+
}
|
|
111
|
+
/** "node /x/node_modules/.bin/vite --port 5173" → "node vite --port 5173". */
|
|
112
|
+
function commandLabel(argv) {
|
|
113
|
+
const label = argv.slice(0, 4).map((arg) => arg.startsWith("/") || arg.startsWith(".") ? path.basename(arg) : arg).join(" ");
|
|
114
|
+
return label.length > 60 ? `${label.slice(0, 59)}…` : label || "server";
|
|
115
|
+
}
|
|
@@ -1,45 +1,226 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
/** This document lives on a different origin from app code. Never put a
|
|
4
|
-
* generated document on the shell host or let the frame navigate its parent.
|
|
4
|
+
* generated document on the shell host or let the frame navigate its parent.
|
|
5
|
+
* Everything the app's inspector reports is untrusted: it is displayed and
|
|
6
|
+
* turned into drafts the user reviews, never sent or acted on by itself. */
|
|
5
7
|
export function previewShell(nonce) {
|
|
6
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bivy · App preview</title>
|
|
8
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>Bivy · App preview</title>
|
|
7
9
|
<link rel="stylesheet" href="/__bivy/tokens.css"><link rel="stylesheet" href="/__bivy/styles.css">
|
|
8
10
|
<style nonce="${nonce}">
|
|
9
11
|
html,body { margin:0; width:100%; height:100%; }
|
|
10
|
-
body { display:flex; flex-direction:column; background:var(--
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
12
|
+
body { display:flex; flex-direction:column; background:var(--surface-2); color:var(--ink); font-family:var(--font-sans); }
|
|
13
|
+
#stage { flex:1; min-height:0; display:flex; justify-content:center; }
|
|
14
|
+
iframe { width:100%; height:100%; border:0; background:var(--bg); }
|
|
15
|
+
#stage[data-lens="tablet"] iframe { max-width:768px; }
|
|
16
|
+
#stage[data-lens="phone"] iframe { max-width:390px; }
|
|
17
|
+
#stage:not([data-lens="full"]) iframe { border-inline:thin solid var(--line); box-shadow:var(--shadow-sm); }
|
|
14
18
|
#status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
|
|
15
|
-
|
|
19
|
+
#down { flex-shrink:0; }
|
|
20
|
+
/* The pill floats above the app instead of taking a header row from it. */
|
|
21
|
+
#dock { position:fixed; left:var(--space-2); right:var(--space-2); bottom:calc(var(--space-2) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); display:flex; flex-direction:column; align-items:center; gap:var(--space-2); pointer-events:none; }
|
|
22
|
+
#dock > * { pointer-events:auto; }
|
|
23
|
+
nav { display:flex; align-items:center; gap:var(--space-1); max-width:100%; padding:var(--space-1); background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
|
|
24
|
+
nav .btn { flex-shrink:0; border-radius:var(--radius-full); }
|
|
25
|
+
nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
|
|
26
|
+
#name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
|
|
27
|
+
#title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
|
|
28
|
+
#stamp { font-size:var(--text-xs); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
29
|
+
#stamp:empty { display:none; }
|
|
30
|
+
#lens { flex-shrink:0; }
|
|
31
|
+
.panel { width:min(560px, 100%); max-height:50vh; overflow:auto; background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg); padding:var(--space-3); box-sizing:border-box; }
|
|
32
|
+
.panel h2 { font-size:var(--text-sm); margin:0 0 var(--space-2); display:flex; align-items:center; justify-content:space-between; gap:var(--space-2); }
|
|
33
|
+
.panel-actions { display:flex; justify-content:flex-end; gap:var(--space-2); margin-top:var(--space-2); flex-wrap:wrap; }
|
|
34
|
+
#compare-stage { display:grid; justify-content:center; background:var(--surface-2); border-radius:var(--radius-md); overflow:hidden; }
|
|
35
|
+
/* Both shots share one grid cell, so they overlay exactly at any size. */
|
|
36
|
+
#compare-stage img { grid-area:1 / 1; display:block; max-height:32vh; max-width:100%; }
|
|
37
|
+
/* Same column as the shots, so the handle sits on the split. */
|
|
38
|
+
#compare-slider { grid-area:2 / 1; width:100%; margin:var(--space-2) 0; accent-color:var(--accent); }
|
|
39
|
+
#compare-hint { font-size:var(--text-xs); margin:var(--space-1) 0 0; }
|
|
40
|
+
#entries { list-style:none; margin:0; padding:0; font-family:var(--font-mono); font-size:var(--text-xs); }
|
|
41
|
+
#entries li { display:flex; gap:var(--space-2); align-items:baseline; padding:var(--space-1) 0; border-top:thin solid var(--line); overflow-wrap:anywhere; }
|
|
42
|
+
#draft-text { width:100%; min-height:5em; box-sizing:border-box; font:inherit; font-size:var(--text-sm); resize:vertical; }
|
|
43
|
+
#draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
|
|
44
|
+
#draft-context { margin:0; padding:var(--space-2); background:var(--surface-2); border-radius:var(--radius-md); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); }
|
|
45
|
+
#show { position:fixed; right:var(--space-3); bottom:calc(var(--space-3) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
|
|
46
|
+
.label-narrow { display:none; }
|
|
47
|
+
@media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
|
|
16
48
|
[hidden] { display:none !important; }
|
|
17
49
|
</style></head><body>
|
|
18
|
-
<
|
|
50
|
+
<div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
|
|
19
51
|
<p id="status" role="status">Opening app…</p>
|
|
20
|
-
<iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
|
|
52
|
+
<div id="stage" data-lens="full"><iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
|
53
|
+
<div id="dock">
|
|
54
|
+
<section class="panel" id="console" hidden aria-labelledby="console-title">
|
|
55
|
+
<h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
|
|
56
|
+
<p class="muted" id="console-empty">No errors or warnings since this page loaded.</p>
|
|
57
|
+
<ul id="entries"></ul>
|
|
58
|
+
<div class="panel-actions"><button class="btn sm" id="send-errors" disabled>Send to agent…</button></div>
|
|
59
|
+
</section>
|
|
60
|
+
<section class="panel" id="compare" hidden aria-labelledby="compare-title">
|
|
61
|
+
<h2 id="compare-title">Before and after the agent’s last change</h2>
|
|
62
|
+
<div id="compare-stage"><img id="compare-after" alt="After the agent’s last change"><img id="compare-before" alt="Before the agent’s last change"><input type="range" id="compare-slider" min="0" max="100" value="50" aria-label="Show more of before or after"></div>
|
|
63
|
+
<p class="muted" id="compare-hint">Left of the handle: before. Right: now. Screenshots at phone width.</p>
|
|
64
|
+
</section>
|
|
65
|
+
<section class="panel" id="draft" hidden aria-labelledby="draft-title">
|
|
66
|
+
<h2 id="draft-title">Draft for the agent</h2>
|
|
67
|
+
<textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
|
|
68
|
+
<p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
|
|
69
|
+
<pre id="draft-context"></pre>
|
|
70
|
+
<div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
|
|
71
|
+
</section>
|
|
72
|
+
<p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. Press Escape or Point again to stop.</p>
|
|
73
|
+
<nav aria-label="Bivy preview controls">
|
|
74
|
+
<button class="btn sm ghost" id="back" aria-label="Back to chat">‹ <span class="label-wide">Back to chat</span><span class="label-narrow">Chat</span></button>
|
|
75
|
+
<span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
|
|
76
|
+
<button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
|
|
77
|
+
<button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
|
|
78
|
+
<button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
|
|
79
|
+
<button class="btn sm ghost" id="errors" aria-pressed="false" aria-label="Console" disabled>Console <span class="badge" data-variant="solid" data-tone="danger" id="error-count" hidden></span></button>
|
|
80
|
+
<div class="segmented" id="lens" role="radiogroup" aria-label="Preview width"><button class="seg-btn" role="radio" aria-selected="true" data-lens="full">Full</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="tablet">Tablet</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="phone">Phone</button></div>
|
|
81
|
+
<button class="btn sm ghost" id="reload" disabled>Reload</button>
|
|
82
|
+
</nav>
|
|
83
|
+
</div>
|
|
84
|
+
<button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
|
|
21
85
|
<script nonce="${nonce}">
|
|
22
|
-
const
|
|
86
|
+
const $=id=>document.getElementById(id);
|
|
87
|
+
const frame=$('app'),status=$('status'),back=$('back'),reload=$('reload'),stage=$('stage');
|
|
23
88
|
const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
|
24
|
-
let metadata;
|
|
89
|
+
let metadata,currentPath='/';
|
|
90
|
+
// Peek: framed by a Bivy client, which owns closing and the composer.
|
|
91
|
+
const embedded=parent!==window;
|
|
92
|
+
const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),new URL(metadata.returnTo).origin);
|
|
25
93
|
const storageKey='bivy-preview';
|
|
94
|
+
const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
|
|
95
|
+
const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
|
|
26
96
|
function show(data,launch){
|
|
27
97
|
metadata=data;
|
|
28
|
-
|
|
29
|
-
|
|
98
|
+
$('title').textContent=data.name;
|
|
99
|
+
$('title').title=data.name;
|
|
30
100
|
document.title='Bivy · '+data.name;
|
|
31
101
|
back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
|
|
32
102
|
frame.title=data.name;
|
|
33
103
|
frame.hidden=false;
|
|
34
|
-
frame.src=data.origin+(launch?'/__bivy/open#'+launch:'/');
|
|
35
|
-
|
|
104
|
+
frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
|
|
105
|
+
back.hidden=embedded&&Boolean(data.returnTo);
|
|
106
|
+
for(const b of [reload,$('point'),$('errors')])b.disabled=false;
|
|
36
107
|
status.textContent='Loading app…';
|
|
37
|
-
frame.onload=()=>{status.hidden=true;};
|
|
108
|
+
frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
|
|
38
109
|
// Store navigation metadata only, never tickets or cookies.
|
|
39
110
|
try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
|
|
40
111
|
}
|
|
112
|
+
// Reload when an agent turn changes files, returning to the page in view.
|
|
113
|
+
// Until the frame has redeemed its access cookie, polls fail; back off, and
|
|
114
|
+
// let the next frame load retry at once.
|
|
115
|
+
let revision=null,retry=0,wake=null;
|
|
116
|
+
async function watch(){
|
|
117
|
+
wake=null;
|
|
118
|
+
try{
|
|
119
|
+
const r=await fetch(metadata.origin+'/__bivy/revision?after='+revision,{credentials:'include',cache:'no-store'});
|
|
120
|
+
if(!r.ok)throw Error();
|
|
121
|
+
const d=await r.json();
|
|
122
|
+
if(revision>=0&&d.revision!==revision){
|
|
123
|
+
open(currentPath!=='/'?currentPath:d.path,'Updating…');
|
|
124
|
+
$('stamp').textContent='Updated after the agent’s turn';
|
|
125
|
+
// Screenshots for Compare land a few seconds after the change.
|
|
126
|
+
for(const ms of [5000,15000])setTimeout(()=>void loadCompare(),ms);
|
|
127
|
+
}
|
|
128
|
+
revision=d.revision;retry=0;setTimeout(watch,0);
|
|
129
|
+
}catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
|
|
130
|
+
}
|
|
131
|
+
// Drafts go to the session's composer; nothing is ever sent from here.
|
|
132
|
+
function toChat(text){
|
|
133
|
+
if(embedded)return toBivy({type:'draft',text});
|
|
134
|
+
const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
|
|
135
|
+
location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
|
|
136
|
+
}
|
|
137
|
+
function draft(context){
|
|
138
|
+
panels(null);
|
|
139
|
+
$('draft').hidden=false;
|
|
140
|
+
$('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
|
|
141
|
+
$('draft-context').textContent=context;
|
|
142
|
+
const box=$('draft-text');box.value='';box.focus();
|
|
143
|
+
}
|
|
144
|
+
$('draft-cancel').onclick=()=>{$('draft').hidden=true;};
|
|
145
|
+
$('draft-add').onclick=async()=>{
|
|
146
|
+
const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
|
|
147
|
+
if(metadata.returnTo)return toChat(text);
|
|
148
|
+
try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
|
|
149
|
+
};
|
|
150
|
+
// Console
|
|
151
|
+
let entries=[];
|
|
152
|
+
function resetConsole(){entries=[];renderConsole();}
|
|
153
|
+
function renderConsole(){
|
|
154
|
+
const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
|
|
155
|
+
count.hidden=!errors;count.textContent=String(errors);
|
|
156
|
+
$('errors').setAttribute('aria-label',errors?'Console, '+errors+' error'+(errors===1?'':'s'):'Console');
|
|
157
|
+
$('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
|
|
158
|
+
$('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
|
|
159
|
+
}
|
|
160
|
+
$('errors').onclick=()=>panels($('console').hidden?'console':null);
|
|
161
|
+
$('clear').onclick=resetConsole;
|
|
162
|
+
$('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
|
|
163
|
+
// Point and tell
|
|
164
|
+
const point=$('point');
|
|
165
|
+
function pointing(on){point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
|
|
166
|
+
point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
|
|
167
|
+
function picked(d){
|
|
168
|
+
pointing(false);
|
|
169
|
+
if(d.cancelled)return;
|
|
170
|
+
const r=d.rect||{},v=d.viewport||{};
|
|
171
|
+
const errors=entries.filter(e=>e.level==='error').slice(-5);
|
|
172
|
+
draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
|
|
173
|
+
+'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
|
|
174
|
+
+(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''));
|
|
175
|
+
}
|
|
176
|
+
// Compare: screenshots around the agent's last change (agent screenshots on).
|
|
177
|
+
const compareBtn=$('compare-btn');let shots=[],urls=[];
|
|
178
|
+
async function loadCompare(){
|
|
179
|
+
try{const r=await fetch(metadata.origin+'/__bivy/compare',{credentials:'include',cache:'no-store'});if(!r.ok)return;shots=(await r.json()).shots||[];compareBtn.hidden=shots.length<2;}catch{}
|
|
180
|
+
}
|
|
181
|
+
function panels(open){for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
|
|
182
|
+
compareBtn.onclick=async()=>{
|
|
183
|
+
if(!$('compare').hidden)return panels(null);
|
|
184
|
+
panels('compare');
|
|
185
|
+
for(const u of urls)URL.revokeObjectURL(u);
|
|
186
|
+
const pick=[shots[shots.length-2],shots[shots.length-1]];
|
|
187
|
+
urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
|
|
188
|
+
const before=$('compare-before'),after=$('compare-after');
|
|
189
|
+
before.src=urls[0];after.src=urls[1];
|
|
190
|
+
split();
|
|
191
|
+
};
|
|
192
|
+
const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
|
|
193
|
+
$('compare-slider').oninput=split;
|
|
194
|
+
// The pill can cover an app's own bottom bar; collapse it to a corner button.
|
|
195
|
+
$('hide').onclick=()=>{if(point.getAttribute('aria-pressed')==='true')pointing(false);$('dock').hidden=true;$('show').hidden=false;$('show').focus();};
|
|
196
|
+
$('show').onclick=()=>{$('dock').hidden=false;$('show').hidden=true;$('hide').focus();};
|
|
197
|
+
// Device lens (desktop widths)
|
|
198
|
+
for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
|
|
199
|
+
stage.dataset.lens=b.dataset.lens;
|
|
200
|
+
for(const o of $('lens').querySelectorAll('button'))o.setAttribute('aria-selected',String(o===b));
|
|
201
|
+
};
|
|
202
|
+
// Messages from the app origin: server state and inspector reports.
|
|
203
|
+
const down=$('down'),downText=$('down-text'),ask=$('ask');
|
|
204
|
+
let downPort=0;
|
|
205
|
+
addEventListener('message',e=>{
|
|
206
|
+
if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow)return;
|
|
207
|
+
const d=e.data||{};
|
|
208
|
+
if(d.type==='bivy:access'&&d.state==='blocked'&&embedded&&metadata.returnTo)toBivy({type:'blocked'});
|
|
209
|
+
else if(d.type==='bivy:upstream'){
|
|
210
|
+
downPort=Number(d.port)||0;
|
|
211
|
+
down.hidden=d.state!=='down';
|
|
212
|
+
downText.textContent='Nothing is answering on port '+downPort+'.';
|
|
213
|
+
ask.hidden=!metadata.returnTo;
|
|
214
|
+
}else if(d.source==='bivy-inspector'){
|
|
215
|
+
if(d.type==='route')currentPath=safePath(d.path);
|
|
216
|
+
else if(d.type==='console'&&(d.level==='error'||d.level==='warn')){entries.push({level:d.level,text:String(d.text).slice(0,500)});if(entries.length>50)entries.shift();renderConsole();}
|
|
217
|
+
else if(d.type==='picked')picked(d);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
ask.onclick=()=>toChat('The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.');
|
|
221
|
+
addEventListener('keydown',e=>{if(e.key==='Escape'){if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
|
|
41
222
|
back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
|
|
42
|
-
reload.onclick=()=>{if(metadata)
|
|
223
|
+
reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
|
|
43
224
|
(async()=>{
|
|
44
225
|
if(ticket){
|
|
45
226
|
const response=await fetch('/__bivy/launch',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket});
|
package/dist/apps/registry.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
6
7
|
const MAX_BYTES = 25 * 1024 * 1024;
|
|
7
8
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
8
9
|
const MAX_FILES = 2000;
|
|
10
|
+
const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
|
|
9
11
|
/** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
|
|
10
12
|
function snapshot(workspace, directory) {
|
|
11
13
|
const base = fs.realpathSync(workspace);
|
|
@@ -72,15 +74,70 @@ function snapshot(workspace, directory) {
|
|
|
72
74
|
throw new Error("Static directory needs an index.html entry point.");
|
|
73
75
|
return { kind: "static", files, bytes };
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
function command(spec, workspace, label) {
|
|
78
|
+
const value = spec;
|
|
79
|
+
if (typeof value?.command !== "string" || !value.command.trim() || value.command.length > 1000 || value.command.includes("\u0000"))
|
|
80
|
+
throw new Error(`${label} requires an executable command.`);
|
|
81
|
+
if (value.args !== undefined && (!Array.isArray(value.args) || value.args.length > 100 || value.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
82
|
+
throw new Error(`Invalid ${label.toLowerCase()} arguments.`);
|
|
83
|
+
return { command: value.command, args: [...(value.args ?? [])], workspace: fs.realpathSync(workspace) };
|
|
84
|
+
}
|
|
85
|
+
/** Apps persist (with their IDs) when given a file, so chat launchers and
|
|
86
|
+
* preview addresses survive restarts. Views whose server Bivy doesn't own —
|
|
87
|
+
* a service without `start` — are not restored: a reused local port could
|
|
88
|
+
* belong to anything by then. Detection offers them again. */
|
|
89
|
+
export class AppRegistry extends EventEmitter {
|
|
77
90
|
reservedPorts;
|
|
91
|
+
file;
|
|
78
92
|
apps = new Map();
|
|
79
93
|
views = new Map();
|
|
80
|
-
|
|
94
|
+
persisted = new Map();
|
|
95
|
+
constructor(reservedPorts = [], file) {
|
|
96
|
+
super();
|
|
81
97
|
this.reservedPorts = reservedPorts;
|
|
98
|
+
this.file = file;
|
|
99
|
+
this.setMaxListeners(0);
|
|
100
|
+
this.restore();
|
|
101
|
+
}
|
|
102
|
+
restore() {
|
|
103
|
+
if (!this.file)
|
|
104
|
+
return;
|
|
105
|
+
let records = [];
|
|
106
|
+
try {
|
|
107
|
+
records = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
113
|
+
const keep = record.manifest?.views?.map((spec, i) => ({ spec, id: record.viewIds?.[i] }))
|
|
114
|
+
.filter(({ spec, id }) => typeof id === "string" && !(spec?.kind === "web" && spec.source?.kind === "service" && !spec.source.start)) ?? [];
|
|
115
|
+
if (!keep.length)
|
|
116
|
+
continue;
|
|
117
|
+
// A workspace that moved or a build that vanished drops that app, not the rest.
|
|
118
|
+
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
|
+
}
|
|
121
|
+
catch { /* skipped */ }
|
|
122
|
+
}
|
|
123
|
+
this.save();
|
|
124
|
+
}
|
|
125
|
+
save() {
|
|
126
|
+
if (!this.file)
|
|
127
|
+
return;
|
|
128
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(tmp, JSON.stringify([...this.persisted.values()]), { mode: 0o600 });
|
|
131
|
+
fs.renameSync(tmp, this.file);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
try {
|
|
135
|
+
fs.rmSync(tmp, { force: true });
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore */ }
|
|
138
|
+
}
|
|
82
139
|
}
|
|
83
|
-
publish(sessionId, workspace, input) {
|
|
140
|
+
publish(sessionId, workspace, input, restore) {
|
|
84
141
|
const name = (value) => {
|
|
85
142
|
if (typeof value !== "string" || !value.trim() || value.length > 100)
|
|
86
143
|
throw new Error("App and view names must contain 1–100 characters.");
|
|
@@ -90,54 +147,93 @@ export class AppRegistry {
|
|
|
90
147
|
throw new Error("Expected manifest version 1 with 1–8 views.");
|
|
91
148
|
if (this.apps.size >= 50)
|
|
92
149
|
throw new Error("Remove an app before publishing more (limit: 50).");
|
|
93
|
-
const
|
|
150
|
+
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() };
|
|
94
152
|
const entries = [];
|
|
95
153
|
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
96
|
-
for (const spec of input.views) {
|
|
97
|
-
const base = { id:
|
|
154
|
+
for (const [index, spec] of input.views.entries()) {
|
|
155
|
+
const base = { id: id(index), name: name(spec?.name) };
|
|
98
156
|
let target;
|
|
99
157
|
let view;
|
|
158
|
+
let source;
|
|
100
159
|
if (spec.kind === "web" && spec.source?.kind === "service") {
|
|
101
160
|
const port = spec.source.port;
|
|
102
161
|
if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
|
|
103
162
|
throw new Error("Choose a non-reserved local service port between 1024 and 65535.");
|
|
104
|
-
|
|
105
|
-
|
|
163
|
+
const start = spec.source.start === undefined ? undefined : command(spec.source.start, workspace, "Service start");
|
|
164
|
+
target = { kind: "service", port, start };
|
|
165
|
+
view = { ...base, kind: "web", source: "service", ...(start ? { managed: true } : {}) };
|
|
106
166
|
}
|
|
107
167
|
else if (spec.kind === "web" && spec.source?.kind === "static") {
|
|
108
168
|
if (typeof spec.source.directory !== "string" || !spec.source.directory)
|
|
109
169
|
throw new Error("Static directory is required.");
|
|
110
170
|
target = snapshot(workspace, spec.source.directory);
|
|
111
|
-
|
|
112
|
-
|
|
171
|
+
source = { workspace, directory: spec.source.directory };
|
|
172
|
+
total += target.bytes;
|
|
113
173
|
if (total > MAX_TOTAL_BYTES)
|
|
114
174
|
throw new Error("Static apps exceed the node's 100 MiB snapshot budget.");
|
|
115
175
|
view = { ...base, kind: "web", source: "static" };
|
|
116
176
|
}
|
|
117
177
|
else if (spec.kind === "terminal") {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (spec.args !== undefined && (!Array.isArray(spec.args) || spec.args.length > 100 || spec.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
121
|
-
throw new Error("Invalid terminal arguments.");
|
|
122
|
-
const args = [...(spec.args ?? [])];
|
|
123
|
-
target = { kind: "terminal", command: spec.command, args, workspace: fs.realpathSync(workspace) };
|
|
124
|
-
view = { ...base, kind: "terminal", command: spec.command, args };
|
|
178
|
+
target = { kind: "terminal", ...command(spec, workspace, "Terminal view") };
|
|
179
|
+
view = { ...base, kind: "terminal", command: target.command, args: target.args };
|
|
125
180
|
}
|
|
126
181
|
else
|
|
127
182
|
throw new Error("Unsupported app view. This machine supports web and terminal views.");
|
|
128
183
|
app.views.push(view);
|
|
129
|
-
entries.push({ app, view, target });
|
|
184
|
+
entries.push({ app, view, target, revision: 0, source });
|
|
130
185
|
}
|
|
131
186
|
// Commit all views together: a bad later view cannot leave a partial app.
|
|
132
187
|
this.apps.set(app.id, app);
|
|
133
188
|
for (const entry of entries)
|
|
134
189
|
this.views.set(entry.view.id, entry);
|
|
190
|
+
this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt });
|
|
191
|
+
if (!restore)
|
|
192
|
+
this.save();
|
|
135
193
|
return structuredClone(app);
|
|
136
194
|
}
|
|
137
195
|
list(sessionId) {
|
|
138
196
|
return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
|
|
139
197
|
}
|
|
140
198
|
getView(id) { return this.views.get(id); }
|
|
199
|
+
/** An agent turn changed files: re-take changed static snapshots and bump
|
|
200
|
+
* every affected web view (emits `revision`). Returns the bumped view IDs. */
|
|
201
|
+
touch(sessionId) {
|
|
202
|
+
const bumped = [];
|
|
203
|
+
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
204
|
+
for (const entry of this.views.values()) {
|
|
205
|
+
if (entry.app.sessionId !== sessionId || entry.view.kind !== "web")
|
|
206
|
+
continue;
|
|
207
|
+
if (entry.target.kind === "static") {
|
|
208
|
+
if (!entry.source)
|
|
209
|
+
continue;
|
|
210
|
+
let next;
|
|
211
|
+
// A broken build keeps the last good snapshot rather than blanking the preview.
|
|
212
|
+
try {
|
|
213
|
+
next = snapshot(entry.source.workspace, entry.source.directory);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (sameFiles(entry.target.files, next.files) || total - entry.target.bytes + next.bytes > MAX_TOTAL_BYTES)
|
|
219
|
+
continue;
|
|
220
|
+
total += next.bytes - entry.target.bytes;
|
|
221
|
+
entry.target = next;
|
|
222
|
+
}
|
|
223
|
+
entry.revision++;
|
|
224
|
+
bumped.push(entry.view.id);
|
|
225
|
+
this.emit("revision", entry.view.id);
|
|
226
|
+
}
|
|
227
|
+
return bumped;
|
|
228
|
+
}
|
|
229
|
+
/** Ports a session already previews, and ports no app may claim. */
|
|
230
|
+
claimedPorts(sessionId) {
|
|
231
|
+
const ports = new Set(this.reservedPorts);
|
|
232
|
+
for (const { app, target } of this.views.values())
|
|
233
|
+
if (app.sessionId === sessionId && target.kind === "service")
|
|
234
|
+
ports.add(target.port);
|
|
235
|
+
return ports;
|
|
236
|
+
}
|
|
141
237
|
require(sessionId, id) {
|
|
142
238
|
const app = this.apps.get(id);
|
|
143
239
|
if (!app || app.sessionId !== sessionId)
|
|
@@ -156,5 +252,7 @@ export class AppRegistry {
|
|
|
156
252
|
for (const view of app.views)
|
|
157
253
|
this.views.delete(view.id);
|
|
158
254
|
this.apps.delete(id);
|
|
255
|
+
this.persisted.delete(id);
|
|
256
|
+
this.save();
|
|
159
257
|
}
|
|
160
258
|
}
|
|
@@ -8,14 +8,16 @@ import { PreviewStream } from "./preview-stream.js";
|
|
|
8
8
|
export class RemotePreview {
|
|
9
9
|
registry;
|
|
10
10
|
returnOrigins;
|
|
11
|
+
signIn;
|
|
11
12
|
gateway;
|
|
12
13
|
origin;
|
|
13
14
|
relayUrl;
|
|
14
15
|
online = false;
|
|
15
16
|
sockets = new Set();
|
|
16
|
-
constructor(registry, returnOrigins) {
|
|
17
|
+
constructor(registry, returnOrigins, signIn) {
|
|
17
18
|
this.registry = registry;
|
|
18
19
|
this.returnOrigins = returnOrigins;
|
|
20
|
+
this.signIn = signIn;
|
|
19
21
|
}
|
|
20
22
|
get available() { return this.online && Boolean(this.gateway); }
|
|
21
23
|
ready(origin, relayUrl) {
|
|
@@ -28,7 +30,7 @@ export class RemotePreview {
|
|
|
28
30
|
return;
|
|
29
31
|
if (this.origin !== template) {
|
|
30
32
|
this.gateway?.close();
|
|
31
|
-
this.gateway = new AppGateway(this.registry, template, this.returnOrigins);
|
|
33
|
+
this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn);
|
|
32
34
|
this.origin = template;
|
|
33
35
|
}
|
|
34
36
|
this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
|
|
@@ -65,6 +67,12 @@ export class RemotePreview {
|
|
|
65
67
|
return this.gateway.share(id);
|
|
66
68
|
}
|
|
67
69
|
revoke(id) { this.gateway?.revoke(id); }
|
|
70
|
+
address(id) { return this.available ? this.gateway.address(id) : undefined; }
|
|
71
|
+
openDirect(id) {
|
|
72
|
+
if (!this.available)
|
|
73
|
+
throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
|
|
74
|
+
return this.gateway.openDirect(id);
|
|
75
|
+
}
|
|
68
76
|
disconnect() {
|
|
69
77
|
this.online = false;
|
|
70
78
|
for (const ws of this.sockets)
|