@bivy/bivy 0.17.2-staging.4 → 0.17.2-staging.6

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.
@@ -1,12 +1,57 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ import { spawn } from "node:child_process";
4
+ import os from "node:os";
5
+ import { loadPty } from "./terminal.js";
6
+ const signalNames = new Map(Object.entries(os.constants.signals).map(([name, num]) => [num, name]));
3
7
  /**
4
8
  * Keep terminal semantics opt-in for short-lived commands. Native interactive
5
- * commands must set requiresTty; ordinary commands stay on direct pipes and do
6
- * not pay for a Python PTY relay.
9
+ * commands must set requiresTty and run under node-pty (the same dependency the
10
+ * interactive terminals use); ordinary commands stay on direct pipes.
7
11
  */
8
- export function commandLaunch(command, args, requiresTty, pythonCommand, ptyRunnerScript) {
9
- return requiresTty
10
- ? { command: pythonCommand, args: [ptyRunnerScript, command, ...args], usesPty: true }
11
- : { command, args: [...args], usesPty: false };
12
+ export function launchCommand(spec, requiresTty, handlers) {
13
+ return requiresTty ? launchPty(spec, handlers) : launchPipes(spec, handlers);
14
+ }
15
+ function launchPipes({ command, args, cwd, env }, handlers) {
16
+ const child = spawn(command, args, { cwd, env });
17
+ child.stdout.on("data", (data) => handlers.onOutput("stdout", String(data)));
18
+ child.stderr.on("data", (data) => handlers.onOutput("stderr", String(data)));
19
+ child.on("error", handlers.onError);
20
+ child.on("exit", handlers.onExit);
21
+ return {
22
+ usesPty: false,
23
+ write: (text) => child.stdin.write(text),
24
+ interrupt: () => child.kill("SIGINT"),
25
+ };
26
+ }
27
+ function launchPty({ command, args, cwd, env }, handlers) {
28
+ let proc;
29
+ try {
30
+ proc = loadPty().spawn(command, args, { name: env.TERM ?? "xterm-256color", cols: 120, rows: 40, cwd, env });
31
+ }
32
+ catch (error) {
33
+ // Match child_process: a launch failure is reported asynchronously, not thrown.
34
+ queueMicrotask(() => handlers.onError(error instanceof Error ? error : new Error(String(error))));
35
+ return { usesPty: true, write: () => { }, interrupt: () => { } };
36
+ }
37
+ // A PTY merges stdout and stderr into one stream.
38
+ proc.onData((text) => handlers.onOutput("stdout", text));
39
+ proc.onExit(({ exitCode, signal }) => handlers.onExit(signal ? null : exitCode, signal ? signalNames.get(signal) ?? null : null));
40
+ return {
41
+ usesPty: true,
42
+ write: (text) => proc.write(text),
43
+ interrupt: () => {
44
+ // node-pty makes the child a session (and process-group) leader, so a
45
+ // negative pid reaches anything it forked too.
46
+ try {
47
+ process.kill(-proc.pid, "SIGINT");
48
+ }
49
+ catch {
50
+ try {
51
+ proc.kill("SIGINT");
52
+ }
53
+ catch { /* already exited */ }
54
+ }
55
+ },
56
+ };
12
57
  }
@@ -16,8 +16,8 @@ import { withExactCapabilitySurface } from "./types.js";
16
16
  * worker processes) die with it instead of being orphaned. Relies on the child
17
17
  * having been spawned `detached` (making it its own process-group leader, POSIX
18
18
  * only — see the spawn() call in ProcessSession.prompt); `process.kill(-pid,
19
- * signal)` then targets the whole group, mirroring `pty-runner.py`'s
20
- * `os.killpg`. Falls back to killing just the direct child on Windows (no
19
+ * signal)` then targets the whole group, as the PTY command launcher does
20
+ * (src/command-launch.ts). Falls back to killing just the direct child on Windows (no
21
21
  * negative-pid group kill there) or if the group is already gone.
22
22
  */
23
23
  function killProcessGroup(child, signal) {
package/dist/server.js CHANGED
@@ -91,7 +91,7 @@ import { hostedCustodyNode } from "./hosted-custody.js";
91
91
  import { PolicyEngine } from "./policy/policy-engine.js";
92
92
  import { SessionAllowRules } from "./policy/session-allow.js";
93
93
  import { TerminalManager } from "./terminal.js";
94
- import { commandLaunch } from "./command-launch.js";
94
+ import { launchCommand } from "./command-launch.js";
95
95
  import { listMultiplexerSessions } from "./multiplexer.js";
96
96
  import { createWorktree, removeWorktree, gitRepoRoot } from "./worktree.js";
97
97
  import { HarnessManager } from "./harness/manager.js";
@@ -344,10 +344,6 @@ const worktreeCleanupSweepMs = Math.max(60 * 60 * 1000, Math.min(worktreeRetenti
344
344
  // Native Pi commands run the operator-installed agent. Bivy does not substitute
345
345
  // a private TUI; BIVY_PI_COMMAND is an explicit path override for managed nodes.
346
346
  const piCommand = process.env.BIVY_PI_COMMAND?.trim() || "pi";
347
- const ptyRunnerScript = process.env.BIVY_PTY_RUNNER ?? (fs.existsSync(path.join(assetRoot, "src", "pty-runner.py"))
348
- ? path.join(assetRoot, "src", "pty-runner.py")
349
- : path.join(assetRoot, "dist", "pty-runner.py"));
350
- const pythonCommand = process.env.PYTHON ?? "python3";
351
347
  // Per-process secret gating the loopback bootstrap endpoint. Published to a
352
348
  // 0600 file (and stdout) so only the launching user can read it.
353
349
  const bootstrapSecret = randomBytes(32).toString("base64url");
@@ -1744,8 +1740,10 @@ function referencedAttachmentHashes() {
1744
1740
  const ids = new Set(metadata.listSessions().map((session) => session.id));
1745
1741
  for (const record of new Set(openSessions.values()))
1746
1742
  ids.add(record.id);
1743
+ // `scan`, not `entries`: this visits every session ever created, and must not pin
1744
+ // each one's full log in the event-log cache.
1747
1745
  for (const id of ids) {
1748
- for (const entry of eventLog.entries(id)) {
1746
+ for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image"])) {
1749
1747
  if (entry.bivyKind === "attachment")
1750
1748
  for (const ref of entry.refs)
1751
1749
  hashes.add(ref.hash);
@@ -5617,26 +5615,19 @@ function runNativeCommand(command) {
5617
5615
  NO_COLOR: "1",
5618
5616
  };
5619
5617
  // Native commands (login/model/config/etc.) need a TTY for prompts and the
5620
- // terminal UI. Everything else uses ordinary pipes: this avoids an extra
5621
- // Python process and PTY relay for non-interactive commands while preserving
5622
- // the old behavior for commands that genuinely require terminal semantics.
5623
- const launch = commandLaunch(command.spawn.command, command.spawn.args, command.spawn.requiresTty, pythonCommand, ptyRunnerScript);
5624
- const child = spawn(launch.command, launch.args, { cwd: repoRoot, env });
5625
- commandProcesses.set(runId, child);
5626
- child.stdout.on("data", (data) => {
5627
- broadcast({ type: "command.output", runId, command: command.name, stream: "stdout", text: stripAnsi(String(data)) });
5628
- });
5629
- child.stderr.on("data", (data) => {
5630
- broadcast({ type: "command.output", runId, command: command.name, stream: "stderr", text: stripAnsi(String(data)) });
5631
- });
5632
- child.on("error", (error) => {
5633
- commandProcesses.delete(runId);
5634
- broadcast({ type: "command.error", runId, command: command.name, error: String(error?.stack ?? error) });
5635
- });
5636
- child.on("exit", (code, signal) => {
5637
- commandProcesses.delete(runId);
5638
- broadcast({ type: "command.exited", runId, command: command.name, code, signal });
5618
+ // terminal UI, so they run under a PTY; everything else uses ordinary pipes.
5619
+ const handle = launchCommand({ command: command.spawn.command, args: command.spawn.args, cwd: repoRoot, env }, command.spawn.requiresTty, {
5620
+ onOutput: (stream, text) => broadcast({ type: "command.output", runId, command: command.name, stream, text: stripAnsi(text) }),
5621
+ onError: (error) => {
5622
+ commandProcesses.delete(runId);
5623
+ broadcast({ type: "command.error", runId, command: command.name, error: String(error?.stack ?? error) });
5624
+ },
5625
+ onExit: (code, signal) => {
5626
+ commandProcesses.delete(runId);
5627
+ broadcast({ type: "command.exited", runId, command: command.name, code, signal });
5628
+ },
5639
5629
  });
5630
+ commandProcesses.set(runId, handle);
5640
5631
  return { ok: true, runId };
5641
5632
  }
5642
5633
  const guardianInterceptorImpl = async ({ sessionId, toolName, input, signal }) => {
@@ -11085,14 +11076,14 @@ app.post("/api/commands/:runId/input", (req, res) => {
11085
11076
  const child = commandProcesses.get(req.params.runId);
11086
11077
  if (!child)
11087
11078
  return res.status(404).json({ error: "No active command process" });
11088
- child.stdin.write(`${String(req.body?.text ?? "")}\n`);
11079
+ child.write(`${String(req.body?.text ?? "")}\n`);
11089
11080
  res.json({ ok: true });
11090
11081
  });
11091
11082
  app.post("/api/commands/:runId/terminate", (req, res) => {
11092
11083
  const child = commandProcesses.get(req.params.runId);
11093
11084
  if (!child)
11094
11085
  return res.status(404).json({ error: "No active command process" });
11095
- child.kill("SIGINT");
11086
+ child.interrupt();
11096
11087
  res.json({ ok: true });
11097
11088
  });
11098
11089
  // On-demand "update GitHub status" for one session: force `refreshPullRequests`
@@ -397,12 +397,12 @@ export function baseReplay(entries) {
397
397
  }
398
398
  return base;
399
399
  }
400
- function parseLogDetailed(body) {
400
+ function parseLogDetailed(body, keep = () => true) {
401
401
  const records = [];
402
402
  let malformedLines = 0;
403
403
  for (const line of body.split("\n")) {
404
404
  const trimmed = line.trim();
405
- if (!trimmed)
405
+ if (!trimmed || !keep(trimmed))
406
406
  continue;
407
407
  try {
408
408
  const value = JSON.parse(trimmed);
@@ -429,6 +429,10 @@ export function parseLog(body) {
429
429
  * `throttleMs` per session (a burst of same-id deltas within a window collapses to
430
430
  * one appended line). Durability at boundaries is explicit `flush()` (turn-end,
431
431
  * close, delete, shutdown), mirroring `SidecarStore`.
432
+ *
433
+ * The in-memory copy of flushed records is an LRU capped at `maxCachedBytes`
434
+ * (measured as log-file size): an evicted session simply re-reads its file on next
435
+ * use, so memory tracks the sessions in use, not the node's whole history.
432
436
  */
433
437
  export class EventLog {
434
438
  dir;
@@ -436,7 +440,10 @@ export class EventLog {
436
440
  redact;
437
441
  throttleMs;
438
442
  onIssue;
443
+ maxCachedBytes;
439
444
  disk = new Map();
445
+ diskBytes = new Map();
446
+ cachedBytes = 0;
440
447
  pending = new Map();
441
448
  timers = new Map();
442
449
  lastFlush = new Map();
@@ -446,12 +453,13 @@ export class EventLog {
446
453
  // on first use of a session after a restart.
447
454
  baseKeys = new Map();
448
455
  lastIssue;
449
- constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`)) {
456
+ constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`), maxCachedBytes = 64 * 1024 * 1024) {
450
457
  this.dir = dir;
451
458
  this.pathFor = pathFor;
452
459
  this.redact = redact;
453
460
  this.throttleMs = throttleMs;
454
461
  this.onIssue = onIssue;
462
+ this.maxCachedBytes = maxCachedBytes;
455
463
  }
456
464
  report(id, operation, error) {
457
465
  const issue = {
@@ -493,23 +501,64 @@ export class EventLog {
493
501
  }
494
502
  return { files, bytes };
495
503
  }
496
- load(id) {
497
- const cached = this.disk.get(id);
498
- if (cached)
499
- return cached;
500
- let data = [];
504
+ /** Read + parse a session's log file (lines passing `keep`), reporting problems. */
505
+ readLog(id, keep) {
501
506
  try {
502
- const parsed = parseLogDetailed(fs.readFileSync(this.pathFor(id), "utf8"));
503
- data = parsed.records;
507
+ const body = fs.readFileSync(this.pathFor(id), "utf8");
508
+ const parsed = parseLogDetailed(body, keep);
504
509
  if (parsed.malformedLines > 0)
505
510
  this.report(id, "parse", new Error(`${parsed.malformedLines} malformed record(s); valid history was recovered`));
511
+ return { records: parsed.records, bytes: body.length };
506
512
  }
507
513
  catch (error) {
508
514
  if (error?.code !== "ENOENT")
509
515
  this.report(id, "read", error);
516
+ return { records: [], bytes: 0 };
517
+ }
518
+ }
519
+ load(id) {
520
+ const cached = this.disk.get(id);
521
+ if (cached) {
522
+ // Re-insert to mark most-recently-used (Map iterates in insertion order).
523
+ this.disk.delete(id);
524
+ this.disk.set(id, cached);
525
+ return cached;
510
526
  }
511
- this.disk.set(id, data);
512
- return data;
527
+ const { records, bytes } = this.readLog(id);
528
+ this.cache(id, records, bytes);
529
+ return records;
530
+ }
531
+ /** Store a session's flushed records, then evict least-recently-used others past the cap. */
532
+ cache(id, records, bytes) {
533
+ this.uncache(id);
534
+ this.disk.set(id, records);
535
+ this.diskBytes.set(id, bytes);
536
+ this.cachedBytes += bytes;
537
+ for (const oldest of this.disk.keys()) {
538
+ if (this.cachedBytes <= this.maxCachedBytes || oldest === id)
539
+ break;
540
+ this.uncache(oldest);
541
+ }
542
+ }
543
+ uncache(id) {
544
+ this.cachedBytes -= this.diskBytes.get(id) ?? 0;
545
+ this.diskBytes.delete(id);
546
+ this.disk.delete(id);
547
+ }
548
+ /**
549
+ * The session's records of the given kinds, read WITHOUT caching the log. For
550
+ * whole-store sweeps (attachment GC) that visit every session, where `load` would
551
+ * pin every log in memory. Uses the cached copy when there is one, and always
552
+ * includes pending records so unflushed writes are never missed. Off the cache only
553
+ * candidate lines are parsed: those naming a wanted kind, plus torn lines (not
554
+ * ending in `}`) so a truncated write is still reported and callers can fail closed.
555
+ */
556
+ scan(id, kinds) {
557
+ const wanted = new Set(kinds);
558
+ const markers = kinds.map((kind) => `"bivyKind":${JSON.stringify(kind)}`);
559
+ const flushed = this.disk.get(id) ?? this.readLog(id, (line) => !line.endsWith("}") || markers.some((m) => line.includes(m))).records;
560
+ const pending = this.pending.get(id)?.values() ?? [];
561
+ return [...flushed, ...pending].filter((record) => wanted.has(record.bivyKind));
513
562
  }
514
563
  /** Allocate a unique synthetic coalescing key (for id-less records). */
515
564
  syntheticKey(id) {
@@ -681,9 +730,16 @@ export class EventLog {
681
730
  const lines = [...batch.values()];
682
731
  try {
683
732
  fs.mkdirSync(this.dir, { recursive: true });
684
- fs.appendFileSync(this.pathFor(id), this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n"));
685
- const disk = this.load(id);
686
- disk.push(...lines);
733
+ const body = this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n");
734
+ fs.appendFileSync(this.pathFor(id), body);
735
+ // Extend the cached copy only if it is still cached: an evicted session re-reads
736
+ // the file (which now holds these lines) on next use, so loading here would
737
+ // double-count them.
738
+ const cached = this.disk.get(id);
739
+ if (cached) {
740
+ cached.push(...lines);
741
+ this.cache(id, cached, (this.diskBytes.get(id) ?? 0) + body.length);
742
+ }
687
743
  batch.clear();
688
744
  this.lastFlush.set(id, Date.now());
689
745
  }
@@ -708,10 +764,10 @@ export class EventLog {
708
764
  }
709
765
  this.pending.delete(id);
710
766
  this.baseKeys.delete(id);
711
- this.disk.set(id, copy);
767
+ const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
768
+ this.cache(id, copy, body.length);
712
769
  try {
713
770
  fs.mkdirSync(this.dir, { recursive: true });
714
- const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
715
771
  fs.writeFileSync(this.pathFor(id), this.redact(body));
716
772
  this.lastFlush.set(id, Date.now());
717
773
  }
@@ -726,7 +782,7 @@ export class EventLog {
726
782
  clearTimeout(timer);
727
783
  this.timers.delete(id);
728
784
  }
729
- this.disk.delete(id);
785
+ this.uncache(id);
730
786
  this.pending.delete(id);
731
787
  this.lastFlush.delete(id);
732
788
  this.counters.delete(id);
package/dist/terminal.js CHANGED
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
6
6
  import path from "node:path";
7
7
  import { depCacheEnv } from "./harness/dep-cache.js";
8
8
  let ptyModule;
9
- function loadPty() {
9
+ export function loadPty() {
10
10
  if (ptyModule)
11
11
  return ptyModule;
12
12
  if (ptyModule === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.2-staging.4",
3
+ "version": "0.17.2-staging.6",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
@@ -1,95 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Run a command under a pseudo-terminal while relaying stdio over pipes."""
3
-
4
- import os
5
- import pty
6
- import select
7
- import signal
8
- import subprocess
9
- import sys
10
-
11
-
12
- def main() -> int:
13
- if len(sys.argv) < 2:
14
- print("usage: pty-runner.py command [args...]", file=sys.stderr)
15
- return 2
16
-
17
- master_fd, slave_fd = pty.openpty()
18
- env = os.environ.copy()
19
- env.setdefault("TERM", "xterm-256color")
20
-
21
- proc = subprocess.Popen(
22
- sys.argv[1:],
23
- stdin=slave_fd,
24
- stdout=slave_fd,
25
- stderr=slave_fd,
26
- env=env,
27
- close_fds=True,
28
- start_new_session=True,
29
- )
30
- os.close(slave_fd)
31
-
32
- def forward_signal(signum, _frame):
33
- try:
34
- os.killpg(proc.pid, signum)
35
- except ProcessLookupError:
36
- pass
37
-
38
- signal.signal(signal.SIGINT, forward_signal)
39
- signal.signal(signal.SIGTERM, forward_signal)
40
-
41
- stdin_fd = sys.stdin.fileno()
42
- stdout_fd = sys.stdout.fileno()
43
- stdin_open = True
44
-
45
- while True:
46
- read_fds = [master_fd]
47
- if stdin_open:
48
- read_fds.append(stdin_fd)
49
-
50
- readable, _, _ = select.select(read_fds, [], [], 0.1)
51
-
52
- if master_fd in readable:
53
- try:
54
- data = os.read(master_fd, 8192)
55
- except OSError:
56
- data = b""
57
- if not data:
58
- break
59
- os.write(stdout_fd, data)
60
-
61
- if stdin_open and stdin_fd in readable:
62
- data = os.read(stdin_fd, 8192)
63
- if not data:
64
- stdin_open = False
65
- else:
66
- try:
67
- os.write(master_fd, data)
68
- except OSError:
69
- break
70
-
71
- if proc.poll() is not None:
72
- # Drain any final PTY output.
73
- while True:
74
- readable, _, _ = select.select([master_fd], [], [], 0)
75
- if not readable:
76
- break
77
- try:
78
- data = os.read(master_fd, 8192)
79
- except OSError:
80
- break
81
- if not data:
82
- break
83
- os.write(stdout_fd, data)
84
- break
85
-
86
- try:
87
- os.close(master_fd)
88
- except OSError:
89
- pass
90
-
91
- return proc.wait()
92
-
93
-
94
- if __name__ == "__main__":
95
- raise SystemExit(main())