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.
Files changed (23) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -18
  2. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +2 -33
  3. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  4. package/.pi/extensions/pi-workflow-engine/src/engine.ts +11 -7
  5. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  6. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +185 -0
  7. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
  8. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
  9. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
  10. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
  11. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
  12. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
  13. package/.pi/extensions/pi-workflow-engine/src/types.ts +4 -0
  14. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
  16. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -3
  18. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
  19. package/README.md +61 -12
  20. package/USAGE.md +189 -0
  21. package/package.json +4 -1
  22. package/skills/workflow-code-review-actions/SKILL.md +44 -0
  23. 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
- async function pickWorkflow(
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
- const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
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
- "Run a named multi-agent workflow (fan-out → verify → synthesize) and return its structured result. Use for thorough reviews or audits.",
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
- return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
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
- const { discoverWorkflows } = await loadDiscovery();
232
- const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
233
- const mod = workflows.get(params.name);
234
- if (!mod) {
235
- const available = [...workflows.keys()].join(", ") || "(none)";
236
- return {
237
- content: [{ type: "text", text: `Unknown workflow "${params.name}". Available: ${available}` }],
238
- details: { error: "unknown_workflow", available },
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(params.name, result, perf) };
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 { WorkflowMeta, WorkflowModule } from "./types.ts";
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
- const snapshot = perf.snapshot();
72
- if (resolvedOptions.perf) {
73
- resolvedOptions.onPerfSnapshot?.(snapshot);
74
- progress.log(formatPerfSummary(snapshot));
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