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