agent-sh 0.14.6 → 0.14.8

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.
@@ -98,7 +98,9 @@ export class AgentLoop {
98
98
  this.conversation = new ConversationState(this.handlers, this.instanceId);
99
99
  this.activeMode = config.initialMode ?? { model: config.llmClient.model };
100
100
  // Tool protocol — controls how tools are presented to the LLM
101
- this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api", getSettings().coreTools ?? []);
101
+ const { names: fromExtensions } = this.bus.emitPipe("agent:core-tools:collect", { names: [] });
102
+ const coreTools = Array.from(new Set([...(getSettings().coreTools ?? []), ...fromExtensions]));
103
+ this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api", coreTools);
102
104
  // Register core tools
103
105
  this.registerCoreTools();
104
106
  // Register any protocol-provided tools (e.g. load_tool for deferred-lookup).
@@ -9,6 +9,10 @@ export interface AgentIdentity {
9
9
  }
10
10
  declare module "../core/event-bus.js" {
11
11
  interface BusEvents {
12
+ /** Sync pipe: extensions append core tool names; unioned with settings.coreTools. */
13
+ "agent:core-tools:collect": {
14
+ names: string[];
15
+ };
12
16
  "agent:providers": {
13
17
  providers: ProviderRegistration[];
14
18
  };
@@ -8,6 +8,7 @@ import { LlmClient } from "./llm-client.js";
8
8
  import { createLlmFacade } from "./llm-facade.js";
9
9
  import { registerReadOnlyTool, unregisterReadOnlyTool } from "./nuclear-form.js";
10
10
  import { resolveProvider, getProviderNames, getSettings } from "../core/settings.js";
11
+ import { resolveApiKey } from "../cli/auth/keys.js";
11
12
  import { discoverSkills } from "./skills.js";
12
13
  import activateOpenrouter from "./providers/openrouter.js";
13
14
  import activateOpenai from "./providers/openai.js";
@@ -205,6 +206,7 @@ export default function agentBackend(ctx) {
205
206
  },
206
207
  };
207
208
  ctx.agent = agentSurface;
209
+ ctx.define("provider:resolve-api-key", (id) => resolveApiKey(id));
208
210
  // Core tools register at activate — before extensions load — so
209
211
  // extensions that look them up at activate time (e.g. scheme.ts) find them.
210
212
  // conversation_recall stays in AgentLoop (needs session state).
@@ -343,8 +345,12 @@ export default function agentBackend(ctx) {
343
345
  loadedExtensionNames = names;
344
346
  resolvedProviders = computeResolvedProviders();
345
347
  const settings = getSettings();
346
- const providerName = config.provider ?? settings.defaultProvider
347
- ?? (resolvedProviders.size > 0 ? resolvedProviders.keys().next().value : undefined);
348
+ // Built-ins register unconditionally so `auth list` can enumerate them;
349
+ // the fallback must skip keyless entries or it lands on openrouter and
350
+ // bails at the `!effectiveApiKey` guard below.
351
+ const providerName = config.provider
352
+ ?? settings.defaultProvider
353
+ ?? [...resolvedProviders].find(([, p]) => p.apiKey)?.[0];
348
354
  const activeProvider = providerName ? resolvedProviders.get(providerName) ?? null : null;
349
355
  // Persisted defaultModel wins over openrouter's hardcoded DEFAULT_MODELS[0].
350
356
  const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
@@ -1,15 +1,20 @@
1
1
  /** Bootstrap a throwaway core to enumerate provider ids extensions
2
2
  * would register, so `auth list` shows ids the user hasn't keyed yet. */
3
+ import * as path from "node:path";
3
4
  import { createCore } from "../../core/index.js";
4
5
  import { activateAgent } from "../../agent/index.js";
5
6
  import { loadExtensions } from "../../core/extension-loader.js";
6
7
  import { loadBuiltinExtensions } from "../../extensions/index.js";
7
- import { getSettings } from "../../core/settings.js";
8
+ import { CONFIG_DIR, getSettings } from "../../core/settings.js";
9
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
10
+ const BARE_IMPORT_RE = /Cannot find (?:package|module) ['"]agent-sh\/[^'"]+['"]/;
8
11
  let cached = null;
9
12
  export async function discoverExtensionProviders() {
10
13
  if (cached)
11
14
  return cached;
12
15
  const core = createCore({});
16
+ const errors = [];
17
+ core.bus.on("ui:error", ({ message }) => { errors.push(message); });
13
18
  try {
14
19
  const ctx = core.extensionContext({ quit: () => { } });
15
20
  activateAgent(ctx);
@@ -17,6 +22,18 @@ export async function discoverExtensionProviders() {
17
22
  await loadExtensions(ctx).catch(() => { });
18
23
  const { providers } = core.bus.emitPipe("agent:providers", { providers: [] });
19
24
  cached = providers.map((p) => ({ id: p.id, noAuth: p.noAuth }));
25
+ if (errors.length > 0) {
26
+ process.stderr.write(`\n[agent-sh] extension load errors during provider discovery:\n`);
27
+ for (const msg of errors) {
28
+ process.stderr.write(` - ${msg}\n`);
29
+ if (BARE_IMPORT_RE.test(msg) && msg.includes(EXT_DIR)) {
30
+ process.stderr.write(` ↳ Single-file extensions can't runtime-import agent-sh modules from ${EXT_DIR}.\n` +
31
+ ` Use ctx.call(...) for runtime needs, or convert to a directory extension\n` +
32
+ ` with its own package.json + node_modules.\n`);
33
+ }
34
+ }
35
+ process.stderr.write(`\n`);
36
+ }
20
37
  return cached;
21
38
  }
22
39
  finally {
@@ -153,7 +153,7 @@ export async function loadExtensions(ctx, cliExtensions) {
153
153
  if (settings.extensions.length > 0) {
154
154
  specifiers.push(...settings.extensions);
155
155
  }
156
- const userSpecifiers = await discoverUserExtensions();
156
+ const userSpecifiers = await discoverUserExtensions(ctx);
157
157
  specifiers.push(...userSpecifiers);
158
158
  const seen = new Set();
159
159
  const unique = specifiers.filter((s) => {
@@ -165,19 +165,30 @@ export async function loadExtensions(ctx, cliExtensions) {
165
165
  const loaded = await loadSpecifiers(unique, ctx, false);
166
166
  return loaded;
167
167
  }
168
- async function discoverUserExtensions() {
168
+ async function discoverUserExtensions(ctx) {
169
169
  const specifiers = [];
170
170
  const disabled = new Set(getSettings().disabledExtensions ?? []);
171
+ let entries;
171
172
  try {
172
- const entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
173
- for (const entry of entries) {
174
- // Disable check: directory name for dir-extensions, or basename sans
175
- // extension for file-extensions. Lets settings.json turn one off
176
- // without renaming it.
177
- const nameForDisable = entry.name.replace(/\.[^.]+$/, "");
178
- if (disabled.has(nameForDisable))
179
- continue;
180
- const fullPath = path.join(EXT_DIR, entry.name);
173
+ entries = await fs.readdir(EXT_DIR, { withFileTypes: true });
174
+ }
175
+ catch (err) {
176
+ if (err.code === "ENOENT")
177
+ return specifiers;
178
+ ctx.bus.emit("ui:error", {
179
+ message: `Failed to read extensions directory ${EXT_DIR}: ${err instanceof Error ? err.message : String(err)}`,
180
+ });
181
+ return specifiers;
182
+ }
183
+ for (const entry of entries) {
184
+ // Disable check: directory name for dir-extensions, or basename sans
185
+ // extension for file-extensions. Lets settings.json turn one off
186
+ // without renaming it.
187
+ const nameForDisable = entry.name.replace(/\.[^.]+$/, "");
188
+ if (disabled.has(nameForDisable))
189
+ continue;
190
+ const fullPath = path.join(EXT_DIR, entry.name);
191
+ try {
181
192
  const isDir = entry.isDirectory() ||
182
193
  (entry.isSymbolicLink() && (await fs.stat(fullPath)).isDirectory());
183
194
  if (isDir) {
@@ -189,9 +200,11 @@ async function discoverUserExtensions() {
189
200
  specifiers.push(fullPath);
190
201
  }
191
202
  }
192
- }
193
- catch {
194
- // Directory doesn't exist no user extensions
203
+ catch (err) {
204
+ ctx.bus.emit("ui:error", {
205
+ message: `Failed to inspect extension ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
206
+ });
207
+ }
195
208
  }
196
209
  return specifiers;
197
210
  }
@@ -240,7 +253,7 @@ async function loadSpecifiers(specifiers, ctx, bustCache) {
240
253
  * Tears down old registrations, busts the module cache, and re-activates.
241
254
  */
242
255
  export async function reloadExtensions(ctx) {
243
- const specifiers = await discoverUserExtensions();
256
+ const specifiers = await discoverUserExtensions(ctx);
244
257
  return loadSpecifiers(specifiers, ctx, true);
245
258
  }
246
259
  /**
@@ -30,8 +30,12 @@ export const bashStrategy = {
30
30
  `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_precmd"`,
31
31
  "",
32
32
  "# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
33
- "# can track history-recalled and tab-completed commands accurately",
34
- "__agent_sh_preexec_ran=0",
33
+ "# can track history-recalled and tab-completed commands accurately.",
34
+ "# Start latched (=1) so the trap stays inert through the rest of",
35
+ "# rcfile sourcing — readline/history aren't loaded yet, and the case",
36
+ "# + bind statements below would otherwise fire a phantom preexec with",
37
+ "# an empty body. __agent_sh_precmd resets it to 0 before user input.",
38
+ "__agent_sh_preexec_ran=1",
35
39
  "__agent_sh_emit_preexec() {",
36
40
  ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
37
41
  ' [[ -n $COMP_LINE ]] && return',
@@ -2058,6 +2058,10 @@ export default function activate(ctx: AgentContext): void {
2058
2058
  for (const name of HIDDEN_IN_SCHEME_ONLY) {
2059
2059
  try { ctx.agent.unregisterTool(name); } catch { /* not registered — fine */ }
2060
2060
  }
2061
+ ctx.bus.onPipe("agent:core-tools:collect", (ev) => ({
2062
+ ...ev,
2063
+ names: [...ev.names, "scheme_eval"],
2064
+ }));
2061
2065
  }
2062
2066
 
2063
2067
  ctx.agent.registerTool({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@earendil-works/pi-tui": "^0.74.0",
59
- "agent-sh": "^0.14.0",
59
+ "agent-sh": "^0.14.7",
60
60
  "chalk": "^5.5.0",
61
61
  "cli-highlight": "^2.1.11"
62
62
  },
@@ -636,6 +636,9 @@ export function mountAshi(
636
636
  let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
637
637
  bus.on("shell:command-start", ({ command }) => {
638
638
  if (agentShellActive) return;
639
+ // Defensive: bash DEBUG-trap integrations have been observed firing
640
+ // before any user input, with an empty command body.
641
+ if (!command.trim()) return;
639
642
  finalizeThinking();
640
643
  if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
641
644
  const isPrivate = pendingUserBlockPrivacy.shift() ?? false;
@@ -3,7 +3,6 @@
3
3
  * `agent-sh auth login ollama-cloud` or OLLAMA_API_KEY). Local host
4
4
  * overridable via OLLAMA_HOST.
5
5
  */
6
- import { resolveApiKey } from "agent-sh/auth";
7
6
  import type { AgentContext } from "agent-sh/types";
8
7
 
9
8
  const ECHO_REASONING_PATTERNS: RegExp[] = [/deepseek/i];
@@ -14,7 +13,9 @@ function reasoningParams(level: string): Record<string, unknown> {
14
13
  }
15
14
 
16
15
  export default function activate(ctx: AgentContext): void {
17
- const cloudKey = resolveApiKey("ollama-cloud").key ?? process.env.OLLAMA_API_KEY;
16
+ const cloudKey =
17
+ (ctx.call("provider:resolve-api-key", "ollama-cloud") as { key: string | null }).key ??
18
+ process.env.OLLAMA_API_KEY;
18
19
  const cloudHost = "https://ollama.com";
19
20
  const cloudBaseURL = `${cloudHost}/v1`;
20
21
  ctx.agent.providers.configure("ollama-cloud", { reasoningParams });
@@ -31,7 +31,6 @@
31
31
  */
32
32
 
33
33
  import type { AgentContext } from "agent-sh/types";
34
- import { resolveApiKey } from "agent-sh/auth";
35
34
 
36
35
  // ── Constants ──────────────────────────────────────────────────────
37
36
 
@@ -180,7 +179,7 @@ function buildReasoningParams(level: string): Record<string, unknown> {
180
179
  export default function activate(ctx: AgentContext): void {
181
180
  const apiKey =
182
181
  process.env.OPENCODE_API_KEY ??
183
- resolveApiKey("opencode").key ?? undefined;
182
+ (ctx.call("provider:resolve-api-key", "opencode") as { key: string | null }).key ?? undefined;
184
183
 
185
184
  // ── Phase 1: register both providers synchronously with fallback models ──
186
185
 
@@ -4,7 +4,6 @@
4
4
  * Auth: agent-sh auth login zai-coding-plan
5
5
  * Usage: agent-sh -e ./examples/extensions/zai-coding-plan.ts
6
6
  */
7
- import { resolveApiKey } from "agent-sh/auth";
8
7
  import type { AgentContext } from "agent-sh/types";
9
8
 
10
9
  const BASE_URL = "https://api.z.ai/api/coding/paas/v4";
@@ -24,7 +23,7 @@ function buildReasoningParams(level: string, _model?: string): Record<string, un
24
23
  }
25
24
 
26
25
  export default function activate(ctx: AgentContext): void {
27
- const { key } = resolveApiKey(ID);
26
+ const { key } = ctx.call("provider:resolve-api-key", ID) as { key: string | null };
28
27
  ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
29
28
  ctx.agent.providers.register({
30
29
  id: ID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.14.6",
3
+ "version": "0.14.8",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "workspaces": [