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
package/src/session.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * session.mjs — session persistence (slot-based model)
3
3
  * Each project (keyed by cwd hash) keeps unlimited session slots.
4
- * Every session lives in a numbered slot; the manifest tracks which slot is active.
5
- * There is no separate "current" file the active slot IS the current session.
4
+ * Every session lives in a numbered slot; the manifest tracks the SHARED active pointer
5
+ * (2026-09-05 §10 D-6 修订:active = 旧版端/ACP 的恢复依据 + 无记录端的一次性继承源 +
6
+ * 列表回退高亮——本端恢复依据 = end marker `{manifest}.cli`,见 session-slots.mjs
7
+ * resumeSlot/readEndMarker,SESSION.md §1/§10)。
6
8
  *
7
9
  * File layout: {hash}.json.N (slots), {hash}.json.manifest (slot metadata + active pointer).
8
10
  * Legacy {hash}.json is migrated to a slot on first access.
@@ -15,15 +17,30 @@ import { readFileSync, renameSync, existsSync, statSync } from "node:fs"
15
17
  import { basename } from "node:path"
16
18
  import {
17
19
  slotPath, writeSessionFile, loadManifest, saveManifest, slotDigest,
18
- activeSlot, sessionPath, getSessionId, isProcessAlive,
20
+ activeSlot, getSessionId, isProcessAlive,
21
+ writeEndMarker, resumeSlot as slotsResumeSlot,
19
22
  } from "./session-slots.mjs"
23
+ // F2 轮转守卫自 2026-09-08 迁至 session-guard.mjs(session-slots 再越 500 行硬限拆分)
24
+ import { guardForeignSlotFile } from "./session-guard.mjs"
25
+ import { scheduleSessionGC } from "./session-gc.mjs"
26
+ import { engTokenSlotFields, restoreEngTokens } from "./token-ttl.mjs"
20
27
 
21
- // re-export slot 管理(保持既有 import session.mjs 的调用点不变)
28
+ // re-export slot 管理(保持既有 import session.mjs 的调用点不变;resumeSlot 下方本地包装导出)
22
29
  export {
23
30
  getSessionId, normalizeCwd, sessionPath, slotPath, manifestPath, activePath,
24
31
  writeSessionFile, slotDigest, loadManifest, saveManifest, activeSlot, listSlots,
25
- deleteSlot, renameSlot, isProcessAlive,
32
+ deleteSlot, isProcessAlive, END, endMarkerPath,
33
+ readEndMarker, writeEndMarker, claimSlot, allocateFresh,
26
34
  } from "./session-slots.mjs"
35
+ // §12.2.5 契约改使 session-slots.mjs 超 500 行硬限 → renameSlot 拆至 session-rename.mjs(§12.3 授权)
36
+ export { renameSlot } from "./session-rename.mjs"
37
+
38
+ /** 恢复决策包装(SESSION.md §12 启动钩子,2026-09-06):本端恢复入口触发一次残留 GC——
39
+ * scheduleSessionGC 内部 setImmediate 空闲执行 + 每进程每前缀去重,不阻塞启动路径(N4)。 */
40
+ export function resumeSlot(cwd) {
41
+ scheduleSessionGC(cwd)
42
+ return slotsResumeSlot(cwd)
43
+ }
27
44
 
28
45
  // ========== legacy transient prefix cleanup ==========
29
46
 
@@ -115,60 +132,29 @@ export function saveSession(agent) {
115
132
  planMode: agent.planMode ?? false,
116
133
  autoApprove: agent.autoApprove ?? false,
117
134
  engineering: agent.config?.agent?.engineering ?? false,
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,
135
+ ...engTokenSlotFields(agent),
125
136
  goal: agent.goal ?? null,
126
137
  advisor: agent.config?.advisor ?? null,
127
138
  pendingReminders: agent._pendingReminders ?? [],
128
139
  sessionStart: agent._sessionStart ?? null,
129
140
  }
141
+ // 2026-09-05 §10 D-4:首认领(_slot 为 null——如 /session 切换后首保存、"查看对方活槽 →
142
+ // 保存 fork 新槽"的落盘槽跟随)写本端记录——marker = 端内最后认领者;粘性 _slot 期间
143
+ // 的日常保存不写(F1:保存永不参与竞争)
144
+ const claimedNow = agent._slot == null
130
145
  const slot = agent._slot ??= activeSlot(agent.cwd)
146
+ if (claimedNow) writeEndMarker(agent.cwd, slot)
131
147
  const p = slotPath(agent.cwd, slot)
132
148
  // 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
133
149
  // 进程/会话的现场)→ 先轮转 .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
- }
150
+ // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-slots.mjs——DESIGN-TOKEN-
151
+ // SETTLEMENT D1)——saveSession token-ttl persistEngTokens(settle 当场落盘)
152
+ // 共用同一份(检查按 mtime 缓存 _slotMtime:自写未变跳过全量解析)。
153
+ // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-guard.mjs——DESIGN-TOKEN-
154
+ // SETTLEMENT D1 二次拆分:先入 session-slots、再因 500 行硬限迁 session-guard)——
155
+ // saveSession token-ttl persistEngTokens(settle 当场落盘)共用同一份(检查按
156
+ // mtime 缓存 _slotMtime:自写未变跳过全量解析)。
157
+ const rotated = guardForeignSlotFile(agent, p, slot)
172
158
  writeSessionFile(p, data)
173
159
  // 记录我们刚写的 mtime——下次保存跳过重复解析
174
160
  try { agent._slotMtime = statSync(p).mtimeMs } catch {}
@@ -245,36 +231,12 @@ export function loadSlotFile(cwd, slot) {
245
231
  return null
246
232
  }
247
233
 
248
- /** Load session data from the active slot; returns null if missing, corrupted, or version mismatch */
234
+ /** Load session data for this cwd 2026-09-05 §10 D-2:平移为 resumeSlot 的数据包装
235
+ * (签名不变——测试兼容;读槽校验/.tmp 回退/legacy 兜底语义原样保留在 resumeSlot 的
236
+ * data 层)。恢复目标选择按本端记录(end marker)——manifest active 仅作无记录端的
237
+ * 一次性继承源(D-6),不再作本端恢复第一依据。 */
249
238
  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
254
-
255
- // Fallback: try the legacy current file (pre-migration, or migration failed to clean up)
256
- const legacy = sessionPath(cwd)
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}`)
275
- try { renameSync(legacy, `${legacy}.corrupted`) } catch {}
276
- }
277
- return null
239
+ return resumeSlot(cwd).data
278
240
  }
279
241
 
280
242
  /** slimForDisplay 截断的 arguments 以 U+2026(…)结尾——不是合法 JSON 的完整值。
@@ -320,15 +282,8 @@ export function applySession(agent, data) {
320
282
  agent.goal = data.goal ?? null
321
283
  agent._pendingReminders = data.pendingReminders ?? []
322
284
  agent._sessionStart = data.sessionStart ?? null
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
- }
285
+ // R16 token 字段恢复(过期过滤——见 token-ttl.mjs restoreEngTokens)
286
+ restoreEngTokens(agent, data)
332
287
  // engineering is session-level (2026-08-29): the slot value is the CLI session's authority
333
288
  // — config.json is only the initial default / cross-end mirror. A legacy slot without the
334
289
  // field keeps whatever config.json seeded (unchanged behavior).
@@ -429,6 +384,8 @@ export function newSession(cwd) {
429
384
  }
430
385
  : null
431
386
  saveManifest(cwd, m, deletions, { setActive: true })
387
+ // 2026-09-05 §10 D-4:/new 落点写本端记录(显式切换跟随——T-M8)
388
+ writeEndMarker(cwd, slot)
432
389
  return slot
433
390
  }
434
391
 
@@ -442,7 +399,7 @@ export function resetSessionState(agent) {
442
399
  agent.title = ""
443
400
  agent.tasks = []
444
401
  agent._sessionStart = null
445
- agent._engDesignToken = null
402
+ // DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——不再维护
446
403
  agent._engDesignTokens = new Map() // multi-design slots die with the session (2026-09-01 fix #2)
447
404
  agent._compressFailures = 0
448
405
  agent._verifyRetries = 0
@@ -479,6 +436,8 @@ export function switchToSlot(cwd, slot) {
479
436
  m.slotSessions[slot] = getSessionId()
480
437
  }
481
438
  saveManifest(cwd, m, null, { setActive: true })
439
+ // 2026-09-05 §10 D-4:/session N 跟随"最后查看的槽"(成功切换才写)
440
+ writeEndMarker(cwd, slot)
482
441
  return data
483
442
  }
484
443
 
@@ -0,0 +1,273 @@
1
+ /**
2
+ * token-ttl.mjs — design-token TTL 语义 + 槽位清理 + 会话槽权威台账 I/O(R16——2026-09-06;
3
+ * DESIGN-TOKEN-SETTLEMENT D1-D3——2026-09-08)。
4
+ *
5
+ * 共享目标(D-R16b):过期判定语义在此定义一次,validateDesignToken
6
+ * (agent-tools/design-token.mjs)、恢复过滤(session.mjs applySession)、开模式清理
7
+ * (agent-tools/eng.mjs + tui/cmd-eng.mjs ON 路径)、spawn 门禁过期拒删槽
8
+ * (subagent-spawn.mjs)共引。
9
+ *
10
+ * token 格式 = `uuid:expiresAt`(无签名流程凭证——HMAC 防伪层已删——见
11
+ * ENGINEERING-MODE.md 2026-09-06 段)。过期判定只对**格式合法**的 token 判
12
+ * 过期:格式/畸形串不在此清理(恢复时读回由门禁格式拒、门禁拒时也不删槽——
13
+ * 防误删有效槽——F-R16b ①③)。
14
+ *
15
+ * DESIGN-TOKEN-SETTLEMENT(2026-09-08):
16
+ * - **D1**:persistEngTokens = settle 当场落盘的可复用函数(engTokenSlotFields 序列化 +
17
+ * session 安全写/轮转——guardForeignSlotFile 与 saveSession 共用同一份守卫)。
18
+ * - **D2/D3**:readEngTokensFromSlot / reconcileEngTokensFromSlot = 门禁 miss 回读权威
19
+ * 槽(spawn 门 D2 与 dispatch 写门 D3 同源 reconcile)。
20
+ * - **D3**:**单值镜像 `_engDesignToken` 退役**——AC3 零写 + 仅一次性迁移读。本模块
21
+ * 不再写镜像(engTokenSlotFields 只产多槽表);restoreEngTokens 是唯一迁移读点(旧
22
+ * slot 残留 engDesignToken → 迁入 Map,此后不写不读镜像)。
23
+ *
24
+ * 依赖方向:本模块只 import 底层槽 I/O(session-slots.mjs 原语 + session-guard.mjs 轮转
25
+ * 守卫)——session.mjs 同时 import 本模块(engTokenSlotFields/restoreEngTokens),形成
26
+ * 静态环,与既有的 session ↔ session-slots 环同构(函数声明实例化期已初始化、只函数体
27
+ * 内运行时使用——环安全)。
28
+ */
29
+
30
+ import { existsSync, readFileSync, statSync } from "node:fs"
31
+ import {
32
+ slotPath, writeSessionFile, activeSlot, loadManifest, saveManifest, slotDigest,
33
+ writeEndMarker,
34
+ } from "./session-slots.mjs"
35
+ // F2 轮转守卫自 2026-09-08 迁至 session-guard.mjs(session-slots 再越 500 行硬限拆分)
36
+ import { guardForeignSlotFile } from "./session-guard.mjs"
37
+
38
+ const DESIGN_TOKEN_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
39
+
40
+ /** 格式校验 + 数值过期时刻:格式合法 → expiresAt 数值;格式/畸形 → null
41
+ * (与 validateDesignToken 的 fail-closed 判定同源——单一权威)。 */
42
+ export function tokenExpiryMs(token) {
43
+ if (!token || typeof token !== "string") return null
44
+ const parts = token.split(":")
45
+ if (parts.length !== 2) return null
46
+ const [uuid, expiresAt] = parts
47
+ if (!DESIGN_TOKEN_UUID_RE.test(uuid)) return null
48
+ if (!/^\d+$/.test(expiresAt)) return null
49
+ const expiry = parseInt(expiresAt, 10)
50
+ if (isNaN(expiry)) return null
51
+ return expiry
52
+ }
53
+
54
+ /** 过期判定——仅格式合法的 token 可判过期(畸形 → false,由门禁格式拒)。 */
55
+ export function tokenExpired(token, now = Date.now()) {
56
+ const expiry = tokenExpiryMs(token)
57
+ return expiry !== null && now > expiry
58
+ }
59
+
60
+ /** 删一个 designId 槽(R16 D-R16c ③——spawn 门禁过期拒的清理面)。
61
+ * 条件由调用方保证(槽在位且槽值 === token 且已过期):Map 无该 id / 值不符 →
62
+ * 不删(防误删有效槽);designId 缺省(单槽省略 designId 路径)→ 按 token 扫删。
63
+ * DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像已退役——不再同步清镜像。
64
+ * 返回是否删了槽。 */
65
+ export function removeDesignTokenSlot(agent, designId, token) {
66
+ const map = agent?._engDesignTokens
67
+ let slotRemoved = false
68
+ if (map instanceof Map && token) {
69
+ if (designId) {
70
+ if (map.get(designId) === token) {
71
+ map.delete(designId)
72
+ slotRemoved = true
73
+ }
74
+ } else {
75
+ for (const [id, t] of map) {
76
+ if (t === token) {
77
+ map.delete(id)
78
+ slotRemoved = true
79
+ break
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return slotRemoved
85
+ }
86
+
87
+ /** 遍历删过期槽(R16 F-R16b ②——eng enter / cmd-eng ON 真转换路径):Map 只留
88
+ * 有效 token。DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像已退役——无
89
+ * legacy 镜像分支。返回清理个数。 */
90
+ export function purgeExpiredDesignTokens(agent) {
91
+ const map = agent?._engDesignTokens
92
+ let cleared = 0
93
+ if (map instanceof Map) {
94
+ for (const [id, t] of [...map]) {
95
+ if (typeof t === "string" && tokenExpired(t)) {
96
+ map.delete(id)
97
+ cleared++
98
+ }
99
+ }
100
+ }
101
+ return cleared
102
+ }
103
+
104
+ // ── 会话槽序列化/恢复面(session.mjs saveSession/applySession 的 token 字段——
105
+ // 2026-09-06 R16:slot 持久化 = 跨重启/跨模式恢复的有意载体) ──
106
+
107
+ /** saveSession 数据面:token 的序列化形态。多槽 Map → {designId: token}
108
+ * (JSON-safe);空/缺 Map → undefined → JSON.stringify 丢 key——清过的会话写
109
+ * NO field,不从上次 save 复活槽。
110
+ * DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `engDesignToken` 退役——
111
+ * saveSession 不再写镜像字段(AC3 零写);只产多槽表 engDesignTokens。 */
112
+ export function engTokenSlotFields(agent) {
113
+ return {
114
+ engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
115
+ ? Object.fromEntries(agent._engDesignTokens)
116
+ : undefined,
117
+ }
118
+ }
119
+
120
+ /** applySession 恢复面(F-R16b ①——恢复 TTL 过滤):EXPIRED token 不读回(丢弃——
121
+ * 下次 save 自然清字段,清盘闭环);格式/畸形串读回(门禁拒——不主动删——恢复不
122
+ * 得销毁它无法判定的槽数据)。
123
+ * DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——
124
+ * 本函数是**唯一镜像迁移读点**(AC3):旧 slot 文件可能残留 engDesignToken 单值
125
+ * (D3 前 saveSession 写的 legacy 字段)。当多槽表缺失/为空(Map 空)且残留镜像为
126
+ * **格式合法且未过期** token 时 → 一次性迁入 Map(标 legacy——以 token 自身 uuid
127
+ * 为 designId——确定性、可复现、不依赖曾回显的原 designId)。此后该字段随下次
128
+ * saveSession/persistEngTokens(不再写镜像)自然从槽文件消失,本函数不再读到
129
+ * 残留——一次性语义。镜像若已过期/畸形 → 不迁移(drop——与 Map 过期项同处理,
130
+ * 清盘闭环)。永不写 agent._engDesignToken。 */
131
+ export function restoreEngTokens(agent, data) {
132
+ const map = new Map()
133
+ if (data.engDesignTokens && typeof data.engDesignTokens === "object" && !Array.isArray(data.engDesignTokens)) {
134
+ for (const [id, t] of Object.entries(data.engDesignTokens)) {
135
+ if (!(typeof t === "string" && tokenExpired(t))) map.set(id, t)
136
+ }
137
+ }
138
+ // legacy 单值镜像一次性迁移读(唯一镜像读点——AC3):Map 空且残留为格式合法未过期 token
139
+ if (map.size === 0) {
140
+ const storedMirror = data.engDesignToken
141
+ if (typeof storedMirror === "string" && tokenExpiryMs(storedMirror) !== null && !tokenExpired(storedMirror)) {
142
+ map.set(storedMirror.split(":")[0], storedMirror)
143
+ }
144
+ }
145
+ if (map.size > 0) agent._engDesignTokens = map
146
+ else delete agent._engDesignTokens
147
+ }
148
+
149
+ // ── 会话槽权威台账 I/O(DESIGN-TOKEN-SETTLEMENT D1/D2/D3——槽文件 = 权威结算台账) ──
150
+
151
+ /** 当前会话槽号(读侧——无认领副作用):粘性 _slot 优先;未钉槽时读 manifest active
152
+ * (loadManifest 只读——不在门禁路径触发认领写)。无槽 → null。 */
153
+ function currentSlotNoReadOnly(agent) {
154
+ const slot = agent?._slot
155
+ if (Number.isInteger(slot)) return slot
156
+ const active = loadManifest(agent?.cwd)?.active
157
+ return Number.isInteger(active) ? active : null
158
+ }
159
+
160
+ /** 读当前会话槽文件的权威台账(engDesignTokens 多槽表——不做 TTL 过滤,判定由调用方)。
161
+ * 门禁侧轻读:JSON.parse 只读不改文件(.tmp 回退/改名等 loadSlotFile 副作用不引入)。
162
+ * 单值镜像 engDesignToken 字段已退役——此处不读(AC3——运行时零镜像读;唯一镜像
163
+ * 迁移读在 restoreEngTokens)。返回 {designId: token} 对象或 null(无槽/无文件/不可读)。 */
164
+ export function readEngTokensFromSlot(agent) {
165
+ try {
166
+ const slot = currentSlotNoReadOnly(agent)
167
+ if (slot == null) return null
168
+ const p = slotPath(agent.cwd, slot)
169
+ if (!existsSync(p)) return null
170
+ const data = JSON.parse(readFileSync(p, "utf8"))
171
+ return (data && typeof data === "object" && !Array.isArray(data.engDesignTokens)
172
+ && data.engDesignTokens && typeof data.engDesignTokens === "object")
173
+ ? data.engDesignTokens
174
+ : null
175
+ } catch { return null }
176
+ }
177
+
178
+ /** 内存 miss 时从权威槽 reconcile(D2 spawn 门 + D3 dispatch 写门同源):把槽文件中
179
+ * **未过期**项并进内存 Map(TTL 过滤保留——expired 不并入,fail-closed;格式/畸形串
180
+ * 与 restoreEngTokens 同语义读回——门禁格式拒);槽无活项 → 内存原样。返回判定用 Map。 */
181
+ export function reconcileEngTokensFromSlot(agent) {
182
+ const slots = agent?._engDesignTokens
183
+ const obj = readEngTokensFromSlot(agent)
184
+ if (!obj) return slots instanceof Map ? slots : new Map()
185
+ const live = new Map()
186
+ for (const [id, t] of Object.entries(obj)) {
187
+ if (typeof t === "string" && !tokenExpired(t)) live.set(id, t)
188
+ }
189
+ if (live.size === 0) return slots instanceof Map ? slots : new Map()
190
+ const merged = slots instanceof Map ? slots : new Map()
191
+ for (const [id, t] of live) merged.set(id, t)
192
+ agent._engDesignTokens = merged
193
+ return merged
194
+ }
195
+
196
+ /** 是否存在任一活槽(DESIGN-TOKEN-SETTLEMENT D3——dispatch 写门判定"任一活槽存在";
197
+ * AC4)。先问内存 Map(当前进程活缓存);miss 回读权威槽文件 reconcile(与 spawn 门
198
+ * D2 同源)。判定 fail-closed:格式合法且未过期才算活槽(畸形/过期不构成活槽——不
199
+ * 授权产品代码写)。 */
200
+ export function anyLiveDesignSlot(agent) {
201
+ const live = (m) => {
202
+ for (const t of m.values()) {
203
+ if (typeof t === "string" && tokenExpiryMs(t) !== null && !tokenExpired(t)) return true
204
+ }
205
+ return false
206
+ }
207
+ const m = agent?._engDesignTokens
208
+ if (m instanceof Map && live(m)) return true
209
+ const rec = reconcileEngTokensFromSlot(agent)
210
+ return rec instanceof Map && live(rec)
211
+ }
212
+
213
+ /**
214
+ * D1(DESIGN-TOKEN-SETTLEMENT,2026-09-08):settle 当场同步落盘——把当前内存多槽
215
+ * Map 写入会话槽文件的权威台账,消除 settle→回合尾 saveSession 间的重启丢 token 窗口。
216
+ * 复用 engTokenSlotFields 序列化 + session 安全写/轮转(guardForeignSlotFile =
217
+ * saveSession 同一份守卫——勿裸写文件)。槽文件缺失(本会话首次落盘)→ 写最小全新
218
+ * 记录(字段形状同 saveSession——loadSlotFile 可读);既有文件 → 读-改-写(只动 token
219
+ * 字段,历史等其他字段原样保留)。写失败(writeSessionFile/parse 抛错)→ 抛出/返回
220
+ * false——调用方(settle)失败即 settle 失败(token 不注册、可重评——评审 #1 语义)。
221
+ */
222
+ export function persistEngTokens(agent) {
223
+ const claimedNow = agent._slot == null
224
+ const slot = agent._slot ??= activeSlot(agent.cwd)
225
+ if (claimedNow) writeEndMarker(agent.cwd, slot)
226
+ const p = slotPath(agent.cwd, slot)
227
+ guardForeignSlotFile(agent, p, slot)
228
+ let data = null
229
+ if (existsSync(p)) {
230
+ data = JSON.parse(readFileSync(p, "utf8"))
231
+ } else {
232
+ // 全新槽(claim 先行、首保存落盘前)——最小记录(VSC newSlotData 同构——字段形状
233
+ // 同 saveSession 数据面,loadSlotFile 校验可读;历史留空——本会话首保存尚未发生,
234
+ // 此前历史本就未落盘,token 台账先行落盘不制造任何额外损失)。
235
+ data = {
236
+ version: 2,
237
+ cwd: agent.cwd,
238
+ title: agent.title ?? "",
239
+ activeProvider: agent.activeProvider ?? agent.provider?.name,
240
+ activeModel: agent.activeModel ?? null,
241
+ history: [],
242
+ contextHistory: [],
243
+ tasks: agent.tasks ?? [],
244
+ planMode: agent.planMode ?? false,
245
+ autoApprove: agent.autoApprove ?? false,
246
+ engineering: agent.config?.agent?.engineering ?? false,
247
+ goal: agent.goal ?? null,
248
+ advisor: agent.config?.advisor ?? null,
249
+ pendingReminders: agent._pendingReminders ?? [],
250
+ sessionStart: agent._sessionStart ?? null,
251
+ }
252
+ }
253
+ const fields = engTokenSlotFields(agent)
254
+ if (fields.engDesignTokens !== undefined) data.engDesignTokens = fields.engDesignTokens
255
+ else delete data.engDesignTokens
256
+ // 单值镜像字段永不复写(D3 零写)——残留 engDesignToken 随本次写一并清(更快退役,
257
+ // 杜绝 stale 镜像 + 空 Map 组合在下次 restore 二次迁移复活死 token 的边角)。
258
+ delete data.engDesignToken
259
+ data.updatedAt = Date.now()
260
+ writeSessionFile(p, data)
261
+ // 记录我们刚写的 mtime——下次保存/守卫跳过重复解析(saveSession 同款)
262
+ try { agent._slotMtime = statSync(p).mtimeMs } catch {}
263
+ // Update slot metadata in manifest(saveSession 同款尾——非致命:数据已安全,
264
+ // metadata 下次 listSlots 惰性恢复)
265
+ try {
266
+ const m = loadManifest(agent.cwd)
267
+ m.slots[slot] = slotDigest(data)
268
+ saveManifest(agent.cwd, m)
269
+ } catch (e) {
270
+ console.error(`[session] manifest metadata update failed for slot ${slot}: ${e.message}`)
271
+ }
272
+ return true
273
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * checklist-sync.mjs — checklist 并发写同步机(R10 F4,MULTI-INSTANCE-COLLAB.md §2a.3
3
+ * ——2026-09-06,自 checklist.mjs 拆出:门控 + 合并 + 序列化——防 checklist.mjs 超 300
4
+ * 行 advisory 线;本模块为纯同步机制,不持 checklist 业务状态)。
5
+ *
6
+ * 双端 checklist 同路径 {cwd}/.thincoder/checklist.md——多实例并发写原为裸整文件覆盖
7
+ * (丢项)。写前 mtime 门控 + ID 键合并(评审修正 #6 基线规则:基线 = 本读-改-写链内
8
+ * 最近一次 stat 的 mtime;本进程自写后刷新——parse 规范化写回不误触发 merged/重分配)。
9
+ * .bak 不做(决策点② A:cwd 在 git 仓库内——git 兜底现场)。
10
+ *
11
+ * 冲突语义(flushWrite 内收口):写前 stat ≠ 基线(对端并发写过)→ 重读磁盘 → 双方
12
+ * union by ID(本端新增 + 盘上他端新增都保留;同 ID 不同文本 → 盘上保留、本端重分配
13
+ * max+1 续分配;同 ID 仅状态不同 → in_progress 胜过 pending——标记不丢);checklist
14
+ * 文件合并时按 done 文件归档 ID 排除(含子任务点号 ID——并发 mark-done 的"盘上有旧
15
+ * pending 副本"不复活已归档项——done 集合 union 的另一半)。checklist-done.md 自身
16
+ * 合并 = 纯 union(无归档排除)。
17
+ *
18
+ * 调用方(checklist.mjs)通过 flushWrite 的 opts 注入:reread(磁盘无写回重读)、
19
+ * doneFile 路径、isDone(目标文件是否为 done 文件)——避免本模块反向依赖 checklist
20
+ * 的 parse/树结构。
21
+ */
22
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
23
+
24
+ const baselines = new Map() // filePath → 最近一次读的 stat mtime(null = 读时文件缺失)
25
+
26
+ /** F4 测试缝(伪属主范式——T-F4a..d):在每次写前门控处注入"对端写盘"钩子——模拟本链
27
+ * parse 后、write 前对端实例的真实并发写。生产不设置(null → 零行为影响)。 */
28
+ let writeGateHook = null
29
+ export function _setWriteGateHookForTest(fn) { writeGateHook = fn }
30
+
31
+ /** 文件缺失 → null(门控 t0 基线形态)。 */
32
+ export function statMtime(p) {
33
+ try { return statSync(p).mtimeMs } catch { return null }
34
+ }
35
+
36
+ /** 调用方(parse 读链)登记基线——读后写链以它为门控比对基准。 */
37
+ export function noteReadBaseline(filePath, mtime) {
38
+ baselines.set(filePath, mtime)
39
+ }
40
+
41
+ /** Done 文件归档根 ID 清单(含子任务点号 ID:T1 与 T1.2 都进——归档排除用;编号预留
42
+ * nextRootId 的 /^T(\d+)$/ 过滤不受影响)。 */
43
+ export function readDoneRoots(doneFile) {
44
+ if (!existsSync(doneFile)) return []
45
+ const roots = []
46
+ for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
47
+ const m = line.match(/^- \[.\] ((?:T\d+)(?:\.\d+)*): /)
48
+ if (m) roots.push({ id: m[1] })
49
+ }
50
+ return roots
51
+ }
52
+
53
+ /** Serialize items to markdown lines (2-space indent per depth) — tree structure preserved. */
54
+ function renderItems(nodes, depth, out) {
55
+ const indent = " ".repeat(depth)
56
+ for (const item of nodes) {
57
+ const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
58
+ const label = item.id ? `${item.id}: ${item.text}` : item.text
59
+ out.push(`${indent}- [${mark}] ${label}`)
60
+ if (item.children?.length) renderItems(item.children, depth + 1, out)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 门控 + 合并 + 写(F4 收口——parse 规范化写回 / add / mark 双写都经它)。
66
+ * @param filePath 目标文件(checklist.md 或 checklist-done.md)
67
+ * @param items 本端(读-改-写链产物)的顶层 items
68
+ * @param opts.reread 磁盘无写回重读函数(返回盘上顶层 items——合并触发时调用)
69
+ * @param opts.doneFile done 文件路径(checklist 合并的归档排除源)
70
+ * @param opts.isDone 目标文件是否为 done 文件(true = 纯 union,无归档排除)
71
+ * @returns { merged, items }——merged=true 时 items 为落盘真相(本端节点原地重分配——
72
+ * 调用方持有的引用/消息读到的即最终 ID);merged=false 时 items 为原输入。
73
+ */
74
+ export function flushWrite(filePath, items, { reread, doneFile, isDone }) {
75
+ if (writeGateHook) writeGateHook(filePath) // 测试缝:模拟对端此刻写盘
76
+ const base = baselines.get(filePath) // undefined = 本链无读(防御——无基线无从门控)
77
+ const now = statMtime(filePath)
78
+ let finalItems = items
79
+ let merged = false
80
+ if (base !== undefined && now !== base) {
81
+ // 磁盘被对端并发改过(本链读后)→ 重读磁盘 + 合并(ID union)
82
+ const diskItems = reread()
83
+ finalItems = mergeItems(diskItems, items, doneFile, isDone)
84
+ merged = true
85
+ }
86
+ const out = []
87
+ renderItems(finalItems, 0, out)
88
+ writeFileSync(filePath, out.join("\n") + "\n")
89
+ baselines.set(filePath, statMtime(filePath)) // 自写后刷新基线(防自写误判并发)
90
+ return { merged, items: finalItems }
91
+ }
92
+
93
+ // ── F4 合并(ID 键 union)─────────────────────────────────────────────────────
94
+
95
+ function flattenTree(nodes, out = []) {
96
+ for (const n of nodes) {
97
+ out.push(n)
98
+ if (n.children?.length) flattenTree(n.children, out)
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** 层内递归合并(磁盘序在前;本端独有/重分配项追加尾部)——规则见文件头注释。 */
104
+ function mergeLevel(diskNodes, ourNodes, env) {
105
+ const diskById = new Map(diskNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
106
+ const ourById = new Map(ourNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
107
+ const result = []
108
+ const pending = [] // 本端独有 + 同 ID 异文本(待重分配)——追加在磁盘序之后
109
+ for (const d of diskNodes) {
110
+ if (d.id != null && !env.isDone && env.doneIds.has(d.id)) continue // 已归档(对端 mark-done)→ 不复活
111
+ const o = d.id != null ? ourById.get(d.id) : null
112
+ if (!o) { result.push(d); continue } // 盘上独有 → 整棵保留
113
+ const childEnv = { ...env, prefix: `${d.id}.` } // 子层重分配域 = 父 ID 前缀("T1" → "T1.")
114
+ if (o.text === d.text && o.status === d.status) {
115
+ // 双端同一节点(parse 规范化幂等场景)→ 去重;子树仍逐层合并(防丢任一端子新增)
116
+ d.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
117
+ result.push(d)
118
+ continue
119
+ }
120
+ if (o.text === d.text) {
121
+ // 仅状态不同 → in_progress 胜过 pending(标记意图不丢;同文本不可能 pending×pending)
122
+ const carry = d.status === "in_progress" ? d : o
123
+ carry.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
124
+ result.push(carry)
125
+ continue
126
+ }
127
+ // 同 ID 不同文本(并发 add 各自从同基线分到同号)→ 盘上保留;本端项排队重分配
128
+ result.push(d)
129
+ pending.push(o)
130
+ }
131
+ for (const o of ourNodes) {
132
+ if (o.id == null) { pending.push(o); continue } // 无 ID 行(理论极少——parse 已归一)→ 原样追加
133
+ if (!env.isDone && env.doneIds.has(o.id)) continue // 本端项已被归档 → 丢弃(不复活)
134
+ if (!diskById.has(o.id)) pending.push(o) // 本端独有 → 保留(含子树)
135
+ // 共享 ID 已在磁盘循环处理(去重/状态/文本冲突排队)
136
+ }
137
+ for (const o of pending) {
138
+ if (o.id != null && diskById.has(o.id)) reassignNode(o, env) // 文本冲突项 → 重分配 max+1 续分配
139
+ result.push(o)
140
+ }
141
+ return result
142
+ }
143
+
144
+ /** 重分配:层前缀(根 "T" / 子树 `${parentId}.`)下 max+1;子树 ID 前缀同步换新
145
+ * ("T4.1" → "T5.1"——ID 层级与父节点保持一致)。原地改——调用方引用即最终 ID。 */
146
+ function reassignNode(node, env) {
147
+ let max = 0
148
+ for (const id of env.allIds) {
149
+ if (!id?.startsWith(env.prefix)) continue
150
+ const suffix = id.slice(env.prefix.length)
151
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
152
+ }
153
+ const oldId = node.id
154
+ const newId = `${env.prefix}${max + 1}`
155
+ node.id = newId
156
+ env.allIds.add(newId)
157
+ if (oldId) renameSubtreePrefixes(node, oldId, newId)
158
+ }
159
+
160
+ function renameSubtreePrefixes(node, oldId, newId) {
161
+ for (const c of node.children ?? []) {
162
+ if (c.id?.startsWith(`${oldId}.`)) {
163
+ c.id = newId + c.id.slice(oldId.length)
164
+ renameSubtreePrefixes(c, oldId, newId)
165
+ }
166
+ }
167
+ }
168
+
169
+ /** 合并入口:isDone = checklist-done 文件(纯 union——无归档排除);否则按 done 文件
170
+ * 当前归档 ID 排除(含点号子 ID——对端 mark-done 归档的项不因本端旧 parse 复活)。 */
171
+ function mergeItems(diskItems, ourItems, doneFile, isDone) {
172
+ const doneRoots = isDone ? [] : readDoneRoots(doneFile)
173
+ const doneIds = new Set(doneRoots.map((r) => r.id))
174
+ const allIds = new Set() // 重分配 max 计算域:双端全树 + 归档根(根层防撞)
175
+ for (const n of flattenTree(diskItems).concat(flattenTree(ourItems))) {
176
+ if (n.id != null) allIds.add(n.id)
177
+ }
178
+ for (const id of doneIds) allIds.add(id)
179
+ const env = { isDone, doneIds, allIds, prefix: "T" } // 根层前缀 "T";子层在 mergeLevel 递归时推导
180
+ return mergeLevel(diskItems, ourItems, env)
181
+ }