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 ¶
- type Opts
- type PermissionDecision
- type PermissionRequest
- type ResultEnvelope
- type Session
- func (s *Session) Attach(w io.Writer)
- func (s *Session) BytesRead() int64
- func (s *Session) Close()
- func (s *Session) ContextUsage() (json.RawMessage, error)
- func (s *Session) CreatedAt() time.Time
- func (s *Session) Detach()
- func (s *Session) DetachWriter(w io.Writer)
- func (s *Session) Done() <-chan struct{}
- func (s *Session) ExitCode() int
- func (s *Session) ExitSignal() syscall.Signal
- func (s *Session) Exited() bool
- func (s *Session) Fd() uintptr
- func (s *Session) ForceKill() error
- func (s *Session) Interrupt(_ int, _ time.Duration) error
- func (s *Session) Kill() error
- func (s *Session) LastOutputAt() time.Time
- func (s *Session) NotifyUserInput()
- func (s *Session) PeakRSSBytes() int64
- func (s *Session) Pgid() int
- func (s *Session) Poke()
- func (s *Session) ProcessPID() int
- func (s *Session) RecentlyAdopted(time.Duration) bool
- func (s *Session) Resize(uint16, uint16) error
- func (s *Session) ScreenPreview() string
- func (s *Session) ScreenSnapshot() grpty.ScreenCapture
- func (s *Session) ScrollbackFile() *grpty.Scrollback
- func (s *Session) Snapshot() Snapshot
- func (s *Session) WaitForUserIdle(time.Duration, time.Duration) bool
- func (s *Session) WasAdopted() bool
- func (s *Session) WriteInput(data []byte) error
- func (s *Session) WriteInputAndSubmit(data []byte) error
- type Snapshot
- type Status
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 ¶
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) ContextUsage ¶
func (s *Session) ContextUsage() (json.RawMessage, error)
ContextUsage issues a get_context_usage control request and returns the raw response payload.
func (*Session) DetachWriter ¶
func (*Session) ExitSignal ¶
func (*Session) Fd ¶
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) Interrupt ¶
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) LastOutputAt ¶
func (*Session) NotifyUserInput ¶
func (s *Session) NotifyUserInput()
NotifyUserInput is a no-op: headless sessions have no attached human typing.
func (*Session) PeakRSSBytes ¶
PeakRSSBytes is not tracked for headless sessions.
func (*Session) Pgid ¶
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) ProcessPID ¶
func (*Session) RecentlyAdopted ¶
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) ScreenPreview ¶
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) WaitForUserIdle ¶
WaitForUserIdle returns immediately true: no interactive user to wait on.
func (*Session) WasAdopted ¶
WasAdopted is always false: headless sessions are not adopted across daemon restart (their stdout pipe can't be re-read).
func (*Session) WriteInput ¶
WriteInput sends the bytes as a stream-json user message (a new turn).
func (*Session) WriteInputAndSubmit ¶
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.