agent-sh 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +88 -6
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -89,6 +89,28 @@ export interface ShellEvents {
89
89
  }>;
90
90
  }>;
91
91
  };
92
+ "agent:tool-batch-complete": {
93
+ results: Array<{
94
+ name: string;
95
+ isError: boolean;
96
+ errorSummary?: string;
97
+ }>;
98
+ };
99
+ "conversation:message-appended": {
100
+ role: "user" | "assistant" | "tool" | "system";
101
+ content: string;
102
+ /** For role="tool": name of the tool whose result this is. */
103
+ toolName?: string;
104
+ /** For role="tool": parsed arguments passed to the tool. */
105
+ toolArgs?: Record<string, unknown>;
106
+ /** For role="tool": whether the tool errored. */
107
+ isError?: boolean;
108
+ };
109
+ "conversation:after-compact": {
110
+ beforeTokens: number;
111
+ afterTokens: number;
112
+ evictedCount: number;
113
+ };
92
114
  "agent:tool-started": {
93
115
  title: string;
94
116
  toolCallId?: string;
@@ -115,10 +137,24 @@ export interface ShellEvents {
115
137
  "agent:tool-output-chunk": {
116
138
  chunk: string;
117
139
  };
140
+ "agent:subagent-started": {
141
+ taskId: string;
142
+ task: string;
143
+ };
144
+ "agent:subagent-completed": {
145
+ taskId: string;
146
+ task: string;
147
+ result: string;
148
+ isError: boolean;
149
+ };
150
+ "tool:interactive-start": Record<string, never>;
151
+ "tool:interactive-end": Record<string, never>;
118
152
  "permission:request": {
119
153
  kind: string;
120
154
  title: string;
121
155
  metadata: Record<string, unknown>;
156
+ /** Interactive UI capability — available when the built-in agent is active. */
157
+ ui?: unknown;
122
158
  decision: Record<string, unknown>;
123
159
  };
124
160
  "command:register": {
@@ -126,6 +162,9 @@ export interface ShellEvents {
126
162
  description: string;
127
163
  handler: (args: string) => Promise<void> | void;
128
164
  };
165
+ "command:unregister": {
166
+ name: string;
167
+ };
129
168
  "command:execute": {
130
169
  name: string;
131
170
  args: string;
@@ -139,6 +178,10 @@ export interface ShellEvents {
139
178
  "ui:suggestion": {
140
179
  text: string;
141
180
  };
181
+ "compositor:write": {
182
+ stream: string;
183
+ text: string;
184
+ };
142
185
  "input:keypress": {
143
186
  key: string;
144
187
  };
@@ -178,8 +221,7 @@ export interface ShellEvents {
178
221
  "agent:compact-request": Record<string, never>;
179
222
  "context:get-stats": {
180
223
  activeTokens: number;
181
- nuclearEntries: number;
182
- recallArchiveSize: number;
224
+ totalTokens: number;
183
225
  budgetTokens: number;
184
226
  };
185
227
  "agent:register-backend": {
@@ -218,12 +260,18 @@ export interface ShellEvents {
218
260
  "config:switch-provider": {
219
261
  provider: string;
220
262
  };
263
+ "config:get-initial-modes": {
264
+ modes: AgentMode[];
265
+ initialModeIndex: number;
266
+ };
221
267
  "config:set-modes": {
222
268
  modes: AgentMode[];
269
+ activeIndex?: number;
223
270
  };
224
271
  "config:add-modes": {
225
272
  modes: AgentMode[];
226
273
  };
274
+ "core:extensions-loaded": Record<string, never>;
227
275
  "provider:register": {
228
276
  id: string;
229
277
  apiKey?: string;
@@ -237,6 +285,39 @@ export interface ShellEvents {
237
285
  /** Provider supports the reasoning_effort parameter. Default: true. */
238
286
  supportsReasoningEffort?: boolean;
239
287
  };
288
+ "agent:register-tool": {
289
+ tool: import("./agent/types.js").ToolDefinition;
290
+ extensionName?: string;
291
+ };
292
+ "agent:unregister-tool": {
293
+ name: string;
294
+ };
295
+ "agent:get-tools": {
296
+ tools: import("./agent/types.js").ToolDefinition[];
297
+ };
298
+ "agent:register-instruction": {
299
+ name: string;
300
+ text: string;
301
+ extensionName: string;
302
+ };
303
+ "agent:remove-instruction": {
304
+ name: string;
305
+ };
306
+ "agent:register-skill": {
307
+ name: string;
308
+ description: string;
309
+ filePath: string;
310
+ extensionName: string;
311
+ };
312
+ "agent:remove-skill": {
313
+ name: string;
314
+ };
315
+ "banner:collect": {
316
+ sections: Array<{
317
+ label: string;
318
+ items: string[];
319
+ }>;
320
+ };
240
321
  "autocomplete:request": {
241
322
  buffer: string;
242
323
  /** Parsed slash command name (e.g. "/backend"), or null if not a command. */
@@ -291,6 +372,8 @@ export declare class EventBus {
291
372
  emitTransform<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): void;
292
373
  /** Register a transform listener for a pipeline event. */
293
374
  onPipe<K extends keyof ShellEvents>(event: K, fn: PipeListener<ShellEvents[K]>): void;
375
+ /** Remove a transform listener from a pipeline event. */
376
+ offPipe<K extends keyof ShellEvents>(event: K, fn: PipeListener<ShellEvents[K]>): void;
294
377
  /**
295
378
  * Emit a pipeline event — each registered pipe listener receives the
296
379
  * 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
  }
@@ -25,7 +25,8 @@ export declare function executeCommand(opts: {
25
25
  done: Promise<void>;
26
26
  };
27
27
  /**
28
- * Kill a running session's process group.
29
- * Sends SIGTERM first, then SIGKILL after 5 seconds.
28
+ * Kill a running session's process group: SIGTERM, then SIGKILL after 5s.
29
+ * Returns a cleanup that cancels the pending SIGKILL callers should invoke
30
+ * it once the process has exited.
30
31
  */
31
- export declare function killSession(session: ExecutorSession): void;
32
+ export declare function killSession(session: ExecutorSession): () => void;
package/dist/executor.js CHANGED
@@ -60,14 +60,15 @@ export function executeCommand(opts) {
60
60
  };
61
61
  child.stdout?.on("data", handleData);
62
62
  child.stderr?.on("data", handleData);
63
- // Timeout handler
63
+ let cancelKill;
64
64
  const timer = setTimeout(() => {
65
65
  if (!session.done) {
66
- killSession(session);
66
+ cancelKill = killSession(session);
67
67
  }
68
68
  }, timeout);
69
69
  child.on("exit", (code, signal) => {
70
70
  clearTimeout(timer);
71
+ cancelKill?.();
71
72
  session.exitCode = code ?? (signal ? -1 : null);
72
73
  session.done = true;
73
74
  session.process = null;
@@ -75,6 +76,7 @@ export function executeCommand(opts) {
75
76
  });
76
77
  child.on("error", (err) => {
77
78
  clearTimeout(timer);
79
+ cancelKill?.();
78
80
  if (!session.done) {
79
81
  session.exitCode = -1;
80
82
  session.output += `\nProcess error: ${err.message}`;
@@ -86,31 +88,32 @@ export function executeCommand(opts) {
86
88
  return { session, done };
87
89
  }
88
90
  /**
89
- * Kill a running session's process group.
90
- * Sends SIGTERM first, then SIGKILL after 5 seconds.
91
+ * Kill a running session's process group: SIGTERM, then SIGKILL after 5s.
92
+ * Returns a cleanup that cancels the pending SIGKILL callers should invoke
93
+ * it once the process has exited.
91
94
  */
92
95
  export function killSession(session) {
93
96
  const proc = session.process;
94
97
  if (!proc || !proc.pid)
95
- return;
98
+ return () => { };
96
99
  try {
97
- // Kill the entire process group
98
100
  process.kill(-proc.pid, "SIGTERM");
99
101
  }
100
- catch {
101
- // Process may already be dead
102
- }
103
- // Fallback: SIGKILL after 5 seconds
102
+ catch { }
103
+ let settled = false;
104
104
  const fallback = setTimeout(() => {
105
- if (!session.done && proc.pid) {
105
+ if (!settled && !session.done && proc.pid) {
106
106
  try {
107
107
  process.kill(-proc.pid, "SIGKILL");
108
108
  }
109
- catch {
110
- // Ignore
111
- }
109
+ catch { }
112
110
  }
113
111
  }, 5000);
114
- // Don't let the timer keep the process alive
115
112
  fallback.unref();
113
+ return () => {
114
+ if (!settled) {
115
+ settled = true;
116
+ clearTimeout(fallback);
117
+ }
118
+ };
116
119
  }
@@ -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,78 @@ 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, extensionName) {
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 — extension name captured in scope
46
+ const scopedRegisterInstruction = (name, text) => {
47
+ bus.emit("agent:register-instruction", { name, text, extensionName });
48
+ cleanups.push(() => bus.emit("agent:remove-instruction", { name }));
49
+ };
50
+ // Track skill registrations — extension name captured in scope
51
+ const scopedRegisterSkill = (name, description, filePath) => {
52
+ bus.emit("agent:register-skill", { name, description, filePath, extensionName });
53
+ cleanups.push(() => bus.emit("agent:remove-skill", { name }));
54
+ };
55
+ // Track tool registrations — extension name captured in scope
56
+ const scopedRegisterTool = (tool) => {
57
+ bus.emit("agent:register-tool", { tool, extensionName });
58
+ cleanups.push(() => bus.emit("agent:unregister-tool", { name: tool.name }));
59
+ };
60
+ // Track slash command registrations — without this, reloading an
61
+ // extension stacks its commands (old `/status` + new `/status`) in
62
+ // the slash-commands registry.
63
+ const scopedRegisterCommand = (name, description, handler) => {
64
+ ctx.registerCommand(name, description, handler);
65
+ cleanups.push(() => bus.emit("command:unregister", { name }));
66
+ };
67
+ const scoped = {
68
+ ...ctx,
69
+ bus: scopedBus,
70
+ advise: scopedAdvise,
71
+ registerInstruction: scopedRegisterInstruction,
72
+ removeInstruction: ctx.removeInstruction,
73
+ registerSkill: scopedRegisterSkill,
74
+ removeSkill: ctx.removeSkill,
75
+ registerTool: scopedRegisterTool,
76
+ unregisterTool: ctx.unregisterTool,
77
+ registerCommand: scopedRegisterCommand,
78
+ };
79
+ const dispose = () => {
80
+ for (const fn of cleanups) {
81
+ try {
82
+ fn();
83
+ }
84
+ catch { /* ignore */ }
85
+ }
86
+ cleanups.length = 0;
87
+ };
88
+ return { scoped, dispose };
89
+ }
90
+ // Track disposers for user extensions so reload can tear them down
91
+ const extensionDisposers = new Map();
20
92
  /**
21
93
  * Load extensions from three sources (merged, deduplicated):
22
94
  *
@@ -43,19 +115,39 @@ export async function loadExtensions(ctx, cliExtensions) {
43
115
  specifiers.push(...settings.extensions);
44
116
  }
45
117
  // 3. ~/.agent-sh/extensions/ directory
118
+ const userSpecifiers = await discoverUserExtensions();
119
+ specifiers.push(...userSpecifiers);
120
+ // Deduplicate
121
+ const seen = new Set();
122
+ const unique = specifiers.filter((s) => {
123
+ if (seen.has(s))
124
+ return false;
125
+ seen.add(s);
126
+ return true;
127
+ });
128
+ // Load each extension (user extensions get scoped contexts for reloadability)
129
+ const loaded = await loadSpecifiers(unique, ctx, false, userSpecifiers);
130
+ return loaded;
131
+ }
132
+ async function discoverUserExtensions() {
133
+ const specifiers = [];
134
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
46
135
  try {
47
136
  const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
48
137
  for (const entry of entries) {
138
+ // Disable check: directory name for dir-extensions, or basename sans
139
+ // extension for file-extensions. Lets settings.json turn one off
140
+ // without renaming it.
141
+ const nameForDisable = entry.name.replace(/\.[^.]+$/, "");
142
+ if (disabled.has(nameForDisable))
143
+ continue;
49
144
  const fullPath = path.join(EXT_DIR, entry.name);
50
- // Resolve symlinks to check if they point to directories
51
145
  const isDir = entry.isDirectory() ||
52
146
  (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
53
147
  if (isDir) {
54
- // Directory extension: look for index.{ts,js,mjs,...}
55
148
  const indexFile = await findIndex(fullPath);
56
- if (indexFile) {
149
+ if (indexFile)
57
150
  specifiers.push(indexFile);
58
- }
59
151
  }
60
152
  else if (SCRIPT_EXTS.some((ext) => entry.name.endsWith(ext))) {
61
153
  specifiers.push(fullPath);
@@ -65,22 +157,22 @@ export async function loadExtensions(ctx, cliExtensions) {
65
157
  catch {
66
158
  // Directory doesn't exist — no user extensions
67
159
  }
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
160
+ return specifiers;
161
+ }
162
+ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
163
+ const userSet = new Set(userSpecifiers ?? []);
77
164
  const loaded = [];
78
- for (const specifier of unique) {
165
+ for (const specifier of specifiers) {
79
166
  try {
80
- const importPath = await resolveSpecifier(specifier);
167
+ let importPath = await resolveSpecifier(specifier);
81
168
  if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
82
169
  await ensureTsSupport();
83
170
  }
171
+ // Append timestamp query to bust Node's module cache on reload
172
+ if (bustCache) {
173
+ const sep = importPath.includes("?") ? "&" : "?";
174
+ importPath += `${sep}t=${Date.now()}`;
175
+ }
84
176
  const mod = await import(importPath);
85
177
  // tsx may double-wrap default exports: mod.default.default
86
178
  const activate = typeof mod.default === "function"
@@ -89,10 +181,25 @@ export async function loadExtensions(ctx, cliExtensions) {
89
181
  ? mod.default.default
90
182
  : mod.activate;
91
183
  if (typeof activate === "function") {
92
- activate(ctx);
93
- // Extract a short name from the specifier
94
184
  const base = path.basename(specifier).replace(/\.(ts|js|mjs|mts|tsx)$/, "");
95
185
  const name = base === "index" ? path.basename(path.dirname(specifier)) : base;
186
+ // Scoped context so /reload can tear user extensions down.
187
+ // Awaiting activate() lets extensions with async setup (e.g.
188
+ // openrouter fetching its model catalog) finish before we move
189
+ // on; a 10s outer timeout in index.ts guards against hangs.
190
+ if (userSet.has(specifier)) {
191
+ // Dispose previous load if reloading
192
+ extensionDisposers.get(name)?.();
193
+ const { scoped, dispose } = createScopedContext(ctx, name);
194
+ await activate(scoped);
195
+ extensionDisposers.set(name, dispose);
196
+ }
197
+ else {
198
+ const { scoped, dispose } = createScopedContext(ctx, name);
199
+ await activate(scoped);
200
+ // Non-user extensions aren't reloadable, but track for cleanup on shutdown
201
+ extensionDisposers.set(name, dispose);
202
+ }
96
203
  loaded.push(name);
97
204
  }
98
205
  }
@@ -104,6 +211,14 @@ export async function loadExtensions(ctx, cliExtensions) {
104
211
  }
105
212
  return loaded;
106
213
  }
214
+ /**
215
+ * Reload user extensions (from ~/.agent-sh/extensions/).
216
+ * Tears down old registrations, busts the module cache, and re-activates.
217
+ */
218
+ export async function reloadExtensions(ctx) {
219
+ const specifiers = await discoverUserExtensions();
220
+ return loadSpecifiers(specifiers, ctx, true, specifiers);
221
+ }
107
222
  /**
108
223
  * Find an index file in a directory extension.
109
224
  */
@@ -136,8 +251,17 @@ async function resolveSpecifier(specifier) {
136
251
  resolved = specifier;
137
252
  }
138
253
  else {
139
- // Bare specifier npm package
140
- return specifier;
254
+ // Distinguish bare npm specifier from a relative path lacking "./".
255
+ // Scoped packages ("@scope/pkg") contain "/" but are npm specifiers,
256
+ // so the "@" prefix takes precedence over the "/" heuristic.
257
+ if (specifier.includes("/") && !specifier.startsWith("@")) {
258
+ // Treat as relative path from cwd
259
+ resolved = path.resolve(process.cwd(), specifier);
260
+ }
261
+ else {
262
+ // Bare specifier — npm package (including @scope/pkg)
263
+ return specifier;
264
+ }
141
265
  }
142
266
  // If it's a directory, find the index file
143
267
  try {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Built-in agent backend extension.
3
+ *
4
+ * Constructs the AgentLoop synchronously with a placeholder LlmClient,
5
+ * so core handlers (history:append, system-prompt:build, conversation:*)
6
+ * are defined before user extensions activate. Mode resolution is
7
+ * deferred to `core:extensions-loaded`, giving runtime-registered
8
+ * providers (e.g. openrouter) a chance to register before we look up
9
+ * settings.defaultProvider. Without this deferral, a persisted
10
+ * `defaultProvider: "openrouter"` loses to a cold-start race and the
11
+ * backend bails silently.
12
+ */
13
+ import type { ExtensionContext } from "../types.js";
14
+ export default function agentBackend(ctx: ExtensionContext): void;