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}