pi-workflow-engine 0.13.0 → 1.1.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 (98) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +152 -416
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +12 -13
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +131 -117
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  18. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
  23. package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
  24. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  25. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  26. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  27. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  33. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  34. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  35. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  36. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  37. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  38. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  40. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  41. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  42. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  43. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
  44. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  45. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  46. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  55. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  56. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  57. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  58. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
  60. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  61. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  62. package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  65. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  68. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  69. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  70. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  71. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  72. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  73. package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  79. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  80. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  81. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  82. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  83. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  84. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  85. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  86. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  87. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  88. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  89. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  90. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  91. package/README.md +1 -1
  92. package/USAGE.md +79 -20
  93. package/package.json +1 -2
  94. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  95. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  96. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  97. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  98. package/.pi/settings.json +0 -3
@@ -1,5 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
3
+ import { isFiniteNumber } from "./guards.ts";
3
4
  import { unknownErrorMessage } from "./unknown-error.ts";
4
5
 
5
6
  /**
@@ -59,10 +60,6 @@ export class Semaphore {
59
60
  cleanup();
60
61
  reject(abortReason(signal));
61
62
  };
62
- if (signal?.aborted) {
63
- reject(abortReason(signal));
64
- return;
65
- }
66
63
  this.waiters.push(waiter);
67
64
  signal?.addEventListener("abort", onAbort, { once: true });
68
65
  });
@@ -138,13 +135,6 @@ export async function parallel<T>(
138
135
  }
139
136
 
140
137
  export function bindParallel(options: ParallelOptions): WorkflowParallel {
141
- const executionOptions: ParallelOptions = {
142
- signal: options.signal,
143
- abortController: options.abortController,
144
- limit: options.limit,
145
- drainTimeoutMs: options.drainTimeoutMs,
146
- };
147
-
148
138
  function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
149
139
  function boundParallel<T>(
150
140
  thunks: Array<() => Promise<T>>,
@@ -155,9 +145,9 @@ export function bindParallel(options: ParallelOptions): WorkflowParallel {
155
145
  workflowOptions?: ParallelSettledOptions,
156
146
  ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
157
147
  if (workflowOptions?.settled) {
158
- return await parallel(thunks, { ...executionOptions, settled: true });
148
+ return await parallel(thunks, { ...options, settled: true });
159
149
  }
160
- return await parallel(thunks, executionOptions);
150
+ return await parallel(thunks, options);
161
151
  }
162
152
 
163
153
  return boundParallel;
@@ -224,7 +214,6 @@ async function runParallel<T, Result>(
224
214
  };
225
215
 
226
216
  options.signal?.addEventListener("abort", onAbort, { once: true });
227
- if (options.signal?.aborted) onAbort();
228
217
  for (let worker = 0; worker < limit; worker++) {
229
218
  void runWorker().then(completeWorker, (error) => {
230
219
  recordFatalFailure(error);
@@ -264,7 +253,7 @@ function readSerializableDetails(
264
253
  typeof detail === "string" ||
265
254
  typeof detail === "boolean" ||
266
255
  detail === null ||
267
- (typeof detail === "number" && Number.isFinite(detail))
256
+ isFiniteNumber(detail)
268
257
  ) {
269
258
  details[key] = detail;
270
259
  }
@@ -285,12 +274,12 @@ function readProperty(value: unknown, property: string): unknown {
285
274
  }
286
275
 
287
276
  function normalizeLimit(limit: number | undefined, itemCount: number): number {
288
- if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
277
+ if (!isFiniteNumber(limit)) return Math.max(1, itemCount);
289
278
  return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
290
279
  }
291
280
 
292
281
  function normalizeDrainTimeout(timeoutMs: number | undefined): number {
293
- if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
282
+ if (!isFiniteNumber(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
294
283
  return Math.max(0, Math.trunc(timeoutMs));
295
284
  }
296
285
 
@@ -302,48 +291,7 @@ function normalizeDrainTimeout(timeoutMs: number | undefined): number {
302
291
  *
303
292
  * Each stage receives (previousResult, originalItem, index).
304
293
  */
305
- export async function pipeline<Item, A>(
306
- items: readonly Item[],
307
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
308
- ): Promise<Array<A | null>>;
309
- export async function pipeline<Item, A, B>(
310
- items: readonly Item[],
311
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
312
- stage2: (prev: A, item: Item, index: number) => Promise<B>,
313
- ): Promise<Array<B | null>>;
314
- export async function pipeline<Item, A, B, C>(
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
- ): Promise<Array<C | null>>;
320
- export async function pipeline<Item, A, B, C, D>(
321
- items: readonly Item[],
322
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
323
- stage2: (prev: A, item: Item, index: number) => Promise<B>,
324
- stage3: (prev: B, item: Item, index: number) => Promise<C>,
325
- stage4: (prev: C, item: Item, index: number) => Promise<D>,
326
- ): Promise<Array<D | null>>;
327
- export async function pipeline<Item, A, B, C, D, E>(
328
- items: readonly Item[],
329
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
330
- stage2: (prev: A, item: Item, index: number) => Promise<B>,
331
- stage3: (prev: B, item: Item, index: number) => Promise<C>,
332
- stage4: (prev: C, item: Item, index: number) => Promise<D>,
333
- stage5: (prev: D, item: Item, index: number) => Promise<E>,
334
- ): Promise<Array<E | null>>;
335
- export async function pipeline(
336
- items: readonly unknown[],
337
- ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
338
- ): Promise<Array<unknown | null>>;
339
- export async function pipeline(
340
- items: readonly unknown[],
341
- ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
342
- ): Promise<Array<unknown | null>> {
343
- return pipelineWithOptions(items, stages);
344
- }
345
-
346
- export function bindPipeline(options: PipelineOptions): Pipeline {
294
+ export function bindPipeline(options: PipelineOptions) {
347
295
  async function boundPipeline<Item, A>(
348
296
  items: readonly Item[],
349
297
  stage1: (prev: Item, item: Item, index: number) => Promise<A>,
@@ -374,33 +322,30 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
374
322
  stage4: (prev: C, item: Item, index: number) => Promise<D>,
375
323
  stage5: (prev: D, item: Item, index: number) => Promise<E>,
376
324
  ): Promise<Array<E | null>>;
325
+ async function boundPipeline(
326
+ items: readonly unknown[],
327
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
328
+ ): Promise<Array<unknown | null>>;
377
329
  async function boundPipeline(
378
330
  items: readonly unknown[],
379
331
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
380
332
  ): Promise<Array<unknown | null>> {
381
- return pipelineWithOptions(items, stages, options);
333
+ return await parallel(
334
+ items.map((item, index) => async () => {
335
+ let acc: unknown = item;
336
+ for (const stage of stages) {
337
+ throwIfAborted(options.signal);
338
+ acc = await stage(acc, item, index);
339
+ }
340
+ return acc;
341
+ }),
342
+ options,
343
+ );
382
344
  }
383
345
  return boundPipeline;
384
346
  }
385
347
 
386
- export async function pipelineWithOptions(
387
- items: readonly unknown[],
388
- stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
389
- options: PipelineOptions = {},
390
- ): Promise<Array<unknown | null>> {
391
- throwIfAborted(options.signal);
392
- return await parallel(
393
- items.map((item, index) => async () => {
394
- throwIfAborted(options.signal);
395
- let acc: unknown = item;
396
- for (const stage of stages) {
397
- throwIfAborted(options.signal);
398
- acc = await stage(acc, item, index);
399
- }
400
- return acc;
401
- }),
402
- options,
403
- );
404
- }
348
+ export type Pipeline = ReturnType<typeof bindPipeline>;
405
349
 
406
- export type Pipeline = typeof pipeline;
350
+ /** `pipeline` without a run's abort signal (tests, benchmarks). */
351
+ export const pipeline: Pipeline = bindPipeline({});
@@ -1,13 +1,12 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
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
- import { captureTreeFingerprint } from "./tree-fingerprint.ts";
10
- import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
9
+ import { captureSourceTreeFingerprint, type FingerprintCapture } from "./tree-fingerprint.ts";
11
10
  import { unknownErrorMessage } from "./unknown-error.ts";
12
11
 
13
12
  export interface DiscoverWorkflowsOptions {
@@ -16,16 +15,13 @@ export interface DiscoverWorkflowsOptions {
16
15
  readonly userWorkflowDir?: string;
17
16
  }
18
17
 
19
- const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
20
- const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
21
-
22
18
  const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
23
19
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
24
20
  async function loadDir(
25
21
  dir: string,
26
22
  sourceIdentity: (path: string) => WorkflowSourceIdentity,
27
23
  excludeFiles: ReadonlySet<string> = new Set(),
28
- provenanceRoot?: string,
24
+ captureProvenance?: () => Promise<FingerprintCapture>,
29
25
  ): Promise<LoadedWorkflow[]> {
30
26
  let entries: string[];
31
27
  try {
@@ -33,12 +29,15 @@ async function loadDir(
33
29
  } catch {
34
30
  return [];
35
31
  }
32
+ const candidates = entries.filter(
33
+ (name) => !excludeFiles.has(name) && name.endsWith(".ts") && !name.startsWith("_") && !name.startsWith("."),
34
+ );
35
+ // The shipped workflows/ dir holds only excluded built-ins; skip the tree hash when nothing will be imported.
36
+ if (candidates.length === 0) return [];
36
37
 
37
- const before = provenanceRoot ? await captureDiscoveryFingerprint(provenanceRoot) : undefined;
38
+ const before = await captureProvenance?.();
38
39
  const modules: Array<{ readonly path: string; readonly module: Parameters<typeof loadWorkflow>[0] }> = [];
39
- for (const name of entries) {
40
- if (excludeFiles.has(name)) continue;
41
- if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
40
+ for (const name of candidates) {
42
41
  try {
43
42
  const path = join(dir, name);
44
43
  const importUrl = pathToFileURL(path);
@@ -56,15 +55,6 @@ async function loadDir(
56
55
  return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
57
56
  }
58
57
 
59
- async function captureDiscoveryFingerprint(root: string) {
60
- return await captureTreeFingerprint({
61
- root,
62
- excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
63
- maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
64
- maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
65
- });
66
- }
67
-
68
58
  /**
69
59
  * All available workflows by name. Static registry wins on name collisions, so the
70
60
  * bundled example is always the verified one even if a same-named file is dropped in.
@@ -80,14 +70,14 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
80
70
 
81
71
  const byName = await timed(options.perf, "discovery.total_ms", async () => {
82
72
  const next = new Map<string, LoadedWorkflow>();
83
- const builtinSource = await captureDiscoveryFingerprint(BUILTIN_SOURCE_ROOT);
73
+ const builtinSource = await captureSourceTreeFingerprint(BUILTIN_SOURCE_ROOT);
84
74
  for (const definition of BUILTIN_WORKFLOW_DEFINITIONS) {
85
75
  const source: WorkflowSourceIdentity =
86
76
  builtinSource.kind === "verified"
87
77
  ? {
88
78
  kind: "file",
89
79
  path: definition.path,
90
- root: definition.root,
80
+ root: BUILTIN_SOURCE_ROOT,
91
81
  fingerprint: builtinSource.fingerprint,
92
82
  }
93
83
  : {
@@ -98,6 +88,9 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
98
88
  }
99
89
 
100
90
  const repoWorkflowDir = join(repoDir, "workflows");
91
+ // The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
92
+ const repoProvenance =
93
+ resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
101
94
  const [repoDynamic, userDynamic] = await Promise.all([
102
95
  timed(options.perf, "discovery.repo_dir_ms", () =>
103
96
  loadDir(
@@ -107,7 +100,7 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
107
100
  reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
108
101
  }),
109
102
  BUILTIN_WORKFLOW_FILES,
110
- repoDir,
103
+ repoProvenance,
111
104
  ),
112
105
  ),
113
106
  timed(options.perf, "discovery.user_dir_ms", () =>
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { Key, type KeyId } from "@earendil-works/pi-tui";
5
+ import { isRecord } from "./guards.ts";
5
6
 
6
7
  export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
7
8
  export const DEFAULT_REVIEW_RESULTS_SHORTCUT = "ctrl+shift+r" satisfies KeyId;
@@ -112,10 +113,6 @@ function defaultDynamaxShortcuts(): DynamaxShortcuts {
112
113
  return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT, results: DEFAULT_REVIEW_RESULTS_SHORTCUT };
113
114
  }
114
115
 
115
- function isRecord(value: unknown): value is Record<string, unknown> {
116
- return typeof value === "object" && value !== null && !Array.isArray(value);
117
- }
118
-
119
116
  function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
120
117
  console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default workflow shortcuts.`);
121
118
  }
@@ -1,10 +1,11 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { EditorComponent, KeyId } from "@earendil-works/pi-tui";
1
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorComponent } from "@earendil-works/pi-tui";
4
3
  import { completeCurrentArgument } from "./command-completions.ts";
5
- import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
4
+ import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
6
5
  import { sessionKey } from "./session-identity.ts";
7
6
  import { unknownErrorMessage } from "./unknown-error.ts";
7
+ import { dot, GLYPH } from "./ui/kit.ts";
8
+ import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
8
9
  import {
9
10
  decorateDynamaxEditor,
10
11
  resolveDynamaxEffect,
@@ -21,17 +22,21 @@ export interface DynamaxState {
21
22
 
22
23
  export interface DynamaxRuntime {
23
24
  state: DynamaxState;
24
- runningWorkflow?: string;
25
25
  }
26
26
 
27
27
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
28
28
 
29
29
  export interface DynamaxRegistrationOptions {
30
- openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
30
+ openInspector: (ctx: ExtensionContext) => Promise<void> | void;
31
31
  effect?: DynamaxEffect;
32
32
  animationScheduler?: DynamaxAnimationScheduler;
33
33
  }
34
34
 
35
+ export interface DynamaxHandle {
36
+ /** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
37
+ markOneShot(ctx: ExtensionContext): void;
38
+ }
39
+
35
40
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
36
41
  export const DYNAMAX_STATUS_KEY = "dynamax";
37
42
 
@@ -63,22 +68,13 @@ Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out whe
63
68
  - synthesize the first-pass and follow-up results together.
64
69
  Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
65
70
 
66
- export const DYNAMAX_REMINDER = `
67
- ## dynamax workflow opt-in
68
-
69
- 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.
70
-
71
- Inline workflow rules:
72
- - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
73
- - Do not use import statements or dynamic import() in inline workflow scripts.
74
- - Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
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.
76
- - Provide exactly one of workflow.name or workflow.script.
71
+ /** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
72
+ export const DYNAMAX_SECTION = "dynamax";
77
73
 
78
- ${ADAPTIVE_WORKFLOW_GUIDANCE}
79
- `;
80
-
81
- const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
74
+ /** Only the opt-in itself: the authoring rules already reach the model as the workflow tool's guidelines. */
75
+ export const DYNAMAX_REMINDER =
76
+ "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
77
+ "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
82
78
 
83
79
  export function createDynamaxState(): DynamaxState {
84
80
  return { sticky: false, oneShotPending: false, turnActive: false };
@@ -126,43 +122,39 @@ export function describeDynamaxState(state: DynamaxState): string {
126
122
  return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
127
123
  }
128
124
 
129
- export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
130
- const modes = [
131
- runningWorkflow ? `running ${runningWorkflow}` : undefined,
132
- state.sticky ? "sticky on" : undefined,
133
- !state.sticky && state.turnActive ? "active this turn" : undefined,
134
- state.oneShotPending ? "one-shot pending" : undefined,
135
- ].filter((value): value is string => value !== undefined);
136
- const mode = modes.join(" + ") || "off";
137
- const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
138
- return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
125
+ /**
126
+ * Footer status: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its
127
+ * prompt, `◆ dynamax · this turn` while that turn runs. A running workflow names itself in its own
128
+ * status (`◆ repo-scan · Scan 2/3 · 4s`), so this one only states the mode.
129
+ */
130
+ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
131
+ const mode = state.sticky ? undefined : state.oneShotPending ? "next prompt" : state.turnActive ? "this turn" : undefined;
132
+ const label = `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("accent", "dynamax")}`;
133
+ return mode ? `${label}${dot(theme)}${theme.fg("muted", mode)}` : label;
139
134
  }
140
135
 
141
- export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
142
- if (!state.sticky && !state.oneShotPending) return systemPrompt;
136
+ /**
137
+ * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
138
+ * run, so an inactive run simply leaves it out and pi records the removal as a patch.
139
+ */
140
+ export function applyDynamaxPromptSection(sections: Record<string, string>, state: DynamaxState): boolean {
141
+ if (!state.sticky && !state.oneShotPending) return false;
143
142
  state.oneShotPending = false;
144
143
  state.turnActive = true;
145
- return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
146
- }
147
-
148
- export function appendDynamaxContextReminder(messages: AgentMessage[], state: DynamaxState): AgentMessage[] {
149
- if (!state.sticky) return messages;
150
- return [...messages, createDynamaxContextMessage()];
144
+ sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
145
+ return true;
151
146
  }
152
147
 
153
- export function registerDynamax(
154
- pi: ExtensionAPI,
155
- shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
156
- options: DynamaxRegistrationOptions = {},
157
- ): void {
148
+ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, options: DynamaxRegistrationOptions): DynamaxHandle {
158
149
  const runtimes: DynamaxRuntimeStore = new Map();
159
150
  const effect = options.effect ?? resolveDynamaxEffect();
151
+ setWorkflowInspectorShortcut(shortcuts.inspector);
160
152
  let editorInstallation: DynamaxEditorInstallation | undefined;
161
- const openInspector =
162
- options.openInspector ??
163
- ((ctx: ExtensionContext): void => {
164
- ctx.ui.notify("No workflow inspector is available yet", "warning");
165
- });
153
+ const markOneShot = (ctx: ExtensionContext): void => {
154
+ const runtime = getDynamaxRuntime(runtimes, ctx);
155
+ markDynamaxOneShot(runtime.state);
156
+ updateDynamaxSurfaces(ctx, runtime);
157
+ };
166
158
  const installEditor = (ctx: ExtensionContext): void => {
167
159
  if (ctx.mode !== "tui" || effect === "off") return;
168
160
  const previousFactory = ctx.ui.getEditorComponent();
@@ -229,7 +221,7 @@ export function registerDynamax(
229
221
 
230
222
  pi.on("session_start", (_event, ctx) => {
231
223
  installEditor(ctx);
232
- updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
224
+ updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
233
225
  });
234
226
 
235
227
  pi.on("session_shutdown", (_event, ctx) => {
@@ -238,58 +230,31 @@ export function registerDynamax(
238
230
  });
239
231
 
240
232
  pi.on("input", (event, ctx) => {
241
- if (event.source === "extension") return { action: "continue" };
242
- const runtime = getDynamaxRuntime(runtimes, ctx);
243
- if (hasDynamaxToken(event.text)) {
244
- markDynamaxOneShot(runtime.state);
245
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
246
- }
233
+ if (event.source !== "extension" && hasDynamaxToken(event.text)) markOneShot(ctx);
247
234
  return { action: "continue" };
248
235
  });
249
236
 
250
237
  pi.on("before_agent_start", (event, ctx) => {
251
238
  const runtime = getDynamaxRuntime(runtimes, ctx);
252
- const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
253
- if (systemPrompt === event.systemPrompt) return undefined;
254
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
255
- return { systemPrompt };
239
+ const options = event.systemPromptOptions;
240
+ if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined;
241
+ updateDynamaxSurfaces(ctx, runtime);
242
+ // An earlier extension forced the whole prompt, and pi renders no sections then; append to its text instead.
243
+ if (options.forceSystemPrompt === undefined) return undefined;
244
+ return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
256
245
  });
257
246
 
258
247
  pi.on("agent_end", (_event, ctx) => {
259
248
  const runtime = getDynamaxRuntime(runtimes, ctx);
260
- runtime.runningWorkflow = undefined;
261
249
  if (!runtime.state.sticky) runtime.state.turnActive = false;
262
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
263
- });
264
-
265
- pi.on("tool_execution_start", (event, ctx) => {
266
- if (event.toolName !== "workflow") return undefined;
267
- const runtime = getDynamaxRuntime(runtimes, ctx);
268
- runtime.runningWorkflow = workflowLabel(event.args);
269
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
270
- return undefined;
271
- });
272
-
273
- pi.on("tool_execution_end", (event, ctx) => {
274
- if (event.toolName !== "workflow") return undefined;
275
- const runtime = getDynamaxRuntime(runtimes, ctx);
276
- runtime.runningWorkflow = undefined;
277
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
278
- return undefined;
279
- });
280
-
281
- pi.on("context", (event, ctx) => {
282
- const runtime = getDynamaxRuntime(runtimes, ctx);
283
- const messages = appendDynamaxContextReminder(event.messages, runtime.state);
284
- if (messages === event.messages) return undefined;
285
- return { messages };
250
+ updateDynamaxSurfaces(ctx, runtime);
286
251
  });
287
252
 
288
253
  if (shortcuts.inspector) {
289
254
  pi.registerShortcut(shortcuts.inspector, {
290
255
  description: "Open workflow inspector",
291
256
  handler: async (ctx) => {
292
- await openInspector(ctx);
257
+ await options.openInspector(ctx);
293
258
  },
294
259
  });
295
260
  }
@@ -306,13 +271,13 @@ export function registerDynamax(
306
271
  }
307
272
  if (action === "on") {
308
273
  enableDynamaxSticky(runtime.state);
309
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
274
+ updateDynamaxSurfaces(ctx, runtime);
310
275
  ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
311
276
  return;
312
277
  }
313
278
  if (action === "off") {
314
279
  clearDynamax(runtime.state);
315
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
280
+ updateDynamaxSurfaces(ctx, runtime);
316
281
  ctx.ui.notify("Dynamax workflow orchestration is off", "info");
317
282
  return;
318
283
  }
@@ -323,44 +288,20 @@ export function registerDynamax(
323
288
  ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
324
289
  },
325
290
  });
291
+
292
+ return { markOneShot };
326
293
  }
327
294
 
328
- export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
295
+ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
329
296
  if (!ctx.hasUI) return;
330
- if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
297
+ if (!isDynamaxActive(runtime.state)) {
331
298
  clearDynamaxSurfaces(ctx);
332
299
  return;
333
300
  }
334
-
335
- const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
336
- ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
301
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, dynamaxStatusText(runtime.state, ctx.ui.theme));
337
302
  }
338
303
 
339
304
  export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">): void {
340
305
  if (!ctx.hasUI) return;
341
306
  ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
342
307
  }
343
-
344
- function workflowLabel(args: unknown): string {
345
- if (!isRecord(args)) return "workflow";
346
- const name = args.name;
347
- if (typeof name === "string" && name.trim()) return name.trim();
348
- const script = args.script;
349
- if (typeof script === "string" && script.trim()) return "inline workflow";
350
- return "workflow";
351
- }
352
-
353
- function createDynamaxContextMessage(): AgentMessage {
354
- return {
355
- role: "custom",
356
- customType: DYNAMAX_CONTEXT_CUSTOM_TYPE,
357
- content: DYNAMAX_REMINDER.trim(),
358
- display: false,
359
- details: { dynamax: true, sticky: true },
360
- timestamp: Date.now(),
361
- };
362
- }
363
-
364
- function isRecord(value: unknown): value is Record<string, unknown> {
365
- return typeof value === "object" && value !== null;
366
- }