// Package ext is the Go SDK for writing zut extensions. // // An extension is a subprocess that talks to zut over its stdin/stdout // in newline-delimited JSON. This package wraps the wire format so // extension authors can write straightforward Go without reimplementing // the protocol. // // Minimal example (registers /hello and replies with a static prompt): // // package main // // import "github.com/bnema/zut/packages/agent/ext" // // func main() { // ext := ext.New("hello", "1.0.0") // ext.Command("hello", "say hi", func(args string) ext.Response { // return ext.Prompt("Greet me in three different languages.") // }) // ext.Run() // } // // Build it, drop the binary + an extension.json next to it under // `$ZUT_HOME/extensions/hello/`, and zut picks it up on next launch. // // Use OnHello when registration or configuration depends on host metadata // such as HostInfo.CWD, Provider, Model, ZutVersion, ExtensionDir, or DataDir. // Run sends hello, waits for hello_ack, runs OnHello, announces registrations, // then sends ready. // // The same wire format also has reference clients in TypeScript and // Python under examples/extensions/. Use whichever language fits. package ext import ( "bufio" "encoding/base64" "encoding/json" "fmt" "io" "os" "sync" "github.com/bnema/zut/packages/agent/extproto" ) func base64Encode(b []byte) string { return base64.StdEncoding.EncodeToString(b) } // CommandHandler is invoked when the user runs the extension's // registered slash command. args is everything the user typed after // the command name (already trimmed). Return a Response describing // what zut should do next. type CommandHandler func(args string) Response // ToolHandler is invoked when the LLM calls a tool the extension // registered. args is the raw JSON object the model produced; the // handler is responsible for parsing/validating it. Return a // ToolResult describing what zut should send back to the model. type ToolHandler func(args json.RawMessage) ToolResult // EventHandler is called for each lifecycle event the extension // subscribed to via Subscribe. The handler is invoked synchronously // on the read goroutine; keep it quick or hand off to your own // worker. type EventHandler func(ev Event) // Event is a lifecycle notification from zut. The fields populated // depend on Name (the host's event_name string): // // session_start : Session // session_opened : Session + extension-owned State snapshot // session_switched : Session + extension-owned State snapshot // session_forked : Session + extension-owned State snapshot // session_compacted: Session + extension-owned State snapshot // turn_start : Step // turn_end : Stop, optional Error // tool_call : ToolID, ToolName, ToolArgs // tool_confirmation_requested: ToolID, ToolName, ToolPreview // assistant_message : Text type Event struct { Name string Session *extproto.SessionContext State json.RawMessage Step int Stop string Error string ToolID string ToolName string ToolArgs json.RawMessage ToolPreview string Text string } // InterceptHandler decides whether a tool call may proceed. Return // (allow=true) to permit, (allow=false, reason) to refuse. The // reason is shown to the model as the tool's error text. // // This is the original 2-value handler. For richer control (arg // rewrites, turn_start / assistant_message interception, etc.) // register via InterceptToolCallX, InterceptTurnStart, or // InterceptAssistantMessage. type InterceptHandler func(toolName string, args json.RawMessage) (allow bool, reason string) // ToolCallDecision is the richer reply an InterceptToolCallHandler // can return. Zero value means "allow, pass through". type ToolCallDecision struct { // Block refuses the call. The model sees Reason as the tool's // error text and typically proposes a different action. Block bool // Reason is the refusal text shown to the model on Block, or a // logged note when passing through (optional). Reason string // ModifiedArgs, when non-nil and Block is false, replaces the // JSON args the tool actually runs with. Lets guards redact / // augment / patch the model's request without rewriting the // transcript. Must encode to a JSON object. ModifiedArgs json.RawMessage } // ToolCallHandler is the richer form of an interceptor. Use this // when you want to rewrite args instead of only blocking. type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecision // TurnStartDecision controls whether the next model call runs. Zero // value means "allow". type TurnStartDecision struct { Block bool Reason string // Context is hidden, per-turn context sent only in the next provider // request. It is not recorded as a user or assistant message. Context string } // TurnStartHandler is called before every turn's model call. Return // Block=true with Reason to abort the turn (shown to the user as a // status line). Useful for rate-limiting and business-hour gates. type TurnStartHandler func(step int) TurnStartDecision // AssistantMessageDecision controls the final assistant-text // rendering. Zero value means "allow, show as-is". type AssistantMessageDecision struct { // Block suppresses the message from the user entirely. The // transcript still records the model's original output so the // model sees what it said on the next turn. Block bool // Reason is logged when blocking (optional). Reason string // ReplaceText, when non-empty and Block is false, is what the // user sees. The model's original text still lives in the // transcript. ReplaceText string } // AssistantMessageHandler is called after the model's final text is // assembled but before it's shown. Use it to scrub secrets, expand // templates, enforce tone, or suppress responses entirely. type AssistantMessageHandler func(text string) AssistantMessageDecision // ToolResult is the extension's reply to a tool invocation. Build // one with TextResult, ImageResult, or directly when you need to // combine multiple blocks. type ToolResult struct { Content []ToolContent IsError bool ActivateTools []string // Details is opaque JSON kept out of provider-visible tool results. The // host may persist it with the active session and return it on the next // session lifecycle event. Details json.RawMessage } // JSONDetails encodes an extension-owned value for ToolResult.Details. Invalid // values return nil, causing the host to ignore the metadata. func JSONDetails(value any) json.RawMessage { data, err := json.Marshal(value) if err != nil || !json.Valid(data) { return nil } return data } // AlertRequest asks the host to surface a structured alert. The host owns // the rendering policy; extensions never write terminal bytes directly. type AlertRequest = extproto.AlertRequest const AlertKindBell = extproto.AlertKindBell // ToolContent is one block of tool output. Either Text is set, or // MimeType+Data (base64-encoded). Use the Text/Image helpers below. type ToolContent struct { Type string // "text" | "image" Text string MimeType string Data string // base64 } // Text returns a text content block. func Text(s string) ToolContent { return ToolContent{Type: "text", Text: s} } // Image returns an image content block. data must already be // base64-encoded; use ImageBytes to encode raw bytes. func Image(mimeType, base64Data string) ToolContent { return ToolContent{Type: "image", MimeType: mimeType, Data: base64Data} } // ImageBytes returns an image content block from raw bytes, // encoding them to base64 for the wire. func ImageBytes(mimeType string, data []byte) ToolContent { return ToolContent{Type: "image", MimeType: mimeType, Data: base64Encode(data)} } // TextResult returns a tool result with one text block, success. func TextResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}} } // TextErrorResult returns a tool result with one text block, marked // as an error to the model. func TextErrorResult(s string) ToolResult { return ToolResult{Content: []ToolContent{Text(s)}, IsError: true} } // Response tells zut how to react to a command invocation. Construct // one with Prompt(), Insert(), Display(), or Noop(). type Panel struct { ID string Title string Lines []string Footer string } type Response struct { Action string // "prompt", "insert", "display", "open_panel", "noop" Prompt string Insert string Display string OpenPanel *Panel Error string } // Prompt returns a Response that submits text as a fresh user message // to the agent (running it through the model loop as if the user had // typed and pressed enter). func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } // Insert returns a Response that drops text into the editor at the // cursor without submitting. func Insert(text string) Response { return Response{Action: "insert", Insert: text} } // Display returns a Response that adds a one-shot styled note to the // chat without invoking the model. Useful for showing a result without // burning tokens. func Display(text string) Response { return Response{Action: "display", Display: text} } // OpenPanel returns a Response that opens an interactive extension-owned // panel inside zut. func OpenPanel(id, title string, lines []string, footer string) Response { return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}} } // Noop returns a Response that signals "I handled it, no UI change". // Use after pushing your own state or notifications. func Noop() Response { return Response{Action: "noop"} } // Errorf returns a Response that surfaces the error in the chat as a // red status line. func Errorf(format string, args ...any) Response { return Response{Action: "noop", Error: fmt.Sprintf(format, args...)} } // Extension is one zut extension. Construct with New, register // commands, then call Run. type Extension struct { name string version string in io.Reader out io.Writer stderr io.Writer writeMu sync.Mutex mu sync.Mutex commands map[string]CommandHandler descriptions []descTuple // ordered so register frames arrive in registration order tools map[string]ToolHandler toolDefs []toolDef // ordered so register frames arrive in registration order eventHandlers map[string]EventHandler eventNames []string // declared subscription order interceptTool InterceptHandler interceptToolRich ToolCallHandler interceptOn bool interceptTurn TurnStartHandler interceptAssistant AssistantMessageHandler panelKeys map[string]func(key, text string) panelCloses map[string]func() onHello func(HostInfo) // Caps reported in the hello frame. caps []string // hostInfo is filled in once HelloAck arrives. host HostInfo } type descTuple struct { name, desc string } type toolDef struct { name string description string schema json.RawMessage deferred bool } // HostInfo is what the host (zut) tells us in HelloAck. Useful for // extensions that want to behave differently per provider. type HostInfo struct { ProtocolVersion int ZutVersion string Provider string Model string CWD string ExtensionDir string DataDir string Session *extproto.SessionContext } // New constructs an Extension with the given identifier. name should // match the name field in extension.json. func New(name, version string) *Extension { return &Extension{ name: name, version: version, in: os.Stdin, out: os.Stdout, stderr: os.Stderr, commands: map[string]CommandHandler{}, tools: map[string]ToolHandler{}, eventHandlers: map[string]EventHandler{}, panelKeys: map[string]func(key, text string){}, panelCloses: map[string]func(){}, caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"}, } } // Host returns the HostInfo received during the hello handshake. // Returns the zero value if Run hasn't started yet. func (e *Extension) Host() HostInfo { return e.host } // OnHello registers a callback that runs after zut acknowledges the // hello frame and before commands/tools are announced. Use it when // registrations depend on host metadata such as CWD or ZutHome-derived // paths. The callback may call Command, Tool, On, and interceptor // registration methods. func (e *Extension) OnHello(fn func(HostInfo)) { e.mu.Lock() e.onHello = fn e.mu.Unlock() } // Logf writes a line to the extension's stderr, which zut captures to // $ZUT_HOME/logs/ext-.log. Use this for debug output: anything // you print to stdout would corrupt the JSON wire protocol. func (e *Extension) Logf(format string, args ...any) { fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...) } // OnPanelKey registers callbacks for panel key + close events. func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) { e.mu.Lock() defer e.mu.Unlock() if onKey != nil { e.panelKeys[panelID] = onKey } if onClose != nil { e.panelCloses[panelID] = onClose } } // OpenPanel opens an interactive panel spontaneously from extension code // without requiring a slash command. Safe to call from a tool handler goroutine // or any background context. The panel receives panel_key events via OnPanelKey // and can be dismissed with ClosePanel, exactly as with command-response panels. func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { _ = e.send(extproto.OpenPanelFromExt{ Type: "open_panel", Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, }) } // RenderPanel pushes a fresh frame for panelID. func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) { _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer}) } // ClosePanel tells zut to close panelID. func (e *Extension) ClosePanel(panelID string) { _ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID}) } // Command registers a slash-command handler. Call this BEFORE Run(). // Once Run is going, when the user runs /name in zut, fn is invoked // with the remaining args. // // Naming conflicts with built-in commands (e.g. /help) are silently // shadowed by the built-in; check the user's "ext logs" if a command // you registered isn't taking effect. func (e *Extension) Command(name, description string, fn CommandHandler) { e.mu.Lock() e.commands[name] = fn e.descriptions = append(e.descriptions, descTuple{name: name, desc: description}) e.mu.Unlock() } // Tool registers an LLM-callable tool. schema is a JSON Schema // object describing the tool's args (the same shape Anthropic / // OpenAI accept). Call this BEFORE Run(); zut folds extension tools // into the agent's registry once the extension's ready frame fires. // // Conflicts with active built-in tool names (read, write, edit, bash, // python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, // schedule, update_goal, update_plan, and skill) are silently shadowed by the // built-in. Every native public-web name, plus grep, python, schedule, // update_goal, and update_plan, is reserved even when unavailable, so an // extension cannot claim or replace it. func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { e.registerTool(name, description, schema, false, fn) } // DeferredTool registers a tool whose definition stays hidden until another // tool result names it in ActivateTools. func (e *Extension) DeferredTool(name, description string, schema json.RawMessage, fn ToolHandler) { e.registerTool(name, description, schema, true, fn) } func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) { e.mu.Lock() e.tools[name] = fn e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred}) e.mu.Unlock() } // On subscribes to a lifecycle event. fn is called for each // notification; the same name can only have one handler (later // registrations replace earlier ones). Recognised names include: // session_start, session_opened, session_switched, session_forked, // session_compacted, turn_start, turn_end, tool_call, // tool_confirmation_requested, assistant_message. func (e *Extension) On(name string, fn EventHandler) { e.mu.Lock() if _, exists := e.eventHandlers[name]; !exists { e.eventNames = append(e.eventNames, name) } e.eventHandlers[name] = fn e.mu.Unlock() } // InterceptToolCall registers a guard that runs immediately before // each tool call. Returning (false, reason) refuses the call; the // model sees reason as the tool error text. Multiple extensions can // install interceptors; if any one refuses, the call is blocked. // // Use this to build permission gates: refuse `bash` calls containing // `rm -rf`, ask the user for confirmation on dangerous patterns, // audit-log every call, etc. // // For the richer form (arg rewrites, structured decisions) use // InterceptToolCallX. func (e *Extension) InterceptToolCall(fn InterceptHandler) { e.mu.Lock() e.interceptTool = fn e.interceptOn = true e.mu.Unlock() } // InterceptToolCallX is the richer variant. Return a ToolCallDecision // to block, allow, or rewrite args mid-flight. If both // InterceptToolCall and InterceptToolCallX are set, the X form wins. func (e *Extension) InterceptToolCallX(fn ToolCallHandler) { e.mu.Lock() e.interceptToolRich = fn e.interceptOn = true e.mu.Unlock() } // InterceptTurnStart registers a guard that runs before every turn's // model call. Return Block=true with Reason to abort the turn. // Useful for deny-by-default gates and usage quotas. func (e *Extension) InterceptTurnStart(fn TurnStartHandler) { e.mu.Lock() e.interceptTurn = fn e.mu.Unlock() } // InterceptAssistantMessage registers a guard that runs after the // model's final text is assembled but before it's shown. Return // Block=true to suppress entirely, or ReplaceText to rewrite what // the user sees. The model's original output stays in the transcript. func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) { e.mu.Lock() e.interceptAssistant = fn e.mu.Unlock() } // Notify pushes an info-level status note into zut's chat without // requiring a slash command from the user. func (e *Extension) Notify(level, message string) { _ = e.send(extproto.NotifyFromExt{ Type: "notify", Level: level, Message: message, }) } // SetStatus replaces one persistent status item owned by this extension. // Passing an empty text clears the item. func (e *Extension) SetStatus(key, text string) { _ = e.send(extproto.StatusFromExt{Type: "status", Key: key, Text: text}) } // ClearStatus removes one persistent status item owned by this extension. func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") } const ( // WidgetPositionAboveInput keeps a widget in the persistent chrome above // the editor. It is the default for empty and unknown positions. WidgetPositionAboveInput = extproto.WidgetPositionAboveInput // WidgetPositionRightBar is retained for source compatibility. Widgets use // the above-input placement because the right sidebar no longer exists. // Deprecated: use WidgetPositionAboveInput. WidgetPositionRightBar = extproto.WidgetPositionRightBar ) // SetWidget replaces one persistent widget owned by this extension. Use // WidgetPositionAboveInput for the position. func (e *Extension) SetWidget(id, position, title string, lines []string) { _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines}) } // ClearWidget removes one persistent widget owned by this extension. func (e *Extension) ClearWidget(id string) { _ = e.send(extproto.ClearWidgetFromExt{Type: "widget_clear", ID: id}) } // Alert asks the host to surface a structured alert. It is fire-and-forget; // hosts that do not support the requested kind may ignore it. func (e *Extension) Alert(alert AlertRequest) { _ = e.send(extproto.AlertFromExt{ Type: "alert", AlertRequest: alert, }) } // Run starts the protocol loop. Blocks until stdin closes (zut has // shut us down). Returns the first fatal error, or nil on clean exit. func (e *Extension) Run() error { scanner := bufio.NewScanner(e.in) scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) // Send hello first. zut answers with hello_ack immediately; read it // synchronously so extensions can use HostInfo while registering // commands/tools before the ready frame. if err := e.send(extproto.HelloFromExt{ Type: "hello", ProtocolVersion: extproto.ProtocolVersion, Name: e.name, Version: e.version, Capabilities: e.caps, }); err != nil { return err } if !scanner.Scan() { return scanner.Err() } var ack extproto.HelloAckFromHost if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil { return fmt.Errorf("parse hello_ack: %w", err) } if ack.Type != "hello_ack" { return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type) } if ack.ProtocolVersion != extproto.ProtocolVersion { return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion) } e.host = HostInfo{ ProtocolVersion: ack.ProtocolVersion, ZutVersion: ack.ZutVersion, Provider: ack.Provider, Model: ack.Model, CWD: ack.CWD, ExtensionDir: ack.ExtensionDir, DataDir: ack.DataDir, Session: ack.Session, } e.mu.Lock() onHello := e.onHello e.mu.Unlock() if onHello != nil { onHello(e.host) } // Re-announce all commands (covers Command calls made before Run and // from OnHello, and is also fine for those made after via Command()'s // direct send). e.mu.Lock() descs := append([]descTuple(nil), e.descriptions...) toolDefs := append([]toolDef(nil), e.toolDefs...) eventNames := append([]string(nil), e.eventNames...) interceptTool := e.interceptOn interceptTurn := e.interceptTurn != nil interceptAsst := e.interceptAssistant != nil e.mu.Unlock() for _, d := range descs { _ = e.send(extproto.RegisterCommandFromExt{ Type: "register_command", Name: d.name, Description: d.desc, }) } for _, td := range toolDefs { _ = e.send(extproto.RegisterToolFromExt{ Type: "register_tool", Name: td.name, Description: td.description, Schema: td.schema, Deferred: td.deferred, }) } var intercepts []string if interceptTool { intercepts = append(intercepts, "tool_call") } if interceptTurn { intercepts = append(intercepts, "turn_start") } if interceptAsst { intercepts = append(intercepts, "assistant_message") } if len(eventNames) > 0 || len(intercepts) > 0 { _ = e.send(extproto.SubscribeFromExt{ Type: "subscribe", Events: eventNames, Intercept: intercepts, }) } // Sentinel: tells the host that all initial registrations have // been flushed and the agent registry can be built. Never block // on this; if the host doesn't act on it, registrations still // land in time for the typical use case. _ = e.send(extproto.ReadyFromExt{Type: "ready"}) for scanner.Scan() { line := scanner.Bytes() var frame extproto.Frame if err := json.Unmarshal(line, &frame); err != nil { e.Logf("malformed frame from host: %v", err) continue } switch frame.Type { case "hello_ack": // Already handled before registrations; ignore duplicates. case "command_invoked": var ci extproto.CommandInvokedFromHost if err := json.Unmarshal(line, &ci); err != nil { continue } e.mu.Lock() fn := e.commands[ci.Name] e.mu.Unlock() if fn == nil { e.respond(ci.ID, Errorf("no handler for /%s", ci.Name)) continue } // Run the handler on its own goroutine so a slow handler // doesn't block subsequent commands. Order isn't promised. go func(id string, fn CommandHandler, args string) { defer func() { if r := recover(); r != nil { e.respond(id, Errorf("panic: %v", r)) } }() resp := fn(args) e.respond(id, resp) }(ci.ID, fn, ci.Args) case "tool_call": var tc extproto.ToolCallFromHost if err := json.Unmarshal(line, &tc); err != nil { continue } e.mu.Lock() fn := e.tools[tc.Name] e.mu.Unlock() if fn == nil { e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name))) continue } go func(id string, fn ToolHandler, args json.RawMessage) { defer func() { if r := recover(); r != nil { e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r))) } }() res := fn(args) e.respondTool(id, res) }(tc.ID, fn, tc.Args) case "event": var ef extproto.EventFromHost if err := json.Unmarshal(line, &ef); err != nil { continue } e.mu.Lock() handler := e.eventHandlers[ef.Event] e.mu.Unlock() if handler != nil { func() { defer func() { if r := recover(); r != nil { e.Logf("event %s handler panicked: %v", ef.Event, r) } }() handler(Event{ Name: ef.Event, Session: ef.Session, State: append(json.RawMessage(nil), ef.State...), Step: ef.Step, Stop: ef.Stop, Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName, ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text, }) }() } case "event_intercept": var ei extproto.EventInterceptFromHost if err := json.Unmarshal(line, &ei); err != nil { continue } go e.dispatchIntercept(ei) case "panel_key": var pk extproto.PanelKeyFromHost if err := json.Unmarshal(line, &pk); err != nil { continue } e.mu.Lock() h := e.panelKeys[pk.PanelID] e.mu.Unlock() if h != nil { go h(pk.Key, pk.Text) } case "panel_close": var pc extproto.PanelCloseFromHost if err := json.Unmarshal(line, &pc); err != nil { continue } e.mu.Lock() h := e.panelCloses[pc.PanelID] e.mu.Unlock() if h != nil { go h() } case "shutdown": _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) return nil default: e.Logf("unknown frame type %q", frame.Type) } } return scanner.Err() } // respond serialises a CommandResponseFromExt for the given id. func (e *Extension) respond(id string, r Response) { if r.Action == "" { r.Action = "noop" } var panel *extproto.PanelSpec if r.OpenPanel != nil { panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer} } _ = e.send(extproto.CommandResponseFromExt{ Type: "command_response", ID: id, Action: r.Action, Prompt: r.Prompt, Insert: r.Insert, Display: r.Display, OpenPanel: panel, Error: r.Error, }) } // respondTool serialises a ToolResultFromExt for the given id. func (e *Extension) respondTool(id string, r ToolResult) { blocks := make([]extproto.ContentBlock, 0, len(r.Content)) for _, c := range r.Content { blocks = append(blocks, extproto.ContentBlock{ Type: c.Type, Text: c.Text, MimeType: c.MimeType, Data: c.Data, }) } _ = e.send(extproto.ToolResultFromExt{ Type: "tool_result", ID: id, Content: blocks, IsError: r.IsError, ActivateTools: r.ActivateTools, Details: append(json.RawMessage(nil), r.Details...), }) } // send marshals v + LF and writes it under a mutex (so concurrent // goroutines don't interleave bytes on stdout). func (e *Extension) send(v any) error { b, err := extproto.Encode(v) if err != nil { return err } e.writeMu.Lock() defer e.writeMu.Unlock() _, err = e.out.Write(b) return err } // dispatchIntercept runs the per-event handler (tool_call / turn_start // / assistant_message) on its own goroutine, catches panics, and // always emits exactly one event_intercept_response. Called from the // Run loop. func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) { defer func() { if r := recover(); r != nil { _ = e.send(extproto.EventInterceptResponseFromExt{ Type: "event_intercept_response", ID: ei.ID, Block: true, Reason: fmt.Sprintf("intercept panic: %v", r), }) } }() resp := extproto.EventInterceptResponseFromExt{ Type: "event_intercept_response", ID: ei.ID, } switch ei.Event { case "tool_call": e.mu.Lock() rich := e.interceptToolRich plain := e.interceptTool e.mu.Unlock() if rich != nil { d := rich(ei.ToolName, ei.ToolArgs) resp.Block = d.Block resp.Reason = d.Reason if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) { resp.ModifiedArgs = d.ModifiedArgs } } else if plain != nil { allow, reason := plain(ei.ToolName, ei.ToolArgs) resp.Block = !allow resp.Reason = reason } case "turn_start": e.mu.Lock() fn := e.interceptTurn e.mu.Unlock() if fn != nil { d := fn(ei.Step) resp.Block = d.Block resp.Reason = d.Reason if !d.Block { resp.Context = d.Context } } case "assistant_message": e.mu.Lock() fn := e.interceptAssistant e.mu.Unlock() if fn != nil { d := fn(ei.Text) resp.Block = d.Block resp.Reason = d.Reason if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text { resp.ReplaceText = d.ReplaceText } } } _ = e.send(resp) }