@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.
@@ -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
- constructor(registry, gateway, terminals) {
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) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
14
- publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
15
- async open(sessionId, appId, viewId, returnTo) {
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
- return { kind: "web", url: this.gateway.open(viewId, returnTo) };
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)
@@ -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%; }
@@ -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
  });
@@ -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
  };