agent-sh 0.12.7 → 0.12.9

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
@@ -77,6 +77,8 @@ alias ash="agent-sh"
77
77
 
78
78
  Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fish, nushell, etc.) are not yet wired up.
79
79
 
80
+ **Windows:** the interactive shell layer is bash/zsh-only. Run agent-sh inside **WSL** for the full experience. Native Windows (cmd.exe / PowerShell) is not supported as the host shell, though headless / library / ACP-bridge usage may work — file an issue if you hit a gap.
81
+
80
82
  ## Key Features
81
83
 
82
84
  **Real terminal, zero compromise.** Full PTY with your shell config, aliases, and environment. Shell starts instantly — the agent connects asynchronously in the background.
@@ -1,6 +1,7 @@
1
1
  import { setMaxListeners } from "node:events";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import * as os from "node:os";
4
5
  import { computeDiff, computeEditDiff, computeInputDiff } from "../utils/diff.js";
5
6
  import { ToolRegistry } from "./tool-registry.js";
6
7
  import { ConversationState } from "./conversation-state.js";
@@ -14,6 +15,7 @@ import { getSettings, updateSettings } from "../settings.js";
14
15
  import { createToolProtocol } from "./tool-protocol.js";
15
16
  // Core tool factories
16
17
  import { createBashTool } from "./tools/bash.js";
18
+ import { createPwshTool } from "./tools/pwsh.js";
17
19
  import { createReadFileTool } from "./tools/read-file.js";
18
20
  import { createWriteFileTool } from "./tools/write-file.js";
19
21
  import { createEditFileTool } from "./tools/edit-file.js";
@@ -590,6 +592,9 @@ export class AgentLoop {
590
592
  return env;
591
593
  };
592
594
  this.toolRegistry.register(createBashTool({ getCwd, getEnv, bus: this.bus }));
595
+ if (process.platform === "win32") {
596
+ this.toolRegistry.register(createPwshTool({ getCwd, getEnv, bus: this.bus }));
597
+ }
593
598
  this.toolRegistry.register(createReadFileTool(getCwd, this.fileReadCache));
594
599
  this.toolRegistry.register(createWriteFileTool(getCwd));
595
600
  this.toolRegistry.register(createEditFileTool(getCwd));
@@ -915,7 +920,7 @@ export class AgentLoop {
915
920
  permKind = "file-write";
916
921
  // Shorten path for display
917
922
  const cwd = process.cwd();
918
- const home = process.env.HOME;
923
+ const home = process.env.HOME ?? os.homedir();
919
924
  let displayPath = absPath;
920
925
  if (absPath.startsWith(cwd + "/"))
921
926
  displayPath = absPath.slice(cwd.length + 1);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { executeCommand } from "../../executor.js";
3
+ import { executeArgv } from "../../executor.js";
4
+ import { resolveRgPath } from "../../utils/ripgrep-path.js";
4
5
  import { expandHome } from "./expand-home.js";
5
6
  export function createGlobTool(getCwd) {
6
7
  return {
@@ -15,11 +16,11 @@ export function createGlobTool(getCwd) {
15
16
  properties: {
16
17
  pattern: {
17
18
  type: "string",
18
- description: "Glob pattern (e.g., 'src/**/*.ts', '*.json')",
19
+ description: "Glob pattern (e.g., 'src/**/*.ts', '*.json'). Do NOT put `~` or absolute prefixes here — pass the directory in `path` instead.",
19
20
  },
20
21
  path: {
21
22
  type: "string",
22
- description: "Base directory to search (default: cwd)",
23
+ description: "Base directory to search (default: cwd). Supports `~` and `~/...` for the home directory.",
23
24
  },
24
25
  },
25
26
  required: ["pattern"],
@@ -42,18 +43,20 @@ export function createGlobTool(getCwd) {
42
43
  const pattern = args.pattern;
43
44
  const searchPath = expandHome(args.path ?? ".");
44
45
  // Use ripgrep for correct glob matching + .gitignore awareness
45
- const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
46
- const parts = [
47
- "rg", "--files",
48
- "--glob", shellEsc(pattern),
49
- shellEsc(searchPath),
50
- ];
51
- const { session, done } = executeCommand({
52
- command: parts.join(" "),
46
+ const { session, done } = executeArgv({
47
+ file: resolveRgPath(),
48
+ args: ["--files", "--glob", pattern, searchPath],
53
49
  cwd: getCwd(),
54
50
  timeout: 10_000,
55
51
  });
56
52
  await done;
53
+ if (session.spawnFailed) {
54
+ return {
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
+ exitCode: 1,
57
+ isError: true,
58
+ };
59
+ }
57
60
  if (!session.output.trim()) {
58
61
  return {
59
62
  content: "No files matched.",
@@ -1,4 +1,5 @@
1
- import { executeCommand } from "../../executor.js";
1
+ import { executeArgv } from "../../executor.js";
2
+ import { resolveRgPath } from "../../utils/ripgrep-path.js";
2
3
  import { expandHome } from "./expand-home.js";
3
4
  export function createGrepTool(getCwd) {
4
5
  return {
@@ -89,43 +90,50 @@ export function createGrepTool(getCwd) {
89
90
  const contextAfter = args.context_after;
90
91
  const headLimit = args.head_limit;
91
92
  const offset = args.offset ?? 0;
92
- const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
93
- const parts = ["rg", "--color=never"];
93
+ const rgArgs = ["--color=never"];
94
94
  // Mode-specific flags
95
95
  if (mode === "files_with_matches") {
96
- parts.push("--files-with-matches");
96
+ rgArgs.push("--files-with-matches");
97
97
  }
98
98
  else if (mode === "count") {
99
- parts.push("--count");
99
+ rgArgs.push("--count");
100
100
  }
101
101
  else {
102
102
  // content mode
103
- parts.push("--line-number", "--no-heading");
103
+ rgArgs.push("--line-number", "--no-heading");
104
104
  if (contextBefore != null && contextBefore > 0) {
105
- parts.push(`-B${contextBefore}`);
105
+ rgArgs.push(`-B${contextBefore}`);
106
106
  }
107
107
  if (contextAfter != null && contextAfter > 0) {
108
- parts.push(`-A${contextAfter}`);
108
+ rgArgs.push(`-A${contextAfter}`);
109
109
  }
110
110
  // If neither -A nor -B specified, use --max-count to limit per-file
111
111
  if (contextBefore == null && contextAfter == null) {
112
- parts.push("--max-count=50");
112
+ rgArgs.push("--max-count=50");
113
113
  }
114
114
  }
115
115
  if (caseInsensitive) {
116
- parts.push("-i");
116
+ rgArgs.push("-i");
117
117
  }
118
118
  if (include) {
119
- parts.push("--glob", shellEsc(include));
119
+ rgArgs.push("--glob", include);
120
120
  }
121
- parts.push("-e", shellEsc(pattern), shellEsc(searchPath));
122
- const { session, done } = executeCommand({
123
- command: parts.join(" "),
121
+ rgArgs.push("-e", pattern, searchPath);
122
+ const { session, done } = executeArgv({
123
+ file: resolveRgPath(),
124
+ args: rgArgs,
124
125
  cwd: getCwd(),
125
126
  timeout: 10_000,
126
127
  maxOutputBytes: 64 * 1024,
127
128
  });
128
129
  await done;
130
+ if (session.spawnFailed) {
131
+ return {
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
+ exitCode: 1,
134
+ isError: true,
135
+ };
136
+ }
129
137
  if (session.exitCode === 1 && !session.output.trim()) {
130
138
  // If the pattern looks like a filename (e.g. "SKILL.md", "package.json"),
131
139
  // the agent probably meant to find files by name, not search inside them.
@@ -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
+ }
@@ -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
  }
@@ -24,6 +27,23 @@ export declare function executeCommand(opts: {
24
27
  session: ExecutorSession;
25
28
  done: Promise<void>;
26
29
  };
30
+ /**
31
+ * Spawn a binary directly (no shell). Use for invoking known tools like `rg`
32
+ * with structured args — avoids shell-quoting bugs and works on platforms
33
+ * without /bin/bash.
34
+ */
35
+ export declare function executeArgv(opts: {
36
+ file: string;
37
+ args: string[];
38
+ cwd: string;
39
+ env?: Record<string, string>;
40
+ timeout?: number;
41
+ maxOutputBytes?: number;
42
+ onOutput?: (chunk: string) => void;
43
+ }): {
44
+ session: ExecutorSession;
45
+ done: Promise<void>;
46
+ };
27
47
  /**
28
48
  * Kill a running session's process group: SIGTERM, then SIGKILL after 5s.
29
49
  * Returns a cleanup that cancels the pending SIGKILL — callers should invoke
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,103 @@ 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;
87
+ session.output += `\nProcess error: ${err.message}`;
88
+ session.done = true;
89
+ session.process = null;
90
+ session.resolve?.();
91
+ }
92
+ });
93
+ return { session, done };
94
+ }
95
+ /**
96
+ * Spawn a binary directly (no shell). Use for invoking known tools like `rg`
97
+ * with structured args — avoids shell-quoting bugs and works on platforms
98
+ * without /bin/bash.
99
+ */
100
+ export function executeArgv(opts) {
101
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
102
+ const maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
103
+ const session = {
104
+ id: "",
105
+ command: `${opts.file} ${opts.args.join(" ")}`,
106
+ output: "",
107
+ exitCode: null,
108
+ done: false,
109
+ truncated: false,
110
+ spawnFailed: false,
111
+ process: null,
112
+ };
113
+ const done = new Promise((resolve) => {
114
+ session.resolve = resolve;
115
+ });
116
+ const env = {};
117
+ const source = opts.env ?? process.env;
118
+ for (const [k, v] of Object.entries(source)) {
119
+ if (v !== undefined)
120
+ env[k] = v;
121
+ }
122
+ let child;
123
+ try {
124
+ child = spawn(opts.file, opts.args, {
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ cwd: opts.cwd,
127
+ env,
128
+ });
129
+ }
130
+ catch (err) {
131
+ session.exitCode = -1;
132
+ session.spawnFailed = true;
133
+ session.output = `Failed to spawn ${opts.file}: ${err instanceof Error ? err.message : String(err)}`;
134
+ session.done = true;
135
+ session.resolve?.();
136
+ return { session, done };
137
+ }
138
+ session.process = child;
139
+ const handleData = (data) => {
140
+ const raw = data.toString("utf-8");
141
+ const clean = stripAnsi(raw);
142
+ session.output += clean;
143
+ if (session.output.length > maxOutput) {
144
+ session.output = session.output.slice(-maxOutput);
145
+ session.truncated = true;
146
+ }
147
+ opts.onOutput?.(raw);
148
+ };
149
+ child.stdout?.on("data", handleData);
150
+ child.stderr?.on("data", handleData);
151
+ const timer = setTimeout(() => {
152
+ if (!session.done && session.process) {
153
+ try {
154
+ session.process.kill("SIGTERM");
155
+ }
156
+ catch { }
157
+ setTimeout(() => {
158
+ if (!session.done && session.process) {
159
+ try {
160
+ session.process.kill("SIGKILL");
161
+ }
162
+ catch { }
163
+ }
164
+ }, 5000).unref();
165
+ }
166
+ }, timeout);
167
+ child.on("exit", (code, signal) => {
168
+ clearTimeout(timer);
169
+ session.exitCode = code ?? (signal ? -1 : null);
170
+ session.done = true;
171
+ session.process = null;
172
+ session.resolve?.();
173
+ });
174
+ child.on("error", (err) => {
175
+ clearTimeout(timer);
176
+ if (!session.done) {
177
+ session.exitCode = -1;
178
+ const code = err.code;
179
+ if (code === "ENOENT" || code === "EACCES")
180
+ session.spawnFailed = true;
82
181
  session.output += `\nProcess error: ${err.message}`;
83
182
  session.done = true;
84
183
  session.process = null;
@@ -96,10 +195,17 @@ export function killSession(session) {
96
195
  const proc = session.process;
97
196
  if (!proc || !proc.pid)
98
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).
99
201
  try {
100
202
  process.kill(-proc.pid, "SIGTERM");
101
203
  }
102
204
  catch { }
205
+ try {
206
+ proc.kill("SIGTERM");
207
+ }
208
+ catch { }
103
209
  let settled = false;
104
210
  const fallback = setTimeout(() => {
105
211
  if (!settled && !session.done && proc.pid) {
@@ -107,6 +213,10 @@ export function killSession(session) {
107
213
  process.kill(-proc.pid, "SIGKILL");
108
214
  }
109
215
  catch { }
216
+ try {
217
+ proc.kill("SIGKILL");
218
+ }
219
+ catch { }
110
220
  }
111
221
  }, 5000);
112
222
  fallback.unref();
@@ -1,6 +1,6 @@
1
1
  import { getSettings } from "../settings.js";
2
2
  const BASE_URL = "https://openrouter.ai/api/v1";
3
- const DEFAULT_MODELS = ["anthropic/claude-sonnet-4.6"];
3
+ const DEFAULT_MODELS = ["deepseek/deepseek-v4-flash"];
4
4
  // Built-in defaults for models requiring reasoning_content echoed back
5
5
  // (server 400s without it). Extend or override in settings.json:
6
6
  // providers.openrouter.echoReasoningPatterns = ["deepseek", "..."]
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolve the ripgrep binary path. Prefers the version bundled via
3
+ * @vscode/ripgrep (downloaded by its postinstall hook). Falls back to plain
4
+ * "rg" so users with rg on PATH still work even if the postinstall failed
5
+ * (offline install, blocked egress, etc.).
6
+ */
7
+ export declare function resolveRgPath(): string;
@@ -0,0 +1,18 @@
1
+ import * as fs from "node:fs";
2
+ import { rgPath as bundledRgPath } from "@vscode/ripgrep";
3
+ /**
4
+ * Resolve the ripgrep binary path. Prefers the version bundled via
5
+ * @vscode/ripgrep (downloaded by its postinstall hook). Falls back to plain
6
+ * "rg" so users with rg on PATH still work even if the postinstall failed
7
+ * (offline install, blocked egress, etc.).
8
+ */
9
+ export function resolveRgPath() {
10
+ try {
11
+ if (bundledRgPath && fs.existsSync(bundledRgPath))
12
+ return bundledRgPath;
13
+ }
14
+ catch {
15
+ // fall through
16
+ }
17
+ return "rg";
18
+ }
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os";
1
2
  import { visibleLen } from "./ansi.js";
2
3
  import { palette as p } from "./palette.js";
3
4
  // ── Quiet command detection ──────────────────────────────────────
@@ -231,7 +232,7 @@ function shortenPath(p, cwd) {
231
232
  return p.slice(cwd.length + 1);
232
233
  if (p.startsWith(cwd))
233
234
  return p.slice(cwd.length) || ".";
234
- const home = process.env.HOME;
235
+ const home = process.env.HOME ?? os.homedir();
235
236
  if (home && p.startsWith(home + "/"))
236
237
  return "~/" + p.slice(home.length + 1);
237
238
  return p;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -127,6 +127,7 @@
127
127
  "node": ">=18"
128
128
  },
129
129
  "dependencies": {
130
+ "@vscode/ripgrep": "^1.17.1",
130
131
  "@xterm/addon-serialize": "^0.13.0",
131
132
  "@xterm/headless": "^5.5.0",
132
133
  "cli-highlight": "^2.1.11",