agent-sh 0.5.0 → 0.7.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.
Files changed (54) hide show
  1. package/README.md +12 -43
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +119 -26
  4. package/dist/agent/subagent.js +3 -1
  5. package/dist/agent/system-prompt.d.ts +1 -1
  6. package/dist/agent/system-prompt.js +21 -16
  7. package/dist/agent/tools/bash.js +10 -1
  8. package/dist/agent/tools/display.d.ts +13 -0
  9. package/dist/agent/tools/display.js +70 -0
  10. package/dist/agent/tools/edit-file.js +60 -7
  11. package/dist/agent/tools/glob.js +39 -7
  12. package/dist/agent/tools/grep.js +111 -20
  13. package/dist/agent/tools/ls.js +31 -2
  14. package/dist/agent/tools/read-file.d.ts +9 -1
  15. package/dist/agent/tools/read-file.js +50 -4
  16. package/dist/agent/tools/user-shell.js +40 -13
  17. package/dist/agent/tools/write-file.js +9 -1
  18. package/dist/agent/types.d.ts +35 -1
  19. package/dist/context-manager.d.ts +3 -1
  20. package/dist/context-manager.js +11 -1
  21. package/dist/core.d.ts +1 -3
  22. package/dist/core.js +23 -12
  23. package/dist/event-bus.d.ts +41 -3
  24. package/dist/extension-loader.d.ts +1 -1
  25. package/dist/extension-loader.js +1 -3
  26. package/dist/extensions/overlay-agent.d.ts +11 -0
  27. package/dist/extensions/overlay-agent.js +43 -0
  28. package/dist/extensions/terminal-buffer.d.ts +14 -0
  29. package/dist/extensions/terminal-buffer.js +120 -0
  30. package/dist/extensions/tui-renderer.js +344 -83
  31. package/dist/index.js +45 -36
  32. package/dist/input-handler.js +10 -3
  33. package/dist/output-parser.js +8 -0
  34. package/dist/settings.js +1 -1
  35. package/dist/shell.d.ts +5 -0
  36. package/dist/shell.js +29 -4
  37. package/dist/types.d.ts +13 -0
  38. package/dist/utils/diff.js +10 -0
  39. package/dist/utils/floating-panel.d.ts +198 -0
  40. package/dist/utils/floating-panel.js +590 -0
  41. package/dist/utils/markdown.d.ts +1 -0
  42. package/dist/utils/markdown.js +23 -1
  43. package/dist/utils/output-writer.d.ts +14 -0
  44. package/dist/utils/output-writer.js +16 -0
  45. package/dist/utils/terminal-buffer.d.ts +65 -0
  46. package/dist/utils/terminal-buffer.js +166 -0
  47. package/dist/utils/tool-display.d.ts +4 -0
  48. package/dist/utils/tool-display.js +22 -5
  49. package/examples/extensions/claude-code-bridge/index.ts +8 -12
  50. package/examples/extensions/overlay-agent.ts +70 -0
  51. package/examples/extensions/pi-bridge/index.ts +10 -12
  52. package/examples/extensions/secret-guard.ts +100 -0
  53. package/examples/extensions/terminal-buffer.ts +184 -0
  54. package/package.json +5 -1
@@ -1,10 +1,43 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { computeDiff } from "../../utils/diff.js";
4
+ /**
5
+ * Find the closest matching region in the file content to help diagnose
6
+ * why an exact match failed. Returns a hint string.
7
+ */
8
+ function findClosestMatch(content, needle) {
9
+ const hints = [];
10
+ // Check if trimming whitespace would match
11
+ const trimmedNeedle = needle.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
12
+ const trimmedContent = content.replace(/[ \t]+$/gm, "").replace(/^[ \t]+/gm, "");
13
+ if (trimmedContent.includes(trimmedNeedle)) {
14
+ hints.push(" Whitespace (indentation or trailing spaces) differs — check leading/trailing spaces on each line.");
15
+ return hints.join("");
16
+ }
17
+ // Check if the first line exists to narrow down the region
18
+ const needleLines = needle.split("\n");
19
+ const firstLine = needleLines[0].trim();
20
+ if (firstLine.length > 10) {
21
+ const contentLines = content.split("\n");
22
+ const matches = contentLines
23
+ .map((l, i) => ({ line: i + 1, text: l }))
24
+ .filter((l) => l.text.includes(firstLine));
25
+ if (matches.length > 0 && needleLines.length > 1) {
26
+ const loc = matches.map((m) => `line ${m.line}`).join(", ");
27
+ 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.`);
28
+ return hints.join("");
29
+ }
30
+ }
31
+ hints.push(" Use read_file to verify the current file contents before retrying.");
32
+ return hints.join("");
33
+ }
4
34
  export function createEditFileTool(getCwd) {
5
35
  return {
6
36
  name: "edit_file",
7
- description: "Edit a file by replacing an exact text match with new text. The old_text must appear exactly once in the file. Include enough context to make the match unique.",
37
+ description: "Edit a file by replacing an exact text match with new text. " +
38
+ "The old_text must appear exactly once unless replace_all=true. " +
39
+ "Include enough context to make the match unique. " +
40
+ "Use replace_all for variable renames or bulk string replacements.",
8
41
  input_schema: {
9
42
  type: "object",
10
43
  properties: {
@@ -20,6 +53,10 @@ export function createEditFileTool(getCwd) {
20
53
  type: "string",
21
54
  description: "Replacement text",
22
55
  },
56
+ replace_all: {
57
+ type: "boolean",
58
+ description: "Replace ALL occurrences instead of requiring a unique match. Useful for variable renames.",
59
+ },
23
60
  },
24
61
  required: ["path", "old_text", "new_text"],
25
62
  },
@@ -28,12 +65,20 @@ export function createEditFileTool(getCwd) {
28
65
  requiresPermission: true,
29
66
  getDisplayInfo: (args) => ({
30
67
  kind: "write",
68
+ icon: "✎",
31
69
  locations: [{ path: args.path }],
32
70
  }),
71
+ formatResult: (_args, result) => {
72
+ if (result.isError)
73
+ return {};
74
+ const m = result.content.match(/\((\+\d+(?:\s-\d+)?)\)/);
75
+ return m ? { summary: m[1] } : {};
76
+ },
33
77
  async execute(args, onChunk) {
34
78
  const filePath = args.path;
35
79
  const oldText = args.old_text;
36
80
  const newText = args.new_text;
81
+ const replaceAll = args.replace_all ?? false;
37
82
  const absPath = path.resolve(getCwd(), filePath);
38
83
  try {
39
84
  const content = await fs.readFile(absPath, "utf-8");
@@ -42,23 +87,31 @@ export function createEditFileTool(getCwd) {
42
87
  const normalizedOld = oldText.replace(/\r\n/g, "\n");
43
88
  const occurrences = normalized.split(normalizedOld).length - 1;
44
89
  if (occurrences === 0) {
90
+ // Try to find the closest match to help the agent self-correct
91
+ const hint = findClosestMatch(normalized, normalizedOld);
45
92
  return {
46
- content: `Error: old_text not found in ${filePath}`,
93
+ content: `Error: old_text not found in ${filePath}.${hint}`,
47
94
  exitCode: 1,
48
95
  isError: true,
49
96
  };
50
97
  }
51
- if (occurrences > 1) {
98
+ if (occurrences > 1 && !replaceAll) {
52
99
  return {
53
- content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context.`,
100
+ content: `Error: old_text found ${occurrences} times, must be unique. Add more surrounding context or use replace_all=true.`,
54
101
  exitCode: 1,
55
102
  isError: true,
56
103
  };
57
104
  }
58
105
  const normalizedNew = newText.replace(/\r\n/g, "\n");
59
- const newContent = normalized.replace(normalizedOld, normalizedNew);
60
- // Restore original line endings
61
- const useCRLF = content.includes("\r\n");
106
+ const newContent = replaceAll
107
+ ? normalized.split(normalizedOld).join(normalizedNew)
108
+ : normalized.replace(normalizedOld, normalizedNew);
109
+ // Restore original line endings — only convert if the file was
110
+ // predominantly CRLF (>50% of line endings), to avoid corrupting
111
+ // mixed-ending files.
112
+ const crlfCount = (content.match(/\r\n/g) || []).length;
113
+ const lfCount = (content.match(/(?<!\r)\n/g) || []).length;
114
+ const useCRLF = crlfCount > 0 && crlfCount >= lfCount;
62
115
  const finalContent = useCRLF
63
116
  ? newContent.replace(/\n/g, "\r\n")
64
117
  : newContent;
@@ -1,8 +1,12 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
1
3
  import { executeCommand } from "../../executor.js";
2
4
  export function createGlobTool(getCwd) {
3
5
  return {
4
6
  name: "glob",
5
- description: "Find files matching a glob pattern. Returns file paths sorted by modification time.",
7
+ description: "Find files by name pattern. Returns paths sorted by modification time (newest first). " +
8
+ "ALWAYS use this instead of find/ls via bash. " +
9
+ "Use glob to locate files, then read_file or grep to inspect contents.",
6
10
  input_schema: {
7
11
  type: "object",
8
12
  properties: {
@@ -18,8 +22,15 @@ export function createGlobTool(getCwd) {
18
22
  required: ["pattern"],
19
23
  },
20
24
  showOutput: false,
25
+ formatResult: (_args, result) => {
26
+ if (result.isError || result.content === "No files matched.")
27
+ return { summary: "0 files" };
28
+ const lines = result.content.split("\n").filter(l => l && !l.startsWith("["));
29
+ return { summary: `${lines.length} files` };
30
+ },
21
31
  getDisplayInfo: (args) => ({
22
32
  kind: "search",
33
+ icon: "⌕",
23
34
  locations: args.path
24
35
  ? [{ path: args.path }]
25
36
  : [],
@@ -27,9 +38,15 @@ export function createGlobTool(getCwd) {
27
38
  async execute(args) {
28
39
  const pattern = args.pattern;
29
40
  const searchPath = args.path ?? ".";
30
- // Use find + shell glob via bash, or rg --files --glob
41
+ // Use ripgrep for correct glob matching + .gitignore awareness
42
+ const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
43
+ const parts = [
44
+ "rg", "--files",
45
+ "--glob", shellEsc(pattern),
46
+ shellEsc(searchPath),
47
+ ];
31
48
  const { session, done } = executeCommand({
32
- command: `find ${JSON.stringify(searchPath)} -path ${JSON.stringify(pattern)} -type f 2>/dev/null | head -200`,
49
+ command: parts.join(" "),
33
50
  cwd: getCwd(),
34
51
  timeout: 10_000,
35
52
  });
@@ -41,12 +58,27 @@ export function createGlobTool(getCwd) {
41
58
  isError: false,
42
59
  };
43
60
  }
44
- const lines = session.output.trim().split("\n");
45
- const suffix = lines.length >= 200
46
- ? `\n[Results capped at 200 files]`
61
+ const cwd = getCwd();
62
+ const files = session.output.trim().split("\n");
63
+ // Sort by modification time (newest first)
64
+ const withMtime = await Promise.all(files.map(async (f) => {
65
+ try {
66
+ const abs = path.resolve(cwd, f);
67
+ const stat = await fs.stat(abs);
68
+ return { file: f, mtime: stat.mtimeMs };
69
+ }
70
+ catch {
71
+ return { file: f, mtime: 0 };
72
+ }
73
+ }));
74
+ withMtime.sort((a, b) => b.mtime - a.mtime);
75
+ const sorted = withMtime.slice(0, 200).map((e) => e.file);
76
+ const truncated = files.length > 200;
77
+ const suffix = truncated
78
+ ? `\n[Results capped at 200 files, ${files.length - 200} more matched]`
47
79
  : "";
48
80
  return {
49
- content: session.output.trim() + suffix,
81
+ content: sorted.join("\n") + suffix,
50
82
  exitCode: 0,
51
83
  isError: false,
52
84
  };
@@ -2,7 +2,12 @@ import { executeCommand } from "../../executor.js";
2
2
  export function createGrepTool(getCwd) {
3
3
  return {
4
4
  name: "grep",
5
- description: "Search file contents using ripgrep (rg). Returns matching lines with file paths and line numbers.",
5
+ description: "Search file contents using ripgrep. ALWAYS use this instead of running grep/rg via bash. " +
6
+ "Supports three output modes: " +
7
+ "'files_with_matches' (default, returns file paths only — use this to find which files contain a pattern), " +
8
+ "'content' (matching lines with optional context_before/context_after), and " +
9
+ "'count' (match counts per file). " +
10
+ "Use head_limit and offset for pagination.",
6
11
  input_schema: {
7
12
  type: "object",
8
13
  properties: {
@@ -18,12 +23,55 @@ export function createGrepTool(getCwd) {
18
23
  type: "string",
19
24
  description: "Glob pattern for files to include (e.g., '*.ts')",
20
25
  },
26
+ output_mode: {
27
+ type: "string",
28
+ enum: ["files_with_matches", "content", "count"],
29
+ description: "Output mode: 'files_with_matches' (default, file paths only), " +
30
+ "'content' (matching lines), 'count' (match counts per file)",
31
+ },
32
+ case_insensitive: {
33
+ type: "boolean",
34
+ description: "Case insensitive search (default: false)",
35
+ },
36
+ context_before: {
37
+ type: "number",
38
+ description: "Lines to show before each match (content mode only)",
39
+ },
40
+ context_after: {
41
+ type: "number",
42
+ description: "Lines to show after each match (content mode only)",
43
+ },
44
+ head_limit: {
45
+ type: "number",
46
+ description: "Max lines/entries to return (default: 200 for files_with_matches, 150 for content/count). Pass 0 for unlimited.",
47
+ },
48
+ offset: {
49
+ type: "number",
50
+ description: "Skip first N lines/entries before applying head_limit. Use with head_limit for pagination.",
51
+ },
21
52
  },
22
53
  required: ["pattern"],
23
54
  },
24
55
  showOutput: false,
56
+ formatResult: (args, result) => {
57
+ if (result.isError || result.content === "No matches found.")
58
+ return { summary: "0 matches" };
59
+ const lines = result.content.split("\n").filter(Boolean);
60
+ // Strip pagination info line from count
61
+ const resultLines = lines.filter(l => !l.startsWith("[Showing "));
62
+ const mode = args.output_mode ?? "files_with_matches";
63
+ if (mode === "files_with_matches") {
64
+ return { summary: `${resultLines.length} files` };
65
+ }
66
+ if (mode === "count") {
67
+ const total = resultLines.reduce((sum, l) => sum + (parseInt(l.split(":").pop() ?? "0", 10) || 0), 0);
68
+ return { summary: `${total} matches` };
69
+ }
70
+ return { summary: `${resultLines.length} lines` };
71
+ },
25
72
  getDisplayInfo: (args) => ({
26
73
  kind: "search",
74
+ icon: "⌕",
27
75
  locations: args.path
28
76
  ? [{ path: args.path }]
29
77
  : [],
@@ -32,17 +80,42 @@ export function createGrepTool(getCwd) {
32
80
  const pattern = args.pattern;
33
81
  const searchPath = args.path ?? ".";
34
82
  const include = args.include;
35
- const parts = [
36
- "rg",
37
- "--line-number",
38
- "--no-heading",
39
- "--color=never",
40
- "--max-count=200",
41
- ];
83
+ const mode = args.output_mode ?? "files_with_matches";
84
+ const caseInsensitive = args.case_insensitive;
85
+ const contextBefore = args.context_before;
86
+ const contextAfter = args.context_after;
87
+ const headLimit = args.head_limit;
88
+ const offset = args.offset ?? 0;
89
+ const shellEsc = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
90
+ const parts = ["rg", "--color=never"];
91
+ // Mode-specific flags
92
+ if (mode === "files_with_matches") {
93
+ parts.push("--files-with-matches");
94
+ }
95
+ else if (mode === "count") {
96
+ parts.push("--count");
97
+ }
98
+ else {
99
+ // content mode
100
+ parts.push("--line-number", "--no-heading");
101
+ if (contextBefore != null && contextBefore > 0) {
102
+ parts.push(`-B${contextBefore}`);
103
+ }
104
+ if (contextAfter != null && contextAfter > 0) {
105
+ parts.push(`-A${contextAfter}`);
106
+ }
107
+ // If neither -A nor -B specified, use --max-count to limit per-file
108
+ if (contextBefore == null && contextAfter == null) {
109
+ parts.push("--max-count=50");
110
+ }
111
+ }
112
+ if (caseInsensitive) {
113
+ parts.push("-i");
114
+ }
42
115
  if (include) {
43
- parts.push("--glob", JSON.stringify(include));
116
+ parts.push("--glob", shellEsc(include));
44
117
  }
45
- parts.push(JSON.stringify(pattern), JSON.stringify(searchPath));
118
+ parts.push("-e", shellEsc(pattern), shellEsc(searchPath));
46
119
  const { session, done } = executeCommand({
47
120
  command: parts.join(" "),
48
121
  cwd: getCwd(),
@@ -57,18 +130,36 @@ export function createGrepTool(getCwd) {
57
130
  isError: false,
58
131
  };
59
132
  }
60
- // Truncate to ~100 lines
61
- const lines = session.output.split("\n");
62
- if (lines.length > 100) {
63
- return {
64
- content: lines.slice(0, 100).join("\n") +
65
- `\n[${lines.length - 100} more lines truncated]`,
66
- exitCode: 0,
67
- isError: false,
68
- };
133
+ let output = session.output;
134
+ // Cap individual line lengths to 500 chars to prevent minified/base64 flood
135
+ if (mode === "content") {
136
+ const MAX_LINE_LEN = 500;
137
+ output = output
138
+ .split("\n")
139
+ .map((line) => line.length > MAX_LINE_LEN
140
+ ? line.slice(0, MAX_LINE_LEN) + "… [truncated]"
141
+ : line)
142
+ .join("\n");
143
+ }
144
+ // Apply pagination (offset + head_limit)
145
+ const defaultLimit = mode === "files_with_matches" ? 200 : 150;
146
+ const limit = headLimit === 0 ? Infinity : (headLimit ?? defaultLimit);
147
+ const lines = output.split("\n");
148
+ const total = lines.length;
149
+ // Apply offset then limit
150
+ const sliced = lines.slice(offset, offset + limit);
151
+ const paginated = sliced.join("\n");
152
+ const parts2 = [];
153
+ if (paginated)
154
+ parts2.push(paginated);
155
+ // Show pagination info when offset is used or results were truncated
156
+ if (offset > 0 || offset + limit < total) {
157
+ const shown = sliced.length;
158
+ const remaining = Math.max(0, total - offset - shown);
159
+ parts2.push(`\n[Showing ${shown} results (offset=${offset}, limit=${limit === Infinity ? "unlimited" : limit})${remaining > 0 ? `, ${remaining} more available` : ""}]`);
69
160
  }
70
161
  return {
71
- content: session.output || "No matches found.",
162
+ content: parts2.join("\n") || "No matches found.",
72
163
  exitCode: 0,
73
164
  isError: false,
74
165
  };
@@ -1,9 +1,19 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ function formatSize(bytes) {
4
+ if (bytes < 1024)
5
+ return `${bytes}B`;
6
+ if (bytes < 1024 * 1024)
7
+ return `${(bytes / 1024).toFixed(1)}K`;
8
+ if (bytes < 1024 * 1024 * 1024)
9
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
10
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
11
+ }
3
12
  export function createLsTool(getCwd) {
4
13
  return {
5
14
  name: "ls",
6
- description: "List files and directories in a given path.",
15
+ description: "List files and directories with timestamps and sizes. " +
16
+ "Use for exploring a single directory. Use glob for recursive file search by pattern.",
7
17
  input_schema: {
8
18
  type: "object",
9
19
  properties: {
@@ -16,10 +26,17 @@ export function createLsTool(getCwd) {
16
26
  showOutput: false,
17
27
  getDisplayInfo: (args) => ({
18
28
  kind: "read",
29
+ icon: "◆",
19
30
  locations: args.path
20
31
  ? [{ path: args.path }]
21
32
  : [],
22
33
  }),
34
+ formatResult: (_args, result) => {
35
+ if (result.isError || result.content === "(empty directory)")
36
+ return { summary: "0 entries" };
37
+ const lines = result.content.split("\n").filter(Boolean);
38
+ return { summary: `${lines.length} entries` };
39
+ },
23
40
  async execute(args) {
24
41
  const dirPath = args.path ?? ".";
25
42
  const absPath = path.resolve(getCwd(), dirPath);
@@ -27,7 +44,19 @@ export function createLsTool(getCwd) {
27
44
  const entries = await fs.readdir(absPath, {
28
45
  withFileTypes: true,
29
46
  });
30
- const lines = entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name);
47
+ const lines = [];
48
+ for (const e of entries) {
49
+ const fullPath = path.join(absPath, e.name);
50
+ try {
51
+ const stat = await fs.stat(fullPath);
52
+ const size = e.isDirectory() ? "-" : formatSize(stat.size);
53
+ const mtime = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
54
+ lines.push(`${mtime} ${size.padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`);
55
+ }
56
+ catch {
57
+ lines.push(`${"?".padStart(16)} ${"?".padStart(8)} ${e.isDirectory() ? e.name + "/" : e.name}`);
58
+ }
59
+ }
31
60
  return {
32
61
  content: lines.join("\n") || "(empty directory)",
33
62
  exitCode: 0,
@@ -1,2 +1,10 @@
1
1
  import type { ToolDefinition } from "../types.js";
2
- export declare function createReadFileTool(getCwd: () => string): ToolDefinition;
2
+ /** Tracks the last-read state of a file for deduplication. */
3
+ export interface FileReadState {
4
+ mtimeMs: number;
5
+ offset: number;
6
+ limit: number | undefined;
7
+ }
8
+ /** Shared cache — keyed by absolute path. */
9
+ export type FileReadCache = Map<string, FileReadState>;
10
+ export declare function createReadFileTool(getCwd: () => string, cache?: FileReadCache): ToolDefinition;
@@ -1,9 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- export function createReadFileTool(getCwd) {
3
+ export function createReadFileTool(getCwd, cache) {
4
4
  return {
5
5
  name: "read_file",
6
- description: "Read a file's contents with line numbers. Optionally specify offset and limit for large files.",
6
+ description: "Read a file's contents with line numbers. Use offset and limit for large files. " +
7
+ "Always read a file before editing it. " +
8
+ "If the file hasn't changed since last read, returns a stub to save context.",
7
9
  input_schema: {
8
10
  type: "object",
9
11
  properties: {
@@ -25,16 +27,52 @@ export function createReadFileTool(getCwd) {
25
27
  showOutput: false,
26
28
  getDisplayInfo: (args) => ({
27
29
  kind: "read",
30
+ icon: "◆",
28
31
  locations: [{ path: args.path }],
29
32
  }),
33
+ formatResult: (_args, result) => {
34
+ if (result.isError)
35
+ return {};
36
+ if (result.content.startsWith("File unchanged"))
37
+ return { summary: "cached" };
38
+ const lines = result.content.split("\n").filter(l => !l.startsWith("["));
39
+ return { summary: `${lines.length} lines` };
40
+ },
30
41
  async execute(args) {
31
42
  const filePath = args.path;
32
43
  const absPath = path.resolve(getCwd(), filePath);
44
+ const reqOffset = args.offset ?? 1;
45
+ const reqLimit = args.limit;
33
46
  try {
47
+ const stat = await fs.stat(absPath);
48
+ // Deduplication: if the file hasn't changed and same range, return stub
49
+ if (cache) {
50
+ const prev = cache.get(absPath);
51
+ if (prev &&
52
+ prev.mtimeMs === stat.mtimeMs &&
53
+ prev.offset === reqOffset &&
54
+ prev.limit === reqLimit) {
55
+ return {
56
+ content: "File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.",
57
+ exitCode: 0,
58
+ isError: false,
59
+ };
60
+ }
61
+ }
62
+ // Check file size before reading to avoid OOM on huge files
63
+ const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
64
+ if (stat.size > MAX_FILE_SIZE && !args.offset && !args.limit) {
65
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
66
+ return {
67
+ content: `File is ${sizeMB}MB (${stat.size} bytes) — too large to read in full. Use offset and limit to read specific sections, e.g. offset=1 limit=200.`,
68
+ exitCode: 1,
69
+ isError: true,
70
+ };
71
+ }
34
72
  const content = await fs.readFile(absPath, "utf-8");
35
73
  const lines = content.split("\n");
36
- const start = (args.offset ?? 1) - 1; // 1-indexed → 0-indexed
37
- const end = args.limit ? start + args.limit : lines.length;
74
+ const start = reqOffset - 1; // 1-indexed → 0-indexed
75
+ const end = reqLimit ? start + reqLimit : lines.length;
38
76
  const slice = lines.slice(start, end);
39
77
  // Add line numbers (1-indexed)
40
78
  const numbered = slice
@@ -44,6 +82,14 @@ export function createReadFileTool(getCwd) {
44
82
  const suffix = truncated
45
83
  ? `\n[${lines.length - end} more lines, use offset=${end + 1} to continue]`
46
84
  : "";
85
+ // Update cache on successful read
86
+ if (cache) {
87
+ cache.set(absPath, {
88
+ mtimeMs: stat.mtimeMs,
89
+ offset: reqOffset,
90
+ limit: reqLimit,
91
+ });
92
+ }
47
93
  return { content: numbered + suffix, exitCode: 0, isError: false };
48
94
  }
49
95
  catch (err) {
@@ -8,7 +8,7 @@
8
8
  export function createUserShellTool(opts) {
9
9
  return {
10
10
  name: "user_shell",
11
- description: "Run a command in the user's live shell (visible in terminal). Output is returned to you by default. Use for cd, export, source, or commands the user wants to see. Set return_output=false for long-running or interactive commands.",
11
+ description: "Run a command with lasting effects in the user's live shell (cd, export, install packages, start servers, apply changes). Output is shown directly to the user but NOT returned to you by default set return_output=true if you need to inspect the result.",
12
12
  input_schema: {
13
13
  type: "object",
14
14
  properties: {
@@ -16,6 +16,10 @@ export function createUserShellTool(opts) {
16
16
  type: "string",
17
17
  description: "Command to execute in user's shell",
18
18
  },
19
+ timeout: {
20
+ type: "number",
21
+ description: "Timeout in seconds (default: 30)",
22
+ },
19
23
  return_output: {
20
24
  type: "boolean",
21
25
  default: false,
@@ -28,29 +32,52 @@ export function createUserShellTool(opts) {
28
32
  modifiesFiles: true,
29
33
  getDisplayInfo: () => ({
30
34
  kind: "execute",
35
+ icon: "▷",
31
36
  locations: [],
32
37
  }),
33
38
  async execute(args) {
34
39
  const command = args.command;
40
+ const timeoutSec = args.timeout ?? 30;
35
41
  const returnOutput = args.return_output ?? false;
36
- // Execute via the shell-exec extension's async pipe
37
- const result = await opts.bus.emitPipeAsync("shell:exec-request", {
38
- command,
39
- output: "",
40
- cwd: opts.getCwd(),
41
- done: false,
42
- });
42
+ // Execute via the shell-exec extension's async pipe with timeout
43
+ let result;
44
+ try {
45
+ const execPromise = opts.bus.emitPipeAsync("shell:exec-request", {
46
+ command,
47
+ output: "",
48
+ cwd: opts.getCwd(),
49
+ exitCode: null,
50
+ done: false,
51
+ });
52
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutSec * 1000));
53
+ result = await Promise.race([execPromise, timeoutPromise]);
54
+ }
55
+ catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ if (msg === "timeout") {
58
+ return {
59
+ content: `Command timed out after ${timeoutSec}s.`,
60
+ exitCode: -1,
61
+ isError: true,
62
+ };
63
+ }
64
+ return { content: `Error: ${msg}`, exitCode: -1, isError: true };
65
+ }
66
+ const exitCode = result.exitCode ?? 0;
67
+ const isError = exitCode !== 0 && exitCode !== null;
43
68
  if (returnOutput) {
44
69
  return {
45
70
  content: result.output || "(no output)",
46
- exitCode: 0,
47
- isError: false,
71
+ exitCode,
72
+ isError,
48
73
  };
49
74
  }
50
75
  return {
51
- content: "Command executed.",
52
- exitCode: 0,
53
- isError: false,
76
+ content: isError
77
+ ? `Command failed with exit code ${exitCode}.`
78
+ : "Command executed.",
79
+ exitCode,
80
+ isError,
54
81
  };
55
82
  },
56
83
  };
@@ -4,7 +4,8 @@ import { computeDiff } from "../../utils/diff.js";
4
4
  export function createWriteFileTool(getCwd) {
5
5
  return {
6
6
  name: "write_file",
7
- description: "Create or overwrite a file with the given content. Creates parent directories if needed. Prefer edit_file for modifying existing files.",
7
+ description: "Create a new file or completely overwrite an existing one. Creates parent directories if needed. " +
8
+ "ALWAYS prefer edit_file for modifying existing files — only use write_file for new files or complete rewrites.",
8
9
  input_schema: {
9
10
  type: "object",
10
11
  properties: {
@@ -24,8 +25,15 @@ export function createWriteFileTool(getCwd) {
24
25
  requiresPermission: true,
25
26
  getDisplayInfo: (args) => ({
26
27
  kind: "write",
28
+ icon: "✎",
27
29
  locations: [{ path: args.path }],
28
30
  }),
31
+ formatResult: (_args, result) => {
32
+ if (result.isError)
33
+ return {};
34
+ const m = result.content.match(/\((\+\d+(?:\s-\d+)?)\)/);
35
+ return m ? { summary: m[1] } : {};
36
+ },
29
37
  async execute(args, onChunk) {
30
38
  const filePath = args.path;
31
39
  const content = args.content;