detached 5dbbcb9

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 {
262262 // frames before we build the agent's tool registry. Half a second
263263 // is plenty for any extension that's actually well-behaved; ones
264264 // 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)
266271 defer extMgr.Stop(2 * time.Second)
267272
268273 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 second262 // frames before we build the agent's tool registry. Half a second
263 // is plenty for any extension that's actually well-behaved; ones263 // 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 {
272272 ext.logFile = logFile
273273 fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339))
274274
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.
275283 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 == "..":
277290 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.
278295 }
279296 cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...)
280297 cmd.Dir = ext.Dir
@@ -272,9 +272,26 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
272 ext.logFile = logFile272 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.Exec283 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.Dir297 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 {
5454 for _, e := range discoveryErrs {
5555 fmt.Fprintln(os.Stderr, "extension load:", e)
5656 }
57- extMgr.WaitForReady(500 * time.Millisecond)
57+ extMgr.WaitForReady(3 * time.Second)
5858 defer extMgr.Stop(2 * time.Second)
5959 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
6060
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close