detached 7f35cae

fix: harden MCP bridge startup

commit
7f35cae72ad59db134e66a54158385e324b369d5
author
patriceckhart · · 2026-07-02 05:46 UTC
parent
04e3270
signature
unsigned
6 files · +256 -111
M docs/extensions.md +45 -21
@@ -25,20 +25,22 @@ no SDK required:
2525 ```python
2626 #!/usr/bin/env python3
2727 # $ZOT_HOME/extensions/hello-py/hello.py
28-import json, sys, threading
28+import json, sys
2929
3030 def emit(obj):
3131 sys.stdout.write(json.dumps(obj) + "\n")
3232 sys.stdout.flush()
3333
3434 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
35-emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
3635
3736 for line in sys.stdin:
3837 msg = json.loads(line)
39- if msg["type"] == "command_invoked":
38+ if msg["type"] == "hello_ack":
39+ emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
40+ emit({"type":"ready"})
41+ elif msg["type"] == "command_invoked":
4042 emit({"type":"command_response","id":msg["id"],"action":"prompt",
41- "prompt": "Greet me very briefly. Add one emoji."})
43+ "prompt": "Greet me very briefly in one sentence."})
4244 elif msg["type"] == "shutdown":
4345 emit({"type":"shutdown_ack"})
4446 break
@@ -135,19 +137,21 @@ manifest tells zot how to launch it:
135137 2. **Spawn**: enabled extensions are launched as subprocesses. stderr
136138 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
137139 extension, append-mode).
138-3. **Hello handshake**: the extension sends a `hello` frame; zot
139- replies with `hello_ack` containing the protocol version, the
140- active provider/model/cwd, and the extension's own data directory
141- so it can persist files beside its manifest.
142-4. **Registration**: the extension sends `register_command` frames.
143- First-come-first-served: a name already taken by a built-in or by
144- a previously-loaded extension is silently shadowed (logged in the
145- extension's own log file).
146-5. **Runtime**: zot dispatches `command_invoked` frames when the
147- user runs a registered command; the extension responds with
148- `command_response`. Extensions can also push `notify` frames at
149- any time. Panel-capable extensions may open an interactive panel,
150- receive key events, and push redraws while the panel is focused.
140+3. **Hello handshake**: the extension's first stdout frame must be
141+ `hello`; zot replies with `hello_ack` containing the protocol
142+ version, the active provider/model/cwd, and the extension's own
143+ data directory so it can persist files beside its manifest.
144+4. **Registration**: after receiving `hello_ack`, the extension sends
145+ `register_command`, `register_tool`, and subscription frames, then
146+ sends `ready`. First-come-first-served: a name already taken by a
147+ built-in or by a previously-loaded extension is silently shadowed
148+ (logged in the extension's own log file).
149+5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
150+ when the user runs a registered command; the extension responds
151+ with `command_response`. Extensions can also push `notify` frames
152+ during runtime. Panel-capable extensions may open an interactive
153+ panel, receive key events, and push redraws while the panel is
154+ focused.
151155 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
152156 2s for the extension to send `shutdown_ack`. Holdouts are
153157 SIGTERM'd, then SIGKILL'd.
@@ -160,7 +164,9 @@ restarted.
160164
161165 All frames are one JSON object per line. Top-level `type` is the
162166 discriminator. Optional `id` correlates request frames with their
163-responses.
167+responses. The canonical startup order is `hello`, `hello_ack`,
168+registration frames, then `ready`. Do not send `notify`, logs, or any
169+other stdout frame before `hello`.
164170
165171 ### Extension → host
166172
@@ -370,9 +376,13 @@ Sent in response to `shutdown`. Extension should exit promptly after.
370376 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
371377 ```
372378
373-Sent immediately after `hello`. The extension can use these fields to
374-decide which commands to register (e.g. only register a Python tool
375-on macOS, only register a model-specific shortcut for opus, etc.).
379+Sent immediately after `hello`. Wait for this frame before sending
380+registrations if they depend on host metadata. The extension can use
381+these fields to decide which commands to register (e.g. only register
382+a Python tool on macOS, only register a model-specific shortcut for
383+opus, etc.). `cwd` is the user's project directory; the extension
384+process itself runs from the extension directory, so do not use
385+`os.Getwd()` or `process.cwd()` when you need the project path.
376386 `extension_dir` / `data_dir` are where the extension should persist
377387 its own state (for example `todos.json`, cached metadata, or auth
378388 tokens scoped to that extension).
@@ -526,6 +536,15 @@ func main() {
526536 return ext.TextResult(in.City + ": sunny")
527537 })
528538
539+ // Optional: register project-specific commands after hello_ack.
540+ e.OnHello(func(host ext.HostInfo) {
541+ if host.CWD != "" {
542+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
543+ return ext.Display(host.CWD)
544+ })
545+ }
546+ })
547+
529548 e.Run()
530549 }
531550 ```
@@ -533,6 +552,11 @@ func main() {
533552 Build with `go build -o hello .`, drop the binary + an `extension.json`
534553 into `$ZOT_HOME/extensions/hello/`.
535554
555+`OnHello` is optional. Use it when configuration or registrations need
556+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
557+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
558+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
559+
536560 The SDK has four interceptor hooks, all optional:
537561
538562 ```go
@@ -25,20 +25,22 @@ no SDK required:
25 ```python25 ```python
26 #!/usr/bin/env python326 #!/usr/bin/env python3
27 # $ZOT_HOME/extensions/hello-py/hello.py27 # $ZOT_HOME/extensions/hello-py/hello.py
28-import json, sys, threading28+import json, sys
29 29
30 def emit(obj):30 def emit(obj):
31 sys.stdout.write(json.dumps(obj) + "\n")31 sys.stdout.write(json.dumps(obj) + "\n")
32 sys.stdout.flush()32 sys.stdout.flush()
33 33
34 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})34 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
35-emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
36 35
37 for line in sys.stdin:36 for line in sys.stdin:
38 msg = json.loads(line)37 msg = json.loads(line)
39- if msg["type"] == "command_invoked":38+ if msg["type"] == "hello_ack":
39+ emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
40+ emit({"type":"ready"})
41+ elif msg["type"] == "command_invoked":
40 emit({"type":"command_response","id":msg["id"],"action":"prompt",42 emit({"type":"command_response","id":msg["id"],"action":"prompt",
41- "prompt": "Greet me very briefly. Add one emoji."})43+ "prompt": "Greet me very briefly in one sentence."})
42 elif msg["type"] == "shutdown":44 elif msg["type"] == "shutdown":
43 emit({"type":"shutdown_ack"})45 emit({"type":"shutdown_ack"})
44 break46 break
@@ -135,19 +137,21 @@ manifest tells zot how to launch it:
135 2. **Spawn**: enabled extensions are launched as subprocesses. stderr137 2. **Spawn**: enabled extensions are launched as subprocesses. stderr
136 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per138 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
137 extension, append-mode).139 extension, append-mode).
138-3. **Hello handshake**: the extension sends a `hello` frame; zot140+3. **Hello handshake**: the extension's first stdout frame must be
139- replies with `hello_ack` containing the protocol version, the141+ `hello`; zot replies with `hello_ack` containing the protocol
140- active provider/model/cwd, and the extension's own data directory142+ version, the active provider/model/cwd, and the extension's own
141- so it can persist files beside its manifest.143+ data directory so it can persist files beside its manifest.
142-4. **Registration**: the extension sends `register_command` frames.144+4. **Registration**: after receiving `hello_ack`, the extension sends
143- First-come-first-served: a name already taken by a built-in or by145+ `register_command`, `register_tool`, and subscription frames, then
144- a previously-loaded extension is silently shadowed (logged in the146+ sends `ready`. First-come-first-served: a name already taken by a
145- extension's own log file).147+ built-in or by a previously-loaded extension is silently shadowed
146-5. **Runtime**: zot dispatches `command_invoked` frames when the148+ (logged in the extension's own log file).
147- user runs a registered command; the extension responds with149+5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
148- `command_response`. Extensions can also push `notify` frames at150+ when the user runs a registered command; the extension responds
149- any time. Panel-capable extensions may open an interactive panel,151+ with `command_response`. Extensions can also push `notify` frames
150- receive key events, and push redraws while the panel is focused.152+ during runtime. Panel-capable extensions may open an interactive
153+ panel, receive key events, and push redraws while the panel is
154+ focused.
151 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to155 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
152 2s for the extension to send `shutdown_ack`. Holdouts are156 2s for the extension to send `shutdown_ack`. Holdouts are
153 SIGTERM'd, then SIGKILL'd.157 SIGTERM'd, then SIGKILL'd.
@@ -160,7 +164,9 @@ restarted.
160 164
161 All frames are one JSON object per line. Top-level `type` is the165 All frames are one JSON object per line. Top-level `type` is the
162 discriminator. Optional `id` correlates request frames with their166 discriminator. Optional `id` correlates request frames with their
163-responses.167+responses. The canonical startup order is `hello`, `hello_ack`,
168+registration frames, then `ready`. Do not send `notify`, logs, or any
169+other stdout frame before `hello`.
164 170
165 ### Extension → host171 ### Extension → host
166 172
@@ -370,9 +376,13 @@ Sent in response to `shutdown`. Extension should exit promptly after.
370 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}376 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
371 ```377 ```
372 378
373-Sent immediately after `hello`. The extension can use these fields to379+Sent immediately after `hello`. Wait for this frame before sending
374-decide which commands to register (e.g. only register a Python tool380+registrations if they depend on host metadata. The extension can use
375-on macOS, only register a model-specific shortcut for opus, etc.).381+these fields to decide which commands to register (e.g. only register
382+a Python tool on macOS, only register a model-specific shortcut for
383+opus, etc.). `cwd` is the user's project directory; the extension
384+process itself runs from the extension directory, so do not use
385+`os.Getwd()` or `process.cwd()` when you need the project path.
376 `extension_dir` / `data_dir` are where the extension should persist386 `extension_dir` / `data_dir` are where the extension should persist
377 its own state (for example `todos.json`, cached metadata, or auth387 its own state (for example `todos.json`, cached metadata, or auth
378 tokens scoped to that extension).388 tokens scoped to that extension).
@@ -526,6 +536,15 @@ func main() {
526 return ext.TextResult(in.City + ": sunny")536 return ext.TextResult(in.City + ": sunny")
527 })537 })
528 538
539+ // Optional: register project-specific commands after hello_ack.
540+ e.OnHello(func(host ext.HostInfo) {
541+ if host.CWD != "" {
542+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
543+ return ext.Display(host.CWD)
544+ })
545+ }
546+ })
547+
529 e.Run()548 e.Run()
530 }549 }
531 ```550 ```
@@ -533,6 +552,11 @@ func main() {
533 Build with `go build -o hello .`, drop the binary + an `extension.json`552 Build with `go build -o hello .`, drop the binary + an `extension.json`
534 into `$ZOT_HOME/extensions/hello/`.553 into `$ZOT_HOME/extensions/hello/`.
535 554
555+`OnHello` is optional. Use it when configuration or registrations need
556+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
557+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
558+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
559+
536 The SDK has four interceptor hooks, all optional:560 The SDK has four interceptor hooks, all optional:
537 561
538 ```go562 ```go
M examples/extensions/mcp-bridge/README.md +18 -16
@@ -7,7 +7,7 @@ This extension reads MCP server configurations from standard locations (same for
77 ## Features
88
99 - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline
10-- **Smart lazy loading** — servers spawn on startup to discover tools, then auto-sleep after idle time
10+- **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time
1111 - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically
1212 - **Multi-transport** — stdio, streamable-http, and SSE transports
1313 - **Multi-server** — connect to any number of MCP servers simultaneously
@@ -53,7 +53,7 @@ This extension reads MCP server configurations from standard locations (same for
5353 zot ext install .
5454 ```
5555
56-4. **Restart zot** — your MCP tools are ready!
56+4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately.
5757
5858 ## Configuration
5959
@@ -168,12 +168,13 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions:
168168 └──────────────────────────────────────────────────────────────┘
169169 ```
170170
171-1. **Startup**: mcp-bridge reads config, spawns all MCP servers
172-2. **Discovery**: calls `tools/list` on each server, registers tools with zot
173-3. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)
174-4. **Idle timeout**: servers not used for 5 minutes are automatically stopped
175-5. **Auto-respawn**: calling a tool on a stopped server wakes it up
176-6. **Routing**: tool calls are forwarded to the appropriate MCP server
171+1. **Startup**: mcp-bridge reads config and registers tools from `mcp-tools-cache.json`
172+2. **Background refresh**: starts configured MCP servers, calls `tools/list`, and updates the cache when definitions change
173+3. **Reload**: if the cache changed, run `/reload-ext` once so zot rebuilds the tool registry with the new definitions
174+4. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)
175+5. **Idle timeout**: servers not used for 5 minutes are automatically stopped
176+6. **Auto-respawn**: calling a tool on a stopped server wakes it up
177+7. **Routing**: tool calls are forwarded to the appropriate MCP server
177178
178179 ## Slash Commands
179180
@@ -210,16 +211,17 @@ Server and tool names are sanitized (non-alphanumeric characters become `_`).
210211
211212 The bridge uses a "smart lazy" strategy:
212213
213-1. **On startup**: all servers spawn, tools are discovered and registered
214-2. **During use**: servers stay running for fast tool calls
215-3. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)
216-4. **On next tool call**: the server is respawned automatically (~1-3s delay)
214+1. **On startup**: cached tool definitions are registered without blocking zot startup
215+2. **In the background**: servers start long enough to refresh the tool cache
216+3. **During use**: servers stay running for fast tool calls
217+4. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)
218+5. **On next tool call**: the server is respawned automatically (~1-3s delay)
217219
218220 This gives you:
219-- ✅ All tools visible to the LLM immediately
220-- ✅ Fast tool calls when actively working
221-- ✅ Memory freed when not using MCP tools
222-- ✅ No manual server management needed
221+- Cached tools visible to the LLM immediately
222+- Fast tool calls when actively working
223+- Memory freed when not using MCP tools
224+- One manual `/reload-ext` only when tool definitions change
223225
224226 ## Troubleshooting
225227
@@ -7,7 +7,7 @@ This extension reads MCP server configurations from standard locations (same for
7 ## Features7 ## Features
8 8
9 - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline9 - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline
10-- **Smart lazy loading** — servers spawn on startup to discover tools, then auto-sleep after idle time10+- **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time
11 - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically11 - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically
12 - **Multi-transport** — stdio, streamable-http, and SSE transports12 - **Multi-transport** — stdio, streamable-http, and SSE transports
13 - **Multi-server** — connect to any number of MCP servers simultaneously13 - **Multi-server** — connect to any number of MCP servers simultaneously
@@ -53,7 +53,7 @@ This extension reads MCP server configurations from standard locations (same for
53 zot ext install .53 zot ext install .
54 ```54 ```
55 55
56-4. **Restart zot** — your MCP tools are ready!56+4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately.
57 57
58 ## Configuration58 ## Configuration
59 59
@@ -168,12 +168,13 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions:
168 └──────────────────────────────────────────────────────────────┘168 └──────────────────────────────────────────────────────────────┘
169 ```169 ```
170 170
171-1. **Startup**: mcp-bridge reads config, spawns all MCP servers171+1. **Startup**: mcp-bridge reads config and registers tools from `mcp-tools-cache.json`
172-2. **Discovery**: calls `tools/list` on each server, registers tools with zot172+2. **Background refresh**: starts configured MCP servers, calls `tools/list`, and updates the cache when definitions change
173-3. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)173+3. **Reload**: if the cache changed, run `/reload-ext` once so zot rebuilds the tool registry with the new definitions
174-4. **Idle timeout**: servers not used for 5 minutes are automatically stopped174+4. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)
175-5. **Auto-respawn**: calling a tool on a stopped server wakes it up175+5. **Idle timeout**: servers not used for 5 minutes are automatically stopped
176-6. **Routing**: tool calls are forwarded to the appropriate MCP server176+6. **Auto-respawn**: calling a tool on a stopped server wakes it up
177+7. **Routing**: tool calls are forwarded to the appropriate MCP server
177 178
178 ## Slash Commands179 ## Slash Commands
179 180
@@ -210,16 +211,17 @@ Server and tool names are sanitized (non-alphanumeric characters become `_`).
210 211
211 The bridge uses a "smart lazy" strategy:212 The bridge uses a "smart lazy" strategy:
212 213
213-1. **On startup**: all servers spawn, tools are discovered and registered214+1. **On startup**: cached tool definitions are registered without blocking zot startup
214-2. **During use**: servers stay running for fast tool calls215+2. **In the background**: servers start long enough to refresh the tool cache
215-3. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)216+3. **During use**: servers stay running for fast tool calls
216-4. **On next tool call**: the server is respawned automatically (~1-3s delay)217+4. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)
218+5. **On next tool call**: the server is respawned automatically (~1-3s delay)
217 219
218 This gives you:220 This gives you:
219-- ✅ All tools visible to the LLM immediately221+- Cached tools visible to the LLM immediately
220-- ✅ Fast tool calls when actively working222+- Fast tool calls when actively working
221-- ✅ Memory freed when not using MCP tools223+- Memory freed when not using MCP tools
222-- ✅ No manual server management needed224+- One manual `/reload-ext` only when tool definitions change
223 225
224 ## Troubleshooting226 ## Troubleshooting
225 227
M examples/extensions/mcp-bridge/main.go +48 -41
@@ -50,50 +50,65 @@ func main() {
5050 // Logger writes to stderr (captured by zot into ext logs)
5151 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)
5252
53- // Load config
54- cwd, err := os.Getwd()
55- if err != nil {
56- logger.Fatalf("getwd: %v", err)
57- }
58- cfg, err := loadConfig(cwd)
59- if err != nil {
60- logger.Printf("config error: %v", err)
61- e.Notify("error", "mcp: config error: "+err.Error())
62- }
53+ var b *bridge
54+ e.OnHello(func(host ext.HostInfo) {
55+ cwd := host.CWD
56+ if cwd == "" {
57+ var err error
58+ cwd, err = os.Getwd()
59+ if err != nil {
60+ logger.Printf("getwd fallback: %v", err)
61+ }
62+ }
6363
64- if len(cfg.MCPServers) == 0 {
65- logger.Printf("no MCP servers configured")
66- // Still register commands so user can check status
67- registerCommands(e, nil)
68- e.Run()
69- return
70- }
64+ cfg, err := loadConfig(cwd)
65+ if err != nil {
66+ logger.Printf("config error: %v", err)
67+ notifyText(e, "error", "mcp: config error: "+err.Error())
68+ }
7169
72- logger.Printf("found %d MCP server(s)", len(cfg.MCPServers))
70+ if len(cfg.MCPServers) == 0 {
71+ logger.Printf("no MCP servers configured")
72+ // Still register commands so user can check status and run setup.
73+ registerCommands(e, nil)
74+ return
75+ }
7376
74- // Create bridge
75- b := newBridge(e, logger)
76- b.loadServers(cfg)
77+ logger.Printf("found %d MCP server(s)", len(cfg.MCPServers))
7778
78- cachePath := toolCachePath()
79- cache, err := readToolCache(cachePath)
80- if err != nil {
81- logger.Printf("tool cache error: %v", err)
82- }
83- cachedToolCount := b.registerCachedTools(cache)
84- logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
79+ b = newBridge(e, logger)
80+ b.loadServers(cfg)
8581
86- // Start idle reaper
87- b.startIdleReaper()
82+ cachePath := toolCachePath()
83+ cache, err := readToolCache(cachePath)
84+ if err != nil {
85+ logger.Printf("tool cache error: %v", err)
86+ }
87+ cachedToolCount := b.registerCachedTools(cache)
88+ logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
8889
89- // Register slash commands
90- registerCommands(e, b)
90+ b.startIdleReaper()
91+ registerCommands(e, b)
92+ startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount)
93+ })
9194
95+ // Run the extension protocol loop
96+ if err := e.Run(); err != nil {
97+ logger.Printf("fatal: %v", err)
98+ }
99+
100+ // Cleanup
101+ if b != nil {
102+ b.stopAll()
103+ }
104+}
105+
106+func startBackgroundToolRefresh(e *ext.Extension, b *bridge, logger *log.Logger, cachePath string, cachedToolCount int) {
92107 // Refresh discovery in the background so zot startup is not blocked. Only ask
93108 // for /reload-ext if the discovered tool cache actually changed; otherwise a
94109 // reload would just repeat this cycle without adding anything.
95110 go func() {
96- time.Sleep(500 * time.Millisecond) // Wait for hello handshake
111+ time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first.
97112 if cachedToolCount == 0 {
98113 notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.")
99114 } else {
@@ -114,14 +129,6 @@ func main() {
114129 }
115130 notifyBridgeStatus(e, b)
116131 }()
117-
118- // Run the extension protocol loop
119- if err := e.Run(); err != nil {
120- logger.Printf("fatal: %v", err)
121- }
122-
123- // Cleanup
124- b.stopAll()
125132 }
126133
127134 // registerCommands sets up the /mcp slash commands.
@@ -50,50 +50,65 @@ func main() {
50 // Logger writes to stderr (captured by zot into ext logs)50 // Logger writes to stderr (captured by zot into ext logs)
51 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)51 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)
52 52
53- // Load config53+ var b *bridge
54- cwd, err := os.Getwd()54+ e.OnHello(func(host ext.HostInfo) {
55- if err != nil {55+ cwd := host.CWD
56- logger.Fatalf("getwd: %v", err)56+ if cwd == "" {
57- }57+ var err error
58- cfg, err := loadConfig(cwd)58+ cwd, err = os.Getwd()
59- if err != nil {59+ if err != nil {
60- logger.Printf("config error: %v", err)60+ logger.Printf("getwd fallback: %v", err)
61- e.Notify("error", "mcp: config error: "+err.Error())61+ }
62- }62+ }
63 63
64- if len(cfg.MCPServers) == 0 {64+ cfg, err := loadConfig(cwd)
65- logger.Printf("no MCP servers configured")65+ if err != nil {
66- // Still register commands so user can check status66+ logger.Printf("config error: %v", err)
67- registerCommands(e, nil)67+ notifyText(e, "error", "mcp: config error: "+err.Error())
68- e.Run()68+ }
69- return
70- }
71 69
72- logger.Printf("found %d MCP server(s)", len(cfg.MCPServers))70+ if len(cfg.MCPServers) == 0 {
71+ logger.Printf("no MCP servers configured")
72+ // Still register commands so user can check status and run setup.
73+ registerCommands(e, nil)
74+ return
75+ }
73 76
74- // Create bridge77+ logger.Printf("found %d MCP server(s)", len(cfg.MCPServers))
75- b := newBridge(e, logger)
76- b.loadServers(cfg)
77 78
78- cachePath := toolCachePath()79+ b = newBridge(e, logger)
79- cache, err := readToolCache(cachePath)80+ b.loadServers(cfg)
80- if err != nil {
81- logger.Printf("tool cache error: %v", err)
82- }
83- cachedToolCount := b.registerCachedTools(cache)
84- logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
85 81
86- // Start idle reaper82+ cachePath := toolCachePath()
87- b.startIdleReaper()83+ cache, err := readToolCache(cachePath)
84+ if err != nil {
85+ logger.Printf("tool cache error: %v", err)
86+ }
87+ cachedToolCount := b.registerCachedTools(cache)
88+ logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
88 89
89- // Register slash commands90+ b.startIdleReaper()
90- registerCommands(e, b)91+ registerCommands(e, b)
92+ startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount)
93+ })
91 94
95+ // Run the extension protocol loop
96+ if err := e.Run(); err != nil {
97+ logger.Printf("fatal: %v", err)
98+ }
99+
100+ // Cleanup
101+ if b != nil {
102+ b.stopAll()
103+ }
104+}
105+
106+func startBackgroundToolRefresh(e *ext.Extension, b *bridge, logger *log.Logger, cachePath string, cachedToolCount int) {
92 // Refresh discovery in the background so zot startup is not blocked. Only ask107 // Refresh discovery in the background so zot startup is not blocked. Only ask
93 // for /reload-ext if the discovered tool cache actually changed; otherwise a108 // for /reload-ext if the discovered tool cache actually changed; otherwise a
94 // reload would just repeat this cycle without adding anything.109 // reload would just repeat this cycle without adding anything.
95 go func() {110 go func() {
96- time.Sleep(500 * time.Millisecond) // Wait for hello handshake111+ time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first.
97 if cachedToolCount == 0 {112 if cachedToolCount == 0 {
98 notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.")113 notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.")
99 } else {114 } else {
@@ -114,14 +129,6 @@ func main() {
114 }129 }
115 notifyBridgeStatus(e, b)130 notifyBridgeStatus(e, b)
116 }()131 }()
117-
118- // Run the extension protocol loop
119- if err := e.Run(); err != nil {
120- logger.Printf("fatal: %v", err)
121- }
122-
123- // Cleanup
124- b.stopAll()
125 }132 }
126 133
127 // registerCommands sets up the /mcp slash commands.134 // registerCommands sets up the /mcp slash commands.
M packages/agent/ext/ext.go +53 -17
@@ -22,6 +22,11 @@
2222 // Build it, drop the binary + an extension.json next to it under
2323 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
2424 //
25+// Use OnHello when registration or configuration depends on host metadata
26+// such as HostInfo.CWD, Provider, Model, ZotVersion, ExtensionDir, or DataDir.
27+// Run sends hello, waits for hello_ack, runs OnHello, announces registrations,
28+// then sends ready.
29+//
2530 // The same wire format also has reference clients in TypeScript and
2631 // Python under examples/extensions/. Use whichever language fits.
2732 package ext
@@ -257,6 +262,7 @@ type Extension struct {
257262 interceptAssistant AssistantMessageHandler
258263 panelKeys map[string]func(key, text string)
259264 panelCloses map[string]func()
265+ onHello func(HostInfo)
260266
261267 // Caps reported in the hello frame.
262268 caps []string
@@ -309,6 +315,17 @@ func New(name, version string) *Extension {
309315 // Returns the zero value if Run hasn't started yet.
310316 func (e *Extension) Host() HostInfo { return e.host }
311317
318+// OnHello registers a callback that runs after zot acknowledges the
319+// hello frame and before commands/tools are announced. Use it when
320+// registrations depend on host metadata such as CWD or ZotHome-derived
321+// paths. The callback may call Command, Tool, On, and interceptor
322+// registration methods.
323+func (e *Extension) OnHello(fn func(HostInfo)) {
324+ e.mu.Lock()
325+ e.onHello = fn
326+ e.mu.Unlock()
327+}
328+
312329 // Logf writes a line to the extension's stderr, which zot captures to
313330 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314331 // you print to stdout would corrupt the JSON wire protocol.
@@ -451,9 +468,12 @@ func (e *Extension) Notify(level, message string) {
451468 // Run starts the protocol loop. Blocks until stdin closes (zot has
452469 // shut us down). Returns the first fatal error, or nil on clean exit.
453470 func (e *Extension) Run() error {
454- // Send hello, then re-announce all commands (covers Command calls
455- // made before Run, and is also fine for those made after via
456- // Command()'s direct send).
471+ scanner := bufio.NewScanner(e.in)
472+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
473+
474+ // Send hello first. zot answers with hello_ack immediately; read it
475+ // synchronously so extensions can use HostInfo while registering
476+ // commands/tools before the ready frame.
457477 if err := e.send(extproto.HelloFromExt{
458478 Type: "hello",
459479 Name: e.name,
@@ -462,6 +482,35 @@ func (e *Extension) Run() error {
462482 }); err != nil {
463483 return err
464484 }
485+ if !scanner.Scan() {
486+ return scanner.Err()
487+ }
488+ var ack extproto.HelloAckFromHost
489+ if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil {
490+ return fmt.Errorf("parse hello_ack: %w", err)
491+ }
492+ if ack.Type != "hello_ack" {
493+ return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
494+ }
495+ e.host = HostInfo{
496+ ProtocolVersion: ack.ProtocolVersion,
497+ ZotVersion: ack.ZotVersion,
498+ Provider: ack.Provider,
499+ Model: ack.Model,
500+ CWD: ack.CWD,
501+ ExtensionDir: ack.ExtensionDir,
502+ DataDir: ack.DataDir,
503+ }
504+ e.mu.Lock()
505+ onHello := e.onHello
506+ e.mu.Unlock()
507+ if onHello != nil {
508+ onHello(e.host)
509+ }
510+
511+ // Re-announce all commands (covers Command calls made before Run and
512+ // from OnHello, and is also fine for those made after via Command()'s
513+ // direct send).
465514 e.mu.Lock()
466515 descs := append([]descTuple(nil), e.descriptions...)
467516 toolDefs := append([]toolDef(nil), e.toolDefs...)
@@ -508,8 +557,6 @@ func (e *Extension) Run() error {
508557 // land in time for the typical use case.
509558 _ = e.send(extproto.ReadyFromExt{Type: "ready"})
510559
511- scanner := bufio.NewScanner(e.in)
512- scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
513560 for scanner.Scan() {
514561 line := scanner.Bytes()
515562 var frame extproto.Frame
@@ -519,18 +566,7 @@ func (e *Extension) Run() error {
519566 }
520567 switch frame.Type {
521568 case "hello_ack":
522- var ack extproto.HelloAckFromHost
523- if err := json.Unmarshal(line, &ack); err == nil {
524- e.host = HostInfo{
525- ProtocolVersion: ack.ProtocolVersion,
526- ZotVersion: ack.ZotVersion,
527- Provider: ack.Provider,
528- Model: ack.Model,
529- CWD: ack.CWD,
530- ExtensionDir: ack.ExtensionDir,
531- DataDir: ack.DataDir,
532- }
533- }
569+ // Already handled before registrations; ignore duplicates.
534570 case "command_invoked":
535571 var ci extproto.CommandInvokedFromHost
536572 if err := json.Unmarshal(line, &ci); err != nil {
@@ -22,6 +22,11 @@
22 // Build it, drop the binary + an extension.json next to it under22 // Build it, drop the binary + an extension.json next to it under
23 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.23 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
24 //24 //
25+// Use OnHello when registration or configuration depends on host metadata
26+// such as HostInfo.CWD, Provider, Model, ZotVersion, ExtensionDir, or DataDir.
27+// Run sends hello, waits for hello_ack, runs OnHello, announces registrations,
28+// then sends ready.
29+//
25 // The same wire format also has reference clients in TypeScript and30 // The same wire format also has reference clients in TypeScript and
26 // Python under examples/extensions/. Use whichever language fits.31 // Python under examples/extensions/. Use whichever language fits.
27 package ext32 package ext
@@ -257,6 +262,7 @@ type Extension struct {
257 interceptAssistant AssistantMessageHandler262 interceptAssistant AssistantMessageHandler
258 panelKeys map[string]func(key, text string)263 panelKeys map[string]func(key, text string)
259 panelCloses map[string]func()264 panelCloses map[string]func()
265+ onHello func(HostInfo)
260 266
261 // Caps reported in the hello frame.267 // Caps reported in the hello frame.
262 caps []string268 caps []string
@@ -309,6 +315,17 @@ func New(name, version string) *Extension {
309 // Returns the zero value if Run hasn't started yet.315 // Returns the zero value if Run hasn't started yet.
310 func (e *Extension) Host() HostInfo { return e.host }316 func (e *Extension) Host() HostInfo { return e.host }
311 317
318+// OnHello registers a callback that runs after zot acknowledges the
319+// hello frame and before commands/tools are announced. Use it when
320+// registrations depend on host metadata such as CWD or ZotHome-derived
321+// paths. The callback may call Command, Tool, On, and interceptor
322+// registration methods.
323+func (e *Extension) OnHello(fn func(HostInfo)) {
324+ e.mu.Lock()
325+ e.onHello = fn
326+ e.mu.Unlock()
327+}
328+
312 // Logf writes a line to the extension's stderr, which zot captures to329 // Logf writes a line to the extension's stderr, which zot captures to
313 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything330 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314 // you print to stdout would corrupt the JSON wire protocol.331 // you print to stdout would corrupt the JSON wire protocol.
@@ -451,9 +468,12 @@ func (e *Extension) Notify(level, message string) {
451 // Run starts the protocol loop. Blocks until stdin closes (zot has468 // Run starts the protocol loop. Blocks until stdin closes (zot has
452 // shut us down). Returns the first fatal error, or nil on clean exit.469 // shut us down). Returns the first fatal error, or nil on clean exit.
453 func (e *Extension) Run() error {470 func (e *Extension) Run() error {
454- // Send hello, then re-announce all commands (covers Command calls471+ scanner := bufio.NewScanner(e.in)
455- // made before Run, and is also fine for those made after via472+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
456- // Command()'s direct send).473+
474+ // Send hello first. zot answers with hello_ack immediately; read it
475+ // synchronously so extensions can use HostInfo while registering
476+ // commands/tools before the ready frame.
457 if err := e.send(extproto.HelloFromExt{477 if err := e.send(extproto.HelloFromExt{
458 Type: "hello",478 Type: "hello",
459 Name: e.name,479 Name: e.name,
@@ -462,6 +482,35 @@ func (e *Extension) Run() error {
462 }); err != nil {482 }); err != nil {
463 return err483 return err
464 }484 }
485+ if !scanner.Scan() {
486+ return scanner.Err()
487+ }
488+ var ack extproto.HelloAckFromHost
489+ if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil {
490+ return fmt.Errorf("parse hello_ack: %w", err)
491+ }
492+ if ack.Type != "hello_ack" {
493+ return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
494+ }
495+ e.host = HostInfo{
496+ ProtocolVersion: ack.ProtocolVersion,
497+ ZotVersion: ack.ZotVersion,
498+ Provider: ack.Provider,
499+ Model: ack.Model,
500+ CWD: ack.CWD,
501+ ExtensionDir: ack.ExtensionDir,
502+ DataDir: ack.DataDir,
503+ }
504+ e.mu.Lock()
505+ onHello := e.onHello
506+ e.mu.Unlock()
507+ if onHello != nil {
508+ onHello(e.host)
509+ }
510+
511+ // Re-announce all commands (covers Command calls made before Run and
512+ // from OnHello, and is also fine for those made after via Command()'s
513+ // direct send).
465 e.mu.Lock()514 e.mu.Lock()
466 descs := append([]descTuple(nil), e.descriptions...)515 descs := append([]descTuple(nil), e.descriptions...)
467 toolDefs := append([]toolDef(nil), e.toolDefs...)516 toolDefs := append([]toolDef(nil), e.toolDefs...)
@@ -508,8 +557,6 @@ func (e *Extension) Run() error {
508 // land in time for the typical use case.557 // land in time for the typical use case.
509 _ = e.send(extproto.ReadyFromExt{Type: "ready"})558 _ = e.send(extproto.ReadyFromExt{Type: "ready"})
510 559
511- scanner := bufio.NewScanner(e.in)
512- scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
513 for scanner.Scan() {560 for scanner.Scan() {
514 line := scanner.Bytes()561 line := scanner.Bytes()
515 var frame extproto.Frame562 var frame extproto.Frame
@@ -519,18 +566,7 @@ func (e *Extension) Run() error {
519 }566 }
520 switch frame.Type {567 switch frame.Type {
521 case "hello_ack":568 case "hello_ack":
522- var ack extproto.HelloAckFromHost569+ // Already handled before registrations; ignore duplicates.
523- if err := json.Unmarshal(line, &ack); err == nil {
524- e.host = HostInfo{
525- ProtocolVersion: ack.ProtocolVersion,
526- ZotVersion: ack.ZotVersion,
527- Provider: ack.Provider,
528- Model: ack.Model,
529- CWD: ack.CWD,
530- ExtensionDir: ack.ExtensionDir,
531- DataDir: ack.DataDir,
532- }
533- }
534 case "command_invoked":570 case "command_invoked":
535 var ci extproto.CommandInvokedFromHost571 var ci extproto.CommandInvokedFromHost
536 if err := json.Unmarshal(line, &ci); err != nil {572 if err := json.Unmarshal(line, &ci); err != nil {
M packages/agent/ext/ext_test.go +52 -0
@@ -137,6 +137,58 @@ func (h *extHarness) handshake(t *testing.T) {
137137
138138 // ---------- tests ----------
139139
140+// TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
141+// host metadata before announcing their initial registrations.
142+func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
143+ h := newHarness("cwd-ext")
144+
145+ h.ext.OnHello(func(host HostInfo) {
146+ h.ext.Command("cwd", "show cwd", func(args string) Response {
147+ return Display(host.CWD)
148+ })
149+ })
150+
151+ go h.ext.Run()
152+
153+ f := h.next(t)
154+ if f.hdr.Type != "hello" {
155+ t.Fatalf("expected hello, got %q", f.hdr.Type)
156+ }
157+ h.sendToExt(t, extproto.HelloAckFromHost{
158+ Type: "hello_ack",
159+ ProtocolVersion: extproto.ProtocolVersion,
160+ ZotVersion: "0.0.0-test",
161+ Provider: "anthropic",
162+ Model: "claude-test",
163+ CWD: "/tmp/project",
164+ })
165+
166+ var sawCommand bool
167+ for {
168+ f := h.next(t)
169+ if f.hdr.Type == "register_command" {
170+ var rc extproto.RegisterCommandFromExt
171+ if err := json.Unmarshal(f.raw, &rc); err != nil {
172+ t.Fatalf("unmarshal register_command: %v", err)
173+ }
174+ if rc.Name == "cwd" {
175+ sawCommand = true
176+ }
177+ }
178+ if f.hdr.Type == "ready" {
179+ break
180+ }
181+ }
182+ if !sawCommand {
183+ t.Fatal("OnHello-registered command was not announced before ready")
184+ }
185+ if got := h.ext.Host().CWD; got != "/tmp/project" {
186+ t.Fatalf("Host().CWD = %q, want /tmp/project", got)
187+ }
188+
189+ h.hostW.Close()
190+}
191+
140192 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141193 // well-formed open_panel frame with the correct PanelSpec fields.
142194 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
@@ -137,6 +137,58 @@ func (h *extHarness) handshake(t *testing.T) {
137 137
138 // ---------- tests ----------138 // ---------- tests ----------
139 139
140+// TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
141+// host metadata before announcing their initial registrations.
142+func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
143+ h := newHarness("cwd-ext")
144+
145+ h.ext.OnHello(func(host HostInfo) {
146+ h.ext.Command("cwd", "show cwd", func(args string) Response {
147+ return Display(host.CWD)
148+ })
149+ })
150+
151+ go h.ext.Run()
152+
153+ f := h.next(t)
154+ if f.hdr.Type != "hello" {
155+ t.Fatalf("expected hello, got %q", f.hdr.Type)
156+ }
157+ h.sendToExt(t, extproto.HelloAckFromHost{
158+ Type: "hello_ack",
159+ ProtocolVersion: extproto.ProtocolVersion,
160+ ZotVersion: "0.0.0-test",
161+ Provider: "anthropic",
162+ Model: "claude-test",
163+ CWD: "/tmp/project",
164+ })
165+
166+ var sawCommand bool
167+ for {
168+ f := h.next(t)
169+ if f.hdr.Type == "register_command" {
170+ var rc extproto.RegisterCommandFromExt
171+ if err := json.Unmarshal(f.raw, &rc); err != nil {
172+ t.Fatalf("unmarshal register_command: %v", err)
173+ }
174+ if rc.Name == "cwd" {
175+ sawCommand = true
176+ }
177+ }
178+ if f.hdr.Type == "ready" {
179+ break
180+ }
181+ }
182+ if !sawCommand {
183+ t.Fatal("OnHello-registered command was not announced before ready")
184+ }
185+ if got := h.ext.Host().CWD; got != "/tmp/project" {
186+ t.Fatalf("Host().CWD = %q, want /tmp/project", got)
187+ }
188+
189+ h.hostW.Close()
190+}
191+
140 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a192 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141 // well-formed open_panel frame with the correct PanelSpec fields.193 // well-formed open_panel frame with the correct PanelSpec fields.
142 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {194 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +40 -16
@@ -106,10 +106,11 @@ zot replies with `hello_ack`:
106106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107107 ```
108108
109-### Registration (immediately after hello)
109+### Registration (after hello_ack)
110110
111-Send registration frames in any order, then a single `ready`
112-sentinel so zot can finalize the agent's tool registry:
111+The canonical startup order is `hello`, wait for `hello_ack`, send
112+registration frames in any order, then send a single `ready` sentinel
113+so zot can finalize the agent's tool registry:
113114
114115 ```json
115116 {"type":"register_command","name":"weather","description":"current weather"}
@@ -121,7 +122,11 @@ sentinel so zot can finalize the agent's tool registry:
121122
122123 If you don't send `ready`, zot's idle watchdog auto-treats you as
123124 ready after 250ms of no frames, but always send it explicitly when
124-you can — newer extensions on faster hosts shave that 250ms off.
125+you can. Newer extensions on faster hosts shave that 250ms off.
126+
127+Use `hello_ack.cwd` for the user's project directory. The extension
128+process itself runs from the extension directory, so do not use
129+`os.Getwd()` or `process.cwd()` when you need the project path.
125130
126131 ### Runtime frames
127132
@@ -171,9 +176,10 @@ never stalls the agent.
171176 ## Important rules
172177
173178 - **stdout is reserved for the protocol.** Anything you print to
174- stdout that isn't a JSON frame breaks the wire. Use stderr for
175- logs / debug output (zot captures stderr to
176- `$ZOT_HOME/logs/ext-<name>.log`).
179+ stdout that isn't a JSON frame breaks the wire. The first stdout
180+ frame must be `hello`; do not send `notify`, logs, or registration
181+ frames before that handshake starts. Use stderr for logs / debug
182+ output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
177183 - **One JSON object per line.** No multi-line JSON. Always end
178184 every frame with `\n`.
179185 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -212,6 +218,15 @@ func main() {
212218 return ext.TextResult(in.City + ": sunny, 21°C (fake)")
213219 })
214220
221+ // Optional: register project-specific commands after hello_ack.
222+ e.OnHello(func(host ext.HostInfo) {
223+ if host.CWD != "" {
224+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
225+ return ext.Display(host.CWD)
226+ })
227+ }
228+ })
229+
215230 if err := e.Run(); err != nil {
216231 e.Logf("fatal: %v", err)
217232 }
@@ -220,6 +235,11 @@ func main() {
220235
221236 Build: `go build -o weather .`
222237
238+`OnHello` is optional. Use it when configuration or registrations need
239+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
240+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
241+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
242+
223243 `extension.json`:
224244 ```json
225245 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
@@ -243,16 +263,18 @@ function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
243263
244264 send({ type: "hello", name: "scratchpad", version: "1.0.0",
245265 capabilities: ["commands", "tools"] });
246-send({ type: "register_command", name: "note", description: "append a note" });
247-send({ type: "register_tool", name: "read_notes",
248- description: "Read the user's scratchpad notes.",
249- schema: { type: "object", properties: {} } });
250-send({ type: "ready" });
251266
252267 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
253268 rl.on("line", (line) => {
254269 const f = JSON.parse(line);
255- if (f.type === "command_invoked" && f.name === "note") {
270+ if (f.type === "hello_ack") {
271+ // f.cwd is the user's project directory.
272+ send({ type: "register_command", name: "note", description: "append a note" });
273+ send({ type: "register_tool", name: "read_notes",
274+ description: "Read the user's scratchpad notes.",
275+ schema: { type: "object", properties: {} } });
276+ send({ type: "ready" });
277+ } else if (f.type === "command_invoked" && f.name === "note") {
256278 send({ type: "command_response", id: f.id, action: "display",
257279 display: `noted: ${f.args}` });
258280 } else if (f.type === "tool_call" && f.name === "read_notes") {
@@ -282,12 +304,14 @@ import json, sys
282304 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
283305
284306 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
285-emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
286-emit({"type": "ready"})
287307
288308 for line in sys.stdin:
289309 msg = json.loads(line)
290- if msg["type"] == "command_invoked":
310+ if msg["type"] == "hello_ack":
311+ # msg["cwd"] is the user's project directory.
312+ emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
313+ emit({"type": "ready"})
314+ elif msg["type"] == "command_invoked":
291315 emit({"type": "command_response", "id": msg["id"],
292316 "action": "prompt", "prompt": "Say hi briefly."})
293317 elif msg["type"] == "shutdown":
@@ -106,10 +106,11 @@ zot replies with `hello_ack`:
106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107 ```107 ```
108 108
109-### Registration (immediately after hello)109+### Registration (after hello_ack)
110 110
111-Send registration frames in any order, then a single `ready`111+The canonical startup order is `hello`, wait for `hello_ack`, send
112-sentinel so zot can finalize the agent's tool registry:112+registration frames in any order, then send a single `ready` sentinel
113+so zot can finalize the agent's tool registry:
113 114
114 ```json115 ```json
115 {"type":"register_command","name":"weather","description":"current weather"}116 {"type":"register_command","name":"weather","description":"current weather"}
@@ -121,7 +122,11 @@ sentinel so zot can finalize the agent's tool registry:
121 122
122 If you don't send `ready`, zot's idle watchdog auto-treats you as123 If you don't send `ready`, zot's idle watchdog auto-treats you as
123 ready after 250ms of no frames, but always send it explicitly when124 ready after 250ms of no frames, but always send it explicitly when
124-you can — newer extensions on faster hosts shave that 250ms off.125+you can. Newer extensions on faster hosts shave that 250ms off.
126+
127+Use `hello_ack.cwd` for the user's project directory. The extension
128+process itself runs from the extension directory, so do not use
129+`os.Getwd()` or `process.cwd()` when you need the project path.
125 130
126 ### Runtime frames131 ### Runtime frames
127 132
@@ -171,9 +176,10 @@ never stalls the agent.
171 ## Important rules176 ## Important rules
172 177
173 - **stdout is reserved for the protocol.** Anything you print to178 - **stdout is reserved for the protocol.** Anything you print to
174- stdout that isn't a JSON frame breaks the wire. Use stderr for179+ stdout that isn't a JSON frame breaks the wire. The first stdout
175- logs / debug output (zot captures stderr to180+ frame must be `hello`; do not send `notify`, logs, or registration
176- `$ZOT_HOME/logs/ext-<name>.log`).181+ frames before that handshake starts. Use stderr for logs / debug
182+ output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
177 - **One JSON object per line.** No multi-line JSON. Always end183 - **One JSON object per line.** No multi-line JSON. Always end
178 every frame with `\n`.184 every frame with `\n`.
179 - **Flush after writing.** Most stdout writes are line-buffered when185 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -212,6 +218,15 @@ func main() {
212 return ext.TextResult(in.City + ": sunny, 21°C (fake)")218 return ext.TextResult(in.City + ": sunny, 21°C (fake)")
213 })219 })
214 220
221+ // Optional: register project-specific commands after hello_ack.
222+ e.OnHello(func(host ext.HostInfo) {
223+ if host.CWD != "" {
224+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
225+ return ext.Display(host.CWD)
226+ })
227+ }
228+ })
229+
215 if err := e.Run(); err != nil {230 if err := e.Run(); err != nil {
216 e.Logf("fatal: %v", err)231 e.Logf("fatal: %v", err)
217 }232 }
@@ -220,6 +235,11 @@ func main() {
220 235
221 Build: `go build -o weather .`236 Build: `go build -o weather .`
222 237
238+`OnHello` is optional. Use it when configuration or registrations need
239+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
240+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
241+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
242+
223 `extension.json`:243 `extension.json`:
224 ```json244 ```json
225 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}245 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
@@ -243,16 +263,18 @@ function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
243 263
244 send({ type: "hello", name: "scratchpad", version: "1.0.0",264 send({ type: "hello", name: "scratchpad", version: "1.0.0",
245 capabilities: ["commands", "tools"] });265 capabilities: ["commands", "tools"] });
246-send({ type: "register_command", name: "note", description: "append a note" });
247-send({ type: "register_tool", name: "read_notes",
248- description: "Read the user's scratchpad notes.",
249- schema: { type: "object", properties: {} } });
250-send({ type: "ready" });
251 266
252 const rl = createInterface({ input: stdin, crlfDelay: Infinity });267 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
253 rl.on("line", (line) => {268 rl.on("line", (line) => {
254 const f = JSON.parse(line);269 const f = JSON.parse(line);
255- if (f.type === "command_invoked" && f.name === "note") {270+ if (f.type === "hello_ack") {
271+ // f.cwd is the user's project directory.
272+ send({ type: "register_command", name: "note", description: "append a note" });
273+ send({ type: "register_tool", name: "read_notes",
274+ description: "Read the user's scratchpad notes.",
275+ schema: { type: "object", properties: {} } });
276+ send({ type: "ready" });
277+ } else if (f.type === "command_invoked" && f.name === "note") {
256 send({ type: "command_response", id: f.id, action: "display",278 send({ type: "command_response", id: f.id, action: "display",
257 display: `noted: ${f.args}` });279 display: `noted: ${f.args}` });
258 } else if (f.type === "tool_call" && f.name === "read_notes") {280 } else if (f.type === "tool_call" && f.name === "read_notes") {
@@ -282,12 +304,14 @@ import json, sys
282 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()304 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
283 305
284 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})306 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
285-emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
286-emit({"type": "ready"})
287 307
288 for line in sys.stdin:308 for line in sys.stdin:
289 msg = json.loads(line)309 msg = json.loads(line)
290- if msg["type"] == "command_invoked":310+ if msg["type"] == "hello_ack":
311+ # msg["cwd"] is the user's project directory.
312+ emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
313+ emit({"type": "ready"})
314+ elif msg["type"] == "command_invoked":
291 emit({"type": "command_response", "id": msg["id"],315 emit({"type": "command_response", "id": msg["id"],
292 "action": "prompt", "prompt": "Say hi briefly."})316 "action": "prompt", "prompt": "Say hi briefly."})
293 elif msg["type"] == "shutdown":317 elif msg["type"] == "shutdown":

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close