agent-sh 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,66 @@
1
+ import type { ToolDefinition, ToolResult, ToolSchemaView } from "./types.js";
2
+ import type { ChatCompletionTool } from "./llm-client.js";
3
+ import type { HandlerFunctions } from "../utils/handler-registry.js";
4
+ import { registerReadOnlyTool, unregisterReadOnlyTool } from "./nuclear-form.js";
5
+
6
+ /**
7
+ * Registry for agent tools. Execution is routed through the named-handler
8
+ * registry under `tool:<name>` so extensions can `advise` a tool without
9
+ * owning it; duplicate `register` calls throw rather than silently evict.
10
+ */
11
+ export class ToolRegistry {
12
+ private tools = new Map<string, ToolDefinition>();
13
+
14
+ constructor(private handlers: HandlerFunctions) {}
15
+
16
+ register(tool: ToolDefinition): void {
17
+ if (this.tools.has(tool.name)) {
18
+ throw new Error(`Tool "${tool.name}" already registered. Use ctx.agent.adviseTool() to wrap it.`);
19
+ }
20
+ this.tools.set(tool.name, tool);
21
+ this.handlers.define(`tool:${tool.name}`, tool.execute.bind(tool));
22
+ this.handlers.define(`tool:${tool.name}:schema`, (): ToolSchemaView => ({
23
+ description: tool.description,
24
+ parameters: tool.input_schema,
25
+ }));
26
+ if (tool.readOnly) registerReadOnlyTool(tool.name);
27
+ else unregisterReadOnlyTool(tool.name);
28
+ }
29
+
30
+ unregister(name: string): void {
31
+ this.tools.delete(name);
32
+ unregisterReadOnlyTool(name);
33
+ // Handler entry intentionally retained so external advisors survive a
34
+ // reload of the tool's owner and reattach when the name re-registers.
35
+ }
36
+
37
+ get(name: string): ToolDefinition | undefined {
38
+ return this.tools.get(name);
39
+ }
40
+
41
+ all(): ToolDefinition[] {
42
+ return Array.from(this.tools.values());
43
+ }
44
+
45
+ allView(): ToolDefinition[] {
46
+ return this.all().map((t) => {
47
+ const view = this.handlers.call(`tool:${t.name}:schema`) as ToolSchemaView;
48
+ return { ...t, description: view.description, input_schema: view.parameters };
49
+ });
50
+ }
51
+
52
+ call(name: string, ...args: Parameters<ToolDefinition["execute"]>): Promise<ToolResult> {
53
+ return this.handlers.call(`tool:${name}`, ...args) as Promise<ToolResult>;
54
+ }
55
+
56
+ toAPITools(): ChatCompletionTool[] {
57
+ return this.allView().map((t) => ({
58
+ type: "function" as const,
59
+ function: {
60
+ name: t.name,
61
+ description: t.description,
62
+ parameters: t.input_schema,
63
+ },
64
+ }));
65
+ }
66
+ }
@@ -0,0 +1,95 @@
1
+ import { executeCommand, killSession } from "../../utils/executor.js";
2
+ import type { EventBus } from "../../core/event-bus.js";
3
+ import type { ToolDefinition } from "../types.js";
4
+
5
+ export function createBashTool(opts: {
6
+ getCwd: () => string;
7
+ getEnv: () => Record<string, string>;
8
+ bus: EventBus;
9
+ }): ToolDefinition {
10
+ return {
11
+ name: "bash",
12
+ description:
13
+ "Execute a bash command in an isolated subprocess. Output is captured and returned. " +
14
+ "Does not affect the user's shell state. " +
15
+ "cwd is set to the working directory from the shell context. " +
16
+ "Keep commands focused; avoid long-running or blocking processes. " +
17
+ "Always check the returned exit code before treating output as success. " +
18
+ "Do NOT use bash for file searching — use grep/glob instead. " +
19
+ "Do NOT use bash for reading files — use read_file instead.",
20
+ input_schema: {
21
+ type: "object",
22
+ properties: {
23
+ command: {
24
+ type: "string",
25
+ description: "The bash command to execute",
26
+ },
27
+ timeout: {
28
+ type: "number",
29
+ description: "Timeout in seconds (default: 60)",
30
+ },
31
+ description: {
32
+ type: "string",
33
+ description:
34
+ "Short description of what this command does (e.g., 'Install dependencies', 'Run test suite')",
35
+ },
36
+ },
37
+ required: ["command"],
38
+ },
39
+
40
+ showOutput: true,
41
+ modifiesFiles: true,
42
+
43
+ getDisplayInfo: (args) => ({
44
+ kind: "execute",
45
+ icon: "▶",
46
+ locations: [],
47
+ }),
48
+
49
+ async execute(args, onChunk, ctx) {
50
+ const command = args.command as string;
51
+ const timeout = ((args.timeout as number) ?? 60) * 1000;
52
+
53
+ // Let extensions intercept before execution
54
+ const intercepted = opts.bus.emitPipe("agent:terminal-intercept", {
55
+ command,
56
+ cwd: opts.getCwd(),
57
+ intercepted: false,
58
+ output: "",
59
+ });
60
+ if (intercepted.intercepted) {
61
+ return {
62
+ content: intercepted.output,
63
+ exitCode: 0,
64
+ isError: false,
65
+ };
66
+ }
67
+
68
+ const { session, done } = executeCommand({
69
+ command,
70
+ cwd: opts.getCwd(),
71
+ env: opts.getEnv(),
72
+ timeout,
73
+ onOutput: onChunk,
74
+ });
75
+
76
+ const onAbort = () => killSession(session);
77
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
78
+ try {
79
+ await done;
80
+ } finally {
81
+ ctx?.signal?.removeEventListener("abort", onAbort);
82
+ }
83
+
84
+ const content = session.truncated
85
+ ? `[output truncated, showing last portion]\n${session.output}`
86
+ : session.output;
87
+
88
+ return {
89
+ content: content || "(no output)",
90
+ exitCode: session.exitCode,
91
+ isError: session.exitCode !== 0,
92
+ };
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,154 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { ToolDefinition } from "../types.js";
4
+ import { computeEditDiff } from "../../utils/diff.js";
5
+ import { expandHome } from "./expand-home.js";
6
+
7
+ /**
8
+ * Find the closest matching region in the file content to help diagnose
9
+ * why an exact match failed. Returns a hint string.
10
+ */
11
+ function findClosestMatch(content: string, needle: string): string {
12
+ const hints: string[] = [];
13
+
14
+ // Check if trimming whitespace would match
15
+ const trimmedNeedle = needle.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
16
+ const trimmedContent = content.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
17
+ if (trimmedContent.includes(trimmedNeedle)) {
18
+ hints.push(" Whitespace (indentation or trailing spaces) differs — check leading/trailing spaces on each line.");
19
+ return hints.join("");
20
+ }
21
+
22
+ // Check if the first line exists to narrow down the region
23
+ const needleLines = needle.split("\n");
24
+ const firstLine = needleLines[0].trim();
25
+ if (firstLine.length > 10) {
26
+ const contentLines = content.split("\n");
27
+ const matches = contentLines
28
+ .map((l, i) => ({ line: i + 1, text: l }))
29
+ .filter((l) => l.text.includes(firstLine));
30
+
31
+ if (matches.length > 0 && needleLines.length > 1) {
32
+ const loc = matches.map((m) => `line ${m.line}`).join(", ");
33
+ hints.push(` First line found at ${loc}, but subsequent lines differ. The file may have changed — use read_file to see current content around that region.`);
34
+ return hints.join("");
35
+ }
36
+ }
37
+
38
+ hints.push(" Use read_file to verify the current file contents before retrying.");
39
+ return hints.join("");
40
+ }
41
+
42
+ export function createEditFileTool(getCwd: () => string): ToolDefinition {
43
+ return {
44
+ name: "edit_file",
45
+ description:
46
+ "Edit a file by replacing an exact text match with new text. " +
47
+ "The old_text must appear exactly once unless replace_all=true. " +
48
+ "Include enough context to make the match unique. " +
49
+ "Use replace_all for variable renames or bulk string replacements.",
50
+ input_schema: {
51
+ type: "object",
52
+ properties: {
53
+ path: {
54
+ type: "string",
55
+ description: "Absolute or relative file path",
56
+ },
57
+ old_text: {
58
+ type: "string",
59
+ description: "Exact text to find (must appear exactly once)",
60
+ },
61
+ new_text: {
62
+ type: "string",
63
+ description: "Replacement text",
64
+ },
65
+ replace_all: {
66
+ type: "boolean",
67
+ description:
68
+ "Replace ALL occurrences instead of requiring a unique match. Useful for variable renames.",
69
+ },
70
+ },
71
+ required: ["path", "old_text", "new_text"],
72
+ },
73
+
74
+ showOutput: true,
75
+ modifiesFiles: true,
76
+
77
+ getDisplayInfo: (args) => ({
78
+ kind: "write",
79
+ icon: "✎",
80
+ locations: [{ path: args.path as string }],
81
+ }),
82
+
83
+ async execute(args) {
84
+ const filePath = expandHome(args.path as string);
85
+ const oldText = args.old_text as string;
86
+ const newText = args.new_text as string;
87
+ const replaceAll = (args.replace_all as boolean) ?? false;
88
+ const absPath = path.resolve(getCwd(), filePath);
89
+
90
+ try {
91
+ const content = await fs.readFile(absPath, "utf-8");
92
+
93
+ // Normalize line endings for matching
94
+ const normalized = content.replace(/\r\n/g, "\n");
95
+ const normalizedOld = oldText.replace(/\r\n/g, "\n");
96
+
97
+ const occurrences =
98
+ normalized.split(normalizedOld).length - 1;
99
+ if (occurrences === 0) {
100
+ // Try to find the closest match to help the agent self-correct
101
+ const hint = findClosestMatch(normalized, normalizedOld);
102
+ return {
103
+ content: `Error: old_text not found in ${filePath}.${hint}`,
104
+ exitCode: 1,
105
+ isError: true,
106
+ };
107
+ }
108
+ if (occurrences > 1 && !replaceAll) {
109
+ return {
110
+ content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context or use replace_all=true.`,
111
+ exitCode: 1,
112
+ isError: true,
113
+ };
114
+ }
115
+
116
+ const normalizedNew = newText.replace(/\r\n/g, "\n");
117
+ // Use split/join for literal replacement everywhere. String.replace()
118
+ // treats dollar-sign patterns in the replacement as special substitution
119
+ // variables, which corrupts file content containing regex escape sequences.
120
+ const newContent = normalized.split(normalizedOld).join(normalizedNew);
121
+ // Note: when !replaceAll, we rely on the occurrence check above to ensure
122
+ // normalizedOld appears exactly once, so split/join replaces only that one.
123
+
124
+ // Restore original line endings — only convert if the file was
125
+ // predominantly CRLF (>50% of line endings), to avoid corrupting
126
+ // mixed-ending files.
127
+ const crlfCount = (content.match(/\r\n/g) || []).length;
128
+ const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
129
+ const useCRLF = crlfCount > 0 && crlfCount >= lfCount;
130
+ const finalContent = useCRLF
131
+ ? newContent.replace(/\n/g, "\r\n")
132
+ : newContent;
133
+
134
+ await fs.writeFile(absPath, finalContent);
135
+
136
+ const diff = computeEditDiff(normalized, normalizedOld, normalizedNew, replaceAll);
137
+ const stats = diff.isNewFile ? `+${diff.added}` : `+${diff.added} -${diff.removed}`;
138
+ return {
139
+ content: `Edited ${absPath} (${stats})`,
140
+ exitCode: 0,
141
+ isError: false,
142
+ display: {
143
+ summary: stats,
144
+ body: { kind: "diff", diff, filePath: absPath },
145
+ },
146
+ };
147
+ } catch (err) {
148
+ const msg =
149
+ err instanceof Error ? err.message : String(err);
150
+ return { content: `Error: ${msg}`, exitCode: 1, isError: true };
151
+ }
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,7 @@
1
+ import * as os from "node:os";
2
+
3
+ export function expandHome(p: string): string {
4
+ if (p === "~") return os.homedir();
5
+ if (p.startsWith("~/")) return os.homedir() + p.slice(1);
6
+ return p;
7
+ }
@@ -0,0 +1,108 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { executeArgv } from "../../utils/executor.js";
4
+ import { resolveRgPath } from "../../utils/ripgrep-path.js";
5
+ import { contentText, type ToolDefinition } from "../types.js";
6
+ import { expandHome } from "./expand-home.js";
7
+
8
+ export function createGlobTool(getCwd: () => string): ToolDefinition {
9
+ return {
10
+ name: "glob",
11
+ description:
12
+ "Use this when you know a FILENAME or PATH SHAPE (e.g. `**/*.ts`, `src/**/*.md`, `package.json`). " +
13
+ "Returns matching file paths sorted by modification time (newest first). " +
14
+ "This does NOT search file contents — use `grep` for that. " +
15
+ "ALWAYS use this instead of find/ls via bash. " +
16
+ "Typical flow: `glob` to locate files, then `read_file` or `grep` to inspect contents.",
17
+ input_schema: {
18
+ type: "object",
19
+ properties: {
20
+ pattern: {
21
+ type: "string",
22
+ description: "Glob pattern (e.g., 'src/**/*.ts', '*.json'). Do NOT put `~` or absolute prefixes here — pass the directory in `path` instead.",
23
+ },
24
+ path: {
25
+ type: "string",
26
+ description: "Base directory to search (default: cwd). Supports `~` and `~/...` for the home directory.",
27
+ },
28
+ },
29
+ required: ["pattern"],
30
+ },
31
+
32
+ showOutput: false,
33
+
34
+ formatResult: (_args, result) => {
35
+ const text = contentText(result.content);
36
+ if (result.isError || text === "No files matched.") return { summary: "0 files" };
37
+ const lines = text.split("\n").filter(l => l && !l.startsWith("["));
38
+ return { summary: `${lines.length} files` };
39
+ },
40
+
41
+ getDisplayInfo: (args) => ({
42
+ kind: "search",
43
+ icon: "⌕",
44
+ locations: args.path
45
+ ? [{ path: args.path as string }]
46
+ : [],
47
+ }),
48
+
49
+ async execute(args) {
50
+ const pattern = args.pattern as string;
51
+ const searchPath = expandHome((args.path as string) ?? ".");
52
+
53
+ // Use ripgrep for correct glob matching + .gitignore awareness
54
+ const { session, done } = executeArgv({
55
+ file: resolveRgPath(),
56
+ args: ["--files", "--glob", pattern, searchPath],
57
+ cwd: getCwd(),
58
+ timeout: 10_000,
59
+ });
60
+ await done;
61
+
62
+ if (session.spawnFailed) {
63
+ return {
64
+ 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).",
65
+ exitCode: 1,
66
+ isError: true,
67
+ };
68
+ }
69
+
70
+ if (!session.output.trim()) {
71
+ return {
72
+ content: "No files matched.",
73
+ exitCode: 0,
74
+ isError: false,
75
+ };
76
+ }
77
+
78
+ const cwd = getCwd();
79
+ const files = session.output.trim().split("\n");
80
+
81
+ // Sort by modification time (newest first)
82
+ const withMtime = await Promise.all(
83
+ files.map(async (f) => {
84
+ try {
85
+ const abs = path.resolve(cwd, f);
86
+ const stat = await fs.stat(abs);
87
+ return { file: f, mtime: stat.mtimeMs };
88
+ } catch {
89
+ return { file: f, mtime: 0 };
90
+ }
91
+ }),
92
+ );
93
+ withMtime.sort((a, b) => b.mtime - a.mtime);
94
+
95
+ const sorted = withMtime.slice(0, 200).map((e) => e.file);
96
+ const truncated = files.length > 200;
97
+ const suffix = truncated
98
+ ? `\n[Results capped at 200 files, ${files.length - 200} more matched]`
99
+ : "";
100
+
101
+ return {
102
+ content: sorted.join("\n") + suffix,
103
+ exitCode: 0,
104
+ isError: false,
105
+ };
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,228 @@
1
+ import { executeArgv } from "../../utils/executor.js";
2
+ import { resolveRgPath } from "../../utils/ripgrep-path.js";
3
+ import { contentText, type ToolDefinition } from "../types.js";
4
+ import { expandHome } from "./expand-home.js";
5
+
6
+ export function createGrepTool(getCwd: () => string): ToolDefinition {
7
+ return {
8
+ name: "grep",
9
+ description:
10
+ "Use this when you know something INSIDE the file (text, identifier, regex). " +
11
+ "To find files by filename alone, use `glob` instead. " +
12
+ "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
13
+ "Supports three output modes: " +
14
+ "'files_with_matches' (default, returns file paths only — use this to find which files contain a pattern), " +
15
+ "'content' (matching lines with optional context_before/context_after), and " +
16
+ "'count' (match counts per file). " +
17
+ "Use head_limit and offset for pagination.",
18
+ input_schema: {
19
+ type: "object",
20
+ properties: {
21
+ pattern: {
22
+ type: "string",
23
+ description: "Regex pattern to search for (NOT a glob — `*.md` is invalid here; use `.*\\.md` for regex, or use the glob tool to find files by name). For filename filtering while searching content, use the `include` parameter.",
24
+ },
25
+ path: {
26
+ type: "string",
27
+ description: "Directory or file to search (default: cwd)",
28
+ },
29
+ include: {
30
+ type: "string",
31
+ description:
32
+ "Glob pattern for files to include (e.g., '*.ts')",
33
+ },
34
+ output_mode: {
35
+ type: "string",
36
+ enum: ["files_with_matches", "content", "count"],
37
+ description:
38
+ "Output mode: 'files_with_matches' (default, file paths only), " +
39
+ "'content' (matching lines), 'count' (match counts per file)",
40
+ },
41
+ case_insensitive: {
42
+ type: "boolean",
43
+ description: "Case insensitive search (default: false)",
44
+ },
45
+ context_before: {
46
+ type: "number",
47
+ description: "Lines to show before each match (content mode only)",
48
+ },
49
+ context_after: {
50
+ type: "number",
51
+ description: "Lines to show after each match (content mode only)",
52
+ },
53
+ head_limit: {
54
+ type: "number",
55
+ description:
56
+ "Max lines/entries to return (default: 200 for files_with_matches, 150 for content/count). Pass 0 for unlimited.",
57
+ },
58
+ offset: {
59
+ type: "number",
60
+ description:
61
+ "Skip first N lines/entries before applying head_limit. Use with head_limit for pagination.",
62
+ },
63
+ },
64
+ required: ["pattern"],
65
+ },
66
+
67
+ showOutput: false,
68
+
69
+ formatResult: (args, result) => {
70
+ const text = contentText(result.content);
71
+ if (result.isError || text === "No matches found.") return { summary: "0 matches" };
72
+ const lines = text.split("\n").filter(Boolean);
73
+ // Strip pagination info line from count
74
+ const resultLines = lines.filter(l => !l.startsWith("[Showing "));
75
+ const mode = (args.output_mode as string) ?? "files_with_matches";
76
+ if (mode === "files_with_matches") {
77
+ return { summary: `${resultLines.length} files` };
78
+ }
79
+ if (mode === "count") {
80
+ const total = resultLines.reduce((sum, l) => sum + (parseInt(l.split(":").pop() ?? "0", 10) || 0), 0);
81
+ return { summary: `${total} matches` };
82
+ }
83
+ return { summary: `${resultLines.length} lines` };
84
+ },
85
+
86
+ getDisplayInfo: (args) => ({
87
+ kind: "search",
88
+ icon: "⌕",
89
+ locations: args.path
90
+ ? [{ path: args.path as string }]
91
+ : [],
92
+ }),
93
+
94
+ async execute(args) {
95
+ const pattern = args.pattern as string;
96
+ const searchPath = expandHome((args.path as string) ?? ".");
97
+ const include = args.include as string | undefined;
98
+ const mode = (args.output_mode as string) ?? "files_with_matches";
99
+ const caseInsensitive = args.case_insensitive as boolean | undefined;
100
+ const contextBefore = args.context_before as number | undefined;
101
+ const contextAfter = args.context_after as number | undefined;
102
+ const headLimit = args.head_limit as number | undefined;
103
+ const offset = (args.offset as number) ?? 0;
104
+
105
+ const rgArgs: string[] = ["--color=never"];
106
+
107
+ // Mode-specific flags
108
+ if (mode === "files_with_matches") {
109
+ rgArgs.push("--files-with-matches");
110
+ } else if (mode === "count") {
111
+ rgArgs.push("--count");
112
+ } else {
113
+ // content mode
114
+ rgArgs.push("--line-number", "--no-heading");
115
+ if (contextBefore != null && contextBefore > 0) {
116
+ rgArgs.push(`-B${contextBefore}`);
117
+ }
118
+ if (contextAfter != null && contextAfter > 0) {
119
+ rgArgs.push(`-A${contextAfter}`);
120
+ }
121
+ // If neither -A nor -B specified, use --max-count to limit per-file
122
+ if (contextBefore == null && contextAfter == null) {
123
+ rgArgs.push("--max-count=50");
124
+ }
125
+ }
126
+
127
+ if (caseInsensitive) {
128
+ rgArgs.push("-i");
129
+ }
130
+ if (include) {
131
+ rgArgs.push("--glob", include);
132
+ }
133
+ rgArgs.push("-e", pattern, searchPath);
134
+
135
+ const { session, done } = executeArgv({
136
+ file: resolveRgPath(),
137
+ args: rgArgs,
138
+ cwd: getCwd(),
139
+ timeout: 10_000,
140
+ maxOutputBytes: 64 * 1024,
141
+ });
142
+ await done;
143
+
144
+ if (session.spawnFailed) {
145
+ return {
146
+ 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).",
147
+ exitCode: 1,
148
+ isError: true,
149
+ };
150
+ }
151
+
152
+ if (session.exitCode === 1 && !session.output.trim()) {
153
+ // If the pattern looks like a filename (e.g. "SKILL.md", "package.json"),
154
+ // the agent probably meant to find files by name, not search inside them.
155
+ // Surface a redirect hint instead of a silent zero.
156
+ const looksLikeFilename =
157
+ /^[A-Za-z0-9_.\-*/]+\.[A-Za-z0-9]{1,6}$/.test(pattern) &&
158
+ !/[\\()\[\]|^$+{}]/.test(pattern);
159
+ const hint = looksLikeFilename
160
+ ? ` Hint: "${pattern}" looks like a filename. grep searches file *contents* — to find files by name, use the \`glob\` tool instead.`
161
+ : "";
162
+ return {
163
+ content: `No matches found.${hint}`,
164
+ exitCode: 0,
165
+ isError: false,
166
+ };
167
+ }
168
+
169
+ // exit code >= 2 is a ripgrep error (invalid regex, unreadable path, etc).
170
+ // Surface it as an error so the model retries with a correct pattern
171
+ // rather than treating "no useful output" as a successful no-match.
172
+ if (session.exitCode != null && session.exitCode >= 2) {
173
+ const looksLikeGlob = /^[*?]|\*\./.test(pattern) && !/[\\()\[\]|^$]/.test(pattern);
174
+ const hint = looksLikeGlob
175
+ ? " Hint: `*.md` is a glob, not a regex — use the glob tool to find files by name, or pass `include: \"*.md\"` here to filter files while searching content for a regex pattern."
176
+ : "";
177
+ return {
178
+ content: `grep failed (rg exit ${session.exitCode}): ${session.output.trim() || "no output"}${hint}`,
179
+ exitCode: session.exitCode,
180
+ isError: true,
181
+ };
182
+ }
183
+
184
+ let output = session.output;
185
+
186
+ // Cap individual line lengths to 500 chars to prevent minified/base64 flood
187
+ if (mode === "content") {
188
+ const MAX_LINE_LEN = 500;
189
+ output = output
190
+ .split("\n")
191
+ .map((line) =>
192
+ line.length > MAX_LINE_LEN
193
+ ? line.slice(0, MAX_LINE_LEN) + "… [truncated]"
194
+ : line,
195
+ )
196
+ .join("\n");
197
+ }
198
+
199
+ // Apply pagination (offset + head_limit)
200
+ const defaultLimit = mode === "files_with_matches" ? 200 : 150;
201
+ const limit = headLimit === 0 ? Infinity : (headLimit ?? defaultLimit);
202
+ const lines = output.split("\n");
203
+ const total = lines.length;
204
+
205
+ // Apply offset then limit
206
+ const sliced = lines.slice(offset, offset + limit);
207
+ const paginated = sliced.join("\n");
208
+
209
+ const parts2: string[] = [];
210
+ if (paginated) parts2.push(paginated);
211
+
212
+ // Show pagination info when offset is used or results were truncated
213
+ if (offset > 0 || offset + limit < total) {
214
+ const shown = sliced.length;
215
+ const remaining = Math.max(0, total - offset - shown);
216
+ parts2.push(
217
+ `\n[Showing ${shown} results (offset=${offset}, limit=${limit === Infinity ? "unlimited" : limit})${remaining > 0 ? `, ${remaining} more available` : ""}]`,
218
+ );
219
+ }
220
+
221
+ return {
222
+ content: parts2.join("\n") || "No matches found.",
223
+ exitCode: 0,
224
+ isError: false,
225
+ };
226
+ },
227
+ };
228
+ }