opencode-raven 2.2.1 → 2.3.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/index.ts CHANGED
@@ -1,2444 +1,14 @@
1
- import type { Plugin, PluginInput } from "@opencode-ai/plugin"
2
- import { tool } from "@opencode-ai/plugin"
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
5
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
6
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
8
- import { join } from "node:path"
9
- import { homedir, tmpdir } from "node:os"
10
-
11
- // ── Resolve paths relative to this package (works in node_modules/) ──
12
- const PKG_DIR = import.meta.dirname!
13
-
14
- const RAVEN_MD = join(PKG_DIR, "Raven.md")
15
- const RAVEN_MCP_MD = join(PKG_DIR, "RavenMcp.md")
16
- const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
17
- const PACKAGE_JSON = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"))
18
- const PACKAGE_NAME = PACKAGE_JSON.name || "opencode-raven"
19
- const PACKAGE_VERSION = PACKAGE_JSON.version || "0.0.0"
20
-
21
- // ── Tools/MCPs that should be intercepted for non-Raven agents ──
22
- const DEFAULT_ROUTE_TOOLS = [
23
- "grep",
24
- "glob",
25
- "webfetch",
26
- "fetch",
27
- "websearch",
28
- "bash",
29
- ]
30
-
31
- const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
32
-
33
- const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
34
- context7: {
35
- enabled: true,
36
- type: "remote",
37
- url: "https://mcp.context7.com/mcp",
38
- description: "Library/framework documentation lookup.",
39
- },
40
- exa: {
41
- enabled: true,
42
- type: "remote",
43
- url: "https://mcp.exa.ai/mcp",
44
- description: "Live web search and webpage fetching.",
45
- },
46
- grep_app: {
47
- enabled: true,
48
- type: "remote",
49
- url: "https://mcp.grep.app",
50
- description: "Public GitHub code search and real-world examples.",
51
- },
52
- }
53
-
54
- const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
55
- context7: {
56
- autoGeneratedDescription: "Finds current library, framework, SDK, package, and API documentation; useful for implementation, configuration, and debugging with version-specific docs.",
57
- },
58
- exa: {
59
- autoGeneratedDescription: "Searches the live web, fetches webpages, researches companies/products, and gathers current information from online sources.",
60
- },
61
- grep_app: {
62
- autoGeneratedDescription: "Searches public GitHub repositories for real-world code examples, usage patterns, configuration snippets, and implementation references.",
63
- },
64
- }
65
-
66
- const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "raven_mcp_bridge", "task", "subtask"])
67
- const MAX_TIMER_MS = 2_147_483_647
68
- const MAX_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_MS / 1000)
69
-
70
- // ── Bash commands that look like search workarounds ──
71
- const SEARCH_BASH_RE = /\b(?:rg|ripgrep|grep|egrep|fgrep|git\s+grep|ack|ag\b|findstr|Select-String)\b|\b(?:Get-ChildItem|gci)\b(?=[^|;&\n]*(?:-Recurse|-Filter|-Include|\s-[A-Za-z]*r[A-Za-z]*\b))|\bdir\b(?=[^|;&\n]*(?:[/-][sS]\b|-Recurse|-Filter|-Include))|\bls\b(?=[^|;&\n]*(?:\s-[A-Za-z]*R[A-Za-z]*\b|--recursive\b))|\bfind\b\s+.*(?:-name|-type)\b/i
72
-
73
- // Strip quoted content to avoid false positives (e.g. echo "use grep here")
74
- function stripHeredocs(cmd: string): string {
75
- return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "")
76
- }
77
-
78
- function stripShellComments(cmd: string): string {
79
- return cmd
80
- .split("\n")
81
- .map((line) => line.replace(/(^|\s)#.*$/, "$1").trimEnd())
82
- .join("\n")
83
- }
84
-
85
- function stripQuotedContent(cmd: string): string {
86
- return stripShellComments(stripHeredocs(cmd)
87
- .replace(/'[^']*'/g, "''")
88
- .replace(/"[^"]*"/g, '""'))
89
- }
90
-
91
- function unquoteShellPayload(value: string): string {
92
- const payload = value.trim()
93
- const quote = payload[0]
94
- return (quote === '"' || quote === "'") && payload.endsWith(quote)
95
- ? payload.slice(1, -1)
96
- : payload
97
- }
98
-
99
- function wrappedShellPayload(cmd: string): string | undefined {
100
- const command = stripShellComments(stripHeredocs(cmd)).trim()
101
- const match = command.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s+([\s\S]+))?$/)
102
- if (!match) return undefined
103
-
104
- const executable = (match[1] ?? match[2] ?? match[3]).replace(/\\/g, "/").split("/").pop()?.toLowerCase()
105
- const rest = match[4] ?? ""
106
- if (executable === "cmd" || executable === "cmd.exe") {
107
- const payload = rest.match(/^(?:\/[dqs]\s+)*\/c\s+([\s\S]+)$/i)?.[1]
108
- return payload === undefined ? undefined : unquoteShellPayload(payload)
109
- }
110
-
111
- if (["sh", "sh.exe", "bash", "bash.exe", "zsh", "zsh.exe", "dash", "dash.exe"].includes(executable ?? "")) {
112
- const payload = rest.match(/^-[a-z]*c\s+([\s\S]+)$/i)?.[1]
113
- return payload === undefined ? undefined : unquoteShellPayload(payload)
114
- }
115
-
116
- if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(executable ?? "")) {
117
- const payload = rest.match(/(?:^|\s)-(?:command|c)\s+([\s\S]+)$/i)?.[1]
118
- if (payload === undefined) return undefined
119
- const unquoted = unquoteShellPayload(payload)
120
- return unquoted.replace(
121
- /(^|(?:(?:&&|\|\||[;|\n{(])\s*))&\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g,
122
- (_match, prefix, doubleQuoted, singleQuoted, bare) => `${prefix}${doubleQuoted ?? singleQuoted ?? bare}`,
123
- )
124
- }
125
-
126
- return undefined
127
- }
128
-
129
- function splitPipelineSegments(cmd: string): string[] {
130
- const segments: string[] = []
131
- let current = ""
132
- for (let i = 0; i < cmd.length; i++) {
133
- const char = cmd[i]
134
- const prev = cmd[i - 1]
135
- const next = cmd[i + 1]
136
- if (char === "|" && prev !== "|" && next !== "|") {
137
- segments.push(current)
138
- current = ""
139
- } else {
140
- current += char
141
- }
142
- }
143
- segments.push(current)
144
- return segments
145
- }
146
-
147
- function isOutputFilterSegment(segment: string): boolean {
148
- return /^\s*(?:grep|ripgrep|rg|egrep|fgrep|findstr|Select-String|head|tail)\b/i.test(segment)
149
- }
150
-
151
- function hasSearchAfterCommandSeparator(segment: string): boolean {
152
- const separator = segment.search(/;|&&|\|\|/)
153
- return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
154
- }
155
-
156
- function commandLooksLikeSearch(cmd: string): boolean {
157
- const wrapped = wrappedShellPayload(cmd)
158
- if (wrapped !== undefined) return commandLooksLikeSearch(wrapped)
159
-
160
- const stripped = stripQuotedContent(cmd)
161
- const lower = stripped.toLowerCase().trim()
162
- if (/^cmd\s+\/c\s+"?(?:findstr|find|tree)\b/.test(lower)) return true
163
- if (/^cmd\s+\/c\s+"?dir\b[^\n]*\s\/s\b/.test(lower)) return true
164
-
165
- return splitPipelineSegments(stripped).some((segment, index) => {
166
- // Allow bounded filters over output already produced by the previous command:
167
- // pacman -Qi libarchive | head -15
168
- // 7z i | grep -i udf
169
- // These are not Raven-worthy filesystem/web/doc searches.
170
- if (index > 0 && isOutputFilterSegment(segment)) return hasSearchAfterCommandSeparator(segment)
171
- return SEARCH_BASH_RE.test(segment)
172
- })
173
- }
174
-
175
- function isSearchBash(tool: string, args: any): boolean {
176
- if (tool !== "bash") return false
177
- return commandLooksLikeSearch(String(args?.command ?? ""))
178
- }
179
-
180
- // ── Config file shape ──
181
- interface OnDemandMcpToolInfo {
182
- name: string
183
- description?: string
184
- }
185
-
186
- interface BaseOnDemandMcpServer {
187
- enabled?: boolean
188
- timeout?: number
189
- description?: string
190
- }
191
-
192
- interface OnDemandMcpMetadata {
193
- autoGeneratedDescription?: string
194
- autoGeneratedTools?: OnDemandMcpToolInfo[]
195
- avoidedSchemaBytes?: number
196
- status?: "pending" | "ready" | "failed"
197
- lastError?: string
198
- lastCheckedAt?: string
199
- }
200
-
201
- interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
202
- type: "remote"
203
- url: string
204
- headers?: Record<string, string>
205
- }
206
-
207
- interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
208
- type: "local"
209
- command: string[]
210
- environment?: Record<string, string>
211
- cwd?: string
212
- }
213
-
214
- type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
215
- type OnDemandMcpDescriptionDetail = "full" | "minimized"
216
- type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
217
-
218
- interface RavenSeekConfig {
219
- model?: string
220
- reasoning_effort?: string
221
- instructions?: string
222
- routeTools?: string[]
223
- routeToolKeywords?: string[]
224
- excludeAgents?: string[]
225
- excludeTools?: string[]
226
- timeout?: number
227
- }
228
-
229
- interface RavenMcpConfig {
230
- model?: string
231
- reasoning_effort?: string
232
- instructions?: string
233
- timeout?: number
234
- descriptionDetail?: OnDemandMcpDescriptionDetail
235
- onDemandMcpServers?: Record<string, OnDemandMcpServer>
236
- }
237
-
238
- interface RavenConfig {
239
- raven_seek: RavenSeekConfig
240
- raven_mcp: RavenMcpConfig
241
- stats?: { bytes: number }
242
- }
243
-
244
- // ── Parse Raven.md frontmatter ──
245
- const ravenMd = readFileSync(RAVEN_MD, "utf-8")
246
- const { frontmatter: fm, prompt: ravenPrompt } = parseRavenMd(ravenMd)
247
- const ravenMcpMd = readFileSync(RAVEN_MCP_MD, "utf-8")
248
- const { frontmatter: mcpFm, prompt: ravenMcpPrompt } = parseRavenMd(ravenMcpMd)
249
-
250
- const DEFAULT_CONFIG: RavenConfig = {
251
- raven_seek: {
252
- model: fm.model,
253
- reasoning_effort: fm.reasoning_effort,
254
- instructions: "",
255
- routeTools: DEFAULT_ROUTE_TOOLS,
256
- routeToolKeywords: DEFAULT_ROUTE_TOOL_KEYWORDS,
257
- excludeAgents: [],
258
- excludeTools: [],
259
- timeout: 600,
260
- },
261
- raven_mcp: {
262
- model: mcpFm.model,
263
- reasoning_effort: mcpFm.reasoning_effort,
264
- instructions: "",
265
- timeout: 600,
266
- descriptionDetail: "full",
267
- onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
1
+ import type { Plugin as V1Plugin } from "@opencode-ai/plugin"
2
+ import type { Plugin as V2Plugin } from "@opencode/plugin"
3
+ import createRavenRuntime from "./raven-core"
4
+
5
+ // Object entrypoints are supported by OpenCode 1.18.29+. Each host selects its
6
+ // own lifecycle; the v2 SDK is never loaded by a v1 installation.
7
+ export default {
8
+ id: "opencode-raven",
9
+ server: (async (input) => createRavenRuntime(input)) satisfies V1Plugin,
10
+ async setup(context) {
11
+ const { setupRaven } = await import("./v2")
12
+ return setupRaven(context)
268
13
  },
269
- }
270
-
271
- function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
272
- const parts = raw.split("---")
273
- if (parts.length < 3) {
274
- throw new Error("Raven.md missing frontmatter (--- delimiters)")
275
- }
276
- return { frontmatter: parseYaml(parts[1]), prompt: parts.slice(2).join("---").trim() }
277
- }
278
-
279
- // ── Minimal YAML parser (handles the structure used in Raven.md) ──
280
- function parseYaml(yaml: string): Record<string, any> {
281
- const lines = yaml.split("\n")
282
- const root: Record<string, any> = {}
283
- const stack: Array<{ obj: Record<string, any>; indent: number }> = [
284
- { obj: root, indent: -1 },
285
- ]
286
-
287
- for (const rawLine of lines) {
288
- const line = rawLine.trimEnd()
289
- if (!line.trim() || line.trim().startsWith("#")) continue
290
-
291
- const indent = line.search(/\S/)
292
- const colonIdx = line.indexOf(":")
293
- if (colonIdx === -1) continue
294
-
295
- const rawKey = line.slice(indent, colonIdx).trim()
296
- const key =
297
- (rawKey.startsWith('"') && rawKey.endsWith('"')) ||
298
- (rawKey.startsWith("'") && rawKey.endsWith("'"))
299
- ? rawKey.slice(1, -1)
300
- : rawKey
301
-
302
- const rawValue = line.slice(colonIdx + 1).trim()
303
-
304
- while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
305
- stack.pop()
306
- }
307
- const current = stack[stack.length - 1].obj
308
-
309
- if (!rawValue) {
310
- const nested: Record<string, any> = {}
311
- current[key] = nested
312
- stack.push({ obj: nested, indent })
313
- } else {
314
- let value: any = rawValue
315
- if (
316
- (value.startsWith('"') && value.endsWith('"')) ||
317
- (value.startsWith("'") && value.endsWith("'"))
318
- ) {
319
- value = value.slice(1, -1)
320
- } else if (value === "true") {
321
- value = true
322
- } else if (value === "false") {
323
- value = false
324
- } else if (/^\d+$/.test(value)) {
325
- value = parseInt(value, 10)
326
- }
327
- current[key] = value
328
- }
329
- }
330
-
331
- return root
332
- }
333
-
334
- // ── Move unknown frontmatter fields into options ──
335
- const KNOWN_KEYS = new Set([
336
- "description", "mode", "hidden", "model", "permission",
337
- "prompt", "name", "color", "steps", "disable",
338
- "temperature", "top_p", "variant",
339
- ])
340
-
341
- function extractOptions(fm: Record<string, any>): Record<string, any> {
342
- const options: Record<string, any> = {}
343
- for (const key of Object.keys(fm)) {
344
- if (!KNOWN_KEYS.has(key)) options[key] = fm[key]
345
- }
346
- return options
347
- }
348
-
349
- function uniqueStrings(value: unknown, fallback: string[] = []): string[] {
350
- const source = Array.isArray(value) ? value : fallback
351
- return [...new Set(source.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
352
- }
353
-
354
- function cloneOnDemandMcpServers(value: Record<string, OnDemandMcpServer>): Record<string, OnDemandMcpServer> {
355
- return JSON.parse(JSON.stringify(value))
356
- }
357
-
358
- function cloneOnDemandMcpMetadata(value: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
359
- return JSON.parse(JSON.stringify(value))
360
- }
361
-
362
- function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
363
- if (!Array.isArray(value)) return undefined
364
- const tools = value
365
- .filter((item) => item && typeof item === "object" && typeof item.name === "string" && item.name.trim())
366
- .map((item) => ({
367
- name: item.name.trim(),
368
- ...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
369
- }))
370
- return tools.length ? tools : undefined
371
- }
372
-
373
- function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
374
- if (!value || typeof value !== "object") return undefined
375
- const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")
376
- return entries.length ? Object.fromEntries(entries) : undefined
377
- }
378
-
379
- function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
380
- if (!value || typeof value !== "object") return undefined
381
- const source = value as Record<string, any>
382
- const base = {
383
- enabled: source.enabled !== false,
384
- ...(typeof source.timeout === "number" && Number.isFinite(source.timeout) && source.timeout >= 1 && source.timeout <= MAX_TIMER_MS
385
- ? { timeout: Math.floor(source.timeout) }
386
- : {}),
387
- ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
388
- }
389
-
390
- if (source.type === "local") {
391
- const commandParts = Array.isArray(source.command)
392
- ? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
393
- : []
394
- if (!commandParts.length) return undefined
395
- const environment = normalizeStringRecord(source.environment)
396
- return {
397
- ...base,
398
- type: "local",
399
- command: commandParts,
400
- ...(environment ? { environment } : {}),
401
- ...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
402
- }
403
- }
404
-
405
- if (source.type === "remote" || typeof source.url === "string") {
406
- if (typeof source.url !== "string" || !source.url.trim()) return undefined
407
- const headers = normalizeStringRecord(source.headers)
408
- return {
409
- ...base,
410
- type: "remote",
411
- url: source.url.trim(),
412
- ...(headers ? { headers } : {}),
413
- }
414
- }
415
-
416
- return undefined
417
- }
418
-
419
- function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, OnDemandMcpMetadata> = {}): Record<string, OnDemandMcpMetadata> {
420
- const source = value && typeof value === "object" ? value as Record<string, any> : fallback
421
- const result: Record<string, OnDemandMcpMetadata> = {}
422
- for (const [key, raw] of Object.entries(source)) {
423
- if (!key.trim() || !raw || typeof raw !== "object") continue
424
- const item = raw as Record<string, unknown>
425
- const metadata: OnDemandMcpMetadata = {}
426
- if (typeof item.autoGeneratedDescription === "string" && item.autoGeneratedDescription.trim()) {
427
- metadata.autoGeneratedDescription = item.autoGeneratedDescription.trim()
428
- }
429
- const tools = normalizeToolInfo(item.autoGeneratedTools)
430
- if (tools) metadata.autoGeneratedTools = tools
431
- if (typeof item.avoidedSchemaBytes === "number" && Number.isFinite(item.avoidedSchemaBytes) && item.avoidedSchemaBytes >= 0) {
432
- metadata.avoidedSchemaBytes = Math.round(item.avoidedSchemaBytes)
433
- }
434
- if (item.status === "pending" || item.status === "ready" || item.status === "failed") metadata.status = item.status
435
- if (typeof item.lastError === "string" && item.lastError.trim()) metadata.lastError = item.lastError.trim()
436
- if (typeof item.lastCheckedAt === "string" && item.lastCheckedAt.trim()) metadata.lastCheckedAt = item.lastCheckedAt.trim()
437
- if (metadata.autoGeneratedDescription || metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes || metadata.status || metadata.lastError || metadata.lastCheckedAt) result[key.trim()] = metadata
438
- }
439
- return result
440
- }
441
-
442
- function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
443
- const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
444
- const result: Record<string, OnDemandMcpServer> = {}
445
- for (const [key, raw] of Object.entries(source)) {
446
- if (!key.trim()) continue
447
- const server = normalizeOnDemandMcpServer(raw)
448
- if (server) result[key.trim()] = server
449
- }
450
- return result
451
- }
452
-
453
- // ── Plugin ──
454
- export default (async (input: PluginInput) => {
455
- const client = input.client
456
-
457
- // Raven state lives under the global opencode config directory.
458
- const opencodeConfigDir = join(homedir(), ".config", "opencode")
459
- const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
460
- const configFile = join(ravenConfigDir, "raven-config.json")
461
- const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
462
- const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
463
-
464
- function normalizeConfig(raw: any): RavenConfig {
465
- const source = raw && typeof raw === "object" ? raw : {}
466
- const seekSource = source.raven_seek && typeof source.raven_seek === "object" ? source.raven_seek : source
467
- const mcpSource = source.raven_mcp && typeof source.raven_mcp === "object" ? source.raven_mcp : source
468
- const legacyInstructions = typeof source.ravenInstructions === "string" ? source.ravenInstructions : undefined
469
- const normalized: RavenConfig = {
470
- raven_seek: {
471
- model: typeof seekSource.model === "string" ? seekSource.model : DEFAULT_CONFIG.raven_seek.model,
472
- reasoning_effort: typeof seekSource.reasoning_effort === "string" ? seekSource.reasoning_effort : DEFAULT_CONFIG.raven_seek.reasoning_effort,
473
- instructions: typeof seekSource.instructions === "string" ? seekSource.instructions : legacyInstructions ?? "",
474
- routeTools: uniqueStrings(seekSource.routeTools, DEFAULT_ROUTE_TOOLS),
475
- routeToolKeywords: uniqueStrings(seekSource.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS),
476
- excludeAgents: uniqueStrings(seekSource.excludeAgents),
477
- excludeTools: uniqueStrings(seekSource.excludeTools),
478
- timeout: typeof seekSource.timeout === "number" && Number.isFinite(seekSource.timeout)
479
- && seekSource.timeout >= 10 && seekSource.timeout <= MAX_TIMEOUT_SECONDS
480
- ? Math.floor(seekSource.timeout)
481
- : DEFAULT_CONFIG.raven_seek.timeout,
482
- },
483
- raven_mcp: {
484
- model: typeof mcpSource.model === "string" ? mcpSource.model : DEFAULT_CONFIG.raven_mcp.model,
485
- reasoning_effort: typeof mcpSource.reasoning_effort === "string" ? mcpSource.reasoning_effort : DEFAULT_CONFIG.raven_mcp.reasoning_effort,
486
- instructions: typeof mcpSource.instructions === "string" ? mcpSource.instructions : legacyInstructions ?? "",
487
- timeout: typeof mcpSource.timeout === "number" && Number.isFinite(mcpSource.timeout)
488
- && mcpSource.timeout >= 10 && mcpSource.timeout <= MAX_TIMEOUT_SECONDS
489
- ? Math.floor(mcpSource.timeout)
490
- : DEFAULT_CONFIG.raven_mcp.timeout,
491
- descriptionDetail: mcpSource.descriptionDetail === "minimized"
492
- ? "minimized"
493
- : mcpSource.descriptionDetail === "full"
494
- ? "full"
495
- : source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full",
496
- onDemandMcpServers: mcpSource.onDemandMcpServers === undefined
497
- ? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
498
- : normalizeOnDemandMcpServers(mcpSource.onDemandMcpServers),
499
- },
500
- }
501
- normalized.stats = source.stats && typeof source.stats === "object"
502
- && typeof source.stats.bytes === "number" && Number.isFinite(source.stats.bytes) && source.stats.bytes >= 0
503
- ? { bytes: Math.round(source.stats.bytes) }
504
- : undefined
505
-
506
- return normalized
507
- }
508
-
509
- function loadConfig(): RavenConfig {
510
- if (existsSync(configFile)) {
511
- try {
512
- const raw = JSON.parse(readFileSync(configFile, "utf-8"))
513
- const normalized = normalizeConfig(raw)
514
- if (!raw.raven_seek || !raw.raven_mcp) writeConfig(normalized)
515
- return normalized
516
- } catch {
517
- // Preserve malformed or partially-written config so the user can repair it.
518
- return { ...DEFAULT_CONFIG }
519
- }
520
- }
521
- // Auto-create config file with defaults on first run
522
- writeConfig(DEFAULT_CONFIG)
523
- return { ...DEFAULT_CONFIG }
524
- }
525
-
526
- function writeConfig(config: RavenConfig) {
527
- try {
528
- mkdirSync(ravenConfigDir, { recursive: true })
529
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
530
- } catch { /* non-fatal: config changes remain in-session */ }
531
- }
532
-
533
- function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
534
- const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
535
- try {
536
- if (existsSync(mcpMetadataFile)) {
537
- return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
538
- }
539
- } catch { /* ignore corruption, use defaults */ }
540
- saveMcpMetadata(defaults)
541
- return defaults
542
- }
543
-
544
- function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
545
- try {
546
- mkdirSync(ravenConfigDir, { recursive: true })
547
- writeFileSync(mcpMetadataFile, JSON.stringify(metadata, null, 2) + "\n")
548
- } catch { /* non-fatal: generated metadata can be refreshed */ }
549
- }
550
-
551
- function saveDynamicGuidance() {
552
- try {
553
- mkdirSync(ravenConfigDir, { recursive: true })
554
- writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
555
- } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
556
- }
557
-
558
- let config = loadConfig()
559
-
560
- function updateConfig(update: Partial<RavenConfig> | ((latest: RavenConfig) => Partial<RavenConfig>)): RavenConfig {
561
- try {
562
- const raw = existsSync(configFile) ? JSON.parse(readFileSync(configFile, "utf-8")) : config
563
- const latest = normalizeConfig(raw)
564
- const patch = typeof update === "function" ? update(latest) : update
565
- config = normalizeConfig({
566
- ...latest,
567
- ...patch,
568
- raven_seek: { ...latest.raven_seek, ...patch.raven_seek },
569
- raven_mcp: { ...latest.raven_mcp, ...patch.raven_mcp },
570
- })
571
- writeConfig(config)
572
- } catch {
573
- // Keep malformed or partially-written external edits intact instead of replacing them.
574
- const patch = typeof update === "function" ? update(config) : update
575
- config = normalizeConfig({
576
- ...config,
577
- ...patch,
578
- raven_seek: { ...config.raven_seek, ...patch.raven_seek },
579
- raven_mcp: { ...config.raven_mcp, ...patch.raven_mcp },
580
- })
581
- }
582
- return config
583
- }
584
- let mcpMetadata = loadMcpMetadata()
585
- type DelegationKind = "seek" | "mcp"
586
- const ravenSessions = new Set<string>()
587
- const ravenSessionKinds = new Map<string, DelegationKind>()
588
- const activeRavenPrompts = new Set<string>()
589
- const ravenTaskCalls = new Set<string>()
590
- const sessionAgents = new Map<string, string>()
591
- const ravenSessionParents = new Map<string, string>()
592
- const ravenErrorWaiters = new Map<string, (error: Error) => void>()
593
- const ravenProgressWaiters = new Map<string, () => void>()
594
- let globalMcpServers: string[] = []
595
- let duplicateMcpServers: string[] = []
596
- let updateInfo: { current: string; latest?: string; available: boolean } | undefined
597
- let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
598
- let updateToastPending = false
599
- let mcpFailureToastPending = false
600
- let duplicateMcpToastPending = false
601
- let mcpRefreshScheduled = false
602
- let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
603
- let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
604
- const mcpRefreshControllers = new Set<AbortController>()
605
- let lastNotifiedMcpFailureKey = ""
606
- const ravenProviderIssues: Partial<Record<DelegationKind, string>> = {}
607
-
608
- // Replace generated instructions from older releases before OpenCode consumes them.
609
- saveDynamicGuidance()
610
- resetOnDemandMcpRuntimeStatuses()
611
-
612
- function resetOnDemandMcpRuntimeStatuses() {
613
- let changed = false
614
- for (const [name] of activeOnDemandMcpEntries()) {
615
- const metadata = mcpMetadata[name] ?? {}
616
- if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
617
- mcpMetadata[name] = { ...metadata, status: "pending" }
618
- delete mcpMetadata[name].lastError
619
- delete mcpMetadata[name].lastCheckedAt
620
- changed = true
621
- }
622
- }
623
- if (changed) saveMcpMetadata(mcpMetadata)
624
- }
625
-
626
- // ── Check if an agent is excluded from Raven enforcement (case-insensitive) ──
627
- function isExcluded(agent: string | undefined): boolean {
628
- if (!agent || !config.raven_seek.excludeAgents?.length) return false
629
- const lower = agent.toLowerCase()
630
- return config.raven_seek.excludeAgents.some((a) => a.toLowerCase() === lower)
631
- }
632
-
633
- function isRouteConfigured(toolName: string): boolean {
634
- const tool = toolName.toLowerCase()
635
- if (NEVER_ROUTE_TOOLS.has(tool)) return false
636
- if (config.raven_seek.routeTools?.some((name) => name.toLowerCase() === tool)) return true
637
- if (config.raven_seek.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
638
- return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
639
- }
640
-
641
- function compactArgs(value: any): any {
642
- if (Array.isArray(value)) {
643
- return value.map(compactArgs).filter((item) => item !== undefined)
644
- }
645
- if (!value || typeof value !== "object") return value
646
- const result: Record<string, any> = {}
647
- for (const [key, raw] of Object.entries(value)) {
648
- const item = compactArgs(raw)
649
- if (item === undefined || item === null || item === "") continue
650
- if (Array.isArray(item) && item.length === 0) continue
651
- if (typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0) continue
652
- result[key] = item
653
- }
654
- return result
655
- }
656
-
657
- function attemptedQuery(tool: string, args: any): string {
658
- const compact = compactArgs(args)
659
- if (!compact || typeof compact !== "object") return `${tool}: ${JSON.stringify(compact)}`
660
- const direct = compact.query ?? compact.pattern ?? compact.url ?? compact.urls ?? compact.command ?? compact.path ?? compact.filePath
661
- const value = direct !== undefined ? direct : compact
662
- const text = typeof value === "string" ? value : JSON.stringify(value)
663
- const query = value === compact ? `${tool} ${text}` : `${tool}: ${text}`
664
- return query.length > 300 ? `${query.slice(0, 297)}...` : query
665
- }
666
-
667
- function rerouteMessage(tool: string, args: any): string {
668
- if (ravenProviderIssues.seek) {
669
- return `Raven search is currently unavailable: ${ravenProviderIssues.seek}
670
-
671
- ${ravenRecoveryInstructions("seek")}`
672
- }
673
- return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
674
- }
675
-
676
- function ravenRecoveryInstructions(kind: DelegationKind): string {
677
- const command = kind === "mcp" ? "/raven mcp model <provider/model>" : "/raven seek model <provider/model>"
678
- return `Stop trying Raven-routed tools or MCPs for this request. Inform the user that Raven cannot run until its model/provider is changed or fixed.
679
-
680
- Tell the user to switch Raven's model with:
681
- ${command}
682
-
683
- Example:
684
- ${command.replace("<provider/model>", "openai/gpt-5.5")}
685
-
686
- Then restart opencode for the model change to take effect. If the task can be completed without Raven, continue without routed tools and clearly mention what could not be checked.`
687
- }
688
-
689
- function routeSummary(): string {
690
- const tools = config.raven_seek.routeTools?.length ? config.raven_seek.routeTools.join(", ") : "(none)"
691
- const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
692
- const keywords = config.raven_seek.routeToolKeywords?.length ? config.raven_seek.routeToolKeywords.join(", ") : "(none)"
693
- const duplicates = duplicateMcpServers.length ? `\n Duplicate MCP config: ${duplicateMcpServers.join(", ")} (configured globally and on-demand)` : ""
694
- return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
695
- }
696
-
697
- function mcpSummary(): string {
698
- const buckets = onDemandMcpStatusBuckets()
699
- const total = buckets.loaded.length + buckets.failed.length + buckets.pending.length
700
- if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`
701
- const lines = [
702
- "Raven on-demand MCPs (always behind Raven):",
703
- ` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
704
- ` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
705
- ` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
706
- ` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
707
- ]
708
- return lines.join("\n")
709
- }
710
-
711
- function ravenAgentPrompt(): string {
712
- const extra = config.raven_seek.instructions?.trim()
713
- const prompt = ravenPrompt
714
- return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
715
- }
716
-
717
- function ravenMcpAgentPrompt(): string {
718
- const extra = config.raven_mcp.instructions?.trim()
719
- const onDemand = onDemandMcpGuidance("raven")
720
- const prompt = `${ravenMcpPrompt}\n\nOn-demand MCP guidance:\n${onDemand}`
721
- return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
722
- }
723
-
724
- function compactText(value: unknown): string {
725
- return String(value ?? "").replace(/\s+/g, " ").trim()
726
- }
727
-
728
- function truncateText(value: unknown, max = 240): string {
729
- const text = compactText(value)
730
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
731
- }
732
-
733
- function dynamicGuidanceMcpOrder(): string[] | undefined {
734
- try {
735
- if (!existsSync(dynamicGuidanceFile)) return undefined
736
- const names: string[] = []
737
- for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
738
- const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
739
- if (name && !names.includes(name)) names.push(name)
740
- }
741
- return names
742
- } catch {
743
- return undefined
744
- }
745
- }
746
-
747
- function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
748
- if (!preferredOrder?.length) return entries
749
- const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
750
- const existing = entries
751
- .filter(([name]) => order.has(name.toLowerCase()))
752
- .sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
753
- const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
754
- return [...existing, ...added]
755
- }
756
-
757
- function firstSentenceText(value: unknown): string {
758
- const text = compactText(value)
759
- const period = text.indexOf(".")
760
- return period === -1 ? text : text.slice(0, period + 1)
761
- }
762
-
763
- function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
764
- const servers = config.raven_mcp.onDemandMcpServers ?? {}
765
- const activeEntries = activeOnDemandMcpEntries()
766
- const entries = orderedOnDemandMcpEntries(
767
- target === "raven"
768
- ? activeEntries
769
- : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
770
- preferredOrder,
771
- )
772
- if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
773
- if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
774
- if (!entries.length) return "No loaded on-demand MCPs are available."
775
-
776
- const detail = config.raven_mcp.descriptionDetail ?? "full"
777
- const lines = entries.map(([name, server]) => {
778
- const metadata = mcpMetadata[name] ?? {}
779
- const userDescription = firstSentenceText(server.description)
780
- const generated = firstSentenceText(metadata.autoGeneratedDescription)
781
- const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
782
- const summary = parts.length ? parts.join(" ") : "Description discovery pending."
783
- if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
784
- const tools = metadata.autoGeneratedTools
785
- .map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
786
- .join("; ")
787
- return `- ${name}: ${summary} Tools: ${tools}`
788
- })
789
- if (target === "raven") {
790
- return `Use raven_mcp_bridge for these on-demand MCP capabilities:\n${lines.join("\n")}`
791
- }
792
- return `Non-Raven agents can use these on-demand MCP capabilities through the public raven_mcp delegator. Use raven_mcp for matching requests and include the target MCP server and likely capability when obvious. The raven-mcp agent itself must call raven_mcp_bridge directly instead:\n${lines.join("\n")}`
793
- }
794
-
795
- function resolveEnvPlaceholders(value: string): string {
796
- return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
797
- const envValue = process.env[name]
798
- if (envValue === undefined) throw new Error(`Missing environment variable ${name}`)
799
- return envValue
800
- })
801
- }
802
-
803
- function resolveStringRecord(value: Record<string, string> | undefined): Record<string, string> | undefined {
804
- if (!value) return undefined
805
- const result: Record<string, string> = {}
806
- for (const [key, raw] of Object.entries(value)) {
807
- result[key] = resolveEnvPlaceholders(raw)
808
- }
809
- return result
810
- }
811
-
812
- function cleanProcessEnv(): Record<string, string> {
813
- const result: Record<string, string> = {}
814
- for (const [key, value] of Object.entries(process.env)) {
815
- if (typeof value === "string") result[key] = value
816
- }
817
- return result
818
- }
819
-
820
- function providerErrorMessage(error: any): string {
821
- const name = String(error?.name ?? "")
822
- const data = error?.data && typeof error.data === "object" ? error.data : {}
823
- const status = typeof data.statusCode === "number" ? ` HTTP ${data.statusCode}.` : ""
824
- const body = typeof data.responseBody === "string" && data.responseBody.trim() ? ` ${truncateText(data.responseBody, 500)}` : ""
825
- const message = String(data.message ?? error?.message ?? error ?? "Unknown provider error")
826
- return `Raven provider/API error${name ? ` (${name})` : ""}.${status} ${message}${body}`.trim()
827
- }
828
-
829
- function waitForRavenProviderError(sessionId: string): Promise<never> {
830
- return new Promise((_resolve, reject) => {
831
- ravenErrorWaiters.set(sessionId, reject)
832
- })
833
- }
834
-
835
- function waitForRavenFirstProgress(sessionId: string, ms: number): Promise<never> {
836
- return new Promise((_resolve, reject) => {
837
- const timer = setTimeout(() => {
838
- ravenProgressWaiters.delete(sessionId)
839
- reject(new Error(`Raven did not produce an initial response within ${Math.round(ms / 1000)}s. If the Raven provider showed a retry dialog, this is likely an auth/quota/subscription issue. Check the Raven subagent/provider account, or retry after switching Raven to a working model.`))
840
- }, ms)
841
- ravenProgressWaiters.set(sessionId, () => {
842
- clearTimeout(timer)
843
- ravenProgressWaiters.delete(sessionId)
844
- })
845
- })
846
- }
847
-
848
- function reportRavenProviderError(sessionId: string, error: any) {
849
- const waiter = ravenErrorWaiters.get(sessionId)
850
- if (!waiter) return
851
- waiter(new Error(providerErrorMessage(error)))
852
- }
853
-
854
- function reportRavenProgress(sessionId: string) {
855
- ravenProgressWaiters.get(sessionId)?.()
856
- }
857
-
858
- function clearRavenWaiters(sessionId: string) {
859
- ravenErrorWaiters.delete(sessionId)
860
- ravenProgressWaiters.get(sessionId)?.()
861
- }
862
-
863
- type McpConnection = {
864
- client: Client
865
- transport: any
866
- lastUsed: number
867
- active: number
868
- }
869
-
870
- type McpConnectionAttempt = {
871
- promise: Promise<McpConnection>
872
- controller: AbortController
873
- waiters: number
874
- }
875
-
876
- const mcpConnections = new Map<string, McpConnection>()
877
- const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
878
- let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
879
-
880
- function mcpTimeoutMs(server: OnDemandMcpServer): number {
881
- return server.timeout ?? (config.raven_mcp.timeout ?? 600) * 1000
882
- }
883
-
884
- async function closeMcpClient(client: Client, transport: any) {
885
- try {
886
- await transport?.close?.()
887
- } catch { /* best-effort */ }
888
- try {
889
- await client.close()
890
- } catch { /* best-effort */ }
891
- }
892
-
893
- async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
894
- const controller = new AbortController()
895
- let timedOut = false
896
- const abort = () => controller.abort(signal?.reason)
897
- if (signal?.aborted) abort()
898
- else signal?.addEventListener("abort", abort, { once: true })
899
- const timer = setTimeout(() => {
900
- timedOut = true
901
- controller.abort()
902
- }, timeout)
903
- const aborted = new Promise<never>((_resolve, reject) => {
904
- const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
905
- if (controller.signal.aborted) fail()
906
- else controller.signal.addEventListener("abort", fail, { once: true })
907
- })
908
-
909
- try {
910
- await Promise.race([
911
- client.connect(transport, { signal: controller.signal, timeout }),
912
- aborted,
913
- ])
914
- } catch (error) {
915
- await closeMcpClient(client, transport)
916
- throw error
917
- } finally {
918
- clearTimeout(timer)
919
- signal?.removeEventListener("abort", abort)
920
- }
921
- }
922
-
923
- async function closeMcpConnection(connection: McpConnection) {
924
- await closeMcpClient(connection.client, connection.transport)
925
- }
926
-
927
- function stopMcpIdleTimerIfUnused() {
928
- if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
929
- clearInterval(mcpIdleTimer)
930
- mcpIdleTimer = undefined
931
- }
932
-
933
- function ensureMcpIdleTimer() {
934
- if (mcpIdleTimer) return
935
- mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
936
- ;(mcpIdleTimer as any).unref?.()
937
- }
938
-
939
- async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
940
- const existing = mcpConnections.get(key)
941
- if (!existing || (expected && existing !== expected)) return
942
- mcpConnections.delete(key)
943
- await closeMcpConnection(existing)
944
- stopMcpIdleTimerIfUnused()
945
- }
946
-
947
- function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
948
- const now = Date.now()
949
- for (const [key, connection] of mcpConnections) {
950
- if (!connection.active && now - connection.lastUsed > maxAgeMs) {
951
- void closeMcpConnectionKey(key, connection)
952
- }
953
- }
954
- }
955
-
956
- async function closeMcpConnectionsForSession(sessionId: string) {
957
- const prefix = `${sessionId}:`
958
- const pending: Promise<McpConnection>[] = []
959
- for (const [key, attempt] of mcpConnectionAttempts) {
960
- if (!key.startsWith(prefix)) continue
961
- attempt.controller.abort()
962
- pending.push(attempt.promise)
963
- }
964
- await Promise.allSettled(pending)
965
- await Promise.all(
966
- [...mcpConnections.keys()]
967
- .filter((key) => key.startsWith(prefix))
968
- .map((key) => closeMcpConnectionKey(key)),
969
- )
970
- }
971
-
972
- function mcpConnectionKey(sessionId: string, serverName: string): string {
973
- return `${sessionId}:${serverName.toLowerCase()}`
974
- }
975
-
976
- function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
977
- const entries = activeOnDemandMcpEntries()
978
- return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
979
- }
980
-
981
- function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
982
- return Object.entries(config.raven_mcp.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
983
- }
984
-
985
- function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
986
- if (!signal) {
987
- attempt.waiters++
988
- return attempt.promise.finally(() => attempt.waiters--)
989
- }
990
- if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
991
- attempt.waiters++
992
- return new Promise((resolve, reject) => {
993
- let settled = false
994
- const finish = () => {
995
- if (settled) return false
996
- settled = true
997
- attempt.waiters--
998
- signal.removeEventListener("abort", abort)
999
- return true
1000
- }
1001
- const abort = () => {
1002
- if (!finish()) return
1003
- if (!attempt.waiters) attempt.controller.abort(signal.reason)
1004
- reject(new Error("MCP request cancelled"))
1005
- }
1006
- signal.addEventListener("abort", abort, { once: true })
1007
- attempt.promise.then(
1008
- (connection) => { if (finish()) resolve(connection) },
1009
- (error) => { if (finish()) reject(error) },
1010
- )
1011
- })
1012
- }
1013
-
1014
- async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
1015
- const timeout = mcpTimeoutMs(server)
1016
- const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1017
-
1018
- if (server.type === "local") {
1019
- const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
1020
- const transport = new StdioClientTransport({
1021
- command: resolveEnvPlaceholders(server.command[0]),
1022
- args: server.command.slice(1).map((arg) => resolveEnvPlaceholders(arg)),
1023
- env,
1024
- stderr: "pipe",
1025
- cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
1026
- })
1027
- transport.stderr?.on("data", () => {})
1028
- transport.stderr?.on("error", () => {})
1029
- await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1030
- return { client, transport, lastUsed: Date.now(), active: 0 }
1031
- }
1032
-
1033
- const url = new URL(resolveEnvPlaceholders(server.url))
1034
- const headers = resolveStringRecord(server.headers)
1035
- const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1036
- try {
1037
- await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1038
- return { client, transport, lastUsed: Date.now(), active: 0 }
1039
- } catch (err) {
1040
- if (signal?.aborted) throw err
1041
- const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1042
- const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1043
- try {
1044
- await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
1045
- return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
1046
- } catch (fallbackError) {
1047
- throw fallbackError
1048
- }
1049
- }
1050
- }
1051
-
1052
- async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
1053
- if (signal?.aborted) throw new Error("MCP request cancelled")
1054
- closeIdleMcpConnections()
1055
- const match = getOnDemandMcpServer(serverName)
1056
- if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
1057
- const [canonicalName, server] = match
1058
- const key = mcpConnectionKey(sessionId, canonicalName)
1059
- const existing = mcpConnections.get(key)
1060
- if (existing) {
1061
- existing.lastUsed = Date.now()
1062
- return existing
1063
- }
1064
-
1065
- const pending = mcpConnectionAttempts.get(key)
1066
- if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
1067
- if (pending) mcpConnectionAttempts.delete(key)
1068
-
1069
- const controller = new AbortController()
1070
- const attempt = { controller, waiters: 0 } as McpConnectionAttempt
1071
- attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
1072
- .then(async (connection) => {
1073
- if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
1074
- await closeMcpConnection(connection)
1075
- throw new Error("MCP connection attempt cancelled")
1076
- }
1077
- mcpConnections.set(key, connection)
1078
- connection.client.onclose = () => {
1079
- if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
1080
- stopMcpIdleTimerIfUnused()
1081
- }
1082
- ensureMcpIdleTimer()
1083
- return connection
1084
- })
1085
- .finally(() => {
1086
- if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
1087
- stopMcpIdleTimerIfUnused()
1088
- })
1089
- mcpConnectionAttempts.set(key, attempt)
1090
- ensureMcpIdleTimer()
1091
- return waitForMcpConnection(attempt, signal)
1092
- }
1093
-
1094
- async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1095
- const tools: any[] = []
1096
- let cursor: string | undefined
1097
- do {
1098
- const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
1099
- tools.push(...(result.tools ?? []))
1100
- cursor = result.nextCursor
1101
- } while (cursor)
1102
- return tools
1103
- }
1104
-
1105
- async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1106
- const resources: any[] = []
1107
- let cursor: string | undefined
1108
- do {
1109
- const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
1110
- resources.push(...(result.resources ?? []))
1111
- cursor = result.nextCursor
1112
- } while (cursor)
1113
- return resources
1114
- }
1115
-
1116
- async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1117
- const prompts: any[] = []
1118
- let cursor: string | undefined
1119
- do {
1120
- const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
1121
- prompts.push(...(result.prompts ?? []))
1122
- cursor = result.nextCursor
1123
- } while (cursor)
1124
- return prompts
1125
- }
1126
-
1127
- function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
1128
- return tools
1129
- .filter((item) => item?.name)
1130
- .map((item) => ({
1131
- name: String(item.name),
1132
- ...(item.description ? { description: compactText(item.description) } : {}),
1133
- }))
1134
- }
1135
-
1136
- function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
1137
- if (!tools.length) return 0
1138
- return JSON.stringify(tools).length
1139
- }
1140
-
1141
- function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
1142
- if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
1143
- const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
1144
- const joined = descriptions.slice(0, 6).join("; ")
1145
- return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
1146
- }
1147
-
1148
- async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
1149
- if (signal?.aborted) return { refreshed: [], failed: [] }
1150
- const entries = activeOnDemandMcpEntries()
1151
- .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
1152
- .filter(([name]) => {
1153
- const metadata = mcpMetadata[name]
1154
- return force || metadata?.status === "pending" || metadata?.status === "failed" || !metadata?.autoGeneratedDescription || !metadata.autoGeneratedTools?.length || metadata.avoidedSchemaBytes === undefined
1155
- })
1156
- const refreshed: string[] = []
1157
- const failed: string[] = []
1158
- if (!entries.length) return { refreshed, failed }
1159
- let changed = false
1160
- for (const [name, server] of entries) {
1161
- if (signal?.aborted) break
1162
- let connection: McpConnection | undefined
1163
- const checkedAt = new Date().toISOString()
1164
- try {
1165
- connection = await connectOnDemandMcp(name, server, signal)
1166
- const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
1167
- const tools = generatedToolsFromMcpTools(rawTools)
1168
- mcpMetadata[name] = {
1169
- autoGeneratedTools: tools,
1170
- autoGeneratedDescription: synthesizeMcpDescription(name, tools),
1171
- avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(rawTools),
1172
- status: "ready",
1173
- lastCheckedAt: checkedAt,
1174
- }
1175
- changed = true
1176
- refreshed.push(name)
1177
- } catch (err: any) {
1178
- if (signal?.aborted) break
1179
- mcpMetadata[name] = {
1180
- ...(mcpMetadata[name] ?? {}),
1181
- status: "failed",
1182
- lastError: truncateText(err?.message ?? err, 500),
1183
- lastCheckedAt: checkedAt,
1184
- }
1185
- changed = true
1186
- failed.push(name)
1187
- // Keep startup/discovery best-effort; /raven mcp exposes failures.
1188
- } finally {
1189
- if (connection) await closeMcpConnection(connection)
1190
- }
1191
- }
1192
- if (changed) {
1193
- saveMcpMetadata(mcpMetadata)
1194
- saveDynamicGuidance()
1195
- }
1196
- return { refreshed, failed }
1197
- }
1198
-
1199
- function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
1200
- if (signal.aborted) return Promise.resolve()
1201
- return new Promise((resolve) => {
1202
- const timer = setTimeout(done, ms)
1203
- function done() {
1204
- clearTimeout(timer)
1205
- signal.removeEventListener("abort", done)
1206
- resolve()
1207
- }
1208
- signal.addEventListener("abort", done, { once: true })
1209
- })
1210
- }
1211
-
1212
- function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
1213
- const controller = new AbortController()
1214
- mcpRefreshControllers.add(controller)
1215
- const run = mcpRefreshQueue
1216
- .catch(() => undefined)
1217
- .then(async () => {
1218
- if (delay) await waitForRefreshDelay(delay, controller.signal)
1219
- return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
1220
- })
1221
- .finally(() => mcpRefreshControllers.delete(controller))
1222
- mcpRefreshQueue = run
1223
- return run
1224
- }
1225
-
1226
- function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
1227
- if (mcpRefreshScheduled) return mcpRefreshPromise!
1228
- mcpRefreshScheduled = true
1229
- mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
1230
- return mcpRefreshPromise
1231
- }
1232
-
1233
- function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
1234
- const metadata = mcpMetadata[name]
1235
- if (metadata?.status) return metadata.status
1236
- return "pending"
1237
- }
1238
-
1239
- function displayMcpStatus(status: "pending" | "ready" | "failed"): "pending" | "loaded" | "failed" {
1240
- return status === "ready" ? "loaded" : status
1241
- }
1242
-
1243
- function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
1244
- const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
1245
- for (const [name] of activeOnDemandMcpEntries()) {
1246
- const status = onDemandMcpRuntimeStatus(name)
1247
- if (status === "ready") buckets.loaded.push(name)
1248
- else if (status === "failed") buckets.failed.push(name)
1249
- else buckets.pending.push(name)
1250
- }
1251
- return buckets
1252
- }
1253
-
1254
- function failedOnDemandMcpNames(names?: string[]): string[] {
1255
- const configured = activeOnDemandMcpEntries().map(([name]) => name)
1256
- const wanted = names?.length ? names : configured
1257
- return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
1258
- }
1259
-
1260
- function setOnDemandMcpHealth(serverName: string, status: "ready" | "failed", error?: unknown) {
1261
- const match = getOnDemandMcpServer(serverName)
1262
- if (!match) return undefined
1263
- const [name] = match
1264
- const next: OnDemandMcpMetadata = {
1265
- ...(mcpMetadata[name] ?? {}),
1266
- status,
1267
- lastCheckedAt: new Date().toISOString(),
1268
- }
1269
- if (status === "failed") next.lastError = truncateText(error, 500)
1270
- else delete next.lastError
1271
- mcpMetadata[name] = next
1272
- saveMcpMetadata(mcpMetadata)
1273
- saveDynamicGuidance()
1274
- return name
1275
- }
1276
-
1277
- function onDemandMcpStatus(): string {
1278
- const entries = activeOnDemandMcpEntries()
1279
- if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
1280
- if (!entries.length) return "Raven on-demand MCPs: none enabled."
1281
- const buckets = onDemandMcpStatusBuckets()
1282
- const lines = entries.map(([name, server]) => {
1283
- const metadata = mcpMetadata[name]
1284
- const source = server.description ? "user description" : "no user description"
1285
- const generated = metadata?.autoGeneratedDescription ? "generated" : "missing generated description"
1286
- const tools = metadata?.autoGeneratedTools?.length ? `${metadata.autoGeneratedTools.length} tools` : "tools pending"
1287
- const status = onDemandMcpRuntimeStatus(name)
1288
- const avoided = status === "ready" && metadata?.avoidedSchemaBytes ? `, ${formatBytes(metadata.avoidedSchemaBytes)} avoided schema` : ""
1289
- const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
1290
- return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
1291
- })
1292
- return `Raven on-demand MCPs (${config.raven_mcp.descriptionDetail ?? "full"} guidance):\n Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}\n Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}\n Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}\n${lines.join("\n")}`
1293
- }
1294
-
1295
- function avoidedMcpSchemaBytes(): number {
1296
- return activeOnDemandMcpEntries().reduce((sum, [name]) => {
1297
- const metadata = mcpMetadata[name]
1298
- if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
1299
- return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
1300
- }, 0)
1301
- }
1302
-
1303
- function statsText(): string {
1304
- const avoided = avoidedMcpSchemaBytes()
1305
- return `Raven context saved:\n Avoided MCP schema load: ${formatBytes(avoided)} (~${formatTokens(avoided)} context)\n This session: ${formatBytes(sessionBytes)} (~${formatTokens(sessionBytes)} context)\n All time: ${formatBytes(totalBytes)} (~${formatTokens(totalBytes)} context)`
1306
- }
1307
-
1308
- function helpText(): string {
1309
- return `Raven commands:\n /raven help - show this help\n /raven route - show or edit routed tools/keywords\n /raven seek model <name> - change the search model (requires restart)\n /raven seek effort <value> - change search reasoning effort (requires restart)\n /raven seek timeout <seconds> - change search timeout\n /raven mcp - show on-demand MCP metadata\n /raven mcp refresh [server] - refresh MCP metadata\n /raven mcp detail full|minimized - change MCP guidance detail\n /raven mcp model <name> - change the MCP model (requires restart)\n /raven mcp effort <value> - change MCP reasoning effort (requires restart)\n /raven mcp timeout <seconds> - change MCP delegation timeout\n /raven update - check for an update\n /raven stats - show context saved`
1310
- }
1311
-
1312
- function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
1313
- if (!items.length) return `${title}: none found.`
1314
- const lines = items.map((item) => {
1315
- const primary = fields.map((field) => item?.[field]).find(Boolean) ?? JSON.stringify(item)
1316
- const description = item?.description && primary !== item.description ? ` - ${truncateText(item.description, 300)}` : ""
1317
- return `- ${truncateText(primary, 180)}${description}`
1318
- })
1319
- return `${title}:\n${lines.join("\n")}`
1320
- }
1321
-
1322
- function formatMcpToolsForRaven(serverName: string, tools: any[]): string {
1323
- if (!tools.length) return `Tools for ${serverName}: none found.`
1324
- const lines = tools.map((item) => {
1325
- const description = item?.description ? `\n Description: ${truncateText(item.description, 600)}` : ""
1326
- const schema = item?.inputSchema ? `\n Input schema: ${truncateText(JSON.stringify(item.inputSchema), 3000)}` : ""
1327
- return `- ${item?.name ?? "(unnamed)"}${description}${schema}`
1328
- })
1329
- return `Tools for ${serverName}:\n${lines.join("\n")}`
1330
- }
1331
-
1332
- function formatMcpResult(result: any): string {
1333
- if (!result) return "No result."
1334
- if (Array.isArray(result.content)) {
1335
- const parts = result.content.map((part: any) => {
1336
- if (part?.type === "text") return part.text
1337
- return JSON.stringify(part)
1338
- })
1339
- return parts.join("\n") || JSON.stringify(result)
1340
- }
1341
- return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1342
- }
1343
-
1344
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1345
- if (!raw?.trim()) return {}
1346
- const parsed = JSON.parse(raw)
1347
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1348
- throw new Error("argumentsJson must be a JSON object")
1349
- }
1350
- return parsed
1351
- }
1352
-
1353
- // ── Context saved by Raven delegation ──
1354
- let sessionBytes = 0
1355
- let totalBytes = config.stats?.bytes ?? 0
1356
-
1357
- function addBytes(bytes: number) {
1358
- sessionBytes += bytes
1359
- const updated = updateConfig((latest) => ({
1360
- stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
1361
- }))
1362
- totalBytes = updated.stats?.bytes ?? totalBytes + bytes
1363
- }
1364
-
1365
- function formatBytes(bytes: number): string {
1366
- return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
1367
- : bytes >= 1000 ? `${(bytes / 1000).toFixed(1)}KB`
1368
- : `${bytes}B`
1369
- }
1370
-
1371
- function formatTokens(bytes: number): string {
1372
- const tokens = Math.round(bytes / 4)
1373
- return tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M`
1374
- : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K`
1375
- : `${tokens}`
1376
- }
1377
-
1378
- function compareVersions(a: string, b: string): number {
1379
- const parse = (v: string) => v.replace(/^v/, "").split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0)
1380
- const left = parse(a)
1381
- const right = parse(b)
1382
- const len = Math.max(left.length, right.length)
1383
- for (let i = 0; i < len; i++) {
1384
- const diff = (left[i] ?? 0) - (right[i] ?? 0)
1385
- if (diff !== 0) return diff
1386
- }
1387
- return 0
1388
- }
1389
-
1390
- async function fetchLatestVersion(): Promise<string | undefined> {
1391
- const controller = new AbortController()
1392
- const timeout = setTimeout(() => controller.abort(), 5000)
1393
- try {
1394
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
1395
- headers: { accept: "application/json" },
1396
- signal: controller.signal,
1397
- })
1398
- if (!res.ok) return undefined
1399
- const data = await res.json() as { version?: string }
1400
- return data.version
1401
- } finally {
1402
- clearTimeout(timeout)
1403
- }
1404
- }
1405
-
1406
- async function checkForUpdate(): Promise<{ current: string; latest?: string; available: boolean }> {
1407
- const latest = await fetchLatestVersion()
1408
- return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
1409
- }
1410
-
1411
- function estimateRavenSavedCandidateBytes(messages: any[]): number {
1412
- const assistantMessages = messages.filter((m: any) =>
1413
- m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1414
- )
1415
- const first = assistantMessages[0]
1416
- const last = assistantMessages[assistantMessages.length - 1]
1417
- const t = last?.info?.tokens
1418
- const firstTokens = first?.info?.tokens
1419
- if (!t) return 0
1420
-
1421
- // Balanced estimate: total Raven context minus the first-turn prompt/schema/cache baseline.
1422
- const totalTokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
1423
- const baselineTokens = firstTokens
1424
- ? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
1425
- : 0
1426
- const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
1427
- return savedCandidateTokens * 4
1428
- }
1429
-
1430
- async function countRavenSavedCandidateBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
1431
- const messagesResp = await client.session.messages({ path: { id: sessionId }, signal })
1432
- const messages = (messagesResp as any)?.data ?? []
1433
- return estimateRavenSavedCandidateBytes(messages)
1434
- }
1435
-
1436
- async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1437
- if (updateInfo) return updateInfo
1438
- if (!updateCheckPromise) {
1439
- updateCheckPromise = checkForUpdate()
1440
- .then((info) => {
1441
- updateInfo = info
1442
- return info
1443
- })
1444
- .catch((err) => {
1445
- updateCheckPromise = undefined
1446
- throw err
1447
- })
1448
- }
1449
- return updateCheckPromise
1450
- }
1451
-
1452
- async function refreshUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1453
- updateInfo = undefined
1454
- updateCheckPromise = checkForUpdate()
1455
- .then((info) => {
1456
- updateInfo = info
1457
- return info
1458
- })
1459
- .catch((err) => {
1460
- updateCheckPromise = undefined
1461
- throw err
1462
- })
1463
- return updateCheckPromise
1464
- }
1465
-
1466
- function clearPluginCache(): string[] {
1467
- const packagesDir = join(homedir(), ".cache", "opencode", "packages")
1468
- if (!existsSync(packagesDir)) return []
1469
-
1470
- const removed: string[] = []
1471
- for (const entry of readdirSync(packagesDir)) {
1472
- if (entry !== PACKAGE_NAME && !entry.startsWith(`${PACKAGE_NAME}@`)) continue
1473
- const target = join(packagesDir, entry)
1474
- rmSync(target, { recursive: true, force: true })
1475
- removed.push(target)
1476
- }
1477
- return removed
1478
- }
1479
-
1480
- function manualUpdateText(latest = "latest"): string {
1481
- return `Restart opencode to load the update.\n\nManual alternatives:\n bun update --latest ${PACKAGE_NAME}\n npm install ${PACKAGE_NAME}@${latest}\n\nIf opencode still loads the old version, clear its plugin cache and restart:\n PowerShell: Remove-Item -Recurse -Force "$HOME\\.cache\\opencode\\packages\\${PACKAGE_NAME}*"\n macOS/Linux: rm -rf ~/.cache/opencode/packages/${PACKAGE_NAME}*`
1482
- }
1483
-
1484
- async function notifyIfUpdateAvailable() {
1485
- try {
1486
- const info = await getUpdateInfo()
1487
- if (!info.available || !info.latest) return
1488
- await (client as any).tui?.showToast?.({
1489
- body: {
1490
- title: "Raven update available",
1491
- message: `${PACKAGE_NAME} ${info.current} → ${info.latest}. Run /raven update, then restart opencode.`,
1492
- variant: "info",
1493
- duration: 10000,
1494
- },
1495
- })
1496
- } catch { /* update checks are best-effort */ }
1497
- }
1498
-
1499
- async function notifyIfMcpFailures(names?: string[]) {
1500
- try {
1501
- const failed = failedOnDemandMcpNames(names)
1502
- if (!failed.length) {
1503
- lastNotifiedMcpFailureKey = ""
1504
- return
1505
- }
1506
- const key = failed.sort().join("|")
1507
- if (key === lastNotifiedMcpFailureKey) return
1508
- lastNotifiedMcpFailureKey = key
1509
- const label = failed.length === 1 ? failed[0] : `${failed.length} on-demand MCPs`
1510
- await (client as any).tui?.showToast?.({
1511
- body: {
1512
- title: "Raven MCP warning",
1513
- message: `${label} failed to load. Run /raven mcp for details.`,
1514
- variant: "warning",
1515
- duration: 12000,
1516
- },
1517
- })
1518
- } catch { /* MCP failure notifications are best-effort */ }
1519
- }
1520
-
1521
- async function notifyIfDuplicateMcps() {
1522
- try {
1523
- if (!duplicateMcpServers.length) return
1524
- const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
1525
- await (client as any).tui?.showToast?.({
1526
- body: {
1527
- title: "Raven MCP duplicate config",
1528
- message: `${label} configured both globally and on-demand. Prefer one location to avoid duplicate schema/connection behavior.`,
1529
- variant: "warning",
1530
- duration: 12000,
1531
- },
1532
- })
1533
- } catch { /* duplicate MCP notifications are best-effort */ }
1534
- }
1535
-
1536
- function delegationAgent(kind: DelegationKind): "raven" | "raven-mcp" {
1537
- return kind === "mcp" ? "raven-mcp" : "raven"
1538
- }
1539
-
1540
- function delegationToolName(kind: DelegationKind): "raven_seek" | "raven_mcp" {
1541
- return kind === "mcp" ? "raven_mcp" : "raven_seek"
1542
- }
1543
-
1544
- function delegationTitle(kind: DelegationKind): string {
1545
- return kind === "mcp" ? "Raven MCP" : "Raven Seek"
1546
- }
1547
-
1548
- async function rootSessionId(sessionId: string, signal?: AbortSignal): Promise<string> {
1549
- let current = sessionId
1550
- const seen = new Set<string>()
1551
- while (!seen.has(current)) {
1552
- seen.add(current)
1553
- const response = await client.session.get({ path: { id: current }, signal })
1554
- const session = (response as any)?.data ?? response
1555
- if (!session?.parentID) return current
1556
- current = session.parentID
1557
- }
1558
- return sessionId
1559
- }
1560
-
1561
- async function syncCreatedSession(sessionId: string, title: string, signal?: AbortSignal) {
1562
- try {
1563
- await client.session.update({
1564
- path: { id: sessionId },
1565
- body: { title },
1566
- signal,
1567
- })
1568
- } catch (error) {
1569
- if (signal?.aborted) throw error
1570
- // Delegation still works if an older or disconnected TUI misses the sync event.
1571
- }
1572
- }
1573
-
1574
- async function executeRavenDelegation(kind: DelegationKind, args: { query: string; sessionId?: string }, context: any) {
1575
- const started = Date.now()
1576
- const agent = delegationAgent(kind)
1577
- const toolName = delegationToolName(kind)
1578
- const title = delegationTitle(kind)
1579
- const timeout = (kind === "mcp" ? config.raven_mcp.timeout : config.raven_seek.timeout) ?? 600
1580
- const timeoutMs = timeout * 1000
1581
- let sessionId: string | undefined
1582
- let resumed = false
1583
- let previousSavedCandidate = 0
1584
-
1585
- try {
1586
- const requestedSessionId = args.sessionId?.trim()
1587
- if (args.sessionId !== undefined && !requestedSessionId) {
1588
- return { title, output: "Cannot resume Raven: sessionId must not be empty." }
1589
- }
1590
-
1591
- let rootId = context.sessionID
1592
- try {
1593
- rootId = await rootSessionId(context.sessionID, context.abort)
1594
- } catch (error) {
1595
- if (context.abort.aborted) throw error
1596
- // Falling back preserves delegation if an older OpenCode build cannot read the caller session.
1597
- }
1598
-
1599
- if (requestedSessionId) {
1600
- const knownKind = ravenSessionKinds.get(requestedSessionId)
1601
- const knownRavenSession = knownKind === kind || sessionAgents.get(requestedSessionId) === agent
1602
- let existingSession: any
1603
- try {
1604
- const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
1605
- existingSession = (sessionResp as any)?.data ?? sessionResp
1606
- } catch (error) {
1607
- if (context.abort.aborted) throw error
1608
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1609
- }
1610
-
1611
- if (!existingSession?.id || existingSession.id !== requestedSessionId) {
1612
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1613
- }
1614
- const validParent = existingSession.parentID === rootId || existingSession.parentID === context.sessionID
1615
- if (!validParent || existingSession.projectID !== input.project.id) {
1616
- return { title, output: "Cannot resume Raven: sessionId is not part of this main session's Raven tree." }
1617
- }
1618
-
1619
- let messages: any[]
1620
- try {
1621
- const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
1622
- const messageData = (messagesResp as any)?.data
1623
- if (!Array.isArray(messageData)) {
1624
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1625
- }
1626
- messages = messageData
1627
- } catch (error) {
1628
- if (context.abort.aborted) throw error
1629
- return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1630
- }
1631
-
1632
- const userMessages = messages.filter((message: any) => message?.info?.role === "user")
1633
- const persistedRavenSession = userMessages.length > 0
1634
- && userMessages.every((message: any) => message.info.agent === agent)
1635
- if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
1636
- return { title, output: `Cannot resume Raven: sessionId does not belong to the ${agent} agent.` }
1637
- }
1638
-
1639
- sessionId = requestedSessionId
1640
- resumed = true
1641
- previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
1642
- } else {
1643
- const sessionTitle = `${toolName}: ${args.query.slice(0, 80)}`
1644
- const session = await client.session.create({
1645
- body: {
1646
- parentID: rootId,
1647
- title: sessionTitle,
1648
- },
1649
- signal: context.abort,
1650
- })
1651
- sessionId = (session as any)?.data?.id ?? (session as any)?.id
1652
- if (!sessionId) return { title, output: "Failed to create Raven session." }
1653
- await syncCreatedSession(sessionId, sessionTitle, context.abort)
1654
- }
1655
-
1656
- ravenSessions.add(sessionId)
1657
- ravenSessionKinds.set(sessionId, kind)
1658
- const activeSessionId = sessionId
1659
- context.metadata({ metadata: { sessionId } })
1660
-
1661
- try {
1662
- writeFileSync(join(tmpdir(), "raven-sessions.log"), `${new Date().toISOString()} ${sessionId}\n`)
1663
- } catch { /* non-fatal */ }
1664
-
1665
- if (activeRavenPrompts.has(activeSessionId)) {
1666
- return {
1667
- title,
1668
- metadata: { sessionId: activeSessionId },
1669
- output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
1670
- }
1671
- }
1672
- activeRavenPrompts.add(activeSessionId)
1673
-
1674
- let timer: ReturnType<typeof setTimeout> | undefined
1675
- let promptCompleted = false
1676
- let sessionAbort: Promise<unknown> | undefined
1677
- const promptController = new AbortController()
1678
- const abortPrompt = () => {
1679
- if (promptController.signal.aborted) return
1680
- promptController.abort(context.abort.reason)
1681
- const abortController = new AbortController()
1682
- const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeoutMs, 5_000))
1683
- sessionAbort = client.session.abort({
1684
- path: { id: activeSessionId },
1685
- signal: abortController.signal,
1686
- throwOnError: true,
1687
- }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
1688
- }
1689
- if (context.abort.aborted) abortPrompt()
1690
- else context.abort.addEventListener("abort", abortPrompt, { once: true })
1691
- const providerError = waitForRavenProviderError(sessionId)
1692
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeoutMs, 30_000))
1693
-
1694
- try {
1695
- const result = await Promise.race([
1696
- client.session.prompt({
1697
- path: { id: sessionId },
1698
- body: { agent, parts: [{ type: "text", text: args.query }] },
1699
- signal: promptController.signal,
1700
- throwOnError: true,
1701
- }),
1702
- providerError,
1703
- firstProgress,
1704
- new Promise<never>((_, reject) => {
1705
- timer = setTimeout(() => {
1706
- abortPrompt()
1707
- reject(new Error(`Raven timed out after ${timeout}s - child session retained: ${sessionId}`))
1708
- }, timeoutMs)
1709
- }),
1710
- ])
1711
- promptCompleted = true
1712
- if (timer) clearTimeout(timer)
1713
- clearRavenWaiters(sessionId)
1714
- if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
1715
-
1716
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1717
- const parts = (result as any)?.data?.parts ?? []
1718
- const output = parts
1719
- .filter((part: any) => part.type === "text" && part.text)
1720
- .map((part: any) => part.text)
1721
- .join("\n") || "Raven returned no results."
1722
-
1723
- let savedCandidate = 0
1724
- const statsController = new AbortController()
1725
- const abortStats = () => statsController.abort(context.abort.reason)
1726
- const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeoutMs, 5_000))
1727
- if (context.abort.aborted) abortStats()
1728
- else context.abort.addEventListener("abort", abortStats, { once: true })
1729
- try {
1730
- const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
1731
- savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
1732
- } catch { /* best-effort */ }
1733
- finally {
1734
- clearTimeout(statsTimer)
1735
- context.abort.removeEventListener("abort", abortStats)
1736
- }
1737
- if (savedCandidate <= 0) {
1738
- for (const part of parts) {
1739
- if (part.args) savedCandidate += JSON.stringify(part.args).length
1740
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1741
- }
1742
- }
1743
-
1744
- const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`${toolName}\` call to continue the same retrieval.`
1745
- const activity = kind === "mcp" ? "used MCPs" : "searched"
1746
- const resultOutput = `${output}\n\n*Raven ${activity} for ${elapsed}s - ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
1747
- addBytes(Math.max(0, savedCandidate - resultOutput.length))
1748
- delete ravenProviderIssues[kind]
1749
- return { title, metadata: { sessionId }, output: resultOutput }
1750
- } finally {
1751
- context.abort.removeEventListener("abort", abortPrompt)
1752
- if (!promptCompleted) abortPrompt()
1753
- if (sessionAbort) await sessionAbort
1754
- if (timer) clearTimeout(timer)
1755
- clearRavenWaiters(sessionId)
1756
- activeRavenPrompts.delete(activeSessionId)
1757
- }
1758
- } catch (error: any) {
1759
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1760
- const message = String(error?.message ?? error ?? "").toLowerCase()
1761
- const missingSession = /session.*not found|not found.*session/i.test(message)
1762
- const activity = kind === "mcp" ? "MCP retrieval" : "search"
1763
- const hint =
1764
- context.abort.aborted ? `Raven ${activity} cancelled.`
1765
- : /rate.?limit|too many requests|429/i.test(message) ? "Raven rate limited - wait 30s, then retry with a narrower query."
1766
- : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(message) ? "Raven API quota/subscription exhausted - proceed without Raven and mention what information may be missing."
1767
- : /auth|unauthorized|forbidden|401|403|api key|login/i.test(message) ? "Raven provider authentication failed - proceed without Raven and check the configured provider login or API key."
1768
- : /token|context.?length|too large|too long/i.test(message) ? "Raven query too large - shorten your query and retry."
1769
- : missingSession ? `The Raven session is no longer available - retry without sessionId to start a new ${activity}.`
1770
- : /model|unavailable|down|not found/i.test(message) ? "Raven model unavailable - retry later, or proceed without the missing information."
1771
- : /did not produce an initial response|retry dialog|timeout|timed.?out|session retained/i.test(message) ? error.message
1772
- : `Raven ${activity} failed. Note the resulting information gap. [${error.message || error}]`
1773
- if (!missingSession && /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(message)) {
1774
- ravenProviderIssues[kind] = hint
1775
- }
1776
- const recovery = ravenProviderIssues[kind] ? `\n\n${ravenRecoveryInstructions(kind)}` : ""
1777
- const continuation = sessionId && !missingSession
1778
- ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`${toolName}\` call to retry or continue.`
1779
- : ""
1780
- return { title, metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
1781
- }
1782
- }
1783
-
1784
- return {
1785
- async dispose() {
1786
- for (const controller of mcpRefreshControllers) controller.abort()
1787
- for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
1788
- await Promise.allSettled([
1789
- ...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
1790
- mcpRefreshQueue,
1791
- ])
1792
- await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
1793
- if (mcpIdleTimer) clearInterval(mcpIdleTimer)
1794
- mcpIdleTimer = undefined
1795
- for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
1796
- ravenErrorWaiters.clear()
1797
- },
1798
-
1799
- async config(configInput: any) {
1800
- globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1801
- .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1802
- .map(([name]) => name))
1803
- const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1804
- duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1805
-
1806
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1807
- // The file is regenerated after MCP health checks, not during the startup pending reset.
1808
- configInput.instructions = configInput.instructions || []
1809
- if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
1810
- configInput.instructions.push(MCP_GUIDANCE_MD)
1811
- }
1812
- if (!configInput.instructions.includes(dynamicGuidanceFile)) {
1813
- configInput.instructions.push(dynamicGuidanceFile)
1814
- }
1815
-
1816
- // Register Raven from Raven.md, with config file overrides
1817
- configInput.agent = configInput.agent || {}
1818
- configInput.agent.raven = {
1819
- description: fm.description || "",
1820
- mode: fm.mode || "subagent",
1821
- hidden: fm.hidden !== undefined ? fm.hidden : false,
1822
- model: config.raven_seek.model || fm.model,
1823
- options: {
1824
- ...extractOptions(fm),
1825
- ...(config.raven_seek.reasoning_effort ? { reasoning_effort: config.raven_seek.reasoning_effort } : {}),
1826
- },
1827
- permission: fm.permission || {},
1828
- prompt: ravenAgentPrompt(),
1829
- }
1830
- configInput.agent["raven-mcp"] = {
1831
- description: mcpFm.description || "",
1832
- mode: mcpFm.mode || "subagent",
1833
- hidden: mcpFm.hidden !== undefined ? mcpFm.hidden : true,
1834
- model: config.raven_mcp.model || mcpFm.model,
1835
- options: {
1836
- ...extractOptions(mcpFm),
1837
- ...(config.raven_mcp.reasoning_effort ? { reasoning_effort: config.raven_mcp.reasoning_effort } : {}),
1838
- },
1839
- permission: mcpFm.permission || {},
1840
- prompt: ravenMcpAgentPrompt(),
1841
- }
1842
-
1843
- // Register /raven command
1844
- configInput.command = configInput.command || {}
1845
- if (!configInput.command.raven) {
1846
- configInput.command.raven = {
1847
- template: "Manage Raven search routing, on-demand MCPs, models, timeouts, updates, and status",
1848
- description: "Manage Raven search routing, on-demand MCPs, and per-agent models",
1849
- }
1850
- }
1851
-
1852
- scheduleOnDemandMcpRefresh()
1853
- updateToastPending = true
1854
- mcpFailureToastPending = true
1855
- duplicateMcpToastPending = duplicateMcpServers.length > 0
1856
- },
1857
-
1858
- // Register raven_seek tool — lets agents with task:false still delegate through Raven
1859
- tool: {
1860
- "raven_seek": tool({
1861
- description: "Search and evidence retrieval only. Creates or resumes a Raven Search session for routed local search, filesystem discovery, URL fetching, web/docs/GitHub search, globally configured MCP searches, or command-output inspection. Do not delegate code review, auditing, correctness judgments, implementation, debugging decisions, planning, or general task execution; the calling agent must perform that work.",
1862
- args: {
1863
- query: tool.schema.string().describe("A narrowly scoped search or evidence-retrieval request. Include original blocked tool args, exact URLs, paths, patterns, or bounded command-output checks. Do not ask Raven to review or audit code."),
1864
- sessionId: tool.schema.string().optional().describe("Raven session ID returned by an earlier raven_seek call. Provide it only when this request should continue that session's research context."),
1865
- },
1866
- async execute(args, context) {
1867
- const started = Date.now()
1868
- const timeout = (config.raven_seek.timeout ?? 600) * 1000
1869
- let sessionId: string | undefined
1870
- let resumed = false
1871
- let previousSavedCandidate = 0
1872
- try {
1873
- const requestedSessionId = args.sessionId?.trim()
1874
- if (args.sessionId !== undefined && !requestedSessionId) {
1875
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId must not be empty." }
1876
- }
1877
-
1878
- let rootId = context.sessionID
1879
- try {
1880
- rootId = await rootSessionId(context.sessionID, context.abort)
1881
- } catch (error) {
1882
- if (context.abort.aborted) throw error
1883
- }
1884
-
1885
- if (requestedSessionId) {
1886
- const knownRavenSession = ravenSessionKinds.get(requestedSessionId) === "seek" || sessionAgents.get(requestedSessionId) === "raven"
1887
- let existingSession: any
1888
- try {
1889
- const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
1890
- existingSession = (sessionResp as any)?.data ?? sessionResp
1891
- } catch (err) {
1892
- if (context.abort.aborted) throw err
1893
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1894
- }
1895
-
1896
- if (!existingSession?.id || existingSession.id !== requestedSessionId) {
1897
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1898
- }
1899
- if ((existingSession.parentID !== rootId && existingSession.parentID !== context.sessionID) || existingSession.projectID !== input.project.id) {
1900
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
1901
- }
1902
-
1903
- let messages: any[]
1904
- try {
1905
- const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
1906
- const messageData = (messagesResp as any)?.data
1907
- if (!Array.isArray(messageData)) {
1908
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1909
- }
1910
- messages = messageData
1911
- } catch (err) {
1912
- if (context.abort.aborted) throw err
1913
- return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1914
- }
1915
-
1916
- const userMessages = messages.filter((message: any) => message?.info?.role === "user")
1917
- const persistedRavenSession = userMessages.length > 0
1918
- && userMessages.every((message: any) => message.info.agent === "raven")
1919
- if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
1920
- return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
1921
- }
1922
-
1923
- sessionId = requestedSessionId
1924
- resumed = true
1925
- previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
1926
- } else {
1927
- const sessionTitle = `raven_seek: ${args.query.slice(0, 80)}`
1928
- const session = await client.session.create({
1929
- body: {
1930
- parentID: rootId,
1931
- title: sessionTitle,
1932
- },
1933
- signal: context.abort,
1934
- })
1935
-
1936
- sessionId = (session as any)?.data?.id ?? (session as any)?.id
1937
- if (!sessionId) {
1938
- return { title: "Raven Seek", output: "Failed to create Raven session." }
1939
- }
1940
- await syncCreatedSession(sessionId, sessionTitle, context.abort)
1941
- }
1942
-
1943
- ravenSessions.add(sessionId)
1944
- ravenSessionKinds.set(sessionId, "seek")
1945
- const activeSessionId = sessionId
1946
-
1947
- // Preserve the child session link for TUI clients that render it.
1948
- context.metadata({ metadata: { sessionId } })
1949
-
1950
- // Log session for debugging
1951
- try {
1952
- const logFile = join(tmpdir(), "raven-sessions.log")
1953
- const ts = new Date().toISOString()
1954
- writeFileSync(logFile, `${ts} ${sessionId}\n`)
1955
- } catch { /* non-fatal */ }
1956
-
1957
- if (activeRavenPrompts.has(activeSessionId)) {
1958
- return {
1959
- title: "Raven Seek",
1960
- metadata: { sessionId: activeSessionId },
1961
- output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
1962
- }
1963
- }
1964
- activeRavenPrompts.add(activeSessionId)
1965
-
1966
- // Send the query to Raven with timeout. Provider/API errors are also
1967
- // surfaced through message.updated events; fail as soon as one lands
1968
- // so the caller does not wait for the full timeout on quota/auth issues.
1969
- let timer: ReturnType<typeof setTimeout> | undefined
1970
- let promptCompleted = false
1971
- let sessionAbort: Promise<unknown> | undefined
1972
- const promptController = new AbortController()
1973
- const abortPrompt = () => {
1974
- if (promptController.signal.aborted) return
1975
- promptController.abort(context.abort.reason)
1976
- const abortController = new AbortController()
1977
- const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeout, 5_000))
1978
- sessionAbort = client.session.abort({
1979
- path: { id: activeSessionId },
1980
- signal: abortController.signal,
1981
- throwOnError: true,
1982
- }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
1983
- }
1984
- if (context.abort.aborted) abortPrompt()
1985
- else context.abort.addEventListener("abort", abortPrompt, { once: true })
1986
- const providerError = waitForRavenProviderError(sessionId)
1987
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1988
- try {
1989
- const result = await Promise.race([
1990
- client.session.prompt({
1991
- path: { id: sessionId },
1992
- body: {
1993
- agent: "raven",
1994
- parts: [{ type: "text", text: args.query }],
1995
- },
1996
- signal: promptController.signal,
1997
- throwOnError: true,
1998
- }),
1999
- providerError,
2000
- firstProgress,
2001
- new Promise<never>((_, reject) => {
2002
- timer = setTimeout(() => {
2003
- abortPrompt()
2004
- reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
2005
- }, timeout)
2006
- }),
2007
- ])
2008
- promptCompleted = true
2009
- if (timer) clearTimeout(timer)
2010
- clearRavenWaiters(sessionId)
2011
- if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
2012
-
2013
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2014
-
2015
- // Extract text from the response
2016
- const parts = (result as any)?.data?.parts ?? []
2017
- const textParts = parts
2018
- .filter((p: any) => p.type === "text" && p.text)
2019
- .map((p: any) => p.text)
2020
- const output = textParts.join("\n") || "Raven returned no results."
2021
-
2022
- // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
2023
- let savedCandidate = 0
2024
- const statsController = new AbortController()
2025
- const abortStats = () => statsController.abort(context.abort.reason)
2026
- const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeout, 5_000))
2027
- if (context.abort.aborted) abortStats()
2028
- else context.abort.addEventListener("abort", abortStats, { once: true })
2029
- try {
2030
- const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
2031
- savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
2032
- } catch { /* best-effort */ }
2033
- finally {
2034
- clearTimeout(statsTimer)
2035
- context.abort.removeEventListener("abort", abortStats)
2036
- }
2037
- if (savedCandidate <= 0) {
2038
- for (const part of parts) {
2039
- if (part.args) savedCandidate += JSON.stringify(part.args).length
2040
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
2041
- }
2042
- }
2043
- const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`raven_seek\` call to continue the same research.`
2044
- const resultOutput = `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
2045
- addBytes(Math.max(0, savedCandidate - resultOutput.length))
2046
- delete ravenProviderIssues.seek
2047
-
2048
- return { title: "Raven Seek", metadata: { sessionId }, output: resultOutput }
2049
- } finally {
2050
- context.abort.removeEventListener("abort", abortPrompt)
2051
- if (!promptCompleted) abortPrompt()
2052
- if (sessionAbort) await sessionAbort
2053
- if (timer) clearTimeout(timer)
2054
- clearRavenWaiters(sessionId)
2055
- activeRavenPrompts.delete(activeSessionId)
2056
- }
2057
- } catch (err: any) {
2058
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2059
- const msg = String(err?.message ?? err ?? "").toLowerCase()
2060
- const missingSession = /session.*not found|not found.*session/i.test(msg)
2061
- const hint =
2062
- context.abort.aborted ? "Raven search cancelled."
2063
- : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
2064
- : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
2065
- : /auth|unauthorized|forbidden|401|403|api key|login/i.test(msg) ? "Raven provider authentication failed — proceed without Raven and tell the user to check the Raven model/provider login or API key."
2066
- : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
2067
- : missingSession ? "The Raven session is no longer available — retry without sessionId to start a new Raven search."
2068
- : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
2069
- : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
2070
- : /timeout|timed.?out|session kept/i.test(msg) ? err.message
2071
- : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
2072
- if (!missingSession && /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(msg)) {
2073
- ravenProviderIssues.seek = hint
2074
- }
2075
- const recovery = ravenProviderIssues.seek ? `\n\n${ravenRecoveryInstructions("seek")}` : ""
2076
- const continuation = sessionId && !missingSession
2077
- ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`raven_seek\` call to retry or continue.`
2078
- : ""
2079
- return { title: "Raven Seek", metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
2080
- }
2081
- },
2082
- }),
2083
- "raven_mcp": tool({
2084
- description: "On-demand MCP information retrieval only. Creates or resumes a dedicated Raven MCP session that selects and calls MCPs configured under raven_mcp.onDemandMcpServers. Use for matching MCP capabilities, not local search, code review, auditing, implementation, planning, or general task delegation.",
2085
- args: {
2086
- query: tool.schema.string().describe("The information to retrieve and, when known, the target MCP server or capability. The calling agent retains analysis and judgment."),
2087
- sessionId: tool.schema.string().optional().describe("Raven MCP session ID returned by an earlier raven_mcp call. Provide it only to continue that MCP retrieval context."),
2088
- },
2089
- async execute(args, context) {
2090
- return executeRavenDelegation("mcp", args, context)
2091
- },
2092
- }),
2093
- "raven_mcp_bridge": tool({
2094
- description: "Internal bridge for the Raven MCP agent. Other agents must not call this tool. Lists and calls tools, resources, and prompts from configured on-demand MCP servers without globally exposing their schemas.",
2095
- args: {
2096
- server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
2097
- operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
2098
- name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
2099
- argumentsJson: tool.schema.string().optional().describe("JSON object string with arguments for call_tool or get_prompt, e.g. {\"query\":\"latest AI news\"}. Omit for no arguments."),
2100
- },
2101
- async execute(args, context) {
2102
- if (ravenSessionKinds.get(context.sessionID) !== "mcp" && sessionAgents.get(context.sessionID) !== "raven-mcp") {
2103
- return { title: "Raven MCP Bridge", output: "raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests." }
2104
- }
2105
-
2106
- const operation = args.operation as RavenMcpOperation
2107
- const server = getOnDemandMcpServer(args.server)?.[1]
2108
- const timeout = server ? mcpTimeoutMs(server) : (config.raven_mcp.timeout ?? 600) * 1000
2109
- const requestOptions = { signal: context.abort, timeout }
2110
- let connected = false
2111
- let connection: McpConnection | undefined
2112
- try {
2113
- connection = await getMcpConnection(context.sessionID, args.server, context.abort)
2114
- connected = true
2115
- connection.active++
2116
- connection.lastUsed = Date.now()
2117
- const client = connection.client
2118
-
2119
- if (operation === "list_tools") {
2120
- const tools = await listAllMcpTools(client, requestOptions)
2121
- const match = getOnDemandMcpServer(args.server)
2122
- if (match) {
2123
- const [name] = match
2124
- const generatedTools = generatedToolsFromMcpTools(tools)
2125
- mcpMetadata[name] = {
2126
- autoGeneratedTools: generatedTools,
2127
- autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
2128
- avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
2129
- status: "ready",
2130
- lastCheckedAt: new Date().toISOString(),
2131
- }
2132
- saveMcpMetadata(mcpMetadata)
2133
- saveDynamicGuidance()
2134
- }
2135
- return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
2136
- }
2137
-
2138
- if (operation === "call_tool") {
2139
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
2140
- const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
2141
- setOnDemandMcpHealth(args.server, "ready")
2142
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2143
- }
2144
-
2145
- if (operation === "list_resources") {
2146
- const resources = await listAllMcpResources(client, requestOptions)
2147
- setOnDemandMcpHealth(args.server, "ready")
2148
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
2149
- }
2150
-
2151
- if (operation === "read_resource") {
2152
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
2153
- const result = await client.readResource({ uri: args.name }, requestOptions)
2154
- setOnDemandMcpHealth(args.server, "ready")
2155
- return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
2156
- }
2157
-
2158
- if (operation === "list_prompts") {
2159
- const prompts = await listAllMcpPrompts(client, requestOptions)
2160
- setOnDemandMcpHealth(args.server, "ready")
2161
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
2162
- }
2163
-
2164
- if (operation === "get_prompt") {
2165
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
2166
- const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
2167
- setOnDemandMcpHealth(args.server, "ready")
2168
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2169
- }
2170
-
2171
- return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
2172
- } catch (err: any) {
2173
- if (!connected && !context.abort.aborted) {
2174
- const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
2175
- if (name) await notifyIfMcpFailures([name])
2176
- }
2177
- return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
2178
- } finally {
2179
- if (connection) {
2180
- connection.active = Math.max(0, connection.active - 1)
2181
- connection.lastUsed = Date.now()
2182
- }
2183
- }
2184
- },
2185
- }),
2186
- },
2187
-
2188
- // Track agent ↔ session mapping for allowlist + Raven exclusion
2189
- async "chat.message"(input: any, _output: any) {
2190
- if (input.agent) {
2191
- sessionAgents.set(input.sessionID, input.agent)
2192
- if (input.agent === "raven") {
2193
- ravenSessions.add(input.sessionID)
2194
- ravenSessionKinds.set(input.sessionID, "seek")
2195
- } else if (input.agent === "raven-mcp") {
2196
- ravenSessions.add(input.sessionID)
2197
- ravenSessionKinds.set(input.sessionID, "mcp")
2198
- }
2199
- }
2200
- },
2201
-
2202
- async event(input: { event: any }) {
2203
- // Track subagent session → parent mapping for accurate context counting
2204
- const evt = input.event
2205
- if (evt?.type === "session.created") {
2206
- const info = evt.properties?.info
2207
- if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
2208
- }
2209
- if (evt?.type === "session.deleted") {
2210
- const sessionId = evt.properties?.info?.id
2211
- if (sessionId) {
2212
- ravenSessions.delete(sessionId)
2213
- ravenSessionKinds.delete(sessionId)
2214
- activeRavenPrompts.delete(sessionId)
2215
- sessionAgents.delete(sessionId)
2216
- clearRavenWaiters(sessionId)
2217
- for (const [parentId, childId] of ravenSessionParents) {
2218
- if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
2219
- }
2220
- await closeMcpConnectionsForSession(sessionId)
2221
- }
2222
- }
2223
- if (evt?.type === "message.updated") {
2224
- const info = evt.properties?.info
2225
- if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
2226
- reportRavenProviderError(info.sessionID, info.error)
2227
- }
2228
- }
2229
- if (evt?.type === "message.part.updated") {
2230
- const part = evt.properties?.part
2231
- if (part?.sessionID && ravenSessions.has(part.sessionID)) {
2232
- if (part.type === "retry" && part.error) {
2233
- reportRavenProviderError(part.sessionID, part.error)
2234
- } else {
2235
- reportRavenProgress(part.sessionID)
2236
- }
2237
- }
2238
- }
2239
- if (evt?.type === "session.status") {
2240
- const sessionId = evt.properties?.sessionID
2241
- const status = evt.properties?.status
2242
- if (sessionId && ravenSessions.has(sessionId)) {
2243
- if (status?.type === "retry") {
2244
- reportRavenProviderError(sessionId, { name: "RetryError", data: { message: status.message } })
2245
- }
2246
- }
2247
- }
2248
- if (evt?.type === "session.error") {
2249
- const sessionId = evt.properties?.sessionID
2250
- if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
2251
- reportRavenProviderError(sessionId, evt.properties.error)
2252
- }
2253
- }
2254
-
2255
- if (updateToastPending) {
2256
- updateToastPending = false
2257
- setTimeout(() => void notifyIfUpdateAvailable(), 500)
2258
- }
2259
- if (mcpFailureToastPending) {
2260
- mcpFailureToastPending = false
2261
- void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
2262
- }
2263
- if (duplicateMcpToastPending) {
2264
- duplicateMcpToastPending = false
2265
- setTimeout(() => void notifyIfDuplicateMcps(), 700)
2266
- }
2267
- },
2268
-
2269
- // /raven help|route|seek ...|mcp ...|update|stats|status
2270
- async "command.execute.before"(input: any, output: any) {
2271
- if (input.command !== "raven") return
2272
- output.parts.length = 0
2273
- const raw = input.arguments.trim()
2274
- const arg = raw.toLowerCase()
2275
-
2276
- if (arg === "help") {
2277
- output.parts.push({ type: "text", text: helpText() })
2278
- } else if (arg === "stats") {
2279
- output.parts.push({ type: "text", text: statsText() })
2280
- } else if (arg === "route") {
2281
- output.parts.push({ type: "text", text: `${routeSummary()}\n\nUsage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name.` })
2282
- } else if (arg.startsWith("route ")) {
2283
- const parts = raw.split(/\s+/)
2284
- const kind = parts[1]?.toLowerCase()
2285
- const action = parts[2]?.toLowerCase()
2286
- const name = parts.slice(3).join(" ").trim()
2287
- const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
2288
-
2289
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
2290
- output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name." })
2291
- } else {
2292
- updateConfig((latest) => {
2293
- const values = uniqueStrings(latest.raven_seek[key])
2294
- const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
2295
- const next = action === "add"
2296
- ? exists ? values : [...values, name]
2297
- : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
2298
- return { raven_seek: key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next } }
2299
- })
2300
- output.parts.push({ type: "text", text: routeSummary() })
2301
- }
2302
- } else if (arg.startsWith("seek ")) {
2303
- const parts = raw.split(/\s+/)
2304
- const action = parts[1]?.toLowerCase()
2305
- const value = parts.slice(2).join(" ").trim()
2306
- if (action === "model" && value) {
2307
- updateConfig({ raven_seek: { model: value } })
2308
- delete ravenProviderIssues.seek
2309
- output.parts.push({ type: "text", text: `Raven Search model set to: ${value}\nRestart opencode for the change to take effect.` })
2310
- } else if (action === "effort" && value) {
2311
- updateConfig({ raven_seek: { reasoning_effort: value } })
2312
- output.parts.push({ type: "text", text: `Raven Search reasoning effort set to: ${value}\nRestart opencode for the change to take effect.` })
2313
- } else if (action === "timeout") {
2314
- const seconds = Number(value)
2315
- if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2316
- output.parts.push({ type: "text", text: `Usage: /raven seek timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.raven_seek.timeout ?? 600}s` })
2317
- } else {
2318
- updateConfig({ raven_seek: { timeout: seconds } })
2319
- output.parts.push({ type: "text", text: `Raven Search timeout set to ${seconds}s. Takes effect immediately.` })
2320
- }
2321
- } else {
2322
- output.parts.push({ type: "text", text: "Usage:\n /raven seek model <provider/model>\n /raven seek effort <value>\n /raven seek timeout <seconds>" })
2323
- }
2324
- } else if (arg === "mcp") {
2325
- output.parts.push({ type: "text", text: `${onDemandMcpStatus()}\n\nUsage:\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized` })
2326
- } else if (arg.startsWith("mcp ")) {
2327
- const parts = raw.split(/\s+/)
2328
- const action = parts[1]?.toLowerCase()
2329
- if (action === "refresh") {
2330
- const serverName = parts.slice(2).join(" ").trim() || undefined
2331
- const result = await queueOnDemandMcpRefresh(true, serverName)
2332
- await notifyIfMcpFailures(result.failed)
2333
- output.parts.push({
2334
- type: "text",
2335
- text: `On-demand MCP refresh complete.\n Refreshed: ${result.refreshed.length ? result.refreshed.join(", ") : "none"}\n Failed: ${result.failed.length ? result.failed.join(", ") : "none"}`,
2336
- })
2337
- } else if (action === "detail") {
2338
- const detail = parts[2]?.toLowerCase()
2339
- if (detail !== "full" && detail !== "minimized") {
2340
- output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.raven_mcp.descriptionDetail ?? "full"}` })
2341
- } else {
2342
- updateConfig({ raven_mcp: { descriptionDetail: detail } })
2343
- output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
2344
- }
2345
- } else if (action === "model" && parts.slice(2).join(" ").trim()) {
2346
- const model = parts.slice(2).join(" ").trim()
2347
- updateConfig({ raven_mcp: { model } })
2348
- delete ravenProviderIssues.mcp
2349
- output.parts.push({ type: "text", text: `Raven MCP model set to: ${model}\nRestart opencode for the change to take effect.` })
2350
- } else if (action === "effort" && parts.slice(2).join(" ").trim()) {
2351
- const effort = parts.slice(2).join(" ").trim()
2352
- updateConfig({ raven_mcp: { reasoning_effort: effort } })
2353
- output.parts.push({ type: "text", text: `Raven MCP reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
2354
- } else if (action === "timeout") {
2355
- const seconds = Number(parts[2])
2356
- if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2357
- output.parts.push({ type: "text", text: `Usage: /raven mcp timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.raven_mcp.timeout ?? 600}s` })
2358
- } else {
2359
- updateConfig({ raven_mcp: { timeout: seconds } })
2360
- output.parts.push({ type: "text", text: `Raven MCP timeout set to ${seconds}s. Takes effect immediately.` })
2361
- }
2362
- } else {
2363
- output.parts.push({ type: "text", text: "Usage:\n /raven mcp\n /raven mcp refresh [server_name]\n /raven mcp detail full|minimized\n /raven mcp model <provider/model>\n /raven mcp effort <value>\n /raven mcp timeout <seconds>" })
2364
- }
2365
- } else if (arg === "update") {
2366
- try {
2367
- const info = await refreshUpdateInfo()
2368
- if (!info.latest) {
2369
- output.parts.push({ type: "text", text: `Could not check npm for ${PACKAGE_NAME}. Try again later.\n\n${manualUpdateText()}` })
2370
- } else if (!info.available) {
2371
- output.parts.push({ type: "text", text: `Raven is up to date (${info.current}). Latest on npm: ${info.latest}.` })
2372
- } else {
2373
- const removed = clearPluginCache()
2374
- output.parts.push({ type: "text", text: `Raven update available: ${info.current} → ${info.latest}.\n\nCleared ${removed.length} opencode plugin cache entr${removed.length === 1 ? "y" : "ies"}. ${manualUpdateText(info.latest)}` })
2375
- }
2376
- } catch (err: any) {
2377
- output.parts.push({ type: "text", text: `Raven update check failed: ${err?.message ?? err}\n\n${manualUpdateText()}` })
2378
- }
2379
- } else {
2380
- const seek = config.raven_seek
2381
- const mcp = config.raven_mcp
2382
- const seekIssue = ravenProviderIssues.seek ? `\n Provider issue: ${ravenProviderIssues.seek}` : ""
2383
- const mcpIssue = ravenProviderIssues.mcp ? `\n Provider issue: ${ravenProviderIssues.mcp}` : ""
2384
- let update = "Update: unable to check npm."
2385
- try {
2386
- const info = await getUpdateInfo()
2387
- update = info.available && info.latest
2388
- ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
2389
- : info.latest
2390
- ? `Update: up to date (latest ${info.latest}).`
2391
- : "Update: unable to check npm."
2392
- } catch { /* keep fallback */ }
2393
- output.parts.push({ type: "text", text: `Raven version: ${PACKAGE_VERSION}.\nSearch agent: ${seek.model || fm.model || "(default)"}. Reasoning: ${seek.reasoning_effort || fm.reasoning_effort || "(default)"}. Timeout: ${seek.timeout ?? 600}s.${seekIssue}\nMCP agent: ${mcp.model || mcpFm.model || "(default)"}. Reasoning: ${mcp.reasoning_effort || mcpFm.reasoning_effort || "(default)"}. Timeout: ${mcp.timeout ?? 600}s.${mcpIssue}\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
2394
- }
2395
- },
2396
-
2397
- async "tool.execute.before"(input: any, output: any) {
2398
- if ((input.tool === "raven_seek" || input.tool === "raven_mcp") && ravenSessions.has(input.sessionID)) {
2399
- throw new Error(`${input.tool} cannot be called from inside a Raven agent. Use that agent's direct tools instead.`)
2400
- }
2401
-
2402
- if (input.tool === "raven_mcp_bridge" && ravenSessionKinds.get(input.sessionID) !== "mcp" && sessionAgents.get(input.sessionID) !== "raven-mcp") {
2403
- throw new Error("raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests.")
2404
- }
2405
-
2406
- if (ravenSessions.has(input.sessionID)) return
2407
- if (isExcluded(sessionAgents.get(input.sessionID))) return
2408
- if (config.raven_seek.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
2409
-
2410
- if (input.tool === "task" && output.args?.subagent_type === "raven") {
2411
- ravenTaskCalls.add(input.callID)
2412
- }
2413
-
2414
- // ── Block routed tools/MCPs for non-Raven agents ──
2415
- const shouldRouteTool = input.tool === "bash" ? false : isRouteConfigured(input.tool)
2416
- const isSearchBashCmd = isRouteConfigured("bash") && isSearchBash(input.tool, output.args || input.args)
2417
-
2418
- if (shouldRouteTool || isSearchBashCmd) {
2419
- const args = compactArgs(output.args || input.args)
2420
- if (output.args && typeof output.args === "object") output.args = args
2421
- throw new Error(rerouteMessage(input.tool, args))
2422
- }
2423
- },
2424
-
2425
- async "tool.execute.after"(input: any, output: any) {
2426
- if (ravenTaskCalls.has(input.callID)) {
2427
- ravenTaskCalls.delete(input.callID)
2428
- // Try task metadata first (built-in tools preserve metadata)
2429
- const ravenSessionId = output.metadata?.sessionId ?? ravenSessionParents.get(input.sessionID)
2430
- if (ravenSessionId) {
2431
- if (ravenSessionParents.has(input.sessionID)) ravenSessionParents.delete(input.sessionID)
2432
- void countRavenSavedCandidateBytes(ravenSessionId)
2433
- .then((total) => {
2434
- const saved = Math.max(0, total - String(output.output ?? "").length)
2435
- if (saved > 0) addBytes(saved)
2436
- })
2437
- .catch(() => {
2438
- // Without token metadata we cannot separate saved context from the compact answer.
2439
- })
2440
- }
2441
- }
2442
- },
2443
- }
2444
- }) satisfies Plugin
14
+ } satisfies V2Plugin.Plugin & { server: V1Plugin }