@zhushanwen/pi-subagent-workflow 8.4.0 → 8.6.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 (171) hide show
  1. package/package.json +22 -7
  2. package/relay/relay.mjs +390 -0
  3. package/skills/subagent-ext-config/SKILL.md +80 -0
  4. package/src/execution/__tests__/agent-registry.test.ts +110 -0
  5. package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
  6. package/src/execution/__tests__/chat-engine-routing.test.ts +601 -0
  7. package/src/execution/__tests__/delivery-methods.test.ts +38 -1
  8. package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
  9. package/src/execution/__tests__/execution-record.test.ts +237 -1
  10. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
  11. package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
  12. package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
  13. package/src/execution/__tests__/index-session-start.test.ts +86 -7
  14. package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
  15. package/src/execution/__tests__/list-fields.test.ts +45 -14
  16. package/src/execution/__tests__/model-resolver.test.ts +57 -5
  17. package/src/execution/__tests__/notifier-flush.test.ts +64 -26
  18. package/src/execution/__tests__/notify-ledger.test.ts +826 -0
  19. package/src/execution/__tests__/output-collector.test.ts +299 -2
  20. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  21. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  22. package/src/execution/__tests__/relay-env.test.ts +42 -0
  23. package/src/execution/__tests__/rpc-mode.test.ts +1 -1
  24. package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
  25. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
  26. package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
  27. package/src/execution/__tests__/spawn-args.test.ts +37 -26
  28. package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
  29. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  30. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  31. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  32. package/src/execution/__tests__/subprocess-agent-runner.test.ts +147 -6
  33. package/src/execution/__tests__/timeout-integration.test.ts +220 -2
  34. package/src/execution/__tests__/tool-action.test.ts +92 -1
  35. package/src/execution/agent-registry.ts +16 -0
  36. package/src/execution/argv-mirror.ts +5 -1
  37. package/src/execution/concurrency-pool.ts +1 -1
  38. package/src/execution/config.ts +25 -2
  39. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  40. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  41. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  42. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  43. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  44. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  45. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  46. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  47. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  48. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  49. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  50. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  51. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  52. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  53. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  54. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  55. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  56. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  57. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  58. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  59. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  60. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  61. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  62. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  63. package/src/execution/engine/common/data-dir.ts +62 -0
  64. package/src/execution/engine/common/errors.ts +183 -0
  65. package/src/execution/engine/common/event-journal.ts +254 -0
  66. package/src/execution/engine/common/journal-replay.ts +62 -0
  67. package/src/execution/engine/common/kill-chain.ts +221 -0
  68. package/src/execution/engine/common/nesting-guard.ts +50 -0
  69. package/src/execution/engine/common/persona-router.ts +108 -0
  70. package/src/execution/engine/common/pool-manager.ts +226 -0
  71. package/src/execution/engine/common/schema-emulation.ts +189 -0
  72. package/src/execution/engine/common/session-view-projection.ts +51 -0
  73. package/src/execution/engine/engine-discovery.ts +65 -0
  74. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  75. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  76. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  77. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  78. package/src/execution/engine/engines/pi/reader.ts +48 -0
  79. package/src/execution/engine/engines/pi/registration.ts +35 -0
  80. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  81. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  82. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  83. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  84. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  85. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  86. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  87. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  88. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
  89. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  90. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  91. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  92. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  93. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  94. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  95. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  96. package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
  97. package/src/execution/engine/host-task-spec.ts +47 -0
  98. package/src/execution/engine/model-prompt.ts +59 -0
  99. package/src/execution/engine/paths.ts +42 -0
  100. package/src/execution/engine/port.ts +153 -0
  101. package/src/execution/engine/registry.ts +123 -0
  102. package/src/execution/engine/routing.ts +218 -0
  103. package/src/execution/engine/types.ts +309 -0
  104. package/src/execution/execute-options-mapper.ts +13 -8
  105. package/src/execution/execution-record.ts +66 -1
  106. package/src/execution/lifecycle-manager.ts +23 -1
  107. package/src/execution/model-config-service.ts +16 -1
  108. package/src/execution/model-resolver.ts +37 -59
  109. package/src/execution/notifier.ts +105 -35
  110. package/src/execution/notify-ledger.ts +580 -0
  111. package/src/execution/output-collector.ts +143 -3
  112. package/src/execution/pi-invocation.ts +32 -2
  113. package/src/execution/record-entry.ts +14 -0
  114. package/src/execution/record-store.ts +34 -0
  115. package/src/execution/relay-env.ts +37 -0
  116. package/src/execution/session-runner.ts +328 -71
  117. package/src/execution/stream-sink.ts +26 -0
  118. package/src/execution/subagent-service.ts +273 -13
  119. package/src/execution/subprocess-agent-runner.ts +210 -14
  120. package/src/execution/types.ts +124 -5
  121. package/src/execution/ui-request-queue.ts +14 -4
  122. package/src/index.ts +99 -1
  123. package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
  124. package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
  125. package/src/interface/bg-notify-render.ts +33 -12
  126. package/src/interface/helpers.ts +2 -2
  127. package/src/interface/subagent-actions.ts +29 -9
  128. package/src/interface/subagent-tool-schema.ts +156 -0
  129. package/src/interface/subagent-tool.ts +56 -119
  130. package/src/interface/subagents.ts +2 -2
  131. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +19 -3
  132. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
  133. package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
  134. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
  135. package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
  136. package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
  137. package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
  138. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
  139. package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
  140. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
  141. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
  142. package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
  143. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
  144. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
  145. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +22 -3
  146. package/src/orchestration/agent-opts-resolver.ts +104 -23
  147. package/src/orchestration/error-recovery.ts +189 -33
  148. package/src/orchestration/execute-agent-call.ts +39 -0
  149. package/src/orchestration/jsonl-run-store.ts +121 -7
  150. package/src/orchestration/launcher.ts +60 -15
  151. package/src/orchestration/lifecycle.ts +10 -7
  152. package/src/orchestration/models/__tests__/budget.test.ts +1 -61
  153. package/src/orchestration/models/budget.ts +5 -35
  154. package/src/orchestration/models/run-runtime.ts +24 -9
  155. package/src/orchestration/models/types.ts +16 -0
  156. package/src/orchestration/script-lint.ts +1 -1
  157. package/src/orchestration/skill-discovery.ts +31 -8
  158. package/src/orchestration/worker-script-builder.ts +19 -3
  159. package/src/shared/__tests__/model-ref.test.ts +306 -0
  160. package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
  161. package/src/shared/__tests__/timer-delay.test.ts +61 -0
  162. package/src/shared/meta-parser.ts +5 -1
  163. package/src/shared/model-ref.ts +286 -0
  164. package/src/shared/resource-meta.ts +5 -0
  165. package/src/shared/schema-env.ts +44 -0
  166. package/src/shared/schema-jsonify.ts +6 -4
  167. package/src/shared/timer-delay.ts +54 -0
  168. package/workflows/review-fix-loop-utils.cjs +9 -7
  169. package/workflows/review-fix-loop.js +20 -12
  170. package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
  171. package/src/orchestration/concurrency-gate.ts +0 -69
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
 
19
- import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
19
+ import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
21
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
22
22
 
@@ -24,12 +24,22 @@ import { bestEffort } from "./execution/best-effort.ts";
24
24
  // ═══ execution/ 层(subagents 核心 + 运行时) ═══
25
25
  import { getOrCreateChannelRegistry } from "./execution/channel-registry-access.ts";
26
26
  import { DialogGlobalQueue } from "./execution/dialog-queue.ts";
27
+ // [U7] 引擎列表状态文件(registry → engines.json,GUI 引擎选择器数据源)
28
+ import { syncEnginesFile } from "./execution/engine/engine-discovery.ts";
29
+ // [U7] 引擎模型段注入(defaultEngine 非 pi 时 system prompt 补 <available_<engine>_models>)
30
+ import { buildEngineModelsPromptAppend } from "./execution/engine/model-prompt.ts";
31
+ // [P1 引擎接线] 组合根登记 'pi' 引擎进 registry(引擎获取统一经 getEngine,缺省 id 'pi')
32
+ import { registerPiEngine } from "./execution/engine/engines/pi/registration.ts";
33
+ // [P3 引擎接线] 组合根登记 'zcode' 引擎(spawn 单轮模式;engineDataDir 默认走
34
+ // common/data-dir SSOT,见 engines/zcode/registration.ts)
35
+ import { registerZcodeEngine } from "./execution/engine/engines/zcode/registration.ts";
27
36
  import { createUiRequestHandlerForMode } from "./execution/ui-request-handler-factory.ts";
28
37
  import {
29
38
  getModelConfigService,
30
39
  ModelConfigService,
31
40
  setModelConfigService,
32
41
  } from "./execution/model-config-service.ts";
42
+ import { bindNotifyLedgerHost, getBoundNotifyLedger, type NotifyLedgerHost } from "./execution/notify-ledger.ts";
33
43
  import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
34
44
  import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
35
45
  import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
@@ -160,6 +170,22 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
160
170
  // 的 getLogger("subagents") 也能走 appendEntry。
161
171
  setPiHandle(pi);
162
172
 
173
+ // [P1 引擎接线] 组合根登记缺省引擎:进程级 SubagentService 单例(session_start 注入)
174
+ // 经 registry 以 'pi' 暴露——引擎获取从此统一走 getEngine(DEFAULT_ENGINE_ID),上层
175
+ // 不再硬编码「spawn pi」。幂等(registerEngine 覆盖语义),工厂惰性解析服务单例。
176
+ // P4 配置路由(agent frontmatter engine 字段 + 三层优先级)在本登记之上消费。
177
+ registerPiEngine();
178
+
179
+ // [P3 引擎接线] 登记 'zcode'(幂等同上)。惰性工厂:不触发 CLI/凭据探测,引擎被
180
+ // 实际选用(P4 路由或显式 getEngine('zcode'))才解析 deps。
181
+ registerZcodeEngine();
182
+
183
+ // [U7b] 引擎列表在 extension 模块加载时即同步 engines.json(不等 session_start——
184
+ // 用户体验拍板 2026-08-25:xyz-agent 打开后激活任意 session 的第一时间(含 TUI 等价
185
+ // 场景)GUI 引擎选择器就该有数据;session_start 处保留幂等重写兜底 jiti 双路径/
186
+ // 模块重载场景的刷新)。
187
+ syncEnginesFile(getAgentDir());
188
+
163
189
  // ════════════════════════════════════════════════════════════
164
190
  // subagents 域:tool + command + messageRenderer
165
191
  // ════════════════════════════════════════════════════════════
@@ -332,6 +358,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
332
358
  const sessionId = ctx.sessionManager.getSessionId();
333
359
  lsRef.lastSessionId = sessionId;
334
360
 
361
+ // [U7] 引擎列表同步 engines.json(幂等零写 + fail-safe;组合根注册已在
362
+ // extension 工厂体完成,此处 registry 已含全部引擎)
363
+ syncEnginesFile(agentDir);
364
+
335
365
  // skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
336
366
  // 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
337
367
  // 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
@@ -381,6 +411,39 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
381
411
  }
382
412
  }
383
413
 
414
+ // ── [U2] 通知账本装配 + 重启恢复(设计 D4:存在性 / 可达性分离)──
415
+ // bind 先于 service.initSession(notifier.revive 在其内——notify() 经
416
+ // getBoundNotifyLedger 消费账本)。recoverFromSession 扫 ledger/ack 两列 entry
417
+ // 差集:未销账号重放投递(已销账零重发,notifyId 幂等);fork 继承未销账
418
+ // pending 属可接受语义(D4 归属规则——扫描域 = 单 session 文件,幂等键作用域
419
+ // 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
420
+ // 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。
421
+ try {
422
+ const ledgerHost: NotifyLedgerHost = {
423
+ appendLedgerEntry: (customType, data) => {
424
+ pi.appendEntry(customType, data);
425
+ },
426
+ readSessionEntries: () => ctx.sessionManager.getEntries(),
427
+ isIdle: () => ctx.isIdle(),
428
+ onAgentSettled: (handler) => {
429
+ pi.on("agent_settled", handler);
430
+ },
431
+ sendDelivery: (message) => {
432
+ // D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
433
+ // courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
434
+ pi.sendMessage(message, { triggerTurn: true });
435
+ },
436
+ };
437
+ // U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
438
+ // 通道落盘),此处不再重复打日志。
439
+ bindNotifyLedgerHost(ledgerHost).recoverFromSession();
440
+ } catch (err) {
441
+ // 账本装配失败不阻断 session_start(通知退回 notifier 的内核路径)
442
+ logger.warn("[subagents] notify ledger bind failed", {
443
+ reason: err instanceof Error ? err.message : String(err),
444
+ });
445
+ }
446
+
384
447
  // ── subagents 域:双 Service 装配 ──
385
448
  const existingService = getSubagentService();
386
449
  const existingModelService = getModelConfigService();
@@ -559,6 +622,41 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
559
622
  });
560
623
  });
561
624
 
625
+ // ════════════════════════════════════════════════════════════
626
+ // [U2 P-B4 降级] session_compact:compaction 对 custom entry 保留行为实装未
627
+ // 验证——检测 ledger/ack entry 被 compaction 清除时按内存态补写(notify-ledger
628
+ // compactionCheck;未清除则 no-op)。内存态在 compaction 后仍活着,作为补写源;
629
+ // 重启后的权威仍是两列 entry 差集(内存不承担销账职责)。
630
+ // ═══════════════════════════════════════════════════════
631
+ pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
632
+ try {
633
+ const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
634
+ if (rewritten > 0) {
635
+ logger.warn(`[subagents] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`);
636
+ }
637
+ } catch (err) {
638
+ logger.warn("[subagents] notify ledger compactionCheck failed", {
639
+ reason: err instanceof Error ? err.message : String(err),
640
+ });
641
+ }
642
+ });
643
+
644
+ // ════════════════════════════════════════════════════════════
645
+ // [U7] before_agent_start:引擎模型段注入(defaultEngine 非 pi 且引擎实现
646
+ // listModels 时追加 <available_<engine>_models>——每 turn 重判 config,改配置
647
+ // 后下一 turn 即生效;fail-safe 任何异常不注入不阻塞 agent loop)
648
+ // ════════════════════════════════════════════════════════════
649
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
650
+ try {
651
+ const service = getModelConfigService();
652
+ const append = service === null ? "" : buildEngineModelsPromptAppend(service.getGlobalConfig().defaultEngine);
653
+ if (append === "" || typeof event.systemPrompt !== "string") return undefined;
654
+ return { systemPrompt: `${event.systemPrompt}\n\n${append}` };
655
+ } catch {
656
+ return undefined;
657
+ }
658
+ });
659
+
562
660
  // ════════════════════════════════════════════════════════════
563
661
  // model_select:用户切换 model 时刷新缓存
564
662
  // ════════════════════════════════════════════════════════════
@@ -0,0 +1,157 @@
1
+ // subagent tool 路径类参数守卫测试(三通道对称审查修复 + MF-13)。
2
+ //
3
+ // 修复背景:subagent tool 的 skillPath 参数此前零校验直传 session-runner 拼
4
+ // `--skill <path>`;cwd 仅 description 声明 "Must be an absolute path" 无运行时
5
+ // 闸。schema pattern(^/)经 pi agent-loop 运行时强校验已是强制(PS-20:
6
+ // agent-loop.js:403-404 → validation.js:247-273),工具层守卫定位 = defense-in-depth
7
+ // + schema 表达力缺口——`..` 穿越语义超出 pattern 能力(^/ 放行 "/a/../b"),
8
+ // 守卫在 executeSubagent start 分支 immediate throw(与 action 枚举守卫同风格)。
9
+ //
10
+ // 本文件锁住:
11
+ // 1. skillPath / cwd 含 `..` 穿越段 → 同步 reject,service.execute 零触达
12
+ // 2. skillPath / cwd 相对路径 → 同步 reject
13
+ // 3. 合法绝对路径放行 → 参数原样透传 service.execute(守卫零误伤对照)
14
+ //
15
+ // harness 复用 sdk-contract.test.ts 的 mock 链(pi-ai/typebox/subagent-service),
16
+ // registerSubagentTool 后捕获 execute 回调直接调用。
17
+
18
+ import { describe, expect, it, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@earendil-works/pi-ai", () => ({
21
+ StringEnum: (values: string[]) => ({ type: "string", enum: values }),
22
+ }));
23
+ vi.mock("typebox", () => ({
24
+ Type: {
25
+ Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
26
+ Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
27
+ String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
28
+ Boolean: () => ({ type: "boolean" }),
29
+ Number: (opts?: Record<string, unknown>) => ({ type: "number", ...opts }),
30
+ Array: (items: unknown) => ({ type: "array", items }),
31
+ Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
32
+ Unknown: () => ({ type: "unknown" }),
33
+ Union: (members: unknown[]) => ({ type: "union", members }),
34
+ Literal: (value: unknown) => ({ type: "literal", value }),
35
+ },
36
+ }));
37
+
38
+ // Mock getSubagentService:断言「守卫拒绝时 service.execute 零触达」的承重证据。
39
+ const { mockServiceExecute } = vi.hoisted(() => ({
40
+ mockServiceExecute: vi.fn(),
41
+ }));
42
+ vi.mock("../../execution/subagent-service.ts", () => ({
43
+ getSubagentService: () => ({ execute: mockServiceExecute }),
44
+ }));
45
+
46
+ import { registerSubagentTool } from "../../interface/subagent-tool.ts";
47
+ import { mockExtensionApi } from "../../execution/__tests__/helpers/mock-extension-api.ts";
48
+
49
+ type ExecuteCb = (...args: unknown[]) => Promise<unknown>;
50
+
51
+ /** 注册 tool 并捕获 execute 回调(sdk-contract 同款)。 */
52
+ function captureExecute(): ExecuteCb {
53
+ let captured: ExecuteCb | undefined;
54
+ const pi = mockExtensionApi({
55
+ registerTool: (tool: unknown) => {
56
+ captured = (tool as { execute: ExecuteCb }).execute;
57
+ },
58
+ });
59
+ registerSubagentTool(pi);
60
+ if (!captured) throw new Error("subagent tool not registered");
61
+ return captured;
62
+ }
63
+
64
+ /** 合法 start 入参基线(守卫测试只变动 skillPath/cwd 字段)。 */
65
+ function baseParams(over: Record<string, unknown> = {}): Record<string, unknown> {
66
+ return { action: "start", task: "guard task", slug: "path-guard", ...over };
67
+ }
68
+
69
+ /** 合法 execute 返回值 stub(放行路径 adapter 包装用)。 */
70
+ function stubHandle() {
71
+ return {
72
+ mode: "background",
73
+ subagentId: "sa-path-guard",
74
+ sessionFile: "/tmp/session.jsonl",
75
+ details: { slug: "path-guard", model: "test/model" },
76
+ };
77
+ }
78
+
79
+ describe("subagent tool 路径守卫(skillPath/cwd:绝对路径 + 禁 .. 穿越)", () => {
80
+ let execute: ExecuteCb;
81
+
82
+ beforeEach(() => {
83
+ vi.clearAllMocks();
84
+ mockServiceExecute.mockResolvedValue(stubHandle());
85
+ execute = captureExecute();
86
+ });
87
+
88
+ // ── `..` 穿越段拒绝 ──
89
+
90
+ it("skillPath 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
91
+ await expect(
92
+ execute("call-1", baseParams({ skillPath: "../../etc/passwd" }), undefined, undefined, undefined),
93
+ ).rejects.toThrow(/skillPath must not contain '\.\.' path segments/);
94
+
95
+ expect(mockServiceExecute).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("cwd 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
99
+ await expect(
100
+ execute("call-2", baseParams({ cwd: "/safe/root/../../unsafe" }), undefined, undefined, undefined),
101
+ ).rejects.toThrow(/cwd must not contain '\.\.' path segments/);
102
+
103
+ expect(mockServiceExecute).not.toHaveBeenCalled();
104
+ });
105
+
106
+ // ── 相对路径拒绝(`~` 缩写不是绝对路径,一并拒)──
107
+
108
+ it("skillPath 相对路径 → immediate reject(报错指引展开为绝对路径)", async () => {
109
+ await expect(
110
+ execute("call-3", baseParams({ skillPath: ".agents/skills/my-skill" }), undefined, undefined, undefined),
111
+ ).rejects.toThrow(/skillPath must be an absolute path.*Expand '~' yourself/s);
112
+
113
+ expect(mockServiceExecute).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("cwd 相对路径 → immediate reject", async () => {
117
+ await expect(
118
+ execute("call-4", baseParams({ cwd: "relative/dir" }), undefined, undefined, undefined),
119
+ ).rejects.toThrow(/cwd must be an absolute path/);
120
+
121
+ expect(mockServiceExecute).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("cwd `~` 缩写 → immediate reject(下游 spawn cwd 不展开 ~)", async () => {
125
+ await expect(
126
+ execute("call-5", baseParams({ cwd: "~/project" }), undefined, undefined, undefined),
127
+ ).rejects.toThrow(/cwd must be an absolute path/);
128
+
129
+ expect(mockServiceExecute).not.toHaveBeenCalled();
130
+ });
131
+
132
+ // ── 放行对照(守卫零误伤)──
133
+
134
+ it("合法绝对路径 skillPath/cwd 放行,参数原样透传 service.execute", async () => {
135
+ await execute(
136
+ "call-ok",
137
+ baseParams({ skillPath: "/work/project/.agents/skills/my-skill", cwd: "/work/project" }),
138
+ undefined,
139
+ undefined,
140
+ undefined,
141
+ );
142
+
143
+ expect(mockServiceExecute).toHaveBeenCalledTimes(1);
144
+ expect(mockServiceExecute).toHaveBeenCalledWith(
145
+ expect.objectContaining({
146
+ skillPath: "/work/project/.agents/skills/my-skill",
147
+ cwd: "/work/project",
148
+ }),
149
+ );
150
+ });
151
+
152
+ it("不传 skillPath/cwd → 守卫不介入(undefined 合法缺省)", async () => {
153
+ await execute("call-none", baseParams(), undefined, undefined, undefined);
154
+
155
+ expect(mockServiceExecute).toHaveBeenCalledTimes(1);
156
+ });
157
+ });
@@ -114,6 +114,18 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
114
114
  expect(DESCRIPTION).not.toContain('"sa_');
115
115
  });
116
116
 
117
+ it("Examples 示例 agent 值必须是绝对路径 .md 形态(显式 ref 硬守卫拒绝裸名)", () => {
118
+ // 显式 agent ref 走硬守卫:getRequiredAgentConfig → loadByPath(ref, true),
119
+ // 裸名(normalizeRef 非绝对路径 → null)同步 throw `Invalid agent ref` +
120
+ // <available_subagents> 恢复指引(explicit-agent-ref-guard.test.ts 锁定拒绝路径)。
121
+ // 示例若教裸名(历史漂移:"agent":"coder"),弱模型照抄即必败调用——浪费一轮
122
+ // 并系统性教唆反模式。与 subagentId 格式测试同风格:示例必须与实际契约一致。
123
+ // ① 零裸名:任何 "agent":"<非/>" 形态都禁止
124
+ expect(DESCRIPTION).not.toMatch(/"agent":"(?!\/)[^"]*"/);
125
+ // ② 正例在位且为绝对路径 .md 形态(<available_subagents> 注入的 <location> 形态)
126
+ expect(DESCRIPTION).toMatch(/"agent":"\/[^"]*\.md"/);
127
+ });
128
+
117
129
  it("agent 字段 description 不写死枚举,指向 <available_subagents>(通用化防漂移)", () => {
118
130
  // 原实现把 9 个内置 agent 名写死在 schema field description(防漏),
119
131
  // 但枚举与包内 agents/*.md 存在漂移风险(新增/删除 agent 要手改两处)。
@@ -23,6 +23,9 @@ import type { Component } from "@earendil-works/pi-tui";
23
23
  import type { Theme } from "@earendil-works/pi-coding-agent";
24
24
 
25
25
  import { displayAgentName } from "../shared/agent-ref.ts";
26
+ import { deriveOutcome } from "../execution/execution-record.ts";
27
+ import { CLOSED_REASONS } from "../execution/types.ts";
28
+ import type { ClosedReason, ExecutionOutcome } from "../execution/types.ts";
26
29
  import {
27
30
  firstLine,
28
31
  padToVisible,
@@ -55,8 +58,10 @@ interface BgNotifyRecord {
55
58
  id: string;
56
59
  /** v4 B-1: closed(终态,含 cancelled)或 running(对话模式轮次完成,旧 idle)。 */
57
60
  status: "running" | "closed";
58
- /** L2 关闭原因子枚举(仅 status="closed" 时有意义)。 */
59
- closedReason?: string;
61
+ /** L2 关闭原因子枚举(内部诊断 + outcome 兑底派生输入;经 toClosedReason 防御性收窄)。 */
62
+ closedReason?: ClosedReason;
63
+ /** 终态三态对外语义(U3 C-outcome)。缺失(升级前旧消息重放)时按 deriveOutcome 兑底。 */
64
+ outcome?: ExecutionOutcome;
60
65
  agent: string;
61
66
  model?: string;
62
67
  result?: string;
@@ -218,12 +223,15 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
218
223
  const modelPart = record.model
219
224
  ? ` ${t.fg("dim", "·")} ${t.fg("accent", truncLine(record.model, MODEL_MAX_WIDTH))}`
220
225
  : "";
221
- // v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生文案。
222
- const reason = record.closedReason ?? "gc";
226
+ // U3 C-outcome:verb 与正文分流只读 outcome——单一权威派生(升级前旧消息重放等
227
+ // details outcome 的存量形态经 deriveOutcome(closedReason, error) 兑底,非同构
228
+ // 重写)。判定先于 patchFile:failed 分支不展示 patch/result(失败轮也会写
229
+ // patchFile,历史 bug 存档见 deriveOutcome 注释)。
230
+ const outcome = record.outcome ?? deriveOutcome(record.closedReason, record.error);
223
231
  let verb: string;
224
- if (reason === "cancelled") {
232
+ if (outcome === "cancelled") {
225
233
  verb = "cancelled";
226
- } else if (reason === "gc" && record.error) {
234
+ } else if (outcome === "failed") {
227
235
  verb = "failed";
228
236
  } else {
229
237
  verb = "finished";
@@ -233,13 +241,12 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
233
241
 
234
242
  switch (record.status) {
235
243
  case "closed": {
236
- // v4 B-1: closed 统一终态(含 cancelled)。cancelled 无正文;失败显示错误;否则结果/patch。
237
- const r = record.closedReason ?? "gc";
238
- if (r === "cancelled") {
244
+ // U3 C-outcome:cancelled 无正文;failed 显示错误;否则结果/patch(同上,分流只读 outcome)。
245
+ if (outcome === "cancelled") {
239
246
  return [head];
240
247
  }
241
- if (r === "gc" && record.error) {
242
- return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error)}`, BODY_MAX_WIDTH))];
248
+ if (outcome === "failed") {
249
+ return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error ?? "")}`, BODY_MAX_WIDTH))];
243
250
  }
244
251
  if (!record.result && !record.patchFile) return [head];
245
252
  const lines: string[] = [];
@@ -275,6 +282,19 @@ function extractBatch(details: unknown): BgNotifyRecord[] | undefined {
275
282
  return records.length > 0 ? records : undefined;
276
283
  }
277
284
 
285
+ /**
286
+ * details.closedReason 防御性收窄:任意字符串 → ClosedReason | undefined。
287
+ * 旧数据/外部构造的非法值按缺失处理,交由 deriveOutcome 兑底(消费方不崩溃)。
288
+ */
289
+ function toClosedReason(value: unknown): ClosedReason | undefined {
290
+ return CLOSED_REASONS.find((reason) => reason === value);
291
+ }
292
+
293
+ /** details.outcome 防御性收窄:仅接受三态枚举值,其余按缺失处理。 */
294
+ function toOutcome(value: unknown): ExecutionOutcome | undefined {
295
+ return value === "completed" || value === "failed" || value === "cancelled" ? value : undefined;
296
+ }
297
+
278
298
  /**
279
299
  * 从 message.details 防御性提取 BgNotifyRecord。
280
300
  * 结构不全(缺 status / agent)返回 undefined。
@@ -297,7 +317,8 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
297
317
  model: typeof d.model === "string" ? d.model : undefined,
298
318
  result: typeof d.result === "string" ? d.result : undefined,
299
319
  error: typeof d.error === "string" ? d.error : undefined,
300
- closedReason: typeof d.closedReason === "string" ? d.closedReason : undefined,
320
+ closedReason: toClosedReason(d.closedReason),
321
+ outcome: toOutcome(d.outcome),
301
322
  round: typeof d.round === "number" ? d.round : undefined,
302
323
  // [MF#1] 提取 patchFile(worktree background 完成通知携带)。
303
324
  patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
@@ -238,7 +238,7 @@ export function notifyDone(
238
238
 
239
239
  const content = parts.join("\n");
240
240
 
241
- // deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering 消息注入
241
+ // deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering 消息注入(g4-allow: 存量待迁移——结果语义通知,账本化迁移登记 pi-boundary-reliability 附录 B 待办)
242
242
  // 并立即唤醒 parent agent 处理结果(与 subagent 的 followUp+triggerTurn 对称)
243
243
  const details: WorkflowNotifyDetails = {
244
244
  runId,
@@ -275,7 +275,7 @@ export function notifyDone(
275
275
  display: true,
276
276
  details,
277
277
  },
278
- { triggerTurn: true, deliverAs: "steer" },
278
+ { triggerTurn: true, deliverAs: "steer" }, // g4-allow: 存量待迁移——workflow 完成通知属结果语义,迁移切片复用 U2 账本设施(附录 B 待办)
279
279
  );
280
280
  }
281
281
 
@@ -17,7 +17,7 @@ import {
17
17
  } from "@xyz-agent/extension-protocol";
18
18
 
19
19
  import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
20
- import { computeElapsedSeconds } from "../execution/execution-record.ts";
20
+ import { computeElapsedSeconds, projectOutcome } from "../execution/execution-record.ts";
21
21
  import { isResumable } from "../execution/lifecycle-predicates.ts";
22
22
  import type { ExecutionRecord } from "../execution/types.ts";
23
23
  import type { ModelInfo } from "../execution/model-resolver.ts";
@@ -49,6 +49,9 @@ const MAX_LIST_LIMIT = 100;
49
49
  /** background 启动提示文案(spec FR-3 bgResponse.message)。 */
50
50
  const BG_MESSAGE = "detached, will notify on completion (auto-injected message, do not poll)";
51
51
 
52
+ /** 通知投递契约回显恒值(U1 预置,U2 账本兑现,见 BgResponse.notifyContract)。 */
53
+ const NOTIFY_CONTRACT = "ledger+at-least-once" as const;
54
+
52
55
  /** subagentId(UUID)在 GUI header 的截断显示长度。 */
53
56
  const SUBAGENT_ID_PREVIEW = 8;
54
57
 
@@ -79,8 +82,13 @@ export interface StartHandlerInput {
79
82
  cwd?: string;
80
83
  /** 可持续对话模式(true = chatMode,轮次完成进 idle 等续聊)。 */
81
84
  conversation?: boolean;
82
- /** 空闲超时毫秒数(仅 conversation 模式有意义,覆盖默认 5min)。 */
85
+ /**
86
+ * 空闲超时毫秒数(仅 conversation 模式有意义,覆盖默认 5min)。
87
+ * 显式传 0/负数 = 禁用 idle GC(不挂 timer);不传走 env/默认优先级。
88
+ */
83
89
  idleTimeoutMs?: number;
90
+ /** 执行引擎(D4 三层路由第一层:本参数 > agent frontmatter engine > config defaultEngine)。 */
91
+ engine?: string;
84
92
  }
85
93
 
86
94
  /** start 领域对象(adapter 包成 bgResponse)。 */
@@ -90,6 +98,11 @@ export type StartHandlerResult = {
90
98
  sessionFile: string | undefined;
91
99
  /** 短标签,来自 record(handle.details.slug)。用于 result 行展示。 */
92
100
  slug: string;
101
+ /**
102
+ * registry 全等回显(U1):handle.details.model = record.model = `${provider}/${id}`,
103
+ * 源头是 resolveModel 裁决放行的条目——通过校验 = 子进程必然按此名执行。
104
+ */
105
+ model: string;
93
106
  response: BgResponse;
94
107
  };
95
108
 
@@ -152,9 +165,11 @@ export function mapExternalState(status: ExecutionStatus): ExternalState {
152
165
  }
153
166
 
154
167
  /** SubagentRecord → SubagentListItem(state 四态主字段 + status 调试字段,duration 实时计算)。
155
- * [v4 A-6] 新增 parent/resumable/closedReason:parent 从 record.parentRecordId 派生
156
- * (配合 A-5 直接父守卫),resumable 从 isResumable 派生(B-1「可续聊」对外表达),
157
- * closedReason 透传(SP-4 级联关闭告知替代 before_agent_start 注入)。
168
+ * [v4 A-6] 新增 parent/resumable:parent 从 record.parentRecordId 派生(配合 A-5 直接父
169
+ * 守卫),resumable 从 isResumable 派生(B-1「可续聊」对外表达)。
170
+ * [U3 C-outcome] 新增 outcome 一等终态语义(projectOutcome 唯一出口);closedReason
171
+ * 退出对外 JSON(保留为 record 内部诊断字段),对外成败判读收口到 outcome,消费方
172
+ * 零手写推导(三处同构 switch 已收敛删除)。
158
173
  * agent 是 GUI/TUI list 共用的显示名——取 basename 短名(displayAgentName),
159
174
  * 完整路径保留在 record.agent(数据层)。 */
160
175
  export function recordToListItem(r: SubagentRecord): SubagentListItem {
@@ -171,7 +186,7 @@ export function recordToListItem(r: SubagentRecord): SubagentListItem {
171
186
  sessionFile: r.sessionFile,
172
187
  parent: r.parentRecordId,
173
188
  resumable: isResumable(r),
174
- closedReason: r.closedReason,
189
+ outcome: projectOutcome(r),
175
190
  };
176
191
  }
177
192
 
@@ -219,6 +234,7 @@ export async function startHandler(
219
234
  cwd: input.cwd,
220
235
  conversation: input.conversation,
221
236
  idleTimeoutMs: input.idleTimeoutMs,
237
+ engine: input.engine,
222
238
  ctxModel,
223
239
  signal,
224
240
  // background detached 运行,完成由 notify 驱动新 turn。
@@ -229,10 +245,13 @@ export async function startHandler(
229
245
  subagentId: handle.subagentId,
230
246
  sessionFile: handle.sessionFile,
231
247
  slug: handle.details.slug,
248
+ // [U1] registry 全等回显:record.model 由 resolved(裁决放行条目)拼接,原样透出。
249
+ model: handle.details.model,
232
250
  response: {
233
251
  status: "running",
234
252
  mode: "background",
235
253
  message: BG_MESSAGE,
254
+ notifyContract: NOTIFY_CONTRACT,
236
255
  },
237
256
  };
238
257
  }
@@ -468,7 +487,8 @@ export function adapter(
468
487
  const d = input.domain;
469
488
  // MF-3(决策 10 细则 4):LLM content (text) 用 null 瘦身,防诱导 agent 用 read 绕过工具
470
489
  // 直接读 session 文件。真实 sessionFile 仅 details 保留(供 GUI/程序化消费)。
471
- result = { action, subagentId: d.subagentId, sessionFile: null, slug: d.slug, bgResponse: d.response };
490
+ // [U1] model registry 全等回显(放行即全等)。
491
+ result = { action, subagentId: d.subagentId, sessionFile: null, slug: d.slug, model: d.model, bgResponse: d.response };
472
492
  } else if (action === "list") {
473
493
  result = { action, subagentId: null, sessionFile: null, listResponse: input.domain.response };
474
494
  } else if (action === "cancel") {
@@ -488,7 +508,7 @@ export function adapter(
488
508
  let detailsBase: SubagentToolResult = result;
489
509
  if (action === "start") {
490
510
  const d = input.domain;
491
- detailsBase = { action: "start", subagentId: d.subagentId, sessionFile: d.sessionFile ?? null, slug: d.slug, bgResponse: d.response };
511
+ detailsBase = { action: "start", subagentId: d.subagentId, sessionFile: d.sessionFile ?? null, slug: d.slug, model: d.model, bgResponse: d.response };
492
512
  }
493
513
 
494
514
  // GUI 协议:RPC 模式下附加结构化渲染数据(union 各成员已声明 __gui__?,无需强转)
@@ -500,7 +520,7 @@ export function adapter(
500
520
  // reminder 作为第二个 text block(独立追加,不污染 details/JSON schema)。
501
521
  // 只有 list 触发——start 的 reminder 已在 BG_MESSAGE 里;cancel 无需。
502
522
  const reminder = action === "list"
503
- ? "\n\nReminder: Subagent completion is auto-notified via injected message (deliverAs: steer). Do NOT poll in a loop — there is no poll action. Use action:'list' only when you concretely need state, then continue working or stop."
523
+ ? "\n\nReminder: Subagent completion is auto-notified via auto-injected message (turn-triggering on idle). Do NOT poll in a loop — there is no poll action. Use action:'list' only when you concretely need state, then continue working or stop." // g4-allow: 契约文案——reminder 字符串描述自动注入通道(triggerTurn 单通道,U2/D5 无 deliverAs),非实际投递调用
504
524
  : "";
505
525
 
506
526
  return {