CI's `go test -race` flagged two races introduced over the recent
extension work:
1. ext.commands / ext.tools were appended to from the read-loop
goroutine without a lock, while Commands() / Tools() / HasCommand
read them under m.mu. Same for the toolIndex pre-check. Fix:
take m.mu around the appends and the index dedup so writers and
readers serialise on the same lock.
2. assumeReadyAfterIdle read ext.lastFrameTime once on entry without
the per-extension lock (the read inside the loop already had it).
Fix: take ext.mu for the initial snapshot too.
Verified locally with `go test -race ./...`; all packages pass.
The corresponding CI run for the scratchpad-persistence commit
failed for exactly these two races on linux + macos.
Two-line addition to docs/extensions.md and a tightening of the
README bullet point. examples/extensions/* are reference code; a
fresh `zot install` gives you a clean agent. Users opt in by
copying examples (or any other extension) via `zot ext install`
or by pointing `zot --ext PATH` at a working directory for one
session.
No code changes.
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.
Notes are now project-local and survive zot restarts. The
extension reads its cwd from the hello_ack handshake, then:
- on /note appends one line of {"at":..,"text":..} JSONL
under <cwd>/.zot/scratchpad-notes.jsonl
- on /notes reads the in-memory cache (loaded on hello_ack)
- on /clear-notes truncates the file and clears the cache
- on read_notes (tool) returns the cached set
Single-writer assumption (one zot session per cwd at a time);
two concurrent zot processes writing to the same file would
interleave but JSONL line boundaries stay intact under POSIX
PIPE_BUF semantics. Good enough for an example.
Verified end-to-end: round 1 writes two notes via the slash
command, round 2 (fresh extension process, same cwd) loads
them and surfaces them via both /notes and the read_notes
tool.
Three independent fixes to startup latency:
1) Discover spawns extensions in parallel.
Before: each spawn synchronously waited on its child's hello
frame; multiple slow runtimes (e.g. tsx) added linearly.
After: every loadOne runs on its own goroutine; total time
collapses to max(spawn_time) instead of sum.
2) WaitForReady waits in parallel.
Before: one extension at a time, so a slow ready (or no ready
at all from a legacy SDK) blocked every other extension's wait
too.
After: one goroutine per extension, all sharing a single
deadline; total = max(per-ext wait), not sum.
3) Auto-ready idle watchdog for legacy extensions.
Phase-1 SDK builds didn't send the ready sentinel introduced
in phase 2. Without it, WaitForReady burned the full 3s grace
on every startup for every legacy extension. Fix: read loop
stamps lastFrameTime on each frame; a per-extension watchdog
closes readyCh as soon as no new frame has arrived for 250ms.
Native binaries register + go quiet within microseconds, so
this fires almost immediately. Newer extensions still trip
the explicit ready path before the watchdog matters.
Also updates the scratchpad example to invoke `tsx` directly
instead of `npx --yes tsx`, with the README explaining how to
install tsx globally and how to fall back to npx (and what it
costs in startup time).
Measured impact on a machine with 4 extensions installed
(guard / hello / weather / scratchpad):
before: 4.2-4.9s per zot launch
after: ~200ms per zot launch (cold-cache first run ~780ms)
The dominant remaining cost in the 200ms is normal node + tsx
boot for scratchpad, which only matters because it's still in
the spawn fan-out — Go extensions add nothing measurable.
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..."
extension commands appeared in the autocomplete popup but invoking
them produced "unknown command: /summon". The submit-handler path
already tried the extension manager before erroring, but the popup-
enter path (suggest.Selection -> runSlash) bypassed that check and
fell straight into runSlash's switch, where the default case bailed
with the generic error.
Fix: runSlash's default branch now also consults
cfg.Extensions.HasCommand and dispatches via invokeExtensionCommand
when matched. Both UI paths (typed-and-enter, popup-enter) now route
identically. Built-in cases above default still always win on
conflict.
Also adds examples/extensions/clock — a node extension demonstrating
the wire protocol from a non-Go runtime. Pure stdlib (readline +
process), no npm install. Registers /now (display) and /uptime
(prompt). Documented in its README; the protocol works the same
from 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.
frame header pad length used len(label) which counts bytes, not
on-screen cells. titles containing em-dashes, box-drawing or any
multibyte rune left a few extra bytes of "imaginary length" so
the trailing ── run came up short of the right edge -- visible on
the /btw header which starts with "btw — side chat ...". swap to
runewidth.StringWidth for both frameHeader and frameHeaderColor.
also lands the /btw side-chat overlay this commit pre-staged:
opens via /btw [optional question], snapshots system + transcript
+ provider client at open time, fires one-off model calls against
that frozen context plus the side-chat history, renders replies
through the markdown pipeline. esc cancels in-flight calls or
closes when idle. nothing is appended to the main transcript or
the session jsonl. spinner reuses the main funny-line rotation.
- welcomeVersionDuration is now 1500ms (was 1s). still ephemeral
but gives a touch more time to read the splash version on
faster terminals.
- ctrl+c "input cleared - ctrl+c again to exit" is now just
"input cleared". the second-press behaviour is still wired and
documented in /help; the long suffix was visual noise on a
status line that already changes shape with each keystroke.
splash variant of the welcome line:
▌ i'm zot (0.0.6). yet another coding agent harness.
reverts to:
▌ i'm zot. yet another coding agent harness.
after welcomeVersionDuration (1s). a one-shot AfterFunc fires
i.invalidate() so the banner refreshes on its own even if the
user has not typed anything by then.
dev builds without ldflags-injected version still print "(0.0.0)"
during the splash; release tags propagate through the makefile
into main.version normally.
bundles a small printhelp tagline tweak that was already staged.
each section had its own label column width (10 vs 14) so the
descriptions formed two staircases at different x-positions.
compute one shared width from the longest label across BOTH
lists (with a 14-cell minimum so future entries don't compress
the column visually) and pad every label to it. now every
description starts at the same column.
status bar:
every horizontal gap is now exactly 2 spaces, applied uniformly
via a single pad constant. before:
1 leading | 6 between model+stats | 4 between stats+cwd
after:
2 leading | 2 between model+stats | 2 between stats+cwd
produces e.g. " (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot"
matching the editor's left inset so the bar lines up vertically
with the conversation column.
assistant body:
user-role messages render their text with a 4-space indent under
the "you" header. assistant text was rendering flush left, so the
conversation column visibly broke at every assistant turn. now
both renderMessage's RoleAssistant branch and Build's streaming
path:
- reduce the wrap width by the indent (4 cells)
- prefix every produced line with the indent
applies to plain text, markdown-rendered code fences / lists /
blockquotes, and tool-call summary lines (>>= name args). tool
result blocks were already indented and stay unchanged.
dragging a file onto the terminal pastes its path via bracketed
paste. before this, agents would receive raw paths with literal
spaces and parens that shells/tools then misinterpreted (e.g.
"/Users/pat/foo bar.png" parsed as two arguments).
the editor's KeyPaste handler now runs pastes through
quotePastedFilePaths which:
- inspects whitespace-separated tokens of single-line pastes
- normalises file:// urls (decode + scheme strip), backslash
space escapes (the macOS Terminal default for drag-drop), and
pre-existing single/double-quoted forms
- wraps tokens that look like paths (start with / or ~ and
contain a separator, no shell metacharacters) in single
quotes, splicing 'foo'\''bar' for embedded apostrophes
- leaves prose, multi-line code pastes, and anything with
metachars untouched
regression suite covers the 12 cases that surfaced while
implementing: backslash-escaped spaces, file:// urls, tilde
paths, multi-file drops, pre-quoted paths, embedded apostrophes,
plain prose, multiline pastes, metachar smuggling attempts, and
path-mixed-with-prose. all pass.
verified the build, vet, gofmt, and go test pipeline on darwin,
linux and windows targets.
applyModelSelection's cross-provider branch built a fresh agent via
BuildAgentFor and assigned it to i.agent without copying anything
from the old one, so the user perceived /model anthropic->openai (or
vice versa) as a hard reset of the entire conversation. same-provider
swaps already worked because they only mutated agent.Model in place.
now the cross-provider path snapshots agent.Messages() and agent.Cost()
before constructing the replacement, then SetMessages + SeedCost on the
new agent so the transcript and the cumulative dollar meter both carry
across.
added core.Agent.SeedCost(provider.Usage) for the cost transfer.
messages already round-trip cleanly because tool names are normalised
to lowercase in the in-memory provider.Message representation; the
oauth-only Read/Write/Edit/Bash rename happens on the wire at request
build time, not in the transcript.
verified end-to-end via zot rpc:
turn 1 (opus): "remember teal" -> "ok"
set_model: opus -> sonnet
turn 2 (sonnet): "what color did i say?" -> "teal"
both same-provider (opus<->sonnet) and cross-provider (anthropic<->openai)
paths exercised.
oauth requests now exceed anthropic's 4-breakpoint cache_control
limit when the conversation has 2+ user messages. previous layout
emitted 5 markers: identity + system + tools + 2 user messages.
drop the marker on the small claude-code identity line. it's a few
tokens and gets folded into the cached prefix implicitly when the
request matches turn-over-turn anyway. budget now: system + tools +
last 2 user messages = 4. fits.
reproduces the user-reported error:
anthropic: http 400 ... A maximum of 4 blocks with cache_control
may be provided. Found 5.
verified by sending two consecutive prompts through zot rpc on an
oauth credential -- first turn returns the assistant message
cleanly, second turn does too instead of 400ing.
three real issues found by actually running zot rpc end-to-end:
1. piping a single command into the process and letting stdin close
would race the agent loop against the process exit, swallowing
the entire prompt response. fix: track in-flight prompt and
compact goroutines in a sync.WaitGroup; run() now waits on it
before returning.
2. each prompt emitted two consecutive done frames - one from the
agent loop EvDone passing through EventToJSON, one added at the
end of runPrompt. suppress EvDone in the prompt sink so only the
explicit terminator remains.
3. cancelling a turn produced a spurious error frame on top of the
turn_end stop=aborted that already carried the cancellation.
suppress error frames when the underlying error is
context.Canceled, in both prompt and compact paths.
verified manually: ping, get_state, get_models, set_model
(valid + invalid id), clear + get_messages, abort, malformed json,
unknown command, auth gate (missing/wrong/correct token),
stdin-close-while-prompt-running, in-process SDK Prompt, plus all
four reference clients parse cleanly and shell + python actually
drive the protocol.
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
a single-cell 10-frame braille rotation at 80ms per step. matches
the well-known dots3 preset from sindresorhus/cli-spinners (MIT).
small, recognisable, occupies one terminal cell so the busyPrefix
in the status bar stays compact regardless of message length.
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.
opening a session via /sessions (or starting with --continue /
--resume / --session) used to drop the user at the live tail
(scrollOffset = 0). on a long session that means the visible
viewport showed only the last few rows of the final assistant
reply -- the rest of the conversation was loaded into the agent
correctly but invisible without scrolling. users read this as
'resume only restored a one-liner'.
now after both code paths we compute the row offset of the last
user message and park the viewport so that user prompt sits at
the top of the chat area, with the assistant's last reply right
below. older history is one scrollup away; pgdn or arrows snap
to the live tail. the existing 'viewing turn N of M' footer
shows up automatically since parkedTurn / parkedTotal are set.
shared scrollToLastTurn helper used by both the /sessions picker
(applySessionSelection) and Run() startup. the old applySession
code is now a thin wrapper that just invalidates caches and
delegates to scrollToLastTurn.
two bugs in the redraw throttle were eating the post-turn frame:
1. requestRedraw, when it could redraw immediately (since >=
redrawMinInterval), didn't reset pendingRedraw or stop the
pendingTimer. so subsequent invalidates within redrawMinInterval
saw pendingRedraw == true and were dropped, leaving the ui
stale until a key event or scroll triggered a fresh path.
2. the tick branch only called drainPending while busy or with an
open dialog. once a turn finished and i.busy went false the
tick stopped clearing the pending flag, so any redraw that
ended up scheduled (because the dirty channel was saturated
under streaming load) had no second chance to fire.
fix:
- requestRedraw now stops the timer and clears pendingRedraw
whenever it does an immediate redraw. throttle state stays
consistent.
- tick branch always drains pending, busy or not. requestRedraw
on top is still gated on busy/dialog so the spinner keeps
animating without forcing a redraw on every tick when idle.
reproduction: long turn finishes -> spinner stops, but the final
assistant text only appears when the user scrolls or types a key.
fixed by both changes; either one alone leaves a small race window.
previously typing any slash command during a turn was rejected with
"cancel the current turn (esc) before running a slash command".
annoying and unnecessary for read-only commands, and the
destructive ones can cancel the turn for you.
read-only commands run immediately, parallel to the streaming
turn: /help, /jump, /sessions, /lock, /unlock, /exit.
destructive commands trigger cancelAndWaitForIdle first (cancels
the turn ctx, polls i.busy at 10ms intervals, gives up after 2s
as a safety cap so a wedged http stream cannot freeze the ui
forever): /clear, /compact, /login, /logout, /model. once the
turn goroutine has wound down they run on the now-quiet agent.
slashCancelsTurn(head) in slash_suggest.go is the single source
of truth for which commands need the wait. readme updated to
match the new behaviour.
/jump:
- new slash command; opens a picker listing every user turn in
the current session (timestamp relative, tool count badge, first
line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put
that turn's user-message header at the top row. non-destructive,
transcript untouched
- runes extend a live filter; backspace shortens. '/jump <text>'
pre-applies the filter; exactly-one-match auto-jumps without
showing the picker
- while parked on a past turn the scroll-up note reads 'viewing
turn N of M \u00b7 pgdn to catch up' instead of the generic row
count. scrolling back to the tail (or starting a new turn, or
/clear) resets the parked state automatically
- view.go: new MessageAnchor type + BuildWithAnchors so the dialog
can resolve msgIdx -> first rendered row
perf for long transcripts (the whole ui stutters on ~50 messages):
- view.renderCache: per-message memoisation keyed by (fnv1a of
role+content, width, expandAll). finalised messages never change
so the cache hit rate is ~100% after the first render. streaming
partials and in-flight tool-call views stay uncached by design
- BuildWithAnchors now pre-sums line counts and allocates
in a single make() instead of 50 appends with log2(N) backing-
array memcpys
- truncateToWidth fast path: byte-length <= cols implies cell-width
<= cols, so we skip the rune-width loop entirely. covers the huge
majority of lines in a session
- cache purged on /clear, /compact completion, and session swap
(applySessionSelection); resize invalidates implicitly via the
width key. LRU eviction at 4x message count caps memory
impact: a 50-msg / 2000-line transcript went from unresponsive-
while-typing to drawing in well under a frame. measured locally
with go-perf traces; no change to correctness.
the repo will be public by the time anyone reads this, so the
GITHUB_TOKEN / GOPRIVATE scaffolding just adds noise. installers
and 'go install' were already coded to work unauthenticated on
public repos; no code changes needed.
- 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.
the up/down handlers required !i.busy before routing to scrollBy,
which meant you couldn't scroll back through a long streaming reply
while it was still arriving. dropped the busy check \u2014 chat scroll
now works in both states, consistent with pgup/pgdn which never
had the restriction.
the help block was prepended to the chat, which pushed any existing
conversation off the top of the viewport on anything but the
shortest sessions. appending it (with scrollOffset=0 so the viewport
sticks to the bottom) means /help is always visible right above the
editor, exactly where the user's eye is already looking.
login / model / sessions dialogs already render in the bottom-sticky
band between chat and editor, so they weren't affected.
the previous guard string kept matching because i literally mentioned
it in my own commit messages when documenting the feature. switched
to a form with an equals sign that will never appear in ordinary
english.
- debug job served its purpose (proved workflow_run fires correctly)
and is gone
- restore the [skip-release] guard
- brews.skip_upload is now a go-template that evaluates to true when
HOMEBREW_TAP_TOKEN is empty, so tag pushes before the tap is
created don't fail the whole release (v0.0.1 cut fine but the
goreleaser exit code was 1 because the brew step 401'd)
the release job kept getting skipped even though the if-expression
should have evaluated true. adding a diagnostic job (no if-filter)
that prints github.event.workflow_run so we can see what fields
are actually populated on the webhook payload at eval time.
removing the [skip-release] guard temporarily to reduce variables.
the previous [skip release] (space) matched my own commit message
that literally described the feature, which caused the first green
ci run to skip the release instead of cutting v0.0.1. hyphenated
form is much less likely to show up in ordinary prose.
also switch the job-level if from folded-scalar yaml to an inline
${{ }} expression for robustness.
- release.yml now triggers on workflow_run of ci (completed+success),
not on bare push, so we never publish binaries of broken code
- checks out github.event.workflow_run.head_sha, computes the next
vX.Y.Z by bumping the patch of the newest existing tag (starts at
v0.0.1), pushes the tag, runs goreleaser
- supports [skip release] in the commit message for docs/ci-only
commits that shouldn't cut a version
OpenSession returns a Session whose writer holds an append handle to
the jsonl file. The test never closed it, so t.TempDir's cleanup hit
'The process cannot access the file because it is being used by
another process' on windows (posix happily deletes open files).
Register a t.Cleanup that closes the reopened session.
syscall.SysProcAttr.Setsid is posix-only — unknown field on windows.
Extracted the detach-on-start logic into a detachChild function
variable, implemented in botcmd_unix.go (Setsid) and botcmd_windows.go
(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP creation flags).
- 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)