v0.3.71 0dbbc58

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

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