thincoder 0.12.43 → 0.12.44

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,25 @@
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.44] — 2026-08-25
6
+
7
+ ### Fixed
8
+
9
+ - **安全修复:subagent 变形 role 绕过工程模式门禁**——`role="Coder"/" coder"` 等非精确字符串穿透两个模式门禁(精确比较),fallthrough 到全工具/无 overlay 子代理,绕过设计评审拿到完整写权限。修复:execute 入口 ROLES 白名单,未知 role 直接 throw(fail-closed);防回归测试锁定 7 种变形值 × 两种模式
10
+ - IK9UZ8 思考型模型标题生成(vscode 端同修对齐)
11
+ - 文档状态/TODO 销账(24+13 处"待评审"→"已实现";TODO 7 条已实现条目核对关闭)
12
+ - TUI.md 章节号重编号(## 4-10 顺延,无重复)
13
+
14
+ ### Added
15
+
16
+ - **ESLint 引入**(规则基线对齐 vscode 端):lint script + 21 个 error 清零(死赋值/cause 补全)
17
+ - **跨端同构模块语义锚点比对测试**:14 个跨仓库契约(advisor 收敛协议、蒸馏语义、64K 阈值)两端必须一致——单边漂移立即红
18
+ - **RELEASE.md**:npm 发布流程 + 4 条踩坑记录(vsce 自动 bump、Open VSX 异步激活、ovsx 无 TTY 静默失败、prepublishOnly)
19
+
20
+ ### Docs
21
+
22
+ - 文档债收口:6 处"待合并"全部处理(真碎片合并/归档/独立保留定性)
23
+
5
24
  ## [0.12.43] — 2026-08-25
6
25
 
7
26
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.43",
3
+ "version": "0.12.44",
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 {
@@ -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/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 {
@@ -261,7 +261,7 @@ export const insertAfterTool = {
261
261
  try {
262
262
  regex = new RegExp(args.after_regex)
263
263
  } catch (e) {
264
- throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
264
+ throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`, { cause: e })
265
265
  }
266
266
  const matches = []
267
267
  for (let i = 0; i < lines.length; i++) {
@@ -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
  }
@@ -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 {