@zhushanwen/pi-subagent-workflow 0.2.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 (64) hide show
  1. package/README.md +56 -0
  2. package/agents/{scout.md → explorer.md} +1 -1
  3. package/agents/orchestrator.md +48 -0
  4. package/package.json +1 -1
  5. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  7. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  8. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  9. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  10. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  11. package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
  12. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
  14. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  15. package/src/execution/__tests__/host-mode.test.ts +87 -0
  16. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  17. package/src/execution/__tests__/list-component.test.ts +1 -1
  18. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  19. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  20. package/src/execution/__tests__/record-store.test.ts +86 -2
  21. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  22. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  23. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  24. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  25. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  26. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  27. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  28. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  29. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  30. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  31. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  32. package/src/execution/__tests__/tool-action.test.ts +1 -1
  33. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  34. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  35. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  36. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  37. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  38. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  39. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  40. package/src/execution/agent-registry.ts +1 -1
  41. package/src/execution/channel-registry-access.ts +138 -0
  42. package/src/execution/dialog-queue.ts +329 -0
  43. package/src/execution/finalize-record.ts +160 -0
  44. package/src/execution/get-state-handshake.ts +104 -0
  45. package/src/execution/host-mode.ts +52 -0
  46. package/src/execution/manifest-store.ts +206 -0
  47. package/src/execution/notifier.ts +5 -1
  48. package/src/execution/path-encoding.ts +18 -0
  49. package/src/execution/pi-invocation.ts +1 -1
  50. package/src/execution/record-store.ts +108 -2
  51. package/src/execution/session-file-gc.ts +25 -3
  52. package/src/execution/session-runner.ts +216 -32
  53. package/src/execution/spawn-event-adapter.ts +219 -6
  54. package/src/execution/stdin-writer.ts +106 -0
  55. package/src/execution/subagent-service.ts +167 -197
  56. package/src/execution/ui-channels.ts +216 -0
  57. package/src/execution/ui-interaction-model.ts +48 -0
  58. package/src/execution/ui-request-handler-factory.ts +175 -0
  59. package/src/execution/ui-request-observability.ts +77 -0
  60. package/src/execution/ui-request-queue.ts +168 -0
  61. package/src/index.ts +90 -6
  62. package/src/interface/format.ts +2 -0
  63. package/src/interface/subagent-actions.ts +9 -2
  64. package/src/interface/subagent-tool.ts +9 -8
@@ -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,24 +9,20 @@
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,
@@ -38,7 +34,15 @@ import {
38
34
  cleanupTempPrompt,
39
35
  writePromptToTempFile,
40
36
  } from "./temp-prompt.ts";
37
+ import type {
38
+ AgentEvent,
39
+ AgentResult,
40
+ ExecutionRecord,
41
+ SdkEvent,
42
+ WorktreeHandle,
43
+ } from "./types.ts";
41
44
  import { createTurnLimiter, WRAP_UP_HINT } from "./turn-limiter.ts";
45
+ import { createUiRequestQueue } from "./ui-request-queue.ts";
42
46
 
43
47
  /**
44
48
  * 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
@@ -50,6 +54,21 @@ function isSdkEvent(x: unknown): x is SdkEvent {
50
54
  return typeof (x as SdkEvent).type === "string";
51
55
  }
52
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
+
53
72
  // ============================================================
54
73
  // 常量
55
74
  // ============================================================
@@ -89,6 +108,35 @@ function computeWatchdogMs(maxTurns: number | undefined | null): number {
89
108
  /** stderr 累积上限(字符)。防止失控子进程打满父进程内存。保留尾部便于诊断。 */
90
109
  const STDERR_MAX_CHARS = 64 * 1024;
91
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
+
92
140
  // ============================================================
93
141
  // 孤儿进程兜底(C1)
94
142
  // ============================================================
@@ -103,7 +151,9 @@ const STDERR_MAX_CHARS = 64 * 1024;
103
151
  // 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Set 移除,
104
152
  // 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
105
153
  // (对已退出的 child.kill 返回 false,无害)。
106
- const spawnedChildren = new Set<ChildProcess>();
154
+ //
155
+ // [export] 测试可观测(断言 dispose 后 size===0)。业务代码误外部修改。
156
+ export const spawnedChildren = new Set<ChildProcess>();
107
157
 
108
158
  /**
109
159
  * kill 所有未退出的 spawned 子进程(dispose 兜底用)。
@@ -135,6 +185,11 @@ export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): numb
135
185
  // best-effort:单个 kill 失败不影响其他子进程
136
186
  }
137
187
  }
188
+ // dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
189
+ // 这里兑底防 close 事件漏触发的极端累积(主进程崩溃后 close 回调可能不再触发,
190
+ // 不 clear 则下次 dispose 会重复向已 kill 的 child 发信号——虽然 killed=true 跳过,
191
+ // 但 Set 无限增长泄漏内存)。
192
+ spawnedChildren.clear();
138
193
  return n;
139
194
  }
140
195
 
@@ -160,6 +215,28 @@ export interface SessionRunnerContext {
160
215
  * 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
161
216
  */
162
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;
163
240
  }
164
241
 
165
242
  /** SessionRunner.run 的入参。 */
@@ -305,7 +382,7 @@ export function buildEnvBlock(
305
382
  }
306
383
 
307
384
  // ============================================================
308
- // [SPAWN 改造] runSpawn:spawn pi --mode json 子进程执行 session
385
+ // [SPAWN 改造] runSpawn:spawn pi --mode rpc 子进程执行 session
309
386
  // ============================================================
310
387
  //
311
388
  // 替代 in-process run()。核心差异:session 在独立子进程跑(进程隔离),
@@ -346,9 +423,11 @@ export function buildSpawnArgs(
346
423
  forkSource: string | undefined;
347
424
  skillPaths: string[] | undefined;
348
425
  },
349
- task: string,
350
426
  ): string[] {
351
- 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];
352
431
  if (params.model) args.push("--model", params.model);
353
432
  if (params.thinkingLevel && params.model) {
354
433
  // thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
@@ -372,7 +451,6 @@ export function buildSpawnArgs(
372
451
  args.push("--skill", sp);
373
452
  }
374
453
  }
375
- args.push(task);
376
454
  return args;
377
455
  }
378
456
 
@@ -394,15 +472,16 @@ export async function runSpawn(
394
472
  const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
395
473
 
396
474
  // b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
397
- // [M1] pi --mode json single-shot,无运行时 steer 通道。补偿:启动时通过
398
- // --append-system-prompt 预置 WRAP_UP_HINT(见上方 appendParts),让 agent 感知
399
- // 接近上限时主动收尾。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 注释)。
400
478
  let proc: ChildProcess | undefined;
401
479
  const limiter = createTurnLimiter({
402
480
  maxTurns: opts.maxTurns ?? 0,
403
481
  graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
404
482
  steer: () => {
405
- // no-op:spawn 无运行时 steer 通道,补偿已在启动时注入 WRAP_UP_HINT。
483
+ // no-op:当前 runSpawn 未接通 rpc stdin steer 通道(rpc mode 支持 steer/followUp,
484
+ // 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
406
485
  },
407
486
  abort: () => {
408
487
  proc?.kill("SIGTERM");
@@ -510,10 +589,14 @@ export async function runSpawn(
510
589
  const appendParts: string[] = [buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
511
590
  if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
512
591
  if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
513
- // [M1 补偿] spawn 模式无运行时 steer 通道(pi --mode json 是 single-shot),
514
- // 改为启动时预置 wrap-up 提示——agent 感知接近上限时主动收尾。
515
- // 长期方案:切到 pi --mode rpc(支持运行时 steer),见 follow-up。
592
+ // [M1 补偿] rpc mode steer 通道当前未接通,改为启动时预置 wrap-up 提示——
593
+ // agent 感知接近上限时主动收尾。
516
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
+ }
517
600
  if (appendParts.length > 0) {
518
601
  tempPromptFile = await writePromptToTempFile(record.agent, appendParts.join("\n\n"));
519
602
  }
@@ -543,7 +626,6 @@ export async function runSpawn(
543
626
  forkSource,
544
627
  skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
545
628
  },
546
- fullTask,
547
629
  );
548
630
  const invocation = getPiInvocation(spawnArgs);
549
631
 
@@ -556,7 +638,7 @@ export async function runSpawn(
556
638
  const child = spawn(invocation.command, invocation.args, {
557
639
  cwd: spawnCwd,
558
640
  shell: false,
559
- stdio: ["ignore", "pipe", "pipe"],
641
+ stdio: ["pipe", "pipe", "pipe"],
560
642
  env: childEnv,
561
643
  });
562
644
  proc = child;
@@ -571,6 +653,11 @@ export async function runSpawn(
571
653
  child.stdout.setEncoding("utf8");
572
654
  child.stderr.setEncoding("utf8");
573
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
+
574
661
  // d. signal → proc.kill 监听(一次性,替代 session.abort)
575
662
  const onAbort = (): void => {
576
663
  child.kill("SIGTERM");
@@ -591,8 +678,43 @@ export async function runSpawn(
591
678
  const watchdog = setTimeout(() => child.kill("SIGTERM"), watchdogMs);
592
679
  watchdog.unref();
593
680
 
594
- // 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>();
595
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
+
596
718
  child.stdout.on("data", (data: string) => {
597
719
  stdoutBuffer += data;
598
720
  const lines = stdoutBuffer.split("\n");
@@ -623,13 +745,61 @@ export async function runSpawn(
623
745
  if (opts.worktree && child.pid) {
624
746
  ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
625
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
+ }
626
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
+ }
627
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);
628
779
  }
629
780
  // invalid 行忽略(stdout 可能有调试输出)
630
781
  }
631
782
  });
632
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
+
633
803
  child.stderr.on("data", (data: string) => {
634
804
  // 截断防 OOM:失控子进程持续打 stderr 会耗尽父进程内存。保留尾部便于诊断。
635
805
  stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS);
@@ -637,9 +807,19 @@ export async function runSpawn(
637
807
 
638
808
  // 等待子进程退出
639
809
  const exitCode = await new Promise<number>((resolve) => {
640
- child.on("close", (code: number | null) => {
810
+ child.on("close", async (code: number | null) => {
641
811
  // [C1] 子进程已退出,从 orphan-tracking Set 移除(dispose 兜底无需再 kill 它)
642
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;
643
823
  // 处理 stdout 末尾残留行
644
824
  if (stdoutBuffer.trim()) {
645
825
  const parsed = parseSpawnLine(stdoutBuffer);
@@ -664,12 +844,16 @@ export async function runSpawn(
664
844
  // session.jsonl 由子进程写入,父进程在子进程退出后(写入完成)補写身份条目。
665
845
  // reconstructFromFile 依赖 IDENTITY_CUSTOM_TYPE custom entry 重建 record 身份,
666
846
  // 缺失则 /subagents list 磁盘源为空(终态 record 全丢失)。[回归修复]
667
- if (sessionHeader && record.sessionFile) {
668
- // 兜底:deriveSessionFilePath 推导的路径可能不存在(pi 命名规则变化),
847
+ if (record.sessionFile) {
848
+ // 兜底:deriveSessionFilePath 推导或握手返回的路径可能不存在(pi 命名规则变化),
669
849
  // 用 sessionId 后缀匹配实际文件。匹配到则修正 record.sessionFile。
850
+ // sessionId 来源:header(json mode)优先,其次握手结果(RPC mode)。
670
851
  if (!fs.existsSync(record.sessionFile)) {
671
- const actual = findSessionFileByHeaderId(sessionDir, sessionHeader.id);
672
- 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
+ }
673
857
  }
674
858
  // 补写 identity custom entry(子进程已退出,append 安全)。
675
859
  if (fs.existsSync(record.sessionFile)) {
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // pi 子进程 stdout JSON 事件流的解析器。Core 叶子原语(仅依赖 types.ts)。
4
4
  //
5
- // spawn 改造的基座模块。pi --mode json 子进程通过 stdout 输出两种行:
6
- // 1. header 行(首行):{ type: "session", id, timestamp, cwd, ... }
7
- // —— session 元信息,含 session id(文件路径由 W2 runSpawn 配合 --session-dir 推导)
5
+ // spawn 改造的基座模块。session-runner runSpawn 用 `pi --mode rpc` spawn 子进程。
6
+ // RPC mode 不向 stdout 输出 header 行(只有 json/print mode 才输出),故 runSpawn 额外
7
+ // 通过 get_state RPC 握手回填 sessionFile/sessionId。两种 stdout 行形态本模块统一解析:
8
+ // 1. header 行(json/print mode 首行):{ type: "session", id, timestamp, cwd, ... }
9
+ // —— session 元信息,含 session id(RPC mode 不发,靠 get_state 握手替代)
8
10
  // 2. 事件行:{ type: "tool_execution_start" | "message_end" | ..., ... }
9
11
  // —— 与 in-process session.subscribe 收到的 SdkEvent 同源同构
10
12
  //
@@ -30,10 +32,68 @@ export interface SpawnSessionHeader {
30
32
  readonly version?: number;
31
33
  }
32
34
 
33
- /** parseSpawnLine 的分类结果。 */
35
+ /** Pi 原生 extension_ui_request 的方法特定字段(按 method 平铺)。
36
+ * 与 Pi rpc-types.ts L230-265 的 RpcExtensionUIRequest 1:1 对应。
37
+ * method 是判别字段;每个变体仅列出该 method 的已知字段(可选字段保持可选)。
38
+ * 未知 method 走 string fallback(保留 raw 字段,避免协议演进时丢字段)。 */
39
+ export type ExtensionUiRequest =
40
+ | { method: "select"; title: string; options: string[]; timeout?: number }
41
+ | { method: "confirm"; title: string; message: string; timeout?: number }
42
+ | { method: "input"; title: string; placeholder?: string; timeout?: number }
43
+ | { method: "editor"; title: string; prefill?: string }
44
+ | { method: "notify"; message: string; notifyType?: "info" | "warning" | "error" }
45
+ | { method: "setStatus"; statusKey: string; statusText: string | undefined }
46
+ | {
47
+ method: "setWidget";
48
+ widgetKey: string;
49
+ widgetLines: string[] | undefined;
50
+ widgetPlacement?: "aboveEditor" | "belowEditor";
51
+ }
52
+ | { method: "setTitle"; title: string }
53
+ | { method: "set_editor_text"; text: string }
54
+ // 未知 method fallback:保留原始字段,避免协议演进时丢信息
55
+ | { method: string; raw: Record<string, unknown> };
56
+
57
+ /** 解析后的 extension_ui_request 顶层形状(type 守卫用)。
58
+ * id 和 method 在顶层,method 特定字段平铺(与 Pi 原生格式一致)。 */
59
+ interface ExtensionUiRequestEnvelope {
60
+ type: "extension_ui_request";
61
+ id: string;
62
+ method: string;
63
+ [key: string]: unknown;
64
+ }
65
+
66
+ /** Pi 原生 RPC response 顶层形状(type 守卫用)。
67
+ * 与 Pi rpc-types.ts 的 RpcResponse 一致:type:"response" + command + success。 */
68
+ interface RpcResponseEnvelope {
69
+ type: "response";
70
+ command: string;
71
+ success: boolean;
72
+ id?: string;
73
+ data?: unknown;
74
+ error?: string;
75
+ [key: string]: unknown;
76
+ }
77
+
78
+ /** parseSpawnLine 的分类结果。
79
+ *
80
+ * 关键改动(W1 协议层重写):
81
+ * - extension_ui_request 分支:从 {id, params:Record} 改为 {id, request: ExtensionUiRequest}
82
+ * (request 按 method 平铺,与 Pi rpc-types.ts 1:1)
83
+ * - response 分支:从 {id, result, error} 改为 {id?, command, success, data?, error?}
84
+ * (Pi 原生 RpcResponse 格式,SR-1 根因 1b) */
34
85
  export type ParsedSpawnLine =
35
86
  | { kind: "header"; header: SpawnSessionHeader }
36
87
  | { kind: "event"; event: SdkEvent }
88
+ | {
89
+ kind: "response";
90
+ id?: string;
91
+ command: string;
92
+ success: boolean;
93
+ data?: unknown;
94
+ error?: string;
95
+ }
96
+ | { kind: "extension_ui_request"; id: string; request: ExtensionUiRequest }
37
97
  | { kind: "invalid"; raw: string; error: string };
38
98
 
39
99
  /**
@@ -52,15 +112,149 @@ function isSessionHeader(obj: unknown): obj is SpawnSessionHeader {
52
112
  );
53
113
  }
54
114
 
115
+ /**
116
+ * 判断解析出的 JSON 是否为 Pi 原生 RPC response。
117
+ *
118
+ * SR-1 根因 1b 修复:旧守卫判 JSON-RPC 2.0(jsonrpc + id + result/error),
119
+ * 但 Pi 实际发 {type:"response", command, success, data?, error?}。
120
+ * 新守卫只认 Pi 原生格式:
121
+ * - type === "response"
122
+ * - command: string(调用的命令名,如 "run_tool")
123
+ * - success: boolean
124
+ * id 可选(通知型 response 无 id)。
125
+ *
126
+ * 旧 JSON-RPC 2.0 response({jsonrpc, id, result})不再被识别 → 落 invalid 分支。
127
+ */
128
+ function isRpcResponse(obj: unknown): obj is RpcResponseEnvelope {
129
+ if (typeof obj !== "object" || obj === null) return false;
130
+ const r = obj as Record<string, unknown>;
131
+ return (
132
+ r.type === "response" &&
133
+ typeof r.command === "string" &&
134
+ typeof r.success === "boolean"
135
+ );
136
+ }
137
+
138
+ /**
139
+ * 判断解析出的 JSON 是否为 Pi 原生 extension_ui_request。
140
+ *
141
+ * 关键改动(W1):旧守卫判 JSON-RPC 2.0(jsonrpc + method:"extension_ui_request" + params),
142
+ * 但 Pi 实际发平铺格式 {type:"extension_ui_request", id, method, ...method特定字段}。
143
+ * 新守卫:
144
+ * - type === "extension_ui_request"(顶层 type 字段,非 method 字段值)
145
+ * - id: string
146
+ * - method: string(select/confirm/.../set_editor_text 等具体方法名)
147
+ *
148
+ * 删掉 jsonrpc 守卫(Pi 不发 JSON-RPC 2.0 envelope)和 params 守卫(字段平铺,无 params 包裹)。
149
+ * 旧 JSON-RPC 2.0 格式({jsonrpc, method:"extension_ui_request", params})不再被识别。
150
+ */
151
+ function isExtensionUiRequest(obj: unknown): obj is ExtensionUiRequestEnvelope {
152
+ if (typeof obj !== "object" || obj === null) return false;
153
+ const r = obj as Record<string, unknown>;
154
+ return (
155
+ r.type === "extension_ui_request" &&
156
+ typeof r.id === "string" &&
157
+ typeof r.method === "string"
158
+ );
159
+ }
160
+
161
+ /**
162
+ * 从已通过 isExtensionUiRequest 守卫的 envelope 构造 ExtensionUiRequest 变体。
163
+ *
164
+ * 按 method 平铺提取字段(与 Pi rpc-types.ts L230-265 1:1)。已知 method
165
+ * 走对应变体;未知 method 走 string fallback(保留 raw 字段全量字段)。
166
+ *
167
+ * 字段类型容错:协议字段类型不符(如 options 非数组)时,该字段降级为空数组/undefined
168
+ *(数组类字段做元素类型过滤,剔除非字符串元素),仍归类为已知 method(不丢 method 信息)。
169
+ */
170
+ function buildExtensionUiRequest(env: ExtensionUiRequestEnvelope): ExtensionUiRequest {
171
+ const r: Record<string, unknown> = env;
172
+ switch (env.method) {
173
+ case "select":
174
+ return {
175
+ method: "select",
176
+ title: typeof r.title === "string" ? r.title : "",
177
+ options: Array.isArray(r.options)
178
+ ? r.options.filter((x): x is string => typeof x === "string")
179
+ : [],
180
+ ...(typeof r.timeout === "number" ? { timeout: r.timeout } : {}),
181
+ };
182
+ case "confirm":
183
+ return {
184
+ method: "confirm",
185
+ title: typeof r.title === "string" ? r.title : "",
186
+ message: typeof r.message === "string" ? r.message : "",
187
+ ...(typeof r.timeout === "number" ? { timeout: r.timeout } : {}),
188
+ };
189
+ case "input":
190
+ return {
191
+ method: "input",
192
+ title: typeof r.title === "string" ? r.title : "",
193
+ ...(typeof r.placeholder === "string" ? { placeholder: r.placeholder } : {}),
194
+ ...(typeof r.timeout === "number" ? { timeout: r.timeout } : {}),
195
+ };
196
+ case "editor":
197
+ return {
198
+ method: "editor",
199
+ title: typeof r.title === "string" ? r.title : "",
200
+ ...(typeof r.prefill === "string" ? { prefill: r.prefill } : {}),
201
+ };
202
+ case "notify":
203
+ return {
204
+ method: "notify",
205
+ message: typeof r.message === "string" ? r.message : "",
206
+ ...(r.notifyType === "info" || r.notifyType === "warning" || r.notifyType === "error"
207
+ ? { notifyType: r.notifyType }
208
+ : {}),
209
+ };
210
+ case "setStatus":
211
+ return {
212
+ method: "setStatus",
213
+ statusKey: typeof r.statusKey === "string" ? r.statusKey : "",
214
+ statusText: typeof r.statusText === "string" ? r.statusText : undefined,
215
+ };
216
+ case "setWidget": {
217
+ const placement = r.widgetPlacement;
218
+ const widgetLines = Array.isArray(r.widgetLines)
219
+ ? r.widgetLines.filter((x): x is string => typeof x === "string")
220
+ : undefined;
221
+ return {
222
+ method: "setWidget",
223
+ widgetKey: typeof r.widgetKey === "string" ? r.widgetKey : "",
224
+ widgetLines,
225
+ ...(placement === "aboveEditor" || placement === "belowEditor"
226
+ ? { widgetPlacement: placement }
227
+ : {}),
228
+ };
229
+ }
230
+ case "setTitle":
231
+ return {
232
+ method: "setTitle",
233
+ title: typeof r.title === "string" ? r.title : "",
234
+ };
235
+ case "set_editor_text":
236
+ return {
237
+ method: "set_editor_text",
238
+ text: typeof r.text === "string" ? r.text : "",
239
+ };
240
+ default:
241
+ // 未知 method:保留全部原始字段,避免协议演进时丢信息
242
+ return { method: env.method, raw: r };
243
+ }
244
+ }
245
+
55
246
  /**
56
247
  * 解析 pi stdout 的一行。
57
248
  *
58
249
  * @param line stdout 的一行(不含换行符;空行返回 null)
59
250
  * @returns 分类结果,或 null(空行/仅空白)
60
251
  *
61
- * 分类规则:
252
+ * 分类规则(判定顺序关键,见 W1 bug 修复):
62
253
  * - 空白行 → null(pi 可能输出空行,跳过)
63
- * - 合法 JSON + type:"session" + id → header
254
+ * - 合法 JSON + type:"session" + 必需字段 → header
255
+ * - 合法 JSON + type:"extension_ui_request" + id + method → extension_ui_request
256
+ * (必须在 event 分支之前,否则被 typeof obj.type===string 吞为 event)
257
+ * - 合法 JSON + type:"response" + command + success → response
64
258
  * - 合法 JSON + 有 type 字段 → event(SdkEvent,type schema 由调用方校验)
65
259
  * - 合法 JSON 但无 type → invalid
66
260
  * - 非法 JSON → invalid(记录 error,不抛——单行损坏不应中断整个流)
@@ -87,6 +281,25 @@ export function parseSpawnLine(line: string): ParsedSpawnLine | null {
87
281
  return { kind: "header", header: obj };
88
282
  }
89
283
 
284
+ // extension_ui_request:必须在 event 分支之前判定(W1 判定顺序 bug 修复)。
285
+ // 原因:extension_ui_request 也有 type 字段,若 event 分支(typeof obj.type===string)
286
+ // 在前,会被当 event 静默吞掉。现在先于 event 判定,命中后按 method 构造 request。
287
+ if (isExtensionUiRequest(obj)) {
288
+ return { kind: "extension_ui_request", id: obj.id, request: buildExtensionUiRequest(obj) };
289
+ }
290
+
291
+ // RPC response:Pi 原生格式 {type:"response", command, success, data?, error?}
292
+ if (isRpcResponse(obj)) {
293
+ return {
294
+ kind: "response",
295
+ ...(typeof obj.id === "string" ? { id: obj.id } : {}),
296
+ command: obj.command,
297
+ success: obj.success,
298
+ ...(obj.data !== undefined ? { data: obj.data } : {}),
299
+ ...(typeof obj.error === "string" ? { error: obj.error } : {}),
300
+ };
301
+ }
302
+
90
303
  // 事件行:必须有 type 字段(SdkEvent 契约)
91
304
  if (
92
305
  typeof obj === "object" &&