From f00b0254f224ac026d0f660169874677a3de489d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 21 Jan 2026 17:20:57 -0800 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.5 --- internal/config/agents.go | 21 +++++ internal/config/agents_test.go | 122 +++++++++++++++++++++++++- internal/config/types.go | 11 +++ templates/agents/opencode-models.json | 60 +++++++++++++ templates/agents/opencode.json.tmpl | 24 +++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 templates/agents/opencode-models.json create mode 100644 templates/agents/opencode.json.tmpl diff --git a/internal/config/agents.go b/internal/config/agents.go index d822fbaf..e440b66e 100644 --- a/internal/config/agents.go +++ b/internal/config/agents.go @@ -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. diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go index 80f64dbf..42c54200 100644 --- a/internal/config/agents_test.go +++ b/internal/config/agents_test.go @@ -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") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 8f7f3964..177f6a5f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 } diff --git a/templates/agents/opencode-models.json b/templates/agents/opencode-models.json new file mode 100644 index 00000000..f0b47b5c --- /dev/null +++ b/templates/agents/opencode-models.json @@ -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 + } +} diff --git a/templates/agents/opencode.json.tmpl b/templates/agents/opencode.json.tmpl new file mode 100644 index 00000000..b5777d27 --- /dev/null +++ b/templates/agents/opencode.json.tmpl @@ -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" + } +}