@thincoder/core 0.9.5 → 0.10.2

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 (216) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +12 -12
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +22 -12
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +54 -296
  20. package/agent/family-tools.mjs +5 -5
  21. package/agent/helpers.mjs +53 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +15 -11
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +89 -29
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +9 -9
  36. package/agent-tools/advisor-settle.mjs +12 -12
  37. package/agent-tools/advisor.mjs +36 -41
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +21 -18
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +57 -18
  43. package/agent-tools/batch-paths.mjs +52 -8
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +12 -65
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +56 -56
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +16 -11
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +2 -2
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +21 -265
  66. package/agent-tools/subagent-async.mjs +25 -20
  67. package/agent-tools/subagent-panel.mjs +33 -4
  68. package/agent-tools/subagent-run.mjs +36 -21
  69. package/agent-tools/subagent-scheduler.mjs +28 -27
  70. package/agent-tools/subagent-spawn.mjs +45 -14
  71. package/agent-tools/subagent.mjs +33 -53
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +18 -355
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +11 -6
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +29 -12
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/history-window.mjs +17 -2
  95. package/i18n.mjs +2 -0
  96. package/index-discover.mjs +7 -7
  97. package/ledger-cmd.mjs +34 -126
  98. package/ledger-db.mjs +10 -2
  99. package/ledger-tools.mjs +188 -0
  100. package/ledger.mjs +9 -4
  101. package/log.mjs +12 -7
  102. package/manifest-discovery.mjs +131 -0
  103. package/manifest-schema.mjs +166 -0
  104. package/manifest.mjs +18 -217
  105. package/mcp.mjs +4 -1
  106. package/memory/code-search.mjs +149 -0
  107. package/memory/code-sync.mjs +89 -230
  108. package/memory/core.mjs +47 -32
  109. package/memory/docs.mjs +63 -262
  110. package/memory/file-list.mjs +89 -0
  111. package/memory/file-walk.mjs +7 -4
  112. package/memory/memory-tool.mjs +229 -0
  113. package/memory/schema.mjs +18 -3
  114. package/memory/sweep.mjs +289 -0
  115. package/memory/sync-tail.mjs +81 -0
  116. package/memory-status.mjs +69 -0
  117. package/memory.mjs +3 -0
  118. package/model-ref.mjs +96 -3
  119. package/model-specs.mjs +16 -7
  120. package/notify-policy.mjs +51 -0
  121. package/package.json +1 -1
  122. package/peer-claims.mjs +2 -2
  123. package/peer-domains.mjs +7 -11
  124. package/peer-instances.mjs +9 -10
  125. package/process-probe-exec.mjs +187 -0
  126. package/process-probe.mjs +27 -175
  127. package/prompt-overlays.mjs +11 -10
  128. package/prompts/advisor-design.md +2 -2
  129. package/prompts/advisor-round2.md +1 -1
  130. package/prompts/advisor-round3.md +1 -1
  131. package/prompts/common.md +16 -8
  132. package/prompts/discipline-engineering.md +57 -16
  133. package/prompts/discipline-normal.md +6 -3
  134. package/prompts/persona-coder.md +0 -1
  135. package/prompts/persona-eng-coder.md +1 -5
  136. package/prompts/persona-eng-designer.md +2 -3
  137. package/prompts/persona-engineering.md +32 -11
  138. package/prompts/persona-explore.md +0 -1
  139. package/prompts/persona-normal.md +1 -1
  140. package/prompts/persona-plan.md +3 -4
  141. package/prompts/subagent-base.md +14 -0
  142. package/provider/core.mjs +11 -48
  143. package/provider/list-models.mjs +3 -0
  144. package/provider/responses-request.mjs +237 -0
  145. package/provider/responses.mjs +6 -228
  146. package/provider-flows.mjs +248 -0
  147. package/queued.mjs +90 -0
  148. package/rules.mjs +209 -2
  149. package/session-guard.mjs +27 -7
  150. package/session-lifecycle.mjs +108 -27
  151. package/session-rename.mjs +5 -1
  152. package/session-slot-claims.mjs +230 -0
  153. package/session-slot-scan.mjs +299 -0
  154. package/session-slot-verify.mjs +304 -0
  155. package/session-slot-write.mjs +74 -8
  156. package/session-slots-manifest.mjs +163 -241
  157. package/session-slots.mjs +99 -71
  158. package/session.mjs +30 -10
  159. package/shell-candidates.mjs +78 -0
  160. package/text-budget.mjs +2 -2
  161. package/think-off.mjs +49 -0
  162. package/token-ttl.mjs +15 -4
  163. package/tool-args.mjs +73 -0
  164. package/tool-docs/advisor.md +9 -0
  165. package/tool-docs/batch.md +1 -0
  166. package/tool-docs/code_search.md +1 -0
  167. package/tool-docs/consult_start.md +5 -0
  168. package/tool-docs/consult_stop.md +3 -0
  169. package/tool-docs/context.md +7 -0
  170. package/tool-docs/doc_search.md +1 -0
  171. package/tool-docs/edit.md +2 -13
  172. package/tool-docs/eng.md +1 -0
  173. package/tool-docs/execute.md +1 -9
  174. package/tool-docs/git.md +25 -51
  175. package/tool-docs/goal.md +6 -0
  176. package/tool-docs/ledger_add.md +1 -0
  177. package/tool-docs/ledger_close.md +1 -0
  178. package/tool-docs/ledger_count.md +1 -0
  179. package/tool-docs/ledger_query.md +1 -0
  180. package/tool-docs/ledger_update.md +1 -0
  181. package/tool-docs/main_history.md +1 -0
  182. package/tool-docs/memory.md +10 -0
  183. package/tool-docs/notify_parent.md +9 -0
  184. package/tool-docs/peer_instances.md +1 -0
  185. package/tool-docs/plan.md +5 -0
  186. package/tool-docs/process.md +7 -3
  187. package/tool-docs/read_history.md +7 -0
  188. package/tool-docs/recent_changes.md +1 -0
  189. package/tool-docs/repo_outline.md +1 -0
  190. package/tool-docs/settings.md +6 -0
  191. package/tool-docs/skill.md +5 -0
  192. package/tool-docs/subagent.md +31 -0
  193. package/tool-docs/task.md +7 -0
  194. package/tool-docs/timer.md +6 -0
  195. package/tool-docs/verify.md +1 -0
  196. package/tool-docs/wait_for.md +2 -1
  197. package/tools/bash.mjs +28 -4
  198. package/tools/edit-batch.mjs +4 -4
  199. package/tools/edit-diff.mjs +4 -23
  200. package/tools/exec-run.mjs +102 -4
  201. package/tools/execute.mjs +5 -2
  202. package/tools/file.mjs +4 -9
  203. package/tools/git-run.mjs +1 -1
  204. package/tools/git.mjs +0 -24
  205. package/tools/index.mjs +2 -1
  206. package/tools/linter.mjs +2 -1
  207. package/tools/node-child.mjs +21 -0
  208. package/tools/ops.mjs +50 -13
  209. package/tools/patch.mjs +4 -2
  210. package/tools/repomap.mjs +20 -9
  211. package/tools/shared.mjs +8 -6
  212. package/tools/web.mjs +5 -2
  213. package/tools/write-path.mjs +1 -1
  214. package/traces/trace-store.mjs +17 -14
  215. package/vision-reader.mjs +76 -0
  216. package/agent-tools/batch-segment.mjs +0 -18
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * session-slot-write.mjs — 会话槽「元数据开关」写面(Parnas 边界:开关写决策组——
3
- * autoApprove / planMode / engineering / advisor guard,外加全新槽默认记录与落盘单点)。
3
+ * autoApprove / planMode / engineering / advisor guard / 会话级偏好三键
4
+ * (provider / model / effort——§6.21),外加全新槽默认记录与落盘单点)。
4
5
  *
5
6
  * 归属:会话数据面(同上位 `session.mjs` / `session-slots.mjs` / `session-store.mjs`)。
6
7
  * 本档 = **(cwd, slot)形态**的槽写入面(读-改-写);另一形态 = `session.mjs` 的
@@ -23,10 +24,14 @@
23
24
 
24
25
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
25
26
 
26
- import { getSessionId, loadManifest, saveManifest, slotDigest, slotPath, writeSessionFile } from "./session-slots.mjs"
27
- import { loadSlotFile } from "./session.mjs"
27
+ import { getSessionId, loadManifest, saveManifest, slotDigest, slotPath, writeSessionFile, sessionEnd } from "./session-slots.mjs"
28
+ import { loadSlotFile, isLegacyTransient } from "./session.mjs"
28
29
  // token 形态单源(格式校验 + 到期时刻)——本档的保存面合并规则复用同一判定。
29
30
  import { tokenExpiryMs } from "./token-ttl.mjs"
31
+ // 会话级偏好写面(§6.21 判据句 3)的判据单源——档位值域(`specForModel(...).reasoningEffortEnum`)
32
+ // 与 off 可达性(`thinkOffPath`);两叶档(零 import)⇒ 不涉环,核内零第二份族别 / 枚举表。
33
+ import { specForModel } from "./model-specs.mjs"
34
+ import { thinkOffPath } from "./think-off.mjs"
30
35
 
31
36
  /** mtime 缓存(键 = 槽文件路径 → 上次所见 mtimeMs):同一槽连写时跳过重复解析。
32
37
  * 测试复位缝(与 `_setSessionsDirForTest` 同款):缓存为进程级单例,用例间需可复位
@@ -34,13 +39,17 @@ import { tokenExpiryMs } from "./token-ttl.mjs"
34
39
  const slotMtimeCache = new Map()
35
40
  export function _resetSlotMtimeCacheForTest() { slotMtimeCache.clear() }
36
41
 
37
- /** Fresh slot data default:全新空槽的规范结构(`newSession` 写盘面同形)。 */
42
+ /** Fresh slot data default:全新空槽的规范结构(`newSession` 写盘面**同源**——SLOT-END-PARAM
43
+ * 批起 `newSession` 与本构造器单源;`effort: null` = 会话级档位未设态——§6.21 判据句 5 规范
44
+ * 结构同源;老槽无该键由读侧按 `null` 容忍)。首物化即记创建端(§6.20 判据句 4 写面:本端
45
+ * 物化者 = 进程端名;「禁回填」不适用——本构造器只产全新槽)。 */
38
46
  export function newSlotData(cwd) {
39
47
  return {
40
48
  version: 2, cwd, title: "", updatedAt: Date.now(),
41
49
  history: [], contextHistory: [], tasks: [],
42
- planMode: false, goal: null, autoApprove: false, advisor: null,
50
+ planMode: false, goal: null, autoApprove: false, advisor: null, effort: null,
43
51
  pendingReminders: [], sessionStart: null,
52
+ createdBy: sessionEnd(),
44
53
  }
45
54
  }
46
55
 
@@ -62,8 +71,11 @@ function rotateIfForeign(p, slot, data) {
62
71
  disk.cwd.toLowerCase() !== data.cwd.toLowerCase()
63
72
  const diskStart = disk?.sessionStart ?? null
64
73
  const myStart = data?.sessionStart ?? null
74
+ // 盘面长度与快照**同口径**(#441①):快照 `data` 来自读槽回放(`loadSlotFile` 已按
75
+ // `isLegacyTransient` 过滤,`session.mjs` 读面)⇒ 盘侧同滤——否则坏档里的 legacy 注入
76
+ // 条目会凭空把盘面算长,无并发追加也误判轮转(.bak 白产 + 误报 concurrent append)。
65
77
  const diskLonger = Array.isArray(disk?.history) && Array.isArray(data?.history) &&
66
- disk.history.length > data.history.length
78
+ disk.history.filter((m) => !isLegacyTransient(m)).length > data.history.length
67
79
  if (!(diskIsNewer || diskForeign || (diskStart && diskStart !== myStart) || diskLonger)) return null
68
80
  const bak = `${p}.bak-${Date.now()}`
69
81
  renameSync(p, bak)
@@ -84,16 +96,24 @@ function rotateIfForeign(p, slot, data) {
84
96
  /**
85
97
  * 槽落盘单点(读-改-写面共用):轮转判定 → 写槽文件 → 回写 manifest 摘要。
86
98
  * 返回轮转出的 `.bak` 路径或 null(调用方按需透出)。
99
+ *
100
+ * 创建端(SLOT-END-PARAM 批 §6.20 判据句 4 写面):入参无 `createdBy` **且**槽文件不在盘
101
+ * (**轮转后**判据——`rotateIfForeign` 已让位现场)⇒ 本端即将首物化 ⇒ 落进程端名;
102
+ * 文件在盘(含老槽无键)⇒ 不动(禁回填)。
87
103
  */
88
104
  export function saveSlotData(cwd, slot, data) {
89
105
  data.updatedAt = Date.now()
90
106
  const p = slotPath(cwd, slot)
91
107
  const rotated = rotateIfForeign(p, slot, data)
108
+ if (data.createdBy === undefined && !existsSync(p)) data.createdBy = sessionEnd()
92
109
  writeSessionFile(p, data)
93
- slotMtimeCache.set(p, (() => { try { return statSync(p).mtimeMs } catch { return 0 } })())
110
+ // 写后取 mtime 单次采集(读失败 ⇒ `0` = 不可得):缓存(本档路径键)+ 摘要 `ts` 地板入参
111
+ // (#503——保存面写后取 mtime,同族先例 `session-slot-verify.mjs:99`)。
112
+ const wroteMtime = (() => { try { return statSync(p).mtimeMs } catch { return 0 } })()
113
+ slotMtimeCache.set(p, wroteMtime)
94
114
  try {
95
115
  const m = loadManifest(cwd)
96
- m.slots[slot] = slotDigest(data)
116
+ m.slots[slot] = slotDigest(data, wroteMtime)
97
117
  saveManifest(cwd, m)
98
118
  } catch { /* 摘要失败非致命——数据已安全,元数据下次 listSlots 惰性恢复 */ }
99
119
  return rotated
@@ -166,3 +186,49 @@ export function mergeEngTokensForSave(incoming, existing) {
166
186
  }
167
187
  return out
168
188
  }
189
+
190
+ /** 会话级偏好(§6.21)键闭集——表外键 = 整 patch 拒(写未发生)。 */
191
+ const SLOT_PREF_KEYS = new Set(["provider", "model", "effort"])
192
+
193
+ /**
194
+ * 会话级档位归一(§6.21 判据句 3——纯函数、不抛):`level` → 槽 `effort` 值。
195
+ * - `null` / `undefined` / `"auto"` ⇒ `null`(未设 ⇒ 回落配置面 / 渠道默认);
196
+ * - `"off"` ⇒ 该模型**有 off 路径**(判据单源 = `thinkOffPath`,勿在本档重写族别判定)
197
+ * ⇒ `"off"`、否则 `null`;
198
+ * - 其余 ⇒ 逐模型枚举(单源 = `specForModel(model).reasoningEffortEnum`)含之 ⇒ 原字面、
199
+ * 不含 / 无枚举 ⇒ `null`。
200
+ * `model` 非串(外端脏载 / 手工档)⇒ 按**未登记**归一(`DEFAULT_SPEC` 径)——「不抛」契约。
201
+ * 写面归一只此一处;读侧另容忍表外串(applySession 边界表——不设,不静默改写)。
202
+ */
203
+ export function resolveEffortPatch(level, model) {
204
+ if (level == null || level === "auto") return null
205
+ const spec = specForModel(typeof model === "string" ? model : "")
206
+ if (level === "off") return thinkOffPath(spec) ? "off" : null
207
+ return (spec?.reasoningEffortEnum ?? []).includes(level) ? level : null
208
+ }
209
+
210
+ /**
211
+ * 会话级偏好写出口(§6.21 判据句 2——沿 `setSlotAutoApprove` 同形,复用 `writeFlag` 单点):
212
+ * `patch` 键闭集 = `provider` / `model` / `effort`,**至少一键**(零键 / 表外键 ⇒ `false`,写未
213
+ * 发生);槽不可读 ⇒ `false`。`provider` / `model` 键入参映射既有槽字段 `activeProvider` /
214
+ * `activeModel`(不新增字段;`null` = 缺键语义——`provider` / `model` 无「未设」态);`effort` 经
215
+ * `resolveEffortPatch` 归一(`model` 取 `patch.model ?? 槽现值 activeModel`),归一 `null` = 未设
216
+ * (回落配置面 / 渠道默认)——**值置 `null` 不删键**(与 `newSlotData` 规范结构同源)。
217
+ * 只写盘:不碰当前内存态(施加面唯一 = `applySession`——§6.21 判据句 4 / 边界表第 5 行)。
218
+ *
219
+ * **值域面(#441② 明文——核侧有意不校)**:本出口只校**键闭集**,**不校值域**——`provider` /
220
+ * `model` 传 `null`(或表外字面)按**缺键语义**零改(不报错;`null` 仅对 `effort` 有「未设」
221
+ * 语义);值域名(`null` 仅 `effort` + `provider` / `model` 非空)由**端侧契约**把守
222
+ * (桌面 IPC「会话级偏好注」;端侧拒例 = `thincoder-desktop/src/main/agent-host.mjs`
223
+ * `prefsPatchFailure`)——读侧容忍为有意(跨端 / 手工改写档零行为变更),核内不立第二份值域校验。
224
+ */
225
+ export function setSlotPrefs(cwd, slot, patch) {
226
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) return false
227
+ const keys = Object.keys(patch)
228
+ if (keys.length === 0 || keys.some((k) => !SLOT_PREF_KEYS.has(k))) return false
229
+ return writeFlag(cwd, slot, (d) => {
230
+ if (patch.provider != null) d.activeProvider = patch.provider
231
+ if (patch.model != null) d.activeModel = patch.model
232
+ if ("effort" in patch) d.effort = resolveEffortPatch(patch.effort, patch.model ?? d.activeModel)
233
+ })
234
+ }
@@ -5,7 +5,18 @@
5
5
  * 摘要面(`extractSlotMeta` / `slotDigest`)· 清单读写(`loadManifest` / `saveManifest`)·
6
6
  * 属主面(`ownerPid` / `ownerPids` / `ownerStateOf` / `ownerArgs` / `cleanDeadOwners`)·
7
7
  * 认领面(`ensureActive` / `allocateFresh` / `claimSlot` / `activeSlot` / **认领释放集
8
- * `staleClaims`**——F-CR1 · SESSION-CLAIM 批:判据单源,`saveManifest` 落盘 + 各落点共用)。
8
+ * `staleClaims`**——F-CR1 · SESSION-CLAIM 批:判据单源,`saveManifest` 落盘 + 各落点共用;
9
+ * **属主 ∕ 认领两族已随 2026-09-28 拆分迁出 `session-slot-claims.mjs`——见下**)。
10
+ *
11
+ * LEDGER-RELIABILITY 批(2026-09-28 · SESSION.md §6.25):补摘要谓词 `healNeeded` + 落点 A 顺手补写
12
+ * `healDigest` / `slotMtime`(判据句 3)· 账本面写安全(判据句 5)· 账本健康出口 `ledgerHealth`(判据句 4)。
13
+ *
14
+ * 2026-09-28 拆分批(R3——本档 421 > 300):**认领面族**(`staleClaims` ∕ owner 三件 ∕
15
+ * `cleanDeadOwners` ∕ `ensureActive` ∕ `allocateFresh` ∕ `claimSlot` ∕ `_releaseStats` ∕
16
+ * `releaseClaimsAll` ∕ `activeSlot`)外提 `./session-slot-claims.mjs`(verbatim——台账 #484
17
+ * 顺延项落形);本档保留摘要 / 清单读写 / 属主健康面,并 re-export 该档导出保既有 import 面
18
+ * (消费档路径与名面不动)。新增静态环:本档 ↔ `session-slot-claims.mjs`(`loadManifest` ∕
19
+ * `saveManifest` ↔ 认领面族——环安全同下)。
9
20
  *
10
21
  * 静态环(与既有的 session.mjs ↔ session-slots.mjs 同形):本档引 `session-slots.mjs` 的
11
22
  * 存储原语(`getSessionId` / `manifestPath` / `slotPath` / `writeSessionFile`),
@@ -18,14 +29,14 @@
18
29
  * 属主三态(`ownerState`)判据单源 = `process-probe.mjs`。`unknown`(探测失败 / 缺行)⇒
19
30
  * 不认领 / 不判死 / 不删(方向不对称见 process-probe.mjs 头注 · D-MI10)。
20
31
  */
21
-
22
- import { existsSync, readFileSync, renameSync } from "node:fs"
32
+ import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
23
33
  // 「真实用户消息」谓词单源(`history-window.mjs`——人读线窗口与槽摘要同判据)。
24
34
  import { isRealUserMsg } from "./history-window.mjs"
25
- // 探测束(判活)+ 属主三态判据 / 死主判定(本档零自有 exec——判据面与探测面分离)。
26
- import { probeOwnersSync, ownerState, filterDeadOwners } from "./process-probe.mjs"
27
35
  // 存储原语(路径 / 原子写 / 进程 sessionId)——静态环见头注。
28
36
  import { getSessionId, manifestPath, slotPath, writeSessionFile } from "./session-slots.mjs"
37
+ // 认领面(2026-09-28 拆分外提——见头注):本档 import `staleClaims` 供 dropStaleClaims 取用,
38
+ // 并在下方 re-export 全族保既有 import 面。
39
+ import { staleClaims } from "./session-slot-claims.mjs"
29
40
 
30
41
  /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
31
42
  export function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
@@ -42,11 +53,49 @@ export function extractSlotMeta(history, activeProvider, updatedAt, title = "")
42
53
  }
43
54
 
44
55
  /** Extract preview summary from session data for manifest storage (with current timestamp)
45
- * MODEL-MERGE-SESSION:摘要升级带 activeModel——listSlots 合成显 "p:m"(双端同规则)。 */
46
- export function slotDigest(data) {
56
+ * MODEL-MERGE-SESSION:摘要升级带 activeModel——listSlots 合成显 "p:m"(双端同规则)。
57
+ * SLOT-END-PARAM 批(SESSION.md §6.20 判据句 4 读面):创建端**有值才带**(老槽无键 ⇒
58
+ * 键缺席 = 未知——禁回填、禁以占用端冒充)。
59
+ * #503:`ts` 经打点单源 `digestTs(mtimeMs)`——`mtimeMs`(可选)= 该档保存面**写后**取的 mtime
60
+ * (地板);缺省 / 不可得 ⇒ 裸墙钟回溯(旧调用点零改)。 */
61
+ export function slotDigest(data, mtimeMs) {
47
62
  const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
48
63
  if (data.activeModel) meta.activeModel = data.activeModel
49
- return { ts: Date.now(), ...meta }
64
+ if (data.createdBy) meta.createdBy = data.createdBy
65
+ return { ts: digestTs(mtimeMs), ...meta }
66
+ }
67
+
68
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v)
69
+
70
+ /** 摘要 `ts` 打点(**单源** · #503 地板):`max(墙钟, mtimeMs)`;`mtimeMs` 不可得(缺省 / 非数)⇒
71
+ * 裸墙钟回溯。沿先例 `session-slot-verify.mjs:99`(回写面地板):**平台文件时间戳可领先
72
+ * `Date.now()` 数 ms**——无地板则保存面刚写的摘要会被 `needsVerify`(`ts < mtime`)判「陈旧」
73
+ * 一次(有界自纠,非数据面);地板后 `ts ≥ mtime` 恒成立(同一 stat 值即比较对象)。 */
74
+ export function digestTs(mtimeMs) {
75
+ return isNum(mtimeMs) ? Math.max(Date.now(), mtimeMs) : Date.now()
76
+ }
77
+
78
+ /** 摘要补写谓词(§6.25 判据句 3 · **单源**):缺失 ∨ 陈旧(`ts < mtime`)⇒ 补写;新鲜 ⇒ 不写(幂等)。 */
79
+ export function healNeeded(entry, mtime) {
80
+ if (!entry || typeof entry !== "object") return true
81
+ if (!isNum(entry.ts)) return true
82
+ return isNum(mtime) ? entry.ts < mtime : false
83
+ }
84
+
85
+ /** 落点 A 顺手补写(§6.25 判据句 3 · 打开 / 切槽):数据**已在手** ⇒ 置入调用方 manifest 对象,随其
86
+ * 既有那次 `saveManifest` 落盘(**零额外槽文件读 + 零额外写**);`mtime` 不可得 ⇒ 不妄断陈旧。
87
+ * `ts` 携同一 `mtime` 地板(#503——否则 mtime 领先墙钟时刚补写的条目又判陈旧)。 */
88
+ export function healDigest(m, slot, data, mtime) {
89
+ if (!data || typeof data !== "object") return false
90
+ if (!healNeeded(m.slots?.[slot], mtime)) return false
91
+ m.slots ??= {}
92
+ m.slots[slot] = slotDigest(data, mtime)
93
+ return true
94
+ }
95
+
96
+ /** 槽文件 mtime(`healDigest` 新鲜度判据入参——读失败 ⇒ `null` = 不可得)。 */
97
+ export function slotMtime(cwd, slot) {
98
+ try { return statSync(slotPath(cwd, slot)).mtimeMs } catch { return null }
50
99
  }
51
100
 
52
101
  export function loadManifest(cwd) {
@@ -60,6 +109,62 @@ export function loadManifest(cwd) {
60
109
  } catch { return { slots: {}, sessionId: null } }
61
110
  }
62
111
 
112
+ /** 可信合并基座判据(§6.23 判据句 1 · ②):顶层非 null 非数组对象 ∧(`slots` / `slotSessions`
113
+ * 缺席或为对象)。**假值(null / `0` / `""` / `false`)按缺席归一**——判据线单源 = `loadManifest`
114
+ * 的 `!m.slots → {}`(同向:空基座可合并、不拒写;展开后自然落 `{}`);真值非对象(`42` / `"x"`)
115
+ * 与数组 ⇒ 不可信。形态非法 ⇒ 不写盘(判据句 2(c))。 */
116
+ function isTrustedBase(fresh) {
117
+ if (!fresh || typeof fresh !== "object" || Array.isArray(fresh)) return false
118
+ const mapField = (v) => !v || (typeof v === "object" && !Array.isArray(v))
119
+ return mapField(fresh.slots) && mapField(fresh.slotSessions)
120
+ }
121
+
122
+ /** (b)(c) 现场保全:原字节改名 `{manifest 路径}.corrupted`(与槽文件面同形)——同名已存在 ⇒
123
+ * 覆盖(单槽位 · last-wins);改名失败被吞、**不阻断拒写**(§6.23 判据句 2)。返回值为**改名目标
124
+ * 路径**(拒写 loud 行的 `preserved=` 即取此)——非存在性保证:改名失败面 = `.corrupted` 缺席自证。 */
125
+ function preserveScene(p) {
126
+ const dst = `${p}.corrupted`
127
+ try { renameSync(p, dst) } catch {}
128
+ return dst
129
+ }
130
+
131
+ /** 账本健康计数(§6.25 判据句 4——**本进程累计**):拒写(§6.23)+ 写后读回失败次数与最近一条。 */
132
+ const ledgerRefusals = { refused: 0, lastReason: null, lastPath: null, lastAt: null }
133
+ const noteRefusal = (reason, p) => Object.assign(ledgerRefusals, { refused: ledgerRefusals.refused + 1, lastReason: reason, lastPath: p, lastAt: Date.now() })
134
+
135
+ /** 账本健康出口(§6.25 判据句 4 · **判据单源**):`refused` = 本进程累计(§6.23 拒写 + 判据句 5 读回
136
+ * 失败)+ `lastReason` ∈ 四类(read-failed / parse-failed / shape-invalid / readback-failed);`scene` =
137
+ * 该 cwd 的 `{manifest}.corrupted` 在盘(损坏现场——随 30 天 GC / 手工清除而止)。 */
138
+ export function ledgerHealth(cwd) {
139
+ let scene = false
140
+ try { scene = existsSync(`${manifestPath(cwd)}.corrupted`) } catch { /* cwd 不可解析 ⇒ 无现场 */ }
141
+ return { ...ledgerRefusals, scene }
142
+ }
143
+
144
+ /** 拒写(§6.23 判据句 2/3):不调 `writeSessionFile`(本次调用对盘面零字节写),stderr 一行 loud,返回 `false`。 */
145
+ function refuseManifestWrite(p, reason, preserved = null) {
146
+ noteRefusal(reason, p)
147
+ console.error(`[session] saveManifest: write refused (reason=${reason}) path=${p}${preserved ? ` preserved=${preserved}` : ""}`)
148
+ return false
149
+ }
150
+
151
+ /** 写后钩子缝(§6.25 判据句 5:写后 / 读回前注入;生产零消费):`fn({ path, tmp })`。 */
152
+ let manifestWriteHook = null
153
+ export function _setManifestWriteHookForTest(fn) { manifestWriteHook = fn }
154
+
155
+ /** 账本面写(两路共用 · D-SE65):独占临时名 + **写后结构读回**(判据单源 = `isTrustedBase`);不过 ⇒
156
+ * loud 一行 + 现场改名 `.corrupted`(解封下一写)+ `false`。**非逐字节相等**(并发合法后写会假红——明裁)。 */
157
+ function writeManifestBase(p, m) {
158
+ const tmp = writeSessionFile(p, m, { tmpUnique: true })
159
+ manifestWriteHook?.({ path: p, tmp })
160
+ let trusted = false
161
+ try { trusted = isTrustedBase(JSON.parse(readFileSync(p, "utf8"))) } catch { trusted = false }
162
+ if (trusted) return true
163
+ noteRefusal("readback-failed", p)
164
+ console.error(`[session] saveManifest: readback-failed path=${p} preserved=${preserveScene(p)}`)
165
+ return false
166
+ }
167
+
63
168
  export function saveManifest(cwd, m, deletions = null, opts = {}) {
64
169
  // 2026-08-31 会诊 kimi/deepseek 🟡:写前重读并按"条目级"合并——原实现把"读时快照"
65
170
  // 整对象写回,另一进程在窗口内对 slots/slotSessions/active 的变更被覆盖抹除(被抹
@@ -74,49 +179,52 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
74
179
  // (落点槽 ∪ 本进程其余活绑定槽;被占落点 ⇒ 空集)。③ 内存认领表先移除残留条目(否则下方
75
180
  // 条目级合并会把它从内存复活回写)——先于 try(fresh 不可读时亦须生效)。
76
181
  if (opts.release) dropStaleClaims(m, opts.release)
182
+ // MANIFEST-WRITE-GUARD 批(2026-09-28 · SESSION.md §6.23 判据句 1–3 / D-SE59):写前分类——只有
183
+ // 「① 合法创建(ENOENT)」与「② 可信合并基座」两路通向 writeSessionFile;文件在盘而基座不可信
184
+ // (读失败 / 解析失败 / 形态非法)⇒ **拒绝写**(不整档替换——#476 实证:读不可信窗口内一次保存把
185
+ // manifest 永久缩水且零告警)。拒写 = stderr 一行 + 返回 false(唯一消费点 = releaseClaimsAll 透传;
186
+ // 其余调用点忽略返回值——零行为变化);拒写不回滚调用方内存态(认领等内存变更归调用方所有,
187
+ // 下一次保存重试——与 F-XR1 同向的失败容忍形态)。
188
+ const p = manifestPath(cwd)
189
+ let fresh
77
190
  try {
78
- const fresh = JSON.parse(readFileSync(manifestPath(cwd), "utf8"))
79
- if (fresh && typeof fresh === "object" && fresh.slots && typeof fresh.slots === "object") {
80
- const merged = { ...fresh }
81
- if (opts.setActive) merged.active = m.active
82
- merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
83
- merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
84
- if (m.sessionId) merged.sessionId = m.sessionId
85
- // ① 释放集按**本次 fresh 快照**取值(不得以陈旧内存 manifest 构 deletions);② **值条件删除**
86
- // (仅当 fresh 属主仍为本进程——窗口内他人的新认领不在集内、不被误删)⇒ 并入本次 deletions。
87
- if (opts.release) {
88
- const released = staleClaims(fresh, opts.release)
89
- if (released.length > 0) {
90
- deletions = { ...(deletions ?? {}), slotSessions: [...(deletions?.slotSessions ?? []), ...released] }
91
- }
92
- }
93
- if (deletions) {
94
- for (const [section, keys] of Object.entries(deletions)) {
95
- for (const k of keys) delete merged[section]?.[k]
96
- }
97
- }
98
- m = merged
191
+ fresh = JSON.parse(readFileSync(p, "utf8"))
192
+ } catch (err) {
193
+ // ① 合法创建:路径不存在(首建)⇒ 以调用方对象建新档(现行为保留)
194
+ if (err?.code === "ENOENT") {
195
+ m.sessionId = getSessionId()
196
+ return writeManifestBase(p, m)
99
197
  }
100
- } catch {
101
- // 首次创建或 manifest 不可读:用传入对象。2026-09-01 advisor 🟡:解析失败时先改名
102
- // 保留现场(与 loadSlotFile 对 slot 文件的 .corrupted 原则一致)——否则覆盖后全部
103
- // 槽位元数据(digest/title/updatedAt)永久丢失,/session 列表变空。文件不存在时
104
- // rename 抛错被吞,无害。
105
- try { renameSync(manifestPath(cwd), `${manifestPath(cwd)}.corrupted`) } catch {}
198
+ // (a) 读失败(非 ENOENT:EISDIR / EPERM / EBUSY 等)⇒ 拒写 · 不改名(读不到 ≠ 损坏——现场
199
+ // 原样保留;该窗口正是 #476 缩水损伤的成因窗口)
200
+ if (!(err instanceof SyntaxError)) return refuseManifestWrite(p, "read-failed")
201
+ // (b) 解析失败 ⇒ 拒写 · 保底改名 .corrupted(现场保全 + 解封下一写——原路径不重建)
202
+ return refuseManifestWrite(p, "parse-failed", preserveScene(p))
106
203
  }
204
+ // (c) 形态非法(顶层非 null 非数组对象 / `slots` 非对象 / `slotSessions` 非对象)⇒ 同 (b)
205
+ if (!isTrustedBase(fresh)) return refuseManifestWrite(p, "shape-invalid", preserveScene(p))
206
+ // ② 可信合并基座:读-合并-写(条目级合并 / deletions / setActive / opts.release 四判据零改)
207
+ const merged = { ...fresh }
208
+ if (opts.setActive) merged.active = m.active
209
+ merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
210
+ merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
211
+ if (m.sessionId) merged.sessionId = m.sessionId
212
+ // ① 释放集按**本次 fresh 快照**取值(不得以陈旧内存 manifest 构 deletions);② **值条件删除**
213
+ // (仅当 fresh 属主仍为本进程——窗口内他人的新认领不在集内、不被误删)⇒ 并入本次 deletions。
214
+ if (opts.release) {
215
+ const released = staleClaims(fresh, opts.release)
216
+ if (released.length > 0) {
217
+ deletions = { ...(deletions ?? {}), slotSessions: [...(deletions?.slotSessions ?? []), ...released] }
218
+ }
219
+ }
220
+ if (deletions) {
221
+ for (const [section, keys] of Object.entries(deletions)) {
222
+ for (const k of keys) delete merged[section]?.[k]
223
+ }
224
+ }
225
+ m = merged
107
226
  m.sessionId = getSessionId()
108
- writeSessionFile(manifestPath(cwd), m)
109
- }
110
-
111
- /** 本进程**残留认领集**(F-CR1 释放谓词 —— **判据单源** · SESSION.md §6.2 / §6.16):
112
- * 「释放 A ⟺ `slotSessions[A]` 为本进程 ∧ `A ∉ 保留集`」。`keep` = 保留集 = 落点槽 ∪ 本进程
113
- * 其余活绑定槽(单绑定落点 ⇒ `{落点槽}`;被占落点 ⇒ 空集)。返回槽号(**字符串键**形态)。 */
114
- export function staleClaims(src, keep = []) {
115
- const keepSet = new Set((keep ?? []).filter((s) => s != null).map(Number))
116
- const mySessionId = getSessionId()
117
- return Object.entries(src?.slotSessions ?? {})
118
- .filter(([slot, owner]) => owner === mySessionId && !keepSet.has(Number(slot)))
119
- .map(([slot]) => slot)
227
+ return writeManifestBase(p, m)
120
228
  }
121
229
 
122
230
  /** 从**内存**认领表移除本进程残留条目(判据③——与写盘同一时机;见 `saveManifest`)。 */
@@ -125,197 +233,11 @@ function dropStaleClaims(m, keep) {
125
233
  for (const slot of staleClaims(m, keep)) delete m.slotSessions[slot]
126
234
  }
127
235
 
128
- /** 属主串 → pid(`"pid-ts-rand"`——`owner.split("-")[0]` 格式判据单源:session-slots ↔
129
- * session-lifecycle 共用,防两处漂移)。不可解析 ⇒ NaN——判据层按 dead 处理
130
- * (见 process-probe `ownerState`)。 */
131
- export function ownerPid(owner) { return parseInt(owner.split("-")[0]) }
132
-
133
- /** manifest 属主 pid 全量清单(**非本进程**——束入参:一次探测覆盖所有待判 pid;
134
- * 空清单 ⇒ 探测面零 exec 早退)。`exclude` 缺省 = 本进程 sessionId。 */
135
- export function ownerPids(m, exclude = getSessionId()) {
136
- const pids = []
137
- for (const owner of Object.values(m.slotSessions ?? {})) {
138
- if (!owner || owner === exclude) continue
139
- const pid = ownerPid(owner)
140
- if (pid) pids.push(pid)
141
- }
142
- return pids
143
- }
144
-
145
- /** 束 → 属主三态(判据单源 = process-probe `ownerState`;束缺失 / 探测失败 ⇒ "unknown"——
146
- * 保守保留,**不得**当全死)。 */
147
- export function ownerStateOf(owner, bundle) {
148
- const pid = ownerPid(owner)
149
- return ownerState(pid, { aliveSet: bundle?.aliveSet ?? null, cmds: bundle?.cmds ?? null })
150
- }
151
-
152
- /** 束 → `filterDeadOwners` 入参(清理面 API 保面——三态 `alive`:无束 / 失败 ⇒ undefined = 未知)。 */
153
- function ownerArgs(owner, bundle) {
154
- const pid = ownerPid(owner)
155
- const aliveSet = bundle?.aliveSet ?? null
156
- return [pid, { alive: aliveSet ? aliveSet.has(pid) : undefined, cmdline: bundle?.cmds?.get?.(pid) }]
157
- }
158
-
159
- /** 死主条目清理(2026-08-31 会诊 F4 + 2026-09-01 会诊 deepseek/kimi 🔴 语义原样抽取——
160
- * ensureActive/resumeSlot 共用):删除 owner 进程已死的 slotSessions 条目。死主判定必须跑
161
- * 判据(不能以"文件缺失"短路——活进程在"认领 → 首次保存"窗口文件暂缺,误删会
162
- * 致双进程同槽),但**不自行探测**:判活 / 命令行来自调用面同一探测束(F-MI7——
163
- * 零逐 pid exec)。返回 deletions 计算函数(调用时按 m 当前状态过滤刚重新认领的槽——防删
164
- * 掉自己的新属主),无清理返回 null。删除须经 saveManifest 的 deletions 显式落盘——条目级
165
- * 合并会把磁盘死条目从 fresh 复活回写(N1 + 会诊 🔴)。
166
- *
167
- * 身份复核(批 1 · F-MI6 / D-MI11——判据单源 `filterDeadOwners`):pid 活 + 命令行**明确
168
- * 可得且非本产品** ⇒ 同判可删(pid 复用即此场景);探测失败 / 缺行 ⇒ 保守保留(D-MI10)。 */
169
- export function cleanDeadOwners(m, bundle) {
170
- const mySessionId = getSessionId()
171
- const entries = Object.entries(m.slotSessions).filter(([, o]) => o && o !== mySessionId)
172
- const deadSlots = []
173
- for (const [slot, owner] of entries) {
174
- const [pid, args] = ownerArgs(owner, bundle)
175
- if (filterDeadOwners(pid, args)) {
176
- delete m.slotSessions[slot]
177
- deadSlots.push(slot)
178
- }
179
- }
180
- return deadSlots.length === 0 ? null : () => ({ slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId) })
181
- }
182
-
183
- /**
184
- * Claim a slot for this process and set it as active. Idempotent.
185
- * Preference order:
186
- * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
187
- * 2. Any slot that is unowned or owned by a dead process (reclaim). → allocateFresh
188
- * 3. A brand-new slot when all are owned by live processes. → allocateFresh
189
- * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
190
- * see which slots are taken and avoid them.
191
- *
192
- * F-MI7(同步例外收窄):粘性早退(已拥有 active)**零探测**(无束、无 exec);
193
- * 冷路径 = 一次有界同步束(`probeOwnersSync`)——清理 / 占用 / 空闲全部查表。
194
- */
195
- function ensureActive(cwd, m) {
196
- const mySessionId = getSessionId()
197
- if (!m.slotSessions) m.slotSessions = {}
198
-
199
- // Already own the active slot — nothing to do. 粘性早退:**零探测**(死项清理交给
200
- // 非粘性路径——resumeSlot 每次进程入口的异步束 / 冷路径同步束都会清并落盘)。
201
- if (m.active && m.slotSessions[m.active] === mySessionId) return
202
-
203
- // 冷路径:入口一次有界同步束(每 exec ≤ SYNC_PROBE_MS)覆盖全量属主 pid。
204
- const bundle = probeOwnersSync(ownerPids(m))
205
- const deadParam = cleanDeadOwners(m, bundle)
206
-
207
- const isFree = (slot) => {
208
- const owner = m.slotSessions[slot]
209
- if (!owner || owner === mySessionId) return true
210
- // 未知(探测失败 / 缺行)⇒ 不认为是空闲(不认领——D-MI10)
211
- return ownerStateOf(owner, bundle) === "dead"
212
- }
213
-
214
- // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
215
- if (m.active && m.slots[m.active] && isFree(m.active)) {
216
- m.slotSessions[m.active] = mySessionId
217
- saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
218
- return
219
- }
220
-
221
- // 2/3. 分支 2/3 已抽取为 allocateFresh(2026-09-05 §6.10 D-2——resumeSlot 全新分配复用)。
222
- allocateFresh(cwd, m, deadParam, bundle)
223
- }
224
-
225
- /**
226
- * Allocate a slot with ensureActive 分支 2/3 语义(2026-09-05 §6.10 D-2 抽取——resumeSlot
227
- * 步骤③"其余一切 → 全新分配"复用;与 newSession 选号语义对齐):
228
- * 2. Reclaim a manifest slot whose FILE does not exist (never held a session);
229
- * 3. A brand-new slot when none is free / all numbers are taken.
230
- * 记录所有权 + active 指针(setActive)并落盘。返回分配的槽号。
231
- * F-MI7:占用 / 空闲判据全部查**调用面束**(`bundle`——零自有探测);缺省 null ⇒
232
- * 全部属主按 unknown 论(只可能取全新号——保守,不认领陌生人)。
233
- */
234
- export function allocateFresh(cwd, m, deadParam = null, bundle = null) {
235
- const mySessionId = getSessionId()
236
- if (!m.slotSessions) m.slotSessions = {}
237
- const isFree = (slot) => {
238
- const owner = m.slotSessions[slot]
239
- if (!owner || owner === mySessionId) return true
240
- return ownerStateOf(owner, bundle) === "dead" // 未知 ⇒ 非空闲(不认领——D-MI10)
241
- }
242
-
243
- // 2. Reclaim a slot whose FILE does not exist (never held a session). 2026-08-31 会诊 F4:
244
- // 原实现认领"编号最小的空闲 slot"——死主的旧 slot 文件仍在,新进程会 resume 进
245
- // 陌生会话("会话乱了"实锤)且退出时覆盖它。只有文件缺失的空 slot 才允许回收。
246
- const allSlots = Object.keys(m.slots).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
247
- for (const slot of allSlots) {
248
- if (isFree(slot) && !existsSync(slotPath(cwd, slot))) {
249
- m.active = slot
250
- m.slotSessions[slot] = mySessionId
251
- saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
252
- return slot
253
- }
254
- }
255
-
256
- // 3. All slots owned by live processes — allocate a new one (no limit).
257
- // 2026-08-31 advisor round2 🟡:新号从 max+1 起逐号跳过"已被活进程认领但尚未落盘"
258
- // 的号(认领→首次保存窗口:slotSessions 有条目、m.slots 无条目、文件不存在——
259
- // 仅凭 m.slots/existsSync 查不到 → 双进程认领同一号 → 同槽双写/互旋)。
260
- // 2026-09-01 会诊 kimi 🟡:同时跳过文件仍存在的号(与 newSession 对齐)——manifest
261
- // 条目丢失/损坏时 max+1 会撞上孤儿槽文件 → F2 把真会话轮转成不可见的 .bak。
262
- // 另:allSlots 已升序,取 max 用 allSlots[allSlots.length-1](数万槽位时 Math.max
263
- // spread 有 RangeError 风险)。
264
- const liveClaimed = (n) => {
265
- const owner = m.slotSessions?.[n]
266
- if (!owner || owner === mySessionId) return false
267
- return ownerStateOf(owner, bundle) !== "dead" // 活 / 未知 ⇒ 视为已占(跳号——保守)
268
- }
269
- let newSlot = allSlots.length > 0 ? allSlots[allSlots.length - 1] + 1 : 1
270
- while (liveClaimed(newSlot) || existsSync(slotPath(cwd, newSlot))) newSlot++
271
- m.active = newSlot
272
- m.slotSessions[newSlot] = mySessionId
273
- saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
274
- return newSlot
275
- }
276
-
277
- /** 认领指定槽为本进程所有并置为 active(2026-09-05 §6.10 D-2 resumeSlot 认领路径——调用方
278
- * 已按"属主 空/死/本进程"判据校验可用性;幂等)。manifest active 保留为共享指针(D-6:
279
- * 旧版端/ACP 恢复依据 + 无记录端一次性继承源 + 列表回退高亮——不再作本端恢复第一依据)。
280
- * deadParam 在写入所有权之后求值(防 deletions 删掉本调用刚认领的槽——ensureActive
281
- * deadParam 同型过滤)。
282
- * F-CR1(2026-09-21 · SESSION-CLAIM 批):认领落点释放本进程残留认领(保留集 = {认领槽}——
283
- * §6.2 公式代入);本函数 = 启动恢复 / 本端恢复落点(核 `resumeSlot` 与 VSC 端壳
284
- * `resumeSlot` 共用),落盘判据(fresh 快照 / 值条件删除 / 内存移除)见 `saveManifest`。 */
285
- export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
286
- m.slotSessions ??= {}
287
- m.slotSessions[slot] = getSessionId()
288
- m.active = slot // 认领即翻共享指针(setActive 写 m.active——不更新则落快照旧值)
289
- saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true, release: [slot] })
290
- }
291
-
292
- /** 观测计数(测试缝——`_storeStats` / `_staleHooks` 同惯例):`releaseClaimsAll` 调用计数。
293
- * 端壳「工作区转空 ⇒ 旧 cwd 释放恰一次」类断言以此为桩计数(生产零消费)。 */
294
- export const _releaseStats = { calls: 0 }
295
-
296
- /** 退出全释放(EXIT-CLAIM-RELEASE 批 · F-XR1 · SESSION.md §6.18 D-SE41):优雅退出前释放
297
- * 本进程**全部**槽认领(保留集空——`staleClaims` 释放谓词单源);落盘判据 = `saveManifest`
298
- * `opts.release` 三条自动继承(fresh 同次快照取释放集 / 值条件删除——他人窗口内新认领不误删 /
299
- * 内存认领表同步移除)。**零触碰 marker 面**(F-XR2 路标保留——下次启动经 `usableSlot`
300
- * 无属主短路零探测直达恢复);崩溃路径不经此(信号无 JS 钩子——F-XR3)。
301
- * 早退面:磁盘无 manifest ⇒ `existsSync` 单条目早退**零写**(不造盘面);本进程无认领 ⇒
302
- * 零写返回 false。失败容忍(F-XR1「退出恒达」):整体 try/catch **永不抛出**,返回 boolean
303
- * (true = 有释放且落盘成功;false = 早退 / 失败——断言面);写失败盘面 = 认领残留 →
304
- * 恢复走既有探测面(现状形态,数据零险)。 */
305
- export function releaseClaimsAll(cwd) {
306
- _releaseStats.calls += 1
307
- try {
308
- if (!existsSync(manifestPath(cwd))) return false // 无 manifest ⇒ 零写(不造盘面)
309
- const m = loadManifest(cwd)
310
- if (staleClaims(m, []).length === 0) return false // 无本进程认领 ⇒ 零写(免白刷共享 sessionId)
311
- saveManifest(cwd, m, null, { release: [] }) // 保留集空 = 全释放;不 setActive(active 共享指针不动)
312
- return true
313
- } catch { return false } // F-XR1:退出恒达(写失败 = 认领残留 → 恢复走探测面)
314
- }
315
-
316
- /** Return the active slot number for this process, claiming one if necessary */
317
- export function activeSlot(cwd) {
318
- const m = loadManifest(cwd)
319
- ensureActive(cwd, m)
320
- return m.active
321
- }
236
+ // ========== 认领面(2026-09-28 拆分外提至 ./session-slot-claims.mjs——见头注)==========
237
+ // 认领面族(staleClaims / ownerPid / ownerPids / ownerStateOf / cleanDeadOwners / ensureActive /
238
+ // allocateFresh / claimSlot / _releaseStats / releaseClaimsAll / activeSlot)语义原样迁至
239
+ // `session-slot-claims.mjs`;本档上方 import 取用(dropStaleClaims)、re-export 保既有 import 面。
240
+ export {
241
+ staleClaims, ownerPid, ownerPids, ownerStateOf, cleanDeadOwners,
242
+ allocateFresh, claimSlot, _releaseStats, releaseClaimsAll, activeSlot,
243
+ } from "./session-slot-claims.mjs"