agent-sh 0.8.0 → 0.9.0

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.
Files changed (74) hide show
  1. package/README.md +25 -34
  2. package/dist/agent/agent-loop.d.ts +29 -6
  3. package/dist/agent/agent-loop.js +177 -59
  4. package/dist/agent/conversation-state.d.ts +3 -1
  5. package/dist/agent/conversation-state.js +6 -2
  6. package/dist/agent/nuclear-form.js +5 -4
  7. package/dist/agent/system-prompt.d.ts +4 -5
  8. package/dist/agent/system-prompt.js +12 -28
  9. package/dist/{token-budget.js → agent/token-budget.js} +1 -1
  10. package/dist/agent/tool-protocol.d.ts +83 -0
  11. package/dist/agent/tool-protocol.js +386 -0
  12. package/dist/agent/types.d.ts +21 -1
  13. package/dist/core.d.ts +7 -7
  14. package/dist/core.js +76 -194
  15. package/dist/event-bus.d.ts +26 -0
  16. package/dist/event-bus.js +20 -1
  17. package/dist/extension-loader.d.ts +5 -0
  18. package/dist/extension-loader.js +104 -17
  19. package/dist/extensions/agent-backend.d.ts +13 -0
  20. package/dist/extensions/agent-backend.js +167 -0
  21. package/dist/extensions/command-suggest.d.ts +3 -3
  22. package/dist/extensions/command-suggest.js +4 -3
  23. package/dist/extensions/index.d.ts +19 -0
  24. package/dist/extensions/index.js +25 -0
  25. package/dist/extensions/slash-commands.d.ts +1 -1
  26. package/dist/extensions/slash-commands.js +16 -1
  27. package/dist/extensions/terminal-buffer.d.ts +1 -1
  28. package/dist/extensions/terminal-buffer.js +13 -4
  29. package/dist/extensions/tui-renderer.js +63 -43
  30. package/dist/index.js +14 -20
  31. package/dist/settings.d.ts +6 -0
  32. package/dist/settings.js +4 -1
  33. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  34. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  35. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  36. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  37. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  38. package/dist/{shell.js → shell/shell.js} +20 -6
  39. package/dist/types.d.ts +49 -10
  40. package/dist/utils/compositor.d.ts +62 -0
  41. package/dist/utils/compositor.js +88 -0
  42. package/dist/utils/diff-renderer.js +92 -4
  43. package/dist/utils/floating-panel.d.ts +2 -0
  44. package/dist/utils/floating-panel.js +30 -14
  45. package/dist/utils/handler-registry.d.ts +26 -10
  46. package/dist/utils/handler-registry.js +52 -16
  47. package/dist/utils/line-editor.d.ts +23 -3
  48. package/dist/utils/line-editor.js +180 -42
  49. package/dist/utils/markdown.d.ts +1 -0
  50. package/dist/utils/markdown.js +1 -1
  51. package/dist/utils/message-utils.d.ts +35 -0
  52. package/dist/utils/message-utils.js +75 -0
  53. package/dist/utils/terminal-buffer.d.ts +5 -1
  54. package/dist/utils/terminal-buffer.js +18 -2
  55. package/dist/utils/tool-interactive.d.ts +12 -0
  56. package/dist/utils/tool-interactive.js +53 -0
  57. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  58. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  60. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  61. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  62. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  63. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  64. package/examples/extensions/interactive-prompts.ts +82 -110
  65. package/examples/extensions/overlay-agent.ts +84 -38
  66. package/examples/extensions/peer-mesh.ts +450 -0
  67. package/examples/extensions/questionnaire.ts +249 -0
  68. package/examples/extensions/tmux-pane.ts +307 -0
  69. package/examples/extensions/web-access.ts +327 -0
  70. package/package.json +9 -1
  71. package/dist/extensions/overlay-agent.d.ts +0 -14
  72. package/dist/extensions/overlay-agent.js +0 -147
  73. package/examples/extensions/terminal-buffer.ts +0 -184
  74. /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
package/dist/core.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Core kernel — the minimum viable agent-sh.
3
3
  *
4
- * Wires up EventBus + ContextManager + AgentBackend without any frontend.
4
+ * Wires up EventBus + ContextManager without any frontend or agent backend.
5
5
  * Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
6
6
  * subscribing to bus events.
7
7
  *
8
- * The default backend (AgentLoop) is created eagerly but wired lazily —
9
- * extensions can register alternative backends via agent:register-backend
10
- * before activateBackend() is called.
8
+ * Agent backends are loaded as extensions and register themselves via
9
+ * the agent:register-backend bus event. The built-in "ash" backend is
10
+ * loaded from src/extensions/agent-backend.ts.
11
11
  *
12
12
  * Usage:
13
13
  * import { createCore } from "agent-sh";
@@ -18,15 +18,13 @@
18
18
  */
19
19
  import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
- import { AgentLoop } from "./agent/agent-loop.js";
22
- import { LlmClient } from "./utils/llm-client.js";
23
21
  import { setPalette } from "./utils/palette.js";
24
22
  import * as streamTransform from "./utils/stream-transform.js";
25
23
  import * as settingsMod from "./settings.js";
26
- import { resolveProvider, getProviderNames } from "./settings.js";
27
24
  import { HandlerRegistry } from "./utils/handler-registry.js";
28
25
  import { TerminalBuffer } from "./utils/terminal-buffer.js";
29
- import { FloatingPanel } from "./utils/floating-panel.js";
26
+ import crypto from "node:crypto";
27
+ import { DefaultCompositor, StdoutSurface } from "./utils/compositor.js";
30
28
  // Re-export types that library consumers need
31
29
  export { EventBus } from "./event-bus.js";
32
30
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
@@ -36,92 +34,26 @@ export function createCore(config) {
36
34
  const bus = new EventBus();
37
35
  const handlers = new HandlerRegistry();
38
36
  const contextManager = new ContextManager(bus, handlers);
39
- // ── Resolve provider ─────────────────────────────────────────
37
+ const instanceId = crypto.randomBytes(2).toString("hex");
40
38
  const settings = settingsMod.getSettings();
41
- let activeProvider = null;
42
- const providerRegistry = new Map();
43
- for (const name of getProviderNames()) {
44
- const p = resolveProvider(name);
45
- if (p)
46
- providerRegistry.set(name, p);
47
- }
48
- const providerName = config.provider ?? settings.defaultProvider;
49
- if (providerName) {
50
- activeProvider = providerRegistry.get(providerName) ?? null;
51
- }
52
- // Build flat modes list across all providers
53
- const buildModes = () => {
54
- const allModes = [];
55
- for (const [id, p] of providerRegistry) {
56
- if (!p.apiKey)
57
- continue;
58
- for (const model of p.models) {
59
- const mc = p.modelCapabilities?.get(model);
60
- allModes.push({
61
- model,
62
- provider: id,
63
- providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
64
- contextWindow: mc?.contextWindow ?? p.contextWindow,
65
- reasoning: mc?.reasoning,
66
- supportsReasoningEffort: p.supportsReasoningEffort,
67
- });
68
- }
69
- }
70
- return allModes;
71
- };
72
- const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
73
- const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
74
- const effectiveModel = config.model ?? activeProvider?.defaultModel;
75
- let modes = buildModes();
76
- if (modes.length === 0 && effectiveApiKey && effectiveModel) {
77
- modes = [{ model: effectiveModel }];
78
- }
79
- const initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
80
- // Shared LLM client — used by agent loop AND fast-path features
81
- let llmClient = null;
82
- if (effectiveApiKey) {
83
- if (!effectiveModel) {
84
- throw new Error("No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json");
85
- }
86
- llmClient = new LlmClient({
87
- apiKey: effectiveApiKey,
88
- baseURL: effectiveBaseURL,
89
- model: effectiveModel,
90
- });
91
- }
92
- // Create AgentLoop (unwired — tools only, no bus subscriptions yet)
93
- const agentLoop = llmClient
94
- ? new AgentLoop(bus, contextManager, llmClient, handlers, modes, initialModeIndex)
95
- : null;
39
+ // Expose raw CLI config so the agent backend extension can resolve
40
+ // providers and create the LLM client.
41
+ handlers.define("config:get-shell-config", () => config);
96
42
  const backends = new Map();
97
43
  let activeBackendName = null;
98
44
  const activateByName = async (name, silent = false) => {
99
- const backend = name === "agent-sh" ? null : backends.get(name);
100
- if (name !== "agent-sh" && !backend) {
45
+ const backend = backends.get(name);
46
+ if (!backend) {
101
47
  bus.emit("ui:error", { message: `Unknown backend: ${name}` });
102
48
  return;
103
49
  }
104
50
  // Deactivate current backend
105
- if (activeBackendName === "agent-sh") {
106
- agentLoop?.unwire();
107
- }
108
- else if (activeBackendName) {
51
+ if (activeBackendName) {
109
52
  backends.get(activeBackendName)?.kill();
110
53
  }
111
54
  // Activate new backend
112
- if (name === "agent-sh") {
113
- if (!agentLoop) {
114
- bus.emit("ui:error", { message: "No LLM provider configured for built-in backend" });
115
- return;
116
- }
117
- agentLoop.wire();
118
- activeBackendName = "agent-sh";
119
- bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: llmClient?.model, provider: activeProvider?.id, contextWindow: activeProvider?.contextWindow });
120
- }
121
- else {
122
- await backend.start?.();
123
- activeBackendName = name;
124
- }
55
+ await backend.start?.();
56
+ activeBackendName = name;
125
57
  if (!silent) {
126
58
  bus.emit("ui:info", { message: `Backend: ${name}` });
127
59
  }
@@ -134,104 +66,22 @@ export function createCore(config) {
134
66
  activateByName(name);
135
67
  });
136
68
  bus.on("config:list-backends", () => {
137
- const names = [];
138
- if (agentLoop)
139
- names.push("agent-sh");
140
- for (const name of backends.keys())
141
- names.push(name);
69
+ const names = [...backends.keys()];
142
70
  const list = names
143
71
  .map((n) => n === activeBackendName ? `${n} (active)` : n)
144
72
  .join(", ");
145
73
  bus.emit("ui:info", { message: `Backends: ${list}` });
146
74
  });
147
- bus.onPipe("config:get-backends", (payload) => {
148
- const names = [];
149
- if (agentLoop)
150
- names.push("agent-sh");
151
- for (const name of backends.keys())
152
- names.push(name);
75
+ bus.onPipe("config:get-backends", () => {
76
+ const names = [...backends.keys()];
153
77
  return { names, active: activeBackendName };
154
78
  });
155
- // ── Runtime provider management ──────────────────────────────
156
- bus.on("provider:register", (p) => {
157
- const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
158
- const modelIds = [];
159
- const caps = new Map();
160
- for (const m of rawModels) {
161
- if (typeof m === "string") {
162
- modelIds.push(m);
163
- }
164
- else {
165
- modelIds.push(m.id);
166
- caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow });
167
- }
168
- }
169
- providerRegistry.set(p.id, {
170
- id: p.id,
171
- apiKey: p.apiKey,
172
- baseURL: p.baseURL,
173
- defaultModel: p.defaultModel,
174
- models: modelIds,
175
- supportsReasoningEffort: p.supportsReasoningEffort,
176
- modelCapabilities: caps.size > 0 ? caps : undefined,
177
- });
178
- // Push registered models into the agent loop so they appear in
179
- // autocomplete and are selectable via /model.
180
- const addModes = modelIds.map((m) => {
181
- const mc = caps.get(m);
182
- return {
183
- model: m,
184
- provider: p.id,
185
- providerConfig: { apiKey: p.apiKey ?? "", baseURL: p.baseURL },
186
- contextWindow: mc?.contextWindow,
187
- reasoning: mc?.reasoning,
188
- supportsReasoningEffort: p.supportsReasoningEffort,
189
- };
190
- });
191
- bus.emit("config:add-modes", { modes: addModes });
192
- });
193
- bus.on("config:switch-provider", ({ provider: name }) => {
194
- const p = providerRegistry.get(name);
195
- if (!p) {
196
- bus.emit("ui:error", { message: `Unknown provider: ${name}` });
197
- return;
198
- }
199
- if (!llmClient) {
200
- bus.emit("ui:error", { message: `Provider switching requires internal agent mode` });
201
- return;
202
- }
203
- const newApiKey = p.apiKey;
204
- if (!newApiKey) {
205
- bus.emit("ui:error", { message: `Provider "${name}" has no API key configured` });
206
- return;
207
- }
208
- const switchModel = p.defaultModel ?? p.models[0];
209
- if (!switchModel) {
210
- bus.emit("ui:error", { message: `Provider "${name}" has no models configured` });
211
- return;
212
- }
213
- llmClient.reconfigure({
214
- apiKey: newApiKey,
215
- baseURL: p.baseURL,
216
- model: switchModel,
217
- });
218
- const newModes = p.models.map((m) => {
219
- const mc = p.modelCapabilities?.get(m);
220
- return {
221
- model: m,
222
- provider: name,
223
- providerConfig: { apiKey: newApiKey, baseURL: p.baseURL },
224
- contextWindow: mc?.contextWindow ?? p.contextWindow,
225
- reasoning: mc?.reasoning,
226
- supportsReasoningEffort: p.supportsReasoningEffort,
227
- };
228
- });
229
- bus.emit("config:set-modes", { modes: newModes });
230
- activeProvider = p;
231
- bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
232
- bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
233
- bus.emit("config:changed", {});
234
- });
79
+ // ── Compositor ──────────────────────────────────────────────
80
+ const compositor = new DefaultCompositor();
81
+ const stdoutSurface = new StdoutSurface();
82
+ compositor.setDefault("agent", stdoutSurface);
83
+ compositor.setDefault("query", stdoutSurface);
84
+ compositor.setDefault("status", stdoutSurface);
235
85
  // ── Lazy singleton terminal buffer ──────────────────────────
236
86
  let terminalBufferSingleton; // undefined = not yet created
237
87
  const getTerminalBuffer = () => {
@@ -243,23 +93,17 @@ export function createCore(config) {
243
93
  return {
244
94
  bus,
245
95
  contextManager,
246
- llmClient,
96
+ handlers,
247
97
  activateBackend() {
248
98
  // Silent — backend info is shown in the startup banner.
249
99
  // Runtime switches (config:switch-backend) still emit ui:info.
100
+ if (backends.size === 0)
101
+ return;
250
102
  const preferred = settings.defaultBackend;
251
103
  if (preferred && backends.has(preferred)) {
252
104
  activateByName(preferred, true);
253
105
  }
254
- else if (backends.size > 0 && !agentLoop) {
255
- activateByName(backends.keys().next().value, true);
256
- }
257
- else if (agentLoop) {
258
- agentLoop.wire();
259
- activeBackendName = "agent-sh";
260
- bus.emit("agent:info", { name: "agent-sh", version: "0.4", model: llmClient?.model, provider: activeProvider?.id, contextWindow: activeProvider?.contextWindow });
261
- }
262
- else if (backends.size > 0) {
106
+ else {
263
107
  activateByName(backends.keys().next().value, true);
264
108
  }
265
109
  },
@@ -304,30 +148,68 @@ export function createCore(config) {
304
148
  return {
305
149
  bus,
306
150
  contextManager,
307
- llmClient,
151
+ instanceId,
308
152
  quit: opts.quit,
309
153
  setPalette,
310
154
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
311
155
  createFencedBlockTransform: (o) => streamTransform.createFencedBlockTransform(bus, o),
312
156
  getExtensionSettings: settingsMod.getExtensionSettings,
313
157
  registerCommand: (name, description, handler) => bus.emit("command:register", { name, description, handler }),
314
- registerTool: (tool) => agentLoop?.registerTool(tool),
315
- getTools: () => agentLoop?.getTools() ?? [],
158
+ registerTool: (tool) => bus.emit("agent:register-tool", { tool }),
159
+ unregisterTool: (name) => bus.emit("agent:unregister-tool", { name }),
160
+ getTools: () => bus.emitPipe("agent:get-tools", { tools: [] }).tools,
161
+ registerInstruction: (name, text) => bus.emit("agent:register-instruction", { name, text }),
162
+ removeInstruction: (name) => bus.emit("agent:remove-instruction", { name }),
316
163
  define: (name, fn) => handlers.define(name, fn),
317
164
  advise: (name, wrapper) => handlers.advise(name, wrapper),
318
165
  call: (name, ...args) => handlers.call(name, ...args),
319
166
  get terminalBuffer() { return getTerminalBuffer(); },
320
- createFloatingPanel: (config) => {
321
- const tb = config.dimBackground !== false ? getTerminalBuffer() : null;
322
- return new FloatingPanel(bus, { ...config, terminalBuffer: tb ?? undefined });
167
+ compositor,
168
+ createRemoteSession: (opts) => {
169
+ const { surface } = opts;
170
+ const cleanups = [];
171
+ let active = true;
172
+ // Redirect all render streams
173
+ cleanups.push(compositor.redirect("agent", surface));
174
+ cleanups.push(compositor.redirect("query", surface));
175
+ cleanups.push(compositor.redirect("status", surface));
176
+ // Keep shell interactive
177
+ cleanups.push(handlers.advise("shell:on-processing-start", (next) => active ? undefined : next()));
178
+ cleanups.push(handlers.advise("shell:on-processing-done", (next) => active ? undefined : next()));
179
+ // Suppress chrome
180
+ if (opts.suppressBorders !== false) {
181
+ cleanups.push(handlers.advise("tui:response-border", (next, ...a) => active ? null : next(...a)));
182
+ }
183
+ if (opts.suppressQueryBox) {
184
+ cleanups.push(handlers.advise("tui:render-user-query", (next, ...a) => active ? [] : next(...a)));
185
+ }
186
+ if (opts.suppressUsage !== false) {
187
+ cleanups.push(handlers.advise("tui:render-usage", (next, ...a) => active ? "" : next(...a)));
188
+ }
189
+ if (opts.interactive) {
190
+ cleanups.push(handlers.advise("dynamic-context:build", (next) => {
191
+ const base = next();
192
+ return active ? base + "\ninteractive-session: true\n" : base;
193
+ }));
194
+ }
195
+ return {
196
+ submit(query) { bus.emit("agent:submit", { query }); },
197
+ get surface() { return surface; },
198
+ get active() { return active; },
199
+ close() {
200
+ if (!active)
201
+ return;
202
+ active = false;
203
+ for (const fn of cleanups.reverse())
204
+ fn();
205
+ cleanups.length = 0;
206
+ },
207
+ };
323
208
  },
324
209
  };
325
210
  },
326
211
  kill() {
327
- if (activeBackendName === "agent-sh") {
328
- agentLoop?.kill();
329
- }
330
- else if (activeBackendName) {
212
+ if (activeBackendName) {
331
213
  backends.get(activeBackendName)?.kill();
332
214
  }
333
215
  },
@@ -115,10 +115,14 @@ export interface ShellEvents {
115
115
  "agent:tool-output-chunk": {
116
116
  chunk: string;
117
117
  };
118
+ "tool:interactive-start": Record<string, never>;
119
+ "tool:interactive-end": Record<string, never>;
118
120
  "permission:request": {
119
121
  kind: string;
120
122
  title: string;
121
123
  metadata: Record<string, unknown>;
124
+ /** Interactive UI capability — available when the built-in agent is active. */
125
+ ui?: unknown;
122
126
  decision: Record<string, unknown>;
123
127
  };
124
128
  "command:register": {
@@ -218,6 +222,10 @@ export interface ShellEvents {
218
222
  "config:switch-provider": {
219
223
  provider: string;
220
224
  };
225
+ "config:get-initial-modes": {
226
+ modes: AgentMode[];
227
+ initialModeIndex: number;
228
+ };
221
229
  "config:set-modes": {
222
230
  modes: AgentMode[];
223
231
  };
@@ -237,6 +245,22 @@ export interface ShellEvents {
237
245
  /** Provider supports the reasoning_effort parameter. Default: true. */
238
246
  supportsReasoningEffort?: boolean;
239
247
  };
248
+ "agent:register-tool": {
249
+ tool: import("./agent/types.js").ToolDefinition;
250
+ };
251
+ "agent:unregister-tool": {
252
+ name: string;
253
+ };
254
+ "agent:get-tools": {
255
+ tools: import("./agent/types.js").ToolDefinition[];
256
+ };
257
+ "agent:register-instruction": {
258
+ name: string;
259
+ text: string;
260
+ };
261
+ "agent:remove-instruction": {
262
+ name: string;
263
+ };
240
264
  "autocomplete:request": {
241
265
  buffer: string;
242
266
  /** Parsed slash command name (e.g. "/backend"), or null if not a command. */
@@ -291,6 +315,8 @@ export declare class EventBus {
291
315
  emitTransform<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): void;
292
316
  /** Register a transform listener for a pipeline event. */
293
317
  onPipe<K extends keyof ShellEvents>(event: K, fn: PipeListener<ShellEvents[K]>): void;
318
+ /** Remove a transform listener from a pipeline event. */
319
+ offPipe<K extends keyof ShellEvents>(event: K, fn: PipeListener<ShellEvents[K]>): void;
294
320
  /**
295
321
  * Emit a pipeline event — each registered pipe listener receives the
296
322
  * output of the previous one. Returns the final transformed payload.
package/dist/event-bus.js CHANGED
@@ -49,6 +49,15 @@ export class EventBus {
49
49
  }
50
50
  listeners.push(fn);
51
51
  }
52
+ /** Remove a transform listener from a pipeline event. */
53
+ offPipe(event, fn) {
54
+ const listeners = this.pipeListeners.get(event);
55
+ if (!listeners)
56
+ return;
57
+ const idx = listeners.indexOf(fn);
58
+ if (idx !== -1)
59
+ listeners.splice(idx, 1);
60
+ }
52
61
  /**
53
62
  * Emit a pipeline event — each registered pipe listener receives the
54
63
  * output of the previous one. Returns the final transformed payload.
@@ -60,7 +69,17 @@ export class EventBus {
60
69
  return payload;
61
70
  let result = payload;
62
71
  for (const fn of listeners) {
63
- result = fn(result);
72
+ try {
73
+ const out = fn(result);
74
+ if (out && typeof out.then === "function") {
75
+ console.error(`[event-bus] Warning: async handler in sync pipe "${String(event)}" — use onPipeAsync instead`);
76
+ continue;
77
+ }
78
+ result = out;
79
+ }
80
+ catch (err) {
81
+ console.error(`[event-bus] Pipe handler error in "${String(event)}":`, err instanceof Error ? err.message : err);
82
+ }
64
83
  }
65
84
  return result;
66
85
  }
@@ -14,3 +14,8 @@ import type { ExtensionContext } from "./types.js";
14
14
  * Errors are non-fatal — logged via ui:error and skipped.
15
15
  */
16
16
  export declare function loadExtensions(ctx: ExtensionContext, cliExtensions?: string[]): Promise<string[]>;
17
+ /**
18
+ * Reload user extensions (from ~/.agent-sh/extensions/).
19
+ * Tears down old registrations, busts the module cache, and re-activates.
20
+ */
21
+ export declare function reloadExtensions(ctx: ExtensionContext): Promise<string[]>;
@@ -17,6 +17,63 @@ async function ensureTsSupport() {
17
17
  // tsx not available — TS extensions will fail with a clear error
18
18
  }
19
19
  }
20
+ /**
21
+ * Wrap an ExtensionContext to track all registrations (bus.on, bus.onPipe,
22
+ * advise, command:register). Returns the wrapped context and a dispose()
23
+ * function that tears down everything registered through it.
24
+ */
25
+ function createScopedContext(ctx) {
26
+ const cleanups = [];
27
+ const bus = ctx.bus;
28
+ const scopedBus = Object.create(bus);
29
+ // Track bus.on registrations
30
+ scopedBus.on = ((event, fn) => {
31
+ bus.on(event, fn);
32
+ cleanups.push(() => bus.off(event, fn));
33
+ });
34
+ // Track bus.onPipe registrations
35
+ scopedBus.onPipe = ((event, fn) => {
36
+ bus.onPipe(event, fn);
37
+ cleanups.push(() => bus.offPipe(event, fn));
38
+ });
39
+ // Track advise registrations
40
+ const scopedAdvise = (name, wrapper) => {
41
+ const unadvise = ctx.advise(name, wrapper);
42
+ cleanups.push(unadvise);
43
+ return unadvise;
44
+ };
45
+ // Track instruction registrations
46
+ const scopedRegisterInstruction = (name, text) => {
47
+ ctx.registerInstruction(name, text);
48
+ cleanups.push(() => ctx.removeInstruction(name));
49
+ };
50
+ // Track tool registrations
51
+ const scopedRegisterTool = (tool) => {
52
+ ctx.registerTool(tool);
53
+ cleanups.push(() => ctx.unregisterTool(tool.name));
54
+ };
55
+ const scoped = {
56
+ ...ctx,
57
+ bus: scopedBus,
58
+ advise: scopedAdvise,
59
+ registerInstruction: scopedRegisterInstruction,
60
+ removeInstruction: ctx.removeInstruction,
61
+ registerTool: scopedRegisterTool,
62
+ unregisterTool: ctx.unregisterTool,
63
+ };
64
+ const dispose = () => {
65
+ for (const fn of cleanups) {
66
+ try {
67
+ fn();
68
+ }
69
+ catch { /* ignore */ }
70
+ }
71
+ cleanups.length = 0;
72
+ };
73
+ return { scoped, dispose };
74
+ }
75
+ // Track disposers for user extensions so reload can tear them down
76
+ const extensionDisposers = new Map();
20
77
  /**
21
78
  * Load extensions from three sources (merged, deduplicated):
22
79
  *
@@ -43,19 +100,32 @@ export async function loadExtensions(ctx, cliExtensions) {
43
100
  specifiers.push(...settings.extensions);
44
101
  }
45
102
  // 3. ~/.agent-sh/extensions/ directory
103
+ const userSpecifiers = await discoverUserExtensions();
104
+ specifiers.push(...userSpecifiers);
105
+ // Deduplicate
106
+ const seen = new Set();
107
+ const unique = specifiers.filter((s) => {
108
+ if (seen.has(s))
109
+ return false;
110
+ seen.add(s);
111
+ return true;
112
+ });
113
+ // Load each extension (user extensions get scoped contexts for reloadability)
114
+ const loaded = await loadSpecifiers(unique, ctx, false, userSpecifiers);
115
+ return loaded;
116
+ }
117
+ async function discoverUserExtensions() {
118
+ const specifiers = [];
46
119
  try {
47
120
  const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
48
121
  for (const entry of entries) {
49
122
  const fullPath = path.join(EXT_DIR, entry.name);
50
- // Resolve symlinks to check if they point to directories
51
123
  const isDir = entry.isDirectory() ||
52
124
  (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
53
125
  if (isDir) {
54
- // Directory extension: look for index.{ts,js,mjs,...}
55
126
  const indexFile = await findIndex(fullPath);
56
- if (indexFile) {
127
+ if (indexFile)
57
128
  specifiers.push(indexFile);
58
- }
59
129
  }
60
130
  else if (SCRIPT_EXTS.some((ext) => entry.name.endsWith(ext))) {
61
131
  specifiers.push(fullPath);
@@ -65,22 +135,22 @@ export async function loadExtensions(ctx, cliExtensions) {
65
135
  catch {
66
136
  // Directory doesn't exist — no user extensions
67
137
  }
68
- // Deduplicate
69
- const seen = new Set();
70
- const unique = specifiers.filter((s) => {
71
- if (seen.has(s))
72
- return false;
73
- seen.add(s);
74
- return true;
75
- });
76
- // Load each extension
138
+ return specifiers;
139
+ }
140
+ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
141
+ const userSet = new Set(userSpecifiers ?? []);
77
142
  const loaded = [];
78
- for (const specifier of unique) {
143
+ for (const specifier of specifiers) {
79
144
  try {
80
- const importPath = await resolveSpecifier(specifier);
145
+ let importPath = await resolveSpecifier(specifier);
81
146
  if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
82
147
  await ensureTsSupport();
83
148
  }
149
+ // Append timestamp query to bust Node's module cache on reload
150
+ if (bustCache) {
151
+ const sep = importPath.includes("?") ? "&" : "?";
152
+ importPath += `${sep}t=${Date.now()}`;
153
+ }
84
154
  const mod = await import(importPath);
85
155
  // tsx may double-wrap default exports: mod.default.default
86
156
  const activate = typeof mod.default === "function"
@@ -89,10 +159,19 @@ export async function loadExtensions(ctx, cliExtensions) {
89
159
  ? mod.default.default
90
160
  : mod.activate;
91
161
  if (typeof activate === "function") {
92
- activate(ctx);
93
- // Extract a short name from the specifier
94
162
  const base = path.basename(specifier).replace(/\.(ts|js|mjs|mts|tsx)$/, "");
95
163
  const name = base === "index" ? path.basename(path.dirname(specifier)) : base;
164
+ // User extensions get a scoped context so /reload can tear them down
165
+ if (userSet.has(specifier)) {
166
+ // Dispose previous load if reloading
167
+ extensionDisposers.get(name)?.();
168
+ const { scoped, dispose } = createScopedContext(ctx);
169
+ activate(scoped);
170
+ extensionDisposers.set(name, dispose);
171
+ }
172
+ else {
173
+ activate(ctx);
174
+ }
96
175
  loaded.push(name);
97
176
  }
98
177
  }
@@ -104,6 +183,14 @@ export async function loadExtensions(ctx, cliExtensions) {
104
183
  }
105
184
  return loaded;
106
185
  }
186
+ /**
187
+ * Reload user extensions (from ~/.agent-sh/extensions/).
188
+ * Tears down old registrations, busts the module cache, and re-activates.
189
+ */
190
+ export async function reloadExtensions(ctx) {
191
+ const specifiers = await discoverUserExtensions();
192
+ return loadSpecifiers(specifiers, ctx, true, specifiers);
193
+ }
107
194
  /**
108
195
  * Find an index file in a directory extension.
109
196
  */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Built-in agent backend extension.
3
+ *
4
+ * Owns the full LLM lifecycle:
5
+ * 1. Resolves providers from settings + CLI config
6
+ * 2. Creates and manages the LlmClient
7
+ * 3. Builds mode list for model cycling
8
+ * 4. Creates AgentLoop and registers it as the "ash" backend
9
+ * 5. Handles runtime provider switching and provider registration
10
+ * 6. Exposes llm:get-client handler for other extensions (e.g. command-suggest)
11
+ */
12
+ import type { ExtensionContext } from "../types.js";
13
+ export default function agentBackend(ctx: ExtensionContext): void;