pi-workflow-engine 0.3.0 → 0.5.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.
Files changed (39) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +369 -0
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  3. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  4. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  5. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  6. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +83 -0
  7. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  10. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  11. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  15. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  16. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
  17. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  19. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  20. package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +8 -2
  21. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
  23. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
  24. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
  25. package/.pi/settings.json +3 -0
  26. package/README.md +101 -27
  27. package/USAGE.md +183 -0
  28. package/assets/preview-candidate-core.png +0 -0
  29. package/assets/preview-candidate-relic-emblem.png +0 -0
  30. package/assets/preview-candidate-robot-squad.png +0 -0
  31. package/assets/preview.png +0 -0
  32. package/package.json +33 -6
  33. package/src/agent-runner.ts +0 -122
  34. package/src/concurrency.ts +0 -55
  35. package/src/discovery.ts +0 -54
  36. package/src/index.ts +0 -141
  37. package/src/ui/workflow-widget.ts +0 -143
  38. package/workflows/ping.ts +0 -28
  39. /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
@@ -0,0 +1,171 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { abortReason, throwIfAborted } from "./cancellation.ts";
3
+
4
+ /**
5
+ * A counting semaphore: bounds how many async tasks run at once.
6
+ *
7
+ * This is the single global concurrency cap for a workflow run. Every `agent()`
8
+ * call acquires a slot before spawning a session and releases it when done, so the
9
+ * cap holds no matter how `parallel`/`pipeline` are nested — exactly like the
10
+ * built-in Workflow tool, where "concurrent agent() calls are capped and excess queue".
11
+ */
12
+ export class Semaphore {
13
+ private active = 0;
14
+ private readonly waiters: Array<() => void> = [];
15
+
16
+ constructor(private readonly max: number) {}
17
+
18
+ async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
19
+ throwIfAborted(options.signal);
20
+ const queuedAt = performance.now();
21
+ if (this.active >= this.max) {
22
+ await this.waitForSlot(options.signal);
23
+ }
24
+ options.onQueueWaitMs?.(performance.now() - queuedAt);
25
+ throwIfAborted(options.signal);
26
+ this.active++;
27
+ try {
28
+ return await fn();
29
+ } finally {
30
+ this.active--;
31
+ this.waiters.shift()?.();
32
+ }
33
+ }
34
+
35
+ private async waitForSlot(signal: AbortSignal | undefined): Promise<void> {
36
+ await new Promise<void>((resolve, reject) => {
37
+ let settled = false;
38
+ const cleanup = () => {
39
+ if (signal) signal.removeEventListener("abort", onAbort);
40
+ };
41
+ const waiter = () => {
42
+ if (settled) return;
43
+ settled = true;
44
+ cleanup();
45
+ resolve();
46
+ };
47
+ const onAbort = () => {
48
+ if (settled) return;
49
+ settled = true;
50
+ const index = this.waiters.indexOf(waiter);
51
+ if (index !== -1) this.waiters.splice(index, 1);
52
+ cleanup();
53
+ reject(abortReason(signal));
54
+ };
55
+ if (signal?.aborted) {
56
+ reject(abortReason(signal));
57
+ return;
58
+ }
59
+ this.waiters.push(waiter);
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ });
62
+ }
63
+ }
64
+
65
+ export interface ParallelOptions {
66
+ readonly signal?: AbortSignal;
67
+ readonly abortController?: AbortController;
68
+ readonly limit?: number;
69
+ }
70
+
71
+ /** Run every thunk concurrently and wait for all results (a barrier). */
72
+ export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<T[]> {
73
+ throwIfAborted(options.signal);
74
+ const results = new Array<T>(thunks.length);
75
+ let remaining = thunks.length;
76
+ if (remaining === 0) return results;
77
+
78
+ const limit = normalizeLimit(options.limit, thunks.length);
79
+ return await new Promise<T[]>((resolve, reject) => {
80
+ let rejected = false;
81
+ let active = 0;
82
+ let next = 0;
83
+ const cleanup = () => options.signal?.removeEventListener("abort", onAbort);
84
+ const rejectOnce = (error: unknown) => {
85
+ if (rejected) return;
86
+ rejected = true;
87
+ options.abortController?.abort(error);
88
+ cleanup();
89
+ reject(error);
90
+ };
91
+ const onAbort = () => rejectOnce(abortReason(options.signal));
92
+ const launchMore = () => {
93
+ while (!rejected && active < limit && next < thunks.length) {
94
+ const index = next++;
95
+ active++;
96
+ void Promise.resolve()
97
+ .then(() => {
98
+ throwIfAborted(options.signal);
99
+ return thunks[index]();
100
+ })
101
+ .then(
102
+ (value) => {
103
+ if (rejected) return;
104
+ active--;
105
+ results[index] = value;
106
+ remaining--;
107
+ if (remaining === 0) {
108
+ cleanup();
109
+ resolve(results);
110
+ return;
111
+ }
112
+ launchMore();
113
+ },
114
+ (error: unknown) => rejectOnce(error),
115
+ );
116
+ }
117
+ };
118
+
119
+ options.signal?.addEventListener("abort", onAbort, { once: true });
120
+ launchMore();
121
+ });
122
+ }
123
+
124
+ function normalizeLimit(limit: number | undefined, itemCount: number): number {
125
+ if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
126
+ return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
127
+ }
128
+
129
+ /**
130
+ * Run each item through all stages independently. There is NO barrier between
131
+ * stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
132
+ * concurrency is bounded by the run's Semaphore (inside each `agent()`), not here —
133
+ * so launching every item at once is safe.
134
+ *
135
+ * Each stage receives (previousResult, originalItem, index).
136
+ */
137
+ export async function pipeline<Item, A>(
138
+ items: readonly Item[],
139
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
140
+ ): Promise<A[]>;
141
+ export async function pipeline<Item, A, B>(
142
+ items: readonly Item[],
143
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
144
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
145
+ ): Promise<B[]>;
146
+ export async function pipeline<Item, A, B, C>(
147
+ items: readonly Item[],
148
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
149
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
150
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
151
+ ): Promise<C[]>;
152
+ export async function pipeline(
153
+ items: readonly unknown[],
154
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
155
+ ): Promise<unknown[]>;
156
+ export async function pipeline(
157
+ items: readonly unknown[],
158
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
159
+ ): Promise<unknown[]> {
160
+ return Promise.all(
161
+ items.map(async (item, index) => {
162
+ let acc: unknown = item;
163
+ for (const stage of stages) {
164
+ acc = await stage(acc, item, index);
165
+ }
166
+ return acc;
167
+ }),
168
+ );
169
+ }
170
+
171
+ export type Pipeline = typeof pipeline;
@@ -0,0 +1,152 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+ import type { PerfSink } from "./perf.ts";
4
+
5
+ export interface AllowedDiffCommand {
6
+ readonly file: "git" | "gh";
7
+ readonly args: readonly string[];
8
+ }
9
+
10
+ export interface DiffCaptureResult {
11
+ readonly ok: boolean;
12
+ readonly stdout: string;
13
+ readonly durationMs: number;
14
+ readonly bytes: number;
15
+ readonly error?: string;
16
+ }
17
+
18
+ export interface DiffCaptureOptions {
19
+ readonly cwd: string;
20
+ readonly signal?: AbortSignal;
21
+ readonly timeoutMs: number;
22
+ readonly maxBufferBytes: number;
23
+ readonly perf?: PerfSink;
24
+ readonly env?: NodeJS.ProcessEnv;
25
+ readonly killGraceMs?: number;
26
+ }
27
+
28
+ const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
29
+ const SAFE_GH_FLAG = /^--[A-Za-z0-9-]+(=[A-Za-z0-9_./:@~+=,\-]+)?$/;
30
+ const SAFE_GIT_DIFF_FLAGS = new Set(["--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
31
+
32
+ export function parseAllowedDiffCommand(command: string): AllowedDiffCommand | { error: string } {
33
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
34
+ if (tokens.length < 2) return { error: "empty or incomplete diff command" };
35
+ if (tokens[0] === "git" && tokens[1] === "diff") return parseGitDiff(tokens);
36
+ if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
37
+ const flags = tokens.slice(4);
38
+ const unsafe = flags.find((token) => !SAFE_GH_FLAG.test(token));
39
+ if (unsafe) return { error: `unsupported gh pr diff token: ${unsafe}` };
40
+ return { file: "gh", args: ["pr", "diff", tokens[3], ...flags] };
41
+ }
42
+ return { error: "diff command is not in the git/gh allowlist" };
43
+ }
44
+
45
+ function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error: string } {
46
+ const args = tokens.slice(2);
47
+ const safeArgs = ["diff", "--no-ext-diff"];
48
+ let pathMode = false;
49
+
50
+ for (const token of args) {
51
+ if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
52
+ if (token === "--") {
53
+ pathMode = true;
54
+ safeArgs.push(token);
55
+ continue;
56
+ }
57
+ if (!pathMode && token.startsWith("-")) {
58
+ if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
59
+ if (token !== "--no-ext-diff") safeArgs.push(token);
60
+ continue;
61
+ }
62
+ safeArgs.push(token);
63
+ }
64
+
65
+ return { file: "git", args: safeArgs };
66
+ }
67
+
68
+ function isAllowedGitDiffFlag(token: string): boolean {
69
+ return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
70
+ }
71
+
72
+ export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
73
+ const parsed = parseAllowedDiffCommand(command);
74
+ if ("error" in parsed) {
75
+ return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
76
+ }
77
+
78
+ const start = performance.now();
79
+ let stdout = "";
80
+ let stderr = "";
81
+ let bytes = 0;
82
+ let error: string | undefined;
83
+ const child = spawn(parsed.file, [...parsed.args], {
84
+ cwd: options.cwd,
85
+ env: diffCaptureEnv(options.env),
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+
89
+ return await new Promise<DiffCaptureResult>((resolve) => {
90
+ let settled = false;
91
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
92
+ const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
93
+ const finish = (ok: boolean, finishError?: string) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ cleanup();
97
+ const durationMs = performance.now() - start;
98
+ options.perf?.observe("diff.capture_ms", durationMs);
99
+ options.perf?.observe("diff.bytes", bytes);
100
+ resolve({ ok, stdout, durationMs, bytes, error: finishError });
101
+ };
102
+ const kill = (message: string) => {
103
+ error = error ?? message;
104
+ child.kill("SIGTERM");
105
+ forceKillTimer ??= setTimeout(() => {
106
+ child.kill("SIGKILL");
107
+ finish(false, error);
108
+ }, killGraceMs);
109
+ };
110
+ const onAbort = () => kill("diff capture aborted");
111
+ const timeout = setTimeout(() => kill(`diff capture timed out after ${options.timeoutMs}ms`), options.timeoutMs);
112
+ const cleanup = () => {
113
+ clearTimeout(timeout);
114
+ if (forceKillTimer) clearTimeout(forceKillTimer);
115
+ options.signal?.removeEventListener("abort", onAbort);
116
+ };
117
+
118
+ if (options.signal?.aborted) {
119
+ kill("diff capture aborted");
120
+ } else {
121
+ options.signal?.addEventListener("abort", onAbort, { once: true });
122
+ }
123
+
124
+ child.stdout?.on("data", (chunk: Buffer) => {
125
+ bytes += chunk.length;
126
+ if (bytes > options.maxBufferBytes) {
127
+ kill(`diff capture exceeded ${options.maxBufferBytes} bytes`);
128
+ return;
129
+ }
130
+ stdout += chunk.toString("utf8");
131
+ });
132
+ child.stderr?.on("data", (chunk: Buffer) => {
133
+ stderr += chunk.toString("utf8");
134
+ });
135
+ child.on("error", (spawnError) => finish(false, spawnError.message));
136
+ child.on("close", (code, signal) => {
137
+ if (error) {
138
+ finish(false, error);
139
+ return;
140
+ }
141
+ if (code === 0) {
142
+ finish(true);
143
+ return;
144
+ }
145
+ finish(false, stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`);
146
+ });
147
+ });
148
+ }
149
+
150
+ function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
151
+ return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
152
+ }
@@ -0,0 +1,83 @@
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 type { PerfSink } from "./perf.ts";
7
+ import { parseWorkflowModule } from "./workflow-module.ts";
8
+ import { BUILTIN_WORKFLOW_FILES, BUILTIN_WORKFLOWS } from "./workflows.ts";
9
+
10
+ export interface DiscoverWorkflowsOptions {
11
+ readonly refresh?: boolean;
12
+ readonly perf?: PerfSink;
13
+ readonly userWorkflowDir?: string;
14
+ }
15
+
16
+ const discoveryCache = new Map<string, Map<string, WorkflowModule>>();
17
+
18
+ /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
19
+ async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set()): Promise<WorkflowModule[]> {
20
+ let entries: string[];
21
+ try {
22
+ entries = await readdir(dir);
23
+ } catch {
24
+ return [];
25
+ }
26
+
27
+ const modules: WorkflowModule[] = [];
28
+ for (const name of entries) {
29
+ if (excludeFiles.has(name)) continue;
30
+ if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
31
+ try {
32
+ const loaded: unknown = await import(pathToFileURL(join(dir, name)).href);
33
+ const parsed = parseWorkflowModule(loaded);
34
+ if ("module" in parsed) modules.push(parsed.module);
35
+ else console.error(`[workflow-engine] skipped ${name}: ${parsed.reason}`);
36
+ } catch (error) {
37
+ // Drop-in loading depends on the runtime resolving TS + the bundled typebox.
38
+ // Failures here are non-fatal: the static registry still works.
39
+ console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ }
42
+ return modules;
43
+ }
44
+
45
+ /**
46
+ * All available workflows by name. Static registry wins on name collisions, so the
47
+ * bundled example is always the verified one even if a same-named file is dropped in.
48
+ */
49
+ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, WorkflowModule>> {
50
+ const userWorkflowDir = options.userWorkflowDir ?? join(homedir(), ".pi", "agent", "workflows");
51
+ const cacheKey = `${repoDir}\0${userWorkflowDir}`;
52
+ const cached = discoveryCache.get(cacheKey);
53
+ if (cached && !options.refresh) {
54
+ options.perf?.counter("discovery.cache_hit");
55
+ return new Map(cached);
56
+ }
57
+
58
+ const byName = await timed(options.perf, "discovery.total_ms", async () => {
59
+ const next = new Map<string, WorkflowModule>();
60
+ for (const mod of BUILTIN_WORKFLOWS) next.set(mod.meta.name, mod);
61
+
62
+ const repoWorkflowDir = join(repoDir, "workflows");
63
+ const [repoDynamic, userDynamic] = await Promise.all([
64
+ timed(options.perf, "discovery.repo_dir_ms", () => loadDir(repoWorkflowDir, BUILTIN_WORKFLOW_FILES)),
65
+ timed(options.perf, "discovery.user_dir_ms", () => loadDir(userWorkflowDir)),
66
+ ]);
67
+
68
+ for (const mod of repoDynamic) {
69
+ if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
70
+ }
71
+ for (const mod of userDynamic) {
72
+ if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
73
+ }
74
+ return next;
75
+ });
76
+
77
+ discoveryCache.set(cacheKey, byName);
78
+ return new Map(byName);
79
+ }
80
+
81
+ async function timed<T>(perf: PerfSink | undefined, name: string, fn: () => Promise<T>): Promise<T> {
82
+ return perf ? await perf.time(name, fn) : await fn();
83
+ }
@@ -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("dynamax", {
90
+ description: "Toggle dynamax workflow orchestration opt-in: /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: /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
+ }
@@ -1,13 +1,15 @@
1
- import { cpus } from "node:os";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import { parallel, pipeline, Semaphore } from "./concurrency.ts";
3
+ import { linkAbortSignal } from "./cancellation.ts";
4
4
  import { runAgent, type RunContext } from "./agent-runner.ts";
5
5
  import { ProgressTracker } from "./progress.ts";
6
+ import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
7
+ import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
6
8
  import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
7
9
  import { WorkflowInspector } from "./ui/workflow-inspector.ts";
8
10
 
9
11
  /** Default global cap on concurrent agents per run. */
10
- const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
12
+ const DEFAULT_CONCURRENCY = defaultConcurrency();
11
13
 
12
14
  /**
13
15
  * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
@@ -19,8 +21,9 @@ export async function runWorkflow(
19
21
  args: string,
20
22
  options: WorkflowRunOptions = {},
21
23
  ): Promise<unknown> {
24
+ const resolvedOptions = resolveWorkflowRunOptions(options);
22
25
  const progress = new ProgressTracker(ctx, mod.meta.name);
23
- if (options.inspect && ctx.hasUI) {
26
+ if (resolvedOptions.inspect && ctx.hasUI) {
24
27
  void ctx.ui
25
28
  .custom<void>(
26
29
  (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
@@ -29,32 +32,57 @@ export async function runWorkflow(
29
32
  .catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
30
33
  }
31
34
 
35
+ const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
36
+ const runAbortController = new AbortController();
37
+ const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
38
+ const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
32
39
  const rc: RunContext = {
33
40
  cwd: ctx.cwd,
34
41
  hostModel: ctx.model,
35
42
  modelRegistry: ctx.modelRegistry,
36
- semaphore: new Semaphore(DEFAULT_CONCURRENCY),
43
+ semaphore: new Semaphore(resolvedOptions.concurrency ?? DEFAULT_CONCURRENCY),
37
44
  progress,
38
- signal: ctx.signal,
45
+ signal: runAbortController.signal,
46
+ perf,
39
47
  };
40
48
 
41
49
  const agent = ((prompt: string, opts?: AgentOptions) => runAgent(rc, prompt, opts)) as WorkflowApi["agent"];
42
50
 
43
51
  const api: WorkflowApi = {
44
52
  agent,
45
- parallel,
53
+ parallel: (thunks) =>
54
+ parallel(thunks, {
55
+ signal: runAbortController.signal,
56
+ abortController: runAbortController,
57
+ limit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
58
+ }),
46
59
  pipeline,
47
60
  phase: (title) => progress.phase(title),
48
61
  log: (message) => progress.log(message),
49
62
  progress: (event) => progress.event(event),
50
63
  args,
51
64
  cwd: ctx.cwd,
52
- signal: ctx.signal,
65
+ signal: runAbortController.signal,
53
66
  };
54
67
 
55
68
  try {
56
- return await mod.default(api);
69
+ return await perf.time("workflow.total_ms", () => mod.default(api));
57
70
  } finally {
71
+ const snapshot = perf.snapshot();
72
+ if (resolvedOptions.perf) {
73
+ resolvedOptions.onPerfSnapshot?.(snapshot);
74
+ progress.log(formatPerfSummary(snapshot));
75
+ }
76
+ unlinkContextAbortSignal();
77
+ unlinkOptionAbortSignal();
58
78
  progress.done();
59
79
  }
60
80
  }
81
+
82
+ function formatPerfSummary(snapshot: PerfSnapshot): string {
83
+ const parts = snapshot.aggregates
84
+ .filter((aggregate) => aggregate.count > 0)
85
+ .slice(0, 5)
86
+ .map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
87
+ return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
88
+ }