thincoder 0.7.8 → 0.8.0

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 (96) hide show
  1. package/README.md +32 -13
  2. package/bin/thincoder.mjs +27 -346
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +98 -0
  5. package/src/agent/helpers.mjs +185 -0
  6. package/src/agent/setup.mjs +117 -0
  7. package/src/agent-tools/goal.mjs +71 -0
  8. package/src/agent-tools/plan.mjs +31 -0
  9. package/src/agent-tools/recent-changes.mjs +23 -0
  10. package/src/agent-tools/skill.mjs +46 -0
  11. package/src/agent-tools/subagent.mjs +113 -0
  12. package/src/agent-tools/task.mjs +67 -0
  13. package/src/agent-tools/verify.mjs +198 -0
  14. package/src/agent-tools.mjs +12 -0
  15. package/src/agent.mjs +90 -1040
  16. package/src/cli/distill-command.mjs +85 -0
  17. package/src/cli/make-agent.mjs +85 -0
  18. package/src/cli/memory-command.mjs +63 -0
  19. package/src/cli/permission.mjs +41 -0
  20. package/src/cli/setup-wizard.mjs +70 -0
  21. package/src/config.mjs +5 -8
  22. package/src/context.mjs +10 -13
  23. package/src/distill.mjs +4 -3
  24. package/src/embedding.mjs +4 -2
  25. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  26. package/src/mcp/helpers.mjs +37 -0
  27. package/src/mcp/transport-http.mjs +176 -0
  28. package/src/mcp/transport-stdio.mjs +84 -0
  29. package/src/mcp/transport-ws.mjs +87 -0
  30. package/src/mcp.mjs +4 -428
  31. package/src/memory/code-index.mjs +211 -0
  32. package/src/memory/code-sync.mjs +306 -0
  33. package/src/memory/core.mjs +277 -0
  34. package/src/memory/docs.mjs +262 -0
  35. package/src/memory/schema.mjs +426 -0
  36. package/src/memory.mjs +12 -1403
  37. package/src/provider/core.mjs +239 -0
  38. package/src/provider/index.mjs +6 -0
  39. package/src/provider/rate.mjs +104 -0
  40. package/src/session.mjs +18 -5
  41. package/src/tools/bash.mjs +144 -0
  42. package/src/tools/file.mjs +205 -0
  43. package/src/tools/git.mjs +166 -0
  44. package/src/tools/glob.mjs +51 -0
  45. package/src/tools/grep.mjs +100 -0
  46. package/src/tools/index.mjs +22 -0
  47. package/src/tools/ls.mjs +36 -0
  48. package/src/tools/patch.mjs +226 -0
  49. package/src/tools/repomap-parse.mjs +168 -0
  50. package/src/tools/shared.mjs +257 -0
  51. package/src/tools/system.mjs +336 -0
  52. package/src/tools/web.mjs +121 -0
  53. package/src/tools.mjs +2 -1194
  54. package/src/tui/agent-turn.mjs +254 -0
  55. package/src/tui/ansi.mjs +32 -0
  56. package/src/tui/clipboard.mjs +48 -0
  57. package/src/tui/cmd-auto.mjs +21 -0
  58. package/src/tui/cmd-clear.mjs +26 -0
  59. package/src/tui/cmd-config.mjs +72 -0
  60. package/src/tui/cmd-exit.mjs +5 -0
  61. package/src/tui/cmd-extract.mjs +5 -0
  62. package/src/tui/cmd-goal.mjs +47 -0
  63. package/src/tui/cmd-help.mjs +25 -0
  64. package/src/tui/cmd-init.mjs +91 -0
  65. package/src/tui/cmd-mcp.mjs +146 -0
  66. package/src/tui/cmd-model.mjs +7 -0
  67. package/src/tui/cmd-new.mjs +18 -0
  68. package/src/tui/cmd-plan.mjs +21 -0
  69. package/src/tui/cmd-reindex.mjs +44 -0
  70. package/src/tui/cmd-restore.mjs +39 -0
  71. package/src/tui/cmd-session.mjs +42 -0
  72. package/src/tui/cmd-skills.mjs +17 -0
  73. package/src/tui/cmd-think.mjs +56 -0
  74. package/src/tui/config-helpers.mjs +34 -0
  75. package/src/tui/distill-cmd.mjs +45 -0
  76. package/src/tui/index.mjs +330 -0
  77. package/src/tui/interaction.mjs +79 -0
  78. package/src/tui/key-handler.mjs +267 -0
  79. package/src/tui/layout.mjs +115 -0
  80. package/src/tui/pickers.mjs +279 -0
  81. package/src/tui/render-frame.mjs +304 -0
  82. package/src/tui/render.mjs +205 -0
  83. package/src/tui/slash-commands.mjs +138 -0
  84. package/src/tui/startup.mjs +113 -0
  85. package/src/tui/wizard.mjs +168 -0
  86. package/src/tui-render.mjs +4 -0
  87. package/src/tui.mjs +3 -2566
  88. package/src/provider.mjs +0 -383
  89. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  90. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  91. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  92. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  93. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  94. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  95. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  96. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * provider/core.mjs — LLM 调用核心
3
+ * chat / listModels / createProvider / requestWithRetry / readSSE
4
+ */
5
+
6
+ import { specForModel } from "../config.mjs"
7
+ import {
8
+ RETRYABLE_STATUS, MAX_RETRIES, MAX_CONTINUATIONS,
9
+ RATE_LIMIT_BACKOFF_MS, _rateHooks,
10
+ estimateRequestTokens, rateGate, recordRate,
11
+ } from "./rate.mjs"
12
+
13
+ export function createProvider(config) {
14
+ if (!config?.baseURL) throw new Error("provider config: baseURL is required")
15
+ if (!config?.apiKey) throw new Error("provider config: apiKey is required (config file or THINCODER_API_KEY env)")
16
+ if (!config?.model) throw new Error("provider config: model is required")
17
+ return {
18
+ baseURL: config.baseURL.replace(/\/+$/, ""),
19
+ apiKey: config.apiKey,
20
+ model: config.model,
21
+ maxTokens: config.maxTokens,
22
+ temperature: config.temperature,
23
+ thinking: config.thinking,
24
+ reasoningEffort: config.reasoningEffort,
25
+ tpm: config.tpm,
26
+ rpm: config.rpm,
27
+ }
28
+ }
29
+
30
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
31
+ const spec = specForModel(provider.model)
32
+ const body = {
33
+ model: provider.model,
34
+ messages,
35
+ stream: true,
36
+ stream_options: { include_usage: true },
37
+ }
38
+ if (provider.maxTokens) body.max_tokens = provider.maxTokens
39
+ if (provider.temperature != null) {
40
+ let t = provider.temperature
41
+ if (spec.tempRange) {
42
+ t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
43
+ t = Math.round(t * 100) / 100
44
+ }
45
+ body.temperature = t
46
+ }
47
+ if (provider.thinking) body.thinking = provider.thinking
48
+ if (provider.reasoningEffort) {
49
+ if (spec.reasoningEffortEnum && !spec.reasoningEffortEnum.includes(provider.reasoningEffort)) {
50
+ throw new Error(
51
+ `reasoning_effort "${provider.reasoningEffort}" not supported by model "${provider.model}"; ` +
52
+ `valid values: ${spec.reasoningEffortEnum.join(", ")}`
53
+ )
54
+ }
55
+ body.reasoning_effort = provider.reasoningEffort
56
+ }
57
+ if (tools?.length) body.tools = tools
58
+
59
+ const estimated = estimateRequestTokens(body)
60
+ await rateGate(provider, estimated, onWait, signal)
61
+
62
+ const response = await requestWithRetry(provider, body, signal, onWait)
63
+ const result = await readSSE(response, { onToken, onReasoning })
64
+ recordRate(provider, estimated, result.usage)
65
+
66
+ if (!spec.partialMode && !spec.prefixMode) return result
67
+ if (spec.prefixMode && !spec.partialMode && result.reasoning) return result
68
+ for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
69
+ const continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
70
+ messages: [
71
+ ...messages,
72
+ spec.partialMode
73
+ ? {
74
+ role: "assistant",
75
+ content: result.content,
76
+ partial: true,
77
+ ...(result.reasoning ? { reasoning_content: result.reasoning } : {}),
78
+ }
79
+ : { role: "assistant", content: result.content, prefix: true },
80
+ ],
81
+ tools,
82
+ onToken,
83
+ onReasoning,
84
+ onWait,
85
+ signal,
86
+ })
87
+ result.content += continued.content
88
+ result.reasoning += continued.reasoning ?? ""
89
+ for (const tc of continued.toolCalls ?? []) {
90
+ const idx = tc.index ?? result.toolCalls.length
91
+ const s = (result.toolCalls[idx] ??= { id: "", name: "", arguments: "" })
92
+ if (tc.id) s.id = tc.id
93
+ s.name += tc.name ?? ""
94
+ s.arguments += tc.arguments ?? ""
95
+ }
96
+ result.finishReason = continued.finishReason
97
+ if (continued.usage) {
98
+ const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
99
+ result.usage = {
100
+ prompt_tokens: sum("prompt_tokens"),
101
+ completion_tokens: sum("completion_tokens"),
102
+ total_tokens: sum("total_tokens"),
103
+ prompt_cache_hit_tokens: sum("prompt_cache_hit_tokens"),
104
+ prompt_cache_miss_tokens: sum("prompt_cache_miss_tokens"),
105
+ }
106
+ }
107
+ }
108
+ return result
109
+ }
110
+
111
+ export async function listModels(provider, { signal } = {}) {
112
+ const response = await fetch(`${provider.baseURL}/models`, {
113
+ headers: { Authorization: `Bearer ${provider.apiKey}` },
114
+ signal,
115
+ })
116
+ if (!response.ok) {
117
+ const text = await response.text().catch(() => "")
118
+ throw new Error(`GET /models failed ${response.status}: ${text}`)
119
+ }
120
+ const data = await response.json()
121
+ return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
122
+ }
123
+
124
+ async function requestWithRetry(provider, body, signal, onWait) {
125
+ let lastError
126
+ let lastWas429 = false
127
+ let rateLimitHits = 0
128
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
129
+ if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
130
+ lastWas429 = false
131
+
132
+ let response
133
+ try {
134
+ response = await fetch(`${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`, {
135
+ method: "POST",
136
+ headers: {
137
+ "Content-Type": "application/json",
138
+ Authorization: `Bearer ${provider.apiKey}`,
139
+ },
140
+ body: JSON.stringify(body),
141
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000),
142
+ })
143
+ } catch (error) {
144
+ if (error.name === "AbortError") throw error
145
+ lastError = error
146
+ continue
147
+ }
148
+
149
+ if (response.ok) return response
150
+
151
+ const text = await response.text().catch(() => "")
152
+ const message = `LLM API error ${response.status}: ${text}`
153
+ if (isQuotaError(text)) throw new Error(message)
154
+ if (response.status === 429) {
155
+ const retryAfter = Number(response.headers.get("retry-after"))
156
+ const waitMs =
157
+ Number.isFinite(retryAfter) && retryAfter > 0
158
+ ? retryAfter * 1000
159
+ : RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
160
+ lastError = new Error(message)
161
+ lastWas429 = true
162
+ if (attempt < MAX_RETRIES) {
163
+ onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
164
+ await _rateHooks.sleep(waitMs)
165
+ }
166
+ continue
167
+ }
168
+ if (RETRYABLE_STATUS.has(response.status)) {
169
+ lastError = new Error(message)
170
+ continue
171
+ }
172
+ throw new Error(message)
173
+ }
174
+ throw lastError
175
+ }
176
+
177
+ function isQuotaError(text) {
178
+ try {
179
+ const type = JSON.parse(text)?.error?.type
180
+ return typeof type === "string" && type.includes("quota")
181
+ } catch {
182
+ return false
183
+ }
184
+ }
185
+
186
+ async function readSSE(response, { onToken, onReasoning }) {
187
+ const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
188
+ const decoder = new TextDecoder()
189
+ let buffer = ""
190
+
191
+ const processLines = (lines) => {
192
+ for (const line of lines) {
193
+ if (!line.startsWith("data:")) continue
194
+ const data = line.slice(5).trim()
195
+ if (!data || data === "[DONE]") continue
196
+
197
+ let json
198
+ try { json = JSON.parse(data) } catch { continue }
199
+
200
+ if (json.usage) result.usage = json.usage
201
+ const choice = json.choices?.[0]
202
+ if (!choice) continue
203
+ if (choice.finish_reason) result.finishReason = choice.finish_reason
204
+
205
+ const delta = choice.delta ?? {}
206
+ if (delta.reasoning_content) {
207
+ result.reasoning += delta.reasoning_content
208
+ onReasoning?.(delta.reasoning_content)
209
+ }
210
+ if (delta.content) {
211
+ result.content += delta.content
212
+ onToken?.(delta.content)
213
+ }
214
+ for (const tc of delta.tool_calls ?? []) {
215
+ const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
216
+ if (tc.id) slot.id = tc.id
217
+ if (tc.function?.name && !slot.name) slot.name = tc.function.name
218
+ if (tc.function?.arguments) slot.arguments += tc.function.arguments
219
+ }
220
+ }
221
+ }
222
+
223
+ if (!response.body) throw new Error("No stream response body")
224
+ for await (const chunk of response.body) {
225
+ buffer += decoder.decode(chunk, { stream: true })
226
+ const lines = buffer.split("\n")
227
+ buffer = lines.pop()
228
+ processLines(lines)
229
+ }
230
+ buffer += decoder.decode()
231
+ processLines(buffer.split("\n"))
232
+ return result
233
+ }
234
+
235
+ function betaBaseURL(baseURL) {
236
+ // DeepSeek prefix 续写走 /beta 端点;只处理 /v1 后缀,缺 /v1 时追加 /beta
237
+ if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
238
+ return baseURL.endsWith("/") ? baseURL + "beta" : baseURL + "/beta"
239
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * provider/index.mjs — 后端兼容重导出
3
+ * import { chat } from "./provider" → 自动解析到本文件
4
+ */
5
+ export { chat, createProvider, listModels } from "./core.mjs"
6
+ export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
@@ -0,0 +1,104 @@
1
+ /**
2
+ * provider/rate.mjs — TPM/RPM 主动节流闸门
3
+ * 滑动窗口记账,发请求前预检预算,超支则睡到窗口腾出空间。
4
+ */
5
+
6
+ import { specForModel } from "../config.mjs"
7
+
8
+ export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
9
+ export const MAX_RETRIES = 3
10
+ export const MAX_CONTINUATIONS = 3
11
+ export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
12
+
13
+ /**
14
+ * 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
15
+ * 生产代码不要直接调 setTimeout/sleep,统一走这里。
16
+ */
17
+ export const _rateHooks = {
18
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
19
+ now: () => Date.now(),
20
+ windowMs: 60_000,
21
+ }
22
+
23
+ const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
24
+
25
+ function rateKey(provider) {
26
+ // 归一化:/beta 和 /v1 视为同一账户的同一限流窗口(DeepSeek prefix continuation 改 /beta 端点)
27
+ const base = provider.baseURL.replace(/\/beta$/, "/v1")
28
+ return `${base}|${provider.apiKey ?? ""}`
29
+ }
30
+
31
+ /** 粗估文本 token 数。
32
+ * ASCII 按 ~4 字符/token;非 ASCII(CJK/emoji)按 ~1 字符/token(保守,实测 BPE 1.5-2.5 字/token)。 */
33
+ export function estimateText(s) {
34
+ let nonAscii = 0
35
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
36
+ return Math.ceil((s.length - nonAscii) / 4) + nonAscii
37
+ }
38
+
39
+ /** 本次请求的 prompt 估算 */
40
+ export function estimateRequestTokens(body) {
41
+ let tokens = 0
42
+ for (const m of body.messages ?? []) {
43
+ if (typeof m.content === "string") tokens += estimateText(m.content)
44
+ if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
45
+ for (const tc of m.tool_calls ?? []) {
46
+ tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
47
+ }
48
+ }
49
+ if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
50
+ return tokens
51
+ }
52
+
53
+ /** 闸门:超预算则睡到窗口腾出空间 */
54
+ export async function rateGate(provider, estimated, onWait, signal) {
55
+ const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
56
+ const rpm = provider.rpm ?? null
57
+ if (tpm == null && rpm == null) return
58
+ const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
59
+ rateWindows.set(rateKey(provider), w)
60
+ for (;;) {
61
+ const now = _rateHooks.now()
62
+ const cutoff = now - _rateHooks.windowMs
63
+ w.tokens = w.tokens.filter((e) => e.ts > cutoff)
64
+ w.requests = w.requests.filter((ts) => ts > cutoff)
65
+ const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
66
+ const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
67
+ const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
68
+ if (overTokens <= 0 && overRequests <= 0) break
69
+ let waitMs = _rateHooks.windowMs
70
+ if (overTokens > 0) {
71
+ let freed = 0
72
+ for (const e of w.tokens) {
73
+ freed += e.n
74
+ if (freed >= overTokens) {
75
+ waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
76
+ break
77
+ }
78
+ }
79
+ }
80
+ if (overRequests > 0) {
81
+ waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
82
+ }
83
+ waitMs = Math.max(waitMs, 50)
84
+ onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
85
+ await _rateHooks.sleep(waitMs)
86
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
87
+ }
88
+ }
89
+
90
+ /** 记账:响应回来后按实测 usage 记 */
91
+ export function recordRate(provider, estimated, usage) {
92
+ if (provider.tpm == null && provider.rpm == null) return
93
+ const key = rateKey(provider)
94
+ const w = rateWindows.get(key) ?? { tokens: [], requests: [] }
95
+ const now = _rateHooks.now()
96
+ const cutoff = now - _rateHooks.windowMs
97
+ w.tokens = w.tokens.filter((e) => e.ts > cutoff)
98
+ w.requests = w.requests.filter((ts) => ts > cutoff)
99
+ w.requests.push(now)
100
+ w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
101
+ // 窗口已空则删条目,防长期跨 provider 配置时 Map 无界增长
102
+ if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
103
+ else rateWindows.set(key, w)
104
+ }
package/src/session.mjs CHANGED
@@ -21,14 +21,20 @@ export function sessionPath(cwd) {
21
21
  function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
22
22
  function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
23
23
 
24
- /** 原子写:先写临时文件再替换,防写入中途崩溃留下截断的 JSON 丢整个会话。
25
- * 不用 renameSync:Windows rename 目标已存在抛 EPERM */
24
+ /** 原子写:先写临时文件再 rename 替换,防写入中途崩溃留下截断的 JSON 丢整个会话。
25
+ * rename 在 POSIX 上原子;Windows 上目标已存在时 Node 24 用 MoveFileExW+REPLACE_EXISTING 也能原子替换。
26
+ * 某些旧 Windows 文件系统可能抛 EPERM,重试一次。 */
26
27
  function writeSessionFile(p, data) {
27
28
  mkdirSync(dirname(p), { recursive: true })
28
29
  const tmp = `${p}.tmp`
29
30
  writeFileSync(tmp, JSON.stringify(data), "utf8")
30
- try { unlinkSync(p) } catch { /* 旧文件不存在就算了 */ }
31
- renameSync(tmp, p)
31
+ try {
32
+ renameSync(tmp, p)
33
+ } catch {
34
+ // Windows EPERM 兜底:删目标后重试(极罕见,仅旧 NTFS/网络盘)
35
+ try { unlinkSync(p) } catch {}
36
+ renameSync(tmp, p)
37
+ }
32
38
  }
33
39
 
34
40
  // ========== 槽位管理 ==========
@@ -52,7 +58,8 @@ export function archiveCurrent(cwd, { exclude } = {}) {
52
58
  const m = loadManifest(cwd)
53
59
 
54
60
  let slot
55
- const entries = Object.entries(m.slots)
61
+ // 只计数字 key 的槽位,排除 _currentName 等遗留非数字 key
62
+ const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
56
63
  if (entries.length < MAX_SLOTS) {
57
64
  slot = 1
58
65
  while (m.slots[slot]) slot++
@@ -175,6 +182,12 @@ export function applySession(agent, data) {
175
182
  agent.goal = data.goal ?? null
176
183
  agent._pendingReminders = data.pendingReminders ?? []
177
184
  agent._sessionStart = data.sessionStart ?? null
185
+ // 重置轮次计数器:切换会话后不应继承旧会话的停滞/压缩状态
186
+ agent._turnsSinceTaskUpdate = 0
187
+ agent._turnsInPlanMode = 0
188
+ agent._compressFailures = 0
189
+ agent._verifyRetries = 0
190
+ agent._verifyPassed = false
178
191
  if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
179
192
  const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
180
193
  if (p) {
@@ -0,0 +1,144 @@
1
+ import {
2
+ DESC,
3
+ sanitizeOutput,
4
+ truncate,
5
+ makeDecoder,
6
+ BASH_TIMEOUT_MS,
7
+ hasFileRedirection,
8
+ shellSegments,
9
+ isDestructiveGitSegment,
10
+ isDestructiveCommand,
11
+ insideGitRepo,
12
+ } from "./shared.mjs"
13
+ import { spawn, execFileSync } from "node:child_process"
14
+
15
+ /** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
16
+ const SAFE_ENV_KEYS = new Set([
17
+ "PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
18
+ "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
19
+ "ComSpec", "PATHEXT", "SystemRoot", "windir",
20
+ "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
21
+ "PYTHONIOENCODING", "GIT_EDITOR", "EDITOR", "VISUAL",
22
+ "GIT_PAGER", "PAGER", "TERM",
23
+ ])
24
+
25
+ export const bashTool = {
26
+ name: "bash",
27
+ description: DESC("bash"),
28
+ parameters: {
29
+ type: "object",
30
+ properties: {
31
+ command: { type: "string", description: "Shell command to execute" },
32
+ timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
33
+ },
34
+ required: ["command"],
35
+ },
36
+ readonly: false,
37
+ async execute(args, ctx) {
38
+ // 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
39
+ if (hasFileRedirection(args.command)) {
40
+ throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
41
+ }
42
+ // 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
43
+ if (shellSegments(args.command).some(isDestructiveCommand)) {
44
+ throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
45
+ }
46
+ // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
47
+ if (shellSegments(args.command).some(isDestructiveGitSegment)) {
48
+ if (!insideGitRepo(ctx.cwd)) {
49
+ throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
50
+ }
51
+ const status = execFileSync("git", ["status", "--porcelain"], {
52
+ cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
53
+ }).trim()
54
+ if (status) {
55
+ throw new Error(
56
+ `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
57
+ `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
58
+ )
59
+ }
60
+ }
61
+
62
+ return new Promise((resolve) => {
63
+ // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
64
+ // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
65
+ const killTree = () => {
66
+ if (process.platform === "win32") {
67
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
+ } else {
69
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
70
+ try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
71
+ }
72
+ }
73
+ // Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
74
+ // chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
75
+ // 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
76
+ // 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
77
+ const winCmd = process.platform === "win32"
78
+ const child = spawn(args.command, {
79
+ cwd: ctx.cwd,
80
+ shell: true,
81
+ windowsHide: true,
82
+ detached: process.platform !== "win32",
83
+ stdio: ["ignore", "pipe", "pipe"],
84
+ env: {
85
+ ...Object.fromEntries(
86
+ Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
87
+ ),
88
+ GIT_EDITOR: "true",
89
+ EDITOR: "true",
90
+ VISUAL: "true",
91
+ GIT_PAGER: "cat",
92
+ PAGER: "cat",
93
+ TERM: "dumb",
94
+ ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
95
+ },
96
+ })
97
+ // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
98
+ // 且允许模型按 stderr 快速定位错误)
99
+ const outDecoder = makeDecoder()
100
+ const errDecoder = makeDecoder()
101
+ let outBuf = ""
102
+ let errBuf = ""
103
+ let truncatedNote = ""
104
+
105
+ const onStdout = (d) => {
106
+ const s = sanitizeOutput(outDecoder(d))
107
+ if (s) {
108
+ ctx.onOutput?.(s)
109
+ if (outBuf.length < 2_000_000) outBuf += s
110
+ else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
111
+ }
112
+ }
113
+ const onStderr = (d) => {
114
+ const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
115
+ if (errBuf.length < 2_000_000) errBuf += s
116
+ }
117
+
118
+ child.stdout.on("data", onStdout)
119
+ child.stderr.on("data", onStderr)
120
+
121
+ const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
122
+ if (ctx.signal) {
123
+ ctx.signal.addEventListener("abort", killTree, { once: true })
124
+ }
125
+ child.on("error", (error) => {
126
+ clearTimeout(timer)
127
+ resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
128
+ })
129
+ child.on("close", (code, signal) => {
130
+ clearTimeout(timer)
131
+ // 冲刷解码器尾部
132
+ outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
133
+ errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
134
+ const status = signal
135
+ ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
136
+ : `exit code ${code}`
137
+ const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
138
+ if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
139
+ parts.push(`(${status})`)
140
+ resolve(truncate(parts.join("\n\n") + truncatedNote))
141
+ })
142
+ })
143
+ },
144
+ }