opencode-raven 2.1.5 → 2.1.7

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 +220 -0
  2. package/README.md +212 -317
  3. package/index.ts +679 -438
  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,56 +451,54 @@ 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
- }
446
- } catch { /* ignore corruption, use defaults */ }
447
- // Auto-create config file with defaults on first run
448
- saveConfig(DEFAULT_CONFIG)
449
- return { ...DEFAULT_CONFIG }
450
- }
451
-
452
- function saveConfig(config: RavenConfig) {
453
- try {
454
- mkdirSync(ravenConfigDir, { recursive: true })
455
- writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
456
- } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
457
- }
458
-
459
- function loadMcpMetadata(seed: Record<string, OnDemandMcpMetadata>): Record<string, OnDemandMcpMetadata> {
460
- const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
471
+ function loadConfig(): RavenConfig {
472
+ if (existsSync(configFile)) {
473
+ try {
474
+ const raw = JSON.parse(readFileSync(configFile, "utf-8"))
475
+ return normalizeConfig(raw)
476
+ } catch {
477
+ // Preserve malformed or partially-written config so the user can repair it.
478
+ return { ...DEFAULT_CONFIG }
479
+ }
480
+ }
481
+ // Auto-create config file with defaults on first run
482
+ writeConfig(DEFAULT_CONFIG)
483
+ return { ...DEFAULT_CONFIG }
484
+ }
485
+
486
+ function writeConfig(config: RavenConfig) {
487
+ try {
488
+ mkdirSync(ravenConfigDir, { recursive: true })
489
+ writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n")
490
+ } catch { /* non-fatal: config won't persist but toggle still works in-session */ }
491
+ }
492
+
493
+ function loadMcpMetadata(): Record<string, OnDemandMcpMetadata> {
494
+ const defaults = cloneOnDemandMcpMetadata(DEFAULT_ON_DEMAND_MCP_METADATA)
461
495
  try {
462
496
  if (existsSync(mcpMetadataFile)) {
463
497
  return { ...defaults, ...normalizeOnDemandMcpMetadata(JSON.parse(readFileSync(mcpMetadataFile, "utf-8"))) }
464
498
  }
465
- } catch { /* ignore corruption, use defaults + seed */ }
466
- const metadata = { ...defaults, ...seed }
467
- saveMcpMetadata(metadata)
468
- return metadata
499
+ } catch { /* ignore corruption, use defaults */ }
500
+ saveMcpMetadata(defaults)
501
+ return defaults
469
502
  }
470
503
 
471
504
  function saveMcpMetadata(metadata: Record<string, OnDemandMcpMetadata>) {
@@ -481,15 +514,24 @@ export default ((input: PluginInput) => {
481
514
  writeFileSync(dynamicGuidanceFile, `## Raven on-demand MCP capabilities\n\n${onDemandMcpGuidance("delegate", dynamicGuidanceMcpOrder())}\n`, "utf-8")
482
515
  } catch { /* non-fatal: guidance still exists in tool/subagent paths */ }
483
516
  }
484
-
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)
517
+
518
+ let config = loadConfig()
519
+
520
+ function updateConfig(update: Partial<RavenConfig> | ((latest: RavenConfig) => Partial<RavenConfig>)): RavenConfig {
521
+ try {
522
+ const raw = existsSync(configFile) ? JSON.parse(readFileSync(configFile, "utf-8")) : config
523
+ const latest = normalizeConfig(raw)
524
+ const patch = typeof update === "function" ? update(latest) : update
525
+ config = normalizeConfig({ ...latest, ...patch })
526
+ writeConfig({ ...raw, ...patch })
527
+ } catch {
528
+ // Keep malformed or partially-written external edits intact instead of replacing them.
529
+ const patch = typeof update === "function" ? update(config) : update
530
+ config = normalizeConfig({ ...config, ...patch })
531
+ }
532
+ return config
533
+ }
534
+ let mcpMetadata = loadMcpMetadata()
493
535
  const ravenSessions = new Set<string>()
494
536
  const ravenTaskCalls = new Set<string>()
495
537
  const sessionAgents = new Map<string, string>()
@@ -502,9 +544,11 @@ export default ((input: PluginInput) => {
502
544
  let updateCheckPromise: Promise<{ current: string; latest?: string; available: boolean }> | undefined
503
545
  let updateToastPending = false
504
546
  let mcpFailureToastPending = false
505
- let duplicateMcpToastPending = false
506
- let mcpRefreshScheduled = false
507
- let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
547
+ let duplicateMcpToastPending = false
548
+ let mcpRefreshScheduled = false
549
+ let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
550
+ let mcpRefreshQueue: Promise<unknown> = Promise.resolve()
551
+ const mcpRefreshControllers = new Set<AbortController>()
508
552
  let lastNotifiedMcpFailureKey = ""
509
553
  let ravenProviderIssue: string | undefined
510
554
 
@@ -648,10 +692,6 @@ Then restart opencode for the model change to take effect. If the task can be co
648
692
  return [...existing, ...added]
649
693
  }
650
694
 
651
- function fullGuidanceText(value: unknown): string {
652
- return compactText(value)
653
- }
654
-
655
695
  function firstSentenceText(value: unknown): string {
656
696
  const text = compactText(value)
657
697
  const period = text.indexOf(".")
@@ -662,7 +702,9 @@ Then restart opencode for the model change to take effect. If the task can be co
662
702
  const servers = config.onDemandMcpServers ?? {}
663
703
  const activeEntries = activeOnDemandMcpEntries()
664
704
  const entries = orderedOnDemandMcpEntries(
665
- activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
705
+ target === "raven"
706
+ ? activeEntries
707
+ : activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready"),
666
708
  preferredOrder,
667
709
  )
668
710
  if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
@@ -713,20 +755,6 @@ Then restart opencode for the model change to take effect. If the task can be co
713
755
  return result
714
756
  }
715
757
 
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
758
  function providerErrorMessage(error: any): string {
731
759
  const name = String(error?.name ?? "")
732
760
  const data = error?.data && typeof error.data === "object" ? error.data : {}
@@ -770,41 +798,114 @@ Then restart opencode for the model change to take effect. If the task can be co
770
798
  ravenProgressWaiters.get(sessionId)?.()
771
799
  }
772
800
 
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
- }
801
+ type McpConnection = {
802
+ client: Client
803
+ transport: any
804
+ lastUsed: number
805
+ active: number
806
+ }
807
+
808
+ type McpConnectionAttempt = {
809
+ promise: Promise<McpConnection>
810
+ controller: AbortController
811
+ waiters: number
812
+ }
813
+
814
+ const mcpConnections = new Map<string, McpConnection>()
815
+ const mcpConnectionAttempts = new Map<string, McpConnectionAttempt>()
816
+ let mcpIdleTimer: ReturnType<typeof setInterval> | undefined
817
+
818
+ function mcpTimeoutMs(server: OnDemandMcpServer): number {
819
+ return server.timeout ?? (config.timeout ?? 600) * 1000
820
+ }
821
+
822
+ async function closeMcpClient(client: Client, transport: any) {
823
+ try {
824
+ await transport?.close?.()
825
+ } catch { /* best-effort */ }
826
+ try {
827
+ await client.close()
828
+ } catch { /* best-effort */ }
829
+ }
830
+
831
+ async function connectMcpClient(client: Client, transport: any, timeout: number, label: string, signal?: AbortSignal) {
832
+ const controller = new AbortController()
833
+ let timedOut = false
834
+ const abort = () => controller.abort(signal?.reason)
835
+ if (signal?.aborted) abort()
836
+ else signal?.addEventListener("abort", abort, { once: true })
837
+ const timer = setTimeout(() => {
838
+ timedOut = true
839
+ controller.abort()
840
+ }, timeout)
841
+ const aborted = new Promise<never>((_resolve, reject) => {
842
+ const fail = () => reject(new Error(timedOut ? `${label} timed out after ${Math.round(timeout / 1000)}s` : `${label} cancelled`))
843
+ if (controller.signal.aborted) fail()
844
+ else controller.signal.addEventListener("abort", fail, { once: true })
845
+ })
846
+
847
+ try {
848
+ await Promise.race([
849
+ client.connect(transport, { signal: controller.signal, timeout }),
850
+ aborted,
851
+ ])
852
+ } catch (error) {
853
+ await closeMcpClient(client, transport)
854
+ throw error
855
+ } finally {
856
+ clearTimeout(timer)
857
+ signal?.removeEventListener("abort", abort)
858
+ }
859
+ }
860
+
861
+ async function closeMcpConnection(connection: McpConnection) {
862
+ await closeMcpClient(connection.client, connection.transport)
863
+ }
864
+
865
+ function stopMcpIdleTimerIfUnused() {
866
+ if (mcpConnections.size || mcpConnectionAttempts.size || !mcpIdleTimer) return
867
+ clearInterval(mcpIdleTimer)
868
+ mcpIdleTimer = undefined
869
+ }
870
+
871
+ function ensureMcpIdleTimer() {
872
+ if (mcpIdleTimer) return
873
+ mcpIdleTimer = setInterval(() => closeIdleMcpConnections(), 60_000)
874
+ ;(mcpIdleTimer as any).unref?.()
875
+ }
876
+
877
+ async function closeMcpConnectionKey(key: string, expected?: McpConnection) {
878
+ const existing = mcpConnections.get(key)
879
+ if (!existing || (expected && existing !== expected)) return
880
+ mcpConnections.delete(key)
881
+ await closeMcpConnection(existing)
882
+ stopMcpIdleTimerIfUnused()
883
+ }
799
884
 
800
885
  function closeIdleMcpConnections(maxAgeMs = 10 * 60 * 1000) {
801
886
  const now = Date.now()
802
- for (const [key, connection] of mcpConnections) {
803
- if (now - connection.lastUsed > maxAgeMs) {
804
- void closeMcpConnectionKey(key)
805
- }
806
- }
807
- }
887
+ for (const [key, connection] of mcpConnections) {
888
+ if (!connection.active && now - connection.lastUsed > maxAgeMs) {
889
+ void closeMcpConnectionKey(key, connection)
890
+ }
891
+ }
892
+ }
893
+
894
+ async function closeMcpConnectionsForSession(sessionId: string) {
895
+ const prefix = `${sessionId}:`
896
+ const pending: Promise<McpConnection>[] = []
897
+ for (const [key, attempt] of mcpConnectionAttempts) {
898
+ if (!key.startsWith(prefix)) continue
899
+ attempt.controller.abort()
900
+ pending.push(attempt.promise)
901
+ }
902
+ await Promise.allSettled(pending)
903
+ await Promise.all(
904
+ [...mcpConnections.keys()]
905
+ .filter((key) => key.startsWith(prefix))
906
+ .map((key) => closeMcpConnectionKey(key)),
907
+ )
908
+ }
808
909
 
809
910
  function mcpConnectionKey(sessionId: string, serverName: string): string {
810
911
  return `${sessionId}:${serverName.toLowerCase()}`
@@ -815,15 +916,44 @@ Then restart opencode for the model change to take effect. If the task can be co
815
916
  return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
816
917
  }
817
918
 
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 })
919
+ function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
920
+ return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
921
+ }
922
+
923
+ function waitForMcpConnection(attempt: McpConnectionAttempt, signal?: AbortSignal): Promise<McpConnection> {
924
+ if (!signal) {
925
+ attempt.waiters++
926
+ return attempt.promise.finally(() => attempt.waiters--)
927
+ }
928
+ if (signal.aborted) return Promise.reject(new Error("MCP request cancelled"))
929
+ attempt.waiters++
930
+ return new Promise((resolve, reject) => {
931
+ let settled = false
932
+ const finish = () => {
933
+ if (settled) return false
934
+ settled = true
935
+ attempt.waiters--
936
+ signal.removeEventListener("abort", abort)
937
+ return true
938
+ }
939
+ const abort = () => {
940
+ if (!finish()) return
941
+ if (!attempt.waiters) attempt.controller.abort(signal.reason)
942
+ reject(new Error("MCP request cancelled"))
943
+ }
944
+ signal.addEventListener("abort", abort, { once: true })
945
+ attempt.promise.then(
946
+ (connection) => { if (finish()) resolve(connection) },
947
+ (error) => { if (finish()) reject(error) },
948
+ )
949
+ })
950
+ }
825
951
 
826
- if (server.type === "local") {
952
+ async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer, signal?: AbortSignal): Promise<McpConnection> {
953
+ const timeout = mcpTimeoutMs(server)
954
+ const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
955
+
956
+ if (server.type === "local") {
827
957
  const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
828
958
  const transport = new StdioClientTransport({
829
959
  command: resolveEnvPlaceholders(server.command[0]),
@@ -831,73 +961,103 @@ Then restart opencode for the model change to take effect. If the task can be co
831
961
  env,
832
962
  stderr: "pipe",
833
963
  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}`)
964
+ })
965
+ transport.stderr?.on("data", () => {})
966
+ transport.stderr?.on("error", () => {})
967
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
968
+ return { client, transport, lastUsed: Date.now(), active: 0 }
969
+ }
970
+
971
+ const url = new URL(resolveEnvPlaceholders(server.url))
972
+ const headers = resolveStringRecord(server.headers)
973
+ const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined)
974
+ try {
975
+ await connectMcpClient(client, transport, timeout, `Connecting to ${serverName}`, signal)
976
+ return { client, transport, lastUsed: Date.now(), active: 0 }
977
+ } catch (err) {
978
+ if (signal?.aborted) throw err
979
+ const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
980
+ const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
981
+ try {
982
+ await connectMcpClient(fallbackClient, fallback, timeout, `Connecting to ${serverName} via SSE`, signal)
983
+ return { client: fallbackClient, transport: fallback, lastUsed: Date.now(), active: 0 }
984
+ } catch (fallbackError) {
985
+ throw fallbackError
986
+ }
987
+ }
988
+ }
989
+
990
+ async function getMcpConnection(sessionId: string, serverName: string, signal?: AbortSignal): Promise<McpConnection> {
991
+ if (signal?.aborted) throw new Error("MCP request cancelled")
992
+ closeIdleMcpConnections()
993
+ const match = getOnDemandMcpServer(serverName)
994
+ if (!match) throw new Error(`Unknown on-demand MCP server: ${serverName}`)
860
995
  const [canonicalName, server] = match
861
996
  const key = mcpConnectionKey(sessionId, canonicalName)
862
997
  const existing = mcpConnections.get(key)
863
998
  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
999
+ existing.lastUsed = Date.now()
1000
+ return existing
1001
+ }
1002
+
1003
+ const pending = mcpConnectionAttempts.get(key)
1004
+ if (pending && !pending.controller.signal.aborted) return waitForMcpConnection(pending, signal)
1005
+ if (pending) mcpConnectionAttempts.delete(key)
1006
+
1007
+ const controller = new AbortController()
1008
+ const attempt = { controller, waiters: 0 } as McpConnectionAttempt
1009
+ attempt.promise = connectOnDemandMcp(canonicalName, server, controller.signal)
1010
+ .then(async (connection) => {
1011
+ if (controller.signal.aborted || mcpConnectionAttempts.get(key) !== attempt) {
1012
+ await closeMcpConnection(connection)
1013
+ throw new Error("MCP connection attempt cancelled")
1014
+ }
1015
+ mcpConnections.set(key, connection)
1016
+ connection.client.onclose = () => {
1017
+ if (mcpConnections.get(key) === connection) mcpConnections.delete(key)
1018
+ stopMcpIdleTimerIfUnused()
1019
+ }
1020
+ ensureMcpIdleTimer()
1021
+ return connection
1022
+ })
1023
+ .finally(() => {
1024
+ if (mcpConnectionAttempts.get(key) === attempt) mcpConnectionAttempts.delete(key)
1025
+ stopMcpIdleTimerIfUnused()
1026
+ })
1027
+ mcpConnectionAttempts.set(key, attempt)
1028
+ ensureMcpIdleTimer()
1029
+ return waitForMcpConnection(attempt, signal)
1030
+ }
1031
+
1032
+ async function listAllMcpTools(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1033
+ const tools: any[] = []
1034
+ let cursor: string | undefined
1035
+ do {
1036
+ const result: any = await client.listTools(cursor ? { cursor } : undefined as any, options)
1037
+ tools.push(...(result.tools ?? []))
1038
+ cursor = result.nextCursor
1039
+ } while (cursor)
1040
+ return tools
1041
+ }
1042
+
1043
+ async function listAllMcpResources(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1044
+ const resources: any[] = []
1045
+ let cursor: string | undefined
1046
+ do {
1047
+ const result: any = await client.listResources(cursor ? { cursor } : undefined as any, options)
1048
+ resources.push(...(result.resources ?? []))
1049
+ cursor = result.nextCursor
1050
+ } while (cursor)
1051
+ return resources
1052
+ }
1053
+
1054
+ async function listAllMcpPrompts(client: Client, options?: { signal?: AbortSignal; timeout?: number }): Promise<any[]> {
1055
+ const prompts: any[] = []
1056
+ let cursor: string | undefined
1057
+ do {
1058
+ const result: any = await client.listPrompts(cursor ? { cursor } : undefined as any, options)
1059
+ prompts.push(...(result.prompts ?? []))
1060
+ cursor = result.nextCursor
901
1061
  } while (cursor)
902
1062
  return prompts
903
1063
  }
@@ -923,8 +1083,9 @@ Then restart opencode for the model change to take effect. If the task can be co
923
1083
  return compactText(`${serverName} provides on-demand MCP tools for ${joined}.`)
924
1084
  }
925
1085
 
926
- async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
927
- const entries = activeOnDemandMcpEntries()
1086
+ async function refreshOnDemandMcpDescriptions(force = false, serverName?: string, signal?: AbortSignal): Promise<{ refreshed: string[]; failed: string[] }> {
1087
+ if (signal?.aborted) return { refreshed: [], failed: [] }
1088
+ const entries = activeOnDemandMcpEntries()
928
1089
  .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
929
1090
  .filter(([name]) => {
930
1091
  const metadata = mcpMetadata[name]
@@ -933,13 +1094,14 @@ Then restart opencode for the model change to take effect. If the task can be co
933
1094
  const refreshed: string[] = []
934
1095
  const failed: string[] = []
935
1096
  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)
1097
+ let changed = false
1098
+ for (const [name, server] of entries) {
1099
+ if (signal?.aborted) break
1100
+ let connection: McpConnection | undefined
1101
+ const checkedAt = new Date().toISOString()
1102
+ try {
1103
+ connection = await connectOnDemandMcp(name, server, signal)
1104
+ const rawTools = await listAllMcpTools(connection.client, { signal, timeout: mcpTimeoutMs(server) })
943
1105
  const tools = generatedToolsFromMcpTools(rawTools)
944
1106
  mcpMetadata[name] = {
945
1107
  autoGeneratedTools: tools,
@@ -948,10 +1110,11 @@ Then restart opencode for the model change to take effect. If the task can be co
948
1110
  status: "ready",
949
1111
  lastCheckedAt: checkedAt,
950
1112
  }
951
- changed = true
952
- refreshed.push(name)
953
- } catch (err: any) {
954
- mcpMetadata[name] = {
1113
+ changed = true
1114
+ refreshed.push(name)
1115
+ } catch (err: any) {
1116
+ if (signal?.aborted) break
1117
+ mcpMetadata[name] = {
955
1118
  ...(mcpMetadata[name] ?? {}),
956
1119
  status: "failed",
957
1120
  lastError: truncateText(err?.message ?? err, 500),
@@ -971,15 +1134,38 @@ Then restart opencode for the model change to take effect. If the task can be co
971
1134
  return { refreshed, failed }
972
1135
  }
973
1136
 
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
1137
+ function waitForRefreshDelay(ms: number, signal: AbortSignal): Promise<void> {
1138
+ if (signal.aborted) return Promise.resolve()
1139
+ return new Promise((resolve) => {
1140
+ const timer = setTimeout(done, ms)
1141
+ function done() {
1142
+ clearTimeout(timer)
1143
+ signal.removeEventListener("abort", done)
1144
+ resolve()
1145
+ }
1146
+ signal.addEventListener("abort", done, { once: true })
1147
+ })
1148
+ }
1149
+
1150
+ function queueOnDemandMcpRefresh(force = false, serverName?: string, delay = 0): Promise<{ refreshed: string[]; failed: string[] }> {
1151
+ const controller = new AbortController()
1152
+ mcpRefreshControllers.add(controller)
1153
+ const run = mcpRefreshQueue
1154
+ .catch(() => undefined)
1155
+ .then(async () => {
1156
+ if (delay) await waitForRefreshDelay(delay, controller.signal)
1157
+ return refreshOnDemandMcpDescriptions(force, serverName, controller.signal)
1158
+ })
1159
+ .finally(() => mcpRefreshControllers.delete(controller))
1160
+ mcpRefreshQueue = run
1161
+ return run
1162
+ }
1163
+
1164
+ function scheduleOnDemandMcpRefresh(): Promise<{ refreshed: string[]; failed: string[] }> {
1165
+ if (mcpRefreshScheduled) return mcpRefreshPromise!
1166
+ mcpRefreshScheduled = true
1167
+ mcpRefreshPromise = queueOnDemandMcpRefresh(false, undefined, 1000)
1168
+ return mcpRefreshPromise
983
1169
  }
984
1170
 
985
1171
  function onDemandMcpRuntimeStatus(name: string): "pending" | "ready" | "failed" {
@@ -1093,25 +1279,26 @@ Then restart opencode for the model change to take effect. If the task can be co
1093
1279
  return typeof result === "string" ? result : JSON.stringify(result, null, 2)
1094
1280
  }
1095
1281
 
1096
- function parseMcpArguments(raw: string | undefined): Record<string, any> {
1282
+ function parseMcpArguments(raw: string | undefined): Record<string, any> {
1097
1283
  if (!raw?.trim()) return {}
1098
1284
  const parsed = JSON.parse(raw)
1099
1285
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1100
1286
  throw new Error("argumentsJson must be a JSON object")
1101
1287
  }
1102
- return parsed
1103
- }
1104
-
1105
- // ── Context saved by Raven delegation ──
1106
- let sessionBytes = 0
1107
- let totalBytes = config.stats?.bytes ?? 0
1108
-
1109
- function addBytes(bytes: number) {
1110
- sessionBytes += bytes
1111
- totalBytes += bytes
1112
- config.stats = { bytes: totalBytes }
1113
- saveConfig(config)
1114
- }
1288
+ return parsed
1289
+ }
1290
+
1291
+ // ── Context saved by Raven delegation ──
1292
+ let sessionBytes = 0
1293
+ let totalBytes = config.stats?.bytes ?? 0
1294
+
1295
+ function addBytes(bytes: number) {
1296
+ sessionBytes += bytes
1297
+ const updated = updateConfig((latest) => ({
1298
+ stats: { bytes: Math.max(totalBytes, latest.stats?.bytes ?? 0) + bytes },
1299
+ }))
1300
+ totalBytes = updated.stats?.bytes ?? totalBytes + bytes
1301
+ }
1115
1302
 
1116
1303
  function formatBytes(bytes: number): string {
1117
1304
  return bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)}MB`
@@ -1243,10 +1430,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1243
1430
  } catch { /* update checks are best-effort */ }
1244
1431
  }
1245
1432
 
1246
- async function notifyIfMcpFailures(names?: string[]) {
1247
- try {
1248
- const failed = failedOnDemandMcpNames(names)
1249
- if (!failed.length) return
1433
+ async function notifyIfMcpFailures(names?: string[]) {
1434
+ try {
1435
+ const failed = failedOnDemandMcpNames(names)
1436
+ if (!failed.length) {
1437
+ lastNotifiedMcpFailureKey = ""
1438
+ return
1439
+ }
1250
1440
  const key = failed.sort().join("|")
1251
1441
  if (key === lastNotifiedMcpFailureKey) return
1252
1442
  lastNotifiedMcpFailureKey = key
@@ -1277,18 +1467,31 @@ Then restart opencode for the model change to take effect. If the task can be co
1277
1467
  } catch { /* duplicate MCP notifications are best-effort */ }
1278
1468
  }
1279
1469
 
1280
- return {
1281
- config(configInput: any) {
1470
+ return {
1471
+ async dispose() {
1472
+ for (const controller of mcpRefreshControllers) controller.abort()
1473
+ for (const attempt of mcpConnectionAttempts.values()) attempt.controller.abort()
1474
+ await Promise.allSettled([
1475
+ ...[...mcpConnectionAttempts.values()].map((attempt) => attempt.promise),
1476
+ mcpRefreshQueue,
1477
+ ])
1478
+ await Promise.all([...mcpConnections.keys()].map((key) => closeMcpConnectionKey(key)))
1479
+ if (mcpIdleTimer) clearInterval(mcpIdleTimer)
1480
+ mcpIdleTimer = undefined
1481
+ for (const sessionId of ravenProgressWaiters.keys()) clearRavenWaiters(sessionId)
1482
+ ravenErrorWaiters.clear()
1483
+ },
1484
+
1485
+ async config(configInput: any) {
1282
1486
  globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1283
1487
  .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1284
1488
  .map(([name]) => name))
1285
1489
  const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1286
1490
  duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1287
1491
 
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 || []
1492
+ // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
1493
+ // The file is regenerated after MCP health checks, not during the startup pending reset.
1494
+ configInput.instructions = configInput.instructions || []
1292
1495
  if (!configInput.instructions.includes(MCP_GUIDANCE_MD)) {
1293
1496
  configInput.instructions.push(MCP_GUIDANCE_MD)
1294
1497
  }
@@ -1338,12 +1541,13 @@ Then restart opencode for the model change to take effect. If the task can be co
1338
1541
  const timeout = (config.timeout ?? 600) * 1000
1339
1542
  try {
1340
1543
  // 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
- })
1544
+ const session = await client.session.create({
1545
+ body: {
1546
+ parentID: context.sessionID,
1547
+ title: `raven_seek: ${args.query.slice(0, 80)}`,
1548
+ },
1549
+ signal: context.abort,
1550
+ })
1347
1551
 
1348
1552
  const sessionId = (session as any)?.data?.id ?? (session as any)?.id
1349
1553
  if (!sessionId) {
@@ -1352,40 +1556,49 @@ Then restart opencode for the model change to take effect. If the task can be co
1352
1556
 
1353
1557
  ravenSessions.add(sessionId)
1354
1558
 
1355
- // Emit sessionId so the TUI renders a clickable delegation box
1356
- context.metadata({ metadata: { sessionId } })
1559
+ // Preserve the child session link for TUI clients that render it.
1560
+ context.metadata({ metadata: { sessionId } })
1357
1561
 
1358
1562
  // Log session for debugging
1359
1563
  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`)
1564
+ const logFile = join(tmpdir(), "raven-sessions.log")
1565
+ const ts = new Date().toISOString()
1566
+ writeFileSync(logFile, `${ts} ${sessionId}\n`)
1364
1567
  } catch { /* non-fatal */ }
1365
1568
 
1366
1569
  // Send the query to Raven with timeout. Provider/API errors are also
1367
1570
  // 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({
1571
+ // so the caller does not wait for the full timeout on quota/auth issues.
1572
+ let timer: ReturnType<typeof setTimeout> | undefined
1573
+ let promptCompleted = false
1574
+ const promptController = new AbortController()
1575
+ const abortPrompt = () => promptController.abort(context.abort.reason)
1576
+ if (context.abort.aborted) abortPrompt()
1577
+ else context.abort.addEventListener("abort", abortPrompt, { once: true })
1578
+ const providerError = waitForRavenProviderError(sessionId)
1579
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1580
+ try {
1581
+ const result = await Promise.race([
1582
+ client.session.prompt({
1375
1583
  path: { id: sessionId },
1376
1584
  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)
1585
+ agent: "raven",
1586
+ parts: [{ type: "text", text: args.query }],
1587
+ },
1588
+ signal: promptController.signal,
1589
+ }),
1590
+ providerError,
1591
+ firstProgress,
1592
+ new Promise<never>((_, reject) => {
1593
+ timer = setTimeout(() => {
1594
+ promptController.abort()
1595
+ reject(new Error(`Raven timed out after ${timeout / 1000}s — child session retained: ${sessionId}`))
1596
+ }, timeout)
1597
+ }),
1598
+ ])
1599
+ promptCompleted = true
1600
+ if (timer) clearTimeout(timer)
1601
+ clearRavenWaiters(sessionId)
1389
1602
 
1390
1603
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1391
1604
 
@@ -1412,15 +1625,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1412
1625
  ravenProviderIssue = undefined
1413
1626
 
1414
1627
  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
- }
1628
+ } finally {
1629
+ context.abort.removeEventListener("abort", abortPrompt)
1630
+ if (!promptCompleted) promptController.abort()
1631
+ if (timer) clearTimeout(timer)
1632
+ clearRavenWaiters(sessionId)
1633
+ }
1419
1634
  } catch (err: any) {
1420
1635
  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."
1636
+ const msg = String(err?.message ?? err ?? "").toLowerCase()
1637
+ const hint =
1638
+ context.abort.aborted ? "Raven search cancelled."
1639
+ : /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1424
1640
  : /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
1641
  : /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
1642
  : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
@@ -1444,81 +1660,95 @@ Then restart opencode for the model change to take effect. If the task can be co
1444
1660
  name: tool.schema.string().optional().describe("Tool name for call_tool, resource URI for read_resource, or prompt name for get_prompt."),
1445
1661
  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
1662
  },
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
1663
+ async execute(args, context) {
1664
+ if (!ravenSessions.has(context.sessionID) && sessionAgents.get(context.sessionID) !== "raven") {
1665
+ return { title: "Raven MCP", output: "raven_mcp is only available inside Raven. Use raven_seek for MCP requests." }
1666
+ }
1667
+
1668
+ const operation = args.operation as RavenMcpOperation
1669
+ const server = getOnDemandMcpServer(args.server)?.[1]
1670
+ const timeout = server ? mcpTimeoutMs(server) : (config.timeout ?? 600) * 1000
1671
+ const requestOptions = { signal: context.abort, timeout }
1672
+ let connected = false
1673
+ let connection: McpConnection | undefined
1674
+ try {
1675
+ connection = await getMcpConnection(context.sessionID, args.server, context.abort)
1676
+ connected = true
1677
+ connection.active++
1678
+ connection.lastUsed = Date.now()
1679
+ const client = connection.client
1680
+
1681
+ if (operation === "list_tools") {
1682
+ const tools = await listAllMcpTools(client, requestOptions)
1683
+ const match = getOnDemandMcpServer(args.server)
1684
+ if (match) {
1685
+ const [name] = match
1467
1686
  const generatedTools = generatedToolsFromMcpTools(tools)
1468
1687
  mcpMetadata[name] = {
1469
1688
  autoGeneratedTools: generatedTools,
1470
1689
  autoGeneratedDescription: mcpMetadata[name]?.autoGeneratedDescription || synthesizeMcpDescription(name, generatedTools),
1471
1690
  avoidedSchemaBytes: estimateAvoidedMcpSchemaBytes(tools),
1472
1691
  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
- }
1692
+ lastCheckedAt: new Date().toISOString(),
1693
+ }
1694
+ saveMcpMetadata(mcpMetadata)
1695
+ saveDynamicGuidance()
1696
+ }
1697
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpToolsForRaven(args.server, tools) }
1698
+ }
1699
+
1700
+ if (operation === "call_tool") {
1701
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "call_tool requires name." }
1702
+ const result = await client.callTool({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, undefined, requestOptions)
1703
+ setOnDemandMcpHealth(args.server, "ready")
1704
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1705
+ }
1706
+
1707
+ if (operation === "list_resources") {
1708
+ const resources = await listAllMcpResources(client, requestOptions)
1709
+ setOnDemandMcpHealth(args.server, "ready")
1710
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Resources for ${args.server}`, resources, ["uri", "name", "description"]) }
1711
+ }
1712
+
1713
+ if (operation === "read_resource") {
1714
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "read_resource requires name as the resource URI." }
1715
+ const result = await client.readResource({ uri: args.name }, requestOptions)
1716
+ setOnDemandMcpHealth(args.server, "ready")
1717
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpResult(result) }
1718
+ }
1719
+
1720
+ if (operation === "list_prompts") {
1721
+ const prompts = await listAllMcpPrompts(client, requestOptions)
1722
+ setOnDemandMcpHealth(args.server, "ready")
1723
+ return { title: `Raven MCP: ${args.server}`, output: formatMcpList(`Prompts for ${args.server}`, prompts) }
1724
+ }
1725
+
1726
+ if (operation === "get_prompt") {
1727
+ if (!args.name) return { title: `Raven MCP: ${args.server}`, output: "get_prompt requires name." }
1728
+ const result = await client.getPrompt({ name: args.name, arguments: parseMcpArguments(args.argumentsJson) }, requestOptions)
1729
+ setOnDemandMcpHealth(args.server, "ready")
1730
+ return { title: `Raven MCP: ${args.server}.${args.name}`, output: formatMcpResult(result) }
1731
+ }
1732
+
1733
+ return { title: `Raven MCP: ${args.server}`, output: `Unsupported operation: ${operation}` }
1734
+ } catch (err: any) {
1735
+ if (!connected && !context.abort.aborted) {
1736
+ const name = setOnDemandMcpHealth(args.server, "failed", err?.message ?? err)
1737
+ if (name) await notifyIfMcpFailures([name])
1738
+ }
1739
+ return { title: `Raven MCP: ${args.server}`, output: `MCP call failed: ${err?.message ?? err}` }
1740
+ } finally {
1741
+ if (connection) {
1742
+ connection.active = Math.max(0, connection.active - 1)
1743
+ connection.lastUsed = Date.now()
1744
+ }
1745
+ }
1516
1746
  },
1517
1747
  }),
1518
1748
  },
1519
1749
 
1520
1750
  // Track agent ↔ session mapping for allowlist + Raven exclusion
1521
- "chat.message"(input: any, _output: any) {
1751
+ async "chat.message"(input: any, _output: any) {
1522
1752
  if (input.agent) {
1523
1753
  sessionAgents.set(input.sessionID, input.agent)
1524
1754
  if (input.agent === "raven") {
@@ -1527,12 +1757,25 @@ Then restart opencode for the model change to take effect. If the task can be co
1527
1757
  }
1528
1758
  },
1529
1759
 
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
- }
1760
+ async event(input: { event: any }) {
1761
+ // Track subagent session → parent mapping for accurate context counting
1762
+ const evt = input.event
1763
+ if (evt?.type === "session.created") {
1764
+ const info = evt.properties?.info
1765
+ if (info?.parentID && info?.id) ravenSessionParents.set(info.parentID, info.id)
1766
+ }
1767
+ if (evt?.type === "session.deleted") {
1768
+ const sessionId = evt.properties?.info?.id
1769
+ if (sessionId) {
1770
+ ravenSessions.delete(sessionId)
1771
+ sessionAgents.delete(sessionId)
1772
+ clearRavenWaiters(sessionId)
1773
+ for (const [parentId, childId] of ravenSessionParents) {
1774
+ if (parentId === sessionId || childId === sessionId) ravenSessionParents.delete(parentId)
1775
+ }
1776
+ await closeMcpConnectionsForSession(sessionId)
1777
+ }
1778
+ }
1536
1779
  if (evt?.type === "message.updated") {
1537
1780
  const info = evt.properties?.info
1538
1781
  if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
@@ -1586,16 +1829,14 @@ Then restart opencode for the model change to take effect. If the task can be co
1586
1829
  const raw = input.arguments.trim()
1587
1830
  const arg = raw.toLowerCase()
1588
1831
 
1589
- if (arg === "on") {
1590
- config.enabled = true
1591
- saveConfig(config)
1592
- output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1832
+ if (arg === "on") {
1833
+ updateConfig({ enabled: true })
1834
+ output.parts.push({ type: "text", text: "Raven tool/MCP routing enabled. Non-Raven agents will be redirected to raven_seek for configured tools." })
1593
1835
  } else if (arg === "help") {
1594
1836
  output.parts.push({ type: "text", text: helpText() })
1595
- } else if (arg === "off") {
1596
- config.enabled = false
1597
- saveConfig(config)
1598
- output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
1837
+ } else if (arg === "off") {
1838
+ updateConfig({ enabled: false })
1839
+ output.parts.push({ type: "text", text: "Raven tool/MCP routing disabled. All agents can use tools directly." })
1599
1840
  } else if (arg === "stats") {
1600
1841
  output.parts.push({ type: "text", text: statsText() })
1601
1842
  } else if (arg === "route") {
@@ -1607,25 +1848,27 @@ Then restart opencode for the model change to take effect. If the task can be co
1607
1848
  const name = parts.slice(3).join(" ").trim()
1608
1849
  const key = kind === "tool" ? "routeTools" : kind === "keyword" ? "routeToolKeywords" : undefined
1609
1850
 
1610
- if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1611
- output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name." })
1612
- } else {
1613
- const values = uniqueStrings(config[key])
1614
- const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
1615
- config[key] = action === "add"
1616
- ? exists ? values : [...values, name]
1617
- : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
1618
- saveConfig(config)
1619
- output.parts.push({ type: "text", text: routeSummary() })
1851
+ if (!key || !["add", "remove", "rm"].includes(action) || !name) {
1852
+ output.parts.push({ type: "text", text: "Usage:\n /raven route tool add <tool_name>\n /raven route tool remove <tool_name>\n /raven route keyword add <keyword>\n /raven route keyword remove <keyword>\n\nGlobal MCPs are auto-routed by detected OpenCode MCP server name." })
1853
+ } else {
1854
+ updateConfig((latest) => {
1855
+ const values = uniqueStrings(latest[key])
1856
+ const exists = values.some((value) => value.toLowerCase() === name.toLowerCase())
1857
+ const next = action === "add"
1858
+ ? exists ? values : [...values, name]
1859
+ : values.filter((value) => value.toLowerCase() !== name.toLowerCase())
1860
+ return key === "routeTools" ? { routeTools: next } : { routeToolKeywords: next }
1861
+ })
1862
+ output.parts.push({ type: "text", text: routeSummary() })
1620
1863
  }
1621
1864
  } else if (arg === "mcp") {
1622
1865
  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
1866
  } else if (arg.startsWith("mcp ")) {
1624
1867
  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)
1868
+ const action = parts[1]?.toLowerCase()
1869
+ if (action === "refresh") {
1870
+ const serverName = parts.slice(2).join(" ").trim() || undefined
1871
+ const result = await queueOnDemandMcpRefresh(true, serverName)
1629
1872
  await notifyIfMcpFailures(result.failed)
1630
1873
  output.parts.push({
1631
1874
  type: "text",
@@ -1633,12 +1876,11 @@ Then restart opencode for the model change to take effect. If the task can be co
1633
1876
  })
1634
1877
  } else if (action === "detail") {
1635
1878
  const detail = parts[2]?.toLowerCase()
1636
- if (detail !== "full" && detail !== "minimized") {
1637
- output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1638
- } else {
1639
- config.onDemandMcpDescriptionDetail = detail
1640
- saveConfig(config)
1641
- output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1879
+ if (detail !== "full" && detail !== "minimized") {
1880
+ output.parts.push({ type: "text", text: `Usage: /raven mcp detail full|minimized\nCurrent: ${config.onDemandMcpDescriptionDetail ?? "full"}` })
1881
+ } else {
1882
+ updateConfig({ onDemandMcpDescriptionDetail: detail })
1883
+ output.parts.push({ type: "text", text: `On-demand MCP guidance detail set to: ${detail}\nRestart opencode for startup instructions/tool descriptions to fully refresh.` })
1642
1884
  }
1643
1885
  } else {
1644
1886
  output.parts.push({ type: "text", text: "Usage:\n /raven mcp\n /raven mcp refresh [server_name]\n /raven mcp detail full\n /raven mcp detail minimized" })
@@ -1659,31 +1901,28 @@ Then restart opencode for the model change to take effect. If the task can be co
1659
1901
  }
1660
1902
  } else if (arg.startsWith("model ")) {
1661
1903
  const model = raw.slice(6).trim()
1662
- if (!model) {
1663
- output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
1664
- } else {
1665
- config.model = model
1666
- saveConfig(config)
1667
- ravenProviderIssue = undefined
1904
+ if (!model) {
1905
+ output.parts.push({ type: "text", text: `Usage: /raven model <name>\nCurrent model: ${config.model || fm.model || "(default)"}` })
1906
+ } else {
1907
+ updateConfig({ model })
1908
+ ravenProviderIssue = undefined
1668
1909
  output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
1669
1910
  }
1670
1911
  } else if (arg.startsWith("effort ")) {
1671
1912
  const effort = raw.slice(7).trim()
1672
- if (!effort) {
1673
- output.parts.push({ type: "text", text: `Usage: /raven effort <value>\nCurrent: ${config.reasoning_effort || fm.reasoning_effort || "(default)"}` })
1674
- } else {
1675
- config.reasoning_effort = effort
1676
- saveConfig(config)
1677
- output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
1913
+ if (!effort) {
1914
+ output.parts.push({ type: "text", text: `Usage: /raven effort <value>\nCurrent: ${config.reasoning_effort || fm.reasoning_effort || "(default)"}` })
1915
+ } else {
1916
+ updateConfig({ reasoning_effort: effort })
1917
+ output.parts.push({ type: "text", text: `Raven reasoning effort set to: ${effort}\nRestart opencode for the change to take effect.` })
1678
1918
  }
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` })
1683
- } else {
1684
- config.timeout = secs
1685
- saveConfig(config)
1686
- output.parts.push({ type: "text", text: `Raven timeout set to ${secs}s. Takes effect immediately.` })
1919
+ } else if (arg.startsWith("timeout ")) {
1920
+ const secs = Number(raw.slice(8).trim())
1921
+ if (!Number.isInteger(secs) || secs < 10 || secs > MAX_TIMEOUT_SECONDS) {
1922
+ output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be between 10 and ${MAX_TIMEOUT_SECONDS}. Current: ${config.timeout ?? 600}s` })
1923
+ } else {
1924
+ updateConfig({ timeout: secs })
1925
+ output.parts.push({ type: "text", text: `Raven timeout set to ${secs}s. Takes effect immediately.` })
1687
1926
  }
1688
1927
  } else {
1689
1928
  const enabled = config.enabled ? "enabled" : "disabled"
@@ -1693,16 +1932,18 @@ Then restart opencode for the model change to take effect. If the task can be co
1693
1932
  const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
1694
1933
  let update = "Update: unable to check npm."
1695
1934
  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})` : ""}.`
1935
+ const info = await getUpdateInfo()
1936
+ update = info.available && info.latest
1937
+ ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1938
+ : info.latest
1939
+ ? `Update: up to date (latest ${info.latest}).`
1940
+ : "Update: unable to check npm."
1700
1941
  } catch { /* keep fallback */ }
1701
1942
  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
1943
  }
1703
1944
  },
1704
1945
 
1705
- "tool.execute.before"(input: any, output: any) {
1946
+ async "tool.execute.before"(input: any, output: any) {
1706
1947
  if (input.tool === "raven_seek" && (ravenSessions.has(input.sessionID) || sessionAgents.get(input.sessionID) === "raven")) {
1707
1948
  throw new Error("raven_seek is disabled inside Raven. Use Raven's direct search/fetch tools instead.")
1708
1949
  }
@@ -1731,7 +1972,7 @@ Then restart opencode for the model change to take effect. If the task can be co
1731
1972
  }
1732
1973
  },
1733
1974
 
1734
- "tool.execute.after"(input: any, output: any) {
1975
+ async "tool.execute.after"(input: any, output: any) {
1735
1976
  if (ravenTaskCalls.has(input.callID)) {
1736
1977
  ravenTaskCalls.delete(input.callID)
1737
1978
  // Try task metadata first (built-in tools preserve metadata)