thincoder 0.12.44 → 0.12.46

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,29 @@
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.46] — 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - **checklist 工具坐标系断裂**:`add` 返回任务 ID、`mark` 却只收列表位置 index,agent 拿 ID 定位不到条目、只能猜 index——误标无关条目(线上事故)。修复:`mark` 加 `id` 参数(优先于 index);auto-ID 按「最大根号+1」分配(含 `checklist-done.md` 双文件扫描,归档 ID 恒占位不复用);历史重复 `T[\d.]+:` 前缀读入即归一;标记父任务 done 时子任务非全 done 则拒绝(防静默丢弃子树),全 done 则递归归档整棵子树
10
+ - **子 agent/advisor 模型显示补录**(TUI.md 文档欠账,功能此前已实现)
11
+
12
+ ## [0.12.45] — 2026-08-26
13
+
14
+ ### Fixed
15
+
16
+ - **编辑工具 CRLF 行尾写回丢失**:`edit` / `apply_patch` / `hashline_edit` / `insert_after` 在 Windows CRLF 文件上写回全部被转成 LF(normalize 后直接落盘)——现按"首个换行符类型"检测原文件行尾并原样恢复,diff 不再整文件重写;`new_string`/`new_content` 含 CRLF 时先归一化再转换,杜绝 `\r\r\n`
17
+ - **`old_string not found` 黑盒报错**:失败时返回相似度最高的 top 3 候选行(行号+预览+LCS 相似度,阈值 0.5,多行 old_string 只对首行并标注 `old_string line 1:`)——从盲猜变导航
18
+
19
+ ### Added
20
+
21
+ - **`write` 行尾语义**:覆盖既有文件按原行尾恢复;新建文件默认 LF,同目录多数派为 CRLF 时跟随(≤20 文件嗅探)
22
+ - **`hashline_edit` 编码损坏探测**:文件含 U+FFFD(替换符)时结果追加警告(编码可能已损坏、哈希寻址可能不可靠),不阻断
23
+
24
+ ### Changed
25
+
26
+ - 候选相似度 LCS 计算复用模块级 DP 缓冲(大文件失败路径不再有每行分配的 GC 压力)
27
+
5
28
  ## [0.12.44] — 2026-08-25
6
29
 
7
30
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.44",
3
+ "version": "0.12.46",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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)
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
  }
@@ -4,7 +4,10 @@ Items support tree hierarchy via indentation (2 spaces per level) and auto-assig
4
4
 
5
5
  Parameters:
6
6
  - action: "add" | "mark" | "list"
7
+ - id: task ID to mark, e.g. "T3" (with "mark"; preferred — use the ID returned by `add`, or from `list`)
7
8
  - item: text for new item (with "add")
8
- - index: 1-based index (with "mark")
9
+ - index: 1-based index (with "mark"; fallback — use only when you have no ID)
9
10
  - status: "pending" | "in_progress" | "done" (with "mark")
10
11
  - parent: parent task ID for hierarchical tasks, e.g. "T1" (with "add")
12
+
13
+ Note: marking a parent "done" requires all its child tasks already done — otherwise it is rejected (complete the children before marking the parent done).
@@ -31,42 +31,79 @@ function parse(filePath) {
31
31
  const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
32
  const text = m[3].trim()
33
33
 
34
- // Extract explicit ID if present (e.g. "T1:", "T1.1:") strip it from the text
35
- // so write() doesn't re-prepend it (round-trip would otherwise accumulate "T1: T1: ...")
36
- const idMatch = text.match(/^(T[\d.]+):\s*/)
37
- const node = {
38
- id: idMatch ? idMatch[1] : null,
39
- index: flatIdx,
40
- depth,
41
- status,
42
- text: idMatch ? text.slice(idMatch[0].length) : text,
43
- children: [],
34
+ // Strip ALL leading "T[\d.]+:" tokens (historical dirty data can accumulate
35
+ // "T15: T15: T15:"); keep the first token as the ID and the rest as text.
36
+ let id = null
37
+ let bareText = text
38
+ let idTok
39
+ while ((idTok = bareText.match(/^(T[\d.]+):\s*/))) {
40
+ if (id == null) id = idTok[1]
41
+ bareText = bareText.slice(idTok[0].length)
44
42
  }
43
+ const node = { id, index: flatIdx, depth, status, text: bareText, children: [] }
45
44
 
46
45
  // Find parent by popping stack until we find a node at depth-1
47
46
  while (stack.length > 1 && stack.at(-1).depth >= depth) stack.pop()
48
47
  const parent = stack.at(-1)
49
48
  parent.children.push(node)
50
- // Auto-assign ID if not explicit
51
- if (!node.id) {
52
- const siblingCount = parent.children.length
53
- const base = parent.id ? `${parent.id}` : "T"
54
- if (parent.id) {
55
- node.id = `${base}.${siblingCount}`
56
- } else {
57
- // Root level: T1, T2, T3...
58
- let rootIdx = 0
59
- for (const c of items) {
60
- if (c.id?.match(/^T\d+$/)) rootIdx = Math.max(rootIdx, parseInt(c.id.slice(1)))
61
- }
62
- node.id = `T${rootIdx + 1}`
49
+ stack.push({ children: node.children, depth, id: node.id })
50
+ }
51
+
52
+ // Assign stable IDs to lines that lacked an explicit one, exactly once.
53
+ // IDs are "max existing number + 1" (not position-based) so gaps left by
54
+ // archived items never collide, and persisted IDs never drift on re-read.
55
+ let assigned = false
56
+ function assignIds(nodes, parentId) {
57
+ for (const n of nodes) {
58
+ if (!n.id) {
59
+ n.id = parentId ? nextChildId(parentId, nodes) : nextRootId(nodes, doneRoots)
60
+ assigned = true
63
61
  }
62
+ if (n.children?.length) assignIds(n.children, n.id)
64
63
  }
65
- stack.push({ children: node.children, depth, id: node.id })
66
64
  }
65
+ // Root IDs archived to the done file also reserve numbers (mirrors the `add`
66
+ // path's double-file scan), so auto-assigned IDs never collide with them.
67
+ const doneRoots = readDoneRoots(join(dirname(filePath), DONE))
68
+ assignIds(items, null)
69
+ if (assigned) write(filePath, items)
70
+
67
71
  return items
68
72
  }
69
73
 
74
+ function readDoneRoots(doneFile) {
75
+ if (!existsSync(doneFile)) return []
76
+ const roots = []
77
+ for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
78
+ const m = line.match(/^- \[.\] (T\d+): /)
79
+ if (m) roots.push({ id: m[1] })
80
+ }
81
+ return roots
82
+ }
83
+
84
+ function nextRootId(items, doneItems) {
85
+ let max = 0
86
+ for (const list of [items, doneItems]) {
87
+ for (const c of list ?? []) {
88
+ const m = c.id?.match(/^T(\d+)$/)
89
+ if (m) max = Math.max(max, parseInt(m[1]))
90
+ }
91
+ }
92
+ return `T${max + 1}`
93
+ }
94
+
95
+ function nextChildId(parentId, children) {
96
+ let max = 0
97
+ const prefix = `${parentId}.`
98
+ for (const c of children) {
99
+ if (c.id?.startsWith(prefix)) {
100
+ const suffix = c.id.slice(prefix.length)
101
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
102
+ }
103
+ }
104
+ return `${prefix}${max + 1}`
105
+ }
106
+
70
107
  /** Write items back to file, preserving tree structure */
71
108
  function write(filePath, items, _depth = 0) {
72
109
  if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
@@ -108,6 +145,26 @@ function flatten(items, out = []) {
108
145
  return out
109
146
  }
110
147
 
148
+ /** True if every descendant (children, grandchildren, …) is done. */
149
+ function allChildrenDone(node) {
150
+ for (const c of node.children ?? []) {
151
+ if (c.status !== "done" || !allChildrenDone(c)) return false
152
+ }
153
+ return true
154
+ }
155
+
156
+ /** Recursively clone a subtree for archiving, forcing every status to done. */
157
+ function archiveSubtree(node) {
158
+ return {
159
+ id: node.id,
160
+ index: 0,
161
+ depth: 0,
162
+ status: "done",
163
+ text: node.text,
164
+ children: (node.children ?? []).map(archiveSubtree),
165
+ }
166
+ }
167
+
111
168
  /** Parse pending items only (for context injection) */
112
169
  export function pendingItems(cwd) {
113
170
  const flat = flatten(parse(checklistPath(cwd)))
@@ -125,13 +182,17 @@ export const checklistTool = {
125
182
  enum: ["add", "mark", "list"],
126
183
  description: "add a new item / mark item status / list all items"
127
184
  },
185
+ id: {
186
+ type: "string",
187
+ description: "Task ID to mark (preferred — use the ID returned by add, e.g. 'T3')"
188
+ },
128
189
  item: {
129
190
  type: "string",
130
191
  description: "Item text (required for add)"
131
192
  },
132
193
  index: {
133
194
  type: "number",
134
- description: "1-based item index (required for mark)"
195
+ description: "1-based item index (fallback for mark, only when id is absent)"
135
196
  },
136
197
  status: {
137
198
  type: "string",
@@ -161,48 +222,46 @@ export const checklistTool = {
161
222
  parentId = found.item.id
162
223
  }
163
224
 
164
- // Auto-assign ID
165
- let id
166
- if (parentId) {
167
- id = `${parentId}.${target.length + 1}`
168
- } else {
169
- let maxIdx = 0
170
- for (const c of items) {
171
- const m = c.id?.match(/^T(\d+)$/)
172
- if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
173
- }
174
- id = `T${maxIdx + 1}`
175
- }
176
-
225
+ const id = parentId ? nextChildId(parentId, target) : nextRootId(items, parse(donePath(ctx.cwd)))
177
226
  const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
178
227
  target.push(node)
179
228
  write(checklistPath(ctx.cwd), items)
180
229
  return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
181
230
  }
182
231
  case "mark": {
183
- if (args.index == null) return "Error: 'index' is required for mark"
232
+ if (args.id == null && args.index == null) return "Error: 'id' or 'index' is required for mark"
184
233
  const status = args.status
185
234
  if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
186
235
  const cp = checklistPath(ctx.cwd)
187
236
  const items = parse(cp)
188
- const flat = flatten(items)
189
- if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
190
- const item = flat[args.index - 1]
237
+ let item
238
+ if (args.id != null) {
239
+ const found = findById(items, args.id)
240
+ if (!found) return `Error: id '${args.id}' not found. Use 'list' to see all task IDs.`
241
+ item = found.item
242
+ } else {
243
+ const flat = flatten(items)
244
+ if (args.index < 1 || args.index > flat.length) return `Error: index ${args.index} out of range (1-${flat.length})`
245
+ item = flat[args.index - 1]
246
+ }
191
247
  const old = item.status
192
248
  if (old === status) return `Already ${status}: ${item.text}`
249
+ if (status === "done" && item.children?.length && !allChildrenDone(item)) {
250
+ return "Error: 父任务仍有未完成的子任务,先处理子任务再标父 done"
251
+ }
193
252
  item.status = status
194
253
  if (status === "done") {
195
- // Move to done file
254
+ // Move the whole subtree to the done file (hierarchy preserved).
196
255
  const dp = donePath(ctx.cwd)
197
256
  const doneItems = parse(dp)
198
- doneItems.push({ id: item.id, index: 0, depth: 0, status: "done", text: item.text, children: [] })
257
+ doneItems.push(archiveSubtree(item))
199
258
  write(dp, doneItems)
200
- // Remove from tree
259
+ // Remove the subtree from the tree.
201
260
  const found = findById(items, item.id)
202
261
  if (found) found.parent.splice(found.idx, 1)
203
262
  }
204
263
  write(cp, items)
205
- return `Marked #${args.index} ${old} → ${status}: ${item.id}: ${item.text}`
264
+ return `Marked ${item.id} ${old} → ${status}`
206
265
  }
207
266
  case "list": {
208
267
  const items = parse(checklistPath(ctx.cwd))
@@ -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
@@ -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 {
@@ -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