README
¶
go-runner
Thin Go substrate that composes go-providers
(CLI adapters + spawn helpers) and go-sandbox
(Profile + Apply) into a single Run entry point. It spawns a CLI binary
under a sandbox profile, 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"
err := runner.Run(ctx, runner.Config{
Provider: myAdapter, // provider.CLIAdapter
Profile: myProfile, // sandbox.Profile (zero = no sandbox)
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 |
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 |
process.timeout |
once, in place of process.exited when ctx deadline hit |
error |
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.
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.
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 (cgroup v2; real OOM-kill) |
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 (1024-byte blocks) |
ulimit -f |
ulimit -f |
unsupported |
systemd-run availability is probed once per process via
systemd-run --user --version. Probe failure (missing binary,
no user bus, Alpine, minimal containers) cleanly falls back to
ulimit-only enforcement; the runner does not error.
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.
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.Profile.IDis non-empty, the runner callssandbox.Apply(cmd, cfg.Profile, cfg.Workspace)beforecmd.Start, wrapping withsandbox-exec(darwin) orbwrap(linux). The cleanup closure runs aftercmd.Wait. A zero-value profile skips sandboxing entirely. - 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 (Profile + Apply) into a single Run entry point.
|
Package runner is a thin substrate that composes go-providers (CLI adapters + spawn bridges) and go-sandbox (Profile + Apply) into a single Run entry point. |