Documentation
¶
Overview ¶
Package headless provides a subprocess-based interface for running claude -p headlessly. It manages session pools for prefix-cache reuse, streaming output, and clean subprocess lifecycle management.
Index ¶
- Constants
- Variables
- func AllowedFeatureKeyList() string
- func DraftPRDescription(ctx context.Context, pool *Pool, ...) (string, error)
- func GenerateAcceptanceCriteria(ctx context.Context, pool *Pool, title, description string) ([]string, error)
- func GenerateSessionCompletionNarrative(ctx context.Context, pool PoolClient, ...) (string, error)
- func HeadlessReviewSystemPrompt() string
- func HeadlessReviewSystemPromptWithCodebaseAccess() string
- func HeadlessTriageSystemPrompt() string
- func ReviewSystemPrompt() string
- func SetDefaultPool(p *Pool)
- func SuggestCommitMessage(ctx context.Context, pool *Pool, diff string) (string, error)
- func SummarizeBacklogItem(ctx context.Context, pool *Pool, title, description string) (string, error)
- type CallOptions
- type ClaudeRunner
- type CodebaseReadCapabilitySelfCheck
- type FakeRunner
- func (f *FakeRunner) ArgsContainSequence(n int, seq ...string) bool
- func (f *FakeRunner) ArgsForCall(n int) []string
- func (f *FakeRunner) CallCount() int
- func (f *FakeRunner) HasArg(arg string) bool
- func (f *FakeRunner) Run(_ context.Context, args []string, stdin io.Reader) (io.ReadCloser, func() error, error)
- func (f *FakeRunner) SetErrors(errs ...error)
- func (f *FakeRunner) StdinForCall(n int) string
- type FeatureKey
- type Pool
- func (p *Pool) Call(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string) (<-chan StreamChunk, error)
- func (p *Pool) CallBlocking(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string, ...) (string, float64, error)
- func (p *Pool) CallWithOptions(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string, ...) (<-chan StreamChunk, error)
- type PoolClient
- type PoolConfig
- type ProcessRunner
- type StreamChunk
Constants ¶
const CodebaseReadAllowedTools = "Read,Grep,Glob"
CodebaseReadAllowedTools is the AllowedTools value granted to the empty-diff codebase-read headless call (BuildReviewCallOptions) and to the capability self-check probe (CodebaseReadCapabilitySelfCheck.run), which exercises the same call shape. Shared as a constant so the two call sites cannot silently drift.
This is deliberately Read/Grep/Glob only — no Bash grant of any kind. A scoped Bash allowlist (git log/show/diff/blame, go test/vet/build, sg) was added here in a later revision, then reverted — see ADR-001's 2026-07-15 addendum. The empirical integration test TestPool_RealClaude_UnlistedBashCommand_BlockedOrAllowed (session/headless/integration_test.go) proved that under --permission-mode bypassPermissions, --allowedTools/--disallowedTools do NOT provide real technical enforcement for Bash: an explicitly unlisted command executed freely and wrote a real file to disk, and command-chaining after an allowed prefix also succeeded in full. This Read/Grep/Glob-only grant is the one whose safety was actually verified empirically, via TestPool_RealClaude_WorkDirOnly_GrantsReadAccess and TestPool_RealClaude_WorkDirWithToolFlags_GrantsReadAccess — do not re-add Bash here without re-reading the ADR addendum and re-running that integration test.
const CodebaseReadCallTimeout = 600 * time.Second
CodebaseReadCallTimeout is the context timeout used for the empty-diff codebase-read headless call. Originally 150s (deliberately shorter than DefaultCallTimeout's 900s so a hung or degraded tool-access call fails fast into the UNVERIFIABLE degrade path). Raised to 600s (10 minutes) and kept there even after the Bash tool grant below was reverted (see CodebaseReadAllowedTools) — the relaxed budget was motivated by the richer context payload (prior review attempts, full notes history, item context, a searchable session transcript file via Grep), not by Bash tool use, so genuine Read/Grep/Glob exploration of that larger context still legitimately takes longer than a bounded lookup and 150s was starting to force premature UNVERIFIABLE degrades on reviews that were making real progress. 600s remains well short of the shared 900s DefaultCallTimeout, so a genuinely hung codebase-read call still fails into the degrade path before hitting the full 15-minute ceiling other headless call types tolerate.
const DefaultCallTimeout = 900 * time.Second
DefaultCallTimeout is the default headless call timeout applied when timeout_seconds is 0.
const MaxCallTimeout = 1800 * time.Second
MaxCallTimeout caps timeout_seconds.
const MaxDiffSizeReview = 40_000
MaxDiffSizeReview is the maximum number of bytes included in a review prompt diff.
Variables ¶
var ( // ErrClaudeNotFound is returned when the claude binary is not in PATH. ErrClaudeNotFound = errors.New("claude binary not found in PATH") // ErrSubprocessStart is wrapped around the error returned when ClaudeRunner.Run // itself fails to start the subprocess — os.Pipe() failing under fd exhaustion, // or cmd.Start() failing under ENOMEM/ENOENT/EACCES — as opposed to the // subprocess starting and later exiting non-zero. This happens before any // StreamChunk is ever produced, so CallBlocking's raw return is always "" for // this failure mode (there is nothing to capture). classifyHeadlessCallError // (server/services/backlog_service_triage.go) matches this sentinel so the // failure is bucketed as "subprocess_start_error" in logs instead of falling // through to an undiagnosable "other". ErrSubprocessStart = errors.New("headless subprocess failed to start") )
Error sentinels returned in StreamChunk.Err or from CallBlocking.
var AllowedFeatureKeys = map[FeatureKey]bool{ FeatureKeyReview: true, FeatureKeySummarize: true, FeatureKeyAC: true, FeatureKeyPRDescription: true, FeatureKeyCommitMessage: true, FeatureKeyCustom: true, }
AllowedFeatureKeys is the set of feature keys accepted by the MCP-exposed RunHeadlessCall path (server/services/headless_service.go). FeatureKeyTriage is intentionally excluded — triage calls go through BacklogService.TriggerTriage → Pool.CallBlocking directly, bypassing the MCP gate. This prevents triage from being triggered via the public headless API.
var DefaultCapabilitySelfCheck = &CodebaseReadCapabilitySelfCheck{}
DefaultCapabilitySelfCheck is the package-level singleton shared by production callers (ReviewGateRunner and TriggerReReview) so a failure discovered via one call site short-circuits the other too. Callers that need test isolation should hold their own *CodebaseReadCapabilitySelfCheck field defaulting to this value instead of calling through the package var directly.
Functions ¶
func AllowedFeatureKeyList ¶
func AllowedFeatureKeyList() string
AllowedFeatureKeyList returns a sorted comma-separated list of allowed feature keys for use in error messages. Generated from AllowedFeatureKeys to stay in sync.
func DraftPRDescription ¶
func DraftPRDescription(ctx context.Context, pool *Pool, itemTitle, itemDescription, diff, branchName string) (string, error)
DraftPRDescription calls the LLM to draft a pull request description tied to the backlog item the diff closes. itemTitle/itemDescription supply the "why" this diff exists — a diff alone never expresses intent, and without this context the model has nothing to tie the Summary section back to (root cause of PR #175 on this repo, which described the diff but couldn't explain why it existed and asked the caller to clarify instead). Diffs longer than maxDiffSizePR bytes are truncated before sending.
Returns an error without calling the LLM if diff is empty/whitespace-only — there is nothing to describe, and sending an empty diff previously produced a conversational non-answer (PR #174: "Empty diff — nothing to describe. Do you want me to check the branch/PR directly...") instead of a usable body. Callers should fall back to a boilerplate body on this error, same as any other.
func GenerateAcceptanceCriteria ¶
func GenerateAcceptanceCriteria(ctx context.Context, pool *Pool, title, description string) ([]string, error)
GenerateAcceptanceCriteria calls the LLM to generate acceptance criteria. Returns a slice of criterion strings.
func GenerateSessionCompletionNarrative ¶ added in v1.41.0
func GenerateSessionCompletionNarrative(ctx context.Context, pool PoolClient, sessionTitle, sessionGoal, diff, decisionsSummary string) (string, error)
GenerateSessionCompletionNarrative calls the LLM to produce a "what was done" narrative for a completed session. sessionTitle/sessionGoal are grounding inputs beyond diff+decisions alone (pre-mortem finding #1 — see project_plans/session-completion-summary/implementation/plan.md's Pattern Decisions "Narrative input scope" row): they give the model real signal for low-diff/high-effort sessions (investigation/exploration work with little or no diff), where diff+decisions alone would otherwise be nearly empty. sessionGoal == "" (never set) simply omits the goal line from the prompt — it is not rendered as an empty/placeholder line, and the call still succeeds. diff is sanitized (sanitizeDiffForNarrative) and truncated to MaxDiffSizeReview bytes before being sent, mirroring the truncation convention already used by session/backlog_review.go's review-prompt diffs.
func HeadlessReviewSystemPrompt ¶
func HeadlessReviewSystemPrompt() string
HeadlessReviewSystemPrompt returns the system prompt for headless (no-tool) review calls. Requests JSON output so the caller can parse the verdict without tool execution.
func HeadlessReviewSystemPromptWithCodebaseAccess ¶ added in v1.38.0
func HeadlessReviewSystemPromptWithCodebaseAccess() string
HeadlessReviewSystemPromptWithCodebaseAccess returns the system prompt used for empty-diff headless review calls granted codebase read access.
func HeadlessTriageSystemPrompt ¶
func HeadlessTriageSystemPrompt() string
HeadlessTriageSystemPrompt returns the stable system prompt for headless triage calls. Requests JSON output so the caller can parse the result without MCP tool execution.
func ReviewSystemPrompt ¶
func ReviewSystemPrompt() string
ReviewSystemPrompt returns the stable system prompt for review gate calls. Exported so session/backlog_lifecycle.go can use it without embedding the prompt inline.
func SetDefaultPool ¶
func SetDefaultPool(p *Pool)
SetDefaultPool sets the package-level default pool. Safe to call concurrently.
func SuggestCommitMessage ¶
SuggestCommitMessage calls the LLM to generate a Conventional Commit message. Diffs longer than maxDiffSizeCommit bytes are truncated before sending.
Types ¶
type CallOptions ¶
type CallOptions struct {
// WorkDir sets the subprocess working directory (for git operations). Callers
// MUST validate this is an absolute, existing directory before passing it here —
// os/exec.Cmd.Dir has a well-documented quirk where a non-existent Dir makes the
// resulting fork/exec error name the EXECUTABLE path, not the directory (e.g.
// "fork/exec /home/user/.local/bin/claude: no such file or directory"), which
// looks exactly like the binary is missing even though the real problem is a bad
// working directory. See BUG-062 (server/services/backlog_service_triage.go's
// TriggerTriage) for a live incident this caused and the validation added there.
WorkDir string
// Model overrides the pool's DefaultModel for this call only.
Model string
// TimeoutSecs is unused by Pool directly — callers wrap ctx with WithTimeout.
TimeoutSecs int
// AllowedTools scopes a WorkDir-bearing call to a specific comma-separated
// tool list (e.g. "Read,Grep,Glob"), mirroring session.InstanceOptions.AllowedTools.
// Only applied when WorkDir is also set; ignored otherwise.
AllowedTools string
// PermissionMode scopes a WorkDir-bearing call to a specific --permission-mode
// value, mirroring session.InstanceOptions.PermissionMode. Only applied when
// WorkDir is also set; ignored otherwise.
PermissionMode string
// DisallowedTools scopes a WorkDir-bearing call to an explicit denylist
// (comma-separated, e.g. "Bash(rm:*),Write,Edit"), passed through to the
// claude CLI's --disallowedTools flag. Mirrors AllowedTools/PermissionMode:
// only applied when WorkDir is also set; ignored otherwise. Used alongside
// AllowedTools as belt-and-suspenders — an explicit denylist of destructive
// Bash prefixes and write-capable tools on top of a scoped allowlist.
DisallowedTools string
}
CallOptions configures an individual pool call with overrides.
type ClaudeRunner ¶
type ClaudeRunner interface {
// Run starts claude -p with the given args. stdin provides the user prompt so
// it does not appear in /proc/<pid>/cmdline. Returns a ReadCloser for stdout,
// a stop function to kill the process, and an error if the process fails to start.
// The caller must call stop() to release resources even when the ReadCloser is drained.
Run(ctx context.Context, args []string, stdin io.Reader) (stdout io.ReadCloser, stop func() error, err error)
}
ClaudeRunner abstracts how claude -p subprocesses are started. Implementors: ProcessRunner (real), FakeRunner (tests).
type CodebaseReadCapabilitySelfCheck ¶ added in v1.38.0
type CodebaseReadCapabilitySelfCheck struct {
// contains filtered or unexported fields
}
CodebaseReadCapabilitySelfCheck lazily verifies, once per process lifetime, that a WorkDir+AllowedTools+PermissionMode headless call actually grants read access — the same empirical fact TestPool_RealClaude_WorkDirWithToolFlags_GrantsReadAccess checks in CI, re-verified here against the actual running process's claude CLI/config.
A zero-value CodebaseReadCapabilitySelfCheck is ready to use. Each instance runs its underlying smoke test at most once (guarded by sync.Once); construct a fresh instance (rather than reusing DefaultCapabilitySelfCheck) when a test needs to exercise the check logic more than once within a process.
func NewFailedCapabilitySelfCheckForTesting ¶ added in v1.38.0
func NewFailedCapabilitySelfCheckForTesting() *CodebaseReadCapabilitySelfCheck
NewFailedCapabilitySelfCheckForTesting returns a CodebaseReadCapabilitySelfCheck pre-marked as failed, so Ensure returns false immediately without invoking pool.CallBlocking. For tests exercising the capability-self-check-failure degrade path without needing to script a failing fake claude subprocess.
func NewPassedCapabilitySelfCheckForTesting ¶ added in v1.38.0
func NewPassedCapabilitySelfCheckForTesting() *CodebaseReadCapabilitySelfCheck
NewPassedCapabilitySelfCheckForTesting returns a CodebaseReadCapabilitySelfCheck pre-marked as passed, so Ensure returns true immediately without invoking pool.CallBlocking at all. For tests exercising the codebase-read call itself (ReviewGateRunner / TriggerReReview) that would otherwise have their mocked pool's canned response consumed by (and very likely fail) the capability smoke test — since a scripted verdict response generally won't happen to contain the self-check's marker string.
func (*CodebaseReadCapabilitySelfCheck) Checked ¶ added in v1.38.0
func (c *CodebaseReadCapabilitySelfCheck) Checked() bool
Checked reports whether the self-check has run (successfully or not) yet.
func (*CodebaseReadCapabilitySelfCheck) Ensure ¶ added in v1.38.0
func (c *CodebaseReadCapabilitySelfCheck) Ensure(ctx context.Context, pool PoolClient) bool
Ensure runs the once-guarded marker-file smoke test on first call (blocking concurrent callers until it resolves) and returns the cached result on every subsequent call. pool is accepted as the narrow PoolClient interface so both *Pool (ReviewGateRunner) and interface-typed fields (BacklogService.headlessPool) can call it without an adapter.
type FakeRunner ¶
type FakeRunner struct {
// Calls records every set of args passed to Run, in order.
Calls [][]string
// Stdins records the full stdin content passed to Run, in order. The user
// prompt is passed via stdin (not args) so it doesn't appear in /proc/<pid>/cmdline
// — see Pool.call in caller.go — so tests asserting on prompt content must
// inspect this rather than Calls/ArgsForCall.
Stdins [][]byte
// contains filtered or unexported fields
}
FakeRunner is a test double for ClaudeRunner. It returns scripted responses and records call arguments for inspection.
When the args contain "--output-format" followed by "json", the response must be valid JSON matching firstCallJSONResult schema:
{"session_id":"...","result":"...","cost_usd":0.0}
Otherwise the response is returned as plain text, line by line.
func NewFakeRunner ¶
func NewFakeRunner(responses ...string) *FakeRunner
NewFakeRunner creates a FakeRunner that returns responses in order. If responses is empty the runner returns an empty string for each call.
func (*FakeRunner) ArgsContainSequence ¶
func (f *FakeRunner) ArgsContainSequence(n int, seq ...string) bool
ArgsContainSequence returns true if the nth call's args contain the given sequence.
func (*FakeRunner) ArgsForCall ¶
func (f *FakeRunner) ArgsForCall(n int) []string
ArgsForCall returns the args recorded for the nth call (0-indexed). Returns nil if call n has not happened yet.
func (*FakeRunner) CallCount ¶
func (f *FakeRunner) CallCount() int
CallCount returns how many times Run has been called.
func (*FakeRunner) HasArg ¶
func (f *FakeRunner) HasArg(arg string) bool
HasArg returns true if any recorded call contains arg.
func (*FakeRunner) Run ¶
func (f *FakeRunner) Run(_ context.Context, args []string, stdin io.Reader) (io.ReadCloser, func() error, error)
Run returns the next scripted response (or error). It records args in Calls and the full stdin content in Stdins. The stop function is a no-op.
func (*FakeRunner) SetErrors ¶
func (f *FakeRunner) SetErrors(errs ...error)
SetErrors configures per-call errors. A nil entry means no error for that call.
func (*FakeRunner) StdinForCall ¶ added in v1.37.0
func (f *FakeRunner) StdinForCall(n int) string
StdinForCall returns the stdin content (the user prompt) recorded for the nth call (0-indexed). Returns "" if call n has not happened yet.
type FeatureKey ¶
type FeatureKey string
FeatureKey is a named type for feature identifiers. Using a named type (not an alias) prevents accidental string injection at call sites.
const ( FeatureKeyReview FeatureKey = "review" FeatureKeySummarize FeatureKey = "summarize" FeatureKeyAC FeatureKey = "acceptance-criteria" FeatureKeyPRDescription FeatureKey = "pr-description" FeatureKeyCommitMessage FeatureKey = "commit-message" FeatureKeyCustom FeatureKey = "custom" FeatureKeyAutonomousFix FeatureKey = "autonomous_fix" FeatureKeyAutonomousApproval FeatureKey = "autonomous_approval" FeatureKeyTriage FeatureKey = "triage" // FeatureKeySessionCompletionSummary is distinct from the existing unused // FeatureKeySummarize so per-feature session rotation doesn't mix narrative // styles between the two features. FeatureKeySessionCompletionSummary FeatureKey = "session-completion-summary" )
Feature key constants for well-known AI features.
type Pool ¶
type Pool struct {
// contains filtered or unexported fields
}
Pool manages a map of named LLM feature sessions, providing session reuse for prefix-cache optimization and bounded concurrency.
func DefaultPool ¶
func DefaultPool() *Pool
DefaultPool returns the package-level default pool. Returns nil if SetDefaultPool has not been called.
func NewPool ¶
func NewPool(cfg PoolConfig) (*Pool, error)
NewPool constructs a Pool by looking up the claude binary in PATH, falling back to well-known install locations if PATH lookup fails. Returns ErrClaudeNotFound if the binary is not found anywhere.
func NewPoolWithRunner ¶
func NewPoolWithRunner(cfg PoolConfig, runner ClaudeRunner) *Pool
NewPoolWithRunner constructs a Pool with a custom runner (no PATH lookup). Used in tests to inject a FakeRunner.
func (*Pool) Call ¶
func (p *Pool) Call(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string) (<-chan StreamChunk, error)
Call starts a streaming headless LLM call for the given feature key. It returns a channel that receives StreamChunk values. The channel is closed when the subprocess exits (or the context is cancelled).
The caller should drain the channel until Done=true or Err!=nil.
func (*Pool) CallBlocking ¶
func (p *Pool) CallBlocking(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string, opts CallOptions) (string, float64, error)
CallBlocking makes a single blocking headless call and returns the result text, the cost in USD reported by claude, and any error. opts is the single place to pass WorkDir/Model/AllowedTools/PermissionMode; the zero value reproduces the simplest call shape. Cost is always parsed from the JSON result at no extra cost to callers that ignore it via `_`.
func (*Pool) CallWithOptions ¶
func (p *Pool) CallWithOptions(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string, opts CallOptions) (<-chan StreamChunk, error)
CallWithOptions is like Call but allows overriding model and working directory.
When opts.WorkDir is non-empty a fresh one-shot subprocess is used (bypassing session caching, which is invalid across directory changes). The parent pool's concurrency semaphore is still acquired so WorkDir calls count against the pool-level cap.
When opts.WorkDir is empty, opts.Model is forwarded to the pool's acquireSession so the correct model is used for the first-call (session-initialisation) request.
type PoolClient ¶
type PoolClient interface {
CallBlocking(ctx context.Context, key FeatureKey, systemPrompt, userPrompt string, opts CallOptions) (string, float64, error)
}
PoolClient is the narrow interface BacklogService uses for headless triage calls. Satisfied by *Pool; allows test injection without needing FakeRunner WorkDir support.
type PoolConfig ¶
type PoolConfig struct {
// MaxCallsPerSession is the maximum number of calls before a session is rotated.
// Defaults to 25 if zero.
MaxCallsPerSession int
// MaxConcurrentSessions is the maximum number of concurrent subprocess calls.
// Defaults to 5 if zero.
MaxConcurrentSessions int
// DefaultModel overrides the claude model used when no model is specified per-call.
DefaultModel string
}
PoolConfig configures a Pool.
type ProcessRunner ¶
type ProcessRunner struct {
// contains filtered or unexported fields
}
ProcessRunner implements ClaudeRunner using executor.StartProcess.
func NewShellWrappedProcessRunnerForTesting ¶ added in v1.43.0
func NewShellWrappedProcessRunnerForTesting(scriptPath string) *ProcessRunner
NewShellWrappedProcessRunnerForTesting constructs a ProcessRunner that execs scriptPath through "sh" instead of forking/exec'ing scriptPath directly. Use this whenever a test writes its own fake-claude shell script to a freshly-created temp file: direct exec-by-path of a just-written, just-chmod'd script can be refused by OS-level exec restrictions (Gatekeeper, TCC, or third-party endpoint security software) on some platforms, even though the exec bit and shebang line are both correct. Invoking through the pre-existing, already-trusted "sh" binary sidesteps that restriction because the OS is never asked to approve a freshly-written file for direct execution.
func (*ProcessRunner) Run ¶
func (r *ProcessRunner) Run(ctx context.Context, args []string, stdin io.Reader) (io.ReadCloser, func() error, error)
Run starts the claude binary with args and returns a ReadCloser for stdout. stdin provides the user prompt to the subprocess so it does not appear in /proc/<pid>/cmdline. The stop function terminates the subprocess and must always be called.
func (*ProcessRunner) WithToolAccess ¶ added in v1.38.0
func (r *ProcessRunner) WithToolAccess(allowedTools, permissionMode, disallowedTools string) *ProcessRunner
WithToolAccess returns a copy of this ProcessRunner with allowedTools/permissionMode/ disallowedTools set, preserving any existing workDir.
func (*ProcessRunner) WithWorkDir ¶
func (r *ProcessRunner) WithWorkDir(workDir string) *ProcessRunner
WithWorkDir returns a copy of this ProcessRunner that sets the subprocess working directory to workDir, preserving any existing allowedTools/permissionMode/ disallowedTools. Used by CallBlocking for per-call directory override.