thincoder 0.12.53 → 0.12.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/agent/setup.mjs +2 -1
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/prompts/discipline.md +2 -2
- package/src/provider/core.mjs +42 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +20 -7
- package/src/tools/git.mjs +48 -162
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +3 -22
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +40 -1
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-slots.mjs — slot / manifest 管理(2026-08-31 advisor round1 🔴 拆分:
|
|
3
|
+
* session.mjs 曾超 500 行硬限;slot 所有权、认领、清单与核心读写分离,session.mjs
|
|
4
|
+
* re-export 全部导出以保持既有 import 兼容)。
|
|
5
|
+
*
|
|
6
|
+
* 模型:每个项目(cwd hash)拥有无限编号 slot;manifest 记录 active 指针 + 每个
|
|
7
|
+
* slot 的属主进程(slotSessions: slot → "pid-timestamp-random",CLI ↔ VS Code
|
|
8
|
+
* 共享 manifest 以互斥认领)。属主判定用 PID 存活探测(isProcessAlive)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto"
|
|
12
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, statSync } 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
|
+
export function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
|
|
47
|
+
export function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
|
|
48
|
+
|
|
49
|
+
/** Path to the active slot's file */
|
|
50
|
+
export function activePath(cwd) {
|
|
51
|
+
return slotPath(cwd, activeSlot(cwd))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
|
|
55
|
+
export function writeSessionFile(p, data) {
|
|
56
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
57
|
+
const tmp = `${p}.tmp`
|
|
58
|
+
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
59
|
+
try {
|
|
60
|
+
renameSync(tmp, p)
|
|
61
|
+
} catch {
|
|
62
|
+
try { unlinkSync(p) } catch {}
|
|
63
|
+
try {
|
|
64
|
+
renameSync(tmp, p)
|
|
65
|
+
try { unlinkSync(tmp) } catch {}
|
|
66
|
+
} catch {
|
|
67
|
+
writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ========== slot management ==========
|
|
73
|
+
|
|
74
|
+
/** Detect a genuine user message (excludes system-reminder injected messages) */
|
|
75
|
+
function isRealUserMsg(m) {
|
|
76
|
+
return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
|
|
80
|
+
function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
|
|
81
|
+
const userMsgs = history.filter(isRealUserMsg)
|
|
82
|
+
const first = userMsgs[0]?.content ?? ""
|
|
83
|
+
return {
|
|
84
|
+
messageCount: history.length,
|
|
85
|
+
turnCount: userMsgs.length,
|
|
86
|
+
firstMessage: first.slice(0, 80),
|
|
87
|
+
activeProvider: activeProvider ?? "",
|
|
88
|
+
updatedAt: updatedAt ?? Date.now(),
|
|
89
|
+
title,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Extract preview summary from session data for manifest storage (with current timestamp) */
|
|
94
|
+
export function slotDigest(data) {
|
|
95
|
+
const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
|
|
96
|
+
return { ts: Date.now(), ...meta }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function loadManifest(cwd) {
|
|
100
|
+
try {
|
|
101
|
+
const p = manifestPath(cwd)
|
|
102
|
+
if (!existsSync(p)) return { slots: {}, sessionId: null }
|
|
103
|
+
const m = JSON.parse(readFileSync(p, "utf8"))
|
|
104
|
+
if (!m.slots) m.slots = {} // 2026-09-01 会诊 deepseek 🔵:损坏的 {} manifest 不再让调用方抛 TypeError
|
|
105
|
+
if (!m.sessionId) m.sessionId = null
|
|
106
|
+
return m
|
|
107
|
+
} catch { return { slots: {}, sessionId: null } }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function saveManifest(cwd, m, deletions = null, opts = {}) {
|
|
111
|
+
// 2026-08-31 会诊 kimi/deepseek 🟡:写前重读并按"条目级"合并——原实现把"读时快照"
|
|
112
|
+
// 整对象写回,另一进程在窗口内对 slots/slotSessions/active 的变更被覆盖抹除(被抹
|
|
113
|
+
// 认领的槽变"文件在、无属主"→ 第三方可认领 → 双属主 → F2 互旋)。无锁文件无法
|
|
114
|
+
// 完全原子,重读合并把丢失更新窗口缩到最小;删除意图经 deletions 参数显式表达
|
|
115
|
+
// (deleteSlot:{ slots: [n], slotSessions: [n] })。
|
|
116
|
+
// 2026-09-01 会诊 deepseek/kimi/glm 🟡:active 是单值——只有显式翻指针的调用方
|
|
117
|
+
// (ensureActive 分支、newSession、switchToSlot、deleteSlot 删到 active 时)传
|
|
118
|
+
// opts.setActive;其余调用方(saveSession/ACP 认领/死项清理)默认保留磁盘 fresh 的
|
|
119
|
+
// active,否则毫秒窗口内会把并发方刚翻的 active 回滚(F1 防漂移的反向变体)。
|
|
120
|
+
try {
|
|
121
|
+
const fresh = JSON.parse(readFileSync(manifestPath(cwd), "utf8"))
|
|
122
|
+
if (fresh && typeof fresh === "object" && fresh.slots && typeof fresh.slots === "object") {
|
|
123
|
+
const merged = { ...fresh }
|
|
124
|
+
if (opts.setActive) merged.active = m.active
|
|
125
|
+
merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
|
|
126
|
+
merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
|
|
127
|
+
if (m.sessionId) merged.sessionId = m.sessionId
|
|
128
|
+
if (deletions) {
|
|
129
|
+
for (const [section, keys] of Object.entries(deletions)) {
|
|
130
|
+
for (const k of keys) delete merged[section]?.[k]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
m = merged
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// 首次创建或 manifest 不可读:用传入对象。2026-09-01 advisor 🟡:解析失败时先改名
|
|
137
|
+
// 保留现场(与 loadSlotFile 对 slot 文件的 .corrupted 原则一致)——否则覆盖后全部
|
|
138
|
+
// 槽位元数据(digest/title/updatedAt)永久丢失,/session 列表变空。文件不存在时
|
|
139
|
+
// rename 抛错被吞,无害。
|
|
140
|
+
try { renameSync(manifestPath(cwd), `${manifestPath(cwd)}.corrupted`) } catch {}
|
|
141
|
+
}
|
|
142
|
+
m.sessionId = getSessionId()
|
|
143
|
+
writeSessionFile(manifestPath(cwd), m)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Claim a slot for this process and set it as active. Idempotent.
|
|
148
|
+
* Preference order:
|
|
149
|
+
* 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
|
|
150
|
+
* 2. Any slot that is unowned or owned by a dead process (reclaim).
|
|
151
|
+
* 3. A brand-new slot when all are owned by live processes.
|
|
152
|
+
* The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
|
|
153
|
+
* see which slots are taken and avoid them.
|
|
154
|
+
*/
|
|
155
|
+
function ensureActive(cwd, m) {
|
|
156
|
+
const mySessionId = getSessionId()
|
|
157
|
+
if (!m.slotSessions) m.slotSessions = {}
|
|
158
|
+
|
|
159
|
+
// 2026-08-31 会诊 F4:顺手清理死主条目——owner 进程已死的 slot 标记不再占用
|
|
160
|
+
// (原实现死项永不清理,空闲 slot 越来越少 → 新号滥发;且每次 save 对每 slot 跑
|
|
161
|
+
// tasklist,延迟随 slot 数增长)。
|
|
162
|
+
// advisor round2 #10:不能加 "文件缺失即删" 的短路——活进程在"认领 → 首次保存"窗口
|
|
163
|
+
// (新项目首跑:启动认领 slot、回合末才落盘)文件暂缺,误删其条目会被另一进程当
|
|
164
|
+
// 空闲认领 → 双进程永久写同一槽(F2 轮转互旋)。死主判定必须跑 tasklist;
|
|
165
|
+
// ensureActive 因 F1 粘性每次进程只跑几次,全量 tasklist 成本可接受。
|
|
166
|
+
let cleaned = false
|
|
167
|
+
const deadSlots = []
|
|
168
|
+
for (const [slot, owner] of Object.entries(m.slotSessions)) {
|
|
169
|
+
if (owner && owner !== mySessionId) {
|
|
170
|
+
const pid = parseInt(owner.split("-")[0])
|
|
171
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
172
|
+
delete m.slotSessions[slot]
|
|
173
|
+
deadSlots.push(slot)
|
|
174
|
+
cleaned = true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2026-09-01 会诊三家:deadSlots 里可能含分支 1/2 刚重新认领的槽(m.slotSessions[s]
|
|
180
|
+
// 已被覆盖为 mySessionId)——deletions 若删除它会把自己的认领抹掉(下次 ensureActive
|
|
181
|
+
// 重新认领,但窗口内属主真空)。过滤在每次调用时按当前 m 状态计算。
|
|
182
|
+
const deadParam = () => (cleaned ? { slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId) } : null)
|
|
183
|
+
|
|
184
|
+
// Already own the active slot — nothing to do.
|
|
185
|
+
// 2026-08-31 advisor round2 🔵:清理结果此时落盘(否则死项清理只在内存生效,早退
|
|
186
|
+
// 路径永不持久化——死条目一直滞留到其他路径保存才消失)。
|
|
187
|
+
// advisor round3 N1 + 2026-09-01 会诊 deepseek/kimi 🔴:必须传 deletions——saveManifest
|
|
188
|
+
// 的条目级合并({...fresh, ...m})会把磁盘上仍存在的死条目从 fresh 复活回写,仅传 m
|
|
189
|
+
// 等于没删。N1 当时只修了早退路径,分支 1/2/3 漏了(认领主路径上死项清理是死代码)。
|
|
190
|
+
if (m.active && m.slotSessions[m.active] === mySessionId) {
|
|
191
|
+
if (cleaned) saveManifest(cwd, m, deadParam())
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isFree = (slot) => {
|
|
196
|
+
const owner = m.slotSessions[slot]
|
|
197
|
+
if (!owner || owner === mySessionId) return true
|
|
198
|
+
return !isProcessAlive(parseInt(owner.split("-")[0]))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
|
|
202
|
+
if (m.active && m.slots[m.active] && isFree(m.active)) {
|
|
203
|
+
m.slotSessions[m.active] = mySessionId
|
|
204
|
+
saveManifest(cwd, m, deadParam(), { setActive: true })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. Reclaim a slot whose FILE does not exist (never held a session). 2026-08-31 会诊 F4:
|
|
209
|
+
// 原实现认领"编号最小的空闲 slot"——死主的旧 slot 文件仍在,新进程会 resume 进
|
|
210
|
+
// 陌生会话("会话乱了"实锤)且退出时覆盖它。只有文件缺失的空 slot 才允许回收。
|
|
211
|
+
const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
|
|
212
|
+
for (const slot of allSlots) {
|
|
213
|
+
if (isFree(slot) && !existsSync(slotPath(cwd, slot))) {
|
|
214
|
+
m.active = slot
|
|
215
|
+
m.slotSessions[slot] = mySessionId
|
|
216
|
+
saveManifest(cwd, m, deadParam(), { setActive: true })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 3. All slots owned by live processes — allocate a new one (no limit).
|
|
222
|
+
// 2026-08-31 advisor round2 🟡:新号从 max+1 起逐号跳过"已被活进程认领但尚未落盘"
|
|
223
|
+
// 的号(认领→首次保存窗口:slotSessions 有条目、m.slots 无条目、文件不存在——
|
|
224
|
+
// 仅凭 m.slots/existsSync 查不到 → 双进程认领同一号 → 同槽双写/互旋)。
|
|
225
|
+
// 2026-09-01 会诊 kimi 🟡:同时跳过文件仍存在的号(与 newSession 对齐)——manifest
|
|
226
|
+
// 条目丢失/损坏时 max+1 会撞上孤儿槽文件 → F2 把真会话轮转成不可见的 .bak。
|
|
227
|
+
// 另:allSlots 已升序,取 max 用 allSlots[allSlots.length-1](数万槽位时 Math.max
|
|
228
|
+
// spread 有 RangeError 风险)。
|
|
229
|
+
const liveClaimed = (n) => {
|
|
230
|
+
const owner = m.slotSessions?.[n]
|
|
231
|
+
return !!(owner && owner !== mySessionId && isProcessAlive(parseInt(owner.split("-")[0])))
|
|
232
|
+
}
|
|
233
|
+
let newSlot = allSlots.length > 0 ? allSlots[allSlots.length - 1] + 1 : 1
|
|
234
|
+
while (liveClaimed(newSlot) || existsSync(slotPath(cwd, newSlot))) newSlot++
|
|
235
|
+
m.active = newSlot
|
|
236
|
+
m.slotSessions[newSlot] = mySessionId
|
|
237
|
+
saveManifest(cwd, m, deadParam(), { setActive: true })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if a process with given PID is still alive.
|
|
242
|
+
* Returns false if process doesn't exist or we can't determine.
|
|
243
|
+
*/
|
|
244
|
+
export function isProcessAlive(pid) {
|
|
245
|
+
if (!pid || isNaN(pid)) return false
|
|
246
|
+
try {
|
|
247
|
+
// On Windows: tasklist /FI "PID eq <pid>" /NH
|
|
248
|
+
// On Unix: kill(pid, 0) or check /proc/<pid>
|
|
249
|
+
if (process.platform === 'win32') {
|
|
250
|
+
// 2026-08-31 会诊 F4 + advisor round1 🔵:/FI 已按 PID 过滤;用 CSV 格式解析 PID
|
|
251
|
+
// 列(第 2 列),避免旧 includes() 误报活、新行解析在罕见镜像名(含"数字+空格")
|
|
252
|
+
// 下误报死。
|
|
253
|
+
const output = execSync(`tasklist /FO CSV /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
|
|
254
|
+
return output.split(/\r?\n/).some((line) => {
|
|
255
|
+
const m = line.match(/^"([^"]*)","(\d+)"/)
|
|
256
|
+
return m && m[2] === String(pid)
|
|
257
|
+
})
|
|
258
|
+
} else {
|
|
259
|
+
// Unix: try to send signal 0 (doesn't kill, just checks)
|
|
260
|
+
process.kill(pid, 0)
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Return the active slot number for this process, claiming one if necessary */
|
|
269
|
+
export function activeSlot(cwd) {
|
|
270
|
+
const m = loadManifest(cwd)
|
|
271
|
+
ensureActive(cwd, m)
|
|
272
|
+
return m.active
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
|
|
276
|
+
function loadSlotMeta(cwd, slot, v) {
|
|
277
|
+
if (typeof v === "object" && v !== null && "ts" in v) return v
|
|
278
|
+
const ts = typeof v === "number" ? v : 0
|
|
279
|
+
try {
|
|
280
|
+
const p = slotPath(cwd, slot)
|
|
281
|
+
if (!existsSync(p)) return { ts }
|
|
282
|
+
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
283
|
+
const history = data.history ?? []
|
|
284
|
+
const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
|
|
285
|
+
return { ts, ...meta }
|
|
286
|
+
} catch {
|
|
287
|
+
return { ts }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** List all slots, newest first. Includes isActive flag.
|
|
292
|
+
* 2026-09-01 会诊 🟢:只读操作不认领——原实现 active 缺失时调 activeSlot(写 manifest
|
|
293
|
+
* 副作用,ACP session/list 可触发)。m.active 缺失时全部 isActive=false,由下一次
|
|
294
|
+
* activeSlot 正常认领。 */
|
|
295
|
+
export function listSlots(cwd) {
|
|
296
|
+
const m = loadManifest(cwd)
|
|
297
|
+
const active = m.active ?? null
|
|
298
|
+
return Object.entries(m.slots)
|
|
299
|
+
.filter(([n]) => /^\d+$/.test(n))
|
|
300
|
+
.map(([n, v]) => {
|
|
301
|
+
const meta = loadSlotMeta(cwd, Number(n), v)
|
|
302
|
+
return {
|
|
303
|
+
slot: Number(n),
|
|
304
|
+
isActive: Number(n) === active,
|
|
305
|
+
timestamp: meta.ts,
|
|
306
|
+
date: new Date(meta.ts).toLocaleString(),
|
|
307
|
+
messageCount: meta.messageCount ?? 0,
|
|
308
|
+
turnCount: meta.turnCount ?? 0,
|
|
309
|
+
firstMessage: meta.firstMessage ?? "",
|
|
310
|
+
activeProvider: meta.activeProvider ?? "",
|
|
311
|
+
updatedAt: meta.updatedAt ?? meta.ts,
|
|
312
|
+
updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
|
|
313
|
+
title: meta.title ?? "",
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Delete a slot: remove its file and manifest entry. Deleting the active slot
|
|
320
|
+
* resets the manifest active pointer (the next claim re-creates one). */
|
|
321
|
+
export function deleteSlot(cwd, slot) {
|
|
322
|
+
const n = Number(slot)
|
|
323
|
+
if (!Number.isInteger(n) || n < 1) return false
|
|
324
|
+
const m = loadManifest(cwd)
|
|
325
|
+
if (!m.slots[n]) return false
|
|
326
|
+
delete m.slots[n]
|
|
327
|
+
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
328
|
+
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
329
|
+
if (m.active === n) delete m.active
|
|
330
|
+
// setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
|
|
331
|
+
// 保留 fresh.active,2026-09-01 会诊三家 🟡)
|
|
332
|
+
saveManifest(cwd, m, { slots: [n], slotSessions: [n] }, { setActive: true })
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Rename a slot: update the slot file's title + the manifest metadata (shared with VS Code).
|
|
337
|
+
* 2026-09-01 会诊 glm 🟡:写回前按 mtime 门控重读——原实现读全量→改 title→整文件写回,
|
|
338
|
+
* 窗口内并发方的最新保存会被旧数据覆盖(丢消息);mtime 变了即放弃本次重命名。 */
|
|
339
|
+
export function renameSlot(cwd, slot, title) {
|
|
340
|
+
const n = Number(slot)
|
|
341
|
+
if (!Number.isInteger(n) || n < 1) return false
|
|
342
|
+
const p = slotPath(cwd, n)
|
|
343
|
+
if (!existsSync(p)) return false
|
|
344
|
+
let data
|
|
345
|
+
try {
|
|
346
|
+
data = JSON.parse(readFileSync(p, "utf8"))
|
|
347
|
+
} catch {
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
const t0 = statSync(p).mtimeMs
|
|
351
|
+
data.title = title
|
|
352
|
+
// 读与写之间文件被并发方改过 → 放弃(保留并发内容,重命名下次重试)
|
|
353
|
+
if (statSync(p).mtimeMs !== t0) return false
|
|
354
|
+
writeSessionFile(p, data)
|
|
355
|
+
const m = loadManifest(cwd)
|
|
356
|
+
if (m.slots[n]) {
|
|
357
|
+
m.slots[n] = slotDigest(data)
|
|
358
|
+
saveManifest(cwd, m)
|
|
359
|
+
}
|
|
360
|
+
return true
|
|
361
|
+
}
|