@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import * as pty from "node-pty";
|
|
8
|
+
import { depCacheEnv } from "./harness/dep-cache.js";
|
|
9
|
+
/**
|
|
10
|
+
* Coalescing window (ms) for PTY output. node-pty delivers a chatty program's
|
|
11
|
+
* output (build logs, `cat` of a large file, an agent streaming tokens) as a
|
|
12
|
+
* flurry of small chunks across many event-loop ticks. Emitting one transport
|
|
13
|
+
* frame per chunk floods the WebSocket and — over the relay — pays a per-frame
|
|
14
|
+
* encrypt/chunk cost each time, which is a large part of why the terminal feels
|
|
15
|
+
* laggy under load. Batching a few ms of output into a single frame collapses
|
|
16
|
+
* that by an order of magnitude while adding latency well below the threshold of
|
|
17
|
+
* perception for interactive echo.
|
|
18
|
+
*/
|
|
19
|
+
const OUTPUT_FLUSH_MS = 8;
|
|
20
|
+
/**
|
|
21
|
+
* How much recent output to retain per terminal for scrollback replay on
|
|
22
|
+
* reconnect. A phone that backgrounds the PWA (or drops its network) reattaches
|
|
23
|
+
* to the still-live shell and gets this tail rewritten so it doesn't come back
|
|
24
|
+
* to a blank screen. Kept modest so a chatty build log can't grow memory without
|
|
25
|
+
* bound.
|
|
26
|
+
*/
|
|
27
|
+
const SCROLLBACK_LIMIT = 256 * 1024;
|
|
28
|
+
function defaultShell() {
|
|
29
|
+
if (process.platform === "win32")
|
|
30
|
+
return process.env.COMSPEC || "powershell.exe";
|
|
31
|
+
return process.env.SHELL || "/bin/bash";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve `command` to a runnable executable path, mirroring how the PTY's exec
|
|
35
|
+
* would search `env`'s PATH. Returns the absolute path, or null when the command
|
|
36
|
+
* can't be found / isn't executable.
|
|
37
|
+
*
|
|
38
|
+
* This exists because node-pty does NOT throw when the target can't be exec'd:
|
|
39
|
+
* its spawn helper prints the cryptic "posix_spawnp failed." into the terminal
|
|
40
|
+
* and exits, so a mistyped or uninstalled agent (e.g. `bivy run claude` with no
|
|
41
|
+
* `claude` on the daemon's PATH) surfaces that message instead of a real error.
|
|
42
|
+
* Resolving up front lets open() throw a clear, catchable error — and hands the
|
|
43
|
+
* PTY an unambiguous absolute path.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveExecutable(command, env, cwd) {
|
|
46
|
+
const win = process.platform === "win32";
|
|
47
|
+
const isExecutable = (file) => {
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.statSync(file).isFile())
|
|
50
|
+
return false;
|
|
51
|
+
if (win)
|
|
52
|
+
return true;
|
|
53
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
// On Windows a bare name may need a PATHEXT suffix (claude → claude.cmd).
|
|
61
|
+
const exts = win ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
62
|
+
const withExts = (file) => exts.map((ext) => (ext && !file.toLowerCase().endsWith(ext.toLowerCase()) ? file + ext : file));
|
|
63
|
+
// A command with an explicit path is resolved directly (against the workspace
|
|
64
|
+
// for a relative one), never via PATH — same as exec.
|
|
65
|
+
if (command.includes("/") || (win && command.includes("\\"))) {
|
|
66
|
+
for (const candidate of withExts(path.resolve(cwd, command))) {
|
|
67
|
+
if (isExecutable(candidate))
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
for (const dir of (env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
73
|
+
for (const candidate of withExts(path.join(dir, command))) {
|
|
74
|
+
if (isExecutable(candidate))
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Restore the execute bit on node-pty's bundled `spawn-helper` binary.
|
|
82
|
+
*
|
|
83
|
+
* On macOS/Linux, node-pty launches every PTY by exec'ing this helper. Its
|
|
84
|
+
* prebuilds ship without the execute bit (mode 0664), and a fresh `npm install`
|
|
85
|
+
* — exactly what `bivy update` runs — resets the bit. node-pty does not surface
|
|
86
|
+
* this: its spawn prints the cryptic "posix_spawnp failed." into the terminal and
|
|
87
|
+
* exits, so `bivy run <agent>` dies before the agent starts and keeps breaking
|
|
88
|
+
* after every update until someone runs `chmod +x` by hand.
|
|
89
|
+
*
|
|
90
|
+
* Fix it once, up front, so runs survive updates and reinstalls automatically.
|
|
91
|
+
* Best-effort and idempotent: if node-pty can't be resolved or the helper is
|
|
92
|
+
* already executable, this is a no-op and node-pty reports its own errors.
|
|
93
|
+
*/
|
|
94
|
+
let spawnHelperEnsured = false;
|
|
95
|
+
function ensureSpawnHelperExecutable() {
|
|
96
|
+
if (spawnHelperEnsured)
|
|
97
|
+
return;
|
|
98
|
+
spawnHelperEnsured = true;
|
|
99
|
+
if (process.platform === "win32")
|
|
100
|
+
return; // Windows has no spawn-helper.
|
|
101
|
+
const makeExecutable = (file) => {
|
|
102
|
+
try {
|
|
103
|
+
const st = fs.statSync(file);
|
|
104
|
+
if (!st.isFile() || (st.mode & 0o111) !== 0)
|
|
105
|
+
return; // missing or already +x
|
|
106
|
+
fs.chmodSync(file, st.mode | 0o755);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Missing candidate or read-only install — best-effort.
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const require = createRequire(import.meta.url);
|
|
114
|
+
const root = path.dirname(require.resolve("node-pty/package.json"));
|
|
115
|
+
// Cover both a from-source build and the prebuilt binaries node-pty ships.
|
|
116
|
+
makeExecutable(path.join(root, "build", "Release", "spawn-helper"));
|
|
117
|
+
const prebuilds = path.join(root, "prebuilds");
|
|
118
|
+
try {
|
|
119
|
+
for (const dir of fs.readdirSync(prebuilds)) {
|
|
120
|
+
makeExecutable(path.join(prebuilds, dir, "spawn-helper"));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// No prebuilds directory — fine.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Couldn't resolve node-pty; leave it to node-pty to report.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export class TerminalManager {
|
|
132
|
+
terminals = new Map();
|
|
133
|
+
/** The number of live terminals (for tests / capacity reporting). */
|
|
134
|
+
get size() {
|
|
135
|
+
return this.terminals.size;
|
|
136
|
+
}
|
|
137
|
+
open(options) {
|
|
138
|
+
const id = `term-${randomUUID()}`;
|
|
139
|
+
const shell = options.command || defaultShell();
|
|
140
|
+
const shellArgs = options.command ? (options.args ?? []) : [];
|
|
141
|
+
// Ensure a UTF-8 locale so TUIs (e.g. Claude Code) render box-drawing and
|
|
142
|
+
// emoji instead of "?". The daemon may be started by launchd/systemd with no
|
|
143
|
+
// LANG/LC_* in its environment; without a UTF-8 locale the child runs under
|
|
144
|
+
// the C/POSIX locale and downgrades all non-ASCII output. Only fill the gap
|
|
145
|
+
// when no locale is configured — a real locale in the environment still wins.
|
|
146
|
+
const hasLocale = Boolean(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG);
|
|
147
|
+
const localeDefaults = hasLocale ? {} : { LANG: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8" };
|
|
148
|
+
const env = {
|
|
149
|
+
...localeDefaults,
|
|
150
|
+
...process.env,
|
|
151
|
+
...depCacheEnv(),
|
|
152
|
+
...options.env,
|
|
153
|
+
TERM: "xterm-256color",
|
|
154
|
+
// Make it obvious in the shell that this is a Bivy terminal.
|
|
155
|
+
BIVY_TERMINAL: "1",
|
|
156
|
+
};
|
|
157
|
+
// node-pty won't throw for an unresolvable command — it prints
|
|
158
|
+
// "posix_spawnp failed." into the PTY and exits. Resolve up front so we can
|
|
159
|
+
// raise a real error (which callers turn into a `terminal.error`) instead.
|
|
160
|
+
const resolved = resolveExecutable(shell, env, options.workspace);
|
|
161
|
+
if (!resolved) {
|
|
162
|
+
throw new Error(options.command
|
|
163
|
+
? `Cannot run "${shell}": command not found or not executable on the node's PATH. Install it, or check the command.`
|
|
164
|
+
: `Cannot start a terminal: no usable shell found (tried "${shell}"). Set the SHELL environment variable.`);
|
|
165
|
+
}
|
|
166
|
+
// node-pty exec's a bundled `spawn-helper` whose execute bit a fresh install
|
|
167
|
+
// (e.g. `bivy update`) strips — restore it before spawning or this fails with
|
|
168
|
+
// "posix_spawnp failed." See ensureSpawnHelperExecutable().
|
|
169
|
+
ensureSpawnHelperExecutable();
|
|
170
|
+
const proc = pty.spawn(resolved, shellArgs, {
|
|
171
|
+
name: "xterm-256color",
|
|
172
|
+
cols: clampDim(options.cols, 80),
|
|
173
|
+
rows: clampDim(options.rows, 24),
|
|
174
|
+
cwd: options.workspace,
|
|
175
|
+
env,
|
|
176
|
+
});
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const entry = {
|
|
179
|
+
proc,
|
|
180
|
+
workspace: options.workspace,
|
|
181
|
+
createdAt: now,
|
|
182
|
+
lastActivityAt: now,
|
|
183
|
+
lastInputAt: now,
|
|
184
|
+
meta: options.meta ?? {},
|
|
185
|
+
clientSizes: new Map(),
|
|
186
|
+
buffer: "",
|
|
187
|
+
pending: "",
|
|
188
|
+
flushTimer: null,
|
|
189
|
+
closed: false,
|
|
190
|
+
onData: options.onData,
|
|
191
|
+
};
|
|
192
|
+
// Register the opener as a sized client so a later, smaller client shrinks
|
|
193
|
+
// the PTY to the min of the two rather than clobbering the opener's size.
|
|
194
|
+
if (options.clientId) {
|
|
195
|
+
entry.clientSizes.set(options.clientId, {
|
|
196
|
+
cols: clampDim(options.cols, 80),
|
|
197
|
+
rows: clampDim(options.rows, 24),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
this.terminals.set(id, entry);
|
|
201
|
+
const flush = () => {
|
|
202
|
+
entry.flushTimer = null;
|
|
203
|
+
if (entry.closed || !entry.pending)
|
|
204
|
+
return;
|
|
205
|
+
const batch = entry.pending;
|
|
206
|
+
entry.pending = "";
|
|
207
|
+
entry.onData(batch);
|
|
208
|
+
};
|
|
209
|
+
proc.onData((data) => {
|
|
210
|
+
// A killed PTY can still emit a final chunk after close() tore the
|
|
211
|
+
// terminal down; ignore it so we never deliver output for a closed one.
|
|
212
|
+
if (entry.closed)
|
|
213
|
+
return;
|
|
214
|
+
// Scrollback tail is kept raw and up to date immediately so a reattach
|
|
215
|
+
// replay never lags the coalesced client stream.
|
|
216
|
+
entry.buffer += data;
|
|
217
|
+
if (entry.buffer.length > SCROLLBACK_LIMIT) {
|
|
218
|
+
entry.buffer = entry.buffer.slice(entry.buffer.length - SCROLLBACK_LIMIT);
|
|
219
|
+
}
|
|
220
|
+
entry.lastActivityAt = Date.now();
|
|
221
|
+
// A BEL in the raw stream means a program rang the terminal bell. Notify the
|
|
222
|
+
// caller once per chunk (a bell-storm within one node-pty chunk collapses to
|
|
223
|
+
// one signal); finer gating is the caller's responsibility.
|
|
224
|
+
if (options.onBell && data.includes("\x07")) {
|
|
225
|
+
try {
|
|
226
|
+
options.onBell();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* a bell handler must never break output delivery */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Coalesce delivery: accumulate and flush on a short timer instead of
|
|
233
|
+
// emitting a frame per chunk.
|
|
234
|
+
entry.pending += data;
|
|
235
|
+
if (!entry.flushTimer)
|
|
236
|
+
entry.flushTimer = setTimeout(flush, OUTPUT_FLUSH_MS);
|
|
237
|
+
});
|
|
238
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
239
|
+
// Deliver whatever was buffered before signalling exit, so a program's
|
|
240
|
+
// final output isn't dropped by the coalescing window.
|
|
241
|
+
if (entry.flushTimer) {
|
|
242
|
+
clearTimeout(entry.flushTimer);
|
|
243
|
+
entry.flushTimer = null;
|
|
244
|
+
}
|
|
245
|
+
// On a natural exit `closed` is still false, so this delivers the final
|
|
246
|
+
// output. If close() already ran, `closed` is true and flush() is a no-op
|
|
247
|
+
// — we don't re-emit output for a terminal the client tore down.
|
|
248
|
+
flush();
|
|
249
|
+
entry.closed = true;
|
|
250
|
+
this.terminals.delete(id);
|
|
251
|
+
options.onExit(exitCode, signal);
|
|
252
|
+
});
|
|
253
|
+
return id;
|
|
254
|
+
}
|
|
255
|
+
write(id, data) {
|
|
256
|
+
const entry = this.terminals.get(id);
|
|
257
|
+
if (!entry)
|
|
258
|
+
return false;
|
|
259
|
+
entry.lastInputAt = Date.now();
|
|
260
|
+
entry.proc.write(data);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Record `clientId`'s desired size for terminal `id` and re-size the shared
|
|
265
|
+
* PTY to the minimum over all currently-attached clients. Called on both
|
|
266
|
+
* attach and resize. Using the min (rather than "last writer wins") means a
|
|
267
|
+
* second client attaching at a different window size can never reflow another
|
|
268
|
+
* client's TUI wider than its viewport — it only ever adds unused margin on
|
|
269
|
+
* the larger client, tmux-style.
|
|
270
|
+
*/
|
|
271
|
+
setClientSize(id, clientId, cols, rows) {
|
|
272
|
+
const entry = this.terminals.get(id);
|
|
273
|
+
if (!entry)
|
|
274
|
+
return false;
|
|
275
|
+
entry.clientSizes.set(clientId, { cols: clampDim(cols, 80), rows: clampDim(rows, 24) });
|
|
276
|
+
this.applyMinSize(entry);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Forget `clientId`'s size for terminal `id` (it detached from this terminal)
|
|
281
|
+
* and re-size the PTY to the min of whoever remains, so the surviving clients
|
|
282
|
+
* grow back to their real dimensions. No-op if the client had no size here.
|
|
283
|
+
*/
|
|
284
|
+
dropClientSize(id, clientId) {
|
|
285
|
+
const entry = this.terminals.get(id);
|
|
286
|
+
if (!entry)
|
|
287
|
+
return false;
|
|
288
|
+
if (entry.clientSizes.delete(clientId))
|
|
289
|
+
this.applyMinSize(entry);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Forget `clientId` across every terminal — called when a client's transport
|
|
294
|
+
* (socket/relay) drops, so terminals it viewed grow back for the clients that
|
|
295
|
+
* remain. A disconnecting client may have sized terminals it never `owned`
|
|
296
|
+
* (e.g. shared run-terminals), so this sweeps all of them.
|
|
297
|
+
*/
|
|
298
|
+
dropClient(clientId) {
|
|
299
|
+
for (const entry of this.terminals.values()) {
|
|
300
|
+
if (entry.clientSizes.delete(clientId))
|
|
301
|
+
this.applyMinSize(entry);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Size the PTY to the min cols/rows over all attached clients. */
|
|
305
|
+
applyMinSize(entry) {
|
|
306
|
+
// No sized clients (nobody attached, or an unsized opener): leave the PTY at
|
|
307
|
+
// its current size rather than snapping to the 80x24 clamp floor.
|
|
308
|
+
if (entry.clientSizes.size === 0)
|
|
309
|
+
return;
|
|
310
|
+
let cols = Infinity;
|
|
311
|
+
let rows = Infinity;
|
|
312
|
+
for (const size of entry.clientSizes.values()) {
|
|
313
|
+
if (size.cols < cols)
|
|
314
|
+
cols = size.cols;
|
|
315
|
+
if (size.rows < rows)
|
|
316
|
+
rows = size.rows;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
entry.proc.resize(clampDim(cols, 80), clampDim(rows, 24));
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// resizing a dying pty can throw — harmless
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
close(id) {
|
|
326
|
+
const entry = this.terminals.get(id);
|
|
327
|
+
if (!entry)
|
|
328
|
+
return false;
|
|
329
|
+
this.terminals.delete(id);
|
|
330
|
+
// Drop any queued output — the client asked to close, so don't emit a
|
|
331
|
+
// trailing batch (which would fire onData for a terminal it has torn down).
|
|
332
|
+
// `closed` also neutralises the final PTY chunk a kill can emit afterwards.
|
|
333
|
+
entry.closed = true;
|
|
334
|
+
if (entry.flushTimer) {
|
|
335
|
+
clearTimeout(entry.flushTimer);
|
|
336
|
+
entry.flushTimer = null;
|
|
337
|
+
}
|
|
338
|
+
entry.pending = "";
|
|
339
|
+
try {
|
|
340
|
+
entry.proc.kill();
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// already gone
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
has(id) {
|
|
348
|
+
return this.terminals.has(id);
|
|
349
|
+
}
|
|
350
|
+
/** Metadata for a live terminal, or null if the id is unknown. */
|
|
351
|
+
meta(id) {
|
|
352
|
+
const entry = this.terminals.get(id);
|
|
353
|
+
return entry ? entry.meta : null;
|
|
354
|
+
}
|
|
355
|
+
/** Epoch ms of the last PTY output for a live terminal, or null if unknown. */
|
|
356
|
+
lastActivity(id) {
|
|
357
|
+
const entry = this.terminals.get(id);
|
|
358
|
+
return entry ? entry.lastActivityAt : null;
|
|
359
|
+
}
|
|
360
|
+
/** Epoch ms of the last client input to a live terminal, or null if unknown. */
|
|
361
|
+
lastInput(id) {
|
|
362
|
+
const entry = this.terminals.get(id);
|
|
363
|
+
return entry ? entry.lastInputAt : null;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Listable summaries of live terminals, newest first. `filter` narrows to a
|
|
367
|
+
* kind (e.g. only `bivy run` terminals) so a plain shell a client opened for
|
|
368
|
+
* itself doesn't show up as an attachable agent session.
|
|
369
|
+
*/
|
|
370
|
+
list(filter) {
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const [id, entry] of this.terminals) {
|
|
373
|
+
if (filter && !filter(entry.meta))
|
|
374
|
+
continue;
|
|
375
|
+
out.push({ id, workspace: entry.workspace, createdAt: entry.createdAt, lastActivityAt: entry.lastActivityAt, meta: entry.meta });
|
|
376
|
+
}
|
|
377
|
+
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The retained scrollback tail for a live terminal, or null if the id is
|
|
381
|
+
* unknown (never opened, or already exited). Callers replay this to a client
|
|
382
|
+
* that is reattaching to an existing shell.
|
|
383
|
+
*/
|
|
384
|
+
snapshot(id) {
|
|
385
|
+
const entry = this.terminals.get(id);
|
|
386
|
+
return entry ? entry.buffer : null;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The OS process id of a terminal's PTY child, or undefined if the id is
|
|
390
|
+
* unknown. Used to register a reliable kill target for a pinned agent run, so a
|
|
391
|
+
* later "continue as chat" takeover can stop the exact process it launched
|
|
392
|
+
* instead of searching for it.
|
|
393
|
+
*/
|
|
394
|
+
pid(id) {
|
|
395
|
+
const entry = this.terminals.get(id);
|
|
396
|
+
return entry ? entry.proc.pid : undefined;
|
|
397
|
+
}
|
|
398
|
+
/** Kill every terminal (process shutdown). */
|
|
399
|
+
disposeAll() {
|
|
400
|
+
for (const id of [...this.terminals.keys()])
|
|
401
|
+
this.close(id);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function clampDim(value, fallback) {
|
|
405
|
+
const n = Math.floor(Number(value));
|
|
406
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
407
|
+
return fallback;
|
|
408
|
+
return Math.min(Math.max(n, 1), 1000);
|
|
409
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Node-side mirror of the shared E2E wire-format constants. The spec's home is
|
|
5
|
+
// packages/core/src/wire-format.ts; this is a byte-for-byte copy so the node
|
|
6
|
+
// (node:crypto) and browser (WebCrypto) stacks derive identical values.
|
|
7
|
+
//
|
|
8
|
+
// Why a copy and not a re-export: the node ships as a STANDALONE bundle. The
|
|
9
|
+
// release build compiles only `src/` with `tsc --rootDir src` and the tarball
|
|
10
|
+
// drops `packages/` (see scripts/build-release.mjs), so a re-export of
|
|
11
|
+
// `../packages/core/src/wire-format.js` both fails the compile (TS6059: file
|
|
12
|
+
// outside rootDir) and would be missing at runtime. Reaching across the package
|
|
13
|
+
// boundary is the browser bundler's job (Vite), not the node's tsc-with-rootDir.
|
|
14
|
+
//
|
|
15
|
+
// The single-source guarantee is kept the way slice 4 always intended — by the
|
|
16
|
+
// CONFORMANCE TEST: test/crypto-conformance.test.ts deep-equals every export here
|
|
17
|
+
// against packages/core/src/wire-format.ts, so any drift fails CI. Do NOT edit a
|
|
18
|
+
// value here without changing the spec (and the wire format) in lockstep.
|
|
19
|
+
/**
|
|
20
|
+
* HKDF `info` strings that domain-separate the per-device ECDH wrap keys. Both
|
|
21
|
+
* ends must derive with the identical string or the room key fails to unwrap.
|
|
22
|
+
*/
|
|
23
|
+
export const HKDF_INFO = {
|
|
24
|
+
/** Initial pairing (pair.hello → pair.welcome). */
|
|
25
|
+
pair: "bivy-pair-v1",
|
|
26
|
+
/** Room-key rotation after a device revoke. */
|
|
27
|
+
rotate: "bivy-rotate-v1",
|
|
28
|
+
/** Model-auth vault delivery (node-only today). */
|
|
29
|
+
modelAuthVault: "bivy-model-auth-vault-v1",
|
|
30
|
+
/** GitHub App private-key vault delivery (node-only, opt-in — issue #88). */
|
|
31
|
+
githubAppVault: "bivy-github-app-vault-v1",
|
|
32
|
+
};
|
|
33
|
+
/** Version byte stamped into every sealed frame's authenticated plaintext. */
|
|
34
|
+
export const FRAME_VERSION = 1;
|
|
35
|
+
/** AES-256-GCM room key length, in bytes. */
|
|
36
|
+
export const ROOM_KEY_BYTES = 32;
|
|
37
|
+
/** Length of the derived ECDH wrap key (HKDF output), in bytes. */
|
|
38
|
+
export const WRAP_KEY_BYTES = 32;
|
|
39
|
+
/** Length of the out-of-band pairing secret carried in the QR, in bytes. */
|
|
40
|
+
export const PAIR_SECRET_BYTES = 32;
|
|
41
|
+
/**
|
|
42
|
+
* Packed sealed-envelope layout: [ IV | GCM tag | ciphertext ], base64.
|
|
43
|
+
* The receiver slices by these fixed offsets, so they are wire-observable.
|
|
44
|
+
*/
|
|
45
|
+
export const IV_BYTES = 12;
|
|
46
|
+
export const GCM_TAG_BYTES = 16;
|
|
47
|
+
/** Offset where ciphertext begins (= IV_BYTES + GCM_TAG_BYTES). */
|
|
48
|
+
export const SEALED_HEADER_BYTES = IV_BYTES + GCM_TAG_BYTES; // 28
|
|
49
|
+
/** Random freshness nonce embedded in each frame's authenticated plaintext. */
|
|
50
|
+
export const FRAME_NONCE_BYTES = 12;
|
|
51
|
+
/**
|
|
52
|
+
* ReplayGuard freshness window and bounded seen-nonce cache size. Frames older
|
|
53
|
+
* than the window (either direction) or with an already-seen nonce are dropped.
|
|
54
|
+
*/
|
|
55
|
+
export const REPLAY_WINDOW_MS = 5 * 60_000;
|
|
56
|
+
export const MAX_SEEN_NONCES = 5000;
|
|
57
|
+
/**
|
|
58
|
+
* Relay frame chunking. The relay caps each WebSocket message at
|
|
59
|
+
* RELAY_MAX_FRAME_BYTES (256 KiB default); larger sealed payloads are split into
|
|
60
|
+
* ordered slices of at most FRAME_CHUNK_BYTES and reassembled before decrypting.
|
|
61
|
+
* Kept comfortably below the relay cap so the JSON envelope still fits.
|
|
62
|
+
*/
|
|
63
|
+
export const FRAME_CHUNK_BYTES = 192 * 1024;
|
|
64
|
+
/** Reassembly safety caps — defend against a buggy/hostile peer. */
|
|
65
|
+
export const MAX_REASSEMBLY_BYTES = 32 * 1024 * 1024;
|
|
66
|
+
export const MAX_FRAME_CHUNKS = 4096;
|
|
67
|
+
export const MAX_REASSEMBLY_GROUPS = 16;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Opportunistic installed-tree dedup for session worktrees (Approach A).
|
|
4
|
+
//
|
|
5
|
+
// Bivy creates a git worktree per session and is not in the install path, so N
|
|
6
|
+
// worktrees of a repo each grow their own node_modules / target / .venv. When
|
|
7
|
+
// the filesystem supports copy-on-write, we can instead clone an already-
|
|
8
|
+
// populated installed dir from a SIBLING worktree of the same repo into a fresh
|
|
9
|
+
// one, so its marginal disk cost is ~its diff, not a full duplicate.
|
|
10
|
+
//
|
|
11
|
+
// Safety rules baked in here:
|
|
12
|
+
// - Opt-in via BIVY_WORKTREE_COW_CLONE.
|
|
13
|
+
// - CoW-only: on a non-CoW filesystem we do NOTHING (a plain copy of
|
|
14
|
+
// node_modules costs the same disk as a fresh install, plus time — a net
|
|
15
|
+
// loss), and let the agent install normally.
|
|
16
|
+
// - Only clone a dir when a sibling's lockfile fingerprint MATCHES the new
|
|
17
|
+
// worktree's, so a reused install is never stale.
|
|
18
|
+
// - Only these git-IGNORED installed/derived dirs — never tracked files (the
|
|
19
|
+
// worktree checkout already has those).
|
|
20
|
+
// - Best-effort: never throws; failure just falls back to a normal install.
|
|
21
|
+
//
|
|
22
|
+
// This is structured so a future "base-provisioning" source (Approach B) can be
|
|
23
|
+
// added as another candidate source without changing callers.
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { detectCloneStrategy, cloneDir } from "./harness/cow-clone.js";
|
|
28
|
+
// Order within `lockfiles` is the fingerprint precedence (first present wins).
|
|
29
|
+
const ECOSYSTEMS = [
|
|
30
|
+
{ dir: "node_modules", lockfiles: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "npm-shrinkwrap.json"] },
|
|
31
|
+
{ dir: "target", lockfiles: ["Cargo.lock"] },
|
|
32
|
+
{ dir: ".venv", lockfiles: ["uv.lock", "poetry.lock", "Pipfile.lock", "requirements.txt"] },
|
|
33
|
+
];
|
|
34
|
+
function isDir(p) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.statSync(p).isDirectory();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Fingerprint the first present lockfile for an ecosystem, or undefined. */
|
|
43
|
+
function lockfileFingerprint(dir, lockfiles) {
|
|
44
|
+
for (const name of lockfiles) {
|
|
45
|
+
try {
|
|
46
|
+
const buf = fs.readFileSync(path.join(dir, name));
|
|
47
|
+
return `${name}:${crypto.createHash("sha256").update(buf).digest("hex")}`;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// not present — try the next
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function listSiblings(worktreesRoot, exclude) {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(worktreesRoot);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const excl = path.resolve(exclude);
|
|
64
|
+
return entries
|
|
65
|
+
.map((e) => path.join(worktreesRoot, e))
|
|
66
|
+
.filter((p) => path.resolve(p) !== excl && isDir(p));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Decide which installed dirs the new worktree can safely reuse from a sibling.
|
|
70
|
+
* Pure filesystem logic (no CoW required), so it is unit-testable everywhere.
|
|
71
|
+
* For each ecosystem: skip if the new worktree already has the dir; otherwise
|
|
72
|
+
* pick the first sibling that HAS the dir and whose lockfile fingerprint matches
|
|
73
|
+
* the new worktree's (guaranteeing the deps are current).
|
|
74
|
+
*/
|
|
75
|
+
export function planCowProvision(opts) {
|
|
76
|
+
const siblings = listSiblings(opts.worktreesRoot, opts.worktreePath);
|
|
77
|
+
const plan = [];
|
|
78
|
+
for (const eco of ECOSYSTEMS) {
|
|
79
|
+
if (isDir(path.join(opts.worktreePath, eco.dir)))
|
|
80
|
+
continue; // never overwrite an existing install
|
|
81
|
+
const destFp = lockfileFingerprint(opts.worktreePath, eco.lockfiles);
|
|
82
|
+
if (!destFp)
|
|
83
|
+
continue; // no lockfile → cannot verify freshness → skip
|
|
84
|
+
for (const sib of siblings) {
|
|
85
|
+
if (!isDir(path.join(sib, eco.dir)))
|
|
86
|
+
continue;
|
|
87
|
+
if (lockfileFingerprint(sib, eco.lockfiles) !== destFp)
|
|
88
|
+
continue; // stale — different deps
|
|
89
|
+
plan.push({ dir: eco.dir, source: path.join(sib, eco.dir) });
|
|
90
|
+
break; // first matching sibling wins
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return plan;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Opportunistically CoW-clone installed dirs into a freshly-created worktree from
|
|
97
|
+
* a sibling of the same repo. Gated on BIVY_WORKTREE_COW_CLONE and on the
|
|
98
|
+
* filesystem supporting copy-on-write. Best-effort; never throws.
|
|
99
|
+
*/
|
|
100
|
+
export function cowProvisionDeps(opts) {
|
|
101
|
+
if (!process.env.BIVY_WORKTREE_COW_CLONE)
|
|
102
|
+
return { strategy: "disabled", cloned: [] };
|
|
103
|
+
const strategy = detectCloneStrategy(opts.worktreesRoot);
|
|
104
|
+
if (strategy === "copy")
|
|
105
|
+
return { strategy: "no-cow", cloned: [] }; // copying installed dirs is a net loss
|
|
106
|
+
const cloned = [];
|
|
107
|
+
for (const item of planCowProvision(opts)) {
|
|
108
|
+
try {
|
|
109
|
+
cloneDir(item.source, path.join(opts.worktreePath, item.dir));
|
|
110
|
+
cloned.push(item.dir);
|
|
111
|
+
opts.log?.(`[worktree] cow-cloned ${item.dir} from ${item.source} (${strategy})`);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
opts.log?.(`[worktree] cow-clone of ${item.dir} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { strategy, cloned };
|
|
118
|
+
}
|