@zhushanwen/pi-subagent-workflow 0.1.0 → 0.3.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 (130) hide show
  1. package/README.md +56 -0
  2. package/agents/context-builder.md +1 -3
  3. package/agents/explorer.md +27 -0
  4. package/agents/oracle.md +2 -2
  5. package/agents/orchestrator.md +48 -0
  6. package/agents/planner.md +1 -3
  7. package/agents/researcher.md +0 -2
  8. package/agents/reviewer.md +2 -2
  9. package/agents/worker.md +0 -2
  10. package/package.json +5 -3
  11. package/skills/workflow-script-format/SKILL.md +6 -6
  12. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  13. package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
  14. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  15. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  16. package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
  17. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  18. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  19. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  20. package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
  21. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  22. package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
  23. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  24. package/src/execution/__tests__/host-mode.test.ts +87 -0
  25. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  26. package/src/execution/__tests__/list-component.test.ts +1 -1
  27. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  28. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  29. package/src/execution/__tests__/record-store.test.ts +86 -2
  30. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  31. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  32. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  33. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  34. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  35. package/src/execution/__tests__/sdk-contract.test.ts +5 -2
  36. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  37. package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
  38. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  39. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  40. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  41. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  42. package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
  43. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  44. package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
  45. package/src/execution/__tests__/tool-action.test.ts +27 -5
  46. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  47. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  48. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  49. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  50. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  51. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  52. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  53. package/src/execution/agent-registry.ts +1 -1
  54. package/src/execution/agent-result-mapper.ts +4 -1
  55. package/src/execution/channel-registry-access.ts +138 -0
  56. package/src/execution/concurrency-pool.ts +38 -6
  57. package/src/execution/dialog-queue.ts +329 -0
  58. package/src/execution/execute-options-mapper.ts +21 -4
  59. package/src/execution/execution-record.ts +5 -0
  60. package/src/execution/finalize-record.ts +160 -0
  61. package/src/execution/get-state-handshake.ts +104 -0
  62. package/src/execution/host-mode.ts +52 -0
  63. package/src/execution/manifest-store.ts +206 -0
  64. package/src/execution/notifier.ts +5 -1
  65. package/src/execution/path-encoding.ts +18 -0
  66. package/src/execution/pi-invocation.ts +1 -1
  67. package/src/execution/record-store.ts +110 -2
  68. package/src/execution/session-file-gc.ts +25 -3
  69. package/src/execution/session-reconstructor.ts +11 -0
  70. package/src/execution/session-runner.ts +228 -32
  71. package/src/execution/spawn-event-adapter.ts +219 -6
  72. package/src/execution/stdin-writer.ts +106 -0
  73. package/src/execution/stream-sink.ts +83 -0
  74. package/src/execution/subagent-service.ts +230 -235
  75. package/src/execution/subprocess-agent-runner.ts +16 -4
  76. package/src/execution/types.ts +23 -3
  77. package/src/execution/ui-channels.ts +216 -0
  78. package/src/execution/ui-interaction-model.ts +48 -0
  79. package/src/execution/ui-request-handler-factory.ts +175 -0
  80. package/src/execution/ui-request-observability.ts +77 -0
  81. package/src/execution/ui-request-queue.ts +168 -0
  82. package/src/index.ts +101 -4
  83. package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
  84. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
  85. package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
  86. package/src/interface/command-actions.ts +77 -0
  87. package/src/interface/commands.ts +40 -4
  88. package/src/interface/format.ts +2 -0
  89. package/src/interface/gui-mappers.ts +83 -0
  90. package/src/interface/helpers.ts +52 -9
  91. package/src/interface/list-component.ts +3 -1
  92. package/src/interface/subagent-actions.ts +44 -24
  93. package/src/interface/subagent-tool.ts +56 -24
  94. package/src/interface/subagents.ts +45 -5
  95. package/src/interface/tool-render.ts +16 -5
  96. package/src/interface/tool-workflow-script.ts +113 -15
  97. package/src/interface/tool-workflow.ts +92 -34
  98. package/src/interface/views/WorkflowsView.ts +13 -4
  99. package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
  100. package/src/interface/views/detail-content.ts +20 -0
  101. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
  102. package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
  103. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
  104. package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
  105. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
  106. package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
  107. package/src/orchestration/agent-opts-resolver.ts +11 -2
  108. package/src/orchestration/error-recovery.ts +131 -23
  109. package/src/orchestration/execute-agent-call.ts +12 -3
  110. package/src/orchestration/jsonl-run-store.ts +10 -0
  111. package/src/orchestration/lifecycle.ts +1 -1
  112. package/src/orchestration/models/agent-call.ts +7 -0
  113. package/src/orchestration/models/ports.ts +15 -2
  114. package/src/orchestration/models/run-spec.ts +6 -0
  115. package/src/orchestration/models/trace.ts +1 -0
  116. package/src/orchestration/models/types.ts +19 -0
  117. package/src/orchestration/node-ops.ts +2 -0
  118. package/src/orchestration/worker-script-builder.ts +1 -0
  119. package/workflows/README.md +58 -0
  120. package/workflows/chain.js +107 -0
  121. package/workflows/map-reduce.js +142 -0
  122. package/workflows/parallel.js +131 -0
  123. package/workflows/scatter-gather.js +146 -0
  124. package/agents/scout.md +0 -17
  125. package/examples/README.md +0 -43
  126. package/examples/chain.example.js +0 -92
  127. package/examples/map-reduce.example.js +0 -99
  128. package/examples/parallel.example.js +0 -82
  129. package/examples/scatter-gather.example.js +0 -106
  130. package/src/interface/gui-adapter.ts +0 -136
@@ -1,6 +1,6 @@
1
1
  // src/core/session-runner.ts
2
2
  //
3
- // spawn pi --mode json 子进程执行 session 的编排器。零 mode 感知。
3
+ // spawn pi --mode rpc 子进程执行 session 的编排器。零 mode 感知。
4
4
  //
5
5
  // spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
6
6
  // runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
@@ -9,35 +9,40 @@
9
9
  import { type ChildProcess,execFileSync, spawn } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
 
12
+ import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
13
+
12
14
  import { writeAliveMarker } from "./alive-store.ts";
13
- import type {
14
- AgentEvent,
15
- AgentResult,
16
- ExecutionRecord,
17
- SdkEvent,
18
- WorktreeHandle,
19
- } from "./types.ts";
15
+ import { type DialogGlobalQueue, type UiRequestHandler } from "./dialog-queue.ts";
20
16
  import { updateFromEvent } from "./execution-record.ts";
21
- import type {
22
- AgentConfig,
23
- ResolvedModel,
24
- } from "./model-resolver.ts";
17
+ import { type GetStateResult, performGetStateHandshake } from "./get-state-handshake.ts";
18
+ import { willRespondToAskUser } from "./host-mode.ts";
19
+ import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
25
20
  import { collectResult } from "./output-collector.ts";
26
21
  import { getSubagentSessionDir } from "./path-encoding.ts";
27
22
  import { getPiInvocation } from "./pi-invocation.ts";
28
23
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
29
24
  import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./session-reconstructor.ts";
25
+ import { sendPromptCommand } from "./stdin-writer.ts";
30
26
  import {
31
27
  deriveSessionFilePath,
32
28
  findSessionFileByHeaderId,
33
29
  parseSpawnLine,
34
30
  type SpawnSessionHeader,
35
31
  } from "./spawn-event-adapter.ts";
32
+ import type { SubagentStream } from "./stream-sink.ts";
36
33
  import {
37
34
  cleanupTempPrompt,
38
35
  writePromptToTempFile,
39
36
  } from "./temp-prompt.ts";
37
+ import type {
38
+ AgentEvent,
39
+ AgentResult,
40
+ ExecutionRecord,
41
+ SdkEvent,
42
+ WorktreeHandle,
43
+ } from "./types.ts";
40
44
  import { createTurnLimiter, WRAP_UP_HINT } from "./turn-limiter.ts";
45
+ import { createUiRequestQueue } from "./ui-request-queue.ts";
41
46
 
42
47
  /**
43
48
  * 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
@@ -49,6 +54,21 @@ function isSdkEvent(x: unknown): x is SdkEvent {
49
54
  return typeof (x as SdkEvent).type === "string";
50
55
  }
51
56
 
57
+ /**
58
+ * M10:agent_end 事件守卫。抽出前调用处用 `(evt as { type: string }).type === "agent_end"`
59
+ * 和 `(evt as { willRetry?: boolean }).willRetry` 两处结构断言触发 taste/no-unsafe-cast
60
+ *(后者断言到全可选属性等于无校验)。守卫返回后 TS 自动窄化为
61
+ * { type: "agent_end"; willRetry?: boolean },调用处无需任何 cast。
62
+ */
63
+ function isAgentEndEvt(
64
+ x: unknown,
65
+ ): x is { type: "agent_end"; willRetry?: boolean } {
66
+ if (typeof x !== "object" || x === null) return false;
67
+ if (!("type" in x)) return false;
68
+ // `"type" in x` 已窄化,TS 允许直接访问 x.type(无需 cast)
69
+ return x.type === "agent_end";
70
+ }
71
+
52
72
  // ============================================================
53
73
  // 常量
54
74
  // ============================================================
@@ -88,6 +108,35 @@ function computeWatchdogMs(maxTurns: number | undefined | null): number {
88
108
  /** stderr 累积上限(字符)。防止失控子进程打满父进程内存。保留尾部便于诊断。 */
89
109
  const STDERR_MAX_CHARS = 64 * 1024;
90
110
 
111
+ // ============================================================
112
+ // W4: ask_user RPC 系统提示词
113
+ // ============================================================
114
+
115
+ /**
116
+ * ask_user 工具的 RPC 使用指引。当子进程配置了 ask_user tool 时注入 appendParts,
117
+ * 告知 LLM:ask_user 的问题会通过 RPC 转发到主 agent UI,用户在主 agent 界面回答。
118
+ *
119
+ * 背景:spawn 模式下子进程没有 TUI 交互通道,ask_user 走 extension_ui_request RPC 协议
120
+ * 转发到父进程,父进程调用 uiRequestHandler 将问题呈现给用户,收到回答后通过 stdin
121
+ * 回写 JSON-RPC response。LLM 需要知道这个机制存在,才能正确使用 ask_user。
122
+ */
123
+ export const ASK_USER_RPC_PROMPT = `
124
+ ## ask_user Tool Availability
125
+
126
+ The \`ask_user\` tool is available in this session. When you call \`ask_user\`, your questions are forwarded via RPC to the main agent's UI, where the user will see them and provide answers. The response is delivered back to you automatically.
127
+
128
+ **How it works:**
129
+ 1. You call \`ask_user\` with structured questions (each with options)
130
+ 2. The questions are forwarded to the main agent's UI via RPC
131
+ 3. The user sees the questions and selects answers in the main agent interface
132
+ 4. The answers are returned to you as the tool result
133
+
134
+ **Important:**
135
+ - The user may take some time to respond — this is normal
136
+ - If the user cancels or the request times out, you'll receive a cancellation notice
137
+ - Use ask_user only when you genuinely cannot resolve ambiguity yourself (see tool description for guidelines)
138
+ `.trim();
139
+
91
140
  // ============================================================
92
141
  // 孤儿进程兜底(C1)
93
142
  // ============================================================
@@ -102,7 +151,9 @@ const STDERR_MAX_CHARS = 64 * 1024;
102
151
  // 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Set 移除,
103
152
  // 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
104
153
  // (对已退出的 child.kill 返回 false,无害)。
105
- const spawnedChildren = new Set<ChildProcess>();
154
+ //
155
+ // [export] 测试可观测(断言 dispose 后 size===0)。业务代码误外部修改。
156
+ export const spawnedChildren = new Set<ChildProcess>();
106
157
 
107
158
  /**
108
159
  * kill 所有未退出的 spawned 子进程(dispose 兜底用)。
@@ -134,6 +185,11 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
134
185
  // best-effort:单个 kill 失败不影响其他子进程
135
186
  }
136
187
  }
188
+ // dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
189
+ // 这里兑底防 close 事件漏触发的极端累积(主进程崩溃后 close 回调可能不再触发,
190
+ // 不 clear 则下次 dispose 会重复向已 kill 的 child 发信号——虽然 killed=true 跳过,
191
+ // 但 Set 无限增长泄漏内存)。
192
+ spawnedChildren.clear();
137
193
  return n;
138
194
  }
139
195
 
@@ -159,6 +215,28 @@ export interface SessionRunnerContext {
159
215
  * 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
160
216
  */
161
217
  onWorktreePid?: (branch: string, pid: number) => void;
218
+ /**
219
+ * UI 请求处理回调。子进程发 extension_ui_request 时调用。
220
+ *
221
+ * 入参 UiRequest(method + channel/channelPayload + method 特定字段),
222
+ * 返回 UiResponse({value}/{confirmed}/{cancelled}/{ack})。
223
+ * 实现方按 req.channel 分发业务路由(ask_user → AskUserComponent)+
224
+ * 默认转发(ctx.ui.*),收到用户回答后 resolve。
225
+ *
226
+ * 未设置时不再静默忽略——console.warn 兜底(FR-9 可观测性),
227
+ * W3 接入 SubagentService.notifyMissingHandler 的 appendEntry。
228
+ */
229
+ uiRequestHandler?: UiRequestHandler;
230
+ /**
231
+ * L2 跨子进程全局 dialog 串行队列(进程单例,由 SubagentService 注入)。
232
+ *
233
+ * SR-4:child close 时调 rejectChildDialogs 清理该 child 在 L2 的 pending dialog,
234
+ * 防 Promise 永挂(handler 等用户输入永不 settle)导致队列死锁(processing 永远 true,
235
+ * 其他子进程的 dialog 永久阻塞)。未注入(旧调用方/测试)时 onclose 只清 L1。
236
+ */
237
+ dialogQueue?: DialogGlobalQueue;
238
+ /** 主进程运行模式(W4 守卫:headless 不注入 ask_user RPC 提示词)。 */
239
+ mode?: ExtensionMode;
162
240
  }
163
241
 
164
242
  /** SessionRunner.run 的入参。 */
@@ -181,6 +259,12 @@ export interface RunOptions {
181
259
  signal: AbortSignal | undefined;
182
260
  /** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
183
261
  onEvent: ((event: AgentEvent) => void) | undefined;
262
+ /** text_delta streaming 生命周期对象——在 text_delta 到达 onEvent 之前分流。
263
+ * background 模式下 onEvent=undefined,但 text_delta 仍可通过此对象被消费。
264
+ * 由调用方(subagent-service)创建,内部做时间窗合并后转发到 setWidget。
265
+ * workflow 路径(executeAndAwait)不传此字段——其 onEvent 是开的,
266
+ * text_delta 经 onEvent 到 workflow liveRecord,不走 streaming 通道。 */
267
+ stream?: SubagentStream;
184
268
  /** D-A6 bridge: workflow schema JSON 字符串,存在时注入 childEnv.PI_WORKFLOW_SCHEMA。
185
269
  * workflow 编排层通过 ExecuteOptions.schemaEnv 透传此处,
186
270
  * runSpawn 将其注入子进程环境变量,激活 structured-output 扩展注册 tool。
@@ -298,7 +382,7 @@ export function buildEnvBlock(
298
382
  }
299
383
 
300
384
  // ============================================================
301
- // [SPAWN 改造] runSpawn:spawn pi --mode json 子进程执行 session
385
+ // [SPAWN 改造] runSpawn:spawn pi --mode rpc 子进程执行 session
302
386
  // ============================================================
303
387
  //
304
388
  // 替代 in-process run()。核心差异:session 在独立子进程跑(进程隔离),
@@ -339,9 +423,11 @@ export function buildSpawnArgs(
339
423
  forkSource: string | undefined;
340
424
  skillPaths: string[] | undefined;
341
425
  },
342
- task: string,
343
426
  ): string[] {
344
- const args: string[] = ["--mode", "json", "-p", "--session-dir", params.sessionDir];
427
+ // task 不通过命令行传——pi runRpcMode 只消费 stdin RpcCommand,
428
+ // positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
429
+ // task 由 runSpawn 内 sendPromptCommand 写 child.stdin 驱动。
430
+ const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
345
431
  if (params.model) args.push("--model", params.model);
346
432
  if (params.thinkingLevel && params.model) {
347
433
  // thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
@@ -365,7 +451,6 @@ export function buildSpawnArgs(
365
451
  args.push("--skill", sp);
366
452
  }
367
453
  }
368
- args.push(task);
369
454
  return args;
370
455
  }
371
456
 
@@ -387,15 +472,16 @@ export async function runSpawn(
387
472
  const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
388
473
 
389
474
  // b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
390
- // [M1] pi --mode json single-shot,无运行时 steer 通道。补偿:启动时通过
391
- // --append-system-prompt 预置 WRAP_UP_HINT(见上方 appendParts),让 agent 感知
392
- // 接近上限时主动收尾。maxTurns soft limit 仍依赖 graceTurns 后的 abort 兑现。
475
+ // [M1] rpc mode 是长驻进程(agent_end 后不自动退出),maxTurns soft limit 依赖
476
+ // graceTurns 后的 abort(proc.kill SIGTERM)兑现。agent 自然结束时由 stdout pump
477
+ // agent_end 拦截 kill(见下方)。steer 通道当前未接通(见下方 steer no-op 注释)。
393
478
  let proc: ChildProcess | undefined;
394
479
  const limiter = createTurnLimiter({
395
480
  maxTurns: opts.maxTurns ?? 0,
396
481
  graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
397
482
  steer: () => {
398
- // no-op:spawn 无运行时 steer 通道,补偿已在启动时注入 WRAP_UP_HINT。
483
+ // no-op:当前 runSpawn 未接通 rpc stdin steer 通道(rpc mode 支持 steer/followUp,
484
+ // 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
399
485
  },
400
486
  abort: () => {
401
487
  proc?.kill("SIGTERM");
@@ -474,6 +560,10 @@ export async function runSpawn(
474
560
  const agentEvent = (event: AgentEvent): void => {
475
561
  updateFromEvent(record, event);
476
562
  if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
563
+ // text_delta 分流到 stream 通道(在 onEvent 之前)。
564
+ // 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
565
+ // workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
566
+ if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
477
567
  opts.onEvent?.(event);
478
568
  };
479
569
 
@@ -499,10 +589,14 @@ export async function runSpawn(
499
589
  const appendParts: string[] = [buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
500
590
  if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
501
591
  if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
502
- // [M1 补偿] spawn 模式无运行时 steer 通道(pi --mode json 是 single-shot),
503
- // 改为启动时预置 wrap-up 提示——agent 感知接近上限时主动收尾。
504
- // 长期方案:切到 pi --mode rpc(支持运行时 steer),见 follow-up。
592
+ // [M1 补偿] rpc mode steer 通道当前未接通,改为启动时预置 wrap-up 提示——
593
+ // agent 感知接近上限时主动收尾。
505
594
  if (opts.maxTurns && opts.maxTurns > 0) appendParts.push(WRAP_UP_HINT);
595
+ // W4: ask_user RPC 使用指引——当子进程配置了 ask_user tool 时,告知 LLM
596
+ // ask_user 的问题会通过 RPC 转发到主 agent UI,用户在主 agent 界面回答。
597
+ if (opts.agentConfig?.tools?.includes("ask_user") && willRespondToAskUser(ctx.mode)) {
598
+ appendParts.push(ASK_USER_RPC_PROMPT);
599
+ }
506
600
  if (appendParts.length > 0) {
507
601
  tempPromptFile = await writePromptToTempFile(record.agent, appendParts.join("\n\n"));
508
602
  }
@@ -532,7 +626,6 @@ export async function runSpawn(
532
626
  forkSource,
533
627
  skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
534
628
  },
535
- fullTask,
536
629
  );
537
630
  const invocation = getPiInvocation(spawnArgs);
538
631
 
@@ -545,7 +638,7 @@ export async function runSpawn(
545
638
  const child = spawn(invocation.command, invocation.args, {
546
639
  cwd: spawnCwd,
547
640
  shell: false,
548
- stdio: ["ignore", "pipe", "pipe"],
641
+ stdio: ["pipe", "pipe", "pipe"],
549
642
  env: childEnv,
550
643
  });
551
644
  proc = child;
@@ -560,6 +653,11 @@ export async function runSpawn(
560
653
  child.stdout.setEncoding("utf8");
561
654
  child.stderr.setEncoding("utf8");
562
655
 
656
+ // 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
657
+ // 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
658
+ // 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
659
+ sendPromptCommand(child, fullTask);
660
+
563
661
  // d. signal → proc.kill 监听(一次性,替代 session.abort)
564
662
  const onAbort = (): void => {
565
663
  child.kill("SIGTERM");
@@ -580,8 +678,43 @@ export async function runSpawn(
580
678
  const watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
581
679
  watchdog.unref();
582
680
 
583
- // stdout pump:逐行解析 → handleSdkEvent
681
+ // stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
682
+ const enqueueUiRequest = createUiRequestQueue(child, ctx);
683
+ // FR-4: get_state RPC response 监听器(id → resolver)。
684
+ // parseSpawnLine 返回 kind:"response" 时,按 command+id 匹配 resolver。
685
+ const get_stateListeners = new Map<string, (data: unknown) => void>();
584
686
  let stdoutBuffer = "";
687
+
688
+ // [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
689
+ // 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
690
+ // "data 事件必然在下一 tick 才触发”的运行时不变式兜底。
691
+ const handshakeResultRef: { current?: GetStateResult } = {};
692
+ let settleHandshake: (() => void) | undefined;
693
+ const handshakeSettled: Promise<void> = new Promise((resolveSettled) => {
694
+ settleHandshake = resolveSettled;
695
+ });
696
+ /** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
697
+ const finishHandshake = (r: GetStateResult): void => {
698
+ handshakeResultRef.current = r;
699
+ // 仅当 header 未先行设置 record.sessionFile 时回填(RPC mode 路径)。
700
+ if (r.sessionFile && !record.sessionFile) {
701
+ record.sessionFile = r.sessionFile;
702
+ if (child.pid) {
703
+ try {
704
+ writeAliveMarker(r.sessionFile, {
705
+ pid: child.pid,
706
+ id: r.sessionId ?? record.id,
707
+ startedAt: Date.now(),
708
+ });
709
+ } catch {
710
+ // best-effort:alive marker 失败不影响执行
711
+ }
712
+ }
713
+ }
714
+ settleHandshake?.();
715
+ settleHandshake = undefined;
716
+ };
717
+
585
718
  child.stdout.on("data", (data: string) => {
586
719
  stdoutBuffer += data;
587
720
  const lines = stdoutBuffer.split("\n");
@@ -612,13 +745,61 @@ export async function runSpawn(
612
745
  if (opts.worktree && child.pid) {
613
746
  ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
614
747
  }
748
+ // FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
749
+ // 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
750
+ // [#25] buildSpawnArgs 固定 --mode rpc,RPC mode 不发 header——此分支当前不触发,
751
+ // 仅为未来 mode 回切(如 json mode 调试)保留:届时 header 先到可省去 get_state 握手等待。
752
+ if (settleHandshake) {
753
+ finishHandshake({
754
+ ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
755
+ sessionId: parsed.header.id,
756
+ });
757
+ }
615
758
  } else if (parsed.kind === "event") {
759
+ const evt = parsed.event;
760
+ // agent_end(willRetry=false)= agent 自然完成。rpc mode 子进程不自动退出
761
+ //(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
762
+ // 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
763
+ if (isAgentEndEvt(evt)) {
764
+ if (!evt.willRetry) child.kill("SIGTERM");
765
+ }
616
766
  if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
767
+ } else if (parsed.kind === "response") {
768
+ // FR-4: RPC response handling — 匹配 get_state 响应
769
+ if (parsed.command === "get_state" && parsed.success && parsed.id) {
770
+ const resolver = get_stateListeners.get(parsed.id);
771
+ if (resolver) {
772
+ get_stateListeners.delete(parsed.id);
773
+ resolver(parsed.data);
774
+ }
775
+ }
776
+ } else if (parsed.kind === "extension_ui_request") {
777
+ // W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
778
+ enqueueUiRequest(parsed.id, parsed.request);
617
779
  }
618
780
  // invalid 行忽略(stdout 可能有调试输出)
619
781
  }
620
782
  });
621
783
 
784
+ // FR-4: get_state RPC 握手——spawn 后无条件启动。
785
+ // RPC mode(pi --mode rpc)不向 stdout 输出 header,record.sessionFile 无法靠 header
786
+ // 推导,必须通过 get_state RPC 查询子进程回填。json mode 下 header 会先到达触发提前
787
+ // resolve(加速路径),握手仍启动但无害——response 到达时外层已 resolve,resolver
788
+ // 因 resolved=true 直接 return。
789
+ //
790
+ // 时序:握手在 stdout pump(get_stateListeners 已就绪)之后启动。get_state 命令写入
791
+ // stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
792
+ // close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
793
+ // [#18] 握手状态变量(handshakeResultRef/settleHandshake/handshakeSettled/finishHandshake)
794
+ // 已在上方 stdout handler 注册前定义,此处直接发起握手。
795
+ void performGetStateHandshake(child, (id, resolver) => {
796
+ get_stateListeners.set(id, resolver);
797
+ }).then((r) => {
798
+ // header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
799
+ // 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
800
+ if (settleHandshake) finishHandshake(r);
801
+ });
802
+
622
803
  child.stderr.on("data", (data: string) => {
623
804
  // 截断防 OOM:失控子进程持续打 stderr 会耗尽父进程内存。保留尾部便于诊断。
624
805
  stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS);
@@ -626,9 +807,19 @@ export async function runSpawn(
626
807
 
627
808
  // 等待子进程退出
628
809
  const exitCode = await new Promise<number>((resolve) => {
629
- child.on("close", (code: number | null) => {
810
+ child.on("close", async (code: number | null) => {
630
811
  // [C1] 子进程已退出,从 orphan-tracking Set 移除(dispose 兜底无需再 kill 它)
631
812
  spawnedChildren.delete(child);
813
+ // FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
814
+ get_stateListeners.clear();
815
+ // FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
816
+ //(record.sessionFile 未回填则走 findSessionFileByHeaderId 兜底)。避免子进程快速
817
+ // 失败/退出场景下 close handler 阻塞等待握手内部 6s 超时。
818
+ settleHandshake?.();
819
+ settleHandshake = undefined;
820
+ // await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
821
+ // 完成的回填结果对后续 identity 写入可见。
822
+ await handshakeSettled;
632
823
  // 处理 stdout 末尾残留行
633
824
  if (stdoutBuffer.trim()) {
634
825
  const parsed = parseSpawnLine(stdoutBuffer);
@@ -653,12 +844,16 @@ export async function runSpawn(
653
844
  // session.jsonl 由子进程写入,父进程在子进程退出后(写入完成)補写身份条目。
654
845
  // reconstructFromFile 依赖 IDENTITY_CUSTOM_TYPE custom entry 重建 record 身份,
655
846
  // 缺失则 /subagents list 磁盘源为空(终态 record 全丢失)。[回归修复]
656
- if (sessionHeader && record.sessionFile) {
657
- // 兜底:deriveSessionFilePath 推导的路径可能不存在(pi 命名规则变化),
847
+ if (record.sessionFile) {
848
+ // 兜底:deriveSessionFilePath 推导或握手返回的路径可能不存在(pi 命名规则变化),
658
849
  // 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
850
+ // sessionId 来源:header(json mode)优先,其次握手结果(RPC mode)。
659
851
  if (!fs.existsSync(record.sessionFile)) {
660
- const actual = findSessionFileByHeaderId(sessionDir, sessionHeader.id);
661
- if (actual) record.sessionFile = actual;
852
+ const lookupId = sessionHeader?.id ?? handshakeResultRef.current?.sessionId;
853
+ if (lookupId) {
854
+ const actual = findSessionFileByHeaderId(sessionDir, lookupId);
855
+ if (actual) record.sessionFile = actual;
856
+ }
662
857
  }
663
858
  // 补写 identity custom entry(子进程已退出,append 安全)。
664
859
  if (fs.existsSync(record.sessionFile)) {
@@ -667,6 +862,7 @@ export async function runSpawn(
667
862
  agent: record.agent,
668
863
  mode: record.mode,
669
864
  task: record.task,
865
+ slug: record.slug,
670
866
  startedAt: record.startedAt,
671
867
  rootSessionId: record.rootSessionId,
672
868
  parentRecordId: record.parentRecordId,