thincoder 0.12.54 → 0.12.58

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 (67) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/advisor/messages.mjs +4 -2
  6. package/src/advisor/run.mjs +2 -2
  7. package/src/agent/dispatch.mjs +66 -26
  8. package/src/agent/helpers.mjs +13 -2
  9. package/src/agent/setup.mjs +14 -2
  10. package/src/agent/spawn-child.mjs +3 -1
  11. package/src/agent-tools/advisor.mjs +19 -9
  12. package/src/agent-tools/eng.mjs +2 -0
  13. package/src/agent-tools/subagent-check.mjs +107 -0
  14. package/src/agent-tools/subagent.mjs +205 -42
  15. package/src/agent.mjs +68 -3
  16. package/src/cli/make-agent.mjs +25 -0
  17. package/src/cli/memory-command.mjs +28 -7
  18. package/src/config.mjs +120 -8
  19. package/src/context.mjs +28 -7
  20. package/src/escape.mjs +76 -23
  21. package/src/mcp/transport-http.mjs +13 -1
  22. package/src/mcp.mjs +52 -7
  23. package/src/memory/core.mjs +78 -10
  24. package/src/memory/docs.mjs +33 -7
  25. package/src/memory.mjs +1 -1
  26. package/src/model-specs.mjs +23 -0
  27. package/src/prompts/discipline.md +15 -1
  28. package/src/prompts/engineering.md +62 -5
  29. package/src/prompts/main.md +1 -0
  30. package/src/prompts/system.md +2 -1
  31. package/src/provider/anthropic.mjs +7 -5
  32. package/src/provider/core.mjs +48 -26
  33. package/src/provider/google.mjs +57 -24
  34. package/src/provider/normalize.mjs +1 -1
  35. package/src/provider/rate.mjs +0 -2
  36. package/src/provider/responses.mjs +8 -13
  37. package/src/provider/sse.mjs +20 -0
  38. package/src/session.mjs +15 -0
  39. package/src/tools/apply_patch.md +2 -0
  40. package/src/tools/bash.md +2 -2
  41. package/src/tools/edit-batch.mjs +104 -0
  42. package/src/tools/edit.md +3 -0
  43. package/src/tools/execute.md +4 -4
  44. package/src/tools/execute.mjs +14 -22
  45. package/src/tools/file.mjs +17 -55
  46. package/src/tools/file_ops.md +1 -1
  47. package/src/tools/git.md +1 -1
  48. package/src/tools/git.mjs +8 -16
  49. package/src/tools/lint.md +1 -1
  50. package/src/tools/linter.mjs +9 -37
  51. package/src/tools/patch.mjs +1 -1
  52. package/src/tools/shared.mjs +7 -20
  53. package/src/tui/agent-turn.mjs +3 -3
  54. package/src/tui/clipboard.mjs +2 -2
  55. package/src/tui/cmd-eng.mjs +1 -0
  56. package/src/tui/cmd-mcp-form.mjs +197 -0
  57. package/src/tui/cmd-mcp.mjs +255 -114
  58. package/src/tui/index.mjs +25 -5
  59. package/src/tui/interaction.mjs +28 -1
  60. package/src/tui/key-handler.mjs +14 -2
  61. package/src/tui/mouse.mjs +1 -1
  62. package/src/tui/pickers.mjs +62 -4
  63. package/src/tui/render-frame.mjs +18 -10
  64. package/src/tui/render.mjs +4 -4
  65. package/src/tui/startup.mjs +4 -2
  66. package/src/tui/subagent-blocks.mjs +119 -4
  67. package/src/tui/tool-events.mjs +60 -15
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { proxyFetch } from "../proxy.mjs"
8
8
  import { requestWithRetry } from "./retry.mjs"
9
+ import { effectiveFetchTimeoutMs } from "./core.mjs"
9
10
 
10
11
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
11
12
  function mapFunctionCallingConfig(choice) {
@@ -100,7 +101,6 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
100
101
  body.toolConfig = { functionCallingConfig: mapFunctionCallingConfig(toolChoice) }
101
102
  }
102
103
 
103
- const FETCH_TIMEOUT_MS = 600_000
104
104
  // Gemini uses API key as query parameter
105
105
  const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
106
106
 
@@ -118,10 +118,9 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
118
118
  method: "POST",
119
119
  headers: { "Content-Type": "application/json" },
120
120
  body: JSON.stringify(body),
121
- signal: signal
122
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
123
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
124
- _headerTimeoutMs: FETCH_TIMEOUT_MS,
121
+ // 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
122
+ signal,
123
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
125
124
  _bodyIdleMs: 120_000,
126
125
  }, provider.proxyUri),
127
126
  { signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
@@ -194,32 +193,66 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
194
193
  }
195
194
 
196
195
  if (!response.body) throw new Error("No stream response body")
197
- for await (const chunk of response.body) {
198
- if (signal?.aborted) {
199
- const e = new DOMException("Aborted", "AbortError")
200
- e.reason = signal.reason
201
- throw e
202
- }
203
- buffer += decoder.decode(chunk, { stream: true })
204
- // BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
205
- if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
206
- const lines = buffer.split("\n")
207
- buffer = lines.pop()
196
+ // 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
197
+ const READ_IDLE_MS = 120_000
198
+ let idleTimer = null
199
+ const armIdle = () => {
200
+ if (idleTimer) clearTimeout(idleTimer)
201
+ idleTimer = setTimeout(() => {
202
+ try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
203
+ }, READ_IDLE_MS)
204
+ idleTimer.unref?.()
205
+ }
206
+ armIdle()
207
+ try {
208
+ for await (const chunk of response.body) {
209
+ armIdle()
210
+ if (signal?.aborted) {
211
+ const e = new DOMException("Aborted", "AbortError")
212
+ e.reason = signal.reason
213
+ throw e
214
+ }
215
+ buffer += decoder.decode(chunk, { stream: true })
216
+ // BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
217
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
218
+ const lines = buffer.split("\n")
219
+ buffer = lines.pop()
208
220
 
209
- for (const line of lines) {
221
+ for (const line of lines) {
222
+ if (!line.startsWith("data:")) continue
223
+ const data = line.slice(5).trim()
224
+ if (!data || data === "[DONE]") continue
225
+ processData(data)
226
+ }
227
+ }
228
+ buffer += decoder.decode()
229
+ for (const line of buffer.split("\n")) {
210
230
  if (!line.startsWith("data:")) continue
211
231
  const data = line.slice(5).trim()
212
232
  if (!data || data === "[DONE]") continue
213
233
  processData(data)
214
234
  }
215
- }
216
- buffer += decoder.decode()
217
- for (const line of buffer.split("\n")) {
218
- if (!line.startsWith("data:")) continue
219
- const data = line.slice(5).trim()
220
- if (!data || data === "[DONE]") continue
221
- processData(data)
235
+ } catch (e) {
236
+ if (idleTimer) clearTimeout(idleTimer)
237
+ if (e.name === "AbortError" && signal?.reason?.interrupt) {
238
+ result.interrupted = true
239
+ result.interruptMessage = signal.reason.message
240
+ return result
241
+ }
242
+ if (hasPartial(e)) {
243
+ result.partial = true
244
+ result.networkError = e.message ?? String(e)
245
+ return result
246
+ }
247
+ throw e
248
+ } finally {
249
+ if (idleTimer) clearTimeout(idleTimer)
222
250
  }
223
251
 
224
252
  return result
225
253
  }
254
+
255
+ /** google.mjs 无 hasChoices 追踪——只有流中途死且已有内容才标 partial(同 sse.mjs 语义的简化版) */
256
+ function hasPartial(e) {
257
+ return /ECONNRESET|terminated|idle timeout|network/i.test(e?.message ?? "")
258
+ }
@@ -5,8 +5,8 @@
5
5
  * pure functions sanitize the message array right before it hits the wire;
6
6
  * no dependency on chat()/retry logic. core.mjs re-exports them so
7
7
  * provider/index.mjs and tool-pairing.test.mjs keep their import paths.
8
+ * The caller passes the spec (providerSpec from core.mjs — provider-aware).
8
9
  */
9
- import { specForModel } from "../config.mjs"
10
10
 
11
11
  const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
12
12
 
@@ -3,8 +3,6 @@
3
3
  * Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
4
4
  */
5
5
 
6
- import { specForModel } from "../config.mjs"
7
-
8
6
  export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
9
7
  export const MAX_RETRIES = 3
10
8
  export const MAX_CONTINUATIONS = 3
@@ -12,11 +12,12 @@
12
12
  * 没有 "data: [DONE]"。
13
13
  */
14
14
  import { specForModel, isBailianHost } from "../config.mjs"
15
+ import { effectiveFetchTimeoutMs } from "./core.mjs"
15
16
  import { proxyFetch } from "../proxy.mjs"
16
17
  import { requestWithRetry } from "./retry.mjs"
17
18
  import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
18
19
 
19
- const FETCH_TIMEOUT_MS = 600_000
20
+ // 2026-09-01:FETCH_TIMEOUT_MS 常量退役(绝对墙钟废除)——经 core.mjs effectiveFetchTimeoutMs 共用
20
21
 
21
22
  /** 白名单:已实证 previous_response_id 的官方端(2026-08-31 真机验证:
22
23
  * 百炼 store:true 全链路 ✅;GLM(open.bigmodel.cn/api/v1)store:true 全链路 ✅)。 */
@@ -430,10 +431,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
430
431
  method: "POST",
431
432
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
432
433
  body: JSON.stringify(body),
433
- signal: signal
434
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
435
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
436
- _headerTimeoutMs: FETCH_TIMEOUT_MS,
434
+ signal,
435
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
437
436
  _bodyIdleMs: 120_000,
438
437
  }, provider.proxyUri),
439
438
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
@@ -451,10 +450,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
451
450
  method: "POST",
452
451
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
453
452
  body: JSON.stringify(fullBody),
454
- signal: signal
455
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
456
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
457
- _headerTimeoutMs: FETCH_TIMEOUT_MS,
453
+ signal,
454
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
458
455
  _bodyIdleMs: 120_000,
459
456
  }, provider.proxyUri),
460
457
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
@@ -471,10 +468,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
471
468
  method: "POST",
472
469
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
473
470
  body: JSON.stringify(fresh2.body),
474
- signal: signal
475
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
476
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
477
- _headerTimeoutMs: FETCH_TIMEOUT_MS,
471
+ signal,
472
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
478
473
  _bodyIdleMs: 120_000,
479
474
  }, provider.proxyUri),
480
475
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
@@ -165,8 +165,23 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
165
165
  }
166
166
 
167
167
  if (!response.body) throw new Error("No stream response body")
168
+ // 2026-09-01 读侧 idle 超时(根因修复):原实现靠 fetch 层的 600s 绝对墙钟兜底——长上下文子代理
169
+ // 单次生成(或上游排队)超 10 分钟即被腰斩("The operation was aborted due to timeout" 直透)。
170
+ // 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 READ_IDLE_MS 无新 chunk 才判死。
171
+ // 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
172
+ const READ_IDLE_MS = 120_000
173
+ let idleTimer = null
174
+ const armIdle = () => {
175
+ if (idleTimer) clearTimeout(idleTimer)
176
+ idleTimer = setTimeout(() => {
177
+ try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
178
+ }, READ_IDLE_MS)
179
+ idleTimer.unref?.()
180
+ }
181
+ armIdle()
168
182
  try {
169
183
  for await (const chunk of response.body) {
184
+ armIdle()
170
185
  if (signal?.aborted) {
171
186
  const e = new DOMException("The operation was aborted", "AbortError")
172
187
  e.reason = signal.reason
@@ -189,6 +204,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
189
204
  result.ruleTriggered = true
190
205
  result.ruleMessage = rule.message
191
206
  result.ruleName = rule.name
207
+ if (idleTimer) clearTimeout(idleTimer)
192
208
  return result
193
209
  }
194
210
  const existing = result._warnings ??= []
@@ -202,9 +218,11 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
202
218
  buffer += decoder.decode()
203
219
  processLines(buffer.split("\n"))
204
220
  } catch (e) {
221
+ if (idleTimer) clearTimeout(idleTimer)
205
222
  if (e.name === "AbortError" && signal?.reason?.interrupt) {
206
223
  result.interrupted = true
207
224
  result.interruptMessage = signal.reason.message
225
+ if (idleTimer) clearTimeout(idleTimer)
208
226
  return result
209
227
  }
210
228
  // 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
@@ -223,6 +241,8 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
223
241
  throw e
224
242
  }
225
243
 
244
+ if (idleTimer) clearTimeout(idleTimer)
245
+
226
246
  if (!hasChoices) {
227
247
  // Stream started as SSE but no choices were parsed — unusual. Include status for debugging.
228
248
  const raw = buffer.trim()
package/src/session.mjs CHANGED
@@ -116,6 +116,12 @@ export function saveSession(agent) {
116
116
  autoApprove: agent.autoApprove ?? false,
117
117
  engineering: agent.config?.agent?.engineering ?? false,
118
118
  engDesignToken: agent._engDesignToken ?? null,
119
+ // Multi-design slots ride the same round-trip (2026-09-01 audit #1): Map → {designId: token}
120
+ // (JSON-safe). Empty/absent Map → undefined → the key is dropped by JSON.stringify, so a
121
+ // cleared session writes NO field instead of resurrecting slots from the previous save.
122
+ engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
123
+ ? Object.fromEntries(agent._engDesignTokens)
124
+ : undefined,
119
125
  goal: agent.goal ?? null,
120
126
  advisor: agent.config?.advisor ?? null,
121
127
  pendingReminders: agent._pendingReminders ?? [],
@@ -315,6 +321,14 @@ export function applySession(agent, data) {
315
321
  agent._pendingReminders = data.pendingReminders ?? []
316
322
  agent._sessionStart = data.sessionStart ?? null
317
323
  agent._engDesignToken = data.engDesignToken ?? null
324
+ // Multi-design slots restore from the {designId: token} object (2026-09-01 audit #1). A legacy
325
+ // slot without the field restores NO Map (fresh state) — never resurrect slots the writer did
326
+ // not have. Expired tokens are rejected downstream by validateDesignToken (fail-closed, TTL).
327
+ if (data.engDesignTokens && typeof data.engDesignTokens === "object" && !Array.isArray(data.engDesignTokens)) {
328
+ agent._engDesignTokens = new Map(Object.entries(data.engDesignTokens))
329
+ } else {
330
+ delete agent._engDesignTokens
331
+ }
318
332
  // engineering is session-level (2026-08-29): the slot value is the CLI session's authority
319
333
  // — config.json is only the initial default / cross-end mirror. A legacy slot without the
320
334
  // field keeps whatever config.json seeded (unchanged behavior).
@@ -429,6 +443,7 @@ export function resetSessionState(agent) {
429
443
  agent.tasks = []
430
444
  agent._sessionStart = null
431
445
  agent._engDesignToken = null
446
+ agent._engDesignTokens = new Map() // multi-design slots die with the session (2026-09-01 fix #2)
432
447
  agent._compressFailures = 0
433
448
  agent._verifyRetries = 0
434
449
  agent._verifyPassed = undefined
@@ -1,5 +1,7 @@
1
1
  Apply a unified diff to one or more files, atomically: if any hunk fails to apply, nothing is written.
2
2
 
3
+ **Use it for whole-file and multi-file changes:** creating MULTIPLE new files at once (`--- /dev/null` header per file), whole-file replacement, and cross-file refactors — one unified-diff call covers the whole change. A batched call is one permission ask and one turn instead of N separate calls.
4
+
3
5
  Parameters:
4
6
  - patch (required): Unified diff text. One `--- a/path` / `+++ b/path` header pair per file, then `@@ -old,count +new,count @@` hunks. Use `--- /dev/null` to create a new file.
5
7
 
package/src/tools/bash.md CHANGED
@@ -33,5 +33,5 @@ Notes:
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.
36
- - Prefer read/glob/grep/ls for file operations inside the project — bash has no directory confinement.
37
- - NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and directory confinement correctly.
36
+ - Prefer read/glob/grep/ls for file operations inside the project — file tools and bash reach the same paths (no directory restriction).
37
+ - NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and EOL conventions correctly.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
3
+ * (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积"。)
4
+ * 从 file.mjs 拆出(500 行硬限,先例 git-ext.mjs)——纯移动,零行为变化。
5
+ *
6
+ * 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
7
+ * 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
8
+ * 全不写(原子性保留)。
9
+ */
10
+ import { readFile, writeFile } from "node:fs/promises"
11
+ import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
12
+ // file.mjs ↔ edit-batch.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
13
+ // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
14
+ import { recordWrite, appendWriteContext } from "./file.mjs"
15
+
16
+ /**
17
+ * Apply the `edits` array form: multi-file atomic replacement. Throws on any
18
+ * failure (atomic — nothing written). Returns the per-entry result text (joined).
19
+ */
20
+ export async function applyEditBatch(args, ctx) {
21
+ if (!Array.isArray(args.edits) || args.edits.length === 0) {
22
+ throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
23
+ }
24
+ if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
25
+ throw new Error("edits array is mutually exclusive with path/old_string/new_string")
26
+ }
27
+ // 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
28
+ // 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
29
+ // 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
30
+ // (原实现每条都基于盘上原始内容计算、写盘循环后置,同文件后者覆盖前者 →
31
+ // 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
32
+ const groups = new Map() // abs → 每文件一条流水线
33
+ for (const e of args.edits) {
34
+ if (!e.path) throw new Error("each edit must have a path")
35
+ if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
36
+ if (typeof e.new_string !== "string") {
37
+ throw new Error(
38
+ `edit for ${e.path}: new_string must be a string` +
39
+ `${e.new_string === undefined ? " (missing)" : ` (got ${typeof e.new_string})`}`,
40
+ )
41
+ }
42
+ const abs = resolveInCwd(ctx, e.path)
43
+ let g = groups.get(abs)
44
+ if (!g) {
45
+ const raw = await readFile(abs, "utf8")
46
+ g = { abs, path: e.path, raw, content: normalizeEOL(raw), edits: [] }
47
+ groups.set(abs, g)
48
+ }
49
+ g.edits.push(e)
50
+ }
51
+ const prepared = [] // 顺序 = args.edits 顺序(回显按条);recordWrite 每组一条合并快照
52
+ for (const g of groups.values()) {
53
+ g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
54
+ for (const e of g.edits) {
55
+ const content = g.content
56
+ const occurrences = content.split(e.old_string).length - 1
57
+ if (occurrences === 0) {
58
+ throw new Error(
59
+ `edit aborted (atomic — no files written): old_string not found in ${g.path}\n` +
60
+ ` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
61
+ )
62
+ }
63
+ if (occurrences > 1 && !e.replace_all) {
64
+ throw new Error(
65
+ `edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${g.path}; ` +
66
+ `provide more context or set replace_all`
67
+ )
68
+ }
69
+ const matchIdx = content.indexOf(e.old_string)
70
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
71
+ const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
72
+ const updated = e.replace_all
73
+ ? content.split(e.old_string).join(e.new_string)
74
+ : content.replace(e.old_string, () => e.new_string)
75
+ prepared.push({
76
+ g,
77
+ editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
78
+ lineShift,
79
+ occurrences: e.replace_all ? occurrences : 1,
80
+ })
81
+ g.content = updated // 串行累积:下一条基于本条应用后的内容
82
+ g.netShift += lineShift
83
+ }
84
+ }
85
+ // 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
86
+ // recordWrite 每组一条合并快照:startLine = 组内**所有**编辑受影响行的最小值
87
+ // (#2,2026-09-01 交付评审尾巴——原实现取首条 = 调用序第一条,逆序条目时
88
+ // 护栏下界过高、受影响区内的 insert_after 被放行),shift = 全组行数差累积
89
+ // ——受影响区护栏覆盖组内所有编辑
90
+ for (const g of groups.values()) {
91
+ const startLine = Math.min(...prepared.filter((p) => p.g === g).map((p) => p.editStartLine))
92
+ await writeFile(g.abs, joinWithEol(normalizeEOL(g.content).split("\n"), g.raw), "utf8")
93
+ recordWrite(g.abs, { type: "edit", startLine, shift: g.netShift })
94
+ }
95
+ const results = []
96
+ for (const p of prepared) {
97
+ // #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
98
+ // autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
99
+ const diff = gitDiffOne(ctx.cwd, p.g.abs)
100
+ const base = `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
101
+ results.push(await appendWriteContext(p.g.abs, p.editStartLine, base))
102
+ }
103
+ return results.join("\n")
104
+ }
package/src/tools/edit.md CHANGED
@@ -8,11 +8,14 @@ Edit a file by exact string replacement. old_string must match exactly once unle
8
8
  - Rewrite an entire file → `write`
9
9
  - Rename a symbol project-wide → `lsp` or `grep` first to map every caller
10
10
 
11
+ **Batch multiple edits into ONE call via the `edits` array** (preferred over N single edit calls): multiple changes to the SAME file go into one `edits` array (entries are applied serially, each based on the previous one's result); independent changes across MULTIPLE files also go into the same `edits` array — one call, atomic (any failure writes nothing). A batched call is one permission ask, one undo unit, and one turn instead of N.
12
+
11
13
  Parameters:
12
14
  - path (required): File path
13
15
  - old_string (required): Exact text to find and replace
14
16
  - new_string (required): Replacement text
15
17
  - replace_all: Replace all occurrences instead of just one (default false)
18
+ - edits: Array of {path, old_string, new_string, replace_all?} entries — batch form; mutually exclusive with path/old_string/new_string
16
19
 
17
20
  Notes:
18
21
  - Prefer this over write for targeted edits — it's safer and keeps changes targeted
@@ -1,4 +1,4 @@
1
- Execute JavaScript — either inline `code` or a workspace `scriptFile`. Runs in a real `node` process with top-level `await` and dynamic `import()`. Use inline `code` to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
1
+ Execute JavaScript — either inline `code` or a `scriptFile`. Runs in a real `node` process with top-level `await` and dynamic `import()`. Use inline `code` to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
2
2
 
3
3
  **Route to execute instead of bash:**
4
4
  - `node -e "…"` → execute (inline code; top-level await + import() + console all work)
@@ -7,15 +7,15 @@ Execute JavaScript — either inline `code` or a workspace `scriptFile`. Runs in
7
7
 
8
8
  Parameters:
9
9
  - code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import. Use this OR scriptFile.
10
- - scriptFile: run a workspace .mjs/.js file with node (self-contained — no prelude; the file imports what it needs). Path relative to workdir, confined to the workspace. Use this OR code.
10
+ - scriptFile: run a .mjs/.js file with node (self-contained — no prelude; the file imports what it needs). Path relative to workdir no directory restriction. Use this OR code.
11
11
  - nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
12
- - workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
12
+ - workdir: run in this directory (relative to cwd no directory restriction; default cwd)
13
13
  - filter: optional — only return output lines matching this regex (case-insensitive)
14
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`.
18
- - File paths are confined to the workspace root (`..` traversal is denied) but `require`/`process`/`import()` are full Node, same boundary as bash.
18
+ - The prelude's readFile/writeFile/glob/grep helpers resolve paths against the working directory (helper-only guard — `require`/`process`/`import()` are full Node, same boundary as bash).
19
19
  - A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
20
20
  - Output capped at ~50KB; use `writeFile` to a file if you need more.
21
21
  - Use `write`/`edit`/`apply_patch` for source edits. Still use `bash` for package-manager/CLI subprocesses (`npm test`/`npm publish`/`vsce`), servers, and interactive/TTY programs — execute covers in-process JS and `node <script>`/`node --test`/`node --check`, not arbitrary CLI or long-running programs.
@@ -11,19 +11,20 @@
11
11
  * process is killed like bash).
12
12
  *
13
13
  * The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
14
- * log/require (paths confined to the workspace root). Full Node via require()/
15
- * process/import() is available — same boundary as bash, no fake sandbox.
14
+ * log/require (helper paths resolve against the working directory orthopedic
15
+ * guard, not a sandbox). Full Node via require()/process/import() is available —
16
+ * same boundary as bash, no fake sandbox.
16
17
  *
17
18
  * Parameters:
18
19
  * code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
19
- * scriptFile — run a workspace .mjs/.js file with node (self-contained, no prelude). Use this OR code.
20
+ * scriptFile — run a .mjs/.js file with node (self-contained, no prelude). Use this OR code.
20
21
  * nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
21
- * workdir — run in this sub-directory (confined to the workspace)
22
+ * workdir — run in this sub-directory (no directory restriction)
22
23
  * filter — return only output lines matching this regex (case-insensitive)
23
24
  * timeoutMs — timeout (default 30s, max 60s)
24
25
  */
25
26
  import { spawn } from "node:child_process"
26
- import { dirname, resolve, relative, isAbsolute, sep } from "node:path"
27
+ import { dirname, resolve } from "node:path"
27
28
  import { fileURLToPath, pathToFileURL } from "node:url"
28
29
  import { DESC } from "./shared.mjs"
29
30
 
@@ -34,20 +35,12 @@ const DEFAULT_TIMEOUT = 30_000
34
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
35
36
  const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
36
37
 
37
- /** True when `abs` is inside `root` (handles `..` and cross-drive, which
38
- * relative() returns as an absolute path on Windows). */
39
- function isInside(root, abs) {
40
- const rel = relative(root, abs)
41
- if (isAbsolute(rel)) return false
42
- return rel !== ".." && !rel.startsWith(".." + sep)
43
- }
44
-
45
- /** Resolve workdir relative to cwd, asserting it stays within the workspace. */
38
+ /** Resolve workdir relative to cwd no boundary assertion
39
+ * (§10.1 2026-09-02: workspace confinement removed; the child node process is
40
+ * not directory-limited — same boundary as bash). */
46
41
  function resolveBaseDir(cwd, workdir) {
47
42
  if (!workdir || typeof workdir !== "string") return cwd
48
- const abs = resolve(cwd, workdir)
49
- if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
50
- return abs
43
+ return resolve(cwd, workdir)
51
44
  }
52
45
 
53
46
  /** Keep only output lines matching a regex (execute filter, case-insensitive). */
@@ -143,7 +136,7 @@ export const executeTool = {
143
136
  },
144
137
  scriptFile: {
145
138
  type: "string",
146
- description: "Run a workspace .mjs/.js file with node (self-contained, no prelude). Path relative to workdir, confined to the workspace. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
139
+ description: "Run a .mjs/.js file with node (self-contained, no prelude). Path relative to workdir no directory restriction. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
147
140
  },
148
141
  nodeArgs: {
149
142
  type: "array",
@@ -152,7 +145,7 @@ export const executeTool = {
152
145
  },
153
146
  workdir: {
154
147
  type: "string",
155
- description: "Run in this directory (relative to cwd, confined to the workspace; default cwd)",
148
+ description: "Run in this directory (relative to cwd no directory restriction; default cwd)",
156
149
  },
157
150
  filter: {
158
151
  type: "string",
@@ -180,10 +173,9 @@ export const executeTool = {
180
173
  let childArgs
181
174
  if (args.scriptFile) {
182
175
  if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
183
- // scriptFile mode: run a workspace .mjs/.js file with node [nodeArgs...]. Self-contained —
184
- // no prelude (a real node process imports what it needs). Confined to the workspace.
176
+ // scriptFile mode: run a .mjs/.js file with node [nodeArgs...]. Self-contained —
177
+ // no prelude (a real node process imports what it needs). No directory restriction.
185
178
  const scriptAbs = resolve(baseDir, args.scriptFile)
186
- if (!isInside(ctx.cwd, scriptAbs)) return `Error: scriptFile escapes the workspace: ${args.scriptFile}`
187
179
  let nodeArgs
188
180
  try { nodeArgs = validateNodeArgs(args.nodeArgs) }
189
181
  catch (e) { return `Error: ${e.message}` }
@@ -13,13 +13,10 @@ import {
13
13
  findCandidates,
14
14
  FFFD_WARNING,
15
15
  } from "./shared.mjs";
16
+ import { applyEditBatch } from "./edit-batch.mjs";
16
17
  import { specForModel } from "../config.mjs";
17
18
  import { createHash } from "node:crypto";
18
- import { mkdir } from "node:fs/promises";
19
- import { readFile } from "node:fs/promises";
20
- import { stat } from "node:fs/promises";
21
- import { writeFile } from "node:fs/promises";
22
- import { unlink } from "node:fs/promises";
19
+ import { mkdir, readFile, stat, writeFile, unlink } from "node:fs/promises";
23
20
  import { join, relative, dirname } from "node:path";
24
21
 
25
22
  const MAX_FILE_READ_BYTES = 10_000_000
@@ -54,8 +51,9 @@ export function clearLastWrite(abs) { lastWrites.delete(abs) }
54
51
  * 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
55
52
  * 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
56
53
  * 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
57
- * write 全文重写跳过(无行号锚点——模型刚写的知道内容)。 */
58
- async function appendWriteContext(abs, writeLine, baseResult) {
54
+ * write 全文重写跳过(无行号锚点——模型刚写的知道内容)。
55
+ * edit-batch.mjs(数组形态)复用——导出仅供内部模块,非公共 API。 */
56
+ export async function appendWriteContext(abs, writeLine, baseResult) {
59
57
  const content = normalizeEOL(await readFile(abs, "utf8"))
60
58
  const lines = content.split("\n")
61
59
  const start = Math.max(1, writeLine - 3)
@@ -77,7 +75,7 @@ export const readTool = {
77
75
  path: { type: "string", description: "File path (relative to cwd or absolute)" },
78
76
  offset: { type: "number", description: "1-based line number to start from" },
79
77
  limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
80
- allowExternal: { type: "boolean", description: "Allow reading files outside the working directory. Only set true when the user explicitly provided an external path — never use this to explore beyond cwd on your own." },
78
+ allowExternal: { type: "boolean", description: "No-op retained for API compatibility path resolution no longer asserts a working-directory boundary (all paths resolve relative to cwd)." },
81
79
  hashes: { type: "boolean", description: "Include SHA256 line hashes for hash-based editing (default false). Use when you plan to edit the file with hashline_edit." },
82
80
  },
83
81
  required: ["path"],
@@ -222,7 +220,7 @@ export const editTool = {
222
220
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
223
221
  edits: {
224
222
  type: "array",
225
- description: "2026-08-31 工具顺手度:一次多文件原子替换——任一失败全不写(先全量检查所有替换可执行)。与 path/old_string/new_string 互斥。",
223
+ description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. Mutually exclusive with path/old_string/new_string.",
226
224
  items: {
227
225
  type: "object",
228
226
  properties: {
@@ -244,58 +242,22 @@ export const editTool = {
244
242
  },
245
243
  async execute(args, ctx) {
246
244
  // 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
- }
245
+ // (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
246
+ if (args.edits) return applyEditBatch(args, ctx)
293
247
 
294
248
  // 单文件(现状路径)
295
249
  const abs = resolveInCwd(ctx, args.path)
296
250
  if (!args.old_string) {
297
251
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
298
252
  }
253
+ // #5(2026-09-01 交付评审尾巴):new_string 非字符串(含 undefined)在写盘前拒绝——
254
+ // 原缺陷:replace 回调返回 undefined 被字符串化成 "undefined" 写入盘,随后
255
+ // args.new_string.split 才 TypeError——文件已损坏 + 错误信息不知所云。
256
+ if (typeof args.new_string !== "string") {
257
+ throw new Error(
258
+ `new_string must be a string${args.new_string === undefined ? " (missing)" : ` (got ${typeof args.new_string})`} — nothing written`,
259
+ )
260
+ }
299
261
  const raw = await readFile(abs, "utf8")
300
262
  const content = normalizeEOL(raw)
301
263
  const occurrences = content.split(args.old_string).length - 1