feat(extensions): typescript example + path-aware exec resolution
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."- commit
- 5dbbcb9040f13e965d23b9334de059d276c62e7d
- author
- patriceckhart · · 2026-04-19 13:06 UTC
- parent
- 83ae236
- signature
- unsigned
- refs
- tag: v0.0.25
6 files · +377 -3
A examples/extensions/scratchpad/README.md +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | # scratchpad — TypeScript extension example | |
| 2 | + | ||
| 3 | + | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx tsx`, | |
| 4 | + | which downloads itself into the npm cache on first invocation and | |
| 5 | + | runs from cache on every subsequent call. | |
| 6 | + | ||
| 7 | + | Demonstrates: | |
| 8 | + | ||
| 9 | + | - registering slash commands (`/note`, `/notes`, `/clear-notes`) | |
| 10 | + | - registering an LLM-callable tool (`read_notes`) | |
| 11 | + | - the wire protocol from a typed TypeScript perspective | |
| 12 | + | - running TypeScript via `npx tsx` from `extension.json` | |
| 13 | + | ||
| 14 | + | ## Requirements | |
| 15 | + | ||
| 16 | + | Node 18+. The first time you use it, `npx` will quietly fetch `tsx` | |
| 17 | + | into the npm cache (~5 MB, one-shot). If you'd rather pre-install | |
| 18 | + | globally: | |
| 19 | + | ||
| 20 | + | ```bash | |
| 21 | + | npm install -g tsx | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | …and change `extension.json`'s `exec` to `"tsx"` and `args` to | |
| 25 | + | `["index.ts"]` if you want. | |
| 26 | + | ||
| 27 | + | ## Install | |
| 28 | + | ||
| 29 | + | From this directory: | |
| 30 | + | ||
| 31 | + | ```bash | |
| 32 | + | zot ext install . | |
| 33 | + | ``` | |
| 34 | + | ||
| 35 | + | ## Use | |
| 36 | + | ||
| 37 | + | In zot: | |
| 38 | + | ||
| 39 | + | - `/note remind me to update the changelog` — appends to the scratchpad | |
| 40 | + | - `/notes` — shows everything stored | |
| 41 | + | - `/clear-notes` — wipes the scratchpad | |
| 42 | + | ||
| 43 | + | The model also has a `read_notes` tool. Ask it: | |
| 44 | + | ||
| 45 | + | > "What did I tell you to remember?" | |
| 46 | + | ||
| 47 | + | …and it will call the tool and tell you. The scratchpad is | |
| 48 | + | process-local: it lives only as long as the extension subprocess | |
| 49 | + | (i.e. the duration of one `zot` session). | |
| 50 | + | ||
| 51 | + | ## Why TypeScript here | |
| 52 | + | ||
| 53 | + | The extension protocol is small enough that you can hand-write it in | |
| 54 | + | any language. JS works fine; TS adds type safety on the frame shapes | |
| 55 | + | without any infrastructure beyond `tsx`. If you want richer ergonomics | |
| 56 | + | (decorators, schema-from-types), publish your own SDK on top. | |
| 57 | + | ||
| 58 | + | ## See also | |
| 59 | + | ||
| 60 | + | - `examples/extensions/clock` — JS sibling (no tsx required) | |
| 61 | + | - `examples/extensions/hello` — Go SDK | |
| 62 | + | - `examples/extensions/weather` — Go SDK, exposes one tool | |
| 63 | + | - `examples/extensions/guard` — Go SDK, demonstrates intercepts | |
| 64 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | # scratchpad — TypeScript extension example | |||
| 2 | + | ||||
| 3 | + | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx tsx`, | |||
| 4 | + | which downloads itself into the npm cache on first invocation and | |||
| 5 | + | runs from cache on every subsequent call. | |||
| 6 | + | ||||
| 7 | + | Demonstrates: | |||
| 8 | + | ||||
| 9 | + | - registering slash commands (`/note`, `/notes`, `/clear-notes`) | |||
| 10 | + | - registering an LLM-callable tool (`read_notes`) | |||
| 11 | + | - the wire protocol from a typed TypeScript perspective | |||
| 12 | + | - running TypeScript via `npx tsx` from `extension.json` | |||
| 13 | + | ||||
| 14 | + | ## Requirements | |||
| 15 | + | ||||
| 16 | + | Node 18+. The first time you use it, `npx` will quietly fetch `tsx` | |||
| 17 | + | into the npm cache (~5 MB, one-shot). If you'd rather pre-install | |||
| 18 | + | globally: | |||
| 19 | + | ||||
| 20 | + | ```bash | |||
| 21 | + | npm install -g tsx | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | …and change `extension.json`'s `exec` to `"tsx"` and `args` to | |||
| 25 | + | `["index.ts"]` if you want. | |||
| 26 | + | ||||
| 27 | + | ## Install | |||
| 28 | + | ||||
| 29 | + | From this directory: | |||
| 30 | + | ||||
| 31 | + | ```bash | |||
| 32 | + | zot ext install . | |||
| 33 | + | ``` | |||
| 34 | + | ||||
| 35 | + | ## Use | |||
| 36 | + | ||||
| 37 | + | In zot: | |||
| 38 | + | ||||
| 39 | + | - `/note remind me to update the changelog` — appends to the scratchpad | |||
| 40 | + | - `/notes` — shows everything stored | |||
| 41 | + | - `/clear-notes` — wipes the scratchpad | |||
| 42 | + | ||||
| 43 | + | The model also has a `read_notes` tool. Ask it: | |||
| 44 | + | ||||
| 45 | + | > "What did I tell you to remember?" | |||
| 46 | + | ||||
| 47 | + | …and it will call the tool and tell you. The scratchpad is | |||
| 48 | + | process-local: it lives only as long as the extension subprocess | |||
| 49 | + | (i.e. the duration of one `zot` session). | |||
| 50 | + | ||||
| 51 | + | ## Why TypeScript here | |||
| 52 | + | ||||
| 53 | + | The extension protocol is small enough that you can hand-write it in | |||
| 54 | + | any language. JS works fine; TS adds type safety on the frame shapes | |||
| 55 | + | without any infrastructure beyond `tsx`. If you want richer ergonomics | |||
| 56 | + | (decorators, schema-from-types), publish your own SDK on top. | |||
| 57 | + | ||||
| 58 | + | ## See also | |||
| 59 | + | ||||
| 60 | + | - `examples/extensions/clock` — JS sibling (no tsx required) | |||
| 61 | + | - `examples/extensions/hello` — Go SDK | |||
| 62 | + | - `examples/extensions/weather` — Go SDK, exposes one tool | |||
| 63 | + | - `examples/extensions/guard` — Go SDK, demonstrates intercepts | |||
| 64 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/scratchpad/extension.json +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "scratchpad", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "npx", | |
| 5 | + | "args": ["--yes", "tsx", "index.ts"], | |
| 6 | + | "language": "typescript", | |
| 7 | + | "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", | |
| 8 | + | "enabled": true | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "scratchpad", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "npx", | |||
| 5 | + | "args": ["--yes", "tsx", "index.ts"], | |||
| 6 | + | "language": "typescript", | |||
| 7 | + | "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", | |||
| 8 | + | "enabled": true | |||
| 9 | + | } | |||
A examples/extensions/scratchpad/index.ts +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | // scratchpad — a real .ts zot extension with no SDK and no build step. | |
| 2 | + | // | |
| 3 | + | // Runs via `npx --yes tsx index.ts` (declared in extension.json). | |
| 4 | + | // First invocation downloads tsx into npm's cache; every subsequent | |
| 5 | + | // run is instant. Pure node + tsx, no other dependencies. | |
| 6 | + | // | |
| 7 | + | // What it does: | |
| 8 | + | // | |
| 9 | + | // /note <text> — append <text> to a process-local scratchpad | |
| 10 | + | // /notes — show the scratchpad inline (no model call) | |
| 11 | + | // /clear-notes — wipe the scratchpad | |
| 12 | + | // | |
| 13 | + | // tool: read_notes() — the model can read the scratchpad on demand | |
| 14 | + | // | |
| 15 | + | // Use this as a template for any TypeScript extension that needs to | |
| 16 | + | // register slash commands, expose tools to the model, or both. | |
| 17 | + | ||
| 18 | + | import { createInterface } from "node:readline"; | |
| 19 | + | import { stderr, stdin, stdout } from "node:process"; | |
| 20 | + | ||
| 21 | + | // ---- protocol types (a tiny subset of internal/extproto) ---- | |
| 22 | + | ||
| 23 | + | type Frame = { type: string; id?: string; [k: string]: unknown }; | |
| 24 | + | ||
| 25 | + | interface CommandInvoked { | |
| 26 | + | type: "command_invoked"; | |
| 27 | + | id: string; | |
| 28 | + | name: string; | |
| 29 | + | args?: string; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | interface ToolCall { | |
| 33 | + | type: "tool_call"; | |
| 34 | + | id: string; | |
| 35 | + | name: string; | |
| 36 | + | args: Record<string, unknown>; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | interface HelloAck { | |
| 40 | + | type: "hello_ack"; | |
| 41 | + | protocol_version: number; | |
| 42 | + | zot_version: string; | |
| 43 | + | provider: string; | |
| 44 | + | model: string; | |
| 45 | + | cwd: string; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type Action = "prompt" | "insert" | "display" | "noop"; | |
| 49 | + | ||
| 50 | + | interface CommandResponse { | |
| 51 | + | type: "command_response"; | |
| 52 | + | id: string; | |
| 53 | + | action: Action; | |
| 54 | + | prompt?: string; | |
| 55 | + | insert?: string; | |
| 56 | + | display?: string; | |
| 57 | + | error?: string; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | interface ToolContent { | |
| 61 | + | type: "text" | "image"; | |
| 62 | + | text?: string; | |
| 63 | + | mime_type?: string; | |
| 64 | + | data?: string; // base64 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | interface ToolResult { | |
| 68 | + | type: "tool_result"; | |
| 69 | + | id: string; | |
| 70 | + | content: ToolContent[]; | |
| 71 | + | is_error?: boolean; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ---- I/O helpers ---- | |
| 75 | + | ||
| 76 | + | const NAME = "scratchpad"; | |
| 77 | + | const VERSION = "1.0.0"; | |
| 78 | + | ||
| 79 | + | function send(frame: Frame): void { | |
| 80 | + | stdout.write(JSON.stringify(frame) + "\n"); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | function log(msg: string): void { | |
| 84 | + | // stderr is captured by zot to $ZOT_HOME/logs/ext-<name>.log; | |
| 85 | + | // safe for debug output. stdout is reserved for the protocol. | |
| 86 | + | stderr.write(`[${NAME}] ${msg}\n`); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // ---- the scratchpad state itself ---- | |
| 90 | + | ||
| 91 | + | const notes: Array<{ at: string; text: string }> = []; | |
| 92 | + | ||
| 93 | + | function appendNote(text: string): number { | |
| 94 | + | notes.push({ at: new Date().toISOString(), text }); | |
| 95 | + | return notes.length; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | function renderNotes(): string { | |
| 99 | + | if (notes.length === 0) return "(scratchpad is empty)"; | |
| 100 | + | return notes | |
| 101 | + | .map((n, i) => `${i + 1}. [${n.at}] ${n.text}`) | |
| 102 | + | .join("\n"); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // ---- handshake + registration ---- | |
| 106 | + | ||
| 107 | + | send({ | |
| 108 | + | type: "hello", | |
| 109 | + | name: NAME, | |
| 110 | + | version: VERSION, | |
| 111 | + | capabilities: ["commands", "tools"], | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | send({ | |
| 115 | + | type: "register_command", | |
| 116 | + | name: "note", | |
| 117 | + | description: "append text to the scratchpad", | |
| 118 | + | }); | |
| 119 | + | send({ | |
| 120 | + | type: "register_command", | |
| 121 | + | name: "notes", | |
| 122 | + | description: "show the scratchpad", | |
| 123 | + | }); | |
| 124 | + | send({ | |
| 125 | + | type: "register_command", | |
| 126 | + | name: "clear-notes", | |
| 127 | + | description: "wipe the scratchpad", | |
| 128 | + | }); | |
| 129 | + | ||
| 130 | + | send({ | |
| 131 | + | type: "register_tool", | |
| 132 | + | name: "read_notes", | |
| 133 | + | description: | |
| 134 | + | "Read the scratchpad. Use this when the user asks about notes or context they have stored, or refers to something from earlier they wanted you to remember.", | |
| 135 | + | schema: { | |
| 136 | + | type: "object", | |
| 137 | + | properties: {}, | |
| 138 | + | }, | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | // Sentinel: tells zot all initial registrations are flushed so the | |
| 142 | + | // agent's tool registry can be built without racing the read loop. | |
| 143 | + | send({ type: "ready" }); | |
| 144 | + | ||
| 145 | + | // ---- frame loop ---- | |
| 146 | + | ||
| 147 | + | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |
| 148 | + | ||
| 149 | + | rl.on("line", (line: string) => { | |
| 150 | + | let frame: Frame; | |
| 151 | + | try { | |
| 152 | + | frame = JSON.parse(line) as Frame; | |
| 153 | + | } catch (err) { | |
| 154 | + | log(`malformed frame: ${err}`); | |
| 155 | + | return; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | switch (frame.type) { | |
| 159 | + | case "hello_ack": | |
| 160 | + | handleHelloAck(frame as unknown as HelloAck); | |
| 161 | + | break; | |
| 162 | + | case "command_invoked": | |
| 163 | + | handleCommand(frame as unknown as CommandInvoked); | |
| 164 | + | break; | |
| 165 | + | case "tool_call": | |
| 166 | + | handleToolCall(frame as unknown as ToolCall); | |
| 167 | + | break; | |
| 168 | + | case "shutdown": | |
| 169 | + | send({ type: "shutdown_ack" }); | |
| 170 | + | rl.close(); | |
| 171 | + | break; | |
| 172 | + | default: | |
| 173 | + | log(`unknown frame: ${frame.type}`); | |
| 174 | + | } | |
| 175 | + | }); | |
| 176 | + | ||
| 177 | + | rl.on("close", () => { | |
| 178 | + | log("read loop closed; exiting"); | |
| 179 | + | process.exit(0); | |
| 180 | + | }); | |
| 181 | + | ||
| 182 | + | function handleHelloAck(ack: HelloAck): void { | |
| 183 | + | log( | |
| 184 | + | `connected to zot ${ack.zot_version} ` + | |
| 185 | + | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | function handleCommand(frame: CommandInvoked): void { | |
| 190 | + | const args = (frame.args ?? "").trim(); | |
| 191 | + | ||
| 192 | + | switch (frame.name) { | |
| 193 | + | case "note": { | |
| 194 | + | if (args === "") { | |
| 195 | + | respond(frame.id, { | |
| 196 | + | type: "command_response", | |
| 197 | + | id: frame.id, | |
| 198 | + | action: "noop", | |
| 199 | + | error: "note: usage is /note <text>", | |
| 200 | + | }); | |
| 201 | + | return; | |
| 202 | + | } | |
| 203 | + | const n = appendNote(args); | |
| 204 | + | respond(frame.id, { | |
| 205 | + | type: "command_response", | |
| 206 | + | id: frame.id, | |
| 207 | + | action: "display", | |
| 208 | + | display: `noted (#${n}): ${args}`, | |
| 209 | + | }); | |
| 210 | + | return; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | case "notes": { | |
| 214 | + | respond(frame.id, { | |
| 215 | + | type: "command_response", | |
| 216 | + | id: frame.id, | |
| 217 | + | action: "display", | |
| 218 | + | display: renderNotes(), | |
| 219 | + | }); | |
| 220 | + | return; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | case "clear-notes": { | |
| 224 | + | notes.length = 0; | |
| 225 | + | respond(frame.id, { | |
| 226 | + | type: "command_response", | |
| 227 | + | id: frame.id, | |
| 228 | + | action: "display", | |
| 229 | + | display: "scratchpad cleared", | |
| 230 | + | }); | |
| 231 | + | return; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | default: | |
| 235 | + | respond(frame.id, { | |
| 236 | + | type: "command_response", | |
| 237 | + | id: frame.id, | |
| 238 | + | action: "noop", | |
| 239 | + | error: `unknown command /${frame.name}`, | |
| 240 | + | }); | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | function handleToolCall(frame: ToolCall): void { | |
| 245 | + | switch (frame.name) { | |
| 246 | + | case "read_notes": { | |
| 247 | + | sendToolResult(frame.id, [ | |
| 248 | + | { type: "text", text: renderNotes() }, | |
| 249 | + | ]); | |
| 250 | + | return; | |
| 251 | + | } | |
| 252 | + | default: | |
| 253 | + | sendToolResult( | |
| 254 | + | frame.id, | |
| 255 | + | [{ type: "text", text: `unknown tool ${frame.name}` }], | |
| 256 | + | true, | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // ---- send wrappers (typed so misuse is a compile error) ---- | |
| 262 | + | ||
| 263 | + | function respond(_id: string, response: CommandResponse): void { | |
| 264 | + | send(response); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | function sendToolResult( | |
| 268 | + | id: string, | |
| 269 | + | content: ToolContent[], | |
| 270 | + | is_error = false, | |
| 271 | + | ): void { | |
| 272 | + | const result: ToolResult = { | |
| 273 | + | type: "tool_result", | |
| 274 | + | id, | |
| 275 | + | content, | |
| 276 | + | is_error: is_error || undefined, | |
| 277 | + | }; | |
| 278 | + | send(result); | |
| 279 | + | } | |
| @@ -0,0 +1,279 @@ | |||||
| 1 | + | // scratchpad — a real .ts zot extension with no SDK and no build step. | |||
| 2 | + | // | |||
| 3 | + | // Runs via `npx --yes tsx index.ts` (declared in extension.json). | |||
| 4 | + | // First invocation downloads tsx into npm's cache; every subsequent | |||
| 5 | + | // run is instant. Pure node + tsx, no other dependencies. | |||
| 6 | + | // | |||
| 7 | + | // What it does: | |||
| 8 | + | // | |||
| 9 | + | // /note <text> — append <text> to a process-local scratchpad | |||
| 10 | + | // /notes — show the scratchpad inline (no model call) | |||
| 11 | + | // /clear-notes — wipe the scratchpad | |||
| 12 | + | // | |||
| 13 | + | // tool: read_notes() — the model can read the scratchpad on demand | |||
| 14 | + | // | |||
| 15 | + | // Use this as a template for any TypeScript extension that needs to | |||
| 16 | + | // register slash commands, expose tools to the model, or both. | |||
| 17 | + | ||||
| 18 | + | import { createInterface } from "node:readline"; | |||
| 19 | + | import { stderr, stdin, stdout } from "node:process"; | |||
| 20 | + | ||||
| 21 | + | // ---- protocol types (a tiny subset of internal/extproto) ---- | |||
| 22 | + | ||||
| 23 | + | type Frame = { type: string; id?: string; [k: string]: unknown }; | |||
| 24 | + | ||||
| 25 | + | interface CommandInvoked { | |||
| 26 | + | type: "command_invoked"; | |||
| 27 | + | id: string; | |||
| 28 | + | name: string; | |||
| 29 | + | args?: string; | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | interface ToolCall { | |||
| 33 | + | type: "tool_call"; | |||
| 34 | + | id: string; | |||
| 35 | + | name: string; | |||
| 36 | + | args: Record<string, unknown>; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | interface HelloAck { | |||
| 40 | + | type: "hello_ack"; | |||
| 41 | + | protocol_version: number; | |||
| 42 | + | zot_version: string; | |||
| 43 | + | provider: string; | |||
| 44 | + | model: string; | |||
| 45 | + | cwd: string; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type Action = "prompt" | "insert" | "display" | "noop"; | |||
| 49 | + | ||||
| 50 | + | interface CommandResponse { | |||
| 51 | + | type: "command_response"; | |||
| 52 | + | id: string; | |||
| 53 | + | action: Action; | |||
| 54 | + | prompt?: string; | |||
| 55 | + | insert?: string; | |||
| 56 | + | display?: string; | |||
| 57 | + | error?: string; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | interface ToolContent { | |||
| 61 | + | type: "text" | "image"; | |||
| 62 | + | text?: string; | |||
| 63 | + | mime_type?: string; | |||
| 64 | + | data?: string; // base64 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | interface ToolResult { | |||
| 68 | + | type: "tool_result"; | |||
| 69 | + | id: string; | |||
| 70 | + | content: ToolContent[]; | |||
| 71 | + | is_error?: boolean; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ---- I/O helpers ---- | |||
| 75 | + | ||||
| 76 | + | const NAME = "scratchpad"; | |||
| 77 | + | const VERSION = "1.0.0"; | |||
| 78 | + | ||||
| 79 | + | function send(frame: Frame): void { | |||
| 80 | + | stdout.write(JSON.stringify(frame) + "\n"); | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | function log(msg: string): void { | |||
| 84 | + | // stderr is captured by zot to $ZOT_HOME/logs/ext-<name>.log; | |||
| 85 | + | // safe for debug output. stdout is reserved for the protocol. | |||
| 86 | + | stderr.write(`[${NAME}] ${msg}\n`); | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // ---- the scratchpad state itself ---- | |||
| 90 | + | ||||
| 91 | + | const notes: Array<{ at: string; text: string }> = []; | |||
| 92 | + | ||||
| 93 | + | function appendNote(text: string): number { | |||
| 94 | + | notes.push({ at: new Date().toISOString(), text }); | |||
| 95 | + | return notes.length; | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | function renderNotes(): string { | |||
| 99 | + | if (notes.length === 0) return "(scratchpad is empty)"; | |||
| 100 | + | return notes | |||
| 101 | + | .map((n, i) => `${i + 1}. [${n.at}] ${n.text}`) | |||
| 102 | + | .join("\n"); | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // ---- handshake + registration ---- | |||
| 106 | + | ||||
| 107 | + | send({ | |||
| 108 | + | type: "hello", | |||
| 109 | + | name: NAME, | |||
| 110 | + | version: VERSION, | |||
| 111 | + | capabilities: ["commands", "tools"], | |||
| 112 | + | }); | |||
| 113 | + | ||||
| 114 | + | send({ | |||
| 115 | + | type: "register_command", | |||
| 116 | + | name: "note", | |||
| 117 | + | description: "append text to the scratchpad", | |||
| 118 | + | }); | |||
| 119 | + | send({ | |||
| 120 | + | type: "register_command", | |||
| 121 | + | name: "notes", | |||
| 122 | + | description: "show the scratchpad", | |||
| 123 | + | }); | |||
| 124 | + | send({ | |||
| 125 | + | type: "register_command", | |||
| 126 | + | name: "clear-notes", | |||
| 127 | + | description: "wipe the scratchpad", | |||
| 128 | + | }); | |||
| 129 | + | ||||
| 130 | + | send({ | |||
| 131 | + | type: "register_tool", | |||
| 132 | + | name: "read_notes", | |||
| 133 | + | description: | |||
| 134 | + | "Read the scratchpad. Use this when the user asks about notes or context they have stored, or refers to something from earlier they wanted you to remember.", | |||
| 135 | + | schema: { | |||
| 136 | + | type: "object", | |||
| 137 | + | properties: {}, | |||
| 138 | + | }, | |||
| 139 | + | }); | |||
| 140 | + | ||||
| 141 | + | // Sentinel: tells zot all initial registrations are flushed so the | |||
| 142 | + | // agent's tool registry can be built without racing the read loop. | |||
| 143 | + | send({ type: "ready" }); | |||
| 144 | + | ||||
| 145 | + | // ---- frame loop ---- | |||
| 146 | + | ||||
| 147 | + | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |||
| 148 | + | ||||
| 149 | + | rl.on("line", (line: string) => { | |||
| 150 | + | let frame: Frame; | |||
| 151 | + | try { | |||
| 152 | + | frame = JSON.parse(line) as Frame; | |||
| 153 | + | } catch (err) { | |||
| 154 | + | log(`malformed frame: ${err}`); | |||
| 155 | + | return; | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | switch (frame.type) { | |||
| 159 | + | case "hello_ack": | |||
| 160 | + | handleHelloAck(frame as unknown as HelloAck); | |||
| 161 | + | break; | |||
| 162 | + | case "command_invoked": | |||
| 163 | + | handleCommand(frame as unknown as CommandInvoked); | |||
| 164 | + | break; | |||
| 165 | + | case "tool_call": | |||
| 166 | + | handleToolCall(frame as unknown as ToolCall); | |||
| 167 | + | break; | |||
| 168 | + | case "shutdown": | |||
| 169 | + | send({ type: "shutdown_ack" }); | |||
| 170 | + | rl.close(); | |||
| 171 | + | break; | |||
| 172 | + | default: | |||
| 173 | + | log(`unknown frame: ${frame.type}`); | |||
| 174 | + | } | |||
| 175 | + | }); | |||
| 176 | + | ||||
| 177 | + | rl.on("close", () => { | |||
| 178 | + | log("read loop closed; exiting"); | |||
| 179 | + | process.exit(0); | |||
| 180 | + | }); | |||
| 181 | + | ||||
| 182 | + | function handleHelloAck(ack: HelloAck): void { | |||
| 183 | + | log( | |||
| 184 | + | `connected to zot ${ack.zot_version} ` + | |||
| 185 | + | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | |||
| 186 | + | ); | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | function handleCommand(frame: CommandInvoked): void { | |||
| 190 | + | const args = (frame.args ?? "").trim(); | |||
| 191 | + | ||||
| 192 | + | switch (frame.name) { | |||
| 193 | + | case "note": { | |||
| 194 | + | if (args === "") { | |||
| 195 | + | respond(frame.id, { | |||
| 196 | + | type: "command_response", | |||
| 197 | + | id: frame.id, | |||
| 198 | + | action: "noop", | |||
| 199 | + | error: "note: usage is /note <text>", | |||
| 200 | + | }); | |||
| 201 | + | return; | |||
| 202 | + | } | |||
| 203 | + | const n = appendNote(args); | |||
| 204 | + | respond(frame.id, { | |||
| 205 | + | type: "command_response", | |||
| 206 | + | id: frame.id, | |||
| 207 | + | action: "display", | |||
| 208 | + | display: `noted (#${n}): ${args}`, | |||
| 209 | + | }); | |||
| 210 | + | return; | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | case "notes": { | |||
| 214 | + | respond(frame.id, { | |||
| 215 | + | type: "command_response", | |||
| 216 | + | id: frame.id, | |||
| 217 | + | action: "display", | |||
| 218 | + | display: renderNotes(), | |||
| 219 | + | }); | |||
| 220 | + | return; | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | case "clear-notes": { | |||
| 224 | + | notes.length = 0; | |||
| 225 | + | respond(frame.id, { | |||
| 226 | + | type: "command_response", | |||
| 227 | + | id: frame.id, | |||
| 228 | + | action: "display", | |||
| 229 | + | display: "scratchpad cleared", | |||
| 230 | + | }); | |||
| 231 | + | return; | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | default: | |||
| 235 | + | respond(frame.id, { | |||
| 236 | + | type: "command_response", | |||
| 237 | + | id: frame.id, | |||
| 238 | + | action: "noop", | |||
| 239 | + | error: `unknown command /${frame.name}`, | |||
| 240 | + | }); | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | function handleToolCall(frame: ToolCall): void { | |||
| 245 | + | switch (frame.name) { | |||
| 246 | + | case "read_notes": { | |||
| 247 | + | sendToolResult(frame.id, [ | |||
| 248 | + | { type: "text", text: renderNotes() }, | |||
| 249 | + | ]); | |||
| 250 | + | return; | |||
| 251 | + | } | |||
| 252 | + | default: | |||
| 253 | + | sendToolResult( | |||
| 254 | + | frame.id, | |||
| 255 | + | [{ type: "text", text: `unknown tool ${frame.name}` }], | |||
| 256 | + | true, | |||
| 257 | + | ); | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // ---- send wrappers (typed so misuse is a compile error) ---- | |||
| 262 | + | ||||
| 263 | + | function respond(_id: string, response: CommandResponse): void { | |||
| 264 | + | send(response); | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | function sendToolResult( | |||
| 268 | + | id: string, | |||
| 269 | + | content: ToolContent[], | |||
| 270 | + | is_error = false, | |||
| 271 | + | ): void { | |||
| 272 | + | const result: ToolResult = { | |||
| 273 | + | type: "tool_result", | |||
| 274 | + | id, | |||
| 275 | + | content, | |||
| 276 | + | is_error: is_error || undefined, | |||
| 277 | + | }; | |||
| 278 | + | send(result); | |||
| 279 | + | } | |||
M internal/agent/cli.go +6 -1
| @@ -262,7 +262,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 262 | 262 | // frames before we build the agent's tool registry. Half a second | |
| 263 | 263 | // is plenty for any extension that's actually well-behaved; ones | |
| 264 | 264 | // that don't send a ready frame eat the full grace and proceed. | |
| 265 | - | extMgr.WaitForReady(500 * time.Millisecond) | |
| 265 | + | // 3s is the per-extension grace period for the ready frame. | |
| 266 | + | // Native binaries are instant; runtimes like `npx tsx` take ~1.5s | |
| 267 | + | // from cold cache. The wait is tight only for extensions that | |
| 268 | + | // haven't sent ready by then; ones that signalled earlier release | |
| 269 | + | // the wait immediately. | |
| 270 | + | extMgr.WaitForReady(3 * time.Second) | |
| 266 | 271 | defer extMgr.Stop(2 * time.Second) | |
| 267 | 272 | ||
| 268 | 273 | extToolAdapter := &extToolAdapter{mgr: extMgr} | |
| @@ -262,7 +262,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 262 | // frames before we build the agent's tool registry. Half a second | 262 | // frames before we build the agent's tool registry. Half a second | ||
| 263 | // is plenty for any extension that's actually well-behaved; ones | 263 | // is plenty for any extension that's actually well-behaved; ones | ||
| 264 | // that don't send a ready frame eat the full grace and proceed. | 264 | // that don't send a ready frame eat the full grace and proceed. | ||
| 265 | - | extMgr.WaitForReady(500 * time.Millisecond) | 265 | + | // 3s is the per-extension grace period for the ready frame. |
| 266 | + | // Native binaries are instant; runtimes like `npx tsx` take ~1.5s | |||
| 267 | + | // from cold cache. The wait is tight only for extensions that | |||
| 268 | + | // haven't sent ready by then; ones that signalled earlier release | |||
| 269 | + | // the wait immediately. | |||
| 270 | + | extMgr.WaitForReady(3 * time.Second) | |||
| 266 | defer extMgr.Stop(2 * time.Second) | 271 | defer extMgr.Stop(2 * time.Second) | ||
| 267 | 272 | ||||
| 268 | extToolAdapter := &extToolAdapter{mgr: extMgr} | 273 | extToolAdapter := &extToolAdapter{mgr: extMgr} | ||
M internal/agent/extensions/manager.go +18 -1
| @@ -272,9 +272,26 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 272 | 272 | ext.logFile = logFile | |
| 273 | 273 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | |
| 274 | 274 | ||
| 275 | + | // Exec resolution rules: | |
| 276 | + | // - absolute path: used as-is. | |
| 277 | + | // - starts with "." (./ or ../): resolved relative to ext.Dir. | |
| 278 | + | // - bare name (no path separator): looked up via $PATH so | |
| 279 | + | // "node", "npx", "python3", | |
| 280 | + | // "tsx" etc. work without | |
| 281 | + | // forcing absolute paths. | |
| 282 | + | // - other relative form (foo/bar): resolved relative to ext.Dir. | |
| 275 | 283 | execPath := ext.Manifest.Exec | |
| 276 | - | if !filepath.IsAbs(execPath) { | |
| 284 | + | switch { | |
| 285 | + | case filepath.IsAbs(execPath): | |
| 286 | + | // keep | |
| 287 | + | case strings.HasPrefix(execPath, "."+string(filepath.Separator)) || | |
| 288 | + | strings.HasPrefix(execPath, ".."+string(filepath.Separator)) || | |
| 289 | + | execPath == "." || execPath == "..": | |
| 277 | 290 | execPath = filepath.Join(ext.Dir, execPath) | |
| 291 | + | case strings.ContainsRune(execPath, filepath.Separator): | |
| 292 | + | execPath = filepath.Join(ext.Dir, execPath) | |
| 293 | + | default: | |
| 294 | + | // bare name: leave as-is for exec.LookPath via exec.Command. | |
| 278 | 295 | } | |
| 279 | 296 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | |
| 280 | 297 | cmd.Dir = ext.Dir | |
| @@ -272,9 +272,26 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||||
| 272 | ext.logFile = logFile | 272 | ext.logFile = logFile | ||
| 273 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | 273 | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | ||
| 274 | 274 | ||||
| 275 | + | // Exec resolution rules: | |||
| 276 | + | // - absolute path: used as-is. | |||
| 277 | + | // - starts with "." (./ or ../): resolved relative to ext.Dir. | |||
| 278 | + | // - bare name (no path separator): looked up via $PATH so | |||
| 279 | + | // "node", "npx", "python3", | |||
| 280 | + | // "tsx" etc. work without | |||
| 281 | + | // forcing absolute paths. | |||
| 282 | + | // - other relative form (foo/bar): resolved relative to ext.Dir. | |||
| 275 | execPath := ext.Manifest.Exec | 283 | execPath := ext.Manifest.Exec | ||
| 276 | - | if !filepath.IsAbs(execPath) { | 284 | + | switch { |
| 285 | + | case filepath.IsAbs(execPath): | |||
| 286 | + | // keep | |||
| 287 | + | case strings.HasPrefix(execPath, "."+string(filepath.Separator)) || | |||
| 288 | + | strings.HasPrefix(execPath, ".."+string(filepath.Separator)) || | |||
| 289 | + | execPath == "." || execPath == "..": | |||
| 277 | execPath = filepath.Join(ext.Dir, execPath) | 290 | execPath = filepath.Join(ext.Dir, execPath) | ||
| 291 | + | case strings.ContainsRune(execPath, filepath.Separator): | |||
| 292 | + | execPath = filepath.Join(ext.Dir, execPath) | |||
| 293 | + | default: | |||
| 294 | + | // bare name: leave as-is for exec.LookPath via exec.Command. | |||
| 278 | } | 295 | } | ||
| 279 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | 296 | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | ||
| 280 | cmd.Dir = ext.Dir | 297 | cmd.Dir = ext.Dir | ||
M internal/agent/rpc.go +1 -1
| @@ -54,7 +54,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 54 | 54 | for _, e := range discoveryErrs { | |
| 55 | 55 | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 56 | 56 | } | |
| 57 | - | extMgr.WaitForReady(500 * time.Millisecond) | |
| 57 | + | extMgr.WaitForReady(3 * time.Second) | |
| 58 | 58 | defer extMgr.Stop(2 * time.Second) | |
| 59 | 59 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 60 | 60 | ||
| @@ -54,7 +54,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 54 | for _, e := range discoveryErrs { | 54 | for _, e := range discoveryErrs { | ||
| 55 | fmt.Fprintln(os.Stderr, "extension load:", e) | 55 | fmt.Fprintln(os.Stderr, "extension load:", e) | ||
| 56 | } | 56 | } | ||
| 57 | - | extMgr.WaitForReady(500 * time.Millisecond) | 57 | + | extMgr.WaitForReady(3 * time.Second) |
| 58 | defer extMgr.Stop(2 * time.Second) | 58 | defer extMgr.Stop(2 * time.Second) | ||
| 59 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | 59 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | ||
| 60 | 60 | ||||