opencode-raven 2.3.0 → 2.3.1

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 (7) hide show
  1. package/CHANGELOG.md +90 -81
  2. package/README.md +7 -1
  3. package/package.json +26 -20
  4. package/raven-core.ts +1703 -1679
  5. package/rpc.ts +18 -0
  6. package/tui.ts +65 -3
  7. package/v2.ts +345 -323
package/v2.ts CHANGED
@@ -1,331 +1,353 @@
1
- import type { Plugin, Agent } from "@opencode/plugin"
2
- import type { PluginInput, Hooks, ToolContext as V1ToolContext } from "@opencode-ai/plugin"
3
- import type { SessionMessageInfo, SessionInfo } from "@opencode/client"
4
- import { z } from "zod"
5
- import createRavenRuntime from "./raven-core"
6
- import { RavenRpc } from "./rpc"
7
-
8
- type Context = Plugin.Context
9
- type RequestOptions = NonNullable<Parameters<Context["session"]["get"]>[1]>
10
- type Runtime = Awaited<ReturnType<typeof createRavenRuntime>>
11
- type Registration = { dispose(): Promise<void> }
12
- type Call = { path?: { id: string }; body?: Record<string, any>; signal?: AbortSignal }
13
- type AgentDefinition = {
14
- description: string
15
- mode: "subagent"
16
- hidden: boolean
17
- disable: boolean
18
- model?: string
19
- options: Record<string, unknown>
20
- permission: Record<string, "allow" | "deny" | "ask">
21
- prompt: string
22
- }
23
-
24
- function modelRef(value: string): NonNullable<Agent.Info["model"]> {
25
- const slash = value.indexOf("/")
26
- if (slash < 1 || slash === value.length - 1) throw new Error(`Invalid Raven model: ${value}`)
27
- const [id, variant] = value.slice(slash + 1).split("#")
28
- if (!id || value.slice(0, slash).includes("#") || value.split("#").length > 2 || (variant !== undefined && !variant)) throw new Error(`Invalid Raven model: ${value}`)
29
- return { providerID: value.slice(0, slash), id, ...(variant ? { variant } : {}) } as NonNullable<Agent.Info["model"]>
30
- }
31
-
32
- function legacyError(error: { type: string; message: string; status?: number; response?: { body: string } }) {
33
- return { name: error.type, data: { message: error.message, statusCode: error.status, responseBody: error.response?.body } }
34
- }
35
-
36
- // V2 projects messages directly, rather than wrapping them in { info, parts }.
37
- // Preserve agent switches when validating persisted continuation history.
38
- function legacyMessages(messages: readonly SessionMessageInfo[], session: SessionInfo) {
39
- let agent = typeof session.metadata?.opencodeRavenAgent === "string"
40
- ? session.metadata.opencodeRavenAgent : session.agent
41
- const currentAgentMatches = session.agent === agent
42
- return messages.map((message) => {
43
- if (message.type === "agent-switched") agent = message.agent
44
- if (message.type === "assistant") {
45
- return { info: { ...message, role: "assistant" }, parts: message.content }
46
- }
47
- return {
48
- info: { ...message, role: message.type, agent: currentAgentMatches ? agent : session.agent },
49
- parts: message.type === "user" ? [{ type: "text", text: message.text }] : [],
50
- }
51
- })
52
- }
53
-
54
- export async function setupRaven(ctx: Context): Promise<Plugin.Cleanup> {
55
- const registrations: Registration[] = []
56
- const controller = new AbortController()
57
- const location = { directory: ctx.location.directory }
58
- const agents: Record<string, AgentDefinition> = {}
59
- const activeSessions = new Set<string>()
60
- let emitNotice: (notice: z.infer<typeof RavenRpc.events.notice.schema>) => Promise<void> = async () => {}
61
- let runtime: Runtime | undefined
62
- let startupTimer: ReturnType<typeof setTimeout> | undefined
63
- let eventPump: Promise<void> | undefined
64
- let disposed = false
65
-
66
- const requestOptions = (call: Call): RequestOptions => ({ signal: call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal })
67
-
68
- // Adapt only the host boundary. Raven's MCP transports, state, timeout logic,
69
- // command parser, and continuation validation remain shared with v1.
70
- const client = {
71
- provider: {
72
- async list(call: Call) {
73
- const response = await ctx.model.list({ location }, requestOptions(call))
74
- const models = response.data.filter((model) => model.providerID === "opencode" && model.enabled)
75
- return { data: {
76
- connected: models.length ? ["opencode"] : [],
77
- all: [{ id: "opencode", models: Object.fromEntries(models.map((model) => [model.id, {
78
- id: model.id,
79
- status: model.status,
80
- release_date: new Date(model.time.released).toISOString(),
81
- tool_call: model.capabilities.tools,
82
- cost: model.cost.length && model.cost.every((tier) => tier.input === 0 && tier.output === 0)
83
- ? { input: 0, output: 0 } : { input: 1, output: 1 },
84
- limit: model.limit,
85
- }])) }],
86
- } }
87
- },
88
- },
89
- session: {
90
- async get(call: Call) {
91
- return { data: await ctx.session.get({ sessionID: call.path!.id }, requestOptions(call)) }
92
- },
93
- async create(call: Call) {
94
- const agent = String(call.body!.agent)
95
- const definition = agents[agent]
96
- if (!definition?.model) throw new Error(`Raven agent ${agent} has no configured model`)
97
- return { data: await ctx.session.create({
98
- parentID: call.body!.parentID,
99
- title: call.body!.title,
100
- agent,
101
- model: modelRef(definition.model),
102
- location,
103
- metadata: { opencodeRavenAgent: agent },
104
- }, requestOptions(call)) }
105
- },
106
- async update(call: Call) {
107
- await ctx.session.update({ sessionID: call.path!.id, title: call.body!.title }, requestOptions(call))
108
- },
109
- async messages(call: Call) {
110
- const sessionID = call.path!.id
111
- const options = requestOptions(call)
112
- const [messages, session] = await Promise.all([
113
- ctx.session.context({ sessionID }, options), ctx.session.get({ sessionID }, options),
114
- ])
115
- return { data: legacyMessages(messages, session) }
116
- },
117
- async abort(call: Call) {
118
- await ctx.session.interrupt({ sessionID: call.path!.id, resume: false }, requestOptions(call))
119
- },
120
- async prompt(call: Call) {
121
- const sessionID = call.path!.id
122
- const options = requestOptions(call)
123
- const session = await ctx.session.get({ sessionID }, options)
124
- if (session.agent !== call.body!.agent) throw new Error("Raven session agent changed; start a new retrieval")
125
- activeSessions.add(sessionID)
126
- try {
127
- const previousIDs = new Set((await ctx.session.context({ sessionID }, options)).map((message) => message.id))
128
- const admitted = await ctx.session.prompt({
129
- sessionID,
130
- text: call.body!.parts.map((part: { text: string }) => part.text).join("\n"),
131
- delivery: "queue",
132
- }, options)
133
- // Admission is not completion. Wait for idle, then read only this turn's
134
- // final assistant message; a follow-up must never return an older answer.
135
- await ctx.session.wait({ sessionID }, options)
136
- options.signal?.throwIfAborted()
137
- const [messages, completed] = await Promise.all([
138
- ctx.session.context({ sessionID }, options), ctx.session.get({ sessionID }, options),
139
- ])
140
- const boundary = messages.findIndex((message) => message.id === admitted.id)
141
- // Compaction may remove the admitted user message from active context.
142
- // In that case, only newly projected messages can belong to this turn.
143
- const candidates = boundary >= 0 ? messages.slice(boundary + 1) : messages.filter((message) => !previousIDs.has(message.id))
144
- const nextUser = candidates.findIndex((message) => message.type === "user")
145
- const turn = nextUser < 0 ? candidates : candidates.slice(0, nextUser)
146
- const failed = turn.find((message) => message.type === "assistant" && (message.error || message.retry))
147
- if (failed?.type === "assistant") throw new Error(JSON.stringify(legacyError(failed.error ?? failed.retry!.error)))
148
- if (completed.outcome === "failed" || completed.outcome === "interrupted") {
149
- throw new Error(`Raven execution ${completed.outcome}; no completed answer is available`)
150
- }
151
- const answer = turn.filter((message) => message.type === "assistant").at(-1)
152
- if (answer?.type !== "assistant") {
153
- throw new Error(`Raven finished without an assistant answer (${completed.outcome ?? "unknown outcome"})`)
154
- }
155
- return { data: { parts: answer.content } }
156
- } finally {
157
- activeSessions.delete(sessionID)
158
- }
159
- },
160
- },
161
- tui: {
162
- async showToast({ body }: { body: { title?: string; message: string; variant?: "info" | "warning"; duration?: number } }) {
163
- if (disposed) return
164
- await emitNotice({ kind: "toast", ...body })
165
- },
166
- },
167
- }
168
-
169
- const cleanup = async () => {
170
- if (disposed) return
171
- disposed = true
172
- const running = [...activeSessions]
173
- controller.abort()
174
- if (startupTimer) clearTimeout(startupTimer)
175
- await Promise.allSettled(running.map((sessionID) => ctx.session.interrupt({ sessionID, resume: false }, { signal: AbortSignal.timeout(5000) })))
176
- await Promise.allSettled(registrations.reverse().map((registration) => registration.dispose()))
177
- await runtime?.dispose()
178
- await eventPump
179
- }
180
-
181
- try {
182
- runtime = await createRavenRuntime({
183
- client: client as unknown as PluginInput["client"],
184
- directory: ctx.location.directory,
185
- project: { id: ctx.location.project.id },
186
- }, "v2")
187
- const raven = runtime
188
- const mcp = await ctx.mcp.list({ location }, { signal: controller.signal })
189
- const config = {
190
- mcp: Object.fromEntries(mcp.data.map((server) => [server.name, { enabled: server.status.status !== "disabled" }])),
191
- agent: agents,
192
- instructions: [] as string[],
193
- }
194
- await raven.config(config)
195
- registrations.push(await ctx.mcp.transform((editor) => {
196
- raven.globalMcpRouting(editor.list().filter(([, server]) => !server.disabled).map(([name]) => name))
197
- }))
198
-
199
- async function executeCommand(raw: string) {
200
- const output = { parts: [] as Array<{ type: string; text: string }> }
201
- await raven["command.execute.before"]({ command: "raven", arguments: raw, sessionID: "" }, output)
202
- return { text: output.parts.map((part) => part.text).join("\n") }
203
- }
204
- const rpc = await ctx.rpc.register(RavenRpc, { command: async ({ arguments: raw }) => executeCommand(raw) })
205
- registrations.push(rpc)
1
+ import type { Plugin, Agent } from "@opencode/plugin"
2
+ import type { PluginInput, Hooks, ToolContext as V1ToolContext } from "@opencode-ai/plugin"
3
+ import type { SessionMessageInfo, SessionInfo } from "@opencode/client"
4
+ import { z } from "zod"
5
+ import createRavenRuntime from "./raven-core"
6
+ import { RavenRpc, mcpRefreshArguments, mcpRefreshToast } from "./rpc"
7
+
8
+ type Context = Plugin.Context
9
+ type RequestOptions = NonNullable<Parameters<Context["session"]["get"]>[1]>
10
+ type Runtime = Awaited<ReturnType<typeof createRavenRuntime>>
11
+ type Registration = { dispose(): Promise<void> }
12
+ type Call = { path?: { id: string }; body?: Record<string, any>; signal?: AbortSignal }
13
+ type AgentDefinition = {
14
+ description: string
15
+ mode: "subagent"
16
+ hidden: boolean
17
+ disable: boolean
18
+ model?: string
19
+ options: Record<string, unknown>
20
+ permission: Record<string, "allow" | "deny" | "ask">
21
+ prompt: string
22
+ }
23
+
24
+ function modelRef(value: string): NonNullable<Agent.Info["model"]> {
25
+ const slash = value.indexOf("/")
26
+ if (slash < 1 || slash === value.length - 1) throw new Error(`Invalid Raven model: ${value}`)
27
+ const [id, variant] = value.slice(slash + 1).split("#")
28
+ if (!id || value.slice(0, slash).includes("#") || value.split("#").length > 2 || (variant !== undefined && !variant)) throw new Error(`Invalid Raven model: ${value}`)
29
+ return { providerID: value.slice(0, slash), id, ...(variant ? { variant } : {}) } as NonNullable<Agent.Info["model"]>
30
+ }
31
+
32
+ function legacyError(error: { type: string; message: string; status?: number; response?: { body: string } }) {
33
+ return { name: error.type, data: { message: error.message, statusCode: error.status, responseBody: error.response?.body } }
34
+ }
35
+
36
+ // V2 projects messages directly, rather than wrapping them in { info, parts }.
37
+ // Preserve agent switches when validating persisted continuation history.
38
+ function legacyMessages(messages: readonly SessionMessageInfo[], session: SessionInfo) {
39
+ let agent = typeof session.metadata?.opencodeRavenAgent === "string"
40
+ ? session.metadata.opencodeRavenAgent : session.agent
41
+ const currentAgentMatches = session.agent === agent
42
+ return messages.map((message) => {
43
+ if (message.type === "agent-switched") agent = message.agent
44
+ if (message.type === "assistant") {
45
+ return { info: { ...message, role: "assistant" }, parts: message.content }
46
+ }
47
+ return {
48
+ info: { ...message, role: message.type, agent: currentAgentMatches ? agent : session.agent },
49
+ parts: message.type === "user" ? [{ type: "text", text: message.text }] : [],
50
+ }
51
+ })
52
+ }
53
+
54
+ export async function setupRaven(ctx: Context): Promise<Plugin.Cleanup> {
55
+ const registrations: Registration[] = []
56
+ const controller = new AbortController()
57
+ const location = { directory: ctx.location.directory }
58
+ const agents: Record<string, AgentDefinition> = {}
59
+ const activeSessions = new Set<string>()
60
+ let emitNotice: (notice: z.infer<typeof RavenRpc.events.notice.schema>) => Promise<void> = async () => {}
61
+ let runtime: Runtime | undefined
62
+ let startupTimer: ReturnType<typeof setTimeout> | undefined
63
+ let eventPump: Promise<void> | undefined
64
+ let disposed = false
65
+
66
+ const requestOptions = (call: Call): RequestOptions => ({ signal: call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal })
67
+
68
+ // Adapt only the host boundary. Raven's MCP transports, state, timeout logic,
69
+ // command parser, and continuation validation remain shared with v1.
70
+ const client = {
71
+ provider: {
72
+ async list(call: Call) {
73
+ const response = await ctx.model.list({ location }, requestOptions(call))
74
+ const models = response.data.filter((model) => model.providerID === "opencode" && model.enabled)
75
+ return { data: {
76
+ connected: models.length ? ["opencode"] : [],
77
+ all: [{ id: "opencode", models: Object.fromEntries(models.map((model) => [model.id, {
78
+ id: model.id,
79
+ status: model.status,
80
+ release_date: new Date(model.time.released).toISOString(),
81
+ tool_call: model.capabilities.tools,
82
+ cost: model.cost.length && model.cost.every((tier) => tier.input === 0 && tier.output === 0)
83
+ ? { input: 0, output: 0 } : { input: 1, output: 1 },
84
+ limit: model.limit,
85
+ }])) }],
86
+ } }
87
+ },
88
+ },
89
+ session: {
90
+ async get(call: Call) {
91
+ return { data: await ctx.session.get({ sessionID: call.path!.id }, requestOptions(call)) }
92
+ },
93
+ async create(call: Call) {
94
+ const agent = String(call.body!.agent)
95
+ const definition = agents[agent]
96
+ if (!definition?.model) throw new Error(`Raven agent ${agent} has no configured model`)
97
+ return { data: await ctx.session.create({
98
+ parentID: call.body!.parentID,
99
+ title: call.body!.title,
100
+ agent,
101
+ model: modelRef(definition.model),
102
+ location,
103
+ metadata: { opencodeRavenAgent: agent },
104
+ }, requestOptions(call)) }
105
+ },
106
+ async update(call: Call) {
107
+ await ctx.session.update({ sessionID: call.path!.id, title: call.body!.title }, requestOptions(call))
108
+ },
109
+ async messages(call: Call) {
110
+ const sessionID = call.path!.id
111
+ const options = requestOptions(call)
112
+ const [messages, session] = await Promise.all([
113
+ ctx.session.context({ sessionID }, options), ctx.session.get({ sessionID }, options),
114
+ ])
115
+ return { data: legacyMessages(messages, session) }
116
+ },
117
+ async abort(call: Call) {
118
+ await ctx.session.interrupt({ sessionID: call.path!.id, resume: false }, requestOptions(call))
119
+ },
120
+ async prompt(call: Call) {
121
+ const sessionID = call.path!.id
122
+ const options = requestOptions(call)
123
+ const session = await ctx.session.get({ sessionID }, options)
124
+ if (session.agent !== call.body!.agent) throw new Error("Raven session agent changed; start a new retrieval")
125
+ activeSessions.add(sessionID)
126
+ try {
127
+ const previousIDs = new Set((await ctx.session.context({ sessionID }, options)).map((message) => message.id))
128
+ const admitted = await ctx.session.prompt({
129
+ sessionID,
130
+ text: call.body!.parts.map((part: { text: string }) => part.text).join("\n"),
131
+ delivery: "queue",
132
+ }, options)
133
+ // Admission is not completion. Wait for idle, then read only this turn's
134
+ // final assistant message; a follow-up must never return an older answer.
135
+ await ctx.session.wait({ sessionID }, options)
136
+ options.signal?.throwIfAborted()
137
+ const [messages, completed] = await Promise.all([
138
+ ctx.session.context({ sessionID }, options), ctx.session.get({ sessionID }, options),
139
+ ])
140
+ const boundary = messages.findIndex((message) => message.id === admitted.id)
141
+ // Compaction may remove the admitted user message from active context.
142
+ // In that case, only newly projected messages can belong to this turn.
143
+ const candidates = boundary >= 0 ? messages.slice(boundary + 1) : messages.filter((message) => !previousIDs.has(message.id))
144
+ const nextUser = candidates.findIndex((message) => message.type === "user")
145
+ const turn = nextUser < 0 ? candidates : candidates.slice(0, nextUser)
146
+ const failed = turn.find((message) => message.type === "assistant" && (message.error || message.retry))
147
+ if (failed?.type === "assistant") throw new Error(JSON.stringify(legacyError(failed.error ?? failed.retry!.error)))
148
+ if (completed.outcome === "failed" || completed.outcome === "interrupted") {
149
+ throw new Error(`Raven execution ${completed.outcome}; no completed answer is available`)
150
+ }
151
+ const answer = turn.filter((message) => message.type === "assistant").at(-1)
152
+ if (answer?.type !== "assistant") {
153
+ throw new Error(`Raven finished without an assistant answer (${completed.outcome ?? "unknown outcome"})`)
154
+ }
155
+ return { data: { parts: answer.content } }
156
+ } finally {
157
+ activeSessions.delete(sessionID)
158
+ }
159
+ },
160
+ },
161
+ tui: {
162
+ async showToast({ body }: { body: { title?: string; message: string; variant?: "info" | "warning"; duration?: number } }) {
163
+ if (disposed) return
164
+ await emitNotice({ kind: "toast", ...body })
165
+ },
166
+ },
167
+ }
168
+
169
+ const cleanup = async () => {
170
+ if (disposed) return
171
+ disposed = true
172
+ const running = [...activeSessions]
173
+ controller.abort()
174
+ if (startupTimer) clearTimeout(startupTimer)
175
+ await Promise.allSettled(running.map((sessionID) => ctx.session.interrupt({ sessionID, resume: false }, { signal: AbortSignal.timeout(5000) })))
176
+ await Promise.allSettled(registrations.reverse().map((registration) => registration.dispose()))
177
+ await runtime?.dispose()
178
+ await eventPump
179
+ }
180
+
181
+ try {
182
+ runtime = await createRavenRuntime({
183
+ client: client as unknown as PluginInput["client"],
184
+ directory: ctx.location.directory,
185
+ project: { id: ctx.location.project.id },
186
+ }, "v2")
187
+ const raven = runtime
188
+ const mcp = await ctx.mcp.list({ location }, { signal: controller.signal })
189
+ const config = {
190
+ mcp: Object.fromEntries(mcp.data.map((server) => [server.name, { enabled: server.status.status !== "disabled" }])),
191
+ agent: agents,
192
+ instructions: [] as string[],
193
+ }
194
+ await raven.config(config)
195
+ registrations.push(await ctx.mcp.transform((editor) => {
196
+ raven.globalMcpRouting(editor.list().filter(([, server]) => !server.disabled).map(([name]) => name))
197
+ }))
198
+
199
+ async function executeCommand(raw: string) {
200
+ const output = { parts: [] as Array<{ type: string; text: string }> }
201
+ await raven["command.execute.before"]({ command: "raven", arguments: raw, sessionID: "" }, output)
202
+ return { text: output.parts.map((part) => part.text).join("\n") }
203
+ }
204
+ const rpc = await ctx.rpc.register(RavenRpc, {
205
+ command: async ({ arguments: raw }) => executeCommand(raw),
206
+ refreshMcps: async ({ serverName }) => raven.refreshMcps(serverName),
207
+ })
208
+ registrations.push(rpc)
206
209
  emitNotice = (notice) => rpc.events.emit("notice", notice)
207
210
 
208
- registrations.push(await ctx.agent.transform((editor) => {
209
- for (const [id, definition] of Object.entries(agents)) {
210
- if (definition.disable) { editor.remove(id); continue }
211
- // The shipped registry upserts on update, including previously absent IDs.
212
- editor.update(id, (agent) => {
213
- agent.name = id as Agent.Name
214
- agent.description = definition.description
215
- agent.mode = definition.mode
216
- agent.hidden = definition.hidden
217
- agent.model = definition.model ? modelRef(definition.model) : undefined
218
- agent.system = definition.prompt
219
- agent.request.body = { ...definition.options }
220
- agent.permissions = Object.entries(definition.permission).map(([action, effect]) => ({
221
- action: action === "bash" ? "shell" : action === "task" ? "subagent" : action,
222
- resource: "*", effect,
223
- }))
224
- })
225
- }
226
- }))
227
-
228
- const tools: NonNullable<Hooks["tool"]> = raven.tool
229
- registrations.push(await ctx.tool.transform((editor) => {
230
- for (const [name, definition] of Object.entries(tools)) {
231
- const schema = z.object(definition.args)
232
- editor.add({
233
- name, description: definition.description,
234
- input: z.toJSONSchema(schema),
235
- options: { permission: name, codemode: false },
236
- async execute(input, context) {
237
- await raven["chat.message"]({ sessionID: context.sessionID, agent: context.agent }, {})
238
- await raven["tool.execute.before"]({ tool: name, sessionID: context.sessionID, callID: context.id }, { args: input })
239
- let metadata: Record<string, unknown> = {}
240
- let progress = Promise.resolve()
241
- const legacyContext: V1ToolContext = {
242
- sessionID: context.sessionID, messageID: context.messageID, agent: context.agent,
243
- directory: ctx.location.directory, worktree: ctx.location.project.directory,
244
- abort: AbortSignal.any([context.signal, controller.signal]),
245
- metadata(update) {
246
- metadata = { ...metadata, ...update.metadata, ...(update.title ? { title: update.title } : {}) }
247
- progress = progress.then(() => context.progress(metadata)).catch(() => {})
248
- },
249
- async ask() { throw new Error("Raven tools do not request additional permissions") },
250
- }
251
- const result = await definition.execute(schema.parse(input), legacyContext)
252
- await progress
253
- return typeof result === "string" ? { content: result } : {
254
- content: result.output,
255
- metadata: { ...metadata, ...result.metadata, ...(result.title ? { title: result.title } : {}) },
256
- }
257
- },
258
- })
211
+ // Delegation needs no extra confirmation. Preserve deny rules and leave
212
+ // child tools and the Raven-only MCP bridge's permissions unchanged.
213
+ registrations.push(await ctx.permission.hook("evaluate", (event) => {
214
+ if (event.effect === "ask" && (event.action === "raven_seek" || event.action === "raven_mcp")) {
215
+ event.effect = "allow"
259
216
  }
260
217
  }))
261
218
 
262
- registrations.push(await ctx.tool.hook("execute.before", async (event) => {
263
- await raven["chat.message"]({ sessionID: event.sessionID, agent: event.agent }, {})
264
- const output = { args: event.input }
265
- await raven["tool.execute.before"]({
266
- ...event,
267
- callID: event.id,
268
- }, output)
269
- event.input = output.args
270
- }))
271
- registrations.push(await ctx.tool.hook("execute.after", async (event) => {
272
- if (event.status !== "completed") return
273
- await raven["tool.execute.after"]({ ...event, callID: event.id }, {
274
- metadata: event.result.metadata, output: event.result.content ?? "",
275
- })
276
- }))
277
- registrations.push(await ctx.session.hook("context", async (event) => {
278
- await raven["chat.message"]({ sessionID: event.sessionID, agent: event.agent }, {})
279
- if (event.agent === "raven" || event.agent === "raven-mcp") return
280
- event.system.push({ type: "text", text: raven.guidance() })
281
- }))
282
- registrations.push(await ctx.session.hook("retry", async (event) => {
283
- await raven.event({ event: {
284
- type: "session.error", properties: { sessionID: event.sessionID, error: legacyError(event.error) },
285
- } })
286
- }))
287
- registrations.push(await ctx.command.transform((editor) => {
288
- editor.add({
219
+ registrations.push(await ctx.agent.transform((editor) => {
220
+ for (const [id, definition] of Object.entries(agents)) {
221
+ if (definition.disable) { editor.remove(id); continue }
222
+ // The shipped registry upserts on update, including previously absent IDs.
223
+ editor.update(id, (agent) => {
224
+ agent.name = id as Agent.Name
225
+ agent.description = definition.description
226
+ agent.mode = definition.mode
227
+ agent.hidden = definition.hidden
228
+ agent.model = definition.model ? modelRef(definition.model) : undefined
229
+ agent.system = definition.prompt
230
+ agent.request.body = { ...definition.options }
231
+ agent.permissions = Object.entries(definition.permission).map(([action, effect]) => ({
232
+ action: action === "bash" ? "shell" : action === "task" ? "subagent" : action,
233
+ resource: "*", effect,
234
+ }))
235
+ })
236
+ }
237
+ }))
238
+
239
+ const tools: NonNullable<Hooks["tool"]> = raven.tool
240
+ registrations.push(await ctx.tool.transform((editor) => {
241
+ for (const [name, definition] of Object.entries(tools)) {
242
+ const schema = z.object(definition.args)
243
+ editor.add({
244
+ name, description: definition.description,
245
+ input: z.toJSONSchema(schema),
246
+ options: { permission: name, codemode: false },
247
+ async execute(input, context) {
248
+ await raven["chat.message"]({ sessionID: context.sessionID, agent: context.agent }, {})
249
+ await raven["tool.execute.before"]({ tool: name, sessionID: context.sessionID, callID: context.id }, { args: input })
250
+ let metadata: Record<string, unknown> = {}
251
+ let progress = Promise.resolve()
252
+ const legacyContext: V1ToolContext = {
253
+ sessionID: context.sessionID, messageID: context.messageID, agent: context.agent,
254
+ directory: ctx.location.directory, worktree: ctx.location.project.directory,
255
+ abort: AbortSignal.any([context.signal, controller.signal]),
256
+ metadata(update) {
257
+ metadata = { ...metadata, ...update.metadata, ...(update.title ? { title: update.title } : {}) }
258
+ progress = progress.then(() => context.progress(metadata)).catch(() => {})
259
+ },
260
+ async ask() { throw new Error("Raven tools do not request additional permissions") },
261
+ }
262
+ const result = await definition.execute(schema.parse(input), legacyContext)
263
+ await progress
264
+ return typeof result === "string" ? { content: result } : {
265
+ content: result.output,
266
+ metadata: { ...metadata, ...result.metadata, ...(result.title ? { title: result.title } : {}) },
267
+ }
268
+ },
269
+ })
270
+ }
271
+ }))
272
+
273
+ registrations.push(await ctx.tool.hook("execute.before", async (event) => {
274
+ await raven["chat.message"]({ sessionID: event.sessionID, agent: event.agent }, {})
275
+ const output = { args: event.input }
276
+ await raven["tool.execute.before"]({
277
+ ...event,
278
+ callID: event.id,
279
+ }, output)
280
+ event.input = output.args
281
+ }))
282
+ registrations.push(await ctx.tool.hook("execute.after", async (event) => {
283
+ if (event.status !== "completed") return
284
+ await raven["tool.execute.after"]({ ...event, callID: event.id }, {
285
+ metadata: event.result.metadata, output: event.result.content ?? "",
286
+ })
287
+ }))
288
+ registrations.push(await ctx.session.hook("context", async (event) => {
289
+ await raven["chat.message"]({ sessionID: event.sessionID, agent: event.agent }, {})
290
+ if (event.agent === "raven") return
291
+ event.system.push({ type: "text", text: event.agent === "raven-mcp" ? raven.mcpGuidance() : raven.guidance() })
292
+ }))
293
+ registrations.push(await ctx.session.hook("retry", async (event) => {
294
+ await raven.event({ event: {
295
+ type: "session.error", properties: { sessionID: event.sessionID, error: legacyError(event.error) },
296
+ } })
297
+ }))
298
+ registrations.push(await ctx.command.transform((editor) => {
299
+ editor.add({
289
300
  name: "raven", description: "Manage Raven routing, MCPs, models, timeouts, updates, and status",
290
301
  async execute({ sessionID, prompt }) {
291
- const { text } = await executeCommand(prompt.text)
292
- await emitNotice({ kind: "command", sessionID, message: text, title: "Raven" })
293
- },
294
- })
295
- }))
296
-
297
- eventPump = (async () => {
298
- try {
299
- for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
300
- if (event.type === "model.updated") {
301
- await raven.event({ event: { type: "raven.models.ready" } })
302
- } else if (event.type === "session.created") {
303
- const data = event.data
304
- await raven.event({ event: { type: event.type, properties: { info: { ...data, id: data.sessionID } } } })
305
- } else if (event.type === "session.deleted") {
306
- const data = event.data
307
- await raven.event({ event: { type: event.type, properties: { info: { id: data.sessionID } } } })
308
- } else if (event.type === "session.execution.failed" || event.type === "session.retry.scheduled") {
309
- const data = event.data
310
- await raven.event({ event: { type: "session.error", properties: { sessionID: data.sessionID, error: legacyError(data.error) } } })
311
- } else if (event.type === "session.text.delta" || event.type === "session.reasoning.delta" || event.type === "session.tool.input.delta" || event.type === "session.text.started" || event.type === "session.reasoning.started" || event.type === "session.tool.input.started") {
312
- const data = event.data
313
- await raven.event({ event: { type: "message.part.updated", properties: { part: { sessionID: data.sessionID, type: "text" } } } })
302
+ const refresh = mcpRefreshArguments(prompt.text)
303
+ if (refresh) {
304
+ await emitNotice({ kind: "toast", sessionID, title: "Raven MCP", message: `Refreshing ${refresh.serverName ?? "on-demand MCPs"}…`, variant: "info" })
305
+ try {
306
+ const result = await raven.refreshMcps(refresh.serverName)
307
+ await emitNotice({ kind: "toast", sessionID, ...mcpRefreshToast(result) })
308
+ } catch (error) {
309
+ await emitNotice({ kind: "toast", sessionID, title: "Raven MCP", message: `Refresh failed: ${String(error)}`, variant: "error" })
310
+ }
311
+ return
314
312
  }
315
- }
316
- } catch (error) {
317
- if (!controller.signal.aborted) console.error("Raven event subscription failed:", error)
318
- }
319
- })()
320
- await ctx.agent.reload()
321
- await ctx.tool.reload()
322
- await ctx.command.reload()
323
- startupTimer = setTimeout(() => {
324
- void raven.event({ event: { type: "raven.ready" } }).catch((error) => console.error("Raven startup failed:", error))
325
- }, 3000)
326
- return cleanup
327
- } catch (error) {
328
- await cleanup()
329
- throw error
330
- }
331
- }
313
+ const { text } = await executeCommand(prompt.text)
314
+ await emitNotice({ kind: "command", sessionID, message: text, title: "Raven" })
315
+ },
316
+ })
317
+ }))
318
+
319
+ eventPump = (async () => {
320
+ try {
321
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
322
+ if (event.type === "model.updated") {
323
+ await raven.event({ event: { type: "raven.models.ready" } })
324
+ } else if (event.type === "session.created") {
325
+ const data = event.data
326
+ await raven.event({ event: { type: event.type, properties: { info: { ...data, id: data.sessionID } } } })
327
+ } else if (event.type === "session.deleted") {
328
+ const data = event.data
329
+ await raven.event({ event: { type: event.type, properties: { info: { id: data.sessionID } } } })
330
+ } else if (event.type === "session.execution.failed" || event.type === "session.retry.scheduled") {
331
+ const data = event.data
332
+ await raven.event({ event: { type: "session.error", properties: { sessionID: data.sessionID, error: legacyError(data.error) } } })
333
+ } else if (event.type === "session.text.delta" || event.type === "session.reasoning.delta" || event.type === "session.tool.input.delta" || event.type === "session.text.started" || event.type === "session.reasoning.started" || event.type === "session.tool.input.started") {
334
+ const data = event.data
335
+ await raven.event({ event: { type: "message.part.updated", properties: { part: { sessionID: data.sessionID, type: "text" } } } })
336
+ }
337
+ }
338
+ } catch (error) {
339
+ if (!controller.signal.aborted) console.error("Raven event subscription failed:", error)
340
+ }
341
+ })()
342
+ await ctx.agent.reload()
343
+ await ctx.tool.reload()
344
+ await ctx.command.reload()
345
+ startupTimer = setTimeout(() => {
346
+ void raven.event({ event: { type: "raven.ready" } }).catch((error) => console.error("Raven startup failed:", error))
347
+ }, 3000)
348
+ return cleanup
349
+ } catch (error) {
350
+ await cleanup()
351
+ throw error
352
+ }
353
+ }