provider

package
v0.14.21 Latest Latest
Warning

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

Go to latest
Published: Jun 3, 2026 License: MIT Imports: 22 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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.

View Source
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

View Source
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

func Delete(t Type, name string) error

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

func InvalidateProbeCache(t Type, name string)

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

func Save(ins Instance) error

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

func SetHookEnabled(t Type, name, event string, enabled bool) error

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

func TruncateFirstMessage(text string) string

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 New

func New(opt Options) *Agent

New builds an Agent from Options. Doesn't spawn — call Start.

func (*Agent) Argv

func (a *Agent) Argv() []string

Argv returns the argument vector of the running subprocess. Empty when not running or when the spawner is a test fake.

func (*Agent) Binary

func (a *Agent) Binary() string

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

func (a *Agent) InFlightEvents() []store.TurnEvent

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

func (a *Agent) PID() int

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

func (a *Agent) PartialText() string

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

func (a *Agent) ResumeID() string

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) Running

func (a *Agent) Running() bool

Running reports whether the subprocess is currently alive.

func (*Agent) Send

func (a *Agent) Send(text string) error

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.

func (*Agent) Start

func (a *Agent) Start(ctx context.Context) error

Start spawns the subprocess and begins consuming its stdout. ctx being canceled tears the agent down. Returns nil immediately on success; the reader runs in a background goroutine.

func (*Agent) Stop

func (a *Agent) Stop() error

Stop kills the subprocess and waits for the reader to exit. Idempotent: calling on a stopped agent returns nil. The accompanying store is flushed so partial assistant turns don't disappear.

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

func Find(t Type, name string) (Instance, error)

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

func Load() ([]Instance, error)

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

func (i Instance) Bin() string

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

func (i Instance) HookEnabled(event string) bool

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

func LoadCached(ctx context.Context) ([]Status, error)

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

func Probe(ctx context.Context, ins Instance) Status

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

func ProbeAll(ctx context.Context) ([]Status, error)

ProbeAll runs Probe on every configured instance in parallel, honouring ctx as the total timeout (per-probe is bounded by ctx).

func ProbeAllCached

func ProbeAllCached(ctx context.Context) ([]Status, error)

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.

func RescanAll

func RescanAll(ctx context.Context) []Status

RescanAll re-probes every configured instance in parallel and persists each result.

func RescanOne

func RescanOne(ctx context.Context, t Type, name string) Status

RescanOne forces a fresh Probe + persist for one instance.

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.

const (
	TypeClaude Type = "claude"
	TypeCodex  Type = "codex"
	TypeGemini Type = "gemini"
)

func SupportedTypes

func SupportedTypes() []Type

SupportedTypes returns all CLI types the agents module knows how to spawn. Order is the UI display order.

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.

Jump to

Keyboard shortcuts

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