@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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -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
+ }