agent-sh 0.13.7 → 0.14.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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent-loop.d.ts +13 -17
  3. package/dist/agent/agent-loop.js +118 -224
  4. package/dist/agent/conversation-state.d.ts +1 -1
  5. package/dist/agent/events.d.ts +218 -0
  6. package/dist/agent/events.js +1 -0
  7. package/dist/agent/host-types.d.ts +20 -0
  8. package/dist/agent/index.d.ts +5 -9
  9. package/dist/agent/index.js +269 -167
  10. package/dist/agent/llm-facade.d.ts +13 -0
  11. package/dist/{utils → agent}/llm-facade.js +1 -1
  12. package/dist/agent/nuclear-form.d.ts +1 -1
  13. package/dist/agent/providers/deepseek.js +2 -5
  14. package/dist/agent/providers/openai-compatible.js +2 -2
  15. package/dist/agent/providers/openai.js +2 -5
  16. package/dist/agent/providers/openrouter.js +5 -5
  17. package/dist/agent/subagent.d.ts +1 -1
  18. package/dist/agent/tool-protocol.d.ts +1 -1
  19. package/dist/agent/tool-registry.d.ts +1 -1
  20. package/dist/cli/auth/cli.js +11 -6
  21. package/dist/cli/auth/discover.d.ts +5 -0
  22. package/dist/cli/auth/discover.js +25 -0
  23. package/dist/cli/auth/keys.d.ts +5 -2
  24. package/dist/cli/auth/keys.js +22 -2
  25. package/dist/cli/index.d.ts +16 -0
  26. package/dist/cli/index.js +12 -2
  27. package/dist/core/event-bus.d.ts +28 -371
  28. package/dist/core/extension-loader.js +6 -6
  29. package/dist/core/index.d.ts +10 -29
  30. package/dist/core/index.js +32 -84
  31. package/dist/extensions/index.d.ts +2 -1
  32. package/dist/extensions/index.js +1 -1
  33. package/dist/extensions/slash-commands/events.d.ts +18 -0
  34. package/dist/extensions/slash-commands/events.js +1 -0
  35. package/dist/extensions/slash-commands/index.d.ts +15 -0
  36. package/dist/extensions/{slash-commands.js → slash-commands/index.js} +4 -3
  37. package/dist/shell/events.d.ts +85 -0
  38. package/dist/shell/events.js +1 -0
  39. package/dist/shell/index.d.ts +1 -0
  40. package/dist/shell/index.js +6 -0
  41. package/dist/shell/tui-renderer.js +0 -1
  42. package/examples/extensions/ash-acp-bridge/src/index.ts +2 -2
  43. package/examples/extensions/ashi/package.json +1 -1
  44. package/examples/extensions/ollama.ts +47 -42
  45. package/examples/extensions/opencode-bridge/README.md +4 -0
  46. package/examples/extensions/opencode-bridge/index.ts +3 -1
  47. package/examples/extensions/pi-bridge/index.ts +3 -4
  48. package/examples/extensions/zai-coding-plan.ts +2 -6
  49. package/package.json +1 -1
  50. package/dist/extensions/slash-commands.d.ts +0 -2
  51. package/dist/utils/llm-facade.d.ts +0 -11
  52. /package/dist/{utils → agent}/llm-client.d.ts +0 -0
  53. /package/dist/{utils → agent}/llm-client.js +0 -0
package/README.md CHANGED
@@ -72,7 +72,7 @@ See [Bring your own agent](#bring-your-own-agent) below for full details and the
72
72
 
73
73
  ### Option B: Use the built-in agent (ash)
74
74
 
75
- `ash` is agent-sh's own lightweight agent. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
75
+ `ash` is agent-sh's own lightweight agent. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
76
76
 
77
77
  **Quickest path** — store a key once via the auth subcommand:
78
78
 
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { EventBus } from "../core/event-bus.js";
15
15
  import type { AgentMode } from "./host-types.js";
16
- import type { LlmClient } from "../utils/llm-client.js";
16
+ import type { LlmClient } from "./llm-client.js";
17
17
  import type { HandlerFunctions } from "../utils/handler-registry.js";
18
18
  import type { AgentBackend, ToolDefinition } from "./types.js";
19
19
  import { type HistoryAdapter } from "./history-file.js";
@@ -22,8 +22,7 @@ export interface AgentLoopConfig {
22
22
  bus: EventBus;
23
23
  llmClient: LlmClient;
24
24
  handlers: HandlerFunctions;
25
- modes?: AgentMode[];
26
- initialModeIndex?: number;
25
+ initialMode?: AgentMode;
27
26
  compositor?: Compositor;
28
27
  /** Instance ID from core — ensures history entries match the ID in prompts. */
29
28
  instanceId?: string;
@@ -35,14 +34,10 @@ export declare class AgentLoop implements AgentBackend {
35
34
  private history;
36
35
  private conversation;
37
36
  private fileReadCache;
38
- private modes;
39
- private currentModeIndex;
37
+ private activeMode;
40
38
  private boundListeners;
41
39
  private boundPipeListeners;
42
- private ctorListeners;
43
- private ctorPipeListeners;
44
40
  private lastProjectSkillNames;
45
- private lastAgentInfo;
46
41
  private sessionStartTime;
47
42
  private toolCallCounts;
48
43
  private totalToolCalls;
@@ -72,8 +67,10 @@ export declare class AgentLoop implements AgentBackend {
72
67
  registerTool(tool: ToolDefinition): void;
73
68
  /** Unregister a tool by name. */
74
69
  unregisterTool(name: string): void;
75
- /** Get all registered tools. */
70
+ /** Get all registered tools (union of builtins + extension contributions). */
76
71
  getTools(): ToolDefinition[];
72
+ /** Find a tool by name across the full pipe union. */
73
+ private findTool;
77
74
  /** Instructions keyed by name, with extension attribution. */
78
75
  private instructions;
79
76
  /** Skills keyed by name, with extension attribution. */
@@ -87,20 +84,19 @@ export declare class AgentLoop implements AgentBackend {
87
84
  registerSkill(name: string, description: string, filePath: string, extensionName: string): void;
88
85
  removeSkill(name: string): void;
89
86
  /**
90
- * Build the system prompt grouped by extension.
91
- *
92
- * Each extension gets a unified block:
93
- * ## extension-name
94
- * ### Tools
95
- * ### Skills
96
- * ### Instructions
87
+ * Build the "Extensions" section of the system prompt. Includes tools,
88
+ * skills, and instructions contributed by extensions (i.e. anything
89
+ * registered via ctx.agent.registerTool/Skill/Instruction). AgentLoop's
90
+ * own builtins are excluded by name — they're documented elsewhere in
91
+ * the prompt or in the tool API params.
97
92
  */
98
93
  buildExtensionSections(): string[];
99
94
  kill(): void;
100
95
  private cancel;
101
96
  private reasoningParams;
102
97
  private get currentMode();
103
- private emitAgentInfoIfChanged;
98
+ private pullModes;
99
+ private emitIdentity;
104
100
  private get currentModel();
105
101
  /**
106
102
  * Run compaction via the `conversation:compact` handler. After any
@@ -12,17 +12,6 @@ import { PACKAGE_VERSION } from "../utils/package-version.js";
12
12
  import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
13
13
  import { getSettings, updateSettings } from "../core/settings.js";
14
14
  import { createToolProtocol } from "./tool-protocol.js";
15
- // Core tool factories
16
- import { createBashTool } from "./tools/bash.js";
17
- import { createPwshTool } from "./tools/pwsh.js";
18
- import { findBash } from "../utils/executor.js";
19
- import { createReadFileTool } from "./tools/read-file.js";
20
- import { createWriteFileTool } from "./tools/write-file.js";
21
- import { createEditFileTool } from "./tools/edit-file.js";
22
- import { createGrepTool } from "./tools/grep.js";
23
- import { createGlobTool } from "./tools/glob.js";
24
- import { createLsTool } from "./tools/ls.js";
25
- import { createListSkillsTool } from "./tools/list-skills.js";
26
15
  import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
27
16
  /**
28
17
  * Compact one-line summary of a tool description for the extension
@@ -52,15 +41,11 @@ export class AgentLoop {
52
41
  toolRegistry;
53
42
  history;
54
43
  conversation;
55
- fileReadCache = new Map();
56
- modes;
57
- currentModeIndex = 0;
44
+ fileReadCache;
45
+ activeMode;
58
46
  boundListeners = [];
59
47
  boundPipeListeners = [];
60
- ctorListeners = [];
61
- ctorPipeListeners = [];
62
48
  lastProjectSkillNames = new Set();
63
- lastAgentInfo = null;
64
49
  // ── Session telemetry — behavioral self-awareness ──────────────
65
50
  // Every ash deserves to know what it's been doing. This tracks the
66
51
  // agent's own behavioral patterns across the session: which tools
@@ -103,18 +88,14 @@ export class AgentLoop {
103
88
  this.compositor = config.compositor ?? null;
104
89
  this.instanceId = config.instanceId ?? "unknown";
105
90
  this.toolRegistry = new ToolRegistry(this.handlers);
91
+ this.fileReadCache = this.handlers.call("agent:file-read-cache");
106
92
  // Shell-history-shaped log. Default writes go through the advisable
107
93
  // `history:append` handler registered below; extensions swap the
108
94
  // backend without touching this wiring.
109
95
  const filePath = process.env.AGENT_SH_HISTORY_FILE || getSettings().historyFilePath;
110
96
  this.history = config.history ?? new HistoryFile({ instanceId: this.instanceId, filePath });
111
97
  this.conversation = new ConversationState(this.handlers, this.instanceId);
112
- // Fall back to a single-mode placeholder if the caller passed an
113
- // empty array (agent-backend does this pre-resolution).
114
- this.modes = config.modes?.length
115
- ? config.modes
116
- : [{ model: config.llmClient.model }];
117
- this.currentModeIndex = config.initialModeIndex ?? 0;
98
+ this.activeMode = config.initialMode ?? { model: config.llmClient.model };
118
99
  // Tool protocol — controls how tools are presented to the LLM
119
100
  this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api", getSettings().coreTools ?? []);
120
101
  // Register core tools
@@ -125,87 +106,6 @@ export class AgentLoop {
125
106
  this.registerTool(t);
126
107
  // Register handlers — extensions can advise these
127
108
  this.registerHandlers();
128
- // Subscribe to bus-based tool/instruction registration from extensions.
129
- // These must be in the constructor (not wire()) because extensions call
130
- // registerTool() during activate(), before activateBackend() calls wire().
131
- const onCtor = (event, fn) => {
132
- this.bus.on(event, fn);
133
- this.ctorListeners.push({ event, fn });
134
- };
135
- onCtor("agent:register-tool", ({ tool, extensionName }) => {
136
- this.registerTool(tool);
137
- if (extensionName)
138
- this.toolExtensions.set(tool.name, extensionName);
139
- });
140
- onCtor("agent:unregister-tool", ({ name }) => {
141
- this.unregisterTool(name);
142
- this.toolExtensions.delete(name);
143
- });
144
- onCtor("agent:register-instruction", ({ name, text, extensionName }) => this.registerInstruction(name, text, extensionName));
145
- onCtor("agent:remove-instruction", ({ name }) => this.removeInstruction(name));
146
- onCtor("agent:register-skill", ({ name, description, filePath, extensionName }) => this.registerSkill(name, description, filePath, extensionName));
147
- onCtor("agent:remove-skill", ({ name }) => this.removeSkill(name));
148
- // Provider registration from user extensions (e.g. openrouter.ts) fires
149
- // during extension activation, which happens before wire(). Subscribe
150
- // here in the ctor so late-registered modes aren't dropped.
151
- onCtor("config:add-modes", ({ modes: extra }) => {
152
- const providers = new Set(extra.map((m) => m.provider).filter(Boolean));
153
- const prev = this.modes[this.currentModeIndex];
154
- // Keep the active mode even if the re-registration drops it (persisted
155
- // model missing from a refreshed catalog) — otherwise currentModeIndex
156
- // slips to modes[0] and the next stream() call uses a different model
157
- // mid-turn.
158
- const activePreserved = prev &&
159
- prev.provider &&
160
- providers.has(prev.provider) &&
161
- !extra.some((m) => m.model === prev.model && m.provider === prev.provider);
162
- this.modes = [
163
- ...this.modes.filter((m) => {
164
- if (activePreserved && m === prev)
165
- return true;
166
- return !m.provider || !providers.has(m.provider);
167
- }),
168
- ...extra,
169
- ];
170
- if (prev) {
171
- const newIdx = this.modes.findIndex((m) => m.model === prev.model && m.provider === prev.provider);
172
- if (newIdx !== -1) {
173
- this.currentModeIndex = newIdx;
174
- const next = this.modes[newIdx];
175
- if (next.providerConfig && next.providerConfig !== prev.providerConfig) {
176
- this.llmClient.reconfigure({ ...next.providerConfig, model: next.model });
177
- }
178
- }
179
- }
180
- if (activePreserved && prev) {
181
- this.bus.emit("ui:info", {
182
- message: `${prev.provider}:${prev.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
183
- });
184
- }
185
- this.emitAgentInfoIfChanged();
186
- this.bus.emit("config:changed", {});
187
- });
188
- // Fires before wire() too — agent-backend emits this from
189
- // `core:extensions-loaded` to replace the placeholder mode list.
190
- onCtor("config:set-modes", ({ modes: newModes, activeIndex }) => {
191
- this.modes = newModes;
192
- const inRange = activeIndex != null && activeIndex >= 0 && activeIndex < newModes.length;
193
- this.currentModeIndex = inRange ? activeIndex : 0;
194
- const m = newModes[this.currentModeIndex];
195
- if (!m)
196
- return;
197
- if (m.providerConfig) {
198
- this.llmClient.reconfigure({ ...m.providerConfig, model: m.model });
199
- }
200
- else {
201
- this.llmClient.model = m.model;
202
- }
203
- this.emitAgentInfoIfChanged();
204
- this.bus.emit("config:changed", {});
205
- });
206
- const getToolsPipe = () => ({ tools: this.getTools() });
207
- this.bus.onPipe("agent:get-tools", getToolsPipe);
208
- this.ctorPipeListeners.push({ event: "agent:get-tools", fn: getToolsPipe });
209
109
  }
210
110
  /** Subscribe to bus events — activates this backend. */
211
111
  wire() {
@@ -221,6 +121,27 @@ export class AgentLoop {
221
121
  this.bus.onPipeAsync(event, fn);
222
122
  this.boundPipeListeners.push({ event, fn, async: true });
223
123
  };
124
+ onPipe("agent:tools", (acc) => {
125
+ // Read internal storage, NOT this.getTools() — that queries the
126
+ // pipe and would recurse.
127
+ for (const tool of this.toolRegistry.allView())
128
+ acc.tools.push(tool);
129
+ return acc;
130
+ });
131
+ onPipe("agent:instructions", (acc) => {
132
+ for (const [name] of this.instructions) {
133
+ const text = this.handlers.call(`instruction:${name}`);
134
+ acc.instructions.push({ name, text });
135
+ }
136
+ return acc;
137
+ });
138
+ onPipe("agent:skills", (acc) => {
139
+ for (const [name] of this.skills) {
140
+ const view = this.handlers.call(`skill:${name}:view`);
141
+ acc.skills.push({ name, description: view.description, filePath: view.filePath });
142
+ }
143
+ return acc;
144
+ });
224
145
  on("agent:submit", ({ query }) => {
225
146
  this.handleQuery(query).catch(() => { });
226
147
  });
@@ -235,29 +156,29 @@ export class AgentLoop {
235
156
  const atIdx = target.lastIndexOf("@");
236
157
  const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
237
158
  const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
238
- const idx = this.modes.findIndex((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
239
- if (idx === -1) {
159
+ const modes = this.pullModes();
160
+ const found = modes.find((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
161
+ if (!found) {
240
162
  this.bus.emit("ui:error", { message: `Unknown model: ${target}` });
241
163
  return;
242
164
  }
243
- this.currentModeIndex = idx;
244
- const m = this.modes[idx];
245
- if (m.providerConfig) {
246
- this.llmClient.reconfigure({ ...m.providerConfig, model: m.model });
165
+ this.activeMode = found;
166
+ if (found.providerConfig) {
167
+ this.llmClient.reconfigure({ ...found.providerConfig, model: found.model });
247
168
  }
248
169
  else {
249
- this.llmClient.model = m.model;
170
+ this.llmClient.model = found.model;
250
171
  }
251
- const label = m.provider ? `${m.provider}: ${m.model}` : m.model;
252
- this.emitAgentInfoIfChanged();
172
+ const label = found.provider ? `${found.provider}: ${found.model}` : found.model;
173
+ this.emitIdentity();
253
174
  // Persist as the new default — selection survives restart.
254
175
  // Safe even for dynamic providers: agent-backend defers mode
255
176
  // resolution to `core:extensions-loaded`, so the extension gets
256
177
  // to re-register before the persisted default is looked up.
257
- if (m.provider) {
178
+ if (found.provider) {
258
179
  updateSettings({
259
- defaultProvider: m.provider,
260
- providers: { [m.provider]: { defaultModel: m.model } },
180
+ defaultProvider: found.provider,
181
+ providers: { [found.provider]: { defaultModel: found.model } },
261
182
  });
262
183
  this.bus.emit("ui:info", { message: `Model: ${label} (saved as default)` });
263
184
  }
@@ -266,10 +187,33 @@ export class AgentLoop {
266
187
  }
267
188
  this.bus.emit("config:changed", {});
268
189
  });
190
+ on("agent:modes-changed", () => {
191
+ const modes = this.pullModes();
192
+ const prev = this.activeMode;
193
+ const fresh = modes.find((m) => m.model === prev.model && m.provider === prev.provider);
194
+ if (fresh) {
195
+ this.activeMode = fresh;
196
+ if (fresh.providerConfig && fresh.providerConfig !== prev.providerConfig) {
197
+ this.llmClient.reconfigure({ ...fresh.providerConfig, model: fresh.model });
198
+ }
199
+ }
200
+ else if (prev.provider) {
201
+ // Ghost: keep prev active so mid-turn stream() doesn't switch models.
202
+ this.bus.emit("ui:info", {
203
+ message: `${prev.provider}:${prev.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
204
+ });
205
+ }
206
+ this.emitIdentity();
207
+ this.bus.emit("config:changed", {});
208
+ });
269
209
  onPipe("config:get-models", () => {
270
- const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
271
- const cur = this.modes[this.currentModeIndex];
272
- const active = cur ? { model: cur.model, provider: cur.provider ?? "" } : null;
210
+ const modes = this.pullModes();
211
+ const models = modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
212
+ // Surface a ghost active mode so /model still shows it.
213
+ if (!modes.some((m) => m.model === this.activeMode.model && m.provider === this.activeMode.provider)) {
214
+ models.push({ model: this.activeMode.model, provider: this.activeMode.provider ?? "" });
215
+ }
216
+ const active = { model: this.activeMode.model, provider: this.activeMode.provider ?? "" };
273
217
  return { models, active };
274
218
  });
275
219
  on("config:set-thinking", ({ level }) => {
@@ -368,8 +312,7 @@ export class AgentLoop {
368
312
  this.bus.emit("conversation:message-appended", { role: "system", content: note });
369
313
  }
370
314
  });
371
- this.lastAgentInfo = null;
372
- this.emitAgentInfoIfChanged();
315
+ this.emitIdentity();
373
316
  }
374
317
  /** Unsubscribe from bus events — deactivates this backend. */
375
318
  unwire() {
@@ -393,9 +336,13 @@ export class AgentLoop {
393
336
  unregisterTool(name) {
394
337
  this.toolRegistry.unregister(name);
395
338
  }
396
- /** Get all registered tools. */
339
+ /** Get all registered tools (union of builtins + extension contributions). */
397
340
  getTools() {
398
- return this.toolRegistry.all();
341
+ return this.bus.emitPipe("agent:tools", { tools: [] }).tools;
342
+ }
343
+ /** Find a tool by name across the full pipe union. */
344
+ findTool(name) {
345
+ return this.getTools().find((t) => t.name === name);
399
346
  }
400
347
  // ── Extension instructions, skills & tool tracking ──────────────────
401
348
  /** Instructions keyed by name, with extension attribution. */
@@ -426,73 +373,41 @@ export class AgentLoop {
426
373
  // Handler entry retained so external advisors survive a reload of the owner.
427
374
  }
428
375
  /**
429
- * Build the system prompt grouped by extension.
430
- *
431
- * Each extension gets a unified block:
432
- * ## extension-name
433
- * ### Tools
434
- * ### Skills
435
- * ### Instructions
376
+ * Build the "Extensions" section of the system prompt. Includes tools,
377
+ * skills, and instructions contributed by extensions (i.e. anything
378
+ * registered via ctx.agent.registerTool/Skill/Instruction). AgentLoop's
379
+ * own builtins are excluded by name — they're documented elsewhere in
380
+ * the prompt or in the tool API params.
436
381
  */
437
382
  buildExtensionSections() {
438
- const groups = new Map();
439
- const ensure = (name) => groups.get(name) ?? (groups.set(name, { tools: [], skills: [], instructions: [] }).get(name));
440
- // Attribute instructions — read text through the advisor chain
441
- for (const [name, { extensionName }] of this.instructions) {
442
- const text = this.handlers.call(`instruction:${name}`);
443
- ensure(extensionName).instructions.push({ text });
444
- }
445
- // Attribute skills read description/filePath through the advisor chain
446
- for (const [skillName, { extensionName }] of this.skills) {
447
- const view = this.handlers.call(`skill:${skillName}:view`);
448
- ensure(extensionName).skills.push({ name: skillName, description: view.description, filePath: view.filePath });
449
- }
450
- // Attribute tools (skip built-in scratchpad tools).
451
- // In "api" mode the full tool schemas are in the API `tools` param,
452
- // making the text catalog here pure duplication — skip it. Other
453
- // modes (deferred / deferred-lookup / inline) rely on the text
454
- // catalog as the discovery surface, so keep it there.
455
- const toolModeHasApiSchemas = this.toolProtocol.mode === "api";
456
- if (!toolModeHasApiSchemas) {
457
- const builtinTools = new Set([
458
- "bash", "read_file", "write_file", "edit_file", "grep", "glob", "ls",
459
- "list_skills",
460
- ]);
461
- for (const tool of this.toolRegistry.allView()) {
462
- if (builtinTools.has(tool.name))
463
- continue;
464
- const extName = this.toolExtensions.get(tool.name);
465
- if (!extName)
466
- continue;
467
- ensure(extName).tools.push({ name: tool.name, description: summarizeDescription(tool.description) });
468
- }
469
- }
470
- // Render
471
- return [...groups.entries()]
472
- .filter(([, g]) => g.tools.length + g.skills.length + g.instructions.length > 0)
473
- .map(([name, g]) => {
474
- const parts = [];
475
- if (g.tools.length > 0)
476
- parts.push("### Tools\n" + g.tools.map(t => `${t.name} — ${t.description}`).join("\n"));
477
- if (g.skills.length > 0)
478
- parts.push("### Skills\n" + g.skills.map(s => `${s.name}: ${s.description}\n → ${s.filePath}`).join("\n\n"));
479
- if (g.instructions.length > 0)
480
- parts.push("### Instructions\n" + g.instructions.map(i => i.text).join("\n\n"));
481
- return `## ${name}\n${parts.join("\n\n")}`;
482
- });
383
+ const BUILTIN_TOOLS = new Set([
384
+ "bash", "read_file", "write_file", "edit_file", "grep", "glob", "ls",
385
+ "list_skills",
386
+ ]);
387
+ const BUILTIN_INSTRUCTIONS = new Set(["recall-guidance"]);
388
+ const BUILTIN_SKILLS = new Set();
389
+ const allTools = this.bus.emitPipe("agent:tools", { tools: [] }).tools;
390
+ const allInstructions = this.bus.emitPipe("agent:instructions", { instructions: [] }).instructions;
391
+ const allSkills = this.bus.emitPipe("agent:skills", { skills: [] }).skills;
392
+ const extTools = this.toolProtocol.mode === "api"
393
+ ? []
394
+ : allTools.filter((t) => !BUILTIN_TOOLS.has(t.name));
395
+ const extInstructions = allInstructions.filter((i) => !BUILTIN_INSTRUCTIONS.has(i.name));
396
+ const extSkills = allSkills.filter((s) => !BUILTIN_SKILLS.has(s.name));
397
+ if (extTools.length + extInstructions.length + extSkills.length === 0)
398
+ return [];
399
+ const parts = [];
400
+ if (extTools.length > 0)
401
+ parts.push("### Tools\n" + extTools.map(t => `${t.name} — ${summarizeDescription(t.description)}`).join("\n"));
402
+ if (extSkills.length > 0)
403
+ parts.push("### Skills\n" + extSkills.map(s => `${s.name}: ${s.description}\n → ${s.filePath}`).join("\n\n"));
404
+ if (extInstructions.length > 0)
405
+ parts.push("### Instructions\n" + extInstructions.map(i => i.text).join("\n\n"));
406
+ return [`## Extensions\n${parts.join("\n\n")}`];
483
407
  }
484
408
  kill() {
485
409
  this.cancel();
486
410
  this.unwire();
487
- // Clean up constructor-level bus subscriptions
488
- for (const { event, fn } of this.ctorListeners) {
489
- this.bus.off(event, fn);
490
- }
491
- this.ctorListeners = [];
492
- for (const { event, fn } of this.ctorPipeListeners) {
493
- this.bus.offPipe(event, fn);
494
- }
495
- this.ctorPipeListeners = [];
496
411
  }
497
412
  cancel() {
498
413
  this.abortController?.abort();
@@ -511,17 +426,18 @@ export class AgentLoop {
511
426
  return { reasoning_effort: effort };
512
427
  }
513
428
  get currentMode() {
514
- return this.modes[this.currentModeIndex];
429
+ return this.activeMode;
515
430
  }
516
- emitAgentInfoIfChanged() {
517
- const m = this.modes[this.currentModeIndex];
518
- if (!m)
519
- return;
520
- const prev = this.lastAgentInfo;
521
- if (prev && prev.model === m.model && prev.provider === m.provider && prev.contextWindow === m.contextWindow) {
522
- return;
431
+ pullModes() {
432
+ try {
433
+ return this.handlers.call("agent:get-modes") ?? [];
523
434
  }
524
- this.lastAgentInfo = { model: m.model, provider: m.provider, contextWindow: m.contextWindow };
435
+ catch {
436
+ return [];
437
+ }
438
+ }
439
+ emitIdentity() {
440
+ const m = this.activeMode;
525
441
  this.bus.emit("agent:info", {
526
442
  name: "ash",
527
443
  version: PACKAGE_VERSION,
@@ -531,7 +447,7 @@ export class AgentLoop {
531
447
  });
532
448
  }
533
449
  get currentModel() {
534
- return this.modes[this.currentModeIndex].model;
450
+ return this.activeMode.model;
535
451
  }
536
452
  /**
537
453
  * Run compaction via the `conversation:compact` handler. After any
@@ -637,30 +553,8 @@ export class AgentLoop {
637
553
  return `${raw}${context}`;
638
554
  }
639
555
  registerCoreTools() {
640
- const getCwd = () => this.handlers.call("cwd");
641
- const getEnv = () => {
642
- const env = {};
643
- for (const [k, v] of Object.entries(process.env)) {
644
- if (v !== undefined)
645
- env[k] = v;
646
- }
647
- return env;
648
- };
649
- if (findBash() !== null) {
650
- this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
651
- }
652
- if (process.platform === "win32") {
653
- this.toolRegistry.register(createPwshTool({ getCwd, getEnv, bus: this.bus }));
654
- }
655
- this.toolRegistry.register(createReadFileTool(getCwd, this.fileReadCache));
656
- this.toolRegistry.register(createWriteFileTool(getCwd));
657
- this.toolRegistry.register(createEditFileTool(getCwd));
658
- this.toolRegistry.register(createGrepTool(getCwd));
659
- this.toolRegistry.register(createGlobTool(getCwd));
660
- this.toolRegistry.register(createLsTool(getCwd));
661
- this.toolRegistry.register(createListSkillsTool(getCwd));
662
- // conversation_recall — browse/search/expand evicted turns from
663
- // the in-session archive and the persistent history file.
556
+ // Stateless core tools register in agentBackend; conversation_recall
557
+ // stays here because it needs this.conversation.
664
558
  this.toolRegistry.register({
665
559
  name: "conversation_recall",
666
560
  displayName: "recall",
@@ -1112,7 +1006,7 @@ export class AgentLoop {
1112
1006
  {
1113
1007
  const groupMap = new Map();
1114
1008
  for (const tc of toolCalls) {
1115
- const tool = this.toolRegistry.get(tc.name);
1009
+ const tool = this.findTool(tc.name);
1116
1010
  const kind = tool?.getDisplayInfo?.((() => { try {
1117
1011
  return JSON.parse(tc.argumentsJson);
1118
1012
  }
@@ -1153,9 +1047,9 @@ export class AgentLoop {
1153
1047
  }
1154
1048
  }
1155
1049
  catch { /* not an error payload, continue */ }
1156
- const tool = this.toolRegistry.get(tc.name);
1050
+ const tool = this.findTool(tc.name);
1157
1051
  if (!tool) {
1158
- const available = this.toolRegistry.all().map((t) => t.name).join(", ");
1052
+ const available = this.getTools().map((t) => t.name).join(", ");
1159
1053
  collectedResults.push({
1160
1054
  callId: tc.id, toolName: tc.name,
1161
1055
  content: `Unknown tool "${tc.name}". Available tools: ${available}`,
@@ -1256,7 +1150,7 @@ export class AgentLoop {
1256
1150
  const parallel = [];
1257
1151
  const sequential = [];
1258
1152
  for (const tc of toolCalls) {
1259
- const tool = this.toolRegistry.get(tc.name);
1153
+ const tool = this.findTool(tc.name);
1260
1154
  if (tool && !tool.modifiesFiles) {
1261
1155
  parallel.push(tc);
1262
1156
  }
@@ -1501,7 +1395,7 @@ export class AgentLoop {
1501
1395
  const reasoningDetailsByIndex = new Map();
1502
1396
  const pendingToolCalls = [];
1503
1397
  // Tool protocol controls what goes in the API tools param vs dynamic context
1504
- const toolView = this.toolRegistry.allView();
1398
+ const toolView = this.getTools();
1505
1399
  const apiTools = this.toolProtocol.getApiTools(toolView);
1506
1400
  const toolPrompt = this.toolProtocol.getToolPrompt(toolView);
1507
1401
  // Dynamic context rides on the trailing message — see
@@ -1513,7 +1407,7 @@ export class AgentLoop {
1513
1407
  // Let extensions transform the message array (compact, summarize, filter, etc.)
1514
1408
  const messages = this.handlers.call("conversation:prepare", rawMessages);
1515
1409
  // Stream filter strips tool tags from display (inline mode only)
1516
- const streamFilter = this.toolProtocol.createStreamFilter(this.toolRegistry.all().map((t) => t.name));
1410
+ const streamFilter = this.toolProtocol.createStreamFilter(this.getTools().map((t) => t.name));
1517
1411
  const requestParams = {
1518
1412
  messages,
1519
1413
  tools: apiTools,
@@ -1,4 +1,4 @@
1
- import type { ChatCompletionMessageParam } from "../utils/llm-client.js";
1
+ import type { ChatCompletionMessageParam } from "./llm-client.js";
2
2
  import { type NuclearEntry } from "./nuclear-form.js";
3
3
  import type { HandlerFunctions } from "../utils/handler-registry.js";
4
4
  /** Search hit shape returned by the `history:search` handler. */