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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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(--bg); color:var(--ink); font-family:var(--font-sans); }
11
- nav { display:flex; align-items:center; gap:var(--space-2); padding:var(--space-2) var(--space-3); border-bottom:thin solid var(--line); background:var(--surface); flex-shrink:0; }
12
- nav .btn { min-height:var(--space-7); flex-shrink:0; }
13
- #title { flex:1; min-width:0; font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
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
- iframe { flex:1; min-height:0; width:100%; border:0; background:var(--bg); }
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
- <nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
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 frame=document.getElementById('app'),status=document.getElementById('status'),back=document.getElementById('back'),reload=document.getElementById('reload');
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
- document.getElementById('title').textContent=data.name;
29
- document.getElementById('title').title=data.name;
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
- reload.disabled=false;
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){status.hidden=false;status.textContent='Reloading app…';frame.src=metadata.origin+'/';}};
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});
@@ -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
- /** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
76
- export class AppRegistry {
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
- constructor(reservedPorts = []) {
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 app = { id: randomBytes(16).toString("hex"), sessionId, name: name(input.name), views: [], createdAt: Date.now() };
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: randomBytes(16).toString("hex"), name: name(spec?.name) };
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
- target = { kind: "service", port };
105
- view = { ...base, kind: "web", source: "service" };
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
- if (target.kind === "static")
112
- total += target.bytes;
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
- if (typeof spec.command !== "string" || !spec.command.trim() || spec.command.length > 1000 || spec.command.includes("\u0000"))
119
- throw new Error("Terminal view requires an executable command.");
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)