@zhushanwen/pi-subagent-workflow 8.3.0 → 8.5.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 (103) hide show
  1. package/package.json +18 -4
  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__/chat-engine-routing.test.ts +597 -0
  6. package/src/execution/__tests__/execution-record.test.ts +127 -1
  7. package/src/execution/__tests__/pi-invocation.test.ts +62 -1
  8. package/src/execution/__tests__/relay-agent.test.ts +448 -0
  9. package/src/execution/__tests__/relay-env.test.ts +42 -0
  10. package/src/execution/__tests__/startup-config-declaration.test.ts +35 -0
  11. package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
  12. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
  13. package/src/execution/__tests__/subprocess-agent-runner.test.ts +53 -5
  14. package/src/execution/agent-registry.ts +10 -0
  15. package/src/execution/config.ts +25 -2
  16. package/src/execution/engine/__tests__/common/data-dir.test.ts +53 -0
  17. package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
  18. package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
  19. package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
  20. package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
  21. package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
  22. package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
  23. package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
  24. package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
  25. package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
  26. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
  27. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
  28. package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
  29. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
  30. package/src/execution/engine/__tests__/conformance/contract.relay.test.ts +342 -0
  31. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
  32. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
  33. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
  34. package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
  35. package/src/execution/engine/__tests__/engines-declaration.test.ts +36 -0
  36. package/src/execution/engine/__tests__/model-prompt.test.ts +85 -0
  37. package/src/execution/engine/__tests__/paths.test.ts +39 -0
  38. package/src/execution/engine/__tests__/registry.test.ts +120 -0
  39. package/src/execution/engine/__tests__/routing.test.ts +231 -0
  40. package/src/execution/engine/common/data-dir.ts +62 -0
  41. package/src/execution/engine/common/errors.ts +183 -0
  42. package/src/execution/engine/common/event-journal.ts +254 -0
  43. package/src/execution/engine/common/journal-replay.ts +62 -0
  44. package/src/execution/engine/common/kill-chain.ts +221 -0
  45. package/src/execution/engine/common/nesting-guard.ts +50 -0
  46. package/src/execution/engine/common/persona-router.ts +108 -0
  47. package/src/execution/engine/common/pool-manager.ts +226 -0
  48. package/src/execution/engine/common/schema-emulation.ts +189 -0
  49. package/src/execution/engine/common/session-view-projection.ts +51 -0
  50. package/src/execution/engine/engine-discovery.ts +65 -0
  51. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
  52. package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
  53. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
  54. package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
  55. package/src/execution/engine/engines/pi/reader.ts +48 -0
  56. package/src/execution/engine/engines/pi/registration.ts +35 -0
  57. package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
  58. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
  59. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
  60. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
  61. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
  62. package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
  63. package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +64 -0
  64. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
  65. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +567 -0
  66. package/src/execution/engine/engines/zcode/constants.ts +43 -0
  67. package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
  68. package/src/execution/engine/engines/zcode/launcher.ts +161 -0
  69. package/src/execution/engine/engines/zcode/parser.ts +436 -0
  70. package/src/execution/engine/engines/zcode/preparer.ts +363 -0
  71. package/src/execution/engine/engines/zcode/reader.ts +381 -0
  72. package/src/execution/engine/engines/zcode/registration.ts +37 -0
  73. package/src/execution/engine/engines/zcode/zcode-engine.ts +648 -0
  74. package/src/execution/engine/host-task-spec.ts +47 -0
  75. package/src/execution/engine/model-prompt.ts +59 -0
  76. package/src/execution/engine/paths.ts +42 -0
  77. package/src/execution/engine/port.ts +153 -0
  78. package/src/execution/engine/registry.ts +123 -0
  79. package/src/execution/engine/routing.ts +218 -0
  80. package/src/execution/engine/types.ts +304 -0
  81. package/src/execution/execute-options-mapper.ts +5 -1
  82. package/src/execution/execution-record.ts +6 -0
  83. package/src/execution/model-resolver.ts +6 -0
  84. package/src/execution/pi-invocation.ts +32 -2
  85. package/src/execution/record-entry.ts +14 -0
  86. package/src/execution/record-store.ts +34 -0
  87. package/src/execution/relay-env.ts +37 -0
  88. package/src/execution/session-runner.ts +24 -0
  89. package/src/execution/stream-sink.ts +26 -0
  90. package/src/execution/subagent-service.ts +249 -11
  91. package/src/execution/subprocess-agent-runner.ts +196 -14
  92. package/src/execution/types.ts +56 -0
  93. package/src/index.ts +46 -1
  94. package/src/interface/command-actions.ts +72 -8
  95. package/src/interface/subagent-actions.ts +8 -2
  96. package/src/interface/subagent-tool.ts +6 -0
  97. package/src/interface/subagents.ts +198 -30
  98. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +5 -2
  99. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +3 -3
  100. package/src/orchestration/models/types.ts +7 -0
  101. package/src/orchestration/worker-script-builder.ts +5 -2
  102. package/src/shared/meta-parser.ts +5 -1
  103. package/src/shared/resource-meta.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "8.3.0",
3
+ "version": "8.5.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -23,6 +23,7 @@
23
23
  "skills/",
24
24
  "workflows/",
25
25
  "scripts/",
26
+ "relay/",
26
27
  "src/index.ts",
27
28
  "src/execution/",
28
29
  "src/injectors/",
@@ -31,7 +32,20 @@
31
32
  "src/shared/"
32
33
  ],
33
34
  "xyz-agent": {
34
- "role": "universal"
35
+ "role": "universal",
36
+ "subagentEngines": [
37
+ "pi",
38
+ "zcode"
39
+ ],
40
+ "startupConfig": [
41
+ {
42
+ "path": "subagents/config.json",
43
+ "content": {
44
+ "version": 1,
45
+ "maxConcurrent": 6
46
+ }
47
+ }
48
+ ]
35
49
  },
36
50
  "pi": {
37
51
  "extensions": [
@@ -47,9 +61,9 @@
47
61
  "dependencies": {
48
62
  "ajv": "^8.20.0",
49
63
  "yaml": "^2.9.0",
50
- "@xyz-agent/extension-protocol": "0.6.0",
51
64
  "@xyz-agent/session-delivery": "0.2.0",
52
65
  "@zhushanwen/pi-extension-logger": "0.3.0",
66
+ "@xyz-agent/extension-protocol": "0.7.0",
53
67
  "@zhushanwen/pi-file-lock": "0.1.2"
54
68
  },
55
69
  "peerDependencies": {
@@ -57,7 +71,7 @@
57
71
  "@earendil-works/pi-coding-agent": "^0.84.1",
58
72
  "@earendil-works/pi-tui": "^0.84.1",
59
73
  "typebox": "*",
60
- "@zhushanwen/pi-pending-notifications": "0.3.5",
74
+ "@zhushanwen/pi-pending-notifications": "0.4.0",
61
75
  "@zhushanwen/pi-structured-output": "5.0.2"
62
76
  },
63
77
  "peerDependenciesMeta": {
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * subagent relay 代理 CLI(E-1,docs/architecture/subagent-realtime-channel.md §3)。
4
+ *
5
+ * 角色:双面字节泵——extension 眼中的 pi 子进程(stdio 与 spawn 真实 pi 字节级同构:
6
+ * 逐行 JSONL RPC / get_state 握手由 extension 侧 session-runner 原样驱动,代理只搬运字节)
7
+ * × runtime 眼中的 relay 客户端(本地 socket,先握手后转发)。协议不假设 stdio 是文本
8
+ * 通道,payload 一律 base64 封装保字节精确。
9
+ *
10
+ * ============================ 镜像常量表 ============================
11
+ * SSOT = src/execution/relay-env.ts(零依赖脚本不能 import workspace 包,只能内嵌镜像;
12
+ * 改名/改值必须双侧同步,一致性由 E-3 conformance relay 变体断言锁定——设计 §10-5):
13
+ *
14
+ * | relay-env.ts 常量 | 本文件常量 | 值 |
15
+ * |---------------------------|---------------------------|-----------------------------|
16
+ * | RELAY_ENV_SOCKET | RELAY_ENV_SOCKET | 'XYZ_SUBAGENT_RELAY_SOCKET' |
17
+ * | RELAY_ENV_SESSION_ID | RELAY_ENV_SESSION_ID | 'XYZ_SUBAGENT_RELAY_SESSION_ID' |
18
+ * | RELAY_ENV_RECORD_ID | RELAY_ENV_RECORD_ID | 'XYZ_SUBAGENT_RELAY_RECORD_ID' |
19
+ * | RELAY_PROTOCOL_VERSION | RELAY_PROTOCOL_VERSION | 1 |
20
+ * | RELAY_EXIT_CODES.* | RELAY_EXIT_CODES.* | 10/11/12/13 |
21
+ *
22
+ * 不镜像:RELAY_ENV_NODE / RELAY_ENV_SCRIPT 由 runtime(注入)与 extension(激活判定 +
23
+ * spawn 组装)消费,代理零消费(不解析自身执行器路径)——镜像无消费方只会积累
24
+ * unused 代码;激活契约的锁定由 pi-invocation 侧测试与 conformance relay 变体承担。
25
+ *
26
+ * ============================ 协议摘要(§3.1) ============================
27
+ * - 握手:连接后第一帧(单行 JSONL,代理 → runtime)
28
+ * {"v":1,"kind":"handshake","mainSessionId":...,"recordId":...,"argv":[...],"env":{...},"cwd":...}
29
+ * - runtime 回 {"v":1,"kind":"accept"}(进入泵模式)或
30
+ * {"kind":"reject","reason":"version","supported":[1]}(→ 退出码 10)
31
+ * - 数据帧 {"v":1,"kind":"data","dir":...,"b64":...}:
32
+ * dir="down"(代理 stdin → runtime → 真实 pi stdin)/ "up"(→ 代理 stdout)/
33
+ * "up-stderr"(→ 代理 stderr)。方向以「数据流向真实 pi」为参照:下行 = 流向 pi。
34
+ * - 退出帧 {"kind":"exit","code":C,"signal":S} → 代理以相同 code/signal 退出
35
+ * - socket 断(close/error/EOF)→ 退出码 12:socket 是代理的生命线,断 = runtime
36
+ * 崩溃等场景下 extension 侧感知「子进程死亡」的机制(崩溃矩阵②),不自杀会变孤儿。
37
+ *
38
+ * ============================ 零依赖约束 ============================
39
+ * 禁 import 任何非 node-builtin 模块:本脚本被 ELECTRON_RUN_AS_NODE(打包版 Electron
40
+ * 内嵌 node)或 dev node 独立执行,无 node_modules 解析保障(设计 §3.2 形态 B)。
41
+ * 同理不读配置文件、不写文件系统(pid 文件归 runtime 侧注册表)。
42
+ */
43
+
44
+ import net from "node:net";
45
+ import os from "node:os";
46
+
47
+ // ---- 镜像常量(与 relay-env.ts 逐字一致,见头部对照表)----
48
+ const RELAY_ENV_SOCKET = "XYZ_SUBAGENT_RELAY_SOCKET";
49
+ const RELAY_ENV_SESSION_ID = "XYZ_SUBAGENT_RELAY_SESSION_ID";
50
+ const RELAY_ENV_RECORD_ID = "XYZ_SUBAGENT_RELAY_RECORD_ID";
51
+
52
+ const RELAY_PROTOCOL_VERSION = 1;
53
+
54
+ const RELAY_EXIT_CODES = {
55
+ VERSION_MISMATCH: 10,
56
+ SOCKET_UNREACHABLE: 11,
57
+ SOCKET_CLOSED: 12,
58
+ MISSING_IDENTITY: 13,
59
+ };
60
+
61
+ /** 首次连接失败到重试的间隔(毫秒)。设计未定值;取短值——只需覆盖 runtime 建 socket 与主 pi spawn 的毫秒级窗口。 */
62
+ const CONNECT_RETRY_DELAY_MS = 200;
63
+ /** 退出前等待 stderr/stdout flush 的兜底上限(毫秒):pipe 写是异步的,process.exit 会丢未 flush 数据。 */
64
+ const FLUSH_TIMEOUT_MS = 1000;
65
+
66
+ const stdin = process.stdin;
67
+ const stdout = process.stdout;
68
+ const stderr = process.stderr;
69
+
70
+ /** 当前 socket 连接(connect 成功后赋值;重试期间为 null)。 */
71
+ let sock = null;
72
+ /** 握手已被 runtime accept,进入双向泵模式。 */
73
+ let handshakeAccepted = false;
74
+ /** 退出流程已启动(幂等守卫——error 后必发 close,防双退出路径竞态)。 */
75
+ let exiting = false;
76
+ /** socket 按行解析的残留缓冲(JSONL 帧)。 */
77
+ let lineBuf = "";
78
+
79
+ // 崩溃矩阵③(代理自身异常死)的兜底:任何未捕获异常也走 12 语义退出 + stderr 留诊断,
80
+ // 保证 extension 侧看到确定的非零退出而非挂死。必须在 main() 调用前注册——main 同步
81
+ // 执行期(模块加载阶段)的异常不会触发「之后」才注册的 handler,会直接以裸 code 1 崩溃。
82
+ process.on("uncaughtException", (err) => {
83
+ fail(
84
+ RELAY_EXIT_CODES.SOCKET_CLOSED,
85
+ `uncaught exception: ${err instanceof Error ? err.stack : String(err)}`,
86
+ );
87
+ });
88
+
89
+ main();
90
+
91
+ function main() {
92
+ // 启动自检(独立防御):激活三 env 由 extension 的 isRelayActive 保证(全有或全无),
93
+ // 归属两 env 由 buildChildEnv 注入——代理不信任上游,缺失即拒绝启动,防无归属帧污染广播。
94
+ const missing = [];
95
+ if (!process.env[RELAY_ENV_SESSION_ID]) missing.push(RELAY_ENV_SESSION_ID);
96
+ if (!process.env[RELAY_ENV_RECORD_ID]) missing.push(RELAY_ENV_RECORD_ID);
97
+ if (missing.length > 0) {
98
+ fail(
99
+ RELAY_EXIT_CODES.MISSING_IDENTITY,
100
+ `identity env missing: ${missing.join(", ")}(应由 extension buildChildEnv 注入;` +
101
+ `缺失时继续运行会产生无归属的 tee 帧)`,
102
+ );
103
+ return;
104
+ }
105
+ const socketPath = process.env[RELAY_ENV_SOCKET];
106
+ if (!socketPath) {
107
+ fail(
108
+ RELAY_EXIT_CODES.MISSING_IDENTITY,
109
+ `${RELAY_ENV_SOCKET} not set(应由 runtime 注入;缺失意味着 relay 激活条件不完整)`,
110
+ );
111
+ return;
112
+ }
113
+ connectWithRetry(socketPath);
114
+ }
115
+
116
+ /** 连接 socket:失败重试一次,再失败退出码 11(runtime 未就绪/路径过期)。 */
117
+ function connectWithRetry(socketPath) {
118
+ let attempt = 0;
119
+ const tryConnect = () => {
120
+ attempt += 1;
121
+ const s = net.createConnection(socketPath);
122
+ let connected = false;
123
+ s.once("connect", () => {
124
+ connected = true;
125
+ sock = s;
126
+ onConnected(s, socketPath);
127
+ });
128
+ // 未 connect 的 error 走重试/失败;已 connect 的 close = 生命线断(退出码 12)。
129
+ s.once("error", (err) => {
130
+ s.destroy();
131
+ if (attempt <= 1) {
132
+ setTimeout(() => {
133
+ if (!exiting) tryConnect();
134
+ }, CONNECT_RETRY_DELAY_MS);
135
+ } else {
136
+ fail(
137
+ RELAY_EXIT_CODES.SOCKET_UNREACHABLE,
138
+ `relay socket unreachable after retry: ${socketPath} (${err.message}). ` +
139
+ `Recovery: 重试任务;持续失败请重启 xyz-agent(runtime 未运行或已重启)`,
140
+ );
141
+ }
142
+ });
143
+ s.on("close", () => {
144
+ if (connected && !exiting) {
145
+ fail(
146
+ RELAY_EXIT_CODES.SOCKET_CLOSED,
147
+ "relay socket closed by runtime(生命线断,可能是 runtime 崩溃/重启). " +
148
+ "Recovery: 重启 xyz-agent 后重试任务",
149
+ );
150
+ }
151
+ });
152
+ };
153
+ tryConnect();
154
+ }
155
+
156
+ /** 连接建立:发握手帧 + 挂 socket 侧监听(数据泵监听在 accept 后才挂,见 startPump)。 */
157
+ function onConnected(s) {
158
+ const handshake = {
159
+ v: RELAY_PROTOCOL_VERSION,
160
+ kind: "handshake",
161
+ mainSessionId: process.env[RELAY_ENV_SESSION_ID],
162
+ recordId: process.env[RELAY_ENV_RECORD_ID],
163
+ argv: process.argv.slice(2),
164
+ env: { ...process.env },
165
+ cwd: process.cwd(),
166
+ };
167
+ s.write(JSON.stringify(handshake) + "\n");
168
+
169
+ // 帧本体是 JSON 文本(payload 已 b64 化),utf8 安全;setEncoding 按字符边界切分,
170
+ // 多字节 UTF-8(中文 argv/env/cwd 值)跨 chunk 不撕裂——与 session-runner.ts stdout 同款先例。
171
+ s.setEncoding("utf8");
172
+ s.on("data", (chunk) => {
173
+ lineBuf += chunk;
174
+ let nl;
175
+ while ((nl = lineBuf.indexOf("\n")) >= 0) {
176
+ const line = lineBuf.slice(0, nl);
177
+ lineBuf = lineBuf.slice(nl + 1);
178
+ handleLine(line);
179
+ }
180
+ });
181
+ // 背压方向 1 的恢复:socket 写缓冲排空后恢复读 stdin。
182
+ s.on("drain", () => {
183
+ if (!exiting) stdin.resume();
184
+ });
185
+ }
186
+
187
+ /** 逐帧分发:握手期是严格状态机(必须先 accept/reject),泵期宽容(转发优先)。 */
188
+ function handleLine(line) {
189
+ if (!line.trim()) return;
190
+ let frame;
191
+ try {
192
+ frame = JSON.parse(line);
193
+ } catch {
194
+ fail(
195
+ RELAY_EXIT_CODES.SOCKET_CLOSED,
196
+ `unparseable relay frame (len ${line.length}): ${line.slice(0, 80)}`,
197
+ );
198
+ return;
199
+ }
200
+ if (!handshakeAccepted) {
201
+ if (frame.kind === "reject") {
202
+ if (frame.reason === "version") {
203
+ fail(
204
+ RELAY_EXIT_CODES.VERSION_MISMATCH,
205
+ `relay protocol version mismatch: agent v${RELAY_PROTOCOL_VERSION}, ` +
206
+ `runtime supports ${JSON.stringify(frame.supported)}. ` +
207
+ "Recovery: 升级 xyz-agent(runtime 与代理资产同包分发,版本不一致意味着安装损坏,重装应用)",
208
+ );
209
+ return;
210
+ }
211
+ // 非 version 的 reject(如握手校验失败)无专用退出码:socket 语义不可用,归 12。
212
+ fail(
213
+ RELAY_EXIT_CODES.SOCKET_CLOSED,
214
+ `handshake rejected by runtime: ${String(frame.reason)}`,
215
+ );
216
+ return;
217
+ }
218
+ if (frame.kind === "accept") {
219
+ handshakeAccepted = true;
220
+ startPump();
221
+ return;
222
+ }
223
+ fail(
224
+ RELAY_EXIT_CODES.SOCKET_CLOSED,
225
+ `unexpected frame before accept: ${String(frame.kind)}`,
226
+ );
227
+ return;
228
+ }
229
+ if (frame.kind === "data" && (frame.dir === "up" || frame.dir === "up-stderr")) {
230
+ if (typeof frame.b64 === "string") {
231
+ writeUp(Buffer.from(frame.b64, "base64"), frame.dir);
232
+ }
233
+ return;
234
+ }
235
+ if (frame.kind === "exit") {
236
+ exitByFrame(frame.code, frame.signal);
237
+ return;
238
+ }
239
+ // 未知帧 kind / down 方向回发:同包同版本下不应出现。宽容忽略保转发——编排通路
240
+ //(extension 的 RPC 消费)优先于严格协议报错,留前向兼容演进余地。
241
+ stderr.write(`[relay] ignoring unexpected frame: kind=${String(frame.kind)} dir=${String(frame.dir)}\n`);
242
+ }
243
+
244
+ /**
245
+ * 双向泵启动(accept 后):
246
+ * - down:stdin chunk → base64 帧 → socket;socket 写缓冲高水位时 pause stdin、drain 恢复。
247
+ * - up:见 writeUp。
248
+ */
249
+ function startPump() {
250
+ stdin.on("data", (chunk) => {
251
+ if (exiting || !sock || sock.destroyed) return;
252
+ const ok = sock.write(
253
+ JSON.stringify({
254
+ v: RELAY_PROTOCOL_VERSION,
255
+ kind: "data",
256
+ dir: "down",
257
+ b64: chunk.toString("base64"),
258
+ }) + "\n",
259
+ );
260
+ if (!ok) stdin.pause();
261
+ });
262
+ // stdin error = 宿主侧管道断(extension 死了)——生命线同类,归 12。
263
+ stdin.on("error", (err) => {
264
+ fail(RELAY_EXIT_CODES.SOCKET_CLOSED, `stdin broken (host may have exited): ${err.message}`);
265
+ });
266
+ // extension 关闭代理 stdin(EOF):协议无 stdin-EOF 帧且不新增(演进克制)——现状
267
+ // spawn 语义下 stdin 生命周期与进程 kill 绑定,runtime 的 exit 帧才是权威退出信号,
268
+ // 此处保持连接等待,不自行退出。
269
+ stdin.on("end", () => {});
270
+ stdin.resume();
271
+ }
272
+
273
+ /**
274
+ * up 帧写出(stdout/stderr)。背压:write 返回 false = 该出口高水位 → 暂停 socket 读
275
+ * (数据滞留 socket 接收缓冲,不丢字节不撑内存);该出口 drain 且另一出口也无 pending
276
+ * drain 时恢复读。pending drain 监听的存在性即该出口的拥堵标志。
277
+ */
278
+ function writeUp(buf, dir) {
279
+ if (exiting) return;
280
+ const target = dir === "up-stderr" ? stderr : stdout;
281
+ if (target.write(buf)) return;
282
+ target.once("drain", maybeResumeSocket);
283
+ sock.pause();
284
+ }
285
+
286
+ function maybeResumeSocket() {
287
+ if (stdout.listenerCount("drain") === 0 && stderr.listenerCount("drain") === 0 && !exiting && sock) {
288
+ sock.resume();
289
+ }
290
+ }
291
+
292
+ /** exit 帧处理:signal 优先(extension 侧 close(code, signal) 与真实 pi 死法对齐),否则按 code。 */
293
+ function exitByFrame(code, signal) {
294
+ const sig = typeof signal === "string" && signal ? signal : null;
295
+ if (sig) {
296
+ const signum = os.constants.signals[sig];
297
+ if (typeof signum === "number") {
298
+ exiting = true;
299
+ try {
300
+ stdin.destroy();
301
+ } catch {}
302
+ try {
303
+ sock?.destroy();
304
+ } catch {}
305
+ // 真实信号自杀:未注册 handler 的终止类信号必然终止进程,spawn 方观察到
306
+ // (code=null, signal=sig),与真实 pi 被信号杀死完全同构。信号路径不等 stdout
307
+ // flush——内核 pipe 缓冲中已 write 的字节对端仍可读,node 内部队列丢弃与真实
308
+ // 进程被杀的行为一致。万一信号被环境截胡(理论不发生),fallback 128+signum。
309
+ process.kill(process.pid, sig);
310
+ process.exit(128 + signum);
311
+ }
312
+ fail(RELAY_EXIT_CODES.SOCKET_CLOSED, `exit frame carried unmappable signal: ${sig}`);
313
+ return;
314
+ }
315
+ exitGracefully(typeof code === "number" ? code : 1);
316
+ }
317
+
318
+ /** 失败退出:先确保 stderr 诊断行 flush 到对端,再走通用退出清理。 */
319
+ function fail(code, message) {
320
+ if (exiting) return;
321
+ exiting = true;
322
+ writeFlushed(stderr, `[relay] ${message}\n`, () => flushStdoutThenExit(code));
323
+ }
324
+
325
+ /** 正常退出(exit 帧 code 路径):尽力 flush stdout 已排队字节后退出。 */
326
+ function exitGracefully(code) {
327
+ if (exiting) return;
328
+ exiting = true;
329
+ flushStdoutThenExit(code);
330
+ }
331
+
332
+ /**
333
+ * 写一段数据并等它 flush(callback / 1s 兜底 / 写异常三路都推进)。
334
+ * 为什么必须等:process.exit 立即返回,pipe 模式的异步写会丢——错误诊断丢了对端就
335
+ * 只剩裸退出码,违反「错误信息必须可操作」。
336
+ */
337
+ function writeFlushed(stream, data, next) {
338
+ let advanced = false;
339
+ const advance = () => {
340
+ if (advanced) return;
341
+ advanced = true;
342
+ next();
343
+ };
344
+ // 定时器保持 ref:进程因未销毁的流而存活,到期必触发;unref 的 timer 在无其他
345
+ // 事件源时不会执行,会导致静默以 code 0 自然退出(误导 extension「成功」)。
346
+ const timer = setTimeout(advance, FLUSH_TIMEOUT_MS);
347
+ try {
348
+ stream.write(data, () => {
349
+ clearTimeout(timer);
350
+ advance();
351
+ });
352
+ } catch {
353
+ clearTimeout(timer);
354
+ advance();
355
+ }
356
+ }
357
+
358
+ /** stdout 队列已空则立即退,否则等 drain(1s 兜底防对端停读挂死)。 */
359
+ function flushStdoutThenExit(code) {
360
+ if (stdout.writableLength === 0) {
361
+ forceExit(code);
362
+ return;
363
+ }
364
+ let advanced = false;
365
+ const advance = () => {
366
+ if (advanced) return;
367
+ advanced = true;
368
+ forceExit(code);
369
+ };
370
+ // 定时器保持 ref(理由同 writeFlushed):进程因未销毁的流而存活,到期必触发。
371
+ const timer = setTimeout(advance, FLUSH_TIMEOUT_MS);
372
+ stdout.once("drain", () => {
373
+ clearTimeout(timer);
374
+ advance();
375
+ });
376
+ }
377
+
378
+ /**
379
+ * 最终退出:destroy stdin 与 socket(防流句柄悬挂阻塞退出);stdout/stderr 不 destroy
380
+ * ——destroy 会丢弃已排队未 flush 的转发字节,协议保真优先(flush 等待已在上游完成)。
381
+ */
382
+ function forceExit(code) {
383
+ try {
384
+ stdin.destroy();
385
+ } catch {}
386
+ try {
387
+ sock?.destroy();
388
+ } catch {}
389
+ process.exit(code);
390
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: subagent-ext-config
3
+ description: "使用或排查 @zhushanwen/pi-subagent-workflow 的引擎路由配置时加载。说明 config.json 三环境路径与动态推导方法、字段表(defaultEngine / engineRouting.strict / maxConcurrent)、三层路由优先级、生效时机(新 session 生效)、probe 缓存语义、验证步骤与常见错误。触发词:subagent 引擎配置、切换 subagent 引擎、defaultEngine、zcode 派发、subagent 配置在哪、engineFallback、engine_not_found、subagent-ext-config。"
4
+ ---
5
+
6
+ # subagent-workflow 引擎路由配置指南
7
+
8
+ > @zhushanwen/pi-subagent-workflow:subagent 派发扩展。P4 起支持多引擎路由(pi / zcode),路由偏好来自 config.json + 调用点覆盖。本指南讲清配置位置、字段语义、生效时机与排查路径。
9
+
10
+ **重要前提**:配置在 pi 子进程启动与每次 `session_start` 时读取——**改完 config.json 后必须新建 session 才生效**,当前 session 内不重读。用户问「改了没生效」时先确认这一点,不要怀疑文件路径。
11
+
12
+ ## 配置文件在哪(三环境)
13
+
14
+ config.json 位于 pi agent 目录下的 `subagents/config.json`,随环境不同:
15
+
16
+ | 环境 | 路径 |
17
+ |------|------|
18
+ | 独立 pi CLI | `~/.pi/agent/subagents/config.json` |
19
+ | xyz-agent dev | `~/.xyz-agent-dev/pi/agent/subagents/config.json` |
20
+ | xyz-agent prod | `~/.xyz-agent/pi/agent/subagents/config.json` |
21
+
22
+ **动态推导(推荐)**:agentDir 由 pi 核心 `getAgentDir()` 决定(读 `PI_CODING_AGENT_DIR`,默认 `~/.pi/agent`);xyz-agent 通过 `XYZ_AGENT_DATA_DIR` 隔离数据目录。排查时先查这两个 env 变量组合出实际路径(`<agentDir>/subagents/config.json`),不要假设单一环境——写错环境的配置文件改了也不生效。
23
+
24
+ 文件不存在 / JSON 解析失败 / 字段缺失时全部回默认配置,不报错。旧版 `categories` / `fallback` / `yoloByDefault` 等字段读取时忽略(模型解析已退化为「主 agent model 优先」)。
25
+
26
+ ## 字段表(sanitize 语义)
27
+
28
+ | 字段 | 类型 | 默认 | 说明 |
29
+ |------|------|------|------|
30
+ | `defaultEngine` | string | 缺省由路由层落 `'pi'` | 全局默认引擎。合法值 `'pi'` / `'zcode'`。坏值(非字符串/空串)**静默忽略**回缺省 pi,不报错——排查「改了 defaultEngine 却还在用 pi」时先检查 JSON 值合法性 |
31
+ | `engineRouting.strict` | boolean | `false` | `true` = 一切 probe 失败直接报错、不做兜底回退。仅认 strict 布尔键,其余键忽略 |
32
+ | `maxConcurrent` | number | `6` | subagent 并发池大小。正整数,非正整数/非整数回默认 |
33
+
34
+ 示例(最小可用):
35
+
36
+ ```json
37
+ {
38
+ "version": 1,
39
+ "defaultEngine": "zcode",
40
+ "engineRouting": { "strict": false },
41
+ "maxConcurrent": 6
42
+ }
43
+ ```
44
+
45
+ ## 三层路由优先级
46
+
47
+ 一次 subagent 派发用哪个引擎,按以下顺序决定(高优先级覆盖低优先级):
48
+
49
+ | 优先级 | 来源 |
50
+ |------|------|
51
+ | 1 | `subagents` 工具调用的 `engine` 参数(单次指定) |
52
+ | 2 | agent `.md` frontmatter 的 `engine` 字段 |
53
+ | 3 | config.json 的 `defaultEngine` |
54
+
55
+ 显式指定(层级 1/2)属「守卫命中」——probe 失败**不兜底**、直接报 `engine_probe_failed`;仅全局默认任务才走 fallback 兜底回 pi。
56
+
57
+ ## 生效时机与 probe 缓存
58
+
59
+ - **配置读取**:pi 子进程启动 + 每次 `session_start` 各读一次,session 内不重读。改配置 → 新建 session 生效。
60
+ - **probe 缓存**:引擎探针(zcode CLI 存在性/版本检查)成功或失败均缓存直返,**进程存活期内不重探**。
61
+ - **engineFallback 留痕条件**:兜底回 pi(record 带 `engineFallback` 标记)只在探针**未缓存**时触发。同一 session 内先 probe 成功后 CLI 损坏,不会再触发兜底。要复现/验证 fallback 场景,必须新建 session 重置缓存。
62
+
63
+ ## 验证步骤
64
+
65
+ 改完配置后:
66
+
67
+ 1. **新建 session**(必须——当前 session 不重读配置)。
68
+ 2. 让主 agent 派一个 subagent(例:用 `subagents` 工具发个简单任务)。
69
+ 3. xyz-agent 侧边栏 **Agents tab** 看该项最左的引擎 icon(pi / zcode)——这是统一验证面。
70
+ 4. journal 落点 `~/.xyz-agent-dev/engines/<engineId>/` **仅适用非 pi 引擎**(zcode 分支建 journal);pi 分支不建 journal,pi 任务以 icon 为验证面。
71
+
72
+ ## 常见错误排查
73
+
74
+ | 症状 | 原因与处置 |
75
+ |------|------|
76
+ | `engine_not_found` | engine id 未注册。检查拼写,合法值仅 `pi` / `zcode` |
77
+ | zcode 任务传 `conversation` / `fork` / `worktree` 被预检拒绝 | 这些是 pi 专属能力,zcode 不支持。改用 `engine: pi` 或不传该参数重试(预检在 record 创建前同步拒绝,可立即换引擎) |
78
+ | 改了 `defaultEngine` 没生效 | 两种可能:① 当前 session 不重读配置——新建 session;② 值非法被静默忽略回 pi——核对 JSON 值 |
79
+ | 期望 fallback 回 pi 却报错 | 显式指定引擎(工具参数/frontmatter)属守卫命中,probe 失败不兜底直接报错;只有走 `defaultEngine` 的任务才兜底 |
80
+ | probe 结果与 CLI 实际状态不符 | 探针进程存活期内缓存——CLI 刚装好/刚损坏,需新建 session 重置缓存 |
@@ -22,6 +22,8 @@ vi.mock("node:os", async (importOriginal) => {
22
22
  import { lintAgentMeta } from "../../orchestration/script-lint.ts";
23
23
  import { parseResourceMeta } from "../../shared/meta-parser.ts";
24
24
  import { AgentRegistry, parseAgentFrontmatter, parseAgentWithMeta } from "../agent-registry.ts";
25
+ import type { EnginePort } from "../engine/port.ts";
26
+ import { clearEngines, registerEngine } from "../engine/registry.ts";
25
27
 
26
28
  // ============================================================
27
29
  // helpers
@@ -251,3 +253,111 @@ describe("builtin agents 数据合规", () => {
251
253
  expect(reg.loadByPath(path.join(AGENTS_DIR, "doc-reviewer.md"))?.tools).toEqual(["read", "grep", "structured-output"]);
252
254
  });
253
255
  });
256
+
257
+ // ============================================================
258
+ // engine 字段(P4 D9:frontmatter 主通道 + 解析期注册表校验)
259
+ // ============================================================
260
+
261
+ describe("parseAgentWithMeta engine 字段(P4 路由)", () => {
262
+ beforeEach(() => {
263
+ // 解析期校验消费注册表——测试内注册假引擎(惰性工厂无副作用)
264
+ clearEngines();
265
+ // 工厂恒 throw:解析期校验只查注册表存在性,不实例化(惰性工厂契约)
266
+ const neverInstantiate = (): EnginePort => {
267
+ throw new Error("parse-time validation must not instantiate engines");
268
+ };
269
+ registerEngine("pi", neverInstantiate);
270
+ registerEngine("zcode", neverInstantiate);
271
+ });
272
+ afterEach(() => {
273
+ clearEngines();
274
+ });
275
+
276
+ it("frontmatter engine 进 config.engine(结构化路径,IF1)", () => {
277
+ const { config, meta } = parseAgentWithMeta(
278
+ "/x/reviewer.md",
279
+ `---
280
+ name: reviewer
281
+ description: review agent
282
+ engine: zcode
283
+ ---
284
+ body`,
285
+ );
286
+ expect(config.engine).toBe("zcode");
287
+ expect(meta?.kind === "agent" && meta.engine).toBe("zcode");
288
+ });
289
+
290
+ it("IF1 未通过(缺 description)时 legacy fallback 取 engine(配置不丢,与 model 同判)", () => {
291
+ const { config } = parseAgentWithMeta(
292
+ "/x/reviewer.md",
293
+ `---
294
+ name: reviewer
295
+ engine: zcode
296
+ ---
297
+ body`,
298
+ );
299
+ expect(config.engine).toBe("zcode");
300
+ });
301
+
302
+ it("未注册 engine id:解析期抛 EngineNotFoundError,文案含注册清单与文件路径", () => {
303
+ expect(() =>
304
+ parseAgentWithMeta(
305
+ "/x/reviewer.md",
306
+ `---
307
+ name: reviewer
308
+ description: review agent
309
+ engine: nonexistent-engine
310
+ ---
311
+ body`,
312
+ ),
313
+ ).toThrowError(/engine_not_found: engine 'nonexistent-engine'/);
314
+ expect(() =>
315
+ parseAgentWithMeta(
316
+ "/x/reviewer.md",
317
+ `---
318
+ name: reviewer
319
+ description: review agent
320
+ engine: nonexistent-engine
321
+ ---
322
+ body`,
323
+ ),
324
+ ).toThrowError(/Registered engines: pi, zcode/);
325
+ expect(() =>
326
+ parseAgentWithMeta(
327
+ "/x/my-agent.md",
328
+ `---
329
+ name: my-agent
330
+ description: d
331
+ engine: nonexistent-engine
332
+ ---
333
+ body`,
334
+ ),
335
+ ).toThrowError(/Source: \/x\/my-agent\.md/);
336
+ });
337
+
338
+ it("已注册 id(pi/zcode):解析通过,不抛", () => {
339
+ expect(() =>
340
+ parseAgentWithMeta(
341
+ "/x/reviewer.md",
342
+ `---
343
+ name: reviewer
344
+ description: review agent
345
+ engine: pi
346
+ ---
347
+ body`,
348
+ ),
349
+ ).not.toThrow();
350
+ });
351
+
352
+ it("无 engine 字段:config.engine 缺省(走全局默认层)", () => {
353
+ const { config } = parseAgentWithMeta(
354
+ "/x/worker.md",
355
+ `---
356
+ name: worker
357
+ description: d
358
+ ---
359
+ body`,
360
+ );
361
+ expect(config.engine).toBeUndefined();
362
+ });
363
+ });