@bivy/bivy 0.17.2 → 0.17.3-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/bin/bivy.mjs +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +115 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +124 -20
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +243 -5
- package/dist/apps/styles.css +5 -0
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +30 -1
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +19 -6
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +7 -1
- package/dist/server.js +99 -7
- package/dist/session/run-terminal.js +114 -70
- package/package.json +2 -1
package/dist/apps/service.js
CHANGED
|
@@ -1,23 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { scanListeners } from "./listeners.js";
|
|
5
|
+
import { takeShots } from "./screenshot.js";
|
|
6
|
+
const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
|
|
7
|
+
/** How long a screenshot waits for a desktop app's first window. */
|
|
8
|
+
const WINDOW_WAIT_MS = 15_000;
|
|
1
9
|
/** Composes view providers; registry never spawns processes and the gateway
|
|
2
10
|
* never knows about terminals. New providers can extend open/remove here. */
|
|
11
|
+
/** A managed server that keeps exiting is left down after this many restarts
|
|
12
|
+
* in RESTART_WINDOW; opening the view again tries afresh. */
|
|
13
|
+
const MAX_RESTARTS = 5;
|
|
14
|
+
/** Compare keeps this many shots per view, taken after servers settle. */
|
|
15
|
+
const COMPARE_KEEP = 4;
|
|
16
|
+
const COMPARE_SETTLE_MS = 1_500;
|
|
17
|
+
const RESTART_WINDOW = 10 * 60_000;
|
|
3
18
|
export class AppService {
|
|
4
19
|
registry;
|
|
5
20
|
gateway;
|
|
6
21
|
terminals;
|
|
7
22
|
terminalStarts = new Map();
|
|
8
|
-
|
|
23
|
+
servers = new Map();
|
|
24
|
+
scan;
|
|
25
|
+
serverWatchMs;
|
|
26
|
+
screenshots;
|
|
27
|
+
displays;
|
|
28
|
+
shooting = Promise.resolve();
|
|
29
|
+
constructor(registry, gateway, terminals, options = {}) {
|
|
9
30
|
this.registry = registry;
|
|
10
31
|
this.gateway = gateway;
|
|
11
32
|
this.terminals = terminals;
|
|
33
|
+
this.scan = options.scan ?? scanListeners;
|
|
34
|
+
this.serverWatchMs = options.serverWatchMs ?? 5_000;
|
|
35
|
+
this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
|
|
36
|
+
this.displays = options.displays ?? NO_DISPLAYS;
|
|
12
37
|
}
|
|
13
|
-
list(sessionId) {
|
|
14
|
-
|
|
15
|
-
|
|
38
|
+
list(sessionId) {
|
|
39
|
+
const available = Boolean(this.gateway) && this.gateway?.available !== false;
|
|
40
|
+
const apps = this.registry.list(sessionId);
|
|
41
|
+
for (const app of apps)
|
|
42
|
+
for (const view of app.views) {
|
|
43
|
+
if (view.kind !== "web")
|
|
44
|
+
continue;
|
|
45
|
+
if (available)
|
|
46
|
+
view.address = this.gateway?.address?.(view.id);
|
|
47
|
+
const entry = this.registry.getView(view.id);
|
|
48
|
+
if (entry?.notes?.length)
|
|
49
|
+
view.notes = structuredClone(entry.notes);
|
|
50
|
+
if (entry?.stats)
|
|
51
|
+
view.stats = structuredClone(entry.stats);
|
|
52
|
+
}
|
|
53
|
+
return { apps, previewAvailable: available };
|
|
54
|
+
}
|
|
55
|
+
publish(sessionId, workspace, manifest) {
|
|
56
|
+
// Tell the agent now, not on first open, when this machine can't show it.
|
|
57
|
+
const unavailable = manifest?.views?.some?.((view) => view?.kind === "display") && this.displays.unavailable();
|
|
58
|
+
if (unavailable)
|
|
59
|
+
throw new Error(unavailable);
|
|
60
|
+
return this.registry.publish(sessionId, workspace, manifest);
|
|
61
|
+
}
|
|
62
|
+
/** An agent turn changed files in this session's workspace. With agent
|
|
63
|
+
* screenshots on, Compare gets an "after" shot once servers have rebuilt. */
|
|
64
|
+
turnChanged(sessionId) {
|
|
65
|
+
const bumped = this.registry.touch(sessionId);
|
|
66
|
+
// Desktop apps that asked for it are restarted, so they run the new code.
|
|
67
|
+
for (const id of bumped) {
|
|
68
|
+
const entry = this.registry.getView(id);
|
|
69
|
+
const server = this.servers.get(id);
|
|
70
|
+
if (entry?.target.kind !== "display" || !server?.termId)
|
|
71
|
+
continue;
|
|
72
|
+
this.terminals.close(server.termId);
|
|
73
|
+
server.termId = undefined;
|
|
74
|
+
void this.ensureServer(entry).catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
if (bumped.length && this.screenshots.enabled())
|
|
77
|
+
setTimeout(() => void this.capture(bumped), COMPARE_SETTLE_MS).unref?.();
|
|
78
|
+
return bumped;
|
|
79
|
+
}
|
|
80
|
+
/** One phone-width shot per view of the page last viewed, kept for Compare. */
|
|
81
|
+
async capture(viewIds) {
|
|
82
|
+
for (const id of viewIds) {
|
|
83
|
+
const entry = this.registry.getView(id);
|
|
84
|
+
if (!entry || entry.view.kind !== "web")
|
|
85
|
+
continue;
|
|
86
|
+
await this.windowShown(entry).catch(() => { });
|
|
87
|
+
const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
|
|
88
|
+
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: entry.lastPath ?? "/" }, outDir));
|
|
89
|
+
this.shooting = run;
|
|
90
|
+
try {
|
|
91
|
+
const [shot] = await run;
|
|
92
|
+
if (!shot)
|
|
93
|
+
continue;
|
|
94
|
+
const png = fs.readFileSync(shot.file);
|
|
95
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
96
|
+
entry.shots = [...(entry.shots ?? []), { revision: entry.revision, at: Date.now(), png }].slice(-COMPARE_KEEP);
|
|
97
|
+
}
|
|
98
|
+
catch { /* a failed shot just leaves Compare without this turn */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Servers running in the workspace that this session doesn't preview yet. */
|
|
102
|
+
async offers(sessionId, workspace) {
|
|
103
|
+
const claimed = this.registry.claimedPorts(sessionId);
|
|
104
|
+
return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
|
|
105
|
+
}
|
|
106
|
+
/** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
|
|
107
|
+
async adopt(sessionId, workspace, port) {
|
|
108
|
+
const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
|
|
109
|
+
if (!offer)
|
|
110
|
+
throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
|
|
111
|
+
return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
|
|
112
|
+
}
|
|
113
|
+
/** `direct` opens the app origin itself (no shell): a signed-in device
|
|
114
|
+
* returning to a view's stable address. */
|
|
115
|
+
/** `scale`: the opening device's pixel density, used if this starts a display. */
|
|
116
|
+
async open(sessionId, appId, viewId, returnTo, direct = false, scale) {
|
|
16
117
|
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
118
|
+
if (entry.target.kind === "display" && !entry.display)
|
|
119
|
+
entry.displayScale = scale === 2 ? 2 : 1;
|
|
17
120
|
if (entry.view.kind === "web") {
|
|
18
121
|
if (!this.gateway)
|
|
19
122
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
20
|
-
|
|
123
|
+
if (direct && !this.gateway.openDirect)
|
|
124
|
+
throw new Error("This machine can't open previews directly.");
|
|
125
|
+
const url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
|
|
126
|
+
// Don't wait for a managed server to boot: the preview shows it starting
|
|
127
|
+
// and reloads itself once it answers.
|
|
128
|
+
if (this.servers.get(viewId))
|
|
129
|
+
this.servers.get(viewId).restarts = [];
|
|
130
|
+
void this.ensureServer(entry).catch(() => { });
|
|
131
|
+
// Compare needs a "before": take a baseline the first time it's opened.
|
|
132
|
+
if (!entry.shots?.length && this.screenshots.enabled())
|
|
133
|
+
setTimeout(() => void this.capture([viewId]), COMPARE_SETTLE_MS).unref?.();
|
|
134
|
+
return { kind: "web", url };
|
|
21
135
|
}
|
|
22
136
|
if (entry.target.kind !== "terminal")
|
|
23
137
|
throw new Error("Unsupported app view provider.");
|
|
@@ -48,6 +162,125 @@ export class AppService {
|
|
|
48
162
|
throw error;
|
|
49
163
|
}
|
|
50
164
|
}
|
|
165
|
+
/** Screenshots of a session's web views (one app, or all), for agents to
|
|
166
|
+
* check their own UI. One browser at a time node-wide; PNGs in a temp dir. */
|
|
167
|
+
async shot(sessionId, appId, input) {
|
|
168
|
+
if (!this.screenshots.enabled())
|
|
169
|
+
throw new Error("Agent screenshots are off on this machine. Turn on “Let agents screenshot their app previews” in Bivy → Settings → this machine, or run: bivy config set sessions.appScreenshots true");
|
|
170
|
+
const widths = input.widths ?? [390, 1280];
|
|
171
|
+
const themes = input.themes ?? ["light"];
|
|
172
|
+
const page = input.path ?? "/";
|
|
173
|
+
if (!Array.isArray(widths) || !widths.length || widths.length > 4 || widths.some((w) => !Number.isInteger(w) || w < 240 || w > 2560))
|
|
174
|
+
throw new Error("Widths must be 1–4 whole numbers between 240 and 2560.");
|
|
175
|
+
if (!Array.isArray(themes) || !themes.length || themes.some((t) => t !== "light" && t !== "dark"))
|
|
176
|
+
throw new Error("Themes must be light and/or dark.");
|
|
177
|
+
if (typeof page !== "string" || !page.startsWith("/") || page.startsWith("//") || page.length > 2048)
|
|
178
|
+
throw new Error("Path must start with /.");
|
|
179
|
+
const apps = appId ? [this.registry.require(sessionId, appId)] : this.registry.list(sessionId);
|
|
180
|
+
const views = apps.flatMap((app) => app.views).filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id)).filter(Boolean);
|
|
181
|
+
if (!views.length)
|
|
182
|
+
throw new Error("This session has no web views to screenshot. Publish one with bivy app publish, or preview a detected server.");
|
|
183
|
+
await Promise.all(views.map((entry) => this.windowShown(entry)));
|
|
184
|
+
const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
|
|
185
|
+
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take(views, { widths, themes: [...new Set(themes)], path: page }, outDir));
|
|
186
|
+
this.shooting = run;
|
|
187
|
+
return { shots: await run };
|
|
188
|
+
}
|
|
189
|
+
/** The managed server's output, as an attachable terminal (starts it if needed). */
|
|
190
|
+
async logs(sessionId, appId, viewId) {
|
|
191
|
+
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
192
|
+
const termId = await this.ensureServer(entry);
|
|
193
|
+
if (!termId)
|
|
194
|
+
throw new Error("Only views with a start command have server logs.");
|
|
195
|
+
return { kind: "terminal", termId };
|
|
196
|
+
}
|
|
197
|
+
/** A desktop app's display is up and showing a window (or gave up waiting),
|
|
198
|
+
* so a screenshot shows the app rather than an empty screen. */
|
|
199
|
+
async windowShown(entry) {
|
|
200
|
+
if (entry.target.kind !== "display")
|
|
201
|
+
return;
|
|
202
|
+
await this.ensureServer(entry);
|
|
203
|
+
const display = await this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale);
|
|
204
|
+
for (const until = Date.now() + WINDOW_WAIT_MS; !display.wm.count && Date.now() < until;)
|
|
205
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
206
|
+
await new Promise((r) => setTimeout(r, 800)); // first paint
|
|
207
|
+
}
|
|
208
|
+
/** The program Bivy runs for a view: a service's start command, or a desktop
|
|
209
|
+
* app on its display (started first). */
|
|
210
|
+
program(entry) {
|
|
211
|
+
const target = entry.target;
|
|
212
|
+
if (target.kind === "service" && target.start)
|
|
213
|
+
return { ...target.start, name: `${entry.app.name} · ${entry.view.name} server` };
|
|
214
|
+
if (target.kind !== "display")
|
|
215
|
+
throw new Error("This view has no program.");
|
|
216
|
+
return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
|
|
217
|
+
entry.display = display.socket;
|
|
218
|
+
return { command: target.command, args: target.args, workspace: target.workspace, env: display.env, name: `${entry.app.name} · ${entry.view.name}` };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** Services start synchronously; a desktop app waits for its display. */
|
|
222
|
+
startProgram(entry) {
|
|
223
|
+
const program = this.program(entry);
|
|
224
|
+
return program instanceof Promise ? program.then((spec) => this.terminals.start(spec)) : this.terminals.start(program);
|
|
225
|
+
}
|
|
226
|
+
ensureServer(entry) {
|
|
227
|
+
if (!(entry.target.kind === "service" && entry.target.start) && entry.target.kind !== "display")
|
|
228
|
+
return Promise.resolve(undefined);
|
|
229
|
+
const id = entry.view.id;
|
|
230
|
+
let state = this.servers.get(id);
|
|
231
|
+
if (!state) {
|
|
232
|
+
state = { restarts: [] };
|
|
233
|
+
this.servers.set(id, state);
|
|
234
|
+
}
|
|
235
|
+
const server = state;
|
|
236
|
+
if (server.termId && this.terminals.has(server.termId))
|
|
237
|
+
return Promise.resolve(server.termId);
|
|
238
|
+
server.pending ??= this.startProgram(entry)
|
|
239
|
+
.then((termId) => {
|
|
240
|
+
if (!this.registry.getView(id)) {
|
|
241
|
+
this.terminals.close(termId);
|
|
242
|
+
throw new Error("App was removed while starting.");
|
|
243
|
+
}
|
|
244
|
+
server.termId = termId;
|
|
245
|
+
return termId;
|
|
246
|
+
})
|
|
247
|
+
.finally(() => { server.pending = undefined; });
|
|
248
|
+
// Watch for exits and restart, backing off a crash loop.
|
|
249
|
+
server.timer ??= setInterval(() => {
|
|
250
|
+
const current = this.registry.getView(id);
|
|
251
|
+
if (!current) {
|
|
252
|
+
this.stopServer(id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (server.pending || !server.termId || this.terminals.has(server.termId))
|
|
256
|
+
return;
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
server.restarts = server.restarts.filter((at) => now - at < RESTART_WINDOW);
|
|
259
|
+
if (server.restarts.length >= MAX_RESTARTS)
|
|
260
|
+
return;
|
|
261
|
+
server.restarts.push(now);
|
|
262
|
+
server.termId = undefined;
|
|
263
|
+
void this.ensureServer(current).catch(() => { });
|
|
264
|
+
}, this.serverWatchMs);
|
|
265
|
+
server.timer.unref?.();
|
|
266
|
+
return server.pending;
|
|
267
|
+
}
|
|
268
|
+
stopServer(id) {
|
|
269
|
+
this.displays.stop(id);
|
|
270
|
+
const entry = this.registry.getView(id);
|
|
271
|
+
if (entry) {
|
|
272
|
+
entry.display = undefined;
|
|
273
|
+
entry.displayScale = undefined;
|
|
274
|
+
}
|
|
275
|
+
const server = this.servers.get(id);
|
|
276
|
+
if (!server)
|
|
277
|
+
return;
|
|
278
|
+
clearInterval(server.timer);
|
|
279
|
+
if (server.termId)
|
|
280
|
+
this.terminals.close(server.termId);
|
|
281
|
+
void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
|
|
282
|
+
this.servers.delete(id);
|
|
283
|
+
}
|
|
51
284
|
share(sessionId, appId, viewId) {
|
|
52
285
|
if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
|
|
53
286
|
throw new Error("Only web views have preview links.");
|
|
@@ -55,6 +288,10 @@ export class AppService {
|
|
|
55
288
|
throw new Error("Bivy's preview service is unavailable on this connection.");
|
|
56
289
|
return this.gateway.share(viewId);
|
|
57
290
|
}
|
|
291
|
+
clearNotes(sessionId, appId, viewId) {
|
|
292
|
+
delete this.registry.requireView(sessionId, appId, viewId).notes;
|
|
293
|
+
return { ok: true };
|
|
294
|
+
}
|
|
58
295
|
/** Ends every link, browser session and open connection for one view; the app stays. */
|
|
59
296
|
revoke(sessionId, appId, viewId) {
|
|
60
297
|
this.registry.requireView(sessionId, appId, viewId);
|
|
@@ -66,6 +303,7 @@ export class AppService {
|
|
|
66
303
|
this.registry.remove(sessionId, appId);
|
|
67
304
|
for (const view of app.views) {
|
|
68
305
|
this.gateway?.revoke(view.id);
|
|
306
|
+
this.stopServer(view.id);
|
|
69
307
|
const pending = this.terminalStarts.get(view.id);
|
|
70
308
|
this.terminalStarts.delete(view.id);
|
|
71
309
|
if (pending)
|
package/dist/apps/styles.css
CHANGED
|
@@ -551,6 +551,8 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
|
|
|
551
551
|
.hljs-addition { color: var(--syntax-addition); }
|
|
552
552
|
.msg.system { align-self: center; color: var(--muted); font-size: 13px; font-style: italic; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
|
|
553
553
|
.msg.system .system-text { display: block; }
|
|
554
|
+
.entry-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); font-style: normal; }
|
|
555
|
+
.msg.system .entry-actions { justify-content: center; }
|
|
554
556
|
.msg.thinking { align-self: flex-start; color: var(--muted); font-size: 13px; border-left: 2px solid var(--line-strong); padding-left: 10px; }
|
|
555
557
|
.msg.error {
|
|
556
558
|
align-self: stretch; display: flex; gap: 8px; align-items: flex-start;
|
|
@@ -2957,6 +2959,9 @@ select.picker-search { appearance: auto; }
|
|
|
2957
2959
|
.app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
|
|
2958
2960
|
.app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
|
|
2959
2961
|
.app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
|
|
2962
|
+
.app-view-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
|
2963
|
+
/* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
|
|
2964
|
+
.preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
|
|
2960
2965
|
.app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
|
|
2961
2966
|
.app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
|
|
2962
2967
|
.app-view-row .artifact-main { flex-basis: 60%; }
|
package/dist/apps/x11.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
/** An X authority file with one MIT-MAGIC-COOKIE-1 for any display number, so
|
|
7
|
+
* only processes Bivy hands the file to can connect. */
|
|
8
|
+
export function writeXauthority(file, cookie = randomBytes(16)) {
|
|
9
|
+
const field = (data) => { const len = Buffer.alloc(2); len.writeUInt16BE(data.length); return Buffer.concat([len, data]); };
|
|
10
|
+
const family = Buffer.from([0xff, 0xff]); // FamilyWild
|
|
11
|
+
fs.writeFileSync(file, Buffer.concat([family, field(Buffer.alloc(0)), field(Buffer.alloc(0)), field(Buffer.from("MIT-MAGIC-COOKIE-1")), field(cookie)]), { mode: 0o600 });
|
|
12
|
+
return cookie;
|
|
13
|
+
}
|
|
14
|
+
const pad = (n) => (4 - (n % 4)) % 4;
|
|
15
|
+
// Core protocol numbers used below (X11 protocol, little-endian client).
|
|
16
|
+
const OP = { ChangeWindowAttributes: 2, MapWindow: 8, ConfigureWindow: 12, GetGeometry: 14, ChangeProperty: 18, GetProperty: 20, SendEvent: 25, SetInputFocus: 42, QueryExtension: 98 };
|
|
17
|
+
/** RandR minor opcodes. */
|
|
18
|
+
const RR = { QueryVersion: 0, SetScreenSize: 7 };
|
|
19
|
+
const EV = { DestroyNotify: 17, UnmapNotify: 18, MapRequest: 20, ConfigureNotify: 22, ConfigureRequest: 23 };
|
|
20
|
+
const MASK = { StructureNotify: 1 << 17, SubstructureNotify: 1 << 19, SubstructureRedirect: 1 << 20 };
|
|
21
|
+
const CFG = { x: 1, y: 2, width: 4, height: 8, border: 16 };
|
|
22
|
+
const ATOM = { STRING: 31, RESOURCE_MANAGER: 23, WM_NORMAL_HINTS: 40, WM_SIZE_HINTS: 41, WINDOW: 33, WM_TRANSIENT_FOR: 68 };
|
|
23
|
+
/** WM_SIZE_HINTS flag: the program set a minimum size. */
|
|
24
|
+
const P_MIN_SIZE = 1 << 4;
|
|
25
|
+
/** A window manager with one rule, sized for previews: every top-level window
|
|
26
|
+
* fills the screen, dialogs are centered at their own size, and everything is
|
|
27
|
+
* refit when the viewer resizes the display. A window that can't shrink to the
|
|
28
|
+
* viewer's size grows the screen instead, and the viewer scales it down, so
|
|
29
|
+
* nothing is cut off. Pure protocol, no dependencies. */
|
|
30
|
+
export class FitWindowManager {
|
|
31
|
+
socket;
|
|
32
|
+
buffer = Buffer.alloc(0);
|
|
33
|
+
seq = 0;
|
|
34
|
+
root = 0;
|
|
35
|
+
width = 0;
|
|
36
|
+
height = 0;
|
|
37
|
+
ready = false;
|
|
38
|
+
/** Pending replies by sequence number: the raw reply, or undefined on error. */
|
|
39
|
+
replies = new Map();
|
|
40
|
+
/** Mapped windows we manage; `size` is what the app asked for (dialogs keep
|
|
41
|
+
* it), `min` the smallest it can draw. */
|
|
42
|
+
windows = new Map();
|
|
43
|
+
/** The size the viewer asked for, and the size we last set ourselves. */
|
|
44
|
+
viewer = [0, 0];
|
|
45
|
+
own;
|
|
46
|
+
randr = 0;
|
|
47
|
+
/** Resolves once the WM owns the root window; rejects if another WM does. */
|
|
48
|
+
/** `dpi` is published as Xft.dpi, which Chromium/Electron and others scale by. */
|
|
49
|
+
start(socketPath, cookie, dpi = 96) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const socket = net.connect(socketPath);
|
|
52
|
+
this.socket = socket;
|
|
53
|
+
socket.on("error", (error) => { if (!this.ready)
|
|
54
|
+
reject(error); });
|
|
55
|
+
socket.on("close", () => { if (!this.ready)
|
|
56
|
+
reject(new Error("The display closed the window manager's connection.")); });
|
|
57
|
+
const name = Buffer.from("MIT-MAGIC-COOKIE-1");
|
|
58
|
+
const setup = Buffer.alloc(12);
|
|
59
|
+
setup.write("l", 0);
|
|
60
|
+
setup.writeUInt16LE(11, 2);
|
|
61
|
+
setup.writeUInt16LE(0, 4);
|
|
62
|
+
setup.writeUInt16LE(name.length, 6);
|
|
63
|
+
setup.writeUInt16LE(cookie.length, 8);
|
|
64
|
+
socket.write(Buffer.concat([setup, name, Buffer.alloc(pad(name.length)), cookie, Buffer.alloc(pad(cookie.length))]));
|
|
65
|
+
socket.on("data", (chunk) => {
|
|
66
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
67
|
+
try {
|
|
68
|
+
if (!this.ready) {
|
|
69
|
+
if (this.buffer.length < 8)
|
|
70
|
+
return;
|
|
71
|
+
const total = 8 + this.buffer.readUInt16LE(6) * 4;
|
|
72
|
+
if (this.buffer.length < total)
|
|
73
|
+
return;
|
|
74
|
+
const reply = this.buffer.subarray(0, total);
|
|
75
|
+
this.buffer = this.buffer.subarray(total);
|
|
76
|
+
if (reply[0] !== 1) {
|
|
77
|
+
reject(new Error(`The display refused the window manager: ${reply.subarray(8, 8 + reply[1]).toString()}`));
|
|
78
|
+
socket.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.parseSetup(reply);
|
|
82
|
+
this.ready = true;
|
|
83
|
+
this.viewer = [this.width, this.height];
|
|
84
|
+
const resources = Buffer.from(`Xft.dpi:\t${dpi}\n`);
|
|
85
|
+
this.request(OP.ChangeProperty, 0, Buffer.concat([u32(this.root, ATOM.RESOURCE_MANAGER, ATOM.STRING, 8, resources.length), resources, Buffer.alloc(pad(resources.length))]));
|
|
86
|
+
const name = Buffer.from("RANDR");
|
|
87
|
+
this.query(OP.QueryExtension, Buffer.concat([u16pair(name.length, 0), name, Buffer.alloc(pad(name.length))]), (reply) => {
|
|
88
|
+
if (!reply?.[8])
|
|
89
|
+
return;
|
|
90
|
+
this.randr = reply[9];
|
|
91
|
+
this.request(this.randr, RR.QueryVersion, u32(1, 5));
|
|
92
|
+
});
|
|
93
|
+
// Redirecting the root's substructure is what makes us the WM.
|
|
94
|
+
const seq = this.request(OP.ChangeWindowAttributes, 0, u32(this.root, 0x800, MASK.SubstructureRedirect | MASK.SubstructureNotify | MASK.StructureNotify));
|
|
95
|
+
this.replies.set(seq, () => reject(new Error("Another window manager already runs on this display.")));
|
|
96
|
+
// A no-reply request can't be confirmed directly; a reply to the next
|
|
97
|
+
// request arrives in order, so it means the first one passed.
|
|
98
|
+
this.query(OP.GetGeometry, u32(this.root), () => { this.replies.delete(seq); resolve(); });
|
|
99
|
+
}
|
|
100
|
+
this.drain();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
socket.destroy();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
stop() { this.socket?.destroy(); this.socket = undefined; }
|
|
109
|
+
get size() { return [this.width, this.height]; }
|
|
110
|
+
/** Top-level windows currently shown. */
|
|
111
|
+
get count() { return this.windows.size; }
|
|
112
|
+
parseSetup(reply) {
|
|
113
|
+
const vendorLength = reply.readUInt16LE(24);
|
|
114
|
+
const formats = reply[29];
|
|
115
|
+
const screen = 40 + vendorLength + pad(vendorLength) + formats * 8;
|
|
116
|
+
this.root = reply.readUInt32LE(screen);
|
|
117
|
+
this.width = reply.readUInt16LE(screen + 20);
|
|
118
|
+
this.height = reply.readUInt16LE(screen + 22);
|
|
119
|
+
}
|
|
120
|
+
request(opcode, data, body) {
|
|
121
|
+
const header = Buffer.alloc(4);
|
|
122
|
+
header[0] = opcode;
|
|
123
|
+
header[1] = data;
|
|
124
|
+
header.writeUInt16LE(1 + body.length / 4, 2);
|
|
125
|
+
this.socket?.write(Buffer.concat([header, body]));
|
|
126
|
+
this.seq = (this.seq + 1) & 0xffff;
|
|
127
|
+
return this.seq;
|
|
128
|
+
}
|
|
129
|
+
query(opcode, body, done) {
|
|
130
|
+
this.replies.set(this.request(opcode, 0, body), done);
|
|
131
|
+
}
|
|
132
|
+
drain() {
|
|
133
|
+
while (this.buffer.length >= 32) {
|
|
134
|
+
const kind = this.buffer[0];
|
|
135
|
+
const length = kind === 1 ? 32 + this.buffer.readUInt32LE(4) * 4 : 32;
|
|
136
|
+
if (this.buffer.length < length)
|
|
137
|
+
return;
|
|
138
|
+
const packet = this.buffer.subarray(0, length);
|
|
139
|
+
this.buffer = this.buffer.subarray(length);
|
|
140
|
+
const seq = packet.readUInt16LE(2);
|
|
141
|
+
if (kind === 0 || kind === 1) {
|
|
142
|
+
const done = this.replies.get(seq);
|
|
143
|
+
this.replies.delete(seq);
|
|
144
|
+
done?.(kind === 1 ? packet : undefined);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
this.event(kind & 0x7f, packet);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
event(code, e) {
|
|
151
|
+
if (code === EV.MapRequest) {
|
|
152
|
+
const window = e.readUInt32LE(8);
|
|
153
|
+
// The size it was created (or last configured) with, and whether it's a
|
|
154
|
+
// dialog: dialogs name their parent and keep their size, centered.
|
|
155
|
+
let size = [0, 0], min = [0, 0];
|
|
156
|
+
this.query(OP.GetGeometry, u32(window), (reply) => { if (reply)
|
|
157
|
+
size = [reply.readUInt16LE(16), reply.readUInt16LE(18)]; });
|
|
158
|
+
this.query(OP.GetProperty, u32(window, ATOM.WM_NORMAL_HINTS, ATOM.WM_SIZE_HINTS, 0, 18), (reply) => {
|
|
159
|
+
if (reply && reply.readUInt32LE(16) >= 7 && reply.readUInt32LE(32) & P_MIN_SIZE)
|
|
160
|
+
min = [reply.readInt32LE(32 + 20), reply.readInt32LE(32 + 24)];
|
|
161
|
+
});
|
|
162
|
+
this.query(OP.GetProperty, u32(window, ATOM.WM_TRANSIENT_FOR, ATOM.WINDOW, 0, 1), (reply) => {
|
|
163
|
+
if (!reply)
|
|
164
|
+
return; // gone already
|
|
165
|
+
const dialog = reply.readUInt32LE(16) > 0 && reply.readUInt32LE(32) > 0;
|
|
166
|
+
this.windows.set(window, { dialog, size, min: min.map((v) => Math.max(0, Math.min(v, 8192))) });
|
|
167
|
+
if (!this.resize())
|
|
168
|
+
this.fit(window);
|
|
169
|
+
this.request(OP.MapWindow, 0, u32(window));
|
|
170
|
+
this.request(OP.SetInputFocus, 2, u32(window, 0));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else if (code === EV.ConfigureRequest) {
|
|
174
|
+
const window = e.readUInt32LE(8);
|
|
175
|
+
const size = [e.readUInt16LE(20), e.readUInt16LE(22)];
|
|
176
|
+
const managed = this.windows.get(window);
|
|
177
|
+
if (managed) {
|
|
178
|
+
managed.size = size;
|
|
179
|
+
this.fit(window);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Not shown yet: honour it; the map request decides the final geometry.
|
|
183
|
+
this.configure(window, e.readInt16LE(16), e.readInt16LE(18), size[0], size[1]);
|
|
184
|
+
}
|
|
185
|
+
else if (code === EV.DestroyNotify || code === EV.UnmapNotify) {
|
|
186
|
+
const window = e.readUInt32LE(8);
|
|
187
|
+
if (e.readUInt32LE(4) === this.root && this.windows.delete(window))
|
|
188
|
+
this.resize();
|
|
189
|
+
}
|
|
190
|
+
else if (code === EV.ConfigureNotify && e.readUInt32LE(8) === this.root) {
|
|
191
|
+
// The display changed size: refit everything to the new screen. If the
|
|
192
|
+
// viewer did it (not us), remember what it asked for.
|
|
193
|
+
this.width = e.readUInt16LE(20);
|
|
194
|
+
this.height = e.readUInt16LE(22);
|
|
195
|
+
if (!this.own || this.own[0] !== this.width || this.own[1] !== this.height) {
|
|
196
|
+
this.viewer = [this.width, this.height];
|
|
197
|
+
this.own = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (this.resize())
|
|
200
|
+
return;
|
|
201
|
+
for (const window of this.windows.keys())
|
|
202
|
+
this.fit(window);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Grow the screen past the viewer's size when a window can't shrink that
|
|
206
|
+
* far; back to the viewer's size when it can. True if a resize was sent (the
|
|
207
|
+
* refit follows when the display reports its new size). */
|
|
208
|
+
resize() {
|
|
209
|
+
if (!this.randr)
|
|
210
|
+
return false;
|
|
211
|
+
let [w, h] = this.viewer;
|
|
212
|
+
for (const { dialog, min } of this.windows.values())
|
|
213
|
+
if (!dialog) {
|
|
214
|
+
w = Math.max(w, min[0]);
|
|
215
|
+
h = Math.max(h, min[1]);
|
|
216
|
+
}
|
|
217
|
+
if (w === this.width && h === this.height)
|
|
218
|
+
return false;
|
|
219
|
+
this.own = [w, h];
|
|
220
|
+
const body = Buffer.alloc(16);
|
|
221
|
+
body.writeUInt32LE(this.root, 0);
|
|
222
|
+
body.writeUInt16LE(w, 4);
|
|
223
|
+
body.writeUInt16LE(h, 6);
|
|
224
|
+
body.writeUInt32LE(Math.round(w * 25.4 / 96), 8);
|
|
225
|
+
body.writeUInt32LE(Math.round(h * 25.4 / 96), 12);
|
|
226
|
+
const seq = this.request(this.randr, RR.SetScreenSize, body);
|
|
227
|
+
// Refused (e.g. too large): stop resizing and fit to the screen we have.
|
|
228
|
+
// A later reply means it was accepted, so drop the error handler.
|
|
229
|
+
this.replies.set(seq, () => { this.randr = 0; this.own = undefined; for (const window of this.windows.keys())
|
|
230
|
+
this.fit(window); });
|
|
231
|
+
this.query(OP.GetGeometry, u32(this.root), () => this.replies.delete(seq));
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
fit(window) {
|
|
235
|
+
const managed = this.windows.get(window);
|
|
236
|
+
if (!managed)
|
|
237
|
+
return;
|
|
238
|
+
if (!managed.dialog) {
|
|
239
|
+
this.configure(window, 0, 0, this.width, this.height);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const w = Math.min(Math.max(managed.size[0], 1), this.width), h = Math.min(Math.max(managed.size[1], 1), this.height);
|
|
243
|
+
this.configure(window, Math.floor((this.width - w) / 2), Math.floor((this.height - h) / 2), w, h);
|
|
244
|
+
}
|
|
245
|
+
configure(window, x, y, width, height) {
|
|
246
|
+
const body = Buffer.alloc(28);
|
|
247
|
+
body.writeUInt32LE(window, 0);
|
|
248
|
+
body.writeUInt16LE(CFG.x | CFG.y | CFG.width | CFG.height | CFG.border, 4);
|
|
249
|
+
body.writeInt32LE(x, 8);
|
|
250
|
+
body.writeInt32LE(y, 12);
|
|
251
|
+
body.writeUInt32LE(Math.max(1, width), 16);
|
|
252
|
+
body.writeUInt32LE(Math.max(1, height), 20);
|
|
253
|
+
body.writeUInt32LE(0, 24);
|
|
254
|
+
this.request(OP.ConfigureWindow, 0, body);
|
|
255
|
+
// ICCCM: tell the client its geometry even when nothing changed, or
|
|
256
|
+
// toolkits that asked for another size wait for an answer and never paint.
|
|
257
|
+
const event = Buffer.alloc(32);
|
|
258
|
+
event[0] = EV.ConfigureNotify;
|
|
259
|
+
event.writeUInt32LE(window, 4);
|
|
260
|
+
event.writeUInt32LE(window, 8);
|
|
261
|
+
event.writeInt16LE(x, 16);
|
|
262
|
+
event.writeInt16LE(y, 18);
|
|
263
|
+
event.writeUInt16LE(Math.max(1, width), 20);
|
|
264
|
+
event.writeUInt16LE(Math.max(1, height), 22);
|
|
265
|
+
this.request(OP.SendEvent, 0, Buffer.concat([u32(window, MASK.StructureNotify), event]));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function u16pair(a, b) { const out = Buffer.alloc(4); out.writeUInt16LE(a, 0); out.writeUInt16LE(b, 2); return out; }
|
|
269
|
+
function u32(...values) {
|
|
270
|
+
const out = Buffer.alloc(values.length * 4);
|
|
271
|
+
values.forEach((value, i) => out.writeUInt32LE(value >>> 0, i * 4));
|
|
272
|
+
return out;
|
|
273
|
+
}
|
package/dist/attach.js
CHANGED
|
@@ -102,6 +102,9 @@ function bridge(args, open) {
|
|
|
102
102
|
let bound = false;
|
|
103
103
|
let detachArmed = 0;
|
|
104
104
|
let settled = false;
|
|
105
|
+
// Set while the session this terminal showed is continuing as a chat in the
|
|
106
|
+
// app: the next key decides whether to take it back here or leave.
|
|
107
|
+
let handedToChat;
|
|
105
108
|
const inputDecoder = new StringDecoder("utf8");
|
|
106
109
|
const stdin = process.stdin;
|
|
107
110
|
const wasRaw = stdin.isTTY ? stdin.isRaw : false;
|
|
@@ -144,6 +147,21 @@ function bridge(args, open) {
|
|
|
144
147
|
socket.send(JSON.stringify(payload));
|
|
145
148
|
};
|
|
146
149
|
const onInput = (data) => {
|
|
150
|
+
if (handedToChat) {
|
|
151
|
+
const sessionId = handedToChat;
|
|
152
|
+
handedToChat = undefined;
|
|
153
|
+
if (data[0] !== 0x0d && data[0] !== 0x0a) {
|
|
154
|
+
finish(0);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Reopen the chat's own TUI, resuming the same conversation; the node
|
|
158
|
+
// locks the chat again and every device sees the handoff.
|
|
159
|
+
process.stdout.write(c.dim("Taking it back…\r\n"));
|
|
160
|
+
modesReset = false;
|
|
161
|
+
const { cols, rows } = termSize();
|
|
162
|
+
send({ kind: "terminal.open.tui", sessionId, cols, rows });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
147
165
|
// Ctrl-\ (0x1c) twice in a row detaches, leaving the daemon session alive.
|
|
148
166
|
if (data.length === 1 && data[0] === 0x1c) {
|
|
149
167
|
const now = Date.now();
|
|
@@ -212,6 +230,17 @@ function bridge(args, open) {
|
|
|
212
230
|
if (typeof msg.data === "string")
|
|
213
231
|
process.stdout.write(msg.data);
|
|
214
232
|
}
|
|
233
|
+
else if (type === "terminal.closed" && msg.reason === "chat" && msg.termId === termId) {
|
|
234
|
+
resetModes();
|
|
235
|
+
termId = undefined;
|
|
236
|
+
process.stdout.write(c.dim("\r\n[continued as chat in the Bivy app]\r\n"));
|
|
237
|
+
if (typeof msg.sessionId !== "string" || !msg.sessionId) {
|
|
238
|
+
finish(0);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
handedToChat = msg.sessionId;
|
|
242
|
+
process.stdout.write(`Press ${c.bold("Enter")} to take it back here, or any other key to leave.\r\n`);
|
|
243
|
+
}
|
|
215
244
|
else if ((type === "terminal.exit" || type === "terminal.closed") && msg.termId === termId) {
|
|
216
245
|
const code = typeof msg.code === "number" ? msg.code : 0;
|
|
217
246
|
resetModes();
|
|
@@ -220,7 +249,7 @@ function bridge(args, open) {
|
|
|
220
249
|
}
|
|
221
250
|
else if (type === "terminal.error") {
|
|
222
251
|
process.stderr.write(c.red(`\r\n${String(msg.error || "Terminal error")}\r\n`));
|
|
223
|
-
if (!bound)
|
|
252
|
+
if (!bound || !termId)
|
|
224
253
|
finish(1);
|
|
225
254
|
}
|
|
226
255
|
});
|
package/dist/config-cli.js
CHANGED
|
@@ -108,6 +108,7 @@ const ENV_FOR_KEY = {
|
|
|
108
108
|
"defaults.sandbox": "BIVY_SANDBOX",
|
|
109
109
|
"defaults.approval": "BIVY_APPROVAL_MODE",
|
|
110
110
|
"sessions.autoAttachToolImages": "BIVY_AUTO_ATTACH_TOOL_IMAGES",
|
|
111
|
+
"sessions.appScreenshots": "BIVY_APP_SCREENSHOTS",
|
|
111
112
|
"automation.checks": "BIVY_AUTOMATION_CHECKS",
|
|
112
113
|
"automation.checkTimeoutMinutes": "BIVY_AUTOMATION_CHECK_TIMEOUT_MS",
|
|
113
114
|
};
|
|
@@ -122,6 +123,7 @@ const BUILTIN_VALUES = {
|
|
|
122
123
|
"sessions.worktreeSync": false,
|
|
123
124
|
"sessions.resume": "auto",
|
|
124
125
|
"sessions.autoAttachToolImages": false,
|
|
126
|
+
"sessions.appScreenshots": false,
|
|
125
127
|
"automation.checks": ["test", "lint", "typecheck"],
|
|
126
128
|
"automation.checkTimeoutMinutes": 10,
|
|
127
129
|
};
|