rein

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 17, 2026 License: MIT Imports: 14 Imported by: 0

README

rein

The shell layer AI agents don't break.

CI Go Report Card

rein is a Go library + CLI for running shell commands inside AI agent frameworks. It fixes the four bugs that every agent framework has — hangs on long-running commands, silent stalls on stalled TCP, leaked child processes, and no graceful shutdown — with proper process group isolation, timeouts, idle kills, and real PTY support.

The CLI speaks NDJSON over stdio so any language can use it. Python and Node.js clients are included. Works on Linux and macOS; Windows supports non-PTY sessions and is on the roadmap for full ConPTY support.

Install

As a Go library
go get github.com/SalzDevs/rein

Go 1.22 or later.

As a CLI
go install github.com/SalzDevs/rein/cmd/rein@latest

The rein binary ends up in $GOPATH/bin.

As a Python client
go install github.com/SalzDevs/rein/cmd/rein@latest
pip install ./clients/python
As a Node.js client
go install github.com/SalzDevs/rein/cmd/rein@latest
npm install ./clients/node

The 5-second pitch (library)

import (
    "context"
    "fmt"
    "time"

    "github.com/SalzDevs/rein"
)

result, err := rein.Run(ctx, "npm install",
    rein.WithTimeout(5*time.Minute),
)
fmt.Println(result.Stdout)

That is it. rein.Run runs the command, captures stdout and stderr, returns the exit code, and kills the entire process tree if the timeout fires. No leaked child processes. No zombie npm installs. No npm run dev hanging forever.

The 5-second pitch (CLI)

$ rein run --timeout 30s "npm install"
{"type":"exit","exit_code":0,"duration_ms":1234,"stdout":"added 1 package in 2s\n","stderr":""}

$ rein start --idle-timeout 2m "npm run dev"
{"type":"started","pid":4213}
{"type":"line","stream":"stdout","text":"> server-ready@1.0.0 dev"}
{"type":"line","stream":"stdout","text":"> next dev"}
...

Stop the start command with NDJSON on stdin:

$ echo '{"type":"stop"}' | rein start "sleep 30"

Why this exists

Every AI agent framework — Claude Code, Cursor, Aider, Cline, Codex, Gemini CLI, OpenCode, you name it — has the same open issues. They all boil down to four things:

  1. Hangs on long-running commands (npm run dev, pnpm dlx convex dev).
  2. Silent stalls on stalled TCP (no keepalive, no read timeout).
  3. Leaked child processes when the agent is killed.
  4. No graceful shutdown (SIGKILL on cancel, no SIGTERM first).

rein solves all four with a single API.

Library API

One-shot commands
func Run(ctx context.Context, command string, opts ...Option) (*Result, error)

type Result struct {
    Stdout   string
    Stderr   string
    ExitCode int
    Duration time.Duration
    Err      error
}
Long-running commands
func Start(ctx context.Context, command string, opts ...Option) (*Session, error)

type Line struct {
    Stream string  // "stdout" or "stderr"
    Text   string
}

type Session struct { ... }

func (s *Session) Lines() <-chan Line
func (s *Session) Wait() (*Result, error)
func (s *Session) Done() <-chan struct{}
func (s *Session) Stop() error
func (s *Session) PID() int
func (s *Session) Write(p []byte) (int, error)   // exec only
func (s *Session) Resize(rows, cols int) error    // exec only
func (s *Session) Drops() uint64                  // overflow count
Functional options
func WithTimeout(d time.Duration) Option
func WithGracefulTimeout(d time.Duration) Option
func WithIdleTimeout(d time.Duration) Option
func WithEnv(env []string) Option
func WithDir(dir string) Option
func WithPTY() Option
func WithLineBuffer(n int) Option
func WithOverflowPolicy(p OverflowPolicy) Option  // Block | DropNewest | DropOldest

CLI reference

rein <command> [arguments]

Commands:
  run       Run a command to completion, print the result as NDJSON
  start     Start a long-running command, stream lines as NDJSON
  exec      Run a command with a PTY, drive it interactively via NDJSON
  daemon    Long-running multi-session manager (NDJSON over stdio)
  version   Print the version
  help      Print this help
rein run
rein run [options] <command>

Options:
  --timeout <duration>          Maximum wall-clock duration (0 = no timeout)
  --graceful-timeout <duration> Time to wait between SIGTERM and SIGKILL
  --dir <path>                  Working directory
  --env <KEY=VALUE>             Environment variable (repeatable)
  --pty                         Allocate a pseudo-terminal

Output: a single NDJSON line on stdout.
rein start
rein start [options] <command>

Options:
  --timeout <duration>          Maximum wall-clock duration
  --graceful-timeout <duration> Time to wait between SIGTERM and SIGKILL
  --idle-timeout <duration>     Kill the process if no output for this duration
  --dir <path>                  Working directory
  --env <KEY=VALUE>             Environment variable (repeatable)
  --pty                         Allocate a pseudo-terminal
  --line-buffer <n>             Size of the output channel buffer

Output: NDJSON on stdout, one message per line.
Control: NDJSON on stdin, one message per line.
Signals: SIGINT/SIGTERM stop the process.
rein exec
rein exec [options] <command>

A PTY is always allocated. This subcommand is for interactive
commands that prompt the user (sudo, ssh-add, npm init, etc.).

Options:
  --timeout <duration>          Maximum wall-clock duration
  --graceful-timeout <duration> Time to wait between SIGTERM and SIGKILL
  --idle-timeout <duration>     Kill the process if no output for this duration
  --dir <path>                  Working directory
  --env <KEY=VALUE>             Environment variable (repeatable)
  --line-buffer <n>             Size of the output channel buffer
  --initial-size <rows>x<cols>  Initial PTY window size (default 24x80)

Output: NDJSON on stdout, one message per line.
Control: NDJSON on stdin, one message per line.
  {"type":"input","text":"password\n"}    Write to the child's stdin.
  {"type":"resize","rows":40,"cols":120} Resize the PTY window.
  {"type":"stop"}                           Stop the process.
Signals: SIGINT/SIGTERM stop the process.
rein daemon
rein daemon

A long-running process that manages multiple rein sessions. The daemon reads commands on stdin and emits events on stdout, both as NDJSON. It enables an agent to start, observe, and control many processes from a single connection.

Inputs: {"type":"create","id":"...","command":"...","options":{...}} {"type":"destroy","id":"..."} {"type":"stop","id":"..."} {"type":"input","id":"...","text":"..."} {"type":"resize","id":"...","rows":N,"cols":M} {"type":"list"}

Outputs: {"type":"created","id":"...","pid":...} {"type":"line","id":"...","stream":"...","text":"..."} {"type":"exit","id":"...","exit_code":...,"duration_ms":...} {"type":"destroyed","id":"...","exit_code":...} {"type":"sessions","sessions":[{"id":"...","pid":...}]}

Example: see clients/python and clients/node for full client libraries that wrap the daemon protocol.

NDJSON protocol

The CLI speaks newline-delimited JSON on stdio. Eight message types:

Outgoing (stdout)
Type Fields When
started pid The process is running.
line stream ("stdout"/"stderr"), text Each line of output.
exit exit_code, duration_ms, stdout, stderr, err The process has exited.
error err Error before the process started.
created (daemon) id, pid A new session was created.
destroyed (daemon) id, exit_code A session was destroyed.
sessions (daemon) sessions (array of {id, pid}) List of active sessions.
Incoming (stdin)
Type Fields Effect
stop (none) Ask rein to stop the running process.
input text Write text to the child's stdin. (exec only)
resize rows, cols Resize the PTY window. (exec only)
create (daemon) id, command Create a new session.
destroy (daemon) id Destroy an existing session.
list (daemon) (none) List active sessions.
Example: Python client
import subprocess, json

proc = subprocess.Popen(
    ["rein", "start", "--idle-timeout", "2m", "npm", "run", "dev"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1,
)

for line in proc.stdout:
    msg = json.loads(line)
    if msg["type"] == "line":
        print(f"[{msg['stream']}] {msg['text']}")
    elif msg["type"] == "started":
        print(f"process started, pid={msg['pid']}")
    elif msg["type"] == "exit":
        print(f"process exited, code={msg.get('exit_code')}")
        break

# Stop the process
proc.stdin.write(json.dumps({"type": "stop"}) + "\n")
proc.stdin.flush()
Example: Node.js client
const { spawn } = require("child_process");
const readline = require("readline");

const child = spawn("rein", ["start", "npm", "run", "dev"], { stdio: "pipe" });

const rl = readline.createInterface({ input: child.stdout });
rl.on("line", (line) => {
  const msg = JSON.parse(line);
  if (msg.type === "line") console.log(`[${msg.stream}] ${msg.text}`);
  if (msg.type === "exit") {
    console.log(`process exited, code=${msg.exit_code}`);
    process.exit(msg.exit_code || 0);
  }
});

setTimeout(() => {
  child.stdin.write(JSON.stringify({ type: "stop" }) + "\n");
}, 30_000);

Shutdown sequence

On timeout, idle timeout, context cancel, or Session.Stop(), rein follows the standard Unix escalation:

  1. SIGTERM is sent to the entire process group.
  2. rein waits up to GracefulTimeout (default 5s) for the process to exit on its own.
  3. If still running, SIGKILL is sent to the process group.

This means npm run dev gets a chance to clean up child processes and write to disk before being killed.

Platform support

OS Status
Linux Full support (process groups, SIGTERM, SIGKILL, PTY, CLI, all features)
macOS Full support (process groups, SIGTERM, SIGKILL, PTY, CLI, all features)
Windows Process group via Job Objects (partial), SIGKILL via TerminateProcess. Non-PTY sessions work on Windows 10 1809+. ConPTY support is on the v0.2 roadmap.

Roadmap

  • Run() for one-shot commands
  • Timeouts with graceful shutdown
  • Process group isolation (POSIX) / Job Object scaffolding (Windows; v0.2)
  • Start() for long-running commands with line-buffered streaming
  • Idle timeout (kill on silence)
  • Stop() for explicit shutdown
  • Real PTY allocation (POSIX creack/pty; Windows ConPTY is on the v0.2 roadmap)
  • CLI binary (rein run, rein start, rein exec, rein daemon)
  • NDJSON protocol for cross-language use
  • Session.Write() and Session.Resize() for interactive PTY use
  • Bounded output buffer with overflow policies
  • Python and Node client libraries
  • rein daemon — multi-session stateful manager
  • Persistent cross-process state (for long-lived agents)
  • Windows ConPTY (true PTY sessions on Windows)
  • rein watch for filesystem-triggered re-runs
  • Security sandbox for untrusted commands (seccomp / sandbox-exec)

Contributing

Contributions are welcome. Please open an issue before sending a PR so we can discuss the approach. Run the test suite (go test ./...) before submitting.

Security

For security issues, please email security@salzdevs.com instead of opening a public issue. See SECURITY.md for our policy.

License

MIT.

Documentation

Overview

Package rein provides a small, reliable shell layer for AI agents.

rein runs shell commands with timeouts that work, signals that propagate, and process trees that get cleaned up when the context is cancelled. It is designed to be the foundation underneath any agent framework that needs to execute commands.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Line

type Line struct {
	// Stream is "stdout" or "stderr". With PTY enabled, both are
	// merged on a single stream and Stream is always "stdout".
	Stream string

	// Text is the line content, without the trailing newline.
	Text string
}

Line is a single line of output from a Session.

type Option

type Option func(*Options)

Option is a functional option for Run or Start.

func WithDir

func WithDir(dir string) Option

WithDir sets the working directory for the child process.

func WithEnv

func WithEnv(env []string) Option

WithEnv sets the environment for the child process. Pass nil to inherit the parent environment.

func WithGracefulTimeout

func WithGracefulTimeout(d time.Duration) Option

WithGracefulTimeout sets how long to wait after a graceful signal before sending SIGKILL. Default: 5s. Only takes effect when the command is being stopped.

func WithIdleTimeout

func WithIdleTimeout(d time.Duration) Option

WithIdleTimeout sets the maximum duration of output silence before the process is signalled. Useful for long-running commands like `npm run dev` that should be considered stuck if they stop emitting output. Default: 0 (disabled).

Only takes effect on Start, not Run.

func WithLineBuffer

func WithLineBuffer(n int) Option

WithLineBuffer sets the size of the channel that buffers output lines from a Start session. Default: 4096.

func WithOverflowPolicy

func WithOverflowPolicy(p OverflowPolicy) Option

WithOverflowPolicy sets what happens when the line buffer is full. See OverflowPolicy for the options. Default: PolicyBlock.

func WithPTY

func WithPTY() Option

WithPTY allocates a pseudo-terminal for the child. The child sees a real TTY (good for `sudo`, `ssh-add`, colored output, and tools that check isatty). Stderr is merged with stdout on the same line. Default: false.

Only implemented on POSIX in v0.0.1.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the maximum wall-clock duration of the command. If the command runs longer, the process tree is signalled (see WithGracefulTimeout for the escalation policy).

type Options

type Options struct {
	// Timeout is the maximum wall-clock duration before the process
	// is signalled. Default: 30s. Zero or negative means no timeout.
	Timeout time.Duration

	// GracefulTimeout is how long to wait after sending SIGTERM
	// before sending SIGKILL. Default: 5s. Only takes effect when
	// the command is being stopped (timeout, context cancel, or Stop).
	GracefulTimeout time.Duration

	// IdleTimeout is the maximum duration of output silence before
	// the process is signalled. Useful for long-running commands
	// like `npm run dev` that should be considered stuck if they
	// stop emitting output. Default: 0 (disabled). Only takes
	// effect on Start, not Run.
	IdleTimeout time.Duration

	// Env is the environment for the child process. If nil, the
	// parent environment is inherited.
	Env []string

	// Dir is the working directory for the child process. If empty,
	// the parent's working directory is used.
	Dir string

	// PTY, if true, allocates a pseudo-terminal for the child. The
	// child sees a real TTY (good for `sudo`, `ssh-add`, colored
	// output, and tools that check `isatty`). Stderr is merged
	// with stdout on the same line. Default: false.
	//
	// Only implemented on POSIX in v0.0.1.
	PTY bool

	// LineBuffer is the size of the channel that buffers output
	// lines from a Start session before the producer applies
	// OverflowPolicy. Default: 4096.
	LineBuffer int

	// Overflow is the policy applied when LineBuffer is full.
	// Default: PolicyBlock.
	Overflow OverflowPolicy
}

Options configures a single Run or Start call. Zero values are replaced with sensible defaults; see the Option functions.

type OverflowPolicy

type OverflowPolicy int

OverflowPolicy controls what happens when the line buffer is full and the producer tries to add another line.

const (
	// PolicyBlock causes the producer to wait until the consumer
	// has drained some lines. This is the safest option: no lines
	// are lost. The downside is that the producer (and the
	// subprocess) can appear to hang if the consumer is slow.
	// Default.
	PolicyBlock OverflowPolicy = iota

	// PolicyDropNewest causes the producer to silently drop the
	// new line and increment the drop counter. The consumer sees
	// only the first N lines of output. The subprocess never
	// blocks.
	PolicyDropNewest

	// PolicyDropOldest causes the producer to drop the oldest
	// buffered line and add the new one. The consumer always sees
	// the most recent output. The drop counter is incremented.
	PolicyDropOldest
)

type Result

type Result struct {
	// Stdout is the standard output captured during execution.
	Stdout string

	// Stderr is the standard error captured during execution.
	Stderr string

	// ExitCode is the process exit code. It is -1 if the process did
	// not exit normally (for example, it was killed by a signal).
	ExitCode int

	// Duration is the wall-clock time from start to exit.
	Duration time.Duration

	// Err is the terminal error, if any. It is nil on success.
	// For non-zero exit codes, Err is set to a non-nil error that
	// describes the failure. For timeouts, it indicates how long the
	// command ran before being killed.
	Err error
}

Result is the outcome of a command run by Run or Start.

Result is always returned, even on failure. Callers should check Err to determine whether the command succeeded.

func Run

func Run(ctx context.Context, command string, opts ...Option) (*Result, error)

Run executes a shell command and waits for it to complete or until the timeout fires.

The command is run via `sh -c <command>`, so shell features (pipes, redirects, globs, env var expansion) work as expected. The process tree is isolated into its own process group so that signals reach every descendant, not just the leader.

Shutdown sequence on timeout or context cancellation:

  1. SIGTERM is sent to the process group.
  2. rein waits up to Options.GracefulTimeout for the process to exit on its own.
  3. If the process is still running, SIGKILL is sent to the process group.

Run always returns a non-nil *Result, even on failure. The returned error is non-nil if and only if result.Err is non-nil.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is a handle to a long-running command started by Start or [StartExec].

A Session streams output line-by-line via Session.Lines, exposes the process lifecycle via Session.Done and Session.Wait, and can be cleanly stopped via Session.Stop.

If the session was started with a PTY (see WithPTY), input can be written to the child's stdin via Session.Write and the terminal window can be resized via Session.Resize.

func Start

func Start(ctx context.Context, command string, opts ...Option) (*Session, error)

Start launches a long-running command and returns a Session.

The command runs in its own process group, so signals propagate to all descendants. Output is streamed line-by-line via Session.Lines. The process is killed on context cancellation, timeout, idle timeout, or Session.Stop.

The shutdown sequence on stop is:

  1. SIGTERM is sent to the process group.
  2. rein waits up to Options.GracefulTimeout for the process to exit on its own.
  3. If the process is still running, SIGKILL is sent to the process group.

For non-PTY sessions, the child's stdin is /dev/null. For PTY sessions (see WithPTY), the child's stdin is the PTY slave and input can be written via Session.Write.

func (*Session) Done

func (s *Session) Done() <-chan struct{}

Done returns a channel that is closed when the process exits.

func (*Session) Drops

func (s *Session) Drops() uint64

Drops returns the number of output lines that have been dropped due to the configured OverflowPolicy. Useful for monitoring back-pressure in long-running consumers.

func (*Session) Lines

func (s *Session) Lines() <-chan Line

Lines returns a channel that emits one Line per line of process output. The channel is closed when the process exits.

The channel is buffered (default 4096 lines; see WithLineBuffer). The OverflowPolicy (see WithOverflowPolicy) controls what happens when the buffer fills:

PolicyBlock      (default) — the producer waits.
PolicyDropNewest — the new line is dropped silently.
PolicyDropOldest — the oldest buffered line is dropped to
                    make room for the new one.

Dropped lines can be detected with Session.Drops.

func (*Session) PID

func (s *Session) PID() int

PID returns the OS process ID, or -1 if the process has not been started yet.

func (*Session) Resize

func (s *Session) Resize(rows, cols int) error

Resize resizes the PTY window to rows x cols. Only works for sessions started with WithPTY; for non-PTY sessions it returns an error.

Resize is safe to call from any goroutine.

func (*Session) Stop

func (s *Session) Stop() error

Stop sends SIGTERM, waits for the graceful timeout, then SIGKILL. It blocks until the process has exited. Safe to call multiple times; subsequent calls are no-ops that return nil.

func (*Session) Wait

func (s *Session) Wait() (*Result, error)

Wait blocks until the process exits and returns the final Result. It is safe to call Wait multiple times; subsequent calls return the same Result.

func (*Session) Write

func (s *Session) Write(p []byte) (int, error)

Write sends p to the child's stdin. Only works for sessions started with WithPTY; for non-PTY sessions it returns an error.

Write is safe to call from any goroutine.

Directories

Path Synopsis
cmd
rein command
Command rein is the CLI for the rein shell-layer library.
Command rein is the CLI for the rein shell-layer library.
internal
ndjson
Package ndjson implements the wire protocol used by the rein CLI.
Package ndjson implements the wire protocol used by the rein CLI.
procgroup
Package procgroup isolates a child process into its own process group so that signals can be delivered to the entire process tree, not just the leader.
Package procgroup isolates a child process into its own process group so that signals can be delivered to the entire process tree, not just the leader.
signal
Package signal sends signals to a child process group.
Package signal sends signals to a child process group.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL