@bivy/bivy 0.17.3-staging.11 → 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.
- package/dist/apps/styles.css +5 -0
- package/dist/attach.js +7 -3
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +1 -0
- package/dist/protocol/client-command-schemas.js +2 -0
- package/dist/server.js +27 -0
- package/dist/session/presence.js +82 -0
- package/package.json +1 -1
package/dist/apps/styles.css
CHANGED
|
@@ -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