README
¶
go-runner
Thin Go substrate that composes go-providers
(CLI adapters + spawn helpers) and go-sandbox
(resolved access policy + legacy Profile enforcement) into a single Run entry point. It spawns a CLI binary under optional OS confinement, parses its structured output through a provider adapter, and emits raw observed events through a caller-supplied callback.
import "github.com/hollis-labs/go-runner/runner"
resolved, _ := sandbox.ResolveAccessPolicy(policy)
err := runner.Run(ctx, runner.Config{
Provider: myAdapter, // provider.CLIAdapter
SandboxPolicy: &resolved, // preferred: required/disabled resolved policy
Workspace: "/abs/path/to/workspace",
Args: []string{"--prompt", "hi"},
Env: nil, // nil = inherit parent env
WaitDelay: 5 * time.Second, // SIGTERM → SIGKILL grace; 0 = lib default
OnEvent: func(ev runner.Event) {
// translate ev.Kind / ev.Payload to your app's vocabulary
},
})
Boundary statement
This library does not know what an FSM transition is.
It does not know what a broker session event is. It does not know what a plugin lifecycle is. It emits a small alphabet of process-lifecycle and provider-stream events; it is your wrapper's job to translate those observations into your app's vocabulary.
That boundary is deliberate. The same spawn-and-parse machinery powers durable executors (Clockwork), session brokers (Mux), and plugin hosts (Nanite). Each app translates the raw event stream differently. Putting those translations into the library would couple the library to one opinion; putting them in per-app wrappers keeps the library generic and the app's vocabulary close to its own state machine.
What changed in v0.3.0: the lib now also owns process-lifetime
policy — supervision (idle-kill, restart-on-crash, watchdog) and OS
resource limits. Those are still process-level, not app-level: they
describe how the process lives and dies, not what the process events
mean. Termination cause is exposed as a small enum (Cause* constants
on ExitError); apps decide what to do with idle_timeout /
watchdog_kill / oom_kill / restart_exhausted in their own
wrappers (e.g. nanite's recovery-broker pattern keys off Cause to
choose between auto-recover and surface-to-user).
If you find yourself wanting to add an app-specific helper here, that's a sign the helper belongs in your wrapper layer instead.
Event alphabet
The runner emits the following EventKind values:
| Kind | When | Payload keys |
|---|---|---|
process.started |
once, after cmd.Start returns |
pid, binary, args, sandbox (SandboxOutcome) |
provider.event |
per parsed provider.StreamEvent from stdout |
event (raw provider.StreamEvent), is_turn_complete |
process.exited |
once, after cmd.Wait returns (clean or non-zero) |
exit_code, signal, killed, cause, error, sandbox |
process.timeout |
once, in place of process.exited when ctx deadline hit |
error, sandbox |
supervisor.restart |
before each restart attempt (when Supervisor.RestartOnCrash is configured) |
attempt (1-indexed), prev_exit (*ExitError), backoff_for (time.Duration) |
supervisor.idle_kill |
when Supervisor.IdleKill triggers |
idle_for (time.Duration) |
supervisor.watchdog |
when Supervisor.WatchdogTimeout triggers |
no_activity_for (time.Duration) |
supervisor.resource_limit |
when a ResourceLimits field causes termination (best-effort) |
resource ("cpu_time" | "memory" | "open_files" | "processes" | "file_size") |
Order guarantee: once the process has successfully started, process.started
is always first and exactly one of process.exited/process.timeout is
always last. provider.events appear between them, including any terminal
EventDone/EventError produced by the adapter (is_turn_complete=true
flags those for consumers). If Run returns an error before spawning or
starting the process (config validation, provider Detect failure, stdout
pipe, sandbox Apply, or cmd.Start failure), no events are emitted.
OnEvent is invoked synchronously from the spawn goroutine. Slow
callbacks block the stream; fan out to your own channel or goroutine if
buffering is needed.
Structured ExitError
For non-clean exits, Run returns an *ExitError (extractable via
errors.As) carrying:
type ExitError struct {
Code int // process exit code; -1 if signal-terminated
Signal int // signal number that terminated the process; 0 for clean exit
Killed bool // true iff terminated by SIGKILL specifically
ProcessState *os.ProcessState
Cause string // one of CauseIdleTimeout / CauseWatchdogKill / CauseRestartExhausted / CauseOOMKill / CauseResourceLimit, or "" for ordinary exits
}
Existing callers that only check if err != nil are unaffected. Clean
exits return nil.
Sandbox policy
Prefer Config.SandboxPolicy *sandbox.ResolvedAccessPolicy for new callers. The runner applies it with sandbox.ApplyResolved before every cmd.Start, including supervisor restarts. Required setup failures return a *runner.SandboxError and no process lifecycle events are emitted. If setup succeeds but cmd.Start fails, the returned *runner.StartError carries a SandboxOutcome in the configured state so callers do not report launched enforcement.
SandboxOutcome is intentionally sanitized: it records policy ID, backend, disabled/unsupported/configured/launched state, unsupported capabilities and diagnostics, but it does not copy argv or environment values. Start, exit and timeout events include the same outcome under Payload["sandbox"]; after a successful start the state is launched.
Legacy callers may continue to set Config.Profile. Config.Profile and Config.SandboxPolicy are mutually exclusive. Legacy profile outcomes are marked Legacy=true and LegacyDefaultAllow=true because the old shape is compatibility/default-allow semantics. A zero-value profile and nil resolved policy means explicit unwrapped execution and reports disabled.
Supervision
Opt in via Config.Supervisor *SupervisorOptions. The zero value
(Supervisor: nil) preserves the default "spawn once, run to completion"
behavior — no goroutines, no timers, no restart loop.
| Option | Triggers | Termination signal | ExitError.Cause |
|---|---|---|---|
IdleKill |
no I/O activity (stdout line / stderr write) for the configured duration | SIGTERM → IdleKillGrace (default 5s) → SIGKILL |
idle_timeout |
RestartOnCrash |
non-zero exit; up to RestartOnCrash retries |
n/a (re-spawn) | restart_exhausted (final) |
WatchdogTimeout |
no ActivityCallback invocation or stdout/stderr activity within the configured duration |
SIGKILL directly (no grace) |
watchdog_kill |
When to use which:
IdleKillis for "how long can the process sit completely silent before we assume it's wedged?" Suitable for chat sessions and long-lived agents (recommended 15m).WatchdogTimeoutis for "how long can the process go without making meaningful progress?" Caller wiresActivityCallbackfrom insideOnEventto define what counts as progress (e.g. onlyis_turn_complete=trueevents). Useful for batch workers and structured pipelines (recommended 30s–2m depending on task).RestartOnCrashis for "the process crashed; try again." Exponential backoff (1s, 2s, 4s, ...) capped atMaxRestartBackoff(default 30s). Backoff respects context cancellation.
Example:
cfg := runner.Config{
/* ... */,
Supervisor: &runner.SupervisorOptions{
IdleKill: 15 * time.Minute,
RestartOnCrash: 3,
MaxRestartBackoff: 30 * time.Second,
WatchdogTimeout: 90 * time.Second,
},
OnEvent: func(ev runner.Event) {
if ev.Kind == runner.EventProviderEvent {
se := ev.Payload["event"].(provider.StreamEvent)
if se.Type == provider.EventDone && cfg.Supervisor.ActivityCallback != nil {
cfg.Supervisor.ActivityCallback() // app-defined "progress" signal
}
}
},
}
ActivityCallback is populated by the runner at Start (the field is
non-nil by the time the first OnEvent fires). It supplements the
runner's automatic stdout/stderr ticks; if the caller never invokes it,
the watchdog falls back to raw I/O activity.
Computing restart backoff outside Run
If you run your own restart loop (for example a PTY-based runtime that never
calls Run), runner.ComputeRestartBackoff returns the same schedule
Supervisor.RestartOnCrash uses: 1s, 2s, 4s, ... doubling per attempt
(1-indexed), capped at maxBackoff (30s when maxBackoff <= 0).
for attempt := 1; attempt <= 3; attempt++ {
if err := startMyProcess(); err == nil {
break
}
time.Sleep(runner.ComputeRestartBackoff(attempt, 30*time.Second)) // 1s, 2s, 4s
}
This is a pure visibility export of the formula Run already applies; it does
not change any Config/Run behavior.
Resource limits
Opt in via Config.ResourceLimits (zero value = unlimited).
| Field | Linux (systemd available) | Linux (no systemd) | macOS | Windows |
|---|---|---|---|---|
CPUTime |
ulimit -t (RLIMIT_CPU) |
ulimit -t (RLIMIT_CPU) |
ulimit -t (RLIMIT_CPU) |
unsupported |
MemoryMax |
systemd-run MemoryMax with MemorySwapMax=0 (cgroup v2; real OOM-kill, swap can't extend it) |
ulimit -v (RLIMIT_AS; advisory) |
silently dropped — no VM-isolation; RLIMIT_AS not exposed via ulimit -v on darwin's bash |
unsupported |
MaxOpenFiles |
ulimit -n |
ulimit -n |
ulimit -n |
unsupported |
MaxProcesses |
ulimit -u |
ulimit -u |
ulimit -u |
unsupported |
MaxFileSize |
ulimit -f (block size measured: 512 bytes in dash) |
ulimit -f |
ulimit -f (1024-byte blocks in bash 3.2) |
unsupported |
systemd-run availability is probed once per process by starting a
transient scope (systemd-run --user --scope -- true). Probe failure
(missing binary, no user bus, a sandbox hiding the user manager,
Alpine, minimal containers) cleanly falls back to ulimit-only
enforcement; the runner does not error.
ulimit -f counts blocks whose size depends on the shell: 512 bytes in
dash, busybox and POSIX-mode bash (bash 4+ invoked as sh), 1024 in
bash 3.2 (macOS's /bin/sh). The runner measures it once per process,
so MaxFileSize is the byte count asked for, rounded down to a block.
macOS memory limits. macOS bash's ulimit -v does not bind to
RLIMIT_AS (which itself is not exposed there), and there is no
systemd. MemoryMax is silently dropped on darwin. Callers needing
hard memory limits on macOS should use VM-based isolation (Lima,
OrbStack) — outside go-runner's scope.
Layering with sandbox.Apply. When both Profile and
ResourceLimits are non-zero, the wrap is [systemd-run] sh -c "ulimit ..." outermost, then sandbox-exec/bwrap, then the
real binary. Limits inherit through every fork-exec.
Note: Go's runtime swallows SIGXCPU on at least darwin — Go binaries
configured with CPUTime may not terminate at the soft limit. Native
C-based binaries (sh, yes, dd, claude, codex) honor SIGXCPU normally.
Applying limits outside Run
runner.ApplyResourceLimits(cmd, limits) applies the same argv wrap Run uses
internally to a *exec.Cmd you built yourself. Call it after any sandbox
wrapping and before cmd.Start, once per *exec.Cmd. It returns a cleanup
closure (currently a no-op; call it after cmd.Wait) and an error; a zero
ResourceLimits leaves cmd untouched, and Windows returns an error for
non-zero limits.
cmd := exec.Command("sh", "-c", `echo "nofile=$(ulimit -n)"`)
cleanup, err := runner.ApplyResourceLimits(cmd, runner.ResourceLimits{MaxOpenFiles: 32})
if err != nil {
log.Fatal(err)
}
defer cleanup()
out, _ := cmd.Output()
fmt.Print(string(out)) // nofile=32
This is a pure visibility export of the function Run already applies before
every spawn and restart; it does not change any Config/Run behavior.
Examples
Two runnable examples ship under examples/:
-
examples/basic— spawn the Claude CLI under the runner with no sandbox and no supervision; print every event to stdout. Requiresclaudeon$PATH.go run ./examples/basic -prompt "explain quicksort in one sentence" -
examples/env-passthrough— demonstrateConfig.Envsemantics (nil = inherit parent env;[]string{}= empty env; explicit slice = exactly those entries). Spawns/usr/bin/envthrough a minimalCLIAdapterso you can see what the child process actually sees.RUNNER_DEMO_INHERITED=from-parent go run ./examples/env-passthrough -mode inherit go run ./examples/env-passthrough -mode empty go run ./examples/env-passthrough -mode customThe minimal
echoAdapterin this example is also a useful template for adapting non-LLM line-delimited CLIs without pulling in a full provider package.
Composition
- Spawn / grace-period.
runner.Runbuilds an*exec.Cmd, setscmd.Cancel = SIGTERMandcmd.WaitDelay = provider.WaitDelayFromContext(ctx), and spawns.cfg.WaitDelay(when non-zero) is installed onto the context viaprovider.WithWaitDelay. The grace-period mechanics themselves live ingo-providers. - Sandbox. When
cfg.SandboxPolicyis non-nil, the runner callssandbox.ApplyResolved(cmd, *cfg.SandboxPolicy)beforecmd.Start. Whencfg.Profile.IDis non-empty, it calls the legacysandbox.Apply(cmd, cfg.Profile, cfg.Workspace)adapter instead. The cleanup closure runs aftercmd.Wait. A nil policy plus zero-value profile skips sandboxing and records a disabled outcome. - Parsing. Each line read from stdout is passed to
cfg.Provider.ParseLine. Each returnedStreamEventis wrapped in aprovider.eventrunner event. Parse errors are silently dropped to matchgo-providersbridge behavior.
Illustrative wrappers (not in this lib)
These sketches show how three different consumers translate the same raw event stream into their own vocabulary. They are intentionally NOT included as packages — each app owns its own wrapper.
Clockwork — translate to FSM transitions
// In Clockwork's executor plugin:
runner.Run(ctx, runner.Config{
/* ... */,
OnEvent: func(ev runner.Event) {
switch ev.Kind {
case runner.EventProcessStarted:
task.Transition(ctx, "doing")
case runner.EventProviderEvent:
if ev.Payload["is_turn_complete"].(bool) {
se := ev.Payload["event"].(provider.StreamEvent)
if se.Type == provider.EventError {
task.Transition(ctx, "blocked", se.Error)
}
}
case runner.EventProcessExited:
if code, _ := ev.Payload["exit_code"].(int); code == 0 {
task.Transition(ctx, "review")
} else {
task.Transition(ctx, "blocked", ev.Payload["error"].(string))
}
case runner.EventProcessTimeout:
task.Transition(ctx, "blocked", "deadline exceeded")
}
},
})
Mux — translate to broker session events
// In Mux's session manager:
runner.Run(ctx, runner.Config{
/* ... */,
OnEvent: func(ev runner.Event) {
switch ev.Kind {
case runner.EventProcessStarted:
broker.Publish(sessionID, "session.opened", ev.Payload)
case runner.EventProviderEvent:
se := ev.Payload["event"].(provider.StreamEvent)
broker.Publish(sessionID, "session.delta", map[string]any{
"type": string(se.Type), "content": se.Content,
})
case runner.EventProcessExited, runner.EventProcessTimeout:
broker.Publish(sessionID, "session.closed", ev.Payload)
}
},
})
Nanite — translate to plugin lifecycle
// In Nanite's plugin host:
runner.Run(ctx, runner.Config{
/* ... */,
OnEvent: func(ev runner.Event) {
switch ev.Kind {
case runner.EventProcessStarted:
plugin.Lifecycle.Started(ev.Payload["pid"].(int))
case runner.EventProviderEvent:
se := ev.Payload["event"].(provider.StreamEvent)
plugin.Lifecycle.Activity(se)
case runner.EventProcessExited:
plugin.Lifecycle.Stopped(ev.Payload["exit_code"].(int))
case runner.EventProcessTimeout:
plugin.Lifecycle.TimedOut()
}
},
})
Notice how each wrapper makes different choices about what counts as a state transition, what payload to serialize, and how to handle the terminal events. None of those choices belong in this library.
In scope
- Spawn + grace-period (delegated to
go-providers) - Sandbox wrapping (delegated to
go-sandbox) - Line-by-line stdout streaming via
cfg.Provider.ParseLine - Stderr passthrough to a caller-supplied
io.WriterviaConfig.Stderr(useio.MultiWriterfor sidecar logging + in-memory tail) - Raw event emission via callback
- Process-lifecycle terminal-event guarantee (always exactly one of
process.exited/process.timeoutper Run) - Structured
*ExitError(returned viaerrors.As) carryingCode/Signal/Killed/Cause/ProcessState - Opt-in process supervision:
IdleKill,RestartOnCrash,WatchdogTimeout,ActivityCallback - Opt-in OS resource limits:
CPUTime,MemoryMax,MaxOpenFiles,MaxProcesses,MaxFileSize(per-platform support; see Resource limits section)
Out of scope
- App vocabulary (FSM transitions, broker events, plugin lifecycle).
- Stderr aggregation or interpretation. The runner only wires
cfg.Stderrtocmd.Stderr; bytes flow through verbatim. Whencfg.Stderris nil,cmd.Stderrstays unset andos/execroutes toos.DevNull(its default for nilStderr). Callers wanting parent- process passthrough should passos.Stderrexplicitly. - Output formats other than line-delimited (newline-terminated). The
underlying scanner uses a 1 MiB max line size to match
go-providers. - Adapter argument construction.
Argsis the raw argv. If you want adapter-aware arg building, callcfg.Provider.BuildArgs(...)in your wrapper before populatingConfig.Args. - Process-group signaling.
cmd.Cancelonly signals the direct child; CLIs that fork long-running children inherit stdout and may delay exit. Seego-providersfollow-up notes. - Hard memory limits on macOS — out of scope without VM-based isolation
(Lima / OrbStack). The runner silently drops
MemoryMaxon darwin rather than promising a limit it can't enforce. - Direct cgroups v2 manipulation via
containerd/cgroups. The current Linux memory-limit path shells out tosystemd-run --user --scope; direct cgroups is a follow-up if the shell-out path hits a real limitation. - Per-syscall sandboxing (seccomp). That's
go-sandbox's lane. - Checkpoint / restore (CRIU).
License
MIT — see LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
basic
command
Example: spawn the Claude CLI under go-runner with no sandbox and no supervision, and print every event observed by the runner to stdout.
|
Example: spawn the Claude CLI under go-runner with no sandbox and no supervision, and print every event observed by the runner to stdout. |
|
env-passthrough
command
Example: demonstrate Config.Env behavior.
|
Example: demonstrate Config.Env behavior. |
|
internal
|
|
|
stubcli
command
stubcli is a deterministic CLI used by go-runner's e2e tests.
|
stubcli is a deterministic CLI used by go-runner's e2e tests. |
|
Package runner is a thin substrate that composes go-providers (CLI adapters + spawn bridges) and go-sandbox (resolved AccessPolicy / legacy Profile enforcement) into a single Run entry point.
|
Package runner is a thin substrate that composes go-providers (CLI adapters + spawn bridges) and go-sandbox (resolved AccessPolicy / legacy Profile enforcement) into a single Run entry point. |