feat(config): add OpenCode as built-in agent preset (#861)
Add AgentOpenCode as a first-class built-in agent preset, similar to
Claude, Gemini, Codex, Cursor, Auggie, and AMP.
OpenCode preset configuration:
- Command: "opencode"
- Args: [] (uses Env for YOLO mode, no CLI flags needed)
- Env: OPENCODE_PERMISSION='{"*":"allow"}' for auto-approve
- ProcessNames: ["opencode", "node"] (runs as Node.js)
- SupportsHooks: true (uses .opencode/plugin/gastown.js)
- NonInteractive: run subcommand with --format json
Runtime defaults for opencode provider:
- ready_delay_ms: 8000 (delay-based detection for TUI)
- process_names: [opencode, node]
- instructions_file: AGENTS.md
This allows users to simply configure:
role_agents:
refinery: "opencode"
Instead of manually configuring agents.json and runtime settings.
Test coverage:
- TestOpenCodeAgentPreset: comprehensive preset validation
- TestOpenCodeProviderDefaults: runtime config defaults
- TestOpenCodeRuntimeConfigFromPreset: Env copying
- TestIsKnownPreset: includes opencode
- TestGetAgentPresetByName: opencode returns preset
Templates added:
- templates/agents/opencode.json.tmpl: agent config template
- templates/agents/opencode-models.json: model delay presets
Co-authored-by: Avyukth <subhrajit.makur@hotmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ const (
|
||||
AgentAuggie AgentPreset = "auggie"
|
||||
// AgentAmp is Sourcegraph AMP.
|
||||
AgentAmp AgentPreset = "amp"
|
||||
// AgentOpenCode is OpenCode multi-model CLI.
|
||||
AgentOpenCode AgentPreset = "opencode"
|
||||
)
|
||||
|
||||
// AgentPresetInfo contains the configuration details for an agent preset.
|
||||
@@ -182,6 +184,25 @@ var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
||||
SupportsHooks: false,
|
||||
SupportsForkSession: false,
|
||||
},
|
||||
AgentOpenCode: {
|
||||
Name: AgentOpenCode,
|
||||
Command: "opencode",
|
||||
Args: []string{}, // No CLI flags needed, YOLO via OPENCODE_PERMISSION env
|
||||
Env: map[string]string{
|
||||
// Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)
|
||||
"OPENCODE_PERMISSION": `{"*":"allow"}`,
|
||||
},
|
||||
ProcessNames: []string{"opencode", "node"}, // Runs as Node.js
|
||||
SessionIDEnv: "", // OpenCode manages sessions internally
|
||||
ResumeFlag: "", // No resume support yet
|
||||
ResumeStyle: "",
|
||||
SupportsHooks: true, // Uses .opencode/plugin/gastown.js
|
||||
SupportsForkSession: false,
|
||||
NonInteractive: &NonInteractiveConfig{
|
||||
Subcommand: "run",
|
||||
OutputFlag: "--format json",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Registry state with proper synchronization.
|
||||
|
||||
@@ -50,8 +50,8 @@ func TestGetAgentPresetByName(t *testing.T) {
|
||||
{"cursor", AgentCursor, false},
|
||||
{"auggie", AgentAuggie, false},
|
||||
{"amp", AgentAmp, false},
|
||||
{"aider", "", true}, // Not built-in, can be added via config
|
||||
{"opencode", "", true}, // Not built-in, can be added via config
|
||||
{"aider", "", true}, // Not built-in, can be added via config
|
||||
{"opencode", AgentOpenCode, false}, // Built-in multi-model CLI agent
|
||||
{"unknown", "", true},
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestIsKnownPreset(t *testing.T) {
|
||||
{"auggie", true},
|
||||
{"amp", true},
|
||||
{"aider", false}, // Not built-in, can be added via config
|
||||
{"opencode", false}, // Not built-in, can be added via config
|
||||
{"opencode", true}, // Built-in multi-model CLI agent
|
||||
{"unknown", false},
|
||||
{"chatgpt", false},
|
||||
}
|
||||
@@ -661,3 +661,119 @@ func TestLoadRigAgentRegistry(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenCodeAgentPreset(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Verify OpenCode agent preset is correctly configured
|
||||
info := GetAgentPreset(AgentOpenCode)
|
||||
if info == nil {
|
||||
t.Fatal("opencode preset not found")
|
||||
}
|
||||
|
||||
// Check command
|
||||
if info.Command != "opencode" {
|
||||
t.Errorf("opencode command = %q, want opencode", info.Command)
|
||||
}
|
||||
|
||||
// Check Args (should be empty - YOLO via Env)
|
||||
if len(info.Args) != 0 {
|
||||
t.Errorf("opencode args = %v, want empty (uses Env for YOLO)", info.Args)
|
||||
}
|
||||
|
||||
// Check Env for OPENCODE_PERMISSION
|
||||
if info.Env == nil {
|
||||
t.Fatal("opencode Env is nil")
|
||||
}
|
||||
permission, ok := info.Env["OPENCODE_PERMISSION"]
|
||||
if !ok {
|
||||
t.Error("opencode Env missing OPENCODE_PERMISSION")
|
||||
}
|
||||
if permission != `{"*":"allow"}` {
|
||||
t.Errorf("OPENCODE_PERMISSION = %q, want {\"*\":\"allow\"}", permission)
|
||||
}
|
||||
|
||||
// Check ProcessNames for detection
|
||||
if len(info.ProcessNames) != 2 {
|
||||
t.Errorf("opencode ProcessNames length = %d, want 2", len(info.ProcessNames))
|
||||
}
|
||||
if info.ProcessNames[0] != "opencode" {
|
||||
t.Errorf("opencode ProcessNames[0] = %q, want opencode", info.ProcessNames[0])
|
||||
}
|
||||
if info.ProcessNames[1] != "node" {
|
||||
t.Errorf("opencode ProcessNames[1] = %q, want node", info.ProcessNames[1])
|
||||
}
|
||||
|
||||
// Check hooks support
|
||||
if !info.SupportsHooks {
|
||||
t.Error("opencode should support hooks")
|
||||
}
|
||||
|
||||
// Check fork session (not supported)
|
||||
if info.SupportsForkSession {
|
||||
t.Error("opencode should not support fork session")
|
||||
}
|
||||
|
||||
// Check NonInteractive config
|
||||
if info.NonInteractive == nil {
|
||||
t.Fatal("opencode NonInteractive is nil")
|
||||
}
|
||||
if info.NonInteractive.Subcommand != "run" {
|
||||
t.Errorf("opencode NonInteractive.Subcommand = %q, want run", info.NonInteractive.Subcommand)
|
||||
}
|
||||
if info.NonInteractive.OutputFlag != "--format json" {
|
||||
t.Errorf("opencode NonInteractive.OutputFlag = %q, want --format json", info.NonInteractive.OutputFlag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeProviderDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test defaultReadyDelayMs for opencode
|
||||
delay := defaultReadyDelayMs("opencode")
|
||||
if delay != 8000 {
|
||||
t.Errorf("defaultReadyDelayMs(opencode) = %d, want 8000", delay)
|
||||
}
|
||||
|
||||
// Test defaultProcessNames for opencode
|
||||
names := defaultProcessNames("opencode", "opencode")
|
||||
if len(names) != 2 {
|
||||
t.Errorf("defaultProcessNames(opencode) length = %d, want 2", len(names))
|
||||
}
|
||||
if names[0] != "opencode" || names[1] != "node" {
|
||||
t.Errorf("defaultProcessNames(opencode) = %v, want [opencode, node]", names)
|
||||
}
|
||||
|
||||
// Test defaultInstructionsFile for opencode
|
||||
instFile := defaultInstructionsFile("opencode")
|
||||
if instFile != "AGENTS.md" {
|
||||
t.Errorf("defaultInstructionsFile(opencode) = %q, want AGENTS.md", instFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeRuntimeConfigFromPreset(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := RuntimeConfigFromPreset(AgentOpenCode)
|
||||
if rc == nil {
|
||||
t.Fatal("RuntimeConfigFromPreset(opencode) returned nil")
|
||||
}
|
||||
|
||||
// Check command
|
||||
if rc.Command != "opencode" {
|
||||
t.Errorf("RuntimeConfig.Command = %q, want opencode", rc.Command)
|
||||
}
|
||||
|
||||
// Check Env is copied
|
||||
if rc.Env == nil {
|
||||
t.Fatal("RuntimeConfig.Env is nil")
|
||||
}
|
||||
if rc.Env["OPENCODE_PERMISSION"] != `{"*":"allow"}` {
|
||||
t.Errorf("RuntimeConfig.Env[OPENCODE_PERMISSION] = %q, want {\"*\":\"allow\"}", rc.Env["OPENCODE_PERMISSION"])
|
||||
}
|
||||
|
||||
// Verify Env is a copy (mutation doesn't affect original)
|
||||
rc.Env["MUTATED"] = "yes"
|
||||
original := GetAgentPreset(AgentOpenCode)
|
||||
if _, exists := original.Env["MUTATED"]; exists {
|
||||
t.Error("Mutation of RuntimeConfig.Env affected original preset")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +575,11 @@ func defaultProcessNames(provider, command string) []string {
|
||||
if provider == "claude" {
|
||||
return []string{"node"}
|
||||
}
|
||||
if provider == "opencode" {
|
||||
// OpenCode runs as Node.js process, need both for IsAgentRunning detection.
|
||||
// tmux pane_current_command may show "node" or "opencode" depending on how invoked.
|
||||
return []string{"opencode", "node"}
|
||||
}
|
||||
if command != "" {
|
||||
return []string{filepath.Base(command)}
|
||||
}
|
||||
@@ -596,6 +601,12 @@ func defaultReadyDelayMs(provider string) int {
|
||||
if provider == "codex" {
|
||||
return 3000
|
||||
}
|
||||
if provider == "opencode" {
|
||||
// OpenCode requires delay-based detection because its TUI uses
|
||||
// box-drawing characters (┃) that break prompt prefix matching.
|
||||
// 8000ms provides reliable startup detection across models.
|
||||
return 8000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
60
templates/agents/opencode-models.json
Normal file
60
templates/agents/opencode-models.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "OpenCode model presets with recommended delay settings for gastown integration",
|
||||
"usage": "Use ready_delay_ms values when configuring runtime.tmux.ready_delay_ms in agents.json",
|
||||
"models_api": "https://models.dev/api.json",
|
||||
"models_api_note": "LLMs should fetch current model list from models_api. The 'models' below are fallback examples with gastown-specific delay recommendations.",
|
||||
"models": {
|
||||
"openai": {
|
||||
"gpt-5.2": {
|
||||
"description": "GPT-5.2 chat model",
|
||||
"ready_delay_ms": 5000
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
"description": "GPT-5.2 Codex for code tasks",
|
||||
"ready_delay_ms": 8000
|
||||
},
|
||||
"codex-1": {
|
||||
"description": "Codex-1 extended context",
|
||||
"ready_delay_ms": 10000
|
||||
}
|
||||
},
|
||||
"google": {
|
||||
"gemini-3-pro-high": {
|
||||
"description": "Gemini 3 Pro High quality",
|
||||
"ready_delay_ms": 6000
|
||||
}
|
||||
},
|
||||
"xai": {
|
||||
"grok-code-fast-1": {
|
||||
"description": "Grok Code Fast",
|
||||
"ready_delay_ms": 4000
|
||||
}
|
||||
},
|
||||
"github-copilot": {
|
||||
"gpt-5.2-codex": {
|
||||
"description": "GitHub Copilot GPT-5.2 Codex",
|
||||
"ready_delay_ms": 8000
|
||||
}
|
||||
},
|
||||
"opencode": {
|
||||
"glm-4.7-free": {
|
||||
"description": "GLM 4.7 Free tier (may timeout, needs longer delay)",
|
||||
"ready_delay_ms": 15000
|
||||
},
|
||||
"minimax-m2.1-free": {
|
||||
"description": "MiniMax M2.1 Free tier",
|
||||
"ready_delay_ms": 10000
|
||||
},
|
||||
"big-pickle": {
|
||||
"description": "Big Pickle experimental",
|
||||
"ready_delay_ms": 12000
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"why_delay_based": "OpenCode TUI uses box-drawing characters that break prompt prefix matching. Delay-based detection is required.",
|
||||
"free_tier_warning": "Free tier models may have longer cold start times. Increase delay if timeout errors occur.",
|
||||
"default_delay": 8000
|
||||
}
|
||||
}
|
||||
24
templates/agents/opencode.json.tmpl
Normal file
24
templates/agents/opencode.json.tmpl
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://gastown.dev/schemas/agent-template-v1.json",
|
||||
"_models_api": "https://models.dev/api.json",
|
||||
"_note": "LLMs: fetch available models from _models_api to get current provider/model options",
|
||||
"name": "opencode-{{.Model}}",
|
||||
"description": "OpenCode agent using {{.Provider}}/{{.Model}}",
|
||||
"command": "{{.OpenCodePath | default \"opencode\"}}",
|
||||
"args": ["-m", "{{.Provider}}/{{.Model}}"],
|
||||
"non_interactive": {
|
||||
"subcommand": "run",
|
||||
"output_flag": "--format json"
|
||||
},
|
||||
"runtime": {
|
||||
"provider": "opencode",
|
||||
"tmux": {
|
||||
"ready_prompt_prefix": "",
|
||||
"ready_delay_ms": {{.ReadyDelayMs | default 8000}},
|
||||
"process_names": ["opencode", "node"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"provider": "opencode"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user