dsh-expert-group 0.1.1

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 (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
package/shared.js ADDED
@@ -0,0 +1,4003 @@
1
+ /**
2
+ * 供 Host 与 Client 共享的常量与轻量工具。此文件保持无副作用,
3
+ * 既可由 Node ESM 导入,也可在需要时被客户端逻辑复制使用;
4
+ * 唯一依赖是同目录下同样无外部依赖的 role-registry.js。
5
+ * @module dsh-expert-group/shared
6
+ */
7
+ import { listOptionalRoles, listRoles, roleStatusLabels } from "./role-registry.js";
8
+
9
+ /** 旧版 Host 的兼容 HTTP 路由(同源、仅 POST JSON)。 */
10
+ export const RPC_PATH = "/api/dsh-expert-group";
11
+
12
+ /** DSH Connection 的独立认证逻辑频道;不能占用 Gateway 独占的 /api interceptor。 */
13
+ export const RPC_CHANNEL = "/dsh-expert-group";
14
+ export const RPC_ENDPOINT = "rpc";
15
+
16
+ /** 已启用角色的插件工具名(role-named);顺序与元数据来自统一角色注册表。 */
17
+ export const ROLES = listRoles().filter((role) => role.optional !== true).map((role) => role.id);
18
+
19
+ /** observer 默认禁用,按配置启用;可选角色同样来自统一角色注册表。 */
20
+ export const OPTIONAL_ROLES = listOptionalRoles().map((role) => role.id);
21
+
22
+ /**
23
+ * 注意:插件不再内置任何角色默认模型——新安装后所有角色为空,
24
+ * 由用户在侧边栏从「设置 → 模型」已配置的模型中自行选择,并通过
25
+ * 「保存」(全局持久)或「应用」(仅当前会话)提交。
26
+ */
27
+
28
+ /**
29
+ * 所有专家角色(含可写的 designer/fixer)一律禁止的再委派工具。
30
+ * 父代理是唯一编排者:专家是叶子工作者,不得再创建子代理/孙代理,
31
+ * 也不得调用 workflow/ralph/goal 等任何多代理编排能力。
32
+ */
33
+ export const EXPERT_DELEGATION_DENY = [
34
+ // 专家只能通过最终 assistant 输出交付结果,禁止主动发送中间 report。
35
+ "report",
36
+ "subagent",
37
+ // Harness 外层入口:专家 child 即使看见这些名字也不得绕过插件。
38
+ "subagent_fork",
39
+ ...listRoles().map((role) => "subagent_" + role.toolName),
40
+ // dsh-expert-group 的唯一插件命名空间:叶子专家不得再次调用。
41
+ // 角色工具名来自注册表:注册表新增角色会自动进入 deny,不会因漏改清单而扩大权限。
42
+ ...listRoles().map((role) => role.toolName),
43
+ "run_code",
44
+ "workflow",
45
+ "ralph",
46
+ "create_goal",
47
+ "update_goal",
48
+ "send_message",
49
+ "interrupt_agent",
50
+ "todo_write",
51
+ ];
52
+
53
+ /**
54
+ * 所有专家 child 还需过滤仅属于当前 live root 的工具。此处独立于历史
55
+ * EXPERT_DELEGATION_DENY,避免破坏外部对旧再委派清单的兼容契约。
56
+ */
57
+ export const ROOT_ONLY_TOOL_DENY = ["dsh_expert_group_finalize", "dsh_expert_group_release_scope"];
58
+
59
+ /**
60
+ * 只读角色(explorer/librarian/oracle/observer)禁止的写操作与命令,
61
+ * 并展开组合覆盖全部 EXPERT_DELEGATION_DENY,保证只读角色同样不能再委派。
62
+ */
63
+ export const READONLY_DENY = [
64
+ "write",
65
+ "edit",
66
+ "bash",
67
+ "pwsh",
68
+ ...EXPERT_DELEGATION_DENY,
69
+ ...ROOT_ONLY_TOOL_DENY,
70
+ ];
71
+
72
+ /**
73
+ * PTC 的 run_code 是展示传输,不是可交给 tools.restrict() 的全局工具。
74
+ * 过滤器保留静态 deny 清单的完整性,同时只返回当前视图中真正可限制的名称。
75
+ * @param {string[]} denyList - 静态候选 deny 工具名。
76
+ * @param {(name: string) => boolean} isVisible - 当前父代理视图可见性判断。
77
+ * @returns {string[]} 去重后的可限制工具名。
78
+ */
79
+ /**
80
+ * 将当前父代理可见的动态 MCP 工具并入只读 deny 候选。
81
+ * MCP 工具名由宿主动态提供,静态清单无法覆盖;缺少公开 schema 时不猜测
82
+ * 其读写语义,调用方应保持 fail-closed(至少拒绝 mcp__ 前缀工具)。
83
+ * @param {string[]} denyList - 静态 deny 候选。
84
+ * @param {unknown[]} visibleSchemas - tools.schemas(parent) 返回的当前视图。
85
+ * @returns {string[]} 合并并去重后的 deny 候选。
86
+ */
87
+ export function isReadonlyDynamicMcpName(name) {
88
+ return typeof name === "string" && name.startsWith("mcp__");
89
+ }
90
+
91
+ export function readonlyDenyCandidates(denyList, visibleSchemas) {
92
+ const candidates = Array.isArray(denyList) ? denyList.slice() : [];
93
+ if (!Array.isArray(visibleSchemas)) return candidates;
94
+ for (const schema of visibleSchemas) {
95
+ const name = schema?.name;
96
+ if (isReadonlyDynamicMcpName(name)) candidates.push(name);
97
+ }
98
+ return candidates;
99
+ }
100
+
101
+ export function restrictableDenyNames(denyList, isVisible) {
102
+ if (!Array.isArray(denyList) || typeof isVisible !== "function") return [];
103
+ const result = [];
104
+ const seen = new Set();
105
+ for (const name of denyList) {
106
+ if (name === "run_code" || seen.has(name) || !isVisible(name)) continue;
107
+ seen.add(name);
108
+ result.push(name);
109
+ }
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * 从父 inbox 注入的 DSH 消息提取专家反馈 child id。
115
+ *
116
+ * 仅接受 subagent-settled/subagent-report,且 senderSessionId 必须是非空字符串;
117
+ * 其他 source 或缺失 sender 一律返回 undefined。
118
+ *
119
+ * B(兼容边界):本 helper 只做**身份归类**,不代表该形态可以释放 handoff。core 当前只铸
120
+ * `subagent-settled`(form=notice) 的 UUID settlement;`subagent-report`(form=relay) 没有
121
+ * correlation id(不同 message.id、无 turn/admission 关联),无法证明属于哪一轮 admission。
122
+ * 调用方必须对 relay fail-closed,不得让它进入 markPendingHandoffFeedback / 通配 release
123
+ * 路径(见 index.js 的 agent/inbox/inserted handler)。
124
+ * @param {object} [message] - DSH inbox 消息。
125
+ * @returns {string|undefined} 匹配的 child session id。
126
+ */
127
+ export function subagentFeedbackChildId(message) {
128
+ const source = message?.source;
129
+ if (source?.kind !== "subagent-settled" && source?.kind !== "subagent-report") return void 0;
130
+ return typeof source.senderSessionId === "string" && source.senderSessionId.length > 0
131
+ ? source.senderSessionId
132
+ : void 0;
133
+ }
134
+
135
+ /**
136
+ * 管理一个 root Agent 当前尚未完成的 admission batch。
137
+ *
138
+ * role tool 在第一次异步校验前就加入 batch,因此同一 root turn 中已被调度的
139
+ * 后续 role tool 会共享这个 batch;accepted、advisory 和异常路径都必须 settle
140
+ * 自己的 ticket,最后一个 ticket settle 后才允许 accepted caller 继续收口。
141
+ * 不使用定时器:新 batch 只会在上一 batch 的 admission 全部结算后建立。
142
+ *
143
+ * @returns {{begin:Function}} 创建 ticket 的最小状态机。
144
+ */
145
+ export function createAdmissionBatchCoordinator() {
146
+ let current;
147
+
148
+ const finish = (batch) => {
149
+ if (batch.pending !== 0 || batch.finished === true) return;
150
+ batch.finished = true;
151
+ for (const resolve of batch.waiters.splice(0)) resolve();
152
+ };
153
+
154
+ const begin = () => {
155
+ if (current === void 0 || current.finished === true) {
156
+ current = { pending: 0, finished: false, waiters: [] };
157
+ }
158
+ const batch = current;
159
+ batch.pending += 1;
160
+ let settled = false;
161
+ let resolveReady;
162
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
163
+ batch.waiters.push(resolveReady);
164
+
165
+ return {
166
+ settle() {
167
+ // 失败重试或 finally 兜底可能重复调用;重复结算不能把 sibling 提前释放。
168
+ if (settled) return false;
169
+ settled = true;
170
+ batch.pending -= 1;
171
+ finish(batch);
172
+ return true;
173
+ },
174
+ whenReady() {
175
+ return ready;
176
+ },
177
+ };
178
+ };
179
+
180
+ return { begin };
181
+ }
182
+
183
+ /**
184
+ * 管理一个 root generation 的后台 admission cohort。entry 以对象身份消费;多个
185
+ * accepted message 若落在同一 child turn,会共享一个 turnGroup。父 feedback 只有
186
+ * senderSessionId 时按 exact child activation 标记全部已结束 group,未结束 future turn
187
+ * 保持未反馈,避免同一 teardown 只释放一个 turn 或一个 token。
188
+ */
189
+ export function createHandoffAdmissionCohort() {
190
+ const entries = [];
191
+ // teardown feedback 只有 child id 时仍需绑定 exact Agent activation:每次无 id feedback
192
+ // 快照「调用时刻已存在」的 admission token,并把这些 token 并入该 activation 的累计集合。
193
+ // 消费判定只看调用时刻的 entries + turnEnded,因此注册晚于本次 feedback 的 token 不会被
194
+ // 本次调用消费;同一 activation 上后续登记的 token 会由下一次 feedback 消费(见 markFeedback)。
195
+ const settlementTokenSnapshotsByActivation = new WeakMap();
196
+ let sequence = 0;
197
+ const owns = (entry) => entries.includes(entry);
198
+ const validId = (value) => typeof value === "string" && value.length > 0;
199
+ // 外部事件可能携带 null/primitive group;访问其 fields 前统一校验,
200
+ // 但未建立 group(undefined)仍表示单 entry 的正常路径。
201
+ const isValidTurnGroup = (value) => value !== null
202
+ && typeof value === "object"
203
+ && !Array.isArray(value)
204
+ && value.entries instanceof Array;
205
+ const groupEntries = (entry) => {
206
+ if (entry?.turnGroup === void 0) return [entry];
207
+ if (!isValidTurnGroup(entry.turnGroup)) return [];
208
+ return entry.turnGroup.entries.filter(owns);
209
+ };
210
+ const register = (root, childId, activation) => {
211
+ const entry = {
212
+ token: "admission-" + (++sequence),
213
+ root,
214
+ childId: validId(childId) ? childId : void 0,
215
+ messageId: void 0,
216
+ turn: void 0,
217
+ activation,
218
+ turnGroup: void 0,
219
+ turnEnded: false,
220
+ feedbackSeen: false,
221
+ feedbackExact: false,
222
+ pauseDone: false,
223
+ failed: false,
224
+ };
225
+ entries.push(entry);
226
+ return entry;
227
+ };
228
+ const bind = (entry, childId, messageId) => {
229
+ if (!owns(entry) || !validId(childId)) return false;
230
+ if (entry.turnGroup !== void 0 && !isValidTurnGroup(entry.turnGroup)) return false;
231
+ const previousChildId = entry.childId;
232
+ entry.childId = childId;
233
+ // 新 admission 尚未进入 turn group 时,childId 与 undefined 比较也可能命中;
234
+ // 只有确实存在 group 才能同步迁移 group.childId,避免写入 undefined。
235
+ if (entry.turnGroup !== void 0 && entry.turnGroup.childId === previousChildId) entry.turnGroup.childId = childId;
236
+ if (validId(messageId)) entry.messageId = messageId;
237
+ return true;
238
+ };
239
+ const rebindChild = (entry, childId) => {
240
+ if (!owns(entry) || !validId(childId)) return false;
241
+ if (entry.turnGroup !== void 0 && !isValidTurnGroup(entry.turnGroup)) return false;
242
+ entry.childId = childId;
243
+ entry.messageId = void 0;
244
+ if (entry.turnGroup !== void 0) {
245
+ entry.turnGroup.childId = childId;
246
+ entry.turnGroup.entries = entry.turnGroup.entries.filter(owns);
247
+ }
248
+ return true;
249
+ };
250
+ /**
251
+ * 标记一个 admission 的 turn 已结束。
252
+ *
253
+ * @param {object} entry - cohort entry。
254
+ * @param {string} messageId - 该 turn 的 admission messageId。
255
+ * @param {number} [turn] - turn 号。
256
+ * @param {object} [activation] - exact child Activation。
257
+ * @param {object} [group] - 该 turn 的 turn group。
258
+ * @param {{deferSnapshotFeedback?:boolean}} [options] - P1:调用方判定本次 turn/end
259
+ * **不是可消费边界**(recoverable failure 的 turn/end)时必须传 `deferSnapshotFeedback:true`:
260
+ * turnEnded/turn/group 等事实照常记录(后续合法 settlement 仍需要它),但**不得**用
261
+ * activation 级 snapshot 把 token 提前标成 feedbackSeen。snapshot 的语义是「某条
262
+ * activation 级 settlement 覆盖了调用时刻存在的全部 token」,而 recoverable failure 的
263
+ * turn/end 并不是结算;一旦在此消费,父 inbox 之后到达的合法 UUID settlement 就再也
264
+ * 找不到未消费 entry,对应 reason 永不释放(goal 永久 paused)。
265
+ * @returns {boolean} 是否成功标记。
266
+ */
267
+ const markTurnEnd = (entry, messageId, turn, activation, group, options) => {
268
+ if (!owns(entry) || !validId(messageId) || entry.messageId !== messageId) return false;
269
+ if (activation !== void 0 && entry.activation !== void 0 && entry.activation !== activation) return false;
270
+ if (group !== void 0 && !isValidTurnGroup(group)) return false;
271
+ if (entry.turnGroup !== void 0 && !isValidTurnGroup(entry.turnGroup)) return false;
272
+ entry.turnEnded = true;
273
+ if (turn !== void 0) entry.turn = turn;
274
+ if (activation !== void 0) entry.activation = activation;
275
+ if (group !== void 0) entry.turnGroup = group;
276
+ const deferSnapshotFeedback = options?.deferSnapshotFeedback === true;
277
+ if (!deferSnapshotFeedback && activation !== void 0
278
+ && (typeof activation === "object" || typeof activation === "function")) {
279
+ const tokens = settlementTokenSnapshotsByActivation.get(activation);
280
+ if (tokens instanceof Set && tokens.has(entry.token)) {
281
+ for (const member of groupEntries(entry)) {
282
+ if (tokens.has(member.token)) member.feedbackSeen = true;
283
+ }
284
+ }
285
+ }
286
+ return true;
287
+ };
288
+ const ensureTurnGroup = (root, childId, turn, activation, messageIds) => {
289
+ if (!validId(childId)) return void 0;
290
+ const ids = messageIds instanceof Set ? messageIds : void 0;
291
+ const childEntries = entries.filter((item) => item.root === root && item.childId === childId);
292
+ if (childEntries.some((item) => item.turnGroup !== void 0 && !isValidTurnGroup(item.turnGroup))) return void 0;
293
+ const existing = childEntries.find((item) => isValidTurnGroup(item.turnGroup)
294
+ && (turn === void 0 || item.turnGroup.turn === turn)
295
+ && (activation === void 0 || item.turnGroup.activation === void 0 || item.turnGroup.activation === activation));
296
+ let group = existing?.turnGroup;
297
+ if (group === void 0) group = { root, childId, turn, activation, entries: [] };
298
+ for (const entry of entries) {
299
+ if (entry.root !== root || entry.childId !== childId
300
+ || (activation !== void 0 && entry.activation !== void 0 && entry.activation !== activation)
301
+ || (ids !== void 0 && !ids.has(entry.messageId))) continue;
302
+ if (!group.entries.includes(entry)) group.entries.push(entry);
303
+ entry.turnGroup = group;
304
+ if (turn !== void 0) entry.turn = turn;
305
+ if (activation !== void 0) entry.activation = activation;
306
+ }
307
+ return group.entries.length > 0 ? group : void 0;
308
+ };
309
+ const markTurnEndGroup = (root, childId, messageIds, turn, activation, group) => {
310
+ if (!validId(childId) || !(messageIds instanceof Set)) return [];
311
+ if (group !== void 0 && !isValidTurnGroup(group)) return [];
312
+ const target = group ?? { root, childId, turn, activation, entries: [] };
313
+ const matched = [];
314
+ for (const entry of entries) {
315
+ if (entry.root !== root || entry.childId !== childId || !messageIds.has(entry.messageId)) continue;
316
+ if (!markTurnEnd(entry, entry.messageId, turn, activation, target)) continue;
317
+ if (!target.entries.includes(entry)) target.entries.push(entry);
318
+ matched.push(entry);
319
+ }
320
+ return matched;
321
+ };
322
+ /**
323
+ * 消费一个 feedback。
324
+ *
325
+ * @param {object} root - exact root Agent。
326
+ * @param {string} childId - durable child session id。
327
+ * @param {string|undefined} messageId - admission token / admission messageId;undefined 表示
328
+ * 无 admission 身份的 activation 级 settlement。
329
+ * @param {object} [activation] - exact child Activation。
330
+ * @param {Set<string>} [restrictTokens] - 可选候选白名单:wildcard 消费只能命中该集合内的
331
+ * token。调用方(index.js)用它把「本次 feedback 到达时刻已存在的 token 快照」固定下来,
332
+ * 从而让**过期 pending marker 无法在被再次消费时把覆盖范围扩展到之后新登记的 token**
333
+ * (审核缺陷:wildcard union snapshot 提前释放下一轮)。
334
+ * @returns {object|undefined} 被消费的 group/entry。
335
+ */
336
+ const markFeedback = (root, childId, messageId, activation, restrictTokens) => {
337
+ if (!validId(childId)) return void 0;
338
+ const restrict = restrictTokens instanceof Set ? restrictTokens : void 0;
339
+ const childEntries = entries.filter((item) => item.root === root && item.childId === childId
340
+ && (activation === void 0 || item.activation === activation));
341
+ if (childEntries.some((item) => item.turnGroup !== void 0 && !isValidTurnGroup(item.turnGroup))) return void 0;
342
+ if (messageId === void 0 && activation !== void 0 && (typeof activation === "object" || typeof activation === "function")) {
343
+ const snapshot = new Set(entries.filter((item) => item.root === root && item.childId === childId
344
+ && (item.activation === void 0 || item.activation === activation) && item.failed !== true).map((item) => item.token));
345
+ // 零 admission feedback 不建立空 marker;只有真实 token 存在时才更新 snapshot。
346
+ //
347
+ // snapshot 用**合并(union)**而不是首次固化:同一 activation 可以先后承载多个
348
+ // admission(典型是 `queue=true` steer 复用既有 child——第二轮 admission 在原 child
349
+ // 上登记 token2,但 feedback 仍是「只有 childId、没有 admissionId」的 teardown 通知)。
350
+ // 固化语义下 token2 永远不在首轮建立的 snapshot 里,candidates 过滤会把它排除,
351
+ // token2 的 feedbackSeen 永不置 true → cohort.ready() 永 false → child reason 永不释放
352
+ // → durable goal 永久停在 paused(线上缺陷 2)。
353
+ //
354
+ // 为什么合并仍然满足原设计意图(「旧 feedback 不消费未来 token」):
355
+ // 1. snapshot 在**本次 markFeedback 调用时刻**重建,注册晚于本次调用的 token 不在
356
+ // 集合里,也不会被本次调用消费——时间边界由构造时机保证,而不是由固化保证;
357
+ // 2. 合并只影响同一 activation 的**下一次**无 id feedback 能看到的历史并集:那时
358
+ // 新 token 已经存在,本就应当被消费(feedback 到达晚于 token 登记);
359
+ // 3. 真正的「未来 turn」保护仍在:candidates 还要求 entry.turnEnded === true,
360
+ // 未结束的 turn 不会被任何 feedback 标记,等待它自己的 turn/end 边界。
361
+ // 残余边界:同一条旧 settlement 被重复投递时,重复调用会看到并集(上层 index.js 的
362
+ // messageId/admissionId 去重与 exact activation 校验是第一道防线,见
363
+ // markPendingHandoffFeedback)。
364
+ if (snapshot.size > 0) {
365
+ const existing = settlementTokenSnapshotsByActivation.get(activation);
366
+ if (existing instanceof Set) {
367
+ for (const admissionToken of snapshot) existing.add(admissionToken);
368
+ } else {
369
+ settlementTokenSnapshotsByActivation.set(activation, snapshot);
370
+ }
371
+ }
372
+ }
373
+ const matching = entries.filter((item) => item.root === root && item.childId === childId
374
+ && (activation === void 0 || item.activation === activation)
375
+ && (messageId === void 0 || item.messageId === messageId || item.token === messageId)
376
+ && item.turnEnded === true && item.feedbackSeen !== true && item.failed !== true);
377
+ // restrict 只收紧 wildcard(activation 级)候选:exact 路径的身份来自 admission
378
+ // token/messageId,调用方传入的到达快照与它无关,不得因此漏消费。
379
+ const candidates = messageId === void 0 && activation !== void 0
380
+ ? matching.filter((item) => (restrict === void 0 || restrict.has(item.token))
381
+ && settlementTokenSnapshotsByActivation.get(activation)?.has(item.token))
382
+ : matching.slice(0, 1);
383
+ if (candidates.length === 0 || candidates.some((item) => item.turnGroup !== void 0 && !isValidTurnGroup(item.turnGroup))) return void 0;
384
+ if (messageId !== void 0) {
385
+ candidates[0].feedbackSeen = true;
386
+ candidates[0].feedbackExact = true;
387
+ return candidates[0].turnGroup ?? candidates[0];
388
+ }
389
+ // 无 admissionId 的 settlement 属于整个 exact activation:把当前已结束的
390
+ // 所有 turnGroup 都标记,未结束的 future turn 保持 false,等待后续 turn/end。
391
+ const seenGroups = new Set();
392
+ for (const candidate of candidates) {
393
+ const group = candidate.turnGroup;
394
+ if (group !== void 0) {
395
+ if (seenGroups.has(group)) continue;
396
+ seenGroups.add(group);
397
+ }
398
+ const tokens = activation !== void 0 && (typeof activation === "object" || typeof activation === "function")
399
+ ? settlementTokenSnapshotsByActivation.get(activation)
400
+ : void 0;
401
+ for (const member of groupEntries(candidate)) {
402
+ if (tokens === void 0 || tokens.has(member.token)) member.feedbackSeen = true;
403
+ }
404
+ }
405
+ return candidates[0].turnGroup ?? candidates[0];
406
+ };
407
+ const updateActivation = (root, childId, nextActivation, { previousActivation, token, nextSession } = {}) => {
408
+ if (root === null || (typeof root !== "object" && typeof root !== "function")
409
+ || !validId(childId) || (typeof nextActivation !== "object" && typeof nextActivation !== "function")) return 0;
410
+ const matches = entries.filter((entry) => entry.root === root && entry.childId === childId
411
+ && (token === void 0 || entry.token === token)
412
+ && (previousActivation === void 0 || entry.activation === void 0 || entry.activation === previousActivation));
413
+ if (matches.length === 0 || matches.some((entry) => entry.turnGroup !== void 0 && !isValidTurnGroup(entry.turnGroup))) return 0;
414
+ if (previousActivation !== void 0 && previousActivation !== nextActivation
415
+ && (typeof previousActivation === "object" || typeof previousActivation === "function")) {
416
+ const previousSnapshot = settlementTokenSnapshotsByActivation.get(previousActivation);
417
+ if (previousSnapshot instanceof Set) {
418
+ // A4:activation 代际迁移时**丢弃**旧 activation 的 token 快照,绝不 union 到新
419
+ // activation。理由:该快照表达「旧代际已收到一条 activation 级(无 admissionId)
420
+ // settlement」,把它迁移到新 activation 会让同一条旧结论跨代覆盖新代际登记的
421
+ // token(审核缺陷:activation 级 wildcard 跨代消费 → 提前 release)。
422
+ //
423
+ // 丢弃不会造成漏消费:新 activation 的下一条 wildcard feedback 会在 markFeedback
424
+ // 内按「调用时刻仍存在的 entries」重建快照(见上),那时新 token 已登记、本就应被
425
+ // 消费;而旧代际的 token 身份由 exact admissionId 通道与 index.js 的 exact marker
426
+ // 保留,不依赖 activation 级快照。
427
+ settlementTokenSnapshotsByActivation.delete(previousActivation);
428
+ }
429
+ }
430
+ for (const entry of matches) {
431
+ if (nextSession !== void 0) entry.session = nextSession;
432
+ entry.activation = nextActivation;
433
+ if (entry.turnGroup !== void 0) {
434
+ entry.turnGroup.activation = nextActivation;
435
+ entry.turnGroup.childId = childId;
436
+ }
437
+ }
438
+ return matches.length;
439
+ };
440
+ const markFeedbackGroup = (root, childId, group, activation) => {
441
+ if (!validId(childId) || !isValidTurnGroup(group)) return void 0;
442
+ const entry = entries.find((item) => item.root === root && item.childId === childId
443
+ && (activation === void 0 || item.activation === activation)
444
+ && item.turnGroup === group && item.turnEnded === true && item.failed !== true);
445
+ if (entry === void 0) return void 0;
446
+ for (const member of groupEntries(entry)) member.feedbackSeen = true;
447
+ return entry.turnGroup ?? entry;
448
+ };
449
+ const markPauseDone = (entry) => {
450
+ if (!owns(entry)) return false;
451
+ entry.pauseDone = true;
452
+ return true;
453
+ };
454
+ const allPaused = (root) => {
455
+ const cohort = entries.filter((entry) => entry.root === root && entry.failed !== true);
456
+ return cohort.length > 0 && cohort.every((entry) => entry.pauseDone === true);
457
+ };
458
+ const ready = (root, entry) => {
459
+ if (!owns(entry) || entry.root !== root || entry.failed === true || !allPaused(root)) return false;
460
+ if (entry.turnGroup !== void 0 && !isValidTurnGroup(entry.turnGroup)) return false;
461
+ const members = entry.feedbackExact === true ? [entry] : groupEntries(entry);
462
+ return members.every((member) => member.turnEnded === true
463
+ && member.feedbackSeen === true && member.pauseDone === true);
464
+ };
465
+ const remove = (entry) => {
466
+ if (!owns(entry)) return false;
467
+ entries.splice(entries.indexOf(entry), 1);
468
+ if (entry.turnGroup !== void 0 && isValidTurnGroup(entry.turnGroup)) {
469
+ entry.turnGroup.entries = entry.turnGroup.entries.filter((member) => member !== entry);
470
+ }
471
+ return true;
472
+ };
473
+ const clearRoot = (root) => {
474
+ let count = 0;
475
+ for (let index = entries.length - 1; index >= 0; index--) {
476
+ if (entries[index].root !== root) continue;
477
+ entries.splice(index, 1);
478
+ count += 1;
479
+ }
480
+ return count;
481
+ };
482
+ const roots = () => [...new Set(entries.map((entry) => entry.root))];
483
+ return {
484
+ register,
485
+ bind,
486
+ rebindChild,
487
+ ensureTurnGroup,
488
+ markTurnEnd,
489
+ markTurnEndGroup,
490
+ markFeedback,
491
+ updateActivation,
492
+ markFeedbackGroup,
493
+ markPauseDone,
494
+ allPaused,
495
+ ready,
496
+ remove,
497
+ clearRoot,
498
+ roots,
499
+ entriesFor(root) { return entries.filter((entry) => entry.root === root); },
500
+ };
501
+ }
502
+
503
+ /**
504
+ * 为每个 Session generation 保存 transient turn/claimed 状态。cold resume 会创建
505
+ * 新的 Session 对象但保留 durable id;对象作为 WeakMap key 可以让新 generation
506
+ * 从空状态开始,同时不影响 recovery record 中持久的 task/op/activity。
507
+ */
508
+ export function createSessionGenerationState() {
509
+ const states = new WeakMap();
510
+ const validSession = (session) => session !== null
511
+ && (typeof session === "object" || typeof session === "function");
512
+ const get = (session) => {
513
+ if (!validSession(session)) return void 0;
514
+ let state = states.get(session);
515
+ if (state === void 0) {
516
+ state = { handledTurnEnds: new Set(), claimed: new Map() };
517
+ states.set(session, state);
518
+ }
519
+ return state;
520
+ };
521
+ const reset = (session) => validSession(session) ? states.delete(session) : false;
522
+ const claimTurn = (session, turn) => {
523
+ if (!Number.isSafeInteger(turn)) return false;
524
+ const state = get(session);
525
+ if (state === void 0 || state.handledTurnEnds.has(turn)) return false;
526
+ state.handledTurnEnds.add(turn);
527
+ return true;
528
+ };
529
+ return { get, reset, claimTurn };
530
+ }
531
+
532
+ /**
533
+ * 保存失败 admission 的 exact terminal tombstone。tombstone 不依赖 cohort entry
534
+ * 的存活;迟到 turn/end 可按 child、durable Session、messageId/turn 反查并消费。
535
+ * 没有可反查的 messageId/turn 时不保存,避免产生无期限 generic fallback。
536
+ */
537
+ export function createHandoffFailureRegistry() {
538
+ const failures = new Map();
539
+ const validObject = (value) => value !== null
540
+ && (typeof value === "object" || typeof value === "function");
541
+ const validString = (value) => typeof value === "string" && value.length > 0;
542
+ const sessionIdOf = (session) => validString(session?.id) ? session.id : void 0;
543
+ const descriptorOf = (admission, context = {}) => {
544
+ const session = admission?.session ?? context.session;
545
+ const sessionId = admission?.sessionId ?? context.sessionId ?? sessionIdOf(session);
546
+ const messageId = admission?.messageId ?? context.messageId;
547
+ const turn = Number.isSafeInteger(admission?.turn) ? admission.turn
548
+ : Number.isSafeInteger(context.turn) ? context.turn : void 0;
549
+ return {
550
+ token: admission?.token,
551
+ childId: admission?.childId ?? context.childId,
552
+ session,
553
+ sessionId,
554
+ messageId: validString(messageId) ? messageId : void 0,
555
+ turn,
556
+ };
557
+ };
558
+ const get = (parent) => failures.get(parent);
559
+ const remember = (parent, admissions, context = {}) => {
560
+ if (!validObject(parent) || !Array.isArray(admissions)) return 0;
561
+ let records = failures.get(parent);
562
+ let count = 0;
563
+ for (const admission of admissions) {
564
+ const descriptor = descriptorOf(admission, context);
565
+ if (!validString(descriptor.token) || !validString(descriptor.childId)
566
+ || (!validString(descriptor.messageId) && !Number.isSafeInteger(descriptor.turn))) continue;
567
+ if (records === void 0) { records = new Map(); failures.set(parent, records); }
568
+ records.set(descriptor.token, descriptor);
569
+ count += 1;
570
+ }
571
+ return count;
572
+ };
573
+ const sessionMatches = (record, query) => {
574
+ if (record.session !== void 0 && query.session !== void 0 && record.session === query.session) return true;
575
+ const querySessionId = query.sessionId ?? sessionIdOf(query.session);
576
+ return validString(record.sessionId) && validString(querySessionId) && record.sessionId === querySessionId;
577
+ };
578
+ const messageMatches = (record, query) => {
579
+ if (!validString(record.messageId)) return false;
580
+ if (record.messageId === query.messageId) return true;
581
+ return query.messageIds instanceof Set && query.messageIds.has(record.messageId);
582
+ };
583
+ const matches = (record, childId, query = {}) => {
584
+ if (record.childId !== childId) return false;
585
+ // 已知 token 仍校验 caller 提供的 Session/turn/message metadata;token 只是
586
+ // 缩小候选,不得让旧 generation 或错误 turn 越过 exact terminal 边界。
587
+ if (validString(query.token) && record.token !== query.token) return false;
588
+ if (!sessionMatches(record, query)) return false;
589
+ if (Number.isSafeInteger(record.turn) && record.turn !== query.turn) return false;
590
+ return messageMatches(record, query) || (Number.isSafeInteger(record.turn) && record.turn === query.turn);
591
+ };
592
+ const lookup = (parent, childId, query = {}) => {
593
+ const records = failures.get(parent);
594
+ if (!(records instanceof Map) || !validString(childId)) return void 0;
595
+ for (const record of records.values()) {
596
+ if (matches(record, childId, query)) return record;
597
+ }
598
+ return void 0;
599
+ };
600
+ const consume = (parent, childId, query = {}) => {
601
+ const records = failures.get(parent);
602
+ if (!(records instanceof Map)) return false;
603
+ const record = lookup(parent, childId, query);
604
+ if (record === void 0) return false;
605
+ records.delete(record.token);
606
+ if (records.size === 0) failures.delete(parent);
607
+ return true;
608
+ };
609
+ const clearChild = (parent, childId) => {
610
+ const records = failures.get(parent);
611
+ if (!(records instanceof Map) || !validString(childId)) return 0;
612
+ let count = 0;
613
+ for (const [token, record] of records) {
614
+ if (record.childId !== childId) continue;
615
+ records.delete(token);
616
+ count += 1;
617
+ }
618
+ if (records.size === 0) failures.delete(parent);
619
+ return count;
620
+ };
621
+ const pruneUnmatchable = (parent) => {
622
+ const records = failures.get(parent);
623
+ if (!(records instanceof Map)) return 0;
624
+ let count = 0;
625
+ for (const [token, record] of records) {
626
+ if (validString(record.messageId) || Number.isSafeInteger(record.turn)) continue;
627
+ records.delete(token);
628
+ count += 1;
629
+ }
630
+ if (records.size === 0) failures.delete(parent);
631
+ return count;
632
+ };
633
+ return {
634
+ get, remember, lookup, consume, clearChild, pruneUnmatchable,
635
+ delete: (parent) => failures.delete(parent),
636
+ has: (parent) => failures.has(parent),
637
+ clear: () => failures.clear(),
638
+ };
639
+ }
640
+
641
+ /**
642
+ * 判断 cold-resume 前后的 Session 是否仍是同一个 durable child。DSH 会为新
643
+ * Activation 创建不同的 Session/Agent 对象,因此不能使用对象引用;同时要求
644
+ * durable id 与当前 child id 一致,避免错误 record 被复用。
645
+ */
646
+ export function sameDurableSessionId(left, right, childId) {
647
+ try {
648
+ return typeof left?.id === "string" && left.id.length > 0
649
+ && typeof right?.id === "string" && right.id.length > 0
650
+ && left.id === right.id
651
+ && (childId === void 0 || left.id === childId);
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ /** 从 DSH GoalView 提取可用于 CAS 的精确引用;无效视图不进入 handoff。 */
658
+ function goalRefOf(goal) {
659
+ try {
660
+ if (goal === null || typeof goal !== "object") return void 0;
661
+ if (typeof goal.id !== "string" || goal.id.length === 0 || !Number.isSafeInteger(goal.revision) || goal.revision <= 0) return void 0;
662
+ return { id: goal.id, revision: goal.revision };
663
+ } catch {
664
+ return void 0;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * 严格解析 GoalView:合法但 disarmed 的目标是 none;字段缺失/非法或 armed
670
+ * 与非 active phase 组合都必须视为 malformed,不能借机放行。
671
+ */
672
+ function goalViewState(goal) {
673
+ // GoalService.get 的 undefined 才表示没有目标;null 是损坏的 GoalView。
674
+ if (goal === void 0) return { kind: "none" };
675
+ if (goal === null) return { kind: "invalid" };
676
+ try {
677
+ const ref = goalRefOf(goal);
678
+ const phase = goal.phase;
679
+ const activation = goal.activation;
680
+ if (ref === void 0
681
+ || !["active", "paused", "blocked", "complete"].includes(phase)
682
+ || !["armed", "disarmed"].includes(activation)
683
+ || (activation === "armed" && phase !== "active")
684
+ || (activation === "disarmed" && !["paused", "blocked", "complete"].includes(phase))) {
685
+ return { kind: "invalid" };
686
+ }
687
+ return activation === "armed" ? { kind: "armed", ref } : { kind: "none", ref };
688
+ } catch {
689
+ return { kind: "invalid" };
690
+ }
691
+ }
692
+
693
+ /** Goal Service 在本地为同步实现,但 remote adapter 可能返回 Promise。 */
694
+ function isThenable(value) {
695
+ try {
696
+ return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
697
+ } catch {
698
+ // 恶意/损坏 adapter 的 then getter 也属于不可兼容异步服务,交给调用方 fail closed。
699
+ return true;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * 同步 guard 无法 await thenable,但拒绝的 Promise 仍必须立刻挂 rejection handler。
705
+ * Promise.resolve 自身也包在 try 中,以覆盖损坏的 then getter/Promise subclass。
706
+ */
707
+ function consumeRejectedThenable(value) {
708
+ try {
709
+ const promise = Promise.resolve(value);
710
+ promise.then(() => {}, () => {});
711
+ } catch {}
712
+ }
713
+
714
+ /** 将 child/user/fanout 等等待原因编码为稳定键,重复 guard/feedback 可幂等处理。 */
715
+ function handoffReasonKey(reason) {
716
+ if (reason?.kind === "child" && typeof reason.id === "string" && reason.id.length > 0) {
717
+ const admissionId = reason.admissionId;
718
+ return typeof admissionId === "string" && admissionId.length > 0
719
+ ? "child:" + reason.id + ":" + admissionId
720
+ : "child:" + reason.id;
721
+ }
722
+ if (reason?.kind === "user") return "user";
723
+ // fan-out 父任务原因:id 是 manager 单调分配的 parentTaskId,同一 root 的多个
724
+ // fan-out 任务各自占一个 reason;任务整体 settle 时按同一 id 精确释放。
725
+ // reason Map 仍是「同 root 多原因共享一次 pause」的模型,这里只扩展键编码。
726
+ //
727
+ // attempt 是**同一父任务的登记代次**(首次 submit=1,每次手工 retry 递增)。键必须
728
+ // 带上代次,否则「retry 在旧 reason 已释放 / async resume 在飞时复用同一 key」会被
729
+ // releaseGoalHandoff 的 resumingReasons cutoff 判定为「不是新原因」,从而在 retry
730
+ // 任务仍在运行时把 goal 提前 resume(审核缺陷)。带代次后每次登记都是新的 reason,
731
+ // resume 期间登记会触发 accept() 的 re-pause 分支,且旧代次 release 只释放旧键。
732
+ if (reason?.kind === "fanout" && typeof reason.id === "string" && reason.id.length > 0) {
733
+ const attempt = reason.attempt;
734
+ return Number.isSafeInteger(attempt) && attempt > 0
735
+ ? "fanout:" + reason.id + ":" + attempt
736
+ : "fanout:" + reason.id;
737
+ }
738
+ return void 0;
739
+ }
740
+
741
+ /**
742
+ * fan-out 等待原因的稳定字符串键(与 handoffReasonKey 的 fanout 分支同源)。
743
+ *
744
+ * 供 index.js 把「reason 键」写进持久化的归属证据(C7/item 5):证据里的 reason 必须与
745
+ * 真实 handoff record 使用的键**逐字符一致**,否则冷恢复后无法证明归属。集中在这里
746
+ * 编码可以避免两处漂移;非法输入返回 undefined。
747
+ *
748
+ * @param {string} parentTaskId - fan-out 父任务 id。
749
+ * @param {number} [attempt] - 全局单调登记代次。
750
+ * @returns {string|undefined} reason 键。
751
+ */
752
+ export function fanoutHandoffReasonKey(parentTaskId, attempt) {
753
+ return handoffReasonKey({ kind: "fanout", id: parentTaskId, attempt });
754
+ }
755
+
756
+ /** 从 Agent 或 session id 取得 handoff map key;不读取任何隐式 cwd/全局状态。 */
757
+ function handoffAgentKey(agentOrId) { if (typeof agentOrId === "string" && agentOrId.length > 0) return agentOrId;
758
+ if (typeof agentOrId?.id === "string" && agentOrId.id.length > 0) return agentOrId.id;
759
+ return void 0;
760
+ }
761
+
762
+ /** 安全记录 handoff API 异常;日志回调异常不能改变主工具结论。 */
763
+ function reportHandoffError(onError, error) {
764
+ if (typeof onError !== "function") return;
765
+ try { onError(error); } catch {}
766
+ }
767
+
768
+ /**
769
+ * 在暂停成功后保存 handoff;失败路径必须删除 provisional record,绝不留下可误 resume 的 ref。
770
+ * pause 返回的 revision 才是后续 resume 的 CAS 基准,不能复用 pause 前的 armed ref。
771
+ */
772
+ function commitPausedGoal(pending, key, record, paused, onError) {
773
+ if (pending.get(key) !== record) return false;
774
+ let phase;
775
+ let activation;
776
+ try {
777
+ phase = paused?.phase;
778
+ activation = paused?.activation;
779
+ } catch (error) {
780
+ pending.delete(key);
781
+ reportHandoffError(onError, error);
782
+ return false;
783
+ }
784
+ const ref = goalRefOf(paused);
785
+ const beforeRef = record.beforeRef;
786
+ if (phase !== "paused" || activation !== "disarmed" || ref === void 0
787
+ || beforeRef === void 0 || ref.id !== beforeRef.id || ref.revision <= beforeRef.revision) {
788
+ pending.delete(key);
789
+ reportHandoffError(onError, new Error("goal pause did not return a newer paused/disarmed GoalView for the same goal"));
790
+ return false;
791
+ }
792
+ record.ref = ref;
793
+ record.state = "paused";
794
+ delete record.operation;
795
+ delete record.beforeRef;
796
+ delete record.resumingReasons;
797
+ return true;
798
+ }
799
+
800
+ /**
801
+ * 记录 pause/get 异常并清理 provisional record。即使异步 remote adapter 在稍后失败,
802
+ * 也不会把一个未暂停的 goal 伪装成可恢复 handoff。
803
+ */
804
+ function failGoalPause(pending, key, record, onError, error) {
805
+ if (pending.get(key) === record) pending.delete(key);
806
+ reportHandoffError(onError, error);
807
+ return false;
808
+ }
809
+
810
+ /**
811
+ * 在后台结论或用户等待前真实暂停 armed durable goal。
812
+ * pending 以 root session id 为 key;同一 root 的 child/user 原因只追加到同一记录,
813
+ * 因此多个并发反馈共享一次 pause。为兼容 remote adapter,此函数按服务返回值保留
814
+ * 同步 boolean 或 Promise<boolean>;调用方必须在 concludeTurn 前等待 Promise。
815
+ * @param {object} goals - 可选 GoalService(需 get/pause)。
816
+ * @param {object} agent - exact root Agent。
817
+ * @param {Map} pending - root id -> handoff record。
818
+ * @param {{kind:string,id?:string,admissionId?:string}} reason - child/user/fanout 等待原因。
819
+ * @param {(error:unknown)=>void} [onError] - 异常记录回调。
820
+ * @returns {boolean|Promise<boolean>} pause 是否真正成功并登记 handoff。
821
+ */
822
+ export function pauseGoalForHandoff(goals, agent, pending, reason, onError) {
823
+ const key = handoffAgentKey(agent);
824
+ const reasonKey = handoffReasonKey(reason);
825
+ if (key === void 0 || reasonKey === void 0 || !(pending instanceof Map)) return false;
826
+ const current = pending.get(key);
827
+ if (current !== void 0) {
828
+ // session id 不能替代对象身份;同 id 的迟到/伪造 Agent 不得接管 root handoff。
829
+ if (current.agent !== agent || !(current.reasons instanceof Map)) return false;
830
+ if (!current.reasons.has(reasonKey)) current.reasons.set(reasonKey, false);
831
+ // 正在等待 remote pause 时,新原因复用同一 operation;pause 成功后统一保存 exact ref。
832
+ if (current.state === "pausing" || current.state === "resuming") {
833
+ // resuming 期间登记新 reason,但不能把 active goal 当作已暂停;
834
+ // caller 必须阻止 conclude,resume 完成后由 release helper 重新 pause。
835
+ return current.operation ?? false;
836
+ }
837
+ return goalRefOf(current.ref) === void 0 ? false : true;
838
+ }
839
+ if (goals === void 0 || goals === null) return void 0;
840
+ let getGoal;
841
+ let pauseGoal;
842
+ try {
843
+ getGoal = goals.get;
844
+ pauseGoal = goals.pause;
845
+ } catch (error) {
846
+ reportHandoffError(onError, error);
847
+ return false;
848
+ }
849
+ if (typeof getGoal !== "function" || typeof pauseGoal !== "function") {
850
+ reportHandoffError(onError, new Error("goal service must provide get and pause methods"));
851
+ return false;
852
+ }
853
+
854
+ // 先挂 provisional record,避免 remote get/pause 未返回期间第二个 child/user 原因重复发 pause。
855
+ const record = { agent, reasons: new Map([[reasonKey, false]]), state: "pausing", operation: void 0, beforeRef: void 0 };
856
+ pending.set(key, record);
857
+ const pauseCurrent = (goal) => {
858
+ // 同步 guard 拒绝 thenable 后会撤销 provisional record;remote get 迟到时不得
859
+ // 再继续调用 pause,否则会在用户等待开始后才改变 goal。
860
+ if (pending.get(key) !== record) return false;
861
+ const state = goalViewState(goal);
862
+ if (state.kind === "none") {
863
+ if (pending.get(key) === record) pending.delete(key);
864
+ return void 0;
865
+ }
866
+ if (state.kind !== "armed") {
867
+ return failGoalPause(pending, key, record, onError, new Error("goal get returned a malformed GoalView"));
868
+ }
869
+ const beforePauseRef = state.ref;
870
+ record.beforeRef = beforePauseRef;
871
+ let paused;
872
+ try {
873
+ paused = pauseGoal.call(goals, agent, beforePauseRef);
874
+ } catch (error) {
875
+ return failGoalPause(pending, key, record, onError, error);
876
+ }
877
+ if (isThenable(paused)) {
878
+ const operation = Promise.resolve(paused).then(
879
+ (view) => commitPausedGoal(pending, key, record, view, onError),
880
+ (error) => failGoalPause(pending, key, record, onError, error),
881
+ );
882
+ record.operation = operation;
883
+ return operation;
884
+ }
885
+ return commitPausedGoal(pending, key, record, paused, onError);
886
+ };
887
+
888
+ let goal;
889
+ try {
890
+ goal = getGoal.call(goals, agent);
891
+ } catch (error) {
892
+ return failGoalPause(pending, key, record, onError, error);
893
+ }
894
+ if (isThenable(goal)) {
895
+ const operation = Promise.resolve(goal).then(
896
+ (view) => pauseCurrent(view),
897
+ (error) => failGoalPause(pending, key, record, onError, error),
898
+ );
899
+ record.operation = operation;
900
+ return operation;
901
+ }
902
+ return pauseCurrent(goal);
903
+ }
904
+
905
+ /**
906
+ * 兼容旧插件调用点的导出名。语义已升级为真实 pause,绝不再调用 goals.disarm;
907
+ * 外部依赖可逐步迁移到 pauseGoalForHandoff,而旧记录仍由 releaseGoalHandoff 识别。
908
+ */
909
+ /**
910
+ * 供 DSH 同步 tools.guard 使用的 handoff 入口。正式 GoalService 是同步服务,
911
+ * 因此真实用户等待会在 guard 返回前完成 pause;若注入了不兼容的 thenable 服务,
912
+ * guard 不拒绝工具调用,也不保存 ref,更不会假装能在同步 guard 中 await。
913
+ */
914
+ export function pauseGoalForHandoffSync(goals, agent, pending, reason, onError) {
915
+ const key = handoffAgentKey(agent);
916
+ const reasonKey = handoffReasonKey(reason);
917
+ if (key === void 0 || reasonKey === void 0 || !(pending instanceof Map)) return false;
918
+ const previous = pending.get(key);
919
+ if (previous !== void 0) {
920
+ if (previous.agent !== agent || !(previous.reasons instanceof Map)) return false;
921
+ // 已有异步 pause 未完成时,同步 tools.guard 无法安全开始用户等待;
922
+ // 更不能留下一个永远不会由同步调用方释放的 user reason。
923
+ if (previous.state === "pausing" || previous.state === "resuming" || goalRefOf(previous.ref) === void 0) return false;
924
+ if (!previous.reasons.has(reasonKey)) previous.reasons.set(reasonKey, false);
925
+ return true;
926
+ }
927
+ // 只有 null/undefined 表示没有 GoalService;存在但不完整的 service 必须拒绝。
928
+ if (goals === void 0 || goals === null) return void 0;
929
+ let getGoal;
930
+ let pauseGoal;
931
+ try {
932
+ getGoal = goals.get;
933
+ pauseGoal = goals.pause;
934
+ } catch (error) {
935
+ reportHandoffError(onError, error);
936
+ return false;
937
+ }
938
+ if (typeof getGoal !== "function" || typeof pauseGoal !== "function") {
939
+ reportHandoffError(onError, new Error("goal service must provide get and pause methods"));
940
+ return false;
941
+ }
942
+ let goal;
943
+ try { goal = getGoal.call(goals, agent); } catch (error) {
944
+ reportHandoffError(onError, error);
945
+ return false;
946
+ }
947
+ if (isThenable(goal)) {
948
+ consumeRejectedThenable(goal);
949
+ reportHandoffError(onError, new Error("synchronous goal handoff guard cannot await a thenable GoalService"));
950
+ return false;
951
+ }
952
+ const state = goalViewState(goal);
953
+ if (state.kind === "none") return void 0;
954
+ if (state.kind !== "armed") {
955
+ reportHandoffError(onError, new Error("goal get returned a malformed GoalView"));
956
+ return false;
957
+ }
958
+ const beforePauseRef = state.ref;
959
+ const record = { agent, reasons: new Map([[reasonKey, false]]), state: "pausing", operation: void 0, beforeRef: void 0 };
960
+ pending.set(key, record);
961
+ let paused;
962
+ try { paused = pauseGoal.call(goals, agent, beforePauseRef); } catch (error) {
963
+ return failGoalPause(pending, key, record, onError, error);
964
+ }
965
+ if (isThenable(paused)) {
966
+ consumeRejectedThenable(paused);
967
+ pending.delete(key);
968
+ reportHandoffError(onError, new Error("synchronous goal handoff guard cannot await a thenable GoalService"));
969
+ return false;
970
+ }
971
+ record.beforeRef = beforePauseRef;
972
+ return commitPausedGoal(pending, key, record, paused, onError);
973
+ }
974
+
975
+ export const disarmGoalForHandoff = pauseGoalForHandoff;
976
+
977
+ /**
978
+ * 读取「已暂停且未武装」目标的可接管 ref。
979
+ *
980
+ * C7(fanout 冷恢复)需要接管上一代插件实例留下的 durable pause:进程/插件重载后
981
+ * goalHandoffs 内存 Map 为空,但 DSH 的 goal phase 仍是 paused 且 activation 为
982
+ * disarmed。此时不能再调用 goals.pause(DSH 的 pause 只接受 active→paused 转移),
983
+ * 只能以**当前视图的 ref** 作为 CAS 基准登记 record,随后由 releaseGoalHandoff 用
984
+ * goals.resume(expectCurrent 校验 revision)恢复。
985
+ *
986
+ * 只接受 paused + disarmed:active/blocked/complete 与 armed 组合一律返回 undefined,
987
+ * 调用方必须退回真实 pause 或放弃接管,绝不凭空构造 ref。
988
+ *
989
+ * @param {object} goal - GoalService.get 的返回视图。
990
+ * @returns {{id:string,revision:number}|undefined} 可接管的 exact ref。
991
+ */
992
+ export function adoptablePausedGoalRef(goal) {
993
+ try {
994
+ const ref = goalRefOf(goal);
995
+ if (ref === void 0) return void 0;
996
+ return goal.phase === "paused" && goal.activation === "disarmed" ? ref : void 0;
997
+ } catch {
998
+ return void 0;
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * 以**调用方已校验**的 exact paused ref 登记一个 handoff 等待原因(冷恢复托管路径)。
1004
+ *
1005
+ * C7 要求托管上一代实例留下的 durable pause 时必须有归属证据(goalId + 我们暂停时的
1006
+ * revision)。证据校验由调用方(index.js 的 fanout evidence)完成后,把校验过的 ref
1007
+ * 交给这里建立 record —— 本函数只负责 record 形状与 reason 键编码,**不读 GoalService、
1008
+ * 不调用 pause**,因此绝不会凭空调用它、也不会把别人的 pause 当作自己的。
1009
+ *
1010
+ * 语义与 pauseGoalForHandoff 的「已有 record」分支一致:record 存在时只追加 reason;
1011
+ * 不存在时以 state="paused" 建立记录,后续 releaseGoalHandoff 用该 ref 做 CAS resume。
1012
+ *
1013
+ * @param {object} agent - exact root Agent。
1014
+ * @param {Map} pending - root id -> handoff record。
1015
+ * @param {{kind:string,id?:string,attempt?:number}} reason - 等待原因。
1016
+ * @param {{id:string,revision:number}} ref - 已校验的 paused 视图 ref。
1017
+ * @returns {boolean} 是否登记。
1018
+ */
1019
+ export function registerPausedGoalHandoff(agent, pending, reason, ref) {
1020
+ const key = handoffAgentKey(agent);
1021
+ const reasonKey = handoffReasonKey(reason);
1022
+ const stableRef = goalRefOf(ref);
1023
+ if (key === void 0 || reasonKey === void 0 || stableRef === void 0 || !(pending instanceof Map)) return false;
1024
+ const current = pending.get(key);
1025
+ if (current !== void 0) {
1026
+ // 同 root 的迟到/伪造 Agent 不得接管既有记录(与 pauseGoalForHandoff 同一身份校验)。
1027
+ if (current.agent !== agent || !(current.reasons instanceof Map)) return false;
1028
+ // 已经处于 resuming/pausing 时不能把 durable paused 的 ref 覆盖到在飞记录上;
1029
+ // 只登记 reason,由既有 operation 的 accept() 决定 re-pause(与新代次语义一致)。
1030
+ if (current.state === "pausing" || current.state === "resuming") {
1031
+ if (!current.reasons.has(reasonKey)) current.reasons.set(reasonKey, false);
1032
+ return true;
1033
+ }
1034
+ if (!current.reasons.has(reasonKey)) current.reasons.set(reasonKey, false);
1035
+ return goalRefOf(current.ref) !== void 0;
1036
+ }
1037
+ pending.set(key, { agent, reasons: new Map([[reasonKey, false]]), state: "paused", ref: stableRef });
1038
+ return true;
1039
+ }
1040
+
1041
+ /**
1042
+ * 为 handoff 登记一个等待原因,并在目标**已经**处于 durable paused 时接管它。
1043
+ *
1044
+ * 语义分派(每次都只读一次 GoalService.get,不产生额外 pause):
1045
+ * - 已有同 root record:等价于 pauseGoalForHandoff 的幂等登记(新 reason 挂到同一 record);
1046
+ * - 目标是 armed+active:委托 pauseGoalForHandoff 执行真实 pause;
1047
+ * - 目标是 disarmed+paused:以当前 ref 建立 state="paused" 的 record(registerPausedGoalHandoff),
1048
+ * 不调用 goals.pause。后续 releaseGoalHandoff 会用它 resume;
1049
+ * - 无目标 / 非法视图 / blocked / complete:返回 false,绝不建立无释放边界的 record。
1050
+ *
1051
+ * 注意:本函数只按**视图形状**判定可接管,不知道 pause 归属。需要归属证据的调用方
1052
+ * (fanout 冷恢复)必须先自行读视图并做证据匹配,再调用 registerPausedGoalHandoff。
1053
+ *
1054
+ * @param {object} goals - GoalService(需 get,可能还需 pause)。
1055
+ * @param {object} agent - exact root Agent。
1056
+ * @param {Map} pending - root id -> handoff record。
1057
+ * @param {{kind:string,id?:string,attempt?:number}} reason - 等待原因。
1058
+ * @param {(error:unknown)=>void} [onError] - 异常记录回调。
1059
+ * @returns {boolean|Promise<boolean>|undefined} 是否已登记(undefined 语义同 pauseGoalForHandoff)。
1060
+ */
1061
+ export function adoptPausedGoalForHandoff(goals, agent, pending, reason, onError) {
1062
+ const key = handoffAgentKey(agent);
1063
+ const reasonKey = handoffReasonKey(reason);
1064
+ if (key === void 0 || reasonKey === void 0 || !(pending instanceof Map)) return false;
1065
+ // 已有 record:沿用 pauseGoalForHandoff 的既有语义(含 resuming 期间的 re-pause 门控)。
1066
+ if (pending.has(key)) return pauseGoalForHandoff(goals, agent, pending, reason, onError);
1067
+ if (goals === void 0 || goals === null) return void 0;
1068
+ let getGoal;
1069
+ try {
1070
+ getGoal = goals.get;
1071
+ } catch (error) {
1072
+ reportHandoffError(onError, error);
1073
+ return false;
1074
+ }
1075
+ if (typeof getGoal !== "function") {
1076
+ reportHandoffError(onError, new Error("goal service must provide get method"));
1077
+ return false;
1078
+ }
1079
+ const adoptView = (view) => {
1080
+ const ref = adoptablePausedGoalRef(view);
1081
+ if (ref === void 0) return pauseGoalForHandoff(goals, agent, pending, reason, onError);
1082
+ // 目标已 paused:建立可直接 resume 的 record。reasons 只含本次原因,state 已是
1083
+ // paused,因此 releaseGoalHandoff 会立刻走 resume 分支并使用 adopted ref 做 CAS。
1084
+ return registerPausedGoalHandoff(agent, pending, reason, ref);
1085
+ };
1086
+ let goal;
1087
+ try {
1088
+ goal = getGoal.call(goals, agent);
1089
+ } catch (error) {
1090
+ reportHandoffError(onError, error);
1091
+ return false;
1092
+ }
1093
+ if (isThenable(goal)) {
1094
+ return Promise.resolve(goal).then(adoptView, (error) => {
1095
+ reportHandoffError(onError, error);
1096
+ return false;
1097
+ });
1098
+ }
1099
+ return adoptView(goal);
1100
+ }
1101
+
1102
+ /**
1103
+ * 清除指定 root 的 handoff,不执行 resume。用于 root/插件销毁和卸载路径。
1104
+ * @param {Map} pending - root id -> handoff record。
1105
+ * @param {object|string} agentOrId - exact Agent 或 root session id。
1106
+ * @returns {boolean} 是否清除了记录。
1107
+ */
1108
+ export function clearGoalHandoff(pending, agentOrId) {
1109
+ if (!(pending instanceof Map)) return false;
1110
+ const key = handoffAgentKey(agentOrId);
1111
+ if (key === void 0) return false;
1112
+ const current = pending.get(key);
1113
+ if (current === void 0) return false;
1114
+ if (typeof agentOrId !== "string" && current.agent !== agentOrId) return false;
1115
+ pending.delete(key);
1116
+ return true;
1117
+ }
1118
+
1119
+ /** 仅接受真实 resume 返回的 active/armed 视图,避免 remote adapter 的空响应伪造恢复。 */
1120
+ function resumedGoalAccepted(goal, savedRef) {
1121
+ try {
1122
+ const ref = goalRefOf(goal);
1123
+ return goal?.phase === "active" && goal?.activation === "armed"
1124
+ && ref !== void 0 && savedRef !== void 0
1125
+ && ref.id === savedRef.id && ref.revision > savedRef.revision;
1126
+ } catch {
1127
+ return false;
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * resume 成功前保留 handoff record;resuming 是显式的 root barrier,避免异步
1133
+ * resume 尚未完成时新 admission 看到 paused/disarmed goal 而绕过 handoff。
1134
+ */
1135
+ function rePauseExistingHandoff(goals, pending, key, current, onError) {
1136
+ if (pending.get(key) !== current) return false;
1137
+ let getGoal;
1138
+ let pauseGoal;
1139
+ try {
1140
+ getGoal = goals?.get;
1141
+ pauseGoal = goals?.pause;
1142
+ } catch (error) {
1143
+ return failGoalPause(pending, key, current, onError, error);
1144
+ }
1145
+ if (typeof getGoal !== "function" || typeof pauseGoal !== "function") {
1146
+ return failGoalPause(pending, key, current, onError, new Error("goal service must provide get and pause methods"));
1147
+ }
1148
+ current.state = "pausing";
1149
+ current.operation = void 0;
1150
+ current.beforeRef = void 0;
1151
+ const pauseCurrent = (goal) => {
1152
+ if (pending.get(key) !== current) return false;
1153
+ const state = goalViewState(goal);
1154
+ if (state.kind !== "armed") {
1155
+ return failGoalPause(pending, key, current, onError, new Error("goal resume re-pause did not return an armed GoalView"));
1156
+ }
1157
+ current.beforeRef = state.ref;
1158
+ let paused;
1159
+ try {
1160
+ paused = pauseGoal.call(goals, current.agent, state.ref);
1161
+ } catch (error) {
1162
+ return failGoalPause(pending, key, current, onError, error);
1163
+ }
1164
+ if (isThenable(paused)) {
1165
+ const operation = Promise.resolve(paused).then(
1166
+ (view) => commitPausedGoal(pending, key, current, view, onError),
1167
+ (error) => failGoalPause(pending, key, current, onError, error),
1168
+ );
1169
+ current.operation = operation;
1170
+ return operation;
1171
+ }
1172
+ return commitPausedGoal(pending, key, current, paused, onError);
1173
+ };
1174
+ let goal;
1175
+ try { goal = getGoal.call(goals, current.agent); } catch (error) {
1176
+ return failGoalPause(pending, key, current, onError, error);
1177
+ }
1178
+ if (isThenable(goal)) {
1179
+ const operation = Promise.resolve(goal).then(pauseCurrent, (error) => failGoalPause(pending, key, current, onError, error));
1180
+ current.operation = operation;
1181
+ return operation;
1182
+ }
1183
+ return pauseCurrent(goal);
1184
+ }
1185
+
1186
+ function failResumeHandoff(pending, key, current, onError, error) {
1187
+ if (pending.get(key) !== current) return false;
1188
+ current.state = "failed";
1189
+ current.operation = void 0;
1190
+ // resume 已确定失败;删除 shared record,交由 host 的 failed 状态回调
1191
+ // 按当前 root 的全部 exact admission 做 fail-closed 收口。
1192
+ pending.delete(key);
1193
+ reportHandoffError(onError, error);
1194
+ return false;
1195
+ }
1196
+
1197
+ /**
1198
+ * 记录竞争窗口(pausing / resuming)内到达的 release 意图(P2)。
1199
+ *
1200
+ * 为什么必须显式记账:`releaseGoalHandoff` 在 resuming 状态下只把 reason 标成 true 并把
1201
+ * 在飞 operation 返回给调用方;如果这是**最后一个**未释放的原因,那么「全部原因已释放」
1202
+ * 这一事实只能由 accept() 的 re-pause 分支之后的补发动作消费。没有这份意图记录,re-pause
1203
+ * 完成后没有任何人再触发 resume → goal 永久 paused(retry 任务已经在旧 resume 落地前
1204
+ * settle 的真实竞态)。
1205
+ *
1206
+ * 记录的是原始 reason 对象:补发时必须用同一稳定键精确 release,不能凭 kid 伪造键。
1207
+ *
1208
+ * @param {object} current - handoff record。
1209
+ * @param {string} reasonKey - handoffReasonKey 编码出的稳定键。
1210
+ * @param {object} reason - 原始 reason 对象。
1211
+ */
1212
+ function recordPendingRelease(current, reasonKey, reason) {
1213
+ if (!(current.pendingReleaseReasons instanceof Map)) current.pendingReleaseReasons = new Map();
1214
+ current.pendingReleaseReasons.set(reasonKey, reason);
1215
+ }
1216
+
1217
+ /**
1218
+ * 竞争窗口结束后的补齐释放(P2)。
1219
+ *
1220
+ * 调用点:`accept()` 判定 resume 期间新增了 reason → re-pause 完成后立即调用。此时若
1221
+ * **同一代次**的全部 reason 都已释放,就必须补发一次精确 release;否则 barrier 会永远
1222
+ * 停在 paused。
1223
+ *
1224
+ * 安全边界(保证「旧 release 不能释放新任务」且「新任务运行时不得提前 armed」):
1225
+ * 1. 只操作 `pending.get(key) === current` 的**同一代次**记录(记录换代/已删除即 no-op);
1226
+ * 2. 只在 state === "paused" 时补发(pausing/resuming 由各自分支收口,failed 不恢复);
1227
+ * 3. 只有全部 reason 都已释放才补发——窗口内新登记且**未**释放的 reason 必须继续持有
1228
+ * 屏障,等待它自己的 feedback/turn barrier;
1229
+ * 4. 补发使用窗口内记录的真实 reason 对象,键不匹配即 fail-closed(宁可保持 paused)。
1230
+ *
1231
+ * @returns {boolean|Promise<boolean>} 与 releaseGoalHandoff 同语义。
1232
+ */
1233
+ function replayPendingRelease(goals, pending, key, current, onError, onResume) {
1234
+ if (pending.get(key) !== current) return false;
1235
+ if (current.state !== "paused") return false;
1236
+ if (!(current.pendingReleaseReasons instanceof Map) || current.pendingReleaseReasons.size === 0) return false;
1237
+ if (!(current.reasons instanceof Map)) return false;
1238
+ for (const released of current.reasons.values()) {
1239
+ if (released !== true) return false;
1240
+ }
1241
+ const entry = current.pendingReleaseReasons.entries().next().value;
1242
+ if (!Array.isArray(entry) || entry.length !== 2) {
1243
+ current.pendingReleaseReasons.clear();
1244
+ return false;
1245
+ }
1246
+ const [reasonKey, reason] = entry;
1247
+ current.pendingReleaseReasons.clear();
1248
+ if (reason === void 0 || handoffReasonKey(reason) !== reasonKey) return false;
1249
+ return releaseGoalHandoff(goals, pending, current.agent, reason, onError, onResume);
1250
+ }
1251
+
1252
+ /**
1253
+ * 消费一个 child/user 等待原因。全部释放后进入 resuming,并保留 record 到
1254
+ * resume/CAS 完成;resuming 期间新 reason 会加入同一 record,成功后必要时重新
1255
+ * pause,失败则由调用方以该 record 的全部 admission 做 fail-closed 收口。
1256
+ * @param {object} goals - 可选 GoalService(需 resume)。
1257
+ * @param {Map} pending - root id -> handoff record。
1258
+ * @param {object|string} agentOrId - exact Agent 或 root session id。
1259
+ * @param {{kind:string,id?:string,admissionId?:string}} reason - 已到达的等待原因。
1260
+ * @param {(error:unknown)=>void} [onError] - 异常记录回调。
1261
+ * @param {(agent:object,ref:{id:string,revision:number})=>unknown} [onResume] - 可选 resume 调用注入。
1262
+ * @returns {boolean|Promise<boolean>} 是否真正恢复;异常、CAS 失效或缺少服务均为 false。
1263
+ */
1264
+ export function releaseGoalHandoff(goals, pending, agentOrId, reason, onError, onResume) {
1265
+ if (!(pending instanceof Map)) return false;
1266
+ const key = handoffAgentKey(agentOrId);
1267
+ const reasonKey = handoffReasonKey(reason);
1268
+ if (key === void 0 || reasonKey === void 0) return false;
1269
+ const current = pending.get(key);
1270
+ if (current === void 0 || (typeof agentOrId !== "string" && current.agent !== agentOrId)) return false;
1271
+ if (!(current.reasons instanceof Map) || !current.reasons.has(reasonKey)) return false;
1272
+
1273
+ if (current.state === "resuming") {
1274
+ // 新 admission 可能已经在 resume in-flight 期间加入;它必须等待同一个
1275
+ // operation,绝不能再次调用 resume。
1276
+ current.reasons.set(reasonKey, true);
1277
+ // P2:resuming 是竞争窗口。release 的「全部原因已释放」语义必须留到 re-pause
1278
+ // 之后补发(见 replayPendingRelease),否则该代次的结算被静默吞掉。
1279
+ recordPendingRelease(current, reasonKey, reason);
1280
+ return current.operation ?? false;
1281
+ }
1282
+ if (current.state === "failed") return false;
1283
+ current.reasons.set(reasonKey, true);
1284
+ for (const released of current.reasons.values()) {
1285
+ if (released !== true) return false;
1286
+ }
1287
+ // remote pause 尚未完成时,待其先保存暂停后的 ref 再递归执行同一门控;不会提前 resume。
1288
+ if (current.state === "pausing") {
1289
+ // P2:pausing 同样是竞争窗口。正常路径由下面的递归 release 收口(等在飞 operation
1290
+ // 落地后重放同一 release);这里额外记账,覆盖 operation 不可 await 的异常形态。
1291
+ recordPendingRelease(current, reasonKey, reason);
1292
+ if (!isThenable(current.operation)) return false;
1293
+ return Promise.resolve(current.operation).then((paused) => paused === true
1294
+ ? releaseGoalHandoff(goals, pending, agentOrId, reason, onError, onResume)
1295
+ : false);
1296
+ }
1297
+
1298
+ const ref = current.ref;
1299
+ if (goalRefOf(ref) === void 0) return false;
1300
+ let resumeGoal;
1301
+ if (typeof onResume !== "function") {
1302
+ try { resumeGoal = goals?.resume; } catch (error) {
1303
+ return failResumeHandoff(pending, key, current, onError, error);
1304
+ }
1305
+ if (typeof resumeGoal !== "function") {
1306
+ return failResumeHandoff(pending, key, current, onError, new Error("goal service must provide resume method"));
1307
+ }
1308
+ }
1309
+ current.state = "resuming";
1310
+ // 记录 resume 发起时的 reason cutoff;resume 期间新增的 reason 即使
1311
+ // 在 Promise resolve 前收到 feedback 变为 true,也必须触发 re-pause。
1312
+ current.resumingReasons = new Set(current.reasons.keys());
1313
+ let resumed;
1314
+ try {
1315
+ resumed = typeof onResume === "function"
1316
+ ? onResume(current.agent, ref)
1317
+ : resumeGoal.call(goals, current.agent, ref);
1318
+ } catch (error) {
1319
+ return failResumeHandoff(pending, key, current, onError, error);
1320
+ }
1321
+ const accept = (view) => {
1322
+ if (pending.get(key) !== current) return false;
1323
+ if (!resumedGoalAccepted(view, ref)) {
1324
+ return failResumeHandoff(pending, key, current, onError, new Error("goal resume did not return a newer active armed GoalView for the same goal"));
1325
+ }
1326
+ const hasNewReason = [...current.reasons.keys()].some((reasonKey) =>
1327
+ !(current.resumingReasons instanceof Set) || !current.resumingReasons.has(reasonKey));
1328
+ if (hasNewReason) {
1329
+ // resume 后不能把新 admission 留在 active goal 上;重新 pause,待新 admission
1330
+ // 的 feedback/turn barrier 再触发下一次 resume。
1331
+ const repaused = rePauseExistingHandoff(goals, pending, key, current, onError);
1332
+ // P2:re-pause 完成后必须补发竞争窗口内到达的 release。若该代次在窗口内已经
1333
+ // settle(全部 reason 已释放),re-pause 就是唯一还没人负责的边界——不补发就
1334
+ // 没有任何人再触发 resume,goal 永久 paused。返回值沿用补发结果,使
1335
+ // releaseGoalHandoffState 能正确区分 resumed / waiting / failed。
1336
+ const afterRepause = () => {
1337
+ const replayed = replayPendingRelease(goals, pending, key, current, onError, onResume);
1338
+ if (isThenable(replayed)) {
1339
+ return Promise.resolve(replayed).then((value) => value === true, () => false);
1340
+ }
1341
+ return replayed === true;
1342
+ };
1343
+ return isThenable(repaused) ? Promise.resolve(repaused).then(afterRepause, afterRepause) : afterRepause();
1344
+ }
1345
+ current.state = "resumed";
1346
+ current.operation = void 0;
1347
+ delete current.resumingReasons;
1348
+ pending.delete(key);
1349
+ return true;
1350
+ };
1351
+ if (isThenable(resumed)) {
1352
+ const operation = Promise.resolve(resumed).then(accept, (error) => failResumeHandoff(pending, key, current, onError, error));
1353
+ current.operation = operation;
1354
+ return operation;
1355
+ }
1356
+ return accept(resumed);
1357
+ }
1358
+
1359
+ /**
1360
+ * 把 release 的兼容 boolean 细化为事件层可消费的状态。无记录表示本 turn 不受
1361
+ * goal handoff 约束;waiting 表示还有 child/user 原因,不能先消费专家结果;
1362
+ * failed 表示最后一次 resume 未成功,调用方只能做终态清理而不能触发自动恢复。
1363
+ * @returns {"none"|"waiting"|"resumed"|"failed"|Promise<"none"|"waiting"|"resumed"|"failed">}
1364
+ */
1365
+ export function releaseGoalHandoffState(goals, pending, agentOrId, reason, onError, onResume) {
1366
+ if (!(pending instanceof Map)) return "none";
1367
+ const key = handoffAgentKey(agentOrId);
1368
+ if (key === void 0) return "none";
1369
+ const before = pending.get(key);
1370
+ if (before === void 0 || (typeof agentOrId !== "string" && before.agent !== agentOrId)) return "none";
1371
+ const classify = (resumed) => {
1372
+ if (resumed === true) return "resumed";
1373
+ // resume failure 会删除旧 record;re-pause 成功则保留同一 record,
1374
+ // 新 admission 仍在 cohort barrier 中等待,不能误报为 failed。
1375
+ if (pending.get(key) === before) {
1376
+ if (before.state === "resuming") return "resuming";
1377
+ return "waiting";
1378
+ }
1379
+ // record 已被删除:只有真正以 resumed 终态收口(accept 成功删除记录)才算恢复成功,
1380
+ // 包括「旧 resume 落地 → re-pause → 补发 release」这条异步链在调用方拿到 resolve
1381
+ // 值之前就已完成的情况。其余删除路径(pause/resume 失败、goal 消失)仍是 fail-closed
1382
+ // 的 failed。
1383
+ return before.state === "resumed" ? "resumed" : "failed";
1384
+ };
1385
+ const released = releaseGoalHandoff(goals, pending, agentOrId, reason, onError, onResume);
1386
+ if (isThenable(released)) return Promise.resolve(released).then(classify, () => classify(false));
1387
+ return classify(released);
1388
+ }
1389
+
1390
+ /**
1391
+ * 后台专家成功越过 admission 后的 root turn 结论策略。
1392
+ * 只有真实 continuable 后台结果才运行 beforeConclude;其返回 false 或 reject 表示
1393
+ * pause/handoff 未完成,必须保留当前 root turn,不能把未暂停的 goal 伪装成 ready。
1394
+ * @param {object} exec - DSH ToolRunContext。
1395
+ * @param {object} outcome - 委派 helper 的返回值。
1396
+ * @param {object} [options] - { runInBackground, continueParentTurn, beforeConclude }。
1397
+ * @returns {boolean|Promise<boolean>} 是否实际发出 concludeTurn。
1398
+ */
1399
+ export function concludeBackgroundDelegation(exec, outcome, options = {}) {
1400
+ // runInBackground 只是兼容输入;是否收口只由父代理是否声明继续当前 turn 决定。
1401
+ if (options?.continueParentTurn === true) return false;
1402
+ // reused 只是已记录任务的查询结果,没有向 child 投递新消息;若为它 pause/conclude,
1403
+ // root 会等待一个永远不会产生的新反馈。queued/steer 仍是新 admission,不受影响。
1404
+ if (outcome?.reused === true) return false;
1405
+ if (outcome?.kind !== "continuable" || typeof outcome?.subagentId !== "string" || outcome.subagentId.length === 0) return false;
1406
+ if (typeof exec?.concludeTurn !== "function") return false;
1407
+ const conclude = (permission) => {
1408
+ if (permission === false) return false;
1409
+ exec.concludeTurn();
1410
+ return true;
1411
+ };
1412
+ if (typeof options?.beforeConclude !== "function") return conclude();
1413
+ let before;
1414
+ try {
1415
+ before = options.beforeConclude(exec, outcome);
1416
+ } catch (error) {
1417
+ reportHandoffError(options?.onBeforeConcludeError, error);
1418
+ return false;
1419
+ }
1420
+ if (isThenable(before)) {
1421
+ return Promise.resolve(before).then(conclude, (error) => {
1422
+ reportHandoffError(options?.onBeforeConcludeError, error);
1423
+ return false;
1424
+ });
1425
+ }
1426
+ return conclude(before);
1427
+ }
1428
+
1429
+ /**
1430
+ * 从结构化 ToolExecution 读取 root 文件操作目标。DSH PTC 的每个结构化
1431
+ * 内层 dispatch 同样经过工具 guard,并直接呈现内层 name/arguments;因此这里只
1432
+ * 读取已解析的 arguments.file_path/path,绝不解析 PTC transport、bash 或 pwsh 文本。
1433
+ * @param {object} [execution] - ToolExecution 或结构化内层执行对象。
1434
+ * @returns {{toolName:string, path:string|null, workspaceWide:boolean}|undefined}
1435
+ */
1436
+ export function structuredToolTarget(execution) {
1437
+ const toolName = execution?.name;
1438
+ if (!["read", "edit", "write", "glob", "grep"].includes(toolName)) return void 0;
1439
+ const args = execution?.arguments;
1440
+ const field = toolName === "glob" || toolName === "grep" ? "path" : "file_path";
1441
+ const value = args !== null && typeof args === "object" && typeof args[field] === "string"
1442
+ ? args[field].trim()
1443
+ : "";
1444
+ if (value.length > 0) return { toolName, path: value, workspaceWide: false };
1445
+ if (toolName === "glob" || toolName === "grep") return { toolName, path: null, workspaceWide: true };
1446
+ return void 0;
1447
+ }
1448
+
1449
+ function activeTaskPathsForGuard(task, workspaceRoot) {
1450
+ if (!Array.isArray(task?.affectedPaths)) return [];
1451
+ return normalizeAffectedPathsForWorkspace(task.affectedPaths, workspaceRoot);
1452
+ }
1453
+
1454
+ function activeTaskGuardLabel(task) {
1455
+ const role = typeof task?.role === "string" && task.role.length > 0 ? task.role : "unknown role";
1456
+ const description = typeof task?.description === "string" && task.description.length > 0
1457
+ ? task.description
1458
+ : typeof task?.taskKey === "string" && task.taskKey.length > 0 ? task.taskKey : "unnamed task";
1459
+ return role + " (" + description + ")";
1460
+ }
1461
+
1462
+ /**
1463
+ * 检查 root 自身结构化文件操作是否会碰到活动专家范围。
1464
+ * 返回值遵循 tools.guard 的约定:字符串表示拒绝,undefined 表示放行。
1465
+ * - 任一活动任务没有 affectedPaths 时先拒绝,避免把未知范围误当作安全;
1466
+ * - glob/grep 无 path 视为 workspace-wide 未知范围;
1467
+ * - 明确路径只比较 normalizeAffectedPaths 与活动范围的交集;
1468
+ * - rootAgentId 与 execution.agent.id 不一致时直接放行,防止 root guard 影响 child。
1469
+ * @param {object} [execution] - 当前 ToolExecution(也可能是 PTC transport 内层执行)。
1470
+ * @param {Array} [activeTasks] - 当前 root 的 tracker.listActiveTasks 快照。
1471
+ * @param {string} [rootAgentId] - 安装 guard 的 root session id。
1472
+ * @param {string|object} [workspaceRootOrOptions] - 稳定 workspace root;也接受
1473
+ * {workspaceRoot} 选项以便调用方显式传参。
1474
+ * @returns {string|undefined} 拒绝原因或 undefined。
1475
+ */
1476
+ export function rootStructuredPathGuard(execution, activeTasks, rootAgentId, workspaceRootOrOptions) {
1477
+ const workspaceRoot = typeof workspaceRootOrOptions === "string"
1478
+ ? workspaceRootOrOptions
1479
+ : workspaceRootOrOptions?.workspaceRoot;
1480
+ if (typeof rootAgentId === "string" && execution?.agent?.id !== rootAgentId) return void 0;
1481
+ const target = structuredToolTarget(execution);
1482
+ if (target === void 0) return void 0;
1483
+ const tasks = Array.isArray(activeTasks) ? activeTasks : [];
1484
+ if (tasks.length === 0) return void 0;
1485
+ const missing = tasks.filter((task) => activeTaskPathsForGuard(task, workspaceRoot).length === 0);
1486
+ if (missing.length > 0) {
1487
+ const labels = missing.map(activeTaskGuardLabel).join(", ");
1488
+ return "Root structured file operation cannot safely continue because affectedPaths is missing for active expert task(s): " + labels + ".";
1489
+ }
1490
+ if (target.workspaceWide) {
1491
+ const labels = tasks.map(activeTaskGuardLabel).join(", ");
1492
+ return "Root structured file operation cannot safely continue because " + target.toolName + " has an unknown/workspace-wide path that may overlap active expert task(s): " + labels + ".";
1493
+ }
1494
+ const targetPaths = normalizeAffectedPathsForWorkspace([target.path], workspaceRoot);
1495
+ const conflicts = [];
1496
+ for (const task of tasks) {
1497
+ const activePaths = activeTaskPathsForGuard(task, workspaceRoot);
1498
+ const overlaps = [];
1499
+ for (const incoming of targetPaths) {
1500
+ for (const active of activePaths) {
1501
+ if (affectedPathEntriesOverlap(incoming, active)) overlaps.push({ incoming, active });
1502
+ }
1503
+ }
1504
+ if (overlaps.length > 0) conflicts.push({ task, overlaps });
1505
+ }
1506
+ if (conflicts.length === 0) return void 0;
1507
+ const details = conflicts.map(({ task, overlaps }) =>
1508
+ activeTaskGuardLabel(task) + " (" + overlaps.map(({ incoming, active }) => incoming.value + " <-> " + active.value).join(", ") + ")",
1509
+ ).join("; ");
1510
+ return "Root structured file operation " + target.toolName + " on " + target.path + " is blocked because it overlaps active expert work: " + details + ".";
1511
+ }
1512
+
1513
+ /**
1514
+ * 把一次委派的 prompt 文本归一化为稳定的任务 key:
1515
+ * NFKC(全角→半角)→ trim → 连续空白折叠为单个空格 → 英文小写。
1516
+ * 归一化后完全相同的任务会被识别为重复,触发「复用」而不是重复启动。
1517
+ */
1518
+ export function delegationTaskKey(text) {
1519
+ return String(text ?? "")
1520
+ .normalize("NFKC")
1521
+ .trim()
1522
+ .replace(/\s+/g, " ")
1523
+ .toLowerCase();
1524
+ }
1525
+
1526
+ /**
1527
+ * 同一父代理 + 同一角色的临界区/复用 scope key。
1528
+ * parentId 是 session id(UUID),role 是英文字母,用 "::" 分隔不会冲突。
1529
+ */
1530
+ export function delegationScopeKey(parentId, role) {
1531
+ return `${String(parentId)}::${String(role)}`;
1532
+ }
1533
+
1534
+ /**
1535
+ * 归一化专家任务声明的影响范围。未带类型前缀的字符串按路径处理,末尾斜杠
1536
+ * 明确表示目录;file:/dir:/directory:/symbol: 前缀可消除歧义。路径统一使用
1537
+ * 小写正斜杠,符号保留大小写,避免把大小写敏感语言的不同符号误判为相同范围。
1538
+ * @param {unknown} affectedPaths - 工具输入的字符串数组。
1539
+ * @returns {Array<{kind:"path"|"file"|"directory"|"symbol", value:string}>} 去重后的范围。
1540
+ */
1541
+ export function normalizeAffectedPaths(affectedPaths) {
1542
+ if (!Array.isArray(affectedPaths)) return [];
1543
+ const result = [];
1544
+ const seen = new Set();
1545
+ for (const item of affectedPaths) {
1546
+ if (typeof item !== "string") continue;
1547
+ const raw = item.normalize("NFKC").trim();
1548
+ if (raw.length === 0) continue;
1549
+ const typed = /^(file|dir|directory|symbol)\s*:(.*)$/i.exec(raw);
1550
+ const type = typed?.[1]?.toLowerCase();
1551
+ if (type === "symbol") {
1552
+ const value = String(typed?.[2] ?? "").trim();
1553
+ if (value.length === 0) continue;
1554
+ const entry = { kind: "symbol", value };
1555
+ const key = `${entry.kind}:${entry.value}`;
1556
+ if (!seen.has(key)) {
1557
+ seen.add(key);
1558
+ result.push(entry);
1559
+ }
1560
+ continue;
1561
+ }
1562
+ const hadTrailingSlash = /[\\/]$/.test(raw);
1563
+ let value = String(typed?.[2] ?? raw)
1564
+ .trim()
1565
+ .replace(/\\/g, "/")
1566
+ .replace(/\/{2,}/g, "/");
1567
+ while (value.startsWith("./")) value = value.slice(2);
1568
+ value = value.replace(/\/+$/, "").toLowerCase();
1569
+ if (value.length === 0) continue;
1570
+ const entry = {
1571
+ kind: type === "file" ? "file" : type === "dir" || type === "directory" || hadTrailingSlash ? "directory" : "path",
1572
+ value,
1573
+ };
1574
+ const key = `${entry.kind}:${entry.value}`;
1575
+ if (!seen.has(key)) {
1576
+ seen.add(key);
1577
+ result.push(entry);
1578
+ }
1579
+ }
1580
+ return result;
1581
+ }
1582
+
1583
+ function canonicalWorkspacePath(value) {
1584
+ if (typeof value !== "string") return "";
1585
+ let raw = value.normalize("NFKC").trim().replace(/\\/g, "/");
1586
+ if (raw.length === 0) return "";
1587
+ const unc = raw.startsWith("//");
1588
+ raw = raw.replace(/\/{2,}/g, "/");
1589
+ let prefix = "";
1590
+ let rest = raw;
1591
+ let absolute = false;
1592
+ const drive = /^([A-Za-z]):\//.exec(raw);
1593
+ if (drive !== null) {
1594
+ prefix = drive[1].toLowerCase() + ":/";
1595
+ rest = raw.slice(3);
1596
+ absolute = true;
1597
+ } else if (unc) {
1598
+ prefix = "//";
1599
+ rest = raw.replace(/^\/+/, "");
1600
+ absolute = true;
1601
+ } else if (raw.startsWith("/")) {
1602
+ prefix = "/";
1603
+ rest = raw.replace(/^\/+/, "");
1604
+ absolute = true;
1605
+ }
1606
+ const segments = [];
1607
+ for (const segment of rest.split("/")) {
1608
+ if (segment.length === 0 || segment === ".") continue;
1609
+ if (segment === "..") {
1610
+ if (segments.length > 0 && segments[segments.length - 1] !== "..") segments.pop();
1611
+ else if (!absolute) segments.push("..");
1612
+ continue;
1613
+ }
1614
+ segments.push(segment.toLowerCase());
1615
+ }
1616
+ const joined = segments.join("/");
1617
+ if (prefix === "/") return joined.length > 0 ? "/" + joined : "/";
1618
+ if (prefix === "//") return joined.length > 0 ? "//" + joined : "//";
1619
+ if (prefix.length > 0) return prefix + joined;
1620
+ return joined.length > 0 ? joined : ".";
1621
+ }
1622
+
1623
+ /**
1624
+ * 将路径统一到可选 workspace root 下的稳定形式。相对路径只在传入 workspaceRoot
1625
+ * 时解析,绝不读取 process.cwd;绝对路径直接规范化。Windows 分隔符、大小写、
1626
+ * 重复分隔符和 . / .. 片段使用同一规则处理,便于 root 与专家声明相互比较。
1627
+ * @param {unknown} value - 文件或目录路径。
1628
+ * @param {unknown} workspaceRoot - 可选稳定工作区根目录。
1629
+ * @returns {string} 规范化后的路径;非法/空输入返回空字符串。
1630
+ */
1631
+ export function normalizeWorkspacePath(value, workspaceRoot) {
1632
+ if (typeof value !== "string") return "";
1633
+ const path = value.normalize("NFKC").trim().replace(/\\/g, "/");
1634
+ if (path.length === 0) return "";
1635
+ const absolute = path.startsWith("/") || /^[A-Za-z]:\//.test(path);
1636
+ if (absolute || typeof workspaceRoot !== "string" || workspaceRoot.trim().length === 0) {
1637
+ return canonicalWorkspacePath(path);
1638
+ }
1639
+ const root = canonicalWorkspacePath(workspaceRoot);
1640
+ return canonicalWorkspacePath(root.length === 0 || root === "." ? path : root + "/" + path);
1641
+ }
1642
+
1643
+ /**
1644
+ * 统一一组专家影响范围;除字符串外也接受 tracker 快照中的已归一化范围对象,
1645
+ * 这样 root guard 可在不改变既有 tracker API 的前提下重新绑定 workspace root。
1646
+ * symbol 不做路径解析,只保持原有精确相等语义。
1647
+ * @param {unknown} affectedPaths - 字符串或 {kind,value} 范围数组。
1648
+ * @param {unknown} workspaceRoot - 可选稳定工作区根目录。
1649
+ * @returns {Array<{kind:string,value:string}>} 统一后的范围。
1650
+ */
1651
+ export function normalizeAffectedPathsForWorkspace(affectedPaths, workspaceRoot) {
1652
+ if (!Array.isArray(affectedPaths)) return [];
1653
+ const raw = [];
1654
+ for (const item of affectedPaths) {
1655
+ if (typeof item === "string") {
1656
+ raw.push(item);
1657
+ continue;
1658
+ }
1659
+ if (item === null || typeof item !== "object" || typeof item.value !== "string") continue;
1660
+ if (item.kind === "symbol") raw.push("symbol:" + item.value);
1661
+ else if (item.kind === "file") raw.push("file:" + item.value);
1662
+ else if (item.kind === "directory") raw.push("dir:" + item.value);
1663
+ else raw.push(item.value);
1664
+ }
1665
+ return normalizeAffectedPaths(raw).map((entry) => entry.kind === "symbol"
1666
+ ? entry
1667
+ : { ...entry, value: normalizeWorkspacePath(entry.value, workspaceRoot) },
1668
+ ).filter((entry) => entry.value.length > 0);
1669
+ }
1670
+
1671
+ /**
1672
+ * 判断两个已归一化影响范围是否相交。符号只在完全相同时相交;文件不会被
1673
+ * 当作祖先,目录和未显式标记类型的路径则会与其后代相交。
1674
+ * @param {object} left - normalizeAffectedPaths 返回的单个范围。
1675
+ * @param {object} right - normalizeAffectedPaths 返回的单个范围。
1676
+ * @returns {boolean} 存在相交范围返回 true。
1677
+ */
1678
+ export function affectedPathEntriesOverlap(left, right) {
1679
+ if (left === null || typeof left !== "object" || right === null || typeof right !== "object") return false;
1680
+ if (typeof left.value !== "string" || typeof right.value !== "string") return false;
1681
+ if (left.kind === "symbol" || right.kind === "symbol") {
1682
+ return left.kind === "symbol" && right.kind === "symbol" && left.value === right.value;
1683
+ }
1684
+ if (left.value === right.value) return true;
1685
+ if (left.value.startsWith(`${right.value}/`)) return right.kind !== "file";
1686
+ if (right.value.startsWith(`${left.value}/`)) return left.kind !== "file";
1687
+ return false;
1688
+ }
1689
+
1690
+ /**
1691
+ * 返回两组影响范围的全部交集对,供活动任务登记和单元测试复用。
1692
+ * @param {unknown} left - 第一组范围。
1693
+ * @param {unknown} right - 第二组范围。
1694
+ * @returns {Array<{left:object, right:object}>} 每一项是一对相交范围。
1695
+ */
1696
+ export function findAffectedPathOverlaps(left, right) {
1697
+ const leftEntries = normalizeAffectedPaths(left);
1698
+ const rightEntries = normalizeAffectedPaths(right);
1699
+ const overlaps = [];
1700
+ for (const leftEntry of leftEntries) {
1701
+ for (const rightEntry of rightEntries) {
1702
+ if (affectedPathEntriesOverlap(leftEntry, rightEntry)) overlaps.push({ left: leftEntry, right: rightEntry });
1703
+ }
1704
+ }
1705
+ return overlaps;
1706
+ }
1707
+
1708
+ /**
1709
+ * 从新建预登记 FIFO 队列取第一个匹配 parentId+role 的待创建项(P3,
1710
+ * startContinuable 新建路径的 binding acceptance 竞态预登记):
1711
+ * agent/created 同步触发且早于 startContinuable resolve,需要按
1712
+ * parentId+role 找到本次新建的预登记项并立即写入 pendingMessage 绑定。
1713
+ * 父+角色串行(delegationTracker.serialize)保证同 scope 最多一个匹配项;
1714
+ * 不同角色互不干扰。匹配即从队列删除并返回;无匹配返回 undefined。
1715
+ * @param {Array} list - pendingCreations 队列(元素 { taskKey, parentId, role })。
1716
+ * @param {string} parentId - 父代理 session id。
1717
+ * @param {string} role - 角色名。
1718
+ * @param {object} [parent] - 可选的 exact root Agent;提供时拒绝同 id 旧代际。
1719
+ * @returns {object|undefined} 匹配项(已从队列移除);无匹配返回 undefined。
1720
+ */
1721
+ export function takePendingCreation(list, parentId, role, parent) {
1722
+ if (!Array.isArray(list)) return void 0;
1723
+ const idx = list.findIndex(
1724
+ (item) => item !== null && typeof item === "object" && item.parentId === parentId && item.role === role
1725
+ && (parent === void 0 || item.parent === parent),
1726
+ );
1727
+ if (idx === -1) return void 0;
1728
+ return list.splice(idx, 1)[0];
1729
+ }
1730
+
1731
+ /**
1732
+ * 回滚已被 agent/created 同步消费、但 startContinuable 未返回有效 messageId 的
1733
+ * 新建 acceptance。通过 transaction.record/childId/taskKey 做 exact ownership 判断,
1734
+ * 不触碰既有 recovery op 或其他 task;保留 durable record 时标记失效,
1735
+ * 让调用方禁止后续错误 reused。
1736
+ */
1737
+ export function rollbackCreatedChildAcceptance({ recoveryRecords, transaction, releaseActivity, dropRecord, message = "child acceptance failed" } = {}) {
1738
+ if (!(recoveryRecords instanceof Map) || transaction === null || typeof transaction !== "object") {
1739
+ return { activityReleased: false, bindingRemoved: false, recordRemoved: false };
1740
+ }
1741
+ let activityReleased = false;
1742
+ if (transaction.activityId !== void 0 && typeof releaseActivity === "function") {
1743
+ try { activityReleased = releaseActivity(transaction.activityId) === true; } catch {}
1744
+ }
1745
+ const childId = transaction.childId;
1746
+ const record = transaction.record;
1747
+ if (!validStringValue(childId) || record === void 0 || recoveryRecords.get(childId) !== record) {
1748
+ return { activityReleased, bindingRemoved: false, recordRemoved: false };
1749
+ }
1750
+ let bindingRemoved = false;
1751
+ const binding = record.tasks?.get(transaction.taskKey);
1752
+ if (transaction.taskBindingOwned === true && binding === transaction.binding
1753
+ && binding?.childId === childId) {
1754
+ if (transaction.previousBinding === void 0) record.tasks.delete(transaction.taskKey);
1755
+ else record.tasks.set(transaction.taskKey, transaction.previousBinding);
1756
+ record.taskRecoveryContexts?.delete(transaction.taskKey);
1757
+ bindingRemoved = true;
1758
+ }
1759
+ if (transaction.recordOwned !== true) {
1760
+ record.invalidAcceptance = true;
1761
+ return { activityReleased, bindingRemoved, recordRemoved: false };
1762
+ }
1763
+ const noDurableState = (record.tasks?.size ?? 0) === 0
1764
+ && (record.pendingRecoveries?.size ?? 0) === 0;
1765
+ if (!noDurableState) {
1766
+ // 事务创建后若又出现 durable task/op,也不能删除 record;同时
1767
+ // 禁止这次失败 acceptance 让 reuseOrSteer 静默复用该 child。
1768
+ record.invalidAcceptance = true;
1769
+ return { activityReleased, bindingRemoved, recordRemoved: false };
1770
+ }
1771
+ let recordRemoved = false;
1772
+ if (typeof dropRecord === "function") {
1773
+ try { dropRecord(childId, message, record); recordRemoved = recoveryRecords.get(childId) !== record; } catch {}
1774
+ }
1775
+ // callback 异常或 no-op 时也必须再次校验 record identity;不能把同 childId
1776
+ // 的并发替代 record 当作本事务目标删除。
1777
+ if (!recordRemoved && recoveryRecords.get(childId) === record) recordRemoved = recoveryRecords.delete(childId);
1778
+ return { activityReleased, bindingRemoved, recordRemoved };
1779
+ }
1780
+
1781
+ function validStringValue(value) {
1782
+ return typeof value === "string" && value.length > 0;
1783
+ }
1784
+
1785
+ /**
1786
+ * 任务指纹默认 TTL:10 分钟。同一归一化 prompt 只在此窗口内视为「重复任务」,
1787
+ * 到期后惰性删除,相同 prompt 可再次进入复用判定——
1788
+ * 避免相同任务在父会话整个生命周期内永久失效(例如修复后重试同一 prompt)。
1789
+ */
1790
+ export const TASK_TTL_DEFAULT_MS = 10 * 60 * 1000;
1791
+
1792
+ /**
1793
+ * 创建轻量委派跟踪器:按 scope(父代理 + 角色)做 FIFO 串行临界区、
1794
+ * 记录当前可复用 child 与其 route(provider/model/reasoningEffort 组合)、
1795
+ * 以及最近任务的 LRU 记忆(默认上限 32 条)。纯内存、无依赖、可单测。
1796
+ * @param {object} [options] - 可选配置:
1797
+ * - taskLimit: 任务记忆 LRU 上限(默认 32);
1798
+ * - taskTtlMs: 任务指纹有效期(毫秒,默认 TASK_TTL_DEFAULT_MS,10 分钟);
1799
+ * 过期记录在 hasTask 时惰性删除;
1800
+ * - now: 时钟注入函数(默认 () => Date.now()),测试可伪造时间验证 TTL,
1801
+ * 无需真实等待。
1802
+ */
1803
+ export function createDelegationTracker(options = {}) {
1804
+ // LRU 上限:超过后淘汰最久未用的任务 key,防止长期运行的内存增长。
1805
+ const taskLimit = Number.isSafeInteger(options?.taskLimit) && options.taskLimit > 0 ? options.taskLimit : 32;
1806
+ // 任务指纹 TTL:正有限毫秒数生效,否则回落默认 10 分钟。
1807
+ const taskTtlMs = Number.isFinite(options?.taskTtlMs) && options.taskTtlMs > 0 ? options.taskTtlMs : TASK_TTL_DEFAULT_MS;
1808
+ // 时钟注入:默认真实时间;测试传 now() 伪造时钟即可验证过期行为。
1809
+ const now = typeof options?.now === "function" ? options.now : () => Date.now();
1810
+ /** scope -> { childId, routeKey, tasks: Map(key -> 记住时刻, 插入序=LRU), queue: Promise 链 } */
1811
+ const scopes = new Map();
1812
+ /** parentId -> Promise:只保护范围预检和登记这个短临界区,不串行专家实际执行。 */
1813
+ const parentQueues = new Map();
1814
+ /** activityId -> 活动任务范围;仅当前插件进程内存,不写入 settings 或跨会话存储。 */
1815
+ const activeTasks = new Map();
1816
+ let activeTaskSeq = 0;
1817
+
1818
+ function copyActiveTask(task) {
1819
+ return {
1820
+ ...task,
1821
+ affectedPaths: task.affectedPaths.map((path) => ({ ...path })),
1822
+ };
1823
+ }
1824
+
1825
+ function activeTasksForParent(parentId) {
1826
+ const id = String(parentId);
1827
+ return [...activeTasks.values()].filter((task) => task.parentId === id);
1828
+ }
1829
+
1830
+ /** 把可迭代的任务 key 或活动 id 归一为字符串 Set,供终态关联复用。 */
1831
+ function stringSet(values) {
1832
+ const result = new Set();
1833
+ if (values === null || values === void 0 || typeof values[Symbol.iterator] !== "function") return result;
1834
+ for (const value of values) {
1835
+ if (typeof value === "string") result.add(value);
1836
+ }
1837
+ return result;
1838
+ }
1839
+
1840
+ /**
1841
+ * 用稳定 admission messageId 优先定位本 child 的活动任务;taskKey 只在消息
1842
+ * 关联不可用或恢复 op 显式携带 key 时作为辅助。活动登记独立于 scope.tasks
1843
+ * TTL/LRU 和 recovery record.tasks 的上限,因此任务绑定被淘汰后仍可终态清理。
1844
+ */
1845
+ function matchingActiveTaskIdsForTurn({ parentId, role, childId, turnMsgIds, taskKeys } = {}) {
1846
+ const messageIds = stringSet(turnMsgIds);
1847
+ const keys = stringSet(taskKeys);
1848
+ const messageMatches = [];
1849
+ const keyFallbacks = [];
1850
+ for (const [activityId, task] of activeTasks) {
1851
+ if (task.parentId !== String(parentId ?? "") || task.role !== String(role ?? "") || task.childId !== childId) continue;
1852
+ if (typeof task.messageId === "string" && messageIds.has(task.messageId)) {
1853
+ messageMatches.push(activityId);
1854
+ } else if (typeof task.messageId !== "string" && keys.has(task.taskKey)) {
1855
+ // taskKey 只能兜底尚未获得 admission messageId 的早期竞态。若同 child
1856
+ // 已有任何稳定消息命中,绝不按 key 扩大集合,避免 TTL 后同 key 新任务
1857
+ // 被旧 turn 误删。
1858
+ keyFallbacks.push(activityId);
1859
+ }
1860
+ }
1861
+ return messageMatches.length > 0 ? messageMatches : keyFallbacks;
1862
+ }
1863
+
1864
+ function scopeWarningFor(conflicts, missing, allowOverlap, missingIncoming = false) {
1865
+ const parts = [];
1866
+ if (conflicts.length > 0) {
1867
+ const details = conflicts.map(({ task, overlaps }) => {
1868
+ const label = task.description.length > 0 ? task.description : task.taskKey;
1869
+ const ranges = overlaps.map(({ incoming, active }) => incoming.value + " <-> " + active.value).join(", ");
1870
+ return task.role + " (" + label + "): " + ranges;
1871
+ }).join("; ");
1872
+ parts.push("Affected-path conflict advisory: this task overlaps active expert work: " + details + "." + (allowOverlap === true
1873
+ ? " Continuing only because allowOverlap=true was explicitly set."
1874
+ : " It was not started; set allowOverlap=true only after explicitly deciding that the overlap is safe."));
1875
+ }
1876
+ if (missingIncoming === true) {
1877
+ parts.push("Affected-path conflict check cannot be completed because the new task is missing affectedPaths.");
1878
+ }
1879
+ if (missing.length > 0) {
1880
+ const labels = missing.map((task) => task.role + " (" + (task.description.length > 0 ? task.description : task.taskKey) + ")").join(", ");
1881
+ parts.push("Affected-path conflict check cannot be completed because affectedPaths is missing for " + labels + ".");
1882
+ }
1883
+ return parts.join(" ");
1884
+ }
1885
+
1886
+ function entryOf(scope) {
1887
+ let entry = scopes.get(scope);
1888
+ if (entry === void 0) {
1889
+ entry = { childId: void 0, routeKey: void 0, tasks: new Map(), queue: Promise.resolve() };
1890
+ scopes.set(scope, entry);
1891
+ }
1892
+ return entry;
1893
+ }
1894
+
1895
+ return {
1896
+ /**
1897
+ * 同 scope FIFO 串行临界区:callback 依次执行,前一个失败不影响后一个。
1898
+ * index.js 在临界区内完成「复用/排队/新建」判定,杜绝并发重复 start。
1899
+ */
1900
+ async serialize(scope, callback) {
1901
+ const entry = entryOf(scope);
1902
+ const run = entry.queue.then(callback, callback);
1903
+ entry.queue = run.catch(() => {});
1904
+ return run;
1905
+ },
1906
+
1907
+ /**
1908
+ * 按父代理短暂串行范围预检和登记。回调不得在其中 await 专家执行;因此独立
1909
+ * 任务完成登记后仍可并行运行,同时不会出现两个角色同时看见空登记表的竞态。
1910
+ */
1911
+ async serializeParent(parentId, callback) {
1912
+ const key = String(parentId);
1913
+ const previous = parentQueues.get(key) ?? Promise.resolve();
1914
+ const run = previous.then(callback, callback);
1915
+ parentQueues.set(key, run.catch(() => {}));
1916
+ return run;
1917
+ },
1918
+
1919
+ /**
1920
+ * 检查新任务与同一父代理下的活动范围。缺少新任务或既有任务范围时只给出
1921
+ * unchecked advisory,不阻止;存在明确交集时默认 blocked,除非 allowOverlap。
1922
+ */
1923
+ checkActiveTaskScope({ parentId, affectedPaths, allowOverlap = false } = {}) {
1924
+ const normalizedAffectedPaths = normalizeAffectedPaths(affectedPaths);
1925
+ const active = activeTasksForParent(parentId);
1926
+ const missing = [];
1927
+ const conflicts = [];
1928
+ if (normalizedAffectedPaths.length === 0) {
1929
+ // 当前任务没有可比范围:这是新任务自身缺少声明,不能把已声明范围的
1930
+ // 活动任务误报为缺失;仍只返回 unchecked advisory,不阻断派发。
1931
+ return {
1932
+ status: "unchecked",
1933
+ blocked: false,
1934
+ warning: scopeWarningFor([], [], allowOverlap, true),
1935
+ affectedPaths: normalizedAffectedPaths,
1936
+ conflicts: [],
1937
+ missing: [],
1938
+ missingIncoming: true,
1939
+ };
1940
+ }
1941
+ for (const task of active) {
1942
+ if (task.affectedPaths.length === 0) {
1943
+ missing.push(task);
1944
+ continue;
1945
+ }
1946
+ const overlaps = [];
1947
+ for (const incoming of normalizedAffectedPaths) {
1948
+ for (const activePath of task.affectedPaths) {
1949
+ if (affectedPathEntriesOverlap(incoming, activePath)) overlaps.push({ incoming, active: activePath });
1950
+ }
1951
+ }
1952
+ if (overlaps.length > 0) conflicts.push({ task, overlaps });
1953
+ }
1954
+ const blocked = conflicts.length > 0 && allowOverlap !== true;
1955
+ return {
1956
+ status: conflicts.length > 0 ? "conflict" : missing.length > 0 ? "unchecked" : "clear",
1957
+ blocked,
1958
+ warning: scopeWarningFor(conflicts, missing, allowOverlap),
1959
+ affectedPaths: normalizedAffectedPaths,
1960
+ conflicts: conflicts.map(({ task, overlaps }) => ({
1961
+ task: copyActiveTask(task),
1962
+ overlaps: overlaps.map(({ incoming, active }) => ({ incoming: { ...incoming }, active: { ...active } })),
1963
+ })),
1964
+ missing: missing.map(copyActiveTask),
1965
+ };
1966
+ },
1967
+
1968
+ /** 将已通过预检的任务登记为活动任务;返回不可变快照。 */
1969
+ registerActiveTask({ parentId, parentAgent, role, taskKey, description, affectedPaths, childId } = {}) {
1970
+ const task = {
1971
+ id: "activity-" + (++activeTaskSeq),
1972
+ parentId: String(parentId ?? ""),
1973
+ ...(parentAgent !== void 0 ? { parentAgent } : {}),
1974
+ role: String(role ?? ""),
1975
+ taskKey: String(taskKey ?? ""),
1976
+ description: typeof description === "string" ? description : "",
1977
+ affectedPaths: normalizeAffectedPaths(affectedPaths),
1978
+ ...(typeof childId === "string" && childId.length > 0 ? { childId } : {}),
1979
+ };
1980
+ activeTasks.set(task.id, task);
1981
+ return copyActiveTask(task);
1982
+ },
1983
+
1984
+ /**
1985
+ * 在 child/message admission 已知时回填活动登记。messageId 是独立于任务
1986
+ * fingerprint 与 record.tasks 的稳定终态关联:两者被 TTL/LRU 淘汰后,turn/end
1987
+ * 仍可按 childId + messageId 精确完成这一个活动任务。
1988
+ */
1989
+ bindActiveTask(activityId, childId, messageId) {
1990
+ const task = activeTasks.get(activityId);
1991
+ if (task === void 0) return false;
1992
+ if (typeof childId === "string" && childId.length > 0) task.childId = childId;
1993
+ if (typeof messageId === "string" && messageId.length > 0) task.messageId = messageId;
1994
+ return typeof task.childId === "string" && task.childId.length > 0;
1995
+ },
1996
+
1997
+ /**
1998
+ * inbox claim 早于 admission 返回时,按 taskKey 只补回第一个尚未拥有稳定
1999
+ * messageId 的活动项。Map 保持插入序:同 child 同 key 的旧任务已绑定 m_old
2000
+ * 时绝不覆盖它;一个 claimed message 也不能绑定多个活动项。
2001
+ */
2002
+ bindActiveTasksByTaskKey({ parentId, role, childId, taskKey, messageId } = {}) {
2003
+ if (typeof childId !== "string" || childId.length === 0 || typeof taskKey !== "string" || typeof messageId !== "string" || messageId.length === 0) return 0;
2004
+ for (const task of activeTasks.values()) {
2005
+ if (task.parentId !== String(parentId ?? "") || task.role !== String(role ?? "") || task.childId !== childId || task.taskKey !== taskKey) continue;
2006
+ if (typeof task.messageId === "string" && task.messageId.length > 0) continue;
2007
+ task.messageId = messageId;
2008
+ return 1;
2009
+ }
2010
+ return 0;
2011
+ },
2012
+
2013
+ /** 返回本 turn 命中的活动 id,供恢复 op 在原绑定被淘汰后仍保留终态关联。 */
2014
+ activeTaskIdsForTurn(options) {
2015
+ return matchingActiveTaskIdsForTurn(options);
2016
+ },
2017
+
2018
+ /** 通过稳定活动 id 终态清理,避免同 child 的其他活动任务被误删。 */
2019
+ completeActiveTasksByIds(activityIds) {
2020
+ let count = 0;
2021
+ for (const activityId of stringSet(activityIds)) {
2022
+ if (activeTasks.delete(activityId)) count += 1;
2023
+ }
2024
+ return count;
2025
+ },
2026
+
2027
+ /** 按 childId + turn message ids(taskKey 仅辅助)终态清理命中的活动项。 */
2028
+ completeActiveTasksForTurn(options) {
2029
+ let count = 0;
2030
+ for (const activityId of matchingActiveTaskIdsForTurn(options)) {
2031
+ if (activeTasks.delete(activityId)) count += 1;
2032
+ }
2033
+ return count;
2034
+ },
2035
+
2036
+ /** 释放尚未 admission 成功的活动登记,避免失败请求留下永久冲突。 */
2037
+ releaseActiveTask(activityId) {
2038
+ return activeTasks.delete(activityId);
2039
+ },
2040
+
2041
+ /** 按任务终态移除精确的活动登记;childId 可选以保护旧 route 的迟到事件。 */
2042
+ completeActiveTask({ parentId, role, taskKey, childId } = {}) {
2043
+ let count = 0;
2044
+ for (const [activityId, task] of activeTasks) {
2045
+ if (task.parentId !== String(parentId ?? "") || task.role !== String(role ?? "") || task.taskKey !== String(taskKey ?? "")) continue;
2046
+ if (childId !== void 0 && task.childId !== childId) continue;
2047
+ activeTasks.delete(activityId);
2048
+ count += 1;
2049
+ }
2050
+ return count;
2051
+ },
2052
+
2053
+ /** child 或其恢复记录被确定清理时移除该 child 的全部活动登记。 */
2054
+ completeActiveTasksForChild(childId, parentId, parentAgent) {
2055
+ let count = 0;
2056
+ for (const [activityId, task] of activeTasks) {
2057
+ if (task.childId !== childId || (parentId !== void 0 && task.parentId !== String(parentId))) continue;
2058
+ // owner 已知时,不删除旧版/无 owner 的 activity;childId 可能跨 root generation 复用。
2059
+ if (parentAgent !== void 0 && task.parentAgent !== parentAgent) continue;
2060
+ activeTasks.delete(activityId);
2061
+ count += 1;
2062
+ }
2063
+ return count;
2064
+ },
2065
+
2066
+ /**
2067
+ * 重建或 followup recovery 后把相关活动项改绑到替代 child/恢复消息。优先
2068
+ * 使用 recovery op 保存的 activityIds;taskKeys 仅兼容旧记录或早期竞态。
2069
+ */
2070
+ rebindActiveTasks({ parentId, role, oldChildId, newChildId, messageId, taskKeys, activityIds } = {}) {
2071
+ const keys = stringSet(taskKeys);
2072
+ const ids = stringSet(activityIds);
2073
+ let count = 0;
2074
+ for (const [activityId, task] of activeTasks) {
2075
+ if (task.parentId !== String(parentId ?? "") || task.role !== String(role ?? "")) continue;
2076
+ if (task.childId !== oldChildId || (!ids.has(activityId) && !keys.has(task.taskKey))) continue;
2077
+ task.childId = newChildId;
2078
+ if (typeof messageId === "string" && messageId.length > 0) task.messageId = messageId;
2079
+ count += 1;
2080
+ }
2081
+ return count;
2082
+ },
2083
+
2084
+ /** 为 node:test 和可观测告警提供当前父代理的活动任务快照。 */
2085
+ listActiveTasks(parentId) {
2086
+ return activeTasksForParent(parentId).map(copyActiveTask);
2087
+ },
2088
+
2089
+ /** 读取 scope 当前可复用 child;无记录返回 undefined。 */
2090
+ getChild(scope) {
2091
+ const entry = scopes.get(scope);
2092
+ if (entry === void 0 || entry.childId === void 0) return void 0;
2093
+ return { childId: entry.childId, routeKey: entry.routeKey };
2094
+ },
2095
+
2096
+ /** 记录 scope 的当前 child 与其 route;child 或 route 变化时清空任务记录。 */
2097
+ setChild(scope, { childId, routeKey }) {
2098
+ const entry = entryOf(scope);
2099
+ if (entry.childId !== childId || entry.routeKey !== routeKey) entry.tasks.clear();
2100
+ entry.childId = childId;
2101
+ entry.routeKey = routeKey;
2102
+ return entry;
2103
+ },
2104
+
2105
+ /** 清除 scope 的 child 记录(可指定 childId:不匹配时不动);返回是否清除。 */
2106
+ clearChild(scope, childId) {
2107
+ const entry = scopes.get(scope);
2108
+ if (entry === void 0) return false;
2109
+ if (childId !== void 0 && entry.childId !== childId) return false;
2110
+ scopes.delete(scope);
2111
+ return true;
2112
+ },
2113
+
2114
+ /** 该 scope 是否已记录过这个(归一化后的)任务 key;过期记录惰性删除。 */
2115
+ hasTask(scope, taskKey) {
2116
+ const entry = scopes.get(scope);
2117
+ if (entry === void 0) return false;
2118
+ const at = entry.tasks.get(taskKey);
2119
+ if (at === void 0) return false;
2120
+ // 超过 TTL 的记录视为过期:删除并返回 false,相同 prompt 到期后可再次复用
2121
+ // (例如用户修复问题后重试同一任务,不再被永久判为重复)。
2122
+ if (now() - at >= taskTtlMs) {
2123
+ entry.tasks.delete(taskKey);
2124
+ return false;
2125
+ }
2126
+ return true;
2127
+ },
2128
+
2129
+ /** 记住任务 key(LRU:重复记忆刷新位置;超上限淘汰最旧)。值记录记住时刻供 TTL 判断。 */
2130
+ rememberTask(scope, taskKey) {
2131
+ const entry = entryOf(scope);
2132
+ if (entry.tasks.has(taskKey)) entry.tasks.delete(taskKey);
2133
+ entry.tasks.set(taskKey, now());
2134
+ while (entry.tasks.size > taskLimit) {
2135
+ const oldest = entry.tasks.keys().next().value;
2136
+ entry.tasks.delete(oldest);
2137
+ }
2138
+ },
2139
+
2140
+ /**
2141
+ * 忘记某 scope 的某个任务 key(任务确定失败后调用):该任务可重新排队/新建,
2142
+ * 而不是在 TTL 窗口内被永久判为 reused。不影响 child 记录与其他任务。
2143
+ * @returns {boolean} 确实存在并删除了返回 true;未知 key 返回 false。
2144
+ */
2145
+ forgetTask(scope, taskKey) {
2146
+ const entry = scopes.get(scope);
2147
+ if (entry === void 0) return false;
2148
+ return entry.tasks.delete(taskKey);
2149
+ },
2150
+
2151
+ /** 父代理销毁时清理其全部 scope、活动登记和短队列,防止内存泄漏。 */
2152
+ dropParent(parentId) {
2153
+ const id = String(parentId);
2154
+ const prefix = `${id}::`;
2155
+ for (const scope of scopes.keys()) {
2156
+ if (scope.startsWith(prefix)) scopes.delete(scope);
2157
+ }
2158
+ for (const [activityId, task] of activeTasks) {
2159
+ if (task.parentId === id) activeTasks.delete(activityId);
2160
+ }
2161
+ parentQueues.delete(id);
2162
+ },
2163
+ };
2164
+ }
2165
+
2166
+ /**
2167
+ * 子代理容量耗尽的稳定错误码(DSH 0.1.7-alpha.2 新增的进程内容量上限)。
2168
+ *
2169
+ * 宿主用一套 `maxActiveSubagents` 名额约束「共享不间断 continuable 父链」的存活 child
2170
+ * 数量;名额耗尽时**接纳不排队**(等待后代的父代理不能又等自己占用的名额),
2171
+ * 直接拒绝新的新建/冷恢复。
2172
+ *
2173
+ * 同一语义会以**两种形态**到达 DEG,必须都覆盖:
2174
+ * - `ACTIVATION_LIMIT_REACHED`:进程内公开服务(startContinuable / sendMessage 的冷恢复、
2175
+ * 重建路径)抛出的原始码;
2176
+ * - `subagent/delivery-unavailable`:宿主 Remote 控制面(浏览器 prompt / interrupt)
2177
+ * 把 DRAINING / ACTIVATION_CLOSING / **ACTIVATION_LIMIT_REACHED** /
2178
+ * CONTINUATION_UNAVAILABLE / PERSISTENCE_UNAVAILABLE 统一折叠后的包装码。
2179
+ *
2180
+ * 列表里还包含 DEG 自己的 `SUBAGENT_CAPACITY_REACHED`:翻译后的错误可能再次经过
2181
+ * 上层分类(例如 steer 抛给工具 admission 分类),识别必须**幂等**,否则已翻译的
2182
+ * 容量错误会在第二次判定时丢失结构化身份。
2183
+ *
2184
+ * ⚠ `subagent/delivery-unavailable` **不是**容量专属:它还覆盖 DRAINING 等不可回落
2185
+ * 情形,因此它只在「调用点上下文已排除排空/权限语义」时才按容量处置(见
2186
+ * {@link isSubagentCapacityError} 的 `assumeCapacity` 选项),绝不单独作为回落依据。
2187
+ */
2188
+ export const SUBAGENT_CAPACITY_ERROR_CODES = Object.freeze([
2189
+ "ACTIVATION_LIMIT_REACHED",
2190
+ "SUBAGENT_CAPACITY_REACHED",
2191
+ ]);
2192
+
2193
+ /**
2194
+ * 容量耗尽时面向用户的可读提示。
2195
+ *
2196
+ * 语义与宿主发布说明一致:等待子代理完成,或继续使用现有子代理。刻意不写死具体名额
2197
+ * 数值——默认值(8)是宿主配置项,可被用户改,写死会立刻过期。也不回显原始错误码。
2198
+ */
2199
+ export const SUBAGENT_CAPACITY_MESSAGE =
2200
+ "已达到同时运行的子代理数量上限;请等待现有子代理完成,或继续使用当前已有的子代理";
2201
+
2202
+ /**
2203
+ * 判断错误是否表示子代理容量耗尽(纯函数)。
2204
+ *
2205
+ * 只接受结构化 code 的**精确**匹配:`ACTIVATION_LIMIT_REACHED` 是原始码;
2206
+ * `subagent/delivery-unavailable` 只有在调用方显式声明该上下文已排除排空/权限
2207
+ * 语义时才按容量处置(`options.assumeCapacity === true`),否则返回 false,
2208
+ * 避免把 DRAINING 之类的不可回落错误伪装成可等待。
2209
+ *
2210
+ * @param {object} [error] - 宿主抛出的错误。
2211
+ * @param {object} [options] - `{assumeCapacity}`:该调用点是否已排除非容量语义。
2212
+ * @returns {boolean} 容量耗尽返回 true。
2213
+ */
2214
+ export function isSubagentCapacityError(error, options = {}) {
2215
+ const code = typeof error?.code === "string" ? error.code : "";
2216
+ if (SUBAGENT_CAPACITY_ERROR_CODES.includes(code)) return true;
2217
+ return options?.assumeCapacity === true && code === "subagent/delivery-unavailable";
2218
+ }
2219
+
2220
+ /**
2221
+ * 把容量耗尽错误翻译成面向用户的可读结果(纯函数)。
2222
+ *
2223
+ * 返回 `{ code, message, retryable }`:容量耗尽是**可重试**的暂态条件(等某个子代理
2224
+ * 结束后同一请求即可成功),因此 `retryable` 为 true,供调用方决定是否保留重试预算。
2225
+ * 非容量错误返回 undefined,调用方据此保持既有上抛/重建语义。
2226
+ *
2227
+ * @param {object} [error] - 宿主抛出的错误。
2228
+ * @param {object} [options] - 透传给 {@link isSubagentCapacityError}。
2229
+ * @returns {{code:string, message:string, retryable:boolean}|undefined}
2230
+ */
2231
+ export function describeSubagentCapacityFailure(error, options = {}) {
2232
+ if (!isSubagentCapacityError(error, options)) return undefined;
2233
+ return {
2234
+ // 稳定的自有 code:不转发宿主原始码,避免调用方重新依赖已折叠的宿主细节。
2235
+ code: "SUBAGENT_CAPACITY_REACHED",
2236
+ message: SUBAGENT_CAPACITY_MESSAGE,
2237
+ retryable: true,
2238
+ };
2239
+ }
2240
+
2241
+ /**
2242
+ * 构造一个携带 DEG 自有稳定 code 的容量耗尽错误。
2243
+ *
2244
+ * 用于「向用户抛出可读提示」的调用点:既保留结构化 code(供上层分类表识别并保持
2245
+ * 可重试语义),又不把宿主原始错误码/英文文案泄漏到用户可见文本里。非容量错误
2246
+ * 返回 undefined,调用方据此保持原样上抛。
2247
+ *
2248
+ * @param {object} [error] - 宿主抛出的错误。
2249
+ * @param {object} [options] - 透传给 {@link isSubagentCapacityError}。
2250
+ * @returns {Error|undefined}
2251
+ */
2252
+ export function createSubagentCapacityError(error, options = {}) {
2253
+ const described = describeSubagentCapacityFailure(error, options);
2254
+ if (described === undefined) return undefined;
2255
+ const capacityError = new Error(described.message);
2256
+ capacityError.name = "SubagentCapacityError";
2257
+ capacityError.code = described.code;
2258
+ capacityError.retryable = described.retryable;
2259
+ return capacityError;
2260
+ }
2261
+
2262
+ /**
2263
+ * 判断 ordinary interrupt 失败后是否可以安全继续 manager sendMessage(纯函数)。
2264
+ * 只有目标 Activation 已进入关闭/不可恢复状态时才回落;这类失败表示 live
2265
+ * direct cancel 已失去意义,manager sendMessage 仍可负责 cold admission。
2266
+ * UNAUTHORIZED(父 authority/lineage 不匹配)、DRAINING、AbortError、容量耗尽
2267
+ * 与未知错误必须原样抛出,不能把权限、取消或容量错误伪装成成功回落——
2268
+ * 容量耗尽时 sendMessage 的冷恢复同样会被拒绝,回落只会立刻再次失败。
2269
+ * @param {object} [error] - interrupt 抛出的错误。
2270
+ * @returns {boolean} 允许继续 sendMessage 时返回 true。
2271
+ */
2272
+ export function interruptFailureCanFallback(error) {
2273
+ if (isSubagentCapacityError(error)) return false;
2274
+ return error?.code === "ACTIVATION_CLOSING" || error?.code === "NOT_RESUMABLE";
2275
+ }
2276
+
2277
+ // ── 自动恢复(自愈监督):纯函数判定 ────────────────────────────────────────
2278
+
2279
+ /**
2280
+ * 同一 child 连续自动恢复次数上限:同一 child 连续失败/空返回时,插件最多自动
2281
+ * 发送 5 次「继续执行」指令;超过后不再自动恢复,任务记录转为确定失败。
2282
+ */
2283
+ export const AUTO_RECOVERY_LIMIT = 5;
2284
+
2285
+ /**
2286
+ * 给确定的恢复投递失败追加真实累计次数。所有 failPendingRecoveryOp 分支使用
2287
+ * 它,因此父不可用、sendMessage/rebuild reject 与无效 messageId 不会丢失重试
2288
+ * 上下文;已经含有该片段时保持幂等,避免嵌套错误路径重复拼接。
2289
+ */
2290
+ export function appendRecoveryRetryCount(message, recoveryCount) {
2291
+ const text = typeof message === "string" && message.length > 0 ? message : "自动恢复失败";
2292
+ const count = Number.isSafeInteger(recoveryCount) && recoveryCount > 0 ? recoveryCount : 0;
2293
+ if (count === 0 || /已自动重试\s+\d+\s+次/.test(text)) return text;
2294
+ return text + ";已自动重试 " + count + " 次";
2295
+ }
2296
+
2297
+ /**
2298
+ * record.tasks 任务绑定表上限(S2):超过后按 Map 插入序淘汰最旧。
2299
+ * 任务绑定用于成功输出缓存与确定失败记录;64 条足以覆盖
2300
+ * 一个 child 生命周期内的活跃任务,防止长期运行内存增长。
2301
+ */
2302
+ export const TASK_BINDING_LIMIT = 64;
2303
+
2304
+ /**
2305
+ * record.claimed 认领映射上限(S2):超过后按 Map 插入序淘汰最旧。
2306
+ * 认领映射只用于「消息被 inbox 认领但 user/message 未落盘」的归属兜底,
2307
+ * 512 条覆盖长时间运行的全部活跃消息,淘汰极旧的条目不影响新归属判定。
2308
+ */
2309
+ export const CLAIMED_LIMIT = 512;
2310
+
2311
+ /**
2312
+ * record.processedFeedbackMessageIds 上限:父 inbox settlement 的 message.id 去重账本。
2313
+ *
2314
+ * 与 CLAIMED_LIMIT 的关键差别:这里**不做插入序淘汰**。淘汰会让一条迟到副本重新变成
2315
+ * 「新 settlement」并消费后续轮次的 token(审核缺陷 A1);因此达到上限后改为 fail-closed
2316
+ * 饱和:不再记录新 id,并置 record.feedbackIdentitySaturated,使没有稳定身份的
2317
+ * settlement 只能走保守路径(不得立即消费已结束的 turn group)。饱和需要同一 child
2318
+ * 记录累计 4096 条 settlement,属于不可达的极端值,只作为资源上界存在。
2319
+ */
2320
+ export const FEEDBACK_DELIVERY_LIMIT = 4096;
2321
+
2322
+ /**
2323
+ * 父 inbox settlement 身份账本(A1 的可测纯状态机)。
2324
+ *
2325
+ * 语义:
2326
+ * - `claim(id)` 对同一 id 的第二次调用返回 duplicate=true(副本必须完全跳过消费/排队);
2327
+ * - **永不淘汰**已记账 id:淘汰会让迟到副本重新变成新 settlement;
2328
+ * - 达到 limit 后进入饱和:不再记账、`saturated` 置 true,之后 claim 返回
2329
+ * `{duplicate:false, unproven:true}`,调用方必须走保守路径(不提前 resume)。
2330
+ *
2331
+ * 生命周期:由调用方(index.js 的 child recovery record)持有,record 被 drop/owner
2332
+ * 变更时随之丢弃。message.id 是 DSH 为每条消息新铸的 UUID,不存在「同 id 被合法复用
2333
+ * 为下一轮真实 settlement」的情况,因此在 record 生命周期内保留 id 是可证明完备的。
2334
+ *
2335
+ * @param {{limit?:number}} [options] - 记账上限(资源上界,不是安全边界)。
2336
+ * @returns {{claim:(id:string)=>{duplicate:boolean,unproven:boolean}, size:number, saturated:boolean}}
2337
+ */
2338
+ export function createFeedbackIdentityLedger({ limit } = {}) {
2339
+ const cap = Number.isSafeInteger(limit) && limit > 0 ? limit : FEEDBACK_DELIVERY_LIMIT;
2340
+ const ids = new Set();
2341
+ let saturated = false;
2342
+ return {
2343
+ claim(id) {
2344
+ if (typeof id !== "string" || id.length === 0) return { duplicate: false, unproven: true };
2345
+ if (ids.has(id)) return { duplicate: true, unproven: false };
2346
+ if (saturated || ids.size >= cap) {
2347
+ saturated = true;
2348
+ return { duplicate: false, unproven: true };
2349
+ }
2350
+ ids.add(id);
2351
+ return { duplicate: false, unproven: false };
2352
+ },
2353
+ get size() { return ids.size; },
2354
+ get saturated() { return saturated; },
2355
+ };
2356
+ }
2357
+
2358
+ /**
2359
+ * 把 Map 裁剪到上限(S2 资源上限):超过 limit 时按插入序删除最旧条目。
2360
+ * 就地修改并返回被删除的 key 列表;limit 非正整数或输入非法视为不裁剪。
2361
+ * @param {Map} map - 目标 Map(record.tasks / record.claimed)。
2362
+ * @param {number} limit - 上限条数。
2363
+ * @returns {Array} 被淘汰的 key 列表。
2364
+ */
2365
+ export function evictOldestEntries(map, limit) {
2366
+ const removed = [];
2367
+ if (!(map instanceof Map) || !Number.isSafeInteger(limit) || limit < 0) return removed;
2368
+ while (map.size > limit) {
2369
+ const oldest = map.keys().next().value;
2370
+ if (oldest === void 0) break;
2371
+ map.delete(oldest);
2372
+ removed.push(oldest);
2373
+ }
2374
+ return removed;
2375
+ }
2376
+
2377
+ /**
2378
+ * 收集某 turn 内所有 assistant/message 事件中的非空文本块。
2379
+ * 纯函数:只依赖事件日志结构({type:"assistant/message", data:{turn, message:{content}}}),
2380
+ * 不依赖 DOM / Node 服务。
2381
+ * @param {Array} events - session.events(不可变事件日志)。
2382
+ * @param {number} turn - turn 号。
2383
+ * @returns {Array<{type:"text", text:string}>} 该 turn 的非空文本块(保持出现顺序)。
2384
+ */
2385
+ export function turnTextBlocks(events, turn) {
2386
+ const blocks = [];
2387
+ if (!Array.isArray(events) || !Number.isSafeInteger(turn)) return blocks;
2388
+ for (const event of events) {
2389
+ if (event?.type !== "assistant/message") continue;
2390
+ if (event?.data?.turn !== turn) continue;
2391
+ const content = event?.data?.message?.content;
2392
+ if (!Array.isArray(content)) continue;
2393
+ for (const block of content) {
2394
+ // 纯空白文本不算有效输出:trim 后为空则跳过。
2395
+ if (block?.type === "text" && typeof block?.text === "string" && block.text.trim().length > 0) {
2396
+ blocks.push({ type: "text", text: block.text });
2397
+ }
2398
+ }
2399
+ }
2400
+ return blocks;
2401
+ }
2402
+
2403
+ /**
2404
+ * 某 turn 内是否有非空 assistant 文本输出(「空返回」判定)。
2405
+ * @param {Array} events - session.events。
2406
+ * @param {number} turn - turn 号。
2407
+ * @returns {boolean} 有非空文本输出返回 true。
2408
+ */
2409
+ export function turnHasNonEmptyText(events, turn) {
2410
+ return turnTextBlocks(events, turn).length > 0;
2411
+ }
2412
+
2413
+ /**
2414
+ * 读取一条 user/message 事件的消息 id。
2415
+ * 生产 DSH 契约是 SessionEventMap['user/message'] = UserMessage:id/role/content/
2416
+ * source **平铺在 event.data 上**,因此必须优先读 event.data.id。历史日志与旧测试
2417
+ * 夹具曾把消息包在 event.data.message 里,保留该形状作为回退(只在平铺 id 缺失或
2418
+ * 为空串时使用),避免回归旧数据;两种形状都没有可用字符串 id 时返回 undefined。
2419
+ * 注意:这里只读取 id,不改变 assistant/message(其 payload 确实是
2420
+ * { turn, step, message })与 tool/result 的既有语义。
2421
+ * @param {object} [event] - 单条 SessionEvent。
2422
+ * @returns {string|undefined} 可用消息 id。
2423
+ */
2424
+ function messageIdOfEvent(event) {
2425
+ const data = event?.data;
2426
+ if (data === null || typeof data !== "object") return void 0;
2427
+ if (typeof data.id === "string" && data.id.length > 0) return data.id;
2428
+ const legacy = data.message;
2429
+ if (legacy !== null && typeof legacy === "object"
2430
+ && typeof legacy.id === "string" && legacy.id.length > 0) return legacy.id;
2431
+ return void 0;
2432
+ }
2433
+
2434
+ /**
2435
+ * 收集某 turn 内所有 user/message 的消息 id。
2436
+ * 用于按消息归属判定 turn:turn/end 的 data.reason 不携带消息 id,需要回看该
2437
+ * turn 的 user/message 事件才能确认「这个 turn 是哪次委派/恢复指令触发的」。
2438
+ * 读到的 id 直接决定活动 affectedPaths 的终态释放能否命中本 turn:拿不到 id 时
2439
+ * rootStructuredPathGuard 会永久阻塞与已结束专家任务重叠的 root 结构化文件操作。
2440
+ * @param {Array} events - session.events。
2441
+ * @param {number} turn - turn 号。
2442
+ * @returns {Set<string>} 该 turn 内的消息 id 集合。
2443
+ */
2444
+ export function messageIdsOfTurn(events, turn) {
2445
+ const ids = new Set();
2446
+ if (!Array.isArray(events) || !Number.isSafeInteger(turn)) return ids;
2447
+ let started = false;
2448
+ for (const event of events) {
2449
+ if (event?.type === "turn/start") {
2450
+ if (event?.data?.turn === turn) started = true;
2451
+ else if (started) break; // 已越过该 turn 的边界(turn/start 按 turn 号顺序追加)
2452
+ continue;
2453
+ }
2454
+ if (started && event?.type === "user/message") {
2455
+ const id = messageIdOfEvent(event);
2456
+ if (id !== void 0) ids.add(id);
2457
+ }
2458
+ }
2459
+ return ids;
2460
+ }
2461
+
2462
+ /**
2463
+ * 某 turn 的消息 id 归属:事件日志中的 user/message 与 claimed 认领映射
2464
+ * (message.id -> turn)的**并集**。claimed 覆盖「消息已被 inbox 认领但
2465
+ * user/message 未落盘」的缺口(pre-step reject / 取消 / 异常),保证消息
2466
+ * 恢复 op 完结与任务绑定都能找到归属(B2 路径 B)。
2467
+ * @param {Map<string, number>} [claimed] - message.id -> turn 认领映射。
2468
+ * @param {Array} [events] - session.events。
2469
+ * @param {number} turn - turn 号。
2470
+ * @returns {Set<string>} 该 turn 的消息 id 集合。
2471
+ */
2472
+ export function messageIdsOfTurnWithClaimed(claimed, events, turn) {
2473
+ const ids = messageIdsOfTurn(events, turn);
2474
+ if (claimed instanceof Map && Number.isSafeInteger(turn)) {
2475
+ for (const [messageId, claimedTurn] of claimed) {
2476
+ if (claimedTurn === turn && typeof messageId === "string") ids.add(messageId);
2477
+ }
2478
+ }
2479
+ return ids;
2480
+ }
2481
+
2482
+ /**
2483
+ * 提取消息 content 中的文本块拼接(S1-D claimed 补绑按任务文本匹配用)。
2484
+ * 与 turnTextBlocks 同构:只取 {type:"text", text} 块,保持出现顺序。
2485
+ * @param {object} [message] - user 消息(含 content 块数组)。
2486
+ * @returns {string} 拼接后的文本。
2487
+ */
2488
+ export function messageTextOf(message) {
2489
+ const blocks = message?.content;
2490
+ if (!Array.isArray(blocks)) return "";
2491
+ return blocks
2492
+ .filter((block) => block?.type === "text" && typeof block?.text === "string")
2493
+ .map((block) => block.text)
2494
+ .join("");
2495
+ }
2496
+
2497
+ /**
2498
+ * 恢复消息 id 的 claimed 占位写入(P2):manager 投递成功后把
2499
+ * message.id -> null 记入 claimed 占位(null = 尚未绑定 turn,后续
2500
+ * agent/inbox/claimed 用真实 turn 覆盖)。
2501
+ * **已有条目绝不覆盖**:promise resolve 可能晚于 inbox 认领事件,若已写入
2502
+ * 真实 turn,直接 set(null) 会把真实归属抹掉——先查 has 再写。
2503
+ * @param {Map<string, number|null>} claimed - message.id -> turn 认领映射。
2504
+ * @param {string} messageId - 恢复消息 id。
2505
+ * @returns {boolean} 确实写入占位返回 true;已存在条目(不覆盖)或非法输入返回 false。
2506
+ */
2507
+ export function rememberClaimedPlaceholder(claimed, messageId) {
2508
+ if (!(claimed instanceof Map) || typeof messageId !== "string" || messageId.length === 0) return false;
2509
+ if (claimed.has(messageId)) return false;
2510
+ claimed.set(messageId, null);
2511
+ return true;
2512
+ }
2513
+
2514
+ /**
2515
+ * claimed 补绑 pendingMessage 任务(S1-D 任务 binding 的 acceptance 竞态):
2516
+ * 当 claimed 消息 id 在 record.tasks 中找不到 binding 时(正常路径的
2517
+ * steer/startContinuable admission 回调晚于 inbox 认领事件),用消息文本
2518
+ * 的归一化任务 key 匹配 binding 表中「pendingMessage === true 且
2519
+ * messageId === null」的项;匹配则回填 binding.messageId = claimed 消息 id
2520
+ * 并清 pendingMessage。只处理待回填的项,已确定的绑定绝不被覆盖。
2521
+ * @param {Map} tasks - taskKey -> binding 表。
2522
+ * @param {object} [message] - 被 inbox 认领的消息(含 id 与 content 文本块)。
2523
+ * @returns {boolean} 发生补绑返回 true。
2524
+ */
2525
+ export function rebindPendingTaskByClaimed(tasks, message) {
2526
+ if (!(tasks instanceof Map) || message === null || typeof message !== "object") return false;
2527
+ const messageId = message.id;
2528
+ if (typeof messageId !== "string" || messageId.length === 0) return false;
2529
+ // 消息 id 已落在某个 binding 上:正常路径已回填,无需补绑。
2530
+ for (const binding of tasks.values()) {
2531
+ if (binding?.messageId === messageId) return false;
2532
+ }
2533
+ const taskKey = delegationTaskKey(messageTextOf(message));
2534
+ if (taskKey.length === 0) return false;
2535
+ const binding = tasks.get(taskKey);
2536
+ if (binding === void 0 || binding.pendingMessage !== true || binding.messageId !== null) return false;
2537
+ binding.messageId = messageId;
2538
+ binding.pendingMessage = false;
2539
+ return true;
2540
+ }
2541
+
2542
+ /**
2543
+ * forgetTask 的 child 身份保护判定(P5):仅当 scope 当前记录的 child 与
2544
+ * record 的 childId 一致时才允许 forget——旧 route child 的迟到失败不得
2545
+ * 删除新 route 同名任务指纹(route 切换后 delegationTracker 的 child 已
2546
+ * 换成新 child,tasks 记录也已清空重建)。
2547
+ * @param {string|undefined} currentChildId - delegationTracker.getChild(scope) 的 childId。
2548
+ * @param {string|undefined} recordChildId - 恢复记录所属 child 的 session id。
2549
+ * @returns {boolean} childId 一致(且 recordChildId 非空)返回 true。
2550
+ */
2551
+ export function shouldForgetTask(currentChildId, recordChildId) {
2552
+ return typeof recordChildId === "string" && recordChildId.length > 0 && currentChildId === recordChildId;
2553
+ }
2554
+
2555
+ /**
2556
+ * 收集一个 turn 应结算的任务 key:恢复 op 继承的 taskKeys 与消息 id 反查结果
2557
+ * 取并集。handleChildTurnEnd 用它清理活动范围登记,applyTurnToTasks 用同一
2558
+ * 规则回写绑定,避免恢复消息替换原消息 id 后丢失原任务归属。
2559
+ * @param {Map} tasks - taskKey -> binding 表。
2560
+ * @param {Set<string>} [turnMsgIds] - 该 turn 的消息 id 并集。
2561
+ * @param {Array|Set} [taskKeys] - 恢复 op 继承的原任务 key。
2562
+ * @returns {Array<string>} 去重后的命中任务 key。
2563
+ */
2564
+ export function taskKeysForTurn(tasks, turnMsgIds, taskKeys) {
2565
+ const targets = new Set();
2566
+ if (taskKeys !== void 0 && taskKeys !== null) {
2567
+ for (const key of taskKeys) {
2568
+ if (typeof key === "string") targets.add(key);
2569
+ }
2570
+ }
2571
+ if (tasks instanceof Map && turnMsgIds instanceof Set) {
2572
+ for (const [key, binding] of tasks) {
2573
+ if (typeof binding?.messageId === "string" && turnMsgIds.has(binding.messageId)) targets.add(key);
2574
+ }
2575
+ }
2576
+ return [...targets];
2577
+ }
2578
+
2579
+ /**
2580
+ * 把一次 turn/end 的结局回写到任务绑定表(S1-C,B3 任务绑定跨恢复链传播)。
2581
+ * handleChildTurnEnd 与恢复失败收口共用本纯函数:
2582
+ * - outcome === "success":命中绑定写入 outcome:"success" 与 blocks(该 turn
2583
+ * 的非空文本块),messageId 保留原消息 id(供 reused 识别原任务);
2584
+ * - outcome === "failed":命中绑定写入 outcome:"failed" 与 failureMessage
2585
+ * (确定失败,含恢复链达上限),返回被写终态的 taskKey 列表——调用方据此
2586
+ * delegationTracker.forgetTask(相同 prompt 可重新排队而非永久 reused);
2587
+ * - outcome === "recoverable":不写终态(恢复链继续,由调用方创建新 op 并
2588
+ * 继承 taskKeys,不要丢)。
2589
+ * 命中范围:显式 taskKeys(恢复 turn 经 op 继承的原任务 key)∪ 按 turnMsgIds
2590
+ * 反查 binding.messageId 的项;taskKeys 缺省时仅按 turnMsgIds 反查。
2591
+ * @param {Map} tasks - taskKey -> binding 表。
2592
+ * @param {Array} [events] - session.events(成功时取输出文本块)。
2593
+ * @param {number} turn - turn 号。
2594
+ * @param {Set<string>} [turnMsgIds] - 该 turn 消息 id 并集(事件 ∪ claimed)。
2595
+ * @param {"success"|"failed"|"recoverable"} outcome - 任务结局。
2596
+ * @param {object} [reason] - turn/end 的 data.reason(failureMessage 缺省兜底)。
2597
+ * @param {string} [failureMessage] - 确定失败的完整错误文案(调用方已格式化)。
2598
+ * @param {Array|Set} [taskKeys] - 恢复 op 继承的任务 key 列表(可选)。
2599
+ * @returns {Array<string>} 被写为确定失败的 taskKey 列表(调用方 forgetTask)。
2600
+ */
2601
+ export function applyTurnToTasks(tasks, events, turn, turnMsgIds, outcome, reason, failureMessage, taskKeys) {
2602
+ const written = [];
2603
+ if (!(tasks instanceof Map) || (outcome !== "success" && outcome !== "failed")) return written;
2604
+ // 与活动范围终态清理共用同一归属规则,避免恢复消息换 id 后失去原任务 key。
2605
+ const targets = taskKeysForTurn(tasks, turnMsgIds, taskKeys);
2606
+ if (outcome === "success") {
2607
+ const blocks = turnTextBlocks(events, turn);
2608
+ for (const key of targets) {
2609
+ const binding = tasks.get(key);
2610
+ if (binding === void 0 || typeof binding !== "object") continue;
2611
+ binding.outcome = "success";
2612
+ binding.blocks = blocks;
2613
+ }
2614
+ } else {
2615
+ const message = typeof failureMessage === "string" && failureMessage.length > 0
2616
+ ? failureMessage
2617
+ : `subagent run failed${reason?.kind !== void 0 ? ` (${String(reason.kind)})` : ""}`;
2618
+ for (const key of targets) {
2619
+ const binding = tasks.get(key);
2620
+ if (binding === void 0 || typeof binding !== "object") continue;
2621
+ binding.outcome = "failed";
2622
+ binding.failureMessage = message;
2623
+ written.push(key);
2624
+ }
2625
+ }
2626
+ return written;
2627
+ }
2628
+
2629
+ /**
2630
+ * 将尚未投递的自动恢复任务标记为被新任务取代(纯函数契约):
2631
+ * - 只处理显式 taskKeys,保持恢复 op 的任务归属,不触碰消息/事件日志;
2632
+ * - 写入明确的 outcome:"superseded" 与 failureMessage,供后续复用识别;
2633
+ * - 将 pendingMessage 清除,防止迟到 claimed 事件重新绑定已失效任务。
2634
+ * @param {Map} tasks - taskKey -> binding 表。
2635
+ * @param {Array|Set} taskKeys - 要失效化的任务 key 集合。
2636
+ * @param {string} message - 面向任务记录的取代原因。
2637
+ * @returns {Array<string>} 实际写入为 superseded 的 taskKey 列表。
2638
+ */
2639
+ export function supersedeTaskBindings(tasks, taskKeys, message) {
2640
+ const written = [];
2641
+ if (!(tasks instanceof Map) || taskKeys === void 0 || taskKeys === null) return written;
2642
+ for (const key of taskKeys) {
2643
+ if (typeof key !== "string") continue;
2644
+ const binding = tasks.get(key);
2645
+ if (binding === void 0 || typeof binding !== "object") continue;
2646
+ binding.outcome = "superseded";
2647
+ binding.failureMessage = typeof message === "string" && message.length > 0
2648
+ ? message
2649
+ : "task was superseded by a newer task";
2650
+ if (binding.pendingMessage === true) binding.pendingMessage = false;
2651
+ written.push(key);
2652
+ }
2653
+ return written;
2654
+ }
2655
+
2656
+ /**
2657
+ * 只有 DSH 明确标注由根/父代理或用户发起的取消,才是用户意图明确的终态。
2658
+ * hook、disposed、legacy 或缺少来源的 aborted 都可能是宿主短暂故障,必须走
2659
+ * 自动继续而不是静默放弃原任务。
2660
+ * @param {object} [reason] - turn/end 的 data.reason。
2661
+ * @returns {boolean} 明确 parent/user 取消返回 true。
2662
+ */
2663
+ export function hasExplicitParentOrUserCancellation(reason) {
2664
+ return reason?.kind === "aborted" && (reason?.reason?.kind === "parent" || reason?.reason?.kind === "user");
2665
+ }
2666
+
2667
+ /**
2668
+ * DSH 结构化 turn/end 错误中明确不可通过自动 continuation 修复的错误码。
2669
+ * 未知 code 保持旧的 recoverable 策略;只有已知的认证、权限、配置、路由、
2670
+ * 非法请求和上下文限制错误才 fail-closed,绝不解析错误文本。
2671
+ */
2672
+ export const PERMANENT_TURN_ERROR_CODES = Object.freeze([
2673
+ "AUTHORIZATION",
2674
+ "UNAUTHENTICATED",
2675
+ "ACCESS_DENIED",
2676
+ "PERMISSION",
2677
+ "PERMISSION_DENIED",
2678
+ "MISSING_CREDENTIAL",
2679
+ "INVALID_CREDENTIAL",
2680
+ "CONFIGURATION",
2681
+ "CONFIG_ERROR",
2682
+ "INVALID_CONFIGURATION",
2683
+ "INVALID_CONFIG",
2684
+ "INVALID_ARGUMENT",
2685
+ "INVALID_ARGS",
2686
+ "REQUEST_EXTENSION",
2687
+ "CONTEXT_WINDOW_EXCEEDED",
2688
+ "QUOTA",
2689
+ "ABORTED",
2690
+ "CANCELLED",
2691
+ "CANCELED",
2692
+ ]);
2693
+
2694
+ /**
2695
+ * 判断 turn/end 是否携带已知永久错误。只读取结构化 reason.error.code。
2696
+ * @param {object} [reason] - turn/end 的 data.reason。
2697
+ * @returns {boolean} 已知永久错误返回 true。
2698
+ */
2699
+ export function isPermanentTurnError(reason) {
2700
+ if (reason?.kind !== "error" || reason?.error === null || typeof reason?.error !== "object") return false;
2701
+ if (typeof reason.error.code === "string" && PERMANENT_TURN_ERROR_CODES.includes(reason.error.code)) return true;
2702
+ return isPermanentPiAiFailure(reason.error);
2703
+ }
2704
+
2705
+ /**
2706
+ * 一次 turn/end 的结局分类:
2707
+ * - "success":completed 且有非空文本输出(成功,恢复计数清零);
2708
+ * - "terminal":明确 parent/user 取消,或结构化永久错误;
2709
+ * - "recoverable":其他非成功结果,包括来源不明的 aborted、未知 code 的 error、
2710
+ * max-tokens、空 completed、refusal、blocked、interrupted 与未知 kind。
2711
+ * @param {object} [reason] - turn/end 的 data.reason。
2712
+ * @param {boolean} hasNonEmptyOutput - 该 turn 是否有非空 assistant 文本输出。
2713
+ * @returns {"success"|"recoverable"|"terminal"}
2714
+ */
2715
+ export function classifyTurnOutcome(reason, hasNonEmptyOutput) {
2716
+ if (reason?.kind === "completed" && hasNonEmptyOutput === true) return "success";
2717
+ if (hasExplicitParentOrUserCancellation(reason) || isPermanentTurnError(reason)) return "terminal";
2718
+ return "recoverable";
2719
+ }
2720
+
2721
+ /**
2722
+ * 判断原 child 无法继续时是否允许按既有安全边界重建。除明确 NOT_RESUMABLE、
2723
+ * child 已销毁或 route 已改变外,宿主、权限和显式取消错误必须保留原错误契约。
2724
+ *
2725
+ * 容量耗尽**不**允许重建:它表示进程名额已被占满,与 child 是否可用无关,
2726
+ * 重建只会立刻再次撞上限(并消耗一份本可用于其他角色的名额),因此必须显式排除。
2727
+ *
2728
+ * @param {object} [error] - sendMessage/start 失败。
2729
+ * @param {object} [state] - childDestroyed / routeChanged 状态。
2730
+ * @returns {boolean} 可以创建替代 continuable child 返回 true。
2731
+ */
2732
+ export function shouldRebuildRecoveryChild(error, state = {}) {
2733
+ if (isSubagentCapacityError(error)) return false;
2734
+ return error?.code === "NOT_RESUMABLE" || state?.childDestroyed === true || state?.routeChanged === true;
2735
+ }
2736
+
2737
+ /**
2738
+ * 重建 child 成功后是否可以把它写为当前 parent+role scope:无当前 child 的
2739
+ * cold-resume scope 与仍指向被替换 old child 的 scope 都应更新;已经被并发新任务
2740
+ * 安全替换成其他 child 时必须保持该新 child,不得覆盖。
2741
+ */
2742
+ export function shouldInstallRebuiltChild(currentChildId, oldChildId) {
2743
+ return currentChildId === void 0 || currentChildId === oldChildId;
2744
+ }
2745
+
2746
+ /**
2747
+ * startContinuable await 返回后的重建提交 guard。父销毁、插件卸载或 op 被终止
2748
+ * 时 record 已从 recoveryRecords 删除,迟到 child 绝不能重新插回记录或
2749
+ * 活动范围登记。controller abort 也表示这次 admission 的结果必须被丢弃。
2750
+ */
2751
+ export function recoveryRebuildCanCommit(recoveryRecords, record, op) {
2752
+ return recoveryRecords instanceof Map
2753
+ && recoveryRecords.get(record?.childId) === record
2754
+ && recoveryOpIsPending(record?.pendingRecoveries, op)
2755
+ && !recoveryOpIsAccepted(op)
2756
+ && op?.controller?.signal?.aborted !== true;
2757
+ }
2758
+
2759
+ /**
2760
+ * 是否需要自动恢复:任何非成功且非明确取消的结果,且未达到连续恢复上限。
2761
+ * @param {object} [reason] - turn/end 的 data.reason。
2762
+ * @param {boolean} hasNonEmptyOutput - 该 turn 是否有非空 assistant 文本输出。
2763
+ * @param {number} recoveryCount - 该 child 当前的连续自动恢复次数。
2764
+ * @returns {boolean} 需要自动恢复返回 true。
2765
+ */
2766
+ export function shouldAutoRecover(reason, hasNonEmptyOutput, recoveryCount) {
2767
+ if (classifyTurnOutcome(reason, hasNonEmptyOutput) !== "recoverable") return false;
2768
+ const count = Number.isSafeInteger(recoveryCount) && recoveryCount >= 0 ? recoveryCount : 0;
2769
+ return count < AUTO_RECOVERY_LIMIT;
2770
+ }
2771
+
2772
+ /**
2773
+ * 自动恢复每次失败后的等待时长(毫秒)。索引从 1 开始,严格对应本次即将
2774
+ * 发出的恢复指令次数;超出预算或非法值返回 undefined,调用方应保守地终止
2775
+ * 该恢复而不是发出没有明确退避策略的第 6 次指令。
2776
+ */
2777
+ export const RECOVERY_BACKOFF_DELAYS = Object.freeze([2000, 4000, 8000, 16000, 32000]);
2778
+
2779
+ export function recoveryDelayForCount(recoveryCount) {
2780
+ if (!Number.isSafeInteger(recoveryCount) || recoveryCount < 1 || recoveryCount > AUTO_RECOVERY_LIMIT) return void 0;
2781
+ return RECOVERY_BACKOFF_DELAYS[recoveryCount - 1];
2782
+ }
2783
+
2784
+ /**
2785
+ * DSH 公开 agent/request-error payload 中允许自动重试的稳定错误码。
2786
+ * 这里只接受结构化 failure.code;message、stack 和 reason 文本不参与判定,
2787
+ * 因而未知的 upstream_unavailable 变体会 fail-closed。
2788
+ */
2789
+ export const REQUEST_RETRYABLE_FAILURE_CODES = Object.freeze([
2790
+ "SERVER",
2791
+ "RATE_LIMIT",
2792
+ "TIMEOUT",
2793
+ "TRANSPORT",
2794
+ "EMPTY_RESPONSE",
2795
+ "upstream_unavailable",
2796
+ "UPSTREAM_UNAVAILABLE",
2797
+ "PI_AI_ERROR",
2798
+ "AUTH",
2799
+ "UNAUTHORIZED",
2800
+ "FORBIDDEN",
2801
+ "INVALID_REQUEST",
2802
+ "BAD_REQUEST",
2803
+ "NO_PROVIDER",
2804
+ "NO_ADAPTER",
2805
+ "UNKNOWN_MODEL",
2806
+ "MODEL_NOT_FOUND",
2807
+ ]);
2808
+
2809
+ // PI_AI_ERROR 的 message 来自上游/流传输层,必须匹配已知完整前缀或边界明确的短语。
2810
+ // 不能用任意 contains:pending、认证、HTTP 4xx、quota 等文案必须保持终态。
2811
+ const PI_AI_TRANSIENT_MESSAGE_PATTERNS = Object.freeze([
2812
+ /^\s*upstream_stream_incomplete(?:\s*:|\b)/i,
2813
+ /^\s*upstream_unavailable(?:\s*:|\b)/i,
2814
+ /^\s*(?:(?:error|cause)\s*:\s*)?stream[\s_-]+ended[\s_-]+(?:before|without)\b/i,
2815
+ /^\s*(?:(?:error|cause|request|stream|upstream)\s*:\s*)?(?:network|connection|socket|fetch)[\s_-]+(?:error|failure|failed|reset|refused|closed|timeout)\b/i,
2816
+ /^\s*(?:(?:error|cause)\s*:\s*)?(?:[A-Za-z][A-Za-z0-9_-]*\s+)?ECONN[A-Z0-9_]*\b/i,
2817
+ /^\s*(?:(?:error|cause)\s*:\s*)?other\s+side\s+closed\b/i,
2818
+ /^\s*(?:(?:error|cause)\s*:\s*)?HTTP2\s+request\s+did\s+not\s+get\s+a\s+response\b/i,
2819
+ /^\s*(?:(?:error|cause)\s*:\s*)?WebSocket\s+closed\s+unexpectedly\b/i,
2820
+ /^\s*(?:(?:error|cause)\s*:\s*)?(?:terminated|(?:the\s+)?(?:request|stream|connection|socket)(?:\s+was)?\s+terminated)\b/i,
2821
+ /^\s*(?:(?:error|cause)\s*:\s*)?premature\s+close\b/i,
2822
+ ]);
2823
+
2824
+ const PI_AI_PERMANENT_MESSAGE_PATTERNS = Object.freeze([
2825
+ /\b(?:pending|deferred|unauthorized|forbidden|invalid[\s_-]+request|model[\s_-]+not[\s_-]+allowed|quota)\b/i,
2826
+ /(?:^|[^0-9])(?:400|401|403|413)(?=$|[^0-9])/i,
2827
+ /\b(?:authentication|authorization|credential)[\s_-]+(?:failed|invalid|missing|required|denied)\b/i,
2828
+ ]);
2829
+
2830
+ /**
2831
+ * PI_AI_ERROR 中已知的永久错误只按结构化 message 的明确边界匹配。
2832
+ * 未知 message 保持既有未知错误策略,不在这里擅自扩大终态集合。
2833
+ * @param {string} [message] - PI AI 错误消息。
2834
+ * @returns {boolean} 已知永久消息返回 true。
2835
+ */
2836
+ export function isPermanentPiAiMessage(message) {
2837
+ return typeof message === "string"
2838
+ && PI_AI_PERMANENT_MESSAGE_PATTERNS.some((pattern) => pattern.test(message));
2839
+ }
2840
+
2841
+ /**
2842
+ * PI_AI_ERROR 只允许已知上游/流传输模式进入 request retry。
2843
+ * @param {object} [failure] - 公开 request-error payload 的结构化 failure。
2844
+ * @returns {boolean} 已知瞬态 PI AI 错误返回 true。
2845
+ */
2846
+ export function isTransientPiAiFailure(failure) {
2847
+ if (failure === null || typeof failure !== "object" || failure.code !== "PI_AI_ERROR") return false;
2848
+ if (isPermanentPiAiMessage(failure.message)) return false;
2849
+ return typeof failure.message === "string"
2850
+ && PI_AI_TRANSIENT_MESSAGE_PATTERNS.some((pattern) => pattern.test(failure.message));
2851
+ }
2852
+
2853
+ /**
2854
+ * 判断 PI_AI_ERROR 是否是已知永久错误,以阻止 child turn/end continuation。
2855
+ * @param {object} [failure] - turn/end reason.error 或 request failure。
2856
+ * @returns {boolean} 已知永久 PI AI 错误返回 true。
2857
+ */
2858
+ export function isPermanentPiAiFailure(failure) {
2859
+ return failure !== null
2860
+ && typeof failure === "object"
2861
+ && failure.code === "PI_AI_ERROR"
2862
+ && isPermanentPiAiMessage(failure.message);
2863
+ }
2864
+
2865
+ /**
2866
+ * 判断一个 agent/request-error failure 是否属于插件允许接管的瞬态错误。
2867
+ * @param {object} [failure] - 公开 request-error payload 的结构化 failure。
2868
+ * @returns {boolean} code 和 PI_AI_ERROR message 均明确可重试时返回 true。
2869
+ */
2870
+ export function isTransientRequestFailure(failure) {
2871
+ if (failure === null || typeof failure !== "object" || typeof failure.code !== "string") return false;
2872
+ if (failure.code === "PI_AI_ERROR") return isTransientPiAiFailure(failure);
2873
+ return REQUEST_RETRYABLE_FAILURE_CODES.includes(failure.code);
2874
+ }
2875
+
2876
+ /**
2877
+ * 判断 Host 的 dsh-llm-retry 是否声明会处理该结构化错误。插件只用它避免
2878
+ * 另起并发退避;实际等待和 durable retry 事件仍由 Host 公开 hook 处理。
2879
+ * @param {object} [retryPolicy] - request-error payload 的 resolved retryPolicy。
2880
+ * @param {object} [failure] - request-error payload 的结构化 failure。
2881
+ * @returns {boolean} Host policy 覆盖该 code 时返回 true。
2882
+ */
2883
+ export function requestRetryPolicyCoversFailure(retryPolicy, failure) {
2884
+ if (!isTransientRequestFailure(failure) || retryPolicy === null || typeof retryPolicy !== "object") return false;
2885
+ if (retryPolicy.mode === "always") return true;
2886
+ return retryPolicy.mode === "normal"
2887
+ && Array.isArray(retryPolicy.retryableCodes)
2888
+ && retryPolicy.retryableCodes.includes(failure.code);
2889
+ }
2890
+
2891
+ /**
2892
+ * 创建一个 Agent/Session 绑定的请求级重试状态。WeakSet/WeakMap 只保存运行时
2893
+ * 对象身份,不把 payload 或 failure 写入持久日志;Set 仅保存不透明 requestId。
2894
+ */
2895
+ export function createRequestRetryState(session) {
2896
+ return {
2897
+ session,
2898
+ turn: void 0,
2899
+ step: void 0,
2900
+ retryCount: 0,
2901
+ exhausted: false,
2902
+ generation: 0,
2903
+ seenPayloads: new WeakSet(),
2904
+ seenFailures: new WeakSet(),
2905
+ seenRequestIds: new Set(),
2906
+ };
2907
+ }
2908
+
2909
+ /**
2910
+ * 切换到新的 Session/turn/step 前清空请求级去重和预算状态。
2911
+ * @param {object} state - createRequestRetryState 返回的状态。
2912
+ * @param {object} session - 当前 durable Session。
2913
+ * @param {number} [turn] - 新 turn。
2914
+ * @param {number} [step] - 新 step。
2915
+ * @returns {object} 原状态,便于调用方链式保存。
2916
+ */
2917
+ export function resetRequestRetryState(state, session, turn, step) {
2918
+ if (state === null || typeof state !== "object") return state;
2919
+ if (typeof state.waitController?.abort === "function") state.waitController.abort();
2920
+ state.waitController = void 0;
2921
+ state.session = session;
2922
+ state.turn = Number.isSafeInteger(turn) ? turn : void 0;
2923
+ state.step = Number.isSafeInteger(step) ? step : void 0;
2924
+ state.retryCount = 0;
2925
+ state.exhausted = false;
2926
+ state.generation = Number.isSafeInteger(state.generation) ? state.generation + 1 : 1;
2927
+ state.activeKey = void 0;
2928
+ state.seenPayloads = new WeakSet();
2929
+ state.seenFailures = new WeakSet();
2930
+ state.seenRequestIds = new Set();
2931
+ return state;
2932
+ }
2933
+
2934
+ /**
2935
+ * 标记一次 request-error 事件并按对象/requestId 去重。没有可靠对象身份或
2936
+ * requestId 时不猜测结构等价,允许后续真实请求尝试继续消耗独立预算。
2937
+ * @param {object} state - 请求级状态。
2938
+ * @param {object} payload - request-error payload。
2939
+ * @param {object} [failure] - payload.failure。
2940
+ * @returns {boolean} 已处理过同一事件对象时返回 true。
2941
+ */
2942
+ export function markRequestRetryEvent(state, payload, failure) {
2943
+ if (state === null || typeof state !== "object") return false;
2944
+ const payloadObject = payload !== null && (typeof payload === "object" || typeof payload === "function");
2945
+ const failureObject = failure !== null && (typeof failure === "object" || typeof failure === "function");
2946
+ const requestId = typeof failure?.requestId === "string" && failure.requestId.length > 0
2947
+ ? failure.requestId : void 0;
2948
+ const duplicate = (payloadObject && state.seenPayloads.has(payload))
2949
+ || (failureObject && state.seenFailures.has(failure))
2950
+ || (requestId !== void 0 && state.seenRequestIds.has(requestId));
2951
+ if (payloadObject) state.seenPayloads.add(payload);
2952
+ if (failureObject) state.seenFailures.add(failure);
2953
+ if (requestId !== void 0) state.seenRequestIds.add(requestId);
2954
+ return duplicate;
2955
+ }
2956
+
2957
+ /**
2958
+ * 清除某个 recovery op 自己持有的 timer(可重复调用)。先摘掉句柄并标记取消,
2959
+ * 再调用注入的 clearTimeout,确保迟到 callback 即使无法被宿主取消也不会 dispatch;
2960
+ * 不读取或改写其他 op 的状态。
2961
+ */
2962
+ export function clearRecoveryOpTimer(op, clearTimeoutFn = globalThis.clearTimeout) {
2963
+ if (op === null || typeof op !== "object") return false;
2964
+ const timer = op.timer;
2965
+ if (timer === void 0) return false;
2966
+ op.timer = void 0;
2967
+ op.timerCancelled = true;
2968
+ if (typeof clearTimeoutFn === "function") clearTimeoutFn(timer);
2969
+ return true;
2970
+ }
2971
+
2972
+ /**
2973
+ * 为一个 recovery op 安排唯一的退避 timer。scheduler、生命周期 guard 和错误
2974
+ * 收口均可注入,测试无需真实等待;生产调用使用宿主 setTimeout。timer 到期后
2975
+ * 只负责清除自身句柄并调用 onReady,具体 queueMicrotask、record/op 身份检查和
2976
+ * manager admission 仍由插件调用方完成。
2977
+ *
2978
+ * timerScheduled 一旦置 true 就不会再次安排同一 op;timerFired/timerCancelled
2979
+ * 处理重复、迟到和取消竞态。onReady 或 isActive 抛错/拒绝时交给 onError,避免
2980
+ * timer callback 产生未处理异常。
2981
+ */
2982
+ export function scheduleRecoveryOpTimer(op, recoveryCount, onReady, options = {}) {
2983
+ if (op === null || typeof op !== "object" || typeof onReady !== "function") return false;
2984
+ if (op.done === true || recoveryOpIsAccepted(op) || op.timerScheduled === true) return false;
2985
+ const delay = recoveryDelayForCount(recoveryCount);
2986
+ if (delay === void 0) return false;
2987
+ const setTimeoutFn = typeof options?.setTimeout === "function" ? options.setTimeout : globalThis.setTimeout;
2988
+ const clearTimeoutFn = typeof options?.clearTimeout === "function" ? options.clearTimeout : globalThis.clearTimeout;
2989
+ const isActive = typeof options?.isActive === "function" ? options.isActive : () => true;
2990
+ const onError = typeof options?.onError === "function" ? options.onError : () => {};
2991
+ if (typeof setTimeoutFn !== "function") return false;
2992
+
2993
+ op.timerScheduled = true;
2994
+ op.timerCancelled = false;
2995
+ let timer;
2996
+ const reportError = (error) => {
2997
+ try {
2998
+ onError(error);
2999
+ } catch {
3000
+ // 错误处理器本身不能把宿主 timer callback 变成未处理异常。
3001
+ }
3002
+ };
3003
+ const callback = () => {
3004
+ if (op.timerFired === true || op.timerCancelled === true) return;
3005
+ op.timerFired = true;
3006
+ // callback 开始即清空句柄;后续 admission/queue 只靠 op/record guard 防迟到。
3007
+ op.timer = void 0;
3008
+ let active;
3009
+ try {
3010
+ active = isActive() === true;
3011
+ } catch (error) {
3012
+ reportError(error);
3013
+ return;
3014
+ }
3015
+ if (!active) return;
3016
+ try {
3017
+ const result = onReady();
3018
+ if (result !== void 0 && result !== null && typeof result.then === "function") {
3019
+ Promise.resolve(result).catch(reportError);
3020
+ }
3021
+ } catch (error) {
3022
+ reportError(error);
3023
+ }
3024
+ };
3025
+ try {
3026
+ timer = setTimeoutFn(callback, delay);
3027
+ } catch (error) {
3028
+ reportError(error);
3029
+ return false;
3030
+ }
3031
+ // 处理测试 scheduler 或宿主替换实现意外同步触发 callback 的边界,避免把
3032
+ // 已到期的句柄重新写回 op,造成后续 clear/dispatch 误判。
3033
+ if (op.timerFired === true || op.timerCancelled === true) {
3034
+ if (timer !== void 0 && typeof clearTimeoutFn === "function") clearTimeoutFn(timer);
3035
+ } else {
3036
+ op.timer = timer;
3037
+ }
3038
+ return true;
3039
+ }
3040
+
3041
+ /**
3042
+ * 创建一个自动恢复 op(B4):每个触发恢复的 turn 一个**独立** op,互不串链、
3043
+ * 互不重复恢复——单个全局槽位无法区分并发恢复。普通 steer 与自动恢复
3044
+ * 交错时仍由插件统一 helper 管理:自动恢复和 queue=true continuation 都走公开
3045
+ * manager sendMessage;自动恢复不调用 interrupt,普通 queue=false 才先请求取消,最终仍由 sendMessage 完成 admission。
3046
+ * @param {number|string} id - op 唯一 id(调用方保证单调递增)。
3047
+ * @param {number} triggerTurn - 触发恢复的 turn 号。
3048
+ * @returns {object} 新的 op:{ id, triggerTurn, messageId:null, accepted:false,
3049
+ * dispatching:false, taskKeys:Set, activityIds:Set, controller, done }——accepted
3050
+ * 表示 manager sendMessage 已越过 admission 边界,即使外围 callback 尚未消费
3051
+ * messageId 也不可 supersede;dispatching 表示同 scope 锁内正在等待该边界。
3052
+ * taskKeys 是本 op 对应的原任务 key;
3053
+ * activityIds 是独立于 task binding LRU 的活动范围终态关联(恢复链跨 op
3054
+ * 传播,S1-C);controller 供统一投递 helper 的 signal 使用,
3055
+ * 由 dispatchRecovery 填写、dropRecoveryRecord 清理时 abort。timer 由
3056
+ * scheduleRecoveryOpTimer 填写,timerScheduled/timerFired/timerCancelled
3057
+ * 共同防止同一 op 重复安排、迟到 callback 和取消竞态。
3058
+ */
3059
+ export function createRecoveryOp(id, triggerTurn) {
3060
+ return {
3061
+ id,
3062
+ triggerTurn,
3063
+ messageId: null,
3064
+ // manager sendMessage 返回 messageId 前为 false;即使 messageId 尚未被外层
3065
+ // 回调消费,accepted=true 也表示消息已经越过 manager admission 边界。
3066
+ accepted: false,
3067
+ // dispatchRecovery 持有同 scope 锁等待 manager admission 时置 true;
3068
+ // supersede 不应在未知 acceptance 的窗口强行取消它。
3069
+ dispatching: false,
3070
+ // S1-C:本 op 对应的原任务 key 集合。首个 op 由触发 turn 的消息 id 反查
3071
+ // record.tasks 填入;恢复链上后续 op 继承前一个 op 的 taskKeys——恢复 turn
3072
+ // 完结时据此把结局回写到原任务绑定(success/failed),不因消息 id 变化丢失。
3073
+ taskKeys: new Set(),
3074
+ // P1:即使 record.tasks 已淘汰原 binding,恢复投递失败或后续终态仍能
3075
+ // 精确撤销对应 affectedPaths 活动登记,不影响同 child 的其他任务。
3076
+ activityIds: new Set(),
3077
+ controller: void 0,
3078
+ // timer 生命周期由 shared.js seam 管理;未安排时句柄为空。
3079
+ timer: void 0,
3080
+ timerScheduled: false,
3081
+ timerFired: false,
3082
+ timerCancelled: false,
3083
+ done: false,
3084
+ };
3085
+ }
3086
+
3087
+ /**
3088
+ * 判断 manager 是否已经接受恢复消息(纯函数):
3089
+ * messageId 可能还没被外围回调消费,但 accepted=true 已经越过
3090
+ * manager sendMessage 的 admission 边界,不能再被普通新任务 supersede。
3091
+ * @param {object} [op] - recovery op。
3092
+ * @returns {boolean} 已接受返回 true。
3093
+ */
3094
+ export function recoveryOpIsAccepted(op) {
3095
+ if (op === null || typeof op !== "object") return false;
3096
+ return op.accepted === true || (typeof op.messageId === "string" && op.messageId.length > 0);
3097
+ }
3098
+
3099
+ /**
3100
+ * 判断 recovery op 是否仍可被新任务取代(纯函数):
3101
+ * dispatching 表示同 scope 锁内正在等待 manager admission,状态尚未确定,
3102
+ * 也不能在锁外抢先取消;accepted/messageId 则明确表示已越过 acceptance 边界。
3103
+ * @param {object} [op] - recovery op。
3104
+ * @returns {boolean} 仍可 supersede 返回 true。
3105
+ */
3106
+ export function recoveryOpCanBeSuperseded(op) {
3107
+ if (op === null || typeof op !== "object") return false;
3108
+ if (op.done === true || op.dispatching === true) return false;
3109
+ return !recoveryOpIsAccepted(op);
3110
+ }
3111
+
3112
+ /**
3113
+ * 在 pending 恢复 op 中查找「恢复消息已落入该 turn」的 op(B4):
3114
+ * 恢复 turn 结束时按恢复 messageId 找到对应 op 并完结(done);
3115
+ * 随后该 turn 再失败时按正常规则创建新 op。
3116
+ * @param {Map<number|string, object>} [pendingRecoveries] - opId -> op。
3117
+ * @param {Set<string>} [turnMsgIds] - 该 turn 的消息 id 集合(含 claimed 并集)。
3118
+ * @returns {object|undefined} 匹配的 op;无匹配返回 undefined。
3119
+ */
3120
+ export function findRecoveryOpByMessage(pendingRecoveries, turnMsgIds) {
3121
+ if (!(pendingRecoveries instanceof Map) || !(turnMsgIds instanceof Set)) return void 0;
3122
+ for (const op of pendingRecoveries.values()) {
3123
+ if (typeof op?.messageId === "string" && turnMsgIds.has(op.messageId)) return op;
3124
+ }
3125
+ return void 0;
3126
+ }
3127
+
3128
+ /**
3129
+ * 是否存在触发该 turn 的进行中恢复 op(扫描场景判定「恢复是否已发出」,B4)。
3130
+ * @param {Map<number|string, object>} [pendingRecoveries] - opId -> op。
3131
+ * @param {number} turn - turn 号。
3132
+ * @returns {boolean} 存在未完结且 triggerTurn === turn 的 op 返回 true。
3133
+ */
3134
+ export function recoveryOpPendingForTurn(pendingRecoveries, turn) {
3135
+ if (!(pendingRecoveries instanceof Map) || !Number.isSafeInteger(turn)) return false;
3136
+ for (const op of pendingRecoveries.values()) {
3137
+ if (op?.triggerTurn === turn && op?.done !== true) return true;
3138
+ }
3139
+ return false;
3140
+ }
3141
+
3142
+ /**
3143
+ * op 是否仍挂在该记录的 pending 表里(B7):steer 异步回调落地前必须校验
3144
+ * (记录被删除/插件卸载/父代理销毁后,回调必须丢弃,不能推进已失效的状态)。
3145
+ * @param {Map<number|string, object>} [pendingRecoveries] - opId -> op。
3146
+ * @param {object} [op] - 要校验的 op。
3147
+ * @returns {boolean} 仍 pending 返回 true。
3148
+ */
3149
+ export function recoveryOpIsPending(pendingRecoveries, op) {
3150
+ return pendingRecoveries instanceof Map && pendingRecoveries.get(op?.id) === op && op?.done !== true;
3151
+ }
3152
+
3153
+ /**
3154
+ * 判断 recovery microtask 是否可以在创建 tracker scope 前开始 dispatch(纯函数)。
3155
+ * 必须同时确认 child 仍映射到同一 record,且 op 仍挂在 pending 表、尚未 accepted;
3156
+ * 这样父销毁/插件卸载后的迟到 microtask 会直接返回,不会由 serialize 的 entryOf
3157
+ * 重建一个空 scope。调用方在 guard 与 serialize 之间不得 await。
3158
+ * @param {Map<string, object>} [recoveryRecords] - childId -> recovery record。
3159
+ * @param {string} childId - microtask 捕获的 child session id。
3160
+ * @param {object} [record] - microtask 捕获的 recovery record。
3161
+ * @param {object} [op] - microtask 捕获的 recovery op。
3162
+ * @returns {boolean} 仍可开始 dispatch 返回 true。
3163
+ */
3164
+ export function recoveryDispatchCanStart(recoveryRecords, childId, record, op) {
3165
+ return recoveryRecords instanceof Map
3166
+ && recoveryRecords.get(childId) === record
3167
+ && recoveryOpIsPending(record?.pendingRecoveries, op)
3168
+ && !recoveryOpIsAccepted(op);
3169
+ }
3170
+
3171
+ /**
3172
+ * 把 turn/end 的 reason 转成面向子代理的中文原因说明。
3173
+ * @param {object} [reason] - turn/end 的 data.reason。
3174
+ * @returns {string} 中文原因文案。
3175
+ */
3176
+ export function recoveryReasonText(reason) {
3177
+ const kind = reason?.kind;
3178
+ if (kind === "error") {
3179
+ const message = reason?.error?.message;
3180
+ if (typeof message === "string" && message.length > 0) return `执行出错:${message}`;
3181
+ return "执行出错";
3182
+ }
3183
+ if (kind === "max-tokens") return "达到输出 token 上限";
3184
+ if (kind === "completed") return "没有返回任何文本输出";
3185
+ if (kind === "aborted") return "执行被取消";
3186
+ // B9:blocked(pre-step 拒绝,消息被认领但未落盘)与 refusal 统一为「任务被拒绝」。
3187
+ if (kind === "refusal" || kind === "blocked") return "任务被拒绝";
3188
+ return `未知结束原因(${String(kind ?? "未知")})`;
3189
+ }
3190
+
3191
+ /**
3192
+ * 生成自动恢复的「继续执行」指令(以 user 消息文本发送给子代理)。
3193
+ * @param {object} [reason] - turn/end 的 data.reason。
3194
+ * @returns {string} 中文恢复指令文本。
3195
+ */
3196
+ export function buildRecoveryMessage(reason) {
3197
+ return `你上一次执行没有返回有效结果(原因:${recoveryReasonText(reason)})。请继续完成你收到的原始任务,并在完成后给出完整最终回复;不要重复已经成功的步骤。`;
3198
+ }
3199
+
3200
+ /**
3201
+ * 从会话事件日志中读取最近一次 turn/end 的底层错误消息(纯函数形态的
3202
+ * childTurnError:只读 events,不依赖 Agent 对象)。
3203
+ * @param {Array} [events] - session.events。
3204
+ * @returns {string|null} 最近一次底层错误消息;无可用详情返回 null。
3205
+ */
3206
+ export function lastTurnErrorMessage(events) {
3207
+ if (!Array.isArray(events)) return null;
3208
+ for (let i = events.length - 1; i >= 0; i--) {
3209
+ const event = events[i];
3210
+ if (event?.type !== "turn/end") continue;
3211
+ const message = event?.data?.reason?.error?.message;
3212
+ if (typeof message === "string" && message.length > 0) return message;
3213
+ }
3214
+ return null;
3215
+ }
3216
+
3217
+ /**
3218
+ * 当前页面状态文案允许使用的英文专家 role id。根会话不属于 role 列表,
3219
+ * 由 resolveRunningStatusText 固定映射为 Orchestrator。
3220
+ */
3221
+ export const STATUS_ROLE_IDS = [...ROLES, ...OPTIONAL_ROLES];
3222
+
3223
+ // ── 专家提示词覆盖的共享配置边界 ──────────────────────────────────────────
3224
+ // Host 与客户端必须使用同一份角色集合、同一上限与同一「空白即无效」语义,
3225
+ // 否则设置页会显示一份可保存、Host 却拒绝(或反之)的草稿。这里是唯一来源;
3226
+ // 客户端 bundle 通过同目录复制使用同样的字面量。
3227
+ // 本模块保持无 Node 依赖:只做字符串/对象归一,不读取 settings 或任何 Host 状态。
3228
+
3229
+ /** dsh-expert-group settings namespace 中承载六角色自定义提示词的字段名。 */
3230
+ export const PROMPT_OVERRIDES_SETTINGS_KEY = "promptOverrides";
3231
+
3232
+ /**
3233
+ * 单个专家提示词的最大 Unicode grapheme 数量(32,000)。
3234
+ *
3235
+ * 用 grapheme 而不是 UTF-16 code unit:中文、emoji 与组合字符的显示计数必须与
3236
+ * 用户直觉一致;组合字符(例如带连接符的 emoji)算一个字符,不会被重复计数。
3237
+ *
3238
+ * 注意这是**显示/校验口径**,与传输体积是两件独立的事:grapheme 与 UTF-8 字节
3239
+ * 不成固定比例(ASCII 1 字节、CJK/emoji 基础码位最多 4 字节,而 ZWJ 组合簇的字节
3240
+ * 数没有固定上界)。整份候选的字节预算由 host/rpc-transport 的
3241
+ * RPC_SETTINGS_COMMIT_BODY_LIMIT 独立负责,超限是结构化 413 而不是截断。
3242
+ */
3243
+ export const PROMPT_OVERRIDE_MAX_GRAPHEMES = 32000;
3244
+
3245
+ /** 提示词覆盖的稳定错误 code:保存候选在写入边界被拒绝时返回。 */
3246
+ export const PROMPT_OVERRIDE_INVALID_CODE = "INVALID_PROMPT_OVERRIDE";
3247
+
3248
+ /**
3249
+ * 未注册角色被写入覆盖时的稳定错误 code。
3250
+ *
3251
+ * 与「非法角色」分开:未知角色键是调用方拼错了角色集合,而不是提示词内容问题。
3252
+ */
3253
+ export const PROMPT_OVERRIDE_UNKNOWN_ROLE_CODE = "UNKNOWN_PROMPT_ROLE";
3254
+
3255
+ /**
3256
+ * 按用户可感知的 Unicode 字符计数。
3257
+ *
3258
+ * 优先使用 Intl.Segmenter(Node 与目标浏览器都提供);只有不可用时才回退到
3259
+ * [...text] 的 code point 计数——回退会低估组合字符(用户看到 1 个字符,这里算 N 个),
3260
+ * 因此上限判定只会更宽松,绝不会因为环境差异拒绝用户能看到的合法长度。
3261
+ *
3262
+ * @param {unknown} text - 待计数文本;非字符串按空字符串处理。
3263
+ * @returns {number} grapheme 数量(非负安全整数)。
3264
+ */
3265
+ export function countPromptGraphemes(text) {
3266
+ const value = typeof text === "string" ? text : "";
3267
+ if (value.length === 0) return 0;
3268
+ try {
3269
+ const segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
3270
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
3271
+ : null;
3272
+ if (segmenter !== null) {
3273
+ let count = 0;
3274
+ for (const _segment of segmenter.segment(value)) count += 1;
3275
+ return count;
3276
+ }
3277
+ } catch { /* 环境不支持 Segmenter:回退到 code point 计数 */ }
3278
+ let count = 0;
3279
+ for (const _codePoint of value) count += 1;
3280
+ return count;
3281
+ }
3282
+
3283
+ /**
3284
+ * 归一化一份提示词覆盖候选。
3285
+ *
3286
+ * 归一规则(Host 写入校验与客户端草稿必须一致):
3287
+ * - 未知角色键、非字符串值与纯空白文本被丢弃(等价于该角色没有覆盖);
3288
+ * - 非空文本保留首尾空白与换行,绝不 trim、压缩空白或截断;
3289
+ * - 超过 grapheme 上限的文本被丢弃(Host 在写入边界会显式拒绝,这里只保证读取安全)。
3290
+ *
3291
+ * @param {unknown} value - settings 文档里的 promptOverrides 原始值。
3292
+ * @returns {Record<string,string>} 归一后的普通对象(含 0 个键表示无覆盖)。
3293
+ */
3294
+ export function normalizePromptOverrides(value) {
3295
+ const result = {};
3296
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return result;
3297
+ for (const [role, text] of Object.entries(value)) {
3298
+ if (!STATUS_ROLE_IDS.includes(role)) continue;
3299
+ if (typeof text !== "string") continue;
3300
+ if (text.trim().length === 0) continue;
3301
+ if (countPromptGraphemes(text) > PROMPT_OVERRIDE_MAX_GRAPHEMES) continue;
3302
+ result[role] = text;
3303
+ }
3304
+ return result;
3305
+ }
3306
+
3307
+ /**
3308
+ * 读取一个角色的有效自定义覆盖。
3309
+ *
3310
+ * @param {unknown} value - settings 文档里的 promptOverrides 原始值。
3311
+ * @param {string} role - 专家角色 id。
3312
+ * @returns {string|undefined} 有效覆盖文本;没有覆盖(或值不合法)时返回 undefined。
3313
+ */
3314
+ export function promptOverrideFor(value, role) {
3315
+ if (typeof role !== "string" || role.length === 0) return void 0;
3316
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
3317
+ const text = value[role];
3318
+ if (typeof text !== "string" || text.trim().length === 0) return void 0;
3319
+ return text;
3320
+ }
3321
+
3322
+ /**
3323
+ * role id 保持小写用于身份匹配;页面显示只使用注册表提供的英文状态标签。
3324
+ * 这里包含可选角色:历史会话或已关闭 Observer 的 child 仍需要正确的状态文案。
3325
+ */
3326
+ export const STATUS_ROLE_LABELS = roleStatusLabels({ enableObserver: true });
3327
+
3328
+ /**
3329
+ * 归一化插件角色元数据;未知、空值和外部角色都返回 undefined,避免客户端
3330
+ * 猜测历史/外部会话的身份。大小写归一只用于可靠匹配,不改变持久化数据。
3331
+ * @param {unknown} role - RPC/marker 提供的角色值。
3332
+ * @returns {string|undefined} 已验证的英文 role id。
3333
+ */
3334
+ export function normalizeStatusRole(role) {
3335
+ if (typeof role !== "string") return void 0;
3336
+ const normalized = role.trim().toLowerCase();
3337
+ return STATUS_ROLE_IDS.includes(normalized) ? normalized : void 0;
3338
+ }
3339
+
3340
+ /**
3341
+ * 判断会话摘要是否明确表示专家 child。只有 parentId 或 DSH 的 subagent origin
3342
+ * 存在时才视为 child;普通根会话不会因缺失插件 metadata 而被误判为未知 child。
3343
+ * @param {object} [session] - useSessions 当前快照中的 SessionSummary。
3344
+ * @returns {boolean} 明确为 child 返回 true。
3345
+ */
3346
+ export function isPluginChildSession(session) {
3347
+ return session !== null
3348
+ && typeof session === "object"
3349
+ && ((typeof session.parentId === "string" && session.parentId.length > 0) || session.origin === "subagent");
3350
+ }
3351
+
3352
+ /**
3353
+ * 从插件 RPC 响应中解析与 sessionId 绑定的状态身份。响应没有当前 session 的
3354
+ * 身份时直接丢弃;未知 kind 返回 unknown,客户端因此保持原生文案而不猜测 root/child。
3355
+ * @param {object} input - 当前请求 sessionId、响应所属 sessionId 与 JSON 响应。
3356
+ * @returns {{sessionId:string, kind:"child"|"root"|"unknown", role:string|undefined}|null}
3357
+ */
3358
+ export function resolveStatusIdentity({ sessionId, responseSessionId, response } = {}) {
3359
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
3360
+ const reportedSessionId = response?.sessionId ?? responseSessionId;
3361
+ if (reportedSessionId !== sessionId) return null;
3362
+ if (response?.ok === false) return { sessionId, kind: "unknown", role: void 0 };
3363
+ if (response?.kind === "child") {
3364
+ return {
3365
+ sessionId,
3366
+ kind: "child",
3367
+ role: normalizeStatusRole(response?.child?.role),
3368
+ };
3369
+ }
3370
+ if (response?.kind === "root") return { sessionId, kind: "root", role: void 0 };
3371
+ return { sessionId, kind: "unknown", role: void 0 };
3372
+ }
3373
+
3374
+ /**
3375
+ * CSS Module 稳定局部名匹配:只认局部名本身,不绑定构建 hash 形态。
3376
+ * Vite 默认产物是 `_label_hash_line`,旧夹具/其它工具链是 `hash_label`;两种形状都只依赖
3377
+ * 局部名,因此既不能硬编码完整 hash,也不能只认某一种前后缀拼接。
3378
+ * @param {string|undefined} className - 元素的 class 属性值。
3379
+ * @param {string} localName - CSS Module 局部名(turnStatus / label / visuallyHidden)。
3380
+ * @returns {boolean} 任一 class token 的局部名命中时返回 true。
3381
+ */
3382
+ const localClassTokenPatterns = new Map();
3383
+ function hasLocalClassToken(className, localName) {
3384
+ if (typeof className !== "string") return false;
3385
+ // 局部名只来自本文件的常量;缓存两个正则是因为这段代码在每次 observer 通知里对
3386
+ // 每个候选元素都会跑一遍,热路径上不值得反复编译同一个正则。
3387
+ let patterns = localClassTokenPatterns.get(localName);
3388
+ if (patterns === void 0) {
3389
+ patterns = [new RegExp("(?:^|_)" + localName + "$"), new RegExp("^_" + localName + "(?:_|$)")];
3390
+ localClassTokenPatterns.set(localName, patterns);
3391
+ }
3392
+ return className.split(/\s+/).some((token) => patterns[0].test(token) || patterns[1].test(token));
3393
+ }
3394
+
3395
+ /**
3396
+ * DSH 0.1.7 的隐藏 announcement 语义。aria-atomic=true 是它最稳定的标记,
3397
+ * visuallyHidden 局部名 / hidden / aria-hidden=true 是等价的隐藏形状;隐藏节点永远不能
3398
+ * 成为可见文案目标,也不能被附加动画。
3399
+ * @param {object} node - 候选元素。
3400
+ * @returns {boolean} 明确隐藏时返回 true。
3401
+ */
3402
+ function isHiddenStatusCandidate(node) {
3403
+ if (node === null || node === void 0 || typeof node?.getAttribute !== "function") return false;
3404
+ if (node.getAttribute("aria-atomic") === "true") return true;
3405
+ if (node.getAttribute("aria-hidden") === "true") return true;
3406
+ const hidden = node.getAttribute("hidden");
3407
+ if (hidden !== null && hidden !== void 0) return true;
3408
+ return hasLocalClassToken(node.getAttribute("class"), "visuallyHidden");
3409
+ }
3410
+
3411
+ /**
3412
+ * 唯一主文本节点:恰好一个 trim 后非空的直接 Text,其余子节点只能是 aria-hidden=true 计时器。
3413
+ * 旧版 turnStatus 与新版 turn-process label 共用这条形状约束,避免命中 notice、拼装状态或容器。
3414
+ * @param {object} node - 候选容器元素。
3415
+ * @returns {object|undefined} 可安全改写的 Text 节点。
3416
+ */
3417
+ function singleNativeStatusTextNode(node) {
3418
+ const childNodes = Array.from(node?.childNodes ?? []);
3419
+ const textNodes = childNodes.filter((child) => child?.nodeType === 3);
3420
+ const accessoryNodes = childNodes.filter((child) => child?.nodeType !== 3);
3421
+ const hasOnlyAriaHiddenAccessories = accessoryNodes.every((child) =>
3422
+ child?.nodeType === 1 && typeof child?.getAttribute === "function" && child.getAttribute("aria-hidden") === "true"
3423
+ );
3424
+ if (textNodes.length !== 1 || typeof textNodes[0].nodeValue !== "string" || textNodes[0].nodeValue.trim().length === 0 || !hasOnlyAriaHiddenAccessories) return void 0;
3425
+ return textNodes[0];
3426
+ }
3427
+
3428
+ /** 深度优先收集后代元素;label 查找只在 button 子树内做,范围固定且很小。 */
3429
+ function collectDescendantElements(node, out = []) {
3430
+ for (const child of Array.from(node?.childNodes ?? [])) {
3431
+ if (child?.nodeType !== 1) continue;
3432
+ out.push(child);
3433
+ collectDescendantElements(child, out);
3434
+ }
3435
+ return out;
3436
+ }
3437
+
3438
+ /**
3439
+ * DSH 0.1.7 的可见状态目标是 span,不是任意带 label 局部名的元素:
3440
+ * 按钮内部的图标/计时器容器或嵌套控件同样可能叫 label,但只有 span 承担可见文案。
3441
+ * 大小写不敏感(真实 DOM 给 SPAN,fake DOM 给 span),缺失 tagName 时保守拒绝。
3442
+ * @param {object} element - 候选元素。
3443
+ * @returns {boolean} 元素是 span 时返回 true。
3444
+ */
3445
+ function isSpanStatusLabelElement(element) {
3446
+ return typeof element?.tagName === "string" && element.tagName.toLowerCase() === "span";
3447
+ }
3448
+
3449
+ /**
3450
+ * DSH 会话边界可能使用任一公开 data-* 属性;状态行自身没有属性时还要沿祖先查找。
3451
+ * markerless DOM 只能在所有已发现属性都精确指向当前会话时写入,否则保持原生文案。
3452
+ */
3453
+ const NATIVE_SESSION_ATTRIBUTES = [
3454
+ "data-session-id",
3455
+ "data-conversation-session-id",
3456
+ "data-conversation-session",
3457
+ "data-session",
3458
+ ];
3459
+
3460
+ function nativeStatusSessionValues(node, boundary) {
3461
+ const values = [];
3462
+ let current = node;
3463
+ while (current !== null && current !== void 0) {
3464
+ if (typeof current.getAttribute === "function") {
3465
+ for (const name of NATIVE_SESSION_ATTRIBUTES) {
3466
+ const value = current.getAttribute(name);
3467
+ if (typeof value === "string" && value.length > 0) values.push(value);
3468
+ }
3469
+ }
3470
+ if (boundary !== void 0 && current === boundary) break;
3471
+ current = current.parentNode;
3472
+ }
3473
+ return values;
3474
+ }
3475
+
3476
+ /**
3477
+ * 按指定边界校验候选节点:markerless 需要至少一条当前会话证据;已有 scope 时即使
3478
+ * 只靠 scope 证明归属,也必须拒绝节点自身或祖先携带的冲突 session 属性。
3479
+ */
3480
+ function nativeStatusNodeMatchesSession(node, sessionId, boundary, requireEvidence) {
3481
+ if (sessionId === void 0 || sessionId === null || String(sessionId).length === 0) return false;
3482
+ const values = nativeStatusSessionValues(node, boundary);
3483
+ const expected = String(sessionId);
3484
+ return (!requireEvidence || values.length > 0) && values.every((value) => value === expected);
3485
+ }
3486
+
3487
+ /** 读取节点自身携带的全部公开会话属性值(空值忽略)。 */
3488
+ function sessionValuesOf(node) {
3489
+ const values = [];
3490
+ if (typeof node?.getAttribute === "function") {
3491
+ for (const name of NATIVE_SESSION_ATTRIBUTES) {
3492
+ const value = node.getAttribute(name);
3493
+ if (typeof value === "string" && value.length > 0) values.push(value);
3494
+ }
3495
+ }
3496
+ return values;
3497
+ }
3498
+
3499
+ /**
3500
+ * 定位 DSH 0.2.0-rc1 的会话证据容器。
3501
+ *
3502
+ * RC1 的 data-conversation-session 不在 [data-conversation-scroll] 上,而在它的
3503
+ * [data-conversation-content] 外层容器上;只读 scroll 自身会把当前会话判定成"无标记",
3504
+ * 于是两棵 conversation 并存时整体放弃,新运行行完全漏掉。
3505
+ *
3506
+ * 语义边界:
3507
+ * - 没有 [data-conversation-content] 祖先时返回 undefined:绝不越过 scroll 向上猜证据,
3508
+ * 既有 legacy 形态(会话属性直接落在 scope 或状态节点上)语义完全不变;
3509
+ * - 有 content 祖先时,就近取它或它最近带会话属性的祖先作为证据容器;冲突属性仍由
3510
+ * nativeStatusNodeMatchesSession 的"链上所有已发现属性必须一致"兜住。
3511
+ * @param {object} scope - 已确定的 [data-conversation-scroll] 会话边界。
3512
+ * @returns {object|undefined} 证据容器;没有 content 祖先时返回 undefined。
3513
+ */
3514
+ function nativeConversationEvidenceOwner(scope) {
3515
+ let content;
3516
+ let current = scope;
3517
+ while (current !== null && current !== void 0) {
3518
+ if (typeof current.getAttribute === "function" && current.getAttribute("data-conversation-content") !== null) {
3519
+ content = current;
3520
+ break;
3521
+ }
3522
+ current = current.parentNode;
3523
+ }
3524
+ if (content === void 0) return void 0;
3525
+ let node = content;
3526
+ while (node !== null && node !== void 0) {
3527
+ if (typeof node.getAttribute === "function" && sessionValuesOf(node).length > 0) return node;
3528
+ node = node.parentNode;
3529
+ }
3530
+ return content;
3531
+ }
3532
+
3533
+ /** scope 的会话证据:scope 自身的会话属性 + RC1 content 外层的会话属性。 */
3534
+ function nativeStatusScopeIds(scope) {
3535
+ return [...sessionValuesOf(scope), ...sessionValuesOf(nativeConversationEvidenceOwner(scope))];
3536
+ }
3537
+
3538
+ /** 候选节点的会话证据边界:RC1 用 content 外层,legacy 用 scope 自身。 */
3539
+ function nativeStatusSessionBoundary(scope) {
3540
+ return nativeConversationEvidenceOwner(scope) ?? scope;
3541
+ }
3542
+
3543
+ /**
3544
+ * RC1 装饰子树判定:aria-hidden=true / inert / hidden / visuallyHidden 局部名都属于
3545
+ * "不承载可见文案"的动画副本(TextShimmer 的扫光副本就在这里)。
3546
+ *
3547
+ * 与 isHiddenStatusCandidate 分开:后者还要把 aria-atomic 的隐藏 announcement 视为不可见
3548
+ * 候选,而这里只用于"在跑马灯里找真实可见文案",不需要把 announcement 排除法混进来。
3549
+ * @param {object} node - 候选元素。
3550
+ * @returns {boolean} 属于装饰子树时返回 true。
3551
+ */
3552
+ function isDecorativeNativeStatusNode(node) {
3553
+ if (node === null || node === void 0 || typeof node?.getAttribute !== "function") return false;
3554
+ if (node.getAttribute("aria-hidden") === "true") return true;
3555
+ const hidden = node.getAttribute("hidden");
3556
+ if (hidden !== null && hidden !== void 0) return true;
3557
+ const inert = node.getAttribute("inert");
3558
+ if (inert !== null && inert !== void 0) return true;
3559
+ return hasLocalClassToken(node.getAttribute("class"), "visuallyHidden");
3560
+ }
3561
+
3562
+ /**
3563
+ * 收集非装饰子树里的可见 Text(深度优先)。
3564
+ * 装饰子树整棵跳过:它的文字与真实可见文案相同,但属于宿主动画的一部分。
3565
+ * @param {object} node - 起始元素。
3566
+ * @param {Array} [out] - 收集结果。
3567
+ * @param {boolean} [hidden] - 祖先是否已判定为装饰。
3568
+ * @returns {Array} trim 后非空的可见 Text 节点。
3569
+ */
3570
+ function collectVisibleStatusTextNodes(node, out = [], hidden = false) {
3571
+ const hiddenHere = hidden || isDecorativeNativeStatusNode(node);
3572
+ for (const child of Array.from(node?.childNodes ?? [])) {
3573
+ if (child?.nodeType === 3) {
3574
+ if (!hiddenHere && typeof child.nodeValue === "string" && child.nodeValue.trim().length > 0) out.push(child);
3575
+ continue;
3576
+ }
3577
+ if (child?.nodeType !== 1) continue;
3578
+ collectVisibleStatusTextNodes(child, out, hiddenHere);
3579
+ }
3580
+ return out;
3581
+ }
3582
+
3583
+ /**
3584
+ * DSH 0.2.0-rc1 官方运行行的字段集合。
3585
+ *
3586
+ * 结构(实测 DOM):
3587
+ * conversation[data-conversation-content][data-conversation-session]
3588
+ * > [data-conversation-scroll] > [data-chat-running]
3589
+ * ├─ role=status/aria-live=polite/aria-atomic=true 的隐藏 announcement
3590
+ * ├─ 鲸鱼尾巴等 aria-hidden 装饰
3591
+ * └─ [data-shimmer="true"]
3592
+ * ├─ 可见子树:唯一的真实文案 Text(含"深度求索中,用时8分12秒...")
3593
+ * └─ aria-hidden/inert 装饰副本:data-shimmer-text 保存同一份完整标签
3594
+ *
3595
+ * fail-closed:行、跑马灯容器、可见 Text 任一项不是恰好一个就整体放弃,绝不猜可见性;
3596
+ * 归属由行到会话边界的属性链证明(冲突或缺证据时放弃)。
3597
+ * @param {object} scope - 已唯一确定的 conversation scope。
3598
+ * @param {string} sessionId - 当前页面会话 id。
3599
+ * @param {boolean} requireSessionEvidence - 是否要求链上至少一条当前会话属性。
3600
+ * @param {object} [sessionBoundary] - 会话证据边界。
3601
+ * @returns {{visibleText:object, shimmerTextNodes:Array, announcementTexts:Array}|undefined}
3602
+ */
3603
+ function findNativeRunningRowParts(scope, sessionId, requireSessionEvidence = false, sessionBoundary = void 0) {
3604
+ if (scope == null) return void 0;
3605
+ const rows = [];
3606
+ if (scope?.matches?.("[data-chat-running]")) rows.push(scope);
3607
+ for (const row of Array.from(scope?.querySelectorAll?.("[data-chat-running]") ?? [])) {
3608
+ if (!rows.includes(row)) rows.push(row);
3609
+ }
3610
+ if (rows.length !== 1) return void 0;
3611
+ const row = rows[0];
3612
+ if (!nativeStatusNodeMatchesSession(row, sessionId, sessionBoundary, requireSessionEvidence)) return void 0;
3613
+
3614
+ const shimmers = collectDescendantElements(row).filter((element) => element?.getAttribute?.("data-shimmer") === "true");
3615
+ if (shimmers.length !== 1) return void 0;
3616
+ const visibleTexts = collectVisibleStatusTextNodes(shimmers[0]);
3617
+ if (visibleTexts.length !== 1) return void 0;
3618
+ const visibleText = visibleTexts[0];
3619
+ // 不只校验 running row:实际被写入的 Text 和属性节点也可能带冲突会话标记。
3620
+ if (!nativeStatusNodeMatchesSession(visibleText, sessionId, sessionBoundary, requireSessionEvidence)) return void 0;
3621
+
3622
+ const shimmerTextNodes = collectDescendantElements(row).filter((element) => {
3623
+ const value = element?.getAttribute?.("data-shimmer-text");
3624
+ return value !== null && value !== void 0;
3625
+ });
3626
+ if (shimmerTextNodes.some((node) => !nativeStatusNodeMatchesSession(node, sessionId, sessionBoundary, requireSessionEvidence))) return void 0;
3627
+ const announcementTexts = [];
3628
+ for (const element of collectDescendantElements(row)) {
3629
+ if (element?.getAttribute?.("role") !== "status") continue;
3630
+ if (element?.getAttribute?.("aria-live") !== "polite") continue;
3631
+ if (element?.getAttribute?.("aria-atomic") !== "true") continue;
3632
+ const textNode = singleNativeStatusTextNode(element);
3633
+ if (textNode !== void 0) announcementTexts.push(textNode);
3634
+ }
3635
+ if (announcementTexts.some((node) => !nativeStatusNodeMatchesSession(node, sessionId, sessionBoundary, requireSessionEvidence))) return void 0;
3636
+ return { visibleText, shimmerTextNodes, announcementTexts };
3637
+ }
3638
+
3639
+ /**
3640
+ * DSH 0.1.7 把可见运行状态放进 button[data-turn-process] 内的 span.label。
3641
+ * 只有可见、且恰好唯一命中的 label 才是候选:隐藏 announcement 永不入选,非 span 元素
3642
+ * 与多义候选(同 scope 多个可见 label,例如历史 turn 的按钮)一律放弃,让 legacy 兜底而不是猜。
3643
+ * @param {object} scope - 已唯一确定的 conversation scope。
3644
+ * @returns {object|undefined} label 内的主 Text 节点。
3645
+ */
3646
+ function findNativeTurnProcessLabelTextNode(scope, sessionId, requireSessionEvidence = false, sessionBoundary = void 0) {
3647
+ if (scope == null) return void 0;
3648
+ const matches = [];
3649
+ for (const button of Array.from(scope?.querySelectorAll?.("button[data-turn-process]") ?? [])) {
3650
+ if (button?.getAttribute?.("data-turn-process") === null || button?.getAttribute?.("data-turn-process") === void 0) continue;
3651
+ if (isHiddenStatusCandidate(button)) continue;
3652
+ for (const candidate of collectDescendantElements(button)) {
3653
+ if (!isSpanStatusLabelElement(candidate)) continue;
3654
+ if (!hasLocalClassToken(candidate?.getAttribute?.("class"), "label")) continue;
3655
+ if (isHiddenStatusCandidate(candidate)) continue;
3656
+ const textNode = singleNativeStatusTextNode(candidate);
3657
+ if (textNode === void 0) continue;
3658
+ if ((requireSessionEvidence || sessionBoundary !== void 0)
3659
+ && !nativeStatusNodeMatchesSession(textNode, sessionId, sessionBoundary, requireSessionEvidence)) continue;
3660
+ matches.push(textNode);
3661
+ }
3662
+ }
3663
+ return matches.length === 1 ? matches[0] : void 0;
3664
+ }
3665
+
3666
+ /**
3667
+ * 在已确定的会话 scope 内定位 legacy(非 RC1)形态的原生状态主文本节点:
3668
+ * 依次尝试 DSH 0.1.7 的 button[data-turn-process] > span.label 与 0.1.6 的 turnStatus。
3669
+ * RC1 的 [data-chat-running] 行由 resolveNativeStatusTargets 优先处理,不在这里兜底。
3670
+ * markerless 根节点由调用方显式要求祖先会话证据;有 scope marker 时沿用 scope 选择结果。
3671
+ */
3672
+ function findNativeTurnStatusTextNode(scope, sessionId, requireSessionEvidence = false, sessionBoundary = void 0) {
3673
+ if (scope == null) return void 0;
3674
+ const labelNode = findNativeTurnProcessLabelTextNode(scope, sessionId, requireSessionEvidence, sessionBoundary);
3675
+ if (labelNode !== void 0) return labelNode;
3676
+
3677
+ const statuses = [];
3678
+ if (scope?.matches?.('[role="status"]')) statuses.push(scope);
3679
+ for (const status of Array.from(scope?.querySelectorAll?.('[role="status"]') ?? [])) {
3680
+ if (!statuses.includes(status)) statuses.push(status);
3681
+ }
3682
+ let match;
3683
+ for (const status of statuses) {
3684
+ if (status?.getAttribute?.("role") !== "status" || status?.getAttribute?.("aria-live") !== "polite") continue;
3685
+ if (isHiddenStatusCandidate(status)) continue;
3686
+ if (!hasLocalClassToken(status?.getAttribute?.("class"), "turnStatus")) continue;
3687
+ const textNode = singleNativeStatusTextNode(status);
3688
+ if (textNode === void 0) continue;
3689
+ if ((requireSessionEvidence || sessionBoundary !== void 0)
3690
+ && !nativeStatusNodeMatchesSession(textNode, sessionId, sessionBoundary, requireSessionEvidence)) continue;
3691
+ if (match !== void 0) return void 0;
3692
+ match = textNode;
3693
+ }
3694
+ return match;
3695
+ }
3696
+
3697
+ /**
3698
+ * 解析当前会话的原生运行状态**全字段**目标(纯 DOM 选择,不写入):
3699
+ * - RC1:返回 { visibleText, shimmerAttributes, announcements } 三项完整字段;
3700
+ * - legacy(0.1.7 label / 0.1.6 turnStatus):只返回可见 Text,另外两项为空。
3701
+ *
3702
+ * 会话判定:优先 data-conversation-scroll 边界;RC1 的会话标记在它的
3703
+ * [data-conversation-content] 外层,因此证据与候选边界都按 nativeStatusScopeIds /
3704
+ * nativeStatusSessionBoundary 取;没有 scope marker 时扫描 root 并强制要求祖先会话证据。
3705
+ * 任何冲突、歧义或缺少证据的情况都返回 undefined,让调用方保持宿主原文。
3706
+ * @param {object} root - 通常为 document.body 的 DOM 根。
3707
+ * @param {string} sessionId - 当前页面会话 id。
3708
+ * @returns {{visibleText:object, shimmerAttributes:Array, announcements:Array}|undefined}
3709
+ */
3710
+ function resolveNativeStatusTargets(root, sessionId) {
3711
+ const scopes = [];
3712
+ if (root?.matches?.("[data-conversation-scroll]")) scopes.push(root);
3713
+ for (const scope of Array.from(root?.querySelectorAll?.("[data-conversation-scroll]") ?? [])) {
3714
+ if (!scopes.includes(scope)) scopes.push(scope);
3715
+ }
3716
+
3717
+ if (scopes.length === 0) {
3718
+ const textNode = findNativeTurnStatusTextNode(root, sessionId, true);
3719
+ return textNode === void 0 ? void 0 : { visibleText: textNode, shimmerAttributes: [], announcements: [] };
3720
+ }
3721
+
3722
+ const exact = sessionId === void 0 || sessionId === null
3723
+ ? []
3724
+ : scopes.filter((scope) => nativeStatusScopeIds(scope).includes(String(sessionId)));
3725
+ const tagged = scopes.filter((scope) => nativeStatusScopeIds(scope).length > 0);
3726
+ let candidates = [];
3727
+ if (exact.length === 1) {
3728
+ candidates = exact;
3729
+ } else if (exact.length > 1) {
3730
+ const visible = exact.filter((scope) => typeof scope?.getClientRects === "function" && scope.getClientRects().length > 0);
3731
+ if (visible.length === 1) candidates = visible;
3732
+ } else if (tagged.length === 0 && scopes.length === 1) {
3733
+ candidates = scopes;
3734
+ } else if (tagged.length === 0) {
3735
+ const visible = scopes.filter((scope) => typeof scope?.getClientRects === "function" && scope.getClientRects().length > 0);
3736
+ if (visible.length === 1) candidates = visible;
3737
+ }
3738
+ if (candidates.length !== 1) return void 0;
3739
+
3740
+ const scope = candidates[0];
3741
+ const requireSessionEvidence = nativeStatusScopeIds(scope).includes(String(sessionId));
3742
+ const sessionBoundary = nativeStatusSessionBoundary(scope);
3743
+ const parts = findNativeRunningRowParts(scope, sessionId, requireSessionEvidence, sessionBoundary);
3744
+ if (parts !== void 0) {
3745
+ return {
3746
+ visibleText: parts.visibleText,
3747
+ shimmerAttributes: parts.shimmerTextNodes,
3748
+ announcements: parts.announcementTexts,
3749
+ };
3750
+ }
3751
+ const textNode = findNativeTurnStatusTextNode(scope, sessionId, requireSessionEvidence, sessionBoundary);
3752
+ return textNode === void 0 ? void 0 : { visibleText: textNode, shimmerAttributes: [], announcements: [] };
3753
+ }
3754
+
3755
+ /**
3756
+ * 选择当前页面会话的原生运行状态**可见**主文本节点(公开只读 seam)。
3757
+ *
3758
+ * RC1 返回 [data-shimmer="true"] 非装饰子树里的真实可见 Text;0.1.7/0.1.6 的 legacy 形态
3759
+ * 继续返回各自唯一的可见 Text。选择与会话判定语义与 client.js 的同名实现同步。
3760
+ * @param {object} root - 通常为 document.body 的 DOM 根。
3761
+ * @param {string} sessionId - 当前页面会话 id。
3762
+ * @returns {object|undefined} 要改写的可见 Text 节点。
3763
+ */
3764
+ export function findNativeStatusTextNode(root, sessionId) {
3765
+ return resolveNativeStatusTargets(root, sessionId)?.visibleText;
3766
+ }
3767
+
3768
+
3769
+ /**
3770
+ * 计算当前会话的插件运行状态文案(纯函数):
3771
+ * - 只接受页面当前会话且 running === true;
3772
+ * - 明确为 child 时必须有已验证的插件 role;
3773
+ * - 根会话使用 Orchestrator;
3774
+ * - 空闲、等待、完成、无会话、无法从 marker/RPC 得到有效 role 时返回 null,让 DSH 保留原文案。
3775
+ *
3776
+ * 返回值是**不带末尾省略号**的前缀文案("Explorer running" / "Orchestrator running"):
3777
+ * 它只替换宿主原生文案里的运行前缀(见 replaceNativeStatusPrefix),逗号、用时与末尾省略号
3778
+ * 仍来自宿主自己的文本,因此这里追加省略号会渲染出 "running...,用时..." 这种双重标点。
3779
+ * @param {object} input - 当前/目标 session id、running、会话摘要、RPC kind 和可选 role。
3780
+ * @returns {string|null} 要替换的原生前缀文案,或保持宿主原文的 null。
3781
+ */
3782
+ export function resolveRunningStatusText({ sessionId, currentSessionId, running, session, kind, role } = {}) {
3783
+ if (sessionId === void 0 || sessionId === null || sessionId !== currentSessionId || running !== true) return null;
3784
+ const markedChild = isPluginChildSession(session);
3785
+ const normalizedRole = normalizeStatusRole(role);
3786
+ const roleText = normalizedRole === void 0 ? null : STATUS_ROLE_LABELS[normalizedRole] + " running";
3787
+ if (kind === "pending" || kind === "unknown") return markedChild ? roleText : null;
3788
+ if (kind === "child") return roleText;
3789
+ if (kind === "root") return markedChild ? null : "Orchestrator running";
3790
+ if (kind !== void 0 && kind !== null) return null;
3791
+ if (markedChild) return roleText;
3792
+ return "Orchestrator running";
3793
+ }
3794
+
3795
+ /**
3796
+ * 原生运行状态文案里唯一可替换的精确子串。
3797
+ *
3798
+ * DSH 0.2.0-rc1 在输入区上方渲染宿主自己的运行状态行,中文 locale 的可见文案形如
3799
+ * 「深度求索中,用时8分12秒...」(前面还可能挂着鲸鱼尾巴节点与独立的 aria-hidden 计时器)。
3800
+ * 插件只拥有这一段前缀:替换它就能把当前会话的角色投影到宿主原生行上,而逗号、用时、
3801
+ * 末尾省略号以及前后兄弟节点/动画全部保持宿主原样——既不重建整行,也不创建覆盖层。
3802
+ */
3803
+ export const NATIVE_RUNNING_STATUS_PREFIX = "深度求索中";
3804
+
3805
+ /**
3806
+ * 只替换原生运行前缀的纯函数(fail-closed)。
3807
+ *
3808
+ * 边界语义:
3809
+ * - 原文里没有该精确子串时返回 null:调用方必须保持宿主原文案,绝不猜测或整行重建;
3810
+ * - 只替换**第一处**出现,其余内容逐字节保留(分隔逗号、用时、末尾省略号、空白与大小写);
3811
+ * - 原文或替换文案不是非空字符串时返回 null。
3812
+ * @param {unknown} currentText - 宿主当前渲染的原生文案。
3813
+ * @param {unknown} replacementText - 角色文案(例如 "Explorer running",不带省略号)。
3814
+ * @returns {string|null} 替换后的完整文案;没有可替换前缀时返回 null。
3815
+ */
3816
+ export function replaceNativeStatusPrefix(currentText, replacementText) {
3817
+ if (typeof currentText !== "string" || currentText.length === 0) return null;
3818
+ if (typeof replacementText !== "string" || replacementText.length === 0) return null;
3819
+ const at = currentText.indexOf(NATIVE_RUNNING_STATUS_PREFIX);
3820
+ if (at === -1) return null;
3821
+ return currentText.slice(0, at) + replacementText + currentText.slice(at + NATIVE_RUNNING_STATUS_PREFIX.length);
3822
+ }
3823
+
3824
+ /**
3825
+ * 创建只操作一个原生文本节点的前缀重写器。
3826
+ *
3827
+ * DOM 适配、MutationObserver 与 React 生命周期由 client.js 持有;本函数只处理
3828
+ * 「原生前缀 → 角色文案」的保存/替换/恢复,因此可在无 DOM 的 node:test 中用最小 mock 验证。
3829
+ * 它永远不触碰 class/style、兄弟计时器、aria 属性或其它节点,也不重建整行。
3830
+ *
3831
+ * 原文跟进规则(同一个 Text 节点上的内容随时可能被宿主改写):
3832
+ * - 角色变化:只换前缀,逗号/用时/末尾省略号继续沿用宿主最新原文;
3833
+ * - 宿主重渲染、locale 切换或用时刷新:当前值不再是本插件写入的那一份时,把宿主最新值
3834
+ * 认定为原文,清理时恢复它——绝不把旧语言或旧用时写回去;
3835
+ * - 原文没有原生前缀(例如英文 locale 的 "Deep diving...")时不做任何写入。
3836
+ *
3837
+ * 可选 decorate/undecorate 钩子仅用于调用方管理附加生命周期;当前原生状态桥不传入
3838
+ * 任何装饰钩子,因此不会向宿主节点添加 marker、样式或动画。rewriter 仍保证换目标和
3839
+ * 清理时先处理旧目标,避免调用方留下 stale 状态。
3840
+ * @param {object} options - findNode/getText/setText/isConnected/decorate/undecorate 适配函数。
3841
+ * @returns {{apply:(text:string|null)=>void, restore:()=>void}}
3842
+ */
3843
+ export function createNativeTextRewriter(options = {}) {
3844
+ const findNode = typeof options.findNode === "function" ? options.findNode : () => void 0;
3845
+ const getText = typeof options.getText === "function" ? options.getText : (node) => node?.nodeValue;
3846
+ const setText = typeof options.setText === "function" ? options.setText : (node, value) => { node.nodeValue = value; };
3847
+ const isConnected = typeof options.isConnected === "function" ? options.isConnected : (node) => node?.isConnected !== false;
3848
+ const decorate = typeof options.decorate === "function" ? options.decorate : () => {};
3849
+ const undecorate = typeof options.undecorate === "function" ? options.undecorate : () => {};
3850
+ let activeNode;
3851
+ let originalText;
3852
+ let rewrittenText;
3853
+
3854
+ function restore() {
3855
+ // 只在"当前值仍是我们写入的那一份"时回写:宿主已自行改写内容时保持宿主最新值,
3856
+ // 避免把旧语言、旧用时或旧角色文案盖回去。
3857
+ if (activeNode !== void 0 && isConnected(activeNode) && getText(activeNode) === rewrittenText) {
3858
+ setText(activeNode, originalText);
3859
+ }
3860
+ activeNode = void 0;
3861
+ originalText = void 0;
3862
+ rewrittenText = void 0;
3863
+ undecorate();
3864
+ }
3865
+
3866
+ function apply(text) {
3867
+ if (typeof text !== "string" || text.length === 0) {
3868
+ restore();
3869
+ return;
3870
+ }
3871
+ const candidate = findNode();
3872
+ if (candidate === null || candidate === void 0) {
3873
+ restore();
3874
+ return;
3875
+ }
3876
+ const currentText = getText(candidate);
3877
+ if (activeNode !== candidate) {
3878
+ restore();
3879
+ activeNode = candidate;
3880
+ originalText = currentText;
3881
+ } else if (currentText !== rewrittenText) {
3882
+ // DSH 在同一 Text 节点上更新原文(locale 切换、用时刷新、React 重写);
3883
+ // 跟进外部值,清理时恢复的必须是最新原文。
3884
+ originalText = currentText;
3885
+ }
3886
+ const next = replaceNativeStatusPrefix(originalText, text);
3887
+ // 没有可替换的原生前缀(英文 locale、宿主已换成完成文案等)时保持原文,不做任何写入。
3888
+ rewrittenText = next === null ? currentText : next;
3889
+ if (next !== null && currentText !== next) setText(activeNode, next);
3890
+ // 装饰放在文本写入之后:无论首次接管、节点替换还是重复通知,装饰都针对当前目标。
3891
+ decorate(activeNode);
3892
+ }
3893
+
3894
+ return { apply, restore };
3895
+ }
3896
+
3897
+ /**
3898
+ * 递归判断当前 root 的全部后代 runtime Agent 是否仍在运行。
3899
+ *
3900
+ * 续接对话时插件本地 tracker 可能为空,因此判定只能依赖 runtime Agent 自身的
3901
+ * exact 血缘(session.header.parentSession 链)与状态字段:
3902
+ * - parentSession 链最终指向 rootId 的 Agent 才算这个 root 的后代;
3903
+ * - 后代状态 running 表示仍在运行,idle/ready 表示空闲(DSH 的 ready 只是"两轮之间");
3904
+ * - 身份、血缘链或状态无法确认时一律返回 known=false,由上层 fail closed,
3905
+ * 绝不能把"查询不到"当成"已经没有专家在跑"。
3906
+ *
3907
+ * root 自身、其他 root 以及非 subagent 运行时 Agent 都不计入。
3908
+ *
3909
+ * @param {unknown} agents - agents.list() 返回的 live Agent 数组。
3910
+ * @param {string} rootId - 当前 exact root id。
3911
+ * @returns {{known: boolean, blocked: boolean, reason?: string, runningDescendants: object[]}}
3912
+ */
3913
+ export function evaluateRunningRootDescendants(agents, rootId) {
3914
+ const unknown = (reason = "runtime-descendant-identity-unknown") => ({
3915
+ known: false, blocked: true, reason, runningDescendants: [],
3916
+ });
3917
+ if (typeof rootId !== "string" || rootId.length === 0 || !Array.isArray(agents)) {
3918
+ return unknown("runtime-child-query-unknown");
3919
+ }
3920
+ // 第一遍:只建立可解释的血缘索引;任何一条无法解释的记录都直接保守失败,
3921
+ // 因为无法证明它不属于当前 root 的子树。
3922
+ const index = new Map();
3923
+ for (const node of agents) {
3924
+ if (node === null || typeof node !== "object") return unknown();
3925
+ const id = node.id;
3926
+ if (typeof id !== "string" || id.length === 0) return unknown();
3927
+ // 同一 id 出现两次(同 id 新旧 Agent 并存)无法区分代际,不能猜测。
3928
+ if (index.has(id)) return unknown("runtime-agent-duplicate-id");
3929
+ const header = node.session?.header;
3930
+ if (header === null || typeof header !== "object") return unknown();
3931
+ const parentSession = header.parentSession;
3932
+ const origin = header.origin;
3933
+ // 非 subagent origin 的 runtime Agent(另一个 root、fork/副本会话等)不属于任何专家子树。
3934
+ if (typeof origin === "string" && origin !== "subagent") {
3935
+ index.set(id, { foreign: true });
3936
+ continue;
3937
+ }
3938
+ if (parentSession === void 0) {
3939
+ // 显式声明 subagent 却没有父会话:身份自相矛盾,无法归属。
3940
+ if (origin === "subagent") return unknown();
3941
+ index.set(id, { parentSession: void 0 });
3942
+ continue;
3943
+ }
3944
+ if (typeof parentSession !== "string" || parentSession.length === 0) return unknown();
3945
+ index.set(id, { parentSession, status: node.status, node });
3946
+ }
3947
+
3948
+ // 第二遍:按父链归属。memo 里已知归属的节点不重复回溯;成环或父级缺失都返回未知。
3949
+ const lineage = new Map([[rootId, true]]);
3950
+ const isDescendant = (id, visiting) => {
3951
+ if (lineage.has(id)) return lineage.get(id);
3952
+ if (visiting.has(id)) return void 0;
3953
+ const entry = index.get(id);
3954
+ if (entry === void 0 || entry.foreign === true) return void 0;
3955
+ visiting.add(id);
3956
+ // 父链在另一个顶层会话处终止:该 Agent 属于那个 root,不是当前 root 的后代。
3957
+ const resolved = entry.parentSession === void 0 ? false : isDescendant(entry.parentSession, visiting);
3958
+ if (resolved !== void 0) lineage.set(id, resolved);
3959
+ return resolved;
3960
+ };
3961
+
3962
+ const runningDescendants = [];
3963
+ for (const [id, entry] of index) {
3964
+ // root 自身不是自己的后代;非 subagent 运行时 Agent 已在上一步排除。
3965
+ if (id === rootId || entry.foreign === true) continue;
3966
+ const descendant = isDescendant(id, new Set());
3967
+ if (descendant === false) continue;
3968
+ // 血缘链断裂(父级不在 live 列表里)时无法证明它不在当前子树内,只能 fail closed。
3969
+ if (descendant === void 0) return unknown("runtime-descendant-lineage-unknown");
3970
+ if (typeof entry.status !== "string") return unknown();
3971
+ if (entry.status === "running") {
3972
+ runningDescendants.push(entry.node);
3973
+ continue;
3974
+ }
3975
+ // idle 与 ready 都是"没有正在执行的任务":ready 只是继续在两轮之间等待新指令。
3976
+ if (entry.status === "idle" || entry.status === "ready") continue;
3977
+ // exact 后代却带着未知状态:不能把它误判为空闲。
3978
+ return unknown("runtime-descendant-status-unknown");
3979
+ }
3980
+ return {
3981
+ known: true,
3982
+ blocked: runningDescendants.length > 0,
3983
+ ...(runningDescendants.length > 0 ? { reason: "runtime-running-descendant" } : {}),
3984
+ runningDescendants,
3985
+ };
3986
+ }
3987
+
3988
+ /**
3989
+ * 同步读取 runtime agents.list() 并转换为终止门控可消费的快照。
3990
+ * 查询缺失、抛错或非数组结果均为未知;终止判定必须 fail closed。
3991
+ * @param {unknown} list - 读取 live Agent 的函数(通常是 () => agents.list())。
3992
+ * @param {string} rootId - 当前 exact root id。
3993
+ */
3994
+ export function snapshotRunningRootDescendants(list, rootId) {
3995
+ if (typeof list !== "function") {
3996
+ return { known: false, blocked: true, reason: "runtime-child-query-unknown", runningDescendants: [] };
3997
+ }
3998
+ try {
3999
+ return evaluateRunningRootDescendants(list(), rootId);
4000
+ } catch {
4001
+ return { known: false, blocked: true, reason: "runtime-child-query-unknown", runningDescendants: [] };
4002
+ }
4003
+ }