Skip to content

[Feature]: Support multiple coding agents (pi, claude code) via built-in agent profiles #37

Description

@neurolabs

Motivation

opencode-sandbox is built opencode-first: the runner image installs opencode, the launcher starts an opencode serve daemon, provisions a merged opencode.json, checks opencode releases for upgrades, and offers worktree sessions via opencode's HTTP API. We want to support other coding agents/harnesses (pi, claude code) without losing opencode's specific features (config merging, upgrade checks, worktree).

Proposed behavior

Introduce a built-in agent abstraction (internal/agent) so that opencode (default, zero-config, fully backward compatible), pi, and claude-code are supported via a --agent <name> flag / config key. The runner image installs the selected agent; the launcher drives version resolution, daemon, worktree, config merging, and the attach command through the agent profile. Agents lacking a capability (pi/claude have no native serve/attach daemon or worktree API) degrade gracefully instead of breaking.

Key decisions (from design)

  • Built-in profiles, no dynamic plugin loading.
  • Per-agent config subdirectory under ~/.config/opencode-sandbox/<agent>/ (opencode already nests this way).
  • Config merging becomes pattern-based per agent (e.g. opencode-*.json*, pi-*.{json,yaml}) over files in that dir; the deepMerge algorithm stays shared, non-plugin code. JSON and YAML only (JSON5 stays for opencode).
  • Optional capability interfaces discovered via type assertion: DaemonProvider, UpgradeChecker, ConfigMerger, AttachRunner. opencode implements all; pi implements upgrade+config+attach; claude implements config+attach.
  • Image provisioning via a structured ImageSpec (version ARG / label / ENV / install command) rendered into the runner Dockerfile.
  • Authentication is intentionally minimal — no new agent auth interface. Env-var secrets via the existing microsandbox secret mechanism (all three agents support env-var auth headlessly) are the preferred path; file-based auth.json / .credentials.json stays an opt-in home.yaml provisioning concern. Documented per agent, no code.
  • Missing-capability degradation: pi/claude skip daemon + worktree; --worktree / --serve-only rejected at flag parse.

Phasing

  1. Milestone 1 — opencode-only refactor (no behavior change): introduce internal/agent, register only opencode, refactor image/upgrade/daemon/worktree/session/reprovision/CLI to drive through the interface, add --agent flag (only opencode valid) and the pattern-based+YAML merge. make check green, behavior identical.
  2. Milestone 2 — add pi and claude-code profiles.

Alternatives considered

  • External plugin binaries/exec — more flexible/decentralized, but needs a runtime protocol, versioning, and discovery; YAGNI.
  • Go plugin .so packages — fragile across Go versions, not portable to all platforms.
  • --agent-version vs --opencode-version — a single --agent-version with --opencode-version kept as a deprecated alias.

Design spec

Full design spec (2026-08-30-multi-agent-support-design.md)

Design: Multi-agent support via built-in agent profiles

Date: 2026-08-30
Status: Draft (pending review)

Problem

opencode-sandbox is built opencode-first: the runner image installs opencode, the launcher starts an
opencode serve daemon, provisions a merged opencode.json, checks opencode releases for upgrades, and
offers worktree sessions via opencode's HTTP API. The user wants to support other coding agents/harnesses
(pi, claude code) without losing opencode's specific features (config merging, upgrade checks, worktree).

This is an architectural change: it introduces an agent abstraction that restructures how the launcher
wires agent-specific behavior, and opens the door to more agents later.

Goals

  • Support opencode (default, zero-config, fully backward compatible), pi, and claude-code as
    built-in agent profiles, selected via a --agent <name> flag / config key.
  • Preserve opencode's config merging, upgrade checks, and worktree support.
  • Each agent owns a dedicated config subdirectory (mirroring how opencode nests under
    ~/.config/opencode-sandbox/opencode/).
  • Config merging uses a per-agent glob pattern over files in that subdirectory; the deep-merge algorithm
    is shared, non-plugin code. Only JSON and YAML are supported initially.
  • Agents that lack a capability degrade gracefully (e.g. no daemon/worktree for pi/claude) rather than
    breaking.

Non-goals

  • Dynamic loading of third-party agent plugins (external binaries, Go plugin .so). Agents are built in.
  • Interactive-login flows inside the VM.
  • Writing credential files back / syncing them from the VM to the host.
  • A share/ mirror directory or verbatim-mirror beyond what the config-mirror spec already covers.
  • File-permission (0600) hardening for credential files. Existing gap, tracked separately.
  • Implementing pi/claude as full daemon/serve multi-client servers like opencode. They run interactively
    via a PTY when the daemon capability is absent.

Context (verified)

opencode-specific surface in the codebase

  • internal/sandbox/image/data/Dockerfile — installs opencode via curl https://opencode.ai/install,
    ARG OPENCODE_VERSION, LABEL org.opencode-sandbox.opencode-version, ENV OPENCODE_DISABLE_AUTOUPDATE.
  • internal/sandbox/image/{build,fetch,version_label}.go — version ARG resolution, LatestOpenCodeVersion,
    the version label read-back (imageInfo.OpenCodeVersion).
  • internal/opencode/opencode.go — GitHub releases/latest endpoint + VersionCompare.
  • internal/sandbox/vm/upgrade.go — once-per-day upgrade offer against the baked version.
  • internal/opencodeconfig/opencodeconfig.go — deep-merges every .json/.jsonc/.json5 file in the dir
    into one opencode.json.
  • internal/sandbox/reprovision/config_files.goLoadConfigFiles builds ConfigFiles (merged config +
    home.yaml files), provisions to OpenCodeConfigPath(home) = ~/.config/opencode/opencode.json.
  • internal/sandbox/vm/daemon.goopencode serve --hostname ... --port ..., health poll on
    127.0.0.1:4096/global/health, kill via pkill -f 'opencode serve'.
  • internal/sandbox/vm/worktree.goResolveTarget uses the opencode daemon HTTP API
    (/experimental/worktree), opencode's slugify, JSON response shapes.
  • internal/sandbox/session/run.gobuildAttachCommand = opencode attach http://127.0.0.1:4096 --dir <target>.
  • cmd/opencode-sandbox/constants.go--opencode-version flag; configpaths hardcodes opencode/ dirs.

Existing config-mirror spec

docs/superpowers/specs/2026-08-28-opencode-config-mirror-design.md (pending) reworks the opencode config
layout into a verbatim top-level mirror plus a config-snippets/ merge subdirectory. It keeps
auth.json out of scope (it stays on the home.yaml path). This multi-agent design builds on the same
principles: per-agent config dir, snippet merge, secrets stay on the secrets mechanism.

Agent runtime models (researched)

Agent Daemon/serve Config dir Credential file Env-var auth Upgrade source
opencode yes, serve + attach, port 4096, worktree API ~/.config/opencode/ ~/.local/share/opencode/auth.json OPENCODE_API_KEY GitHub releases/latest
pi none native (community wrappers exist) ~/.pi/agent/ ~/.pi/agent/auth.json (0600) per-provider (ANTHROPIC_API_KEY, OPENAI_API_KEY, ...) pi.dev latest / PI_SKIP_VERSION_CHECK
claude-code none native (claude mcp serve stdio) ~/.claude/ (CLAUDE_CONFIG_DIR) ~/.claude/.credentials.json (0600, machine-managed) ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN none (self-manages)

Key insight: all three agents support env-var auth as a first-class headless channel. opencode-sandbox
already injects env secrets into the VM via the microsandbox secret mechanism (never written to disk), which
is the natural and most secure way to authenticate these agents. File-based credential provisioning stays
possible via home.yaml (opt-in) but is not the primary path and is not implemented by the agent profiles.

Design

1. Package layout

internal/agent/
  agent.go          // Agent core interface + Registry (Register/Lookup)
  capabilities.go   // optional capability interfaces
  image.go          // ImageSpec struct
  opencode.go       // opencode profile (default)
  pi.go             // pi profile
  claudecode.go     // claude code profile

2. Core Agent interface

package agent

type Agent interface {
    // Name is the canonical id, e.g. "opencode", "pi", "claude-code".
    Name() string
    // ConfigDirName is the subdirectory under the tool's config dir holding this
    // agent's snippet files, e.g. "opencode", "pi", "claude".
    ConfigDirName() string
    // ImageSpec returns the structured bits needed to bake the agent into the
    // runner image: version ARG, version label, ENV vars, and install command.
    ImageSpec() ImageSpec
}

3. Registry

var registry = map[string]Agent{}

func Register(a Agent)
func Lookup(name string) (Agent, bool)   // "" or "opencode" => opencode (default)
func Names() []string                    // for --agent flag help & docs

The three built-ins register themselves in init(). Lookup("") returns the opencode profile, so
behavior is unchanged with zero config.

4. Optional capability interfaces (type-asserted discovery)

The framework type-asserts the concrete profile to discover optional capabilities. A profile that does not
implement an interface simply lacks that capability.

// DaemonProvider runs a long-lived server that clients attach to (opencode only).
type DaemonProvider interface {
    DaemonStartCmd(serveOnly bool) string
    DaemonKillCmd() string
    DaemonHealthCmd() string
    DaemonHealthParse(stdout string) (bool, error)
    WorktreeListCmd() string
    WorktreeCreateCmd(spec options.WorktreeSpec) string
    WorktreeParseDir(stdout string) (string, bool)
}

// UpgradeChecker can resolve and compare releases.
type UpgradeChecker interface {
    LatestVersion(ctx context.Context) (string, error)
    VersionCompare(a, b string) int
}

// ConfigMerger merges snippet files matching a pattern into a single config document.
type ConfigMerger interface {
    SnippetPattern() string              // e.g. "opencode-*.json*", "pi-*.{json,yaml}"
    VMConfigPath(home string) string     // e.g. ~/.config/opencode/opencode.json
    BuildMerged(userDir, projectDir string) ([]byte, []string, bool, error)
}

// AttachRunner starts the client TUI/session.
type AttachRunner interface {
    AttachCommand(target string, args []string) string
}

Discovery:

if p, ok := a.(DaemonProvider); ok { ensureDaemon(p, ...) }
if p, ok := a.(UpgradeChecker); ok { resolveBuildVersion(p, ...) }
if p, ok := a.(ConfigMerger); ok { mergeAndProvision(p, ...) }

5. Capability matrix

Capability opencode pi claude-code
AttachRunner opencode attach http://127.0.0.1:4096 --dir <target> pi (interactive) claude (interactive)
DaemonProvider yes (serve + worktree) no no
UpgradeChecker yes (GitHub releases) yes (pi.dev latest) no
ConfigMerger opencode-*.json*~/.config/opencode/opencode.json pi-*.{json,yaml}~/.pi/agent/settings.json *.json~/.claude/settings.json
ImageSpec opencode install npm i -g @earendil-works/pi-coding-agent npm i -g @anthropic-ai/claude-code

6. Default behaviors per missing capability

  • No DaemonProvider (pi/claude): setUpSandbox skips ensureDaemon/worktree; AttachCommand launches
    the interactive TUI directly through the existing PTY attach path (runAttach). --worktree and
    --serve-only are rejected at flag-parsing time with a clear "not supported by agent X" error.
  • No UpgradeChecker (claude): the build-version resolution path is skipped; the image uses whatever
    version the profile pins. No upgrade prompt/state.
  • No ConfigMerger: nothing provisioned for config; only home.yaml files are provisioned.

7. Config subdirectories per agent

configpaths gains an agent dimension. Today UserOpencodeConfigDir() / ProjectOpencodeConfigDir()
hardcode opencode/. These become UserAgentConfigDir(agent) / ProjectAgentConfigDir(agent),
returning UserConfigDir()/<agent.ConfigDirName()> (and project equivalent). opencode keeps
opencode/ as ConfigDirName, so existing layouts are unchanged.

8. Config merging (shared algorithm, pattern-based)

internal/opencodeconfig becomes agent-agnostic:

  • Keep the deepMerge algorithm unchanged (shared, non-plugin code).
  • Change scanSnippets from "all .json* files in dir" to "files matching the agent's
    SnippetPattern() glob".
  • Add YAML/JSON parsing (.yaml, .yml) into map[string]any, then deep-merge maps. JSON5 stays for
    opencode. JSON and YAML only (per non-goals).
  • BuildMerged returns the merged document + ordered source list + "has snippets" boolean.
  • The VM path comes from ConfigMerger.VMConfigPath(home).

9. Image provisioning

type ImageSpec struct {
    VersionArg      string // "OPENCODE_VERSION"
    VersionLabel    string // "org.opencode-sandbox.opencode-version"
    DisableUpdateEnv string // "OPENCODE_DISABLE_AUTOUPDATE" ("" if none)
    InstallCommand  string // e.g. "curl ... | bash -s -- --version \"$OPENCODE_VERSION\" && cp ..."
}

The image package generates the base Dockerfile with agent.ImageSpec() spliced in, replacing the
hardcoded opencode block. Version resolution, the label read-back for imageInfo.OpenCodeVersion, and
upgrade state all become agent-parameterized.

10. Authentication (minimal, no new interface)

Authentication deliberately does not add an agent interface or new plumbing. Two existing channels
cover all three agents:

  • Env-var auth (preferred): the existing microsandbox secret mechanism (env.secret /
    env.secret.yaml) injects secrets into the VM as environment variables, never written to disk. Each
    agent reads env vars:
    • opencode: OPENCODE_API_KEY
    • pi: per-provider vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, ...)
    • claude-code: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN
  • File-based auth (opt-in): the existing home.yaml mechanism can provision an agent's credential
    file verbatim (e.g. ~/.pi/agent/auth.json). This is opt-in and remains the user's responsibility; it is
    not the default path. claude's .credentials.json is machine-managed upstream; hand-provisioning it is
    unsupported, so env vars are the documented path for claude.

The agent profiles do not implement any auth interface. This is documented in docs/configuration.md
per agent (which env vars to put in env.secret, or how to provision a credential file via home.yaml).
The EnvAuthSpec idea from earlier iterations is dropped: no code, only documentation.

11. What stays generic vs agent-parameterized

  • Stays generic / non-plugin: deep-merge, home.yaml provisioning, env/secrets, network, VM lifecycle,
    session/reap, docker, daemon orchestration (driven by the DaemonProvider interface).
  • Becomes agent-parameterized: configpaths dirs, image Dockerfile generation, upgrade check/state,
    daemon + worktree, attach command, the version flag.

12. CLI surface

  • New --agent <name> flag (run/build), default opencode. Stored in viperconfig.
  • Replace --opencode-version with a single --agent-version that applies to the active agent; keep
    --opencode-version as a deprecated alias for backward compatibility.
  • --worktree and --serve-only are rejected at flag-parsing time for agents without DaemonProvider.

Phasing (milestones)

This is a large refactor. Implement in two milestones, both fully backward compatible:

  • Milestone 1 — opencode-only refactor (no behavior change): introduce internal/agent, define the
    interface + capability interfaces, register only the opencode profile, and refactor
    configpaths/image/upgrade/daemon/worktree/session/reprovision/CLI to drive through the
    agent interface. Add the --agent flag (only opencode valid) and the pattern-based+YAML config merge.
    Prove the seam: make check green, behavior identical.
  • Milestone 2 — add pi and claude-code profiles: implement the two profiles, wire their
    ImageSpec/AttachRunner/ConfigMerger (and pi's UpgradeChecker), and the flag-time rejection of
    --worktree/--serve-only. Docs + changelog for both agents.

Testing

  • Unit tests in internal/agent: registry lookup/default, capability discovery via type assertion,
    each profile's Name/ConfigDirName/ImageSpec/SnippetPattern/VMConfigPath.
  • Unit tests in internal/opencodeconfig: pattern-based merge, YAML merge, JSON5 (opencode) merge,
    mixed-format merge across user+project dirs.
  • Update internal/sandbox/vm/{upgrade,daemon,worktree}_test.go to drive through the agent interface
    (opencode profile) so existing behavior is preserved.
  • Update internal/sandbox/image tests for generated Dockerfile from ImageSpec.
  • CLI tests in cmd/opencode-sandbox/cli_*_test.go: --agent flag selection, default opencode, and the
    rejection of --worktree/--serve-only for pi/claude.
  • make check passes (fmt, lint, test).

Documentation & changelog

  • README.md, docs/configuration.md, docs/runner-image.md: document --agent, per-agent config
    subdirs, snippet patterns, and per-agent authentication (env-var secrets + opt-in home.yaml
    credential files).
  • CHANGELOG.md: add an [Unreleased] entry for multi-agent support.

Files touched (anticipated)

  • internal/agent/ (new) — interface, registry, profiles.
  • internal/opencodeconfig/opencodeconfig.go — pattern-based + YAML merge.
  • internal/sandbox/image/{dockerfile,build,fetch,version_label}.goImageSpec-driven generation.
  • internal/sandbox/vm/{upgrade,daemon,worktree,reconfig,run_orchestrate}.go — drive through agent.
  • internal/sandbox/session/run.goAttachCommand via agent.
  • internal/configpaths/configpaths.go — agent-dimensioned config dirs.
  • internal/sandbox/reprovision/config_files.go — agent-driven merge/provision path.
  • cmd/opencode-sandbox/--agent flag, version flag rename/alias.
  • README.md, docs/, CHANGELOG.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions