@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.
- package/README.md +56 -0
- package/agents/context-builder.md +1 -3
- package/agents/explorer.md +27 -0
- package/agents/oracle.md +2 -2
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- 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__/concurrency-pool.test.ts +33 -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 +41 -9
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
- 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__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -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-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +27 -5
- 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/agent-result-mapper.ts +4 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -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 +110 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +228 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +230 -235
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- 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 +101 -4
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/format.ts +2 -0
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +44 -24
- package/src/interface/subagent-tool.ts +56 -24
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/agents/scout.md +0 -17
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/execution/ui-request-queue.ts
|
|
2
|
+
//
|
|
3
|
+
// W3/W2: 子进程 extension_ui_request 的 FIFO 串行队列 + 转发处理。
|
|
4
|
+
//
|
|
5
|
+
// 从 session-runner.ts 提取(保持文件 < 1000 行)。职责单一:
|
|
6
|
+
// - createUiRequestQueue:每个子进程一个队列,保证多个 UI 请求(ask_user 等)
|
|
7
|
+
// FIFO 串行处理,防止并发询问用户导致交错。
|
|
8
|
+
// - handleUiRequest:从 ExtensionUiRequest 构造 UiRequest → 调主 agent uiRequestHandler
|
|
9
|
+
// → 按 UiResponse 形状回写 stdin。
|
|
10
|
+
// - extractMethodFields:method-specific 字段类型安全复制。
|
|
11
|
+
//
|
|
12
|
+
// session-runner.runSpawn 在 stdout pump 中拿到 extension_ui_request 后调 enqueue 入队。
|
|
13
|
+
|
|
14
|
+
import type { ChildProcess } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
import type { UiRequest } from "./dialog-queue.ts";
|
|
17
|
+
// 类型再导出:dialog-queue.ts 是 UiRequest/UiResponse/UiRequestHandler 的规范来源,
|
|
18
|
+
// 本模块再导出供测试 import(避免测试直接依赖 dialog-queue 内部实现)。
|
|
19
|
+
export type { UiRequest, UiRequestHandler, UiResponse } from "./dialog-queue.ts";
|
|
20
|
+
import type { SessionRunnerContext } from "./session-runner.ts";
|
|
21
|
+
import type { ExtensionUiRequest } from "./spawn-event-adapter.ts";
|
|
22
|
+
import { respond } from "./stdin-writer.ts";
|
|
23
|
+
import { parseChannel } from "./ui-channels.ts";
|
|
24
|
+
import { notifyMissingHandlerGlobal } from "./ui-request-observability.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建 UI 请求队列。返回 enqueue 函数,调用方将 extension_ui_request 入队。
|
|
28
|
+
*
|
|
29
|
+
* 多个 extension_ui_request 并发到达时,队列保证 FIFO 串行处理:
|
|
30
|
+
* 前一个请求的 uiRequestHandler resolve 后,才将下一个请求发给主 agent UI。
|
|
31
|
+
* 防止并发询问用户导致交错(用户同时看到多个问题)。
|
|
32
|
+
*
|
|
33
|
+
* 设计:队列是 runSpawn 生命周期内的闭包状态(非模块级),
|
|
34
|
+
* 每个子进程实例独立队列,无跨 session 泄漏。
|
|
35
|
+
*
|
|
36
|
+
* @param child 子进程(stdin 写入 extension_ui_response)
|
|
37
|
+
* @param ctx SessionRunnerContext(含 uiRequestHandler 回调)
|
|
38
|
+
* @returns enqueue 函数:(id, request) => void,将请求入队并触发顺序处理
|
|
39
|
+
*/
|
|
40
|
+
export function createUiRequestQueue(
|
|
41
|
+
child: ChildProcess,
|
|
42
|
+
ctx: SessionRunnerContext,
|
|
43
|
+
): (id: string, request: ExtensionUiRequest) => void {
|
|
44
|
+
// [R3] AbortController 取消 pending handler——子进程退出时队列不再阻塞
|
|
45
|
+
const abortController = new AbortController();
|
|
46
|
+
const queue: Array<{ id: string; request: ExtensionUiRequest; signal: AbortSignal }> = [];
|
|
47
|
+
let processing = false;
|
|
48
|
+
let closed = false;
|
|
49
|
+
|
|
50
|
+
function processNext(): void {
|
|
51
|
+
if (processing || queue.length === 0 || closed) return;
|
|
52
|
+
processing = true;
|
|
53
|
+
const { id, request, signal } = queue.shift()!;
|
|
54
|
+
handleUiRequest(child, id, request, ctx, signal).finally(() => {
|
|
55
|
+
processing = false;
|
|
56
|
+
processNext();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// [R3] 子进程退出时 abort 所有 pending handler,队列不再阻塞
|
|
61
|
+
// [SR-4] 同步清理 L2 队列中该 child 的 pending dialog——child 在 dialog 等 L2 时退出,
|
|
62
|
+
// L2 里该项永不 settle → processing 永远 true → 所有其他子进程 dialog 永久阻塞(全局死锁)。
|
|
63
|
+
// pid 缺省(spawn 后极短窗口 child.pid 可能为 undefined)时跳过——此时该 child 还未在 L2
|
|
64
|
+
// 注册过任何 dialog(handleUiRequest 构造 UiRequest 时用同样的 child.pid,pid undefined 时
|
|
65
|
+
// 不填 _childPid,rejectChildDialogs 也匹配不到),无清理必要。
|
|
66
|
+
const onClose = (): void => {
|
|
67
|
+
// #19/#17 幂等守卫:close + error 可能都触发,二次调用会重复 rejectChildDialogs(虽依赖 L2 的 settled 幂等,
|
|
68
|
+
// 但 close 状态本身需守卫——避免 closed=true 后 queue.length=0 + abort 重复执行,以及重复 reject 导致语义噪声)。
|
|
69
|
+
if (closed) return;
|
|
70
|
+
closed = true;
|
|
71
|
+
abortController.abort();
|
|
72
|
+
queue.length = 0;
|
|
73
|
+
if (child.pid !== undefined) {
|
|
74
|
+
ctx.dialogQueue?.rejectChildDialogs({ pid: child.pid });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
child.on("close", onClose);
|
|
78
|
+
child.on("error", onClose);
|
|
79
|
+
|
|
80
|
+
return function enqueue(id: string, request: ExtensionUiRequest): void {
|
|
81
|
+
if (closed) return;
|
|
82
|
+
queue.push({ id, request, signal: abortController.signal });
|
|
83
|
+
processNext();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 处理子进程发来的 extension_ui_request(ask_user 及其他 Pi UI method)。
|
|
89
|
+
*
|
|
90
|
+
* 流程:从 ExtensionUiRequest 构造 UiRequest(含 channel/channelPayload)
|
|
91
|
+
* → 调用主 agent uiRequestHandler → 按 UiResponse 形状回写 stdin。
|
|
92
|
+
*
|
|
93
|
+
* handler 未设置时不再静默忽略——console.warn 兜底(FR-9 可观测性),
|
|
94
|
+
* W3 接入 SubagentService.notifyMissingHandler 的 appendEntry。
|
|
95
|
+
*
|
|
96
|
+
* @param child 子进程(stdin 写入响应)
|
|
97
|
+
* @param id 请求 id(子进程用它关联 response)
|
|
98
|
+
* @param request ExtensionUiRequest(method 平铺,从 enqueueUiRequest 传入)
|
|
99
|
+
* @param ctx SessionRunnerContext(含 uiRequestHandler 回调)
|
|
100
|
+
* @param signal abort signal(子进程退出时触发,取消正在等待的 handler)
|
|
101
|
+
* @returns Promise(队列等待用:resolve 表示响应已写入 stdin 或已放弃)
|
|
102
|
+
*/
|
|
103
|
+
async function handleUiRequest(
|
|
104
|
+
child: ChildProcess,
|
|
105
|
+
id: string,
|
|
106
|
+
request: ExtensionUiRequest,
|
|
107
|
+
ctx: SessionRunnerContext,
|
|
108
|
+
signal?: AbortSignal,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const handler = ctx.uiRequestHandler;
|
|
111
|
+
if (!handler) {
|
|
112
|
+
// #9 + #11:handler 缺失时不再静默 return(会让子进程永久挂起等 response)。
|
|
113
|
+
// - notifyMissingHandlerGlobal:经 globalThis 桥接到 observability 单例做 per-session 去重告警
|
|
114
|
+
// (FR-9 可观测性;未注册时走 fallback warn 不丢日志)
|
|
115
|
+
// - respond(cancelled):让子进程收到明确取消,不再永久挂起。等价于用户主动取消的语义。
|
|
116
|
+
// 去重 key 用 child.pid(队列内拿不到真实 sessionId;pid 是子进程会话的稳定标识,
|
|
117
|
+
// 去重粒度正确——同一子进程多次 handler 缺失只告警一次)。
|
|
118
|
+
notifyMissingHandlerGlobal(child.pid?.toString() ?? id);
|
|
119
|
+
respond(child, id, { cancelled: true }, signal);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 从 ExtensionUiRequest 构造 UiRequest(含 channel/channelPayload)
|
|
124
|
+
const { channel, channelPayload } = parseChannel(request);
|
|
125
|
+
// [SR-4] 填入 child.pid 作为内部元数据:L2 队列的 rejectChildDialogs 据此关联 child close
|
|
126
|
+
// 清理。factory 层 enqueue 时读 req._childPid 传给 opts.child。pid undefined(spawn 后极短
|
|
127
|
+
// 窗口)时不填——rejectChildDialogs 也匹配不到(onClose 同样用 child.pid 守卫),无副作用。
|
|
128
|
+
const uiReq: UiRequest = {
|
|
129
|
+
id,
|
|
130
|
+
method: request.method,
|
|
131
|
+
...(child.pid !== undefined ? { _childPid: child.pid } : {}),
|
|
132
|
+
...(channel !== undefined ? { channel } : {}),
|
|
133
|
+
...(channelPayload !== undefined ? { channelPayload } : {}),
|
|
134
|
+
...extractMethodFields(request),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await handler(uiReq);
|
|
139
|
+
// [R3] 子进程已退出,跳过写入
|
|
140
|
+
if (signal?.aborted) return;
|
|
141
|
+
respond(child, id, result, signal);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// [R3] 子进程已退出,跳过写入
|
|
144
|
+
if (signal?.aborted) return;
|
|
145
|
+
console.error("[subagents] uiRequestHandler threw:", err);
|
|
146
|
+
respond(child, id, { cancelled: true }, signal);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 从 ExtensionUiRequest 提取 method-specific 字段到 UiRequest(与 Pi rpc-types.ts 1:1)。
|
|
151
|
+
* 按 method 变体类型安全地复制对应字段;缺失字段不复制(保持 UiRequest 可选)。 */
|
|
152
|
+
function extractMethodFields(req: ExtensionUiRequest): Partial<UiRequest> {
|
|
153
|
+
const out: Partial<UiRequest> = {};
|
|
154
|
+
if ("title" in req && typeof req.title === "string") out.title = req.title;
|
|
155
|
+
if ("options" in req && Array.isArray(req.options)) out.options = req.options;
|
|
156
|
+
if ("message" in req && typeof req.message === "string") out.message = req.message;
|
|
157
|
+
if ("placeholder" in req && typeof req.placeholder === "string") out.placeholder = req.placeholder;
|
|
158
|
+
if ("prefill" in req && typeof req.prefill === "string") out.prefill = req.prefill;
|
|
159
|
+
if ("notifyType" in req && typeof req.notifyType === "string") out.notifyType = req.notifyType;
|
|
160
|
+
if ("statusKey" in req && typeof req.statusKey === "string") out.statusKey = req.statusKey;
|
|
161
|
+
if ("statusText" in req) out.statusText = req.statusText;
|
|
162
|
+
if ("widgetKey" in req && typeof req.widgetKey === "string") out.widgetKey = req.widgetKey;
|
|
163
|
+
if ("widgetLines" in req) out.widgetLines = req.widgetLines;
|
|
164
|
+
if ("widgetPlacement" in req) out.widgetPlacement = req.widgetPlacement;
|
|
165
|
+
if ("text" in req && typeof req.text === "string") out.text = req.text;
|
|
166
|
+
if ("timeout" in req && typeof req.timeout === "number") out.timeout = req.timeout;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,12 +17,15 @@ import * as fs from "node:fs";
|
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
|
|
20
|
-
import type { ExtensionAPI, ExtensionContext, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ResourcesDiscoverEvent, ResourcesDiscoverResult, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@mariozechner/pi-coding-agent";
|
|
21
21
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
22
22
|
|
|
23
23
|
import type { AgentRegistry } from "./execution/agent-registry.ts";
|
|
24
24
|
import { bestEffort } from "./execution/best-effort.ts";
|
|
25
25
|
// ═══ execution/ 层(subagents 核心 + 运行时) ═══
|
|
26
|
+
import { getOrCreateChannelRegistry } from "./execution/channel-registry-access.ts";
|
|
27
|
+
import { DialogGlobalQueue } from "./execution/dialog-queue.ts";
|
|
28
|
+
import { createUiRequestHandlerForMode } from "./execution/ui-request-handler-factory.ts";
|
|
26
29
|
import {
|
|
27
30
|
getModelConfigService,
|
|
28
31
|
ModelConfigService,
|
|
@@ -38,6 +41,7 @@ import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
|
|
|
38
41
|
import { WorktreeManager } from "./execution/worktree-manager.ts";
|
|
39
42
|
import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
|
|
40
43
|
import { registerWorkflowsCommand } from "./interface/commands.ts";
|
|
44
|
+
import { toGuiCtx } from "./interface/gui-mappers.ts";
|
|
41
45
|
import { notifyDone } from "./interface/helpers.ts";
|
|
42
46
|
import { registerSubagentTool } from "./interface/subagent-tool.ts";
|
|
43
47
|
// ═══ interface/ 层(tools/commands/tui 合并) ═══
|
|
@@ -168,7 +172,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
168
172
|
runner: state.runner,
|
|
169
173
|
runs: state.runs,
|
|
170
174
|
registry,
|
|
171
|
-
onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, sessionCtx),
|
|
175
|
+
onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx)),
|
|
172
176
|
agentRegistry: state.agentRegistry,
|
|
173
177
|
sessionDir: state.sessionDir,
|
|
174
178
|
activeTempFiles: state.activeTempFiles,
|
|
@@ -177,6 +181,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
177
181
|
scheduleTimeBudget(runId, deps, budgetTimeMs),
|
|
178
182
|
onWorkflowCall: (name: string, args: Record<string, unknown>, parentRun: WorkflowRun) =>
|
|
179
183
|
executeNestedWorkflow(name, args, parentRun, deps),
|
|
184
|
+
streamSink: getSubagentService()?.getStreamSink() ?? undefined,
|
|
180
185
|
log,
|
|
181
186
|
};
|
|
182
187
|
return deps;
|
|
@@ -211,9 +216,35 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
211
216
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
212
217
|
ctxModel: ctx.model ?? undefined,
|
|
213
218
|
});
|
|
219
|
+
|
|
220
|
+
// ── W3: handler 注入链路接通 ──
|
|
221
|
+
// 进程级单例:channel registry + dialog queue 跨 session 复用
|
|
222
|
+
//(与 SubagentService 单例模式一致,globalThis Symbol 持有避免 jiti 多实例分裂)。
|
|
223
|
+
const channelRegistry = getOrCreateChannelRegistry();
|
|
224
|
+
const dialogQueue = getOrCreateDialogQueue();
|
|
225
|
+
const uiRequestHandler = createUiRequestHandlerForMode(ctx, channelRegistry, dialogQueue);
|
|
226
|
+
// SR-3: 无论 new 还是 existing(/resume /fork 复用),session_start 都必须注入 handler
|
|
227
|
+
service.setUiRequestHandler(uiRequestHandler);
|
|
228
|
+
|
|
214
229
|
service.initSession({
|
|
215
230
|
pi,
|
|
216
231
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
232
|
+
// 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
|
|
233
|
+
// background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
|
|
234
|
+
// [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
|
|
235
|
+
// rpc mode(GUI/xyz-agent)下保持原行为(ctx.ui.setWidget → sidecar → chatStore)。
|
|
236
|
+
// streamSink API 不变(SubagentStream.onDelta 仍可调,只是 TUI 下 stream 不会被创建)。
|
|
237
|
+
streamSink: ctx.mode === "rpc"
|
|
238
|
+
? { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) }
|
|
239
|
+
: undefined,
|
|
240
|
+
// [#24] uiRequestHandler 单一注入入口:上方 setUiRequestHandler 已注入(SR-3 语义,
|
|
241
|
+
// new/existing service 均覆盖)。此处不再重复传 initSession.uiRequestHandler,避免
|
|
242
|
+
// 同一 handler 双路径注入造成的语义混淆与“哪一个是 source of truth”歧义。
|
|
243
|
+
// mode 仍需 session 级注入(uiObservability.setMode 依赖它,与 handler 无关)。
|
|
244
|
+
mode: ctx.mode,
|
|
245
|
+
// SR-4:注入 L2 dialog 队列——session-runner child close 时调 rejectChildDialogs
|
|
246
|
+
// 清理该 child 在 L2 的 pending dialog,防全局死锁(C1 修复:清理路径接通)。
|
|
247
|
+
dialogQueue,
|
|
217
248
|
});
|
|
218
249
|
|
|
219
250
|
if (!existingService) {
|
|
@@ -230,6 +261,18 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
230
261
|
console.warn("[subagents] expired session file cleanup failed:", err);
|
|
231
262
|
}
|
|
232
263
|
|
|
264
|
+
// ADR-035 启动恢复:扫描 manifest tmp 残留(崩溃打断的 writeManifest 留下),
|
|
265
|
+
// 每次 session_start 都调(与上方 maybeCleanupExpiredSessionFiles 一致)。
|
|
266
|
+
try {
|
|
267
|
+
const recovered = await service.recoverManifestTmpFiles();
|
|
268
|
+
if (recovered.recovered > 0 || recovered.deleted > 0) {
|
|
269
|
+
console.warn(`[subagents] manifest tmp recovery: ${recovered.recovered} promoted, ${recovered.deleted} deleted`);
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
void err;
|
|
273
|
+
console.warn("[subagents] manifest tmp recovery failed:", err);
|
|
274
|
+
}
|
|
275
|
+
|
|
233
276
|
try {
|
|
234
277
|
const wtm = new WorktreeManager(agentDir);
|
|
235
278
|
wtm.scan();
|
|
@@ -296,17 +339,23 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
296
339
|
// ════════════════════════════════════════════════════════════
|
|
297
340
|
// model_select:用户切换 model 时刷新缓存
|
|
298
341
|
// ════════════════════════════════════════════════════════════
|
|
299
|
-
pi.on("model_select", (event:
|
|
342
|
+
pi.on("model_select", (event: ModelSelectEvent, ctx: ExtensionContext) => {
|
|
300
343
|
const service = getModelConfigService();
|
|
301
344
|
if (service && typeof service.setCtxModel === "function") {
|
|
302
345
|
service.setCtxModel(event.model);
|
|
303
346
|
}
|
|
347
|
+
// H1: 同步刷新所有 session 的 SAR ctxModel(旧实现只在 session_start 固化)
|
|
348
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
349
|
+
const state = sessionState.get(sid);
|
|
350
|
+
if (state) {
|
|
351
|
+
state.runner.updateCtxModel(event.model);
|
|
352
|
+
}
|
|
304
353
|
});
|
|
305
354
|
|
|
306
355
|
// ════════════════════════════════════════════════════════════
|
|
307
356
|
// session_tree:切分支前 pause 所有 running run
|
|
308
357
|
// ════════════════════════════════════════════════════════════
|
|
309
|
-
pi.on("session_tree", async (_event:
|
|
358
|
+
pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
|
|
310
359
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
311
360
|
lsRef.lastSessionId = sessionId;
|
|
312
361
|
|
|
@@ -342,6 +391,20 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
342
391
|
cleanupAllFiles(state.activeTempFiles);
|
|
343
392
|
sessionState.delete(sessionId);
|
|
344
393
|
}
|
|
394
|
+
|
|
395
|
+
// M2: 清理 dialog queue 运行时状态(queue/current/processing)。
|
|
396
|
+
// [#10] 先 rejectAll() settle 所有 pending dialog Promise(防闭包泄漏:未 settle 的
|
|
397
|
+
// Promise 持有 resolve/reject 闭包及 handler 上下文,session 退出后仍挂在全球队列上),
|
|
398
|
+
// 再 clear() 重置 queue/current/processing(防异常退出后 processing=true 卡死下次 session)。
|
|
399
|
+
// rejectAll() 由 dialog-queue.ts 提供(Group B 新增);若其内部已 reset 状态,此处 clear() 为幂等兜底。
|
|
400
|
+
// 单 session 假设(M-2,同 lastSessionId):rejectAll() 清空进程级单例的所有 pending,
|
|
401
|
+
// 依赖 Pi 单进程单 session 串行保证——不会误清其他 session。多 session 并发的迁移策略
|
|
402
|
+
// 见 DialogGlobalQueue 类注释(rejectAllForSession)。
|
|
403
|
+
// channel registry 不清:跨 session 持久是有意设计(ask-user 扩展注册的 channel handler
|
|
404
|
+
// 在 /new /resume /fork 时不丢失注册)。
|
|
405
|
+
const dialogQueue = getOrCreateDialogQueue();
|
|
406
|
+
dialogQueue.rejectAll();
|
|
407
|
+
dialogQueue.clear();
|
|
345
408
|
});
|
|
346
409
|
|
|
347
410
|
// ════════════════════════════════════════════════════════════
|
|
@@ -452,3 +515,37 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
452
515
|
lazyDeps,
|
|
453
516
|
);
|
|
454
517
|
}
|
|
518
|
+
|
|
519
|
+
// ============================================================
|
|
520
|
+
// 进程级单例(channel registry + dialog queue)
|
|
521
|
+
// ============================================================
|
|
522
|
+
|
|
523
|
+
// channel registry 经 channel-registry-access.ts 公开访问(跨扩展 API)。
|
|
524
|
+
// dialog queue 仍为本模块私有——无外部消费者。
|
|
525
|
+
const DIALOG_QUEUE_KEY = Symbol.for("@zhushanwen/pi-subagents.dialogQueue");
|
|
526
|
+
|
|
527
|
+
/** 获取或创建进程级 dialog queue 单例。
|
|
528
|
+
* L2 跨子进程串行队列——所有子进程的 dialog 类请求共享同一队列实例。 */
|
|
529
|
+
function getOrCreateDialogQueue(): DialogGlobalQueue {
|
|
530
|
+
let queue = Reflect.get(globalThis, DIALOG_QUEUE_KEY) as DialogGlobalQueue | undefined;
|
|
531
|
+
if (!queue) {
|
|
532
|
+
queue = new DialogGlobalQueue();
|
|
533
|
+
Reflect.set(globalThis, DIALOG_QUEUE_KEY, queue);
|
|
534
|
+
}
|
|
535
|
+
return queue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ============================================================
|
|
539
|
+
// Public cross-extension API(channel handler 注册入口)
|
|
540
|
+
// ============================================================
|
|
541
|
+
//
|
|
542
|
+
// 跨扩展消费者(ask-user 等)通过包根 import 注册 channel handler,
|
|
543
|
+
// 让 subagent 子进程的 UI 请求(ask_user 等)透传到主进程渲染。
|
|
544
|
+
// 重新导出 channel-registry-access 的公开 API——稳定 surface,
|
|
545
|
+
// 内部存储实现演进不影响消费者。
|
|
546
|
+
|
|
547
|
+
export {
|
|
548
|
+
getOrCreateChannelRegistry,
|
|
549
|
+
type UiChannelRegistry,
|
|
550
|
+
type ChannelHandler,
|
|
551
|
+
} from "./execution/channel-registry-access.ts";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 提示词质量回归:subagent tool description 必须是"行为约束器"而非"功能说明书"。
|
|
2
|
+
//
|
|
3
|
+
// agent(LLM)决策时唯一能看到的 tool 元信息就是 description。审查发现旧版
|
|
4
|
+
// description 把篇幅花在功能说明上,缺乏调用信号(何时委派 vs 自己做)、
|
|
5
|
+
// 能力边界(cannot)、反模式密度(高风险要求 ≥4 条)、以及对 auto-injected
|
|
6
|
+
// completion message 的注入防御。
|
|
7
|
+
//
|
|
8
|
+
// 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把约束措辞
|
|
9
|
+
// 删掉或弱化。读源码而非 import,避免 mock 链(subagent-tool.ts 依赖 pi-ai/
|
|
10
|
+
// typebox/pi-tui/ExtensionAPI 等值导入)。
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const SUBAGENT_TOOL_SRC = readFileSync(
|
|
19
|
+
join(__dirname, "../subagent-tool.ts"),
|
|
20
|
+
"utf-8",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** 提取 description: `...` 模板字符串的原始内容。 */
|
|
24
|
+
function extractDescription(src: string): string {
|
|
25
|
+
const m = src.match(/description:\s*`([\s\S]*?)`,/);
|
|
26
|
+
if (!m) throw new Error("description template literal not found");
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DESCRIPTION = extractDescription(SUBAGENT_TOOL_SRC);
|
|
31
|
+
|
|
32
|
+
describe("subagent tool description — 行为约束器(非功能说明书)", () => {
|
|
33
|
+
it("词数 ≤ 400(高风险 description 密度上限)", () => {
|
|
34
|
+
// 高风险 tool 的 description 应聚焦约束而非功能铺陈;过长会稀释信号。
|
|
35
|
+
const words = DESCRIPTION.trim().split(/\s+/).filter(Boolean).length;
|
|
36
|
+
expect(words).toBeLessThanOrEqual(400);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("含 'When to delegate' 调用条件段(何时委派 vs 自己做)", () => {
|
|
40
|
+
// 开篇必须给信号驱动的调用条件,而非纯功能说明。
|
|
41
|
+
expect(DESCRIPTION).toMatch(/When to delegate/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("Anti-patterns 段含 ≥ 4 条 bullet", () => {
|
|
45
|
+
// 高风险 tool 要求 ≥ 4 条反模式,密度才足以覆盖主要误用路径。
|
|
46
|
+
const apIdx = DESCRIPTION.indexOf("## Anti-patterns");
|
|
47
|
+
expect(apIdx).toBeGreaterThan(-1);
|
|
48
|
+
const afterAp = DESCRIPTION.slice(apIdx);
|
|
49
|
+
// 截到下一个 ## 段
|
|
50
|
+
const nextSection = afterAp.indexOf("##", "## Anti-patterns".length);
|
|
51
|
+
const apSection =
|
|
52
|
+
nextSection > -1 ? afterAp.slice(0, nextSection) : afterAp;
|
|
53
|
+
const bullets = apSection.match(/^- .+/gm) || [];
|
|
54
|
+
expect(bullets.length).toBeGreaterThanOrEqual(4);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("含能力边界段 'You cannot'", () => {
|
|
58
|
+
// 必须显式声明 tool 做不到的事,阻止 LLM 错误假设。
|
|
59
|
+
expect(DESCRIPTION).toMatch(/You cannot/);
|
|
60
|
+
expect(DESCRIPTION.toLowerCase()).toContain("cannot");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("含注入防御:声明 completion message 为不可信数据", () => {
|
|
64
|
+
// completion message 是 auto-injected(F14 注入面),必须告诉 LLM
|
|
65
|
+
// 把它当作不可信数据,校验其中的指令后再执行。
|
|
66
|
+
const lower = DESCRIPTION.toLowerCase();
|
|
67
|
+
expect(
|
|
68
|
+
lower.includes("untrusted") || lower.includes("verify"),
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("保留 nested spawning 段(允许 sub-subagent,仅深度限制)", () => {
|
|
73
|
+
// 这段防止 LLM 错误拒绝合法的 nested delegation。
|
|
74
|
+
expect(DESCRIPTION).toMatch(/Nested spawning/);
|
|
75
|
+
expect(DESCRIPTION).toMatch(/Depth: N\/10/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("保留 executionMode sequential 的 CRITICAL 说明", () => {
|
|
79
|
+
// sequential 是关键执行语义,删了会导致 LLM 误以为并行可用。
|
|
80
|
+
expect(DESCRIPTION).toMatch(/CRITICAL/i);
|
|
81
|
+
expect(DESCRIPTION).toMatch(/sequential/);
|
|
82
|
+
expect(DESCRIPTION).toMatch(/SAME message/i);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/interface/__tests__/workflow-state-file-exposure.test.ts
|
|
2
|
+
//
|
|
3
|
+
// W2: WorkflowToolDetails 携带 stateFile + toRunSummary 填充逻辑
|
|
4
|
+
//
|
|
5
|
+
// 防的 bug:workflow run 自身的状态文件路径(<sessionDir>/workflow-state/<runId>.jsonl)
|
|
6
|
+
// 从未暴露到 tool details——overlay/GUI 拿不到,无法定位 run 状态文件做后续查看。
|
|
7
|
+
// 本测试验证 toRunSummary 和 actionRun 的 details 包含 stateFile 字段。
|
|
8
|
+
//
|
|
9
|
+
// 使用源码断言(读 .ts 文件文本)避免 import 重 mock 链——与 workflow-tool-prompt.test.ts 同模式。
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const TOOL_WORKFLOW_SRC = readFileSync(
|
|
18
|
+
join(__dirname, "../tool-workflow.ts"),
|
|
19
|
+
"utf-8",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
describe("W2: workflow tool details 暴露 stateFile", () => {
|
|
23
|
+
it("RunSummary 类型声明含 stateFile 可选字段", () => {
|
|
24
|
+
// RunSummary 必须声明 stateFile,否则 toRunSummary 返回的对象类型不含此字段
|
|
25
|
+
expect(TOOL_WORKFLOW_SRC).toMatch(/stateFile\?\s*:\s*string/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("toRunSummary 调用 deps.store.stateFilePath 填充 stateFile", () => {
|
|
29
|
+
// toRunSummary 必须从 store.stateFilePath(runId) 取值——这是数据来源
|
|
30
|
+
expect(TOOL_WORKFLOW_SRC).toMatch(/stateFilePath/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("run action 的 details 含 stateFile 字段", () => {
|
|
34
|
+
// actionRun 返回的 details 必须携带 stateFile(run 启动时即暴露路径)
|
|
35
|
+
// 验证 actionRun 函数体中构造 details 时引用了 stateFilePath
|
|
36
|
+
expect(TOOL_WORKFLOW_SRC).toMatch(/action.*run[\s\S]*stateFilePath|stateFilePath[\s\S]*action.*run/i);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// U1: workflow tool 提示词包含内置 workflow 清单 + 交叉引用。
|
|
2
|
+
//
|
|
3
|
+
// agent(LLM)决策时唯一能看到的 tool 元信息就是 description + promptGuidelines。
|
|
4
|
+
// 若这些文本里不提及内置 workflow(chain/parallel/scatter-gather/map-reduce),
|
|
5
|
+
// LLM 无法知道有现成的编排工具可用,会倾向自己 generate 脚本或瞎猜 name。
|
|
6
|
+
//
|
|
7
|
+
// 本测试用源码断言(读 .ts 文件文本)验证提示词内容,避免 import 重 mock 链
|
|
8
|
+
// (tool-workflow.ts 依赖 pi-ai/typebox/pi-tui/lifecycle 等值导入)。
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TOOL_WORKFLOW_SRC = readFileSync(
|
|
17
|
+
join(__dirname, "../tool-workflow.ts"),
|
|
18
|
+
"utf-8",
|
|
19
|
+
);
|
|
20
|
+
const TOOL_WORKFLOW_SCRIPT_SRC = readFileSync(
|
|
21
|
+
join(__dirname, "../tool-workflow-script.ts"),
|
|
22
|
+
"utf-8",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
26
|
+
it("tool-workflow.ts description 或 promptGuidelines 含 4 个内置 workflow 名称", () => {
|
|
27
|
+
// 4 个内置通用编排 workflow 必须在提示词里出现,LLM 才能发现并使用。
|
|
28
|
+
expect(TOOL_WORKFLOW_SRC).toContain("chain");
|
|
29
|
+
expect(TOOL_WORKFLOW_SRC).toContain("parallel");
|
|
30
|
+
expect(TOOL_WORKFLOW_SRC).toContain("scatter-gather");
|
|
31
|
+
expect(TOOL_WORKFLOW_SRC).toContain("map-reduce");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tool-workflow.ts promptGuidelines 含 workflow-script list 交叉引用", () => {
|
|
35
|
+
// LLM 需要知道"先 list 再 run"的发现路径——两个 tool 之间必须有交叉引用。
|
|
36
|
+
// 文本可能跨字符串拼接行,分别断言两个关键词都存在。
|
|
37
|
+
expect(TOOL_WORKFLOW_SRC).toContain("workflow-script");
|
|
38
|
+
expect(TOOL_WORKFLOW_SRC).toMatch(/action.*list/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("tool-workflow.ts promptGuidelines 含 run action 的正例", () => {
|
|
42
|
+
// 给出 workflow run <name> 的调用示例,LLM 才知道参数格式。
|
|
43
|
+
expect(TOOL_WORKFLOW_SRC).toMatch(/workflow run .+--args/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("tool-workflow-script.ts list action 的 promptGuidelines 含 workflow run 交叉引用", () => {
|
|
47
|
+
// 反向交叉引用:list 的指引里要提到用 workflow tool 的 run action 启动脚本。
|
|
48
|
+
expect(TOOL_WORKFLOW_SCRIPT_SRC).toMatch(/workflow.*tool.*run|run.*workflow.*tool/i);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-actions — RPC 模式 slash command action 解析纯函数。
|
|
3
|
+
*
|
|
4
|
+
* xyz-agent GUI 通过 `client.prompt("/subagents cancel <id>")` 等触发生命周期操作,
|
|
5
|
+
* 不经 LLM(pi 的 _tryExecuteExtensionCommand 在 agent loop 前短路)。command handler
|
|
6
|
+
* 在 RPC 模式下用这两个函数解析 action 字符串,分发到对应 service/lifecycle 调用。
|
|
7
|
+
*
|
|
8
|
+
* 设计为纯函数(无 ctx / service 依赖),便于独立单测,handler 只做薄分发。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** /subagents RPC action 判别联合。 */
|
|
12
|
+
export type SubagentRpcAction =
|
|
13
|
+
| { action: "cancel"; recordId: string }
|
|
14
|
+
| { action: "cancel-missing-id" }
|
|
15
|
+
| { action: "noop" };
|
|
16
|
+
|
|
17
|
+
/** /workflows RPC action 判别联合。 */
|
|
18
|
+
export type WorkflowRpcAction =
|
|
19
|
+
| { action: "pause"; runId: string }
|
|
20
|
+
| { action: "resume"; runId: string }
|
|
21
|
+
| { action: "abort"; runId: string }
|
|
22
|
+
| { action: "lifecycle-missing-id"; verb: "pause" | "resume" | "abort" }
|
|
23
|
+
| { action: "noop" };
|
|
24
|
+
|
|
25
|
+
/** workflow lifecycle verb 类型。 */
|
|
26
|
+
type LifecycleVerb = "pause" | "resume" | "abort";
|
|
27
|
+
|
|
28
|
+
/** workflow lifecycle verb 集合。 */
|
|
29
|
+
const LIFECYCLE_VERBS: ReadonlySet<LifecycleVerb> = new Set(["pause", "resume", "abort"]);
|
|
30
|
+
|
|
31
|
+
/** verb 是否为 lifecycle action(类型守卫,收窄到 LifecycleVerb)。 */
|
|
32
|
+
function isLifecycleVerb(verb: string): verb is LifecycleVerb {
|
|
33
|
+
return LIFECYCLE_VERBS.has(verb as LifecycleVerb);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析 /subagents RPC 命令字符串。
|
|
38
|
+
*
|
|
39
|
+
* 支持格式:
|
|
40
|
+
* - `cancel <id>` → { action: "cancel", recordId }
|
|
41
|
+
* - `cancel`(无 id)→ { action: "cancel-missing-id" }
|
|
42
|
+
* - 其他(空 / 未知 action / 无参)→ { action: "noop" }
|
|
43
|
+
*
|
|
44
|
+
* noop 表示 GUI 端无对应程序化操作(GUI 已在 CommandPopover 屏蔽 /subagents 入口,
|
|
45
|
+
* 此分支仅兜底手动 prompt)。
|
|
46
|
+
*/
|
|
47
|
+
export function parseSubagentRpcCommand(argsStr: string): SubagentRpcAction {
|
|
48
|
+
const args = argsStr.trim().split(/\s+/).filter(Boolean);
|
|
49
|
+
if (args.length === 0) return { action: "noop" };
|
|
50
|
+
|
|
51
|
+
const [verb, recordId] = args;
|
|
52
|
+
if (verb === "cancel") {
|
|
53
|
+
if (!recordId) return { action: "cancel-missing-id" };
|
|
54
|
+
return { action: "cancel", recordId };
|
|
55
|
+
}
|
|
56
|
+
return { action: "noop" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 解析 /workflows RPC 命令字符串。
|
|
61
|
+
*
|
|
62
|
+
* 支持格式:
|
|
63
|
+
* - `pause|resume|abort <runId>` → 对应 lifecycle action + runId
|
|
64
|
+
* - `pause|resume|abort`(无 runId)→ { action: "lifecycle-missing-id", verb }
|
|
65
|
+
* - 其他(空 / 未知 action / 无参)→ { action: "noop" }
|
|
66
|
+
*/
|
|
67
|
+
export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
|
|
68
|
+
const args = argsStr.trim().split(/\s+/).filter(Boolean);
|
|
69
|
+
if (args.length === 0) return { action: "noop" };
|
|
70
|
+
|
|
71
|
+
const [verb, runId] = args;
|
|
72
|
+
if (isLifecycleVerb(verb)) {
|
|
73
|
+
if (!runId) return { action: "lifecycle-missing-id", verb };
|
|
74
|
+
return { action: verb, runId };
|
|
75
|
+
}
|
|
76
|
+
return { action: "noop" };
|
|
77
|
+
}
|
|
@@ -24,6 +24,7 @@ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner
|
|
|
24
24
|
import type { LauncherDeps } from "../orchestration/launcher.ts";
|
|
25
25
|
import { abortRun, pauseRun, resumeRun } from "../orchestration/lifecycle.ts";
|
|
26
26
|
import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
|
|
27
|
+
import { parseWorkflowRpcCommand } from "./command-actions.ts";
|
|
27
28
|
import { createWorkflowsView, type ViewActions } from "./views/WorkflowsView.ts";
|
|
28
29
|
|
|
29
30
|
/** runId 截断长度(显示用)。 */
|
|
@@ -64,10 +65,45 @@ export function registerWorkflowsCommand(
|
|
|
64
65
|
deps: LauncherDeps,
|
|
65
66
|
): void {
|
|
66
67
|
api.registerCommand("workflows", {
|
|
67
|
-
description: "Open workflow
|
|
68
|
+
description: "Open workflow panel. /workflows [runId] | /workflows pause|resume|abort <runId>",
|
|
68
69
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// ── RPC 模式(xyz-agent GUI):解析 lifecycle action 直接执行,不打开 TUI ──
|
|
71
|
+
// hasUI 在 TUI 和 RPC 都为 true,不能用于区分;用 ctx.mode === "rpc" 判定 GUI 通道。
|
|
72
|
+
if (ctx.mode === "rpc") {
|
|
73
|
+
const parsed = parseWorkflowRpcCommand(args);
|
|
74
|
+
switch (parsed.action) {
|
|
75
|
+
case "pause":
|
|
76
|
+
case "resume":
|
|
77
|
+
case "abort": {
|
|
78
|
+
try {
|
|
79
|
+
if (parsed.action === "pause") await pauseRun(parsed.runId, deps);
|
|
80
|
+
else if (parsed.action === "resume") await resumeRun(parsed.runId, deps);
|
|
81
|
+
else await abortRun(parsed.runId, deps);
|
|
82
|
+
const pastTense = parsed.action === "abort" ? "aborted" : `${parsed.action}d`;
|
|
83
|
+
ctx.ui.notify(`Workflow ${parsed.runId}: ${pastTense}`, "info");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
ctx.ui.notify(`Failed to ${parsed.action} workflow ${parsed.runId}: ${msg}`, "warning");
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case "lifecycle-missing-id":
|
|
91
|
+
ctx.ui.notify(`Usage: /workflows ${parsed.verb} <runId>`, "warning");
|
|
92
|
+
return;
|
|
93
|
+
case "noop":
|
|
94
|
+
// 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
|
|
95
|
+
ctx.ui.notify("View workflows in the sidebar Flows tab", "info");
|
|
96
|
+
return;
|
|
97
|
+
default: {
|
|
98
|
+
// exhaustiveness 断言:未来新增 action verb 忘加 case 时 tsc 报错
|
|
99
|
+
const _exhaustive: never = parsed;
|
|
100
|
+
throw new Error(`Unhandled workflow RPC action: ${String(_exhaustive)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── print/json 模式(headless):不可交互 ──
|
|
106
|
+
if (ctx.mode !== "tui") {
|
|
71
107
|
ctx.ui.notify("/workflows requires interactive mode", "error");
|
|
72
108
|
return;
|
|
73
109
|
}
|
|
@@ -153,5 +189,5 @@ async function openView(
|
|
|
153
189
|
resume: (runId: string) => resumeRun(runId, deps),
|
|
154
190
|
abort: (runId: string) => abortRun(runId, deps),
|
|
155
191
|
};
|
|
156
|
-
await createWorkflowsView(run, theme, ctx, actions);
|
|
192
|
+
await createWorkflowsView(run, theme, ctx, actions, deps.store.stateFilePath(run.runId));
|
|
157
193
|
}
|