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