@zhushanwen/pi-subagent-workflow 0.1.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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// src/tui/tool-render.ts
|
|
2
|
+
//
|
|
3
|
+
// 对话流 tool block 渲染。renderCall(标题行)+ renderResult(背景色 block)。
|
|
4
|
+
//
|
|
5
|
+
// 关键设计(参照 nicobailon pi-subagents 渲染架构):
|
|
6
|
+
// 1. 不设 renderShell(默认 default)。背景色/padding 归 Pi 的 contentBox = Box(1,1,bgFn),
|
|
7
|
+
// 它按 isPartial/isError 自动切 toolPendingBg/toolSuccessBg/toolErrorBg 三态。
|
|
8
|
+
// 组件 render 返回的 string[] **绝不调 theme.bg**——否则双重背景混色。
|
|
9
|
+
// 2. 所有输出行经 truncLine(ANSI 安全,省略号前重应用 SGR,背景不断裂)。
|
|
10
|
+
// 3. 上下留白(Spacer(1) + Box paddingY=1)由 ToolExecutionComponent 负责,
|
|
11
|
+
// 组件不自己加 Spacer 做间隔。
|
|
12
|
+
// 4. spinner 用 seed-based 选帧(progress 字段派生),不用 setInterval/invalidate。
|
|
13
|
+
// onUpdate 驱动重绘时 seed 变化 → 自动换帧。
|
|
14
|
+
// 5. streaming delta(text/thinking)不触发 onUpdate,仅离散边界事件触发重绘。
|
|
15
|
+
// 6. compact 分支返回单 Text(多行 join "\n"),expanded 返回 Container。
|
|
16
|
+
// 单 Text 让整块作为单个渲染单元整体失效,绕开 pi 差分引擎对「多 Text 子组件
|
|
17
|
+
// + 内容平移」的 cell 残留 bug(ghosting 空行)。详见 renderSubagentResult 的
|
|
18
|
+
// [HISTORICAL] 注释。
|
|
19
|
+
|
|
20
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
22
|
+
import type { AgentToolResult, Theme } from "@mariozechner/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ListResponse,
|
|
26
|
+
SubagentToolResult,
|
|
27
|
+
} from "../execution/types.ts";
|
|
28
|
+
import {
|
|
29
|
+
extractAgentName,
|
|
30
|
+
firstLine,
|
|
31
|
+
formatElapsedSeconds,
|
|
32
|
+
sanitizeLabel,
|
|
33
|
+
statusGlyph,
|
|
34
|
+
type ThemeLike,
|
|
35
|
+
truncLine,
|
|
36
|
+
} from "./format.ts";
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// 常量
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
/** message stream 每行的缩进前缀(2 空格 + ⎿ + 空格),dim 色。 */
|
|
43
|
+
const STREAM_PREFIX = " ⎿ ";
|
|
44
|
+
|
|
45
|
+
/** footer 用的纯空格缩进(与 STREAM_PREFIX 等宽 4 列,但不带 ⎿)。 */
|
|
46
|
+
const FOOTER_PREFIX = " ";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 获取终端宽度(参照 nicobailon getTermWidth)。
|
|
50
|
+
* truncLine 需要在创建 Text 之前执行——此时 Pi 的 Box.render(contentWidth) 还未调用,
|
|
51
|
+
* 只能从 process.stdout 估算。-4 对应 Pi Box paddingX=1(左右各 1 列)+ 安全余量。
|
|
52
|
+
*/
|
|
53
|
+
function getTermWidth(): number {
|
|
54
|
+
return (process.stdout.columns || 120) - 4;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// 类型(已存在的契约)
|
|
59
|
+
// ============================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* renderResult 的 context(SDK ToolRenderContext 的有意子集——只读 state/invalidate)。
|
|
63
|
+
* SDK 实际传入更完整的 { args, toolCallId, cwd, executionStarted, argsComplete, isPartial,
|
|
64
|
+
* expanded, showImages, isError, lastComponent, ... },本组件结构兼容只取需要的字段。
|
|
65
|
+
*
|
|
66
|
+
* 注意:不使用 lastComponent。每次 renderResult 返回新 Container(参照 nicobailon)。
|
|
67
|
+
*/
|
|
68
|
+
export interface RenderContext {
|
|
69
|
+
state: Record<string, never>;
|
|
70
|
+
invalidate(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================
|
|
74
|
+
// renderCall —— tool 标题行
|
|
75
|
+
// ============================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* renderCall:tool 标题行(agent + model + thinking,不变信息)。
|
|
79
|
+
*
|
|
80
|
+
* "subagent worker · glm-5.2 · thinking high"
|
|
81
|
+
*
|
|
82
|
+
* model/thinkingLevel 由调用方(subagent-tool.ts 的闭包)预解析后传入,
|
|
83
|
+
* 因为 renderCall 在 execute 前调用,但 model 解析是同步的(只读配置)。
|
|
84
|
+
* resolved 缺失时(hub 未就绪)降级为只显示 agent 名。
|
|
85
|
+
*
|
|
86
|
+
* 返回 `new Text(line, 0, 0)`——paddingX=0 paddingY=0,背景交给 contentBox。
|
|
87
|
+
*/
|
|
88
|
+
export function renderSubagentCall(
|
|
89
|
+
args: unknown,
|
|
90
|
+
theme: Theme,
|
|
91
|
+
_context: RenderContext,
|
|
92
|
+
resolved?: { model: string; thinkingLevel?: string },
|
|
93
|
+
): Component {
|
|
94
|
+
const t = theme as ThemeLike;
|
|
95
|
+
// args 结构:{ action:"start", startParam:{ agent, task, ... } }(见 subagent-tool.ts schema)。
|
|
96
|
+
// 从 startParam 提取 agent + task,对齐 nicobailon 的 renderCall 多行布局。
|
|
97
|
+
const startParam = typeof args === "object" && args !== null && "startParam" in args
|
|
98
|
+
? (args as { startParam?: unknown }).startParam
|
|
99
|
+
: undefined;
|
|
100
|
+
const agent = extractAgentName(startParam);
|
|
101
|
+
const parts = [`${t.fg("toolTitle", t.bold("subagent "))}${t.fg("accent", agent)}`];
|
|
102
|
+
|
|
103
|
+
// model + thinking——完整 provider/model(accent 色),thinking 保持 dim。
|
|
104
|
+
// 不去 provider 前缀——provider 是模型来源的关键信息,感知「用错模型」需要完整路径。
|
|
105
|
+
if (resolved) {
|
|
106
|
+
parts.push(t.fg("dim", " ("));
|
|
107
|
+
parts.push(t.fg("accent", resolved.model));
|
|
108
|
+
if (resolved.thinkingLevel) {
|
|
109
|
+
parts.push(t.fg("dim", ` · thinking ${resolved.thinkingLevel})`));
|
|
110
|
+
} else {
|
|
111
|
+
parts.push(t.fg("dim", ")"));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// task preview 行——对齐 nicobailon:renderCall 输出多行(标题 + \n + task 预览)。
|
|
116
|
+
// 实验假设:call 多行让首帧(无 result)与后续帧(有 result)的高度跳变模式
|
|
117
|
+
// 与 nicobailon 一致,可能影响 pi diff 引擎的行对齐路径。preview 截断到 60 字符。
|
|
118
|
+
const task = typeof startParam === "object" && startParam !== null && "task" in startParam
|
|
119
|
+
? (startParam as { task?: unknown }).task
|
|
120
|
+
: undefined;
|
|
121
|
+
if (typeof task === "string" && task.length > 0) {
|
|
122
|
+
// task 取首行——prompt 常含换行(多行指令),直接 slice 会保留 \n,
|
|
123
|
+
// 渲染时意外换行破坏 tool block 行对齐。
|
|
124
|
+
const taskFirst = task.split("\n").find((l) => l.trim())?.trim() ?? "";
|
|
125
|
+
const preview = taskFirst.length > 60 ? `${taskFirst.slice(0, 60)}...` : taskFirst;
|
|
126
|
+
if (preview) parts.push(`\n ${t.fg("dim", preview)}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Text(parts.join(""), 0, 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ============================================================
|
|
133
|
+
// renderResult —— 对话流背景色 block
|
|
134
|
+
// ============================================================
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* renderResult:对话流背景色 block。
|
|
138
|
+
*
|
|
139
|
+
* compact(默认)返回单个 Text(多行 join "\n"),expanded 返回 Container。
|
|
140
|
+
* 背景色由 Pi default shell 的 contentBox 按 isPartial/isError 自动施加,
|
|
141
|
+
* 组件本身不施加背景色。
|
|
142
|
+
*
|
|
143
|
+
* 1. details 缺失 → fallback new Text(防御性)
|
|
144
|
+
* 2. 按 action 路由到 compact / expanded / list / cancel 渲染
|
|
145
|
+
* 3. compact:lines.join("\n") → new Text(绕开 ghosting,见下方 HISTORICAL)
|
|
146
|
+
* 4. expanded:lines → Container { Text, Text, ... }
|
|
147
|
+
*/
|
|
148
|
+
export function renderSubagentResult(
|
|
149
|
+
result: AgentToolResult<SubagentToolResult>,
|
|
150
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
151
|
+
theme: Theme,
|
|
152
|
+
_context: RenderContext,
|
|
153
|
+
): Component {
|
|
154
|
+
const themeLike = theme as ThemeLike;
|
|
155
|
+
const details = result.details;
|
|
156
|
+
|
|
157
|
+
// 防御性 fallback:按 action 判断 details 结构是否完整(G2-007)。
|
|
158
|
+
// list/cancel 无顶层 status/agent,旧 guard(typeof details.status)会误判「execution failed」。
|
|
159
|
+
// details 缺失通常是因为 execute 抛错(如 hub disposed)——此时 Pi 把 error.message 塞进
|
|
160
|
+
// result.content[0].text。旧实现只显示 "no details available",吞掉真实原因,导致 AI 盲猜。
|
|
161
|
+
// 现在从 result.content 提取错误文本显示,拿不到才退回通用文案。
|
|
162
|
+
if (!details || typeof details.action !== "string" || !isDetailsStructurallyComplete(details)) {
|
|
163
|
+
const errorText = extractResultError(result.content);
|
|
164
|
+
const fallback = errorText ?? "(subagent execution failed — no details available)";
|
|
165
|
+
return new Text(themeLike.fg("warning", fallback), 0, 0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lines = options.expanded
|
|
169
|
+
? buildExpandedLines(details, themeLike)
|
|
170
|
+
: buildCompactLines(details, themeLike);
|
|
171
|
+
|
|
172
|
+
// [HISTORICAL] compact 分支返回单个 Text(多行 join "\n"),而非 Container{Text...}。
|
|
173
|
+
// 对齐 nicobailon 的渲染结构(single Text 整块)。
|
|
174
|
+
// expanded 分支仍用 Container(未来可能含 Markdown 等富组件)。
|
|
175
|
+
if (!options.expanded) {
|
|
176
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const container = new Container();
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
container.addChild(new Text(line, 0, 0));
|
|
182
|
+
}
|
|
183
|
+
return container;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================
|
|
187
|
+
// 行内容生成(compact / expanded)
|
|
188
|
+
// ============================================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 压缩视图行内容生成。返回裸内容行(不含背景色/padding)。
|
|
192
|
+
*
|
|
193
|
+
* 布局:statusLine + 滚动区(eventLog 最近 N 条) + 底部行。
|
|
194
|
+
* running 和 terminal 态行数可能不同(running 有 "Press Ctrl+O",terminal 有 delivery),
|
|
195
|
+
* 不强制行数对齐(行数随 eventLog 增长自然变化)。
|
|
196
|
+
*/
|
|
197
|
+
function buildCompactLines(d: SubagentToolResult, theme: ThemeLike): string[] {
|
|
198
|
+
const width = getTermWidth();
|
|
199
|
+
|
|
200
|
+
// ── list 分支:表格(每行一个 item 摘要)──
|
|
201
|
+
if (d.action === "list" && d.listResponse) {
|
|
202
|
+
return renderListCompact(d.listResponse, theme, width);
|
|
203
|
+
}
|
|
204
|
+
// ── cancel 分支:确认行 ──
|
|
205
|
+
if (d.action === "cancel" && d.cancelResponse) {
|
|
206
|
+
return [truncLine(
|
|
207
|
+
`${theme.fg("muted", "■")} ${theme.fg("dim", "cancelled ")}${theme.fg("accent", d.subagentId ?? "?")}`,
|
|
208
|
+
width,
|
|
209
|
+
)];
|
|
210
|
+
}
|
|
211
|
+
// ── start 分支:background ──
|
|
212
|
+
if ("bgResponse" in d) {
|
|
213
|
+
return [truncLine(
|
|
214
|
+
`${theme.fg("accent", "●")} ${theme.fg("dim", "background: ")}${theme.fg("accent", d.subagentId ?? "?")}`
|
|
215
|
+
+ ` ${theme.fg("dim", "· running detached · will notify on completion")}`,
|
|
216
|
+
width,
|
|
217
|
+
)];
|
|
218
|
+
}
|
|
219
|
+
return [truncLine(theme.fg("warning", "(subagent: no response)"), width)];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 展开视图行内容生成。完整 eventLog + 交付物。
|
|
224
|
+
*/
|
|
225
|
+
function buildExpandedLines(d: SubagentToolResult, theme: ThemeLike): string[] {
|
|
226
|
+
const width = getTermWidth();
|
|
227
|
+
|
|
228
|
+
if (d.action === "list" && d.listResponse) {
|
|
229
|
+
return renderListExpanded(d.listResponse, theme, width);
|
|
230
|
+
}
|
|
231
|
+
if (d.action === "cancel" && d.cancelResponse) {
|
|
232
|
+
return [truncLine(
|
|
233
|
+
`${theme.fg("muted", "■")} ${theme.fg("dim", "cancelled ")}${theme.fg("accent", d.subagentId ?? "?")}`,
|
|
234
|
+
width,
|
|
235
|
+
)];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lines: string[] = [];
|
|
239
|
+
// bg 占位 expanded 与 compact 同(一次性 block 无细节可展开)
|
|
240
|
+
if ("bgResponse" in d) {
|
|
241
|
+
lines.push(truncLine(
|
|
242
|
+
`${theme.fg("accent", "●")} ${theme.fg("dim", "background: ")}${theme.fg("accent", d.subagentId ?? "?")}`,
|
|
243
|
+
width,
|
|
244
|
+
));
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
return [truncLine(theme.fg("warning", "(subagent: no response)"), width)];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================
|
|
251
|
+
// 私有 helper(模块内)
|
|
252
|
+
// ============================================================
|
|
253
|
+
|
|
254
|
+
/** 按 action 检查 details 内层分组是否存在(G2-007 guard)。 */
|
|
255
|
+
function isDetailsStructurallyComplete(d: SubagentToolResult): boolean {
|
|
256
|
+
switch (d.action) {
|
|
257
|
+
case "start":
|
|
258
|
+
return "bgResponse" in d;
|
|
259
|
+
case "list":
|
|
260
|
+
return "listResponse" in d;
|
|
261
|
+
case "cancel":
|
|
262
|
+
return "cancelResponse" in d;
|
|
263
|
+
default:
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 从 tool result 的 content 里提取错误文本。
|
|
270
|
+
*
|
|
271
|
+
* execute 抛错(如 hub disposed / task 缺失)时,subagents handler 不 catch,
|
|
272
|
+
* Pi 框架会把 error.message 塞进 result.content[0].text。renderResult 的 fallback
|
|
273
|
+
* 分支用它把真实原因显示出来,避免只显「no details available」让 AI 盲猜。
|
|
274
|
+
* content 可能多行,只取首行(用共享 firstLine 裁断 + sanitize)。
|
|
275
|
+
*/
|
|
276
|
+
function extractResultError(content: AgentToolResult<SubagentToolResult>["content"]): string | undefined {
|
|
277
|
+
if (!Array.isArray(content)) return undefined;
|
|
278
|
+
for (const item of content) {
|
|
279
|
+
const text = getStringText(item);
|
|
280
|
+
if (text) return firstLineSanitized(text);
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 若 item 是带非空 .text 的对象则返回 text,否则 undefined。 */
|
|
286
|
+
function getStringText(item: unknown): string | undefined {
|
|
287
|
+
if (typeof item !== "object" || item === null) return undefined;
|
|
288
|
+
const val = (item as Record<string, unknown>).text;
|
|
289
|
+
return typeof val === "string" && val.trim().length > 0 ? val : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 取文本首个非空行(多行压成首行展示),并 sanitize。
|
|
294
|
+
* 用于 done/failed 的交付物预览。
|
|
295
|
+
* 共享 firstLine(./format.ts)取首行,本 wrapper 叠加 sanitizeLabel。
|
|
296
|
+
*/
|
|
297
|
+
function firstLineSanitized(text?: string): string {
|
|
298
|
+
return sanitizeLabel(firstLine(text));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================
|
|
302
|
+
// list 渲染 helper(action:"list" 分支)
|
|
303
|
+
// ============================================================
|
|
304
|
+
|
|
305
|
+
/** list compact:标题行 + 每行一个 item 摘要(glyph + agent + mode + status + duration)。 */
|
|
306
|
+
function renderListCompact(resp: ListResponse, theme: ThemeLike, width: number): string[] {
|
|
307
|
+
if (resp.items.length === 0) {
|
|
308
|
+
return [truncLine(theme.fg("dim", `No subagents (running: ${resp.running})`), width)];
|
|
309
|
+
}
|
|
310
|
+
const lines: string[] = [
|
|
311
|
+
truncLine(theme.fg("dim", `Subagents (running: ${resp.running}/${resp.items.length})`), width),
|
|
312
|
+
];
|
|
313
|
+
for (const it of resp.items) {
|
|
314
|
+
const glyph = statusGlyph(it.status);
|
|
315
|
+
const icon = glyph.icon ?? "●";
|
|
316
|
+
const mode = "bg";
|
|
317
|
+
const line = `${theme.fg(glyph.color, icon)} ${theme.fg("accent", it.agent)}`
|
|
318
|
+
+ ` ${theme.fg("dim", `· ${mode} · ${it.status} · ${formatElapsedSeconds(it.duration)}`)}`;
|
|
319
|
+
lines.push(truncLine(`${STREAM_PREFIX}${line}`, width));
|
|
320
|
+
}
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** list expanded:compact 基础上每 item 追加 sessionFile 路径行。 */
|
|
325
|
+
function renderListExpanded(resp: ListResponse, theme: ThemeLike, width: number): string[] {
|
|
326
|
+
const lines = renderListCompact(resp, theme, width);
|
|
327
|
+
for (const it of resp.items) {
|
|
328
|
+
if (it.sessionFile) {
|
|
329
|
+
lines.push(truncLine(`${theme.fg("dim", `${FOOTER_PREFIX}session: `)}${it.sessionFile}`, width));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — tool-workflow-script
|
|
3
|
+
*
|
|
4
|
+
* workflow-script tool,5 actions(FR-5:脚本领域收口为单 tool)。
|
|
5
|
+
*
|
|
6
|
+
* Actions:
|
|
7
|
+
* - generate: AI 生成临时脚本 → 写 .pi/workflows/.tmp/
|
|
8
|
+
* - lint: 静态检查脚本(调 engine/script-lint.ts lintScript)
|
|
9
|
+
* - save: 临时脚本转固定(.tmp → .pi/workflows/)
|
|
10
|
+
* - delete: 删除脚本(前查 isRunning 防删运行中脚本)
|
|
11
|
+
* - list: 列出可用脚本(调 registry.loadAll)
|
|
12
|
+
*
|
|
13
|
+
* 层归属:Interface。依赖 Pi SDK + engine script-lint + infra workflow-files。
|
|
14
|
+
*
|
|
15
|
+
* 参考:domain-models.md §FR-5(tool 收口 4→2)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { resolve as pathResolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
23
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
24
|
+
import { type Static, Type } from "typebox";
|
|
25
|
+
|
|
26
|
+
import type { WorkflowScriptRegistry } from "../orchestration/models/workflow-script-registry.ts";
|
|
27
|
+
import { lintScript } from "../orchestration/script-lint.ts";
|
|
28
|
+
import { deleteWorkflow, saveWorkflow } from "../orchestration/workflow-files.ts";
|
|
29
|
+
import { renderTextFallback } from "./views/format.ts";
|
|
30
|
+
|
|
31
|
+
// ── Parameter schema ─────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const WorkflowScriptParams = Type.Object({
|
|
34
|
+
action: StringEnum(["generate", "lint", "save", "delete", "list"] as const, {
|
|
35
|
+
description: "Script management action",
|
|
36
|
+
}),
|
|
37
|
+
name: Type.Optional(
|
|
38
|
+
Type.String({ description: "Workflow script name (generate/lint/save/delete)" }),
|
|
39
|
+
),
|
|
40
|
+
script: Type.Optional(
|
|
41
|
+
Type.String({ description: "Complete JS workflow script content (generate only)" }),
|
|
42
|
+
),
|
|
43
|
+
description: Type.Optional(
|
|
44
|
+
Type.String({ description: "Workflow purpose (generate only)" }),
|
|
45
|
+
),
|
|
46
|
+
newName: Type.Optional(
|
|
47
|
+
Type.String({ description: "New name when saving a tmp script (save --as only)" }),
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
type ScriptParams = Static<typeof WorkflowScriptParams>;
|
|
52
|
+
|
|
53
|
+
// ── Tool result types (S3: typed details, replaces Record<string, unknown>) ──
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Discriminated union of `workflow-script` tool `details` payloads.
|
|
57
|
+
*
|
|
58
|
+
* Discriminant: `action`. `save`/`delete` may surface structured `ok:false`
|
|
59
|
+
* details on failure (instead of bare `undefined`) so programmatic consumers
|
|
60
|
+
* can distinguish error shape from success.
|
|
61
|
+
*/
|
|
62
|
+
export type WorkflowScriptToolDetails =
|
|
63
|
+
| { action: "generate"; path: string; name: string; status: "ready" }
|
|
64
|
+
| { action: "lint"; name: string; valid: boolean; findingCount: number }
|
|
65
|
+
| { action: "list"; count: number }
|
|
66
|
+
| { action: "save"; name: string; ok: boolean }
|
|
67
|
+
| { action: "delete"; name: string; ok: boolean };
|
|
68
|
+
|
|
69
|
+
/** Result returned by the `workflow-script` tool's execute. */
|
|
70
|
+
export interface TextContent {
|
|
71
|
+
content: Array<{ type: "text"; text: string }>;
|
|
72
|
+
details: WorkflowScriptToolDetails | undefined;
|
|
73
|
+
isError?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Tool registration ────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 注册 workflow-script tool(5 actions: generate/lint/save/delete/list)。
|
|
80
|
+
*
|
|
81
|
+
* @param pi ExtensionAPI
|
|
82
|
+
* @param registry WorkflowScriptRegistry
|
|
83
|
+
* @param isRunning 判断脚本是否正在运行(delete 前防删运行中脚本;factory 传入)
|
|
84
|
+
*/
|
|
85
|
+
export function registerWorkflowScriptTool(
|
|
86
|
+
pi: ExtensionAPI,
|
|
87
|
+
registry: WorkflowScriptRegistry,
|
|
88
|
+
isRunning: (name: string) => boolean,
|
|
89
|
+
): void {
|
|
90
|
+
pi.registerTool({
|
|
91
|
+
name: "workflow-script",
|
|
92
|
+
label: "Workflow Script",
|
|
93
|
+
description:
|
|
94
|
+
"Manage workflow scripts: generate (AI creates tmp script), lint (static check), " +
|
|
95
|
+
"save (tmp→permanent), delete, list. Replaces workflow-generate + workflow-lint tools.",
|
|
96
|
+
promptSnippet: "Generate, lint, save, delete, or list workflow scripts",
|
|
97
|
+
promptGuidelines: [
|
|
98
|
+
"generate: AI writes a tmp workflow script to .pi/workflows/.tmp/. Script can be run immediately via the workflow tool.",
|
|
99
|
+
"lint: Statically check a script for common API misuse (outputSchema, result.output, file state).",
|
|
100
|
+
"save: Promote a tmp script to permanent (.pi/workflows/).",
|
|
101
|
+
"delete: Remove a script (blocked if a run is active).",
|
|
102
|
+
"list: Show all available workflow scripts with source tags.",
|
|
103
|
+
],
|
|
104
|
+
parameters: WorkflowScriptParams,
|
|
105
|
+
|
|
106
|
+
async execute(
|
|
107
|
+
_toolCallId: string,
|
|
108
|
+
params: ScriptParams,
|
|
109
|
+
signal: AbortSignal | undefined,
|
|
110
|
+
_onUpdate: unknown,
|
|
111
|
+
_ctx: ExtensionContext,
|
|
112
|
+
): Promise<TextContent> {
|
|
113
|
+
switch (params.action) {
|
|
114
|
+
case "generate":
|
|
115
|
+
return actionGenerate(params, signal);
|
|
116
|
+
case "lint":
|
|
117
|
+
return actionLint(params, registry);
|
|
118
|
+
case "save":
|
|
119
|
+
return actionSave(params);
|
|
120
|
+
case "delete":
|
|
121
|
+
return actionDelete(params, registry, isRunning);
|
|
122
|
+
case "list":
|
|
123
|
+
return await actionList(registry);
|
|
124
|
+
default:
|
|
125
|
+
return textResult(`Unknown action: ${String(params.action)}`, true);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
renderCall(args: ScriptParams, theme: Theme, _context?: unknown) {
|
|
130
|
+
const label = `workflow-script ${args.action}`;
|
|
131
|
+
const name = args.name ?? "";
|
|
132
|
+
const text =
|
|
133
|
+
theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("accent", name);
|
|
134
|
+
return new Text(text, 0, 0);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
renderResult(
|
|
138
|
+
result: { content?: Array<{ type: string; text?: string }> },
|
|
139
|
+
_options: unknown,
|
|
140
|
+
_theme: Theme,
|
|
141
|
+
_context?: unknown,
|
|
142
|
+
) {
|
|
143
|
+
return new Text(renderTextFallback(result), 0, 0);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── generate action ──────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined): TextContent {
|
|
151
|
+
if (signal?.aborted) {
|
|
152
|
+
return textResult("Operation aborted before start", true);
|
|
153
|
+
}
|
|
154
|
+
const name = params.name;
|
|
155
|
+
const script = params.script;
|
|
156
|
+
if (!name || !script) {
|
|
157
|
+
return textResult("generate requires 'name' and 'script' parameters", true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 1. Reject ESM syntax (Worker runs CJS); 'export const meta' 例外
|
|
161
|
+
const stripped = script.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
162
|
+
if (/\bimport\s+(?:type\s+)?[\w{*]/.test(stripped)) {
|
|
163
|
+
return textResult(
|
|
164
|
+
"Script uses ESM 'import' syntax. Workflow scripts run in a CJS Worker — use require() instead.",
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const hasExportMeta = /\bexport\s+const\s+meta\s*=/.test(stripped);
|
|
169
|
+
const otherExports = stripped.match(/\bexport\s+(?:const|let|var|function|default|\{)/g);
|
|
170
|
+
if (otherExports && !hasExportMeta) {
|
|
171
|
+
return textResult(
|
|
172
|
+
"Script uses ESM 'export' (non-meta). Use 'const meta = {...}' at top level instead.",
|
|
173
|
+
true,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 2. Validate meta declaration
|
|
178
|
+
if (!script.includes("const meta") && !script.includes("export const meta")) {
|
|
179
|
+
return textResult(
|
|
180
|
+
"Script must contain a meta declaration: const meta = { name, description, phases }",
|
|
181
|
+
true,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 3. Check agent usage
|
|
186
|
+
if (!/\bagent\s*\(/.test(stripped)) {
|
|
187
|
+
return textResult(
|
|
188
|
+
"Script does not contain any agent() calls. A workflow must call agent() at least once.",
|
|
189
|
+
true,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 4. Syntax check (wrap in async IIFE like runtime)
|
|
194
|
+
const cjsScript = script.replace(/\bexport\s+const\s+meta\b/, "const meta");
|
|
195
|
+
try {
|
|
196
|
+
new Function(`(async () => { ${cjsScript} })();`);
|
|
197
|
+
} catch (err: unknown) {
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
return textResult(`Syntax error in script: ${msg}`, true);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 5. Write to .tmp directory
|
|
203
|
+
const tmpDir = pathResolve(".pi/workflows/.tmp");
|
|
204
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
205
|
+
const filePath = pathResolve(tmpDir, `${name}.js`);
|
|
206
|
+
writeFileSync(filePath, script, "utf-8");
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: "text",
|
|
212
|
+
text: `Generated workflow script: ${filePath}\nName: ${name}\nReady to run via the workflow tool.`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
details: { action: "generate", path: filePath, name, status: "ready" },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── lint action ──────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
async function actionLint(
|
|
222
|
+
params: ScriptParams,
|
|
223
|
+
registry: WorkflowScriptRegistry,
|
|
224
|
+
): Promise<TextContent> {
|
|
225
|
+
const name = params.name;
|
|
226
|
+
if (!name) {
|
|
227
|
+
return textResult("lint requires 'name' parameter", true);
|
|
228
|
+
}
|
|
229
|
+
const source = await loadScriptSource(name, registry);
|
|
230
|
+
if (!source) {
|
|
231
|
+
return textResult(`Workflow '${name}' not found or not available.`, true);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const result = lintScript(source);
|
|
235
|
+
if (result.findings.length === 0) {
|
|
236
|
+
return textResult(`✅ No issues found in '${name}'.`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const lines = result.findings.map((f) => {
|
|
240
|
+
const icon = f.severity === "error" ? "❌" : "⚠️";
|
|
241
|
+
return `${icon} L${f.line}: ${f.message}\n Suggestion: ${f.suggestion}`;
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
content: [
|
|
245
|
+
{
|
|
246
|
+
type: "text",
|
|
247
|
+
text: `${result.valid ? "Warnings" : "Errors"} found in '${name}':\n\n${lines.join("\n\n")}`,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
details: { action: "lint", name, valid: result.valid, findingCount: result.findings.length },
|
|
251
|
+
isError: !result.valid,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 加载脚本源码(lint 用)。通过 registry port 获取——registry 返回的
|
|
257
|
+
* WorkflowScript 自带 sourceCode(FR-2:registry 是唯一读文件处),
|
|
258
|
+
* 不再穿透到 config-loader 直接扫文件系统。
|
|
259
|
+
*/
|
|
260
|
+
async function loadScriptSource(
|
|
261
|
+
name: string,
|
|
262
|
+
registry: WorkflowScriptRegistry,
|
|
263
|
+
): Promise<string | undefined> {
|
|
264
|
+
const script = await registry.get(name);
|
|
265
|
+
return script?.available ? script.sourceCode : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── save action ──────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
async function actionSave(params: ScriptParams): Promise<TextContent> {
|
|
271
|
+
const name = params.name;
|
|
272
|
+
if (!name) {
|
|
273
|
+
return textResult("save requires 'name' parameter (tmp script name)", true);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const result = await saveWorkflow(name, params.newName);
|
|
277
|
+
return {
|
|
278
|
+
content: [{ type: "text", text: result }],
|
|
279
|
+
details: { action: "save", name, ok: true },
|
|
280
|
+
};
|
|
281
|
+
} catch (err: unknown) {
|
|
282
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: "text", text: `Save failed: ${msg}` }],
|
|
285
|
+
details: { action: "save", name, ok: false },
|
|
286
|
+
isError: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── delete action ────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function actionDelete(
|
|
294
|
+
params: ScriptParams,
|
|
295
|
+
registry: WorkflowScriptRegistry,
|
|
296
|
+
isRunning: (name: string) => boolean,
|
|
297
|
+
): TextContent {
|
|
298
|
+
const name = params.name;
|
|
299
|
+
if (!name) {
|
|
300
|
+
return textResult("delete requires 'name' parameter", true);
|
|
301
|
+
}
|
|
302
|
+
// deleteWorkflow 内部检查 isRunning(防止删运行中脚本)
|
|
303
|
+
try {
|
|
304
|
+
const result = deleteWorkflow(name, isRunning);
|
|
305
|
+
// 失效 registry 缓存(下次 list/get 重扫)
|
|
306
|
+
registry.invalidate();
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text", text: result }],
|
|
309
|
+
details: { action: "delete", name, ok: true },
|
|
310
|
+
};
|
|
311
|
+
} catch (err: unknown) {
|
|
312
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: "text", text: `Delete failed: ${msg}` }],
|
|
315
|
+
details: { action: "delete", name, ok: false },
|
|
316
|
+
isError: true,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── list action ──────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
async function actionList(registry: WorkflowScriptRegistry): Promise<TextContent> {
|
|
324
|
+
try {
|
|
325
|
+
const all = await registry.loadAll();
|
|
326
|
+
const available = all.filter((wf) => wf.available);
|
|
327
|
+
if (available.length === 0) {
|
|
328
|
+
return textResult("No workflow scripts available.");
|
|
329
|
+
}
|
|
330
|
+
const lines = available.map(
|
|
331
|
+
(wf) => ` [${wf.source}] ${wf.name} — ${wf.meta.description || "(no description)"}`,
|
|
332
|
+
);
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text: `Available workflows:\n${lines.join("\n")}` }],
|
|
335
|
+
details: { action: "list", count: available.length },
|
|
336
|
+
};
|
|
337
|
+
} catch (err: unknown) {
|
|
338
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
return textResult(`List failed: ${msg}`, true);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── helper ───────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function textResult(text: string, isError = false): TextContent {
|
|
346
|
+
return {
|
|
347
|
+
content: [{ type: "text", text }],
|
|
348
|
+
details: undefined,
|
|
349
|
+
isError: isError || undefined,
|
|
350
|
+
};
|
|
351
|
+
}
|