@bivy/bivy 0.3.0-staging.47 → 0.3.0-staging.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bivy.mjs CHANGED
@@ -1528,7 +1528,7 @@ function cmdCompletions(args = []) {
1528
1528
  const shell = (args[0] || "").toLowerCase();
1529
1529
  const commands = [
1530
1530
  "run", "sessions", "ls", "resume", "promote", "rename", "nodes", "agents", "agents:install", "shim", "takeover", "token", "exec",
1531
- "send", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
1531
+ "send", "attach", "kill", "setup", "start", "stop", "restart", "status", "doctor", "logs", "login",
1532
1532
  "update", "update:log", "open", "service", "secrets", "voice", "link", "relay:setup",
1533
1533
  "github:connect", "github:app-create", "github:app-connect", "github:app-sync", "prune", "uninstall", "help", "version",
1534
1534
  ];
@@ -1974,6 +1974,62 @@ async function cmdSend(args = []) {
1974
1974
  process.exit(code);
1975
1975
  }
1976
1976
 
1977
+ // `bivy attach <file> [--caption "…"] [--session <id>]` — surface a file the
1978
+ // agent produced into the chat as an image/file attachment (the reverse of the
1979
+ // composer paperclip). The universal path: any agent that can run a shell command
1980
+ // can call this. The session id defaults to $BIVY_SESSION_ID, which the daemon
1981
+ // injects into the agent's subprocess env. The file is resolved to an absolute
1982
+ // path here (the CLI's cwd is the agent's workdir) and confined to the session
1983
+ // workspace server-side.
1984
+ async function cmdAttach(args = []) {
1985
+ const flag = (name) => {
1986
+ const i = args.indexOf(name);
1987
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
1988
+ };
1989
+ const sessionId = flag("--session") || process.env.BIVY_SESSION_ID;
1990
+ const caption = flag("--caption");
1991
+ const name = flag("--name");
1992
+ const mimeType = flag("--mime") || flag("--mimeType");
1993
+ const flagsWithValue = new Set(["--session", "--caption", "--name", "--mime", "--mimeType"]);
1994
+ // First positional that isn't a flag or a flag's value.
1995
+ let file;
1996
+ for (let i = 0; i < args.length; i++) {
1997
+ const a = args[i];
1998
+ if (a.startsWith("-")) { if (flagsWithValue.has(a)) i++; continue; }
1999
+ if (i > 0 && flagsWithValue.has(args[i - 1])) continue;
2000
+ file = a;
2001
+ break;
2002
+ }
2003
+ if (!file) { console.error(c.red('Usage: bivy attach <file> [--caption "…"] [--session <id>]')); process.exit(1); return; }
2004
+ if (!sessionId) { console.error(c.red("No session id. Set --session <id> or run inside an agent session ($BIVY_SESSION_ID).")); process.exit(1); return; }
2005
+ const absPath = path.resolve(process.cwd(), file);
2006
+ if (!fs.existsSync(absPath)) { console.error(c.red(`File not found: ${file}`)); process.exit(1); return; }
2007
+
2008
+ const config = loadConfig();
2009
+ if (!(await ensureNodeRunning(config))) { console.error(c.red(`Could not reach the Bivy node at ${url(config)}.`)); process.exit(1); return; }
2010
+ // A token isn't required on a single-user host (loopback bypasses auth), but
2011
+ // include it when available so multi-user hosts work too.
2012
+ let token;
2013
+ try { token = await localDeviceToken(config); } catch { token = undefined; }
2014
+ const headers = { "content-type": "application/json" };
2015
+ if (token) headers.authorization = `Bearer ${token}`;
2016
+ let res;
2017
+ try {
2018
+ res = await fetch(`${url(config)}/api/session/${encodeURIComponent(sessionId)}/attach`, {
2019
+ method: "POST",
2020
+ headers,
2021
+ body: JSON.stringify({ path: absPath, caption, name, mimeType }),
2022
+ });
2023
+ } catch (error) {
2024
+ console.error(c.red(`Could not reach the Bivy node: ${error?.message || String(error)}`));
2025
+ process.exit(1);
2026
+ return;
2027
+ }
2028
+ const body = await res.json().catch(() => ({}));
2029
+ if (!res.ok) { console.error(c.red(`Attach failed (${res.status}): ${body?.error || "unknown error"}`)); process.exit(1); return; }
2030
+ console.log(c.green(`Attached ${body.name} (${body.kind}, ${body.size} bytes) to the chat.`));
2031
+ }
2032
+
1977
2033
  // Map a saved session's runtime id to the `bivy run` agent whose native CLI can
1978
2034
  // resume it in a terminal. Only agents with a real native resume qualify; other
1979
2035
  // runtimes (generic-cli, SDK-only) have no terminal resume and open in the web app.
@@ -4128,6 +4184,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4128
4184
  case "send":
4129
4185
  await cmdSend(args);
4130
4186
  break;
4187
+ case "attach":
4188
+ await cmdAttach(args);
4189
+ break;
4131
4190
  case "completions":
4132
4191
  case "completion":
4133
4192
  cmdCompletions(args);
@@ -671,6 +671,11 @@ class ClaudeSession {
671
671
  const env = { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env };
672
672
  const credEnv = await this.resolveCredentialEnv();
673
673
  Object.assign(env, credEnv);
674
+ // Let the agent's own shell surface a file into the chat via `bivy attach`
675
+ // (POST /api/session/:id/attach). The session id is otherwise invisible to
676
+ // the subprocess. Other runtimes should set this the same way to enable the
677
+ // universal attach path for their agents.
678
+ env.BIVY_SESSION_ID = this.id;
674
679
  this.spawnedToken = authTokenFromEnv(credEnv);
675
680
  const options = {
676
681
  cwd: this.cwd,
package/dist/server.js CHANGED
@@ -71,6 +71,7 @@ import { normalizeMessages } from "./session/transcript-normal.js";
71
71
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
72
72
  import { EventLog } from "./session/event-log.js";
73
73
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
74
+ import { planAttachment, isAttachPlanError } from "./session/attach-to-chat.js";
74
75
  import { ReplicationService } from "./session/replication-service.js";
75
76
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
76
77
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
@@ -1032,6 +1033,40 @@ function materializeAttachments(record, files) {
1032
1033
  }
1033
1034
  return { note: notes.join("\n"), refs };
1034
1035
  }
1036
+ /**
1037
+ * Surface an AGENT-produced file into the chat as an attachment (image or file)
1038
+ * — the reverse of the composer paperclip. Confines to the session workspace,
1039
+ * stores the bytes in the content-addressed AttachmentStore, persists a durable
1040
+ * outbound reference anchored at the current transcript position (so a reload or
1041
+ * another device shows it), and emits the live `attachment` event so attached
1042
+ * devices render the chip/thumbnail immediately. Shared by the HTTP endpoint and
1043
+ * the `bivy attach` CLI. Returns the stored ref, or a human-readable error.
1044
+ */
1045
+ function attachToChat(record, opts) {
1046
+ const plan = planAttachment({
1047
+ workspaceDir: harnessDirFor(record),
1048
+ filePath: opts.filePath,
1049
+ mimeType: opts.mimeType,
1050
+ name: opts.name,
1051
+ });
1052
+ if (isAttachPlanError(plan))
1053
+ return { error: plan.error };
1054
+ let ref;
1055
+ try {
1056
+ ref = attachmentStore.put(plan.bytes, { name: plan.name, mimeType: plan.mimeType, kind: plan.kind });
1057
+ }
1058
+ catch (error) {
1059
+ return { error: `Could not store the attachment: ${error instanceof Error ? error.message : String(error)}` };
1060
+ }
1061
+ const entryId = `att-${randomBytes(8).toString("hex")}`;
1062
+ const caption = opts.caption ? String(opts.caption).slice(0, 2000) : undefined;
1063
+ // Anchor at the current base length so history replay interleaves the
1064
+ // attachment where it was emitted (see event-log outbound projection).
1065
+ const afterMessageCount = record.session.getMessages().length;
1066
+ eventLog.appendOutboundAttachment(record.id, { afterMessageCount, id: entryId, ref, caption });
1067
+ broadcast({ type: "session.event", sessionId: record.id, event: { type: "attachment", id: entryId, ref, caption } });
1068
+ return { ref };
1069
+ }
1035
1070
  function approvalModeFrom(value) {
1036
1071
  return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
1037
1072
  }
@@ -9523,6 +9558,29 @@ app.get("/api/attachment/:hash", (req, res) => {
9523
9558
  res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
9524
9559
  res.end(bytes);
9525
9560
  });
9561
+ // Let an AGENT push a file into the chat as an attachment (image/file) — the
9562
+ // reverse of the composer upload. Called by the agent's own shell (`bivy attach`)
9563
+ // or any local tool; on a single-user host the loopback bypass means no token is
9564
+ // needed. Behind /api's authMiddleware. `path` is resolved inside — and confined
9565
+ // to — the session's workspace (see planAttachment's security note).
9566
+ app.post("/api/session/:id/attach", (req, res) => {
9567
+ const record = openSessions.get(String(req.params.id));
9568
+ if (!record)
9569
+ return res.status(404).json({ error: "Session not found" });
9570
+ const filePath = String(req.body?.path ?? req.body?.filePath ?? "").trim();
9571
+ if (!filePath)
9572
+ return res.status(400).json({ error: "Missing file path" });
9573
+ const result = attachToChat(record, {
9574
+ filePath,
9575
+ caption: typeof req.body?.caption === "string" ? req.body.caption : undefined,
9576
+ mimeType: typeof req.body?.mimeType === "string" ? req.body.mimeType : undefined,
9577
+ name: typeof req.body?.name === "string" ? req.body.name : undefined,
9578
+ });
9579
+ if ("error" in result)
9580
+ return res.status(400).json({ error: result.error });
9581
+ const { hash, name, mimeType, size, kind } = result.ref;
9582
+ res.json({ ok: true, hash, name, mimeType, size, kind });
9583
+ });
9526
9584
  app.post("/api/session/prompt", async (req, res, next) => {
9527
9585
  try {
9528
9586
  const text = String(req.body?.text ?? "").trim();
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Plan an AGENT-sent chat attachment: the reverse of the composer paperclip.
5
+ // An agent points at a file it produced in the workspace (a rendered chart, a
6
+ // screenshot, a report), and Bivy surfaces it into the chat as an image/file
7
+ // chip. This module is the PURE, testable half — path confinement, size cap, and
8
+ // mime/kind classification — returning bytes + metadata (or a human-readable
9
+ // error). The server half stores the bytes in the content-addressed
10
+ // AttachmentStore, emits the live `attachment` event, and persists the outbound
11
+ // reference for durable history.
12
+ //
13
+ // Security posture: the resolved file MUST live inside the session's working
14
+ // directory. An agent is a semi-trusted process; without confinement, a prompt
15
+ // injection could turn "attach a file to the chat" into "exfiltrate /etc/passwd
16
+ // (or ~/.ssh/id_rsa) to the user's phone". Symlinks are resolved before the
17
+ // check so a symlink inside the workspace can't point out of it.
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ /** Ceiling for a single agent attachment. Kept comfortably under the relay's
21
+ * 32 MiB reassembly limit (see packages/core/src/wire-format.ts) so a large
22
+ * attachment still travels to a phone over the encrypted relay in chunks. */
23
+ export const MAX_AGENT_ATTACHMENT_BYTES = 25 * 1024 * 1024;
24
+ export function isAttachPlanError(value) {
25
+ return typeof value.error === "string";
26
+ }
27
+ const EXT_MIME = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".svg": "image/svg+xml",
34
+ ".bmp": "image/bmp",
35
+ ".ico": "image/x-icon",
36
+ ".avif": "image/avif",
37
+ ".pdf": "application/pdf",
38
+ ".txt": "text/plain",
39
+ ".md": "text/markdown",
40
+ ".csv": "text/csv",
41
+ ".json": "application/json",
42
+ ".html": "text/html",
43
+ ".zip": "application/zip",
44
+ };
45
+ /** Sniff a mime type from the leading magic bytes for the common image/PDF
46
+ * formats, so a mislabeled or extension-less file still classifies correctly.
47
+ * Returns "" when nothing matches (caller falls back to extension/default). */
48
+ export function sniffMime(bytes) {
49
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
50
+ return "image/png";
51
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
52
+ return "image/jpeg";
53
+ if (bytes.length >= 6 && (bytes.subarray(0, 6).toString("latin1") === "GIF87a" || bytes.subarray(0, 6).toString("latin1") === "GIF89a"))
54
+ return "image/gif";
55
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP")
56
+ return "image/webp";
57
+ if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-")
58
+ return "application/pdf";
59
+ return "";
60
+ }
61
+ /** Strip directory components and control/path characters from a filename so it
62
+ * is safe to show and to store as an attachment display name. */
63
+ export function sanitizeAttachmentName(name) {
64
+ const base = path.basename(String(name || "").trim());
65
+ const cleaned = base
66
+ .replace(/[/\\]+/g, "_")
67
+ // eslint-disable-next-line no-control-regex
68
+ .replace(/[\x00-\x1f]+/g, "")
69
+ .trim();
70
+ return cleaned.slice(0, 200) || "attachment";
71
+ }
72
+ /**
73
+ * Resolve, confine, size-check, read, and classify a file the agent asked to
74
+ * attach. `filePath` may be absolute or relative to `workspaceDir`; either way
75
+ * the resolved real path must sit inside `workspaceDir`.
76
+ */
77
+ export function planAttachment(opts) {
78
+ const raw = String(opts.filePath || "").trim();
79
+ if (!raw)
80
+ return { error: "No file path given." };
81
+ const workspaceDir = path.resolve(opts.workspaceDir);
82
+ const resolved = path.resolve(workspaceDir, raw);
83
+ // Confinement, symlink-safe: resolve the real path of the file before comparing
84
+ // against the real workspace root. realpathSync also fails cleanly for a missing
85
+ // file.
86
+ let realFile;
87
+ let realRoot;
88
+ try {
89
+ realRoot = fs.realpathSync(workspaceDir);
90
+ }
91
+ catch {
92
+ return { error: "Workspace directory is unavailable." };
93
+ }
94
+ try {
95
+ realFile = fs.realpathSync(resolved);
96
+ }
97
+ catch {
98
+ return { error: `File not found: ${raw}` };
99
+ }
100
+ const rel = path.relative(realRoot, realFile);
101
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
102
+ return { error: "Refusing to attach a file outside the session workspace." };
103
+ }
104
+ let stat;
105
+ try {
106
+ stat = fs.statSync(realFile);
107
+ }
108
+ catch {
109
+ return { error: `File not found: ${raw}` };
110
+ }
111
+ if (stat.isDirectory())
112
+ return { error: `Not a file: ${raw}` };
113
+ const maxBytes = opts.maxBytes ?? MAX_AGENT_ATTACHMENT_BYTES;
114
+ if (stat.size > maxBytes) {
115
+ return { error: `File is too large to attach (${stat.size} bytes; limit ${maxBytes}).` };
116
+ }
117
+ if (stat.size === 0)
118
+ return { error: "Refusing to attach an empty file." };
119
+ let bytes;
120
+ try {
121
+ bytes = fs.readFileSync(realFile);
122
+ }
123
+ catch {
124
+ return { error: `Could not read file: ${raw}` };
125
+ }
126
+ const ext = path.extname(realFile).toLowerCase();
127
+ const mimeType = (opts.mimeType && String(opts.mimeType).trim()) ||
128
+ sniffMime(bytes) ||
129
+ EXT_MIME[ext] ||
130
+ "application/octet-stream";
131
+ const kind = mimeType.startsWith("image/") ? "image" : "file";
132
+ const name = sanitizeAttachmentName(opts.name || path.basename(realFile));
133
+ return { bytes, name, mimeType, kind };
134
+ }
@@ -28,6 +28,10 @@
28
28
  // interleaved in any order on disk; each replay reads only its own kind.
29
29
  import fs from "node:fs";
30
30
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
31
+ /** Content-block type carried by a folded outbound attachment. MUST match
32
+ * `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
33
+ * renderHistory keys on this exact string to render the chip. */
34
+ const AGENT_ATTACHMENT_BLOCK = "bivy_attachment";
31
35
  function isOverlay(value) {
32
36
  if (!value || typeof value !== "object")
33
37
  return false;
@@ -46,8 +50,19 @@ function isAttachment(value) {
46
50
  const record = value;
47
51
  return record.bivyKind === "attachment" && typeof record.text === "string" && Array.isArray(record.refs);
48
52
  }
53
+ function isOutboundAttachment(value) {
54
+ if (!value || typeof value !== "object")
55
+ return false;
56
+ const record = value;
57
+ return (record.bivyKind === "outbound-attachment" &&
58
+ typeof record.afterMessageCount === "number" &&
59
+ typeof record.id === "string" &&
60
+ !!record.ref &&
61
+ typeof record.ref === "object" &&
62
+ typeof record.ref.hash === "string");
63
+ }
49
64
  function isRecord(value) {
50
- return isOverlay(value) || isBase(value) || isAttachment(value);
65
+ return isOverlay(value) || isBase(value) || isAttachment(value) || isOutboundAttachment(value);
51
66
  }
52
67
  /**
53
68
  * Fold attachment records into a text→refs list: last write wins per text (a
@@ -132,7 +147,32 @@ export function replayExtras(entries) {
132
147
  else if (entry.bivyKind === "tool")
133
148
  tool.push(entry);
134
149
  }
135
- return [...foldIntermediate(intermediate), ...foldTool(tool)];
150
+ return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries)];
151
+ }
152
+ /**
153
+ * Fold the outbound (agent-sent) attachment records into time-anchored synthetic
154
+ * assistant messages `mergeTranscript` interleaves into the transcript. Last write
155
+ * wins per id (a re-emitted id updates in place, matching the log's coalescing),
156
+ * preserving first-seen order. Each becomes one `bivy_attachment` block the client
157
+ * renders as a chip/thumbnail.
158
+ */
159
+ export function replayOutboundAttachments(entries) {
160
+ const byId = new Map();
161
+ for (const entry of entries) {
162
+ if (entry.bivyKind !== "outbound-attachment")
163
+ continue;
164
+ // set() on an existing key updates the value in place (Map keeps first-seen
165
+ // insertion order), so last write wins while position is stable. Final
166
+ // placement is by time in mergeTranscript regardless.
167
+ byId.set(entry.id, entry);
168
+ }
169
+ return [...byId.values()].map((entry) => ({
170
+ role: "assistant",
171
+ content: [{ type: AGENT_ATTACHMENT_BLOCK, ref: entry.ref, caption: entry.caption }],
172
+ afterMessageCount: entry.afterMessageCount,
173
+ createdAt: entry.createdAt,
174
+ id: entry.id,
175
+ }));
136
176
  }
137
177
  /**
138
178
  * Replay a session's base records into the base transcript: `reset` replaces the
@@ -286,6 +326,23 @@ export class EventLog {
286
326
  readAttachments(id) {
287
327
  return replayAttachments(this.entries(id));
288
328
  }
329
+ /**
330
+ * Record an agent-sent (outbound) attachment, anchored at the current base
331
+ * length so history replay interleaves it where it was emitted. Coalesces on
332
+ * the transcript-entry id so a re-emit of the same attachment updates in place.
333
+ */
334
+ appendOutboundAttachment(id, entry) {
335
+ this.load(id);
336
+ const record = {
337
+ bivyKind: "outbound-attachment",
338
+ createdAt: Date.now(),
339
+ afterMessageCount: entry.afterMessageCount,
340
+ id: entry.id,
341
+ ref: { ...entry.ref },
342
+ ...(entry.caption ? { caption: entry.caption } : {}),
343
+ };
344
+ this.enqueue(id, `oa:${entry.id}`, record);
345
+ }
289
346
  /** Replay the overlay entries (disk + pending) into the flat `extras` list. */
290
347
  read(id) {
291
348
  return replayExtras(this.entries(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.3.0-staging.47",
3
+ "version": "0.3.0-staging.48",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",