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