@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
package/dist/guard.js ADDED
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import path from "node:path";
4
+ export function bashCommand(input) {
5
+ if (!input || typeof input !== "object")
6
+ return "";
7
+ const command = input.command;
8
+ return typeof command === "string" ? command : "";
9
+ }
10
+ /** Heuristic for the legacy "risky" mode (prompt-heavy). */
11
+ export function looksRiskyBash(command) {
12
+ const normalized = command.trim().toLowerCase();
13
+ if (!normalized)
14
+ return false;
15
+ return (/(^|[;&|()\s])(rm|rmdir|mv|cp|chmod|chown|dd|mkfs|sudo|su|kill|pkill|curl|wget|git\s+(commit|push|reset|clean|checkout|switch|merge|rebase)|npm\s+(install|update|publish)|pnpm\s+(install|update|publish)|yarn\s+(add|install|upgrade|publish))([;&|()\s]|$)/.test(normalized) ||
16
+ /(^|\s)(>|>>|2>|&>|tee\s+)/.test(normalized));
17
+ }
18
+ /**
19
+ * Catastrophic, irreversible, system-wide actions. Blocked OUTRIGHT in every mode
20
+ * — the boundary that makes unattended autonomy safe rather than reckless.
21
+ */
22
+ export function looksCatastrophic(command) {
23
+ const c = command.trim().toLowerCase();
24
+ if (!c)
25
+ return false;
26
+ return (/\brm\s+(-\S*[rf]\S*\s+)+(\/|~|\$home|\/\*)(\s|$|\/)/.test(c) || // rm -rf / | ~ | /*
27
+ /\bmkfs(\.\w+)?\b/.test(c) ||
28
+ /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/.test(c) ||
29
+ />\s*\/dev\/(sd|nvme|hd|disk)/.test(c) ||
30
+ /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c) || // fork bomb
31
+ /\bchmod\s+-\S*r\S*\s+777\s+\/(\s|$)/.test(c) ||
32
+ /(^|[;&|]\s*)(shutdown|reboot|halt|poweroff)\b/.test(c));
33
+ }
34
+ /**
35
+ * Irreversible / outward-facing actions that pause for a human even in autonomous
36
+ * mode (the "backstop"): publishing, deploying, force-pushing, pushing to the
37
+ * default branch, privilege escalation.
38
+ */
39
+ export function looksBackstop(command) {
40
+ const c = command.trim().toLowerCase();
41
+ if (!c)
42
+ return false;
43
+ return (/\bgit\s+push\b[^\n]*(--force|--force-with-lease|\s-f\b)/.test(c) ||
44
+ /\bgit\s+push\b[^\n]*\b(main|master)\b/.test(c) ||
45
+ /\bnpm\s+publish\b/.test(c) ||
46
+ /\b(kubectl\s+apply|terraform\s+apply|docker\s+push|fly\s+deploy|vercel\s+(deploy|--prod)|netlify\s+deploy|gh\s+release\s+create)\b/.test(c) ||
47
+ /(^|[;&|]\s*)(mail|mailx|sendmail|ssmtp)\b/.test(c) || // send email from the shell
48
+ /(^|[;&|]\s*)sudo\b/.test(c));
49
+ }
50
+ /** Extract a filesystem path from a write/edit tool input, if present. */
51
+ export function toolPath(input) {
52
+ if (!input || typeof input !== "object")
53
+ return undefined;
54
+ const o = input;
55
+ for (const key of ["path", "file_path", "filePath", "file"]) {
56
+ if (typeof o[key] === "string" && o[key])
57
+ return o[key];
58
+ }
59
+ return undefined;
60
+ }
61
+ /** True if `p` resolves outside `workspace` (so a write there escapes the boundary). */
62
+ export function pathEscapesWorkspace(workspace, p) {
63
+ const root = path.resolve(workspace);
64
+ const abs = path.resolve(root, p);
65
+ return abs !== root && !abs.startsWith(root + path.sep);
66
+ }
67
+ /**
68
+ * Decide what to do with a tool call. The hard floor (catastrophic commands,
69
+ * writes outside the session workspace) applies in every mode. Above that floor,
70
+ * `mode` decides how often to prompt.
71
+ */
72
+ export function guardToolCall(workspace, toolName, input, mode, isRiskyIntegration) {
73
+ // Built-in tool names arrive with runtime-specific casing: the Claude Code SDK
74
+ // sends `Bash`/`Write`/`Edit` verbatim, while Pi/others send lowercase. Match
75
+ // case-insensitively so the hard floor below fires for every runtime — a
76
+ // case-sensitive compare silently disabled it for claude-code. Integration
77
+ // tool names (MCP, etc.) keep their original casing via `isRiskyIntegration`.
78
+ const tool = toolName.toLowerCase();
79
+ // Tools that write to the filesystem and so must respect the workspace
80
+ // boundary. MultiEdit/NotebookEdit also take a `file_path` (see toolPath).
81
+ const isWrite = tool === "write" || tool === "edit" || tool === "multiedit" || tool === "notebookedit";
82
+ // --- Hard floor: blocked in every mode ---
83
+ if (tool === "bash" && looksCatastrophic(bashCommand(input))) {
84
+ return { decision: "deny", reason: "Blocked: catastrophic command (outside the safety boundary)" };
85
+ }
86
+ if (isWrite) {
87
+ const p = toolPath(input);
88
+ if (p && pathEscapesWorkspace(workspace, p)) {
89
+ return { decision: "deny", reason: "Blocked: write outside the workspace boundary" };
90
+ }
91
+ }
92
+ if (mode === "never")
93
+ return { decision: "allow" };
94
+ // Integration tools flagged risky (send email, upload, ...) always confirm.
95
+ if (isRiskyIntegration(toolName))
96
+ return { decision: "ask" };
97
+ if (mode === "autonomous") {
98
+ if (tool === "bash" && looksBackstop(bashCommand(input)))
99
+ return { decision: "ask" };
100
+ return { decision: "allow" };
101
+ }
102
+ if (mode === "always") {
103
+ return { decision: tool === "bash" || isWrite ? "ask" : "allow" };
104
+ }
105
+ // "risky"
106
+ if (tool === "bash")
107
+ return { decision: looksRiskyBash(bashCommand(input)) ? "ask" : "allow" };
108
+ return { decision: isWrite ? "ask" : "allow" };
109
+ }
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Size-capped LRU eviction for regenerable caches (e.g. the shared dep cache).
4
+ //
5
+ // The shared package-manager cache (BIVY_SHARED_DEP_CACHE) grows without bound
6
+ // as sessions pull dependencies. These caches are content-addressed and
7
+ // self-healing — a package manager simply re-downloads anything missing — so it
8
+ // is safe to evict least-recently-used files to hold the cache under a byte cap.
9
+ // Best-effort: a locked/racing file is skipped, not fatal.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ /** Recursive total size of the files under `dir` (symlinks not followed). */
13
+ export function dirSizeBytes(dir) {
14
+ let total = 0;
15
+ let entries;
16
+ try {
17
+ entries = fs.readdirSync(dir, { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return 0;
21
+ }
22
+ for (const e of entries) {
23
+ const p = path.join(dir, e.name);
24
+ try {
25
+ if (e.isDirectory())
26
+ total += dirSizeBytes(p);
27
+ else if (e.isFile())
28
+ total += fs.statSync(p).size;
29
+ }
30
+ catch {
31
+ // unreadable entry — skip
32
+ }
33
+ }
34
+ return total;
35
+ }
36
+ /**
37
+ * Evict least-recently-modified files under `root` until the total size is at or
38
+ * below `maxBytes`. No-op when already under the cap or `maxBytes <= 0`.
39
+ */
40
+ export function evictToCap(root, maxBytes) {
41
+ const files = [];
42
+ const walk = (dir) => {
43
+ let entries;
44
+ try {
45
+ entries = fs.readdirSync(dir, { withFileTypes: true });
46
+ }
47
+ catch {
48
+ return;
49
+ }
50
+ for (const e of entries) {
51
+ const p = path.join(dir, e.name);
52
+ try {
53
+ if (e.isDirectory())
54
+ walk(p);
55
+ else if (e.isFile()) {
56
+ const st = fs.statSync(p);
57
+ files.push({ path: p, size: st.size, mtimeMs: st.mtimeMs });
58
+ }
59
+ }
60
+ catch {
61
+ // skip
62
+ }
63
+ }
64
+ };
65
+ walk(root);
66
+ const before = files.reduce((n, f) => n + f.size, 0);
67
+ if (maxBytes <= 0 || before <= maxBytes) {
68
+ return { before, after: before, removedFiles: 0, removedBytes: 0 };
69
+ }
70
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first = least recently used
71
+ let total = before;
72
+ let removedFiles = 0;
73
+ let removedBytes = 0;
74
+ for (const f of files) {
75
+ if (total <= maxBytes)
76
+ break;
77
+ try {
78
+ fs.rmSync(f.path, { force: true });
79
+ total -= f.size;
80
+ removedFiles += 1;
81
+ removedBytes += f.size;
82
+ }
83
+ catch {
84
+ // locked / racing with an install — leave it
85
+ }
86
+ }
87
+ return { before, after: total, removedFiles, removedBytes };
88
+ }
Binary file
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Copy-on-write directory clone primitive.
4
+ //
5
+ // The foundation for installed-tree dedup across session worktrees: cloning a
6
+ // directory (e.g. a populated node_modules / target) so the copy shares blocks
7
+ // with the source until written, making the marginal disk cost of a second
8
+ // worktree ≈ its diff instead of a full duplicate.
9
+ //
10
+ // CoW is filesystem-specific and NOT portable, so we detect capability at
11
+ // runtime and ALWAYS fall back to a plain recursive copy — correct everywhere,
12
+ // just without block sharing:
13
+ // - macOS (APFS, the default) → clonefile via `cp -c`
14
+ // - Linux (btrfs / XFS reflink=1 / ZFS) → reflink via `cp --reflink=always`
15
+ // - Linux ext4, Windows NTFS, unknown → recursive copy (no CoW)
16
+ // - Windows ReFS/Dev Drive → not reachable via `cp`; copy for now
17
+ //
18
+ // Detection probes the REAL mechanism on a throwaway file rather than trusting
19
+ // the filesystem name, because CoW depends on the mount, not just the OS.
20
+ import { spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ const strategyByDir = new Map();
25
+ function tryCp(args) {
26
+ try {
27
+ return spawnSync("cp", args, { stdio: "ignore" }).status === 0;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Determine, once per directory, the best copy-on-write strategy the filesystem
35
+ * backing `dir` supports. Cached per resolved directory. Falls back to "copy".
36
+ */
37
+ export function detectCloneStrategy(dir = os.tmpdir()) {
38
+ const key = path.resolve(dir);
39
+ const cached = strategyByDir.get(key);
40
+ if (cached)
41
+ return cached;
42
+ const strategy = probeCloneStrategy(key);
43
+ strategyByDir.set(key, strategy);
44
+ return strategy;
45
+ }
46
+ function probeCloneStrategy(dir) {
47
+ const src = path.join(dir, `.bivy-cow-probe-${process.pid}-${process.hrtime.bigint()}`);
48
+ const dst = `${src}.clone`;
49
+ try {
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(src, "probe");
52
+ if (process.platform === "darwin" && tryCp(["-c", src, dst]))
53
+ return "apple-clonefile";
54
+ if (process.platform === "linux" && tryCp(["--reflink=always", src, dst]))
55
+ return "reflink";
56
+ return "copy";
57
+ }
58
+ catch {
59
+ return "copy";
60
+ }
61
+ finally {
62
+ fs.rmSync(src, { force: true });
63
+ fs.rmSync(dst, { force: true });
64
+ }
65
+ }
66
+ /**
67
+ * Clone directory `src` to `dst`, using copy-on-write where the destination's
68
+ * filesystem supports it and falling back to a plain recursive copy otherwise.
69
+ * `dst` must not already exist. Returns the strategy actually used.
70
+ */
71
+ export function cloneDir(src, dst) {
72
+ const strategy = detectCloneStrategy(path.dirname(dst));
73
+ if (strategy === "apple-clonefile" && tryCp(["-c", "-R", src, dst]))
74
+ return "apple-clonefile";
75
+ if (strategy === "reflink" && tryCp(["--reflink=always", "-R", src, dst]))
76
+ return "reflink";
77
+ // Universal fallback — correct on every OS/FS, no block sharing.
78
+ fs.cpSync(src, dst, { recursive: true });
79
+ return "copy";
80
+ }
81
+ /** Test-only: forget cached probe results. */
82
+ export function __resetCloneStrategyForTests() {
83
+ strategyByDir.clear();
84
+ }
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Universal Agent Harness — shared dependency-cache singleton.
4
+ //
5
+ // Bivy creates a git worktree per session and is NOT in the dependency-install
6
+ // path: the agent (or user) runs `npm install` / `pip install` / `cargo build`
7
+ // ad-hoc inside the worktree. Without intervention, every worktree re-downloads
8
+ // the same packages into its own tree, so N sessions of the same repo multiply
9
+ // both disk and network.
10
+ //
11
+ // When BIVY_SHARED_DEP_CACHE is set, point every package manager at ONE shared
12
+ // cache/registry directory via env, so downloads dedup across all sessions.
13
+ // These are cache/registry knobs only — they never change a project's lockfile
14
+ // or where its dependencies actually install, so the user's project tooling is
15
+ // unaffected. Opt-in, mirroring the egress-proxy singleton (see egress.ts).
16
+ //
17
+ // Note: this dedups DOWNLOADS, not the installed trees themselves (each worktree
18
+ // still gets its own node_modules/target/...). Installed-tree dedup needs either
19
+ // copy-on-write worktrees or a base-provisioning step Bivy does not have today —
20
+ // see docs/worktree-disk-strategy.md.
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ let cacheRoot;
24
+ /**
25
+ * Enable the shared dep cache if BIVY_SHARED_DEP_CACHE is set. Idempotent.
26
+ * Value "1"/"true" → a default location under the Bivy data dir; any other
27
+ * value is treated as an explicit cache-root path. Returns the resolved root,
28
+ * or undefined when disabled (or the root could not be created).
29
+ */
30
+ export function initSharedDepCache(dataDir) {
31
+ if (cacheRoot)
32
+ return cacheRoot;
33
+ const flag = process.env.BIVY_SHARED_DEP_CACHE;
34
+ if (!flag)
35
+ return undefined;
36
+ const root = flag === "1" || flag.toLowerCase() === "true" ? path.join(dataDir, "dep-cache") : flag;
37
+ try {
38
+ fs.mkdirSync(root, { recursive: true });
39
+ }
40
+ catch {
41
+ // If we can't create the shared root, stay disabled rather than point every
42
+ // package manager at an unwritable path (which would break installs).
43
+ return undefined;
44
+ }
45
+ cacheRoot = root;
46
+ return cacheRoot;
47
+ }
48
+ /**
49
+ * Env to merge into an agent/terminal subprocess so its package managers share
50
+ * one cache, or {} when disabled. Cache/registry locations only.
51
+ */
52
+ export function depCacheEnv() {
53
+ return cacheRoot ? sharedCacheEnvFor(cacheRoot) : {};
54
+ }
55
+ /**
56
+ * Pure mapping of a cache root → per-tool cache env. Exported for tests and so
57
+ * the mapping stays reviewable in one place. Add ecosystems here as needed
58
+ * (Ruby GEM/bundler and Gradle change install/config location, not just cache,
59
+ * so they are intentionally left out until validated — see the strategy doc).
60
+ */
61
+ export function sharedCacheEnvFor(root) {
62
+ return {
63
+ npm_config_cache: path.join(root, "npm"), // npm + npx download cache
64
+ YARN_CACHE_FOLDER: path.join(root, "yarn"), // yarn (classic) cache
65
+ PIP_CACHE_DIR: path.join(root, "pip"), // pip wheel/download cache
66
+ CARGO_HOME: path.join(root, "cargo"), // cargo registry + git cache
67
+ GOMODCACHE: path.join(root, "go", "mod"), // go module cache
68
+ GOCACHE: path.join(root, "go", "build"), // go build cache
69
+ };
70
+ }
71
+ /** The resolved shared cache root, or undefined when disabled. */
72
+ export function sharedDepCacheRoot() {
73
+ return cacheRoot;
74
+ }
75
+ /** Test-only: forget the resolved root so a fresh init can run. */
76
+ export function __resetSharedDepCacheForTests() {
77
+ cacheRoot = undefined;
78
+ }
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Disk admission control.
4
+ //
5
+ // The safe form of a hard disk cap: rather than deleting a live worktree that
6
+ // may hold uncommitted work (destructive), refuse to PROVISION new
7
+ // disk-consuming work — a fresh session worktree — when the filesystem is
8
+ // already low on free space. Bivy therefore never fills the user's device; the
9
+ // user frees space (or closes a session) and the next attempt succeeds.
10
+ //
11
+ // Opt-in via BIVY_MIN_FREE_DISK_BYTES (0/unset disables). Best-effort: if free
12
+ // space cannot be measured we ALLOW — real work must never be blocked by a
13
+ // measurement error.
14
+ import fs from "node:fs";
15
+ const mib = (n) => Math.round(n / (1024 * 1024));
16
+ function defaultStatfs(p) {
17
+ const st = fs.statfsSync(p);
18
+ return { bavail: Number(st.bavail), bsize: Number(st.bsize) };
19
+ }
20
+ /**
21
+ * Decide whether new disk-consuming work may be admitted on the filesystem
22
+ * backing `pathOnFs`. `minFreeBytes` defaults to BIVY_MIN_FREE_DISK_BYTES;
23
+ * <= 0 disables the guard (always allowed). `statfs` is injectable for tests.
24
+ */
25
+ export function checkDiskAdmission(pathOnFs, opts = {}) {
26
+ const minFreeBytes = opts.minFreeBytes ?? Number(process.env.BIVY_MIN_FREE_DISK_BYTES ?? 0);
27
+ if (!Number.isFinite(minFreeBytes) || minFreeBytes <= 0)
28
+ return { allowed: true };
29
+ let freeBytes;
30
+ try {
31
+ const st = (opts.statfs ?? defaultStatfs)(pathOnFs);
32
+ freeBytes = st.bavail * st.bsize;
33
+ }
34
+ catch {
35
+ return { allowed: true }; // cannot measure → do not block real work
36
+ }
37
+ if (freeBytes < minFreeBytes) {
38
+ return {
39
+ allowed: false,
40
+ freeBytes,
41
+ minFreeBytes,
42
+ reason: `only ${mib(freeBytes)} MiB free (need ${mib(minFreeBytes)} MiB) — free up disk, close a session, or lower BIVY_MIN_FREE_DISK_BYTES`,
43
+ };
44
+ }
45
+ return { allowed: true, freeBytes, minFreeBytes };
46
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Universal Agent Harness — egress-proxy singleton.
4
+ //
5
+ // A tiny shared holder so the daemon (server.ts) can start one EgressProxy at
6
+ // boot and the runtime layer (process.ts) can inject its env into every agent
7
+ // subprocess — without process.ts importing the server. Opt-in via the
8
+ // BIVY_EGRESS_PROXY env var, so routing all agent traffic through the broker is
9
+ // an explicit choice (it adds a hop and logs destinations).
10
+ import { EgressProxy } from "./net-proxy.js";
11
+ let proxy;
12
+ /** Start the egress proxy if BIVY_EGRESS_PROXY is set. Idempotent. */
13
+ export async function startEgressProxyIfEnabled(onEvent) {
14
+ if (proxy)
15
+ return proxy;
16
+ if (!process.env.BIVY_EGRESS_PROXY)
17
+ return undefined;
18
+ proxy = await EgressProxy.start({ onEvent });
19
+ return proxy;
20
+ }
21
+ /** Proxy env to merge into an agent subprocess, or {} when disabled. */
22
+ export function egressEnv() {
23
+ return proxy ? proxy.env() : {};
24
+ }
25
+ export async function stopEgressProxy() {
26
+ if (!proxy)
27
+ return;
28
+ await proxy.stop();
29
+ proxy = undefined;
30
+ }
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Universal Agent Harness — per-session checkpoint lifecycle.
4
+ //
5
+ // Thin, runtime-agnostic coordinator the daemon calls around a turn, regardless
6
+ // of which agent runtime backs the session. It owns one CheckpointStore per live
7
+ // session and turns the raw git primitives into the three events the product
8
+ // needs: a snapshot before a turn, the structured diff a turn produced, and a
9
+ // rewind. If a session's workspace is not a git repo, the harness disables itself
10
+ // for that session (every method becomes a safe no-op) so non-repo sessions keep
11
+ // working exactly as before.
12
+ //
13
+ // Deliberately holds no transport/UI types: the daemon maps TurnChanges onto its
14
+ // own `session.changes` broadcast and the `session.rewind` command. Unit-tested
15
+ // in test/harness-manager.test.ts.
16
+ import { CheckpointStore, NotAGitRepoError } from "./checkpoint.js";
17
+ export class HarnessManager {
18
+ stores = new Map();
19
+ /** Checkpoint captured at the start of the currently-running turn, per session. */
20
+ turnBase = new Map();
21
+ /**
22
+ * Begin tracking a session's workspace. Returns true when checkpointing is
23
+ * available (the workspace is inside a git repo), false otherwise — the caller
24
+ * can surface a "rewind unavailable" hint but must not treat false as an error.
25
+ */
26
+ async attach(sessionId, worktree) {
27
+ if (this.stores.has(sessionId))
28
+ return true;
29
+ try {
30
+ const store = await CheckpointStore.open(worktree, sessionId);
31
+ this.stores.set(sessionId, store);
32
+ return true;
33
+ }
34
+ catch (error) {
35
+ if (error instanceof NotAGitRepoError)
36
+ return false;
37
+ throw error;
38
+ }
39
+ }
40
+ detach(sessionId) {
41
+ this.stores.delete(sessionId);
42
+ this.turnBase.delete(sessionId);
43
+ }
44
+ isTracking(sessionId) {
45
+ return this.stores.has(sessionId);
46
+ }
47
+ /**
48
+ * Snapshot the workspace at the start of a turn. Safe no-op for untracked
49
+ * (non-repo) sessions. Idempotent within a turn: a second call before endTurn
50
+ * keeps the original base so a mid-turn re-prompt doesn't lose the diff origin.
51
+ */
52
+ async beginTurn(sessionId, label) {
53
+ const store = this.stores.get(sessionId);
54
+ if (!store)
55
+ return;
56
+ if (this.turnBase.has(sessionId))
57
+ return;
58
+ this.turnBase.set(sessionId, await store.snapshot(label));
59
+ }
60
+ /**
61
+ * Snapshot the workspace at the end of a turn and return the structured diff
62
+ * against the turn's base. Returns undefined for untracked sessions or when
63
+ * beginTurn was never called. Returns an empty `changes` array when the turn
64
+ * touched no files.
65
+ */
66
+ async endTurn(sessionId, label) {
67
+ const store = this.stores.get(sessionId);
68
+ if (!store)
69
+ return undefined;
70
+ if (!this.turnBase.has(sessionId))
71
+ return undefined;
72
+ const before = this.turnBase.get(sessionId);
73
+ this.turnBase.delete(sessionId);
74
+ const after = await store.snapshot(label);
75
+ const changes = before ? await store.changesBetween(before, after) : [];
76
+ return { before, after, changes };
77
+ }
78
+ /** The full checkpoint chain for a session, newest first. */
79
+ async checkpoints(sessionId) {
80
+ const store = this.stores.get(sessionId);
81
+ if (!store)
82
+ return [];
83
+ return await store.list();
84
+ }
85
+ /**
86
+ * Restore a session's workspace to a checkpoint. Throws if the session isn't
87
+ * tracked or the id is unknown to git. Any in-flight turn base is cleared so
88
+ * the next turn re-snapshots from the restored state.
89
+ */
90
+ async rewind(sessionId, checkpointId) {
91
+ const store = this.stores.get(sessionId);
92
+ if (!store)
93
+ throw new Error(`Session ${sessionId} is not tracked by the harness.`);
94
+ await store.rewindTo(checkpointId);
95
+ this.turnBase.delete(sessionId);
96
+ }
97
+ }