@shgroup/dsh-serenity-hooks 1.39.2 → 1.39.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.39.2",
3
+ "version": "1.39.3",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -2,11 +2,12 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as hostSettings, c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
3
3
  import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, h as resolveInside, i as findGitRoot, l as pathInside, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-NlLr_sxy.js";
4
4
  import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-Bd_SjEs7.js";
5
- import { C as localFileStamp, S as isoLocal, _ as sessionsRoot, a as DEFAULT_SESSION_SCOPE, b as summarize, c as createSession, d as getActiveSessionInfo, f as listSessions, g as sessionEvents$1, h as resolveSessionByTitle, i as resolveSessionTrajectoryLabel, l as extractSessionMdPathFromText, m as readActiveSessionMd, o as TRAJECTORY_ACTIONS, p as parseSessionContextFromEvents, r as readLastBound, s as clearActiveSessionInfo, t as appendBound, u as findSession, v as setActiveSessionInfo, x as useSession, y as showSession } from "./trajectory-bound-OyuNkn6e.js";
5
+ import { C as useSession, S as summarize, T as localFileStamp, _ as resolveSessionByTitle, a as resolveSessionTrajectoryLabel, b as setActiveSessionInfo, c as TRAJECTORY_ACTIONS, d as extractSessionMdPathFromText, f as findSession, g as readActiveSessionMd, h as parseSessionContextFromEvents, i as readLastBound, l as clearActiveSessionInfo, m as listSessions, o as supersedeOtherBindings, p as getActiveSessionInfo, r as pruneMissingBindings, s as DEFAULT_SESSION_SCOPE, t as appendBound, u as createSession, v as sessionEvents$1, w as isoLocal, x as showSession, y as sessionsRoot } from "./trajectory-bound-B9o_4CMF.js";
6
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-B2ajFypI.js";
7
7
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-CEHL5cek.js";
8
8
  import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-FTjoWQTQ.js";
9
- import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-D0HDjEO8.js";
9
+ import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
10
+ import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-CCpCk-7r.js";
10
11
  import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-DMDUoQum.js";
11
12
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
12
13
  import z from "@deepseek-ai/schemastery";
@@ -3285,6 +3286,13 @@ function createTrajectoryTool(ctx) {
3285
3286
  context: active.context
3286
3287
  };
3287
3288
  if (advisory) out.advisory = advisory;
3289
+ if (dsh) {
3290
+ const selfId = String(dsh.header?.id ?? "");
3291
+ const superseded = supersedeOtherBindings(root, info?.dirName ?? targetDirName, selfId === "" ? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set([selfId]));
3292
+ if (superseded.length > 0) out.supersededBindings = superseded;
3293
+ const pruned = pruneMissingBindings(root, (id) => !hasSessionLogById(sessionsRootDir(), id));
3294
+ if (pruned.length > 0) out.prunedBindings = pruned;
3295
+ }
3288
3296
  return out;
3289
3297
  }
3290
3298
  case "rebuild": {
@@ -3340,7 +3348,7 @@ function createTrajectoryTool(ctx) {
3340
3348
  "✓ 已即时投递",
3341
3349
  ` · ${res.detail}`,
3342
3350
  " · 语义:**不排队** —— 目标正在跑轮次则**当场注入当前轮**(steer),空闲则立即起一轮(followup)。",
3343
- " · ⚠️ 回执只到「已注入/已入队」——**不表示**目标已执行或已答复。判\"目标真的动了\"须看它自己的 SESSION.md(文件级判据)。"
3351
+ " · ⚠️ 回执只到「已注入 / 已起轮」——**不表示**目标已执行或已答复。判\"目标真的动了\"须看它自己的 SESSION.md(文件级判据)。"
3344
3352
  ].join("\n")
3345
3353
  };
3346
3354
  }
@@ -7029,7 +7037,7 @@ function registerStatusApi(ctx, opts = {}) {
7029
7037
  const id = s.id ?? s.header?.id;
7030
7038
  if (id) liveIds.add(id);
7031
7039
  }
7032
- const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await import("./session-cleanup-B6IHZgww.js");
7040
+ const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await import("./session-cleanup-BtkSmry8.js").then((n) => n.n);
7033
7041
  const root = sessionsRootDir();
7034
7042
  if (req.method === "GET") {
7035
7043
  const { candidates } = performCleanup(root, cutoffDaysAgo(days), liveIds, { dryRun: true });
@@ -1,3 +1,4 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
1
2
  import { join } from "node:path";
2
3
  import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
3
4
  //#region src/session-cleanup.ts
@@ -19,6 +20,16 @@ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
19
20
  *
20
21
  * 本模块纯逻辑(零 ctx 依赖):传入 sessionsRoot + liveIds,可单测。
21
22
  */
23
+ var session_cleanup_exports = /* @__PURE__ */ __exportAll({
24
+ collectEligibleSessions: () => collectEligibleSessions,
25
+ cutoffDaysAgo: () => cutoffDaysAgo,
26
+ findSessionLog: () => findSessionLog,
27
+ hasSessionLogById: () => hasSessionLogById,
28
+ performCleanup: () => performCleanup,
29
+ sessionLogArtifacts: () => sessionLogArtifacts,
30
+ sessionLogBytesById: () => sessionLogBytesById,
31
+ sessionsRootDir: () => sessionsRootDir
32
+ });
22
33
  /** DSH 会话 root:env DSH_HOME → ~/.dsh(config-ops globalConfigPath 同款推导) */
23
34
  function sessionsRootDir() {
24
35
  const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
@@ -142,9 +153,61 @@ function performCleanup(root, cutoffMs, liveIds = /* @__PURE__ */ new Set(), opt
142
153
  function dirOf(logPath) {
143
154
  return logPath.slice(0, logPath.lastIndexOf("/")) || logPath;
144
155
  }
156
+ /**
157
+ * 某一 dsh 会话(目录名 / 会话 id)的**当前世代日志体积**(字节)。
158
+ *
159
+ * 为什么要它(S142 §0y「旧会话累积 ⇒ dsh 内存崩溃」,所有者令):
160
+ * · 宿主恢复/继续一条会话必须**重放全部事件**(`scanLog` → `materializeAppendBatch`)
161
+ * ⇒ **代价 ∝ 日志体积**;
162
+ * · 实测(`_tmp/measure-session-load.mjs`,照宿主 `scanZstdFrames` 复刻):
163
+ * 一条 **72.2 MB**(zstd)的日志 = 35 559 个 frame / 252.6 MB 文本 / 62 680 条事件,
164
+ * **光是读一遍峰值 RSS +1 175 MB**(探针什么都不保留);
165
+ * · 而宿主的解析缓存 `COLD_LOG_MEMO_MAX_ENTRIES = 2` 是**按条数限、不按字节限**的
166
+ * (`dsh-session-persistence-jsonl/lib/index.js:2175`)⇒ 两条巨日志 = 上限内。
167
+ * 🔴 **在本函数之前,ACC 侧没有任何地方知道"绑定的这条会话日志多大"——这个信号根本不存在。**
168
+ * 这是"靠 agent 自觉 rebuild 不够"的机制层原因(keeper 的 K 量的是**模型上下文**,压缩后回落;
169
+ * 而**日志文件只增不减** ⇒ rebuild 提醒永远不会因为"日志 72 MB"触发)。
170
+ *
171
+ * 只认会话目录(含任意世代日志);返回**最高世代**的日志(= 宿主 `findLog` 选中的当前世代)。
172
+ * 未命中 / 不可读 → null(非错误,调用方按"未知"处理)。
173
+ * @param root - DSH 会话 root({@link sessionsRootDir})
174
+ * @param sessionId - 会话目录名(`.bindings.json` 的键,含 `session-` 前缀)或裸 id
175
+ * @returns 字节数 + 日志绝对路径;未命中 → null
176
+ */
177
+ function sessionLogBytesById(root, sessionId) {
178
+ if (sessionId === "" || !existsSync(root)) return null;
179
+ const dirName = sessionId.startsWith("session-") ? sessionId : `session-${sessionId}`;
180
+ for (const project of readdirSync(root, { withFileTypes: true })) {
181
+ if (!project.isDirectory()) continue;
182
+ const log = findSessionLog(join(root, project.name, dirName));
183
+ if (log === null) continue;
184
+ try {
185
+ return {
186
+ bytes: statSync(log).size,
187
+ path: log
188
+ };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ /**
196
+ * **(c) 绑定一致性兜底**的判据:某会话的日志**是否仍在磁盘上**。
197
+ *
198
+ * 🔴 **fail-closed**:sessions root 本身不存在时**一律返回 true(当作"在")**——
199
+ * 否则换 `DSH_HOME` / 换机器会让"全部绑定看起来都悬空",进而误清整张绑定表。
200
+ * 只有"root 在、而这个会话目录确实不在"才算悬空。
201
+ * @param sessionRoot - DSH 会话 root({@link sessionsRootDir})
202
+ * @param sessionId - 会话目录名(`.bindings.json` 的键)或裸 id
203
+ */
204
+ function hasSessionLogById(sessionRoot, sessionId) {
205
+ if (!existsSync(sessionRoot)) return true;
206
+ return sessionLogBytesById(sessionRoot, sessionId) !== null;
207
+ }
145
208
  /** 便捷:默认 N 天前为 cutoff(供 API/调用方) */
146
209
  function cutoffDaysAgo(days, nowMs = Date.now()) {
147
210
  return nowMs - days * DAY_MS;
148
211
  }
149
212
  //#endregion
150
- export { cutoffDaysAgo, performCleanup, sessionsRootDir };
213
+ export { session_cleanup_exports as n, sessionsRootDir as r, hasSessionLogById as t };
@@ -73,6 +73,41 @@ export declare function performCleanup(root: string, cutoffMs: number, liveIds?:
73
73
  candidates: CandidateSession[];
74
74
  result: CleanupResult | null;
75
75
  };
76
+ /**
77
+ * 某一 dsh 会话(目录名 / 会话 id)的**当前世代日志体积**(字节)。
78
+ *
79
+ * 为什么要它(S142 §0y「旧会话累积 ⇒ dsh 内存崩溃」,所有者令):
80
+ * · 宿主恢复/继续一条会话必须**重放全部事件**(`scanLog` → `materializeAppendBatch`)
81
+ * ⇒ **代价 ∝ 日志体积**;
82
+ * · 实测(`_tmp/measure-session-load.mjs`,照宿主 `scanZstdFrames` 复刻):
83
+ * 一条 **72.2 MB**(zstd)的日志 = 35 559 个 frame / 252.6 MB 文本 / 62 680 条事件,
84
+ * **光是读一遍峰值 RSS +1 175 MB**(探针什么都不保留);
85
+ * · 而宿主的解析缓存 `COLD_LOG_MEMO_MAX_ENTRIES = 2` 是**按条数限、不按字节限**的
86
+ * (`dsh-session-persistence-jsonl/lib/index.js:2175`)⇒ 两条巨日志 = 上限内。
87
+ * 🔴 **在本函数之前,ACC 侧没有任何地方知道"绑定的这条会话日志多大"——这个信号根本不存在。**
88
+ * 这是"靠 agent 自觉 rebuild 不够"的机制层原因(keeper 的 K 量的是**模型上下文**,压缩后回落;
89
+ * 而**日志文件只增不减** ⇒ rebuild 提醒永远不会因为"日志 72 MB"触发)。
90
+ *
91
+ * 只认会话目录(含任意世代日志);返回**最高世代**的日志(= 宿主 `findLog` 选中的当前世代)。
92
+ * 未命中 / 不可读 → null(非错误,调用方按"未知"处理)。
93
+ * @param root - DSH 会话 root({@link sessionsRootDir})
94
+ * @param sessionId - 会话目录名(`.bindings.json` 的键,含 `session-` 前缀)或裸 id
95
+ * @returns 字节数 + 日志绝对路径;未命中 → null
96
+ */
97
+ export declare function sessionLogBytesById(root: string, sessionId: string): {
98
+ bytes: number;
99
+ path: string;
100
+ } | null;
101
+ /**
102
+ * **(c) 绑定一致性兜底**的判据:某会话的日志**是否仍在磁盘上**。
103
+ *
104
+ * 🔴 **fail-closed**:sessions root 本身不存在时**一律返回 true(当作"在")**——
105
+ * 否则换 `DSH_HOME` / 换机器会让"全部绑定看起来都悬空",进而误清整张绑定表。
106
+ * 只有"root 在、而这个会话目录确实不在"才算悬空。
107
+ * @param sessionRoot - DSH 会话 root({@link sessionsRootDir})
108
+ * @param sessionId - 会话目录名(`.bindings.json` 的键)或裸 id
109
+ */
110
+ export declare function hasSessionLogById(sessionRoot: string, sessionId: string): boolean;
76
111
  /** 便捷:默认 N 天前为 cutoff(供 API/调用方) */
77
112
  export declare function cutoffDaysAgo(days: number, nowMs?: number): number;
78
113
  export {};
@@ -644,6 +644,7 @@ function listBoundSessionIds(root, dirName) {
644
644
  for (const [id, raw] of Object.entries(file.sessions)) {
645
645
  const rec = asBoundRecord(raw);
646
646
  if (!rec || rec.dirName !== dirName) continue;
647
+ if (rec.supersededAt !== void 0) continue;
647
648
  hits.push({
648
649
  id,
649
650
  at: typeof rec.at === "number" ? rec.at : 0
@@ -652,6 +653,82 @@ function listBoundSessionIds(root, dirName) {
652
653
  return hits.sort((a, b) => b.at - a.at).map((h) => h.id);
653
654
  }
654
655
  /**
656
+ * **(b) 同一 trajectory 多 live 会话的机械守卫**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
657
+ *
658
+ * 把绑定到 `dirName` 的**其它**会话 id 标记为 `supersededAt` —— **保留记录**(沿革/正向查询不丢),
659
+ * 只切断"**唤醒还会找到它**"这条路。语义上即"解绑",且**不动那个会话本身**(它仍可被人工使用)。
660
+ *
661
+ * 为什么必须有机制(R↓):`.bindings.json` 的语义是"latest-wins 覆盖**本条会话记录**",
662
+ * 对**同一 trajectory 的其它会话**没有任何约束 ⇒ 多代会话自然堆积(实测 S142 4 代 / S185 2 代),
663
+ * 而 `acquireWakeAgent` 是"遍历全部绑定 id、任一条 live 就用它"——**两条 live 都能被选中**。
664
+ *
665
+ * @param root CCC 根
666
+ * @param dirName 目标 trajectory 目录名
667
+ * @param keepIds 保留(不标记)的会话 id 集合——通常是"当前正在 use 的这条"
668
+ * @returns **实际被标记**的会话 id(升序稳定;供调用方如实报告,未标记任何一条 → `[]`)
669
+ */
670
+ function supersedeOtherBindings(root, dirName, keepIds) {
671
+ const path = join(root, BINDINGS_REL_PATH);
672
+ const file = readBindingsFile(path);
673
+ const marked = [];
674
+ const at = Date.now();
675
+ for (const [id, raw] of Object.entries(file.sessions)) {
676
+ const rec = asBoundRecord(raw);
677
+ if (!rec || rec.dirName !== dirName) continue;
678
+ if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
679
+ rec.supersededAt = at;
680
+ file.sessions[id] = rec;
681
+ marked.push(id);
682
+ }
683
+ if (marked.length === 0) return [];
684
+ try {
685
+ writeBindingsFile(path, file);
686
+ } catch {
687
+ return [];
688
+ }
689
+ return marked.sort();
690
+ }
691
+ /**
692
+ * **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
693
+ * 删除指向**已不存在**的 dsh 会话的绑定记录。
694
+ *
695
+ * 为什么需要:`.bindings.json` **只增不清**,而 DSH 会话目录会被
696
+ * `session-cleanup`(本仓既有机制)物理删除 ⇒ 表里必然残留**悬空 id**。
697
+ * 悬空记录本身无害,但它会**污染反向索引**(`listBoundSessionIds` 是唤醒选人的唯一入口),
698
+ * 让"这条轨迹到底还有没有可用会话"变得不可判。
699
+ *
700
+ * 🔴 **安全性由调用方的判据负责**(本函数是纯函数,不认识文件系统):
701
+ * `isMissing` 必须是 **fail-closed** 的(读不到 ⇒ 返回 false = "不当作缺失"),
702
+ * 见 `hasSessionLogById`。判据写错会**清掉整张表**,故此处刻意不提供默认实现。
703
+ *
704
+ * @param root CCC 根
705
+ * @param isMissing 判据:该会话 id 是否**确认**已不存在(false = 保留)
706
+ * @returns 被删除的会话 id(升序稳定;未删任何一条 → `[]`)
707
+ */
708
+ function pruneMissingBindings(root, isMissing) {
709
+ const path = join(root, BINDINGS_REL_PATH);
710
+ const file = readBindingsFile(path);
711
+ const removed = [];
712
+ for (const id of Object.keys(file.sessions)) {
713
+ let missing;
714
+ try {
715
+ missing = isMissing(id);
716
+ } catch {
717
+ continue;
718
+ }
719
+ if (!missing) continue;
720
+ delete file.sessions[id];
721
+ removed.push(id);
722
+ }
723
+ if (removed.length === 0) return [];
724
+ try {
725
+ writeBindingsFile(path, file);
726
+ } catch {
727
+ return [];
728
+ }
729
+ return removed.sort();
730
+ }
731
+ /**
655
732
  * 写入绑定(latest-wins 覆盖该会话记录)。
656
733
  *
657
734
  * 绑定持久化尽力而为:无法定位 CCC 根 / 无会话 id / 写盘失败 → 返回 false,不阻断主流程。
@@ -680,4 +757,4 @@ function appendBound(session, action, rec) {
680
757
  }
681
758
  }
682
759
  //#endregion
683
- export { localFileStamp as C, isoLocal as S, localIdStamp as T, sessionsRoot as _, DEFAULT_SESSION_SCOPE as a, summarize as b, createSession as c, getActiveSessionInfo as d, listSessions as f, sessionEvents as g, resolveSessionByTitle as h, resolveSessionTrajectoryLabel as i, extractSessionMdPathFromText as l, readActiveSessionMd as m, listBoundSessionIds as n, TRAJECTORY_ACTIONS as o, parseSessionContextFromEvents as p, readLastBound as r, clearActiveSessionInfo as s, appendBound as t, findSession as u, setActiveSessionInfo as v, localHuman as w, useSession as x, showSession as y };
760
+ export { useSession as C, localIdStamp as D, localHuman as E, summarize as S, localFileStamp as T, resolveSessionByTitle as _, resolveSessionTrajectoryLabel as a, setActiveSessionInfo as b, TRAJECTORY_ACTIONS as c, extractSessionMdPathFromText as d, findSession as f, readActiveSessionMd as g, parseSessionContextFromEvents as h, readLastBound as i, clearActiveSessionInfo as l, listSessions as m, listBoundSessionIds as n, supersedeOtherBindings as o, getActiveSessionInfo as p, pruneMissingBindings as r, DEFAULT_SESSION_SCOPE as s, appendBound as t, createSession as u, sessionEvents as v, isoLocal as w, showSession as x, sessionsRoot as y };
@@ -31,6 +31,17 @@ interface SessionBoundRecord {
31
31
  action: SessionBoundAction;
32
32
  at: number;
33
33
  note?: string;
34
+ /**
35
+ * **(b) 已被取代标记**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
36
+ *
37
+ * 语义:这条绑定**仍然存在**(沿革不丢、正向 `readLastBound` 仍能查到"我曾属于哪条轨迹"),
38
+ * 但**不再作为唤醒候选**({@link listBoundSessionIds} 过滤掉)——这正是"解绑"的机械形态。
39
+ *
40
+ * 为什么需要(实证):`.bindings.json` 只增不清——实测 S142 名下挂 **4 代**、S185 挂 **2 代**,
41
+ * 同一 trajectory 因此可以有**两条 live 会话**:各持一份完整上下文(内存翻倍)
42
+ * 且**都在写同一批文件**(双写者)。
43
+ */
44
+ supersededAt?: number;
34
45
  }
35
46
  /** 绑定文件相对 CCC 根的路径 */
36
47
  export declare const BINDINGS_REL_PATH = "AGENT_SESSIONS/.bindings.json";
@@ -73,6 +84,40 @@ export declare function resolveSessionTrajectoryLabel(session: unknown, scope: s
73
84
  * @returns 会话 id 列表(最新绑定在前);无绑定 → `[]`
74
85
  */
75
86
  export declare function listBoundSessionIds(root: string, dirName: string): string[];
87
+ /**
88
+ * **(b) 同一 trajectory 多 live 会话的机械守卫**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
89
+ *
90
+ * 把绑定到 `dirName` 的**其它**会话 id 标记为 `supersededAt` —— **保留记录**(沿革/正向查询不丢),
91
+ * 只切断"**唤醒还会找到它**"这条路。语义上即"解绑",且**不动那个会话本身**(它仍可被人工使用)。
92
+ *
93
+ * 为什么必须有机制(R↓):`.bindings.json` 的语义是"latest-wins 覆盖**本条会话记录**",
94
+ * 对**同一 trajectory 的其它会话**没有任何约束 ⇒ 多代会话自然堆积(实测 S142 4 代 / S185 2 代),
95
+ * 而 `acquireWakeAgent` 是"遍历全部绑定 id、任一条 live 就用它"——**两条 live 都能被选中**。
96
+ *
97
+ * @param root CCC 根
98
+ * @param dirName 目标 trajectory 目录名
99
+ * @param keepIds 保留(不标记)的会话 id 集合——通常是"当前正在 use 的这条"
100
+ * @returns **实际被标记**的会话 id(升序稳定;供调用方如实报告,未标记任何一条 → `[]`)
101
+ */
102
+ export declare function supersedeOtherBindings(root: string, dirName: string, keepIds: ReadonlySet<string>): string[];
103
+ /**
104
+ * **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
105
+ * 删除指向**已不存在**的 dsh 会话的绑定记录。
106
+ *
107
+ * 为什么需要:`.bindings.json` **只增不清**,而 DSH 会话目录会被
108
+ * `session-cleanup`(本仓既有机制)物理删除 ⇒ 表里必然残留**悬空 id**。
109
+ * 悬空记录本身无害,但它会**污染反向索引**(`listBoundSessionIds` 是唤醒选人的唯一入口),
110
+ * 让"这条轨迹到底还有没有可用会话"变得不可判。
111
+ *
112
+ * 🔴 **安全性由调用方的判据负责**(本函数是纯函数,不认识文件系统):
113
+ * `isMissing` 必须是 **fail-closed** 的(读不到 ⇒ 返回 false = "不当作缺失"),
114
+ * 见 `hasSessionLogById`。判据写错会**清掉整张表**,故此处刻意不提供默认实现。
115
+ *
116
+ * @param root CCC 根
117
+ * @param isMissing 判据:该会话 id 是否**确认**已不存在(false = 保留)
118
+ * @returns 被删除的会话 id(升序稳定;未删任何一条 → `[]`)
119
+ */
120
+ export declare function pruneMissingBindings(root: string, isMissing: (sessionId: string) => boolean): string[];
76
121
  /**
77
122
  * 写入绑定(latest-wins 覆盖该会话记录)。
78
123
  *
@@ -1,6 +1,6 @@
1
1
  import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
2
2
  import { o as listCccs } from "./ccc-roots-Bd_SjEs7.js";
3
- import { S as isoLocal, T as localIdStamp, _ as sessionsRoot, n as listBoundSessionIds, u as findSession, w as localHuman } from "./trajectory-bound-OyuNkn6e.js";
3
+ import { D as localIdStamp, E as localHuman, f as findSession, n as listBoundSessionIds, w as isoLocal, y as sessionsRoot } from "./trajectory-bound-B9o_4CMF.js";
4
4
  import { t as readSimpleSettings } from "./settings-section-DMDUoQum.js";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -572,6 +572,7 @@ function wakeSchedulerState() {
572
572
  * @param root CCC 根
573
573
  * @param dirName 目标 trajectory 目录名
574
574
  * @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
575
+ * (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
575
576
  */
576
577
  async function acquireWakeAgent(ctx, root, dirName) {
577
578
  const agents = hostAgents(ctx);
@@ -586,7 +587,10 @@ async function acquireWakeAgent(ctx, root, dirName) {
586
587
  if (ids.length > 0) {
587
588
  const sc = hostService(ctx, "sessionController");
588
589
  const resolved = sc?.resolveAgent;
589
- if (typeof resolved !== "function") return { error: `目标会话未加载且 sessionController 不可用(headless profile?)——绑定 id: ${ids[0]}` };
590
+ if (typeof resolved !== "function") return {
591
+ error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
592
+ notReady: true
593
+ };
590
594
  const id = ids[0];
591
595
  try {
592
596
  const out = await resolved.call(sc, id);
@@ -631,7 +635,8 @@ async function deliverWake(ctx, root, entry) {
631
635
  const acquired = await acquireWakeAgent(ctx, root, target.dirName);
632
636
  if ("error" in acquired) return {
633
637
  ok: false,
634
- detail: acquired.error
638
+ detail: acquired.error,
639
+ notReady: acquired.notReady === true
635
640
  };
636
641
  try {
637
642
  acquired.agent.followup(createUserMessage({
@@ -811,6 +816,10 @@ async function runWakeTick(ctx) {
811
816
  }
812
817
  for (const e of due) {
813
818
  const res = await deliverWake(ctx, root, e);
819
+ if (res.notReady) {
820
+ log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
821
+ continue;
822
+ }
814
823
  updateWake(root, e.id, {
815
824
  state: res.ok ? "delivered" : "pending",
816
825
  attempts: e.attempts + 1,
@@ -14,7 +14,8 @@
14
14
  * → 回退:live 会话标题匹配
15
15
  * 3. agent:**live 优先**(`ctx.agents.get`)→ 冷会话走 `ctx.sessionController.resolveAgent`
16
16
  * (宿主实现:live 优先 + resume 去重 + 由会话元数据恢复 preset)
17
- * → `sessionController` 缺席(headless profile)⇒ 仅投递 live 目标,**不静默**
17
+ * → `sessionController` 缺席 仅投递 live 目标,**不静默**(v1.39.3:**归为"环境未就绪"**
18
+ * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
18
19
  * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
19
20
  *
20
21
  * 守卫(I-4 默认 + v1.34 S-1 解耦):全局 `wakeSchedulerEnabled`(**缺省开**;**不再**回退旧键
@@ -28,6 +29,19 @@ import { type WakeEntry } from './wake-registry.js';
28
29
  interface WakeDeliveryResult {
29
30
  ok: boolean;
30
31
  detail: string;
32
+ /**
33
+ * 🔴 **环境未就绪**(不是投递失败)——只有一种情形:目标需要**冷载入**,而宿主此刻**还没把
34
+ * 懒服务 `sessionController` 供上来**(典型 = `dsh web` 刚重启:时钟武装时**立刻跑一次 tick**,
35
+ * 那一拍早于懒服务就绪、也早于会话恢复)。
36
+ *
37
+ * 为什么要把它和"真失败"分开(2026-09-17 实测,S142 §0t):
38
+ * 旧实现把这一情形当成投递失败写进条目 ⇒ ① `attempts` 无端 +1 ② `lastResult` 留下一条
39
+ * **误导性错误**(原文猜"headless profile?",而真因是**重启窗口**,属**假陈述**)
40
+ * ③ 注册表/面板里出现"失败的投递",**每次 restart 都必然复现**——一个**假告警发生器**
41
+ * (所有者 2026-09-17 就是被它引来的)。而消息**从未丢失**:下一个 tick 就投成功了。
42
+ * ⇒ 判据:**"宿主服务还没起来"不是投递结果**,不得计入条目。
43
+ */
44
+ notReady?: boolean;
31
45
  }
32
46
  /**
33
47
  * 调度器**进程态**(诊断用,模块级 = 进程级,正是诊断对象)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.39.2",
3
+ "version": "1.39.3",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2。",
5
5
  "license": "MIT",
6
6
  "repository": {