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 +17 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/agent/setup.mjs +2 -1
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/prompts/discipline.md +2 -2
- package/src/provider/core.mjs +42 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +20 -7
- package/src/tools/git.mjs +48 -162
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +3 -22
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +40 -1
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
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
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,
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
},
|
package/src/agent/setup.mjs
CHANGED
|
@@ -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
|
|
151
|
+
// input): transient on the HUMAN line — dropped on persist; on the MACHINE line — kept
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 前移除。
|
package/src/git/checkpoint.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { configDir } from "../config.mjs"
|
|
|
16
16
|
|
|
17
17
|
const CWD_HASH_LEN = 12
|
|
18
18
|
|
|
19
|
-
const MAX_CHECKPOINTS =
|
|
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
|
-
/**
|
|
416
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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) |
|
package/src/provider/core.mjs
CHANGED
|
@@ -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
|
package/src/session-migrate.mjs
CHANGED
|
@@ -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
|
}
|