pi-workflow-engine 0.4.0 → 0.6.1
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 +199 -18
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +2 -33
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +11 -7
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +185 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
- package/README.md +61 -12
- package/USAGE.md +189 -0
- package/package.json +4 -1
- package/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/.pi/extensions/pi-workflow-engine/workflows/ping.ts +0 -29
|
@@ -2,8 +2,13 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { WorkflowProgressSnapshot } from "./src/progress.ts";
|
|
5
6
|
import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
|
|
7
|
+
import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
6
8
|
import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
|
|
9
|
+
import { registerDynamax } from "./src/dynamax.ts";
|
|
10
|
+
import { handleReviewViewerAction } from "./src/review/review-actions.ts";
|
|
11
|
+
import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
|
|
7
12
|
import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
|
|
8
13
|
|
|
9
14
|
/** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
|
|
@@ -38,6 +43,7 @@ function formatPerfLine(perf: WorkflowPerfDetails | undefined): string | undefin
|
|
|
38
43
|
|
|
39
44
|
type DiscoveryModule = typeof import("./src/discovery.ts");
|
|
40
45
|
type EngineModule = typeof import("./src/engine.ts");
|
|
46
|
+
type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
|
|
41
47
|
|
|
42
48
|
async function loadDiscovery(): Promise<DiscoveryModule> {
|
|
43
49
|
return await import("./src/discovery.ts");
|
|
@@ -47,6 +53,10 @@ async function loadEngine(): Promise<EngineModule> {
|
|
|
47
53
|
return await import("./src/engine.ts");
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
|
|
57
|
+
return await import("./src/inline-workflow.ts");
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSink | undefined> {
|
|
51
61
|
const enabled = options.perf ?? process.env.PI_WORKFLOW_PERF === "1";
|
|
52
62
|
if (!enabled) return undefined;
|
|
@@ -54,11 +64,34 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
|
|
|
54
64
|
return createPerfRecorder(true);
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
|
|
68
|
+
|
|
69
|
+
export interface LastWorkflowInspection {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly args: string;
|
|
72
|
+
readonly completedAt: number;
|
|
73
|
+
readonly snapshot: WorkflowProgressSnapshot;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let lastWorkflowInspection: LastWorkflowInspection | undefined;
|
|
77
|
+
|
|
78
|
+
export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
|
|
79
|
+
return lastWorkflowInspection;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openWorkflowInspector(ctx: ExtensionCommandContext, inspection: LastWorkflowInspection): Promise<void> {
|
|
83
|
+
await ctx.ui.custom<void>(
|
|
84
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(() => inspection.snapshot, tui, theme, () => done(undefined)),
|
|
85
|
+
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export interface WorkflowInvocation {
|
|
58
90
|
name: string;
|
|
59
91
|
args: string;
|
|
60
92
|
options: WorkflowRunOptions;
|
|
61
93
|
refreshDiscovery?: boolean;
|
|
94
|
+
authorBrief?: string;
|
|
62
95
|
}
|
|
63
96
|
|
|
64
97
|
export function parseWorkflowInvocation(input: string): WorkflowInvocation {
|
|
@@ -89,6 +122,14 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
|
|
|
89
122
|
options.perf = true;
|
|
90
123
|
continue;
|
|
91
124
|
}
|
|
125
|
+
if (token === "--result-viewer" || token === "--review-viewer") {
|
|
126
|
+
options.resultViewer = "open";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token === "--no-result-viewer" || token === "--no-review-viewer") {
|
|
130
|
+
options.resultViewer = "skip";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
92
133
|
if (token.startsWith("--concurrency=")) {
|
|
93
134
|
options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
|
|
94
135
|
continue;
|
|
@@ -120,22 +161,84 @@ function parseNumericOption(value: string | undefined): number | undefined {
|
|
|
120
161
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
121
162
|
}
|
|
122
163
|
|
|
123
|
-
|
|
164
|
+
function compactInlinePreview(script: string | undefined): string {
|
|
165
|
+
if (!script) return "";
|
|
166
|
+
const compact = script.replace(/\s+/g, " ").trim();
|
|
167
|
+
return compact.length > 60 ? `${compact.slice(0, 57)}…` : compact;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
|
|
171
|
+
return `dynamax author and run a temporary one-shot inline workflow.
|
|
172
|
+
|
|
173
|
+
User brief:
|
|
174
|
+
${brief.trim()}
|
|
175
|
+
|
|
176
|
+
Use the workflow tool with a script argument, not a saved workflow name.
|
|
177
|
+
The script must start with export const meta = { ... } and default-export an async workflow function.
|
|
178
|
+
Use the injected Type object for schemas. Do not import anything or use dynamic import().
|
|
179
|
+
Set thinkingLevel explicitly on each agent() call.
|
|
180
|
+
Do not edit files unless the user explicitly requested edits.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface WorkflowToolRequestParams {
|
|
184
|
+
readonly name?: string;
|
|
185
|
+
readonly script?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type WorkflowToolRequest =
|
|
189
|
+
| { readonly kind: "named"; readonly name: string }
|
|
190
|
+
| { readonly kind: "inline"; readonly script: string }
|
|
191
|
+
| { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
|
|
192
|
+
|
|
193
|
+
export interface WorkflowToolErrorResult {
|
|
194
|
+
readonly content: Array<{ readonly type: "text"; readonly text: string }>;
|
|
195
|
+
readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
|
|
199
|
+
|
|
200
|
+
export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
|
|
201
|
+
const name = params.name?.trim() ?? "";
|
|
202
|
+
const script = params.script?.trim() ?? "";
|
|
203
|
+
const hasName = name.length > 0;
|
|
204
|
+
const hasScript = script.length > 0;
|
|
205
|
+
if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
|
|
206
|
+
return hasName ? { kind: "named", name } : { kind: "inline", script };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
|
|
210
|
+
return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
|
|
214
|
+
return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function pickWorkflow(
|
|
124
218
|
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
125
219
|
ctx: ExtensionCommandContext,
|
|
126
220
|
): Promise<WorkflowInvocation | undefined> {
|
|
127
|
-
const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
|
|
221
|
+
const choices = [AUTHOR_TEMP_WORKFLOW_CHOICE, ...[...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`)];
|
|
128
222
|
const choice = await ctx.ui.select("Run workflow", choices);
|
|
129
223
|
if (!choice) return undefined;
|
|
130
224
|
|
|
225
|
+
if (choice === AUTHOR_TEMP_WORKFLOW_CHOICE) {
|
|
226
|
+
const brief = await ctx.ui.editor(
|
|
227
|
+
"Describe temporary workflow",
|
|
228
|
+
"Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
|
|
229
|
+
);
|
|
230
|
+
const trimmed = brief?.trim();
|
|
231
|
+
if (!trimmed) return undefined;
|
|
232
|
+
return { name: "", args: "", options: {}, authorBrief: trimmed };
|
|
233
|
+
}
|
|
234
|
+
|
|
131
235
|
const separator = choice.indexOf(" — ");
|
|
132
236
|
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
133
237
|
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
134
|
-
|
|
135
|
-
return { name, args, options: { inspect } };
|
|
238
|
+
return { name, args, options: {} };
|
|
136
239
|
}
|
|
137
240
|
|
|
138
|
-
async function sendWorkflowResult(
|
|
241
|
+
export async function sendWorkflowResult(
|
|
139
242
|
pi: ExtensionAPI,
|
|
140
243
|
ctx: ExtensionCommandContext,
|
|
141
244
|
name: string,
|
|
@@ -154,8 +257,24 @@ async function sendWorkflowResult(
|
|
|
154
257
|
perfSnapshot = snapshot;
|
|
155
258
|
options.onPerfSnapshot?.(snapshot);
|
|
156
259
|
},
|
|
260
|
+
onProgressSnapshot(snapshot) {
|
|
261
|
+
lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
262
|
+
options.onProgressSnapshot?.(snapshot);
|
|
263
|
+
},
|
|
157
264
|
});
|
|
158
265
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
266
|
+
const reviewDecision = decideReviewResultsPresentation({
|
|
267
|
+
workflowName: name,
|
|
268
|
+
result,
|
|
269
|
+
mode: extensionContextMode(ctx),
|
|
270
|
+
hasUI: ctx.hasUI,
|
|
271
|
+
resultViewer: options.resultViewer,
|
|
272
|
+
invocationKind: "command",
|
|
273
|
+
});
|
|
274
|
+
const reviewAction = await maybeShowReviewResultsViewer(ctx, reviewDecision);
|
|
275
|
+
if (reviewDecision.kind !== "send") {
|
|
276
|
+
await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
|
|
277
|
+
}
|
|
159
278
|
pi.sendMessage(
|
|
160
279
|
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
161
280
|
{ triggerTurn: false },
|
|
@@ -163,12 +282,35 @@ async function sendWorkflowResult(
|
|
|
163
282
|
}
|
|
164
283
|
|
|
165
284
|
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
285
|
+
registerDynamax(pi);
|
|
286
|
+
|
|
166
287
|
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
167
288
|
const details = message.details;
|
|
168
289
|
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
169
290
|
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
170
291
|
});
|
|
171
292
|
|
|
293
|
+
pi.registerCommand("workflow:inspector", {
|
|
294
|
+
description: "Open the last completed workflow inspector",
|
|
295
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
|
+
const trimmed = args.trim();
|
|
297
|
+
if (trimmed && trimmed !== "last") {
|
|
298
|
+
ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!ctx.hasUI) {
|
|
302
|
+
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const inspection = lastWorkflowInspection;
|
|
306
|
+
if (!inspection) {
|
|
307
|
+
ctx.ui.notify("No completed workflow inspector is available yet", "warning");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await openWorkflowInspector(ctx, inspection);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
172
314
|
// /workflow <name> [args] — user-invoked.
|
|
173
315
|
pi.registerCommand("workflow", {
|
|
174
316
|
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
@@ -185,6 +327,11 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
185
327
|
return;
|
|
186
328
|
}
|
|
187
329
|
|
|
330
|
+
if (invocation.authorBrief) {
|
|
331
|
+
pi.sendUserMessage(buildTemporaryWorkflowAuthorPrompt(invocation.authorBrief));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
188
335
|
const mod = workflows.get(invocation.name);
|
|
189
336
|
if (!mod) {
|
|
190
337
|
ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
|
|
@@ -200,9 +347,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
200
347
|
name: "workflow",
|
|
201
348
|
label: "Workflow",
|
|
202
349
|
description:
|
|
203
|
-
"
|
|
350
|
+
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
|
|
351
|
+
promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
|
|
352
|
+
promptGuidelines: [
|
|
353
|
+
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
354
|
+
"Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
|
|
355
|
+
"Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
|
|
356
|
+
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
357
|
+
"Every workflow tool call must provide exactly one of `name` or `script`, never both.",
|
|
358
|
+
],
|
|
204
359
|
parameters: Type.Object({
|
|
205
|
-
name: Type.String({ description: "Workflow name, e.g. code-review" }),
|
|
360
|
+
name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
|
|
361
|
+
script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
|
|
206
362
|
args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
|
|
207
363
|
concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
|
|
208
364
|
parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
|
|
@@ -210,7 +366,12 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
210
366
|
}),
|
|
211
367
|
renderCall(args, theme) {
|
|
212
368
|
const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
|
|
213
|
-
|
|
369
|
+
if (args.name?.trim()) {
|
|
370
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${suffix}`, 0, 0);
|
|
371
|
+
}
|
|
372
|
+
const preview = compactInlinePreview(args.script);
|
|
373
|
+
const previewSuffix = preview ? ` ${theme.fg("dim", preview)}` : "";
|
|
374
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${suffix}${previewSuffix}`, 0, 0);
|
|
214
375
|
},
|
|
215
376
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
216
377
|
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
@@ -221,6 +382,9 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
221
382
|
return new Text(theme.fg("muted", text), 0, 0);
|
|
222
383
|
},
|
|
223
384
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
385
|
+
const request = normalizeWorkflowToolRequest(params);
|
|
386
|
+
if (request.kind === "error") return invalidWorkflowInvocationResult();
|
|
387
|
+
|
|
224
388
|
const runOptions: WorkflowRunOptions = {
|
|
225
389
|
concurrency: params.concurrency,
|
|
226
390
|
parallelSubmissionLimit: params.parallelSubmissionLimit,
|
|
@@ -228,16 +392,33 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
228
392
|
signal,
|
|
229
393
|
};
|
|
230
394
|
const perfRecorder = await createInvocationPerf(runOptions);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
395
|
+
let mod: WorkflowModule;
|
|
396
|
+
let resultName: string;
|
|
397
|
+
|
|
398
|
+
if (request.kind === "named") {
|
|
399
|
+
const { discoverWorkflows } = await loadDiscovery();
|
|
400
|
+
const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
|
|
401
|
+
const named = workflows.get(request.name);
|
|
402
|
+
if (!named) {
|
|
403
|
+
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
404
|
+
return {
|
|
405
|
+
content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
|
|
406
|
+
details: { error: "unknown_workflow", available },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
mod = named;
|
|
410
|
+
resultName = request.name;
|
|
411
|
+
} else {
|
|
412
|
+
const inline = await loadInlineWorkflow();
|
|
413
|
+
try {
|
|
414
|
+
mod = inline.compileInlineWorkflow(request.script);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
resultName = mod.meta.name;
|
|
240
420
|
}
|
|
421
|
+
|
|
241
422
|
const { runWorkflow } = await loadEngine();
|
|
242
423
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
243
424
|
const result = await runWorkflow(ctx, mod, params.args ?? "", {
|
|
@@ -250,7 +431,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
250
431
|
});
|
|
251
432
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
252
433
|
const perfLine = formatPerfLine(perf);
|
|
253
|
-
return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(
|
|
434
|
+
return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(resultName, result, perf) };
|
|
254
435
|
},
|
|
255
436
|
});
|
|
256
437
|
}
|
|
@@ -2,15 +2,11 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import type {
|
|
5
|
+
import type { WorkflowModule } from "./types.ts";
|
|
6
6
|
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import { parseWorkflowModule } from "./workflow-module.ts";
|
|
7
8
|
import { BUILTIN_WORKFLOW_FILES, BUILTIN_WORKFLOWS } from "./workflows.ts";
|
|
8
9
|
|
|
9
|
-
type WorkflowModuleCandidate = {
|
|
10
|
-
meta?: { name?: unknown; description?: unknown; phases?: unknown };
|
|
11
|
-
default?: unknown;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
10
|
export interface DiscoverWorkflowsOptions {
|
|
15
11
|
readonly refresh?: boolean;
|
|
16
12
|
readonly perf?: PerfSink;
|
|
@@ -19,33 +15,6 @@ export interface DiscoverWorkflowsOptions {
|
|
|
19
15
|
|
|
20
16
|
const discoveryCache = new Map<string, Map<string, WorkflowModule>>();
|
|
21
17
|
|
|
22
|
-
function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
|
|
23
|
-
if (!value || typeof value !== "object") return { reason: "module export is not an object" };
|
|
24
|
-
const candidate = value as WorkflowModuleCandidate;
|
|
25
|
-
const meta = candidate.meta;
|
|
26
|
-
if (!meta || typeof meta !== "object") return { reason: "missing meta export" };
|
|
27
|
-
if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
|
|
28
|
-
|
|
29
|
-
const description = meta.description;
|
|
30
|
-
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
31
|
-
|
|
32
|
-
const phases = meta.phases;
|
|
33
|
-
if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
|
|
34
|
-
if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
|
|
35
|
-
|
|
36
|
-
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
37
|
-
if (phases !== undefined) workflowMeta.phases = phases;
|
|
38
|
-
return { module: { meta: workflowMeta, default: candidate.default } };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
42
|
-
return typeof value === "function";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
|
|
46
|
-
return Array.isArray(value) && value.every((phase) => typeof phase === "object" && phase !== null && typeof (phase as { title?: unknown }).title === "string");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
18
|
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
50
19
|
async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set()): Promise<WorkflowModule[]> {
|
|
51
20
|
let entries: string[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export interface DynamaxState {
|
|
5
|
+
sticky: boolean;
|
|
6
|
+
oneShotPending: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
|
|
10
|
+
|
|
11
|
+
export const DYNAMAX_REMINDER = `
|
|
12
|
+
## dynamax workflow opt-in
|
|
13
|
+
|
|
14
|
+
The user has opted into dynamax multi-agent orchestration. The workflow tool is permitted for this task. You may either run an existing named workflow or author a new inline workflow script when that best serves the user's request.
|
|
15
|
+
|
|
16
|
+
Inline workflow rules:
|
|
17
|
+
- Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
|
|
18
|
+
- Do not use import statements or dynamic import() in inline workflow scripts.
|
|
19
|
+
- Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
|
|
20
|
+
- Provide exactly one of workflow.name or workflow.script.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
|
|
24
|
+
|
|
25
|
+
export function createDynamaxState(): DynamaxState {
|
|
26
|
+
return { sticky: false, oneShotPending: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasDynamaxToken(text: string): boolean {
|
|
30
|
+
return DYNAMAX_TOKEN_PATTERN.test(text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function markDynamaxOneShot(state: DynamaxState): void {
|
|
34
|
+
state.oneShotPending = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
|
|
38
|
+
state.sticky = sticky;
|
|
39
|
+
if (!sticky) state.oneShotPending = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearDynamax(state: DynamaxState): void {
|
|
43
|
+
state.sticky = false;
|
|
44
|
+
state.oneShotPending = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function consumeDynamaxOneShot(state: DynamaxState): boolean {
|
|
48
|
+
const pending = state.oneShotPending;
|
|
49
|
+
state.oneShotPending = false;
|
|
50
|
+
return pending;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isDynamaxActive(state: DynamaxState): boolean {
|
|
54
|
+
return state.sticky || state.oneShotPending;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
|
|
58
|
+
if (!isDynamaxActive(state)) return systemPrompt;
|
|
59
|
+
state.oneShotPending = false;
|
|
60
|
+
return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function appendDynamaxContextReminder(messages: AgentMessage[], state: DynamaxState): AgentMessage[] {
|
|
64
|
+
if (!state.sticky) return messages;
|
|
65
|
+
return [...messages, createDynamaxContextMessage()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function registerDynamax(pi: ExtensionAPI): void {
|
|
69
|
+
const state = createDynamaxState();
|
|
70
|
+
|
|
71
|
+
pi.on("input", (event) => {
|
|
72
|
+
if (event.source === "extension") return { action: "continue" };
|
|
73
|
+
if (hasDynamaxToken(event.text)) markDynamaxOneShot(state);
|
|
74
|
+
return { action: "continue" };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.on("before_agent_start", (event) => {
|
|
78
|
+
const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, state);
|
|
79
|
+
if (systemPrompt === event.systemPrompt) return undefined;
|
|
80
|
+
return { systemPrompt };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.on("context", (event) => {
|
|
84
|
+
const messages = appendDynamaxContextReminder(event.messages, state);
|
|
85
|
+
if (messages === event.messages) return undefined;
|
|
86
|
+
return { messages };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
pi.registerCommand("workflow:dynamax", {
|
|
90
|
+
description: "Toggle dynamax workflow orchestration opt-in: /workflow:dynamax on|off|status",
|
|
91
|
+
handler: async (args, ctx) => {
|
|
92
|
+
const action = args.trim().toLowerCase();
|
|
93
|
+
if (action === "on") {
|
|
94
|
+
setDynamaxSticky(state, true);
|
|
95
|
+
ctx.ui.notify("dynamax workflow orchestration is on for this session", "info");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (action === "off") {
|
|
99
|
+
clearDynamax(state);
|
|
100
|
+
ctx.ui.notify("dynamax workflow orchestration is off", "info");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (action === "status") {
|
|
104
|
+
const sticky = state.sticky ? "on" : "off";
|
|
105
|
+
const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
|
|
106
|
+
ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
ctx.ui.notify("Usage: /workflow:dynamax on|off|status", "warning");
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createDynamaxContextMessage(): AgentMessage {
|
|
115
|
+
return {
|
|
116
|
+
role: "custom",
|
|
117
|
+
customType: DYNAMAX_CONTEXT_CUSTOM_TYPE,
|
|
118
|
+
content: DYNAMAX_REMINDER.trim(),
|
|
119
|
+
display: false,
|
|
120
|
+
details: { dynamax: true, sticky: true },
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -68,14 +68,18 @@ export async function runWorkflow(
|
|
|
68
68
|
try {
|
|
69
69
|
return await perf.time("workflow.total_ms", () => mod.default(api));
|
|
70
70
|
} finally {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
resolvedOptions.
|
|
74
|
-
|
|
71
|
+
try {
|
|
72
|
+
const snapshot = perf.snapshot();
|
|
73
|
+
if (resolvedOptions.perf) {
|
|
74
|
+
resolvedOptions.onPerfSnapshot?.(snapshot);
|
|
75
|
+
progress.log(formatPerfSummary(snapshot));
|
|
76
|
+
}
|
|
77
|
+
progress.done();
|
|
78
|
+
resolvedOptions.onProgressSnapshot?.(progress.snapshot());
|
|
79
|
+
} finally {
|
|
80
|
+
unlinkContextAbortSignal();
|
|
81
|
+
unlinkOptionAbortSignal();
|
|
75
82
|
}
|
|
76
|
-
unlinkContextAbortSignal();
|
|
77
|
-
unlinkOptionAbortSignal();
|
|
78
|
-
progress.done();
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|