@bivy/bivy 0.18.3 → 0.18.4-staging.10

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/README.md CHANGED
@@ -4,17 +4,22 @@
4
4
  [![license: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-2b6cb0)](LICENSE)
5
5
  [![node](https://img.shields.io/badge/node-%E2%89%A520-2b6cb0)](https://nodejs.org)
6
6
 
7
- **Run coding agents on your machines and use them from anywhere — from a phone,
8
- browser, terminal, GitHub issue, Slack message, schedule, or webhook.**
7
+ **Build, try, and review apps from your phone. Your coding agent and dev
8
+ environment stay on your own machines.**
9
9
 
10
- Bivy is an open-source workspace for coding-agent work. Turn prompts, GitHub
11
- issues, CI failures, Slack messages, and schedules into live sessions on your
12
- machines. Choose the agent and model, sync supported API keys and OAuth logins,
13
- and steer and review the work from your browser, phone, or terminal.
10
+ Bivy is an open-source remote development environment built around coding
11
+ agents. Ask for a change, try the running app, and point to or draw on what
12
+ needs fixing. Send that feedback to the agent and try again—all in the same
13
+ session, without going back to your computer.
14
+
15
+ Live previews bring your app to your phone or browser without deploying it to
16
+ Render, building a Docker image, or setting up a separate preview server.
17
+ Share a preview link so someone else can try it and leave notes, too.
14
18
 
15
19
  Keep Claude Code, Codex, Pi, OpenCode, or another supported agent. Keep your
16
- repos, tools, and development environment. Bivy connects them into a workflow
17
- that doesn't end when you leave your desk.
20
+ repos, tools, databases, and development environment. Bivy connects them to
21
+ chat, a terminal, app previews, and code review wherever you are. Start work
22
+ yourself, or let an issue, CI failure, message, schedule, or webhook start it.
18
23
 
19
24
  **[Start free on Bivy Cloud](https://app.bivy.sh)** ·
20
25
  **[Quickstart](docs/quickstart.md)** ·
@@ -47,11 +52,41 @@ entire remote-access stack.
47
52
  > sandboxing depend on the runtime. See the
48
53
  > [runtime support matrix](docs/runtime-support-matrix.md).
49
54
 
50
- ## More than remote access
51
-
52
- Remote access lets you reach an agent. Bivy also connects **what starts the
53
- work, where it runs, which agent and credentials it uses, and how you review
54
- what happened**.
55
+ ## Build → preview → mark up → iterate
56
+
57
+ Remote chat lets you ask an agent for a change. Bivy also lets you **use what
58
+ it built and show it what to fix**, without a separate deployment step.
59
+
60
+ 1. **Ask for a change.** The agent works in your repo with your existing tools
61
+ and local services.
62
+ 2. **Try the app.** Open a live preview from the session. Use the running app,
63
+ not just a screenshot of what the agent says it finished. Bivy can discover
64
+ dev servers running in the session workspace; tap **Preview** to open one.
65
+ 3. **Mark up what needs work.** Point at an element or circle and draw on the
66
+ preview. Add a note, then send the draft to the agent with the element and
67
+ page context. Marked-up screenshots and before/after comparison are available
68
+ when agent screenshots are enabled.
69
+ 4. **Iterate and review.** Try the next version, inspect the diff, review checks,
70
+ and decide when the work is ready—not just when the agent stops.
71
+ 5. **Bring someone else into the review.** Copy a public preview link so they
72
+ can use the app without a Bivy account and leave notes on specific elements.
73
+ Bring their notes into the session when you're ready to act on them.
74
+
75
+ On Bivy Cloud, preview delivery is built in: no per-app domains, certificates,
76
+ public ports, or tunnels to configure. Self-hosters configure preview delivery
77
+ once for their Bivy deployment, not for every app. The app still needs its normal
78
+ build or dev-server setup, and **the machine serving it must stay awake and
79
+ online**. These are development previews, not production hosting.
80
+
81
+ **Share deliberately:** anyone with a preview link can use that app, including
82
+ its live backend. Links expire after 24 hours and can be revoked sooner.
83
+ Reviewer notes aren't sent to the agent automatically; you send them or explicitly
84
+ allow agent access. Preview traffic uses HTTPS through the preview relay, not
85
+ session end-to-end encryption; the relay operator can see it.
86
+
87
+ [App previews, visual feedback, and sharing →](docs/apps.md)
88
+
89
+ ## Your agents and machines, one workspace
55
90
 
56
91
  | Capability | What it means for you |
57
92
  |---|---|
@@ -59,8 +94,10 @@ what happened**.
59
94
  | **Your machines and environment** | Work beside your existing repos, dev servers, databases, private networks, toolchains, and GPUs. |
60
95
  | **Automations and triggers** | Let issues, failed CI, messages, schedules, and webhooks start work instead of copying requests into a chat. |
61
96
  | **Encrypted key and OAuth sync** | Reuse Bivy-managed provider credentials across enrolled machines and compatible runtimes, with less repeated setup. |
62
- | **Live sessions from anywhere** | Start at your desk, answer a question or approve an action from your phone, then return to the terminal. |
63
- | **Reviewable results** | See changes, declared checks, artifacts, and pull requests—not just an agent's claim that it finished. |
97
+ | **Live sessions from anywhere** | Build and review from your phone, browser, or terminal; answer questions and approve supported actions in the same session. |
98
+ | **Live app previews** | Try the running app without deploying it to a separate hosting service. |
99
+ | **Visual feedback and sharing** | Point, draw, and send specific feedback to the agent; share a preview link for outside review. |
100
+ | **Reviewable results** | Inspect the app, changes, declared checks, artifacts, and pull requests—not just an agent's claim that it finished. |
64
101
  | **Hosted convenience or self-hosting** | Use Bivy Cloud for managed remote access, or run the same open-source core yourself. |
65
102
 
66
103
  ## One workflow, from trigger to review
@@ -77,7 +114,13 @@ Prompt · GitHub issue · CI failure · Linear · Slack · Schedule · Webhook
77
114
  Join · steer · approve · stop
78
115
  │
79
116
  ▼
80
- Changes · checks · artifacts · PR
117
+ Try app · inspect changes · checks
118
+ │
119
+ ▼
120
+ Mark up · send feedback · iterate
121
+ │
122
+ ▼
123
+ Share preview · review PR
81
124
  ```
82
125
 
83
126
  A **Machine** is a computer or server you connect. A **Session** is live agent
@@ -206,10 +249,9 @@ answer questions, approve supported tool calls, or stop the agent.
206
249
 
207
250
  - Send screenshots, images, logs, and other files from your phone.
208
251
  - Download reports and artifacts the agent creates.
209
- - Publish [session apps](docs/apps.md) with web and CLI/TUI views using
210
- `bivy app publish bivy.app.json`. Linked machines automatically discover web
211
- preview delivery through Bivy's relay—no per-machine domains, certificates or
212
- public ports. Terminal views use the existing connection.
252
+ - Open [session apps](docs/apps.md) from chat or the **Apps** menu: live web
253
+ previews, desktop app views, and CLI/TUI tools. Agents can publish views with
254
+ `bivy app publish bivy.app.json` and create share links with `bivy app share`.
213
255
  - Use voice input and read-aloud where supported; provider-backed voice may
214
256
  send audio or text to the selected provider.
215
257
  - Keep a native terminal workflow or use structured chat, depending on the agent.
@@ -259,8 +301,13 @@ bivy run claude
259
301
  bivy open
260
302
  ```
261
303
 
262
- Open that same session on your phone while it runs. Once that works, connect
263
- another machine or add your first automation.
304
+ Open that same session on your phone while it runs. For a web app, ask the agent
305
+ to start its dev server, then open **Apps → Running in this workspace → Preview**.
306
+ Try a page, point to something you'd change, and send the feedback back to the
307
+ agent. No separate preview deployment needed.
308
+
309
+ Once that works, share a preview for review, connect another machine, or add
310
+ your first automation.
264
311
 
265
312
  **Local-only works too.** `bivy run`, `bivy resume`, and `bivy sessions` need no
266
313
  account or server. Choose **local only for now** during setup; use `bivy login`
@@ -327,9 +374,14 @@ Your machine Hosted or self-hosted
327
374
 
328
375
  - **Execution stays on your machine.** Bivy Cloud does not run your agents.
329
376
  Your model provider still sees whatever the agent sends it.
330
- - **Interactive traffic is end-to-end encrypted** between the node and paired
331
- devices. The relay forwards opaque frames; your node dials out, so no inbound
332
- public port is required.
377
+ - **Interactive session traffic is end-to-end encrypted** between the node and
378
+ paired devices. The relay forwards opaque session frames; your node dials out,
379
+ so no inbound public port is required.
380
+ - **App previews have a separate security boundary.** Preview traffic uses HTTPS
381
+ and an outbound tunnel, not session E2E encryption. The preview ingress/relay
382
+ operator can see it. Public preview links grant anyone holding them access to
383
+ that view and its live backend until expiry or revocation. See
384
+ [preview security and sharing](docs/apps.md#runtime-and-security-boundaries).
333
385
  - **Ordinary credential sync uploads ciphertext, not plaintext keys.**
334
386
  Supported credentials and recovery limits are documented separately.
335
387
  - **Encryption is not universal across integrations.** Slack commands and
@@ -528,7 +528,12 @@ async function onBivyCommand(msg) {
528
528
  bivy({ replyTo: id, ok: true });
529
529
  asRequest("turn/start", {
530
530
  threadId,
531
- input: [{ type: "text", text: String(msg.text ?? "") }],
531
+ input: [
532
+ { type: "text", text: String(msg.text ?? "") },
533
+ ...(msg.images ?? []).map((image) => ({
534
+ type: "image", url: `data:${image.mimeType || "image/png"};base64,${image.data}`,
535
+ })),
536
+ ],
532
537
  ...(selectedModel ? { model: selectedModel } : {}),
533
538
  })
534
539
  .catch((error) => bivy({ type: "session.error", error: error instanceof Error ? error.message : String(error) }));
@@ -12,6 +12,7 @@ import { previewShell } from "./preview-shell.js";
12
12
  import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
13
13
  import { pressMenu, readMenuPath, readMenus } from "./menu.js";
14
14
  import { inspectorScript } from "./inspector.js";
15
+ import { readStrokes } from "./annotate.js";
15
16
  const MIME = {
16
17
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
17
18
  ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
@@ -199,6 +200,7 @@ export class AppGateway {
199
200
  registry;
200
201
  returnOrigins;
201
202
  signIn;
203
+ captureNote;
202
204
  server;
203
205
  template;
204
206
  tickets = new Map();
@@ -210,13 +212,16 @@ export class AppGateway {
210
212
  * `embedded`: it runs framed inside a Bivy client (a third-party frame). */
211
213
  sessions = new Map();
212
214
  sockets = new Map();
215
+ captureAfter = 0;
216
+ capturing = false;
213
217
  displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
214
218
  /** `signIn` sends a signed-out visit to a view's stable address back
215
219
  * through Bivy, which re-opens it for a signed-in device. */
216
- constructor(registry, originTemplate, returnOrigins = () => [], signIn) {
220
+ constructor(registry, originTemplate, returnOrigins = () => [], signIn, captureNote) {
217
221
  this.registry = registry;
218
222
  this.returnOrigins = returnOrigins;
219
223
  this.signIn = signIn;
224
+ this.captureNote = captureNote;
220
225
  this.template = previewOriginTemplate(originTemplate);
221
226
  this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
222
227
  res.writeHead(502); res.end("Preview request failed."); }); });
@@ -253,12 +258,12 @@ export class AppGateway {
253
258
  this.requireWeb(id);
254
259
  return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
255
260
  }
256
- /** A reusable link straight to the app origin (no shell frame), so it opens
257
- * in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
261
+ /** Public reviewers use the same isolated controls as the owner, but can
262
+ * only submit notes. Valid until revoked or SHARE_TTL. */
258
263
  share(id) {
259
264
  this.requireWeb(id);
260
265
  const expiresAt = Date.now() + SHARE_TTL;
261
- return { url: `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
266
+ return { url: `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
262
267
  }
263
268
  revoke(id) {
264
269
  for (const map of [this.tickets, this.sessions])
@@ -366,7 +371,7 @@ export class AppGateway {
366
371
  return;
367
372
  }
368
373
  res.setHeader("Content-Type", "application/json");
369
- res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
374
+ res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
370
375
  return;
371
376
  }
372
377
  res.writeHead(404);
@@ -731,7 +736,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
731
736
  let body = "";
732
737
  for await (const chunk of req) {
733
738
  body += chunk.toString();
734
- if (body.length > 4096) {
739
+ if (body.length > 256_000) {
735
740
  res.writeHead(413);
736
741
  res.end();
737
742
  return;
@@ -747,6 +752,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
747
752
  return;
748
753
  }
749
754
  const text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
755
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
756
+ res.writeHead(400);
757
+ res.end();
758
+ return;
759
+ }
760
+ if (input.mark === undefined && input.context === undefined && body.length > 4096) {
761
+ res.writeHead(413);
762
+ res.end();
763
+ return;
764
+ }
750
765
  const note = text(input.note, 1000);
751
766
  if (!note) {
752
767
  res.writeHead(400);
@@ -755,12 +770,71 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
755
770
  }
756
771
  const viewport = input.viewport;
757
772
  const path = text(input.path, 2048);
773
+ let shot;
774
+ if (input.mark !== undefined) {
775
+ // Public input cannot choose a session, app, Compare image, or capture
776
+ // dimensions beyond a modest viewport. No agent commands are exposed.
777
+ if (!this.session(req, entry.view.id)?.reviewer) {
778
+ res.writeHead(403);
779
+ res.end();
780
+ return;
781
+ }
782
+ let mark;
783
+ try {
784
+ const raw = input.mark;
785
+ const strokes = readStrokes(raw?.strokes);
786
+ const v = raw.viewport;
787
+ if (!Number.isFinite(v?.width) || !Number.isFinite(v?.height) || v.width < 40 || v.width > 4000 || v.height < 40 || v.height > 4000 || v.width * v.height > 4_000_000)
788
+ throw Error();
789
+ const scroll = raw.scroll;
790
+ if (scroll && (![scroll.x, scroll.y].every(n => Number.isFinite(n) && n >= 0 && n <= 100_000)))
791
+ throw Error();
792
+ if (strokes.some(s => s.points.some(([x, y]) => x < 0 || y < 0 || x > 104_000 || y > 104_000)))
793
+ throw Error();
794
+ // Bound raster work as well as JSON size (long off-screen paths can
795
+ // otherwise consume CPU even though they paint no visible pixels).
796
+ const distance = strokes.reduce((total, s) => total + s.points.reduce((length, p, i) => i ? length + Math.hypot(p[0] - s.points[i - 1][0], p[1] - s.points[i - 1][1]) : length, 0), 0);
797
+ if (distance > 50_000)
798
+ throw Error();
799
+ mark = { appId: entry.app.id, viewId: entry.view.id, strokes, viewport: v, path: text(raw.path, 2048), scroll, dpr: 1, theme: raw.theme === "dark" ? "dark" : "light", signals: { unknown: true } };
800
+ }
801
+ catch {
802
+ res.writeHead(400);
803
+ res.end("Invalid or oversized annotation. Try fewer marks or a narrower preview.");
804
+ return;
805
+ }
806
+ if (this.capturing || Date.now() < this.captureAfter) {
807
+ res.writeHead(429);
808
+ res.end("Please wait a moment before sending another picture.");
809
+ return;
810
+ }
811
+ this.capturing = true;
812
+ this.captureAfter = Date.now() + 10_000;
813
+ try {
814
+ shot = await this.captureNote?.(entry, mark);
815
+ }
816
+ catch {
817
+ res.writeHead(502);
818
+ res.end("Could not take the picture. Your note has not been sent; please retry.");
819
+ return;
820
+ }
821
+ finally {
822
+ this.capturing = false;
823
+ }
824
+ if (!this.session(req, entry.view.id) || this.registry.getView(entry.view.id) !== entry) {
825
+ res.writeHead(401);
826
+ res.end();
827
+ return;
828
+ }
829
+ }
758
830
  this.registry.addNote(entry.view.id, {
831
+ ...(shot ? { shot } : {}),
832
+ ...(input.context ? { context: text(input.context, 8000) } : {}),
759
833
  id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
760
834
  path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
761
835
  });
762
- res.writeHead(204);
763
- res.end();
836
+ res.writeHead(200, { "content-type": "application/json" });
837
+ res.end(JSON.stringify({ screenshot: Boolean(shot) }));
764
838
  }
765
839
  /** Compare shots for the shell: the list, or one PNG by index. */
766
840
  compare(req, res, entry) {
@@ -51,7 +51,7 @@ const report=(el,hold)=>{
51
51
  const r=el.getBoundingClientRect();
52
52
  onPicked({type:'picked',selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,200),
53
53
  rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
54
- viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash,hold});
54
+ viewport:{width:innerWidth,height:innerHeight},scroll:{x:Math.round(scrollX),y:Math.round(scrollY)},theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,hold});
55
55
  };
56
56
  // A long press picks at once and says so (the shell starts listening); the
57
57
  // layer stays until the finger lifts, so the release never reaches the app.
@@ -123,7 +123,12 @@ const marked=r=>{
123
123
  addEventListener('message',e=>{
124
124
  if(!framed||e.origin!==SHELL||e.source!==parent)return;
125
125
  const d=e.data||{},n=v=>Number.isFinite(+v)?+v:0;
126
- if(d.type==='bivy:draw')post(drawState());
126
+ if(d.type==='bivy:note'&&REVIEWER){
127
+ void fetch('/__bivy/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(d.note)})
128
+ .then(async r=>{if(!r.ok)throw Error(r.status===429?'Please wait a moment before sending another picture.':r.status===400||r.status===502?(await r.text()).slice(0,200):'Could not send. Check that the link is still valid and try again.');return r.json();})
129
+ .then(result=>post({type:'note-sent',id:d.id,screenshot:result.screenshot===true}),error=>post({type:'note-sent',id:d.id,error:error.message}));
130
+ }
131
+ else if(d.type==='bivy:draw')post(drawState());
127
132
  else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
128
133
  else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
129
134
  });
@@ -22,7 +22,7 @@ iframe { width:100%; height:100%; border:0; background:var(--bg); }
22
22
  #dock > * { pointer-events:auto; }
23
23
  /* The hint sits over the app; a tap on it is meant for the app underneath. */
24
24
  #dock > #pointing { pointer-events:none; }
25
- nav, #draw-bar { 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); }
25
+ nav, #draw-bar { display:flex; flex-wrap:wrap; justify-content:center; 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-xl); box-shadow:var(--shadow-lg); }
26
26
  nav .btn, #draw-bar .btn { flex-shrink:0; border-radius:var(--radius-full); }
27
27
  nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
28
28
  #name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
@@ -46,26 +46,34 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
46
46
  #draft-row { display:flex; align-items:flex-start; gap:var(--space-2); }
47
47
  #draft-row #draft-text { flex:1; min-width:0; }
48
48
  /* Hold to talk: a phone-sized target that never selects text or opens a callout. */
49
- #mic { flex:none; inline-size:44px; block-size:44px; padding:0; border-radius:var(--radius-full); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
49
+ #mic { flex:none; inline-size:var(--space-7); block-size:var(--space-7); padding:0; border-radius:var(--radius-full); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
50
50
  #mic[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
51
- #voice-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
- #voice-status:empty { display:none; }
53
- /* The panel scrolls on a phone; its actions stay in reach at the bottom. */
54
- #draft .panel-actions { position:sticky; bottom:calc(-1 * var(--space-3)); margin:var(--space-2) calc(-1 * var(--space-3)) calc(-1 * var(--space-3)); padding:var(--space-2) var(--space-3) var(--space-3); background:var(--surface); border-top:thin solid var(--line); }
51
+ #voice-status, #note-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
+ #voice-status:empty, #note-status:empty { display:none; }
53
+ /* The note owns the dock while editing. Only its body scrolls; actions never
54
+ cover the text field, even when the keyboard leaves little vertical room. */
55
+ #dock { max-height:calc(100% - var(--space-4)); }
56
+ #dock:has(#draft:not([hidden])) nav, #dock:has(#draft:not([hidden])) #pointing { display:none; }
57
+ #draft { display:flex; flex-direction:column; max-height:70dvh; overflow:hidden; padding:0; }
58
+ #draft-body { min-height:0; overflow:auto; padding:var(--space-3); }
59
+ #draft .panel-actions { flex-shrink:0; margin:0; padding:var(--space-2) var(--space-3); border-top:thin solid var(--line); }
60
+ #draft details { margin-top:var(--space-2); }
61
+ #draft summary { color:var(--muted); font-size:var(--text-sm); cursor:pointer; }
55
62
  #draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
56
63
  #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); }
57
64
  #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); }
58
65
  /* Draw: marks over the frozen app (or Compare's "after" shot). The layer takes
59
66
  every touch, so nothing reaches the app; two fingers scroll the page. */
60
67
  #ink { position:fixed; z-index:var(--z-sticky); touch-action:none; cursor:crosshair; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
68
+ /* An HTML hit surface owns gestures even where the SVG has no painted marks. */
69
+ #ink svg { display:block; width:100%; height:100%; pointer-events:none; }
61
70
  #ink.frozen { pointer-events:none; cursor:default; }
62
71
  #ink .halo { fill:none; stroke:var(--annotate-halo); stroke-width:7; stroke-linecap:round; stroke-linejoin:round; }
63
72
  #ink .mark { fill:none; stroke:var(--annotate); stroke-width:4; stroke-linecap:round; stroke-linejoin:round; }
64
73
  #dock > #drawing { pointer-events:none; }
65
74
  /* Hints float over the app, whose page may be any colour: back them solidly. */
66
75
  #dock > .banner.inline[data-tone="accent"] { background:color-mix(in srgb, var(--accent) 14%, var(--surface)); box-shadow:var(--shadow-sm); }
67
- #draw-bar .btn { min-block-size:44px; }
68
- #draw-bar .seg-btn { min-block-size:36px; min-inline-size:48px; }
76
+ #dock .btn, #dock .seg-btn, #show, #down .btn { min-block-size:var(--space-7); min-inline-size:var(--space-7); font-size:var(--text-sm); }
69
77
  #draw-bar .segmented { flex-shrink:0; }
70
78
  #compare-draw { margin-top:var(--space-2); }
71
79
  /* Marking a Compare shot: make it big enough to draw on with a thumb. */
@@ -78,7 +86,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
78
86
  <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>
79
87
  <p id="status" role="status">Opening app…</p>
80
88
  <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>
81
- <svg id="ink" hidden aria-hidden="true"></svg>
89
+ <div id="ink" hidden aria-hidden="true"><svg id="ink-marks"></svg></div>
82
90
  <div id="dock">
83
91
  <section class="panel" id="console" hidden aria-labelledby="console-title">
84
92
  <h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
@@ -93,12 +101,15 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
93
101
  <button class="btn sm" id="compare-draw" type="button" hidden>Draw on “now”</button>
94
102
  </section>
95
103
  <section class="panel" id="draft" hidden aria-labelledby="draft-title">
96
- <h2 id="draft-title">Draft for the agent</h2>
104
+ <div id="draft-body">
105
+ <h2 id="draft-title">Note for the agent</h2>
97
106
  <div id="draft-row"><textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
98
107
  <button class="btn" id="mic" type="button" hidden aria-pressed="false" aria-label="Speak" title="Hold to speak, or tap to start and stop"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5a3 3 0 003-3V6a3 3 0 10-6 0v6.5a3 3 0 003 3z"/><path d="M6 11.5v1a6 6 0 0012 0v-1M12 18.5V21M9 21h6"/></svg></button></div>
99
108
  <p class="muted" id="voice-status" role="status"></p>
100
- <p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
101
- <pre id="draft-context"></pre>
109
+ <p class="muted" id="draft-hint">Review in chat before sending.</p>
110
+ <p class="muted" id="note-status" role="status"></p>
111
+ <details id="draft-details"><summary>Preview context</summary><pre id="draft-context"></pre></details>
112
+ </div>
102
113
  <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>
103
114
  </section>
104
115
  <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. <span id="pointing-voice" hidden>Hold to point and speak. </span>Press Escape or Point again to stop.</p>
@@ -136,9 +147,16 @@ const embedded=parent!==window;
136
147
  const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),location.ancestorOrigins?.[0]||new URL(metadata.returnTo).origin);
137
148
  const storageKey='bivy-preview';
138
149
  const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
139
- const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
150
+ const reviewerTools=ready=>{if(metadata?.reviewer)for(const id of ['point','draw','errors'])$(id).disabled=!ready;};
151
+ const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();reviewerTools(false);frame.src=metadata.origin+safePath(path);};
140
152
  function show(data,launch){
141
153
  metadata=data;
154
+ if(data.reviewer){
155
+ drawOk=data.inspect!==false;$('draw').hidden=!drawOk;back.hidden=true;
156
+ $('draft-title').textContent='Note for the app’s owner';
157
+ $('draft-text').maxLength=1000;
158
+ $('send-errors').textContent='Leave a note…';
159
+ }
142
160
  $('title').textContent=data.name;
143
161
  $('title').title=data.name;
144
162
  document.title='Bivy · '+data.name;
@@ -151,8 +169,9 @@ function show(data,launch){
151
169
  for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
152
170
  if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
153
171
  frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
154
- back.hidden=embedded&&Boolean(data.returnTo);
172
+ back.hidden=Boolean(data.reviewer)||(embedded&&Boolean(data.returnTo));
155
173
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
174
+ reviewerTools(false);
156
175
  status.textContent='Loading app…';
157
176
  frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
158
177
  // A Bivy client framing the shell says whether it can take dictation.
@@ -179,17 +198,23 @@ async function watch(){
179
198
  revision=d.revision;retry=0;setTimeout(watch,0);
180
199
  }catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
181
200
  }
182
- // Drafts go to the session's composer; nothing is ever sent from here.
201
+ // Owner drafts go to the session's composer. Reviewer drafts use only the
202
+ // bounded notes endpoint, never the owner's command channel.
183
203
  function toChat(text){
184
204
  if(embedded)return toBivy({type:'draft',text});
185
205
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
186
206
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
187
207
  }
208
+ let noteMark=null,pendingNote=null,noteSequence=0;
188
209
  function draft(context,listen){
189
- panels(null);
210
+ panels(null);noteMark=null;
211
+ $('draft-add').disabled=false;
212
+ $('note-status').textContent='';
213
+ $('draft-hint').textContent=metadata.reviewer?'Feedback only — no agent runs. Pictures are approximate; if screenshots are off, we save text and mark details.':'Review in chat before sending.';
190
214
  $('draft').hidden=false;
191
- $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
215
+ $('draft-add').textContent=metadata.reviewer?'Send note':metadata.returnTo?'Add to chat':'Copy';
192
216
  $('draft-context').textContent=context;
217
+ $('draft-details').open=false;
193
218
  const box=$('draft-text');box.value='';
194
219
  // Speaking right away: don't raise the keyboard over the draft.
195
220
  if(listen)startListening();else box.focus();
@@ -226,15 +251,37 @@ function fromBivy(d){
226
251
  else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
227
252
  else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
228
253
  }
229
- $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;endDraw();};
254
+ $('draft-cancel').onclick=()=>{const target=marks?$('draw'):point;cancelListening();$('draft').hidden=true;endDraw();target.focus();};
230
255
  $('draft-add').onclick=async()=>{
231
256
  cancelListening();
232
- const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
257
+ const note=$('draft-text').value.trim();
258
+ if(metadata.reviewer){
259
+ if(!note){$('note-status').textContent='Write a note before sending.';$('draft-text').focus();return;}
260
+ const mark=marks||noteMark,id=++noteSequence;
261
+ $('draft-add').disabled=true;$('draft-cancel').disabled=true;$('draft-text').disabled=true;
262
+ $('note-status').textContent='Sending note…';$('draft-add').textContent='Sending…';
263
+ const timer=setTimeout(()=>finishNote({id,error:'No reply yet. Your note may have arrived; check the connection before retrying.'}),60000);
264
+ pendingNote={id,timer};
265
+ frame.contentWindow?.postMessage({type:'bivy:note',id,note:{note,context:$('draft-context').textContent,selector:mark?.selector||'',text:mark?.text||'',path:mark?.path||currentPath,viewport:mark?.viewport||{width:frame.clientWidth,height:frame.clientHeight},...(mark?{mark}:{})}},metadata.origin);
266
+ return;
267
+ }
268
+ const text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
233
269
  // Marks go to the Bivy client, which adds a picture made on the machine.
234
- if(marks){const mark=marks;endDraw();return toBivy({type:'annotation',text,mark});}
235
- if(metadata.returnTo)return toChat(text);
270
+ if(marks){const mark=marks;$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
271
+ if(metadata.returnTo){$('draft').hidden=true;return toChat(text);}
236
272
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
237
273
  };
274
+ $('draft-text').addEventListener('input',()=>{if(!pendingNote)$('note-status').textContent='';});
275
+ function finishNote(d){
276
+ if(!pendingNote||d.id!==pendingNote.id)return;
277
+ clearTimeout(pendingNote.timer);pendingNote=null;
278
+ $('draft-cancel').disabled=false;$('draft-add').disabled=false;$('draft-text').disabled=false;$('draft-add').textContent='Send note';
279
+ if(d.error){$('note-status').textContent=String(d.error).slice(0,200);return;}
280
+ $('draft').hidden=true;endDraw();noteMark=null;
281
+ $('stamp').textContent=d.screenshot?'Note and approximate picture sent':'Note sent without a picture';
282
+ status.hidden=false;status.textContent=$('stamp').textContent+'. The app’s owner will see it.';
283
+ point.focus();
284
+ }
238
285
  // Console
239
286
  let entries=[];
240
287
  function resetConsole(){entries=[];renderConsole();}
@@ -263,14 +310,16 @@ function picked(d){
263
310
  draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
264
311
  +'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
265
312
  +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
313
+ if(metadata.reviewer){const s=pos(d.scroll);noteMark={path:safePath(d.path),viewport:v,scroll:s,theme:d.theme,selector:String(d.selector).slice(0,300),text:String(d.text||'').slice(0,200),strokes:[{tool:'box',points:[[r.x+s.x,r.y+s.y],[r.x+r.width+s.x,r.y+r.height+s.y]]}]};}
266
314
  }
267
315
  // Draw: freeze the app and mark what's wrong. Marks are kept in page
268
316
  // coordinates (so they stay on the content while two fingers scroll), and
269
317
  // the app reports what's under each one. On Done they go into the draft box
270
318
  // with that context; Add to chat hands them to the Bivy client, which gets a
271
- // picture from the machine. Nothing about them goes through this origin's
272
- // network. Compare's "after" shot can be marked the same way.
273
- const ink=$('ink'),SVG='http://www.w3.org/2000/svg';
319
+ // picture from the machine. Reviewers instead submit marks with their note;
320
+ // pictures stay on the machine for the owner. Compare's "after" shot can be
321
+ // marked the same way by the owner.
322
+ const ink=$('ink'),inkMarks=$('ink-marks'),SVG='http://www.w3.org/2000/svg';
274
323
  let draw=null,marks=null,drawOk=false,nextStroke=0;
275
324
  const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
276
325
  function startDraw(target){
@@ -292,6 +341,7 @@ function startDraw(target){
292
341
  current.waiting=d=>{current.waiting=null;current.scroll=pos(d.scroll);current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
293
342
  frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
294
343
  }
344
+ for(const b of $('draw-bar').querySelectorAll('[data-tool]'))b.setAttribute('aria-checked',String(b.dataset.tool===draw.tool));
295
345
  $('draw-bar').querySelector('[aria-checked="true"]').focus();
296
346
  updateDrawBar();renderInk();
297
347
  }
@@ -299,7 +349,7 @@ function startDraw(target){
299
349
  function placeInk(target){
300
350
  const r=(target==='compare'?$('compare-after'):frame).getBoundingClientRect();
301
351
  Object.assign(ink.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
302
- ink.setAttribute('viewBox','0 0 '+r.width+' '+r.height);
352
+ inkMarks.setAttribute('viewBox','0 0 '+r.width+' '+r.height);
303
353
  return {left:r.left,top:r.top,width:r.width,height:r.height};
304
354
  }
305
355
  // A rotation or a keyboard moves things: keep the layer on them. (Marks on
@@ -308,7 +358,7 @@ addEventListener('resize',()=>{if(draw&&!draw.done)draw.rect=Object.assign(place
308
358
  function endDraw(){
309
359
  if(!draw)return;
310
360
  delete $('compare').dataset.drawing;
311
- draw=null;marks=null;ink.toggleAttribute('hidden',true);ink.replaceChildren();
361
+ draw=null;marks=null;ink.toggleAttribute('hidden',true);inkMarks.replaceChildren();
312
362
  $('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
313
363
  }
314
364
  function updateDrawBar(){const has=Boolean(draw?.strokes.length);for(const id of ['draw-undo','draw-clear','draw-done'])$(id).disabled=!has;}
@@ -321,7 +371,7 @@ function shape(stroke){
321
371
  function renderInk(){
322
372
  if(!draw)return;
323
373
  const all=[...draw.strokes,...(draw.current?[draw.current]:[])];
324
- ink.replaceChildren(...all.flatMap(s=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',c);return el;})));
374
+ inkMarks.replaceChildren(...all.flatMap(s=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',c);return el;})));
325
375
  }
326
376
  function local(e){const x=e.clientX-draw.rect.left,y=e.clientY-draw.rect.top;return draw.target==='frame'?[Math.round(x+draw.scroll.x),Math.round(y+draw.scroll.y)]:[Math.round(x),Math.round(y)];}
327
377
  let scrollAsk=null;
@@ -389,6 +439,7 @@ $('draw-done').onclick=()=>{
389
439
  // Compare: screenshots around the agent's last change (agent screenshots on).
390
440
  const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
391
441
  async function loadCompare(){
442
+ if(metadata.reviewer)return;
392
443
  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{}
393
444
  }
394
445
  function panels(open){if(listening)cancelListening();if(draw&&!(open==='compare'&&draw.target==='compare'))endDraw();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
@@ -431,7 +482,8 @@ addEventListener('message',e=>{
431
482
  downText.textContent='Nothing is answering on port '+downPort+'.';
432
483
  ask.hidden=!metadata.returnTo;
433
484
  }else if(d.source==='bivy-inspector'){
434
- if(d.type==='route')currentPath=safePath(d.path);
485
+ if(d.type==='note-sent'&&metadata.reviewer)finishNote(d);
486
+ else if(d.type==='route'){currentPath=safePath(d.path);reviewerTools(true);}
435
487
  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();}
436
488
  else if(d.type==='picked')picked(d);
437
489
  else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
@@ -441,7 +493,7 @@ addEventListener('message',e=>{
441
493
  }
442
494
  });
443
495
  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.');
444
- addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
496
+ addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(!$('draft').hidden)$('draft-cancel').click();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
445
497
  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.';}};
446
498
  reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
447
499
  (async()=>{