Login and logout dialogs show descriptive labels (Anthropic Claude Pro/Max, OpenAI ChatGPT Plus/Pro). Unknown saved providers fall back to an available one instead of crashing. Model picker only shows models from logged-in providers.
Changelog dialog now shows only the changelog section from release notes with headings in accent color. Works for local 0.0.0 builds (fetches latest release). Full-width highlight bars fixed everywhere via erase-to-EOL and trailing ANSI preservation in truncateToWidth. Session ops dialog fixed. README documents the @ file picker.
Reads $ZOT_HOME/models.json at startup and merges user-defined models into the active catalog with highest precedence. Provider keys like openai-codex are normalized. Documented in README.
Add open_panel / panel_render / panel_close / panel_key to the extension protocol, expose extension_dir + data_dir in hello_ack, wire panel rendering and key routing through the interactive TUI, extend the Go SDK, and document the new capability. Also fix doubled user-message indent and redundant assistant wrap.
Two bugs in yesterday's /session export + import:
1. Quoted / tilde paths weren't normalised.
Drag-drop paste in the tui auto-quotes dropped file paths so
the shell-style `/session import 'foo bar.zotsession'` stays
well-formed. But the /session handler's expandTilde checked
for a leading '~' and the string's first char was a literal
quote, so the tilde never expanded and stat failed with
"no such file or directory".
unquotePath helper now strips a matching pair of surrounding
single or double quotes before expandTilde runs. Applies to
both export (dst) and import (src).
2. Export was writing only the meta row when called mid-session.
The tui's default persistence strategy writes agent messages
to the session file lazily: WriteNewTranscript runs once
when the tui exits, NOT after every turn. Meanwhile the
running agent's messages live in a sync.Mutex-guarded slice
in core.Agent.messages. /session export was reading the file
bytes off disk, which at that point only contained the meta
row plus whatever was there on startup.
New FlushSession hook on InteractiveConfig: the cli wires it
to WriteNewTranscript against the current agent, then
advances sessBaselineMsgs so the tui's own exit-time flush
doesn't double-write. /session export calls the hook right
before ExportSession, so the file on disk reflects the full
running transcript at the moment the user hit enter.
Tests:
- internal/core/session_portable_test.go was already exercising
ExportSession/ImportSession against on-disk files; this fix
lives in the cli/modes glue, not in core.
- internal/agent/modes ad-hoc TestUnquotePathThenExpandTilde
(run locally, not committed) covered the 8 tilde+quote
combinations.
Verified: create a fresh session, type "hello", reply, "foo",
reply, run /session export. Exported .zotsession now contains
the meta row + 2 user + 2 assistant + 1 usage row. Re-import
into a different cwd via /session import <path>, /sessions to
confirm it lands as a resumable entry.
Lets one user hand a conversation off to another machine or
user. New slash command:
/session picker with export / import rows
/session export defaults to ~/Downloads/<name>.zotsession
/session export ~/foo writes ~/foo.zotsession
/session export ~/bar/x.zs writes to that exact path (ext added if missing)
/session import <path> loads and switches to it
Exported file is the same jsonl the live session writes, with
the meta row rewritten to strip the source user's cwd. The
importer rotates the id and cwd to claim the copy, so the
imported session becomes a first-class entry in the current
user's sessions/ directory and shows up in /sessions,
/jump, and on-disk summaries like any other.
core/session_portable.go (new)
- ExportSession(src, dst) string returns the resolved
output path. dst can be a file, a directory, or a bare
name missing the .zotsession ext; all three shapes land
somewhere sensible.
- ImportSession(src, root, cwd, version) string returns
the newly-created session file path, ready for
OpenSession.
- firstUserPrompt() + slugify() build descriptive
"20260420-080305-3f268850-say-hello-in-one-sentence.zotsession"
filenames when exporting into a directory.
core/session_portable_test.go (new)
- Full round trip: write → export → import into a
different cwd → OpenSession → message payloads match.
- Verifies the exported meta drops the original cwd.
- Verifies the .zotsession extension is appended when
missing from dst.
modes/session_ops_dialog.go (new)
- Tiny picker matching the telegramDialog / logoutDialog
shape: arrow keys, enter, esc. Two rows (export / import)
with muted hint text.
modes/interactive.go
- sessionOpsDialog field + constructor + key dispatch +
render selector, identical boilerplate to the other small
dialogs.
- openSessionOpsDialog, doSessionOp, doSessionExport,
doSessionImport. Export uses CurrentSessionPath (new
config hook); import calls core.ImportSession then routes
through the existing LoadSession so the agent switches to
the new file.
- defaultExportDir (~/Downloads → ~ → /tmp fallback),
expandTilde, friendlyPath helpers.
cli.go
- CurrentSessionPath: sess.Path getter wired into the
interactive config.
slash_suggest.go + README
- /session listed in the slash catalog and the README
commands table, with a short description of the two
direct forms.
Not wired into the session_dialog.go picker (which stays
resume-only); a later change could add "export this one"
directly from the picker rows if that's useful.
runPrintMode and runJSONMode never constructed the extension
manager, so --ext and installed extensions were silently ignored in
non-interactive flows. Only the interactive TUI and rpc mode were
loading them. The symptom: 'zot -e ~/path/to/weather -p "..."'
would spawn nothing, no log, and the model had no weather tool.
Added shared helpers used by both print and json:
- setupNonInteractiveExtensions: same --ext + Discover sequence
as interactive, plus the session_start event and MergeExtensionTools.
- wireNonInteractiveAgentExtHooks: same BeforeToolExecute /
BeforeTurn / BeforeAssistantMessage / OnEvent plumbing so guard
extensions, event interceptors, and extension-contributed tools
work identically in one-shot runs.
- nonInteractiveExtHooks: minimal HostHooks impl. Notify goes to
stderr so extensions can still log; Submit / Insert / Display
are no-ops because there's no TUI to steer.
Verified end-to-end:
zot -e ~/Developer/zot/examples/extensions/weather \
-p 'use the weather tool for Berlin'
-> 'Berlin: 16°C, fog. (deterministic demo)'
Before the fix, the same command silently fell back to bash/curl
suggestions because no tool was ever registered.
Previously --no-yolo in -p / --json / rpc modes auto-refused every
tool call. That made the flag dangerous to pass to scripts: a
single --no-yolo in a shell config or wrapper script would silently
break any tool-using prompt.
New behaviour:
- Default: every mode is yolo (tools run freely, no prompts).
- --no-yolo + interactive TUI: confirm dialog before each tool.
- --no-yolo + -p / --json / rpc: stderr warning and ignore the
flag. Tools run freely; scripts keep working.
The TUI confirm dialog and /yolo runtime toggle still work as
before. Also removed the unused wireNoYoloAutoRefuse helper and
simplified core.NewConfirmGate's doc comment.
Adds a per-tool-call confirmation gate. Default stays yolo mode
(tools run freely, same as today). Pass --no-yolo to require
explicit user approval before each tool invocation.
Interactive TUI:
A dialog appears before every tool call. Shows the tool name and
a one-line preview of its args (command / path / url / etc.)
with four choices, selectable by arrow keys or numeric shortcut:
1. yes (run this call)
2. yes, always this tool (skip prompts for this tool,
session-scoped)
3. yes, always (skip prompts for every tool,
session-scoped)
4. no (refuse and let the model try
something else)
Esc/ctrl+c refuses the current prompt. Esc during a running turn
both cancels the turn AND drains any pending confirm so the
agent goroutine doesn't deadlock. Multiple pending confirms are
queued and answered one at a time with a count visible in the
header.
Type /yolo to disable the gate for the rest of the session
(equivalent to the "yes, always" choice but without needing a
pending prompt). Any currently-open confirm auto-allows so the
agent keeps moving.
Print / JSON / RPC modes:
No interactive prompt is available, so every tool call is
auto-refused with a reason the model can learn from:
"tool call refused: --no-yolo is active and there is no
interactive prompt in this mode; ask the user what to do
instead". Observed behaviour: the model pivots to asking the
user directly instead of looping on the same tool.
Implementation:
internal/core/confirm.go
- ConfirmDecision, Confirmer interface
- ConfirmGate with session-scoped memory for "always this tool"
and "always everything" decisions, both concurrency-safe
- BuildPreview: turns {"command":"ls"} into "ls", etc.
- Lives in core to avoid a modes -> agent import cycle
internal/core/confirm_test.go
- Tests: nil gate allows, nil-inner refuses with reason, one-
shot allow doesn't remember, remember-tool short-circuits
only same tool, remember-all short-circuits everything,
refusal reasons surface, empty-reason gets a default,
runtime AllowAll works, BuildPreview handles each field
internal/agent/modes/confirm_dialog.go
- Queue-based dialog, HandleKey wiring, CancelAll and
AllowAllPending for the two exit cases
internal/agent/modes/interactive.go
- InteractiveConfig gains NoYolo + ConfirmGate fields
- Interactive implements core.Confirmer via a response channel
- Confirm dialog dispatched FIRST in the key-handler chain so
keys never leak to other dialogs while the agent is blocked
- Esc-while-busy also calls confirmDialog.CancelAll so the
agent unblocks
- /yolo slash command handled in runSlash
internal/agent/cli.go
- Constructs the ConfirmGate when args.NoYolo is set,
BeforeToolExecute calls it first, extensions only see calls
the user already approved
- After iv is built, SetConfirmer(iv) wires the gate's inner
so interactive + gate share the same struct
- wireNoYoloAutoRefuse() for print / json modes
internal/agent/args.go
- --no-yolo flag and help text
internal/agent/modes/slash_suggest.go
- /yolo added to slashCatalog
Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now
returns "I can't read files in this mode (--no-yolo without an
interactive prompt). How would you like to proceed" instead of
actually reading.
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".
The first time a user launches a newer zot binary, the tui pops
a dismissible overlay with the release notes for that version.
Press any key to close; the version goes into config.json's
last_changelog_shown so the same notes never reappear.
Lifecycle:
- dev builds (version "" / "dev" / "0.0.0"): no fetch ever
- first-ever launch (no LastChangelogShown stored): seed it
silently with the current version so fresh installs don't
get release notes dumped at them
- subsequent launches with the same version: skipped (config
already records that version was shown)
- launch with a different version: fetch the release page from
https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver>
and open the dialog if the body is non-empty
- dismiss writes LastChangelogShown so it never repeats
Components:
- internal/agent/changelog.go: FetchChangelog/Async, and the
Should/Mark/Seed helpers around config.LastChangelogShown.
Honours $GITHUB_TOKEN exactly like the install scripts and
the existing update check, so private-repo fetches work
with auth.
- internal/agent/modes/changelog_dialog.go: the overlay.
Markdown body via the existing RenderMarkdown pipeline,
scrollable with up/down/pgup/pgdn, any other key dismisses.
- internal/agent/modes/interactive.go: new ChangelogChan and
OnChangelogDismiss config fields, single-shot select case
in Run() that opens the dialog when a payload arrives.
- internal/agent/cli.go: spawns the fetch goroutine, gates it
on ShouldShowChangelog, wires OnChangelogDismiss to
MarkChangelogShown so the version is persisted.
Best-effort: timeouts at 4s, missing tag => silent skip, network
failure => silent skip + retry on next launch (no
LastChangelogShown update if we never showed anything).
Documented in the README under the SYSTEM.md note.
Behaviour change: a fresh zot run now loads only the built-in
skills compiled into the binary. User-installed SKILL.md files
under $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, or
.agents/skills/ stay dormant until the user explicitly opts in
with --with-skills.
Three discrete modes:
zot built-ins only (default)
zot --with-skills built-ins + user skills
zot --no-skill nothing (no skill tool, no manifest)
Rationale: a fresh install should have a deterministic skill
set, regardless of what's already lying around in $ZOT_HOME from
old experiments. Built-ins ship with the binary so they're
auditable; user skills are loaded only when the user explicitly
asks for them.
Discover() gained an includeUser bool (was 3 args, now 4). The
in-tree caller updated; the test that exercised the old signature
gets includeUser=true so its existing assertions still hold.
scanUserSkills() split out so the includeUser=false path is a
cheap no-op.
End-to-end verified live:
default -> write-zot-extension (only)
--with-skills -> write-zot-extension + code-review + test-fix
--no-skill -> no skill tool, no manifest at all
Mirrors --no-ext: one flag that skips skill discovery + the
`skill` tool registration entirely for one run. Useful for
clean-room runs where you want zero extra context biasing the
model.
Defaults are unchanged:
- user-installed skills under $ZOT_HOME/skills/, .zot/skills/,
.claude/skills/, .agents/skills/ load normally
- built-in skills compiled into the binary (currently the
write-zot-extension authoring guide) load normally
- both groups appear in the system-prompt manifest and are
loadable via the `skill` tool
With --no-skill (or --no-skills):
- skills.Discover() is not called
- the `skill` tool is not registered
- no "Available skills" addendum is appended to the system
prompt
- /skills picker is empty
Wired into both interactive and rpc modes via the existing
SkillSnapshot wiring (returns nil when args.NoSkill is set, so
the picker also stays empty).
End-to-end verified live:
default : tool list includes "skill"
--no-skill : tool list is read, write, edit, bash only
(no skill, no extra context manifest)
The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.
Built-in skills:
- shipped via //go:embed at internal/skills/builtin/
- merged into Discover()'s output AFTER user skills, so a
user-installed skill with the same name shadows the built-in
(drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
to customise)
- marked Builtin: true on the Skill struct
- hidden from user-facing surfaces: VisibleSkills() filters them
so /skills only shows skills the user actually installed or
shipped in their project
The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.
Verified live:
prompt: "List the names of the skills you have available"
-> code-review, test-fix, write-zot-extension
prompt: "I want to write a zot extension that adds a slash
command /pwd which inserts the current directory path
into the editor. What language should I use, and what
files do I need to create?"
-> [tool_call] skill({"name":"write-zot-extension"})
-> body returned
-> the model produces a complete extension with the right
manifest, the right hello/register/ready frames, action:
insert correctly chosen, and a remark about cwd capture.
The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.
Useful for clean-room runs ("does this prompt work without my
extensions interfering?") and for reproducing issues without
auto-loaded guardrails.
zot --no-ext # zero extensions
zot --no-ext --ext ./x # only x; nothing else gets discovered
Explicit --ext paths still load on top, so --no-ext is "skip the
implicit scan", not "block all extensions". Lets you run with
exactly one extension without uninstalling the rest.
Wired into both interactive (cli.go) and rpc (rpc.go) modes.
Help text updated.
The "── extensions ────" divider in the slash autocomplete
already hides itself when no extension commands are registered
(empty s.extra short-circuits allCatalog before the header
insertion path), so --no-ext naturally produces a clean popup
with no orphan rule. pruneOrphanHeaders also drops the rule
when filter input narrows the extension group to zero matches.
End-to-end verified live:
default : agent sees weather + read_notes
--no-ext : agent sees only the 5 built-ins
--no-ext --ext scratchpad: agent sees built-ins + read_notes
Loads an extension from any directory for one zot session without
needing to copy / install it under $ZOT_HOME. Repeatable. Resolved
to absolute before spawn so paths like "." survive a later cwd
change. Loaded BEFORE the installed-extension scan so explicit
paths win on name conflicts, letting you shadow an installed copy
with a work-in-progress version.
zot --ext ./my-extension # one extension
zot -e ./a -e ./b # multiple
zot --ext . # the cwd is itself an extension
Manager.LoadExplicit is the public entry point. Spawns happen in
parallel like the regular Discover path, with per-path errors
returned so a typo in one --ext doesn't break the others.
Wired into both interactive (cli.go) and rpc (rpc.go) modes.
Help text + docs/extensions.md updated.
Verified end-to-end: disabling the installed scratchpad,
running `zot rpc --ext .` from its directory, and asking the
model to list its tools shows read_notes available again.
examples/extensions/scratchpad: real .ts (not .js) extension, no
build step, no SDK. Runs via `npx --yes tsx index.ts` so authors
can use TypeScript without forcing a global install. Demonstrates:
/note <text> slash command (typed CommandResponse)
/notes slash command (display action)
/clear-notes slash command
read_notes LLM-callable tool (typed ToolResult)
Plus a typed wire-format subset inline so the file shows what the
protocol actually looks like from the consumer side. Pure node +
tsx, zero npm deps beyond tsx itself (~5 MB cached on first call).
Manager fix: extension exec paths are now resolved by shape:
absolute used as-is
starts with ./ or ../ joined to ext.Dir
contains a separator joined to ext.Dir (other relative form)
bare name (no sep) left as-is so $PATH lookup works
Before this, "exec": "npx" was being looked up at
extensions/scratchpad/npx and failing with a "no such file or
directory" error. With the fix, "node", "npx", "python3", "tsx",
etc. resolve via $PATH like users intuitively expect.
Bumped WaitForReady grace from 500ms to 3s so slow runtimes
(npx tsx cold-start ≈ 1.4s) get their register_tool frames
in before the agent's tool registry is built. Extensions that
send ready quickly still release the wait immediately; the
extra grace only applies to laggards.
Verified end-to-end live against anthropic:
prompt: "Use the read_notes tool now and tell me what's in the
scratchpad"
-> [tool_call] read_notes({})
-> [tool_result] (scratchpad is empty)
-> "The scratchpad is empty."
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.
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.
A skill is a single SKILL.md file with a YAML frontmatter header,
discovered from well-known directories at startup. Two integration
points:
1. The system prompt gains a short manifest listing each skill's
name + one-line description. Cheap (a few dozen tokens).
2. A built-in `skill` tool lets the model load any one skill's
full body on demand and follow the instructions there.
The on-demand-load model keeps token usage cheap: only the
manifest goes into every request; the body is fetched as a tool
result the one or two turns the model actually needs it.
Discovery (priority order — first match wins per name):
./.zot/skills/<name>/SKILL.md project (native)
$ZOT_HOME/skills/<name>/SKILL.md global (native)
./.claude/skills/<name>/SKILL.md project (claude-compat)
~/.claude/skills/<name>/SKILL.md global (claude-compat)
./.agents/skills/<name>/SKILL.md project (agent-compat)
~/.agents/skills/<name>/SKILL.md global (agent-compat)
Compat paths are deliberate: any SKILL.md written for a related
ecosystem works in zot unchanged.
Frontmatter fields:
name optional; defaults to directory name
description required; shown in the system prompt
allowed-tools optional list; informational (no enforcement)
permissions optional per-tool patterns; informational
allowed-tools and permissions are parsed but not enforced this
version. They render in the body so the model can self-regulate.
What landed:
- internal/skills: discovery + frontmatter parsing (no yaml dep —
hand-rolled subset for the limited shape skills use), the on-
demand `skill` tool implementing core.Tool, system-prompt
addendum, FindByName lookup helper. Real unit tests cover all
five locations + dedup priority + parser corner cases.
- internal/agent/build.go: Resolve discovers skills, registers the
skill tool when at least one was found, appends the manifest to
the system prompt's append list. Resolved gains a SkillTool
field so the tui can read the live set.
- internal/agent/modes/skills_dialog.go: /skills picker with two
modes — list view (cursor + paging) and body view (markdown-
rendered with scroll). Refreshes its snapshot each open via
cfg.SkillSnapshot so edits to a SKILL.md during a session are
reflected immediately.
- /skills slash command + entry in slashCatalog.
- examples/skills/code-review and examples/skills/test-fix as
starter skills demonstrating procedural style + frontmatter.
- docs/skills.md: full reference covering discovery, frontmatter,
inspection, authoring tips, and ecosystem compat.
End-to-end verified against the live anthropic backend:
prompt: "What skills do you have available?"
-> "- code-review\n- test-fix"
prompt: "Use the skill tool to load the code-review skill,
then summarize step 1."
-> [tool_call] skill({"name":"code-review"})
-> [tool_result] body returned
-> "Step 1 is to establish what changed by running git status..."
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.
two new ways to embed the zot agent runtime in third-party apps:
1. pkg/zotcore - public Go SDK
- Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event,
Cancel, Compact, SetModel, State, Messages, Cost, ListModels,
Close. Concurrent-safe; one prompt at a time per Runtime,
ErrBusy if you try to overlap. Spawn multiple Runtimes for
multiple projects.
- Public types mirror the JSON-RPC wire schema 1:1 so consumers
can share parsing code with the out-of-process clients.
- Internal core/agent/provider stay internal; SDK is a thin
facade that exposes only what's stable.
2. zot rpc subcommand - newline-delimited JSON on stdin/stdout
- 'zot rpc' (or 'zot --rpc') turns the agent runtime into a
subprocess that any language can drive via pipes.
- Commands: hello, prompt, abort, compact, get_state,
get_messages, clear, set_model, get_models, ping. Each
optionally carries an id; the matching response echoes it.
- Stream notifications: turn_start, user_message,
assistant_start, text_delta, tool_call, tool_progress,
tool_result, assistant_message, usage, turn_end, done,
error, compact_done. Same shape as the existing --json mode
events (modes.EventToJSON / ContentToJSON were exported
for reuse).
- Auth: optional ZOTCORE_RPC_TOKEN env var; first command
must be hello {token: ...} when set. Without the env var
the spawning process is implicitly trusted.
- Concurrency: one prompt or compact at a time per process,
enforced by a turnMu mutex. abort fires immediately
regardless. Stdin close exits the process.
3. docs/rpc.md - full schema reference
4. examples/rpc/{python,node,shell,go} - reference clients
5. examples/sdk - in-process Go embedding example
6. README updated with a new modes entry and an embedding section
every zot launch was creating a session file with just a meta line,
even when the user exited without prompting. /sessions and ls -la
ended up showing dozens of empty entries.
now Session tracks messagesAppended and freshFile (true for
NewSession, false for OpenSession). Close() removes the file when
both conditions hold: this process created it AND no messages
were ever appended. resumed sessions are never auto-deleted even
if the resume run added nothing, since the prior content is real.
PruneEmptySessions sweeps existing meta-only stubs from the cwd's
session dir on each interactive launch. cheap (only reads enough
of each file to find a 'message' line) and fixes the existing
backlog automatically the first time you reopen zot in a project.
- internal/agent/update.go: check github releases api for a newer
tag than the compiled-in version, cached in $ZOT_HOME/update-check.json
with a 12h ttl so startup never hits the network twice. honours
$GITHUB_TOKEN for the window while the repo is private; falls back
to silent no-op on any failure. skipped entirely on dev builds
(version = 0.0.0 or dev)
- internal/agent/modes/update_banner.go: yellow-framed block with
the new version, the current version, the one-liner install
command appropriate for the platform, and a link to the release
page. rendered above the welcome / transcript so it's the first
thing the user sees
- wired through via InteractiveConfig.UpdateInfoChan to avoid an
import cycle (modes -> agent). cli.go kicks off the check async
and feeds the result in
note: no 'dismiss' key yet \u2014 the banner stays until you update to
the shown version. if the nagging gets annoying we can add a
per-version dismiss cache later.
- rewrite resize_unix.go on top of golang.org/x/sys/unix so the
peek-stdin helper compiles on linux (Select returns (int, error),
Timeval.Usec is int64) as well as darwin (int32, error-only)
- promote golang.org/x/sys to a direct dep
- gofmt -w . (11 files of alignment drift from recent edits)
- install.sh / install.ps1: accept $GITHUB_TOKEN so the installers
work against the repo while it's private; no-op on public repos
- README: document the private-repo install paths (PAT for curl|bash
and powershell, GOPRIVATE for go install)