thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,348 @@
1
+ /**
2
+ * edit-diff.mjs — edit 工具的行级 diff 内核(权威语义 = EDIT.md——2026-09-04 拆出)。
3
+ *
4
+ * edit 语义(权威 = EDIT.md §2-§5——2026-09-08 语义升级 D1/D2/D3 已并入):两种定位形态(互斥)——
5
+ * ① 按行号改(D1/F1):line(单行)/ startLine+endLine(1-based 闭区间)→ 直接替换该
6
+ * 行/行范围为 new_string(无需 old_string——实现落点 edit-batch.mjs applyLineEdit);
7
+ * 阶段 2 删行形态(EDIT.md §5——约束):省略 new_string = 删除该行/范围(有界意图);
8
+ * ② 内容定位:old_string = 变化区**当前内容**(单次匹配——匹配档位:逐字 → P15.11 唯一
9
+ * 空白差异窗口 → D2/F2 模糊匹配(行级 normalize 后 ≥90% 行相等——edit-batch.mjs
10
+ * findFuzzyWindow));new_string = 该区的**期望结果**。判定序:
11
+ * 0. 分支 0(EDIT.md §4——单行精确替换):old 单行 && new 单行 && old 全文唯一 &&
12
+ * new 非空 → **就地替换该行**(行数不变——EOL 由调用方 joinWithEol 恢复);
13
+ * 1. 零重叠(old 每一行都不出现在 new 行集中)→ **替换即删**(D3/F3,2026-09-08——
14
+ * breaking:old 行整体删除、new 取而代之——旧行不再保留;新增行用 insert_after);
15
+ * 2. 有公共行(≥1)→ 行级 LCS——公共行保留、差异行增删;
16
+ * 3. new 与 old 行级完全一致 → 原样替换(no-op 语义——仍报成功)。
17
+ * 内容形态空 new_string(纯删除意图)→ 显式报错(不静默——先于分支 0——单行替换永不
18
+ * 成删除);行号形态显式空串 → 同样显式错(防误删——模板落空 ≠ 删行意图;EDIT.md
19
+ * §5 空串 vs 省略矩阵);**省略 new_string = 删行/范围**(阶段 2 命名删行形态);
20
+ * 分支 0 只在 computeEditEntry 条目判定层(壳/桥/批量自动继承)——
21
+ * applyPatchLines(纯 diff 层)语义不动。old/new 行数各上限 1000(超限报错)。
22
+ *
23
+ * 行尾权威 = EDIT-TOOL-EOL-DESIGN.md:判定/应用在 normalizeEOL 后的 LF 域计算,
24
+ * 写回由调用方 joinWithEol(原文) 恢复原行尾。
25
+ * 模块拆分(file.mjs ≤500 硬限):file.mjs 只留工具壳与转发——单形态执行体与前置校验
26
+ * 分支整段迁出至本模块(模块拆分写优先纪律:先迁后删、逻辑体不变、wiring 导入)。
27
+ * edit-diff.mjs ↔ edit-batch.mjs 循环引用(2026-09-08——D1/D2 实现落点 edit-batch):
28
+ * 两侧仅函数声明(提升初始化)、仅调用期使用——ESM 循环下安全(同 file.mjs 先例)。
29
+ */
30
+ import { readFile, writeFile } from "node:fs/promises"
31
+ import {
32
+ resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck, findCandidates,
33
+ } from "./shared.mjs"
34
+ // file.mjs ↔ edit-diff.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
35
+ // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
36
+ import { recordWrite, appendWriteContext, lastWriteOf, isDirty } from "./file.mjs"
37
+ // D1/D2 落点(EDIT.md §6——edit-batch.mjs):按行号改 + 模糊匹配纯函数。
38
+ import { applyLineEdit, findFuzzyWindow, FUZZY_MATCH_NOTE } from "./edit-batch.mjs"
39
+
40
+ export const MAX_DIFF_LINES = 1000
41
+ export const REGION_TOO_LARGE = "edit region too large — narrow the change"
42
+ export const EMPTY_NEW_STRING = "empty new_string — for deletion, keep the context lines you want to preserve in both old_string and new_string"
43
+ export const EMPTY_NEW_STRING_LINE = "empty new_string with line-based targeting is an explicit error — OMIT new_string to delete the line/range (deleting by number is explicit intent; an empty replacement is a mistake, not a delete)"
44
+ export const EDIT_ARGS_MUTEX = "edits array is mutually exclusive with top-level old_string/new_string/line/startLine/endLine — a top-level path is allowed (default for entries without their own path); provide each change's targeting (old_string or line range) and new_string inside its edits entry"
45
+
46
+ /** D1(2026-09-08):条目带按行号改参数(line / startLine / endLine 任一)。 */
47
+ export function hasLineParams(entry) {
48
+ return entry.line !== undefined || entry.startLine !== undefined || entry.endLine !== undefined
49
+ }
50
+
51
+ /** 删行结果文本(EDIT.md——删行返回文本;out.deleted 才调用):单行 "line N" / 范围 "lines N-M"。
52
+ * 删行 lineShift = -(范围行数)——end = start - 1 - lineShift。 */
53
+ export function deleteTarget(out) {
54
+ const start = out.editStartLine
55
+ const end = start - 1 - out.lineShift
56
+ return start === end ? `line ${start}` : `lines ${start}-${end}`
57
+ }
58
+
59
+ /** 行切分(尾随换行终止最后一行——非额外空行):"a\nb\n" → ["a","b"]。
60
+ * 2026-09-08 导出(D1 落点 edit-batch.mjs applyLineEdit 复用同语义)。 */
61
+ export function splitLines(text) {
62
+ const lines = text.split("\n")
63
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
64
+ return lines
65
+ }
66
+
67
+ /**
68
+ * 行级 LCS(整行相等判定)合并:公共行保留(LCS 序)、old 独有行删除、new 独有行按其在
69
+ * new 中相对公共行的位置插入;**零重叠 → 替换即删**(D3,2026-09-08 语义升级——
70
+ * breaking:old 行整体删除、new 取而代之,旧行不再保留——原"零重叠→插入保留旧行"
71
+ * 语义废止;新增行用 insert_after)。结果在 LF 域;区域尾随换行随 oldText(区域边界保持)。
72
+ * 返回 { ok: true, resultText } 或 { ok: false, reason }。
73
+ */
74
+ export function applyPatchLines(oldText, newText) {
75
+ if (newText === "") return { ok: false, reason: EMPTY_NEW_STRING }
76
+ const oldLines = splitLines(oldText)
77
+ const newLines = splitLines(newText)
78
+ if (oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
79
+ return { ok: false, reason: REGION_TOO_LARGE }
80
+ }
81
+ // 判定 1(D3——替换即删):零重叠 → old 行删、new 整体取代(旧行不再保留)
82
+ const newSet = new Set(newLines)
83
+ const merged = oldLines.some((l) => newSet.has(l))
84
+ ? lcsMerge(oldLines, newLines) // 判定 2/3——公共行保留、差异行增删
85
+ : [...newLines]
86
+ return { ok: true, resultText: merged.join("\n") + (oldText.endsWith("\n") ? "\n" : "") }
87
+ }
88
+
89
+ /** LCS 推导(DP + 回溯)——整行相等判定;结果 = 公共行 + 差异行增删的合并序列。 */
90
+ function lcsMerge(a, b) {
91
+ const n = a.length
92
+ const m = b.length
93
+ const width = m + 1
94
+ const dp = new Uint16Array((n + 1) * width)
95
+ for (let i = 1; i <= n; i++) {
96
+ for (let j = 1; j <= m; j++) {
97
+ dp[i * width + j] = a[i - 1] === b[j - 1]
98
+ ? dp[(i - 1) * width + (j - 1)] + 1
99
+ : Math.max(dp[(i - 1) * width + j], dp[i * width + (j - 1)])
100
+ }
101
+ }
102
+ const merged = []
103
+ let i = n
104
+ let j = m
105
+ while (i > 0 && j > 0) {
106
+ if (a[i - 1] === b[j - 1]) {
107
+ merged.push(a[i - 1])
108
+ i--
109
+ j--
110
+ } else if (dp[(i - 1) * width + j] >= dp[i * width + (j - 1)]) {
111
+ i-- // old 独有行——删除
112
+ } else {
113
+ merged.push(b[j - 1]) // new 独有行——按 LCS 相对位置插入
114
+ j--
115
+ }
116
+ }
117
+ while (j > 0) { merged.push(b[j - 1]); j-- }
118
+ return merged.reverse()
119
+ }
120
+
121
+ /**
122
+ * (2026-09-05 用户裁定——EDIT.md §5——顶层 path + edits 并存合法化:顶层 path = 无自带
123
+ * path 条目的默认——模型直觉形态「顶层 path + 数组条目」不再拒绝;条目自带 path 优先)。
124
+ * 互斥收窄为只对顶层 old_string/new_string(+ 2026-09-08 D1 的 line/startLine/endLine)——
125
+ * edits 下它们无批语义可解释——顶层 path 不再触发。
126
+ */
127
+ export function assertEditArgsExclusive(args) {
128
+ if (args.old_string !== undefined || args.new_string !== undefined || hasLineParams(args)) {
129
+ throw new Error(EDIT_ARGS_MUTEX)
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 前置校验——内容形态:空 old / 非字符串 new;按行号改形态(D1,2026-09-08;阶段 2
135
+ * 删行——EDIT.md §5):与 old_string 互斥 / line 与 startLine|endLine 互斥 /
136
+ * startLine+endLine 须成对 / 正整数 / endLine ≥ startLine / replace_all 不适用 /
137
+ * new_string:省略(undefined)= 删行 / 显式空串 = 显式错(防误删)/ 其他类型报错。
138
+ * error 文本按调用形态(单形态 rich / 批量 label)。
139
+ * opts: { label = ""(批量前缀 "edit for <path>: "), rich = true(单形态完整文本) }
140
+ */
141
+ export function validateEditEntry(entry, opts = {}) {
142
+ const label = opts.label ?? ""
143
+ if (hasLineParams(entry)) {
144
+ if (entry.old_string !== undefined) {
145
+ throw new Error(label + "line/startLine/endLine are mutually exclusive with old_string — target by line number OR by content, not both")
146
+ }
147
+ if (entry.line !== undefined && (entry.startLine !== undefined || entry.endLine !== undefined)) {
148
+ throw new Error(label + "line is mutually exclusive with startLine/endLine — single line or a range, not both")
149
+ }
150
+ if ((entry.startLine === undefined) !== (entry.endLine === undefined)) {
151
+ throw new Error(label + "startLine and endLine must be given together (1-based, inclusive)")
152
+ }
153
+ for (const k of ["line", "startLine", "endLine"]) {
154
+ if (entry[k] !== undefined && (!Number.isInteger(entry[k]) || entry[k] < 1)) {
155
+ throw new Error(label + `${k} must be a positive integer (1-based), got ${JSON.stringify(entry[k])}`)
156
+ }
157
+ }
158
+ if (entry.startLine !== undefined && entry.endLine < entry.startLine) {
159
+ throw new Error(label + `endLine (${entry.endLine}) is before startLine (${entry.startLine})`)
160
+ }
161
+ if (entry.replace_all) {
162
+ throw new Error(label + "replace_all does not apply to line-based edits (a line range is already a single explicit target)")
163
+ }
164
+ if (entry.new_string === "") {
165
+ throw new Error(label + EMPTY_NEW_STRING_LINE + (opts.rich === false ? "" : " — nothing written"))
166
+ }
167
+ if (entry.new_string !== undefined && typeof entry.new_string !== "string") {
168
+ throw new Error(
169
+ label + `new_string must be a string (got ${typeof entry.new_string})` +
170
+ (opts.rich === false ? "" : " — nothing written")
171
+ )
172
+ }
173
+ return
174
+ }
175
+ if (!entry.old_string) {
176
+ throw new Error(
177
+ label + "old_string must not be empty" + (opts.rich === false ? "" : " (empty string matches everywhere and would corrupt the file)")
178
+ )
179
+ }
180
+ if (entry.new_string === "") {
181
+ // 空串 vs 省略矩阵(EDIT.md §5/§8.1):内容形态空串 = 显式错(无界意图防静默)——
182
+ // 先于 replace_all 分支(否则 replace_all + 空串会静默删除全部 occurrences——绕过保护)
183
+ throw new Error(label + EMPTY_NEW_STRING + (opts.rich === false ? "" : " — nothing written"))
184
+ }
185
+ if (typeof entry.new_string !== "string") {
186
+ throw new Error(
187
+ label + `new_string must be a string${entry.new_string === undefined ? " (missing)" : ` (got ${typeof entry.new_string})`}` +
188
+ (opts.rich === false ? "" : " — nothing written")
189
+ )
190
+ }
191
+ }
192
+
193
+ /**
194
+ * 分支 0 形态判定(EDIT.md §4):old/new 各为单行——无内部换行(splitLines
195
+ * 语义:尾随单个换行符是行终止而非新行——old/new 行内容均不含 \n)——且 new 非空
196
+ * ("" 经 splitLines 切分为 []——由下方 !== "" 守卫先拦截——空 new 不落本分支——落
197
+ * applyPatchLines 的空 new 显式错误——单行替换永不成删除)。多匹配与 replace_all 不落
198
+ * 本分支(occurrences 错误 / 字面全部先行)。
199
+ */
200
+ function isSingleLineReplace(entry) {
201
+ return (
202
+ entry.new_string !== "" &&
203
+ splitLines(entry.old_string).length === 1 &&
204
+ splitLines(entry.new_string).length === 1
205
+ )
206
+ }
207
+
208
+ /**
209
+ * 条目级判定+应用(单形态与批量共用——EDIT.md §6"批量条目判定+应用调用 edit-diff"):
210
+ * 匹配校验(精确存在——非 replace_all 单次)→ 按判定序应用(分支 0 单行替换 / 零重叠替换即删 /
211
+ * LCS 替换 / replace_all 字面)→
212
+ * 元数据(受影响区首行/行数差/次数——recordWrite 与结果回显用)。
213
+ * 返回 { updated, editStartLine, lineShift, occurrences, note?, deleted? };失败抛错(含路径/引导)。
214
+ * opts: { path, abortPrefix(批量 "edit aborted (atomic — no files written): ") }
215
+ */
216
+ /**
217
+ * P15.11(2026-09-05 用户裁定——edit 连续失败分析):old_string 逐字 not-found 时——
218
+ * 若文件中存在**唯一**窗口:行数与 old 相同、逐行 trim() 相等(内容零差异——差异仅前导/
219
+ * 尾随空白——EOL 已在 normalize 域消除)→ 返回 { actual }(actual = 文件窗口原文);
220
+ * 多窗口(两处 trim 同内容不同空白)→ null(歧义不猜——报错引导);old 含尾换行 → null
221
+ * (终止符语义边界——不做窗口猜测)。模型从 read 记忆拷贝 old_string 时丢/加前导空格是
222
+ * 纯机械损耗(本轮 12 次 edit 失败中 8 次空白差异)——内容零差异时自动落点 + 结果明示。
223
+ */
224
+ function findWhitespaceVariant(content, old) {
225
+ if (old.endsWith("\n")) return null
226
+ const oldLines = old.split("\n")
227
+ const fileLines = content.split("\n")
228
+ const m = oldLines.length
229
+ if (m === 0 || fileLines.length < m) return null
230
+ const trimmed = oldLines.map((l) => l.trim())
231
+ let hit = null
232
+ for (let i = 0; i + m <= fileLines.length; i++) {
233
+ let same = true
234
+ for (let j = 0; j < m; j++) {
235
+ if (fileLines[i + j].trim() !== trimmed[j]) { same = false; break }
236
+ }
237
+ if (!same) continue
238
+ const actual = fileLines.slice(i, i + m).join("\n")
239
+ if (actual === old) continue // 逐字已匹配——occurrences=0 前提下不会发生
240
+ if (hit) return null // 多窗口空白差异 → 歧义 → 不猜
241
+ hit = { actual }
242
+ }
243
+ return hit
244
+ }
245
+
246
+ /**
247
+ * P15.11 note 文案(各端成功消息追加——双端同句)
248
+ */
249
+ export const WHITESPACE_VARIANT_NOTE = "applied to the unique whitespace-only match (content identical, leading/trailing whitespace differs from your old_string)"
250
+
251
+ export function computeEditEntry(content, entry, opts = {}) {
252
+ validateEditEntry(entry, { ...opts, rich: !opts.abortPrefix })
253
+ // D1 按行号改(EDIT.md §2/§5):行号定位条目直接按行替换(applyLineEdit——落点
254
+ // edit-batch.mjs),不走 old_string 匹配/判定序;省略 new_string = 删行(EDIT.md §5);
255
+ // 互斥/越界/显式空串均在 validateEditEntry/applyLineEdit 显式报错。
256
+ if (hasLineParams(entry)) return applyLineEdit(content, entry, opts)
257
+ // P15.11:not-found 时先查唯一空白差异窗口——命中则以其原文为实际 old 继续(内容零差异
258
+ // ——自动落点 + note 明示);实质差异/歧义仍走下方 not-found 报错(不猜内容)。
259
+ let old = entry.old_string
260
+ let occurrences = content.split(old).length - 1
261
+ let note = null
262
+ if (occurrences === 0) {
263
+ const variant = findWhitespaceVariant(content, old)
264
+ if (variant) { old = variant.actual; occurrences = 1; note = WHITESPACE_VARIANT_NOTE }
265
+ }
266
+ // D2 模糊匹配(2026-09-08——P15.11 的推广):逐字/trim 等价都失败后,找唯一
267
+ // normalize 后 ≥90% 行相等的窗口——细微差异(缩进/引号/行尾空格/少量行差异)自动
268
+ // 落点 + note 明示;多窗口歧义/不足阈值仍走 not-found 报错(不猜内容)。
269
+ if (occurrences === 0) {
270
+ const fuzzy = findFuzzyWindow(content, old)
271
+ if (fuzzy) { old = fuzzy.actual; occurrences = 1; note = FUZZY_MATCH_NOTE }
272
+ }
273
+ if (occurrences === 0) {
274
+ const preview = entry.old_string.slice(0, 100).split("\n")[0]
275
+ const cands = findCandidates(content.split("\n"), entry.old_string)
276
+ let candText = ""
277
+ if (cands.length > 0) {
278
+ const header = entry.old_string.includes("\n")
279
+ ? ` similar lines (old_string line 1: "${entry.old_string.split("\n")[0].slice(0, 80)}"):`
280
+ : " similar lines:"
281
+ candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
282
+ }
283
+ throw new Error(
284
+ `${opts.abortPrefix ?? ""}old_string not found in ${opts.path ?? "file"}\n` +
285
+ ` searched: "${preview}${entry.old_string.length > 100 ? "…" : ""}" — use grep to locate the actual content\n` +
286
+ (opts.absPath && lastWriteOf(opts.absPath)?.type === "write"
287
+ ? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
288
+ : opts.absPath && isDirty(opts.absPath)
289
+ ? ` 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`
290
+ : ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
291
+ candText
292
+ )
293
+ }
294
+ if (occurrences > 1 && !entry.replace_all) {
295
+ throw new Error(
296
+ `${opts.abortPrefix ?? ""}old_string matches ${occurrences} times in ${opts.path ?? "file"}; provide more context or set replace_all`
297
+ )
298
+ }
299
+ const matchIdx = content.indexOf(old)
300
+ const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
301
+ let updated
302
+ let resultForShift
303
+ if (entry.replace_all) {
304
+ // 字面替换——不做插入规则(old 多处时"插到哪处"无定义)——不落分支 0(EDIT.md §4)
305
+ updated = content.split(old).join(entry.new_string)
306
+ resultForShift = entry.new_string
307
+ } else if (isSingleLineReplace(entry)) {
308
+ // 分支 0(EDIT.md §4——单行精确替换):old 单行 && new 单行 && old 唯一匹配
309
+ // (occ==1 既有校验保证)&& new 非空(空 new 显式错误先于本分支——防删除)→ **就地
310
+ // 替换该行**——不再零重叠插入(P15.8——行尾段编辑反复踩的插入坑)。只替换行内容段:
311
+ // old/new 的尾随换行符属文件结构而非行内容——留在原位——行数不变——EOL 由调用方
312
+ // joinWithEol 恢复(normalizeEOL 后本域为 LF)。边界声明(评审 2026-09-04):new 为
313
+ // 行内容空串(如 new="\n"——置空行意图)时,常规带终止换行的行变空行(行数不变);
314
+ // 唯一例外是**无终止换行的末行**被置空——该行随文件结束自然消失(行数 −1——文件格式
315
+ // 固有边界——空行无法表达)——不落 EMPTY_NEW_STRING 错误路径(new 本身非空串)。
316
+ const oldLine = splitLines(old)[0]
317
+ const newLine = splitLines(entry.new_string)[0]
318
+ updated = content.slice(0, matchIdx) + newLine + content.slice(matchIdx + oldLine.length)
319
+ resultForShift = newLine
320
+ } else {
321
+ const r = applyPatchLines(old, entry.new_string)
322
+ if (!r.ok) throw new Error(r.reason)
323
+ updated = content.slice(0, matchIdx) + r.resultText + content.slice(matchIdx + old.length)
324
+ resultForShift = r.resultText
325
+ }
326
+ // 行数差 = 应用后区域行数 − 旧区域行数(分支 0 单行替换:0;零重叠替换即删:new−old;LCS:new−old)
327
+ const lineShift = splitLines(resultForShift).length - splitLines(old).length
328
+ return { updated, editStartLine, lineShift, occurrences: entry.replace_all ? occurrences : 1, note }
329
+ }
330
+
331
+ /**
332
+ * 单形态 edit 执行体(自 file.mjs 整段迁出——模块拆分写优先纪律:逻辑体不变)——
333
+ * file.mjs 只留工具壳与转发。
334
+ */
335
+ export async function runSingleEdit(args, ctx) {
336
+ const abs = resolveInCwd(ctx, args.path)
337
+ const raw = await readFile(abs, "utf8")
338
+ const content = normalizeEOL(raw)
339
+ const out = computeEditEntry(content, args, { path: args.path, absPath: abs })
340
+ // 写回按原文行尾(joinWithEol)——normalizeEOL 先行避免 \r\n 污染 split/join
341
+ await writeFile(abs, joinWithEol(normalizeEOL(out.updated).split("\n"), raw), "utf8")
342
+ recordWrite(abs, { type: "edit", startLine: out.editStartLine, shift: out.lineShift })
343
+ const diff = gitDiffOne(ctx.cwd, abs)
344
+ const baseResult = out.deleted
345
+ ? `Deleted ${deleteTarget(out)} of ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
346
+ : `Edited ${args.path}: replaced ${out.occurrences} occurrence(s)${out.note ? ` — ${out.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
347
+ return await appendWriteContext(abs, out.editStartLine, baseResult)
348
+ }
package/src/tools/edit.md CHANGED
@@ -1,23 +1,30 @@
1
- Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
1
+ Edit a file as a patch — targeted by content or by line number. Two targeting forms (mutually exclusive — use one): ① line-based: `line: N` replaces that single line, `startLine: N, endLine: M` replaces the inclusive 1-based line range — no old_string needed when you know the line number; give new_string to replace, or OMIT new_string to delete the line/range; ② content-based: old_string is the current content of the region to change (must match exactly once); new_string is the desired result of that region. old_string matching is tolerant: exact match first, then a unique whitespace-only variant, then fuzzy match (unique window with ≥90% of lines identical after trimming, tab→space indent and quote normalization — ASCII single, curly single/double and backtick quotes all unify to straight double quotes). Lines shared by old/new are kept; lines only in new_string take their position relative to the shared lines (LCS order); lines only in old_string are deleted — a replacement never leaves old lines behind (zero overlap → old lines are replaced by new_string, not kept). A unique single-line old_string paired with a single-line new_string replaces that exact line in place (line count unchanged). To add a new line without removing anything use insert_after. replace_all keeps literal replacement of every occurrence — the diff rules do not apply.
2
2
 
3
3
  **Routing — pick the right edit tool:**
4
- - Precise line-targeted change`hashline_edit` (hash-based, immune to whitespace/encoding drift preferred)
5
- - One exact-string swapthis tool
6
- - Add a function/block after a known line → `insert_after`
7
- - Same change across multiple files or many spots `apply_patch`
8
- - Rewrite an entire file`write`
4
+ - Delete a line/range by number omit new_string: edit with `line: N` / `startLine: N, endLine: M`
5
+ - Line numbers fresh (just read) `line`/`startLine`/`endLine` targeting — precise, no content copy needed
6
+ - Line numbers may have drifted / content has whitespace-encoding noise → `hashline_edit` (content-hash addressing — position-independent)
7
+ - Add a line/entry after a known lineinsert_after
8
+ - Same change across multiple files apply_patch
9
+ - Rewrite an entire file → write
9
10
  - Rename a symbol project-wide → `lsp` or `grep` first to map every caller
10
11
 
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
+ **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. A top-level path may accompany the array — entries without their own path inherit it (entry paths override).
12
13
 
13
14
  Parameters:
14
- - path (required): File path
15
- - old_string (required): Exact text to find and replace
16
- - new_string (required): Replacement text
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
15
+ - path: File path (single form: required; with the edits array: optional — the default for entries without their own path)
16
+ - old_string: Current content of the region to change (must match exactly once — tolerant matching: exact whitespace-only variant fuzzy ≥90% line-equal). Mutually exclusive with line/startLine/endLine — required unless line targeting is used
17
+ - new_string: Desired result of the region — diffed against old_string (shared lines kept; old-only lines deleted — a replacement never leaves old lines behind). Content-based edits: required — an explicit empty string is an error (protects against forgetting it). Line-based edits (line/startLine/endLine): give it to replace the line/range, or OMIT it to delete — an explicit empty string is NOT deletion (error — omit instead)
18
+ - line: 1-based line number replace that single line with new_string, or OMIT new_string to DELETE it (mutually exclusive with old_string and startLine/endLine)
19
+ - startLine / endLine: 1-based inclusive line range to replace with new_string (given together; mutually exclusive with old_string) — OMIT new_string to DELETE the range
20
+ - replace_all: Replace all occurrences instead of just one (default false; content-based targeting only)
21
+ - edits: Array of {path?, old_string?, new_string?, line?, startLine?, endLine?, replace_all?} entries — batch form; mutually exclusive with top-level old_string/new_string/line/startLine/endLine — a top-level path is allowed and applies to entries without their own path
19
22
 
20
23
  Notes:
21
24
  - Prefer this over write for targeted edits — it's safer and keeps changes targeted
22
- - If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
25
+ - If old_string matches zero times (even fuzzy): error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
26
+ - Content-based edits: new_string empty is an explicit error (protects against forgetting it).
27
+ - Line-based edits: OMIT new_string to delete the line/range — deleting by number is an explicit, bounded intent.
28
+ - Returns `Edited <path>: replaced N occurrence(s)` (delete-mode: `Deleted line N of <path>` / `Deleted lines N-M of <path>`) + git diff + syntax-check note + context block (L..-L..).
23
29
  - Never fabricate the old_string — copy it verbatim from the actual file using read first
30
+ - use the most recent read of the file as the source of old_string / line numbers / hashes — re-read after the file changed
@@ -1,4 +1,4 @@
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`.
1
+ Execute JavaScript — either inline `code` or a `scriptFile`. Runs in a real child `node` process a pure node ESM environment: top-level `await` and dynamic `import()` are available, no globals are injected. File reads/writes/searches belong to the dedicated read/ls/glob/grep/write/edit tools not to execute. If a script genuinely needs fs/path, `import` the `node:` module inside the code (one explicit import line).
2
2
 
3
3
  **Route to execute instead of bash:**
4
4
  - `node -e "…"` → execute (inline code; top-level await + import() + console all work)
@@ -6,16 +6,16 @@ Execute JavaScript — either inline `code` or a `scriptFile`. Runs in a real `n
6
6
  - `node --test <file>` / `node --check <file>` → execute with scriptFile + nodeArgs
7
7
 
8
8
  Parameters:
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 .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.
9
+ - code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported. Pure node ESM — no preloaded helpers; import `node:fs`/`node:path` etc. yourself when needed. File reads/writes go through the dedicated read/ls/glob/grep/write/edit tools. Use this OR scriptFile.
10
+ - scriptFile: run a .mjs/.js file with node (self-contained — 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
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
- - timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers slow `node --test` suites and long package scripts)
14
+ - timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers `node --test` suites and package scripts)
15
15
 
16
16
  Notes:
17
- - `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
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).
17
+ - `console.log(...)` prints to the result; objects are JSON-stringified where needed.
19
18
  - A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
20
- - Output capped at ~50KB; use `writeFile` to a file if you need more.
19
+ - Output is capped at ~50KB; when a script overruns it, an explicit `[output truncated]` marker is appended — print large results in chunks, or have the script write them to a file (node:fs) and read that file back with the `read` tool.
21
20
  - 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.
21
+ - Irreversibility: it runs with full filesystem access and no automatic undo — script side effects are permanent; checkpoint (git) before risky bulk operations.
@@ -10,30 +10,34 @@
10
10
  * killable timeout (an in-process infinite loop would freeze the CLI; a child
11
11
  * process is killed like bash).
12
12
  *
13
- * The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
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.
13
+ * The child runs PURE node ESM — no helpers are injected (exec-prelude.mjs
14
+ * retired 2026-09-03, TOOLS.md §12: preloaded readFile/writeFile/glob/grep/log
15
+ * helpers made execute look like a file tool, bypassing the dedicated
16
+ * read/ls/glob/grep/write/edit tools). Scripts that need fs/path import the
17
+ * node: modules themselves — same boundary as bash, no fake sandbox.
17
18
  *
18
19
  * Parameters:
19
20
  * code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
20
- * scriptFile — run a .mjs/.js file with node (self-contained, no prelude). Use this OR code.
21
+ * scriptFile — run a .mjs/.js file with node (self-contained, imports what it needs). Use this OR code.
21
22
  * nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
22
23
  * workdir — run in this sub-directory (no directory restriction)
23
24
  * filter — return only output lines matching this regex (case-insensitive)
24
- * timeoutMs — timeout (default 30s, max 60s)
25
+ * timeoutMs — timeout (default 30s, max 600000ms)
25
26
  */
26
- import { spawn } from "node:child_process"
27
- import { dirname, resolve } from "node:path"
28
- import { fileURLToPath, pathToFileURL } from "node:url"
27
+ import { spawn, execFileSync } from "node:child_process"
28
+ import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
30
 
31
31
  const MAX_SCRIPT = 50_000
32
32
  const MAX_OUTPUT = 50_000
33
33
  const DEFAULT_TIMEOUT = 30_000
34
34
 
35
- const __dirname = dirname(fileURLToPath(import.meta.url))
36
- const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
35
+ /**
36
+ * 超时错误文本——带重试引导(TOOLS.md §14.1 D14.1.2——"下一跳"):数字 = 实际生效的
37
+ * timeoutMs(Math.min(t, 600_000) 或默认 30s)——上限 600000 与 schema/头注/execute.md 一致。
38
+ */
39
+ const timeoutErrorText = (timeoutMs) =>
40
+ `Error: script timed out after ${timeoutMs}ms — retry with a larger timeoutMs (up to 600000) for long scripts, or use bash (default 120s) for shell commands`
37
41
 
38
42
  /** Resolve workdir relative to cwd — no boundary assertion
39
43
  * (§10.1 2026-09-02: workspace confinement removed; the child node process is
@@ -54,15 +58,34 @@ function applyFilter(output, filter) {
54
58
  }
55
59
  }
56
60
 
61
+ /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
62
+ * Timeout/abort must reach grandchildren: a script that spawned children keeps the
63
+ * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
64
+ * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
65
+ function killProcessTree(child) {
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
+
57
74
  /** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
58
75
  * Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
59
- function runNode(childArgs, baseDir, root, timeoutMs, signal) {
76
+ function runNode(childArgs, baseDir, timeoutMs, signal) {
60
77
  return new Promise((resolvePromise) => {
61
78
  const child = spawn(process.execPath, childArgs, {
62
79
  cwd: baseDir,
63
- env: { ...process.env, THINCODER_EXEC_ROOT: root },
64
80
  stdio: ["ignore", "pipe", "pipe"],
65
81
  windowsHide: true,
82
+ // POSIX detached → 子进程为组首——killProcessTree 的 -pid 组杀可达孙进程(win 用
83
+ // taskkill /T 不需 detached)——2026-09-05 advisor 🟡#4
84
+ detached: process.platform !== "win32",
85
+ // 双保险(2026-09-05——裸 spawn 无 signal 教训——system.mjs 同款):abort 时
86
+ // Node 自动杀直接子进程(第一道)——onAbort 手动 kill 兜底(SIGKILL 防信号陷阱
87
+ // 脚本——见 kill 注释)——AbortError 在 error 分支让路(close 必随——走 mode 收尾)
88
+ ...(signal ? { signal } : {}),
66
89
  })
67
90
 
68
91
  let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
@@ -76,11 +99,12 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
76
99
  if (signal) signal.removeEventListener("abort", onAbort)
77
100
  resolvePromise({ text, ok })
78
101
  }
79
- // SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
80
- const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
102
+ // Tree kill(SIGKILL/taskkill /T——signal-trapping 脚本躲不开 watchdog;孙进程持管道
103
+ // 时直接 kill 不达——close 不触发拖到 kick——2026-09-05 advisor 🟡#4 对齐 bash/verify)
104
+ const kill = () => { killProcessTree(child) }
81
105
  // After kill, wait for "close" (child fully reaped) before settling — settling
82
106
  // early races the caller deleting the cwd dir while the child still holds it.
83
- const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : `Error: script timed out after ${timeoutMs}ms`, false), 3000) }
107
+ const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : timeoutErrorText(timeoutMs), false), 3000) }
84
108
  const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
85
109
 
86
110
  timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
@@ -97,10 +121,16 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
97
121
  }
98
122
  child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
99
123
  child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
100
- child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
124
+ child.on("error", (e) => {
125
+ // signal 双保险(2026-09-05):abort 时 Node signal option 杀子进程 → 本事件以
126
+ // AbortError 先触发——让路(close 必随——close 分支 mode==="abort" →
127
+ // settle("(stopped)"))——不把中止误报为启动失败
128
+ if (e.name === "AbortError") return
129
+ settle(`Error: failed to start node: ${e.message}`, false)
130
+ })
101
131
  child.on("close", (code) => {
102
132
  if (mode === "abort") return settle("(stopped)", false)
103
- if (mode === "timeout") return settle(`Error: script timed out after ${timeoutMs}ms`, false)
133
+ if (mode === "timeout") return settle(timeoutErrorText(timeoutMs), false)
104
134
  const out = outBuf.trimEnd()
105
135
  const err = errBuf.trim()
106
136
  if (code === 0) {
@@ -132,11 +162,11 @@ export const executeTool = {
132
162
  properties: {
133
163
  code: {
134
164
  type: "string",
135
- description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import. Use this OR scriptFile.",
165
+ description: "JavaScript code to execute (top-level await and dynamic import() supported). Pure node ESM — no preloaded globals; import node: modules (fs/path) yourself when needed. File reads/writes go through the dedicated read/ls/glob/grep/write/edit tools. Use this OR scriptFile.",
136
166
  },
137
167
  scriptFile: {
138
168
  type: "string",
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>`.",
169
+ description: "Run a .mjs/.js file with node (self-contained the file imports what it needs). Path relative to workdir — no directory restriction. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
140
170
  },
141
171
  nodeArgs: {
142
172
  type: "array",
@@ -174,7 +204,7 @@ export const executeTool = {
174
204
  if (args.scriptFile) {
175
205
  if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
176
206
  // 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.
207
+ // a real node process imports what it needs. No directory restriction.
178
208
  const scriptAbs = resolve(baseDir, args.scriptFile)
179
209
  let nodeArgs
180
210
  try { nodeArgs = validateNodeArgs(args.nodeArgs) }
@@ -186,12 +216,13 @@ export const executeTool = {
186
216
  if (code.length > MAX_SCRIPT) {
187
217
  return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
188
218
  }
189
- childArgs = ["--input-type=module", "--eval", `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`]
219
+ // inline mode: pure node ESM — no prelude, nothing injected (TOOLS.md §12).
220
+ childArgs = ["--input-type=module", "--eval", code]
190
221
  }
191
222
 
192
- const { text, ok } = await runNode(childArgs, baseDir, ctx.cwd, timeoutMs, ctx.signal)
223
+ const { text, ok } = await runNode(childArgs, baseDir, timeoutMs, ctx.signal)
193
224
  // Only filter successful output — never swallow an error report behind a filter.
194
225
  if (!ok) return text
195
226
  return args.filter ? applyFilter(text, args.filter) : text
196
227
  },
197
- }
228
+ }