runner

package
v0.28.1 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package runner is the connector doing the work itself, with whichever coding agent the person chose: `basecamp connect -P <profile> --orchestrator claude --worker codex`.

What it stands in for

The basecamp-connect skill runs in a Claude Code session and leans on two things only that harness has: a monitor that wakes the session for each request line, and background subagents. The runner supplies both from the CLI, so the same skill runs on any coding agent that can do three things: take a prompt without a terminal, run shell commands, and continue an earlier session.

  • The monitor is the runner's inbox. Each trusted request the connector hands off is kept in the runner's state and delivered to the orchestrator as one turn of a headless session, resumed by its id.
  • The background subagent is a worker: a headless session of its own, in the repo the orchestrator chose, started by `basecamp connect dispatch` and reported back to the orchestrator as another turn. Unlike a subagent, a worker is kept: a later request on the same card, to-do, message or chat is given to the worker that handled the first, in the same session, and a new one starts only when that session cannot take it.

What stays where it was

The workflow is the skill's two files, verbatim (instructions.go adds only the table that maps the mechanisms they name onto these). Trust, served projects, the acknowledgement boost and the ledger are the connector's, and nothing here reads a request the connector did not hand off. Everything an agent posts goes through the `basecamp` CLI with the agent's profile, as it does from the skill.

Adapters

An Adapter is one coding agent's command line: the arguments of a turn and how to read its event stream. Claude Code, Codex and Pi are built in. A turn is one process, started through driver.StartWorker so it is the leader of a process group the runner can end whole.

What is durable, and what is not

The runner's state file holds the inbox, the orchestrator's session id and every worker. A request is written there before the connector closes its record, so a crash between the two repeats the request rather than losing it, provided the connector starts again within the handoff's grace of a minute: a request older than that at a start is not run late, with or without a runner. The repeat is dropped by its event id. A worker that was running when the connector stopped is resumed in its own session at the next start and told it was interrupted, with the request it was last given: a stop just after its turn began may have come before the session got that request. What it had already done (a post, a commit) is the session's to recognize, as it is for the skill after a crash.

A stop ends every agent process it started. A crash ends none, so the state names the process of each turn in flight, and a start that finds one named ends it, by its process group, before it continues that session: a request is not worked on by two processes at once. A start that cannot confirm one ended does not start at all, and says which process a person has to end. A turn is held to the same: its process is named before the agent is told anything, and the name goes only once nothing of its process group is left. A session whose last turn left something is not given another.

Agents run unattended. Under full permissions each adapter starts its CLI with permission prompts off; under guarded permissions it starts the CLI under that CLI's own guardrails. Who may give the agent work is `basecamp connect setup`'s.

Index

Constants

View Source
const (
	// DefaultMaxWorkers is how many workers run at once.
	DefaultMaxWorkers = 4
	// DefaultOrchestratorTimeout bounds one orchestrator turn: it
	// acknowledges and hands off, and never does the work.
	DefaultOrchestratorTimeout = 15 * time.Minute
	// DefaultWorkerTimeout bounds one worker turn.
	DefaultWorkerTimeout = 3 * time.Hour
)

Defaults a Config or an Options leaves unset.

View Source
const (
	SocketEnv  = "BASECAMP_CONNECT_RUNNER_SOCKET"
	ProfileEnv = "BASECAMP_CONNECT_RUNNER_PROFILE"
)

Variables an agent is started with that say which runner started it, for the commands it runs against that runner: `basecamp connect dispatch` and the rest. They reach the runner's socket by them, without reading the profile's connect.json, whose ownership cannot be checked from inside a coding agent's sandbox. Knowing where the socket is grants nothing: the socket's own permissions decide who may use it.

View Source
const (
	OpDispatch   = "dispatch"
	OpWorkers    = "workers"
	OpSend       = "send"
	OpStopWorker = "stop_worker"
	OpSay        = "say"
	OpStop       = "stop"
)

Operations a Request names.

View Source
const ConfigFile = "runner.json"

ConfigFile is the routing file's name, beside connect.json in the profile's directory.

View Source
const StateFile = "state.json"

StateFile is the runner's state, in its directory.

Variables

View Source
var ErrNotRunning = errors.New("no connector is running with an orchestrator for this profile")

ErrNotRunning is a call on a socket nothing listens on: no connector is running with an orchestrator for the profile.

View Source
var ErrTurnFailed = errors.New("the turn failed")

ErrTurnFailed wraps a turn the CLI ran and reported failed, as opposed to one that could not be started.

Functions

func AdapterNames

func AdapterNames() []string

AdapterNames lists the built-in adapters.

func CheckInstalled

func CheckInstalled(look func(string) (string, error), specs ...Spec) error

CheckInstalled reports the first of specs whose coding agent's CLI is not on PATH, so a connector that could never run a turn says so as it starts rather than at its first request. A nil look means exec.LookPath.

func CheckPermissions

func CheckPermissions(mode Permissions, specs ...Spec) error

CheckPermissions reports the first of specs whose coding agent cannot be started under mode: one with no guardrails of its own, under guarded.

func PermissionsNotice

func PermissionsNotice(mode Permissions, profile string, agents ...string) []string

PermissionsNotice says, in sentences a person can act on, what the agents a connector starts may do on this computer under mode, for the coding agents named, and how to change it.

func SaveStart

func SaveStart(path string, orchestrator, worker Spec, mode Permissions) error

SaveStart keeps what a connector was started with in runner.json, beside whatever else the file holds, and creates the file when there is none. A file that cannot be read as written is left alone and reported: rewriting it would drop whatever the person meant by it.

func SocketPath

func SocketPath(dir string) string

SocketPath is where the runner in dir listens, for whoever calls it. It is inside dir, unless that path is longer than a socket address can be. Then it is where the running connector said it listens (socketPointer), so a caller whose environment is not the connector's still finds it, and failing that where a start in this environment would listen.

Types

type Adapter

type Adapter interface {
	// Name is the adapter's name as flags and runner.json spell it.
	Name() string
	// Binary is the CLI's executable, looked up on PATH.
	Binary() string
	// NewSessionID is the id a new session is started under, for a CLI that
	// lets its caller choose one, and empty for one that names its own
	// sessions. A chosen id is not a session until the CLI's stream says so.
	NewSessionID() string
	// Args are the arguments of one turn. The prompt is not among them: it
	// goes in on standard input, so nothing a person wrote is ever in argv.
	Args(t Turn) []string
	// Parse reads one line of the CLI's standard output. A line it does not
	// know is no events and no error: the streams grow new line types.
	Parse(line []byte) []Event
	// Guarded reports whether the CLI has guardrails of its own to run
	// unattended under. One without cannot be started under
	// PermissionsGuarded: running it wide open under that name would be
	// worse than refusing.
	Guarded() bool
}

Adapter is one coding agent's command line: what a turn of a headless session is started with, and how its event stream reads. An adapter starts nothing and keeps nothing between turns; the runner does both.

Under PermissionsFull an adapter starts its CLI with permission prompts off: a headless turn has nobody to answer one, and an agent that cannot run `basecamp` or `git` can neither read its request nor reply to it. Under PermissionsGuarded it starts the CLI under that CLI's own unattended guardrails, where it has any (Guarded).

type Config

type Config struct {
	Orchestrator Spec `json:"orchestrator,omitzero"`
	Worker       Spec `json:"worker,omitzero"`
	// Permissions is what agents may do on this computer: "full" or
	// "guarded". Empty means full. It is read again for every turn.
	Permissions Permissions `json:"permissions,omitempty"`
	// MaxWorkers is how many workers run at once. Zero means DefaultMaxWorkers.
	MaxWorkers int `json:"max_workers,omitempty"`
	// Rules are tried in order and the first that matches wins. What its
	// worker leaves out comes from the default worker.
	Rules []Rule `json:"rules,omitempty"`
}

Config is runner.json: which coding agent does what. It is the person's own file, written by hand, and read again for every worker, so an edit applies to the next request without a restart.

Nothing in it is required. Orchestrator, Worker and Permissions are what the connector was last started with, kept so the next start need not name them again (SaveStart); a start that names one wins over what is kept. Rules then choose a worker by where the request came from and which repo its work goes in.

func LoadConfig

func LoadConfig(path string) (Config, error)

LoadConfig reads runner.json. A missing file is the empty Config and no error: the file is optional. One that cannot be read as written is an error, never a guess: a typo in a key would otherwise run a request on the wrong agent in silence.

func ParseConfig

func ParseConfig(data []byte) (Config, error)

ParseConfig reads runner.json's bytes and refuses what it would misread.

func (Config) Mode

func (c Config) Mode() Permissions

Mode is the permissions in force: what the file says, or full when it says nothing.

func (Config) NeedsRecordings

func (c Config) NeedsRecordings() bool

NeedsRecordings reports whether any rule asks what a request sits under, which costs reads of Basecamp to answer.

func (Config) WorkerFor

func (c Config) WorkerFor(base Spec, route Route, home string) (Spec, string)

WorkerFor is the worker a request runs on: the first rule that matches route, laid over base, and which rule that was. With no match it is base.

type DispatchRequest

type DispatchRequest struct {
	// EventID is the request the worker is for. Zero is work the owner asked
	// for at this computer, which no request line carries.
	EventID int64 `json:"event_id,omitempty"`
	// Repo is the directory the worker starts in; empty with NoRepo.
	Repo   string `json:"repo,omitempty"`
	NoRepo bool   `json:"no_repo,omitempty"`
	// Spec is what the dispatch itself asked for, over every rule.
	Spec Spec `json:"spec,omitzero"`
	// Handoff is everything the worker is told.
	Handoff string `json:"handoff"`
	// Again starts a new worker for an event that already has one.
	Again bool `json:"again,omitempty"`
}

DispatchRequest asks for a worker: `basecamp connect dispatch`.

type Event

type Event struct {
	Kind      EventKind
	SessionID string
	Model     string
	// Text is what was said (EventText), the turn's answer when the CLI
	// repeats it (EventResult), or the failure (EventError).
	Text string
	// Tool is the tool's name (EventTool).
	Tool string
	// Failed says the turn ended in an error (EventResult).
	Failed bool
	Usage  Usage
	// SessionCost says Usage.CostUSD is what the whole session has cost so
	// far, not what this turn did: Claude Code reports a running total.
	SessionCost bool
	// Refused names the tools whose calls the CLI refused during the turn,
	// one entry per call (EventResult), where the CLI says. Prompts are off,
	// but a rule the person wrote to deny something still holds.
	Refused []string
}

Event is one thing an agent's stream said.

type EventKind

type EventKind int

EventKind names what a line of an agent's stream said.

const (
	// EventSession is the CLI confirming the session, with its id.
	EventSession EventKind = iota + 1
	// EventText is something the agent said. The last one of a turn is its
	// answer unless an EventResult carries one.
	EventText
	// EventTool is the agent starting a tool call.
	EventTool
	// EventResult is the end of the turn.
	EventResult
	// EventError is the CLI reporting a failure. A turn that reports one and
	// no successful result after it has failed.
	EventError
	// EventUsage is cost reported apart from the turn's end.
	EventUsage
)

type Options

type Options struct {
	// Profile is the agent's CLI profile.
	Profile string
	// Dir is the runner's own directory, inside the connector's state
	// directory and as private.
	Dir string
	// Orchestrator and Worker are what the connector was started with. Worker
	// may be partial or empty: what it leaves out comes from runner.json's
	// worker, then from Orchestrator.
	Orchestrator Spec
	Worker       Spec
	// ConfigPath is runner.json.
	ConfigPath string
	// Skill and Subagent are the basecamp-connect skill's two files.
	Skill    string
	Subagent string
	// ConnectorLog is the file the connector's output lines are kept in.
	ConnectorLog string
	// Executable is this CLI's own binary. Agents find it first on their
	// PATH as `basecamp`, so the commands the instructions name are this
	// build's.
	Executable string
	// StartLine is what the connector says of itself when it starts: the
	// profile and whether it answers Pings.
	StartLine string
	// Recordings lists a request's recording and everything above it, for
	// rules that ask what a request sits under. Nil answers with nothing.
	Recordings func(ctx context.Context, project, recordingID int64, recordingType string) []int64
	// Held reports whether the connector's hold stands. While it does, no
	// agent is started. Nil means it never does.
	Held func(ctx context.Context) (bool, error)
	// Stop ends the connector, to start again when restart is set.
	Stop   func(restart bool)
	Logger *slog.Logger

	OrchestratorTimeout time.Duration
	WorkerTimeout       time.Duration

	// Environ is the environment agents start from. Nil means os.Environ.
	Environ func() []string
	// LookPath finds an adapter's CLI. Nil means exec.LookPath.
	LookPath func(string) (string, error)
	// Now is the clock. Nil means time.Now.
	Now func() time.Time
	// Retry is the wait before a failed orchestrator turn is tried again,
	// by how many have failed in a row. Nil means retryDelay.
	Retry func(failures int) time.Duration
}

Options configure a Runner.

type Permissions

type Permissions string

Permissions is what the agents the connector starts may do on this computer. It is one switch for every coding agent, and each adapter maps it onto its own CLI.

const (
	// PermissionsFull lets an agent run any command and change any file, as
	// the person, without asking: each CLI is started with its permission
	// prompts off. It is the default, because a headless turn has nobody to
	// answer a prompt.
	PermissionsFull Permissions = "full"
	// PermissionsGuarded starts each CLI under its own unattended
	// guardrails: Claude Code's auto mode, which judges each action and
	// stops the ones it finds unsafe, and Codex's sandbox, which lets it
	// write only in the folder it works in. A CLI with neither cannot be
	// started under it (Adapter.Guarded).
	PermissionsGuarded Permissions = "guarded"
)

func ParsePermissions

func ParsePermissions(value string) (Permissions, error)

ParsePermissions reads the switch as flags and runner.json spell it.

type Request

type Request struct {
	Op       string          `json:"op"`
	Dispatch DispatchRequest `json:"dispatch,omitzero"`
	// Worker is the worker a send or a stop names.
	Worker string `json:"worker,omitempty"`
	// Message is what a send tells a worker, or what say tells the
	// orchestrator.
	Message string `json:"message,omitempty"`
	// Restart asks a stop to start the connector again.
	Restart bool `json:"restart,omitempty"`
}

Request is one call on the socket.

type Response

type Response struct {
	OK    bool   `json:"ok"`
	Error string `json:"error,omitempty"`

	Worker *WorkerView `json:"worker,omitempty"`
	// Existing says a dispatch returned the worker its event already had.
	Existing bool         `json:"existing,omitempty"`
	Workers  []WorkerView `json:"workers,omitempty"`
	// Reply is the orchestrator's answer to say.
	Reply  string  `json:"reply,omitempty"`
	Status *Status `json:"status,omitempty"`
}

Response answers a Request.

func Call

func Call(ctx context.Context, path string, req Request, timeout time.Duration) (Response, error)

Call makes one call on the runner listening at path (SocketPath). A zero timeout waits as long as the answer takes, which for say is as long as the orchestrator's turn.

type Role

type Role string

Role is which of the runner's two kinds of session a turn belongs to.

const (
	RoleOrchestrator Role = "orchestrator"
	RoleWorker       Role = "worker"
)

type Route

type Route struct {
	// Repo is the directory the worker starts in, absolute; empty with none.
	Repo string
	// Project is the request's project; zero for a Ping or with no request.
	Project int64
	Ping    bool
	// Recordings are the request's recording and everything above it.
	Recordings []int64
}

Route is what a rule is matched against: where a request came from and where its work goes.

type Rule

type Rule struct {
	// Repo is the local repo the orchestrator chose, or a folder above it.
	// A leading ~ is the person's home.
	Repo string `json:"repo,omitempty"`
	// Project is the Basecamp project the request was made in.
	Project int64 `json:"project,omitempty"`
	// CardTable, Todolist and Within match a request made on, or anywhere
	// under, the recording with that id: a card in that card table, a to-do
	// in that list, a comment on either. Within takes any recording's id.
	CardTable int64 `json:"card_table,omitempty"`
	Todolist  int64 `json:"todolist,omitempty"`
	Within    int64 `json:"within,omitempty"`
	// Ping matches a request made in a Ping, which is in no project.
	Ping bool `json:"ping,omitempty"`

	Worker Spec `json:"worker"`
}

Rule gives requests that match a worker of their own. Every condition a rule names must hold; a rule that names none is refused.

type Runner

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

Runner delivers the connector's requests to an orchestrator session and runs the workers it dispatches.

func New

func New(opts Options) (*Runner, error)

New builds a Runner over the state in opts.Dir, creating the directory's contents as needed. Nothing is started until Run.

func (*Runner) Deliver

func (r *Runner) Deliver(_ context.Context, line connector.HandoffLine) error

Deliver takes one trusted request from the connector's handoff and keeps it. It returns once the request is in the state file, so the connector closes its record only for a request the runner still has after a crash. A request delivered twice is kept once.

func (*Runner) Dispatch

func (r *Runner) Dispatch(ctx context.Context, req DispatchRequest) (view WorkerView, existing bool, err error)

Dispatch gives a request to a worker and returns at once. A request that already has a worker gets that worker back, with existing true, unless the dispatch asks for another.

A request made on a thread an earlier worker handled goes to that worker, continued in its session, so what it did before is in front of it: the worktree, the branch, what it found. That needs the earlier work to have been in the same repo and on the same coding agent, since a session lives in the directory and the CLI it was started in. Without either, and with --again, a new worker starts. A session that turns out not to open any more is replaced by a new one too, when its turn comes (work).

func (*Runner) Ready

func (r *Runner) Ready() <-chan struct{}

Ready is closed once Run is serving: the socket answers and the workers the last stop interrupted are running again.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context) error

Run serves the runner's socket, continues the workers a stop interrupted, and gives the orchestrator its messages one turn at a time, until ctx ends. Stopping ends every running turn; their sessions are continued at the next start.

func (*Runner) Say

func (r *Runner) Say(ctx context.Context, message string) (string, error)

Say gives the orchestrator a message from the person at this computer and waits for its answer.

func (*Runner) Send

func (r *Runner) Send(id, message string) (WorkerView, error)

Send tells a worker something more. A worker that is running hears it after its turn; one that has finished is continued in its session.

func (*Runner) Status

func (r *Runner) Status() Status

Status is the runner as it is now.

func (*Runner) StopWorker

func (r *Runner) StopWorker(id string) (WorkerView, error)

StopWorker ends a worker: its running turn is stopped and nothing more is sent to it.

func (*Runner) Workers

func (r *Runner) Workers() []WorkerView

Workers lists the workers the state still holds, oldest first.

type Spec

type Spec struct {
	Adapter string `json:"adapter,omitempty"`
	Model   string `json:"model,omitempty"`
	Effort  string `json:"effort,omitempty"`
}

Spec names the coding agent a session runs on: the adapter that drives its CLI, and the model and reasoning effort handed to it. An empty model or effort leaves that choice to the CLI's own configuration.

func (Spec) Over

func (s Spec) Over(base Spec) Spec

Over lays s on base: what s names wins. A model belongs to its adapter, so a change of adapter does not carry base's model across; effort levels read the same everywhere, so base's effort stays unless s names one.

func (Spec) String

func (s Spec) String() string

String is the spec as a person reads it: "codex · gpt-6 · high".

func (Spec) Validate

func (s Spec) Validate(partial bool) error

Validate refuses a spec no adapter could run. An empty adapter is refused unless partial, which a rule's spec is: it names only what it changes.

type Status

type Status struct {
	Profile      string `json:"profile"`
	Orchestrator Spec   `json:"orchestrator"`
	// Session is the orchestrator's session id, once its CLI confirmed one.
	Session string `json:"session,omitempty"`
	Turns   int    `json:"turns"`
	// Worker is the agent a request runs on when no rule says otherwise.
	Worker Spec `json:"worker"`
	// Waiting counts messages the orchestrator has not handled yet.
	Waiting int `json:"waiting"`
	// SetAside counts messages it failed to handle after every retry.
	SetAside int `json:"set_aside"`
	Running  int `json:"running"`
	Queued   int `json:"queued"`
	// ConfigError says runner.json cannot be read as written, and why. The
	// worker shown is then not the one a request would get: no agent is
	// started until the file is fixed.
	ConfigError string `json:"config_error,omitempty"`
}

Status is the runner as `basecamp connect workers list` and a person checking on it see it.

type Turn

type Turn struct {
	Role Role
	Spec Spec
	// Resume is the id of the session to continue. Empty starts a new one,
	// under NewID where the adapter chooses ids.
	Resume string
	NewID  string
	// Dir is the directory the session runs in. A session is continued from
	// the directory it was started in.
	Dir string
	// Permissions is what the agent may do. Empty means PermissionsFull.
	Permissions Permissions
}

Turn is one prompt of one session.

type TurnResult

type TurnResult struct {
	// SessionID is the session the CLI confirmed, which is the one to resume.
	// Empty when the turn failed before the CLI opened one.
	SessionID string
	Model     string
	// Text is the agent's answer: what it said last.
	Text string
	// Usage is what the turn cost, where the CLI says so per turn.
	Usage Usage
	// SessionCostUSD is what the session has cost in all, for a CLI that
	// reports that instead; zero for one that does not.
	SessionCostUSD float64
	// Refused names the tools whose calls the CLI refused, one entry per
	// call, where the CLI says.
	Refused []string
}

TurnResult is a finished turn.

type Usage

type Usage struct {
	InputTokens  int64   `json:"input_tokens,omitempty"`
	OutputTokens int64   `json:"output_tokens,omitempty"`
	CostUSD      float64 `json:"cost_usd,omitempty"`
}

Usage is what a turn cost, as far as the CLI says.

type WorkerView

type WorkerView struct {
	ID      string `json:"id"`
	EventID int64  `json:"event_id,omitempty"`
	State   string `json:"state"`
	Adapter string `json:"adapter"`
	Model   string `json:"model,omitempty"`
	Effort  string `json:"effort,omitempty"`
	Repo    string `json:"repo,omitempty"`
	// Matched is the runner.json rule that chose the agent, when one did.
	Matched string `json:"matched,omitempty"`
	// Requests counts the requests it has been given: the first, and each
	// later one on the same thread.
	Requests int `json:"requests"`
	// Doing is the tool a running worker called last.
	Doing string `json:"doing,omitempty"`
	// Refused counts the tool calls its coding agent refused, by a rule of
	// its own settings.
	Refused   int        `json:"refused,omitempty"`
	Report    string     `json:"report,omitempty"`
	Error     string     `json:"error,omitempty"`
	CreatedAt time.Time  `json:"created_at"`
	StartedAt *time.Time `json:"started_at,omitempty"`
	EndedAt   *time.Time `json:"ended_at,omitempty"`
	CostUSD   float64    `json:"cost_usd,omitempty"`
}

WorkerView is a worker as `basecamp connect workers list` shows it.

Jump to

Keyboard shortcuts

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