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