thincoder 0.12.53 → 0.12.58

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/acp.mjs +60 -18
  6. package/src/advisor/messages.mjs +4 -2
  7. package/src/advisor/run.mjs +2 -2
  8. package/src/agent/dispatch.mjs +66 -26
  9. package/src/agent/helpers.mjs +13 -2
  10. package/src/agent/setup.mjs +16 -3
  11. package/src/agent/spawn-child.mjs +3 -1
  12. package/src/agent-tools/advisor.mjs +19 -9
  13. package/src/agent-tools/eng.mjs +2 -0
  14. package/src/agent-tools/subagent-check.mjs +107 -0
  15. package/src/agent-tools/subagent.mjs +205 -42
  16. package/src/agent.mjs +68 -3
  17. package/src/cli/make-agent.mjs +25 -0
  18. package/src/cli/memory-command.mjs +28 -7
  19. package/src/config.mjs +120 -8
  20. package/src/context.mjs +28 -7
  21. package/src/escape.mjs +110 -22
  22. package/src/git/checkpoint.mjs +32 -6
  23. package/src/mcp/transport-http.mjs +13 -1
  24. package/src/mcp.mjs +52 -7
  25. package/src/memory/core.mjs +78 -10
  26. package/src/memory/docs.mjs +33 -7
  27. package/src/memory.mjs +1 -1
  28. package/src/model-specs.mjs +23 -0
  29. package/src/prompts/discipline.md +17 -3
  30. package/src/prompts/engineering.md +62 -5
  31. package/src/prompts/main.md +1 -0
  32. package/src/prompts/system.md +2 -1
  33. package/src/provider/anthropic.mjs +7 -5
  34. package/src/provider/core.mjs +90 -26
  35. package/src/provider/google.mjs +57 -24
  36. package/src/provider/normalize.mjs +1 -1
  37. package/src/provider/rate.mjs +0 -2
  38. package/src/provider/responses.mjs +8 -13
  39. package/src/provider/sse.mjs +20 -0
  40. package/src/session-migrate.mjs +6 -0
  41. package/src/session-slots.mjs +361 -0
  42. package/src/session.mjs +282 -306
  43. package/src/tools/apply_patch.md +2 -0
  44. package/src/tools/bash.md +2 -2
  45. package/src/tools/edit-batch.mjs +104 -0
  46. package/src/tools/edit.md +3 -0
  47. package/src/tools/execute.md +4 -4
  48. package/src/tools/execute.mjs +14 -22
  49. package/src/tools/file.mjs +17 -55
  50. package/src/tools/file_ops.md +1 -1
  51. package/src/tools/git-checkpoint.mjs +143 -0
  52. package/src/tools/git-ext.mjs +173 -0
  53. package/src/tools/git.md +21 -8
  54. package/src/tools/git.mjs +55 -177
  55. package/src/tools/lint.md +1 -1
  56. package/src/tools/linter.mjs +9 -37
  57. package/src/tools/patch.mjs +1 -1
  58. package/src/tools/shared.mjs +7 -20
  59. package/src/tui/agent-turn.mjs +3 -3
  60. package/src/tui/ansi.mjs +2 -0
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-eng.mjs +1 -0
  63. package/src/tui/cmd-mcp-form.mjs +197 -0
  64. package/src/tui/cmd-mcp.mjs +255 -114
  65. package/src/tui/cmd-new.mjs +6 -6
  66. package/src/tui/cmd-restore.mjs +27 -6
  67. package/src/tui/cmd-session.mjs +17 -4
  68. package/src/tui/index.mjs +28 -27
  69. package/src/tui/interaction.mjs +28 -1
  70. package/src/tui/key-handler.mjs +14 -2
  71. package/src/tui/layout.mjs +81 -25
  72. package/src/tui/mouse.mjs +41 -2
  73. package/src/tui/pickers.mjs +62 -4
  74. package/src/tui/render-conversation.mjs +36 -93
  75. package/src/tui/render-frame.mjs +40 -16
  76. package/src/tui/render-loop.mjs +1 -1
  77. package/src/tui/render.mjs +4 -4
  78. package/src/tui/startup.mjs +4 -2
  79. package/src/tui/subagent-blocks.mjs +119 -4
  80. package/src/tui/subagent-panel.mjs +81 -0
  81. package/src/tui/tool-events.mjs +61 -16
  82. package/src/tui/tui-lifecycle.mjs +45 -0
package/src/session.mjs CHANGED
@@ -6,283 +6,24 @@
6
6
  *
7
7
  * File layout: {hash}.json.N (slots), {hash}.json.manifest (slot metadata + active pointer).
8
8
  * Legacy {hash}.json is migrated to a slot on first access.
9
+ *
10
+ * 2026-08-31 advisor round1 🔴:slot/清单管理拆至 session-slots.mjs(本文件曾超 500 行
11
+ * 硬限);本文件只保留核心读写 + re-export 全部 slot 导出(既有 import 路径不变)。
9
12
  */
10
13
 
11
- import { createHash } from "node:crypto"
12
- import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs"
13
- import { join, dirname } from "node:path"
14
- import { execSync } from "node:child_process"
15
- import { configDir } from "./config.mjs"
16
- import { migrateHashLength } from "./session-migrate.mjs"
17
-
18
- let currentSessionId = null
19
-
20
- /** Generate unique session ID for this process */
21
- export function getSessionId() {
22
- if (!currentSessionId) {
23
- currentSessionId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
24
- }
25
- return currentSessionId
26
- }
27
-
28
- /** Normalize cwd for hashing: uppercase Windows drive letter so both ends
29
- * (CLI's process.cwd() vs VS Code's uri.fsPath, which lowercases it) agree. */
30
- export function normalizeCwd(cwd) {
31
- return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
32
- }
33
-
34
- /** Full sha1 hex (40 chars), not truncated. Shared contract with the VS Code extension. */
35
- function cwdHash(cwd) {
36
- return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
37
- }
38
-
39
- /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
40
- export function sessionPath(cwd) {
41
- const hash = cwdHash(cwd)
42
- migrateHashLength(cwd, hash)
43
- return join(configDir, "sessions", `${hash}.json`)
44
- }
45
-
46
- function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
47
- export { slotPath }
48
- function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
49
-
50
- /** Path to the active slot's file */
51
- export function activePath(cwd) {
52
- return slotPath(cwd, activeSlot(cwd))
53
- }
54
-
55
- /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
56
- function writeSessionFile(p, data) {
57
- mkdirSync(dirname(p), { recursive: true })
58
- const tmp = `${p}.tmp`
59
- writeFileSync(tmp, JSON.stringify(data), "utf8")
60
- try {
61
- renameSync(tmp, p)
62
- } catch {
63
- try { unlinkSync(p) } catch {}
64
- try {
65
- renameSync(tmp, p)
66
- try { unlinkSync(tmp) } catch {}
67
- } catch {
68
- writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
69
- }
70
- }
71
- }
72
-
73
- // ========== slot management ==========
74
-
75
- /** Normalize slot metadata: old format is a raw timestamp number; new format is { ts, ... } object */
76
- function slotMetaTs(v) { return typeof v === "number" ? v : (v?.ts ?? 0) }
77
-
78
- function slotCmp(a, b) { return slotMetaTs(a[1]) - slotMetaTs(b[1]) }
79
-
80
- /** Detect a genuine user message (excludes system-reminder injected messages) */
81
- function isRealUserMsg(m) {
82
- return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
83
- }
84
-
85
- /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
86
- function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
87
- const userMsgs = history.filter(isRealUserMsg)
88
- const first = userMsgs[0]?.content ?? ""
89
- return {
90
- messageCount: history.length,
91
- turnCount: userMsgs.length,
92
- firstMessage: first.slice(0, 80),
93
- activeProvider: activeProvider ?? "",
94
- updatedAt: updatedAt ?? Date.now(),
95
- title,
96
- }
97
- }
98
-
99
- /** Extract preview summary from session data for manifest storage (with current timestamp) */
100
- function slotDigest(data) {
101
- const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
102
- return { ts: Date.now(), ...meta }
103
- }
104
-
105
- function loadManifest(cwd) {
106
- try {
107
- const p = manifestPath(cwd)
108
- if (!existsSync(p)) return { slots: {}, sessionId: null }
109
- const m = JSON.parse(readFileSync(p, "utf8"))
110
- if (!m.sessionId) m.sessionId = null
111
- return m
112
- } catch { return { slots: {}, sessionId: null } }
113
- }
114
-
115
- function saveManifest(cwd, m) {
116
- m.sessionId = getSessionId()
117
- writeSessionFile(manifestPath(cwd), m)
118
- }
119
-
120
- /**
121
- * Claim a slot for this process and set it as active. Idempotent.
122
- * Preference order:
123
- * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
124
- * 2. Any slot that is unowned or owned by a dead process (reclaim).
125
- * 3. A brand-new slot when all are owned by live processes.
126
- * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
127
- * see which slots are taken and avoid them.
128
- */
129
- function ensureActive(cwd, m) {
130
- const mySessionId = getSessionId()
131
- if (!m.slotSessions) m.slotSessions = {}
132
-
133
- // Already own the active slot — nothing to do.
134
- if (m.active && m.slotSessions[m.active] === mySessionId) return
135
-
136
- const isFree = (slot) => {
137
- const owner = m.slotSessions[slot]
138
- if (!owner || owner === mySessionId) return true
139
- return !isProcessAlive(parseInt(owner.split('-')[0]))
140
- }
141
-
142
- // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
143
- if (m.active && m.slots[m.active] && isFree(m.active)) {
144
- m.slotSessions[m.active] = mySessionId
145
- saveManifest(cwd, m)
146
- return
147
- }
148
-
149
- // 2. Otherwise claim the first slot that is free.
150
- const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
151
- for (const slot of allSlots) {
152
- if (isFree(slot)) {
153
- m.active = slot
154
- m.slotSessions[slot] = mySessionId
155
- saveManifest(cwd, m)
156
- return
157
- }
158
- }
159
-
160
- // 3. All slots owned by live processes — allocate a new one (no limit).
161
- const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
162
- m.active = newSlot
163
- m.slotSessions[newSlot] = mySessionId
164
- saveManifest(cwd, m)
165
- }
166
-
167
- /**
168
- * Check if a process with given PID is still alive.
169
- * Returns false if process doesn't exist or we can't determine.
170
- */
171
- function isProcessAlive(pid) {
172
- if (!pid || isNaN(pid)) return false
173
- try {
174
- // On Windows: tasklist /FI "PID eq <pid>" /NH
175
- // On Unix: kill(pid, 0) or check /proc/<pid>
176
- if (process.platform === 'win32') {
177
- const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
178
- return output.includes(String(pid))
179
- } else {
180
- // Unix: try to send signal 0 (doesn't kill, just checks)
181
- process.kill(pid, 0)
182
- return true
183
- }
184
- } catch {
185
- return false
186
- }
187
- }
188
-
189
- /** Return the active slot number for this process, claiming one if necessary */
190
- export function activeSlot(cwd) {
191
- const m = loadManifest(cwd)
192
- ensureActive(cwd, m)
193
- return m.active
194
- }
195
-
196
- /** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
197
- function loadSlotMeta(cwd, slot, v) {
198
- if (typeof v === "object" && v !== null && "ts" in v) return v
199
- const ts = typeof v === "number" ? v : 0
200
- try {
201
- const p = slotPath(cwd, slot)
202
- if (!existsSync(p)) return { ts }
203
- const data = JSON.parse(readFileSync(p, "utf8"))
204
- const history = data.history ?? []
205
- const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
206
- return { ts, ...meta }
207
- } catch {
208
- return { ts }
209
- }
210
- }
211
-
212
- /** List all slots, newest first. Includes isActive flag. */
213
- export function listSlots(cwd) {
214
- const m = loadManifest(cwd)
215
- const active = m.active ?? activeSlot(cwd)
216
- return Object.entries(m.slots)
217
- .filter(([n]) => /^\d+$/.test(n))
218
- .map(([n, v]) => {
219
- const meta = loadSlotMeta(cwd, Number(n), v)
220
- return {
221
- slot: Number(n),
222
- isActive: Number(n) === active,
223
- timestamp: meta.ts,
224
- date: new Date(meta.ts).toLocaleString(),
225
- messageCount: meta.messageCount ?? 0,
226
- turnCount: meta.turnCount ?? 0,
227
- firstMessage: meta.firstMessage ?? "",
228
- activeProvider: meta.activeProvider ?? "",
229
- updatedAt: meta.updatedAt ?? meta.ts,
230
- updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
231
- title: meta.title ?? "",
232
- }
233
- })
234
- .sort((a, b) => b.updatedAt - a.updatedAt)
235
- }
236
-
237
- /**
238
- * Switch active slot. No file copying — just change the pointer in the manifest.
239
- * Returns the loaded session data (null if slot doesn't exist).
240
- */
241
- export function switchToSlot(cwd, slot) {
242
- const m = loadManifest(cwd)
243
- if (!m.slots[slot]) return null
244
- m.active = slot
245
- saveManifest(cwd, m)
246
- return loadSession(cwd)
247
- }
248
-
249
- /** Delete a slot: remove its file and manifest entry. Deleting the active slot
250
- * resets the manifest active pointer (the next claim re-creates one). */
251
- export function deleteSlot(cwd, slot) {
252
- const n = Number(slot)
253
- if (!Number.isInteger(n) || n < 1) return false
254
- const m = loadManifest(cwd)
255
- if (!m.slots[n]) return false
256
- delete m.slots[n]
257
- delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
258
- try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
259
- if (m.active === n) delete m.active
260
- saveManifest(cwd, m)
261
- return true
262
- }
263
-
264
- /** Rename a slot: update the slot file's title + the manifest metadata (shared with VS Code). */
265
- export function renameSlot(cwd, slot, title) {
266
- const n = Number(slot)
267
- if (!Number.isInteger(n) || n < 1) return false
268
- const p = slotPath(cwd, n)
269
- if (!existsSync(p)) return false
270
- let data
271
- try {
272
- data = JSON.parse(readFileSync(p, "utf8"))
273
- } catch {
274
- return false
275
- }
276
- data.title = title
277
- writeSessionFile(p, data)
278
- const m = loadManifest(cwd)
279
- if (m.slots[n]) {
280
- m.slots[n] = slotDigest(data)
281
- saveManifest(cwd, m)
282
- }
283
- return true
284
- }
14
+ import { readFileSync, renameSync, existsSync, statSync } from "node:fs"
15
+ import { basename } from "node:path"
16
+ import {
17
+ slotPath, writeSessionFile, loadManifest, saveManifest, slotDigest,
18
+ activeSlot, sessionPath, getSessionId, isProcessAlive,
19
+ } from "./session-slots.mjs"
285
20
 
21
+ // re-export slot 管理(保持既有 import session.mjs 的调用点不变)
22
+ export {
23
+ getSessionId, normalizeCwd, sessionPath, slotPath, manifestPath, activePath,
24
+ writeSessionFile, slotDigest, loadManifest, saveManifest, activeSlot, listSlots,
25
+ deleteSlot, renameSlot, isProcessAlive,
26
+ } from "./session-slots.mjs"
286
27
 
287
28
  // ========== legacy transient prefix cleanup ==========
288
29
 
@@ -343,7 +84,10 @@ function slimForDisplay(m) {
343
84
  /** Save agent state to the active slot file (atomic write). `display` (the old
344
85
  * WYSIWYG render snapshot) is DEPRECATED — it drifted out of sync with history
345
86
  * whenever VS Code wrote the slot, and the TUI resumed from a stale snapshot.
346
- * Restore now always rebuilds from history (lazy, see startup.mjs). */
87
+ * Restore now always rebuilds from history (lazy, see startup.mjs).
88
+ * 2026-08-31 会诊 F1:slot 粘性——首次认领后缓存 agent._slot,永不重跑 ensureActive
89
+ * (原实现每次保存重推,manifest active 被并发方翻动时会话静默迁移)。
90
+ * 返回轮转的 .bak 路径或 null。 */
347
91
  export function saveSession(agent) {
348
92
  // _fullHistory is written at the source via pushReal — no flush needed here.
349
93
  // history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
@@ -372,13 +116,62 @@ export function saveSession(agent) {
372
116
  autoApprove: agent.autoApprove ?? false,
373
117
  engineering: agent.config?.agent?.engineering ?? false,
374
118
  engDesignToken: agent._engDesignToken ?? null,
119
+ // Multi-design slots ride the same round-trip (2026-09-01 audit #1): Map → {designId: token}
120
+ // (JSON-safe). Empty/absent Map → undefined → the key is dropped by JSON.stringify, so a
121
+ // cleared session writes NO field instead of resurrecting slots from the previous save.
122
+ engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
123
+ ? Object.fromEntries(agent._engDesignTokens)
124
+ : undefined,
375
125
  goal: agent.goal ?? null,
376
126
  advisor: agent.config?.advisor ?? null,
377
127
  pendingReminders: agent._pendingReminders ?? [],
378
128
  sessionStart: agent._sessionStart ?? null,
379
129
  }
380
- const slot = activeSlot(agent.cwd)
381
- writeSessionFile(slotPath(agent.cwd, slot), data)
130
+ const slot = agent._slot ??= activeSlot(agent.cwd)
131
+ const p = slotPath(agent.cwd, slot)
132
+ // 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
133
+ // 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
134
+ // 检查按 mtime 缓存(_slotMtime):文件自上次检查/自写未变就跳过全量解析——
135
+ // 每次保存都 readFileSync+JSON.parse 多 MB 会话 → O(n²) 退化。
136
+ let rotated = null
137
+ try {
138
+ if (existsSync(p)) {
139
+ const st = statSync(p)
140
+ let disk = null
141
+ if (st.mtimeMs !== (agent._slotMtime ?? -1)) {
142
+ disk = JSON.parse(readFileSync(p, "utf8"))
143
+ // 2026-08-31 会诊 deepseek 🟡:version>2 的新版文件无论 sessionStart 一律轮转
144
+ // (loadSlotFile 对 v3 返回 null 不动文件——若其 sessionStart 为 null,旧版首次
145
+ // 保存会静默覆盖;轮转 .bak 保证新版文件保留)。
146
+ // 2026-09-01 advisor 🔵:磁盘文件 cwd 不匹配(异项目文件误落本路径)同样轮转——
147
+ // 与 loadSlotFile/legacy 读的"别人的文件不动"原则对齐(否则 sessionStart null +
148
+ // version≤2 的异项目文件被静默覆盖且无 .bak)。
149
+ const diskIsNewer = typeof disk?.version === "number" && disk.version > 2
150
+ const diskStart = disk?.sessionStart ?? null
151
+ const myStart = agent._sessionStart ?? null
152
+ const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
153
+ if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
154
+ const bak = `${p}.bak-${Date.now()}`
155
+ renameSync(p, bak)
156
+ rotated = bak
157
+ console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
158
+ }
159
+ agent._slotMtime = st.mtimeMs
160
+ }
161
+ }
162
+ } catch {
163
+ // 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
164
+ // 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
165
+ // 给 F2 轮转路径,损坏现场不再混入轮转后缀,恢复/清理工具按后缀分类不误判)
166
+ if (existsSync(p)) {
167
+ try {
168
+ renameSync(p, `${p}.corrupted`)
169
+ } catch {}
170
+ }
171
+ }
172
+ writeSessionFile(p, data)
173
+ // 记录我们刚写的 mtime——下次保存跳过重复解析
174
+ try { agent._slotMtime = statSync(p).mtimeMs } catch {}
382
175
  // Update slot metadata in manifest
383
176
  try {
384
177
  const m = loadManifest(agent.cwd)
@@ -388,28 +181,40 @@ export function saveSession(agent) {
388
181
  // Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
389
182
  console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
390
183
  }
184
+ return rotated
391
185
  }
392
186
 
393
- /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
394
- export function loadSession(cwd) {
395
- const tryLoad = (p) => {
187
+ /** Load slot file with shared validation 2026-08-31 会诊 deepseek 🟡 抽取:
188
+ * loadSession(active 槽)/switchToSlot/ACP session/load 共享。无认领副作用。
189
+ * version 1/2 + history 数组 + cwd 匹配(2026-09-01 会诊 kimi 🔵:cwd 先行——"别人的
190
+ * 文件不动"优先于结构校验,异 cwd + 坏 version 不得改名);结构不符改名 .unreadable、
191
+ * 解析失败 .tmp 回退成功后提升为正主(损坏主文件改名 .corrupted 保留)、主文件缺失
192
+ * 时恢复孤儿 .tmp(rename 前崩溃现场)。 */
193
+ export function loadSlotFile(cwd, slot) {
194
+ const p = slotPath(cwd, slot)
195
+ const tryLoad = (path) => {
396
196
  try {
397
- if (!existsSync(p)) return null
398
- const data = JSON.parse(readFileSync(p, "utf8"))
399
- if (data?.version !== 1 && data?.version !== 2) return null
400
- if (!Array.isArray(data.history)) return null
401
- if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
197
+ if (!existsSync(path)) return null
198
+ const data = JSON.parse(readFileSync(path, "utf8"))
199
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null // 别人的文件,不动
200
+ if (data?.version !== 1 && data?.version !== 2) {
201
+ if (typeof data?.version === "number" && data.version > 2) return null // 新版 CLI 的文件,不动(F2 轮转兜底)
202
+ try { renameSync(path, `${path}.unreadable`) } catch {}
203
+ console.error(`[session] unsupported version ${data?.version} at ${path} — preserved as ${basename(path)}.unreadable`)
204
+ return null
205
+ }
206
+ if (!Array.isArray(data.history)) {
207
+ try { renameSync(path, `${path}.unreadable`) } catch {}
208
+ console.error(`[session] slot file ${path}: history is not an array — preserved as ${basename(path)}.unreadable`)
209
+ return null
210
+ }
402
211
  data.history = data.history.filter((m) => !isLegacyTransient(m))
403
- data._recovered = false
404
212
  return data
405
213
  } catch (e) {
406
214
  return { _error: e }
407
215
  }
408
216
  }
409
217
 
410
- // Try the active slot first (post-migration, legacy file may be stale)
411
- const slot = activeSlot(cwd)
412
- const p = slotPath(cwd, slot)
413
218
  let result = tryLoad(p)
414
219
  if (result && !result._error) return result
415
220
  if (result?._error) {
@@ -417,36 +222,95 @@ export function loadSession(cwd) {
417
222
  const tmpResult = tryLoad(`${p}.tmp`)
418
223
  if (tmpResult && !tmpResult._error) {
419
224
  console.error(`[session] recovered from .tmp fallback`)
420
- tmpResult._recovered = true
225
+ // 2026-09-01 会诊 🟢:.tmp 提升为正主(损坏主文件改名 .corrupted 保留现场)——
226
+ // 否则主文件留在原地,每次加载都重复"恢复",且保存侧 F2 会持续轮转它。
227
+ try {
228
+ renameSync(p, `${p}.corrupted`)
229
+ renameSync(`${p}.tmp`, p)
230
+ } catch {}
421
231
  return tmpResult
422
232
  }
423
233
  console.error(`[session] .tmp fallback also failed — session lost.`)
424
234
  try { renameSync(p, `${p}.corrupted`) } catch {}
235
+ } else if (!result) {
236
+ // 2026-09-01 advisor 🔵:主文件缺失但孤儿 .tmp 存在(原子写 rename 前崩溃)——
237
+ // 恢复并提升为正主(SESSION.md §4 的 .tmp 回退语义应覆盖此场景)。
238
+ const orphan = tryLoad(`${p}.tmp`)
239
+ if (orphan && !orphan._error) {
240
+ console.error(`[session] slot ${slot} main file missing — recovered orphan .tmp`)
241
+ try { renameSync(`${p}.tmp`, p) } catch {}
242
+ return orphan
243
+ }
425
244
  }
245
+ return null
246
+ }
247
+
248
+ /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
249
+ export function loadSession(cwd) {
250
+ // Try the active slot first (post-migration, legacy file may be stale)
251
+ const slot = activeSlot(cwd)
252
+ let result = loadSlotFile(cwd, slot)
253
+ if (result) return result
426
254
 
427
255
  // Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
428
256
  const legacy = sessionPath(cwd)
429
- result = tryLoad(legacy)
430
- if (result && !result._error) {
431
- return result
432
- }
433
- if (result?._error) {
434
- console.error(`[session] failed to load legacy ${legacy}: ${result._error.message}`)
257
+ try {
258
+ if (existsSync(legacy)) {
259
+ const data = JSON.parse(readFileSync(legacy, "utf8"))
260
+ // cwd 不匹配是别人的文件——与 loadSlotFile 一致直接 return null 不改名
261
+ // ("别人的文件不动"原则,2026-08-31 advisor round2 🟡)
262
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
263
+ if ((data?.version === 1 || data?.version === 2) && Array.isArray(data.history)) {
264
+ data.history = data.history.filter((m) => !isLegacyTransient(m))
265
+ return data
266
+ }
267
+ // 结构不匹配:保留现场(version>2 的新版文件不动)
268
+ if (!(typeof data?.version === "number" && data.version > 2)) {
269
+ try { renameSync(legacy, `${legacy}.unreadable`) } catch {}
270
+ console.error(`[session] legacy file ${legacy}: invalid structure — preserved as ${basename(legacy)}.unreadable`)
271
+ }
272
+ }
273
+ } catch (e) {
274
+ console.error(`[session] failed to load legacy ${legacy}: ${e.message}`)
435
275
  try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
436
276
  }
437
-
438
277
  return null
439
278
  }
440
279
 
441
- /** Apply loaded session data onto an agent object; returns true if provider was switched */
280
+ /** slimForDisplay 截断的 arguments U+2026(…)结尾——不是合法 JSON 的完整值。
281
+ * v1 老文件回退播种机器线时置为 {}(合法空参数),防止半截 \\uXXXX 毒化发送载荷(会诊 F6)。 */
282
+ function stripTruncatedToolArgs(m) {
283
+ if (m?.role !== "assistant" || !Array.isArray(m.tool_calls)) return m
284
+ let changed = false
285
+ const tool_calls = m.tool_calls.map((tc) => {
286
+ const args = tc?.function?.arguments
287
+ if (typeof args === "string" && args.endsWith("…")) {
288
+ changed = true
289
+ return { ...tc, function: { ...tc.function, arguments: "{}" } }
290
+ }
291
+ return tc
292
+ })
293
+ return changed ? { ...m, tool_calls } : m
294
+ }
295
+
296
+ /** Apply loaded session data onto an agent object; returns true if provider was switched.
297
+ * 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领(F1b 回归:
298
+ * switchToSlot 后保存必须落新槽而非旧槽)。 */
442
299
  export function applySession(agent, data) {
443
300
  // data.history is the FULL never-compacted record (human line); data.contextHistory is the
444
301
  // (possibly compacted) machine line. Restore each line from its own source — the machine
445
302
  // context keeps its compaction savings across resume. Legacy files without contextHistory
446
303
  // fall back to seeding the machine line from the full history (it re-compacts when needed).
304
+ // 2026-08-31 会诊 deepseek 🟡:机读线必须从 contextHistory 恢复而非从完整 history 重建——
305
+ // 后者会把已压缩的中间过程塞回上下文(实测 prompt 膨胀到 283%)。compactThresholdAuto 时
306
+ // 按恢复后的模型重新推导阈值(bin/thincoder.mjs)。v1 老文件(无 contextHistory)回退
307
+ // 播种时剥离被 slimForDisplay 截断的 tool_calls.arguments(以 … 结尾 → 置 {};会诊 F6——
308
+ // 截断可劈断 \\uXXXX 产生 400 毒载荷)。2026-09-01 会诊三家:length > 0 才当机读线
309
+ // (contextHistory: [] 是"无机读线"而非空机器线——空机器线会静默丢全部上下文)。
447
310
  agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
448
311
  const full = Array.isArray(data.history) ? data.history : []
449
- const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
312
+ const ch = data.contextHistory
313
+ const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
450
314
  agent._fullHistory = [...full]
451
315
  agent.history = [...machine]
452
316
  agent.title = data.title ?? ""
@@ -457,6 +321,14 @@ export function applySession(agent, data) {
457
321
  agent._pendingReminders = data.pendingReminders ?? []
458
322
  agent._sessionStart = data.sessionStart ?? null
459
323
  agent._engDesignToken = data.engDesignToken ?? null
324
+ // Multi-design slots restore from the {designId: token} object (2026-09-01 audit #1). A legacy
325
+ // slot without the field restores NO Map (fresh state) — never resurrect slots the writer did
326
+ // not have. Expired tokens are rejected downstream by validateDesignToken (fail-closed, TTL).
327
+ if (data.engDesignTokens && typeof data.engDesignTokens === "object" && !Array.isArray(data.engDesignTokens)) {
328
+ agent._engDesignTokens = new Map(Object.entries(data.engDesignTokens))
329
+ } else {
330
+ delete agent._engDesignTokens
331
+ }
460
332
  // engineering is session-level (2026-08-29): the slot value is the CLI session's authority
461
333
  // — config.json is only the initial default / cross-end mirror. A legacy slot without the
462
334
  // field keeps whatever config.json seeded (unchanged behavior).
@@ -471,6 +343,8 @@ export function applySession(agent, data) {
471
343
  agent._compressFailures = 0
472
344
  agent._verifyRetries = 0
473
345
  agent._verifyPassed = false
346
+ agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
347
+ agent._slotMtime = null
474
348
  if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
475
349
  const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
476
350
  if (p) {
@@ -502,18 +376,120 @@ export function applySession(agent, data) {
502
376
  */
503
377
  export function newSession(cwd) {
504
378
  const m = loadManifest(cwd)
505
- // Ensure active is set (migrate if needed)
506
- if (!m.active) ensureActive(cwd, m)
507
379
 
508
- // Find next available slot number
380
+ // 2026-09-01 advisor 🔵:先清理死主条目(与 ensureActive 分支2 语义一致)——否则
381
+ // "死主 + 文件缺失"的空槽(m.slots 有条目、无文件、属主已死)永不复用,槽号持续
382
+ // 增长。死主且无文件 = 该会话从未落盘(进程死了没保存),条目可安全删除回收。
383
+ // 2026-09-01 会诊 deepseek/glm 🟡:清理必须经 deletions 显式落盘——saveManifest 条目级
384
+ // 合并会把磁盘死条目从 fresh 复活回写,仅传 m 等于没删(VS Code newSlot 已修,CLI 对称)。
385
+ const mySessionId = getSessionId()
386
+ const deadSlots = []
387
+ for (const [s, owner] of Object.entries(m.slotSessions ?? {})) {
388
+ if (owner && owner !== mySessionId) {
389
+ const pid = parseInt(owner.split("-")[0])
390
+ if (!pid || !isProcessAlive(pid)) {
391
+ delete m.slotSessions[s]
392
+ if (!existsSync(slotPath(cwd, Number(s)))) delete m.slots[s]
393
+ deadSlots.push(s)
394
+ }
395
+ }
396
+ }
397
+
398
+ // Find next available slot number — 2026-08-31 会诊 deepseek 🟡:不能只看 manifest
399
+ // 条目(丢失更新可能让条目消失而文件仍在)——复用该号会直接覆写真实会话
400
+ // (F2 防护不覆盖 newSession 的空数据直写)。
401
+ // 2026-08-31 advisor round2 🟡:同时跳过"已被另一活进程认领但尚未落盘"的号
402
+ // (slotSessions 有条目、slots 无条目、文件不存在)——否则双进程会认领同一号。
403
+ const liveClaimed = (n) => {
404
+ const owner = m.slotSessions?.[n]
405
+ return !!(owner && owner !== mySessionId && isProcessAlive(parseInt(owner.split("-")[0])))
406
+ }
509
407
  let slot = 1
510
- while (m.slots[slot]) slot++
408
+ while (m.slots[slot] || existsSync(slotPath(cwd, slot)) || liveClaimed(slot)) slot++
511
409
 
512
- // Write empty session
513
- const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], tasks: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
410
+ // Write empty session — 2026-09-01 advisor 🔵:补 contextHistory/planMode 字段与
411
+ // VS Code newSlot 对齐(SESSION.md §3 v2 格式双端一致;两端读侧均有兜底,功能等价)
412
+ const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], contextHistory: [], tasks: [], planMode: false, goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
514
413
  writeSessionFile(slotPath(cwd, slot), data)
515
414
  m.slots[slot] = slotDigest(data)
516
415
  m.active = slot
517
- saveManifest(cwd, m)
416
+ // 2026-08-31 advisor round1 🟡:与 ensureActive 认领模型一致——立即记录新 slot 所有权,
417
+ // 否则 /new 后到首次保存之间并发方(VS Code/另一 CLI)会把新 active 槽当"空闲可恢复"
418
+ // 认领 → 双进程写同一槽(F2 轮转互旋)。
419
+ m.slotSessions ??= {}
420
+ m.slotSessions[slot] = mySessionId
421
+ // 2026-09-01 会诊三家 🟡:显式翻 active 的调用点传 setActive(saveManifest 默认保留
422
+ // 磁盘 fresh.active,避免把并发方刚翻的指针回滚)
423
+ // 2026-09-01 会诊 deepseek/glm 🟡:deletions 过滤掉本调用刚重新认领的槽(防删掉自己的
424
+ // 新属主)——与 ensureActive deadParam / VS Code newSlot 同型。
425
+ const deletions = deadSlots.length
426
+ ? {
427
+ slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId),
428
+ slots: deadSlots.filter((s) => !m.slots[s]),
429
+ }
430
+ : null
431
+ saveManifest(cwd, m, deletions, { setActive: true })
518
432
  return slot
519
433
  }
434
+
435
+ /** 清空会话运行态(/new 用,2026-08-31 会诊 F3):_fullHistory/title/_sessionStart 等
436
+ * 全部会话级状态 + 一次性注入标志必须全清——否则新会话首次落盘把旧会话完整人类线 +
437
+ * 旧标题写进新槽(实锤 .19/.3 双副本);注入标志不清则 /new 后新会话永不注入
438
+ * OS/cwd reminder(2026-09-01 会诊 glm 🟡)。autoApprove 是用户偏好,跨会话保留(有意)。 */
439
+ export function resetSessionState(agent) {
440
+ agent._fullHistory = []
441
+ agent.history = []
442
+ agent.title = ""
443
+ agent.tasks = []
444
+ agent._sessionStart = null
445
+ agent._engDesignToken = null
446
+ agent._engDesignTokens = new Map() // multi-design slots die with the session (2026-09-01 fix #2)
447
+ agent._compressFailures = 0
448
+ agent._verifyRetries = 0
449
+ agent._verifyPassed = undefined
450
+ agent._runStartHistoryLen = 0
451
+ agent._lastPromptTokens = null
452
+ agent._usageAtLen = null
453
+ agent.planMode = false
454
+ agent.goal = null
455
+ agent._pendingReminders = []
456
+ agent._slot = null
457
+ agent._slotMtime = null
458
+ agent._osReminderInjected = false
459
+ agent._restartReminderInjected = false
460
+ agent._lastEngState = false
461
+ }
462
+
463
+ /** Switch the manifest active pointer to a slot. Returns the slot's session data
464
+ * (null if the slot doesn't exist / can't be read). 2026-08-31 会诊 deepseek 🔴:
465
+ * 原实现经 loadSession → activeSlot 有认领副作用——目标槽被活进程占用时 ensureActive
466
+ * 分支 3 会把 active 拨到新空槽并读回 null + 劫持对方指针。现改为 loadSlotFile 直接读
467
+ * (无认领副作用);目标槽空闲则一并认领、被另一活进程占用则不认领(slotOccupancy——
468
+ * 下次保存经 activeSlot 自然 fork 到新槽);只改 manifest 指针(setActive 意图)。
469
+ * 2026-09-01 会诊三家 🟡:saveManifest 条目级合并 + setActive(不把并发方刚翻的指针回滚)。 */
470
+ export function switchToSlot(cwd, slot) {
471
+ const m = loadManifest(cwd)
472
+ if (!m.slots[slot]) return null
473
+ const data = loadSlotFile(cwd, slot)
474
+ if (!data) return null
475
+ m.active = slot
476
+ const occ = slotOccupancy(cwd, slot)
477
+ if (!occ.occupied) {
478
+ m.slotSessions ??= {}
479
+ m.slotSessions[slot] = getSessionId()
480
+ }
481
+ saveManifest(cwd, m, null, { setActive: true })
482
+ return data
483
+ }
484
+
485
+ /** 目标槽是否被另一活进程占用(2026-09-01 会诊/advisor 🟡):排除本进程属主——
486
+ * /session 重选当前槽不误报;同进程双会话防护由 ACP sameProcessPinned 承担。 */
487
+ export function slotOccupancy(cwd, slot) {
488
+ const m = loadManifest(cwd)
489
+ const owner = m.slotSessions?.[slot]
490
+ if (!owner) return { occupied: false }
491
+ if (owner === getSessionId()) return { occupied: false }
492
+ const pid = parseInt(owner.split("-")[0])
493
+ if (!pid || !isProcessAlive(pid)) return { occupied: false }
494
+ return { occupied: true, owner }
495
+ }