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
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 { join, resolve, relative, isAbsolute, sep } from "node:path";
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
- /** Validate a git ref / branch / tag / remote name (no option injection, no whitespace). */
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
- /** Snapshot the working tree before a destructive op (reset --hard / checkout file / restore /
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/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create) Commit/branch/ref; (push/pull/fetch) the branch or tag to push/pull/fetch (space-separated for multiple)" },
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) { if (commit.out) parts.push(commit.out) }
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
- case "checkpoint": {
334
- const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
335
- if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
336
-
337
- const sub = args.checkpointAction
338
- if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
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
- }
@@ -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
- export function runGit(cwd, cmdArgs) {
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,
@@ -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
- return guard ? `${guard.notice}\n\n${filtered}` : filtered
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 { resolveWebProxy, proxyFetch } from "../proxy.mjs";
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
- async function fetchTavily(query, limit, ctx) {
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
- }, resolveWebProxy(ctx))
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, ctx) {
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
- }, resolveWebProxy(ctx))
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, ctx)
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, ctx))
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: { type: "object", properties: { url: { type: "string", description: "http/https URL" } }, required: ["url"] },
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, ctx) {
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
- const proxyUri = resolveWebProxy(ctx)
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
- return ct2.includes("text/html") ? truncate(htmlToText(b2)) : truncate(b2)
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
- return ct.includes("text/html") ? truncate(htmlToText(body)) : truncate(body)
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
  }
@@ -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
- - Proxy support: set `"proxy": {"uri": "http://host:port", "web": true}` in config.json
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`,
@@ -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
- agent.history = []
12
- agent.tasks = []
13
- agent.planMode = false
14
- agent.goal = null
15
- agent._pendingReminders = []
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 = ""
@@ -1,7 +1,8 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
 
3
- /** /restore command: list git checkpoints and roll back to selected snapshot.
4
- * ctx: { agent, showPicker, pushLine, pushLabel } */
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 restore, Esc cancel)" },
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
- const summary = await rewind(agent.cwd, e.id)
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 to ${e.id}: patch ${summary.patchApplied ? "applied" : "none"}, deleted ${summary.deleted} new files, restored ${summary.restored} file(s)`, C.tool)
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)