agent-sh 0.10.3 → 0.11.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.
package/README.md CHANGED
@@ -22,18 +22,45 @@ agent-sh flips this. It's your shell first — full PTY, your rc config, your al
22
22
 
23
23
  ## Quick Start
24
24
 
25
+ Install and launch:
26
+
25
27
  ```bash
26
28
  npm install -g agent-sh
29
+ ```
30
+
31
+ 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.
32
+
33
+ **Hosted models via OpenRouter** (300+ models, one key):
34
+
35
+ ```bash
36
+ export OPENROUTER_API_KEY=sk-or-...
27
37
  agent-sh
28
38
  ```
29
39
 
30
- Tip: add an alias to your shell config for quick access:
40
+ **OpenAI:**
31
41
 
32
42
  ```bash
33
- alias ash="agent-sh"
43
+ export OPENAI_API_KEY=sk-...
44
+ agent-sh
34
45
  ```
35
46
 
36
- Set `OPENAI_API_KEY` in your environment (or configure providers in `~/.agent-sh/settings.json`). Works with any OpenAI-compatible API — see the [Usage Guide](docs/usage.md) for provider examples (OpenAI, Ollama, OpenRouter, Together, Groq, LM Studio, vLLM).
47
+ **Local models** (Ollama, llama.cpp server, LM Studio, vLLM — anything OpenAI-compatible):
48
+
49
+ ```bash
50
+ export OPENAI_API_KEY=ollama # any value; dummy is fine
51
+ export OPENAI_BASE_URL=http://localhost:11434/v1 # point at your server
52
+ agent-sh
53
+ ```
54
+
55
+ Once running, switch models at any time with `/model <name>` (tab-completes; selection persists across sessions).
56
+
57
+ For richer configuration (multiple providers, extensions), run `agent-sh init` to scaffold `~/.agent-sh/settings.json` with copy-pasteable examples. See the [Usage Guide](docs/usage.md) for the full list of supported providers.
58
+
59
+ Tip — add a shell alias:
60
+
61
+ ```bash
62
+ alias ash="agent-sh"
63
+ ```
37
64
 
38
65
  Requires Node.js 18+.
39
66
 
@@ -619,58 +619,6 @@ export class AgentLoop {
619
619
  "Treat recurring user guidance as standing preferences. " +
620
620
  "If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
621
621
  "Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).", "core");
622
- // ── ask_llm — direct LLM sub-query (from the 24th ash's vision) ──
623
- //
624
- // The ash can ask the LLM a question directly — not as a tool-output
625
- // loop, but as a lightweight sub-query. Use cases: second opinions,
626
- // brainstorming, summarizing complex context, getting a fresh
627
- // perspective without tool overhead. The 24th ash injected this via
628
- // diagnose as a proof-of-concept. The 25th ash made it permanent.
629
- this.toolRegistry.register({
630
- name: "ask_llm",
631
- description: "Send a direct query to the LLM and get a text response. Use for " +
632
- "sub-queries, second opinions, brainstorming, or getting a fresh " +
633
- "perspective on a problem. Much lighter than a full tool loop — " +
634
- "just query in, text out. Optional system prompt sets context.",
635
- input_schema: {
636
- type: "object",
637
- properties: {
638
- query: {
639
- type: "string",
640
- description: "The question or prompt to send to the LLM.",
641
- },
642
- system: {
643
- type: "string",
644
- description: "Optional system prompt to set context for the sub-query.",
645
- },
646
- },
647
- required: ["query"],
648
- },
649
- showOutput: true,
650
- execute: async (args) => {
651
- const messages = [];
652
- if (args.system) {
653
- messages.push({ role: "system", content: args.system });
654
- }
655
- messages.push({ role: "user", content: args.query });
656
- try {
657
- const content = await this.llmClient.complete({
658
- messages,
659
- max_tokens: 2000,
660
- });
661
- return { content: content || "(empty response)", exitCode: 0, isError: false };
662
- }
663
- catch (err) {
664
- const message = err instanceof Error ? err.message : String(err);
665
- return { content: `LLM error: ${message}`, exitCode: 1, isError: true };
666
- }
667
- },
668
- getDisplayInfo: () => ({ kind: "search", icon: "💬" }),
669
- formatCall: (args) => {
670
- const q = args.query?.slice(0, 60);
671
- return `ask_llm: ${q}${args.query?.length > 60 ? "..." : ""}`;
672
- },
673
- });
674
622
  }
675
623
  /**
676
624
  * Register named handlers that extensions can advise.
package/dist/core.d.ts CHANGED
@@ -22,7 +22,7 @@ import type { AgentShellConfig, ExtensionContext } from "./types.js";
22
22
  import { HandlerRegistry } from "./utils/handler-registry.js";
23
23
  export { EventBus } from "./event-bus.js";
24
24
  export type { ShellEvents } from "./event-bus.js";
25
- export type { AgentShellConfig, ExtensionContext } from "./types.js";
25
+ export type { AgentShellConfig, ExtensionContext, LlmInterface, LlmMessage, LlmSession } from "./types.js";
26
26
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
27
27
  export type { ColorPalette } from "./utils/palette.js";
28
28
  export type { AgentBackend, ToolDefinition } from "./agent/types.js";
package/dist/core.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
+ import { createLlmFacade } from "./utils/llm-facade.js";
21
22
  import { setPalette } from "./utils/palette.js";
22
23
  import * as streamTransform from "./utils/stream-transform.js";
23
24
  import * as settingsMod from "./settings.js";
@@ -161,6 +162,7 @@ export function createCore(config) {
161
162
  bus,
162
163
  contextManager,
163
164
  instanceId,
165
+ llm: createLlmFacade(handlers),
164
166
  quit: opts.quit,
165
167
  setPalette,
166
168
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
@@ -275,7 +275,9 @@ export interface ShellEvents {
275
275
  id: string;
276
276
  apiKey?: string;
277
277
  baseURL?: string;
278
- defaultModel: string;
278
+ /** Optional — providers for custom endpoints may not know the catalog
279
+ * at registration time. Falls back to models[0] when absent. */
280
+ defaultModel?: string;
279
281
  models?: (string | {
280
282
  id: string;
281
283
  reasoning?: boolean;
@@ -42,6 +42,12 @@ export default function agentBackend(ctx) {
42
42
  // wire the loop until we've resolved, so users never hit that path.
43
43
  const llmClient = new LlmClient({ apiKey: "not-configured", model: "not-configured" });
44
44
  ctx.define("llm:get-client", () => llmClient);
45
+ ctx.define("llm:invoke", (messages, opts) => {
46
+ return llmClient.complete({
47
+ messages: messages,
48
+ max_tokens: opts?.maxTokens,
49
+ });
50
+ });
45
51
  let modes = [];
46
52
  let initialModeIndex = 0;
47
53
  let resolved = false;
@@ -81,7 +87,10 @@ export default function agentBackend(ctx) {
81
87
  });
82
88
  bus.on("core:extensions-loaded", () => {
83
89
  const settings = getSettings();
84
- const providerName = config.provider ?? settings.defaultProvider;
90
+ // If the user didn't pick a default, fall back to the first registered
91
+ // provider (built-in load order biases to openrouter → openai).
92
+ const providerName = config.provider ?? settings.defaultProvider
93
+ ?? (providerRegistry.size > 0 ? providerRegistry.keys().next().value : undefined);
85
94
  const activeProvider = providerName ? providerRegistry.get(providerName) ?? null : null;
86
95
  // User's persisted defaultModel wins over the provider's declared
87
96
  // default. Dynamic providers (openrouter) re-register with their
@@ -91,7 +100,7 @@ export default function agentBackend(ctx) {
91
100
  const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
92
101
  const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
93
102
  if (!effectiveApiKey) {
94
- bus.emit("ui:error", { message: "No LLM provider configured. Set --api-key, configure a provider in ~/.agent-sh/settings.json, or load a provider extension (e.g. openrouter) that sets OPENROUTER_API_KEY." });
103
+ bus.emit("ui:error", { message: "No LLM provider configured. Export OPENROUTER_API_KEY or OPENAI_API_KEY (built-in providers auto-activate), pass --api-key, or run `agent-sh init` for a settings.json template." });
95
104
  return;
96
105
  }
97
106
  if (!effectiveModel) {
@@ -9,11 +9,13 @@ import type { ExtensionContext } from "../types.js";
9
9
  type ActivateFn = (ctx: ExtensionContext) => void;
10
10
  export declare const BUILTIN_EXTENSIONS: Array<{
11
11
  name: string;
12
+ when?: () => boolean;
12
13
  load: () => Promise<ActivateFn>;
13
14
  }>;
14
15
  /**
15
- * Load built-in extensions sequentially, skipping any in the disabled list.
16
- * Returns the names of extensions that were loaded.
16
+ * Load built-in extensions sequentially, skipping any in the disabled list
17
+ * or whose `when` predicate returns false. Returns the names of extensions
18
+ * that were loaded.
17
19
  */
18
20
  export declare function loadBuiltinExtensions(ctx: ExtensionContext, disabled?: string[]): Promise<string[]>;
19
21
  export {};
@@ -1,13 +1,19 @@
1
1
  export const BUILTIN_EXTENSIONS = [
2
2
  { name: "agent-backend", load: () => import("./agent-backend.js").then(m => m.default) },
3
+ { name: "openrouter",
4
+ when: () => !!process.env.OPENROUTER_API_KEY,
5
+ load: () => import("./openrouter.js").then(m => m.default) },
6
+ { name: "openai",
7
+ when: () => !!process.env.OPENAI_API_KEY,
8
+ load: () => import("./openai.js").then(m => m.default) },
3
9
  { name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
4
10
  { name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
5
11
  { name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
6
- { name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
7
12
  ];
8
13
  /**
9
- * Load built-in extensions sequentially, skipping any in the disabled list.
10
- * Returns the names of extensions that were loaded.
14
+ * Load built-in extensions sequentially, skipping any in the disabled list
15
+ * or whose `when` predicate returns false. Returns the names of extensions
16
+ * that were loaded.
11
17
  */
12
18
  export async function loadBuiltinExtensions(ctx, disabled = []) {
13
19
  const disabledSet = new Set(disabled);
@@ -15,6 +21,8 @@ export async function loadBuiltinExtensions(ctx, disabled = []) {
15
21
  for (const ext of BUILTIN_EXTENSIONS) {
16
22
  if (disabledSet.has(ext.name))
17
23
  continue;
24
+ if (ext.when && !ext.when())
25
+ continue;
18
26
  const activate = await ext.load();
19
27
  activate(ctx);
20
28
  loaded.push(ext.name);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Built-in OpenAI-compatible provider — auto-activates when OPENAI_API_KEY
3
+ * is set. OPENAI_BASE_URL redirects to local servers (Ollama, LM Studio,
4
+ * vLLM, llama.cpp) which then get their catalog via /models.
5
+ */
6
+ import type { ExtensionContext } from "../types.js";
7
+ export default function activate(ctx: ExtensionContext): void;
@@ -0,0 +1,46 @@
1
+ const DEFAULT_MODELS = [
2
+ "gpt-5",
3
+ "gpt-4.1",
4
+ "gpt-4o",
5
+ "gpt-4o-mini",
6
+ "o3",
7
+ "o3-mini",
8
+ ];
9
+ export default function activate(ctx) {
10
+ const apiKey = process.env.OPENAI_API_KEY;
11
+ if (!apiKey)
12
+ return;
13
+ const baseURL = process.env.OPENAI_BASE_URL;
14
+ const id = baseURL ? "openai-compatible" : "openai";
15
+ if (!baseURL) {
16
+ ctx.bus.emit("provider:register", {
17
+ id,
18
+ apiKey,
19
+ defaultModel: DEFAULT_MODELS[0],
20
+ models: DEFAULT_MODELS,
21
+ });
22
+ return;
23
+ }
24
+ // Register empty immediately so the provider resolves; refill from /models.
25
+ ctx.bus.emit("provider:register", { id, apiKey, baseURL, models: [] });
26
+ fetchModels(baseURL, apiKey).then((models) => {
27
+ if (models.length === 0)
28
+ return;
29
+ ctx.bus.emit("provider:register", {
30
+ id,
31
+ apiKey,
32
+ baseURL,
33
+ defaultModel: models[0],
34
+ models,
35
+ });
36
+ }).catch(() => { });
37
+ }
38
+ async function fetchModels(baseURL, apiKey) {
39
+ const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, {
40
+ headers: { Authorization: `Bearer ${apiKey}` },
41
+ });
42
+ if (!res.ok)
43
+ return [];
44
+ const data = await res.json();
45
+ return (data.data ?? []).map((m) => m.id);
46
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Built-in OpenRouter provider — auto-activates when OPENROUTER_API_KEY is set.
3
+ * Registers curated defaults synchronously so the first query works, then
4
+ * fetches the full catalog to populate /model autocomplete.
5
+ */
6
+ import type { ExtensionContext } from "../types.js";
7
+ export default function activate(ctx: ExtensionContext): void;
@@ -0,0 +1,44 @@
1
+ const BASE_URL = "https://openrouter.ai/api/v1";
2
+ // First entry is the cold-start default — kept cheap so trial users don't
3
+ // get a surprise bill. Persisted /model selection overrides this.
4
+ const DEFAULT_MODELS = [
5
+ "deepseek/deepseek-v3.2",
6
+ "anthropic/claude-sonnet-4.6",
7
+ ];
8
+ export default function activate(ctx) {
9
+ const apiKey = process.env.OPENROUTER_API_KEY;
10
+ if (!apiKey)
11
+ return;
12
+ ctx.bus.emit("provider:register", {
13
+ id: "openrouter",
14
+ apiKey,
15
+ baseURL: BASE_URL,
16
+ defaultModel: DEFAULT_MODELS[0],
17
+ models: DEFAULT_MODELS,
18
+ });
19
+ fetchModels(apiKey).then((models) => {
20
+ if (models.length === 0)
21
+ return;
22
+ ctx.bus.emit("provider:register", {
23
+ id: "openrouter",
24
+ apiKey,
25
+ baseURL: BASE_URL,
26
+ defaultModel: DEFAULT_MODELS[0],
27
+ supportsReasoningEffort: true,
28
+ models: models.map((m) => ({
29
+ id: m.id,
30
+ reasoning: m.supported_parameters?.includes("reasoning") ?? false,
31
+ contextWindow: m.context_length,
32
+ })),
33
+ });
34
+ }).catch(() => { });
35
+ }
36
+ async function fetchModels(apiKey) {
37
+ const res = await fetch(`${BASE_URL}/models`, {
38
+ headers: { Authorization: `Bearer ${apiKey}` },
39
+ });
40
+ if (!res.ok)
41
+ return [];
42
+ const data = await res.json();
43
+ return data.data ?? [];
44
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
10
  import { discoverSkills } from "./agent/skills.js";
11
+ import { runInit } from "./init.js";
11
12
  /**
12
13
  * Capture the user's full shell environment.
13
14
  * This picks up env vars exported in .zshrc/.bashrc that the
@@ -105,6 +106,7 @@ function parseArgs(argv) {
105
106
  console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
106
107
 
107
108
  Usage: agent-sh [options]
109
+ agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
108
110
 
109
111
  Provider Profiles:
110
112
  --provider <name> Use a provider from ~/.agent-sh/settings.json
@@ -145,13 +147,19 @@ Inside the shell:
145
147
  return { shell, model, extensions, apiKey, baseURL, provider };
146
148
  }
147
149
  async function main() {
150
+ // Subcommands — handled before the shell-launch path.
151
+ const rawArgs = process.argv.slice(2);
152
+ if (rawArgs[0] === "init") {
153
+ runInit({ force: rawArgs.includes("--force") });
154
+ return;
155
+ }
148
156
  if (process.env.AGENT_SH) {
149
157
  console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
150
158
  process.exit(1);
151
159
  }
152
160
  process.on("SIGTTOU", () => { });
153
161
  process.on("SIGTTIN", () => { });
154
- const config = parseArgs(process.argv.slice(2));
162
+ const config = parseArgs(rawArgs);
155
163
  // Capture user's full shell environment
156
164
  const baseEnv = {};
157
165
  for (const [k, v] of Object.entries(process.env)) {
@@ -270,6 +278,7 @@ async function main() {
270
278
  const bannerW = Math.min(termW, 60);
271
279
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
272
280
  const info = agentInfo;
281
+ const backendReady = !!info?.model;
273
282
  const backendName = info?.name ?? "ash";
274
283
  const model = info?.model;
275
284
  const provider = info?.provider;
@@ -277,7 +286,7 @@ async function main() {
277
286
  ? provider ? `${model} [${provider}]` : model
278
287
  : null;
279
288
  let sections = "";
280
- sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
289
+ sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${backendReady ? "" : " (not configured)"}${p.reset}`;
281
290
  if (modelValue) {
282
291
  sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
283
292
  }
@@ -300,7 +309,9 @@ async function main() {
300
309
  sections += `\n ${p.dim}${item}${p.reset}`;
301
310
  }
302
311
  }
303
- const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
312
+ const hint = backendReady
313
+ ? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
314
+ : `${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}`;
304
315
  const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
305
316
  process.stdout.write("\n" + borderLine + "\n" +
306
317
  " " + productName +
package/dist/init.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function runInit(opts: {
2
+ force: boolean;
3
+ }): void;
package/dist/init.js ADDED
@@ -0,0 +1,72 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
5
+ const EXTENSIONS_DIR = path.join(CONFIG_DIR, "extensions");
6
+ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
7
+ const EXAMPLE_PATH = path.join(CONFIG_DIR, "settings.example.json");
8
+ const AGENTS_PATH = path.join(CONFIG_DIR, "AGENTS.md");
9
+ // Shape-discoverable stub — all fields present, none filled in.
10
+ const STARTER_SETTINGS = {
11
+ defaultProvider: null,
12
+ providers: {},
13
+ extensions: [],
14
+ disabledBuiltins: [],
15
+ disabledExtensions: [],
16
+ };
17
+ // Not loaded at runtime — users copy blocks from here into settings.json.
18
+ const EXAMPLE_SETTINGS = {
19
+ defaultProvider: "openrouter",
20
+ providers: {
21
+ openrouter: {
22
+ apiKey: "$OPENROUTER_API_KEY",
23
+ baseURL: "https://openrouter.ai/api/v1",
24
+ defaultModel: "anthropic/claude-sonnet-4.6",
25
+ },
26
+ openai: {
27
+ apiKey: "$OPENAI_API_KEY",
28
+ defaultModel: "gpt-5",
29
+ },
30
+ anthropic: {
31
+ apiKey: "$ANTHROPIC_API_KEY",
32
+ baseURL: "https://api.anthropic.com/v1",
33
+ defaultModel: "claude-sonnet-4-5",
34
+ },
35
+ ollama: {
36
+ apiKey: "ollama",
37
+ baseURL: "http://localhost:11434/v1",
38
+ defaultModel: "llama3.3",
39
+ },
40
+ },
41
+ extensions: [
42
+ "./examples/extensions/openrouter.ts",
43
+ ],
44
+ disabledBuiltins: [],
45
+ disabledExtensions: [],
46
+ };
47
+ function writeIfMissing(filePath, content, force) {
48
+ if (!force && fs.existsSync(filePath))
49
+ return "kept";
50
+ fs.writeFileSync(filePath, content);
51
+ return "written";
52
+ }
53
+ export function runInit(opts) {
54
+ fs.mkdirSync(EXTENSIONS_DIR, { recursive: true });
55
+ const settingsResult = writeIfMissing(SETTINGS_PATH, JSON.stringify(STARTER_SETTINGS, null, 2) + "\n", opts.force);
56
+ // Always refreshed — reference material, not user state.
57
+ fs.writeFileSync(EXAMPLE_PATH, JSON.stringify(EXAMPLE_SETTINGS, null, 2) + "\n");
58
+ console.log(`agent-sh initialized at ${CONFIG_DIR}`);
59
+ console.log();
60
+ console.log(` settings.json ${settingsResult}${opts.force ? "" : settingsResult === "kept" ? " (exists — pass --force to overwrite)" : ""}`);
61
+ console.log(` settings.example.json refreshed`);
62
+ console.log(` extensions/ ready`);
63
+ console.log();
64
+ console.log("Next steps:");
65
+ console.log(` 1. Open ${SETTINGS_PATH}`);
66
+ console.log(` 2. Copy a provider block from settings.example.json into \`providers\` and set \`defaultProvider\`.`);
67
+ console.log(` 3. Export the referenced env var (e.g. \`export OPENROUTER_API_KEY=...\`).`);
68
+ console.log(` 4. Run \`agent-sh\`.`);
69
+ console.log();
70
+ console.log(`Optional: create ${AGENTS_PATH} with standing instructions`);
71
+ console.log(`(code style, commands to avoid, etc.) to load them into every session.`);
72
+ }
@@ -34,11 +34,8 @@ export declare class Shell implements InputContext {
34
34
  isAgentActive(): boolean;
35
35
  writeToPty(data: string): void;
36
36
  /**
37
- * Lightweight redraw: ask the shell to redraw its own prompt via a hidden
38
- * ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
39
- * prompt correctly — we don't try to replay captured bytes.
40
- *
41
- * For bash, falls back to sending \n for a fresh prompt cycle.
37
+ * Ask the shell to redraw its own prompt in place via \e[9999~, which both
38
+ * zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
42
39
  */
43
40
  redrawPrompt(): void;
44
41
  /**
@@ -87,7 +87,15 @@ export class Shell {
87
87
  `[ -f "${userHome}/.bashrc" ] && source "${userHome}/.bashrc"`,
88
88
  "",
89
89
  "# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
90
- `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_preexec_ran=0; ${osc7Cmd}; ${promptMarker}${showIndicator ? `; ${titleCmd}` : ""}"`,
90
+ "# Wrapped in a function because inlining printf \"...\" into",
91
+ "# PROMPT_COMMAND=\"...\" breaks the outer quoting.",
92
+ "__agent_sh_precmd() {",
93
+ ` ${osc7Cmd}`,
94
+ ` ${promptMarker}`,
95
+ ...(showIndicator ? [` ${titleCmd}`] : []),
96
+ " __agent_sh_preexec_ran=0",
97
+ "}",
98
+ `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_precmd"`,
91
99
  "",
92
100
  "# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
93
101
  "# can track history-recalled and tab-completed commands accurately",
@@ -104,6 +112,12 @@ export class Shell {
104
112
  "",
105
113
  "# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
106
114
  'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
115
+ "",
116
+ "# Mirrors the zsh \\e[9999~ reset-prompt widget — used by agent-sh",
117
+ "# to repaint the prompt in place. All keymaps so `set -o vi` works.",
118
+ `bind -m emacs '"\\e[9999~":redraw-current-line' 2>/dev/null`,
119
+ `bind -m vi-insert '"\\e[9999~":redraw-current-line' 2>/dev/null`,
120
+ `bind -m vi-command '"\\e[9999~":redraw-current-line' 2>/dev/null`,
107
121
  ];
108
122
  fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), bashrcLines.join("\n") + "\n");
109
123
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
@@ -190,16 +204,12 @@ export class Shell {
190
204
  this.ptyProcess.write(data);
191
205
  }
192
206
  /**
193
- * Lightweight redraw: ask the shell to redraw its own prompt via a hidden
194
- * ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
195
- * prompt correctly — we don't try to replay captured bytes.
196
- *
197
- * For bash, falls back to sending \n for a fresh prompt cycle.
207
+ * Ask the shell to redraw its own prompt in place via \e[9999~, which both
208
+ * zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
198
209
  */
199
210
  redrawPrompt() {
200
- // A stale echoSkip or paused flag (left over from handleProcessingDone
201
- // re-entering a mode) would swallow the redrawn prompt and make the
202
- // terminal appear frozen. Reset both before emitting.
211
+ // Stale echoSkip/paused from handleProcessingDone re-entering a mode
212
+ // would swallow the redraw and freeze the terminal visually.
203
213
  this.echoSkip = false;
204
214
  this.paused = false;
205
215
  const result = this.bus.emitPipe("shell:redraw-prompt", {
@@ -207,14 +217,7 @@ export class Shell {
207
217
  handled: false,
208
218
  });
209
219
  if (!result.handled) {
210
- if (this.isZsh) {
211
- // Trigger the hidden ZLE widget — zle reset-prompt redraws cleanly
212
- this.ptyProcess.write("\x1b[9999~");
213
- }
214
- else {
215
- // Bash: no zle reset-prompt equivalent, use fresh prompt cycle
216
- this.ptyProcess.write("\n");
217
- }
220
+ this.ptyProcess.write("\x1b[9999~");
218
221
  }
219
222
  }
220
223
  /**
package/dist/types.d.ts CHANGED
@@ -49,6 +49,31 @@ export interface AgentMode {
49
49
  /** Provider supports the reasoning_effort parameter. */
50
50
  supportsReasoningEffort?: boolean;
51
51
  }
52
+ /**
53
+ * Backend-agnostic LLM interface exposed via `ctx.llm`. Backends fulfill it
54
+ * by defining an `llm:invoke` handler; those without an LLM leave
55
+ * `available` false and calls reject.
56
+ */
57
+ export interface LlmMessage {
58
+ role: "system" | "user" | "assistant";
59
+ content: string;
60
+ }
61
+ export interface LlmSession {
62
+ send(message: string): Promise<string>;
63
+ history(): ReadonlyArray<LlmMessage>;
64
+ }
65
+ export interface LlmInterface {
66
+ readonly available: boolean;
67
+ ask(opts: {
68
+ query: string;
69
+ system?: string;
70
+ maxTokens?: number;
71
+ }): Promise<string>;
72
+ session(opts?: {
73
+ system?: string;
74
+ maxTokens?: number;
75
+ }): LlmSession;
76
+ }
52
77
  export interface AgentShellConfig {
53
78
  shell?: string;
54
79
  model?: string;
@@ -102,6 +127,7 @@ export interface ExtensionContext {
102
127
  registerSkill: (name: string, description: string, filePath: string) => void;
103
128
  /** Remove a registered skill by name. */
104
129
  removeSkill: (name: string) => void;
130
+ llm: LlmInterface;
105
131
  /** Register a named handler. */
106
132
  define: (name: string, fn: (...args: any[]) => any) => void;
107
133
  /** Wrap a named handler. Receives `next` (original) + args. Returns an unadvise function. */
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ctx.llm facade — delegates to an `llm:invoke` handler registered by the
3
+ * active backend. No handler → `available` is false and calls reject.
4
+ */
5
+ import type { HandlerRegistry } from "./handler-registry.js";
6
+ import type { LlmInterface } from "../types.js";
7
+ export declare function createLlmFacade(handlers: HandlerRegistry): LlmInterface;
@@ -0,0 +1,33 @@
1
+ export function createLlmFacade(handlers) {
2
+ const invoke = (messages, maxTokens) => {
3
+ const result = handlers.call("llm:invoke", messages, { maxTokens });
4
+ if (result === undefined)
5
+ return Promise.reject(new Error("ctx.llm: no LLM backend available"));
6
+ return result;
7
+ };
8
+ return {
9
+ get available() { return handlers.list().includes("llm:invoke"); },
10
+ ask: ({ query, system, maxTokens }) => {
11
+ const messages = [];
12
+ if (system)
13
+ messages.push({ role: "system", content: system });
14
+ messages.push({ role: "user", content: query });
15
+ return invoke(messages, maxTokens);
16
+ },
17
+ session: (opts = {}) => {
18
+ const messages = [];
19
+ if (opts.system)
20
+ messages.push({ role: "system", content: opts.system });
21
+ const session = {
22
+ async send(message) {
23
+ messages.push({ role: "user", content: message });
24
+ const reply = await invoke(messages, opts.maxTokens);
25
+ messages.push({ role: "assistant", content: reply });
26
+ return reply;
27
+ },
28
+ history: () => messages.slice(),
29
+ };
30
+ return session;
31
+ },
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -1,10 +0,0 @@
1
- /**
2
- * Command suggestion extension (fast-path LLM feature).
3
- *
4
- * After a shell command fails (non-zero exit), uses LlmClient.complete()
5
- * to suggest a fix. Shows the suggestion below the prompt.
6
- *
7
- * Only active when an LLM client is available (registered by agent-backend).
8
- */
9
- import type { ExtensionContext } from "../types.js";
10
- export default function activate({ bus, call }: ExtensionContext): void;
@@ -1,42 +0,0 @@
1
- export default function activate({ bus, call }) {
2
- let suggesting = false;
3
- bus.on("shell:command-done", ({ command, output, exitCode, cwd }) => {
4
- if (exitCode === null || exitCode === 0)
5
- return;
6
- if (!command.trim())
7
- return;
8
- if (suggesting)
9
- return; // don't stack suggestions
10
- const llmClient = call("llm:get-client");
11
- if (!llmClient)
12
- return;
13
- suggesting = true;
14
- // Truncate output to avoid blowing up the prompt
15
- const truncated = output.length > 1000
16
- ? output.slice(-1000)
17
- : output;
18
- llmClient.complete({
19
- messages: [
20
- {
21
- role: "system",
22
- content: "You are a shell assistant. The user's command failed. " +
23
- "Suggest a fix as a single command. Just the command, no explanation, no backticks, no prefix. " +
24
- "If you can't suggest anything useful, reply with an empty string.",
25
- },
26
- {
27
- role: "user",
28
- content: `cwd: ${cwd}\n$ ${command}\n${truncated}\nexit code: ${exitCode}`,
29
- },
30
- ],
31
- max_tokens: 150,
32
- }).then((suggestion) => {
33
- suggesting = false;
34
- const trimmed = suggestion.trim().replace(/^`+|`+$/g, ""); // strip backticks
35
- if (trimmed && trimmed.length < 500) {
36
- bus.emit("ui:suggestion", { text: trimmed });
37
- }
38
- }).catch(() => {
39
- suggesting = false;
40
- });
41
- });
42
- }