@bivy/bivy 0.18.4-staging.9 → 0.18.5-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bivy.mjs CHANGED
@@ -194,6 +194,9 @@ function commandPath(extraPath = "") {
194
194
  path.join(os.homedir(), ".local", "bin"),
195
195
  extraPath,
196
196
  process.env.PATH || "",
197
+ // npm global executables normally live beside the Node binary (including
198
+ // nvm/fnm installs). Services need this even without shell initialization.
199
+ path.dirname(process.execPath),
197
200
  ].filter(Boolean);
198
201
  return [...new Set(parts.flatMap((part) => String(part).split(path.delimiter)).filter(Boolean))].join(path.delimiter);
199
202
  }
@@ -18,7 +18,7 @@ import { randomUUID } from "node:crypto";
18
18
  import { EventEmitter } from "node:events";
19
19
  import { withExactCapabilitySurface } from "../../runtime/types.js";
20
20
  import { mapToolCall, mapToolResult } from "../../runtime/tool-call-map.js";
21
- import { spawnSync } from "node:child_process";
21
+ import { resolveExecutable } from "../../executable.js";
22
22
  import fs from "node:fs";
23
23
  import { depCacheEnv } from "../../harness/dep-cache.js";
24
24
  import os from "node:os";
@@ -33,23 +33,9 @@ import { bivySessionEnv } from "../../runtime/session-env.js";
33
33
  /** Binary names a live Claude Code process could be running under (see
34
34
  * native-process-scan.ts's best-effort cwd match). */
35
35
  const CLAUDE_BIN_NAMES = ["claude"];
36
- const CLAUDE_CLI_PATH_CACHE = new Map();
37
36
  /** Resolve the operator-installed Claude Code CLI; never use the SDK's bundled fallback. */
38
37
  export function claudeCliPath() {
39
- const command = process.env.BIVY_CLAUDE_COMMAND?.trim() || "claude";
40
- const key = `${command}\0${process.env.PATH ?? ""}`;
41
- if (CLAUDE_CLI_PATH_CACHE.has(key))
42
- return CLAUDE_CLI_PATH_CACHE.get(key);
43
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
44
- shell: process.platform !== "win32",
45
- encoding: "utf8",
46
- });
47
- const resolved = result.status === 0 ? result.stdout.split(/\r?\n/)[0]?.trim() || undefined : undefined;
48
- CLAUDE_CLI_PATH_CACHE.set(key, resolved);
49
- return resolved;
50
- }
51
- export function invalidateClaudeCliProbe() {
52
- CLAUDE_CLI_PATH_CACHE.clear();
38
+ return resolveExecutable(process.env.BIVY_CLAUDE_COMMAND?.trim() || "claude") ?? undefined;
53
39
  }
54
40
  export function claudeCliAvailable() {
55
41
  return Boolean(claudeCliPath());
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
- import { spawn, spawnSync } from "node:child_process";
3
+ import { spawn } from "node:child_process";
4
+ import { resolveExecutable } from "../../executable.js";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { defineAgentIntegration } from "../definition.js";
@@ -10,25 +11,11 @@ import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, w
10
11
  import { ProtocolRuntime } from "../../runtime/protocol.js";
11
12
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
12
13
  export const CODEX_TESTED_VERSION = "0.156.1";
13
- const CODEX_AVAILABLE_CACHE = new Map();
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
16
16
  }
17
17
  export function codexCommandAvailable() {
18
- const command = codexCommand();
19
- const cached = CODEX_AVAILABLE_CACHE.get(command);
20
- if (cached !== undefined)
21
- return cached;
22
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
23
- shell: process.platform !== "win32",
24
- stdio: "ignore",
25
- });
26
- const available = result.status === 0;
27
- CODEX_AVAILABLE_CACHE.set(command, available);
28
- return available;
29
- }
30
- export function invalidateCodexCommandProbe() {
31
- CODEX_AVAILABLE_CACHE.clear();
18
+ return resolveExecutable(codexCommand()) !== null;
32
19
  }
33
20
  async function suggestCodexSessionName(firstPrompt, context) {
34
21
  const prompt = firstPrompt.trim();
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
- import { spawnSync } from "node:child_process";
3
+ import { resolveExecutable } from "../../executable.js";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { defineAgentIntegration } from "../definition.js";
@@ -36,22 +36,8 @@ export async function listNativePiSessions() {
36
36
  firstMessage: session.firstMessage,
37
37
  }));
38
38
  }
39
- const PI_COMMAND_CACHE = new Map();
40
39
  export function piCommandAvailable() {
41
- const command = piCommand();
42
- const cached = PI_COMMAND_CACHE.get(command);
43
- if (cached !== undefined)
44
- return cached;
45
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
46
- shell: process.platform !== "win32",
47
- stdio: "ignore",
48
- });
49
- const available = result.status === 0;
50
- PI_COMMAND_CACHE.set(command, available);
51
- return available;
52
- }
53
- export function invalidatePiCommandProbe() {
54
- PI_COMMAND_CACHE.clear();
40
+ return resolveExecutable(piCommand()) !== null;
55
41
  }
56
42
  export function piBridgeInstalled() {
57
43
  try {
@@ -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
  });
@@ -48,8 +48,8 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
48
48
  /* Hold to talk: a phone-sized target that never selects text or opens a callout. */
49
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; }
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
53
  /* The note owns the dock while editing. Only its body scrolls; actions never
54
54
  cover the text field, even when the keyboard leaves little vertical room. */
55
55
  #dock { max-height:calc(100% - var(--space-4)); }
@@ -107,6 +107,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
107
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>
108
108
  <p class="muted" id="voice-status" role="status"></p>
109
109
  <p class="muted" id="draft-hint">Review in chat before sending.</p>
110
+ <p class="muted" id="note-status" role="status"></p>
110
111
  <details id="draft-details"><summary>Preview context</summary><pre id="draft-context"></pre></details>
111
112
  </div>
112
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>
@@ -146,9 +147,16 @@ const embedded=parent!==window;
146
147
  const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),location.ancestorOrigins?.[0]||new URL(metadata.returnTo).origin);
147
148
  const storageKey='bivy-preview';
148
149
  const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
149
- 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);};
150
152
  function show(data,launch){
151
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
+ }
152
160
  $('title').textContent=data.name;
153
161
  $('title').title=data.name;
154
162
  document.title='Bivy · '+data.name;
@@ -161,8 +169,9 @@ function show(data,launch){
161
169
  for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
162
170
  if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
163
171
  frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
164
- back.hidden=embedded&&Boolean(data.returnTo);
172
+ back.hidden=Boolean(data.reviewer)||(embedded&&Boolean(data.returnTo));
165
173
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
174
+ reviewerTools(false);
166
175
  status.textContent='Loading app…';
167
176
  frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
168
177
  // A Bivy client framing the shell says whether it can take dictation.
@@ -189,16 +198,21 @@ async function watch(){
189
198
  revision=d.revision;retry=0;setTimeout(watch,0);
190
199
  }catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
191
200
  }
192
- // 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.
193
203
  function toChat(text){
194
204
  if(embedded)return toBivy({type:'draft',text});
195
205
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
196
206
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
197
207
  }
208
+ let noteMark=null,pendingNote=null,noteSequence=0;
198
209
  function draft(context,listen){
199
- 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.';
200
214
  $('draft').hidden=false;
201
- $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
215
+ $('draft-add').textContent=metadata.reviewer?'Send note':metadata.returnTo?'Add to chat':'Copy';
202
216
  $('draft-context').textContent=context;
203
217
  $('draft-details').open=false;
204
218
  const box=$('draft-text');box.value='';
@@ -240,12 +254,34 @@ function fromBivy(d){
240
254
  $('draft-cancel').onclick=()=>{const target=marks?$('draw'):point;cancelListening();$('draft').hidden=true;endDraw();target.focus();};
241
255
  $('draft-add').onclick=async()=>{
242
256
  cancelListening();
243
- 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;
244
269
  // Marks go to the Bivy client, which adds a picture made on the machine.
245
270
  if(marks){const mark=marks;$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
246
271
  if(metadata.returnTo){$('draft').hidden=true;return toChat(text);}
247
272
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
248
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
+ }
249
285
  // Console
250
286
  let entries=[];
251
287
  function resetConsole(){entries=[];renderConsole();}
@@ -274,13 +310,15 @@ function picked(d){
274
310
  draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
275
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
276
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]]}]};}
277
314
  }
278
315
  // Draw: freeze the app and mark what's wrong. Marks are kept in page
279
316
  // coordinates (so they stay on the content while two fingers scroll), and
280
317
  // the app reports what's under each one. On Done they go into the draft box
281
318
  // with that context; Add to chat hands them to the Bivy client, which gets a
282
- // picture from the machine. Nothing about them goes through this origin's
283
- // network. Compare's "after" shot can be marked the same way.
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.
284
322
  const ink=$('ink'),inkMarks=$('ink-marks'),SVG='http://www.w3.org/2000/svg';
285
323
  let draw=null,marks=null,drawOk=false,nextStroke=0;
286
324
  const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
@@ -401,6 +439,7 @@ $('draw-done').onclick=()=>{
401
439
  // Compare: screenshots around the agent's last change (agent screenshots on).
402
440
  const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
403
441
  async function loadCompare(){
442
+ if(metadata.reviewer)return;
404
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{}
405
444
  }
406
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;}
@@ -443,7 +482,8 @@ addEventListener('message',e=>{
443
482
  downText.textContent='Nothing is answering on port '+downPort+'.';
444
483
  ask.hidden=!metadata.returnTo;
445
484
  }else if(d.source==='bivy-inspector'){
446
- 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);}
447
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();}
448
488
  else if(d.type==='picked')picked(d);
449
489
  else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
@@ -15,7 +15,9 @@ const isNote = (value) => {
15
15
  const note = value;
16
16
  return !!note && typeof note.id === "string" && typeof note.at === "number" && typeof note.note === "string" && note.note.length <= 1000
17
17
  && typeof note.selector === "string" && typeof note.text === "string" && typeof note.path === "string"
18
- && typeof note.viewport?.width === "number" && typeof note.viewport?.height === "number";
18
+ && typeof note.viewport?.width === "number" && typeof note.viewport?.height === "number"
19
+ && (note.context === undefined || (typeof note.context === "string" && note.context.length <= 8000))
20
+ && (note.shot === undefined || (typeof note.shot?.hash === "string" && /^[a-f0-9]{64}$/.test(note.shot.hash) && Number.isFinite(note.shot.size) && Number.isFinite(note.shot.width) && Number.isFinite(note.shot.height)));
19
21
  };
20
22
  const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
21
23
  /** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
@@ -295,6 +297,10 @@ export class AppRegistry extends EventEmitter {
295
297
  this.save();
296
298
  }
297
299
  agentNotes(id) { return this.apps.get(id)?.agentNotes === true; }
300
+ /** Includes persisted apps not restored yet; unread pictures survive GC. */
301
+ notePictureHashes() {
302
+ return [...this.persisted.values()].flatMap(record => Object.values(record.notes ?? {}).flatMap(notes => Array.isArray(notes) ? notes.filter(isNote).flatMap(note => note.shot ? [note.shot.hash] : []) : []));
303
+ }
298
304
  /** A reviewer's note: kept bounded, saved with the app, announced (`notes`). */
299
305
  addNote(viewId, note) {
300
306
  const entry = this.views.get(viewId);
@@ -9,15 +9,17 @@ export class RemotePreview {
9
9
  registry;
10
10
  returnOrigins;
11
11
  signIn;
12
+ captureNote;
12
13
  gateway;
13
14
  origin;
14
15
  relayUrl;
15
16
  online = false;
16
17
  sockets = new Set();
17
- constructor(registry, returnOrigins, signIn) {
18
+ constructor(registry, returnOrigins, signIn, captureNote) {
18
19
  this.registry = registry;
19
20
  this.returnOrigins = returnOrigins;
20
21
  this.signIn = signIn;
22
+ this.captureNote = captureNote;
21
23
  }
22
24
  get available() { return this.online && Boolean(this.gateway); }
23
25
  ready(origin, relayUrl) {
@@ -30,7 +32,7 @@ export class RemotePreview {
30
32
  return;
31
33
  if (this.origin !== template) {
32
34
  this.gateway?.close();
33
- this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn);
35
+ this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn, this.captureNote);
34
36
  this.origin = template;
35
37
  }
36
38
  this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ /** Resolve without a shell or a lifetime cache: installs, removals and PATH
6
+ * changes must be visible to a running daemon as well as its terminals. */
7
+ export function resolveExecutable(command, env = process.env, cwd = process.cwd()) {
8
+ if (!command.trim())
9
+ return null;
10
+ const win = process.platform === "win32";
11
+ const isExecutable = (file) => {
12
+ try {
13
+ if (!fs.statSync(file).isFile())
14
+ return false;
15
+ fs.accessSync(file, win ? fs.constants.F_OK : fs.constants.X_OK);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ };
22
+ const exts = win ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
23
+ const withExts = (file) => exts.map((ext) => (ext && !file.toLowerCase().endsWith(ext.toLowerCase()) ? file + ext : file));
24
+ const explicit = command.includes("/") || (win && command.includes("\\"));
25
+ const bases = explicit
26
+ ? [path.resolve(cwd, command)]
27
+ : (env.PATH || "").split(path.delimiter).filter(Boolean).map((dir) => path.resolve(cwd, dir, command));
28
+ for (const base of bases) {
29
+ for (const candidate of withExts(base)) {
30
+ if (isExecutable(candidate))
31
+ return candidate;
32
+ }
33
+ }
34
+ return null;
35
+ }
@@ -66,8 +66,9 @@ export class RuntimeHost {
66
66
  const info = this.list(canonical).find((candidate) => candidate.id === canonical);
67
67
  if (!info)
68
68
  throw new Error(`Unknown agent: ${wantId}`);
69
- if (info.status !== "available")
70
- throw new Error(`${info.displayName} is not available on this node yet.`);
69
+ if (info.status !== "available") {
70
+ throw new Error(`${info.displayName} is not available on this node. ${info.notes || "Install this agent on the node before starting a session."}`);
71
+ }
71
72
  return canonical;
72
73
  }
73
74
  get(requested, fallback, sandbox) {
@@ -4,12 +4,13 @@
4
4
  // This is the seam where additional runtimes (Claude Agent SDK, generic RPC,
5
5
  // …) are registered without touching the daemon.
6
6
  import { spawnSync } from "node:child_process";
7
+ import { resolveExecutable as resolveCommandPath } from "../executable.js";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
- import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, invalidateClaudeCliProbe } from "../agents/claude-code/runtime.js";
10
+ import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "../agents/claude-code/runtime.js";
10
11
  import { claudeCodeIntegration } from "../agents/claude-code/integration.js";
11
- import { codexAppServerRuntime, codexIntegration, invalidateCodexCommandProbe, } from "../agents/codex/integration.js";
12
- import { invalidatePiCommandProbe, piAgentDir, piCommandAvailable, piIntegration, LazyPiRuntime } from "../agents/pi/integration.js";
12
+ import { codexAppServerRuntime, codexIntegration, } from "../agents/codex/integration.js";
13
+ import { piAgentDir, piCommandAvailable, piIntegration, LazyPiRuntime } from "../agents/pi/integration.js";
13
14
  import { deleteCodexSession, loadCodexTranscript } from "./codex-sessions.js";
14
15
  import { deleteOpenCodeSession, exportOpenCodeSession, importOpenCodeSession, loadOpenCodeTranscript, writeOpenCodeHistory } from "./opencode-sessions.js";
15
16
  import { discoverNativeGrokSessions, listGrokSessions, loadGrokTranscript } from "./grok-sessions.js";
@@ -70,27 +71,9 @@ function cliSlashCommands(spec) {
70
71
  }
71
72
  export * from "./types.js";
72
73
  export { NodeCredentialResolver, createCredentialStore } from "./credentials.js";
73
- // Memoized CLI probes. `commandAvailable`/`resolveCommandPath`/`probeHelpText`
74
- // each shell out with a BLOCKING spawnSync, and the runtime catalog that calls
75
- // them (cliAgentInfo → prefersAcp/acpSupportedByBinary) is rebuilt often — on
76
- // every advertise and every runtimes.list send. Re-probing per build stalled the
77
- // event loop for seconds at a time (a synchronous spawn storm). A CLI's presence
78
- // is effectively constant for the daemon's run, so cache per command for the
79
- // process lifetime and clear on install (invalidateCliProbeCache).
80
- const COMMAND_AVAILABLE_CACHE = new Map();
74
+ // Filesystem lookup stays fresh without spawning a shell on every catalog build.
81
75
  function commandAvailable(command) {
82
- if (!command.trim())
83
- return false;
84
- const cached = COMMAND_AVAILABLE_CACHE.get(command);
85
- if (cached !== undefined)
86
- return cached;
87
- const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
88
- shell: process.platform !== "win32",
89
- stdio: "ignore",
90
- });
91
- const available = result.status === 0;
92
- COMMAND_AVAILABLE_CACHE.set(command, available);
93
- return available;
76
+ return resolveCommandPath(command) !== null;
94
77
  }
95
78
  function genericCliInfo() {
96
79
  const options = processRuntimeFromEnv();
@@ -421,29 +404,6 @@ function cliThinkingConfig(id, spec) {
421
404
  // installed binary doesn't actually mention. It never UPGRADES — adding a
422
405
  // capability needs the exact arg template, which help text can't safely supply — so
423
406
  // probing can only make the catalog MORE honest, never invent a no-op control.
424
- /**
425
- * Absolute path of a command on the current PATH, or null when it isn't there.
426
- * Used to key the help-probe cache: caching by the bare NAME would keep serving a
427
- * stale answer after the binary behind that name changed (a CLI upgraded or
428
- * installed while the daemon is running, or a different PATH entry winning).
429
- * Memoized per command (see COMMAND_AVAILABLE_CACHE) — it spawnSyncs, and is hit
430
- * on every catalog build.
431
- */
432
- const COMMAND_PATH_CACHE = new Map();
433
- function resolveCommandPath(command) {
434
- if (!command.trim())
435
- return null;
436
- const cached = COMMAND_PATH_CACHE.get(command);
437
- if (cached !== undefined)
438
- return cached;
439
- const res = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
440
- shell: process.platform !== "win32",
441
- encoding: "utf8",
442
- });
443
- const resolved = res.status !== 0 ? null : ((res.stdout ?? "").split(/\r?\n/)[0]?.trim() || null);
444
- COMMAND_PATH_CACHE.set(command, resolved);
445
- return resolved;
446
- }
447
407
  const HELP_PROBE_CACHE = new Map();
448
408
  function probeHelpText(command) {
449
409
  const key = resolveCommandPath(command) ?? command;
@@ -461,21 +421,10 @@ function probeHelpText(command) {
461
421
  HELP_PROBE_CACHE.set(key, text);
462
422
  return text;
463
423
  }
464
- /**
465
- * Drop every memoized CLI probe (availability, resolved path, --help text). These
466
- * probes shell out with a blocking spawnSync and are cached for the process
467
- * lifetime to keep the frequently-rebuilt runtime catalog off the event loop, so a
468
- * CLI installed/updated mid-run wouldn't otherwise be noticed until a restart.
469
- * Call this right after Bivy installs a runtime so the next catalog build re-probes
470
- * and reflects the new binary immediately.
471
- */
424
+ /** Refresh expensive help probes after Bivy installs or updates an agent.
425
+ * Executable availability itself is never cached. */
472
426
  export function invalidateCliProbeCache() {
473
- COMMAND_AVAILABLE_CACHE.clear();
474
- COMMAND_PATH_CACHE.clear();
475
427
  HELP_PROBE_CACHE.clear();
476
- invalidatePiCommandProbe();
477
- invalidateClaudeCliProbe();
478
- invalidateCodexCommandProbe();
479
428
  }
480
429
  // A resume template mixes launch flags (`-p`, `--force`) with the resume-specific
481
430
  // token(s) (`--resume`, `threads continue`, `-s`, `--restore`, …). Only the latter
package/dist/server.js CHANGED
@@ -239,7 +239,7 @@ process.chdir(appDir);
239
239
  // dirs here makes commands work from agent shells without requiring a user shell
240
240
  // profile.
241
241
  const userLocalPrefix = process.env.BIVY_NPM_GLOBAL_PREFIX || path.join(process.env.HOME ?? os.homedir(), ".local");
242
- process.env.PATH = [path.join(repoRoot, "bin"), path.join(userLocalPrefix, "bin"), process.env.PATH || ""].filter(Boolean).join(path.delimiter);
242
+ process.env.PATH = [path.join(repoRoot, "bin"), path.join(userLocalPrefix, "bin"), process.env.PATH || "", path.dirname(process.execPath)].filter(Boolean).join(path.delimiter);
243
243
  const piDir = path.join(appDir, "pi");
244
244
  // The node's shared, agent-neutral credential vault (auth.enc/auth.key). NOT
245
245
  // inside any one agent's dir — every runtime (Pi, Codex, Claude, …) reads the
@@ -1654,6 +1654,8 @@ function referencedAttachmentHashes() {
1654
1654
  hashes.add(entry.ref.hash);
1655
1655
  }
1656
1656
  }
1657
+ for (const hash of appRegistry.notePictureHashes())
1658
+ hashes.add(hash);
1657
1659
  return eventLog.health().ok ? hashes : null;
1658
1660
  }
1659
1661
  // --- Warm session replication (docs/session-replication.md) -----------------
@@ -1842,8 +1844,18 @@ const appSignIn = (view, pagePath) => {
1842
1844
  return undefined;
1843
1845
  return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
1844
1846
  };
1845
- const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1846
- const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1847
+ // Public notes get owner-only encrypted pictures, never a screenshot response
1848
+ // on the public origin. The annotation service honors appScreenshots being off.
1849
+ const captureReviewerNote = async (entry, mark) => {
1850
+ const result = await appService.annotate(entry.app.sessionId, mark);
1851
+ if (!result.image)
1852
+ return undefined;
1853
+ const image = result.image;
1854
+ const ref = attachmentStore.put(Buffer.from(image.data, "base64"), { name: "Reviewer note (approximate).png", mimeType: "image/png", kind: "image" });
1855
+ return { hash: ref.hash, size: ref.size, width: image.width, height: image.height };
1856
+ };
1857
+ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn, captureReviewerNote) : undefined;
1858
+ const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn, captureReviewerNote);
1847
1859
  // Desktop app views: one private display per view, started on first open.
1848
1860
  const appDisplays = createDisplayHost(appDir);
1849
1861
  /** Review cards: screenshots become encrypted attachments (fetched by hash
package/dist/terminal.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import { randomUUID } from "node:crypto";
4
4
  import fs from "node:fs";
5
+ import { resolveExecutable } from "./executable.js";
5
6
  import { createRequire } from "node:module";
6
7
  import path from "node:path";
7
8
  import { depCacheEnv } from "./harness/dep-cache.js";
@@ -70,41 +71,7 @@ function defaultShell() {
70
71
  * Resolving up front lets open() throw a clear, catchable error — and hands the
71
72
  * PTY an unambiguous absolute path.
72
73
  */
73
- export function resolveExecutable(command, env, cwd) {
74
- const win = process.platform === "win32";
75
- const isExecutable = (file) => {
76
- try {
77
- if (!fs.statSync(file).isFile())
78
- return false;
79
- if (win)
80
- return true;
81
- fs.accessSync(file, fs.constants.X_OK);
82
- return true;
83
- }
84
- catch {
85
- return false;
86
- }
87
- };
88
- // On Windows a bare name may need a PATHEXT suffix (claude → claude.cmd).
89
- const exts = win ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
90
- const withExts = (file) => exts.map((ext) => (ext && !file.toLowerCase().endsWith(ext.toLowerCase()) ? file + ext : file));
91
- // A command with an explicit path is resolved directly (against the workspace
92
- // for a relative one), never via PATH — same as exec.
93
- if (command.includes("/") || (win && command.includes("\\"))) {
94
- for (const candidate of withExts(path.resolve(cwd, command))) {
95
- if (isExecutable(candidate))
96
- return candidate;
97
- }
98
- return null;
99
- }
100
- for (const dir of (env.PATH || "").split(path.delimiter).filter(Boolean)) {
101
- for (const candidate of withExts(path.join(dir, command))) {
102
- if (isExecutable(candidate))
103
- return candidate;
104
- }
105
- }
106
- return null;
107
- }
74
+ export { resolveExecutable } from "./executable.js";
108
75
  /**
109
76
  * Restore the execute bit on node-pty's bundled `spawn-helper` binary.
110
77
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.4-staging.9",
3
+ "version": "0.18.5-staging.1",
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.",