pi-workflow-engine 0.11.0 → 0.12.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 (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. package/package.json +1 -1
@@ -78,6 +78,8 @@ export interface ParallelSettledOptions {
78
78
  export interface ParallelSettledError {
79
79
  readonly name?: string;
80
80
  readonly message: string;
81
+ readonly code?: string;
82
+ readonly details?: Readonly<Record<string, string | number | boolean | null>>;
81
83
  }
82
84
 
83
85
  export type ParallelSettledResult<T> =
@@ -229,14 +231,48 @@ async function runParallel<T, Result>(
229
231
  function serializeParallelError(error: unknown): ParallelSettledError {
230
232
  const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
231
233
  const name = readStringProperty(error, "name");
232
- return name && name.length > 0 ? { name, message } : { message };
234
+ const code = readStringProperty(error, "code");
235
+ const details = readSerializableDetails(error);
236
+ return {
237
+ ...(name && name.length > 0 ? { name } : {}),
238
+ message,
239
+ ...(code && code.length > 0 ? { code } : {}),
240
+ ...(details ? { details } : {}),
241
+ };
242
+ }
243
+
244
+ function readStringProperty(value: unknown, property: "message" | "name" | "code"): string | undefined {
245
+ const candidate = readProperty(value, property);
246
+ return typeof candidate === "string" ? candidate : undefined;
247
+ }
248
+
249
+ function readSerializableDetails(
250
+ value: unknown,
251
+ ): Readonly<Record<string, string | number | boolean | null>> | undefined {
252
+ const candidate = readProperty(value, "details");
253
+ if (typeof candidate !== "object" || candidate === null) return undefined;
254
+ try {
255
+ const details: Record<string, string | number | boolean | null> = {};
256
+ for (const [key, detail] of Object.entries(candidate)) {
257
+ if (
258
+ typeof detail === "string" ||
259
+ typeof detail === "boolean" ||
260
+ detail === null ||
261
+ (typeof detail === "number" && Number.isFinite(detail))
262
+ ) {
263
+ details[key] = detail;
264
+ }
265
+ }
266
+ return Object.keys(details).length > 0 ? details : undefined;
267
+ } catch {
268
+ return undefined;
269
+ }
233
270
  }
234
271
 
235
- function readStringProperty(value: unknown, property: "message" | "name"): string | undefined {
272
+ function readProperty(value: unknown, property: string): unknown {
236
273
  if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
237
274
  try {
238
- const candidate = Reflect.get(value, property);
239
- return typeof candidate === "string" ? candidate : undefined;
275
+ return Reflect.get(value, property);
240
276
  } catch {
241
277
  return undefined;
242
278
  }
@@ -275,6 +311,21 @@ export async function pipeline<Item, A, B, C>(
275
311
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
276
312
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
277
313
  ): Promise<Array<C | null>>;
314
+ export async function pipeline<Item, A, B, C, D>(
315
+ items: readonly Item[],
316
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
317
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
318
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
319
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
320
+ ): Promise<Array<D | null>>;
321
+ export async function pipeline<Item, A, B, C, D, E>(
322
+ items: readonly Item[],
323
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
324
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
325
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
326
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
327
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
328
+ ): Promise<Array<E | null>>;
278
329
  export async function pipeline(
279
330
  items: readonly unknown[],
280
331
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -302,6 +353,21 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
302
353
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
303
354
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
304
355
  ): Promise<Array<C | null>>;
356
+ async function boundPipeline<Item, A, B, C, D>(
357
+ items: readonly Item[],
358
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
359
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
360
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
361
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
362
+ ): Promise<Array<D | null>>;
363
+ async function boundPipeline<Item, A, B, C, D, E>(
364
+ items: readonly Item[],
365
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
366
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
367
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
368
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
369
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
370
+ ): Promise<Array<E | null>>;
305
371
  async function boundPipeline(
306
372
  items: readonly unknown[],
307
373
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -1,30 +1,21 @@
1
- import { Type, type Static } from "typebox";
2
- import { Value } from "typebox/value";
3
1
  import type { PerfSink } from "./perf.ts";
4
2
  import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
5
-
6
- export type GitDiffBaselineTarget =
7
- | { readonly kind: "working-tree" }
8
- | { readonly kind: "index" }
9
- | { readonly kind: "range"; readonly ref: string };
10
-
11
- export const GitReviewDiffTargetSchema = Type.Object({
12
- kind: Type.Literal("git"),
13
- args: Type.Array(Type.String()),
14
- });
15
-
16
- export const PullRequestReviewDiffTargetSchema = Type.Object({
17
- kind: Type.Literal("pull-request"),
18
- number: Type.Integer({ minimum: 1 }),
19
- });
20
-
21
- export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
22
-
23
- export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
24
- export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
25
-
26
- /** Serializable semantic identity of the diff being reviewed. */
27
- export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
3
+ import { parseAllowedDiffCommand, reviewDiffCommand, type ReviewDiffTarget } from "./review-diff-target.ts";
4
+
5
+ export {
6
+ formatReviewDiffTarget,
7
+ GitReviewDiffTargetSchema,
8
+ isReviewDiffTarget,
9
+ parseAllowedDiffCommand,
10
+ PullRequestReviewDiffTargetSchema,
11
+ reviewDiffCommand,
12
+ ReviewDiffTargetSchema,
13
+ reviewGitDiffBaseline,
14
+ type GitDiffBaselineTarget,
15
+ type GitReviewDiffTarget,
16
+ type PullRequestReviewDiffTarget,
17
+ type ReviewDiffTarget,
18
+ } from "./review-diff-target.ts";
28
19
 
29
20
  interface DiffCaptureResultBase {
30
21
  readonly stdout: string;
@@ -48,78 +39,6 @@ export interface DiffCaptureOptions {
48
39
  readonly killGraceMs?: number;
49
40
  }
50
41
 
51
- const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
52
- const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
53
- const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
54
-
55
- export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
56
- const tokens = command.trim().split(/\s+/).filter(Boolean);
57
- if (tokens.length < 2) return { error: "empty or incomplete diff command" };
58
- if (tokens[0] === "git" && tokens[1] === "diff") {
59
- const parsed = parseGitDiff(tokens);
60
- return "error" in parsed ? parsed : parsed.target;
61
- }
62
- if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
63
- const number = Number(tokens[3]);
64
- if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
65
- const flags = tokens.slice(4);
66
- if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
67
- const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
68
- if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
69
- return { kind: "pull-request", number };
70
- }
71
- return { error: "diff command is not in the git/gh allowlist" };
72
- }
73
-
74
- function parseGitDiff(
75
- tokens: readonly string[],
76
- ): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
77
- const args = tokens.slice(2);
78
- const safeArgs = ["diff", "--no-ext-diff"];
79
- const operands: string[] = [];
80
- let staged = false;
81
- let pathMode = false;
82
-
83
- for (const token of args) {
84
- if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
85
- if (token === "--") {
86
- pathMode = true;
87
- safeArgs.push(token);
88
- continue;
89
- }
90
- if (!pathMode && token.startsWith("-")) {
91
- if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
92
- staged ||= token === "--cached" || token === "--staged";
93
- if (token !== "--no-ext-diff") safeArgs.push(token);
94
- continue;
95
- }
96
- if (!pathMode) operands.push(token);
97
- safeArgs.push(token);
98
- }
99
-
100
- const baseline = classifyGitDiffBaseline(staged, operands);
101
- return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
102
- }
103
-
104
- function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
105
- if (operands.length > 1) {
106
- return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
107
- }
108
- if (staged) return { kind: "index" };
109
-
110
- const range = operands.find((operand) => operand.includes(".."));
111
- if (range) {
112
- const separator = range.includes("...") ? "..." : "..";
113
- return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
114
- }
115
-
116
- return { kind: "working-tree" };
117
- }
118
-
119
- function isAllowedGitDiffFlag(token: string): boolean {
120
- return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
121
- }
122
-
123
42
  export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
124
43
  const parsed = parseAllowedDiffCommand(command);
125
44
  if ("error" in parsed) {
@@ -157,39 +76,6 @@ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffC
157
76
  return result;
158
77
  }
159
78
 
160
- export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
161
- return target.kind === "pull-request"
162
- ? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
163
- : { file: "git", args: target.args };
164
- }
165
-
166
- /** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
167
- export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
168
- const command = reviewDiffCommand(target);
169
- return [command.file, ...command.args].join(" ");
170
- }
171
-
172
- /** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
173
- export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
174
- const parsed = parseGitDiff(["git", ...target.args]);
175
- if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
176
- return parsed.baseline;
177
- }
178
-
179
- /** Validate persisted targets by round-tripping through the single command allowlist parser. */
180
- export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
181
- if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
182
- const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
183
- return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
184
- }
185
-
186
- function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
187
- if (left.kind !== right.kind) return false;
188
- if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
189
- if (right.kind !== "git" || left.args.length !== right.args.length) return false;
190
- return left.args.every((arg, index) => arg === right.args[index]);
191
- }
192
-
193
79
  function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
194
80
  return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
195
81
  }
@@ -1,13 +1,14 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
3
  import { pathToFileURL } from "node:url";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
7
  import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
8
8
  import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
9
9
  import { captureTreeFingerprint } from "./tree-fingerprint.ts";
10
10
  import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
11
+ import { unknownErrorMessage } from "./unknown-error.ts";
11
12
 
12
13
  export interface DiscoverWorkflowsOptions {
13
14
  readonly refresh?: boolean;
@@ -15,6 +16,9 @@ export interface DiscoverWorkflowsOptions {
15
16
  readonly userWorkflowDir?: string;
16
17
  }
17
18
 
19
+ const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
20
+ const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
21
+
18
22
  const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
19
23
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
20
24
  async function loadDir(
@@ -46,7 +50,7 @@ async function loadDir(
46
50
  } catch (error) {
47
51
  // Drop-in loading depends on the runtime resolving TS + the bundled typebox.
48
52
  // Failures here are non-fatal: the static registry still works.
49
- console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
53
+ console.error(`[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`);
50
54
  }
51
55
  }
52
56
  return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
@@ -56,8 +60,8 @@ async function captureDiscoveryFingerprint(root: string) {
56
60
  return await captureTreeFingerprint({
57
61
  root,
58
62
  excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
59
- maxBytes: 32 << 20,
60
- maxFiles: 4096,
63
+ maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
64
+ maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
61
65
  });
62
66
  }
63
67
 
@@ -66,7 +70,7 @@ async function captureDiscoveryFingerprint(root: string) {
66
70
  * bundled example is always the verified one even if a same-named file is dropped in.
67
71
  */
68
72
  export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
69
- const userWorkflowDir = options.userWorkflowDir ?? join(homedir(), ".pi", "agent", "workflows");
73
+ const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
70
74
  const cacheKey = `${repoDir}\0${userWorkflowDir}`;
71
75
  const cached = discoveryCache.get(cacheKey);
72
76
  if (cached && !options.refresh) {
@@ -1,7 +1,17 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { KeyId } from "@earendil-works/pi-tui";
2
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { EditorComponent, KeyId } from "@earendil-works/pi-tui";
4
+ import { completeCurrentArgument } from "./command-completions.ts";
4
5
  import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
6
+ import { sessionKey } from "./session-identity.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+ import {
9
+ decorateDynamaxEditor,
10
+ resolveDynamaxEffect,
11
+ type DynamaxAnimationScheduler,
12
+ type DynamaxEditorDecoration,
13
+ type DynamaxEffect,
14
+ } from "./ui/dynamax-editor-decoration.ts";
5
15
 
6
16
  export interface DynamaxState {
7
17
  sticky: boolean;
@@ -15,13 +25,34 @@ export interface DynamaxRuntime {
15
25
  }
16
26
 
17
27
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
28
+
18
29
  export interface DynamaxRegistrationOptions {
19
30
  openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
31
+ effect?: DynamaxEffect;
32
+ animationScheduler?: DynamaxAnimationScheduler;
20
33
  }
21
34
 
22
35
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
23
36
  export const DYNAMAX_STATUS_KEY = "dynamax";
24
- export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
37
+
38
+ type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
39
+
40
+ interface DynamaxEditorInstallation {
41
+ factory: EditorFactory;
42
+ previousFactory: EditorFactory | undefined;
43
+ decorations: Set<DynamaxEditorDecoration>;
44
+ }
45
+
46
+ function disposeDynamaxDecorations(decorations: Set<DynamaxEditorDecoration>): void {
47
+ for (const decoration of decorations) decoration.dispose();
48
+ decorations.clear();
49
+ }
50
+
51
+ const DYNAMAX_ACTION_COMPLETIONS = [
52
+ { value: "on", description: "Keep Dynamax enabled for this session" },
53
+ { value: "off", description: "Disable sticky and pending Dynamax modes" },
54
+ { value: "status", description: "Show the current Dynamax state" },
55
+ ] as const;
25
56
 
26
57
  export const ADAPTIVE_WORKFLOW_GUIDANCE = `
27
58
  Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out when it is sufficient. When the first pass may expose gaps, conflicts, weak claims, or missing evidence:
@@ -40,7 +71,7 @@ The user has opted into dynamax multi-agent orchestration. The workflow tool is
40
71
  Inline workflow rules:
41
72
  - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
42
73
  - Do not use import statements or dynamic import() in inline workflow scripts.
43
- - Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
74
+ - Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
44
75
  - Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
45
76
  - Provide exactly one of workflow.name or workflow.script.
46
77
 
@@ -57,12 +88,8 @@ export function createDynamaxRuntime(): DynamaxRuntime {
57
88
  return { state: createDynamaxState() };
58
89
  }
59
90
 
60
- export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
61
- return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
62
- }
63
-
64
91
  export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
65
- const key = dynamaxSessionKey(ctx);
92
+ const key = sessionKey(ctx);
66
93
  const existing = store.get(key);
67
94
  if (existing) return existing;
68
95
  const runtime = createDynamaxRuntime();
@@ -78,12 +105,8 @@ export function markDynamaxOneShot(state: DynamaxState): void {
78
105
  state.oneShotPending = true;
79
106
  }
80
107
 
81
- export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
82
- state.sticky = sticky;
83
- if (!sticky) {
84
- state.oneShotPending = false;
85
- state.turnActive = false;
86
- }
108
+ export function enableDynamaxSticky(state: DynamaxState): void {
109
+ state.sticky = true;
87
110
  }
88
111
 
89
112
  export function clearDynamax(state: DynamaxState): void {
@@ -92,12 +115,6 @@ export function clearDynamax(state: DynamaxState): void {
92
115
  state.turnActive = false;
93
116
  }
94
117
 
95
- export function consumeDynamaxOneShot(state: DynamaxState): boolean {
96
- const pending = state.oneShotPending;
97
- state.oneShotPending = false;
98
- return pending;
99
- }
100
-
101
118
  export function isDynamaxActive(state: DynamaxState): boolean {
102
119
  return state.sticky || state.oneShotPending || state.turnActive;
103
120
  }
@@ -139,23 +156,84 @@ export function registerDynamax(
139
156
  options: DynamaxRegistrationOptions = {},
140
157
  ): void {
141
158
  const runtimes: DynamaxRuntimeStore = new Map();
159
+ const effect = options.effect ?? resolveDynamaxEffect();
160
+ let editorInstallation: DynamaxEditorInstallation | undefined;
142
161
  const openInspector =
143
162
  options.openInspector ??
144
163
  ((ctx: ExtensionContext): void => {
145
164
  ctx.ui.notify("No workflow inspector is available yet", "warning");
146
165
  });
147
- const refreshIfVisible = (ctx: ExtensionContext): void => {
148
- const runtime = getDynamaxRuntime(runtimes, ctx);
149
- if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
150
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
166
+ const installEditor = (ctx: ExtensionContext): void => {
167
+ if (ctx.mode !== "tui" || effect === "off") return;
168
+ const previousFactory = ctx.ui.getEditorComponent();
169
+ if (editorInstallation && editorInstallation.factory === previousFactory) return;
170
+
171
+ const decorations = new Set<DynamaxEditorDecoration>();
172
+ const installation: DynamaxEditorInstallation = {
173
+ previousFactory,
174
+ decorations,
175
+ factory: (tui, theme, keybindings) => {
176
+ const decorate = (editor: EditorComponent): EditorComponent => {
177
+ const decoration = decorateDynamaxEditor(editor, () => tui.requestRender(), {
178
+ effect,
179
+ scheduler: options.animationScheduler,
180
+ isActive: () => editorInstallation === installation && ctx.ui.getEditorComponent() === installation.factory,
181
+ });
182
+ decorations.add(decoration);
183
+ return decoration.editor;
184
+ };
185
+ if (!previousFactory) return decorate(new CustomEditor(tui, theme, keybindings));
186
+
187
+ let editor: EditorComponent;
188
+ try {
189
+ editor = previousFactory(tui, theme, keybindings);
190
+ } catch (error) {
191
+ ctx.ui.notify(
192
+ `Dynamax could not compose the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
193
+ "warning",
194
+ );
195
+ installation.previousFactory = undefined;
196
+ return decorate(new CustomEditor(tui, theme, keybindings));
197
+ }
198
+
199
+ try {
200
+ return decorate(editor);
201
+ } catch (error) {
202
+ ctx.ui.notify(
203
+ `Dynamax could not decorate the existing custom editor (${unknownErrorMessage(error)}); using pi's stock-compatible CustomEditor so highlighting stays enabled`,
204
+ "warning",
205
+ );
206
+ return decorate(new CustomEditor(tui, theme, keybindings));
207
+ }
208
+ },
209
+ };
210
+
211
+ try {
212
+ ctx.ui.setEditorComponent(installation.factory);
213
+ } catch (error) {
214
+ disposeDynamaxDecorations(decorations);
215
+ ctx.ui.notify(`Dynamax highlighting could not be installed: ${unknownErrorMessage(error)}`, "error");
216
+ throw error;
217
+ }
218
+ editorInstallation = installation;
219
+ };
220
+ const uninstallEditor = (ctx: ExtensionContext): void => {
221
+ const installation = editorInstallation;
222
+ if (!installation) return;
223
+ editorInstallation = undefined;
224
+ disposeDynamaxDecorations(installation.decorations);
225
+ if (ctx.mode === "tui" && ctx.ui.getEditorComponent() === installation.factory) {
226
+ ctx.ui.setEditorComponent(installation.previousFactory);
151
227
  }
152
228
  };
153
229
 
154
230
  pi.on("session_start", (_event, ctx) => {
231
+ installEditor(ctx);
155
232
  updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
156
233
  });
157
234
 
158
235
  pi.on("session_shutdown", (_event, ctx) => {
236
+ uninstallEditor(ctx);
159
237
  clearDynamaxSurfaces(ctx);
160
238
  });
161
239
 
@@ -177,26 +255,6 @@ export function registerDynamax(
177
255
  return { systemPrompt };
178
256
  });
179
257
 
180
- pi.on("agent_start", (_event, ctx) => {
181
- refreshIfVisible(ctx);
182
- });
183
-
184
- pi.on("message_start", (_event, ctx) => {
185
- refreshIfVisible(ctx);
186
- });
187
-
188
- pi.on("message_update", (_event, ctx) => {
189
- refreshIfVisible(ctx);
190
- });
191
-
192
- pi.on("message_end", (_event, ctx) => {
193
- refreshIfVisible(ctx);
194
- });
195
-
196
- pi.on("turn_end", (_event, ctx) => {
197
- refreshIfVisible(ctx);
198
- });
199
-
200
258
  pi.on("agent_end", (_event, ctx) => {
201
259
  const runtime = getDynamaxRuntime(runtimes, ctx);
202
260
  runtime.runningWorkflow = undefined;
@@ -205,23 +263,17 @@ export function registerDynamax(
205
263
  });
206
264
 
207
265
  pi.on("tool_execution_start", (event, ctx) => {
266
+ if (event.toolName !== "workflow") return undefined;
208
267
  const runtime = getDynamaxRuntime(runtimes, ctx);
209
- if (event.toolName === "workflow") {
210
- runtime.runningWorkflow = workflowLabel(event.args);
211
- }
268
+ runtime.runningWorkflow = workflowLabel(event.args);
212
269
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
213
270
  return undefined;
214
271
  });
215
272
 
216
- pi.on("tool_execution_update", (_event, ctx) => {
217
- refreshIfVisible(ctx);
218
- });
219
-
220
273
  pi.on("tool_execution_end", (event, ctx) => {
274
+ if (event.toolName !== "workflow") return undefined;
221
275
  const runtime = getDynamaxRuntime(runtimes, ctx);
222
- if (event.toolName === "workflow") {
223
- runtime.runningWorkflow = undefined;
224
- }
276
+ runtime.runningWorkflow = undefined;
225
277
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
226
278
  return undefined;
227
279
  });
@@ -244,6 +296,7 @@ export function registerDynamax(
244
296
 
245
297
  pi.registerCommand("workflow:dynamax", {
246
298
  description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
299
+ getArgumentCompletions: (argumentPrefix) => completeCurrentArgument(argumentPrefix, DYNAMAX_ACTION_COMPLETIONS),
247
300
  handler: async (args, ctx) => {
248
301
  const runtime = getDynamaxRuntime(runtimes, ctx);
249
302
  const action = args.trim().toLowerCase();
@@ -252,7 +305,7 @@ export function registerDynamax(
252
305
  return;
253
306
  }
254
307
  if (action === "on") {
255
- setDynamaxSticky(runtime.state, true);
308
+ enableDynamaxSticky(runtime.state);
256
309
  updateDynamaxSurfaces(ctx, runtime, shortcuts);
257
310
  ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
258
311
  return;
@@ -274,8 +327,6 @@ export function registerDynamax(
274
327
 
275
328
  export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
276
329
  if (!ctx.hasUI) return;
277
- ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
278
- ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
279
330
  if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
280
331
  clearDynamaxSurfaces(ctx);
281
332
  return;
@@ -287,9 +338,7 @@ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui"
287
338
 
288
339
  export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">): void {
289
340
  if (!ctx.hasUI) return;
290
- ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
291
341
  ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
292
- ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
293
342
  }
294
343
 
295
344
  function workflowLabel(args: unknown): string {