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.
- package/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/prompts/discipline.md +44 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +163 -36
- package/src/provider/google.mjs +41 -15
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -6
- package/src/tools/git.mjs +68 -155
- package/src/tools/shared.mjs +5 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +59 -67
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +86 -8
- package/src/tui/render-conversation.mjs +260 -214
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +11 -1
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-args.mjs +4 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tools/git.mjs
CHANGED
|
@@ -3,49 +3,25 @@ import {
|
|
|
3
3
|
truncate,
|
|
4
4
|
runGit
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
-
import { escapeXml } from "../agent/helpers.mjs";
|
|
7
6
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
import {
|
|
7
|
+
import { resolve, relative, isAbsolute, sep } from "node:path";
|
|
8
|
+
import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
|
|
9
|
+
import { executeCheckpointAction } from "./git-checkpoint.mjs";
|
|
9
10
|
|
|
10
|
-
/** Keep only output lines matching a regex (git filter, case-insensitive). */
|
|
11
|
-
function filterLines(output, filter) {
|
|
12
|
-
if (!filter) return output
|
|
13
|
-
try {
|
|
14
|
-
const re = new RegExp(filter, "i")
|
|
15
|
-
const lines = output.split("\n").filter((l) => re.test(l))
|
|
16
|
-
return lines.length ? lines.join("\n") : `(no lines matched filter "${filter}")`
|
|
17
|
-
} catch (e) {
|
|
18
|
-
return `Error: filter regex invalid: ${e.message}`
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
11
|
|
|
22
12
|
/** Run git PRESERVING per-line leading whitespace. runGit trims the WHOLE output, which
|
|
23
13
|
* strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
|
|
24
14
|
* unstaged-only first line as staged. status uses this so the staged/unstaged column survives. */
|
|
25
|
-
function runGitRaw(cwd, cmdArgs) {
|
|
15
|
+
function runGitRaw(cwd, cmdArgs, config = []) {
|
|
26
16
|
try {
|
|
27
|
-
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
|
|
17
|
+
return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
|
|
28
18
|
} catch (e) {
|
|
29
19
|
return String(e.stdout || "").replace(/\r/g, "")
|
|
30
20
|
}
|
|
31
21
|
}
|
|
32
22
|
|
|
33
|
-
/** Run git and report failure (stderr + exit code) instead of swallowing it.
|
|
34
|
-
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
|
|
35
|
-
function runGitStrict(cwd, cmdArgs) {
|
|
36
|
-
try {
|
|
37
|
-
const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
38
|
-
return { ok: true, out }
|
|
39
|
-
} catch (e) {
|
|
40
|
-
return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
23
|
|
|
44
|
-
|
|
45
|
-
function validateRef(ref, what = "git ref") {
|
|
46
|
-
if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid ${what}: ${ref}`)
|
|
47
|
-
return ref
|
|
48
|
-
}
|
|
24
|
+
|
|
49
25
|
|
|
50
26
|
/** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
|
|
51
27
|
* returns as an absolute path on Windows). */
|
|
@@ -63,19 +39,7 @@ function resolveBaseDir(cwd, workdir) {
|
|
|
63
39
|
return abs
|
|
64
40
|
}
|
|
65
41
|
|
|
66
|
-
|
|
67
|
-
* stash pop / branch|tag delete). Best-effort — a snapshot failure must not block the op
|
|
68
|
-
* (the approval/permission layer is the real gate). Returns a note line or "". */
|
|
69
|
-
async function snapshotBefore(ctx, label) {
|
|
70
|
-
try {
|
|
71
|
-
const { createCheckpoint, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
72
|
-
if (!isGitRepo(ctx.cwd)) return ""
|
|
73
|
-
const cp = await createCheckpoint(ctx.cwd)
|
|
74
|
-
return `[snapshot ${cp.id} created before ${label}]\n`
|
|
75
|
-
} catch {
|
|
76
|
-
return ""
|
|
77
|
-
}
|
|
78
|
-
}
|
|
42
|
+
|
|
79
43
|
|
|
80
44
|
export const gitTool = {
|
|
81
45
|
name: "git",
|
|
@@ -83,11 +47,11 @@ export const gitTool = {
|
|
|
83
47
|
parameters: {
|
|
84
48
|
type: "object",
|
|
85
49
|
properties: {
|
|
86
|
-
action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "add", "rm", "commit", "push", "tag", "branch", "checkout", "restore", "stash", "fetch", "pull", "reset", "revert", "merge", "cherry-pick"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick" },
|
|
50
|
+
action: { type: "string", enum: ["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"], description: "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 — clean/rebase 操作前自动快照,checkpointAction=rewind 恢复" },
|
|
87
51
|
// diff/log params
|
|
88
52
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
89
|
-
path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm) File or directory to scope to / stage / restore" },
|
|
90
|
-
ref: { type: "string", description: "(show/
|
|
53
|
+
path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm/apply/archive/blame/mv/worktree) File or directory to scope to / stage / restore(checkout/restore 操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
54
|
+
ref: { type: "string", description: "(diff/show/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)" },
|
|
91
55
|
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
92
56
|
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
93
57
|
message: { type: "string", description: "(commit) Commit message — required for commit; (stash:push) stash message" },
|
|
@@ -96,14 +60,23 @@ export const gitTool = {
|
|
|
96
60
|
name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
|
|
97
61
|
remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
|
|
98
62
|
workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
|
|
63
|
+
config: { type: "array", items: { type: "string" }, description: "(network actions: push/fetch/pull/ls-remote) git -c overrides, e.g. [\"http.proxy=http://10.2.2.112:3128\"] for blocked remotes" },
|
|
99
64
|
tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
|
|
100
|
-
mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation" },
|
|
101
|
-
tagAction: { type: "string", enum: ["list", "create", "delete"], description: "(tag) list tags / create one / delete one" },
|
|
102
|
-
branchAction: { type: "string", enum: ["list", "create", "delete", "switch"], description: "(branch) list branches / create / delete / switch to one" },
|
|
103
|
-
stashAction: { type: "string", enum: ["push", "pop", "list"], description: "(stash) push (stash now) / pop (apply+drop) / list" },
|
|
65
|
+
mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation(操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
66
|
+
tagAction: { type: "string", enum: ["list", "create", "delete"], description: "(tag) list tags / create one / delete one(delete 操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
67
|
+
branchAction: { type: "string", enum: ["list", "create", "delete", "switch"], description: "(branch) list branches / create / delete / switch to one(delete 操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
68
|
+
stashAction: { type: "string", enum: ["push", "pop", "list"], description: "(stash) push (stash now) / pop (apply+drop) / list(pop 操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
104
69
|
// checkpoint params
|
|
105
|
-
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
|
|
70
|
+
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions(rewind 可恢复操作前状态,恢复前自动快照可逆)" },
|
|
106
71
|
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
72
|
+
// F7 new-action params
|
|
73
|
+
remoteAction: { type: "string", enum: ["list", "add", "remove", "set-url"], description: "(remote) list remotes / add / remove / set-url" },
|
|
74
|
+
remoteUrl: { type: "string", description: "(remote add/set-url) Remote URL (https/git/ssh or local path)" },
|
|
75
|
+
rebaseAction: { type: "string", enum: ["start", "abort", "continue"], description: "(rebase) start (ref required) / abort / continue(操作前自动快照,checkpointAction=rewind 恢复)" },
|
|
76
|
+
dryRun: { type: "boolean", description: "(clean) preview only (-n) — no deletion, no snapshot; real clean 操作前自动快照,checkpointAction=rewind 恢复" },
|
|
77
|
+
create: { type: "boolean", description: "(switch) create the branch then switch (-c)" },
|
|
78
|
+
dest: { type: "string", description: "(mv) destination path (file or directory)" },
|
|
79
|
+
worktreeAction: { type: "string", enum: ["list", "add", "remove"], description: "(worktree) list / add (path, ref) / remove (path)" },
|
|
107
80
|
},
|
|
108
81
|
required: ["action"],
|
|
109
82
|
},
|
|
@@ -112,6 +85,9 @@ export const gitTool = {
|
|
|
112
85
|
// workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
|
|
113
86
|
// every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
|
|
114
87
|
if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
|
|
88
|
+
// git -c overrides (proxy etc.) — only network actions need them; passing to every
|
|
89
|
+
// action would be harmless but noisy. cfgArgs stays [] for local ops.
|
|
90
|
+
const cfgArgs = gitConfigArgs(args.config)
|
|
115
91
|
switch (args.action) {
|
|
116
92
|
case "diff": {
|
|
117
93
|
const ref = args.ref ?? "HEAD"
|
|
@@ -186,7 +162,18 @@ export const gitTool = {
|
|
|
186
162
|
const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
187
163
|
const parts = []
|
|
188
164
|
if (add.out) parts.push(add.out)
|
|
189
|
-
if (commit.ok) {
|
|
165
|
+
if (commit.ok) {
|
|
166
|
+
if (commit.out) parts.push(commit.out)
|
|
167
|
+
// F6: commit = new safety baseline — clear this project's checkpoints
|
|
168
|
+
// (best-effort per NF7: a failed cleanup never blocks the commit result).
|
|
169
|
+
try {
|
|
170
|
+
const { deleteCheckpointsForCwd } = await import("../git/checkpoint.mjs")
|
|
171
|
+
await deleteCheckpointsForCwd(ctx.cwd)
|
|
172
|
+
parts.push("(checkpoints cleared — commit is a new safety baseline)")
|
|
173
|
+
} catch (e) {
|
|
174
|
+
parts.push(`(checkpoint cleanup skipped: ${e.message})`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
190
177
|
else parts.push(`git commit failed: ${commit.err || "(no output)"}`)
|
|
191
178
|
return truncate(parts.join("\n") || "(commit produced no output)")
|
|
192
179
|
}
|
|
@@ -195,9 +182,19 @@ export const gitTool = {
|
|
|
195
182
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
196
183
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
197
184
|
if (args.tags) cmdArgs.push("--tags")
|
|
198
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
185
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
199
186
|
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
200
187
|
}
|
|
188
|
+
case "ls-remote": {
|
|
189
|
+
// Lightweight remote-ref check (which refs a remote has) — network action,
|
|
190
|
+
// read-only, no snapshot. Config plumbing for blocked/gated remotes.
|
|
191
|
+
const cmdArgs = ["ls-remote"]
|
|
192
|
+
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
193
|
+
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
194
|
+
const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
195
|
+
if (!out) return "(no refs / remote unreachable)"
|
|
196
|
+
return truncate(filterLines(out, args.filter))
|
|
197
|
+
}
|
|
201
198
|
case "add": {
|
|
202
199
|
// Granular staging: stage `path` when given, else all changes (add -A).
|
|
203
200
|
const cmdArgs = args.path ? ["add", "--", args.path] : ["add", "-A"]
|
|
@@ -293,14 +290,14 @@ export const gitTool = {
|
|
|
293
290
|
const cmdArgs = ["fetch"]
|
|
294
291
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
295
292
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
296
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
293
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
297
294
|
return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
|
|
298
295
|
}
|
|
299
296
|
case "pull": {
|
|
300
297
|
const cmdArgs = ["pull"]
|
|
301
298
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
302
299
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
303
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
300
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
304
301
|
return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
|
|
305
302
|
}
|
|
306
303
|
case "reset": {
|
|
@@ -330,66 +327,24 @@ export const gitTool = {
|
|
|
330
327
|
const r = runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
|
|
331
328
|
return r.ok ? truncate(r.out || `Cherry-picked ${args.ref}`) : truncate(`git cherry-pick failed: ${r.err || r.out}`)
|
|
332
329
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
// F7 扩展 action + checkpoint:实现拆在 git-ext.mjs / git-checkpoint.mjs(500 行硬限)
|
|
331
|
+
case "clone":
|
|
332
|
+
case "init":
|
|
333
|
+
case "rebase":
|
|
334
|
+
case "remote":
|
|
335
|
+
case "clean":
|
|
336
|
+
case "switch":
|
|
337
|
+
case "apply":
|
|
338
|
+
case "worktree":
|
|
339
|
+
case "archive":
|
|
340
|
+
case "blame":
|
|
341
|
+
case "mv":
|
|
342
|
+
return executeExtAction(args, ctx)
|
|
343
|
+
case "checkpoint":
|
|
344
|
+
return executeCheckpointAction(args, ctx)
|
|
339
345
|
|
|
340
|
-
if (sub === "create") {
|
|
341
|
-
const cp = await createCheckpoint(ctx.cwd)
|
|
342
|
-
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
343
|
-
}
|
|
344
|
-
if (sub === "versions") {
|
|
345
|
-
if (!args.path) throw new Error("path is required for versions — the file whose history you want")
|
|
346
|
-
const versions = await listFileVersions(ctx.cwd, args.path)
|
|
347
|
-
if (versions.length === 0) return `No snapshot copies of "${args.path}" found (it was never part of an auto/protection snapshot).`
|
|
348
|
-
return (
|
|
349
|
-
`Historical versions of "${args.path}" (${versions.length}, newest first):\n` +
|
|
350
|
-
versions.map((v) =>
|
|
351
|
-
` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
|
|
352
|
-
(v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
|
|
353
|
-
).join("\n") +
|
|
354
|
-
`\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${args.path}"`
|
|
355
|
-
)
|
|
356
|
-
}
|
|
357
|
-
if (sub === "rewind") {
|
|
358
|
-
if (!args.checkpointId) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
359
|
-
if (!args.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.")
|
|
360
|
-
const s = await rewind(ctx.cwd, args.checkpointId, { path: args.path })
|
|
361
|
-
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
|
|
362
|
-
}
|
|
363
|
-
if (sub === "cat") {
|
|
364
|
-
if (!args.checkpointId) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
365
|
-
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
366
|
-
const { catFile } = await import("../git/checkpoint.mjs")
|
|
367
|
-
return await catFile(ctx.cwd, args.checkpointId, args.path)
|
|
368
|
-
}
|
|
369
|
-
if (sub === "list") {
|
|
370
|
-
const cps = await listCheckpoints(ctx.cwd)
|
|
371
|
-
if (cps.length === 0) return "(no checkpoints yet)"
|
|
372
|
-
|
|
373
|
-
// Specific id: show the file tree within that snapshot
|
|
374
|
-
if (args.checkpointId) {
|
|
375
|
-
const cp = cps.find((c) => c.id === args.checkpointId)
|
|
376
|
-
if (!cp) throw new Error(`checkpoint ${args.checkpointId} not found`)
|
|
377
|
-
return formatFileTree(cp)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Overview: list of all snapshots (file names are XML-escaped: they are
|
|
381
|
-
// untrusted input that flows back into the model's context)
|
|
382
|
-
return cps.map((c) => {
|
|
383
|
-
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
384
|
-
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
|
|
385
|
-
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
|
|
386
|
-
return parts.join(" ")
|
|
387
|
-
}).join("\n")
|
|
388
|
-
}
|
|
389
|
-
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
390
|
-
}
|
|
391
346
|
default:
|
|
392
|
-
return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | add | rm | commit | push | tag | branch | checkout | restore | stash | fetch | pull | reset | revert | merge | cherry-pick`
|
|
347
|
+
return `Unknown action '${args.action}'. Use: 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`
|
|
393
348
|
}
|
|
394
349
|
},
|
|
395
350
|
}
|
|
@@ -418,45 +373,3 @@ export const questionTool = {
|
|
|
418
373
|
},
|
|
419
374
|
}
|
|
420
375
|
|
|
421
|
-
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
422
|
-
function formatFileTree(cp) {
|
|
423
|
-
// File names are XML-escaped: untrusted input that flows back into the model's context
|
|
424
|
-
const all = [
|
|
425
|
-
...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
|
|
426
|
-
...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
|
|
427
|
-
]
|
|
428
|
-
if (all.length === 0) return "(empty checkpoint)"
|
|
429
|
-
|
|
430
|
-
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
431
|
-
|
|
432
|
-
const tree = new Map()
|
|
433
|
-
for (const { path, type } of all) {
|
|
434
|
-
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
435
|
-
if (!tree.has(dir)) tree.set(dir, [])
|
|
436
|
-
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const lines = []
|
|
440
|
-
const dirs = [...tree.keys()].sort()
|
|
441
|
-
for (const dir of dirs) {
|
|
442
|
-
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
443
|
-
const parts = dir.split("/")
|
|
444
|
-
for (let i = 1; i <= parts.length; i++) {
|
|
445
|
-
const prefix = parts.slice(0, i).join("/") + "/"
|
|
446
|
-
if (!lines.includes(prefix)) lines.push(prefix)
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
for (const dir of dirs) {
|
|
451
|
-
if (dir !== ".") {
|
|
452
|
-
for (const { name, type } of tree.get(dir)) {
|
|
453
|
-
lines.push(` ${dir}/${name}${type}`)
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
for (const { name, type } of tree.get(".") ?? []) {
|
|
458
|
-
lines.push(name + type)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return lines.join("\n")
|
|
462
|
-
}
|
package/src/tools/shared.mjs
CHANGED
|
@@ -447,10 +447,12 @@ export function htmlToText(html) {
|
|
|
447
447
|
.trim()
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
|
-
|
|
450
|
+
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
|
+
* config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
|
|
452
|
+
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy. */
|
|
453
|
+
export function runGit(cwd, cmdArgs, config = []) {
|
|
452
454
|
try {
|
|
453
|
-
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
455
|
+
return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
454
456
|
} catch (e) {
|
|
455
457
|
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
456
458
|
// (callers show "(truncated)"-style tails). ALL OTHER errors (non-git repo,
|
package/src/tools/system.mjs
CHANGED
|
@@ -31,6 +31,22 @@ function applyLineFilter(output, filter) {
|
|
|
31
31
|
return truncate(lines.join("\n"))
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** POSIX-only constructs that cmd.exe reads literally (and thus breaks). Detected
|
|
35
|
+
* so the agent is told IMMEDIATELY instead of chasing a confusing failure — warning
|
|
36
|
+
* only, never a block (the approval layer is the real gate, same as destructive-command policy). */
|
|
37
|
+
function posixSyntaxHint(command) {
|
|
38
|
+
const hits = []
|
|
39
|
+
if (/\$\([^)]*\)/.test(command)) hits.push("$(...)")
|
|
40
|
+
if (command.includes("`")) hits.push("backtick")
|
|
41
|
+
if (/;\s+/.test(command)) hits.push("';' separators (cmd.exe needs && or newline)")
|
|
42
|
+
if (/2>\s*\/dev\/null|>\s*\/dev\/null|&>\s*\/dev\/null/.test(command)) hits.push("/dev/null (use NUL)")
|
|
43
|
+
if (/'.*'/.test(command)) hits.push("single quotes (cmd.exe doesn't group)")
|
|
44
|
+
if (/\$\{[A-Za-z_]/.test(command)) hits.push("${VAR} (use %VAR%)")
|
|
45
|
+
if (!hits.length) return ""
|
|
46
|
+
return "[hint: POSIX-only construct(s) detected — " + hits.join(", ") + ". Current shell is cmd.exe; these will NOT work. Use && / NUL / %VAR%, or use the execute tool (node) for complex logic]"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
// ====================================================================
|
|
35
51
|
// bash — command execution with safety gates
|
|
36
52
|
// ====================================================================
|
|
@@ -259,7 +275,9 @@ export const bashTool = {
|
|
|
259
275
|
shell: ctx.agent?.config?.shell ?? null,
|
|
260
276
|
})
|
|
261
277
|
const filtered = args.filter ? applyLineFilter(result, args.filter) : result
|
|
262
|
-
|
|
278
|
+
const hint = posixSyntaxHint(args.command)
|
|
279
|
+
const body = guard ? `${guard.notice}\n\n${filtered}` : filtered
|
|
280
|
+
return hint ? `${hint}\n${body}` : body
|
|
263
281
|
},
|
|
264
282
|
}
|
|
265
283
|
|
package/src/tools/web.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.mjs";
|
|
2
2
|
import { URL } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { proxyFetch } from "../proxy.mjs";
|
|
4
4
|
|
|
5
5
|
export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
6
6
|
const FETCH_TIMEOUT = 15_000
|
|
@@ -41,8 +41,9 @@ const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBi
|
|
|
41
41
|
const ENGINE_NAMES = ENGINES.map(e => e.name)
|
|
42
42
|
|
|
43
43
|
/** Structured search via Tavily (optional — config.websearch.apiKey). Returns
|
|
44
|
-
* { engine, results } or null to fall back to Bing HTML scraping.
|
|
45
|
-
|
|
44
|
+
* { engine, results } or null to fall back to Bing HTML scraping.
|
|
45
|
+
* proxyUri: explicit per-call proxy (args.proxy) — never the config.json one. */
|
|
46
|
+
async function fetchTavily(query, limit, ctx, proxyUri) {
|
|
46
47
|
const apiKey = ctx?.agent?.config?.websearch?.apiKey
|
|
47
48
|
if (!apiKey) return null
|
|
48
49
|
const ctrl = new AbortController()
|
|
@@ -53,7 +54,7 @@ async function fetchTavily(query, limit, ctx) {
|
|
|
53
54
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
|
|
54
55
|
body: JSON.stringify({ query, search_depth: "basic", max_results: limit, include_answer: false, include_raw_content: false }),
|
|
55
56
|
signal: ctrl.signal,
|
|
56
|
-
},
|
|
57
|
+
}, proxyUri)
|
|
57
58
|
if (!response.ok) return null
|
|
58
59
|
const data = await response.json()
|
|
59
60
|
const results = (Array.isArray(data.results) ? data.results : []).map((r) => ({
|
|
@@ -64,14 +65,14 @@ async function fetchTavily(query, limit, ctx) {
|
|
|
64
65
|
finally { clearTimeout(timer) }
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
async function fetchEngine(engine, query, page,
|
|
68
|
+
async function fetchEngine(engine, query, page, proxyUri) {
|
|
68
69
|
const ctrl = new AbortController()
|
|
69
70
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
|
|
70
71
|
try {
|
|
71
72
|
const response = await proxyFetch(engine.url(query, page), {
|
|
72
73
|
headers: { "User-Agent": engine.ua, "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8" },
|
|
73
74
|
signal: ctrl.signal,
|
|
74
|
-
},
|
|
75
|
+
}, proxyUri)
|
|
75
76
|
if (!response.ok) return null
|
|
76
77
|
const html = await response.text()
|
|
77
78
|
const results = engine.extract(html)
|
|
@@ -90,6 +91,7 @@ export const websearchTool = {
|
|
|
90
91
|
limit: { type: "number", description: "Max results (default 8, max 20)" },
|
|
91
92
|
engine: { type: "string", enum: ENGINE_NAMES, description: "Specific engine — \"bing\" (Bing). Omit to search all engines concurrently." },
|
|
92
93
|
page: { type: "number", description: "Page number for pagination (1-based, default 1). Only used when engine is specified." },
|
|
94
|
+
proxy: { type: "string", description: "http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling: fixed config breaks domestic sites)" },
|
|
93
95
|
},
|
|
94
96
|
required: ["query"],
|
|
95
97
|
},
|
|
@@ -97,20 +99,23 @@ export const websearchTool = {
|
|
|
97
99
|
async execute(args, ctx) {
|
|
98
100
|
const limit = Math.min(args.limit ?? 8, 20)
|
|
99
101
|
const page = Math.max(1, args.page ?? 1)
|
|
102
|
+
// 2026-08-31 ruling: proxy is a PER-CALL decision (args.proxy), never the config.json
|
|
103
|
+
// fixed configuration. Model picks by target: github/foreign → pass proxy; gitee/domestic → omit.
|
|
104
|
+
const proxyUri = args.proxy ?? null
|
|
100
105
|
// Structured search first when a Tavily key is configured — stable, dated,
|
|
101
106
|
// no HTML scraping. Falls back to Bing silently.
|
|
102
|
-
const tavily = await fetchTavily(args.query, limit, ctx)
|
|
107
|
+
const tavily = await fetchTavily(args.query, limit, ctx, proxyUri)
|
|
103
108
|
if (tavily && tavily.results.length > 0) {
|
|
104
109
|
return truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
|
|
105
110
|
}
|
|
106
111
|
if (args.engine) {
|
|
107
112
|
const engine = ENGINES.find(e => e.name === args.engine)
|
|
108
113
|
if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
|
|
109
|
-
const fetched = await fetchEngine(engine, args.query, page,
|
|
114
|
+
const fetched = await fetchEngine(engine, args.query, page, proxyUri)
|
|
110
115
|
if (!fetched || fetched.results.length === 0) return "(no results)"
|
|
111
116
|
return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
|
|
112
117
|
}
|
|
113
|
-
const promises = ENGINES.map(e => fetchEngine(e, args.query, 1,
|
|
118
|
+
const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, proxyUri))
|
|
114
119
|
const fetched = (await Promise.all(promises)).filter(Boolean)
|
|
115
120
|
if (fetched.length === 0) return "(no results)"
|
|
116
121
|
const merged = [], indexes = fetched.map(() => 0)
|
|
@@ -156,16 +161,39 @@ export function resolveRedirectTarget(loc, baseUrl) {
|
|
|
156
161
|
return { target }
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
/** Heuristic: a fetch result that is a near-empty shell or a region-block page hides
|
|
165
|
+
* the real content. Flags the pattern so the model switches strategy instead of
|
|
166
|
+
* re-fetching blind (2026-08-31: 21-char MiniMax SPA shell + "App unavailable in
|
|
167
|
+
* region" Claude page both wasted an hour of round trips). */
|
|
168
|
+
export function detectSparseHtml(html, text) {
|
|
169
|
+
if (text.length >= 300) return ""
|
|
170
|
+
if (/unavailable in (your )?region|not available in (your )?region|app-unavailable|enable.?javascript|just a moment|attention required|cf-browser-verification/i.test(html)) {
|
|
171
|
+
return "\n\n[fetch hint: page looks region-blocked or JS-gated (body <300 chars). Try a search MCP tool (configured provider) or the site's .md/raw/mirror endpoint]"
|
|
172
|
+
}
|
|
173
|
+
if (/<div[^>]+id="(app|root)"[^>]*>\s*<\/div>|id="app"|id="root"/i.test(html)) {
|
|
174
|
+
return "\n\n[fetch hint: page is a JS-rendered SPA shell (body <300 chars) — content loads client-side. Try a search MCP tool or the site's .md/API endpoint]"
|
|
175
|
+
}
|
|
176
|
+
return ""
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
export const fetchTool = {
|
|
160
180
|
name: "fetch",
|
|
161
181
|
description: DESC("fetch"),
|
|
162
|
-
parameters: {
|
|
182
|
+
parameters: {
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: {
|
|
185
|
+
url: { type: "string", description: "http/https URL" },
|
|
186
|
+
proxy: { type: "string", description: "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)" },
|
|
187
|
+
},
|
|
188
|
+
required: ["url"],
|
|
189
|
+
},
|
|
163
190
|
readonly: true,
|
|
164
|
-
async execute(args,
|
|
191
|
+
async execute(args, _ctx) {
|
|
165
192
|
if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
|
|
166
193
|
if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
|
|
167
194
|
try {
|
|
168
|
-
|
|
195
|
+
// 2026-08-31 ruling: per-call decision — args.proxy when passed, direct otherwise.
|
|
196
|
+
const proxyUri = args.proxy ?? null
|
|
169
197
|
const response = await proxyFetch(args.url, { headers: { "User-Agent": UA } }, proxyUri)
|
|
170
198
|
if (!response.ok) {
|
|
171
199
|
if ([301, 302, 307, 308].includes(response.status)) {
|
|
@@ -179,14 +207,16 @@ export const fetchTool = {
|
|
|
179
207
|
if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
|
|
180
208
|
const ct2 = headerOf(r2, "content-type") ?? ""
|
|
181
209
|
const b2 = await r2.text()
|
|
182
|
-
|
|
210
|
+
if (ct2.includes("text/html")) { const t = htmlToText(b2); return truncate(t + detectSparseHtml(b2, t)) }
|
|
211
|
+
return truncate(b2)
|
|
183
212
|
}
|
|
184
213
|
}
|
|
185
214
|
throw new Error(`fetch failed: HTTP ${response.status}`)
|
|
186
215
|
}
|
|
187
216
|
const ct = headerOf(response, "content-type") ?? ""
|
|
188
217
|
const body = await response.text()
|
|
189
|
-
|
|
218
|
+
if (ct.includes("text/html")) { const t = htmlToText(body); return truncate(t + detectSparseHtml(body, t)) }
|
|
219
|
+
return truncate(body)
|
|
190
220
|
} catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`, { cause: e }) }
|
|
191
221
|
},
|
|
192
222
|
}
|
package/src/tools/websearch.md
CHANGED
|
@@ -5,9 +5,11 @@ Parameters:
|
|
|
5
5
|
- limit: Max results (default 8, max 20)
|
|
6
6
|
- engine: Specific engine to use — "bing" (Bing). Omit to search all engines concurrently.
|
|
7
7
|
- page: Page number for pagination (1-based, default 1). Only used when engine is specified.
|
|
8
|
+
- 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); Bing/foreign sites usually need a proxy, domestic targets don't
|
|
8
9
|
|
|
9
10
|
Notes:
|
|
10
11
|
- Before searching the web, call `memory_search` first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
|
|
11
12
|
- Use this for information that is NOT in the local codebase — current docs, error messages, API references
|
|
12
13
|
- Follow up with `fetch` to read full pages from the results
|
|
13
|
-
-
|
|
14
|
+
- **Weak engine warning**: Bing's index is noisy for technical queries — if a first websearch returns irrelevant/townhall-grade results, DO NOT retry the same query. Configure a search MCP tool (e.g. `glm-websearch` via the MCP config) for technical lookups; websearch is the fallback.
|
|
15
|
+
- Proxy: NOT auto-applied from config.json (2026-08-31 ruling). Pass `proxy: "http://host:port"` explicitly when the target needs one; omit for domestic targets.
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const ansi = {
|
|
|
25
25
|
restoreCursor: `${ESC}8`, // DECRC — restore cursor position
|
|
26
26
|
syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
|
|
27
27
|
syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
|
|
28
|
+
wrapOff: `${ESC}[?7l`, // DECRST 7 — disable auto-wrap: over-wide rows hard-truncate at the margin instead of wrapping to the next physical line (2026-08-31 会诊:Ambiguous 宽度字符如 │/—/●/▸ 在中文 locale 终端渲染 2 格而 stringWidth 按 1 格算 → 行实际超宽 → wrap 污染下一物理行 + \x1b[K 清错行 → picker 残影)
|
|
29
|
+
wrapOn: `${ESC}[?7h`, // DECSET 7 — restore auto-wrap (TUI exit; also re-enable per-frame after write)
|
|
28
30
|
reset: `${ESC}[0m`,
|
|
29
31
|
dim: `${ESC}[2m`,
|
|
30
32
|
bold: `${ESC}[1m`,
|
package/src/tui/cmd-new.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newSession } from "../session.mjs"
|
|
1
|
+
import { newSession, resetSessionState } from "../session.mjs"
|
|
2
2
|
import { C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
4
|
/** /new command: start a new session in a fresh slot.
|
|
@@ -8,11 +8,11 @@ export async function handleNewCommand(ctx) {
|
|
|
8
8
|
|
|
9
9
|
const doNewSession = () => {
|
|
10
10
|
const slot = newSession(agent.cwd)
|
|
11
|
-
|
|
12
|
-
agent.
|
|
13
|
-
|
|
14
|
-
agent
|
|
15
|
-
agent.
|
|
11
|
+
// 2026-08-31 会诊 F3:resetSessionState 清全量会话态(_fullHistory/title/_sessionStart/
|
|
12
|
+
// _engDesignToken/压缩与验证计数等)——原实现只清 agent.history,新会话首次落盘把旧
|
|
13
|
+
// 会话完整人类线 + 旧标题写进新 slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
|
|
14
|
+
resetSessionState(agent)
|
|
15
|
+
agent._slot = slot
|
|
16
16
|
state.tasks = []
|
|
17
17
|
state.lines = []
|
|
18
18
|
state.streaming = ""
|
package/src/tui/cmd-restore.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/** /restore command:
|
|
4
|
-
*
|
|
3
|
+
/** /restore command: pick a snapshot, then pick ONE file from it to restore.
|
|
4
|
+
* Full restore is disabled (v2) — this is the user-side per-file recovery entry
|
|
5
|
+
* (CHECKPOINT.md D8). ctx: { agent, showPicker, pushLine, pushLabel } */
|
|
5
6
|
export async function handleRestoreCommand(ctx) {
|
|
6
7
|
const { agent, showPicker, pushLine, pushLabel } = ctx
|
|
7
8
|
const { listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
@@ -9,25 +10,45 @@ export async function handleRestoreCommand(ctx) {
|
|
|
9
10
|
pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
|
|
10
11
|
return
|
|
11
12
|
}
|
|
13
|
+
// F6 lazy fallback——与 git 工具 checkpoint list/create 入口一致(git-checkpoint.mjs):外部
|
|
14
|
+
// git commit 后(HEAD 时间 > 最新快照)先清空过期快照,/restore 不列出 commit 前状态。
|
|
15
|
+
const { lazyClearIfCommitted } = await import("../tools/git-checkpoint.mjs")
|
|
16
|
+
await lazyClearIfCommitted(agent.cwd)
|
|
12
17
|
const cps = await listCheckpoints(agent.cwd)
|
|
13
18
|
if (cps.length === 0) {
|
|
14
19
|
pushLine("(no checkpoints — created automatically before each task)", C.dim)
|
|
15
20
|
return
|
|
16
21
|
}
|
|
22
|
+
// Level 1: pick a snapshot (untracked shown as array length — CHECKPOINT.md D8).
|
|
17
23
|
const entries = [
|
|
18
|
-
{ type: "header", text: "Checkpoints (↑↓ select, Enter
|
|
24
|
+
{ type: "header", text: "Checkpoints — 全量恢复已禁用,逐文件恢复 (↑↓ select, Enter next, Esc cancel)" },
|
|
19
25
|
...cps.slice(0, 12).map((cp) => ({
|
|
20
26
|
type: "item",
|
|
21
|
-
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
|
|
27
|
+
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${(cp.untracked ?? []).length} untracked files)`,
|
|
22
28
|
id: cp.id,
|
|
23
29
|
})),
|
|
24
30
|
]
|
|
25
31
|
const e = await showPicker("Restore Checkpoint", entries)
|
|
26
32
|
if (!e) return
|
|
33
|
+
const cp = cps.find((c) => c.id === e.id)
|
|
34
|
+
if (!cp) return
|
|
35
|
+
// Level 2: pick a file from the snapshot's tracked/untracked merged list.
|
|
36
|
+
const files = [...(cp.tracked ?? []), ...(cp.untracked ?? [])]
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
pushLine("该快照无文件,无法逐文件恢复", C.dim)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const fileEntries = [
|
|
42
|
+
{ type: "header", text: `Files in ${cp.id} (↑↓ select, Enter restore, Esc cancel)` },
|
|
43
|
+
...files.map((f) => ({ type: "item", text: f, id: f })),
|
|
44
|
+
]
|
|
45
|
+
const fe = await showPicker("Restore File", fileEntries)
|
|
46
|
+
if (!fe) return
|
|
27
47
|
try {
|
|
28
|
-
|
|
48
|
+
// v2 return: { path, type, restored } — rewind snapshots current state first (reversible).
|
|
49
|
+
const summary = await rewind(agent.cwd, cp.id, { path: fe.id })
|
|
29
50
|
pushLabel(`❯ Rewind`, ansi.bold + C.warn)
|
|
30
|
-
pushLine(`Restored
|
|
51
|
+
pushLine(`Restored ${summary.type}: ${summary.path}${summary.restored ? "" : " — nothing restored (snapshot copy missing)"}`, C.tool)
|
|
31
52
|
pushLine("(current state saved as new checkpoint; /restore again to go back)", C.dim)
|
|
32
53
|
} catch (error) {
|
|
33
54
|
pushLine(`[rewind] ${error.message}`, C.error)
|