subagent

package
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: May 31, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package subagent runs a ONE-SHOT, HEADLESS vendor subagent: it launches `claude -p` backed by a third-party vendor model (via the vendor profile's --settings + --model) and returns the result synchronously. It is the lean half of the spawn pipeline (load vendor → write profile → load fingerprint) plus an exec shell, with the entire tmux / team / lock half removed.

Three invariants hold here:

  • Keys never leak: the vendor key flows ONLY via the profile's apiKeyHelper (`<abs cc-fleet> keyget <vendor>`), which claude execs; cc-fleet's subagent process never reads key bytes, never puts a key in argv/env/log/stdout.
  • Lock-free: subagent writes no team config / members / inbox and splits no tmux pane, so it takes NEITHER WithTeamLock NOR WithServerLock. The only write is profile.WriteForVendor (already atomic + idempotent), so N concurrent subagents for one vendor are embarrassingly parallel.
  • The headless child's env strips the lead's creds AND the nested-CC / teams markers (see cleanEnv); fp.Env is deliberately NOT re-applied.

Index

Constants

View Source
const (
	// Pre-flight failures (claude never launched). Reuse spawn's code spellings
	// so the skill already recognizes them.
	ErrCodeBadArgs            = "SUBAGENT_BAD_ARGS"   // --prompt/--prompt-file missing or both given (CLI layer)
	ErrCodeUnknownVendor      = "UNKNOWN_VENDOR"      // vendor not in vendors.toml
	ErrCodeVendorDisabled     = "VENDOR_DISABLED"     // enabled = false
	ErrCodeFingerprintMissing = "FINGERPRINT_MISSING" // never captured → skill self-heal
	ErrCodeFingerprintStale   = "FINGERPRINT_STALE"   // BinaryPath gone from disk

	// Probe failure (only when --probe).
	ErrCodeVendorUnreachable = "VENDOR_UNREACHABLE" // transport-layer failure

	// Runtime failures parsed from claude's inner envelope.
	ErrCodeKeyInvalid          = "KEY_INVALID"          // api_error_status 401/403
	ErrCodeRateLimited         = "RATE_LIMITED"         // 429 (no balance signature)
	ErrCodeInsufficientBalance = "INSUFFICIENT_BALANCE" // 429/402 + balance signature
	ErrCodeModelNotFound       = "MODEL_NOT_FOUND"      // 400 + model-name rejection
	ErrCodeVendorAPIError      = "VENDOR_API_ERROR"     // other is_error / 5xx / overloaded

	// cc-fleet layer.
	ErrCodeTimeout = "SUBAGENT_TIMEOUT" // --timeout deadline fired before claude returned
	ErrCodeFailed  = "SUBAGENT_FAILED"  // non-zero exit with no parseable envelope / internal error
)

Error code enumeration. Skills switch on these strings without parsing prose. The runtime codes deliberately echo teardown's error_class vocab (auth↔KEY_INVALID, rate_limit↔RATE_LIMITED, …) for a consistent mental model across teammate (`ps --check`) and subagent.

Variables

This section is empty.

Functions

func PurgeJobs

func PurgeJobs() (dir string, removedFinished []string, running []string, err error)

PurgeJobs is the uninstall-time cleanup of ConfigDir()/subagent-jobs. It is a sibling of GC but unconditional on age: it removes the full file group (.json/.out/.err/.prompt/.result.json) of every FINISHED job — even when OTHER jobs are still running — and keeps only the running ones. So a live background subagent's files are never yanked out from under it, while finished jobs' (possibly sensitive) .prompt/.result.json are still cleaned up. The now-empty jobs dir is removed only when nothing is left running. Returns the removed-finished job IDs and the kept-running job IDs (both sorted). A missing jobs dir is not an error — nothing has ever run — and returns both empty.

"running" uses the SAME signal as GC: a cached <id>.result.json is the authoritative terminal marker (a finished job is never "running" even if its pid was recycled); only when it's absent do we fall back to processAlive. An unreadable meta can't be polled, so it is treated as finished garbage and removed.

Types

type Request

type Request struct {
	Vendor       string        // required: vendors.toml table name
	Model        string        // empty → vendor.default_model
	Prompt       string        // task text (mutually exclusive with PromptReader)
	PromptReader io.Reader     // --prompt-file / stdin; non-nil feeds claude's stdin, keeps -p value out of argv
	OutputFormat string        // "text" | "json" — claude's inner output format
	JSON         bool          // cc-fleet's own machine-readable Result envelope (forces inner json)
	Timeout      time.Duration // hard wall-clock deadline; 0 → 300s
	Probe        bool          // pre-run 3s reachability check; default off (opposite of spawn)

	PermissionMode string  // empty → --dangerously-skip-permissions; else --permission-mode <v>
	Resume         string  // --resume <session_id> (multi-turn)
	Background     bool    // --background: launch detached, return a job handle
	MaxTurns       int     // --max-turns (claude graceful cap); 0 → omit
	MaxBudgetUSD   float64 // --max-budget-usd (claude graceful cap); 0 → omit
	LeadSessionID  string  // parent Claude session id for agent-status board grouping
}

Request is the input to Run. Zero values fall back to the documented defaults (OutputFormat "text", Timeout 300s, Probe off).

type Result

type Result struct {
	OK bool `json:"ok"`

	// Success path (distilled from claude's inner result envelope).
	Result        string  `json:"result,omitempty"` // inner .result (the answer text)
	Vendor        string  `json:"vendor,omitempty"`
	Model         string  `json:"model,omitempty"`           // inner modelUsage key (route evidence) else req.Model
	DurationMs    int64   `json:"duration_ms,omitempty"`     // inner .duration_ms (incl. retry wall-clock)
	APIDurationMs int64   `json:"duration_api_ms,omitempty"` // inner .duration_api_ms (pure API time)
	NumTurns      int     `json:"num_turns,omitempty"`
	StopReason    string  `json:"stop_reason,omitempty"`
	Usage         *Usage  `json:"usage,omitempty"`
	CostUSD       float64 `json:"total_cost_usd,omitempty"`
	SessionID     string  `json:"session_id,omitempty"` // for --resume
	LeadSessionID string  `json:"lead_session_id,omitempty"`
	PermDenials   int     `json:"permission_denials,omitempty"`

	// Async / background job fields. Present on --background launch and
	// subagent-status / subagent-gc results.
	JobID      string `json:"job_id,omitempty"`
	Status     string `json:"status,omitempty"` // running | done | failed
	OutputFile string `json:"output_file,omitempty"`
	PID        int    `json:"pid,omitempty"`
	StartedAt  string `json:"started_at,omitempty"`
	Removed    int    `json:"removed,omitempty"` // subagent-gc: job groups removed

	// Failure path. error_msg is a CANONICAL string only (never raw vendor body
	// text); the one raw-text exception is the SUBAGENT_FAILED stderr preview,
	// which is claude's own (key-safe) stderr.
	ErrorCode      string `json:"error_code,omitempty"`
	ErrorMsg       string `json:"error_msg,omitempty"`
	Suggestion     string `json:"suggestion,omitempty"`
	APIErrorStatus int    `json:"api_error_status,omitempty"` // inner .api_error_status (e.g. 429/401)

	// Raw passthrough, never serialized in cc-fleet's envelope: used by the
	// human/debug --output-format json path (Raw = claude's inner JSON) and the
	// bare text path (Raw = claude's text answer), each exiting ExitCode.
	Raw      []byte `json:"-"`
	ExitCode int    `json:"-"`
}

Result is cc-fleet's stable outer envelope (mirrors spawn.Result style). It is decoupled from claude's inner schema: Run parses the inner envelope, then distills these fields, so a claude version bump doesn't break the skill contract. The JSON tags are part of that contract — keep them stable.

func GC

func GC(olderThan time.Duration) Result

GC removes the file group of every finished job older than olderThan. A job is "finished" when its process is no longer alive (or its terminal result is cached). Running jobs are always kept regardless of age.

olderThan semantics: a NEGATIVE duration is treated as "unset" and falls back to defaultGCAge; ZERO means "no age limit — remove every finished job" (cutoff = now), which is how `subagent-gc --older-than 0s` clears the board's done entries. The CLI defaults --older-than to 24h, so an unset invocation passes 24h and never hits the zero case by accident.

func ListJobs

func ListJobs() ([]Result, error)

ListJobs scans the jobs dir and returns each background job's current Result via StatusFor, newest first (by StartedAt). A missing jobs dir yields an empty slice and no error (nothing has run yet). Like StatusFor it's read-only with respect to team/settings state; the only side effect is StatusFor caching a just-finished job's terminal <id>.result.json (benign, idempotent).

func Run

func Run(req Request) Result

Run executes the full subagent pipeline and returns a structured Result. Like Spawn it NEVER returns a Go error — every failure path produces a Result. It builds its own timeout context (self-contained, like Spawn).

func StatusFor

func StatusFor(jobID string) Result

StatusFor reports a background job's status. While the process is alive it returns status=running; once dead it classifies the captured stdout with the SAME classifier as the sync path, caches the terminal Result to <id>.result.json, and returns done/failed.

type Usage

type Usage struct {
	InputTokens          int `json:"input_tokens,omitempty"`
	OutputTokens         int `json:"output_tokens,omitempty"`
	CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
}

Usage mirrors the token-usage subset of claude's inner envelope we surface.

Jump to

Keyboard shortcuts

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