Documentation
¶
Overview ¶
Package runtime owns the per-AI-CLI runtime layer for agents:
- the supported-type list (claude / codex / gemini)
- per-instance config (named profile, binary override, extra args, extra env) read from userconfig
- PATH detect + `--version` probes
- spawn-log files used by the Backends UI page
"runtime" = how a single AI CLI invocation is configured + observed. A user can have multiple instances of the same type ("claude/work" + "claude/personal") so this package returns lists, not singletons.
Why not in `agent/`: `agent/` drives one subprocess (stdin/stdout loop, idle timer). This package answers "which binary, with what flags + env, and is it healthy?" — orthogonal concerns kept apart so `agent/` stays CLI-agnostic.
Package provider owns everything per-AI-CLI for the agents module:
- Agent lifecycle: spawn one CLI subprocess, pipe stdin/stdout, run an idle timer, surface state, tear down on demand
- Spawner interface: pluggable subprocess construction so tests can drive the agent without a real claude binary
- Type / Instance config: which CLIs are supported (claude / codex / gemini), per-instance overrides (binary path, extra args, env) read from userconfig
- Detect + `--version` probes used by the Backends UI page
- Per-spawn jsonl logs used by the Backends UI page
Sub-packages `claude/`, `codex/`, `gemini/` provide the real CLI-specific Spawner implementations. They depend on this package for the Spawner / SpawnOptions interface; this package never imports them back.
Index ¶
- Constants
- Variables
- func AutoRescanEnabled() bool
- func Delete(t Type, name string) error
- func InvalidateProbeCache(t Type, name string)
- func MergeHookCapability(t Type, name, event string, hc HookCapability)
- func Save(ins Instance) error
- func SetAutoRescanLookup(fn func() bool)
- func SetHookEnabled(t Type, name, event string, enabled bool) error
- func Switch(layout config.Layout, pool Pool, sessionID, agentName, tag string, ...) error
- func TruncateFirstMessage(text string) string
- type Agent
- func (a *Agent) Argv() []string
- func (a *Agent) Binary() string
- func (a *Agent) InFlightEvents() []store.TurnEvent
- func (a *Agent) PID() int
- func (a *Agent) PartialText() string
- func (a *Agent) ResumeID() string
- func (a *Agent) Running() bool
- func (a *Agent) Send(text string) error
- func (a *Agent) Start(ctx context.Context) error
- func (a *Agent) Stop() error
- type CodexConfig
- type CodexSandboxMode
- type ExitReason
- type HookCapability
- type HookInstanceConfig
- type Instance
- type Options
- type Pool
- type Process
- type SpawnEvent
- type SpawnLogFile
- type SpawnLogger
- func (s *SpawnLogger) Append(path string, ev SpawnEvent) error
- func (s *SpawnLogger) List(providerType, providerName, sessionID string) ([]SpawnLogFile, error)
- func (s *SpawnLogger) Path(providerType, providerName, sessionID string, startedAt time.Time) string
- func (s *SpawnLogger) Prune(keep int) error
- func (s *SpawnLogger) Read(path string) ([]SpawnEvent, error)
- type SpawnOptions
- type Spawner
- type Status
- func LoadCached(ctx context.Context) ([]Status, error)
- func Probe(ctx context.Context, ins Instance) Status
- func ProbeAll(ctx context.Context) ([]Status, error)
- func ProbeAllCached(ctx context.Context) ([]Status, error)
- func RescanAll(ctx context.Context) []Status
- func RescanOne(ctx context.Context, t Type, name string) Status
- type StorageConfig
- type SwitchOptions
- type Type
Constants ¶
const FirstMessageWordLimit = 10
FirstMessageWordLimit caps the spawn log's first_user_message at the first N whitespace-separated tokens. Word-based (not byte-based) so the preview reads naturally regardless of language; the UI table stays one line per row.
const HookEventPreToolUse = "PreToolUse"
HookEventPreToolUse is the well-known event key for the command gate. Hard-coded here so callers don't have to spell the string. New event keys are added as constants alongside this one as wick learns to intercept additional lifecycle hooks.
const MaxSpawnLogs = 50
MaxSpawnLogs is how many spawn log files are retained. The newest N are kept; older ones are deleted on every new spawn. Keeps the Recent Spawns list bounded + the spawns/ dir from growing unbounded.
const VersionRefreshInterval = 24 * time.Hour
VersionRefreshInterval is how stale a persisted version probe must be before a page render kicks off a background re-probe (when auto-rescan is on). Path scan is cheap and re-runs on every Rescan; version probe is the expensive bit and rarely changes outside CLI upgrades.
Variables ¶
var AppName = ""
AppName is the userconfig project name the agents module reads/writes under. Wired by the bootstrap: a server with APP_NAME=foo stores its agents config in ~/.foo/config.json.
Functions ¶
func AutoRescanEnabled ¶
func AutoRescanEnabled() bool
AutoRescanEnabled returns the current toggle value, defaulting to true when no lookup is wired.
func Delete ¶
Delete removes an instance. Removing the last instance for a type is allowed — Load will auto-seed the default again on the next read.
func InvalidateProbeCache ¶
InvalidateProbeCache drops the cached Status for one instance. Empty type or name drops the whole cache (useful on bulk ops).
func MergeHookCapability ¶ added in v0.9.5
func MergeHookCapability(t Type, name, event string, hc HookCapability)
MergeHookCapability writes the capability-probe result for one hook event into the persisted status, leaving other hooks + version/path fields untouched. Called by the HTTP handler that runs HookCapabilityCheck so the next page render reflects the new Verified state straight from disk — same TTL semantics as the version probe (only re-runs on Rescan / Version change / explicit Test).
event: well-known hook event key (HookEventPreToolUse, …). Callers supplying a free-form string is fine — the map accommodates any key future versions decide to probe.
func Save ¶
Save persists a new or updated instance. Empty Name is rejected. Replaces any existing entry with the same {Type, Name}.
func SetAutoRescanLookup ¶
func SetAutoRescanLookup(fn func() bool)
SetAutoRescanLookup wires the boot-time accessor for the toggle. Until called, AutoRescanEnabled defaults to true.
func SetHookEnabled ¶ added in v0.9.5
SetHookEnabled flips the user's enable/disable intent for one hook event on one instance, persisting through userconfig. Used by the per-card Enable/Disable button on the Providers page after a successful (or failed) capability probe.
func Switch ¶ added in v0.13.4
func Switch(layout config.Layout, pool Pool, sessionID, agentName, tag string, opts SwitchOptions) error
Switch changes the provider for sessionID+agentName, persists the change to agents.json, records a system turn in conversation.jsonl (with step trace), kills the running agent, and optionally sends a greeting to the new agent.
func TruncateFirstMessage ¶
TruncateFirstMessage keeps the first FirstMessageWordLimit words of text and appends "…" when more content was dropped. Whitespace inside the message is collapsed so multi-line input renders on one line.
Types ¶
type Agent ¶
type Agent struct {
// contains filtered or unexported fields
}
Agent owns one running subprocess. Lifecycle:
NewAgent(...) — constructed, not yet started
Start(ctx) — spawn subprocess, kick off reader + idle timer
Send("...") — write a user message into stdin
Stop() — kill subprocess, wait for reader to drain
Idle TTL: while no parser event arrives for IdleTimeout, the agent kills its own subprocess. State machine is reset to Idle so callers can spawn a fresh process on the next message (with --resume if a CLI session ID was captured).
Callers (the pool) treat Agent as one-shot per spawn — kill returns the agent to "ready to spawn again" rather than reusing the process.
func (*Agent) Argv ¶
Argv returns the argument vector of the running subprocess. Empty when not running or when the spawner is a test fake.
func (*Agent) Binary ¶
Binary returns the resolved binary path of the running subprocess. Empty when not running or when the spawner is a test fake.
func (*Agent) InFlightEvents ¶ added in v0.13.3
InFlightEvents returns events buffered in the current in-progress turn (tool_use, tool_result, thinking) that have not yet been flushed to disk. Returns nil when no turn is active or store is not wired.
func (*Agent) PID ¶
PID returns the OS pid of the current subprocess, or 0 if not running. Pool reads this after Start so the spawn log captures the real pid (Build runs before Start, so the start event written there can't know the pid yet).
func (*Agent) PartialText ¶ added in v0.14.3
PartialText returns the assistant text accumulated so far for the in-flight turn. Empty when no turn is active or store is not wired. The SSE snapshot endpoint uses this so a refresh mid-stream repaints the partial bubble instead of waiting for the next delta.
func (*Agent) ResumeID ¶
ResumeID returns the captured CLI session ID, or "" if SessionStart has not arrived yet. Pool reads this when re-spawning after idle kill so claude --resume picks up the same conversation.
func (*Agent) Send ¶
Send writes one user message line to the subprocess stdin. The message is wrapped as a stream-json user message — claude expects `{"type":"user","message":{"role":"user","content":"..."}}` plus newline when invoked with --input-format stream-json.
Caller is also expected to AppendUserTurn into the store so conversation.jsonl reflects the message; we don't double-write here because some transports (replay tests) skip storage.
type CodexConfig ¶ added in v0.13.4
type CodexConfig struct {
// SandboxMode sets --sandbox. Empty = CodexSandboxFullAccess.
SandboxMode CodexSandboxMode
}
CodexConfig holds codex-specific spawn configuration. Populated only when Instance.Type == TypeCodex.
type CodexSandboxMode ¶ added in v0.13.4
type CodexSandboxMode string
CodexSandboxMode maps to codex's --sandbox flag values.
const ( CodexSandboxReadOnly CodexSandboxMode = "read-only" CodexSandboxWorkspaceWrite CodexSandboxMode = "workspace-write" CodexSandboxFullAccess CodexSandboxMode = "danger-full-access" )
type ExitReason ¶
type ExitReason int
ExitReason classifies why the subprocess ended. The pool uses this to decide whether to drain queued messages immediately.
const ( ExitClean ExitReason = iota // subprocess returned normally ExitIdle // idle TTL killed it ExitStopped // Stop() was called ExitError // wait returned an error )
type HookCapability ¶ added in v0.9.5
type HookCapability struct {
Supported bool
Verified bool
ProbedAt time.Time
Error string
Scope string
}
HookCapability is the in-memory mirror of userconfig.HookCapability — same fields, ProbedAt parsed as time.Time so handlers don't have to re-parse on every render.
type HookInstanceConfig ¶ added in v0.9.5
type HookInstanceConfig struct {
Enabled bool
}
HookInstanceConfig mirrors userconfig.HookInstanceConfig in-memory. Per-event user intent; not capability state (that lives on Status).
type Instance ¶
type Instance struct {
Type Type
Name string
Binary string // override path; empty = use Type as PATH name
ExtraArgs []string
Env []string
Disabled bool
// Hooks holds the user's enable/disable intent per hook event
// (PreToolUse, SessionStart, …). Spawners read this on every
// Spawn to decide whether to install / remove the per-workspace
// hook config.
Hooks map[string]HookInstanceConfig
// Storage configures credential-file syncing for this instance.
// nil = sync disabled.
Storage *StorageConfig
// CodexConfig holds codex-specific spawn options. nil for non-codex instances.
CodexConfig *CodexConfig
}
Instance is the in-memory view of one configured runtime instance — merged from userconfig + supported-type defaults. The Backends UI page renders one card per Instance.
func Find ¶
Find resolves an instance by {type, name}. Empty name resolves to the per-type default whose Name equals the type itself. Uses the in-memory cache so the hot Spawn path doesn't re-read userconfig.
func Load ¶
Load returns every configured instance across all supported types, auto-seeding the per-type default entry when its list is empty so the UI always has at least one row per supported runtime.
func (Instance) Bin ¶
Bin returns the binary the spawner should execute: override path when set, else the canonical type name (resolved later via PATH).
func (Instance) HookEnabled ¶ added in v0.9.5
HookEnabled reports whether the user has opted this instance into the named hook event. Missing key = false (default off).
type Options ¶
type Options struct {
Workspace string
ResumeID string
IdleTimeout time.Duration
// KillAfterIdle is the extra grace period after IdleTimeout fires
// before the subprocess is actually killed. 0 = kill immediately.
// During the grace period, new output from the subprocess resets
// the cycle (grace is cancelled and IdleTimeout restarts).
KillAfterIdle time.Duration
ParserFactory func() event.Parser
Spawner Spawner
Store *store.Store
State *state.Machine
OnEvent func(event.AgentEvent)
OnExit func(reason ExitReason)
// Instance is the per-instance config the spawner should consult
// every spawn (hook intent, env, …). Forwarded into SpawnOptions
// so the spawn package is the only place that reads the registry.
Instance *Instance
// GateBinary is the absolute path to <app>-gate, resolved once by
// the factory and threaded through every spawn so the spawner can
// write hook configs without re-resolving.
GateBinary string
// Preset is the system prompt content forwarded to the spawner as
// --append-system-prompt (or equivalent). Stripped from spawn logs.
Preset string
// ExtraEnv merges into the subprocess env on every spawn. Used by
// per-channel transports (Slack, HTTP) that need to inject auth
// tokens or routing keys.
ExtraEnv []string
// MessageEncoder formats a user message before writing to stdin.
// nil = default Claude stream-json envelope. Ignored when RespawnOnSend=true.
MessageEncoder func(text string) string
// RespawnOnSend, when true, means Send() kills the current process and
// spawns a new one with the message as InitialMessage (positional arg).
// Used by codex which is one-shot per invocation, not long-lived.
RespawnOnSend bool
}
Options is the constructor argument. ParserFactory returns a fresh parser per spawn (parsers carry per-stream state — block index map and so on — so we can't reuse one across processes).
type Pool ¶ added in v0.13.4
type Pool interface {
Kill(sessionID, agentName string) error
Send(ctx context.Context, sessionID, agentName, source, role, text string) error
}
Pool is the subset of pool.Pool that Switch needs.
type Process ¶
type Process interface {
Stdout() io.Reader
Stdin() io.WriteCloser
Wait() error
Kill() error
// Pid returns the OS process id of the started subprocess, or 0 if
// not applicable (fake spawners in tests). Used by the spawn logger
// + Backends UI to verify a re-spawn actually got a new process and
// not just the same one looping.
Pid() int
// Binary is the resolved absolute path of the launched executable
// (e.g. "/usr/local/bin/claude"). Empty when the spawner is a test
// fake. Logged at spawn-start so operators can debug "claude not
// found" / wrong binary issues from the Backends UI alone.
Binary() string
// Argv is the argument vector handed to the subprocess (excluding
// argv[0] = binary). Logged at spawn-start so the operator can
// reproduce the spawn manually outside wick.
Argv() []string
}
Process is a started subprocess: stdout reader, stdin writer, and a Wait method that returns when the process exits.
Implementations:
- exec.Cmd-backed (production)
- pipe-backed fake (tests)
Stdout is the parser-facing stream — for claude that's stream-json. Wait MUST drain Stdout to EOF before returning so callers can rely on the read loop seeing every line.
type SpawnEvent ¶
type SpawnEvent struct {
Type string `json:"type"`
At time.Time `json:"at"`
ProviderType string `json:"provider_type,omitempty"`
ProviderName string `json:"provider_name,omitempty"`
SessionID string `json:"session_id,omitempty"`
AgentName string `json:"agent_name,omitempty"`
Workspace string `json:"workspace,omitempty"`
ResumeID string `json:"resume_id,omitempty"`
Binary string `json:"binary,omitempty"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
// PID is the OS pid of the started subprocess. Set on the `start`
// event after Spawner.Spawn returns; carried on `exit` so listings
// can verify the same pid was reaped. 0 = test fake or unknown.
PID int `json:"pid,omitempty"`
// Origin is the session origin that triggered the spawn (e.g. "slack",
// "telegram", "rest", "ui"). Written once on the initial start event
// so the Recent Spawns list can show the channel without a session
// registry lookup.
Origin string `json:"origin,omitempty"`
// FirstUserMessage is a short prefix of the user input that
// triggered the spawn (truncated). Surfaces in the Backends UI
// "Recent Spawns" list so operators see what each spawn was for.
FirstUserMessage string `json:"first_user_message,omitempty"`
ExitReason string `json:"exit_reason,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
SpawnEvent is one line in a spawn log file. Type carries the event kind (`start` / `version` / `error` / `exit`), other fields are populated based on Type — tests should match by Type rather than asserting on every field.
type SpawnLogFile ¶
type SpawnLogFile struct {
Path string
ProviderType string
ProviderName string
SessionID string
StartedAt time.Time
PID int
Origin string // session origin (slack/telegram/rest/ui/…)
FirstUserMessage string
Binary string
Argv []string
// ExitReason is "" while the spawn is still alive (no exit event
// recorded yet), else "clean" / "idle" / "stopped" / "error".
ExitReason string
}
SpawnLogFile is a parsed metadata view of one spawn log filename — used by the Providers page to filter by `ls` alone. PID + FirstUserMessage + ExitReason + Binary + Argv are populated by List from the file's first/last events (one read per file, cheap because spawn logs are short).
type SpawnLogger ¶
type SpawnLogger struct {
BaseDir string // <agents-base>/providers/spawns
// contains filtered or unexported fields
}
SpawnLogger writes one jsonl file per spawn under `<base>/providers/spawns/`. Filename encodes provider type + name + session id + start unix-ts so an `ls` already filters cheaply by any of those without opening files. Example:
claude__work__abc123__1715234567890.jsonl
Each file holds line-delimited JSON events for that single spawn: `start`, optional `version`, `error`, `exit`. The Providers UI lists recent files (newest first) and renders one event timeline per file.
func NewSpawnLogger ¶
func NewSpawnLogger(agentsBase string) *SpawnLogger
NewSpawnLogger returns a logger rooted at <agentsBase>/providers/spawns. agentsBase is typically Layout.BaseDir.
func (*SpawnLogger) Append ¶
func (s *SpawnLogger) Append(path string, ev SpawnEvent) error
Append writes one event to the spawn log file, creating it on first call. Errors are returned but never panic — the caller (pool) treats them as logging failures, not spawn failures.
func (*SpawnLogger) List ¶
func (s *SpawnLogger) List(providerType, providerName, sessionID string) ([]SpawnLogFile, error)
List returns parsed metadata for every spawn log file under BaseDir, newest first. Filter args narrow the result; pass empty strings for wildcards. Files whose names don't match the canonical `<type>__<name>__<session>__<unix-ms>.jsonl` shape are skipped.
func (*SpawnLogger) Path ¶
func (s *SpawnLogger) Path(providerType, providerName, sessionID string, startedAt time.Time) string
Path returns the on-disk path for a spawn log without creating the file. Useful for tests.
func (*SpawnLogger) Prune ¶ added in v0.14.21
func (s *SpawnLogger) Prune(keep int) error
Prune keeps the newest `keep` spawn log files and deletes the rest. Serialized so concurrent spawns don't double-delete. Best-effort: individual delete errors are ignored (a missing file is already gone).
func (*SpawnLogger) Read ¶
func (s *SpawnLogger) Read(path string) ([]SpawnEvent, error)
Read parses every event line from one spawn log file in order.
type SpawnOptions ¶
type SpawnOptions struct {
Workspace string
ResumeID string
// ExtraEnv lets the gate (phase 3) inject hook config paths
// without coupling the agent package to gate internals.
ExtraEnv []string
// Instance is the resolved per-instance config the factory looked
// up before this spawn. Spawners read Instance.Hooks to decide
// which hook configs to install / remove on the workspace and
// whether to flip provider-specific bypass flags. nil = legacy
// test paths that don't drive hook plumbing.
Instance *Instance
// GateBinary is the absolute path to <app>-gate the spawner should
// reference when writing hook configs. Resolved once by the
// factory (sibling / embed / PATH) and forwarded so each provider
// sub-package doesn't have to repeat the resolution dance.
GateBinary string
// Preset is the system prompt content injected via --append-system-prompt
// when non-empty. Each provider spawner decides how to pass it to the
// underlying CLI. The value is never written to spawn logs — Argv() strips it.
Preset string
// InitialMessage is the first user prompt for providers that take the
// prompt as a positional arg (codex) rather than via stdin after spawn.
// Empty = no positional prompt arg appended. claude ignores this field.
InitialMessage string
}
SpawnOptions describes one spawn request. Workspace is the cwd of the subprocess (session worktree). ResumeID is the CLI-side session ID captured from a previous run; empty = start a fresh session.
The agent package never reaches into the spawner internals — every CLI-flag decision happens inside the spawner, keeping agent.go CLI-agnostic and easier to extend with codex / gemini in phase 6.
type Spawner ¶
type Spawner interface {
Spawn(ctx context.Context, opt SpawnOptions) (Process, error)
}
Spawner builds a Process from spawn parameters. The agent package asks the spawner to start a subprocess; the spawner is responsible for choosing argv, working directory, env, and any CLI-specific flags (e.g. claude's --output-format stream-json + --resume).
type Status ¶
type Status struct {
Instance Instance
ResolvedAt time.Time
Path string // result of LookPath / override
PathFound bool
Version string // first line of `<bin> --version`
VersionErr string // error message when version probe failed
Hooks map[string]HookCapability
// Probing is a render-time hint set by the HTTP layer when a
// capability probe is currently in flight for this instance. UI
// disables the Test / Enable buttons so the user can't double-fire.
// Not persisted — pure in-memory state.
Probing bool
}
Status is the live health of one instance, as shown in the UI.
Hooks is per-hook-event capability info, keyed by event name ("PreToolUse" for the command gate; more events join the map later without struct churn). Empty map = never probed; UI shows a "Click Test" prompt.
func LoadCached ¶
LoadCached returns Status per configured instance, served from the persistent cache. Misses return a zero Status (with the instance metadata filled in) and trigger a background RescanOne so the next render sees the result — page render must NEVER block on a cold `--version` spawn or the page hangs while npm shims warm up.
Background refresh: when AutoRescanEnabled() and a cached entry's version_at is older than VersionRefreshInterval, also spawn a detached re-probe.
func Probe ¶
Probe resolves the binary path and runs `--version` for one instance. Disabled instances skip the spawn but still report the resolved path so the UI can show what wick would have run.
ctx bounds the version probe; HTTP handlers should pass a 3s timeout.
func ProbeAll ¶
ProbeAll runs Probe on every configured instance in parallel, honouring ctx as the total timeout (per-probe is bounded by ctx).
func ProbeAllCached ¶
ProbeAllCached returns Status per configured instance, serving from an in-memory cache when the entry is younger than probeCacheTTL. Stale or missing entries are re-probed in parallel under ctx.
type StorageConfig ¶ added in v0.11.0
type StorageConfig struct {
Mode string // "folder" | "single"
SyncPath string
IntervalSeconds int
}
StorageConfig mirrors userconfig.StorageConfig in-memory.
type SwitchOptions ¶ added in v0.13.4
type SwitchOptions struct {
// Greeting, if non-empty, is sent as a user message to the new agent
// immediately after the switch so it can introduce itself.
Greeting string
// Source is the transport label written into conversation.jsonl ("ui", "slack", etc.).
Source string
// UserText is the original raw message (e.g. "#codex") to record as a
// user turn in conversation.jsonl before the switch events.
UserText string
// Notify, if set, is called after the system turn is written so the
// caller can push a realtime event (e.g. SSE) to connected clients.
// tag is the new provider tag; steps are the trace lines.
Notify func(tag string, steps []string)
// Reply, if set, is called with the confirmation text so every channel
// (UI SSE, Slack postReply, REST response, etc.) can deliver it back to
// the user without forwarding to the provider.
Reply func(text string)
}
SwitchOptions controls optional behaviour after a provider switch.
type Type ¶
type Type string
Type is the AI CLI kind. Adding a new type = add a constant here + teach Spawn how to wire its argv + auto-seed on bootstrap.
func SupportedTypes ¶
func SupportedTypes() []Type
SupportedTypes returns all CLI types the agents module knows how to spawn. Order is the UI display order.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package claude is the Claude-CLI specific Spawner implementation.
|
Package claude is the Claude-CLI specific Spawner implementation. |
|
Package codex will hold the Codex-CLI specific Spawner implementation.
|
Package codex will hold the Codex-CLI specific Spawner implementation. |
|
Package gemini will hold the Gemini-CLI specific Spawner implementation.
|
Package gemini will hold the Gemini-CLI specific Spawner implementation. |