opencode-raven 2.1.4 → 2.1.6

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 (4) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +212 -313
  3. package/index.ts +684 -413
  4. package/package.json +22 -4
package/index.ts CHANGED
@@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4
4
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
5
5
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
6
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, rmSync } from "node:fs"
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"
8
8
  import { join } from "node:path"
9
9
  import { homedir, tmpdir } from "node:os"
10
10
 
@@ -62,7 +62,9 @@ const DEFAULT_ON_DEMAND_MCP_METADATA: Record<string, OnDemandMcpMetadata> = {
62
62
  },
63
63
  }
64
64
 
65
- const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
65
+ const NEVER_ROUTE_TOOLS = new Set(["raven_seek", "raven_mcp", "task", "subtask"])
66
+ const MAX_TIMER_MS = 2_147_483_647
67
+ const MAX_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_MS / 1000)
66
68
 
67
69
  // ── Bash commands that look like search workarounds ──
68
70
  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
@@ -79,11 +81,49 @@ function stripShellComments(cmd: string): string {
79
81
  .join("\n")
80
82
  }
81
83
 
82
- function stripQuotedContent(cmd: string): string {
83
- return stripShellComments(stripHeredocs(cmd)
84
- .replace(/'[^']*'/g, "''")
85
- .replace(/"[^"]*"/g, '""'))
86
- }
84
+ function stripQuotedContent(cmd: string): string {
85
+ return stripShellComments(stripHeredocs(cmd)
86
+ .replace(/'[^']*'/g, "''")
87
+ .replace(/"[^"]*"/g, '""'))
88
+ }
89
+
90
+ function unquoteShellPayload(value: string): string {
91
+ const payload = value.trim()
92
+ const quote = payload[0]
93
+ return (quote === '"' || quote === "'") && payload.endsWith(quote)
94
+ ? payload.slice(1, -1)
95
+ : payload
96
+ }
97
+
98
+ function wrappedShellPayload(cmd: string): string | undefined {
99
+ const command = stripShellComments(stripHeredocs(cmd)).trim()
100
+ const match = command.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s+([\s\S]+))?$/)
101
+ if (!match) return undefined
102
+
103
+ const executable = (match[1] ?? match[2] ?? match[3]).replace(/\\/g, "/").split("/").pop()?.toLowerCase()
104
+ const rest = match[4] ?? ""
105
+ if (executable === "cmd" || executable === "cmd.exe") {
106
+ const payload = rest.match(/^(?:\/[dqs]\s+)*\/c\s+([\s\S]+)$/i)?.[1]
107
+ return payload === undefined ? undefined : unquoteShellPayload(payload)
108
+ }
109
+
110
+ if (["sh", "sh.exe", "bash", "bash.exe", "zsh", "zsh.exe", "dash", "dash.exe"].includes(executable ?? "")) {
111
+ const payload = rest.match(/^-[a-z]*c\s+([\s\S]+)$/i)?.[1]
112
+ return payload === undefined ? undefined : unquoteShellPayload(payload)
113
+ }
114
+
115
+ if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes(executable ?? "")) {
116
+ const payload = rest.match(/(?:^|\s)-(?:command|c)\s+([\s\S]+)$/i)?.[1]
117
+ if (payload === undefined) return undefined
118
+ const unquoted = unquoteShellPayload(payload)
119
+ return unquoted.replace(
120
+ /(^|(?:(?:&&|\|\||[;|\n{(])\s*))&\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g,
121
+ (_match, prefix, doubleQuoted, singleQuoted, bare) => `${prefix}${doubleQuoted ?? singleQuoted ?? bare}`,
122
+ )
123
+ }
124
+
125
+ return undefined
126
+ }
87
127
 
88
128
  function splitPipelineSegments(cmd: string): string[] {
89
129
  const segments: string[] = []
@@ -112,12 +152,16 @@ function hasSearchAfterCommandSeparator(segment: string): boolean {
112
152
  return separator !== -1 && SEARCH_BASH_RE.test(segment.slice(separator))
113
153
  }
114
154
 
115
- function commandLooksLikeSearch(cmd: string): boolean {
116
- const lower = cmd.toLowerCase().trim()
117
- if (/^cmd\s+\/c\s+"?(?:findstr|find|tree)\b/.test(lower)) return true
118
- if (/^cmd\s+\/c\s+"?dir\b[^\n]*\s\/s\b/.test(lower)) return true
119
-
120
- return splitPipelineSegments(cmd).some((segment, index) => {
155
+ function commandLooksLikeSearch(cmd: string): boolean {
156
+ const wrapped = wrappedShellPayload(cmd)
157
+ if (wrapped !== undefined) return commandLooksLikeSearch(wrapped)
158
+
159
+ const stripped = stripQuotedContent(cmd)
160
+ const lower = stripped.toLowerCase().trim()
161
+ if (/^cmd\s+\/c\s+"?(?:findstr|find|tree)\b/.test(lower)) return true
162
+ if (/^cmd\s+\/c\s+"?dir\b[^\n]*\s\/s\b/.test(lower)) return true
163
+
164
+ return splitPipelineSegments(stripped).some((segment, index) => {
121
165
  // Allow bounded filters over output already produced by the previous command:
122
166
  // pacman -Qi libarchive | head -15
123
167
  // 7z i | grep -i udf
@@ -127,12 +171,10 @@ function commandLooksLikeSearch(cmd: string): boolean {
127
171
  })
128
172
  }
129
173
 
130
- function isSearchBash(tool: string, args: any): boolean {
131
- if (tool !== "bash") return false
132
- const raw = String(args?.command ?? "")
133
- const cmd = stripQuotedContent(raw)
134
- return commandLooksLikeSearch(cmd)
135
- }
174
+ function isSearchBash(tool: string, args: any): boolean {
175
+ if (tool !== "bash") return false
176
+ return commandLooksLikeSearch(String(args?.command ?? ""))
177
+ }
136
178
 
137
179
  // ── Config file shape ──
138
180
  interface OnDemandMcpToolInfo {
@@ -155,12 +197,11 @@ interface OnDemandMcpMetadata {
155
197
  lastCheckedAt?: string
156
198
  }
157
199
 
158
- interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
159
- type: "remote"
160
- url: string
161
- headers?: Record<string, string>
162
- oauth?: Record<string, unknown> | false
163
- }
200
+ interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
201
+ type: "remote"
202
+ url: string
203
+ headers?: Record<string, string>
204
+ }
164
205
 
165
206
  interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
166
207
  type: "local"
@@ -316,17 +357,19 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
316
357
 
317
358
  function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
318
359
  if (!value || typeof value !== "object") return undefined
319
- const source = value as Record<string, any>
320
- const base = {
321
- enabled: source.enabled !== false,
322
- ...(typeof source.timeout === "number" && source.timeout > 0 ? { timeout: Math.floor(source.timeout) } : {}),
323
- ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
324
- }
360
+ const source = value as Record<string, any>
361
+ const base = {
362
+ enabled: source.enabled !== false,
363
+ ...(typeof source.timeout === "number" && Number.isFinite(source.timeout) && source.timeout >= 1 && source.timeout <= MAX_TIMER_MS
364
+ ? { timeout: Math.floor(source.timeout) }
365
+ : {}),
366
+ ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
367
+ }
325
368
 
326
369
  if (source.type === "local") {
327
- const commandParts = Array.isArray(source.command)
328
- ? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && arg.trim())
329
- : []
370
+ const commandParts = Array.isArray(source.command)
371
+ ? source.command.filter((arg: unknown): arg is string => typeof arg === "string" && !!arg.trim())
372
+ : []
330
373
  if (!commandParts.length) return undefined
331
374
  const environment = normalizeStringRecord(source.environment)
332
375
  return {
@@ -343,11 +386,10 @@ function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefin
343
386
  const headers = normalizeStringRecord(source.headers)
344
387
  return {
345
388
  ...base,
346
- type: "remote",
347
- url: source.url.trim(),
348
- ...(headers ? { headers } : {}),
349
- ...(source.oauth === false ? { oauth: false } : source.oauth && typeof source.oauth === "object" ? { oauth: source.oauth } : {}),
350
- }
389
+ type: "remote",
390
+ url: source.url.trim(),
391
+ ...(headers ? { headers } : {}),
392
+ }
351
393
  }
352
394
 
353
395
  return undefined
@@ -376,11 +418,7 @@ function normalizeOnDemandMcpMetadata(value: unknown, fallback: Record<string, O
376
418
  return result
377
419
  }
378
420
 
379
- function extractOnDemandMcpMetadata(value: unknown): Record<string, OnDemandMcpMetadata> {
380
- return normalizeOnDemandMcpMetadata(value)
381
- }
382
-
383
- function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
421
+ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, OnDemandMcpServer> = {}): Record<string, OnDemandMcpServer> {
384
422
  const source = value && typeof value === "object" ? value as Record<string, unknown> : fallback
385
423
  const result: Record<string, OnDemandMcpServer> = {}
386
424
  for (const [key, raw] of Object.entries(source)) {
@@ -392,18 +430,15 @@ function normalizeOnDemandMcpServers(value: unknown, fallback: Record<string, On
392
430
  }
393
431
 
394
432
  // ── Plugin ──
395
- export default ((input: PluginInput) => {
433
+ export default (async (input: PluginInput) => {
396
434
  const client = input.client
397
435
 
398
- // Raven state lives under the global opencode config directory.
399
- const opencodeConfigDir = join(homedir(), ".config", "opencode")
400
- const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
401
- const oldConfigFile = join(opencodeConfigDir, "raven-config.json")
402
- const configFile = join(ravenConfigDir, "raven-config.json")
403
- const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
404
- const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
405
- let migratedFromOldConfig = false
406
- let initialMcpMetadata: Record<string, OnDemandMcpMetadata> = {}
436
+ // Raven state lives under the global opencode config directory.
437
+ const opencodeConfigDir = join(homedir(), ".config", "opencode")
438
+ const ravenConfigDir = join(opencodeConfigDir, "opencode-raven")
439
+ const configFile = join(ravenConfigDir, "raven-config.json")
440
+ const mcpMetadataFile = join(ravenConfigDir, "autogenerated-on-demand-mcp-metadata.json")
441
+ const dynamicGuidanceFile = join(ravenConfigDir, "autogenerated-on-demand-guidance.md")
407
442
 
408
443
  function normalizeConfig(raw: any): RavenConfig {
409
444
  const source = raw && typeof raw === "object" ? raw : {}
@@ -416,33 +451,33 @@ export default ((input: PluginInput) => {
416
451
  normalized.routeTools = uniqueStrings(source.routeTools, DEFAULT_ROUTE_TOOLS)
417
452
  normalized.routeToolKeywords = uniqueStrings(source.routeToolKeywords, DEFAULT_ROUTE_TOOL_KEYWORDS)
418
453
  normalized.onDemandMcpDescriptionDetail = source.onDemandMcpDescriptionDetail === "minimized" ? "minimized" : "full"
419
- normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
420
- ? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
421
- : normalizeOnDemandMcpServers(source.onDemandMcpServers)
422
- normalized.excludeAgents = uniqueStrings(source.excludeAgents)
423
- normalized.excludeTools = uniqueStrings(source.excludeTools)
424
- normalized.timeout = typeof source.timeout === "number" ? source.timeout : DEFAULT_CONFIG.timeout
425
- normalized.stats = source.stats || undefined
454
+ normalized.onDemandMcpServers = source.onDemandMcpServers === undefined
455
+ ? cloneOnDemandMcpServers(DEFAULT_ON_DEMAND_MCP_SERVERS)
456
+ : normalizeOnDemandMcpServers(source.onDemandMcpServers)
457
+ normalized.excludeAgents = uniqueStrings(source.excludeAgents)
458
+ normalized.excludeTools = uniqueStrings(source.excludeTools)
459
+ normalized.timeout = typeof source.timeout === "number" && Number.isFinite(source.timeout)
460
+ && source.timeout >= 10 && source.timeout <= MAX_TIMEOUT_SECONDS
461
+ ? Math.floor(source.timeout)
462
+ : DEFAULT_CONFIG.timeout
463
+ normalized.stats = source.stats && typeof source.stats === "object"
464
+ && typeof source.stats.bytes === "number" && Number.isFinite(source.stats.bytes) && source.stats.bytes >= 0
465
+ ? { bytes: Math.round(source.stats.bytes) }
466
+ : undefined
426
467
 
427
468
  return normalized
428
469
  }
429
470
 
430
- function loadConfig(): RavenConfig {
431
- try {
432
- const sourceFile = existsSync(configFile) ? configFile : existsSync(oldConfigFile) ? oldConfigFile : undefined
433
- if (sourceFile) {
434
- const raw = JSON.parse(readFileSync(sourceFile, "utf-8"))
435
- migratedFromOldConfig = sourceFile === oldConfigFile
436
- initialMcpMetadata = extractOnDemandMcpMetadata(raw.onDemandMcpServers)
437
- const normalized = normalizeConfig(raw)
438
- if (migratedFromOldConfig || JSON.stringify(raw) !== JSON.stringify(normalized)) {
439
- saveConfig(normalized)
440
- }
441
- if (migratedFromOldConfig) {
442
- try { rmSync(oldConfigFile, { force: true }) } catch { /* best-effort */ }
443
- }
444
- return normalized
445
- }
471
+ function loadConfig(): RavenConfig {
472
+ try {
473
+ if (existsSync(configFile)) {
474
+ const raw = JSON.parse(readFileSync(configFile, "utf-8"))
475
+ const normalized = normalizeConfig(raw)
476
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
477
+ saveConfig(normalized)
478
+ }
479
+ return normalized
480
+ }
446
481
  } catch { /* ignore corruption, use defaults */ }
447
482
  // Auto-create config file with defaults on first run
448
483
  saveConfig(DEFAULT_CONFIG)
@@ -456,16 +491,15 @@ export default ((input: PluginInput) => {
456
491
  } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
457
492
  }
458
493
 
459
- function loadMcpMetadata(seed: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
460
- const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
494
+ function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
495
+ const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
461
496
  try {
462
497
  if (existsSync(mcpMetadataFile)) {
463
498
  return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
464
499
  }
465
- } catch { /* ignore corruption, use defaults + seed */ }
466
- const metadata = { ...defaults, ...seed }
467
- saveMcpMetadata(metadata)
468
- return metadata
500
+ } catch { /* ignore corruption, use defaults */ }
501
+ saveMcpMetadata(defaults)
502
+ return defaults
469
503
  }
470
504
 
471
505
  function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
@@ -475,21 +509,15 @@ export default ((input: PluginInput) => {
475
509
  } catch { /* non-fatal: generated metadata can be refreshed */ }
476
510
  }
477
511
 
478
- function saveDynamicGuidance() {
479
- try {
480
- mkdirSync(ravenConfigDir, { recursive: true })
481
- writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate")}\n`, "utf-8")
482
- } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
483
- }
484
-
485
- function cleanupOldRootFiles() {
486
- try {
487
- if (existsSync(configFile)) rmSync(oldConfigFile, { force: true })
488
- } catch { /* best-effort migration cleanup */ }
489
- }
512
+ function saveDynamicGuidance() {
513
+ try {
514
+ mkdirSync(ravenConfigDir, { recursive: true })
515
+ writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
516
+ } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
517
+ }
490
518
 
491
- let config = loadConfig()
492
- let mcpMetadata = loadMcpMetadata(initialMcpMetadata)
519
+ let config = loadConfig()
520
+ let mcpMetadata = loadMcpMetadata()
493
521
  const ravenSessions = new Set<string>()
494
522
  const ravenTaskCalls = new Set<string>()
495
523
  const sessionAgents = new Map<string, string>()
@@ -502,9 +530,11 @@ export default ((input: PluginInput) => {
502
530
  let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
503
531
  let updateToastPending = false
504
532
  let mcpFailureToastPending = false
505
- let duplicateMcpToastPending = false
506
- let mcpRefreshScheduled = false
507
- let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
533
+ let duplicateMcpToastPending = false
534
+ let mcpRefreshScheduled = false
535
+ let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
536
+ let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
537
+ const mcpRefreshControllers = new Set<AbortController>()
508
538
  let lastNotifiedMcpFailureKey = ""
509
539
  let ravenProviderIssue: string | undefined
510
540
 
@@ -615,32 +645,71 @@ Then restart opencode for the model change to take effect. If the task can be co
615
645
  return extra ? `${prompt}\n\nAdditional user instructions:\n${extra}` : prompt
616
646
  }
617
647
 
618
- function truncateText(value: unknown, max = 240): string {
619
- const text = String(value ?? "").replace(/\s+/g, " ").trim()
620
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
621
- }
622
-
623
- function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate"): string {
624
- const servers = config.onDemandMcpServers ?? {}
625
- const activeEntries = activeOnDemandMcpEntries()
626
- const entries = activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready")
627
- if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
628
- if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
629
- if (!entries.length) return "No loaded on-demand MCPs are available."
630
-
631
- const detail = config.onDemandMcpDescriptionDetail ?? "full"
632
- const lines = entries.map(([name, server]) => {
633
- const metadata = mcpMetadata[name] ?? {}
634
- const userDescription = truncateText(server.description, 220)
635
- const generated = truncateText(metadata.autoGeneratedDescription, 260)
636
- const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
637
- const summary = parts.length ? parts.join(" ") : "Description discovery pending."
638
- if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
639
- const tools = metadata.autoGeneratedTools
640
- .map((item) => `${item.name}${item.description ? ` - ${truncateText(item.description, 140)}` : ""}`)
641
- .join("; ")
642
- return `- ${name}: ${summary} Tools: ${tools}`
643
- })
648
+ function compactText(value: unknown): string {
649
+ return String(value ?? "").replace(/\s+/g, " ").trim()
650
+ }
651
+
652
+ function truncateText(value: unknown, max = 240): string {
653
+ const text = compactText(value)
654
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text
655
+ }
656
+
657
+ function dynamicGuidanceMcpOrder(): string[] | undefined {
658
+ try {
659
+ if (!existsSync(dynamicGuidanceFile)) return undefined
660
+ const names: string[] = []
661
+ for (const line of readFileSync(dynamicGuidanceFile, "utf-8").split("\n")) {
662
+ const name = line.match(/^-\s+([^:]+):/)?.[1]?.trim()
663
+ if (name && !names.includes(name)) names.push(name)
664
+ }
665
+ return names
666
+ } catch {
667
+ return undefined
668
+ }
669
+ }
670
+
671
+ function orderedOnDemandMcpEntries(entries: [string, OnDemandMcpServer][], preferredOrder?: string[]): [string, OnDemandMcpServer][] {
672
+ if (!preferredOrder?.length) return entries
673
+ const order = new Map(preferredOrder.map((name, index) => [name.toLowerCase(), index]))
674
+ const existing = entries
675
+ .filter(([name]) => order.has(name.toLowerCase()))
676
+ .sort(([left], [right]) => order.get(left.toLowerCase())! - order.get(right.toLowerCase())!)
677
+ const added = entries.filter(([name]) => !order.has(name.toLowerCase()))
678
+ return [...existing, ...added]
679
+ }
680
+
681
+ function firstSentenceText(value: unknown): string {
682
+ const text = compactText(value)
683
+ const period = text.indexOf(".")
684
+ return period === -1 ? text : text.slice(0, period + 1)
685
+ }
686
+
687
+ function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate", preferredOrder?: string[]): string {
688
+ const servers = config.onDemandMcpServers ?? {}
689
+ const activeEntries = activeOnDemandMcpEntries()
690
+ const entries = orderedOnDemandMcpEntries(
691
+ target === "raven"
692
+ ? activeEntries
693
+ : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
694
+ preferredOrder,
695
+ )
696
+ if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
697
+ if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
698
+ if (!entries.length) return "No loaded on-demand MCPs are available."
699
+
700
+ const detail = config.onDemandMcpDescriptionDetail ?? "full"
701
+ const lines = entries.map(([name, server]) => {
702
+ const metadata = mcpMetadata[name] ?? {}
703
+ const userDescription = firstSentenceText(server.description)
704
+ const generated = firstSentenceText(metadata.autoGeneratedDescription)
705
+ const parts = [userDescription, generated ? `Discovered capabilities: ${generated}` : ""].filter(Boolean)
706
+ const summary = parts.length ? parts.join(" ") : "Description discovery pending."
707
+ if (detail === "minimized" || !metadata.autoGeneratedTools?.length) return `- ${name}: ${summary}`
708
+ const tools = metadata.autoGeneratedTools
709
+ .map((item) => `${item.name}${item.description ? ` - ${firstSentenceText(item.description)}` : ""}`)
710
+ .join("; ")
711
+ return `- ${name}: ${summary} Tools: ${tools}`
712
+ })
644
713
  if (target === "raven") {
645
714
  return `Raven can handle these on-demand MCP capabilities through raven_mcp:\n${lines.join("\n")}`
646
715
  }
@@ -672,20 +741,6 @@ Then restart opencode for the model change to take effect. If the task can be co
672
741
  return result
673
742
  }
674
743
 
675
- async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
676
- let timer: ReturnType<typeof setTimeout> | undefined
677
- try {
678
- return await Promise.race([
679
- promise,
680
- new Promise<never>((_, reject) => {
681
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
682
- }),
683
- ])
684
- } finally {
685
- if (timer) clearTimeout(timer)
686
- }
687
- }
688
-
689
744
  function providerErrorMessage(error: any): string {
690
745
  const name = String(error?.name ?? "")
691
746
  const data = error?.data && typeof error.data === "object" ? error.data : {}
@@ -729,41 +784,114 @@ Then restart opencode for the model change to take effect. If the task can be co
729
784
  ravenProgressWaiters.get(sessionId)?.()
730
785
  }
731
786
 
732
- type McpConnection = {
733
- client: Client
734
- transport: any
735
- serverName: string
736
- lastUsed: number
737
- }
738
-
739
- const mcpConnections = new Map<string, McpConnection>()
740
-
741
- async function closeMcpConnection(connection: McpConnection) {
742
- try {
743
- if (typeof connection.transport?.terminateSession === "function") {
744
- await connection.transport.terminateSession()
745
- }
746
- } catch { /* best-effort */ }
747
- try {
748
- await connection.client.close()
749
- } catch { /* best-effort */ }
750
- }
751
-
752
- async function closeMcpConnectionKey(key: string) {
753
- const existing = mcpConnections.get(key)
754
- if (!existing) return
755
- mcpConnections.delete(key)
756
- await closeMcpConnection(existing)
757
- }
787
+ type McpConnection = {
788
+ client: Client
789
+ transport: any
790
+ lastUsed: number
791
+ active: number
792
+ }
793
+
794
+ type McpConnectionAttempt = {
795
+ promise: Promise<McpConnection>
796
+ controller: AbortController
797
+ waiters: number
798
+ }
799
+
800
+ const mcpConnections = new Map<string, McpConnection>()
801
+ const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
802
+ let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
803
+
804
+ function mcpTimeoutMs(server: OnDemandMcpServer): number {
805
+ return server.timeout ?? (config.timeout ?? 600) * 1000
806
+ }
807
+
808
+ async function closeMcpClient(client: Client, transport: any) {
809
+ try {
810
+ await transport?.close?.()
811
+ } catch { /* best-effort */ }
812
+ try {
813
+ await client.close()
814
+ } catch { /* best-effort */ }
815
+ }
816
+
817
+ async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
818
+ const controller = new AbortController()
819
+ let timedOut = false
820
+ const abort = () => controller.abort(signal?.reason)
821
+ if (signal?.aborted) abort()
822
+ else signal?.addEventListener("abort", abort, { once: true })
823
+ const timer = setTimeout(() => {
824
+ timedOut = true
825
+ controller.abort()
826
+ }, timeout)
827
+ const aborted = new Promise<never>((_resolve, reject) => {
828
+ const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
829
+ if (controller.signal.aborted) fail()
830
+ else controller.signal.addEventListener("abort", fail, { once: true })
831
+ })
832
+
833
+ try {
834
+ await Promise.race([
835
+ client.connect(transport, { signal: controller.signal, timeout }),
836
+ aborted,
837
+ ])
838
+ } catch (error) {
839
+ await closeMcpClient(client, transport)
840
+ throw error
841
+ } finally {
842
+ clearTimeout(timer)
843
+ signal?.removeEventListener("abort", abort)
844
+ }
845
+ }
846
+
847
+ async function closeMcpConnection(connection: McpConnection) {
848
+ await closeMcpClient(connection.client, connection.transport)
849
+ }
850
+
851
+ function stopMcpIdleTimerIfUnused() {
852
+ if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
853
+ clearInterval(mcpIdleTimer)
854
+ mcpIdleTimer = undefined
855
+ }
856
+
857
+ function ensureMcpIdleTimer() {
858
+ if (mcpIdleTimer) return
859
+ mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
860
+ ;(mcpIdleTimer as any).unref?.()
861
+ }
862
+
863
+ async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
864
+ const existing = mcpConnections.get(key)
865
+ if (!existing || (expected && existing !== expected)) return
866
+ mcpConnections.delete(key)
867
+ await closeMcpConnection(existing)
868
+ stopMcpIdleTimerIfUnused()
869
+ }
758
870
 
759
871
  function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
760
872
  const now = Date.now()
761
- for (const [key, connection] of mcpConnections) {
762
- if (now - connection.lastUsed > maxAgeMs) {
763
- void closeMcpConnectionKey(key)
764
- }
765
- }
766
- }
873
+ for (const [key, connection] of mcpConnections) {
874
+ if (!connection.active && now - connection.lastUsed > maxAgeMs) {
875
+ void closeMcpConnectionKey(key, connection)
876
+ }
877
+ }
878
+ }
879
+
880
+ async function closeMcpConnectionsForSession(sessionId: string) {
881
+ const prefix = `${sessionId}:`
882
+ const pending: Promise<McpConnection>[] = []
883
+ for (const [key, attempt] of mcpConnectionAttempts) {
884
+ if (!key.startsWith(prefix)) continue
885
+ attempt.controller.abort()
886
+ pending.push(attempt.promise)
887
+ }
888
+ await Promise.allSettled(pending)
889
+ await Promise.all(
890
+ [...mcpConnections.keys()]
891
+ .filter((key) => key.startsWith(prefix))
892
+ .map((key) => closeMcpConnectionKey(key)),
893
+ )
894
+ }
767
895
 
768
896
  function mcpConnectionKey(sessionId: string, serverName: string): string {
769
897
  return `${sessionId}:${serverName.toLowerCase()}`
@@ -774,15 +902,44 @@ Then restart opencode for the model change to take effect. If the task can be co
774
902
  return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
775
903
  }
776
904
 
777
- function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
778
- return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
779
- }
780
-
781
- async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
782
- const timeout = server.timeout ?? (config.timeout ?? 180) * 1000
783
- const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
784
-
785
- if (server.type === "local") {
905
+ function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
906
+ return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
907
+ }
908
+
909
+ function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
910
+ if (!signal) {
911
+ attempt.waiters++
912
+ return attempt.promise.finally(() => attempt.waiters--)
913
+ }
914
+ if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
915
+ attempt.waiters++
916
+ return new Promise((resolve, reject) => {
917
+ let settled = false
918
+ const finish = () => {
919
+ if (settled) return false
920
+ settled = true
921
+ attempt.waiters--
922
+ signal.removeEventListener("abort", abort)
923
+ return true
924
+ }
925
+ const abort = () => {
926
+ if (!finish()) return
927
+ if (!attempt.waiters) attempt.controller.abort(signal.reason)
928
+ reject(new Error("MCP request cancelled"))
929
+ }
930
+ signal.addEventListener("abort", abort, { once: true })
931
+ attempt.promise.then(
932
+ (connection) => { if (finish()) resolve(connection) },
933
+ (error) => { if (finish()) reject(error) },
934
+ )
935
+ })
936
+ }
937
+
938
+ async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
939
+ const timeout = mcpTimeoutMs(server)
940
+ const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
941
+
942
+ if (server.type === "local") {
786
943
  const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
787
944
  const transport = new StdioClientTransport({
788
945
  command: resolveEnvPlaceholders(server.command[0]),
@@ -790,84 +947,114 @@ Then restart opencode for the model change to take effect. If the task can be co
790
947
  env,
791
948
  stderr: "pipe",
792
949
  cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
793
- })
794
- transport.stderr?.on("data", () => {})
795
- transport.stderr?.on("error", () => {})
796
- await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
797
- return { client, transport, serverName, lastUsed: Date.now() }
798
- }
799
-
800
- const url = new URL(resolveEnvPlaceholders(server.url))
801
- const headers = resolveStringRecord(server.headers)
802
- try {
803
- const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
804
- await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
805
- return { client, transport, serverName, lastUsed: Date.now() }
806
- } catch (err) {
807
- try { await client.close() } catch { /* best-effort */ }
808
- const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
809
- const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
810
- await withTimeout(fallbackClient.connect(fallback), timeout, `Connecting to ${serverName} via SSE`)
811
- return { client: fallbackClient, transport: fallback, serverName, lastUsed: Date.now() }
812
- }
813
- }
814
-
815
- async function getMcpConnection(sessionId: string, serverName: string): Promise<McpConnection> {
816
- closeIdleMcpConnections()
817
- const match = getOnDemandMcpServer(serverName)
818
- if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
950
+ })
951
+ transport.stderr?.on("data", () => {})
952
+ transport.stderr?.on("error", () => {})
953
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
954
+ return { client, transport, lastUsed: Date.now(), active: 0 }
955
+ }
956
+
957
+ const url = new URL(resolveEnvPlaceholders(server.url))
958
+ const headers = resolveStringRecord(server.headers)
959
+ const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
960
+ try {
961
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
962
+ return { client, transport, lastUsed: Date.now(), active: 0 }
963
+ } catch (err) {
964
+ if (signal?.aborted) throw err
965
+ const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
966
+ const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
967
+ try {
968
+ await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
969
+ return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
970
+ } catch (fallbackError) {
971
+ throw fallbackError
972
+ }
973
+ }
974
+ }
975
+
976
+ async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
977
+ if (signal?.aborted) throw new Error("MCP request cancelled")
978
+ closeIdleMcpConnections()
979
+ const match = getOnDemandMcpServer(serverName)
980
+ if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
819
981
  const [canonicalName, server] = match
820
982
  const key = mcpConnectionKey(sessionId, canonicalName)
821
983
  const existing = mcpConnections.get(key)
822
984
  if (existing) {
823
- existing.lastUsed = Date.now()
824
- return existing
825
- }
826
- const connection = await connectOnDemandMcp(canonicalName, server)
827
- mcpConnections.set(key, connection)
828
- return connection
829
- }
830
-
831
- async function listAllMcpTools(client: Client): Promise<any[]> {
832
- const tools: any[] = []
833
- let cursor: string | undefined
834
- do {
835
- const result: any = await client.listTools(cursor ? { cursor } : undefined as any)
836
- tools.push(...(result.tools ?? []))
837
- cursor = result.nextCursor
838
- } while (cursor)
839
- return tools
840
- }
841
-
842
- async function listAllMcpResources(client: Client): Promise<any[]> {
843
- const resources: any[] = []
844
- let cursor: string | undefined
845
- do {
846
- const result: any = await client.listResources(cursor ? { cursor } : undefined as any)
847
- resources.push(...(result.resources ?? []))
848
- cursor = result.nextCursor
849
- } while (cursor)
850
- return resources
851
- }
852
-
853
- async function listAllMcpPrompts(client: Client): Promise<any[]> {
854
- const prompts: any[] = []
855
- let cursor: string | undefined
856
- do {
857
- const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any)
858
- prompts.push(...(result.prompts ?? []))
859
- cursor = result.nextCursor
985
+ existing.lastUsed = Date.now()
986
+ return existing
987
+ }
988
+
989
+ const pending = mcpConnectionAttempts.get(key)
990
+ if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
991
+ if (pending) mcpConnectionAttempts.delete(key)
992
+
993
+ const controller = new AbortController()
994
+ const attempt = { controller, waiters: 0 } as McpConnectionAttempt
995
+ attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
996
+ .then(async (connection) => {
997
+ if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
998
+ await closeMcpConnection(connection)
999
+ throw new Error("MCP connection attempt cancelled")
1000
+ }
1001
+ mcpConnections.set(key, connection)
1002
+ connection.client.onclose = () => {
1003
+ if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
1004
+ stopMcpIdleTimerIfUnused()
1005
+ }
1006
+ ensureMcpIdleTimer()
1007
+ return connection
1008
+ })
1009
+ .finally(() => {
1010
+ if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
1011
+ stopMcpIdleTimerIfUnused()
1012
+ })
1013
+ mcpConnectionAttempts.set(key, attempt)
1014
+ ensureMcpIdleTimer()
1015
+ return waitForMcpConnection(attempt, signal)
1016
+ }
1017
+
1018
+ async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1019
+ const tools: any[] = []
1020
+ let cursor: string | undefined
1021
+ do {
1022
+ const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
1023
+ tools.push(...(result.tools ?? []))
1024
+ cursor = result.nextCursor
1025
+ } while (cursor)
1026
+ return tools
1027
+ }
1028
+
1029
+ async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1030
+ const resources: any[] = []
1031
+ let cursor: string | undefined
1032
+ do {
1033
+ const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
1034
+ resources.push(...(result.resources ?? []))
1035
+ cursor = result.nextCursor
1036
+ } while (cursor)
1037
+ return resources
1038
+ }
1039
+
1040
+ async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1041
+ const prompts: any[] = []
1042
+ let cursor: string | undefined
1043
+ do {
1044
+ const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
1045
+ prompts.push(...(result.prompts ?? []))
1046
+ cursor = result.nextCursor
860
1047
  } while (cursor)
861
1048
  return prompts
862
1049
  }
863
1050
 
864
1051
  function generatedToolsFromMcpTools(tools: any[]): OnDemandMcpToolInfo[] {
865
1052
  return tools
866
- .filter((item) => item?.name)
867
- .map((item) => ({
868
- name: String(item.name),
869
- ...(item.description ? { description: truncateText(item.description, 500) } : {}),
870
- }))
1053
+ .filter((item) => item?.name)
1054
+ .map((item) => ({
1055
+ name: String(item.name),
1056
+ ...(item.description ? { description: compactText(item.description) } : {}),
1057
+ }))
871
1058
  }
872
1059
 
873
1060
  function estimateAvoidedMcpSchemaBytes(tools: any[]): number {
@@ -876,14 +1063,15 @@ Then restart opencode for the model change to take effect. If the task can be co
876
1063
  }
877
1064
 
878
1065
  function synthesizeMcpDescription(serverName: string, tools: OnDemandMcpToolInfo[]): string {
879
- if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
880
- const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
881
- const joined = descriptions.slice(0, 6).join("; ")
882
- return truncateText(`${serverName} provides on-demand MCP tools for ${joined}.`, 300)
883
- }
884
-
885
- async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
886
- const entries = activeOnDemandMcpEntries()
1066
+ if (!tools.length) return `${serverName} exposes on-demand MCP capabilities. Use list_tools for available operations.`
1067
+ const descriptions = tools.map((item) => item.description || item.name).filter(Boolean)
1068
+ const joined = descriptions.slice(0, 6).join("; ")
1069
+ return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
1070
+ }
1071
+
1072
+ async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
1073
+ if (signal?.aborted) return { refreshed: [], failed: [] }
1074
+ const entries = activeOnDemandMcpEntries()
887
1075
  .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
888
1076
  .filter(([name]) => {
889
1077
  const metadata = mcpMetadata[name]
@@ -892,13 +1080,14 @@ Then restart opencode for the model change to take effect. If the task can be co
892
1080
  const refreshed: string[] = []
893
1081
  const failed: string[] = []
894
1082
  if (!entries.length) return { refreshed, failed }
895
- let changed = false
896
- for (const [name, server] of entries) {
897
- let connection: McpConnection | undefined
898
- const checkedAt = new Date().toISOString()
899
- try {
900
- connection = await connectOnDemandMcp(name, server)
901
- const rawTools = await listAllMcpTools(connection.client)
1083
+ let changed = false
1084
+ for (const [name, server] of entries) {
1085
+ if (signal?.aborted) break
1086
+ let connection: McpConnection | undefined
1087
+ const checkedAt = new Date().toISOString()
1088
+ try {
1089
+ connection = await connectOnDemandMcp(name, server, signal)
1090
+ const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
902
1091
  const tools = generatedToolsFromMcpTools(rawTools)
903
1092
  mcpMetadata[name] = {
904
1093
  autoGeneratedTools: tools,
@@ -907,10 +1096,11 @@ Then restart opencode for the model change to take effect. If the task can be co
907
1096
  status: "ready",
908
1097
  lastCheckedAt: checkedAt,
909
1098
  }
910
- changed = true
911
- refreshed.push(name)
912
- } catch (err: any) {
913
- mcpMetadata[name] = {
1099
+ changed = true
1100
+ refreshed.push(name)
1101
+ } catch (err: any) {
1102
+ if (signal?.aborted) break
1103
+ mcpMetadata[name] = {
914
1104
  ...(mcpMetadata[name] ?? {}),
915
1105
  status: "failed",
916
1106
  lastError: truncateText(err?.message ?? err, 500),
@@ -930,15 +1120,38 @@ Then restart opencode for the model change to take effect. If the task can be co
930
1120
  return { refreshed, failed }
931
1121
  }
932
1122
 
933
- function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
934
- if (mcpRefreshScheduled && mcpRefreshPromise) return mcpRefreshPromise
935
- mcpRefreshScheduled = true
936
- mcpRefreshPromise = new Promise((resolve) => {
937
- setTimeout(() => {
938
- void refreshOnDemandMcpDescriptions().then(resolve)
939
- }, 1000)
940
- })
941
- return mcpRefreshPromise
1123
+ function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
1124
+ if (signal.aborted) return Promise.resolve()
1125
+ return new Promise((resolve) => {
1126
+ const timer = setTimeout(done, ms)
1127
+ function done() {
1128
+ clearTimeout(timer)
1129
+ signal.removeEventListener("abort", done)
1130
+ resolve()
1131
+ }
1132
+ signal.addEventListener("abort", done, { once: true })
1133
+ })
1134
+ }
1135
+
1136
+ function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
1137
+ const controller = new AbortController()
1138
+ mcpRefreshControllers.add(controller)
1139
+ const run = mcpRefreshQueue
1140
+ .catch(() => undefined)
1141
+ .then(async () => {
1142
+ if (delay) await waitForRefreshDelay(delay, controller.signal)
1143
+ return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
1144
+ })
1145
+ .finally(() => mcpRefreshControllers.delete(controller))
1146
+ mcpRefreshQueue = run
1147
+ return run
1148
+ }
1149
+
1150
+ function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
1151
+ if (mcpRefreshScheduled) return mcpRefreshPromise!
1152
+ mcpRefreshScheduled = true
1153
+ mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
1154
+ return mcpRefreshPromise
942
1155
  }
943
1156
 
944
1157
  function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
@@ -1052,15 +1265,15 @@ Then restart opencode for the model change to take effect. If the task can be co
1052
1265
  return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1053
1266
  }
1054
1267
 
1055
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1268
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
1056
1269
  if (!raw?.trim()) return {}
1057
1270
  const parsed = JSON.parse(raw)
1058
1271
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1059
1272
  throw new Error("argumentsJson must be a JSON object")
1060
1273
  }
1061
- return parsed
1062
- }
1063
-
1274
+ return parsed
1275
+ }
1276
+
1064
1277
  // ── Context saved by Raven delegation ──
1065
1278
  let sessionBytes = 0
1066
1279
  let totalBytes = config.stats?.bytes ?? 0
@@ -1202,10 +1415,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1202
1415
  } catch { /* update checks are best-effort */ }
1203
1416
  }
1204
1417
 
1205
- async function notifyIfMcpFailures(names?: string[]) {
1206
- try {
1207
- const failed = failedOnDemandMcpNames(names)
1208
- if (!failed.length) return
1418
+ async function notifyIfMcpFailures(names?: string[]) {
1419
+ try {
1420
+ const failed = failedOnDemandMcpNames(names)
1421
+ if (!failed.length) {
1422
+ lastNotifiedMcpFailureKey = ""
1423
+ return
1424
+ }
1209
1425
  const key = failed.sort().join("|")
1210
1426
  if (key === lastNotifiedMcpFailureKey) return
1211
1427
  lastNotifiedMcpFailureKey = key
@@ -1236,18 +1452,31 @@ Then restart opencode for the model change to take effect. If the task can be co
1236
1452
  } catch { /* duplicate MCP notifications are best-effort */ }
1237
1453
  }
1238
1454
 
1239
- return {
1240
- config(configInput: any) {
1455
+ return {
1456
+ async dispose() {
1457
+ for (const controller of mcpRefreshControllers) controller.abort()
1458
+ for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
1459
+ await Promise.allSettled([
1460
+ ...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
1461
+ mcpRefreshQueue,
1462
+ ])
1463
+ await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
1464
+ if (mcpIdleTimer) clearInterval(mcpIdleTimer)
1465
+ mcpIdleTimer = undefined
1466
+ for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
1467
+ ravenErrorWaiters.clear()
1468
+ },
1469
+
1470
+ async config(configInput: any) {
1241
1471
  globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1242
1472
  .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1243
1473
  .map(([name]) => name))
1244
1474
  const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1245
1475
  duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1246
1476
 
1247
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1248
- // The file is regenerated after MCP health checks, not during the startup pending reset.
1249
- cleanupOldRootFiles()
1250
- configInput.instructions = configInput.instructions || []
1477
+ // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1478
+ // The file is regenerated after MCP health checks, not during the startup pending reset.
1479
+ configInput.instructions = configInput.instructions || []
1251
1480
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
1252
1481
  configInput.instructions.push(MCP_GUIDANCE_MD)
1253
1482
  }
@@ -1297,12 +1526,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1297
1526
  const timeout = (config.timeout ?? 600) * 1000
1298
1527
  try {
1299
1528
  // Create a Raven session
1300
- const session = await client.session.create({
1301
- body: {
1302
- parentID: context.sessionID,
1303
- title: `raven_seek: ${args.query.slice(0, 80)}`,
1304
- },
1305
- })
1529
+ const session = await client.session.create({
1530
+ body: {
1531
+ parentID: context.sessionID,
1532
+ title: `raven_seek: ${args.query.slice(0, 80)}`,
1533
+ },
1534
+ signal: context.abort,
1535
+ })
1306
1536
 
1307
1537
  const sessionId = (session as any)?.data?.id ?? (session as any)?.id
1308
1538
  if (!sessionId) {
@@ -1311,40 +1541,49 @@ Then restart opencode for the model change to take effect. If the task can be co
1311
1541
 
1312
1542
  ravenSessions.add(sessionId)
1313
1543
 
1314
- // Emit sessionId so the TUI renders a clickable delegation box
1315
- context.metadata({ metadata: { sessionId } })
1544
+ // Preserve the child session link for TUI clients that render it.
1545
+ context.metadata({ metadata: { sessionId } })
1316
1546
 
1317
1547
  // Log session for debugging
1318
1548
  try {
1319
- const logFile = join(tmpdir(), "raven-sessions.log")
1320
- const ts = new Date().toISOString()
1321
- const q = String(args.query).slice(0, 100)
1322
- appendFileSync(logFile, `${ts} ${sessionId} "${q}"\n`)
1549
+ const logFile = join(tmpdir(), "raven-sessions.log")
1550
+ const ts = new Date().toISOString()
1551
+ writeFileSync(logFile, `${ts} ${sessionId}\n`)
1323
1552
  } catch { /* non-fatal */ }
1324
1553
 
1325
1554
  // Send the query to Raven with timeout. Provider/API errors are also
1326
1555
  // surfaced through message.updated events; fail as soon as one lands
1327
- // so the caller does not wait for the full timeout on quota/auth issues.
1328
- let timer: ReturnType<typeof setTimeout> | undefined
1329
- const providerError = waitForRavenProviderError(sessionId)
1330
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1331
- try {
1332
- const result = await Promise.race([
1333
- client.session.prompt({
1556
+ // so the caller does not wait for the full timeout on quota/auth issues.
1557
+ let timer: ReturnType<typeof setTimeout> | undefined
1558
+ let promptCompleted = false
1559
+ const promptController = new AbortController()
1560
+ const abortPrompt = () => promptController.abort(context.abort.reason)
1561
+ if (context.abort.aborted) abortPrompt()
1562
+ else context.abort.addEventListener("abort", abortPrompt, { once: true })
1563
+ const providerError = waitForRavenProviderError(sessionId)
1564
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1565
+ try {
1566
+ const result = await Promise.race([
1567
+ client.session.prompt({
1334
1568
  path: { id: sessionId },
1335
1569
  body: {
1336
- agent: "raven",
1337
- parts: [{ type: "text", text: args.query }],
1338
- },
1339
- }),
1340
- providerError,
1341
- firstProgress,
1342
- new Promise<never>((_, reject) => {
1343
- timer = setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1344
- }),
1345
- ])
1346
- if (timer) clearTimeout(timer)
1347
- clearRavenWaiters(sessionId)
1570
+ agent: "raven",
1571
+ parts: [{ type: "text", text: args.query }],
1572
+ },
1573
+ signal: promptController.signal,
1574
+ }),
1575
+ providerError,
1576
+ firstProgress,
1577
+ new Promise<never>((_, reject) => {
1578
+ timer = setTimeout(() => {
1579
+ promptController.abort()
1580
+ reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
1581
+ }, timeout)
1582
+ }),
1583
+ ])
1584
+ promptCompleted = true
1585
+ if (timer) clearTimeout(timer)
1586
+ clearRavenWaiters(sessionId)
1348
1587
 
1349
1588
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1350
1589
 
@@ -1371,15 +1610,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1371
1610
  ravenProviderIssue = undefined
1372
1611
 
1373
1612
  return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1374
- } finally {
1375
- if (timer) clearTimeout(timer)
1376
- clearRavenWaiters(sessionId)
1377
- }
1613
+ } finally {
1614
+ context.abort.removeEventListener("abort", abortPrompt)
1615
+ if (!promptCompleted) promptController.abort()
1616
+ if (timer) clearTimeout(timer)
1617
+ clearRavenWaiters(sessionId)
1618
+ }
1378
1619
  } catch (err: any) {
1379
1620
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1380
- const msg = String(err?.message ?? err ?? "").toLowerCase()
1381
- const hint =
1382
- /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1621
+ const msg = String(err?.message ?? err ?? "").toLowerCase()
1622
+ const hint =
1623
+ context.abort.aborted ? "Raven search cancelled."
1624
+ : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1383
1625
  : /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."
1384
1626
  : /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."
1385
1627
  : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
@@ -1403,81 +1645,95 @@ Then restart opencode for the model change to take effect. If the task can be co
1403
1645
  name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
1404
1646
  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."),
1405
1647
  },
1406
- async execute(args, context) {
1407
- if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1408
- return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1409
- }
1410
-
1411
- const operation = args.operation as RavenMcpOperation
1412
- const timeout = (config.timeout ?? 600) * 1000
1413
- let connected = false
1414
- try {
1415
- const connection = await getMcpConnection(context.sessionID, args.server)
1416
- connected = true
1417
- setOnDemandMcpHealth(args.server, "ready")
1418
- connection.lastUsed = Date.now()
1419
- const client = connection.client
1420
-
1421
- if (operation === "list_tools") {
1422
- const tools = await withTimeout(listAllMcpTools(client), timeout, `Listing ${args.server} tools`)
1423
- const match = getOnDemandMcpServer(args.server)
1424
- if (match) {
1425
- const [name] = match
1648
+ async execute(args, context) {
1649
+ if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1650
+ return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1651
+ }
1652
+
1653
+ const operation = args.operation as RavenMcpOperation
1654
+ const server = getOnDemandMcpServer(args.server)?.[1]
1655
+ const timeout = server ? mcpTimeoutMs(server) : (config.timeout ?? 600) * 1000
1656
+ const requestOptions = { signal: context.abort, timeout }
1657
+ let connected = false
1658
+ let connection: McpConnection | undefined
1659
+ try {
1660
+ connection = await getMcpConnection(context.sessionID, args.server, context.abort)
1661
+ connected = true
1662
+ connection.active++
1663
+ connection.lastUsed = Date.now()
1664
+ const client = connection.client
1665
+
1666
+ if (operation === "list_tools") {
1667
+ const tools = await listAllMcpTools(client, requestOptions)
1668
+ const match = getOnDemandMcpServer(args.server)
1669
+ if (match) {
1670
+ const [name] = match
1426
1671
  const generatedTools = generatedToolsFromMcpTools(tools)
1427
1672
  mcpMetadata[name] = {
1428
1673
  autoGeneratedTools: generatedTools,
1429
1674
  autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
1430
1675
  avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
1431
1676
  status: "ready",
1432
- lastCheckedAt: new Date().toISOString(),
1433
- }
1434
- saveMcpMetadata(mcpMetadata)
1435
- }
1436
- return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1437
- }
1438
-
1439
- if (operation === "call_tool") {
1440
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1441
- const result = await withTimeout(client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Calling ${args.server}.${args.name}`)
1442
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1443
- }
1444
-
1445
- if (operation === "list_resources") {
1446
- const resources = await withTimeout(listAllMcpResources(client), timeout, `Listing ${args.server} resources`)
1447
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1448
- }
1449
-
1450
- if (operation === "read_resource") {
1451
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1452
- const result = await withTimeout(client.readResource({ uri: args.name }), timeout, `Reading ${args.server} resource`)
1453
- return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1454
- }
1455
-
1456
- if (operation === "list_prompts") {
1457
- const prompts = await withTimeout(listAllMcpPrompts(client), timeout, `Listing ${args.server} prompts`)
1458
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1459
- }
1460
-
1461
- if (operation === "get_prompt") {
1462
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1463
- const result = await withTimeout(client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Getting ${args.server} prompt`)
1464
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1465
- }
1466
-
1467
- return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1468
- } catch (err: any) {
1469
- if (!connected) {
1470
- const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1471
- if (name) await notifyIfMcpFailures([name])
1472
- }
1473
- return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1474
- }
1677
+ lastCheckedAt: new Date().toISOString(),
1678
+ }
1679
+ saveMcpMetadata(mcpMetadata)
1680
+ saveDynamicGuidance()
1681
+ }
1682
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1683
+ }
1684
+
1685
+ if (operation === "call_tool") {
1686
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1687
+ const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
1688
+ setOnDemandMcpHealth(args.server, "ready")
1689
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1690
+ }
1691
+
1692
+ if (operation === "list_resources") {
1693
+ const resources = await listAllMcpResources(client, requestOptions)
1694
+ setOnDemandMcpHealth(args.server, "ready")
1695
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1696
+ }
1697
+
1698
+ if (operation === "read_resource") {
1699
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1700
+ const result = await client.readResource({ uri: args.name }, requestOptions)
1701
+ setOnDemandMcpHealth(args.server, "ready")
1702
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1703
+ }
1704
+
1705
+ if (operation === "list_prompts") {
1706
+ const prompts = await listAllMcpPrompts(client, requestOptions)
1707
+ setOnDemandMcpHealth(args.server, "ready")
1708
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1709
+ }
1710
+
1711
+ if (operation === "get_prompt") {
1712
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1713
+ const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
1714
+ setOnDemandMcpHealth(args.server, "ready")
1715
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1716
+ }
1717
+
1718
+ return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1719
+ } catch (err: any) {
1720
+ if (!connected && !context.abort.aborted) {
1721
+ const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1722
+ if (name) await notifyIfMcpFailures([name])
1723
+ }
1724
+ return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1725
+ } finally {
1726
+ if (connection) {
1727
+ connection.active = Math.max(0, connection.active - 1)
1728
+ connection.lastUsed = Date.now()
1729
+ }
1730
+ }
1475
1731
  },
1476
1732
  }),
1477
1733
  },
1478
1734
 
1479
1735
  // Track agent ↔ session mapping for allowlist + Raven exclusion
1480
- "chat.message"(input: any, _output: any) {
1736
+ async "chat.message"(input: any, _output: any) {
1481
1737
  if (input.agent) {
1482
1738
  sessionAgents.set(input.sessionID, input.agent)
1483
1739
  if (input.agent === "raven") {
@@ -1486,12 +1742,25 @@ Then restart opencode for the model change to take effect. If the task can be co
1486
1742
  }
1487
1743
  },
1488
1744
 
1489
- event(input: { event: any }) {
1490
- // Track subagent session → parent mapping for accurate context counting
1491
- const evt = input.event
1492
- if (evt?.type === "session.created" && evt?.properties?.parentID) {
1493
- ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
1494
- }
1745
+ async event(input: { event: any }) {
1746
+ // Track subagent session → parent mapping for accurate context counting
1747
+ const evt = input.event
1748
+ if (evt?.type === "session.created") {
1749
+ const info = evt.properties?.info
1750
+ if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
1751
+ }
1752
+ if (evt?.type === "session.deleted") {
1753
+ const sessionId = evt.properties?.info?.id
1754
+ if (sessionId) {
1755
+ ravenSessions.delete(sessionId)
1756
+ sessionAgents.delete(sessionId)
1757
+ clearRavenWaiters(sessionId)
1758
+ for (const [parentId, childId] of ravenSessionParents) {
1759
+ if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
1760
+ }
1761
+ await closeMcpConnectionsForSession(sessionId)
1762
+ }
1763
+ }
1495
1764
  if (evt?.type === "message.updated") {
1496
1765
  const info = evt.properties?.info
1497
1766
  if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
@@ -1581,10 +1850,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1581
1850
  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` })
1582
1851
  } else if (arg.startsWith("mcp ")) {
1583
1852
  const parts = raw.split(/\s+/)
1584
- const action = parts[1]?.toLowerCase()
1585
- if (action === "refresh") {
1586
- const serverName = parts.slice(2).join(" ").trim() || undefined
1587
- const result = await refreshOnDemandMcpDescriptions(true, serverName)
1853
+ const action = parts[1]?.toLowerCase()
1854
+ if (action === "refresh") {
1855
+ const serverName = parts.slice(2).join(" ").trim() || undefined
1856
+ const result = await queueOnDemandMcpRefresh(true, serverName)
1588
1857
  await notifyIfMcpFailures(result.failed)
1589
1858
  output.parts.push({
1590
1859
  type: "text",
@@ -1635,10 +1904,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1635
1904
  saveConfig(config)
1636
1905
  output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
1637
1906
  }
1638
- } else if (arg.startsWith("timeout ")) {
1639
- const secs = parseInt(raw.slice(8).trim(), 10)
1640
- if (!secs || secs < 10) {
1641
- output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 600}s` })
1907
+ } else if (arg.startsWith("timeout ")) {
1908
+ const secs = Number(raw.slice(8).trim())
1909
+ if (!Number.isInteger(secs) || secs < 10 || secs > MAX_TIMEOUT_SECONDS) {
1910
+ output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.timeout ?? 600}s` })
1642
1911
  } else {
1643
1912
  config.timeout = secs
1644
1913
  saveConfig(config)
@@ -1652,16 +1921,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1652
1921
  const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
1653
1922
  let update = "Update: unable to check npm."
1654
1923
  try {
1655
- const info = await getUpdateInfo()
1656
- update = info.available && info.latest
1657
- ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1658
- : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
1924
+ const info = await getUpdateInfo()
1925
+ update = info.available && info.latest
1926
+ ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1927
+ : info.latest
1928
+ ? `Update: up to date (latest ${info.latest}).`
1929
+ : "Update: unable to check npm."
1659
1930
  } catch { /* keep fallback */ }
1660
1931
  output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s${providerIssue}\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
1661
1932
  }
1662
1933
  },
1663
1934
 
1664
- "tool.execute.before"(input: any, output: any) {
1935
+ async "tool.execute.before"(input: any, output: any) {
1665
1936
  if (input.tool === "raven_seek" && (ravenSessions.has(input.sessionID) || sessionAgents.get(input.sessionID) === "raven")) {
1666
1937
  throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
1667
1938
  }
@@ -1690,7 +1961,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1690
1961
  }
1691
1962
  },
1692
1963
 
1693
- "tool.execute.after"(input: any, output: any) {
1964
+ async "tool.execute.after"(input: any, output: any) {
1694
1965
  if (ravenTaskCalls.has(input.callID)) {
1695
1966
  ravenTaskCalls.delete(input.callID)
1696
1967
  // Try task metadata first (built-in tools preserve metadata)