Help us shape the future of Go! Take the 2026 Go Developer Survey

driver

package
v0.22.1 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package driver is the connector's agent boundary: how a dispatched task becomes a working coding agent, and how the connector hears what it does.

The shape is ACP's

The interface is Agent Client Protocol v1's session model, whatever speaks underneath. A Driver opens a session (session/new) or reloads one (session/load) in a working directory with an explicit set of MCP servers; a Session takes prompts, each returning a stop reason (session/prompt); progress arrives as a stream of updates (session/update); a turn is ended with Cancel (session/cancel); and a permission the agent asks for is answered by the connector's policy (session/request_permission). A spawn driver (claude -p, codex exec) is an adapter onto that shape: it maps its vendor stream onto the same updates and stop reasons, freezes the policy into flags it verifies, and cancels by ending the process group it started. So the ACP driver is one more driver, not a rewrite.

Invariants every driver holds

Each is held by a test in the driver that implements it.

  1. Nothing is inherited. A worker process gets exactly the environment in SessionConfig.Env and each MCP server exactly MCPServer.Env; the connector's own environment (which carries tokens of its host) never reaches either. No secret is ever put in a process's argv.
  2. The permission mode is set explicitly and verified. A session whose agent did not confirm the mode the policy asked for is unsafe, and the driver refuses to go on with it (ErrUnsafeMode) rather than run under the host's own configuration.
  3. A refusal is the driver's own record. A policy refusal is not distinguishable from a cancel by the agent's stop reason, so every refusal the driver made or observed is recorded once, through SessionConfig.Refusals, at the moment it is made or observed; it is reported as well as a Refusal on the prompt's result and as an update; and a stop the connector did not ask for is never reported as TurnCanceled. See "Refusals" below.
  4. ErrNotStarted means no worker process ever existed. It is the only start error after which the connector retries on its own, so a driver returns it only when it can prove nothing ran; any doubt is some other error. A configuration no retry can fix wraps ErrUnusable as well, and is not retried. Whatever the error, a start that fails leaves no process behind: either none was started, or the driver ended the one it started — through Terminate, so the whole group goes — before returning. A driver that cannot promise that returns a Session the connector can Close instead of an error.
  5. A worker is ended by the process group the driver started, never by name. Close is idempotent and leaves no process of the session behind.
  6. Content stays in the stream. Updates carry kinds, ids, tool names and counts; they never carry the agent's text or a tool's input, so a sink that logs an update cannot log content. What a sink does log from an agent stream goes through the redaction rule (redact.go).
  7. A worker is announced as soon as it exists, not when its session is ready. SessionConfig.Started is called with the worker's process the moment the fork returns, before any handshake the driver runs — which is what lets the connector arm the task token's socket for a worker whose handshake cannot finish until its MCP servers have connected. StartWorker calls it, so every driver that starts a process holds this by construction; a driver whose session runs somewhere the connector cannot signal announces nothing, and the connector arms on what Session.Process reports instead.

Refusals: where one is recorded, and when it counts as settled

A refusal is a permission the agent asked for and did not get. It is recorded in the ledger, once, at the moment the driver answers the request — or, for an agent that answers its own requests under a mode the driver froze (claude -p), at the moment the driver first reads that it was refused. It is never held only in a session's memory, because a worker that exits before its result, a connector that crashes mid-turn, and a turn cut short by a deadline all end the session that memory lives in.

  1. The driver calls SessionConfig.Refusals.RecordRefusal before it sends its answer to the agent, or before it emits the update for a refusal it observed. It calls it once per tool call id: a refusal the stream announced and the result repeats is one refusal. A refusal with NO tool call id — one read from a line of the agent's output rather than from a call — counts every time it happens, identical text included: two refusals of the same tool are two refusals, and nothing but an id can say they are one. The recorder itself deduplicates NOTHING: it records what it is told, once per call. Deciding what is one refusal is the driver's, which knows what it read — a tool call id where the agent gives one, and where a driver reads refusals from lines of output, the line AND its occurrence in that output, so two identical lines are two refusals and reading the same output twice records neither again (card 19).
  2. The dispatcher's recorder writes it to the attempt's row at once (connector.Ledger.RecordRefusal: attempts.refusals, incremented while the attempt is live). A write the ledger refuses is carried by the recorder into the attempt's settlement instead, and logged.
  3. The refusal is settled with its attempt: EndAttempt adds whatever the recorder could not write, and the ended attempt's count is final. The session's updates are drained before the attempt is released, and the recorder is called before an update is emitted, so a worker that exits between a refusal and its result has already recorded it.

Once-ness is the driver's (a set of tool call ids per session), not a key in the ledger: it holds for as long as a session lives, which is as long as a refusal can be reported twice. A connector that restarts does not resume a session — its attempt is settled as lost and its task superseded — so a ledger key on (attempt, tool call) would buy nothing, and this is settled, not open.

Where a refusal can be seen differs by agent: Claude Code announces it in its stream and repeats it in the turn's result, and an agent that writes refusals only to stderr is read through Worker.StderrLines, not StderrTail — the tail is the last line, and whatever the agent prints next would bury the refusal.

Where this can still be broken: a refusal the agent never reports — a tool it declined to ask for, or a denial its stream does not carry — is not a refusal the driver can record.

Index

Constants

View Source
const (
	PreflightStarts = "starts"
	PreflightFlags  = "flags"
	PreflightLogin  = "login"
)

The checks, by name.

View Source
const DefaultGrace = 10 * time.Second

DefaultGrace is how long a worker's process group has between SIGTERM and SIGKILL.

View Source
const PreflightTimeout = 30 * time.Second

PreflightTimeout bounds each of a preflight's runs.

Variables

View Source
var (
	// ErrNotStarted wraps a start that failed before any worker process
	// existed (invariant 4): the binary is missing, the launcher refused, the
	// fork failed. Only this is retried automatically.
	ErrNotStarted = errors.New("driver: the worker was not started")
	// ErrUnusable wraps ErrNotStarted for a configuration no retry can fix:
	// a mode the driver cannot express, a policy for another directory, an
	// MCP server without a command. No process existed, and starting again
	// would fail the same way, so the connector does not retry it.
	ErrUnusable = errors.New("driver: the session's configuration cannot start a worker")
	// ErrUnsafeMode is an agent that did not confirm the permission mode the
	// policy asked for (invariant 2). The session is ended.
	ErrUnsafeMode = errors.New("driver: the agent did not confirm the permission mode asked for")
	// ErrSessionUnverified is a session that started but is not the one the
	// connector asked for: an MCP server the agent did not connect, or a
	// session id that is not the one requested. The driver ends such a
	// session rather than let a worker run without the tools its dispatch
	// needs — a worker with no Basecamp tools can neither read its dispatch
	// nor report it, and would otherwise finish with the mention unanswered
	// (card 23's finding). A driver's own sentinel for one of these wraps
	// this one.
	ErrSessionUnverified = errors.New("driver: the session is not the one the connector asked for")
	// A server that stops working AFTER the handshake is not detectable from
	// Claude Code's stream, which carries server status only in its init
	// message: the connector's record is what catches it, since an event the
	// worker could not report settles completed(unknown) and never succeeded,
	// and the dispatcher logs connector.UnreportedFinishLine for a person to
	// find.
	//
	// ErrSessionEnded is a call on a session whose worker is gone.
	ErrSessionEnded = errors.New("driver: the session has ended")
)

Errors a driver reports.

View Source
var BaseEnv = []string{
	"HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
	"TERM", "TMPDIR", "TZ",
	"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR",
}

BaseEnv is the environment every worker process may get from the connector's own: what a program needs to find its home, its tools, its locale and its terminal, and nothing that authenticates anyone. A driver adds the few variables its agent needs by name; nothing is passed by pattern.

View Source
var ErrGroupOutlivedLeader = errors.New("driver: the recorded process group outlived its leader")

ErrGroupOutlivedLeader is a recorded process group whose leader is gone — or is a pid the kernel has since reused — while the group still has members. They may be the worker's own children, so the caller must not treat the worker as finished.

View Source
var ErrIdentityUnknown = errors.New("driver: the recorded process has no kernel start time, so it cannot be told from a later process that reused its pid")

ErrIdentityUnknown is a record the connector cannot tell from a later process that reused its pid, because no kernel start time was ever recorded for it. It is not "gone" and it is not "still running": it is unanswerable, and the one-owner rule signals nothing and releases nothing on an unanswerable identity.

Functions

func BuildEnv

func BuildEnv(allow []string, lookup func(string) (string, bool), extra map[string]string) []string

BuildEnv is the environment made of the allowlisted names that lookup has, plus extra, which wins over a looked-up value of the same name. Its output is sorted, so the same inputs make the same environment.

lookup is os.LookupEnv in production. A name is taken only as given: no prefix, no pattern, so a new variable of the host's never reaches a worker by resembling an allowed one.

func ConfirmGroupGone

func ConfirmGroupGone(p Process, grace time.Duration) error

ConfirmGroupGone is step 3 of the one-owner rule: it answers whether a worker's process group is gone, and it is what every caller asks before settling an attempt.

It signals the group once more — a worker that ignored SIGTERM gets SIGKILL — then waits up to grace for the last member to go. A group with members left is ErrGroupOutlivedLeader, and the zero Process (a session the connector cannot signal at all) is gone as far as this rule goes, since there is nothing of it here to own.

func EnvMap

func EnvMap(env []string) map[string]string

EnvMap is BuildEnv's result as a map, for an MCPServer's Env.

func EnvOf

func EnvOf(m map[string]string) []string

EnvOf is an MCP server's environment map as KEY=VALUE, for Redaction.Env.

func FlagsOf

func FlagsOf(args []string) []string

FlagsOf is every flag in a command line, once each, in order. A lone "-" (read the prompt from stdin) is an argument, not a flag.

func GroupMembersRemain

func GroupMembersRemain(p Process) bool

GroupMembersRemain reports whether the process group still has members. It signals nothing: it is the observation the one-owner rule's step 3 and 4 rest on, and what a caller asks when it must not disturb the group.

A probe that cannot answer — the group exists but is not ours to signal — counts as members remaining, because the rule releases nothing it cannot prove gone.

func MissingFlags

func MissingFlags(help string, flags []string) []string

MissingFlags is the flags help does not list. A flag counts as listed only as a whole word: --tools is not found inside --allowed-tools.

func OwnProcessGroup

func OwnProcessGroup() (int, bool)

OwnProcessGroup is the connector's own process group, which nothing of a worker's is ever in: every worker leads a group of its own.

func OwnsWorker

func OwnsWorker(p Process) (bool, error)

OwnsWorker answers the one-owner rule's identity question: is the process this record names still the worker the task owns?

A pid is not an identity — the kernel reuses them — so ownership is the pid AND the start time the owner recorded for it. Everything that acts on a recorded worker (recovery, status, redispatch, discard, hold) asks this before it acts, rather than writing its own pid check:

  • (true, nil): the process is still that worker. It may be signaled.
  • (false, nil): it is gone, and its group has no members left. Its record may be settled.
  • (false, ErrGroupOutlivedLeader): the leader is gone or is now some other process, and the recorded group still has members — they may be the worker's children. Nothing may be settled or released.
  • (false, err): the identity cannot be established here (an unreadable process table, a record with no kernel start time, a platform that cannot say). Nothing may be settled or released either.

func ProcessGone

func ProcessGone(p Process) (bool, error)

ProcessGone reports whether the process a record names is gone: no process by that pid, a zombie, or a later process the kernel gave the same pid. It asks only about that process and says nothing about its group, which is what a caller wants to know about a worker's MCP server — the group is the agent's and outlives its servers.

The comparison is exact. A kernel start time is read the same way every time it is read, in ticks since boot, so the process that was recorded answers with the value recorded for it and anything else is another process. A record whose start time the kernel never gave (StartedExact false) is ErrIdentityUnknown rather than a comparison against a tolerance: under fast pid reuse a window wide enough to cover a wall-clock stamp is wide enough to accept a stranger.

It is the one place the question "is this still that process?" is answered; OwnsWorker asks it too, and adds the group.

func TerminateRecorded

func TerminateRecorded(p Process, grace time.Duration) (bool, error)

TerminateRecorded ends a worker a previous connector process started, by the process group it recorded, and only while OwnsWorker says that group is still this task's worker: a pid the kernel has since given to something else is left alone. It reports whether it signaled anything.

Types

type Capabilities

type Capabilities struct {
	// LoadSession: LoadSession works, so a follow-up after the worker ended
	// can continue its conversation.
	LoadSession bool
	// FollowUpPrompts: a live session takes further prompts, so a follow-up
	// is delivered into the same session rather than as a new attempt.
	FollowUpPrompts bool
	// PermissionCallback: the agent asks, and PermissionPolicy.Decide answers
	// each request. False for a spawn driver, whose permissions are frozen
	// into flags from PermissionPolicy.Rules before the process starts.
	PermissionCallback bool
}

Capabilities are what a driver advertises, as an ACP agent advertises its own at initialize.

type Command

type Command struct {
	Path string
	Args []string
	Env  []string
	Dir  string
}

Command is a process to run: path, argv (without the path) and the whole environment.

type DirectLauncher

type DirectLauncher struct{}

DirectLauncher runs the worker as it is, in the scope's directory.

func (DirectLauncher) Launch

Launch implements Launcher.

func (DirectLauncher) Receipts

Receipts implements Launcher.

type Driver

type Driver interface {
	// Name is the driver's name as connect.json and the ledger spell it:
	// "claude", "codex", "acp".
	Name() string
	// Capabilities says what the driver supports beyond NewSession and Prompt.
	Capabilities() Capabilities
	// NewSession starts a worker and opens a session in cfg.Cwd. The worker
	// is announced through cfg.Started as soon as its process exists, before
	// the session is opened on top of it (invariant 7).
	//
	// An error that wraps ErrNotStarted means no process ever existed, and
	// the connector may retry the start once. Any other error from a start
	// that launched a process wraps a *StartError carrying that process, whose
	// group the driver has already asked to end: the connector confirms it
	// gone (ConfirmGroupGone) before it settles anything, however long the
	// driver's own handshake took to fail.
	NewSession(ctx context.Context, cfg SessionConfig) (Session, error)
	// LoadSession reopens a session by the id an earlier Session reported,
	// where Capabilities().LoadSession is true. Its errors read as
	// NewSession's, and leave no process behind either.
	LoadSession(ctx context.Context, cfg SessionConfig, sessionID string) (Session, error)
}

Driver starts and reloads sessions for one kind of coding agent.

type Exit

type Exit struct {
	// Code is the exit status, or -1 when a signal ended the process.
	Code int
	// Signaled is true when a signal ended it.
	Signaled bool
	// Err is a failure to wait on the process at all.
	Err error
}

Exit is how a worker ended.

type LaunchRequest

type LaunchRequest struct {
	Scope   Scope
	Command Command
}

LaunchRequest is a worker command and its scope.

type Launched

type Launched struct {
	Command Command
	// WorkDir is the directory the worker works in: Scope.WorkDir for the
	// direct launcher, a broker-owned scope under a sandbox.
	WorkDir string
}

Launched is what runs.

type Launcher

type Launcher interface {
	// Launch returns the command that actually runs and the directory it runs
	// in. A launcher refuses a request whose scope it cannot honor.
	Launch(ctx context.Context, req LaunchRequest) (Launched, error)
	// Receipts are what the launcher confirms the worker did, for the attempt
	// the scope named. The direct launcher confirms nothing.
	Receipts(ctx context.Context, attemptID string) ([]Receipt, error)
}

Launcher wraps the worker command: the seam where a sandbox launcher (sandbox-run) takes the dispatch. Scopes in, working directory and receipts out.

type MCPServer

type MCPServer struct {
	// Name is the server's name as the agent's tools will be prefixed.
	Name string
	// Command is the executable, absolute.
	Command string
	// Args are its arguments. Never a secret: argv is readable by every
	// process on the machine.
	Args []string
	// Env is the server's whole environment, KEY -> VALUE. Declared
	// explicitly, never counted on to be inherited: some agents pass their
	// own environment down and some pass almost nothing.
	Env map[string]string
}

MCPServer is one stdio MCP server handed to the agent, as ACP's mcpServers[] entry.

type PermissionDecision

type PermissionDecision struct {
	Allow bool
}

PermissionDecision is the policy's answer. A driver answers with the offered option of kind AllowOnce or RejectOnce, and refuses when the kind it needs is not offered.

type PermissionMode

type PermissionMode string

PermissionMode is the connector's name for an agent's permission mode. A driver maps it to the agent's own mode id and verifies the agent reports that id back.

const (
	// ModeEdits allows edits, and refuses, without asking anyone, whatever
	// the rules do not allow. It bounds where an edit may land no further
	// than the agent's own sandbox does.
	ModeEdits PermissionMode = "edits"
	// ModeAnything allows every tool the agent has, commands included,
	// without asking anyone: dangerous mode. The worker runs with the
	// operator's own authority on this computer, and nothing but the agent's
	// judgment stands between a request and a command.
	ModeAnything PermissionMode = "anything"
)

type PermissionOption

type PermissionOption struct {
	ID   string
	Kind PermissionOptionKind
}

PermissionOption is one choice the agent offers.

type PermissionOptionKind

type PermissionOptionKind string

PermissionOptionKind is ACP's option kind.

const (
	AllowOnce    PermissionOptionKind = "allow_once"
	AllowAlways  PermissionOptionKind = "allow_always"
	RejectOnce   PermissionOptionKind = "reject_once"
	RejectAlways PermissionOptionKind = "reject_always"
)

type PermissionPolicy

type PermissionPolicy interface {
	// Decide answers one request, for drivers that ask
	// (Capabilities.PermissionCallback).
	Decide(ctx context.Context, req PermissionRequest) PermissionDecision
	// Rules is the same policy, pre-decided, for drivers whose permissions
	// are fixed before the worker starts.
	Rules() PermissionRules
}

PermissionPolicy is the connector's answer to what a worker may do. Permission answers are policy, not containment: the worker still runs with the operator's ambient authority, and nothing here is a sandbox.

type PermissionRequest

type PermissionRequest struct {
	ToolCallID string
	Tool       string
	Kind       ToolKind
	// Locations are the paths the call touches, where the agent says.
	Locations []string
	// Options are the choices the agent offers. A driver selects by kind,
	// never by id or label: ids are not portable across agents.
	Options []PermissionOption
}

PermissionRequest is ACP's session/request_permission, reduced to what a policy decides on.

type PermissionRules

type PermissionRules struct {
	// Mode is the asking mode the agent must run in and confirm.
	Mode PermissionMode
	// AllowKinds are the tool kinds allowed without asking, besides edits.
	AllowKinds []ToolKind
	// AllowMCPServers are the MCP servers whose every tool is allowed.
	AllowMCPServers []string
}

PermissionRules is a policy pre-decided.

It names no directory. The connector's policy bounded edits to the working directory while a project was routed to one; with the routes gone the only directory left is wherever the operator started the connector, and a bound that moves with that is a guarantee in name and an accident in behavior. What confines a worker to a directory now is the agent's own sandbox (Codex's workspace-write) or the sandbox launcher being built separately — not this.

type Preflight

type Preflight struct {
	// Product is the worker's name for a person: "Claude Code".
	Product string
	// Version is what the worker said its version is; empty when it did not
	// start.
	Version string
	Checks  []PreflightCheck
}

Preflight is what a worker's preflight found.

func (Preflight) Failed

func (p Preflight) Failed() (PreflightCheck, bool)

Failed is the first check that failed.

type PreflightCheck

type PreflightCheck struct {
	Name    string
	Status  PreflightStatus
	Message string
	// Hint is the fix, where one is known.
	Hint string
}

PreflightCheck is one question a preflight asked.

type PreflightStatus

type PreflightStatus string

PreflightStatus is how a check went. A warning is something the preflight could not settle; it never stops anything.

const (
	PreflightPass PreflightStatus = "pass"
	PreflightFail PreflightStatus = "fail"
	PreflightWarn PreflightStatus = "warn"
)

type Preflighter

type Preflighter interface {
	Preflight(ctx context.Context, policy PermissionPolicy) Preflight
}

Preflighter is a driver that can check its worker before any work is given to it.

type ProbeResult

type ProbeResult struct {
	Stdout, Stderr string
	// Exit is the exit status; -1 when the program did not exit on its own.
	Exit int
	// NotFound is a program PATH does not have.
	NotFound bool
	// TimedOut is a program that did not answer in time.
	TimedOut bool
	// Err is why the program could not be run, when it could not.
	Err error
}

ProbeResult is one short run of a worker's program.

func RunProbe

func RunProbe(ctx context.Context, cmd Command, timeout time.Duration) ProbeResult

RunProbe runs cmd with no input, bounded by timeout. The program is found as StartWorker finds it.

type Process

type Process struct {
	// PID is the process's id; zero when there is none to signal.
	PID int
	// PGID is its process group, which Close signals. A driver starts every
	// worker as the leader of a new group, so PGID == PID.
	PGID int
	// StartedAt is when the process started, to tell it from a later one
	// that reused its id.
	StartedAt time.Time
	// StartedExact is true when StartedAt is the kernel's own start time for
	// the pid, which is what an identity is compared by. False is a
	// wall-clock stamp the driver took around the fork because the kernel
	// could not be asked: readable, but not an identity, and the one-owner
	// rule signals nothing and releases nothing on one (ErrIdentityUnknown).
	StartedExact bool
}

Process is a worker process the connector started.

func LookupProcess

func LookupProcess(pid int) (Process, error)

LookupProcess is a live process's identity: its pid, the process group it leads or belongs to, and the start time that tells it from a later process the kernel gave the same pid. A process that is gone — or a zombie, which runs nothing — is os.ErrNotExist.

It is how the connector takes the identity of a process it did not start but knows about, such as the MCP server that took a task token from the socket, which an agent may have started in a process group of its own.

func StartedProcess

func StartedProcess(err error) Process

StartedProcess is the process a failed start launched, if it launched one.

type PromptResult

type PromptResult struct {
	Stop TurnStop
	// Refusals are the permissions refused during the turn (invariant 3).
	Refusals []Refusal
	// Usage is the turn's token use, where the agent reports it.
	Usage Usage
}

PromptResult is a finished turn.

type Receipt

type Receipt struct {
	Kind string
	ID   int64
	URL  string
}

Receipt is something a launcher confirms a worker posted.

type Redaction

type Redaction struct {
	// Secrets are values removed wherever they appear: a task token, an
	// agent credential.
	Secrets []string
	// Env is an environment, as KEY=VALUE, whose values are removed unless
	// BaseEnv names them.
	Env []string
	// Dirs are directories any path under which is removed: the state and
	// runtime directories.
	Dirs []string
}

Redaction names what a Redactor takes out.

func (Redaction) With

func (r Redaction) With(more Redaction) Redaction

With is r with more added.

type Redactor

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

Redactor applies a Redaction. Build one with NewRedactor; it is safe for concurrent use.

func NewRedactor

func NewRedactor(r Redaction) *Redactor

NewRedactor compiles r.

func (*Redactor) Err

func (r *Redactor) Err(err error) error

Err is err with its message sanitized. errors.Is still answers for every error err wraps, and errors.As for a *StartError, whose own error is sanitized in turn; nothing else of the original chain is reachable, so no wrapped message can carry a secret past it.

func (*Redactor) Handler

func (r *Redactor) Handler(h slog.Handler) slog.Handler

Handler is h with every message and attribute sanitized. A string, an error or any value that is not a number, a boolean, a time or a duration is written as its sanitized text.

func (*Redactor) Lines

func (r *Redactor) Lines(text string) []string

Lines is what may be passed on of a worker's stderr when the LAST line is not enough: its last maxStderrLines non-empty lines, each sanitized, on one line and no longer than maxStderr bytes, oldest first.

Stderr gives the last line, which is where a program that could not start says why. A refusal, though, is written when it happens and whatever the agent prints afterwards buries it, so a driver that reads refusals from stderr reads them here (driver.go's "Refusals").

func (*Redactor) Sanitize

func (r *Redactor) Sanitize(s string) string

Sanitize is the one function every text crossing out of a worker passes through. See the rule above.

func (*Redactor) Stderr

func (r *Redactor) Stderr(text string) string

Stderr is what may be passed on of a worker's stderr: its last non-empty line, sanitized, on one line, and no longer than maxStderr bytes.

type Refusal

type Refusal struct {
	// ToolCallID is the agent's id for the call.
	ToolCallID string
	// Tool is the tool's name or ACP kind; never its input.
	Tool string
}

Refusal is one permission the policy refused.

type RefusalRecorder

type RefusalRecorder interface {
	RecordRefusal(ctx context.Context, r Refusal) error
}

RefusalRecorder records a refusal at the moment a driver makes or observes it (see "Refusals" above). RecordRefusal must not block for long: a driver calls it on the goroutine that reads the agent's stream.

type Scope

type Scope struct {
	TaskID    int64
	AttemptID string
	// EventIDs are the events the task may cover. Only the originating event
	// has been handed to the worker when the session starts; the others are
	// exposed as they are prompted.
	EventIDs []int64
	// WorkDir is the directory the worker runs in: the connector's own, the
	// one it was started in. It is where the process starts, not a bound on
	// where it may write.
	WorkDir string
	// SocketDir holds the task token's unix socket, which the worker's MCP
	// server dials. A launcher that confines a worker must let it reach this
	// directory, or the worker's MCP server cannot be handed its token. It is
	// SessionConfig.PrivateDir in the ordinary case, and a short directory of
	// the connector's own where a socket path under PrivateDir would be
	// longer than a unix socket takes.
	SocketDir string
	Class     string
}

Scope is what a worker is for, as the launcher is told.

type Session

type Session interface {
	// ID is the agent's session id (ACP sessionId, Claude Code's session_id).
	// It is known when NewSession returns.
	ID() string
	// Process is the worker's process, or the zero Process when the session
	// runs somewhere the connector cannot signal.
	Process() Process
	// Prompt sends one prompt and blocks until the turn ends. The first
	// prompt of a session is its handshake: a driver that verifies the
	// agent's mode on it returns ErrUnsafeMode and ends the session. A ctx
	// that ends makes Prompt return ctx's error without ending the turn; use
	// Cancel for that.
	Prompt(ctx context.Context, prompt string) (PromptResult, error)
	// Updates streams the session's progress. It is closed when the session
	// ends. A consumer that stops reading does not stall the agent: a driver
	// drops updates rather than block.
	Updates() <-chan Update
	// Cancel ends the turn in flight. Prompt then returns TurnCanceled.
	// With no turn in flight it does nothing.
	Cancel(ctx context.Context) error
	// Close ends the session and its worker: the process group is signaled,
	// given grace, and killed. Idempotent; safe concurrently with Prompt,
	// which then returns an error.
	Close() error
	// Done is closed once the worker has exited, however it exited.
	Done() <-chan struct{}
	// Exit is how the worker exited; meaningful once Done is closed.
	Exit() Exit
}

Session is one live conversation with a worker.

type SessionConfig

type SessionConfig struct {
	// Cwd is the directory the session runs in, absolute: the connector's
	// own, the one it was started in.
	Cwd string
	// Env is the worker process's whole environment, as KEY=VALUE. Nothing
	// else is inherited (invariant 1). BuildEnv makes one from an allowlist.
	Env []string
	// MCPServers are the only MCP servers the agent gets. A driver makes the
	// agent ignore every other MCP configuration it would otherwise load.
	MCPServers []MCPServer
	// Policy answers permissions.
	Policy PermissionPolicy
	// Launcher wraps the worker command. Nil means DirectLauncher.
	Launcher Launcher
	// Scope is what the launcher is told the worker is for.
	Scope Scope
	// SocketDir is the directory holding the task token's unix socket, which
	// the worker's MCP server dials. It is PrivateDir in the ordinary case
	// and a short directory of the connector's own where a socket path under
	// PrivateDir would be longer than a unix socket takes. It is in Scope
	// too, which is what a launcher is given.
	SocketDir string
	// PrivateDir is an owner-only directory the driver may write session
	// files into (an MCP config, say). The driver removes what it wrote when
	// the session is closed; the dispatcher sweeps the directory on start.
	PrivateDir string
	// Refusals records every refusal at the moment it is made or observed.
	// Nil records nothing; the dispatcher always sets it.
	Refusals RefusalRecorder
	// Started is called once with the worker's process as soon as that
	// process exists — the fork has returned and the group is the worker's —
	// and before whatever handshake the driver runs on top of it (invariant
	// 7). It is how the connector learns the process group while NewSession
	// is still running, which is what arms the task token's socket in time
	// for an agent that starts its MCP servers during its own handshake:
	// arming only once NewSession returned deadlocks such an agent against a
	// token the connector cannot hand over until the handshake it is blocking
	// has finished. It must not block: a driver calls it on the goroutine
	// that is starting the session. Nil announces nothing.
	Started func(Process)
	// Redaction is what the driver takes out of every error it returns and
	// every text an update or a stderr tail carries (redact.go). The driver
	// adds the environment it builds, its MCP servers' environments and
	// PrivateDir to it.
	Redaction Redaction
}

SessionConfig is everything a driver needs to start a session. The dispatcher builds it from the task's record; the driver adds nothing of its own beyond its binary and its flags.

type StartError

type StartError struct {
	Process Process
	Err     error
}

StartError is a start that failed after it launched a process. The driver has asked the process's group to end; the connector owns confirming it gone before it settles the attempt.

func (*StartError) Error

func (e *StartError) Error() string

func (*StartError) Unwrap

func (e *StartError) Unwrap() error

type ToolKind

type ToolKind string

ToolKind is ACP's tool kind.

const (
	ToolRead    ToolKind = "read"
	ToolEdit    ToolKind = "edit"
	ToolDelete  ToolKind = "delete"
	ToolMove    ToolKind = "move"
	ToolSearch  ToolKind = "search"
	ToolExecute ToolKind = "execute"
	ToolThink   ToolKind = "think"
	ToolFetch   ToolKind = "fetch"
	ToolOther   ToolKind = "other"
)

type ToolStatus

type ToolStatus string

ToolStatus is a tool call's status.

const (
	ToolPending    ToolStatus = "pending"
	ToolInProgress ToolStatus = "in_progress"
	ToolCompleted  ToolStatus = "completed"
	ToolFailed     ToolStatus = "failed"
)

type TurnStop

type TurnStop string

TurnStop is why a prompt turn ended: ACP v1's stop reasons.

const (
	// TurnEndTurn is the agent finishing its turn.
	TurnEndTurn TurnStop = "end_turn"
	// TurnMaxTokens is the token limit.
	TurnMaxTokens TurnStop = "max_tokens"
	// TurnMaxTurnRequests is the agent's own request budget for the turn.
	TurnMaxTurnRequests TurnStop = "max_turn_requests"
	// TurnRefusal is the agent refusing to continue.
	TurnRefusal TurnStop = "refusal"
	// TurnCanceled is a cancel the connector asked for, and only that
	// (invariant 3). The value is ACP's spelling.
	TurnCanceled TurnStop = "cancelled" //nolint:misspell // ACP's wire value
)

type Update

type Update struct {
	Kind UpdateKind
	At   time.Time

	// ToolCallID, Tool, ToolKind and Status describe a tool call.
	ToolCallID string
	// Tool is the tool's name ("Bash", "mcp__basecamp__basecamp_connect").
	Tool     string
	ToolKind ToolKind
	Status   ToolStatus

	// Usage is set on UpdateUsage.
	Usage *Usage
	// Chars is the length of an agent message chunk, whose text is not
	// carried.
	Chars int
	// Allowed is set on UpdatePermission: whether the policy allowed it.
	Allowed bool
}

Update is one piece of progress. It carries no content (invariant 6): progress is for liveness, budgets and the ledger, never for reading what the agent said.

type UpdateKind

type UpdateKind string

UpdateKind names a session update, as ACP's sessionUpdate does.

const (
	UpdateToolCall          UpdateKind = "tool_call"
	UpdateToolCallUpdate    UpdateKind = "tool_call_update"
	UpdateUsage             UpdateKind = "usage_update"
	UpdateAgentMessageChunk UpdateKind = "agent_message_chunk"
	// UpdatePlan is optional: no adapter the spike ran emitted one.
	UpdatePlan UpdateKind = "plan"
	// UpdatePermission is a permission decision the driver made or observed.
	UpdatePermission UpdateKind = "permission"
)

type Usage

type Usage struct {
	InputTokens  int64
	OutputTokens int64
	// ContextUsed and ContextSize are ACP usage_update's {used, size}, where
	// known.
	ContextUsed int64
	ContextSize int64
}

Usage is token accounting.

type Worker

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

One owner, one release point

This is the connector's rule for a task's process tree and its ledger record. Both belong to one owner — the attempt — and are released at one point, in this order:

  1. Every worker starts as the leader of its own process group (StartWorker), so the tree it makes can be signaled as one.
  2. A cancel, a deadline or a shutdown ends that group: SIGTERM, a bounded wait, then SIGKILL, by process group id and never by name (Terminate).
  3. The group is then CONFIRMED gone (ConfirmGroupGone). Only after that may the attempt be settled and its record made terminal.
  4. A group that cannot be confirmed gone — members left, a pid whose identity cannot be established, a platform that cannot say — leaves the record HELD: live in the ledger, its conversation and a worker slot still its own, for a person to settle. Never terminal, never released.
  5. A restart reaps by the same rule (TerminateRecorded, then the same confirmation), and asks OwnsWorker first: a pid is not an identity, so ownership is the pid AND the kernel's own start time for it, compared exactly. Everything that acts on a recorded worker asks OwnsWorker rather than testing a pid of its own: in this card, recovery (through TerminateRecorded) and the release point's second confirmation; any later one — status, redispatch, discard, hold — the same way. Every group signal that follows the first asks again (signalRecordedGroup): ownership established before a grace period is not ownership after it, because a pid freed during the grace can be leading another group by the time the kill goes out.

The one thing this cannot cover is a descendant that leaves the group by calling setsid: it is outside every group signal, and the connector can only avoid waiting on it (WaitDelay, CloseStdout). Containment is the sandbox launcher's job, not this rule's.

Cards that start workers or settle records use the functions here rather than writing their own.

What a driver promises, and where each promise can still be broken

The rule above is about the release point. These are the promises the rest of the boundary makes, each with the paths that can still break it named, so a reader does not have to take "held everywhere" on trust.

## A worker's lifetime

  • After a start returns a Session, a process group exists whose leader is the worker, and the connector owns it: Process() names it, and nobody else may signal it.
  • After a start returns an ERROR, no process of that session exists. Either none was started, or the driver ended the one it started, whole group, before returning (Driver.NewSession). ErrNotStarted says more: none ever existed, so the connector may retry the start once.
  • Cancel ends the turn, not the worker, and never blocks on a worker that has stopped reading its input: it gives up instead, and says so.
  • Close ends the session and its group — signal, bounded wait, kill — and is idempotent. It never waits on the worker's cooperation.
  • A worker that goes with a turn in flight is classified by how it went: one that exited on its own with a non-zero status FAILED, and one that vanished — signaled by someone else, or gone with no status the connector observed — is LOST.
  • Descriptors have an owner too. A start that fails closes every descriptor it opened; a terminated worker's output pipe is closed by the Worker once its reader has had the same bound to drain it that Wait gives a stray descendant, whether or not the reader closed it.
  • After a crash of the connector, the group survives. A later process identifies it by OwnsWorker (pid AND recorded start time), ends it with TerminateRecorded, and confirms with ConfirmGroupGone before anything is settled or released.

Where this can still be broken: a descendant that calls setsid leaves the group and no signal reaches it (there is no portable way to see it, and containment is the sandbox launcher's); a driver that returns an error after leaving a process behind breaks the start promise, which is why it is written on the method rather than left to each driver; and on a platform where process start times cannot be read — or for a worker whose start time the kernel would not give — OwnsWorker refuses to answer and nothing may be settled; the run command refuses to start on such a platform at all.

## Credentials

Two secrets exist around a worker, and each has one carriage.

  • The agent's Basecamp credential stays in the CLI's credential store. It is never in any environment, argv, file or log the connector writes; the worker's MCP server, running as the agent's profile, reads it from that store itself.
  • A task token lives from LaunchTask to the end of its task. The ledger keeps only its hash. It crosses only to the worker's MCP server, and never to the agent process: the dispatcher serves it over a unix socket in the attempt's owner-only runtime directory, once per start of that server (an MCP host that restarts a stdio server re-runs it, so the bridge asks again) and at most connector.MaxTokenHandoffs times, only to a peer of this user in the worker's process group or descended from its leader (connector.ServeTaskToken), and `basecamp connect worker-mcp` passes it on to `basecamp mcp` over an inherited descriptor. It is never in an environment, never in argv, never in a file, and never in a log or a dispatch line.
  • The agent's own credential (ANTHROPIC_API_KEY, where one is used) is in the agent's environment because the agent needs it, and nowhere else the connector writes.

drivertest.RequireNoSecret and RequireNoSecretFilesDuring are the checks: the environment, argv, written text, and — watched continuously, so a file that lives milliseconds is still caught — every file under the working and session directories. What comes back OUT of a worker is the redaction rule's (redact.go), and drivertest.RequireRedacted is its check.

Where this can still be broken: an agent may copy what it was handed anywhere its tools can write, and any process of this user in the worker's group could take the token first — the group is the agent's own tree.

## The environment a worker and its MCP servers get

  • The connector owns both. SessionConfig.Env is the worker's whole environment and MCPServer.Env is each server's, and each is an allowlist the dispatcher built by name (BuildEnv over BaseEnv, plus the variables a driver names for its own agent).
  • No credential is in either: the agent's Basecamp credential stays in the CLI's store, and the task token travels over the socket.
  • No secret is ever in argv, which every process on the machine can read.

Where this can still be broken: an agent may ADD to the environment it hands its MCP servers — Claude Code passes its own whole environment down, which carries the agent's own credentials — so the declared environment is a floor, not a ceiling. The bridge (`basecamp connect worker-mcp`) execs `basecamp mcp` with the declared environment only, so the connector's own server does not keep them; a third-party MCP server the operator adds to a worker would inherit them regardless, and the connector ships none.

## When an attempt may be adopted, settled or released

  • Adoption links a reply to an event; it is never evidence that work finished, and never makes an outcome succeeded. It needs exactly one reply by the agent at that destination after the event's own acknowledgement and before any later instruction's, it is never the worker's own acknowledgement, and a listing the scan limit cut short adopts nothing.
  • An attempt is settled and its record made terminal at one point (Dispatcher.release), and only after the group is confirmed gone and the ledger has taken the settlement.
  • An attempt that cannot be confirmed or cannot be settled stays live and holds its conversation and one of the connector's worker slots, until a person settles it.

Where this can still be broken: adoption trusts Basecamp's ordering of replies against this machine's clock for "after the acknowledgement", so a clock far behind the server's could see a reply as later than it was — the exactly-one rule and the acknowledgement exclusion are what keep that from mattering; and a person who writes to the ledger by hand can of course strand anything.

Worker is a process a spawn driver started: the leader of its own process group, with its stdin and stdout piped and its stderr kept, redacted, for diagnosis. Every spawn driver starts its agent through StartWorker, so the rules for processes (invariants 1, 4 and 5) live in one place.

func StartWorker

func StartWorker(ctx context.Context, cfg SessionConfig, cmd Command) (*Worker, error)

StartWorker launches cmd through cfg's launcher, in cfg's scope, as a new process group. An error wrapping ErrNotStarted means no process exists; StartWorker returns no other error.

The whole of cfg is taken rather than its launcher and scope so that the announcement of the worker (cfg.Started, driver invariant 7) is made here, once, for every driver: the connector cannot arm the task token's socket until it knows the process group, and a driver that announced it only when its handshake returned would deadlock an agent whose handshake waits on its MCP servers.

func (*Worker) CloseStdout

func (w *Worker) CloseStdout()

CloseStdout closes the worker's output: a reader blocked on it returns, and the descriptor is released. For a worker that is gone while a descendant that left its group still holds the pipe.

func (*Worker) Done

func (w *Worker) Done() <-chan struct{}

Done is closed once the process has exited and been reaped.

func (*Worker) Exit

func (w *Worker) Exit() Exit

Exit is how it exited; meaningful once Done is closed.

func (*Worker) Process

func (w *Worker) Process() Process

Process is the worker's process.

func (*Worker) StderrLines

func (w *Worker) StderrLines(r *Redactor) []string

StderrLines is what may be passed on of the worker's stderr when its last line is not enough — a refusal the agent wrote before it wrote anything else — through r (Redactor.Lines): bounded in lines and in bytes, each sanitized, never the text verbatim.

func (*Worker) StderrTail

func (w *Worker) StderrTail(r *Redactor) string

StderrTail is what may be passed on of the worker's stderr, through r (Redactor.Stderr): never the text verbatim.

func (*Worker) Stdin

func (w *Worker) Stdin() io.WriteCloser

Stdin is the worker's standard input.

func (*Worker) Stdout

func (w *Worker) Stdout() io.Reader

Stdout is the worker's standard output. Read it to end of file.

func (*Worker) Terminate

func (w *Worker) Terminate(grace time.Duration)

Terminate ends the process group: SIGTERM, grace, SIGKILL. It returns once the leader is reaped. Idempotent.

type WorkerProbe

type WorkerProbe struct {
	// Product is the worker's name for a person: "Claude Code".
	Product string
	// Binary is the program a session starts, found on PATH as a session
	// finds it.
	Binary string
	// Env is the environment a session gets.
	Env []string
	// Help is the arguments that list the flags Flags must be among.
	Help []string
	// Flags are the flags a session passes.
	Flags []string
	// Update is how a person updates the worker.
	Update string
	// Login is the arguments that ask whether the worker is logged in, with
	// no model call.
	Login []string
	// LoggedIn reads Login's answer: whether the worker said it is logged in,
	// and whether it said anything that could be read.
	LoggedIn func(ProbeResult) (loggedIn, known bool)
	// LoginFix is what a person does when the worker is logged out.
	LoginFix string
	// LoginWarns is a login check that never fails: its answer is a guess.
	LoginWarns bool
	// Timeout bounds each run; PreflightTimeout when zero.
	Timeout time.Duration
	// Run runs one probe; RunProbe when nil. A test seam.
	Run func(ctx context.Context, cmd Command, timeout time.Duration) ProbeResult
}

WorkerProbe is how one driver's preflight runs.

func (WorkerProbe) Check

func (w WorkerProbe) Check(ctx context.Context) Preflight

Check runs the preflight: the start first, and the rest only when it started.

Jump to

Keyboard shortcuts

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