v0.3.14 9eb66e1

ext.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close