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