@shgroup/dsh-serenity-hooks 1.76.0 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh.plugin.json +1 -1
- package/lib/cleanup-C-92qnDs.js +213 -0
- package/lib/index.js +26 -232
- package/lib/message-source-CYa8LbbM.js +488 -0
- package/lib/trajectory/bind-file.d.ts +228 -0
- package/lib/trajectory/bound.d.ts +117 -132
- package/package.json +1 -1
- package/lib/message-source-C_Zd2vJh.js +0 -435
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
import { r as cccRootForCwd } from "./ccc-roots-rwgzt_0F.js";
|
|
2
|
-
import { T as parseTrajectoryCode, b as getLastActiveSessionInfo, k as sessionEvents, y as getActiveSessionInfo } from "./profile-DplLGGqo.js";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
-
//#region src/trajectory/bound.ts
|
|
6
|
-
/**
|
|
7
|
-
* trajectory/bound.ts — SESSION 绑定持久化(CCC 内 `AGENT_SESSIONS/.bindings.json`)
|
|
8
|
-
*
|
|
9
|
-
* 目标(S142 用户拍板,方案 v1.0):让「dsh 会话(载体)↔ SESSION(宁静号轨迹)」
|
|
10
|
-
* 的绑定坚固——LLM 不能因智力因素(幻觉/理解偏差)在过程中或 cognitive_trajectory rebuild 后
|
|
11
|
-
* 自行更换 SESSION。
|
|
12
|
-
*
|
|
13
|
-
* 存储形态(v1.30.6 起,S142 review F-01):绑定**不再写入 dsh 会话日志**。
|
|
14
|
-
* 原因(宿主契约实证):dsp 的自定义事件 `serenity/bound` 既不在宿主的
|
|
15
|
-
* `KNOWN_SESSION_EVENT_TYPES`(生成静态集,明确说明 out-of-repo 插件事件不在其中),
|
|
16
|
-
* 而 `Session.append` 构造事件时只写 `{type,seq,time,data,surfaceOp?,sourceEventSeqs?}`
|
|
17
|
-
* ——**没有写入 envelope `ignorable` 标记的通道**;宿主的 `assertEventsSupported`
|
|
18
|
-
* (session-persistence/coordinator.ts:1248-1252,调用点含恢复/HMR 路径)会拒绝任何
|
|
19
|
-
* "未知且非 ignorable" 的事件 → 含该事件的会话在冷加载时可能抛
|
|
20
|
-
* `SessionFormatUnsupportedError`。v1.29.1~v1.30.5 的设计文档虽写明要带
|
|
21
|
-
* `ignorable: true`(docs/session-binding-hardening-research.md:53),但类型层声明
|
|
22
|
-
* 不等于持久层标记,实际从未落盘。
|
|
23
|
-
*
|
|
24
|
-
* 现形态:每 CCC 一个 JSON 文件 `AGENT_SESSIONS/.bindings.json`(原子写:tmp + rename),
|
|
25
|
-
* 以 dsh 会话 id 为键,latest-wins 覆盖。**向后兼容**:读取时若文件中无该会话记录,
|
|
26
|
-
* 仍回落到扫描会话日志中的旧 `serenity/bound` 事件(老会话的绑定不丢)。
|
|
27
|
-
*
|
|
28
|
-
* 编码无关(U4):绑定锚 = **完整 AGENT_SESSIONS 目录名**(磁盘唯一存在),
|
|
29
|
-
* sessionId(S###/apaas-xxx/自定义)仅是派生展示字段——绝不假设 S 前缀。
|
|
30
|
-
*/
|
|
31
|
-
/** 旧形态事件类型(v1.29.1~v1.30.5 写入会话日志;v1.30.6 起只读不写——兼容存量绑定) */
|
|
32
|
-
const SESSION_BOUND_EVENT = "serenity/bound";
|
|
33
|
-
/** 绑定文件相对 CCC 根的路径 */
|
|
34
|
-
const BINDINGS_REL_PATH = "AGENT_SESSIONS/.bindings.json";
|
|
35
|
-
const BINDINGS_VERSION = 1;
|
|
36
|
-
let domainStore = null;
|
|
37
|
-
/**
|
|
38
|
-
* 注入域句柄(装载期调用一次)。传 `null` = 域不可用 ⇒ 全部退回旧文件行为。
|
|
39
|
-
* 幂等:重复调用以后者为准(HMR / 重载场景)。
|
|
40
|
-
*/
|
|
41
|
-
function setBindingStore(store) {
|
|
42
|
-
domainStore = store;
|
|
43
|
-
}
|
|
44
|
-
/** 域记录 → 本模块记录形状(同名字段,无需映射表;见 host/storage-domain.ts 头注) */
|
|
45
|
-
function fromDomainRecord(rec) {
|
|
46
|
-
return {
|
|
47
|
-
dirName: rec.dirName,
|
|
48
|
-
mdPath: rec.mdPath,
|
|
49
|
-
...rec.sessionId ? { sessionId: rec.sessionId } : {},
|
|
50
|
-
action: rec.action ?? "activate",
|
|
51
|
-
at: typeof rec.at === "number" ? rec.at : 0,
|
|
52
|
-
...rec.note ? { note: rec.note } : {},
|
|
53
|
-
...rec.createdAsUser === true ? { createdAsUser: true } : {},
|
|
54
|
-
...rec.supersededAt !== void 0 ? { supersededAt: rec.supersededAt } : {}
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
/** 本模块记录 → 域记录形状 */
|
|
58
|
-
function toDomainRecord(rec) {
|
|
59
|
-
return {
|
|
60
|
-
dirName: rec.dirName,
|
|
61
|
-
mdPath: rec.mdPath,
|
|
62
|
-
...rec.sessionId ? { sessionId: rec.sessionId } : {},
|
|
63
|
-
action: rec.action,
|
|
64
|
-
at: rec.at,
|
|
65
|
-
...rec.note ? { note: rec.note } : {},
|
|
66
|
-
...rec.createdAsUser === true ? { createdAsUser: true } : {},
|
|
67
|
-
...rec.supersededAt !== void 0 ? { supersededAt: rec.supersededAt } : {}
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* §0L **一次性迁移**:把某个 CCC 的旧 `.bindings.json` 全量灌入宿主存储域。
|
|
72
|
-
*
|
|
73
|
-
* 设计要点(R↓):
|
|
74
|
-
* - **幂等**:只写域里**尚不存在**的键(`skip existing`)。⇒ 重复调用(每次启动都调)
|
|
75
|
-
* 不会覆盖域里的新状态,也不会因并发而互相踩踏。
|
|
76
|
-
* - **不删旧文件**:迁移只读不写旧表(owner「向前兼容」的第二道保险仍在)。
|
|
77
|
-
* - **不丢绑定**:逐条迁移,单条失败不影响其余(返回计数供诊断)。
|
|
78
|
-
* - **失败静默**:域写不可用 ⇒ 返回 `{migrated:0, skipped:0, failed:0}`,不抛错
|
|
79
|
-
* (装载期调用,不能成为启动单点)。
|
|
80
|
-
*
|
|
81
|
-
* @param root CCC 根(`AGENT_SESSIONS/.bindings.json` 所在)
|
|
82
|
-
* @returns 迁移计数(诊断用;`migrated` = 本次真正灌入的条数)
|
|
83
|
-
*/
|
|
84
|
-
function migrateBindingsToDomain(root) {
|
|
85
|
-
const out = {
|
|
86
|
-
migrated: 0,
|
|
87
|
-
skipped: 0,
|
|
88
|
-
failed: 0
|
|
89
|
-
};
|
|
90
|
-
if (!domainStore?.available) return out;
|
|
91
|
-
let file;
|
|
92
|
-
try {
|
|
93
|
-
file = readBindingsFile(join(root, BINDINGS_REL_PATH));
|
|
94
|
-
} catch {
|
|
95
|
-
return out;
|
|
96
|
-
}
|
|
97
|
-
for (const [id, raw] of Object.entries(file.sessions)) {
|
|
98
|
-
const rec = asBoundRecord(raw);
|
|
99
|
-
if (!rec) continue;
|
|
100
|
-
if (domainStore.get(id)) {
|
|
101
|
-
out.skipped += 1;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
try {
|
|
105
|
-
domainStore.put(id, toDomainRecord(rec)).catch(() => {});
|
|
106
|
-
out.migrated += 1;
|
|
107
|
-
} catch {
|
|
108
|
-
out.failed += 1;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (out.migrated > 0) migratedRoots.add(root);
|
|
112
|
-
return out;
|
|
113
|
-
}
|
|
114
|
-
/** 已补过迁移的 CCC 根(进程内去重;跨进程各补一次是可接受的——迁移幂等) */
|
|
115
|
-
const migratedRoots = /* @__PURE__ */ new Set();
|
|
116
|
-
/**
|
|
117
|
-
* **按 CCC 惰性补迁移**(§0L 修正,S142 2026-09-19)。
|
|
118
|
-
*
|
|
119
|
-
* 🔴 为什么需要它(实测缺陷,R↓):
|
|
120
|
-
* 装载期那次迁移用的是 `process.cwd()` —— 而 dsh **服务进程的 cwd 是用户家目录**,
|
|
121
|
-
* **不是 CCC 根**(实测 `acc-diag`:`进程 cwd: <家目录> | 进程 CCC: (无)`)。
|
|
122
|
-
* ⇒ 迁移读的是一个**不存在的文件**,静默迁移 0 条
|
|
123
|
-
* (症状:发布后 `~/.dsh/storages/serenity_bindings.json` **没出现**)。
|
|
124
|
-
*
|
|
125
|
-
* 现在改为:**任何一次能定位到 CCC 根的路径**都顺手补一次迁移(幂等 + 进程内去重)。
|
|
126
|
-
* 这样无论服务从哪个 cwd 启动,只要那条 CCC 有会话活动,它的绑定就会被带上。
|
|
127
|
-
*
|
|
128
|
-
* @param root CCC 根
|
|
129
|
-
*/
|
|
130
|
-
function ensureBindingsMigrated(root) {
|
|
131
|
-
if (!domainStore?.available) return;
|
|
132
|
-
if (migratedRoots.has(root)) return;
|
|
133
|
-
try {
|
|
134
|
-
migrateBindingsToDomain(root);
|
|
135
|
-
} catch {}
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* 域内读一条(同步)。域不可用 → `undefined`(调用方据此回落文件)。
|
|
139
|
-
* @param sessionId dsh 会话 id
|
|
140
|
-
*/
|
|
141
|
-
function readFromDomain(sessionId) {
|
|
142
|
-
if (!domainStore?.available) return void 0;
|
|
143
|
-
const rec = domainStore.get(sessionId);
|
|
144
|
-
return rec ? fromDomainRecord(rec) : void 0;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* 域内取**某轨迹的全部绑定**(同步;已排除 superseded —— 与 `listBoundSessionIds` 同判据)。
|
|
148
|
-
* 域不可用 → `null`(**注意与"空结果"区分**:null = 回落文件,[] = 域里确实没有)。
|
|
149
|
-
*/
|
|
150
|
-
function listFromDomain(dirName) {
|
|
151
|
-
if (!domainStore?.available) return null;
|
|
152
|
-
const out = [];
|
|
153
|
-
for (const [id, raw] of domainStore.entries()) {
|
|
154
|
-
const rec = fromDomainRecord(raw);
|
|
155
|
-
if (rec.dirName !== dirName) continue;
|
|
156
|
-
out.push({
|
|
157
|
-
id,
|
|
158
|
-
rec
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
function sessionHeader(session) {
|
|
164
|
-
const header = session?.header;
|
|
165
|
-
if (!header || typeof header !== "object") return null;
|
|
166
|
-
return header;
|
|
167
|
-
}
|
|
168
|
-
/** 绑定文件绝对路径(由会话 cwd 上溯 .serenity 定位 CCC 根);无法定位返回 null */
|
|
169
|
-
function bindingsPathFor(session) {
|
|
170
|
-
const header = sessionHeader(session);
|
|
171
|
-
if (typeof header?.cwd !== "string") return null;
|
|
172
|
-
const root = cccRootForCwd(header.cwd);
|
|
173
|
-
if (!root) return null;
|
|
174
|
-
ensureBindingsMigrated(root);
|
|
175
|
-
return join(root, BINDINGS_REL_PATH);
|
|
176
|
-
}
|
|
177
|
-
function readBindingsFile(path) {
|
|
178
|
-
if (!existsSync(path)) return {
|
|
179
|
-
version: BINDINGS_VERSION,
|
|
180
|
-
sessions: {}
|
|
181
|
-
};
|
|
182
|
-
try {
|
|
183
|
-
const sessions = JSON.parse(readFileSync(path, "utf-8")).sessions;
|
|
184
|
-
if (!sessions || typeof sessions !== "object") return {
|
|
185
|
-
version: BINDINGS_VERSION,
|
|
186
|
-
sessions: {}
|
|
187
|
-
};
|
|
188
|
-
return {
|
|
189
|
-
version: BINDINGS_VERSION,
|
|
190
|
-
sessions
|
|
191
|
-
};
|
|
192
|
-
} catch {
|
|
193
|
-
return {
|
|
194
|
-
version: BINDINGS_VERSION,
|
|
195
|
-
sessions: {}
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
/** 原子写:同目录 tmp + rename(避免半写文件被后续读取) */
|
|
200
|
-
function writeBindingsFile(path, data) {
|
|
201
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
202
|
-
const tmp = `${path}.tmp`;
|
|
203
|
-
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
204
|
-
renameSync(tmp, path);
|
|
205
|
-
}
|
|
206
|
-
/** 记录形状校验(容忍手改/旧数据) */
|
|
207
|
-
function asBoundRecord(value) {
|
|
208
|
-
const r = value;
|
|
209
|
-
if (!r || typeof r.dirName !== "string" || typeof r.mdPath !== "string") return null;
|
|
210
|
-
return r;
|
|
211
|
-
}
|
|
212
|
-
/** 旧形态:从会话日志扫最后一条 `serenity/bound`(兼容 v1.30.5 及更早写入的绑定) */
|
|
213
|
-
function readLegacyBoundFromEvents(session) {
|
|
214
|
-
const events = sessionEvents(session);
|
|
215
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
216
|
-
const ev = events[i];
|
|
217
|
-
if (!ev || ev.type !== SESSION_BOUND_EVENT) continue;
|
|
218
|
-
const rec = asBoundRecord(ev.data);
|
|
219
|
-
if (rec) return rec;
|
|
220
|
-
}
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* 读取会话当前绑定(权威,latest-wins)。
|
|
225
|
-
*
|
|
226
|
-
* 读取顺序(§0L,S142 2026-09-19):
|
|
227
|
-
* 1. **宿主存储域**(域可用时的权威来源);
|
|
228
|
-
* 2. **旧文件** `.bindings.json`(域不可用 ⇒ 零回归回落;域可用但该会话未迁移 ⇒ 兜底);
|
|
229
|
-
* 3. **旧事件形态** `serenity/bound`(v1.30.5 及更早的存量)。
|
|
230
|
-
* 无绑定返回 null。
|
|
231
|
-
*/
|
|
232
|
-
function readLastBound(session) {
|
|
233
|
-
const id = sessionHeader(session)?.id;
|
|
234
|
-
if (typeof id === "string") {
|
|
235
|
-
const fromDomain = readFromDomain(id);
|
|
236
|
-
if (fromDomain) return fromDomain;
|
|
237
|
-
}
|
|
238
|
-
const path = bindingsPathFor(session);
|
|
239
|
-
if (path && typeof id === "string") {
|
|
240
|
-
const rec = asBoundRecord(readBindingsFile(path).sessions[id]);
|
|
241
|
-
if (rec) return rec;
|
|
242
|
-
}
|
|
243
|
-
return readLegacyBoundFromEvents(session);
|
|
244
|
-
}
|
|
245
|
-
/** 目录名 → 轨迹标签(取固定位置编码;非本工具建的目录回退整名——编码无关 U4) */
|
|
246
|
-
function cognitiveTrajectoryLabel(dirName) {
|
|
247
|
-
return parseTrajectoryCode(dirName) ?? dirName;
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* **调用方自身**所属 cognitive trajectory 的标签(审计字段 `createdBy` 的唯一正解)。
|
|
251
|
-
*
|
|
252
|
-
* 三级来源,逐级回退(R↓:为什么不用单一来源):
|
|
253
|
-
* 1. `readLastBound(session)` —— **持久 bound**,权威(重启存活、不受别的会话激活污染)
|
|
254
|
-
* 2. `getActiveSessionInfo(scope)` —— 本 dsh 会话的**内存**激活(按 scope 隔离)
|
|
255
|
-
* 3. `getLastActiveSessionInfo()` —— **全局**最近激活指针(无绑定的会话兼容回退)
|
|
256
|
-
*
|
|
257
|
-
* ⚠️ 为何单列成本函数(S142 §30.12.1 实测缺陷):唤醒注册表的 `createdBy` 原先直接取第 3 级
|
|
258
|
-
* 全局指针,而它记录的是"**进程内最近一次被激活的 cognitive trajectory**"——多会话并发时必然张冠李戴
|
|
259
|
-
* (实测:S142 登记唤醒被写成 `by=S060`)。`createdBy` 是 D60「以可审计替代入口栅」的**唯一凭证**,
|
|
260
|
-
* 失真即替代控制失效 ⇒ 必须以"调用方自己是谁"为准,全局指针只作最后兜底。
|
|
261
|
-
*
|
|
262
|
-
* @param session 调用方 dsh 会话对象(需 `header.id` + `header.cwd`)
|
|
263
|
-
* @param scope 调用方 dsh 会话 id(取不到传空串)
|
|
264
|
-
* @returns 轨迹标签(`S###` 或完整目录名);三级全落空 → 空串(渲染为 `?`)
|
|
265
|
-
*/
|
|
266
|
-
function resolveSessionCognitiveTrajectoryLabel(session, scope) {
|
|
267
|
-
const bound = session ? readLastBound(session) : null;
|
|
268
|
-
if (bound?.dirName) return cognitiveTrajectoryLabel(bound.dirName);
|
|
269
|
-
const scoped = scope !== "" ? getActiveSessionInfo(scope) : null;
|
|
270
|
-
if (scoped) return scoped.sessionId;
|
|
271
|
-
return getLastActiveSessionInfo()?.sessionId ?? "";
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* 反向查:绑定到某 cognitive trajectory 目录名的 dsh 会话 id(**按绑定时间倒序,最新在前**)。
|
|
275
|
-
*
|
|
276
|
-
* 用途(wake-scheduler 冷唤醒):.bindings.json 以 **sessionId 为键**,而唤醒条目的
|
|
277
|
-
* target 是**目录名**——需要值侧扫描做反向索引。冷会话被唤醒时必须先知道"哪个 dsh
|
|
278
|
-
* 会话承载这条轨迹",这是唯一的权威来源(标题只作回退,见 v1.29.2 R2)。
|
|
279
|
-
*
|
|
280
|
-
* @param root CCC 根
|
|
281
|
-
* @param dirName AGENT_SESSIONS 目录名(完整名,如 `2026-09-01--S151--x--auto`)
|
|
282
|
-
* @returns 会话 id 列表(最新绑定在前);无绑定 → `[]`
|
|
283
|
-
*/
|
|
284
|
-
function listBoundSessionIds(root, dirName) {
|
|
285
|
-
const hits = [];
|
|
286
|
-
const fromDomain = listFromDomain(dirName);
|
|
287
|
-
if (fromDomain !== null) {
|
|
288
|
-
for (const { id, rec } of fromDomain) {
|
|
289
|
-
if (rec.supersededAt !== void 0) continue;
|
|
290
|
-
hits.push({
|
|
291
|
-
id,
|
|
292
|
-
at: typeof rec.at === "number" ? rec.at : 0
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
if (hits.length > 0) return hits.sort((a, b) => b.at - a.at).map((h) => h.id);
|
|
296
|
-
}
|
|
297
|
-
const file = readBindingsFile(join(root, BINDINGS_REL_PATH));
|
|
298
|
-
for (const [id, raw] of Object.entries(file.sessions)) {
|
|
299
|
-
const rec = asBoundRecord(raw);
|
|
300
|
-
if (!rec || rec.dirName !== dirName) continue;
|
|
301
|
-
if (rec.supersededAt !== void 0) continue;
|
|
302
|
-
if (hits.some((h) => h.id === id)) continue;
|
|
303
|
-
hits.push({
|
|
304
|
-
id,
|
|
305
|
-
at: typeof rec.at === "number" ? rec.at : 0
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
return hits.sort((a, b) => b.at - a.at).map((h) => h.id);
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* 🔴 **「只留一条」的唯一判据**(2026-09-21 收进单一实现;此前同一判据在域侧/文件侧各写一遍)。
|
|
312
|
-
*
|
|
313
|
-
* 判据:同 `dirName` ∧ **不是** `keepIds` 里的 ∧ **尚未** `supersededAt`。
|
|
314
|
-
* 域侧与文件侧**同判据**——历史缺陷(2026-09-19)正是"只在一侧收集 ⇒ 返回值恒空",
|
|
315
|
-
* 见 {@link supersedeOtherBindings} 的注释。
|
|
316
|
-
*
|
|
317
|
-
* @param fileSessions 旧文件表的记录集
|
|
318
|
-
* @param domainEntries 域侧条目(域不可用时传 `undefined` = 不参与判定)
|
|
319
|
-
*/
|
|
320
|
-
function collectSupersedeTargets(dirName, keepIds, fileSessions, domainEntries) {
|
|
321
|
-
const toMark = /* @__PURE__ */ new Set();
|
|
322
|
-
if (domainEntries) for (const [id, raw] of domainEntries) {
|
|
323
|
-
const rec = fromDomainRecord(raw);
|
|
324
|
-
if (rec.dirName !== dirName) continue;
|
|
325
|
-
if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
|
|
326
|
-
toMark.add(id);
|
|
327
|
-
}
|
|
328
|
-
for (const [id, raw] of Object.entries(fileSessions)) {
|
|
329
|
-
const rec = asBoundRecord(raw);
|
|
330
|
-
if (!rec || rec.dirName !== dirName) continue;
|
|
331
|
-
if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
|
|
332
|
-
toMark.add(id);
|
|
333
|
-
}
|
|
334
|
-
return toMark;
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
|
|
338
|
-
* 删除指向**已不存在**的 dsh 会话的绑定记录。
|
|
339
|
-
*
|
|
340
|
-
* 为什么需要:`.bindings.json` **只增不清**,而 DSH 会话目录会被
|
|
341
|
-
* `session-cleanup`(本仓既有机制)物理删除 ⇒ 表里必然残留**悬空 id**。
|
|
342
|
-
* 悬空记录本身无害,但它会**污染反向索引**(`listBoundSessionIds` 是唤醒选人的唯一入口),
|
|
343
|
-
* 让"这条轨迹到底还有没有可用会话"变得不可判。
|
|
344
|
-
*
|
|
345
|
-
* 🔴 **安全性由调用方的判据负责**(本函数是纯函数,不认识文件系统):
|
|
346
|
-
* `isMissing` 必须是 **fail-closed** 的(读不到 ⇒ 返回 false = "不当作缺失"),
|
|
347
|
-
* 见 `hasSessionLogById`。判据写错会**清掉整张表**,故此处刻意不提供默认实现。
|
|
348
|
-
*
|
|
349
|
-
* @param root CCC 根
|
|
350
|
-
* @param isMissing 判据:该会话 id 是否**确认**已不存在(false = 保留)
|
|
351
|
-
* @returns 被删除的会话 id(升序稳定;未删任何一条 → `[]`)
|
|
352
|
-
*/
|
|
353
|
-
function pruneMissingBindings(root, isMissing) {
|
|
354
|
-
const path = join(root, BINDINGS_REL_PATH);
|
|
355
|
-
const file = readBindingsFile(path);
|
|
356
|
-
const toRemove = /* @__PURE__ */ new Set();
|
|
357
|
-
const judge = (id) => {
|
|
358
|
-
try {
|
|
359
|
-
return isMissing(id);
|
|
360
|
-
} catch {
|
|
361
|
-
return false;
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
if (domainStore?.available) {
|
|
365
|
-
for (const [id] of domainStore.entries()) if (judge(id)) toRemove.add(id);
|
|
366
|
-
}
|
|
367
|
-
for (const id of Object.keys(file.sessions)) if (judge(id)) toRemove.add(id);
|
|
368
|
-
if (toRemove.size === 0) return [];
|
|
369
|
-
if (domainStore?.available) for (const id of toRemove) domainStore.delete(id).catch(() => {});
|
|
370
|
-
for (const id of toRemove) delete file.sessions[id];
|
|
371
|
-
try {
|
|
372
|
-
writeBindingsFile(path, file);
|
|
373
|
-
} catch {}
|
|
374
|
-
return [...toRemove].sort();
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* 写入绑定(latest-wins 覆盖该会话记录)。
|
|
378
|
-
*
|
|
379
|
-
* 绑定持久化尽力而为:无法定位 CCC 根 / 无会话 id / 写盘失败 → 返回 false,不阻断主流程。
|
|
380
|
-
*
|
|
381
|
-
* 🔴 **同时是「只留一条」(D69)的唯一写入口**(2026-09-21 起):每写一条,就在**同一次 RMW** 里
|
|
382
|
-
* 把同轨迹的**其它**条标 `supersededAt`(自己除外)。⇒ 任何调用路径(`create`/`activate`/`switch`/
|
|
383
|
-
* `rebuild`/`reconcile`)**都自动满足"只留一条"**,调用方**不需要**(也**不应当**)自己算 selfId。
|
|
384
|
-
*
|
|
385
|
-
* @param session 目标 dsh 会话(需 `header.id` + `header.cwd` 可定位 CCC 根)
|
|
386
|
-
* @param action 绑定动作
|
|
387
|
-
* @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
|
|
388
|
-
*/
|
|
389
|
-
function appendBound(session, action, rec) {
|
|
390
|
-
const path = bindingsPathFor(session);
|
|
391
|
-
const id = sessionHeader(session)?.id;
|
|
392
|
-
if (typeof id !== "string") return false;
|
|
393
|
-
const record = {
|
|
394
|
-
dirName: rec.dirName,
|
|
395
|
-
mdPath: rec.mdPath,
|
|
396
|
-
...rec.sessionId ? { sessionId: rec.sessionId } : {},
|
|
397
|
-
action,
|
|
398
|
-
at: Date.now(),
|
|
399
|
-
...rec.note ? { note: rec.note } : {},
|
|
400
|
-
...rec.createdAsUser === true ? { createdAsUser: true } : {}
|
|
401
|
-
};
|
|
402
|
-
if (domainStore?.available) domainStore.put(id, toDomainRecord(record)).catch(() => {});
|
|
403
|
-
if (!path) return domainStore?.available === true;
|
|
404
|
-
try {
|
|
405
|
-
const file = readBindingsFile(path);
|
|
406
|
-
file.sessions[id] = record;
|
|
407
|
-
const targets = collectSupersedeTargets(rec.dirName, /* @__PURE__ */ new Set([id]), file.sessions, domainStore?.available ? domainStore.entries() : void 0);
|
|
408
|
-
for (const other of targets) {
|
|
409
|
-
const otherRec = asBoundRecord(file.sessions[other]);
|
|
410
|
-
if (!otherRec) continue;
|
|
411
|
-
otherRec.supersededAt = record.at;
|
|
412
|
-
file.sessions[other] = otherRec;
|
|
413
|
-
}
|
|
414
|
-
writeBindingsFile(path, file);
|
|
415
|
-
if (domainStore?.available) for (const other of targets) {
|
|
416
|
-
const raw = domainStore.get(other);
|
|
417
|
-
if (!raw) continue;
|
|
418
|
-
domainStore.put(other, toDomainRecord({
|
|
419
|
-
...fromDomainRecord(raw),
|
|
420
|
-
supersededAt: record.at
|
|
421
|
-
})).catch(() => {});
|
|
422
|
-
}
|
|
423
|
-
return true;
|
|
424
|
-
} catch {
|
|
425
|
-
return domainStore?.available === true;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
//#endregion
|
|
429
|
-
//#region src/kernel/message-source.ts
|
|
430
|
-
/** ACC 注入消息的 kind —— 唤醒 / 打回 / keeper 提醒 / 锚定轮 / rebuild 交接等一律同值。 */
|
|
431
|
-
const ACC_MESSAGE_KIND = "plugin:dsh-serenity-hooks";
|
|
432
|
-
/** ACC 注入消息的统一来源值(各缝共用;需要 `form` 时按 `ContextFormed` 另加字段)。 */
|
|
433
|
-
const PLUGIN_SOURCE = { kind: ACC_MESSAGE_KIND };
|
|
434
|
-
//#endregion
|
|
435
|
-
export { pruneMissingBindings as a, setBindingStore as c, listBoundSessionIds as i, PLUGIN_SOURCE as n, readLastBound as o, appendBound as r, resolveSessionCognitiveTrajectoryLabel as s, ACC_MESSAGE_KIND as t };
|