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/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)
@@ -377,8 +121,51 @@ export function saveSession(agent) {
377
121
  pendingReminders: agent._pendingReminders ?? [],
378
122
  sessionStart: agent._sessionStart ?? null,
379
123
  }
380
- const slot = activeSlot(agent.cwd)
381
- writeSessionFile(slotPath(agent.cwd, slot), data)
124
+ const slot = agent._slot ??= activeSlot(agent.cwd)
125
+ const p = slotPath(agent.cwd, slot)
126
+ // 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
127
+ // 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
128
+ // 检查按 mtime 缓存(_slotMtime):文件自上次检查/自写未变就跳过全量解析——
129
+ // 每次保存都 readFileSync+JSON.parse 多 MB 会话 → O(n²) 退化。
130
+ let rotated = null
131
+ try {
132
+ if (existsSync(p)) {
133
+ const st = statSync(p)
134
+ let disk = null
135
+ if (st.mtimeMs !== (agent._slotMtime ?? -1)) {
136
+ disk = JSON.parse(readFileSync(p, "utf8"))
137
+ // 2026-08-31 会诊 deepseek 🟡:version>2 的新版文件无论 sessionStart 一律轮转
138
+ // (loadSlotFile 对 v3 返回 null 不动文件——若其 sessionStart 为 null,旧版首次
139
+ // 保存会静默覆盖;轮转 .bak 保证新版文件保留)。
140
+ // 2026-09-01 advisor 🔵:磁盘文件 cwd 不匹配(异项目文件误落本路径)同样轮转——
141
+ // 与 loadSlotFile/legacy 读的"别人的文件不动"原则对齐(否则 sessionStart null +
142
+ // version≤2 的异项目文件被静默覆盖且无 .bak)。
143
+ const diskIsNewer = typeof disk?.version === "number" && disk.version > 2
144
+ const diskStart = disk?.sessionStart ?? null
145
+ const myStart = agent._sessionStart ?? null
146
+ const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
147
+ if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
148
+ const bak = `${p}.bak-${Date.now()}`
149
+ renameSync(p, bak)
150
+ rotated = bak
151
+ 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)}`)
152
+ }
153
+ agent._slotMtime = st.mtimeMs
154
+ }
155
+ }
156
+ } catch {
157
+ // 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
158
+ // 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
159
+ // 给 F2 轮转路径,损坏现场不再混入轮转后缀,恢复/清理工具按后缀分类不误判)
160
+ if (existsSync(p)) {
161
+ try {
162
+ renameSync(p, `${p}.corrupted`)
163
+ } catch {}
164
+ }
165
+ }
166
+ writeSessionFile(p, data)
167
+ // 记录我们刚写的 mtime——下次保存跳过重复解析
168
+ try { agent._slotMtime = statSync(p).mtimeMs } catch {}
382
169
  // Update slot metadata in manifest
383
170
  try {
384
171
  const m = loadManifest(agent.cwd)
@@ -388,28 +175,40 @@ export function saveSession(agent) {
388
175
  // Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
389
176
  console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
390
177
  }
178
+ return rotated
391
179
  }
392
180
 
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) => {
181
+ /** Load slot file with shared validation 2026-08-31 会诊 deepseek 🟡 抽取:
182
+ * loadSession(active 槽)/switchToSlot/ACP session/load 共享。无认领副作用。
183
+ * version 1/2 + history 数组 + cwd 匹配(2026-09-01 会诊 kimi 🔵:cwd 先行——"别人的
184
+ * 文件不动"优先于结构校验,异 cwd + 坏 version 不得改名);结构不符改名 .unreadable、
185
+ * 解析失败 .tmp 回退成功后提升为正主(损坏主文件改名 .corrupted 保留)、主文件缺失
186
+ * 时恢复孤儿 .tmp(rename 前崩溃现场)。 */
187
+ export function loadSlotFile(cwd, slot) {
188
+ const p = slotPath(cwd, slot)
189
+ const tryLoad = (path) => {
396
190
  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
191
+ if (!existsSync(path)) return null
192
+ const data = JSON.parse(readFileSync(path, "utf8"))
193
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null // 别人的文件,不动
194
+ if (data?.version !== 1 && data?.version !== 2) {
195
+ if (typeof data?.version === "number" && data.version > 2) return null // 新版 CLI 的文件,不动(F2 轮转兜底)
196
+ try { renameSync(path, `${path}.unreadable`) } catch {}
197
+ console.error(`[session] unsupported version ${data?.version} at ${path} — preserved as ${basename(path)}.unreadable`)
198
+ return null
199
+ }
200
+ if (!Array.isArray(data.history)) {
201
+ try { renameSync(path, `${path}.unreadable`) } catch {}
202
+ console.error(`[session] slot file ${path}: history is not an array — preserved as ${basename(path)}.unreadable`)
203
+ return null
204
+ }
402
205
  data.history = data.history.filter((m) => !isLegacyTransient(m))
403
- data._recovered = false
404
206
  return data
405
207
  } catch (e) {
406
208
  return { _error: e }
407
209
  }
408
210
  }
409
211
 
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
212
  let result = tryLoad(p)
414
213
  if (result && !result._error) return result
415
214
  if (result?._error) {
@@ -417,36 +216,95 @@ export function loadSession(cwd) {
417
216
  const tmpResult = tryLoad(`${p}.tmp`)
418
217
  if (tmpResult && !tmpResult._error) {
419
218
  console.error(`[session] recovered from .tmp fallback`)
420
- tmpResult._recovered = true
219
+ // 2026-09-01 会诊 🟢:.tmp 提升为正主(损坏主文件改名 .corrupted 保留现场)——
220
+ // 否则主文件留在原地,每次加载都重复"恢复",且保存侧 F2 会持续轮转它。
221
+ try {
222
+ renameSync(p, `${p}.corrupted`)
223
+ renameSync(`${p}.tmp`, p)
224
+ } catch {}
421
225
  return tmpResult
422
226
  }
423
227
  console.error(`[session] .tmp fallback also failed — session lost.`)
424
228
  try { renameSync(p, `${p}.corrupted`) } catch {}
229
+ } else if (!result) {
230
+ // 2026-09-01 advisor 🔵:主文件缺失但孤儿 .tmp 存在(原子写 rename 前崩溃)——
231
+ // 恢复并提升为正主(SESSION.md §4 的 .tmp 回退语义应覆盖此场景)。
232
+ const orphan = tryLoad(`${p}.tmp`)
233
+ if (orphan && !orphan._error) {
234
+ console.error(`[session] slot ${slot} main file missing — recovered orphan .tmp`)
235
+ try { renameSync(`${p}.tmp`, p) } catch {}
236
+ return orphan
237
+ }
425
238
  }
239
+ return null
240
+ }
241
+
242
+ /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
243
+ export function loadSession(cwd) {
244
+ // Try the active slot first (post-migration, legacy file may be stale)
245
+ const slot = activeSlot(cwd)
246
+ let result = loadSlotFile(cwd, slot)
247
+ if (result) return result
426
248
 
427
249
  // Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
428
250
  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}`)
251
+ try {
252
+ if (existsSync(legacy)) {
253
+ const data = JSON.parse(readFileSync(legacy, "utf8"))
254
+ // cwd 不匹配是别人的文件——与 loadSlotFile 一致直接 return null 不改名
255
+ // ("别人的文件不动"原则,2026-08-31 advisor round2 🟡)
256
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
257
+ if ((data?.version === 1 || data?.version === 2) && Array.isArray(data.history)) {
258
+ data.history = data.history.filter((m) => !isLegacyTransient(m))
259
+ return data
260
+ }
261
+ // 结构不匹配:保留现场(version>2 的新版文件不动)
262
+ if (!(typeof data?.version === "number" && data.version > 2)) {
263
+ try { renameSync(legacy, `${legacy}.unreadable`) } catch {}
264
+ console.error(`[session] legacy file ${legacy}: invalid structure — preserved as ${basename(legacy)}.unreadable`)
265
+ }
266
+ }
267
+ } catch (e) {
268
+ console.error(`[session] failed to load legacy ${legacy}: ${e.message}`)
435
269
  try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
436
270
  }
437
-
438
271
  return null
439
272
  }
440
273
 
441
- /** Apply loaded session data onto an agent object; returns true if provider was switched */
274
+ /** slimForDisplay 截断的 arguments U+2026(…)结尾——不是合法 JSON 的完整值。
275
+ * v1 老文件回退播种机器线时置为 {}(合法空参数),防止半截 \\uXXXX 毒化发送载荷(会诊 F6)。 */
276
+ function stripTruncatedToolArgs(m) {
277
+ if (m?.role !== "assistant" || !Array.isArray(m.tool_calls)) return m
278
+ let changed = false
279
+ const tool_calls = m.tool_calls.map((tc) => {
280
+ const args = tc?.function?.arguments
281
+ if (typeof args === "string" && args.endsWith("…")) {
282
+ changed = true
283
+ return { ...tc, function: { ...tc.function, arguments: "{}" } }
284
+ }
285
+ return tc
286
+ })
287
+ return changed ? { ...m, tool_calls } : m
288
+ }
289
+
290
+ /** Apply loaded session data onto an agent object; returns true if provider was switched.
291
+ * 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领(F1b 回归:
292
+ * switchToSlot 后保存必须落新槽而非旧槽)。 */
442
293
  export function applySession(agent, data) {
443
294
  // data.history is the FULL never-compacted record (human line); data.contextHistory is the
444
295
  // (possibly compacted) machine line. Restore each line from its own source — the machine
445
296
  // context keeps its compaction savings across resume. Legacy files without contextHistory
446
297
  // fall back to seeding the machine line from the full history (it re-compacts when needed).
298
+ // 2026-08-31 会诊 deepseek 🟡:机读线必须从 contextHistory 恢复而非从完整 history 重建——
299
+ // 后者会把已压缩的中间过程塞回上下文(实测 prompt 膨胀到 283%)。compactThresholdAuto 时
300
+ // 按恢复后的模型重新推导阈值(bin/thincoder.mjs)。v1 老文件(无 contextHistory)回退
301
+ // 播种时剥离被 slimForDisplay 截断的 tool_calls.arguments(以 … 结尾 → 置 {};会诊 F6——
302
+ // 截断可劈断 \\uXXXX 产生 400 毒载荷)。2026-09-01 会诊三家:length > 0 才当机读线
303
+ // (contextHistory: [] 是"无机读线"而非空机器线——空机器线会静默丢全部上下文)。
447
304
  agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
448
305
  const full = Array.isArray(data.history) ? data.history : []
449
- const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
306
+ const ch = data.contextHistory
307
+ const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
450
308
  agent._fullHistory = [...full]
451
309
  agent.history = [...machine]
452
310
  agent.title = data.title ?? ""
@@ -471,6 +329,8 @@ export function applySession(agent, data) {
471
329
  agent._compressFailures = 0
472
330
  agent._verifyRetries = 0
473
331
  agent._verifyPassed = false
332
+ agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
333
+ agent._slotMtime = null
474
334
  if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
475
335
  const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
476
336
  if (p) {
@@ -502,18 +362,119 @@ export function applySession(agent, data) {
502
362
  */
503
363
  export function newSession(cwd) {
504
364
  const m = loadManifest(cwd)
505
- // Ensure active is set (migrate if needed)
506
- if (!m.active) ensureActive(cwd, m)
507
365
 
508
- // Find next available slot number
366
+ // 2026-09-01 advisor 🔵:先清理死主条目(与 ensureActive 分支2 语义一致)——否则
367
+ // "死主 + 文件缺失"的空槽(m.slots 有条目、无文件、属主已死)永不复用,槽号持续
368
+ // 增长。死主且无文件 = 该会话从未落盘(进程死了没保存),条目可安全删除回收。
369
+ // 2026-09-01 会诊 deepseek/glm 🟡:清理必须经 deletions 显式落盘——saveManifest 条目级
370
+ // 合并会把磁盘死条目从 fresh 复活回写,仅传 m 等于没删(VS Code newSlot 已修,CLI 对称)。
371
+ const mySessionId = getSessionId()
372
+ const deadSlots = []
373
+ for (const [s, owner] of Object.entries(m.slotSessions ?? {})) {
374
+ if (owner && owner !== mySessionId) {
375
+ const pid = parseInt(owner.split("-")[0])
376
+ if (!pid || !isProcessAlive(pid)) {
377
+ delete m.slotSessions[s]
378
+ if (!existsSync(slotPath(cwd, Number(s)))) delete m.slots[s]
379
+ deadSlots.push(s)
380
+ }
381
+ }
382
+ }
383
+
384
+ // Find next available slot number — 2026-08-31 会诊 deepseek 🟡:不能只看 manifest
385
+ // 条目(丢失更新可能让条目消失而文件仍在)——复用该号会直接覆写真实会话
386
+ // (F2 防护不覆盖 newSession 的空数据直写)。
387
+ // 2026-08-31 advisor round2 🟡:同时跳过"已被另一活进程认领但尚未落盘"的号
388
+ // (slotSessions 有条目、slots 无条目、文件不存在)——否则双进程会认领同一号。
389
+ const liveClaimed = (n) => {
390
+ const owner = m.slotSessions?.[n]
391
+ return !!(owner && owner !== mySessionId && isProcessAlive(parseInt(owner.split("-")[0])))
392
+ }
509
393
  let slot = 1
510
- while (m.slots[slot]) slot++
394
+ while (m.slots[slot] || existsSync(slotPath(cwd, slot)) || liveClaimed(slot)) slot++
511
395
 
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 }
396
+ // Write empty session — 2026-09-01 advisor 🔵:补 contextHistory/planMode 字段与
397
+ // VS Code newSlot 对齐(SESSION.md §3 v2 格式双端一致;两端读侧均有兜底,功能等价)
398
+ const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], contextHistory: [], tasks: [], planMode: false, goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
514
399
  writeSessionFile(slotPath(cwd, slot), data)
515
400
  m.slots[slot] = slotDigest(data)
516
401
  m.active = slot
517
- saveManifest(cwd, m)
402
+ // 2026-08-31 advisor round1 🟡:与 ensureActive 认领模型一致——立即记录新 slot 所有权,
403
+ // 否则 /new 后到首次保存之间并发方(VS Code/另一 CLI)会把新 active 槽当"空闲可恢复"
404
+ // 认领 → 双进程写同一槽(F2 轮转互旋)。
405
+ m.slotSessions ??= {}
406
+ m.slotSessions[slot] = mySessionId
407
+ // 2026-09-01 会诊三家 🟡:显式翻 active 的调用点传 setActive(saveManifest 默认保留
408
+ // 磁盘 fresh.active,避免把并发方刚翻的指针回滚)
409
+ // 2026-09-01 会诊 deepseek/glm 🟡:deletions 过滤掉本调用刚重新认领的槽(防删掉自己的
410
+ // 新属主)——与 ensureActive deadParam / VS Code newSlot 同型。
411
+ const deletions = deadSlots.length
412
+ ? {
413
+ slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId),
414
+ slots: deadSlots.filter((s) => !m.slots[s]),
415
+ }
416
+ : null
417
+ saveManifest(cwd, m, deletions, { setActive: true })
518
418
  return slot
519
419
  }
420
+
421
+ /** 清空会话运行态(/new 用,2026-08-31 会诊 F3):_fullHistory/title/_sessionStart 等
422
+ * 全部会话级状态 + 一次性注入标志必须全清——否则新会话首次落盘把旧会话完整人类线 +
423
+ * 旧标题写进新槽(实锤 .19/.3 双副本);注入标志不清则 /new 后新会话永不注入
424
+ * OS/cwd reminder(2026-09-01 会诊 glm 🟡)。autoApprove 是用户偏好,跨会话保留(有意)。 */
425
+ export function resetSessionState(agent) {
426
+ agent._fullHistory = []
427
+ agent.history = []
428
+ agent.title = ""
429
+ agent.tasks = []
430
+ agent._sessionStart = null
431
+ agent._engDesignToken = null
432
+ agent._compressFailures = 0
433
+ agent._verifyRetries = 0
434
+ agent._verifyPassed = undefined
435
+ agent._runStartHistoryLen = 0
436
+ agent._lastPromptTokens = null
437
+ agent._usageAtLen = null
438
+ agent.planMode = false
439
+ agent.goal = null
440
+ agent._pendingReminders = []
441
+ agent._slot = null
442
+ agent._slotMtime = null
443
+ agent._osReminderInjected = false
444
+ agent._restartReminderInjected = false
445
+ agent._lastEngState = false
446
+ }
447
+
448
+ /** Switch the manifest active pointer to a slot. Returns the slot's session data
449
+ * (null if the slot doesn't exist / can't be read). 2026-08-31 会诊 deepseek 🔴:
450
+ * 原实现经 loadSession → activeSlot 有认领副作用——目标槽被活进程占用时 ensureActive
451
+ * 分支 3 会把 active 拨到新空槽并读回 null + 劫持对方指针。现改为 loadSlotFile 直接读
452
+ * (无认领副作用);目标槽空闲则一并认领、被另一活进程占用则不认领(slotOccupancy——
453
+ * 下次保存经 activeSlot 自然 fork 到新槽);只改 manifest 指针(setActive 意图)。
454
+ * 2026-09-01 会诊三家 🟡:saveManifest 条目级合并 + setActive(不把并发方刚翻的指针回滚)。 */
455
+ export function switchToSlot(cwd, slot) {
456
+ const m = loadManifest(cwd)
457
+ if (!m.slots[slot]) return null
458
+ const data = loadSlotFile(cwd, slot)
459
+ if (!data) return null
460
+ m.active = slot
461
+ const occ = slotOccupancy(cwd, slot)
462
+ if (!occ.occupied) {
463
+ m.slotSessions ??= {}
464
+ m.slotSessions[slot] = getSessionId()
465
+ }
466
+ saveManifest(cwd, m, null, { setActive: true })
467
+ return data
468
+ }
469
+
470
+ /** 目标槽是否被另一活进程占用(2026-09-01 会诊/advisor 🟡):排除本进程属主——
471
+ * /session 重选当前槽不误报;同进程双会话防护由 ACP sameProcessPinned 承担。 */
472
+ export function slotOccupancy(cwd, slot) {
473
+ const m = loadManifest(cwd)
474
+ const owner = m.slotSessions?.[slot]
475
+ if (!owner) return { occupied: false }
476
+ if (owner === getSessionId()) return { occupied: false }
477
+ const pid = parseInt(owner.split("-")[0])
478
+ if (!pid || !isProcessAlive(pid)) return { occupied: false }
479
+ return { occupied: true, owner }
480
+ }