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
@@ -56,9 +56,9 @@ export function resolveWorkflowRunOptions(
56
56
  input: WorkflowRunOptions = {},
57
57
  env: Record<string, string | undefined> = process.env,
58
58
  ): ResolvedWorkflowRunOptions {
59
- const concurrency = clampInteger(input.concurrency ?? parseInteger(env.PI_WORKFLOW_CONCURRENCY), 1, 64, defaultConcurrency());
59
+ const concurrency = clampInteger(input.concurrency ?? parseWorkflowIntegerString(env.PI_WORKFLOW_CONCURRENCY), 1, 64, defaultConcurrency());
60
60
  const parallelSubmissionLimit = optionalClampedInteger(
61
- input.parallelSubmissionLimit ?? parseInteger(env.PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT),
61
+ input.parallelSubmissionLimit ?? parseWorkflowIntegerString(env.PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT),
62
62
  1,
63
63
  10_000,
64
64
  );
@@ -80,7 +80,7 @@ export function resolveWorkflowRunOptions(
80
80
  WORKFLOW_AGENT_RETRIES_MAX,
81
81
  DEFAULT_WORKFLOW_AGENT_RETRIES,
82
82
  );
83
- const budget = resolveBudget(input.budget, env.PI_WORKFLOW_BUDGET);
83
+ const budget = input.budget !== undefined ? normalizeExplicitBudget(input.budget) : parseWorkflowBudgetString(env.PI_WORKFLOW_BUDGET);
84
84
  const usageLimitMaxAttempts = clampInteger(
85
85
  input.usageLimitMaxAttempts ?? parseWorkflowIntegerString(env.PI_WORKFLOW_USAGE_LIMIT_MAX_ATTEMPTS),
86
86
  WORKFLOW_USAGE_LIMIT_ATTEMPTS_MIN,
@@ -129,23 +129,12 @@ function clampInteger(value: number | undefined, min: number, max: number, fallb
129
129
  return Math.min(max, Math.max(min, Math.trunc(value)));
130
130
  }
131
131
 
132
- function parseInteger(value: string | undefined): number | undefined {
133
- if (value === undefined || value.trim() === "") return undefined;
134
- const parsed = Number(value);
135
- return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
136
- }
137
-
138
132
  export function parseWorkflowIntegerString(value: string | undefined): number | undefined {
139
133
  if (value === undefined || value.trim() === "") return undefined;
140
134
  const parsed = Number(value);
141
135
  return Number.isSafeInteger(parsed) ? parsed : undefined;
142
136
  }
143
137
 
144
- function resolveBudget(inputBudget: number | undefined, envBudget: string | undefined): number | undefined {
145
- if (inputBudget !== undefined) return normalizeExplicitBudget(inputBudget);
146
- return envBudget === undefined ? undefined : parseWorkflowBudgetString(envBudget);
147
- }
148
-
149
138
  function normalizeExplicitBudget(value: number): number {
150
139
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < WORKFLOW_BUDGET_MIN || value > WORKFLOW_BUDGET_MAX) {
151
140
  throw new RangeError(`Workflow budget must be an integer between ${WORKFLOW_BUDGET_MIN} and ${WORKFLOW_BUDGET_MAX}.`);
@@ -153,10 +142,102 @@ function normalizeExplicitBudget(value: number): number {
153
142
  return value;
154
143
  }
155
144
 
156
- export function parseWorkflowBudgetString(value: string): number | undefined {
157
- const trimmed = value.trim();
145
+ export function parseWorkflowBudgetString(value: string | undefined): number | undefined {
146
+ const trimmed = value?.trim() ?? "";
158
147
  if (!/^\d+$/.test(trimmed)) return undefined;
159
148
  const parsed = Number(trimmed);
160
149
  if (!Number.isSafeInteger(parsed) || parsed < WORKFLOW_BUDGET_MIN || parsed > WORKFLOW_BUDGET_MAX) return undefined;
161
150
  return parsed;
162
151
  }
152
+
153
+ function parseRunIdString(value: string | undefined): string | undefined {
154
+ return value === undefined || value === "" || value.startsWith("--") ? undefined : value;
155
+ }
156
+
157
+ /** A `/workflow <name> [flags] [args]` command line with its flags lifted into run options. */
158
+ export interface WorkflowInvocation {
159
+ readonly name: string;
160
+ readonly args: string;
161
+ readonly options: WorkflowRunOptions;
162
+ readonly refreshDiscovery: boolean;
163
+ readonly optionErrors: readonly string[];
164
+ }
165
+
166
+ type WorkflowValueOptions = Pick<
167
+ WorkflowRunOptions,
168
+ "concurrency" | "parallelSubmissionLimit" | "maxAgents" | "agentTimeoutMs" | "agentRetries" | "budget" | "resumeFromRunId"
169
+ >;
170
+
171
+ interface WorkflowValueFlag<K extends keyof WorkflowValueOptions> {
172
+ readonly flag: string;
173
+ readonly key: K;
174
+ readonly parse: (value: string | undefined) => WorkflowValueOptions[K] | undefined;
175
+ readonly error: string;
176
+ }
177
+
178
+ type AnyWorkflowValueFlag = { [K in keyof WorkflowValueOptions]-?: WorkflowValueFlag<K> }[keyof WorkflowValueOptions];
179
+
180
+ /** `--flag=V` or `--flag V`; the next token is consumed only when it parses, so a following flag or argument survives. */
181
+ const WORKFLOW_VALUE_FLAGS: readonly AnyWorkflowValueFlag[] = [
182
+ { flag: "--concurrency", key: "concurrency", parse: parseWorkflowIntegerString, error: "--concurrency requires an integer" },
183
+ { flag: "--parallel-limit", key: "parallelSubmissionLimit", parse: parseWorkflowIntegerString, error: "--parallel-limit requires an integer" },
184
+ { flag: "--max-agents", key: "maxAgents", parse: parseWorkflowIntegerString, error: "--max-agents requires an integer" },
185
+ { flag: "--agent-timeout-ms", key: "agentTimeoutMs", parse: parseWorkflowIntegerString, error: "--agent-timeout-ms requires an integer" },
186
+ { flag: "--agent-retries", key: "agentRetries", parse: parseWorkflowIntegerString, error: "--agent-retries requires an integer" },
187
+ { flag: "--budget", key: "budget", parse: parseWorkflowBudgetString, error: "--budget requires a positive integer output-token count" },
188
+ { flag: "--resume", key: "resumeFromRunId", parse: parseRunIdString, error: "--resume requires a workflow run id" },
189
+ ];
190
+
191
+ const WORKFLOW_SWITCH_FLAGS = new Map<string, Partial<WorkflowRunOptions>>([
192
+ ["--inspect", { inspect: true }],
193
+ ["--perf", { perf: true }],
194
+ ["--resume-edited", { resumeEditedWorkflow: true }],
195
+ ["--result-viewer", { resultViewer: "open" }],
196
+ ["--review-viewer", { resultViewer: "open" }],
197
+ ["--no-result-viewer", { resultViewer: "skip" }],
198
+ ["--no-review-viewer", { resultViewer: "skip" }],
199
+ ]);
200
+
201
+ export function parseWorkflowInvocation(input: string): WorkflowInvocation {
202
+ const [name = "", ...tokens] = input.split(/\s+/).filter(Boolean);
203
+ const kept: string[] = [];
204
+ const options: WorkflowRunOptions = {};
205
+ const optionErrors: string[] = [];
206
+ let refreshDiscovery = false;
207
+ for (let i = 0; i < tokens.length; i++) {
208
+ const token = tokens[i];
209
+ if (token === "--refresh") {
210
+ refreshDiscovery = true;
211
+ continue;
212
+ }
213
+ const switched = WORKFLOW_SWITCH_FLAGS.get(token);
214
+ if (switched) {
215
+ Object.assign(options, switched);
216
+ continue;
217
+ }
218
+ const valueFlag = WORKFLOW_VALUE_FLAGS.find(({ flag }) => token === flag || token.startsWith(`${flag}=`));
219
+ if (!valueFlag) {
220
+ kept.push(token);
221
+ continue;
222
+ }
223
+ const inline = token !== valueFlag.flag;
224
+ if (applyValueFlag(options, valueFlag, inline ? token.slice(valueFlag.flag.length + 1) : tokens[i + 1])) {
225
+ if (!inline) i++;
226
+ } else {
227
+ optionErrors.push(valueFlag.error);
228
+ }
229
+ }
230
+ if (options.resumeEditedWorkflow && !options.resumeFromRunId) optionErrors.push("--resume-edited requires --resume <run-id>");
231
+ return { name, args: kept.join(" "), options, refreshDiscovery, optionErrors };
232
+ }
233
+
234
+ function applyValueFlag<K extends keyof WorkflowValueOptions>(
235
+ options: WorkflowValueOptions,
236
+ valueFlag: WorkflowValueFlag<K>,
237
+ value: string | undefined,
238
+ ): boolean {
239
+ const parsed = valueFlag.parse(value);
240
+ if (parsed === undefined) return false;
241
+ options[valueFlag.key] = parsed;
242
+ return true;
243
+ }
@@ -1,14 +1,5 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
 
3
- export type PerfTags = Record<string, string | number>;
4
-
5
- export interface PerfSample {
6
- readonly name: string;
7
- readonly durationMs?: number;
8
- readonly value?: number;
9
- readonly tags?: PerfTags;
10
- }
11
-
12
3
  export interface PerfAggregate {
13
4
  readonly name: string;
14
5
  readonly count: number;
@@ -22,118 +13,98 @@ export interface PerfAggregate {
22
13
 
23
14
  export interface PerfSnapshot {
24
15
  readonly enabled: boolean;
25
- readonly startedAt: number;
26
- readonly samples: readonly PerfSample[];
27
16
  readonly aggregates: readonly PerfAggregate[];
28
17
  }
29
18
 
30
19
  export interface PerfSink {
31
- time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T>;
32
- timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T;
33
- observe(name: string, value: number, tags?: PerfTags): void;
34
- counter(name: string, delta?: number, tags?: PerfTags): void;
20
+ time<T>(name: string, fn: () => Promise<T>): Promise<T>;
21
+ timeSync<T>(name: string, fn: () => T): T;
22
+ observe(name: string, value: number): void;
23
+ counter(name: string, delta?: number): void;
35
24
  snapshot(): PerfSnapshot;
36
25
  }
37
26
 
38
27
  export class PerfRecorder implements PerfSink {
39
- private readonly samples: PerfSample[] = [];
40
-
41
- constructor(private readonly startedAt = Date.now()) {}
28
+ /** Finite observed values per metric, in first-observation order. */
29
+ private readonly values = new Map<string, number[]>();
42
30
 
43
- async time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T> {
31
+ async time<T>(name: string, fn: () => Promise<T>): Promise<T> {
44
32
  const start = performance.now();
45
33
  try {
46
34
  return await fn();
47
35
  } finally {
48
- this.observeDuration(name, performance.now() - start, tags);
36
+ this.observe(name, performance.now() - start);
49
37
  }
50
38
  }
51
39
 
52
- timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T {
40
+ timeSync<T>(name: string, fn: () => T): T {
53
41
  const start = performance.now();
54
42
  try {
55
43
  return fn();
56
44
  } finally {
57
- this.observeDuration(name, performance.now() - start, tags);
45
+ this.observe(name, performance.now() - start);
58
46
  }
59
47
  }
60
48
 
61
- observe(name: string, value: number, tags?: PerfTags): void {
49
+ observe(name: string, value: number): void {
62
50
  if (!Number.isFinite(value)) return;
63
- this.samples.push({ name, value, tags });
51
+ const values = this.values.get(name);
52
+ if (values) values.push(value);
53
+ else this.values.set(name, [value]);
64
54
  }
65
55
 
66
- counter(name: string, delta = 1, tags?: PerfTags): void {
67
- this.observe(name, delta, tags);
56
+ counter(name: string, delta = 1): void {
57
+ this.observe(name, delta);
68
58
  }
69
59
 
70
60
  snapshot(): PerfSnapshot {
71
- return {
72
- enabled: true,
73
- startedAt: this.startedAt,
74
- samples: this.samples.map((sample) => ({ ...sample, tags: sample.tags ? { ...sample.tags } : undefined })),
75
- aggregates: aggregateSamples(this.samples),
76
- };
77
- }
78
-
79
- private observeDuration(name: string, durationMs: number, tags?: PerfTags): void {
80
- if (!Number.isFinite(durationMs)) return;
81
- this.samples.push({ name, durationMs, tags });
61
+ return { enabled: true, aggregates: [...this.values].map(([name, values]) => aggregate(name, values)) };
82
62
  }
83
63
  }
84
64
 
85
65
  export class NoopPerfRecorder implements PerfSink {
86
- constructor(private readonly startedAt = Date.now()) {}
87
-
88
- async time<T>(_name: string, fn: () => Promise<T>, _tags?: PerfTags): Promise<T> {
66
+ async time<T>(_name: string, fn: () => Promise<T>): Promise<T> {
89
67
  return await fn();
90
68
  }
91
69
 
92
- timeSync<T>(_name: string, fn: () => T, _tags?: PerfTags): T {
70
+ timeSync<T>(_name: string, fn: () => T): T {
93
71
  return fn();
94
72
  }
95
73
 
96
- observe(_name: string, _value: number, _tags?: PerfTags): void {}
74
+ observe(): void {}
97
75
 
98
- counter(_name: string, _delta?: number, _tags?: PerfTags): void {}
76
+ counter(): void {}
99
77
 
100
78
  snapshot(): PerfSnapshot {
101
- return { enabled: false, startedAt: this.startedAt, samples: [], aggregates: [] };
79
+ return { enabled: false, aggregates: [] };
102
80
  }
103
81
  }
104
82
 
105
- export function createPerfRecorder(enabled: boolean, startedAt?: number): PerfSink {
106
- return enabled ? new PerfRecorder(startedAt) : new NoopPerfRecorder(startedAt);
83
+ export function createPerfRecorder(enabled: boolean): PerfSink {
84
+ return enabled ? new PerfRecorder() : new NoopPerfRecorder();
107
85
  }
108
86
 
109
- function aggregateSamples(samples: readonly PerfSample[]): PerfAggregate[] {
110
- const byName = new Map<string, number[]>();
111
- for (const sample of samples) {
112
- const value = sample.durationMs ?? sample.value;
113
- if (value === undefined || !Number.isFinite(value)) continue;
114
- const values = byName.get(sample.name) ?? [];
115
- values.push(value);
116
- byName.set(sample.name, values);
117
- }
87
+ export function formatPerfSummary(aggregates: readonly PerfAggregate[]): string {
88
+ const parts = aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
89
+ return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
90
+ }
118
91
 
119
- return [...byName.entries()].map(([name, rawValues]) => {
120
- const values = [...rawValues].sort((a, b) => a - b);
121
- const total = values.reduce((sum, value) => sum + value, 0);
122
- return {
123
- name,
124
- count: values.length,
125
- total,
126
- min: values[0] ?? 0,
127
- max: values[values.length - 1] ?? 0,
128
- mean: values.length === 0 ? 0 : total / values.length,
129
- p50: percentile(values, 0.5),
130
- p95: percentile(values, 0.95),
131
- };
132
- });
92
+ /** `observed` is never empty: a metric is recorded with its first value. */
93
+ function aggregate(name: string, observed: readonly number[]): PerfAggregate {
94
+ const values = [...observed].sort((a, b) => a - b);
95
+ const total = values.reduce((sum, value) => sum + value, 0);
96
+ return {
97
+ name,
98
+ count: values.length,
99
+ total,
100
+ min: values[0],
101
+ max: values[values.length - 1],
102
+ mean: total / values.length,
103
+ p50: percentile(values, 0.5),
104
+ p95: percentile(values, 0.95),
105
+ };
133
106
  }
134
107
 
135
108
  function percentile(sortedValues: readonly number[], p: number): number {
136
- if (sortedValues.length === 0) return 0;
137
- const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * p) - 1));
138
- return sortedValues[index] ?? 0;
109
+ return sortedValues[Math.ceil(sortedValues.length * p) - 1];
139
110
  }
@@ -1,6 +1,6 @@
1
- const MINIMUM_PI_VERSION = "0.80.10";
1
+ const MINIMUM_PI_VERSION = "1.0.0";
2
2
 
3
- const MINIMUM_PI_CORE = [0, 80, 10] as const;
3
+ const MINIMUM_PI_CORE = [1, 0, 0] as const;
4
4
 
5
5
  /** Fail during extension registration instead of failing later in a workflow. */
6
6
  export function assertSupportedPiVersion(version: string): void {
@@ -1,5 +1,6 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { performance } from "node:perf_hooks";
3
+ import { StringDecoder } from "node:string_decoder";
3
4
  import { unknownErrorMessage } from "./unknown-error.ts";
4
5
 
5
6
  export interface BoundedProcessOptions {
@@ -102,6 +103,13 @@ export async function runBoundedProcess(
102
103
  let stdout = "";
103
104
  let stderr = "";
104
105
  let bytes = 0;
106
+ // Pipe chunks can split a multi-byte UTF-8 sequence; streaming decoders carry the tail forward.
107
+ const stdoutDecoder = new StringDecoder("utf8");
108
+ const stderrDecoder = new StringDecoder("utf8");
109
+ const flushDecoders = () => {
110
+ stdout += stdoutDecoder.end();
111
+ stderr += stderrDecoder.end();
112
+ };
105
113
  let pendingFailure: BoundedProcessFailure | undefined;
106
114
  const child = spawn(options.file, [...options.args], {
107
115
  cwd: options.cwd,
@@ -127,6 +135,7 @@ export async function runBoundedProcess(
127
135
  if (settled) return;
128
136
  settled = true;
129
137
  cleanup();
138
+ flushDecoders();
130
139
  const base = { stdout, stderr, durationMs: performance.now() - start, bytes };
131
140
  if (!failure) {
132
141
  resolve({ ...base, ok: true });
@@ -216,7 +225,7 @@ export async function runBoundedProcess(
216
225
  const onAbort = () => terminate({ kind: "abort", message: options.abortError });
217
226
  const timeout = setTimeout(() => terminate({ kind: "timeout", message: options.timeoutError }), options.timeoutMs);
218
227
 
219
- const captureChunk = (chunk: Buffer, append: (text: string) => void) => {
228
+ const captureChunk = (chunk: Buffer, decoder: StringDecoder, append: (text: string) => void) => {
220
229
  if (pendingFailure) return;
221
230
  bytes += chunk.length;
222
231
  if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
@@ -226,10 +235,10 @@ export async function runBoundedProcess(
226
235
  });
227
236
  return;
228
237
  }
229
- append(chunk.toString("utf8"));
238
+ append(decoder.write(chunk));
230
239
  };
231
- child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stdout += text)));
232
- child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stderr += text)));
240
+ child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, stdoutDecoder, (text) => (stdout += text)));
241
+ child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, stderrDecoder, (text) => (stderr += text)));
233
242
  child.on("error", (spawnError) => {
234
243
  if (!pendingFailure) finish({ kind: "spawn", message: spawnError.message });
235
244
  });
@@ -242,6 +251,7 @@ export async function runBoundedProcess(
242
251
  finish();
243
252
  return;
244
253
  }
254
+ flushDecoders();
245
255
  const message = options.exitError(stderr, code, signal);
246
256
  finish({ kind: "exit", message, code, signal });
247
257
  });
@@ -40,6 +40,8 @@ export interface WorkflowProgressSnapshot {
40
40
  readonly startedAt: number;
41
41
  readonly doneAt?: number;
42
42
  readonly currentPhase: string;
43
+ /** Phase titles the workflow declared in `meta.phases`; live surfaces list the ones not reached yet. */
44
+ readonly plannedPhases?: readonly string[];
43
45
  readonly phases: readonly PhaseSnapshot[];
44
46
  readonly counters: readonly WorkflowCounterSnapshot[];
45
47
  readonly summary: readonly [string, string | number][];
@@ -1,20 +1,16 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
2
3
  import type { WorkflowProgressEvent } from "./types.ts";
3
4
  import type { AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
4
- import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./usage.ts";
5
+ import type { WorkflowUsageSnapshot } from "./usage.ts";
5
6
  import { unknownErrorMessage } from "./unknown-error.ts";
6
7
  import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
7
- import { renderWorkflowWidgetLines } from "./ui/workflow-widget.ts";
8
-
9
- export type {
10
- AgentRowSnapshot,
11
- AgentRowStatus,
12
- PhaseSnapshot,
13
- WorkflowCounterSnapshot,
14
- WorkflowLaneItemSnapshot,
15
- WorkflowLaneItemStatus,
16
- WorkflowProgressSnapshot,
17
- } from "./progress-types.ts";
8
+ import {
9
+ renderBackgroundWorkflowLine,
10
+ renderWorkflowWidget,
11
+ STRING_WIDGET_WIDTH,
12
+ WidthAwareWidget,
13
+ } from "./ui/workflow-widget.ts";
18
14
 
19
15
  interface AgentRow {
20
16
  id: number;
@@ -51,6 +47,16 @@ const LOG_LIMIT = 24;
51
47
  const WIDGET_REFRESH_INTERVAL_MS = 1_000;
52
48
  export const DEFAULT_LANE_ITEM_LIMIT = 200;
53
49
 
50
+ export interface ProgressTrackerOptions {
51
+ /** Phase titles from the workflow's `meta.phases`, shown as upcoming until the run reaches them. */
52
+ readonly plannedPhases?: readonly string[];
53
+ /**
54
+ * `live` (default) draws the full progress widget and a footer status; `background` draws one
55
+ * summary line per run so a run the user sent to the background never takes over the screen.
56
+ */
57
+ readonly display?: "live" | "background";
58
+ }
59
+
54
60
  /**
55
61
  * Tracks live workflow state for widgets, footer/status text, result renderers,
56
62
  * and headless stderr breadcrumbs.
@@ -73,14 +79,24 @@ export class ProgressTracker {
73
79
  private usageSnapshot: WorkflowUsageSnapshot | undefined;
74
80
  private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
75
81
  private readonly surfaceKey: string;
82
+ private readonly plannedPhases: readonly string[] | undefined;
83
+ private readonly display: "live" | "background";
84
+ /** Latest published snapshot; the TUI widget renders it at whatever width the terminal has. */
85
+ private latest: WorkflowProgressSnapshot | undefined;
86
+ /** Set once the TUI has instantiated this run's widget component. */
87
+ private tui: Pick<TUI, "requestRender"> | undefined;
88
+ private widgetRegistered = false;
76
89
 
77
90
  constructor(
78
91
  private readonly ctx: ExtensionContext,
79
92
  private readonly title: string,
80
93
  private readonly runId: string,
81
94
  private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
95
+ options: ProgressTrackerOptions = {},
82
96
  ) {
83
97
  this.surfaceKey = `workflow:${runId}`;
98
+ this.plannedPhases = options.plannedPhases?.length ? [...options.plannedPhases] : undefined;
99
+ this.display = options.display ?? "live";
84
100
  this.ensurePhase(this.currentPhase);
85
101
  }
86
102
 
@@ -152,29 +168,18 @@ export class ProgressTracker {
152
168
  return id;
153
169
  }
154
170
 
155
- agentStart(phase: string | undefined, label: string, id?: number): void {
156
- const row = id === undefined ? undefined : this.findRowById(id);
171
+ agentStart(id: number): void {
172
+ const row = this.rowsById.get(id);
157
173
  if (row) {
158
174
  this.transitionAgentStatus(row, "running");
159
175
  row.startedAt = Date.now();
160
176
  row.error = undefined;
161
- } else {
162
- const nextRow = {
163
- label,
164
- id: this.nextAgentId++,
165
- status: "running" as const,
166
- startedAt: Date.now(),
167
- toolUses: 0,
168
- };
169
- this.ensurePhase(phase ?? this.currentPhase).agents.push(nextRow);
170
- this.rowsById.set(nextRow.id, nextRow);
171
- this.agentCounts.running++;
172
177
  }
173
178
  this.publish();
174
179
  }
175
180
 
176
- agentTool(label: string, tool: string, id?: number): void {
177
- const row = this.findRow(label, id);
181
+ agentTool(id: number, tool: string): void {
182
+ const row = this.rowsById.get(id);
178
183
  if (row) {
179
184
  row.lastTool = tool;
180
185
  row.toolUses += 1;
@@ -182,8 +187,8 @@ export class ProgressTracker {
182
187
  this.publish();
183
188
  }
184
189
 
185
- agentDone(label: string, id?: number): void {
186
- const row = this.findRow(label, id);
190
+ agentDone(id: number): void {
191
+ const row = this.rowsById.get(id);
187
192
  if (row && row.status !== "failed") {
188
193
  this.transitionAgentStatus(row, "done");
189
194
  row.doneAt = Date.now();
@@ -191,8 +196,8 @@ export class ProgressTracker {
191
196
  this.publish();
192
197
  }
193
198
 
194
- agentFailed(label: string, error: unknown, id?: number): void {
195
- const row = this.findRow(label, id);
199
+ agentFailed(id: number, error: unknown): void {
200
+ const row = this.rowsById.get(id);
196
201
  if (row) {
197
202
  this.transitionAgentStatus(row, "failed");
198
203
  row.doneAt = Date.now();
@@ -213,6 +218,7 @@ export class ProgressTracker {
213
218
  startedAt: this.startedAt,
214
219
  doneAt: this.doneAt,
215
220
  currentPhase: this.currentPhase,
221
+ plannedPhases: this.plannedPhases,
216
222
  phases: this.phases.map((phase) => ({
217
223
  title: phase.title,
218
224
  agents: phase.agents.map((agent) => ({ ...agent })),
@@ -227,10 +233,6 @@ export class ProgressTracker {
227
233
  }
228
234
 
229
235
  statusCounts(): WorkflowStatusCounts {
230
- return this.statusCountsSnapshot();
231
- }
232
-
233
- private statusCountsSnapshot(): WorkflowStatusCounts {
234
236
  return {
235
237
  queued: this.agentCounts.queued,
236
238
  running: this.agentCounts.running,
@@ -248,51 +250,59 @@ export class ProgressTracker {
248
250
  }
249
251
 
250
252
  private pruneLane(laneName: string, lane: WorkflowLaneItem[]): void {
251
- if (this.laneItemLimit <= 0) return;
252
253
  while (lane.length > this.laneItemLimit) {
253
254
  lane.shift();
254
255
  this.laneOverflow.set(laneName, (this.laneOverflow.get(laneName) ?? 0) + 1);
255
256
  }
256
257
  }
257
258
 
258
- private findRow(label: string, id?: number): AgentRow | undefined {
259
- if (id !== undefined) return this.findRowById(id);
260
- for (let i = this.phases.length - 1; i >= 0; i--) {
261
- const running = this.phases[i].agents.find(
262
- (agent) => agent.label === label && (agent.status === "running" || agent.status === "queued"),
263
- );
264
- if (running) return running;
265
- }
266
- for (let i = this.phases.length - 1; i >= 0; i--) {
267
- const matching = this.phases[i].agents.find((agent) => agent.label === label);
268
- if (matching) return matching;
269
- }
270
- return undefined;
271
- }
272
-
273
- private findRowById(id: number): AgentRow | undefined {
274
- return this.rowsById.get(id);
275
- }
276
-
277
259
  private publish(): void {
278
- this.publishSnapshot();
279
- if (!this.ctx.hasUI) return;
280
- this.publishWidget();
260
+ const snapshot = this.snapshot();
261
+ this.onSnapshot?.(snapshot);
262
+ // Agents that outlive a fatal drain still report after done(); record them without reviving live surfaces.
263
+ if (!this.ctx.hasUI || this.doneAt !== undefined) return;
264
+ this.latest = snapshot;
265
+ this.publishWidget(snapshot);
281
266
  this.startWidgetRefresh();
282
- this.publishStatus();
267
+ this.publishStatus(snapshot);
283
268
  }
284
269
 
285
- private publishWidget(): void {
270
+ /**
271
+ * In the TUI the widget is a width-aware component registered once and redrawn on demand; surfaces
272
+ * that only take strings (RPC) get lines pre-rendered at a nominal width on every publish.
273
+ */
274
+ private publishWidget(snapshot: WorkflowProgressSnapshot): void {
275
+ if (this.ctx.mode !== "tui") {
276
+ this.ctx.ui.setWidget(this.surfaceKey, this.renderWidget(snapshot, STRING_WIDGET_WIDTH, this.ctx.ui.theme), { placement: "aboveEditor" });
277
+ return;
278
+ }
279
+ if (this.widgetRegistered) {
280
+ this.tui?.requestRender();
281
+ return;
282
+ }
283
+ this.widgetRegistered = true;
286
284
  this.ctx.ui.setWidget(
287
285
  this.surfaceKey,
288
- renderWorkflowWidgetLines(this.snapshot(), this.ctx.ui.theme),
286
+ (tui, theme) => {
287
+ this.tui = tui;
288
+ return new WidthAwareWidget((width) => (this.latest ? this.renderWidget(this.latest, width, theme) : []));
289
+ },
289
290
  { placement: "aboveEditor" },
290
291
  );
291
292
  }
292
293
 
294
+ private renderWidget(snapshot: WorkflowProgressSnapshot, width: number, theme: ExtensionContext["ui"]["theme"]): string[] {
295
+ return this.display === "background" ? renderBackgroundWorkflowLine(snapshot, width, theme) : renderWorkflowWidget(snapshot, width, theme);
296
+ }
297
+
298
+ /** Elapsed time ticks even when no agent reports, so redraw the widget and status once a second. */
293
299
  private startWidgetRefresh(): void {
294
300
  if (this.widgetRefreshInterval !== undefined) return;
295
- this.widgetRefreshInterval = setInterval(() => this.publishWidget(), WIDGET_REFRESH_INTERVAL_MS);
301
+ this.widgetRefreshInterval = setInterval(() => {
302
+ if (!this.latest) return;
303
+ this.publishWidget(this.latest);
304
+ this.publishStatus(this.latest);
305
+ }, WIDGET_REFRESH_INTERVAL_MS);
296
306
  }
297
307
 
298
308
  private stopWidgetRefresh(): void {
@@ -301,19 +311,10 @@ export class ProgressTracker {
301
311
  this.widgetRefreshInterval = undefined;
302
312
  }
303
313
 
304
- private publishStatus(): void {
305
- const status = statusTextFromCounts(
306
- {
307
- title: this.title,
308
- doneAt: this.doneAt,
309
- currentPhase: this.currentPhase,
310
- counters: [...this.counters.values()].map((counter) => ({ ...counter })),
311
- },
312
- this.statusCountsSnapshot(),
313
- this.ctx.ui.theme,
314
- );
315
- const usage = formatWorkflowUsageLine(this.usageSnapshot);
316
- const next = usage ? `${status} · ${usage}` : status;
314
+ /** Background runs are summarised by their widget line alone. */
315
+ private publishStatus(snapshot: WorkflowProgressSnapshot): void {
316
+ if (this.display === "background") return;
317
+ const next = statusTextFromCounts(snapshot, this.statusCounts(), this.ctx.ui.theme);
317
318
  if (next === this.lastStatusText) return;
318
319
  this.ctx.ui.setStatus(this.surfaceKey, next);
319
320
  this.lastStatusText = next;
@@ -323,15 +324,13 @@ export class ProgressTracker {
323
324
  done(): void {
324
325
  this.doneAt = Date.now();
325
326
  this.stopWidgetRefresh();
326
- this.publishSnapshot();
327
+ this.onSnapshot?.(this.snapshot());
327
328
  if (!this.ctx.hasUI) return;
328
329
  this.ctx.ui.setWidget(this.surfaceKey, undefined);
329
330
  this.ctx.ui.setStatus(this.surfaceKey, undefined);
330
331
  this.lastStatusText = undefined;
331
- }
332
-
333
- private publishSnapshot(): void {
334
- this.onSnapshot?.(this.snapshot());
332
+ this.latest = undefined;
333
+ this.tui = undefined;
335
334
  }
336
335
  }
337
336