headless

package
v0.69.0 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package headless drives a Claude Code agent in headless stream-json mode (`claude -p --output-format stream-json`) instead of an interactive PTY. It parses the typed event stream for status and the terminal result cost/usage envelope. See docs/design/2026-07-13-headless-stream-json-design.md (#1075).

v1 runs the one-shot control-channel form: `claude -p --output-format stream-json --input-format stream-json --verbose --permission-prompt-tool stdio`. The prompt is delivered as an initial stdin user message (not a positional arg), the stdin channel stays open for the turn so graith can issue an `interrupt` control request and answer inbound `can_use_tool` permission asks, and stdin is closed on the terminal `result` so the process exits (preserving one-shot semantics). See issue #1136 (Phase 4).

The control protocol is an SDK-internal contract, not a documented CLI API, so everything is written defensively (unknown message types ignored, malformed data lines skipped) and the whole feature is gated behind an experimental flag by the daemon. The wire shapes here are pinned to the forms empirically verified against claude 2.1.211 — notably the *asymmetric* request-id placement (see below).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Opts

type Opts struct {
	ID         string
	Command    string
	Args       []string
	Dir        string
	Env        map[string]string
	LogPath    string
	MaxLogSize int64

	// Prompt is the initial turn, sent as a stream-json user message on stdin
	// right after launch (the control-channel launch takes no positional
	// prompt). It should be non-empty: with the control channel and no
	// positional prompt, an empty prompt gives the CLI no turn to run, so it
	// blocks on stdin and never reaches a result (the daemon guards against this
	// at session creation).
	Prompt string

	// Control reports whether the process was launched with the stdin control
	// channel (`--input-format stream-json`). When true, Interrupt issues an
	// `interrupt` control request and stdin is closed on the terminal result so
	// the one-shot process exits; when false, Interrupt falls back to SIGINT.
	Control bool

	// OnPermission is invoked for each inbound can_use_tool request. If nil,
	// every tool request is denied (fail-closed — a headless session must not
	// block on a human that will never answer). It runs on a dedicated goroutine
	// per request (not the read loop), so a slow backend can't stall reading —
	// but it still must not block indefinitely, as the CLI blocks its turn on the
	// decision.
	OnPermission func(PermissionRequest) PermissionDecision
}

Opts configures a headless session launch. It mirrors the fields pty.SessionOpts needs, plus the initial prompt and approval callback.

type PermissionDecision

type PermissionDecision struct {
	Allow bool
	// Reason is surfaced to the agent on deny.
	Reason string
}

PermissionDecision is graith's answer to a PermissionRequest.

type PermissionRequest

type PermissionRequest struct {
	RequestID string
	ToolName  string
	// Input is the raw tool input JSON, passed through to the approval backend.
	Input json.RawMessage
}

PermissionRequest is an inbound can_use_tool control request: the CLI asking graith to approve a tool call.

type ResultEnvelope

type ResultEnvelope struct {
	IsError     bool            `json:"is_error"`
	TotalCost   float64         `json:"total_cost_usd"`
	NumTurns    int             `json:"num_turns"`
	DurationMS  int64           `json:"duration_ms"`
	DurationAPI int64           `json:"duration_api_ms"`
	Usage       json.RawMessage `json:"usage"`
	Text        string          `json:"result"`
	At          time.Time       `json:"-"`
}

ResultEnvelope is the terminal `result` message: the structured cost/usage summary the design feeds into token accounting (#644). A one-shot headless session emits exactly one of these.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is a headless stream-json agent process. It satisfies the daemon's SessionDriver surface so the session manager can hold it interchangeably with a *pty.Session.

func New

func New(opts Opts) (*Session, error)

New launches a headless session: starts the process with piped stdin/stdout/stderr and starts the read/stderr/wait goroutines. It returns an error if the process fails to start. (v1 one-shot mode does not perform an initialize handshake — that belongs to the deferred bidirectional control phase.)

func (*Session) Attach

func (s *Session) Attach(w io.Writer)

func (*Session) BytesRead

func (s *Session) BytesRead() int64

BytesRead reports total stream-json output bytes consumed this session.

func (*Session) Close

func (s *Session) Close()

func (*Session) ContextUsage

func (s *Session) ContextUsage() (json.RawMessage, error)

ContextUsage issues a get_context_usage control request and returns the raw response payload.

func (*Session) CreatedAt

func (s *Session) CreatedAt() time.Time

CreatedAt returns when this headless session was launched.

func (*Session) Detach

func (s *Session) Detach()

func (*Session) DetachWriter

func (s *Session) DetachWriter(w io.Writer)

func (*Session) Done

func (s *Session) Done() <-chan struct{}

func (*Session) ExitCode

func (s *Session) ExitCode() int

func (*Session) ExitSignal

func (s *Session) ExitSignal() syscall.Signal

func (*Session) Exited

func (s *Session) Exited() bool

func (*Session) Fd

func (s *Session) Fd() uintptr

Fd has no meaning for a pipe-backed session (there is no ptmx). It returns 0; the daemon's upgrade FD-handoff skips sessions it can't hand off.

func (*Session) ForceKill

func (s *Session) ForceKill() error

func (*Session) Interrupt

func (s *Session) Interrupt(_ int, _ time.Duration) error

Interrupt interrupts the running agent. With the control channel enabled it issues an `interrupt` control request (clean, acknowledged), falling back to a SIGINT to the process group if the control round-trip fails (channel wedged, timed out, or process already exited). Without the control channel it sends SIGINT directly. The count/delay arguments exist only for SessionDriver compatibility (the control interrupt is a single acknowledged request).

func (*Session) Kill

func (s *Session) Kill() error

func (*Session) LastOutputAt

func (s *Session) LastOutputAt() time.Time

func (*Session) NotifyUserInput

func (s *Session) NotifyUserInput()

NotifyUserInput is a no-op: headless sessions have no attached human typing.

func (*Session) PeakRSSBytes

func (s *Session) PeakRSSBytes() int64

PeakRSSBytes is not tracked for headless sessions.

func (*Session) Pgid

func (s *Session) Pgid() int

Pgid returns the process-group id graith signals on Kill/ForceKill. Like *pty.Session, a headless session is started with Setsid, so the child is a group leader and its PGID equals its PID. Returns 0 when the pid is unknown. Part of the SessionDriver interface (issue #1104).

func (*Session) Poke

func (s *Session) Poke()

Poke is a no-op: there is no TUI to nudge.

func (*Session) ProcessPID

func (s *Session) ProcessPID() int

func (*Session) RecentlyAdopted

func (s *Session) RecentlyAdopted(time.Duration) bool

RecentlyAdopted is always false: headless sessions are not adopted across daemon restart (their stdout pipe can't be re-read); the daemon resumes them.

func (*Session) Resize

func (s *Session) Resize(uint16, uint16) error

Resize is a no-op: a headless session has no terminal to resize.

func (*Session) ScreenPreview

func (s *Session) ScreenPreview() string

ScreenPreview returns a plain-text tail of the rendered scrollback. A headless session has no terminal screen emulator, so the overlay preview and the screen_preview control message degrade to the recent rendered output.

func (*Session) ScreenSnapshot

func (s *Session) ScreenSnapshot() grpty.ScreenCapture

ScreenSnapshot returns a ScreenCapture whose Frame is the scrollback tail, so callers expecting a PTY snapshot get something sensible for a headless session.

func (*Session) ScrollbackFile

func (s *Session) ScrollbackFile() *grpty.Scrollback

func (*Session) Snapshot

func (s *Session) Snapshot() Snapshot

Snapshot returns the current structured status + last result envelope.

func (*Session) WaitForUserIdle

func (s *Session) WaitForUserIdle(time.Duration, time.Duration) bool

WaitForUserIdle returns immediately true: no interactive user to wait on.

func (*Session) WasAdopted

func (s *Session) WasAdopted() bool

WasAdopted is always false: headless sessions are not adopted across daemon restart (their stdout pipe can't be re-read).

func (*Session) WriteInput

func (s *Session) WriteInput(data []byte) error

WriteInput sends the bytes as a stream-json user message (a new turn).

func (*Session) WriteInputAndSubmit

func (s *Session) WriteInputAndSubmit(data []byte) error

WriteInputAndSubmit is identical to WriteInput for headless: a user message is a complete, submitted turn (there is no separate submit key).

type Snapshot

type Snapshot struct {
	Status   Status
	ToolName string
	Result   *ResultEnvelope
	Degraded bool // reader hit malformed control frames / decode failures
}

Snapshot is the structured status the daemon reads off a headless driver in place of PTY scraping.

type Status

type Status string

Status is the coarse agent status graith tracks, mirroring the strings the detector/hook path uses so the daemon can treat headless and PTY sessions uniformly.

const (
	StatusActive   Status = "active"
	StatusApproval Status = "approval"
	StatusReady    Status = "ready"
	StatusStopped  Status = "stopped"
)

Jump to

Keyboard shortcuts

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