thincoder 0.7.0 → 0.7.1
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 +12 -1
- package/bin/thincoder.mjs +25 -1
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +1 -0
- package/src/agent.mjs +97 -62
- package/src/checkpoint.mjs +6 -3
- package/src/config.mjs +10 -4
- package/src/context.mjs +37 -8
- package/src/distill.mjs +6 -2
- package/src/embedding.mjs +11 -2
- package/src/gitmem.mjs +6 -2
- package/src/markdown.mjs +11 -4
- package/src/mcp.mjs +118 -36
- package/src/memory.mjs +214 -74
- package/src/provider.mjs +15 -11
- package/src/repomap.mjs +17 -6
- package/src/session.mjs +8 -5
- package/src/skills.mjs +6 -2
- package/src/tools/apply_patch.md +11 -0
- package/src/tools/checkpoint.md +11 -0
- package/src/tools.mjs +311 -25
- package/src/tui.mjs +44 -15
package/src/tools.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools.mjs — 内置工具集
|
|
3
|
-
* read / write / edit / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question,零依赖实现。
|
|
3
|
+
* read / write / edit / insert_after / apply_patch / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question / checkpoint,零依赖实现。
|
|
4
4
|
* 工具描述从 src/tools/*.md 加载(方便人读和修改)。
|
|
5
5
|
* readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn, execFileSync } from "node:child_process"
|
|
9
9
|
import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
|
|
10
|
-
import { readFileSync, existsSync } from "node:fs"
|
|
11
|
-
import { dirname, join, resolve, relative } from "node:path"
|
|
10
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs"
|
|
11
|
+
import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
|
|
12
12
|
import { fileURLToPath } from "node:url"
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -19,6 +19,7 @@ const MAX_READ_LINES = 2000
|
|
|
19
19
|
// 这里截断必须远高于落盘阈值,否则被截掉的内容在落盘前就永远丢了
|
|
20
20
|
const MAX_OUTPUT_CHARS = 200_000
|
|
21
21
|
const BASH_TIMEOUT_MS = 120_000
|
|
22
|
+
const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
22
23
|
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
23
24
|
|
|
24
25
|
/** 每个工具:{ name, description, parameters, readonly, execute(args, ctx) }(定义见文件末尾导出) */
|
|
@@ -48,6 +49,31 @@ function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
|
48
49
|
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** 限量读取响应体:超 limit 字节即取消流,防超大页面把整个 body 缓冲进内存 */
|
|
53
|
+
async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
54
|
+
if (!response.body) return ""
|
|
55
|
+
const reader = response.body.getReader()
|
|
56
|
+
const chunks = []
|
|
57
|
+
let total = 0
|
|
58
|
+
try {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { done, value } = await reader.read()
|
|
61
|
+
if (done) break
|
|
62
|
+
if (value) {
|
|
63
|
+
chunks.push(value)
|
|
64
|
+
total += value.length
|
|
65
|
+
}
|
|
66
|
+
if (total >= limit) {
|
|
67
|
+
await reader.cancel()
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
reader.releaseLock()
|
|
73
|
+
}
|
|
74
|
+
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
/** 创建独立的流解码器(编码嗅探:ASCII → UTF-8 → GBK 回退) */
|
|
52
78
|
function makeDecoder() {
|
|
53
79
|
let decoder = null
|
|
@@ -91,12 +117,83 @@ function gitDiffOne(cwd, abs) {
|
|
|
91
117
|
}
|
|
92
118
|
}
|
|
93
119
|
|
|
120
|
+
/** 目标可能不存在(write 新文件),逐级向上找真实存在的祖先做 realpath */
|
|
121
|
+
function realpathNearest(abs) {
|
|
122
|
+
let cur = abs
|
|
123
|
+
const tail = []
|
|
124
|
+
while (!existsSync(cur)) {
|
|
125
|
+
const parent = dirname(cur)
|
|
126
|
+
if (parent === cur) return abs // 到根了,原样返回(不会发生)
|
|
127
|
+
tail.unshift(cur.slice(parent.length + 1))
|
|
128
|
+
cur = parent
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const real = realpathSync(cur)
|
|
132
|
+
return tail.length ? join(real, ...tail) : real
|
|
133
|
+
} catch {
|
|
134
|
+
return abs
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// cwd 的 realpath 缓存:进程内 cwd 不变,只需解析一次
|
|
139
|
+
const realCwdCache = new Map()
|
|
140
|
+
function realCwd(cwd) {
|
|
141
|
+
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
142
|
+
return realCwdCache.get(cwd)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertInside(cwd, resolved, p) {
|
|
146
|
+
const rel = relative(cwd, resolved)
|
|
147
|
+
// 跨盘符时 relative 返回绝对路径(Windows);startsWith("..") 会误伤 cwd 内的 "..foo",故精确判断
|
|
148
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
149
|
+
throw new Error(`Access denied outside working directory: ${p}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
94
153
|
function resolveInCwd(ctx, p) {
|
|
95
|
-
const
|
|
96
|
-
|
|
154
|
+
const cwd = realCwd(ctx.cwd)
|
|
155
|
+
const resolved = resolve(cwd, p)
|
|
156
|
+
assertInside(cwd, resolved, p)
|
|
157
|
+
// 防 symlink 逃逸:cwd 内若存在指向外部的符号链接,realpath 后会落到 cwd 外
|
|
158
|
+
const real = realpathNearest(resolved)
|
|
159
|
+
assertInside(cwd, real, p)
|
|
97
160
|
return resolved
|
|
98
161
|
}
|
|
99
162
|
|
|
163
|
+
/**
|
|
164
|
+
* 破坏性预检用的粗切分:&& || ; | 换行 命令替换 子 shell 都视作命令边界。
|
|
165
|
+
* 宁多切不少切——防 "cd x && git checkout ."、"echo $(git checkout .)" 这类写法绕过行首锚定
|
|
166
|
+
*/
|
|
167
|
+
function shellSegments(command) {
|
|
168
|
+
return command.split(/&&|\|\||[;|\n]|\$\(|`|[(]/)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 单个命令段是否会销毁未提交改动:
|
|
173
|
+
* checkout -- / checkout . / reset --hard / clean -f* / restore(动工作区的)
|
|
174
|
+
* checkout <branch>、restore --staged、clean -n(dry-run)不算
|
|
175
|
+
*/
|
|
176
|
+
function isDestructiveGitSegment(seg) {
|
|
177
|
+
if (!/^\s*git\s/.test(seg)) return false
|
|
178
|
+
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
179
|
+
if (/\sreset\s+--hard\b/.test(seg)) return true
|
|
180
|
+
if (/\sclean\s+-\S*f/.test(seg)) return true
|
|
181
|
+
if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** cwd 是否在 git 仓库内(预检用,失败静默视为不在) */
|
|
186
|
+
function insideGitRepo(cwd) {
|
|
187
|
+
try {
|
|
188
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
189
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
190
|
+
})
|
|
191
|
+
return true
|
|
192
|
+
} catch {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
100
197
|
// ---------------------------------------------------------------- read
|
|
101
198
|
|
|
102
199
|
const readTool = {
|
|
@@ -114,6 +211,7 @@ const readTool = {
|
|
|
114
211
|
readonly: true,
|
|
115
212
|
async execute(args, ctx) {
|
|
116
213
|
const abs = resolveInCwd(ctx, args.path)
|
|
214
|
+
// 注意:整文件一次性读入内存,大文件会被完整缓冲(offset/limit 只影响返回切片)
|
|
117
215
|
const content = await readFile(abs, "utf8")
|
|
118
216
|
const lines = content.split("\n")
|
|
119
217
|
const offset = Math.max(1, args.offset ?? 1)
|
|
@@ -168,6 +266,9 @@ const editTool = {
|
|
|
168
266
|
readonly: false,
|
|
169
267
|
async execute(args, ctx) {
|
|
170
268
|
const abs = resolveInCwd(ctx, args.path)
|
|
269
|
+
if (!args.old_string) {
|
|
270
|
+
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
271
|
+
}
|
|
171
272
|
const content = await readFile(abs, "utf8")
|
|
172
273
|
const occurrences = content.split(args.old_string).length - 1
|
|
173
274
|
if (occurrences === 0) {
|
|
@@ -184,7 +285,8 @@ const editTool = {
|
|
|
184
285
|
}
|
|
185
286
|
const updated = args.replace_all
|
|
186
287
|
? content.split(args.old_string).join(args.new_string)
|
|
187
|
-
|
|
288
|
+
// 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
|
|
289
|
+
: content.replace(args.old_string, () => args.new_string)
|
|
188
290
|
await writeFile(abs, updated, "utf8")
|
|
189
291
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
190
292
|
return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
|
|
@@ -215,7 +317,10 @@ const insertAfterTool = {
|
|
|
215
317
|
let targetLine
|
|
216
318
|
if (args.after_line != null) {
|
|
217
319
|
targetLine = args.after_line
|
|
218
|
-
if (targetLine
|
|
320
|
+
if (!Number.isInteger(targetLine)) {
|
|
321
|
+
throw new Error(`after_line must be an integer, got: ${args.after_line}`)
|
|
322
|
+
}
|
|
323
|
+
if (targetLine < 1 || targetLine > lines.length) {
|
|
219
324
|
throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
|
|
220
325
|
}
|
|
221
326
|
} else if (args.after_regex) {
|
|
@@ -239,6 +344,134 @@ const insertAfterTool = {
|
|
|
239
344
|
},
|
|
240
345
|
}
|
|
241
346
|
|
|
347
|
+
// ---------------------------------------------------------------- apply_patch
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 解析统一 diff(unified diff):返回 [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
|
|
351
|
+
* 按 @@ 头的行数计数消费 hunk 行——LLM 常把上下文空行剥成纯空行,靠计数而不是行首字符判断 hunk 边界
|
|
352
|
+
*/
|
|
353
|
+
function parsePatch(patch) {
|
|
354
|
+
// 补丁文本常来自 CRLF 终端/模型输出,行尾 \r 会混进 hunk 内容导致上下文对不上,统一剥掉
|
|
355
|
+
const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
|
|
356
|
+
const files = []
|
|
357
|
+
let cur = null
|
|
358
|
+
let i = 0
|
|
359
|
+
const stripPrefix = (p) => p.replace(/^[ab]\//, "")
|
|
360
|
+
while (i < lines.length) {
|
|
361
|
+
const line = lines[i]
|
|
362
|
+
if (line.startsWith("--- ")) {
|
|
363
|
+
const oldPath = line.slice(4).trim()
|
|
364
|
+
const plus = lines[i + 1]
|
|
365
|
+
if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
|
|
366
|
+
const newPath = plus.slice(4).trim()
|
|
367
|
+
if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
|
|
368
|
+
cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
|
|
369
|
+
files.push(cur)
|
|
370
|
+
i += 2
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (line.startsWith("@@")) {
|
|
374
|
+
if (!cur) throw new Error("Malformed patch: hunk header before any file header")
|
|
375
|
+
const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
|
|
376
|
+
if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
|
|
377
|
+
let oldNeed = m[1] == null ? 1 : Number(m[1])
|
|
378
|
+
let newNeed = m[2] == null ? 1 : Number(m[2])
|
|
379
|
+
const hunk = { ops: [] }
|
|
380
|
+
i++
|
|
381
|
+
while (oldNeed > 0 || newNeed > 0) {
|
|
382
|
+
if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
|
|
383
|
+
const hl = lines[i]
|
|
384
|
+
if (hl.startsWith("\\")) { i++; continue } // ""
|
|
385
|
+
const tag = hl === "" ? " " : hl[0] // 纯空行按上下文行宽容处理
|
|
386
|
+
const text = hl === "" ? "" : hl.slice(1)
|
|
387
|
+
if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
|
|
388
|
+
else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
|
|
389
|
+
else if (tag === "+") { hunk.ops.push({ type: "+", text }); newNeed-- }
|
|
390
|
+
else throw new Error(`Malformed patch: unexpected line "${hl.slice(0, 60)}" inside hunk`)
|
|
391
|
+
i++
|
|
392
|
+
}
|
|
393
|
+
cur.hunks.push(hunk)
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
i++ // diff --git / index / 空行等元信息跳过
|
|
397
|
+
}
|
|
398
|
+
if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
|
|
399
|
+
return files
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 在内存行数组上按序应用 hunks;任何一步失败抛错(调用方保证不落盘)。比较时忽略行尾 \r,上下文行保留原始字节 */
|
|
403
|
+
function applyHunks(fileLines, hunks, eol, path) {
|
|
404
|
+
const cr = eol === "\r\n" ? "\r" : ""
|
|
405
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
406
|
+
const oldSeq = hunks[h].ops.filter((o) => o.type !== "+").map((o) => o.text)
|
|
407
|
+
if (oldSeq.length === 0) throw new Error(`Hunk ${h + 1} in ${path} has no context/removed lines to locate`)
|
|
408
|
+
const matches = []
|
|
409
|
+
for (let pos = 0; pos + oldSeq.length <= fileLines.length; pos++) {
|
|
410
|
+
let ok = true
|
|
411
|
+
for (let j = 0; j < oldSeq.length; j++) {
|
|
412
|
+
if (fileLines[pos + j].replace(/\r$/, "") !== oldSeq[j]) { ok = false; break }
|
|
413
|
+
}
|
|
414
|
+
if (ok) matches.push(pos)
|
|
415
|
+
}
|
|
416
|
+
if (matches.length === 0) {
|
|
417
|
+
const preview = oldSeq.slice(0, 3).join(" ⏎ ")
|
|
418
|
+
throw new Error(`Hunk ${h + 1} in ${path} does not apply — context not found: "${preview}${oldSeq.length > 3 ? "…" : ""}". Read the file first and regenerate the patch from actual content.`)
|
|
419
|
+
}
|
|
420
|
+
if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
|
|
421
|
+
const pos = matches[0]
|
|
422
|
+
const out = []
|
|
423
|
+
let src = pos
|
|
424
|
+
for (const op of hunks[h].ops) {
|
|
425
|
+
if (op.type === " ") out.push(fileLines[src++]) // 上下文保留原始行(行尾/空白原样)
|
|
426
|
+
else if (op.type === "-") src++
|
|
427
|
+
else out.push(op.text + cr)
|
|
428
|
+
}
|
|
429
|
+
fileLines.splice(pos, oldSeq.length, ...out)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const applyPatchTool = {
|
|
434
|
+
name: "apply_patch",
|
|
435
|
+
description: DESC("apply_patch"),
|
|
436
|
+
parameters: {
|
|
437
|
+
type: "object",
|
|
438
|
+
properties: {
|
|
439
|
+
patch: { type: "string", description: "Unified diff. May span multiple files; --- / +++ headers per file, @@ -old,count +new,count @@ hunks. Use --- /dev/null to create a file." },
|
|
440
|
+
},
|
|
441
|
+
required: ["patch"],
|
|
442
|
+
},
|
|
443
|
+
readonly: false,
|
|
444
|
+
/** 供 agent 层追踪触碰文件(多路径,替代单 path 参数) */
|
|
445
|
+
touchedPaths(args) {
|
|
446
|
+
try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
|
|
447
|
+
},
|
|
448
|
+
async execute(args, ctx) {
|
|
449
|
+
const files = parsePatch(args.patch ?? "")
|
|
450
|
+
// 先全部读入内存试算:任何一个 hunk 不上就整体抛错,不写半个补丁(原子性)
|
|
451
|
+
const planned = []
|
|
452
|
+
for (const f of files) {
|
|
453
|
+
const abs = resolveInCwd(ctx, f.path)
|
|
454
|
+
if (f.isNew) {
|
|
455
|
+
if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
|
|
456
|
+
const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
|
|
457
|
+
planned.push({ abs, path: f.path, content, isNew: true })
|
|
458
|
+
} else {
|
|
459
|
+
const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
|
|
460
|
+
const eol = original.includes("\r\n") ? "\r\n" : "\n"
|
|
461
|
+
const lines = original.split("\n")
|
|
462
|
+
applyHunks(lines, f.hunks, eol, f.path)
|
|
463
|
+
planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const p of planned) {
|
|
467
|
+
await mkdir(dirname(p.abs), { recursive: true })
|
|
468
|
+
await writeFile(p.abs, p.content, "utf8")
|
|
469
|
+
}
|
|
470
|
+
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
471
|
+
return `Applied patch to ${planned.length} file(s):\n${summary}`
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
|
|
242
475
|
// ---------------------------------------------------------------- syntax_check
|
|
243
476
|
|
|
244
477
|
const syntaxCheckTool = {
|
|
@@ -285,25 +518,38 @@ const bashTool = {
|
|
|
285
518
|
},
|
|
286
519
|
readonly: false,
|
|
287
520
|
async execute(args, ctx) {
|
|
288
|
-
// 安全预检:销毁性 git
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
521
|
+
// 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
|
|
522
|
+
if (shellSegments(args.command).some(isDestructiveGitSegment)) {
|
|
523
|
+
if (!insideGitRepo(ctx.cwd)) {
|
|
524
|
+
throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
|
|
525
|
+
}
|
|
292
526
|
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
293
527
|
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
294
528
|
}).trim()
|
|
295
529
|
if (status) {
|
|
296
530
|
throw new Error(
|
|
297
|
-
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n
|
|
531
|
+
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
|
|
532
|
+
`(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
|
|
298
533
|
)
|
|
299
534
|
}
|
|
300
535
|
}
|
|
301
536
|
|
|
302
537
|
return new Promise((resolve) => {
|
|
538
|
+
// detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
|
|
539
|
+
// Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
|
|
540
|
+
const killTree = () => {
|
|
541
|
+
if (process.platform === "win32") {
|
|
542
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
543
|
+
} else {
|
|
544
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
545
|
+
try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
|
|
546
|
+
}
|
|
547
|
+
}
|
|
303
548
|
const child = spawn(args.command, {
|
|
304
549
|
cwd: ctx.cwd,
|
|
305
550
|
shell: true,
|
|
306
551
|
windowsHide: true,
|
|
552
|
+
detached: process.platform !== "win32",
|
|
307
553
|
stdio: ["ignore", "pipe", "pipe"],
|
|
308
554
|
env: {
|
|
309
555
|
...process.env,
|
|
@@ -332,15 +578,16 @@ const bashTool = {
|
|
|
332
578
|
}
|
|
333
579
|
}
|
|
334
580
|
const onStderr = (d) => {
|
|
335
|
-
|
|
581
|
+
const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
|
|
582
|
+
if (errBuf.length < 2_000_000) errBuf += s
|
|
336
583
|
}
|
|
337
584
|
|
|
338
585
|
child.stdout.on("data", onStdout)
|
|
339
586
|
child.stderr.on("data", onStderr)
|
|
340
587
|
|
|
341
|
-
const timer = setTimeout(
|
|
588
|
+
const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
|
|
342
589
|
if (ctx.signal) {
|
|
343
|
-
ctx.signal.addEventListener("abort",
|
|
590
|
+
ctx.signal.addEventListener("abort", killTree, { once: true })
|
|
344
591
|
}
|
|
345
592
|
child.on("error", (error) => {
|
|
346
593
|
clearTimeout(timer)
|
|
@@ -416,7 +663,7 @@ function globToRegex(pattern) {
|
|
|
416
663
|
const DS = "\u0001" // **/ 的占位符(零或多级目录)
|
|
417
664
|
const DP = "\u0002" // ** 的占位符
|
|
418
665
|
const escaped = pattern
|
|
419
|
-
.replace(/[.+^${}()
|
|
666
|
+
.replace(/[.+^${}()|\\]/g, "\\$&") // [ ] 不转义,保留为 glob 字符组语法
|
|
420
667
|
.replace(/\*\*\//g, DS)
|
|
421
668
|
.replace(/\*\*/g, DP)
|
|
422
669
|
.replace(/\*/g, "[^/]*")
|
|
@@ -458,7 +705,7 @@ const grepTool = {
|
|
|
458
705
|
try {
|
|
459
706
|
content = await readFile(file, "utf8")
|
|
460
707
|
} catch {
|
|
461
|
-
return //
|
|
708
|
+
return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
|
|
462
709
|
}
|
|
463
710
|
const lines = content.split("\n")
|
|
464
711
|
if (wantCtx) fileLines.set(file, lines)
|
|
@@ -545,7 +792,7 @@ const websearchTool = {
|
|
|
545
792
|
: AbortSignal.timeout(15_000),
|
|
546
793
|
})
|
|
547
794
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
548
|
-
html = await response
|
|
795
|
+
html = await readBodyText(response)
|
|
549
796
|
} catch (error) {
|
|
550
797
|
throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
|
|
551
798
|
}
|
|
@@ -577,10 +824,10 @@ function stripTags(html) {
|
|
|
577
824
|
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
578
825
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
579
826
|
.replace(/ /g, " ")
|
|
580
|
-
.replace(/&/g, "&")
|
|
581
827
|
.replace(/</g, "<")
|
|
582
828
|
.replace(/>/g, ">")
|
|
583
829
|
.replace(/"/g, '"')
|
|
830
|
+
.replace(/&/g, "&") // & 必须最后解码,否则 &lt; 会被二次解码成 <
|
|
584
831
|
.replace(/\s+/g, " ")
|
|
585
832
|
.trim()
|
|
586
833
|
}
|
|
@@ -649,7 +896,7 @@ const fetchTool = {
|
|
|
649
896
|
if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
|
|
650
897
|
|
|
651
898
|
const contentType = response.headers.get("content-type") ?? ""
|
|
652
|
-
const body = await response
|
|
899
|
+
const body = await readBodyText(response)
|
|
653
900
|
if (!contentType.includes("text/html")) return truncate(body)
|
|
654
901
|
return truncate(htmlToText(body))
|
|
655
902
|
},
|
|
@@ -668,16 +915,16 @@ function htmlToText(html) {
|
|
|
668
915
|
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
669
916
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
670
917
|
.replace(/ | /g, " ")
|
|
671
|
-
.replace(/&/g, "&")
|
|
672
918
|
.replace(/</g, "<")
|
|
673
919
|
.replace(/>/g, ">")
|
|
674
920
|
.replace(/"/g, '"')
|
|
921
|
+
.replace(/&/g, "&") // & 必须最后解码,否则 &lt; 会被二次解码成 <
|
|
675
922
|
.replace(/[ \t]+/g, " ")
|
|
676
923
|
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
677
924
|
.trim()
|
|
678
925
|
}
|
|
679
926
|
|
|
680
|
-
export const builtinTools = [readTool, writeTool, editTool, insertAfterTool, syntaxCheckTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
|
|
927
|
+
export const builtinTools = [readTool, writeTool, editTool, insertAfterTool, applyPatchTool, syntaxCheckTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
|
|
681
928
|
|
|
682
929
|
// ---------------------------------------------------------------- delete
|
|
683
930
|
|
|
@@ -730,6 +977,8 @@ const gitDiffTool = {
|
|
|
730
977
|
readonly: true,
|
|
731
978
|
execute(args, ctx) {
|
|
732
979
|
const ref = args.ref ?? "HEAD"
|
|
980
|
+
// ref 由模型提供且位于 "--" 之前:校验字符集,防 "--output=..." 之类被 git 当成选项
|
|
981
|
+
if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
733
982
|
const flags = args.staged ? ["--staged"] : []
|
|
734
983
|
const paths = args.path ? [args.path] : []
|
|
735
984
|
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
@@ -763,7 +1012,9 @@ const gitStatusTool = {
|
|
|
763
1012
|
// 尝试匹配 "XY path" 或 "XY path"(可变间距)
|
|
764
1013
|
const m = clean.match(/^(..?)\s+(.+)$/)
|
|
765
1014
|
if (!m) continue
|
|
766
|
-
const [, status,
|
|
1015
|
+
const [, status, rawFile] = m
|
|
1016
|
+
// 重命名条目 porcelain 输出为 "R old -> new",拆开明确展示而非当成一个字面文件名
|
|
1017
|
+
const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
|
|
767
1018
|
const idx = status[0] ?? " "
|
|
768
1019
|
const wt = status[1] ?? " "
|
|
769
1020
|
if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
|
|
@@ -843,5 +1094,40 @@ function runGit(cwd, cmdArgs) {
|
|
|
843
1094
|
}
|
|
844
1095
|
}
|
|
845
1096
|
|
|
846
|
-
|
|
847
|
-
|
|
1097
|
+
// ---------------------------------------------------------------- checkpoint
|
|
1098
|
+
|
|
1099
|
+
const checkpointTool = {
|
|
1100
|
+
name: "checkpoint",
|
|
1101
|
+
description: DESC("checkpoint"),
|
|
1102
|
+
parameters: {
|
|
1103
|
+
type: "object",
|
|
1104
|
+
properties: {
|
|
1105
|
+
action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
|
|
1106
|
+
id: { type: "string", description: "Snapshot id (required for rewind)" },
|
|
1107
|
+
},
|
|
1108
|
+
required: ["action"],
|
|
1109
|
+
},
|
|
1110
|
+
readonly: false,
|
|
1111
|
+
async execute(args, ctx) {
|
|
1112
|
+
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
|
|
1113
|
+
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
1114
|
+
if (args.action === "create") {
|
|
1115
|
+
const cp = await createCheckpoint(ctx.cwd)
|
|
1116
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s) captured)`
|
|
1117
|
+
}
|
|
1118
|
+
if (args.action === "rewind") {
|
|
1119
|
+
if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
|
|
1120
|
+
const s = await rewind(ctx.cwd, args.id)
|
|
1121
|
+
return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored} untracked file(s) restored, ${s.deleted} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
1122
|
+
}
|
|
1123
|
+
if (args.action === "list") {
|
|
1124
|
+
const cps = await listCheckpoints(ctx.cwd)
|
|
1125
|
+
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
1126
|
+
return cps.map((c) => `${c.id} ${new Date(c.time).toISOString()} ${c.untracked} untracked file(s)`).join("\n")
|
|
1127
|
+
}
|
|
1128
|
+
throw new Error(`Unknown action: ${args.action}`)
|
|
1129
|
+
},
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool }
|
|
1133
|
+
builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool)
|
package/src/tui.mjs
CHANGED
|
@@ -206,7 +206,6 @@ export function layoutInput(chars, cursor, width) {
|
|
|
206
206
|
return { lines, cursorLine, cursorCol }
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/** 文本按宽度折行(保留 \n),返回行数组 */
|
|
210
209
|
/**
|
|
211
210
|
* 显示净化:控制字符会破坏终端网格数学(\r 回车覆盖、\t 宽度误判致整帧错位、ANSI/响铃冲屏)。
|
|
212
211
|
* 只动显示层——模型看到的工具结果原文不变;session 里已存的脏 display 回放时也经此净化。
|
|
@@ -222,6 +221,7 @@ export function sanitizeDisplay(s) {
|
|
|
222
221
|
.replace(/\n+$/, "")
|
|
223
222
|
}
|
|
224
223
|
|
|
224
|
+
/** 文本按宽度折行(保留 \n),返回行数组 */
|
|
225
225
|
export function wrapText(text, width) {
|
|
226
226
|
const lines = []
|
|
227
227
|
for (const rawLine of text.split("\n")) {
|
|
@@ -406,9 +406,17 @@ export async function startTUI(agent, opts = {}) {
|
|
|
406
406
|
let boxLines = inputLines
|
|
407
407
|
if (state.question) {
|
|
408
408
|
const q = state.question
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
409
|
+
if (q.options.length > 0) {
|
|
410
|
+
// 选项窗口:只显示选中项 ±2,选项过多时防输入框无限增高撑破锚定布局
|
|
411
|
+
const sel = q.selected ?? 0
|
|
412
|
+
const QWIN = 5
|
|
413
|
+
const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
|
|
414
|
+
boxLines = q.options
|
|
415
|
+
.slice(start, start + QWIN)
|
|
416
|
+
.map((opt, i) => (start + i === sel ? "▸ " : " ") + opt)
|
|
417
|
+
} else {
|
|
418
|
+
boxLines = ["▸ " + (q.answer ?? "")]
|
|
419
|
+
}
|
|
412
420
|
}
|
|
413
421
|
const inputBoxH = boxLines.length + 2
|
|
414
422
|
|
|
@@ -434,8 +442,18 @@ export async function startTUI(agent, opts = {}) {
|
|
|
434
442
|
const taskPanelH = visibleTasks.length
|
|
435
443
|
// 子 agent 流式输出占位(显示时占最多 2 行)
|
|
436
444
|
const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
|
|
437
|
-
//
|
|
438
|
-
|
|
445
|
+
// 权限预览占位:字符数之外再封顶显示行数(rows-8),多行短行也能把帧撑过终端高度,破坏锚定布局
|
|
446
|
+
let permPreviewLines = []
|
|
447
|
+
if (state.permission) {
|
|
448
|
+
const maxLines = Math.max(1, rows - 8)
|
|
449
|
+
outer: for (const l of state.permissionPreview) {
|
|
450
|
+
for (const wrapped of wrapText(` ${l}`, W - 1)) {
|
|
451
|
+
if (permPreviewLines.length >= maxLines) break outer
|
|
452
|
+
permPreviewLines.push(wrapped)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const permPreviewLen = state.permission ? 1 + permPreviewLines.length : 0
|
|
439
457
|
const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
|
|
440
458
|
|
|
441
459
|
// 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
|
|
@@ -519,13 +537,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
519
537
|
}
|
|
520
538
|
}
|
|
521
539
|
|
|
522
|
-
//
|
|
540
|
+
// 权限审批内容预览(黄色,紧挨输入框上方);用上方已封顶的 permPreviewLines,渲染行数与占位一致
|
|
523
541
|
if (state.permission) {
|
|
524
542
|
out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
|
|
525
|
-
for (const
|
|
526
|
-
|
|
527
|
-
out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
|
|
528
|
-
}
|
|
543
|
+
for (const wrapped of permPreviewLines) {
|
|
544
|
+
out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
|
|
529
545
|
}
|
|
530
546
|
}
|
|
531
547
|
|
|
@@ -697,8 +713,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
697
713
|
|
|
698
714
|
const callbacks = {
|
|
699
715
|
onToken: (t) => {
|
|
700
|
-
// 子 agent 流式输出:前缀匹配 explore/coder/plan
|
|
701
|
-
const subMatch = t.match(/^(explore|coder|plan)\//)
|
|
716
|
+
// 子 agent 流式输出:前缀匹配 explore/coder/plan/sub(无角色子 agent 用 sub/)的 token 进 subOutput
|
|
717
|
+
const subMatch = t.match(/^(explore|coder|plan|sub)\//)
|
|
702
718
|
if (subMatch) {
|
|
703
719
|
state.currentSub = subMatch[1]
|
|
704
720
|
state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
|
|
@@ -710,6 +726,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
710
726
|
scheduleRender()
|
|
711
727
|
},
|
|
712
728
|
onReasoning: (t) => {
|
|
729
|
+
// 子 agent 的思考 token 同样带 role/ 前缀,进 subOutput 滚动区,不污染主思考流
|
|
730
|
+
const subMatch = t.match(/^(explore|coder|plan|sub)\//)
|
|
731
|
+
if (subMatch) {
|
|
732
|
+
state.currentSub = subMatch[1]
|
|
733
|
+
state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
|
|
734
|
+
scheduleRender()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
713
737
|
ensureAssistantLabel()
|
|
714
738
|
state.reasoning += t
|
|
715
739
|
scheduleRender()
|
|
@@ -722,8 +746,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
722
746
|
},
|
|
723
747
|
onToolResult: (name, result) => {
|
|
724
748
|
state.currentTool = null
|
|
725
|
-
// 子 agent
|
|
726
|
-
|
|
749
|
+
// 子 agent 结束(父 agent 侧的 subagent 工具结果带着最终报告):清空流式缓冲,报告进对话区。
|
|
750
|
+
// 注意只能用精确匹配——子 agent 内部工具调用不 relay 到 TUI(刷了满屏的教训)
|
|
751
|
+
const isSubagent = name === "subagent"
|
|
727
752
|
if (isSubagent) {
|
|
728
753
|
state.subOutput = ""
|
|
729
754
|
state.currentSub = null
|
|
@@ -877,6 +902,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
877
902
|
...cap(args.new_string ?? "", 500).split("\n").map((l) => `+ ${l}`),
|
|
878
903
|
]
|
|
879
904
|
}
|
|
905
|
+
if (base === "apply_patch") {
|
|
906
|
+
// 补丁本身就是可读的 diff,直接预览
|
|
907
|
+
return cap(args.patch ?? "", 1500).split("\n")
|
|
908
|
+
}
|
|
880
909
|
if (base === "delete") return [`${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`]
|
|
881
910
|
if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
882
911
|
if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
|