runtime.Provider operation
to a user-supplied script. This allows any terminal multiplexer or process
manager to be used as a session backend without writing Go code.
Usage
Set theGC_SESSION environment variable to exec:<script>:
Pack-Declared Runtimes
A pack can ship (or install) a runtime executable and bind it to a selection name inpack.toml:
city.toml selects it like a builtin:
- A
commandcontaining a path separator resolves relative to the pack directory; a bare name resolves on PATH at session start. protocoldeclares the RPP version the executable speaks (version 0 is the only version today); any other value fails composition.- Name collisions with builtin runtimes or other packs are composition errors — no silent shadowing. Identical re-declarations of the same pack reached through a diamond import graph dedupe.
- The
pack-runtimesdoctor check verifies each declared executable is installed and that its RPP handshake works. An executable with noprotocolop is the version-0 floor and passes; a present-but-broken handshake fails. - Config reload enforces the same registration rules, and rebuilds the session provider when the declaration behind the selected name changes (the executable binding is fixed at provider construction).
gc runtime check <name>andgc runtime conformance <name>both resolve the declared name and run against the pack’s executable:checkis the smoke test,conformancethe full requirement-coded suite.
Calling Convention
The script receives the operation name as its first argument:Exit Codes
Exit code 2 is the forward-compatibility mechanism. When Gas City adds new
operations in the future, old scripts return exit 2 and the provider treats
it as a no-op success. Scripts only need to implement the operations they
care about. An operation a script declares in its
handshake cannot be unknown, so declared
operations do not read exit 2 as success: under
proc.exec, exec returns it
as the command’s own exit code, and under proc.provision and
report-attachment, provision and is-attached report it as a failure.
start cannot be unknown either, so its exit 2 (for example a helper’s
grep or jq exit status escaping a set -e script) is a failed start.
Cancellation
Each invocation runs in its own process group. When an operation is canceled (its timeout expires, or Gas City abandons the call), the script is stopped in two steps:
A canceled call is reported as canceled whatever the script’s exit code, so a
TERM handler may exit with any status, including 2.
Trap TERM (or EXIT, which bash also runs when TERM ends the script) to
clean up. SIGINT is not sent: a script that traps only INT gets no chance
to roll back. Keep the rollback inside the grace period; prefer one bounded
step (for example docker rm -f <id>) over a graceful stop that waits out its
own timeout.
start and provision get 120 seconds, attach
none (it holds your terminal), and watch-startup runs only while gc clears
startup dialogs. Every other operation gets 30 seconds, and a caller can
cancel a call sooner. Two operations are stopped differently from the steps
above: a canceled exec or watch-startup call is killed outright, with
SIGKILL to the script process and no SIGTERM first.
Operations
Box without agent (the un-weld).
provision is start MINUS the agent
launch: it creates/prepares the box (PreStart, SessionSetup, SessionSetupScript,
SessionLive — every box step start runs EXCEPT spawning the agent in tmux) and
returns. The controller then launches the agent itself by exec-ing tmux new-session / respawn-pane -k over the exec op, so a launch-only config
change relaunches the agent in the warm box instead of reprovisioning (B2.3). A
pack opts in by declaring proc.provision (and proc.exec, since the controller
drives the launch over exec); without it, the welded start op provisions and
launches as before, and the controller issues no provision/launch. The op is
gated by the RPP-PROVISION-001 conformance requirement.
The connection primitive (slim RPP). exec is the connection op
(RPP-CONN-001): a carrier drives a box through exec rather than via
dedicated driving ops, and any runtime that declares an env.* capability
already implements it. It is optional for now — conformance verifies it
only when present (the output reaches the caller and the op exit mirrors the
command’s exit code) — and becomes required as Gas City moves its own input
delivery and observation onto exec. The dedicated driving ops (interrupt,
nudge, peek, clear-scrollback, send-keys) are reproducible over exec
and are deliberately NOT conformance requirements: gc now drives input and reads
output over exec (via the tmux carrier) when a runtime implements it, and
falls back to the dedicated driving ops when exec is unsupported
(RPP-CONN-001 answered exit 2). So a runtime that ships exec + tmux-in-box
needs none of the driving ops, while one that implements only the driving ops
keeps working via the fallback. (watch-startup is a streaming op the
request/response exec connection cannot carry, so it stays a dedicated op.)
Protocol Handshake (protocol)
The protocol operation declares which Runtime Provider Protocol version
the script speaks and which optional capabilities it implements:
protocol (exit 2) are treated as version 0
with no optional capabilities — every pre-handshake script remains valid.
Unknown capability strings are ignored, so scripts may declare
capabilities for newer Gas City versions without breaking older ones.
Malformed handshake JSON is an error: capability probes fall back to the
no-capability behavior and the failure is reported by conformance and
doctor checks.
Capabilities:
The handshake runs once per provider instance and is cached.
Idle sleep
An idle sandbox still costs money and capacity. With a[session_sleep]
policy (or an agent’s sleep_after_idle), the orchestrator stops a session
that has been idle long enough and starts it again when work, a nudge, or an
attach arrives. Which sessions a script-backed runtime can put to sleep
depends on its handshake:
full needs a safe idle boundary, so an interactive session is never stopped
in the middle of a turn. A runtime that declares all three must:
- Run the agent in the in-box tmux session
main. The orchestrator reads the pane overexec(tmux capture-pane -t main -p -S -120). It treats the session as idle after two consecutive captures, 200 ms apart, that show the agent’s ready prompt (ready_prompt_prefix, default❯) and no busy indicator such asesc to interrupt. This is the same check a local tmux session gets. - Answer
is-attachedhonestly. Printfalseand exit 0 for a session that does not exist. Exit non-zero only when you cannot tell; any non-zero exit, 2 included, keeps the session awake. - Treat
stopas the sleep. Sleeping a session callsstop, and waking it callsstart. Anything the runtime keeps only inside the box, such as the agent’s conversation, is lost across a sleep unlessstoppreserves it.
GC_READY_PROMPT_PREFIX is absent from the session. Treat 2.4 s and 1.6 s as
limits, not targets: a capture that ends exactly at the 5 s mark does not
count. A slower check fails closed: the session stays awake, and the check
runs again on the next orchestrator tick.
Once the agent’s tmux session exists, the orchestrator stores the session’s
ready_prompt_prefix in that session’s environment as
GC_READY_PROMPT_PREFIX (tmux -u set-environment -t main; a blank prefix
stores the default prompt, ❯ ), as local tmux sessions do. A process that
did not start the session, such as a restarted orchestrator or a gc command
that cannot reach the API, reads it back with tmux -u show-environment -t main and then remembers it. Like local tmux, gc passes -u to these
commands, so the prompt survives an exec environment without a UTF-8 locale.
Do not clear the main session’s environment. While the read fails or the
variable is absent, that process looks for the default prompt and reads again
on its next wait. The variable is absent when storing it failed (gc does not
retry; the next start stores it again) and for a session started by an older
gc. A session whose agent shows another prompt then never reads as idle. It
stays awake, and every wait for its idle boundary runs to its timeout:
interrupting it (interrupt_now) can restart the session, and stopping a turn
reports an error.
The is-attached rule above applies to every runtime that declares
report-attachment, even without the other two capabilities. gc runs the op
directly and treats any non-zero exit, including 2, as “attached”: the session
stays awake and destructive actions on it are held.
Fenced input over exec
A runtime that declares both proc.exec and input-fence takes fenced
input: gc sends a nudge, keys or an interrupt only to the agent incarnation it
is meant for. The fence is the pane option @gc_fence on the agent pane of
the in-box tmux session main, and gc manages it over exec. Declare
input-fence only for a runtime that:
- Runs tmux 3.0 or later in the box. gc sets and clears the fence with
set-option -p(pane options), which older tmux rejects. With older tmux everystartfails and the box is torn down, and a separable launch fails as a whole, because gc appends the fence command to the launch’s own tmux command list. - Has the
mainsession whenstartreturns. gc publishes the fence right afterstart. A publish that fails fails the start and tears the box down, unless the box was running before the attempt. - Provides
shin the box. gc reads the fence, sends guarded input and loads nudge text into a tmux buffer through shortsh -cscripts.
proc.exec without input-fence starts and takes
unfenced input as usual: gc touches no fence in its box, and refuses only a
fenced request to it.
Before a start of a box that may already be running, gc reads and clears
the pane’s fence, so a start that reuses the pane never leaves the previous
incarnation’s fence on it. When start, or the fence publish after it, then
fails and the box stays up (it reports that the session already exists, or
is-running said it was running before the attempt), gc puts the fence back
on the same pane, as long as the pane’s root process is unchanged and no
other fence has been set since.
Fenced input sends nothing until tmux in the box answers the fence read. A
live tmux server without the main session or its agent pane answers that
the session is absent. A command the box does not answer, because the exec
op fails (its own non-zero exit) or tmux in the box fails (no server, a
missing tmux, a socket it cannot reach), is an outage
(ErrRuntimeUnavailable), unless is-running then answers false, which
makes it an absent session. Either way, a failure after part of the input
went in (nudge text without its Enter) reports the input as partly
delivered. An exec op that is canceled or cannot be run at
all is an outage too, without asking is-running, and it does not say
whether part of the input went in before it stopped. A pane started before gc
published fences takes no fenced input until its agent is relaunched.
Start Config (JSON on stdin)
Thestart and provision operations receive a JSON object on stdin:
On a fresh start the agent’s startup prompt is not in the start config: gc
renders it as a command-line argument (
prompt_mode = "arg", the default) or
flag ("flag"), and the exec provider passes neither to the script, so the
agent starts without it. gc puts the prompt at the front of nudge instead in
three cases: with prompt_mode = "none", on a start that resumes an existing
conversation (any prompt mode), and for a prompt too large for a command line
on a runtime whose [runtimes.<name>] entry sets
prompt_delivery = "nudge-fallback". A script should handle any non-empty
nudge.
Session environment
env is what gc wants set in the session, to apply over the box’s own
environment. For a configured agent’s session it holds:
PATH,HOME,USER,LOGNAME,TZ, the locale and XDG directory variables, and a few Claude Code and telemetry settings, from gc’s own environment.- Every non-empty
GC_*variable in gc’s environment exceptGC_CONTROLLER_TOKEN, and any variable named inGC_SUPERVISOR_ENV. - Provider credentials from gc’s environment: every variable whose name starts
with a provider prefix such as
ANTHROPIC_,OPENAI_,GEMINI_,GOOGLE_,AZURE_orXAI_, and a fixed list of AWS variables (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN,AWS_REGION,AWS_PROFILEand others), not everyAWS_*variable. - The city’s
[workspace]env, then the provider’s, then the agent’s, then the session’s own variables (GC_AGENT,GC_ALIAS,GC_CITY,GC_DIR,GC_SESSION_IDand more), and last the agent’s selected upstream’s variables and a few gc-owned settings, the emptyGC_CONTROLLER_TOKENamong them.
GC_CONTROLLER_TOKEN among them, so that a value inherited from its
own environment does not reach the agent. The script itself runs with gc’s
environment, so a script that starts the agent as its own child must apply
those empty values too.
Provider credentials in the supervisor’s environment therefore reach every
session. GC_SUPERVISOR_OMIT_PROVIDER_CREDS=1 does not change that: it only
keeps them out of the launchd plist or systemd unit that gc generates, and
out of a gc supervisor run that gc forks. The bundled
contrib/session-scripts/gc-session-k8s copies env into the pod spec as
literal env values (it drops or rewrites a few GC_* keys), and Kubernetes
gives a literal env entry precedence over the same key from envFrom, so a
credential in the supervisor’s environment overrides one you supply through
envFrom.
Startup Hints
The JSON config contains fields that the tmux provider uses for multi-step startup orchestration. The exec provider does not firestart and forget it.
gc first runs is-running to learn whether a box already holds the name, then
runs start and waits for it to exit. After a successful start it can run
more operations before it counts the session started: it publishes the
input fence and the ready prompt for
a runtime that declares the capabilities they need, and when the start config
lists process_names or the provider declares a bypass-permissions dialog (a
provider’s accept_startup_dialogs setting overrides both) it dismisses known
startup dialogs, such as workspace trust, through watch-startup, peek and
send-keys (peek and send-keys go over exec when the runtime implements
it).
If start fails or times out, or a step after it fails, gc runs
stop <name> to tear down whatever the attempt created, then reports the
start as failed. It skips that stop when the box belongs to a live session:
when is-running printed true before the attempt, or when start exited
non-zero on its own with stderr saying the name is taken (it contains
already exists or already running). The stderr check does not apply to a
start that gc canceled at its deadline. A runtime that declares both
proc.provision and proc.exec gets provision in place of that
is-running and start: no stop follows a failed provision, and one
always follows a failed agent launch after it.
Scripts may handle these hints or ignore them:
-
process_names— the tmux adapter polls for these process names to appear in the session’s process tree (30s timeout) before considering the agent “started.” A script can implement this by polling its backend’s process tree after session creation, or ignore it for fire-and-forget behavior (like the subprocess provider does). -
lifecycle—"one_shot"marks a short-lived command that is expected to exit after handling its prompt. Providers should not require a persistent post-start process for one-shot starts. -
nudge— text that the tmux adapter types into the session after the agent is ready. gc does not send it through thenudgeoperation whenstartreturns, so a script that wants the agent to receive it types it in itself, instart, once the agent is ready. Later nudges, such asgc session nudge, go through thenudgeoperation, or overexecwhen the runtime implements it. -
pre_start— array of shell commands to run on the target filesystem before the session is created. Used for directory preparation, worktree creation, or other setup that must exist before the agent starts. Scripts should execute each command in the target environment before creating the tmux session. Non-fatal: warn on stderr if a command fails, but don’t abort start. -
session_setup— array of shell commands to run on the target filesystem after the session is created and ready, before returning. Scripts should execute each command inside the session environment (e.g.kubectl exec -- sh -c '<cmd>'for K8s,docker exec -- sh -c '<cmd>'for Docker, or plainsh -c '<cmd>'for local providers). Non-fatal: warn on stderr if a command fails, but don’t abort start. -
session_setup_script— path to a script on the orchestrator filesystem, run aftersession_setupcommands. For remote providers (K8s, Docker), read the file locally and pipe its contents into the session (e.g.kubectl exec -i -- sh < script). For local providers, run directly viash -c. Non-fatal likesession_setup.
emits_permission_warning— bypass-permissions dialog handlingfingerprint_extra— config change detection metadata
ready_prompt_prefix and ready_delay_ms are sent, but gc does not wait
for the agent to show the prompt, or wait out the delay, before it counts
the start done. A script that must reach a ready agent before it returns
(to type nudge, say) waits itself; otherwise it may ignore them. Session setup
commands are the script’s responsibility — they run on the target
filesystem, not the orchestrator.
Work-dir export exclusions
work_dir_exclude— array of patterns, relative towork_dir, naming orchestrator-local state that must not leave the orchestrator host. gc sends it with every start config that has awork_dir, whatever the directory holds: a work dir with no top-level.gc/or.beads/can still hold rig scopes below it. A script that copies, archives, or syncswork_dirinto another filesystem (a remote sandbox, a container image) must skip every matching path. A script that runs the agent in place on the orchestrator’s filesystem ignores it.
work_dir_exclude; it never
replaces its list with gc’s, so a gap on either side cannot widen what
leaves the host.
Rather than implement the grammar below, a script can ask gc for the
entries that survive it:
-
Call the gc named by the start config’s
env.GC_BIN, the binary that produced the list. An oldergconPATHmay lack the subcommand. -
Check its exit status (
set -o pipefailin a pipeline). On failure it prints nothing to stdout, and an unchecked pipeline archives an empty work dir. -
Use
--null. The default newline-terminated output is for humans only: no byte filter makes it safe for a consumer that splits on blanks or quotes, such asxargs. As a backstop it refuses, printing nothing, any entry that contains a newline, a carriage return, or a backslash (GNUtar -Treads\057as/), or that starts with-. -
GNU tar reads a
--nulllist verbatim only from 1.30; bsdtar always does. Older GNU tar unquotes backslash escapes even with--null, so a crafted file name can make it archive.gc/controller.tokenor a file above the work dir. Where GNU tar may predate 1.30, use the form below: 1.29 reads the list verbatim with it, and older releases reject its options, which fails the pipeline. bsdtar rejects those options, so keep the form above there. - The output lists every surviving file and symlink, plus every surviving directory that is empty on disk. A non-empty directory is never listed, so a consumer that recurses into listed entries still copies nothing excluded; a consumer that does not recurse recreates parent directories from the paths below them. A directory whose every entry is excluded is absent.
-
Entries come in depth-first walk order, each directory’s entries sorted by
name. This is not
sortorder:a/bcomes beforea-c.
work_dir_exclude itself copies symlinks as links and
never dereferences them: an in-tree link such as x -> .gc/controller.token
would otherwise ship its target.
Pattern grammar:
- Patterns and paths are
/-separated and relative towork_dir. - A segment of exactly
**matches zero or more whole path segments. - Every other segment is a single-segment glob (Go
path.Matchsyntax):*matches any run of characters except/, leading dots included. - A path is excluded when it, or any directory above it, matches. A
trailing
/**spells out that a whole subtree stays behind. - This is not gitignore, rsync, or tar
--excludesyntax. A pattern with no/matches only at the work-dir root, and.gc/**/*.sockmatches.gc/controller.sockbecause**may match zero segments. Translating patterns into another tool’s syntax is error-prone; prefergc runtime workdir-export.
.gc/services/**,
.gc/secrets/**, .gc/controller.token, .gc/credentials.toml,
.gc/credentials.json, .gc/mcp-adopted/**, .beads/.env, .beads/.beads-credential-key,
.beads/proxy.secret), the event and usage logs, the nudge queue,
per-session diagnostics under .gc/sessions/ and ACP transcripts under
.gc/transcripts/, sockets, locks and pid files, the stores under
.gc/store/ (the default SQLite binding root), .gc/storage/,
.gc/infra/ and .gc/dolt-data/, bd’s stores and their backups in any
scope (.beads/dolt/, .beads/doltlite/, .beads/embeddeddolt/, its
SQLite files, bd backups and corrupt-store backups), the file provider’s
.gc/beads.json and its .bak in any scope, the legacy JSONL archive,
every file named beads.sqlite* wherever it sits, bd server, proxy and
sync state, the provider-lifecycle journal .gc/scope-ownership.json,
orchestrator runtime state, and other agents’ work dirs under
.gc/worktrees/ and .gc/agents/. Copying any of it gives the session a
stale, private fork of state the orchestrator owns, or hands it secrets it
must not hold. Files the session needs are never listed:
.gc/settings.json, .gc/site.toml, .gc/cache/ and .gc/system/ (pid
and socket files aside), .gc/scripts/, the prompt and skill snapshots
under .gc/tmp/, the staged prompts under .gc/synth/, the MCP
projections under .gc/mcp-managed/, the step and iteration artifacts
agents read and write under .gc/molecules/ and .gc/artifacts/, the
.gc/file-beads-layout marker, .beads/metadata.json,
.beads/config.yaml, .beads/http_target.json, and .beads/redirect.
The list is fixed and matches paths by name. It does not read the city’s
configuration: when a SQLite storage binding’s path moves its root to a
path inside the work dir that the list does not name, only that root’s
beads.sqlite* files match, and a script for such a city adds the root to
its own denylist. Matching names also cannot stop a process on the
orchestrator host from copying or hard-linking excluded content to a path
the list keeps.
.gc/site.toml ships because session-side gc commands load it with
city.toml: it carries the workspace name and bead prefix as well as rig
path bindings. Its rig paths are orchestrator-host paths, so a script that
relocates the work dir remaps them as it remaps the GC_* path variables.
The field is additive. A script that predates it ignores the unknown key
and keeps copying the whole work dir.
Conventions
- stdin for values:
set-meta,nudge, andstartpass data on stdin to avoid shell quoting and argument length limits. - stdout for results:
is-running,process-alivereturntrue/false.get-metareturns the value or empty for unset.list-runningreturns one name per line. - Idempotent stop:
stopmust succeed (exit 0) even if the session doesn’t exist. - Best-effort interrupt/nudge: Return 0 even if the session doesn’t exist.
- Empty = unsupported:
get-last-activityreturning empty stdout means the backend doesn’t support activity tracking (zero time in Go).
Session identity
Theenv object in the start config carries the session’s identity:
GC_SESSION_ID, GC_INSTANCE_TOKEN and GC_RUNTIME_EPOCH. When get-meta
returns these keys from the moment is-running reports the session, the
orchestrator can tell which session a running box belongs to. After an
orchestrator restart it then adopts a session whose start was cut short,
instead of failing it.
A script whose get-meta answers them empty still works. If a session
created with gc session new (or the API) fails to start, or its start is cut
short, the orchestrator stops any box running under its name before it marks
the session failed. A box that reports another session’s GC_SESSION_ID is
left alone.
That teardown reads the identity keys first, so get-meta must print nothing
and exit 0 both for an unset key and for a session that does not exist. A
non-zero exit reads as “cannot tell”: a failed gc session new or API session
then stays open in creating until get-meta answers, or until an operator
runs gc session close. A script that runs its backend’s CLI under
set -e should catch the “no such session” case and print nothing.
Writing Your Own Script
- Start with
contrib/session-scripts/gc-session-screenas a template. - Implement the operations your backend supports.
- Return exit 2 for operations you don’t support.
- Validate with
gc runtime check ./your-script— it runs the protocol handshake, the required lifecycle round-trip (start, is-running, stop, idempotent stop), exercises every capability the handshake declares, and probes optional operations (absent ones are reported, not failed). It exits non-zero if any check fails, so CI can gate on it. - Test with
GC_SESSION=exec:./your-script gc start <city>.
Minimal script (start/stop/is-running only)
Environment Variables
Scripts can useGC_EXEC_STATE_DIR (if set) as a directory for sidecar
state files (metadata, wrappers). If not set, scripts should use a
reasonable default under $TMPDIR or /tmp.
A script runs with gc’s own environment. The session’s environment arrives
in the start config’s env (see Session environment).
Shipped Scripts
Seecontrib/session-scripts/ for maintained implementations:
- gc-session-screen — GNU screen backend. Dependencies:
screen,jq,bash.