@wichayutdew/pi-workflows 0.3.0 → 1.0.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 (142) hide show
  1. package/README.md +53 -39
  2. package/dist/index.js +5754 -4794
  3. package/package.json +1 -1
  4. package/src/command-names.ts +7 -1
  5. package/src/commands.ts +126 -52
  6. package/src/config/catalog.ts +162 -0
  7. package/src/config/ceiling.ts +116 -116
  8. package/src/config/command-conflicts.ts +4 -4
  9. package/src/config/diagnostics.ts +28 -0
  10. package/src/config/load-settings.ts +37 -0
  11. package/src/config/load-types.ts +53 -0
  12. package/src/config/load-workflows.ts +135 -0
  13. package/src/config/load.ts +43 -311
  14. package/src/config/types.ts +137 -135
  15. package/src/config/validate.ts +12 -1261
  16. package/src/config/validation/permissions.ts +291 -0
  17. package/src/config/validation/prompt.ts +19 -0
  18. package/src/config/validation/settings.ts +122 -0
  19. package/src/config/validation/shared.ts +129 -0
  20. package/src/config/validation/shortcut.ts +118 -0
  21. package/src/config/validation/step.ts +228 -0
  22. package/src/config/validation/subagent.ts +288 -0
  23. package/src/config/validation/workflow.ts +156 -0
  24. package/src/config/yaml.ts +29 -0
  25. package/src/digest.ts +38 -6
  26. package/src/engine/checkpoint.ts +13 -9
  27. package/src/engine/create-run.ts +42 -0
  28. package/src/engine/gate-transitions.ts +184 -0
  29. package/src/engine/reconciliation-history.ts +88 -0
  30. package/src/engine/resume.ts +19 -7
  31. package/src/engine/run-advance.ts +122 -0
  32. package/src/engine/run-lifecycle.ts +124 -0
  33. package/src/engine/run-reconciliation.ts +116 -0
  34. package/src/engine/run-validation.ts +128 -0
  35. package/src/engine/state-types.ts +64 -0
  36. package/src/engine/state.ts +10 -193
  37. package/src/engine/transition-helpers.ts +28 -0
  38. package/src/engine/transition-types.ts +8 -0
  39. package/src/engine/transitions.ts +17 -471
  40. package/src/harness/action-context.ts +181 -0
  41. package/src/harness/catalog.ts +45 -0
  42. package/src/harness/context-idle.ts +21 -0
  43. package/src/harness/core-actions.ts +299 -0
  44. package/src/harness/delegation-control-actions.ts +250 -0
  45. package/src/harness/delegation-failure.ts +215 -0
  46. package/src/harness/delegation-plan.ts +219 -0
  47. package/src/harness/delegation-recovery-validation.ts +159 -0
  48. package/src/harness/delegation-response-actions.ts +295 -0
  49. package/src/harness/delegation-retry-policy.ts +118 -0
  50. package/src/harness/dependencies.ts +165 -0
  51. package/src/harness/gate-submission-action.ts +154 -0
  52. package/src/harness/lifecycle-actions.ts +154 -0
  53. package/src/harness/pause-actions.ts +120 -0
  54. package/src/harness/plannotator-result-actions.ts +123 -0
  55. package/src/harness/prompt-gate-actions.ts +270 -0
  56. package/src/harness/resume-action.ts +261 -0
  57. package/src/harness/start-actions.ts +183 -0
  58. package/src/harness/status-actions.ts +191 -0
  59. package/src/harness/step-execution-actions.ts +283 -0
  60. package/src/harness/types.ts +76 -0
  61. package/src/harness.ts +236 -2303
  62. package/src/index.ts +73 -11
  63. package/src/integrations/plannotator-requests.ts +127 -0
  64. package/src/integrations/plannotator-responses.ts +141 -0
  65. package/src/integrations/plannotator-types.ts +44 -0
  66. package/src/integrations/plannotator.ts +16 -235
  67. package/src/integrations/prompt-gate.ts +17 -10
  68. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  69. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  70. package/src/integrations/subagents/child-policy-sections.ts +174 -0
  71. package/src/integrations/subagents/child-policy-types.ts +32 -0
  72. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  73. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  74. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  75. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  76. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  77. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  78. package/src/integrations/subagents/child-runtime.ts +140 -301
  79. package/src/integrations/subagents/client-delegation.ts +181 -0
  80. package/src/integrations/subagents/client-messages.ts +66 -0
  81. package/src/integrations/subagents/client-types.ts +36 -0
  82. package/src/integrations/subagents/client.ts +127 -219
  83. package/src/integrations/subagents/delegated-result.ts +31 -0
  84. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  85. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  86. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  87. package/src/integrations/subagents/diagnostics.ts +24 -977
  88. package/src/integrations/subagents/failure-correlation.ts +235 -0
  89. package/src/integrations/subagents/failure-transcript.ts +192 -0
  90. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  91. package/src/integrations/subagents/protocol-events.ts +27 -0
  92. package/src/integrations/subagents/protocol.ts +30 -375
  93. package/src/integrations/subagents/replay-audit.ts +147 -0
  94. package/src/integrations/subagents/replay-safety.ts +82 -0
  95. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  96. package/src/policy/approved-command-extraction.ts +71 -0
  97. package/src/policy/approved-commands.ts +15 -432
  98. package/src/policy/bash-authorization.ts +86 -0
  99. package/src/policy/bash-read-only.ts +161 -0
  100. package/src/policy/bash-types.ts +20 -0
  101. package/src/policy/bash.ts +4 -346
  102. package/src/policy/completion-batch.ts +21 -16
  103. package/src/policy/immutable-input.ts +5 -2
  104. package/src/policy/mcp-authorization.ts +71 -0
  105. package/src/policy/restricted-command.ts +141 -0
  106. package/src/policy/restricted-git.ts +30 -0
  107. package/src/policy/reviewed-artifact.ts +97 -0
  108. package/src/policy/reviewed-command-safety.ts +150 -0
  109. package/src/policy/reviewed-command-shape.ts +49 -0
  110. package/src/policy/reviewed-repository-contract.ts +138 -0
  111. package/src/policy/reviewed-repository-cwd.ts +102 -0
  112. package/src/policy/tool-call-authorization.ts +58 -0
  113. package/src/policy/tool-selection.ts +72 -0
  114. package/src/policy/tool-types.ts +14 -0
  115. package/src/policy/tools.ts +11 -150
  116. package/src/preflight.ts +96 -61
  117. package/src/prompt/main-workflow-notice.ts +41 -0
  118. package/src/prompt/retry-task.ts +83 -0
  119. package/src/prompt/step-contract.ts +101 -0
  120. package/src/prompt/step-sections.ts +75 -0
  121. package/src/prompt/step-task.ts +156 -0
  122. package/src/prompt/template.ts +81 -0
  123. package/src/prompt.ts +10 -254
  124. package/src/runtime/completion-tool.ts +3 -0
  125. package/src/runtime/main-step-completion.ts +78 -0
  126. package/src/runtime/main-step-lifecycle.ts +37 -0
  127. package/src/runtime/main-step-policy.ts +84 -0
  128. package/src/runtime/main-step-runtime-types.ts +64 -0
  129. package/src/runtime/main-step-runtime.ts +129 -200
  130. package/src/runtime/main-step-state.ts +85 -0
  131. package/src/runtime/serial-task-queue.ts +48 -13
  132. package/src/runtime/step-result.ts +48 -27
  133. package/src/workflow-list.ts +15 -6
  134. package/src/workflow-status/format-status.ts +52 -0
  135. package/src/workflow-status/formatting.ts +178 -0
  136. package/src/workflow-status/layout.ts +109 -0
  137. package/src/workflow-status/render-board.ts +101 -0
  138. package/src/workflow-status/render-path.ts +123 -0
  139. package/src/workflow-status/render-summary.ts +144 -0
  140. package/src/workflow-status/types.ts +40 -0
  141. package/src/workflow-status/view.ts +273 -0
  142. package/src/workflow-status.ts +20 -820
@@ -1,820 +1,20 @@
1
- import type {
2
- ExtensionContext,
3
- Theme,
4
- ThemeColor,
5
- } from '@earendil-works/pi-coding-agent';
6
- import {
7
- Key,
8
- matchesKey,
9
- truncateToWidth,
10
- visibleWidth,
11
- wrapTextWithAnsi,
12
- type Component,
13
- type KeyId,
14
- type TUI,
15
- } from '@earendil-works/pi-tui';
16
- import {
17
- DEFAULT_STATUS_SHORTCUT,
18
- type LoadedWorkflow,
19
- } from './config/types.ts';
20
- import type {
21
- StepHistoryEntry,
22
- WorkflowRun,
23
- WorkflowRunStatus,
24
- } from './engine/state.ts';
25
-
26
- const REFRESH_INTERVAL_MS = 250;
27
- const WORKING_ICON_FRAME_MS = 250;
28
- const WORKING_ICON_FRAMES = ['◐', '◓', '◑', '◒'] as const;
29
- const WIDE_LAYOUT_MIN_COLUMNS = 92;
30
- const MAX_PATH_ROWS = 16;
31
- const MAX_REASON_ROWS = 5;
32
-
33
- export type WorkflowStatusExecution =
34
- | {
35
- kind: 'main';
36
- }
37
- | {
38
- kind: 'subagent';
39
- agent: string;
40
- requestId: string;
41
- progress: string;
42
- };
43
-
44
- export interface WorkflowStatusSnapshot {
45
- run: WorkflowRun;
46
- workflow?: LoadedWorkflow;
47
- execution?: WorkflowStatusExecution;
48
- now: number;
49
- }
50
-
51
- type SnapshotProvider = () => WorkflowStatusSnapshot | undefined;
52
- type StepDisplayStatus = WorkflowRunStatus | 'completed' | 'failed';
53
- type StatusViewTui = Pick<TUI, 'requestRender'> & {
54
- terminal?: { rows: number };
55
- };
56
-
57
- interface PathEntry {
58
- stepId: string;
59
- title: string;
60
- status: StepDisplayStatus;
61
- visit: number;
62
- outcome?: string;
63
- current: boolean;
64
- }
65
-
66
- export function formatWorkflowStatusText(
67
- snapshot: WorkflowStatusSnapshot,
68
- ): string {
69
- const { run } = snapshot;
70
- const lines = [
71
- `Workflow: ${run.workflowId}`,
72
- `Run: ${run.runId}`,
73
- `Status: ${run.status}`,
74
- `Step: ${run.currentStepId}`,
75
- `Completed steps: ${run.history.length}`,
76
- ];
77
- if (run.pendingGate?.reviewId) {
78
- lines.push(`Review: ${run.pendingGate.reviewId}`);
79
- }
80
- if (snapshot.execution?.kind === 'subagent') {
81
- lines.push(
82
- `Subagent: ${snapshot.execution.agent} (${snapshot.execution.requestId})`,
83
- `Progress: ${snapshot.execution.progress}`,
84
- );
85
- } else if (snapshot.execution?.kind === 'main') {
86
- lines.push('Execution: main agent');
87
- }
88
- if (run.pauseReason) lines.push(`Reason: ${run.pauseReason}`);
89
- return lines.join('\n');
90
- }
91
-
92
- /** Format the full workflow status board. */
93
- export function formatWorkflowStatusBoard(
94
- snapshot: WorkflowStatusSnapshot,
95
- width = 88,
96
- theme: Theme = unstyledTheme,
97
- ): string[] {
98
- return renderBoard(
99
- theme,
100
- snapshot,
101
- Math.max(8, Math.floor(width)),
102
- false,
103
- formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
104
- );
105
- }
106
-
107
- const unstyledTheme = {
108
- fg: (_color: string, value: string) => value,
109
- bg: (_color: string, value: string) => value,
110
- bold: (value: string) => value,
111
- } as unknown as Theme;
112
-
113
- const SHORTCUT_LABELS: Readonly<Record<string, string>> = {
114
- ctrl: 'Ctrl',
115
- shift: 'Shift',
116
- alt: 'Alt',
117
- super: 'Super',
118
- escape: 'Esc',
119
- esc: 'Esc',
120
- enter: 'Enter',
121
- return: 'Enter',
122
- tab: 'Tab',
123
- space: 'Space',
124
- backspace: 'Backspace',
125
- delete: 'Del',
126
- insert: 'Ins',
127
- clear: 'Clear',
128
- home: 'Home',
129
- end: 'End',
130
- pageUp: 'PgUp',
131
- pageDown: 'PgDn',
132
- up: 'Up',
133
- down: 'Down',
134
- left: 'Left',
135
- right: 'Right',
136
- };
137
-
138
- export function formatShortcutLabel(shortcut: KeyId): string {
139
- return shortcut
140
- .split('+')
141
- .map((part) => {
142
- const label = SHORTCUT_LABELS[part];
143
- if (label) return label;
144
- if (/^f\d+$/.test(part)) return part.toUpperCase();
145
- return part.length === 1 ? part.toUpperCase() : part;
146
- })
147
- .join('+');
148
- }
149
-
150
- export async function showWorkflowStatus(
151
- ctx: ExtensionContext,
152
- getSnapshot: SnapshotProvider,
153
- statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
154
- ): Promise<void> {
155
- await ctx.ui.custom<void>(
156
- (tui, theme, _keybindings, done) => {
157
- const view = new WorkflowStatusView(
158
- getSnapshot,
159
- tui,
160
- theme,
161
- done,
162
- statusShortcut,
163
- );
164
- view.start();
165
- return view;
166
- },
167
- {
168
- overlay: true,
169
- overlayOptions: {
170
- anchor: 'center',
171
- width: '95%',
172
- maxHeight: '95%',
173
- margin: 1,
174
- },
175
- },
176
- );
177
- }
178
-
179
- export class WorkflowStatusView implements Component {
180
- private timer: ReturnType<typeof setInterval> | undefined;
181
- private closed = false;
182
- private scrollOffset = 0;
183
- private viewportRows = 0;
184
- private contentRows = 0;
185
- private readonly statusShortcutLabel: string;
186
-
187
- constructor(
188
- private readonly getSnapshot: SnapshotProvider,
189
- private readonly tui: StatusViewTui,
190
- private readonly theme: Theme,
191
- private readonly done: () => void,
192
- private readonly statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
193
- ) {
194
- this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
195
- }
196
-
197
- start(): void {
198
- this.timer = setInterval(
199
- () => this.tui.requestRender(),
200
- REFRESH_INTERVAL_MS,
201
- );
202
- this.timer.unref?.();
203
- }
204
-
205
- dispose(): void {
206
- if (this.timer) clearInterval(this.timer);
207
- this.timer = undefined;
208
- }
209
-
210
- invalidate(): void {}
211
-
212
- handleInput(data: string): void {
213
- if (
214
- data === 'q' ||
215
- data === 'Q' ||
216
- matchesKey(data, 'escape') ||
217
- matchesKey(data, 'ctrl+c') ||
218
- matchesKey(data, 'ctrl+d') ||
219
- matchesKey(data, this.statusShortcut)
220
- ) {
221
- this.close();
222
- return;
223
- }
224
- const pageSize = Math.max(1, this.viewportRows - 2);
225
- if (matchesKey(data, Key.down) || data === 'j') {
226
- this.scrollBy(1);
227
- } else if (matchesKey(data, Key.up) || data === 'k') {
228
- this.scrollBy(-1);
229
- } else if (matchesKey(data, Key.pageDown)) {
230
- this.scrollBy(pageSize);
231
- } else if (matchesKey(data, Key.pageUp)) {
232
- this.scrollBy(-pageSize);
233
- } else if (matchesKey(data, Key.home)) {
234
- this.setScrollOffset(0);
235
- } else if (matchesKey(data, Key.end)) {
236
- this.setScrollOffset(Number.MAX_SAFE_INTEGER);
237
- }
238
- }
239
-
240
- render(width: number): string[] {
241
- const viewportWidth = Math.max(1, Math.floor(width || 1));
242
- const snapshot = this.getSnapshot();
243
- if (viewportWidth < 12) {
244
- const label = snapshot
245
- ? `${statusGlyph(this.theme, runDisplayStatus(snapshot.run), snapshot.now)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}`
246
- : 'No workflow';
247
- return [truncateToWidth(label, viewportWidth, '…', true)];
248
- }
249
-
250
- const contentWidth = viewportWidth - 2;
251
- const lines = snapshot
252
- ? renderBoard(
253
- this.theme,
254
- snapshot,
255
- contentWidth,
256
- false,
257
- this.statusShortcutLabel,
258
- )
259
- : renderEmptyBoard(this.theme, contentWidth);
260
- const rendered = lines.map((line) =>
261
- padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
262
- );
263
- return this.paginate(rendered, viewportWidth);
264
- }
265
-
266
- private paginate(lines: string[], width: number): string[] {
267
- this.contentRows = lines.length;
268
- const terminalRows = this.tui.terminal?.rows;
269
- const maximumRows =
270
- terminalRows === undefined
271
- ? lines.length + 1
272
- : Math.max(4, Math.floor(terminalRows * 0.95));
273
- this.viewportRows = maximumRows;
274
- const contentHeight = Math.max(1, maximumRows - 1);
275
- const maximumOffset = Math.max(0, lines.length - contentHeight);
276
- this.scrollOffset = Math.min(this.scrollOffset, maximumOffset);
277
- const visible = lines.slice(
278
- this.scrollOffset,
279
- this.scrollOffset + contentHeight,
280
- );
281
- const first = lines.length === 0 ? 0 : this.scrollOffset + 1;
282
- const last = Math.min(lines.length, this.scrollOffset + contentHeight);
283
- const hint =
284
- maximumOffset > 0
285
- ? `↑/↓ PgUp/PgDn Home/End · rows ${first}-${last}/${lines.length} · ${this.statusShortcutLabel} / q / Esc hide`
286
- : `${this.statusShortcutLabel} / q / Esc hide · live refresh`;
287
- return [
288
- ...visible,
289
- padAnsi(truncateToWidth(this.theme.fg('dim', hint), width, '…'), width),
290
- ];
291
- }
292
-
293
- private scrollBy(delta: number): void {
294
- this.setScrollOffset(this.scrollOffset + delta);
295
- }
296
-
297
- private setScrollOffset(value: number): void {
298
- const contentHeight = Math.max(1, this.viewportRows - 1);
299
- const maximumOffset = Math.max(0, this.contentRows - contentHeight);
300
- const next = Math.max(0, Math.min(value, maximumOffset));
301
- if (next === this.scrollOffset) return;
302
- this.scrollOffset = next;
303
- this.tui.requestRender(true);
304
- }
305
-
306
- private close(): void {
307
- if (this.closed) return;
308
- this.closed = true;
309
- this.dispose();
310
- this.done();
311
- this.tui.requestRender(true);
312
- }
313
- }
314
-
315
- function renderBoard(
316
- theme: Theme,
317
- snapshot: WorkflowStatusSnapshot,
318
- width: number,
319
- showCloseHint = true,
320
- statusShortcutLabel = formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
321
- ): string[] {
322
- const header = boxed(
323
- theme,
324
- '✦ Workflow Status',
325
- width,
326
- renderHeaderLines(theme, snapshot, width - 4),
327
- 'borderAccent',
328
- );
329
-
330
- let body: string[];
331
- if (width >= WIDE_LAYOUT_MIN_COLUMNS) {
332
- const gap = 2;
333
- const summaryWidth = Math.min(42, Math.max(36, Math.floor(width * 0.36)));
334
- const pathWidth = width - summaryWidth - gap;
335
- const summary = boxed(
336
- theme,
337
- 'Run Summary',
338
- summaryWidth,
339
- renderSummaryLines(theme, snapshot, summaryWidth - 4),
340
- );
341
- const path = boxed(
342
- theme,
343
- 'Execution Path',
344
- pathWidth,
345
- renderPathLines(theme, snapshot, pathWidth - 4),
346
- 'borderAccent',
347
- );
348
- body = joinPanels(summary, summaryWidth, path, pathWidth, gap);
349
- } else {
350
- body = [
351
- ...boxed(
352
- theme,
353
- 'Run Summary',
354
- width,
355
- renderSummaryLines(theme, snapshot, width - 4),
356
- ),
357
- '',
358
- ...boxed(
359
- theme,
360
- 'Execution Path',
361
- width,
362
- renderPathLines(theme, snapshot, width - 4),
363
- 'borderAccent',
364
- ),
365
- ];
366
- }
367
-
368
- const lines = [...header, '', ...body];
369
- if (showCloseHint) {
370
- lines.push(
371
- '',
372
- theme.fg('dim', `${statusShortcutLabel} / q / Esc hide · live refresh`),
373
- );
374
- }
375
- return lines;
376
- }
377
-
378
- function renderEmptyBoard(theme: Theme, width: number): string[] {
379
- return [
380
- ...boxed(
381
- theme,
382
- '✦ Workflow Status',
383
- width,
384
- [theme.fg('muted', 'No workflow checkpoint in this session')],
385
- 'borderAccent',
386
- ),
387
- '',
388
- theme.fg('dim', 'q / Esc close'),
389
- ];
390
- }
391
-
392
- function renderHeaderLines(
393
- theme: Theme,
394
- snapshot: WorkflowStatusSnapshot,
395
- width: number,
396
- ): string[] {
397
- const { run } = snapshot;
398
- const workflowName = inline(run.workflowId);
399
- const status = statusBadge(theme, run.status);
400
- const completed = theme.fg(
401
- 'success',
402
- `${run.history.length} completed attempt${run.history.length === 1 ? '' : 's'}`,
403
- );
404
- const firstLine = [
405
- statusGlyph(theme, runDisplayStatus(run), snapshot.now),
406
- theme.bold(workflowName),
407
- status,
408
- theme.fg('muted', '·'),
409
- completed,
410
- ].join(' ');
411
-
412
- const currentTitle = stepTitle(snapshot.workflow, run.currentStepId);
413
- const visit = Math.max(1, run.visits[run.currentStepId] ?? 1);
414
- const elapsed = formatElapsed(elapsedMs(snapshot));
415
- const secondLine = [
416
- theme.fg('muted', 'step'),
417
- theme.fg('text', formatStepName(currentTitle, run.currentStepId)),
418
- theme.fg('muted', `· visit ${visit} · elapsed ${elapsed}`),
419
- ].join(' ');
420
-
421
- return [
422
- truncateToWidth(firstLine, width),
423
- truncateToWidth(secondLine, width),
424
- ];
425
- }
426
-
427
- function renderSummaryLines(
428
- theme: Theme,
429
- snapshot: WorkflowStatusSnapshot,
430
- width: number,
431
- ): string[] {
432
- const { run, workflow } = snapshot;
433
- const lines = [
434
- ...keyValueLines(theme, 'workflow', run.workflowId, width),
435
- ...keyValueLines(theme, 'run', run.runId, width),
436
- ...keyValueLines(
437
- theme,
438
- 'status',
439
- statusLabel(run.status),
440
- width,
441
- statusColor(run.status),
442
- ),
443
- ...keyValueLines(
444
- theme,
445
- 'current',
446
- formatStepName(stepTitle(workflow, run.currentStepId), run.currentStepId),
447
- width,
448
- ),
449
- ...keyValueLines(
450
- theme,
451
- 'visit',
452
- String(Math.max(1, run.visits[run.currentStepId] ?? 1)),
453
- width,
454
- ),
455
- ...keyValueLines(theme, 'started', formatTimestamp(run.startedAt), width),
456
- ...keyValueLines(
457
- theme,
458
- 'updated',
459
- `${formatTimestamp(run.updatedAt)} · ${formatElapsed(elapsedMs(snapshot))}`,
460
- width,
461
- ),
462
- ];
463
-
464
- const execution = formatExecution(snapshot.execution);
465
- if (execution) {
466
- lines.push(
467
- ...keyValueLines(theme, 'execution', execution, width, 'accent'),
468
- );
469
- }
470
- if (run.pendingGate) {
471
- const review = run.pendingGate.reviewId
472
- ? `${run.pendingGate.provider} · ${run.pendingGate.reviewId}`
473
- : `${run.pendingGate.provider} · opening`;
474
- lines.push(...keyValueLines(theme, 'review', review, width, 'warning'));
475
- }
476
- if (!workflow) {
477
- lines.push(
478
- ...keyValueLines(
479
- theme,
480
- 'config',
481
- 'workflow definition is not loaded',
482
- width,
483
- 'warning',
484
- ),
485
- );
486
- } else if (workflow.digest !== run.workflowDigest) {
487
- lines.push(
488
- ...keyValueLines(
489
- theme,
490
- 'config',
491
- 'definition changed since this checkpoint',
492
- width,
493
- 'warning',
494
- ),
495
- );
496
- }
497
- if (run.pauseReason) {
498
- lines.push(
499
- ...clampRows(
500
- keyValueLines(
501
- theme,
502
- 'reason',
503
- run.pauseReason,
504
- width,
505
- run.status === 'aborted' ? 'error' : 'warning',
506
- ),
507
- MAX_REASON_ROWS,
508
- width,
509
- theme,
510
- ),
511
- );
512
- }
513
- return lines;
514
- }
515
-
516
- function clampRows(
517
- lines: string[],
518
- maximum: number,
519
- width: number,
520
- theme: Theme,
521
- ): string[] {
522
- if (lines.length <= maximum) return lines;
523
- const visible = lines.slice(0, maximum);
524
- const last = visible.at(-1) ?? '';
525
- visible[maximum - 1] =
526
- truncateToWidth(last, Math.max(1, width - 1), '', true) +
527
- theme.fg('dim', '…');
528
- return visible;
529
- }
530
-
531
- function renderPathLines(
532
- theme: Theme,
533
- snapshot: WorkflowStatusSnapshot,
534
- width: number,
535
- ): string[] {
536
- const entries = buildPathEntries(snapshot);
537
- if (entries.length === 0) {
538
- return [theme.fg('muted', 'No step attempts recorded')];
539
- }
540
-
541
- const hidden = Math.max(0, entries.length - MAX_PATH_ROWS);
542
- const visible = entries.slice(hidden);
543
- const lines =
544
- hidden > 0
545
- ? [
546
- theme.fg(
547
- 'dim',
548
- `… ${hidden} earlier attempt${hidden === 1 ? '' : 's'}`,
549
- ),
550
- ]
551
- : [];
552
- for (const entry of visible) {
553
- const visit =
554
- entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
555
- const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
556
- entry.current ? 'text' : 'muted',
557
- entry.title,
558
- )}${visit}`;
559
- const right = entry.outcome
560
- ? `${statusLabel(entry.status)} · ${inline(entry.outcome)}`
561
- : statusLabel(entry.status);
562
- const row = joinColumns(
563
- left,
564
- theme.fg(statusColor(entry.status), right),
565
- width,
566
- Math.max(12, Math.floor(width * 0.58)),
567
- );
568
- lines.push(
569
- entry.current
570
- ? theme.bg('selectedBg', padAnsi(row, width))
571
- : truncateToWidth(row, width),
572
- );
573
- }
574
- return lines;
575
- }
576
-
577
- function buildPathEntries(snapshot: WorkflowStatusSnapshot): PathEntry[] {
578
- const { run, workflow } = snapshot;
579
- const visits = new Map<string, number>();
580
- const entries = run.history.map((entry) => {
581
- const visit = (visits.get(entry.stepId) ?? 0) + 1;
582
- visits.set(entry.stepId, visit);
583
- return historyPathEntry(workflow, entry, visit);
584
- });
585
-
586
- if (run.status !== 'completed') {
587
- entries.push({
588
- stepId: run.currentStepId,
589
- title: stepTitle(workflow, run.currentStepId),
590
- status: runDisplayStatus(run),
591
- visit: Math.max(
592
- visits.get(run.currentStepId) ?? 0,
593
- run.visits[run.currentStepId] ?? 1,
594
- ),
595
- current: true,
596
- });
597
- } else if (
598
- entries.length === 0 ||
599
- entries.at(-1)?.stepId !== run.currentStepId
600
- ) {
601
- entries.push({
602
- stepId: run.currentStepId,
603
- title: stepTitle(workflow, run.currentStepId),
604
- status: 'completed',
605
- visit: Math.max(1, run.visits[run.currentStepId] ?? 1),
606
- current: true,
607
- });
608
- }
609
- return entries;
610
- }
611
-
612
- function historyPathEntry(
613
- workflow: LoadedWorkflow | undefined,
614
- entry: StepHistoryEntry,
615
- visit: number,
616
- ): PathEntry {
617
- return {
618
- stepId: entry.stepId,
619
- title: stepTitle(workflow, entry.stepId),
620
- status: 'completed',
621
- visit,
622
- outcome: entry.outcome,
623
- current: false,
624
- };
625
- }
626
-
627
- function keyValueLines(
628
- theme: Theme,
629
- label: string,
630
- rawValue: string,
631
- width: number,
632
- valueColor: ThemeColor = 'text',
633
- ): string[] {
634
- const safeWidth = Math.max(1, width);
635
- const labelWidth = Math.min(10, Math.max(7, label.length + 1));
636
- const valueWidth = Math.max(1, safeWidth - labelWidth);
637
- const value = theme.fg(valueColor, inline(rawValue));
638
- const wrapped = wrapTextWithAnsi(value, valueWidth);
639
- const prefix = theme.fg('muted', label.padEnd(labelWidth));
640
- return (wrapped.length > 0 ? wrapped : ['']).map((line, index) =>
641
- index === 0 ? `${prefix}${line}` : `${' '.repeat(labelWidth)}${line}`,
642
- );
643
- }
644
-
645
- function boxed(
646
- theme: Theme,
647
- title: string,
648
- width: number,
649
- content: string[],
650
- color: ThemeColor = 'borderMuted',
651
- ): string[] {
652
- const safeWidth = Math.max(8, Math.floor(width));
653
- const bodyWidth = Math.max(1, safeWidth - 4);
654
- const topLabel = `╭─ ${title} `;
655
- const top = `${topLabel}${'─'.repeat(
656
- Math.max(0, safeWidth - visibleWidth(topLabel) - 1),
657
- )}╮`;
658
- const bottom = `╰${'─'.repeat(Math.max(0, safeWidth - 2))}╯`;
659
- const body = content.length > 0 ? content : [''];
660
- return [
661
- theme.fg(color, top),
662
- ...body.map(
663
- (line) =>
664
- `${theme.fg(color, '│')} ${padAnsi(
665
- truncateToWidth(line, bodyWidth),
666
- bodyWidth,
667
- )} ${theme.fg(color, '│')}`,
668
- ),
669
- theme.fg(color, bottom),
670
- ];
671
- }
672
-
673
- function joinPanels(
674
- left: string[],
675
- leftWidth: number,
676
- right: string[],
677
- rightWidth: number,
678
- gap: number,
679
- ): string[] {
680
- const height = Math.max(left.length, right.length);
681
- return Array.from({ length: height }, (_, index) => {
682
- const leftLine = padAnsi(left[index] ?? '', leftWidth);
683
- const rightLine = padAnsi(right[index] ?? '', rightWidth);
684
- return `${leftLine}${' '.repeat(gap)}${rightLine}`;
685
- });
686
- }
687
-
688
- function joinColumns(
689
- left: string,
690
- right: string,
691
- width: number,
692
- leftWidth: number,
693
- ): string {
694
- const safeLeftWidth = Math.max(1, Math.min(leftWidth, width - 2));
695
- const rightWidth = Math.max(1, width - safeLeftWidth - 1);
696
- return `${padAnsi(
697
- truncateToWidth(left, safeLeftWidth),
698
- safeLeftWidth,
699
- )} ${truncateToWidth(right, rightWidth)}`;
700
- }
701
-
702
- function padAnsi(value: string, width: number): string {
703
- const visible = visibleWidth(value);
704
- if (visible >= width) return value;
705
- return `${value}${' '.repeat(width - visible)}`;
706
- }
707
-
708
- function statusGlyph(
709
- theme: Theme,
710
- status: StepDisplayStatus,
711
- now = Date.now(),
712
- ): string {
713
- if (status === 'completed') return theme.fg('success', '✓');
714
- if (status === 'running') {
715
- return theme.fg('accent', workingIcon(now));
716
- }
717
- if (status === 'paused' || status === 'awaiting-gate') {
718
- return theme.fg('warning', '◆');
719
- }
720
- if (status === 'failed' || status === 'aborted') {
721
- return theme.fg('error', '✕');
722
- }
723
- return theme.fg('dim', '•');
724
- }
725
-
726
- function statusColor(status: StepDisplayStatus): ThemeColor {
727
- if (status === 'completed') return 'success';
728
- if (status === 'running') return 'accent';
729
- if (status === 'paused' || status === 'awaiting-gate') return 'warning';
730
- if (status === 'failed' || status === 'aborted') return 'error';
731
- return 'dim';
732
- }
733
-
734
- function statusLabel(status: StepDisplayStatus): string {
735
- return status === 'awaiting-gate'
736
- ? 'AWAITING REVIEW'
737
- : status.toUpperCase().replaceAll('-', ' ');
738
- }
739
-
740
- function statusBadge(theme: Theme, status: StepDisplayStatus): string {
741
- return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
742
- }
743
-
744
- function runDisplayStatus(run: WorkflowRun): StepDisplayStatus {
745
- return run.status === 'paused' && run.failedStepId === run.currentStepId
746
- ? 'failed'
747
- : run.status;
748
- }
749
-
750
- export function workflowStatusIcon(run: WorkflowRun, now = Date.now()): string {
751
- const status = runDisplayStatus(run);
752
- if (status === 'completed') return '✓';
753
- if (status === 'running') return workingIcon(now);
754
- if (status === 'failed' || status === 'aborted') return '✕';
755
- if (status === 'paused' || status === 'awaiting-gate') return '◆';
756
- return '•';
757
- }
758
-
759
- function workingIcon(now: number): string {
760
- return WORKING_ICON_FRAMES[
761
- Math.floor(now / WORKING_ICON_FRAME_MS) % WORKING_ICON_FRAMES.length
762
- ]!;
763
- }
764
-
765
- function stepTitle(
766
- workflow: LoadedWorkflow | undefined,
767
- stepId: string,
768
- ): string {
769
- return inline(workflow?.definition.steps[stepId]?.title ?? stepId);
770
- }
771
-
772
- function formatStepName(title: string, stepId: string): string {
773
- const safeStepId = inline(stepId);
774
- return title === safeStepId ? title : `${title} (${safeStepId})`;
775
- }
776
-
777
- function formatExecution(
778
- execution: WorkflowStatusExecution | undefined,
779
- ): string | undefined {
780
- if (!execution) return undefined;
781
- if (execution.kind === 'main') return 'main agent';
782
- return `${execution.agent} · ${execution.progress} · ${execution.requestId}`;
783
- }
784
-
785
- function inline(value: string): string {
786
- return value.replace(/\s+/g, ' ').trim();
787
- }
788
-
789
- function elapsedMs(snapshot: WorkflowStatusSnapshot): number {
790
- const { run } = snapshot;
791
- const end =
792
- run.status === 'running' || run.status === 'awaiting-gate'
793
- ? snapshot.now
794
- : run.updatedAt;
795
- return Math.max(0, end - run.startedAt);
796
- }
797
-
798
- function formatElapsed(milliseconds: number): string {
799
- const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
800
- const days = Math.floor(totalSeconds / 86_400);
801
- const hours = Math.floor((totalSeconds % 86_400) / 3_600);
802
- const minutes = Math.floor((totalSeconds % 3_600) / 60);
803
- const seconds = totalSeconds % 60;
804
- if (days > 0) return `${days}d ${hours}h`;
805
- if (hours > 0) return `${hours}h ${minutes}m`;
806
- if (minutes > 0) return `${minutes}m ${seconds}s`;
807
- return `${seconds}s`;
808
- }
809
-
810
- function formatTimestamp(milliseconds: number): string {
811
- const value = new Date(milliseconds);
812
- if (!Number.isFinite(value.getTime())) return 'unknown';
813
- const year = value.getFullYear();
814
- const month = String(value.getMonth() + 1).padStart(2, '0');
815
- const day = String(value.getDate()).padStart(2, '0');
816
- const hours = String(value.getHours()).padStart(2, '0');
817
- const minutes = String(value.getMinutes()).padStart(2, '0');
818
- const seconds = String(value.getSeconds()).padStart(2, '0');
819
- return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
820
- }
1
+ export {
2
+ formatWorkflowStatusBoard,
3
+ formatWorkflowStatusText,
4
+ } from './workflow-status/format-status.ts';
5
+ export {
6
+ formatShortcutLabel,
7
+ workflowStatusIcon,
8
+ } from './workflow-status/formatting.ts';
9
+ export {
10
+ showWorkflowStatus,
11
+ WorkflowStatusView,
12
+ type WorkflowStatusViewDependencies,
13
+ } from './workflow-status/view.ts';
14
+ export type {
15
+ SnapshotProvider,
16
+ StatusViewTui,
17
+ WorkflowStatusExecution,
18
+ WorkflowStatusSnapshot,
19
+ WorkflowStatusTheme,
20
+ } from './workflow-status/types.ts';