v0.38.0 58133ea

ext.go RawHistoryFolder

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, glob, ast, lsp, web_search, web_open,
425// web_find, web_click, schedule, update_goal, update_plan, and skill) are
426// silently shadowed by the built-in. Every native public-web name, plus
427// grep, glob, ast, python, schedule,
428// update_goal, and update_plan, is reserved even when unavailable, so an
429// extension cannot claim or replace it.
430func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
431	e.registerTool(name, description, schema, false, fn)
432}
433
434// DeferredTool registers a tool whose definition stays hidden until another
435// tool result names it in ActivateTools.
436func (e *Extension) DeferredTool(name, description string, schema json.RawMessage, fn ToolHandler) {
437	e.registerTool(name, description, schema, true, fn)
438}
439
440func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
441	e.mu.Lock()
442	e.tools[name] = fn
443	e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
444	e.mu.Unlock()
445}
446
447// On subscribes to a lifecycle event. fn is called for each
448// notification; the same name can only have one handler (later
449// registrations replace earlier ones). Recognised names include:
450// session_start, session_opened, session_switched, session_forked,
451// session_compacted, turn_start, turn_end, tool_call,
452// tool_confirmation_requested, assistant_message.
453func (e *Extension) On(name string, fn EventHandler) {
454	e.mu.Lock()
455	if _, exists := e.eventHandlers[name]; !exists {
456		e.eventNames = append(e.eventNames, name)
457	}
458	e.eventHandlers[name] = fn
459	e.mu.Unlock()
460}
461
462// InterceptToolCall registers a guard that runs immediately before
463// each tool call. Returning (false, reason) refuses the call; the
464// model sees reason as the tool error text. Multiple extensions can
465// install interceptors; if any one refuses, the call is blocked.
466//
467// Use this to build permission gates: refuse `bash` calls containing
468// `rm -rf`, ask the user for confirmation on dangerous patterns,
469// audit-log every call, etc.
470//
471// For the richer form (arg rewrites, structured decisions) use
472// InterceptToolCallX.
473func (e *Extension) InterceptToolCall(fn InterceptHandler) {
474	e.mu.Lock()
475	e.interceptTool = fn
476	e.interceptOn = true
477	e.mu.Unlock()
478}
479
480// InterceptToolCallX is the richer variant. Return a ToolCallDecision
481// to block, allow, or rewrite args mid-flight. If both
482// InterceptToolCall and InterceptToolCallX are set, the X form wins.
483func (e *Extension) InterceptToolCallX(fn ToolCallHandler) {
484	e.mu.Lock()
485	e.interceptToolRich = fn
486	e.interceptOn = true
487	e.mu.Unlock()
488}
489
490// InterceptTurnStart registers a guard that runs before every turn's
491// model call. Return Block=true with Reason to abort the turn.
492// Useful for deny-by-default gates and usage quotas.
493func (e *Extension) InterceptTurnStart(fn TurnStartHandler) {
494	e.mu.Lock()
495	e.interceptTurn = fn
496	e.mu.Unlock()
497}
498
499// InterceptAssistantMessage registers a guard that runs after the
500// model's final text is assembled but before it's shown. Return
501// Block=true to suppress entirely, or ReplaceText to rewrite what
502// the user sees. The model's original output stays in the transcript.
503func (e *Extension) InterceptAssistantMessage(fn AssistantMessageHandler) {
504	e.mu.Lock()
505	e.interceptAssistant = fn
506	e.mu.Unlock()
507}
508
509// Notify pushes an info-level status note into zut's chat without
510// requiring a slash command from the user.
511func (e *Extension) Notify(level, message string) {
512	_ = e.send(extproto.NotifyFromExt{
513		Type:    "notify",
514		Level:   level,
515		Message: message,
516	})
517}
518
519// SetStatus replaces one persistent status item owned by this extension.
520// Passing an empty text clears the item.
521func (e *Extension) SetStatus(key, text string) {
522	_ = e.send(extproto.StatusFromExt{Type: "status", Key: key, Text: text})
523}
524
525// ClearStatus removes one persistent status item owned by this extension.
526func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") }
527
528const (
529	// WidgetPositionAboveInput keeps a widget in the persistent chrome above
530	// the editor. It is the default for empty and unknown positions.
531	WidgetPositionAboveInput = extproto.WidgetPositionAboveInput
532	// WidgetPositionRightBar is retained for source compatibility. Widgets use
533	// the above-input placement because the right sidebar no longer exists.
534	// Deprecated: use WidgetPositionAboveInput.
535	WidgetPositionRightBar = extproto.WidgetPositionRightBar
536)
537
538// SetWidget replaces one persistent widget owned by this extension. Use
539// WidgetPositionAboveInput for the position.
540func (e *Extension) SetWidget(id, position, title string, lines []string) {
541	_ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})
542}
543
544// ClearWidget removes one persistent widget owned by this extension.
545func (e *Extension) ClearWidget(id string) {
546	_ = e.send(extproto.ClearWidgetFromExt{Type: "widget_clear", ID: id})
547}
548
549// Alert asks the host to surface a structured alert. It is fire-and-forget;
550// hosts that do not support the requested kind may ignore it.
551func (e *Extension) Alert(alert AlertRequest) {
552	_ = e.send(extproto.AlertFromExt{
553		Type:         "alert",
554		AlertRequest: alert,
555	})
556}
557
558// Run starts the protocol loop. Blocks until stdin closes (zut has
559// shut us down). Returns the first fatal error, or nil on clean exit.
560func (e *Extension) Run() error {
561	scanner := bufio.NewScanner(e.in)
562	scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
563
564	// Send hello first. zut answers with hello_ack immediately; read it
565	// synchronously so extensions can use HostInfo while registering
566	// commands/tools before the ready frame.
567	if err := e.send(extproto.HelloFromExt{
568		Type:            "hello",
569		ProtocolVersion: extproto.ProtocolVersion,
570		Name:            e.name,
571		Version:         e.version,
572		Capabilities:    e.caps,
573	}); err != nil {
574		return err
575	}
576	if !scanner.Scan() {
577		return scanner.Err()
578	}
579	var ack extproto.HelloAckFromHost
580	if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil {
581		return fmt.Errorf("parse hello_ack: %w", err)
582	}
583	if ack.Type != "hello_ack" {
584		return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
585	}
586	if ack.ProtocolVersion != extproto.ProtocolVersion {
587		return fmt.Errorf("unsupported host protocol version %d (want %d)", ack.ProtocolVersion, extproto.ProtocolVersion)
588	}
589	e.host = HostInfo{
590		ProtocolVersion: ack.ProtocolVersion,
591		ZutVersion:      ack.ZutVersion,
592		Provider:        ack.Provider,
593		Model:           ack.Model,
594		CWD:             ack.CWD,
595		ExtensionDir:    ack.ExtensionDir,
596		DataDir:         ack.DataDir,
597		Session:         ack.Session,
598	}
599	e.mu.Lock()
600	onHello := e.onHello
601	e.mu.Unlock()
602	if onHello != nil {
603		onHello(e.host)
604	}
605
606	// Re-announce all commands (covers Command calls made before Run and
607	// from OnHello, and is also fine for those made after via Command()'s
608	// direct send).
609	e.mu.Lock()
610	descs := append([]descTuple(nil), e.descriptions...)
611	toolDefs := append([]toolDef(nil), e.toolDefs...)
612	eventNames := append([]string(nil), e.eventNames...)
613	interceptTool := e.interceptOn
614	interceptTurn := e.interceptTurn != nil
615	interceptAsst := e.interceptAssistant != nil
616	e.mu.Unlock()
617	for _, d := range descs {
618		_ = e.send(extproto.RegisterCommandFromExt{
619			Type:        "register_command",
620			Name:        d.name,
621			Description: d.desc,
622		})
623	}
624	for _, td := range toolDefs {
625		_ = e.send(extproto.RegisterToolFromExt{
626			Type:        "register_tool",
627			Name:        td.name,
628			Description: td.description,
629			Schema:      td.schema,
630			Deferred:    td.deferred,
631		})
632	}
633	var intercepts []string
634	if interceptTool {
635		intercepts = append(intercepts, "tool_call")
636	}
637	if interceptTurn {
638		intercepts = append(intercepts, "turn_start")
639	}
640	if interceptAsst {
641		intercepts = append(intercepts, "assistant_message")
642	}
643	if len(eventNames) > 0 || len(intercepts) > 0 {
644		_ = e.send(extproto.SubscribeFromExt{
645			Type:      "subscribe",
646			Events:    eventNames,
647			Intercept: intercepts,
648		})
649	}
650	// Sentinel: tells the host that all initial registrations have
651	// been flushed and the agent registry can be built. Never block
652	// on this; if the host doesn't act on it, registrations still
653	// land in time for the typical use case.
654	_ = e.send(extproto.ReadyFromExt{Type: "ready"})
655
656	for scanner.Scan() {
657		line := scanner.Bytes()
658		var frame extproto.Frame
659		if err := json.Unmarshal(line, &frame); err != nil {
660			e.Logf("malformed frame from host: %v", err)
661			continue
662		}
663		switch frame.Type {
664		case "hello_ack":
665			// Already handled before registrations; ignore duplicates.
666		case "command_invoked":
667			var ci extproto.CommandInvokedFromHost
668			if err := json.Unmarshal(line, &ci); err != nil {
669				continue
670			}
671			e.mu.Lock()
672			fn := e.commands[ci.Name]
673			e.mu.Unlock()
674			if fn == nil {
675				e.respond(ci.ID, Errorf("no handler for /%s", ci.Name))
676				continue
677			}
678			// Run the handler on its own goroutine so a slow handler
679			// doesn't block subsequent commands. Order isn't promised.
680			go func(id string, fn CommandHandler, args string) {
681				defer func() {
682					if r := recover(); r != nil {
683						e.respond(id, Errorf("panic: %v", r))
684					}
685				}()
686				resp := fn(args)
687				e.respond(id, resp)
688			}(ci.ID, fn, ci.Args)
689		case "tool_call":
690			var tc extproto.ToolCallFromHost
691			if err := json.Unmarshal(line, &tc); err != nil {
692				continue
693			}
694			e.mu.Lock()
695			fn := e.tools[tc.Name]
696			e.mu.Unlock()
697			if fn == nil {
698				e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
699				continue
700			}
701			go func(id string, fn ToolHandler, args json.RawMessage) {
702				defer func() {
703					if r := recover(); r != nil {
704						e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
705					}
706				}()
707				res := fn(args)
708				e.respondTool(id, res)
709			}(tc.ID, fn, tc.Args)
710		case "event":
711			var ef extproto.EventFromHost
712			if err := json.Unmarshal(line, &ef); err != nil {
713				continue
714			}
715			e.mu.Lock()
716			handler := e.eventHandlers[ef.Event]
717			e.mu.Unlock()
718			if handler != nil {
719				func() {
720					defer func() {
721						if r := recover(); r != nil {
722							e.Logf("event %s handler panicked: %v", ef.Event, r)
723						}
724					}()
725					handler(Event{
726						Name: ef.Event, Session: ef.Session, State: append(json.RawMessage(nil), ef.State...),
727						Step: ef.Step, Stop: ef.Stop,
728						Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
729						ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
730					})
731				}()
732			}
733		case "event_intercept":
734			var ei extproto.EventInterceptFromHost
735			if err := json.Unmarshal(line, &ei); err != nil {
736				continue
737			}
738			go e.dispatchIntercept(ei)
739		case "panel_key":
740			var pk extproto.PanelKeyFromHost
741			if err := json.Unmarshal(line, &pk); err != nil {
742				continue
743			}
744			e.mu.Lock()
745			h := e.panelKeys[pk.PanelID]
746			e.mu.Unlock()
747			if h != nil {
748				go h(pk.Key, pk.Text)
749			}
750		case "panel_close":
751			var pc extproto.PanelCloseFromHost
752			if err := json.Unmarshal(line, &pc); err != nil {
753				continue
754			}
755			e.mu.Lock()
756			h := e.panelCloses[pc.PanelID]
757			e.mu.Unlock()
758			if h != nil {
759				go h()
760			}
761		case "shutdown":
762			_ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
763			return nil
764		default:
765			e.Logf("unknown frame type %q", frame.Type)
766		}
767	}
768	return scanner.Err()
769}
770
771// respond serialises a CommandResponseFromExt for the given id.
772func (e *Extension) respond(id string, r Response) {
773	if r.Action == "" {
774		r.Action = "noop"
775	}
776	var panel *extproto.PanelSpec
777	if r.OpenPanel != nil {
778		panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
779	}
780	_ = e.send(extproto.CommandResponseFromExt{
781		Type:      "command_response",
782		ID:        id,
783		Action:    r.Action,
784		Prompt:    r.Prompt,
785		Insert:    r.Insert,
786		Display:   r.Display,
787		OpenPanel: panel,
788		Error:     r.Error,
789	})
790}
791
792// respondTool serialises a ToolResultFromExt for the given id.
793func (e *Extension) respondTool(id string, r ToolResult) {
794	blocks := make([]extproto.ContentBlock, 0, len(r.Content))
795	for _, c := range r.Content {
796		blocks = append(blocks, extproto.ContentBlock{
797			Type:     c.Type,
798			Text:     c.Text,
799			MimeType: c.MimeType,
800			Data:     c.Data,
801		})
802	}
803	_ = e.send(extproto.ToolResultFromExt{
804		Type:          "tool_result",
805		ID:            id,
806		Content:       blocks,
807		IsError:       r.IsError,
808		ActivateTools: r.ActivateTools,
809		Details:       append(json.RawMessage(nil), r.Details...),
810	})
811}
812
813// send marshals v + LF and writes it under a mutex (so concurrent
814// goroutines don't interleave bytes on stdout).
815func (e *Extension) send(v any) error {
816	b, err := extproto.Encode(v)
817	if err != nil {
818		return err
819	}
820	e.writeMu.Lock()
821	defer e.writeMu.Unlock()
822	_, err = e.out.Write(b)
823	return err
824}
825
826// dispatchIntercept runs the per-event handler (tool_call / turn_start
827// / assistant_message) on its own goroutine, catches panics, and
828// always emits exactly one event_intercept_response. Called from the
829// Run loop.
830func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
831	defer func() {
832		if r := recover(); r != nil {
833			_ = e.send(extproto.EventInterceptResponseFromExt{
834				Type:   "event_intercept_response",
835				ID:     ei.ID,
836				Block:  true,
837				Reason: fmt.Sprintf("intercept panic: %v", r),
838			})
839		}
840	}()
841
842	resp := extproto.EventInterceptResponseFromExt{
843		Type: "event_intercept_response",
844		ID:   ei.ID,
845	}
846
847	switch ei.Event {
848	case "tool_call":
849		e.mu.Lock()
850		rich := e.interceptToolRich
851		plain := e.interceptTool
852		e.mu.Unlock()
853		if rich != nil {
854			d := rich(ei.ToolName, ei.ToolArgs)
855			resp.Block = d.Block
856			resp.Reason = d.Reason
857			if !d.Block && len(d.ModifiedArgs) > 0 && json.Valid(d.ModifiedArgs) {
858				resp.ModifiedArgs = d.ModifiedArgs
859			}
860		} else if plain != nil {
861			allow, reason := plain(ei.ToolName, ei.ToolArgs)
862			resp.Block = !allow
863			resp.Reason = reason
864		}
865	case "turn_start":
866		e.mu.Lock()
867		fn := e.interceptTurn
868		e.mu.Unlock()
869		if fn != nil {
870			d := fn(ei.Step)
871			resp.Block = d.Block
872			resp.Reason = d.Reason
873			if !d.Block {
874				resp.Context = d.Context
875			}
876		}
877	case "assistant_message":
878		e.mu.Lock()
879		fn := e.interceptAssistant
880		e.mu.Unlock()
881		if fn != nil {
882			d := fn(ei.Text)
883			resp.Block = d.Block
884			resp.Reason = d.Reason
885			if !d.Block && d.ReplaceText != "" && d.ReplaceText != ei.Text {
886				resp.ReplaceText = d.ReplaceText
887			}
888		}
889	}
890
891	_ = e.send(resp)
892}

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 476

↑↓ moveenter openesc close