Merge pull request #8 from kevinWangSheng/feat/opencode-agent
feat(agent): add OpenCode support
This commit is contained in:
6 files changed
+635
-13
No files matched your search
@@ -20,15 +20,15 @@ cc-connect bridges AI coding assistants running on your dev machine to the messa
|
||||
└────────────┘
|
||||
┌─────┼─────┐
|
||||
▼ ▼ ▼
|
||||
Claude Gemini Codex ...5 agents
|
||||
Code CLI
|
||||
Claude Gemini Codex ...6 agents
|
||||
Code CLI OpenCode
|
||||
```
|
||||
|
||||
### Why cc-connect?
|
||||
|
||||
> Time to uninstall OpenClaw — cc-connect gives you access to the most powerful coding agents available, not just one.
|
||||
|
||||
- **5 AI Agents** — Claude Code, Codex, Cursor Agent, Qoder CLI, Gemini CLI. Use whichever fits your workflow, or all of them at once.
|
||||
- **6 AI Agents** — Claude Code, Codex, Cursor Agent, Qoder CLI, Gemini CLI, OpenCode. Use whichever fits your workflow, or all of them at once.
|
||||
- **8 Chat Platforms** — Feishu, DingTalk, Slack, Telegram, Discord, WeChat Work, LINE, QQ. Most need zero public IP.
|
||||
- **Full Control from Chat** — Switch models (`/model`), change permission modes (`/mode`), manage sessions, all via slash commands.
|
||||
- **Agent Memory** — Read and write agent instruction files (`/memory`) without touching the terminal.
|
||||
@@ -49,7 +49,8 @@ cc-connect bridges AI coding assistants running on your dev machine to the messa
|
||||
| Agent | Cursor Agent | ✅ Supported |
|
||||
| Agent | Gemini CLI (Google) | ✅ Supported |
|
||||
| Agent | Qoder CLI | ✅ Supported |
|
||||
| Agent | Crush / OpenCode | 🔜 Planned |
|
||||
| Agent | OpenCode | ✅ Supported |
|
||||
| Agent | Crush | 🔜 Planned |
|
||||
| Agent | Goose (Block) | 🔜 Planned |
|
||||
| Agent | Aider | 🔜 Planned |
|
||||
| Agent | Kimi Code (Moonshot) | 🔭 Exploring |
|
||||
@@ -81,7 +82,8 @@ cc-connect bridges AI coding assistants running on your dev machine to the messa
|
||||
- **Codex**: [Codex CLI](https://github.com/openai/codex) installed (`npm install -g @openai/codex`), OR
|
||||
- **Cursor Agent**: [Cursor Agent CLI](https://docs.cursor.com/agent) installed (`agent --version` to verify), OR
|
||||
- **Gemini CLI**: [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed (`npm install -g @google/gemini-cli`), OR
|
||||
- **Qoder CLI**: [Qoder CLI](https://qoder.com) installed (`curl -fsSL https://qoder.com/install | bash`)
|
||||
- **Qoder CLI**: [Qoder CLI](https://qoder.com) installed (`curl -fsSL https://qoder.com/install | bash`), OR
|
||||
- **OpenCode**: [OpenCode](https://github.com/opencode-ai/opencode) installed (`opencode --version` to verify)
|
||||
|
||||
### Install & Configure via AI Agent (Recommended)
|
||||
|
||||
@@ -274,6 +276,13 @@ All agents support permission modes switchable at runtime via `/mode`.
|
||||
| **Default** | `default` | Standard permissions; prompt for approval. |
|
||||
| **YOLO** | `yolo` | Skip all permission checks, auto-approve. |
|
||||
|
||||
**OpenCode** modes:
|
||||
|
||||
| Mode | Config Value | Behavior |
|
||||
|------|-------------|----------|
|
||||
| **Default** | `default` | Standard mode. |
|
||||
| **YOLO** | `yolo` | Auto-approve all tool calls. |
|
||||
|
||||
```toml
|
||||
# Claude Code
|
||||
[projects.agent.options]
|
||||
@@ -296,6 +305,10 @@ mode = "default"
|
||||
# Qoder CLI
|
||||
[projects.agent.options]
|
||||
mode = "default"
|
||||
|
||||
# OpenCode
|
||||
[projects.agent.options]
|
||||
mode = "default"
|
||||
```
|
||||
|
||||
Switch mode at runtime from the chat:
|
||||
@@ -375,6 +388,7 @@ Adding, removing, and switching providers all persist to `config.toml` automatic
|
||||
| Claude Code | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` |
|
||||
| Codex | `OPENAI_API_KEY` | `OPENAI_BASE_URL` |
|
||||
| Gemini CLI | `GEMINI_API_KEY` | — (use `env` map) |
|
||||
| OpenCode | `ANTHROPIC_API_KEY` | — (use `env` map) |
|
||||
|
||||
The `env` map in provider config lets you set arbitrary environment variables for any setup (Bedrock, Vertex, Azure, custom proxies, etc.).
|
||||
|
||||
@@ -473,6 +487,7 @@ Claude Code will automatically translate your request into a `cc-connect cron ad
|
||||
| Cursor | `.cursorrules` |
|
||||
| Qoder CLI | `AGENTS.md` (project), `~/.qoder/AGENTS.md` (global) |
|
||||
| Gemini CLI | `GEMINI.md` |
|
||||
| OpenCode | `OPENCODE.md` |
|
||||
|
||||
**Content to add:**
|
||||
|
||||
@@ -649,7 +664,8 @@ cc-connect/
|
||||
│ ├── codex/ # OpenAI Codex CLI (exec --json)
|
||||
│ ├── cursor/ # Cursor Agent CLI (--print stream-json)
|
||||
│ ├── qoder/ # Qoder CLI (-p -f stream-json)
|
||||
│ └── gemini/ # Gemini CLI (-p --output-format stream-json)
|
||||
│ ├── gemini/ # Gemini CLI (-p --output-format stream-json)
|
||||
│ └── opencode/ # OpenCode (run --format json)
|
||||
├── docs/ # Platform setup guides
|
||||
├── config.example.toml # Config template
|
||||
├── INSTALL.md # AI-agent-friendly install guide
|
||||
|
||||
+22
-6
@@ -20,15 +20,15 @@ cc-connect 把运行在你开发机上的 AI 编程助手桥接到你日常使
|
||||
└────────────┘
|
||||
┌─────┼─────┐
|
||||
▼ ▼ ▼
|
||||
Claude Gemini Codex ...5 个 Agent
|
||||
Code CLI
|
||||
Claude Gemini Codex ...6 个 Agent
|
||||
Code CLI OpenCode
|
||||
```
|
||||
|
||||
### 核心亮点
|
||||
|
||||
> 是时候卸载 OpenClaw 了 — cc-connect 让你同时拥有最强的那几个 AI 编程助手,而不只是一个。
|
||||
|
||||
- **5 大 AI Agent** — Claude Code、Codex、Cursor Agent、Qoder CLI、Gemini CLI,按需选用,也可以同时使用
|
||||
- **6 大 AI Agent** — Claude Code、Codex、Cursor Agent、Qoder CLI、Gemini CLI、OpenCode,按需选用,也可以同时使用
|
||||
- **8 大聊天平台** — 飞书、钉钉、Slack、Telegram、Discord、企业微信、LINE、QQ,大部分无需公网 IP
|
||||
- **聊天即控制** — 切换模型 `/model`、切换权限 `/mode`、管理会话,全部通过斜杠命令完成
|
||||
- **Agent 记忆** — 在聊天中直接读写 Agent 指令文件 `/memory`,无需回到终端
|
||||
@@ -54,7 +54,8 @@ cc-connect 把运行在你开发机上的 AI 编程助手桥接到你日常使
|
||||
| Agent | Cursor Agent | ✅ 已支持 |
|
||||
| Agent | Gemini CLI (Google) | ✅ 已支持 |
|
||||
| Agent | Qoder CLI | ✅ 已支持 |
|
||||
| Agent | Crush / OpenCode | 🔜 计划中 |
|
||||
| Agent | OpenCode | ✅ 已支持 |
|
||||
| Agent | Crush | 🔜 计划中 |
|
||||
| Agent | Goose (Block) | 🔜 计划中 |
|
||||
| Agent | Aider | 🔜 计划中 |
|
||||
| Agent | Kimi Code (月之暗面) | 🔭 探索中 |
|
||||
@@ -86,7 +87,8 @@ cc-connect 把运行在你开发机上的 AI 编程助手桥接到你日常使
|
||||
- **Codex**: [Codex CLI](https://github.com/openai/codex) 已安装(`npm install -g @openai/codex`),或
|
||||
- **Cursor Agent**: [Cursor Agent CLI](https://docs.cursor.com/agent) 已安装(`agent --version` 验证),或
|
||||
- **Gemini CLI**: [Gemini CLI](https://github.com/google-gemini/gemini-cli) 已安装(`npm install -g @google/gemini-cli`),或
|
||||
- **Qoder CLI**: [Qoder CLI](https://qoder.com) 已安装(`curl -fsSL https://qoder.com/install | bash`)
|
||||
- **Qoder CLI**: [Qoder CLI](https://qoder.com) 已安装(`curl -fsSL https://qoder.com/install | bash`),或
|
||||
- **OpenCode**: [OpenCode](https://github.com/opencode-ai/opencode) 已安装(`opencode --version` 验证)
|
||||
|
||||
### 通过 AI Agent 安装配置(推荐)
|
||||
|
||||
@@ -278,6 +280,13 @@ allow_from = "*" # 允许的 QQ 号,如 "12345,67890","*" 表示所有
|
||||
| **标准权限** | `default` | 标准权限,每次工具调用需确认。 |
|
||||
| **YOLO 模式** | `yolo` | 跳过所有权限检查,自动批准。 |
|
||||
|
||||
**OpenCode** 模式:
|
||||
|
||||
| 模式 | 配置值 | 行为 |
|
||||
|------|--------|------|
|
||||
| **默认** | `default` | 标准模式。 |
|
||||
| **全自动** | `yolo` | 自动批准所有工具调用。 |
|
||||
|
||||
```toml
|
||||
# Claude Code
|
||||
[projects.agent.options]
|
||||
@@ -300,6 +309,10 @@ mode = "default"
|
||||
# Qoder CLI
|
||||
[projects.agent.options]
|
||||
mode = "default"
|
||||
|
||||
# OpenCode
|
||||
[projects.agent.options]
|
||||
mode = "default"
|
||||
```
|
||||
|
||||
在聊天中切换模式:
|
||||
@@ -379,6 +392,7 @@ cc-connect provider import --db-path ~/.cc-switch/cc-switch.db # 指定数据
|
||||
| Claude Code | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` |
|
||||
| Codex | `OPENAI_API_KEY` | `OPENAI_BASE_URL` |
|
||||
| Gemini CLI | `GEMINI_API_KEY` | —(使用 `env` 字段)|
|
||||
| OpenCode | `ANTHROPIC_API_KEY` | —(使用 `env` 字段)|
|
||||
|
||||
Provider 配置中的 `env` 字段支持设置任意环境变量,可用于 Bedrock、Vertex、Azure、自定义代理等各种场景。
|
||||
|
||||
@@ -477,6 +491,7 @@ Claude Code 会通过 `--append-system-prompt` 自动将你的请求转为 `cc-c
|
||||
| Cursor | `.cursorrules` |
|
||||
| Qoder CLI | `AGENTS.md`(项目级)、`~/.qoder/AGENTS.md`(全局) |
|
||||
| Gemini CLI | `GEMINI.md` |
|
||||
| OpenCode | `OPENCODE.md` |
|
||||
|
||||
**需要添加的内容:**
|
||||
|
||||
@@ -653,7 +668,8 @@ cc-connect/
|
||||
│ ├── codex/ # OpenAI Codex CLI(exec --json)
|
||||
│ ├── cursor/ # Cursor Agent CLI(--print stream-json)
|
||||
│ ├── qoder/ # Qoder CLI(-p -f stream-json)
|
||||
│ └── gemini/ # Gemini CLI(-p --output-format stream-json)
|
||||
│ ├── gemini/ # Gemini CLI(-p --output-format stream-json)
|
||||
│ └── opencode/ # OpenCode(run --format json)
|
||||
├── docs/ # 平台接入指南
|
||||
├── config.example.toml # 配置模板
|
||||
├── INSTALL.md # AI agent 友好的安装配置指南
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("opencode", New)
|
||||
}
|
||||
|
||||
// Agent drives the OpenCode CLI in headless mode using `opencode run --format json`.
|
||||
//
|
||||
// Modes:
|
||||
// - "default": standard mode
|
||||
// - "yolo": auto mode (opencode run is auto by default in non-interactive mode)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
cmd string // CLI binary name, default "opencode"
|
||||
providers []core.ProviderConfig
|
||||
activeIdx int
|
||||
sessionEnv []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Agent, error) {
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
if workDir == "" {
|
||||
workDir = "."
|
||||
}
|
||||
model, _ := opts["model"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
mode = normalizeMode(mode)
|
||||
cmd, _ := opts["cmd"].(string)
|
||||
if cmd == "" {
|
||||
cmd = "opencode"
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
return nil, fmt.Errorf("opencode: %q CLI not found in PATH, install from: https://github.com/opencode-ai/opencode", cmd)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
cmd: cmd,
|
||||
activeIdx: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "yolo", "auto", "force", "bypasspermissions":
|
||||
return "yolo"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "opencode" }
|
||||
|
||||
func (a *Agent) SetModel(model string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.model = model
|
||||
slog.Info("opencode: model changed", "model", model)
|
||||
}
|
||||
|
||||
func (a *Agent) GetModel() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.model
|
||||
}
|
||||
|
||||
func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption {
|
||||
return []core.ModelOption{
|
||||
{Name: "anthropic/claude-sonnet-4-20250514", Desc: "Claude Sonnet 4 (default)"},
|
||||
{Name: "anthropic/claude-opus-4-20250514", Desc: "Claude Opus 4"},
|
||||
{Name: "openai/gpt-4o", Desc: "GPT-4o"},
|
||||
{Name: "openai/o3", Desc: "OpenAI o3"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetSessionEnv(env []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sessionEnv = env
|
||||
}
|
||||
|
||||
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.Lock()
|
||||
model := a.model
|
||||
mode := a.mode
|
||||
cmd := a.cmd
|
||||
workDir := a.workDir
|
||||
extraEnv := a.providerEnvLocked()
|
||||
extraEnv = append(extraEnv, a.sessionEnv...)
|
||||
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
||||
if m := a.providers[a.activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
return newOpencodeSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv)
|
||||
}
|
||||
|
||||
// ListSessions runs `opencode session list` and parses the JSON output.
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return listOpencodeSessions(a.cmd, a.workDir)
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// -- ModeSwitcher --
|
||||
|
||||
func (a *Agent) SetMode(mode string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mode = normalizeMode(mode)
|
||||
slog.Info("opencode: mode changed", "mode", a.mode)
|
||||
}
|
||||
|
||||
func (a *Agent) GetMode() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mode
|
||||
}
|
||||
|
||||
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
||||
return []core.PermissionModeInfo{
|
||||
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode", DescZh: "标准模式"},
|
||||
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
||||
}
|
||||
}
|
||||
|
||||
// -- ContextCompressor --
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "/compact" }
|
||||
|
||||
// -- MemoryFileProvider --
|
||||
|
||||
func (a *Agent) ProjectMemoryFile() string {
|
||||
absDir, err := filepath.Abs(a.workDir)
|
||||
if err != nil {
|
||||
absDir = a.workDir
|
||||
}
|
||||
return filepath.Join(absDir, "OPENCODE.md")
|
||||
}
|
||||
|
||||
func (a *Agent) GlobalMemoryFile() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, ".opencode", "OPENCODE.md")
|
||||
}
|
||||
|
||||
// -- ProviderSwitcher --
|
||||
|
||||
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.providers = providers
|
||||
}
|
||||
|
||||
func (a *Agent) SetActiveProvider(name string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for i, p := range a.providers {
|
||||
if p.Name == name {
|
||||
a.activeIdx = i
|
||||
slog.Info("opencode: provider switched", "provider", name)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
return &p
|
||||
}
|
||||
|
||||
func (a *Agent) ListProviders() []core.ProviderConfig {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
result := make([]core.ProviderConfig, len(a.providers))
|
||||
copy(result, a.providers)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agent) providerEnvLocked() []string {
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
var env []string
|
||||
if p.APIKey != "" {
|
||||
env = append(env, "ANTHROPIC_API_KEY="+p.APIKey)
|
||||
}
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// -- Session listing --
|
||||
|
||||
// opencodeSessionEntry represents a session from `opencode session list` output.
|
||||
type opencodeSessionEntry struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func listOpencodeSessions(cmd, workDir string) ([]core.AgentSessionInfo, error) {
|
||||
c := exec.Command(cmd, "session", "list", "--format", "json")
|
||||
c.Dir = workDir
|
||||
|
||||
out, err := c.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opencode: session list: %w", err)
|
||||
}
|
||||
|
||||
var entries []opencodeSessionEntry
|
||||
if err := json.Unmarshal(out, &entries); err != nil {
|
||||
return nil, fmt.Errorf("opencode: parse session list: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, e := range entries {
|
||||
sessions = append(sessions, core.AgentSessionInfo{
|
||||
ID: e.ID,
|
||||
Summary: e.Title,
|
||||
})
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// opencodeSession manages multi-turn conversations with the OpenCode CLI.
|
||||
// Each Send() launches a new `opencode run --format json` process
|
||||
// with --session for conversation continuity.
|
||||
type opencodeSession struct {
|
||||
cmd string
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
chatID atomic.Value // stores string — OpenCode session ID
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
}
|
||||
|
||||
func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*opencodeSession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s := &opencodeSession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.alive.Store(true)
|
||||
|
||||
if resumeID != "" {
|
||||
s.chatID.Store(resumeID)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *opencodeSession) Send(prompt string, images []core.ImageAttachment) error {
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
|
||||
chatID := s.CurrentSessionID()
|
||||
isResume := chatID != ""
|
||||
|
||||
args := []string{"run", "--format", "json"}
|
||||
|
||||
if isResume {
|
||||
args = append(args, "--session", chatID)
|
||||
}
|
||||
if s.model != "" {
|
||||
args = append(args, "--model", s.model)
|
||||
}
|
||||
if s.workDir != "" {
|
||||
args = append(args, "--dir", s.workDir)
|
||||
}
|
||||
|
||||
// Enable thinking blocks
|
||||
args = append(args, "--thinking")
|
||||
|
||||
// Append prompt as positional arg
|
||||
args = append(args, prompt)
|
||||
|
||||
slog.Debug("opencodeSession: launching", "resume", isResume, "args", args)
|
||||
|
||||
cmd := exec.CommandContext(s.ctx, s.cmd, args...)
|
||||
cmd.Dir = s.workDir
|
||||
env := os.Environ()
|
||||
if len(s.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, s.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("opencodeSession: stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("opencodeSession: start: %w", err)
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.readLoop(cmd, stdout, &stderrBuf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
stderrMsg := stderrBuf.String()
|
||||
if stderrMsg != "" {
|
||||
slog.Error("opencodeSession: process failed", "error", err, "stderr", stderrMsg)
|
||||
s.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
slog.Debug("opencodeSession: non-JSON line", "line", line)
|
||||
continue
|
||||
}
|
||||
|
||||
s.handleEvent(raw)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
slog.Error("opencodeSession: scanner error", "error", err)
|
||||
s.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode NDJSON event structure:
|
||||
//
|
||||
// { "type": "text|tool_use|reasoning|step_start|step_finish",
|
||||
// "part": { "type": "text|tool|reasoning|step-start|step-finish", ... } }
|
||||
func (s *opencodeSession) handleEvent(raw map[string]any) {
|
||||
eventType, _ := raw["type"].(string)
|
||||
|
||||
switch eventType {
|
||||
case "text":
|
||||
s.handleText(raw)
|
||||
case "tool_use":
|
||||
s.handleToolUse(raw)
|
||||
case "reasoning":
|
||||
s.handleReasoning(raw)
|
||||
case "step_start":
|
||||
s.handleStepStart(raw)
|
||||
case "step_finish":
|
||||
s.handleStepFinish(raw)
|
||||
default:
|
||||
slog.Debug("opencodeSession: unhandled event", "type", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) handleText(raw map[string]any) {
|
||||
part, _ := raw["part"].(map[string]any)
|
||||
if part == nil {
|
||||
return
|
||||
}
|
||||
text, _ := part["text"].(string)
|
||||
if text != "" {
|
||||
s.events <- core.Event{
|
||||
Type: core.EventText,
|
||||
Content: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) handleToolUse(raw map[string]any) {
|
||||
part, _ := raw["part"].(map[string]any)
|
||||
if part == nil {
|
||||
return
|
||||
}
|
||||
|
||||
toolName, _ := part["tool"].(string)
|
||||
|
||||
// Check state for status
|
||||
state, _ := part["state"].(map[string]any)
|
||||
status := ""
|
||||
if state != nil {
|
||||
status, _ = state["status"].(string)
|
||||
}
|
||||
|
||||
if status == "completed" {
|
||||
// Tool result
|
||||
output := ""
|
||||
if state != nil {
|
||||
output, _ = state["output"].(string)
|
||||
}
|
||||
s.events <- core.Event{
|
||||
Type: core.EventToolResult,
|
||||
ToolName: toolName,
|
||||
Content: truncate(output, 500),
|
||||
}
|
||||
} else {
|
||||
// Tool use (running or starting)
|
||||
input := ""
|
||||
if state != nil {
|
||||
inputVal, _ := state["input"].(string)
|
||||
if inputVal != "" {
|
||||
input = inputVal
|
||||
} else {
|
||||
// Try to marshal input as JSON summary
|
||||
if inputMap, ok := state["input"].(map[string]any); ok {
|
||||
b, _ := json.Marshal(inputMap)
|
||||
input = truncate(string(b), 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.events <- core.Event{
|
||||
Type: core.EventToolUse,
|
||||
ToolName: toolName,
|
||||
ToolInput: input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) handleReasoning(raw map[string]any) {
|
||||
part, _ := raw["part"].(map[string]any)
|
||||
if part == nil {
|
||||
return
|
||||
}
|
||||
text, _ := part["text"].(string)
|
||||
if text != "" {
|
||||
s.events <- core.Event{
|
||||
Type: core.EventThinking,
|
||||
Content: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) handleStepStart(raw map[string]any) {
|
||||
part, _ := raw["part"].(map[string]any)
|
||||
if part == nil {
|
||||
return
|
||||
}
|
||||
sessionID, _ := part["sessionID"].(string)
|
||||
if sessionID != "" {
|
||||
s.chatID.Store(sessionID)
|
||||
slog.Debug("opencodeSession: session started", "session_id", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) handleStepFinish(_ map[string]any) {
|
||||
sid := s.CurrentSessionID()
|
||||
s.events <- core.Event{
|
||||
Type: core.EventResult,
|
||||
SessionID: sid,
|
||||
Done: true,
|
||||
}
|
||||
}
|
||||
|
||||
// RespondPermission is a no-op — OpenCode handles permissions internally.
|
||||
func (s *opencodeSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *opencodeSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *opencodeSession) CurrentSessionID() string {
|
||||
v, _ := s.chatID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *opencodeSession) Alive() bool {
|
||||
return s.alive.Load()
|
||||
}
|
||||
|
||||
func (s *opencodeSession) Close() error {
|
||||
s.alive.Store(false)
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
close(s.events)
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:maxRunes]) + "..."
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
_ "github.com/chenhg5/cc-connect/agent/codex"
|
||||
_ "github.com/chenhg5/cc-connect/agent/cursor"
|
||||
_ "github.com/chenhg5/cc-connect/agent/gemini"
|
||||
_ "github.com/chenhg5/cc-connect/agent/opencode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/qoder"
|
||||
|
||||
_ "github.com/chenhg5/cc-connect/platform/dingtalk"
|
||||
|
||||
+32
-1
@@ -509,7 +509,38 @@ app_secret = "your-feishu-app-secret"
|
||||
# token = "your-telegram-bot-token"
|
||||
|
||||
# =============================================================================
|
||||
# Project 6: Another example / 项目 6:更多示例 (uncomment to enable / 取消注释以启用)
|
||||
# Project 6: Using OpenCode / 项目 6:使用 OpenCode (uncomment to enable / 取消注释以启用)
|
||||
# =============================================================================
|
||||
# Requires: OpenCode CLI — https://github.com/opencode-ai/opencode
|
||||
# 需要安装:OpenCode CLI
|
||||
# Uses `opencode run --format json` under the hood.
|
||||
# 底层使用 `opencode run --format json` 命令。
|
||||
|
||||
# [[projects]]
|
||||
# name = "my-opencode-project"
|
||||
#
|
||||
# [projects.agent]
|
||||
# type = "opencode"
|
||||
#
|
||||
# [projects.agent.options]
|
||||
# work_dir = "/path/to/project"
|
||||
# mode = "default" # "default" | "yolo"
|
||||
#
|
||||
# Mode options / 模式说明:
|
||||
# - "default": Standard mode / 标准模式
|
||||
# - "yolo": Auto-approve all tool calls / 自动批准所有工具调用
|
||||
#
|
||||
# Optional: specify a model (provider/model format) / 可选:指定模型
|
||||
# model = "anthropic/claude-sonnet-4-20250514"
|
||||
#
|
||||
# [[projects.platforms]]
|
||||
# type = "telegram"
|
||||
#
|
||||
# [projects.platforms.options]
|
||||
# token = "your-telegram-bot-token"
|
||||
|
||||
# =============================================================================
|
||||
# Project 7: Another example / 项目 7:更多示例 (uncomment to enable / 取消注释以启用)
|
||||
# =============================================================================
|
||||
|
||||
# [[projects]]
|
||||
|
||||
Reference in new issue
Block a user