@wichayutdew/pi-workflows 0.2.2 → 0.2.3

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,
@@ -63,29 +66,66 @@ import {
63
66
  type SubagentDelegationResponse,
64
67
  type SubagentDelegationUpdate,
65
68
  } from './integrations/subagents/protocol.ts';
69
+ import {
70
+ deriveSubagentSessionRoot,
71
+ failedToolName,
72
+ formatToolFailureDiagnostic,
73
+ readToolFailureDiagnostic,
74
+ type ToolFailureDiagnostic,
75
+ } from './integrations/subagents/diagnostics.ts';
66
76
  import { preflightStep } from './preflight.ts';
67
77
  import {
68
78
  buildDelegatedStepTask,
69
79
  buildMainStepTask,
70
80
  buildMainWorkflowNotice,
81
+ toolRetryTask,
71
82
  } from './prompt.ts';
72
- import { extractApprovedBashCommands } from './policy/approved-commands.ts';
83
+ import {
84
+ narrowApprovedBashCommands,
85
+ resolveReviewedRepositoryCwd,
86
+ reviewedCommandShapeError,
87
+ } from './policy/approved-commands.ts';
73
88
  import {
74
89
  MainStepRuntime,
75
90
  type MainStepExecution,
76
91
  } from './runtime/main-step-runtime.ts';
92
+ import { WORKFLOW_COMPLETION_PARAMETERS } from './runtime/completion-tool.ts';
77
93
  import { SerialTaskQueue } from './runtime/serial-task-queue.ts';
78
94
  import type { WorkflowStepResult } from './runtime/step-result.ts';
79
95
  import { formatWorkflowList } from './workflow-list.ts';
80
96
  import {
81
- formatWorkflowStatusText,
82
- showWorkflowStatus,
97
+ formatShortcutLabel,
98
+ showWorkflowStatus as showWorkflowStatusOverlay,
99
+ workflowStatusIcon,
83
100
  type WorkflowStatusExecution,
84
101
  type WorkflowStatusSnapshot,
85
102
  } from './workflow-status.ts';
86
103
 
87
104
  const STATE_ENTRY_TYPE = 'pi-workflows-state-v1';
88
105
  const STATUS_KEY = 'pi-workflows';
106
+ const LEGACY_PROGRESS_WIDGET_KEY = 'pi-workflows-progress';
107
+ const STATUS_REFRESH_INTERVAL_MS = 250;
108
+ const MAX_TOOL_FAILURE_RETRIES = 1;
109
+
110
+ function isRetryableToolFailure(reason: string): boolean {
111
+ return failedToolName(reason) !== undefined;
112
+ }
113
+
114
+ function isSafeToRetryDelegation(
115
+ policy: ChildStepPolicy,
116
+ replayExplicitlyAuthorized: boolean,
117
+ diagnostic: ToolFailureDiagnostic | undefined,
118
+ ): boolean {
119
+ const tools = new Set(policy.permissions.tools);
120
+ return (
121
+ diagnostic?.replaySafe === true &&
122
+ !tools.has('edit') &&
123
+ !tools.has('write') &&
124
+ (replayExplicitlyAuthorized ||
125
+ policy.permissions.bash.mode === 'deny' ||
126
+ policy.permissions.bash.mode === 'read-only')
127
+ );
128
+ }
89
129
 
90
130
  interface ActiveDelegation {
91
131
  requestId: string;
@@ -96,10 +136,244 @@ interface ActiveDelegation {
96
136
  resultDirectory: string;
97
137
  policy: ChildStepPolicy;
98
138
  agent: string;
139
+ trustedSessionRoot?: string;
140
+ retryToolFailures: boolean;
141
+ toolFailureRetryCount: number;
142
+ retryDiagnostic?: ToolFailureDiagnostic;
99
143
  progress?: string;
100
144
  cancelling?: boolean;
101
145
  }
102
146
 
147
+ const MAX_FAILURE_FIELD_CHARS = 1_600;
148
+ const MAX_DELEGATED_RESULT_BYTES = 1024 * 1024;
149
+
150
+ function boundedFailureField(value: string): string {
151
+ if (value.length <= MAX_FAILURE_FIELD_CHARS) return value;
152
+ const marker = '… [truncated] …';
153
+ const available = MAX_FAILURE_FIELD_CHARS - marker.length - 2;
154
+ const startLength = Math.ceil(available / 2);
155
+ const endLength = Math.floor(available / 2);
156
+ return `${value.slice(0, startLength)}\n${marker}\n${value.slice(-endLength)}`;
157
+ }
158
+
159
+ async function readStableDelegatedResult(
160
+ active: ActiveDelegation,
161
+ ): Promise<string> {
162
+ const expectedPath = join(active.resultDirectory, 'result.json');
163
+ if (active.policy.resultPath !== expectedPath) {
164
+ throw new Error(
165
+ 'delegated result path does not match its private directory',
166
+ );
167
+ }
168
+ const inspected = await lstat(expectedPath);
169
+ if (inspected.isSymbolicLink() || !inspected.isFile()) {
170
+ throw new Error('delegated result is not a regular file');
171
+ }
172
+ const handle = await open(
173
+ expectedPath,
174
+ constants.O_RDONLY | constants.O_NOFOLLOW,
175
+ );
176
+ try {
177
+ const beforeRead = await handle.stat();
178
+ if (!beforeRead.isFile() || beforeRead.size > MAX_DELEGATED_RESULT_BYTES) {
179
+ throw new Error('delegated result is not a bounded regular file');
180
+ }
181
+ const value = await handle.readFile({ encoding: 'utf8' });
182
+ const afterRead = await handle.stat();
183
+ if (
184
+ afterRead.dev !== beforeRead.dev ||
185
+ afterRead.ino !== beforeRead.ino ||
186
+ afterRead.size !== beforeRead.size ||
187
+ afterRead.mtimeMs !== beforeRead.mtimeMs
188
+ ) {
189
+ throw new Error('delegated result changed while it was being read');
190
+ }
191
+ return value;
192
+ } finally {
193
+ await handle.close();
194
+ }
195
+ }
196
+
197
+ function rejectedRecoveryReason(
198
+ failure: DelegationFailureDetails,
199
+ error: unknown,
200
+ ): string {
201
+ const detail = error instanceof Error ? error.message : String(error);
202
+ return `${failure.reason}\nRecovery rejected: ${boundedFailureField(detail)}`;
203
+ }
204
+
205
+ function completionMatchesResult(
206
+ diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
207
+ result: WorkflowStepResult,
208
+ policy: ChildStepPolicy,
209
+ ): boolean {
210
+ const value = diagnostic.completionValue;
211
+ if (!value) return false;
212
+ const expectedKeys = [
213
+ 'outcome',
214
+ 'summary',
215
+ ...(result.artifact === undefined ? [] : ['artifact']),
216
+ ].sort();
217
+ const actualKeys = Object.keys(value).sort();
218
+ if (
219
+ actualKeys.length !== expectedKeys.length ||
220
+ !actualKeys.every((key, index) => key === expectedKeys[index])
221
+ ) {
222
+ return false;
223
+ }
224
+ try {
225
+ const completion = parseDelegatedStepResult(
226
+ {
227
+ ...value,
228
+ version: 1,
229
+ policyDigest: policy.policyDigest,
230
+ },
231
+ policy,
232
+ );
233
+ return (
234
+ completion.outcome === result.outcome &&
235
+ completion.summary === result.summary &&
236
+ completion.artifact === result.artifact
237
+ );
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ function recoveredProjectionError(
244
+ active: ActiveDelegation,
245
+ response: SubagentDelegationResponse,
246
+ diagnostic: NonNullable<DelegationFailureDetails['diagnostic']>,
247
+ ): string | undefined {
248
+ if (response.agent !== active.agent) {
249
+ return `terminal agent identity is ${JSON.stringify(response.agent)}; expected ${JSON.stringify(active.agent)}`;
250
+ }
251
+ if (response.childIndex !== 0) {
252
+ return `terminal child index is ${JSON.stringify(response.childIndex)}; expected 0`;
253
+ }
254
+ if (
255
+ typeof response.exitCode !== 'number' ||
256
+ !Number.isSafeInteger(response.exitCode) ||
257
+ response.exitCode <= 0
258
+ ) {
259
+ return `terminal exit code is ${JSON.stringify(response.exitCode)}; expected a positive safe integer`;
260
+ }
261
+ const execution = response.execution;
262
+ if (!execution) return 'terminal response has no execution projection';
263
+ if (execution.status !== 'failed' || execution.success !== false) {
264
+ return `execution projection is ${JSON.stringify({
265
+ status: execution.status,
266
+ success: execution.success,
267
+ })}; expected failed/false`;
268
+ }
269
+ if (execution.exitCode !== response.exitCode) {
270
+ return `execution exit code ${JSON.stringify(execution.exitCode)} does not match terminal exit code ${JSON.stringify(response.exitCode)}`;
271
+ }
272
+ if (
273
+ typeof response.error !== 'string' ||
274
+ !response.error ||
275
+ typeof execution.error !== 'string' ||
276
+ execution.error !== response.error
277
+ ) {
278
+ return 'terminal and execution errors are missing or do not match exactly';
279
+ }
280
+ const warnings = response.warnings as unknown;
281
+ if (
282
+ warnings !== undefined &&
283
+ (!Array.isArray(warnings) ||
284
+ warnings.some(
285
+ (warning) => typeof warning !== 'string' || warning.trim().length > 0,
286
+ ))
287
+ ) {
288
+ return `terminal response contains warning evidence: ${JSON.stringify(warnings)}`;
289
+ }
290
+ if (
291
+ diagnostic.transcriptToolCount !== undefined &&
292
+ response.toolCount !== undefined &&
293
+ response.toolCount !== diagnostic.transcriptToolCount
294
+ ) {
295
+ return `terminal tool count ${response.toolCount} does not match transcript tool count ${diagnostic.transcriptToolCount}`;
296
+ }
297
+ if (
298
+ diagnostic.transcriptTurnCount !== undefined &&
299
+ response.turns !== undefined &&
300
+ response.turns !== diagnostic.transcriptTurnCount
301
+ ) {
302
+ return `terminal turn count ${response.turns} does not match transcript turn count ${diagnostic.transcriptTurnCount}`;
303
+ }
304
+ const toolFailure = response.error.match(
305
+ /^\s*([a-z][\w-]*) failed\s*\(exit\s+(\d+)\)\s*:/i,
306
+ );
307
+ if (!toolFailure) {
308
+ return 'terminal error is not a recognized "<tool> failed (exit N): <detail>" failure';
309
+ }
310
+ const terminalTool = toolFailure[1]!;
311
+ const terminalExitCode = Number(toolFailure[2]);
312
+ if (
313
+ terminalTool.toLowerCase() !== diagnostic.tool.toLowerCase() ||
314
+ terminalExitCode !== response.exitCode
315
+ ) {
316
+ return `terminal tool/exit ${JSON.stringify({
317
+ tool: terminalTool,
318
+ exitCode: terminalExitCode,
319
+ })} does not match the correlated failure ${JSON.stringify({
320
+ tool: diagnostic.tool,
321
+ exitCode: response.exitCode,
322
+ })}`;
323
+ }
324
+ const unsafeFlag = (
325
+ [
326
+ ['interrupted', execution.interrupted],
327
+ ['timedOut', execution.timedOut],
328
+ ['stopped', execution.stopped],
329
+ ['detached', execution.detached],
330
+ ] as const
331
+ ).find(([, enabled]) => enabled === true)?.[0];
332
+ return unsafeFlag
333
+ ? `execution projection reports ${unsafeFlag}=true`
334
+ : undefined;
335
+ }
336
+
337
+ interface DelegationFailureDetails {
338
+ reason: string;
339
+ diagnostic?: Awaited<ReturnType<typeof readToolFailureDiagnostic>>;
340
+ }
341
+
342
+ async function delegationFailureDetails(
343
+ active: ActiveDelegation,
344
+ response: SubagentDelegationResponse,
345
+ ): Promise<DelegationFailureDetails> {
346
+ const error =
347
+ response.error ??
348
+ response.execution?.error ??
349
+ 'The subagent returned no terminal error details.';
350
+ const diagnostic = await readToolFailureDiagnostic(
351
+ response.sessionFile,
352
+ active.trustedSessionRoot,
353
+ response.runId !== undefined && response.childIndex !== undefined
354
+ ? { runId: response.runId, childIndex: response.childIndex }
355
+ : undefined,
356
+ failedToolName(error),
357
+ error,
358
+ );
359
+ const exitCode = response.exitCode ?? response.execution?.exitCode;
360
+ const reason = [
361
+ `Subagent "${active.agent}" ${response.status.replaceAll('_', ' ')}.`,
362
+ ...(diagnostic ? formatToolFailureDiagnostic(diagnostic) : []),
363
+ ...(exitCode !== undefined ? [`Subagent exit code: ${exitCode}`] : []),
364
+ `Terminal error: ${boundedFailureField(error)}`,
365
+ ...(diagnostic && response.sessionFile
366
+ ? [
367
+ `Diagnostic session: ${boundedFailureField(response.sessionFile.replaceAll(/\s+/g, ' '))}`,
368
+ ]
369
+ : []),
370
+ ].join('\n');
371
+ return {
372
+ reason,
373
+ ...(diagnostic ? { diagnostic } : {}),
374
+ };
375
+ }
376
+
103
377
  interface MainStepIdentity {
104
378
  runId: string;
105
379
  stepId: string;
@@ -115,6 +389,12 @@ interface ActivePromptReview {
115
389
  abortController: AbortController;
116
390
  }
117
391
 
392
+ interface WorkflowStartContext {
393
+ context: ExtensionContext;
394
+ skills: () => readonly { name: string }[] | undefined;
395
+ waitForIdle: () => Promise<void>;
396
+ }
397
+
118
398
  function emptyCatalog(): WorkflowCatalog {
119
399
  return {
120
400
  workflows: new Map(),
@@ -135,6 +415,30 @@ function formatDiagnostics(catalog: WorkflowCatalog): string {
135
415
  ].join('\n');
136
416
  }
137
417
 
418
+ function skillNamesFromSystemPrompt(
419
+ systemPrompt: string,
420
+ ): Array<{ name: string }> {
421
+ const sections = [
422
+ ...systemPrompt.matchAll(
423
+ /<available_skills>([\s\S]*?)<\/available_skills>/g,
424
+ ),
425
+ ];
426
+ const section = sections.at(-1)?.[1] ?? '';
427
+ return [...section.matchAll(/<name>([^<]+)<\/name>/g)].map((match) => ({
428
+ name: match[1]!.trim(),
429
+ }));
430
+ }
431
+
432
+ async function waitForEventContextIdle(ctx: ExtensionContext): Promise<void> {
433
+ const deadline = Date.now() + 30_000;
434
+ while (!ctx.isIdle()) {
435
+ if (Date.now() >= deadline) {
436
+ throw new Error('Timed out waiting for the interrupted Pi turn to stop');
437
+ }
438
+ await new Promise((resolve) => setTimeout(resolve, 10));
439
+ }
440
+ }
441
+
138
442
  export class WorkflowHarness implements WorkflowCommandController {
139
443
  private readonly pi: ExtensionAPI;
140
444
  private readonly subagents: SubagentDelegationClient;
@@ -150,12 +454,24 @@ export class WorkflowHarness implements WorkflowCommandController {
150
454
  private registeredWorkflowCommands = new Set<string>();
151
455
  private catalogLoadSequence = 0;
152
456
  private readonly mutationQueue = new SerialTaskQueue();
153
-
154
- constructor(pi: ExtensionAPI) {
457
+ private readonly statusShortcut: KeyId;
458
+ private readonly statusShortcutLabel: string;
459
+ private statusRefreshTimer: ReturnType<typeof setInterval> | undefined;
460
+ private statusOverlayOpen = false;
461
+ private legacyProgressWidgetContext: ExtensionContext | undefined;
462
+
463
+ constructor(
464
+ pi: ExtensionAPI,
465
+ statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
466
+ ) {
155
467
  this.pi = pi;
468
+ this.statusShortcut = statusShortcut;
469
+ this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
156
470
  this.subagents = new SubagentDelegationClient(pi.events);
157
471
  this.mainSteps = new MainStepRuntime(pi);
158
472
  registerHarnessCommands(pi, this);
473
+ this.registerWorkflowStatusShortcut();
474
+ this.registerMultilineCommandInput();
159
475
  this.registerLifecycle();
160
476
  this.registerPolicy();
161
477
  this.registerPlannotatorResults();
@@ -165,6 +481,49 @@ export class WorkflowHarness implements WorkflowCommandController {
165
481
  return [...this.catalog.workflows.keys()].sort();
166
482
  }
167
483
 
484
+ private registerMultilineCommandInput(): void {
485
+ this.pi.on('input', async (event, ctx) => {
486
+ if (
487
+ event.source === 'extension' ||
488
+ event.images?.length ||
489
+ !event.text.startsWith('/')
490
+ ) {
491
+ return;
492
+ }
493
+ const newline = event.text.indexOf('\n');
494
+ if (newline === -1) return;
495
+ const command = event.text.slice(1, newline).replace(/\r$/, '');
496
+ if (!this.registeredWorkflowCommands.has(command)) return;
497
+ const workflow = [...this.catalog.workflows.values()].find(
498
+ (candidate) => candidate.definition.command === command,
499
+ );
500
+ if (!workflow) return;
501
+
502
+ const input = event.text.slice(newline + 1);
503
+ const skills = skillNamesFromSystemPrompt(ctx.getSystemPrompt());
504
+ try {
505
+ await this.enqueueMutation(ctx, (sessionEpoch) =>
506
+ this.startNow(
507
+ workflow.definition.id,
508
+ input,
509
+ {
510
+ context: ctx,
511
+ skills: () => skills,
512
+ waitForIdle: () => waitForEventContextIdle(ctx),
513
+ },
514
+ sessionEpoch,
515
+ ),
516
+ );
517
+ } catch (error) {
518
+ ctx.ui.notify(
519
+ `Cannot start workflow: ${error instanceof Error ? error.message : String(error)}`,
520
+ 'error',
521
+ );
522
+ }
523
+ return { action: 'handled' as const };
524
+ });
525
+ }
526
+
168
527
  async list(ctx: ExtensionCommandContext): Promise<void> {
169
528
  const workflows = [...this.catalog.workflows.values()].sort((left, right) =>
170
529
  left.definition.id.localeCompare(right.definition.id),
@@ -191,16 +550,26 @@ export class WorkflowHarness implements WorkflowCommandController {
191
550
  ctx: ExtensionCommandContext,
192
551
  ): Promise<void> {
193
552
  return this.enqueueMutation(ctx, (sessionEpoch) =>
194
- this.startNow(workflowId, input, ctx, sessionEpoch),
553
+ this.startNow(
554
+ workflowId,
555
+ input,
556
+ {
557
+ context: ctx,
558
+ skills: () => ctx.getSystemPromptOptions().skills,
559
+ waitForIdle: () => ctx.waitForIdle(),
560
+ },
561
+ sessionEpoch,
562
+ ),
195
563
  );
196
564
  }
197
565
 
198
566
  private async startNow(
199
567
  workflowId: string,
200
568
  input: string,
201
- ctx: ExtensionCommandContext,
569
+ startContext: WorkflowStartContext,
202
570
  sessionEpoch: number,
203
571
  ): Promise<void> {
572
+ const { context: ctx } = startContext;
204
573
  if (this.activeDelegation) {
205
574
  ctx.ui.notify(
206
575
  `Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`,
@@ -221,7 +590,7 @@ export class WorkflowHarness implements WorkflowCommandController {
221
590
  }
222
591
  if (!ctx.isIdle()) {
223
592
  ctx.abort();
224
- await ctx.waitForIdle();
593
+ await startContext.waitForIdle();
225
594
  }
226
595
  if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
227
596
  ctx.ui.notify(
@@ -231,7 +600,7 @@ export class WorkflowHarness implements WorkflowCommandController {
231
600
  return;
232
601
  }
233
602
 
234
- this.captureSkills(ctx.getSystemPromptOptions().skills);
603
+ this.captureSkills(startContext.skills());
235
604
  if (!(await this.reloadCatalog(ctx, false))) {
236
605
  ctx.ui.notify(
237
606
  'Workflow start was superseded by a newer configuration load',
@@ -271,6 +640,7 @@ export class WorkflowHarness implements WorkflowCommandController {
271
640
  this.persist();
272
641
  this.isolateMainSessionTools();
273
642
  this.updateStatus();
643
+ this.openWorkflowStatus(ctx);
274
644
  this.launchCurrentStep(workflow);
275
645
  }
276
646
 
@@ -510,7 +880,7 @@ export class WorkflowHarness implements WorkflowCommandController {
510
880
 
511
881
  const preflightErrors = this.preflight(workflow, this.run.currentStepId);
512
882
  if (preflightErrors.length > 0) {
513
- this.run = pauseRun(
883
+ this.run = failRun(
514
884
  this.run,
515
885
  `Step preflight failed: ${preflightErrors.join('; ')}`,
516
886
  Date.now(),
@@ -591,19 +961,6 @@ export class WorkflowHarness implements WorkflowCommandController {
591
961
  await this.reloadCatalog(ctx, true);
592
962
  }
593
963
 
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
964
  private workflowStatusSnapshot(): WorkflowStatusSnapshot | undefined {
608
965
  if (!this.run) return undefined;
609
966
  const workflow = this.catalog.workflows.get(this.run.workflowId);
@@ -662,6 +1019,7 @@ export class WorkflowHarness implements WorkflowCommandController {
662
1019
  if (this.run) this.restoreBaselineTools();
663
1020
  this.run = undefined;
664
1021
  this.latestContext = undefined;
1022
+ this.stopStatusRefresh();
665
1023
  });
666
1024
  }
667
1025
 
@@ -672,7 +1030,7 @@ export class WorkflowHarness implements WorkflowCommandController {
672
1030
  if (!this.run || this.run.status !== 'running') return;
673
1031
  const workflow = this.catalog.workflows.get(this.run.workflowId);
674
1032
  if (!workflow) {
675
- this.run = pauseRun(
1033
+ this.run = failRun(
676
1034
  this.run,
677
1035
  'Workflow configuration disappeared; reload or restore it',
678
1036
  Date.now(),
@@ -683,12 +1041,15 @@ export class WorkflowHarness implements WorkflowCommandController {
683
1041
  return;
684
1042
  }
685
1043
  return {
686
- systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run)}`,
1044
+ systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run, this.statusShortcutLabel)}`,
687
1045
  };
688
1046
  });
689
1047
  }
690
1048
 
691
- private launchCurrentStep(workflow: LoadedWorkflow): void {
1049
+ private launchCurrentStep(
1050
+ workflow: LoadedWorkflow,
1051
+ toolRetry?: { count: number; reason: string },
1052
+ ): void {
692
1053
  const run = this.run;
693
1054
  if (
694
1055
  !run ||
@@ -712,6 +1073,18 @@ export class WorkflowHarness implements WorkflowCommandController {
712
1073
  return;
713
1074
  }
714
1075
 
1076
+ const reviewedRepository = resolveReviewedRepositoryCwd(
1077
+ run.reviewedArtifact ?? '',
1078
+ );
1079
+ if (reviewedRepository.kind === 'invalid') {
1080
+ this.pauseForExecutionFailure('Subagent step', reviewedRepository.reason);
1081
+ return;
1082
+ }
1083
+ const delegationCwd =
1084
+ reviewedRepository.kind === 'resolved'
1085
+ ? reviewedRepository.cwd
1086
+ : (this.latestContext?.cwd ?? process.cwd());
1087
+ const runtimeAgent = subagent.agent;
715
1088
  const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
716
1089
  const resultDirectory = mkdtempSync(join(tmpdir(), 'pi-workflows-step-'));
717
1090
  const capabilityPath = join(resultDirectory, 'capability');
@@ -722,25 +1095,38 @@ export class WorkflowHarness implements WorkflowCommandController {
722
1095
  flag: 'wx',
723
1096
  mode: 0o600,
724
1097
  });
725
- const approvedBashCommands = extractApprovedBashCommands(
1098
+ const outcomes = allowedOutcomes(workflow, run);
1099
+ const outcomeSet = new Set(outcomes);
1100
+ const approvedBashCommands = narrowApprovedBashCommands(
726
1101
  run.reviewedArtifact ?? '',
1102
+ run.stepHandoff ?? '',
727
1103
  step.permissions.bash.approvedSources ?? [],
728
1104
  );
1105
+ const repositoryPolicy =
1106
+ reviewedRepository.kind === 'resolved'
1107
+ ? {
1108
+ repositoryCwd: reviewedRepository.repositoryCwd,
1109
+ ...(reviewedRepository.bootstrapping
1110
+ ? { bootstrapCwd: reviewedRepository.cwd }
1111
+ : {}),
1112
+ }
1113
+ : {};
729
1114
  const policyDigest = digest({
730
1115
  version: 1,
731
1116
  requestId,
732
- agent: subagent.agent,
1117
+ agent: runtimeAgent,
733
1118
  runId: run.runId,
734
1119
  stepId: run.currentStepId,
735
1120
  stepDigest: run.currentStepDigest,
736
1121
  capabilityPath,
737
1122
  resultPath,
738
1123
  approvedBashCommands,
1124
+ ...repositoryPolicy,
739
1125
  });
740
1126
  const policy: ChildStepPolicy = {
741
1127
  version: 1,
742
1128
  requestId,
743
- agent: subagent.agent,
1129
+ agent: runtimeAgent,
744
1130
  workflowId: workflow.definition.id,
745
1131
  runId: run.runId,
746
1132
  stepId: run.currentStepId,
@@ -751,10 +1137,19 @@ export class WorkflowHarness implements WorkflowCommandController {
751
1137
  resultPath,
752
1138
  permissions: structuredClone(step.permissions),
753
1139
  ...(approvedBashCommands.length > 0 ? { approvedBashCommands } : {}),
754
- outcomes: allowedOutcomes(workflow, run),
1140
+ ...repositoryPolicy,
1141
+ outcomes,
1142
+ pauseOutcomes: Object.entries(step.transitions)
1143
+ .filter(
1144
+ ([outcome, target]) => target === '$pause' && outcomeSet.has(outcome),
1145
+ )
1146
+ .map(([outcome]) => outcome),
755
1147
  summaryMaxChars: workflow.definition.summaryMaxChars,
756
1148
  ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
757
1149
  };
1150
+ const trustedSessionRoot = deriveSubagentSessionRoot(
1151
+ this.latestContext?.sessionManager.getSessionFile(),
1152
+ );
758
1153
  const active: ActiveDelegation = {
759
1154
  requestId,
760
1155
  runId: run.runId,
@@ -764,24 +1159,33 @@ export class WorkflowHarness implements WorkflowCommandController {
764
1159
  resultDirectory,
765
1160
  policy,
766
1161
  agent: subagent.agent,
1162
+ ...(trustedSessionRoot ? { trustedSessionRoot } : {}),
1163
+ retryToolFailures: subagent.retryToolFailures,
1164
+ toolFailureRetryCount: toolRetry?.count ?? 0,
767
1165
  };
768
1166
  const request: SubagentDelegationRequest = {
769
1167
  version: 1,
770
1168
  requestId,
771
- agent: subagent.agent,
772
- task: buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
773
- context: subagent.context,
774
- cwd: this.latestContext?.cwd ?? process.cwd(),
1169
+ agent: runtimeAgent,
1170
+ task: [
1171
+ buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
1172
+ ...(toolRetry ? [toolRetryTask(toolRetry.reason)] : []),
1173
+ ].join('\n\n'),
1174
+ // Workflow steps are isolation boundaries. Never fork the parent or a
1175
+ // sibling step's transcript; pass only the explicit workflow handoff.
1176
+ context: 'fresh',
1177
+ cwd: delegationCwd,
775
1178
  timeoutMs: subagent.timeoutMs,
776
1179
  skill:
777
1180
  step.permissions.skills.length > 0
778
1181
  ? [...step.permissions.skills]
779
1182
  : false,
780
- acceptance: {
781
- level: 'none',
782
- reason:
783
- 'Pi Workflows owns correlated step completion and human-review gates',
784
- },
1183
+ output: false,
1184
+ outputSchema: WORKFLOW_COMPLETION_PARAMETERS as unknown as Record<
1185
+ string,
1186
+ unknown
1187
+ >,
1188
+ agentContract: { version: 1 },
785
1189
  artifacts: subagent.artifacts,
786
1190
  ...(subagent.model ? { model: subagent.model } : {}),
787
1191
  ...(subagent.turnBudget
@@ -819,8 +1223,9 @@ export class WorkflowHarness implements WorkflowCommandController {
819
1223
  run: WorkflowRun,
820
1224
  step: WorkflowStep,
821
1225
  ): void {
822
- const approvedBashCommands = extractApprovedBashCommands(
1226
+ const approvedBashCommands = narrowApprovedBashCommands(
823
1227
  run.reviewedArtifact ?? '',
1228
+ run.stepHandoff ?? '',
824
1229
  step.permissions.bash.approvedSources ?? [],
825
1230
  );
826
1231
  const identity: MainStepIdentity = {
@@ -1011,19 +1416,35 @@ export class WorkflowHarness implements WorkflowCommandController {
1011
1416
  ) {
1012
1417
  return;
1013
1418
  }
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
1419
  const workflow = this.catalog.workflows.get(this.run.workflowId);
1023
1420
  const step = workflow?.definition.steps[this.run.currentStepId];
1024
1421
  if (!workflow || !step) {
1025
1422
  throw new Error('Active workflow configuration is unavailable');
1026
1423
  }
1424
+ let recoveredTerminalFailure: DelegationFailureDetails | undefined;
1425
+ if (response.status !== 'completed') {
1426
+ const failure = await delegationFailureDetails(active, response);
1427
+ if (failure.diagnostic) {
1428
+ active.retryDiagnostic = failure.diagnostic;
1429
+ } else {
1430
+ delete active.retryDiagnostic;
1431
+ }
1432
+ if (
1433
+ response.status !== 'failed' ||
1434
+ failure.diagnostic?.completionAfterFailure !== true
1435
+ ) {
1436
+ throw new Error(failure.reason);
1437
+ }
1438
+ const projectionError = recoveredProjectionError(
1439
+ active,
1440
+ response,
1441
+ failure.diagnostic,
1442
+ );
1443
+ if (projectionError) {
1444
+ throw new Error(rejectedRecoveryReason(failure, projectionError));
1445
+ }
1446
+ recoveredTerminalFailure = failure;
1447
+ }
1027
1448
  const requiredSkillWarning =
1028
1449
  step.requires.skills.length > 0
1029
1450
  ? response.warnings?.find((warning) => /skill/i.test(warning))
@@ -1034,10 +1455,65 @@ export class WorkflowHarness implements WorkflowCommandController {
1034
1455
  );
1035
1456
  }
1036
1457
 
1037
- const rawResult = JSON.parse(
1038
- await readFile(active.policy.resultPath, 'utf8'),
1039
- ) as unknown;
1040
- const result = parseDelegatedStepResult(rawResult, active.policy);
1458
+ let serializedResult: string;
1459
+ try {
1460
+ serializedResult = await readStableDelegatedResult(active);
1461
+ } catch (error) {
1462
+ if (recoveredTerminalFailure) {
1463
+ throw new Error(
1464
+ rejectedRecoveryReason(recoveredTerminalFailure, error),
1465
+ { cause: error },
1466
+ );
1467
+ }
1468
+ if (
1469
+ (error as { code?: unknown } | null | undefined)?.code === 'ENOENT'
1470
+ ) {
1471
+ throw new Error(
1472
+ `Subagent "${active.agent}" completed without producing the required correlated structured_output result`,
1473
+ { cause: error },
1474
+ );
1475
+ }
1476
+ throw error;
1477
+ }
1478
+ let result: WorkflowStepResult;
1479
+ try {
1480
+ const rawResult = JSON.parse(serializedResult) as unknown;
1481
+ result = parseDelegatedStepResult(rawResult, active.policy);
1482
+ } catch (error) {
1483
+ if (recoveredTerminalFailure) {
1484
+ throw new Error(
1485
+ rejectedRecoveryReason(recoveredTerminalFailure, error),
1486
+ { cause: error },
1487
+ );
1488
+ }
1489
+ throw error;
1490
+ }
1491
+ if (
1492
+ recoveredTerminalFailure?.diagnostic &&
1493
+ !completionMatchesResult(
1494
+ recoveredTerminalFailure.diagnostic,
1495
+ result,
1496
+ active.policy,
1497
+ )
1498
+ ) {
1499
+ throw new Error(
1500
+ rejectedRecoveryReason(
1501
+ recoveredTerminalFailure,
1502
+ 'structured_output transcript value does not match the correlated result',
1503
+ ),
1504
+ );
1505
+ }
1506
+ if (recoveredTerminalFailure) {
1507
+ const falsePositive =
1508
+ recoveredTerminalFailure.diagnostic?.correlation ===
1509
+ 'successful-output-before-completion';
1510
+ this.latestContext?.ui.notify(
1511
+ falsePositive
1512
+ ? `Accepted "${active.stepId}" because the trusted child transcript proved the terminal tool error was a false positive and produced a matching structured result`
1513
+ : `Accepted "${active.stepId}" because the child resolved an earlier tool failure and produced a valid structured result`,
1514
+ 'warning',
1515
+ );
1516
+ }
1041
1517
  if (step.gate?.submitOutcome === result.outcome) {
1042
1518
  await this.submitGate(
1043
1519
  workflow,
@@ -1057,9 +1533,10 @@ export class WorkflowHarness implements WorkflowCommandController {
1057
1533
  );
1058
1534
  this.settleAfterTransition(workflow);
1059
1535
  } catch (error) {
1060
- this.pauseForDelegationFailure(
1061
- error instanceof Error ? error.message : String(error),
1062
- );
1536
+ const reason = error instanceof Error ? error.message : String(error);
1537
+ if (!this.retryDelegationAfterToolFailure(active, reason)) {
1538
+ this.pauseForDelegationFailure(reason);
1539
+ }
1063
1540
  } finally {
1064
1541
  await this.cleanupDelegation(active);
1065
1542
  if (active.cancelling) this.releaseMainAfterCancellation(active);
@@ -1098,6 +1575,43 @@ export class WorkflowHarness implements WorkflowCommandController {
1098
1575
  await rm(active.resultDirectory, { recursive: true, force: true });
1099
1576
  }
1100
1577
 
1578
+ private retryDelegationAfterToolFailure(
1579
+ active: ActiveDelegation,
1580
+ reason: string,
1581
+ ): boolean {
1582
+ if (
1583
+ active.toolFailureRetryCount >= MAX_TOOL_FAILURE_RETRIES ||
1584
+ !isRetryableToolFailure(reason) ||
1585
+ !isSafeToRetryDelegation(
1586
+ active.policy,
1587
+ active.retryToolFailures,
1588
+ active.retryDiagnostic,
1589
+ ) ||
1590
+ !this.sessionActive ||
1591
+ this.sessionEpoch !== active.sessionEpoch ||
1592
+ !this.run ||
1593
+ this.run.status !== 'running' ||
1594
+ this.run.runId !== active.runId ||
1595
+ this.run.currentStepId !== active.stepId ||
1596
+ this.run.currentStepDigest !== active.stepDigest ||
1597
+ this.activeDelegation
1598
+ ) {
1599
+ return false;
1600
+ }
1601
+ const workflow = this.catalog.workflows.get(this.run.workflowId);
1602
+ if (!workflow) return false;
1603
+
1604
+ this.latestContext?.ui.notify(
1605
+ `Retrying "${active.stepId}" after a tool failure (${active.toolFailureRetryCount + 1}/${MAX_TOOL_FAILURE_RETRIES})`,
1606
+ 'warning',
1607
+ );
1608
+ this.launchCurrentStep(workflow, {
1609
+ count: active.toolFailureRetryCount + 1,
1610
+ reason,
1611
+ });
1612
+ return true;
1613
+ }
1614
+
1101
1615
  private pauseForDelegationFailure(reason: string): void {
1102
1616
  this.pauseForExecutionFailure('Subagent step', reason);
1103
1617
  }
@@ -1105,7 +1619,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1105
1619
  private pauseForExecutionFailure(label: string, reason: string): void {
1106
1620
  if (!this.run || this.run.status !== 'running') return;
1107
1621
  this.mainSteps.deactivate();
1108
- this.run = pauseRun(this.run, `${label} failed: ${reason}`, Date.now());
1622
+ this.run = failRun(this.run, `${label} failed: ${reason}`, Date.now());
1109
1623
  this.persist();
1110
1624
  if (this.activeDelegation) {
1111
1625
  this.isolateMainSessionTools();
@@ -1126,7 +1640,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1126
1640
  active.cancelling = true;
1127
1641
  active.progress = 'cancellation unconfirmed';
1128
1642
  if (this.run?.status === 'running') {
1129
- this.run = pauseRun(
1643
+ this.run = failRun(
1130
1644
  this.run,
1131
1645
  `Subagent step failed: ${reason}`,
1132
1646
  Date.now(),
@@ -1169,6 +1683,34 @@ export class WorkflowHarness implements WorkflowCommandController {
1169
1683
  const step = workflow.definition.steps[originalRun.currentStepId];
1170
1684
  if (!step?.gate) throw new Error('Current step has no gate');
1171
1685
 
1686
+ const commandShapeError = reviewedCommandShapeError(artifact);
1687
+ if (commandShapeError) {
1688
+ const awaitingReview = beginGate(
1689
+ workflow,
1690
+ originalRun,
1691
+ outcome,
1692
+ artifact,
1693
+ requestId,
1694
+ Date.now(),
1695
+ );
1696
+ this.run = resolveGate(
1697
+ workflow,
1698
+ awaitingReview,
1699
+ {
1700
+ approved: false,
1701
+ feedback: commandShapeError,
1702
+ resolvedAt: Date.now(),
1703
+ },
1704
+ Date.now(),
1705
+ );
1706
+ this.latestContext?.ui.notify(
1707
+ `Plan contract needs repair before review: ${commandShapeError}`,
1708
+ 'warning',
1709
+ );
1710
+ this.settleAfterTransition(workflow);
1711
+ return;
1712
+ }
1713
+
1172
1714
  this.run = beginGate(
1173
1715
  workflow,
1174
1716
  originalRun,
@@ -1213,10 +1755,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1213
1755
  if (response.status !== 'handled') {
1214
1756
  const reason = response.error ?? 'Plannotator is unavailable';
1215
1757
  const gateFailed = failGate(this.run, reason, Date.now());
1216
- this.run =
1217
- this.run.status === 'paused'
1218
- ? pauseRun(gateFailed, reason, Date.now())
1219
- : gateFailed;
1758
+ this.run = failRun(gateFailed, reason, Date.now());
1220
1759
  this.persist();
1221
1760
  if (this.run.status === 'running') {
1222
1761
  this.isolateMainSessionTools();
@@ -1250,6 +1789,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1250
1789
  this.pausePromptGate(
1251
1790
  pendingGate.requestId,
1252
1791
  'Built-in review requires Pi TUI or RPC mode; resume there to continue',
1792
+ false,
1253
1793
  );
1254
1794
  return;
1255
1795
  }
@@ -1304,6 +1844,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1304
1844
  this.pausePromptGate(
1305
1845
  active.requestId,
1306
1846
  `Built-in review failed: ${reason}`,
1847
+ true,
1307
1848
  );
1308
1849
  })
1309
1850
  .catch((error: unknown) => {
@@ -1335,6 +1876,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1335
1876
  this.pausePromptGate(
1336
1877
  active.requestId,
1337
1878
  'Built-in review was dismissed; resume to reopen it',
1879
+ false,
1338
1880
  );
1339
1881
  return;
1340
1882
  }
@@ -1360,6 +1902,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1360
1902
  this.pausePromptGate(
1361
1903
  active.requestId,
1362
1904
  'Built-in review finished, but workflow configuration is unavailable',
1905
+ true,
1363
1906
  );
1364
1907
  return;
1365
1908
  }
@@ -1370,11 +1913,16 @@ export class WorkflowHarness implements WorkflowCommandController {
1370
1913
  this.pausePromptGate(
1371
1914
  active.requestId,
1372
1915
  `Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
1916
+ true,
1373
1917
  );
1374
1918
  }
1375
1919
  }
1376
1920
 
1377
- private pausePromptGate(requestId: string, reason: string): void {
1921
+ private pausePromptGate(
1922
+ requestId: string,
1923
+ reason: string,
1924
+ failed: boolean,
1925
+ ): void {
1378
1926
  if (
1379
1927
  !this.run ||
1380
1928
  this.run.pendingGate?.provider !== 'prompt' ||
@@ -1383,7 +1931,9 @@ export class WorkflowHarness implements WorkflowCommandController {
1383
1931
  return;
1384
1932
  }
1385
1933
  if (this.run.status === 'awaiting-gate') {
1386
- this.run = pauseRun(this.run, reason, Date.now());
1934
+ this.run = failed
1935
+ ? failRun(this.run, reason, Date.now())
1936
+ : pauseRun(this.run, reason, Date.now());
1387
1937
  }
1388
1938
  this.persist();
1389
1939
  this.restoreBaselineTools();
@@ -1445,7 +1995,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1445
1995
 
1446
1996
  const workflow = this.catalog.workflows.get(this.run.workflowId);
1447
1997
  if (!workflow) {
1448
- this.run = pauseRun(
1998
+ this.run = failRun(
1449
1999
  this.run,
1450
2000
  'Gate result arrived, but workflow configuration is unavailable',
1451
2001
  Date.now(),
@@ -1459,7 +2009,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1459
2009
  this.run = resolveGate(workflow, this.run, resolution, Date.now());
1460
2010
  this.settleAfterTransition(workflow);
1461
2011
  } catch (error) {
1462
- this.run = pauseRun(
2012
+ this.run = failRun(
1463
2013
  this.run,
1464
2014
  `Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`,
1465
2015
  Date.now(),
@@ -1475,7 +2025,7 @@ export class WorkflowHarness implements WorkflowCommandController {
1475
2025
  if (this.run.status === 'running') {
1476
2026
  const preflightErrors = this.preflight(workflow, this.run.currentStepId);
1477
2027
  if (preflightErrors.length > 0) {
1478
- this.run = pauseRun(
2028
+ this.run = failRun(
1479
2029
  this.run,
1480
2030
  `Step preflight failed: ${preflightErrors.join('; ')}`,
1481
2031
  Date.now(),
@@ -1610,6 +2160,15 @@ export class WorkflowHarness implements WorkflowCommandController {
1610
2160
  return false;
1611
2161
  }
1612
2162
  this.latestContext = ctx;
2163
+ if (catalog.settings.statusShortcut !== this.statusShortcut) {
2164
+ catalog.diagnostics.push({
2165
+ level: 'warning',
2166
+ path: join(catalog.userDirectory, 'settings.yaml'),
2167
+ message:
2168
+ `settings.statusShortcut is "${catalog.settings.statusShortcut}", ` +
2169
+ `but the active shortcut is "${this.statusShortcut}"; run Pi /reload to apply shortcut changes`,
2170
+ });
2171
+ }
1613
2172
  const availableCommands = this.pi.getCommands();
1614
2173
  for (const [workflowId, workflow] of catalog.workflows) {
1615
2174
  const command = workflow.definition.command;
@@ -1658,19 +2217,87 @@ export class WorkflowHarness implements WorkflowCommandController {
1658
2217
  }
1659
2218
 
1660
2219
  private updateStatus(): void {
2220
+ this.refreshStatusWhileRunning();
1661
2221
  if (!this.latestContext) return;
2222
+ if (this.legacyProgressWidgetContext !== this.latestContext) {
2223
+ this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
2224
+ this.legacyProgressWidgetContext = this.latestContext;
2225
+ }
1662
2226
  if (!this.run) {
1663
2227
  this.latestContext.ui.setStatus(STATUS_KEY, undefined);
1664
2228
  return;
1665
2229
  }
1666
- const delegation = this.activeDelegation
1667
- ? `; ${this.activeDelegation.agent}: ${this.activeDelegation.progress ?? 'starting'}`
1668
- : this.mainSteps.activeStepId
1669
- ? '; main agent: running'
1670
- : '';
2230
+ const snapshot = this.workflowStatusSnapshot();
2231
+ if (this.run.status !== 'running') {
2232
+ this.latestContext.ui.setStatus(STATUS_KEY, undefined);
2233
+ return;
2234
+ }
1671
2235
  this.latestContext.ui.setStatus(
1672
2236
  STATUS_KEY,
1673
- `${this.run.workflowId}: ${this.run.currentStepId} (${this.run.status}${delegation})`,
2237
+ `${workflowStatusIcon(this.run, snapshot?.now)} ${this.run.workflowId}: working · ${this.statusShortcutLabel}`,
1674
2238
  );
1675
2239
  }
2240
+
2241
+ private refreshStatusWhileRunning(): void {
2242
+ if (this.run?.status === 'running' && this.latestContext) {
2243
+ if (this.statusRefreshTimer) return;
2244
+ this.statusRefreshTimer = setInterval(
2245
+ () => this.updateStatus(),
2246
+ STATUS_REFRESH_INTERVAL_MS,
2247
+ );
2248
+ this.statusRefreshTimer.unref?.();
2249
+ return;
2250
+ }
2251
+ this.stopStatusRefresh();
2252
+ }
2253
+
2254
+ private stopStatusRefresh(): void {
2255
+ if (this.statusRefreshTimer) clearInterval(this.statusRefreshTimer);
2256
+ this.statusRefreshTimer = undefined;
2257
+ }
2258
+
2259
+ private registerWorkflowStatusShortcut(): void {
2260
+ this.pi.registerShortcut(this.statusShortcut, {
2261
+ description: 'Toggle workflow status',
2262
+ handler: async (ctx) => {
2263
+ this.latestContext = ctx;
2264
+ if (this.statusOverlayOpen) return;
2265
+ if (!this.run) {
2266
+ ctx.ui.notify('No workflow checkpoint in this session', 'info');
2267
+ return;
2268
+ }
2269
+ await this.showWorkflowStatus(ctx);
2270
+ },
2271
+ });
2272
+ }
2273
+
2274
+ private openWorkflowStatus(ctx: ExtensionContext): void {
2275
+ void this.showWorkflowStatus(ctx).catch((error: unknown) => {
2276
+ ctx.ui.notify(
2277
+ `Cannot open workflow status: ${error instanceof Error ? error.message : String(error)}`,
2278
+ 'error',
2279
+ );
2280
+ });
2281
+ }
2282
+
2283
+ private async showWorkflowStatus(ctx: ExtensionContext): Promise<void> {
2284
+ if (
2285
+ this.statusOverlayOpen ||
2286
+ !this.run ||
2287
+ !ctx.hasUI ||
2288
+ ctx.mode !== 'tui'
2289
+ ) {
2290
+ return;
2291
+ }
2292
+ this.statusOverlayOpen = true;
2293
+ try {
2294
+ await showWorkflowStatusOverlay(
2295
+ ctx,
2296
+ () => this.workflowStatusSnapshot(),
2297
+ this.statusShortcut,
2298
+ );
2299
+ } finally {
2300
+ this.statusOverlayOpen = false;
2301
+ }
2302
+ }
1676
2303
  }