Adds the todo panel example under examples/extensions, updates example manifests and READMEs to match the current extension API, and surfaces extension install/load commands in zot --help.
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."