@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
@@ -88,6 +88,15 @@ export type ExecutionMode = "background";
88
88
  *
89
89
  * 设计:AgentEvent 携带 updateFromEvent 收口进 record 所需的**全部数据**——
90
90
  * tool_end 带 result(供 turn.toolCalls 存完整 ToolCall),无需翻译层旁路累积。
91
+ *
92
+ * ACP 词汇对照(D11 注记级校准,零行为变更;新引擎实现者按本表对齐语义,
93
+ * 详见 docs/architecture/subagent-engine-gui-visibility.md §3.3 D11):
94
+ * text_delta / thinking_delta ↔ ACP content blocks(text / thinking)
95
+ * tool_start / tool_end ↔ ACP tool_call / tool_call_update
96
+ * turn_end / message_end ↔ ACP prompt turn 终态(stop_reason + usage)
97
+ * compaction ↔ ACP session/compaction
98
+ * 本协议以 pi 为语义锚点(D3)——命名不迁移,对照表仅保证未来 AcpEngine 适配器
99
+ * 与跨引擎 trace 映射的翻译成本最低。
91
100
  */
92
101
  export type AgentEvent =
93
102
  | { type: "tool_start"; toolName: string; args?: unknown }
@@ -384,6 +393,23 @@ export interface ExecutionRecord {
384
393
  * 向后兼容:旧 record 无此字段,按默认值处理。
385
394
  */
386
395
  readonly idleTimeoutMs?: number;
396
+ /**
397
+ * 实际执行引擎 id(P4 路由留痕,D9①)。创建时确定不可变;缺省(存量 record)
398
+ * = pi 投影(消费方零迁移)。持久化经 subagent-record entry。
399
+ */
400
+ readonly engine?: string;
401
+ /**
402
+ * 引擎 fallback 留痕(D9①:probe 失败路由回默认引擎)。GUI 警告条数据源;
403
+ * 缺省 = 无 fallback。持久化经 subagent-record entry。
404
+ */
405
+ readonly engineFallback?: { from: string; reason: string };
406
+ /**
407
+ * 引擎自描述定位符(U2:非 pi run resolve 后回填、终态迁移落 entry 前——run 前
408
+ * 缺省不可用)。sessionRef 整体透传(失败终态 sessionId 缺失时仍回填已有部分,
409
+ * 读侧①级降②级的防御形态);journalPath 为 retarget 后实际落盘路径。pi 分支不
410
+ * 回填(sessionFile 即定位符)。持久化经 subagent-record entry。
411
+ */
412
+ engineHandle?: { sessionRef: Record<string, string>; journalPath?: string; poolKey: string };
387
413
 
388
414
  // ── 状态(实时更新)──
389
415
  status: ExecutionStatus;
@@ -563,6 +589,16 @@ export interface ExecuteOptions {
563
589
  * 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms。
564
590
  */
565
591
  idleTimeoutMs?: number;
592
+ /**
593
+ * 实际执行引擎 id(P4 路由留痕):pi 引擎由 PiEngine.run 在还原 opts 时写入;
594
+ * 缺省(历史调用方不设)= pi 投影。createRecordForMode 读入 record identity。
595
+ */
596
+ engine?: string;
597
+ /**
598
+ * 引擎 fallback 留痕(D9①:probe 失败路由回默认引擎时由路由层写入)。
599
+ * from = 请求引擎 id,reason 恒 'engine_probe_failed'(GUI 警告条数据源)。
600
+ */
601
+ engineFallback?: { from: string; reason: string };
566
602
  // 注:fork 深度不从外部传入(曾暴露 parentForkDepth,改用 ALS 后 execute 内部从调用链派生,
567
603
  // 公开字段成为死字段误导调用方,已移除)。深度限制检查见 session-runner.ts 内部 RunOptions.parentForkDepth
568
604
  // (与历史残留的 types.ts RunOptions 同名不同 interface——后者已删除)。
@@ -731,6 +767,19 @@ export interface SubagentRecord {
731
767
  externalInstance?: AliveMarker;
732
768
  /** fork 模式下的 worktree handle。 */
733
769
  worktreeHandle?: WorktreeHandle;
770
+ /**
771
+ * 实际执行引擎 id(P4 路由留痕)。缺省 = pi 投影(存量 record 零迁移);
772
+ * GUI 警告条/引擎标记的数据源之一。
773
+ */
774
+ engine?: string;
775
+ /** 引擎 fallback 留痕(D9①:probe 失败路由回默认引擎)。GUI 警告条数据源。 */
776
+ engineFallback?: { from: string; reason: string };
777
+ /**
778
+ * 引擎自描述定位符(U1:EngineHandleData 的持久化消费面子集,引擎无关——
779
+ * sessionRef 整体透传不枚举内部键)。read 降级链①②级的数据源(runtime
780
+ * subagent-engine-history);缺省 = pi(走 JSONL 直读链)。
781
+ */
782
+ engineHandle?: { sessionRef: Record<string, string>; journalPath?: string; poolKey: string };
734
783
  }
735
784
 
736
785
  // ============================================================
@@ -747,6 +796,13 @@ export interface SubagentRecord {
747
796
  export interface SubagentsGlobalConfig {
748
797
  version: number;
749
798
  maxConcurrent: number;
799
+ /**
800
+ * 全局默认执行引擎(D9 三层优先级的最底层:调用参数 > agent frontmatter > 本值)。
801
+ * 缺省 'pi'(P4 路由层 DEFAULT_ENGINE_ID)。加载期只做类型校验,注册表校验归路由层。
802
+ */
803
+ defaultEngine?: string;
804
+ /** 引擎路由策略(D9①):strict=true 时一切 probe 失败直接报错(不 fallback)。 */
805
+ engineRouting?: { strict: boolean };
750
806
  }
751
807
 
752
808
  // ============================================================
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, 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,6 +24,15 @@ 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,
@@ -160,6 +169,22 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
160
169
  // 的 getLogger("subagents") 也能走 appendEntry。
161
170
  setPiHandle(pi);
162
171
 
172
+ // [P1 引擎接线] 组合根登记缺省引擎:进程级 SubagentService 单例(session_start 注入)
173
+ // 经 registry 以 'pi' 暴露——引擎获取从此统一走 getEngine(DEFAULT_ENGINE_ID),上层
174
+ // 不再硬编码「spawn pi」。幂等(registerEngine 覆盖语义),工厂惰性解析服务单例。
175
+ // P4 配置路由(agent frontmatter engine 字段 + 三层优先级)在本登记之上消费。
176
+ registerPiEngine();
177
+
178
+ // [P3 引擎接线] 登记 'zcode'(幂等同上)。惰性工厂:不触发 CLI/凭据探测,引擎被
179
+ // 实际选用(P4 路由或显式 getEngine('zcode'))才解析 deps。
180
+ registerZcodeEngine();
181
+
182
+ // [U7b] 引擎列表在 extension 模块加载时即同步 engines.json(不等 session_start——
183
+ // 用户体验拍板 2026-08-25:xyz-agent 打开后激活任意 session 的第一时间(含 TUI 等价
184
+ // 场景)GUI 引擎选择器就该有数据;session_start 处保留幂等重写兜底 jiti 双路径/
185
+ // 模块重载场景的刷新)。
186
+ syncEnginesFile(getAgentDir());
187
+
163
188
  // ════════════════════════════════════════════════════════════
164
189
  // subagents 域:tool + command + messageRenderer
165
190
  // ════════════════════════════════════════════════════════════
@@ -332,6 +357,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
332
357
  const sessionId = ctx.sessionManager.getSessionId();
333
358
  lsRef.lastSessionId = sessionId;
334
359
 
360
+ // [U7] 引擎列表同步 engines.json(幂等零写 + fail-safe;组合根注册已在
361
+ // extension 工厂体完成,此处 registry 已含全部引擎)
362
+ syncEnginesFile(agentDir);
363
+
335
364
  // skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
336
365
  // 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
337
366
  // 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
@@ -559,6 +588,22 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
559
588
  });
560
589
  });
561
590
 
591
+ // ════════════════════════════════════════════════════════════
592
+ // [U7] before_agent_start:引擎模型段注入(defaultEngine 非 pi 且引擎实现
593
+ // listModels 时追加 <available_<engine>_models>——每 turn 重判 config,改配置
594
+ // 后下一 turn 即生效;fail-safe 任何异常不注入不阻塞 agent loop)
595
+ // ════════════════════════════════════════════════════════════
596
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
597
+ try {
598
+ const service = getModelConfigService();
599
+ const append = service === null ? "" : buildEngineModelsPromptAppend(service.getGlobalConfig().defaultEngine);
600
+ if (append === "" || typeof event.systemPrompt !== "string") return undefined;
601
+ return { systemPrompt: `${event.systemPrompt}\n\n${append}` };
602
+ } catch {
603
+ return undefined;
604
+ }
605
+ });
606
+
562
607
  // ════════════════════════════════════════════════════════════
563
608
  // model_select:用户切换 model 时刷新缓存
564
609
  // ════════════════════════════════════════════════════════════
@@ -8,10 +8,15 @@
8
8
  * 设计为纯函数(无 ctx / service 依赖),便于独立单测,handler 只做薄分发。
9
9
  */
10
10
 
11
- /** /subagents RPC action 判别联合。 */
11
+ /** /subagents RPC action 判别联合。message/start 为 GUI 定向消息通道(设计 §3.3.3,
12
+ * 仅 RPC 分支消费;missing-args 携带 missing 字段供 handler 输出指明缺什么的 usage)。 */
12
13
  export type SubagentRpcAction =
13
14
  | { action: "cancel"; recordId: string }
14
15
  | { action: "cancel-missing-id" }
16
+ | { action: "message"; recordId: string; text: string }
17
+ | { action: "message-missing-args"; missing: "recordId" | "text" }
18
+ | { action: "start"; slug: string; task: string }
19
+ | { action: "start-missing-args"; missing: "slug" | "task" }
15
20
  | { action: "noop" };
16
21
 
17
22
  /** /workflows RPC action 判别联合。 */
@@ -46,25 +51,84 @@ function isRemovedLifecycleVerb(verb: string): verb is "pause" | "resume" {
46
51
  return REMOVED_LIFECYCLE_VERBS.has(verb as "pause" | "resume");
47
52
  }
48
53
 
54
+ /**
55
+ * 还原转义协议(设计 §3.3.3 / 探针 P3):字面 `\n`(反斜杠 + n 两字符)→ 真实换行、
56
+ * 字面 `\\`(两反斜杠)→ 单反斜杠。
57
+ *
58
+ * 与 runtime encodeDirectiveText(session-service.ts)互逆:composer 多行输入在
59
+ * client.prompt 传输前把真实换行编码为字面 \n、原生反斜杠编码为 \\(命令保持单行),
60
+ * extension 解析侧在此还原。反斜杠转义必须与换行转义在**单次遍历**里成对处理
61
+ * (交替分支 `\\\\|\\n`,两反斜杠优先匹配)——若只处理 \n,原文里的字面反斜杠+n
62
+ * (如路径 `C:\new`)会被误解码为换行,往返歧义。
63
+ */
64
+ function decodeNewlineEscapes(s: string): string {
65
+ return s.replace(/\\\\|\\n/g, (m) => (m === "\\\\" ? "\\" : "\n"));
66
+ }
67
+
68
+ /**
69
+ * 提取首个非空白 token 与其后剩余原文。
70
+ *
71
+ * 与 split(/\s+/) 不同:rest 保留 token 之后的全部原文(含空格/引号/换行转义),
72
+ * 供 message text / start task 的「剩余全量到字符串末尾」语义使用(设计 §3.3.3——
73
+ * pi 以首个空格拆命令名后 args 为其后全文,文本内的空格/引号必须原样保留)。
74
+ * rest 跳过 token 后的分隔空白(分隔符不属文本),但保留其后全部内容原样。
75
+ */
76
+ function splitFirstToken(s: string): { token: string; rest: string } | null {
77
+ const head = s.trimStart();
78
+ if (!head) return null;
79
+ const idx = head.search(/\s/);
80
+ if (idx === -1) return { token: head, rest: "" };
81
+ return { token: head.slice(0, idx), rest: head.slice(idx + 1).trimStart() };
82
+ }
83
+
49
84
  /**
50
85
  * 解析 /subagents RPC 命令字符串。
51
86
  *
52
87
  * 支持格式:
53
88
  * - `cancel <id>` → { action: "cancel", recordId }
54
89
  * - `cancel`(无 id)→ { action: "cancel-missing-id" }
90
+ * - `message <recordId> <text...>` → { action: "message", recordId, text }
91
+ * text 为第二 token 后的剩余全量(含空格/引号原样;字面 \n 还原为换行、字面 \\ 还原为
92
+ * 反斜杠——composer 定向消息经此协议编码,与 runtime encodeDirectiveText 互逆,设计 §3.3.3)
93
+ * - `message`(缺 recordId 或 text 为空白)→ { action: "message-missing-args", missing }
94
+ * - `start <slug> <task...>` → { action: "start", slug, task }(task 同 text 转义协议)
95
+ * - `start`(缺 slug 或 task 为空白)→ { action: "start-missing-args", missing }
55
96
  * - 其他(空 / 未知 action / 无参)→ { action: "noop" }
56
97
  *
57
- * noop 表示 GUI 端无对应程序化操作(GUI 已在 CommandPopover 屏蔽 /subagents 入口,
58
- * 此分支仅兜底手动 prompt)。
98
+ * missing-args 携带 missing 字段(缺哪个参数),handler 据此输出可操作的 usage
99
+ * 错误(全局规则:错误信息指向恢复动作)。noop 表示 GUI 端无对应程序化操作(GUI
100
+ * 已在 CommandPopover 屏蔽 /subagents 入口,此分支仅兜底手动 prompt)。
59
101
  */
60
102
  export function parseSubagentRpcCommand(argsStr: string): SubagentRpcAction {
61
- const args = argsStr.trim().split(/\s+/).filter(Boolean);
62
- if (args.length === 0) return { action: "noop" };
103
+ const first = splitFirstToken(argsStr);
104
+ if (!first) return { action: "noop" };
63
105
 
64
- const [verb, recordId] = args;
106
+ const { token: verb, rest } = first;
65
107
  if (verb === "cancel") {
66
- if (!recordId) return { action: "cancel-missing-id" };
67
- return { action: "cancel", recordId };
108
+ const idToken = splitFirstToken(rest);
109
+ if (!idToken) return { action: "cancel-missing-id" };
110
+ return { action: "cancel", recordId: idToken.token };
111
+ }
112
+ if (verb === "message" || verb === "start") {
113
+ // message 与 start 共用解析骨架,仅结果字段名不同(recordId/text vs slug/task)
114
+ const isMessage = verb === "message";
115
+ // 第二 token:message→recordId / start→slug;其后剩余全量(还原换行转义)为 text/task
116
+ const second = splitFirstToken(rest);
117
+ if (!second) {
118
+ return isMessage
119
+ ? { action: "message-missing-args", missing: "recordId" }
120
+ : { action: "start-missing-args", missing: "slug" };
121
+ }
122
+ // 先还原再判空:纯字面 \n 还原后是真实换行(whitespace),应在解析层拦截为缺参
123
+ const payload = decodeNewlineEscapes(second.rest);
124
+ if (!payload.trim()) {
125
+ return isMessage
126
+ ? { action: "message-missing-args", missing: "text" }
127
+ : { action: "start-missing-args", missing: "task" };
128
+ }
129
+ return isMessage
130
+ ? { action: "message", recordId: second.token, text: payload }
131
+ : { action: "start", slug: second.token, task: payload };
68
132
  }
69
133
  return { action: "noop" };
70
134
  }
@@ -81,6 +81,8 @@ export interface StartHandlerInput {
81
81
  conversation?: boolean;
82
82
  /** 空闲超时毫秒数(仅 conversation 模式有意义,覆盖默认 5min)。 */
83
83
  idleTimeoutMs?: number;
84
+ /** 执行引擎(D4 三层路由第一层:本参数 > agent frontmatter engine > config defaultEngine)。 */
85
+ engine?: string;
84
86
  }
85
87
 
86
88
  /** start 领域对象(adapter 包成 bgResponse)。 */
@@ -219,6 +221,7 @@ export async function startHandler(
219
221
  cwd: input.cwd,
220
222
  conversation: input.conversation,
221
223
  idleTimeoutMs: input.idleTimeoutMs,
224
+ engine: input.engine,
222
225
  ctxModel,
223
226
  signal,
224
227
  // background detached 运行,完成由 notify 驱动新 turn。
@@ -329,10 +332,13 @@ export interface MessageHandlerInput {
329
332
  interrupt?: boolean;
330
333
  }
331
334
 
332
- /** message 领域对象(adapter 包成 messageResponse)。 */
335
+ /** message 领域对象(adapter 包成 messageResponse)。
336
+ * slug 来自 record(GUI /subagents message 通道的留痕 details 需要,设计 §3.3.3),
337
+ * 避免调用方二次 getRecordForAction 查询。 */
333
338
  export type MessageHandlerResult = {
334
339
  kind: "message";
335
340
  subagentId: string;
341
+ slug: string;
336
342
  response: MessageResponse;
337
343
  };
338
344
 
@@ -393,7 +399,7 @@ export async function messageHandler(
393
399
  `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
394
400
  );
395
401
  }
396
- return { kind: "message", subagentId: id, response: { delivered: true } };
402
+ return { kind: "message", subagentId: id, slug: record.slug, response: { delivered: true } };
397
403
  }
398
404
 
399
405
  // ============================================================
@@ -127,6 +127,12 @@ const SubagentParams = Type.Object({
127
127
  "Default: 300000 (5min). Override for long-interval collaboration where each round is spaced >5min apart. " +
128
128
  "Only meaningful with conversation:true; ignored for one-shot subagents.",
129
129
  })),
130
+ engine: Type.Optional(StringEnum(["pi", "zcode"], {
131
+ description:
132
+ "Execution engine for this subagent. Omit to inherit the global config. " +
133
+ "Three-layer priority: this parameter > agent .md frontmatter engine > config.json defaultEngine. " +
134
+ "Non-pi engines do not support conversation/fork/worktree (rejected before the subagent is created).",
135
+ })),
130
136
  // action:"list" → listParam OPTIONAL (all fields optional, defaults apply). Ignored by other actions.
131
137
  listParam: Type.Optional(Type.Object({
132
138
  includeFinished: Type.Optional(Type.Boolean({
@@ -3,16 +3,211 @@
3
3
  // /subagents 命令。薄壳——打开 list overlay(等同原 /subagents list [<id>])。
4
4
  //
5
5
  // 解析:args[0] 直接作可选 <id>(聚焦该 record)。
6
- // RPC 模式(xyz-agent GUI):解析 cancel action 直接执行,不打开 TUI。
6
+ // RPC 模式(xyz-agent GUI):解析 cancel/message/start action 直接执行,不打开 TUI。
7
+ // message/start 为 GUI 定向消息通道(设计 §3.3.3):GUI 经 client.prompt 短路
8
+ // extension 命令(不经主 agent LLM),TUI 分支不消费这两个 verb(行为零变化)。
7
9
 
8
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
11
 
10
12
  import { getSubagentService } from "../execution/subagent-service.ts";
13
+ import type { SubagentService } from "../execution/subagent-service.ts";
11
14
  import { displayAgentName } from "../shared/agent-ref.ts";
15
+ import { messageHandler, startHandler } from "./subagent-actions.ts";
12
16
  import { parseSubagentRpcCommand } from "./command-actions.ts";
17
+ import type { SubagentRpcAction } from "./command-actions.ts";
13
18
  import { LIST_LIMIT } from "./list-shared.ts";
14
19
  import { createSubagentsView } from "./list-view.ts";
15
20
 
21
+ /**
22
+ * subagent-directive custom_message 的 customType。
23
+ *
24
+ * 定向消息留痕载体(设计 §3.3.3):message/start 成功派发后落主 session 的
25
+ * custom_message entry,一 entry 双消费——
26
+ * 1. 主 agent 上下文(custom_message 进 context,主 agent 下次 turn 可见定向对话)
27
+ * 2. renderer 定向气泡渲染源(§3.3.3a live/reload 双链路,后续 wave 消费)
28
+ * 字段形状是 GUI 契约,改动需与 renderer 侧同步。
29
+ */
30
+ export const SUBAGENT_DIRECTIVE_CUSTOM_TYPE = "subagent-directive";
31
+
32
+ /** subagent-directive entry 的 details 形状(GUI 定向气泡渲染契约)。 */
33
+ export interface SubagentDirectiveDetails {
34
+ subagentId: string;
35
+ slug: string;
36
+ /** 消息方向:'user' = 用户 → subagent 定向(当前唯一方向,命名预留双向扩展)。 */
37
+ direction: "user";
38
+ }
39
+
40
+ /**
41
+ * 定向消息留痕:向主 session 落 subagent-directive custom_message entry。
42
+ *
43
+ * 按主 agent streaming 状态分流 sendMessage options。pi 0.84.1 sendCustomMessage
44
+ * 实装(agent-session.js):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
45
+ * 定向消息注入正在运行的主 agent LLM turn,违反「不经主 agent LLM 直达 subagent」。
46
+ * 故按调用时刻的权威 streaming 状态(ctx.isIdle(),与 sendCustomMessage 内部
47
+ * isStreaming 判据精确互补,含 agent_end 后 retry/continuation 窗口)分流:
48
+ * - streaming(isMainAgentIdle=false):传 { deliverAs: "nextTurn" }——消息入
49
+ * pi 内存 _pendingNextTurnMessages 队列,下个 turn 注入主 agent 上下文;不打断、
50
+ * 不 steer 当前 turn。注意:该队列不落 entry,留痕延迟到下个 turn
51
+ * - 非 streaming(isMainAgentIdle=true):不传 options——立即 append entry 留痕
52
+ * + message_start/end 双发(renderer live 链路即时可见,现状行为)
53
+ * 两者都不传 triggerTurn——不产生新 turn(§3.3.8「留痕 ≠ 处理」的结构性保证);
54
+ * display:false 使 pi TUI 不渲染该 entry(GUI 侧由 §3.3.3a 定向气泡通路渲染)。
55
+ */
56
+ function emitSubagentDirective(
57
+ pi: Pick<ExtensionAPI, "sendMessage">,
58
+ details: SubagentDirectiveDetails,
59
+ text: string,
60
+ isMainAgentIdle: boolean,
61
+ ): void {
62
+ pi.sendMessage(
63
+ {
64
+ customType: SUBAGENT_DIRECTIVE_CUSTOM_TYPE,
65
+ content: text,
66
+ display: false,
67
+ details,
68
+ },
69
+ isMainAgentIdle ? undefined : { deliverAs: "nextTurn" },
70
+ );
71
+ }
72
+
73
+ /** RPC cancel 执行体(行为等价拆分自 handler,复杂度治理)。 */
74
+ async function rpcCancel(
75
+ service: SubagentService,
76
+ recordId: string,
77
+ ctx: ExtensionCommandContext,
78
+ ): Promise<void> {
79
+ try {
80
+ const ok = service.cancel(recordId);
81
+ ctx.ui.notify(
82
+ ok ? `Cancelled subagent ${recordId}` : `Subagent ${recordId} not found or already finished`,
83
+ ok ? "info" : "warning",
84
+ );
85
+ } catch (err) {
86
+ // service.cancel 内部 assertReady 在 session_shutdown 并发 dispose 时会抛
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ ctx.ui.notify(`Failed to cancel subagent ${recordId}: ${msg}`, "warning");
89
+ }
90
+ }
91
+
92
+ /** RPC message 执行体(行为等价拆分自 handler,复杂度治理)。 */
93
+ async function rpcMessage(
94
+ pi: ExtensionAPI,
95
+ service: SubagentService,
96
+ recordId: string,
97
+ text: string,
98
+ ctx: ExtensionCommandContext,
99
+ ): Promise<void> {
100
+ // GUI 定向消息(设计 §3.3.3):不经主 agent LLM 直达 subagent。
101
+ // one-shot 首条 message 自动升级 chatMode 的机制在 messageHandler 内(勿在此重复)。
102
+ try {
103
+ const result = await messageHandler(service, {
104
+ subagentId: recordId,
105
+ text,
106
+ });
107
+ // 留痕(§3.3.3):成功派发后才留痕——失败时不留痕,GUI 按 toast 错误重发。
108
+ // ctx.isIdle() 按调用时刻分流(streaming → nextTurn 队列延迟留痕,见
109
+ // emitSubagentDirective JSDoc),保证任何时刻都不 steer 主 agent 当前 turn
110
+ emitSubagentDirective(
111
+ pi,
112
+ { subagentId: result.subagentId, slug: result.slug, direction: "user" },
113
+ text,
114
+ ctx.isIdle(),
115
+ );
116
+ ctx.ui.notify(`Message delivered to subagent ${result.slug} (${result.subagentId})`, "info");
117
+ } catch (err) {
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ ctx.ui.notify(`Failed to message subagent ${recordId}: ${msg}`, "warning");
120
+ }
121
+ }
122
+
123
+ /** RPC start 执行体(行为等价拆分自 handler,复杂度治理)。 */
124
+ async function rpcStart(
125
+ pi: ExtensionAPI,
126
+ service: SubagentService,
127
+ slug: string,
128
+ task: string,
129
+ ctx: ExtensionCommandContext,
130
+ ): Promise<void> {
131
+ // GUI 定向新建(设计 §3.3.3):conversation 固定 true(GUI 定向对话场景需要可续聊)
132
+ try {
133
+ const result = await startHandler(
134
+ service,
135
+ {
136
+ slug,
137
+ task,
138
+ conversation: true,
139
+ },
140
+ // RPC 命令无外层 AbortSignal(GUI 请求生命周期不映射到 subagent 取消——
141
+ // start 是 detached 后台语义,取消走 /subagents cancel)
142
+ undefined,
143
+ );
144
+ emitSubagentDirective(
145
+ pi,
146
+ { subagentId: result.subagentId, slug: result.slug, direction: "user" },
147
+ task,
148
+ ctx.isIdle(),
149
+ );
150
+ ctx.ui.notify(`Started subagent ${result.slug} (${result.subagentId})`, "info");
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ ctx.ui.notify(`Failed to start subagent ${slug}: ${msg}`, "warning");
154
+ }
155
+ }
156
+
157
+ /**
158
+ * RPC 模式(xyz-agent GUI):解析后的 action 分发执行,不打开 TUI。
159
+ * 行为等价拆分自 handler(fallow 圈复杂度 21 > 15):三个执行体
160
+ * (cancel/message/start)各自成函数,本函数只做 switch 分发 +
161
+ * usage notify + exhaustiveness 断言。
162
+ */
163
+ async function executeRpcAction(
164
+ pi: ExtensionAPI,
165
+ service: SubagentService,
166
+ parsed: SubagentRpcAction,
167
+ ctx: ExtensionCommandContext,
168
+ ): Promise<void> {
169
+ switch (parsed.action) {
170
+ case "cancel":
171
+ await rpcCancel(service, parsed.recordId, ctx);
172
+ return;
173
+ case "cancel-missing-id":
174
+ ctx.ui.notify("Usage: /subagents cancel <id>", "warning");
175
+ return;
176
+ case "message":
177
+ await rpcMessage(pi, service, parsed.recordId, parsed.text, ctx);
178
+ return;
179
+ case "message-missing-args":
180
+ // 错误可操作:指明缺什么 + 完整 usage(全局规则 16)
181
+ ctx.ui.notify(
182
+ parsed.missing === "recordId"
183
+ ? "Usage: /subagents message <recordId> <text> — recordId is missing"
184
+ : "Usage: /subagents message <recordId> <text> — text is missing",
185
+ "warning",
186
+ );
187
+ return;
188
+ case "start":
189
+ await rpcStart(pi, service, parsed.slug, parsed.task, ctx);
190
+ return;
191
+ case "start-missing-args":
192
+ ctx.ui.notify(
193
+ parsed.missing === "slug"
194
+ ? "Usage: /subagents start <slug> <task> — slug is missing"
195
+ : "Usage: /subagents start <slug> <task> — task is missing",
196
+ "warning",
197
+ );
198
+ return;
199
+ case "noop":
200
+ // 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
201
+ ctx.ui.notify("View subagents in the sidebar Agents tab", "info");
202
+ return;
203
+ default: {
204
+ // exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
205
+ const _exhaustive: never = parsed;
206
+ throw new Error(`Unhandled subagent RPC action: ${String(_exhaustive)}`);
207
+ }
208
+ }
209
+ }
210
+
16
211
  /** 注册 /subagents 命令(= list overlay)。 */
17
212
  export function registerSubagentsCommand(pi: ExtensionAPI): void {
18
213
  pi.registerCommand("subagents", {
@@ -59,35 +254,8 @@ export function registerSubagentsCommand(pi: ExtensionAPI): void {
59
254
  // ── RPC 模式(xyz-agent GUI):解析 action 直接执行,不打开 TUI ──
60
255
  // hasUI 在 TUI 和 RPC 都为 true,不能用于区分;用 ctx.mode === "rpc" 判定 GUI 通道。
61
256
  if (ctx.mode === "rpc") {
62
- const parsed = parseSubagentRpcCommand(argsStr);
63
- switch (parsed.action) {
64
- case "cancel": {
65
- try {
66
- const ok = service.cancel(parsed.recordId);
67
- ctx.ui.notify(
68
- ok ? `Cancelled subagent ${parsed.recordId}` : `Subagent ${parsed.recordId} not found or already finished`,
69
- ok ? "info" : "warning",
70
- );
71
- } catch (err) {
72
- // service.cancel 内部 assertReady 在 session_shutdown 并发 dispose 时会抛
73
- const msg = err instanceof Error ? err.message : String(err);
74
- ctx.ui.notify(`Failed to cancel subagent ${parsed.recordId}: ${msg}`, "warning");
75
- }
76
- return;
77
- }
78
- case "cancel-missing-id":
79
- ctx.ui.notify("Usage: /subagents cancel <id>", "warning");
80
- return;
81
- case "noop":
82
- // 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
83
- ctx.ui.notify("View subagents in the sidebar Agents tab", "info");
84
- return;
85
- default: {
86
- // exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
87
- const _exhaustive: never = parsed;
88
- throw new Error(`Unhandled subagent RPC action: ${String(_exhaustive)}`);
89
- }
90
- }
257
+ await executeRpcAction(pi, service, parseSubagentRpcCommand(argsStr), ctx);
258
+ return;
91
259
  }
92
260
 
93
261
  // ── print/json 模式(headless):不可交互 ──
@@ -12,7 +12,7 @@ const { parentPort: _parentPort, workerData: _workerData } = require("node:worke
12
12
  const _workerLogs = [];
13
13
  // IF6(#12): known agent() fields — hoisted to module scope, built once per worker
14
14
  // (was rebuilt inside agent() on every call; field set is call-invariant).
15
- const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);
15
+ const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel", "engine"]);
16
16
  function _pushWorkerLog(level, args) {
17
17
  try { _workerLogs.push({ level, message: args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ") }); } catch (e) { /* swallow */ }
18
18
  }
@@ -153,6 +153,8 @@ function _safePost(msg, context) {
153
153
  scene: (secondArg && typeof secondArg === "object" && secondArg.scene) || undefined,
154
154
  phase: (secondArg && typeof secondArg === "object" && secondArg.phase) || undefined,
155
155
  thinkingLevel: (secondArg && typeof secondArg === "object" && secondArg.thinkingLevel) || $THINKING_LEVEL,
156
+ // P4 D9③:step 级 engine 显式指定(仅限必须某引擎独有能力的场景)
157
+ engine: (secondArg && typeof secondArg === "object" && secondArg.engine) || undefined,
156
158
  };
157
159
  } else if (typeof firstArg === "object" && firstArg !== null) {
158
160
  if (firstArg.prompt) {
@@ -174,6 +176,7 @@ function _safePost(msg, context) {
174
176
  worktree: firstArg.worktree,
175
177
  returnMeta: firstArg.returnMeta,
176
178
  thinkingLevel: firstArg.thinkingLevel || $THINKING_LEVEL,
179
+ engine: firstArg.engine,
177
180
  };
178
181
  } else {
179
182
  opts = firstArg;
@@ -190,7 +193,7 @@ function _safePost(msg, context) {
190
193
  // Validate known agent() fields to catch API misuse early (_KNOWN_FIELDS at module scope)
191
194
  const _unknownFields = Object.keys(opts).filter((k) => !_KNOWN_FIELDS.has(k));
192
195
  if (_unknownFields.length > 0) {
193
- _pushWorkerLog("warn", ["[workflow] agent() received unknown fields: " + _unknownFields.join(", ") + ". Known fields: prompt, description, schema, model, scene, label, task, agent, phase, skill, timeoutMs, cwd, fork, worktree, returnMeta, thinkingLevel"]);
196
+ _pushWorkerLog("warn", ["[workflow] agent() received unknown fields: " + _unknownFields.join(", ") + ". Known fields: prompt, description, schema, model, scene, label, task, agent, phase, skill, timeoutMs, cwd, fork, worktree, returnMeta, thinkingLevel, engine"]);
194
197
  }
195
198
 
196
199
  const callId = _callIdCounter;
@@ -100,11 +100,11 @@ describe("buildWorkerScript — _KNOWN_FIELDS module scope 提升(IF6)", ()
100
100
  expect(agentBody).toContain("_KNOWN_FIELDS.has(k)");
101
101
  });
102
102
 
103
- it("字段集合内容逐字段一致(16 known fields 不丢失)", () => {
103
+ it("字段集合内容逐字段一致(17 known fields 不丢失——P4 增 engine)", () => {
104
104
  expect(script).toContain(
105
- 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
105
+ 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel", "engine"]);',
106
106
  );
107
107
  // unknown-fields 警告文案不变(known 列表仍全量)
108
- expect(script).toMatch(/Known fields:.*returnMeta.*thinkingLevel/);
108
+ expect(script).toMatch(/Known fields:.*returnMeta.*thinkingLevel.*engine/);
109
109
  });
110
110
  });
@@ -143,6 +143,13 @@ export interface AgentCallOpts {
143
143
  cwd?: string;
144
144
  /** Inherit parent session context (fork mode). Independent of worktree (file isolation). */
145
145
  fork?: boolean;
146
+ /**
147
+ * 执行引擎 id(P4 D9 三层优先级的第一层:调用参数级,workflow step 显式指定)。
148
+ * 仅限「必须某引擎独有能力」的场景使用并注释原因(D9③ workflow 脚本不写死
149
+ * engine——环境差异由 frontmatter/全局默认承载);透传链 worker-script-builder
150
+ * agent() → execute-agent-call → SAR 路由层。
151
+ */
152
+ engine?: string;
146
153
  /** Filesystem isolation: when true, creates a new git worktree for the agent. Independent of fork. */
147
154
  worktree?: boolean;
148
155
  /** When true, agent() resolves {value, sessionFile, worktreePath, error} instead of a bare value.