pi-lazy-extensions 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nico Bailon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ <div align="center">
2
+
3
+ # 🦥 pi-lazy-extensions
4
+
5
+ **Lazy-load [pi](https://github.com/earendil-works/pi-coding-agent) extensions on demand**
6
+
7
+ _ToolSearch-style proxy tool — extensions only load when you need them._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ---
17
+
18
+ ## The Problem
19
+
20
+ Every extension in `~/.pi/agent/extensions/` loads at startup. If you have many extensions, they all register their tools, commands, and event handlers immediately — even if you rarely use them. This clutters the tool list and wastes resources.
21
+
22
+ ## The Solution
23
+
24
+ `pi-lazy-extensions` registers a single `ext` proxy tool. The LLM discovers and activates extensions on demand, just like Anthropic's ToolSearch for MCP tools. Extensions stay unloaded until needed.
25
+
26
+ ```
27
+ Startup: only eager extensions load
28
+ │
29
+ ▼
30
+ ┌─────────────┐
31
+ │ ext tool │ ← always available
32
+ │ (pi-lazy) │
33
+ └──────┬──────┘
34
+ │
35
+ │ LLM calls ext({ activate: "todo" })
36
+ ▼
37
+ ┌─────────────┐
38
+ │ todo ext │ ← loaded on demand
39
+ │ (lazy) │ tools appear instantly
40
+ └─────────────┘
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pi install npm:pi-lazy-extensions
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Setup
54
+
55
+ Create a `lazy-extensions.json` manifest in your project root or `.pi/` directory:
56
+
57
+ ```json
58
+ {
59
+ "version": 1,
60
+ "extensions": [
61
+ {
62
+ "name": "todo",
63
+ "path": "~/.pi/agent/extensions/todo.ts",
64
+ "lifecycle": "lazy",
65
+ "description": "Task management - create, list, and track todos",
66
+ "toolSummary": ["todo_list", "todo_add", "todo_complete"],
67
+ "tags": ["productivity", "tasks"]
68
+ },
69
+ {
70
+ "name": "snake",
71
+ "path": "~/.pi/agent/extensions/snake.ts",
72
+ "lifecycle": "lazy",
73
+ "description": "Snake game while you wait",
74
+ "tags": ["fun"]
75
+ },
76
+ {
77
+ "name": "git-checkpoint",
78
+ "path": "~/.pi/agent/extensions/git-checkpoint.ts",
79
+ "lifecycle": "eager",
80
+ "description": "Auto git stash/restore on each turn"
81
+ }
82
+ ],
83
+ "settings": {
84
+ "idleTimeout": 10,
85
+ "eagerOverrides": ""
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### Manifest Location
91
+
92
+ The manifest is searched in this order:
93
+
94
+ 1. `LAZY_EXTENSIONS_CONFIG` environment variable (explicit path)
95
+ 2. `<cwd>/.pi/lazy-extensions.json` (project `.pi` dir)
96
+ 3. `~/.pi/agent/lazy-extensions.json` (global)
97
+ 4. `<cwd>/lazy-extensions.json` (project root)
98
+
99
+ ### Extension Config
100
+
101
+ | Field | Type | Description |
102
+ |-------|------|-------------|
103
+ | `name` | string | Unique identifier for the extension |
104
+ | `path` | string | Path to the extension's entry point (`.ts` or `.js`) |
105
+ | `lifecycle` | `"lazy"` \| `"eager"` \| `"keep-alive"` | Default: `"lazy"`. Eager loads at startup, keep-alive never unloads |
106
+ | `description` | string? | What the extension does (shown in search results) |
107
+ | `toolSummary` | string[]? | Names of tools this extension registers (for discovery before load) |
108
+ | `tags` | string[]? | Search/filter tags |
109
+
110
+ ### Settings
111
+
112
+ | Setting | Type | Default | Description |
113
+ |---------|------|---------|-------------|
114
+ | `disableProxyTool` | boolean | false | If true, don't register the `ext` proxy tool |
115
+ | `idleTimeout` | number | 10 | Minutes before unloading idle lazy extensions (0 = never) |
116
+ | `eagerOverrides` | string | "" | Comma-separated extension names to force eager loading |
117
+
118
+ ---
119
+
120
+ ## Usage
121
+
122
+ ### Via the `ext` tool (LLM calls)
123
+
124
+ ```
125
+ ext({}) → Status: list all extensions
126
+ ext({ search: "todo" }) → Search extensions matching "todo"
127
+ ext({ activate: "todo" }) → Load and activate the "todo" extension
128
+ ext({ tools: "todo" }) → List tools registered by "todo"
129
+ ```
130
+
131
+ After `ext({ activate: "todo" })`, the `todo` extension's tools become directly available to the LLM.
132
+
133
+ ### Via the `/ext` command (user calls)
134
+
135
+ ```
136
+ /ext → Show status
137
+ /ext activate todo → Activate an extension
138
+ /ext search productivity → Search extensions
139
+ /ext tools todo → List extension tools
140
+ ```
141
+
142
+ ---
143
+
144
+ ## How It Works
145
+
146
+ 1. At startup, `pi-lazy-extensions` reads the manifest and registers the `ext` proxy tool
147
+ 2. **Eager** and **keep-alive** extensions are loaded immediately during `session_start`
148
+ 3. **Lazy** extensions stay unloaded — their metadata is available for search/discovery
149
+ 4. When the LLM (or user) calls `ext({ activate: "name" })`, the extension is dynamically loaded via jiti (with the same module alias map that pi's own loader uses, so `import { Type } from "typebox"` and `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"` work correctly), and its factory function is called with the shared `ExtensionAPI`
150
+ 5. New tools registered by the activated extension appear immediately — no `/reload` needed
151
+ 6. After an idle timeout, lazy extensions are "soft unloaded" (their tools are deactivated)
152
+
153
+ ---
154
+
155
+ ### Limitations
156
+
157
+ - **No full unloading:** Once an extension's factory runs, its event handlers are permanent. Idle unloading only removes tools from the active set.
158
+ - **Shortcuts, flags, and message renderers persist:** These registrations have no deactivate/remove API in the current ExtensionAPI. They remain active even after idle-unload. The `ext` status display shows counts of these for awareness.
159
+ - **`sourceInfo` attribution:** Tools registered by lazy-loaded extensions will show the `pi-lazy-extensions` extension's `sourceInfo`, not the original extension's. This is because `pi.registerTool()` stamps each tool with the `sourceInfo` of the *extension that made the call* — and since the lazy extension's factory receives the same `ExtensionAPI` as `pi-lazy-extensions`, all registered tools are attributed to `pi-lazy-extensions`. There is no SDK API to override this.
160
+ - **`/ext` command vs `ext` tool:** The `/ext` command uses `ctx.ui.notify()` for output, which may truncate large results. For rich output (search results, detailed status), prefer the `ext` tool interface which renders properly in the TUI.
161
+ - **`session_start` handlers never fire for the current session:** When a lazy extension is activated mid-session, any `session_start` handlers it registers will not fire until the next session or `/reload`. Extensions that depend on `session_start` for initialization (reading config, setting up state) may not work correctly when lazy-loaded. The `ext` tool displays a warning when this is detected.
162
+ - **Duplicate tool names:** If a lazy extension registers a tool with the same name as an already-registered tool, pi's "first registration wins" policy silently skips it. The `ext` tool displays a warning when this is detected during activation.
163
+
164
+ ---
165
+
166
+ ## Example: Converting an Existing Extension
167
+
168
+ Before (always loaded):
169
+ ```
170
+ ~/.pi/agent/extensions/my-heavy-ext.ts
171
+ ```
172
+
173
+ After (lazy loaded):
174
+
175
+ 1. Move or keep the extension in its existing path
176
+ 2. Add to `lazy-extensions.json`:
177
+ ```json
178
+ {
179
+ "version": 1,
180
+ "extensions": [
181
+ {
182
+ "name": "my-heavy-ext",
183
+ "path": "~/.pi/agent/extensions/my-heavy-ext.ts",
184
+ "lifecycle": "lazy",
185
+ "description": "My heavy extension with 5 tools",
186
+ "toolSummary": ["heavy_tool_1", "heavy_tool_2", "heavy_tool_3", "heavy_tool_4", "heavy_tool_5"]
187
+ }
188
+ ]
189
+ }
190
+ ```
191
+ 3. Rename or remove the original from auto-discovery (add a `.bak` suffix or move it out of `~/.pi/agent/extensions/`) so it isn't loaded eagerly by pi itself
192
+
193
+ ---
194
+
195
+ ## Motivation
196
+
197
+ This is an experiment. If the pattern proves useful, the goal is to propose a `pi.loadExtension(path)` method to the pi SDK that handles proper `sourceInfo` attribution, deduplication, and full lifecycle management. This package serves as a working prototype to validate the approach.
198
+
199
+ ### Module Resolution
200
+
201
+ Lazy extensions are loaded via `jiti` — the same TypeScript/ESM transpiler that pi uses for its own extension loader. The jiti instance is configured with the same alias map that pi builds internally, which resolves:
202
+
203
+ - `typebox` (and `typebox/compile`, `typebox/value`)
204
+ - `@earendil-works/pi-coding-agent`
205
+ - `@earendil-works/pi-agent-core`
206
+ - `@earendil-works/pi-tui`
207
+ - `@earendil-works/pi-ai` (and `@earendil-works/pi-ai/oauth`)
208
+ - `@sinclair/typebox` (legacy alias)
209
+
210
+ This ensures lazy extensions can use the same imports as normally-loaded extensions. If jiti is unavailable (e.g. stripped from the runtime), the loader falls back to raw `import()`, which only works for `.js` files without SDK imports.
211
+
212
+ ## License
213
+
214
+ MIT
package/config.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Configuration loading for pi-lazy-extensions.
3
+ *
4
+ * Reads a manifest file (lazy-extensions.json) that describes which
5
+ * extensions to lazy-load and their metadata.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
+ import type { LazyExtensionConfig, LazyExtensionsManifest, LazyExtensionsState, LoadedExtensionState } from "./types.js";
12
+
13
+ /**
14
+ * Find and load the manifest file.
15
+ *
16
+ * Resolution order:
17
+ * 1. LAZY_EXTENSIONS_CONFIG env var (explicit path)
18
+ * 2. Project .pi/lazy-extensions.json
19
+ * 3. Global ~/.pi/agent/lazy-extensions.json
20
+ * 4. Project lazy-extensions.json
21
+ */
22
+ export function loadManifest(cwd: string, agentDir: string): { manifest: LazyExtensionsManifest; path: string } | null {
23
+ const envPath = process.env.LAZY_EXTENSIONS_CONFIG;
24
+ if (envPath && existsSync(envPath)) {
25
+ const manifest = parseManifest(envPath);
26
+ if (manifest) return { manifest, path: resolve(envPath) };
27
+ }
28
+
29
+ // Project .pi dir
30
+ const projectPi = join(cwd, ".pi", "lazy-extensions.json");
31
+ if (existsSync(projectPi)) {
32
+ const manifest = parseManifest(projectPi);
33
+ if (manifest) return { manifest, path: projectPi };
34
+ }
35
+
36
+ // Global agent dir
37
+ const globalPath = join(agentDir, "lazy-extensions.json");
38
+ if (existsSync(globalPath)) {
39
+ const manifest = parseManifest(globalPath);
40
+ if (manifest) return { manifest, path: globalPath };
41
+ }
42
+
43
+ // Project root
44
+ const projectRoot = join(cwd, "lazy-extensions.json");
45
+ if (existsSync(projectRoot)) {
46
+ const manifest = parseManifest(projectRoot);
47
+ if (manifest) return { manifest, path: projectRoot };
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ function parseManifest(filePath: string): LazyExtensionsManifest | null {
54
+ try {
55
+ const raw = readFileSync(filePath, "utf-8");
56
+ const parsed = JSON.parse(raw);
57
+
58
+ if (!parsed || typeof parsed !== "object") return null;
59
+ if (parsed.version !== 1) {
60
+ console.error(`pi-lazy-extensions: unsupported manifest version ${parsed.version}`);
61
+ return null;
62
+ }
63
+ if (!Array.isArray(parsed.extensions)) {
64
+ console.error("pi-lazy-extensions: manifest missing 'extensions' array");
65
+ return null;
66
+ }
67
+
68
+ // Validate entries
69
+ for (const ext of parsed.extensions as LazyExtensionConfig[]) {
70
+ if (!ext.name || typeof ext.name !== "string") {
71
+ console.error("pi-lazy-extensions: extension entry missing 'name'");
72
+ return null;
73
+ }
74
+ if (!ext.path || typeof ext.path !== "string") {
75
+ console.error(`pi-lazy-extensions: extension "${ext.name}" missing 'path'`);
76
+ return null;
77
+ }
78
+ }
79
+
80
+ return parsed as LazyExtensionsManifest;
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ console.error(`pi-lazy-extensions: failed to parse ${filePath}: ${message}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Expand leading ~ to the user's home directory.
90
+ * Mirrors the pi SDK's own expandPath() in its extension loader.
91
+ */
92
+ function expandTilde(p: string): string {
93
+ if (p.startsWith("~/")) {
94
+ return join(homedir(), p.slice(2));
95
+ }
96
+ if (p === "~") {
97
+ return homedir();
98
+ }
99
+ return p;
100
+ }
101
+
102
+ /**
103
+ * Resolve an extension path relative to the manifest's base directory.
104
+ * Supports ~ expansion (e.g. "~/.pi/agent/extensions/todo.ts").
105
+ */
106
+ export function resolveExtensionPath(extPath: string, baseDir: string): string {
107
+ const expanded = expandTilde(extPath);
108
+ if (isAbsolute(expanded)) return expanded;
109
+ return resolve(baseDir, expanded);
110
+ }
111
+
112
+ /**
113
+ * Build initial state from a loaded manifest.
114
+ */
115
+ export function buildState(
116
+ manifest: LazyExtensionsManifest,
117
+ manifestPath: string,
118
+ ): LazyExtensionsState {
119
+ const baseDir = dirname(manifestPath);
120
+ const extensions = new Map<string, LoadedExtensionState>();
121
+
122
+ for (const config of manifest.extensions) {
123
+ extensions.set(config.name, {
124
+ config,
125
+ loaded: false,
126
+ factoryCalled: false,
127
+ registeredTools: [],
128
+ registeredCommands: [],
129
+ registeredShortcuts: [],
130
+ registeredFlags: [],
131
+ registeredRenderers: [],
132
+ });
133
+ }
134
+
135
+ return { extensions, manifest, manifestPath, baseDir, failureTracker: new Map() };
136
+ }
137
+
138
+ /**
139
+ * Determine which extensions should be loaded eagerly at startup.
140
+ */
141
+ export function getEagerExtensions(state: LazyExtensionsState): LazyExtensionConfig[] {
142
+ const rawOverrides = state.manifest.settings?.eagerOverrides ?? "";
143
+ const eagerOverrides = new Set(
144
+ rawOverrides.split(",").map(s => s.trim()).filter(Boolean)
145
+ );
146
+
147
+ return state.manifest.extensions.filter(ext => {
148
+ if (eagerOverrides.has(ext.name)) return true;
149
+ const lifecycle = ext.lifecycle ?? "lazy";
150
+ return lifecycle === "eager" || lifecycle === "keep-alive";
151
+ });
152
+ }
package/index.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * pi-lazy-extensions
3
+ *
4
+ * A pi extension that lazy-loads other extensions on demand,
5
+ * analogous to Anthropic's ToolSearch but for entire pi extensions.
6
+ *
7
+ * Usage:
8
+ * 1. Create a lazy-extensions.json manifest
9
+ * 2. Install this package: pi install npm:pi-lazy-extensions
10
+ * 3. The `ext` proxy tool lets the LLM discover and activate extensions
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
14
+ import { Type } from "typebox";
15
+ import { loadManifest, buildState, getEagerExtensions } from "./config.js";
16
+ import { activateExtension, clearAllTimers, touchExtension } from "./registry.js";
17
+ import { executeStatus, executeSearch, executeActivate, executeListTools } from "./proxy-modes.js";
18
+ import type { LazyExtensionsState } from "./types.js";
19
+
20
+ const PROXY_TOOL_DESCRIPTION =
21
+ "Extension gateway - discover, search, and activate extensions on demand. " +
22
+ "Call ext({}) for status, ext({ search: \"query\" }) to find extensions, " +
23
+ "ext({ activate: \"name\" }) to load one.";
24
+
25
+ export default function lazyExtensions(pi: ExtensionAPI) {
26
+ let state: LazyExtensionsState | null = null;
27
+ let initPromise: Promise<void> | null = null;
28
+ let lifecycleGeneration = 0;
29
+
30
+ const getPiTools = (): ToolInfo[] => pi.getAllTools();
31
+ const agentDir = process.env.PI_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? `${process.env.HOME}/.pi/agent`;
32
+
33
+ // Check for tool name collision before registering the proxy tool.
34
+ // Pi uses "first registration wins" — if another extension already
35
+ // registered a tool named "ext", our proxy would be silently skipped.
36
+ const existingTools = pi.getAllTools();
37
+ if (existingTools.some(t => t.name === "ext")) {
38
+ console.error(
39
+ "pi-lazy-extensions: another extension already registered a tool named 'ext'. " +
40
+ "The proxy tool will not be available. Use the /ext command instead, " +
41
+ "or remove the conflicting extension."
42
+ );
43
+ }
44
+
45
+ pi.on("session_start", async (_event, ctx) => {
46
+ const generation = ++lifecycleGeneration;
47
+
48
+ // Clear previous state
49
+ if (state) {
50
+ clearAllTimers(state);
51
+ state = null;
52
+ }
53
+
54
+ const result = loadManifest(ctx.cwd, agentDir);
55
+
56
+ if (!result) {
57
+ // No manifest found — proxy tool still active, shows "not configured"
58
+ return;
59
+ }
60
+
61
+ // Honour disableProxyTool by removing the ext proxy from the active set
62
+ if (result.manifest.settings?.disableProxyTool) {
63
+ const active = pi.getActiveTools();
64
+ pi.setActiveTools(active.filter(t => t !== "ext"));
65
+ return;
66
+ }
67
+
68
+ state = buildState(result.manifest, result.path);
69
+ state.pi = pi;
70
+
71
+ if (generation !== lifecycleGeneration) return;
72
+
73
+ if (ctx.hasUI) {
74
+ const total = state.extensions.size;
75
+ if (total > 0) {
76
+ ctx.ui.notify(`Lazy Extensions: ${total} extension${total === 1 ? "" : "s"} configured`, "info");
77
+ }
78
+ }
79
+
80
+ // Load eager extensions immediately
81
+ const eager = getEagerExtensions(state);
82
+ if (eager.length > 0 && ctx.hasUI) {
83
+ ctx.ui.setStatus("lazy-ext", `Lazy Extensions: loading ${eager.length} eager extensions...`);
84
+ }
85
+
86
+ const init = (async () => {
87
+ for (const extConfig of eager) {
88
+ if (generation !== lifecycleGeneration) return;
89
+ const result = await activateExtension(extConfig.name, state!, pi);
90
+ if (!result.success && ctx.hasUI) {
91
+ ctx.ui.notify(`Lazy Extensions: failed to load "${extConfig.name}": ${result.error}`, "warning");
92
+ }
93
+ }
94
+ })();
95
+
96
+ initPromise = init;
97
+ await init;
98
+ initPromise = null;
99
+
100
+ if (generation !== lifecycleGeneration) return;
101
+
102
+ if (eager.length > 0 && ctx.hasUI) {
103
+ const loaded = eager.filter(e => state!.extensions.get(e.name)?.loaded).length;
104
+ ctx.ui.setStatus("lazy-ext", `Lazy Extensions: ${loaded}/${eager.length} eager loaded`);
105
+ // Clear status after a few seconds
106
+ setTimeout(() => {
107
+ if (state && generation === lifecycleGeneration) {
108
+ const active = Array.from(state.extensions.values()).filter(e => e.loaded).length;
109
+ const total = state.extensions.size;
110
+ if (active > 0) {
111
+ ctx.ui.setStatus("lazy-ext", ctx.ui.theme.fg("accent", `Ext: ${active}/${total} active`));
112
+ } else {
113
+ ctx.ui.setStatus("lazy-ext", undefined);
114
+ }
115
+ }
116
+ }, 3000);
117
+ }
118
+ });
119
+
120
+ pi.on("session_shutdown", () => {
121
+ ++lifecycleGeneration;
122
+ if (state) {
123
+ clearAllTimers(state);
124
+ state = null;
125
+ }
126
+ initPromise = null;
127
+ });
128
+
129
+ // Reset idle timers when lazy extension tools are used.
130
+ // Note: if a lazy extension's tool result arrives during session_start
131
+ // (after state is built but before initPromise resolves), touchExtension
132
+ // won't find the tool name and safely returns without touching. This is
133
+ // harmless — the idle timer hasn't started yet for that extension anyway.
134
+ pi.on("tool_execution_end", (event) => {
135
+ if (!state) return;
136
+ for (const [name, extState] of state.extensions) {
137
+ if (extState.loaded && extState.registeredTools.includes(event.toolName)) {
138
+ touchExtension(name, state);
139
+ break;
140
+ }
141
+ }
142
+ });
143
+
144
+ pi.registerCommand("ext", {
145
+ description: "Show lazy extensions status",
146
+ handler: async (args, ctx) => {
147
+ // Wait for init if still running
148
+ if (initPromise) await initPromise;
149
+
150
+ if (!state) {
151
+ if (ctx.hasUI) ctx.ui.notify("No lazy extensions configured", "info");
152
+ return;
153
+ }
154
+
155
+ const parts = args?.trim()?.split(/\s+/) ?? [];
156
+ const subcommand = parts[0] ?? "";
157
+ const target = parts[1];
158
+
159
+ switch (subcommand) {
160
+ case "activate": {
161
+ if (!target) {
162
+ if (ctx.hasUI) ctx.ui.notify("Usage: /ext activate <name>", "warning");
163
+ return;
164
+ }
165
+ const result = await activateExtension(target, state, pi);
166
+ if (result.success) {
167
+ const warnings: string[] = [];
168
+ if (result.duplicateTools?.length) warnings.push("duplicate tools");
169
+ if (result.sessionStartWarning) warnings.push("session_start won't fire");
170
+ const warnStr = warnings.length > 0 ? ` (⚠ ${warnings.join(", ")})` : "";
171
+ if (ctx.hasUI) ctx.ui.notify(`✓ Activated "${target}"${result.tools?.length ? ` (${result.tools.length} tools)` : ""}${warnStr}`, "info");
172
+ } else {
173
+ if (ctx.hasUI) ctx.ui.notify(`Failed to activate "${target}": ${result.error}`, "error");
174
+ }
175
+ break;
176
+ }
177
+ case "search": {
178
+ const query = parts.slice(1).join(" ");
179
+ if (!query) {
180
+ if (ctx.hasUI) ctx.ui.notify("Usage: /ext search <query>", "warning");
181
+ return;
182
+ }
183
+ const result = executeSearch(state, query);
184
+ const msg = result.content[0]?.type === "text" ? result.content[0].text : undefined;
185
+ if (ctx.hasUI) ctx.ui.notify(msg ?? "No results", "info");
186
+ break;
187
+ }
188
+ case "tools": {
189
+ const result = executeListTools(state, target, getPiTools);
190
+ const msg = result.content[0]?.type === "text" ? result.content[0].text : undefined;
191
+ if (ctx.hasUI) ctx.ui.notify(msg ?? "No tools", "info");
192
+ break;
193
+ }
194
+ case "status":
195
+ case "":
196
+ default: {
197
+ const result = executeStatus(state);
198
+ const msg = result.content[0]?.type === "text" ? result.content[0].text : undefined;
199
+ if (ctx.hasUI) ctx.ui.notify(msg ?? "No status", "info");
200
+ break;
201
+ }
202
+ }
203
+ },
204
+ });
205
+
206
+ // Register the proxy tool unconditionally — it's the LLM's entry point for
207
+ // discovering and activating extensions. If the manifest sets disableProxyTool,
208
+ // we remove "ext" from the active tool set in session_start.
209
+ // @ts-ignore - Type.Object causes excessive type instantiation with typebox
210
+ pi.registerTool({
211
+ name: "ext",
212
+ label: "Extension Gateway",
213
+ description: PROXY_TOOL_DESCRIPTION,
214
+ promptSnippet: "Discover and activate lazy-loaded extensions",
215
+ promptGuidelines: [
216
+ "Use ext to discover and activate extensions before using their tools.",
217
+ "If a tool you need is not available, search ext({ search: \"...\" }) for an extension that provides it.",
218
+ "After activating an extension with ext({ activate: \"name\" }), its tools become immediately available.",
219
+ ],
220
+ parameters: Type.Object({
221
+ search: Type.Optional(Type.String({ description: "Search extensions by name, description, tags, or tool names" })),
222
+ activate: Type.Optional(Type.String({ description: "Activate (load) an extension by name" })),
223
+ tools: Type.Optional(Type.String({ description: "List tools for a specific extension (or omit for all active)" })),
224
+ regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
225
+ }),
226
+ async execute(_toolCallId: string, params: { search?: string; activate?: string; tools?: string; regex?: boolean }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext) {
227
+ // Wait for init if still running
228
+ if (initPromise) await initPromise;
229
+
230
+ if (!state) {
231
+ return {
232
+ content: [{ type: "text" as const, text: "No lazy extensions configured. Create a lazy-extensions.json manifest." }],
233
+ details: { error: "not_configured" },
234
+ };
235
+ }
236
+
237
+ if (params.activate) {
238
+ return executeActivate(state, params.activate, pi) as any;
239
+ }
240
+ if (params.search) {
241
+ return executeSearch(state, params.search, params.regex) as any;
242
+ }
243
+ if (params.tools !== undefined) {
244
+ return executeListTools(state, params.tools || undefined, getPiTools) as any;
245
+ }
246
+ return executeStatus(state) as any;
247
+ },
248
+ });
249
+ }