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.
- package/CHANGELOG.md +220 -0
- package/README.md +212 -317
- package/index.ts +679 -438
- 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,
|
|
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
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
323
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
402
|
-
const
|
|
403
|
-
const
|
|
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"
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function
|
|
453
|
-
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
|
838
|
-
return { client, transport,
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const url = new URL(resolveEnvPlaceholders(server.url))
|
|
842
|
-
const headers = resolveStringRecord(server.headers)
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
await
|
|
846
|
-
return { client, transport,
|
|
847
|
-
} catch (err) {
|
|
848
|
-
|
|
849
|
-
const fallbackClient = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
|
|
850
|
-
const fallback = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
return
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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
|
|
975
|
-
if (
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
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
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
connection
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
return { title: `Raven MCP: ${args.server}`, output: `
|
|
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"
|
|
1534
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
|
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
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
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
|
-
|
|
1676
|
-
|
|
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 =
|
|
1681
|
-
if (!secs || secs < 10) {
|
|
1682
|
-
output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be
|
|
1683
|
-
} else {
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
-
:
|
|
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)
|