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
@@ -1,13 +1,28 @@
1
+ /**
2
+ * Mode resolution is deferred to `core:extensions-loaded` so a persisted
3
+ * `defaultProvider: "openrouter"` doesn't lose to a cold-start race.
4
+ */
5
+ import "./events.js";
1
6
  import { AgentLoop } from "./agent-loop.js";
2
- import { LlmClient } from "../utils/llm-client.js";
3
- import { createLlmFacade } from "../utils/llm-facade.js";
7
+ import { LlmClient } from "./llm-client.js";
8
+ import { createLlmFacade } from "./llm-facade.js";
9
+ import { registerReadOnlyTool, unregisterReadOnlyTool } from "./nuclear-form.js";
4
10
  import { resolveProvider, getProviderNames, getSettings } from "../core/settings.js";
5
11
  import { discoverSkills } from "./skills.js";
6
- import { resolveApiKey } from "../cli/auth/keys.js";
7
12
  import activateOpenrouter from "./providers/openrouter.js";
8
13
  import activateOpenai from "./providers/openai.js";
9
14
  import activateOpenaiCompatible from "./providers/openai-compatible.js";
10
15
  import activateDeepseek from "./providers/deepseek.js";
16
+ import { findBash } from "../utils/executor.js";
17
+ import { createBashTool } from "./tools/bash.js";
18
+ import { createPwshTool } from "./tools/pwsh.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";
11
26
  function persistedModelFor(providerName) {
12
27
  if (!providerName)
13
28
  return undefined;
@@ -36,24 +51,142 @@ function mergeCaps(settingsCaps, payloadCaps, modelIds) {
36
51
  }
37
52
  return out.size > 0 ? out : undefined;
38
53
  }
54
+ function splitRegistration(p) {
55
+ const raw = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
56
+ const ids = [];
57
+ const caps = new Map();
58
+ for (const m of raw) {
59
+ if (typeof m === "string") {
60
+ ids.push(m);
61
+ }
62
+ else {
63
+ ids.push(m.id);
64
+ caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, maxTokens: m.maxTokens, echoReasoning: m.echoReasoning });
65
+ }
66
+ }
67
+ return { ids, caps };
68
+ }
39
69
  export default function agentBackend(ctx) {
40
70
  const { bus } = ctx;
41
71
  const config = ctx.call("config:get-app-config") ?? {};
72
+ const toolContribs = new Map();
73
+ const instructionContribs = new Map();
74
+ const skillContribs = new Map();
75
+ const providerContribs = new Map();
76
+ // Settings overlay — fields here win over contributing extensions' payloads.
77
+ const settingsProviders = new Map();
78
+ for (const name of getProviderNames()) {
79
+ const p = resolveProvider(name);
80
+ if (p)
81
+ settingsProviders.set(name, p);
82
+ }
83
+ const providerHooks = new Map();
84
+ // Bakes model id so AgentMode.buildReasoningParams keeps its (level) signature.
85
+ const bindReasoning = (shapeId, model) => {
86
+ const hook = providerHooks.get(shapeId)?.reasoningParams;
87
+ return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
88
+ };
42
89
  const agentSurface = {
43
90
  llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
44
91
  providers: {
92
+ register: (reg) => {
93
+ const existing = providerContribs.get(reg.id);
94
+ if (existing)
95
+ bus.offPipe("agent:providers", existing);
96
+ const contrib = (acc) => {
97
+ acc.providers.push(reg);
98
+ return acc;
99
+ };
100
+ providerContribs.set(reg.id, contrib);
101
+ bus.onPipe("agent:providers", contrib);
102
+ bus.emit("agent:providers:changed", {});
103
+ return () => agentSurface.providers.unregister(reg.id);
104
+ },
105
+ unregister: (id) => {
106
+ const contrib = providerContribs.get(id);
107
+ if (!contrib)
108
+ return;
109
+ bus.offPipe("agent:providers", contrib);
110
+ providerContribs.delete(id);
111
+ bus.emit("agent:providers:changed", {});
112
+ },
45
113
  configure: (id, configureOpts) => bus.emit("provider:configure", { id, ...configureOpts }),
46
114
  },
47
- registerTool: (tool) => bus.emit("agent:register-tool", { tool, extensionName: "" }),
48
- unregisterTool: (name) => bus.emit("agent:unregister-tool", { name }),
115
+ registerTool: (tool) => {
116
+ if (toolContribs.has(tool.name)) {
117
+ throw new Error(`Tool "${tool.name}" already registered. Use ctx.agent.adviseTool() to wrap it.`);
118
+ }
119
+ ctx.define(`tool:${tool.name}`, tool.execute.bind(tool));
120
+ ctx.define(`tool:${tool.name}:schema`, () => ({
121
+ description: tool.description,
122
+ parameters: tool.input_schema,
123
+ }));
124
+ if (tool.readOnly)
125
+ registerReadOnlyTool(tool.name);
126
+ else
127
+ unregisterReadOnlyTool(tool.name);
128
+ const contrib = (acc) => {
129
+ // Pull through schema so adviseToolSchema reflects.
130
+ const view = ctx.call(`tool:${tool.name}:schema`);
131
+ acc.tools.push({ ...tool, description: view.description, input_schema: view.parameters });
132
+ return acc;
133
+ };
134
+ toolContribs.set(tool.name, contrib);
135
+ bus.onPipe("agent:tools", contrib);
136
+ },
137
+ unregisterTool: (name) => {
138
+ const contrib = toolContribs.get(name);
139
+ if (!contrib)
140
+ return;
141
+ bus.offPipe("agent:tools", contrib);
142
+ toolContribs.delete(name);
143
+ unregisterReadOnlyTool(name);
144
+ // Handlers retained so external advisors survive a reload.
145
+ },
49
146
  adviseTool: (name, advisor) => ctx.advise(`tool:${name}`, advisor),
50
147
  adviseToolSchema: (name, advisor) => ctx.advise(`tool:${name}:schema`, advisor),
51
- getTools: () => bus.emitPipe("agent:get-tools", { tools: [] }).tools,
52
- registerInstruction: (name, text) => bus.emit("agent:register-instruction", { name, text, extensionName: "" }),
53
- removeInstruction: (name) => bus.emit("agent:remove-instruction", { name }),
148
+ getTools: () => bus.emitPipe("agent:tools", { tools: [] }).tools,
149
+ registerInstruction: (name, text) => {
150
+ const existing = instructionContribs.get(name);
151
+ if (existing)
152
+ bus.offPipe("agent:instructions", existing);
153
+ ctx.define(`instruction:${name}`, () => text);
154
+ const contrib = (acc) => {
155
+ const current = ctx.call(`instruction:${name}`);
156
+ acc.instructions.push({ name, text: current });
157
+ return acc;
158
+ };
159
+ instructionContribs.set(name, contrib);
160
+ bus.onPipe("agent:instructions", contrib);
161
+ },
162
+ removeInstruction: (name) => {
163
+ const contrib = instructionContribs.get(name);
164
+ if (!contrib)
165
+ return;
166
+ bus.offPipe("agent:instructions", contrib);
167
+ instructionContribs.delete(name);
168
+ },
54
169
  adviseInstruction: (name, advisor) => ctx.advise(`instruction:${name}`, advisor),
55
- registerSkill: (name, description, filePath) => bus.emit("agent:register-skill", { name, description, filePath, extensionName: "" }),
56
- removeSkill: (name) => bus.emit("agent:remove-skill", { name }),
170
+ registerSkill: (name, description, filePath) => {
171
+ const existing = skillContribs.get(name);
172
+ if (existing)
173
+ bus.offPipe("agent:skills", existing);
174
+ ctx.define(`skill:${name}:view`, () => ({ description, filePath }));
175
+ const contrib = (acc) => {
176
+ const view = ctx.call(`skill:${name}:view`);
177
+ acc.skills.push({ name, description: view.description, filePath: view.filePath });
178
+ return acc;
179
+ };
180
+ skillContribs.set(name, contrib);
181
+ bus.onPipe("agent:skills", contrib);
182
+ },
183
+ removeSkill: (name) => {
184
+ const contrib = skillContribs.get(name);
185
+ if (!contrib)
186
+ return;
187
+ bus.offPipe("agent:skills", contrib);
188
+ skillContribs.delete(name);
189
+ },
57
190
  adviseSkill: (name, advisor) => ctx.advise(`skill:${name}:view`, advisor),
58
191
  registerContextProducer: (_name, producer, producerOpts) => {
59
192
  const handlerName = producerOpts?.mode === "per-query"
@@ -72,32 +205,80 @@ export default function agentBackend(ctx) {
72
205
  },
73
206
  };
74
207
  ctx.agent = agentSurface;
75
- // Immutable settings snapshot; provider:register payloads merge against it.
76
- const providerRegistry = new Map();
77
- const settingsProviders = new Map();
78
- for (const name of getProviderNames()) {
79
- const p = resolveProvider(name);
80
- if (p) {
81
- providerRegistry.set(name, p);
82
- settingsProviders.set(name, p);
208
+ // Core tools register at activate before extensions load — so
209
+ // extensions that look them up at activate time (e.g. scheme.ts) find them.
210
+ // conversation_recall stays in AgentLoop (needs session state).
211
+ const fileReadCache = new Map();
212
+ ctx.define("agent:file-read-cache", () => fileReadCache);
213
+ const getCwd = () => ctx.call("cwd");
214
+ const getEnv = () => {
215
+ const env = {};
216
+ for (const [k, v] of Object.entries(process.env)) {
217
+ if (v !== undefined)
218
+ env[k] = v;
83
219
  }
220
+ return env;
221
+ };
222
+ if (findBash() !== null) {
223
+ agentSurface.registerTool(createBashTool({ getCwd, getEnv, bus }));
84
224
  }
85
- const providerHooks = new Map();
86
- // Bakes model id into the hook so AgentMode.buildReasoningParams keeps
87
- // its (level) signature while the hook can branch on model.
88
- const bindReasoning = (shapeId, model) => {
89
- const hook = providerHooks.get(shapeId)?.reasoningParams;
90
- return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
225
+ if (process.platform === "win32") {
226
+ agentSurface.registerTool(createPwshTool({ getCwd, getEnv, bus }));
227
+ }
228
+ agentSurface.registerTool(createReadFileTool(getCwd, fileReadCache));
229
+ agentSurface.registerTool(createWriteFileTool(getCwd));
230
+ agentSurface.registerTool(createEditFileTool(getCwd));
231
+ agentSurface.registerTool(createGrepTool(getCwd));
232
+ agentSurface.registerTool(createGlobTool(getCwd));
233
+ agentSurface.registerTool(createLsTool(getCwd));
234
+ agentSurface.registerTool(createListSkillsTool(getCwd));
235
+ let resolvedProviders = new Map();
236
+ const resolveWithSettings = (id, p) => {
237
+ const s = settingsProviders.get(id);
238
+ const { ids: payloadIds, caps: payloadCaps } = p ? splitRegistration(p) : { ids: [], caps: new Map() };
239
+ const fallbackIds = s?.models ?? (s?.defaultModel ? [s.defaultModel] : []);
240
+ const modelIds = s?.modelsExplicit && s.models.length > 0
241
+ ? s.models
242
+ : payloadIds.length > 0 ? payloadIds : fallbackIds;
243
+ return {
244
+ id,
245
+ apiKey: s?.apiKey ?? p?.apiKey,
246
+ baseURL: s?.baseURL ?? p?.baseURL,
247
+ defaultModel: s?.defaultModel ?? p?.defaultModel ?? modelIds[0],
248
+ models: modelIds,
249
+ modelsExplicit: s?.modelsExplicit ?? false,
250
+ contextWindow: s?.contextWindow,
251
+ supportsReasoningEffort: s?.supportsReasoningEffort ?? p?.supportsReasoningEffort,
252
+ modelCapabilities: mergeCaps(s?.modelCapabilities, payloadCaps, modelIds),
253
+ reasoningShape: s?.reasoningShape,
254
+ };
255
+ };
256
+ const computeResolvedProviders = () => {
257
+ const out = new Map();
258
+ // Last contribution per id wins (openrouter's catalog-refresh replaces
259
+ // its curated default).
260
+ const { providers } = bus.emitPipe("agent:providers", { providers: [] });
261
+ const byId = new Map();
262
+ for (const p of providers)
263
+ byId.set(p.id, p);
264
+ for (const [id, p] of byId)
265
+ out.set(id, resolveWithSettings(id, p));
266
+ for (const [id] of settingsProviders) {
267
+ if (out.has(id))
268
+ continue;
269
+ out.set(id, resolveWithSettings(id, null));
270
+ }
271
+ return out;
91
272
  };
92
273
  const buildModes = () => {
93
- const allModes = [];
94
- for (const [id, p] of providerRegistry) {
274
+ const out = [];
275
+ for (const [id, p] of resolvedProviders) {
95
276
  if (!p.apiKey)
96
277
  continue;
97
278
  const shapeId = p.reasoningShape ?? id;
98
279
  for (const model of p.models) {
99
280
  const mc = p.modelCapabilities?.get(model);
100
- allModes.push({
281
+ out.push({
101
282
  model,
102
283
  provider: id,
103
284
  providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
@@ -110,11 +291,10 @@ export default function agentBackend(ctx) {
110
291
  });
111
292
  }
112
293
  }
113
- return allModes;
294
+ return out;
114
295
  };
115
- // Placeholder client — reconfigured at core:extensions-loaded. Any
116
- // stream() call before then fails from the OpenAI SDK; start() won't
117
- // wire the loop until we've resolved, so users never hit that path.
296
+ ctx.define("agent:get-modes", () => buildModes());
297
+ // Reconfigured at core:extensions-loaded; start() gates on `resolved`.
118
298
  const llmClient = new LlmClient({ apiKey: "not-configured", model: "not-configured" });
119
299
  ctx.define("llm:get-client", () => llmClient);
120
300
  ctx.define("llm:invoke", (messages, opts) => {
@@ -127,71 +307,59 @@ export default function agentBackend(ctx) {
127
307
  ...(clampedEffort && clampedEffort !== "off" ? { reasoning_effort: clampedEffort } : {}),
128
308
  });
129
309
  });
130
- let modes = [];
131
- let initialModeIndex = 0;
132
310
  let resolved = false;
133
- // Gates late-registration reconcile so its config:switch-model emit doesn't misroute under a non-ash backend.
311
+ // Gates late-reconcile so config:switch-model doesn't misroute under a non-ash backend.
134
312
  let ashActive = false;
135
- bus.onPipe("config:get-initial-modes", () => ({ modes, initialModeIndex }));
136
- // AgentLoop must be constructed *before* user extensions activate,
137
- // because its ctor defines handlers (history:append, etc.) that
138
- // extensions like superash call synchronously during their own
139
- // activate. Advise-before-define works for advisers, but plain calls
140
- // would hit a no-op stub.
141
- const agentLoop = new AgentLoop({
142
- bus,
143
- llmClient,
144
- handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call, list: ctx.list },
145
- modes,
146
- initialModeIndex,
147
- compositor: ctx.shell?.compositor,
148
- instanceId: ctx.instanceId,
149
- history: config.history,
150
- });
313
+ let agentLoop = null;
151
314
  let loadedExtensionNames = [];
315
+ bus.on("agent:providers:changed", () => {
316
+ resolvedProviders = computeResolvedProviders();
317
+ if (!resolved)
318
+ return;
319
+ bus.emit("agent:modes-changed", {});
320
+ if (!ashActive)
321
+ return;
322
+ const pendingProvider = getSettings().defaultProvider;
323
+ if (!pendingProvider)
324
+ return;
325
+ const p = resolvedProviders.get(pendingProvider);
326
+ if (!p)
327
+ return;
328
+ const pendingModel = persistedModelFor(pendingProvider);
329
+ if (pendingModel && p.models.includes(pendingModel) && llmClient.model !== pendingModel) {
330
+ bus.emit("config:switch-model", { model: pendingModel });
331
+ }
332
+ });
333
+ bus.on("provider:configure", ({ id, reasoningParams }) => {
334
+ const prev = providerHooks.get(id) ?? {};
335
+ if (reasoningParams !== undefined)
336
+ prev.reasoningParams = reasoningParams;
337
+ providerHooks.set(id, prev);
338
+ });
152
339
  bus.on("core:extensions-loaded", ({ names }) => {
153
340
  loadedExtensionNames = names;
341
+ resolvedProviders = computeResolvedProviders();
154
342
  const settings = getSettings();
155
- // If the user didn't pick a default, fall back to the first registered
156
- // provider (built-in load order biases to openrouter → openai).
157
343
  const providerName = config.provider ?? settings.defaultProvider
158
- ?? (providerRegistry.size > 0 ? providerRegistry.keys().next().value : undefined);
159
- const activeProvider = providerName ? providerRegistry.get(providerName) ?? null : null;
160
- // User's persisted defaultModel wins over the provider's declared
161
- // default. Dynamic providers (openrouter) re-register with their
162
- // hardcoded DEFAULT_MODELS[0] each startup, which would otherwise
163
- // clobber the user's /model selection.
344
+ ?? (resolvedProviders.size > 0 ? resolvedProviders.keys().next().value : undefined);
345
+ const activeProvider = providerName ? resolvedProviders.get(providerName) ?? null : null;
346
+ // Persisted defaultModel wins over openrouter's hardcoded DEFAULT_MODELS[0].
164
347
  const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
165
348
  const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
166
349
  const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
167
- // No provider → don't register ash at all, so another backend (e.g.
168
- // claude-code-bridge) can own activation. index.ts hard-fails only
169
- // when no backend ended up registered.
350
+ // No provider → don't register ash; let another backend own activation.
170
351
  if (!effectiveApiKey || !effectiveModel)
171
352
  return;
172
- modes = buildModes();
173
- if (modes.length === 0)
174
- modes = [{ model: effectiveModel }];
175
- let foundIdx = modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
176
- // Persisted default may not be in the provider's curated list yet (e.g.
177
- // openrouter's async catalog fetch hasn't returned). Prepend a stub so
178
- // the initial config:set-modes activeIndex points at the real model —
179
- // otherwise AgentLoop reconfigures llmClient back to modes[0].
180
- if (foundIdx === -1 && activeProvider) {
181
- modes = [
182
- {
183
- model: effectiveModel,
184
- provider: activeProvider.id,
185
- providerConfig: { apiKey: effectiveApiKey, baseURL: effectiveBaseURL },
186
- supportsReasoningEffort: activeProvider.supportsReasoningEffort,
187
- },
188
- ...modes,
189
- ];
190
- foundIdx = 0;
191
- }
192
- initialModeIndex = Math.max(0, foundIdx);
353
+ const foundInModes = buildModes().find((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
354
+ // Stub when openrouter's async catalog hasn't returned yet; reconciled
355
+ // later via agent:providers:changed config:switch-model.
356
+ const initialMode = foundInModes ?? (activeProvider ? {
357
+ model: effectiveModel,
358
+ provider: activeProvider.id,
359
+ providerConfig: { apiKey: effectiveApiKey, baseURL: effectiveBaseURL },
360
+ supportsReasoningEffort: activeProvider.supportsReasoningEffort,
361
+ } : { model: effectiveModel });
193
362
  llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
194
- bus.emit("config:set-modes", { modes, activeIndex: initialModeIndex });
195
363
  resolved = true;
196
364
  bus.emit("agent:register-backend", {
197
365
  name: "ash",
@@ -199,9 +367,19 @@ export default function agentBackend(ctx) {
199
367
  ashActive = false;
200
368
  bus.emit("command:unregister", { name: "/compact" });
201
369
  bus.emit("command:unregister", { name: "/context" });
202
- agentLoop.kill();
370
+ agentLoop?.kill();
371
+ agentLoop = null;
203
372
  },
204
373
  start: async () => {
374
+ agentLoop = new AgentLoop({
375
+ bus,
376
+ llmClient,
377
+ handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call, list: ctx.list },
378
+ initialMode,
379
+ compositor: ctx.shell?.compositor,
380
+ instanceId: ctx.instanceId,
381
+ history: config.history,
382
+ });
205
383
  agentLoop.wire();
206
384
  ashActive = true;
207
385
  bus.emit("command:register", {
@@ -229,71 +407,8 @@ export default function agentBackend(ctx) {
229
407
  },
230
408
  });
231
409
  });
232
- bus.on("provider:configure", ({ id, reasoningParams }) => {
233
- const prev = providerHooks.get(id) ?? {};
234
- if (reasoningParams !== undefined)
235
- prev.reasoningParams = reasoningParams;
236
- providerHooks.set(id, prev);
237
- });
238
- bus.on("provider:register", (p) => {
239
- const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
240
- const payloadModelIds = [];
241
- const payloadCaps = new Map();
242
- for (const m of rawModels) {
243
- if (typeof m === "string") {
244
- payloadModelIds.push(m);
245
- }
246
- else {
247
- payloadModelIds.push(m.id);
248
- payloadCaps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, maxTokens: m.maxTokens, echoReasoning: m.echoReasoning });
249
- }
250
- }
251
- const settings = settingsProviders.get(p.id);
252
- const modelIds = settings?.modelsExplicit && settings.models.length > 0 ? settings.models : payloadModelIds;
253
- const mergedCaps = mergeCaps(settings?.modelCapabilities, payloadCaps, modelIds);
254
- const merged = {
255
- id: p.id,
256
- apiKey: settings?.apiKey ?? p.apiKey,
257
- baseURL: settings?.baseURL ?? p.baseURL,
258
- defaultModel: settings?.defaultModel ?? p.defaultModel,
259
- models: modelIds,
260
- modelsExplicit: settings?.modelsExplicit ?? false,
261
- contextWindow: settings?.contextWindow,
262
- supportsReasoningEffort: settings?.supportsReasoningEffort ?? p.supportsReasoningEffort,
263
- modelCapabilities: mergedCaps,
264
- reasoningShape: settings?.reasoningShape,
265
- };
266
- providerRegistry.set(p.id, merged);
267
- const addModes = modelIds.map((m) => {
268
- const mc = mergedCaps?.get(m);
269
- return {
270
- model: m,
271
- provider: p.id,
272
- providerConfig: { apiKey: merged.apiKey ?? "", baseURL: merged.baseURL },
273
- contextWindow: mc?.contextWindow,
274
- maxTokens: mc?.maxTokens,
275
- reasoning: mc?.reasoning,
276
- supportsReasoningEffort: merged.supportsReasoningEffort,
277
- echoReasoning: mc?.echoReasoning,
278
- buildReasoningParams: bindReasoning(p.id, m),
279
- };
280
- });
281
- bus.emit("config:add-modes", { modes: addModes });
282
- // Late-registration reconcile: if this completes the user's persisted
283
- // default (openrouter's async fetch delivers the full catalog after
284
- // we've already fallen back to mode 0), quietly switch to it.
285
- if (!resolved || !ashActive)
286
- return;
287
- const pendingProvider = getSettings().defaultProvider;
288
- if (pendingProvider !== p.id)
289
- return;
290
- const pendingModel = persistedModelFor(pendingProvider);
291
- if (pendingModel && modelIds.includes(pendingModel) && llmClient.model !== pendingModel) {
292
- bus.emit("config:switch-model", { model: pendingModel });
293
- }
294
- });
295
410
  bus.on("config:switch-provider", ({ provider: name }) => {
296
- const p = providerRegistry.get(name);
411
+ const p = resolvedProviders.get(name);
297
412
  if (!p) {
298
413
  bus.emit("ui:error", { message: `Unknown provider: ${name}` });
299
414
  return;
@@ -308,20 +423,8 @@ export default function agentBackend(ctx) {
308
423
  return;
309
424
  }
310
425
  llmClient.reconfigure({ apiKey: p.apiKey, baseURL: p.baseURL, model: switchModel });
311
- const newModes = p.models.map((m) => {
312
- const mc = p.modelCapabilities?.get(m);
313
- return {
314
- model: m,
315
- provider: name,
316
- providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
317
- contextWindow: mc?.contextWindow ?? p.contextWindow,
318
- maxTokens: mc?.maxTokens ?? (mc?.contextWindow ? Math.min(Math.floor(mc.contextWindow * 0.4), 65536) : undefined),
319
- reasoning: mc?.reasoning,
320
- supportsReasoningEffort: p.supportsReasoningEffort,
321
- echoReasoning: mc?.echoReasoning,
322
- };
323
- });
324
- bus.emit("config:set-modes", { modes: newModes });
426
+ bus.emit("agent:modes-changed", {});
427
+ bus.emit("config:switch-model", { model: switchModel });
325
428
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
326
429
  });
327
430
  bus.onPipe("banner:collect", (e) => {
@@ -340,14 +443,13 @@ export default function agentBackend(ctx) {
340
443
  export { AgentLoop } from "./agent-loop.js";
341
444
  export { ToolRegistry } from "./tool-registry.js";
342
445
  export { runSubagent } from "./subagent.js";
343
- /** Activate the ash backend and any provider whose key is configured. */
446
+ /** Built-in providers register unconditionally so `auth list` can
447
+ * enumerate them; buildModes() skips entries without an apiKey. */
344
448
  export function activateAgent(ctx) {
345
449
  agentBackend(ctx);
346
450
  const agentCtx = ctx;
347
- if (resolveApiKey("openrouter").key)
348
- activateOpenrouter(agentCtx);
349
- if (resolveApiKey("openai").key && !process.env.OPENAI_BASE_URL)
350
- activateOpenai(agentCtx);
451
+ activateOpenrouter(agentCtx);
452
+ activateOpenai(agentCtx);
351
453
  if (process.env.OPENAI_BASE_URL)
352
454
  activateOpenaiCompatible(agentCtx);
353
455
  activateDeepseek(agentCtx);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ctx.agent.llm facade — delegates to an `llm:invoke` handler defined
3
+ * by the ash backend. Other backends (claude-code, pi, opencode) bring
4
+ * their own LLM and do not define this handler; `available` is false
5
+ * under those backends and calls reject.
6
+ */
7
+ import type { LlmInterface } from "./host-types.js";
8
+ interface HandlerGate {
9
+ list: () => string[];
10
+ call: (name: string, ...args: unknown[]) => unknown;
11
+ }
12
+ export declare function createLlmFacade(handlers: HandlerGate): LlmInterface;
13
+ export {};
@@ -2,7 +2,7 @@ export function createLlmFacade(handlers) {
2
2
  const invoke = (messages, maxTokens, model, reasoningEffort) => {
3
3
  const result = handlers.call("llm:invoke", messages, { maxTokens, model, reasoningEffort });
4
4
  if (result === undefined)
5
- return Promise.reject(new Error("ctx.llm: no LLM backend available"));
5
+ return Promise.reject(new Error("ctx.agent.llm: no LLM backend available"));
6
6
  return result;
7
7
  };
8
8
  return {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Nuclear entries are the currency of Tier 2 and Tier 3.
9
9
  */
10
- import type { ChatCompletionMessageParam } from "../utils/llm-client.js";
10
+ import type { ChatCompletionMessageParam } from "./llm-client.js";
11
11
  export interface NuclearEntry {
12
12
  /** Global sequence number. */
13
13
  seq: number;
@@ -11,12 +11,9 @@ function buildReasoningParams(level, _model) {
11
11
  }
12
12
  export default function activate(ctx) {
13
13
  ctx.agent.providers.configure("deepseek", { reasoningParams: buildReasoningParams });
14
- const apiKey = resolveApiKey("deepseek").key;
15
- if (!apiKey)
16
- return;
17
- ctx.bus.emit("provider:register", {
14
+ ctx.agent.providers.register({
18
15
  id: "deepseek",
19
- apiKey,
16
+ apiKey: resolveApiKey("deepseek").key ?? undefined,
20
17
  baseURL: BASE_URL,
21
18
  defaultModel: DEFAULT_MODELS[0].id,
22
19
  models: DEFAULT_MODELS,
@@ -5,11 +5,11 @@ export default function activate(ctx) {
5
5
  // Local servers often need no key; SDK still wants a non-empty string.
6
6
  const apiKey = process.env.OPENAI_API_KEY || "no-key";
7
7
  const id = "openai-compatible";
8
- ctx.bus.emit("provider:register", { id, apiKey, baseURL, models: [] });
8
+ ctx.agent.providers.register({ id, apiKey, baseURL, models: [] });
9
9
  fetchModels(baseURL, apiKey).then((models) => {
10
10
  if (models.length === 0)
11
11
  return;
12
- ctx.bus.emit("provider:register", {
12
+ ctx.agent.providers.register({
13
13
  id,
14
14
  apiKey,
15
15
  baseURL,
@@ -32,15 +32,12 @@ function buildReasoningParams(level, model) {
32
32
  return off ? { reasoning_effort: off } : {};
33
33
  }
34
34
  export default function activate(ctx) {
35
- const apiKey = resolveApiKey("openai").key;
36
- if (!apiKey)
37
- return;
38
35
  if (process.env.OPENAI_BASE_URL)
39
36
  return; // openai-compatible handles this
40
37
  ctx.agent.providers.configure("openai", { reasoningParams: buildReasoningParams });
41
- ctx.bus.emit("provider:register", {
38
+ ctx.agent.providers.register({
42
39
  id: "openai",
43
- apiKey,
40
+ apiKey: resolveApiKey("openai").key ?? undefined,
44
41
  defaultModel: CLOUD_MODELS[0].id,
45
42
  models: CLOUD_MODELS,
46
43
  });
@@ -16,22 +16,22 @@ function buildReasoningParams(level, _model) {
16
16
  }
17
17
  export default function activate(ctx) {
18
18
  const apiKey = resolveApiKey("openrouter").key;
19
- if (!apiKey)
20
- return;
21
19
  ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
22
- ctx.bus.emit("provider:register", {
20
+ ctx.agent.providers.register({
23
21
  id: "openrouter",
24
- apiKey,
22
+ apiKey: apiKey ?? undefined,
25
23
  baseURL: BASE_URL,
26
24
  defaultModel: DEFAULT_MODELS[0],
27
25
  models: DEFAULT_MODELS,
28
26
  });
27
+ if (!apiKey)
28
+ return;
29
29
  fetchModels(apiKey).then((models) => {
30
30
  if (models.length === 0)
31
31
  return;
32
32
  const userOverrides = readUserOverrides();
33
33
  const patterns = readEchoPatterns();
34
- ctx.bus.emit("provider:register", {
34
+ ctx.agent.providers.register({
35
35
  id: "openrouter",
36
36
  apiKey,
37
37
  baseURL: BASE_URL,