v0.38.0 58133ea

manager.go RawHistoryFolder

1// Package extensions implements the host side of zut's subprocess
2// extension protocol. The Manager discovers extensions in well-known
3// directories, spawns each one, completes the hello handshake, and
4// routes slash commands to the right extension.
5//
6// Each extension is its own process, communicating with zut over its
7// stdin/stdout in newline-delimited JSON. Stderr is redirected to a
8// per-extension log file under $ZUT_HOME/logs/. Crashing one
9// extension does not affect the others or the host.
10//
11// See docs/extensions.md for the user-facing reference and
12// packages/agent/extproto for the wire format.
13package extensions
14
15import (
16	"bufio"
17	"context"
18	"encoding/json"
19	"errors"
20	"fmt"
21	"io"
22	"os"
23	"os/exec"
24	"path/filepath"
25	"sort"
26	"strings"
27	"sync"
28	"syscall"
29	"time"
30
31	"github.com/bnema/zut/packages/agent/extproto"
32	"github.com/bnema/zut/packages/agent/skills"
33	"github.com/bnema/zut/packages/tui"
34)
35
36// Manifest is the extension.json file shipped alongside an
37// extension's executable. It tells zut how to launch the extension
38// and provides display metadata.
39type Manifest struct {
40	Name        string   `json:"name"`
41	Version     string   `json:"version,omitempty"`
42	Exec        string   `json:"exec"`               // executable path, relative to manifest dir
43	Args        []string `json:"args,omitempty"`     // extra argv passed to exec
44	Language    string   `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
45	Enabled     *bool    `json:"enabled,omitempty"`  // nil = enabled
46	Description string   `json:"description,omitempty"`
47	Skills      []string `json:"skills,omitempty"` // bundled skill directories
48}
49
50// IsEnabled returns the manifest's effective enabled state. Default
51// is true so adding a new extension folder Just Works without an
52// extra zut ext enable command.
53func (m Manifest) IsEnabled() bool {
54	return m.Enabled == nil || *m.Enabled
55}
56
57func loadManifestSkills(extDir string, mf Manifest) ([]*skills.Skill, []error) {
58	if len(mf.Skills) == 0 {
59		return nil, nil
60	}
61	root, err := filepath.Abs(extDir)
62	if err != nil {
63		return nil, []error{fmt.Errorf("resolve extension directory: %w", err)}
64	}
65	root, err = filepath.EvalSymlinks(root)
66	if err != nil {
67		return nil, []error{fmt.Errorf("resolve extension directory: %w", err)}
68	}
69	declared := make([]skills.BundledSkillDir, 0, len(mf.Skills))
70	var errs []error
71	for _, raw := range mf.Skills {
72		rel := strings.TrimSpace(raw)
73		if rel == "" || filepath.IsAbs(rel) {
74			errs = append(errs, fmt.Errorf("skill path %q must be a relative directory", raw))
75			continue
76		}
77		clean := filepath.Clean(rel)
78		if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
79			errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory", raw))
80			continue
81		}
82		candidate := filepath.Join(root, clean)
83		resolved, err := filepath.EvalSymlinks(candidate)
84		if err != nil {
85			errs = append(errs, fmt.Errorf("skill path %q: %w", raw, err))
86			continue
87		}
88		relResolved, err := filepath.Rel(root, resolved)
89		if err != nil || relResolved == ".." || strings.HasPrefix(relResolved, ".."+string(filepath.Separator)) {
90			errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory through a symlink", raw))
91			continue
92		}
93		declared = append(declared, skills.BundledSkillDir{Dir: resolved, Root: root, Source: "extension " + mf.Name})
94	}
95	loaded, loadErrs := skills.LoadBundled(declared)
96	return loaded, append(errs, loadErrs...)
97}
98
99// Extension is a running extension subprocess and the metadata zut
100// tracks about it.
101type Extension struct {
102	Manifest Manifest
103	Dir      string // absolute path to extension directory
104	LogPath  string
105
106	cmd      *exec.Cmd
107	stdin    io.WriteCloser
108	stdout   io.ReadCloser
109	logFile  *os.File
110	helloAck bool
111	commands []extproto.RegisterCommandFromExt
112	tools    []extproto.RegisterToolFromExt
113	skills   []*skills.Skill
114
115	writeMu sync.Mutex // serializes concurrent host→extension frames
116
117	// lifecycleFrames decouples session-event delivery from the host's
118	// synchronous control paths. The queue preserves lifecycle ordering while
119	// bounding backpressure from an extension that stops reading stdin.
120	lifecycleFrames   chan []byte
121	lifecycleStop     chan struct{}
122	lifecycleStopOnce sync.Once
123
124	// readyCh is closed when the extension sends a ReadyFromExt
125	// frame, or when the host gives up waiting (registrationGrace).
126	readyCh   chan struct{}
127	readyOnce sync.Once
128
129	// pending command invocations waiting on a CommandResponseFromExt
130	// keyed by the id we sent in CommandInvokedFromHost.
131	// pendingTool is the same idea for tool calls.
132	// pendingIntercept is the same idea for event_intercept calls.
133	mu               sync.Mutex
134	pending          map[string]chan extproto.CommandResponseFromExt
135	pendingTool      map[string]chan extproto.ToolResultFromExt
136	pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt
137
138	// lastFrameTime is updated by the read loop on every frame it
139	// processes. Used by the auto-ready idle watchdog so legacy
140	// extensions (no `ready` frame) don't pin the WaitForReady wait
141	// to its full grace.
142	lastFrameTime time.Time
143
144	// eventSubs and interceptSubs are the sets of event names this
145	// extension subscribed to via SubscribeFromExt. Used by
146	// EmitEvent / InterceptToolCall to filter recipients.
147	eventSubs     map[string]struct{}
148	interceptSubs map[string]struct{}
149
150	autoReady     bool
151	readyTimedOut bool
152	diagnostics   []string
153}
154
155// HostHooks is the small interface the manager calls back into the
156// running TUI through. Decouples extensions from interactive.go.
157type HostHooks interface {
158	// Notify pushes an ext-originated status message into the chat.
159	// Level is one of "info", "warn", "error", "success".
160	Notify(extName, level, message string)
161
162	// Submit feeds text as if the user had typed and pressed enter,
163	// running it through the agent loop.
164	Submit(text string)
165
166	// SubmitSlash runs text as a slash command in the TUI as if the
167	// user had typed it (text must start with '/'). Unlike Submit it
168	// does NOT run text through the model. Wired to the spontaneous
169	// submit_slash frame from extensions; ignored when the host is
170	// not interactive.
171	SubmitSlash(text string)
172
173	// Insert places text at the cursor in the editor.
174	Insert(text string)
175
176	// Display appends a one-shot styled note to the chat without
177	// invoking the model and without writing to the transcript.
178	Display(extName, text string)
179
180	// ClearNotes removes any notes previously pushed by extName via
181	// Notify/Display so transient status lines do not stack forever.
182	ClearNotes(extName string)
183
184	OpenPanel(extName string, spec extproto.PanelSpec)
185	UpdatePanel(extName, panelID, title string, lines []string, footer string)
186	ClosePanel(extName, panelID string)
187
188	// SetStatus and SetWidget maintain persistent extension-owned UI state.
189	// Hosts without a persistent UI may ignore these calls.
190	SetStatus(extName, key, level, text string)
191	SetWidget(extName, id, position, title string, lines []string)
192	ClearWidget(extName, id string)
193}
194
195// AlertHostHooks is an optional extension to HostHooks. Hosts that support
196// structured alerts implement it without breaking older embedders that only
197// implement the original HostHooks surface.
198type AlertHostHooks interface {
199	Alert(extName string, alert extproto.AlertRequest)
200}
201
202// ExtensionChromeHooks is an optional lifecycle hook for hosts that retain
203// persistent extension-owned UI. The manager calls it when an extension exits
204// so a crash, disable, or reload cannot leave stale widgets or statuses.
205type ExtensionChromeHooks interface {
206	ClearExtensionChrome(extName string)
207}
208
209type commandRegistration struct {
210	ext  *Extension
211	name string
212}
213
214// Manager owns every extension subprocess for the lifetime of zut.
215type Manager struct {
216	zutHome    string
217	cwd        string
218	zutVersion string
219	provider   string
220	model      string
221	hooks      HostHooks
222
223	mu  sync.RWMutex
224	ext map[string]*Extension // keyed by manifest name
225
226	// commandIndex maps a lower-cased slash-command name (without the
227	// leading /) to its canonical registration. First-come-first-served:
228	// later registrations that differ only by case are dropped with a warning.
229	commandIndex map[string]commandRegistration
230
231	// toolIndex maps an extension-defined tool name to its owning
232	// extension. Same first-come-first-served rule as commandIndex.
233	toolIndex map[string]*Extension
234
235	// explicitPaths remembers ad-hoc paths passed via --ext so
236	// Reload can respawn them alongside the discovered set.
237	explicitPaths []string
238
239	// onReload, if set, is invoked after a successful Reload. Used
240	// by the host so it can rebuild the agent's tool registry with
241	// the freshly-registered extension tools.
242	onReload func()
243
244	// activeSession is replayed to extensions loaded after the current
245	// session was opened, such as after /reload-ext.
246	activeSession *sessionEventState
247	sessionEmitMu sync.Mutex
248}
249
250type sessionEventState struct {
251	event  extproto.EventFromHost
252	states map[string]json.RawMessage
253}
254
255// New constructs an empty Manager. Call Discover to populate it from
256// the on-disk extension directories.
257func New(zutHome, cwd, zutVersion, provider, model string, hooks HostHooks) *Manager {
258	return &Manager{
259		zutHome:      zutHome,
260		cwd:          cwd,
261		zutVersion:   zutVersion,
262		provider:     provider,
263		model:        model,
264		hooks:        hooks,
265		ext:          map[string]*Extension{},
266		commandIndex: map[string]commandRegistration{},
267		toolIndex:    map[string]*Extension{},
268	}
269}
270
271// Discover scans the global and project extension dirs and starts
272// every extension whose manifest is enabled. Spawns happen in
273// parallel so a slow runtime (e.g. `npx tsx` cold-start, ~1.5s)
274// doesn't block other extensions from starting. Returns a slice of
275// errors encountered (one per extension); a single bad extension
276// doesn't abort the rest.
277func (m *Manager) Discover(ctx context.Context) []error {
278	type loadJob struct {
279		dir string
280	}
281	var jobs []loadJob
282	seenDirs := map[string]bool{} // dedup by basename so project wins
283	for _, dir := range m.searchDirs() {
284		entries, err := os.ReadDir(dir)
285		if err != nil {
286			continue // missing directory is fine
287		}
288		for _, e := range entries {
289			if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
290				continue
291			}
292			extDir := filepath.Join(dir, e.Name())
293			if _, err := os.Stat(filepath.Join(extDir, "extension.json")); errors.Is(err, os.ErrNotExist) {
294				continue
295			}
296			if seenDirs[e.Name()] {
297				continue // higher-priority location already queued
298			}
299			seenDirs[e.Name()] = true
300			jobs = append(jobs, loadJob{dir: extDir})
301		}
302	}
303
304	var wg sync.WaitGroup
305	errCh := make(chan error, len(jobs))
306	for _, j := range jobs {
307		wg.Add(1)
308		go func(extDir string) {
309			defer wg.Done()
310			if err := m.loadOne(ctx, extDir); err != nil {
311				errCh <- err
312			}
313		}(j.dir)
314	}
315	wg.Wait()
316	close(errCh)
317
318	var errs []error
319	for e := range errCh {
320		errs = append(errs, e)
321	}
322	return errs
323}
324
325// searchDirs returns the directories the discoverer walks, in
326// priority order: project-local first (so a project can override
327// global behavior), then global.
328func (m *Manager) searchDirs() []string {
329	var dirs []string
330	if m.cwd != "" {
331		dirs = append(dirs, filepath.Join(m.cwd, ".zut", "extensions"))
332	}
333	if m.zutHome != "" {
334		dirs = append(dirs, filepath.Join(m.zutHome, "extensions"))
335	}
336	return dirs
337}
338
339// loadOne reads a single extension's manifest and, if enabled,
340// spawns its subprocess + completes the hello handshake.
341func (m *Manager) loadOne(ctx context.Context, dir string) error {
342	manifestPath := filepath.Join(dir, "extension.json")
343	raw, err := os.ReadFile(manifestPath)
344	if err != nil {
345		return fmt.Errorf("%s: read manifest: %w", dir, err)
346	}
347	var mf Manifest
348	if err := json.Unmarshal(raw, &mf); err != nil {
349		return fmt.Errorf("%s: parse manifest: %w", dir, err)
350	}
351	if mf.Name == "" {
352		return fmt.Errorf("%s: manifest: name is required", dir)
353	}
354	hasTheme := HasExtensionTheme(dir)
355	if mf.Exec == "" && !hasTheme {
356		return fmt.Errorf("%s: manifest: exec is required", dir)
357	}
358	if !mf.IsEnabled() {
359		// Quietly skip disabled extensions; zut ext list will show them.
360		return nil
361	}
362
363	m.mu.RLock()
364	_, dup := m.ext[mf.Name]
365	m.mu.RUnlock()
366	if dup {
367		// Project-local copy already won; ignore the global one.
368		return nil
369	}
370
371	bundledSkills, skillErrs := loadManifestSkills(dir, mf)
372	ext := &Extension{
373		Manifest:         mf,
374		Dir:              dir,
375		readyCh:          make(chan struct{}),
376		pending:          map[string]chan extproto.CommandResponseFromExt{},
377		pendingTool:      map[string]chan extproto.ToolResultFromExt{},
378		pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
379		eventSubs:        map[string]struct{}{},
380		interceptSubs:    map[string]struct{}{},
381		skills:           bundledSkills,
382		lifecycleFrames:  make(chan []byte, 64),
383		lifecycleStop:    make(chan struct{}),
384	}
385	for _, skillErr := range skillErrs {
386		ext.diagnostics = append(ext.diagnostics, "skill: "+skillErr.Error())
387	}
388	if mf.Exec != "" {
389		if err := m.spawn(ctx, ext); err != nil {
390			language := ""
391			if mf.Language != "" {
392				language = fmt.Sprintf(" (declared language: %q)", mf.Language)
393			}
394			logDetail := ""
395			if ext.LogPath != "" {
396				logDetail = "\n  stderr log: " + ext.LogPath
397			}
398			return fmt.Errorf("Extension %s failed to start.\n\n  exec: %q%s\n  error: %w\n  extension directory: %s%s", mf.Name, mf.Exec, language, err, dir, logDetail)
399		}
400	} else {
401		ext.readyOnce.Do(func() { close(ext.readyCh) })
402	}
403
404	m.mu.Lock()
405	m.ext[mf.Name] = ext
406	// Note: ext.commands and ext.tools may be empty here — they're
407	// populated by the read loop as register_* frames arrive after
408	// hello. Indexing happens in the read loop too. Discover()'s
409	// caller can WaitForReady() before relying on the registries.
410	m.mu.Unlock()
411	return nil
412}
413
414// LoadExplicit loads each path as an ad-hoc extension. Used for
415// `zut --ext <path>` so extension authors can iterate on a working
416// copy without having to `zut ext install` after every change.
417//
418// Loaded BEFORE Discover so explicit paths win on name conflicts
419// against installed extensions. Spawns happen in parallel like the
420// regular discovery path; errors are returned per path.
421// HasExtensionTheme reports whether an extension directory contains a theme
422// file in one of the supported extension-owned locations.
423func HasExtensionTheme(dir string) bool {
424	for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {
425		if _, err := os.Stat(filepath.Join(dir, file)); err == nil {
426			return true
427		}
428	}
429	return false
430}
431
432func (m *Manager) ThemeOptions() []tui.ThemeOption {
433	m.mu.RLock()
434	defer m.mu.RUnlock()
435	names := make([]string, 0, len(m.ext))
436	for name := range m.ext {
437		names = append(names, name)
438	}
439	sort.Strings(names)
440	var out []tui.ThemeOption
441	for _, name := range names {
442		ext := m.ext[name]
443		for _, file := range []string{"theme.json", "themes/theme.json"} {
444			path := filepath.Join(ext.Dir, file)
445			value := path
446			if opt, ok := tui.ThemeOptionFromFile(path, value, "extension "+ext.Manifest.Name); ok {
447				out = append(out, opt)
448			}
449		}
450	}
451	return out
452}
453
454func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
455	if len(paths) == 0 {
456		return nil
457	}
458
459	var wg sync.WaitGroup
460	errCh := make(chan error, len(paths))
461	absPaths := make([]string, 0, len(paths))
462	for _, p := range paths {
463		abs, err := filepath.Abs(p)
464		if err != nil {
465			errCh <- fmt.Errorf("%s: %w", p, err)
466			continue
467		}
468		absPaths = append(absPaths, abs)
469		wg.Add(1)
470		go func(extDir string) {
471			defer wg.Done()
472			if err := m.loadOne(ctx, extDir); err != nil {
473				errCh <- err
474			}
475		}(abs)
476	}
477	wg.Wait()
478	close(errCh)
479
480	m.mu.Lock()
481	m.explicitPaths = append(m.explicitPaths, absPaths...)
482	m.mu.Unlock()
483
484	var errs []error
485	for e := range errCh {
486		errs = append(errs, e)
487	}
488	return errs
489}
490
491// SetOnReload registers a callback fired after a successful Reload.
492// Hosts use it to rebuild the agent's tool registry with freshly-
493// registered extension tools.
494func (m *Manager) SetOnReload(fn func()) {
495	m.mu.Lock()
496	m.onReload = fn
497	m.mu.Unlock()
498}
499
500// ReloadStats summarises the outcome of Reload.
501type ReloadStats struct {
502	Stopped int     // how many old processes were torn down
503	Loaded  int     // how many new processes reached spawn
504	Ready   int     // how many of those signalled ready in time
505	Errors  []error // non-fatal per-extension errors
506}
507
508// Reload tears down every running extension, re-reads the manifests
509// from disk, respawns everyone (including the --ext paths remembered
510// from LoadExplicit), waits up to grace for ready signals, and
511// invokes the SetOnReload callback so the host can rebuild its tool
512// registry. The manager's internal maps are cleared before the
513// new load to ensure a clean slate.
514//
515// Safe to call concurrently with normal host operations: the lock is
516// released between stop and respawn so pending InvokeTool / Invoke
517// calls on the old processes get a clean error as their stdin
518// closes.
519func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
520	stats := ReloadStats{}
521
522	// Snapshot and remember the explicit paths before we wipe state.
523	m.mu.Lock()
524	old := m.ext
525	explicit := append([]string(nil), m.explicitPaths...)
526	stats.Stopped = len(old)
527	m.ext = map[string]*Extension{}
528	m.commandIndex = map[string]commandRegistration{}
529	m.toolIndex = map[string]*Extension{}
530	m.explicitPaths = nil
531	callback := m.onReload
532	m.mu.Unlock()
533
534	// Graceful stop of the old set (reuses Stop's shutdown logic,
535	// but Stop re-reads m.ext which is now empty, so we replicate
536	// the small shutdown loop here on the snapshot).
537	oldExts := make([]*Extension, 0, len(old))
538	for _, ext := range old {
539		oldExts = append(oldExts, ext)
540	}
541	stopExtensions(oldExts, grace)
542
543	// Fresh load. Explicit paths first (they still win on conflict).
544	if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
545		stats.Errors = append(stats.Errors, errs...)
546	}
547	if errs := m.Discover(ctx); len(errs) > 0 {
548		stats.Errors = append(stats.Errors, errs...)
549	}
550
551	m.mu.RLock()
552	stats.Loaded = len(m.ext)
553	m.mu.RUnlock()
554
555	// Wait for ready frames. Use the same 3s grace zut uses at
556	// startup so the reload feels no slower than a cold boot.
557	readyDeadline := time.Now().Add(grace)
558	if time.Until(readyDeadline) < 3*time.Second {
559		readyDeadline = time.Now().Add(3 * time.Second)
560	}
561	m.WaitForReady(time.Until(readyDeadline))
562
563	m.mu.RLock()
564	for _, ext := range m.ext {
565		select {
566		case <-ext.readyCh:
567			stats.Ready++
568		default:
569		}
570	}
571	m.mu.RUnlock()
572
573	if callback != nil {
574		callback()
575	}
576
577	return stats
578}
579
580// WaitForReady blocks until every loaded extension has signalled
581// ReadyFromExt, or the grace period expires for the slowest one.
582//
583// Waits run in parallel: total time is max(per-extension wait), not
584// sum. Without this, a single slow extension (e.g. `npx tsx` cold)
585// would gate every other extension's wait too and zut startup would
586// scale linearly with the number of slow runtimes installed.
587//
588// Call after Discover and before relying on tool registrations.
589func (m *Manager) WaitForReady(grace time.Duration) {
590	m.mu.RLock()
591	exts := make([]*Extension, 0, len(m.ext))
592	for _, e := range m.ext {
593		exts = append(exts, e)
594	}
595	m.mu.RUnlock()
596
597	deadline := time.After(grace)
598	var wg sync.WaitGroup
599	for _, ext := range exts {
600		wg.Add(1)
601		go func(ext *Extension) {
602			defer wg.Done()
603			select {
604			case <-ext.readyCh:
605			case <-deadline:
606				ext.mu.Lock()
607				ext.readyTimedOut = true
608				ext.diagnostics = append(ext.diagnostics, "timed out waiting for ready frame")
609				ext.mu.Unlock()
610				fmt.Fprintf(ext.logFile, "[zut] timed out waiting for ready frame; proceeding\n")
611				ext.readyOnce.Do(func() { close(ext.readyCh) })
612			}
613		}(ext)
614	}
615	wg.Wait()
616}
617
618// extensionExitGrace bounds how long startup waits for a process to exit
619// after it closes stdout before completing the hello handshake.
620const extensionExitGrace = time.Second
621
622// ResolveExecPath applies the manifest exec resolution rules. The bool is
623// true when exec names an absolute or extension-relative path; false means
624// the value is a bare command resolved through PATH.
625func ResolveExecPath(extDir, execName string) (string, bool) {
626	normalized := filepath.FromSlash(execName)
627	if filepath.IsAbs(normalized) {
628		return normalized, true
629	}
630	if normalized == "." || normalized == ".." ||
631		strings.HasPrefix(normalized, "."+string(filepath.Separator)) ||
632		strings.HasPrefix(normalized, ".."+string(filepath.Separator)) ||
633		strings.ContainsRune(normalized, filepath.Separator) {
634		return filepath.Join(extDir, normalized), true
635	}
636	return normalized, false
637}
638
639// spawn launches the subprocess, hooks up pipes, logs stderr, and
640// runs the synchronous portion of the hello handshake. Asynchronous
641// frames are processed in a goroutine started here.
642func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
643	logsDir := filepath.Join(m.zutHome, "logs")
644	_ = os.MkdirAll(logsDir, 0o755)
645	logPath := filepath.Join(logsDir, "ext-"+ext.Manifest.Name+".log")
646	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
647	if err != nil {
648		return fmt.Errorf("open log: %w", err)
649	}
650	ext.LogPath = logPath
651	ext.logFile = logFile
652	fmt.Fprintf(logFile, "\n[zut] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339))
653
654	// Bare names (node, npx, python3, tsx, etc.) are looked up via PATH;
655	// absolute and extension-relative paths are resolved by the helper.
656	execPath, _ := ResolveExecPath(ext.Dir, ext.Manifest.Exec)
657	cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...)
658	cmd.Dir = ext.Dir
659	cmd.Stderr = logFile
660	isolateExtensionProcess(cmd)
661
662	var stdin io.WriteCloser
663	var stdout io.ReadCloser
664	started := false
665	cleanup := true
666	defer func() {
667		if !cleanup {
668			return
669		}
670		if stdin != nil {
671			_ = stdin.Close()
672		}
673		if stdout != nil {
674			_ = stdout.Close()
675		}
676		if started {
677			_ = cmd.Process.Kill()
678			_ = cmd.Wait()
679		}
680		_ = logFile.Close()
681	}()
682
683	stdin, err = cmd.StdinPipe()
684	if err != nil {
685		return fmt.Errorf("stdin pipe (stderr log: %s): %w", logPath, err)
686	}
687	stdout, err = cmd.StdoutPipe()
688	if err != nil {
689		return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
690	}
691	if err := cmd.Start(); err != nil {
692		return err
693	}
694	started = true
695	ext.cmd = cmd
696	ext.stdin = stdin
697	ext.stdout = stdout
698
699	// Hello handshake. Read the extension's HelloFromExt synchronously
700	// so we can fail fast on a broken extension; everything after is
701	// processed in the read goroutine.
702	scanner := bufio.NewScanner(stdout)
703	scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
704	if !scanner.Scan() {
705		if err := scanner.Err(); err != nil {
706			return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)
707		}
708		status, exited := waitForExtensionExit(cmd, extensionExitGrace)
709		started = false // waitForExtensionExit always reaps the process.
710		if exited {
711			return fmt.Errorf("extension exited before hello: %s (stderr log: %s)", status, logPath)
712		}
713		return fmt.Errorf("extension closed stdout before hello without exiting (stderr log: %s)", logPath)
714	}
715	var hello extproto.HelloFromExt
716	if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
717		return fmt.Errorf("parse hello (stderr log: %s): %w", logPath, err)
718	}
719	if hello.Type != "hello" || hello.Name == "" {
720		return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)
721	}
722	if hello.ProtocolVersion != extproto.ProtocolVersion {
723		return fmt.Errorf("unsupported extension protocol version %d (want %d; stderr log: %s)", hello.ProtocolVersion, extproto.ProtocolVersion, logPath)
724	}
725	// Trust the manifest's name; ignore mismatch from the hello.
726	ext.helloAck = true
727
728	m.mu.RLock()
729	var activeSession *extproto.SessionContext
730	if m.activeSession != nil {
731		activeSession = cloneSessionContext(m.activeSession.event.Session)
732	}
733	m.mu.RUnlock()
734	ack, _ := extproto.Encode(extproto.HelloAckFromHost{
735		Type:            "hello_ack",
736		ProtocolVersion: extproto.ProtocolVersion,
737		ZutVersion:      m.zutVersion,
738		Provider:        m.provider,
739		Model:           m.model,
740		CWD:             m.cwd,
741		ExtensionDir:    ext.Dir,
742		DataDir:         ext.Dir,
743		Session:         activeSession,
744	})
745	if _, err := stdin.Write(ack); err != nil {
746		return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)
747	}
748
749	cleanup = false
750
751	// Spin up the lifecycle writer and read loop now that the handshake is done.
752	ext.startLifecycleWriter()
753	go m.readLoop(ext, scanner)
754
755	// Compatibility shim: extensions built against the phase-1 SDK
756	// don't send a ready frame. Watch the read loop's frame arrival
757	// rate; if nothing's arrived for readyIdleWindow we treat the
758	// extension as ready so WaitForReady doesn't burn the full grace
759	// on every startup. Newer extensions still trigger the explicit
760	// path on their own ready frame.
761	go m.assumeReadyAfterIdle(ext)
762
763	return nil
764}
765
766// waitForExtensionExit returns the process status when it exits promptly.
767// A process that closes stdout but remains alive has already violated the
768// protocol, so kill and reap it rather than blocking extension discovery.
769func waitForExtensionExit(cmd *exec.Cmd, grace time.Duration) (string, bool) {
770	done := make(chan error, 1)
771	go func() { done <- cmd.Wait() }()
772
773	timer := time.NewTimer(grace)
774	defer timer.Stop()
775	select {
776	case err := <-done:
777		if cmd.ProcessState != nil {
778			return cmd.ProcessState.String(), true
779		}
780		if err != nil {
781			return err.Error(), true
782		}
783		return "exit status 0", true
784	case <-timer.C:
785		_ = cmd.Process.Kill()
786		<-done
787		return "", false
788	}
789}
790
791// readyIdleWindow is how long the manager waits for a frame after
792// hello before assuming an extension that doesn't send `ready` is
793// nevertheless ready. 250ms is enough for any well-behaved native
794// binary to flush its register frames; slow runtimes (npx tsx) flush
795// even faster once they've started, so this rarely affects them.
796const readyIdleWindow = 250 * time.Millisecond
797
798func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
799	ext.mu.Lock()
800	last := ext.lastFrameTime
801	ext.mu.Unlock()
802	for {
803		select {
804		case <-ext.readyCh:
805			return
806		case <-time.After(readyIdleWindow):
807		}
808		ext.mu.Lock()
809		current := ext.lastFrameTime
810		ext.mu.Unlock()
811		if current.Equal(last) {
812			// No new frame in the idle window. Treat as ready.
813			ext.readyOnce.Do(func() {
814				ext.mu.Lock()
815				ext.autoReady = true
816				ext.diagnostics = append(ext.diagnostics, "no ready frame; auto-ready after idle")
817				ext.mu.Unlock()
818				fmt.Fprintf(ext.logFile, "[zut] no ready frame; auto-readying after idle (legacy SDK?)\n")
819				close(ext.readyCh)
820			})
821			return
822		}
823		last = current
824	}
825}
826
827// readLoop processes every frame the extension sends after hello.
828// Returns when stdout closes.
829func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
830	defer func() {
831		// On close, drop every command + tool this extension owned so
832		// future invocations don't dangle. The subprocess is gone; we
833		// won't hear back about its commands or tool calls anymore.
834		m.mu.Lock()
835		for name, registration := range m.commandIndex {
836			if registration.ext == ext {
837				delete(m.commandIndex, name)
838			}
839		}
840		for name, owner := range m.toolIndex {
841			if owner == ext {
842				delete(m.toolIndex, name)
843			}
844		}
845		m.mu.Unlock()
846		if hooks, ok := m.hooks.(ExtensionChromeHooks); ok {
847			hooks.ClearExtensionChrome(ext.Manifest.Name)
848		}
849		ext.stopLifecycleWriter()
850		ext.readyOnce.Do(func() { close(ext.readyCh) })
851		fmt.Fprintf(ext.logFile, "[zut] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
852	}()
853
854	for scanner.Scan() {
855		line := scanner.Bytes()
856		ext.mu.Lock()
857		ext.lastFrameTime = time.Now()
858		ext.mu.Unlock()
859		var frame extproto.Frame
860		if err := json.Unmarshal(line, &frame); err != nil {
861			ext.recordDiagnostic(fmt.Sprintf("malformed json frame: %v", err))
862			fmt.Fprintf(ext.logFile, "[zut] malformed json from extension: %v\n", err)
863			continue
864		}
865		switch frame.Type {
866		case "register_command":
867			var rc extproto.RegisterCommandFromExt
868			if err := json.Unmarshal(line, &rc); err == nil {
869				// Both ext.commands and m.commandIndex are read by
870				// the public Commands() / HasCommand() helpers under
871				// m.mu, so the writes have to take the same lock to
872				// keep the race detector happy.
873				m.mu.Lock()
874				ext.commands = append(ext.commands, rc)
875				key := strings.ToLower(rc.Name)
876				_, shadowed := m.commandIndex[key]
877				if !shadowed {
878					m.commandIndex[key] = commandRegistration{ext: ext, name: rc.Name}
879				}
880				m.mu.Unlock()
881				if shadowed {
882					ext.recordDiagnostic(fmt.Sprintf("command /%s conflicts with another extension registering the same name", rc.Name))
883				}
884			}
885		case "register_tool":
886			var rt extproto.RegisterToolFromExt
887			if err := json.Unmarshal(line, &rt); err != nil {
888				fmt.Fprintf(ext.logFile, "[zut] bad register_tool frame: %v\n", err)
889				continue
890			}
891			// Validate the schema parses as JSON. If not, refuse to
892			// register — a broken schema confuses the model.
893			if len(rt.Schema) > 0 {
894				var tmp any
895				if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
896					ext.recordDiagnostic(fmt.Sprintf("tool %q schema is not valid json: %v", rt.Name, err))
897					fmt.Fprintf(ext.logFile, "[zut] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
898					continue
899				}
900			}
901			m.mu.Lock()
902			ext.tools = append(ext.tools, rt)
903			_, shadowed := m.toolIndex[rt.Name]
904			if !shadowed {
905				m.toolIndex[rt.Name] = ext
906			}
907			m.mu.Unlock()
908			if shadowed {
909				ext.recordDiagnostic(fmt.Sprintf("tool %s conflicts with another extension registering the same name", rt.Name))
910			}
911		case "ready":
912			ext.readyOnce.Do(func() { close(ext.readyCh) })
913			m.replaySessionEvent(ext)
914		case "subscribe":
915			var sub extproto.SubscribeFromExt
916			if err := json.Unmarshal(line, &sub); err == nil {
917				ext.mu.Lock()
918				for _, ev := range sub.Events {
919					ext.eventSubs[ev] = struct{}{}
920				}
921				for _, ev := range sub.Intercept {
922					switch ev {
923					case "tool_call", "turn_start", "assistant_message":
924						ext.interceptSubs[ev] = struct{}{}
925					}
926				}
927				ext.mu.Unlock()
928			}
929		case "event_intercept_response":
930			var er extproto.EventInterceptResponseFromExt
931			if err := json.Unmarshal(line, &er); err == nil {
932				ext.mu.Lock()
933				ch, ok := ext.pendingIntercept[er.ID]
934				if ok {
935					delete(ext.pendingIntercept, er.ID)
936				}
937				ext.mu.Unlock()
938				if ok {
939					select {
940					case ch <- er:
941					default:
942					}
943				}
944			}
945		case "tool_result":
946			var tr extproto.ToolResultFromExt
947			if err := json.Unmarshal(line, &tr); err == nil {
948				ext.mu.Lock()
949				ch, ok := ext.pendingTool[tr.ID]
950				if ok {
951					delete(ext.pendingTool, tr.ID)
952				}
953				ext.mu.Unlock()
954				if ok {
955					select {
956					case ch <- tr:
957					default:
958					}
959				}
960			}
961		case "notify":
962			var n extproto.NotifyFromExt
963			if err := json.Unmarshal(line, &n); err == nil && m.hooks != nil {
964				m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
965			}
966		case "status":
967			var s extproto.StatusFromExt
968			if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil {
969				m.hooks.SetStatus(ext.Manifest.Name, s.Key, s.Level, s.Text)
970			}
971		case "widget":
972			var w extproto.WidgetFromExt
973			if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil {
974				m.hooks.SetWidget(ext.Manifest.Name, w.ID, w.Position, w.Title, w.Lines)
975			}
976		case "widget_clear":
977			var w extproto.ClearWidgetFromExt
978			if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil {
979				m.hooks.ClearWidget(ext.Manifest.Name, w.ID)
980			}
981		case "alert":
982			var a extproto.AlertFromExt
983			if err := json.Unmarshal(line, &a); err == nil && m.hooks != nil {
984				if hooks, ok := m.hooks.(AlertHostHooks); ok {
985					hooks.Alert(ext.Manifest.Name, a.AlertRequest)
986				}
987			}
988		case "clear_notes":
989			if m.hooks != nil {
990				m.hooks.ClearNotes(ext.Manifest.Name)
991			}
992		case "submit":
993			var s extproto.SubmitFromExt
994			if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil {
995				text := strings.TrimSpace(s.Text)
996				if text != "" {
997					m.hooks.Submit(text)
998				}
999			}
1000		case "submit_slash":
1001			// Spontaneous request to invoke a slash command in the
1002			// TUI. Refused unless the payload looks like a slash
1003			// command so a misbehaving extension can't sneak a model
1004			// prompt through this path.
1005			var s extproto.SubmitSlashFromExt
1006			if err := json.Unmarshal(line, &s); err == nil {
1007				text := strings.TrimSpace(s.Text)
1008				if strings.HasPrefix(text, "/") && m.hooks != nil {
1009					m.hooks.SubmitSlash(text)
1010				} else {
1011					fmt.Fprintf(ext.logFile, "[zut] submit_slash refused (not a slash command): %q\n", s.Text)
1012				}
1013			}
1014		case "command_response":
1015			var cr extproto.CommandResponseFromExt
1016			if err := json.Unmarshal(line, &cr); err == nil {
1017				ext.mu.Lock()
1018				ch, ok := ext.pending[cr.ID]
1019				if ok {
1020					delete(ext.pending, cr.ID)
1021				}
1022				ext.mu.Unlock()
1023				if ok {
1024					select {
1025					case ch <- cr:
1026					default:
1027					}
1028				}
1029			}
1030		case "open_panel":
1031			var op extproto.OpenPanelFromExt
1032			if err := json.Unmarshal(line, &op); err == nil && m.hooks != nil {
1033				m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
1034			}
1035		case "panel_render":
1036			var pr extproto.PanelRenderFromExt
1037			if err := json.Unmarshal(line, &pr); err == nil && m.hooks != nil {
1038				m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
1039			}
1040		case "panel_close":
1041			var pc extproto.PanelCloseFromExt
1042			if err := json.Unmarshal(line, &pc); err == nil && m.hooks != nil {
1043				m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
1044			}
1045		case "shutdown_ack":
1046			// Caller of Stop is waiting on the process exit, not this frame.
1047		default:
1048			ext.recordDiagnostic(fmt.Sprintf("unknown frame type %q", frame.Type))
1049			fmt.Fprintf(ext.logFile, "[zut] unknown frame type %q\n", frame.Type)
1050		}
1051	}
1052}
1053
1054const lifecycleWriteGrace = 250 * time.Millisecond
1055
1056func (ext *Extension) writeFrame(frame []byte) (int, error) {
1057	if ext == nil || ext.stdin == nil {
1058		return 0, fmt.Errorf("extension stdin is closed")
1059	}
1060	ext.writeMu.Lock()
1061	defer ext.writeMu.Unlock()
1062	return ext.stdin.Write(frame)
1063}
1064
1065// tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared
1066// writer lock. Synchronous host frames retain their existing write behavior;
1067// a stalled lifecycle snapshot is dropped rather than delaying them.
1068func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error {
1069	if ext == nil || ext.stdin == nil {
1070		return fmt.Errorf("extension stdin is closed")
1071	}
1072	if !ext.writeMu.TryLock() {
1073		timer := time.NewTimer(lifecycleWriteGrace)
1074		defer timer.Stop()
1075		ticker := time.NewTicker(5 * time.Millisecond)
1076		defer ticker.Stop()
1077		for {
1078			select {
1079			case <-timer.C:
1080				return fmt.Errorf("extension lifecycle write is blocked")
1081			case <-ticker.C:
1082				if ext.writeMu.TryLock() {
1083					goto locked
1084				}
1085			}
1086		}
1087	}
1088locked:
1089	defer ext.writeMu.Unlock()
1090	_, err := ext.stdin.Write(frame)
1091	return err
1092}
1093
1094func (ext *Extension) startLifecycleWriter() {
1095	if ext.lifecycleFrames == nil {
1096		ext.lifecycleFrames = make(chan []byte, 64)
1097	}
1098	if ext.lifecycleStop == nil {
1099		ext.lifecycleStop = make(chan struct{})
1100	}
1101	go ext.lifecycleWriter()
1102}
1103
1104func (ext *Extension) stopLifecycleWriter() {
1105	if ext == nil || ext.lifecycleStop == nil {
1106		return
1107	}
1108	ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) })
1109}
1110
1111func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool {
1112	if ext == nil {
1113		return false
1114	}
1115	if ext.lifecycleFrames == nil || ext.lifecycleStop == nil {
1116		_, err := ext.writeFrame(frame)
1117		return err == nil
1118	}
1119	copyFrame := append([]byte(nil), frame...)
1120	select {
1121	case <-ext.lifecycleStop:
1122		return false
1123	default:
1124	}
1125	select {
1126	case ext.lifecycleFrames <- copyFrame:
1127		return true
1128	default:
1129		// Prefer the newest lifecycle snapshot when a broken extension stops
1130		// draining its pipe. The latest event still carries the current state.
1131		select {
1132		case <-ext.lifecycleFrames:
1133		default:
1134		}
1135		select {
1136		case ext.lifecycleFrames <- copyFrame:
1137			return true
1138		default:
1139			return false
1140		}
1141	}
1142}
1143
1144func (ext *Extension) lifecycleWriter() {
1145	for {
1146		select {
1147		case <-ext.lifecycleStop:
1148			return
1149		case frame := <-ext.lifecycleFrames:
1150			if err := ext.tryWriteLifecycleFrame(frame); err != nil {
1151				return
1152			}
1153		}
1154	}
1155}
1156
1157func (ext *Extension) recordDiagnostic(msg string) {
1158	ext.mu.Lock()
1159	defer ext.mu.Unlock()
1160	ext.diagnostics = append(ext.diagnostics, msg)
1161}
1162
1163// RegisteredCommandDiagnostic describes one command an extension attempted
1164// to register and whether it owns the active dispatch slot.
1165type RegisteredCommandDiagnostic struct {
1166	Name        string
1167	Description string
1168	Active      bool
1169}
1170
1171// RegisteredToolDiagnostic describes one tool an extension attempted to
1172// register and whether it owns the active dispatch slot.
1173type RegisteredToolDiagnostic struct {
1174	Name        string
1175	Description string
1176	Active      bool
1177}
1178
1179// ExtensionDiagnostic is a stable snapshot for diagnostic commands.
1180type ExtensionDiagnostic struct {
1181	Name          string
1182	Version       string
1183	Dir           string
1184	LogPath       string
1185	ThemeOnly     bool
1186	Ready         bool
1187	AutoReady     bool
1188	ReadyTimedOut bool
1189	Commands      []RegisteredCommandDiagnostic
1190	Tools         []RegisteredToolDiagnostic
1191	Messages      []string
1192}
1193
1194// Diagnostics returns a snapshot of loaded extension state for commands such
1195// as `zut ext doctor`. It is read-only and does not change manager behavior.
1196func (m *Manager) Diagnostics() []ExtensionDiagnostic {
1197	m.mu.RLock()
1198	defer m.mu.RUnlock()
1199	names := make([]string, 0, len(m.ext))
1200	for name := range m.ext {
1201		names = append(names, name)
1202	}
1203	sort.Strings(names)
1204	out := make([]ExtensionDiagnostic, 0, len(names))
1205	for _, name := range names {
1206		ext := m.ext[name]
1207		ext.mu.Lock()
1208		msgs := append([]string(nil), ext.diagnostics...)
1209		autoReady := ext.autoReady
1210		readyTimedOut := ext.readyTimedOut
1211		ext.mu.Unlock()
1212
1213		diag := ExtensionDiagnostic{
1214			Name:          ext.Manifest.Name,
1215			Version:       ext.Manifest.Version,
1216			Dir:           ext.Dir,
1217			LogPath:       ext.LogPath,
1218			ThemeOnly:     ext.Manifest.Exec == "",
1219			AutoReady:     autoReady,
1220			ReadyTimedOut: readyTimedOut,
1221			Messages:      msgs,
1222		}
1223		select {
1224		case <-ext.readyCh:
1225			diag.Ready = true
1226		default:
1227		}
1228		for _, c := range ext.commands {
1229			registration, ok := m.commandIndex[strings.ToLower(c.Name)]
1230			diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{
1231				Name:        c.Name,
1232				Description: c.Description,
1233				Active:      ok && registration.ext == ext && registration.name == c.Name,
1234			})
1235		}
1236		for _, t := range ext.tools {
1237			diag.Tools = append(diag.Tools, RegisteredToolDiagnostic{
1238				Name:        t.Name,
1239				Description: t.Description,
1240				Active:      m.toolIndex[t.Name] == ext,
1241			})
1242		}
1243		out = append(out, diag)
1244	}
1245	return out
1246}
1247
1248// Commands returns a snapshot of every (extension, command) pair
1249// currently registered. Used by the slash autocomplete + /help.
1250func (m *Manager) Commands() []CommandInfo {
1251	m.mu.RLock()
1252	defer m.mu.RUnlock()
1253	var out []CommandInfo
1254	for _, ext := range m.ext {
1255		for _, c := range ext.commands {
1256			registration, ok := m.commandIndex[strings.ToLower(c.Name)]
1257			if !ok || registration.ext != ext || registration.name != c.Name {
1258				continue
1259			}
1260			out = append(out, CommandInfo{
1261				Extension:   ext.Manifest.Name,
1262				Name:        c.Name,
1263				Description: c.Description,
1264			})
1265		}
1266	}
1267	return out
1268}
1269
1270// CommandInfo is one extension-registered slash command, surfaced to
1271// the rest of zut for display purposes.
1272type CommandInfo struct {
1273	Extension   string
1274	Name        string
1275	Description string
1276}
1277
1278// ToolInfo is one extension-registered tool. Used by the agent's
1279// build step to materialise core.Tool wrappers.
1280type ToolInfo struct {
1281	Extension   string
1282	Name        string
1283	Description string
1284	Schema      json.RawMessage
1285	Deferred    bool
1286}
1287
1288// Tools returns a snapshot of every (extension, tool) pair currently
1289// registered. Used at agent-build time to fold extension tools into
1290// the runtime tool registry.
1291func (m *Manager) Tools() []ToolInfo {
1292	m.mu.RLock()
1293	defer m.mu.RUnlock()
1294	var out []ToolInfo
1295	for _, ext := range m.ext {
1296		for _, t := range ext.tools {
1297			out = append(out, ToolInfo{
1298				Extension:   ext.Manifest.Name,
1299				Name:        t.Name,
1300				Description: t.Description,
1301				Schema:      t.Schema,
1302				Deferred:    t.Deferred,
1303			})
1304		}
1305	}
1306	return out
1307}
1308
1309// Skills returns extension-bundled skills in deterministic extension-name
1310// order. The build layer merges them after user skills and before built-ins.
1311func (m *Manager) Skills() []*skills.Skill {
1312	m.mu.RLock()
1313	names := make([]string, 0, len(m.ext))
1314	for name := range m.ext {
1315		names = append(names, name)
1316	}
1317	sort.Strings(names)
1318	var out []*skills.Skill
1319	for _, name := range names {
1320		ext := m.ext[name]
1321		out = append(out, ext.skills...)
1322	}
1323	m.mu.RUnlock()
1324	return out
1325}
1326
1327// HasTool reports whether name is registered by any extension.
1328func (m *Manager) HasTool(name string) bool {
1329	m.mu.RLock()
1330	defer m.mu.RUnlock()
1331	_, ok := m.toolIndex[name]
1332	return ok
1333}
1334
1335// InvokeTool sends a tool_call to the owning extension and waits for
1336// the matching tool_result. Used by the core.Tool wrapper that the
1337// agent registers per extension-defined tool.
1338func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
1339	m.mu.RLock()
1340	ext, ok := m.toolIndex[name]
1341	m.mu.RUnlock()
1342	if !ok {
1343		return extproto.ToolResultFromExt{}, fmt.Errorf("no extension registered for tool %q", name)
1344	}
1345
1346	id := newCorrelationID()
1347	ch := make(chan extproto.ToolResultFromExt, 1)
1348	ext.mu.Lock()
1349	ext.pendingTool[id] = ch
1350	ext.mu.Unlock()
1351
1352	frame, _ := extproto.Encode(extproto.ToolCallFromHost{
1353		Type: "tool_call",
1354		ID:   id,
1355		Name: name,
1356		Args: args,
1357	})
1358	if _, err := ext.writeFrame(frame); err != nil {
1359		ext.mu.Lock()
1360		delete(ext.pendingTool, id)
1361		ext.mu.Unlock()
1362		return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
1363	}
1364
1365	select {
1366	case resp := <-ch:
1367		return resp, nil
1368	case <-time.After(timeout):
1369		ext.mu.Lock()
1370		delete(ext.pendingTool, id)
1371		ext.mu.Unlock()
1372		return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
1373	case <-ctx.Done():
1374		ext.mu.Lock()
1375		delete(ext.pendingTool, id)
1376		ext.mu.Unlock()
1377		return extproto.ToolResultFromExt{}, ctx.Err()
1378	}
1379}
1380
1381// HasCommand reports whether name is registered by any extension.
1382func (m *Manager) HasCommand(name string) bool {
1383	m.mu.RLock()
1384	defer m.mu.RUnlock()
1385	_, ok := m.commandIndex[strings.ToLower(name)]
1386	return ok
1387}
1388
1389func (m *Manager) CommandOwner(name string) string {
1390	m.mu.RLock()
1391	defer m.mu.RUnlock()
1392	if registration, ok := m.commandIndex[strings.ToLower(name)]; ok && registration.ext != nil {
1393		return registration.ext.Manifest.Name
1394	}
1395	return ""
1396}
1397
1398// Invoke fires the named slash command's handler in the owning
1399// extension and waits up to timeout for the response. Returns the
1400// extension's CommandResponse so the caller can act on the action
1401// (prompt / insert / display).
1402func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {
1403	m.mu.RLock()
1404	registration, ok := m.commandIndex[strings.ToLower(name)]
1405	m.mu.RUnlock()
1406	if !ok {
1407		return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)
1408	}
1409	ext := registration.ext
1410	name = registration.name
1411
1412	id := newCorrelationID()
1413	ch := make(chan extproto.CommandResponseFromExt, 1)
1414	ext.mu.Lock()
1415	ext.pending[id] = ch
1416	ext.mu.Unlock()
1417
1418	frame, _ := extproto.Encode(extproto.CommandInvokedFromHost{
1419		Type: "command_invoked",
1420		ID:   id,
1421		Name: name,
1422		Args: args,
1423	})
1424	if _, err := ext.writeFrame(frame); err != nil {
1425		ext.mu.Lock()
1426		delete(ext.pending, id)
1427		ext.mu.Unlock()
1428		return extproto.CommandResponseFromExt{}, fmt.Errorf("write: %w", err)
1429	}
1430
1431	select {
1432	case resp := <-ch:
1433		return resp, nil
1434	case <-time.After(timeout):
1435		ext.mu.Lock()
1436		delete(ext.pending, id)
1437		ext.mu.Unlock()
1438		return extproto.CommandResponseFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
1439	case <-ctx.Done():
1440		ext.mu.Lock()
1441		delete(ext.pending, id)
1442		ext.mu.Unlock()
1443		return extproto.CommandResponseFromExt{}, ctx.Err()
1444	}
1445}
1446
1447// Stop cleanly terminates every extension. Sends ShutdownFromHost,
1448// waits up to gracePeriod for each subprocess to exit, then SIGTERMs
1449// (and SIGKILLs after another second) the holdouts.
1450func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
1451	m.mu.RLock()
1452	ext, ok := m.ext[extName]
1453	m.mu.RUnlock()
1454	if !ok {
1455		return fmt.Errorf("no extension %q", extName)
1456	}
1457	frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
1458	_, err := ext.writeFrame(frame)
1459	return err
1460}
1461
1462func (m *Manager) SendPanelClose(extName, panelID string) error {
1463	m.mu.RLock()
1464	ext, ok := m.ext[extName]
1465	m.mu.RUnlock()
1466	if !ok {
1467		return fmt.Errorf("no extension %q", extName)
1468	}
1469	frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
1470	_, err := ext.writeFrame(frame)
1471	return err
1472}
1473
1474func (m *Manager) Stop(gracePeriod time.Duration) {
1475	m.mu.RLock()
1476	exts := make([]*Extension, 0, len(m.ext))
1477	for _, e := range m.ext {
1478		exts = append(exts, e)
1479	}
1480	m.mu.RUnlock()
1481	stopExtensions(exts, gracePeriod)
1482}
1483
1484func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1485	for _, ext := range exts {
1486		if ext.stdin == nil {
1487			continue
1488		}
1489		if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1490			_, _ = ext.writeFrame(frame)
1491		}
1492		_ = ext.stdin.Close()
1493	}
1494
1495	deadline := time.Now().Add(gracePeriod)
1496	for _, ext := range exts {
1497		if ext.cmd == nil {
1498			if ext.logFile != nil {
1499				_ = ext.logFile.Close()
1500			}
1501			continue
1502		}
1503		remaining := time.Until(deadline)
1504		if remaining <= 0 {
1505			remaining = 100 * time.Millisecond
1506		}
1507		done := make(chan struct{})
1508		go func() { _ = ext.cmd.Wait(); close(done) }()
1509		select {
1510		case <-done:
1511		case <-time.After(remaining):
1512			_ = ext.cmd.Process.Signal(syscall.SIGTERM)
1513			select {
1514			case <-done:
1515			case <-time.After(time.Second):
1516				_ = ext.cmd.Process.Kill()
1517				<-done
1518			}
1519		}
1520		if ext.logFile != nil {
1521			_ = ext.logFile.Close()
1522		}
1523	}
1524}
1525
1526// All returns every extension currently tracked, enabled or not.
1527// Used by `zut ext list`.
1528func (m *Manager) All() []*Extension {
1529	m.mu.RLock()
1530	defer m.mu.RUnlock()
1531	out := make([]*Extension, 0, len(m.ext))
1532	for _, e := range m.ext {
1533		out = append(out, e)
1534	}
1535	return out
1536}
1537
1538// newCorrelationID returns a short non-cryptographic id. We don't
1539// need uniqueness across processes, just within the lifetime of one
1540// extension's pending map.
1541func newCorrelationID() string {
1542	return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
1543}

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