@zhushanwen/pi-subagent-workflow 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +12 -8
  2. package/skills/workflow-script-format/SKILL.md +1 -1
  3. package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
  4. package/src/execution/__tests__/channel-registry-handshake.test.ts +1 -1
  5. package/src/execution/__tests__/dialog-queue.test.ts +1 -1
  6. package/src/execution/__tests__/host-mode.test.ts +1 -1
  7. package/src/execution/__tests__/notifier-flush.test.ts +193 -7
  8. package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
  9. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +1 -1
  10. package/src/execution/__tests__/status-refactor.test.ts +5 -5
  11. package/src/execution/__tests__/ui-channels.test.ts +1 -1
  12. package/src/execution/__tests__/ui-interaction-model.test.ts +1 -1
  13. package/src/execution/__tests__/ui-request-observability.test.ts +1 -1
  14. package/src/execution/notifier.ts +182 -222
  15. package/src/execution/session-runner.ts +1 -1
  16. package/src/execution/subagent-service.ts +11 -4
  17. package/src/index.ts +3 -0
  18. package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
  19. package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
  20. package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
  21. package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
  22. package/src/injectors/model-list-injector.ts +138 -0
  23. package/src/injectors/subagent-list-injector.ts +33 -24
  24. package/src/injectors/workflow-list-injector.ts +16 -25
  25. package/src/orchestration/__tests__/workflows-e2e.test.ts +2 -2
  26. package/src/orchestration/config-loader.ts +5 -1
  27. package/src/orchestration/models/types.ts +1 -1
  28. package/src/shared/__tests__/resource-discovery.test.ts +79 -0
  29. package/src/shared/resource-discovery.ts +38 -0
  30. package/src/shared/xml-injection.ts +35 -0
  31. package/workflows/review-fix-loop-utils.cjs +1 -1
@@ -15,8 +15,8 @@
15
15
  import { describe, expect, it } from "vitest";
16
16
 
17
17
  import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
18
- import { BgNotifier } from "../notifier.ts";
19
- import type { BgNotifyRecord, NotifierHost } from "../notifier.ts";
18
+ import { createNotifier } from "../notifier.ts";
19
+ import type { BgNotifyRecord, BgNotifier, NotifierHost } from "../notifier.ts";
20
20
  import { mapExternalState } from "../../interface/subagent-actions.ts";
21
21
  import { statusGlyph } from "../../interface/format.ts";
22
22
  import type { ClosedReason, ExecutionRecord, ExecutionStatus } from "../types.ts";
@@ -205,7 +205,7 @@ describe("BgNotifier dedupKey", () => {
205
205
 
206
206
  it("同 id 不同 round 不被去重(round 参与 dedup key)", () => {
207
207
  const host = createMockHost(false);
208
- const notifier = new BgNotifier(host);
208
+ const notifier = createNotifier(host);
209
209
  const sent: unknown[] = [];
210
210
  // 重写 sendMessage 捕获
211
211
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
@@ -238,7 +238,7 @@ describe("BgNotifier dedupKey", () => {
238
238
 
239
239
  it("不同 id 不被去重", () => {
240
240
  const host = createMockHost(false);
241
- const notifier = new BgNotifier(host);
241
+ const notifier = createNotifier(host);
242
242
  const sent: unknown[] = [];
243
243
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
244
244
 
@@ -267,7 +267,7 @@ describe("BgNotifier dedupKey", () => {
267
267
 
268
268
  it("closed status 被正确入队", () => {
269
269
  const host = createMockHost(false);
270
- const notifier = new BgNotifier(host);
270
+ const notifier = createNotifier(host);
271
271
  const sent: unknown[] = [];
272
272
  (host as { sendMessage: NotifierHost["sendMessage"] }).sendMessage = (msg) => { sent.push(msg); };
273
273
 
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // W1 红灯测试:ui-channels.ts — channel 提取(marker 解析)+ channel 注册表。
4
4
  //
5
- // 测试对象:extensions/subagent-workflow/src/execution/ui-channels.ts(新建)
5
+ // 测试对象:extensions/universal/subagent-workflow/src/execution/ui-channels.ts(新建)
6
6
  // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 2:channel 注册表」
7
7
  //
8
8
  // parseChannel(req): ExtensionUiRequest → { channel?, channelPayload? }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // W1 红灯测试:ui-interaction-model.ts — method 交互模型分类。
4
4
  //
5
- // 测试对象:extensions/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
5
+ // 测试对象:extensions/universal/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
6
6
  // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 1:透传判定规则」
7
7
  //
8
8
  // isDialogMethod(method):
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // M4 测试:ui-request-observability.ts — UiRequestObservability 纯逻辑类。
4
4
  //
5
- // 测试对象:extensions/subagent-workflow/src/execution/ui-request-observability.ts
5
+ // 测试对象:extensions/universal/subagent-workflow/src/execution/ui-request-observability.ts
6
6
  // 契约来源:类注释(per-session 去重 + resetMissingHandlerWarnings 清洗 + setMode/getMode 往返)
7
7
  //
8
8
  // UiRequestObservability 职责:
@@ -2,11 +2,15 @@
2
2
  //
3
3
  // Background 完成回注主对话。sync 不用(调用方还在 await,结果直接返回)。
4
4
  //
5
- // 职责:
6
- // - 合并窗口:MERGE_WINDOW_MS 内多个完成合并为一条通知
7
- // - 去重 TTL:同 id 在 TTL 内不重复通知
8
- // - 通过 pi.sendMessage({ deliverAs:"followUp", triggerTurn:true }) 注入——
9
- // 当前 turn 结束后唤醒父 agent 处理结果(followUp 不打断 streaming、不锁滚动)
5
+ // 职责(迁移后,U3):
6
+ // - buildLlmContent:格式化通知文案(本文件唯一逻辑职责)
7
+ // - createNotifier:薄工厂,装配 @xyz-agent/session-delivery 内核
8
+ // - 合并窗口 / 去重 / 退避 / flush 全部委托内核
9
+ //
10
+ // 投递通道:内核 → pi.sendMessage({ deliverAs:"steer", triggerTurn:true }) 注入——
11
+ // 当前 turn 结束后唤醒父 agent 处理结果(steer 不打断 streaming、不锁滚动)
12
+
13
+ import { createDelivery, type DeliveryHandle, type DeliveryPort } from "@xyz-agent/session-delivery";
10
14
 
11
15
  /**
12
16
  * 一条待发送的完成通知记录。
@@ -50,251 +54,207 @@ export interface BgNotifyRecord {
50
54
  totalRounds?: number;
51
55
  }
52
56
 
53
- /** notifier 依赖的 pi 最小接口(解耦,便于测试)。 */
57
+ /** notifier 依赖的宿主最小接口(解耦,便于测试)。
58
+ * 迁移后:仅用于构造 DeliveryPort 的底层依赖。 */
54
59
  export interface NotifierHost {
55
- /** 注入消息到主对话。
56
- * triggerTurn:true + deliverAs:"steer" → 空闲时立即 prompt 新 turn;streaming 时
57
- * 进 steer 队列等下个 turn 边界 drain。
58
- *
59
- * ⚠️ 竞态注意:triggerTurn 分支只在主 agent isStreaming===false 时生效。若调用时
60
- * 主 agent 处于 agent_end → finishRun 的窄窗口(isStreaming 仍 true),消息会被
61
- * 错误走 steer 分支入队,而 runLoop 已结束无人 drain → 通知静默丢失。flushPendingNotifications
62
- * 通过 isIdle() 退避保证在 idle 后同步送达,规避此窗口。 */
60
+ /** 注入消息到主对话。 */
63
61
  sendMessage(
64
62
  message: { customType: string; content: string; display: boolean; details?: unknown },
65
63
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
66
64
  ): void;
67
65
  /** 是否还有 running 的 background 任务(用于滑动窗口立即 flush 判断)。 */
68
66
  hasRunningBackground(): boolean;
69
- /** 主 agent 是否空闲(非 streaming)。flush gate 用——避免在 agent_end→finishRun
70
- * 竞态窗口里 sendMessage 走错分支(steer 入队无人 drain)。
71
- * 可选:未注入(旧测试 host)时 flush 不 gate,保持原行为。 */
67
+ /** 主 agent 是否空闲(非 streaming)。可选:未注入时 flush gate */
72
68
  isIdle?: () => boolean;
69
+ /**
70
+ * 原生 settled 事件订阅能力(D8):注册 handler 监听主 agent settled 边沿。
71
+ * 无退订语义(pi 0.84.1 的 `pi.on(...)` 全部重载返回 void、无 off——实装锚点:
72
+ * node_modules `@earendil-works/pi-coding-agent` dist/core/extensions/types.d.ts
73
+ * `on()` 系列重载,0.84.1 实测)——退订由本模块的 disposed
74
+ * 标志包装兑现(见下方 port 装配)。可选:未注入时内核退化退避轮询(busy 消息
75
+ * 靠退避达上限强发,不走 settled 边沿驱动)。
76
+ */
77
+ onAgentSettled?(handler: () => void): void;
73
78
  }
74
79
 
75
- /** 合并窗口(ms)。窗口内多个完成合并为一条消息。 */
76
- const MERGE_WINDOW_MS = 60_000;
77
- /** 去重 TTL(ms)。同 id 在此窗口内不重复通知。 */
78
- const DEDUP_TTL_MS = 60_000;
79
- /** [竞态修复] flush 时若主 agent 仍 streaming,短退避重试间隔(ms)。
80
- * 场景:subagent 完成的 detached microtask 与主 agent agent_end→finishRun 竞态,
81
- * isIdle()=false 时退避,等 idle 后再 sendMessage(triggerTurn),避免走 steer 分支丢失。 */
82
- const FLUSH_BACKOFF_MS = 100;
83
- /** [竞态修复] flush 退避上限次数。防止主 agent 永久 busy 时无限重试——
84
- * 达上限后强制发送(fallthrough 到 pi 的 steer/triggerTurn 分支,至少不丢消息)。 */
85
- const FLUSH_BACKOFF_MAX = 50; // 50 × 100ms = 5s
86
-
87
80
  /** 发送给主对话的 customType(bg-notify-render 消费)。 */
88
81
  const NOTIFY_CUSTOM_TYPE = "subagent-bg-notify";
89
82
 
90
83
  /**
91
- * Background 完成通知器(滑动窗口合并)。
92
- *
93
- * notify(record):
94
- * 1. dedup TTL 检查:同 id 在 TTL 内 → 跳过
95
- * 2. 入 pending 队列
96
- * 3. 清除旧 timer(滑动窗口重置)
97
- * 4. 已无 running background → 立即 flush(最后一批)
98
- * 5. 否则重启 MERGE_WINDOW_MS timer(等后续完成合并)
84
+ * BgNotifyRecord 格式化为 LLM 可读的 notification content。
99
85
  *
100
- * flushPendingNotifications(): timer 到期 / running / shutdown 触发
101
- * 1. isIdle gate:主 agent 仍 streaming → 退避重试(scheduleFlush),等 idle 后再发
102
- * 2. doSend:取出 pending 全部 record
103
- * 3. 合并为一条消息(多条时列 bullet list)
104
- * 4. sendMessage({ customType:"subagent-bg-notify",
105
- * content, display:true,
106
- * triggerTurn:true, deliverAs:"steer" })
107
- * 5. 清空 pending + timer
108
- *
109
- * 滑动窗口:每次有新完成都重置 60s 计时器,密集完成的任务尽量合并到一条通知。
110
- * 无 running 时立即 flush——避免最后一条等满窗口。
86
+ * 模块内唯一消费方是下方 createNotifier notify()(预格式化后传 delivery.send,
87
+ * 内核只做拼接——多条以 "\n\n---\n\n" join)。
111
88
  */
112
- export class BgNotifier {
113
- private readonly pending: BgNotifyRecord[] = [];
114
- /** dedup:id 上次通知时间戳。 */
115
- private readonly dedup = new Map<string, number>();
116
- private timer: ReturnType<typeof setTimeout> | undefined;
117
- private _disposed = false;
118
- /** flush 重试计数(isIdle gate 退避用)。每次成功发送后清零。 */
119
- private flushAttempts = 0;
120
-
121
- constructor(private readonly host: NotifierHost) {}
122
-
123
- /**
124
- * 入队一条完成通知(去重 + 滑动窗口合并)。dispose 后短路。
125
- */
126
- notify(record: BgNotifyRecord): void {
127
- if (this._disposed) return;
128
-
129
- const now = Date.now();
130
- // sweep 过期 dedup 条目(防 Map 无限增长)
131
- if (this.dedup.size > 0) {
132
- for (const [id, ts] of this.dedup) {
133
- if (now - ts >= DEDUP_TTL_MS) this.dedup.delete(id);
89
+ function buildLlmContent(record: BgNotifyRecord): string {
90
+ const agent = record.agent;
91
+ const id = record.id;
92
+ // [wave2 指针行] 增量语义下轮次通知只携带本轮增量,异步 flush 窗口丢失时不可重发
93
+ //(见 subagent-service.ts onRoundSettled 注释),恢复通道是 session 文件全文。
94
+ // chatMode 透传 sessionFile,one-shot 通知不含该行;cancelled/gc-failed 不追加
95
+ //(无成功结果可读,指针无意义);缺失时省略整行(追加空串 = 输出逐字节不变)。
96
+ const transcriptPointer = record.sessionFile
97
+ ? `\n\nFull transcript: ${record.sessionFile}`
98
+ : "";
99
+ switch (record.status) {
100
+ case "closed": {
101
+ // v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生通知文案。
102
+ const reason = record.closedReason ?? "gc";
103
+ if (reason === "cancelled") {
104
+ return `Subagent "${agent}" (${id}) cancelled.`;
134
105
  }
106
+ // 失败场景(gc + 有 error):展示错误。判定必须先于 patchFile——失败轮也会写
107
+ // patchFile(doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch),若
108
+ // patch 分支在前,gc 失败 + worktree 并存时 LLM 被告知 completed。顺序与三处
109
+ // 同构契约的另外两处一致(shared/subagent.ts deriveClosedDisplay、
110
+ // bg-notify-render.ts renderRecordLines:cancelled → gc+error → patch/result)。
111
+ if (record.error && reason === "gc") {
112
+ return `Subagent "${agent}" (${id}) failed: ${record.error}`;
113
+ }
114
+ // 成功完成(user-close)或通用结束(gc/parent-shutdown 等):展示结果。
115
+ // [C-2] chatMode close 终态通知附轮次统计(设计 D2 路径①"completed after N rounds")。
116
+ // totalRounds 仅 close 语义携带(notifyClosed);one-shot 完成通知不设置(round 无轮次
117
+ // 语义),文案保持 "completed. Result:" 逐字节(G4 硬约束,one-shot 字节锁测试锚定)。
118
+ const roundsSuffix =
119
+ record.totalRounds != null && record.totalRounds > 0
120
+ ? ` after ${record.totalRounds} round${record.totalRounds === 1 ? "" : "s"}`
121
+ : "";
122
+ const base = `Subagent "${agent}" (${id}) completed${roundsSuffix}. Result:\n${record.result ?? "(empty)"}`;
123
+ if (record.patchFile) {
124
+ // [wave2 review] 长 return 拆行:模板串内不可直接换行(会改变输出内容),提取 patchHint 中转变量
125
+ const patchHint = `\n\nThis subagent ran in an isolated worktree; its file changes were captured as a patch:\n ${record.patchFile}\nTo bring these changes into the current repo, run: \`git apply ${record.patchFile}\``;
126
+ return `${base}${patchHint}${transcriptPointer}`;
127
+ }
128
+ return `${base}${transcriptPointer}`;
135
129
  }
136
- // dedup key:idle(对话模式每轮完成)按 id:round 去重——不同轮次不互相掩蔽;
137
- // idle(closed/cancelled)round 恒定 undefined,key 同旧行为不变。
138
- const dedupKey = record.round != null ? `${record.id}:${record.round}` : record.id;
139
- const lastSeen = this.dedup.get(dedupKey);
140
- if (lastSeen !== undefined && now - lastSeen < DEDUP_TTL_MS) return;
141
- this.dedup.set(dedupKey, now);
142
-
143
- this.pending.push(record);
144
-
145
- if (this.timer !== undefined) {
146
- clearTimeout(this.timer);
147
- this.timer = undefined;
148
- }
149
-
150
- if (!this.host.hasRunningBackground()) {
151
- this.flushPendingNotifications();
152
- return;
153
- }
154
-
155
- this.timer = setTimeout(() => this.flushPendingNotifications(), MERGE_WINDOW_MS);
130
+ case "running":
131
+ // v4 B-1: 对话模式轮次完成(旧 idle 折入 running):携带本轮结果送回主 agent,等待下一轮 message。
132
+ return `Subagent "${agent}" (${id}) finished a round. Reply:\n${record.result ?? "(empty)"}${transcriptPointer}`;
156
133
  }
134
+ }
157
135
 
158
- /** 立即 flush(session_shutdown 调用,防丢失)。
159
- * 外部入口(notify 立即触发 / shutdown)。内部实际发送在 doSend 中,
160
- * 外部入口不传 attempt,走 isIdle gate 退避逻辑。 */
161
- flushPendingNotifications(): void {
162
- this.scheduleFlush(0);
163
- }
136
+ /**
137
+ * Background 完成通知器接口(迁移后由内核实现)。
138
+ *
139
+ * 保持与旧 BgNotifier 类相同的公共 API,避免消费方(subagent-service)改动面过大。
140
+ */
141
+ export interface BgNotifier {
142
+ /** 入队一条完成通知(去重 + 合批窗口合并)。dispose 后短路。 */
143
+ notify(record: BgNotifyRecord): void;
144
+ /** 立即 flush(session_shutdown 调用,防丢失)。 */
145
+ flushPendingNotifications(): void;
146
+ /** session 结束:清队列,dispose 内核 handle。 */
147
+ dispose(): void;
148
+ /** /resume /fork /new 后复活。 */
149
+ revive(): void;
150
+ }
164
151
 
165
- /**
166
- * [竞态修复] 调度 flush:isIdle gate + 退避重试。
167
- *
168
- * 核心问题:sendMessage({triggerTurn:true}) 只在主 agent isStreaming===false
169
- * 启动新 turn。若 flush agent_end finishRun 的窄窗口触发(isStreaming 仍 true),
170
- * 消息走 steer 分支入队,runLoop 已结束无人 drain → 通知丢失(非必现,时序竞态)。
171
- *
172
- * 修复:isIdle() 可用时,busy 退避到 idle 后再发送。isIdle() 与 sendMessage 同步链
173
- * (均读 agent.state.isStreaming,host.sendMessage 不 await),故一旦 isIdle=true,
174
- * 同步调 sendMessage 必走 triggerTurn 分支。isIdle 未注入(旧 host)时不 gate,原行为。
175
- *
176
- * 退避上限:FLUSH_BACKOFF_MAX 次后强制发送——防主 agent 永久 busy(长 turn)时通知饿死,
177
- * fallthrough 到 pi 的 steer/triggerTurn 分支,至少不丢消息(busy 时走 steer 会在该
178
- * turn 结束后由 _handlePostAgentRun drain)。
179
- */
180
- private scheduleFlush(attempt: number): void {
181
- if (this._disposed) return;
182
- if (this.pending.length === 0) return;
152
+ /**
153
+ * 创建 Background 完成通知器(薄工厂,装配 @xyz-agent/session-delivery 内核)。
154
+ *
155
+ * 内核接管:gate(isIdle 退避)/ 合批窗口(滑动 60s)/ dedup(按 id:round)/ flush / shutdown flush。
156
+ * 本函数职责:将 BgNotifyRecord DeliveryMessage 映射 + buildLlmContent 预格式化。
157
+ *
158
+ * @param host 宿主能力注入(pi.sendMessage + hasRunningBackground + isIdle)
159
+ * @returns BgNotifier 接口(与旧类同形)
160
+ */
161
+ export function createNotifier(host: NotifierHost): BgNotifier {
162
+ let disposed = false;
183
163
 
184
- // isIdle gate:注入了 isIdle 且当前 busy → 退避重试(未达上限)
185
- if (this.host.isIdle) {
186
- let idle = true;
187
- try {
188
- idle = this.host.isIdle();
189
- } catch {
190
- // isIdle 内部 assertActive 可能抛(session 已关闭)——视为不可发送,丢弃。
191
- // dispose 后本函数首行已短路,此处 catch 兜底极端时序。
192
- this.pending.length = 0;
193
- this.flushAttempts = 0;
194
- return;
195
- }
196
- if (!idle && attempt < FLUSH_BACKOFF_MAX) {
197
- this.timer = setTimeout(() => this.scheduleFlush(attempt + 1), FLUSH_BACKOFF_MS);
198
- return;
164
+ // 构造 DeliveryPort:intent pi 参数翻译在适配器内(D3)
165
+ const port: DeliveryPort = {
166
+ supportedPayloads: ["custom"],
167
+ isIdle: () => {
168
+ if (host.isIdle) {
169
+ return host.isIdle();
199
170
  }
200
- // idle 或达上限继续发送
201
- }
171
+ // 未注入 isIdle视为 idle(不 gate,向后兼容旧 host)
172
+ return true;
173
+ },
174
+ hasPendingMessages: () => false, // notifier 不关心 hasPendingMessages
175
+ // D8(must-fix #4):settled 边沿驱动装配——内核 busy 入队后由 settled 事件唤醒
176
+ // flush(watch-dog 兜底事件丢失),替代无订阅时的退避轮询。host 只注入原生订阅
177
+ // 能力;disposed 标志包装(兑现退订语义——pi.on 返回 void 且无 off)在此完成。
178
+ subscribeSettled:
179
+ host.onAgentSettled === undefined
180
+ ? undefined
181
+ : (cb) => {
182
+ let disposed = false;
183
+ host.onAgentSettled?.(() => {
184
+ if (!disposed) cb();
185
+ });
186
+ return () => {
187
+ disposed = true;
188
+ };
189
+ },
190
+ send: (msg, intent) => {
191
+ host.sendMessage(
192
+ {
193
+ customType: NOTIFY_CUSTOM_TYPE,
194
+ content: msg.payload.content,
195
+ display: true,
196
+ details: msg.payload.kind === "custom" ? msg.payload.details : undefined,
197
+ },
198
+ intent === "interrupt-at-turn-boundary"
199
+ ? { triggerTurn: true, deliverAs: "steer" }
200
+ : { triggerTurn: true, deliverAs: "followUp" },
201
+ );
202
+ },
203
+ };
202
204
 
203
- this.doSend();
204
- }
205
+ // #5(must-fix):内核 handle 的 disposed 不可逆——dispose 后 revive 必须重建 handle,
206
+ // 否则 revive 后所有 notify() 被内核静默吞(外层标志复位救不回已销毁的内核)。
207
+ // revive = 新生命周期:合批窗口 / 在途批次 / dedup LRU 随重建自然复位(可接受)。
208
+ const createHandle = (): DeliveryHandle =>
209
+ createDelivery(port, {
210
+ intent: "interrupt-at-turn-boundary", // D3:turn 边界抢占(F1 教训内化)
211
+ mergeWindowMs: 60_000, // 滑动窗口合批(继承 MERGE_WINDOW_MS=60s)
212
+ mergeHoldActive: () => host.hasRunningBackground(), // D4 must-fix #1:禁止用 isIdle 代替
213
+ busyPolicy: "retry-force", // settled 边沿驱动 + 退避达上限强发
214
+ backoff: { ms: 100, max: 50 }, // 继承 FLUSH_BACKOFF_MS/MAX
215
+ // dedup LRU:语义与旧 DEDUP_TTL_MS=60s **不同**——按 key 永久去重(仅 LRU 逐出后
216
+ // 同 key 可再入)。当前 key 空间(id / id:round,id 每 spawn 唯一)无实际差异;
217
+ // 后续复用方勿按「60s 内不重复」假设接入(同 key 通知会被永久吞)。
218
+ dedupe: { maxKeys: 1000 },
219
+ });
220
+ let handle: DeliveryHandle = createHandle();
205
221
 
206
- /** 实际发送(取出 pending + 合并 + sendMessage)。清 timer + 重试计数。 */
207
- private doSend(): void {
208
- if (this.timer !== undefined) {
209
- clearTimeout(this.timer);
210
- this.timer = undefined;
211
- }
212
- if (this.pending.length === 0) return;
213
- this.flushAttempts = 0;
222
+ return {
223
+ notify(record: BgNotifyRecord): void {
224
+ if (disposed) return;
214
225
 
215
- const records = this.pending.splice(0);
216
- const content = records.length === 1
217
- ? this.buildLlmContent(records[0])
218
- : records.map((r) => this.buildLlmContent(r)).join("\n\n---\n\n");
219
- const details = records.length === 1
220
- ? records[0]
221
- : { batch: true, items: records };
226
+ // dedup key:idle(对话模式每轮完成)按 id:round 去重——不同轮次不互相掩蔽;
227
+ // idle(closed/cancelled)round 恒定 undefined,key 同旧行为不变。
228
+ const dedupeKey = record.round != null ? `${record.id}:${record.round}` : record.id;
222
229
 
223
- this.host.sendMessage({
224
- customType: NOTIFY_CUSTOM_TYPE,
225
- content,
226
- display: true,
227
- details,
228
- // [W2 修复] followUp → steer:subagent 完成通知需立即抢占主 agent 下一个 turn,
229
- // 即使主 agent 处于轮询 subagent_list 的 processing 状态(followUp 永远排不上)。
230
- // 与 workflow helpers.ts:151 同语义对齐(commit d214d0d83 验证 steer 能避免
231
- // 'Agent is already processing' 错误)。
232
- // [竞态修复] 配合 scheduleFlush 的 isIdle gate:此时主 agent 已确认 idle,
233
- // triggerTurn 必走 _runAgentPrompt 启动新 turn,不会撞 steer 分支丢失。
234
- }, { triggerTurn: true, deliverAs: "steer" });
235
- }
230
+ handle.send({
231
+ payload: {
232
+ kind: "custom",
233
+ customType: NOTIFY_CUSTOM_TYPE,
234
+ content: buildLlmContent(record),
235
+ display: true,
236
+ details: record,
237
+ },
238
+ dedupeKey,
239
+ });
240
+ },
236
241
 
237
- private buildLlmContent(record: BgNotifyRecord): string {
238
- const agent = record.agent;
239
- const id = record.id;
240
- // [wave2 指针行] 增量语义下轮次通知只携带本轮增量,异步 flush 窗口丢失时不可重发
241
- //(见 subagent-service.ts onRoundSettled 注释),恢复通道是 session 文件全文。
242
- // 仅 chatMode 透传 sessionFile,one-shot 通知不含该行;cancelled/gc-failed 不追加
243
- //(无成功结果可读,指针无意义);缺失时省略整行(追加空串 = 输出逐字节不变)。
244
- const transcriptPointer = record.sessionFile
245
- ? `\n\nFull transcript: ${record.sessionFile}`
246
- : "";
247
- switch (record.status) {
248
- case "closed": {
249
- // v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生通知文案。
250
- const reason = record.closedReason ?? "gc";
251
- if (reason === "cancelled") {
252
- return `Subagent "${agent}" (${id}) cancelled.`;
253
- }
254
- // 失败场景(gc + 有 error):展示错误。判定必须先于 patchFile——失败轮也会写
255
- // patchFile(doFinalizeRecord Step 0 对 worktreeHandle 无条件 collectPatch),若
256
- // patch 分支在前,gc 失败 + worktree 并存时 LLM 被告知 completed。顺序与三处
257
- // 同构契约的另外两处一致(shared/subagent.ts deriveClosedDisplay、
258
- // bg-notify-render.ts renderRecordLines:cancelled → gc+error → patch/result)。
259
- if (record.error && reason === "gc") {
260
- return `Subagent "${agent}" (${id}) failed: ${record.error}`;
261
- }
262
- // 成功完成(user-close)或通用结束(gc/parent-shutdown 等):展示结果。
263
- // [C-2] chatMode close 终态通知附轮次统计(设计 D2 路径①"completed after N rounds")。
264
- // totalRounds 仅 close 语义携带(notifyClosed);one-shot 完成通知不设置(round 无轮次
265
- // 语义),文案保持 "completed. Result:" 逐字节(G4 硬约束,one-shot 字节锁测试锚定)。
266
- const roundsSuffix =
267
- record.totalRounds != null && record.totalRounds > 0
268
- ? ` after ${record.totalRounds} round${record.totalRounds === 1 ? "" : "s"}`
269
- : "";
270
- const base = `Subagent "${agent}" (${id}) completed${roundsSuffix}. Result:\n${record.result ?? "(empty)"}`;
271
- if (record.patchFile) {
272
- // [wave2 review] 长 return 拆行:模板串内不可直接换行(会改变输出内容),提取 patchHint 中转变量
273
- const patchHint = `\n\nThis subagent ran in an isolated worktree; its file changes were captured as a patch:\n ${record.patchFile}\nTo bring these changes into the current repo, run: \`git apply ${record.patchFile}\``;
274
- return `${base}${patchHint}${transcriptPointer}`;
275
- }
276
- return `${base}${transcriptPointer}`;
277
- }
278
- case "running":
279
- // v4 B-1: 对话模式轮次完成(旧 idle 折入 running):携带本轮结果送回主 agent,等待下一轮 message。
280
- return `Subagent "${agent}" (${id}) finished a round. Reply:\n${record.result ?? "(empty)"}${transcriptPointer}`;
281
- }
282
- }
242
+ flushPendingNotifications(): void {
243
+ handle.flush();
244
+ },
283
245
 
284
- /** session 结束:清 timer,丢弃 pending。 */
285
- dispose(): void {
286
- this._disposed = true;
287
- if (this.timer !== undefined) {
288
- clearTimeout(this.timer);
289
- this.timer = undefined;
290
- }
291
- this.pending.length = 0;
292
- this.dedup.clear();
293
- this.flushAttempts = 0;
294
- }
246
+ dispose(): void {
247
+ disposed = true;
248
+ handle.dispose();
249
+ },
295
250
 
296
- /** /resume /fork /new 后复活。 */
297
- revive(): void {
298
- this._disposed = false;
299
- }
251
+ revive(): void {
252
+ disposed = false;
253
+ // #5:内核 disposed 不可逆——revive 必须重建 handle(/resume /fork /new 后的
254
+ // session_start 时序:disposeAll → initSession → revive)。旧 handle 若仍存活
255
+ // (首次 session_start 的 revive)dispose 为幂等清理,无副作用。
256
+ handle.dispose();
257
+ handle = createHandle();
258
+ },
259
+ };
300
260
  }
@@ -181,7 +181,7 @@ const STDERR_MAX_CHARS = 65_536;
181
181
  *
182
182
  * [跨包契约 SSOT] 此字面量是两个独立 npm 包(@zhushanwen/pi-subagent-workflow 与
183
183
  * @zhushanwen/pi-structured-output)之间的隐式 env 契约。structured-output 包内同名常量为
184
- * `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/structured-output/src/index.ts)。
184
+ * `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/universal/structured-output/src/index.ts)。
185
185
  * 两包是独立 npm 包不能直接 import,故各自保留常量但显式标注此契约关系——
186
186
  * 任一端改名必须同步另一端,否则权威 schema 注入会静默断桥(子进程不注册 tool/hook)。
187
187
  */
@@ -32,8 +32,8 @@ import { doFinalizeRecord, doFinalizeRoundToIdle } from "./finalize-record.ts";
32
32
  import { ManifestStore } from "./manifest-store.ts";
33
33
  import type { ModelConfigService } from "./model-config-service.ts";
34
34
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
35
- import type { BgNotifyRecord, NotifierHost } from "./notifier.ts";
36
- import { BgNotifier } from "./notifier.ts";
35
+ import type { BgNotifyRecord, BgNotifier, NotifierHost } from "./notifier.ts";
36
+ import { createNotifier } from "./notifier.ts";
37
37
  import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
38
38
  import type { StatusFilter } from "./record-store.ts";
39
39
  import { RecordStore } from "./record-store.ts";
@@ -104,6 +104,10 @@ interface PiLike {
104
104
  message: { customType: string; content: string; display: boolean; details?: unknown },
105
105
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
106
106
  ): void;
107
+ /** 订阅 pi 事件(D8:notifier 的 settled 边沿订阅用 'agent_settled')。
108
+ * pi 0.84.1 的 on 返回 void 且无 off——退订语义由调用侧 disposed 标志包装兑现。
109
+ * 可选:旧测试 mock pi 可能未实现 on,缺省时 notifier 退化为内核退避路径。 */
110
+ on?(event: "agent_settled", handler: () => void): void;
107
111
  }
108
112
 
109
113
  /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
@@ -299,7 +303,7 @@ export class SubagentService {
299
303
  const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
300
304
  this.manifestStore = new ManifestStore(recordsDir);
301
305
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
302
- this.notifier = new BgNotifier(this.piAdapter());
306
+ this.notifier = createNotifier(this.piAdapter());
303
307
  // #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
304
308
  // globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
305
309
  // warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
@@ -586,7 +590,10 @@ export class SubagentService {
586
590
  (r) => r.mode === "background" && hasLiveProcessHandle(r.id) && !hasIdleTimer(r.id),
587
591
  );
588
592
  },
589
- isIdle: this.isIdleFn,
593
+ isIdle: () => this.isIdleFn?.() ?? true,
594
+ // [must-fix #4 / D8] settled 边沿订阅,与 isIdle 同源(session_start 注入的 pi)。
595
+ // 只注入原生订阅能力;disposed 标志包装(退订语义)在 notifier 的 port 装配完成。
596
+ onAgentSettled: (handler) => { this.pi?.on?.("agent_settled", handler); },
590
597
  };
591
598
  }
592
599
 
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  import { killAllSpawnedChildren } from "./execution/session-runner.ts";
42
42
  import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
43
43
  import { WorktreeManager } from "./execution/worktree-manager.ts";
44
+ import { setupModelListInjector } from "./injectors/model-list-injector.ts";
44
45
  import { setupSubagentListInjector } from "./injectors/subagent-list-injector.ts";
45
46
  import { setupWorkflowListInjector } from "./injectors/workflow-list-injector.ts";
46
47
  import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
@@ -168,6 +169,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
168
169
 
169
170
  // ════════════════════════════════════════════════════════════
170
171
  // injectors:before_agent_start 注入 <available_subagents> + <available_workflows>
172
+ // + <available_provider_models>(模型列表供派发时指定 model 参数;与 subagent/workflow 清单对称)
171
173
  //
172
174
  // 归位自 unified-hooks(subagent-list-injector)+ 新增 workflow-list-injector。
173
175
  // injector 是 subagent-workflow 的内聚功能(让 LLM 知道有哪些 agent/workflow
@@ -176,6 +178,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
176
178
  // ════════════════════════════════════════════════════════════
177
179
  setupSubagentListInjector(pi);
178
180
  setupWorkflowListInjector(pi);
181
+ setupModelListInjector(pi);
179
182
 
180
183
  // 模块级缓存:主 session 的 sessionFile(fork source 解析用)。
181
184
  let cachedMainSessionFile: string | undefined;