v0.3.60 bfc7b11

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

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