@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
@@ -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" &&
@@ -0,0 +1,106 @@
1
+ // src/execution/stdin-writer.ts
2
+ //
3
+ // 向 rpc 子进程 stdin 写入命令的 helper 集合。
4
+ //
5
+ // pi --mode rpc 通过 stdin 的 JSON RpcCommand / RpcExtensionUIResponse 驱动:
6
+ // - extension_ui_response(主进程回答子进程的 UI 请求,如 ask_user)
7
+ // - prompt(驱动子进程开始处理 task)
8
+ // 两者共用 child.stdin.write + 背压检查,提取到此模块统一维护。
9
+
10
+ import type { ChildProcess } from "node:child_process";
11
+ import * as crypto from "node:crypto";
12
+
13
+ import type { UiResponse } from "./dialog-queue.ts";
14
+
15
+ /**
16
+ * 按 UiResponse 形状构造 Pi 原生 extension_ui_response 并写 stdin。
17
+ *
18
+ * SR-5:ack(fire-and-forget)不写 stdin——Pi 对 fire-and-forget method 不期待响应,
19
+ * 写入会触发协议错配。其他三种 shape(value/confirmed/cancelled)按对应字段写。
20
+ *
21
+ * [R1] 背压检查:child.stdin.write 返回 false 时记 warn(不阻塞,内核缓冲会随后排空)。
22
+ * [R2] 序列化在本函数内逐分支完成。JSON.stringify 可能抛错(out.value 含循环引用 /
23
+ * BigInt 等不可序列化结构),try/catch 降级为 cancelled——宁可取消单次 dialog 也不让
24
+ * 父进程崩溃(UI 请求通道不应被脏数据拖垮)。
25
+ *
26
+ * @param child 子进程(stdin 写入响应)
27
+ * @param id 请求 id(关联 response)
28
+ * @param out UiResponse({value}/{confirmed}/{cancelled}/{ack})
29
+ * @param signal abort signal(已 aborted 时跳过写入)
30
+ */
31
+ export function respond(child: ChildProcess, id: string, out: UiResponse, signal?: AbortSignal): void {
32
+ if (signal?.aborted) return;
33
+ let line: string | undefined;
34
+ try {
35
+ if ("value" in out) line = JSON.stringify({ type: "extension_ui_response", id, value: out.value });
36
+ else if ("confirmed" in out) line = JSON.stringify({ type: "extension_ui_response", id, confirmed: out.confirmed });
37
+ else if ("cancelled" in out) line = JSON.stringify({ type: "extension_ui_response", id, cancelled: true });
38
+ } catch (err) {
39
+ // [R2] out.value 含循环引用/BigInt 等不可序列化结构——降级 cancelled,避免父进程崩溃。
40
+ console.warn(`[subagents] JSON.stringify failed for ui response ${id}, degrading to cancelled:`, err);
41
+ line = JSON.stringify({ type: "extension_ui_response", id, cancelled: true });
42
+ }
43
+ // ack: fire-and-forget,不写 stdin(SR-5)
44
+ if (line === undefined) return;
45
+ writeStdinLine(child, line, `ui response for request ${id}`);
46
+ }
47
+
48
+ /**
49
+ * spawn 后向 rpc 子进程 stdin 写 prompt 命令,驱动 agent 开始处理 task。
50
+ *
51
+ * pi 的 runRpcMode 只通过 stdin RpcCommand 驱动——positional task arg / -p flag
52
+ * 在 rpc mode 下被 resolveAppMode 无视。必须在 spawn 后主动喂 prompt 命令,
53
+ * 否则子进程阻塞等 stdin、永不进入推理(totalTokens 恒 0)。
54
+ *
55
+ * 时机:spawn 后立即写。stdin 是 pipe,内核缓冲保证数据不丢;
56
+ * pi 在 await rebindSession() 后才挂 stdin reader(rpc-mode.ts:778-781),
57
+ * reader 处理 prompt 时 session 已就绪。
58
+ *
59
+ * @param child 子进程(stdin 写入 prompt 命令)
60
+ * @param task 完整 task 文本(含 schema 指令)
61
+ */
62
+ export function sendPromptCommand(child: ChildProcess, task: string): void {
63
+ if (!child.stdin || child.stdin.destroyed) return;
64
+ const command = JSON.stringify({
65
+ id: crypto.randomUUID(),
66
+ type: "prompt",
67
+ message: task,
68
+ });
69
+ writeStdinLine(child, command, "prompt command");
70
+ }
71
+
72
+ /**
73
+ * 向 rpc 子进程 stdin 写 get_state 命令,查询 sessionFile/sessionId。
74
+ *
75
+ * FR-4: RPC get_state 握手。当 stdout header 未携带 sessionFile 时,
76
+ * 通过此命令向子进程查询当前 session 状态。子进程收到后返回
77
+ * {type:"response", command:"get_state", success:true, data:{sessionFile, sessionId}}。
78
+ *
79
+ * @param child 子进程(stdin 写入 get_state 命令)
80
+ * @returns 请求 id(用于匹配 response)
81
+ */
82
+ export function sendGetStateCommand(child: ChildProcess): string {
83
+ const id = crypto.randomUUID();
84
+ const command = JSON.stringify({
85
+ id,
86
+ type: "get_state",
87
+ });
88
+ writeStdinLine(child, command, "get_state command");
89
+ return id;
90
+ }
91
+
92
+ /**
93
+ * 向子进程 stdin 写一行(自动补换行),带背压检查。
94
+ *
95
+ * [R1] write 返回 false 时记 warn(不阻塞,内核缓冲会随后排空)。
96
+ * stdin 已关闭/销毁时跳过——respond 已检查 signal,sendPromptCommand 已检查 destroyed。
97
+ *
98
+ * @param child 子进程
99
+ * @param line JSON 行(不含换行)
100
+ * @param warnTag warn 日志的语义标记
101
+ */
102
+ function writeStdinLine(child: ChildProcess, line: string, warnTag: string): void {
103
+ if (!child.stdin || child.stdin.destroyed) return;
104
+ const ok = child.stdin.write(line + "\n");
105
+ if (!ok) console.warn(`[subagents] stdin backpressure on ${warnTag}`);
106
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * subagent text_delta streaming sink。
3
+ *
4
+ * background subagent 执行期间,session-runner 的 agentEvent 出口把每个 text_delta
5
+ * 传到 SubagentStream.onDelta。本模块做 100ms 时间窗合并后,通过 StreamSink.setWidget
6
+ * 转发到 RPC stdout(经 ctx.ui.setWidget → extension_ui_request 通道)。
7
+ *
8
+ * SubagentStream 是一个生命周期对象——内聚 buffer/timer 状态 + onDelta/dispose 方法。
9
+ * 调用方(subagent-service)创建后只需在 text_delta 时调 onDelta、终态时调 dispose,
10
+ * 不需要拆散 push/clear 两个函数跨层透传。
11
+ *
12
+ * 设计要点:
13
+ * - leading edge:第一个 delta 立即 flush(前端尽快看到开始)
14
+ * - trailing edge:后续 delta 追加 buffer,timer 到期后 flush
15
+ * - 每次 flush 把 buffer 的累积全文 split("\n") 传给 setWidget
16
+ * - dispose 清除 widget + 清 timer
17
+ */
18
+
19
+ /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
20
+ *
21
+ * 当前只有一个 adapter(index.ts session_start 包装 ctx.ui.setWidget)。
22
+ * 保留接口而非裸函数类型,因为 StreamSink 的语义是「UI sink 契约」——
23
+ * 测试 mock 和未来可能的第二 sink(如写文件)都走此契约。 */
24
+ export interface StreamSink {
25
+ setWidget(key: string, lines: string[] | undefined): void;
26
+ }
27
+
28
+ /** delta 合并窗口时间(ms)。与 onEventThrottled 的节流间隔对齐。 */
29
+ const STREAM_FLUSH_MS = 100;
30
+
31
+ /**
32
+ * subagent text_delta streaming 生命周期对象。
33
+ *
34
+ * 创建后:
35
+ * - `onDelta(delta)`:session-runner 每次 text_delta 调
36
+ * - `dispose()`:subagent 终态时调,清除 widget + 清 timer
37
+ *
38
+ * buffer/timer 状态全部内聚在此对象,调用方不需要关心合并逻辑。
39
+ */
40
+ export class SubagentStream {
41
+ private readonly widgetKey: string;
42
+ private readonly sink: StreamSink;
43
+ private buffer = "";
44
+ private timer: ReturnType<typeof setTimeout> | undefined;
45
+ private hasFlushed = false;
46
+ private disposed = false;
47
+
48
+ constructor(recordId: string, sink: StreamSink) {
49
+ this.widgetKey = `subagent-stream-${recordId}`;
50
+ this.sink = sink;
51
+ }
52
+
53
+ /** 接收一个 text_delta 增量。空串静默丢弃(不消耗 leading edge)。 */
54
+ onDelta(delta: string): void {
55
+ if (this.disposed || delta.length === 0) return;
56
+ this.buffer += delta;
57
+ if (!this.hasFlushed) {
58
+ // leading edge:第一个 delta 立即 flush
59
+ this.hasFlushed = true;
60
+ this.flush();
61
+ } else if (this.timer === undefined) {
62
+ // trailing edge:后续 delta 经 timer 合并
63
+ this.timer = setTimeout(() => this.flush(), STREAM_FLUSH_MS);
64
+ }
65
+ }
66
+
67
+ /** 终态清理:清除 widget + 清 timer(幂等)。 */
68
+ dispose(): void {
69
+ if (this.disposed) return;
70
+ this.disposed = true;
71
+ if (this.timer !== undefined) {
72
+ clearTimeout(this.timer);
73
+ this.timer = undefined;
74
+ }
75
+ this.sink.setWidget(this.widgetKey, undefined);
76
+ }
77
+
78
+ private flush(): void {
79
+ this.timer = undefined;
80
+ if (this.buffer.length === 0 || this.disposed) return;
81
+ this.sink.setWidget(this.widgetKey, this.buffer.split("\n"));
82
+ }
83
+ }