@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.21

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.
@@ -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
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ import { hostname } from "node:os";
3
4
  import { StringDecoder } from "node:string_decoder";
4
5
  import { WebSocket } from "ws";
5
6
  /**
@@ -50,6 +51,9 @@ const TTY_MODE_RESET = "\x1b[?1049l" + // leave the alternate screen
50
51
  "\x1b[?1l\x1b>" + // cursor keys + keypad back to normal mode
51
52
  "\x1b[?25h" + // cursor visible
52
53
  "\x1b[0m"; // reset colors/attributes
54
+ /** How this terminal names itself to the node, so other devices can say where a
55
+ * session was last driven ("Terminal on my-laptop"). */
56
+ const DEVICE = { id: `terminal:${hostname()}`, label: `Terminal on ${hostname()}` };
53
57
  function parseArgs(argv) {
54
58
  let url = process.env.BIVY_URL || `http://localhost:${process.env.PORT || "4317"}`;
55
59
  let token = process.env.BIVY_DEVICE_TOKEN || undefined;
@@ -102,6 +106,9 @@ function bridge(args, open) {
102
106
  let bound = false;
103
107
  let detachArmed = 0;
104
108
  let settled = false;
109
+ // Set while the session this terminal showed is continuing as a chat in the
110
+ // app: the next key decides whether to take it back here or leave.
111
+ let handedToChat;
105
112
  const inputDecoder = new StringDecoder("utf8");
106
113
  const stdin = process.stdin;
107
114
  const wasRaw = stdin.isTTY ? stdin.isRaw : false;
@@ -144,6 +151,21 @@ function bridge(args, open) {
144
151
  socket.send(JSON.stringify(payload));
145
152
  };
146
153
  const onInput = (data) => {
154
+ if (handedToChat) {
155
+ const sessionId = handedToChat;
156
+ handedToChat = undefined;
157
+ if (data[0] !== 0x0d && data[0] !== 0x0a) {
158
+ finish(0);
159
+ return;
160
+ }
161
+ // Reopen the chat's own TUI, resuming the same conversation; the node
162
+ // locks the chat again and every device sees the handoff.
163
+ process.stdout.write(c.dim("Taking it back…\r\n"));
164
+ modesReset = false;
165
+ const { cols, rows } = termSize();
166
+ send({ kind: "terminal.open.tui", sessionId, cols, rows });
167
+ return;
168
+ }
147
169
  // Ctrl-\ (0x1c) twice in a row detaches, leaving the daemon session alive.
148
170
  if (data.length === 1 && data[0] === 0x1c) {
149
171
  const now = Date.now();
@@ -212,6 +234,17 @@ function bridge(args, open) {
212
234
  if (typeof msg.data === "string")
213
235
  process.stdout.write(msg.data);
214
236
  }
237
+ else if (type === "terminal.closed" && msg.reason === "chat" && msg.termId === termId) {
238
+ resetModes();
239
+ termId = undefined;
240
+ process.stdout.write(c.dim("\r\n[continued as chat in the Bivy app]\r\n"));
241
+ if (typeof msg.sessionId !== "string" || !msg.sessionId) {
242
+ finish(0);
243
+ return;
244
+ }
245
+ handedToChat = msg.sessionId;
246
+ process.stdout.write(`Press ${c.bold("Enter")} to take it back here, or any other key to leave.\r\n`);
247
+ }
215
248
  else if ((type === "terminal.exit" || type === "terminal.closed") && msg.termId === termId) {
216
249
  const code = typeof msg.code === "number" ? msg.code : 0;
217
250
  resetModes();
@@ -220,7 +253,7 @@ function bridge(args, open) {
220
253
  }
221
254
  else if (type === "terminal.error") {
222
255
  process.stderr.write(c.red(`\r\n${String(msg.error || "Terminal error")}\r\n`));
223
- if (!bound)
256
+ if (!bound || !termId)
224
257
  finish(1);
225
258
  }
226
259
  });
@@ -247,7 +280,7 @@ function openDetached(args, spec, cols, rows) {
247
280
  catch { }
248
281
  resolve(code);
249
282
  };
250
- socket.on("open", () => socket.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows })));
283
+ socket.on("open", () => socket.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows, device: DEVICE })));
251
284
  socket.on("error", (err) => {
252
285
  process.stderr.write(c.red(`Connection error: ${err instanceof Error ? err.message : String(err)}\n`));
253
286
  finish(1);
@@ -283,7 +316,7 @@ async function main() {
283
316
  process.exit(await openDetached(args, spec, cols, rows));
284
317
  }
285
318
  process.stdout.write(c.dim(`Starting ${c.cyan(spec.name || spec.label || spec.agent || spec.command)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
286
- const code = await bridge(args, (ws) => ws.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows })));
319
+ const code = await bridge(args, (ws) => ws.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows, device: DEVICE })));
287
320
  process.exit(code);
288
321
  }
289
322
  // attach
@@ -297,7 +330,7 @@ async function main() {
297
330
  async function attachById(args, termId) {
298
331
  const { cols, rows } = termSize();
299
332
  process.stdout.write(c.dim(`Attaching to ${c.cyan(termId)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
300
- const code = await bridge({ ...args, termId }, (ws) => ws.send(JSON.stringify({ kind: "terminal.attach", termId, cols, rows })));
333
+ const code = await bridge({ ...args, termId }, (ws) => ws.send(JSON.stringify({ kind: "terminal.attach", termId, cols, rows, device: DEVICE })));
301
334
  process.exit(code);
302
335
  }
303
336
  main().catch((error) => {
@@ -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
  };
@@ -1,16 +1,29 @@
1
1
  export function createAppCommands(service, workspaceFor, published = () => { }) {
2
+ const workspaceOf = (sessionId) => {
3
+ const workspace = workspaceFor(sessionId);
4
+ if (!workspace)
5
+ throw new Error("Open the session on this machine before publishing an app.");
6
+ return workspace;
7
+ };
2
8
  const operations = {
3
- "apps.list": (msg) => service.list(String(msg.sessionId)),
9
+ "apps.list": (msg) => service.list(typeof msg.sessionId === "string" ? msg.sessionId : undefined),
4
10
  "apps.publish": (msg) => {
5
11
  const sessionId = String(msg.sessionId);
6
- const workspace = workspaceFor(sessionId);
7
- if (!workspace)
8
- throw new Error("Open the session on this machine before publishing an app.");
9
- const app = service.publish(sessionId, workspace, msg.manifest);
12
+ const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
13
+ published(app);
14
+ return { app };
15
+ },
16
+ "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
17
+ "apps.adopt": async (msg) => {
18
+ const sessionId = String(msg.sessionId);
19
+ const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
10
20
  published(app);
11
21
  return { app };
12
22
  },
13
- "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined),
23
+ "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined),
24
+ "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
+ "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
26
+ "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
14
27
  "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
15
28
  "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
16
29
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
@@ -18,7 +31,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
18
31
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
19
32
  try {
20
33
  ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
21
- if (kind === "apps.publish" || kind === "apps.remove")
34
+ if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
22
35
  ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
23
36
  }
24
37
  catch (error) {
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The machine-wide Artifacts index behind the sidebar's Artifacts page: every
5
+ // file an agent sent into a chat (`bivy attach` / `attach_to_chat`), across all
6
+ // of this machine's sessions. Built from the same event-log records attachment
7
+ // GC already sweeps, so there is no second index to keep in sync. Only what can
8
+ // still be opened is listed: the session exists and the blob is still stored.
9
+ /** Newest first, bounded like the per-session projection. */
10
+ export const MAX_MACHINE_ARTIFACTS = 500;
11
+ /** One entry per content hash — the newest send wins, so the same report sent
12
+ * to two sessions links to where it was sent last. */
13
+ export function listMachineArtifacts(deps) {
14
+ const byHash = new Map();
15
+ for (const sessionId of deps.sessionIds()) {
16
+ for (const record of deps.scan(sessionId)) {
17
+ if (record.bivyKind !== "outbound-attachment")
18
+ continue;
19
+ const { ref } = record;
20
+ const existing = byHash.get(ref.hash);
21
+ if (existing && existing.createdAt >= record.createdAt)
22
+ continue;
23
+ byHash.set(ref.hash, {
24
+ sessionId, hash: ref.hash, name: ref.name, mimeType: ref.mimeType, kind: ref.kind, size: ref.size,
25
+ ...(record.caption ? { caption: record.caption } : {}),
26
+ createdAt: record.createdAt,
27
+ artifact: Boolean(record.artifact || existing?.artifact),
28
+ });
29
+ }
30
+ }
31
+ return [...byHash.values()]
32
+ .filter((item) => deps.stored(item.hash))
33
+ .sort((a, b) => b.createdAt - a.createdAt)
34
+ .slice(0, MAX_MACHINE_ARTIFACTS);
35
+ }
36
+ export function createArtifactCommands(deps) {
37
+ return {
38
+ "artifacts.list"(msg, ctx) {
39
+ ctx.reply({ type: "artifacts.list.ok", requestId: msg.requestId, artifacts: listMachineArtifacts(deps) });
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,19 @@
1
+ import { deviceFrom } from "../session/presence.js";
2
+ /** Device handoff presence, shared by relay and HTTP: read a session's driver
3
+ * and carried-over draft, and publish this device's unsent draft. */
4
+ export function createPresenceCommands(book, publish) {
5
+ return {
6
+ "session.presence.get"(input, ctx) {
7
+ const requestId = typeof input.requestId === "string" ? input.requestId : undefined;
8
+ ctx.reply({ type: "session.presence.get.ok", requestId, presence: book.get(String(input.sessionId)) });
9
+ },
10
+ "session.presence.draft"(input, ctx) {
11
+ const requestId = typeof input.requestId === "string" ? input.requestId : undefined;
12
+ const device = deviceFrom(input.device);
13
+ if (!device)
14
+ return ctx.reply({ type: "session.presence.draft.error", httpStatus: 400, requestId, error: "Missing device" });
15
+ publish(book.draft(String(input.sessionId), device, String(input.text ?? "")));
16
+ ctx.reply({ type: "session.presence.draft.ok", requestId });
17
+ },
18
+ };
19
+ }
@@ -74,7 +74,7 @@ export function validateNodeConfig(value) {
74
74
  const node = section(root, "node", ["workspace", "port", "maxConcurrentAutomations", "capabilities"], errors);
75
75
  const defaults = section(root, "defaults", ["agent", "model", "sandbox", "approval"], errors);
76
76
  const safety = section(root, "safety", ["maxSandbox", "approvalFloor"], errors);
77
- const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
77
+ const sessions = section(root, "sessions", ["sync", "worktreeSync", "standbyNodeId", "resume", "autoAttachToolImages", "appScreenshots", "forkWorkspaceMaxBytes", "wedgedTurnMinutes"], errors);
78
78
  const github = section(root, "github", ["issuePrompt"], errors);
79
79
  const automation = section(root, "automation", ["checks", "checkTimeoutMinutes"], errors);
80
80
  const agentsRaw = section(root, "agents", Object.keys(record(root.agents) ?? {}), errors);
@@ -133,6 +133,7 @@ export function validateNodeConfig(value) {
133
133
  if (resume !== undefined && resume !== "auto" && resume !== "manual")
134
134
  errors.push("sessions.resume must be auto or manual");
135
135
  const autoAttachToolImages = optionalBoolean(sessions.autoAttachToolImages, "sessions.autoAttachToolImages", errors);
136
+ const appScreenshots = optionalBoolean(sessions.appScreenshots, "sessions.appScreenshots", errors);
136
137
  const forkWorkspaceMaxBytes = optionalInteger(sessions.forkWorkspaceMaxBytes, "sessions.forkWorkspaceMaxBytes", errors, 1_048_576, 1_073_741_824);
137
138
  // Upper bound is the wall-clock turn cap (60 min): a wedged window at/above it
138
139
  // would never fire before the cap. 0 disables the band.
@@ -216,7 +217,7 @@ export function validateNodeConfig(value) {
216
217
  ...(Object.keys(node).length ? { node: { workspace, port, maxConcurrentAutomations, capabilities } } : {}),
217
218
  ...(Object.keys(defaults).length ? { defaults: { agent, model, sandbox, approval } } : {}),
218
219
  ...(Object.keys(safety).length ? { safety: { maxSandbox, approvalFloor } } : {}),
219
- ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
+ ...(Object.keys(sessions).length ? { sessions: { sync, worktreeSync, standbyNodeId, resume, autoAttachToolImages, appScreenshots, forkWorkspaceMaxBytes, wedgedTurnMinutes } } : {}),
220
221
  ...(Object.keys(github).length ? { github: { issuePrompt } } : {}),
221
222
  ...(Object.keys(automation).length ? { automation: { checks, checkTimeoutMinutes } } : {}),
222
223
  ...(Object.keys(agents).length ? { agents } : {}),
@@ -272,6 +273,7 @@ export function configToLegacySettings(config) {
272
273
  ...(config.sessions?.standbyNodeId ? { syncStandbyNodeId: config.sessions.standbyNodeId } : {}),
273
274
  ...(config.sessions?.resume ? { sessionResumeMode: config.sessions.resume } : {}),
274
275
  ...(config.sessions?.autoAttachToolImages !== undefined ? { autoAttachToolImages: config.sessions.autoAttachToolImages } : {}),
276
+ ...(config.sessions?.appScreenshots !== undefined ? { appScreenshots: config.sessions.appScreenshots } : {}),
275
277
  ...(config.sessions?.forkWorkspaceMaxBytes !== undefined ? { forkWorkspaceMaxBytes: config.sessions.forkWorkspaceMaxBytes } : {}),
276
278
  };
277
279
  }
@@ -335,6 +337,7 @@ export function mergeLegacyIntoNodeConfig(cli, settings) {
335
337
  standbyNodeId: typeof settings.syncStandbyNodeId === "string" ? settings.syncStandbyNodeId : undefined,
336
338
  resume: settings.sessionResumeMode === "manual" ? "manual" : "auto",
337
339
  autoAttachToolImages: settings.autoAttachToolImages === true,
340
+ appScreenshots: settings.appScreenshots === true,
338
341
  forkWorkspaceMaxBytes: Number.isInteger(settings.forkWorkspaceMaxBytes) ? Number(settings.forkWorkspaceMaxBytes) : undefined,
339
342
  },
340
343
  github: { issuePrompt: typeof settings.githubIssuePrompt === "string" ? settings.githubIssuePrompt : undefined },
@@ -361,7 +364,7 @@ export function setConfigValue(config, dotted, value) {
361
364
  "node.workspace", "node.port", "node.maxConcurrentAutomations", "node.capabilities",
362
365
  "defaults.agent", "defaults.model", "defaults.sandbox", "defaults.approval",
363
366
  "safety.maxSandbox", "safety.approvalFloor",
364
- "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
367
+ "sessions.sync", "sessions.worktreeSync", "sessions.standbyNodeId", "sessions.resume", "sessions.autoAttachToolImages", "sessions.appScreenshots", "sessions.forkWorkspaceMaxBytes", "sessions.wedgedTurnMinutes",
365
368
  "github.issuePrompt", "automation.checks", "automation.checkTimeoutMinutes",
366
369
  ]);
367
370
  if (!allowed.has(dotted) && !/^agents\.[a-z][a-z0-9-]{1,47}$/.test(dotted) && !/^environment\.[A-Z][A-Z0-9_]+$/.test(dotted))
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Session limit recovery — the user-driven counterpart to the policy's automatic
5
+ // resume (session-reroute.ts planResume).
6
+ //
7
+ // When a turn ends because the agent hit a provider usage/rate limit and no
8
+ // ruleset recovered it automatically, the session shouldn't just show a dead
9
+ // error. This decides whether the failure IS such a limit, and what the user can
10
+ // do about it: fork the work to another agent right away, or opt in to retrying
11
+ // the same prompt automatically once the window resets. The caller (server.ts)
12
+ // surfaces the offer as a notice with action buttons and, when the user accepts
13
+ // the retry, schedules it through the same durable resume machinery the
14
+ // automatic path uses.
15
+ import { classifyFailure } from "./conditions.js";
16
+ /** The conditions a user can route around (fork) or wait out (retry at reset),
17
+ * with how the notice describes each. Adding a limit-like condition is a row. */
18
+ const LIMIT_COPY = {
19
+ credits_exhausted: "reached its usage limit",
20
+ rate_limited: "is being rate-limited",
21
+ };
22
+ /** Retry a little after the stated reset: providers round reset times and a
23
+ * re-send that lands a few seconds early just hits the same limit again. */
24
+ const RESET_GRACE_MS = 60_000;
25
+ /** Classify a turn error; returns the limit it represents, or undefined when it
26
+ * is some other failure. `resetsAtHint` is the provider's structured reset time
27
+ * (see ClassifyOptions) and wins over anything scraped from the text. */
28
+ export function detectSessionLimit(error, opts = {}) {
29
+ const now = opts.now ?? Date.now();
30
+ const classified = classifyFailure(error, { now, resetsAtHint: opts.resetsAtHint });
31
+ if (!LIMIT_COPY[classified.condition])
32
+ return undefined;
33
+ const resetsAt = classified.resetsAt
34
+ ?? (classified.retryAfterMs !== undefined ? new Date(now + classified.retryAfterMs).toISOString() : undefined);
35
+ return { condition: classified.condition, ...(resetsAt ? { resetsAt } : {}) };
36
+ }
37
+ /** The notice offering the user a way past the limit. Actions are client
38
+ * action ids: `fork` opens the fork sheet; `retry-at-reset:<iso>` opts in to an
39
+ * automatic retry (the timestamp is only for the button's label — the daemon
40
+ * schedules from its own remembered limit). */
41
+ export function sessionLimitNotice(agentName, limit) {
42
+ const what = `${agentName} ${LIMIT_COPY[limit.condition] ?? "hit a limit"}.`;
43
+ return limit.resetsAt
44
+ ? {
45
+ message: `${what} Fork to another agent to keep going, or retry automatically when the limit resets.`,
46
+ actions: ["fork", `retry-at-reset:${limit.resetsAt}`],
47
+ }
48
+ : { message: `${what} Fork to another agent to keep going.`, actions: ["fork"] };
49
+ }
50
+ /** A resume plan for a user who opted in to retrying at reset. Undefined when
51
+ * the reset time is unknown. A reset already in the past retries shortly. */
52
+ export function limitResumePlan(limit, now = Date.now()) {
53
+ const reset = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN;
54
+ if (!Number.isFinite(reset))
55
+ return undefined;
56
+ const dueMs = Math.max(reset, now) + RESET_GRACE_MS;
57
+ return {
58
+ condition: limit.condition,
59
+ summary: `${limit.condition}: retrying when the limit resets at ${limit.resetsAt}.`,
60
+ delayMs: dueMs - now,
61
+ resumeAt: new Date(dueMs).toISOString(),
62
+ };
63
+ }
@@ -3,10 +3,12 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "open", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
+ { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
7
8
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
9
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
10
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
11
+ ...["get", "draft"].map((action) => ({ method: "post", path: `/api/session/presence/${action}`, kind: `session.presence.${action}` })),
10
12
  { method: "post", path: "/api/session/resume", kind: "session.resume" },
11
13
  { method: "post", path: "/api/session/question/answer", kind: "session.question.answer" },
12
14
  ];