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