@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 +3 -0
- package/dist/agents/claude-code/runtime.js +2 -16
- package/dist/agents/codex/integration.js +3 -16
- package/dist/agents/pi/integration.js +2 -16
- package/dist/apps/gateway.js +82 -8
- package/dist/apps/inspector.js +7 -2
- package/dist/apps/preview-shell.js +51 -11
- package/dist/apps/registry.js +7 -1
- package/dist/apps/remote-preview.js +4 -2
- package/dist/executable.js +35 -0
- package/dist/runtime/host.js +3 -2
- package/dist/runtime/index.js +8 -59
- package/dist/server.js +15 -3
- package/dist/terminal.js +2 -35
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
-
/**
|
|
257
|
-
*
|
|
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.
|
|
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 >
|
|
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(
|
|
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) {
|
package/dist/apps/inspector.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
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()
|
|
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.
|
|
283
|
-
//
|
|
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==='
|
|
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();}
|
package/dist/apps/registry.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/host.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
12
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1846
|
-
|
|
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
|
|
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