headless

package
v1.45.1 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

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

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

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

View Source
const DefaultCallTimeout = 900 * time.Second

DefaultCallTimeout is the default headless call timeout applied when timeout_seconds is 0.

View Source
const MaxCallTimeout = 1800 * time.Second

MaxCallTimeout caps timeout_seconds.

View Source
const MaxDiffSizeReview = 40_000

MaxDiffSizeReview is the maximum number of bytes included in a review prompt diff.

Variables

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

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.

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

func SuggestCommitMessage(ctx context.Context, pool *Pool, diff string) (string, error)

SuggestCommitMessage calls the LLM to generate a Conventional Commit message. Diffs longer than maxDiffSizeCommit bytes are truncated before sending.

func SummarizeBacklogItem

func SummarizeBacklogItem(ctx context.Context, pool *Pool, title, description string) (string, error)

SummarizeBacklogItem calls the LLM to summarize a backlog item. Returns the summary text from the JSON response.

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

Checked reports whether the self-check has run (successfully or not) yet.

func (*CodebaseReadCapabilitySelfCheck) Ensure added in v1.38.0

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.

type StreamChunk

type StreamChunk struct {
	Text    string
	Err     error
	Done    bool
	CostUSD float64 // non-zero only on the final chunk from a first-call JSON response
}

StreamChunk is a single unit of output from a headless LLM call.

Jump to

Keyboard shortcuts

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