thincoder 0.12.52 → 0.12.54

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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/src/acp.mjs +60 -18
  4. package/src/advisor/run.mjs +9 -11
  5. package/src/agent/dispatch.mjs +38 -13
  6. package/src/agent/setup.mjs +2 -1
  7. package/src/agent.mjs +34 -0
  8. package/src/cli/make-agent.mjs +11 -5
  9. package/src/escape.mjs +43 -8
  10. package/src/git/checkpoint.mjs +32 -6
  11. package/src/mcp/helpers.mjs +14 -5
  12. package/src/mcp/transport-http.mjs +79 -27
  13. package/src/mcp/transport-stdio.mjs +57 -3
  14. package/src/mcp/transport-ws.mjs +46 -12
  15. package/src/mcp.mjs +197 -58
  16. package/src/prompts/discipline.md +44 -1
  17. package/src/provider/anthropic.mjs +51 -18
  18. package/src/provider/core.mjs +163 -36
  19. package/src/provider/google.mjs +41 -15
  20. package/src/provider/rate.mjs +5 -0
  21. package/src/provider/responses.mjs +498 -0
  22. package/src/provider/retry.mjs +125 -0
  23. package/src/provider/sse.mjs +58 -24
  24. package/src/proxy.mjs +36 -6
  25. package/src/session-migrate.mjs +6 -0
  26. package/src/session-slots.mjs +361 -0
  27. package/src/session.mjs +267 -306
  28. package/src/tools/bash.md +2 -2
  29. package/src/tools/execute.md +1 -1
  30. package/src/tools/execute.mjs +3 -3
  31. package/src/tools/fetch.md +1 -0
  32. package/src/tools/file.mjs +136 -11
  33. package/src/tools/git-checkpoint.mjs +143 -0
  34. package/src/tools/git-ext.mjs +173 -0
  35. package/src/tools/git.md +21 -6
  36. package/src/tools/git.mjs +68 -155
  37. package/src/tools/shared.mjs +5 -3
  38. package/src/tools/system.mjs +19 -1
  39. package/src/tools/web.mjs +44 -14
  40. package/src/tools/websearch.md +3 -1
  41. package/src/tui/ansi.mjs +2 -0
  42. package/src/tui/cmd-new.mjs +6 -6
  43. package/src/tui/cmd-restore.mjs +27 -6
  44. package/src/tui/cmd-session.mjs +17 -4
  45. package/src/tui/fold-block.mjs +59 -11
  46. package/src/tui/index.mjs +59 -67
  47. package/src/tui/key-handler.mjs +3 -1
  48. package/src/tui/layout.mjs +81 -25
  49. package/src/tui/mouse.mjs +86 -8
  50. package/src/tui/render-conversation.mjs +260 -214
  51. package/src/tui/render-frame.mjs +22 -6
  52. package/src/tui/render-loop.mjs +11 -1
  53. package/src/tui/startup.mjs +1 -1
  54. package/src/tui/subagent-blocks.mjs +5 -1
  55. package/src/tui/subagent-panel.mjs +81 -0
  56. package/src/tui/tool-args.mjs +4 -0
  57. package/src/tui/tool-events.mjs +1 -1
  58. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -11,7 +11,7 @@ Parameters:
11
11
  - nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
12
12
  - workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
13
13
  - filter: optional — only return output lines matching this regex (case-insensitive)
14
- - timeoutMs: Timeout in milliseconds (default 30000, max 60000)
14
+ - timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers slow `node --test` suites and long package scripts)
15
15
 
16
16
  Notes:
17
17
  - `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
@@ -161,8 +161,8 @@ export const executeTool = {
161
161
  timeoutMs: {
162
162
  type: "integer",
163
163
  minimum: 1,
164
- maximum: 60000,
165
- description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
164
+ maximum: 600000,
165
+ description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 600000)`,
166
166
  },
167
167
  },
168
168
  required: [],
@@ -175,7 +175,7 @@ export const executeTool = {
175
175
  catch (e) { return `Error: ${e.message}` }
176
176
 
177
177
  const t = Number(args.timeoutMs)
178
- const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
178
+ const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 600_000) : DEFAULT_TIMEOUT
179
179
 
180
180
  let childArgs
181
181
  if (args.scriptFile) {
@@ -2,6 +2,7 @@ Fetch a URL and return its content as text. HTML pages are stripped to readable
2
2
 
3
3
  Parameters:
4
4
  - url (required): http/https URL
5
+ - proxy: http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); pick per target (github/foreign sites need a proxy, gitee/domestic don't)
5
6
 
6
7
  Notes:
7
8
  - Follows redirects automatically
@@ -38,6 +38,36 @@ export function markDirty(abs) { dirtyPaths.add(abs) }
38
38
  export function clearDirty(abs) { dirtyPaths.delete(abs) }
39
39
  export function isDirty(abs) { return dirtyPaths.has(abs) }
40
40
 
41
+ // 2026-08-31 工具顺手度优化(用户批准):写入工具记录受影响行范围——insert_after
42
+ // 精确判定:after_line 在未受影响区(< lastWrite.startLine)→ 行号未漂移 → 允许
43
+ // (消掉"我写的文件被当外部修改、必须重 read"的摩擦);受影响区内 → 拒绝(护栏保留);
44
+ // write 全文重写 → 全文件受影响,任何 after_line 拒绝。
45
+ const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
46
+ export function recordWrite(abs, write) {
47
+ lastWrites.set(abs, write)
48
+ dirtyPaths.delete(abs) // 本 session 写入——等效于刚 read 过(快照在 lastWrites)
49
+ }
50
+ export function lastWriteOf(abs) { return lastWrites.get(abs) }
51
+ export function clearLastWrite(abs) { lastWrites.delete(abs) }
52
+
53
+ /** 2026-08-31 工具顺手度(用户批准"可以啊"):写入工具返回带上下文窗口——
54
+ * 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
55
+ * 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
56
+ * 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
57
+ * write 全文重写跳过(无行号锚点——模型刚写的知道内容)。 */
58
+ async function appendWriteContext(abs, writeLine, baseResult) {
59
+ const content = normalizeEOL(await readFile(abs, "utf8"))
60
+ const lines = content.split("\n")
61
+ const start = Math.max(1, writeLine - 3)
62
+ const end = Math.min(lines.length, writeLine + 3)
63
+ const ctxLines = []
64
+ for (let i = start; i <= end; i++) {
65
+ const marker = i === writeLine ? "→" : " "
66
+ ctxLines.push(`${marker} L${i}\t${lines[i - 1]}`)
67
+ }
68
+ return `${baseResult}\ncontext (L${start}-L${end}):\n${ctxLines.join("\n")}`
69
+ }
70
+
41
71
  export const readTool = {
42
72
  name: "read",
43
73
  description: DESC("read"),
@@ -61,6 +91,7 @@ export const readTool = {
61
91
  const content = normalizeEOL(await readFile(abs, "utf8"))
62
92
  // A read refreshes the agent's view — line numbers are fresh again.
63
93
  clearDirty(abs)
94
+ clearLastWrite(abs) // 2026-08-31:read 同时清写入快照(新视图以 read 为准)
64
95
  const lines = content.split("\n")
65
96
  const offset = Math.max(1, args.offset ?? 1)
66
97
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
@@ -171,7 +202,7 @@ export const writeTool = {
171
202
  const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
172
203
  const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
173
204
  await writeFile(abs, content, "utf8")
174
- markDirty(abs)
205
+ recordWrite(abs, { type: "write", startLine: 1, shift: 0 }) // 全文重写——全文件受影响
175
206
  const diff = gitDiffOne(ctx.cwd, abs)
176
207
  return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
177
208
  },
@@ -189,12 +220,78 @@ export const editTool = {
189
220
  old_string: { type: "string", description: "Exact text to replace" },
190
221
  new_string: { type: "string", description: "Replacement text" },
191
222
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
223
+ edits: {
224
+ type: "array",
225
+ description: "2026-08-31 工具顺手度:一次多文件原子替换——任一失败全不写(先全量检查所有替换可执行)。与 path/old_string/new_string 互斥。",
226
+ items: {
227
+ type: "object",
228
+ properties: {
229
+ path: { type: "string" },
230
+ old_string: { type: "string" },
231
+ new_string: { type: "string" },
232
+ replace_all: { type: "boolean" },
233
+ },
234
+ required: ["path", "old_string", "new_string"],
235
+ },
236
+ },
192
237
  },
193
- required: ["path", "old_string", "new_string"],
238
+ required: [],
194
239
  },
195
240
  readonly: false,
196
- touchedPaths(args) { return args.path ? [args.path] : [] },
241
+ touchedPaths(args) {
242
+ if (args.edits) return args.edits.map((e) => e.path).filter(Boolean)
243
+ return args.path ? [args.path] : []
244
+ },
197
245
  async execute(args, ctx) {
246
+ // 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
247
+ if (args.edits) {
248
+ if (!Array.isArray(args.edits) || args.edits.length === 0) {
249
+ throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
250
+ }
251
+ if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
252
+ throw new Error("edits array is mutually exclusive with path/old_string/new_string")
253
+ }
254
+ // 原子:先全量 read+match 检查(所有文件都能替换)——任一失败全不写
255
+ const prepared = []
256
+ for (const e of args.edits) {
257
+ if (!e.path) throw new Error("each edit must have a path")
258
+ if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
259
+ const abs = resolveInCwd(ctx, e.path)
260
+ const raw = await readFile(abs, "utf8")
261
+ const content = normalizeEOL(raw)
262
+ const occurrences = content.split(e.old_string).length - 1
263
+ if (occurrences === 0) {
264
+ throw new Error(
265
+ `edit aborted (atomic — no files written): old_string not found in ${e.path}\n` +
266
+ ` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
267
+ )
268
+ }
269
+ if (occurrences > 1 && !e.replace_all) {
270
+ throw new Error(
271
+ `edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${e.path}; ` +
272
+ `provide more context or set replace_all`
273
+ )
274
+ }
275
+ const updated = e.replace_all
276
+ ? content.split(e.old_string).join(e.new_string)
277
+ : content.replace(e.old_string, () => e.new_string)
278
+ const matchIdx = content.indexOf(e.old_string)
279
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
280
+ const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
281
+ prepared.push({ abs, path: e.path, raw, updated, editStartLine, lineShift, occurrences: e.replace_all ? occurrences : 1 })
282
+ }
283
+ // 全部检查通过——逐个写
284
+ const results = []
285
+ for (const p of prepared) {
286
+ await writeFile(p.abs, joinWithEol(normalizeEOL(p.updated).split("\n"), p.raw), "utf8")
287
+ recordWrite(p.abs, { type: "edit", startLine: p.editStartLine, shift: p.lineShift })
288
+ const withCtx = await appendWriteContext(p.abs, p.editStartLine, `Edited ${p.path}: replaced ${p.occurrences} occurrence(s)`)
289
+ results.push(withCtx)
290
+ }
291
+ return results.join("\n")
292
+ }
293
+
294
+ // 单文件(现状路径)
198
295
  const abs = resolveInCwd(ctx, args.path)
199
296
  if (!args.old_string) {
200
297
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
@@ -219,7 +316,11 @@ export const editTool = {
219
316
  throw new Error(
220
317
  `old_string not found in ${args.path}\n` +
221
318
  ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
222
- ` hints: whitespace mismatch? file already changed? try reading the file first` +
319
+ (lastWriteOf(abs)?.type === "write"
320
+ ? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
321
+ : isDirty(abs)
322
+ ? ` hints: this file was modified since your last read (a prior write marked it dirty) — re-read it to refresh your copy of the content, then retry\n`
323
+ : ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
223
324
  candText
224
325
  )
225
326
  }
@@ -235,9 +336,14 @@ export const editTool = {
235
336
  // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
236
337
  // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
237
338
  await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
238
- markDirty(abs)
339
+ // 2026-08-31 工具顺手度:记录受影响区(替换首行 + 行数差)——insert_after 精确判定
340
+ const matchIdx = content.indexOf(args.old_string)
341
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
342
+ const lineShift = args.new_string.split("\n").length - args.old_string.split("\n").length
343
+ recordWrite(abs, { type: "edit", startLine: editStartLine, shift: lineShift })
239
344
  const diff = gitDiffOne(ctx.cwd, abs)
240
- return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
345
+ const baseResult = `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
346
+ return await appendWriteContext(abs, editStartLine, baseResult)
241
347
  },
242
348
  }
243
349
 
@@ -265,7 +371,24 @@ export const insertAfterTool = {
265
371
  // inserting at a drifted position (the failure mode that corrupted test
266
372
  // structure repeatedly). after_regex callers get the same gate — a stale
267
373
  // target line is just as wrong, and the rule is simpler to reason about.
268
- if (isDirty(abs)) {
374
+ // 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
375
+ // (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
376
+ // (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
377
+ const lw = lastWriteOf(abs)
378
+ if (lw && args.after_line != null) {
379
+ if (lw.type === "write") {
380
+ throw new Error(
381
+ `${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
382
+ )
383
+ }
384
+ if (args.after_line > lw.startLine) {
385
+ throw new Error(
386
+ `${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
387
+ `行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
388
+ )
389
+ }
390
+ // after_line <= startLine → 行号未漂移 → 允许
391
+ } else if (isDirty(abs)) {
269
392
  throw new Error(
270
393
  `${args.path} was modified since your last read — line numbers may be stale.\n` +
271
394
  `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
@@ -307,9 +430,10 @@ export const insertAfterTool = {
307
430
  // edit — a CRLF file must not silently become LF here either).
308
431
  const updated = joinWithEol(lines, raw)
309
432
  await writeFile(abs, updated, "utf8")
310
- markDirty(abs)
433
+ recordWrite(abs, { type: "insert", startLine: targetLine, shift: normalizeEOL(args.content).split("\n").length })
311
434
  const diff = gitDiffOne(ctx.cwd, abs)
312
- return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
435
+ const baseResult = `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
436
+ return await appendWriteContext(abs, targetLine + 1, baseResult)
313
437
  },
314
438
  }
315
439
 
@@ -399,9 +523,10 @@ export const hashlineEditTool = {
399
523
  // Write back in the file's original EOL style (same rule as edit / apply_patch).
400
524
  const updated = joinWithEol(lines, raw)
401
525
  await writeFile(abs, updated, "utf8")
402
- markDirty(abs)
526
+ recordWrite(abs, { type: "edit", startLine: pos + 1, shift: newLines.length - target.length })
403
527
  const diff = gitDiffOne(ctx.cwd, abs)
404
- return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
528
+ const baseResult = `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
529
+ return await appendWriteContext(abs, pos + 1, baseResult)
405
530
  },
406
531
  }
407
532
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * git-checkpoint.mjs — git 工具 checkpoint action 子系统(CHECKPOINT.md F2/F6)。
3
+ * git.mjs 的 checkpoint case 委托到这里:list/create/rewind/cat/versions + F6 懒清理 +
4
+ * F2/D7 提示行 + 文件树格式化。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
5
+ */
6
+ import { runGit } from "./shared.mjs"
7
+ import { escapeXml } from "../agent/helpers.mjs"
8
+ import {
9
+ createCheckpoint,
10
+ listCheckpoints,
11
+ rewind,
12
+ listFileVersions,
13
+ catFile,
14
+ isGitRepo,
15
+ deleteCheckpointsForCwd,
16
+ } from "../git/checkpoint.mjs"
17
+
18
+ /** F6 lazy fallback (CHECKPOINT.md D3): an EXTERNAL git commit (via bash / IDE — not the git
19
+ * tool) leaves this cwd's checkpoints as pre-commit state. Compare HEAD commit time
20
+ * (epoch SECONDS from %ct) against the newest snapshot's meta.time (ms): `%ct × 1000` aligns
21
+ * both to ms. HEAD newer → every snapshot predates the commit → clear all. All-or-nothing:
22
+ * any snapshot NEWER than HEAD (e.g. a manual create after the external commit) skips the
23
+ * whole clear. Best-effort — never blocks the checkpoint op. */
24
+ export async function lazyClearIfCommitted(cwd) {
25
+ try {
26
+ const cps = await listCheckpoints(cwd)
27
+ if (cps.length === 0) return
28
+ const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
29
+ const headMs = Number.parseInt(headSec, 10) * 1000
30
+ if (!Number.isFinite(headMs) || headMs <= 0) return
31
+ const newest = cps[0] // listCheckpoints returns newest → oldest
32
+ if (headMs > newest.time) await deleteCheckpointsForCwd(cwd)
33
+ } catch {
34
+ // best-effort (NF7 philosophy) — a lazy-clear failure must not break list/create
35
+ }
36
+ }
37
+
38
+ /** checkpoint case 主入口(git 工具 checkpoint action 的全部子动作)。 */
39
+ export async function executeCheckpointAction(args, ctx) {
40
+ const { checkpointAction: sub, checkpointId: id, path } = args
41
+ if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
42
+
43
+ if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
44
+
45
+ // F6 lazy fallback (list/create entry): an EXTERNAL git commit (HEAD time newer
46
+ // than the newest snapshot) means every snapshot predates a safety baseline —
47
+ // clear them. All-or-nothing: if any snapshot is newer than HEAD, skip entirely.
48
+ if (sub === "list" || sub === "create") await lazyClearIfCommitted(ctx.cwd)
49
+
50
+ if (sub === "create") {
51
+ const cp = await createCheckpoint(ctx.cwd)
52
+ return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
53
+ }
54
+ if (sub === "versions") {
55
+ if (!path) throw new Error("path is required for versions — the file whose history you want")
56
+ const versions = await listFileVersions(ctx.cwd, path)
57
+ if (versions.length === 0) return `No snapshot copies of "${path}" found (it was never part of an auto/protection snapshot).`
58
+ return (
59
+ `Historical versions of "${path}" (${versions.length}, newest first):\n` +
60
+ versions.map((v) =>
61
+ ` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
62
+ (v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
63
+ ).join("\n") +
64
+ `\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${path}"`
65
+ )
66
+ }
67
+ if (sub === "rewind") {
68
+ if (!id) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
69
+ if (!path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
70
+ const s = await rewind(ctx.cwd, id, { path })
71
+ return `Restored "${path}" (${s.type}) from checkpoint ${id}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
72
+ }
73
+ if (sub === "cat") {
74
+ if (!id) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
75
+ if (!path) throw new Error("path is required for cat — specify which file to read")
76
+ return await catFile(ctx.cwd, id, path)
77
+ }
78
+ if (sub === "list") {
79
+ const cps = await listCheckpoints(ctx.cwd)
80
+ if (cps.length === 0) return "(no checkpoints yet)"
81
+
82
+ // Specific id: show the file tree within that snapshot
83
+ if (id) {
84
+ const cp = cps.find((c) => c.id === id)
85
+ if (!cp) throw new Error(`checkpoint ${id} not found`)
86
+ return formatFileTree(cp)
87
+ }
88
+
89
+ // Overview: list of all snapshots (file names are XML-escaped: they are
90
+ // untrusted input that flows back into the model's context). F2/D7: fixed
91
+ // hint line at the tail — the recovery entry for a snapshot after an accident.
92
+ return cps.map((c) => {
93
+ const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
94
+ if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
95
+ if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
96
+ return parts.join(" ")
97
+ }).join("\n") + "\n(意外丢弃改动?checkpointAction=rewind 可恢复操作前状态)"
98
+ }
99
+ throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
100
+ }
101
+
102
+ /** Format a checkpoint's file list as a directory tree (directories first, indented display) */
103
+ function formatFileTree(cp) {
104
+ // File names are XML-escaped: untrusted input that flows back into the model's context
105
+ const all = [
106
+ ...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
107
+ ...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
108
+ ]
109
+ if (all.length === 0) return "(empty checkpoint)"
110
+
111
+ all.sort((a, b) => a.path.localeCompare(b.path))
112
+
113
+ const tree = new Map()
114
+ for (const { path, type } of all) {
115
+ const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
116
+ if (!tree.has(dir)) tree.set(dir, [])
117
+ tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
118
+ }
119
+
120
+ const lines = []
121
+ const dirs = [...tree.keys()].sort()
122
+ for (const dir of dirs) {
123
+ if (dir !== "." && !lines.includes(dir + "/")) {
124
+ const parts = dir.split("/")
125
+ for (let i = 1; i <= parts.length; i++) {
126
+ const prefix = parts.slice(0, i).join("/") + "/"
127
+ if (!lines.includes(prefix)) lines.push(prefix)
128
+ }
129
+ }
130
+ }
131
+ for (const dir of dirs) {
132
+ if (dir !== ".") {
133
+ for (const { name, type } of tree.get(dir)) {
134
+ lines.push(` ${dir}/${name}${type}`)
135
+ }
136
+ }
137
+ }
138
+ for (const { name, type } of tree.get(".") ?? []) {
139
+ lines.push(name + type)
140
+ }
141
+
142
+ return lines.join("\n")
143
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * git-ext.mjs — git 工具 F7 扩展 action(clone/init/rebase/remote/clean/switch/apply/worktree/
3
+ * archive/blame/mv)+ 共享 git 辅助函数(validateRef/runGitStrict/filterLines/snapshotBefore,
4
+ * 供 git.mjs 核心 action 复用——500 行硬限拆分)。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
5
+ */
6
+ import { runGit, truncate } from "./shared.mjs"
7
+ import { execFileSync } from "node:child_process"
8
+
9
+ /** Keep only output lines matching a regex (git filter, case-insensitive). */
10
+ export function filterLines(output, filter) {
11
+ if (!filter) return output
12
+ try {
13
+ const re = new RegExp(filter, "i")
14
+ const lines = output.split("\n").filter((l) => re.test(l))
15
+ return lines.length ? lines.join("\n") : `(no lines matched filter "${filter}")`
16
+ } catch (e) {
17
+ return `Error: filter regex invalid: ${e.message}`
18
+ }
19
+ }
20
+
21
+ /** Run git and report failure (stderr + exit code) instead of swallowing it.
22
+ * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
23
+ export function runGitStrict(cwd, cmdArgs, config = []) {
24
+ try {
25
+ const out = execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
26
+ return { ok: true, out }
27
+ } catch (e) {
28
+ return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
29
+ }
30
+ }
31
+
32
+ /** Validate a git ref / branch / tag / remote name (no option injection, no whitespace). */
33
+ export function validateRef(ref, what = "git ref") {
34
+ if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid ${what}: ${ref}`)
35
+ return ref
36
+ }
37
+
38
+ /** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
39
+ * Values are execFileSync array args (no shell injection) — still reject newlines/empty. */
40
+ export function gitConfigArgs(config) {
41
+ if (config == null) return []
42
+ if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
43
+ const out = []
44
+ for (const c of config) {
45
+ if (typeof c !== "string" || !c.trim() || c.includes("\n")) throw new Error(`invalid git -c config entry: ${String(c).slice(0, 60)}`)
46
+ out.push("-c", c)
47
+ }
48
+ return out
49
+ }
50
+
51
+ /** Snapshot the working tree before a destructive op (reset --hard / checkout file / restore /
52
+ * stash pop / branch|tag delete / clean / rebase). Best-effort — a snapshot failure must not
53
+ * block the op (the approval/permission layer is the real gate). Returns a note line or "". */
54
+ export async function snapshotBefore(ctx, label) {
55
+ try {
56
+ const { createCheckpoint, isGitRepo } = await import("../git/checkpoint.mjs")
57
+ if (!isGitRepo(ctx.cwd)) return ""
58
+ const cp = await createCheckpoint(ctx.cwd)
59
+ return `[snapshot ${cp.id} created before ${label}]\n`
60
+ } catch {
61
+ return ""
62
+ }
63
+ }
64
+
65
+ /** F7 扩展 action 主入口(git 工具 switch 的 fall-through 组委托到这里)。 */
66
+ export async function executeExtAction(args, ctx) {
67
+ switch (args.action) {
68
+ case "clone": {
69
+ // Clone a repo into a NEW directory — non-destructive (never touches existing work).
70
+ if (!args.remote) return "Error: clone requires remote (URL or local path)"
71
+ const cmdArgs = ["clone", args.remote]
72
+ if (args.path) cmdArgs.push(args.path)
73
+ const r = runGitStrict(ctx.cwd, cmdArgs, gitConfigArgs(args.config))
74
+ return r.ok ? truncate(r.out || `Cloned ${args.remote}`) : truncate(`git clone failed: ${r.err || r.out}`)
75
+ }
76
+ case "init": {
77
+ const r = runGitStrict(ctx.cwd, ["init"])
78
+ return r.ok ? truncate(r.out || "Initialized empty git repository") : truncate(`git init failed: ${r.err || r.out}`)
79
+ }
80
+ case "rebase": {
81
+ // Belt-and-braces snapshot: bare rebase refuses uncommitted changes (unless
82
+ // --autostash), but --autostash restore-failure / interrupted-rebase scenarios
83
+ // can leave the working tree damaged — the snapshot makes that recoverable.
84
+ // The snapshot line is included on FAILURE too: a rejected rebase (unstaged
85
+ // changes) is exactly when the model must know its work is protected (F1 loop).
86
+ const snap = await snapshotBefore(ctx, "rebase")
87
+ const sub = args.rebaseAction ?? "start"
88
+ const cmdArgs = ["rebase"]
89
+ if (sub === "abort") cmdArgs.push("--abort")
90
+ else if (sub === "continue") cmdArgs.push("--continue")
91
+ else { if (!args.ref) return "Error: rebase requires ref (branch/commit to rebase onto)"; cmdArgs.push(validateRef(args.ref)) }
92
+ const r = runGitStrict(ctx.cwd, cmdArgs)
93
+ return r.ok ? truncate(snap + (r.out || `Rebase ${sub} complete`)) : truncate(snap + `git rebase failed: ${r.err || r.out} — use rebaseAction=abort to abort`)
94
+ }
95
+ case "remote": {
96
+ const sub = args.remoteAction ?? "list"
97
+ if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["remote", "-v"]) || "(no remotes)", args.filter))
98
+ if (!args.remote) return `Error: remote ${sub} requires remote (name)`
99
+ validateRef(args.remote, "remote name")
100
+ if (sub === "add" || sub === "set-url") {
101
+ if (!args.remoteUrl) return `Error: remote ${sub} requires remoteUrl`
102
+ const r = runGitStrict(ctx.cwd, ["remote", sub === "add" ? "add" : "set-url", args.remote, args.remoteUrl])
103
+ return r.ok ? `Remote ${args.remote} ${sub === "add" ? "added" : "URL set"}` : truncate(`git remote ${sub} failed: ${r.err || r.out}`)
104
+ }
105
+ if (sub === "remove") {
106
+ const r = runGitStrict(ctx.cwd, ["remote", "remove", args.remote])
107
+ return r.ok ? `Remote ${args.remote} removed` : truncate(`git remote remove failed: ${r.err || r.out}`)
108
+ }
109
+ return "Error: remote requires remoteAction — use: list | add | remove | set-url"
110
+ }
111
+ case "clean": {
112
+ // Destructive: removes untracked files/dirs — snapshot first (guard parity).
113
+ // dryRun (-n) is a preview: no deletion, no snapshot.
114
+ const snap = args.dryRun ? "" : await snapshotBefore(ctx, "clean")
115
+ const cmdArgs = ["clean", args.dryRun ? "-n" : "-f", "-d"]
116
+ const r = runGitStrict(ctx.cwd, cmdArgs)
117
+ return r.ok ? truncate(snap + (r.out || (args.dryRun ? "Nothing to clean (dry run)" : "Clean complete"))) : truncate(snap + `git clean failed: ${r.err || r.out}`)
118
+ }
119
+ case "switch": {
120
+ if (!args.name) return "Error: switch requires name (branch)"
121
+ validateRef(args.name, "branch")
122
+ const cmdArgs = ["switch"]
123
+ if (args.create) cmdArgs.push("-c")
124
+ cmdArgs.push(args.name)
125
+ const r = runGitStrict(ctx.cwd, cmdArgs)
126
+ return r.ok ? truncate(r.out || `Switched to branch ${args.name}`) : truncate(`git switch failed: ${r.err || r.out}`)
127
+ }
128
+ case "apply": {
129
+ // Apply a patch — non-destructive (fails cleanly on conflict, applies nothing).
130
+ if (!args.path) return "Error: apply requires path (patch file)"
131
+ const r = runGitStrict(ctx.cwd, ["apply", "--", args.path])
132
+ return r.ok ? truncate(r.out || `Applied ${args.path}`) : truncate(`git apply failed: ${r.err || r.out}`)
133
+ }
134
+ case "worktree": {
135
+ const sub = args.worktreeAction ?? "list"
136
+ if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["worktree", "list"]) || "(no worktrees)", args.filter))
137
+ if (sub === "add") {
138
+ if (!args.path) return "Error: worktree add requires path (new worktree directory)"
139
+ const cmdArgs = ["worktree", "add", args.path]
140
+ if (args.ref) cmdArgs.push(validateRef(args.ref))
141
+ const r = runGitStrict(ctx.cwd, cmdArgs)
142
+ return r.ok ? truncate(r.out || `Worktree added at ${args.path}`) : truncate(`git worktree add failed: ${r.err || r.out}`)
143
+ }
144
+ if (sub === "remove") {
145
+ if (!args.path) return "Error: worktree remove requires path"
146
+ const r = runGitStrict(ctx.cwd, ["worktree", "remove", args.path])
147
+ return r.ok ? truncate(r.out || `Worktree removed: ${args.path}`) : truncate(`git worktree remove failed: ${r.err || r.out}`)
148
+ }
149
+ return "Error: worktree requires worktreeAction — use: list | add | remove"
150
+ }
151
+ case "archive": {
152
+ // Write a tar of a commit/branch — non-destructive (output file only).
153
+ if (!args.path) return "Error: archive requires path (output file)"
154
+ const cmdArgs = ["archive", "--format=tar", "-o", args.path]
155
+ if (args.ref) cmdArgs.push(validateRef(args.ref))
156
+ else cmdArgs.push("HEAD")
157
+ const r = runGitStrict(ctx.cwd, cmdArgs)
158
+ return r.ok ? truncate(r.out || `Archived ${args.ref ?? "HEAD"} to ${args.path}`) : truncate(`git archive failed: ${r.err || r.out}`)
159
+ }
160
+ case "blame": {
161
+ if (!args.path) return "Error: blame requires path (file)"
162
+ const out = runGit(ctx.cwd, ["blame", "--", args.path])
163
+ return truncate(out || `(no blame output for ${args.path})`)
164
+ }
165
+ case "mv": {
166
+ if (!args.path || !args.dest) return "Error: mv requires path (source) and dest (destination)"
167
+ const r = runGitStrict(ctx.cwd, ["mv", "--", args.path, args.dest])
168
+ return r.ok ? truncate(r.out || `Moved ${args.path} → ${args.dest}`) : truncate(`git mv failed: ${r.err || r.out}`)
169
+ }
170
+ default:
171
+ throw new Error(`Unknown ext action: ${args.action}`)
172
+ }
173
+ }
package/src/tools/git.md CHANGED
@@ -1,6 +1,6 @@
1
1
  Run a git command. Only works inside a git repository.
2
2
 
3
- **Route to git instead of bash:** `git status`→status, `git log`→log, `git diff`→diff, `git show`→show, `git add`→add, `git rm`→rm, `git commit -m`→commit, `git push <remote> <branch> <tag>`→push, `git tag`→tag, `git branch`→branch, `git checkout`→checkout, `git restore`→restore, `git stash`→stash, `git fetch/pull`→fetch/pull, `git reset`→reset, `git revert`→revert, `git merge`→merge, `git cherry-pick`→cherry-pick.
3
+ **Route to git instead of bash:** `git status`→status, `git log`→log, `git diff`→diff, `git show`→show, `git add`→add, `git rm`→rm, `git commit -m`→commit, `git push <remote> <branch> <tag>`→push, `git tag`→tag, `git branch`→branch, `git checkout`→checkout, `git restore`→restore, `git stash`→stash, `git fetch/pull`→fetch/pull, `git reset`→reset, `git revert`→revert, `git merge`→merge, `git cherry-pick`→cherry-pick, `git ls-remote`→ls-remote, `git clone`→clone, `git init`→init, `git rebase`→rebase, `git remote`→remote, `git clean`→clean, `git switch`→switch, `git apply`→apply, `git worktree`→worktree, `git archive`→archive, `git blame`→blame, `git mv`→mv.
4
4
 
5
5
  - action='diff': unified diff — what changed since last commit. staged=true for staged-only; ref=<ref> to compare a commit/branch; path=<dir> to scope.
6
6
  - action='status': working tree state — staged / unstaged / untracked / conflicts, categorized.
@@ -19,15 +19,29 @@ Run a git command. Only works inside a git repository.
19
19
  - action='revert': revert a commit (safe). ref=<commit> (default HEAD).
20
20
  - action='merge': merge ref=<branch/commit>; conflicts reported for you to resolve.
21
21
  - action='cherry-pick': cherry-pick ref=<commit>.
22
+ - action='ls-remote': light remote-ref check — which refs a remote has (read-only, network). remote=<origin>, ref=<branch/tag> optional, config for proxy.
23
+ - action='clone': clone a repo. remote required (URL or local path); path optional (target dir).
24
+ - action='init': init a repo in the current (work)dir.
25
+ - action='rebase': rebase onto ref. rebaseAction=start (ref required) / abort / continue(操作前自动快照,checkpointAction=rewind 恢复).
26
+ - action='remote': manage remotes. remoteAction=list / add / remove / set-url; remoteUrl for add/set-url.
27
+ - action='clean': remove untracked files/dirs. dryRun for -n preview(真删除操作前自动快照,checkpointAction=rewind 恢复).
28
+ - action='switch': switch branch. name required; create for -c (new branch).
29
+ - action='apply': apply a patch. path required (patch file).
30
+ - action='worktree': manage worktrees. worktreeAction=list / add (path, ref) / remove (path).
31
+ - action='archive': write a tar of ref (default HEAD). path required (output file).
32
+ - action='blame': file blame. path required.
33
+ - action='mv': rename/move. path (source) + dest required.
22
34
  - action='checkpoint': git snapshots. checkpointAction=list/create/rewind/cat/versions; checkpointId required for rewind/cat.
35
+ - Destructive ops (checkout -- path / restore / reset --hard / stash pop / branch|tag delete / clean / rebase) auto-snapshot first — restore via checkpointAction=rewind.
23
36
 
24
37
  Parameters:
25
- - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick
38
+ - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote / clone / init / rebase / remote / clean / switch / apply / worktree / archive / blame / mv
26
39
  - workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
27
- - path: (diff/log/add/commit/checkout/restore/rm) file or directory to scope / stage / restore
28
- - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
29
- - name: (branch/tag) the branch or tag name
30
- - remote: (push/fetch/pull) remote name (e.g. origin); default: current upstream
40
+ - config: (network actions push/fetch/pull/ls-remote/clone) git -c overrides, e.g. ["http.proxy=http://10.2.2.112:3128"] for blocked remotes
41
+ - path: (diff/log/add/commit/checkout/restore/rm/apply/archive/blame/mv/worktree) file or directory to scope / stage / restore
42
+ - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create/rebase/worktree:add/archive) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
43
+ - name: (branch/tag/switch) the branch or tag name
44
+ - remote: (push/fetch/pull/remote/clone) remote name (e.g. origin) or URL; default: current upstream
31
45
  - tags: (push) also push all tags (--tags)
32
46
  - staged: (diff) staged changes; (restore) the staged copy
33
47
  - count: (log) number of commits (default 10)
@@ -37,3 +51,4 @@ Parameters:
37
51
  - tagAction: (tag) list / create / delete — branchAction: (branch) list / create / delete / switch — stashAction: (stash) push / pop / list
38
52
  - filter: (read-only actions) keep only output lines matching this regex (case-insensitive)
39
53
  - checkpointAction: (checkpoint) list / create / rewind / cat / versions — checkpointId: snapshot id (rewind/cat)
54
+ - remoteAction: (remote) list / add / remove / set-url — remoteUrl: (remote add/set-url) URL — rebaseAction: (rebase) start / abort / continue — dryRun: (clean) -n preview — create: (switch) -c — dest: (mv) destination — worktreeAction: (worktree) list / add / remove