opencode-raven 2.1.5 → 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 -317
  3. package/index.ts +604 -374
  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>) {
@@ -482,14 +516,8 @@ export default ((input: PluginInput) => {
482
516
  } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
483
517
  }
484
518
 
485
- function cleanupOldRootFiles() {
486
- try {
487
- if (existsSync(configFile)) rmSync(oldConfigFile, { force: true })
488
- } catch { /* best-effort migration cleanup */ }
489
- }
490
-
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
 
@@ -648,10 +678,6 @@ Then restart opencode for the model change to take effect. If the task can be co
648
678
  return [...existing, ...added]
649
679
  }
650
680
 
651
- function fullGuidanceText(value: unknown): string {
652
- return compactText(value)
653
- }
654
-
655
681
  function firstSentenceText(value: unknown): string {
656
682
  const text = compactText(value)
657
683
  const period = text.indexOf(".")
@@ -662,7 +688,9 @@ Then restart opencode for the model change to take effect. If the task can be co
662
688
  const servers = config.onDemandMcpServers ?? {}
663
689
  const activeEntries = activeOnDemandMcpEntries()
664
690
  const entries = orderedOnDemandMcpEntries(
665
- activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
691
+ target === "raven"
692
+ ? activeEntries
693
+ : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
666
694
  preferredOrder,
667
695
  )
668
696
  if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
@@ -713,20 +741,6 @@ Then restart opencode for the model change to take effect. If the task can be co
713
741
  return result
714
742
  }
715
743
 
716
- async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
717
- let timer: ReturnType<typeof setTimeout> | undefined
718
- try {
719
- return await Promise.race([
720
- promise,
721
- new Promise<never>((_, reject) => {
722
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms)
723
- }),
724
- ])
725
- } finally {
726
- if (timer) clearTimeout(timer)
727
- }
728
- }
729
-
730
744
  function providerErrorMessage(error: any): string {
731
745
  const name = String(error?.name ?? "")
732
746
  const data = error?.data && typeof error.data === "object" ? error.data : {}
@@ -770,41 +784,114 @@ Then restart opencode for the model change to take effect. If the task can be co
770
784
  ravenProgressWaiters.get(sessionId)?.()
771
785
  }
772
786
 
773
- type McpConnection = {
774
- client: Client
775
- transport: any
776
- serverName: string
777
- lastUsed: number
778
- }
779
-
780
- const mcpConnections = new Map<string, McpConnection>()
781
-
782
- async function closeMcpConnection(connection: McpConnection) {
783
- try {
784
- if (typeof connection.transport?.terminateSession === "function") {
785
- await connection.transport.terminateSession()
786
- }
787
- } catch { /* best-effort */ }
788
- try {
789
- await connection.client.close()
790
- } catch { /* best-effort */ }
791
- }
792
-
793
- async function closeMcpConnectionKey(key: string) {
794
- const existing = mcpConnections.get(key)
795
- if (!existing) return
796
- mcpConnections.delete(key)
797
- await closeMcpConnection(existing)
798
- }
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
+ }
799
870
 
800
871
  function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
801
872
  const now = Date.now()
802
- for (const [key, connection] of mcpConnections) {
803
- if (now - connection.lastUsed > maxAgeMs) {
804
- void closeMcpConnectionKey(key)
805
- }
806
- }
807
- }
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
+ }
808
895
 
809
896
  function mcpConnectionKey(sessionId: string, serverName: string): string {
810
897
  return `${sessionId}:${serverName.toLowerCase()}`
@@ -815,15 +902,44 @@ Then restart opencode for the model change to take effect. If the task can be co
815
902
  return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
816
903
  }
817
904
 
818
- function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
819
- return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
820
- }
821
-
822
- async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
823
- const timeout = server.timeout ?? (config.timeout ?? 180) * 1000
824
- const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
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
+ }
825
937
 
826
- if (server.type === "local") {
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") {
827
943
  const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
828
944
  const transport = new StdioClientTransport({
829
945
  command: resolveEnvPlaceholders(server.command[0]),
@@ -831,73 +947,103 @@ Then restart opencode for the model change to take effect. If the task can be co
831
947
  env,
832
948
  stderr: "pipe",
833
949
  cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
834
- })
835
- transport.stderr?.on("data", () => {})
836
- transport.stderr?.on("error", () => {})
837
- await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
838
- return { client, transport, serverName, lastUsed: Date.now() }
839
- }
840
-
841
- const url = new URL(resolveEnvPlaceholders(server.url))
842
- const headers = resolveStringRecord(server.headers)
843
- try {
844
- const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
845
- await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
846
- return { client, transport, serverName, lastUsed: Date.now() }
847
- } catch (err) {
848
- try { await client.close() } catch { /* best-effort */ }
849
- const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
850
- const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
851
- await withTimeout(fallbackClient.connect(fallback), timeout, `Connecting to ${serverName} via SSE`)
852
- return { client: fallbackClient, transport: fallback, serverName, lastUsed: Date.now() }
853
- }
854
- }
855
-
856
- async function getMcpConnection(sessionId: string, serverName: string): Promise<McpConnection> {
857
- closeIdleMcpConnections()
858
- const match = getOnDemandMcpServer(serverName)
859
- 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}`)
860
981
  const [canonicalName, server] = match
861
982
  const key = mcpConnectionKey(sessionId, canonicalName)
862
983
  const existing = mcpConnections.get(key)
863
984
  if (existing) {
864
- existing.lastUsed = Date.now()
865
- return existing
866
- }
867
- const connection = await connectOnDemandMcp(canonicalName, server)
868
- mcpConnections.set(key, connection)
869
- return connection
870
- }
871
-
872
- async function listAllMcpTools(client: Client): Promise<any[]> {
873
- const tools: any[] = []
874
- let cursor: string | undefined
875
- do {
876
- const result: any = await client.listTools(cursor ? { cursor } : undefined as any)
877
- tools.push(...(result.tools ?? []))
878
- cursor = result.nextCursor
879
- } while (cursor)
880
- return tools
881
- }
882
-
883
- async function listAllMcpResources(client: Client): Promise<any[]> {
884
- const resources: any[] = []
885
- let cursor: string | undefined
886
- do {
887
- const result: any = await client.listResources(cursor ? { cursor } : undefined as any)
888
- resources.push(...(result.resources ?? []))
889
- cursor = result.nextCursor
890
- } while (cursor)
891
- return resources
892
- }
893
-
894
- async function listAllMcpPrompts(client: Client): Promise<any[]> {
895
- const prompts: any[] = []
896
- let cursor: string | undefined
897
- do {
898
- const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any)
899
- prompts.push(...(result.prompts ?? []))
900
- 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
901
1047
  } while (cursor)
902
1048
  return prompts
903
1049
  }
@@ -923,8 +1069,9 @@ Then restart opencode for the model change to take effect. If the task can be co
923
1069
  return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
924
1070
  }
925
1071
 
926
- async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
927
- const entries = activeOnDemandMcpEntries()
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()
928
1075
  .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
929
1076
  .filter(([name]) => {
930
1077
  const metadata = mcpMetadata[name]
@@ -933,13 +1080,14 @@ Then restart opencode for the model change to take effect. If the task can be co
933
1080
  const refreshed: string[] = []
934
1081
  const failed: string[] = []
935
1082
  if (!entries.length) return { refreshed, failed }
936
- let changed = false
937
- for (const [name, server] of entries) {
938
- let connection: McpConnection | undefined
939
- const checkedAt = new Date().toISOString()
940
- try {
941
- connection = await connectOnDemandMcp(name, server)
942
- 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) })
943
1091
  const tools = generatedToolsFromMcpTools(rawTools)
944
1092
  mcpMetadata[name] = {
945
1093
  autoGeneratedTools: tools,
@@ -948,10 +1096,11 @@ Then restart opencode for the model change to take effect. If the task can be co
948
1096
  status: "ready",
949
1097
  lastCheckedAt: checkedAt,
950
1098
  }
951
- changed = true
952
- refreshed.push(name)
953
- } catch (err: any) {
954
- mcpMetadata[name] = {
1099
+ changed = true
1100
+ refreshed.push(name)
1101
+ } catch (err: any) {
1102
+ if (signal?.aborted) break
1103
+ mcpMetadata[name] = {
955
1104
  ...(mcpMetadata[name] ?? {}),
956
1105
  status: "failed",
957
1106
  lastError: truncateText(err?.message ?? err, 500),
@@ -971,15 +1120,38 @@ Then restart opencode for the model change to take effect. If the task can be co
971
1120
  return { refreshed, failed }
972
1121
  }
973
1122
 
974
- function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
975
- if (mcpRefreshScheduled && mcpRefreshPromise) return mcpRefreshPromise
976
- mcpRefreshScheduled = true
977
- mcpRefreshPromise = new Promise((resolve) => {
978
- setTimeout(() => {
979
- void refreshOnDemandMcpDescriptions().then(resolve)
980
- }, 1000)
981
- })
982
- 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
983
1155
  }
984
1156
 
985
1157
  function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
@@ -1093,15 +1265,15 @@ Then restart opencode for the model change to take effect. If the task can be co
1093
1265
  return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1094
1266
  }
1095
1267
 
1096
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1268
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
1097
1269
  if (!raw?.trim()) return {}
1098
1270
  const parsed = JSON.parse(raw)
1099
1271
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1100
1272
  throw new Error("argumentsJson must be a JSON object")
1101
1273
  }
1102
- return parsed
1103
- }
1104
-
1274
+ return parsed
1275
+ }
1276
+
1105
1277
  // ── Context saved by Raven delegation ──
1106
1278
  let sessionBytes = 0
1107
1279
  let totalBytes = config.stats?.bytes ?? 0
@@ -1243,10 +1415,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1243
1415
  } catch { /* update checks are best-effort */ }
1244
1416
  }
1245
1417
 
1246
- async function notifyIfMcpFailures(names?: string[]) {
1247
- try {
1248
- const failed = failedOnDemandMcpNames(names)
1249
- 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
+ }
1250
1425
  const key = failed.sort().join("|")
1251
1426
  if (key === lastNotifiedMcpFailureKey) return
1252
1427
  lastNotifiedMcpFailureKey = key
@@ -1277,18 +1452,31 @@ Then restart opencode for the model change to take effect. If the task can be co
1277
1452
  } catch { /* duplicate MCP notifications are best-effort */ }
1278
1453
  }
1279
1454
 
1280
- return {
1281
- 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) {
1282
1471
  globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1283
1472
  .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1284
1473
  .map(([name]) => name))
1285
1474
  const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1286
1475
  duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1287
1476
 
1288
- // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1289
- // The file is regenerated after MCP health checks, not during the startup pending reset.
1290
- cleanupOldRootFiles()
1291
- 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 || []
1292
1480
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
1293
1481
  configInput.instructions.push(MCP_GUIDANCE_MD)
1294
1482
  }
@@ -1338,12 +1526,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1338
1526
  const timeout = (config.timeout ?? 600) * 1000
1339
1527
  try {
1340
1528
  // Create a Raven session
1341
- const session = await client.session.create({
1342
- body: {
1343
- parentID: context.sessionID,
1344
- title: `raven_seek: ${args.query.slice(0, 80)}`,
1345
- },
1346
- })
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
+ })
1347
1536
 
1348
1537
  const sessionId = (session as any)?.data?.id ?? (session as any)?.id
1349
1538
  if (!sessionId) {
@@ -1352,40 +1541,49 @@ Then restart opencode for the model change to take effect. If the task can be co
1352
1541
 
1353
1542
  ravenSessions.add(sessionId)
1354
1543
 
1355
- // Emit sessionId so the TUI renders a clickable delegation box
1356
- context.metadata({ metadata: { sessionId } })
1544
+ // Preserve the child session link for TUI clients that render it.
1545
+ context.metadata({ metadata: { sessionId } })
1357
1546
 
1358
1547
  // Log session for debugging
1359
1548
  try {
1360
- const logFile = join(tmpdir(), "raven-sessions.log")
1361
- const ts = new Date().toISOString()
1362
- const q = String(args.query).slice(0, 100)
1363
- 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`)
1364
1552
  } catch { /* non-fatal */ }
1365
1553
 
1366
1554
  // Send the query to Raven with timeout. Provider/API errors are also
1367
1555
  // surfaced through message.updated events; fail as soon as one lands
1368
- // so the caller does not wait for the full timeout on quota/auth issues.
1369
- let timer: ReturnType<typeof setTimeout> | undefined
1370
- const providerError = waitForRavenProviderError(sessionId)
1371
- const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1372
- try {
1373
- const result = await Promise.race([
1374
- 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({
1375
1568
  path: { id: sessionId },
1376
1569
  body: {
1377
- agent: "raven",
1378
- parts: [{ type: "text", text: args.query }],
1379
- },
1380
- }),
1381
- providerError,
1382
- firstProgress,
1383
- new Promise<never>((_, reject) => {
1384
- timer = setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1385
- }),
1386
- ])
1387
- if (timer) clearTimeout(timer)
1388
- 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)
1389
1587
 
1390
1588
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1391
1589
 
@@ -1412,15 +1610,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1412
1610
  ravenProviderIssue = undefined
1413
1611
 
1414
1612
  return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1415
- } finally {
1416
- if (timer) clearTimeout(timer)
1417
- clearRavenWaiters(sessionId)
1418
- }
1613
+ } finally {
1614
+ context.abort.removeEventListener("abort", abortPrompt)
1615
+ if (!promptCompleted) promptController.abort()
1616
+ if (timer) clearTimeout(timer)
1617
+ clearRavenWaiters(sessionId)
1618
+ }
1419
1619
  } catch (err: any) {
1420
1620
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1421
- const msg = String(err?.message ?? err ?? "").toLowerCase()
1422
- const hint =
1423
- /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."
1424
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."
1425
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."
1426
1627
  : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
@@ -1444,81 +1645,95 @@ Then restart opencode for the model change to take effect. If the task can be co
1444
1645
  name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
1445
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."),
1446
1647
  },
1447
- async execute(args, context) {
1448
- if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1449
- return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1450
- }
1451
-
1452
- const operation = args.operation as RavenMcpOperation
1453
- const timeout = (config.timeout ?? 600) * 1000
1454
- let connected = false
1455
- try {
1456
- const connection = await getMcpConnection(context.sessionID, args.server)
1457
- connected = true
1458
- setOnDemandMcpHealth(args.server, "ready")
1459
- connection.lastUsed = Date.now()
1460
- const client = connection.client
1461
-
1462
- if (operation === "list_tools") {
1463
- const tools = await withTimeout(listAllMcpTools(client), timeout, `Listing ${args.server} tools`)
1464
- const match = getOnDemandMcpServer(args.server)
1465
- if (match) {
1466
- 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
1467
1671
  const generatedTools = generatedToolsFromMcpTools(tools)
1468
1672
  mcpMetadata[name] = {
1469
1673
  autoGeneratedTools: generatedTools,
1470
1674
  autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
1471
1675
  avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
1472
1676
  status: "ready",
1473
- lastCheckedAt: new Date().toISOString(),
1474
- }
1475
- saveMcpMetadata(mcpMetadata)
1476
- }
1477
- return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1478
- }
1479
-
1480
- if (operation === "call_tool") {
1481
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1482
- const result = await withTimeout(client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Calling ${args.server}.${args.name}`)
1483
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1484
- }
1485
-
1486
- if (operation === "list_resources") {
1487
- const resources = await withTimeout(listAllMcpResources(client), timeout, `Listing ${args.server} resources`)
1488
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1489
- }
1490
-
1491
- if (operation === "read_resource") {
1492
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1493
- const result = await withTimeout(client.readResource({ uri: args.name }), timeout, `Reading ${args.server} resource`)
1494
- return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1495
- }
1496
-
1497
- if (operation === "list_prompts") {
1498
- const prompts = await withTimeout(listAllMcpPrompts(client), timeout, `Listing ${args.server} prompts`)
1499
- return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1500
- }
1501
-
1502
- if (operation === "get_prompt") {
1503
- if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1504
- const result = await withTimeout(client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }), timeout, `Getting ${args.server} prompt`)
1505
- return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1506
- }
1507
-
1508
- return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1509
- } catch (err: any) {
1510
- if (!connected) {
1511
- const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1512
- if (name) await notifyIfMcpFailures([name])
1513
- }
1514
- return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1515
- }
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
+ }
1516
1731
  },
1517
1732
  }),
1518
1733
  },
1519
1734
 
1520
1735
  // Track agent ↔ session mapping for allowlist + Raven exclusion
1521
- "chat.message"(input: any, _output: any) {
1736
+ async "chat.message"(input: any, _output: any) {
1522
1737
  if (input.agent) {
1523
1738
  sessionAgents.set(input.sessionID, input.agent)
1524
1739
  if (input.agent === "raven") {
@@ -1527,12 +1742,25 @@ Then restart opencode for the model change to take effect. If the task can be co
1527
1742
  }
1528
1743
  },
1529
1744
 
1530
- event(input: { event: any }) {
1531
- // Track subagent session → parent mapping for accurate context counting
1532
- const evt = input.event
1533
- if (evt?.type === "session.created" && evt?.properties?.parentID) {
1534
- ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
1535
- }
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
+ }
1536
1764
  if (evt?.type === "message.updated") {
1537
1765
  const info = evt.properties?.info
1538
1766
  if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
@@ -1622,10 +1850,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1622
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` })
1623
1851
  } else if (arg.startsWith("mcp ")) {
1624
1852
  const parts = raw.split(/\s+/)
1625
- const action = parts[1]?.toLowerCase()
1626
- if (action === "refresh") {
1627
- const serverName = parts.slice(2).join(" ").trim() || undefined
1628
- 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)
1629
1857
  await notifyIfMcpFailures(result.failed)
1630
1858
  output.parts.push({
1631
1859
  type: "text",
@@ -1676,10 +1904,10 @@ Then restart opencode for the model change to take effect. If the task can be co
1676
1904
  saveConfig(config)
1677
1905
  output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
1678
1906
  }
1679
- } else if (arg.startsWith("timeout ")) {
1680
- const secs = parseInt(raw.slice(8).trim(), 10)
1681
- if (!secs || secs < 10) {
1682
- 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` })
1683
1911
  } else {
1684
1912
  config.timeout = secs
1685
1913
  saveConfig(config)
@@ -1693,16 +1921,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1693
1921
  const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
1694
1922
  let update = "Update: unable to check npm."
1695
1923
  try {
1696
- const info = await getUpdateInfo()
1697
- update = info.available && info.latest
1698
- ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1699
- : `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."
1700
1930
  } catch { /* keep fallback */ }
1701
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.` })
1702
1932
  }
1703
1933
  },
1704
1934
 
1705
- "tool.execute.before"(input: any, output: any) {
1935
+ async "tool.execute.before"(input: any, output: any) {
1706
1936
  if (input.tool === "raven_seek" && (ravenSessions.has(input.sessionID) || sessionAgents.get(input.sessionID) === "raven")) {
1707
1937
  throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
1708
1938
  }
@@ -1731,7 +1961,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1731
1961
  }
1732
1962
  },
1733
1963
 
1734
- "tool.execute.after"(input: any, output: any) {
1964
+ async "tool.execute.after"(input: any, output: any) {
1735
1965
  if (ravenTaskCalls.has(input.callID)) {
1736
1966
  ravenTaskCalls.delete(input.callID)
1737
1967
  // Try task metadata first (built-in tools preserve metadata)