s-app-scoder-agent 0.9.3 → 0.9.6
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
package/dist/tools.js
DELETED
|
@@ -1,1223 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, realpathSync, existsSync } from "node:fs";
|
|
2
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { resolve, dirname, basename, join, relative, isAbsolute, sep } from "node:path";
|
|
4
|
-
import { unifiedDiff } from "./diff.js";
|
|
5
|
-
import { detectImageBytes } from "./tui/image.js";
|
|
6
|
-
import { discoverSkills, findSkill, loadSkillSection } from "./skills.js";
|
|
7
|
-
import { redactSecrets } from "./llm.js";
|
|
8
|
-
import { loadRcFile } from "./config.js";
|
|
9
|
-
import { resolveSandbox } from "./sandbox.js";
|
|
10
|
-
import * as ui from "./ui.js";
|
|
11
|
-
import { toolWebFetch } from "./webfetch.js";
|
|
12
|
-
import { buildRepoMap, renderRepoMap } from "./repo-map.js";
|
|
13
|
-
/**
|
|
14
|
-
* G7: max chars tool output — default 20_000, runtime override qua
|
|
15
|
-
* setMaxOutputChars (agent constructor gọi với config.limits).
|
|
16
|
-
* ponytail: module-level mutable thay vì ctx param vào 12 call sites —
|
|
17
|
-
* 1 agent/process đổi 1 lần lúc khởi tạo; đa-config cùng process (server mode)
|
|
18
|
-
* chỉ cần giá trị giống nhau, nâng cấp thành ctx field khi có nhu cầu khác nhau.
|
|
19
|
-
*/
|
|
20
|
-
let MAX_OUTPUT_CHARS = 20_000;
|
|
21
|
-
export function setMaxOutputChars(n) {
|
|
22
|
-
if (Number.isFinite(n) && n > 0)
|
|
23
|
-
MAX_OUTPUT_CHARS = Math.floor(n);
|
|
24
|
-
}
|
|
25
|
-
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
26
|
-
function clamp(s, max = MAX_OUTPUT_CHARS) {
|
|
27
|
-
if (s.length <= max)
|
|
28
|
-
return s;
|
|
29
|
-
return s.slice(0, max) + `\n... [truncated, ${s.length - max} more chars]`;
|
|
30
|
-
}
|
|
31
|
-
/** Resolve a user-supplied path inside workdir; reject escapes and symlink traversal. */
|
|
32
|
-
function safePath(workdir, p) {
|
|
33
|
-
const abs = isAbsolute(p) ? resolve(p) : resolve(workdir, p);
|
|
34
|
-
// Lexical containment first (cheap): reject obvious ../ escapes.
|
|
35
|
-
const relLex = relative(workdir, abs);
|
|
36
|
-
if (relLex.startsWith("..") || isAbsolute(relLex)) {
|
|
37
|
-
throw new Error(`Path escapes working directory: ${p}`);
|
|
38
|
-
}
|
|
39
|
-
// Realpath containment: resolve symlinks so a link inside workdir pointing
|
|
40
|
-
// outside (e.g. evil-link -> /etc) cannot be used to read/write outside.
|
|
41
|
-
// macOS may present /tmp as /private/tmp — resolve workdir the same way.
|
|
42
|
-
let realWorkdir = workdir;
|
|
43
|
-
try {
|
|
44
|
-
realWorkdir = realpathSync(workdir);
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
// workdir must exist; keep lexical fallback for a transient ENOENT,
|
|
48
|
-
// but surface unexpected failures instead of silently swallowing them.
|
|
49
|
-
const code = err.code;
|
|
50
|
-
if (code !== "ENOENT") {
|
|
51
|
-
throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
// Walk up to the nearest existing ancestor, then verify containment.
|
|
55
|
-
let probe = abs;
|
|
56
|
-
const missing = [];
|
|
57
|
-
for (;;) {
|
|
58
|
-
try {
|
|
59
|
-
const real = realpathSync(probe);
|
|
60
|
-
const reconstructed = real + [...missing].reverse().map((s) => `/${s}`).join("");
|
|
61
|
-
const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
|
|
62
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
63
|
-
throw new Error(`Path escapes working directory: ${p}`);
|
|
64
|
-
}
|
|
65
|
-
return abs;
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
if (err.code === "ENOENT" && probe !== dirname(probe)) {
|
|
69
|
-
missing.push(basename(probe));
|
|
70
|
-
probe = dirname(probe);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
// realpath failed for another reason (e.g. escaped) — rethrow escape errors
|
|
74
|
-
throw err;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Tool implementations
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
/** Turn a sync tool step into a ToolResult, converting thrown errors (e.g. path escapes) into structured results. */
|
|
82
|
-
function toResult(fn) {
|
|
83
|
-
try {
|
|
84
|
-
return fn();
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
return { ok: false, output: `ERROR: ${err.message}` };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
// H-04: protected files — read ⇒ ask (khi không allow rule), write luôn gate.
|
|
91
|
-
// `.env.example` và các biến thể template luôn được phép đọc.
|
|
92
|
-
const PROTECTED_READ_PATTERNS = [/^\.env$/, /\/\.env$/, /^\.env\.[^/]+$/, /\/\.env\.[^/]+$/];
|
|
93
|
-
const PROTECTED_READ_EXEMPT = [/^\.env\.example$/, /\/\.env\.example$/, /^\.env\.sample$/, /\/\.env\.sample$/, /^\.env\.template$/, /\/\.env\.template$/];
|
|
94
|
-
export function isProtectedEnvFile(relPath) {
|
|
95
|
-
if (PROTECTED_READ_EXEMPT.some((rx) => rx.test(relPath)))
|
|
96
|
-
return "allow";
|
|
97
|
-
if (PROTECTED_READ_PATTERNS.some((rx) => rx.test(relPath)))
|
|
98
|
-
return "ask";
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
/** Coerce a schema-provided numeric-ish arg to a positive int, else fallback. */
|
|
102
|
-
function toPosInt(v, fallback) {
|
|
103
|
-
const n = typeof v === "number" ? v : Number(v);
|
|
104
|
-
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
|
105
|
-
}
|
|
106
|
-
function toolReadFile(workdir, args) {
|
|
107
|
-
return toResult(() => {
|
|
108
|
-
const abs = safePath(workdir, args.path);
|
|
109
|
-
// H-04: .env read policy — ask trước khi trả nội dung (secrets hygiene).
|
|
110
|
-
const rel = relative(workdir, abs);
|
|
111
|
-
let policyPath = rel;
|
|
112
|
-
try {
|
|
113
|
-
policyPath = relative(workdir, realpathSync(abs));
|
|
114
|
-
}
|
|
115
|
-
catch { /* handled by safePath */ }
|
|
116
|
-
const envPolicy = isProtectedEnvFile(policyPath.toLowerCase());
|
|
117
|
-
if (envPolicy === "ask") {
|
|
118
|
-
return {
|
|
119
|
-
ok: false,
|
|
120
|
-
output: `ASK_PERMISSION: ${rel} may contain secrets (env file). ` +
|
|
121
|
-
`Ask the user to confirm before reading; suggest reading .env.example instead ` +
|
|
122
|
-
`or running with a permission rule ({"action":"shell","resource":"read .env","effect":"allow"}).`,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const st = statSync(abs);
|
|
126
|
-
if (st.isDirectory()) {
|
|
127
|
-
return { ok: false, output: `ERROR: ${args.path} is a directory — use list_files instead.` };
|
|
128
|
-
}
|
|
129
|
-
if (st.size > MAX_FILE_SIZE_BYTES) {
|
|
130
|
-
return {
|
|
131
|
-
ok: false,
|
|
132
|
-
output: `ERROR: ${args.path} is ${(st.size / 1024 / 1024).toFixed(1)}MB (limit ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB). Use run_command with grep/head/tail to inspect specific parts.`,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
// T7: image file → base64 notice cho TUI render (LLM vẫn nhận mô tả text).
|
|
136
|
-
let image;
|
|
137
|
-
try {
|
|
138
|
-
const probe = readFileSync(abs);
|
|
139
|
-
const ext = detectImageBytes(probe);
|
|
140
|
-
if (ext)
|
|
141
|
-
image = `data:image/${ext};base64,${probe.toString("base64")}`;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// not an image / unreadable — fall through to text read
|
|
145
|
-
}
|
|
146
|
-
if (image) {
|
|
147
|
-
return { ok: true, output: `[image: ${args.path}]`, image };
|
|
148
|
-
}
|
|
149
|
-
if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
|
|
150
|
-
return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB edit limit.` };
|
|
151
|
-
}
|
|
152
|
-
const content = readFileSync(abs, "utf8");
|
|
153
|
-
const lines = content.split("\n");
|
|
154
|
-
const total = lines.length;
|
|
155
|
-
// v2: line-range reads — offset (1-based, default 1) + limit (default all).
|
|
156
|
-
const offset = Math.min(toPosInt(args.offset, 1), total);
|
|
157
|
-
const limit = Math.min(toPosInt(args.limit, total - offset + 1), 2000);
|
|
158
|
-
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
159
|
-
const numbered = slice.map((l, i) => `${offset + i}: ${l}`).join("\n");
|
|
160
|
-
const end = offset + slice.length - 1;
|
|
161
|
-
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
162
|
-
return { ok: true, output: clamp(header + numbered) };
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
function toToolDiff(path, diff) {
|
|
166
|
-
if (!diff.text && !diff.truncated)
|
|
167
|
-
return undefined;
|
|
168
|
-
return { path, text: diff.text, added: diff.added, removed: diff.removed, truncated: diff.truncated };
|
|
169
|
-
}
|
|
170
|
-
/** Shared permission/diff gate for file writes. Returns null = approved. */
|
|
171
|
-
async function gateWrite(ctx, path, diff) {
|
|
172
|
-
const mode = ctx.permissionMode ?? "auto";
|
|
173
|
-
if (mode === "readonly") {
|
|
174
|
-
if (diff.text)
|
|
175
|
-
ui.printDiff(diff.text);
|
|
176
|
-
return {
|
|
177
|
-
ok: false,
|
|
178
|
-
output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
|
|
179
|
-
diff: toToolDiff(path, diff),
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
if (mode === "ask" && ctx.permissions) {
|
|
183
|
-
if (diff.text)
|
|
184
|
-
ui.printDiff(diff.text);
|
|
185
|
-
const allowed = await ctx.permissions.confirmEdit(path, diff.text);
|
|
186
|
-
if (!allowed) {
|
|
187
|
-
return {
|
|
188
|
-
ok: false,
|
|
189
|
-
output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.`,
|
|
190
|
-
diff: toToolDiff(path, diff),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
else if (diff.text && !diff.truncated) {
|
|
195
|
-
ui.printDiff(diff.text);
|
|
196
|
-
}
|
|
197
|
-
else if (diff.truncated) {
|
|
198
|
-
ui.diffSummary(path, diff.added, diff.removed);
|
|
199
|
-
}
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
async function toolWriteFile(ctx, args) {
|
|
203
|
-
let abs;
|
|
204
|
-
try {
|
|
205
|
-
abs = safePath(ctx.workdir, args.path);
|
|
206
|
-
}
|
|
207
|
-
catch (err) {
|
|
208
|
-
return { ok: false, output: `ERROR: ${err.message}` };
|
|
209
|
-
}
|
|
210
|
-
let oldContent = null;
|
|
211
|
-
try {
|
|
212
|
-
oldContent = readFileSync(abs, "utf8");
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// new file
|
|
216
|
-
}
|
|
217
|
-
const diff = unifiedDiff(args.path, oldContent, args.content);
|
|
218
|
-
const gate = await gateWrite(ctx, args.path, diff);
|
|
219
|
-
if (gate)
|
|
220
|
-
return gate;
|
|
221
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
222
|
-
writeFileSync(abs, args.content, "utf8");
|
|
223
|
-
const fmtNote = formatFile(ctx, abs);
|
|
224
|
-
return {
|
|
225
|
-
ok: true,
|
|
226
|
-
output: `Wrote ${args.content.split("\n").length} lines to ${args.path}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
227
|
-
diff: toToolDiff(args.path, diff),
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
/** v2: whitespace-insensitive regex for edit_file fuzzy fallback. */
|
|
231
|
-
function wsInsensitiveRegex(s) {
|
|
232
|
-
let rx = "";
|
|
233
|
-
for (const ch of s) {
|
|
234
|
-
if (/\s/.test(ch))
|
|
235
|
-
rx += "\\s+";
|
|
236
|
-
else
|
|
237
|
-
rx += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
238
|
-
}
|
|
239
|
-
return new RegExp(rx);
|
|
240
|
-
}
|
|
241
|
-
/** Apply one exact (or whitespace-tolerant) replacement to in-memory content. */
|
|
242
|
-
function applyOneEdit(content, path, oldString, newString) {
|
|
243
|
-
if (typeof oldString !== "string" || oldString.length === 0) {
|
|
244
|
-
return { ok: false, error: "old_string must be a non-empty string." };
|
|
245
|
-
}
|
|
246
|
-
if (content.includes(oldString)) {
|
|
247
|
-
const count = content.split(oldString).length - 1;
|
|
248
|
-
if (count > 1) {
|
|
249
|
-
return { ok: false, error: `old_string matches ${count} locations in ${path}; include more surrounding context to make it unique.` };
|
|
250
|
-
}
|
|
251
|
-
// Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
|
|
252
|
-
// which would corrupt literal text like "$&" in the new content.
|
|
253
|
-
return { ok: true, content: content.replace(oldString, () => newString), fuzzy: false };
|
|
254
|
-
}
|
|
255
|
-
// v2 fuzzy fallback: exact string missing → retry with whitespace-tolerant
|
|
256
|
-
// matching (indent/trailing-space drift between model and file). Reports
|
|
257
|
-
// ambiguity instead of guessing when multiple regions match.
|
|
258
|
-
const all = [...content.matchAll(new RegExp(wsInsensitiveRegex(oldString).source, "g"))];
|
|
259
|
-
if (all.length === 0) {
|
|
260
|
-
return { ok: false, error: `old_string not found in ${path}. Read the file again and retry with exact text.` };
|
|
261
|
-
}
|
|
262
|
-
if (all.length > 1) {
|
|
263
|
-
return { ok: false, error: `old_string not found exactly and ${all.length} whitespace-tolerant matches exist in ${path}; include more surrounding context to disambiguate.` };
|
|
264
|
-
}
|
|
265
|
-
return {
|
|
266
|
-
ok: true,
|
|
267
|
-
content: content.slice(0, all[0].index) + newString + content.slice(all[0].index + all[0][0].length),
|
|
268
|
-
fuzzy: true,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
async function toolEditFile(ctx, args) {
|
|
272
|
-
let abs;
|
|
273
|
-
try {
|
|
274
|
-
abs = safePath(ctx.workdir, args.path);
|
|
275
|
-
}
|
|
276
|
-
catch (err) {
|
|
277
|
-
return { ok: false, output: `ERROR: ${err.message}` };
|
|
278
|
-
}
|
|
279
|
-
const content = readFileSync(abs, "utf8");
|
|
280
|
-
const outcome = applyOneEdit(content, args.path, args.old_string, args.new_string);
|
|
281
|
-
if (!outcome.ok)
|
|
282
|
-
return { ok: false, output: outcome.error };
|
|
283
|
-
const newContent = outcome.content;
|
|
284
|
-
const diff = unifiedDiff(args.path, content, newContent);
|
|
285
|
-
const gate = await gateWrite(ctx, args.path, diff);
|
|
286
|
-
if (gate)
|
|
287
|
-
return gate;
|
|
288
|
-
writeFileSync(abs, newContent, "utf8");
|
|
289
|
-
const fmtNote = formatFile(ctx, abs);
|
|
290
|
-
return {
|
|
291
|
-
ok: true,
|
|
292
|
-
output: `Edited ${args.path}${outcome.fuzzy ? " (fuzzy whitespace match)" : ""}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
293
|
-
diff: toToolDiff(args.path, diff),
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
async function toolApplyPatch(ctx, args) {
|
|
297
|
-
let abs;
|
|
298
|
-
try {
|
|
299
|
-
abs = safePath(ctx.workdir, args.path);
|
|
300
|
-
}
|
|
301
|
-
catch (err) {
|
|
302
|
-
return { ok: false, output: `ERROR: ${err.message}` };
|
|
303
|
-
}
|
|
304
|
-
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
305
|
-
return { ok: false, output: "ERROR: edits must be a non-empty array." };
|
|
306
|
-
}
|
|
307
|
-
if (args.edits.length > 50) {
|
|
308
|
-
return { ok: false, output: `ERROR: too many edits (${args.edits.length}); split into chunks of at most 50.` };
|
|
309
|
-
}
|
|
310
|
-
let content;
|
|
311
|
-
try {
|
|
312
|
-
if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
|
|
313
|
-
return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB patch limit.` };
|
|
314
|
-
}
|
|
315
|
-
content = readFileSync(abs, "utf8");
|
|
316
|
-
}
|
|
317
|
-
catch (err) {
|
|
318
|
-
return { ok: false, output: `ERROR: cannot read ${args.path}: ${err.message}` };
|
|
319
|
-
}
|
|
320
|
-
const original = content;
|
|
321
|
-
let fuzzy = 0;
|
|
322
|
-
for (let i = 0; i < args.edits.length; i++) {
|
|
323
|
-
const e = args.edits[i];
|
|
324
|
-
if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
|
|
325
|
-
return { ok: false, output: `ERROR: edit #${i + 1} must have string old_string and new_string. No changes written.` };
|
|
326
|
-
}
|
|
327
|
-
const outcome = applyOneEdit(content, args.path, e.old_string, e.new_string);
|
|
328
|
-
if (!outcome.ok) {
|
|
329
|
-
return {
|
|
330
|
-
ok: false,
|
|
331
|
-
output: `ERROR: edit #${i + 1}/${args.edits.length} failed — ${outcome.error} No changes written.`,
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
content = outcome.content;
|
|
335
|
-
if (outcome.fuzzy)
|
|
336
|
-
fuzzy++;
|
|
337
|
-
}
|
|
338
|
-
if (content === original) {
|
|
339
|
-
return { ok: true, output: `No changes to ${args.path} (all edits were no-ops).` };
|
|
340
|
-
}
|
|
341
|
-
const diff = unifiedDiff(args.path, original, content);
|
|
342
|
-
const gate = await gateWrite(ctx, args.path, diff);
|
|
343
|
-
if (gate)
|
|
344
|
-
return gate;
|
|
345
|
-
writeFileSync(abs, content, "utf8");
|
|
346
|
-
const fmtNote = formatFile(ctx, abs);
|
|
347
|
-
const fuzzyNote = fuzzy > 0 ? ` (${fuzzy} fuzzy whitespace match${fuzzy === 1 ? "" : "es"})` : "";
|
|
348
|
-
return {
|
|
349
|
-
ok: true,
|
|
350
|
-
output: `Applied ${args.edits.length} edit${args.edits.length === 1 ? "" : "s"} to ${args.path}${fuzzyNote}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
351
|
-
diff: toToolDiff(args.path, diff),
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
function toolListFiles(workdir, args) {
|
|
355
|
-
return toResult(() => {
|
|
356
|
-
const root = safePath(workdir, args.path ?? ".");
|
|
357
|
-
const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
358
|
-
// v2: configurable depth (default 4, cap 8), hidden-dotfiles opt-in, exclude globs.
|
|
359
|
-
const maxDepth = Math.min(toPosInt(args.depth, 4), 8);
|
|
360
|
-
const includeHidden = args.include_hidden === true || args.include_hidden === "true";
|
|
361
|
-
const excludeRx = args.exclude
|
|
362
|
-
? new RegExp(args.exclude.split(/[,\s]+/).filter(Boolean)
|
|
363
|
-
.map((g) => globToRegExp(g).source)
|
|
364
|
-
.join("|"))
|
|
365
|
-
: null;
|
|
366
|
-
const out = [];
|
|
367
|
-
const walk = (dir, depth) => {
|
|
368
|
-
if (depth > maxDepth || out.length > 500)
|
|
369
|
-
return;
|
|
370
|
-
let entries;
|
|
371
|
-
try {
|
|
372
|
-
entries = readdirSync(dir).sort();
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
for (const e of entries) {
|
|
378
|
-
if (skip.has(e))
|
|
379
|
-
continue;
|
|
380
|
-
if (!includeHidden && e.startsWith(".") && e !== ".github")
|
|
381
|
-
continue;
|
|
382
|
-
if (excludeRx?.test(e))
|
|
383
|
-
continue;
|
|
384
|
-
const full = join(dir, e);
|
|
385
|
-
let st;
|
|
386
|
-
try {
|
|
387
|
-
safePath(workdir, relative(workdir, full));
|
|
388
|
-
st = statSync(full);
|
|
389
|
-
}
|
|
390
|
-
catch {
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
const rel = relative(workdir, full).split(sep).join("/");
|
|
394
|
-
if (st.isDirectory()) {
|
|
395
|
-
out.push(`${rel}/`);
|
|
396
|
-
walk(full, depth + 1);
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
out.push(`${rel} (${st.size}b)`);
|
|
400
|
-
}
|
|
401
|
-
if (out.length > 500) {
|
|
402
|
-
out.push("... [truncated]");
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
};
|
|
407
|
-
walk(root, 0);
|
|
408
|
-
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(empty directory)" };
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
// ---------------------------------------------------------------------------
|
|
412
|
-
// grep / glob (I-07)
|
|
413
|
-
// ---------------------------------------------------------------------------
|
|
414
|
-
const SEARCH_SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
415
|
-
const MAX_SEARCH_HITS = 50;
|
|
416
|
-
/** Walk files under root (bounded depth, skip noise dirs), collect relative paths. */
|
|
417
|
-
function walkFiles(root, cb, depth = 8) {
|
|
418
|
-
if (depth <= 0)
|
|
419
|
-
return;
|
|
420
|
-
let entries;
|
|
421
|
-
try {
|
|
422
|
-
entries = readdirSync(root, { withFileTypes: true });
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
for (const e of entries) {
|
|
428
|
-
if (SEARCH_SKIP.has(e.name))
|
|
429
|
-
continue;
|
|
430
|
-
const abs = join(root, e.name);
|
|
431
|
-
if (e.isDirectory())
|
|
432
|
-
walkFiles(abs, cb, depth - 1);
|
|
433
|
-
else if (e.isFile())
|
|
434
|
-
cb(e.name, abs);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
/** ripgrep if on PATH; else grep -rn; else JS line-scan fallback. Returns formatted hits. */
|
|
438
|
-
export function toolGrep(workdir, args) {
|
|
439
|
-
return toResult(() => {
|
|
440
|
-
const pattern = String(args.pattern ?? "").trim();
|
|
441
|
-
if (!pattern)
|
|
442
|
-
return { ok: false, output: "ERROR: pattern is required." };
|
|
443
|
-
const root = safePath(workdir, args.path ?? ".");
|
|
444
|
-
// v2: context lines + hit cap — before/after look like ripgrep -B/-A.
|
|
445
|
-
const ctxBefore = Math.min(toPosInt(args.context_before, 0), 20);
|
|
446
|
-
const ctxAfter = Math.min(toPosInt(args.context_after, 0), 20);
|
|
447
|
-
const maxResults = Math.min(toPosInt(args.max_results, MAX_SEARCH_HITS), 100);
|
|
448
|
-
// Fast path: ripgrep (respects .gitignore, fast, JSON output). Skip
|
|
449
|
-
// common noise dirs even when the project has no .gitignore.
|
|
450
|
-
const rgArgs = [
|
|
451
|
-
"--json", "--max-count", "5", "-e", pattern, root,
|
|
452
|
-
// rg globs match the printed (absolute) path — bare dir name excludes it
|
|
453
|
-
// anywhere in the tree; ** forms do NOT match absolute-path matches.
|
|
454
|
-
"--glob", "!node_modules", "--glob", "!.git", "--glob", "!dist",
|
|
455
|
-
"--glob", "!build", "--glob", "!.next", "--glob", "!__pycache__",
|
|
456
|
-
"--glob", "!.venv",
|
|
457
|
-
];
|
|
458
|
-
if (ctxBefore > 0)
|
|
459
|
-
rgArgs.push("-B", String(ctxBefore));
|
|
460
|
-
if (ctxAfter > 0)
|
|
461
|
-
rgArgs.push("-A", String(ctxAfter));
|
|
462
|
-
if (args.include)
|
|
463
|
-
rgArgs.push("-g", args.include);
|
|
464
|
-
if (args.type)
|
|
465
|
-
rgArgs.push("--type", String(args.type));
|
|
466
|
-
const rg = spawnSync("rg", rgArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
467
|
-
const lines = [];
|
|
468
|
-
let count = 0;
|
|
469
|
-
if ((rg.status ?? 2) === 0 || rg.stdout) {
|
|
470
|
-
let prevLine = -1;
|
|
471
|
-
let prevRel = "";
|
|
472
|
-
for (const line of rg.stdout.split("\n")) {
|
|
473
|
-
if (!line)
|
|
474
|
-
continue;
|
|
475
|
-
if (count >= maxResults)
|
|
476
|
-
break;
|
|
477
|
-
try {
|
|
478
|
-
const j = JSON.parse(line);
|
|
479
|
-
if (!j.data || (j.type !== "match" && j.type !== "context"))
|
|
480
|
-
continue;
|
|
481
|
-
const rel = relative(workdir, j.data.path?.text ?? "").split(sep).join("/");
|
|
482
|
-
const ln = j.data.line_number ?? 0;
|
|
483
|
-
const text = (j.data.lines?.text ?? "").trimEnd().slice(0, 200);
|
|
484
|
-
// GNU-style: match lines keep ':' separators, context lines use '-'.
|
|
485
|
-
if (rel !== prevRel || (prevLine >= 0 && ln > prevLine + 1)) {
|
|
486
|
-
if (lines.length > 0)
|
|
487
|
-
lines.push("--");
|
|
488
|
-
}
|
|
489
|
-
lines.push(`${rel}${j.type === "match" ? ":" : "-"}${ln}${j.type === "match" ? ":" : "-"} ${text}`);
|
|
490
|
-
if (j.type === "match")
|
|
491
|
-
count++;
|
|
492
|
-
prevLine = ln;
|
|
493
|
-
prevRel = rel;
|
|
494
|
-
}
|
|
495
|
-
catch { /* skip malformed */ }
|
|
496
|
-
}
|
|
497
|
-
if ((rg.status ?? 2) <= 1) {
|
|
498
|
-
return { ok: true, output: lines.length > 0 ? clamp(lines.join("\n")) : "(no matches)" };
|
|
499
|
-
}
|
|
500
|
-
// status >= 2 → rg error; fall through to grep fallback.
|
|
501
|
-
}
|
|
502
|
-
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
503
|
-
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
504
|
-
if (ctxBefore > 0)
|
|
505
|
-
gArgs.push("-B", String(ctxBefore));
|
|
506
|
-
if (ctxAfter > 0)
|
|
507
|
-
gArgs.push("-A", String(ctxAfter));
|
|
508
|
-
gArgs.push(root);
|
|
509
|
-
const g = spawnSync("grep", gArgs, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
|
510
|
-
if ((g.status ?? 2) <= 1) {
|
|
511
|
-
const hits = (g.stdout ?? "").split("\n").filter(Boolean);
|
|
512
|
-
const out = [];
|
|
513
|
-
let matches = 0;
|
|
514
|
-
for (const l of hits) {
|
|
515
|
-
// Context lines GNU-separated with '-'; matches with ':'.
|
|
516
|
-
const isMatch = /^[^:]*:\d+:/.test(l);
|
|
517
|
-
if (isMatch && matches >= maxResults)
|
|
518
|
-
continue;
|
|
519
|
-
if (isMatch)
|
|
520
|
-
matches++;
|
|
521
|
-
out.push(l.startsWith(root) ? relative(workdir, root).split(sep).join("/") + l.slice(root.length) : l);
|
|
522
|
-
}
|
|
523
|
-
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no matches)" };
|
|
524
|
-
}
|
|
525
|
-
// rg vắng mặt + grep vắng mặt/lỗi → scan JS thuần (luôn chạy được, không cần binary).
|
|
526
|
-
return jsGrepFallback(workdir, root, pattern, args.include, ctxBefore, ctxAfter, maxResults);
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Fallback cuối: scan dòng thuần JS (không cần rg/grep). Kém tối ưu hơn rg
|
|
531
|
-
* (không tôn trọng .gitignore ngoài SEARCH_SKIP, không lazy) nhưng luôn chạy
|
|
532
|
-
* — quan trọng trên Windows máy chưa cài ripgrep. Pattern là regex JS; regex
|
|
533
|
-
* không hợp lệ → coi như literal substring.
|
|
534
|
-
*/
|
|
535
|
-
function jsGrepFallback(workdir, root, pattern, include, ctxBefore = 0, ctxAfter = 0, maxResults = MAX_SEARCH_HITS) {
|
|
536
|
-
let rx;
|
|
537
|
-
try {
|
|
538
|
-
rx = new RegExp(pattern);
|
|
539
|
-
}
|
|
540
|
-
catch {
|
|
541
|
-
rx = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
542
|
-
}
|
|
543
|
-
const includeRx = include
|
|
544
|
-
? globToRegExp(include.includes("/") ? include : `**/${include}`)
|
|
545
|
-
: null;
|
|
546
|
-
const hits = [];
|
|
547
|
-
let matches = 0;
|
|
548
|
-
walkFiles(root, (_name, abs) => {
|
|
549
|
-
if (matches >= maxResults)
|
|
550
|
-
return;
|
|
551
|
-
const rel = relative(workdir, abs).split(sep).join("/");
|
|
552
|
-
if (includeRx && !includeRx.test(rel))
|
|
553
|
-
return;
|
|
554
|
-
try {
|
|
555
|
-
const st = statSync(abs);
|
|
556
|
-
if (st.size > 2 * 1024 * 1024)
|
|
557
|
-
return; // skip file quá lớn
|
|
558
|
-
const buf = readFileSync(abs);
|
|
559
|
-
if (buf.includes(0))
|
|
560
|
-
return; // skip binary (NUL byte)
|
|
561
|
-
const lines = buf.toString("utf8").split("\n");
|
|
562
|
-
// Collect matching line numbers, then emit merged windows with context.
|
|
563
|
-
const matchIdx = [];
|
|
564
|
-
for (let i = 0; i < lines.length && matchIdx.length < 5; i++) {
|
|
565
|
-
if (rx.test(lines[i]))
|
|
566
|
-
matchIdx.push(i);
|
|
567
|
-
}
|
|
568
|
-
if (matchIdx.length === 0)
|
|
569
|
-
return;
|
|
570
|
-
// Merge overlapping windows.
|
|
571
|
-
const windows = [];
|
|
572
|
-
for (const i of matchIdx) {
|
|
573
|
-
const lo = Math.max(0, i - ctxBefore), hi = Math.min(lines.length - 1, i + ctxAfter);
|
|
574
|
-
const last = windows[windows.length - 1];
|
|
575
|
-
if (last && lo <= last[1] + 1)
|
|
576
|
-
last[1] = Math.max(last[1], hi);
|
|
577
|
-
else
|
|
578
|
-
windows.push([lo, hi]);
|
|
579
|
-
}
|
|
580
|
-
for (const [lo, hi] of windows) {
|
|
581
|
-
if (hits.length > 0 && hits[hits.length - 1] !== "--")
|
|
582
|
-
hits.push("--");
|
|
583
|
-
for (let i = lo; i <= hi; i++) {
|
|
584
|
-
const isMatch = matchIdx.includes(i);
|
|
585
|
-
if (isMatch && matches >= maxResults)
|
|
586
|
-
break;
|
|
587
|
-
hits.push(`${rel}${isMatch ? ":" : "-"}${i + 1}${isMatch ? ":" : "-"} ${lines[i].trimEnd().slice(0, 200)}`);
|
|
588
|
-
if (isMatch)
|
|
589
|
-
matches++;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
catch { /* unreadable — skip */ }
|
|
594
|
-
});
|
|
595
|
-
return { ok: true, output: hits.length > 0 ? clamp(hits.join("\n")) : "(no matches)" };
|
|
596
|
-
}
|
|
597
|
-
/** Mini glob: supports **, *, ?, {a,b}. Implemented over walkFiles (no deps). */
|
|
598
|
-
export function globToRegExp(pattern) {
|
|
599
|
-
let rx = "";
|
|
600
|
-
for (let i = 0; i < pattern.length; i++) {
|
|
601
|
-
const c = pattern[i];
|
|
602
|
-
if (c === "*") {
|
|
603
|
-
if (pattern[i + 1] === "*") {
|
|
604
|
-
rx += ".*";
|
|
605
|
-
i++;
|
|
606
|
-
if (pattern[i + 1] === "/")
|
|
607
|
-
i++; // `**/` also matches zero dirs
|
|
608
|
-
}
|
|
609
|
-
else
|
|
610
|
-
rx += "[^/]*";
|
|
611
|
-
}
|
|
612
|
-
else if (c === "?")
|
|
613
|
-
rx += "[^/]";
|
|
614
|
-
else if (c === "{") {
|
|
615
|
-
// {a,b} → (a|b)
|
|
616
|
-
const end = pattern.indexOf("}", i);
|
|
617
|
-
const inner = pattern.slice(i + 1, end).replace(/,/g, "|");
|
|
618
|
-
rx += `(${inner})`;
|
|
619
|
-
i = end;
|
|
620
|
-
}
|
|
621
|
-
else
|
|
622
|
-
rx += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
623
|
-
}
|
|
624
|
-
return new RegExp(`^${rx}$`);
|
|
625
|
-
}
|
|
626
|
-
export function toolGlob(workdir, args) {
|
|
627
|
-
return toResult(() => {
|
|
628
|
-
const pattern = String(args.pattern ?? "").trim();
|
|
629
|
-
if (!pattern)
|
|
630
|
-
return { ok: false, output: "ERROR: pattern is required (e.g. 'src/**/*.ts')." };
|
|
631
|
-
const root = safePath(workdir, args.path ?? ".");
|
|
632
|
-
const rx = globToRegExp(pattern);
|
|
633
|
-
const out = [];
|
|
634
|
-
walkFiles(root, (_name, abs) => {
|
|
635
|
-
// Pattern dùng '/' — normalize separator của OS (Windows trả '\') để khớp.
|
|
636
|
-
const rel = relative(workdir, abs).split(sep).join("/");
|
|
637
|
-
if (rx.test(rel) && out.length < MAX_SEARCH_HITS * 4)
|
|
638
|
-
out.push(rel);
|
|
639
|
-
});
|
|
640
|
-
out.sort();
|
|
641
|
-
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(no files match)" };
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
// ---------------------------------------------------------------------------
|
|
645
|
-
// Formatter post-edit (I-11)
|
|
646
|
-
// ---------------------------------------------------------------------------
|
|
647
|
-
const FORMATTER_TIMEOUT_MS = 30_000;
|
|
648
|
-
/**
|
|
649
|
-
* Run the project formatter on a just-written file. Detection: `formatter`
|
|
650
|
-
* config in ToolContext (true → auto-detect prettier/biome/denofmt from
|
|
651
|
-
* node_modules; string → explicit binary/script). Fire-and-observe: errors
|
|
652
|
-
* are appended to the tool output (LLM self-corrects), never thrown.
|
|
653
|
-
*/
|
|
654
|
-
function formatFile(ctx, abs) {
|
|
655
|
-
const fmt = ctx.formatter;
|
|
656
|
-
if (!fmt)
|
|
657
|
-
return null;
|
|
658
|
-
let cmd;
|
|
659
|
-
if (typeof fmt === "string" && fmt !== "auto") {
|
|
660
|
-
cmd = fmt.startsWith("npm:")
|
|
661
|
-
? ["npm", "run", fmt.slice(4), "--", abs]
|
|
662
|
-
: [fmt, "--write", abs];
|
|
663
|
-
}
|
|
664
|
-
else {
|
|
665
|
-
// auto-detect (formatter: true) — trên Windows, shim trong node_modules/.bin
|
|
666
|
-
// là file .cmd (file không extension chỉ chạy được trên POSIX).
|
|
667
|
-
const localBin = (name) => {
|
|
668
|
-
const base = join(ctx.workdir, "node_modules", ".bin", name);
|
|
669
|
-
if (process.platform === "win32" && existsSync(`${base}.cmd`))
|
|
670
|
-
return `${base}.cmd`;
|
|
671
|
-
if (existsSync(base))
|
|
672
|
-
return base;
|
|
673
|
-
return null;
|
|
674
|
-
};
|
|
675
|
-
const prettier = localBin("prettier");
|
|
676
|
-
const biome = localBin("biome");
|
|
677
|
-
const eslint = localBin("eslint");
|
|
678
|
-
if (prettier)
|
|
679
|
-
cmd = [prettier, "--write", abs];
|
|
680
|
-
else if (biome)
|
|
681
|
-
cmd = [biome, "format", "--write", abs];
|
|
682
|
-
else if (eslint)
|
|
683
|
-
cmd = [eslint, "--fix", abs];
|
|
684
|
-
else
|
|
685
|
-
return null;
|
|
686
|
-
}
|
|
687
|
-
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
688
|
-
cwd: ctx.workdir,
|
|
689
|
-
encoding: "utf8",
|
|
690
|
-
timeout: FORMATTER_TIMEOUT_MS,
|
|
691
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
692
|
-
// Windows: .cmd shim (npm.cmd, prettier.cmd) chỉ chạy được qua shell.
|
|
693
|
-
shell: process.platform === "win32",
|
|
694
|
-
});
|
|
695
|
-
if (r.error)
|
|
696
|
-
return `formatter notice: ${r.error.message.split("\n")[0]}`;
|
|
697
|
-
if (r.status !== 0) {
|
|
698
|
-
return `formatter notice: ${cmd[0]} exited ${r.status}\n${(r.stderr ?? r.stdout ?? "").slice(0, 500)}`;
|
|
699
|
-
}
|
|
700
|
-
return null;
|
|
701
|
-
}
|
|
702
|
-
// ── G3: child env allowlist ─────────────────────────────────────────────────
|
|
703
|
-
// spawn /bin/sh -c with the FULL process.env leaks every secret in the shell's
|
|
704
|
-
// environment (API keys, tokens) to any command the model runs — and thus to
|
|
705
|
-
// the conversation/log via `env` or a misbehaving tool. Allowlist instead.
|
|
706
|
-
/** Always passed through — minimum for node/python/git builds to work. */
|
|
707
|
-
const BASE_ENV_KEYS = [
|
|
708
|
-
"PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "SHELL", "USER", "LOGNAME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
|
709
|
-
// Windows essentiels — cmd.exe/npm.cmd cần SystemRoot/ComSpec/PATHEXT để chạy;
|
|
710
|
-
// Node trên Windows tra env key không phân biệt hoa thường nên viết hoa cũng khớp.
|
|
711
|
-
"SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "WINDIR", "TEMP", "TMP",
|
|
712
|
-
"USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
|
|
713
|
-
"PROGRAMFILES", "PROGRAMFILES(X86)", "PUBLIC", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
|
|
714
|
-
];
|
|
715
|
-
/**
|
|
716
|
-
* G3: build the child env. Base keys + SCODER_* config + explicit extras from
|
|
717
|
-
* the rc (`shellEnvAllow`), resolved from process.env. Missing keys are absent
|
|
718
|
-
* (not empty) so tools detect them correctly.
|
|
719
|
-
*/
|
|
720
|
-
export function buildChildEnv(workdir, extraKeys) {
|
|
721
|
-
const rc = loadRcFile(workdir);
|
|
722
|
-
const allow = [...BASE_ENV_KEYS, ...(rc.shellEnvAllow ?? []), ...(extraKeys ?? [])];
|
|
723
|
-
const out = {};
|
|
724
|
-
for (const k of allow) {
|
|
725
|
-
const v = process.env[k];
|
|
726
|
-
if (v !== undefined)
|
|
727
|
-
out[k] = v;
|
|
728
|
-
}
|
|
729
|
-
// Windows + Git Bash: không có HOME — git/bash đọc config từ ~ (.gitconfig,
|
|
730
|
-
// ssh keys). Map HOME → USERPROFILE để git hoạt động đúng user hiện tại.
|
|
731
|
-
if (process.platform === "win32" && out.HOME === undefined && out.USERPROFILE) {
|
|
732
|
-
out.HOME = out.USERPROFILE;
|
|
733
|
-
}
|
|
734
|
-
return out;
|
|
735
|
-
}
|
|
736
|
-
/**
|
|
737
|
-
* Shell mặc định theo platform:
|
|
738
|
-
* - Unix → /bin/sh -c.
|
|
739
|
-
* - Windows → Git Bash nếu tìm thấy (model hay emit cú pháp POSIX: pipes, grep,
|
|
740
|
-
* rm -rf — cmd.exe không chạy được), fallback cmd.exe /d /c.
|
|
741
|
-
* KHÔNG dùng `where bash` — System32\bash.exe là WSL (sai filesystem).
|
|
742
|
-
*/
|
|
743
|
-
export function defaultShellArgv(command) {
|
|
744
|
-
if (process.platform === "win32") {
|
|
745
|
-
const bash = findGitBash();
|
|
746
|
-
if (bash)
|
|
747
|
-
return [bash, "-c", command];
|
|
748
|
-
return [process.env.ComSpec || process.env.COMSPEC || "cmd.exe", "/d", "/c", command];
|
|
749
|
-
}
|
|
750
|
-
return ["/bin/sh", "-c", command];
|
|
751
|
-
}
|
|
752
|
-
/** Locate Git for Windows' bash.exe (cached); null when not installed. */
|
|
753
|
-
let gitBashCache;
|
|
754
|
-
function findGitBash() {
|
|
755
|
-
if (gitBashCache !== undefined)
|
|
756
|
-
return gitBashCache;
|
|
757
|
-
const pf = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
758
|
-
const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
759
|
-
const local = process.env.LOCALAPPDATA;
|
|
760
|
-
const candidates = [
|
|
761
|
-
join(pf, "Git", "bin", "bash.exe"),
|
|
762
|
-
join(pf86, "Git", "bin", "bash.exe"),
|
|
763
|
-
...(local ? [join(local, "Programs", "Git", "bin", "bash.exe")] : []),
|
|
764
|
-
];
|
|
765
|
-
for (const c of candidates) {
|
|
766
|
-
if (existsSync(c)) {
|
|
767
|
-
gitBashCache = c;
|
|
768
|
-
return c;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
gitBashCache = null;
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
/**
|
|
775
|
-
* Run a shell command asynchronously (async spawn keeps the event loop free,
|
|
776
|
-
* so streaming output renders and SIGINT is handled promptly).
|
|
777
|
-
* Kills the child on timeout or external abort (SIGINT via ctx.signal).
|
|
778
|
-
* When `onOutput` is provided, stdout/stderr chunks are forwarded live as they
|
|
779
|
-
* arrive (in addition to being collected into the final ToolResult).
|
|
780
|
-
* G3: output is redacted (secret shapes) before it enters the conversation,
|
|
781
|
-
* the live stream, or the JSONL log.
|
|
782
|
-
*/
|
|
783
|
-
export async function toolRunCommand(workdir, args, signal, onOutput, sandbox) {
|
|
784
|
-
const timeoutMs = Math.min(Math.max(args.timeout_secs ?? 120, 1), 600) * 1000;
|
|
785
|
-
// G3: the echoed command line itself may contain a secret (e.g. `curl -H
|
|
786
|
-
// "Authorization: Bearer sk-..."`) — redact the echo, never run redacted
|
|
787
|
-
// (the child gets the raw command).
|
|
788
|
-
const echoLine = `$ ${redactSecrets(args.command)}`;
|
|
789
|
-
// G4: sandbox wrap khi config bật. Wrapper thiếu → warn + chạy trực tiếp.
|
|
790
|
-
const sandboxed = await resolveSandbox(sandbox ?? {}, workdir, args.command);
|
|
791
|
-
if (sandboxed.argv) {
|
|
792
|
-
ui.info(`sandbox: ${sandboxed.wrapper} (${(sandbox ?? {}).mode})`);
|
|
793
|
-
}
|
|
794
|
-
else if ((sandbox ?? {}).mode && (sandbox ?? {}).mode !== "off") {
|
|
795
|
-
ui.warn(`sandbox: ${sandboxed.reason}`);
|
|
796
|
-
}
|
|
797
|
-
const spawnArgv = sandboxed.argv ?? defaultShellArgv(args.command);
|
|
798
|
-
return new Promise((resolve) => {
|
|
799
|
-
let settled = false;
|
|
800
|
-
const finish = (result) => {
|
|
801
|
-
if (settled)
|
|
802
|
-
return;
|
|
803
|
-
settled = true;
|
|
804
|
-
clearTimeout(timer);
|
|
805
|
-
signal?.removeEventListener("abort", onAbort);
|
|
806
|
-
resolve(result);
|
|
807
|
-
};
|
|
808
|
-
const child = spawn(spawnArgv[0], spawnArgv.slice(1), {
|
|
809
|
-
cwd: workdir,
|
|
810
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
811
|
-
env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
|
|
812
|
-
});
|
|
813
|
-
let stdout = "";
|
|
814
|
-
let stderr = "";
|
|
815
|
-
let killedReason = null;
|
|
816
|
-
const killChild = (reason) => {
|
|
817
|
-
killedReason = reason;
|
|
818
|
-
try {
|
|
819
|
-
child.kill("SIGTERM");
|
|
820
|
-
}
|
|
821
|
-
catch { /* already dead */ }
|
|
822
|
-
// SIGKILL fallback if the process ignores SIGTERM
|
|
823
|
-
setTimeout(() => {
|
|
824
|
-
try {
|
|
825
|
-
if (child.exitCode === null)
|
|
826
|
-
child.kill("SIGKILL");
|
|
827
|
-
}
|
|
828
|
-
catch { /* already dead */ }
|
|
829
|
-
}, 5_000).unref();
|
|
830
|
-
};
|
|
831
|
-
const timer = setTimeout(() => killChild("timeout"), timeoutMs);
|
|
832
|
-
const onAbort = () => killChild("aborted");
|
|
833
|
-
if (signal?.aborted)
|
|
834
|
-
onAbort();
|
|
835
|
-
else
|
|
836
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
837
|
-
child.stdout?.on("data", (c) => {
|
|
838
|
-
const s = c.toString("utf8");
|
|
839
|
-
if (stdout.length < MAX_OUTPUT_CHARS)
|
|
840
|
-
stdout += s.slice(0, MAX_OUTPUT_CHARS - stdout.length);
|
|
841
|
-
onOutput?.("stdout", redactSecrets(s)); // G3
|
|
842
|
-
});
|
|
843
|
-
child.stderr?.on("data", (c) => {
|
|
844
|
-
const s = c.toString("utf8");
|
|
845
|
-
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
846
|
-
stderr += s.slice(0, MAX_OUTPUT_CHARS - stderr.length);
|
|
847
|
-
onOutput?.("stderr", redactSecrets(s)); // G3
|
|
848
|
-
});
|
|
849
|
-
child.on("error", (err) => {
|
|
850
|
-
finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
|
|
851
|
-
});
|
|
852
|
-
child.on("close", (code) => {
|
|
853
|
-
if (killedReason === "timeout") {
|
|
854
|
-
finish({
|
|
855
|
-
ok: false,
|
|
856
|
-
output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
857
|
-
});
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
if (killedReason === "aborted") {
|
|
861
|
-
finish({
|
|
862
|
-
ok: false,
|
|
863
|
-
output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
864
|
-
});
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
const status = code ?? -1;
|
|
868
|
-
// G3: redact trước khi output vào conversation/log/TUI.
|
|
869
|
-
const redacted = redactSecrets(stdout);
|
|
870
|
-
const redactedErr = redactSecrets(stderr);
|
|
871
|
-
if (status === 0) {
|
|
872
|
-
finish({ ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) });
|
|
873
|
-
}
|
|
874
|
-
else {
|
|
875
|
-
finish({
|
|
876
|
-
ok: false,
|
|
877
|
-
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
});
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
const obj = (properties, required) => ({
|
|
884
|
-
type: "object",
|
|
885
|
-
properties,
|
|
886
|
-
required,
|
|
887
|
-
additionalProperties: false,
|
|
888
|
-
});
|
|
889
|
-
const str = (description) => ({ type: "string", description });
|
|
890
|
-
const num = (description) => ({ type: "number", description });
|
|
891
|
-
export const AGENT_TOOLS = [
|
|
892
|
-
{
|
|
893
|
-
spec: {
|
|
894
|
-
type: "function",
|
|
895
|
-
function: {
|
|
896
|
-
name: "load_skill",
|
|
897
|
-
description: "Load the full instructions of a named skill from the AVAILABLE SKILLS index (project + global). Call this at the start of a task when it matches a skill, then follow the returned instructions exactly.",
|
|
898
|
-
parameters: obj({
|
|
899
|
-
name: str("Skill name from the AVAILABLE SKILLS index, e.g. 'prompt-gate-workflow'"),
|
|
900
|
-
section: str("Optional section heading to load (progressive disclosure). Omit for full body."),
|
|
901
|
-
}, ["name"]),
|
|
902
|
-
},
|
|
903
|
-
},
|
|
904
|
-
run: (ctx, args) => {
|
|
905
|
-
const name = String(args.name ?? "").trim();
|
|
906
|
-
if (!name)
|
|
907
|
-
return { ok: false, output: "ERROR: name is required — pick one from the AVAILABLE SKILLS index." };
|
|
908
|
-
const skill = findSkill(ctx.workdir, name);
|
|
909
|
-
if (!skill) {
|
|
910
|
-
const names = discoverSkills(ctx.workdir).map((s) => s.name).join(", ");
|
|
911
|
-
return { ok: false, output: `ERROR: skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered"}.` };
|
|
912
|
-
}
|
|
913
|
-
const section = args.section;
|
|
914
|
-
const body = section ? loadSkillSection(skill, String(section)) : skill.body;
|
|
915
|
-
const refs = skill.references?.length ? `\n\nReferences: ${skill.references.join(", ")}` : "";
|
|
916
|
-
return { ok: true, output: `# skill: ${skill.name} (${skill.scope})${skill.version ? ` v${skill.version}` : ""}\n\n${body}${refs}` };
|
|
917
|
-
},
|
|
918
|
-
},
|
|
919
|
-
{
|
|
920
|
-
spec: {
|
|
921
|
-
type: "function",
|
|
922
|
-
function: {
|
|
923
|
-
name: "write_todos",
|
|
924
|
-
description: "Write the full task plan (replaces the previous list). Use at the start of a multi-step task and update as you go: mark items completed IMMEDIATELY after finishing them, set exactly one item in_progress, and add newly discovered steps. " +
|
|
925
|
-
"v2: each item may carry verify (how you will know it's done), depends_on (earlier step ids), files (expected paths) and acceptance (criteria). The user sees this list live.",
|
|
926
|
-
parameters: obj({
|
|
927
|
-
todos: {
|
|
928
|
-
type: "array",
|
|
929
|
-
description: "The complete todo list (full replacement).",
|
|
930
|
-
items: {
|
|
931
|
-
type: "object",
|
|
932
|
-
properties: {
|
|
933
|
-
id: { type: "string", description: "Stable step id (e.g. 't1'). Auto-assigned when omitted." },
|
|
934
|
-
content: { type: "string", description: "Imperative step, e.g. 'Fix null check in UserService'" },
|
|
935
|
-
status: { type: "string", enum: ["pending", "in_progress", "completed"] },
|
|
936
|
-
verify: { type: "string", description: "How to verify the step — command or observable behavior" },
|
|
937
|
-
depends_on: { type: "array", items: { type: "string" }, description: "Ids of steps that must complete first" },
|
|
938
|
-
files: { type: "array", items: { type: "string" }, description: "Files this step is expected to touch" },
|
|
939
|
-
acceptance: { type: "string", description: "Criteria for the step to count as done" },
|
|
940
|
-
},
|
|
941
|
-
required: ["content", "status"],
|
|
942
|
-
additionalProperties: false,
|
|
943
|
-
},
|
|
944
|
-
},
|
|
945
|
-
}, ["todos"]),
|
|
946
|
-
},
|
|
947
|
-
},
|
|
948
|
-
run: (ctx, args) => {
|
|
949
|
-
const todos = args.todos;
|
|
950
|
-
if (!Array.isArray(todos))
|
|
951
|
-
return { ok: false, output: "ERROR: todos must be an array of {content, status}." };
|
|
952
|
-
const clean = [];
|
|
953
|
-
for (const [i, t] of todos.entries()) {
|
|
954
|
-
if (typeof t?.content !== "string" || !t.content.trim()) {
|
|
955
|
-
return { ok: false, output: "ERROR: every todo needs a non-empty content string." };
|
|
956
|
-
}
|
|
957
|
-
if (!["pending", "in_progress", "completed"].includes(t.status)) {
|
|
958
|
-
return { ok: false, output: `ERROR: invalid status "${String(t.status)}" (pending|in_progress|completed).` };
|
|
959
|
-
}
|
|
960
|
-
const item = {
|
|
961
|
-
id: typeof t.id === "string" && t.id.trim() ? t.id.trim().slice(0, 32) : `t${i + 1}`,
|
|
962
|
-
content: t.content.trim().slice(0, 200),
|
|
963
|
-
status: t.status,
|
|
964
|
-
};
|
|
965
|
-
if (typeof t.verify === "string" && t.verify.trim())
|
|
966
|
-
item.verify = t.verify.trim().slice(0, 300);
|
|
967
|
-
if (typeof t.acceptance === "string" && t.acceptance.trim())
|
|
968
|
-
item.acceptance = t.acceptance.trim().slice(0, 300);
|
|
969
|
-
if (Array.isArray(t.depends_on))
|
|
970
|
-
item.depends_on = t.depends_on.filter((x) => typeof x === "string").slice(0, 10);
|
|
971
|
-
if (Array.isArray(t.files))
|
|
972
|
-
item.files = t.files.filter((x) => typeof x === "string").slice(0, 20);
|
|
973
|
-
clean.push(item);
|
|
974
|
-
}
|
|
975
|
-
// v2 rule: exactly one in_progress at a time.
|
|
976
|
-
const inProgress = clean.filter((t) => t.status === "in_progress");
|
|
977
|
-
if (inProgress.length > 1) {
|
|
978
|
-
return { ok: false, output: `ERROR: exactly one in_progress step allowed (got ${inProgress.length}: ${inProgress.map((t) => t.content).join(" | ")}). Mark the others pending or completed.` };
|
|
979
|
-
}
|
|
980
|
-
// v2 rule: depends_on must reference earlier ids in the SAME plan.
|
|
981
|
-
const ids = new Set(clean.map((t) => t.id));
|
|
982
|
-
for (const t of clean) {
|
|
983
|
-
for (const dep of t.depends_on ?? []) {
|
|
984
|
-
if (!ids.has(dep))
|
|
985
|
-
return { ok: false, output: `ERROR: step "${t.content}" depends_on unknown id "${dep}" — ids in this plan: ${[...ids].join(", ")}` };
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
ctx.plan?.set(clean);
|
|
989
|
-
const done = clean.filter((t) => t.status === "completed").length;
|
|
990
|
-
const next = clean.find((t) => t.status === "in_progress");
|
|
991
|
-
return { ok: true, output: `Plan updated: ${done}/${clean.length} completed.${next ? ` In progress: ${next.content}` : " (none in progress)"}` };
|
|
992
|
-
},
|
|
993
|
-
},
|
|
994
|
-
{
|
|
995
|
-
spec: {
|
|
996
|
-
type: "function",
|
|
997
|
-
function: {
|
|
998
|
-
name: "list_files",
|
|
999
|
-
description: "List files and directories recursively under a path in the working directory. Hidden dotfiles are excluded unless include_hidden=true; pass depth / exclude to shape the output.",
|
|
1000
|
-
parameters: obj({
|
|
1001
|
-
path: str("Relative path, defaults to '.'"),
|
|
1002
|
-
depth: num("Max directory depth to recurse (default 4, cap 8)"),
|
|
1003
|
-
exclude: str("Comma/space-separated globs to exclude, e.g. '*.log,coverage'"),
|
|
1004
|
-
include_hidden: str("'true' to include dotfiles (default: excluded)"),
|
|
1005
|
-
}, []),
|
|
1006
|
-
},
|
|
1007
|
-
},
|
|
1008
|
-
run: (ctx, args) => toolListFiles(ctx.workdir, args),
|
|
1009
|
-
},
|
|
1010
|
-
{
|
|
1011
|
-
spec: {
|
|
1012
|
-
type: "function",
|
|
1013
|
-
function: {
|
|
1014
|
-
name: "read_file",
|
|
1015
|
-
description: "Read a text file. Returns content with 1-based line numbers. Pass offset (1-based start line) and limit to read a slice instead of the whole file. Image files (PNG/JPG/GIF) return an `[image: path]` placeholder rendered inline by the TUI.",
|
|
1016
|
-
parameters: obj({
|
|
1017
|
-
path: str("Relative file path"),
|
|
1018
|
-
offset: num("1-based line to start from (default: 1)"),
|
|
1019
|
-
limit: num("Max lines to read (default: whole file, capped at 2000)"),
|
|
1020
|
-
}, ["path"]),
|
|
1021
|
-
},
|
|
1022
|
-
},
|
|
1023
|
-
run: (ctx, args) => toolReadFile(ctx.workdir, args),
|
|
1024
|
-
},
|
|
1025
|
-
{
|
|
1026
|
-
spec: {
|
|
1027
|
-
type: "function",
|
|
1028
|
-
function: {
|
|
1029
|
-
name: "write_file",
|
|
1030
|
-
description: "Create or overwrite a file with the given full content. Parent dirs are created automatically.",
|
|
1031
|
-
parameters: obj({ path: str("Relative file path"), content: str("Full file content") }, ["path", "content"]),
|
|
1032
|
-
},
|
|
1033
|
-
},
|
|
1034
|
-
run: (ctx, args) => toolWriteFile(ctx, args),
|
|
1035
|
-
},
|
|
1036
|
-
{
|
|
1037
|
-
spec: {
|
|
1038
|
-
type: "function",
|
|
1039
|
-
function: {
|
|
1040
|
-
name: "edit_file",
|
|
1041
|
-
description: "Replace an exact unique substring in a file. old_string must appear exactly once; include enough surrounding context. " +
|
|
1042
|
-
"For multiple replacements in the same file, use apply_patch (atomic, one write).",
|
|
1043
|
-
parameters: obj({
|
|
1044
|
-
path: str("Relative file path"),
|
|
1045
|
-
old_string: str("Exact text to replace"),
|
|
1046
|
-
new_string: str("Replacement text"),
|
|
1047
|
-
}, ["path", "old_string", "new_string"]),
|
|
1048
|
-
},
|
|
1049
|
-
},
|
|
1050
|
-
run: (ctx, args) => toolEditFile(ctx, args),
|
|
1051
|
-
},
|
|
1052
|
-
{
|
|
1053
|
-
spec: {
|
|
1054
|
-
type: "function",
|
|
1055
|
-
function: {
|
|
1056
|
-
name: "apply_patch",
|
|
1057
|
-
description: "Apply multiple exact replacements to ONE file atomically. Edits run in order; each old_string must match exactly once " +
|
|
1058
|
-
"in the content produced by the previous edits. If any edit fails, nothing is written. " +
|
|
1059
|
-
"Use this instead of repeated edit_file calls for multi-hunk changes.",
|
|
1060
|
-
parameters: obj({
|
|
1061
|
-
path: str("Relative file path"),
|
|
1062
|
-
edits: {
|
|
1063
|
-
type: "array",
|
|
1064
|
-
description: "Ordered list of replacements applied atomically (1–50).",
|
|
1065
|
-
items: obj({
|
|
1066
|
-
old_string: str("Exact text to replace (must be unique at its turn)"),
|
|
1067
|
-
new_string: str("Replacement text"),
|
|
1068
|
-
}, ["old_string", "new_string"]),
|
|
1069
|
-
},
|
|
1070
|
-
}, ["path", "edits"]),
|
|
1071
|
-
},
|
|
1072
|
-
},
|
|
1073
|
-
run: (ctx, args) => toolApplyPatch(ctx, args),
|
|
1074
|
-
},
|
|
1075
|
-
{
|
|
1076
|
-
spec: {
|
|
1077
|
-
type: "function",
|
|
1078
|
-
function: {
|
|
1079
|
-
name: "run_command",
|
|
1080
|
-
description: "Run a shell command in the working directory (sh/bash -c; Windows: Git Bash when installed, else cmd /c). Use for builds, tests, git, installing deps, etc.",
|
|
1081
|
-
parameters: obj({
|
|
1082
|
-
command: str("Shell command to run"),
|
|
1083
|
-
timeout_secs: num("Timeout in seconds (default 120, max 600)"),
|
|
1084
|
-
}, ["command"]),
|
|
1085
|
-
},
|
|
1086
|
-
},
|
|
1087
|
-
run: (ctx, args) => toolRunCommand(ctx.workdir, args, ctx.signal, ctx.onToolOutput ? (stream, delta) => ctx.onToolOutput?.("run_command", stream, delta) : undefined, ctx.sandbox),
|
|
1088
|
-
},
|
|
1089
|
-
{
|
|
1090
|
-
spec: {
|
|
1091
|
-
type: "function",
|
|
1092
|
-
function: {
|
|
1093
|
-
name: "web_fetch",
|
|
1094
|
-
description: "Fetch a public HTTP(S) URL and return its content as readable plain text " +
|
|
1095
|
-
"(HTML converted to text, JSON pretty-printed). WHEN TO USE: the task depends on external, " +
|
|
1096
|
-
"possibly outdated knowledge — library/API documentation, an error message or comment " +
|
|
1097
|
-
"containing a URL, current package versions or changelogs, standards/specs. " +
|
|
1098
|
-
"WHEN NOT TO: the answer is already in the working directory or in the user's request; " +
|
|
1099
|
-
"you need a search engine (this tool fetches exact URLs only, it cannot search); " +
|
|
1100
|
-
"the content is a binary file (images, archives, PDFs are refused). " +
|
|
1101
|
-
"Private/internal addresses (localhost, 10.x, 192.168.x, 127.x) are blocked.",
|
|
1102
|
-
parameters: obj({
|
|
1103
|
-
url: str("Absolute http(s) URL to fetch"),
|
|
1104
|
-
max_chars: str("Max characters of extracted content to return (default 20000, max 100000)"),
|
|
1105
|
-
}, ["url"]),
|
|
1106
|
-
},
|
|
1107
|
-
},
|
|
1108
|
-
run: (ctx, args) => toolWebFetch(ctx, args),
|
|
1109
|
-
},
|
|
1110
|
-
{
|
|
1111
|
-
spec: {
|
|
1112
|
-
type: "function",
|
|
1113
|
-
function: {
|
|
1114
|
-
name: "grep",
|
|
1115
|
-
description: "Search file contents with a regex (ripgrep-backed, gitignore-aware). Returns path:line:text hits (max 50 by default), with optional context lines and a hit cap. " +
|
|
1116
|
-
"Use for finding code by symbol, error string, or pattern. Combine with read_file for full context.",
|
|
1117
|
-
parameters: obj({
|
|
1118
|
-
pattern: str("Regular expression to search for"),
|
|
1119
|
-
path: str("Directory or file to search (default: workdir root)"),
|
|
1120
|
-
include: str("Glob filter for filenames, e.g. '*.ts'"),
|
|
1121
|
-
context_before: num("Context lines to print before each match (default 0)"),
|
|
1122
|
-
context_after: num("Context lines to print after each match (default 0)"),
|
|
1123
|
-
max_results: num("Max matching lines to return (default 50, cap 100)"),
|
|
1124
|
-
type: str("ripgrep file type filter, e.g. 'ts', 'py' (rg only; ignored by fallbacks)"),
|
|
1125
|
-
}, ["pattern"]),
|
|
1126
|
-
},
|
|
1127
|
-
},
|
|
1128
|
-
run: (ctx, args) => toolGrep(ctx.workdir, args),
|
|
1129
|
-
},
|
|
1130
|
-
{
|
|
1131
|
-
spec: {
|
|
1132
|
-
type: "function",
|
|
1133
|
-
function: {
|
|
1134
|
-
name: "glob",
|
|
1135
|
-
description: "Find files by glob pattern (supports **, *, ?, {a,b}). Returns matching paths sorted, max 200. " +
|
|
1136
|
-
"Use when you know the filename shape but not the location.",
|
|
1137
|
-
parameters: obj({
|
|
1138
|
-
pattern: str("Glob pattern relative to workdir, e.g. 'src/**/*.ts'"),
|
|
1139
|
-
path: str("Directory to search (default: workdir root)"),
|
|
1140
|
-
}, ["pattern"]),
|
|
1141
|
-
},
|
|
1142
|
-
},
|
|
1143
|
-
run: (ctx, args) => toolGlob(ctx.workdir, args),
|
|
1144
|
-
},
|
|
1145
|
-
{
|
|
1146
|
-
spec: {
|
|
1147
|
-
type: "function",
|
|
1148
|
-
function: {
|
|
1149
|
-
name: "repo_map",
|
|
1150
|
-
description: "Return the repository map — file tree with sizes + exported symbols (regex-extracted, budget-bounded, cached). " +
|
|
1151
|
-
"Use to orient before reading files; cheaper than listing/reading everything.",
|
|
1152
|
-
parameters: obj({ path: str("Sub-directory to map (default: repo root)") }, []),
|
|
1153
|
-
},
|
|
1154
|
-
},
|
|
1155
|
-
run: (ctx, args) => {
|
|
1156
|
-
try {
|
|
1157
|
-
const sub = typeof args.path === "string" ? args.path.trim() : "";
|
|
1158
|
-
const out = sub
|
|
1159
|
-
? renderRepoMap(ctx.workdir, 4000, sub)
|
|
1160
|
-
: buildRepoMap(ctx.workdir);
|
|
1161
|
-
return { ok: true, output: out };
|
|
1162
|
-
}
|
|
1163
|
-
catch (err) {
|
|
1164
|
-
return { ok: false, output: `ERROR: repo map failed: ${err.message}` };
|
|
1165
|
-
}
|
|
1166
|
-
},
|
|
1167
|
-
},
|
|
1168
|
-
];
|
|
1169
|
-
/** task tool — runner resolved from ctx.taskRunner (per-agent, no global). */
|
|
1170
|
-
const taskTool = {
|
|
1171
|
-
spec: {
|
|
1172
|
-
type: "function",
|
|
1173
|
-
function: {
|
|
1174
|
-
name: "task",
|
|
1175
|
-
description: "Delegate self-contained subtasks to subagents that run their own agent loop with their own context. " +
|
|
1176
|
-
"WHEN TO USE: broad codebase exploration (find where X lives, summarize module Y) so the main " +
|
|
1177
|
-
"conversation stays lean, or independent subtasks that need many steps. " +
|
|
1178
|
-
"SINGLE: pass description + prompt. BATCH: pass tasks[] to run up to 8 subtasks concurrently " +
|
|
1179
|
-
"(bounded at 4 in flight) and get one merged result. " +
|
|
1180
|
-
"Every prompt must be self-contained (the subagent sees nothing else); each returns a summary. " +
|
|
1181
|
-
"Prefer this over reading dozens of files yourself.",
|
|
1182
|
-
parameters: obj({
|
|
1183
|
-
description: str("One-line summary of the subtask (single mode; shown in the UI)"),
|
|
1184
|
-
prompt: str("Self-contained instructions for the subagent (single mode)"),
|
|
1185
|
-
agent: str("Optional agent name from the available agents (default: explore for research, build for changes)"),
|
|
1186
|
-
tasks: {
|
|
1187
|
-
type: "array",
|
|
1188
|
-
description: "Batch mode: up to 8 subtasks run concurrently. When set, description/prompt are ignored.",
|
|
1189
|
-
items: obj({
|
|
1190
|
-
description: str("One-line summary of this subtask (shown in the UI)"),
|
|
1191
|
-
prompt: str("Self-contained instructions for this subagent"),
|
|
1192
|
-
agent: str("Optional agent name for this subtask"),
|
|
1193
|
-
}, ["description", "prompt"]),
|
|
1194
|
-
},
|
|
1195
|
-
}, []),
|
|
1196
|
-
},
|
|
1197
|
-
},
|
|
1198
|
-
run: (ctx, args) => {
|
|
1199
|
-
if (!ctx.taskRunner)
|
|
1200
|
-
return { ok: false, output: "ERROR: task tool not wired (no runner in context)." };
|
|
1201
|
-
const a = args;
|
|
1202
|
-
if (Array.isArray(a.tasks) && a.tasks.length > 0) {
|
|
1203
|
-
if (a.tasks.length > 8) {
|
|
1204
|
-
return { ok: false, output: `ERROR: too many tasks (${a.tasks.length}); split into batches of at most 8.` };
|
|
1205
|
-
}
|
|
1206
|
-
const invalid = a.tasks.findIndex((t) => !t || typeof t.prompt !== "string" || !t.prompt.trim());
|
|
1207
|
-
if (invalid >= 0)
|
|
1208
|
-
return { ok: false, output: `ERROR: tasks[${invalid}] is missing a non-empty prompt.` };
|
|
1209
|
-
return ctx.taskRunner(ctx, { tasks: a.tasks });
|
|
1210
|
-
}
|
|
1211
|
-
if (typeof a.prompt !== "string" || !a.prompt.trim()) {
|
|
1212
|
-
return { ok: false, output: "ERROR: task requires a non-empty `prompt` (single mode) or a non-empty `tasks` array (batch mode)." };
|
|
1213
|
-
}
|
|
1214
|
-
return ctx.taskRunner(ctx, a);
|
|
1215
|
-
},
|
|
1216
|
-
};
|
|
1217
|
-
export function getTool(name) {
|
|
1218
|
-
return AGENT_TOOLS.find((t) => t.spec.function.name === name) ?? (name === "task" ? taskTool : undefined);
|
|
1219
|
-
}
|
|
1220
|
-
/** All tool specs incl. task (agent adds MCP specs on top). */
|
|
1221
|
-
export function allToolSpecs() {
|
|
1222
|
-
return [...AGENT_TOOLS.map((t) => t.spec), taskTool.spec];
|
|
1223
|
-
}
|