@wichayutdew/pi-workflows 0.2.2 → 0.3.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.
package/src/harness.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
- import { mkdtempSync, writeFileSync } from 'node:fs';
3
- import { readFile, rm } from 'node:fs/promises';
2
+ import { constants, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { lstat, open, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import type {
@@ -8,6 +8,7 @@ import type {
8
8
  ExtensionCommandContext,
9
9
  ExtensionContext,
10
10
  } from '@earendil-works/pi-coding-agent';
11
+ import type { KeyId } from '@earendil-works/pi-tui';
11
12
  import {
12
13
  registerHarnessCommands,
13
14
  type WorkflowCommandController,
@@ -16,6 +17,7 @@ import { loadCatalog } from './config/load.ts';
16
17
  import { hasRuntimeCommandConflict } from './config/command-conflicts.ts';
17
18
  import {
18
19
  DEFAULT_SETTINGS,
20
+ DEFAULT_STATUS_SHORTCUT,
19
21
  type LoadedWorkflow,
20
22
  type WorkflowCatalog,
21
23
  type WorkflowStep,
@@ -28,6 +30,7 @@ import {
28
30
  attachGateReviewId,
29
31
  beginGate,
30
32
  failGate,
33
+ failRun,
31
34
  pauseRun,
32
35
  reconcileRun,
33
36
  resolveGate,
@@ -61,31 +64,128 @@ import {
61
64
  type ChildStepPolicy,
62
65
  type SubagentDelegationRequest,
63
66
  type SubagentDelegationResponse,
67
+ type SubagentDelegationStatus,
64
68
  type SubagentDelegationUpdate,
65
69
  } from './integrations/subagents/protocol.ts';
70
+ import {
71
+ deriveSubagentSessionRoot,
72
+ failedToolName,
73
+ formatToolFailureDiagnostic,
74
+ readDelegationReplayAudit,
75
+ readToolFailureDiagnostic,
76
+ type DelegationReplayAudit,
77
+ } from './integrations/subagents/diagnostics.ts';
66
78
  import { preflightStep } from './preflight.ts';
67
79
  import {
68
80
  buildDelegatedStepTask,
69
81
  buildMainStepTask,
70
82
  buildMainWorkflowNotice,
83
+ reinforcementRetryTask,
71
84
  } from './prompt.ts';
72
- import { extractApprovedBashCommands } from './policy/approved-commands.ts';
85
+ import {
86
+ narrowApprovedBashCommands,
87
+ resolveReviewedRepositoryCwd,
88
+ reviewedCommandShapeError,
89
+ } from './policy/approved-commands.ts';
73
90
  import {
74
91
  MainStepRuntime,
75
92
  type MainStepExecution,
76
93
  } from './runtime/main-step-runtime.ts';
94
+ import { WORKFLOW_COMPLETION_PARAMETERS } from './runtime/completion-tool.ts';
77
95
  import { SerialTaskQueue } from './runtime/serial-task-queue.ts';
78
96
  import type { WorkflowStepResult } from './runtime/step-result.ts';
79
97
  import { formatWorkflowList } from './workflow-list.ts';
80
98
  import {
81
- formatWorkflowStatusText,
82
- showWorkflowStatus,
99
+ formatShortcutLabel,
100
+ showWorkflowStatus as showWorkflowStatusOverlay,
101
+ workflowStatusIcon,
83
102
  type WorkflowStatusExecution,
84
103
  type WorkflowStatusSnapshot,
85
104
  } from './workflow-status.ts';
86
105
 
87
106
  const STATE_ENTRY_TYPE = 'pi-workflows-state-v1';
88
107
  const STATUS_KEY = 'pi-workflows';
108
+ const LEGACY_PROGRESS_WIDGET_KEY = 'pi-workflows-progress';
109
+ const STATUS_REFRESH_INTERVAL_MS = 250;
110
+ const MAX_REINFORCEMENT_RETRIES = 1;
111
+
112
+ function delegationTranscriptBinding(
113
+ requestId: string,
114
+ policyDigest: string,
115
+ ): string {
116
+ return `<pi-workflows-delegation-binding-v1>${requestId}:${policyDigest}</pi-workflows-delegation-binding-v1>`;
117
+ }
118
+
119
+ function nonEmptyTerminalError(
120
+ response: SubagentDelegationResponse,
121
+ ): string | undefined {
122
+ return [response.error, response.execution?.error].find(
123
+ (error): error is string =>
124
+ typeof error === 'string' && error.trim().length > 0,
125
+ );
126
+ }
127
+
128
+ function nonzeroTerminalExitCode(
129
+ response: SubagentDelegationResponse,
130
+ ): number | undefined {
131
+ return [response.exitCode, response.execution?.exitCode].find(
132
+ (exitCode): exitCode is number =>
133
+ typeof exitCode === 'number' &&
134
+ Number.isSafeInteger(exitCode) &&
135
+ exitCode !== 0,
136
+ );
137
+ }
138
+
139
+ function hasContradictoryCompletion(
140
+ response: SubagentDelegationResponse,
141
+ ): boolean {
142
+ return (
143
+ response.status === 'completed' &&
144
+ (nonEmptyTerminalError(response) !== undefined ||
145
+ nonzeroTerminalExitCode(response) !== undefined)
146
+ );
147
+ }
148
+
149
+ function isRetryableTerminalFailure(
150
+ failure: DelegationFailureDetails,
151
+ ): boolean {
152
+ return (
153
+ (failure.status === 'failed' ||
154
+ failure.status === 'structured_output_failed') &&
155
+ (failure.error !== undefined ||
156
+ (Number.isSafeInteger(failure.exitCode) && failure.exitCode !== 0))
157
+ );
158
+ }
159
+
160
+ function validateReplayAudit(
161
+ response: SubagentDelegationResponse,
162
+ replayAudit: DelegationReplayAudit | undefined,
163
+ ): DelegationReplayAudit | undefined {
164
+ if (!replayAudit) return undefined;
165
+ if (
166
+ response.toolCount !== undefined &&
167
+ response.toolCount !== replayAudit.toolCount
168
+ ) {
169
+ return { ...replayAudit, replaySafe: false };
170
+ }
171
+ return replayAudit;
172
+ }
173
+
174
+ function isSafeToRetryDelegation(
175
+ policy: ChildStepPolicy,
176
+ replayExplicitlyAuthorized: boolean,
177
+ replayAudit: DelegationReplayAudit | undefined,
178
+ ): boolean {
179
+ const tools = new Set(policy.permissions.tools);
180
+ return (
181
+ replayAudit?.replaySafe === true &&
182
+ !tools.has('edit') &&
183
+ !tools.has('write') &&
184
+ (replayExplicitlyAuthorized ||
185
+ policy.permissions.bash.mode === 'deny' ||
186
+ policy.permissions.bash.mode === 'read-only')
187
+ );
188
+ }
89
189
 
90
190
  interface ActiveDelegation {
91
191
  requestId: string;
@@ -95,11 +195,275 @@ interface ActiveDelegation {
95
195
  sessionEpoch: number;
96
196
  resultDirectory: string;
97
197
  policy: ChildStepPolicy;
198
+ transcriptTask: string;
98
199
  agent: string;
200
+ trustedSessionRoot?: string;
201
+ reinforcementReplayAuthorized: boolean;
202
+ reinforcementRetryCount: number;
99
203
  progress?: string;
100
204
  cancelling?: boolean;
101
205
  }
102
206
 
207
+ const MAX_FAILURE_FIELD_CHARS = 1_600;
208
+ const MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
209
+
210
+ function boundedFailureField(value: string): string {
211
+ if (value.length <= MAX_FAILURE_FIELD_CHARS) return value;
212
+ const marker = '… [truncated] …';
213
+ const available = MAX_FAILURE_FIELD_CHARS - marker.length - 2;
214
+ const startLength = Math.ceil(available / 2);
215
+ const endLength = Math.floor(available / 2);
216
+ return `${value.slice(0, startLength)}\n${marker}\n${value.slice(-endLength)}`;
217
+ }
218
+
219
+ async function readStableDelegatedResult(
220
+ active: ActiveDelegation,
221
+ ): Promise<string> {
222
+ const expectedPath = join(active.resultDirectory, 'result.json');
223
+ if (active.policy.resultPath !== expectedPath) {
224
+ throw new Error(
225
+ 'delegated result path does not match its private directory',
226
+ );
227
+ }
228
+ const inspected = await lstat(expectedPath);
229
+ if (inspected.isSymbolicLink() || !inspected.isFile()) {
230
+ throw new Error('delegated result is not a regular file');
231
+ }
232
+ const handle = await open(
233
+ expectedPath,
234
+ constants.O_RDONLY | constants.O_NOFOLLOW,
235
+ );
236
+ try {
237
+ const beforeRead = await handle.stat();
238
+ if (!beforeRead.isFile() || beforeRead.size > MAX_DELEGATED_RESULT_BYTES) {
239
+ throw new Error('delegated result is not a bounded regular file');
240
+ }
241
+ const value = await handle.readFile({ encoding: 'utf8' });
242
+ const afterRead = await handle.stat();
243
+ if (
244
+ afterRead.dev !== beforeRead.dev ||
245
+ afterRead.ino !== beforeRead.ino ||
246
+ afterRead.size !== beforeRead.size ||
247
+ afterRead.mtimeMs !== beforeRead.mtimeMs
248
+ ) {
249
+ throw new Error('delegated result changed while it was being read');
250
+ }
251
+ return value;
252
+ } finally {
253
+ await handle.close();
254
+ }
255
+ }
256
+
257
+ function rejectedRecoveryReason(
258
+ failure: DelegationFailureDetails,
259
+ error: unknown,
260
+ ): string {
261
+ const detail = error instanceof Error ? error.message : String(error);
262
+ return `${failure.reason}\nRecovery rejected: ${boundedFailureField(detail)}`;
263
+ }
264
+
265
+ function completionMatchesResult(
266
+ diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
267
+ result: WorkflowStepResult,
268
+ policy: ChildStepPolicy,
269
+ ): boolean {
270
+ const value = diagnostic.completionValue;
271
+ if (!value) return false;
272
+ const expectedKeys = [
273
+ 'outcome',
274
+ 'summary',
275
+ ...(result.artifact === undefined ? [] : ['artifact']),
276
+ ].sort();
277
+ const actualKeys = Object.keys(value).sort();
278
+ if (
279
+ actualKeys.length !== expectedKeys.length ||
280
+ !actualKeys.every((key, index) => key === expectedKeys[index])
281
+ ) {
282
+ return false;
283
+ }
284
+ try {
285
+ const completion = parseDelegatedStepResult(
286
+ {
287
+ ...value,
288
+ version: 1,
289
+ policyDigest: policy.policyDigest,
290
+ },
291
+ policy,
292
+ );
293
+ return (
294
+ completion.outcome === result.outcome &&
295
+ completion.summary === result.summary &&
296
+ completion.artifact === result.artifact
297
+ );
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
303
+ function recoveredProjectionError(
304
+ active: ActiveDelegation,
305
+ response: SubagentDelegationResponse,
306
+ diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
307
+ ): string | undefined {
308
+ if (response.agent !== active.agent) {
309
+ return `terminal agent identity is ${JSON.stringify(response.agent)}; expected ${JSON.stringify(active.agent)}`;
310
+ }
311
+ if (response.childIndex !== 0) {
312
+ return `terminal child index is ${JSON.stringify(response.childIndex)}; expected 0`;
313
+ }
314
+ if (
315
+ typeof response.exitCode !== 'number' ||
316
+ !Number.isSafeInteger(response.exitCode) ||
317
+ response.exitCode <= 0
318
+ ) {
319
+ return `terminal exit code is ${JSON.stringify(response.exitCode)}; expected a positive safe integer`;
320
+ }
321
+ const execution = response.execution;
322
+ if (!execution) return 'terminal response has no execution projection';
323
+ if (execution.status !== 'failed' || execution.success !== false) {
324
+ return `execution projection is ${JSON.stringify({
325
+ status: execution.status,
326
+ success: execution.success,
327
+ })}; expected failed/false`;
328
+ }
329
+ if (execution.exitCode !== response.exitCode) {
330
+ return `execution exit code ${JSON.stringify(execution.exitCode)} does not match terminal exit code ${JSON.stringify(response.exitCode)}`;
331
+ }
332
+ if (
333
+ typeof response.error !== 'string' ||
334
+ !response.error ||
335
+ typeof execution.error !== 'string' ||
336
+ execution.error !== response.error
337
+ ) {
338
+ return 'terminal and execution errors are missing or do not match exactly';
339
+ }
340
+ const warnings = response.warnings as unknown;
341
+ if (
342
+ warnings !== undefined &&
343
+ (!Array.isArray(warnings) ||
344
+ warnings.some(
345
+ (warning) => typeof warning !== 'string' || warning.trim().length > 0,
346
+ ))
347
+ ) {
348
+ return `terminal response contains warning evidence: ${JSON.stringify(warnings)}`;
349
+ }
350
+ if (
351
+ diagnostic.transcriptToolCount !== undefined &&
352
+ response.toolCount !== undefined &&
353
+ response.toolCount !== diagnostic.transcriptToolCount
354
+ ) {
355
+ return `terminal tool count ${response.toolCount} does not match transcript tool count ${diagnostic.transcriptToolCount}`;
356
+ }
357
+ if (
358
+ diagnostic.transcriptTurnCount !== undefined &&
359
+ response.turns !== undefined &&
360
+ response.turns !== diagnostic.transcriptTurnCount
361
+ ) {
362
+ return `terminal turn count ${response.turns} does not match transcript turn count ${diagnostic.transcriptTurnCount}`;
363
+ }
364
+ const toolFailure = response.error.match(
365
+ /^\s*([a-z][\w-]*) failed\s*\(exit\s+(\d+)\)\s*:/i,
366
+ );
367
+ if (!toolFailure) {
368
+ return 'terminal error is not a recognized "<tool> failed (exit N): <detail>" failure';
369
+ }
370
+ const terminalTool = toolFailure[1]!;
371
+ const terminalExitCode = Number(toolFailure[2]);
372
+ if (
373
+ terminalTool.toLowerCase() !== diagnostic.tool.toLowerCase() ||
374
+ terminalExitCode !== response.exitCode
375
+ ) {
376
+ return `terminal tool/exit ${JSON.stringify({
377
+ tool: terminalTool,
378
+ exitCode: terminalExitCode,
379
+ })} does not match the correlated failure ${JSON.stringify({
380
+ tool: diagnostic.tool,
381
+ exitCode: response.exitCode,
382
+ })}`;
383
+ }
384
+ const unsafeFlag = (
385
+ [
386
+ ['interrupted', execution.interrupted],
387
+ ['timedOut', execution.timedOut],
388
+ ['stopped', execution.stopped],
389
+ ['detached', execution.detached],
390
+ ] as const
391
+ ).find(([, enabled]) => enabled === true)?.[0];
392
+ return unsafeFlag
393
+ ? `execution projection reports ${unsafeFlag}=true`
394
+ : undefined;
395
+ }
396
+
397
+ interface DelegationFailureDetails {
398
+ reason: string;
399
+ status: SubagentDelegationStatus;
400
+ error?: string;
401
+ exitCode?: number;
402
+ diagnostic?: Awaited<ReturnType<typeof readToolFailureDiagnostic>>;
403
+ replayAudit?: DelegationReplayAudit;
404
+ }
405
+
406
+ async function delegationFailureDetails(
407
+ active: ActiveDelegation,
408
+ response: SubagentDelegationResponse,
409
+ ): Promise<DelegationFailureDetails> {
410
+ const terminalError = nonEmptyTerminalError(response);
411
+ const error =
412
+ terminalError ?? 'The subagent returned no terminal error details.';
413
+ const responseIdentityMatches =
414
+ response.childIndex === 0 &&
415
+ (response.agent === undefined || response.agent === active.agent);
416
+ const identity =
417
+ responseIdentityMatches && response.runId !== undefined
418
+ ? { runId: response.runId, childIndex: 0 }
419
+ : undefined;
420
+ const [diagnostic, replayAudit] = await Promise.all([
421
+ readToolFailureDiagnostic(
422
+ response.sessionFile,
423
+ active.trustedSessionRoot,
424
+ identity,
425
+ failedToolName(terminalError),
426
+ terminalError,
427
+ ),
428
+ readDelegationReplayAudit(
429
+ response.sessionFile,
430
+ active.trustedSessionRoot,
431
+ identity,
432
+ {
433
+ task: active.transcriptTask,
434
+ bashPermission: active.policy.permissions.bash,
435
+ approvedBashCommands: active.policy.approvedBashCommands ?? [],
436
+ },
437
+ ),
438
+ ]);
439
+ const validatedReplayAudit = validateReplayAudit(response, replayAudit);
440
+ const exitCode =
441
+ nonzeroTerminalExitCode(response) ??
442
+ response.exitCode ??
443
+ response.execution?.exitCode;
444
+ const reason = [
445
+ hasContradictoryCompletion(response)
446
+ ? `Subagent "${active.agent}" reported terminal failure signals with completed status.`
447
+ : `Subagent "${active.agent}" ${response.status.replaceAll('_', ' ')}.`,
448
+ ...(diagnostic ? formatToolFailureDiagnostic(diagnostic) : []),
449
+ ...(exitCode !== undefined ? [`Subagent exit code: ${exitCode}`] : []),
450
+ `Terminal error: ${boundedFailureField(error)}`,
451
+ ...(diagnostic && response.sessionFile
452
+ ? [
453
+ `Diagnostic session: ${boundedFailureField(response.sessionFile.replaceAll(/\s+/g, ' '))}`,
454
+ ]
455
+ : []),
456
+ ].join('\n');
457
+ return {
458
+ reason,
459
+ status: response.status,
460
+ ...(terminalError ? { error: terminalError } : {}),
461
+ ...(exitCode !== undefined ? { exitCode } : {}),
462
+ ...(diagnostic ? { diagnostic } : {}),
463
+ ...(validatedReplayAudit ? { replayAudit: validatedReplayAudit } : {}),
464
+ };
465
+ }
466
+
103
467
  interface MainStepIdentity {
104
468
  runId: string;
105
469
  stepId: string;
@@ -115,6 +479,12 @@ interface ActivePromptReview {
115
479
  abortController: AbortController;
116
480
  }
117
481
 
482
+ interface WorkflowStartContext {
483
+ context: ExtensionContext;
484
+ skills: () => readonly { name: string }[] | undefined;
485
+ waitForIdle: () => Promise<void>;
486
+ }
487
+
118
488
  function emptyCatalog(): WorkflowCatalog {
119
489
  return {
120
490
  workflows: new Map(),
@@ -135,6 +505,30 @@ function formatDiagnostics(catalog: WorkflowCatalog): string {
135
505
  ].join('\n');
136
506
  }
137
507
 
508
+ function skillNamesFromSystemPrompt(
509
+ systemPrompt: string,
510
+ ): Array<{ name: string }> {
511
+ const sections = [
512
+ ...systemPrompt.matchAll(
513
+ /<available_skills>([\s\S]*?)<\/available_skills>/g,
514
+ ),
515
+ ];
516
+ const section = sections.at(-1)?.[1] ?? '';
517
+ return [...section.matchAll(/<name>([^<]+)<\/name>/g)].map((match) => ({
518
+ name: match[1]!.trim(),
519
+ }));
520
+ }
521
+
522
+ async function waitForEventContextIdle(ctx: ExtensionContext): Promise<void> {
523
+ const deadline = Date.now() + 30_000;
524
+ while (!ctx.isIdle()) {
525
+ if (Date.now() >= deadline) {
526
+ throw new Error('Timed out waiting for the interrupted Pi turn to stop');
527
+ }
528
+ await new Promise((resolve) => setTimeout(resolve, 10));
529
+ }
530
+ }
531
+
138
532
  export class WorkflowHarness implements WorkflowCommandController {
139
533
  private readonly pi: ExtensionAPI;
140
534
  private readonly subagents: SubagentDelegationClient;
@@ -150,12 +544,24 @@ export class WorkflowHarness implements WorkflowCommandController {
150
544
  private registeredWorkflowCommands = new Set<string>();
151
545
  private catalogLoadSequence = 0;
152
546
  private readonly mutationQueue = new SerialTaskQueue();
153
-
154
- constructor(pi: ExtensionAPI) {
547
+ private readonly statusShortcut: KeyId;
548
+ private readonly statusShortcutLabel: string;
549
+ private statusRefreshTimer: ReturnType<typeof setInterval> | undefined;
550
+ private statusOverlayOpen = false;
551
+ private legacyProgressWidgetContext: ExtensionContext | undefined;
552
+
553
+ constructor(
554
+ pi: ExtensionAPI,
555
+ statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
556
+ ) {
155
557
  this.pi = pi;
558
+ this.statusShortcut = statusShortcut;
559
+ this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
156
560
  this.subagents = new SubagentDelegationClient(pi.events);
157
561
  this.mainSteps = new MainStepRuntime(pi);
158
562
  registerHarnessCommands(pi, this);
563
+ this.registerWorkflowStatusShortcut();
564
+ this.registerMultilineCommandInput();
159
565
  this.registerLifecycle();
160
566
  this.registerPolicy();
161
567
  this.registerPlannotatorResults();
@@ -165,6 +571,49 @@ export class WorkflowHarness implements WorkflowCommandController {
165
571
  return [...this.catalog.workflows.keys()].sort();
166
572
  }
167
573
 
574
+ private registerMultilineCommandInput(): void {
575
+ this.pi.on('input', async (event, ctx) => {
576
+ if (
577
+ event.source === 'extension' ||
578
+ event.images?.length ||
579
+ !event.text.startsWith('/')
580
+ ) {
581
+ return;
582
+ }
583
+ const newline = event.text.indexOf('\n');
584
+ if (newline === -1) return;
585
+ const command = event.text.slice(1, newline).replace(/\r$/, '');
586
+ if (!this.registeredWorkflowCommands.has(command)) return;
587
+ const workflow = [...this.catalog.workflows.values()].find(
588
+ (candidate) => candidate.definition.command === command,
589
+ );
590
+ if (!workflow) return;
591
+
592
+ const input = event.text.slice(newline + 1);
593
+ const skills = skillNamesFromSystemPrompt(ctx.getSystemPrompt());
594
+ try {
595
+ await this.enqueueMutation(ctx, (sessionEpoch) =>
596
+ this.startNow(
597
+ workflow.definition.id,
598
+ input,
599
+ {
600
+ context: ctx,
601
+ skills: () => skills,
602
+ waitForIdle: () => waitForEventContextIdle(ctx),
603
+ },
604
+ sessionEpoch,
605
+ ),
606
+ );
607
+ } catch (error) {
608
+ ctx.ui.notify(
609
+ `Cannot start workflow: ${error instanceof Error ? error.message : String(error)}`,
610
+ 'error',
611
+ );
612
+ }
613
+ return { action: 'handled' as const };
614
+ });
615
+ }
616
+
168
617
  async list(ctx: ExtensionCommandContext): Promise<void> {
169
618
  const workflows = [...this.catalog.workflows.values()].sort((left, right) =>
170
619
  left.definition.id.localeCompare(right.definition.id),
@@ -191,16 +640,26 @@ export class WorkflowHarness implements WorkflowCommandController {
191
640
  ctx: ExtensionCommandContext,
192
641
  ): Promise<void> {
193
642
  return this.enqueueMutation(ctx, (sessionEpoch) =>
194
- this.startNow(workflowId, input, ctx, sessionEpoch),
643
+ this.startNow(
644
+ workflowId,
645
+ input,
646
+ {
647
+ context: ctx,
648
+ skills: () => ctx.getSystemPromptOptions().skills,
649
+ waitForIdle: () => ctx.waitForIdle(),
650
+ },
651
+ sessionEpoch,
652
+ ),
195
653
  );
196
654
  }
197
655
 
198
656
  private async startNow(
199
657
  workflowId: string,
200
658
  input: string,
201
- ctx: ExtensionCommandContext,
659
+ startContext: WorkflowStartContext,
202
660
  sessionEpoch: number,
203
661
  ): Promise<void> {
662
+ const { context: ctx } = startContext;
204
663
  if (this.activeDelegation) {
205
664
  ctx.ui.notify(
206
665
  `Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`,
@@ -221,7 +680,7 @@ export class WorkflowHarness implements WorkflowCommandController {
221
680
  }
222
681
  if (!ctx.isIdle()) {
223
682
  ctx.abort();
224
- await ctx.waitForIdle();
683
+ await startContext.waitForIdle();
225
684
  }
226
685
  if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
227
686
  ctx.ui.notify(
@@ -231,7 +690,7 @@ export class WorkflowHarness implements WorkflowCommandController {
231
690
  return;
232
691
  }
233
692
 
234
- this.captureSkills(ctx.getSystemPromptOptions().skills);
693
+ this.captureSkills(startContext.skills());
235
694
  if (!(await this.reloadCatalog(ctx, false))) {
236
695
  ctx.ui.notify(
237
696
  'Workflow start was superseded by a newer configuration load',
@@ -271,6 +730,7 @@ export class WorkflowHarness implements WorkflowCommandController {
271
730
  this.persist();
272
731
  this.isolateMainSessionTools();
273
732
  this.updateStatus();
733
+ this.openWorkflowStatus(ctx);
274
734
  this.launchCurrentStep(workflow);
275
735
  }
276
736
 
@@ -510,7 +970,7 @@ export class WorkflowHarness implements WorkflowCommandController {
510
970
 
511
971
  const preflightErrors = this.preflight(workflow, this.run.currentStepId);
512
972
  if (preflightErrors.length > 0) {
513
- this.run = pauseRun(
973
+ this.run = failRun(
514
974
  this.run,
515
975
  `Step preflight failed: ${preflightErrors.join('; ')}`,
516
976
  Date.now(),
@@ -591,19 +1051,6 @@ export class WorkflowHarness implements WorkflowCommandController {
591
1051
  await this.reloadCatalog(ctx, true);
592
1052
  }
593
1053
 
594
- async status(ctx: ExtensionCommandContext): Promise<void> {
595
- const snapshot = this.workflowStatusSnapshot();
596
- if (!snapshot) {
597
- ctx.ui.notify('No workflow checkpoint in this session', 'info');
598
- return;
599
- }
600
- if (ctx.hasUI && ctx.mode === 'tui') {
601
- await showWorkflowStatus(ctx, () => this.workflowStatusSnapshot());
602
- return;
603
- }
604
- ctx.ui.notify(formatWorkflowStatusText(snapshot), 'info');
605
- }
606
-
607
1054
  private workflowStatusSnapshot(): WorkflowStatusSnapshot | undefined {
608
1055
  if (!this.run) return undefined;
609
1056
  const workflow = this.catalog.workflows.get(this.run.workflowId);
@@ -662,6 +1109,7 @@ export class WorkflowHarness implements WorkflowCommandController {
662
1109
  if (this.run) this.restoreBaselineTools();
663
1110
  this.run = undefined;
664
1111
  this.latestContext = undefined;
1112
+ this.stopStatusRefresh();
665
1113
  });
666
1114
  }
667
1115
 
@@ -672,7 +1120,7 @@ export class WorkflowHarness implements WorkflowCommandController {
672
1120
  if (!this.run || this.run.status !== 'running') return;
673
1121
  const workflow = this.catalog.workflows.get(this.run.workflowId);
674
1122
  if (!workflow) {
675
- this.run = pauseRun(
1123
+ this.run = failRun(
676
1124
  this.run,
677
1125
  'Workflow configuration disappeared; reload or restore it',
678
1126
  Date.now(),
@@ -683,12 +1131,15 @@ export class WorkflowHarness implements WorkflowCommandController {
683
1131
  return;
684
1132
  }
685
1133
  return {
686
- systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run)}`,
1134
+ systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run, this.statusShortcutLabel)}`,
687
1135
  };
688
1136
  });
689
1137
  }
690
1138
 
691
- private launchCurrentStep(workflow: LoadedWorkflow): void {
1139
+ private launchCurrentStep(
1140
+ workflow: LoadedWorkflow,
1141
+ reinforcementRetry?: { count: number; reason: string },
1142
+ ): void {
692
1143
  const run = this.run;
693
1144
  if (
694
1145
  !run ||
@@ -712,6 +1163,18 @@ export class WorkflowHarness implements WorkflowCommandController {
712
1163
  return;
713
1164
  }
714
1165
 
1166
+ const reviewedRepository = resolveReviewedRepositoryCwd(
1167
+ run.reviewedArtifact ?? '',
1168
+ );
1169
+ if (reviewedRepository.kind === 'invalid') {
1170
+ this.pauseForExecutionFailure('Subagent step', reviewedRepository.reason);
1171
+ return;
1172
+ }
1173
+ const delegationCwd =
1174
+ reviewedRepository.kind === 'resolved'
1175
+ ? reviewedRepository.cwd
1176
+ : (this.latestContext?.cwd ?? process.cwd());
1177
+ const runtimeAgent = subagent.agent;
715
1178
  const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
716
1179
  const resultDirectory = mkdtempSync(join(tmpdir(), 'pi-workflows-step-'));
717
1180
  const capabilityPath = join(resultDirectory, 'capability');
@@ -722,25 +1185,38 @@ export class WorkflowHarness implements WorkflowCommandController {
722
1185
  flag: 'wx',
723
1186
  mode: 0o600,
724
1187
  });
725
- const approvedBashCommands = extractApprovedBashCommands(
1188
+ const outcomes = allowedOutcomes(workflow, run);
1189
+ const outcomeSet = new Set(outcomes);
1190
+ const approvedBashCommands = narrowApprovedBashCommands(
726
1191
  run.reviewedArtifact ?? '',
1192
+ run.stepHandoff ?? '',
727
1193
  step.permissions.bash.approvedSources ?? [],
728
1194
  );
1195
+ const repositoryPolicy =
1196
+ reviewedRepository.kind === 'resolved'
1197
+ ? {
1198
+ repositoryCwd: reviewedRepository.repositoryCwd,
1199
+ ...(reviewedRepository.bootstrapping
1200
+ ? { bootstrapCwd: reviewedRepository.cwd }
1201
+ : {}),
1202
+ }
1203
+ : {};
729
1204
  const policyDigest = digest({
730
1205
  version: 1,
731
1206
  requestId,
732
- agent: subagent.agent,
1207
+ agent: runtimeAgent,
733
1208
  runId: run.runId,
734
1209
  stepId: run.currentStepId,
735
1210
  stepDigest: run.currentStepDigest,
736
1211
  capabilityPath,
737
1212
  resultPath,
738
1213
  approvedBashCommands,
1214
+ ...repositoryPolicy,
739
1215
  });
740
1216
  const policy: ChildStepPolicy = {
741
1217
  version: 1,
742
1218
  requestId,
743
- agent: subagent.agent,
1219
+ agent: runtimeAgent,
744
1220
  workflowId: workflow.definition.id,
745
1221
  runId: run.runId,
746
1222
  stepId: run.currentStepId,
@@ -751,10 +1227,32 @@ export class WorkflowHarness implements WorkflowCommandController {
751
1227
  resultPath,
752
1228
  permissions: structuredClone(step.permissions),
753
1229
  ...(approvedBashCommands.length > 0 ? { approvedBashCommands } : {}),
754
- outcomes: allowedOutcomes(workflow, run),
1230
+ ...repositoryPolicy,
1231
+ outcomes,
1232
+ pauseOutcomes: Object.entries(step.transitions)
1233
+ .filter(
1234
+ ([outcome, target]) => target === '$pause' && outcomeSet.has(outcome),
1235
+ )
1236
+ .map(([outcome]) => outcome),
755
1237
  summaryMaxChars: workflow.definition.summaryMaxChars,
756
1238
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
757
1239
  };
1240
+ const trustedSessionRoot = deriveSubagentSessionRoot(
1241
+ this.latestContext?.sessionManager.getSessionFile(),
1242
+ );
1243
+ const transcriptTask = [
1244
+ buildDelegatedStepTask(workflow, run, ''),
1245
+ delegationTranscriptBinding(requestId, policyDigest),
1246
+ ...(reinforcementRetry
1247
+ ? [
1248
+ reinforcementRetryTask(
1249
+ reinforcementRetry.reason,
1250
+ reinforcementRetry.count,
1251
+ MAX_REINFORCEMENT_RETRIES,
1252
+ ),
1253
+ ]
1254
+ : []),
1255
+ ].join('\n\n');
758
1256
  const active: ActiveDelegation = {
759
1257
  requestId,
760
1258
  runId: run.runId,
@@ -763,25 +1261,32 @@ export class WorkflowHarness implements WorkflowCommandController {
763
1261
  sessionEpoch: this.sessionEpoch,
764
1262
  resultDirectory,
765
1263
  policy,
1264
+ transcriptTask,
766
1265
  agent: subagent.agent,
1266
+ ...(trustedSessionRoot ? { trustedSessionRoot } : {}),
1267
+ reinforcementReplayAuthorized: subagent.retryToolFailures,
1268
+ reinforcementRetryCount: reinforcementRetry?.count ?? 0,
767
1269
  };
768
1270
  const request: SubagentDelegationRequest = {
769
1271
  version: 1,
770
1272
  requestId,
771
- agent: subagent.agent,
772
- task: buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
773
- context: subagent.context,
774
- cwd: this.latestContext?.cwd ?? process.cwd(),
1273
+ agent: runtimeAgent,
1274
+ task: `${encodeChildPolicy(policy)}\n\n${transcriptTask}`,
1275
+ // Workflow steps are isolation boundaries. Never fork the parent or a
1276
+ // sibling step's transcript; pass only the explicit workflow handoff.
1277
+ context: 'fresh',
1278
+ cwd: delegationCwd,
775
1279
  timeoutMs: subagent.timeoutMs,
776
1280
  skill:
777
1281
  step.permissions.skills.length > 0
778
1282
  ? [...step.permissions.skills]
779
1283
  : false,
780
- acceptance: {
781
- level: 'none',
782
- reason:
783
- 'Pi Workflows owns correlated step completion and human-review gates',
784
- },
1284
+ output: false,
1285
+ outputSchema: WORKFLOW_COMPLETION_PARAMETERS as unknown as Record<
1286
+ string,
1287
+ unknown
1288
+ >,
1289
+ agentContract: { version: 1 },
785
1290
  artifacts: subagent.artifacts,
786
1291
  ...(subagent.model ? { model: subagent.model } : {}),
787
1292
  ...(subagent.turnBudget
@@ -819,8 +1324,9 @@ export class WorkflowHarness implements WorkflowCommandController {
819
1324
  run: WorkflowRun,
820
1325
  step: WorkflowStep,
821
1326
  ): void {
822
- const approvedBashCommands = extractApprovedBashCommands(
1327
+ const approvedBashCommands = narrowApprovedBashCommands(
823
1328
  run.reviewedArtifact ?? '',
1329
+ run.stepHandoff ?? '',
824
1330
  step.permissions.bash.approvedSources ?? [],
825
1331
  );
826
1332
  const identity: MainStepIdentity = {
@@ -998,6 +1504,7 @@ export class WorkflowHarness implements WorkflowCommandController {
998
1504
  return;
999
1505
  }
1000
1506
  this.activeDelegation = undefined;
1507
+ let terminalFailure: DelegationFailureDetails | undefined;
1001
1508
 
1002
1509
  try {
1003
1510
  if (
@@ -1011,19 +1518,34 @@ export class WorkflowHarness implements WorkflowCommandController {
1011
1518
  ) {
1012
1519
  return;
1013
1520
  }
1014
- if (response.status !== 'completed') {
1015
- throw new Error(
1016
- `Subagent "${active.agent}" ${response.status.replaceAll('_', ' ')}${
1017
- response.error ? `: ${response.error}` : ''
1018
- }`,
1019
- );
1020
- }
1021
-
1022
1521
  const workflow = this.catalog.workflows.get(this.run.workflowId);
1023
1522
  const step = workflow?.definition.steps[this.run.currentStepId];
1024
1523
  if (!workflow || !step) {
1025
1524
  throw new Error('Active workflow configuration is unavailable');
1026
1525
  }
1526
+ let recoveredTerminalFailure: DelegationFailureDetails | undefined;
1527
+ if (
1528
+ response.status !== 'completed' ||
1529
+ hasContradictoryCompletion(response)
1530
+ ) {
1531
+ const failure = await delegationFailureDetails(active, response);
1532
+ terminalFailure = failure;
1533
+ if (
1534
+ response.status !== 'failed' ||
1535
+ failure.diagnostic?.completionAfterFailure !== true
1536
+ ) {
1537
+ throw new Error(failure.reason);
1538
+ }
1539
+ const projectionError = recoveredProjectionError(
1540
+ active,
1541
+ response,
1542
+ failure.diagnostic,
1543
+ );
1544
+ if (projectionError) {
1545
+ throw new Error(rejectedRecoveryReason(failure, projectionError));
1546
+ }
1547
+ recoveredTerminalFailure = failure;
1548
+ }
1027
1549
  const requiredSkillWarning =
1028
1550
  step.requires.skills.length > 0
1029
1551
  ? response.warnings?.find((warning) => /skill/i.test(warning))
@@ -1034,10 +1556,65 @@ export class WorkflowHarness implements WorkflowCommandController {
1034
1556
  );
1035
1557
  }
1036
1558
 
1037
- const rawResult = JSON.parse(
1038
- await readFile(active.policy.resultPath, 'utf8'),
1039
- ) as unknown;
1040
- const result = parseDelegatedStepResult(rawResult, active.policy);
1559
+ let serializedResult: string;
1560
+ try {
1561
+ serializedResult = await readStableDelegatedResult(active);
1562
+ } catch (error) {
1563
+ if (recoveredTerminalFailure) {
1564
+ throw new Error(
1565
+ rejectedRecoveryReason(recoveredTerminalFailure, error),
1566
+ { cause: error },
1567
+ );
1568
+ }
1569
+ if (
1570
+ (error as { code?: unknown } | null | undefined)?.code === 'ENOENT'
1571
+ ) {
1572
+ throw new Error(
1573
+ `Subagent "${active.agent}" completed without producing the required correlated structured_output result`,
1574
+ { cause: error },
1575
+ );
1576
+ }
1577
+ throw error;
1578
+ }
1579
+ let result: WorkflowStepResult;
1580
+ try {
1581
+ const rawResult = JSON.parse(serializedResult) as unknown;
1582
+ result = parseDelegatedStepResult(rawResult, active.policy);
1583
+ } catch (error) {
1584
+ if (recoveredTerminalFailure) {
1585
+ throw new Error(
1586
+ rejectedRecoveryReason(recoveredTerminalFailure, error),
1587
+ { cause: error },
1588
+ );
1589
+ }
1590
+ throw error;
1591
+ }
1592
+ if (
1593
+ recoveredTerminalFailure?.diagnostic &&
1594
+ !completionMatchesResult(
1595
+ recoveredTerminalFailure.diagnostic,
1596
+ result,
1597
+ active.policy,
1598
+ )
1599
+ ) {
1600
+ throw new Error(
1601
+ rejectedRecoveryReason(
1602
+ recoveredTerminalFailure,
1603
+ 'structured_output transcript value does not match the correlated result',
1604
+ ),
1605
+ );
1606
+ }
1607
+ if (recoveredTerminalFailure) {
1608
+ const falsePositive =
1609
+ recoveredTerminalFailure.diagnostic?.correlation ===
1610
+ 'successful-output-before-completion';
1611
+ this.latestContext?.ui.notify(
1612
+ falsePositive
1613
+ ? `Accepted "${active.stepId}" because the trusted child transcript proved the terminal tool error was a false positive and produced a matching structured result`
1614
+ : `Accepted "${active.stepId}" because the child resolved an earlier tool failure and produced a valid structured result`,
1615
+ 'warning',
1616
+ );
1617
+ }
1041
1618
  if (step.gate?.submitOutcome === result.outcome) {
1042
1619
  await this.submitGate(
1043
1620
  workflow,
@@ -1057,9 +1634,10 @@ export class WorkflowHarness implements WorkflowCommandController {
1057
1634
  );
1058
1635
  this.settleAfterTransition(workflow);
1059
1636
  } catch (error) {
1060
- this.pauseForDelegationFailure(
1061
- error instanceof Error ? error.message : String(error),
1062
- );
1637
+ const reason = error instanceof Error ? error.message : String(error);
1638
+ if (!this.retryDelegationAfterFailure(active, terminalFailure, reason)) {
1639
+ this.pauseForDelegationFailure(reason);
1640
+ }
1063
1641
  } finally {
1064
1642
  await this.cleanupDelegation(active);
1065
1643
  if (active.cancelling) this.releaseMainAfterCancellation(active);
@@ -1098,6 +1676,45 @@ export class WorkflowHarness implements WorkflowCommandController {
1098
1676
  await rm(active.resultDirectory, { recursive: true, force: true });
1099
1677
  }
1100
1678
 
1679
+ private retryDelegationAfterFailure(
1680
+ active: ActiveDelegation,
1681
+ failure: DelegationFailureDetails | undefined,
1682
+ reason: string,
1683
+ ): boolean {
1684
+ if (
1685
+ !failure ||
1686
+ active.reinforcementRetryCount >= MAX_REINFORCEMENT_RETRIES ||
1687
+ !isRetryableTerminalFailure(failure) ||
1688
+ !isSafeToRetryDelegation(
1689
+ active.policy,
1690
+ active.reinforcementReplayAuthorized,
1691
+ failure.replayAudit,
1692
+ ) ||
1693
+ !this.sessionActive ||
1694
+ this.sessionEpoch !== active.sessionEpoch ||
1695
+ !this.run ||
1696
+ this.run.status !== 'running' ||
1697
+ this.run.runId !== active.runId ||
1698
+ this.run.currentStepId !== active.stepId ||
1699
+ this.run.currentStepDigest !== active.stepDigest ||
1700
+ this.activeDelegation
1701
+ ) {
1702
+ return false;
1703
+ }
1704
+ const workflow = this.catalog.workflows.get(this.run.workflowId);
1705
+ if (!workflow) return false;
1706
+
1707
+ this.latestContext?.ui.notify(
1708
+ `Reinforcement retry for "${active.stepId}" after a subagent failure (${active.reinforcementRetryCount + 1}/${MAX_REINFORCEMENT_RETRIES})`,
1709
+ 'warning',
1710
+ );
1711
+ this.launchCurrentStep(workflow, {
1712
+ count: active.reinforcementRetryCount + 1,
1713
+ reason,
1714
+ });
1715
+ return true;
1716
+ }
1717
+
1101
1718
  private pauseForDelegationFailure(reason: string): void {
1102
1719
  this.pauseForExecutionFailure('Subagent step', reason);
1103
1720
  }
@@ -1105,7 +1722,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1105
1722
  private pauseForExecutionFailure(label: string, reason: string): void {
1106
1723
  if (!this.run || this.run.status !== 'running') return;
1107
1724
  this.mainSteps.deactivate();
1108
- this.run = pauseRun(this.run, `${label} failed: ${reason}`, Date.now());
1725
+ this.run = failRun(this.run, `${label} failed: ${reason}`, Date.now());
1109
1726
  this.persist();
1110
1727
  if (this.activeDelegation) {
1111
1728
  this.isolateMainSessionTools();
@@ -1126,7 +1743,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1126
1743
  active.cancelling = true;
1127
1744
  active.progress = 'cancellation unconfirmed';
1128
1745
  if (this.run?.status === 'running') {
1129
- this.run = pauseRun(
1746
+ this.run = failRun(
1130
1747
  this.run,
1131
1748
  `Subagent step failed: ${reason}`,
1132
1749
  Date.now(),
@@ -1169,6 +1786,34 @@ export class WorkflowHarness implements WorkflowCommandController {
1169
1786
  const step = workflow.definition.steps[originalRun.currentStepId];
1170
1787
  if (!step?.gate) throw new Error('Current step has no gate');
1171
1788
 
1789
+ const commandShapeError = reviewedCommandShapeError(artifact);
1790
+ if (commandShapeError) {
1791
+ const awaitingReview = beginGate(
1792
+ workflow,
1793
+ originalRun,
1794
+ outcome,
1795
+ artifact,
1796
+ requestId,
1797
+ Date.now(),
1798
+ );
1799
+ this.run = resolveGate(
1800
+ workflow,
1801
+ awaitingReview,
1802
+ {
1803
+ approved: false,
1804
+ feedback: commandShapeError,
1805
+ resolvedAt: Date.now(),
1806
+ },
1807
+ Date.now(),
1808
+ );
1809
+ this.latestContext?.ui.notify(
1810
+ `Plan contract needs repair before review: ${commandShapeError}`,
1811
+ 'warning',
1812
+ );
1813
+ this.settleAfterTransition(workflow);
1814
+ return;
1815
+ }
1816
+
1172
1817
  this.run = beginGate(
1173
1818
  workflow,
1174
1819
  originalRun,
@@ -1213,10 +1858,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1213
1858
  if (response.status !== 'handled') {
1214
1859
  const reason = response.error ?? 'Plannotator is unavailable';
1215
1860
  const gateFailed = failGate(this.run, reason, Date.now());
1216
- this.run =
1217
- this.run.status === 'paused'
1218
- ? pauseRun(gateFailed, reason, Date.now())
1219
- : gateFailed;
1861
+ this.run = failRun(gateFailed, reason, Date.now());
1220
1862
  this.persist();
1221
1863
  if (this.run.status === 'running') {
1222
1864
  this.isolateMainSessionTools();
@@ -1250,6 +1892,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1250
1892
  this.pausePromptGate(
1251
1893
  pendingGate.requestId,
1252
1894
  'Built-in review requires Pi TUI or RPC mode; resume there to continue',
1895
+ false,
1253
1896
  );
1254
1897
  return;
1255
1898
  }
@@ -1304,6 +1947,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1304
1947
  this.pausePromptGate(
1305
1948
  active.requestId,
1306
1949
  `Built-in review failed: ${reason}`,
1950
+ true,
1307
1951
  );
1308
1952
  })
1309
1953
  .catch((error: unknown) => {
@@ -1335,6 +1979,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1335
1979
  this.pausePromptGate(
1336
1980
  active.requestId,
1337
1981
  'Built-in review was dismissed; resume to reopen it',
1982
+ false,
1338
1983
  );
1339
1984
  return;
1340
1985
  }
@@ -1360,6 +2005,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1360
2005
  this.pausePromptGate(
1361
2006
  active.requestId,
1362
2007
  'Built-in review finished, but workflow configuration is unavailable',
2008
+ true,
1363
2009
  );
1364
2010
  return;
1365
2011
  }
@@ -1370,11 +2016,16 @@ export class WorkflowHarness implements WorkflowCommandController {
1370
2016
  this.pausePromptGate(
1371
2017
  active.requestId,
1372
2018
  `Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
2019
+ true,
1373
2020
  );
1374
2021
  }
1375
2022
  }
1376
2023
 
1377
- private pausePromptGate(requestId: string, reason: string): void {
2024
+ private pausePromptGate(
2025
+ requestId: string,
2026
+ reason: string,
2027
+ failed: boolean,
2028
+ ): void {
1378
2029
  if (
1379
2030
  !this.run ||
1380
2031
  this.run.pendingGate?.provider !== 'prompt' ||
@@ -1383,7 +2034,9 @@ export class WorkflowHarness implements WorkflowCommandController {
1383
2034
  return;
1384
2035
  }
1385
2036
  if (this.run.status === 'awaiting-gate') {
1386
- this.run = pauseRun(this.run, reason, Date.now());
2037
+ this.run = failed
2038
+ ? failRun(this.run, reason, Date.now())
2039
+ : pauseRun(this.run, reason, Date.now());
1387
2040
  }
1388
2041
  this.persist();
1389
2042
  this.restoreBaselineTools();
@@ -1445,7 +2098,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1445
2098
 
1446
2099
  const workflow = this.catalog.workflows.get(this.run.workflowId);
1447
2100
  if (!workflow) {
1448
- this.run = pauseRun(
2101
+ this.run = failRun(
1449
2102
  this.run,
1450
2103
  'Gate result arrived, but workflow configuration is unavailable',
1451
2104
  Date.now(),
@@ -1459,7 +2112,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1459
2112
  this.run = resolveGate(workflow, this.run, resolution, Date.now());
1460
2113
  this.settleAfterTransition(workflow);
1461
2114
  } catch (error) {
1462
- this.run = pauseRun(
2115
+ this.run = failRun(
1463
2116
  this.run,
1464
2117
  `Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`,
1465
2118
  Date.now(),
@@ -1475,7 +2128,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1475
2128
  if (this.run.status === 'running') {
1476
2129
  const preflightErrors = this.preflight(workflow, this.run.currentStepId);
1477
2130
  if (preflightErrors.length > 0) {
1478
- this.run = pauseRun(
2131
+ this.run = failRun(
1479
2132
  this.run,
1480
2133
  `Step preflight failed: ${preflightErrors.join('; ')}`,
1481
2134
  Date.now(),
@@ -1610,6 +2263,15 @@ export class WorkflowHarness implements WorkflowCommandController {
1610
2263
  return false;
1611
2264
  }
1612
2265
  this.latestContext = ctx;
2266
+ if (catalog.settings.statusShortcut !== this.statusShortcut) {
2267
+ catalog.diagnostics.push({
2268
+ level: 'warning',
2269
+ path: join(catalog.userDirectory, 'settings.yaml'),
2270
+ message:
2271
+ `settings.statusShortcut is "${catalog.settings.statusShortcut}", ` +
2272
+ `but the active shortcut is "${this.statusShortcut}"; run Pi /reload to apply shortcut changes`,
2273
+ });
2274
+ }
1613
2275
  const availableCommands = this.pi.getCommands();
1614
2276
  for (const [workflowId, workflow] of catalog.workflows) {
1615
2277
  const command = workflow.definition.command;
@@ -1658,19 +2320,87 @@ export class WorkflowHarness implements WorkflowCommandController {
1658
2320
  }
1659
2321
 
1660
2322
  private updateStatus(): void {
2323
+ this.refreshStatusWhileRunning();
1661
2324
  if (!this.latestContext) return;
2325
+ if (this.legacyProgressWidgetContext !== this.latestContext) {
2326
+ this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
2327
+ this.legacyProgressWidgetContext = this.latestContext;
2328
+ }
1662
2329
  if (!this.run) {
1663
2330
  this.latestContext.ui.setStatus(STATUS_KEY, undefined);
1664
2331
  return;
1665
2332
  }
1666
- const delegation = this.activeDelegation
1667
- ? `; ${this.activeDelegation.agent}: ${this.activeDelegation.progress ?? 'starting'}`
1668
- : this.mainSteps.activeStepId
1669
- ? '; main agent: running'
1670
- : '';
2333
+ const snapshot = this.workflowStatusSnapshot();
2334
+ if (this.run.status !== 'running') {
2335
+ this.latestContext.ui.setStatus(STATUS_KEY, undefined);
2336
+ return;
2337
+ }
1671
2338
  this.latestContext.ui.setStatus(
1672
2339
  STATUS_KEY,
1673
- `${this.run.workflowId}: ${this.run.currentStepId} (${this.run.status}${delegation})`,
2340
+ `${workflowStatusIcon(this.run, snapshot?.now)} ${this.run.workflowId}: working · ${this.statusShortcutLabel}`,
1674
2341
  );
1675
2342
  }
2343
+
2344
+ private refreshStatusWhileRunning(): void {
2345
+ if (this.run?.status === 'running' && this.latestContext) {
2346
+ if (this.statusRefreshTimer) return;
2347
+ this.statusRefreshTimer = setInterval(
2348
+ () => this.updateStatus(),
2349
+ STATUS_REFRESH_INTERVAL_MS,
2350
+ );
2351
+ this.statusRefreshTimer.unref?.();
2352
+ return;
2353
+ }
2354
+ this.stopStatusRefresh();
2355
+ }
2356
+
2357
+ private stopStatusRefresh(): void {
2358
+ if (this.statusRefreshTimer) clearInterval(this.statusRefreshTimer);
2359
+ this.statusRefreshTimer = undefined;
2360
+ }
2361
+
2362
+ private registerWorkflowStatusShortcut(): void {
2363
+ this.pi.registerShortcut(this.statusShortcut, {
2364
+ description: 'Toggle workflow status',
2365
+ handler: async (ctx) => {
2366
+ this.latestContext = ctx;
2367
+ if (this.statusOverlayOpen) return;
2368
+ if (!this.run) {
2369
+ ctx.ui.notify('No workflow checkpoint in this session', 'info');
2370
+ return;
2371
+ }
2372
+ await this.showWorkflowStatus(ctx);
2373
+ },
2374
+ });
2375
+ }
2376
+
2377
+ private openWorkflowStatus(ctx: ExtensionContext): void {
2378
+ void this.showWorkflowStatus(ctx).catch((error: unknown) => {
2379
+ ctx.ui.notify(
2380
+ `Cannot open workflow status: ${error instanceof Error ? error.message : String(error)}`,
2381
+ 'error',
2382
+ );
2383
+ });
2384
+ }
2385
+
2386
+ private async showWorkflowStatus(ctx: ExtensionContext): Promise<void> {
2387
+ if (
2388
+ this.statusOverlayOpen ||
2389
+ !this.run ||
2390
+ !ctx.hasUI ||
2391
+ ctx.mode !== 'tui'
2392
+ ) {
2393
+ return;
2394
+ }
2395
+ this.statusOverlayOpen = true;
2396
+ try {
2397
+ await showWorkflowStatusOverlay(
2398
+ ctx,
2399
+ () => this.workflowStatusSnapshot(),
2400
+ this.statusShortcut,
2401
+ );
2402
+ } finally {
2403
+ this.statusOverlayOpen = false;
2404
+ }
2405
+ }
1676
2406
  }