zot/docs/extensions.md

636 lines
21 KiB
Markdown
Raw Permalink Normal View History

feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
# zot extensions
zot can be extended with custom slash commands by running an external
program as a subprocess and exchanging newline-delimited JSON over
its stdin/stdout. Extensions can be written in **any language** that
can read and write JSON lines from stdio — Go, TypeScript, Python,
Rust, shell with `jq`, anything.
Four phases shipped so far:
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
- **Phase 1**: slash commands + chat notifications.
- **Phase 2**: tools the LLM can call.
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
- **Phase 3**: lifecycle event subscriptions + tool-call interception
for guardrail extensions.
- **Phase 4**: interactive extension-owned panels rendered inside zot.
- **Theme-only extensions**: ship `theme.json` without launching a
subprocess. See [themes.md](themes.md).
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
## Quick start
The simplest extension is a script that prints a hello frame, reads
commands, and prints responses. Here's the whole thing in **Python**,
no SDK required:
```python
#!/usr/bin/env python3
# $ZOT_HOME/extensions/hello-py/hello.py
import json, sys, threading
def emit(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
for line in sys.stdin:
msg = json.loads(line)
if msg["type"] == "command_invoked":
emit({"type":"command_response","id":msg["id"],"action":"prompt",
"prompt": "Greet me very briefly. Add one emoji."})
elif msg["type"] == "shutdown":
emit({"type":"shutdown_ack"})
break
```
Drop it in a directory with this `extension.json`:
```json
{
"name": "hello-py",
"version": "1.0.0",
"exec": "./hello.py",
"language": "python",
"enabled": true
}
```
`exec` is required for protocol extensions. If an extension only ships
`theme.json` or `themes/theme.json`, no `exec` is required and zot does
not spawn a subprocess.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
`chmod +x hello.py`, install:
```bash
zot ext install ./hello-py
```
Restart `zot`, type `/hellopy`, the agent greets you. Done.
## Built-in extensions
**zot ships with no extensions installed by default.** A fresh `zot install` (or `go install`) gives you a clean agent. Extensions are entirely opt-in: you install (or `--ext` for one run) only the ones you want.
The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:
```bash
# go-based examples need a build first
cd path/to/zot/examples/extensions/hello && go build -o hello .
# install (copies to $ZOT_HOME/extensions/hello/)
zot ext install path/to/zot/examples/extensions/hello
# or load straight from the repo for one zot session
zot --ext path/to/zot/examples/extensions/hello
```
Nothing is auto-installed and nothing reaches out to the network without your explicit action.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
## Layout & discovery
zot scans two directories on startup, in this order:
1. **Project-local**: `./.zot/extensions/<name>/extension.json`
2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
A project-local extension with the same name wins over a global one.
On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
Because each extension owns its own directory, the recommended place
for extension state is inside that directory itself (for example
`todos.json`, `settings.json`, or an auth/cache file used only by that
extension). The host also passes this path back in `hello_ack` as
`extension_dir` / `data_dir` so runtime code does not need to guess it.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
Each extension owns its own subdirectory. The `extension.json`
manifest tells zot how to launch it:
```json
{
"name": "weather",
"version": "1.0.0",
"exec": "./weather",
"args": ["--mode", "daemon"],
"language": "go",
"description": "current weather for any city",
"enabled": true
}
```
| field | meaning |
|---|---|
| `name` | required. how zot identifies the extension; must match what's sent in the `hello` frame. |
| `version` | optional. shown in `zot ext list`. |
| `exec` | required. path to the executable (relative to the manifest). |
| `args` | optional. extra argv passed to `exec`. |
| `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
| `description` | optional. shown in `zot ext list`. |
| `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
## Lifecycle
1. **Discovery**: zot reads every `extension.json` in the search dirs.
2. **Spawn**: enabled extensions are launched as subprocesses. stderr
redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
extension, append-mode).
3. **Hello handshake**: the extension sends a `hello` frame; zot
replies with `hello_ack` containing the protocol version, the
active provider/model/cwd, and the extension's own data directory
so it can persist files beside its manifest.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
4. **Registration**: the extension sends `register_command` frames.
First-come-first-served: a name already taken by a built-in or by
a previously-loaded extension is silently shadowed (logged in the
extension's own log file).
5. **Runtime**: zot dispatches `command_invoked` frames when the
user runs a registered command; the extension responds with
`command_response`. Extensions can also push `notify` frames at
any time. Panel-capable extensions may open an interactive panel,
receive key events, and push redraws while the panel is focused.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
2s for the extension to send `shutdown_ack`. Holdouts are
SIGTERM'd, then SIGKILL'd.
A crashing extension does not bring down zot. The slash command it
owned simply stops working until the extension is fixed and zot is
restarted.
## Wire format
All frames are one JSON object per line. Top-level `type` is the
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
discriminator. Optional `id` correlates request frames with their
responses.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
### Extension → host
#### `hello` (required, first frame)
```json
{"type":"hello","name":"weather","version":"1.0.0",
"capabilities":["commands","tools","panels"]}
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
```
#### `register_command`
```json
{"type":"register_command","name":"weather",
"description":"current weather for a city"}
```
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
#### `register_tool`
Registers a tool the LLM can call. `schema` is a JSON Schema object
describing the tool's args (the same shape Anthropic and OpenAI accept).
```json
{"type":"register_tool","name":"weather",
"description":"Get the current weather for a city.",
"schema":{
"type":"object",
"properties":{"city":{"type":"string"}},
"required":["city"]
}}
```
Tool names live in the same namespace as built-in tools (`read`,
`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
the built-in.
#### `ready`
Sentinel telling zot "all initial registrations are flushed". Send it
right after your last `register_*` frame so the host can build the
agent's tool registry without racing the registration window.
```json
{"type":"ready"}
```
#### `tool_result`
Reply to a `tool_call` from the host. `content[]` is a list of
message blocks; each block is `{"type":"text","text":"..."}` or
`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
`is_error: true` to mark the call as failed.
```json
{"type":"tool_result","id":"...",
"content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
```
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
#### `subscribe`
Declares which lifecycle events the extension wants to observe and
which it wants to intercept. Send once after `hello`, before `ready`.
```json
{"type":"subscribe",
"events":["session_start","turn_start","tool_call","turn_end","assistant_message"],
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
"intercept":["tool_call","turn_start","assistant_message"]}
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
```
Recognised event names: `session_start`, `turn_start`, `turn_end`,
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
`tool_call`, `assistant_message`.
Interceptable events:
- `tool_call`: block the call (model sees `reason` as the tool
error) or rewrite args via `modified_args`.
- `turn_start`: block the turn before the model is called. Useful
for rate-limiting and business-hour gates. `reason` is shown to
the user as a status line. No rewrite supported.
- `assistant_message`: suppress the message via `block`, or rewrite
the user-visible text via `replace_text`. The model's original
text stays in the transcript so the model sees what it actually
said on subsequent turns.
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
#### `event_intercept_response`
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
Reply to an `event_intercept` from the host. All fields default to
"allow, pass through unmodified".
| field | meaning |
|---|---|
| `block` | `true` refuses the action. For `tool_call`, `reason` is shown to the model; for `turn_start` / `assistant_message`, `reason` is shown to the user. |
| `reason` | refusal text (on block) or pass-through note. |
| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
| `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
Missing the response within 5s is treated as "allow" (i.e. an
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
unresponsive extension never stalls the agent). When multiple
extensions subscribe to the same event, they're consulted serially;
the first `block` wins and rewrites (args / text) chain: each
subsequent interceptor sees the previous one's output.
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
```json
{"type":"event_intercept_response","id":"...",
"block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
{"type":"event_intercept_response","id":"...",
"modified_args":{"command":"echo GUARDED: ls"}}
{"type":"event_intercept_response","id":"...",
"replace_text":"[redacted]"}
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
```
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
#### `command_response` (reply to `command_invoked`)
```json
{"type":"command_response","id":"...","action":"prompt",
"prompt":"Show today's weather for Berlin in one line."}
```
`action` is one of:
- `"prompt"` — submits `prompt` as a fresh user message; the agent
runs a turn against it.
- `"insert"` — inserts `insert` into the editor at the cursor without
submitting.
- `"display"` — appends `display` to the chat as a one-shot styled
note. No model call, nothing written to the transcript.
- `"open_panel"` — opens an extension-owned interactive panel inside
zot. The panel content lives in `open_panel`.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
- `"noop"` — the extension handled it itself (e.g. it pushed
`notify` frames or kicked off background work). zot doesn't change
the UI in response.
Example:
```json
{"type":"command_response","id":"...","action":"open_panel",
"open_panel":{
"id":"todos-main",
"title":"Todos",
"lines":["□ ship panel api","✓ persist state"],
"footer":"↑/↓ navigate - a add - x complete - esc close"
}}
```
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
If `error` is non-empty, zot renders it as a red status line
regardless of `action`.
#### `panel_render` (one-way, while a panel is open)
Pushes a fresh frame for an already-open panel.
```json
{"type":"panel_render","panel_id":"todos-main",
"title":"Todos",
"lines":["□ ship panel api","✓ persist state"],
"footer":"↑/↓ navigate - a add - x complete - esc close"}
```
#### `panel_close`
Closes a previously-open panel.
```json
{"type":"panel_close","panel_id":"todos-main"}
```
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
#### `notify` (one-way, any time)
```json
{"type":"notify","level":"info",
"message":"refreshed cache (12 entries)"}
```
`level` is one of `info`, `success`, `warn`, `error`. The note shows
up below the transcript with the extension's name in brackets. Notes
are one-shot: they clear automatically when the user sends their next
prompt (and on `esc` / `/clear`).
#### `clear_notes` (one-way, any time)
Removes every note this extension previously pushed via `notify` /
`display`. Use it for transient status lines (e.g. an approval prompt)
so they do not stack up; notes from other extensions are untouched.
```json
{"type":"clear_notes"}
```
In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
event (alongside `ext_notify` / `ext_display`).
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
#### `shutdown_ack`
Sent in response to `shutdown`. Extension should exit promptly after.
### Host → extension
#### `hello_ack`
```json
{"type":"hello_ack","protocol_version":1,
"zot_version":"0.0.7","provider":"anthropic",
"model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
"extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
"data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
```
Sent immediately after `hello`. The extension can use these fields to
decide which commands to register (e.g. only register a Python tool
on macOS, only register a model-specific shortcut for opus, etc.).
`extension_dir` / `data_dir` are where the extension should persist
its own state (for example `todos.json`, cached metadata, or auth
tokens scoped to that extension).
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
#### `command_invoked`
```json
{"type":"command_invoked","id":"...",
"name":"weather","args":"berlin"}
```
`args` is everything the user typed after the command name, trimmed.
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
#### `tool_call`
Sent when the LLM invokes a tool the extension registered. `args` is
the parsed JSON object the model produced; the extension is
responsible for validating/coercing it.
```json
{"type":"tool_call","id":"...","name":"weather",
"args":{"city":"Berlin"}}
```
Reply with `tool_result` within the host's tool timeout (default 60s).
Missing the timeout surfaces an error to the model and the call is
marked as failed.
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
#### `event`
Lifecycle notification for events the extension subscribed to via
`subscribe`. One-way — no response expected.
```json
{"type":"event","event":"turn_start","step":1}
{"type":"event","event":"tool_call",
"tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
{"type":"event","event":"turn_end","stop":"end_turn"}
```
#### `event_intercept`
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
Sent when zot wants to give the extension a chance to block, modify,
or annotate a lifecycle event before it happens. Reply with
`event_intercept_response` within 5s; missing the deadline is
treated as "allow".
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
Payload fields depend on the event:
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
```json
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
// tool_call: includes the tool id, name, and parsed args
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
{"type":"event_intercept","id":"...","event":"tool_call",
"tool_id":"...","tool_name":"bash",
"tool_args":{"command":"rm -rf /tmp/foo"}}
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
// turn_start: includes the step number
{"type":"event_intercept","id":"...","event":"turn_start",
"step":3}
// assistant_message: includes the assembled text
{"type":"event_intercept","id":"...","event":"assistant_message",
"text":"here is your api key: sk-ant-..."}
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
```
#### `panel_key`
Sent while an extension-owned panel is focused. `key` is a normalized
name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
`key:"rune"`, `text` carries the typed character.
```json
{"type":"panel_key","panel_id":"todos-main","key":"down"}
{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
```
#### `panel_close`
Sent when the user closes the focused panel from zot (for example with
Esc or Ctrl+C). The extension should treat this as the panel lifetime
ending and stop sending `panel_render` updates for that `panel_id`.
```json
{"type":"panel_close","panel_id":"todos-main"}
```
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
#### `shutdown`
Sent during graceful zot exit (or `/reload-ext` once that lands).
Reply with `shutdown_ack` and then exit.
## Managing extensions from the CLI
```
zot ext list list installed extensions and their state
zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
zot ext remove <name> delete an extension directory
zot ext enable <name> re-enable a disabled extension
zot ext disable <name> disable without removing
zot ext logs <name> [-f] cat / tail the extension's stderr
```
`zot ext install <path>` does a recursive copy; `<git-url>` does a
shallow clone. Both validate that the destination contains an
`extension.json` and roll back if not.
## Loading an extension for one run
For iteration on a working copy, skip the install + reload cycle
and load straight from disk for one zot session:
```
zot --ext ./my-extension # short form: -e ./my-extension
zot --ext ./a -e ./b # repeatable
```
`--ext` paths take precedence over installed extensions of the same
name, so you can shadow an installed copy with a work-in-progress
version without uninstalling first. Nothing is copied or persisted;
the extension dies with zot like any other subprocess.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
## SDKs
Writing the wire protocol by hand is fine for one-off scripts, but
for anything bigger the SDKs handle the boilerplate.
### Go — `packages/agent/ext`
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
```go
package main
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
import (
"encoding/json"
"github.com/patriceckhart/zot/packages/agent/ext"
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
)
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
func main() {
e := ext.New("hello", "1.0.0")
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
// Slash command
e.Command("hello", "say hi", func(args string) ext.Response {
return ext.Prompt("Greet me in one short sentence.")
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
})
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
// LLM-callable tool
e.Tool("weather", "Current weather for a city.",
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
func(args json.RawMessage) ext.ToolResult {
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
var in struct{ City string `json:"city"` }
json.Unmarshal(args, &in)
return ext.TextResult(in.City + ": sunny")
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
})
e.Run()
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
}
```
Build with `go build -o hello .`, drop the binary + an `extension.json`
into `$ZOT_HOME/extensions/hello/`.
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
The SDK has four interceptor hooks, all optional:
```go
// e is the *ext.Extension returned by ext.New(...).
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
// Refuse calls or rewrite args before they run.
e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
if tool == "bash" { /* inspect args, return false, reason */ }
return true, ""
})
// Richer variant: returns ToolCallDecision so you can also rewrite
// args via ModifiedArgs.
e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
return ext.ToolCallDecision{
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
}
})
// Block the next turn before the model is called.
e.InterceptTurnStart(func(step int) ext.TurnStartDecision {
if time.Now().Hour() < 9 { return ext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
return ext.TurnStartDecision{}
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
})
// Scrub or rewrite the assistant's final text before the user sees it.
e.InterceptAssistantMessage(func(text string) ext.AssistantMessageDecision {
return ext.AssistantMessageDecision{
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
}
})
```
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
See:
- `examples/extensions/hello/` — slash commands
- `examples/extensions/clock/` — slash commands in plain Node, no SDK
- `examples/extensions/weather/` — LLM-callable tool
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
- `examples/extensions/guard/` — event subscriptions + tool-call
interception (refuses dangerous bash patterns)
- `examples/extensions/todo/` — interactive persistent panel + tool
- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
### Hot reload
Type `/reload-ext` in the TUI to tear down every running extension
subprocess, re-read the manifests from disk, and respawn the set.
The agent's tool registry is rebuilt automatically, so freshly-
registered extension tools become callable without restarting zot.
Useful while developing an extension: edit, save, `/reload-ext`,
done. Explicit `--ext` paths are remembered and reloaded alongside
discovered extensions.
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
### TypeScript / Python
These SDKs aren't in the main repo yet; the wire format is small
enough that a `~30 line` raw script gets you started in either
language. See the [Quick start](#quick-start) Python example for the
shape. SDK packages will land in follow-up commits.
## Security
Extensions run with **the user's full filesystem and network
permissions**. Treat installing an extension the same as installing
any other binary on your machine.
`zot ext install <git-url>` clones from any URL you give it. There's
no sandbox in v1; if you need isolation, install only extensions you
trust or run zot under your platform's sandboxing tool (`bwrap` /
`sandbox-exec` / AppContainer).
## Roadmap
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
Phase 1 (shipped):
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
- [x] subprocess lifecycle + hello handshake
- [x] `register_command` + `command_invoked`
- [x] `notify` + `clear_notes`
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
- [x] `zot ext` CLI
feat(extensions): phase 2 — extension-defined tools Extensions can now register tools the LLM calls directly. The model sees them in its tool list alongside the built-ins (read, write, edit, bash, skill); when it invokes one, zot routes the tool_call to the owning extension subprocess and feeds the tool_result back. Wire format additions (internal/extproto): ext -> host: register_tool {name, description, schema} ready # all initial regs flushed tool_result {id, content[], is_error} # reply to a tool_call host -> ext: tool_call {id, name, args} # raw json args from the model Manager (internal/agent/extensions): - tracks per-extension RegisterToolFromExt frames - validates schemas parse as JSON before registering (bad schema skipped + logged, doesn't crash zot) - toolIndex map for O(1) lookup - WaitForReady(grace): blocks per extension on its readyCh until a ready frame arrives or the grace expires; called once after Discover so the agent's tool registry is built against the final set - Tools() / HasTool() / InvokeTool() public surface - readLoop closes readyCh on stdout EOF so a wedged extension doesn't permanently block WaitForReady extensionTool (internal/agent/extensions/tool.go): implements core.Tool. Execute() round-trips through Manager.InvokeTool with a 60s default timeout, decodes base64 image blocks, surfaces extension+tool name in ToolResult.Details for the renderer. internal/agent/build.go: - new ExtensionToolSource interface (declared here to avoid the build->extensions->core import cycle) + ExtensionToolInfo mirror of extensions.ToolInfo - Resolved.MergeExtensionTools(): folds extension tools into ToolRegistry, re-renders the system prompt's tool summary with both built-in and extension tools listed - Resolved gains private bookkeeping fields so the rebuild works without re-running Resolve internal/agent/cli.go: extension manager built BEFORE the agent in interactive mode so MergeExtensionTools can fire before NewAgent. Same in buildAgent + buildAgentFor closures so login / model-switch rebuilds also include extension tools. extToolAdapter bridges *extensions.Manager to ExtensionToolSource. internal/agent/rpc.go: extension lifecycle now also runs in `zot rpc` mode. Notify and Display from extensions surface as `ext_notify` / `ext_display` events on the rpc stream so any consumer can react. pkg/zotext (Go SDK): - ToolHandler, ToolResult, ToolContent types - Tool(name, desc, schema, fn) registration method - TextResult / TextErrorResult / Image / ImageBytes constructors - Run() now also flushes register_tool frames + a final ready sentinel after the last registration examples/extensions/weather: working Go example registering one tool. Deterministic fake weather (sha1 of city -> temp + cond) so the demo is repeatable. Plus README explaining how to install. Tests: internal/agent/extensions/tool_test.go: spawns a mock /bin/sh extension that registers a tool, sends ready, and echoes tool calls. Verifies registration timing, lookup via HasTool/Tools, invoke roundtrip via InvokeTool. End-to-end verified against live anthropic backend: prompt: "What is the weather in Berlin?" -> [tool_call] weather({"city":"Berlin"}) -> [tool_result] Berlin: 16°C, fog (deterministic fake) -> reply: "Berlin is 16°C." Docs/extensions.md updated with phase 2 wire format, the new SDK tool API, and the weather example.
2026-04-19 14:46:32 +02:00
Phase 2 (shipped):
- [x] `register_tool` + `tool_call` + `tool_result`
- [x] `ready` sentinel for safe agent-registry build timing
- [x] tool result attribution surfaces extension name in details
feat: extension system (subprocess + json-rpc, any language) Phase 1: extensions can register slash commands and push chat notifications. Tools and event subscriptions land in later phases. Architecture: each extension is its own subprocess. Zot launches it on startup, completes a hello/hello_ack handshake over its stdin/stdout, then routes slash commands the extension registered. Crash isolation, language agnostic, works with any executable that can read/write json lines. What lands here: - internal/extproto: shared wire-format types (Frame, HelloFromExt, RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt, HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...). Both the host and the SDK marshal/unmarshal the same types. - internal/agent/extensions: discovery + lifecycle manager. - Discover() walks $ZOT_HOME/extensions and ./.zot/extensions (project-local first, global second; first wins for duplicates) - Spawns each enabled extension, captures stderr to $ZOT_HOME/logs/ext-<name>.log - Reads frames in a goroutine, dispatches register_command and notify, correlates command_response by id - Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL - HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display) - Interactive bridge: extensions slot into the slash dispatcher *after* the built-in catalog, so built-ins always win on conflict. Extension-registered commands also flow into the autocomplete popup and /help via slashSuggester.SetExtra. NotifyFromExt frames render as muted [ext-name] notes above the editor. - internal/agent/extcmd: `zot ext` CLI. list / install <path|git-url> / remove / enable / disable / logs - pkg/zotext: public Go SDK. Construct an Extension, register Command(name, desc, fn), call Run(). Fn returns a Response built with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr via Logf() so stdout stays clean for the protocol. - examples/extensions/hello: working Go example registering /hello and /summon, plus README + extension.json. - docs/extensions.md: full protocol reference, including a ~30-line raw-Python example for users who don't want the SDK. Tests: internal/agent/extensions/manager_test.go spawns a mock extension via /bin/sh and exercises the full handshake -> register -> invoke -> response cycle. Verifies the hello frame ordering, correlation-by-id, and graceful shutdown. Verified manually: built and installed the example, drove it via stdin pipes, confirmed clean handshake + correct frame ordering and shutdown_ack. Builds vet-clean on darwin / linux / windows. Editor.Insert exported (was Editor.insert) so the extension hooks can drop text into the input.
2026-04-19 14:09:43 +02:00
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
Phase 3 (shipped):
- [x] event subscriptions (`session_start`, `turn_start`, `turn_end`,
`tool_call`, `assistant_message`)
- [x] tool-call interception (block before execution)
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
Phase 4 (shipped):
- [x] interception for `turn_start` and `assistant_message` (in
addition to `tool_call`)
- [x] modify tool args mid-flight via `modified_args`
- [x] rewrite user-visible assistant text via `replace_text`
- [x] `/reload-ext` slash command (hot-reload without restarting zot)
feat(extensions): phase 3 — event subscriptions + tool-call interception Two new capabilities, both ride on the existing subprocess protocol with a couple of new frame types. Event subscriptions (one-way notifications): ext -> host: subscribe {events: [...], intercept: [...]} host -> ext: event {event, ...payload} Recognised events: session_start, turn_start, turn_end, tool_call, assistant_message. Subscribers get fire-and-forget notifications on each. Useful for telemetry, audit logs, custom state widgets that follow live agent activity. Tool-call interception (round-trip, can refuse): host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args} ext -> host: event_intercept_response {id, block?, reason?} When at least one extension subscribed to "tool_call" intercept, zot asks each one in turn before running every tool call. First blocker wins; reason becomes the tool-result error text the model sees. Per-extension 5s timeout treats unresponsive interceptors as "allow" so a wedged extension never stalls the agent. Wire format additions (internal/extproto): ext -> host: SubscribeFromExt, EventInterceptResponseFromExt host -> ext: EventFromHost, EventInterceptFromHost Manager (internal/agent/extensions): - per-extension eventSubs / interceptSubs sets, populated by the subscribe frame - EmitEvent fans out to every subscribed extension on its own goroutine (won't block the agent on slow stdin writes) - InterceptToolCall walks subscribers serially, returning the first refusal; 5s timeout per subscriber (allow on timeout) - readLoop handles event_intercept_response correlations the same way it handles command/tool responses Core (internal/core/agent.go): - Agent.BeforeToolExecute hook called from runOneTool right before tool.Execute. Returning (allowed=false, reason) short-circuits with an IsError tool result containing reason. - Agent.OnEvent observer fires for every emitted AgentEvent; composed transparently with the per-Prompt sink via wrapSink so neither the existing TUI nor the rpc loop need changes. Wiring (internal/agent/cli.go, rpc.go): - wireAgentExt sets BeforeToolExecute -> InterceptToolCall and OnEvent -> fanoutAgentEvent for every freshly-built agent (initial, login rebuild, model swap) - fanoutAgentEvent translates core AgentEvent kinds into extproto.EventFromHost. Internal-only events (text_delta, tool_progress) are dropped to keep the per-extension stream sane. - session_start emitted once after extensions come up SDK (pkg/zotext): - On(name, EventHandler) registers per-event observers - InterceptToolCall(InterceptHandler) registers a single intercept callback - Run() now also sends a subscribe frame before the ready sentinel, with the union of subscribed events + intercept - Frame loop handles "event" and "event_intercept" frames, runs the handlers (intercepts on a goroutine to avoid head-of-line blocking) - Capabilities advertised: commands + tools + events Example (examples/extensions/guard): - subscribes to session_start / turn_start / tool_call / turn_end and writes one-line audit entries - intercepts every bash call; refuses commands matching rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777 - end-to-end verified live: agent -> bash("rm -rf /tmp/foo") -> guard refuses -> model sees the refusal text and surfaces it in its reply ("the guard blocked it, as expected — the pattern \brm\s+-rf\b matched") Docs/extensions.md updated with all five new frame types and the guard example.
2026-04-19 14:57:03 +02:00
Future (no firm timeline):
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
- [ ] TypeScript and Python SDK packages (currently the wire format
is stable enough to hand-roll, see the Python quick-start)
- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
- [ ] per-extension permission scopes (today: full user privileges)