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

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.
@@ -15,7 +15,25 @@ const MIME = {
15
15
  const COOKIE = "__Host-bivy-preview";
16
16
  const OPEN_PATH = "/__bivy/open";
17
17
  const REDEEM_PATH = "/__bivy/redeem";
18
+ const REVISION_PATH = "/__bivy/revision";
18
19
  const HOUR = 60 * 60_000;
20
+ /** Marks gateway-generated "server not answering" responses, so recovery
21
+ * polling can tell them apart from an app's own 502s. */
22
+ const UPSTREAM_DOWN = "x-bivy-upstream-down";
23
+ /** A top-level or framed page load. Browsers without Fetch Metadata still ask for HTML. */
24
+ function isPageLoad(req) {
25
+ const dest = req.headers["sec-fetch-dest"];
26
+ return req.method === "GET" && (dest ? ["document", "iframe"].includes(dest) : /text\/html/.test(req.headers.accept ?? ""));
27
+ }
28
+ /** Served on the app's origin in place of a blank frame. It reloads itself once
29
+ * the server answers, and tells a framing shell so it can offer next steps. */
30
+ function upstreamDownPage(nonce, port, shellOrigin) {
31
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Waiting for the app server</title>
32
+ <style nonce="${nonce}">:root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;box-sizing:border-box;text-align:center}main{max-width:32rem}h1{font-size:1.25rem;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}</style>
33
+ <main><h1>Nothing is answering on port ${port}</h1><p role="status">The app server stopped or hasn’t started yet. This page reloads by itself when it’s back.</p></main>
34
+ <script nonce="${nonce}">const tell=state=>{if(parent!==window)parent.postMessage({type:'bivy:upstream',state,port:${port}},${JSON.stringify(shellOrigin)});};tell('down');
35
+ const poll=async()=>{try{const r=await fetch(location.href,{method:'HEAD',cache:'no-store'});if(!r.headers.has('${UPSTREAM_DOWN}')){tell('up');location.reload();return;}}catch{}setTimeout(poll,2000);};setTimeout(poll,2000);</script></html>`;
36
+ }
19
37
  /** Copied links are reusable until revoked, capped so a forgotten one lapses. */
20
38
  export const SHARE_TTL = 24 * HOUR;
21
39
  const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
@@ -176,7 +194,7 @@ export class AppGateway {
176
194
  const origin = this.shellOrigin(id);
177
195
  const nonce = randomBytes(16).toString("hex");
178
196
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
179
- res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self'; frame-src ${this.origin(id)}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
197
+ res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self' ${this.origin(id)}; frame-src ${this.origin(id)}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
180
198
  if (req.method === "GET" && this.styles.has(req.url ?? "")) {
181
199
  res.setHeader("Content-Type", "text/css; charset=utf-8");
182
200
  res.end(this.styles.get(req.url));
@@ -297,6 +315,13 @@ export class AppGateway {
297
315
  return;
298
316
  }
299
317
  this.track(id, req.socket, expires);
318
+ if (req.url.startsWith(`${REVISION_PATH}?`) && req.method === "GET") {
319
+ this.revision(req, res, entry);
320
+ return;
321
+ }
322
+ // Remember the framed page so a turn reload lands where the user was.
323
+ if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
324
+ entry.lastPath = req.url;
300
325
  if (entry.target.kind === "static") {
301
326
  if (!["GET", "HEAD"].includes(req.method ?? "")) {
302
327
  res.writeHead(405, { Allow: "GET, HEAD" });
@@ -314,7 +339,17 @@ export class AppGateway {
314
339
  }
315
340
  if (file.endsWith("/"))
316
341
  file += "index.html";
317
- const data = entry.target.files.get(file);
342
+ let data = entry.target.files.get(file);
343
+ let status = 200;
344
+ // Client-side routes: an extensionless page load falls back like common
345
+ // static hosts do — to 404.html (status 404) when present, else index.html.
346
+ if (!data && !path.extname(file) && isPageLoad(req)) {
347
+ const notFound = entry.target.files.get("/404.html");
348
+ file = notFound ? "/404.html" : "/index.html";
349
+ data = notFound ?? entry.target.files.get(file);
350
+ if (notFound)
351
+ status = 404;
352
+ }
318
353
  if (!data) {
319
354
  res.writeHead(404);
320
355
  res.end("File not found.");
@@ -322,7 +357,7 @@ export class AppGateway {
322
357
  }
323
358
  const headers = responseHeaders({}, this.shellOrigin(id));
324
359
  headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
325
- res.writeHead(200, { ...headers, "content-length": data.length });
360
+ res.writeHead(status, { ...headers, "content-length": data.length });
326
361
  res.end(req.method === "HEAD" ? undefined : data);
327
362
  return;
328
363
  }
@@ -331,17 +366,54 @@ export class AppGateway {
331
366
  res.end();
332
367
  return;
333
368
  }
334
- const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
369
+ const port = entry.target.port;
370
+ const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
335
371
  res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
336
372
  response.on("error", () => res.destroy());
337
373
  response.pipe(res);
338
374
  });
339
375
  upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
340
- upstream.on("error", () => { if (!res.headersSent)
341
- res.writeHead(502, { "content-type": "text/plain" }); res.end("App server unavailable. Start it on the registered port, then reload."); });
376
+ upstream.on("error", () => {
377
+ if (res.headersSent) {
378
+ res.destroy();
379
+ return;
380
+ }
381
+ if (!isPageLoad(req)) {
382
+ res.writeHead(502, { "content-type": "text/plain", [UPSTREAM_DOWN]: "1" });
383
+ res.end("App server unavailable. Start it on the registered port, then reload.");
384
+ return;
385
+ }
386
+ const nonce = randomBytes(16).toString("hex");
387
+ res.writeHead(502, { "content-type": "text/html; charset=utf-8", [UPSTREAM_DOWN]: "1", "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.shellOrigin(id)}; base-uri 'none'` });
388
+ res.end(upstreamDownPage(nonce, port, this.shellOrigin(id)));
389
+ });
342
390
  res.on("close", () => upstream.destroy());
343
391
  req.pipe(upstream);
344
392
  }
393
+ /** Long poll from the trusted shell (same-site, credentialed CORS): answers
394
+ * when the view's revision differs from `after`, or after 25 seconds. */
395
+ revision(req, res, entry) {
396
+ const id = entry.view.id;
397
+ const after = Number(new URL(req.url, "http://gateway").searchParams.get("after"));
398
+ let timer;
399
+ const onRevision = (viewId) => { if (viewId === id)
400
+ send(); };
401
+ const cleanup = () => { clearTimeout(timer); this.registry.off("revision", onRevision); };
402
+ const send = () => {
403
+ cleanup();
404
+ if (res.writableEnded || res.destroyed)
405
+ return;
406
+ res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": this.shellOrigin(id), "access-control-allow-credentials": "true", vary: "Origin" });
407
+ res.end(JSON.stringify({ revision: entry.revision, path: entry.lastPath ?? "/" }));
408
+ };
409
+ if (entry.revision !== after) {
410
+ send();
411
+ return;
412
+ }
413
+ this.registry.on("revision", onRevision);
414
+ timer = setTimeout(send, 25_000);
415
+ res.on("close", cleanup);
416
+ }
345
417
  upgrade(req, socket, head) {
346
418
  const entry = this.entry(req);
347
419
  const expires = entry && this.authorize(req, entry.view.id);
@@ -350,7 +422,8 @@ export class AppGateway {
350
422
  return;
351
423
  }
352
424
  this.track(entry.view.id, socket, expires);
353
- const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
425
+ const port = entry.target.port;
426
+ const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
354
427
  upstream.setTimeout(10_000, () => upstream.destroy());
355
428
  upstream.on("upgrade", (response, peer, upstreamHead) => {
356
429
  peer.setTimeout(0);
@@ -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
+ }
@@ -10,12 +10,15 @@ html,body { margin:0; width:100%; height:100%; }
10
10
  body { display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--font-sans); }
11
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
12
  nav .btn { min-height:var(--space-7); flex-shrink:0; }
13
+ #stamp { font-size:var(--text-xs); white-space:nowrap; }
13
14
  #title { flex:1; min-width:0; font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15
+ #down { flex-shrink:0; }
14
16
  #status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
15
17
  iframe { flex:1; min-height:0; width:100%; border:0; background:var(--bg); }
16
18
  [hidden] { display:none !important; }
17
19
  </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>
20
+ <nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
21
+ <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
22
  <p id="status" role="status">Opening app…</p>
20
23
  <iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
21
24
  <script nonce="${nonce}">
@@ -34,10 +37,44 @@ function show(data,launch){
34
37
  frame.src=data.origin+(launch?'/__bivy/open#'+launch:'/');
35
38
  reload.disabled=false;
36
39
  status.textContent='Loading app…';
37
- frame.onload=()=>{status.hidden=true;};
40
+ frame.onload=()=>{status.hidden=true;if(revision===null){revision=-1;watch();}else if(wake)wake();};
38
41
  // Store navigation metadata only, never tickets or cookies.
39
42
  try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
40
43
  }
44
+ // Reload when an agent turn changes files, returning to the last framed page.
45
+ // Until the frame has redeemed its access cookie, polls fail; back off, and
46
+ // let the next frame load retry at once.
47
+ let revision=null,retry=0,wake=null;
48
+ async function watch(){
49
+ wake=null;
50
+ try{
51
+ const r=await fetch(metadata.origin+'/__bivy/revision?after='+revision,{credentials:'include',cache:'no-store'});
52
+ if(!r.ok)throw Error();
53
+ const d=await r.json();
54
+ if(revision>=0&&d.revision!==revision){
55
+ const path=typeof d.path==='string'&&d.path.startsWith('/')&&!d.path.startsWith('//')?d.path:'/';
56
+ frame.src=metadata.origin+path;
57
+ document.getElementById('stamp').textContent='Updated after the agent’s turn';
58
+ }
59
+ revision=d.revision;retry=0;setTimeout(watch,0);
60
+ }catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
61
+ }
62
+ // The app origin reports when its server stops answering. Its content is
63
+ // untrusted: the port is only ever placed in a draft the user reviews.
64
+ const down=document.getElementById('down'),downText=document.getElementById('down-text'),ask=document.getElementById('ask');
65
+ let downPort=0;
66
+ addEventListener('message',e=>{
67
+ if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow||e.data?.type!=='bivy:upstream')return;
68
+ downPort=Number(e.data.port)||0;
69
+ down.hidden=e.data.state!=='down';
70
+ downText.textContent='Nothing is answering on port '+downPort+'.';
71
+ ask.hidden=!metadata.returnTo;
72
+ });
73
+ ask.onclick=()=>{
74
+ const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
75
+ const text='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.';
76
+ location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
77
+ };
41
78
  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
79
  reload.onclick=()=>{if(metadata){status.hidden=false;status.textContent='Reloading app…';frame.src=metadata.origin+'/';}};
43
80
  (async()=>{
@@ -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);
@@ -73,12 +75,14 @@ function snapshot(workspace, directory) {
73
75
  return { kind: "static", files, bytes };
74
76
  }
75
77
  /** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
76
- export class AppRegistry {
78
+ export class AppRegistry extends EventEmitter {
77
79
  reservedPorts;
78
80
  apps = new Map();
79
81
  views = new Map();
80
82
  constructor(reservedPorts = []) {
83
+ super();
81
84
  this.reservedPorts = reservedPorts;
85
+ this.setMaxListeners(0);
82
86
  }
83
87
  publish(sessionId, workspace, input) {
84
88
  const name = (value) => {
@@ -97,6 +101,7 @@ export class AppRegistry {
97
101
  const base = { id: randomBytes(16).toString("hex"), name: name(spec?.name) };
98
102
  let target;
99
103
  let view;
104
+ let source;
100
105
  if (spec.kind === "web" && spec.source?.kind === "service") {
101
106
  const port = spec.source.port;
102
107
  if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
@@ -108,8 +113,8 @@ export class AppRegistry {
108
113
  if (typeof spec.source.directory !== "string" || !spec.source.directory)
109
114
  throw new Error("Static directory is required.");
110
115
  target = snapshot(workspace, spec.source.directory);
111
- if (target.kind === "static")
112
- total += target.bytes;
116
+ source = { workspace, directory: spec.source.directory };
117
+ total += target.bytes;
113
118
  if (total > MAX_TOTAL_BYTES)
114
119
  throw new Error("Static apps exceed the node's 100 MiB snapshot budget.");
115
120
  view = { ...base, kind: "web", source: "static" };
@@ -126,7 +131,7 @@ export class AppRegistry {
126
131
  else
127
132
  throw new Error("Unsupported app view. This machine supports web and terminal views.");
128
133
  app.views.push(view);
129
- entries.push({ app, view, target });
134
+ entries.push({ app, view, target, revision: 0, source });
130
135
  }
131
136
  // Commit all views together: a bad later view cannot leave a partial app.
132
137
  this.apps.set(app.id, app);
@@ -138,6 +143,44 @@ export class AppRegistry {
138
143
  return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
139
144
  }
140
145
  getView(id) { return this.views.get(id); }
146
+ /** An agent turn changed files: re-take changed static snapshots and bump
147
+ * every affected web view (emits `revision`). Returns the bumped view IDs. */
148
+ touch(sessionId) {
149
+ const bumped = [];
150
+ let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
151
+ for (const entry of this.views.values()) {
152
+ if (entry.app.sessionId !== sessionId || entry.view.kind !== "web")
153
+ continue;
154
+ if (entry.target.kind === "static") {
155
+ if (!entry.source)
156
+ continue;
157
+ let next;
158
+ // A broken build keeps the last good snapshot rather than blanking the preview.
159
+ try {
160
+ next = snapshot(entry.source.workspace, entry.source.directory);
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ if (sameFiles(entry.target.files, next.files) || total - entry.target.bytes + next.bytes > MAX_TOTAL_BYTES)
166
+ continue;
167
+ total += next.bytes - entry.target.bytes;
168
+ entry.target = next;
169
+ }
170
+ entry.revision++;
171
+ bumped.push(entry.view.id);
172
+ this.emit("revision", entry.view.id);
173
+ }
174
+ return bumped;
175
+ }
176
+ /** Ports a session already previews, and ports no app may claim. */
177
+ claimedPorts(sessionId) {
178
+ const ports = new Set(this.reservedPorts);
179
+ for (const { app, target } of this.views.values())
180
+ if (app.sessionId === sessionId && target.kind === "service")
181
+ ports.add(target.port);
182
+ return ports;
183
+ }
141
184
  require(sessionId, id) {
142
185
  const app = this.apps.get(id);
143
186
  if (!app || app.sessionId !== sessionId)
@@ -1,17 +1,34 @@
1
+ import { scanListeners } from "./listeners.js";
1
2
  /** Composes view providers; registry never spawns processes and the gateway
2
3
  * never knows about terminals. New providers can extend open/remove here. */
3
4
  export class AppService {
4
5
  registry;
5
6
  gateway;
6
7
  terminals;
8
+ scan;
7
9
  terminalStarts = new Map();
8
- constructor(registry, gateway, terminals) {
10
+ constructor(registry, gateway, terminals, scan = scanListeners) {
9
11
  this.registry = registry;
10
12
  this.gateway = gateway;
11
13
  this.terminals = terminals;
14
+ this.scan = scan;
12
15
  }
13
16
  list(sessionId) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
14
17
  publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
18
+ /** An agent turn changed files in this session's workspace. */
19
+ turnChanged(sessionId) { return this.registry.touch(sessionId); }
20
+ /** Servers running in the workspace that this session doesn't preview yet. */
21
+ async offers(sessionId, workspace) {
22
+ const claimed = this.registry.claimedPorts(sessionId);
23
+ return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
24
+ }
25
+ /** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
26
+ async adopt(sessionId, workspace, port) {
27
+ const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
28
+ if (!offer)
29
+ throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
30
+ return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
31
+ }
15
32
  async open(sessionId, appId, viewId, returnTo) {
16
33
  const entry = this.registry.requireView(sessionId, appId, viewId);
17
34
  if (entry.view.kind === "web") {
@@ -1,12 +1,22 @@
1
1
  export function createAppCommands(service, workspaceFor, published = () => { }) {
2
+ const workspaceOf = (sessionId) => {
3
+ const workspace = workspaceFor(sessionId);
4
+ if (!workspace)
5
+ throw new Error("Open the session on this machine before publishing an app.");
6
+ return workspace;
7
+ };
2
8
  const operations = {
3
9
  "apps.list": (msg) => service.list(String(msg.sessionId)),
4
10
  "apps.publish": (msg) => {
5
11
  const sessionId = String(msg.sessionId);
6
- const workspace = workspaceFor(sessionId);
7
- if (!workspace)
8
- throw new Error("Open the session on this machine before publishing an app.");
9
- const app = service.publish(sessionId, workspace, msg.manifest);
12
+ const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
13
+ published(app);
14
+ return { app };
15
+ },
16
+ "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
17
+ "apps.adopt": async (msg) => {
18
+ const sessionId = String(msg.sessionId);
19
+ const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
10
20
  published(app);
11
21
  return { app };
12
22
  },
@@ -18,7 +28,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
18
28
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
19
29
  try {
20
30
  ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
21
- if (kind === "apps.publish" || kind === "apps.remove")
31
+ if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
22
32
  ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
23
33
  }
24
34
  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", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
8
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
9
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -10,6 +10,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
10
10
  ping: Type.Object(request),
11
11
  "apps.list": Type.Object({ ...request, ...session }),
12
12
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
+ "apps.offers": Type.Object({ ...request, ...session }),
14
+ "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
13
15
  "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })) }),
14
16
  "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
15
17
  "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
package/dist/server.js CHANGED
@@ -963,6 +963,8 @@ async function harnessEndTurn(record) {
963
963
  record.workspaceState = (await harness.isDirty(record.id)) ? "dirty" : "clean";
964
964
  if (!result || result.changes.length === 0)
965
965
  return;
966
+ // Open previews of this session reload; static snapshots are re-taken.
967
+ appService.turnChanged(record.id);
966
968
  broadcast({
967
969
  type: "session.changes",
968
970
  sessionId: record.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.2",
3
+ "version": "0.17.3-staging.3",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",