pi-workflow-engine 0.3.0 → 0.4.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/.pi/extensions/pi-workflow-engine/index.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
- package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
- package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +7 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
- package/.pi/settings.json +3 -0
- package/README.md +70 -23
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
- package/assets/preview.png +0 -0
- package/package.json +32 -6
- package/src/agent-runner.ts +0 -122
- package/src/concurrency.ts +0 -55
- package/src/discovery.ts +0 -54
- package/src/index.ts +0 -141
- package/src/ui/workflow-widget.ts +0 -143
- /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
package/package.json
CHANGED
|
@@ -1,23 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-workflow-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
|
|
6
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-workflow",
|
|
10
|
+
"workflow-engine",
|
|
11
|
+
"multi-agent",
|
|
12
|
+
"coding-agent"
|
|
13
|
+
],
|
|
7
14
|
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/timbrinded/pi-workflow-engine#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/timbrinded/pi-workflow-engine/issues"
|
|
18
|
+
},
|
|
8
19
|
"repository": {
|
|
9
20
|
"type": "git",
|
|
10
21
|
"url": "git+https://github.com/timbrinded/pi-workflow-engine.git"
|
|
11
22
|
},
|
|
12
|
-
"files": [
|
|
23
|
+
"files": [
|
|
24
|
+
".pi/extensions/pi-workflow-engine",
|
|
25
|
+
".pi/settings.json",
|
|
26
|
+
"assets",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
13
30
|
"pi": {
|
|
14
|
-
"extensions": ["
|
|
31
|
+
"extensions": [".pi/extensions/pi-workflow-engine/index.ts"],
|
|
15
32
|
"image": "https://raw.githubusercontent.com/timbrinded/pi-workflow-engine/master/assets/preview.png"
|
|
16
33
|
},
|
|
17
34
|
"scripts": {
|
|
18
35
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test
|
|
20
|
-
"
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"test:changedlines": "bun test tests/changedlines.test.ts",
|
|
38
|
+
"test:concurrency": "bun test tests/concurrency.test.ts",
|
|
39
|
+
"test:smoke": "bun test tests/discovery.test.ts",
|
|
40
|
+
"test:ui": "bun test tests/workflow-ui.test.ts",
|
|
41
|
+
"bench": "bun run bench:concurrency && bun run bench:discovery && bun run bench:ui",
|
|
42
|
+
"bench:concurrency": "bun run scripts/bench/concurrency.ts",
|
|
43
|
+
"bench:discovery": "bun run scripts/bench/discovery.ts",
|
|
44
|
+
"bench:startup": "bun run scripts/bench/startup.ts",
|
|
45
|
+
"bench:ui": "bun run scripts/bench/ui.ts",
|
|
46
|
+
"prepublishOnly": "tsc --noEmit && bun run test"
|
|
21
47
|
},
|
|
22
48
|
"peerDependencies": {
|
|
23
49
|
"@earendil-works/pi-agent-core": "*",
|
package/src/agent-runner.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
4
|
-
import type { AgentState } from "@earendil-works/pi-agent-core";
|
|
5
|
-
import type { AgentOptions } from "./types.ts";
|
|
6
|
-
import type { Semaphore } from "./concurrency.ts";
|
|
7
|
-
import type { ProgressTracker } from "./progress.ts";
|
|
8
|
-
|
|
9
|
-
/** Name of the synthetic terminating tool that carries structured output. */
|
|
10
|
-
const FINAL_TOOL = "final_answer";
|
|
11
|
-
|
|
12
|
-
/** Shared per-run context threaded into every agent() call. */
|
|
13
|
-
export interface RunContext {
|
|
14
|
-
cwd: string;
|
|
15
|
-
hostModel: Model<any> | undefined;
|
|
16
|
-
modelRegistry: ModelRegistry;
|
|
17
|
-
semaphore: Semaphore;
|
|
18
|
-
progress: ProgressTracker;
|
|
19
|
-
signal: AbortSignal | undefined;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Pull the last assistant message's plain text out of a finished session. */
|
|
23
|
-
function lastAssistantText(state: AgentState): string {
|
|
24
|
-
const messages = state.messages;
|
|
25
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
26
|
-
const message = messages[i];
|
|
27
|
-
if ("role" in message && message.role === "assistant") {
|
|
28
|
-
const content = (message as { content?: Array<{ type: string; text?: string }> }).content ?? [];
|
|
29
|
-
return content
|
|
30
|
-
.filter((part) => part.type === "text")
|
|
31
|
-
.map((part) => part.text ?? "")
|
|
32
|
-
.join("")
|
|
33
|
-
.trim();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return "";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Run one subagent to completion in an isolated in-memory session.
|
|
41
|
-
*
|
|
42
|
-
* Structured output trick: when `opts.schema` is set we register a single
|
|
43
|
-
* terminating tool whose `parameters` IS the schema. The agent's last action is to
|
|
44
|
-
* call it; pi validates the args against the schema, hands them to `execute`, and
|
|
45
|
-
* `terminate: true` ends the turn with no extra LLM round-trip. We capture those
|
|
46
|
-
* validated args in a closure — that captured object is the structured result, so
|
|
47
|
-
* no event-stream parsing is needed.
|
|
48
|
-
*/
|
|
49
|
-
export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
|
|
50
|
-
const label = opts.label ?? "agent";
|
|
51
|
-
|
|
52
|
-
// Track queued agents before acquiring a global concurrency slot.
|
|
53
|
-
const rowId = rc.progress.agentQueued(opts.phase, label);
|
|
54
|
-
return rc.semaphore.run(async () => {
|
|
55
|
-
rc.progress.agentStart(opts.phase, label, rowId);
|
|
56
|
-
|
|
57
|
-
let captured: unknown = null;
|
|
58
|
-
let failed = false;
|
|
59
|
-
const customTools: ToolDefinition[] = opts.schema
|
|
60
|
-
? [
|
|
61
|
-
defineTool({
|
|
62
|
-
name: FINAL_TOOL,
|
|
63
|
-
label: "Final Answer",
|
|
64
|
-
description:
|
|
65
|
-
"Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
|
|
66
|
-
parameters: opts.schema,
|
|
67
|
-
async execute(_toolCallId, params) {
|
|
68
|
-
captured = params;
|
|
69
|
-
return { content: [{ type: "text", text: "Recorded." }], details: params, terminate: true };
|
|
70
|
-
},
|
|
71
|
-
}),
|
|
72
|
-
]
|
|
73
|
-
: [];
|
|
74
|
-
|
|
75
|
-
// When the workflow restricts built-in tools, keep the terminating tool visible.
|
|
76
|
-
const allow = opts.tools
|
|
77
|
-
? opts.schema
|
|
78
|
-
? [...opts.tools, FINAL_TOOL]
|
|
79
|
-
: opts.tools
|
|
80
|
-
: undefined;
|
|
81
|
-
|
|
82
|
-
// Resolve the model by runtime id lookup (no compile-time model-id union); fall back to the host's model.
|
|
83
|
-
const model = opts.model ? rc.modelRegistry.find("anthropic", opts.model) ?? rc.hostModel : rc.hostModel;
|
|
84
|
-
|
|
85
|
-
const { session } = await createAgentSession({
|
|
86
|
-
cwd: rc.cwd,
|
|
87
|
-
model,
|
|
88
|
-
modelRegistry: rc.modelRegistry,
|
|
89
|
-
thinkingLevel: opts.thinkingLevel,
|
|
90
|
-
tools: allow,
|
|
91
|
-
customTools,
|
|
92
|
-
sessionManager: SessionManager.inMemory(rc.cwd),
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const unsubscribe = session.subscribe((event) => {
|
|
96
|
-
if (event.type === "tool_execution_start" && event.toolName !== FINAL_TOOL) {
|
|
97
|
-
rc.progress.agentTool(label, event.toolName, rowId);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const finalPrompt = opts.schema
|
|
103
|
-
? `${prompt}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
|
|
104
|
-
: prompt;
|
|
105
|
-
await session.prompt(finalPrompt);
|
|
106
|
-
if (opts.schema) {
|
|
107
|
-
if (captured === null) rc.progress.log(`${label}: no structured answer returned`);
|
|
108
|
-
return captured;
|
|
109
|
-
}
|
|
110
|
-
return lastAssistantText(session.state);
|
|
111
|
-
} catch (error) {
|
|
112
|
-
failed = true;
|
|
113
|
-
rc.progress.agentFailed(label, error, rowId);
|
|
114
|
-
rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
-
throw error;
|
|
116
|
-
} finally {
|
|
117
|
-
unsubscribe();
|
|
118
|
-
session.dispose();
|
|
119
|
-
if (!failed) rc.progress.agentDone(label, rowId);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
}
|
package/src/concurrency.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A counting semaphore: bounds how many async tasks run at once.
|
|
3
|
-
*
|
|
4
|
-
* This is the single global concurrency cap for a workflow run. Every `agent()`
|
|
5
|
-
* call acquires a slot before spawning a session and releases it when done, so the
|
|
6
|
-
* cap holds no matter how `parallel`/`pipeline` are nested — exactly like the
|
|
7
|
-
* built-in Workflow tool, where "concurrent agent() calls are capped and excess queue".
|
|
8
|
-
*/
|
|
9
|
-
export class Semaphore {
|
|
10
|
-
private active = 0;
|
|
11
|
-
private readonly waiters: Array<() => void> = [];
|
|
12
|
-
|
|
13
|
-
constructor(private readonly max: number) {}
|
|
14
|
-
|
|
15
|
-
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
16
|
-
if (this.active >= this.max) {
|
|
17
|
-
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
18
|
-
}
|
|
19
|
-
this.active++;
|
|
20
|
-
try {
|
|
21
|
-
return await fn();
|
|
22
|
-
} finally {
|
|
23
|
-
this.active--;
|
|
24
|
-
this.waiters.shift()?.();
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Run every thunk concurrently and wait for all results (a barrier). */
|
|
30
|
-
export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]> {
|
|
31
|
-
return Promise.all(thunks.map((thunk) => thunk()));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Run each item through all stages independently. There is NO barrier between
|
|
36
|
-
* stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
|
|
37
|
-
* concurrency is bounded by the run's Semaphore (inside each `agent()`), not here —
|
|
38
|
-
* so launching every item at once is safe.
|
|
39
|
-
*
|
|
40
|
-
* Each stage receives (previousResult, originalItem, index).
|
|
41
|
-
*/
|
|
42
|
-
export async function pipeline(
|
|
43
|
-
items: unknown[],
|
|
44
|
-
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
45
|
-
): Promise<unknown[]> {
|
|
46
|
-
return Promise.all(
|
|
47
|
-
items.map(async (item, index) => {
|
|
48
|
-
let acc: unknown = item;
|
|
49
|
-
for (const stage of stages) {
|
|
50
|
-
acc = await stage(acc, item, index);
|
|
51
|
-
}
|
|
52
|
-
return acc;
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
}
|
package/src/discovery.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { readdir } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import type { WorkflowModule } from "./types.ts";
|
|
6
|
-
import { BUILTIN_WORKFLOWS } from "./workflows.ts";
|
|
7
|
-
|
|
8
|
-
function isWorkflowModule(value: unknown): value is WorkflowModule {
|
|
9
|
-
if (!value || typeof value !== "object") return false;
|
|
10
|
-
const candidate = value as { meta?: { name?: unknown }; default?: unknown };
|
|
11
|
-
return typeof candidate.meta?.name === "string" && typeof candidate.default === "function";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
15
|
-
async function loadDir(dir: string): Promise<WorkflowModule[]> {
|
|
16
|
-
let entries: string[];
|
|
17
|
-
try {
|
|
18
|
-
entries = await readdir(dir);
|
|
19
|
-
} catch {
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const modules: WorkflowModule[] = [];
|
|
24
|
-
for (const name of entries) {
|
|
25
|
-
if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
|
|
26
|
-
try {
|
|
27
|
-
const loaded: unknown = await import(pathToFileURL(join(dir, name)).href);
|
|
28
|
-
if (isWorkflowModule(loaded)) modules.push(loaded);
|
|
29
|
-
} catch (error) {
|
|
30
|
-
// Drop-in loading depends on the runtime resolving TS + the bundled typebox.
|
|
31
|
-
// Failures here are non-fatal: the static registry still works.
|
|
32
|
-
console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return modules;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* All available workflows by name. Static registry wins on name collisions, so the
|
|
40
|
-
* bundled example is always the verified one even if a same-named file is dropped in.
|
|
41
|
-
*/
|
|
42
|
-
export async function discoverWorkflows(repoDir: string): Promise<Map<string, WorkflowModule>> {
|
|
43
|
-
const byName = new Map<string, WorkflowModule>();
|
|
44
|
-
for (const mod of BUILTIN_WORKFLOWS) byName.set(mod.meta.name, mod);
|
|
45
|
-
|
|
46
|
-
const dynamic = [
|
|
47
|
-
...(await loadDir(join(repoDir, "workflows"))),
|
|
48
|
-
...(await loadDir(join(homedir(), ".pi", "agent", "workflows"))),
|
|
49
|
-
];
|
|
50
|
-
for (const mod of dynamic) {
|
|
51
|
-
if (!byName.has(mod.meta.name)) byName.set(mod.meta.name, mod);
|
|
52
|
-
}
|
|
53
|
-
return byName;
|
|
54
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { Type } from "typebox";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
-
import { discoverWorkflows } from "./discovery.ts";
|
|
6
|
-
import { runWorkflow } from "./engine.ts";
|
|
7
|
-
import type { WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
8
|
-
import { isWorkflowResult, renderWorkflowResult, type WorkflowResultEnvelope } from "./ui/workflow-result-renderer.ts";
|
|
9
|
-
|
|
10
|
-
/** Repo root (this file lives in <repo>/src/index.ts). */
|
|
11
|
-
const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
|
|
12
|
-
|
|
13
|
-
function summarize(result: unknown): string {
|
|
14
|
-
if (result && typeof result === "object" && typeof (result as { summary?: unknown }).summary === "string") {
|
|
15
|
-
return (result as { summary: string }).summary;
|
|
16
|
-
}
|
|
17
|
-
return typeof result === "string" ? result : "Workflow finished.";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function formatReport(name: string, result: unknown): string {
|
|
21
|
-
return [`## Workflow: ${name}`, "", "```json", JSON.stringify(result, null, 2), "```"].join("\n");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function workflowEnvelope(name: string, result: unknown): WorkflowResultEnvelope {
|
|
25
|
-
return { name, result, completedAt: Date.now() };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface WorkflowInvocation {
|
|
29
|
-
name: string;
|
|
30
|
-
args: string;
|
|
31
|
-
options: WorkflowRunOptions;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function parseWorkflowInvocation(input: string): WorkflowInvocation {
|
|
35
|
-
const trimmed = input.trim();
|
|
36
|
-
const space = trimmed.indexOf(" ");
|
|
37
|
-
const name = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
38
|
-
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
39
|
-
const inspect = /(?:^|\s)--inspect(?:\s|$)/.test(rest);
|
|
40
|
-
const cleanedArgs = rest.replace(/(?:^|\s)--inspect(?=\s|$)/g, " ").trim();
|
|
41
|
-
return { name, args: cleanedArgs, options: inspect ? { inspect: true } : {} };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function pickWorkflow(
|
|
45
|
-
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
46
|
-
ctx: ExtensionCommandContext,
|
|
47
|
-
): Promise<WorkflowInvocation | undefined> {
|
|
48
|
-
const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
|
|
49
|
-
const choice = await ctx.ui.select("Run workflow", choices);
|
|
50
|
-
if (!choice) return undefined;
|
|
51
|
-
|
|
52
|
-
const separator = choice.indexOf(" — ");
|
|
53
|
-
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
54
|
-
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
55
|
-
const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
|
|
56
|
-
return { name, args, options: { inspect } };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function sendWorkflowResult(
|
|
60
|
-
pi: ExtensionAPI,
|
|
61
|
-
ctx: ExtensionCommandContext,
|
|
62
|
-
name: string,
|
|
63
|
-
mod: WorkflowModule,
|
|
64
|
-
args: string,
|
|
65
|
-
options: WorkflowRunOptions,
|
|
66
|
-
): Promise<void> {
|
|
67
|
-
const result = await runWorkflow(ctx, mod, args, options);
|
|
68
|
-
pi.sendMessage(
|
|
69
|
-
{ customType: "workflow-result", content: formatReport(name, result), display: true, details: workflowEnvelope(name, result) },
|
|
70
|
-
{ triggerTurn: false },
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
75
|
-
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
76
|
-
const details = message.details;
|
|
77
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
78
|
-
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// /workflow <name> [args] — user-invoked.
|
|
82
|
-
pi.registerCommand("workflow", {
|
|
83
|
-
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
84
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
85
|
-
const workflows = await discoverWorkflows(REPO_DIR);
|
|
86
|
-
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
87
|
-
const direct = parseWorkflowInvocation(args);
|
|
88
|
-
const invocation = direct.name ? direct : ctx.hasUI ? await pickWorkflow(workflows, ctx) : undefined;
|
|
89
|
-
|
|
90
|
-
if (!invocation) {
|
|
91
|
-
ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const mod = workflows.get(invocation.name);
|
|
96
|
-
if (!mod) {
|
|
97
|
-
ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
await sendWorkflowResult(pi, ctx, invocation.name, mod, invocation.args, invocation.options);
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// workflow tool — lets the host agent fan out mid-conversation.
|
|
106
|
-
pi.registerTool({
|
|
107
|
-
name: "workflow",
|
|
108
|
-
label: "Workflow",
|
|
109
|
-
description:
|
|
110
|
-
"Run a named multi-agent workflow (fan-out → verify → synthesize) and return its structured result. Use for thorough reviews or audits.",
|
|
111
|
-
parameters: Type.Object({
|
|
112
|
-
name: Type.String({ description: "Workflow name, e.g. code-review" }),
|
|
113
|
-
args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
|
|
114
|
-
}),
|
|
115
|
-
renderCall(args, theme) {
|
|
116
|
-
const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
|
|
117
|
-
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
|
|
118
|
-
},
|
|
119
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
120
|
-
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
121
|
-
const details = result.details;
|
|
122
|
-
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
123
|
-
const first = result.content[0];
|
|
124
|
-
const text = first?.type === "text" ? first.text : "Workflow finished.";
|
|
125
|
-
return new Text(theme.fg("muted", text), 0, 0);
|
|
126
|
-
},
|
|
127
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
128
|
-
const workflows = await discoverWorkflows(REPO_DIR);
|
|
129
|
-
const mod = workflows.get(params.name);
|
|
130
|
-
if (!mod) {
|
|
131
|
-
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
132
|
-
return {
|
|
133
|
-
content: [{ type: "text", text: `Unknown workflow "${params.name}". Available: ${available}` }],
|
|
134
|
-
details: { error: "unknown_workflow", available },
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
const result = await runWorkflow(ctx, mod, params.args ?? "");
|
|
138
|
-
return { content: [{ type: "text", text: summarize(result) }], details: workflowEnvelope(params.name, result) };
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
3
|
-
import { formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
4
|
-
|
|
5
|
-
const MAX_WIDGET_LINES = 12;
|
|
6
|
-
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
-
|
|
8
|
-
export interface WorkflowWidget {
|
|
9
|
-
nextFrame(): void;
|
|
10
|
-
render(width: number, theme: Theme): string[];
|
|
11
|
-
invalidate(): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function createWorkflowWidget(snapshotProvider: () => WorkflowProgressSnapshot): WorkflowWidget {
|
|
15
|
-
return new LiveWorkflowWidget(snapshotProvider);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function renderWorkflowWidgetLines(
|
|
19
|
-
snapshot: WorkflowProgressSnapshot,
|
|
20
|
-
frame: number,
|
|
21
|
-
width: number,
|
|
22
|
-
theme: Theme,
|
|
23
|
-
): string[] {
|
|
24
|
-
const safeWidth = Math.max(1, width);
|
|
25
|
-
const agents = snapshot.phases.flatMap((phase) => phase.agents);
|
|
26
|
-
const running = agents.filter((agent) => agent.status === "running").length;
|
|
27
|
-
const queued = agents.filter((agent) => agent.status === "queued").length;
|
|
28
|
-
const done = agents.filter((agent) => agent.status === "done").length;
|
|
29
|
-
const failed = agents.filter((agent) => agent.status === "failed").length;
|
|
30
|
-
const active = running + queued;
|
|
31
|
-
const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
|
|
32
|
-
const spinner = active > 0 ? theme.fg("accent", SPINNER[frame % SPINNER.length]) : theme.fg("success", "✓");
|
|
33
|
-
|
|
34
|
-
const headingParts = [`${done}/${agents.length} done`, elapsed];
|
|
35
|
-
if (running > 0) headingParts.unshift(`${running} running`);
|
|
36
|
-
if (queued > 0) headingParts.unshift(`${queued} queued`);
|
|
37
|
-
if (failed > 0) headingParts.unshift(theme.fg("error", `${failed} failed`));
|
|
38
|
-
|
|
39
|
-
const lines: string[] = [
|
|
40
|
-
truncateDisplay(
|
|
41
|
-
`${spinner} ${theme.bold(snapshot.title)} ${theme.fg("dim", "·")} ${theme.fg("muted", snapshot.currentPhase)} ${theme.fg("dim", "·")} ${headingParts.join(` ${theme.fg("dim", "·")} `)}`,
|
|
42
|
-
safeWidth,
|
|
43
|
-
),
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
const footer = footerLine(snapshot, theme);
|
|
47
|
-
const bodyBudget = Math.max(0, MAX_WIDGET_LINES - lines.length - (footer ? 1 : 0));
|
|
48
|
-
const bodyLines = prioritizedBodyLines(snapshot.phases, theme);
|
|
49
|
-
const visibleBody = bodyLines.slice(0, bodyBudget);
|
|
50
|
-
const hidden = bodyLines.length - visibleBody.length;
|
|
51
|
-
|
|
52
|
-
for (const line of visibleBody) lines.push(truncateDisplay(line, safeWidth));
|
|
53
|
-
if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
|
|
54
|
-
lines.push(truncateDisplay(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`, safeWidth));
|
|
55
|
-
}
|
|
56
|
-
if (footer && lines.length < MAX_WIDGET_LINES) lines.push(truncateDisplay(footer, safeWidth));
|
|
57
|
-
|
|
58
|
-
return lines.slice(0, MAX_WIDGET_LINES).map((line) => truncateDisplay(line, safeWidth));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
class LiveWorkflowWidget implements WorkflowWidget {
|
|
62
|
-
private frame = 0;
|
|
63
|
-
private cachedWidth: number | undefined;
|
|
64
|
-
private cachedFrame: number | undefined;
|
|
65
|
-
private cachedDoneAt: number | undefined;
|
|
66
|
-
private cachedLines: string[] | undefined;
|
|
67
|
-
|
|
68
|
-
constructor(private readonly snapshotProvider: () => WorkflowProgressSnapshot) {}
|
|
69
|
-
|
|
70
|
-
nextFrame(): void {
|
|
71
|
-
this.frame = (this.frame + 1) % SPINNER.length;
|
|
72
|
-
this.invalidate();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
render(width: number, theme: Theme): string[] {
|
|
76
|
-
const snapshot = this.snapshotProvider();
|
|
77
|
-
if (
|
|
78
|
-
this.cachedLines &&
|
|
79
|
-
this.cachedWidth === width &&
|
|
80
|
-
this.cachedFrame === this.frame &&
|
|
81
|
-
this.cachedDoneAt === snapshot.doneAt
|
|
82
|
-
) {
|
|
83
|
-
return this.cachedLines;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
this.cachedLines = renderWorkflowWidgetLines(snapshot, this.frame, width, theme);
|
|
87
|
-
this.cachedWidth = width;
|
|
88
|
-
this.cachedFrame = this.frame;
|
|
89
|
-
this.cachedDoneAt = snapshot.doneAt;
|
|
90
|
-
return this.cachedLines;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
invalidate(): void {
|
|
94
|
-
this.cachedWidth = undefined;
|
|
95
|
-
this.cachedFrame = undefined;
|
|
96
|
-
this.cachedDoneAt = undefined;
|
|
97
|
-
this.cachedLines = undefined;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function prioritizedBodyLines(phases: readonly PhaseSnapshot[], theme: Theme): string[] {
|
|
102
|
-
const activePhases = phases.filter((phase) => phase.agents.some((agent) => agent.status === "running" || agent.status === "queued"));
|
|
103
|
-
const inactivePhases = phases.filter((phase) => !activePhases.includes(phase));
|
|
104
|
-
return [...activePhases, ...inactivePhases].flatMap((phase) => phaseLines(phase, theme));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function phaseLines(phase: PhaseSnapshot, theme: Theme): string[] {
|
|
108
|
-
if (phase.agents.length === 0 && phase.title === "Workflow") return [];
|
|
109
|
-
const running = phase.agents.filter((agent) => agent.status === "running").length;
|
|
110
|
-
const done = phase.agents.filter((agent) => agent.status === "done").length;
|
|
111
|
-
const failed = phase.agents.filter((agent) => agent.status === "failed").length;
|
|
112
|
-
const parts: string[] = [];
|
|
113
|
-
if (running > 0) parts.push(`${running} running`);
|
|
114
|
-
if (done > 0) parts.push(`${done} done`);
|
|
115
|
-
if (failed > 0) parts.push(`${failed} failed`);
|
|
116
|
-
|
|
117
|
-
const lines = [`${theme.fg("dim", "├─")} ${theme.fg(running > 0 ? "accent" : "muted", phase.title)}${parts.length ? ` ${theme.fg("dim", `(${parts.join(" · ")})`)}` : ""}`];
|
|
118
|
-
const active = phase.agents.filter((agent) => agent.status === "running" || agent.status === "queued");
|
|
119
|
-
const failedAgents = phase.agents.filter((agent) => agent.status === "failed");
|
|
120
|
-
const completed = phase.agents.filter((agent) => agent.status === "done");
|
|
121
|
-
for (const agent of [...active, ...failedAgents, ...completed]) lines.push(agentLine(agent, theme));
|
|
122
|
-
return lines;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function agentLine(agent: AgentRowSnapshot, theme: Theme): string {
|
|
126
|
-
const detailParts: string[] = [];
|
|
127
|
-
if (agent.toolUses > 0) detailParts.push(`${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`);
|
|
128
|
-
if (agent.status === "running" && agent.lastTool) detailParts.push(agent.lastTool);
|
|
129
|
-
if (agent.startedAt !== undefined) detailParts.push(formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt));
|
|
130
|
-
if (agent.status === "failed" && agent.error) detailParts.push(agent.error);
|
|
131
|
-
|
|
132
|
-
const activity = detailParts.length > 0 ? ` ${theme.fg("dim", `· ${detailParts.join(" · ")}`)}` : "";
|
|
133
|
-
return `${theme.fg("dim", "│ ")} ${statusIcon(agent.status, theme)} ${theme.fg(agent.status === "running" ? "text" : "muted", agent.label)}${activity}`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function footerLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
137
|
-
const counters = snapshot.counters.slice(0, 4).map((counter) => `${counter.label} ${formatCount(counter.value)}`);
|
|
138
|
-
const latestLog = snapshot.logs[snapshot.logs.length - 1];
|
|
139
|
-
const parts = counters.length > 0 ? counters : [];
|
|
140
|
-
if (latestLog) parts.push(latestLog);
|
|
141
|
-
if (parts.length === 0) return undefined;
|
|
142
|
-
return `${theme.fg("dim", "└─")} ${theme.fg("dim", parts.join(" · "))}`;
|
|
143
|
-
}
|
|
File without changes
|