agent-sh 0.12.8 → 0.12.10

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.
@@ -17,6 +17,7 @@ import type { ContextManager } from "../context-manager.js";
17
17
  import type { LlmClient } from "../utils/llm-client.js";
18
18
  import type { HandlerFunctions } from "../utils/handler-registry.js";
19
19
  import type { AgentBackend, ToolDefinition } from "./types.js";
20
+ import { type HistoryAdapter } from "./history-file.js";
20
21
  import type { Compositor } from "../utils/compositor.js";
21
22
  export interface AgentLoopConfig {
22
23
  bus: EventBus;
@@ -28,11 +29,12 @@ export interface AgentLoopConfig {
28
29
  compositor?: Compositor;
29
30
  /** Instance ID from core — ensures history entries match the ID in prompts. */
30
31
  instanceId?: string;
32
+ history?: HistoryAdapter;
31
33
  }
32
34
  export declare class AgentLoop implements AgentBackend {
33
35
  private abortController;
34
36
  private toolRegistry;
35
- private historyFile;
37
+ private history;
36
38
  private conversation;
37
39
  private fileReadCache;
38
40
  private modes;
@@ -100,8 +102,7 @@ export declare class AgentLoop implements AgentBackend {
100
102
  buildExtensionSections(): string[];
101
103
  kill(): void;
102
104
  private cancel;
103
- /** Check if reasoning_effort should be sent for the current model/provider. */
104
- private shouldSendReasoningEffort;
105
+ private reasoningParams;
105
106
  private get currentMode();
106
107
  private get currentModel();
107
108
  /**
@@ -15,6 +15,7 @@ import { getSettings, updateSettings } from "../settings.js";
15
15
  import { createToolProtocol } from "./tool-protocol.js";
16
16
  // Core tool factories
17
17
  import { createBashTool } from "./tools/bash.js";
18
+ import { createPwshTool } from "./tools/pwsh.js";
18
19
  import { createReadFileTool } from "./tools/read-file.js";
19
20
  import { createWriteFileTool } from "./tools/write-file.js";
20
21
  import { createEditFileTool } from "./tools/edit-file.js";
@@ -49,7 +50,7 @@ function summarizeDescription(desc) {
49
50
  export class AgentLoop {
50
51
  abortController = null;
51
52
  toolRegistry = new ToolRegistry();
52
- historyFile;
53
+ history;
53
54
  conversation;
54
55
  fileReadCache = new Map();
55
56
  modes;
@@ -109,7 +110,7 @@ export class AgentLoop {
109
110
  // `history:append` handler registered below; extensions swap the
110
111
  // backend without touching this wiring.
111
112
  const filePath = process.env.AGENT_SH_HISTORY_FILE || getSettings().historyFilePath;
112
- this.historyFile = new HistoryFile({ instanceId: this.instanceId, filePath });
113
+ this.history = config.history ?? new HistoryFile({ instanceId: this.instanceId, filePath });
113
114
  this.conversation = new ConversationState(this.handlers, this.instanceId);
114
115
  // Fall back to a single-mode placeholder if the caller passed an
115
116
  // empty array (agent-backend does this pre-resolution).
@@ -179,6 +180,16 @@ export class AgentLoop {
179
180
  message: `${prev.provider}:${prev.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
180
181
  });
181
182
  }
183
+ const active = this.modes[this.currentModeIndex];
184
+ if (active && active.contextWindow !== prev?.contextWindow) {
185
+ this.bus.emit("agent:info", {
186
+ name: "ash",
187
+ version: PACKAGE_VERSION,
188
+ model: active.model,
189
+ provider: active.provider,
190
+ contextWindow: active.contextWindow,
191
+ });
192
+ }
182
193
  this.bus.emit("config:changed", {});
183
194
  });
184
195
  // Fires before wire() too — agent-backend emits this from
@@ -460,16 +471,17 @@ export class AgentLoop {
460
471
  cancel() {
461
472
  this.abortController?.abort();
462
473
  }
463
- /** Check if reasoning_effort should be sent for the current model/provider. */
464
- shouldSendReasoningEffort() {
465
- if (this.thinkingLevel === "off")
466
- return false;
474
+ reasoningParams() {
467
475
  const mode = this.currentMode;
468
476
  if (mode.reasoning === false)
469
- return false;
477
+ return {};
470
478
  if (mode.supportsReasoningEffort === false)
471
- return false;
472
- return true;
479
+ return {};
480
+ if (mode.buildReasoningParams)
481
+ return mode.buildReasoningParams(this.thinkingLevel);
482
+ if (this.thinkingLevel === "off")
483
+ return {};
484
+ return { reasoning_effort: this.thinkingLevel };
473
485
  }
474
486
  get currentMode() {
475
487
  return this.modes[this.currentModeIndex];
@@ -591,6 +603,9 @@ export class AgentLoop {
591
603
  return env;
592
604
  };
593
605
  this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
606
+ if (process.platform === "win32") {
607
+ this.toolRegistry.register(createPwshTool({ getCwd, getEnv, bus: this.bus }));
608
+ }
594
609
  this.toolRegistry.register(createReadFileTool(getCwd, this.fileReadCache));
595
610
  this.toolRegistry.register(createWriteFileTool(getCwd));
596
611
  this.toolRegistry.register(createEditFileTool(getCwd));
@@ -818,11 +833,11 @@ export class AgentLoop {
818
833
  return;
819
834
  const writable = entries.filter((e) => !isReadOnly(e));
820
835
  if (writable.length > 0)
821
- this.historyFile.append(writable).catch(() => { });
836
+ this.history.append(writable).catch(() => { });
822
837
  });
823
- h.define("history:search", async (query) => this.historyFile.search(query));
824
- h.define("history:find-by-seq", async (seq) => this.historyFile.findBySeq(seq));
825
- h.define("history:read-recent", async (max) => this.historyFile.readRecent(max));
838
+ h.define("history:search", async (query) => this.history.search(query));
839
+ h.define("history:find-by-seq", async (seq) => this.history.findBySeq(seq));
840
+ h.define("history:read-recent", async (max) => this.history.readRecent(max));
826
841
  // Prior-session preamble renderer. Default: flat chronological list.
827
842
  h.define("conversation:format-prior-history", (entries) => {
828
843
  if (!entries || entries.length === 0)
@@ -1521,7 +1536,7 @@ export class AgentLoop {
1521
1536
  messages,
1522
1537
  tools: apiTools,
1523
1538
  model: this.currentModel,
1524
- reasoning_effort: this.shouldSendReasoningEffort() ? this.thinkingLevel : undefined,
1539
+ ...this.reasoningParams(),
1525
1540
  };
1526
1541
  this.bus.emit("llm:request", requestParams);
1527
1542
  const stream = await this.llmClient.stream({ ...requestParams, signal });
@@ -1,5 +1,34 @@
1
1
  import { type NuclearEntry } from "./nuclear-form.js";
2
- export declare class HistoryFile {
2
+ export interface HistoryAdapter {
3
+ append(entries: NuclearEntry[]): Promise<void>;
4
+ readRecent(maxEntries?: number): Promise<NuclearEntry[]>;
5
+ search(query: string): Promise<{
6
+ entry: NuclearEntry;
7
+ line: string;
8
+ }[]>;
9
+ findBySeq(seq: number): Promise<NuclearEntry | null>;
10
+ }
11
+ export declare class InMemoryHistory implements HistoryAdapter {
12
+ private entries;
13
+ constructor(initial?: NuclearEntry[]);
14
+ append(entries: NuclearEntry[]): Promise<void>;
15
+ readRecent(maxEntries?: number): Promise<NuclearEntry[]>;
16
+ search(query: string): Promise<{
17
+ entry: NuclearEntry;
18
+ line: string;
19
+ }[]>;
20
+ findBySeq(seq: number): Promise<NuclearEntry | null>;
21
+ }
22
+ export declare class NoopHistory implements HistoryAdapter {
23
+ append(): Promise<void>;
24
+ readRecent(): Promise<NuclearEntry[]>;
25
+ search(): Promise<{
26
+ entry: NuclearEntry;
27
+ line: string;
28
+ }[]>;
29
+ findBySeq(): Promise<NuclearEntry | null>;
30
+ }
31
+ export declare class HistoryFile implements HistoryAdapter {
3
32
  readonly instanceId: string;
4
33
  private filePath;
5
34
  private lockPath;
@@ -10,9 +10,43 @@ import * as fss from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import * as crypto from "node:crypto";
12
12
  import { CONFIG_DIR, getSettings } from "../settings.js";
13
- import { serializeEntry, deserializeEntry, formatNuclearLine, isReadOnly, } from "./nuclear-form.js";
13
+ import { serializeEntry, deserializeEntry, isReadOnly, compileSearchRegex, matchEntry, } from "./nuclear-form.js";
14
14
  const HISTORY_PATH = path.join(CONFIG_DIR, "history");
15
15
  const LOCK_STALE_MS = 10_000; // consider lock stale after 10s
16
+ export class InMemoryHistory {
17
+ entries;
18
+ constructor(initial = []) {
19
+ this.entries = [...initial];
20
+ }
21
+ async append(entries) {
22
+ this.entries.push(...entries);
23
+ }
24
+ async readRecent(maxEntries) {
25
+ const filtered = this.entries.filter((e) => !isReadOnly(e));
26
+ return maxEntries ? filtered.slice(-maxEntries) : filtered;
27
+ }
28
+ async search(query) {
29
+ if (!query.trim())
30
+ return [];
31
+ const re = compileSearchRegex(query);
32
+ const out = [];
33
+ for (let i = this.entries.length - 1; i >= 0; i--) {
34
+ const m = matchEntry(this.entries[i], re);
35
+ if (m)
36
+ out.push(m);
37
+ }
38
+ return out;
39
+ }
40
+ async findBySeq(seq) {
41
+ return this.entries.find((e) => e.seq === seq) ?? null;
42
+ }
43
+ }
44
+ export class NoopHistory {
45
+ async append() { }
46
+ async readRecent() { return []; }
47
+ async search() { return []; }
48
+ async findBySeq() { return null; }
49
+ }
16
50
  export class HistoryFile {
17
51
  instanceId;
18
52
  filePath;
@@ -65,16 +99,7 @@ export class HistoryFile {
65
99
  async search(query) {
66
100
  if (!query.trim())
67
101
  return [];
68
- let regex;
69
- try {
70
- regex = new RegExp(query, "i");
71
- }
72
- catch {
73
- const words = query.split(/\s+/).filter((w) => w.length > 0);
74
- const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
75
- const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
76
- regex = new RegExp(lookaheads, "i");
77
- }
102
+ const regex = compileSearchRegex(query);
78
103
  const budgetBytes = 20 * 1024 * 1024;
79
104
  let scanned = 0;
80
105
  const results = [];
@@ -83,13 +108,11 @@ export class HistoryFile {
83
108
  if (scanned > budgetBytes)
84
109
  break;
85
110
  const entry = deserializeEntry(line);
86
- if (!entry || isReadOnly(entry))
111
+ if (!entry)
87
112
  continue;
88
- // Body can hold ~4000 chars the summary truncates — search both.
89
- const searchText = [entry.sum, entry.body].filter(Boolean).join("\n");
90
- if (regex.test(searchText)) {
91
- results.push({ entry, line: formatNuclearLine(entry) });
92
- }
113
+ const m = matchEntry(entry, regex);
114
+ if (m)
115
+ results.push(m);
93
116
  }
94
117
  return results;
95
118
  }
@@ -66,3 +66,10 @@ export declare function serializeEntry(entry: NuclearEntry): string;
66
66
  export declare function deserializeEntry(line: string): NuclearEntry | null;
67
67
  /** Check if a nuclear entry represents a read-only action (should be dropped). */
68
68
  export declare function isReadOnly(entry: NuclearEntry): boolean;
69
+ /** Compile a search query, falling back to whitespace-split AND-of-words on invalid regex. */
70
+ export declare function compileSearchRegex(query: string): RegExp;
71
+ /** Match a writable entry against a search regex; null if filtered or no match. */
72
+ export declare function matchEntry(entry: NuclearEntry, re: RegExp): {
73
+ entry: NuclearEntry;
74
+ line: string;
75
+ } | null;
@@ -200,6 +200,25 @@ export function isReadOnly(entry) {
200
200
  return false;
201
201
  return READ_ONLY_TOOLS.has(entry.tool) || extraReadOnlyTools.has(entry.tool);
202
202
  }
203
+ /** Compile a search query, falling back to whitespace-split AND-of-words on invalid regex. */
204
+ export function compileSearchRegex(query) {
205
+ try {
206
+ return new RegExp(query, "i");
207
+ }
208
+ catch {
209
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
210
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
211
+ const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
212
+ return new RegExp(lookaheads, "i");
213
+ }
214
+ }
215
+ /** Match a writable entry against a search regex; null if filtered or no match. */
216
+ export function matchEntry(entry, re) {
217
+ if (isReadOnly(entry))
218
+ return null;
219
+ const text = [entry.sum, entry.body].filter(Boolean).join("\n");
220
+ return re.test(text) ? { entry, line: formatNuclearLine(entry) } : null;
221
+ }
203
222
  // ── Internal helpers ──────────────────────────────────────────────
204
223
  function truncate(text, maxLen) {
205
224
  const oneLine = text.replace(/\n/g, " ").trim();
@@ -50,7 +50,7 @@ export function createGlobTool(getCwd) {
50
50
  timeout: 10_000,
51
51
  });
52
52
  await done;
53
- if (session.exitCode === -1 && session.output.startsWith("Failed to spawn")) {
53
+ if (session.spawnFailed) {
54
54
  return {
55
55
  content: "ripgrep not available — the bundled binary failed to load and `rg` is not on PATH. Reinstall agent-sh, or install ripgrep manually (https://github.com/BurntSushi/ripgrep#installation).",
56
56
  exitCode: 1,
@@ -127,7 +127,7 @@ export function createGrepTool(getCwd) {
127
127
  maxOutputBytes: 64 * 1024,
128
128
  });
129
129
  await done;
130
- if (session.exitCode === -1 && session.output.startsWith("Failed to spawn")) {
130
+ if (session.spawnFailed) {
131
131
  return {
132
132
  content: "ripgrep not available — the bundled binary failed to load and `rg` is not on PATH. Reinstall agent-sh, or install ripgrep manually (https://github.com/BurntSushi/ripgrep#installation).",
133
133
  exitCode: 1,
@@ -0,0 +1,7 @@
1
+ import type { EventBus } from "../../event-bus.js";
2
+ import type { ToolDefinition } from "../types.js";
3
+ export declare function createPwshTool(opts: {
4
+ getCwd: () => string;
5
+ getEnv: () => Record<string, string>;
6
+ bus: EventBus;
7
+ }): ToolDefinition;
@@ -0,0 +1,90 @@
1
+ import { executeArgv, killSession } from "../../executor.js";
2
+ // Targets PowerShell 7+ (`pwsh`). Legacy `powershell.exe` is intentionally
3
+ // not auto-fallback — its tool surface diverges enough that compatibility
4
+ // shims aren't worth the maintenance.
5
+ export function createPwshTool(opts) {
6
+ return {
7
+ name: "pwsh",
8
+ description: "Execute a PowerShell command in an isolated subprocess. " +
9
+ "Use this on Windows when the `bash` tool fails (no /bin/bash available). " +
10
+ "Use PowerShell syntax — e.g. `Get-ChildItem`, `Select-String`, `$env:HOME`. " +
11
+ "Does not affect the user's shell state. " +
12
+ "cwd is set to the working directory from the shell context. " +
13
+ "Do NOT use pwsh for file searching — use grep/glob instead. " +
14
+ "Do NOT use pwsh for reading files — use read_file instead.",
15
+ input_schema: {
16
+ type: "object",
17
+ properties: {
18
+ command: {
19
+ type: "string",
20
+ description: "The PowerShell command to execute",
21
+ },
22
+ timeout: {
23
+ type: "number",
24
+ description: "Timeout in seconds (default: 60)",
25
+ },
26
+ description: {
27
+ type: "string",
28
+ description: "Short description of what this command does (e.g., 'Install dependencies', 'Run test suite')",
29
+ },
30
+ },
31
+ required: ["command"],
32
+ },
33
+ showOutput: true,
34
+ modifiesFiles: true,
35
+ requiresPermission: true,
36
+ getDisplayInfo: () => ({
37
+ kind: "execute",
38
+ icon: "▶",
39
+ locations: [],
40
+ }),
41
+ async execute(args, onChunk, ctx) {
42
+ const command = args.command;
43
+ const timeout = (args.timeout ?? 60) * 1000;
44
+ const intercepted = opts.bus.emitPipe("agent:terminal-intercept", {
45
+ command,
46
+ cwd: opts.getCwd(),
47
+ intercepted: false,
48
+ output: "",
49
+ });
50
+ if (intercepted.intercepted) {
51
+ return {
52
+ content: intercepted.output,
53
+ exitCode: 0,
54
+ isError: false,
55
+ };
56
+ }
57
+ const { session, done } = executeArgv({
58
+ file: "pwsh",
59
+ args: ["-NoProfile", "-NonInteractive", "-Command", command],
60
+ cwd: opts.getCwd(),
61
+ env: opts.getEnv(),
62
+ timeout,
63
+ onOutput: onChunk,
64
+ });
65
+ const onAbort = () => killSession(session);
66
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
67
+ try {
68
+ await done;
69
+ }
70
+ finally {
71
+ ctx?.signal?.removeEventListener("abort", onAbort);
72
+ }
73
+ if (session.spawnFailed) {
74
+ return {
75
+ content: "PowerShell (pwsh) not found on PATH. Install PowerShell 7: winget install Microsoft.PowerShell.",
76
+ exitCode: 1,
77
+ isError: true,
78
+ };
79
+ }
80
+ const content = session.truncated
81
+ ? `[output truncated, showing last portion]\n${session.output}`
82
+ : session.output;
83
+ return {
84
+ content: content || "(no output)",
85
+ exitCode: session.exitCode,
86
+ isError: session.exitCode !== 0,
87
+ };
88
+ },
89
+ };
90
+ }
package/dist/core.d.ts CHANGED
@@ -28,6 +28,8 @@ export type { ColorPalette } from "./utils/palette.js";
28
28
  export type { AgentBackend, ToolDefinition } from "./agent/types.js";
29
29
  export { runSubagent, type SubagentOptions } from "./agent/subagent.js";
30
30
  export { LlmClient } from "./utils/llm-client.js";
31
+ export { HistoryFile, InMemoryHistory, NoopHistory, type HistoryAdapter } from "./agent/history-file.js";
32
+ export type { NuclearEntry } from "./agent/nuclear-form.js";
31
33
  export interface AgentShellCore {
32
34
  bus: EventBus;
33
35
  contextManager: ContextManager;
package/dist/core.js CHANGED
@@ -34,6 +34,7 @@ export { EventBus } from "./event-bus.js";
34
34
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
35
35
  export { runSubagent } from "./agent/subagent.js";
36
36
  export { LlmClient } from "./utils/llm-client.js";
37
+ export { HistoryFile, InMemoryHistory, NoopHistory } from "./agent/history-file.js";
37
38
  export function createCore(config) {
38
39
  const bus = new EventBus();
39
40
  const handlers = new HandlerRegistry();
@@ -164,6 +165,9 @@ export function createCore(config) {
164
165
  contextManager,
165
166
  instanceId,
166
167
  llm: createLlmFacade(handlers),
168
+ providers: {
169
+ configure: (id, opts) => bus.emit("provider:configure", { id, ...opts }),
170
+ },
167
171
  quit: opts.quit,
168
172
  setPalette,
169
173
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),
@@ -320,6 +320,10 @@ export interface ShellEvents {
320
320
  /** Provider supports the reasoning_effort parameter. Default: true. */
321
321
  supportsReasoningEffort?: boolean;
322
322
  };
323
+ "provider:configure": {
324
+ id: string;
325
+ reasoningParams?: (level: string) => Record<string, unknown>;
326
+ };
323
327
  "agent:register-tool": {
324
328
  tool: import("./agent/types.js").ToolDefinition;
325
329
  extensionName?: string;
@@ -6,6 +6,9 @@ export interface ExecutorSession {
6
6
  exitCode: number | null;
7
7
  done: boolean;
8
8
  truncated: boolean;
9
+ /** True when the binary couldn't be launched (ENOENT, EACCES). Lets callers
10
+ * distinguish "tool missing" from "tool ran and exited with -1". */
11
+ spawnFailed: boolean;
9
12
  process: ChildProcess | null;
10
13
  resolve?: () => void;
11
14
  }
package/dist/executor.js CHANGED
@@ -16,6 +16,7 @@ export function executeCommand(opts) {
16
16
  exitCode: null,
17
17
  done: false,
18
18
  truncated: false,
19
+ spawnFailed: false,
19
20
  process: null,
20
21
  };
21
22
  const done = new Promise((resolve) => {
@@ -39,6 +40,7 @@ export function executeCommand(opts) {
39
40
  }
40
41
  catch (err) {
41
42
  session.exitCode = -1;
43
+ session.spawnFailed = true;
42
44
  session.output = `Failed to spawn: ${err instanceof Error ? err.message : String(err)}`;
43
45
  session.done = true;
44
46
  session.resolve?.();
@@ -79,6 +81,9 @@ export function executeCommand(opts) {
79
81
  cancelKill?.();
80
82
  if (!session.done) {
81
83
  session.exitCode = -1;
84
+ const code = err.code;
85
+ if (code === "ENOENT" || code === "EACCES")
86
+ session.spawnFailed = true;
82
87
  session.output += `\nProcess error: ${err.message}`;
83
88
  session.done = true;
84
89
  session.process = null;
@@ -102,6 +107,7 @@ export function executeArgv(opts) {
102
107
  exitCode: null,
103
108
  done: false,
104
109
  truncated: false,
110
+ spawnFailed: false,
105
111
  process: null,
106
112
  };
107
113
  const done = new Promise((resolve) => {
@@ -123,6 +129,7 @@ export function executeArgv(opts) {
123
129
  }
124
130
  catch (err) {
125
131
  session.exitCode = -1;
132
+ session.spawnFailed = true;
126
133
  session.output = `Failed to spawn ${opts.file}: ${err instanceof Error ? err.message : String(err)}`;
127
134
  session.done = true;
128
135
  session.resolve?.();
@@ -168,6 +175,9 @@ export function executeArgv(opts) {
168
175
  clearTimeout(timer);
169
176
  if (!session.done) {
170
177
  session.exitCode = -1;
178
+ const code = err.code;
179
+ if (code === "ENOENT" || code === "EACCES")
180
+ session.spawnFailed = true;
171
181
  session.output += `\nProcess error: ${err.message}`;
172
182
  session.done = true;
173
183
  session.process = null;
@@ -185,10 +195,17 @@ export function killSession(session) {
185
195
  const proc = session.process;
186
196
  if (!proc || !proc.pid)
187
197
  return () => { };
198
+ // Try process-group kill first (works for executeCommand's detached bash
199
+ // children); fall back to direct kill (executeArgv's non-detached spawn,
200
+ // and Windows where negative pids aren't supported).
188
201
  try {
189
202
  process.kill(-proc.pid, "SIGTERM");
190
203
  }
191
204
  catch { }
205
+ try {
206
+ proc.kill("SIGTERM");
207
+ }
208
+ catch { }
192
209
  let settled = false;
193
210
  const fallback = setTimeout(() => {
194
211
  if (!settled && !session.done && proc.pid) {
@@ -196,6 +213,10 @@ export function killSession(session) {
196
213
  process.kill(-proc.pid, "SIGKILL");
197
214
  }
198
215
  catch { }
216
+ try {
217
+ proc.kill("SIGKILL");
218
+ }
219
+ catch { }
199
220
  }
200
221
  }, 5000);
201
222
  fallback.unref();
@@ -8,6 +8,9 @@ function persistedModelFor(providerName) {
8
8
  return undefined;
9
9
  return getSettings().providers?.[providerName]?.defaultModel;
10
10
  }
11
+ function defaultReasoningBuilder(level) {
12
+ return level === "off" ? {} : { reasoning_effort: level };
13
+ }
11
14
  export default function agentBackend(ctx) {
12
15
  const { bus } = ctx;
13
16
  const config = ctx.call("config:get-shell-config") ?? {};
@@ -18,11 +21,14 @@ export default function agentBackend(ctx) {
18
21
  if (p)
19
22
  providerRegistry.set(name, p);
20
23
  }
24
+ const providerHooks = new Map();
21
25
  const buildModes = () => {
22
26
  const allModes = [];
23
27
  for (const [id, p] of providerRegistry) {
24
28
  if (!p.apiKey)
25
29
  continue;
30
+ const shapeId = p.reasoningShape ?? id;
31
+ const buildReasoningParams = providerHooks.get(shapeId)?.reasoningParams ?? defaultReasoningBuilder;
26
32
  for (const model of p.models) {
27
33
  const mc = p.modelCapabilities?.get(model);
28
34
  allModes.push({
@@ -33,6 +39,7 @@ export default function agentBackend(ctx) {
33
39
  reasoning: mc?.reasoning,
34
40
  supportsReasoningEffort: p.supportsReasoningEffort,
35
41
  echoReasoning: mc?.echoReasoning,
42
+ buildReasoningParams,
36
43
  });
37
44
  }
38
45
  }
@@ -67,6 +74,7 @@ export default function agentBackend(ctx) {
67
74
  initialModeIndex,
68
75
  compositor: ctx.compositor,
69
76
  instanceId: ctx.instanceId,
77
+ history: config.history,
70
78
  });
71
79
  bus.on("core:extensions-loaded", () => {
72
80
  const settings = getSettings();
@@ -126,6 +134,12 @@ export default function agentBackend(ctx) {
126
134
  },
127
135
  });
128
136
  });
137
+ bus.on("provider:configure", ({ id, reasoningParams }) => {
138
+ const prev = providerHooks.get(id) ?? {};
139
+ if (reasoningParams !== undefined)
140
+ prev.reasoningParams = reasoningParams;
141
+ providerHooks.set(id, prev);
142
+ });
129
143
  bus.on("provider:register", (p) => {
130
144
  const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
131
145
  const modelIds = [];
@@ -148,6 +162,7 @@ export default function agentBackend(ctx) {
148
162
  supportsReasoningEffort: p.supportsReasoningEffort,
149
163
  modelCapabilities: caps.size > 0 ? caps : undefined,
150
164
  });
165
+ const buildReasoningParams = providerHooks.get(p.id)?.reasoningParams ?? defaultReasoningBuilder;
151
166
  const addModes = modelIds.map((m) => {
152
167
  const mc = caps.get(m);
153
168
  return {
@@ -158,6 +173,7 @@ export default function agentBackend(ctx) {
158
173
  reasoning: mc?.reasoning,
159
174
  supportsReasoningEffort: p.supportsReasoningEffort,
160
175
  echoReasoning: mc?.echoReasoning,
176
+ buildReasoningParams,
161
177
  };
162
178
  });
163
179
  bus.emit("config:add-modes", { modes: addModes });
@@ -1,7 +1,9 @@
1
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.
2
+ * Built-in OpenAI-compatible provider. Two activation paths:
3
+ * - OPENAI_API_KEY only → cloud OpenAI, ships a curated catalog.
4
+ * - OPENAI_BASE_URL (any key) local/3rd-party server (Ollama, LM Studio,
5
+ * vLLM, llama.cpp); the catalog is fetched
6
+ * from the server's /models endpoint.
5
7
  */
6
8
  import type { ExtensionContext } from "../types.js";
7
9
  export default function activate(ctx: ExtensionContext): void;
@@ -1,34 +1,36 @@
1
- const DEFAULT_MODELS = [
2
- "gpt-5",
3
- "gpt-4.1",
4
- "gpt-4o",
5
- "gpt-4o-mini",
6
- "o3",
7
- "o3-mini",
1
+ const OPENAI_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
8
  ];
9
9
  export default function activate(ctx) {
10
- const apiKey = process.env.OPENAI_API_KEY;
11
- if (!apiKey)
12
- return;
10
+ const apiKey = process.env.OPENAI_API_KEY ?? "";
13
11
  const baseURL = process.env.OPENAI_BASE_URL;
14
- const id = baseURL ? "openai-compatible" : "openai";
15
12
  if (!baseURL) {
13
+ if (!apiKey)
14
+ return;
16
15
  ctx.bus.emit("provider:register", {
17
- id,
16
+ id: "openai",
18
17
  apiKey,
19
- defaultModel: DEFAULT_MODELS[0],
20
- models: DEFAULT_MODELS,
18
+ defaultModel: OPENAI_CLOUD_MODELS[0].id,
19
+ models: OPENAI_CLOUD_MODELS,
21
20
  });
22
21
  return;
23
22
  }
24
- // Register empty immediately so the provider resolves; refill from /models.
25
- ctx.bus.emit("provider:register", { id, apiKey, baseURL, models: [] });
23
+ const id = "openai-compatible";
24
+ // Local servers (Ollama, llama.cpp) often need no key; the SDK still
25
+ // requires a non-empty string for construction.
26
+ const sdkKey = apiKey || "no-key";
27
+ ctx.bus.emit("provider:register", { id, apiKey: sdkKey, baseURL, models: [] });
26
28
  fetchModels(baseURL, apiKey).then((models) => {
27
29
  if (models.length === 0)
28
30
  return;
29
31
  ctx.bus.emit("provider:register", {
30
32
  id,
31
- apiKey,
33
+ apiKey: sdkKey,
32
34
  baseURL,
33
35
  defaultModel: models[0],
34
36
  models,
@@ -36,9 +38,10 @@ export default function activate(ctx) {
36
38
  }).catch(() => { });
37
39
  }
38
40
  async function fetchModels(baseURL, apiKey) {
39
- const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, {
40
- headers: { Authorization: `Bearer ${apiKey}` },
41
- });
41
+ const headers = {};
42
+ if (apiKey)
43
+ headers.Authorization = `Bearer ${apiKey}`;
44
+ const res = await fetch(`${baseURL.replace(/\/$/, "")}/models`, { headers });
42
45
  if (!res.ok)
43
46
  return [];
44
47
  const data = await res.json();
@@ -6,10 +6,16 @@ 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) {
10
+ return level === "off"
11
+ ? { reasoning: { enabled: false } }
12
+ : { reasoning: { effort: level } };
13
+ }
9
14
  export default function activate(ctx) {
10
15
  const apiKey = process.env.OPENROUTER_API_KEY;
11
16
  if (!apiKey)
12
17
  return;
18
+ ctx.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
13
19
  ctx.bus.emit("provider:register", {
14
20
  id: "openrouter",
15
21
  apiKey,
@@ -27,6 +27,9 @@ export interface ProviderConfig {
27
27
  /** Case-insensitive regex sources matched against model id; matches default
28
28
  * to echoReasoning=true. Per-model echoReasoning still wins. */
29
29
  echoReasoningPatterns?: string[];
30
+ /** Borrow another registered provider's reasoning request shape by id
31
+ * (e.g. "openrouter"). Defaults to OpenAI-compat. */
32
+ reasoningShape?: string;
30
33
  }
31
34
  export interface Settings {
32
35
  /** Extensions to load (npm packages or file paths). */
@@ -145,6 +148,8 @@ export interface ResolvedProvider {
145
148
  contextWindow?: number;
146
149
  echoReasoning?: boolean;
147
150
  }>;
151
+ /** Borrow another registered provider's reasoning request shape by id. */
152
+ reasoningShape?: string;
148
153
  }
149
154
  /**
150
155
  * Resolve a provider config by name from settings.
package/dist/settings.js CHANGED
@@ -161,6 +161,7 @@ export function resolveProvider(name) {
161
161
  models: modelIds.length ? modelIds : (defaultModel ? [defaultModel] : []),
162
162
  contextWindow: provider.contextWindow,
163
163
  modelCapabilities: caps.size > 0 ? caps : undefined,
164
+ reasoningShape: provider.reasoningShape,
164
165
  };
165
166
  }
166
167
  /** Get all configured provider names. */
package/dist/types.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils
5
5
  import type { ToolDefinition } from "./agent/types.js";
6
6
  import type { TerminalBuffer } from "./utils/terminal-buffer.js";
7
7
  import type { Compositor } from "./utils/compositor.js";
8
+ import type { HistoryAdapter } from "./agent/history-file.js";
8
9
  export type { ContentBlock } from "./event-bus.js";
9
10
  export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
10
11
  export type { RenderSurface } from "./utils/compositor.js";
@@ -51,6 +52,7 @@ export interface AgentMode {
51
52
  /** Echo reasoning_content back on assistant turns. Required by DeepSeek;
52
53
  * default off (leaky shims may forward it to the model as OOD input). */
53
54
  echoReasoning?: boolean;
55
+ buildReasoningParams?: (level: string) => Record<string, unknown>;
54
56
  }
55
57
  /**
56
58
  * Backend-agnostic LLM interface exposed via `ctx.llm`. Backends fulfill it
@@ -87,6 +89,8 @@ export interface AgentShellConfig {
87
89
  baseURL?: string;
88
90
  /** Named provider to use from settings.json. */
89
91
  provider?: string;
92
+ /** Conversation history backend. Defaults to the on-disk HistoryFile. */
93
+ history?: HistoryAdapter;
90
94
  }
91
95
  /**
92
96
  * Context passed to user/third-party extensions.
@@ -130,6 +134,11 @@ export interface ExtensionContext {
130
134
  registerSkill: (name: string, description: string, filePath: string) => void;
131
135
  /** Remove a registered skill by name. */
132
136
  removeSkill: (name: string) => void;
137
+ providers: {
138
+ configure: (id: string, opts: {
139
+ reasoningParams?: (level: string) => Record<string, unknown>;
140
+ }) => void;
141
+ };
133
142
  llm: LlmInterface;
134
143
  /** Register a named handler. */
135
144
  define: (name: string, fn: (...args: any[]) => any) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",