1// Package ext is the Go SDK for writing zot extensions.
2//
3// An extension is a subprocess that talks to zot over its stdin/stdout
4// in newline-delimited JSON. This package wraps the wire format so
5// extension authors can write straightforward Go without reimplementing
6// the protocol.
7//
8// Minimal example (registers /hello and replies with a static prompt):
9//
10// package main
11//
12// import "github.com/patriceckhart/zot/packages/agent/ext"
13//
14// func main() {
15// ext := ext.New("hello", "1.0.0")
16// ext.Command("hello", "say hi", func(args string) ext.Response {
17// return ext.Prompt("Greet me in three different languages.")
18// })
19// ext.Run()
20// }
21//
22// Build it, drop the binary + an extension.json next to it under
23// `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
24//
25// The same wire format also has reference clients in TypeScript and
26// Python under examples/extensions/. Use whichever language fits.
27package ext
28
29import (
30 "bufio"
31 "encoding/base64"
32 "encoding/json"
33 "fmt"
34 "io"
35 "os"
36 "sync"
37
38 "github.com/patriceckhart/zot/packages/agent/extproto"
39)
40
41func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
42
43// CommandHandler is invoked when the user runs the extension's
44// registered slash command. args is everything the user typed after
45// the command name (already trimmed). Return a Response describing
46// what zot should do next.
47type CommandHandler func(args string) Response
48
49// ToolHandler is invoked when the LLM calls a tool the extension
50// registered. args is the raw JSON object the model produced; the
51// handler is responsible for parsing/validating it. Return a
52// ToolResult describing what zot should send back to the model.
53type ToolHandler func(args json.RawMessage) ToolResult
54
55// EventHandler is called for each lifecycle event the extension
56// subscribed to via Subscribe. The handler is invoked synchronously
57// on the read goroutine; keep it quick or hand off to your own
58// worker.
59type EventHandler func(ev Event)
60
61// Event is a lifecycle notification from zot. The fields populated
62// depend on Name (the host's event_name string):
63//
64// session_start : (no extra fields)
65// turn_start : Step
66// turn_end : Stop, optional Error
67// tool_call : ToolID, ToolName, ToolArgs
68// assistant_message: Text
69type Event struct {
70 Name string
71
72 Step int
73 Stop string
74 Error string
75
76 ToolID string
77 ToolName string
78 ToolArgs json.RawMessage
79
80 Text string
81}
82
83// InterceptHandler decides whether a tool call may proceed. Return
84// (allow=true) to permit, (allow=false, reason) to refuse. The
85// reason is shown to the model as the tool's error text.
86//
87// This is the original 2-value handler. For richer control (arg
88// rewrites, turn_start / assistant_message interception, etc.)
89// register via InterceptToolCallX, InterceptTurnStart, or
90// InterceptAssistantMessage.
91type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string)
92
93// ToolCallDecision is the richer reply an InterceptToolCallHandler
94// can return. Zero value means "allow, pass through".
95type ToolCallDecision struct {
96 // Block refuses the call. The model sees Reason as the tool's
97 // error text and typically proposes a different action.
98 Block bool
99 // Reason is the refusal text shown to the model on Block, or a
100 // logged note when passing through (optional).
101 Reason string
102 // ModifiedArgs, when non-nil and Block is false, replaces the
103 // JSON args the tool actually runs with. Lets guards redact /
104 // augment / patch the model's request without rewriting the
105 // transcript. Must encode to a JSON object.
106 ModifiedArgs json.RawMessage
107}
108
109// ToolCallHandler is the richer form of an interceptor. Use this
110// when you want to rewrite args instead of only blocking.
111type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision
112
113// TurnStartDecision controls whether the next model call runs. Zero
114// value means "allow".
115type TurnStartDecision struct {
116 Block bool
117 Reason string
118}
119
120// TurnStartHandler is called before every turn's model call. Return
121// Block=true with Reason to abort the turn (shown to the user as a
122// status line). Useful for rate-limiting and business-hour gates.
123type TurnStartHandler func(step int) TurnStartDecision
124
125// AssistantMessageDecision controls the final assistant-text
126// rendering. Zero value means "allow, show as-is".
127type AssistantMessageDecision struct {
128 // Block suppresses the message from the user entirely. The
129 // transcript still records the model's original output so the
130 // model sees what it said on the next turn.
131 Block bool
132 // Reason is logged when blocking (optional).
133 Reason string
134 // ReplaceText, when non-empty and Block is false, is what the
135 // user sees. The model's original text still lives in the
136 // transcript.
137 ReplaceText string
138}
139
140// AssistantMessageHandler is called after the model's final text is
141// assembled but before it's shown. Use it to scrub secrets, expand
142// templates, enforce tone, or suppress responses entirely.
143type AssistantMessageHandler func(text string) AssistantMessageDecision
144
145// ToolResult is the extension's reply to a tool invocation. Build
146// one with TextResult, ImageResult, or directly when you need to
147// combine multiple blocks.
148type ToolResult struct {
149 Content []ToolContent
150 IsError bool
151}
152
153// ToolContent is one block of tool output. Either Text is set, or
154// MimeType+Data (base64-encoded). Use the Text/Image helpers below.
155type ToolContent struct {
156 Type string // "text" | "image"
157 Text string
158 MimeType string
159 Data string // base64
160}
161
162// Text returns a text content block.
163func Text(s string) ToolContent { return ToolContent{Type: "text", Text: s} }
164
165// Image returns an image content block. data must already be
166// base64-encoded; use ImageBytes to encode raw bytes.
167func Image(mimeType, base64Data string) ToolContent {
168 return ToolContent{Type: "image", MimeType: mimeType, Data: base64Data}
169}
170
171// ImageBytes returns an image content block from raw bytes,
172// encoding them to base64 for the wire.
173func ImageBytes(mimeType string, data []byte) ToolContent {
174 return ToolContent{Type: "image", MimeType: mimeType, Data: base64Encode(data)}
175}
176
177// TextResult returns a tool result with one text block, success.
178func TextResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}} }
179
180// TextErrorResult returns a tool result with one text block, marked
181// as an error to the model.
182func TextErrorResult(s string) ToolResult {
183 return ToolResult{Content: []ToolContent{Text(s)}, IsError: true}
184}
185
186// Response tells zot how to react to a command invocation. Construct
187// one with Prompt(), Insert(), Display(), or Noop().
188type Panel struct {
189 ID string
190 Title string
191 Lines []string
192 Footer string
193}
194
195type Response struct {
196 Action string // "prompt", "insert", "display", "open_panel", "noop"
197 Prompt string
198 Insert string
199 Display string
200 OpenPanel *Panel
201 Error string
202}
203
204// Prompt returns a Response that submits text as a fresh user message
205// to the agent (running it through the model loop as if the user had
206// typed and pressed enter).
207func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} }
208
209// Insert returns a Response that drops text into the editor at the
210// cursor without submitting.
211func Insert(text string) Response { return Response{Action: "insert", Insert: text} }
212
213// Display returns a Response that adds a one-shot styled note to the
214// chat without invoking the model. Useful for showing a result without
215// burning tokens.
216func Display(text string) Response { return Response{Action: "display", Display: text} }
217
218// OpenPanel returns a Response that opens an interactive extension-owned
219// panel inside zot.
220func OpenPanel(id, title string, lines []string, footer string) Response {
221 return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}}
222}
223
224// Noop returns a Response that signals "I handled it, no UI change".
225// Use after pushing your own state or notifications.
226func Noop() Response { return Response{Action: "noop"} }
227
228// Errorf returns a Response that surfaces the error in the chat as a
229// red status line.
230func Errorf(format string, args ...any) Response {
231 return Response{Action: "noop", Error: fmt.Sprintf(format, args...)}
232}
233
234// Extension is one zot extension. Construct with New, register
235// commands, then call Run.
236type Extension struct {
237 name string
238 version string
239
240 in io.Reader
241 out io.Writer
242 stderr io.Writer
243 writeMu sync.Mutex
244
245 mu sync.Mutex
246 commands map[string]CommandHandler
247 descriptions []descTuple // ordered so register frames arrive in registration order
248 tools map[string]ToolHandler
249 toolDefs []toolDef // ordered so register frames arrive in registration order
250 eventHandlers map[string]EventHandler
251 eventNames []string // declared subscription order
252
253 interceptTool InterceptHandler
254 interceptToolRich ToolCallHandler
255 interceptOn bool
256 interceptTurn TurnStartHandler
257 interceptAssistant AssistantMessageHandler
258 panelKeys map[string]func(key, text string)
259 panelCloses map[string]func()
260
261 // Caps reported in the hello frame.
262 caps []string
263
264 // hostInfo is filled in once HelloAck arrives.
265 host HostInfo
266}
267
268type descTuple struct {
269 name, desc string
270}
271
272type toolDef struct {
273 name string
274 description string
275 schema json.RawMessage
276}
277
278// HostInfo is what the host (zot) tells us in HelloAck. Useful for
279// extensions that want to behave differently per provider.
280type HostInfo struct {
281 ProtocolVersion int
282 ZotVersion string
283 Provider string
284 Model string
285 CWD string
286 ExtensionDir string
287 DataDir string
288}
289
290// New constructs an Extension with the given identifier. name should
291// match the name field in extension.json.
292func New(name, version string) *Extension {
293 return &Extension{
294 name: name,
295 version: version,
296 in: os.Stdin,
297 out: os.Stdout,
298 stderr: os.Stderr,
299 commands: map[string]CommandHandler{},
300 tools: map[string]ToolHandler{},
301 eventHandlers: map[string]EventHandler{},
302 panelKeys: map[string]func(key, text string){},
303 panelCloses: map[string]func(){},
304 caps: []string{"commands", "tools", "events", "panels"},
305 }
306}
307
308// Host returns the HostInfo received during the hello handshake.
309// Returns the zero value if Run hasn't started yet.
310func (e *Extension) Host() HostInfo { return e.host }
311
312// Logf writes a line to the extension's stderr, which zot captures to
313// $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314// you print to stdout would corrupt the JSON wire protocol.
315func (e *Extension) Logf(format string, args ...any) {
316 fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)
317}
318
319// OnPanelKey registers callbacks for panel key + close events.
320func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) {
321 e.mu.Lock()
322 defer e.mu.Unlock()
323 if onKey != nil {
324 e.panelKeys[panelID] = onKey
325 }
326 if onClose != nil {
327 e.panelCloses[panelID] = onClose
328 }
329}
330
331// OpenPanel opens an interactive panel spontaneously from extension code
332// without requiring a slash command. Safe to call from a tool handler goroutine
333// or any background context. The panel receives panel_key events via OnPanelKey
334// and can be dismissed with ClosePanel, exactly as with command-response panels.
335func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
336 _ = e.send(extproto.OpenPanelFromExt{
337 Type: "open_panel",
338 Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
339 })
340}
341
342// RenderPanel pushes a fresh frame for panelID.
343func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {
344 _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})
345}
346
347// ClosePanel tells zot to close panelID.
348func (e *Extension) ClosePanel(panelID string) {
349 _ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID})
350}
351
352// Command registers a slash-command handler. Call this BEFORE Run().
353// Once Run is going, when the user runs /name in zot, fn is invoked
354// with the remaining args.
355//
356// Naming conflicts with built-in commands (e.g. /help) are silently
357// shadowed by the built-in; check the user's "ext logs" if a command
358// you registered isn't taking effect.
359func (e *Extension) Command(name, description string, fn CommandHandler) {
360 e.mu.Lock()
361 e.commands[name] = fn
362 e.descriptions = append(e.descriptions, descTuple{name: name, desc: description})
363 e.mu.Unlock()
364}
365
366// Tool registers an LLM-callable tool. schema is a JSON Schema
367// object describing the tool's args (the same shape Anthropic /
368// OpenAI accept). Call this BEFORE Run(); zot folds extension tools
369// into the agent's registry once the extension's ready frame fires.
370//
371// Naming conflicts with built-in tools (read, write, edit, bash,
372// skill) are silently shadowed by the built-in.
373func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
374 e.mu.Lock()
375 e.tools[name] = fn
376 e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema})
377 e.mu.Unlock()
378}
379
380// On subscribes to a lifecycle event. fn is called for each
381// notification; the same name can only have one handler (later
382// registrations replace earlier ones). Recognised names:
383// session_start, turn_start, turn_end, tool_call,
384// assistant_message.
385func (e *Extension) On(name string, fn EventHandler) {
386 e.mu.Lock()
387 if _, exists := e.eventHandlers[name]; !exists {
388 e.eventNames = append(e.eventNames, name)
389 }
390 e.eventHandlers[name] = fn
391 e.mu.Unlock()
392}
393
394// InterceptToolCall registers a guard that runs immediately before
395// each tool call. Returning (false, reason) refuses the call; the
396// model sees reason as the tool error text. Multiple extensions can
397// install interceptors; if any one refuses, the call is blocked.
398//
399// Use this to build permission gates: refuse `bash` calls containing
400// `rm -rf`, ask the user for confirmation on dangerous patterns,
401// audit-log every call, etc.
402//
403// For the richer form (arg rewrites, structured decisions) use
404// InterceptToolCallX.
405func (e *Extension) InterceptToolCall(fn InterceptHandler) {
406 e.mu.Lock()
407 e.interceptTool = fn
408 e.interceptOn = true
409 e.mu.Unlock()
410}
411
412// InterceptToolCallX is the richer variant. Return a ToolCallDecision
413// to block, allow, or rewrite args mid-flight. If both
414// InterceptToolCall and InterceptToolCallX are set, the X form wins.
415func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
416 e.mu.Lock()
417 e.interceptToolRich = fn
418 e.interceptOn = true
419 e.mu.Unlock()
420}
421
422// InterceptTurnStart registers a guard that runs before every turn's
423// model call. Return Block=true with Reason to abort the turn.
424// Useful for deny-by-default gates and usage quotas.
425func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
426 e.mu.Lock()
427 e.interceptTurn = fn
428 e.mu.Unlock()
429}
430
431// InterceptAssistantMessage registers a guard that runs after the
432// model's final text is assembled but before it's shown. Return
433// Block=true to suppress entirely, or ReplaceText to rewrite what
434// the user sees. The model's original output stays in the transcript.
435func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
436 e.mu.Lock()
437 e.interceptAssistant = fn
438 e.mu.Unlock()
439}
440
441// Notify pushes an info-level status note into zot's chat without
442// requiring a slash command from the user.
443func (e *Extension) Notify(level, message string) {
444 _ = e.send(extproto.NotifyFromExt{
445 Type: "notify",
446 Level: level,
447 Message: message,
448 })
449}
450
451// Run starts the protocol loop. Blocks until stdin closes (zot has
452// shut us down). Returns the first fatal error, or nil on clean exit.
453func (e *Extension) Run() error {
454 // Send hello, then re-announce all commands (covers Command calls
455 // made before Run, and is also fine for those made after via
456 // Command()'s direct send).
457 if err := e.send(extproto.HelloFromExt{
458 Type: "hello",
459 Name: e.name,
460 Version: e.version,
461 Capabilities: e.caps,
462 }); err != nil {
463 return err
464 }
465 e.mu.Lock()
466 descs := append([]descTuple(nil), e.descriptions...)
467 toolDefs := append([]toolDef(nil), e.toolDefs...)
468 eventNames := append([]string(nil), e.eventNames...)
469 interceptTool := e.interceptOn
470 interceptTurn := e.interceptTurn != nil
471 interceptAsst := e.interceptAssistant != nil
472 e.mu.Unlock()
473 for _, d := range descs {
474 _ = e.send(extproto.RegisterCommandFromExt{
475 Type: "register_command",
476 Name: d.name,
477 Description: d.desc,
478 })
479 }
480 for _, td := range toolDefs {
481 _ = e.send(extproto.RegisterToolFromExt{
482 Type: "register_tool",
483 Name: td.name,
484 Description: td.description,
485 Schema: td.schema,
486 })
487 }
488 var intercepts []string
489 if interceptTool {
490 intercepts = append(intercepts, "tool_call")
491 }
492 if interceptTurn {
493 intercepts = append(intercepts, "turn_start")
494 }
495 if interceptAsst {
496 intercepts = append(intercepts, "assistant_message")
497 }
498 if len(eventNames) > 0 || len(intercepts) > 0 {
499 _ = e.send(extproto.SubscribeFromExt{
500 Type: "subscribe",
501 Events: eventNames,
502 Intercept: intercepts,
503 })
504 }
505 // Sentinel: tells the host that all initial registrations have
506 // been flushed and the agent registry can be built. Never block
507 // on this; if the host doesn't act on it, registrations still
508 // land in time for the typical use case.
509 _ = e.send(extproto.ReadyFromExt{Type: "ready"})
510
511 scanner := bufio.NewScanner(e.in)
512 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
513 for scanner.Scan() {
514 line := scanner.Bytes()
515 var frame extproto.Frame
516 if err := json.Unmarshal(line, &frame); err != nil {
517 e.Logf("malformed frame from host: %v", err)
518 continue
519 }
520 switch frame.Type {
521 case "hello_ack":
522 var ack extproto.HelloAckFromHost
523 if err := json.Unmarshal(line, &ack); err == nil {
524 e.host = HostInfo{
525 ProtocolVersion: ack.ProtocolVersion,
526 ZotVersion: ack.ZotVersion,
527 Provider: ack.Provider,
528 Model: ack.Model,
529 CWD: ack.CWD,
530 ExtensionDir: ack.ExtensionDir,
531 DataDir: ack.DataDir,
532 }
533 }
534 case "command_invoked":
535 var ci extproto.CommandInvokedFromHost
536 if err := json.Unmarshal(line, &ci); err != nil {
537 continue
538 }
539 e.mu.Lock()
540 fn := e.commands[ci.Name]
541 e.mu.Unlock()
542 if fn == nil {
543 e.respond(ci.ID, Errorf("no handler for /%s", ci.Name))
544 continue
545 }
546 // Run the handler on its own goroutine so a slow handler
547 // doesn't block subsequent commands. Order isn't promised.
548 go func(id string, fn CommandHandler, args string) {
549 defer func() {
550 if r := recover(); r != nil {
551 e.respond(id, Errorf("panic: %v", r))
552 }
553 }()
554 resp := fn(args)
555 e.respond(id, resp)
556 }(ci.ID, fn, ci.Args)
557 case "tool_call":
558 var tc extproto.ToolCallFromHost
559 if err := json.Unmarshal(line, &tc); err != nil {
560 continue
561 }
562 e.mu.Lock()
563 fn := e.tools[tc.Name]
564 e.mu.Unlock()
565 if fn == nil {
566 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
567 continue
568 }
569 go func(id string, fn ToolHandler, args json.RawMessage) {
570 defer func() {
571 if r := recover(); r != nil {
572 e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
573 }
574 }()
575 res := fn(args)
576 e.respondTool(id, res)
577 }(tc.ID, fn, tc.Args)
578 case "event":
579 var ef extproto.EventFromHost
580 if err := json.Unmarshal(line, &ef); err != nil {
581 continue
582 }
583 e.mu.Lock()
584 handler := e.eventHandlers[ef.Event]
585 e.mu.Unlock()
586 if handler != nil {
587 func() {
588 defer func() {
589 if r := recover(); r != nil {
590 e.Logf("event %s handler panicked: %v", ef.Event, r)
591 }
592 }()
593 handler(Event{
594 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
595 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
596 ToolArgs: ef.ToolArgs, Text: ef.Text,
597 })
598 }()
599 }
600 case "event_intercept":
601 var ei extproto.EventInterceptFromHost
602 if err := json.Unmarshal(line, &ei); err != nil {
603 continue
604 }
605 go e.dispatchIntercept(ei)
606 case "panel_key":
607 var pk extproto.PanelKeyFromHost
608 if err := json.Unmarshal(line, &pk); err != nil {
609 continue
610 }
611 e.mu.Lock()
612 h := e.panelKeys[pk.PanelID]
613 e.mu.Unlock()
614 if h != nil {
615 go h(pk.Key, pk.Text)
616 }
617 case "panel_close":
618 var pc extproto.PanelCloseFromHost
619 if err := json.Unmarshal(line, &pc); err != nil {
620 continue
621 }
622 e.mu.Lock()
623 h := e.panelCloses[pc.PanelID]
624 e.mu.Unlock()
625 if h != nil {
626 go h()
627 }
628 case "shutdown":
629 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
630 return nil
631 default:
632 e.Logf("unknown frame type %q", frame.Type)
633 }
634 }
635 return scanner.Err()
636}
637
638// respond serialises a CommandResponseFromExt for the given id.
639func (e *Extension) respond(id string, r Response) {
640 if r.Action == "" {
641 r.Action = "noop"
642 }
643 var panel *extproto.PanelSpec
644 if r.OpenPanel != nil {
645 panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
646 }
647 _ = e.send(extproto.CommandResponseFromExt{
648 Type: "command_response",
649 ID: id,
650 Action: r.Action,
651 Prompt: r.Prompt,
652 Insert: r.Insert,
653 Display: r.Display,
654 OpenPanel: panel,
655 Error: r.Error,
656 })
657}
658
659// respondTool serialises a ToolResultFromExt for the given id.
660func (e *Extension) respondTool(id string, r ToolResult) {
661 blocks := make([]extproto.ContentBlock, 0, len(r.Content))
662 for _, c := range r.Content {
663 blocks = append(blocks, extproto.ContentBlock{
664 Type: c.Type,
665 Text: c.Text,
666 MimeType: c.MimeType,
667 Data: c.Data,
668 })
669 }
670 _ = e.send(extproto.ToolResultFromExt{
671 Type: "tool_result",
672 ID: id,
673 Content: blocks,
674 IsError: r.IsError,
675 })
676}
677
678// send marshals v + LF and writes it under a mutex (so concurrent
679// goroutines don't interleave bytes on stdout).
680func (e *Extension) send(v any) error {
681 b, err := extproto.Encode(v)
682 if err != nil {
683 return err
684 }
685 e.writeMu.Lock()
686 defer e.writeMu.Unlock()
687 _, err = e.out.Write(b)
688 return err
689}
690
691// dispatchIntercept runs the per-event handler (tool_call / turn_start
692// / assistant_message) on its own goroutine, catches panics, and
693// always emits exactly one event_intercept_response. Called from the
694// Run loop.
695func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
696 defer func() {
697 if r := recover(); r != nil {
698 _ = e.send(extproto.EventInterceptResponseFromExt{
699 Type: "event_intercept_response",
700 ID: ei.ID,
701 Block: true,
702 Reason: fmt.Sprintf("intercept panic: %v", r),
703 })
704 }
705 }()
706
707 resp := extproto.EventInterceptResponseFromExt{
708 Type: "event_intercept_response",
709 ID: ei.ID,
710 }
711
712 switch ei.Event {
713 case "tool_call":
714 e.mu.Lock()
715 rich := e.interceptToolRich
716 plain := e.interceptTool
717 e.mu.Unlock()
718 if rich != nil {
719 d := rich(ei.ToolName, ei.ToolArgs)
720 resp.Block = d.Block
721 resp.Reason = d.Reason
722 if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
723 resp.ModifiedArgs = d.ModifiedArgs
724 }
725 } else if plain != nil {
726 allow, reason := plain(ei.ToolName, ei.ToolArgs)
727 resp.Block = !allow
728 resp.Reason = reason
729 }
730 case "turn_start":
731 e.mu.Lock()
732 fn := e.interceptTurn
733 e.mu.Unlock()
734 if fn != nil {
735 d := fn(ei.Step)
736 resp.Block = d.Block
737 resp.Reason = d.Reason
738 }
739 case "assistant_message":
740 e.mu.Lock()
741 fn := e.interceptAssistant
742 e.mu.Unlock()
743 if fn != nil {
744 d := fn(ei.Text)
745 resp.Block = d.Block
746 resp.Reason = d.Reason
747 if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
748 resp.ReplaceText = d.ReplaceText
749 }
750 }
751 }
752
753 _ = e.send(resp)
754}