pi-workflow-engine 0.10.3 → 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 (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  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 +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -1,11 +1,13 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { performance } from "node:perf_hooks";
3
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
4
 
4
5
  export interface BoundedProcessOptions {
5
6
  readonly file: string;
6
7
  readonly args: readonly string[];
7
8
  readonly cwd: string;
8
9
  readonly env?: NodeJS.ProcessEnv;
10
+ readonly stdin?: string | Uint8Array;
9
11
  readonly signal?: AbortSignal;
10
12
  readonly timeoutMs: number;
11
13
  readonly maxBufferBytes?: number;
@@ -16,81 +18,243 @@ export interface BoundedProcessOptions {
16
18
  readonly exitError: (stderr: string, code: number | null, signal: NodeJS.Signals | null) => string;
17
19
  }
18
20
 
19
- export interface BoundedProcessResult {
20
- readonly ok: boolean;
21
+ interface BoundedProcessResultBase {
21
22
  readonly stdout: string;
22
23
  readonly stderr: string;
23
24
  readonly durationMs: number;
24
25
  readonly bytes: number;
25
- readonly error?: string;
26
26
  }
27
27
 
28
- export async function runBoundedProcess(options: BoundedProcessOptions): Promise<BoundedProcessResult> {
28
+ export interface UnconfirmedProcessTermination {
29
+ readonly status: "unconfirmed";
30
+ readonly reason: string;
31
+ }
32
+
33
+ export type BoundedProcessFailure =
34
+ | { readonly kind: "spawn" | "abort" | "timeout" | "max-buffer"; readonly message: string }
35
+ | {
36
+ readonly kind: "exit";
37
+ readonly message: string;
38
+ readonly code: number | null;
39
+ readonly signal: NodeJS.Signals | null;
40
+ };
41
+
42
+ export type BoundedProcessResult =
43
+ | (BoundedProcessResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
44
+ | (BoundedProcessResultBase & {
45
+ readonly ok: false;
46
+ /** Backward-compatible display message. Branch on `failure.kind` for control flow. */
47
+ readonly error: string;
48
+ readonly failure: BoundedProcessFailure;
49
+ /** Present only when the runner's final bound elapsed without observing child closure. */
50
+ readonly termination?: UnconfirmedProcessTermination;
51
+ });
52
+
53
+ export type WindowsTaskkillResult =
54
+ | { readonly ok: true }
55
+ | { readonly ok: false; readonly reason: string };
56
+
57
+ export interface ProcessRunnerDependencies {
58
+ readonly platform: NodeJS.Platform;
59
+ readonly runWindowsTaskkill: (pid: number, complete: (result: WindowsTaskkillResult) => void) => void;
60
+ readonly killChild: (child: ChildProcess, signal: NodeJS.Signals) => boolean;
61
+ }
62
+
63
+ const NODE_PROCESS_RUNNER_DEPENDENCIES: ProcessRunnerDependencies = {
64
+ platform: process.platform,
65
+ runWindowsTaskkill(pid, complete) {
66
+ let completed = false;
67
+ const finish = (result: WindowsTaskkillResult) => {
68
+ if (completed) return;
69
+ completed = true;
70
+ complete(result);
71
+ };
72
+ try {
73
+ const killer = spawn("taskkill.exe", ["/pid", String(pid), "/t", "/f"], {
74
+ detached: false,
75
+ stdio: "ignore",
76
+ windowsHide: true,
77
+ });
78
+ killer.once("error", (error) => finish({ ok: false, reason: `taskkill failed to start: ${unknownErrorMessage(error)}` }));
79
+ killer.once("close", (code, signal) => {
80
+ if (code === 0) {
81
+ finish({ ok: true });
82
+ return;
83
+ }
84
+ finish({
85
+ ok: false,
86
+ reason: `taskkill exited with ${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}`,
87
+ });
88
+ });
89
+ killer.unref();
90
+ } catch (error) {
91
+ finish({ ok: false, reason: `taskkill could not be started: ${unknownErrorMessage(error)}` });
92
+ }
93
+ },
94
+ killChild: (child, signal) => child.kill(signal),
95
+ };
96
+
97
+ export async function runBoundedProcess(
98
+ options: BoundedProcessOptions,
99
+ dependencies: ProcessRunnerDependencies = NODE_PROCESS_RUNNER_DEPENDENCIES,
100
+ ): Promise<BoundedProcessResult> {
29
101
  const start = performance.now();
30
102
  let stdout = "";
31
103
  let stderr = "";
32
104
  let bytes = 0;
33
- let error: string | undefined;
105
+ let pendingFailure: BoundedProcessFailure | undefined;
34
106
  const child = spawn(options.file, [...options.args], {
35
107
  cwd: options.cwd,
36
108
  env: options.env ?? process.env,
37
- stdio: ["ignore", "pipe", "pipe"],
109
+ detached: dependencies.platform !== "win32",
110
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
38
111
  });
39
112
 
40
113
  return await new Promise<BoundedProcessResult>((resolve) => {
41
114
  let settled = false;
42
115
  let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
116
+ let hardSettleTimer: ReturnType<typeof setTimeout> | undefined;
117
+ let directChildKillAttempted = false;
118
+ let windowsTerminationReason = "process close was not observed after taskkill and direct-child fallback";
43
119
  const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
44
- const finish = (ok: boolean, finishError?: string) => {
120
+ const cleanup = () => {
121
+ clearTimeout(timeout);
122
+ if (forceKillTimer) clearTimeout(forceKillTimer);
123
+ if (hardSettleTimer) clearTimeout(hardSettleTimer);
124
+ options.signal?.removeEventListener("abort", onAbort);
125
+ };
126
+ const finish = (failure?: BoundedProcessFailure, termination?: UnconfirmedProcessTermination) => {
45
127
  if (settled) return;
46
128
  settled = true;
47
129
  cleanup();
48
- resolve({ ok, stdout, stderr, durationMs: performance.now() - start, bytes, error: finishError });
130
+ const base = { stdout, stderr, durationMs: performance.now() - start, bytes };
131
+ if (!failure) {
132
+ resolve({ ...base, ok: true });
133
+ return;
134
+ }
135
+ resolve({ ...base, ok: false, error: failure.message, failure, ...(termination ? { termination } : {}) });
49
136
  };
50
- const kill = (message: string) => {
51
- error = error ?? message;
52
- child.kill("SIGTERM");
53
- forceKillTimer ??= setTimeout(() => {
54
- child.kill("SIGKILL");
55
- finish(false, error);
56
- }, killGraceMs);
137
+ const killDirectChild = (signal: NodeJS.Signals, reason: string) => {
138
+ if (directChildKillAttempted) return;
139
+ directChildKillAttempted = true;
140
+ windowsTerminationReason = reason;
141
+ try {
142
+ if (!dependencies.killChild(child, signal)) {
143
+ windowsTerminationReason = `${reason}; direct-child termination was not accepted`;
144
+ }
145
+ } catch (error) {
146
+ windowsTerminationReason = `${reason}; direct-child termination failed: ${unknownErrorMessage(error)}`;
147
+ }
57
148
  };
58
- const onAbort = () => kill(options.abortError);
59
- const timeout = setTimeout(() => kill(options.timeoutError), options.timeoutMs);
60
- const cleanup = () => {
61
- clearTimeout(timeout);
62
- if (forceKillTimer) clearTimeout(forceKillTimer);
63
- options.signal?.removeEventListener("abort", onAbort);
149
+ const signalPosixProcess = (signal: NodeJS.Signals) => {
150
+ let groupSignalled = false;
151
+ if (child.pid !== undefined) {
152
+ try {
153
+ process.kill(-child.pid, signal);
154
+ groupSignalled = true;
155
+ } catch {
156
+ // The process may have exited before its descendants released stdio.
157
+ // Fall back to the direct child below.
158
+ }
159
+ }
160
+ if (!groupSignalled) {
161
+ try {
162
+ dependencies.killChild(child, signal);
163
+ } catch {
164
+ // The hard-settlement timer remains authoritative when signalling fails.
165
+ }
166
+ }
64
167
  };
168
+ const destroyStdio = () => {
169
+ child.stdin?.destroy();
170
+ child.stdout?.destroy();
171
+ child.stderr?.destroy();
172
+ };
173
+ const terminate = (failure: BoundedProcessFailure) => {
174
+ pendingFailure ??= failure;
175
+ if (forceKillTimer) return;
176
+ if (dependencies.platform === "win32") {
177
+ if (child.pid === undefined) {
178
+ killDirectChild("SIGKILL", "taskkill was unavailable because the child process had no pid");
179
+ } else {
180
+ try {
181
+ dependencies.runWindowsTaskkill(child.pid, (result) => {
182
+ if (settled || result.ok) return;
183
+ killDirectChild("SIGKILL", result.reason);
184
+ });
185
+ } catch (error) {
186
+ killDirectChild("SIGKILL", `taskkill invocation failed: ${unknownErrorMessage(error)}`);
187
+ }
188
+ }
189
+ } else {
190
+ signalPosixProcess("SIGTERM");
191
+ }
192
+ forceKillTimer = setTimeout(() => {
193
+ if (dependencies.platform === "win32") {
194
+ if (directChildKillAttempted) {
195
+ windowsTerminationReason = `${windowsTerminationReason}; child did not close within the termination grace period`;
196
+ } else {
197
+ killDirectChild("SIGKILL", "taskkill did not close the child within the termination grace period");
198
+ }
199
+ } else {
200
+ signalPosixProcess("SIGKILL");
201
+ }
202
+ destroyStdio();
203
+ if (!settled) {
204
+ hardSettleTimer = setTimeout(() => {
205
+ if (!pendingFailure) return;
206
+ finish(
207
+ pendingFailure,
208
+ { status: "unconfirmed", reason: dependencies.platform === "win32"
209
+ ? windowsTerminationReason
210
+ : "process close was not observed after forced process-group termination" },
211
+ );
212
+ }, killGraceMs);
213
+ }
214
+ }, killGraceMs);
215
+ };
216
+ const onAbort = () => terminate({ kind: "abort", message: options.abortError });
217
+ const timeout = setTimeout(() => terminate({ kind: "timeout", message: options.timeoutError }), options.timeoutMs);
65
218
 
66
- if (options.signal?.aborted) {
67
- kill(options.abortError);
68
- } else {
69
- options.signal?.addEventListener("abort", onAbort, { once: true });
70
- }
71
-
72
- child.stdout?.on("data", (chunk: Buffer) => {
219
+ const captureChunk = (chunk: Buffer, append: (text: string) => void) => {
220
+ if (pendingFailure) return;
73
221
  bytes += chunk.length;
74
222
  if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
75
- kill(options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`);
223
+ terminate({
224
+ kind: "max-buffer",
225
+ message: options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`,
226
+ });
76
227
  return;
77
228
  }
78
- stdout += chunk.toString("utf8");
79
- });
80
- child.stderr?.on("data", (chunk: Buffer) => {
81
- stderr += chunk.toString("utf8");
229
+ append(chunk.toString("utf8"));
230
+ };
231
+ child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stdout += text)));
232
+ child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stderr += text)));
233
+ child.on("error", (spawnError) => {
234
+ if (!pendingFailure) finish({ kind: "spawn", message: spawnError.message });
82
235
  });
83
- child.on("error", (spawnError) => finish(false, spawnError.message));
84
236
  child.on("close", (code, signal) => {
85
- if (error) {
86
- finish(false, error);
237
+ if (pendingFailure) {
238
+ finish(pendingFailure);
87
239
  return;
88
240
  }
89
241
  if (code === 0) {
90
- finish(true);
242
+ finish();
91
243
  return;
92
244
  }
93
- finish(false, options.exitError(stderr, code, signal));
245
+ const message = options.exitError(stderr, code, signal);
246
+ finish({ kind: "exit", message, code, signal });
94
247
  });
248
+ if (options.signal?.aborted) {
249
+ onAbort();
250
+ } else {
251
+ options.signal?.addEventListener("abort", onAbort, { once: true });
252
+ }
253
+ if (options.stdin !== undefined) {
254
+ // The child may reject input and exit before consuming it. Its process exit
255
+ // remains the authoritative result; avoid surfacing a secondary EPIPE.
256
+ child.stdin?.on("error", () => undefined);
257
+ child.stdin?.end(options.stdin);
258
+ }
95
259
  });
96
260
  }
@@ -0,0 +1,50 @@
1
+ import type { WorkflowUsageSnapshot } from "./usage.ts";
2
+
3
+ export type AgentRowStatus = "queued" | "running" | "done" | "failed";
4
+ export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
5
+
6
+ export interface AgentRowSnapshot {
7
+ readonly id: number;
8
+ readonly label: string;
9
+ readonly status: AgentRowStatus;
10
+ readonly startedAt?: number;
11
+ readonly doneAt?: number;
12
+ readonly toolUses: number;
13
+ readonly lastTool?: string;
14
+ readonly error?: string;
15
+ }
16
+
17
+ export interface PhaseSnapshot {
18
+ readonly title: string;
19
+ readonly agents: readonly AgentRowSnapshot[];
20
+ }
21
+
22
+ export interface WorkflowCounterSnapshot {
23
+ readonly key: string;
24
+ readonly label: string;
25
+ readonly value: number;
26
+ }
27
+
28
+ export interface WorkflowLaneItemSnapshot {
29
+ readonly lane: string;
30
+ readonly title: string;
31
+ readonly subtitle?: string;
32
+ readonly status: WorkflowLaneItemStatus;
33
+ readonly details?: string;
34
+ readonly createdAt: number;
35
+ }
36
+
37
+ export interface WorkflowProgressSnapshot {
38
+ readonly runId: string;
39
+ readonly title: string;
40
+ readonly startedAt: number;
41
+ readonly doneAt?: number;
42
+ readonly currentPhase: string;
43
+ readonly phases: readonly PhaseSnapshot[];
44
+ readonly counters: readonly WorkflowCounterSnapshot[];
45
+ readonly summary: readonly [string, string | number][];
46
+ readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
47
+ readonly laneOverflow: readonly [string, number][];
48
+ readonly logs: readonly string[];
49
+ readonly usage?: WorkflowUsageSnapshot;
50
+ }
@@ -1,54 +1,20 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { TUI } from "@earendil-works/pi-tui";
3
- import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
2
+ import type { WorkflowProgressEvent } from "./types.ts";
3
+ import type { AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
4
+ import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./usage.ts";
5
+ import { unknownErrorMessage } from "./unknown-error.ts";
4
6
  import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
5
- import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
6
-
7
- export type AgentRowStatus = "queued" | "running" | "done" | "failed";
8
-
9
- export interface AgentRowSnapshot {
10
- readonly id: number;
11
- readonly label: string;
12
- readonly status: AgentRowStatus;
13
- readonly startedAt?: number;
14
- readonly doneAt?: number;
15
- readonly toolUses: number;
16
- readonly lastTool?: string;
17
- readonly error?: string;
18
- }
19
-
20
- export interface PhaseSnapshot {
21
- readonly title: string;
22
- readonly agents: readonly AgentRowSnapshot[];
23
- }
24
-
25
- export interface WorkflowCounterSnapshot {
26
- readonly key: string;
27
- readonly label: string;
28
- readonly value: number;
29
- }
30
-
31
- export interface WorkflowLaneItemSnapshot {
32
- readonly lane: string;
33
- readonly title: string;
34
- readonly subtitle?: string;
35
- readonly status: WorkflowLaneItemStatus;
36
- readonly details?: string;
37
- readonly createdAt: number;
38
- }
39
-
40
- export interface WorkflowProgressSnapshot {
41
- readonly title: string;
42
- readonly startedAt: number;
43
- readonly doneAt?: number;
44
- readonly currentPhase: string;
45
- readonly phases: readonly PhaseSnapshot[];
46
- readonly counters: readonly WorkflowCounterSnapshot[];
47
- readonly summary: readonly [string, string | number][];
48
- readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
49
- readonly laneOverflow: readonly [string, number][];
50
- readonly logs: readonly string[];
51
- }
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";
52
18
 
53
19
  interface AgentRow {
54
20
  id: number;
@@ -82,6 +48,7 @@ interface WorkflowLaneItem {
82
48
  }
83
49
 
84
50
  const LOG_LIMIT = 24;
51
+ const WIDGET_REFRESH_INTERVAL_MS = 1_000;
85
52
  export const DEFAULT_LANE_ITEM_LIMIT = 200;
86
53
 
87
54
  /**
@@ -102,17 +69,18 @@ export class ProgressTracker {
102
69
  private doneAt: number | undefined;
103
70
  private currentPhase = "Workflow";
104
71
  private nextAgentId = 1;
105
- private widget: WorkflowWidget | undefined;
106
- private widgetRegistered = false;
107
- private tui: Pick<TUI, "requestRender"> | undefined;
108
- private widgetInterval: ReturnType<typeof setInterval> | undefined;
109
72
  private lastStatusText: string | undefined;
110
- private renderQueued = false;
73
+ private usageSnapshot: WorkflowUsageSnapshot | undefined;
74
+ private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
75
+ private readonly surfaceKey: string;
111
76
 
112
77
  constructor(
113
78
  private readonly ctx: ExtensionContext,
114
79
  private readonly title: string,
80
+ private readonly runId: string,
81
+ private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
115
82
  ) {
83
+ this.surfaceKey = `workflow:${runId}`;
116
84
  this.ensurePhase(this.currentPhase);
117
85
  }
118
86
 
@@ -129,14 +97,14 @@ export class ProgressTracker {
129
97
  this.currentPhase = title;
130
98
  this.ensurePhase(title);
131
99
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${title}\n`);
132
- this.render();
100
+ this.publish();
133
101
  }
134
102
 
135
103
  log(message: string): void {
136
104
  this.logs.push(message);
137
105
  while (this.logs.length > LOG_LIMIT) this.logs.shift();
138
106
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${message}\n`);
139
- this.render();
107
+ this.publish();
140
108
  }
141
109
 
142
110
  event(event: WorkflowProgressEvent): void {
@@ -171,7 +139,7 @@ export class ProgressTracker {
171
139
  this.summary.set(event.key, event.value);
172
140
  break;
173
141
  }
174
- this.render();
142
+ this.publish();
175
143
  }
176
144
 
177
145
  agentQueued(phase: string | undefined, label: string): number {
@@ -180,7 +148,7 @@ export class ProgressTracker {
180
148
  this.ensurePhase(phase ?? this.currentPhase).agents.push(row);
181
149
  this.rowsById.set(id, row);
182
150
  this.agentCounts.queued++;
183
- this.render();
151
+ this.publish();
184
152
  return id;
185
153
  }
186
154
 
@@ -202,7 +170,7 @@ export class ProgressTracker {
202
170
  this.rowsById.set(nextRow.id, nextRow);
203
171
  this.agentCounts.running++;
204
172
  }
205
- this.render();
173
+ this.publish();
206
174
  }
207
175
 
208
176
  agentTool(label: string, tool: string, id?: number): void {
@@ -211,7 +179,7 @@ export class ProgressTracker {
211
179
  row.lastTool = tool;
212
180
  row.toolUses += 1;
213
181
  }
214
- this.render();
182
+ this.publish();
215
183
  }
216
184
 
217
185
  agentDone(label: string, id?: number): void {
@@ -220,7 +188,7 @@ export class ProgressTracker {
220
188
  this.transitionAgentStatus(row, "done");
221
189
  row.doneAt = Date.now();
222
190
  }
223
- this.render();
191
+ this.publish();
224
192
  }
225
193
 
226
194
  agentFailed(label: string, error: unknown, id?: number): void {
@@ -228,13 +196,19 @@ export class ProgressTracker {
228
196
  if (row) {
229
197
  this.transitionAgentStatus(row, "failed");
230
198
  row.doneAt = Date.now();
231
- row.error = errorMessage(error);
199
+ row.error = unknownErrorMessage(error);
232
200
  }
233
- this.render();
201
+ this.publish();
202
+ }
203
+
204
+ updateUsage(snapshot: WorkflowUsageSnapshot): void {
205
+ this.usageSnapshot = snapshot;
206
+ this.publish();
234
207
  }
235
208
 
236
209
  snapshot(): WorkflowProgressSnapshot {
237
210
  return {
211
+ runId: this.runId,
238
212
  title: this.title,
239
213
  startedAt: this.startedAt,
240
214
  doneAt: this.doneAt,
@@ -248,6 +222,7 @@ export class ProgressTracker {
248
222
  lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
249
223
  laneOverflow: [...this.laneOverflow.entries()],
250
224
  logs: [...this.logs],
225
+ usage: this.usageSnapshot,
251
226
  };
252
227
  }
253
228
 
@@ -299,25 +274,35 @@ export class ProgressTracker {
299
274
  return this.rowsById.get(id);
300
275
  }
301
276
 
302
- private render(): void {
277
+ private publish(): void {
278
+ this.publishSnapshot();
303
279
  if (!this.ctx.hasUI) return;
304
- this.ensureWidget();
305
- this.widget?.invalidate();
306
- this.requestRenderSoon();
280
+ this.publishWidget();
281
+ this.startWidgetRefresh();
307
282
  this.publishStatus();
308
283
  }
309
284
 
310
- private requestRenderSoon(): void {
311
- if (this.renderQueued) return;
312
- this.renderQueued = true;
313
- queueMicrotask(() => {
314
- this.renderQueued = false;
315
- this.tui?.requestRender();
316
- });
285
+ private publishWidget(): void {
286
+ this.ctx.ui.setWidget(
287
+ this.surfaceKey,
288
+ renderWorkflowWidgetLines(this.snapshot(), this.ctx.ui.theme),
289
+ { placement: "aboveEditor" },
290
+ );
291
+ }
292
+
293
+ private startWidgetRefresh(): void {
294
+ if (this.widgetRefreshInterval !== undefined) return;
295
+ this.widgetRefreshInterval = setInterval(() => this.publishWidget(), WIDGET_REFRESH_INTERVAL_MS);
296
+ }
297
+
298
+ private stopWidgetRefresh(): void {
299
+ if (this.widgetRefreshInterval === undefined) return;
300
+ clearInterval(this.widgetRefreshInterval);
301
+ this.widgetRefreshInterval = undefined;
317
302
  }
318
303
 
319
304
  private publishStatus(): void {
320
- const next = statusTextFromCounts(
305
+ const status = statusTextFromCounts(
321
306
  {
322
307
  title: this.title,
323
308
  doneAt: this.doneAt,
@@ -327,55 +312,26 @@ export class ProgressTracker {
327
312
  this.statusCountsSnapshot(),
328
313
  this.ctx.ui.theme,
329
314
  );
315
+ const usage = formatWorkflowUsageLine(this.usageSnapshot);
316
+ const next = usage ? `${status} · ${usage}` : status;
330
317
  if (next === this.lastStatusText) return;
331
- this.ctx.ui.setStatus("workflow", next);
318
+ this.ctx.ui.setStatus(this.surfaceKey, next);
332
319
  this.lastStatusText = next;
333
320
  }
334
321
 
335
- private ensureWidget(): void {
336
- if (this.widgetRegistered) return;
337
- this.widget = createWorkflowWidget(() => this.snapshot());
338
- this.ctx.ui.setWidget(
339
- "workflow",
340
- (tui, theme) => {
341
- this.tui = tui;
342
- return {
343
- render: (width?: number) => this.widget?.render(width ?? tui.terminal.columns, theme) ?? [],
344
- invalidate: () => this.widget?.invalidate(),
345
- };
346
- },
347
- { placement: "aboveEditor" },
348
- );
349
- this.widgetRegistered = true;
350
- this.startWidgetTimer();
351
- }
352
-
353
- private startWidgetTimer(): void {
354
- if (this.widgetInterval !== undefined) return;
355
- this.widgetInterval = setInterval(() => {
356
- this.widget?.nextFrame();
357
- this.tui?.requestRender();
358
- }, 100);
359
- }
360
-
361
- private stopWidgetTimer(): void {
362
- if (this.widgetInterval === undefined) return;
363
- clearInterval(this.widgetInterval);
364
- this.widgetInterval = undefined;
365
- }
366
-
367
- /** Clear live workflow surfaces; optionally leave a one-line final status. */
368
- done(status?: string): void {
322
+ /** Clear this run's live workflow surfaces. Final feedback is delivered by the result surface. */
323
+ done(): void {
369
324
  this.doneAt = Date.now();
370
- this.stopWidgetTimer();
325
+ this.stopWidgetRefresh();
326
+ this.publishSnapshot();
371
327
  if (!this.ctx.hasUI) return;
372
- this.ctx.ui.setWidget("workflow", undefined);
373
- this.ctx.ui.setStatus("workflow", status);
374
- this.lastStatusText = status;
375
- this.renderQueued = false;
376
- this.widgetRegistered = false;
377
- this.widget = undefined;
378
- this.tui = undefined;
328
+ this.ctx.ui.setWidget(this.surfaceKey, undefined);
329
+ this.ctx.ui.setStatus(this.surfaceKey, undefined);
330
+ this.lastStatusText = undefined;
331
+ }
332
+
333
+ private publishSnapshot(): void {
334
+ this.onSnapshot?.(this.snapshot());
379
335
  }
380
336
  }
381
337
 
@@ -384,7 +340,3 @@ function laneItemLimitFromEnv(): number {
384
340
  if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_LANE_ITEM_LIMIT;
385
341
  return Math.trunc(parsed);
386
342
  }
387
-
388
- function errorMessage(error: unknown): string {
389
- return error instanceof Error ? error.message : String(error);
390
- }