thincoder 0.12.52 → 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 +36 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/prompts/discipline.md +44 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +163 -36
- package/src/provider/google.mjs +41 -15
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -6
- package/src/tools/git.mjs +68 -155
- package/src/tools/shared.mjs +5 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- 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/fold-block.mjs +59 -11
- package/src/tui/index.mjs +59 -67
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +86 -8
- package/src/tui/render-conversation.mjs +260 -214
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +11 -1
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-args.mjs +4 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
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
|
|
19
|
+
## [0.12.53] — 2026-08-31
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **TUI 展开块内滚动**(2026-08-31 用户需求):折叠区块 60% 高度封顶保留,正文改为滚动视口——`state._foldScroll` 记每块窗口起点;**滚轮命中块内容行 → 块内 ±3 行**(未命中走会话滚动),▲/▼ 控制行点击翻窗(step=winH 快速跳转);**穿出语义**:块顶滚上/块底滚下 → 交还会话滚动(会话顶懒加载可达);锚定补偿:暂停流式跟随期间内容增长按 convLen 增量补偿 scroll(读的位置不漂移)。**滚动读全文、永不截断**
|
|
24
|
+
- **流式跟随尾部**:`state._followTail` 默认 true——输出活动期间渲染前钉底;用户上滚(PgUp/滚轮)暂停跟随,PgDn/滚回底部/新提交消息恢复
|
|
25
|
+
- **工具顺手度**(2026-08-31):① insert_after 精确判定(本 session 写入记录受影响区,未受影响区直接插入不逼重 read、受影响区拒绝保护栏);② edit 数组形态 `edits: [{path, old_string, new_string}, ...]` 一次多文件原子替换;③ dispatch 拦截工具执行期间 console.log/console.error 回显给模型(异常路径同样回显);④ 写入工具返回带上下文窗口(edit/insert_after/hashline_edit 返回写入点 ±3 行带行号——模型自检行号语义,防"行号漂移死循环")
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **懒加载滚动到头自动加载**(2026-08-31 用户约定修复):恢复会话向上滚动到会话顶部自动加载更早一页(原只挂 PgUp 键=违约);`HISTORY_PAGE_MESSAGES` 50→20(单页更平顺,vscode parity)
|
|
30
|
+
- **三层渲染缓存**(懒加载卡顿根治):行级 wrapRowsCached + 段级 _lineSegCache(覆盖普通行/工具块/frozenSubTask/frozenAdvisor)——loadOlder 后 rebuild 111ms→5-8ms 平坦(不随已加载历史增长)
|
|
31
|
+
- **折叠 key 身份化**(会诊三家共识):`long-${i}`/`fold-${foldCounter++}`/`advisor-done-${i}` 位置键全部改 `_lineId` 派生(loadOlder unshift 后展开态/块内滚动 offset 不串位)
|
|
32
|
+
- **视口数学单源** `convViewport`(渲染+鼠标命中共用):短会话顶部补 pad 后命中整体偏移的存量 bug 修复(点击折叠头/滚轮落空或错行)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **块内滚动穿出缺陷**:滚到块顶/块尾后滚轮永远命中该块、穿不出 → 会话顶/懒加载不可达("经过展开块滚不到顶")——显式边界判定穿出
|
|
37
|
+
- **懒加载只挂 PgUp 键**(小键盘无 PgUp 用户等于无入口)——滚轮滚到会话顶同样触发
|
|
2
38
|
|
|
3
39
|
### Fixed
|
|
4
40
|
|
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/advisor/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { findProvider, specForModel } from "../config.mjs"
|
|
|
7
7
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
8
8
|
import { prepareAdvisorMessages } from "../advisor.mjs"
|
|
9
9
|
import { appendCitationReport } from "./citations.mjs"
|
|
10
|
+
import { describeToolArgs } from "../tui/tool-args.mjs"
|
|
10
11
|
|
|
11
12
|
const MAX_ADVISOR_TURNS = 100
|
|
12
13
|
// Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
|
|
@@ -97,16 +98,12 @@ function advisorToolsFor(agent) {
|
|
|
97
98
|
// Test seam: the tool set is pure (agent.memory → code_search inclusion).
|
|
98
99
|
export { advisorToolsFor as _advisorToolsFor }
|
|
99
100
|
|
|
100
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
s = s.replace(/\s+/g, " ").trim()
|
|
107
|
-
return s.length > 80 ? s.slice(0, 79) + "…" : s
|
|
108
|
-
}
|
|
109
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Tool-call progress line summary delegates to the single source describeToolArgs
|
|
103
|
+
* (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
|
|
104
|
+
* blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
|
|
105
|
+
* so advisor progress lines show the quoted-path forms everywhere else.
|
|
106
|
+
*/
|
|
110
107
|
/**
|
|
111
108
|
* Render the ordered review timeline — thinking / tool progress / final text
|
|
112
109
|
* interleaved EXACTLY as emitted, so the persisted record shows the review
|
|
@@ -246,7 +243,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
246
243
|
continue
|
|
247
244
|
}
|
|
248
245
|
|
|
249
|
-
|
|
246
|
+
const argsLine = describeToolArgs(tc.name, args)
|
|
247
|
+
onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
|
|
250
248
|
let result
|
|
251
249
|
if (!tool) {
|
|
252
250
|
result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
|
package/src/agent/dispatch.mjs
CHANGED
|
@@ -156,6 +156,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
156
156
|
: "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
|
|
157
157
|
return { ...item, result: reason, ok: false }
|
|
158
158
|
}
|
|
159
|
+
// 2026-08-31 工具顺手度(用户批准"做吧"):dispatch 拦截工具执行期间的
|
|
160
|
+
// console.log/console.error——工具的探查/调试输出(原本只到终端、模型看不到)
|
|
161
|
+
// 收集后附在工具结果后回显给模型。bash 工具的输出走子进程回显(onOutput),
|
|
162
|
+
// 不走 dispatch console——拦截安全。嵌套 dispatch(subagent)各自拦截/恢复,
|
|
163
|
+
// 捕获分离(父恢复原始后子的拦截期间父捕获停止、子恢复后父继续)——正确。
|
|
164
|
+
// 声明在 try 之外:catch 块(异常路径)也要访问(报错前的探查输出回显)。
|
|
165
|
+
const capturedConsole = []
|
|
159
166
|
try {
|
|
160
167
|
// Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
|
|
161
168
|
if (!item.tool?.readonly && item.args) {
|
|
@@ -170,26 +177,40 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
170
177
|
return { ...item, result: routed.result, ok: true }
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
const origConsoleLog = console.log
|
|
181
|
+
const origConsoleErr = console.error
|
|
182
|
+
console.log = (...a) => capturedConsole.push(a.map(String).join(" "))
|
|
183
|
+
console.error = (...a) => capturedConsole.push("[err] " + a.map(String).join(" "))
|
|
184
|
+
let rawResult
|
|
185
|
+
try {
|
|
186
|
+
rawResult = await item.tool.execute(item.args, {
|
|
187
|
+
cwd: agent.cwd,
|
|
188
|
+
agent,
|
|
189
|
+
depth,
|
|
190
|
+
signal,
|
|
191
|
+
callbacks,
|
|
192
|
+
onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
|
|
193
|
+
onQuestion: callbacks.onQuestion,
|
|
194
|
+
onPermissionRequest: callbacks.onPermissionRequest,
|
|
195
|
+
})
|
|
196
|
+
} finally {
|
|
197
|
+
console.log = origConsoleLog
|
|
198
|
+
console.error = origConsoleErr
|
|
199
|
+
}
|
|
183
200
|
if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
|
|
184
201
|
const raw = String(rawResult)
|
|
185
202
|
// Multimodal tools keep the raw result (base64 images ride the multimodal
|
|
186
203
|
// channel); everything else offloads oversized text to disk. Flag-driven, not
|
|
187
204
|
// name-driven (consult P3, 2026-08-30).
|
|
188
205
|
const result = item.tool?.multimodal ? raw : await offloadToolResult(raw, item.toolCall.id)
|
|
189
|
-
|
|
206
|
+
// 2026-08-31:工具执行期间捕获的 console 输出附在结果后回显(模型视野)
|
|
207
|
+
const resultWithConsole = capturedConsole.length > 0
|
|
208
|
+
? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
|
|
209
|
+
: result
|
|
210
|
+
callbacks.onToolResult?.(item.toolCall.name, resultWithConsole, item.toolCall.id)
|
|
190
211
|
// PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
|
|
191
212
|
runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
|
|
192
|
-
return { ...item, result, ok: true }
|
|
213
|
+
return { ...item, result: resultWithConsole, ok: true }
|
|
193
214
|
} catch (error) {
|
|
194
215
|
// Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
|
|
195
216
|
logToolError(item.toolCall.name, item.args, error)
|
|
@@ -205,7 +226,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
|
|
|
205
226
|
if (item.args.pattern) ctxParts.push(`pattern=${item.args.pattern}`)
|
|
206
227
|
if (item.args.command) ctxParts.push(`cmd=${item.args.command.slice(0, 80)}`)
|
|
207
228
|
const ctx = ctxParts.length > 0 ? ` [${ctxParts.join(", ")}]` : ""
|
|
208
|
-
|
|
229
|
+
// 2026-08-31:异常路径同样回显捕获的 console(工具报错前的探查输出最有价值)
|
|
230
|
+
const consolePart = capturedConsole.length > 0
|
|
231
|
+
? `\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
|
|
232
|
+
: ""
|
|
233
|
+
return { ...item, result: `Error: ${error.message}${ctx}${consolePart}`, ok: false }
|
|
209
234
|
}
|
|
210
235
|
}
|
|
211
236
|
|
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/agent.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path"
|
|
|
10
10
|
import { fileURLToPath } from "node:url"
|
|
11
11
|
import { executeToolCalls } from "./agent/dispatch.mjs"
|
|
12
12
|
import { recordToolResults } from "./agent/record-results.mjs"
|
|
13
|
+
import { FILE_MUTATORS } from "./agent/helpers.mjs"
|
|
13
14
|
import { prepareRun } from "./agent/setup.mjs"
|
|
14
15
|
import { injectPostTurn } from "./agent/post-turn.mjs"
|
|
15
16
|
import { handleCompletion } from "./agent/completion.mjs"
|
|
@@ -256,6 +257,20 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
256
257
|
throw e
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
// 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息,
|
|
261
|
+
// 模型下一轮可见;全量回传时 transport 依 tool_call_id 前缀还原 web_search_call item。
|
|
262
|
+
// 注意:服务端 item id 是 msg_xxx 非 web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
|
|
263
|
+
// 原始 id 存入 content(真机冒烟 2026-08-31:直接用 msg_xxx 会被转成 function_call_output
|
|
264
|
+
// 与服务端不配对,属蒙对)。
|
|
265
|
+
for (const btr of response.builtinToolResults ?? []) {
|
|
266
|
+
if (!btr?.id) continue
|
|
267
|
+
pushReal(agent, {
|
|
268
|
+
role: "tool",
|
|
269
|
+
tool_call_id: `web_search_call_${btr.id}`,
|
|
270
|
+
content: JSON.stringify({ id: btr.id, query: btr.query ?? "", sources: btr.sources ?? [], status: btr.status ?? "completed" }),
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
// Stream rule triggered mid-generation (action: "abort"): halt current output,
|
|
260
275
|
// inject rule's message as a reminder, and retry from the same context.
|
|
261
276
|
if (response.ruleTriggered) {
|
|
@@ -357,6 +372,25 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
357
372
|
// Ctrl+I interrupt during tool execution: skip committing partial results —
|
|
358
373
|
// the tool failure messages would mislead the model. Inject the interrupt and retry.
|
|
359
374
|
if (signal?.reason?.interrupt) {
|
|
375
|
+
// 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
|
|
376
|
+
// 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
|
|
377
|
+
// "本轮未改代码" 放行,评审/verify 门禁被绕过(文件改了却没评审)。
|
|
378
|
+
for (const { toolCall, ok } of results) {
|
|
379
|
+
const tool = toolByName.get(toolCall.name)
|
|
380
|
+
if (!ok || !tool || !FILE_MUTATORS.has(toolCall.name)) continue
|
|
381
|
+
agent._mutatedThisRun = true
|
|
382
|
+
agent._calledAdvisorThisRun = false
|
|
383
|
+
agent._verifiedThisRun = false
|
|
384
|
+
agent._verifyPassed = undefined
|
|
385
|
+
try {
|
|
386
|
+
const args = JSON.parse(toolCall.arguments)
|
|
387
|
+
const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
|
|
388
|
+
for (const p of paths) {
|
|
389
|
+
const abs = join(agent.cwd, p)
|
|
390
|
+
if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
|
|
391
|
+
}
|
|
392
|
+
} catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
|
|
393
|
+
}
|
|
360
394
|
// The assistant tool_calls were already committed above (L347) — a strict
|
|
361
395
|
// provider 400s on dangling tool_calls, so synthesize placeholder tool
|
|
362
396
|
// results BEFORE the interrupt message (tool result must immediately
|
package/src/cli/make-agent.mjs
CHANGED
|
@@ -60,11 +60,17 @@ export async function assembleAgent() {
|
|
|
60
60
|
if (existsSync(mcpJsonPath)) {
|
|
61
61
|
const mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"))
|
|
62
62
|
if (mcpJson.mcpServers && typeof mcpJson.mcpServers === "object") {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
// 2026-08-31 MCP 会诊 #10:数组型 mcpServers 不是规范形态——Object.entries 会产出
|
|
64
|
+
// "0"/"1" 数字名(变成工具前缀 "0_tool"),必须跳过;server 条目嵌套数组同理。
|
|
65
|
+
if (Array.isArray(mcpJson.mcpServers)) {
|
|
66
|
+
console.error("[mcp] .mcp.json: mcpServers must be a plain object, got array — skipped")
|
|
67
|
+
} else {
|
|
68
|
+
const configNames = new Set(mcpServers.map((s) => s.name))
|
|
69
|
+
for (const [name, server] of Object.entries(mcpJson.mcpServers)) {
|
|
70
|
+
if (configNames.has(name)) continue // config.json takes priority
|
|
71
|
+
if (!server || typeof server !== "object" || Array.isArray(server)) continue
|
|
72
|
+
mcpServers.push({ name, ...server })
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
}
|
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
|
}
|
package/src/mcp/helpers.mjs
CHANGED
|
@@ -27,13 +27,22 @@ export function quoteArg(s) {
|
|
|
27
27
|
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Convert an Authorization Bearer token into a WebSocket subprotocol.
|
|
31
|
+
* 2026-08-31 MCP 会诊 #10:原实现把 token 塞进 URL query——代理/网关日志会泄露凭证,
|
|
32
|
+
* 且无标准依据。Node 内置 WebSocket(undici)无法自定义请求头,MCP 生态的标准替代
|
|
33
|
+
* 通道是 subprotocol(`bearer.<token>`)。用户 URL 自带的 query token 不动(兼容)。
|
|
34
|
+
* @returns {{ url: string, protocols: string[] }} — protocols 为空数组表示无认证。 */
|
|
31
35
|
export function withAuthToken(wsUrl, authorization) {
|
|
32
|
-
if (!authorization) return wsUrl
|
|
36
|
+
if (!authorization) return { url: wsUrl, protocols: [] }
|
|
33
37
|
const token = authorization.replace(/^Bearer\s+/i, "")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
let u
|
|
39
|
+
try {
|
|
40
|
+
u = new URL(wsUrl)
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`Invalid WebSocket URL: ${String(wsUrl).slice(0, 120)}`)
|
|
43
|
+
}
|
|
44
|
+
// subprotocol 不进入 URL/日志,token 不再注入 query
|
|
45
|
+
return { url: u.href, protocols: [`bearer.${token}`] }
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
|