Home
Design notes

Provider contract

The capability-record contract each provider binding implements.

Each provider is a capability record, not a persona: what binary to invoke, how to run it non-interactively, how to read back its structured result and usage/cost, how it resumes, and whether it natively fans out subagents. Shipped providers — claude, codex, opencode — each implement the same Runner interface (internal/provider/provider.go).

Contract interface

type Runner interface {
    Run(ctx context.Context, binding Binding, req Request) (Result, error)
}

type Request struct {
    WorkingDir   string
    SystemPrompt string
    UserPrompt   string
    OutputSchema string
    Model        Model
    Effort       string
    AllowedTools []string
    TurnTimeout  time.Duration
    StallTimeout time.Duration
}

type Result struct {
    SessionID       string
    AssistantOutput string
    Usage           Usage // token/cost accounting; zero when unreported
}

One Runner.Run call is one turn. The runtime gives the runner a fully-resolved request; the runner invokes the provider CLI under its own pty, captures output, and returns the assistant's output plus an optional session ID for resume and a best-effort Usage (tokens + CostUSD). The orchestrator accumulates Usage.CostUSD per provider to enforce spend caps.

Every turn resolves two independent clocks. TurnTimeout is an absolute deadline over the entire turn; StallTimeout is a renewable progress lease. Built-in PTY runners renew the lease on underlying reads, and declarative runners renew it on stdout or stderr bytes. Progress never extends the absolute deadline. Request overrides win over binding/project configuration, which wins over bounded defaults (30-minute turn, 3-minute stall). Hard maxima are 24 hours and 1 hour respectively.

Capability record

type BindingConfig struct {
    Type, Bin, Binary string
    Args              []string
    SupportsResume        *bool
    NativeFanout          bool
    // ...model/effort tier overrides
}

NativeFanout is the flag the orchestrator uses to decide whether a parallel step-group should be delegated to one fan-out-capable agent invocation rather than spawned as N Ralph-managed workers:

ProviderNativeFanoutEvidence
claudetrue--agents, --agent, --forward-subagent-text, --bg/claude agents — the CLI natively manages subagents
codexfalse (unconfirmed)codex exec --help exposes no subagent/parallel-workflow flag as of the CLI version evaluated
opencodetrueopencode run --agent, opencode agent create/list — a native multi-agent surface

Project config can deliberately choose Ralph-managed fan-out with providers = ["claude", "codex", "opencode"]. In that mode the resolver round-robins one binding per plan step and overrides NativeFanout to false on the resolved copy. The provider capability record remains truthful; only the execution policy changes. This makes every worker, claim, watchdog, evidence record, and failure independently observable.

Stateful vs. stateless

ProviderState modelBinding
claudeStateful — session resume via claude --resume <id>internal/provider/claudesession holds the session lifecycle
codexStateless — each turn is independentinternal/provider/codex.go
opencodeStateless in v1internal/provider/opencode.go

A stateful binding threads Result.SessionID into the next Request so the provider reuses its own conversation context; stateless bindings ignore it.

Codex result and failure channels

The Codex binding invokes codex exec --json --color never. A clean exit reads only the temporary --output-last-message file as AssistantOutput; terminal events are never mixed into a successful result. The path must remain the same regular-file identity across a pre-open identity snapshot, a platform no-follow/nonblocking open, and the opened-file fstat; Unix snapshots the eager lstat device/inode while Windows snapshots through a stable no-follow handle because its path FileInfo loads file IDs lazily. FIFO, symlink/reparse-point, device, and identity-swap substitutions fail closed. A limited read then enforces the 16 MiB authoritative-result ceiling even if the regular file grows.

On a nonzero exit, the last-message file is treated as potentially partial and is not read. The runner transiently inspects exactly two documented JSONL fields: type: "error"message, and type: "turn.failed"error.message. It classifies them into a closed vocabulary of static failure categories—authentication, model access, quota, rate limit, network, provider service, invalid request, or generic failure—and deduplicates those constants. Classification uses normalized whole tokens and phrases, not arbitrary substrings; its precedence is pinned by tests. No provider substring or captured value is retained or surfaced. The fixed categories are bounded to 4 KiB total, with generic failure as the fail-closed fallback. Diagnostic inspection is separately bounded to 64 frames, 256 KiB total, and 64 KiB per frame after excluding Agent's normalized trailing record delimiter. A 65,536-byte JSON frame is therefore still inspected; its appended newline is not miscounted as provider payload. Crossing a diagnostic budget replaces the accumulated categories with generic failure and makes the collector terminal before message classification. Once all eight categories have been observed, the collector is likewise information-complete and rejects subsequent diagnostic payloads before doing more work.

turn.failed is authoritative process state rather than optional diagnostic text. Ralph continues recognizing that bounded event after diagnostic classification is exhausted, and it dominates an exit code of zero and any last-message file. A retained turn.failed frame terminates an otherwise endless tail immediately, matching the discarded-prefix path. A failed turn therefore cannot be laundered by a misleading process status or partial result.

Codex fully retains and validates observational frames through 4 MiB. A complete object may put its case-sensitive top-level type key anywhere because JSON object order is not semantic, but it must contain that key only once; duplicate discriminators fail closed with a static schema error. A valid object with no exact type key, or with a non-string value, remains non-authoritative pane noise. The complete-frame walk validates JSON and inspects syntax without copying or decoding arbitrary values. A command/item frame inside this bound remains observational even if nested text contains terminal-looking literals.

For a line discarded above that threshold, Agent exposes only a 4 KiB prefix on a separate, unbuffered framing channel. An immediately recognizable case-sensitive type: "turn.failed" remains authoritative. Every other structured prefix fails closed because an unseen later duplicate or reordered failure cannot be ruled out. A whitespace-only prefix is inconclusive and also fails closed; only a first non-JSON-whitespace byte other than { positively proves non-object pane noise and may be ignored. Prefix bytes are never rendered, prompt-matched, or included in an error.

Raw terminal lines and every other event type are ignored, including user/prompt, assistant, reasoning, command, and tool events. Independently, the shared watchdog uses the static reason interactive prompt detected; it never interpolates the observed prompt line into an error for Codex or any other provider.

Claude and OpenCode terminal contracts

Claude is verified against CLI 2.1.218. A terminal type: "result" frame is a success only when subtype is exactly success and is_error is false. error_max_turns maps to a fixed maximum-turn error; every other unsuccessful or unknown result shape fails closed behind a generic static Claude error. Provider result text never enters an error. A success frame remains provisional until Claude exits naturally, so a subsequent nonzero exit status still fails the turn. Ralph supplies the single stream-JSON user message through a finite stdin pipe that closes at EOF while keeping stdout/stderr on the observed PTY. This gives Claude's one-turn protocol its required shutdown signal without closing the output channel or relying on terminal control characters.

OpenCode is verified against CLI 1.18.3. opencode run --format json may emit several model steps before the session becomes idle. Ralph therefore consumes the process to its natural exit instead of treating the first step_finish as terminal. It concatenates every text frame, sums usage and cost from every finish, treats tool-calls as intermediate, and accepts only a final reason of stop or length. A type: "error" event, a nonzero exit, invalid aggregate usage, a missing finish, or another final reason fails without partial output. The error event is immediately terminal; Ralph does not allow an endless noisy tail to keep an already-failed run alive.

Claude and OpenCode independently cap the assembled assistant result and their Ralph-owned structured-evidence tee at 16 MiB each. Crossing either ceiling records only a static error, terminates and joins the Agent, and returns a zero Result. OpenCode session identifiers share its authoritative result budget.

Bounded provider output

The transport bounds retention, not provider protocol size. One MaxOutputRetentionBytes budget accounts for the fixed 64 KiB read buffer, the three bounded 4 KiB discarded-record prefix slots, the callback-owned line, a line awaiting Watch admission, the line currently being assembled, and transient bounded slice-growth overlap. The prefix slots correspond exactly to the provider callback, Watch's pending supervisor admission, and readLoop's next unbuffered handoff. Both output stages are unbuffered, and a Progress signal transfers the original byte slice without converting it to a second string. The default aggregate budget derives a 1 MiB retained-line threshold; a hard aggregate ceiling prevents configuration from turning that bound into an arbitrary allocation. Prefix capture is independent of the retained-line threshold, including when the first kernel read already exceeds a tiny threshold.

Retention is separate from cumulative work. MaxObservedOutputBytes optionally counts raw bytes from every underlying PTY read before line assembly, retention, or discard. Zero deliberately means unlimited for compatibility. Claude, OpenCode, and Codex each set a 16 MiB ceiling: exactly 16 MiB is allowed, while the next byte raises the static ErrObservedOutputTooLarge, actively terminates and reaps the process/session, and is surfaced through Wait and provider supervision. This includes partial lines and Codex records discarded by DiscardOversizeOutput, so continuous noise cannot refresh the stall timer forever without consuming a bounded work budget.

Providers whose result is the line stream use RejectOversizeOutput: crossing the retained-line threshold records a fixed transport error, kills the whole process group, closes the pty, and reaps the child. Codex is different. Codex 0.145's command_execution.command string is not capped alongside its 1 MiB aggregated_output, and codex exec --help publishes no JSONL-record size contract. Ralph therefore uses a measured 4 MiB retained inspection threshold: Darwin arm64's 1 MiB ARG_MAX, plus common worst-case quote/backslash JSON expansion and event-envelope headroom. This stays below Agent's existing 8 MiB per-line maximum. Records with control-heavy escaping can exceed 4 MiB; they are continuously drained without ordinary output emission, then fail safely if their bounded prefix proves or could still become a structured object. The independent cumulative 16 MiB raw ceiling is unchanged. Codex's authoritative success result still comes only from --output-last-message.

Activity observations are emitted immediately whenever the underlying pty Read returns bytes, including one-byte trickles and bytes in a record already being discarded. The channel retains one content-free timestamp and coalesces to the newest read time. Watch computes the stall deadline from that read time, not from when downstream backpressure finally lets it consume the observation; an old queued observation therefore cannot grant a fresh timeout. Partial content never reaches prompt matching or parsers. Every built-in provider honors the resolved stall lease exactly; no provider-specific floor silently widens configured policy. A reader that returns (0, nil) without progress is rejected after 100 consecutive reads, matching Go's bounded no-progress convention; any successful read resets the counter, and cancellation is checked between empty reads. The failure is the static ErrOutputRead and never includes terminal content.

Process lifecycle and output ownership

Agent subprocesses use plain exec.Command, not exec.CommandContext. Ralph owns the only cancellation path. A lifecycle mutex linearizes natural running -> reaping -> finished, forced running -> reaping -> finished, and unrecoverable running -> failed paths. A kernel observer reports child exit without reaping it (pidfd/waitid on Linux, kern.proc.pid zombie observation on macOS, a stable process handle on Windows). Natural observation is deliberately separate from termination ownership: it may claim cmd.Wait only after the kernel proves exit. A successful explicit termination atomically claims its own cmd.Wait, so output backpressure or a broken observer cannot wedge reclamation. Every observer probe also takes that mutex; once any path owns cmd.Wait, no raw PID probe can begin and observe a recycled identity.

Kill checks the same non-reaping observer under the lifecycle mutex. A late cancellation handed an already-exited child preserves its natural status. Otherwise Ralph signals the process group. A real group-signal failure falls back to the stable direct os.Process handle: successful direct termination is reaped but returns ErrProcessSessionCleanup, distinguishing unproven same-session descendant cleanup from the direct child's outcome. ErrProcessTreeCleanup remains as a compatibility alias. A direct termination error is re-probed and retried through the same stable handle at most three times. A transient first failure can converge; a persistent failure moves the lifecycle to an explicit failed terminal path, releases every Agent-owned goroutine, and returns ErrProcessTermination without claiming the still-live process was reclaimed.

The final natural/forced classification comes from the status actually returned by cmd.Wait, not merely from a requested signal. If a child exits normally in the probe-to-signal gap, its real exit status remains natural. On Windows, Ralph requires both a requested termination and TerminateProcess's concrete exit code 1 before suppressing the wait status as forced. Process.Kill returning os.ErrProcessDone explicitly transfers to natural reaping rather than marking the earlier termination request as forced.

On Linux, cleanup enumerates the original PTY session, opens and revalidates a pidfd for each regrouped member, signals it, and repeats with a fixed bound until no live member remains. On Darwin, Ralph obtains each member's TASK_AUDIT_TOKEN, revalidates its session, start time, parent, group, and effective UID, then calls the public proc_signal_with_audittoken API leaf-first. XNU validates the token's PID version, so an exited process cannot turn a recycled numeric PID into a signal target. Darwin deliberately has no raw-PID descendant fallback. Both implementations therefore reclaim setpgrp(2) descendants without PID-reuse risk. Apple documents (pid, pidversion) as a specific execution identity in Endpoint Security, and the validation path is visible in XNU's proc_find_audit_token.

A descendant that deliberately creates a new session with setsid(2) remains outside the portable original-PTY-session boundary; the terminal-aware reader prevents it from wedging Ralph, but the child is not claimed as safely discoverable after reparenting. Extending the boundary requires a pre-exec kernel containment primitive rather than a raw process-tree walk (cgroup v2 on Linux, a native Job Object design on Windows, or an entitled Darwin equivalent).

Provider failures cross the persistence boundary only as a closed failure_category plus a static summary. Categories distinguish total deadline, stall, interactive prompt, cancellation, output limit, process cleanup, provider rejection, and generic runtime failure. Raw CLI diagnostics remain transient error causes and never enter task events or A2A evidence. Successful assistant output is rechecked against the 16 MiB evidence ceiling at the orchestrator boundary, so declarative and custom runners cannot bypass the bounds enforced while built-in provider streams are parsed.

Linux retries only EINTR around pidfd acquisition/polling and the waitid fallback. Every other observer backend error is wrapped by ErrProcessExitObservation, followed by explicit termination and independent reaping convergence. If that termination works, the turn fails honestly and all resources are reclaimed; if it does not, the joined termination error still releases Ralph's control path. Hosts outside the release matrix (currently AIX, DragonFly BSD, FreeBSD, NetBSD, OpenBSD, and Solaris) are rejected by Start until they have an equally strong observer.

PTY EOF means only that output ended. It never starts a timer, forces the process, or authorizes cmd.Wait; a child may close all stdio, work for another 250 ms, and exit naturally with its exact status preserved. The reader uses nonblocking readiness polling so even an impossible direct-kill failure can interrupt it. Once process control reaches a terminal state it drains bytes already ready in the kernel, then stops instead of waiting on a PTY slave inherited by an out-of-session descendant. Because O_NONBLOCK applies to both sides of the PTY, WriteInput provides a full-write contract with short-write/EAGAIN polling, caller-cancellation, and terminal-result checks. Finite one-turn protocols instead use Options.OneShotInput: Agent clones the bytes, attaches an EOF-capable stdin pipe, leaves stdout/stderr on the PTY, and rejects later interactive writes with ErrOneShotInputClosed.

Output is deliberately unbuffered and lossless during normal supervision. It closes immediately before Done, but only after natural reaping or an explicit terminal-control result. Wait idempotently abandons unread output and joins every Agent-owned goroutine. Provider supervision drains normal output, uses Wait for natural exit, and synchronously calls TerminateAndWait for prompts, stalls, cancellation, output failures, and parsed terminal frames. Primary and cleanup failures are joined; a terminal frame becomes success only after reclamation succeeds.

Resolution and validation

ResolveBinding picks a provider by name, falling back to the built-in capability record for claude/codex/opencode when no explicit override is configured, and defaulting to claude when nothing is specified. The supervisor's store-backed resolver accepts either the backward-compatible singular provider key or the plural providers pool described above. NewRunner maps the resolved binding's Type to a concrete Runner implementation. An unknown provider type fails loudly rather than silently defaulting.

Only a shipped binary name (claude, codex, opencode) may be named by a binding sourced from shared config; any other binary — a custom declarative CLI, an absolute path, a wrapper — must come from an operator-local override, never from something another party could hand the supervisor. agy/Antigravity was evaluated and found to route through a cloud control surface (cloudcode-pa.googleapis.com), so no runner is registered for it — see Declarative provider bindings for CLIs that don't ship a hand-written Go runner.

Adding a new built-in provider

  1. Create internal/provider/<name>.go implementing Runner.
  2. Register it in NewRunner's switch and builtInProvider.
  3. Add its capability record (default<Name>Provider) with evidence for NativeFanout.
  4. Add a doctor check in internal/doctor/checks.go.
  5. Document its state model + argv shape here.