pi-pipelines 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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Subagent Bridge — programmatic access to pi-subagents via the event bus
3
+ *
4
+ * Uses pi-subagents' internal event bridge to execute subagent tasks
5
+ * synchronously from within another extension.
6
+ *
7
+ * Events used:
8
+ * subagent:slash:request — emit to request subagent execution
9
+ * subagent:slash:started — emitted when execution starts
10
+ * subagent:slash:response — emitted with execution result
11
+ * subagent:slash:cancel — emit to cancel a running subagent
12
+ *
13
+ * This approach is cleaner than spawning child Pi processes because:
14
+ * - It reuses pi-subagents' existing execution pipeline
15
+ * - Results are returned via the same process (no IPC needed)
16
+ * - Parallel execution is handled natively
17
+ * - The extension composes with pi-subagents rather than wrapping it
18
+ */
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+
23
+ /** Request params expected by pi-subagents' slash bridge */
24
+ interface SubagentSlashParams {
25
+ agent?: string;
26
+ task?: string;
27
+ chain?: SubagentChainStep[];
28
+ tasks?: SubagentTaskParam[];
29
+ async?: boolean;
30
+ clarify?: boolean;
31
+ context?: "fresh" | "fork";
32
+ model?: string;
33
+ output?: string | boolean;
34
+ agentScope?: string;
35
+ cwd?: string;
36
+ skill?: string | string[] | boolean;
37
+ progress?: boolean;
38
+ reads?: string[] | boolean;
39
+ outputMode?: "inline" | "file-only";
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ interface SubagentChainStep {
44
+ agent: string;
45
+ task?: string;
46
+ phase?: string;
47
+ label?: string;
48
+ as?: string;
49
+ output?: string | boolean;
50
+ reads?: string[] | boolean;
51
+ model?: string;
52
+ skill?: string | string[] | boolean;
53
+ progress?: boolean;
54
+ }
55
+
56
+ interface SubagentTaskParam {
57
+ agent: string;
58
+ task: string;
59
+ output?: string | boolean;
60
+ reads?: string[] | boolean;
61
+ model?: string;
62
+ count?: number;
63
+ }
64
+
65
+ interface SlashResponse {
66
+ requestId: string;
67
+ result: {
68
+ content: string | Array<{ type: string; text: string }>;
69
+ details?: {
70
+ results?: Array<{
71
+ agent?: string;
72
+ exitCode?: number;
73
+ output?: string;
74
+ sessionFile?: string;
75
+ progress?: { status: string };
76
+ }>;
77
+ };
78
+ isError?: boolean;
79
+ };
80
+ isError: boolean;
81
+ errorText?: string;
82
+ }
83
+
84
+ /**
85
+ * Execute a single subagent task and wait for the result.
86
+ *
87
+ * Uses pi-subagents' slash event bridge (fast, same-process).
88
+ * Falls back to pi.exec() launching a child Pi process if the bridge
89
+ * is not available (transparent to the caller).
90
+ */
91
+ export async function executeSubagent(
92
+ pi: ExtensionAPI,
93
+ params: SubagentSlashParams,
94
+ signal?: AbortSignal,
95
+ ): Promise<SlashResponse> {
96
+ // Pipeline stages should never fork the parent session — they are independent tasks.
97
+ // Explicitly default to "fresh" context to avoid failures when pi-subagents agents
98
+ // have defaultContext: "fork" but the parent session hasn't been persisted to disk yet.
99
+ // This affects ALL pipelines, not just the built-in ones.
100
+ if (params.context === undefined) {
101
+ params.context = "fresh";
102
+ }
103
+
104
+ // Try the event bridge first
105
+ try {
106
+ return await tryBridge(pi, params, signal);
107
+ } catch (bridgeErr) {
108
+ const bridgeMessage = (bridgeErr as Error).message;
109
+
110
+ // If it's a bridge-unavailable error, try pi.exec() fallback
111
+ if (bridgeMessage.includes("bridge")) {
112
+ console.warn(`Bridge unavailable, falling back to pi.exec(): ${bridgeMessage}`);
113
+ try {
114
+ return await tryExec(pi, params, signal);
115
+ } catch (execErr) {
116
+ throw new Error(
117
+ `Subagent execution failed (bridge+fallback): ${(execErr as Error).message}`,
118
+ { cause: execErr },
119
+ );
120
+ }
121
+ }
122
+
123
+ throw bridgeErr;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Event-bridge execution path (same-process, pi-subagents must be loaded).
129
+ */
130
+ function tryBridge(
131
+ pi: ExtensionAPI,
132
+ params: SubagentSlashParams,
133
+ signal?: AbortSignal,
134
+ ): Promise<SlashResponse> {
135
+ return new Promise((resolve, reject) => {
136
+ const requestId = randomUUID();
137
+ let started = false;
138
+ let done = false;
139
+
140
+ const startTimeout = setTimeout(() => {
141
+ if (!done) cleanup();
142
+ if (!done) {
143
+ done = true;
144
+ reject(new Error("Subagent bridge did not respond within 15s. Is pi-subagents installed?"));
145
+ }
146
+ }, 15_000);
147
+
148
+ const onStarted = (data: unknown) => {
149
+ if (done) return;
150
+ const d = data as { requestId?: string } | undefined;
151
+ if (d?.requestId !== requestId) return;
152
+ started = true;
153
+ clearTimeout(startTimeout);
154
+ };
155
+
156
+ const onResponse = (data: unknown) => {
157
+ if (done) return;
158
+ const d = data as SlashResponse | undefined;
159
+ if (d?.requestId !== requestId) return;
160
+ cleanup();
161
+ done = true;
162
+ resolve(d);
163
+ };
164
+
165
+ const cleanup = () => {
166
+ clearTimeout(startTimeout);
167
+ try {
168
+ unsubStarted?.();
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ try {
173
+ unsubResponse?.();
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ };
178
+
179
+ if (signal) {
180
+ if (signal.aborted) {
181
+ cleanup();
182
+ reject(new Error("Aborted"));
183
+ return;
184
+ }
185
+ signal.addEventListener(
186
+ "abort",
187
+ () => {
188
+ if (!done) {
189
+ pi.events.emit("subagent:slash:cancel", { requestId });
190
+ cleanup();
191
+ done = true;
192
+ reject(new Error("Aborted"));
193
+ }
194
+ },
195
+ { once: true },
196
+ );
197
+ }
198
+
199
+ const unsubStarted = pi.events.on("subagent:slash:started", onStarted);
200
+ const unsubResponse = pi.events.on("subagent:slash:response", onResponse);
201
+
202
+ pi.events.emit("subagent:slash:request", { requestId, params });
203
+
204
+ // If not started after a microtask tick, bridge didn't respond
205
+ setTimeout(() => {
206
+ if (!started && !done) {
207
+ cleanup();
208
+ done = true;
209
+ reject(
210
+ new Error(
211
+ "No subagent bridge responded. Ensure pi-subagents is installed (pi install npm:pi-subagents).",
212
+ ),
213
+ );
214
+ }
215
+ }, 100);
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Fallback: launch a child Pi process to execute the subagent.
221
+ * Used when the event bridge is not available.
222
+ */
223
+ async function tryExec(
224
+ pi: ExtensionAPI,
225
+ params: SubagentSlashParams,
226
+ signal?: AbortSignal,
227
+ ): Promise<SlashResponse> {
228
+ const agent = params.agent ?? "worker";
229
+ const task = params.task ?? "";
230
+
231
+ // Build the command
232
+ const escapedTask = task.replace(/"/g, '\\"');
233
+ const cmd = `/run ${agent} "${escapedTask}"`;
234
+
235
+ const result = await pi.exec("pi", ["-p", cmd], {
236
+ timeout: 300_000,
237
+ signal,
238
+ });
239
+
240
+ const output = (result.stdout ?? "").trim();
241
+ const error = (result.stderr ?? "").trim();
242
+
243
+ if (result.code !== 0 && !output) {
244
+ return {
245
+ requestId: "",
246
+ result: { content: error || "(no output)" },
247
+ isError: true,
248
+ errorText: error || `Exit code: ${result.code}`,
249
+ };
250
+ }
251
+
252
+ return {
253
+ requestId: "",
254
+ result: { content: output || "(done)" },
255
+ isError: result.code !== 0,
256
+ errorText: result.code !== 0 ? error : undefined,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Extract text content from a subagent response.
262
+ */
263
+ export function extractResponseText(response: SlashResponse): string {
264
+ if (response.isError) return response.errorText ?? "(error)";
265
+
266
+ const content = response.result?.content;
267
+ if (typeof content === "string" && content.length > 0) return content;
268
+ if (Array.isArray(content)) {
269
+ const texts = content.filter((c) => c.type === "text").map((c) => c.text);
270
+ if (texts.length > 0) return texts.join("\n");
271
+ }
272
+
273
+ // Try details
274
+ const details = response.result?.details;
275
+ if (details?.results?.length) {
276
+ const outputs = details.results.map((r) => r.output ?? "").filter(Boolean);
277
+ if (outputs.length > 0) return outputs.join("\n\n");
278
+ }
279
+
280
+ return "(no output)";
281
+ }
282
+
283
+ /**
284
+ * Check if pi-subagents is available by probing the event bus.
285
+ */
286
+ export function isSubagentAvailable(_pi: ExtensionAPI): boolean {
287
+ // We can probe by emitting a quick test or checking a known event handler
288
+ // Simple heuristic: check if the slash request event can be emitted
289
+ // (we can't directly check, but we'll learn from errors)
290
+ return true; // optimistic — errors surface when trying to execute
291
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * TUI Widgets — rendering for pipeline progress and results
3
+ */
4
+
5
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
6
+ import type { PipelineResult } from "./types.ts";
7
+ import { formatDuration } from "./utils.ts";
8
+
9
+ export type ThemeAccessor = {
10
+ fg: (category: string, text: string) => string;
11
+ bg: (category: string, text: string) => string;
12
+ bold: (text: string) => string;
13
+ };
14
+
15
+ /**
16
+ * Create a TUI component for a pipeline result (used in custom message rendering).
17
+ */
18
+ export function createPipelineResultComponent(
19
+ result: PipelineResult,
20
+ theme: ThemeAccessor,
21
+ ): Container {
22
+ const container = new Container();
23
+ container.addChild(new Spacer(1));
24
+
25
+ // Header
26
+ const statusIcon = result.success ? "✅" : "❌";
27
+ const headerText = theme.fg("toolTitle", theme.bold(`Pipeline: ${result.pipelineName}`));
28
+ container.addChild(
29
+ new Text(
30
+ `${headerText} ${theme.fg(result.success ? "success" : "error", `[${statusIcon}]`)}`,
31
+ 0,
32
+ 0,
33
+ ),
34
+ );
35
+ container.addChild(new Text(theme.fg("dim", `Task: ${result.task}`), 0, 0));
36
+ container.addChild(
37
+ new Text(theme.fg("dim", `Duration: ${formatDuration(result.totalDurationMs)}`), 0, 0),
38
+ );
39
+ container.addChild(new Text("", 0, 0));
40
+
41
+ // Stages
42
+ for (const stage of result.stages) {
43
+ const icon = stage.success ? theme.fg("success", "✓") : theme.fg("error", "✗");
44
+ const name = theme.bold(stage.stageId);
45
+
46
+ let meta = "";
47
+ if (stage.rounds) {
48
+ meta += ` ${theme.fg("dim", `(${stage.rounds}r)`)}`;
49
+ }
50
+ if (stage.scores?.length) {
51
+ meta += ` ${theme.fg("accent", `[${stage.scores.join(",")}]`)}`;
52
+ }
53
+ meta += ` ${theme.fg("dim", formatDuration(stage.durationMs))}`;
54
+
55
+ container.addChild(new Text(`${icon} ${name}${meta}`, 0, 0));
56
+
57
+ if (stage.error) {
58
+ container.addChild(new Text(theme.fg("error", ` ${stage.error}`), 0, 0));
59
+ }
60
+ }
61
+
62
+ // Fatal error
63
+ if (result.error && result.stages.length === 0) {
64
+ container.addChild(new Text(theme.fg("error", `Fatal: ${result.error}`), 0, 0));
65
+ }
66
+
67
+ return container;
68
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Core types for the Pipelines extension
3
+ */
4
+
5
+ /** A reviewer configuration inside a review gate */
6
+ export interface ReviewerDef {
7
+ /** What this reviewer should focus on (prompt fragment) */
8
+ focus: string;
9
+ /** Optional specific agent to use (defaults to "reviewer") */
10
+ agent?: string;
11
+ }
12
+
13
+ /** Controls what output of a stage is passed forward to {outputs.stageId} */
14
+ export interface StageReportConfig {
15
+ /** How to pass output to next stages:
16
+ * 'full' — pass the raw agent output as-is (default)
17
+ * 'summary' — ask an LLM to summarize before passing forward
18
+ */
19
+ mode?: "full" | "summary";
20
+ /** Max length of the summary in characters (default: 500). Only used when mode='summary'. */
21
+ maxLength?: number;
22
+ /** Optional instruction to guide the summarizer. Only used when mode='summary'. */
23
+ instruction?: string;
24
+ }
25
+
26
+ /**
27
+ * Dynamic stage expansion configuration.
28
+ * Transforms a single stage template into N parallel stages,
29
+ * one per item from the source stage's output.
30
+ */
31
+ export interface ExpandConfig {
32
+ /** ID of the source stage whose output provides the items to expand over */
33
+ from: string;
34
+ /** Maximum number of items to expand (default: 10) */
35
+ maxItems?: number;
36
+ }
37
+
38
+ /** A review gate that wraps a stage with iterative scoring */
39
+ export interface ReviewGate {
40
+ type: "review-loop";
41
+ /** Maximum number of worker → review → fix rounds */
42
+ maxRounds: number;
43
+ /** Target score (0-10) that must be met to pass the gate */
44
+ targetScore: number;
45
+ /** List of reviewers to run in parallel each round */
46
+ reviewers: ReviewerDef[];
47
+ /** Optional model to use for the cross-model judge (different from worker) */
48
+ judgeModel?: string;
49
+ }
50
+
51
+ /** A single pipeline stage */
52
+ export interface Stage {
53
+ /** Unique stage ID (used for output references like {outputs.stageId}) */
54
+ id: string;
55
+ /** Agent name to use (e.g. "planner", "worker", "reviewer", "scout") */
56
+ agent?: string;
57
+ /** Task description for the agent. Supports {task}, {outputs.stageId}, {lastFeedback} */
58
+ task?: string;
59
+ /** If set, run these stages in parallel */
60
+ parallel?: Stage[];
61
+ /**
62
+ * If set, dynamically expand this stage template into N parallel stages,
63
+ * one per item from the output of stage `expand.from`.
64
+ * In v1, expanded stages do NOT execute gates — use a separate
65
+ * parallel/review stage after the expand stage for quality checks.
66
+ */
67
+ expand?: ExpandConfig;
68
+ /** Optional review gate wrapping this stage */
69
+ gate?: ReviewGate;
70
+ /** Optional model override for this stage */
71
+ model?: string;
72
+ /** Output file for this stage's results */
73
+ output?: string;
74
+ /** Files to read before execution */
75
+ reads?: string[];
76
+ /** Maximum subagent depth for nested delegation */
77
+ maxSubagentDepth?: number;
78
+ /**
79
+ * Controls what output of this stage is passed forward via {outputs.stageId}.
80
+ * When undefined or { mode: 'full' }, the raw agent output is used.
81
+ * When { mode: 'summary' }, the output is summarized before being stored.
82
+ */
83
+ report?: StageReportConfig;
84
+ }
85
+
86
+ /** Configuration for the automatic post-pipeline report synthesizer */
87
+ export interface ReportConfig {
88
+ /** Agent to use for synthesis (default: "planner") */
89
+ agent?: string;
90
+ /** Optional focus area to guide the synthesis prompt */
91
+ focus?: string;
92
+ }
93
+
94
+ /** A complete pipeline definition */
95
+ export interface PipelineDef {
96
+ /** Pipeline name (matches filename without extension) */
97
+ name: string;
98
+ /** Human-readable description */
99
+ description: string;
100
+ /** Schema version */
101
+ version?: number;
102
+ /** Default judge model for review gates (cross-model) */
103
+ judgeModel?: string;
104
+ /** Pipeline stages */
105
+ stages: Stage[];
106
+ /**
107
+ * Optional report synthesizer configuration.
108
+ * When set (or when omitted), a synthesis agent is called after all
109
+ * stages complete to produce a summary report.
110
+ * Set to false to disable automatic synthesis.
111
+ */
112
+ report?: ReportConfig | false;
113
+ }
114
+
115
+ /** Runtime state for a single stage execution */
116
+ export interface StageResult {
117
+ stageId: string;
118
+ success: boolean;
119
+ output: string;
120
+ error?: string;
121
+ durationMs: number;
122
+ rounds?: number; // How many review rounds were needed
123
+ scores?: number[]; // Review scores per round
124
+ rawOutput?: string; // Full raw output from the subagent
125
+ }
126
+
127
+ /** Full pipeline execution result */
128
+ export interface PipelineResult {
129
+ pipelineName: string;
130
+ task: string;
131
+ success: boolean;
132
+ stages: StageResult[];
133
+ totalDurationMs: number;
134
+ error?: string;
135
+ /** Synthesized report from the automatic post-pipeline summary agent */
136
+ synthesis?: string;
137
+ /** Error from the synthesis step (synthesis itself can fail without failing the pipeline) */
138
+ synthesisError?: string;
139
+ }
140
+
141
+ /** How to execute a stage — resolved from YAML + defaults */
142
+ export interface ResolvedStage {
143
+ original: Stage;
144
+ agent: string;
145
+ task: string;
146
+ isParallel: boolean;
147
+ children: ResolvedStage[];
148
+ gate: ReviewGate | null;
149
+ model: string | undefined;
150
+ output: string | undefined;
151
+ reads: string[] | undefined;
152
+ maxSubagentDepth: number | undefined;
153
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared utility functions for pi-pipelines.
3
+ */
4
+
5
+ /**
6
+ * Format a duration in milliseconds into a human-readable string.
7
+ * @internal Exported for testing. See pipeline-runner.test.ts
8
+ */
9
+ export function formatDuration(ms: number): string {
10
+ if (ms < 1000) return `${ms}ms`;
11
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
12
+ const min = Math.floor(ms / 60000);
13
+ const sec = ((ms % 60000) / 1000).toFixed(0);
14
+ return `${min}m ${sec}s`;
15
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "pi-pipelines",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for defining and running multi-agent pipelines with review gates, loops, and scoring — powered by pi-subagents",
5
+ "author": "Rybens92",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./extensions/index.ts",
9
+ "exports": {
10
+ ".": "./extensions/index.ts"
11
+ },
12
+ "files": [
13
+ "extensions/",
14
+ "pipelines/",
15
+ "skills/",
16
+ "CHANGELOG.md",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Rybens92/pi-pipelines.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Rybens92/pi-pipelines/issues"
26
+ },
27
+ "homepage": "https://github.com/Rybens92/pi-pipelines#readme",
28
+ "engines": {
29
+ "node": ">=20.0.0"
30
+ },
31
+ "keywords": [
32
+ "pi-package",
33
+ "pi",
34
+ "pi-coding-agent",
35
+ "pipelines",
36
+ "orchestration",
37
+ "workflows"
38
+ ],
39
+ "pi": {
40
+ "extensions": [
41
+ "./extensions/index.ts"
42
+ ],
43
+ "skills": [
44
+ "./skills"
45
+ ]
46
+ },
47
+ "dependencies": {
48
+ "js-yaml": "^4.1.0"
49
+ },
50
+ "peerDependencies": {
51
+ "@earendil-works/pi-ai": "*",
52
+ "@earendil-works/pi-coding-agent": "*",
53
+ "@earendil-works/pi-tui": "*",
54
+ "typebox": "*"
55
+ },
56
+ "scripts": {
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "test:coverage": "vitest run --coverage",
60
+ "lint": "eslint extensions/ tests/",
61
+ "lint:fix": "eslint --fix extensions/ tests/",
62
+ "format": "prettier --write 'extensions/**/*.ts' 'tests/**/*.ts'",
63
+ "format:check": "prettier --check 'extensions/**/*.ts' 'tests/**/*.ts'",
64
+ "check": "pnpm lint && pnpm format:check && pnpm test",
65
+ "prepublishOnly": "pnpm check"
66
+ },
67
+ "devDependencies": {
68
+ "@eslint/js": "^10.0.1",
69
+ "@types/js-yaml": "^4.0.9",
70
+ "@types/node": "^25.9.2",
71
+ "@vitest/coverage-v8": "^4.1.8",
72
+ "eslint": "^10.4.1",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "prettier": "^3.8.4",
75
+ "typescript": "^6.0.3",
76
+ "typescript-eslint": "^8.61.0",
77
+ "vitest": "^4.1.8"
78
+ }
79
+ }