thincoder 0.8.2 → 0.8.3
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 +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tools/shared.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools/shared.mjs —
|
|
3
|
-
*
|
|
2
|
+
* tools/shared.mjs — shared tool utilities, constants, OpenAI schema conversion
|
|
3
|
+
* Imported by tools/file.mjs / system.mjs / web.mjs / git.mjs
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { spawn, execFileSync } from "node:child_process"
|
|
6
|
+
import { spawn, execFileSync, execFile } from "node:child_process"
|
|
7
7
|
import { readFileSync, existsSync, realpathSync } from "node:fs"
|
|
8
8
|
import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
@@ -13,11 +13,14 @@ export const DESC = (name) => readFileSync(join(__dirname, "..", "tools", `${nam
|
|
|
13
13
|
|
|
14
14
|
export const MAX_READ_LINES = 2000
|
|
15
15
|
export const MAX_OUTPUT_CHARS = 200_000
|
|
16
|
+
|
|
17
|
+
const ENCODING_DETECT_MAX_TRIM = 3
|
|
18
|
+
const SYNTAX_CHECK_TIMEOUT = 10000
|
|
16
19
|
export const BASH_TIMEOUT_MS = 120_000
|
|
17
20
|
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
18
21
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
19
22
|
|
|
20
|
-
/**
|
|
23
|
+
/** Convert to OpenAI tools parameter format */
|
|
21
24
|
export function toOpenAISchema(tool) {
|
|
22
25
|
return {
|
|
23
26
|
type: "function",
|
|
@@ -29,7 +32,7 @@ export function toOpenAISchema(tool) {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
/**
|
|
35
|
+
/** Strip ANSI escape sequences */
|
|
33
36
|
export function sanitizeOutput(s) {
|
|
34
37
|
return s
|
|
35
38
|
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
@@ -37,12 +40,13 @@ export function sanitizeOutput(s) {
|
|
|
37
40
|
.replace(/\r/g, "\n")
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/** Truncate text to max chars, appending a truncation notice */
|
|
40
44
|
export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
41
45
|
if (text.length <= max) return text
|
|
42
46
|
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
/**
|
|
49
|
+
/** Read response body with a byte limit */
|
|
46
50
|
export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
47
51
|
if (!response.body) return ""
|
|
48
52
|
const reader = response.body.getReader()
|
|
@@ -59,7 +63,8 @@ export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
|
59
63
|
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/**
|
|
66
|
+
/** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
|
|
67
|
+
* Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
|
|
63
68
|
export function makeDecoder() {
|
|
64
69
|
let decoder = null
|
|
65
70
|
let pending = Buffer.alloc(0)
|
|
@@ -68,7 +73,7 @@ export function makeDecoder() {
|
|
|
68
73
|
if (!decoder) {
|
|
69
74
|
const hasHighByte = pending.some((b) => b >= 0x80)
|
|
70
75
|
if (!hasHighByte) { const s = pending.toString("ascii"); pending = Buffer.alloc(0); return s }
|
|
71
|
-
for (let trim = 0; trim <=
|
|
76
|
+
for (let trim = 0; trim <= ENCODING_DETECT_MAX_TRIM && !decoder; trim++) {
|
|
72
77
|
try { new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim)); decoder = new TextDecoder("utf-8") }
|
|
73
78
|
catch { /* continue */ }
|
|
74
79
|
}
|
|
@@ -80,7 +85,7 @@ export function makeDecoder() {
|
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
/**
|
|
88
|
+
/** Single-file git diff. Silently returns empty on failure. Large diffs that exceed maxBuffer are truncated rather than swallowed. */
|
|
84
89
|
export function gitDiffOne(cwd, abs) {
|
|
85
90
|
try {
|
|
86
91
|
const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
|
|
@@ -91,7 +96,7 @@ export function gitDiffOne(cwd, abs) {
|
|
|
91
96
|
if (lines.length <= 200) return diff
|
|
92
97
|
return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
|
|
93
98
|
} catch (e) {
|
|
94
|
-
// maxBuffer
|
|
99
|
+
// maxBuffer overflow: e.stdout contains partial collected output; other errors (non-git repo etc.) return empty
|
|
95
100
|
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
96
101
|
const lines = e.stdout.toString().split("\n")
|
|
97
102
|
return lines.slice(0, 200).join("\n") + `\n... (diff too large, showing first 200 of more lines)`
|
|
@@ -100,19 +105,32 @@ export function gitDiffOne(cwd, abs) {
|
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
/**
|
|
104
|
-
export function autoSyntaxCheck(abs) {
|
|
108
|
+
/** Auto syntax check after file modification */
|
|
109
|
+
export async function autoSyntaxCheck(abs) {
|
|
105
110
|
if (!/\.(m?js)$/i.test(abs)) return ""
|
|
106
111
|
try {
|
|
107
|
-
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
const child = execFile("node", ["--check", abs], { timeout: SYNTAX_CHECK_TIMEOUT, stdio: ["ignore", "pipe", "pipe"] })
|
|
114
|
+
let stderr = ""
|
|
115
|
+
child.stderr.on("data", (d) => { stderr += d.toString() })
|
|
116
|
+
child.on("error", reject)
|
|
117
|
+
child.on("close", (code) => {
|
|
118
|
+
if (code === 0) resolve()
|
|
119
|
+
else {
|
|
120
|
+
const err = new Error(stderr.trim() || `node --check exited with code ${code}`)
|
|
121
|
+
err.stderr = stderr
|
|
122
|
+
reject(err)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
108
126
|
return "\nSyntax: OK"
|
|
109
127
|
} catch (e) {
|
|
110
128
|
const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
|
|
111
|
-
return `\nSyntax: FAILED — ${err}`
|
|
129
|
+
return `\nSyntax: FAILED — ${err}\n(If this file was corrupted by a bad edit, recover it from a checkpoint: checkpoint action=list then action=rewind with the latest id.)`
|
|
112
130
|
}
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
/**
|
|
133
|
+
/** Resolve realpath by walking up the directory tree */
|
|
116
134
|
export function realpathNearest(abs) {
|
|
117
135
|
let cur = abs
|
|
118
136
|
const tail = []
|
|
@@ -127,11 +145,13 @@ export function realpathNearest(abs) {
|
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
const realCwdCache = new Map()
|
|
148
|
+
/** Resolve cwd to realpath, cached */
|
|
130
149
|
export function realCwd(cwd) {
|
|
131
150
|
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
132
151
|
return realCwdCache.get(cwd)
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
/** Assert that a resolved path is inside cwd; throws on escape */
|
|
135
155
|
export function assertInside(cwd, resolved, p) {
|
|
136
156
|
const rel = relative(cwd, resolved)
|
|
137
157
|
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
@@ -139,6 +159,7 @@ export function assertInside(cwd, resolved, p) {
|
|
|
139
159
|
}
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
/** Resolve a user-supplied path relative to cwd, asserting it stays within cwd */
|
|
142
163
|
export function resolveInCwd(ctx, p) {
|
|
143
164
|
const cwd = realCwd(ctx.cwd)
|
|
144
165
|
const resolved = resolve(cwd, p)
|
|
@@ -148,25 +169,25 @@ export function resolveInCwd(ctx, p) {
|
|
|
148
169
|
return resolved
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
172
|
+
/** Coarse segmentation for destructive pre-check (also splits on > >> < so destructive detection still works through redirection) */
|
|
152
173
|
export function shellSegments(command) {
|
|
153
174
|
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
154
175
|
}
|
|
155
176
|
|
|
156
|
-
/**
|
|
177
|
+
/** Detect shell output/input redirection (> >> < followed by filename) — not excluded inside quotes, conservative block */
|
|
157
178
|
export function hasFileRedirection(command) {
|
|
158
179
|
return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
|
|
159
180
|
}
|
|
160
181
|
|
|
161
|
-
/**
|
|
182
|
+
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
|
162
183
|
export function isDestructiveCommand(seg) {
|
|
163
184
|
const s = seg
|
|
164
|
-
// rm
|
|
185
|
+
// rm with both recursive (-r/-R) and force (-f) flags: -rf / -fr / -r -f / -Rf etc.
|
|
165
186
|
if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
|
|
166
187
|
if (/\brmdir\b/i.test(s)) return true
|
|
167
188
|
if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
|
|
168
189
|
if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
|
|
169
|
-
// format
|
|
190
|
+
// format called as a command (exclude --format= option false positives)
|
|
170
191
|
if (/\bformat\b\s+\S/i.test(s) && !/--format\b/i.test(s)) return true
|
|
171
192
|
if (/\bshred\b/i.test(s)) return true
|
|
172
193
|
if (/\bdd\b/.test(s) && /\bof=/i.test(s)) return true
|
|
@@ -176,7 +197,7 @@ export function isDestructiveCommand(seg) {
|
|
|
176
197
|
return false
|
|
177
198
|
}
|
|
178
199
|
|
|
179
|
-
/**
|
|
200
|
+
/** Whether a single command segment destroys uncommitted changes */
|
|
180
201
|
export function isDestructiveGitSegment(seg) {
|
|
181
202
|
if (!/^\s*git\s/.test(seg)) return false
|
|
182
203
|
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
@@ -186,7 +207,7 @@ export function isDestructiveGitSegment(seg) {
|
|
|
186
207
|
return false
|
|
187
208
|
}
|
|
188
209
|
|
|
189
|
-
/** cwd
|
|
210
|
+
/** Whether cwd is inside a git repository */
|
|
190
211
|
export function insideGitRepo(cwd) {
|
|
191
212
|
try {
|
|
192
213
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
@@ -196,7 +217,7 @@ export function insideGitRepo(cwd) {
|
|
|
196
217
|
} catch { return false }
|
|
197
218
|
}
|
|
198
219
|
|
|
199
|
-
/** glob
|
|
220
|
+
/** Convert glob pattern to regex */
|
|
200
221
|
export function globToRegex(pattern) {
|
|
201
222
|
const DS = "\u0001", DP = "\u0002"
|
|
202
223
|
const escaped = pattern
|
|
@@ -208,7 +229,7 @@ export function globToRegex(pattern) {
|
|
|
208
229
|
return new RegExp(`^${escaped}$`)
|
|
209
230
|
}
|
|
210
231
|
|
|
211
|
-
/**
|
|
232
|
+
/** Strip HTML tags */
|
|
212
233
|
export function stripTags(html) {
|
|
213
234
|
return html
|
|
214
235
|
.replace(/<[^>]+>/g, "")
|
|
@@ -223,7 +244,7 @@ export function stripTags(html) {
|
|
|
223
244
|
.trim()
|
|
224
245
|
}
|
|
225
246
|
|
|
226
|
-
/** HTML →
|
|
247
|
+
/** HTML → plain text: strip scripts/styles, newline block tags, strip tags, decode entities, compress blank lines */
|
|
227
248
|
export function htmlToText(html) {
|
|
228
249
|
return html
|
|
229
250
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
@@ -239,18 +260,18 @@ export function htmlToText(html) {
|
|
|
239
260
|
.replace(/</g, "<")
|
|
240
261
|
.replace(/>/g, ">")
|
|
241
262
|
.replace(/"/g, '"')
|
|
242
|
-
.replace(/&/g, "&") // &
|
|
263
|
+
.replace(/&/g, "&") // & must be decoded last, otherwise &lt; gets double-decoded to <
|
|
243
264
|
.replace(/[ \t]+/g, " ")
|
|
244
265
|
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
245
266
|
.trim()
|
|
246
267
|
}
|
|
247
268
|
|
|
248
|
-
/**
|
|
269
|
+
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty. */
|
|
249
270
|
export function runGit(cwd, cmdArgs) {
|
|
250
271
|
try {
|
|
251
272
|
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
252
273
|
} catch (e) {
|
|
253
|
-
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER
|
|
274
|
+
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER: e.stdout contains partial output, return first 200 lines
|
|
254
275
|
if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
255
276
|
return ""
|
|
256
277
|
}
|
package/src/tools/system.mjs
CHANGED
|
@@ -13,14 +13,11 @@ import {
|
|
|
13
13
|
insideGitRepo,
|
|
14
14
|
globToRegex
|
|
15
15
|
} from "./shared.mjs";
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import {
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
-
import { readdir } from "node:fs/promises";
|
|
20
|
-
import { stat, lstat } from "node:fs/promises";
|
|
16
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
17
|
+
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
21
18
|
import { join } from "node:path";
|
|
22
19
|
|
|
23
|
-
/**
|
|
20
|
+
/** Child process environment variable whitelist: only pass through safe variables, isolate sensitive info like API keys */
|
|
24
21
|
const SAFE_ENV_KEYS = new Set([
|
|
25
22
|
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
|
26
23
|
"TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
|
|
@@ -30,6 +27,152 @@ const SAFE_ENV_KEYS = new Set([
|
|
|
30
27
|
"GIT_PAGER", "PAGER", "TERM",
|
|
31
28
|
])
|
|
32
29
|
|
|
30
|
+
/** Maximum buffer size per stream (stdout / stderr) before truncation */
|
|
31
|
+
const MAX_STREAM_BUF = 2_000_000
|
|
32
|
+
|
|
33
|
+
// ====================================================================
|
|
34
|
+
// bash — command execution with safety gates
|
|
35
|
+
// ====================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pre-execution safety checks for bash commands.
|
|
39
|
+
* Three layers: file redirection → destructive commands → destructive git ops with uncommitted changes.
|
|
40
|
+
* Throws with actionable guidance on failure.
|
|
41
|
+
*/
|
|
42
|
+
function checkBashSafety(command, cwd) {
|
|
43
|
+
if (hasFileRedirection(command)) {
|
|
44
|
+
throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
|
|
45
|
+
}
|
|
46
|
+
if (shellSegments(command).some(isDestructiveCommand)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Destructive command blocked — use specific tools or confirm with the user first. " +
|
|
49
|
+
"(If work was already destroyed, recover from auto-snapshot: checkpoint action=list then action=rewind.)"
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
if (shellSegments(command).some(isDestructiveGitSegment)) {
|
|
53
|
+
if (!insideGitRepo(cwd)) {
|
|
54
|
+
throw new Error(`Refusing destructive git command: not a git repository: ${cwd}`)
|
|
55
|
+
}
|
|
56
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
57
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
58
|
+
}).trim()
|
|
59
|
+
if (status) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Refusing destructive git command: uncommitted changes exist.\n` +
|
|
62
|
+
`First create a checkpoint (action=create) to protect current work, then commit or stash before the destructive operation.\n` +
|
|
63
|
+
`(If uncommitted work was already lost, recover from the latest auto-snapshot: checkpoint action=list, then action=rewind.)\n\n${status}`
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a sanitized environment for the child process.
|
|
71
|
+
* Whitelists safe variables, forces non-interactive defaults (EDITOR/PAGER/TERM),
|
|
72
|
+
* and sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
|
|
73
|
+
*/
|
|
74
|
+
function buildBashEnv() {
|
|
75
|
+
const winCmd = process.platform === "win32"
|
|
76
|
+
return {
|
|
77
|
+
...Object.fromEntries(
|
|
78
|
+
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
79
|
+
),
|
|
80
|
+
GIT_EDITOR: "true",
|
|
81
|
+
EDITOR: "true",
|
|
82
|
+
VISUAL: "true",
|
|
83
|
+
GIT_PAGER: "cat",
|
|
84
|
+
PAGER: "cat",
|
|
85
|
+
TERM: "dumb",
|
|
86
|
+
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Platform-aware process tree kill.
|
|
92
|
+
* POSIX: kill process group (spawned with detached=true).
|
|
93
|
+
* Windows: taskkill /T to reach grandchildren (npm test's subprocesses, etc.).
|
|
94
|
+
*/
|
|
95
|
+
function killProcessTree(child) {
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
98
|
+
} else {
|
|
99
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
100
|
+
try { child.kill("SIGKILL") } catch {} // fallback: kill directly if group kill fails
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
|
|
106
|
+
* enforce timeout and signal abort, return formatted result.
|
|
107
|
+
*
|
|
108
|
+
* Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
|
|
109
|
+
*/
|
|
110
|
+
function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const child = spawn(command, {
|
|
113
|
+
cwd,
|
|
114
|
+
shell: true,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
detached: process.platform !== "win32",
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
118
|
+
env: buildBashEnv(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const killTree = () => killProcessTree(child)
|
|
122
|
+
|
|
123
|
+
// Separate decoders for stdout / stderr (same encoding in practice, but separate
|
|
124
|
+
// collection is cleaner and lets the model locate errors via stderr quickly)
|
|
125
|
+
const outDecoder = makeDecoder()
|
|
126
|
+
const errDecoder = makeDecoder()
|
|
127
|
+
let outBuf = ""
|
|
128
|
+
let errBuf = ""
|
|
129
|
+
let truncatedNote = ""
|
|
130
|
+
|
|
131
|
+
child.stdout.on("data", (d) => {
|
|
132
|
+
const s = sanitizeOutput(outDecoder(d))
|
|
133
|
+
if (s) {
|
|
134
|
+
onOutput?.(s)
|
|
135
|
+
if (outBuf.length < MAX_STREAM_BUF) outBuf += s
|
|
136
|
+
else if (!truncatedNote) truncatedNote =
|
|
137
|
+
"\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
child.stderr.on("data", (d) => {
|
|
142
|
+
const s = sanitizeOutput(errDecoder(d))
|
|
143
|
+
if (errBuf.length < MAX_STREAM_BUF) errBuf += s
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const timer = setTimeout(killTree, timeout)
|
|
147
|
+
if (signal) signal.addEventListener("abort", killTree, { once: true })
|
|
148
|
+
|
|
149
|
+
child.on("error", (error) => {
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
child.on("close", (code, exitSignal) => {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
// Flush decoder tails (trailing multi-byte sequences that incomplete buffers left pending)
|
|
157
|
+
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
158
|
+
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
159
|
+
|
|
160
|
+
const status = exitSignal
|
|
161
|
+
? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
|
|
162
|
+
: `exit code ${code}`
|
|
163
|
+
|
|
164
|
+
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
165
|
+
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
166
|
+
parts.push(`(${status})`)
|
|
167
|
+
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ====================================================================
|
|
173
|
+
// tool definitions
|
|
174
|
+
// ====================================================================
|
|
175
|
+
|
|
33
176
|
export const bashTool = {
|
|
34
177
|
name: "bash",
|
|
35
178
|
description: DESC("bash"),
|
|
@@ -42,111 +185,13 @@ export const bashTool = {
|
|
|
42
185
|
required: ["command"],
|
|
43
186
|
},
|
|
44
187
|
readonly: false,
|
|
188
|
+
outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
|
|
45
189
|
async execute(args, ctx) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (shellSegments(args.command).some(isDestructiveCommand)) {
|
|
52
|
-
throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
|
|
53
|
-
}
|
|
54
|
-
// 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
|
|
55
|
-
if (shellSegments(args.command).some(isDestructiveGitSegment)) {
|
|
56
|
-
if (!insideGitRepo(ctx.cwd)) {
|
|
57
|
-
throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
|
|
58
|
-
}
|
|
59
|
-
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
60
|
-
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
-
}).trim()
|
|
62
|
-
if (status) {
|
|
63
|
-
throw new Error(
|
|
64
|
-
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
|
|
65
|
-
`(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return new Promise((resolve) => {
|
|
71
|
-
// detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
|
|
72
|
-
// Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
|
|
73
|
-
const killTree = () => {
|
|
74
|
-
if (process.platform === "win32") {
|
|
75
|
-
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
76
|
-
} else {
|
|
77
|
-
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
78
|
-
try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
|
|
82
|
-
// chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
|
|
83
|
-
// 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
|
|
84
|
-
// 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
|
|
85
|
-
const winCmd = process.platform === "win32"
|
|
86
|
-
const child = spawn(args.command, {
|
|
87
|
-
cwd: ctx.cwd,
|
|
88
|
-
shell: true,
|
|
89
|
-
windowsHide: true,
|
|
90
|
-
detached: process.platform !== "win32",
|
|
91
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
-
env: {
|
|
93
|
-
...Object.fromEntries(
|
|
94
|
-
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
95
|
-
),
|
|
96
|
-
GIT_EDITOR: "true",
|
|
97
|
-
EDITOR: "true",
|
|
98
|
-
VISUAL: "true",
|
|
99
|
-
GIT_PAGER: "cat",
|
|
100
|
-
PAGER: "cat",
|
|
101
|
-
TERM: "dumb",
|
|
102
|
-
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
103
|
-
},
|
|
104
|
-
})
|
|
105
|
-
// stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
|
|
106
|
-
// 且允许模型按 stderr 快速定位错误)
|
|
107
|
-
const outDecoder = makeDecoder()
|
|
108
|
-
const errDecoder = makeDecoder()
|
|
109
|
-
let outBuf = ""
|
|
110
|
-
let errBuf = ""
|
|
111
|
-
let truncatedNote = ""
|
|
112
|
-
|
|
113
|
-
const onStdout = (d) => {
|
|
114
|
-
const s = sanitizeOutput(outDecoder(d))
|
|
115
|
-
if (s) {
|
|
116
|
-
ctx.onOutput?.(s)
|
|
117
|
-
if (outBuf.length < 2_000_000) outBuf += s
|
|
118
|
-
else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
const onStderr = (d) => {
|
|
122
|
-
const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
|
|
123
|
-
if (errBuf.length < 2_000_000) errBuf += s
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
child.stdout.on("data", onStdout)
|
|
127
|
-
child.stderr.on("data", onStderr)
|
|
128
|
-
|
|
129
|
-
const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
|
|
130
|
-
if (ctx.signal) {
|
|
131
|
-
ctx.signal.addEventListener("abort", killTree, { once: true })
|
|
132
|
-
}
|
|
133
|
-
child.on("error", (error) => {
|
|
134
|
-
clearTimeout(timer)
|
|
135
|
-
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
136
|
-
})
|
|
137
|
-
child.on("close", (code, signal) => {
|
|
138
|
-
clearTimeout(timer)
|
|
139
|
-
// 冲刷解码器尾部
|
|
140
|
-
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
141
|
-
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
142
|
-
const status = signal
|
|
143
|
-
? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
|
|
144
|
-
: `exit code ${code}`
|
|
145
|
-
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
146
|
-
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
147
|
-
parts.push(`(${status})`)
|
|
148
|
-
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
149
|
-
})
|
|
190
|
+
checkBashSafety(args.command, ctx.cwd)
|
|
191
|
+
return runBash(args.command, ctx.cwd, {
|
|
192
|
+
timeout: args.timeout ?? BASH_TIMEOUT_MS,
|
|
193
|
+
signal: ctx.signal,
|
|
194
|
+
onOutput: ctx.onOutput,
|
|
150
195
|
})
|
|
151
196
|
},
|
|
152
197
|
}
|
|
@@ -180,7 +225,7 @@ export const globTool = {
|
|
|
180
225
|
},
|
|
181
226
|
}
|
|
182
227
|
|
|
183
|
-
/**
|
|
228
|
+
/** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
|
|
184
229
|
async function* walkFiles(dir, rel = "") {
|
|
185
230
|
let entries
|
|
186
231
|
try {
|
|
@@ -199,7 +244,7 @@ async function* walkFiles(dir, rel = "") {
|
|
|
199
244
|
}
|
|
200
245
|
}
|
|
201
246
|
|
|
202
|
-
/**
|
|
247
|
+
/** Glob to regex: **\/ matches zero or more directory levels, ** crosses directories, * within segment, ? single char within segment */
|
|
203
248
|
|
|
204
249
|
// ---------------------------------------------------------------- grep
|
|
205
250
|
|
|
@@ -226,17 +271,17 @@ export const grepTool = {
|
|
|
226
271
|
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
227
272
|
const wantCtx = before > 0 || after > 0
|
|
228
273
|
const hits = [] // { file, line(1-based), text }
|
|
229
|
-
const fileLines = new Map() // file -> string[]
|
|
274
|
+
const fileLines = new Map() // file -> string[] (cached only when context lines requested)
|
|
230
275
|
|
|
231
276
|
async function search(file) {
|
|
232
277
|
let content
|
|
233
278
|
try {
|
|
234
|
-
//
|
|
279
|
+
// Large file guard: skip files over 10MB to prevent OOM
|
|
235
280
|
const fst = await stat(file)
|
|
236
281
|
if (fst.size > 10_000_000) return
|
|
237
282
|
content = await readFile(file, "utf8")
|
|
238
283
|
} catch {
|
|
239
|
-
return //
|
|
284
|
+
return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
|
|
240
285
|
}
|
|
241
286
|
const lines = content.split("\n")
|
|
242
287
|
if (wantCtx) fileLines.set(file, lines)
|
|
@@ -250,7 +295,7 @@ export const grepTool = {
|
|
|
250
295
|
|
|
251
296
|
async function walk(target) {
|
|
252
297
|
if (hits.length >= 200) return
|
|
253
|
-
//
|
|
298
|
+
// Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
|
|
254
299
|
let s
|
|
255
300
|
try { s = await lstat(target) } catch { return }
|
|
256
301
|
if (!s.isDirectory()) {
|
|
@@ -272,12 +317,12 @@ export const grepTool = {
|
|
|
272
317
|
await walk(base)
|
|
273
318
|
if (hits.length === 0) return "(no matches)"
|
|
274
319
|
|
|
275
|
-
//
|
|
320
|
+
// No context: keep original path:line: content format
|
|
276
321
|
if (!wantCtx) {
|
|
277
322
|
return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
|
|
278
323
|
}
|
|
279
324
|
|
|
280
|
-
//
|
|
325
|
+
// With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
|
|
281
326
|
const fileMatched = new Map() // file -> Set<line>
|
|
282
327
|
for (const h of hits) {
|
|
283
328
|
if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
|
package/src/tools/web.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export const websearchTool = {
|
|
|
36
36
|
throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
39
|
+
// Result block <li class="b_algo">: <h2><a href>title</a></h2> + <p>snippet</p>
|
|
40
40
|
const blocks = html.split('<li class="b_algo"').slice(1)
|
|
41
41
|
const results = []
|
|
42
42
|
for (const block of blocks) {
|
|
@@ -60,17 +60,17 @@ export const websearchTool = {
|
|
|
60
60
|
|
|
61
61
|
// ---------------------------------------------------------------- ls
|
|
62
62
|
|
|
63
|
-
/** SSRF
|
|
63
|
+
/** SSRF protection: block internal private-network/metadata endpoints (localhost allowed — user's dev server, tests depend on it) */
|
|
64
64
|
function isPrivateUrl(urlStr) {
|
|
65
65
|
let u
|
|
66
66
|
try { u = new URL(urlStr) } catch { return true }
|
|
67
67
|
const host = u.hostname.toLowerCase()
|
|
68
|
-
// localhost / 127.x
|
|
68
|
+
// localhost / 127.x allowed (user's dev server on local machine, test mock server)
|
|
69
69
|
if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
|
|
70
70
|
if (host === "127.0.0.1" || host.startsWith("127.")) return false
|
|
71
|
-
//
|
|
71
|
+
// cloud metadata endpoint
|
|
72
72
|
if (host === "169.254.169.254" || host === "metadata.google.internal") return true
|
|
73
|
-
// IPv4
|
|
73
|
+
// IPv4 private ranges
|
|
74
74
|
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
75
75
|
if (m) {
|
|
76
76
|
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
@@ -80,7 +80,7 @@ function isPrivateUrl(urlStr) {
|
|
|
80
80
|
if (a === 169 && b === 254) return true
|
|
81
81
|
if (a === 0) return true
|
|
82
82
|
}
|
|
83
|
-
// IPv6
|
|
83
|
+
// IPv6 loopback / link-local / unique local addresses
|
|
84
84
|
if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
|
|
85
85
|
return false
|
|
86
86
|
}
|