@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 +88 -18
- package/bin/bivy.mjs +87 -5
- package/bin/port-picker.mjs +68 -0
- package/dist/server.js +113 -12
- package/dist/session/attachment-store.js +110 -0
- package/dist/session/event-log.js +44 -1
- package/package.json +1 -1
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
182
|
-
daemon
|
|
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
|
|
241
|
-
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
4018
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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));
|