v0.3.73 c4c30ff

manager.go RawHistoryFolder

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

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