opencode-raven 2.1.2 → 2.1.4

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 +4 -4
  2. package/index.ts +162 -62
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -188,7 +188,7 @@ Edit config manually or use `/raven` commands:
188
188
  },
189
189
  "excludeAgents": [],
190
190
  "excludeTools": [],
191
- "timeout": 180
191
+ "timeout": 600
192
192
  }
193
193
  ```
194
194
 
@@ -204,7 +204,7 @@ Edit config manually or use `/raven` commands:
204
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`. |
205
205
  | `excludeAgents` | `[]` | Agents that bypass Raven routing, case-insensitive. |
206
206
  | `excludeTools` | `[]` | Exact tools that never get blocked, even if matched by auto-routed global MCP prefixes. |
207
- | `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. |
208
208
  | `stats` | auto | Session and global estimated context saved by Raven. Managed automatically. |
209
209
 
210
210
  Use `ravenInstructions` for extra Raven-only guidance, such as how to use custom MCPs:
@@ -285,10 +285,10 @@ MCPs configured directly in OpenCode `opencode.jsonc` still load globally and ma
285
285
  | `chat.message` | Tracks agent to session mapping for allowlist and Raven exclusion. |
286
286
  | `event` | Shows startup update and MCP failure notifications after the TUI event stream is ready. |
287
287
  | `command.execute.before` | Handles `/raven help`, routing commands, MCP commands, update checks, model settings, timeout, and stats. |
288
- | `tool.execute.before` | Hard-blocks configured tools/MCPs for non-Raven, non-excluded agents. Error output gives the next `raven_seek(query="...")` call. Injects dynamic `<raven_guidance>` with configured routes into subagent prompts. |
288
+ | `tool.execute.before` | Hard-blocks configured tools/MCPs for non-Raven, non-excluded agents. Error output gives the next `raven_seek(query="...")` call or recovery instructions if Raven's provider is unavailable. Tracks direct Raven subagent calls for stats. |
289
289
  | `tool.execute.after` | Tracks direct `@Raven` calls for context-saved stats. |
290
290
 
291
- Every non-Raven, non-excluded subagent gets `<raven_guidance>` injected into its prompt at spawn time. The injected guidance includes `routeTools`, auto-detected global MCP prefixes, `routeToolKeywords`, and on-demand MCP capabilities.
291
+ Raven routing guidance is loaded through OpenCode instructions at startup. Subagent prompts are not modified; if a non-Raven agent calls a routed tool, Raven blocks it and returns the exact `raven_seek(query="...")` call to use.
292
292
 
293
293
  ## Agent Capabilities
294
294
 
package/index.ts CHANGED
@@ -203,7 +203,7 @@ const DEFAULT_CONFIG: RavenConfig = {
203
203
  onDemandMcpServers: DEFAULT_ON_DEMAND_MCP_SERVERS,
204
204
  excludeAgents: [],
205
205
  excludeTools: [],
206
- timeout: 180,
206
+ timeout: 600,
207
207
  }
208
208
 
209
209
  function parseRavenMd(raw: string): { frontmatter: Record<string, any>; prompt: string } {
@@ -494,6 +494,8 @@ export default ((input: PluginInput) => {
494
494
  const ravenTaskCalls = new Set<string>()
495
495
  const sessionAgents = new Map<string, string>()
496
496
  const ravenSessionParents = new Map<string, string>()
497
+ const ravenErrorWaiters = new Map<string, (error: Error) => void>()
498
+ const ravenProgressWaiters = new Map<string, () => void>()
497
499
  let globalMcpServers: string[] = []
498
500
  let duplicateMcpServers: string[] = []
499
501
  let updateInfo: { current: string; latest?: string; available: boolean } | undefined
@@ -504,6 +506,7 @@ export default ((input: PluginInput) => {
504
506
  let mcpRefreshScheduled = false
505
507
  let mcpRefreshPromise: Promise<{ refreshed: string[]; failed: string[] }> | undefined
506
508
  let lastNotifiedMcpFailureKey = ""
509
+ let ravenProviderIssue: string | undefined
507
510
 
508
511
  resetOnDemandMcpRuntimeStatuses()
509
512
 
@@ -528,13 +531,6 @@ export default ((input: PluginInput) => {
528
531
  return config.excludeAgents.some((a) => a.toLowerCase() === lower)
529
532
  }
530
533
 
531
- function ravenGuidance(): string {
532
- const tools = config.routeTools?.length ? config.routeTools.join(", ") : "none"
533
- const mcps = globalMcpServers.length ? globalMcpServers.map((server) => `${server}_*`).join(", ") : "none"
534
- const keywords = config.routeToolKeywords?.length ? config.routeToolKeywords.join(", ") : "none"
535
- return `Some tools/MCPs are routed through Raven to save context. Routed tools: ${tools}. Auto-routed global MCP prefixes: ${mcps}. Routed tool-name keywords: ${keywords}. ${onDemandMcpGuidance("delegate")} If one is blocked, your next tool call should be raven_seek(query="<same request>"). Include the original tool/MCP name and relevant arguments.`
536
- }
537
-
538
534
  function isRouteConfigured(toolName: string): boolean {
539
535
  const tool = toolName.toLowerCase()
540
536
  if (NEVER_ROUTE_TOOLS.has(tool)) return false
@@ -570,9 +566,26 @@ export default ((input: PluginInput) => {
570
566
  }
571
567
 
572
568
  function rerouteMessage(tool: string, args: any): string {
569
+ if (ravenProviderIssue) {
570
+ return `Raven is currently unavailable: ${ravenProviderIssue}
571
+
572
+ ${ravenRecoveryInstructions()}`
573
+ }
573
574
  return `The '${tool}' tool call is blocked by Raven. Your next tool call should be raven_seek(query="${attemptedQuery(tool, args).replace(/"/g, "'")}").`
574
575
  }
575
576
 
577
+ function ravenRecoveryInstructions(): string {
578
+ 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.
579
+
580
+ Tell the user to switch Raven's model with:
581
+ /raven model <provider/model>
582
+
583
+ Example:
584
+ /raven model openai/gpt-5.5
585
+
586
+ 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.`
587
+ }
588
+
576
589
  function routeSummary(): string {
577
590
  const tools = config.routeTools?.length ? config.routeTools.join(", ") : "(none)"
578
591
  const mcps = globalMcpServers.length ? `${globalMcpServers.join(", ")} (auto-detected)` : "none detected"
@@ -673,6 +686,49 @@ export default ((input: PluginInput) => {
673
686
  }
674
687
  }
675
688
 
689
+ function providerErrorMessage(error: any): string {
690
+ const name = String(error?.name ?? "")
691
+ const data = error?.data && typeof error.data === "object" ? error.data : {}
692
+ const status = typeof data.statusCode === "number" ? ` HTTP ${data.statusCode}.` : ""
693
+ const body = typeof data.responseBody === "string" && data.responseBody.trim() ? ` ${truncateText(data.responseBody, 500)}` : ""
694
+ const message = String(data.message ?? error?.message ?? error ?? "Unknown provider error")
695
+ return `Raven provider/API error${name ? ` (${name})` : ""}.${status} ${message}${body}`.trim()
696
+ }
697
+
698
+ function waitForRavenProviderError(sessionId: string): Promise<never> {
699
+ return new Promise((_resolve, reject) => {
700
+ ravenErrorWaiters.set(sessionId, reject)
701
+ })
702
+ }
703
+
704
+ function waitForRavenFirstProgress(sessionId: string, ms: number): Promise<never> {
705
+ return new Promise((_resolve, reject) => {
706
+ const timer = setTimeout(() => {
707
+ ravenProgressWaiters.delete(sessionId)
708
+ 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.`))
709
+ }, ms)
710
+ ravenProgressWaiters.set(sessionId, () => {
711
+ clearTimeout(timer)
712
+ ravenProgressWaiters.delete(sessionId)
713
+ })
714
+ })
715
+ }
716
+
717
+ function reportRavenProviderError(sessionId: string, error: any) {
718
+ const waiter = ravenErrorWaiters.get(sessionId)
719
+ if (!waiter) return
720
+ waiter(new Error(providerErrorMessage(error)))
721
+ }
722
+
723
+ function reportRavenProgress(sessionId: string) {
724
+ ravenProgressWaiters.get(sessionId)?.()
725
+ }
726
+
727
+ function clearRavenWaiters(sessionId: string) {
728
+ ravenErrorWaiters.delete(sessionId)
729
+ ravenProgressWaiters.get(sessionId)?.()
730
+ }
731
+
676
732
  type McpConnection = {
677
733
  client: Client
678
734
  transport: any
@@ -1238,7 +1294,7 @@ export default ((input: PluginInput) => {
1238
1294
  },
1239
1295
  async execute(args, context) {
1240
1296
  const started = Date.now()
1241
- const timeout = (config.timeout ?? 180) * 1000
1297
+ const timeout = (config.timeout ?? 600) * 1000
1242
1298
  try {
1243
1299
  // Create a Raven session
1244
1300
  const session = await client.session.create({
@@ -1266,55 +1322,76 @@ export default ((input: PluginInput) => {
1266
1322
  appendFileSync(logFile, `${ts} ${sessionId} "${q}"\n`)
1267
1323
  } catch { /* non-fatal */ }
1268
1324
 
1269
- // Send the query to Raven with timeout
1270
- const result = await Promise.race([
1271
- client.session.prompt({
1272
- path: { id: sessionId },
1273
- body: {
1274
- agent: "raven",
1275
- parts: [{ type: "text", text: args.query }],
1276
- },
1277
- }),
1278
- new Promise<never>((_, reject) =>
1279
- setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1280
- ),
1281
- ])
1282
-
1283
- const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1284
-
1285
- // Extract text from the response
1286
- const parts = (result as any)?.data?.parts ?? []
1287
- const textParts = parts
1288
- .filter((p: any) => p.type === "text" && p.text)
1289
- .map((p: any) => p.text)
1290
- const output = textParts.join("\n") || "Raven returned no results."
1291
-
1292
- // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1293
- let savedCandidate = 0
1325
+ // Send the query to Raven with timeout. Provider/API errors are also
1326
+ // surfaced through message.updated events; fail as soon as one lands
1327
+ // so the caller does not wait for the full timeout on quota/auth issues.
1328
+ let timer: ReturnType<typeof setTimeout> | undefined
1329
+ const providerError = waitForRavenProviderError(sessionId)
1330
+ const firstProgress = waitForRavenFirstProgress(sessionId, Math.min(timeout, 30_000))
1294
1331
  try {
1295
- savedCandidate = await countRavenSavedCandidateBytes(sessionId)
1296
- } catch { /* best-effort */ }
1297
- if (savedCandidate <= 0) {
1298
- for (const part of parts) {
1299
- if (part.args) savedCandidate += JSON.stringify(part.args).length
1300
- if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1332
+ const result = await Promise.race([
1333
+ client.session.prompt({
1334
+ path: { id: sessionId },
1335
+ body: {
1336
+ agent: "raven",
1337
+ parts: [{ type: "text", text: args.query }],
1338
+ },
1339
+ }),
1340
+ providerError,
1341
+ firstProgress,
1342
+ new Promise<never>((_, reject) => {
1343
+ timer = setTimeout(() => reject(new Error(`Raven timed out after ${timeout / 1000}s — session kept: ${sessionId}`)), timeout)
1344
+ }),
1345
+ ])
1346
+ if (timer) clearTimeout(timer)
1347
+ clearRavenWaiters(sessionId)
1348
+
1349
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1350
+
1351
+ // Extract text from the response
1352
+ const parts = (result as any)?.data?.parts ?? []
1353
+ const textParts = parts
1354
+ .filter((p: any) => p.type === "text" && p.text)
1355
+ .map((p: any) => p.text)
1356
+ const output = textParts.join("\n") || "Raven returned no results."
1357
+
1358
+ // Context saved = Raven output/reasoning/cache context - compact answer returned to main session.
1359
+ let savedCandidate = 0
1360
+ try {
1361
+ savedCandidate = await countRavenSavedCandidateBytes(sessionId)
1362
+ } catch { /* best-effort */ }
1363
+ if (savedCandidate <= 0) {
1364
+ for (const part of parts) {
1365
+ if (part.args) savedCandidate += JSON.stringify(part.args).length
1366
+ if (part.content) savedCandidate += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content).length
1367
+ }
1301
1368
  }
1369
+ const saved = Math.max(0, savedCandidate - output.length)
1370
+ addBytes(saved)
1371
+ ravenProviderIssue = undefined
1372
+
1373
+ return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1374
+ } finally {
1375
+ if (timer) clearTimeout(timer)
1376
+ clearRavenWaiters(sessionId)
1302
1377
  }
1303
- const saved = Math.max(0, savedCandidate - output.length)
1304
- addBytes(saved)
1305
-
1306
- return { title: "Raven Seek", metadata: { sessionId }, output: `${output}\n\n*Raven searched for ${elapsed}s — ${formatBytes(savedCandidate)} handled, ${formatTokens(savedCandidate)} tokens*` }
1307
1378
  } catch (err: any) {
1308
1379
  const elapsed = ((Date.now() - started) / 1000).toFixed(1)
1309
1380
  const msg = String(err?.message ?? err ?? "").toLowerCase()
1310
1381
  const hint =
1311
1382
  /rate.?limit|too many requests|429/i.test(msg) ? "Raven rate limited — wait 30s then retry with a narrower query."
1312
- : /quota|usage.?limit|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota exhausted — proceed without search, tell user what's missing."
1383
+ : /quota|usage.?limit|usage exceeded|free usage|subscribe to go|subscription|billing|insufficient.*(?:credit|balance|quota)/i.test(msg) ? "Raven API quota/subscription exhausted — proceed without Raven, tell the user Raven could not run, and mention what information may be missing."
1384
+ : /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."
1313
1385
  : /token|context.?length|too large|too long/i.test(msg) ? "Raven query too large — shorten your query and retry."
1314
1386
  : /model|unavailable|down|not found/i.test(msg) ? "Raven model unavailable — retry later, or proceed without search."
1387
+ : /did not produce an initial response|retry dialog/i.test(msg) ? err.message
1315
1388
  : /timeout|timed.?out|session kept/i.test(msg) ? err.message
1316
1389
  : `Raven search failed. Proceed without search — note gaps for the user. [${err.message || err}]`
1317
- return { title: "Raven Seek", output: `${hint}\n\n*Attempt took ${elapsed}s*` }
1390
+ 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)) {
1391
+ ravenProviderIssue = hint
1392
+ }
1393
+ const recovery = ravenProviderIssue ? `\n\n${ravenRecoveryInstructions()}` : ""
1394
+ return { title: "Raven Seek", output: `${hint}${recovery}\n\n*Attempt took ${elapsed}s*` }
1318
1395
  }
1319
1396
  },
1320
1397
  }),
@@ -1332,7 +1409,7 @@ export default ((input: PluginInput) => {
1332
1409
  }
1333
1410
 
1334
1411
  const operation = args.operation as RavenMcpOperation
1335
- const timeout = (config.timeout ?? 180) * 1000
1412
+ const timeout = (config.timeout ?? 600) * 1000
1336
1413
  let connected = false
1337
1414
  try {
1338
1415
  const connection = await getMcpConnection(context.sessionID, args.server)
@@ -1415,6 +1492,37 @@ export default ((input: PluginInput) => {
1415
1492
  if (evt?.type === "session.created" && evt?.properties?.parentID) {
1416
1493
  ravenSessionParents.set(evt.properties.parentID, evt.properties.id)
1417
1494
  }
1495
+ if (evt?.type === "message.updated") {
1496
+ const info = evt.properties?.info
1497
+ if (info?.role === "assistant" && info?.sessionID && info.error && ravenSessions.has(info.sessionID)) {
1498
+ reportRavenProviderError(info.sessionID, info.error)
1499
+ }
1500
+ }
1501
+ if (evt?.type === "message.part.updated") {
1502
+ const part = evt.properties?.part
1503
+ if (part?.sessionID && ravenSessions.has(part.sessionID)) {
1504
+ if (part.type === "retry" && part.error) {
1505
+ reportRavenProviderError(part.sessionID, part.error)
1506
+ } else {
1507
+ reportRavenProgress(part.sessionID)
1508
+ }
1509
+ }
1510
+ }
1511
+ if (evt?.type === "session.status") {
1512
+ const sessionId = evt.properties?.sessionID
1513
+ const status = evt.properties?.status
1514
+ if (sessionId && ravenSessions.has(sessionId)) {
1515
+ if (status?.type === "retry") {
1516
+ reportRavenProviderError(sessionId, { name: "RetryError", data: { message: status.message } })
1517
+ }
1518
+ }
1519
+ }
1520
+ if (evt?.type === "session.error") {
1521
+ const sessionId = evt.properties?.sessionID
1522
+ if (sessionId && evt.properties?.error && ravenSessions.has(sessionId)) {
1523
+ reportRavenProviderError(sessionId, evt.properties.error)
1524
+ }
1525
+ }
1418
1526
 
1419
1527
  if (updateToastPending) {
1420
1528
  updateToastPending = false
@@ -1515,6 +1623,7 @@ export default ((input: PluginInput) => {
1515
1623
  } else {
1516
1624
  config.model = model
1517
1625
  saveConfig(config)
1626
+ ravenProviderIssue = undefined
1518
1627
  output.parts.push({ type: "text", text: `Raven model set to: ${model}\nRestart opencode for the change to take effect.` })
1519
1628
  }
1520
1629
  } else if (arg.startsWith("effort ")) {
@@ -1529,7 +1638,7 @@ export default ((input: PluginInput) => {
1529
1638
  } else if (arg.startsWith("timeout ")) {
1530
1639
  const secs = parseInt(raw.slice(8).trim(), 10)
1531
1640
  if (!secs || secs < 10) {
1532
- output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 180}s` })
1641
+ output.parts.push({ type: "text", text: `Usage: /raven timeout <seconds>\nMust be at least 10. Current: ${config.timeout ?? 600}s` })
1533
1642
  } else {
1534
1643
  config.timeout = secs
1535
1644
  saveConfig(config)
@@ -1539,7 +1648,8 @@ export default ((input: PluginInput) => {
1539
1648
  const enabled = config.enabled ? "enabled" : "disabled"
1540
1649
  const model = config.model || fm.model || "(default)"
1541
1650
  const effort = config.reasoning_effort || fm.reasoning_effort || "(default)"
1542
- const timeout = config.timeout ?? 180
1651
+ const timeout = config.timeout ?? 600
1652
+ const providerIssue = ravenProviderIssue ? `\nProvider issue: ${ravenProviderIssue}` : ""
1543
1653
  let update = "Update: unable to check npm."
1544
1654
  try {
1545
1655
  const info = await getUpdateInfo()
@@ -1547,7 +1657,7 @@ export default ((input: PluginInput) => {
1547
1657
  ? `Update: ${info.latest} available. Run /raven update, then restart opencode.`
1548
1658
  : `Update: up to date${info.latest ? ` (latest ${info.latest})` : ""}.`
1549
1659
  } catch { /* keep fallback */ }
1550
- 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.` })
1660
+ 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.` })
1551
1661
  }
1552
1662
  },
1553
1663
 
@@ -1565,18 +1675,8 @@ export default ((input: PluginInput) => {
1565
1675
  if (isExcluded(sessionAgents.get(input.sessionID))) return
1566
1676
  if (config.excludeTools?.some((name) => name.toLowerCase() === input.tool.toLowerCase())) return
1567
1677
 
1568
- // ── Subagent prompt injection: inject Raven guidance into every subagent ──
1569
- if ((input.tool === "task" || input.tool === "subtask") && output.args) {
1570
- const subagentType = input.tool === "task" ? (output.args.subagent_type ?? "") : ""
1571
- if (subagentType === "raven") {
1572
- ravenTaskCalls.add(input.callID)
1573
- }
1574
- if (subagentType !== "raven" && !isExcluded(subagentType)) {
1575
- const field = ["prompt", "description", "request", "objective", "query"].find(
1576
- (f) => f in output.args
1577
- ) ?? "prompt"
1578
- output.args[field] = `${output.args[field] ?? ""}\n\n<raven_guidance>\n${ravenGuidance()}\n</raven_guidance>`
1579
- }
1678
+ if (input.tool === "task" && output.args?.subagent_type === "raven") {
1679
+ ravenTaskCalls.add(input.callID)
1580
1680
  }
1581
1681
 
1582
1682
  // ── Block routed tools/MCPs for non-Raven agents ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-raven",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
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": {