thincoder 0.12.59 → 0.12.60

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 (127) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +2 -2
  3. package/bin/thincoder.mjs +80 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +7 -4
  6. package/src/advisor/messages.mjs +24 -4
  7. package/src/advisor/run.mjs +35 -33
  8. package/src/advisor.mjs +25 -6
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +102 -19
  11. package/src/agent/helpers.mjs +36 -0
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +18 -2
  16. package/src/agent/spawn-child.mjs +29 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +110 -108
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +121 -102
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +24 -29
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/read-history.mjs +155 -31
  25. package/src/agent-tools/recent-changes.mjs +2 -1
  26. package/src/agent-tools/settings.mjs +7 -17
  27. package/src/agent-tools/subagent-actions.mjs +168 -130
  28. package/src/agent-tools/subagent-async.mjs +129 -174
  29. package/src/agent-tools/subagent-panel.mjs +153 -0
  30. package/src/agent-tools/subagent-run.mjs +202 -0
  31. package/src/agent-tools/subagent-scheduler.mjs +45 -21
  32. package/src/agent-tools/subagent-spawn.mjs +406 -0
  33. package/src/agent-tools/subagent.mjs +107 -555
  34. package/src/agent-tools/verify.mjs +118 -270
  35. package/src/agent.mjs +57 -190
  36. package/src/cli/distill-command.mjs +10 -4
  37. package/src/cli/make-agent.mjs +3 -1
  38. package/src/cli/memory-command.mjs +2 -1
  39. package/src/cli/permission.mjs +2 -2
  40. package/src/cli/setup-wizard.mjs +17 -12
  41. package/src/config.mjs +56 -8
  42. package/src/context.mjs +5 -147
  43. package/src/crash-reports.mjs +123 -0
  44. package/src/distill.mjs +11 -11
  45. package/src/explore-distill.mjs +155 -0
  46. package/src/memory/code-sync.mjs +2 -1
  47. package/src/memory/core.mjs +6 -193
  48. package/src/memory/delete.mjs +234 -0
  49. package/src/memory/docs.mjs +58 -48
  50. package/src/memory.mjs +3 -1
  51. package/src/peer-domains.mjs +265 -0
  52. package/src/peer-instances.mjs +231 -0
  53. package/src/prompt-overlays.mjs +25 -0
  54. package/src/prompts/advisor-design.md +9 -76
  55. package/src/prompts/advisor-round1.md +9 -68
  56. package/src/prompts/advisor-round2.md +7 -54
  57. package/src/prompts/advisor-round3.md +7 -54
  58. package/src/prompts/coder.md +7 -50
  59. package/src/prompts/consult-base.md +4 -24
  60. package/src/prompts/discipline.md +26 -44
  61. package/src/prompts/eng-coder.md +7 -32
  62. package/src/prompts/engineering-sub.md +3 -23
  63. package/src/prompts/engineering.md +53 -306
  64. package/src/prompts/explore.md +3 -12
  65. package/src/prompts/main.md +10 -32
  66. package/src/prompts/methodology-template.md +28 -48
  67. package/src/prompts/plan.md +2 -9
  68. package/src/prompts/system.md +16 -35
  69. package/src/provider/core.mjs +6 -67
  70. package/src/provider/errors.mjs +76 -0
  71. package/src/provider/retry.mjs +8 -45
  72. package/src/session-gc.mjs +214 -0
  73. package/src/session-guard.mjs +47 -0
  74. package/src/session-rename.mjs +38 -0
  75. package/src/session-slots.mjs +181 -58
  76. package/src/session.mjs +48 -89
  77. package/src/token-ttl.mjs +273 -0
  78. package/src/tools/checklist-sync.mjs +181 -0
  79. package/src/tools/checklist.mjs +52 -39
  80. package/src/tools/edit-batch.mjs +109 -10
  81. package/src/tools/edit-diff.mjs +110 -27
  82. package/src/tools/edit.md +17 -12
  83. package/src/tools/execute.mjs +31 -4
  84. package/src/tools/file.mjs +11 -6
  85. package/src/tools/git.mjs +14 -6
  86. package/src/tools/glob-dialect.mjs +130 -0
  87. package/src/tools/glob.md +3 -3
  88. package/src/tools/grep.md +1 -1
  89. package/src/tools/index.mjs +5 -6
  90. package/src/tools/ops.mjs +175 -3
  91. package/src/tools/patch.mjs +3 -3
  92. package/src/tools/question.md +3 -0
  93. package/src/tools/read.md +0 -1
  94. package/src/tools/shared.mjs +14 -13
  95. package/src/tools/system.mjs +44 -9
  96. package/src/tools/wait_for.md +22 -0
  97. package/src/tui/agent-turn.mjs +17 -228
  98. package/src/tui/cmd-config.mjs +48 -7
  99. package/src/tui/cmd-eng.mjs +20 -16
  100. package/src/tui/cmd-mcp.mjs +8 -2
  101. package/src/tui/cmd-new.mjs +3 -2
  102. package/src/tui/cmd-session.mjs +19 -4
  103. package/src/tui/cmd-think.mjs +10 -10
  104. package/src/tui/cmd-upgrade.mjs +19 -4
  105. package/src/tui/config-helpers.mjs +28 -16
  106. package/src/tui/distill-cmd.mjs +1 -1
  107. package/src/tui/index.mjs +3 -2
  108. package/src/tui/interaction.mjs +3 -3
  109. package/src/tui/mouse.mjs +7 -1
  110. package/src/tui/pickers.mjs +40 -22
  111. package/src/tui/render-segments.mjs +27 -10
  112. package/src/tui/startup.mjs +4 -0
  113. package/src/tui/subagent-blocks.mjs +95 -263
  114. package/src/tui/subagent-children.mjs +176 -0
  115. package/src/tui/subagent-freeze.mjs +172 -0
  116. package/src/tui/subagent-panel.mjs +61 -23
  117. package/src/tui/suspension-drive.mjs +351 -0
  118. package/src/tui/tool-args.mjs +3 -3
  119. package/src/tui/tool-display.mjs +142 -0
  120. package/src/tui/tool-events.mjs +37 -173
  121. package/src/tui/tui-lifecycle.mjs +29 -0
  122. package/src/tui/update-notice.mjs +4 -0
  123. package/src/tui/wizard.mjs +12 -6
  124. package/src/tools/pdf-parse-text.mjs +0 -497
  125. package/src/tools/pdf-parse-xref.mjs +0 -499
  126. package/src/tools/pdf.mjs +0 -155
  127. package/src/tools/read_pdf.md +0 -21
@@ -0,0 +1,214 @@
1
+ /**
2
+ * session-gc.mjs — 会话目录残留 GC + 冷 cwd 报告/删除(SESSION.md §12,2026-09-06)。
3
+ *
4
+ * F1 残留 GC(自动):进程启动时对当前 cwd hash 前缀做一次轻量清理——.corrupted /
5
+ * .unreadable / .manifest.corrupted / .bak-* 保留 30 天,孤儿 .tmp 保留 7 天
6
+ *(mtime < now − retention 才删,等于保留期保留——older-than 边界语义)。
7
+ * 安全(N1):活跃槽(主文件在 + manifest slotSessions[N] 属主活)的现场一律保留;
8
+ * manifest 主文件 / end marker / 数据主文件永不进入候选(后缀预过滤只匹配残留后缀)。
9
+ *
10
+ * F2 冷 cwd(手动——12.2.4 三步,v1 不自动删 manifest):
11
+ * 判定:manifest mtime 距今 > 90 天 且 无任何活跃数据文件(.json.N 不存在或全部属死主);
12
+ * 报告:thincoder session gc --dry-run(跨 cwd 枚举,不删除);
13
+ * 删除:thincoder session gc --confirm <hash|--all>(整前缀清空——manifest+marker+
14
+ * .json.N 死主数据+裸 v1 {hash}.json(§12.7 D-V1)+残留;删除前重校验冷态——
15
+ * TOCTOU 防护 T12)。
16
+ *
17
+ * 拆分理由(§12.3 review #1):session-slots.mjs 已在 500 行硬限零余量,GC 是独立决策
18
+ * ("何时清何种残留"),独立成模块;原语(sessionPath/isProcessAlive)自 session-slots
19
+ * import,启动钩子(session.mjs resumeSlot 包装)只一行调用。
20
+ */
21
+
22
+ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"
23
+ import { join, dirname, basename } from "node:path"
24
+ import { configDir } from "./config.mjs"
25
+ import { sessionPath, isProcessAlive } from "./session-slots.mjs"
26
+
27
+ /** 保留期(§12.2.2):损坏现场(.corrupted/.unreadable/.manifest.corrupted)与并发轮转
28
+ * 备份(.bak-*)30 天;孤儿 .tmp 7 天(崩溃现场恢复窗口)。 */
29
+ export const RESIDUE_RETENTION_MS = 30 * 24 * 3600 * 1000
30
+ export const ORPHAN_TMP_RETENTION_MS = 7 * 24 * 3600 * 1000
31
+ /** 冷 cwd 阈值(§12.2.4):manifest mtime 距今 > 90 天(保守——正常开发会频繁触碰)。 */
32
+ export const COLD_CWD_RETENTION_MS = 90 * 24 * 3600 * 1000
33
+
34
+ function sessionsDir() { return join(configDir, "sessions") }
35
+
36
+ /** 残留分类(§12.2.2 后缀表):name 须以 `${prefix}.` 开头(prefix = `${hash}.json`)。
37
+ * 返回 { retention, slot, tmp } 或 null(主文件/end marker/他前缀——不动)。
38
+ * slot = 关联槽号(`.N.` 段),无则 null(如 .manifest.corrupted)。 */
39
+ function classifyResidue(name, prefix) {
40
+ if (!name.startsWith(prefix + ".")) return null
41
+ const slotMatch = name.slice(prefix.length).match(/^\.(\d+)\./)
42
+ const slot = slotMatch ? Number(slotMatch[1]) : null
43
+ if (name.endsWith(".corrupted") || name.endsWith(".unreadable")) return { retention: RESIDUE_RETENTION_MS, slot, tmp: false }
44
+ if (/\.bak-\d+$/.test(name)) return { retention: RESIDUE_RETENTION_MS, slot, tmp: false }
45
+ if (name.endsWith(".tmp")) return { retention: ORPHAN_TMP_RETENTION_MS, slot, tmp: true }
46
+ return null
47
+ }
48
+
49
+ /** 活跃槽集合(§12.4 T3/T12 操作定义):.json.N 主文件存在 且 manifest slotSessions[N]
50
+ * 属主进程存活。manifest 缺失/损坏 → 空集(无活跃槽——不阻碍残留清理)。 */
51
+ function liveSlots(dir, prefix, aliveFn) {
52
+ const active = new Set()
53
+ try {
54
+ const m = JSON.parse(readFileSync(join(dir, `${prefix}.manifest`), "utf8"))
55
+ for (const [n, owner] of Object.entries(m.slotSessions ?? {})) {
56
+ if (!/^\d+$/.test(n) || !owner) continue
57
+ if (!existsSync(join(dir, `${prefix}.${n}`))) continue
58
+ const pid = parseInt(String(owner).split("-")[0])
59
+ if (pid && aliveFn(pid)) active.add(Number(n))
60
+ }
61
+ } catch { /* manifest 不可读 → 无活跃槽 */ }
62
+ return active
63
+ }
64
+
65
+ /**
66
+ * 残留 GC(§12.2.3——单 cwd 前缀,不跨 cwd 扫描):返回 { candidates, deleted }
67
+ * (dryRun 时 candidates 照列、deleted 为空——只列不删,N2 可预览)。
68
+ * 扫描先按后缀预过滤(N4——只 stat 残留候选),再对候选做活跃槽/孤儿/保留期判定。
69
+ */
70
+ export function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(), dryRun = false, aliveFn = isProcessAlive } = {}) {
71
+ const result = { candidates: [], deleted: [] }
72
+ let entries
73
+ try { entries = readdirSync(dir) } catch { return result }
74
+ const candidates = []
75
+ for (const name of entries) {
76
+ const c = classifyResidue(name, prefix)
77
+ if (c) candidates.push({ name, ...c })
78
+ }
79
+ if (!candidates.length) return result
80
+ const active = liveSlots(dir, prefix, aliveFn)
81
+ for (const c of candidates) {
82
+ if (c.slot !== null && active.has(c.slot)) continue // N1:活跃槽现场一律保留(T3)
83
+ const p = join(dir, c.name)
84
+ if (c.tmp && existsSync(p.slice(0, -".tmp".length))) continue // 非孤儿 .tmp(主文件在——写中/回退候选)
85
+ let st
86
+ try { st = statSync(p) } catch { continue }
87
+ if (st.mtimeMs >= now - c.retention) continue // 边界:older-than 才删,等于保留期保留(§12.4)
88
+ result.candidates.push(c.name)
89
+ if (!dryRun) {
90
+ try { unlinkSync(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
91
+ }
92
+ }
93
+ return result
94
+ }
95
+
96
+ const scheduledPrefixes = new Set()
97
+
98
+ /** 启动钩子(§12 review #8——N4):GC 延后到进程启动完成后空闲执行(setImmediate),
99
+ * 不阻塞启动路径;每进程每前缀一次(Set 去重——resumeSlot 可多次进入)。 */
100
+ export function scheduleSessionGC(cwd) {
101
+ let base
102
+ try { base = sessionPath(cwd) } catch { return }
103
+ if (scheduledPrefixes.has(base)) return
104
+ scheduledPrefixes.add(base)
105
+ setImmediate(() => {
106
+ try { gcResidue({ dir: dirname(base), prefix: basename(base) }) } catch { /* 清理失败静默——不影响主流程 */ }
107
+ })
108
+ }
109
+
110
+ /**
111
+ * 冷 cwd 枚举(§12.2.4 步骤 1/2——跨 cwd 报告面,仅手动命令调用):候选 =
112
+ * manifest mtime 距今 > 90 天 且 无活跃数据文件(.json.N 主文件不存在或全部属死主进程)。
113
+ * 返回 [{ hash, prefix, manifestMtime, dataFiles, files }](files = 整前缀全部文件——
114
+ * 含裸 v1 `{hash}.json`,§12.7 D-V1——dataFiles 仅 .json.N,v1 不参与冷态判定)。
115
+ */
116
+ export function listColdCwds({ dir = sessionsDir(), now = Date.now(), aliveFn = isProcessAlive } = {}) {
117
+ let entries
118
+ try { entries = readdirSync(dir) } catch { return [] }
119
+ const cold = []
120
+ for (const name of entries) {
121
+ if (!name.endsWith(".json.manifest")) continue // end marker(.manifest.cli/.vscode)与 .manifest.corrupted 天然排除
122
+ const prefix = name.slice(0, -".manifest".length) // `${hash}.json`
123
+ let st
124
+ try { st = statSync(join(dir, name)) } catch { continue }
125
+ if (st.mtimeMs >= now - COLD_CWD_RETENTION_MS) continue // 近期活跃(<90 天阈值——T10)
126
+ let m
127
+ try { m = JSON.parse(readFileSync(join(dir, name), "utf8")) } catch { continue } // 损坏 manifest 不判冷(.corrupted 归残留 GC)
128
+ const dataFiles = entries.filter((e) => e.startsWith(prefix + ".") && /^\d+$/.test(e.slice(prefix.length + 1)))
129
+ const hasLive = dataFiles.some((e) => {
130
+ const owner = m.slotSessions?.[e.slice(prefix.length + 1)]
131
+ const pid = parseInt(String(owner ?? "").split("-")[0])
132
+ return !!(pid && aliveFn(pid))
133
+ })
134
+ if (hasLive) continue // 有活跃数据文件 → 非冷(T12)
135
+ cold.push({
136
+ hash: prefix.slice(0, -".json".length),
137
+ prefix,
138
+ manifestMtime: st.mtimeMs,
139
+ dataFiles: dataFiles.length,
140
+ files: entries.filter((e) => e.startsWith(prefix + ".") || e === prefix), // 整前缀清空含裸 v1 {hash}.json(§12.7 D-V1)
141
+ })
142
+ }
143
+ return cold
144
+ }
145
+
146
+ /**
147
+ * 删除指定冷 cwd 的整个前缀(§12.2.4 步骤 3——manifest+end marker+.json.N 死主数据+
148
+ * 裸 v1 {hash}.json(§12.7 D-V1)+残留;只删 manifest 留数据文件会制造孤儿数据,整前缀清空才真正释放)。
149
+ * 删除前重跑冷 cwd 判定(TOCTOU 防护,T12——期间变活跃则拒绝)。
150
+ */
151
+ export function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), aliveFn = isProcessAlive } = {}) {
152
+ const target = listColdCwds({ dir, now, aliveFn }).find((c) => c.hash === hash)
153
+ if (!target) return { ok: false, reason: "not-cold", deleted: [] }
154
+ const deleted = []
155
+ for (const name of target.files) {
156
+ try { unlinkSync(join(dir, name)); deleted.push(name) } catch { /* 占用/竞态——跳过 */ }
157
+ }
158
+ return { ok: true, deleted }
159
+ }
160
+
161
+ /**
162
+ * `thincoder session gc` 子命令分发(§12.2.3/12.2.4 手动面——F2 执行入口仅 CLI,
163
+ * VS Code 端无 shell 子命令通道,review #7):
164
+ * --dry-run 报告当前 cwd 残留候选 + 跨 cwd 冷候选(只列不删,N2 预览)
165
+ * --confirm <hash> 删除指定冷 cwd 整前缀(警告 + 文件清单 + TOCTOU 重校验)
166
+ * --confirm --all 逐冷 cwd 同型警告删除
167
+ * 返回进程退出码(0/1)。dir/prefix/now/out/err/aliveFn 为测试注入缝(默认生产行为)。
168
+ */
169
+ export function runSessionGc(args, { dir = sessionsDir(), prefix = null, cwd = process.cwd(), now = Date.now(), out = console.log, err = console.error, aliveFn = isProcessAlive } = {}) {
170
+ const dryRun = args.includes("--dry-run")
171
+ const confirmIdx = args.indexOf("--confirm")
172
+ const hasConfirm = confirmIdx >= 0
173
+ const confirmTarget = hasConfirm ? args[confirmIdx + 1] : null
174
+ // --dry-run 与 --confirm 互斥(review 🔵#4——同给时不得静默忽略 confirm;无值 --confirm 同为用法错误)
175
+ if (args[0] !== "gc" || (!dryRun && !hasConfirm) || (dryRun && hasConfirm) || (hasConfirm && !confirmTarget)) {
176
+ err("Usage: thincoder session gc --dry-run | --confirm <hash> | --confirm --all")
177
+ return 1
178
+ }
179
+
180
+ if (dryRun) {
181
+ out("Session GC dry-run — no files will be deleted.")
182
+ // 当前 cwd 残留(§12.2.3 删除面——自动 GC 的预览)
183
+ let p = prefix
184
+ if (!p) { try { p = basename(sessionPath(cwd)) } catch { p = null } }
185
+ const residue = p ? gcResidue({ dir, prefix: p, now, dryRun: true, aliveFn }) : { candidates: [] }
186
+ out(`Residue candidates for current project (${p ?? "unknown"}): ${residue.candidates.length}`)
187
+ for (const name of residue.candidates) out(` ${name}`)
188
+ }
189
+
190
+ const cold = listColdCwds({ dir, now, aliveFn })
191
+ if (dryRun) {
192
+ out(`Cold project candidates (manifest idle > 90 days, no live data files): ${cold.length}`)
193
+ for (const c of cold) {
194
+ out(` ${c.hash} manifest mtime ${new Date(c.manifestMtime).toISOString()} data files ${c.dataFiles} total files ${c.files.length}`)
195
+ }
196
+ if (cold.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to permanently delete a cold project prefix.')
197
+ return 0
198
+ }
199
+
200
+ // --confirm:删除前警告(N2 可逆——文件清单 + 永久删除提示);deleteColdCwd 内部重校验冷态(TOCTOU)
201
+ const targets = confirmTarget === "--all" ? cold : cold.filter((c) => c.hash === confirmTarget)
202
+ if (!targets.length) {
203
+ err(`Refused: ${confirmTarget} is not a cold project (active, recent, or unknown) — nothing deleted.`)
204
+ return 1
205
+ }
206
+ for (const t of targets) {
207
+ out(`WARNING: 此操作永久删除该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files):`)
208
+ for (const name of t.files) out(` ${name}`)
209
+ const r = deleteColdCwd(t.hash, { dir, now, aliveFn })
210
+ if (!r.ok) { err(`Refused: ${t.hash} is no longer cold (became active) — skipped.`) ; continue }
211
+ out(`Deleted ${r.deleted.length} files for ${t.hash}.`)
212
+ }
213
+ return 0
214
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * session-guard.mjs — F2 轮转守卫(2026-08-31 会诊 F2 🔴;2026-09-08 DESIGN-TOKEN-
3
+ * SETTLEMENT D1 自 session-slots.mjs 提取——session-slots 再越 500 行硬限,本守卫约 41
4
+ * 行随迁,verbatim 零语义变):saveSession 与 token-ttl persistEngTokens 共用同一份。
5
+ * 写前校验磁盘槽文件是否本进程会话的现场——磁盘文件 sessionStart 与本进程会话不符
6
+ * (另一进程/会话的现场)/版本更新/异 cwd → 先轮转 .bak 保留再写。检查按 mtime 缓存
7
+ * (agent._slotMtime):文件自上次检查/自写未变就跳过全量解析——每次保存都
8
+ * readFileSync+JSON.parse 多 MB 会话 → O(n²) 退化。文件存在但不可读(损坏/半写)→
9
+ * 改名 .corrupted 保留现场。返回轮转的 .bak 路径或 null。
10
+ */
11
+
12
+ import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
13
+ import { basename } from "node:path"
14
+
15
+ export function guardForeignSlotFile(agent, p, slot) {
16
+ try {
17
+ if (!existsSync(p)) return null
18
+ const st = statSync(p)
19
+ if (st.mtimeMs === (agent._slotMtime ?? -1)) return null
20
+ const disk = JSON.parse(readFileSync(p, "utf8"))
21
+ // version>2 的新版文件无论 sessionStart 一律轮转(loadSlotFile 对 v3 返回 null 不动
22
+ // 文件——若其 sessionStart 为 null,旧版首次保存会静默覆盖;轮转 .bak 保证新版文件
23
+ // 保留)。磁盘文件 cwd 不匹配(异项目文件误落本路径)同样轮转——与 loadSlotFile/
24
+ // legacy 读的"别人的文件不动"原则对齐。
25
+ const diskIsNewer = typeof disk?.version === "number" && disk.version > 2
26
+ const diskStart = disk?.sessionStart ?? null
27
+ const myStart = agent._sessionStart ?? null
28
+ const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
29
+ if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
30
+ const bak = `${p}.bak-${Date.now()}`
31
+ renameSync(p, bak)
32
+ 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)}`)
33
+ agent._slotMtime = st.mtimeMs
34
+ return bak
35
+ }
36
+ agent._slotMtime = st.mtimeMs
37
+ return null
38
+ } catch {
39
+ // 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
40
+ // 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
41
+ // 给 F2 轮转路径,损坏现场不再混入轮转后缀)。
42
+ if (existsSync(p)) {
43
+ try { renameSync(p, `${p}.corrupted`) } catch {}
44
+ }
45
+ return null
46
+ }
47
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * session-rename.mjs — 槽位重命名(标题写路径)。2026-09-06 自 session-slots.mjs 拆出:
3
+ * §12.2.5 契约改(boolean → { ok, reason })使 session-slots.mjs 超 500 行硬限
4
+ *(§12.3 授权——"若 >500 则按 §10.6 先例拆文件,一并拆出 renameSlot")。
5
+ * 原语(slotPath/manifest 读写)自 session-slots.mjs import;session.mjs re-export
6
+ * renameSlot 保持既有调用点(cmd-session.mjs / 测试经 session.mjs)不变。
7
+ */
8
+
9
+ import { readFileSync, existsSync, statSync } from "node:fs"
10
+ import { slotPath, loadManifest, saveManifest, slotDigest, writeSessionFile } from "./session-slots.mjs"
11
+
12
+ /** Rename a slot: update the slot file's title + the manifest metadata (shared with VS Code).
13
+ * 2026-09-01 会诊 glm 🟡:写回前按 mtime 门控重读——原实现读全量→改 title→整文件写回,
14
+ * 窗口内并发方的最新保存会被旧数据覆盖(丢消息);mtime 变了即放弃本次重命名。
15
+ * 2026-09-06 §12.2.5:契约 boolean → { ok, reason? }(file-missing/parse-failure/mtime-conflict/invalid-slot,F3 失败可见);_stat = mtime-conflict 测试缝(默认 statSync)。 */
16
+ export function renameSlot(cwd, slot, title, _stat = statSync) {
17
+ const n = Number(slot)
18
+ if (!Number.isInteger(n) || n < 1) return { ok: false, reason: "invalid-slot" }
19
+ const p = slotPath(cwd, n)
20
+ if (!existsSync(p)) return { ok: false, reason: "file-missing" }
21
+ let data
22
+ try {
23
+ data = JSON.parse(readFileSync(p, "utf8"))
24
+ } catch {
25
+ return { ok: false, reason: "parse-failure" }
26
+ }
27
+ const t0 = _stat(p).mtimeMs
28
+ data.title = title
29
+ // 读与写之间文件被并发方改过 → 放弃(保留并发内容,重命名下次重试)
30
+ if (_stat(p).mtimeMs !== t0) return { ok: false, reason: "mtime-conflict" }
31
+ writeSessionFile(p, data)
32
+ const m = loadManifest(cwd)
33
+ if (m.slots[n]) {
34
+ m.slots[n] = slotDigest(data)
35
+ saveManifest(cwd, m)
36
+ }
37
+ return { ok: true }
38
+ }
@@ -9,11 +9,15 @@
9
9
  */
10
10
 
11
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"
12
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs"
13
+ import { join, dirname, basename } from "node:path"
14
14
  import { execSync } from "node:child_process"
15
15
  import { configDir } from "./config.mjs"
16
16
  import { migrateHashLength } from "./session-migrate.mjs"
17
+ // §10 D-2(2026-09-05):resumeSlot 决策归本文件(slot/claim 层);data 层读(loadSlotFile/
18
+ // legacy 过滤)属 session.mjs(500 行内不迁移)——静态环仅此一处:函数声明实例化期已初始化、
19
+ // 只函数体内运行时使用(环安全);VS Code 端 session-slots ↔ session-io 同构镜像。
20
+ import { loadSlotFile, isLegacyTransient } from "./session.mjs"
17
21
 
18
22
  let currentSessionId = null
19
23
 
@@ -36,16 +40,57 @@ function cwdHash(cwd) {
36
40
  return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
37
41
  }
38
42
 
43
+ /** sessions 根目录隔离缝(2026-09-08 DESIGN-TOKEN-SETTLEMENT 测试——VSC 端
44
+ * _setSessionsDirForTest 同构):默认 null 走真实 configDir;测试指向临时目录
45
+ * (beforeEach 设、afterEach 复位)。migrateHashLength 的 legacy 迁移仍查真实
46
+ * 目录(temp 哈希无 legacy 文件——no-op 无害)。 */
47
+ let sessionsDirOverride = null
48
+ export function _setSessionsDirForTest(dir) { sessionsDirOverride = dir }
49
+ export function _resetSessionsDirForTest() { sessionsDirOverride = null }
50
+ function sessionsBase() { return sessionsDirOverride ?? join(configDir, "sessions") }
51
+
39
52
  /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
40
53
  export function sessionPath(cwd) {
41
54
  const hash = cwdHash(cwd)
42
55
  migrateHashLength(cwd, hash)
43
- return join(configDir, "sessions", `${hash}.json`)
56
+ return join(sessionsBase(), `${hash}.json`)
44
57
  }
45
58
 
46
59
  export function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
47
60
  export function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
48
61
 
62
+ // ========== end marker(SESSION.md §10 端分离恢复——本端"最后使用槽位"记录)==========
63
+
64
+ /** 端常量:本端记录文件后缀——CLI 写 .cli、VS Code 写 .vscode,互不触碰
65
+ * (NF1:本端记录是本端单写者文件——不新增跨端共享可变字段)。 */
66
+ export const END = "cli"
67
+
68
+ /** 本端记录路径:{manifest}.{END}(manifest 旁的独立小文件,非内嵌字段——NF1)。 */
69
+ export function endMarkerPath(cwd) { return `${manifestPath(cwd)}.${END}` }
70
+
71
+ /** 读本端记录:返回 { slot: <number|null>, updatedAt } 或 null。三态语义(D-1):
72
+ * 文件缺失 = 从未记录(升级/首用迁移窗口——可能触发一次性继承);
73
+ * JSON 解析失败/结构非法(损坏)= 按"缺失"降级——不 rename 不 unlink(幂等、不误伤,
74
+ * T-M13);`slot: null` = 显式置空(删过本端记录槽——绝不触发继承,T-M4)。 */
75
+ export function readEndMarker(cwd) {
76
+ try {
77
+ const p = endMarkerPath(cwd)
78
+ if (!existsSync(p)) return null
79
+ const m = JSON.parse(readFileSync(p, "utf8"))
80
+ if (m && m.slot === null) return { slot: null, updatedAt: m.updatedAt ?? null }
81
+ if (m && Number.isInteger(m.slot) && m.slot >= 1) return { slot: m.slot, updatedAt: m.updatedAt ?? null }
82
+ return null
83
+ } catch { return null }
84
+ }
85
+
86
+ /** 写本端记录(原子 .tmp+rename,复用 writeSessionFile):slot = 目标槽号或 null(显式置空)。
87
+ * 失败容忍(NF2):写失败按无记录路径降级——不抛错,不影响会话数据(T-M14)。 */
88
+ export function writeEndMarker(cwd, slot) {
89
+ try {
90
+ writeSessionFile(endMarkerPath(cwd), { slot, updatedAt: Date.now() })
91
+ } catch { /* NF2:失败容忍 */ }
92
+ }
93
+
49
94
  /** Path to the active slot's file */
50
95
  export function activePath(cwd) {
51
96
  return slotPath(cwd, activeSlot(cwd))
@@ -143,27 +188,14 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
143
188
  writeSessionFile(manifestPath(cwd), m)
144
189
  }
145
190
 
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) {
191
+ /** 死主条目清理(2026-08-31 会诊 F4 + 2026-09-01 会诊 deepseek/kimi 🔴 语义原样抽取——
192
+ * ensureActive/resumeSlot 共用):删除 owner 进程已死的 slotSessions 条目。死主判定必须跑
193
+ * isProcessAlive——不能以"文件缺失"短路(活进程在"认领 首次保存"窗口文件暂缺,误删会
194
+ * 致双进程同槽)。返回 deletions 计算函数(调用时按 m 当前状态过滤刚重新认领的槽——防删
195
+ * 掉自己的新属主),无清理返回 null。删除须经 saveManifest deletions 显式落盘——条目级
196
+ * 合并会把磁盘死条目从 fresh 复活回写(N1 + 会诊 🔴)。 */
197
+ function cleanDeadOwners(m) {
156
198
  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
199
  const deadSlots = []
168
200
  for (const [slot, owner] of Object.entries(m.slotSessions)) {
169
201
  if (owner && owner !== mySessionId) {
@@ -171,24 +203,33 @@ function ensureActive(cwd, m) {
171
203
  if (!pid || !isProcessAlive(pid)) {
172
204
  delete m.slotSessions[slot]
173
205
  deadSlots.push(slot)
174
- cleaned = true
175
206
  }
176
207
  }
177
208
  }
209
+ return deadSlots.length === 0 ? null : () => ({ slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId) })
210
+ }
178
211
 
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)
212
+ /**
213
+ * Claim a slot for this process and set it as active. Idempotent.
214
+ * Preference order:
215
+ * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
216
+ * 2. Any slot that is unowned or owned by a dead process (reclaim). → allocateFresh
217
+ * 3. A brand-new slot when all are owned by live processes. → allocateFresh
218
+ * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
219
+ * see which slots are taken and avoid them.
220
+ */
221
+ function ensureActive(cwd, m) {
222
+ const mySessionId = getSessionId()
223
+ if (!m.slotSessions) m.slotSessions = {}
224
+ // 顺手清理死主条目(见 cleanDeadOwners)。ensureActive 因 F1 粘性每次进程只跑几次,
225
+ // 全量 tasklist 成本可接受;清理结果必须落盘——早退 + 分支 1/2/3 全部传 deletions。
226
+ const deadParam = cleanDeadOwners(m)
183
227
 
184
228
  // Already own the active slot — nothing to do.
185
229
  // 2026-08-31 advisor round2 🔵:清理结果此时落盘(否则死项清理只在内存生效,早退
186
230
  // 路径永不持久化——死条目一直滞留到其他路径保存才消失)。
187
- // advisor round3 N1 + 2026-09-01 会诊 deepseek/kimi 🔴:必须传 deletions——saveManifest
188
- // 的条目级合并({...fresh, ...m})会把磁盘上仍存在的死条目从 fresh 复活回写,仅传 m
189
- // 等于没删。N1 当时只修了早退路径,分支 1/2/3 漏了(认领主路径上死项清理是死代码)。
190
231
  if (m.active && m.slotSessions[m.active] === mySessionId) {
191
- if (cleaned) saveManifest(cwd, m, deadParam())
232
+ if (deadParam) saveManifest(cwd, m, deadParam())
192
233
  return
193
234
  }
194
235
 
@@ -201,20 +242,40 @@ function ensureActive(cwd, m) {
201
242
  // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
202
243
  if (m.active && m.slots[m.active] && isFree(m.active)) {
203
244
  m.slotSessions[m.active] = mySessionId
204
- saveManifest(cwd, m, deadParam(), { setActive: true })
245
+ saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
205
246
  return
206
247
  }
207
248
 
249
+ // 2/3. 分支 2/3 已抽取为 allocateFresh(2026-09-05 §10 D-2——resumeSlot 全新分配复用)。
250
+ allocateFresh(cwd, m, deadParam)
251
+ }
252
+
253
+ /**
254
+ * Allocate a slot with ensureActive 分支 2/3 语义(2026-09-05 §10 D-2 抽取——resumeSlot
255
+ * 步骤③"其余一切 → 全新分配"复用;与 newSession 选号语义对齐):
256
+ * 2. Reclaim a manifest slot whose FILE does not exist (never held a session);
257
+ * 3. A brand-new slot when none is free / all numbers are taken.
258
+ * 记录所有权 + active 指针(setActive)并落盘。返回分配的槽号。
259
+ */
260
+ export function allocateFresh(cwd, m, deadParam = null) {
261
+ const mySessionId = getSessionId()
262
+ if (!m.slotSessions) m.slotSessions = {}
263
+ const isFree = (slot) => {
264
+ const owner = m.slotSessions[slot]
265
+ if (!owner || owner === mySessionId) return true
266
+ return !isProcessAlive(parseInt(owner.split("-")[0]))
267
+ }
268
+
208
269
  // 2. Reclaim a slot whose FILE does not exist (never held a session). 2026-08-31 会诊 F4:
209
270
  // 原实现认领"编号最小的空闲 slot"——死主的旧 slot 文件仍在,新进程会 resume 进
210
271
  // 陌生会话("会话乱了"实锤)且退出时覆盖它。只有文件缺失的空 slot 才允许回收。
211
- const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
272
+ const allSlots = Object.keys(m.slots).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
212
273
  for (const slot of allSlots) {
213
274
  if (isFree(slot) && !existsSync(slotPath(cwd, slot))) {
214
275
  m.active = slot
215
276
  m.slotSessions[slot] = mySessionId
216
- saveManifest(cwd, m, deadParam(), { setActive: true })
217
- return
277
+ saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
278
+ return slot
218
279
  }
219
280
  }
220
281
 
@@ -234,7 +295,20 @@ function ensureActive(cwd, m) {
234
295
  while (liveClaimed(newSlot) || existsSync(slotPath(cwd, newSlot))) newSlot++
235
296
  m.active = newSlot
236
297
  m.slotSessions[newSlot] = mySessionId
237
- saveManifest(cwd, m, deadParam(), { setActive: true })
298
+ saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
299
+ return newSlot
300
+ }
301
+
302
+ /** 认领指定槽为本进程所有并置为 active(2026-09-05 §10 D-2 resumeSlot 认领路径——调用方
303
+ * 已按"属主 空/死/本进程"判据校验可用性;幂等)。manifest active 保留为共享指针(D-6:
304
+ * 旧版端/ACP 恢复依据 + 无记录端一次性继承源 + 列表回退高亮——不再作本端恢复第一依据)。
305
+ * deadParam 在写入所有权之后求值(防 deletions 删掉本调用刚认领的槽——ensureActive
306
+ * deadParam 同型过滤)。 */
307
+ export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
308
+ m.slotSessions ??= {}
309
+ m.slotSessions[slot] = getSessionId()
310
+ m.active = slot // 认领即翻共享指针(setActive 写 m.active——不更新则落快照旧值)
311
+ saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
238
312
  }
239
313
 
240
314
  /**
@@ -330,32 +404,81 @@ export function deleteSlot(cwd, slot) {
330
404
  // setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
331
405
  // 保留 fresh.active,2026-09-01 会诊三家 🟡)
332
406
  saveManifest(cwd, m, { slots: [n], slotSessions: [n] }, { setActive: true })
407
+ // 2026-09-05 §10 D-4/F4:删到本端记录槽 → 记录显式置空(文件保留 + slot:null——下次全新起步,不复活——T-M4/T-M8)
408
+ if (readEndMarker(cwd)?.slot === n) writeEndMarker(cwd, null)
333
409
  return true
334
410
  }
335
411
 
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
412
+ // renameSlot 已拆至 session-rename.mjs(2026-09-06 §12.2.5 契约改使本文件超 500 行硬限,
413
+ // §12.3 授权拆分);session.mjs re-export 保持调用点不变。
414
+
415
+ // ========== resumeSlot(SESSION.md §10 D-2——端分离恢复决策)==========
416
+
417
+ /** 本端记录槽可用判据(D-2):slot ∈ m.slots + 槽文件在盘 + 属主 空/死/本进程。 */
418
+ function usableSlot(cwd, m, slot) {
419
+ if (!m.slots[slot] || !existsSync(slotPath(cwd, slot))) return false
420
+ const owner = m.slotSessions?.[slot]
421
+ if (!owner || owner === getSessionId()) return true
422
+ const pid = parseInt(owner.split("-")[0])
423
+ return !pid || !isProcessAlive(pid)
424
+ }
425
+
426
+ /** legacy 单文件兜底(v1/v2 单会话 {hash}.json——迁移前残留;仅 data 层——2026-09-05 §10
427
+ * D-2:恢复数据兜底不改变 claim 落点,T-M10)。纪律与 loadSlotFile 一致:cwd 不匹配(别人的文件)
428
+ * 直接 null 不改名;坏结构改名 .unreadable 保留(version>2 不动);解析失败 .corrupted 保留。 */
429
+ function loadLegacyFile(cwd) {
430
+ const legacy = sessionPath(cwd)
345
431
  try {
346
- data = JSON.parse(readFileSync(p, "utf8"))
347
- } catch {
348
- return false
432
+ if (existsSync(legacy)) {
433
+ const data = JSON.parse(readFileSync(legacy, "utf8"))
434
+ // cwd 不匹配是别人的文件——与 loadSlotFile 一致直接 return null 不改名
435
+ // ("别人的文件不动"原则,2026-08-31 advisor round2 🟡)
436
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
437
+ if ((data?.version === 1 || data?.version === 2) && Array.isArray(data.history)) {
438
+ data.history = data.history.filter((m) => !isLegacyTransient(m))
439
+ return data
440
+ }
441
+ // 结构不匹配:保留现场(version>2 的新版文件不动)
442
+ if (!(typeof data?.version === "number" && data.version > 2)) {
443
+ try { renameSync(legacy, `${legacy}.unreadable`) } catch {}
444
+ console.error(`[session] legacy file ${legacy}: invalid structure — preserved as ${basename(legacy)}.unreadable`)
445
+ }
446
+ }
447
+ } catch (e) {
448
+ console.error(`[session] failed to load legacy ${legacy}: ${e.message}`)
449
+ try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
349
450
  }
350
- const t0 = statSync(p).mtimeMs
351
- data.title = title
352
- // 读与写之间文件被并发方改过 → 放弃(保留并发内容,重命名下次重试)
353
- if (statSync(p).mtimeMs !== t0) return false
354
- writeSessionFile(p, data)
451
+ return null
452
+ }
453
+
454
+ /**
455
+ * 恢复决策(SESSION.md §10 D-2)——TUI 启动的本端恢复入口(VS Code 面板同构镜像)。
456
+ * 返回 { slot, data }(data 可为 null——全新起步或读槽失败)。判据:
457
+ * ① 本端记录可用(slot ≠ null 且 ∈ m.slots 且槽文件在盘 且属主 空/死/本进程)→ claimSlot;
458
+ * ② 记录缺失(从未记录 = 升级/首用迁移窗口):②a active 属主 = 本进程(同进程重入——
459
+ * ensureActive 早退语义镜像:认领后尚未写 slots 条目/文件的窗口)→ 直接沿用;
460
+ * ②b 否则一次性继承 manifest.active(同判据;活属主绝不继承——全新槽起步,T-M3);
461
+ * ③ 其余一切(slot:null 显式置空 / 槽被删 / 属主为活外人 / 继承失败)→ allocateFresh。
462
+ * 每次落点都写本端记录;claim 后读槽失败(.corrupted/.unreadable——loadSlotFile 既有改名
463
+ * 保全语义)→ 保持已 claim 槽 + data:null——不改 marker——下次保存原地重建(T-M15)。
464
+ */
465
+ export function resumeSlot(cwd) {
355
466
  const m = loadManifest(cwd)
356
- if (m.slots[n]) {
357
- m.slots[n] = slotDigest(data)
358
- saveManifest(cwd, m)
467
+ m.slotSessions ??= {}
468
+ // 与 ensureActive 同型的死主清理(认领路径持久化——F5a 纪律:仅传 m 等于没删)
469
+ const deadParam = cleanDeadOwners(m)
470
+ const rec = readEndMarker(cwd) // null = 缺失/损坏;{slot:null|N} = 文件在(D-1)
471
+ let slot = null
472
+ if (rec?.slot != null && usableSlot(cwd, m, rec.slot)) slot = rec.slot // ① 本端记录可用
473
+ else if (rec === null) {
474
+ if (m.active && m.slotSessions?.[m.active] === getSessionId()) slot = m.active // ②a 同进程重入
475
+ else if (m.active && usableSlot(cwd, m, m.active)) slot = m.active // ②b 一次性继承
359
476
  }
360
- return true
477
+ if (slot !== null) claimSlot(cwd, slot, m, deadParam)
478
+ else slot = allocateFresh(cwd, m, deadParam) // ③ 全新分配(slot:null 绝不继承——T-M4)
479
+ // data 层:读已认领槽(loadSlotFile 自 session.mjs——环 import 见文件头);读失败/槽文件不在 → legacy 单文件兜底(仅 data)
480
+ let data = loadSlotFile(cwd, slot)
481
+ if (!data) data = loadLegacyFile(cwd)
482
+ writeEndMarker(cwd, slot)
483
+ return { slot, data }
361
484
  }