@maplezzk/pi-dynamic-workflows 1.0.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.
@@ -0,0 +1,136 @@
1
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
4
+ import { Type } from "typebox";
5
+ import { loadConfig, saveConfig } from "./config.ts";
6
+ import { cancelRunningWorkflow, createWorkflowTool, renderWorkflowThemed } from "./index.ts";
7
+
8
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
9
+ const LOG_PREFIX = "[pi-dynamic-workflows]";
10
+
11
+ export default function extension(pi: ExtensionAPI) {
12
+ // Subagent session 不注册 workflow 工具:subagent 是 workflow 的执行节点,
13
+ // 不应再拥有启动 workflow 的能力(防止递归调用、误激活、误取消等)。
14
+ // pi-interactive-subagents 启动子 pi session 时会设置 PI_SUBAGENT_NAME。
15
+ if (process.env.PI_SUBAGENT_NAME) {
16
+ return;
17
+ }
18
+
19
+ const config = loadConfig();
20
+ const workflowTool = createWorkflowTool({ pi });
21
+ pi.registerTool(workflowTool);
22
+
23
+ // 异步模式:注册 workflow_cancel 工具
24
+ if (config.async) {
25
+ const cancelTool = defineTool({
26
+ name: "workflow_cancel",
27
+ label: "Cancel Workflow",
28
+ description: i18n.t("cancelToolDescription"),
29
+ promptSnippet: "Cancel a running background workflow.",
30
+ parameters: Type.Object({}),
31
+ async execute() {
32
+ const result = cancelRunningWorkflow();
33
+ if (result.cancelled) {
34
+ return {
35
+ content: [{ type: "text", text: i18n.t("cancelSent", { name: result.name }) }],
36
+ details: {},
37
+ };
38
+ }
39
+ return {
40
+ content: [{ type: "text", text: i18n.t("noneRunning") }],
41
+ details: {},
42
+ };
43
+ },
44
+ renderCall(_args, theme) {
45
+ return new Text(theme.fg("toolTitle", theme.bold("workflow_cancel")), 0, 0);
46
+ },
47
+ });
48
+ pi.registerTool(cancelTool);
49
+ }
50
+
51
+ // 注册异步模式的结果消息渲染器
52
+ pi.registerMessageRenderer("workflow_result", (message: any, _options: any, theme: any) => {
53
+ const snapshot = message.details;
54
+ if (!snapshot?.name) return undefined;
55
+
56
+ return {
57
+ render(width: number): string[] {
58
+ const hasError = snapshot.errorCount > 0;
59
+ const bgFn = hasError
60
+ ? (text: string) => theme.bg("toolErrorBg", text)
61
+ : (text: string) => theme.bg("toolSuccessBg", text);
62
+ const icon = hasError ? theme.fg("error", "✗") : theme.fg("success", "✓");
63
+ const status = hasError ? "completed with errors" : "completed";
64
+ const elapsed = snapshot.durationMs ? `${Math.round(snapshot.durationMs / 1000)}s` : "?";
65
+
66
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(`Workflow: ${snapshot.name}`))} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
67
+
68
+ const contentLines = [header, ""];
69
+ const themed = renderWorkflowThemed(snapshot, theme, {
70
+ key: "workflow",
71
+ maxAgents: 4,
72
+ maxLogs: 1,
73
+ showResultPreviews: true,
74
+ });
75
+ contentLines.push(...themed.split("\n"));
76
+
77
+ const box = new Box(1, 1, bgFn);
78
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
79
+ return ["", ...box.render(width)];
80
+ },
81
+ invalidate(): void {},
82
+ };
83
+ });
84
+
85
+ registerConfigCommand(pi);
86
+
87
+ pi.on("session_start", () => {
88
+ const active = pi.getActiveTools();
89
+ const toolNames = [workflowTool.name];
90
+ if (loadConfig().async) toolNames.push("workflow_cancel");
91
+ for (const name of toolNames) {
92
+ if (!active.includes(name)) {
93
+ pi.setActiveTools([...pi.getActiveTools(), name]);
94
+ }
95
+ }
96
+ });
97
+
98
+ // 会话关闭时取消运行中的异步 workflow
99
+ pi.on("session_shutdown", () => {
100
+ cancelRunningWorkflow();
101
+ });
102
+ }
103
+
104
+ /** /workflow-config 交互式配置命令:切换执行后端与异步模式,持久化到 JSON。 */
105
+ function registerConfigCommand(pi: ExtensionAPI) {
106
+ pi.registerCommand("workflow-config", {
107
+ description: i18n.t("commandDescription"),
108
+ handler: async (_args, ctx) => {
109
+ if (!ctx.hasUI) return;
110
+ while (true) {
111
+ const cfg = loadConfig();
112
+ const EXIT = i18n.t("exit");
113
+ const on = i18n.t("on");
114
+ const off = i18n.t("off");
115
+ const choices = [
116
+ i18n.t("toggleBackend", { value: cfg.backend }),
117
+ i18n.t("toggleAsync", { value: cfg.async ? on : off }),
118
+ EXIT,
119
+ ];
120
+ const choice = await ctx.ui.select(i18n.t("configTitle"), choices);
121
+ if (choice === undefined || choice === EXIT) return;
122
+
123
+ if (choice === choices[0]) {
124
+ const saved = saveConfig({ backend: cfg.backend === "subagent" ? "workflow" : "subagent" });
125
+ ctx.ui.notify(`${LOG_PREFIX} ${i18n.t("savedBackend", { value: saved.backend })}`, "info");
126
+ } else if (choice === choices[1]) {
127
+ const saved = saveConfig({ async: !cfg.async });
128
+ ctx.ui.notify(
129
+ `${LOG_PREFIX} ${i18n.t("savedAsync", { value: saved.async ? on : off })} ${i18n.t("reloadHint")}`,
130
+ "info",
131
+ );
132
+ }
133
+ }
134
+ },
135
+ });
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export type { AgentRunOptions, AgentRunResult, WorkflowAgentOptions } from "./agent.ts";
2
+ export { WorkflowAgent } from "./agent.ts";
3
+ export type {
4
+ WorkflowAgentSnapshot,
5
+ WorkflowAgentStatus,
6
+ WorkflowDisplay,
7
+ WorkflowDisplayOptions,
8
+ WorkflowSnapshot,
9
+ WorkflowTheme,
10
+ } from "./display.ts";
11
+ export {
12
+ createToolUpdateWorkflowDisplay,
13
+ createWidgetWorkflowDisplay,
14
+ createWorkflowSnapshot,
15
+ preview,
16
+ recomputeWorkflowSnapshot,
17
+ renderWorkflowLines,
18
+ renderWorkflowText,
19
+ renderWorkflowThemed,
20
+ renderWorkflowWidgetLines,
21
+ } from "./display.ts";
22
+ export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.ts";
23
+ export { createStructuredOutputTool } from "./structured-output.ts";
24
+ export type { SubagentWorkflowAgentOptions } from "./subagent-agent.ts";
25
+ export { SubagentWorkflowAgent } from "./subagent-agent.ts";
26
+ export type {
27
+ AgentOptions,
28
+ WorkflowMeta,
29
+ WorkflowMetaPhase,
30
+ WorkflowRunOptions,
31
+ WorkflowRunResult,
32
+ } from "./workflow.ts";
33
+ export { parseWorkflowScript, runWorkflow } from "./workflow.ts";
34
+ export type { WorkflowToolInput, WorkflowToolOptions } from "./workflow-tool.ts";
35
+ export { cancelRunningWorkflow, createWorkflowTool } from "./workflow-tool.ts";
36
+ export type { WorkflowBackend, WorkflowConfig } from "./config.ts";
37
+ export { configPath, loadConfig, saveConfig } from "./config.ts";
@@ -0,0 +1,47 @@
1
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import type { Static, TSchema } from "typebox";
3
+
4
+ export interface StructuredOutputCapture<T = unknown> {
5
+ value: T | undefined;
6
+ called: boolean;
7
+ }
8
+
9
+ export interface StructuredOutputToolOptions<TSchemaDef extends TSchema> {
10
+ schema: TSchemaDef;
11
+ capture: StructuredOutputCapture<Static<TSchemaDef>>;
12
+ name?: string;
13
+ }
14
+
15
+ /**
16
+ * Create a terminating tool that captures validated params as the subagent result.
17
+ *
18
+ * Pi validates `params` against `schema` before execute() is called. Returning
19
+ * `terminate: true` lets the subagent finish on this tool call without paying for
20
+ * an extra assistant follow-up turn.
21
+ */
22
+ export function createStructuredOutputTool<TSchemaDef extends TSchema>({
23
+ schema,
24
+ capture,
25
+ name = "structured_output",
26
+ }: StructuredOutputToolOptions<TSchemaDef>): ToolDefinition<TSchemaDef, Static<TSchemaDef>> {
27
+ return defineTool({
28
+ name,
29
+ label: "Structured Output",
30
+ description: "Return the final machine-readable result for this subagent task.",
31
+ promptSnippet: "Return final machine-readable output",
32
+ promptGuidelines: [
33
+ `${name} is the final answer channel for this task; call ${name} exactly once when done.`,
34
+ `Do not write a prose final answer after calling ${name}.`,
35
+ ],
36
+ parameters: schema,
37
+ async execute(_toolCallId, params) {
38
+ capture.value = params;
39
+ capture.called = true;
40
+ return {
41
+ content: [{ type: "text", text: "Structured output received." }],
42
+ details: params,
43
+ terminate: true,
44
+ };
45
+ },
46
+ });
47
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SubagentWorkflowAgent — workflow backend backed by pi-interactive-subagents.
3
+ *
4
+ * Activated when PI_WORKFLOW_BACKEND=subagent. Uses launchSubagent / watchSubagent
5
+ * to run each agent() call as a separate tmux-pane subagent with real tool access.
6
+ * Structured output is enforced via the subagent's structured_output tool (ajv validation)
7
+ * rather than the in-memory session's structured_output mechanism.
8
+ */
9
+
10
+ // ── defensive .jsonl validator ──
11
+ // 拦截 pollForExit 误判(fast path 命中残留 .exit 或 slow path 读到 stale
12
+ // sentinel)导致的"子 agent 根本没启动就被判定完成"。herdr-split.log 9:01 失败批次
13
+ // 模式:pane run 6ms 后 close,subagentSessionFile.jsonl 永远不存在。
14
+ import { existsSync, statSync } from "node:fs";
15
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
16
+
17
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
18
+
19
+ /** 校验子 agent 的 .jsonl 会话文件是否真实存在且非空,防止 pollForExit 误判。 */
20
+ function sessionFileLooksValid(jsonlPath: string): { ok: boolean; reason: string; size: number } {
21
+ if (!existsSync(jsonlPath)) return { ok: false, reason: "missing", size: 0 };
22
+ try {
23
+ const size = statSync(jsonlPath).size;
24
+ if (size === 0) return { ok: false, reason: "empty", size: 0 };
25
+ return { ok: true, reason: "ok", size };
26
+ } catch (e: unknown) {
27
+ const msg = e instanceof Error ? e.message : String(e);
28
+ return { ok: false, reason: `stat_failed:${msg}`, size: 0 };
29
+ }
30
+ }
31
+
32
+ // ── types lifted from pi-interactive-subagents ──
33
+ interface SubagentCtx {
34
+ sessionManager: {
35
+ getSessionFile(): string | null;
36
+ getSessionId(): string;
37
+ getSessionDir(): string;
38
+ };
39
+ cwd: string;
40
+ model?: unknown;
41
+ modelRegistry?: unknown;
42
+ ui?: { notify?(message: string, level: "info" | "warning" | "error"): void };
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /** Mirror of pi-interactive-subagents' RunningSubagent (subset we care about). */
47
+ interface RunningSubagent {
48
+ id: string;
49
+ name: string;
50
+ surface: string;
51
+ sessionFile: string;
52
+ startTime: number;
53
+ }
54
+
55
+ /** Mirror of pi-interactive-subagents' SubagentResult. */
56
+ interface SubagentResult {
57
+ name: string;
58
+ task: string;
59
+ summary: string;
60
+ sessionFile?: string;
61
+ exitCode: number;
62
+ elapsed: number;
63
+ structuredOutput?: unknown;
64
+ }
65
+
66
+ // ── subagent API (lazily resolved from globalThis) ──
67
+ interface SubagentApi {
68
+ launchSubagent(
69
+ params: Record<string, unknown>,
70
+ ctx: SubagentCtx,
71
+ options?: { surface?: string },
72
+ ): Promise<RunningSubagent>;
73
+ watchSubagent(running: RunningSubagent, signal: AbortSignal): Promise<SubagentResult>;
74
+ }
75
+
76
+ function getSubagentApi(): SubagentApi {
77
+ const api = (globalThis as any).__pi_subagents;
78
+ if (!api) {
79
+ throw new Error(i18n.t("subagentRequired"));
80
+ }
81
+ return api as SubagentApi;
82
+ }
83
+
84
+ // ── options ──
85
+ export interface SubagentWorkflowAgentOptions {
86
+ cwd?: string;
87
+ /** Pi extension context (passed from workflow-tool execute callback). */
88
+ launchCtx: SubagentCtx;
89
+ /** Model override for subagent sessions (string id, not Model object). */
90
+ model?: string;
91
+ /** Extra instructions prepended to every agent() prompt. */
92
+ instructions?: string;
93
+ }
94
+
95
+ export interface AgentRunOptions {
96
+ label?: string;
97
+ schema?: unknown;
98
+ signal?: AbortSignal;
99
+ instructions?: string;
100
+ /** 覆盖 subagent 的模型,不传则走默认 fallback */
101
+ model?: string;
102
+ }
103
+
104
+ export type AgentRunResult = unknown;
105
+
106
+ // ── agent ──
107
+ export class SubagentWorkflowAgent {
108
+ private readonly cwd: string;
109
+ private readonly launchCtx: SubagentCtx;
110
+ private readonly model?: string;
111
+ private readonly instructions?: string;
112
+
113
+ constructor(options: SubagentWorkflowAgentOptions) {
114
+ this.cwd = options.cwd ?? process.cwd();
115
+ this.launchCtx = options.launchCtx;
116
+ this.model = options.model;
117
+ this.instructions = options.instructions;
118
+ }
119
+
120
+ async run(prompt: string, options: AgentRunOptions = {}): Promise<AgentRunResult> {
121
+ const api = getSubagentApi();
122
+
123
+ const taskParts = [
124
+ this.instructions,
125
+ options.instructions,
126
+ options.label ? `Task label: ${options.label}` : undefined,
127
+ prompt,
128
+ ].filter(Boolean);
129
+ const task = taskParts.join("\n\n");
130
+
131
+ const launchedAt = Date.now();
132
+ const running = await api.launchSubagent(
133
+ {
134
+ name: options.label ?? "workflow-agent",
135
+ task,
136
+ model: options.model ?? this.model,
137
+ cwd: this.cwd,
138
+ ...(options.schema ? { structuredOutputSchema: options.schema } : {}),
139
+ },
140
+ this.launchCtx,
141
+ );
142
+ this.launchCtx.ui?.notify?.(`[workflow] "${options.label ?? "workflow-agent"}" launched (${Date.now() - launchedAt}ms)`, "info");
143
+
144
+ // Create abort signal that combines caller's signal with module-level abort
145
+ const abortController = new AbortController();
146
+ let removeAbort: (() => void) | undefined;
147
+ if (options.signal) {
148
+ if (options.signal.aborted) {
149
+ throw new Error("Subagent was aborted");
150
+ }
151
+ const onAbort = () => abortController.abort();
152
+ options.signal.addEventListener("abort", onAbort, { once: true });
153
+ removeAbort = () => options.signal?.removeEventListener("abort", onAbort);
154
+ }
155
+
156
+ try {
157
+ const watchStartedAt = Date.now();
158
+ const result = await api.watchSubagent(running, abortController.signal);
159
+ const watchTookMs = Date.now() - watchStartedAt;
160
+ this.launchCtx.ui?.notify?.(
161
+ `[workflow] "${options.label ?? "workflow-agent"}" done (${watchTookMs}ms) → ` +
162
+ `exitCode=${result.exitCode} hasOutput=${result.structuredOutput !== undefined}`,
163
+ "info",
164
+ );
165
+
166
+ if (options.signal?.aborted) throw new Error("Subagent was aborted");
167
+
168
+ // 防御性校验:pollForExit 任何 reason (done / structured_output / ping / sentinel)
169
+ // 都要求 .jsonl 实际存在 + 非空。如果 .jsonl 不存在/为空,说明子 pi 根本没启动
170
+ // (典型场景:pane run 6ms 内 close、herdr 静默失败、stale .exit 残留命中 fast path)。
171
+ // 这种"假成功"比"明确失败"更危险——workflow 会把空数据当成结果继续往下走。
172
+ const jsonlCheck = sessionFileLooksValid(running.sessionFile);
173
+ if (!jsonlCheck.ok) {
174
+ this.launchCtx.ui?.notify?.(
175
+ `[workflow] "${options.label ?? "workflow-agent"}" SESSION FILE ${jsonlCheck.reason} ` +
176
+ `(${watchTookMs}ms, session ${running.sessionFile}) — ` +
177
+ `subagent never actually started, likely pollForExit false positive`,
178
+ "error",
179
+ );
180
+ throw new Error(
181
+ `Subagent pollForExit returned in ${watchTookMs}ms but session file is ` +
182
+ `${jsonlCheck.reason} (${running.sessionFile}). ` +
183
+ `This indicates the subagent never actually started — ` +
184
+ `likely a false positive from pollForExit (herdr workspace state issue). ` +
185
+ `Try restarting the herdr workspace or check for stale .exit sidecar files.`,
186
+ );
187
+ }
188
+
189
+ if (options.schema) {
190
+ if (result.structuredOutput === undefined) {
191
+ this.launchCtx.ui?.notify?.(
192
+ `[workflow] "${options.label ?? "workflow-agent"}" ${watchTookMs}ms exitCode=${result.exitCode} ` +
193
+ `— finished without calling structured_output`,
194
+ "warning",
195
+ );
196
+ throw new Error("Subagent finished without calling structured_output");
197
+ }
198
+ return result.structuredOutput as AgentRunResult;
199
+ }
200
+
201
+ return result.summary as AgentRunResult;
202
+ } finally {
203
+ removeAbort?.();
204
+ }
205
+ }
206
+ }