@bivy/bivy 0.17.2-staging.5 → 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");
@@ -5619,26 +5615,19 @@ function runNativeCommand(command) {
5619
5615
  NO_COLOR: "1",
5620
5616
  };
5621
5617
  // Native commands (login/model/config/etc.) need a TTY for prompts and the
5622
- // terminal UI. Everything else uses ordinary pipes: this avoids an extra
5623
- // Python process and PTY relay for non-interactive commands while preserving
5624
- // the old behavior for commands that genuinely require terminal semantics.
5625
- const launch = commandLaunch(command.spawn.command, command.spawn.args, command.spawn.requiresTty, pythonCommand, ptyRunnerScript);
5626
- const child = spawn(launch.command, launch.args, { cwd: repoRoot, env });
5627
- commandProcesses.set(runId, child);
5628
- child.stdout.on("data", (data) => {
5629
- broadcast({ type: "command.output", runId, command: command.name, stream: "stdout", text: stripAnsi(String(data)) });
5630
- });
5631
- child.stderr.on("data", (data) => {
5632
- broadcast({ type: "command.output", runId, command: command.name, stream: "stderr", text: stripAnsi(String(data)) });
5633
- });
5634
- child.on("error", (error) => {
5635
- commandProcesses.delete(runId);
5636
- broadcast({ type: "command.error", runId, command: command.name, error: String(error?.stack ?? error) });
5637
- });
5638
- child.on("exit", (code, signal) => {
5639
- commandProcesses.delete(runId);
5640
- 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
+ },
5641
5629
  });
5630
+ commandProcesses.set(runId, handle);
5642
5631
  return { ok: true, runId };
5643
5632
  }
5644
5633
  const guardianInterceptorImpl = async ({ sessionId, toolName, input, signal }) => {
@@ -11087,14 +11076,14 @@ app.post("/api/commands/:runId/input", (req, res) => {
11087
11076
  const child = commandProcesses.get(req.params.runId);
11088
11077
  if (!child)
11089
11078
  return res.status(404).json({ error: "No active command process" });
11090
- child.stdin.write(`${String(req.body?.text ?? "")}\n`);
11079
+ child.write(`${String(req.body?.text ?? "")}\n`);
11091
11080
  res.json({ ok: true });
11092
11081
  });
11093
11082
  app.post("/api/commands/:runId/terminate", (req, res) => {
11094
11083
  const child = commandProcesses.get(req.params.runId);
11095
11084
  if (!child)
11096
11085
  return res.status(404).json({ error: "No active command process" });
11097
- child.kill("SIGINT");
11086
+ child.interrupt();
11098
11087
  res.json({ ok: true });
11099
11088
  });
11100
11089
  // On-demand "update GitHub status" for one session: force `refreshPullRequests`
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.5",
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())