agent-sh 0.9.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 (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -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 +84 -3
  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 +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  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 +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -38,7 +38,7 @@ export type ToolResultBody = {
38
38
  maxLines?: number;
39
39
  };
40
40
  export interface ToolDisplayInfo {
41
- kind: "read" | "write" | "execute" | "search" | "display";
41
+ kind: "read" | "write" | "execute" | "search";
42
42
  locations?: {
43
43
  path: string;
44
44
  line?: number | null;
@@ -24,6 +24,23 @@ export declare class ContextManager {
24
24
  * Optional start/end restrict to a line range (1-indexed).
25
25
  */
26
26
  expand(ids: number[], start?: number, end?: number): string;
27
+ /**
28
+ * Return shell events with id > afterId, formatted as an incremental
29
+ * delta suitable for injection into conversation history. Skips
30
+ * agent-source commands (already visible in tool results). Returns
31
+ * null when nothing new exists.
32
+ *
33
+ * The motivation: resending the full <shell_context> every turn wastes
34
+ * tokens — N turns × full history = O(N²) cost for O(N) information.
35
+ * Instead we inject only new events as regular conversation messages,
36
+ * so the provider's prefix cache amortizes them to O(N).
37
+ */
38
+ getEventsSince(afterId: number): {
39
+ text: string;
40
+ lastSeq: number;
41
+ } | null;
42
+ /** Highest exchange id seen so far (0 if none). */
43
+ lastSeq(): number;
27
44
  /**
28
45
  * One-line summaries of last N exchanges.
29
46
  */
@@ -140,6 +140,43 @@ export class ContextManager {
140
140
  }
141
141
  return results.join("\n\n");
142
142
  }
143
+ /**
144
+ * Return shell events with id > afterId, formatted as an incremental
145
+ * delta suitable for injection into conversation history. Skips
146
+ * agent-source commands (already visible in tool results). Returns
147
+ * null when nothing new exists.
148
+ *
149
+ * The motivation: resending the full <shell_context> every turn wastes
150
+ * tokens — N turns × full history = O(N²) cost for O(N) information.
151
+ * Instead we inject only new events as regular conversation messages,
152
+ * so the provider's prefix cache amortizes them to O(N).
153
+ */
154
+ getEventsSince(afterId) {
155
+ const fresh = this.exchanges.filter((e) => e.id > afterId && !(e.type === "shell_command" && e.source === "agent"));
156
+ if (fresh.length === 0)
157
+ return null;
158
+ const lastSeq = this.exchanges[this.exchanges.length - 1].id;
159
+ // Apply per-type truncation so giant outputs don't blow up the turn.
160
+ const truncated = fresh.map((ex) => {
161
+ if (ex.type === "shell_command") {
162
+ const s = getSettings();
163
+ return {
164
+ ...ex,
165
+ output: truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id),
166
+ };
167
+ }
168
+ return { ...ex };
169
+ });
170
+ const body = truncated.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
171
+ return {
172
+ text: `<shell-events>\n${body}</shell-events>`,
173
+ lastSeq,
174
+ };
175
+ }
176
+ /** Highest exchange id seen so far (0 if none). */
177
+ lastSeq() {
178
+ return this.exchanges.length === 0 ? 0 : this.exchanges[this.exchanges.length - 1].id;
179
+ }
143
180
  /**
144
181
  * One-line summaries of last N exchanges.
145
182
  */
@@ -229,13 +266,9 @@ export class ContextManager {
229
266
  out += `The user interacts with a real shell (PTY) and sends you queries inline. You are there to help them with their tasks.\n`;
230
267
  out += `\n`;
231
268
  out += `IMPORTANT tool usage rules:\n`;
232
- out += `- user_shell runs commands in the user's live shell (PTY). The user sees output directly — no summary needed.\n`;
233
269
  out += `- Your internal tools (bash, read, write, ls, etc.) run in an isolated subprocess. The user CANNOT see their output.\n`;
234
- out += `- When the user asks to see, list, view, or display anything, ALWAYS use user_shell. NEVER use internal tools like ls/read/bash for display — the user won't see it.\n`;
235
270
  out += `- Only use internal tools when YOU need to reason about content silently (e.g. reading a file to answer a question about it).\n`;
236
- out += `- After a user_shell command, the user already saw the output. Do NOT repeat or summarize it.\n`;
237
271
  out += `- You can browse or search shell history with shell_recall.\n`;
238
- out += `- You can browse or search evicted conversation turns with conversation_recall.\n`;
239
272
  out += `\n`;
240
273
  this.firstPrompt = false;
241
274
  }
package/dist/core.js CHANGED
@@ -24,7 +24,11 @@ import * as settingsMod from "./settings.js";
24
24
  import { HandlerRegistry } from "./utils/handler-registry.js";
25
25
  import { TerminalBuffer } from "./utils/terminal-buffer.js";
26
26
  import crypto from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import * as os from "node:os";
27
30
  import { DefaultCompositor, StdoutSurface } from "./utils/compositor.js";
31
+ const STORAGE_ROOT = path.join(os.homedir(), ".agent-sh");
28
32
  // Re-export types that library consumers need
29
33
  export { EventBus } from "./event-bus.js";
30
34
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
@@ -34,7 +38,10 @@ export function createCore(config) {
34
38
  const bus = new EventBus();
35
39
  const handlers = new HandlerRegistry();
36
40
  const contextManager = new ContextManager(bus, handlers);
37
- const instanceId = crypto.randomBytes(2).toString("hex");
41
+ // 3 bytes = 6 hex chars, ~16M values — ample for per-lineage uniqueness and
42
+ // short enough to read/remember. Legacy content may have 16-char iids; any
43
+ // parsers should accept ≥6 hex chars.
44
+ const instanceId = crypto.randomBytes(3).toString("hex");
38
45
  const settings = settingsMod.getSettings();
39
46
  // Expose raw CLI config so the agent backend extension can resolve
40
47
  // providers and create the LLM client.
@@ -63,7 +70,12 @@ export function createCore(config) {
63
70
  backends.set(backend.name, backend);
64
71
  });
65
72
  bus.on("config:switch-backend", ({ name }) => {
66
- activateByName(name);
73
+ activateByName(name).then(() => {
74
+ if (activeBackendName === name) {
75
+ settingsMod.updateSettings({ defaultBackend: name });
76
+ bus.emit("ui:info", { message: `Saved '${name}' as default backend.` });
77
+ }
78
+ });
67
79
  });
68
80
  bus.on("config:list-backends", () => {
69
81
  const names = [...backends.keys()];
@@ -77,7 +89,7 @@ export function createCore(config) {
77
89
  return { names, active: activeBackendName };
78
90
  });
79
91
  // ── Compositor ──────────────────────────────────────────────
80
- const compositor = new DefaultCompositor();
92
+ const compositor = new DefaultCompositor(bus);
81
93
  const stdoutSurface = new StdoutSurface();
82
94
  compositor.setDefault("agent", stdoutSurface);
83
95
  compositor.setDefault("query", stdoutSurface);
@@ -145,7 +157,7 @@ export function createCore(config) {
145
157
  bus.emit("agent:cancel-request", {});
146
158
  },
147
159
  extensionContext(opts) {
148
- return {
160
+ const ctx = {
149
161
  bus,
150
162
  contextManager,
151
163
  instanceId,
@@ -154,15 +166,23 @@ export function createCore(config) {
154
166
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
155
167
  createFencedBlockTransform: (o) => streamTransform.createFencedBlockTransform(bus, o),
156
168
  getExtensionSettings: settingsMod.getExtensionSettings,
169
+ getStoragePath: (namespace) => {
170
+ const dir = path.join(STORAGE_ROOT, namespace);
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ return dir;
173
+ },
157
174
  registerCommand: (name, description, handler) => bus.emit("command:register", { name, description, handler }),
158
- registerTool: (tool) => bus.emit("agent:register-tool", { tool }),
175
+ registerTool: (tool) => bus.emit("agent:register-tool", { tool, extensionName: "" }),
159
176
  unregisterTool: (name) => bus.emit("agent:unregister-tool", { name }),
160
177
  getTools: () => bus.emitPipe("agent:get-tools", { tools: [] }).tools,
161
- registerInstruction: (name, text) => bus.emit("agent:register-instruction", { name, text }),
178
+ registerInstruction: (name, text) => bus.emit("agent:register-instruction", { name, text, extensionName: "" }),
162
179
  removeInstruction: (name) => bus.emit("agent:remove-instruction", { name }),
180
+ registerSkill: (name, description, filePath) => bus.emit("agent:register-skill", { name, description, filePath, extensionName: "" }),
181
+ removeSkill: (name) => bus.emit("agent:remove-skill", { name }),
163
182
  define: (name, fn) => handlers.define(name, fn),
164
183
  advise: (name, wrapper) => handlers.advise(name, wrapper),
165
184
  call: (name, ...args) => handlers.call(name, ...args),
185
+ list: () => handlers.list(),
166
186
  get terminalBuffer() { return getTerminalBuffer(); },
167
187
  compositor,
168
188
  createRemoteSession: (opts) => {
@@ -207,6 +227,7 @@ export function createCore(config) {
207
227
  };
208
228
  },
209
229
  };
230
+ return ctx;
210
231
  },
211
232
  kill() {
212
233
  if (activeBackendName) {
@@ -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,6 +137,16 @@ 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
+ };
118
150
  "tool:interactive-start": Record<string, never>;
119
151
  "tool:interactive-end": Record<string, never>;
120
152
  "permission:request": {
@@ -130,6 +162,9 @@ export interface ShellEvents {
130
162
  description: string;
131
163
  handler: (args: string) => Promise<void> | void;
132
164
  };
165
+ "command:unregister": {
166
+ name: string;
167
+ };
133
168
  "command:execute": {
134
169
  name: string;
135
170
  args: string;
@@ -143,6 +178,10 @@ export interface ShellEvents {
143
178
  "ui:suggestion": {
144
179
  text: string;
145
180
  };
181
+ "compositor:write": {
182
+ stream: string;
183
+ text: string;
184
+ };
146
185
  "input:keypress": {
147
186
  key: string;
148
187
  };
@@ -182,8 +221,7 @@ export interface ShellEvents {
182
221
  "agent:compact-request": Record<string, never>;
183
222
  "context:get-stats": {
184
223
  activeTokens: number;
185
- nuclearEntries: number;
186
- recallArchiveSize: number;
224
+ totalTokens: number;
187
225
  budgetTokens: number;
188
226
  };
189
227
  "agent:register-backend": {
@@ -228,10 +266,12 @@ export interface ShellEvents {
228
266
  };
229
267
  "config:set-modes": {
230
268
  modes: AgentMode[];
269
+ activeIndex?: number;
231
270
  };
232
271
  "config:add-modes": {
233
272
  modes: AgentMode[];
234
273
  };
274
+ "core:extensions-loaded": Record<string, never>;
235
275
  "provider:register": {
236
276
  id: string;
237
277
  apiKey?: string;
@@ -247,6 +287,7 @@ export interface ShellEvents {
247
287
  };
248
288
  "agent:register-tool": {
249
289
  tool: import("./agent/types.js").ToolDefinition;
290
+ extensionName?: string;
250
291
  };
251
292
  "agent:unregister-tool": {
252
293
  name: string;
@@ -257,10 +298,26 @@ export interface ShellEvents {
257
298
  "agent:register-instruction": {
258
299
  name: string;
259
300
  text: string;
301
+ extensionName: string;
260
302
  };
261
303
  "agent:remove-instruction": {
262
304
  name: string;
263
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
+ };
264
321
  "autocomplete:request": {
265
322
  buffer: string;
266
323
  /** Parsed slash command name (e.g. "/backend"), or null if not a command. */
@@ -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
  }
@@ -22,7 +22,7 @@ async function ensureTsSupport() {
22
22
  * advise, command:register). Returns the wrapped context and a dispose()
23
23
  * function that tears down everything registered through it.
24
24
  */
25
- function createScopedContext(ctx) {
25
+ function createScopedContext(ctx, extensionName) {
26
26
  const cleanups = [];
27
27
  const bus = ctx.bus;
28
28
  const scopedBus = Object.create(bus);
@@ -42,15 +42,27 @@ function createScopedContext(ctx) {
42
42
  cleanups.push(unadvise);
43
43
  return unadvise;
44
44
  };
45
- // Track instruction registrations
45
+ // Track instruction registrations — extension name captured in scope
46
46
  const scopedRegisterInstruction = (name, text) => {
47
- ctx.registerInstruction(name, text);
48
- cleanups.push(() => ctx.removeInstruction(name));
47
+ bus.emit("agent:register-instruction", { name, text, extensionName });
48
+ cleanups.push(() => bus.emit("agent:remove-instruction", { name }));
49
49
  };
50
- // Track tool registrations
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
51
56
  const scopedRegisterTool = (tool) => {
52
- ctx.registerTool(tool);
53
- cleanups.push(() => ctx.unregisterTool(tool.name));
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 }));
54
66
  };
55
67
  const scoped = {
56
68
  ...ctx,
@@ -58,8 +70,11 @@ function createScopedContext(ctx) {
58
70
  advise: scopedAdvise,
59
71
  registerInstruction: scopedRegisterInstruction,
60
72
  removeInstruction: ctx.removeInstruction,
73
+ registerSkill: scopedRegisterSkill,
74
+ removeSkill: ctx.removeSkill,
61
75
  registerTool: scopedRegisterTool,
62
76
  unregisterTool: ctx.unregisterTool,
77
+ registerCommand: scopedRegisterCommand,
63
78
  };
64
79
  const dispose = () => {
65
80
  for (const fn of cleanups) {
@@ -116,9 +131,16 @@ export async function loadExtensions(ctx, cliExtensions) {
116
131
  }
117
132
  async function discoverUserExtensions() {
118
133
  const specifiers = [];
134
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
119
135
  try {
120
136
  const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
121
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;
122
144
  const fullPath = path.join(EXT_DIR, entry.name);
123
145
  const isDir = entry.isDirectory() ||
124
146
  (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
@@ -161,16 +183,22 @@ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
161
183
  if (typeof activate === "function") {
162
184
  const base = path.basename(specifier).replace(/\.(ts|js|mjs|mts|tsx)$/, "");
163
185
  const name = base === "index" ? path.basename(path.dirname(specifier)) : base;
164
- // User extensions get a scoped context so /reload can tear them down
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.
165
190
  if (userSet.has(specifier)) {
166
191
  // Dispose previous load if reloading
167
192
  extensionDisposers.get(name)?.();
168
- const { scoped, dispose } = createScopedContext(ctx);
169
- activate(scoped);
193
+ const { scoped, dispose } = createScopedContext(ctx, name);
194
+ await activate(scoped);
170
195
  extensionDisposers.set(name, dispose);
171
196
  }
172
197
  else {
173
- activate(ctx);
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);
174
202
  }
175
203
  loaded.push(name);
176
204
  }
@@ -223,8 +251,17 @@ async function resolveSpecifier(specifier) {
223
251
  resolved = specifier;
224
252
  }
225
253
  else {
226
- // Bare specifier npm package
227
- 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
+ }
228
265
  }
229
266
  // If it's a directory, find the index file
230
267
  try {
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Built-in agent backend extension.
3
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)
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.
11
12
  */
12
13
  import type { ExtensionContext } from "../types.js";
13
14
  export default function agentBackend(ctx: ExtensionContext): void;