Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c75e7c0
feat(agent): 抽象 IAgentRuntime 接口 + OpenCode ACP runtime
yhsunshining May 1, 2026
712bb40
feat(agent): OpenCode runtime 接入沙箱(agent/sandbox 严格分离)
yhsunshining May 2, 2026
ee29bca
refactor(agent): OpenCode 沙箱接入改为全局 tool override + env 注入
yhsunshining May 2, 2026
2e0556e
feat(agent): OpenCode ToolConfirm 交互式权限确认接入
yhsunshining May 2, 2026
268aa49
feat(agent): OpenCode AskUserQuestion 接入(custom tool + 内部 HTTP 回调)
yhsunshining May 2, 2026
1a7681a
refactor(agent): 对齐 AskUserQuestion 工具名与参数 schema 到 Tencent 契约
yhsunshining May 2, 2026
721a9fe
feat(agent): OpenCode runtime 消息持久化对齐 Tencent SDK 契约
yhsunshining May 2, 2026
3fe82bf
feat(agent): 修复挂起态持久化 - tool_use 事件触发 flushToDb 里程碑
yhsunshining May 2, 2026
0d9ea58
feat(agent): 多轮记忆 - 新 chatStream 注入历史对话上下文
yhsunshining May 2, 2026
59ca6b2
refactor(agent): 三项借鉴 open-design 的优化(全部 TDD,27 个单元测试)
yhsunshining May 2, 2026
77a31c3
feat(web): 前端 Runtime 选择器 UI(类似 CodeBuddy 添加 OpenCode 选项)
yhsunshining May 3, 2026
1d2639a
test(web): runtime 选择器 e2e (11/11 PASS)
yhsunshining May 3, 2026
3c7d4a1
fix(agent): OpenCode runtime finally 块加 cleanupStreamEvents
yhsunshining May 3, 2026
5d8ec0e
feat: OpenCode ACP Runtime 集成 (IAgentRuntime 抽象 + 沙箱隔离 + 多轮记忆 + 持久化)
yhsunshining May 3, 2026
071943a
docs: 变更说明 & 验收清单 (feat-acp-runtime-abstraction.md)
yhsunshining May 4, 2026
bd424e5
fix(agent): tool override schema 完全对齐 opencode builtin (v1.14.33)
yhsunshining May 4, 2026
190db79
feat: Agent 统一选择器 + MiMo 集成 + 配置隔离 + 发送按钮修复
yhsunshining May 6, 2026
17d1c7a
docs: 新增 CHANGELOG.md
yhsunshining May 6, 2026
a87c64c
feat(multimodal): add image input support across full stack
yhsunshining May 6, 2026
d7ab65f
feat(opencode): e2e 验证 + UI 工具名适配 + git archive + todowrite 面板
yhsunshining May 7, 2026
041af88
fix(opencode): archiveToGit 移到 finally 块,error/cancel 场景也归档
yhsunshining May 7, 2026
dade05c
feat(opencode): 为 MCP server 暴露本地 HTTP endpoint,opencode 可调用 CloudBas…
yhsunshining May 7, 2026
dd19fb4
feat(opencode): CloudBase MCP server via stdio — opencode 可调用全部 Cloud…
yhsunshining May 7, 2026
38114f1
refactor(opencode): move tool overrides to .opencode/tools/, simplify…
yhsunshining May 7, 2026
c5c3612
feat(opencode): 全局 CloudBase MCP HTTP server — 复用 Express 端口,零额外 TCP 开销
yhsunshining May 7, 2026
05b54df
fix(mcp-cloudbase): 用 x-hono-already-sent 防止 ERR_HTTP_HEADERS_SENT
yhsunshining May 7, 2026
127ea6d
fix(mcp-cloudbase): 认证改用当前登录用户 apiKey,scopeId 改名 sessionId 仅本地缓存
yhsunshining May 7, 2026
c3a15d3
fix(opencode): /cloudbase-mcp 复用 sessionJwe 鉴权 + 重命名 SandboxInstance.…
yhsunshining May 7, 2026
68ec7c4
fix(acp): askAnswers/toolConfirmation 必须路由到 runtime.chatStream resume 分支
yhsunshining May 9, 2026
1fa3693
feat: 更新镜像支持
yhsunshining May 9, 2026
5dab197
feat(opencode-acp): stopReason 为 refusal/max_tokens 时注入友好提示
yhsunshining May 9, 2026
7674342
fix(build): 移除已迁移目录的 cp 步骤 + 生产镜像拷贝 .opencode/
yhsunshining May 9, 2026
738fb4c
chore(opencode): 删除 opencode-installer 中的死代码
yhsunshining May 9, 2026
cdb880b
feat: 添加错误说明
yhsunshining May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ WORKSPACE_ISOLATION=isolated
# ==================== Proxy (Optional) ====================

# http_proxy=

# ==================== Mino (Optional) ====================

# MiMo openai-compatible proxy API key (used by opencode-acp runtime)
# MIMO_API_KEY=
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,6 @@ skills-lock.json
CodeBuddy Code_decompiled
CodeBuddy Code_files
decompiled
decompiled-ui
decompiled-ui
# opencode project-level config (auto-generated tool overrides)
.opencode/tools/
76 changes: 76 additions & 0 deletions .opencode/opencode.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "mimo/mimo-v2.5-pro",
"provider": {
"mimo": {
"npm": "@ai-sdk/openai-compatible",
"name": "MiMo",
"options": {
"baseURL": "{env:OPENAI_API_ENDPOINT}",
"apiKey": "{env:MIMO_API_KEY}"
},
"models": {
"mimo-v2.5-pro": {
"name": "MiMo V2.5 Pro",
"tool_call": true,
"limit": {
"context": 1048576,
"output": 131072
},
"modalities": {
"input": ["text", "image"],
"output": ["text"]
}
},
"mimo-v2.5": {
"name": "MiMo V2.5",
"tool_call": true,
"limit": {
"context": 1048576,
"output": 131072
},
"modalities": {
"input": ["text"],
"output": ["text"]
}
},
"mimo-v2.5-tts": {
"name": "MiMo V2.5 TTS",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
},
"mimo-v2.5-tts-voiceclone": {
"name": "MiMo V2.5 TTS VoiceClone",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
},
"mimo-v2.5-tts-voicedesign": {
"name": "MiMo V2.5 TTS VoiceDesign",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
}
}
}
}
}
76 changes: 76 additions & 0 deletions .opencode/opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "mimo/mimo-v2.5-pro",
"provider": {
"mimo": {
"npm": "@ai-sdk/openai-compatible",
"name": "MiMo",
"options": {
"baseURL": "{env:OPENAI_API_ENDPOINT}",
"apiKey": "{env:OPENAI_API_KEY}"
},
"models": {
"mimo-v2.5-pro": {
"name": "MiMo V2.5 Pro",
"tool_call": true,
"limit": {
"context": 1048576,
"output": 131072
},
"modalities": {
"input": ["text", "image"],
"output": ["text"]
}
},
"mimo-v2.5": {
"name": "MiMo V2.5",
"tool_call": true,
"limit": {
"context": 1048576,
"output": 131072
},
"modalities": {
"input": ["text"],
"output": ["text"]
}
},
"mimo-v2.5-tts": {
"name": "MiMo V2.5 TTS",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
},
"mimo-v2.5-tts-voiceclone": {
"name": "MiMo V2.5 TTS VoiceClone",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
},
"mimo-v2.5-tts-voicedesign": {
"name": "MiMo V2.5 TTS VoiceDesign",
"tool_call": false,
"limit": {
"context": 8192,
"output": 4096
},
"modalities": {
"input": ["text"],
"output": ["audio"]
}
}
}
}
}
}
128 changes: 128 additions & 0 deletions .opencode/tools/AskUserQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* AskUserQuestion custom tool — 对齐 Tencent AskUserQuestion 契约
*
* 这不是 opencode builtin tool 的 override,而是我们新增的 custom tool。
* opencode 原生的 question tool 在 ACP 模式默认禁用且无 ACP 路由,
* 因此我们自行实现。工具名和 schema 对齐 Tencent SDK 的 AskUserQuestion。
*
* 版本声明(用于 check:tool-schemas 脚本识别):
* custom tool, not synced from opencode v1.14.33 src/tool/
*
* Schema 来源:packages/web/src/types/task-chat.ts:AskUserQuestionData
* - 前端 `task-chat.tsx` 按 `part.toolName === 'AskUserQuestion'` 匹配渲染
* AskUserForm;用其他名字前端识别不到
* - 前端从 `part.input.questions` 取字段,结构必须是
* `{ question, header, options:[{label, description}], multiSelect }`
* - askAnswers resume 契约也已存在:
* `{ [assistantMessageId]: { toolCallId, answers: { [header]: value } } }`
*
* OpenCode 文件名约定:文件名 = tool id(ACP tool_call.title)
* 所以文件名 `AskUserQuestion.ts` → opencode 注册的 tool id `AskUserQuestion`
* → 我们 runtime 把 ACP tool_call.title 透传为 AgentCallbackMessage.name
* → convertToSessionUpdate 把 name 放到 sessionUpdate.title
* → 前端 part.toolName = 'AskUserQuestion' ✓
*
* 运行时行为:
* - execute 发 fetch 到 ASK_USER_URL 阻塞等答案
* - 收到答案格式:{ ok: true, answers: { [header]: value } }
* - 格式化文本返回给 LLM
*
* env 契约(由 server spawn opencode 时注入):
* ASK_USER_URL — server 本地回环 endpoint
* ASK_USER_TOKEN — shared secret,X-Internal-Token header
* ASK_USER_CONVERSATION_ID — 当前会话 id
*/
import { z } from 'zod'

const OptionSchema = z.object({
label: z.string().describe('Short display text (1-5 words)'),
description: z.string().describe('Explanation of what this option means or its implications'),
})

const QuestionSchema = z.object({
question: z.string().describe('The complete question text (ends with ?)'),
header: z
.string()
.max(30)
.describe('Very short label for this question (max 30 chars, e.g. "Database", "Framework")'),
options: z.array(OptionSchema).min(2).max(4).describe('2-4 available choices'),
multiSelect: z.boolean().optional().describe('true = user may select multiple; false (default) = single selection'),
})

export default {
description:
'Ask the user one or more multiple-choice questions during execution. Use this when you need a decision or clarification that can be expressed as choices. Each question has a `question` (full text), `header` (short label), `options` (2-4 choices, each with `label` and `description`), and `multiSelect` (default false). The user may also type a custom answer.',
args: {
questions: z.array(QuestionSchema).min(1).describe('Questions to ask'),
},
async execute(
args: { questions: Array<z.infer<typeof QuestionSchema>> },
context: { sessionID?: string; callID?: string },
) {
const url = process.env.ASK_USER_URL
const token = process.env.ASK_USER_TOKEN
const conversationId = process.env.ASK_USER_CONVERSATION_ID

if (!url || !token || !conversationId) {
return {
output:
'Cannot ask questions: AskUser HTTP endpoint is not configured in this environment (ASK_USER_URL missing). Please ask the user directly in your next text response instead.',
}
}

// 用 opencode 的 callID 作为 toolCallId,与 tool_call 事件的 toolCallId 对齐
const toolCallId = context.callID || `ask-${context.sessionID ?? 'unknown'}-${Date.now()}`

const timeoutMs = Number(process.env.ASK_USER_TIMEOUT_MS || 10 * 60 * 1000)

try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Token': token,
},
body: JSON.stringify({
conversationId,
toolCallId,
questions: args.questions,
}),
signal: AbortSignal.timeout(timeoutMs + 5_000),
})

const data = (await res.json().catch(() => ({}))) as {
ok?: boolean
answers?: Record<string, string>
error?: string
}

if (!res.ok || !data.ok || !data.answers) {
return {
output: `Failed to get user answer: ${data.error ?? 'unknown error'} (status=${res.status}). Consider asking via plain text.`,
}
}

// 格式化答案给 LLM(answer key 是 question.header)
const formatted = args.questions
.map((q) => {
const a = data.answers![q.header]
return `"${q.question}" → ${a || '(unanswered)'}`
})
.join('; ')

return {
output: formatted,
hint: `You can continue with these answers in mind.`,
// output: `User answered: ${formatted}. You can continue with these answers in mind.`,
metadata: {
answers: data.answers,
},
}
} catch (e) {
const msg = (e as Error).message
return {
output: `Error asking user (${msg}). You can try asking via plain text in your next response.`,
}
}
},
}
83 changes: 83 additions & 0 deletions .opencode/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Global opencode tool override: bash
* 覆盖 opencode builtin bash tool,沙箱模式下通过 HTTP 路由到 SCF 沙箱。
*
* Schema 与 opencode builtin 完全对齐(v1.14.33 src/tool/bash.ts)。
* 如果 opencode 版本升级导致 builtin schema 变更,需同步更新本文件。
*
* 运行时行为:
* SANDBOX_MODE=1 → fetch SANDBOX_BASE_URL/api/tools/bash(沙箱内执行,与宿主机完全隔离)
* 否则 → 本地 execSync
*
* 注意:sandbox 模式下 workdir 参数会被忽略(沙箱有自己的 cwd,
* 沙箱 API 暂不支持覆盖 cwd)。
*/
import { z } from 'zod'
import { execSync } from 'node:child_process'

export default {
description:
'Executes a bash command. In sandbox mode, the command runs inside the SCF container (isolated from host).\n\n' +
'Usage:\n' +
'- The command argument is required.\n' +
'- You can specify an optional timeout in milliseconds (default 120000ms).\n' +
'- It is very helpful to write a clear, concise description of what this command does in 5-10 words.\n' +
'- Use the workdir parameter to change the working directory instead of "cd ... && command".\n' +
'- AVOID using this tool for file operations (reading/writing/editing/searching); use the specialized tools instead.',
args: {
command: z.string().describe('The command to execute'),
timeout: z.number().int().positive().optional().describe('Optional timeout in milliseconds'),
description: z.string().optional().describe('A clear, concise description of what this command does in 5-10 words'),
workdir: z
.string()
.optional()
.describe(
'The working directory to run the command in. Defaults to the current directory. Use this instead of "cd" commands.',
),
},
async execute(
args: { command: string; timeout?: number; description?: string; workdir?: string },
context: { directory?: string },
) {
const timeoutMs = args.timeout ?? 120_000
if (process.env.SANDBOX_MODE === '1') {
// workdir not forwarded to sandbox (sandbox manages its own cwd)
return await sandboxCall('bash', { command: args.command, timeout: timeoutMs }, timeoutMs)
}
try {
const out = execSync(args.command, {
timeout: timeoutMs,
cwd: args.workdir ?? context?.directory ?? undefined,
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 4,
})
return out
} catch (e) {
const err = e as { stdout?: Buffer | string; stderr?: Buffer | string; message: string }
const stdout = typeof err.stdout === 'string' ? err.stdout : (err.stdout?.toString() ?? '')
const stderr = typeof err.stderr === 'string' ? err.stderr : (err.stderr?.toString() ?? '')
throw new Error(`${err.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`)
}
},
}

async function sandboxCall(tool: string, body: unknown, timeoutMs: number): Promise<string | { output: string }> {
const baseUrl = process.env.SANDBOX_BASE_URL
if (!baseUrl) throw new Error('SANDBOX_BASE_URL not set')
const headers = JSON.parse(process.env.SANDBOX_AUTH_HEADERS_JSON || '{}') as Record<string, string>
const res = await fetch(`${baseUrl}/api/tools/${tool}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs + 5_000),
})
const data = (await res.json().catch(() => ({}))) as { success?: boolean; result?: unknown; error?: string }
if (!data.success) throw new Error(data.error ?? `sandbox ${tool} failed (${res.status})`)
const r = data.result as { output?: string; stdout?: string } | string | undefined
if (typeof r === 'string') return r
if (r && typeof r === 'object') {
if (typeof r.output === 'string') return r.output
if (typeof r.stdout === 'string') return r.stdout
}
return JSON.stringify(r ?? '')
}
Loading