1// Package extensions implements the host side of zot's subprocess
2// extension protocol. The Manager discovers extensions in well-known
3// directories, spawns each one, completes the hello handshake, and
4// routes slash commands to the right extension.
5//
6// Each extension is its own process, communicating with zot over its
7// stdin/stdout in newline-delimited JSON. Stderr is redirected to a
8// per-extension log file under $ZOT_HOME/logs/. Crashing one
9// extension does not affect the others or the host.
10//
11// See docs/extensions.md for the user-facing reference and
12// internal/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 "strings"
26 "sync"
27 "syscall"
28 "time"
29
30 "github.com/patriceckhart/zot/internal/extproto"
31)
32
33// Manifest is the extension.json file shipped alongside an
34// extension's executable. It tells zot how to launch the extension
35// and provides display metadata.
36type Manifest struct {
37 Name string `json:"name"`
38 Version string `json:"version,omitempty"`
39 Exec string `json:"exec"` // executable path, relative to manifest dir
40 Args []string `json:"args,omitempty"` // extra argv passed to exec
41 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
42 Enabled *bool `json:"enabled,omitempty"` // nil = enabled
43 Description string `json:"description,omitempty"`
44}
45
46// IsEnabled returns the manifest's effective enabled state. Default
47// is true so adding a new extension folder Just Works without an
48// extra zot ext enable command.
49func (m Manifest) IsEnabled() bool {
50 return m.Enabled == nil || *m.Enabled
51}
52
53// Extension is a running extension subprocess and the metadata zot
54// tracks about it.
55type Extension struct {
56 Manifest Manifest
57 Dir string // absolute path to extension directory
58 LogPath string
59
60 cmd *exec.Cmd
61 stdin io.WriteCloser
62 stdout io.ReadCloser
63 logFile *os.File
64 helloAck bool
65 commands []extproto.RegisterCommandFromExt
66 tools []extproto.RegisterToolFromExt
67
68 // readyCh is closed when the extension sends a ReadyFromExt
69 // frame, or when the host gives up waiting (registrationGrace).
70 readyCh chan struct{}
71 readyOnce sync.Once
72
73 // pending command invocations waiting on a CommandResponseFromExt
74 // keyed by the id we sent in CommandInvokedFromHost.
75 // pendingTool is the same idea for tool calls.
76 // pendingIntercept is the same idea for event_intercept calls.
77 mu sync.Mutex
78 pending map[string]chan extproto.CommandResponseFromExt
79 pendingTool map[string]chan extproto.ToolResultFromExt
80 pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt
81
82 // eventSubs and interceptSubs are the sets of event names this
83 // extension subscribed to via SubscribeFromExt. Used by
84 // EmitEvent / InterceptToolCall to filter recipients.
85 eventSubs map[string]struct{}
86 interceptSubs map[string]struct{}
87}
88
89// HostHooks is the small interface the manager calls back into the
90// running TUI through. Decouples extensions from interactive.go.
91type HostHooks interface {
92 // Notify pushes an ext-originated status message into the chat.
93 // Level is one of "info", "warn", "error", "success".
94 Notify(extName, level, message string)
95
96 // Submit feeds text as if the user had typed and pressed enter,
97 // running it through the agent loop.
98 Submit(text string)
99
100 // Insert places text at the cursor in the editor.
101 Insert(text string)
102
103 // Display appends a one-shot styled note to the chat without
104 // invoking the model and without writing to the transcript.
105 Display(extName, text string)
106}
107
108// Manager owns every extension subprocess for the lifetime of zot.
109type Manager struct {
110 zotHome string
111 cwd string
112 zotVersion string
113 provider string
114 model string
115 hooks HostHooks
116
117 mu sync.RWMutex
118 ext map[string]*Extension // keyed by manifest name
119
120 // commandIndex maps a slash-command name (without the leading /)
121 // to the extension that registered it. First-come-first-served:
122 // later registrations of the same command are dropped with a
123 // warning.
124 commandIndex map[string]*Extension
125
126 // toolIndex maps an extension-defined tool name to its owning
127 // extension. Same first-come-first-served rule as commandIndex.
128 toolIndex map[string]*Extension
129}
130
131// New constructs an empty Manager. Call Discover to populate it from
132// the on-disk extension directories.
133func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Manager {
134 return &Manager{
135 zotHome: zotHome,
136 cwd: cwd,
137 zotVersion: zotVersion,
138 provider: provider,
139 model: model,
140 hooks: hooks,
141 ext: map[string]*Extension{},
142 commandIndex: map[string]*Extension{},
143 toolIndex: map[string]*Extension{},
144 }
145}
146
147// Discover scans the global and project extension dirs and starts
148// every extension whose manifest is enabled. Returns a slice of
149// errors encountered (one per extension); a single bad extension
150// doesn't abort the rest.
151func (m *Manager) Discover(ctx context.Context) []error {
152 var errs []error
153 for _, dir := range m.searchDirs() {
154 entries, err := os.ReadDir(dir)
155 if err != nil {
156 continue // missing directory is fine
157 }
158 for _, e := range entries {
159 if !e.IsDir() {
160 continue
161 }
162 extDir := filepath.Join(dir, e.Name())
163 if err := m.loadOne(ctx, extDir); err != nil {
164 errs = append(errs, fmt.Errorf("%s: %w", extDir, err))
165 }
166 }
167 }
168 return errs
169}
170
171// searchDirs returns the directories the discoverer walks, in
172// priority order: project-local first (so a project can override
173// global behavior), then global.
174func (m *Manager) searchDirs() []string {
175 var dirs []string
176 if m.cwd != "" {
177 dirs = append(dirs, filepath.Join(m.cwd, ".zot", "extensions"))
178 }
179 if m.zotHome != "" {
180 dirs = append(dirs, filepath.Join(m.zotHome, "extensions"))
181 }
182 return dirs
183}
184
185// loadOne reads a single extension's manifest and, if enabled,
186// spawns its subprocess + completes the hello handshake.
187func (m *Manager) loadOne(ctx context.Context, dir string) error {
188 manifestPath := filepath.Join(dir, "extension.json")
189 raw, err := os.ReadFile(manifestPath)
190 if err != nil {
191 return fmt.Errorf("read manifest: %w", err)
192 }
193 var mf Manifest
194 if err := json.Unmarshal(raw, &mf); err != nil {
195 return fmt.Errorf("parse manifest: %w", err)
196 }
197 if mf.Name == "" {
198 return errors.New("manifest: name is required")
199 }
200 if mf.Exec == "" {
201 return errors.New("manifest: exec is required")
202 }
203 if !mf.IsEnabled() {
204 // Quietly skip disabled extensions; zot ext list will show them.
205 return nil
206 }
207
208 m.mu.RLock()
209 _, dup := m.ext[mf.Name]
210 m.mu.RUnlock()
211 if dup {
212 // Project-local copy already won; ignore the global one.
213 return nil
214 }
215
216 ext := &Extension{
217 Manifest: mf,
218 Dir: dir,
219 readyCh: make(chan struct{}),
220 pending: map[string]chan extproto.CommandResponseFromExt{},
221 pendingTool: map[string]chan extproto.ToolResultFromExt{},
222 pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
223 eventSubs: map[string]struct{}{},
224 interceptSubs: map[string]struct{}{},
225 }
226 if err := m.spawn(ctx, ext); err != nil {
227 return err
228 }
229
230 m.mu.Lock()
231 m.ext[mf.Name] = ext
232 // Note: ext.commands and ext.tools may be empty here — they're
233 // populated by the read loop as register_* frames arrive after
234 // hello. Indexing happens in the read loop too. Discover()'s
235 // caller can WaitForReady() before relying on the registries.
236 m.mu.Unlock()
237 return nil
238}
239
240// WaitForReady blocks until every loaded extension has sent its
241// ReadyFromExt sentinel, or the per-extension grace period expires.
242// Call after Discover and before relying on tool registrations.
243func (m *Manager) WaitForReady(grace time.Duration) {
244 m.mu.RLock()
245 exts := make([]*Extension, 0, len(m.ext))
246 for _, e := range m.ext {
247 exts = append(exts, e)
248 }
249 m.mu.RUnlock()
250 for _, ext := range exts {
251 select {
252 case <-ext.readyCh:
253 case <-time.After(grace):
254 fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
255 ext.readyOnce.Do(func() { close(ext.readyCh) })
256 }
257 }
258}
259
260// spawn launches the subprocess, hooks up pipes, logs stderr, and
261// runs the synchronous portion of the hello handshake. Asynchronous
262// frames are processed in a goroutine started here.
263func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
264 logsDir := filepath.Join(m.zotHome, "logs")
265 _ = os.MkdirAll(logsDir, 0o755)
266 logPath := filepath.Join(logsDir, "ext-"+ext.Manifest.Name+".log")
267 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
268 if err != nil {
269 return fmt.Errorf("open log: %w", err)
270 }
271 ext.LogPath = logPath
272 ext.logFile = logFile
273 fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339))
274
275 // Exec resolution rules:
276 // - absolute path: used as-is.
277 // - starts with "." (./ or ../): resolved relative to ext.Dir.
278 // - bare name (no path separator): looked up via $PATH so
279 // "node", "npx", "python3",
280 // "tsx" etc. work without
281 // forcing absolute paths.
282 // - other relative form (foo/bar): resolved relative to ext.Dir.
283 execPath := ext.Manifest.Exec
284 switch {
285 case filepath.IsAbs(execPath):
286 // keep
287 case strings.HasPrefix(execPath, "."+string(filepath.Separator)) ||
288 strings.HasPrefix(execPath, ".."+string(filepath.Separator)) ||
289 execPath == "." || execPath == "..":
290 execPath = filepath.Join(ext.Dir, execPath)
291 case strings.ContainsRune(execPath, filepath.Separator):
292 execPath = filepath.Join(ext.Dir, execPath)
293 default:
294 // bare name: leave as-is for exec.LookPath via exec.Command.
295 }
296 cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...)
297 cmd.Dir = ext.Dir
298 cmd.Stderr = logFile
299
300 stdin, err := cmd.StdinPipe()
301 if err != nil {
302 return err
303 }
304 stdout, err := cmd.StdoutPipe()
305 if err != nil {
306 return err
307 }
308 if err := cmd.Start(); err != nil {
309 return fmt.Errorf("spawn: %w", err)
310 }
311 ext.cmd = cmd
312 ext.stdin = stdin
313 ext.stdout = stdout
314
315 // Hello handshake. Read the extension's HelloFromExt synchronously
316 // so we can fail fast on a broken extension; everything after is
317 // processed in the read goroutine.
318 scanner := bufio.NewScanner(stdout)
319 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
320 if !scanner.Scan() {
321 return fmt.Errorf("extension exited before hello: %w", scanner.Err())
322 }
323 var hello extproto.HelloFromExt
324 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
325 return fmt.Errorf("parse hello: %w", err)
326 }
327 if hello.Type != "hello" || hello.Name == "" {
328 return fmt.Errorf("first frame must be hello (got %q)", hello.Type)
329 }
330 // Trust the manifest's name; ignore mismatch from the hello.
331 ext.helloAck = true
332
333 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
334 Type: "hello_ack",
335 ProtocolVersion: extproto.ProtocolVersion,
336 ZotVersion: m.zotVersion,
337 Provider: m.provider,
338 Model: m.model,
339 CWD: m.cwd,
340 })
341 if _, err := stdin.Write(ack); err != nil {
342 return fmt.Errorf("send hello_ack: %w", err)
343 }
344
345 // Spin up the read loop now that the handshake is done.
346 go m.readLoop(ext, scanner)
347 return nil
348}
349
350// readLoop processes every frame the extension sends after hello.
351// Returns when stdout closes.
352func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
353 defer func() {
354 // On close, drop every command + tool this extension owned so
355 // future invocations don't dangle. The subprocess is gone; we
356 // won't hear back about its commands or tool calls anymore.
357 m.mu.Lock()
358 for name, owner := range m.commandIndex {
359 if owner == ext {
360 delete(m.commandIndex, name)
361 }
362 }
363 for name, owner := range m.toolIndex {
364 if owner == ext {
365 delete(m.toolIndex, name)
366 }
367 }
368 m.mu.Unlock()
369 ext.readyOnce.Do(func() { close(ext.readyCh) })
370 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
371 }()
372
373 for scanner.Scan() {
374 line := scanner.Bytes()
375 var frame extproto.Frame
376 if err := json.Unmarshal(line, &frame); err != nil {
377 fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err)
378 continue
379 }
380 switch frame.Type {
381 case "register_command":
382 var rc extproto.RegisterCommandFromExt
383 if err := json.Unmarshal(line, &rc); err == nil {
384 ext.commands = append(ext.commands, rc)
385 m.mu.Lock()
386 if _, exists := m.commandIndex[rc.Name]; !exists {
387 m.commandIndex[rc.Name] = ext
388 }
389 m.mu.Unlock()
390 }
391 case "register_tool":
392 var rt extproto.RegisterToolFromExt
393 if err := json.Unmarshal(line, &rt); err != nil {
394 fmt.Fprintf(ext.logFile, "[zot] bad register_tool frame: %v\n", err)
395 continue
396 }
397 // Validate the schema parses as JSON. If not, refuse to
398 // register — a broken schema confuses the model.
399 if len(rt.Schema) > 0 {
400 var tmp any
401 if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
402 fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
403 continue
404 }
405 }
406 ext.tools = append(ext.tools, rt)
407 m.mu.Lock()
408 if _, exists := m.toolIndex[rt.Name]; !exists {
409 m.toolIndex[rt.Name] = ext
410 }
411 m.mu.Unlock()
412 case "ready":
413 ext.readyOnce.Do(func() { close(ext.readyCh) })
414 case "subscribe":
415 var sub extproto.SubscribeFromExt
416 if err := json.Unmarshal(line, &sub); err == nil {
417 ext.mu.Lock()
418 for _, ev := range sub.Events {
419 ext.eventSubs[ev] = struct{}{}
420 }
421 for _, ev := range sub.Intercept {
422 if ev == "tool_call" { // only kind supported in v1
423 ext.interceptSubs[ev] = struct{}{}
424 }
425 }
426 ext.mu.Unlock()
427 }
428 case "event_intercept_response":
429 var er extproto.EventInterceptResponseFromExt
430 if err := json.Unmarshal(line, &er); err == nil {
431 ext.mu.Lock()
432 ch, ok := ext.pendingIntercept[er.ID]
433 if ok {
434 delete(ext.pendingIntercept, er.ID)
435 }
436 ext.mu.Unlock()
437 if ok {
438 select {
439 case ch <- er:
440 default:
441 }
442 }
443 }
444 case "tool_result":
445 var tr extproto.ToolResultFromExt
446 if err := json.Unmarshal(line, &tr); err == nil {
447 ext.mu.Lock()
448 ch, ok := ext.pendingTool[tr.ID]
449 if ok {
450 delete(ext.pendingTool, tr.ID)
451 }
452 ext.mu.Unlock()
453 if ok {
454 select {
455 case ch <- tr:
456 default:
457 }
458 }
459 }
460 case "notify":
461 var n extproto.NotifyFromExt
462 if err := json.Unmarshal(line, &n); err == nil {
463 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
464 }
465 case "command_response":
466 var cr extproto.CommandResponseFromExt
467 if err := json.Unmarshal(line, &cr); err == nil {
468 ext.mu.Lock()
469 ch, ok := ext.pending[cr.ID]
470 if ok {
471 delete(ext.pending, cr.ID)
472 }
473 ext.mu.Unlock()
474 if ok {
475 select {
476 case ch <- cr:
477 default:
478 }
479 }
480 }
481 case "shutdown_ack":
482 // Caller of Stop is waiting on the process exit, not this frame.
483 default:
484 fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type)
485 }
486 }
487}
488
489// Commands returns a snapshot of every (extension, command) pair
490// currently registered. Used by the slash autocomplete + /help.
491func (m *Manager) Commands() []CommandInfo {
492 m.mu.RLock()
493 defer m.mu.RUnlock()
494 var out []CommandInfo
495 for _, ext := range m.ext {
496 for _, c := range ext.commands {
497 out = append(out, CommandInfo{
498 Extension: ext.Manifest.Name,
499 Name: c.Name,
500 Description: c.Description,
501 })
502 }
503 }
504 return out
505}
506
507// CommandInfo is one extension-registered slash command, surfaced to
508// the rest of zot for display purposes.
509type CommandInfo struct {
510 Extension string
511 Name string
512 Description string
513}
514
515// ToolInfo is one extension-registered tool. Used by the agent's
516// build step to materialise core.Tool wrappers.
517type ToolInfo struct {
518 Extension string
519 Name string
520 Description string
521 Schema json.RawMessage
522}
523
524// Tools returns a snapshot of every (extension, tool) pair currently
525// registered. Used at agent-build time to fold extension tools into
526// the runtime tool registry.
527func (m *Manager) Tools() []ToolInfo {
528 m.mu.RLock()
529 defer m.mu.RUnlock()
530 var out []ToolInfo
531 for _, ext := range m.ext {
532 for _, t := range ext.tools {
533 out = append(out, ToolInfo{
534 Extension: ext.Manifest.Name,
535 Name: t.Name,
536 Description: t.Description,
537 Schema: t.Schema,
538 })
539 }
540 }
541 return out
542}
543
544// HasTool reports whether name is registered by any extension.
545func (m *Manager) HasTool(name string) bool {
546 m.mu.RLock()
547 defer m.mu.RUnlock()
548 _, ok := m.toolIndex[name]
549 return ok
550}
551
552// InvokeTool sends a tool_call to the owning extension and waits for
553// the matching tool_result. Used by the core.Tool wrapper that the
554// agent registers per extension-defined tool.
555func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
556 m.mu.RLock()
557 ext, ok := m.toolIndex[name]
558 m.mu.RUnlock()
559 if !ok {
560 return extproto.ToolResultFromExt{}, fmt.Errorf("no extension registered for tool %q", name)
561 }
562
563 id := newCorrelationID()
564 ch := make(chan extproto.ToolResultFromExt, 1)
565 ext.mu.Lock()
566 ext.pendingTool[id] = ch
567 ext.mu.Unlock()
568
569 frame, _ := extproto.Encode(extproto.ToolCallFromHost{
570 Type: "tool_call",
571 ID: id,
572 Name: name,
573 Args: args,
574 })
575 if _, err := ext.stdin.Write(frame); err != nil {
576 ext.mu.Lock()
577 delete(ext.pendingTool, id)
578 ext.mu.Unlock()
579 return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
580 }
581
582 select {
583 case resp := <-ch:
584 return resp, nil
585 case <-time.After(timeout):
586 ext.mu.Lock()
587 delete(ext.pendingTool, id)
588 ext.mu.Unlock()
589 return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
590 case <-ctx.Done():
591 ext.mu.Lock()
592 delete(ext.pendingTool, id)
593 ext.mu.Unlock()
594 return extproto.ToolResultFromExt{}, ctx.Err()
595 }
596}
597
598// HasCommand reports whether name is registered by any extension.
599func (m *Manager) HasCommand(name string) bool {
600 m.mu.RLock()
601 defer m.mu.RUnlock()
602 _, ok := m.commandIndex[name]
603 return ok
604}
605
606// Invoke fires the named slash command's handler in the owning
607// extension and waits up to timeout for the response. Returns the
608// extension's CommandResponse so the caller can act on the action
609// (prompt / insert / display).
610func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {
611 m.mu.RLock()
612 ext, ok := m.commandIndex[name]
613 m.mu.RUnlock()
614 if !ok {
615 return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)
616 }
617
618 id := newCorrelationID()
619 ch := make(chan extproto.CommandResponseFromExt, 1)
620 ext.mu.Lock()
621 ext.pending[id] = ch
622 ext.mu.Unlock()
623
624 frame, _ := extproto.Encode(extproto.CommandInvokedFromHost{
625 Type: "command_invoked",
626 ID: id,
627 Name: name,
628 Args: args,
629 })
630 if _, err := ext.stdin.Write(frame); err != nil {
631 ext.mu.Lock()
632 delete(ext.pending, id)
633 ext.mu.Unlock()
634 return extproto.CommandResponseFromExt{}, fmt.Errorf("write: %w", err)
635 }
636
637 select {
638 case resp := <-ch:
639 return resp, nil
640 case <-time.After(timeout):
641 ext.mu.Lock()
642 delete(ext.pending, id)
643 ext.mu.Unlock()
644 return extproto.CommandResponseFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
645 case <-ctx.Done():
646 ext.mu.Lock()
647 delete(ext.pending, id)
648 ext.mu.Unlock()
649 return extproto.CommandResponseFromExt{}, ctx.Err()
650 }
651}
652
653// Stop cleanly terminates every extension. Sends ShutdownFromHost,
654// waits up to gracePeriod for each subprocess to exit, then SIGTERMs
655// (and SIGKILLs after another second) the holdouts.
656func (m *Manager) Stop(gracePeriod time.Duration) {
657 m.mu.RLock()
658 exts := make([]*Extension, 0, len(m.ext))
659 for _, e := range m.ext {
660 exts = append(exts, e)
661 }
662 m.mu.RUnlock()
663
664 for _, ext := range exts {
665 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
666 _, _ = ext.stdin.Write(frame)
667 }
668 _ = ext.stdin.Close()
669 }
670
671 deadline := time.Now().Add(gracePeriod)
672 for _, ext := range exts {
673 remaining := time.Until(deadline)
674 if remaining <= 0 {
675 remaining = 100 * time.Millisecond
676 }
677 done := make(chan struct{})
678 go func() { _ = ext.cmd.Wait(); close(done) }()
679 select {
680 case <-done:
681 case <-time.After(remaining):
682 _ = ext.cmd.Process.Signal(syscall.SIGTERM)
683 select {
684 case <-done:
685 case <-time.After(time.Second):
686 _ = ext.cmd.Process.Kill()
687 <-done
688 }
689 }
690 if ext.logFile != nil {
691 _ = ext.logFile.Close()
692 }
693 }
694}
695
696// All returns every extension currently tracked, enabled or not.
697// Used by `zot ext list`.
698func (m *Manager) All() []*Extension {
699 m.mu.RLock()
700 defer m.mu.RUnlock()
701 out := make([]*Extension, 0, len(m.ext))
702 for _, e := range m.ext {
703 out = append(out, e)
704 }
705 return out
706}
707
708// newCorrelationID returns a short non-cryptographic id. We don't
709// need uniqueness across processes, just within the lifetime of one
710// extension's pending map.
711func newCorrelationID() string {
712 return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
713}