@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,49 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /** Fetch issue content without sending the Linear API key or description to the control plane. */
4
+ export async function getLinearIssue(apiKey, id, fetchImpl = fetch) {
5
+ if (!apiKey.trim() || !id.trim())
6
+ return undefined;
7
+ const res = await fetchImpl("https://api.linear.app/graphql", {
8
+ method: "POST",
9
+ headers: { authorization: apiKey, "content-type": "application/json" },
10
+ body: JSON.stringify({
11
+ query: "query BivyLinearIssue($id: String!) { issue(id: $id) { id identifier title description url } }",
12
+ variables: { id },
13
+ }),
14
+ });
15
+ if (!res.ok)
16
+ return undefined;
17
+ const raw = (await res.json().catch(() => undefined));
18
+ const issue = raw?.data?.issue;
19
+ if (!issue || raw?.errors?.length)
20
+ return undefined;
21
+ const identifier = String(issue.identifier ?? "").trim();
22
+ const title = String(issue.title ?? "").trim();
23
+ if (!identifier || !title)
24
+ return undefined;
25
+ return {
26
+ id: String(issue.id ?? id),
27
+ identifier,
28
+ title,
29
+ description: String(issue.description ?? ""),
30
+ url: String(issue.url ?? ""),
31
+ };
32
+ }
33
+ export function linearBranchName(identifier) {
34
+ const slug = identifier.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
35
+ return `bivy/linear-${slug || "issue"}`;
36
+ }
37
+ export function buildLinearTaskPrompt(issue) {
38
+ return [
39
+ `You are working on Linear issue ${issue.identifier}: ${issue.title}`,
40
+ "",
41
+ issue.description.trim() || "(no description provided)",
42
+ "",
43
+ issue.url ? `Issue: ${issue.url}` : "",
44
+ "",
45
+ "Understand the issue and surrounding codebase, implement it completely, and run the project's tests, linter, and type-checker.",
46
+ "",
47
+ `When finished, commit and push your changes and open a pull request yourself. Include a link to ${issue.identifier} in the pull request description.`,
48
+ ].filter((part, index, all) => part !== "" || all[index - 1] !== "").join("\n");
49
+ }
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ function nowIso() { return new Date().toISOString(); }
6
+ function iso(value, fallback = Date.now()) {
7
+ const ms = new Date(value).getTime();
8
+ return new Date(Number.isFinite(ms) ? ms : fallback).toISOString();
9
+ }
10
+ function empty() { return { version: 1, sessions: {}, workspaces: {}, approvals: {}, devices: {} }; }
11
+ // Coalescing window for the metadata file. A streaming agent fires a metadata
12
+ // mutation (touchSession/upsertSession) per output line; without debouncing,
13
+ // each did a synchronous whole-file fsync rewrite whose cost is O(all sessions).
14
+ // At many writes/sec × many sessions that is O(sessions²) of blocking work on
15
+ // the single event loop — enough to peg a core. Coalescing collapses a burst
16
+ // into at most one fsync per window. Durability is bounded to this window: a
17
+ // clean shutdown calls flushSync(), and any 'working' row left stale by a hard
18
+ // crash is reconciled on restart (resetStaleWorking), so the trade is safe.
19
+ const SAVE_DEBOUNCE_MS = 150;
20
+ export class MetadataStore {
21
+ filePath;
22
+ data;
23
+ /** True when an in-memory mutation has not yet been persisted to disk. */
24
+ dirty = false;
25
+ /** Pending coalesced-write timer, or null when nothing is queued. */
26
+ saveTimer = null;
27
+ constructor(filePath, data) {
28
+ this.filePath = filePath;
29
+ this.data = data;
30
+ }
31
+ static load(appDir) {
32
+ const filePath = path.join(appDir, "metadata.json");
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
35
+ return new MetadataStore(filePath, {
36
+ version: 1,
37
+ sessions: parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {},
38
+ workspaces: parsed.workspaces && typeof parsed.workspaces === "object" ? parsed.workspaces : {},
39
+ approvals: parsed.approvals && typeof parsed.approvals === "object" ? parsed.approvals : {},
40
+ devices: parsed.devices && typeof parsed.devices === "object" ? parsed.devices : {},
41
+ });
42
+ }
43
+ catch {
44
+ const store = new MetadataStore(filePath, empty());
45
+ store.persistNow();
46
+ return store;
47
+ }
48
+ }
49
+ /**
50
+ * Request a persist. Coalesces a burst of mutations into at most one fsync
51
+ * per SAVE_DEBOUNCE_MS instead of a synchronous whole-file fsync per call —
52
+ * the hot-path cost that pegged the event loop. The mutation is already live
53
+ * in memory (the store is the source of truth); only the disk write is
54
+ * deferred. A clean shutdown must call flushSync() to persist the tail.
55
+ */
56
+ save() {
57
+ this.dirty = true;
58
+ if (this.saveTimer)
59
+ return;
60
+ this.saveTimer = setTimeout(() => {
61
+ this.saveTimer = null;
62
+ if (this.dirty)
63
+ this.persistNow();
64
+ }, SAVE_DEBOUNCE_MS);
65
+ // Don't keep the process alive just to flush metadata — a pending write is
66
+ // flushed synchronously on shutdown via flushSync().
67
+ this.saveTimer.unref?.();
68
+ }
69
+ /**
70
+ * Synchronously flush any pending debounced write. Call on shutdown so an
71
+ * in-flight coalesced change isn't lost, or when a caller needs the on-disk
72
+ * file to immediately reflect a just-made mutation.
73
+ */
74
+ flushSync() {
75
+ if (this.saveTimer) {
76
+ clearTimeout(this.saveTimer);
77
+ this.saveTimer = null;
78
+ }
79
+ if (this.dirty)
80
+ this.persistNow();
81
+ }
82
+ /** Atomically write the metadata blob (temp file, fsync, rename). Synchronous
83
+ * and O(all sessions) — reached only through the debounced `save()` or an
84
+ * explicit `flushSync()`, never once per hot-path mutation. */
85
+ persistNow() {
86
+ this.dirty = false;
87
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
88
+ const tmp = `${this.filePath}.tmp`;
89
+ // fsync the temp file before the atomic rename. Without it the rename can be
90
+ // persisted before the data blocks, so a power cut can surface a zero-length
91
+ // metadata.json and lose the entire session index.
92
+ const fd = fs.openSync(tmp, "w", 0o600);
93
+ try {
94
+ fs.writeSync(fd, `${JSON.stringify(this.data, null, 2)}\n`);
95
+ fs.fsyncSync(fd);
96
+ }
97
+ finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ fs.renameSync(tmp, this.filePath);
101
+ }
102
+ upsertSession(input) {
103
+ const prev = this.data.sessions[input.id];
104
+ const createdAt = input.createdAt ?? prev?.createdAt ?? nowIso();
105
+ const updatedAt = input.updatedAt ?? nowIso();
106
+ this.data.sessions[input.id] = { ...prev, ...input, createdAt, updatedAt };
107
+ this.save();
108
+ }
109
+ touchSession(id, status) {
110
+ const prev = this.data.sessions[id];
111
+ if (!prev)
112
+ return;
113
+ const at = nowIso();
114
+ this.data.sessions[id] = { ...prev, ...(status ? { status } : {}), updatedAt: at, lastActivityAt: at };
115
+ this.save();
116
+ }
117
+ removeSession(id, sessionPath) {
118
+ if (id && this.data.sessions[id])
119
+ delete this.data.sessions[id];
120
+ if (sessionPath) {
121
+ const resolved = path.resolve(sessionPath);
122
+ for (const [key, session] of Object.entries(this.data.sessions)) {
123
+ if (session.path && path.resolve(session.path) === resolved)
124
+ delete this.data.sessions[key];
125
+ }
126
+ }
127
+ this.save();
128
+ }
129
+ markWorktreePruned(id) {
130
+ const prev = this.data.sessions[id];
131
+ if (!prev?.worktree)
132
+ return;
133
+ this.data.sessions[id] = { ...prev, worktree: undefined, updatedAt: nowIso() };
134
+ this.save();
135
+ }
136
+ listSessions() {
137
+ return Object.values(this.data.sessions).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
138
+ }
139
+ /**
140
+ * Clear transient "working" status left over from a crash/kill. Call once at
141
+ * boot, before any session is resumed: a fresh process has no live runtimes,
142
+ * so any persisted "working" row is stale. Leaving it set permanently exempts
143
+ * the session's worktree from cleanup (an unbounded disk leak). Returns the
144
+ * ids of the sessions that were reset — i.e. the ones cut off mid-turn by the
145
+ * process death, which the resume reconciler picks up (auto-continue or offer
146
+ * a manual "Resume").
147
+ */
148
+ resetStaleWorking() {
149
+ const reset = [];
150
+ for (const [id, session] of Object.entries(this.data.sessions)) {
151
+ if (session.status === "working") {
152
+ this.data.sessions[id] = { ...session, status: "idle" };
153
+ reset.push(id);
154
+ }
155
+ }
156
+ if (reset.length > 0)
157
+ this.save();
158
+ return reset;
159
+ }
160
+ /** Set/clear the durable "resume pending" flag (manual resume mode). No-op when
161
+ * the row is missing or already in the requested state, so it never churns the
162
+ * file on the hot turn-completion path. */
163
+ setResumePending(id, pending) {
164
+ const prev = this.data.sessions[id];
165
+ if (!prev)
166
+ return;
167
+ if ((prev.resumePending ?? false) === pending)
168
+ return;
169
+ this.data.sessions[id] = { ...prev, resumePending: pending, updatedAt: nowIso() };
170
+ this.save();
171
+ }
172
+ /** Look up a session's durable metadata by id, or by session-file path. */
173
+ getSession(idOrPath) {
174
+ if (!idOrPath)
175
+ return undefined;
176
+ const direct = this.data.sessions[idOrPath];
177
+ if (direct)
178
+ return direct;
179
+ const resolved = path.resolve(idOrPath);
180
+ return Object.values(this.data.sessions).find((session) => session.path && path.resolve(session.path) === resolved);
181
+ }
182
+ rememberWorkspace(workspace) {
183
+ const resolved = path.resolve(workspace);
184
+ this.data.workspaces[resolved] = { path: resolved, lastUsedAt: nowIso() };
185
+ this.save();
186
+ }
187
+ listWorkspaces() {
188
+ return Object.values(this.data.workspaces).sort((a, b) => new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime()).map((w) => w.path);
189
+ }
190
+ removeWorkspace(workspace) {
191
+ delete this.data.workspaces[path.resolve(workspace)];
192
+ this.save();
193
+ }
194
+ recordApproval(input) {
195
+ this.data.approvals[input.id] = {
196
+ id: input.id,
197
+ sessionId: input.sessionId,
198
+ toolName: input.toolName,
199
+ reason: input.reason,
200
+ risk: input.risk,
201
+ status: input.status,
202
+ createdAt: iso(input.createdAt),
203
+ resolvedAt: input.resolvedAt ? iso(input.resolvedAt) : undefined,
204
+ workspace: input.workspace,
205
+ repo: input.repo,
206
+ branch: input.branch,
207
+ };
208
+ const entries = Object.entries(this.data.approvals).sort((a, b) => new Date(b[1].createdAt).getTime() - new Date(a[1].createdAt).getTime()).slice(0, 1000);
209
+ this.data.approvals = Object.fromEntries(entries);
210
+ this.save();
211
+ }
212
+ listApprovals(limit = 100) {
213
+ return Object.values(this.data.approvals).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, limit);
214
+ }
215
+ upsertDevice(input) {
216
+ const prev = this.data.devices[input.id];
217
+ this.data.devices[input.id] = { ...prev, ...input, firstSeenAt: input.firstSeenAt ?? prev?.firstSeenAt ?? nowIso(), lastSeenAt: input.lastSeenAt ?? prev?.lastSeenAt };
218
+ this.save();
219
+ }
220
+ revokeDevice(id) {
221
+ const prev = this.data.devices[id] ?? { id };
222
+ this.data.devices[id] = { ...prev, revokedAt: nowIso() };
223
+ this.save();
224
+ }
225
+ listDevices() { return Object.values(this.data.devices).sort((a, b) => String(b.lastSeenAt ?? b.firstSeenAt ?? "").localeCompare(String(a.lastSeenAt ?? a.firstSeenAt ?? ""))); }
226
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { execFile } from "node:child_process";
4
+ function runCmd(command, args, timeoutMs = 2500) {
5
+ return new Promise((resolve) => {
6
+ execFile(command, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
7
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
8
+ resolve({ code, stdout: String(stdout || ""), stderr: String(stderr || "") });
9
+ });
10
+ });
11
+ }
12
+ // Strip ANSI so multiplexers that colorize `list-sessions` (zellij) parse cleanly.
13
+ function stripAnsi(s) {
14
+ // eslint-disable-next-line no-control-regex
15
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
16
+ }
17
+ // Pure parsers (exported for tests) — take a multiplexer's raw stdout and return
18
+ // sessions. Kept separate from process spawning so parsing is verifiable.
19
+ // tmux is asked for "<attached>:<name>" — `session_attached` is a single char
20
+ // ("1" when a client is attached), so a fixed 2-char prefix delimits the name
21
+ // without an exotic separator (a literal tab is mishandled by tmux -F), and the
22
+ // name may itself contain ":".
23
+ export function parseTmux(stdout) {
24
+ return stdout.split("\n").filter((line) => /^[01]:/.test(line)).map((line) => {
25
+ const attached = line[0] === "1";
26
+ const name = line.slice(2);
27
+ return { multiplexer: "tmux", name, attached, target: `tmux:${name}` };
28
+ });
29
+ }
30
+ export function parseZellij(stdout) {
31
+ return stripAnsi(stdout).split("\n").map((line) => line.trim())
32
+ // Lines look like: "name [Created ...] (current)" or "name (EXITED ...)".
33
+ // Drop exited sessions — they can't be attached to.
34
+ .filter((line) => line && !/EXITED/i.test(line))
35
+ .map((line) => {
36
+ const name = line.split(/\s+/)[0];
37
+ const attached = /\(current\)/i.test(line);
38
+ return { multiplexer: "zellij", name, attached, target: `zellij:${name}` };
39
+ });
40
+ }
41
+ export function parseScreen(stdout) {
42
+ return stdout.split("\n").map((line) => line.trim()).filter((line) => /^\d+\./.test(line)).map((line) => {
43
+ const name = line.split(/\s+/)[0]; // e.g. "12345.work"
44
+ const attached = /\(Attached\)/i.test(line);
45
+ return { multiplexer: "screen", name, attached, target: `screen:${name}` };
46
+ });
47
+ }
48
+ export async function listTmux() {
49
+ // A tab-delimited format keeps parsing robust against names with spaces.
50
+ const { code, stdout } = await runCmd("tmux", ["list-sessions", "-F", "#{session_attached}:#{session_name}"]);
51
+ return code === 0 ? parseTmux(stdout) : [];
52
+ }
53
+ export async function listZellij() {
54
+ const { code, stdout } = await runCmd("zellij", ["list-sessions", "--no-formatting"]);
55
+ // Older zellij lacks --no-formatting; retry without it.
56
+ const out = code === 0 ? stdout : (await runCmd("zellij", ["list-sessions"])).stdout;
57
+ return parseZellij(out);
58
+ }
59
+ export async function listScreen() {
60
+ // `screen -ls` exits non-zero (1) when listing; parse stdout regardless.
61
+ const { stdout } = await runCmd("screen", ["-ls"]);
62
+ return parseScreen(stdout);
63
+ }
64
+ export async function listMultiplexerSessions() {
65
+ const [tmux, zellij, screen] = await Promise.all([
66
+ listTmux().catch(() => []),
67
+ listZellij().catch(() => []),
68
+ listScreen().catch(() => []),
69
+ ]);
70
+ return [...tmux, ...zellij, ...screen];
71
+ }
72
+ /** The PTY command that attaches to a given multiplexer session. */
73
+ export function attachCommand(kind, name) {
74
+ switch (kind) {
75
+ case "tmux": return { command: "tmux", args: ["attach", "-t", name] };
76
+ case "zellij": return { command: "zellij", args: ["attach", name] };
77
+ case "screen": return { command: "screen", args: ["-r", name] };
78
+ }
79
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { provisionPiAuthJson } from "./runtime/credential-provisioning.js";
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const repoRoot = path.resolve(__dirname, "..");
9
+ // Honor the same overrides as the daemon so packaged/release builds can point
10
+ // at a writable data dir and the bundled pi CLI. Defaults match running from source.
11
+ const dataDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
12
+ const piDir = path.join(dataDir, "pi");
13
+ // The shared, agent-neutral credential vault (not inside any agent's dir).
14
+ const credsDir = path.join(dataDir, "credentials");
15
+ const piCli = process.env.BIVY_PI_CLI ??
16
+ path.join(repoRoot, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
17
+ const workspace = process.env.BIVY_WORKSPACE ?? repoRoot;
18
+ const args = process.argv.slice(2);
19
+ // Pi's CLI reads its plaintext auth.json; project Bivy's vault (refreshed) so the
20
+ // shared logins are available to the native pi command. Best-effort.
21
+ await provisionPiAuthJson(credsDir, piDir).catch(() => { });
22
+ const child = spawn(process.execPath, [piCli, ...args], {
23
+ cwd: workspace,
24
+ stdio: "inherit",
25
+ env: {
26
+ ...process.env,
27
+ PI_CODING_AGENT_DIR: piDir,
28
+ },
29
+ });
30
+ child.on("exit", (code, signal) => {
31
+ if (signal)
32
+ process.kill(process.pid, signal);
33
+ process.exit(code ?? 0);
34
+ });
35
+ child.on("error", (error) => {
36
+ console.error("Failed to start terminal agent.", error);
37
+ process.exit(1);
38
+ });
@@ -0,0 +1,237 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // System-resource snapshot for the node the current session runs on.
4
+ //
5
+ // Surfaced to the web app's "Node stats" panel (header ⋯ menu). For each of
6
+ // memory, CPU, and storage it reports three tiers:
7
+ //
8
+ // * session — the active session's live agent subprocess tree. Agents spawn
9
+ // per-turn and exit, so this is non-zero only while a turn is running (and
10
+ // only for runtimes with a separable OS process — Pi runs in-process, and
11
+ // Claude Code's process lives inside its SDK). `measurable` says whether a
12
+ // figure could be attributed at all.
13
+ // * bivy — the whole Bivy node process tree (server + every live agent child +
14
+ // PTYs). Uniform across runtimes; this is "everything Bivy is using".
15
+ // * node — the machine total (what's available), so the other two can be shown
16
+ // as both an absolute figure and a percentage of the whole node.
17
+ import os from "node:os";
18
+ import { promises as fs } from "node:fs";
19
+ import { execFile } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ const execFileP = promisify(execFile);
22
+ const pct = (part, whole) => whole > 0 ? Math.min(100, Math.max(0, (part / whole) * 100)) : 0;
23
+ async function psSnapshot() {
24
+ const table = new Map();
25
+ try {
26
+ const { stdout } = await execFileP("ps", ["-Ao", "pid=,ppid=,rss=,pcpu="], {
27
+ maxBuffer: 16 * 1024 * 1024,
28
+ timeout: 4000,
29
+ });
30
+ for (const line of stdout.split("\n")) {
31
+ const parts = line.trim().split(/\s+/);
32
+ if (parts.length < 4)
33
+ continue;
34
+ const pid = Number(parts[0]);
35
+ const ppid = Number(parts[1]);
36
+ const rssKb = Number(parts[2]);
37
+ const pcpu = Number(parts[3]);
38
+ if (!Number.isFinite(pid))
39
+ continue;
40
+ table.set(pid, { pid, ppid, rssBytes: (Number.isFinite(rssKb) ? rssKb : 0) * 1024, pcpu: Number.isFinite(pcpu) ? pcpu : 0 });
41
+ }
42
+ }
43
+ catch {
44
+ // ps missing/blocked — subtree sums degrade to empty (tiers report null).
45
+ }
46
+ return table;
47
+ }
48
+ /** All entries in the subtree rooted at `rootPid` (inclusive), by ppid links. */
49
+ function subtree(table, rootPid) {
50
+ const kids = new Map();
51
+ for (const p of table.values()) {
52
+ const list = kids.get(p.ppid);
53
+ if (list)
54
+ list.push(p.pid);
55
+ else
56
+ kids.set(p.ppid, [p.pid]);
57
+ }
58
+ const out = [];
59
+ const seen = new Set();
60
+ const stack = [rootPid];
61
+ while (stack.length) {
62
+ const pid = stack.pop();
63
+ if (seen.has(pid))
64
+ continue;
65
+ seen.add(pid);
66
+ const entry = table.get(pid);
67
+ if (entry)
68
+ out.push(entry);
69
+ for (const child of kids.get(pid) ?? [])
70
+ stack.push(child);
71
+ }
72
+ return out;
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // CPU sampling
76
+ // ---------------------------------------------------------------------------
77
+ function sumCpuTimes() {
78
+ let idle = 0;
79
+ let total = 0;
80
+ for (const cpu of os.cpus()) {
81
+ const t = cpu.times;
82
+ idle += t.idle;
83
+ total += t.user + t.nice + t.sys + t.idle + t.irq;
84
+ }
85
+ return { idle, total };
86
+ }
87
+ /** Sample node-wide busy% and this process's own busy% over one window. The
88
+ * main process's CPU is measured accurately via process.cpuUsage() (the `ps`
89
+ * lifetime-average would badly under-report a long-lived server), while agent
90
+ * children are attributed from the `ps` snapshot. */
91
+ async function sampleCpu(cores, ms = 150) {
92
+ const startCpus = sumCpuTimes();
93
+ const startProc = process.cpuUsage();
94
+ const startAt = Date.now();
95
+ await new Promise((r) => setTimeout(r, ms));
96
+ const endCpus = sumCpuTimes();
97
+ const endProc = process.cpuUsage(startProc);
98
+ const elapsedMs = Math.max(1, Date.now() - startAt);
99
+ const idleDelta = endCpus.idle - startCpus.idle;
100
+ const totalDelta = endCpus.total - startCpus.total;
101
+ const nodePct = totalDelta > 0 ? Math.min(100, Math.max(0, (1 - idleDelta / totalDelta) * 100)) : 0;
102
+ // process.cpuUsage() is microseconds of user+system across all cores this
103
+ // process used in the window. Convert to a share of one core, then of the node.
104
+ const procMicros = endProc.user + endProc.system;
105
+ const mainCorePct = (procMicros / 1000 / elapsedMs) * 100;
106
+ const mainNodePct = cores > 0 ? Math.min(100, mainCorePct / cores) : 0;
107
+ return { nodePct, mainNodePct };
108
+ }
109
+ const duCache = new Map();
110
+ async function measureDir(pathStr) {
111
+ try {
112
+ // -s summary, -k KiB, -x stay on one filesystem (don't wander into mounts).
113
+ const { stdout } = await execFileP("du", ["-skx", pathStr], { timeout: 8000, maxBuffer: 1024 * 1024 });
114
+ const kb = Number(stdout.trim().split(/\s+/)[0]);
115
+ return Number.isFinite(kb) ? kb * 1024 : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /** Directory size in bytes, cached for `ttlMs`. `du` can be slow on big trees,
122
+ * so a stale value is returned immediately while a refresh runs in the
123
+ * background; only the very first call for a path awaits (bounded by du's own
124
+ * timeout) so the panel isn't blank on open. */
125
+ async function dirSizeCached(pathStr, ttlMs = 20000) {
126
+ const now = Date.now();
127
+ const cached = duCache.get(pathStr);
128
+ if (cached && now - cached.at < ttlMs)
129
+ return cached.bytes;
130
+ const entry = cached ?? { bytes: null, at: 0 };
131
+ duCache.set(pathStr, entry);
132
+ if (!entry.inflight) {
133
+ entry.inflight = (async () => {
134
+ const bytes = await measureDir(pathStr);
135
+ entry.bytes = bytes;
136
+ entry.at = Date.now();
137
+ entry.inflight = undefined;
138
+ })();
139
+ }
140
+ if (!cached) {
141
+ try {
142
+ await entry.inflight;
143
+ }
144
+ catch {
145
+ /* measured to null */
146
+ }
147
+ }
148
+ return entry.bytes;
149
+ }
150
+ async function measureFilesystem(pathStr) {
151
+ try {
152
+ const s = await fs.statfs(pathStr);
153
+ const bsize = Number(s.bsize) || 0;
154
+ const total = Number(s.blocks) * bsize;
155
+ const free = Number(s.bavail) * bsize; // available to this unprivileged user
156
+ const used = Math.max(0, total - Number(s.bfree) * bsize);
157
+ return { total, free, used };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ export async function collectNodeStats(opts) {
164
+ const cores = os.cpus().length;
165
+ const cpuModel = os.cpus()[0]?.model?.trim() || null;
166
+ const [table, cpuSample, fsInfo] = await Promise.all([
167
+ psSnapshot(),
168
+ sampleCpu(cores),
169
+ measureFilesystem(opts.workspacePath),
170
+ ]);
171
+ // --- Memory & CPU: Bivy = this process's whole subtree ---
172
+ const bivyProcs = subtree(table, process.pid);
173
+ let bivyRss = 0;
174
+ let bivyChildPcpu = 0;
175
+ for (const p of bivyProcs) {
176
+ bivyRss += p.rssBytes;
177
+ if (p.pid !== process.pid)
178
+ bivyChildPcpu += p.pcpu;
179
+ }
180
+ // If ps gave us nothing (no table), fall back to this process's own RSS.
181
+ if (bivyProcs.length === 0)
182
+ bivyRss = process.memoryUsage().rss;
183
+ const bivyCpuNodePct = Math.min(100, cpuSample.mainNodePct + (cores > 0 ? bivyChildPcpu / cores : 0));
184
+ // --- Session tier (only when a live, separable agent process exists) ---
185
+ let sessionMem = null;
186
+ let sessionCpu = null;
187
+ let sessionMeasurable = false;
188
+ if (typeof opts.sessionPid === "number" && table.has(opts.sessionPid)) {
189
+ const procs = subtree(table, opts.sessionPid);
190
+ let rss = 0;
191
+ let pcpu = 0;
192
+ for (const p of procs) {
193
+ rss += p.rssBytes;
194
+ pcpu += p.pcpu;
195
+ }
196
+ sessionMeasurable = true;
197
+ sessionMem = { bytes: rss, pct: pct(rss, os.totalmem()) };
198
+ sessionCpu = { pct: Math.min(100, cores > 0 ? pcpu / cores : 0) };
199
+ }
200
+ const totalMem = os.totalmem();
201
+ const freeMem = os.freemem();
202
+ const usedMem = Math.max(0, totalMem - freeMem);
203
+ // --- Storage ---
204
+ const [bivyDir, sessionDir] = await Promise.all([
205
+ opts.appDir ? dirSizeCached(opts.appDir) : Promise.resolve(null),
206
+ opts.sessionWorkspace ? dirSizeCached(opts.sessionWorkspace) : Promise.resolve(null),
207
+ ]);
208
+ const storageTotal = fsInfo?.total ?? 0;
209
+ return {
210
+ nodeId: opts.nodeId,
211
+ name: opts.name ?? null,
212
+ uptime: os.uptime(),
213
+ cores,
214
+ cpuModel,
215
+ load: (() => {
216
+ const l = os.loadavg();
217
+ return [l[0] ?? 0, l[1] ?? 0, l[2] ?? 0];
218
+ })(),
219
+ sessionMeasurable,
220
+ memory: {
221
+ node: { used: usedMem, total: totalMem, free: freeMem, usedPct: pct(usedMem, totalMem) },
222
+ bivy: { bytes: bivyRss, pct: pct(bivyRss, totalMem) },
223
+ session: sessionMem,
224
+ },
225
+ cpu: {
226
+ node: { usedPct: cpuSample.nodePct },
227
+ bivy: { pct: bivyCpuNodePct },
228
+ session: sessionCpu,
229
+ },
230
+ storage: {
231
+ node: fsInfo ? { path: opts.workspacePath, used: fsInfo.used, total: fsInfo.total, free: fsInfo.free, usedPct: pct(fsInfo.used, fsInfo.total) } : null,
232
+ bivy: bivyDir != null ? { bytes: bivyDir, pct: pct(bivyDir, storageTotal) } : null,
233
+ session: sessionDir != null ? { bytes: sessionDir, pct: pct(sessionDir, storageTotal) } : null,
234
+ },
235
+ at: new Date().toISOString(),
236
+ };
237
+ }