dsh-expert-group 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/orchestrator.js CHANGED
@@ -184,7 +184,7 @@ export function buildOrchestratorPrompt({ roles = ORCHESTRATOR_ROLES, providerRe
184
184
  "YOU are the current root/parent orchestrator for this conversation. There is no separate outer main agent above you. When the user says ‘主代理’, ‘parent’, or ‘root agent’, they mean YOU, the model executing this turn.",
185
185
  "Your current root session id is " + identitySessionId + ". Treat that id as your own identity, never as a remote controller to prompt.",
186
186
  "Never call session.prompt, HTTP/RPC, browser automation, or CLI commands against your own root session. That is self-messaging, not parent-to-child delegation.",
187
- "Each role-tool call with a different prompt starts its own independent child: a new prompt is never auto-continued to, steered into, or cancelled against an existing plugin-managed child, and the same parent session plus role may hold several children at once. Continue an existing child only by passing that child's explicit childId from an earlier receipt, and cancel one only by passing cancelChildId for that exact child via the public subagents.interrupt API; never infer the target from the parent session and role. An identical normalized prompt reuses its own child and sends nothing. For an explicit childId continuation, queue=true is delivered through the public subagents.sendMessage API; queue=false may first call public subagents.interrupt, then also uses public subagents.sendMessage. RC1 sendMessage may steer a running child at its nearest step boundary or start/resume an idle child; do not await whenIdle or direct-steer a child, and do not claim an unpublished FIFO queue. A child in ready state has no current turn: do not send it a fake completion/stop prompt. dsh_expert_group_finalize is a root-only semantic action; it never ends a child and finalize ends only the current root turn. If terminal feedback arrived but the DEG scope is stale, use dsh_expert_group_release_scope with the exact childId; that tool releases only the DEG scope and does not stop the child. Never use tools.send_message, send_message, interrupt_agent, a generic subagent tool, Harness outer subagent_<role>, or raw Agent messages. Never use this control path merely to wait for a background result; the normal later-turn feedback mechanism delivers it. provider cancellation or teardown can affect delivery.",
187
+ "Each role-tool call with a different prompt starts its own independent child: a new prompt is never auto-continued to, steered into, or cancelled against an existing plugin-managed child, and the same parent session plus role may hold several children at once. Continue an existing child only by passing that child's explicit childId from an earlier receipt, and cancel one only by passing cancelChildId for that exact child via the public subagents.interrupt API; never infer the target from the parent session and role. An identical normalized prompt reuses its own child and sends nothing. For an explicit childId continuation, queue=true is delivered through the public subagents.sendMessage API; queue=false may first call public subagents.interrupt, then also uses public subagents.sendMessage. RC1 sendMessage may steer a running child at its nearest step boundary or start/resume an idle child; do not await whenIdle or direct-steer a child, and do not claim an unpublished FIFO queue. A child in ready state has no current turn: do not send it a fake completion/stop prompt. dsh_expert_group_finalize is a root-only semantic action that validates and registers accepted work, then returns a non-terminal waiting receipt. The root turn continues waiting at the agent/turn-stopping lifecycle boundary; once the exact accepted work settles, the plugin automatically adds one finalization conclusion to the same root run. It never ends a child. After a waiting receipt, do not call finalize again or poll; child settlement feedback and the finalization conclusion arrive automatically. The plugin-internal conclusion delivery is not a model action. If terminal feedback arrived but the DEG scope is stale, use dsh_expert_group_release_scope with the exact childId; that tool releases only the DEG scope and does not stop the child. Never use tools.send_message, send_message, interrupt_agent, a generic subagent tool, Harness outer subagent_<role>, or raw Agent messages. Never use the subagent message/interrupt control path merely to wait for a background result; the normal later-turn feedback mechanism delivers child settlement messages automatically. provider cancellation or teardown can affect delivery.",
188
188
  "</Identity>",
189
189
  ].join("\n");
190
190
 
@@ -217,8 +217,8 @@ export function buildOrchestratorPrompt({ roles = ORCHESTRATOR_ROLES, providerRe
217
217
  "1. Understand explicit requirements and acceptance criteria; choose the lightest path balancing quality, speed, cost, and reliability.",
218
218
  "2. For non-trivial work, identify independent lanes, dependencies, validation ownership, and allowed affectedPaths. Check the role routing before executing and allocate independently verifiable work units; order facts → judgment → implementation as dependencies and dispatch the fact-gathering step first whenever the judgment or implementation depends on it.",
219
219
  "3. Dispatch the intended expert calls first. Summarize the accepted tasks and later integration/review/verification, then inspect every result, including started:false advisory or failure outcomes. Preserve each admissionEpoch and complete admissionToken set.",
220
- "4. Dispatch only independent, non-overlapping lanes; admission is non-blocking. Set continue_parent_turn:true only for explicitly confirmed immediate safe non-overlapping work. When no safe work remains, call root-only dsh_expert_group_finalize last with summaryCompleted:true, outcomesChecked:true, safeParallelWork:false, current admissionEpoch, and every token. Finalize keeps the current root turn open until every expert it accepted has actually finished, then returns; it has no timeout, because an expert may legitimately run for hours. It never stops, resumes, or concludes a child, and it never pauses or resumes the root goal. ask_user_question and exit_plan_mode keep the root turn open on their own; grill-me/to-spec user replies work the same way. Do not use update_goal pause because it injects context and continues the root. A pure continue_parent_turn batch has no accepted admission to finalize and may remain open only for the explicitly safe work it authorized. Do not continue with another tool call, poll, list_agents, or intervene in a child after finalize.",
221
- "5. A dependent task waits for prerequisite feedback: dispatch the prerequisite first, finalize the checked batch so the root turn stays open for it, let DSH deliver its feedback in a later turn, then dispatch the dependent task with a compressed result. Never poll and never intervene in a child; finalize is the only waiting mechanism and it has no timeout.",
220
+ "4. Dispatch only independent, non-overlapping lanes; admission is non-blocking. Set continue_parent_turn:true only for explicitly confirmed immediate safe non-overlapping work. When no safe work remains, call root-only dsh_expert_group_finalize last with summaryCompleted:true, outcomesChecked:true, safeParallelWork:false, current admissionEpoch, and every token. Finalize validates that batch and registers every accepted expert on the root wait ledger, then returns immediately with its non-terminal waiting receipt carrying admissionEpoch and acceptedCount; waiting means the wait is registered, not that any expert finished, it is not an asynchronous task handle, and there is no status to poll. The current root turn then keeps waiting at the agent/turn-stopping lifecycle boundary on that same ledger, with no timeout, because an expert may legitimately run for hours. It never stops, resumes, or concludes a child, and it never pauses or resumes the root goal. ask_user_question and exit_plan_mode keep the root turn open on their own; grill-me/to-spec user replies work the same way. Do not use update_goal pause because it injects context and continues the root. A pure continue_parent_turn batch has no accepted admission to finalize and may remain open only for the explicitly safe work it authorized. After that waiting receipt, do not call finalize again, do not poll for status, and do not continue with another tool call, list_agents, or intervene in a child: the plugin closes the batch and steers one finalization message into your next step automatically once the accepted work is actually still, and that plugin-internal steer at the lifecycle boundary is not a model action.",
221
+ "5. A dependent task waits for prerequisite feedback: dispatch the prerequisite first, finalize the checked batch so the root turn stays open at the turn-stopping boundary for it, let DSH deliver its feedback in a later turn, then dispatch the dependent task with a compressed result. Never poll and never intervene in a child; finalize is the only waiting mechanism you call, it returns waiting as soon as the wait is registered, the root turn lifecycle then waits with no timeout, and the plugin delivers one finalization message into your next step once the batch closes.",
222
222
  "6. Independent tasks own independent children: the same parent session and role may hold several continuable children at once, so each task's run/admission is bound to its own child and none of them is replaced or cancelled by a later task. An identical task (same normalized prompt) reuses its own child and queues no new prompt. A different prompt starts an independent child on the same role tool; it is never auto-continued to, steered into, or cancelled against an existing child. Continue an existing child only by passing that child's explicit childId from an earlier receipt, and cancel only by passing cancelChildId for that exact child; both use the public subagents.sendMessage/interrupt API, and cancellation is never inferred from the parent session and role. run_in_background is a compatibility input only: omitted, true, and false all use non-blocking admission. queue=true applies only to an explicit childId continuation, is delivered by public subagents.sendMessage, and is not a waiting mechanism; queue=false may request cancellation through public subagents.interrupt before that same sendMessage admission. Do not call Agent.whenIdle/Agent.steer directly or claim an unpublished FIFO queue. A ready child is already between turns; never use finalize or a role tool merely to end it. Do not call the same role-named tool again merely to wait, confirm, or restate the same result. Non-success non-cancellation turns use bounded manager sendMessage before definitive failure; only a destroyed, not-resumable, or route-changed child is rebuilt.",
223
223
  "7. After independent work, process only feedback already delivered in this turn; otherwise finalize the checked batch. Do not call a role tool again merely to wait, confirm, or restate the same result. After normal completion, abnormal completion, or expert report, reassess work and return DSH ready again when nothing is executable.",
224
224
  "8. Route only to enabled roles. Read-only findings return for root integration; the root owns the verification strategy and final acceptance while experts may run bounded unit verification. Verify proportionately with project test/build/diagnostic commands. Reuse verification evidence only when it corresponds to the current change and environment and states the command, its result or exit status, and what it covered; re-verify when a later change invalidates it, its coverage is insufficient, or the result is doubtful.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-expert-group",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "DSH 专家子代理、Expert Group 任务侧边栏与设置页(双面 bundle)",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -89,27 +89,27 @@
89
89
  },
90
90
  "peerDependencies": {
91
91
  "@deepseek-ai/cordis": "^4.0.2",
92
- "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
93
- "@deepseek-ai/dsh-attachment": "^0.2.0-rc.1",
94
- "@deepseek-ai/dsh-client-modules": "^0.2.0-rc.1",
95
- "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
96
- "@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.1",
97
- "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.1",
98
- "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
99
- "@deepseek-ai/dsh-client-ui-workspace": "^0.2.0-rc.1",
100
- "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.1",
101
- "@deepseek-ai/dsh-invariants": "^0.2.0-rc.1",
102
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
103
- "@deepseek-ai/dsh-scope": "^0.2.0-rc.1",
104
- "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
105
- "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.1",
106
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
107
- "@deepseek-ai/dsh-storage": "^0.2.0-rc.1",
108
- "@deepseek-ai/dsh-storage-domain": "^0.2.0-rc.1",
109
- "@deepseek-ai/dsh-subagent": "^0.2.0-rc.1",
110
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
111
- "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
112
- "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.1",
92
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.2",
93
+ "@deepseek-ai/dsh-attachment": "^0.2.0-rc.2",
94
+ "@deepseek-ai/dsh-client-modules": "^0.2.0-rc.2",
95
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
96
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
97
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
98
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
99
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.2.0-rc.2",
100
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.2",
101
+ "@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
102
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
103
+ "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
104
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
105
+ "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.2",
106
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
107
+ "@deepseek-ai/dsh-storage": "^0.2.0-rc.2",
108
+ "@deepseek-ai/dsh-storage-domain": "^0.2.0-rc.2",
109
+ "@deepseek-ai/dsh-subagent": "^0.2.0-rc.2",
110
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
111
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
112
+ "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.2",
113
113
  "@deepseek-ai/schemastery": "^3.18.1",
114
114
  "dsh-better-sidebar": "^0.19.1",
115
115
  "react": "^18.2.0",
@@ -136,9 +136,9 @@
136
136
  "license": "MIT",
137
137
  "repository": {
138
138
  "type": "git",
139
- "url": "git+https://github.com/dimensionli95-ship-it/dsh-expert-group.git"
139
+ "url": "git+https://github.com/NTOIOTN/dsh-expert-group.git"
140
140
  },
141
141
  "optionalDependencies": {
142
142
  "sharp": "^0.35.4"
143
143
  }
144
- }
144
+ }
@@ -21,6 +21,8 @@ export const ROLE_TOOL_ADMISSION_CONTRACT = [
21
21
  " 普通 role 语义不是「每个 parent 每个 role 只有一个活动 child」:归一化后完全相同的任务复用**它自己**那个 child(reused,不新建、不投递);不同任务各自新建独立 child,同一个 parent+role 下可以并存多个 child。不同 prompt 绝不会被自动当成旧 child 的续作或 steer,也不会取消旧 child。",
22
22
  " 续作必须显式指定 childId(此前回执里的 subagentId):带 childId 时本轮 prompt 只投递给该 child;不带 childId 时一律按新任务派发。childId 必须是当前 root 该角色已知的专家 child,否则返回 CHILD_NOT_FOUND advisory 而不派发。childId 与 cancelChildId 只要出现就必须是非空且非纯空白的字符串:空串、纯空白或非字符串一律 fail-closed 返回 rejected(code 为 INVALID_CHILD_ID / INVALID_CANCEL_CHILD_ID),既不派发也不新建 child。取消同样必须用 cancelChildId 显式定位单个 child(公开 subagents.interrupt),绝不按 parent+role 猜目标,因此不会误取消同 scope 的其他独立 child;未知或不属于本 root 该角色的目标返回无 admission 凭据的 rejected(CHILD_NOT_FOUND),既不会登记 admission 也不会 interrupt。",
23
23
  "只有设置 continue_parent_turn:true 前检查 affectedPaths 并明确确认任务独立且范围不重叠;否则必须检查每个结果,并使用准确 epoch 和完整 token 集调用 root-only dsh_expert_group_finalize。",
24
+ "对校验通过、且至少含一个 accepted work 的批次,finalize 先登记本批全部已接受工作,再立即返回带 admissionEpoch 与 acceptedCount 的非终态 waiting 回执:waiting 只表示「等待已登记」,不表示专家已完成、批次已收口或已有终态结论,工具调用本身并不等专家结束,因此不会长时间占用 run_code 的执行预算。收到 waiting 后不要再次调用 finalize,也不要轮询或重试——waiting 不是异步 task handle,没有 status 查询接口,重复调用 finalize、反复 list_agents 或自造状态查询都不必要。",
25
+ "等待由插件在 root turn 的 turn-stopping 生命周期边界用同一张 wait 账本继续:root turn 保持打开且没有超时(专家可能运行数小时),直到本批每一个精确关联的专家任务真正结束;随后插件以 DEG 插件来源,向同一 root run 的后续模型 step 投递一次收口结论(含 finalized、admissionEpoch 与 acceptedCount),专家任务的具体结果仍由 DSH 的结算通知提供。该投递是插件内部在生命周期边界上的实现细节,不是模型可调用的操作:模型仍然不要直接调用 Agent.whenIdle/Agent.steer,也不要声称存在未公开的 FIFO 队列。",
24
26
  "role 调用不会结束 root,DSH 会在稍后交付 feedback;queue=true 只在带 childId 的显式续作下生效(经公开的 subagents.sendMessage 投递,只排队不请求取消),不能把它当作等待机制。",
25
27
  "role 工具仅允许 root 使用,child 是叶子 worker。显式续作使用公开的 manager sendMessage;带 childId 且 queue=false 可以先经公开的 interrupt 请求取消再投递同一 sendMessage。RC1 sendMessage 可以在运行 child 的最近边界 steer;不要直接调用 Agent.whenIdle/Agent.steer,也不要声称存在未公开的 FIFO 队列。非成功任务最多自动重试 5 次,之后才判定为最终失败。",
26
28
  ].join("");
@@ -25,7 +25,7 @@
25
25
  * 已删除**,理由与收益:
26
26
  *
27
27
  * 1. **契约**:package.json peerDependencies 下限是 `@deepseek-ai/dsh-client-modules`
28
- * `^0.2.0-rc.1`,README 声明的目标运行时同为 `0.2.0-rc.1`;宿主只加载
28
+ * `^0.2.0-rc.2`,README 声明的目标运行时同为 `0.2.0-rc.2`;宿主只加载
29
29
  * exports["./client"] 指向的装配产物,而该产物恒含本脚本生成的投影片段,且投影排在
30
30
  * 唯一 descriptor 之前执行,因此 `require("dsh-expert-group/client-freshness")`
31
31
  * 必然命中同一张模块表——fallback 在生产路径上不可达。
package/shared.js CHANGED
@@ -113,8 +113,10 @@ export function restrictableDenyNames(denyList, isVisible) {
113
113
  /**
114
114
  * 从父 inbox 注入的 DSH 消息提取专家反馈 child id。
115
115
  *
116
- * 仅接受 subagent-settled/subagent-report,且 senderSessionId 必须是非空字符串;
117
- * 其他 source 或缺失 sender 一律返回 undefined。
116
+ * 结构化通知仍只接受 subagent-settled/subagent-report,且 senderSessionId 必须是非空字符串。
117
+ * 真实宿主在部分路径不会附带 source,只会把固定的自动完成前缀写入多个 text block;
118
+ * 此处只接受该完整固定前缀和 canonical UUID,避免从普通正文、代码块或用户消息猜测 child。
119
+ * 解析只是身份归类,调用方仍必须通过 exact activation、turnEnded 和 cohort 结算保护。
118
120
  *
119
121
  * B(兼容边界):本 helper 只做**身份归类**,不代表该形态可以释放 handoff。core 当前只铸
120
122
  * `subagent-settled`(form=notice) 的 UUID settlement;`subagent-report`(form=relay) 没有
@@ -122,14 +124,28 @@ export function restrictableDenyNames(denyList, isVisible) {
122
124
  * 调用方必须对 relay fail-closed,不得让它进入 markPendingHandoffFeedback / 通配 release
123
125
  * 路径(见 index.js 的 agent/inbox/inserted handler)。
124
126
  * @param {object} [message] - DSH inbox 消息。
127
+ * @param {{allowSourceLess?:boolean}} [options] - 仅 session/event/spliced 可打开无 source 解析。
125
128
  * @returns {string|undefined} 匹配的 child session id。
126
129
  */
127
- export function subagentFeedbackChildId(message) {
130
+ export function subagentFeedbackChildId(message, options = {}) {
128
131
  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;
132
+ if (source?.kind === "subagent-settled" || source?.kind === "subagent-report") {
133
+ return typeof source.senderSessionId === "string" && source.senderSessionId.length > 0
134
+ ? source.senderSessionId
135
+ : void 0;
136
+ }
137
+ // 无 source 的文本形态默认拒绝;只有 root session/event/spliced 显式打开时才解析。
138
+ // 普通 agent/inbox/inserted 不能仅凭正文猜测 child,避免用户文本伪造 settlement。
139
+ if (options?.allowSourceLess !== true
140
+ || source !== void 0 || message === null || typeof message !== "object") return void 0;
141
+ const content = message.content;
142
+ if (!Array.isArray(content)) return void 0;
143
+ const text = content
144
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
145
+ .map((block) => block.text)
146
+ .join("");
147
+ const match = /^Background subagent ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) finished and will do no further work unless you send it more\./i.exec(text);
148
+ return match?.[1];
133
149
  }
134
150
 
135
151
  /**
@@ -341,7 +357,9 @@ export function createHandoffAdmissionCohort() {
341
357
  if (childEntries.some((item) => item.turnGroup !== void 0 && !isValidTurnGroup(item.turnGroup))) return void 0;
342
358
  if (messageId === void 0 && activation !== void 0 && (typeof activation === "object" || typeof activation === "function")) {
343
359
  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));
360
+ && (item.activation === void 0 || item.activation === activation) && item.failed !== true
361
+ && item.turnEnded === true
362
+ && (restrict === void 0 || restrict.has(item.token))).map((item) => item.token));
345
363
  // 零 admission feedback 不建立空 marker;只有真实 token 存在时才更新 snapshot。
346
364
  //
347
365
  // snapshot 用**合并(union)**而不是首次固化:同一 activation 可以先后承载多个
@@ -3236,24 +3254,49 @@ export function createNativeTextRewriter(options = {}) {
3236
3254
  }
3237
3255
 
3238
3256
  /**
3239
- * 递归判断当前 root 的全部后代 runtime Agent 是否仍在运行。
3257
+ * 视为「当前未运行」的 Agent 状态集合。
3258
+ *
3259
+ * 默认 `["idle", "ready"]` 保持 0.1.x 的兼容语义(ready 只是「两轮之间」);DSH rc.2 的
3260
+ * `Agent.status` 只有 `idle` / `running`,因此等待判定传入 `["idle"]`,让 `ready` 落到
3261
+ * 「状态非法」→ `known:false`(fail-closed),绝不被当成空闲。
3262
+ */
3263
+ const DEFAULT_IDLE_STATUSES = Object.freeze(["idle", "ready"]);
3264
+
3265
+ function normalizeIdleStatuses(value) {
3266
+ if (!Array.isArray(value)) return new Set(DEFAULT_IDLE_STATUSES);
3267
+ const statuses = value.filter((item) => typeof item === "string" && item.length > 0);
3268
+ return statuses.length > 0 ? new Set(statuses) : new Set(DEFAULT_IDLE_STATUSES);
3269
+ }
3270
+
3271
+ /**
3272
+ * runtime agents.list() 快照的后代索引:**唯一**的血缘/身份/状态校验实现。
3240
3273
  *
3241
- * 续接对话时插件本地 tracker 可能为空,因此判定只能依赖 runtime Agent 自身的
3242
- * exact 血缘(session.header.parentSession 链)与状态字段:
3243
- * - parentSession 链最终指向 rootId 的 Agent 才算这个 root 的后代;
3244
- * - 后代状态 running 表示仍在运行,idle/ready 表示空闲(DSH 的 ready 只是"两轮之间");
3245
- * - 身份、血缘链或状态无法确认时一律返回 known=false,由上层 fail closed,
3246
- * 绝不能把"查询不到"当成"已经没有专家在跑"。
3274
+ * 续接对话时插件本地 tracker 可能为空,因此判定只能依赖 runtime Agent 自身的 exact
3275
+ * 血缘(`session.header.parentSession` 链)与状态字段:parentSession 链最终指向 rootId
3276
+ * 的 Agent 才算这个 root 的后代;root 自身、其他 root 与非 subagent 运行时 Agent 都不计入。
3247
3277
  *
3248
- * root 自身、其他 root 以及非 subagent 运行时 Agent 都不计入。
3278
+ * 校验规则(任一不成立都返回 known:false,绝不猜测):
3279
+ * - 列表不是数组、条目不是对象、id 缺失或**重复**、header 不是非数组 object;
3280
+ * - `origin` 存在但不是 string(`origin: null` 等既非 subagent 也非已确认 foreign);
3281
+ * - **当前 root 自己**出现在快照里却声称有父会话(root -> child -> root 环)或父字段无效;
3282
+ * - 显式声明 subagent 却没有父会话、父会话字段非法、父链成环或链上父级不在 live 列表;
3283
+ * - 状态不是字符串,或既不是 running 也不在 idleStatuses 里(rc.2 严格模式下 ready 即非法)。
3284
+ *
3285
+ * rootId 优先级:当前 exact root 永远先判为「本树」,即使它自身显式声明非 subagent 的
3286
+ * origin(例如 `"user"`)也不会被 foreign 分类排除,它的正常 subagent child / 孙辈仍算后代;
3287
+ * 其他已确认 foreign 的顶层会话能证明其整支不属于当前 root(false),但真正缺父、非 root 环、
3288
+ * 重复 id、非法状态或 subagent 无父会话仍然 fail-closed。空列表是可信的空状态。
3249
3289
  *
3250
3290
  * @param {unknown} agents - agents.list() 返回的 live Agent 数组。
3251
3291
  * @param {string} rootId - 当前 exact root id。
3252
- * @returns {{known: boolean, blocked: boolean, reason?: string, runningDescendants: object[]}}
3292
+ * @param {{idleStatuses?:string[]}} [options] 视为「当前未运行」的状态集合。
3293
+ * @returns {{known:boolean, reason?:string, descendants:Map<string,object>}} 后代索引
3294
+ * (id → { parentSession, status, node })。
3253
3295
  */
3254
- export function evaluateRunningRootDescendants(agents, rootId) {
3296
+ export function collectRuntimeRootDescendants(agents, rootId, options = {}) {
3297
+ const idleStatuses = normalizeIdleStatuses(options?.idleStatuses);
3255
3298
  const unknown = (reason = "runtime-descendant-identity-unknown") => ({
3256
- known: false, blocked: true, reason, runningDescendants: [],
3299
+ known: false, reason, descendants: new Map(),
3257
3300
  });
3258
3301
  if (typeof rootId !== "string" || rootId.length === 0 || !Array.isArray(agents)) {
3259
3302
  return unknown("runtime-child-query-unknown");
@@ -3268,9 +3311,24 @@ export function evaluateRunningRootDescendants(agents, rootId) {
3268
3311
  // 同一 id 出现两次(同 id 新旧 Agent 并存)无法区分代际,不能猜测。
3269
3312
  if (index.has(id)) return unknown("runtime-agent-duplicate-id");
3270
3313
  const header = node.session?.header;
3271
- if (header === null || typeof header !== "object") return unknown();
3314
+ // header 必须是非 null、非数组的 object:数组 / 字符串等无法解释,绝不能当成
3315
+ // 「没有父会话的顶层会话」而放行。
3316
+ if (header === null || typeof header !== "object" || Array.isArray(header)) return unknown();
3272
3317
  const parentSession = header.parentSession;
3273
3318
  const origin = header.origin;
3319
+ // origin 若存在必须是 string:`origin: null` / 数字等既不是 subagent 也不是已确认
3320
+ // foreign,归属无法解释 ⇒ 未知。`origin === undefined` 保持兼容(历史 host 不写该字段)。
3321
+ if (origin !== void 0 && typeof origin !== "string") {
3322
+ return unknown("runtime-agent-origin-invalid");
3323
+ }
3324
+ // 当前 root 自己出现在快照里时**必须**符合 DEG root 身份:parentSession 必须缺省。
3325
+ // 否则 root -> child -> root 这类环会被第二遍的 rootId 捷径掩盖(rootId 只是 memo 的
3326
+ // 初值,不能代替 header 校验)。任何字符串 parentSession 或无效父字段都不可解释。
3327
+ if (id === rootId) {
3328
+ if (parentSession !== void 0) return unknown("runtime-root-parent-conflict");
3329
+ index.set(id, { root: true });
3330
+ continue;
3331
+ }
3274
3332
  // 非 subagent origin 的 runtime Agent(另一个 root、fork/副本会话等)不属于任何专家子树。
3275
3333
  if (typeof origin === "string" && origin !== "subagent") {
3276
3334
  index.set(id, { foreign: true });
@@ -3288,11 +3346,21 @@ export function evaluateRunningRootDescendants(agents, rootId) {
3288
3346
 
3289
3347
  // 第二遍:按父链归属。memo 里已知归属的节点不重复回溯;成环或父级缺失都返回未知。
3290
3348
  const lineage = new Map([[rootId, true]]);
3349
+ // 已确认 foreign 的 runtime Agent(非 subagent origin,例如另一个 root 的 `"user"` 会话)
3350
+ // **先验地**不属于当前 root:父链在它那里终止时,整支都能被证明「不是当前 root 的后代」
3351
+ // (false),而不是未知——否则另一个 root 的专家子树会让当前 root 永久 fail-closed。
3352
+ // 顺序保护:rootId 自己先入 memo(lineage 初始化即含 true),因此即使当前 root 自身带
3353
+ // 非 subagent origin,也永远优先判为「本树」。
3354
+ for (const [id, entry] of index) {
3355
+ if (entry.foreign === true && id !== rootId) lineage.set(id, false);
3356
+ }
3291
3357
  const isDescendant = (id, visiting) => {
3292
3358
  if (lineage.has(id)) return lineage.get(id);
3293
3359
  if (visiting.has(id)) return void 0;
3294
3360
  const entry = index.get(id);
3295
- if (entry === void 0 || entry.foreign === true) return void 0;
3361
+ // 未知 id / 未登记的 foreign 条目:前者仍然未知(绝不猜测),后者只可能是「非本树」。
3362
+ if (entry === void 0) return void 0;
3363
+ if (entry.foreign === true) return false;
3296
3364
  visiting.add(id);
3297
3365
  // 父链在另一个顶层会话处终止:该 Agent 属于那个 root,不是当前 root 的后代。
3298
3366
  const resolved = entry.parentSession === void 0 ? false : isDescendant(entry.parentSession, visiting);
@@ -3300,7 +3368,7 @@ export function evaluateRunningRootDescendants(agents, rootId) {
3300
3368
  return resolved;
3301
3369
  };
3302
3370
 
3303
- const runningDescendants = [];
3371
+ const descendants = new Map();
3304
3372
  for (const [id, entry] of index) {
3305
3373
  // root 自身不是自己的后代;非 subagent 运行时 Agent 已在上一步排除。
3306
3374
  if (id === rootId || entry.foreign === true) continue;
@@ -3309,14 +3377,37 @@ export function evaluateRunningRootDescendants(agents, rootId) {
3309
3377
  // 血缘链断裂(父级不在 live 列表里)时无法证明它不在当前子树内,只能 fail closed。
3310
3378
  if (descendant === void 0) return unknown("runtime-descendant-lineage-unknown");
3311
3379
  if (typeof entry.status !== "string") return unknown();
3312
- if (entry.status === "running") {
3313
- runningDescendants.push(entry.node);
3314
- continue;
3380
+ // running 或「明确未运行」之外的任何状态(含 rc.2 里不存在的 ready)都不能当成空闲。
3381
+ if (entry.status !== "running" && !idleStatuses.has(entry.status)) {
3382
+ return unknown("runtime-descendant-status-unknown");
3315
3383
  }
3316
- // idle 与 ready 都是"没有正在执行的任务":ready 只是继续在两轮之间等待新指令。
3317
- if (entry.status === "idle" || entry.status === "ready") continue;
3318
- // exact 后代却带着未知状态:不能把它误判为空闲。
3319
- return unknown("runtime-descendant-status-unknown");
3384
+ descendants.set(id, entry);
3385
+ }
3386
+ return { known: true, descendants };
3387
+ }
3388
+
3389
+ /**
3390
+ * 递归判断当前 root 的全部后代 runtime Agent 是否仍在运行(通知/终止门控使用)。
3391
+ *
3392
+ * 复用 collectRuntimeRootDescendants 的同一份校验;返回形状保持既有契约不变。
3393
+ *
3394
+ * @param {unknown} agents - agents.list() 返回的 live Agent 数组。
3395
+ * @param {string} rootId - 当前 exact root id。
3396
+ * @param {{idleStatuses?:string[]}} [options] 视为「当前未运行」的状态集合。
3397
+ */
3398
+ export function evaluateRunningRootDescendants(agents, rootId, options = {}) {
3399
+ const built = collectRuntimeRootDescendants(agents, rootId, options);
3400
+ if (built.known !== true) {
3401
+ return {
3402
+ known: false,
3403
+ blocked: true,
3404
+ reason: built.reason ?? "runtime-descendant-identity-unknown",
3405
+ runningDescendants: [],
3406
+ };
3407
+ }
3408
+ const runningDescendants = [];
3409
+ for (const entry of built.descendants.values()) {
3410
+ if (entry.status === "running") runningDescendants.push(entry.node);
3320
3411
  }
3321
3412
  return {
3322
3413
  known: true,
@@ -3332,12 +3423,12 @@ export function evaluateRunningRootDescendants(agents, rootId) {
3332
3423
  * @param {unknown} list - 读取 live Agent 的函数(通常是 () => agents.list())。
3333
3424
  * @param {string} rootId - 当前 exact root id。
3334
3425
  */
3335
- export function snapshotRunningRootDescendants(list, rootId) {
3426
+ export function snapshotRunningRootDescendants(list, rootId, options = {}) {
3336
3427
  if (typeof list !== "function") {
3337
3428
  return { known: false, blocked: true, reason: "runtime-child-query-unknown", runningDescendants: [] };
3338
3429
  }
3339
3430
  try {
3340
- return evaluateRunningRootDescendants(list(), rootId);
3431
+ return evaluateRunningRootDescendants(list(), rootId, options);
3341
3432
  } catch {
3342
3433
  return { known: false, blocked: true, reason: "runtime-child-query-unknown", runningDescendants: [] };
3343
3434
  }