thincoder 0.12.51 → 0.12.53

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 +49 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/acp/bridge.mjs +1 -0
  5. package/src/advisor/run.mjs +9 -11
  6. package/src/agent/dispatch.mjs +38 -13
  7. package/src/agent/helpers.mjs +1 -1
  8. package/src/agent/setup.mjs +2 -2
  9. package/src/agent-tools/consult.mjs +0 -1
  10. package/src/agent-tools/skill.mjs +1 -1
  11. package/src/agent-tools/task.mjs +0 -2
  12. package/src/agent-tools/verify.mjs +0 -1
  13. package/src/agent.mjs +36 -3
  14. package/src/cli/make-agent.mjs +11 -5
  15. package/src/config.mjs +8 -103
  16. package/src/mcp/helpers.mjs +14 -5
  17. package/src/mcp/transport-http.mjs +79 -27
  18. package/src/mcp/transport-stdio.mjs +57 -3
  19. package/src/mcp/transport-ws.mjs +46 -12
  20. package/src/mcp.mjs +197 -58
  21. package/src/model-specs.mjs +108 -0
  22. package/src/prompts/discipline.md +43 -0
  23. package/src/prompts/system.md +1 -1
  24. package/src/provider/anthropic.mjs +51 -18
  25. package/src/provider/core.mjs +121 -102
  26. package/src/provider/google.mjs +41 -15
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/provider/rate.mjs +5 -0
  29. package/src/provider/responses.mjs +498 -0
  30. package/src/provider/retry.mjs +125 -0
  31. package/src/provider/sse.mjs +58 -24
  32. package/src/proxy.mjs +36 -6
  33. package/src/tools/bash.md +2 -2
  34. package/src/tools/execute.md +1 -1
  35. package/src/tools/execute.mjs +3 -3
  36. package/src/tools/fetch.md +1 -0
  37. package/src/tools/file.mjs +136 -11
  38. package/src/tools/git.md +4 -2
  39. package/src/tools/git.mjs +38 -11
  40. package/src/tools/shared.mjs +6 -3
  41. package/src/tools/system.mjs +19 -1
  42. package/src/tools/web.mjs +44 -14
  43. package/src/tools/websearch.md +3 -1
  44. package/src/tui/agent-turn.mjs +2 -10
  45. package/src/tui/clipboard.mjs +3 -1
  46. package/src/tui/dims.mjs +20 -47
  47. package/src/tui/fold-block.mjs +59 -11
  48. package/src/tui/index.mjs +65 -75
  49. package/src/tui/key-handler.mjs +4 -1
  50. package/src/tui/mouse.mjs +47 -7
  51. package/src/tui/render-conversation.mjs +226 -124
  52. package/src/tui/render-frame.mjs +7 -2
  53. package/src/tui/render-loop.mjs +10 -0
  54. package/src/tui/render.mjs +12 -1
  55. package/src/tui/startup.mjs +1 -2
  56. package/src/tui/subagent-blocks.mjs +6 -1
  57. package/src/tui/tool-args.mjs +4 -0
  58. package/src/tui/tool-events.mjs +2 -4
package/src/tools/git.mjs CHANGED
@@ -22,9 +22,9 @@ function filterLines(output, filter) {
22
22
  /** Run git PRESERVING per-line leading whitespace. runGit trims the WHOLE output, which
23
23
  * strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
24
24
  * unstaged-only first line as staged. status uses this so the staged/unstaged column survives. */
25
- function runGitRaw(cwd, cmdArgs) {
25
+ function runGitRaw(cwd, cmdArgs, config = []) {
26
26
  try {
27
- return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
27
+ return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
28
28
  } catch (e) {
29
29
  return String(e.stdout || "").replace(/\r/g, "")
30
30
  }
@@ -32,9 +32,9 @@ function runGitRaw(cwd, cmdArgs) {
32
32
 
33
33
  /** Run git and report failure (stderr + exit code) instead of swallowing it.
34
34
  * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
35
- function runGitStrict(cwd, cmdArgs) {
35
+ function runGitStrict(cwd, cmdArgs, config = []) {
36
36
  try {
37
- const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
37
+ const out = execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
38
38
  return { ok: true, out }
39
39
  } catch (e) {
40
40
  return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
@@ -43,10 +43,23 @@ function runGitStrict(cwd, cmdArgs) {
43
43
 
44
44
  /** Validate a git ref / branch / tag / remote name (no option injection, no whitespace). */
45
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}`)
46
+ if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid ${what}: ${ref}`)
47
47
  return ref
48
48
  }
49
49
 
50
+ /** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
51
+ * Values are execFileSync array args (no shell injection) — still reject newlines/empty. */
52
+ function gitConfigArgs(config) {
53
+ if (config == null) return []
54
+ if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
55
+ const out = []
56
+ for (const c of config) {
57
+ if (typeof c !== "string" || !c.trim() || c.includes("\n")) throw new Error(`invalid git -c config entry: ${String(c).slice(0, 60)}`)
58
+ out.push("-c", c)
59
+ }
60
+ return out
61
+ }
62
+
50
63
  /** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
51
64
  * returns as an absolute path on Windows). */
52
65
  function isInside(root, abs) {
@@ -83,7 +96,7 @@ export const gitTool = {
83
96
  parameters: {
84
97
  type: "object",
85
98
  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" },
99
+ 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"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote" },
87
100
  // diff/log params
88
101
  staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
89
102
  path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm) File or directory to scope to / stage / restore" },
@@ -96,6 +109,7 @@ export const gitTool = {
96
109
  name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
97
110
  remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
98
111
  workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
112
+ 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
113
  tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
100
114
  mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation" },
101
115
  tagAction: { type: "string", enum: ["list", "create", "delete"], description: "(tag) list tags / create one / delete one" },
@@ -112,10 +126,13 @@ export const gitTool = {
112
126
  // workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
113
127
  // every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
114
128
  if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
129
+ // git -c overrides (proxy etc.) — only network actions need them; passing to every
130
+ // action would be harmless but noisy. cfgArgs stays [] for local ops.
131
+ const cfgArgs = gitConfigArgs(args.config)
115
132
  switch (args.action) {
116
133
  case "diff": {
117
134
  const ref = args.ref ?? "HEAD"
118
- if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
135
+ if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
119
136
  const flags = args.staged ? ["--staged"] : []
120
137
  const paths = args.path ? [args.path] : []
121
138
  const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
@@ -169,7 +186,7 @@ export const gitTool = {
169
186
  }
170
187
  case "show": {
171
188
  const ref = args.ref ?? "HEAD"
172
- if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
189
+ if (!/^[A-Za-z0-9._/~^@][A-Za-z0-9._/~^@{}-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
173
190
  const out = runGit(ctx.cwd, ["show", "--stat", ref])
174
191
  return truncate(out || "(no such commit)")
175
192
  }
@@ -195,9 +212,19 @@ export const gitTool = {
195
212
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
196
213
  if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
197
214
  if (args.tags) cmdArgs.push("--tags")
198
- const r = runGitStrict(ctx.cwd, cmdArgs)
215
+ const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
199
216
  return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
200
217
  }
218
+ case "ls-remote": {
219
+ // Lightweight remote-ref check (which refs a remote has) — network action,
220
+ // read-only, no snapshot. Config plumbing for blocked/gated remotes.
221
+ const cmdArgs = ["ls-remote"]
222
+ if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
223
+ if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
224
+ const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
225
+ if (!out) return "(no refs / remote unreachable)"
226
+ return truncate(filterLines(out, args.filter))
227
+ }
201
228
  case "add": {
202
229
  // Granular staging: stage `path` when given, else all changes (add -A).
203
230
  const cmdArgs = args.path ? ["add", "--", args.path] : ["add", "-A"]
@@ -293,14 +320,14 @@ export const gitTool = {
293
320
  const cmdArgs = ["fetch"]
294
321
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
295
322
  if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
296
- const r = runGitStrict(ctx.cwd, cmdArgs)
323
+ const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
297
324
  return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
298
325
  }
299
326
  case "pull": {
300
327
  const cmdArgs = ["pull"]
301
328
  if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
302
329
  if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
303
- const r = runGitStrict(ctx.cwd, cmdArgs)
330
+ const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
304
331
  return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
305
332
  }
306
333
  case "reset": {
@@ -170,6 +170,7 @@ export function toOpenAISchema(tool) {
170
170
  /** Strip ANSI escape sequences */
171
171
  export function sanitizeOutput(s) {
172
172
  return s
173
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
173
174
  .replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
174
175
  .replace(/\r\n/g, "\n")
175
176
  .replace(/\r/g, "\n")
@@ -446,10 +447,12 @@ export function htmlToText(html) {
446
447
  .trim()
447
448
  }
448
449
 
449
- /** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty. */
450
- 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 = []) {
451
454
  try {
452
- 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, "")
453
456
  } catch (e) {
454
457
  // maxBuffer overflow: e.stdout contains partial collected output — return it
455
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.
@@ -26,7 +26,7 @@ const DISTILL_FLUSH_TIMEOUT_MS = 5000
26
26
  * ensureAssistantLabel, askPermission, askQuestion,
27
27
  * handleSlash, summarize } */
28
28
  export async function runAgentTurn(ctx, text) {
29
- const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash, summarize } = ctx
29
+ const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash } = ctx
30
30
  // 可注入覆盖(测试用);默认走真实实现
31
31
  const runAgentImpl = ctx.runAgent ?? runAgent
32
32
  const saveSessionImpl = ctx.saveSession ?? saveSession
@@ -47,10 +47,6 @@ export async function runAgentTurn(ctx, text) {
47
47
  state.currentTool = null
48
48
  state.processingStarted = Date.now()
49
49
  state.controller = new AbortController()
50
- // Turn-start re-sample (2026-08-30): ConPTY may have recovered the true size
51
- // since the last event hook — a growth is accepted immediately (asymmetric
52
- // rule), so generation starts full-width instead of waiting for the finally.
53
- state.dims?.refresh()
54
50
  state.interruptPrompt = null
55
51
  // Refresh status bar every second during processing; also refresh when any
56
52
  // subagent block is still running so its header elapsed ticks (§7.2 D4 —
@@ -63,7 +59,7 @@ export async function runAgentTurn(ctx, text) {
63
59
  render()
64
60
 
65
61
  const { callbacks, flushStream } = buildToolCallbacks({
66
- agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, summarize, saveSessionImpl,
62
+ agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl,
67
63
  })
68
64
 
69
65
  // try/finally: every exit path — including an unexpected throw inside the catch
@@ -128,10 +124,6 @@ export async function runAgentTurn(ctx, text) {
128
124
  // an onToolResult their header would say "running" forever; ticks are cleared
129
125
  // so no stale start time leaks into the next turn.
130
126
  sweepToolBlocks(state)
131
- // Output just stopped — the deterministic moment ConPTY's buffer info has
132
- // recovered (stale-small only occurs DURING heavy output). Sample here:
133
- // a growth is accepted immediately, a shrink still needs double-confirm.
134
- state.dims?.refresh()
135
127
  state.controller = null
136
128
  state.status = "Ready"
137
129
  // FR1: status bar must recover immediately — the awaits below (title-gen, distill flush,
@@ -102,6 +102,7 @@ export function insertPastedText(state, rawText) {
102
102
  * Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
103
103
  * (degrades to a normal submit; Alt+Enter remains the fallback). */
104
104
  export function translateShiftEnter(text) {
105
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
105
106
  return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
106
107
  }
107
108
 
@@ -110,6 +111,7 @@ export function translateShiftEnter(text) {
110
111
  * modifyOtherKeys: \x1b[27;mod;key~ — function keys
111
112
  * Call AFTER translateShiftEnter (which already handles Shift+Enter). */
112
113
  export function stripKeyboardProtocol(text) {
114
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
113
115
  return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
114
116
  }
115
117
 
@@ -119,7 +121,7 @@ export function stripKeyboardProtocol(text) {
119
121
  export async function pasteClipboardImage(ctx) {
120
122
  const { agent, state, pushLine, render } = ctx
121
123
  const { execFile } = await import("node:child_process")
122
- const { mkdir, stat, unlink } = await import("node:fs/promises")
124
+ const { stat, unlink } = await import("node:fs/promises")
123
125
  const { join } = await import("node:path")
124
126
 
125
127
  const run = (cmd, args) => new Promise((resolve, reject) => {
package/src/tui/dims.mjs CHANGED
@@ -1,23 +1,21 @@
1
1
  /**
2
- * dims.mjs — terminal dimension single source (2026-08-30).
2
+ * dims.mjs — terminal dimension single source (2026-08-30; simplified 2026-08-31).
3
3
  *
4
- * Windows ConPTY bug (user report: streaming output crammed into a left-hand
5
- * sliver on a 2K screen, restored to full width after mouse interaction):
6
- * process.stdout.columns/rows are UNSTABLEGetConsoleScreenBufferInfo lags
7
- * behind the real window (ConPTY async update). Reads return falsy at startup
8
- * (the old ||80 fallback cramped the whole session) and flip between stale
9
- * and fresh values across calls.
4
+ * One dimension source for every render/interaction path. Consumers read the
5
+ * CACHED dims via get() never process.stdout.columns/rows directly.
6
+ * Sampling happens in event hooks only (startup seed, resize) never in the
7
+ * render path (guard test enforces this).
10
8
  *
11
- * Rule: sample-and-hold with ASYMMETRIC acceptance (2026-08-30 consult).
12
- * Every consumer reads the CACHED dims here, NEVER process.stdout.columns
13
- * directly. Sampling happens only in event hooks (startup, delayed resample,
14
- * resize, agent-turn finally, idle watchdog) never in the render path.
15
- *
16
- * Asymmetric acceptance: the failure mode is one-directional ConPTY reports
17
- * a value SMALLER than reality (stale small buffer), never larger. So:
18
- * - a LARGER sample is accepted immediately (real grow / recovery),
19
- * - a SMALLER sample needs two consecutive confirmations before it is
20
- * committed (real shrink), which absorbs the stale-shrink race.
9
+ * Terminal semantics (2026-08-31 simplification): the earlier ConPTY unstable-
10
+ * size hypothesis (sample-and-hold, asymmetric acceptance, double-confirm
11
+ * shrink with settle windows, idle watchdog) was built on a misdiagnosis —
12
+ * the real 2026-08-30 narrow-streaming bug was missing `cols` args at
13
+ * fold-block call sites (component default 80), and a resize event is a
14
+ * genuine dimension change on any terminal. The double-confirm rule even
15
+ * broke window drag-to-shrink (a drag ends with ONE final resize event).
16
+ * Remaining defense kept: sane-gate (cols>=40, rows>=10) drops falsy/unusable
17
+ * reads (headless / no TTY), and any sane sample larger OR smaller is
18
+ * accepted immediately. A later real resize corrects the cache naturally.
21
19
  */
22
20
  function defaultSample() {
23
21
  return {
@@ -31,44 +29,19 @@ export function makeDimsState(initial = {}, sampleFn = defaultSample, onChange =
31
29
  cols: Number(initial.cols) || 80,
32
30
  rows: Number(initial.rows) || 24,
33
31
  }
34
- let sawValid = false
35
- // Pending shrink confirmation: first sighting of a smaller sample parks it
36
- // here; a second consecutive identical sighting commits it.
37
- let pendingShrink = null
38
-
39
32
  return {
40
33
  /** Cached dims (what every render path must use). */
41
34
  get: () => dims,
42
- /** True once a real terminal size has been observed (diagnostics / startup retry). */
43
- get sawValid() { return sawValid },
44
- /** Sample (event hooks only). See asymmetric-acceptance note above. */
35
+ /** Sample (event hooks only: startup seed, resize). Falsy/unusable keep last good. */
45
36
  refresh: () => {
46
37
  const s = sampleFn()
47
38
  const c = Number(s.cols)
48
39
  const r = Number(s.rows)
49
40
  if (!(c >= 40 && r >= 10)) return dims // falsy/stale-unusable → keep last good
50
- sawValid = true
51
- if (c > dims.cols || r > dims.rows) {
52
- // Growth (incl. recovery from a stale-small cache): accept immediately.
53
- dims = { cols: c, rows: r }
54
- pendingShrink = null
55
- onChange?.(dims)
56
- return dims
57
- }
58
- if (c < dims.cols || r < dims.rows) {
59
- // Shrink: needs two consecutive identical sightings (ConPTY reports a
60
- // stale-small buffer during output activity; one sighting proves nothing).
61
- if (pendingShrink && pendingShrink.cols === c && pendingShrink.rows === r) {
62
- dims = { cols: c, rows: r }
63
- pendingShrink = null
64
- onChange?.(dims)
65
- } else {
66
- pendingShrink = { cols: c, rows: r }
67
- }
68
- return dims
69
- }
70
- pendingShrink = null
41
+ if (c === dims.cols && r === dims.rows) return dims
42
+ dims = { cols: c, rows: r }
43
+ onChange?.(dims)
71
44
  return dims
72
45
  },
73
46
  }
74
- }
47
+ }
@@ -36,8 +36,31 @@ export function isExpanded(state, foldKey) {
36
36
  /** Bidirectional toggle (mouse click / future keyboard path share this). */
37
37
  export function toggleFoldBlock(state, foldKey) {
38
38
  state.expandedBlocks ??= new Set()
39
- if (state.expandedBlocks.has(foldKey)) state.expandedBlocks.delete(foldKey)
40
- else state.expandedBlocks.add(foldKey)
39
+ if (state.expandedBlocks.has(foldKey)) {
40
+ state.expandedBlocks.delete(foldKey)
41
+ state._foldScroll?.delete(foldKey) // 2026-08-31 会诊 kimi:收起清理块内滚动残留(防 Map 缓涨)
42
+ } else {
43
+ state.expandedBlocks.add(foldKey)
44
+ }
45
+ }
46
+
47
+ /** 块内滚动:展开态窗口起点(2026-08-31 用户需求——60% 封顶保留、块内可滚动读全文)。
48
+ * 存在 = state._foldScroll: Map<foldKey, offset>(渲染读、点击/滚轮写)。 */
49
+ export function foldScrollOffset(state, foldKey) {
50
+ return state._foldScroll?.get(foldKey) ?? 0
51
+ }
52
+
53
+ /** 块内滚动步长:▲/▼ 控制行 = 一整窗(winH);滚轮 = 3 行(与外部会话滚动节拍一致)。
54
+ * dir=+1 向下(offset 增)、-1 向上;upper = 合法上限(total-winH),提供时写时钳制
55
+ * (2026-08-31 会诊 glm:▲▼ 过冲原靠渲染 clamp 收敛→每次点击双重建缓存)。 */
56
+ export function scrollFoldBlock(state, foldKey, dir, step, upper) {
57
+ state._foldScroll ??= new Map()
58
+ const prev = state._foldScroll.get(foldKey) ?? 0
59
+ let next = prev + dir * Math.max(1, Math.floor(step))
60
+ if (upper != null) next = Math.min(next, upper)
61
+ next = Math.max(0, next)
62
+ state._foldScroll.set(foldKey, next)
63
+ return next
41
64
  }
42
65
 
43
66
  /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
@@ -191,18 +214,43 @@ export function renderExpandedBlock({ body, foldKey, state, maxRows, label, cols
191
214
  })
192
215
  const out = [blankLine(), foldHintLine(`▼ … ${label} — click to collapse`, foldKey)]
193
216
  const cap = foldCapRows(maxRows)
194
- if (lined.length <= cap) {
217
+ if (lined.length <= cap - 5) {
218
+ // 2026-08-31 用户需求:整块(含控制行)≤ 60% 屏时全量显示
195
219
  out.push(...lined)
196
220
  return out
197
221
  }
198
- // Reserve room for blank + top control + cap marker + bottom control.
199
- const keep = Math.max(1, cap - 4)
200
- out.push(...lined.slice(0, keep))
201
- out.push({
202
- text: `│ ${body.length - keep} more lines — expansion capped at 60% of screen (collapse to re-expand)`,
203
- color: C.dim,
204
- _skipDimFold: true,
205
- })
222
+ // 块高 60% 封顶保留(用户 2026-08-30 拍板),正文改窗口显示(2026-08-31):
223
+ // 窗口 = cap 内可读行数(扣除 blank+顶部控制+▲+▼+底部收起 = 5 行开销);
224
+ // ▲/▼ 控制行点击翻窗(_foldScrollUp/_foldScrollDown 标记,mouse.mjs 分派)——
225
+ // 滚动读全文、收起控制行永远在块尾。
226
+ const winH = Math.max(1, cap - 5)
227
+ const total = lined.length
228
+ const offset = Math.min(Math.max(0, foldScrollOffset(state, foldKey)), Math.max(0, total - winH))
229
+ // 2026-08-31:clamp 写回状态——否则 scrollFoldBlock 越界累积(渲染只看 clamped、事件判定
230
+ // 却看原值),handleWheel 会误判"未到边界"→ 永远命中块 → 穿不出块 → 会话顶/懒加载不可达
231
+ if (state._foldScroll?.get(foldKey) !== offset) {
232
+ state._foldScroll ??= new Map()
233
+ state._foldScroll.set(foldKey, offset)
234
+ }
235
+ const window = lined.slice(offset, offset + winH).map((l) => ({
236
+ ...l,
237
+ _foldBlock: foldKey, // 2026-08-31 滚轮命中标记:每行自描述所属块(mouse handleWheel 用——无需区间簿记)
238
+ _foldWindow: winH,
239
+ _foldTotal: total,
240
+ }))
241
+ if (offset > 0) {
242
+ out.push({
243
+ text: `▲ 上方还有 ${offset} 行(点击向上翻窗)`,
244
+ color: C.dim, _skipDimFold: true, _foldScrollUp: foldKey, _foldWindow: winH, _foldTotal: total,
245
+ })
246
+ }
247
+ out.push(...window)
248
+ if (offset + winH < total) {
249
+ out.push({
250
+ text: `▼ 下方还有 ${total - offset - winH} 行(点击向下翻窗)`,
251
+ color: C.dim, _skipDimFold: true, _foldScrollDown: foldKey, _foldWindow: winH, _foldTotal: total,
252
+ })
253
+ }
206
254
  out.push(foldHintLine(`▼ … ${label} — click to collapse`, foldKey))
207
255
  return out
208
256
  }