thincoder 0.12.53 → 0.12.54

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
@@ -1,3 +1,20 @@
1
+ ## [0.12.54] — 2026-09-01
2
+
3
+ ### Added
4
+
5
+ - **Checkpoint 事故恢复闭环(CLI ↔ VS Code 两端)**:git 工具破坏性操作(checkout -- / restore / reset --hard / clean / rebase)前自动快照 + schema 描述含 rewind 恢复指引;`checkpointAction=list` 输出尾部提示行;**commit 后清空该项目 checkpoint**(commit = 安全点;懒兜底覆盖外部 git/IDE commit);每 cwd 快照上限 100(最旧淘汰);git 工具补齐 11 个 action(clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv);`/restore` 改为两级 picker 逐文件恢复;bash guard 保留并对齐(宽匹配 + 全量副本 + rewind 指引,与 VS Code 同构)
6
+ - **TUI 子 agent 运行中面板固定化**:运行中子 agent 活动从会话流内联改为固定底部面板(会话与 todo 之间,不随会话滚动);完全自适应高度;默认折叠(头部 + tail 3,⏸ = 等待审批);完成仍冻结进会话流(✓ 头可展开)
7
+ - **TUI 渲染鲁棒性**:wrap-off 硬截断(Ambiguous 宽度字符防软折行污染);启动/退出序列抽取(tui-lifecycle.mjs)
8
+
9
+ ### Changed
10
+
11
+ - **跨端会话共享一致性(会诊 4 模型收敛)**:sessionStart 打点(跨端同槽不再 F2 互轮转);F2 写前磁盘校验(同会话并发追加 → 轮转 .bak 保留);legacy transient 双端过滤;contextHistory 机读线判定(length>0);activeModel 双向;cwd 先行校验;newSession 死主清理落盘(deletions)
12
+ - **checkpoint cwdHash 归一化**:`sha1(normalizeCwd(cwd)).slice(0,12)`——CLI/VS Code 快照跨端互通(存量旧路径孤儿化不迁移)
13
+
14
+ ### Fixed
15
+
16
+ - **hex-escape 毒载荷 400**(deepseek-v4-flash 实测):发送前统一中和字面 `\\x`/`\\u` 不足位序列(escape.mjs,VS Code 同构)
17
+
1
18
  ## [0.12.52] — 2026-08-31
2
19
  ## [0.12.53] — 2026-08-31
3
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.53",
3
+ "version": "0.12.54",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/acp.mjs CHANGED
@@ -19,28 +19,15 @@ import { assembleAgent } from "./cli/make-agent.mjs"
19
19
  import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
20
20
  import { createAcpSession } from "./acp/session.mjs"
21
21
  import { replayHistory } from "./acp/bridge.mjs"
22
- import { listSlots, applySession, deleteSlot, sessionPath, normalizeCwd, isLegacyTransient } from "./session.mjs"
22
+ import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession } from "./session.mjs"
23
23
  import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
24
24
  import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
25
25
 
26
26
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
27
27
 
28
28
  /** Load a specific slot file (not the active one) — session/load by id.
29
- * Same validation as loadSession: version 1/2, cwd match, legacy-transient
30
- * filtering (pre-filtering slot files must not leak machine lines into replay). */
31
- function loadSlotFile(cwd, slot) {
32
- const path = `${sessionPath(cwd)}.${slot}`
33
- try {
34
- const data = JSON.parse(readFileSync(path, "utf8"))
35
- if (data?.version !== 1 && data?.version !== 2) return null
36
- if (!Array.isArray(data.history)) return null
37
- if (data.cwd && normalizeCwd(data.cwd).toLowerCase() !== normalizeCwd(cwd).toLowerCase()) return null
38
- data.history = data.history.filter((m) => !isLegacyTransient(m))
39
- return data
40
- } catch {
41
- return null
42
- }
43
- }
29
+ * 2026-08-31 会诊 deepseek 🟡:改用 session.mjs 共享 loadSlotFile(校验/保现场/.tmp
30
+ * 回退与主路径一致)——本地实现此前无 .unreadable/.corrupted 保留。 */
44
31
 
45
32
  /**
46
33
  * Apply a session-level config option to the agent instance (memory only —
@@ -160,6 +147,11 @@ export function buildAcpHandlers({
160
147
  const id = String(nextId++)
161
148
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
162
149
  // `id` is immutable after construction (baked into the callbacks) — never reassign.
150
+ // 2026-09-01 会诊 kimi/glm 🔴:立即认领独立槽(对齐 cmd-new)——否则首回合保存
151
+ // 走 _slot ??= activeSlot() → ensureActive 早退分支(slotSessions[active]===
152
+ // mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
153
+ // F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
154
+ session.agent._slot = newSession(getCwd())
163
155
  sessions.set(id, session)
164
156
  return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
165
157
  } catch (e) {
@@ -185,6 +177,7 @@ export function buildAcpHandlers({
185
177
  },
186
178
 
187
179
  "session/cancel": (params) => {
180
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
188
181
  const found = findSession(params)
189
182
  if (found.error) return found
190
183
  found.session.cancel()
@@ -192,6 +185,7 @@ export function buildAcpHandlers({
192
185
  },
193
186
 
194
187
  "session/close": (params) => {
188
+ if (!authenticated) return { error: ACP_ERRORS.AUTH_REQUIRED } // 2026-08-31 advisor round2 🔵:与其他 handler 一致
195
189
  const found = findSession(params)
196
190
  if (!found.error) {
197
191
  // Abort any in-flight turn first — the client is gone, the agent must
@@ -233,11 +227,33 @@ export function buildAcpHandlers({
233
227
  const id = String(nextId++)
234
228
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
235
229
  applySession(session.agent, data)
230
+ // 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
231
+ // IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
232
+ // 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
233
+ // 到新槽(与 switchToSlot 的"占用则 fork"语义对齐)。
234
+ const occ = slotOccupancy(getCwd(), slot)
235
+ // 2026-09-01 会诊 kimi/glm 🔴:同进程双会话同槽——slotOccupancy 排除本进程属主后,
236
+ // 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
237
+ // (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
238
+ const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
239
+ if (!occ.occupied && !sameProcessPinned) {
240
+ const m = loadManifest(getCwd())
241
+ m.slotSessions ??= {}
242
+ m.slotSessions[slot] = getSessionId()
243
+ saveManifest(getCwd(), m)
244
+ session.agent._slot = slot
245
+ } else {
246
+ // 2026-09-01 advisor 🔴:占用时显式分配全新槽(fork)——原 `_slot = null` 的
247
+ // fork 会经 saveSession → activeSlot → ensureActive 分支1 早退(slotSessions
248
+ // [active] === mySessionId 同进程恒真)落回同进程 active 槽 → 两会话写同一槽
249
+ // 静默互覆盖。newSession 跳过活认领号/现存文件号,必定落到新槽。
250
+ session.agent._slot = newSession(getCwd())
251
+ }
236
252
  sessions.set(id, session)
237
253
  // Replay the human line (role → chunk mapping, design §4.5) so the
238
254
  // client renders the restored conversation.
239
255
  replayHistory({ sessionId: id, notify: notifyRef.current, history: data.history, log })
240
- log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)`)
256
+ log(`session ${slot} loaded as session ${id} (${data.history?.length ?? 0} messages replayed)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
241
257
  return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
242
258
  } catch (e) {
243
259
  return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to load session ${slot}: ${e.message}` } }
@@ -258,9 +274,25 @@ export function buildAcpHandlers({
258
274
  const id = String(nextId++)
259
275
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
260
276
  applySession(session.agent, data)
277
+ // 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
278
+ // 下次保存 fork 新槽);空闲则认领后钉回。
279
+ const occ = slotOccupancy(getCwd(), slot)
280
+ // 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
281
+ const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
282
+ if (!occ.occupied && !sameProcessPinned) {
283
+ const m = loadManifest(getCwd())
284
+ m.slotSessions ??= {}
285
+ m.slotSessions[slot] = getSessionId()
286
+ saveManifest(getCwd(), m)
287
+ session.agent._slot = slot
288
+ } else {
289
+ // 2026-09-01 advisor 🔴:同 load——显式 newSession fork(_slot=null 的 fork 会
290
+ // 落回同进程 active 槽 → 两会话写同一槽静默互覆盖)
291
+ session.agent._slot = newSession(getCwd())
292
+ }
261
293
  sessions.set(id, session)
262
294
  // resume: no history replay — the client keeps its own rendering.
263
- log(`session ${slot} resumed as session ${id} (no replay)`)
295
+ log(`session ${slot} resumed as session ${id} (no replay)${occ.occupied || sameProcessPinned ? ` — slot busy, forked to ${session.agent._slot}` : ""}`)
264
296
  return { id, cwd: getCwd(), configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
265
297
  } catch (e) {
266
298
  return { error: { code: ACP_ERRORS.INTERNAL.code, message: `failed to resume session ${slot}: ${e.message}` } }
@@ -278,6 +310,16 @@ export function buildAcpHandlers({
278
310
  if (!deleteSlot(getCwd(), slot)) {
279
311
  return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `session ${slot} not found` } }
280
312
  }
313
+ // 2026-08-31 会诊 deepseek 🟡:被删槽的在存会话 _slot 仍钉着 → 下次保存会重建
314
+ // 文件并重注册(删后复活)。清空其 _slot,下次保存重新认领新槽。
315
+ // 2026-09-01 advisor round2 🔴:不能清 _slot 等下次保存——saveSession 走
316
+ // activeSlot → ensureActive 分支1 早退(slotSessions[active] === mySessionId 同
317
+ // 进程恒真)→ 落回同进程 active 槽(另一在存会话的槽)→ 两会话写同一槽静默
318
+ // 互覆盖(sessionStart 均 null → F2 永不轮转)。与 load/resume 同型修复:
319
+ // 立即 newSession 钉全新槽(跳过活认领号/现存文件号)。
320
+ for (const s of sessions.values()) {
321
+ if (s.agent?._slot === slot) s.agent._slot = newSession(getCwd())
322
+ }
281
323
  log(`session ${slot} archive deleted`)
282
324
  return {}
283
325
  },
@@ -148,7 +148,8 @@ export async function prepareRun(agent, input, callbacks, {
148
148
  pushReal(agent, { role: "user", content: input })
149
149
  }
150
150
  // Time grounding for EVERY agent depth AND every resume, pushed LAST (after the user
151
- // input): transient, dropped on persist, fresh at every run start including resumes
151
+ // input): transient on the HUMAN line — dropped on persist; on the MACHINE linekept
152
+ // (byte-identical resume for the provider prefix cache, 2026-08-16), fresh at every run start
152
153
  // (an interrupt-continuation must know NOW, not the pre-interrupt time; 2026-08-16).
153
154
  // Tail position keeps the second-precision content out of any prefix — caches stay hit.
154
155
  agent.history.push({
package/src/escape.mjs CHANGED
@@ -10,7 +10,10 @@
10
10
  * 一个字节/码点)。JSON.stringify 层面的反斜杠转义由发送方负责,本模块不碰。
11
11
  */
12
12
 
13
- /** 中和单段文本里的非法字面转义序列。 */
13
+ /** 中和单段文本里的非法字面转义序列。
14
+ * Known limitation (documented, accepted, VS Code port parity): an ODD backslash
15
+ * run of 3+ (e.g. "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare
16
+ * in real conversation text (no such hit in the 2026-08-31 repro session). */
14
17
  export function escapeLiteralEscapes(text) {
15
18
  text = String(text ?? "")
16
19
  return text
@@ -20,14 +23,21 @@ export function escapeLiteralEscapes(text) {
20
23
  .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
21
24
  }
22
25
 
23
- /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。 */
26
+ /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。
27
+ * 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
28
+ * reasoning_content 做同样的非标二次转义解析(字面 \x/\u 经工具参数/思考回传 → 400,
29
+ * 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
24
30
  export function escapeMessageContent(message) {
25
31
  const content = message?.content
32
+ let changed = false
33
+ let next = message
26
34
  if (typeof content === "string") {
27
- return { ...message, content: escapeLiteralEscapes(content) }
28
- }
29
- if (Array.isArray(content)) {
30
- let changed = false
35
+ const escaped = escapeLiteralEscapes(content)
36
+ if (escaped !== content) {
37
+ next = { ...next, content: escaped }
38
+ changed = true
39
+ }
40
+ } else if (Array.isArray(content)) {
31
41
  const parts = content.map((p) => {
32
42
  if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
33
43
  const escaped = escapeLiteralEscapes(p.text)
@@ -38,9 +48,34 @@ export function escapeMessageContent(message) {
38
48
  }
39
49
  return p
40
50
  })
41
- return changed ? { ...message, content: parts } : message
51
+ if (changed) next = { ...next, content: parts }
52
+ }
53
+ if (Array.isArray(next.tool_calls)) {
54
+ let tcChanged = false
55
+ const tool_calls = next.tool_calls.map((tc) => {
56
+ const args = tc?.function?.arguments
57
+ if (typeof args === "string") {
58
+ const escaped = escapeLiteralEscapes(args)
59
+ if (escaped !== args) {
60
+ tcChanged = true
61
+ return { ...tc, function: { ...tc.function, arguments: escaped } }
62
+ }
63
+ }
64
+ return tc
65
+ })
66
+ if (tcChanged) {
67
+ next = { ...next, tool_calls }
68
+ changed = true
69
+ }
70
+ }
71
+ if (typeof next.reasoning_content === "string") {
72
+ const escaped = escapeLiteralEscapes(next.reasoning_content)
73
+ if (escaped !== next.reasoning_content) {
74
+ next = { ...next, reasoning_content: escaped }
75
+ changed = true
76
+ }
42
77
  }
43
- return message
78
+ return changed ? next : message
44
79
  }
45
80
 
46
81
  /** IKBGX4 (2026-08-28):剥离仅本地使用的整消息标记字段(transient 等)——发送给 provider 前移除。
@@ -16,7 +16,7 @@ import { configDir } from "../config.mjs"
16
16
 
17
17
  const CWD_HASH_LEN = 12
18
18
 
19
- const MAX_CHECKPOINTS = 20
19
+ const MAX_CHECKPOINTS = 100
20
20
 
21
21
  /** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
22
22
  const MAX_FILE_BYTES = 5 * 1024 * 1024
@@ -33,8 +33,16 @@ function git(cwd, args, { allowFail = false } = {}) {
33
33
  }
34
34
  }
35
35
 
36
+ /** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
37
+ * extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
38
+ * process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
39
+ * contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
40
+ function normalizeCwd(cwd) {
41
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
42
+ }
43
+
36
44
  function checkpointRoot(cwd) {
37
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
45
+ const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
38
46
  return join(configDir, "checkpoints", hash)
39
47
  }
40
48
 
@@ -412,11 +420,29 @@ export async function catFile(cwd, id, filePath) {
412
420
  }
413
421
  }
414
422
 
415
- /** Keep only the most recent MAX_CHECKPOINTS */
416
- async function pruneCheckpoints(cwd) {
423
+ /** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
424
+ * a missing dir is a no-op; deletion failures propagate so the git tool's commit case
425
+ * can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
426
+ export async function deleteCheckpointsForCwd(cwd) {
427
+ await rm(checkpointRoot(cwd), { recursive: true, force: true })
428
+ }
429
+
430
+ /** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
431
+ * timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
432
+ * (0 when the cwd has no checkpoint dir). */
433
+ export async function deleteCheckpointsOlderThan(cwd, count) {
417
434
  const root = checkpointRoot(cwd)
418
- const ids = (await readdir(root)).sort()
419
- while (ids.length > MAX_CHECKPOINTS) {
435
+ let ids
436
+ try { ids = (await readdir(root)).sort() } catch { return 0 }
437
+ let removed = 0
438
+ while (ids.length > count) {
420
439
  await rm(join(root, ids.shift()), { recursive: true, force: true })
440
+ removed++
421
441
  }
442
+ return removed
443
+ }
444
+
445
+ /** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
446
+ async function pruneCheckpoints(cwd) {
447
+ await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
422
448
  }
@@ -22,7 +22,7 @@ UI & interface design:
22
22
  - **用户约定执行纪律(2026-08-31,两次违约教训)**:用户对交互/行为的约定以用户原话为准——实现时逐字对照,不得用"等效实现"替换约定本身(已发生:滚动→点击翻窗、滚动到头自动加载→PgUp 键触发)。已确认约定的简化/降级必须提前上报,不得包装成"升级路径"交付。注释里的 parity with X / 对齐 X 只描述来源,不代表 X 就是正确语义——以用户约定为唯一判据,实现后真机验证用户原话的每个承诺点。
23
23
 
24
24
  Tool routing — use the dedicated tool, not bash:
25
- - **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick; `workdir` for sub-repos). Never run git via bash.
25
+ - **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv; `workdir` for sub-repos). Never run git via bash.
26
26
  - **JavaScript** → `execute` (inline code; or `scriptFile`+`nodeArgs` for `node <file>` / `node --test` / `node --check`). Never `bash node -e`.
27
27
  - **File reads/searches** → `read` / `grep` / `ls` / `glob` — never `cat` / `type` / `findstr` / `dir` / shell-grep.
28
28
  - **File mutations** → `write` / `edit` / `apply_patch` / `hashline_edit` / `insert_after` / `file_ops` (move/copy/rename) / `delete`.
@@ -51,7 +51,7 @@ Tool routing — use the dedicated tool, not bash:
51
51
  | `read_image` | view an image (vision models) | external viewers |
52
52
  | `execute` | run JS inline / scriptFile (+ nodeArgs for `node --test`/`--check`) | `bash node -e`, `node <script>` via bash |
53
53
  | `bash` | npm/vsce/CLI subprocess, servers, TTY programs, one-off pipelines no tool expresses | always; see allowed list above |
54
- | `git` | ALL git ops (status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote) | `git` in bash |
54
+ | `git` | ALL git ops (status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv) | `git` in bash |
55
55
  | `process` | list running processes | `tasklist`, `ps`, `wmic` |
56
56
  | `get_current_time` | current date/time | `date` |
57
57
  | `timer` | thinking budget / wait reminder | `sleep`, `timeout` (for real waits) |
@@ -66,6 +66,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
66
66
  // the same way it bricks OpenAI-format ones (all raster-only).
67
67
  const spec = specForModel(provider.model)
68
68
  messages = stripImagesForTextModel(messages, spec)
69
+ const _debugBeforeLen = process.env.THIN_DEBUG_BODY ? JSON.stringify(messages).length : 0
69
70
 
70
71
  // Format dispatch: delegate to non-OpenAI transports
71
72
  if (provider.format === "anthropic") {
@@ -106,6 +107,9 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
106
107
  // 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
107
108
  // 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
108
109
  messages = escapeMessages(messages)
110
+ if (process.env.THIN_DEBUG_BODY) {
111
+ console.error(`[debug-body] escape: ${_debugBeforeLen} -> ${JSON.stringify(messages).length} chars, ${messages.length} msgs (provider=${provider.name}, model=${provider.model})`)
112
+ }
109
113
  // Compile string-pattern rules to RegExp at call time
110
114
  const rules = compileStreamRules(streamRules)
111
115
  const body = {
@@ -285,6 +289,44 @@ export async function listModels(provider, { signal } = {}) {
285
289
  }
286
290
 
287
291
  async function requestWithRetry(provider, body, signal, onWait) {
292
+ // THIN_DEBUG_BODY=1:发送前诊断——复现网关侧 "unexpected end of hex escape" 400 时
293
+ // 定位真实载荷里的毒序列(2026-08-31 slot 3 deepseek-v4-flash)。模拟网关最宽松的
294
+ // 爆炸条件:任何字面 "\u"/"\x" 后不足位(不看前置反斜杠)。
295
+ if (process.env.THIN_DEBUG_BODY) {
296
+ try {
297
+ const msgs = body?.messages ?? []
298
+ const raw = JSON.stringify(body)
299
+ const hits = []
300
+ for (let i = 0; i < msgs.length; i++) {
301
+ const m = msgs[i] ?? {}
302
+ const fields = []
303
+ if (typeof m.content === "string") fields.push(["content", m.content])
304
+ else if (Array.isArray(m.content)) m.content.forEach((p, pi) => { if (p && typeof p.text === "string") fields.push([`content[${pi}]`, p.text]) })
305
+ if (typeof m.reasoning_content === "string") fields.push(["reasoning_content", m.reasoning_content])
306
+ if (Array.isArray(m.tool_calls)) m.tool_calls.forEach((tc, ti) => { if (tc && typeof tc.arguments === "string") fields.push([`tool_calls[${ti}].arguments`, tc.arguments]) })
307
+ if (typeof m.name === "string") fields.push(["name", m.name])
308
+ for (const [f, t] of fields) {
309
+ const re = /\\[xu]/g
310
+ let mm
311
+ while ((mm = re.exec(t))) {
312
+ const c = t[mm.index + 1]
313
+ const need = c === "u" ? 4 : 2
314
+ const after = t.slice(mm.index + 2, mm.index + 2 + need)
315
+ if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
316
+ hits.push({ i, role: m.role, field: f, ctx: t.slice(Math.max(0, mm.index - 40), mm.index + 12) })
317
+ }
318
+ }
319
+ }
320
+ }
321
+ console.error(`[debug-body] messages=${msgs.length} bodyLen=${raw.length} suspicious=${hits.length}`)
322
+ for (const h of hits.slice(0, 20)) console.error("[debug-body] hit", JSON.stringify(h))
323
+ if (!hits.length && msgs[1151]) {
324
+ console.error("[debug-body] no suspicious hit; messages[1151] =", JSON.stringify({ role: msgs[1151].role, contentLen: msgs[1151].content?.length, contentHead: String(msgs[1151].content).slice(0, 150) }))
325
+ }
326
+ } catch (e) {
327
+ console.error("[debug-body] diag failed:", e.message)
328
+ }
329
+ }
288
330
  let lastError
289
331
  let lastStatus = 0
290
332
  let lastWas429 = false
@@ -15,7 +15,12 @@ import { configDir } from "./config.mjs"
15
15
  * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
16
16
  * Every combination is tried — a migration that only checks one candidate misses real
17
17
  * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
18
+ /** 2026-09-01 advisor 🔵(VS Code 侧已修,CLI 对称补齐):已迁移/确认无 legacy 的 hash
19
+ * 记录在 Set 中短路——否则每次 sessionPath() 都重跑 5 候选 × 3 existsSync 的系统调用。 */
20
+ const migratedHashes = new Set() // full 40-char hash → migration already attempted (found none or done)
21
+
18
22
  export function migrateHashLength(cwd, fullHash) {
23
+ if (migratedHashes.has(fullHash)) return false
19
24
  const dir = join(configDir, "sessions")
20
25
  const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
21
26
  const candidates = [
@@ -38,5 +43,6 @@ export function migrateHashLength(cwd, fullHash) {
38
43
  }
39
44
  } catch { /* best-effort; leave files in place on failure */ }
40
45
  }
46
+ migratedHashes.add(fullHash)
41
47
  return migrated
42
48
  }