v0.3.66 94efc28

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

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