11export * as PluginHost from "./host"
22
33import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
4- import { Effect , Schema } from "effect"
4+ import { EventManifest } from "@opencode-ai/schema/event-manifest"
5+ import { Effect , Schema , Stream } from "effect"
56import { AgentV2 } from "../agent"
67import { AISDK } from "../aisdk"
78import { Catalog } from "../catalog"
89import { CommandV2 } from "../command"
910import { Credential } from "../credential"
11+ import { EventV2 } from "../event"
1012import { Integration } from "../integration"
1113import { ModelV2 } from "../model"
1214import { PluginV2 } from "../plugin"
1315import { ProviderV2 } from "../provider"
1416import { Reference } from "../reference"
1517import type { DeepMutable } from "../schema"
18+ import { SessionHooks } from "../session/hooks"
1619import { SkillV2 } from "../skill"
1720
1821const mutable = < T > ( value : T ) => value as DeepMutable < T >
@@ -22,6 +25,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
2225 const aisdk = yield * AISDK . Service
2326 const catalog = yield * Catalog . Service
2427 const commands = yield * CommandV2 . Service
28+ const events = yield * EventV2 . Service
29+ const hooks = yield * SessionHooks . Service
2530 const integration = yield * Integration . Service
2631 const reference = yield * Reference . Service
2732 const skill = yield * SkillV2 . Service
@@ -100,6 +105,24 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
100105 reload : commands . reload ,
101106 transform : commands . transform ,
102107 } ,
108+ event : {
109+ // SDK event type strings are identical to the internal definition types, so
110+ // the public manifest resolves a subscription directly. A few SDK types (e.g.
111+ // server.instance.disposed) are not EventV2 definitions and never emit on the
112+ // durable bus; those hand back an empty stream. Payload data is encoded so
113+ // in-process subscribers receive the same shape as remote SDK consumers.
114+ subscribe : ( ( type : string ) => {
115+ const definition = EventManifest . Latest . get ( type )
116+ if ( ! definition ) return Stream . empty
117+ return events . subscribe ( definition ) . pipe (
118+ Stream . map ( ( payload ) => ( {
119+ id : payload . id ,
120+ type : payload . type ,
121+ properties : Schema . encodeUnknownSync ( definition . data ) ( payload . data ) ,
122+ } ) ) ,
123+ )
124+ } ) as Interface [ "event" ] [ "subscribe" ] ,
125+ } ,
103126 integration : {
104127 reload : integration . reload ,
105128 connection : {
@@ -215,5 +238,64 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
215238 } ) ,
216239 ) ,
217240 } ,
241+ tool : {
242+ // Bridges the public hook context onto the internal SessionHooks seam the
243+ // runner invokes around each tool settlement. before-hooks may rewrite args,
244+ // deny, or skip; after-hooks may append context to the tool result.
245+ hook : ( ( name : string , callback : ( event : unknown ) => Effect . Effect < void > ) => {
246+ if ( name === "execute.before" ) {
247+ return hooks . registerPreToolUse ( ( tool ) =>
248+ Effect . gen ( function * ( ) {
249+ let input = tool . input
250+ let denied : string | undefined
251+ let skipped = false
252+ yield * callback ( {
253+ name : tool . name ,
254+ input : tool . input ,
255+ args : {
256+ update : ( next : unknown ) => {
257+ input = next
258+ } ,
259+ } ,
260+ deny : ( reason : string ) => {
261+ denied = reason
262+ } ,
263+ skip : ( ) => {
264+ skipped = true
265+ } ,
266+ } )
267+ if ( denied !== undefined ) return { action : "deny" as const , reason : denied }
268+ if ( skipped ) return { action : "skip" as const }
269+ return input === tool . input
270+ ? { action : "allow" as const }
271+ : { action : "allow" as const , modifiedInput : input }
272+ } ) ,
273+ )
274+ }
275+ if ( name === "execute.after" ) {
276+ return hooks . registerPostToolUse ( ( tool ) =>
277+ Effect . gen ( function * ( ) {
278+ const parts : string [ ] = [ ]
279+ yield * callback ( {
280+ name : tool . name ,
281+ input : tool . input ,
282+ output : tool . output ,
283+ context : {
284+ add : ( text : string ) => {
285+ parts . push ( text )
286+ } ,
287+ } ,
288+ } )
289+ return parts . length
290+ ? { action : "continue" as const , additionalContext : parts . join ( "\n" ) }
291+ : { action : "continue" as const }
292+ } ) ,
293+ )
294+ }
295+ // Unknown hook names must not silently bridge to a different seam (e.g. a
296+ // typo'd "execute.before" landing on the post hook). Warn and no-op.
297+ return Effect . logWarning ( "Unknown tool hook name; ignoring" , { name } ) . pipe ( Effect . asVoid )
298+ } ) as Interface [ "tool" ] [ "hook" ] ,
299+ } ,
218300 } satisfies Interface
219301} )
0 commit comments