agent-sh 0.12.20 → 0.12.22

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 (37) hide show
  1. package/README.md +11 -3
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +30 -5
  4. package/dist/agent/conversation-state.d.ts +3 -2
  5. package/dist/agent/conversation-state.js +27 -14
  6. package/dist/agent/normalize-args.d.ts +29 -0
  7. package/dist/agent/normalize-args.js +56 -0
  8. package/dist/agent/subagent.js +2 -0
  9. package/dist/core.d.ts +3 -1
  10. package/dist/core.js +16 -22
  11. package/dist/event-bus.d.ts +9 -2
  12. package/dist/event-bus.js +9 -0
  13. package/dist/extensions/agent-backend.js +104 -24
  14. package/dist/extensions/index.js +8 -3
  15. package/dist/extensions/providers/deepseek.d.ts +8 -0
  16. package/dist/extensions/providers/deepseek.js +23 -0
  17. package/dist/extensions/providers/openai-compatible.d.ts +7 -0
  18. package/dist/extensions/providers/openai-compatible.js +30 -0
  19. package/dist/extensions/providers/openai.d.ts +7 -0
  20. package/dist/extensions/providers/openai.js +39 -0
  21. package/dist/extensions/{openrouter.d.ts → providers/openrouter.d.ts} +1 -1
  22. package/dist/extensions/{openrouter.js → providers/openrouter.js} +5 -3
  23. package/dist/extensions/slash-commands.js +0 -24
  24. package/dist/extensions/tui-renderer.js +28 -15
  25. package/dist/index.js +8 -33
  26. package/dist/settings.d.ts +2 -0
  27. package/dist/settings.js +1 -0
  28. package/dist/types.d.ts +14 -1
  29. package/dist/utils/box-frame.js +14 -8
  30. package/dist/utils/llm-client.d.ts +5 -1
  31. package/dist/utils/llm-client.js +6 -1
  32. package/dist/utils/llm-facade.js +5 -5
  33. package/examples/extensions/pi-bridge/README.md +12 -19
  34. package/examples/extensions/pi-bridge/index.ts +307 -35
  35. package/package.json +1 -1
  36. package/dist/extensions/openai.d.ts +0 -9
  37. package/dist/extensions/openai.js +0 -49
@@ -2,6 +2,7 @@ import { AgentLoop } from "../agent/agent-loop.js";
2
2
  import { LlmClient } from "../utils/llm-client.js";
3
3
  import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
4
4
  import { PACKAGE_VERSION } from "../utils/package-version.js";
5
+ import { discoverSkills } from "../agent/skills.js";
5
6
  /** Read the user's persisted defaultModel for a provider, if any. */
6
7
  function persistedModelFor(providerName) {
7
8
  if (!providerName)
@@ -11,24 +12,50 @@ function persistedModelFor(providerName) {
11
12
  function defaultReasoningBuilder(level) {
12
13
  return level === "off" ? {} : { reasoning_effort: level };
13
14
  }
15
+ function mergeCaps(settingsCaps, payloadCaps, modelIds) {
16
+ if (!settingsCaps)
17
+ return payloadCaps.size > 0 ? payloadCaps : undefined;
18
+ const out = new Map();
19
+ for (const id of modelIds) {
20
+ const s = settingsCaps.get(id);
21
+ const p = payloadCaps.get(id);
22
+ if (!s && !p)
23
+ continue;
24
+ out.set(id, {
25
+ reasoning: s?.reasoning ?? p?.reasoning,
26
+ contextWindow: s?.contextWindow ?? p?.contextWindow,
27
+ maxTokens: s?.maxTokens ?? p?.maxTokens,
28
+ echoReasoning: s?.echoReasoning ?? p?.echoReasoning,
29
+ });
30
+ }
31
+ return out.size > 0 ? out : undefined;
32
+ }
14
33
  export default function agentBackend(ctx) {
15
34
  const { bus } = ctx;
16
35
  const config = ctx.call("config:get-shell-config") ?? {};
17
- // Seed from settings.json; runtime provider:register events add more.
36
+ // Immutable settings snapshot; provider:register payloads merge against it.
18
37
  const providerRegistry = new Map();
38
+ const settingsProviders = new Map();
19
39
  for (const name of getProviderNames()) {
20
40
  const p = resolveProvider(name);
21
- if (p)
41
+ if (p) {
22
42
  providerRegistry.set(name, p);
43
+ settingsProviders.set(name, p);
44
+ }
23
45
  }
24
46
  const providerHooks = new Map();
47
+ // Bakes model id into the hook so AgentMode.buildReasoningParams keeps
48
+ // its (level) signature while the hook can branch on model.
49
+ const bindReasoning = (shapeId, model) => {
50
+ const hook = providerHooks.get(shapeId)?.reasoningParams;
51
+ return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
52
+ };
25
53
  const buildModes = () => {
26
54
  const allModes = [];
27
55
  for (const [id, p] of providerRegistry) {
28
56
  if (!p.apiKey)
29
57
  continue;
30
58
  const shapeId = p.reasoningShape ?? id;
31
- const buildReasoningParams = providerHooks.get(shapeId)?.reasoningParams ?? defaultReasoningBuilder;
32
59
  for (const model of p.models) {
33
60
  const mc = p.modelCapabilities?.get(model);
34
61
  allModes.push({
@@ -40,7 +67,7 @@ export default function agentBackend(ctx) {
40
67
  reasoning: mc?.reasoning,
41
68
  supportsReasoningEffort: p.supportsReasoningEffort,
42
69
  echoReasoning: mc?.echoReasoning,
43
- buildReasoningParams,
70
+ buildReasoningParams: bindReasoning(shapeId, model),
44
71
  });
45
72
  }
46
73
  }
@@ -55,11 +82,15 @@ export default function agentBackend(ctx) {
55
82
  return llmClient.complete({
56
83
  messages: messages,
57
84
  max_tokens: opts?.maxTokens,
85
+ model: opts?.model,
86
+ reasoning_effort: opts?.reasoningEffort,
58
87
  });
59
88
  });
60
89
  let modes = [];
61
90
  let initialModeIndex = 0;
62
91
  let resolved = false;
92
+ // Gates late-registration reconcile so its config:switch-model emit doesn't misroute under a non-ash backend.
93
+ let ashActive = false;
63
94
  bus.onPipe("config:get-initial-modes", () => ({ modes, initialModeIndex }));
64
95
  // AgentLoop must be constructed *before* user extensions activate,
65
96
  // because its ctor defines handlers (history:append, etc.) that
@@ -76,7 +107,9 @@ export default function agentBackend(ctx) {
76
107
  instanceId: ctx.instanceId,
77
108
  history: config.history,
78
109
  });
79
- bus.on("core:extensions-loaded", () => {
110
+ let loadedExtensionNames = [];
111
+ bus.on("core:extensions-loaded", ({ names }) => {
112
+ loadedExtensionNames = names;
80
113
  const settings = getSettings();
81
114
  // If the user didn't pick a default, fall back to the first registered
82
115
  // provider (built-in load order biases to openrouter → openai).
@@ -121,9 +154,37 @@ export default function agentBackend(ctx) {
121
154
  resolved = true;
122
155
  bus.emit("agent:register-backend", {
123
156
  name: "ash",
124
- kill: () => agentLoop.kill(),
157
+ kill: () => {
158
+ ashActive = false;
159
+ bus.emit("command:unregister", { name: "/compact" });
160
+ bus.emit("command:unregister", { name: "/context" });
161
+ agentLoop.kill();
162
+ },
125
163
  start: async () => {
126
164
  agentLoop.wire();
165
+ ashActive = true;
166
+ bus.emit("command:register", {
167
+ name: "/compact",
168
+ description: "Compact conversation via the active compaction strategy",
169
+ handler: () => bus.emit("agent:compact-request", {}),
170
+ });
171
+ bus.emit("command:register", {
172
+ name: "/context",
173
+ description: "Show context budget usage",
174
+ handler: () => {
175
+ const stats = bus.emitPipe("context:get-stats", {
176
+ activeTokens: 0,
177
+ totalTokens: 0,
178
+ budgetTokens: 0,
179
+ });
180
+ const pct = stats.budgetTokens > 0
181
+ ? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
182
+ : 0;
183
+ bus.emit("ui:info", {
184
+ message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
185
+ });
186
+ },
187
+ });
127
188
  bus.emit("agent:info", {
128
189
  name: "ash",
129
190
  version: PACKAGE_VERSION,
@@ -142,46 +203,52 @@ export default function agentBackend(ctx) {
142
203
  });
143
204
  bus.on("provider:register", (p) => {
144
205
  const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
145
- const modelIds = [];
146
- const caps = new Map();
206
+ const payloadModelIds = [];
207
+ const payloadCaps = new Map();
147
208
  for (const m of rawModels) {
148
209
  if (typeof m === "string") {
149
- modelIds.push(m);
210
+ payloadModelIds.push(m);
150
211
  }
151
212
  else {
152
- modelIds.push(m.id);
153
- caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, maxTokens: m.maxTokens, echoReasoning: m.echoReasoning });
213
+ payloadModelIds.push(m.id);
214
+ payloadCaps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow, maxTokens: m.maxTokens, echoReasoning: m.echoReasoning });
154
215
  }
155
216
  }
156
- providerRegistry.set(p.id, {
217
+ const settings = settingsProviders.get(p.id);
218
+ const modelIds = settings?.modelsExplicit && settings.models.length > 0 ? settings.models : payloadModelIds;
219
+ const mergedCaps = mergeCaps(settings?.modelCapabilities, payloadCaps, modelIds);
220
+ const merged = {
157
221
  id: p.id,
158
- apiKey: p.apiKey,
159
- baseURL: p.baseURL,
160
- defaultModel: p.defaultModel,
222
+ apiKey: settings?.apiKey ?? p.apiKey,
223
+ baseURL: settings?.baseURL ?? p.baseURL,
224
+ defaultModel: settings?.defaultModel ?? p.defaultModel,
161
225
  models: modelIds,
162
- supportsReasoningEffort: p.supportsReasoningEffort,
163
- modelCapabilities: caps.size > 0 ? caps : undefined,
164
- });
165
- const buildReasoningParams = providerHooks.get(p.id)?.reasoningParams ?? defaultReasoningBuilder;
226
+ modelsExplicit: settings?.modelsExplicit ?? false,
227
+ contextWindow: settings?.contextWindow,
228
+ supportsReasoningEffort: settings?.supportsReasoningEffort ?? p.supportsReasoningEffort,
229
+ modelCapabilities: mergedCaps,
230
+ reasoningShape: settings?.reasoningShape,
231
+ };
232
+ providerRegistry.set(p.id, merged);
166
233
  const addModes = modelIds.map((m) => {
167
- const mc = caps.get(m);
234
+ const mc = mergedCaps?.get(m);
168
235
  return {
169
236
  model: m,
170
237
  provider: p.id,
171
- providerConfig: { apiKey: p.apiKey ?? "", baseURL: p.baseURL },
238
+ providerConfig: { apiKey: merged.apiKey ?? "", baseURL: merged.baseURL },
172
239
  contextWindow: mc?.contextWindow,
173
240
  maxTokens: mc?.maxTokens,
174
241
  reasoning: mc?.reasoning,
175
- supportsReasoningEffort: p.supportsReasoningEffort,
242
+ supportsReasoningEffort: merged.supportsReasoningEffort,
176
243
  echoReasoning: mc?.echoReasoning,
177
- buildReasoningParams,
244
+ buildReasoningParams: bindReasoning(p.id, m),
178
245
  };
179
246
  });
180
247
  bus.emit("config:add-modes", { modes: addModes });
181
248
  // Late-registration reconcile: if this completes the user's persisted
182
249
  // default (openrouter's async fetch delivers the full catalog after
183
250
  // we've already fallen back to mode 0), quietly switch to it.
184
- if (!resolved)
251
+ if (!resolved || !ashActive)
185
252
  return;
186
253
  const pendingProvider = getSettings().defaultProvider;
187
254
  if (pendingProvider !== p.id)
@@ -225,4 +292,17 @@ export default function agentBackend(ctx) {
225
292
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
226
293
  bus.emit("config:changed", {});
227
294
  });
295
+ bus.onPipe("banner:collect", (e) => {
296
+ const settings = getSettings();
297
+ if (settings.defaultBackend && settings.defaultBackend !== "ash")
298
+ return e;
299
+ if (loadedExtensionNames.length > 0) {
300
+ e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
301
+ }
302
+ const skills = discoverSkills(ctx.call("cwd") ?? process.cwd());
303
+ if (skills.length > 0) {
304
+ e.sections.push({ label: "Skills", items: skills.map((s) => s.name) });
305
+ }
306
+ return e;
307
+ });
228
308
  }
@@ -3,10 +3,15 @@ export const BUILTIN_EXTENSIONS = [
3
3
  { name: "agent-backend", load: () => import("./agent-backend.js").then(m => m.default) },
4
4
  { name: "openrouter",
5
5
  when: () => !!process.env.OPENROUTER_API_KEY,
6
- load: () => import("./openrouter.js").then(m => m.default) },
6
+ load: () => import("./providers/openrouter.js").then(m => m.default) },
7
7
  { name: "openai",
8
- when: () => !!process.env.OPENAI_API_KEY,
9
- load: () => import("./openai.js").then(m => m.default) },
8
+ when: () => !!process.env.OPENAI_API_KEY && !process.env.OPENAI_BASE_URL,
9
+ load: () => import("./providers/openai.js").then(m => m.default) },
10
+ { name: "openai-compatible",
11
+ when: () => !!process.env.OPENAI_BASE_URL,
12
+ load: () => import("./providers/openai-compatible.js").then(m => m.default) },
13
+ { name: "deepseek",
14
+ load: () => import("./providers/deepseek.js").then(m => m.default) },
10
15
  { name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
11
16
  { name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
12
17
  { name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Native DeepSeek (api.deepseek.com). V4 ignores reasoning_effort for
3
+ * on/off — disable lives in a separate `thinking` field that defaults
4
+ * to enabled. The hook always attaches; provider registration via env
5
+ * is opt-in alongside any settings.json entry.
6
+ */
7
+ import type { ExtensionContext } from "../../types.js";
8
+ export default function activate(ctx: ExtensionContext): void;
@@ -0,0 +1,23 @@
1
+ const BASE_URL = "https://api.deepseek.com";
2
+ const DEFAULT_MODELS = [
3
+ { id: "deepseek-v4-flash", reasoning: true, echoReasoning: true },
4
+ { id: "deepseek-v4-pro", reasoning: true, echoReasoning: true },
5
+ ];
6
+ function buildReasoningParams(level, _model) {
7
+ return level === "off"
8
+ ? { thinking: { type: "disabled" } }
9
+ : { thinking: { type: "enabled" }, reasoning_effort: level };
10
+ }
11
+ export default function activate(ctx) {
12
+ ctx.providers.configure("deepseek", { reasoningParams: buildReasoningParams });
13
+ const apiKey = process.env.DEEPSEEK_API_KEY;
14
+ if (!apiKey)
15
+ return;
16
+ ctx.bus.emit("provider:register", {
17
+ id: "deepseek",
18
+ apiKey,
19
+ baseURL: BASE_URL,
20
+ defaultModel: DEFAULT_MODELS[0].id,
21
+ models: DEFAULT_MODELS,
22
+ });
23
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OpenAI Chat Completions-compatible local/3rd-party server (Ollama, LM
3
+ * Studio, vLLM, llama.cpp, …). No reasoning hook — the right shape depends
4
+ * on which model the server is serving; user extensions can add one.
5
+ */
6
+ import type { ExtensionContext } from "../../types.js";
7
+ export default function activate(ctx: ExtensionContext): void;
@@ -0,0 +1,30 @@
1
+ export default function activate(ctx) {
2
+ const baseURL = process.env.OPENAI_BASE_URL;
3
+ if (!baseURL)
4
+ return;
5
+ // Local servers often need no key; SDK still wants a non-empty string.
6
+ const apiKey = process.env.OPENAI_API_KEY || "no-key";
7
+ const id = "openai-compatible";
8
+ ctx.bus.emit("provider:register", { id, apiKey, baseURL, models: [] });
9
+ fetchModels(baseURL, apiKey).then((models) => {
10
+ if (models.length === 0)
11
+ return;
12
+ ctx.bus.emit("provider:register", {
13
+ id,
14
+ apiKey,
15
+ baseURL,
16
+ defaultModel: models[0],
17
+ models,
18
+ });
19
+ }).catch(() => { });
20
+ }
21
+ async function fetchModels(baseURL, apiKey) {
22
+ const headers = {};
23
+ if (apiKey && apiKey !== "no-key")
24
+ headers.Authorization = `Bearer ${apiKey}`;
25
+ const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, { headers });
26
+ if (!res.ok)
27
+ return [];
28
+ const data = await res.json();
29
+ return (data.data ?? []).map((m) => m.id);
30
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Cloud OpenAI (api.openai.com). reasoning_effort vocabulary diverges per
3
+ * family: o-series has no off; gpt-5-codex floors at "low"; plain gpt-5
4
+ * floors at "minimal"; gpt-5.1+ accepts "none" as documented full off.
5
+ */
6
+ import type { ExtensionContext } from "../../types.js";
7
+ export default function activate(ctx: ExtensionContext): void;
@@ -0,0 +1,39 @@
1
+ const CLOUD_MODELS = [
2
+ { id: "gpt-5", reasoning: true },
3
+ { id: "gpt-4.1", reasoning: false },
4
+ { id: "gpt-4o", reasoning: false },
5
+ { id: "gpt-4o-mini", reasoning: false },
6
+ { id: "o3", reasoning: true },
7
+ { id: "o3-mini", reasoning: true },
8
+ ];
9
+ function offEffortFor(model) {
10
+ if (/^o\d/.test(model))
11
+ return null;
12
+ if (model.startsWith("gpt-5-codex"))
13
+ return "low";
14
+ if (/^gpt-5\.[1-9]/.test(model))
15
+ return "none";
16
+ if (/^gpt-5(?!\.)/.test(model))
17
+ return "minimal";
18
+ return null;
19
+ }
20
+ function buildReasoningParams(level, model) {
21
+ if (level !== "off")
22
+ return { reasoning_effort: level };
23
+ const off = model ? offEffortFor(model) : null;
24
+ return off ? { reasoning_effort: off } : {};
25
+ }
26
+ export default function activate(ctx) {
27
+ const apiKey = process.env.OPENAI_API_KEY;
28
+ if (!apiKey)
29
+ return;
30
+ if (process.env.OPENAI_BASE_URL)
31
+ return; // openai-compatible handles this
32
+ ctx.providers.configure("openai", { reasoningParams: buildReasoningParams });
33
+ ctx.bus.emit("provider:register", {
34
+ id: "openai",
35
+ apiKey,
36
+ defaultModel: CLOUD_MODELS[0].id,
37
+ models: CLOUD_MODELS,
38
+ });
39
+ }
@@ -3,5 +3,5 @@
3
3
  * Registers curated defaults synchronously so the first query works, then
4
4
  * fetches the full catalog to populate /model autocomplete.
5
5
  */
6
- import type { ExtensionContext } from "../types.js";
6
+ import type { ExtensionContext } from "../../types.js";
7
7
  export default function activate(ctx: ExtensionContext): void;
@@ -1,4 +1,4 @@
1
- import { getSettings } from "../settings.js";
1
+ import { getSettings } from "../../settings.js";
2
2
  const BASE_URL = "https://openrouter.ai/api/v1";
3
3
  const DEFAULT_MODELS = ["deepseek/deepseek-v4-flash"];
4
4
  // Built-in defaults for models requiring reasoning_content echoed back
@@ -6,9 +6,11 @@ const DEFAULT_MODELS = ["deepseek/deepseek-v4-flash"];
6
6
  // providers.openrouter.echoReasoningPatterns = ["deepseek", "..."]
7
7
  // providers.openrouter.models[*].echoReasoning = true | false
8
8
  const BUILTIN_ECHO_REASONING_PATTERNS = [/deepseek/i];
9
- function buildReasoningParams(level) {
9
+ // `effort: "none"` is the documented disable; honored by OpenAI/Grok, ignored
10
+ // by Anthropic/Gemini/DeepSeek-via-OpenRouter (use native deepseek for a hard off).
11
+ function buildReasoningParams(level, _model) {
10
12
  return level === "off"
11
- ? { reasoning: { enabled: false } }
13
+ ? { reasoning: { effort: "none" } }
12
14
  : { reasoning: { effort: level } };
13
15
  }
14
16
  export default function activate(ctx) {
@@ -76,30 +76,6 @@ export default function activate(ctx) {
76
76
  }
77
77
  },
78
78
  });
79
- register({
80
- name: "/compact",
81
- description: "Compact conversation via the active compaction strategy",
82
- handler: () => {
83
- bus.emit("agent:compact-request", {});
84
- },
85
- });
86
- register({
87
- name: "/context",
88
- description: "Show context budget usage",
89
- handler: () => {
90
- const stats = bus.emitPipe("context:get-stats", {
91
- activeTokens: 0,
92
- totalTokens: 0,
93
- budgetTokens: 0,
94
- });
95
- const pct = stats.budgetTokens > 0
96
- ? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
97
- : 0;
98
- bus.emit("ui:info", {
99
- message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
100
- });
101
- },
102
- });
103
79
  register({
104
80
  name: "/reload",
105
81
  description: "Reload user extensions from ~/.agent-sh/extensions/",
@@ -67,6 +67,8 @@ function createRenderState() {
67
67
  isThinking: false,
68
68
  showThinkingText: false,
69
69
  thinkingPending: false,
70
+ previewedDiffPending: false,
71
+ previewedDiffToolIds: new Set(),
70
72
  };
71
73
  }
72
74
  export default function activate(ctx) {
@@ -175,21 +177,20 @@ export default function activate(ctx) {
175
177
  s.thinkingPending = true;
176
178
  if (!s.isThinking) {
177
179
  s.isThinking = true;
178
- if (s.showThinkingText) {
179
- stopCurrentSpinner();
180
- if (!s.renderer)
181
- startAgentResponse();
182
- }
183
- else {
180
+ if (!s.showThinkingText)
184
181
  startThinkingSpinner();
185
- }
186
182
  }
187
- if (s.showThinkingText && e.text) {
188
- s.thinkingPending = false;
183
+ if (s.showThinkingText) {
184
+ stopCurrentSpinner();
189
185
  if (!s.renderer)
190
186
  startAgentResponse();
191
- s.renderer.push(`${p.dim}${e.text}${p.reset}`);
192
- drain();
187
+ if (e.text) {
188
+ s.thinkingPending = false;
189
+ // Wrap each sub-line so dim survives \n boundaries in the renderer.
190
+ const wrapped = `${p.dim}${e.text.replace(/\n/g, `${p.reset}\n${p.dim}`)}${p.reset}`;
191
+ s.renderer.push(wrapped);
192
+ drain();
193
+ }
193
194
  }
194
195
  });
195
196
  bus.on("agent:response-chunk", (e) => {
@@ -272,6 +273,10 @@ export default function activate(ctx) {
272
273
  s.currentToolKind = e.kind;
273
274
  s.toolStartTime = Date.now();
274
275
  s.orphanContHeaderKind = undefined;
276
+ if (s.previewedDiffPending && e.toolCallId) {
277
+ s.previewedDiffToolIds.add(e.toolCallId);
278
+ }
279
+ s.previewedDiffPending = false;
275
280
  if (e.title === "user_shell") {
276
281
  finalizeToolGroup();
277
282
  closeToolLine();
@@ -335,11 +340,18 @@ export default function activate(ctx) {
335
340
  s.toolExitCode = e.exitCode;
336
341
  if (e.exitCode !== 0)
337
342
  s.toolGroupAllOk = false;
343
+ let resultDisplay = e.resultDisplay;
344
+ if (e.toolCallId && s.previewedDiffToolIds.has(e.toolCallId)) {
345
+ s.previewedDiffToolIds.delete(e.toolCallId);
346
+ if (resultDisplay?.body?.kind === "diff") {
347
+ resultDisplay = { ...resultDisplay, body: undefined };
348
+ }
349
+ }
338
350
  if (s.toolGroupKind) {
339
351
  // Grouped tool — track success/failure and summaries, show aggregate on ⎿ line.
340
352
  // Don't restart spinner between grouped tools — it's already running from group start.
341
- if (e.resultDisplay?.summary)
342
- s.toolGroupSummaries.push(e.resultDisplay.summary);
353
+ if (resultDisplay?.summary)
354
+ s.toolGroupSummaries.push(resultDisplay.summary);
343
355
  if (e.toolCallId)
344
356
  s.pendingToolCompletes.delete(e.toolCallId);
345
357
  s.toolGroupCompletedCount++;
@@ -358,10 +370,10 @@ export default function activate(ctx) {
358
370
  if (pending)
359
371
  s.pendingToolCompletes.delete(e.toolCallId);
360
372
  if (pending?.orphaned) {
361
- showOrphanedComplete(e.exitCode, e.resultDisplay, pending.title, pending.kind, pending.displayDetail);
373
+ showOrphanedComplete(e.exitCode, resultDisplay, pending.title, pending.kind, pending.displayDetail);
362
374
  }
363
375
  else {
364
- showToolComplete(e.exitCode, e.resultDisplay, pending?.displayDetail ?? pending?.title);
376
+ showToolComplete(e.exitCode, resultDisplay, pending?.displayDetail ?? pending?.title);
365
377
  }
366
378
  s.currentToolKind = undefined;
367
379
  s.spinnerStartTime = 0;
@@ -432,6 +444,7 @@ export default function activate(ctx) {
432
444
  // Mark lastContentKind as "tool" so the tool call line that follows
433
445
  // doesn't inject an extra gap between the diff box and the checkmark.
434
446
  s.lastContentKind = "tool";
447
+ s.previewedDiffPending = true;
435
448
  }
436
449
  // Don't endAgentResponse() here — permission requests that aren't
437
450
  // file-write diffs are handled inline (auto-approved or by extensions).
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ import { palette as p } from "./utils/palette.js";
7
7
  import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
- import { discoverSkills } from "./agent/skills.js";
11
10
  import { runInit } from "./init.js";
12
11
  import { PACKAGE_VERSION } from "./utils/package-version.js";
13
12
  /**
@@ -270,11 +269,8 @@ async function main() {
270
269
  if (process.env.DEBUG) {
271
270
  console.error('[agent-sh] Extensions loaded');
272
271
  }
273
- // Tell deferred-init listeners (agent-backend) that the provider
274
- // registry is now complete.
275
- core.bus.emit("core:extensions-loaded", {});
276
- // ── Discover skills ───────────────────────────────────────────
277
- const skills = discoverSkills(process.cwd());
272
+ // Names ride along so backend extensions can build banner sections.
273
+ core.bus.emit("core:extensions-loaded", { names: loadedExtensions });
278
274
  // ── Activate agent backend ────────────────────────────────────
279
275
  // Extensions had their chance to register via agent:register-backend.
280
276
  // If none did, the built-in AgentLoop gets wired to bus events.
@@ -288,6 +284,7 @@ async function main() {
288
284
  " Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
289
285
  process.exit(1);
290
286
  }
287
+ // No await: banner must out-race the shell's PS1 arriving via PTY.
291
288
  core.activateBackend();
292
289
  // ── Startup banner ───────────────────────────────────────────
293
290
  const settings = getSettings();
@@ -295,31 +292,11 @@ async function main() {
295
292
  const termW = process.stdout.columns || 80;
296
293
  const bannerW = Math.min(termW, 60);
297
294
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
298
- const info = agentInfo;
299
- const backendReady = !!info?.model;
300
- const backendName = info?.name ?? "ash";
301
- const model = info?.model;
302
- const provider = info?.provider;
303
- const modelValue = model
304
- ? provider ? `${model} [${provider}]` : model
305
- : null;
295
+ const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
296
+ ? settings.defaultBackend
297
+ : backendNames[0];
306
298
  let sections = "";
307
- sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${backendReady ? "" : " (not configured)"}${p.reset}`;
308
- if (modelValue) {
309
- sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
310
- }
311
- if (loadedExtensions.length > 0) {
312
- sections += `\n\n ${p.muted}Extensions:${p.reset}`;
313
- for (const name of loadedExtensions) {
314
- sections += `\n ${p.dim}${name}${p.reset}`;
315
- }
316
- }
317
- if (skills.length > 0) {
318
- sections += `\n\n ${p.muted}Skills:${p.reset}`;
319
- for (const s of skills) {
320
- sections += `\n ${p.dim}${s.name}${p.reset}`;
321
- }
322
- }
299
+ sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
323
300
  const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
324
301
  for (const sec of extSections) {
325
302
  sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
@@ -327,9 +304,7 @@ async function main() {
327
304
  sections += `\n ${p.dim}${item}${p.reset}`;
328
305
  }
329
306
  }
330
- const hint = backendReady
331
- ? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
332
- : `${p.muted}Set ${p.warning}OPENROUTER_API_KEY${p.muted} or ${p.warning}OPENAI_API_KEY${p.muted} and restart to enable AI${p.reset}`;
307
+ const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
333
308
  const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
334
309
  process.stdout.write("\n" + borderLine + "\n" +
335
310
  " " + productName +
@@ -143,6 +143,8 @@ export interface ResolvedProvider {
143
143
  baseURL?: string;
144
144
  defaultModel?: string;
145
145
  models: string[];
146
+ /** User explicitly listed `models` (locks the catalog to that list). */
147
+ modelsExplicit: boolean;
146
148
  contextWindow?: number;
147
149
  /** Provider supports the reasoning_effort parameter. Default: true. */
148
150
  supportsReasoningEffort?: boolean;
package/dist/settings.js CHANGED
@@ -160,6 +160,7 @@ export function resolveProvider(name) {
160
160
  baseURL: provider.baseURL,
161
161
  defaultModel,
162
162
  models: modelIds.length ? modelIds : (defaultModel ? [defaultModel] : []),
163
+ modelsExplicit: Array.isArray(provider.models),
163
164
  contextWindow: provider.contextWindow,
164
165
  modelCapabilities: caps.size > 0 ? caps : undefined,
165
166
  reasoningShape: provider.reasoningShape,
package/dist/types.d.ts CHANGED
@@ -67,14 +67,27 @@ export interface LlmSession {
67
67
  }
68
68
  export interface LlmInterface {
69
69
  readonly available: boolean;
70
+ /** `model` overrides the globally-configured model for this call only.
71
+ * Provider-specific identifier (e.g. "claude-haiku-4-5"). When omitted,
72
+ * the active provider's configured default is used.
73
+ *
74
+ * `reasoningEffort` controls thinking-model token allocation between
75
+ * reasoning and final content (e.g. "low", "medium", "high", or
76
+ * provider-specific). For non-reasoning models it is ignored. Set to
77
+ * "low" for cheap structured-output calls so reasoning doesn't exhaust
78
+ * the max-tokens budget and leave content empty. */
70
79
  ask(opts: {
71
80
  query: string;
72
81
  system?: string;
73
82
  maxTokens?: number;
83
+ model?: string;
84
+ reasoningEffort?: string;
74
85
  }): Promise<string>;
75
86
  session(opts?: {
76
87
  system?: string;
77
88
  maxTokens?: number;
89
+ model?: string;
90
+ reasoningEffort?: string;
78
91
  }): LlmSession;
79
92
  }
80
93
  export interface AgentShellConfig {
@@ -158,7 +171,7 @@ export interface ExtensionContext {
158
171
  }) => () => void;
159
172
  providers: {
160
173
  configure: (id: string, opts: {
161
- reasoningParams?: (level: string) => Record<string, unknown>;
174
+ reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
162
175
  }) => void;
163
176
  };
164
177
  llm: LlmInterface;