@bivy/bivy 0.17.3-staging.10 → 0.17.3-staging.12

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.
@@ -3201,3 +3201,8 @@ select.picker-search { appearance: auto; }
3201
3201
  .automation-history-view .autom-section-head { align-items: flex-start; gap: var(--space-3); }
3202
3202
  .automation-history-view .autom-section-head > strong { min-width: 0; line-height: var(--lh-lg); }
3203
3203
  }
3204
+
3205
+ /* Device handoff (HandoffBanner): sits above the composer like the composer
3206
+ itself, with one line of the other device's draft. */
3207
+ .handoff-banner { border-radius: var(--radius-md); }
3208
+ .handoff-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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;
@@ -276,7 +280,7 @@ function openDetached(args, spec, cols, rows) {
276
280
  catch { }
277
281
  resolve(code);
278
282
  };
279
- 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 })));
280
284
  socket.on("error", (err) => {
281
285
  process.stderr.write(c.red(`Connection error: ${err instanceof Error ? err.message : String(err)}\n`));
282
286
  finish(1);
@@ -312,7 +316,7 @@ async function main() {
312
316
  process.exit(await openDetached(args, spec, cols, rows));
313
317
  }
314
318
  process.stdout.write(c.dim(`Starting ${c.cyan(spec.name || spec.label || spec.agent || spec.command)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
315
- 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 })));
316
320
  process.exit(code);
317
321
  }
318
322
  // attach
@@ -326,7 +330,7 @@ async function main() {
326
330
  async function attachById(args, termId) {
327
331
  const { cols, rows } = termSize();
328
332
  process.stdout.write(c.dim(`Attaching to ${c.cyan(termId)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
329
- 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 })));
330
334
  process.exit(code);
331
335
  }
332
336
  main().catch((error) => {
@@ -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
+ }
@@ -7,6 +7,7 @@ export const CLIENT_COMMAND_ROUTES = [
7
7
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
8
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
9
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
10
+ ...["get", "draft"].map((action) => ({ method: "post", path: `/api/session/presence/${action}`, kind: `session.presence.${action}` })),
10
11
  { method: "post", path: "/api/session/resume", kind: "session.resume" },
11
12
  { method: "post", path: "/api/session/question/answer", kind: "session.question.answer" },
12
13
  ];
@@ -28,6 +28,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
28
28
  }),
29
29
  "attachment.fetch": Type.Object({ ...request, hash: Type.String() }),
30
30
  "session.pause": Type.Object(session),
31
+ "session.presence.get": Type.Object({ ...request, ...session }),
32
+ "session.presence.draft": Type.Object({ ...request, ...session, device: Type.Object({ id: Type.String({ maxLength: 100 }), label: Type.Optional(Type.String({ maxLength: 200 })) }), text: Type.String({ maxLength: 20_000 }) }),
31
33
  "session.resume": Type.Object(session),
32
34
  "session.question.answer": Type.Object({ ...optionalSession, requestId: Type.String(), answers: Type.Optional(Type.Record(Type.String(), Type.String())) }),
33
35
  "session.replay": Type.Object({ ...session, afterSeq: Type.Optional(Type.Number()) }),
package/dist/server.js CHANGED
@@ -114,6 +114,8 @@ import { listInstalledPlugins } from "./plugins/store.js";
114
114
  import { createCapabilitiesController } from "./controllers/capabilities.js";
115
115
  import { createAccessDeviceController, createLinkedDeviceController } from "./controllers/devices.js";
116
116
  import { createSessionControlCommands } from "./controllers/session-control.js";
117
+ import { createPresenceCommands } from "./controllers/presence-commands.js";
118
+ import { PresenceBook, deviceFrom } from "./session/presence.js";
117
119
  import { createForkCommands } from "./controllers/fork-commands.js";
118
120
  import { createGithubCommands } from "./controllers/github-commands.js";
119
121
  import { createCredentialCommands } from "./controllers/credential-commands.js";
@@ -1676,6 +1678,19 @@ const sessionEvents = new SessionEventCoalescer({
1676
1678
  function broadcastTuiState(sessionId, active) {
1677
1679
  broadcast({ type: "terminal.tui", sessionId, active });
1678
1680
  }
1681
+ // Device handoff (src/session/presence.ts): which device last drove each
1682
+ // session and the draft it left, so the next device can pick up from there.
1683
+ const presenceBook = new PresenceBook();
1684
+ function publishPresence(presence) {
1685
+ broadcast({ type: "session.presence", presence });
1686
+ }
1687
+ function notePresence(sessionId, device, via) {
1688
+ if (!sessionId || !device)
1689
+ return;
1690
+ const changed = presenceBook.drove(sessionId, device, via);
1691
+ if (changed)
1692
+ publishPresence(changed);
1693
+ }
1679
1694
  // Terminals opened by relay clients (phone/web over the relay). Output is emitted
1680
1695
  // via the relay tagged with termId; clients filter by it. (Per-client unicast
1681
1696
  // is a known future hardening step.)
@@ -2000,6 +2015,7 @@ const RELAY_COMMANDS = {
2000
2015
  const meta = attachmentStore.readMeta(hash);
2001
2016
  ctx.reply({ type: "attachment.data", requestId, hash, mimeType: meta?.mimeType ?? "application/octet-stream", name: meta?.name, data: bytes.toString("base64") });
2002
2017
  },
2018
+ ...createPresenceCommands(presenceBook, publishPresence),
2003
2019
  ...createSessionControlCommands({
2004
2020
  resolve: (sessionId) => resolveSession(sessionId),
2005
2021
  pause: pauseSession,
@@ -2910,6 +2926,7 @@ const RELAY_COMMANDS = {
2910
2926
  broadcast({ type: "session.error", sessionId: record.id, error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
2911
2927
  return;
2912
2928
  }
2929
+ notePresence(record.id, deviceFrom(msg.device), "chat");
2913
2930
  touchSession(record);
2914
2931
  // Now that the session (and its workdir) exists, write file attachments to
2915
2932
  // disk and fold their path notes into the prompt the agent actually sees.
@@ -3052,6 +3069,8 @@ async function handleRelayMessage(msg) {
3052
3069
  // Fallthrough for kinds not in RELAY_COMMANDS: terminal.* frames go to the
3053
3070
  // PTY manager; anything else is an unknown client message.
3054
3071
  if (typeof msg.kind === "string" && msg.kind.startsWith("terminal.")) {
3072
+ if (msg.kind === "terminal.attach")
3073
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, deviceFrom(msg.device), "terminal");
3055
3074
  // The relay is a single tunnel with no per-remote-client identity at this
3056
3075
  // layer, so every relay-tunneled client shares one size slot. That still
3057
3076
  // keeps them distinct from each local socket, so a PTY shared between a
@@ -11132,6 +11151,7 @@ app.post("/api/session/prompt", async (req, res, next) => {
11132
11151
  if (record.tuiTermId || record.tuiRefreshing) {
11133
11152
  return res.status(409).json({ error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
11134
11153
  }
11154
+ notePresence(record.id, deviceFrom(req.body?.device), "chat");
11135
11155
  const session = record.session;
11136
11156
  const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
11137
11157
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
@@ -11466,6 +11486,7 @@ wss.on("connection", (socket, req) => {
11466
11486
  // Stable id for this socket, keying its per-terminal size so several clients
11467
11487
  // sharing a PTY size it to their min (see TerminalManager.setClientSize).
11468
11488
  const clientTerminalId = `sock-${randomUUID()}`;
11489
+ let socketDevice;
11469
11490
  socket.send(JSON.stringify({ type: "hello", activeSessionId: active?.id, activeSession: active ? { id: active.id, isStreaming: sessionBusy(active), sessionState: sessionState(active), lastActivity: active.lastActivity, workingStartedAt: active.workingStartedAt } : null }));
11470
11491
  // Authoritative version status on every connect: `latest` set means this node
11471
11492
  // is behind (banner shows); absent means up to date (banner + any "Updating…"
@@ -11490,6 +11511,12 @@ wss.on("connection", (socket, req) => {
11490
11511
  return;
11491
11512
  }
11492
11513
  if (typeof msg?.kind === "string" && msg.kind.startsWith("terminal.")) {
11514
+ // A terminal client (`bivy run`/`bivy resume`) names its device once; typing
11515
+ // into a session's terminal then makes it that session's driver.
11516
+ socketDevice = deviceFrom(msg.device) ?? socketDevice;
11517
+ if (msg.kind === "terminal.input" || msg.kind === "terminal.attach") {
11518
+ notePresence(terminals.meta(String(msg.termId ?? ""))?.sessionId, socketDevice, "terminal");
11519
+ }
11493
11520
  runTerms.handleTerminalMessage(msg, (event) => {
11494
11521
  if (socket.readyState === WebSocket.OPEN)
11495
11522
  socket.send(JSON.stringify(event));
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Which device is driving a session, and the draft it left behind. This is a
5
+ // soft lease, not a lock. Nothing is ever refused because another device drove
6
+ // last; it only lets the next device say "you were on your Mac a minute ago" and
7
+ // carry the unsent draft over. Devices name themselves (the room key already
8
+ // trusts every linked device, so this is presentation, not authentication).
9
+ // In-memory: a handoff is minutes-scale, and a node restart is a fine reset.
10
+ /** Longest device label kept; they're shown in a one-line strip. */
11
+ const LABEL_MAX = 60;
12
+ /** Longest draft carried between devices. */
13
+ const DRAFT_MAX = 20_000;
14
+ /** A driver that keeps typing re-announces at most this often. */
15
+ const REANNOUNCE_MS = 30_000;
16
+ /** Presence older than this is forgotten. */
17
+ const TTL_MS = 6 * 60 * 60 * 1000;
18
+ /** A device reference from untrusted client input, or undefined. */
19
+ export function deviceFrom(value) {
20
+ if (!value || typeof value !== "object")
21
+ return undefined;
22
+ const { id, label } = value;
23
+ if (typeof id !== "string" || !id.trim() || id.length > 100)
24
+ return undefined;
25
+ const name = typeof label === "string" && label.trim() ? label.trim().slice(0, LABEL_MAX) : "Another device";
26
+ return { id: id.trim(), label: name };
27
+ }
28
+ export class PresenceBook {
29
+ now;
30
+ sessions = new Map();
31
+ constructor(now = Date.now) {
32
+ this.now = now;
33
+ }
34
+ get(sessionId) {
35
+ this.expire();
36
+ return this.sessions.get(sessionId) ?? { sessionId };
37
+ }
38
+ /** A device sent input to the session. Returns the presence to broadcast when
39
+ * the driver changed (or is due a re-announce), else undefined. A chat send
40
+ * also consumes that device's draft, since the draft has now been sent. */
41
+ drove(sessionId, device, via) {
42
+ const at = this.now();
43
+ const entry = this.entry(sessionId);
44
+ const previous = entry.driver;
45
+ entry.driver = { ...device, via, at };
46
+ const sentDraft = via === "chat" && entry.draft?.device.id === device.id;
47
+ if (sentDraft)
48
+ entry.draft = undefined;
49
+ const changed = !previous || previous.id !== device.id || previous.via !== via || at - previous.at >= REANNOUNCE_MS;
50
+ return changed || sentDraft ? { ...entry } : undefined;
51
+ }
52
+ /** A device's unsent draft for the session (empty text clears it). */
53
+ draft(sessionId, device, text) {
54
+ const entry = this.entry(sessionId);
55
+ const trimmed = text.slice(0, DRAFT_MAX);
56
+ if (trimmed.trim())
57
+ entry.draft = { device, text: trimmed, at: this.now() };
58
+ else if (entry.draft?.device.id === device.id)
59
+ entry.draft = undefined;
60
+ return { ...entry };
61
+ }
62
+ entry(sessionId) {
63
+ this.expire();
64
+ let entry = this.sessions.get(sessionId);
65
+ if (!entry) {
66
+ entry = { sessionId };
67
+ this.sessions.set(sessionId, entry);
68
+ }
69
+ return entry;
70
+ }
71
+ expire() {
72
+ const cutoff = this.now() - TTL_MS;
73
+ for (const [id, entry] of this.sessions) {
74
+ if (entry.driver && entry.driver.at < cutoff)
75
+ entry.driver = undefined;
76
+ if (entry.draft && entry.draft.at < cutoff)
77
+ entry.draft = undefined;
78
+ if (!entry.driver && !entry.draft)
79
+ this.sessions.delete(id);
80
+ }
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.10",
3
+ "version": "0.17.3-staging.12",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",