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