opencode-mega-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/index.mjs +504 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# opencode-mega-agent
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that connects [MegaRouter](https://megarouter.ai) cloud agents to your editor. Agents are domain-trained specialist AIs (code reviewer, security auditor, debugger, etc.) that can read files, run commands, and search your codebase.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install opencode-mega-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Add to your `opencode.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"plugin": ["opencode-mega-agent"],
|
|
18
|
+
"provider": {
|
|
19
|
+
"mega-router": {
|
|
20
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
21
|
+
"options": {
|
|
22
|
+
"baseURL": "https://api.megarouter.ai/v1",
|
|
23
|
+
"apiKey": "sk-mega-..."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's it. The plugin auto-discovers available agents from the API on startup and registers them as native OpenCode subagents. No manual agent or model definitions needed.
|
|
31
|
+
|
|
32
|
+
Restart OpenCode — cloud agents will appear as subagents in the task tool.
|
|
33
|
+
|
|
34
|
+
## How It Works
|
|
35
|
+
|
|
36
|
+
On startup the plugin:
|
|
37
|
+
|
|
38
|
+
1. **Discovers agents** — calls `GET /api/v1/agents` to fetch available agents
|
|
39
|
+
2. **Injects config** — registers each agent as a native subagent (`cfg.agent`) and model (`cfg.provider.models`)
|
|
40
|
+
3. **Caches manifest** — writes to `.opencode/.mega-router-agents.json` for offline resilience
|
|
41
|
+
4. **Injects system prompt** — teaches the AI about available agents and how to invoke them
|
|
42
|
+
5. **Registers fallback tool** — `cloud_agent` tool with local file access via SSE tool loop
|
|
43
|
+
|
|
44
|
+
## Invocation
|
|
45
|
+
|
|
46
|
+
The AI can invoke agents two ways:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
task(subagent_type="code-reviewer", prompt="Review auth.go for security issues")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or via the fallback tool (if native subagent isn't available):
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
cloud_agent(agent="code-reviewer", message="Review auth.go for security issues")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Available Agents
|
|
59
|
+
|
|
60
|
+
| Agent | Specialty |
|
|
61
|
+
|-------|-----------|
|
|
62
|
+
| code-reviewer | Code review — bugs, security, style |
|
|
63
|
+
| security-reviewer | OWASP security audit |
|
|
64
|
+
| frontend-advisor | Frontend/UX — components, a11y, performance |
|
|
65
|
+
| code-debugger | Root cause debugging |
|
|
66
|
+
| devops-cli | Infrastructure & deployment |
|
|
67
|
+
| doc-writer | Technical documentation |
|
|
68
|
+
| data-analyst | Data analysis & visualization |
|
|
69
|
+
| research-assistant | Deep research with citations |
|
|
70
|
+
| git-assistant | Git workflow — commits, rebases |
|
|
71
|
+
| api-tester | API endpoint testing |
|
|
72
|
+
| office-hours | Product/business brainstorming |
|
|
73
|
+
| creative-writer | Stories, poems, copywriting |
|
|
74
|
+
|
|
75
|
+
## Offline / Backend Down
|
|
76
|
+
|
|
77
|
+
If the MegaRouter API is unreachable at startup, the plugin loads agents from:
|
|
78
|
+
|
|
79
|
+
1. **Disk cache** (`.opencode/.mega-router-agents.json`) — from last successful discovery
|
|
80
|
+
2. **Hardcoded fallback** — built-in list of 12 standard agents
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
| Source | Priority | Notes |
|
|
85
|
+
|--------|----------|-------|
|
|
86
|
+
| `opencode.json` provider.mega-router.options | 1st | Recommended |
|
|
87
|
+
| `MEGAROUTER_BASE_URL` / `MEGAROUTER_API_KEY` env vars | 2nd | Fallback |
|
|
88
|
+
|
|
89
|
+
## Troubleshooting
|
|
90
|
+
|
|
91
|
+
**"Authentication failed"** — Your API key is invalid or expired. Check `opencode.json` → `provider.mega-router.options.apiKey`.
|
|
92
|
+
|
|
93
|
+
**"Insufficient credits"** — Your MegaRouter balance is too low. Top up at the dashboard.
|
|
94
|
+
|
|
95
|
+
**"Agent not found"** — The agent ID doesn't exist. Check available agents in the system prompt or run `curl $BASE_URL/api/v1/agents`.
|
|
96
|
+
|
|
97
|
+
**Agents not showing up** — Make sure the MegaRouter backend is running and reachable at the configured `baseURL`. Check `.opencode/.mega-router-agents.json` for cached state.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cd plugins/opencode-cloud-agent
|
|
103
|
+
npm install
|
|
104
|
+
npx vitest run # run tests
|
|
105
|
+
npx vitest --watch # watch mode
|
|
106
|
+
```
|
package/index.mjs
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
// ── Fallback Agent Registry ─────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const FALLBACK_AGENTS = [
|
|
6
|
+
{ id: "code-reviewer", description: "Review code for bugs, security issues, race conditions, and style", category: "coding" },
|
|
7
|
+
{ id: "security-reviewer", description: "OWASP-trained security auditor — injection, auth bypass, supply chain", category: "security" },
|
|
8
|
+
{ id: "frontend-advisor", description: "Frontend/UX specialist — component architecture, a11y, performance", category: "coding" },
|
|
9
|
+
{ id: "devops-cli", description: "Infrastructure & deployment — Docker, CI/CD, cloud CLI tools", category: "coding" },
|
|
10
|
+
{ id: "code-debugger", description: "Systematic debugging from root cause to verified fix", category: "coding" },
|
|
11
|
+
{ id: "doc-writer", description: "Technical writing — READMEs, API docs, architecture guides", category: "productivity" },
|
|
12
|
+
{ id: "data-analyst", description: "Data analysis with visualization and statistical methods", category: "research" },
|
|
13
|
+
{ id: "research-assistant", description: "Deep research with citations and structured summaries", category: "research" },
|
|
14
|
+
{ id: "git-assistant", description: "Git workflow — clean commits, rebases, history analysis", category: "productivity" },
|
|
15
|
+
{ id: "api-tester", description: "API endpoint testing with repeatable test cases", category: "coding" },
|
|
16
|
+
{ id: "office-hours", description: "YC-style product/business brainstorming", category: "creative" },
|
|
17
|
+
{ id: "creative-writer", description: "Creative content — stories, poems, copywriting", category: "creative" },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const MODEL_PREFIX = "agent:"
|
|
23
|
+
const PROVIDER_KEY = "mega-router"
|
|
24
|
+
const DISCOVERY_TIMEOUT = 5_000 // ms — don't block startup too long
|
|
25
|
+
const MAX_TOOL_ROUNDS = 30 // safety limit per invocation
|
|
26
|
+
const TOTAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 min hard cap for entire cloud_agent call
|
|
27
|
+
const CACHE_FILENAME = ".opencode/.mega-router-agents.json"
|
|
28
|
+
|
|
29
|
+
let providerConfigCache = null
|
|
30
|
+
let discoveredAgents = []
|
|
31
|
+
let projectDir = null
|
|
32
|
+
|
|
33
|
+
function resolveConfig() {
|
|
34
|
+
if (providerConfigCache?.[PROVIDER_KEY]?.options) {
|
|
35
|
+
const opts = providerConfigCache[PROVIDER_KEY].options
|
|
36
|
+
const raw = opts.baseURL || opts.baseUrl || ""
|
|
37
|
+
const baseUrl = raw.replace(/\/v1\/?$/, "").replace(/\/$/, "")
|
|
38
|
+
return {
|
|
39
|
+
baseUrl: baseUrl || "http://localhost:8080",
|
|
40
|
+
apiKey: opts.apiKey || "",
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
baseUrl: (process.env.MEGAROUTER_BASE_URL || "http://localhost:8080").replace(/\/$/, ""),
|
|
45
|
+
apiKey: process.env.MEGAROUTER_API_KEY || "",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function agentList() {
|
|
50
|
+
return discoveredAgents.length > 0 ? discoveredAgents : FALLBACK_AGENTS
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Manifest Cache ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async function readCachedManifest(dir) {
|
|
56
|
+
if (!dir) return null
|
|
57
|
+
try {
|
|
58
|
+
const { readFileSync } = await import("fs")
|
|
59
|
+
const { join } = await import("path")
|
|
60
|
+
const raw = readFileSync(join(dir, CACHE_FILENAME), "utf-8")
|
|
61
|
+
const data = JSON.parse(raw)
|
|
62
|
+
if (Array.isArray(data) && data.length > 0 && data[0].id) return data
|
|
63
|
+
} catch { /* no cache or corrupt */ }
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeCachedManifest(dir, agents) {
|
|
68
|
+
if (!dir || !agents?.length) return
|
|
69
|
+
try {
|
|
70
|
+
const { writeFileSync, mkdirSync } = await import("fs")
|
|
71
|
+
const { join, dirname } = await import("path")
|
|
72
|
+
const path = join(dir, CACHE_FILENAME)
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
74
|
+
writeFileSync(path, JSON.stringify(agents, null, 2), "utf-8")
|
|
75
|
+
} catch { /* best-effort, non-fatal */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Agent Discovery ─────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async function discoverAgents(baseUrl, apiKey) {
|
|
81
|
+
const ac = new AbortController()
|
|
82
|
+
const timer = setTimeout(() => ac.abort(), DISCOVERY_TIMEOUT)
|
|
83
|
+
try {
|
|
84
|
+
const headers = {}
|
|
85
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
|
|
86
|
+
const resp = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
87
|
+
headers,
|
|
88
|
+
signal: ac.signal,
|
|
89
|
+
})
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
if (!resp.ok) return null
|
|
92
|
+
const data = await resp.json()
|
|
93
|
+
const agents = (data.agents || data || [])
|
|
94
|
+
return agents
|
|
95
|
+
.filter(a => a.id && a.id !== "direct")
|
|
96
|
+
.map(a => ({
|
|
97
|
+
id: a.id,
|
|
98
|
+
name: a.name || a.id,
|
|
99
|
+
description: a.description || "",
|
|
100
|
+
category: a.category || "",
|
|
101
|
+
tags: a.tags || [],
|
|
102
|
+
}))
|
|
103
|
+
} catch {
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── SSE Parser ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function parseSSEStream(raw) {
|
|
112
|
+
const chunks = []
|
|
113
|
+
for (const line of raw.split("\n")) {
|
|
114
|
+
if (!line.startsWith("data: ")) continue
|
|
115
|
+
const payload = line.slice(6).trim()
|
|
116
|
+
if (payload === "[DONE]") break
|
|
117
|
+
try { chunks.push(JSON.parse(payload)) } catch { /* skip malformed */ }
|
|
118
|
+
}
|
|
119
|
+
return chunks
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractFromChunks(chunks) {
|
|
123
|
+
let conversationId = ""
|
|
124
|
+
let text = ""
|
|
125
|
+
let toolCall = null
|
|
126
|
+
let finishReason = null
|
|
127
|
+
|
|
128
|
+
for (const chunk of chunks) {
|
|
129
|
+
if (chunk.x_conversation_id) conversationId = chunk.x_conversation_id
|
|
130
|
+
const choice = chunk.choices?.[0]
|
|
131
|
+
if (!choice) continue
|
|
132
|
+
if (choice.delta?.content) text += choice.delta.content
|
|
133
|
+
if (choice.delta?.tool_calls?.length) {
|
|
134
|
+
const tc = choice.delta.tool_calls[0]
|
|
135
|
+
toolCall = { id: tc.id, name: tc.function?.name, arguments: tc.function?.arguments }
|
|
136
|
+
}
|
|
137
|
+
if (choice.finish_reason) finishReason = choice.finish_reason
|
|
138
|
+
}
|
|
139
|
+
return { conversationId, text, toolCall, finishReason }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Error Helpers ───────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function parseAPIError(responseText) {
|
|
145
|
+
try {
|
|
146
|
+
const data = JSON.parse(responseText)
|
|
147
|
+
return data?.error?.message || data?.error || responseText
|
|
148
|
+
} catch {
|
|
149
|
+
return responseText
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatAPIError(status, responseText) {
|
|
154
|
+
const msg = parseAPIError(responseText)
|
|
155
|
+
switch (status) {
|
|
156
|
+
case 401:
|
|
157
|
+
return `Authentication failed: ${msg}. Check your MegaRouter API key in opencode.json provider.mega-router.options.apiKey`
|
|
158
|
+
case 402:
|
|
159
|
+
return `Insufficient credits. ${msg}. Top up your balance to continue using cloud agents.`
|
|
160
|
+
case 404:
|
|
161
|
+
return `Agent not found: ${msg}. Run with a valid agent name — check available agents in the system prompt.`
|
|
162
|
+
case 429:
|
|
163
|
+
return `Rate limited: ${msg}. Too many requests — wait a moment and retry.`
|
|
164
|
+
case 500:
|
|
165
|
+
case 502:
|
|
166
|
+
return `MegaRouter server error (${status}): ${msg}. The backend may be restarting — retry in a few seconds.`
|
|
167
|
+
case 503:
|
|
168
|
+
return `MegaRouter unavailable (503): ${msg}. The service is temporarily down — retry shortly.`
|
|
169
|
+
default:
|
|
170
|
+
return `MegaRouter API error (${status}): ${msg}`
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Local Tool Executor ─────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
async function executeLocalTool(toolName, argsJson, directory) {
|
|
177
|
+
let args
|
|
178
|
+
try { args = JSON.parse(argsJson) } catch { return `Error: invalid JSON arguments for ${toolName}: ${argsJson.slice(0, 200)}` }
|
|
179
|
+
|
|
180
|
+
const { execSync } = await import("child_process")
|
|
181
|
+
const { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } = await import("fs")
|
|
182
|
+
const { join, dirname } = await import("path")
|
|
183
|
+
|
|
184
|
+
const resolve = (p) => {
|
|
185
|
+
if (!p) return directory
|
|
186
|
+
if (p.startsWith("/")) return p
|
|
187
|
+
return join(directory, p)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
switch (toolName) {
|
|
192
|
+
case "Read": {
|
|
193
|
+
const target = resolve(args.file_path)
|
|
194
|
+
const stat = statSync(target)
|
|
195
|
+
if (stat.isDirectory()) {
|
|
196
|
+
return readdirSync(target, { withFileTypes: true })
|
|
197
|
+
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
|
198
|
+
.join("\n")
|
|
199
|
+
}
|
|
200
|
+
const content = readFileSync(target, "utf-8")
|
|
201
|
+
return content.length > 100_000
|
|
202
|
+
? content.slice(0, 100_000) + "\n... [truncated at 100 KB]"
|
|
203
|
+
: content
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case "Bash": {
|
|
207
|
+
const result = execSync(args.command, {
|
|
208
|
+
cwd: directory, timeout: 30_000,
|
|
209
|
+
maxBuffer: 1024 * 1024, encoding: "utf-8",
|
|
210
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
211
|
+
})
|
|
212
|
+
return (result.length > 100_000
|
|
213
|
+
? result.slice(0, 100_000) + "\n... [truncated at 100 KB]"
|
|
214
|
+
: result) || "(no output)"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "Glob": {
|
|
218
|
+
const cmd = `find . -path './${args.pattern}' 2>/dev/null | head -200`
|
|
219
|
+
try {
|
|
220
|
+
const r = execSync(cmd, { cwd: directory, timeout: 10_000, maxBuffer: 256 * 1024, encoding: "utf-8" })
|
|
221
|
+
return r.trim() || "(no matches)"
|
|
222
|
+
} catch { return "(no matches)" }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "Grep": {
|
|
226
|
+
const inc = args.include ? `--include='${args.include}'` : ""
|
|
227
|
+
const paths = args.paths?.join(" ") || "."
|
|
228
|
+
const safe = args.pattern.replace(/'/g, "'\\''")
|
|
229
|
+
const cmd = `grep -rn ${inc} '${safe}' ${paths} 2>/dev/null | head -150`
|
|
230
|
+
try {
|
|
231
|
+
return execSync(cmd, { cwd: directory, timeout: 15_000, maxBuffer: 512 * 1024, encoding: "utf-8" }) || "(no matches)"
|
|
232
|
+
} catch (e) { return e.stdout || "(no matches)" }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case "Edit": {
|
|
236
|
+
const target = resolve(args.file_path)
|
|
237
|
+
const content = readFileSync(target, "utf-8")
|
|
238
|
+
if (!content.includes(args.old_string)) return `Error: old_string not found in ${args.file_path}. File exists but the search string doesn't match.`
|
|
239
|
+
writeFileSync(target, content.replace(args.old_string, args.new_string), "utf-8")
|
|
240
|
+
return `Edited ${args.file_path}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "Write": {
|
|
244
|
+
const target = resolve(args.file_path)
|
|
245
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
246
|
+
writeFileSync(target, args.content, "utf-8")
|
|
247
|
+
return `Wrote ${args.file_path}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
default:
|
|
251
|
+
return `Tool "${toolName}" is not available in this context. Use Read, Bash, Glob, or Grep.`
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return `Error executing ${toolName}: ${err.message}`
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Client Tool Definitions ─────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const CLIENT_TOOLS = [
|
|
261
|
+
{ type: "function", function: { name: "Read", description: "Read file contents or list directory contents", parameters: { type: "object", properties: { file_path: { type: "string", description: "Path to file or directory" } }, required: ["file_path"] } } },
|
|
262
|
+
{ type: "function", function: { name: "Bash", description: "Execute bash command in local environment", parameters: { type: "object", properties: { command: { type: "string", description: "Bash command to execute" } }, required: ["command"] } } },
|
|
263
|
+
{ type: "function", function: { name: "Glob", description: "Find files matching pattern", parameters: { type: "object", properties: { pattern: { type: "string", description: "Glob pattern like **/*.go" } }, required: ["pattern"] } } },
|
|
264
|
+
{ type: "function", function: { name: "Grep", description: "Search code with regex pattern", parameters: { type: "object", properties: { pattern: { type: "string", description: "Regex pattern" }, paths: { type: "array", items: { type: "string" }, description: "Paths to search" }, include: { type: "string", description: "File pattern to include" } }, required: ["pattern"] } } },
|
|
265
|
+
{ type: "function", function: { name: "Edit", description: "Replace text in file", parameters: { type: "object", properties: { file_path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" } }, required: ["file_path", "old_string", "new_string"] } } },
|
|
266
|
+
{ type: "function", function: { name: "Write", description: "Create or overwrite file", parameters: { type: "object", properties: { file_path: { type: "string" }, content: { type: "string" } }, required: ["file_path", "content"] } } },
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
// ── Cloud Agent Call with Tool Loop ─────────────────────────────────
|
|
270
|
+
|
|
271
|
+
async function callCloudAgent(baseUrl, apiKey, agentName, message, abort, directory) {
|
|
272
|
+
const model = `${MODEL_PREFIX}${agentName}`
|
|
273
|
+
const url = `${baseUrl}/v1/chat/completions`
|
|
274
|
+
const deadline = Date.now() + TOTAL_TIMEOUT_MS
|
|
275
|
+
|
|
276
|
+
let conversationId = ""
|
|
277
|
+
let messages = [{ role: "user", content: message }]
|
|
278
|
+
let fullText = ""
|
|
279
|
+
|
|
280
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
281
|
+
if (abort?.aborted) throw new Error("Aborted by user")
|
|
282
|
+
if (Date.now() > deadline) {
|
|
283
|
+
return fullText || `Cloud agent timed out after ${TOTAL_TIMEOUT_MS / 1000}s. Partial output above (if any).`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const headers = { "Content-Type": "application/json" }
|
|
287
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
|
|
288
|
+
if (conversationId) headers["X-Conversation-ID"] = conversationId
|
|
289
|
+
|
|
290
|
+
const response = await fetch(url, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers,
|
|
293
|
+
body: JSON.stringify({ model, messages, stream: true, tools: CLIENT_TOOLS }),
|
|
294
|
+
signal: abort,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
const errText = await response.text().catch(() => "")
|
|
299
|
+
throw new Error(formatAPIError(response.status, errText))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const raw = await response.text()
|
|
303
|
+
const chunks = parseSSEStream(raw)
|
|
304
|
+
const { conversationId: cid, text, toolCall, finishReason } = extractFromChunks(chunks)
|
|
305
|
+
|
|
306
|
+
if (cid) conversationId = cid
|
|
307
|
+
if (text) fullText += text
|
|
308
|
+
|
|
309
|
+
if (finishReason === "tool_calls" && toolCall) {
|
|
310
|
+
const toolResult = await executeLocalTool(toolCall.name, toolCall.arguments, directory)
|
|
311
|
+
messages = [
|
|
312
|
+
...messages,
|
|
313
|
+
{
|
|
314
|
+
role: "assistant",
|
|
315
|
+
content: null,
|
|
316
|
+
tool_calls: [{ id: toolCall.id, type: "function", function: { name: toolCall.name, arguments: toolCall.arguments } }],
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
role: "tool",
|
|
320
|
+
tool_call_id: toolCall.id,
|
|
321
|
+
content: toolResult,
|
|
322
|
+
},
|
|
323
|
+
]
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return fullText || "(Cloud agent returned no text)"
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return fullText || `Cloud agent exhausted ${MAX_TOOL_ROUNDS} tool rounds without completing.`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── System Prompt Builder ───────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function buildSystemPrompt(agents) {
|
|
336
|
+
const grouped = {}
|
|
337
|
+
for (const a of agents) {
|
|
338
|
+
const cat = a.category || "other"
|
|
339
|
+
if (!grouped[cat]) grouped[cat] = []
|
|
340
|
+
grouped[cat].push(a)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const categoryLabels = {
|
|
344
|
+
coding: "Coding & Review",
|
|
345
|
+
security: "Security",
|
|
346
|
+
research: "Research & Analysis",
|
|
347
|
+
productivity: "Productivity",
|
|
348
|
+
creative: "Creative",
|
|
349
|
+
other: "Other",
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let table = ""
|
|
353
|
+
for (const [cat, label] of Object.entries(categoryLabels)) {
|
|
354
|
+
const items = grouped[cat]
|
|
355
|
+
if (!items?.length) continue
|
|
356
|
+
table += `\n**${label}**\n`
|
|
357
|
+
for (const a of items) {
|
|
358
|
+
table += `| ${a.id} | ${a.description} |\n`
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return `## MegaRouter Cloud Agents
|
|
363
|
+
|
|
364
|
+
You have access to MegaRouter cloud agents — domain-trained specialist AIs with dedicated skills and sandboxed execution. They produce better, more focused results than you for their specialty.
|
|
365
|
+
|
|
366
|
+
### How to invoke
|
|
367
|
+
|
|
368
|
+
Cloud agents are registered as native subagents. Use \`task()\`:
|
|
369
|
+
\`\`\`
|
|
370
|
+
task(subagent_type="code-reviewer", prompt="Review backend/internal/auth/session.go for security issues.")
|
|
371
|
+
\`\`\`
|
|
372
|
+
|
|
373
|
+
If \`task()\` is unavailable, use the \`cloud_agent\` tool as fallback:
|
|
374
|
+
\`\`\`
|
|
375
|
+
cloud_agent(agent="code-reviewer", message="Review backend/internal/auth/session.go for security issues.")
|
|
376
|
+
\`\`\`
|
|
377
|
+
|
|
378
|
+
### Available agents
|
|
379
|
+
|
|
380
|
+
| Agent | Specialty |
|
|
381
|
+
|-------|-----------|${table}
|
|
382
|
+
|
|
383
|
+
### Tips
|
|
384
|
+
- **Be specific**: Include file paths, code snippets, and exactly what you need.
|
|
385
|
+
- **One task per call**: Don't bundle unrelated tasks.
|
|
386
|
+
- Cloud agents can read files, run commands, and search your local codebase.`
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Tool Creator ────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function createCloudAgentTool() {
|
|
392
|
+
const fallbackDesc = FALLBACK_AGENTS.map(a => `${a.id} (${a.description})`).join("; ")
|
|
393
|
+
|
|
394
|
+
return tool({
|
|
395
|
+
description: `Delegate a task to a MegaRouter cloud agent — a domain-trained specialist AI with dedicated skills and sandboxed execution. Cloud agents can read files, run commands, and search your codebase. This is a fallback — prefer using task(subagent_type="agent-name") when available.
|
|
396
|
+
|
|
397
|
+
Available agents: ${fallbackDesc}
|
|
398
|
+
|
|
399
|
+
Use when: code review, security audit, frontend advice, debugging, documentation, DevOps, research, data analysis, API testing, or creative writing.`,
|
|
400
|
+
|
|
401
|
+
args: {
|
|
402
|
+
agent: tool.schema.string().describe(
|
|
403
|
+
`Cloud agent name. One of: ${FALLBACK_AGENTS.map(a => a.id).join(", ")}`
|
|
404
|
+
),
|
|
405
|
+
message: tool.schema.string().describe(
|
|
406
|
+
"The task to send. Be specific — include file paths, code context, and what you need."
|
|
407
|
+
),
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
async execute(args, ctx) {
|
|
411
|
+
const agentName = args.agent.trim().toLowerCase()
|
|
412
|
+
const agents = agentList()
|
|
413
|
+
const known = agents.find(a => a.id === agentName)
|
|
414
|
+
|
|
415
|
+
if (!known) {
|
|
416
|
+
const available = agents.map(a => a.id)
|
|
417
|
+
const closest = available.find(id => id.includes(agentName) || agentName.includes(id))
|
|
418
|
+
const hint = closest ? ` Did you mean "${closest}"?` : ""
|
|
419
|
+
return `Unknown cloud agent "${args.agent}".${hint} Available: ${available.join(", ")}`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const config = resolveConfig()
|
|
423
|
+
try {
|
|
424
|
+
const result = await callCloudAgent(
|
|
425
|
+
config.baseUrl,
|
|
426
|
+
config.apiKey,
|
|
427
|
+
agentName,
|
|
428
|
+
args.message,
|
|
429
|
+
ctx.abort,
|
|
430
|
+
ctx.directory || process.cwd(),
|
|
431
|
+
)
|
|
432
|
+
return `[Cloud Agent: ${agentName}]\n\n${result}`
|
|
433
|
+
} catch (err) {
|
|
434
|
+
return `Cloud agent "${agentName}" error: ${err.message || err}`
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Plugin Entry Point ──────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
const plugin = async (ctx) => {
|
|
443
|
+
projectDir = ctx?.directory || ctx?.worktree || null
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
config: async (cfg) => {
|
|
447
|
+
providerConfigCache = null
|
|
448
|
+
discoveredAgents = []
|
|
449
|
+
|
|
450
|
+
if (cfg?.provider?.[PROVIDER_KEY]) {
|
|
451
|
+
providerConfigCache = cfg.provider
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const config = resolveConfig()
|
|
455
|
+
if (config.baseUrl) {
|
|
456
|
+
const agents = await discoverAgents(config.baseUrl, config.apiKey)
|
|
457
|
+
if (agents && agents.length > 0) {
|
|
458
|
+
discoveredAgents = agents
|
|
459
|
+
await writeCachedManifest(projectDir, agents)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (discoveredAgents.length === 0) {
|
|
464
|
+
const cached = await readCachedManifest(projectDir)
|
|
465
|
+
if (cached) discoveredAgents = cached
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const agents = agentList()
|
|
469
|
+
|
|
470
|
+
if (!cfg.agent) cfg.agent = {}
|
|
471
|
+
for (const agent of agents) {
|
|
472
|
+
if (cfg.agent[agent.id]) continue
|
|
473
|
+
cfg.agent[agent.id] = {
|
|
474
|
+
model: `${PROVIDER_KEY}/${MODEL_PREFIX}${agent.id}`,
|
|
475
|
+
mode: "subagent",
|
|
476
|
+
description: agent.description,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (cfg.provider?.[PROVIDER_KEY]) {
|
|
481
|
+
if (!cfg.provider[PROVIDER_KEY].models) cfg.provider[PROVIDER_KEY].models = {}
|
|
482
|
+
for (const agent of agents) {
|
|
483
|
+
const modelKey = `${MODEL_PREFIX}${agent.id}`
|
|
484
|
+
if (cfg.provider[PROVIDER_KEY].models[modelKey]) continue
|
|
485
|
+
cfg.provider[PROVIDER_KEY].models[modelKey] = {
|
|
486
|
+
name: agent.name || agent.id,
|
|
487
|
+
tool_call: true,
|
|
488
|
+
limit: { context: 200000, output: 32000 },
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
tool: {
|
|
495
|
+
cloud_agent: createCloudAgentTool(),
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
499
|
+
output.system.push(buildSystemPrompt(agentList()))
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export default plugin
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-mega-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for MegaRouter cloud agents — auto-discovers and registers specialist AI agents (code review, security audit, debugging, etc.) as native subagents",
|
|
5
|
+
"main": "index.mjs",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"opencode",
|
|
10
|
+
"opencode-plugin",
|
|
11
|
+
"megarouter",
|
|
12
|
+
"cloud-agent",
|
|
13
|
+
"ai-agent",
|
|
14
|
+
"code-review",
|
|
15
|
+
"security-audit"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/kiyoakii/mega-router.git",
|
|
20
|
+
"directory": "plugins/opencode-cloud-agent"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/kiyoakii/mega-router/tree/main/plugins/opencode-cloud-agent#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/kiyoakii/mega-router/issues"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.mjs",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@opencode-ai/plugin": "^1.3.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"vitest": "^3.1.1"
|
|
38
|
+
}
|
|
39
|
+
}
|