subagent

package
v0.1.9 Latest Latest
Warning

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

Go to latest
Published: Jun 11, 2026 License: Apache-2.0 Imports: 36 Imported by: 0

Documentation

Overview

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

Three invariants hold here:

  • Keys never leak: the provider key flows ONLY via the profile's apiKeyHelper (`<abs cc-fleet> keyget <provider>`), 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.WriteForProvider (already atomic + idempotent), so N concurrent subagents for one provider are embarrassingly parallel.
  • The headless child's env strips the lead's creds AND the nested-CC / teams markers (see childenv.Clean); fp.Env is deliberately NOT re-applied.

Index

Constants

View Source
const (
	ProfileFull   = "full"
	ProfileSlim   = "slim"
	ProfileSlimRO = "slim-ro"
)

Prompt profiles. "" and ProfileFull mean today's full `claude -p` session (byte-identical argv); the two slim profiles mirror native Claude Code agent classes — ProfileSlim the generic subagent (keeps CLAUDE.md + gitStatus), ProfileSlimRO the read-only Explore/Plan agent (drops both).

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)
	ErrCodeUnknownProvider    = "UNKNOWN_PROVIDER"        // provider not in providers.toml
	ErrCodeProviderDisabled   = "PROVIDER_DISABLED"       // enabled = false
	ErrCodeFingerprintMissing = "FINGERPRINT_MISSING"     // never captured → skill self-heal
	ErrCodeFingerprintStale   = "FINGERPRINT_STALE"       // BinaryPath gone from disk
	ErrCodeProxyUnavailable   = "CODEX_PROXY_UNAVAILABLE" // codex conversion daemon could not be started

	// Probe failure (only when --probe).
	ErrCodeProviderUnreachable = "PROVIDER_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
	ErrCodeProviderAPIError    = "PROVIDER_API_ERROR"       // other is_error / 5xx / overloaded
	ErrCodeCloudflareBlocked   = "CODEX_CLOUDFLARE_BLOCKED" // 403 + Cloudflare edge-block signature

	// cc-fleet layer.
	ErrCodeTimeout        = "SUBAGENT_TIMEOUT"          // --timeout deadline fired before claude returned
	ErrCodeFailed         = "SUBAGENT_FAILED"           // non-zero exit with no parseable envelope / internal error
	ErrCodeStopped        = "SUBAGENT_STOPPED"          // a still-running leaf finalized by `workflow stop` (a stop, not a failure)
	ErrCodeOutputTooLarge = "SUBAGENT_OUTPUT_TOO_LARGE" // child stdout/stderr exceeded the byte cap; group killed
)

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.

View Source
const SlimVersionFloor = "2.1.88"

SlimVersionFloor is the lowest claude version whose source carries every flag the slim profiles emit (--system-prompt-file / --tools / --thinking disabled / --strict-mcp-config). Below it the version gate runs `full` instead.

Variables

This section is empty.

Functions

func CanonicalizeTools added in v0.1.4

func CanonicalizeTools(names []string) ([]string, error)

CanonicalizeTools validates every name against the canonical floor-version built-in set and returns the set deduped + sorted, so caller order never changes the result (the argv join and the journal key both depend on this). Unknown, duplicate, or empty entries are rejected.

func ClearFinished added in v0.1.9

func ClearFinished(sessionID string, pins pinned.Set) (int, error)

ClearFinished removes one session's finished records: workflow runs (with their member jobs) and standalone subagent jobs whose Status is done/failed/stopped and whose session matches sessionID. It is the board "clear-finished" / `subagent-gc --session` primitive — status-driven and immediate (no age threshold), deliberately distinct from GC's age/membership housekeeping (a crashed run still labeled "running" is NOT swept here).

Pins are honored from the caller's snapshot: a pinned job, a pinned run, or a run with any pinned member is kept whole (the run and all its leaves), so a pinned leaf is never orphaned. Returns the number of run manifests + job groups removed (member jobs reaped with their run are not counted separately).

func DefaultSlimTools added in v0.1.4

func DefaultSlimTools(profile string, noSkills bool) []string

DefaultSlimTools returns a profile's default tool set: slim is read-write (Bash,Read,Edit,Write,Grep,Glob), slim-ro is read-only (Bash,Read,Grep,Glob); each adds Skill unless noSkills. Bash is present in slim-ro exactly as in native Explore — read-only is a prompt-level contract, not sandbox enforcement.

func DeleteJob added in v0.1.9

func DeleteJob(jobID string) error

DeleteJob removes a single standalone job's file group and clears any pin (the board `d` per-record delete; works on a pinned record — the sanctioned manual removal). The id is validated before it becomes a path component.

func DeleteSession added in v0.1.9

func DeleteSession(sessionID string, pins pinned.Set) (int, error)

DeleteSession removes EVERY record of one session — workflow runs (any status; a live run's engine is stopped first by PurgeRun) and standalone subagent jobs (a still-live one's process tree is reaped first) — EXCEPT pinned ones (a pinned run, a run with a pinned member, or a pinned job is kept; pins are removed only by an explicit per-record delete). Each run delete runs under the per-run lock so it can't race a concurrent restart/resume. A run that won't delete (its engine can't be stopped in time) is skipped and REPORTED: the error names how many were skipped, alongside the count actually removed.

func EngineAlive added in v0.1.4

func EngineAlive(run WorkflowRun) bool

EngineAlive reports whether run's DETACHED engine MIGHT still be running. It is a read-only LIVENESS check (it kills nothing), used by a watcher to stop waiting on a stale "running" manifest whose engine is gone. A foreground run (EnginePID 0) or a definitively-dead pid is "not alive". When the pid IS alive, the answer depends on whether we can read its argv: where we can (unix), require it to still be THIS run's engine so a RECYCLED pid (now an unrelated process) reads as gone — otherwise a SIGKILLed engine whose pid was reused would hold the watcher open forever; where we can't (a platform without process introspection, e.g. Windows), trust pidAlive alone, since a false "gone" for a live engine is worse than a rare missed recycled-pid detection. Unlike StopRun (which must fail-SAFE to never-kill an unverifiable pid), this fails-SOFT to keep-watching — neither can ever kill the wrong process.

func FinalizeQueuedLeafFailed added in v0.1.5

func FinalizeQueuedLeafFailed(jobID string, res Result)

FinalizeQueuedLeafFailed marks a leaf's (reused) job terminal-failed when the engine abandoned it without a success: a queued placeholder cancelled before its slot or whose worktree failed, a pre-flight provider failure (subagent.Run returned before registering), or a schema-invalid leaf whose exec cached "done". A res carrying a real error class is preserved (so the board keeps e.g. INSUFFICIENT_BALANCE); otherwise a canonical SUBAGENT_FAILED is written. No-op for an empty id.

func HoldLeaf added in v0.1.9

func HoldLeaf(jobID string)

HoldLeaf parks a leaf's job in the NONTERMINAL `held` status (kill-and-HOLD): meta Status="held" with PID/PGID cleared, so StatusFor's held branch — not the PID fallthrough — classifies it. The engine flips the meta BEFORE cancelling the attempt; finalizeSyncJob then SUPPRESSES the killed attempt's terminal cache, so no terminal cache ever exists during a hold and GC keeps treating the job as live. A job whose terminal cache already exists settled before the directive (success-beats-kill): the hold is a no-op, keeping the no-cache-under-hold invariant from the other direction. No-op for an empty id or an unreadable meta.

func LegacyRunScriptPath added in v0.1.9

func LegacyRunScriptPath(runID string) (string, error)

LegacyRunScriptPath returns the pre-JS-engine saved-script path runs/<id>.star. Only ever read: its existence (with no .js beside it) marks a run recorded by the retired Starlark engine, which restart/save refuse with an explicit engine-changed error instead of a JS parse failure.

func LoadFingerprint added in v0.1.4

func LoadFingerprint() (*fingerprint.Fingerprint, error)

LoadFingerprint loads the spawn recipe the same way Run does (probed cache or the bundled default). The workflow engine uses it to resolve the effective profile against the SAME recipe binary Run will exec, so its pre-keying version gate can't read a different executable.

func MintQueuedLeaf added in v0.1.5

func MintQueuedLeaf(req Request, model string) string

MintQueuedLeaf records a leaf the workflow engine has admitted but not yet given a pool slot: a PLACEHOLDER jobMeta with Status="queued" and PID=0 (no process yet), so the board shows it as a queued ◌ row immediately instead of only once it starts running. The engine passes the returned id back as Request.JobID, so the SAME on-disk job flips queued→running (registerSyncJob) →terminal (finalizeSyncJob) as one file. It writes NO prompt/answer (key-safety, same as registerSyncJob). Best-effort: "" on any error, so board bookkeeping never fails the run; an empty id makes the engine fall back to minting at run time.

func NormalizeHeldLeaf added in v0.1.9

func NormalizeHeldLeaf(jobID string)

NormalizeHeldLeaf clears a held pre-mark that lost its race: the directive landed after the attempt's finalize had already cached a terminal outcome, so the hold never took effect. Cache-first — with no terminal cache there is nothing truer to restore and the meta is left alone (the live engine still owns that leaf).

func NormalizeStaleHolds added in v0.1.9

func NormalizeStaleHolds(runID string)

NormalizeStaleHolds sweeps a run's member jobs at engine start: a meta still `held` from a PRIOR invocation (a kill-9'd engine ran no abort walk) is finalized terminal `stopped` when no result cache exists — holds are never persisted, and the resume re-runs that leaf from the journal — or, under a terminal cache (a hard kill between the cache write and the settle normalization), the meta alone is normalized to the cache's status. Cache-first: the sweep never terminalizes a leaf whose cache says it finished.

func PruneRuns added in v0.1.5

func PruneRuns() (int, error)

PruneRuns deletes every run whose engine is no longer alive — crashed/killed runs still stuck "running", plus terminal ones — while sparing any run with a live engine. Each delete runs under the per-run execution lock so it can't race a concurrent restart/resume, and the sweep is best-effort: one run that won't delete (an unverifiable-live engine PurgeRun refuses) doesn't abort the rest. Returns the number of runs removed.

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.

func PurgeRun added in v0.1.4

func PurgeRun(runID string) error

PurgeRun deletes a run entirely — the board's manual "delete" (the board never auto-clears, so runs accumulate). A VERIFIABLY-LIVE detached engine is stopped AND confirmed dead before any file is removed: the engine is recreate-safe (it rewrites a dropped manifest on its next save), so deleting under a live engine would not stick and could race its writes; if it can't be confirmed dead, the delete is aborted. A "running" manifest whose engine is GONE — crashed, or finished without finalizing — is exactly the accumulated junk this is for, so it is removed as-is. The id is validated before it becomes any path component.

When no engine pid is recorded but the run is genuinely still writing — a --foreground run, or a detached run in the moment between mint and its child stamping EnginePID — reads as not-alive, so its files are removed and the recreate-safe engine harmlessly rewrites the manifest (it reappears on the next refresh; atomic writes keep it consistent — no corruption). Blocking that would also block deleting freshly-minted and crashed runs, which is the whole point.

func ReapJob added in v0.1.4

func ReapJob(jobID string) error

ReapJob terminates a job's process tree and finalizes it as a timeout failure. The workflow runtime uses it to enforce a background leaf's timeout at wait() time (launchBackground itself is deadline-less so a detached job survives the launcher); DeleteSession uses it to stop a still-live standalone job whose files it removes right after (so the timeout-flavored finalize never surfaces there). Path-safe (validates the id) and best-effort: an unknown/gone job is a no-op.

func ReleaseHeldLeafStopped added in v0.1.9

func ReleaseHeldLeafStopped(jobID, msg string)

ReleaseHeldLeafStopped terminal-stops a HELD leaf (the run is aborting and nothing will ever wake it): the meta leaves `held` FIRST — otherwise the finalize's hold suppression would swallow the stopped cache this release exists to write. The transition and the finalize run under ONE jobMetaMu acquisition (the Locked body — the public wrapper would deadlock).

func RenderSlimPrompt added in v0.1.4

func RenderSlimPrompt(profile, workingDir, model string) (string, error)

RenderSlimPrompt renders the native-mirror system prompt for a slim profile: identity + agent prompt, the Notes block, the <env> block, and the model line. slim-ro adds a read-only research paragraph; slim (only) appends a gitStatus snapshot when workingDir is inside a git repo. profile must be ProfileSlim or ProfileSlimRO.

func RequeueLeaf added in v0.1.9

func RequeueLeaf(jobID string, attempt int)

RequeueLeaf flips a held leaf's job back to a queued placeholder for its next attempt (restart): Status="queued", PID=0, Attempt=attempt, with the terminal sidecars dropped (the same clean-slate rule as registerSyncJob's reuse path) so the board re-reads the row as queued ◌ immediately. registerSyncJob re-registers it when the attempt execs.

func ResolveEffectiveProfile added in v0.1.4

func ResolveEffectiveProfile(requested string, fp *fingerprint.Fingerprint) (effective string, downgrade string)

ResolveEffectiveProfile maps a REQUESTED profile to the one that will actually run, applying the version gate. full/"" pass through unchanged. A slim profile is kept only when the resolved claude version is at or above SlimVersionFloor; a below-floor / unknown version — or any error resolving the binary — fails OPEN to "full" with a human downgrade reason (never silent, never failing the leaf for an optimization).

fp is the fingerprint already loaded by the caller (Run loads it once; the workflow engine loads it the same way) — the version is resolved against THAT recipe's binary, never a re-loaded one, so the gate can't read a different executable than the one Run resolved. Version resolution is process-cached per resolved path in the fingerprint resolver.

func RunCtlPath added in v0.1.9

func RunCtlPath(runID string) (string, error)

RunCtlPath returns the control-plane path runs/<id>.ctl — the NDJSON command file a CLI/board writer appends leaf directives to and the live engine polls.

func RunEventsPath added in v0.1.4

func RunEventsPath(runID string) (string, error)

RunEventsPath returns the live-event channel path runs/<id>.events — the one-way engine→watcher stream `cc-fleet workflow watch` tails for a flowing live log.

func RunJournalPath added in v0.1.4

func RunJournalPath(runID string) (string, error)

RunJournalPath returns the content-hash journal path runs/<id>.journal. The workflow runtime owns the journal's I/O + format; this just centralizes the path so GC reaps it.

func RunLockPath added in v0.1.4

func RunLockPath(runID string) (string, error)

RunLockPath returns runs/<id>.lock — the per-run execution lock file. It is deliberately NOT in runSidecarExts: a flock locks an inode, so GC / removeRun must never unlink a possibly-held lock (unlink + recreate yields a new inode and silently breaks mutual exclusion). Leftover empty lock files are bounded by distinct-run-count and cleared by PurgeJobs' RemoveAll at an exclusive uninstall.

func RunScriptPath added in v0.1.4

func RunScriptPath(runID string) (string, error)

RunScriptPath returns the saved-script path runs/<id>.js — the run's source, persisted so a stopped run can be restarted (resumed).

func RunStatus added in v0.1.4

func RunStatus(runID string) (WorkflowRun, []Result, error)

RunStatus returns a run's manifest plus the Results of the jobs tagged with it. A missing manifest is an error (unknown run). The jobs are ListJobs() filtered by RunID, already newest-first.

func SaveRun added in v0.1.4

func SaveRun(run WorkflowRun) error

SaveRun writes a complete manifest, overwriting any prior file (atomic temp+rename). The workflow-run engine is the single authoritative writer of its manifest: it holds the run's identity + phase plan + status in memory and overwrites the whole file on every phase()/finalize, so there is NO read-modify-write to race, and a manifest a concurrent GC happened to drop is simply recreated on the next write (the run stays inspectable via `workflow status`).

func SaveWorkflow added in v0.1.4

func SaveWorkflow(runID, name, sessionID, description string) error

SaveWorkflow copies a run's saved script (runs/<runID>.js) to a NAMED, reusable workflow (workflows/<name>.js) plus its metadata, overwriting an existing same-name save (re-save). It errors if the run has no saved script — explicitly so for a pre-JS-engine (.star) run, whose script the current runtime can't execute. sessionID + description are recorded for discovery.

func SavedWorkflowScript added in v0.1.4

func SavedWorkflowScript(name string) (string, error)

SavedWorkflowScript returns a saved workflow's .js path, erroring if the name is invalid or absent — explicitly so for a workflow saved by the pre-JS (Starlark) engine, whose script the current runtime can't execute. Used by `workflow run --saved <name>`.

func SetDetachGroup added in v0.1.4

func SetDetachGroup(cmd *exec.Cmd)

SetDetachGroup puts cmd in its own process group (Setpgid on unix, CREATE_NEW_PROCESS_GROUP on Windows) — the SAME platform primitive the background subagent leaf uses, exported so the workflow runtime can re-exec itself as a detached child that outlives the launching CLI without a second, divergent platform split. The caller still does Start + Process.Release.

func ToolArgPreview added in v0.1.6

func ToolArgPreview(input json.RawMessage) string

ToolArgPreview renders a tool_use input as a single safe line: the primary argument value (preferring a known primary key, else the first key in sorted order), key-masked and length-capped. Model-generated content — never the key (apiKeyHelper) — but masked + truncated defense-in-depth, then CleanTitle-scrubbed by the board at render. Exported because the board's teammate card projects transcript tool_use blocks into the same signature format the activity sidecar uses.

func ValidSavedName added in v0.1.4

func ValidSavedName(name string) bool

ValidSavedName reports whether name is a path-safe saved-workflow name. A leading dot, "." or ".." is rejected so a name can never escape the workflows dir or shadow a dotfile.

func ValidateProfile added in v0.1.4

func ValidateProfile(p string) error

ValidateProfile accepts "" and "full" (both full), "slim", "slim-ro"; anything else is an error.

func ValidateRunID added in v0.1.4

func ValidateRunID(id string) error

ValidateRunID reports whether id is a path-safe run-manifest component (the same check ReadRun/SaveRun apply). Exported so the workflow runtime can fail-fast on a bad `--run-id` before executing a script.

func ValidateToolsSkills added in v0.1.4

func ValidateToolsSkills(tools []string, noSkills bool) error

ValidateToolsSkills rejects the contradictory combination of an explicit tool set that names "Skill" together with skills disabled (NoSkills): the caller both requests and forbids the Skill tool. Shared so the bare-CLI, Run, and workflow paths reject it identically. nil when the inputs are consistent.

func WaitEngineStopped added in v0.1.4

func WaitEngineStopped(runID string, timeout time.Duration) bool

WaitEngineStopped polls a run's engine liveness until it is gone or the deadline passes (true once EngineAlive is false / the manifest is unreadable, false on timeout). EngineAlive fails SOFT (an unreadable argv reads as alive), so a stuck poll times out into the caller's fail-closed path rather than touching files under a still-live engine. Shared by PurgeRun (delete) and workflow.Restart.

func WithRunLock added in v0.1.4

func WithRunLock(runID string, fn func() error) error

WithRunLock serializes a run's lifecycle decisions (restart / resume / stop / delete) across processes via a blocking exclusive flock on runs/<id>.lock — the workflow runtime's per-run execution lock. It is a STANDALONE flock scope, independent of the three config scopes (the run-lifecycle paths take none of providers/team/server). It must wrap an entry point's whole decision and NEVER the engine's Execute: the detached engine runs lock-free, so there is no re-entrancy or deadlock when Restart composes stop + launch under one lock.

Types

type Request

type Request struct {
	Provider     string        // required: providers.toml table name
	Model        string        // empty → provider.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
	WorkingDir     string  // child's cwd (empty = inherit); used for git-worktree isolation
	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 Agents Board grouping

	// Workflow run grouping (all optional). A workflow orchestrator tags each
	// subagent with the run it belongs to, the phase within that run, and a human
	// label, so the board can group N jobs into one run tree. Distinct from
	// LeadSessionID (a Claude session can host many ad-hoc subagents and runs).
	RunID string // run this job belongs to
	Phase string // phase label within the run
	Label string // human label for this agent within the run
	// JobID, when set, REUSES an existing job record instead of minting a fresh one — the
	// workflow engine mints a queued placeholder (PID=0) before a leaf gets a pool slot and
	// passes its id here so the same on-disk job flips queued→running→terminal as ONE file.
	// Empty (the bare-CLI path) mints a fresh id, byte-identical to before.
	JobID string
	// Attempt is the leaf's 1-based exec ordinal; the workflow engine sets 1 (a schema
	// mismatch is terminal, so a leaf never re-runs). The board shows "attempt N" only
	// when >1 — reachable now only via caches from engines that retried schema
	// mismatches. Zero (a non-workflow call) renders no marker.
	Attempt int

	// PersistIO opts this job into board drill-in: persist the prompt + answer to
	// per-job 0600 side files (<id>.prompt / <id>.answer) for the boards' detail cards.
	// It is CONTENT-PRIVACY, not key-safety — the provider key never enters the prompt or
	// answer (it flows via apiKeyHelper). The sync finalizer's result cache stays
	// answer-stripped; a background job's cache keeps the answer (subagent-status serves
	// it from there), and board ROWS never show a reply by the render-side rule that
	// Result.Result is never drawn. Both the workflow engine and the subagent CLI set
	// this default-on with a --no-persist-io opt-out.
	PersistIO bool
	// IOPrompt is the prompt text to persist when PersistIO. A prompt fed via
	// PromptReader/stdin is consumed by the child, so callers holding the text (the
	// workflow engine; the CLI's --prompt) pass it here separately for the side file.
	// Empty → no prompt side file.
	IOPrompt string
	// StreamActivity opts a SYNC run into `--output-format stream-json --verbose` so its
	// per-job tool calls + running token usage stream to <jobID>.activity for the boards'
	// Activity feed WHILE it runs. Content-privacy, gated like PersistIO; set only where
	// the caller consumes a distilled envelope rather than passing claude's output through
	// (workflow sync leaves; the CLI's --json path), so every passthrough run — plain text
	// AND --output-format json — stays byte-identical.
	StreamActivity bool
	// JournalKey is the leaf's content-hash key (workflow-engine-only). Persisted on the job
	// record so the board can target THIS leaf for restart — drop its journal entry and resume,
	// which re-runs only it (+ its dependents). It is a sha256 hex, never a secret.
	JournalKey string

	// PromptProfile selects the prompt shape: "" / "full" (today's full claude -p
	// session, byte-identical argv) | "slim" (native generic-subagent mirror) |
	// "slim-ro" (native Explore/Plan read-only mirror). The zero value is full.
	PromptProfile string
	// Tools replaces a slim profile's default tool set (validated + canonicalized).
	// Empty → the profile's default set. Ignored for full.
	Tools []string
	// NoSkills drops the Skill tool + host skill listing from a slim profile. The
	// zero value (false) is the documented default: skills ON (native parity).
	NoSkills bool
	// MCP, when true, inherits the host MCP config (native parity) for a slim
	// profile; false means --strict-mcp-config. The user-facing boundaries (CLI /
	// workflow agent()) resolve the per-profile default — slim inherits, slim-ro
	// strict — and pass the FINAL value here; the zero value stays strict for
	// direct constructors.
	MCP bool
	// JSONSchema, when non-empty, is the leaf's JSON Schema (canonical JSON text).
	// buildArgv passes it via --json-schema, making claude inject a forced
	// StructuredOutput tool — profile-independent (the injected tool survives a
	// slim --tools whitelist). The flag needs claude >= 2.1.88 (the slim floor);
	// an older claude fails the leaf with the ordinary classified usage error.
	// Workflow-engine-only: the bare CLI exposes no schema flag.
	JSONSchema string

	// Diag is the --verbose step-trace sink. nil (the default) is a no-op;
	// a logger changes nothing but the diagnostic writes.
	Diag *diag.Logger
}

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)
	Provider      string  `json:"provider,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"`

	// Workflow run grouping (optional; mirrors the Request fields). Carried on the
	// job files so the board can group jobs into a run → phase → agent tree.
	RunID string `json:"run_id,omitempty"`
	Phase string `json:"phase,omitempty"`
	Label string `json:"label,omitempty"`
	// JournalKey is the leaf's content-hash key, persisted so the board can restart THIS leaf
	// (invalidate its journal entry + resume). A sha256 hex — never a secret.
	JournalKey string `json:"journal_key,omitempty"`
	// Attempt is the 1-based exec ordinal the leaf ran at (>1 occurs only in caches from
	// engines that retried schema mismatches); 0 backfills a cache that predates the field.
	Attempt int `json:"attempt,omitempty"`

	// PromptProfile is the EFFECTIVE profile this run used (post-version-gate);
	// SlimDowngrade is non-empty when a slim request ran full instead (the reason).
	PromptProfile string `json:"prompt_profile,omitempty"`
	SlimDowngrade string `json:"slim_downgrade,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"` // queued | running | held | done | failed | stopped
	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 provider 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:"-"`
	// StructuredOutput is the inner envelope's structured_output payload (present
	// when the run carried --json-schema). In-process only, like Raw: the workflow
	// engine consumes it in agent() and persists it only via the run journal — it
	// never enters the CLI envelope, the sanitized result cache, or jobMeta.
	StructuredOutput json.RawMessage `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(parent context.Context, 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. Its hard deadline derives from parent (the workflow engine's per-leaf cancel handle; the CLI lane passes context.Background()): a cancelled parent kills the exec promptly and classifies as a stop, not a failure. nil falls back to Background.

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 RunPhase added in v0.1.4

type RunPhase struct {
	Title  string `json:"title"`
	Detail string `json:"detail,omitempty"`
}

RunPhase is one planned step in a run. Title is the short name a worker passes as --phase; Detail is optional free text describing the step.

type SavedWorkflow added in v0.1.4

type SavedWorkflow struct {
	Name        string `json:"name"`
	RunID       string `json:"run_id,omitempty"`
	SessionID   string `json:"session_id,omitempty"`
	Description string `json:"description,omitempty"`
	SavedAt     string `json:"saved_at"`
}

SavedWorkflow is one saved workflow's metadata (workflows/<name>.json). SessionID records the session it was saved from so discovery can surface the current session's saves first.

func ListSavedWorkflows added in v0.1.4

func ListSavedWorkflows() ([]SavedWorkflow, error)

ListSavedWorkflows reads the saved-workflows dir, newest-first by SavedAt. A missing dir → (nil, nil); unparseable metadata is skipped.

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"`
	CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // tokens billed to WRITE the prompt cache
}

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

type WorkflowRun added in v0.1.4

type WorkflowRun struct {
	RunID       string `json:"run_id"`
	Name        string `json:"name,omitempty"`
	Description string `json:"description,omitempty"`
	WhenToUse   string `json:"when_to_use,omitempty"` // meta.whenToUse — display/board text
	StartedAt   string `json:"started_at"`
	// UpdatedAt is a liveness heartbeat: the engine restamps it (RFC3339 UTC) on every
	// manifest write, and a resume restamps it at launch. Run-aware GC treats a run as
	// recent (and so protects its manifest + journal) by the LATER of StartedAt/UpdatedAt
	// — so a resumed run, whose StartedAt is its original (old) timestamp, is not pruned
	// out from under itself in the window before its first leaf registers a member.
	UpdatedAt string     `json:"updated_at,omitempty"`
	Phases    []RunPhase `json:"phases,omitempty"`
	Status    string     `json:"status,omitempty"`
	// EnginePID is the OS pid of the process running the engine (the detached child for a
	// normal run). `workflow stop` reaps its whole process tree — which includes the
	// engine's in-flight provider-leaf children — after a cmdline reuse-guard check, so a
	// recycled pid can never make stop kill an unrelated process.
	EnginePID int `json:"engine_pid,omitempty"`
	// Error is the failure cause, set when Status is "failed" — so a DETACHED run
	// (whose stderr went to /dev/null) still records WHY it failed for `workflow
	// status`. It is a canonical/script-level message (agent() failures carry
	// subagent's canonical error_msg, never raw provider body), so it is key-safe.
	Error string `json:"error,omitempty"`
	// SessionID is the parent Claude session this run was launched from (leadsession.Detect
	// at `workflow run`, or a --lead-session-id override) — so the board groups runs by
	// session like the teammates board. Empty when launched outside a Claude session.
	SessionID string `json:"session_id,omitempty"`
	// Cwd is the directory `workflow run` was invoked from (the project dir); the board shows it on
	// the run header. Captured at mint in the foreground launcher and preserved across resume.
	Cwd string `json:"cwd,omitempty"`
	// Launch options replayed on restart (resume re-execs with the SAME inputs, else a leaf's
	// key — and thus its cache validity — would shift). Persisted at mint; the engine carries
	// them so every manifest overwrite preserves them. ArgsJSON is the script's `args` input.
	ArgsJSON    string  `json:"args_json,omitempty"`
	NoPersistIO bool    `json:"no_persist_io,omitempty"`
	BudgetUSD   float64 `json:"budget_usd,omitempty"`
	// BudgetTokens is the run-level token cap (Usage.InputTokens+OutputTokens summed across
	// leaves, cache-read excluded); 0 = uncapped. Replayed on resume like BudgetUSD.
	BudgetTokens int64 `json:"budget_tokens,omitempty"`
	// SpentUSD / SpentTokens are the run's LIVE cumulative spend (list-price USD estimate +
	// input+output tokens), restamped by the engine as each leaf charges — so `workflow status`
	// shows a run-level running total without summing per-leaf jobs (which omit in-flight leaves).
	// A resume counts only newly-run leaves; journaled replays are free. Not a leaf determinant.
	SpentUSD    float64 `json:"spent_usd,omitempty"`
	SpentTokens int64   `json:"spent_tokens,omitempty"`
	// DefaultProvider / DefaultProviderError record the run's default-provider
	// resolution at mint, so a provider-less agent() resolves to the SAME provider on
	// resume regardless of a live config change (a mid-run default change must never
	// re-key an omitted-provider leaf). Exactly one is set when the run uses any
	// default: the resolved provider name, or the error_code (NO_DEFAULT_PROVIDER /
	// DEFAULT_PROVIDER_DISABLED) a provider-less agent() then throws. Both empty when
	// no default was resolvable AND nothing needed one (an all-explicit script). Not
	// a leaf determinant beyond the provider it supplies.
	DefaultProvider      string `json:"default_provider,omitempty"`
	DefaultProviderError string `json:"default_provider_error,omitempty"`
}

WorkflowRun is the on-disk manifest for a workflow run, stored at ConfigDir/subagent-jobs/runs/<run_id>.json. It records the run's identity and its intended phase sequence; the actual subagent jobs are separate files tagged with this RunID, joined back in RunStatus.

func ListRuns added in v0.1.4

func ListRuns() ([]WorkflowRun, error)

ListRuns returns every run manifest, newest-first by StartedAt (RFC3339 is lexically sortable, so a string descending sort works). A missing runs dir means nothing has run yet → (nil, nil). Unparseable manifests are skipped.

func NewRun added in v0.1.4

func NewRun(name string, phases []RunPhase) (WorkflowRun, error)

NewRun mints a run manifest and persists it. RunID is a fresh uuid; StartedAt is RFC3339 UTC (lexically sortable for newest-first listing); Status starts "running".

func NewRunWithMeta added in v0.1.4

func NewRunWithMeta(name, description, whenToUse string, phases []RunPhase) (WorkflowRun, error)

NewRunWithMeta is NewRun plus a description + whenToUse — the workflow runtime mints from a script's `meta` literal (name + description + whenToUse + declared phases), so a detached run's `--json`/board read carries them before the engine child starts.

func ReadRun added in v0.1.4

func ReadRun(runID string) (WorkflowRun, error)

ReadRun loads a manifest by id. runID is validated first because it becomes a filesystem path component (guards against a "../" escape via the CLI/status path).

func StopRun added in v0.1.4

func StopRun(runID string) (WorkflowRun, error)

StopRun reaps an actively-running workflow run and marks its manifest stopped. When a reapable DETACHED engine is found it kills the engine's whole process TREE by ANCESTRY — the engine plus its in-flight provider-leaf `claude` children and their grandchildren (each leaf is its OWN process group, so an ancestry walk, not a single group signal, is required on unix; reapEngineTree handles the platform split). A cmdline reuse guard means a recycled EnginePID can NEVER make this kill an unrelated process: the pid is reaped only when its argv still proves it is this run's detached `workflow run … --run-id <id>` engine. An already-terminal run is returned untouched (no clobbering a real done/failed). Every other case — a foreground run (EnginePID deliberately 0), an engine already gone (crashed), or a recycled pid whose argv no longer matches — is reaped of nothing and simply flipped to stopped, clearing a stale "running"; the reuse guard ensures such an unverifiable pid is never killed.

Jump to

Keyboard shortcuts

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