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.
Files changed (97) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.mjs +25 -25
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +17 -12
  5. package/src/agent/helpers.mjs +22 -11
  6. package/src/agent/setup.mjs +32 -10
  7. package/src/agent-tools/goal.mjs +7 -6
  8. package/src/agent-tools/plan.mjs +3 -3
  9. package/src/agent-tools/recent-changes.mjs +3 -3
  10. package/src/agent-tools/skill.mjs +6 -6
  11. package/src/agent-tools/subagent.mjs +19 -18
  12. package/src/agent-tools/task.mjs +4 -4
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +58 -37
  16. package/src/cli/distill-command.mjs +2 -2
  17. package/src/cli/make-agent.mjs +9 -9
  18. package/src/cli/memory-command.mjs +1 -1
  19. package/src/cli/permission.mjs +3 -3
  20. package/src/cli/setup-wizard.mjs +15 -15
  21. package/src/config.mjs +55 -53
  22. package/src/context.mjs +59 -56
  23. package/src/distill.mjs +35 -35
  24. package/src/embedding.mjs +17 -17
  25. package/src/git/checkpoint.mjs +211 -38
  26. package/src/git/gitmem.mjs +21 -20
  27. package/src/markdown.mjs +13 -13
  28. package/src/mcp/helpers.mjs +6 -1
  29. package/src/mcp/transport-http.mjs +2 -1
  30. package/src/mcp/transport-stdio.mjs +3 -2
  31. package/src/mcp/transport-ws.mjs +3 -2
  32. package/src/mcp.mjs +5 -2
  33. package/src/memory/code-index.mjs +16 -14
  34. package/src/memory/code-sync.mjs +36 -26
  35. package/src/memory/core.mjs +42 -35
  36. package/src/memory/docs.mjs +24 -15
  37. package/src/memory/schema.mjs +28 -27
  38. package/src/memory.mjs +2 -2
  39. package/src/prompts/coder.md +1 -1
  40. package/src/prompts/discipline.md +3 -0
  41. package/src/prompts/main.md +1 -2
  42. package/src/prompts/system.md +21 -16
  43. package/src/provider/core.mjs +11 -6
  44. package/src/provider/index.mjs +2 -2
  45. package/src/provider/rate.mjs +11 -11
  46. package/src/session.mjs +73 -40
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +16 -12
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/patch.mjs +20 -18
  53. package/src/tools/repomap-parse.mjs +17 -17
  54. package/src/tools/repomap.mjs +29 -29
  55. package/src/tools/shared.mjs +49 -28
  56. package/src/tools/system.mjs +163 -118
  57. package/src/tools/web.mjs +6 -6
  58. package/src/tui/agent-turn.mjs +82 -26
  59. package/src/tui/ansi.mjs +4 -2
  60. package/src/tui/clipboard.mjs +2 -2
  61. package/src/tui/cmd-auto.mjs +1 -1
  62. package/src/tui/cmd-clear.mjs +1 -1
  63. package/src/tui/cmd-config.mjs +2 -2
  64. package/src/tui/cmd-exit.mjs +2 -2
  65. package/src/tui/cmd-extract.mjs +1 -1
  66. package/src/tui/cmd-goal.mjs +4 -4
  67. package/src/tui/cmd-help.mjs +1 -1
  68. package/src/tui/cmd-init.mjs +3 -3
  69. package/src/tui/cmd-mcp.mjs +5 -5
  70. package/src/tui/cmd-model.mjs +1 -1
  71. package/src/tui/cmd-new.mjs +1 -1
  72. package/src/tui/cmd-plan.mjs +1 -1
  73. package/src/tui/cmd-reindex.mjs +2 -2
  74. package/src/tui/cmd-restore.mjs +1 -1
  75. package/src/tui/cmd-session.mjs +1 -1
  76. package/src/tui/cmd-skills.mjs +1 -1
  77. package/src/tui/cmd-think.mjs +2 -2
  78. package/src/tui/config-helpers.mjs +6 -6
  79. package/src/tui/distill-cmd.mjs +3 -3
  80. package/src/tui/index.mjs +131 -72
  81. package/src/tui/interaction.mjs +13 -12
  82. package/src/tui/key-handler.mjs +31 -23
  83. package/src/tui/layout.mjs +22 -17
  84. package/src/tui/pickers.mjs +19 -19
  85. package/src/tui/render-frame.mjs +37 -11
  86. package/src/tui/render.mjs +25 -24
  87. package/src/tui/slash-commands.mjs +14 -14
  88. package/src/tui/startup.mjs +15 -14
  89. package/src/tui/wizard.mjs +11 -11
  90. package/src/tui.mjs +2 -2
  91. package/bin/thincoder.js +0 -4
  92. package/src/tools/bash.mjs +0 -144
  93. package/src/tools/glob.mjs +0 -51
  94. package/src/tools/grep.mjs +0 -100
  95. package/src/tools/ls.mjs +0 -36
  96. package/src/tools.mjs +0 -2
  97. package/src/tui-render.mjs +0 -4
@@ -1,9 +1,9 @@
1
1
  /**
2
- * tools/shared.mjs — 共享工具函数、常量、OpenAI schema 转换
3
- * tools/file.mjs / system.mjs / web.mjs / git.mjs 导入
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
- /** 转成 OpenAI tools 参数格式 */
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
- /** 剥离 ANSI 转义序列 */
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
- /** 流解码器:编码嗅探 ASCII→UTF-8→GBK */
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 <= 3 && !decoder; 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
- /** 单文件 git diff,失败静默返回空。大 diff maxBuffer 时截断而非吞掉 */
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 溢出时 e.stdout 含已收集的部分;其他错误(非 git 仓库等)返回空
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
- execFileSync("node", ["--check", abs], { stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
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
- /** 检测 shell 输出/输入重定向(> >> < 后跟文件名)——引号内未排除,保守拦截 */
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
- /** 单命令段是否为破坏性非 git 命令(保守:宁可误拦) */
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 同时带递归(-r/-R)与强制(-f)标志:-rf / -fr / -r -f / -Rf
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 作为命令调用(排除 --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 是否在 git 仓库内 */
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
- /** HTML 标签 */
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(/&lt;/g, "<")
240
261
  .replace(/&gt;/g, ">")
241
262
  .replace(/&quot;/g, '"')
242
- .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
263
+ .replace(/&amp;/g, "&") // &amp; must be decoded last, otherwise &amp;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
- /** 执行 git 命令。maxBuffer 10MB 防大 diff/log 溢出;溢出时返回截断的部分输出而非空。 */
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 e.stdout 含部分输出,截取前 200 行返回
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
  }
@@ -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 { execFileSync } from "node:child_process";
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
- /** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
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
- // 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
47
- if (hasFileRedirection(args.command)) {
48
- throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
49
- }
50
- // 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
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
- /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS */
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
- /** glob 转正则:**\/ 匹配零或多级目录,** 跨目录,* 段内,? 段内单字符 */
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[](仅 wantCtx 时缓存)
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
- // 大文件保护:超 10MB 跳过,防 OOM
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 // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
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
- // lstat 不跟随符号链接——防 ./evil → /etc grep 读遍 /etc
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
- // 无上下文:保持原 path:line: content 格式
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
- // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
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
- // 结果块 <li class="b_algo">:<h2><a href>标题</a></h2> + <p>摘要</p>
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 防护:拒绝内网私有段/metadata 端点(localhost 放行——用户本机,测试也依赖) */
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 放行(用户本机开发服务器、测试 mock server
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
- // metadata 端点
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
  }