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