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
- Variables
- func AdapterNames() []string
- func CheckInstalled(look func(string) (string, error), specs ...Spec) error
- func CheckPermissions(mode Permissions, specs ...Spec) error
- func PermissionsNotice(mode Permissions, profile string, agents ...string) []string
- func SaveStart(path string, orchestrator, worker Spec, mode Permissions) error
- func SocketPath(dir string) string
- type Adapter
- type Config
- type DispatchRequest
- type Event
- type EventKind
- type Options
- type Permissions
- type Request
- type Response
- type Role
- type Route
- type Rule
- type Runner
- func (r *Runner) Deliver(_ context.Context, line connector.HandoffLine) error
- func (r *Runner) Dispatch(ctx context.Context, req DispatchRequest) (view WorkerView, existing bool, err error)
- func (r *Runner) Ready() <-chan struct{}
- func (r *Runner) Run(ctx context.Context) error
- func (r *Runner) Say(ctx context.Context, message string) (string, error)
- func (r *Runner) Send(id, message string) (WorkerView, error)
- func (r *Runner) Status() Status
- func (r *Runner) StopWorker(id string) (WorkerView, error)
- func (r *Runner) Workers() []WorkerView
- type Spec
- type Status
- type Turn
- type TurnResult
- type Usage
- type WorkerView
Constants ¶
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.
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.
const ( OpDispatch = "dispatch" OpWorkers = "workers" OpSend = "send" OpStopWorker = "stop_worker" OpSay = "say" OpStop = "stop" )
Operations a Request names.
const ConfigFile = "runner.json"
ConfigFile is the routing file's name, beside connect.json in the profile's directory.
const StateFile = "state.json"
StateFile is the runner's state, in its directory.
Variables ¶
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.
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 CheckInstalled ¶
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 ¶
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 ¶
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 ¶
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 ¶
NeedsRecordings reports whether any rule asks what a request sits under, which costs reads of Basecamp to answer.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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) 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 ¶
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.
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.