opencode-raven 2.1.1 → 2.1.3

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 (3) hide show
  1. package/README.md +49 -35
  2. package/index.ts +216 -71
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,27 +2,27 @@
2
2
 
3
3
  <table>
4
4
  <tr>
5
- <td><img src="Raven.png" alt="Raven" width="768" /></td>
5
+ <td width="40%"><img src="Raven.png" alt="Raven" width="100%" /></td>
6
6
  <td>
7
- <strong>Context firewall for <a href="https://opencode.ai">OpenCode</a></strong><br/>
8
- Raven routes noisy search, docs, web, GitHub, and MCP calls through a free focused agent before they hit your main model, saving cost and context.
7
+ Raven gives OpenCode useful search tools by default and optional on-demand MCPs for any remote or local MCP you want to add. It keeps noisy tool work behind a focused Raven agent, so your main model gets compact answers instead of raw search results, docs pages, GitHub examples, MCP output, or large MCP schemas.
8
+
9
+ ## Why Use Raven?
10
+
11
+ * Routed search, fetch, and bash discovery work through Raven.
12
+ * Context7, Exa, and Grep.app are bundled as on-demand MCP defaults.
13
+ * Any extra MCP can be added under `onDemandMcpServers` when you want it.
14
+ * Configured noisy tools and MCPs are blocked for non-Raven agents and forced through `raven_seek`.
9
15
  </td>
10
16
  </tr>
11
17
  </table>
12
18
 
13
- ## What Raven Does
14
-
15
- Tool-heavy work floods context. Search, docs, web, GitHub examples, and verbose MCP calls can dump raw results into your expensive main model. Raven is a hard blocker-rerouter: configured tools fail closed for non-Raven agents and must be delegated through `raven_seek`, where a focused Raven agent performs the work and returns a compact answer.
16
-
17
- Raven fixes three problems:
18
-
19
- 1. **Context flooding** - Keep noisy tool results out of the main session. Raven summarizes tool/MCP output before returning it.
20
- 2. **Cost** - Use a cheaper model like `opencode/deepseek-v4-flash-free` for tool-heavy work while saving your main model's context for decisions and edits.
21
- 3. **Enforcement** - This is not soft nudging. Raven blocks configured tools/MCP prefixes for non-Raven agents and gives them the exact `raven_seek` retry path.
19
+ ## Example
22
20
 
23
- Raven defaults to routing noisy search/fetch tools and bundles Context7, Exa, and Grep.app as on-demand MCPs that are always handled behind Raven.
21
+ <p align="center">
22
+ <img src="example.gif" alt="Raven example: searching today's top 30 AI news items while saving 71K context" />
23
+ </p>
24
24
 
25
- Important limitation: MCPs enabled directly in OpenCode config still load their tool schemas into the main session. Raven saves context from tool calls and raw results for those global MCPs, but schema hiding requires putting the MCP in Raven's `onDemandMcpServers`. **Strongly recommended:** configure noisy/search/docs/web MCPs as on-demand MCPs unless you need direct main-agent access.
25
+ In this example, the main model asks for the top 30 AI news items from today. Raven handles the noisy search/web work in a focused agent and returns the compact result, saving about 71K context that would otherwise have landed in the main session.
26
26
 
27
27
  ## Install
28
28
 
@@ -36,6 +36,8 @@ Add Raven to your `opencode.jsonc` plugins:
36
36
 
37
37
  Restart opencode. It will resolve Raven from npm and cache the plugin automatically.
38
38
 
39
+ That is enough to start using Raven. The default config already includes routed search/fetch tools and bundled on-demand MCPs for Context7, Exa, and Grep.app. Add any other MCP under `onDemandMcpServers` only if you want more capabilities.
40
+
39
41
  Raven checks for package updates and notifies you when a newer version is available. Run `/raven update`, then restart opencode.
40
42
 
41
43
  ## Quick Use
@@ -78,17 +80,11 @@ The main agent does not see Raven's internal tool calls or raw tool output, just
78
80
 
79
81
  Config persists across restarts in `~/.config/opencode/opencode-raven/raven-config.json`. It is global and shared across all projects.
80
82
 
81
- ## Example
82
-
83
- <p align="center">
84
- <img src="example.gif" alt="Raven example: searching today's top 30 AI news items while saving 71K context" />
85
- </p>
86
-
87
- In this example, the main model asks for the top 30 AI news items from today. Raven handles the noisy search/web work in a focused agent and returns the compact result, saving about 71K context that would otherwise have landed in the main session.
88
-
89
83
  ## Recommended MCP Setup
90
84
 
91
- Raven's bundled Context7, Exa, and Grep.app MCPs are on-demand by default. They live in `onDemandMcpServers`, so Raven connects to them internally through `raven_mcp` instead of registering their full tool schemas in OpenCode's global MCP config.
85
+ You do not need to configure MCPs just to try Raven. Raven gives you the search tools up front, supports any MCP as an on-demand MCP, and bundles Context7, Exa, and Grep.app as defaults. Add more MCPs only when you want extra capabilities.
86
+
87
+ On-demand MCPs live in `onDemandMcpServers`, so Raven connects to them internally through `raven_mcp` only when needed instead of registering their full tool schemas in OpenCode's global MCP config. Context7, Exa, and Grep.app are included there by default, but the same setup works for any remote or local MCP you want to add.
92
88
 
93
89
  On-demand MCPs are always behind Raven and do not need routing entries.
94
90
 
@@ -96,6 +92,8 @@ Raven checks configured on-demand MCPs after startup. `/raven` and `/raven mcp`
96
92
 
97
93
  Raven also warns if the same MCP server name is configured both globally in OpenCode and in Raven's `onDemandMcpServers`. Prefer one location: on-demand for schema hiding, or global only when direct main-agent access is required.
98
94
 
95
+ Important limitation: MCPs enabled directly in OpenCode config still load their tool schemas into the main session. Raven saves context from tool calls and raw results for those global MCPs, but schema hiding requires putting the MCP in Raven's `onDemandMcpServers`. **Strongly recommended:** configure noisy/search/docs/web MCPs as on-demand MCPs unless you need direct main-agent access.
96
+
99
97
  All three bundled MCPs work without API keys. Add keys for higher rate limits:
100
98
 
101
99
  | MCP | URL | API key |
@@ -119,23 +117,36 @@ To add an API key to an on-demand MCP, edit Raven config:
119
117
  }
120
118
  ```
121
119
 
122
- To add a local stdio MCP:
120
+ ### Add Your Own On-Demand MCP
121
+
122
+ To add another MCP, put it under `onDemandMcpServers`. Raven will connect to it only when needed, discover its tools, and generate guidance automatically. Your main model does not need to load that MCP's full schema up front.
123
+
124
+ Use `remote` for hosted MCPs and `local` for command-based MCPs.
125
+
126
+ To add an OpenCode-style local MCP:
123
127
 
124
128
  ```jsonc
125
129
  {
126
130
  "onDemandMcpServers": {
127
- "localServer": {
128
- "type": "stdio",
129
- "command": "bunx",
130
- "args": ["some-mcp-server"],
131
- "env": { "API_KEY": "{env:API_KEY}" },
132
- "cwd": "/optional/path",
133
- "description": "Local MCP server."
131
+ "unityMCP": {
132
+ "type": "local",
133
+ "command": [
134
+ "C:\\Users\\evila\\.local\\bin\\uvx.exe",
135
+ "--offline",
136
+ "--from",
137
+ "mcpforunityserver==9.7.1",
138
+ "mcp-for-unity",
139
+ "--transport",
140
+ "stdio"
141
+ ],
142
+ "enabled": true
134
143
  }
135
144
  }
136
145
  }
137
146
  ```
138
147
 
148
+ This matches OpenCode's local MCP shape: `type: "local"`, `command` as an array, optional `environment`, optional `timeout`, and optional `enabled`. Raven also supports `cwd` and `description`. `enabled` defaults to `true`; set it to `false` to keep a server in config without loading it.
149
+
139
150
  `description` is user-authored and never overwritten. Raven fills missing generated metadata after startup or when you run `/raven mcp refresh [server]`, storing tool summaries, MCP health, and avoided schema-load estimates separately in `~/.config/opencode/opencode-raven/autogenerated-on-demand-mcp-metadata.json`.
140
151
 
141
152
  Raven also writes generated main-model guidance to `~/.config/opencode/opencode-raven/autogenerated-on-demand-guidance.md` and injects that file into OpenCode instructions at startup.
@@ -157,16 +168,19 @@ Edit config manually or use `/raven` commands:
157
168
  "onDemandMcpDescriptionDetail": "full",
158
169
  "onDemandMcpServers": {
159
170
  "context7": {
171
+ "enabled": true,
160
172
  "type": "remote",
161
173
  "url": "https://mcp.context7.com/mcp",
162
174
  "description": "Library/framework documentation lookup."
163
175
  },
164
176
  "exa": {
177
+ "enabled": true,
165
178
  "type": "remote",
166
179
  "url": "https://mcp.exa.ai/mcp",
167
180
  "description": "Live web search and webpage fetching."
168
181
  },
169
182
  "grep_app": {
183
+ "enabled": true,
170
184
  "type": "remote",
171
185
  "url": "https://mcp.grep.app",
172
186
  "description": "Public GitHub code search and real-world examples."
@@ -174,7 +188,7 @@ Edit config manually or use `/raven` commands:
174
188
  },
175
189
  "excludeAgents": [],
176
190
  "excludeTools": [],
177
- "timeout": 180
191
+ "timeout": 600
178
192
  }
179
193
  ```
180
194
 
@@ -187,10 +201,10 @@ Edit config manually or use `/raven` commands:
187
201
  | `routeTools` | built-in search/fetch tools plus `bash` | Exact tool names hard-routed through Raven. `bash` routes only search-like bash commands, not every bash call. |
188
202
  | `routeToolKeywords` | `[]` | Case-insensitive substrings hard-routed through Raven for globally registered tools with non-standard names. Catches suffix-style names like `web_search_exa`. |
189
203
  | `onDemandMcpDescriptionDetail` | `"full"` | On-demand MCP guidance detail. `full` includes tool names/descriptions; `minimized` uses compact capability summaries. |
190
- | `onDemandMcpServers` | Context7, Exa, Grep.app | MCPs Raven connects to internally on demand, without registering full schemas in OpenCode global MCP config. Supports `remote` and `stdio`. |
204
+ | `onDemandMcpServers` | Context7, Exa, Grep.app | MCPs Raven connects to internally on demand, without registering full schemas in OpenCode global MCP config. Supports OpenCode-style `remote` and `local` entries. Each server supports `enabled`, defaulting to `true`. |
191
205
  | `excludeAgents` | `[]` | Agents that bypass Raven routing, case-insensitive. |
192
206
  | `excludeTools` | `[]` | Exact tools that never get blocked, even if matched by auto-routed global MCP prefixes. |
193
- | `timeout` | `180` | Max seconds for a `raven_seek` call. On timeout the session is kept for inspection. |
207
+ | `timeout` | `600` | Max seconds for a `raven_seek` call. Provider/API retry errors and no-initial-response startup failures are reported quickly; after provider failures, routed tools tell the user how to switch Raven's model instead of retrying Raven. On timeout the session is kept for inspection. |
194
208
  | `stats` | auto | Session and global estimated context saved by Raven. Managed automatically. |
195
209
 
196
210
  Use `ravenInstructions` for extra Raven-only guidance, such as how to use custom MCPs:
@@ -285,7 +299,7 @@ Raven itself has access to these tools, blocked for other agents when configured
285
299
  | `read`, `glob`, `grep`, `list` | Local codebase inspection |
286
300
  | `bash` | Full local shell access, including `rg`, `grep`, `dir`, `ls`, `Get-ChildItem`, and `find` |
287
301
  | `external_directory` | Allowed, with no permission prompts when accessing paths outside the workspace |
288
- | `raven_mcp` | Raven-only bridge to on-demand remote/stdio MCP servers |
302
+ | `raven_mcp` | Raven-only bridge to on-demand remote/local MCP servers |
289
303
  | Context7 | On-demand library/framework/SDK/API docs |
290
304
  | Exa AI | On-demand web search, news, pages, products |
291
305
  | Grep.app | On-demand public GitHub examples |
package/index.ts CHANGED
@@ -31,16 +31,19 @@ const DEFAULT_ROUTE_TOOL_KEYWORDS: string[] = []
31
31
 
32
32
  const DEFAULT_ON_DEMAND_MCP_SERVERS: Record<string, OnDemandMcpServer> = {
33
33
  context7: {
34
+ enabled: true,
34
35
  type: "remote",
35
36
  url: "https://mcp.context7.com/mcp",
36
37
  description: "Library/framework documentation lookup.",
37
38
  },
38
39
  exa: {
40
+ enabled: true,
39
41
  type: "remote",
40
42
  url: "https://mcp.exa.ai/mcp",
41
43
  description: "Live web search and webpage fetching.",
42
44
  },
43
45
  grep_app: {
46
+ enabled: true,
44
47
  type: "remote",
45
48
  url: "https://mcp.grep.app",
46
49
  description: "Public GitHub code search and real-world examples.",
@@ -138,6 +141,8 @@ interface OnDemandMcpToolInfo {
138
141
  }
139
142
 
140
143
  interface BaseOnDemandMcpServer {
144
+ enabled?: boolean
145
+ timeout?: number
141
146
  description?: string
142
147
  }
143
148
 
@@ -154,17 +159,17 @@ interface RemoteOnDemandMcpServer extends BaseOnDemandMcpServer {
154
159
  type: "remote"
155
160
  url: string
156
161
  headers?: Record<string, string>
162
+ oauth?: Record<string, unknown> | false
157
163
  }
158
164
 
159
- interface StdioOnDemandMcpServer extends BaseOnDemandMcpServer {
160
- type: "stdio"
161
- command: string
162
- args?: string[]
163
- env?: Record<string, string>
165
+ interface LocalOnDemandMcpServer extends BaseOnDemandMcpServer {
166
+ type: "local"
167
+ command: string[]
168
+ environment?: Record<string, string>
164
169
  cwd?: string
165
170
  }
166
171
 
167
- type OnDemandMcpServer = RemoteOnDemandMcpServer | StdioOnDemandMcpServer
172
+ type OnDemandMcpServer = RemoteOnDemandMcpServer | LocalOnDemandMcpServer
168
173
  type OnDemandMcpDescriptionDetail = "full" | "minimized"
169
174
  type RavenMcpOperation = "list_tools" | "call_tool" | "list_resources" | "read_resource" | "list_prompts" | "get_prompt"
170
175
 
@@ -198,7 +203,7 @@ const DEFAULT_CONFIG: RavenConfig = {
198
203
  onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
199
204
  excludeAgents: [],
200
205
  excludeTools: [],
201
- timeout: 180,
206
+ timeout: 600,
202
207
  }
203
208
 
204
209
  function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
@@ -303,32 +308,45 @@ function normalizeToolInfo(value: unknown): OnDemandMcpToolInfo[] | undefined {
303
308
  return tools.length ? tools : undefined
304
309
  }
305
310
 
311
+ function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
312
+ if (!value || typeof value !== "object") return undefined
313
+ const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")
314
+ return entries.length ? Object.fromEntries(entries) : undefined
315
+ }
316
+
306
317
  function normalizeOnDemandMcpServer(value: unknown): OnDemandMcpServer | undefined {
307
318
  if (!value || typeof value !== "object") return undefined
308
319
  const source = value as Record<string, any>
309
320
  const base = {
321
+ enabled: source.enabled !== false,
322
+ ...(typeof source.timeout === "number" && source.timeout > 0 ? { timeout: Math.floor(source.timeout) } : {}),
310
323
  ...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}),
311
324
  }
312
325
 
313
- if (source.type === "stdio") {
314
- if (typeof source.command !== "string" || !source.command.trim()) return undefined
326
+ 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
+ : []
330
+ if (!commandParts.length) return undefined
331
+ const environment = normalizeStringRecord(source.environment)
315
332
  return {
316
333
  ...base,
317
- type: "stdio",
318
- command: source.command.trim(),
319
- ...(Array.isArray(source.args) ? { args: source.args.filter((arg: unknown) => typeof arg === "string") } : {}),
320
- ...(source.env && typeof source.env === "object" ? { env: Object.fromEntries(Object.entries(source.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
334
+ type: "local",
335
+ command: commandParts,
336
+ ...(environment ? { environment } : {}),
321
337
  ...(typeof source.cwd === "string" && source.cwd.trim() ? { cwd: source.cwd.trim() } : {}),
322
338
  }
323
339
  }
324
340
 
325
341
  if (source.type === "remote" || typeof source.url === "string") {
326
342
  if (typeof source.url !== "string" || !source.url.trim()) return undefined
343
+ const headers = normalizeStringRecord(source.headers)
327
344
  return {
328
345
  ...base,
329
346
  type: "remote",
330
347
  url: source.url.trim(),
331
- ...(source.headers && typeof source.headers === "object" ? { headers: Object.fromEntries(Object.entries(source.headers).filter((entry): entry is [string, string] => typeof entry[1] === "string")) } : {}),
348
+ ...(headers ? { headers } : {}),
349
+ ...(source.oauth === false ? { oauth: false } : source.oauth && typeof source.oauth === "object" ? { oauth: source.oauth } : {}),
332
350
  }
333
351
  }
334
352
 
@@ -476,6 +494,8 @@ export default ((input: PluginInput) => {
476
494
  const ravenTaskCalls = new Set<string>()
477
495
  const sessionAgents = new Map<string, string>()
478
496
  const ravenSessionParents = new Map<string, string>()
497
+ const ravenErrorWaiters = new Map<string, (error: Error) => void>()
498
+ const ravenProgressWaiters = new Map<string, () => void>()
479
499
  let globalMcpServers: string[] = []
480
500
  let duplicateMcpServers: string[] = []
481
501
  let updateInfo: { current: string; latest?: string; available: boolean } | undefined
@@ -486,12 +506,13 @@ export default ((input: PluginInput) => {
486
506
  let mcpRefreshScheduled = false
487
507
  let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
488
508
  let lastNotifiedMcpFailureKey = ""
509
+ let ravenProviderIssue: string | undefined
489
510
 
490
511
  resetOnDemandMcpRuntimeStatuses()
491
512
 
492
513
  function resetOnDemandMcpRuntimeStatuses() {
493
514
  let changed = false
494
- for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
515
+ for (const [name] of activeOnDemandMcpEntries()) {
495
516
  const metadata = mcpMetadata[name] ?? {}
496
517
  if (metadata.status !== "pending" || metadata.lastError || metadata.lastCheckedAt) {
497
518
  mcpMetadata[name] = { ...metadata, status: "pending" }
@@ -552,9 +573,26 @@ export default ((input: PluginInput) => {
552
573
  }
553
574
 
554
575
  function rerouteMessage(tool: string, args: any): string {
576
+ if (ravenProviderIssue) {
577
+ return `Raven is currently unavailable: ${ravenProviderIssue}
578
+
579
+ ${ravenRecoveryInstructions()}`
580
+ }
555
581
  return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
556
582
  }
557
583
 
584
+ function ravenRecoveryInstructions(): string {
585
+ return `Stop trying Raven-routed tools or MCPs for this request. Inform the user that Raven cannot run until its model/provider is changed or fixed.
586
+
587
+ Tell the user to switch Raven's model with:
588
+ /raven model <provider/model>
589
+
590
+ Example:
591
+ /raven model openai/gpt-5.5
592
+
593
+ Then restart opencode for the model change to take effect. If the task can be completed without Raven, continue without routed tools and clearly mention what could not be checked.`
594
+ }
595
+
558
596
  function routeSummary(): string {
559
597
  const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
560
598
  const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
@@ -591,8 +629,10 @@ export default ((input: PluginInput) => {
591
629
 
592
630
  function onDemandMcpGuidance(target: "delegate" | "raven" = "delegate"): string {
593
631
  const servers = config.onDemandMcpServers ?? {}
594
- const entries = Object.entries(servers).filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready")
632
+ const activeEntries = activeOnDemandMcpEntries()
633
+ const entries = activeEntries.filter(([name]) => onDemandMcpRuntimeStatus(name) === "ready")
595
634
  if (!Object.keys(servers).length) return "No on-demand MCPs are configured."
635
+ if (!activeEntries.length) return "No enabled on-demand MCPs are configured."
596
636
  if (!entries.length) return "No loaded on-demand MCPs are available."
597
637
 
598
638
  const detail = config.onDemandMcpDescriptionDetail ?? "full"
@@ -653,6 +693,49 @@ export default ((input: PluginInput) => {
653
693
  }
654
694
  }
655
695
 
696
+ function providerErrorMessage(error: any): string {
697
+ const name = String(error?.name ?? "")
698
+ const data = error?.data && typeof error.data === "object" ? error.data : {}
699
+ const status = typeof data.statusCode === "number" ? ` HTTP ${data.statusCode}.` : ""
700
+ const body = typeof data.responseBody === "string" && data.responseBody.trim() ? ` ${truncateText(data.responseBody, 500)}` : ""
701
+ const message = String(data.message ?? error?.message ?? error ?? "Unknown provider error")
702
+ return `Raven provider/API error${name ? ` (${name})` : ""}.${status} ${message}${body}`.trim()
703
+ }
704
+
705
+ function waitForRavenProviderError(sessionId: string): Promise<never> {
706
+ return new Promise((_resolve, reject) => {
707
+ ravenErrorWaiters.set(sessionId, reject)
708
+ })
709
+ }
710
+
711
+ function waitForRavenFirstProgress(sessionId: string, ms: number): Promise<never> {
712
+ return new Promise((_resolve, reject) => {
713
+ const timer = setTimeout(() => {
714
+ ravenProgressWaiters.delete(sessionId)
715
+ reject(new Error(`Raven did not produce an initial response within ${Math.round(ms / 1000)}s. If the Raven provider showed a retry dialog, this is likely an auth/quota/subscription issue. Check the Raven subagent/provider account, or retry after switching Raven to a working model.`))
716
+ }, ms)
717
+ ravenProgressWaiters.set(sessionId, () => {
718
+ clearTimeout(timer)
719
+ ravenProgressWaiters.delete(sessionId)
720
+ })
721
+ })
722
+ }
723
+
724
+ function reportRavenProviderError(sessionId: string, error: any) {
725
+ const waiter = ravenErrorWaiters.get(sessionId)
726
+ if (!waiter) return
727
+ waiter(new Error(providerErrorMessage(error)))
728
+ }
729
+
730
+ function reportRavenProgress(sessionId: string) {
731
+ ravenProgressWaiters.get(sessionId)?.()
732
+ }
733
+
734
+ function clearRavenWaiters(sessionId: string) {
735
+ ravenErrorWaiters.delete(sessionId)
736
+ ravenProgressWaiters.get(sessionId)?.()
737
+ }
738
+
656
739
  type McpConnection = {
657
740
  client: Client
658
741
  transport: any
@@ -694,22 +777,29 @@ export default ((input: PluginInput) => {
694
777
  }
695
778
 
696
779
  function getOnDemandMcpServer(serverName: string): [string, OnDemandMcpServer] | undefined {
697
- const entries = Object.entries(config.onDemandMcpServers ?? {})
780
+ const entries = activeOnDemandMcpEntries()
698
781
  return entries.find(([name]) => name.toLowerCase() === serverName.toLowerCase())
699
782
  }
700
783
 
784
+ function activeOnDemandMcpEntries(): [string, OnDemandMcpServer][] {
785
+ return Object.entries(config.onDemandMcpServers ?? {}).filter(([, server]) => server.enabled !== false)
786
+ }
787
+
701
788
  async function connectOnDemandMcp(serverName: string, server: OnDemandMcpServer): Promise<McpConnection> {
702
- const timeout = (config.timeout ?? 180) * 1000
789
+ const timeout = server.timeout ?? (config.timeout ?? 180) * 1000
703
790
  const client = new Client({ name: PACKAGE_NAME, version: PACKAGE_VERSION })
704
791
 
705
- if (server.type === "stdio") {
706
- const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.env) }
792
+ if (server.type === "local") {
793
+ const env = { ...cleanProcessEnv(), ...resolveStringRecord(server.environment) }
707
794
  const transport = new StdioClientTransport({
708
- command: resolveEnvPlaceholders(server.command),
709
- args: server.args?.map((arg) => resolveEnvPlaceholders(arg)),
795
+ command: resolveEnvPlaceholders(server.command[0]),
796
+ args: server.command.slice(1).map((arg) => resolveEnvPlaceholders(arg)),
710
797
  env,
798
+ stderr: "pipe",
711
799
  cwd: server.cwd ? resolveEnvPlaceholders(server.cwd) : undefined,
712
800
  })
801
+ transport.stderr?.on("data", () => {})
802
+ transport.stderr?.on("error", () => {})
713
803
  await withTimeout(client.connect(transport), timeout, `Connecting to ${serverName}`)
714
804
  return { client, transport, serverName, lastUsed: Date.now() }
715
805
  }
@@ -800,7 +890,7 @@ export default ((input: PluginInput) => {
800
890
  }
801
891
 
802
892
  async function refreshOnDemandMcpDescriptions(force = false, serverName?: string): Promise<{ refreshed: string[]; failed: string[] }> {
803
- const entries = Object.entries(config.onDemandMcpServers ?? {})
893
+ const entries = activeOnDemandMcpEntries()
804
894
  .filter(([name]) => !serverName || name.toLowerCase() === serverName.toLowerCase())
805
895
  .filter(([name]) => {
806
896
  const metadata = mcpMetadata[name]
@@ -870,7 +960,7 @@ export default ((input: PluginInput) => {
870
960
 
871
961
  function onDemandMcpStatusBuckets(): { loaded: string[]; failed: string[]; pending: string[] } {
872
962
  const buckets = { loaded: [] as string[], failed: [] as string[], pending: [] as string[] }
873
- for (const name of Object.keys(config.onDemandMcpServers ?? {})) {
963
+ for (const [name] of activeOnDemandMcpEntries()) {
874
964
  const status = onDemandMcpRuntimeStatus(name)
875
965
  if (status === "ready") buckets.loaded.push(name)
876
966
  else if (status === "failed") buckets.failed.push(name)
@@ -880,7 +970,7 @@ export default ((input: PluginInput) => {
880
970
  }
881
971
 
882
972
  function failedOnDemandMcpNames(names?: string[]): string[] {
883
- const configured = Object.keys(config.onDemandMcpServers ?? {})
973
+ const configured = activeOnDemandMcpEntries().map(([name]) => name)
884
974
  const wanted = names?.length ? names : configured
885
975
  return wanted.filter((name) => configured.includes(name) && onDemandMcpRuntimeStatus(name) === "failed")
886
976
  }
@@ -903,8 +993,9 @@ export default ((input: PluginInput) => {
903
993
  }
904
994
 
905
995
  function onDemandMcpStatus(): string {
906
- const entries = Object.entries(config.onDemandMcpServers ?? {})
907
- if (!entries.length) return "Raven on-demand MCPs: none configured."
996
+ const entries = activeOnDemandMcpEntries()
997
+ if (!Object.keys(config.onDemandMcpServers ?? {}).length) return "Raven on-demand MCPs: none configured."
998
+ if (!entries.length) return "Raven on-demand MCPs: none enabled."
908
999
  const buckets = onDemandMcpStatusBuckets()
909
1000
  const lines = entries.map(([name, server]) => {
910
1001
  const metadata = mcpMetadata[name]
@@ -920,7 +1011,7 @@ export default ((input: PluginInput) => {
920
1011
  }
921
1012
 
922
1013
  function avoidedMcpSchemaBytes(): number {
923
- return Object.keys(config.onDemandMcpServers ?? {}).reduce((sum, name) => {
1014
+ return activeOnDemandMcpEntries().reduce((sum, [name]) => {
924
1015
  const metadata = mcpMetadata[name]
925
1016
  if (onDemandMcpRuntimeStatus(name) !== "ready") return sum
926
1017
  return sum + Math.max(0, metadata?.avoidedSchemaBytes ?? 0)
@@ -1157,7 +1248,7 @@ export default ((input: PluginInput) => {
1157
1248
  globalMcpServers = uniqueStrings(Object.entries(configInput.mcp ?? {})
1158
1249
  .filter(([, value]) => !value || typeof value !== "object" || (value as any).enabled !== false)
1159
1250
  .map(([name]) => name))
1160
- const onDemandNames = new Set(Object.keys(config.onDemandMcpServers ?? {}).map((name) => name.toLowerCase()))
1251
+ const onDemandNames = new Set(activeOnDemandMcpEntries().map(([name]) => name.toLowerCase()))
1161
1252
  duplicateMcpServers = globalMcpServers.filter((name) => onDemandNames.has(name.toLowerCase()))
1162
1253
 
1163
1254
  // Inject MCP guidance as a startup instruction file (absolute path for npm compat).
@@ -1210,7 +1301,7 @@ export default ((input: PluginInput) => {
1210
1301
  },
1211
1302
  async execute(args, context) {
1212
1303
  const started = Date.now()
1213
- const timeout = (config.timeout ?? 180) * 1000
1304
+ const timeout = (config.timeout ?? 600) * 1000
1214
1305
  try {
1215
1306
  // Create a Raven session
1216
1307
  const session = await client.session.create({
@@ -1238,55 +1329,76 @@ export default ((input: PluginInput) => {
1238
1329
  appendFileSync(logFile, `${ts} ${sessionId} "${q}"\n`)
1239
1330
  } catch { /* non-fatal */ }
1240
1331
 
1241
- // Send the query to Raven with timeout
1242
- const result = await Promise.race([
1243
- client.session.prompt({
1244
- path: { id: sessionId },
1245
- body: {
1246
- agent: "raven",
1247
- parts: [{ type: "text", text: args.query }],
1248
- },
1249
- }),
1250
- new Promise<never>((_, reject) =>
1251
- setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1252
- ),
1253
- ])
1254
-
1255
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1256
-
1257
- // Extract text from the response
1258
- const parts = (result as any)?.data?.parts ?? []
1259
- const textParts = parts
1260
- .filter((p: any) => p.type === "text" && p.text)
1261
- .map((p: any) => p.text)
1262
- const output = textParts.join("\n") || "Raven returned no results."
1263
-
1264
- // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1265
- let savedCandidate = 0
1332
+ // Send the query to Raven with timeout. Provider/API errors are also
1333
+ // surfaced through message.updated events; fail as soon as one lands
1334
+ // so the caller does not wait for the full timeout on quota/auth issues.
1335
+ let timer: ReturnType<typeof setTimeout> | undefined
1336
+ const providerError = waitForRavenProviderError(sessionId)
1337
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1266
1338
  try {
1267
- savedCandidate = await countRavenSavedCandidateBytes(sessionId)
1268
- } catch { /* best-effort */ }
1269
- if (savedCandidate <= 0) {
1270
- for (const part of parts) {
1271
- if (part.args) savedCandidate += JSON.stringify(part.args).length
1272
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1339
+ const result = await Promise.race([
1340
+ client.session.prompt({
1341
+ path: { id: sessionId },
1342
+ body: {
1343
+ agent: "raven",
1344
+ parts: [{ type: "text", text: args.query }],
1345
+ },
1346
+ }),
1347
+ providerError,
1348
+ firstProgress,
1349
+ new Promise<never>((_, reject) => {
1350
+ timer = setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1351
+ }),
1352
+ ])
1353
+ if (timer) clearTimeout(timer)
1354
+ clearRavenWaiters(sessionId)
1355
+
1356
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1357
+
1358
+ // Extract text from the response
1359
+ const parts = (result as any)?.data?.parts ?? []
1360
+ const textParts = parts
1361
+ .filter((p: any) => p.type === "text" && p.text)
1362
+ .map((p: any) => p.text)
1363
+ const output = textParts.join("\n") || "Raven returned no results."
1364
+
1365
+ // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1366
+ let savedCandidate = 0
1367
+ try {
1368
+ savedCandidate = await countRavenSavedCandidateBytes(sessionId)
1369
+ } catch { /* best-effort */ }
1370
+ if (savedCandidate <= 0) {
1371
+ for (const part of parts) {
1372
+ if (part.args) savedCandidate += JSON.stringify(part.args).length
1373
+ if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1374
+ }
1273
1375
  }
1376
+ const saved = Math.max(0, savedCandidate - output.length)
1377
+ addBytes(saved)
1378
+ ravenProviderIssue = undefined
1379
+
1380
+ return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1381
+ } finally {
1382
+ if (timer) clearTimeout(timer)
1383
+ clearRavenWaiters(sessionId)
1274
1384
  }
1275
- const saved = Math.max(0, savedCandidate - output.length)
1276
- addBytes(saved)
1277
-
1278
- return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1279
1385
  } catch (err: any) {
1280
1386
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1281
1387
  const msg = String(err?.message ?? err ?? "").toLowerCase()
1282
1388
  const hint =
1283
1389
  /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1284
- : /quota|usage.?limit|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota exhausted — proceed without search, tell user what's missing."
1390
+ : /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."
1391
+ : /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."
1285
1392
  : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
1286
1393
  : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
1394
+ : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
1287
1395
  : /timeout|timed.?out|session kept/i.test(msg) ? err.message
1288
1396
  : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
1289
- return { title: "Raven Seek", output: `${hint}\n\n*Attempt took ${elapsed}s*` }
1397
+ if (/quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|auth|unauthorized|forbidden|401|403|api key|login|model|unavailable|down|not found|did not produce an initial response|retry dialog/i.test(msg)) {
1398
+ ravenProviderIssue = hint
1399
+ }
1400
+ const recovery = ravenProviderIssue ? `\n\n${ravenRecoveryInstructions()}` : ""
1401
+ return { title: "Raven Seek", output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*` }
1290
1402
  }
1291
1403
  },
1292
1404
  }),
@@ -1304,7 +1416,7 @@ export default ((input: PluginInput) => {
1304
1416
  }
1305
1417
 
1306
1418
  const operation = args.operation as RavenMcpOperation
1307
- const timeout = (config.timeout ?? 180) * 1000
1419
+ const timeout = (config.timeout ?? 600) * 1000
1308
1420
  let connected = false
1309
1421
  try {
1310
1422
  const connection = await getMcpConnection(context.sessionID, args.server)
@@ -1387,6 +1499,37 @@ export default ((input: PluginInput) => {
1387
1499
  if (evt?.type === "session.created" && evt?.properties?.parentID) {
1388
1500
  ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
1389
1501
  }
1502
+ if (evt?.type === "message.updated") {
1503
+ const info = evt.properties?.info
1504
+ if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
1505
+ reportRavenProviderError(info.sessionID, info.error)
1506
+ }
1507
+ }
1508
+ if (evt?.type === "message.part.updated") {
1509
+ const part = evt.properties?.part
1510
+ if (part?.sessionID && ravenSessions.has(part.sessionID)) {
1511
+ if (part.type === "retry" && part.error) {
1512
+ reportRavenProviderError(part.sessionID, part.error)
1513
+ } else {
1514
+ reportRavenProgress(part.sessionID)
1515
+ }
1516
+ }
1517
+ }
1518
+ if (evt?.type === "session.status") {
1519
+ const sessionId = evt.properties?.sessionID
1520
+ const status = evt.properties?.status
1521
+ if (sessionId && ravenSessions.has(sessionId)) {
1522
+ if (status?.type === "retry") {
1523
+ reportRavenProviderError(sessionId, { name: "RetryError", data: { message: status.message } })
1524
+ }
1525
+ }
1526
+ }
1527
+ if (evt?.type === "session.error") {
1528
+ const sessionId = evt.properties?.sessionID
1529
+ if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
1530
+ reportRavenProviderError(sessionId, evt.properties.error)
1531
+ }
1532
+ }
1390
1533
 
1391
1534
  if (updateToastPending) {
1392
1535
  updateToastPending = false
@@ -1487,6 +1630,7 @@ export default ((input: PluginInput) => {
1487
1630
  } else {
1488
1631
  config.model = model
1489
1632
  saveConfig(config)
1633
+ ravenProviderIssue = undefined
1490
1634
  output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
1491
1635
  }
1492
1636
  } else if (arg.startsWith("effort ")) {
@@ -1501,7 +1645,7 @@ export default ((input: PluginInput) => {
1501
1645
  } else if (arg.startsWith("timeout ")) {
1502
1646
  const secs = parseInt(raw.slice(8).trim(), 10)
1503
1647
  if (!secs || secs < 10) {
1504
- output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 180}s` })
1648
+ output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 600}s` })
1505
1649
  } else {
1506
1650
  config.timeout = secs
1507
1651
  saveConfig(config)
@@ -1511,7 +1655,8 @@ export default ((input: PluginInput) => {
1511
1655
  const enabled = config.enabled ? "enabled" : "disabled"
1512
1656
  const model = config.model || fm.model || "(default)"
1513
1657
  const effort = config.reasoning_effort || fm.reasoning_effort || "(default)"
1514
- const timeout = config.timeout ?? 180
1658
+ const timeout = config.timeout ?? 600
1659
+ const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
1515
1660
  let update = "Update: unable to check npm."
1516
1661
  try {
1517
1662
  const info = await getUpdateInfo()
@@ -1519,7 +1664,7 @@ export default ((input: PluginInput) => {
1519
1664
  ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1520
1665
  : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
1521
1666
  } catch { /* keep fallback */ }
1522
- output.parts.push({ type: "text", text: `Raven is ${enabled}. Version: ${PACKAGE_VERSION}. Model: ${model}. Reasoning: ${effort}. Timeout: ${timeout}s\n${update}\n\n${routeSummary()}\n\n${mcpSummary()}\n\n${statsText()}\n\nRun /raven help for commands.` })
1667
+ 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.` })
1523
1668
  }
1524
1669
  },
1525
1670
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "2.1.1",
4
- "description": "Raven is a context firewall for OpenCode. It routes noisy search, docs, web, GitHub, and MCP calls through a free focused agent before they hit your main model, saving cost and context!",
3
+ "version": "2.1.3",
4
+ "description": "Ready-to-use search tools and optional on-demand MCPs for OpenCode, routed through Raven to save your main model's context and cost.",
5
5
  "main": "./index.ts",
6
6
  "exports": {
7
7
  ".": "./index.ts"