@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.
- package/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +1 -1
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +9 -2
- 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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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
|
|
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 补偿]
|
|
514
|
-
//
|
|
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: ["
|
|
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 (
|
|
668
|
-
// 兜底:deriveSessionFilePath
|
|
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
|
|
672
|
-
if (
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
/**
|
|
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" +
|
|
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" &&
|