thincoder 0.12.43 → 0.12.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  本文件记录 ThinCoder CLI 的发布历史。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),版本遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.12.45] — 2026-08-26
6
+
7
+ ### Fixed
8
+
9
+ - **编辑工具 CRLF 行尾写回丢失**:`edit` / `apply_patch` / `hashline_edit` / `insert_after` 在 Windows CRLF 文件上写回全部被转成 LF(normalize 后直接落盘)——现按"首个换行符类型"检测原文件行尾并原样恢复,diff 不再整文件重写;`new_string`/`new_content` 含 CRLF 时先归一化再转换,杜绝 `\r\r\n`
10
+ - **`old_string not found` 黑盒报错**:失败时返回相似度最高的 top 3 候选行(行号+预览+LCS 相似度,阈值 0.5,多行 old_string 只对首行并标注 `old_string line 1:`)——从盲猜变导航
11
+
12
+ ### Added
13
+
14
+ - **`write` 行尾语义**:覆盖既有文件按原行尾恢复;新建文件默认 LF,同目录多数派为 CRLF 时跟随(≤20 文件嗅探)
15
+ - **`hashline_edit` 编码损坏探测**:文件含 U+FFFD(替换符)时结果追加警告(编码可能已损坏、哈希寻址可能不可靠),不阻断
16
+
17
+ ### Changed
18
+
19
+ - 候选相似度 LCS 计算复用模块级 DP 缓冲(大文件失败路径不再有每行分配的 GC 压力)
20
+
21
+ ## [0.12.44] — 2026-08-25
22
+
23
+ ### Fixed
24
+
25
+ - **安全修复:subagent 变形 role 绕过工程模式门禁**——`role="Coder"/" coder"` 等非精确字符串穿透两个模式门禁(精确比较),fallthrough 到全工具/无 overlay 子代理,绕过设计评审拿到完整写权限。修复:execute 入口 ROLES 白名单,未知 role 直接 throw(fail-closed);防回归测试锁定 7 种变形值 × 两种模式
26
+ - IK9UZ8 思考型模型标题生成(vscode 端同修对齐)
27
+ - 文档状态/TODO 销账(24+13 处"待评审"→"已实现";TODO 7 条已实现条目核对关闭)
28
+ - TUI.md 章节号重编号(## 4-10 顺延,无重复)
29
+
30
+ ### Added
31
+
32
+ - **ESLint 引入**(规则基线对齐 vscode 端):lint script + 21 个 error 清零(死赋值/cause 补全)
33
+ - **跨端同构模块语义锚点比对测试**:14 个跨仓库契约(advisor 收敛协议、蒸馏语义、64K 阈值)两端必须一致——单边漂移立即红
34
+ - **RELEASE.md**:npm 发布流程 + 4 条踩坑记录(vsce 自动 bump、Open VSX 异步激活、ovsx 无 TTY 静默失败、prepublishOnly)
35
+
36
+ ### Docs
37
+
38
+ - 文档债收口:6 处"待合并"全部处理(真碎片合并/归档/独立保留定性)
39
+
5
40
  ## [0.12.43] — 2026-08-25
6
41
 
7
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.43",
3
+ "version": "0.12.45",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -32,8 +32,13 @@
32
32
  },
33
33
  "scripts": {
34
34
  "test": "node --test \"test/*.mjs\"",
35
- "prepublishOnly": "node --test \"test/*.mjs\""
35
+ "prepublishOnly": "node --test \"test/*.mjs\"",
36
+ "lint": "eslint src"
36
37
  },
37
38
  "author": "liwei <liwei@51marine.com> (上海新舶)",
38
- "license": "MIT"
39
+ "license": "MIT",
40
+ "devDependencies": {
41
+ "eslint": "^9.0.0",
42
+ "@eslint/js": "^9.0.0"
43
+ }
39
44
  }
@@ -49,7 +49,7 @@ export function collectRepoSnapshots(repos, cwd) {
49
49
  const targets = repos.length > 0 ? repos : [cwd]
50
50
  const parts = []
51
51
  for (const repo of targets) {
52
- let status = "", diff = ""
52
+ let status, diff
53
53
  try {
54
54
  status = execFileSync("git", ["status", "--porcelain"], {
55
55
  cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
@@ -153,7 +153,7 @@ export function isDocOnlyChange(repos, cwd) {
153
153
  const targets = repos.length > 0 ? repos : [cwd]
154
154
  let sawChanges = false
155
155
  for (const repo of targets) {
156
- let status = ""
156
+ let status
157
157
  try {
158
158
  status = execFileSync("git", ["status", "--porcelain"], {
159
159
  cwd: repo, encoding: "utf8", timeout: GIT_TIMEOUT, stdio: ["ignore", "pipe", "pipe"],
@@ -49,7 +49,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
49
49
  const prepared = []
50
50
  for (const toolCall of toolCalls) {
51
51
  const tool = toolByName.get(toolCall.name)
52
- let args = {}
52
+ let args
53
53
  try {
54
54
  args = JSON.parse(toolCall.arguments || "{}")
55
55
  } catch {
@@ -7,40 +7,40 @@ import { randomUUID, createHmac } from "node:crypto"
7
7
  import { runAdvisorReview } from "../advisor/run.mjs"
8
8
  import { isDocFile } from "../advisor/repos.mjs"
9
9
 
10
- const TOKEN_EXPIRY_MS = 3600000 // 1 hour
10
+ const TOKEN_TTL_DEFAULT_MS = 7 * 24 * 3600 * 1000 // 7-day ceiling (v2 2026-08-25): multi-batch delivery must not re-review an unchanged design within a week; agent.engTokenTtlMs overrides
11
11
  const TOKEN_SECRET = process.env.THINCODER_TOKEN_SECRET || "thincoder-default-secret"
12
12
 
13
+ /** Effective token TTL: config override with runtime validation (advisor timeoutMs precedent —
14
+ * invalid values fall back to the default, never silently disable the ceiling). */
15
+ function effectiveTokenTtlMs(agent) {
16
+ const cfg = agent?.config?.agent?.engTokenTtlMs
17
+ return (Number.isFinite(cfg) && cfg > 0) ? cfg : TOKEN_TTL_DEFAULT_MS
18
+ }
19
+
13
20
  /** Generate a signed design token with expiration */
14
- function generateDesignToken() {
21
+ function generateDesignToken(agent) {
15
22
  const uuid = randomUUID()
16
- const expiresAt = Date.now() + TOKEN_EXPIRY_MS
23
+ const expiresAt = Date.now() + effectiveTokenTtlMs(agent)
17
24
  const payload = `${uuid}:${expiresAt}`
18
25
  const signature = createHmac("sha256", TOKEN_SECRET).update(payload).digest("hex").slice(0, 16)
19
26
  return `${payload}:${signature}`
20
27
  }
21
28
 
22
- /** Validate design token: check format, expiration, and signature
23
- * For backward compatibility, tokens that don't match the new format are accepted as-is
24
- */
29
+ /** Validate design token: format, expiration, signature — ALL fail-closed (v2 2026-08-25).
30
+ * The two legacy fail-open branches (parts!=3 true, NaN expiry true) were pass-through
31
+ * backdoors: any malformed string bypassed validation. Only exact signed tokens pass now.
32
+ * Format must be exactly uuid:expiresAt:hmacSig. */
25
33
  export function validateDesignToken(token) {
26
34
  if (!token || typeof token !== "string") return false
27
-
35
+
28
36
  // New format: uuid:expiresAt:signature (3 parts separated by ':')
29
37
  const parts = token.split(":")
30
- if (parts.length !== 3) {
31
- // Old format or simple token - accept for backward compatibility
32
- return true
33
- }
34
-
38
+ if (parts.length !== 3) return false // fail-closed (was: return true)
39
+
35
40
  const [uuid, expiresAt, signature] = parts
36
41
  const expTime = parseInt(expiresAt, 10)
37
-
38
- // If it looks like a new format token, validate it properly
39
- if (isNaN(expTime)) {
40
- // Not a valid new format, treat as old format
41
- return true
42
- }
43
-
42
+ if (isNaN(expTime)) return false // fail-closed (was: return true)
43
+
44
44
  // Check expiration
45
45
  if (Date.now() > expTime) return false
46
46
 
@@ -130,7 +130,7 @@ export const advisorTool = {
130
130
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
131
131
  // The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
132
132
  // the gate is a mechanical string match, not fragile semantics parsing.
133
- const designToken = reviewType === "design" ? generateDesignToken() : null
133
+ const designToken = reviewType === "design" ? generateDesignToken(agent) : null
134
134
  const result = await runAdvisorReview(agent, reviewType, {
135
135
  onOutput: ctx.onOutput,
136
136
  signal: ctx.signal,
@@ -159,9 +159,13 @@ export const advisorTool = {
159
159
  return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
160
160
  }
161
161
  // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
162
- // Guard: result === null means the review was skipped (advisor disabled / not engineering
163
- // mode) — a skipped review must not revoke an already-issued token.
164
- if (result !== null) agent._engDesignToken = null
162
+ // Guards (v2 2026-08-25): result === null means the review was SKIPPED (advisor disabled /
163
+ // not engineering mode) — must not revoke. An error reply (own "Advisor:" prefix — the
164
+ // error-return convention of runAdvisorReview) is a provider crash/timeout artifact, not
165
+ // a completed verdict — a network glitch must not revoke unrelated standing tokens.
166
+ // Only a COMPLETED review that did not pass revokes.
167
+ const isCompletedReview = result !== null && !result.startsWith("Advisor:")
168
+ if (isCompletedReview) agent._engDesignToken = null
165
169
  // Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
166
170
  if (result) {
167
171
  const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
@@ -3,7 +3,7 @@
3
3
  * In engineering mode the agent follows design-before-code methodology.
4
4
  * Toggled here at session level; persisted by /eng.
5
5
  */
6
- import { ENG_ON_REMINDER } from "../agent.mjs"
6
+ import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
7
7
 
8
8
  export const engTool = {
9
9
  name: "eng",
@@ -27,8 +27,7 @@ export const engTool = {
27
27
  ctx.agent._touchedFiles = [] // clear mutation tracking
28
28
  ctx.agent._lastEngState = false
29
29
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
30
- ctx.agent._pendingReminders.push(
31
- "[System reminder: engineering mode is now OFF — standard discipline applies. Changes go through the normal workflow.]")
30
+ ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
32
31
  // 持久化工程模式状态到会话
33
32
  if (ctx.persistState) {
34
33
  await ctx.persistState({
@@ -42,8 +41,14 @@ export const engTool = {
42
41
  return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
43
42
  }
44
43
  if (args.action === "enter") {
44
+ // Idempotent enter (v2 2026-08-25): already in engineering mode → no-op. The old
45
+ // unconditional token clear killed standing design tokens on a redundant defensive
46
+ // eng(enter) — only a real off→on transition requires a fresh design review.
47
+ if (ctx.agent.config.agent.engineering) {
48
+ return "Engineering mode already active. Existing design tokens stay valid."
49
+ }
45
50
  ctx.agent.config.agent.engineering = true
46
- ctx.agent._engDesignToken = null // re-entering requires a fresh design review
51
+ ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
47
52
  ctx.agent._lastEngState = true
48
53
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
49
54
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
@@ -77,6 +77,14 @@ export const subagentTool = {
77
77
  const parent = ctx.agent
78
78
  const role = args.role
79
79
 
80
+ // Role normalization + whitelist (2026-08-25, coder-leak fix): exact-string gates let
81
+ // variant roles ("Coder", " coder") bypass BOTH mode gates and fall through to
82
+ // full tools / no overlay — a full-write coder without design review. Schema enums are
83
+ // advisory; providers don't enforce them. Fail closed on unknown roles.
84
+ const ROLES = new Set(["explore", "plan", "coder", "eng-coder"])
85
+ if (!ROLES.has(role)) {
86
+ throw new Error(`Unknown subagent role: ${JSON.stringify(role)}. Valid roles: explore, plan, coder, eng-coder (exact spelling).`)
87
+ }
80
88
  // Role is mutually exclusive per mode: normal mode → "coder", engineering mode → "eng-coder"
81
89
  if (parent.config?.agent?.engineering && role === "coder") {
82
90
  throw new Error("Engineering mode: use role='eng-coder' for implementation tasks.")
package/src/agent.mjs CHANGED
@@ -61,14 +61,19 @@ export const ENG_ON_REMINDER =
61
61
  // Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
62
62
  export { hasCodeMutations } from "./advisor/repos.mjs"
63
63
 
64
- /** Engineering-mode status injectionone reminder when engineering mode is ON. */
64
+ /** Engineering mode OFF remindershared with the eng tool and the injector. */
65
+ export const ENG_OFF_REMINDER =
66
+ "[System reminder: engineering mode is now OFF — standard discipline applies. " +
67
+ "Changes go through the normal workflow: you may edit files directly, advisor/verify " +
68
+ "guards apply per config.]"
69
+
70
+ /** Engineering-mode status injection — one reminder on EVERY transition (2026-08-25:
71
+ * OFF is announced too — the model must know the gates lifted; silence after /eng-off
72
+ * left it guessing. Covers TUI /eng, resume, and any path bypassing the eng tool.) */
65
73
  function injectEngineeringReminder(agent) {
66
74
  const eng = agent.config?.agent?.engineering ?? false
67
- // Only notify on transitions into ON — OFF is silence (the system prompt
68
- // already carries the standard discipline; no need to remind the model
69
- // that it's in the default mode).
70
- if (eng && !agent._lastEngState) {
71
- agent.history.push({ role: "user", content: ENG_ON_REMINDER, transient: true })
75
+ if (eng !== agent._lastEngState) {
76
+ agent.history.push({ role: "user", content: eng ? ENG_ON_REMINDER : ENG_OFF_REMINDER, transient: true })
72
77
  }
73
78
  agent._lastEngState = eng
74
79
  }
package/src/config.mjs CHANGED
@@ -235,7 +235,7 @@ export function loadConfig() {
235
235
  try {
236
236
  config = JSON.parse(readFileSync(configPath, "utf8"))
237
237
  } catch (error) {
238
- throw new Error(`Config file is not valid JSON, check or delete it: ${configPath}\n ${error.message}`)
238
+ throw new Error(`Config file is not valid JSON, check or delete it: ${configPath}\n ${error.message}`, { cause: error })
239
239
  }
240
240
  }
241
241
 
@@ -29,7 +29,7 @@ function git(cwd, args, { allowFail = false } = {}) {
29
29
  return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
30
30
  } catch (error) {
31
31
  if (allowFail) return null
32
- throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`)
32
+ throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`, { cause: error })
33
33
  }
34
34
  }
35
35
 
@@ -324,7 +324,7 @@ export async function listFileVersions(cwd, filePath) {
324
324
  } else if ((meta.tracked ?? []).includes(filePath) || (meta.untracked ?? []).includes(filePath)) {
325
325
  // Legacy snapshot (no per-file meta): fall back to stat-ing the copy
326
326
  const src = join((meta.tracked ?? []).includes(filePath) ? join(root, id, "files") : join(root, id, "untracked"), filePath)
327
- let size = null, sha = null
327
+ let size, sha
328
328
  try {
329
329
  const buf = await readFile(src)
330
330
  size = buf.length
@@ -55,14 +55,16 @@ export async function pullTeam(dir) {
55
55
  try { await git(dir, ["rebase", "--abort"]) } catch {
56
56
  abortFailed = true
57
57
  }
58
- throw new Error(
58
+ const conflictError = new Error(
59
59
  `Team memory sync conflict: local and remote modified the same entry.\n` +
60
60
  `Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
61
61
  (abortFailed
62
62
  ? `(WARNING: git rebase --abort also failed — the repo may be in a conflicted state. ` +
63
63
  `Run \`cd ${dir} && git rebase --abort\` manually to clean up.)`
64
64
  : `(The local repo has been restored to its pre-sync state — nothing was lost.)`),
65
+ { cause: error },
65
66
  )
67
+ throw conflictError
66
68
  }
67
69
  throw error
68
70
  }
@@ -198,7 +198,7 @@ export async function putMarkdown(memory, { layer, dir, type, title, content, ta
198
198
  * vanished entries are removed from the index.
199
199
  */
200
200
  export async function syncDir(memory, { layer, dir }) {
201
- let names = []
201
+ let names
202
202
  try {
203
203
  names = (await readdir(dir)).filter((n) => n.endsWith(".md"))
204
204
  } catch {
@@ -7,6 +7,11 @@ import {
7
7
  resolveInCwd,
8
8
  resolveExternal,
9
9
  normalizeEOL,
10
+ detectFileEol,
11
+ joinWithEol,
12
+ majorityEol,
13
+ findCandidates,
14
+ FFFD_WARNING,
10
15
  } from "./shared.mjs";
11
16
  import { specForModel } from "../config.mjs";
12
17
  import { createHash } from "node:crypto";
@@ -160,7 +165,12 @@ export const writeTool = {
160
165
  await mkdir(dirname(abs), { recursive: true })
161
166
  const st = await stat(abs).catch(() => null)
162
167
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
163
- await writeFile(abs, args.content, "utf8")
168
+ // EOL semantics: overwriting an existing file restores ITS original EOL style (F1);
169
+ // a new file follows the directory's majority style, defaulting to LF (F2).
170
+ const prev = st ? await readFile(abs, "utf8").catch(() => null) : null
171
+ const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
172
+ const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
173
+ await writeFile(abs, content, "utf8")
164
174
  markDirty(abs)
165
175
  const diff = gitDiffOne(ctx.cwd, abs)
166
176
  return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
@@ -189,15 +199,28 @@ export const editTool = {
189
199
  if (!args.old_string) {
190
200
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
191
201
  }
192
- const content = normalizeEOL(await readFile(abs, "utf8"))
202
+ const raw = await readFile(abs, "utf8")
203
+ const content = normalizeEOL(raw)
193
204
  const occurrences = content.split(args.old_string).length - 1
194
205
  if (occurrences === 0) {
195
206
  // Give clues to help the model locate: first-line preview + common causes
196
207
  const preview = args.old_string.slice(0, 100).split("\n")[0]
208
+ // Similarity candidates (LCS, line-level, top 3, score ≥ 0.5) — turns the
209
+ // "not found" black box into a pointer at the most likely intended line.
210
+ // Multi-line old_string: only its first line is scored (marked accordingly).
211
+ const cands = findCandidates(content.split("\n"), args.old_string)
212
+ let candText = ""
213
+ if (cands.length > 0) {
214
+ const header = args.old_string.includes("\n")
215
+ ? ` similar lines (old_string line 1: "${args.old_string.split("\n")[0].slice(0, 80)}"):`
216
+ : " similar lines:"
217
+ candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
218
+ }
197
219
  throw new Error(
198
220
  `old_string not found in ${args.path}\n` +
199
221
  ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
200
- ` hints: whitespace mismatch? file already changed? try reading the file first`
222
+ ` hints: whitespace mismatch? file already changed? try reading the file first` +
223
+ candText
201
224
  )
202
225
  }
203
226
  if (occurrences > 1 && !args.replace_all) {
@@ -207,7 +230,11 @@ export const editTool = {
207
230
  ? content.split(args.old_string).join(args.new_string)
208
231
  // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
209
232
  : content.replace(args.old_string, () => args.new_string)
210
- await writeFile(abs, updated, "utf8")
233
+ // Write back in the file's ORIGINAL EOL style (first-newline rule) — a CRLF
234
+ // file must not come back as LF (that rewrites every line in the diff).
235
+ // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
236
+ // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
237
+ await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
211
238
  markDirty(abs)
212
239
  const diff = gitDiffOne(ctx.cwd, abs)
213
240
  return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
@@ -244,7 +271,8 @@ export const insertAfterTool = {
244
271
  `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
245
272
  )
246
273
  }
247
- const text = normalizeEOL(await readFile(abs, "utf8"))
274
+ const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
275
+ const text = normalizeEOL(raw)
248
276
  const lines = text.split("\n")
249
277
 
250
278
  let targetLine
@@ -261,7 +289,7 @@ export const insertAfterTool = {
261
289
  try {
262
290
  regex = new RegExp(args.after_regex)
263
291
  } catch (e) {
264
- throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
292
+ throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`, { cause: e })
265
293
  }
266
294
  const matches = []
267
295
  for (let i = 0; i < lines.length; i++) {
@@ -274,8 +302,10 @@ export const insertAfterTool = {
274
302
  throw new Error("Either after_line or after_regex is required")
275
303
  }
276
304
 
277
- lines.splice(targetLine, 0, args.content)
278
- const updated = lines.join("\n")
305
+ lines.splice(targetLine, 0, normalizeEOL(args.content))
306
+ // Write back in the file's ORIGINAL EOL style (review R9#2: same bug class as
307
+ // edit — a CRLF file must not silently become LF here either).
308
+ const updated = joinWithEol(lines, raw)
279
309
  await writeFile(abs, updated, "utf8")
280
310
  markDirty(abs)
281
311
  const diff = gitDiffOne(ctx.cwd, abs)
@@ -310,7 +340,11 @@ export const hashlineEditTool = {
310
340
  async execute(args, ctx) {
311
341
  const abs = resolveInCwd(ctx, args.path)
312
342
  if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
313
- const content = normalizeEOL(await readFile(abs, "utf8"))
343
+ const raw = await readFile(abs, "utf8")
344
+ const content = normalizeEOL(raw)
345
+ // Encoding-corruption probe: U+FFFD means the file is not clean UTF-8 — hash
346
+ // addressing may be unreliable. Warn (never block).
347
+ const corrupted = content.includes("\uFFFD")
314
348
  const lines = content.split("\n")
315
349
  const fileHashes = lines.map((l) => hashLine(l))
316
350
  const target = args.old_hashes
@@ -334,7 +368,8 @@ export const hashlineEditTool = {
334
368
  const preview = target.join(" ")
335
369
  throw new Error(
336
370
  `Hash sequence not found in ${args.path}: ${preview}\n` +
337
- `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}`
371
+ `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
372
+ (corrupted ? `\n${FFFD_WARNING}` : "")
338
373
  )
339
374
  }
340
375
 
@@ -359,13 +394,14 @@ export const hashlineEditTool = {
359
394
 
360
395
  const pos = matches[0]
361
396
  // Replace: remove old lines, insert new lines at the same position
362
- const newLines = args.new_content.split("\n")
397
+ const newLines = normalizeEOL(args.new_content).split("\n") // normalize: CRLF in new_content would join into \r\r\n
363
398
  lines.splice(pos, target.length, ...newLines)
364
- const updated = lines.join("\n")
399
+ // Write back in the file's original EOL style (same rule as edit / apply_patch).
400
+ const updated = joinWithEol(lines, raw)
365
401
  await writeFile(abs, updated, "utf8")
366
402
  markDirty(abs)
367
403
  const diff = gitDiffOne(ctx.cwd, abs)
368
- return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(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}` : ""}`
369
405
  },
370
406
  }
371
407
 
@@ -1,17 +1,16 @@
1
1
  import {
2
2
  DESC,
3
3
  autoSyntaxCheck,
4
- resolveInCwd
4
+ resolveInCwd,
5
+ normalizeEOL,
6
+ detectFileEol,
7
+ majorityEol
5
8
  } from "./shared.mjs";
6
9
  import { markDirty } from "./file.mjs";
7
10
  import { execFileSync } from "node:child_process";
8
- import { mkdir } from "node:fs/promises";
9
- import { readFile } from "node:fs/promises";
10
- import { stat, lstat } from "node:fs/promises";
11
- import { writeFile } from "node:fs/promises";
12
- import { unlink } from "node:fs/promises";
11
+ import { mkdir, readFile, lstat, writeFile, unlink } from "node:fs/promises";
13
12
  import { existsSync } from "node:fs";
14
- import { join, relative, dirname } from "node:path";
13
+ import { relative, dirname } from "node:path";
15
14
 
16
15
  /**
17
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
@@ -121,18 +120,22 @@ export const applyPatchTool = {
121
120
  const abs = resolveInCwd(ctx, f.path)
122
121
  if (f.isNew) {
123
122
  if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
124
- const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
123
+ // New file: follow the directory's majority EOL style (default LF).
124
+ const eol = majorityEol(dirname(abs))
125
+ const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join(eol) + eol
125
126
  planned.push({ abs, path: f.path, content, isNew: true })
126
127
  } else {
127
128
  const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
128
- const eol = original.includes("\r\n") ? "\r\n" : "\n"
129
- const lines = original.split("\n")
130
- applyHunks(lines, f.hunks, eol, f.path)
131
- planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
129
+ const eol = detectFileEol(original)
130
+ // Apply hunks in the normalized LF domain, then write back joined with the
131
+ // file's ORIGINAL EOL style — join("\n") here used to rewrite CRLF files as LF.
132
+ const lines = normalizeEOL(original).split("\n")
133
+ applyHunks(lines, f.hunks, "\n", f.path)
134
+ planned.push({ abs, path: f.path, content: lines.join(eol), isNew: false })
132
135
  }
133
136
  }
134
137
  // Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
135
- const { rename, unlink } = await import("node:fs/promises")
138
+ const { rename } = await import("node:fs/promises") // unlink already statically imported
136
139
  const written = []
137
140
  try {
138
141
  for (const p of planned) {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { spawn, execFileSync, execFile } from "node:child_process"
7
- import { readFileSync, existsSync, realpathSync } from "node:fs"
7
+ import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
8
8
  import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
9
9
  import { fileURLToPath } from "node:url"
10
10
 
@@ -55,6 +55,106 @@ export function normalizeEOL(text) {
55
55
  return text.replace(/\r\n/g, "\n")
56
56
  }
57
57
 
58
+ /** Detect a file's EOL style by the type of its FIRST newline: "\r\n" first →
59
+ * the whole file is written back as CRLF; a bare "\n" or no newline → LF.
60
+ * Never counts occurrences (mixed files follow the first line's style). */
61
+ export function detectFileEol(text) {
62
+ const i = text.indexOf("\n")
63
+ return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n"
64
+ }
65
+
66
+ /** Join lines with the EOL style detected from the original text (write-back restore). */
67
+ export function joinWithEol(lines, originalText) {
68
+ return lines.join(detectFileEol(originalText))
69
+ }
70
+
71
+ const MAJORITY_EOL_MAX_FILES = 20
72
+ const EOL_SNIFF_BYTES = 4096
73
+
74
+ /** Majority EOL style of a directory's existing files (≤20 files, first 4KB each).
75
+ * New files follow the directory's majority style; empty dir / tie / LF majority → "\n". */
76
+ export function majorityEol(dirPath) {
77
+ let names
78
+ try { names = readdirSync(dirPath) } catch { return "\n" }
79
+ let crlf = 0, lf = 0
80
+ for (const name of names) {
81
+ if (crlf + lf >= MAJORITY_EOL_MAX_FILES) break
82
+ try {
83
+ const p = join(dirPath, name)
84
+ if (!statSync(p).isFile()) continue
85
+ const fd = openSync(p, "r")
86
+ let head = ""
87
+ try {
88
+ const buf = Buffer.alloc(EOL_SNIFF_BYTES)
89
+ const n = readSync(fd, buf, 0, EOL_SNIFF_BYTES, 0)
90
+ head = buf.subarray(0, n).toString("utf8")
91
+ } finally {
92
+ closeSync(fd)
93
+ }
94
+ if (detectFileEol(head) === "\r\n") crlf++
95
+ else lf++
96
+ } catch { /* unreadable entry — skip */ }
97
+ }
98
+ return crlf > lf ? "\r\n" : "\n"
99
+ }
100
+
101
+ const CANDIDATE_MAX_LEN = 500
102
+ const CANDIDATE_PREVIEW_LEN = 80
103
+
104
+ /** Longest-common-substring length (rolling-row DP). Inputs are pre-truncated by the caller. */
105
+ function lcsLength(a, b) {
106
+ // Reused DP buffers (review R9#6): per-line allocation caused GC pressure on
107
+ // large files — hoist two module-level rows, grow to fit, swap by index.
108
+ const need = b.length + 1
109
+ if (_lcsBuf0.length < need) {
110
+ const size = Math.max(need, _lcsBuf0.length * 2)
111
+ _lcsBuf0 = new Uint16Array(size)
112
+ _lcsBuf1 = new Uint16Array(size)
113
+ }
114
+ let prev = _lcsBuf0, cur = _lcsBuf1
115
+ prev.fill(0, 0, need)
116
+ let best = 0
117
+ for (let i = 1; i <= a.length; i++) {
118
+ cur[0] = 0
119
+ const ca = a.charCodeAt(i - 1)
120
+ for (let j = 1; j < need; j++) {
121
+ if (ca === b.charCodeAt(j - 1)) {
122
+ const v = prev[j - 1] + 1
123
+ cur[j] = v
124
+ if (v > best) best = v
125
+ } else cur[j] = 0 // must reset — buffer is reused
126
+ }
127
+ const t = prev; prev = cur; cur = t
128
+ }
129
+ return best
130
+ }
131
+ let _lcsBuf0 = new Uint16Array(0), _lcsBuf1 = new Uint16Array(0)
132
+ /** Line-level similarity candidates for a failed edit: score = LCS(oldString, line) / max(len).
133
+ * Multi-line old_string matches on its FIRST line only (failures usually diverge there).
134
+ * Both sides are truncated to 500 chars before scoring so minified files can't blow the budget.
135
+ * Returns up to topN [{ line (1-based), preview, score }] with score >= threshold, best first. */
136
+ export function findCandidates(lines, oldString, topN = 3, threshold = 0.5) {
137
+ const needle = oldString.split("\n")[0].slice(0, CANDIDATE_MAX_LEN)
138
+ if (!needle) return []
139
+ const scored = []
140
+ for (let i = 0; i < lines.length; i++) {
141
+ const raw = lines[i]
142
+ if (!raw) continue
143
+ const line = raw.length > CANDIDATE_MAX_LEN ? raw.slice(0, CANDIDATE_MAX_LEN) : raw
144
+ const longer = Math.max(needle.length, line.length)
145
+ const shorter = Math.min(needle.length, line.length)
146
+ // LCS ≤ shorter side — a length ratio below the threshold can never reach it; skip the DP.
147
+ if (shorter / longer < threshold) continue
148
+ const score = lcsLength(needle, line) / longer
149
+ if (score >= threshold) scored.push({ line: i + 1, preview: raw.slice(0, CANDIDATE_PREVIEW_LEN), score })
150
+ }
151
+ scored.sort((a, b) => b.score - a.score || a.line - b.line)
152
+ return scored.slice(0, topN)
153
+ }
154
+
155
+ /** Appended to hashline_edit results when the file contains U+FFFD (encoding-corruption probe). */
156
+ export const FFFD_WARNING = "⚠ file contains U+FFFD (replacement char) — encoding may be corrupted; hash-based addressing may be unreliable. Consider fixing the file encoding first."
157
+
58
158
  /** Convert to OpenAI tools parameter format */
59
159
  export function toOpenAISchema(tool) {
60
160
  return {
@@ -339,7 +339,7 @@ export const grepTool = {
339
339
  const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
340
340
  regex = new RegExp(pat, args.ignoreCase ? "i" : "")
341
341
  } catch (e) {
342
- throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
342
+ throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
343
343
  }
344
344
  const fileFilter = args.glob ? globToRegex(args.glob) : null
345
345
  const before = Math.max(0, Math.floor(args.before ?? 0))
@@ -439,7 +439,7 @@ export const lsTool = {
439
439
  try {
440
440
  entries = await readdir(abs, { withFileTypes: true })
441
441
  } catch (e) {
442
- if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`)
442
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`, { cause: e })
443
443
  throw e
444
444
  }
445
445
  const rows = await Promise.all(
package/src/tools/web.mjs CHANGED
@@ -187,6 +187,6 @@ export const fetchTool = {
187
187
  const ct = headerOf(response, "content-type") ?? ""
188
188
  const body = await response.text()
189
189
  return ct.includes("text/html") ? truncate(htmlToText(body)) : truncate(body)
190
- } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`) }
190
+ } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`, { cause: e }) }
191
191
  },
192
192
  }
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"
7
7
  import { ansi, C } from "./ansi.mjs"
8
8
 
9
9
  const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
10
+ import { ENG_OFF_REMINDER } from "../agent.mjs"
10
11
 
11
12
  export async function handleEngCommand(ctx) {
12
13
  const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
@@ -31,7 +32,14 @@ export async function handleEngCommand(ctx) {
31
32
  }
32
33
 
33
34
  agent.config.agent.engineering = !agent.config.agent.engineering
34
- if (!agent.config.agent.engineering) agent._engDesignToken = null // invalidate stale token
35
+ if (!agent.config.agent.engineering) {
36
+ agent._engDesignToken = null // invalidate stale token
37
+ // OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
38
+ // mode flip is invisible to the agent otherwise. (ON needs none here: the injector
39
+ // in agent.mjs already announces ON transitions on the next turn.)
40
+ agent._pendingReminders = agent._pendingReminders ?? []
41
+ agent._pendingReminders.push(ENG_OFF_REMINDER)
42
+ }
35
43
  await persistRaw((raw) => {
36
44
  raw.agent ??= {}
37
45
  raw.agent.engineering = agent.config.agent.engineering
@@ -61,7 +61,7 @@ export function computeLayout(state, { cols, rows }) {
61
61
  const pickerH = overlay ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12)) : 0
62
62
 
63
63
  // Todo
64
- let visibleTasks = []
64
+ let visibleTasks
65
65
  if (state.tasks.length <= MAX_TASK_LINES) {
66
66
  visibleTasks = state.tasks
67
67
  } else {