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