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