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.
- package/CHANGELOG.md +49 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/acp/bridge.mjs +1 -0
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/helpers.mjs +1 -1
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/consult.mjs +0 -1
- package/src/agent-tools/skill.mjs +1 -1
- package/src/agent-tools/task.mjs +0 -2
- package/src/agent-tools/verify.mjs +0 -1
- package/src/agent.mjs +36 -3
- package/src/cli/make-agent.mjs +11 -5
- package/src/config.mjs +8 -103
- 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/model-specs.mjs +108 -0
- package/src/prompts/discipline.md +43 -0
- package/src/prompts/system.md +1 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +121 -102
- package/src/provider/google.mjs +41 -15
- package/src/provider/normalize.mjs +81 -0
- 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/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.md +4 -2
- package/src/tools/git.mjs +38 -11
- package/src/tools/shared.mjs +6 -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/agent-turn.mjs +2 -10
- package/src/tui/clipboard.mjs +3 -1
- package/src/tui/dims.mjs +20 -47
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +65 -75
- package/src/tui/key-handler.mjs +4 -1
- package/src/tui/mouse.mjs +47 -7
- package/src/tui/render-conversation.mjs +226 -124
- package/src/tui/render-frame.mjs +7 -2
- package/src/tui/render-loop.mjs +10 -0
- package/src/tui/render.mjs +12 -1
- package/src/tui/startup.mjs +1 -2
- package/src/tui/subagent-blocks.mjs +6 -1
- package/src/tui/tool-args.mjs +4 -0
- 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._
|
|
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._
|
|
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._
|
|
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": {
|
package/src/tools/shared.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
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/agent-turn.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* a
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
package/src/tui/fold-block.mjs
CHANGED
|
@@ -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))
|
|
40
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
}
|