opencode-raven 2.3.0 → 2.3.1

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.
Files changed (7) hide show
  1. package/CHANGELOG.md +90 -81
  2. package/README.md +7 -1
  3. package/package.json +26 -20
  4. package/raven-core.ts +1703 -1679
  5. package/rpc.ts +18 -0
  6. package/tui.ts +65 -3
  7. package/v2.ts +345 -323
package/raven-core.ts CHANGED
@@ -1,23 +1,23 @@
1
- import type { PluginInput } from "@opencode-ai/plugin"
2
- import type { tool as defineTool } from "@opencode-ai/plugin"
3
- import { z } from "zod"
1
+ import type { PluginInput } from "@opencode-ai/plugin"
2
+ import type { tool as defineTool } from "@opencode-ai/plugin"
3
+ import { z } from "zod"
4
4
  import { Client } from "@modelcontextprotocol/sdk/client/index.js"
5
5
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
6
6
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
7
7
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
9
9
  import { join } from "node:path"
10
10
  import { homedir, tmpdir } from "node:os"
11
11
 
12
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")
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
21
  const MCP_GUIDANCE_MD = join(PKG_DIR, "mcp-guidance.md")
22
22
  const PACKAGE_JSON = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"))
23
23
  const PACKAGE_NAME = PACKAGE_JSON.name || "opencode-raven"
@@ -68,10 +68,10 @@ const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
68
68
  },
69
69
  }
70
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)
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
75
 
76
76
  // ── Bash commands that look like search workarounds ──
77
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
@@ -88,49 +88,49 @@ function stripShellComments(cmd: string): string {
88
88
  .join("\n")
89
89
  }
90
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
- }
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
134
 
135
135
  function splitPipelineSegments(cmd: string): string[] {
136
136
  const segments: string[] = []
@@ -159,16 +159,16 @@ function hasSearchAfterCommandSeparator(segment: string): boolean {
159
159
  return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
160
160
  }
161
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) => {
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
172
  // Allow bounded filters over output already produced by the previous command:
173
173
  // pacman -Qi libarchive | head -15
174
174
  // 7z i | grep -i udf
@@ -178,10 +178,10 @@ function commandLooksLikeSearch(cmd: string): boolean {
178
178
  })
179
179
  }
180
180
 
181
- function isSearchBash(tool: string, args: any): boolean {
182
- if (tool !== "bash" && tool !== "shell") return false
183
- return commandLooksLikeSearch(String(args?.command ?? ""))
184
- }
181
+ function isSearchBash(tool: string, args: any): boolean {
182
+ if (tool !== "bash" && tool !== "shell") return false
183
+ return commandLooksLikeSearch(String(args?.command ?? ""))
184
+ }
185
185
 
186
186
  // ── Config file shape ──
187
187
  interface OnDemandMcpToolInfo {
@@ -204,11 +204,11 @@ interface OnDemandMcpMetadata {
204
204
  lastCheckedAt?: string
205
205
  }
206
206
 
207
- interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
208
- type: "remote"
209
- url: string
210
- headers?: Record<string, string>
211
- }
207
+ interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
208
+ type: "remote"
209
+ url: string
210
+ headers?: Record<string, string>
211
+ }
212
212
 
213
213
  interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
214
214
  type: "local"
@@ -221,60 +221,60 @@ type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
221
221
  type OnDemandMcpDescriptionDetail = "full" | "minimized"
222
222
  type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
223
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
- }
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
251
 
252
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
- }
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
278
 
279
279
  function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
280
280
  const parts = raw.split("---")
@@ -386,19 +386,19 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
386
386
 
387
387
  function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
388
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
- }
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
397
 
398
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
- : []
399
+ const commandParts = Array.isArray(source.command)
400
+ ? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
401
+ : []
402
402
  if (!commandParts.length) return undefined
403
403
  const environment = normalizeStringRecord(source.environment)
404
404
  return {
@@ -415,10 +415,10 @@ function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefin
415
415
  const headers = normalizeStringRecord(source.headers)
416
416
  return {
417
417
  ...base,
418
- type: "remote",
419
- url: source.url.trim(),
420
- ...(headers ? { headers } : {}),
421
- }
418
+ type: "remote",
419
+ url: source.url.trim(),
420
+ ...(headers ? { headers } : {}),
421
+ }
422
422
  }
423
423
 
424
424
  return undefined
@@ -447,7 +447,7 @@ function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, O
447
447
  return result
448
448
  }
449
449
 
450
- function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
450
+ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
451
451
  const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
452
452
  const result: Record<string, OnDemandMcpServer> = {}
453
453
  for (const [key, raw] of Object.entries(source)) {
@@ -459,96 +459,96 @@ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, On
459
459
  }
460
460
 
461
461
  // ── Plugin ──
462
- export default (async (input: Pick<PluginInput, "client" | "directory"> & { project: Pick<PluginInput["project"], "id"> }, host: "v1" | "v2" = "v1") => {
462
+ export default (async (input: Pick<PluginInput, "client" | "directory"> & { project: Pick<PluginInput["project"], "id"> }, host: "v1" | "v2" = "v1") => {
463
463
  const client = input.client
464
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
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
515
 
516
516
  return normalized
517
517
  }
518
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)
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
545
  try {
546
546
  if (existsSync(mcpMetadataFile)) {
547
547
  return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
548
548
  }
549
- } catch { /* ignore corruption, use defaults */ }
550
- saveMcpMetadata(defaults)
551
- return defaults
549
+ } catch { /* ignore corruption, use defaults */ }
550
+ saveMcpMetadata(defaults)
551
+ return defaults
552
552
  }
553
553
 
554
554
  function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
@@ -558,154 +558,154 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
558
558
  } catch { /* non-fatal: generated metadata can be refreshed */ }
559
559
  }
560
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>()
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
709
  const sessionAgents = new Map<string, string>()
710
710
  const ravenSessionParents = new Map<string, string>()
711
711
  const ravenErrorWaiters = new Map<string, (error: Error) => void>()
@@ -716,28 +716,30 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
716
716
  let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
717
717
  let updateToastPending = false
718
718
  let mcpFailureToastPending = false
719
- let duplicateMcpToastPending = false
720
- let modelSetupToastPending = false
719
+ let duplicateMcpToastPending = false
720
+ let modelSetupToastPending = false
721
721
  let mcpRefreshScheduled = false
722
+ let mcpStartupRefreshComplete = false
722
723
  let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
723
724
  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()
725
+ const pendingMcpRefreshes = new Map<string, Promise<{ refreshed: string[]; failed: string[] }>>()
726
+ const mcpRefreshControllers = new Set<AbortController>()
727
+ let lastNotifiedMcpFailureKey = ""
728
+ const ravenProviderIssues: Partial<Record<DelegationKind, string>> = {}
729
+
730
+ async function refreshModelsIfPending() {
731
+ if (modelRefreshPromise) return modelRefreshPromise
732
+ if (!modelRefreshPending) return
733
+ modelRefreshPending = false
734
+ modelRefreshPromise = ensureRavenModels().then(() => {
735
+ modelSetupToastPending = !!modelSetupInstructions()
736
+ })
737
+ return modelRefreshPromise
738
+ }
739
+
740
+ // Replace generated instructions from older releases before OpenCode consumes them.
741
+ saveDynamicGuidance()
742
+ resetOnDemandMcpRuntimeStatuses()
741
743
 
742
744
  function resetOnDemandMcpRuntimeStatuses() {
743
745
  let changed = false
@@ -754,19 +756,19 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
754
756
  }
755
757
 
756
758
  // ── 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
759
+ function isExcluded(agent: string | undefined): boolean {
760
+ if (!agent || !config.raven_seek.excludeAgents?.length) return false
761
+ const lower = agent.toLowerCase()
762
+ return config.raven_seek.excludeAgents.some((a) => a.toLowerCase() === lower)
763
+ }
764
+
765
+ function isRouteConfigured(toolName: string): boolean {
766
+ if (!modelOperational("seek")) return false
765
767
  const tool = toolName.toLowerCase()
766
768
  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
769
+ const aliases = tool === "bash" || tool === "shell" ? ["bash", "shell"] : [tool]
770
+ if (config.raven_seek.routeTools?.some((name) => aliases.includes(name.toLowerCase()))) return true
771
+ if (config.raven_seek.routeToolKeywords?.some((keyword) => tool.includes(keyword.toLowerCase()))) return true
770
772
  return globalMcpServers.some((server) => tool.startsWith(`${server.toLowerCase()}_`))
771
773
  }
772
774
 
@@ -796,168 +798,169 @@ export default (async (input: Pick<PluginInput, "client" | "directory"> & { proj
796
798
  return query.length > 300 ? `${query.slice(0, 297)}...` : query
797
799
  }
798
800
 
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
- }
801
+ function rerouteMessage(tool: string, args: any): string {
802
+ if (ravenProviderIssues.seek) {
803
+ return `Raven search is currently unavailable: ${ravenProviderIssues.seek}
804
+
805
+ ${ravenRecoveryInstructions("seek")}`
806
+ }
805
807
  return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
806
808
  }
807
809
 
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")}
810
+ function ravenRecoveryInstructions(kind: DelegationKind): string {
811
+ const command = kind === "mcp" ? "/raven mcp model <provider/model>" : "/raven seek model <provider/model>"
812
+ 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.
813
+
814
+ Tell the user to switch Raven's model with:
815
+ ${command}
816
+
817
+ Example:
818
+ ${command.replace("<provider/model>", "openai/gpt-5.5")}
817
819
 
818
820
  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
821
  }
820
822
 
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)"
823
+ function routeSummary(): string {
824
+ if (!modelOperational("seek")) return "Raven routing: inactive until the configured Search model is loaded."
825
+ const tools = config.raven_seek.routeTools?.length ? config.raven_seek.routeTools.join(", ") : "(none)"
826
+ const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
827
+ const keywords = config.raven_seek.routeToolKeywords?.length ? config.raven_seek.routeToolKeywords.join(", ") : "(none)"
826
828
  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
- }
829
+ return `Raven routing:\n Routed tools: ${tools}\n Routed global MCPs: ${mcps}\n Routed tool keywords: ${keywords}${duplicates}`
830
+ }
831
+
832
+ function modelSetupInstructions(kind?: DelegationKind): string {
833
+ const unavailable = [
834
+ ...(!modelOperational("seek") && kind !== "mcp" ? ["seek"] : []),
835
+ ...(!modelOperational("mcp") && kind !== "seek" ? ["mcp"] : []),
836
+ ] as DelegationKind[]
837
+ if (!unavailable.length) return ""
838
+ const pendingRestart = unavailable.filter((item) => item === "seek" ? !!config.raven_seek.model : !!config.raven_mcp.model)
839
+ const missing = unavailable.filter((item) => item === "seek" ? !config.raven_seek.model : !config.raven_mcp.model)
840
+ const lines: string[] = []
841
+ if (pendingRestart.length) {
842
+ const models = pendingRestart.map((item) => `${item === "seek" ? "Search" : "MCP"}: ${item === "seek" ? modelLabel(config.raven_seek) : modelLabel(config.raven_mcp)}`)
843
+ lines.push(`Raven model update ready (${models.join(", ")}). Restart OpenCode to load it.`)
844
+ }
845
+ if (!missing.length) return lines.join("\n")
846
+ const reason = modelDiscoveryError
847
+ ? `Automatic free-model detection failed: ${modelDiscoveryError}.`
848
+ : "No connected, zero-cost OpenCode model with tool support was detected."
849
+ const commands = missing.map((item) => item === "seek"
850
+ ? " /raven seek model <provider/model>"
851
+ : " /raven mcp model <provider/model>")
852
+ 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.`)
853
+ return lines.join("\n\n")
854
+ }
855
+
856
+ function modelOperational(kind: DelegationKind): boolean {
857
+ const active = kind === "seek" ? activeSeekModel : activeMcpModel
858
+ const configured = kind === "seek" ? config.raven_seek.model : config.raven_mcp.model
859
+ return !!active && active === configured
860
+ }
861
+
862
+ function modelLabel(section: RavenSeekConfig | RavenMcpConfig): string {
863
+ if (!section.model) return "(not configured)"
864
+ return section.autoSelectedModel === section.model ? `${section.model} (auto)` : section.model
865
+ }
864
866
 
865
867
  function mcpSummary(): string {
866
868
  const buckets = onDemandMcpStatusBuckets()
867
869
  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"}`
870
+ if (!total) return `Raven on-demand MCPs (always behind Raven): none configured.\n Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`
869
871
  const lines = [
870
872
  "Raven on-demand MCPs (always behind Raven):",
871
873
  ` Loaded: ${buckets.loaded.length ? buckets.loaded.join(", ") : "none"}`,
872
874
  ` Failed: ${buckets.failed.length ? buckets.failed.join(", ") : "none"}`,
873
875
  ` Pending: ${buckets.pending.length ? buckets.pending.join(", ") : "none"}`,
874
- ` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
876
+ ` Guidance detail: ${config.raven_mcp.descriptionDetail ?? "full"}`,
875
877
  ]
876
878
  return lines.join("\n")
877
879
  }
878
880
 
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
-
881
+ function ravenAgentPrompt(): string {
882
+ const extra = config.raven_seek.instructions?.trim()
883
+ const prompt = ravenPrompt
884
+ return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
885
+ }
886
+
885
887
  function ravenMcpAgentPrompt(): string {
886
888
  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")}`
889
+ // V2 supplies current capabilities through the session context hook.
890
+ const prompt = host === "v2" ? ravenMcpPrompt
891
+ : `${ravenMcpPrompt}\n\nOn-demand MCP guidance:\n${onDemandMcpGuidance("raven")}`
892
+ return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
893
+ }
894
+
895
+ function compactText(value: unknown): string {
896
+ return String(value ?? "").replace(/\s+/g, " ").trim()
897
+ }
898
+
899
+ function truncateText(value: unknown, max = 240): string {
900
+ const text = compactText(value)
901
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
902
+ }
903
+
904
+ function dynamicGuidanceMcpOrder(): string[] | undefined {
905
+ try {
906
+ if (!existsSync(dynamicGuidanceFile)) return undefined
907
+ const names: string[] = []
908
+ for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
909
+ const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
910
+ if (name && !names.includes(name)) names.push(name)
911
+ }
912
+ return names
913
+ } catch {
914
+ return undefined
915
+ }
916
+ }
917
+
918
+ function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
919
+ if (!preferredOrder?.length) return entries
920
+ const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
921
+ const existing = entries
922
+ .filter(([name]) => order.has(name.toLowerCase()))
923
+ .sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
924
+ const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
925
+ return [...existing, ...added]
926
+ }
927
+
928
+ function firstSentenceText(value: unknown): string {
929
+ const text = compactText(value)
930
+ const period = text.indexOf(".")
931
+ return period === -1 ? text : text.slice(0, period + 1)
932
+ }
933
+
934
+ function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
935
+ const servers = config.raven_mcp.onDemandMcpServers ?? {}
936
+ const activeEntries = activeOnDemandMcpEntries()
937
+ const entries = orderedOnDemandMcpEntries(
938
+ target === "raven"
939
+ ? activeEntries
940
+ : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
941
+ preferredOrder,
942
+ )
943
+ if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
944
+ if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
945
+ if (!entries.length) return "No loaded on-demand MCPs are available."
946
+
947
+ const detail = config.raven_mcp.descriptionDetail ?? "full"
948
+ const lines = entries.map(([name, server]) => {
949
+ const metadata = mcpMetadata[name] ?? {}
950
+ const userDescription = firstSentenceText(server.description)
951
+ const generated = firstSentenceText(metadata.autoGeneratedDescription)
952
+ const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
953
+ const summary = parts.length ? parts.join(" ") : "Description discovery pending."
954
+ if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
955
+ const tools = metadata.autoGeneratedTools
956
+ .map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
957
+ .join("; ")
958
+ return `- ${name}: ${summary} Tools: ${tools}`
959
+ })
960
+ if (target === "raven") {
961
+ return `Use raven_mcp_bridge for these on-demand MCP capabilities:\n${lines.join("\n")}`
959
962
  }
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")}`
963
+ 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
964
  }
962
965
 
963
966
  function resolveEnvPlaceholders(value: string): string {
@@ -1028,114 +1031,114 @@ Then restart opencode for the model change to take effect. If the task can be co
1028
1031
  ravenProgressWaiters.get(sessionId)?.()
1029
1032
  }
1030
1033
 
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
- }
1034
+ type McpConnection = {
1035
+ client: Client
1036
+ transport: any
1037
+ lastUsed: number
1038
+ active: number
1039
+ }
1040
+
1041
+ type McpConnectionAttempt = {
1042
+ promise: Promise<McpConnection>
1043
+ controller: AbortController
1044
+ waiters: number
1045
+ }
1046
+
1047
+ const mcpConnections = new Map<string, McpConnection>()
1048
+ const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
1049
+ let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
1050
+
1051
+ function mcpTimeoutMs(server: OnDemandMcpServer): number {
1052
+ return server.timeout ?? (config.raven_mcp.timeout ?? 600) * 1000
1053
+ }
1054
+
1055
+ async function closeMcpClient(client: Client, transport: any) {
1056
+ try {
1057
+ await transport?.close?.()
1058
+ } catch { /* best-effort */ }
1059
+ try {
1060
+ await client.close()
1061
+ } catch { /* best-effort */ }
1062
+ }
1063
+
1064
+ async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
1065
+ const controller = new AbortController()
1066
+ let timedOut = false
1067
+ const abort = () => controller.abort(signal?.reason)
1068
+ if (signal?.aborted) abort()
1069
+ else signal?.addEventListener("abort", abort, { once: true })
1070
+ const timer = setTimeout(() => {
1071
+ timedOut = true
1072
+ controller.abort()
1073
+ }, timeout)
1074
+ const aborted = new Promise<never>((_resolve, reject) => {
1075
+ const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
1076
+ if (controller.signal.aborted) fail()
1077
+ else controller.signal.addEventListener("abort", fail, { once: true })
1078
+ })
1079
+
1080
+ try {
1081
+ await Promise.race([
1082
+ client.connect(transport, { signal: controller.signal, timeout }),
1083
+ aborted,
1084
+ ])
1085
+ } catch (error) {
1086
+ await closeMcpClient(client, transport)
1087
+ throw error
1088
+ } finally {
1089
+ clearTimeout(timer)
1090
+ signal?.removeEventListener("abort", abort)
1091
+ }
1092
+ }
1093
+
1094
+ async function closeMcpConnection(connection: McpConnection) {
1095
+ await closeMcpClient(connection.client, connection.transport)
1096
+ }
1097
+
1098
+ function stopMcpIdleTimerIfUnused() {
1099
+ if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
1100
+ clearInterval(mcpIdleTimer)
1101
+ mcpIdleTimer = undefined
1102
+ }
1103
+
1104
+ function ensureMcpIdleTimer() {
1105
+ if (mcpIdleTimer) return
1106
+ mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
1107
+ ;(mcpIdleTimer as any).unref?.()
1108
+ }
1109
+
1110
+ async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
1111
+ const existing = mcpConnections.get(key)
1112
+ if (!existing || (expected && existing !== expected)) return
1113
+ mcpConnections.delete(key)
1114
+ await closeMcpConnection(existing)
1115
+ stopMcpIdleTimerIfUnused()
1116
+ }
1114
1117
 
1115
1118
  function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
1116
1119
  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
- }
1120
+ for (const [key, connection] of mcpConnections) {
1121
+ if (!connection.active && now - connection.lastUsed > maxAgeMs) {
1122
+ void closeMcpConnectionKey(key, connection)
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ async function closeMcpConnectionsForSession(sessionId: string) {
1128
+ const prefix = `${sessionId}:`
1129
+ const pending: Promise<McpConnection>[] = []
1130
+ for (const [key, attempt] of mcpConnectionAttempts) {
1131
+ if (!key.startsWith(prefix)) continue
1132
+ attempt.controller.abort()
1133
+ pending.push(attempt.promise)
1134
+ }
1135
+ await Promise.allSettled(pending)
1136
+ await Promise.all(
1137
+ [...mcpConnections.keys()]
1138
+ .filter((key) => key.startsWith(prefix))
1139
+ .map((key) => closeMcpConnectionKey(key)),
1140
+ )
1141
+ }
1139
1142
 
1140
1143
  function mcpConnectionKey(sessionId: string, serverName: string): string {
1141
1144
  return `${sessionId}:${serverName.toLowerCase()}`
@@ -1146,44 +1149,44 @@ Then restart opencode for the model change to take effect. If the task can be co
1146
1149
  return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
1147
1150
  }
1148
1151
 
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") {
1152
+ function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
1153
+ return Object.entries(config.raven_mcp.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
1154
+ }
1155
+
1156
+ function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
1157
+ if (!signal) {
1158
+ attempt.waiters++
1159
+ return attempt.promise.finally(() => attempt.waiters--)
1160
+ }
1161
+ if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
1162
+ attempt.waiters++
1163
+ return new Promise((resolve, reject) => {
1164
+ let settled = false
1165
+ const finish = () => {
1166
+ if (settled) return false
1167
+ settled = true
1168
+ attempt.waiters--
1169
+ signal.removeEventListener("abort", abort)
1170
+ return true
1171
+ }
1172
+ const abort = () => {
1173
+ if (!finish()) return
1174
+ if (!attempt.waiters) attempt.controller.abort(signal.reason)
1175
+ reject(new Error("MCP request cancelled"))
1176
+ }
1177
+ signal.addEventListener("abort", abort, { once: true })
1178
+ attempt.promise.then(
1179
+ (connection) => { if (finish()) resolve(connection) },
1180
+ (error) => { if (finish()) reject(error) },
1181
+ )
1182
+ })
1183
+ }
1184
+
1185
+ async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
1186
+ const timeout = mcpTimeoutMs(server)
1187
+ const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1188
+
1189
+ if (server.type === "local") {
1187
1190
  const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
1188
1191
  const transport = new StdioClientTransport({
1189
1192
  command: resolveEnvPlaceholders(server.command[0]),
@@ -1191,114 +1194,114 @@ Then restart opencode for the model change to take effect. If the task can be co
1191
1194
  env,
1192
1195
  stderr: "pipe",
1193
1196
  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}`)
1197
+ })
1198
+ transport.stderr?.on("data", () => {})
1199
+ transport.stderr?.on("error", () => {})
1200
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1201
+ return { client, transport, lastUsed: Date.now(), active: 0 }
1202
+ }
1203
+
1204
+ const url = new URL(resolveEnvPlaceholders(server.url))
1205
+ const headers = resolveStringRecord(server.headers)
1206
+ const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1207
+ try {
1208
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
1209
+ return { client, transport, lastUsed: Date.now(), active: 0 }
1210
+ } catch (err) {
1211
+ if (signal?.aborted) throw err
1212
+ const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
1213
+ const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
1214
+ try {
1215
+ await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
1216
+ return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
1217
+ } catch (fallbackError) {
1218
+ throw fallbackError
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
1224
+ if (signal?.aborted) throw new Error("MCP request cancelled")
1225
+ closeIdleMcpConnections()
1226
+ const match = getOnDemandMcpServer(serverName)
1227
+ if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
1225
1228
  const [canonicalName, server] = match
1226
1229
  const key = mcpConnectionKey(sessionId, canonicalName)
1227
1230
  const existing = mcpConnections.get(key)
1228
1231
  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
1232
+ existing.lastUsed = Date.now()
1233
+ return existing
1234
+ }
1235
+
1236
+ const pending = mcpConnectionAttempts.get(key)
1237
+ if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
1238
+ if (pending) mcpConnectionAttempts.delete(key)
1239
+
1240
+ const controller = new AbortController()
1241
+ const attempt = { controller, waiters: 0 } as McpConnectionAttempt
1242
+ attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
1243
+ .then(async (connection) => {
1244
+ if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
1245
+ await closeMcpConnection(connection)
1246
+ throw new Error("MCP connection attempt cancelled")
1247
+ }
1248
+ mcpConnections.set(key, connection)
1249
+ connection.client.onclose = () => {
1250
+ if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
1251
+ stopMcpIdleTimerIfUnused()
1252
+ }
1253
+ ensureMcpIdleTimer()
1254
+ return connection
1255
+ })
1256
+ .finally(() => {
1257
+ if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
1258
+ stopMcpIdleTimerIfUnused()
1259
+ })
1260
+ mcpConnectionAttempts.set(key, attempt)
1261
+ ensureMcpIdleTimer()
1262
+ return waitForMcpConnection(attempt, signal)
1263
+ }
1264
+
1265
+ async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1266
+ const tools: any[] = []
1267
+ let cursor: string | undefined
1268
+ do {
1269
+ const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
1270
+ tools.push(...(result.tools ?? []))
1271
+ cursor = result.nextCursor
1272
+ } while (cursor)
1273
+ return tools
1274
+ }
1275
+
1276
+ async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1277
+ const resources: any[] = []
1278
+ let cursor: string | undefined
1279
+ do {
1280
+ const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
1281
+ resources.push(...(result.resources ?? []))
1282
+ cursor = result.nextCursor
1283
+ } while (cursor)
1284
+ return resources
1285
+ }
1286
+
1287
+ async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1288
+ const prompts: any[] = []
1289
+ let cursor: string | undefined
1290
+ do {
1291
+ const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
1292
+ prompts.push(...(result.prompts ?? []))
1293
+ cursor = result.nextCursor
1291
1294
  } while (cursor)
1292
1295
  return prompts
1293
1296
  }
1294
1297
 
1295
1298
  function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
1296
1299
  return tools
1297
- .filter((item) => item?.name)
1298
- .map((item) => ({
1299
- name: String(item.name),
1300
- ...(item.description ? { description: compactText(item.description) } : {}),
1301
- }))
1300
+ .filter((item) => item?.name)
1301
+ .map((item) => ({
1302
+ name: String(item.name),
1303
+ ...(item.description ? { description: compactText(item.description) } : {}),
1304
+ }))
1302
1305
  }
1303
1306
 
1304
1307
  function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
@@ -1307,15 +1310,15 @@ Then restart opencode for the model change to take effect. If the task can be co
1307
1310
  }
1308
1311
 
1309
1312
  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()
1313
+ if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
1314
+ const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
1315
+ const joined = descriptions.slice(0, 6).join("; ")
1316
+ return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
1317
+ }
1318
+
1319
+ async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
1320
+ if (signal?.aborted) return { refreshed: [], failed: [] }
1321
+ const entries = activeOnDemandMcpEntries()
1319
1322
  .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
1320
1323
  .filter(([name]) => {
1321
1324
  const metadata = mcpMetadata[name]
@@ -1324,14 +1327,14 @@ Then restart opencode for the model change to take effect. If the task can be co
1324
1327
  const refreshed: string[] = []
1325
1328
  const failed: string[] = []
1326
1329
  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) })
1330
+ let changed = false
1331
+ for (const [name, server] of entries) {
1332
+ if (signal?.aborted) break
1333
+ let connection: McpConnection | undefined
1334
+ const checkedAt = new Date().toISOString()
1335
+ try {
1336
+ connection = await connectOnDemandMcp(name, server, signal)
1337
+ const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
1335
1338
  const tools = generatedToolsFromMcpTools(rawTools)
1336
1339
  mcpMetadata[name] = {
1337
1340
  autoGeneratedTools: tools,
@@ -1340,11 +1343,11 @@ Then restart opencode for the model change to take effect. If the task can be co
1340
1343
  status: "ready",
1341
1344
  lastCheckedAt: checkedAt,
1342
1345
  }
1343
- changed = true
1344
- refreshed.push(name)
1345
- } catch (err: any) {
1346
- if (signal?.aborted) break
1347
- mcpMetadata[name] = {
1346
+ changed = true
1347
+ refreshed.push(name)
1348
+ } catch (err: any) {
1349
+ if (signal?.aborted) break
1350
+ mcpMetadata[name] = {
1348
1351
  ...(mcpMetadata[name] ?? {}),
1349
1352
  status: "failed",
1350
1353
  lastError: truncateText(err?.message ?? err, 500),
@@ -1364,38 +1367,46 @@ Then restart opencode for the model change to take effect. If the task can be co
1364
1367
  return { refreshed, failed }
1365
1368
  }
1366
1369
 
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
-
1370
+ function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
1371
+ if (signal.aborted) return Promise.resolve()
1372
+ return new Promise((resolve) => {
1373
+ const timer = setTimeout(done, ms)
1374
+ function done() {
1375
+ clearTimeout(timer)
1376
+ signal.removeEventListener("abort", done)
1377
+ resolve()
1378
+ }
1379
+ signal.addEventListener("abort", done, { once: true })
1380
+ })
1381
+ }
1382
+
1380
1383
  function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
1384
+ const key = `${force}:${serverName?.toLowerCase() ?? "*"}`
1385
+ const pending = pendingMcpRefreshes.get(key)
1386
+ if (pending) return pending
1381
1387
  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
+ mcpRefreshControllers.add(controller)
1389
+ const run = mcpRefreshQueue
1390
+ .catch(() => undefined)
1391
+ .then(async () => {
1392
+ if (delay) await waitForRefreshDelay(delay, controller.signal)
1393
+ return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
1394
+ })
1395
+ .finally(() => {
1396
+ mcpRefreshControllers.delete(controller)
1397
+ pendingMcpRefreshes.delete(key)
1388
1398
  })
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
1399
+ pendingMcpRefreshes.set(key, run)
1400
+ mcpRefreshQueue = run
1401
+ return run
1402
+ }
1403
+
1404
+ function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
1405
+ if (mcpRefreshScheduled) return mcpRefreshPromise!
1406
+ mcpRefreshScheduled = true
1397
1407
  mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
1398
- return mcpRefreshPromise
1408
+ .finally(() => { mcpStartupRefreshComplete = true })
1409
+ return mcpRefreshPromise
1399
1410
  }
1400
1411
 
1401
1412
  function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
@@ -1444,7 +1455,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1444
1455
 
1445
1456
  function onDemandMcpStatus(): string {
1446
1457
  const entries = activeOnDemandMcpEntries()
1447
- if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
1458
+ if (!Object.keys(config.raven_mcp.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
1448
1459
  if (!entries.length) return "Raven on-demand MCPs: none enabled."
1449
1460
  const buckets = onDemandMcpStatusBuckets()
1450
1461
  const lines = entries.map(([name, server]) => {
@@ -1457,7 +1468,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1457
1468
  const error = status === "failed" && metadata?.lastError ? `, error: ${metadata.lastError}` : ""
1458
1469
  return `- ${name} (${server.type}): ${displayMcpStatus(status)}, ${source}, ${generated}, ${tools}${avoided}${error}`
1459
1470
  })
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")}`
1471
+ 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
1472
  }
1462
1473
 
1463
1474
  function avoidedMcpSchemaBytes(): number {
@@ -1473,9 +1484,9 @@ Then restart opencode for the model change to take effect. If the task can be co
1473
1484
  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
1485
  }
1475
1486
 
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
- }
1487
+ function helpText(): string {
1488
+ 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`
1489
+ }
1479
1490
 
1480
1491
  function formatMcpList(title: string, items: any[], fields: string[] = ["name", "description"]): string {
1481
1492
  if (!items.length) return `${title}: none found.`
@@ -1509,26 +1520,26 @@ Then restart opencode for the model change to take effect. If the task can be co
1509
1520
  return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1510
1521
  }
1511
1522
 
1512
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1523
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
1513
1524
  if (!raw?.trim()) return {}
1514
1525
  const parsed = JSON.parse(raw)
1515
1526
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1516
1527
  throw new Error("argumentsJson must be a JSON object")
1517
1528
  }
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
- }
1529
+ return parsed
1530
+ }
1531
+
1532
+ // ── Context saved by Raven delegation ──
1533
+ let sessionBytes = 0
1534
+ let totalBytes = config.stats?.bytes ?? 0
1535
+
1536
+ function addBytes(bytes: number) {
1537
+ sessionBytes += bytes
1538
+ const updated = updateConfig((latest) => ({
1539
+ stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
1540
+ }))
1541
+ totalBytes = updated.stats?.bytes ?? totalBytes + bytes
1542
+ }
1532
1543
 
1533
1544
  function formatBytes(bytes: number): string {
1534
1545
  return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
@@ -1576,10 +1587,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1576
1587
  return { current: PACKAGE_VERSION, latest, available: !!latest && compareVersions(latest, PACKAGE_VERSION) > 0 }
1577
1588
  }
1578
1589
 
1579
- function estimateRavenSavedCandidateBytes(messages: any[]): number {
1580
- const assistantMessages = messages.filter((m: any) =>
1581
- m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1582
- )
1590
+ function estimateRavenSavedCandidateBytes(messages: any[]): number {
1591
+ const assistantMessages = messages.filter((m: any) =>
1592
+ m?.info?.role === "assistant" && m?.info?.tokens?.output > 0
1593
+ )
1583
1594
  const first = assistantMessages[0]
1584
1595
  const last = assistantMessages[assistantMessages.length - 1]
1585
1596
  const t = last?.info?.tokens
@@ -1591,15 +1602,15 @@ Then restart opencode for the model change to take effect. If the task can be co
1591
1602
  const baselineTokens = firstTokens
1592
1603
  ? (firstTokens.input ?? 0) + (firstTokens.cache?.read ?? 0) + (firstTokens.cache?.write ?? 0)
1593
1604
  : 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
- }
1605
+ const savedCandidateTokens = Math.max(0, totalTokens - baselineTokens)
1606
+ return savedCandidateTokens * 4
1607
+ }
1608
+
1609
+ async function countRavenSavedCandidateBytes(sessionId: string, signal?: AbortSignal): Promise<number> {
1610
+ const messagesResp = await client.session.messages({ path: { id: sessionId }, signal })
1611
+ const messages = (messagesResp as any)?.data ?? []
1612
+ return estimateRavenSavedCandidateBytes(messages)
1613
+ }
1603
1614
 
1604
1615
  async function getUpdateInfo(): Promise<{ current: string; latest?: string; available: boolean }> {
1605
1616
  if (updateInfo) return updateInfo
@@ -1631,8 +1642,8 @@ Then restart opencode for the model change to take effect. If the task can be co
1631
1642
  return updateCheckPromise
1632
1643
  }
1633
1644
 
1634
- function clearPluginCache(): string[] {
1635
- if (host === "v2") return []
1645
+ function clearPluginCache(): string[] {
1646
+ if (host === "v2") return []
1636
1647
  const packagesDir = join(homedir(), ".cache", "opencode", "packages")
1637
1648
  if (!existsSync(packagesDir)) return []
1638
1649
 
@@ -1646,8 +1657,8 @@ Then restart opencode for the model change to take effect. If the task can be co
1646
1657
  return removed
1647
1658
  }
1648
1659
 
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.`
1660
+ function manualUpdateText(latest = "latest"): string {
1661
+ 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
1662
  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
1663
  }
1653
1664
 
@@ -1666,13 +1677,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1666
1677
  } catch { /* update checks are best-effort */ }
1667
1678
  }
1668
1679
 
1669
- async function notifyIfMcpFailures(names?: string[]) {
1670
- try {
1671
- const failed = failedOnDemandMcpNames(names)
1672
- if (!failed.length) {
1673
- lastNotifiedMcpFailureKey = ""
1674
- return
1675
- }
1680
+ async function notifyIfMcpFailures(names?: string[]) {
1681
+ try {
1682
+ const failed = failedOnDemandMcpNames(names)
1683
+ if (!failed.length) {
1684
+ lastNotifiedMcpFailureKey = ""
1685
+ return
1686
+ }
1676
1687
  const key = failed.sort().join("|")
1677
1688
  if (key === lastNotifiedMcpFailureKey) return
1678
1689
  lastNotifiedMcpFailureKey = key
@@ -1688,7 +1699,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1688
1699
  } catch { /* MCP failure notifications are best-effort */ }
1689
1700
  }
1690
1701
 
1691
- async function notifyIfDuplicateMcps() {
1702
+ async function notifyIfDuplicateMcps() {
1692
1703
  try {
1693
1704
  if (!duplicateMcpServers.length) return
1694
1705
  const label = duplicateMcpServers.length === 1 ? duplicateMcpServers[0] : `${duplicateMcpServers.length} MCPs`
@@ -1700,317 +1711,330 @@ Then restart opencode for the model change to take effect. If the task can be co
1700
1711
  duration: 12000,
1701
1712
  },
1702
1713
  })
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
-
1714
+ } catch { /* duplicate MCP notifications are best-effort */ }
1715
+ }
1716
+
1717
+ async function notifyIfModelSetupRequired() {
1718
+ const message = modelSetupInstructions()
1719
+ if (!message) return
1720
+ const updateReady = (!!config.raven_seek.model && activeSeekModel !== config.raven_seek.model)
1721
+ || (!!config.raven_mcp.model && activeMcpModel !== config.raven_mcp.model)
1722
+ try {
1723
+ await (client as any).tui?.showToast?.({
1724
+ body: {
1725
+ title: updateReady ? "Raven model selected" : "Raven first-time setup",
1726
+ message: updateReady
1727
+ ? "A free Raven model was selected. Restart OpenCode to load it."
1728
+ : "No free Raven model was detected. Run /raven for setup instructions; routing is inactive until configured.",
1729
+ variant: updateReady ? "info" : "warning",
1730
+ duration: 15000,
1731
+ },
1732
+ })
1733
+ } catch { /* model setup remains visible in /raven */ }
1734
+ }
1735
+
1736
+ function delegationAgent(kind: DelegationKind): "raven" | "raven-mcp" {
1737
+ return kind === "mcp" ? "raven-mcp" : "raven"
1738
+ }
1739
+
1740
+ function delegationToolName(kind: DelegationKind): "raven_seek" | "raven_mcp" {
1741
+ return kind === "mcp" ? "raven_mcp" : "raven_seek"
1742
+ }
1743
+
1744
+ function delegationTitle(kind: DelegationKind): string {
1745
+ return kind === "mcp" ? "Raven MCP" : "Raven Seek"
1746
+ }
1747
+
1748
+ async function rootSessionId(sessionId: string, signal?: AbortSignal): Promise<string> {
1749
+ let current = sessionId
1750
+ const seen = new Set<string>()
1751
+ while (!seen.has(current)) {
1752
+ seen.add(current)
1753
+ const response = await client.session.get({ path: { id: current }, signal })
1754
+ const session = (response as any)?.data ?? response
1755
+ if (!session?.parentID) return current
1756
+ current = session.parentID
1757
+ }
1758
+ return sessionId
1759
+ }
1760
+
1761
+ async function syncCreatedSession(sessionId: string, title: string, signal?: AbortSignal) {
1762
+ try {
1763
+ await client.session.update({
1764
+ path: { id: sessionId },
1765
+ body: { title },
1766
+ signal,
1767
+ })
1768
+ } catch (error) {
1769
+ if (signal?.aborted) throw error
1770
+ // Delegation still works if an older or disconnected TUI misses the sync event.
1771
+ }
1772
+ }
1773
+
1774
+ async function executeRavenDelegation(kind: DelegationKind, args: { query: string; sessionId?: string }, context: any) {
1775
+ const started = Date.now()
1776
+ const agent = delegationAgent(kind)
1777
+ const toolName = delegationToolName(kind)
1778
+ const title = delegationTitle(kind)
1779
+ await refreshModelsIfPending()
1780
+ if (!modelOperational(kind)) {
1781
+ return { title, output: modelSetupInstructions(kind) }
1782
+ }
1783
+ const timeout = (kind === "mcp" ? config.raven_mcp.timeout : config.raven_seek.timeout) ?? 600
1784
+ const timeoutMs = timeout * 1000
1785
+ let sessionId: string | undefined
1786
+ let resumed = false
1787
+ let previousSavedCandidate = 0
1788
+
1789
+ try {
1790
+ const requestedSessionId = args.sessionId?.trim()
1791
+ if (args.sessionId !== undefined && !requestedSessionId) {
1792
+ return { title, output: "Cannot resume Raven: sessionId must not be empty." }
1793
+ }
1794
+
1795
+ let rootId = context.sessionID
1796
+ try {
1797
+ rootId = await rootSessionId(context.sessionID, context.abort)
1798
+ } catch (error) {
1799
+ if (context.abort.aborted) throw error
1800
+ // Falling back preserves delegation if an older OpenCode build cannot read the caller session.
1801
+ }
1802
+
1803
+ if (requestedSessionId) {
1804
+ const knownKind = ravenSessionKinds.get(requestedSessionId)
1805
+ const knownRavenSession = knownKind === kind || sessionAgents.get(requestedSessionId) === agent
1806
+ let existingSession: any
1807
+ try {
1808
+ const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
1809
+ existingSession = (sessionResp as any)?.data ?? sessionResp
1810
+ } catch (error) {
1811
+ if (context.abort.aborted) throw error
1812
+ return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1813
+ }
1814
+
1815
+ if (!existingSession?.id || existingSession.id !== requestedSessionId) {
1816
+ return { title, output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
1817
+ }
1818
+ const validParent = existingSession.parentID === rootId || existingSession.parentID === context.sessionID
1819
+ if (!validParent || existingSession.projectID !== input.project.id) {
1820
+ return { title, output: "Cannot resume Raven: sessionId is not part of this main session's Raven tree." }
1821
+ }
1822
+
1823
+ let messages: any[]
1824
+ try {
1825
+ const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
1826
+ const messageData = (messagesResp as any)?.data
1827
+ if (!Array.isArray(messageData)) {
1828
+ return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1829
+ }
1830
+ messages = messageData
1831
+ } catch (error) {
1832
+ if (context.abort.aborted) throw error
1833
+ return { title, output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
1834
+ }
1835
+
1836
+ const userMessages = messages.filter((message: any) => message?.info?.role === "user")
1837
+ const persistedRavenSession = (userMessages.length > 0 || (host === "v2"
1838
+ && existingSession.agent === agent && existingSession.metadata?.opencodeRavenAgent === agent))
1839
+ && userMessages.every((message: any) => message.info.agent === agent)
1840
+ if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
1841
+ return { title, output: `Cannot resume Raven: sessionId does not belong to the ${agent} agent.` }
1842
+ }
1843
+
1844
+ sessionId = requestedSessionId
1845
+ resumed = true
1846
+ previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
1847
+ } else {
1848
+ const sessionTitle = `${toolName}: ${args.query.slice(0, 80)}`
1849
+ const session = await client.session.create({
1850
+ body: {
1851
+ parentID: rootId,
1852
+ title: sessionTitle,
1853
+ ...(host === "v2" ? { agent } : {}),
1854
+ },
1855
+ signal: context.abort,
1856
+ })
1857
+ sessionId = (session as any)?.data?.id ?? (session as any)?.id
1858
+ if (!sessionId) return { title, output: "Failed to create Raven session." }
1859
+ await syncCreatedSession(sessionId, sessionTitle, context.abort)
1860
+ }
1861
+
1862
+ ravenSessions.add(sessionId)
1863
+ ravenSessionKinds.set(sessionId, kind)
1864
+ const activeSessionId = sessionId
1865
+ context.metadata({ metadata: { sessionId } })
1866
+
1867
+ try {
1868
+ writeFileSync(join(tmpdir(), "raven-sessions.log"), `${new Date().toISOString()} ${sessionId}\n`)
1869
+ } catch { /* non-fatal */ }
1870
+
1871
+ if (activeRavenPrompts.has(activeSessionId)) {
1872
+ return {
1873
+ title,
1874
+ metadata: { sessionId: activeSessionId },
1875
+ output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
1876
+ }
1877
+ }
1878
+ activeRavenPrompts.add(activeSessionId)
1879
+
1880
+ let timer: ReturnType<typeof setTimeout> | undefined
1881
+ let promptCompleted = false
1882
+ let sessionAbort: Promise<unknown> | undefined
1883
+ const promptController = new AbortController()
1884
+ const abortPrompt = () => {
1885
+ if (promptController.signal.aborted) return
1886
+ promptController.abort(context.abort.reason)
1887
+ const abortController = new AbortController()
1888
+ const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeoutMs, 5_000))
1889
+ sessionAbort = client.session.abort({
1890
+ path: { id: activeSessionId },
1891
+ signal: abortController.signal,
1892
+ throwOnError: true,
1893
+ }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
1894
+ }
1895
+ if (context.abort.aborted) abortPrompt()
1896
+ else context.abort.addEventListener("abort", abortPrompt, { once: true })
1897
+ const providerError = waitForRavenProviderError(sessionId)
1898
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeoutMs, 30_000))
1899
+
1900
+ try {
1901
+ const result = await Promise.race([
1902
+ client.session.prompt({
1903
+ path: { id: sessionId },
1904
+ body: { agent, parts: [{ type: "text", text: args.query }] },
1905
+ signal: promptController.signal,
1906
+ throwOnError: true,
1907
+ }),
1908
+ providerError,
1909
+ firstProgress,
1910
+ new Promise<never>((_, reject) => {
1911
+ timer = setTimeout(() => {
1912
+ abortPrompt()
1913
+ reject(new Error(`Raven timed out after ${timeout}s - child session retained: ${sessionId}`))
1914
+ }, timeoutMs)
1915
+ }),
1916
+ ])
1917
+ promptCompleted = true
1918
+ if (timer) clearTimeout(timer)
1919
+ clearRavenWaiters(sessionId)
1920
+ if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
1921
+
1922
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1923
+ const parts = (result as any)?.data?.parts ?? []
1924
+ const output = parts
1925
+ .filter((part: any) => part.type === "text" && part.text)
1926
+ .map((part: any) => part.text)
1927
+ .join("\n") || "Raven returned no results."
1928
+
1929
+ let savedCandidate = 0
1930
+ const statsController = new AbortController()
1931
+ const abortStats = () => statsController.abort(context.abort.reason)
1932
+ const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeoutMs, 5_000))
1933
+ if (context.abort.aborted) abortStats()
1934
+ else context.abort.addEventListener("abort", abortStats, { once: true })
1935
+ try {
1936
+ const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
1937
+ savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
1938
+ } catch { /* best-effort */ }
1939
+ finally {
1940
+ clearTimeout(statsTimer)
1941
+ context.abort.removeEventListener("abort", abortStats)
1942
+ }
1943
+ if (savedCandidate <= 0) {
1944
+ for (const part of parts) {
1945
+ if (part.args) savedCandidate += JSON.stringify(part.args).length
1946
+ if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1947
+ }
1948
+ }
1949
+
1950
+ const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`${toolName}\` call to continue the same retrieval.`
1951
+ const activity = kind === "mcp" ? "used MCPs" : "searched"
1952
+ const resultOutput = `${output}\n\n*Raven ${activity} for ${elapsed}s - ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
1953
+ addBytes(Math.max(0, savedCandidate - resultOutput.length))
1954
+ delete ravenProviderIssues[kind]
1955
+ return { title, metadata: { sessionId }, output: resultOutput }
1956
+ } finally {
1957
+ context.abort.removeEventListener("abort", abortPrompt)
1958
+ if (!promptCompleted) abortPrompt()
1959
+ if (sessionAbort) await sessionAbort
1960
+ if (timer) clearTimeout(timer)
1961
+ clearRavenWaiters(sessionId)
1962
+ activeRavenPrompts.delete(activeSessionId)
1963
+ }
1964
+ } catch (error: any) {
1965
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1966
+ const message = String(error?.message ?? error ?? "").toLowerCase()
1967
+ const missingSession = /session.*not found|not found.*session/i.test(message)
1968
+ const activity = kind === "mcp" ? "MCP retrieval" : "search"
1969
+ const hint =
1970
+ context.abort.aborted ? `Raven ${activity} cancelled.`
1971
+ : /rate.?limit|too many requests|429/i.test(message) ? "Raven rate limited - wait 30s, then retry with a narrower query."
1972
+ : /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."
1973
+ : /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."
1974
+ : /token|context.?length|too large|too long/i.test(message) ? "Raven query too large - shorten your query and retry."
1975
+ : missingSession ? `The Raven session is no longer available - retry without sessionId to start a new ${activity}.`
1976
+ : /model|unavailable|down|not found/i.test(message) ? "Raven model unavailable - retry later, or proceed without the missing information."
1977
+ : /did not produce an initial response|retry dialog|timeout|timed.?out|session retained/i.test(message) ? error.message
1978
+ : `Raven ${activity} failed. Note the resulting information gap. [${error.message || error}]`
1979
+ 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)) {
1980
+ ravenProviderIssues[kind] = hint
1981
+ }
1982
+ const recovery = ravenProviderIssues[kind] ? `\n\n${ravenRecoveryInstructions(kind)}` : ""
1983
+ const continuation = sessionId && !missingSession
1984
+ ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`${toolName}\` call to retry or continue.`
1985
+ : ""
1986
+ return { title, metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
1987
+ }
1988
+ }
1989
+
1979
1990
  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
1991
+ async refreshMcps(serverName?: string) {
1992
+ // V2 clients present the refresh lifecycle themselves, including failures.
1993
+ mcpFailureToastPending = false
1994
+ // First attachment shares the startup probe; later attachments force a
1995
+ // fresh health/tool-discovery check even when cached status is ready.
1996
+ const result = await (!serverName && !mcpStartupRefreshComplete
1997
+ ? scheduleOnDemandMcpRefresh()
1998
+ : queueOnDemandMcpRefresh(true, serverName))
1999
+ return result
1985
2000
  },
1986
- // V2 injects these texts at model-request time instead of using config.instructions.
2001
+ globalMcpRouting(names: string[]) {
2002
+ globalMcpServers = uniqueStrings(names)
2003
+ const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
2004
+ duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
2005
+ duplicateMcpToastPending = duplicateMcpServers.length > 0
2006
+ },
2007
+ // V2 injects these texts at model-request time instead of using config.instructions.
1987
2008
  guidance() {
1988
2009
  return `${readFileSync(MCP_GUIDANCE_MD, "utf-8")}\n\n${onDemandMcpGuidance("delegate")}`
1989
2010
  },
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()
2011
+ mcpGuidance() {
2012
+ return `On-demand MCP guidance:\n${onDemandMcpGuidance("raven")}`
2002
2013
  },
2003
-
2004
- async config(configInput: any) {
2014
+ async dispose() {
2015
+ for (const controller of mcpRefreshControllers) controller.abort()
2016
+ for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
2017
+ await Promise.allSettled([
2018
+ ...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
2019
+ mcpRefreshQueue,
2020
+ ])
2021
+ await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
2022
+ if (mcpIdleTimer) clearInterval(mcpIdleTimer)
2023
+ mcpIdleTimer = undefined
2024
+ for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
2025
+ ravenErrorWaiters.clear()
2026
+ },
2027
+
2028
+ async config(configInput: any) {
2005
2029
  globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
2006
2030
  .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
2007
2031
  .map(([name]) => name))
2008
2032
  const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
2009
2033
  duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
2010
2034
 
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 || []
2035
+ // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
2036
+ // The file is regenerated after MCP health checks, not during the startup pending reset.
2037
+ configInput.instructions = configInput.instructions || []
2014
2038
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
2015
2039
  configInput.instructions.push(MCP_GUIDANCE_MD)
2016
2040
  }
@@ -2018,215 +2042,215 @@ Then restart opencode for the model change to take effect. If the task can be co
2018
2042
  configInput.instructions.push(dynamicGuidanceFile)
2019
2043
  }
2020
2044
 
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
- }
2045
+ // Register Raven from Raven.md, with config file overrides
2046
+ configInput.agent = configInput.agent || {}
2047
+ const seekModel = activeSeekModel || fm.model
2048
+ const mcpModel = activeMcpModel || mcpFm.model
2049
+ configInput.agent.raven = {
2050
+ description: fm.description || "",
2051
+ mode: fm.mode || "subagent",
2052
+ hidden: fm.hidden !== undefined ? fm.hidden : false,
2053
+ disable: !seekModel,
2054
+ ...(seekModel ? { model: seekModel } : {}),
2055
+ options: {
2056
+ ...extractOptions(fm),
2057
+ ...(config.raven_seek.reasoning_effort ? { reasoning_effort: config.raven_seek.reasoning_effort } : {}),
2058
+ },
2059
+ permission: fm.permission || {},
2060
+ prompt: ravenAgentPrompt(),
2061
+ }
2062
+ configInput.agent["raven-mcp"] = {
2063
+ description: mcpFm.description || "",
2064
+ mode: mcpFm.mode || "subagent",
2065
+ hidden: mcpFm.hidden !== undefined ? mcpFm.hidden : true,
2066
+ disable: !mcpModel,
2067
+ ...(mcpModel ? { model: mcpModel } : {}),
2068
+ options: {
2069
+ ...extractOptions(mcpFm),
2070
+ ...(config.raven_mcp.reasoning_effort ? { reasoning_effort: config.raven_mcp.reasoning_effort } : {}),
2071
+ },
2072
+ permission: mcpFm.permission || {},
2073
+ prompt: ravenMcpAgentPrompt(),
2074
+ }
2051
2075
 
2052
2076
  // Register /raven command
2053
2077
  configInput.command = configInput.command || {}
2054
2078
  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
- }
2079
+ configInput.command.raven = {
2080
+ template: "Manage Raven search routing, on-demand MCPs, models, timeouts, updates, and status",
2081
+ description: "Manage Raven search routing, on-demand MCPs, and per-agent models",
2082
+ }
2059
2083
  }
2060
2084
 
2061
2085
  scheduleOnDemandMcpRefresh()
2062
2086
  updateToastPending = true
2063
- mcpFailureToastPending = true
2064
- duplicateMcpToastPending = duplicateMcpServers.length > 0
2065
- modelSetupToastPending = !!modelSetupInstructions()
2087
+ mcpFailureToastPending = true
2088
+ duplicateMcpToastPending = duplicateMcpServers.length > 0
2089
+ modelSetupToastPending = !!modelSetupInstructions()
2066
2090
  },
2067
2091
 
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 } })
2092
+ // Register raven_seek tool — lets agents with task:false still delegate through Raven
2093
+ tool: {
2094
+ "raven_seek": tool({
2095
+ 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.",
2096
+ args: {
2097
+ 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."),
2098
+ 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."),
2099
+ },
2100
+ async execute(args, context) {
2101
+ const started = Date.now()
2102
+ await refreshModelsIfPending()
2103
+ if (!modelOperational("seek")) {
2104
+ return { title: "Raven Seek", output: modelSetupInstructions("seek") }
2105
+ }
2106
+ const timeout = (config.raven_seek.timeout ?? 600) * 1000
2107
+ let sessionId: string | undefined
2108
+ let resumed = false
2109
+ let previousSavedCandidate = 0
2110
+ try {
2111
+ const requestedSessionId = args.sessionId?.trim()
2112
+ if (args.sessionId !== undefined && !requestedSessionId) {
2113
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId must not be empty." }
2114
+ }
2115
+
2116
+ let rootId = context.sessionID
2117
+ try {
2118
+ rootId = await rootSessionId(context.sessionID, context.abort)
2119
+ } catch (error) {
2120
+ if (context.abort.aborted) throw error
2121
+ }
2122
+
2123
+ if (requestedSessionId) {
2124
+ const knownRavenSession = ravenSessionKinds.get(requestedSessionId) === "seek" || sessionAgents.get(requestedSessionId) === "raven"
2125
+ let existingSession: any
2126
+ try {
2127
+ const sessionResp = await client.session.get({ path: { id: requestedSessionId }, signal: context.abort })
2128
+ existingSession = (sessionResp as any)?.data ?? sessionResp
2129
+ } catch (err) {
2130
+ if (context.abort.aborted) throw err
2131
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
2132
+ }
2133
+
2134
+ if (!existingSession?.id || existingSession.id !== requestedSessionId) {
2135
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: the session was not found or could not be read.` }
2136
+ }
2137
+ if ((existingSession.parentID !== rootId && existingSession.parentID !== context.sessionID) || existingSession.projectID !== input.project.id) {
2138
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
2139
+ }
2140
+
2141
+ let messages: any[]
2142
+ try {
2143
+ const messagesResp = await client.session.messages({ path: { id: requestedSessionId }, signal: context.abort })
2144
+ const messageData = (messagesResp as any)?.data
2145
+ if (!Array.isArray(messageData)) {
2146
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
2147
+ }
2148
+ messages = messageData
2149
+ } catch (err) {
2150
+ if (context.abort.aborted) throw err
2151
+ return { title: "Raven Seek", output: `Cannot resume Raven session ${requestedSessionId}: its message history could not be read.` }
2152
+ }
2153
+
2154
+ const userMessages = messages.filter((message: any) => message?.info?.role === "user")
2155
+ const persistedRavenSession = (userMessages.length > 0 || (host === "v2"
2156
+ && existingSession.agent === "raven" && existingSession.metadata?.opencodeRavenAgent === "raven"))
2157
+ && userMessages.every((message: any) => message.info.agent === "raven")
2158
+ if (!persistedRavenSession && !(knownRavenSession && userMessages.length === 0)) {
2159
+ return { title: "Raven Seek", output: "Cannot resume Raven: sessionId is not a Raven child of this main session." }
2160
+ }
2161
+
2162
+ sessionId = requestedSessionId
2163
+ resumed = true
2164
+ previousSavedCandidate = estimateRavenSavedCandidateBytes(messages)
2165
+ } else {
2166
+ const sessionTitle = `raven_seek: ${args.query.slice(0, 80)}`
2167
+ const session = await client.session.create({
2168
+ body: {
2169
+ parentID: rootId,
2170
+ title: sessionTitle,
2171
+ ...(host === "v2" ? { agent: "raven" } : {}),
2172
+ },
2173
+ signal: context.abort,
2174
+ })
2175
+
2176
+ sessionId = (session as any)?.data?.id ?? (session as any)?.id
2177
+ if (!sessionId) {
2178
+ return { title: "Raven Seek", output: "Failed to create Raven session." }
2179
+ }
2180
+ await syncCreatedSession(sessionId, sessionTitle, context.abort)
2181
+ }
2182
+
2183
+ ravenSessions.add(sessionId)
2184
+ ravenSessionKinds.set(sessionId, "seek")
2185
+ const activeSessionId = sessionId
2186
+
2187
+ // Preserve the child session link for TUI clients that render it.
2188
+ context.metadata({ metadata: { sessionId } })
2165
2189
 
2166
2190
  // Log session for debugging
2167
2191
  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)
2192
+ const logFile = join(tmpdir(), "raven-sessions.log")
2193
+ const ts = new Date().toISOString()
2194
+ writeFileSync(logFile, `${ts} ${sessionId}\n`)
2195
+ } catch { /* non-fatal */ }
2196
+
2197
+ if (activeRavenPrompts.has(activeSessionId)) {
2198
+ return {
2199
+ title: "Raven Seek",
2200
+ metadata: { sessionId: activeSessionId },
2201
+ output: `Raven session \`${activeSessionId}\` is already handling a request. Wait for it to finish before sending another follow-up.`,
2202
+ }
2203
+ }
2204
+ activeRavenPrompts.add(activeSessionId)
2181
2205
 
2182
2206
  // Send the query to Raven with timeout. Provider/API errors are also
2183
2207
  // 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({
2208
+ // so the caller does not wait for the full timeout on quota/auth issues.
2209
+ let timer: ReturnType<typeof setTimeout> | undefined
2210
+ let promptCompleted = false
2211
+ let sessionAbort: Promise<unknown> | undefined
2212
+ const promptController = new AbortController()
2213
+ const abortPrompt = () => {
2214
+ if (promptController.signal.aborted) return
2215
+ promptController.abort(context.abort.reason)
2216
+ const abortController = new AbortController()
2217
+ const abortTimer = setTimeout(() => abortController.abort(), Math.min(timeout, 5_000))
2218
+ sessionAbort = client.session.abort({
2219
+ path: { id: activeSessionId },
2220
+ signal: abortController.signal,
2221
+ throwOnError: true,
2222
+ }).catch(() => undefined).finally(() => clearTimeout(abortTimer))
2223
+ }
2224
+ if (context.abort.aborted) abortPrompt()
2225
+ else context.abort.addEventListener("abort", abortPrompt, { once: true })
2226
+ const providerError = waitForRavenProviderError(sessionId)
2227
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
2228
+ try {
2229
+ const result = await Promise.race([
2230
+ client.session.prompt({
2207
2231
  path: { id: sessionId },
2208
2232
  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)
2233
+ agent: "raven",
2234
+ parts: [{ type: "text", text: args.query }],
2235
+ },
2236
+ signal: promptController.signal,
2237
+ throwOnError: true,
2238
+ }),
2239
+ providerError,
2240
+ firstProgress,
2241
+ new Promise<never>((_, reject) => {
2242
+ timer = setTimeout(() => {
2243
+ abortPrompt()
2244
+ reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
2245
+ }, timeout)
2246
+ }),
2247
+ ])
2248
+ promptCompleted = true
2249
+ if (timer) clearTimeout(timer)
2250
+ clearRavenWaiters(sessionId)
2251
+ if ((result as any)?.error) throw new Error(providerErrorMessage((result as any).error))
2252
+
2253
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2230
2254
 
2231
2255
  // Extract text from the response
2232
2256
  const parts = (result as any)?.data?.parts ?? []
@@ -2235,207 +2259,207 @@ Then restart opencode for the model change to take effect. If the task can be co
2235
2259
  .map((p: any) => p.text)
2236
2260
  const output = textParts.join("\n") || "Raven returned no results."
2237
2261
 
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
- }
2262
+ // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
2263
+ let savedCandidate = 0
2264
+ const statsController = new AbortController()
2265
+ const abortStats = () => statsController.abort(context.abort.reason)
2266
+ const statsTimer = setTimeout(() => statsController.abort(), Math.min(timeout, 5_000))
2267
+ if (context.abort.aborted) abortStats()
2268
+ else context.abort.addEventListener("abort", abortStats, { once: true })
2269
+ try {
2270
+ const totalSavedCandidate = await countRavenSavedCandidateBytes(sessionId, statsController.signal)
2271
+ savedCandidate = Math.max(0, totalSavedCandidate - previousSavedCandidate)
2272
+ } catch { /* best-effort */ }
2273
+ finally {
2274
+ clearTimeout(statsTimer)
2275
+ context.abort.removeEventListener("abort", abortStats)
2276
+ }
2253
2277
  if (savedCandidate <= 0) {
2254
2278
  for (const part of parts) {
2255
2279
  if (part.args) savedCandidate += JSON.stringify(part.args).length
2256
2280
  if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
2257
2281
  }
2258
2282
  }
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}` }
2283
+ const continuation = `Raven session: \`${sessionId}\`${resumed ? " (continued)" : ""}. Pass this as \`sessionId\` in a later \`raven_seek\` call to continue the same research.`
2284
+ const resultOutput = `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*\n\n${continuation}`
2285
+ addBytes(Math.max(0, savedCandidate - resultOutput.length))
2286
+ delete ravenProviderIssues.seek
2287
+
2288
+ return { title: "Raven Seek", metadata: { sessionId }, output: resultOutput }
2289
+ } finally {
2290
+ context.abort.removeEventListener("abort", abortPrompt)
2291
+ if (!promptCompleted) abortPrompt()
2292
+ if (sessionAbort) await sessionAbort
2293
+ if (timer) clearTimeout(timer)
2294
+ clearRavenWaiters(sessionId)
2295
+ activeRavenPrompts.delete(activeSessionId)
2296
+ }
2297
+ } catch (err: any) {
2298
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
2299
+ const msg = String(err?.message ?? err ?? "").toLowerCase()
2300
+ const missingSession = /session.*not found|not found.*session/i.test(msg)
2301
+ const hint =
2302
+ context.abort.aborted ? "Raven search cancelled."
2303
+ : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
2304
+ : /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."
2305
+ : /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."
2306
+ : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
2307
+ : missingSession ? "The Raven session is no longer available — retry without sessionId to start a new Raven search."
2308
+ : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
2309
+ : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
2310
+ : /timeout|timed.?out|session kept/i.test(msg) ? err.message
2311
+ : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
2312
+ 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)) {
2313
+ ravenProviderIssues.seek = hint
2314
+ }
2315
+ const recovery = ravenProviderIssues.seek ? `\n\n${ravenRecoveryInstructions("seek")}` : ""
2316
+ const continuation = sessionId && !missingSession
2317
+ ? `\n\nRaven session: \`${sessionId}\`. Pass this as \`sessionId\` in a later \`raven_seek\` call to retry or continue.`
2318
+ : ""
2319
+ return { title: "Raven Seek", metadata: sessionId ? { sessionId } : undefined, output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*${continuation}` }
2296
2320
  }
2297
2321
  },
2298
2322
  }),
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.",
2323
+ "raven_mcp": tool({
2324
+ 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.",
2325
+ args: {
2326
+ 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."),
2327
+ 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."),
2328
+ },
2329
+ async execute(args, context) {
2330
+ return executeRavenDelegation("mcp", args, context)
2331
+ },
2332
+ }),
2333
+ "raven_mcp_bridge": tool({
2334
+ 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
2335
  args: {
2312
2336
  server: tool.schema.string().describe("On-demand MCP server name from Raven config, e.g. context7, exa, grep_app, unityMCP."),
2313
2337
  operation: tool.schema.enum(["list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"]).describe("MCP operation to perform."),
2314
2338
  name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
2315
2339
  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
2340
  },
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
2341
+ async execute(args, context) {
2342
+ if (ravenSessionKinds.get(context.sessionID) !== "mcp" && sessionAgents.get(context.sessionID) !== "raven-mcp") {
2343
+ 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." }
2344
+ }
2345
+
2346
+ const operation = args.operation as RavenMcpOperation
2347
+ const server = getOnDemandMcpServer(args.server)?.[1]
2348
+ const timeout = server ? mcpTimeoutMs(server) : (config.raven_mcp.timeout ?? 600) * 1000
2349
+ const requestOptions = { signal: context.abort, timeout }
2350
+ let connected = false
2351
+ let connection: McpConnection | undefined
2352
+ try {
2353
+ connection = await getMcpConnection(context.sessionID, args.server, context.abort)
2354
+ connected = true
2355
+ connection.active++
2356
+ connection.lastUsed = Date.now()
2357
+ const client = connection.client
2358
+
2359
+ if (operation === "list_tools") {
2360
+ const tools = await listAllMcpTools(client, requestOptions)
2361
+ const match = getOnDemandMcpServer(args.server)
2362
+ if (match) {
2363
+ const [name] = match
2340
2364
  const generatedTools = generatedToolsFromMcpTools(tools)
2341
2365
  mcpMetadata[name] = {
2342
2366
  autoGeneratedTools: generatedTools,
2343
2367
  autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
2344
2368
  avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
2345
2369
  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
- }
2370
+ lastCheckedAt: new Date().toISOString(),
2371
+ }
2372
+ saveMcpMetadata(mcpMetadata)
2373
+ saveDynamicGuidance()
2374
+ }
2375
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
2376
+ }
2377
+
2378
+ if (operation === "call_tool") {
2379
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
2380
+ const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
2381
+ setOnDemandMcpHealth(args.server, "ready")
2382
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2383
+ }
2384
+
2385
+ if (operation === "list_resources") {
2386
+ const resources = await listAllMcpResources(client, requestOptions)
2387
+ setOnDemandMcpHealth(args.server, "ready")
2388
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
2389
+ }
2390
+
2391
+ if (operation === "read_resource") {
2392
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
2393
+ const result = await client.readResource({ uri: args.name }, requestOptions)
2394
+ setOnDemandMcpHealth(args.server, "ready")
2395
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
2396
+ }
2397
+
2398
+ if (operation === "list_prompts") {
2399
+ const prompts = await listAllMcpPrompts(client, requestOptions)
2400
+ setOnDemandMcpHealth(args.server, "ready")
2401
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
2402
+ }
2403
+
2404
+ if (operation === "get_prompt") {
2405
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
2406
+ const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
2407
+ setOnDemandMcpHealth(args.server, "ready")
2408
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
2409
+ }
2410
+
2411
+ return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
2412
+ } catch (err: any) {
2413
+ if (!connected && !context.abort.aborted) {
2414
+ const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
2415
+ if (name) await notifyIfMcpFailures([name])
2416
+ }
2417
+ return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
2418
+ } finally {
2419
+ if (connection) {
2420
+ connection.active = Math.max(0, connection.active - 1)
2421
+ connection.lastUsed = Date.now()
2422
+ }
2423
+ }
2400
2424
  },
2401
2425
  }),
2402
2426
  },
2403
2427
 
2404
2428
  // 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
- }
2429
+ async "chat.message"(input: any, _output: any) {
2430
+ if (input.agent) {
2431
+ sessionAgents.set(input.sessionID, input.agent)
2432
+ if (input.agent === "raven") {
2433
+ ravenSessions.add(input.sessionID)
2434
+ ravenSessionKinds.set(input.sessionID, "seek")
2435
+ } else if (input.agent === "raven-mcp") {
2436
+ ravenSessions.add(input.sessionID)
2437
+ ravenSessionKinds.set(input.sessionID, "mcp")
2438
+ }
2415
2439
  }
2416
2440
  },
2417
2441
 
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
- }
2442
+ async event(input: { event: any }) {
2443
+ // Track subagent session → parent mapping for accurate context counting
2444
+ const evt = input.event
2445
+ if (evt?.type === "session.created") {
2446
+ const info = evt.properties?.info
2447
+ if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
2448
+ }
2449
+ if (evt?.type === "session.deleted") {
2450
+ const sessionId = evt.properties?.info?.id
2451
+ if (sessionId) {
2452
+ ravenSessions.delete(sessionId)
2453
+ ravenSessionKinds.delete(sessionId)
2454
+ activeRavenPrompts.delete(sessionId)
2455
+ sessionAgents.delete(sessionId)
2456
+ clearRavenWaiters(sessionId)
2457
+ for (const [parentId, childId] of ravenSessionParents) {
2458
+ if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
2459
+ }
2460
+ await closeMcpConnectionsForSession(sessionId)
2461
+ }
2462
+ }
2439
2463
  if (evt?.type === "message.updated") {
2440
2464
  const info = evt.properties?.info
2441
2465
  if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
@@ -2461,16 +2485,16 @@ Then restart opencode for the model change to take effect. If the task can be co
2461
2485
  }
2462
2486
  }
2463
2487
  }
2464
- if (evt?.type === "session.error") {
2488
+ if (evt?.type === "session.error") {
2465
2489
  const sessionId = evt.properties?.sessionID
2466
2490
  if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
2467
2491
  reportRavenProviderError(sessionId, evt.properties.error)
2468
- }
2469
- }
2470
-
2471
- await refreshModelsIfPending()
2472
-
2473
- if (updateToastPending) {
2492
+ }
2493
+ }
2494
+
2495
+ await refreshModelsIfPending()
2496
+
2497
+ if (updateToastPending) {
2474
2498
  updateToastPending = false
2475
2499
  setTimeout(() => void notifyIfUpdateAvailable(), 500)
2476
2500
  }
@@ -2478,27 +2502,27 @@ Then restart opencode for the model change to take effect. If the task can be co
2478
2502
  mcpFailureToastPending = false
2479
2503
  void scheduleOnDemandMcpRefresh().then((result) => notifyIfMcpFailures(result.failed))
2480
2504
  }
2481
- if (duplicateMcpToastPending) {
2482
- duplicateMcpToastPending = false
2483
- setTimeout(() => void notifyIfDuplicateMcps(), 700)
2484
- }
2485
- if (modelSetupToastPending) {
2486
- modelSetupToastPending = false
2487
- setTimeout(() => void notifyIfModelSetupRequired(), 300)
2488
- }
2505
+ if (duplicateMcpToastPending) {
2506
+ duplicateMcpToastPending = false
2507
+ setTimeout(() => void notifyIfDuplicateMcps(), 700)
2508
+ }
2509
+ if (modelSetupToastPending) {
2510
+ modelSetupToastPending = false
2511
+ setTimeout(() => void notifyIfModelSetupRequired(), 300)
2512
+ }
2489
2513
  },
2490
2514
 
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") {
2515
+ // /raven help|route|seek ...|mcp ...|update|stats|status
2516
+ async "command.execute.before"(input: any, output: any) {
2517
+ if (input.command !== "raven") return
2518
+ output.parts.length = 0
2519
+ const raw = input.arguments.trim()
2520
+ const arg = raw.toLowerCase()
2521
+ if (!arg) await refreshModelsIfPending()
2522
+
2523
+ if (arg === "help") {
2524
+ output.parts.push({ type: "text", text: helpText() })
2525
+ } else if (arg === "stats") {
2502
2526
  output.parts.push({ type: "text", text: statsText() })
2503
2527
  } else if (arg === "route") {
2504
2528
  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.` })
@@ -2509,95 +2533,95 @@ Then restart opencode for the model change to take effect. If the task can be co
2509
2533
  const name = parts.slice(3).join(" ").trim()
2510
2534
  const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
2511
2535
 
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") {
2536
+ if (!key || !["add", "remove", "rm"].includes(action) || !name) {
2537
+ 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." })
2538
+ } else {
2539
+ updateConfig((latest) => {
2540
+ const values = uniqueStrings(latest.raven_seek[key])
2541
+ const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
2542
+ const next = action === "add"
2543
+ ? exists ? values : [...values, name]
2544
+ : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
2545
+ return { raven_seek: key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next } }
2546
+ })
2547
+ output.parts.push({ type: "text", text: routeSummary() })
2548
+ }
2549
+ } else if (arg.startsWith("seek ")) {
2550
+ const parts = raw.split(/\s+/)
2551
+ const action = parts[1]?.toLowerCase()
2552
+ const value = parts.slice(2).join(" ").trim()
2553
+ if (action === "model" && value) {
2554
+ if (value.toLowerCase() === "auto") {
2555
+ updateConfig({ raven_seek: { model: undefined, autoSelectedModel: undefined } })
2556
+ await ensureRavenModels()
2557
+ } else {
2558
+ updateConfig({ raven_seek: { model: value, autoSelectedModel: undefined } })
2559
+ }
2560
+ delete ravenProviderIssues.seek
2561
+ output.parts.push({ type: "text", text: config.raven_seek.model
2562
+ ? `Raven Search model set to: ${modelLabel(config.raven_seek)}\nRestart opencode for the change to take effect.`
2563
+ : modelSetupInstructions("seek") })
2564
+ } else if (action === "effort" && value) {
2565
+ updateConfig({ raven_seek: { reasoning_effort: value } })
2566
+ output.parts.push({ type: "text", text: `Raven Search reasoning effort set to: ${value}\nRestart opencode for the change to take effect.` })
2567
+ } else if (action === "timeout") {
2568
+ const seconds = Number(value)
2569
+ if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2570
+ 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` })
2571
+ } else {
2572
+ updateConfig({ raven_seek: { timeout: seconds } })
2573
+ output.parts.push({ type: "text", text: `Raven Search timeout set to ${seconds}s. Takes effect immediately.` })
2574
+ }
2575
+ } else {
2576
+ output.parts.push({ type: "text", text: "Usage:\n /raven seek model <provider/model>\n /raven seek effort <value>\n /raven seek timeout <seconds>" })
2577
+ }
2578
+ } else if (arg === "mcp") {
2555
2579
  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
2580
  } else if (arg.startsWith("mcp ")) {
2557
2581
  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)
2582
+ const action = parts[1]?.toLowerCase()
2583
+ if (action === "refresh") {
2584
+ const serverName = parts.slice(2).join(" ").trim() || undefined
2585
+ const result = await queueOnDemandMcpRefresh(true, serverName)
2562
2586
  await notifyIfMcpFailures(result.failed)
2563
2587
  output.parts.push({
2564
2588
  type: "text",
2565
2589
  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
2590
  })
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>" })
2591
+ } else if (action === "detail") {
2592
+ const detail = parts[2]?.toLowerCase()
2593
+ if (detail !== "full" && detail !== "minimized") {
2594
+ output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.raven_mcp.descriptionDetail ?? "full"}` })
2595
+ } else {
2596
+ updateConfig({ raven_mcp: { descriptionDetail: detail } })
2597
+ output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
2598
+ }
2599
+ } else if (action === "model" && parts.slice(2).join(" ").trim()) {
2600
+ const model = parts.slice(2).join(" ").trim()
2601
+ if (model.toLowerCase() === "auto") {
2602
+ updateConfig({ raven_mcp: { model: undefined, autoSelectedModel: undefined } })
2603
+ await ensureRavenModels()
2604
+ } else {
2605
+ updateConfig({ raven_mcp: { model, autoSelectedModel: undefined } })
2606
+ }
2607
+ delete ravenProviderIssues.mcp
2608
+ output.parts.push({ type: "text", text: config.raven_mcp.model
2609
+ ? `Raven MCP model set to: ${modelLabel(config.raven_mcp)}\nRestart opencode for the change to take effect.`
2610
+ : modelSetupInstructions("mcp") })
2611
+ } else if (action === "effort" && parts.slice(2).join(" ").trim()) {
2612
+ const effort = parts.slice(2).join(" ").trim()
2613
+ updateConfig({ raven_mcp: { reasoning_effort: effort } })
2614
+ output.parts.push({ type: "text", text: `Raven MCP reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
2615
+ } else if (action === "timeout") {
2616
+ const seconds = Number(parts[2])
2617
+ if (!Number.isInteger(seconds) || seconds < 10 || seconds > MAX_TIMEOUT_SECONDS) {
2618
+ 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` })
2619
+ } else {
2620
+ updateConfig({ raven_mcp: { timeout: seconds } })
2621
+ output.parts.push({ type: "text", text: `Raven MCP timeout set to ${seconds}s. Takes effect immediately.` })
2622
+ }
2623
+ } else {
2624
+ 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
2625
  }
2602
2626
  } else if (arg === "update") {
2603
2627
  try {
@@ -2608,50 +2632,50 @@ Then restart opencode for the model change to take effect. If the task can be co
2608
2632
  output.parts.push({ type: "text", text: `Raven is up to date (${info.current}). Latest on npm: ${info.latest}.` })
2609
2633
  } else {
2610
2634
  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)}` })
2635
+ 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
2636
  }
2613
2637
  } catch (err: any) {
2614
2638
  output.parts.push({ type: "text", text: `Raven update check failed: ${err?.message ?? err}\n\n${manualUpdateText()}` })
2615
2639
  }
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}` : ""
2640
+ } else {
2641
+ const seek = config.raven_seek
2642
+ const mcp = config.raven_mcp
2643
+ const seekIssue = ravenProviderIssues.seek ? `\n Provider issue: ${ravenProviderIssues.seek}` : ""
2644
+ const mcpIssue = ravenProviderIssues.mcp ? `\n Provider issue: ${ravenProviderIssues.mcp}` : ""
2621
2645
  let update = "Update: unable to check npm."
2622
2646
  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."
2647
+ const info = await getUpdateInfo()
2648
+ update = info.available && info.latest
2649
+ ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
2650
+ : info.latest
2651
+ ? `Update: up to date (latest ${info.latest}).`
2652
+ : "Update: unable to check npm."
2629
2653
  } 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.` })
2654
+ const setup = modelSetupInstructions()
2655
+ 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
2656
  }
2633
2657
  },
2634
2658
 
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") {
2659
+ async "tool.execute.before"(input: any, output: any) {
2660
+ if ((input.tool === "raven_seek" || input.tool === "raven_mcp") && ravenSessions.has(input.sessionID)) {
2661
+ throw new Error(`${input.tool} cannot be called from inside a Raven agent. Use that agent's direct tools instead.`)
2662
+ }
2663
+
2664
+ if (input.tool === "raven_mcp_bridge" && ravenSessionKinds.get(input.sessionID) !== "mcp" && sessionAgents.get(input.sessionID) !== "raven-mcp") {
2665
+ throw new Error("raven_mcp_bridge is only available inside the Raven MCP agent. Use raven_mcp for on-demand MCP requests.")
2666
+ }
2667
+
2668
+ if (ravenSessions.has(input.sessionID)) return
2669
+ if (isExcluded(sessionAgents.get(input.sessionID))) return
2670
+ if (config.raven_seek.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
2671
+
2672
+ if ((input.tool === "task" || input.tool === "subagent") && (output.args?.subagent_type ?? output.args?.agent) === "raven") {
2649
2673
  ravenTaskCalls.add(input.callID)
2650
2674
  }
2651
2675
 
2652
2676
  // ── 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)
2677
+ const shouldRouteTool = input.tool === "bash" || input.tool === "shell" ? false : isRouteConfigured(input.tool)
2678
+ const isSearchBashCmd = isRouteConfigured(input.tool) && isSearchBash(input.tool, output.args || input.args)
2655
2679
 
2656
2680
  if (shouldRouteTool || isSearchBashCmd) {
2657
2681
  const args = compactArgs(output.args || input.args)
@@ -2660,7 +2684,7 @@ Then restart opencode for the model change to take effect. If the task can be co
2660
2684
  }
2661
2685
  },
2662
2686
 
2663
- async "tool.execute.after"(input: any, output: any) {
2687
+ async "tool.execute.after"(input: any, output: any) {
2664
2688
  if (ravenTaskCalls.has(input.callID)) {
2665
2689
  ravenTaskCalls.delete(input.callID)
2666
2690
  // Try task metadata first (built-in tools preserve metadata)
@@ -2679,4 +2703,4 @@ Then restart opencode for the model change to take effect. If the task can be co
2679
2703
  }
2680
2704
  },
2681
2705
  }
2682
- })
2706
+ })