@bivy/bivy 0.1.0-staging.9 → 0.1.0

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/README.md CHANGED
@@ -25,30 +25,83 @@ reports around agents you already use.
25
25
  curl -fsSL https://bivy.sh/install.sh | bash
26
26
  ```
27
27
 
28
- macOS and Linux. Requires Node 22.19 or newer; the installer sets it up if
29
- missing. It installs the [`@bivy/bivy`](https://www.npmjs.com/package/@bivy/bivy) package
30
- from npm, then runs the guided `bivy setup` wizard — workspace, relay/control-
31
- plane sign-in, and an auto-start background service (launchd on macOS, systemd
32
- on Linux).
28
+ macOS and Linux. Requires Node.js 22.19 or newer; the installer installs it for
29
+ you on Debian/Ubuntu and otherwise points you at nodejs.org. It installs the
30
+ [`@bivy/bivy`](https://www.npmjs.com/package/@bivy/bivy) package from npm, puts
31
+ the `bivy` command on your `PATH`, then runs the guided `bivy setup` wizard —
32
+ workspace, relay/control-plane sign-in, and an auto-start background service
33
+ (launchd on macOS, systemd on Linux). Re-running it on a machine that already
34
+ has Bivy just applies the latest build and restarts the service.
33
35
 
34
- Already have Node? The installer is optional:
36
+ Already have Node.js 22.19+? The installer is optional:
35
37
 
36
38
  ```bash
37
39
  npm install -g @bivy/bivy
38
40
  bivy setup
39
41
  ```
40
42
 
41
- Releases are published from CI with provenance attestations; verify with
42
- `npm audit signatures`. See [`docs/releasing.md`](docs/releasing.md).
43
+ Or try it once without installing anything (`npx` always fetches the latest):
43
44
 
44
- Already have the repository checked out:
45
+ ```bash
46
+ npx @bivy/bivy setup
47
+ ```
48
+
49
+ Releases are published from CI with provenance attestations; verify a build's
50
+ origin with `npm audit signatures`. See [`docs/releasing.md`](docs/releasing.md).
51
+
52
+ ### Install options
53
+
54
+ Environment variables passed to the one-line installer change what it does:
55
+
56
+ | Goal | Variable |
57
+ |---|---|
58
+ | Track the dev channel (new build on every merge to `main`) | `BIVY_CHANNEL=staging` |
59
+ | Pin an exact version | `BIVY_VERSION=0.1.0` |
60
+ | Install without sudo, into a user-owned prefix | `BIVY_NPM_PREFIX=~/.local` |
61
+ | Preinstall every bundled agent runtime | `BIVY_INSTALL_ALL_AGENTS=1` |
62
+
63
+ For example: `BIVY_CHANNEL=staging curl -fsSL https://bivy.sh/install.sh | bash`.
64
+
65
+ Working from a checkout of this repository instead:
45
66
 
46
67
  ```bash
47
68
  npm install
48
69
  npm run setup
49
70
  ```
50
71
 
51
- See [`docs/install.md`](docs/install.md) for service management and uninstall.
72
+ See [`docs/install.md`](docs/install.md) for where data lives, service
73
+ management, and uninstall.
74
+
75
+ ## Updating
76
+
77
+ ```bash
78
+ bivy update
79
+ ```
80
+
81
+ `bivy update` detects how Bivy was installed and does the right thing, then
82
+ waits for any active session to finish its current turn and restarts the
83
+ background service so the node reconnects on the new build:
84
+
85
+ | Install kind | What `bivy update` does |
86
+ |---|---|
87
+ | npm global (`npm i -g`) | `npm install -g @bivy/bivy@<channel>`, then restart the service |
88
+ | installer / packaged | re-runs `install.sh` (migrating to npm if needed), then restart |
89
+ | git checkout | `git pull --ff-only` + `npm ci`, then restart |
90
+ | `npx` run | nothing to update — each run already fetches the latest |
91
+
92
+ Updates follow the release **channel** recorded at install time — `latest`
93
+ (production) by default, or `staging` if you installed with
94
+ `BIVY_CHANNEL=staging`. Switch channels (the choice is remembered for next
95
+ time), or skip the wait for a busy session:
96
+
97
+ ```bash
98
+ bivy update --staging # move to the dev channel
99
+ bivy update --stable # move back to production (latest)
100
+ bivy update --force # don't wait for an in-flight turn to finish
101
+ ```
102
+
103
+ The daemon also checks the registry periodically and posts an in-session notice
104
+ when a newer build is available.
52
105
 
53
106
  ## Architecture
54
107
 
@@ -90,7 +143,7 @@ Nineteen agents are available in the picker, each driven through its native inte
90
143
  | Pi | `bivy run pi` | Default; bundled |
91
144
  | Claude Code | `bivy run claude` | Bundled SDK |
92
145
  | Codex | `bivy run codex` | Installs `@openai/codex` |
93
- | OpenCode | `bivy run opencode` | Installs `opencode-ai/opencode` |
146
+ | OpenCode | `bivy run opencode` | Installs `opencode-ai` |
94
147
  | Gemini CLI | `bivy run gemini` | Installs `@google/gemini-cli` |
95
148
  | Qwen Code | `bivy run qwen` | Installs `@qwen-code/qwen-code` |
96
149
  | Goose | `bivy run goose` | Requires `goose` on PATH |
@@ -154,7 +207,7 @@ If you want to be asked about more, set the mode explicitly:
154
207
 
155
208
  ```bash
156
209
  BIVY_APPROVAL_MODE=risky # prompt on risky shell commands and file edits
157
- BIVY_APPROVAL_MODE=always # prompt on every tool call
210
+ BIVY_APPROVAL_MODE=always # prompt on all shell commands and file edits
158
211
  BIVY_APPROVAL_MODE=never # no prompts beyond the hard floor
159
212
  ```
160
213
 
@@ -178,8 +231,9 @@ bivy secrets ref github.repo-token op://Bivy/GitHub/repo-token
178
231
  bivy secrets doctor
179
232
  ```
180
233
 
181
- `secret://`, `env://`, and `op://` (1Password) references are resolved before the
182
- daemon starts. See [`docs/key-management.md`](docs/key-management.md).
234
+ `secret://`, `env://`, and `op://` (1Password) references are resolved on demand
235
+ when the daemon provisions an agent run, so the raw values never sit in your
236
+ config. See [`docs/key-management.md`](docs/key-management.md).
183
237
 
184
238
  ## GitHub work queue
185
239
 
@@ -237,14 +291,30 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md).
237
291
 
238
292
  ## Self-hosting
239
293
 
240
- Node, relay, and control plane are all in this repository, and the CLI accepts
241
- flags to point at your own deployment:
294
+ Node, relay, and control plane are all in this repository. Point a node at your
295
+ own deployment by passing URLs to `bivy relay:setup` re-running it switches an
296
+ existing node over to the new endpoints:
242
297
 
243
298
  ```bash
244
- bivy relay:setup --relay wss://relay.example.com \
245
- --control-plane https://bivy.example.com
299
+ bivy relay:setup \
300
+ --control-plane https://bivy.example.com \
301
+ --relay wss://relay.example.com
246
302
  ```
247
303
 
304
+ Each URL has a flag and an environment-variable equivalent (the flag wins):
305
+
306
+ | Flag | Environment variable | Points at | Default |
307
+ |---|---|---|---|
308
+ | `--control-plane <url>` | `BIVY_CONTROL_PLANE_URL` | accounts, node registry, and the web-app API | hosted (`app.bivy.sh`) |
309
+ | `--relay <wss-url>` | `BIVY_RELAY_URL` | the encrypted-frame relay your node dials out to | hosted |
310
+ | `--client <url>` | `BIVY_CLIENT_BASE_URL` | base URL used when building app/PWA links | the `--control-plane` URL |
311
+
312
+ Sign-in defaults to GitHub device login (`--github`); pass
313
+ `--email you@example.com` for an email magic-link, or `--session-token <token>`
314
+ to skip interactive sign-in. `relay:setup` checks the control plane is reachable, enrolls
315
+ this node, and writes the endpoints to `.bivy/relay.json`, so `bivy open`,
316
+ `bivy link`, and `bivy update` all keep using your deployment afterwards.
317
+
248
318
  **Self-hosting is unsupported** — no SLA, community best-effort via GitHub
249
319
  issues. You own TLS, backups, upgrades, and hardening. See
250
320
  [`docs/self-host.md`](docs/self-host.md).
package/bin/bivy.mjs CHANGED
@@ -37,6 +37,7 @@ import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
37
37
  import { resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
38
38
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
39
39
  import { removeExcept } from "./uninstall-paths.mjs";
40
+ import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
40
41
 
41
42
  const selfScript = fileURLToPath(import.meta.url);
42
43
  const __dirname = path.dirname(selfScript);
@@ -403,6 +404,12 @@ function url(config) {
403
404
  return `http://localhost:${config.port}`;
404
405
  }
405
406
 
407
+ // The host the node will bind (mirrors src/server.ts). We probe on this same
408
+ // address so a "free" port here is one the daemon can actually claim.
409
+ function nodeBindHost() {
410
+ return process.env.BIVY_HOST ?? process.env.HOST ?? "127.0.0.1";
411
+ }
412
+
406
413
  // --- process helpers --------------------------------------------------------
407
414
 
408
415
  function run(cmd, args, opts = {}) {
@@ -2637,6 +2644,11 @@ async function installService(config) {
2637
2644
  console.log(c.yellow(`No background-service template for ${process.platform}. Use 'bivy start' instead.`));
2638
2645
  return false;
2639
2646
  }
2647
+ // Re-check the port before baking it into the unit: a second node on this
2648
+ // machine may have claimed the saved port since setup, and unlike `bivy setup`
2649
+ // this path used to write it in verbatim (so the node would fail to bind and
2650
+ // silently exit). reconcileNodePort persists any change into `config` first.
2651
+ await reconcileNodePort(config);
2640
2652
  fs.mkdirSync(path.dirname(file), { recursive: true });
2641
2653
  if (kind === "launchd") {
2642
2654
  fs.writeFileSync(file, plistContent(config));
@@ -2726,6 +2738,57 @@ function restartService() {
2726
2738
  return false;
2727
2739
  }
2728
2740
 
2741
+ // Is `config.port` currently held by *this install's own* node, as opposed to a
2742
+ // foreign node (a second OS user's, a staging+prod pair) or an unrelated
2743
+ // process? We ask whoever is listening for its data dir via /api/status and
2744
+ // compare to ours: our own node reports the same appDir; a foreign bivy node
2745
+ // reports a different one (or rejects our device token and throws); a non-bivy
2746
+ // process makes the request throw. Only a match counts as "ours", so port
2747
+ // reconciliation never relocates a node off a port it legitimately owns.
2748
+ async function portHeldByOwnNode(config) {
2749
+ try {
2750
+ const status = await localApi(config, "/api/status");
2751
+ return Boolean(status?.appDir) && path.resolve(status.appDir) === path.resolve(appDir);
2752
+ } catch {
2753
+ return false;
2754
+ }
2755
+ }
2756
+
2757
+ // Re-validate the saved node port before it is baked into a service unit or
2758
+ // restarted into, rolling it forward (and persisting the new value) if a second
2759
+ // node on this machine has claimed it since setup. An explicit `PORT=…` is
2760
+ // honored verbatim. Returns true when the port changed, so the caller knows to
2761
+ // rewrite the unit whose PORT env is now stale. See reconcilePort() for the
2762
+ // decision rules. Our own running node is never treated as a collision.
2763
+ async function reconcileNodePort(config) {
2764
+ const current = Number(config.port) || 4317;
2765
+ const chosen = await reconcilePort(current, nodeBindHost(), {
2766
+ explicitPort: Number(process.env.PORT),
2767
+ heldByOwnNode: () => portHeldByOwnNode(config),
2768
+ });
2769
+ if (chosen === current) return false;
2770
+ console.log(
2771
+ c.yellow(`Port ${current} is already in use by another node on this machine — moving this node to ${chosen}.`),
2772
+ );
2773
+ config.port = chosen;
2774
+ saveConfig(config);
2775
+ return true;
2776
+ }
2777
+
2778
+ // Restart the background service, but first make sure the port it will bind is
2779
+ // still free. If a foreign node grabbed it while ours was down, relocate: the
2780
+ // unit's baked-in PORT is now stale, so a full reinstall rewrites it (and
2781
+ // reloads/relaunches). Otherwise a plain restart. Returns true if the service
2782
+ // was (re)started. Used by `bivy restart` and `bivy update` — the paths that
2783
+ // previously trusted the saved port verbatim.
2784
+ async function restartServiceReconciled(config) {
2785
+ const { file } = servicePaths();
2786
+ if (fs.existsSync(file) && (await reconcileNodePort(config))) {
2787
+ return await installService(config);
2788
+ }
2789
+ return restartService();
2790
+ }
2791
+
2729
2792
  // How long a restart triggered by `bivy update`/`bivy restart` will wait for
2730
2793
  // in-flight agent turns to finish before restarting anyway. Restarting the
2731
2794
  // service SIGTERMs every open session's agent process; done mid-turn that
@@ -2994,11 +3057,27 @@ async function cmdSetup(args = []) {
2994
3057
  // defaults to a dedicated ~/bivy-workspace folder that won't collide with the
2995
3058
  // user's own projects; the local port is for this machine only (remote access
2996
3059
  // goes through the relay). Both are changeable later in Settings.
3060
+ //
3061
+ // Port selection auto-avoids collisions so multiple nodes on one machine (e.g.
3062
+ // a staging + production node, or one node per OS user) "just work" without the
3063
+ // user picking ports: an explicit `PORT=… bivy setup` is honored verbatim,
3064
+ // otherwise we take the first free port at or above 4317. Without this the
3065
+ // second node would default to 4317 too and silently fail to bind.
2997
3066
  if (!existingConfig || config.workspace === repoRoot) {
2998
3067
  const workspace = config.workspace !== repoRoot ? config.workspace : path.join(os.homedir(), "bivy-workspace");
2999
3068
  if (!fs.existsSync(workspace)) fs.mkdirSync(workspace, { recursive: true });
3000
3069
  config.workspace = workspace;
3001
- config.port = Number(config.port) || 4317;
3070
+ const explicitPort = Number(process.env.PORT);
3071
+ const savedPort = Number(config.port);
3072
+ if (explicitPort) {
3073
+ config.port = explicitPort;
3074
+ } else {
3075
+ const preferred = savedPort || 4317;
3076
+ config.port = await findAvailablePort(preferred, nodeBindHost());
3077
+ if (config.port !== preferred) {
3078
+ console.log(c.dim(`Port ${preferred} is already in use (another node?) — using ${config.port} for this node.`));
3079
+ }
3080
+ }
3002
3081
  saveConfig(config);
3003
3082
  }
3004
3083
  console.log(c.dim(`Workspace: ${config.workspace} · local port: ${config.port} (change both in Settings)`));
@@ -3532,7 +3611,7 @@ async function runUpdate(args = []) {
3532
3611
  await ensureBundledAgents();
3533
3612
  const config = loadConfig();
3534
3613
  await waitForIdleSessions(config, { skip: skipWait });
3535
- if (config.service && restartService()) {
3614
+ if (config.service && (await restartServiceReconciled(config))) {
3536
3615
  console.log(c.green("Updated and restarted the background service."));
3537
3616
  } else {
3538
3617
  console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
@@ -3568,7 +3647,7 @@ async function runUpdate(args = []) {
3568
3647
  await ensureBundledAgents();
3569
3648
  const config = loadConfig();
3570
3649
  await waitForIdleSessions(config, { skip: skipWait });
3571
- if (config.service && restartService()) {
3650
+ if (config.service && (await restartServiceReconciled(config))) {
3572
3651
  console.log(c.green("Updated and restarted the background service."));
3573
3652
  } else {
3574
3653
  console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
@@ -4014,8 +4093,10 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4014
4093
  console.log("Usage: bivy restart [--force|--no-wait]\n\nRestart the background service. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait.");
4015
4094
  break;
4016
4095
  }
4017
- await waitForIdleSessions(loadConfig(), { skip: args.includes("--force") || args.includes("--no-wait") });
4018
- if (restartService()) {
4096
+ {
4097
+ const restartConfig = loadConfig();
4098
+ await waitForIdleSessions(restartConfig, { skip: args.includes("--force") || args.includes("--no-wait") });
4099
+ if (await restartServiceReconciled(restartConfig)) {
4019
4100
  console.log(c.green("Service restarted."));
4020
4101
  } else if (fs.existsSync(servicePaths().file)) {
4021
4102
  console.error(c.red("Failed to restart the background service."));
@@ -4028,6 +4109,7 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4028
4109
  console.log(c.yellow("No background service to restart. Run 'bivy setup' to install one so the node stays reachable."));
4029
4110
  process.exitCode = 1;
4030
4111
  }
4112
+ }
4031
4113
  break;
4032
4114
  case "status":
4033
4115
  await cmdStatus(args);
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Free-port selection for `bivy setup`, extracted so it can be unit-tested
4
+ // without executing the CLI (bin/bivy.mjs runs main() on import).
5
+ //
6
+ // Background: every node defaulted its local port to 4317, and setup never
7
+ // checked whether that port was already taken. Running a second node on the same
8
+ // machine — a staging + production node, or one node per OS user — therefore
9
+ // collided: the loopback address 127.0.0.1:4317 is machine-wide, not per-user,
10
+ // so whichever node started second failed to bind. Users had to discover this
11
+ // and hand-pick a port. Setup now takes the first free port at or above the
12
+ // preferred one, so additional nodes land on 4318, 4319, … automatically.
13
+ import net from "node:net";
14
+
15
+ // Is `port` bindable on `host` right now? Resolves false on EADDRINUSE (another
16
+ // node/process already holds it — the exact collision two nodes hit when both
17
+ // default to 4317), true when the port is free, and true on any other error so a
18
+ // probe quirk never blocks setup on an otherwise-usable port.
19
+ export function portIsFree(port, host) {
20
+ return new Promise((resolve) => {
21
+ const tester = net.createServer();
22
+ tester.once("error", (err) => {
23
+ tester.close();
24
+ resolve(err?.code !== "EADDRINUSE");
25
+ });
26
+ tester.once("listening", () => tester.close(() => resolve(true)));
27
+ tester.listen(port, host);
28
+ });
29
+ }
30
+
31
+ // First free port at or after `preferred`, so a second node on the same machine
32
+ // lands on 4318, 4319, … automatically instead of failing to bind. Falls back to
33
+ // `preferred` if the whole scan window is somehow busy (the daemon then reports a
34
+ // clear EADDRINUSE rather than the CLI hanging). `isFree` is injectable for tests.
35
+ export async function findAvailablePort(preferred, host, isFree = portIsFree) {
36
+ for (let port = preferred; port < preferred + 100; port++) {
37
+ if (await isFree(port, host)) return port;
38
+ }
39
+ return preferred;
40
+ }
41
+
42
+ // Decide which port a node should (re)start on, re-validating a *saved* port
43
+ // right before it is baked into a service unit or restarted into. `bivy setup`
44
+ // already auto-avoids collisions, but `bivy service install`, `bivy restart` and
45
+ // `bivy update` used to trust the saved port verbatim — so a node whose 4317 had
46
+ // since been claimed by a second node on the same machine would fail to bind
47
+ // (EADDRINUSE) and silently exit. Rules, in order:
48
+ // - an explicit `PORT=…` override wins verbatim (the operator pinned it);
49
+ // - a free port is kept as-is (the common case — no collision);
50
+ // - a port still held by *our own* node is kept (a plain restart must not
51
+ // relocate a node off the port it already owns — `heldByOwnNode` tells ours
52
+ // apart from a foreign occupant);
53
+ // - otherwise roll forward to the first free port above it.
54
+ // `isFree` and `heldByOwnNode` are injectable so the decision is unit-testable
55
+ // without sockets or a live node. Returns the chosen port; callers compare it to
56
+ // the current one to decide whether to persist and rewrite the unit.
57
+ export async function reconcilePort(current, host, {
58
+ explicitPort = 0,
59
+ isFree = portIsFree,
60
+ heldByOwnNode = async () => false,
61
+ } = {}) {
62
+ const pinned = Number(explicitPort);
63
+ if (pinned) return pinned;
64
+ const port = Number(current) || 4317;
65
+ if (await isFree(port, host)) return port;
66
+ if (await heldByOwnNode(port, host)) return port;
67
+ return findAvailablePort(port + 1, host, isFree);
68
+ }
package/dist/server.js CHANGED
@@ -66,6 +66,7 @@ import { thinkingTextFromContent } from "./session/transcript-merge.js";
66
66
  import { normalizeMessages } from "./session/transcript-normal.js";
67
67
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
68
68
  import { EventLog } from "./session/event-log.js";
69
+ import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
69
70
  import { ReplicationService } from "./session/replication-service.js";
70
71
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
71
72
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
@@ -855,9 +856,12 @@ function safeAttachmentName(value) {
855
856
  return String(value || "attachment").replace(/[\r\n]/g, " ").slice(0, 180);
856
857
  }
857
858
  /**
858
- * Split composer attachments into three channels:
859
+ * Split composer attachments into channels:
859
860
  * - `images` — base64 blobs passed to the model as vision.
860
861
  * - `imageNotes` — one prose line per image for the persisted transcript.
862
+ * - `imageRefs` — durable AttachmentStore references for the images, persisted
863
+ * in the event log so they rehydrate after a reload / on
864
+ * another device (images used to be vision-only, then lost).
861
865
  * - `files` — decoded file attachments (bytes or text) to be written to
862
866
  * disk by materializeAttachments so the agent can open them
863
867
  * with its normal file tools. Any file type is supported;
@@ -865,9 +869,10 @@ function safeAttachmentName(value) {
865
869
  */
866
870
  function attachmentsFrom(value) {
867
871
  if (!Array.isArray(value))
868
- return { images: [], imageNotes: [], files: [] };
872
+ return { images: [], imageNotes: [], imageRefs: [], files: [] };
869
873
  const images = [];
870
874
  const imageNotes = [];
875
+ const imageRefs = [];
871
876
  const files = [];
872
877
  for (const raw of value.slice(0, 12)) {
873
878
  if (!raw || typeof raw !== "object")
@@ -877,8 +882,17 @@ function attachmentsFrom(value) {
877
882
  const size = Number(attachment.size || 0);
878
883
  const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
879
884
  if (attachment.kind === "image" && typeof attachment.data === "string") {
880
- images.push({ type: "image", data: attachment.data, mimeType: mimeType ?? "image/png" });
885
+ const imgMime = mimeType ?? "image/png";
886
+ images.push({ type: "image", data: attachment.data, mimeType: imgMime });
881
887
  imageNotes.push(`[Image attachment: ${name}${size ? ` (${size} bytes)` : ""}]`);
888
+ // Persist the image bytes durably (dedup by hash). Best-effort: a store
889
+ // failure must not break vision for the turn, so it only costs the ref.
890
+ try {
891
+ imageRefs.push(attachmentStore.put(Buffer.from(attachment.data, "base64"), { name, mimeType: imgMime, kind: "image" }));
892
+ }
893
+ catch (error) {
894
+ console.warn("[attachments] failed to store image:", error instanceof Error ? error.message : String(error));
895
+ }
882
896
  }
883
897
  else if (attachment.kind === "file") {
884
898
  if (typeof attachment.data === "string" && attachment.data) {
@@ -891,7 +905,7 @@ function attachmentsFrom(value) {
891
905
  // nothing to write, so there is nothing to hand the agent — skip it.
892
906
  }
893
907
  }
894
- return { images, imageNotes, files };
908
+ return { images, imageNotes, imageRefs, files };
895
909
  }
896
910
  /** Strip a user-supplied filename to a safe basename — no path traversal, no
897
911
  * characters that would break the placeholder note or the filesystem. */
@@ -914,7 +928,22 @@ function sanitizeAttachmentFilename(name) {
914
928
  */
915
929
  function materializeAttachments(record, files) {
916
930
  if (!files.length)
917
- return "";
931
+ return { note: "", refs: [] };
932
+ const refs = [];
933
+ // Store every file durably in the global content-addressed store first (for
934
+ // re-findability), independent of the per-workdir copy below. Best-effort per
935
+ // file so one bad blob doesn't lose the others.
936
+ for (const file of files) {
937
+ const bytes = file.bytes ?? (typeof file.text === "string" ? Buffer.from(file.text, "utf8") : undefined);
938
+ if (!bytes)
939
+ continue;
940
+ try {
941
+ refs.push(attachmentStore.put(bytes, { name: sanitizeAttachmentFilename(file.name), mimeType: file.mimeType, kind: "file" }));
942
+ }
943
+ catch (error) {
944
+ console.warn("[attachments] failed to store file:", error instanceof Error ? error.message : String(error));
945
+ }
946
+ }
918
947
  const workdir = harnessDirFor(record);
919
948
  const dir = path.join(workdir, ".bivy-attachments");
920
949
  try {
@@ -922,7 +951,7 @@ function materializeAttachments(record, files) {
922
951
  }
923
952
  catch (error) {
924
953
  const why = error instanceof Error ? error.message : String(error);
925
- return files.map((f) => `[File attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n");
954
+ return { note: files.map((f) => `[File attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n"), refs };
926
955
  }
927
956
  const notes = [];
928
957
  const used = new Set();
@@ -953,7 +982,7 @@ function materializeAttachments(record, files) {
953
982
  notes.push(`[File attachment: ${label} could not be saved: ${error instanceof Error ? error.message : String(error)}]`);
954
983
  }
955
984
  }
956
- return notes.join("\n");
985
+ return { note: notes.join("\n"), refs };
957
986
  }
958
987
  function approvalModeFrom(value) {
959
988
  return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
@@ -1971,6 +2000,12 @@ function eventLogPath(sessionId) {
1971
2000
  // eventLog.deriveHistory. `redactSecrets` scrubs credentials at the single flush
1972
2001
  // choke point before anything lands on the synced-to-PWA disk.
1973
2002
  const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets);
2003
+ // Global content-addressed store for message attachments (images + files). Unlike
2004
+ // the per-session `.bivy-attachments/` worktree copy (kept so the agent can open
2005
+ // files with its tools), this is durable, session-independent, and re-findable:
2006
+ // the transcript references blobs by hash, and clients rehydrate thumbnails by
2007
+ // hash after a reload or on another device. See src/session/attachment-store.ts.
2008
+ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"));
1974
2009
  // --- Warm session replication (docs/session-replication.md) -----------------
1975
2010
  // A standby's replica repo lives under appDir/replicas/<id>: a self-contained git
1976
2011
  // repo that receives checkpoint bundles and is checked out on promotion. Created
@@ -2228,6 +2263,9 @@ function buildHistoryEvent(opts) {
2228
2263
  prUrl: record?.prUrl ?? opts.prUrl,
2229
2264
  prs: record?.prs ?? opts.prs,
2230
2265
  bivySession: bSess,
2266
+ // Durable attachment references (text→refs), so a client that never sent the
2267
+ // attachment (a reload, or a different device) rehydrates thumbnails by hash.
2268
+ attachmentRefs: opts.sessionId ? eventLog.readAttachments(opts.sessionId) : [],
2231
2269
  };
2232
2270
  }
2233
2271
  // Idempotency for `session.new` keyed by requestId: a client's post-reconnect
@@ -2248,6 +2286,27 @@ const RELAY_COMMANDS = {
2248
2286
  ping(msg, ctx) {
2249
2287
  ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
2250
2288
  },
2289
+ // Fetch a stored attachment's bytes by content hash. The relay client (a phone
2290
+ // not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
2291
+ // over the encrypted tunnel instead; the relay framing chunks the base64 payload
2292
+ // (the same mechanism that carries large image uploads). Direct/LAN clients use
2293
+ // the HTTP endpoint. Both are authenticated — the relay tunnel by enrollment,
2294
+ // the HTTP route by /api's authMiddleware.
2295
+ "attachment.fetch"(msg, ctx) {
2296
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
2297
+ const hash = typeof msg.hash === "string" ? String(msg.hash) : "";
2298
+ if (!isValidAttachmentHash(hash)) {
2299
+ ctx.reply({ type: "attachment.error", requestId, hash, error: "Invalid attachment id" });
2300
+ return;
2301
+ }
2302
+ const bytes = attachmentStore.read(hash);
2303
+ if (!bytes) {
2304
+ ctx.reply({ type: "attachment.error", requestId, hash, error: "Attachment not found" });
2305
+ return;
2306
+ }
2307
+ const meta = attachmentStore.readMeta(hash);
2308
+ ctx.reply({ type: "attachment.data", requestId, hash, mimeType: meta?.mimeType ?? "application/octet-stream", name: meta?.name, data: bytes.toString("base64") });
2309
+ },
2251
2310
  "session.pause"(msg) {
2252
2311
  const record = resolveSession(msg.sessionId);
2253
2312
  if (record)
@@ -3039,7 +3098,7 @@ const RELAY_COMMANDS = {
3039
3098
  },
3040
3099
  async prompt(msg) {
3041
3100
  const text = String(msg.text ?? "").trim();
3042
- const { images, imageNotes, files } = attachmentsFrom(msg.attachments);
3101
+ const { images, imageNotes, imageRefs, files } = attachmentsFrom(msg.attachments);
3043
3102
  if (!text && !images.length && !files.length)
3044
3103
  return;
3045
3104
  // Title/naming can only see what we have before the session exists; the file
@@ -3077,9 +3136,12 @@ const RELAY_COMMANDS = {
3077
3136
  touchSession(record);
3078
3137
  // Now that the session (and its workdir) exists, write file attachments to
3079
3138
  // disk and fold their path notes into the prompt the agent actually sees.
3080
- const fileNote = materializeAttachments(record, files);
3139
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
3081
3140
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
3082
3141
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
3142
+ // Persist durable attachment refs keyed by the exact text the transcript
3143
+ // stores for this user message, so history rehydrates thumbnails by hash.
3144
+ eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
3083
3145
  const agentPrompt = promptForAgent(record, promptText);
3084
3146
  const cmid = typeof msg.clientMessageId === "string" && msg.clientMessageId ? msg.clientMessageId : undefined;
3085
3147
  void dedupePrompt(cmid, async () => {
@@ -6340,7 +6402,10 @@ function maybeRenameWorktreeBranch(record, name) {
6340
6402
  const result = spawnSync("git", ["-C", wt.path, "branch", "-m", wt.branch, next], { encoding: "utf8", timeout: 10_000 });
6341
6403
  if (result.error || result.status !== 0) {
6342
6404
  const detail = String(result.stderr || result.error || "git branch rename failed").trim();
6343
- console.warn(`[branch-rename] could not rename ${wt.branch} to ${next}:`, detail);
6405
+ // Pass the branch names as args, not spliced into the format string: a branch
6406
+ // name containing a %-specifier would otherwise be interpreted by console.warn
6407
+ // (CodeQL js/tainted-format-string).
6408
+ console.warn("[branch-rename] could not rename %s to %s:", wt.branch, next, detail);
6344
6409
  return;
6345
6410
  }
6346
6411
  wt.branch = next;
@@ -9023,13 +9088,31 @@ app.get("/api/repos", async (_req, res) => {
9023
9088
  app.get("/api/repos/branches", async (req, res) => {
9024
9089
  res.json(await listRepoBranches(String(req.query.repo || "").trim()));
9025
9090
  });
9091
+ // Serve a stored attachment's bytes by content hash (direct/LAN clients). Behind
9092
+ // /api's authMiddleware. Content-addressed, so responses are immutably cacheable.
9093
+ // The hash is validated to a 64-char hex before it ever touches a path.
9094
+ app.get("/api/attachment/:hash", (req, res) => {
9095
+ const hash = String(req.params.hash || "");
9096
+ if (!isValidAttachmentHash(hash))
9097
+ return res.status(400).json({ error: "Invalid attachment id" });
9098
+ const bytes = attachmentStore.read(hash);
9099
+ if (!bytes)
9100
+ return res.status(404).json({ error: "Attachment not found" });
9101
+ const meta = attachmentStore.readMeta(hash);
9102
+ res.setHeader("Content-Type", meta?.mimeType || "application/octet-stream");
9103
+ res.setHeader("Content-Length", String(bytes.length));
9104
+ res.setHeader("X-Content-Type-Options", "nosniff");
9105
+ // Content-addressed: the bytes for a hash never change, so cache aggressively.
9106
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
9107
+ res.end(bytes);
9108
+ });
9026
9109
  app.post("/api/session/prompt", async (req, res, next) => {
9027
9110
  try {
9028
9111
  const text = String(req.body?.text ?? "").trim();
9029
9112
  if (text === "/login" || text.startsWith("/login ")) {
9030
9113
  return res.status(400).json({ error: "Use the Login / API tokens dialog from the phone UI. If it is not visible, refresh this page after updating Bivy." });
9031
9114
  }
9032
- const { images, imageNotes, files } = attachmentsFrom(req.body?.attachments);
9115
+ const { images, imageNotes, imageRefs, files } = attachmentsFrom(req.body?.attachments);
9033
9116
  if (!text && !images.length && !files.length)
9034
9117
  return res.status(400).json({ error: "Missing text" });
9035
9118
  // File notes (with on-disk paths) are folded in once the workdir exists; the
@@ -9052,9 +9135,10 @@ app.post("/api/session/prompt", async (req, res, next) => {
9052
9135
  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." });
9053
9136
  }
9054
9137
  const session = record.session;
9055
- const fileNote = materializeAttachments(record, files);
9138
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
9056
9139
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
9057
9140
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
9141
+ eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
9058
9142
  const agentPrompt = promptForAgent(record, promptText);
9059
9143
  const cmid = typeof req.body?.clientMessageId === "string" && req.body.clientMessageId ? req.body.clientMessageId : undefined;
9060
9144
  markSessionWorking(record, { type: "agent_start" });
@@ -9304,6 +9388,23 @@ const server = app.listen(port, host, async () => {
9304
9388
  if (!process.env.BIVY_MCP_ENDPOINT)
9305
9389
  process.env.BIVY_MCP_ENDPOINT = `http://127.0.0.1:${port}`;
9306
9390
  });
9391
+ // A failed bind is fatal and must be reported, not swallowed. `app.listen` emits
9392
+ // the error on the server; with no handler it reaches the global
9393
+ // `uncaughtException` net below, which keeps the process alive — leaving a node
9394
+ // that is "running" but listening on nothing. The common cause is another node
9395
+ // already on this port (two OS users, or a staging + production node, both
9396
+ // defaulting to 4317). Print an actionable message and exit so the supervisor
9397
+ // and `bivy logs` surface a real failure instead of a silent hang.
9398
+ server.on("error", (error) => {
9399
+ if (error.code === "EADDRINUSE") {
9400
+ console.error(`[bivy] Port ${port} is already in use — another Bivy node or process holds ${host}:${port}. ` +
9401
+ `Give this node its own port (set PORT, or re-run 'bivy setup', which now picks a free one automatically) and start it again.`);
9402
+ }
9403
+ else {
9404
+ console.error(`[bivy] Could not bind ${host}:${port}:`, error);
9405
+ }
9406
+ process.exit(1);
9407
+ });
9307
9408
  const wss = new WebSocketServer({ server, path: "/ws" });
9308
9409
  wss.on("connection", (socket, req) => {
9309
9410
  // Reject cross-origin / DNS-rebinding upgrades before auth: loopback bypasses
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Content-addressed blob store for message attachments (images + files).
5
+ //
6
+ // Attachments used to be fragile and un-refindable: image bytes were passed to
7
+ // the model as vision for a single turn and then dropped entirely (never written
8
+ // anywhere), while file attachments were written only into the session's
9
+ // ephemeral worktree at `<workdir>/.bivy-attachments/`. Nothing survived in a
10
+ // stable, session-independent location, so a reload on another device — or after
11
+ // the client's in-memory attachment cache aged out — showed only a bare
12
+ // "[Image attachment: …]" placeholder.
13
+ //
14
+ // This store fixes that: every attachment is written ONCE to a global folder
15
+ // under `<appDir>/attachments/`, addressed by the SHA-256 of its bytes. Identical
16
+ // bytes dedupe for free (same hash → same path), the location is stable and
17
+ // re-findable, and the transcript references a blob by hash instead of carrying
18
+ // (or losing) the bytes. Paths are two-level sharded (`ab/cd/<hash>`) so a busy
19
+ // node never piles millions of entries into one directory. A small sidecar
20
+ // `<hash>.json` remembers a human name / mime / size for the blob.
21
+ //
22
+ // Out of scope for now (tracked as TODOs): garbage-collecting blobs no transcript
23
+ // references any more, and a global size cap / LRU eviction on the store.
24
+ import crypto from "node:crypto";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ /** A 64-char lowercase-hex SHA-256, the only shape a valid hash can take. */
28
+ const HASH_RE = /^[0-9a-f]{64}$/;
29
+ /** Whether `value` is a well-formed content hash (guards path building). */
30
+ export function isValidAttachmentHash(value) {
31
+ return typeof value === "string" && HASH_RE.test(value);
32
+ }
33
+ /**
34
+ * A global, content-addressed attachment store rooted at a single directory.
35
+ * All methods are best-effort and synchronous, matching the surrounding
36
+ * server code (EventLog, SidecarStore) — attachment persistence must never sink
37
+ * a turn, so a write failure surfaces as a thrown error the caller degrades to a
38
+ * text note rather than a crash.
39
+ */
40
+ export class AttachmentStore {
41
+ dir;
42
+ constructor(dir) {
43
+ this.dir = dir;
44
+ }
45
+ /** `<dir>/ab/cd` for a hash beginning `abcd…`. */
46
+ shardDir(hash) {
47
+ return path.join(this.dir, hash.slice(0, 2), hash.slice(2, 4));
48
+ }
49
+ blobPath(hash) {
50
+ return path.join(this.shardDir(hash), hash);
51
+ }
52
+ metaPath(hash) {
53
+ return path.join(this.shardDir(hash), `${hash}.json`);
54
+ }
55
+ /**
56
+ * Store `bytes` and return a durable reference. Content-addressed, so calling
57
+ * this twice with identical bytes writes the blob once and returns the same
58
+ * hash. The blob write is skipped when the file already exists (dedupe); the
59
+ * sidecar is written only the first time so the earliest name/mime wins.
60
+ */
61
+ put(bytes, opts) {
62
+ const hash = crypto.createHash("sha256").update(bytes).digest("hex");
63
+ const ref = { hash, name: opts.name, mimeType: opts.mimeType, size: bytes.length, kind: opts.kind };
64
+ fs.mkdirSync(this.shardDir(hash), { recursive: true });
65
+ const blob = this.blobPath(hash);
66
+ if (!fs.existsSync(blob))
67
+ fs.writeFileSync(blob, bytes);
68
+ if (!fs.existsSync(this.metaPath(hash))) {
69
+ const meta = { ...ref, createdAt: Date.now() };
70
+ try {
71
+ fs.writeFileSync(this.metaPath(hash), JSON.stringify(meta));
72
+ }
73
+ catch {
74
+ // A missing sidecar only costs us the remembered name/mime — the blob is
75
+ // what matters, so never let a sidecar write failure fail the store.
76
+ }
77
+ }
78
+ return ref;
79
+ }
80
+ /** The blob's metadata, or null if the hash is unknown/malformed. */
81
+ readMeta(hash) {
82
+ if (!isValidAttachmentHash(hash))
83
+ return null;
84
+ try {
85
+ return JSON.parse(fs.readFileSync(this.metaPath(hash), "utf8"));
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /** The on-disk path of a stored blob, or null if absent/malformed. */
92
+ getPath(hash) {
93
+ if (!isValidAttachmentHash(hash))
94
+ return null;
95
+ const blob = this.blobPath(hash);
96
+ return fs.existsSync(blob) ? blob : null;
97
+ }
98
+ /** Read a stored blob's raw bytes, or null if absent/malformed. */
99
+ read(hash) {
100
+ const p = this.getPath(hash);
101
+ if (!p)
102
+ return null;
103
+ try {
104
+ return fs.readFileSync(p);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ }
@@ -40,8 +40,35 @@ function isBase(value) {
40
40
  const record = value;
41
41
  return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean";
42
42
  }
43
+ function isAttachment(value) {
44
+ if (!value || typeof value !== "object")
45
+ return false;
46
+ const record = value;
47
+ return record.bivyKind === "attachment" && typeof record.text === "string" && Array.isArray(record.refs);
48
+ }
43
49
  function isRecord(value) {
44
- return isOverlay(value) || isBase(value);
50
+ return isOverlay(value) || isBase(value) || isAttachment(value);
51
+ }
52
+ /**
53
+ * Fold attachment records into a text→refs list: last write wins per text (a
54
+ * resent identical prompt re-keys onto the newest refs), preserving first-seen
55
+ * order. `mergeTranscript` never sees these — the client matches them onto the
56
+ * user messages by their persisted text, exactly as its in-memory attachment
57
+ * cache did, but now sourced durably from the log.
58
+ */
59
+ export function replayAttachments(entries) {
60
+ const byText = new Map();
61
+ for (const entry of entries) {
62
+ if (entry.bivyKind !== "attachment")
63
+ continue;
64
+ if (!entry.text || !entry.refs.length)
65
+ continue;
66
+ // delete+set so a re-keyed text moves to the end (newest), matching the
67
+ // client's rememberAttachments last-wins semantics.
68
+ byText.delete(entry.text);
69
+ byText.set(entry.text, entry.refs);
70
+ }
71
+ return [...byText.entries()];
45
72
  }
46
73
  /**
47
74
  * Fold the intermediate-reasoning entries exactly as the legacy incremental
@@ -243,6 +270,22 @@ export class EventLog {
243
270
  this.baseKeys.set(id, nextKeys);
244
271
  this.enqueue(id, this.syntheticKey(id), record);
245
272
  }
273
+ /**
274
+ * Record the attachment references a user sent with a prompt, keyed by the
275
+ * prompt's persisted text. Id-less (synthetic key) so successive prompts never
276
+ * coalesce. A no-op when there are no refs.
277
+ */
278
+ appendAttachments(id, text, refs) {
279
+ if (!text || !refs.length)
280
+ return;
281
+ this.load(id);
282
+ const record = { bivyKind: "attachment", createdAt: Date.now(), text, refs: refs.map((r) => ({ ...r })) };
283
+ this.enqueue(id, this.syntheticKey(id), record);
284
+ }
285
+ /** Replay the attachment records (disk + pending) into a text→refs list. */
286
+ readAttachments(id) {
287
+ return replayAttachments(this.entries(id));
288
+ }
246
289
  /** Replay the overlay entries (disk + pending) into the flat `extras` list. */
247
290
  read(id) {
248
291
  return replayExtras(this.entries(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.1.0-staging.9",
3
+ "version": "0.1.0",
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.",