thincoder 0.12.52 → 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.
@@ -91,15 +91,17 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
91
91
  throw new Error(`API error: HTTP ${response.status} — ${errorMsg}`)
92
92
  }
93
93
  // HTTP < 400 non-SSE: might be a valid single-chunk JSON response (e.g. proxy
94
- // stripped SSE framing). Try to parse as a chat.completion.chunk.
94
+ // stripped SSE framing). Parse `choice.message` (full chat.completion) OR
95
+ // `choice.delta` (chunk shape) — 2026-08-31: gateways downgrading stream:true
96
+ // to a complete completion used to return EMPTY content silently (message-only shape).
95
97
  try {
96
98
  const parsed = JSON.parse(body)
97
99
  const choice = parsed.choices?.[0]
98
100
  if (choice) {
99
101
  const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
100
- const delta = choice.delta ?? {}
102
+ const delta = choice.delta ?? choice.message ?? {}
101
103
  result.content = delta.content ?? ""
102
- result.reasoning = delta.reasoning_content ?? ""
104
+ result.reasoning = delta.reasoning_content ?? delta.reasoning ?? ""
103
105
  result.finishReason = choice.finish_reason ?? null
104
106
  mergeToolCalls(result, delta)
105
107
  if (result.content) onToken?.(result.content)
@@ -118,32 +120,48 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
118
120
  let hasChoices = false
119
121
  const firedPatterns = sharedFired ?? new Set()
120
122
 
121
- const processLines = (lines) => {
122
- for (const line of lines) {
123
- if (!line.startsWith("data:")) continue
124
- const data = line.slice(5).trim()
125
- if (!data || data === "[DONE]") continue
123
+ /** Process one complete SSE event (data lines joined with \n per the SSE spec).
124
+ * 2026-08-31: rows are buffered per event (multi-line data: support that the
125
+ * per-line JSON.parse used to crash on — single-line events behave identically). */
126
+ const handleEvent = (data) => {
127
+ if (!data || data === "[DONE]") return
128
+ let json
129
+ try { json = JSON.parse(data) } catch { return }
126
130
 
127
- let json
128
- try { json = JSON.parse(data) } catch { continue }
131
+ if (json.usage) result.usage = normalizeUsageCache(json.usage)
132
+ const choice = json.choices?.[0]
133
+ if (!choice) return
134
+ hasChoices = true
135
+ if (choice.finish_reason) result.finishReason = choice.finish_reason
129
136
 
130
- if (json.usage) result.usage = normalizeUsageCache(json.usage)
131
- const choice = json.choices?.[0]
132
- if (!choice) continue
133
- hasChoices = true
134
- if (choice.finish_reason) result.finishReason = choice.finish_reason
137
+ const delta = choice.delta ?? choice.message ?? {}
138
+ // reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
139
+ // 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
140
+ const rDelta = delta.reasoning_content ?? delta.reasoning
141
+ if (rDelta) {
142
+ result.reasoning += rDelta
143
+ onReasoning?.(rDelta)
144
+ }
145
+ if (delta.content) {
146
+ result.content += delta.content
147
+ onToken?.(delta.content)
148
+ }
149
+ mergeToolCalls(result, delta)
150
+ }
135
151
 
136
- const delta = choice.delta ?? {}
137
- if (delta.reasoning_content) {
138
- result.reasoning += delta.reasoning_content
139
- onReasoning?.(delta.reasoning_content)
140
- }
141
- if (delta.content) {
142
- result.content += delta.content
143
- onToken?.(delta.content)
152
+ const processLines = (lines) => {
153
+ let currentData = ""
154
+ for (const line of lines) {
155
+ if (line.startsWith("data:")) {
156
+ const v = line.slice(5).trim()
157
+ if (v === "[DONE]") { currentData = ""; continue }
158
+ // multi-line data: joins with \n (SSE spec); single-line is the common case
159
+ currentData = currentData ? currentData + "\n" + v : v
160
+ continue
144
161
  }
145
- mergeToolCalls(result, delta)
162
+ if (line === "" && currentData) { handleEvent(currentData); currentData = "" }
146
163
  }
164
+ if (currentData) handleEvent(currentData)
147
165
  }
148
166
 
149
167
  if (!response.body) throw new Error("No stream response body")
@@ -155,6 +173,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
155
173
  throw e
156
174
  }
157
175
  buffer += decoder.decode(chunk, { stream: true })
176
+ // UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
177
+ // 首行 "data:" 前缀匹配失败会被静默丢弃(首个事件整体消失)。
178
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
158
179
  const lines = buffer.split("\n")
159
180
  buffer = lines.pop()
160
181
  processLines(lines)
@@ -186,6 +207,19 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
186
207
  result.interruptMessage = signal.reason.message
187
208
  return result
188
209
  }
210
+ // 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
211
+ // proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
212
+ // 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
213
+ if (hasChoices && (result.content || result.toolCalls.length)) {
214
+ finalizeToolCalls(result)
215
+ result.partial = true
216
+ result.networkError = e.message ?? String(e)
217
+ const existing = result._warnings ??= []
218
+ if (!existing.some((w) => w.name === "network-partial")) {
219
+ existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
220
+ }
221
+ return result
222
+ }
189
223
  throw e
190
224
  }
191
225
 
package/src/proxy.mjs CHANGED
@@ -64,9 +64,12 @@ function abortError(signal) {
64
64
  * body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
65
65
  * opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
66
66
  * absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
67
+ * 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
68
+ * 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
69
+ * bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
67
70
  * 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
68
71
  */
69
- export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false) {
72
+ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
70
73
  return new Promise((resolve, reject) => {
71
74
  const target = new URL(urlStr)
72
75
  const method = opts.method ?? "GET"
@@ -78,6 +81,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
78
81
  const body = new PassThrough()
79
82
  let settled = false
80
83
  let headerBuf = ""
84
+ let idleTimer = null
81
85
 
82
86
  const timer = setTimeout(() => fail(new Error("Response timeout")), timeout)
83
87
  const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
@@ -85,6 +89,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
85
89
 
86
90
  function cleanup() {
87
91
  clearTimeout(timer)
92
+ clearTimeout(idleTimer)
88
93
  signal?.removeEventListener("abort", onAbort)
89
94
  }
90
95
  /** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
@@ -93,6 +98,11 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
93
98
  if (!settled) { settled = true; reject(err) }
94
99
  else body.destroy(err)
95
100
  }
101
+ /** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
102
+ function armIdle() {
103
+ clearTimeout(idleTimer)
104
+ if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(new Error("Response body timeout (idle)")), bodyIdleMs)
105
+ }
96
106
 
97
107
  sock.on("data", (d) => {
98
108
  if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
@@ -113,11 +123,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
113
123
  sock.removeAllListeners("data")
114
124
  settled = true
115
125
  cleanup()
126
+ armIdle()
116
127
  const remaining = headerBuf.slice(idx + 4)
117
128
  if (remaining) body.write(Buffer.from(remaining, "utf8"))
118
129
  sock.pipe(body)
119
- // body 结束后才移除 abort 监听(流式中途 abort 要能终止流)
120
- body.on("close", () => signal?.removeEventListener("abort", onAbort))
130
+ // 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
131
+ // 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
132
+ // 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
133
+ // 'readable' 不改变流模式,数据仍由消费方按需拉取。
134
+ body.on("readable", armIdle)
135
+ // body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
136
+ body.on("close", () => {
137
+ clearTimeout(idleTimer)
138
+ signal?.removeEventListener("abort", onAbort)
139
+ })
121
140
  signal?.addEventListener("abort", onAbort, { once: true })
122
141
 
123
142
  resolve({
@@ -156,12 +175,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
156
175
  /**
157
176
  * HTTPS request through HTTP CONNECT proxy tunnel.
158
177
  * CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
178
+ * 2026-08-31 会诊 #1/#4:
179
+ * - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
180
+ * 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
181
+ * - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
182
+ * opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
183
+ * 即误报 "Response timeout",与直连 600s 语义割裂。
159
184
  */
160
185
  export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
161
186
  return new Promise((resolve, reject) => {
162
187
  const target = new URL(urlStr)
163
188
  const proxy = new URL(proxyUri)
164
189
  const signal = opts?.signal
190
+ const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
191
+ const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
165
192
 
166
193
  if (signal?.aborted) return reject(abortError(signal))
167
194
 
@@ -181,12 +208,13 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
181
208
  if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
182
209
  sock.removeAllListeners("data"); clearTimeout(timer)
183
210
 
184
- const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: false, timeout })
211
+ // 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
212
+ const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
185
213
  if (buf) tlsSock.unshift(Buffer.from(buf))
186
214
  tlsSock.on("secureConnect", () => {
187
215
  // TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
188
216
  signal?.removeEventListener("abort", onAbort)
189
- streamHttpResponse(tlsSock, urlStr, opts, timeout).then(resolve, reject)
217
+ streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
190
218
  })
191
219
  tlsSock.on("error", e => { sock.destroy(); reject(e) })
192
220
  })
@@ -232,5 +260,7 @@ export async function proxyFetch(urlStr, opts, proxyUri) {
232
260
  if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
233
261
  // http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
234
262
  const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
235
- return streamHttpResponse(sock, urlStr, opts, FETCH_TIMEOUT, true)
263
+ const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
264
+ const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
265
+ return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
236
266
  }
package/src/tools/bash.md CHANGED
@@ -27,9 +27,9 @@ Output format:
27
27
  Notes:
28
28
  - There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
29
29
  - The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
30
- - Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors.
30
+ - Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors, so read the saved log file's tail before trusting success. Use `filter` to narrow instead of hand-piping.
31
31
  - Check `[stderr]` for error messages, warnings, and diagnostic output — it is separated from `[stdout]` so you can quickly identify problems.
32
- - On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)`) FAIL. For complex logic prefer `node -e` over shell gymnastics.
32
+ - On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)`, `${VAR}`, single quotes, `;` separators) FAIL — the tool prepends a hint when it detects POSIX-only syntax, and still executes. For complex logic prefer the execute tool (node) over shell gymnastics.
33
33
  - Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
34
34
  - Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
35
35
  - After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
@@ -11,7 +11,7 @@ Parameters:
11
11
  - nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
12
12
  - workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
13
13
  - filter: optional — only return output lines matching this regex (case-insensitive)
14
- - timeoutMs: Timeout in milliseconds (default 30000, max 60000)
14
+ - timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers slow `node --test` suites and long package scripts)
15
15
 
16
16
  Notes:
17
17
  - `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
@@ -161,8 +161,8 @@ export const executeTool = {
161
161
  timeoutMs: {
162
162
  type: "integer",
163
163
  minimum: 1,
164
- maximum: 60000,
165
- description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
164
+ maximum: 600000,
165
+ description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 600000)`,
166
166
  },
167
167
  },
168
168
  required: [],
@@ -175,7 +175,7 @@ export const executeTool = {
175
175
  catch (e) { return `Error: ${e.message}` }
176
176
 
177
177
  const t = Number(args.timeoutMs)
178
- const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
178
+ const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 600_000) : DEFAULT_TIMEOUT
179
179
 
180
180
  let childArgs
181
181
  if (args.scriptFile) {
@@ -2,6 +2,7 @@ Fetch a URL and return its content as text. HTML pages are stripped to readable
2
2
 
3
3
  Parameters:
4
4
  - url (required): http/https URL
5
+ - 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); pick per target (github/foreign sites need a proxy, gitee/domestic don't)
5
6
 
6
7
  Notes:
7
8
  - Follows redirects automatically
@@ -38,6 +38,36 @@ export function markDirty(abs) { dirtyPaths.add(abs) }
38
38
  export function clearDirty(abs) { dirtyPaths.delete(abs) }
39
39
  export function isDirty(abs) { return dirtyPaths.has(abs) }
40
40
 
41
+ // 2026-08-31 工具顺手度优化(用户批准):写入工具记录受影响行范围——insert_after
42
+ // 精确判定:after_line 在未受影响区(< lastWrite.startLine)→ 行号未漂移 → 允许
43
+ // (消掉"我写的文件被当外部修改、必须重 read"的摩擦);受影响区内 → 拒绝(护栏保留);
44
+ // write 全文重写 → 全文件受影响,任何 after_line 拒绝。
45
+ const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
46
+ export function recordWrite(abs, write) {
47
+ lastWrites.set(abs, write)
48
+ dirtyPaths.delete(abs) // 本 session 写入——等效于刚 read 过(快照在 lastWrites)
49
+ }
50
+ export function lastWriteOf(abs) { return lastWrites.get(abs) }
51
+ export function clearLastWrite(abs) { lastWrites.delete(abs) }
52
+
53
+ /** 2026-08-31 工具顺手度(用户批准"可以啊"):写入工具返回带上下文窗口——
54
+ * 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
55
+ * 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
56
+ * 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
57
+ * write 全文重写跳过(无行号锚点——模型刚写的知道内容)。 */
58
+ async function appendWriteContext(abs, writeLine, baseResult) {
59
+ const content = normalizeEOL(await readFile(abs, "utf8"))
60
+ const lines = content.split("\n")
61
+ const start = Math.max(1, writeLine - 3)
62
+ const end = Math.min(lines.length, writeLine + 3)
63
+ const ctxLines = []
64
+ for (let i = start; i <= end; i++) {
65
+ const marker = i === writeLine ? "→" : " "
66
+ ctxLines.push(`${marker} L${i}\t${lines[i - 1]}`)
67
+ }
68
+ return `${baseResult}\ncontext (L${start}-L${end}):\n${ctxLines.join("\n")}`
69
+ }
70
+
41
71
  export const readTool = {
42
72
  name: "read",
43
73
  description: DESC("read"),
@@ -61,6 +91,7 @@ export const readTool = {
61
91
  const content = normalizeEOL(await readFile(abs, "utf8"))
62
92
  // A read refreshes the agent's view — line numbers are fresh again.
63
93
  clearDirty(abs)
94
+ clearLastWrite(abs) // 2026-08-31:read 同时清写入快照(新视图以 read 为准)
64
95
  const lines = content.split("\n")
65
96
  const offset = Math.max(1, args.offset ?? 1)
66
97
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
@@ -171,7 +202,7 @@ export const writeTool = {
171
202
  const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
172
203
  const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
173
204
  await writeFile(abs, content, "utf8")
174
- markDirty(abs)
205
+ recordWrite(abs, { type: "write", startLine: 1, shift: 0 }) // 全文重写——全文件受影响
175
206
  const diff = gitDiffOne(ctx.cwd, abs)
176
207
  return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
177
208
  },
@@ -189,12 +220,78 @@ export const editTool = {
189
220
  old_string: { type: "string", description: "Exact text to replace" },
190
221
  new_string: { type: "string", description: "Replacement text" },
191
222
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
223
+ edits: {
224
+ type: "array",
225
+ description: "2026-08-31 工具顺手度:一次多文件原子替换——任一失败全不写(先全量检查所有替换可执行)。与 path/old_string/new_string 互斥。",
226
+ items: {
227
+ type: "object",
228
+ properties: {
229
+ path: { type: "string" },
230
+ old_string: { type: "string" },
231
+ new_string: { type: "string" },
232
+ replace_all: { type: "boolean" },
233
+ },
234
+ required: ["path", "old_string", "new_string"],
235
+ },
236
+ },
192
237
  },
193
- required: ["path", "old_string", "new_string"],
238
+ required: [],
194
239
  },
195
240
  readonly: false,
196
- touchedPaths(args) { return args.path ? [args.path] : [] },
241
+ touchedPaths(args) {
242
+ if (args.edits) return args.edits.map((e) => e.path).filter(Boolean)
243
+ return args.path ? [args.path] : []
244
+ },
197
245
  async execute(args, ctx) {
246
+ // 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
247
+ if (args.edits) {
248
+ if (!Array.isArray(args.edits) || args.edits.length === 0) {
249
+ throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
250
+ }
251
+ if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
252
+ throw new Error("edits array is mutually exclusive with path/old_string/new_string")
253
+ }
254
+ // 原子:先全量 read+match 检查(所有文件都能替换)——任一失败全不写
255
+ const prepared = []
256
+ for (const e of args.edits) {
257
+ if (!e.path) throw new Error("each edit must have a path")
258
+ if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
259
+ const abs = resolveInCwd(ctx, e.path)
260
+ const raw = await readFile(abs, "utf8")
261
+ const content = normalizeEOL(raw)
262
+ const occurrences = content.split(e.old_string).length - 1
263
+ if (occurrences === 0) {
264
+ throw new Error(
265
+ `edit aborted (atomic — no files written): old_string not found in ${e.path}\n` +
266
+ ` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
267
+ )
268
+ }
269
+ if (occurrences > 1 && !e.replace_all) {
270
+ throw new Error(
271
+ `edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${e.path}; ` +
272
+ `provide more context or set replace_all`
273
+ )
274
+ }
275
+ const updated = e.replace_all
276
+ ? content.split(e.old_string).join(e.new_string)
277
+ : content.replace(e.old_string, () => e.new_string)
278
+ const matchIdx = content.indexOf(e.old_string)
279
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
280
+ const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
281
+ prepared.push({ abs, path: e.path, raw, updated, editStartLine, lineShift, occurrences: e.replace_all ? occurrences : 1 })
282
+ }
283
+ // 全部检查通过——逐个写
284
+ const results = []
285
+ for (const p of prepared) {
286
+ await writeFile(p.abs, joinWithEol(normalizeEOL(p.updated).split("\n"), p.raw), "utf8")
287
+ recordWrite(p.abs, { type: "edit", startLine: p.editStartLine, shift: p.lineShift })
288
+ const withCtx = await appendWriteContext(p.abs, p.editStartLine, `Edited ${p.path}: replaced ${p.occurrences} occurrence(s)`)
289
+ results.push(withCtx)
290
+ }
291
+ return results.join("\n")
292
+ }
293
+
294
+ // 单文件(现状路径)
198
295
  const abs = resolveInCwd(ctx, args.path)
199
296
  if (!args.old_string) {
200
297
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
@@ -219,7 +316,11 @@ export const editTool = {
219
316
  throw new Error(
220
317
  `old_string not found in ${args.path}\n` +
221
318
  ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
222
- ` hints: whitespace mismatch? file already changed? try reading the file first` +
319
+ (lastWriteOf(abs)?.type === "write"
320
+ ? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
321
+ : isDirty(abs)
322
+ ? ` hints: this file was modified since your last read (a prior write marked it dirty) — re-read it to refresh your copy of the content, then retry\n`
323
+ : ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
223
324
  candText
224
325
  )
225
326
  }
@@ -235,9 +336,14 @@ export const editTool = {
235
336
  // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
236
337
  // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
237
338
  await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
238
- markDirty(abs)
339
+ // 2026-08-31 工具顺手度:记录受影响区(替换首行 + 行数差)——insert_after 精确判定
340
+ const matchIdx = content.indexOf(args.old_string)
341
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
342
+ const lineShift = args.new_string.split("\n").length - args.old_string.split("\n").length
343
+ recordWrite(abs, { type: "edit", startLine: editStartLine, shift: lineShift })
239
344
  const diff = gitDiffOne(ctx.cwd, abs)
240
- return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
345
+ const baseResult = `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
346
+ return await appendWriteContext(abs, editStartLine, baseResult)
241
347
  },
242
348
  }
243
349
 
@@ -265,7 +371,24 @@ export const insertAfterTool = {
265
371
  // inserting at a drifted position (the failure mode that corrupted test
266
372
  // structure repeatedly). after_regex callers get the same gate — a stale
267
373
  // target line is just as wrong, and the rule is simpler to reason about.
268
- if (isDirty(abs)) {
374
+ // 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
375
+ // (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
376
+ // (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
377
+ const lw = lastWriteOf(abs)
378
+ if (lw && args.after_line != null) {
379
+ if (lw.type === "write") {
380
+ throw new Error(
381
+ `${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
382
+ )
383
+ }
384
+ if (args.after_line > lw.startLine) {
385
+ throw new Error(
386
+ `${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
387
+ `行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
388
+ )
389
+ }
390
+ // after_line <= startLine → 行号未漂移 → 允许
391
+ } else if (isDirty(abs)) {
269
392
  throw new Error(
270
393
  `${args.path} was modified since your last read — line numbers may be stale.\n` +
271
394
  `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
@@ -307,9 +430,10 @@ export const insertAfterTool = {
307
430
  // edit — a CRLF file must not silently become LF here either).
308
431
  const updated = joinWithEol(lines, raw)
309
432
  await writeFile(abs, updated, "utf8")
310
- markDirty(abs)
433
+ recordWrite(abs, { type: "insert", startLine: targetLine, shift: normalizeEOL(args.content).split("\n").length })
311
434
  const diff = gitDiffOne(ctx.cwd, abs)
312
- return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
435
+ const baseResult = `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
436
+ return await appendWriteContext(abs, targetLine + 1, baseResult)
313
437
  },
314
438
  }
315
439
 
@@ -399,9 +523,10 @@ export const hashlineEditTool = {
399
523
  // Write back in the file's original EOL style (same rule as edit / apply_patch).
400
524
  const updated = joinWithEol(lines, raw)
401
525
  await writeFile(abs, updated, "utf8")
402
- markDirty(abs)
526
+ recordWrite(abs, { type: "edit", startLine: pos + 1, shift: newLines.length - target.length })
403
527
  const diff = gitDiffOne(ctx.cwd, abs)
404
- return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
528
+ const baseResult = `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
529
+ return await appendWriteContext(abs, pos + 1, baseResult)
405
530
  },
406
531
  }
407
532
 
package/src/tools/git.md CHANGED
@@ -1,6 +1,6 @@
1
1
  Run a git command. Only works inside a git repository.
2
2
 
3
- **Route to git instead of bash:** `git status`→status, `git log`→log, `git diff`→diff, `git show`→show, `git add`→add, `git rm`→rm, `git commit -m`→commit, `git push <remote> <branch> <tag>`→push, `git tag`→tag, `git branch`→branch, `git checkout`→checkout, `git restore`→restore, `git stash`→stash, `git fetch/pull`→fetch/pull, `git reset`→reset, `git revert`→revert, `git merge`→merge, `git cherry-pick`→cherry-pick.
3
+ **Route to git instead of bash:** `git status`→status, `git log`→log, `git diff`→diff, `git show`→show, `git add`→add, `git rm`→rm, `git commit -m`→commit, `git push <remote> <branch> <tag>`→push, `git tag`→tag, `git branch`→branch, `git checkout`→checkout, `git restore`→restore, `git stash`→stash, `git fetch/pull`→fetch/pull, `git reset`→reset, `git revert`→revert, `git merge`→merge, `git cherry-pick`→cherry-pick, `git ls-remote`→ls-remote.
4
4
 
5
5
  - action='diff': unified diff — what changed since last commit. staged=true for staged-only; ref=<ref> to compare a commit/branch; path=<dir> to scope.
6
6
  - action='status': working tree state — staged / unstaged / untracked / conflicts, categorized.
@@ -19,11 +19,13 @@ Run a git command. Only works inside a git repository.
19
19
  - action='revert': revert a commit (safe). ref=<commit> (default HEAD).
20
20
  - action='merge': merge ref=<branch/commit>; conflicts reported for you to resolve.
21
21
  - action='cherry-pick': cherry-pick ref=<commit>.
22
+ - action='ls-remote': light remote-ref check — which refs a remote has (read-only, network). remote=<origin>, ref=<branch/tag> optional, config for proxy.
22
23
  - action='checkpoint': git snapshots. checkpointAction=list/create/rewind/cat/versions; checkpointId required for rewind/cat.
23
24
 
24
25
  Parameters:
25
- - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick
26
+ - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote
26
27
  - workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
28
+ - config: (network actions push/fetch/pull/ls-remote) git -c overrides, e.g. ["http.proxy=http://10.2.2.112:3128"] for blocked remotes
27
29
  - path: (diff/log/add/commit/checkout/restore/rm) file or directory to scope / stage / restore
28
30
  - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
29
31
  - name: (branch/tag) the branch or tag name
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() }
@@ -47,6 +47,19 @@ function validateRef(ref, what = "git 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,6 +126,9 @@ 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"
@@ -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": {