@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
package/src/harness.ts CHANGED
@@ -1,8 +1,3 @@
1
- import { randomBytes, randomUUID } from 'node:crypto';
2
- import { constants, mkdtempSync, writeFileSync } from 'node:fs';
3
- import { lstat, open, rm } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
1
  import type {
7
2
  ExtensionAPI,
8
3
  ExtensionCommandContext,
@@ -13,2394 +8,332 @@ import {
13
8
  registerHarnessCommands,
14
9
  type WorkflowCommandController,
15
10
  } from './commands.ts';
16
- import { loadCatalog } from './config/load.ts';
17
- import { hasRuntimeCommandConflict } from './config/command-conflicts.ts';
18
11
  import {
19
- DEFAULT_SETTINGS,
20
12
  DEFAULT_STATUS_SHORTCUT,
21
13
  type LoadedWorkflow,
22
14
  type WorkflowCatalog,
23
15
  type WorkflowStep,
24
16
  } from './config/types.ts';
25
- import { digest } from './digest.ts';
17
+ import type { WorkflowRun } from './engine/state.ts';
18
+ import { type PromptGateReviewResult } from './integrations/prompt-gate.ts';
19
+ import type { SubagentDelegationClientController } from './integrations/subagents/client.ts';
26
20
  import {
27
- abortRun,
28
- advanceRun,
29
- allowedOutcomes,
30
- attachGateReviewId,
31
- beginGate,
32
- failGate,
33
- failRun,
34
- pauseRun,
35
- reconcileRun,
36
- resolveGate,
37
- resumeRun,
38
- storeGateResolution,
39
- } from './engine/transitions.ts';
40
- import {
41
- createRun,
42
- type GateResolution,
43
- type WorkflowRun,
44
- } from './engine/state.ts';
45
- import { readLatestCheckpoint } from './engine/checkpoint.ts';
46
- import {
47
- captureResumeCheckpoint,
48
- matchesResumeCheckpoint,
49
- } from './engine/resume.ts';
50
- import {
51
- parsePlannotatorResult,
52
- PLANNOTATOR_RESULT_CHANNEL,
53
- requestPlannotatorReview,
54
- requestPlannotatorReviewStatus,
55
- } from './integrations/plannotator.ts';
56
- import {
57
- requestPromptGateReview,
58
- type PromptGateReviewResult,
59
- } from './integrations/prompt-gate.ts';
60
- import { SubagentDelegationClient } from './integrations/subagents/client.ts';
61
- import {
62
- encodeChildPolicy,
63
- parseDelegatedStepResult,
64
- type ChildStepPolicy,
65
- type SubagentDelegationRequest,
66
21
  type SubagentDelegationResponse,
67
- type SubagentDelegationStatus,
68
22
  type SubagentDelegationUpdate,
69
23
  } from './integrations/subagents/protocol.ts';
24
+ import type { MainStepRuntimeController } from './runtime/main-step-runtime.ts';
25
+ import type { SerialTaskQueueController } from './runtime/serial-task-queue.ts';
26
+ import type { WorkflowStepResult } from './runtime/step-result.ts';
70
27
  import {
71
- deriveSubagentSessionRoot,
72
- failedToolName,
73
- formatToolFailureDiagnostic,
74
- readDelegationReplayAudit,
75
- readToolFailureDiagnostic,
76
- type DelegationReplayAudit,
77
- } from './integrations/subagents/diagnostics.ts';
78
- import { preflightStep } from './preflight.ts';
79
- import {
80
- buildDelegatedStepTask,
81
- buildMainStepTask,
82
- buildMainWorkflowNotice,
83
- reinforcementRetryTask,
84
- } from './prompt.ts';
85
- import {
86
- narrowApprovedBashCommands,
87
- resolveReviewedRepositoryCwd,
88
- reviewedCommandShapeError,
89
- } from './policy/approved-commands.ts';
28
+ createDelegationFailureActions,
29
+ type DelegationFailureActions,
30
+ } from './harness/delegation-failure.ts';
31
+ import { createEmptyCatalog } from './harness/catalog.ts';
90
32
  import {
91
- MainStepRuntime,
92
- type MainStepExecution,
93
- } from './runtime/main-step-runtime.ts';
94
- import { WORKFLOW_COMPLETION_PARAMETERS } from './runtime/completion-tool.ts';
95
- import { SerialTaskQueue } from './runtime/serial-task-queue.ts';
96
- import type { WorkflowStepResult } from './runtime/step-result.ts';
97
- import { formatWorkflowList } from './workflow-list.ts';
33
+ createWorkflowHarnessDependencies,
34
+ type WorkflowHarnessDependencies,
35
+ } from './harness/dependencies.ts';
36
+ import type {
37
+ ActiveDelegation,
38
+ ActivePromptReview,
39
+ DelegationRecovery,
40
+ DelegationFailureDetails,
41
+ MainStepIdentity,
42
+ WorkflowStartContext,
43
+ } from './harness/types.ts';
44
+ import { createStatusActions } from './harness/status-actions.ts';
45
+ import { createStartActions } from './harness/start-actions.ts';
46
+ import { createPauseActions } from './harness/pause-actions.ts';
47
+ import { createResumeAction } from './harness/resume-action.ts';
48
+ import { createLifecycleActions } from './harness/lifecycle-actions.ts';
49
+ import { createStepExecutionActions } from './harness/step-execution-actions.ts';
50
+ import { createDelegationResponseActions } from './harness/delegation-response-actions.ts';
51
+ import { createDelegationControlActions } from './harness/delegation-control-actions.ts';
52
+ import { createGateSubmissionAction } from './harness/gate-submission-action.ts';
53
+ import { createPromptGateActions } from './harness/prompt-gate-actions.ts';
54
+ import { createPlannotatorResultActions } from './harness/plannotator-result-actions.ts';
55
+ import { createCoreActions } from './harness/core-actions.ts';
98
56
  import {
99
57
  formatShortcutLabel,
100
- showWorkflowStatus as showWorkflowStatusOverlay,
101
- workflowStatusIcon,
102
- type WorkflowStatusExecution,
103
58
  type WorkflowStatusSnapshot,
104
59
  } from './workflow-status.ts';
105
60
 
106
- const STATE_ENTRY_TYPE = 'pi-workflows-state-v1';
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
- }
189
-
190
- interface ActiveDelegation {
191
- requestId: string;
192
- runId: string;
193
- stepId: string;
194
- stepDigest: string;
195
- sessionEpoch: number;
196
- resultDirectory: string;
197
- policy: ChildStepPolicy;
198
- transcriptTask: string;
199
- agent: string;
200
- trustedSessionRoot?: string;
201
- reinforcementReplayAuthorized: boolean;
202
- reinforcementRetryCount: number;
203
- progress?: string;
204
- cancelling?: boolean;
205
- }
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
-
467
- interface MainStepIdentity {
468
- runId: string;
469
- stepId: string;
470
- stepDigest: string;
471
- sessionEpoch: number;
472
- }
473
-
474
- interface ActivePromptReview {
475
- requestId: string;
476
- runId: string;
477
- stepId: string;
478
- sessionEpoch: number;
479
- abortController: AbortController;
480
- }
481
-
482
- interface WorkflowStartContext {
483
- context: ExtensionContext;
484
- skills: () => readonly { name: string }[] | undefined;
485
- waitForIdle: () => Promise<void>;
486
- }
487
-
488
- function emptyCatalog(): WorkflowCatalog {
489
- return {
490
- workflows: new Map(),
491
- settings: DEFAULT_SETTINGS,
492
- diagnostics: [],
493
- userDirectory: '',
494
- };
495
- }
496
-
497
- function formatDiagnostics(catalog: WorkflowCatalog): string {
498
- const shown = catalog.diagnostics
499
- .slice(0, 3)
500
- .map((item) => `${item.path}: ${item.message}`);
501
- const remaining = catalog.diagnostics.length - shown.length;
502
- return [
503
- ...shown,
504
- ...(remaining > 0 ? [`${remaining} more diagnostic(s)`] : []),
505
- ].join('\n');
506
- }
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
-
61
+ const STATUS_ACTIONS = createStatusActions();
62
+ const START_ACTIONS = createStartActions();
63
+ const PAUSE_ACTIONS = createPauseActions();
64
+ const RESUME_ACTION = createResumeAction();
65
+ const LIFECYCLE_ACTIONS = createLifecycleActions();
66
+ const STEP_EXECUTION_ACTIONS = createStepExecutionActions();
67
+ const DELEGATION_RESPONSE_ACTIONS = createDelegationResponseActions();
68
+ const DELEGATION_CONTROL_ACTIONS = createDelegationControlActions();
69
+ const GATE_SUBMISSION_ACTION = createGateSubmissionAction();
70
+ const PROMPT_GATE_ACTIONS = createPromptGateActions();
71
+ const PLANNOTATOR_RESULT_ACTIONS = createPlannotatorResultActions();
72
+ const CORE_ACTIONS = createCoreActions();
73
+
74
+ /**
75
+ * Coordinates workflow commands while delegating all external effects through
76
+ * injectable dependencies.
77
+ */
532
78
  export class WorkflowHarness implements WorkflowCommandController {
533
79
  private readonly pi: ExtensionAPI;
534
- private readonly subagents: SubagentDelegationClient;
535
- private readonly mainSteps: MainStepRuntime;
536
- private catalog: WorkflowCatalog = emptyCatalog();
80
+ private readonly dependencies: WorkflowHarnessDependencies;
81
+ private readonly delegationFailures: DelegationFailureActions;
82
+ private readonly subagents: SubagentDelegationClientController;
83
+ private readonly mainSteps: MainStepRuntimeController;
84
+ private catalog: WorkflowCatalog = createEmptyCatalog();
537
85
  private run: WorkflowRun | undefined;
538
86
  private latestContext: ExtensionContext | undefined;
539
87
  private availableSkills = new Set<string>();
540
- private sessionActive = false;
88
+ private isSessionActive = false;
541
89
  private sessionEpoch = 0;
542
90
  private activeDelegation: ActiveDelegation | undefined;
543
91
  private activePromptReview: ActivePromptReview | undefined;
544
92
  private registeredWorkflowCommands = new Set<string>();
545
93
  private catalogLoadSequence = 0;
546
- private readonly mutationQueue = new SerialTaskQueue();
94
+ private readonly mutationQueue: SerialTaskQueueController;
547
95
  private readonly statusShortcut: KeyId;
548
96
  private readonly statusShortcutLabel: string;
549
97
  private statusRefreshTimer: ReturnType<typeof setInterval> | undefined;
550
- private statusOverlayOpen = false;
98
+ private isStatusOverlayOpen = false;
551
99
  private legacyProgressWidgetContext: ExtensionContext | undefined;
552
-
553
- constructor(
554
- pi: ExtensionAPI,
555
- statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
556
- ) {
557
- this.pi = pi;
558
- this.statusShortcut = statusShortcut;
559
- this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
560
- this.subagents = new SubagentDelegationClient(pi.events);
561
- this.mainSteps = new MainStepRuntime(pi);
562
- registerHarnessCommands(pi, this);
563
- this.registerWorkflowStatusShortcut();
564
- this.registerMultilineCommandInput();
565
- this.registerLifecycle();
566
- this.registerPolicy();
567
- this.registerPlannotatorResults();
568
- }
569
-
570
- workflowIds(): string[] {
571
- return [...this.catalog.workflows.keys()].sort();
572
- }
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
-
617
- async list(ctx: ExtensionCommandContext): Promise<void> {
618
- const workflows = [...this.catalog.workflows.values()].sort((left, right) =>
619
- left.definition.id.localeCompare(right.definition.id),
620
- );
621
- if (workflows.length === 0) {
622
- ctx.ui.notify(
623
- `No workflows loaded from ${this.catalog.userDirectory}`,
624
- this.catalog.diagnostics.length > 0 ? 'warning' : 'info',
625
- );
626
- return;
627
- }
628
- this.pi.sendMessage({
629
- customType: 'workflow-list',
630
- content: formatWorkflowList(
631
- workflows.map((workflow) => workflow.definition),
632
- ),
633
- display: true,
634
- });
635
- }
636
-
637
- start(
638
- workflowId: string,
639
- input: string,
640
- ctx: ExtensionCommandContext,
641
- ): Promise<void> {
642
- return this.enqueueMutation(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
- ),
653
- );
654
- }
655
-
656
- private async startNow(
100
+ private readonly workflowStatusSnapshot: () =>
101
+ WorkflowStatusSnapshot | undefined = STATUS_ACTIONS.workflowStatusSnapshot;
102
+ private readonly updateStatus: () => void = STATUS_ACTIONS.updateStatus;
103
+ private readonly refreshStatusWhileRunning: () => void =
104
+ STATUS_ACTIONS.refreshStatusWhileRunning;
105
+ private readonly stopStatusRefresh: () => void =
106
+ STATUS_ACTIONS.stopStatusRefresh;
107
+ private readonly registerWorkflowStatusShortcut: () => void =
108
+ STATUS_ACTIONS.registerWorkflowStatusShortcut;
109
+ private readonly openWorkflowStatus: (context: ExtensionContext) => void =
110
+ STATUS_ACTIONS.openWorkflowStatus;
111
+ private readonly showWorkflowStatus: (
112
+ context: ExtensionContext,
113
+ ) => Promise<void> = STATUS_ACTIONS.showWorkflowStatus;
114
+ private readonly listWorkflows: (
115
+ context: ExtensionCommandContext,
116
+ ) => Promise<void> = START_ACTIONS.listWorkflows;
117
+ private readonly startNow: (
657
118
  workflowId: string,
658
119
  input: string,
659
120
  startContext: WorkflowStartContext,
660
121
  sessionEpoch: number,
661
- ): Promise<void> {
662
- const { context: ctx } = startContext;
663
- if (this.activeDelegation) {
664
- ctx.ui.notify(
665
- `Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`,
666
- 'warning',
667
- );
668
- return;
669
- }
670
- if (
671
- this.run &&
672
- this.run.status !== 'completed' &&
673
- this.run.status !== 'aborted'
674
- ) {
675
- ctx.ui.notify(
676
- `Workflow "${this.run.workflowId}" is ${this.run.status}; resume or abort it first`,
677
- 'warning',
678
- );
679
- return;
680
- }
681
- if (!ctx.isIdle()) {
682
- ctx.abort();
683
- await startContext.waitForIdle();
684
- }
685
- if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
686
- ctx.ui.notify(
687
- 'Workflow start was superseded by a session change',
688
- 'warning',
689
- );
690
- return;
691
- }
692
-
693
- this.captureSkills(startContext.skills());
694
- if (!(await this.reloadCatalog(ctx, false))) {
695
- ctx.ui.notify(
696
- 'Workflow start was superseded by a newer configuration load',
697
- 'warning',
698
- );
699
- return;
700
- }
701
- if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
702
- ctx.ui.notify(
703
- 'Workflow start was superseded by a session change',
704
- 'warning',
705
- );
706
- return;
707
- }
708
- const workflow = this.catalog.workflows.get(workflowId);
709
- if (!workflow) {
710
- ctx.ui.notify(`Workflow "${workflowId}" is not loaded`, 'error');
711
- return;
712
- }
713
- const preflightErrors = this.preflight(workflow, workflow.definition.start);
714
- if (preflightErrors.length > 0) {
715
- ctx.ui.notify(
716
- `Cannot start workflow:\n${preflightErrors.join('\n')}`,
717
- 'error',
718
- );
719
- return;
720
- }
721
-
722
- const baselineTools = this.pi.getActiveTools();
723
- this.run = createRun(
724
- workflow,
725
- input.trim(),
726
- baselineTools,
727
- randomUUID(),
728
- Date.now(),
729
- );
730
- this.persist();
731
- this.isolateMainSessionTools();
732
- this.updateStatus();
733
- this.openWorkflowStatus(ctx);
734
- this.launchCurrentStep(workflow);
735
- }
736
-
737
- pause(reason: string, ctx: ExtensionCommandContext): Promise<void> {
738
- return this.enqueueMutation(ctx, () => this.pauseNow(reason, ctx));
739
- }
740
-
741
- private async pauseNow(
122
+ ) => Promise<void> = START_ACTIONS.startNow;
123
+ private readonly reloadNow: (
124
+ context: ExtensionCommandContext,
125
+ ) => Promise<void> = START_ACTIONS.reloadNow;
126
+ private readonly pauseNow: (
742
127
  reason: string,
743
- ctx: ExtensionCommandContext,
744
- ): Promise<void> {
745
- if (
746
- !this.run ||
747
- this.run.status === 'completed' ||
748
- this.run.status === 'aborted'
749
- ) {
750
- ctx.ui.notify('No active workflow to pause', 'warning');
751
- return;
752
- }
753
- if (this.run.status === 'paused') {
754
- ctx.ui.notify(
755
- `Workflow is already paused${this.run.pauseReason ? `: ${this.run.pauseReason}` : ''}`,
756
- 'info',
757
- );
758
- return;
759
- }
760
- this.cancelPromptReview();
761
- const mainSuspended = this.mainSteps.suspend();
762
- const cancellationConfirmed = await this.cancelActiveDelegation(
763
- 'Workflow paused by user',
764
- );
765
- if (!ctx.isIdle()) {
766
- ctx.abort();
767
- if (mainSuspended) await ctx.waitForIdle();
768
- }
769
-
770
- this.run = pauseRun(this.run, reason, Date.now());
771
- this.persist();
772
- if (cancellationConfirmed) {
773
- this.restoreBaselineTools();
774
- } else {
775
- this.isolateMainSessionTools();
776
- }
777
- this.updateStatus();
778
- ctx.ui.notify(
779
- cancellationConfirmed
780
- ? `Paused "${this.run.workflowId}" at step "${this.run.currentStepId}". Fix the issue, then run /workflow-resume.`
781
- : `Pause recorded at "${this.run.currentStepId}", but child cancellation is not confirmed. Main tools remain isolated until it exits.`,
782
- cancellationConfirmed ? 'info' : 'warning',
783
- );
784
- }
785
-
786
- resume(ctx: ExtensionCommandContext): Promise<void> {
787
- return this.enqueueMutation(ctx, () => this.resumeNow(ctx));
788
- }
789
-
790
- private async resumeNow(ctx: ExtensionCommandContext): Promise<void> {
791
- if (!this.run || this.run.status !== 'paused') {
792
- ctx.ui.notify('No paused workflow to resume', 'warning');
793
- return;
794
- }
795
- if (this.activeDelegation) {
796
- ctx.ui.notify(
797
- `Cannot resume while subagent "${this.activeDelegation.agent}" is still cancelling`,
798
- 'warning',
799
- );
800
- return;
801
- }
802
- const checkpoint = captureResumeCheckpoint(this.run, this.sessionEpoch);
803
- if (!ctx.isIdle()) {
804
- ctx.abort();
805
- await ctx.waitForIdle();
806
- }
807
- if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
808
- ctx.ui.notify(
809
- 'Resume was superseded by another workflow or session change',
810
- 'warning',
811
- );
812
- return;
813
- }
814
- this.captureSkills(ctx.getSystemPromptOptions().skills);
815
- await this.reloadCatalog(ctx, false);
816
- if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
817
- ctx.ui.notify(
818
- 'Resume was superseded by another workflow or session change',
819
- 'warning',
820
- );
821
- return;
822
- }
823
-
824
- let workflow = this.catalog.workflows.get(this.run.workflowId);
825
- if (!workflow) {
826
- ctx.ui.notify(
827
- `Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`,
828
- 'error',
829
- );
830
- return;
831
- }
832
- const reconciled = reconcileRun(this.run, workflow, Date.now());
833
- if (!reconciled.run) {
834
- ctx.ui.notify(
835
- reconciled.error ?? 'Cannot reconcile workflow configuration',
836
- 'error',
837
- );
838
- return;
839
- }
840
-
841
- let resumed = reconciled.run;
842
- if (
843
- resumed.pendingGate?.provider === 'plannotator' &&
844
- !resumed.pendingGate.reviewId
845
- ) {
846
- resumed = failGate(
847
- resumed,
848
- 'Gate submission was interrupted before a review id was recorded; submit it again',
849
- Date.now(),
850
- );
851
- }
852
- if (
853
- resumed.pendingGate?.provider === 'plannotator' &&
854
- resumed.pendingGate.reviewId &&
855
- !resumed.pendingGate.resolution
856
- ) {
857
- const requestedReviewId = resumed.pendingGate.reviewId;
858
- const gateStep = workflow.definition.steps[resumed.pendingGate.stepId];
859
- const statusResponse = await requestPlannotatorReviewStatus(
860
- this.pi.events,
861
- `${resumed.runId}:review-status:${randomUUID()}`,
862
- requestedReviewId,
863
- gateStep?.gate?.provider === 'plannotator'
864
- ? gateStep.gate.timeoutMs
865
- : 5_000,
866
- );
867
- if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
868
- ctx.ui.notify(
869
- 'Resume was superseded by another workflow or session change',
870
- 'warning',
871
- );
872
- return;
873
- }
874
-
875
- workflow = this.catalog.workflows.get(this.run.workflowId);
876
- if (!workflow) {
877
- ctx.ui.notify(
878
- `Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`,
879
- 'error',
880
- );
881
- return;
882
- }
883
- const latest = reconcileRun(this.run, workflow, Date.now());
884
- if (!latest.run) {
885
- ctx.ui.notify(
886
- latest.error ?? 'Cannot reconcile workflow configuration',
887
- 'error',
888
- );
889
- return;
890
- }
891
- resumed = latest.run;
892
-
893
- if (
894
- !resumed.pendingGate?.resolution &&
895
- resumed.pendingGate?.reviewId === requestedReviewId &&
896
- statusResponse.status !== 'handled'
897
- ) {
898
- ctx.ui.notify(
899
- statusResponse.error ?? 'Cannot query the pending Plannotator review',
900
- 'error',
901
- );
902
- return;
903
- }
904
- if (
905
- !resumed.pendingGate?.resolution &&
906
- resumed.pendingGate?.reviewId === requestedReviewId &&
907
- statusResponse.status === 'handled' &&
908
- statusResponse.result.status === 'completed'
909
- ) {
910
- resumed = storeGateResolution(
911
- resumed,
912
- {
913
- approved: statusResponse.result.approved,
914
- feedback: statusResponse.result.feedback,
915
- resolvedAt: Date.now(),
916
- },
917
- Date.now(),
918
- );
919
- } else if (
920
- !resumed.pendingGate?.resolution &&
921
- resumed.pendingGate?.reviewId === requestedReviewId &&
922
- statusResponse.status === 'handled' &&
923
- statusResponse.result.status === 'missing'
924
- ) {
925
- resumed = failGate(
926
- resumed,
927
- 'Plannotator no longer has the pending review; submit it again',
928
- Date.now(),
929
- );
930
- }
931
- }
932
- const storedResolution = resumed.pendingGate?.resolution;
933
- if (storedResolution) {
934
- try {
935
- resumed = resolveGate(workflow, resumed, storedResolution, Date.now());
936
- } catch (error) {
937
- ctx.ui.notify(
938
- `Cannot apply stored gate result: ${error instanceof Error ? error.message : String(error)}`,
939
- 'error',
940
- );
941
- return;
942
- }
943
- } else {
944
- resumed = resumeRun(resumed, Date.now());
945
- }
946
- this.run = resumed;
947
-
948
- if (this.run.status === 'awaiting-gate') {
949
- this.persist();
950
- this.restoreBaselineTools();
951
- this.updateStatus();
952
- if (this.run.pendingGate?.provider === 'prompt') {
953
- this.launchPromptReview(workflow, this.run, ctx);
954
- ctx.ui.notify('Workflow resumed with built-in review open', 'info');
955
- return;
956
- }
957
- ctx.ui.notify(
958
- `Workflow resumed and is waiting for review ${this.run.pendingGate?.reviewId ?? ''}`.trim(),
959
- 'info',
960
- );
961
- return;
962
- }
963
- if (this.run.status !== 'running') {
964
- this.persist();
965
- this.restoreBaselineTools();
966
- this.updateStatus();
967
- ctx.ui.notify(`Workflow is now ${this.run.status}`, 'info');
968
- return;
969
- }
970
-
971
- const preflightErrors = this.preflight(workflow, this.run.currentStepId);
972
- if (preflightErrors.length > 0) {
973
- this.run = failRun(
974
- this.run,
975
- `Step preflight failed: ${preflightErrors.join('; ')}`,
976
- Date.now(),
977
- );
978
- this.persist();
979
- this.restoreBaselineTools();
980
- this.updateStatus();
981
- ctx.ui.notify(
982
- `Cannot resume workflow:\n${preflightErrors.join('\n')}`,
983
- 'error',
984
- );
985
- return;
986
- }
987
-
988
- this.persist();
989
- this.isolateMainSessionTools();
990
- this.updateStatus();
991
- this.launchCurrentStep(workflow);
992
- }
993
-
994
- abort(reason: string, ctx: ExtensionCommandContext): Promise<void> {
995
- return this.enqueueMutation(ctx, () => this.abortNow(reason, ctx));
996
- }
997
-
998
- private async abortNow(
128
+ context: ExtensionCommandContext,
129
+ ) => Promise<void> = PAUSE_ACTIONS.pauseNow;
130
+ private readonly abortNow: (
999
131
  reason: string,
1000
- ctx: ExtensionCommandContext,
1001
- ): Promise<void> {
1002
- if (
1003
- !this.run ||
1004
- this.run.status === 'completed' ||
1005
- this.run.status === 'aborted'
1006
- ) {
1007
- ctx.ui.notify('No active workflow to abort', 'warning');
1008
- return;
1009
- }
1010
- this.cancelPromptReview();
1011
- const mainSuspended = this.mainSteps.suspend();
1012
- const cancellationConfirmed = await this.cancelActiveDelegation(
1013
- 'Workflow aborted by user',
1014
- );
1015
- if (!ctx.isIdle()) {
1016
- ctx.abort();
1017
- if (mainSuspended) await ctx.waitForIdle();
1018
- }
1019
- this.run = abortRun(this.run, reason, Date.now());
1020
- this.persist();
1021
- if (cancellationConfirmed) {
1022
- this.restoreBaselineTools();
1023
- } else {
1024
- this.isolateMainSessionTools();
1025
- }
1026
- this.updateStatus();
1027
- ctx.ui.notify(
1028
- cancellationConfirmed
1029
- ? `Aborted workflow "${this.run.workflowId}"`
1030
- : `Workflow "${this.run.workflowId}" is aborted, but its child has not confirmed cancellation; main tools remain isolated`,
1031
- cancellationConfirmed ? 'info' : 'warning',
1032
- );
1033
- }
1034
-
1035
- reload(ctx: ExtensionCommandContext): Promise<void> {
1036
- return this.enqueueMutation(ctx, () => this.reloadNow(ctx));
1037
- }
1038
-
1039
- private async reloadNow(ctx: ExtensionCommandContext): Promise<void> {
1040
- if (
1041
- this.run &&
1042
- (this.run.status === 'running' || this.run.status === 'awaiting-gate')
1043
- ) {
1044
- ctx.ui.notify(
1045
- 'Pause the workflow before reloading its configuration',
1046
- 'warning',
1047
- );
1048
- return;
1049
- }
1050
- this.captureSkills(ctx.getSystemPromptOptions().skills);
1051
- await this.reloadCatalog(ctx, true);
1052
- }
1053
-
1054
- private workflowStatusSnapshot(): WorkflowStatusSnapshot | undefined {
1055
- if (!this.run) return undefined;
1056
- const workflow = this.catalog.workflows.get(this.run.workflowId);
1057
- let execution: WorkflowStatusExecution | undefined;
1058
- if (this.activeDelegation) {
1059
- execution = {
1060
- kind: 'subagent',
1061
- agent: this.activeDelegation.agent,
1062
- requestId: this.activeDelegation.requestId,
1063
- progress: this.activeDelegation.progress ?? 'starting',
1064
- };
1065
- } else if (this.mainSteps.activeStepId) {
1066
- execution = { kind: 'main' };
1067
- }
1068
- return {
1069
- run: this.run,
1070
- now: Date.now(),
1071
- ...(workflow ? { workflow } : {}),
1072
- ...(execution ? { execution } : {}),
1073
- };
1074
- }
1075
-
1076
- private registerLifecycle(): void {
1077
- this.pi.on('session_start', async (_event, ctx) => {
1078
- this.sessionEpoch += 1;
1079
- this.sessionActive = false;
1080
- this.cancelPromptReview();
1081
- this.mainSteps.deactivate();
1082
- await this.cancelActiveDelegation('Pi session changed');
1083
- if (this.run) this.restoreBaselineTools();
1084
- this.run = undefined;
1085
- this.latestContext = ctx;
1086
- if (!(await this.reloadCatalog(ctx, false))) return;
1087
- this.restoreFromSession(ctx);
1088
- this.sessionActive = true;
1089
- });
1090
-
1091
- this.pi.on('session_tree', async (_event, ctx) => {
1092
- this.sessionEpoch += 1;
1093
- this.sessionActive = false;
1094
- this.cancelPromptReview();
1095
- this.mainSteps.deactivate();
1096
- await this.cancelActiveDelegation('Pi session tree changed');
1097
- this.latestContext = ctx;
1098
- if (!(await this.reloadCatalog(ctx, false))) return;
1099
- this.restoreFromSession(ctx);
1100
- this.sessionActive = true;
1101
- });
1102
-
1103
- this.pi.on('session_shutdown', async () => {
1104
- this.sessionEpoch += 1;
1105
- this.sessionActive = false;
1106
- this.cancelPromptReview();
1107
- this.mainSteps.deactivate();
1108
- await this.cancelActiveDelegation('Pi session shut down');
1109
- if (this.run) this.restoreBaselineTools();
1110
- this.run = undefined;
1111
- this.latestContext = undefined;
1112
- this.stopStatusRefresh();
1113
- });
1114
- }
1115
-
1116
- private registerPolicy(): void {
1117
- this.pi.on('before_agent_start', (event, ctx) => {
1118
- this.latestContext = ctx;
1119
- this.captureSkills(event.systemPromptOptions.skills);
1120
- if (!this.run || this.run.status !== 'running') return;
1121
- const workflow = this.catalog.workflows.get(this.run.workflowId);
1122
- if (!workflow) {
1123
- this.run = failRun(
1124
- this.run,
1125
- 'Workflow configuration disappeared; reload or restore it',
1126
- Date.now(),
1127
- );
1128
- this.persist();
1129
- this.restoreBaselineTools();
1130
- this.updateStatus();
1131
- return;
1132
- }
1133
- return {
1134
- systemPrompt: `${event.systemPrompt}\n\n${buildMainWorkflowNotice(workflow, this.run, this.statusShortcutLabel)}`,
1135
- };
1136
- });
1137
- }
1138
-
1139
- private launchCurrentStep(
132
+ context: ExtensionCommandContext,
133
+ ) => Promise<void> = PAUSE_ACTIONS.abortNow;
134
+ private readonly resumeNow: (
135
+ context: ExtensionCommandContext,
136
+ ) => Promise<void> = RESUME_ACTION.resumeNow;
137
+ private readonly registerMultilineCommandInput: () => void =
138
+ LIFECYCLE_ACTIONS.registerMultilineCommandInput;
139
+ private readonly registerLifecycle: () => void =
140
+ LIFECYCLE_ACTIONS.registerLifecycle;
141
+ private readonly registerPolicy: () => void =
142
+ LIFECYCLE_ACTIONS.registerPolicy;
143
+ private readonly launchCurrentStep: (
1140
144
  workflow: LoadedWorkflow,
1141
- reinforcementRetry?: { count: number; reason: string },
1142
- ): void {
1143
- const run = this.run;
1144
- if (
1145
- !run ||
1146
- run.status !== 'running' ||
1147
- this.activeDelegation ||
1148
- this.mainSteps.activeStepId
1149
- ) {
1150
- return;
1151
- }
1152
- const step = workflow.definition.steps[run.currentStepId];
1153
- if (!step) {
1154
- this.pauseForExecutionFailure(
1155
- 'Workflow',
1156
- `Step "${run.currentStepId}" is missing from the workflow`,
1157
- );
1158
- return;
1159
- }
1160
- const subagent = step.subagent;
1161
- if (!subagent) {
1162
- this.launchMainStep(workflow, run, step);
1163
- return;
1164
- }
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;
1178
- const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
1179
- const resultDirectory = mkdtempSync(join(tmpdir(), 'pi-workflows-step-'));
1180
- const capabilityPath = join(resultDirectory, 'capability');
1181
- const capabilityToken = randomBytes(32).toString('hex');
1182
- const resultPath = join(resultDirectory, 'result.json');
1183
- writeFileSync(capabilityPath, capabilityToken, {
1184
- encoding: 'utf8',
1185
- flag: 'wx',
1186
- mode: 0o600,
1187
- });
1188
- const outcomes = allowedOutcomes(workflow, run);
1189
- const outcomeSet = new Set(outcomes);
1190
- const approvedBashCommands = narrowApprovedBashCommands(
1191
- run.reviewedArtifact ?? '',
1192
- run.stepHandoff ?? '',
1193
- step.permissions.bash.approvedSources ?? [],
1194
- );
1195
- const repositoryPolicy =
1196
- reviewedRepository.kind === 'resolved'
1197
- ? {
1198
- repositoryCwd: reviewedRepository.repositoryCwd,
1199
- ...(reviewedRepository.bootstrapping
1200
- ? { bootstrapCwd: reviewedRepository.cwd }
1201
- : {}),
1202
- }
1203
- : {};
1204
- const policyDigest = digest({
1205
- version: 1,
1206
- requestId,
1207
- agent: runtimeAgent,
1208
- runId: run.runId,
1209
- stepId: run.currentStepId,
1210
- stepDigest: run.currentStepDigest,
1211
- capabilityPath,
1212
- resultPath,
1213
- approvedBashCommands,
1214
- ...repositoryPolicy,
1215
- });
1216
- const policy: ChildStepPolicy = {
1217
- version: 1,
1218
- requestId,
1219
- agent: runtimeAgent,
1220
- workflowId: workflow.definition.id,
1221
- runId: run.runId,
1222
- stepId: run.currentStepId,
1223
- stepTitle: step.title,
1224
- policyDigest,
1225
- capabilityPath,
1226
- capabilityToken,
1227
- resultPath,
1228
- permissions: structuredClone(step.permissions),
1229
- ...(approvedBashCommands.length > 0 ? { approvedBashCommands } : {}),
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),
1237
- summaryMaxChars: workflow.definition.summaryMaxChars,
1238
- ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
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');
1256
- const active: ActiveDelegation = {
1257
- requestId,
1258
- runId: run.runId,
1259
- stepId: run.currentStepId,
1260
- stepDigest: run.currentStepDigest,
1261
- sessionEpoch: this.sessionEpoch,
1262
- resultDirectory,
1263
- policy,
1264
- transcriptTask,
1265
- agent: subagent.agent,
1266
- ...(trustedSessionRoot ? { trustedSessionRoot } : {}),
1267
- reinforcementReplayAuthorized: subagent.retryToolFailures,
1268
- reinforcementRetryCount: reinforcementRetry?.count ?? 0,
1269
- };
1270
- const request: SubagentDelegationRequest = {
1271
- version: 1,
1272
- requestId,
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,
1279
- timeoutMs: subagent.timeoutMs,
1280
- skill:
1281
- step.permissions.skills.length > 0
1282
- ? [...step.permissions.skills]
1283
- : false,
1284
- output: false,
1285
- outputSchema: WORKFLOW_COMPLETION_PARAMETERS as unknown as Record<
1286
- string,
1287
- unknown
1288
- >,
1289
- agentContract: { version: 1 },
1290
- artifacts: subagent.artifacts,
1291
- ...(subagent.model ? { model: subagent.model } : {}),
1292
- ...(subagent.turnBudget
1293
- ? { turnBudget: structuredClone(subagent.turnBudget) }
1294
- : {}),
1295
- ...(subagent.toolBudget
1296
- ? { toolBudget: structuredClone(subagent.toolBudget) }
1297
- : {}),
1298
- };
1299
-
1300
- this.activeDelegation = active;
1301
- this.updateStatus();
1302
- this.latestContext?.ui.notify(
1303
- `Delegated "${run.currentStepId}" to subagent "${subagent.agent}"`,
1304
- 'info',
1305
- );
1306
- void this.subagents
1307
- .delegate(request, {
1308
- onUpdate: (update) => this.handleDelegationUpdate(active, update),
1309
- onLateTerminal: (response) =>
1310
- this.queueDelegationResponse(active, response),
1311
- })
1312
- .then(
1313
- (response) => this.queueDelegationResponse(active, response),
1314
- (error: unknown) =>
1315
- this.queueDelegationFailure(
1316
- active,
1317
- error instanceof Error ? error.message : String(error),
1318
- ),
1319
- );
1320
- }
1321
-
1322
- private launchMainStep(
145
+ recovery?: DelegationRecovery,
146
+ ) => void = STEP_EXECUTION_ACTIONS.launchCurrentStep;
147
+ private readonly launchMainStep: (
1323
148
  workflow: LoadedWorkflow,
1324
149
  run: WorkflowRun,
1325
150
  step: WorkflowStep,
1326
- ): void {
1327
- const approvedBashCommands = narrowApprovedBashCommands(
1328
- run.reviewedArtifact ?? '',
1329
- run.stepHandoff ?? '',
1330
- step.permissions.bash.approvedSources ?? [],
1331
- );
1332
- const identity: MainStepIdentity = {
1333
- runId: run.runId,
1334
- stepId: run.currentStepId,
1335
- stepDigest: run.currentStepDigest,
1336
- sessionEpoch: this.sessionEpoch,
1337
- };
1338
- const policyDigest = digest({
1339
- version: 1,
1340
- execution: 'main',
1341
- workflowId: workflow.definition.id,
1342
- runId: run.runId,
1343
- stepId: run.currentStepId,
1344
- stepDigest: run.currentStepDigest,
1345
- permissions: step.permissions,
1346
- approvedBashCommands,
1347
- nonce: randomUUID(),
1348
- });
1349
- const execution: MainStepExecution = {
1350
- workflowId: workflow.definition.id,
1351
- runId: run.runId,
1352
- stepId: run.currentStepId,
1353
- stepDigest: run.currentStepDigest,
1354
- policyDigest,
1355
- step: structuredClone(step),
1356
- approvedBashCommands,
1357
- outcomes: allowedOutcomes(workflow, run),
1358
- summaryMaxChars: workflow.definition.summaryMaxChars,
1359
- ...(step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}),
1360
- onSettled: (result, context) =>
1361
- this.queueMainStepResult(identity, result, context),
1362
- };
1363
-
1364
- try {
1365
- this.mainSteps.activate(execution);
1366
- this.updateStatus();
1367
- this.latestContext?.ui.notify(
1368
- `Started "${run.currentStepId}" in the main agent`,
1369
- 'info',
1370
- );
1371
- this.pi.sendUserMessage(buildMainStepTask(workflow, run), {
1372
- deliverAs: 'followUp',
1373
- });
1374
- } catch (error) {
1375
- this.mainSteps.deactivate();
1376
- this.pauseForExecutionFailure(
1377
- 'Main-agent step',
1378
- error instanceof Error ? error.message : String(error),
1379
- );
1380
- }
1381
- }
1382
-
1383
- private queueMainStepResult(
151
+ ) => void = STEP_EXECUTION_ACTIONS.launchMainStep;
152
+ private readonly queueMainStepResult: (
1384
153
  identity: MainStepIdentity,
1385
154
  result: WorkflowStepResult | undefined,
1386
155
  context: ExtensionContext,
1387
- ): Promise<void> {
1388
- return this.mutationQueue
1389
- .run(() => this.finishMainStep(identity, result, context))
1390
- .catch((error: unknown) => {
1391
- this.pauseForExecutionFailure(
1392
- 'Main-agent step',
1393
- error instanceof Error ? error.message : String(error),
1394
- );
1395
- });
1396
- }
1397
-
1398
- private async finishMainStep(
156
+ ) => Promise<void> = STEP_EXECUTION_ACTIONS.queueMainStepResult;
157
+ private readonly finishMainStep: (
1399
158
  identity: MainStepIdentity,
1400
159
  result: WorkflowStepResult | undefined,
1401
160
  context: ExtensionContext,
1402
- ): Promise<void> {
1403
- this.latestContext = context;
1404
- if (
1405
- !this.sessionActive ||
1406
- this.sessionEpoch !== identity.sessionEpoch ||
1407
- !this.run ||
1408
- this.run.status !== 'running' ||
1409
- this.run.runId !== identity.runId ||
1410
- this.run.currentStepId !== identity.stepId ||
1411
- this.run.currentStepDigest !== identity.stepDigest
1412
- ) {
1413
- return;
1414
- }
1415
- if (!result) {
1416
- throw new Error(
1417
- 'agent settled without calling workflow_complete_step exactly once',
1418
- );
1419
- }
1420
-
1421
- const workflow = this.catalog.workflows.get(this.run.workflowId);
1422
- const step = workflow?.definition.steps[this.run.currentStepId];
1423
- if (!workflow || !step) {
1424
- throw new Error('Active workflow configuration is unavailable');
1425
- }
1426
- if (step.gate?.submitOutcome === result.outcome) {
1427
- await this.submitGate(
1428
- workflow,
1429
- this.run,
1430
- result.outcome,
1431
- result.artifact ?? '',
1432
- );
1433
- return;
1434
- }
1435
-
1436
- this.run = advanceRun(
1437
- workflow,
1438
- this.run,
1439
- result.outcome,
1440
- result.summary,
1441
- Date.now(),
1442
- );
1443
- this.settleAfterTransition(workflow);
1444
- }
1445
-
1446
- private handleDelegationUpdate(
161
+ ) => Promise<void> = STEP_EXECUTION_ACTIONS.finishMainStep;
162
+ private readonly handleDelegationUpdate: (
1447
163
  active: ActiveDelegation,
1448
164
  update: SubagentDelegationUpdate,
1449
- ): void {
1450
- if (this.activeDelegation !== active) return;
1451
- const progress = [
1452
- update.currentTool ? `tool ${update.currentTool}` : undefined,
1453
- update.toolCount !== undefined ? `${update.toolCount} calls` : undefined,
1454
- update.tokens !== undefined ? `${update.tokens} tokens` : undefined,
1455
- ].filter((part): part is string => part !== undefined);
1456
- active.progress = progress.join(', ') || 'running';
1457
- this.updateStatus();
1458
- }
1459
-
1460
- private queueDelegationResponse(
165
+ ) => void = DELEGATION_RESPONSE_ACTIONS.handleDelegationUpdate;
166
+ private readonly queueDelegationResponse: (
1461
167
  active: ActiveDelegation,
1462
168
  response: SubagentDelegationResponse,
1463
- ): void {
1464
- void this.mutationQueue
1465
- .run(() => this.finishDelegation(active, response))
1466
- .catch((error: unknown) => {
1467
- this.pauseForDelegationFailure(
1468
- error instanceof Error ? error.message : String(error),
1469
- );
1470
- });
1471
- }
1472
-
1473
- private queueDelegationFailure(
169
+ ) => void = DELEGATION_RESPONSE_ACTIONS.queueDelegationResponse;
170
+ private readonly queueDelegationFailure: (
1474
171
  active: ActiveDelegation,
1475
172
  reason: string,
1476
- ): void {
1477
- void this.mutationQueue
1478
- .run(async () => {
1479
- if (this.activeDelegation !== active) {
1480
- await this.cleanupDelegation(active);
1481
- return;
1482
- }
1483
- if (this.subagents.activeRequestId === active.requestId) {
1484
- this.retainUnconfirmedDelegation(active, reason);
1485
- return;
1486
- }
1487
- this.activeDelegation = undefined;
1488
- await this.cleanupDelegation(active);
1489
- this.pauseForDelegationFailure(reason);
1490
- })
1491
- .catch((error: unknown) => {
1492
- this.pauseForDelegationFailure(
1493
- error instanceof Error ? error.message : String(error),
1494
- );
1495
- });
1496
- }
1497
-
1498
- private async finishDelegation(
173
+ ) => void = DELEGATION_RESPONSE_ACTIONS.queueDelegationFailure;
174
+ private readonly finishDelegation: (
1499
175
  active: ActiveDelegation,
1500
176
  response: SubagentDelegationResponse,
1501
- ): Promise<void> {
1502
- if (this.activeDelegation !== active) {
1503
- await this.cleanupDelegation(active);
1504
- return;
1505
- }
1506
- this.activeDelegation = undefined;
1507
- let terminalFailure: DelegationFailureDetails | undefined;
1508
-
1509
- try {
1510
- if (
1511
- !this.sessionActive ||
1512
- this.sessionEpoch !== active.sessionEpoch ||
1513
- !this.run ||
1514
- this.run.status !== 'running' ||
1515
- this.run.runId !== active.runId ||
1516
- this.run.currentStepId !== active.stepId ||
1517
- this.run.currentStepDigest !== active.stepDigest
1518
- ) {
1519
- return;
1520
- }
1521
- const workflow = this.catalog.workflows.get(this.run.workflowId);
1522
- const step = workflow?.definition.steps[this.run.currentStepId];
1523
- if (!workflow || !step) {
1524
- throw new Error('Active workflow configuration is unavailable');
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
- }
1549
- const requiredSkillWarning =
1550
- step.requires.skills.length > 0
1551
- ? response.warnings?.find((warning) => /skill/i.test(warning))
1552
- : undefined;
1553
- if (requiredSkillWarning) {
1554
- throw new Error(
1555
- `Subagent skill preflight failed: ${requiredSkillWarning}`,
1556
- );
1557
- }
1558
-
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
- }
1618
- if (step.gate?.submitOutcome === result.outcome) {
1619
- await this.submitGate(
1620
- workflow,
1621
- this.run,
1622
- result.outcome,
1623
- result.artifact ?? '',
1624
- );
1625
- return;
1626
- }
1627
-
1628
- this.run = advanceRun(
1629
- workflow,
1630
- this.run,
1631
- result.outcome,
1632
- result.summary,
1633
- Date.now(),
1634
- );
1635
- this.settleAfterTransition(workflow);
1636
- } catch (error) {
1637
- const reason = error instanceof Error ? error.message : String(error);
1638
- if (!this.retryDelegationAfterFailure(active, terminalFailure, reason)) {
1639
- this.pauseForDelegationFailure(reason);
1640
- }
1641
- } finally {
1642
- await this.cleanupDelegation(active);
1643
- if (active.cancelling) this.releaseMainAfterCancellation(active);
1644
- }
1645
- }
1646
-
1647
- private async cancelActiveDelegation(reason: string): Promise<boolean> {
1648
- const active = this.activeDelegation;
1649
- if (!active) return true;
1650
- active.cancelling = true;
1651
- active.progress = 'cancelling';
1652
- this.updateStatus();
1653
- if (this.subagents.activeRequestId !== active.requestId) {
1654
- active.progress = 'cancellation unconfirmed';
1655
- this.updateStatus();
1656
- this.latestContext?.ui.notify(
1657
- `${reason}; the delegation channel already closed without a terminal response`,
1658
- 'warning',
1659
- );
1660
- return false;
1661
- }
1662
- const confirmed = await this.subagents.cancelActiveAndWait();
1663
- if (confirmed && this.activeDelegation === active) {
1664
- this.activeDelegation = undefined;
1665
- await this.cleanupDelegation(active);
1666
- } else if (!confirmed) {
1667
- this.latestContext?.ui.notify(
1668
- `${reason}; waiting for subagent "${active.agent}" to confirm termination`,
1669
- 'warning',
1670
- );
1671
- }
1672
- return confirmed;
1673
- }
1674
-
1675
- private async cleanupDelegation(active: ActiveDelegation): Promise<void> {
1676
- await rm(active.resultDirectory, { recursive: true, force: true });
1677
- }
1678
-
1679
- private retryDelegationAfterFailure(
177
+ ) => Promise<void> = DELEGATION_RESPONSE_ACTIONS.finishDelegation;
178
+ private readonly cancelActiveDelegation: (
179
+ reason: string,
180
+ ) => Promise<boolean> = DELEGATION_CONTROL_ACTIONS.cancelActiveDelegation;
181
+ private readonly cleanupDelegation: (
182
+ active: ActiveDelegation,
183
+ ) => Promise<void> = DELEGATION_CONTROL_ACTIONS.cleanupDelegation;
184
+ private readonly retryDelegationAfterFailure: (
1680
185
  active: ActiveDelegation,
1681
186
  failure: DelegationFailureDetails | undefined,
1682
187
  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
-
1718
- private pauseForDelegationFailure(reason: string): void {
1719
- this.pauseForExecutionFailure('Subagent step', reason);
1720
- }
1721
-
1722
- private pauseForExecutionFailure(label: string, reason: string): void {
1723
- if (!this.run || this.run.status !== 'running') return;
1724
- this.mainSteps.deactivate();
1725
- this.run = failRun(this.run, `${label} failed: ${reason}`, Date.now());
1726
- this.persist();
1727
- if (this.activeDelegation) {
1728
- this.isolateMainSessionTools();
1729
- } else {
1730
- this.restoreBaselineTools();
1731
- }
1732
- this.updateStatus();
1733
- this.latestContext?.ui.notify(
1734
- `Workflow paused at "${this.run.currentStepId}": ${reason}`,
1735
- 'error',
1736
- );
1737
- }
1738
-
1739
- private retainUnconfirmedDelegation(
188
+ ) => boolean = DELEGATION_CONTROL_ACTIONS.retryDelegationAfterFailure;
189
+ private readonly pauseForDelegationFailure: (reason: string) => void =
190
+ DELEGATION_CONTROL_ACTIONS.pauseForDelegationFailure;
191
+ private readonly pauseForExecutionFailure: (
192
+ label: string,
193
+ reason: string,
194
+ ) => void = DELEGATION_CONTROL_ACTIONS.pauseForExecutionFailure;
195
+ private readonly retainUnconfirmedDelegation: (
1740
196
  active: ActiveDelegation,
1741
197
  reason: string,
1742
- ): void {
1743
- active.cancelling = true;
1744
- active.progress = 'cancellation unconfirmed';
1745
- if (this.run?.status === 'running') {
1746
- this.run = failRun(
1747
- this.run,
1748
- `Subagent step failed: ${reason}`,
1749
- Date.now(),
1750
- );
1751
- this.persist();
1752
- }
1753
- this.isolateMainSessionTools();
1754
- this.updateStatus();
1755
- this.latestContext?.ui.notify(
1756
- `Workflow paused, but subagent "${active.agent}" has not confirmed termination. Main tools and resume remain blocked; restart Pi if no terminal response arrives.`,
1757
- 'error',
1758
- );
1759
- }
1760
-
1761
- private releaseMainAfterCancellation(active: ActiveDelegation): void {
1762
- if (this.activeDelegation === active) this.activeDelegation = undefined;
1763
- if (
1764
- !this.activeDelegation &&
1765
- this.run &&
1766
- this.run.status !== 'running' &&
1767
- this.run.status !== 'awaiting-gate'
1768
- ) {
1769
- this.restoreBaselineTools();
1770
- this.updateStatus();
1771
- this.latestContext?.ui.notify(
1772
- `Subagent "${active.agent}" has terminated; main tools are restored`,
1773
- 'info',
1774
- );
1775
- }
1776
- }
1777
-
1778
- private async submitGate(
198
+ ) => void = DELEGATION_CONTROL_ACTIONS.retainUnconfirmedDelegation;
199
+ private readonly releaseMainAfterCancellation: (
200
+ active: ActiveDelegation,
201
+ ) => void = DELEGATION_CONTROL_ACTIONS.releaseMainAfterCancellation;
202
+ private readonly submitGate: (
1779
203
  workflow: LoadedWorkflow,
1780
204
  originalRun: WorkflowRun,
1781
205
  outcome: string,
1782
206
  artifact: string,
1783
- ): Promise<void> {
1784
- const requestSessionEpoch = this.sessionEpoch;
1785
- const requestId = `${originalRun.runId}:${originalRun.currentStepId}:${randomUUID()}`;
1786
- const step = workflow.definition.steps[originalRun.currentStepId];
1787
- if (!step?.gate) throw new Error('Current step has no gate');
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
-
1817
- this.run = beginGate(
1818
- workflow,
1819
- originalRun,
1820
- outcome,
1821
- artifact,
1822
- requestId,
1823
- Date.now(),
1824
- );
1825
- this.persist();
1826
- this.restoreBaselineTools();
1827
- this.updateStatus();
1828
-
1829
- if (step.gate.provider === 'prompt') {
1830
- this.launchPromptReview(workflow, this.run, this.latestContext);
1831
- return;
1832
- }
1833
-
1834
- const response = await requestPlannotatorReview(
1835
- this.pi.events,
1836
- requestId,
1837
- artifact,
1838
- `pi-workflows:${workflow.definition.id}:${originalRun.currentStepId}`,
1839
- step.gate.timeoutMs,
1840
- );
1841
- if (
1842
- !this.sessionActive ||
1843
- this.sessionEpoch !== requestSessionEpoch ||
1844
- !this.run ||
1845
- this.run.runId !== originalRun.runId ||
1846
- this.run.currentStepId !== originalRun.currentStepId ||
1847
- this.run.pendingGate?.requestId !== requestId ||
1848
- this.run.pendingGate.reviewId !== undefined
1849
- ) {
1850
- throw new Error('Gate request was superseded by a workflow state change');
1851
- }
1852
- if (
1853
- this.run.status !== 'awaiting-gate' &&
1854
- !(this.run.status === 'paused' && this.run.pendingGate)
1855
- ) {
1856
- throw new Error('Gate request was superseded by a workflow state change');
1857
- }
1858
- if (response.status !== 'handled') {
1859
- const reason = response.error ?? 'Plannotator is unavailable';
1860
- const gateFailed = failGate(this.run, reason, Date.now());
1861
- this.run = failRun(gateFailed, reason, Date.now());
1862
- this.persist();
1863
- if (this.run.status === 'running') {
1864
- this.isolateMainSessionTools();
1865
- } else {
1866
- this.restoreBaselineTools();
1867
- }
1868
- this.updateStatus();
1869
- throw new Error(reason);
1870
- }
1871
- this.run = attachGateReviewId(
1872
- this.run,
1873
- response.result.reviewId,
1874
- Date.now(),
1875
- );
1876
- this.persist();
1877
- this.updateStatus();
1878
- this.latestContext?.ui.notify(
1879
- `Submitted "${originalRun.currentStepId}" for Plannotator review ${response.result.reviewId}`,
1880
- 'info',
1881
- );
1882
- }
1883
-
1884
- private launchPromptReview(
207
+ ) => Promise<void> = GATE_SUBMISSION_ACTION.submitGate;
208
+ private readonly launchPromptReview: (
1885
209
  workflow: LoadedWorkflow,
1886
210
  run: WorkflowRun,
1887
211
  context: ExtensionContext | undefined,
1888
- ): void {
1889
- const pendingGate = run.pendingGate;
1890
- if (!pendingGate || pendingGate.provider !== 'prompt') return;
1891
- if (!context?.hasUI) {
1892
- this.pausePromptGate(
1893
- pendingGate.requestId,
1894
- 'Built-in review requires Pi TUI or RPC mode; resume there to continue',
1895
- false,
1896
- );
1897
- return;
1898
- }
1899
- if (this.activePromptReview?.requestId === pendingGate.requestId) return;
1900
- this.cancelPromptReview();
1901
-
1902
- const active: ActivePromptReview = {
1903
- requestId: pendingGate.requestId,
1904
- runId: run.runId,
1905
- stepId: pendingGate.stepId,
1906
- sessionEpoch: this.sessionEpoch,
1907
- abortController: new AbortController(),
1908
- };
1909
- this.activePromptReview = active;
1910
- void requestPromptGateReview(
1911
- context.ui,
1912
- `Review ${workflow.definition.id}:${pendingGate.stepId}`,
1913
- pendingGate.artifact,
1914
- active.abortController.signal,
1915
- ).then(
1916
- (result) => this.queuePromptReviewResult(active, result),
1917
- (error: unknown) =>
1918
- this.queuePromptReviewFailure(
1919
- active,
1920
- error instanceof Error ? error.message : String(error),
1921
- ),
1922
- );
1923
- }
1924
-
1925
- private queuePromptReviewResult(
212
+ ) => void = PROMPT_GATE_ACTIONS.launchPromptReview;
213
+ private readonly queuePromptReviewResult: (
1926
214
  active: ActivePromptReview,
1927
215
  result: PromptGateReviewResult,
1928
- ): void {
1929
- void this.mutationQueue
1930
- .run(() => this.finishPromptReview(active, result))
1931
- .catch((error: unknown) => {
1932
- this.latestContext?.ui.notify(
1933
- `Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
1934
- 'error',
1935
- );
1936
- });
1937
- }
1938
-
1939
- private queuePromptReviewFailure(
216
+ ) => void = PROMPT_GATE_ACTIONS.queuePromptReviewResult;
217
+ private readonly queuePromptReviewFailure: (
1940
218
  active: ActivePromptReview,
1941
219
  reason: string,
1942
- ): void {
1943
- void this.mutationQueue
1944
- .run(async () => {
1945
- if (this.activePromptReview !== active) return;
1946
- this.activePromptReview = undefined;
1947
- this.pausePromptGate(
1948
- active.requestId,
1949
- `Built-in review failed: ${reason}`,
1950
- true,
1951
- );
1952
- })
1953
- .catch((error: unknown) => {
1954
- this.latestContext?.ui.notify(
1955
- `Cannot pause failed built-in review: ${error instanceof Error ? error.message : String(error)}`,
1956
- 'error',
1957
- );
1958
- });
1959
- }
1960
-
1961
- private async finishPromptReview(
220
+ ) => void = PROMPT_GATE_ACTIONS.queuePromptReviewFailure;
221
+ private readonly finishPromptReview: (
1962
222
  active: ActivePromptReview,
1963
223
  result: PromptGateReviewResult,
1964
- ): Promise<void> {
1965
- if (this.activePromptReview !== active) return;
1966
- this.activePromptReview = undefined;
1967
- if (
1968
- !this.sessionActive ||
1969
- this.sessionEpoch !== active.sessionEpoch ||
1970
- !this.run ||
1971
- this.run.runId !== active.runId ||
1972
- this.run.currentStepId !== active.stepId ||
1973
- this.run.pendingGate?.provider !== 'prompt' ||
1974
- this.run.pendingGate.requestId !== active.requestId
1975
- ) {
1976
- return;
1977
- }
1978
- if (result.status === 'dismissed') {
1979
- this.pausePromptGate(
1980
- active.requestId,
1981
- 'Built-in review was dismissed; resume to reopen it',
1982
- false,
1983
- );
1984
- return;
1985
- }
1986
-
1987
- const resolution: GateResolution = {
1988
- approved: result.approved,
1989
- feedback: result.feedback,
1990
- resolvedAt: Date.now(),
1991
- };
1992
- if (this.run.status === 'paused') {
1993
- this.run = storeGateResolution(this.run, resolution, Date.now());
1994
- this.persist();
1995
- this.latestContext?.ui.notify(
1996
- 'Built-in review finished while paused. Run /workflow-resume to apply it.',
1997
- 'info',
1998
- );
1999
- return;
2000
- }
2001
- if (this.run.status !== 'awaiting-gate') return;
2002
-
2003
- const workflow = this.catalog.workflows.get(this.run.workflowId);
2004
- if (!workflow) {
2005
- this.pausePromptGate(
2006
- active.requestId,
2007
- 'Built-in review finished, but workflow configuration is unavailable',
2008
- true,
2009
- );
2010
- return;
2011
- }
2012
- try {
2013
- this.run = resolveGate(workflow, this.run, resolution, Date.now());
2014
- this.settleAfterTransition(workflow);
2015
- } catch (error) {
2016
- this.pausePromptGate(
2017
- active.requestId,
2018
- `Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`,
2019
- true,
2020
- );
2021
- }
2022
- }
2023
-
2024
- private pausePromptGate(
224
+ ) => Promise<void> = PROMPT_GATE_ACTIONS.finishPromptReview;
225
+ private readonly pausePromptGate: (
2025
226
  requestId: string,
2026
227
  reason: string,
2027
- failed: boolean,
2028
- ): void {
2029
- if (
2030
- !this.run ||
2031
- this.run.pendingGate?.provider !== 'prompt' ||
2032
- this.run.pendingGate.requestId !== requestId
2033
- ) {
2034
- return;
2035
- }
2036
- if (this.run.status === 'awaiting-gate') {
2037
- this.run = failed
2038
- ? failRun(this.run, reason, Date.now())
2039
- : pauseRun(this.run, reason, Date.now());
2040
- }
2041
- this.persist();
2042
- this.restoreBaselineTools();
2043
- this.updateStatus();
2044
- this.latestContext?.ui.notify(
2045
- `Workflow paused at "${this.run.currentStepId}": ${reason}`,
2046
- 'warning',
2047
- );
2048
- }
2049
-
2050
- private cancelPromptReview(): void {
2051
- const active = this.activePromptReview;
2052
- if (!active) return;
2053
- this.activePromptReview = undefined;
2054
- active.abortController.abort();
2055
- }
2056
-
2057
- private registerPlannotatorResults(): void {
2058
- this.pi.events.on(PLANNOTATOR_RESULT_CHANNEL, (data) => {
2059
- void this.mutationQueue
2060
- .run(() => this.handlePlannotatorResult(data))
2061
- .catch((error: unknown) => {
2062
- this.latestContext?.ui.notify(
2063
- `Cannot apply Plannotator result: ${
2064
- error instanceof Error ? error.message : String(error)
2065
- }`,
2066
- 'error',
2067
- );
2068
- });
2069
- });
2070
- }
2071
-
2072
- private async handlePlannotatorResult(data: unknown): Promise<void> {
2073
- if (
2074
- !this.sessionActive ||
2075
- this.run?.pendingGate?.provider !== 'plannotator' ||
2076
- !this.run.pendingGate.reviewId
2077
- ) {
2078
- return;
2079
- }
2080
- const result = parsePlannotatorResult(data);
2081
- if (!result || result.reviewId !== this.run.pendingGate.reviewId) return;
2082
-
2083
- const resolution: GateResolution = {
2084
- approved: result.approved,
2085
- feedback: result.feedback,
2086
- resolvedAt: Date.now(),
2087
- };
2088
- if (this.run.status === 'paused') {
2089
- this.run = storeGateResolution(this.run, resolution, Date.now());
2090
- this.persist();
2091
- this.latestContext?.ui.notify(
2092
- `Review ${result.reviewId} finished while paused. Run /workflow-resume to apply it.`,
2093
- 'info',
2094
- );
2095
- return;
2096
- }
2097
- if (this.run.status !== 'awaiting-gate') return;
2098
-
2099
- const workflow = this.catalog.workflows.get(this.run.workflowId);
2100
- if (!workflow) {
2101
- this.run = failRun(
2102
- this.run,
2103
- 'Gate result arrived, but workflow configuration is unavailable',
2104
- Date.now(),
2105
- );
2106
- this.persist();
2107
- this.restoreBaselineTools();
2108
- this.updateStatus();
2109
- return;
2110
- }
2111
- try {
2112
- this.run = resolveGate(workflow, this.run, resolution, Date.now());
2113
- this.settleAfterTransition(workflow);
2114
- } catch (error) {
2115
- this.run = failRun(
2116
- this.run,
2117
- `Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`,
2118
- Date.now(),
2119
- );
2120
- this.persist();
2121
- this.restoreBaselineTools();
2122
- this.updateStatus();
2123
- }
2124
- }
2125
-
2126
- private settleAfterTransition(workflow: LoadedWorkflow): void {
2127
- if (!this.run) return;
2128
- if (this.run.status === 'running') {
2129
- const preflightErrors = this.preflight(workflow, this.run.currentStepId);
2130
- if (preflightErrors.length > 0) {
2131
- this.run = failRun(
2132
- this.run,
2133
- `Step preflight failed: ${preflightErrors.join('; ')}`,
2134
- Date.now(),
2135
- );
2136
- }
2137
- }
2138
-
2139
- this.persist();
2140
- if (this.run.status !== 'running') {
2141
- this.restoreBaselineTools();
2142
- this.updateStatus();
2143
- if (this.run.status === 'completed') {
2144
- this.latestContext?.ui.notify(
2145
- `Workflow "${this.run.workflowId}" completed`,
2146
- 'info',
2147
- );
2148
- } else if (this.run.status === 'paused') {
2149
- this.latestContext?.ui.notify(
2150
- `Workflow paused: ${this.run.pauseReason ?? 'manual action required'}`,
2151
- 'warning',
2152
- );
2153
- }
2154
- return;
2155
- }
2156
-
2157
- this.isolateMainSessionTools();
2158
- this.updateStatus();
2159
- this.launchCurrentStep(workflow);
2160
- }
2161
-
2162
- private preflight(workflow: LoadedWorkflow, stepId: string): string[] {
2163
- const step = workflow.definition.steps[stepId];
2164
- if (!step) return [`step "${stepId}" does not exist`];
2165
- return preflightStep(step, {
2166
- tools: this.pi.getAllTools(),
2167
- commands: this.pi.getCommands(),
2168
- skills: this.availableSkills,
2169
- });
2170
- }
2171
-
2172
- private isolateMainSessionTools(): void {
2173
- this.pi.setActiveTools([]);
2174
- }
2175
-
2176
- private restoreBaselineTools(): void {
2177
- this.mainSteps.release();
2178
- if (this.run) {
2179
- this.pi.setActiveTools(this.run.baselineTools);
2180
- return;
2181
- }
2182
- this.pi.setActiveTools(this.pi.getActiveTools());
2183
- }
2184
-
2185
- private captureSkills(skills: readonly { name: string }[] | undefined): void {
2186
- if (!skills) return;
2187
- this.availableSkills = new Set(skills.map((skill) => skill.name));
2188
- }
2189
-
2190
- private enqueueMutation(
2191
- ctx: ExtensionContext,
228
+ isFailed: boolean,
229
+ ) => void = PROMPT_GATE_ACTIONS.pausePromptGate;
230
+ private readonly cancelPromptReview: () => void =
231
+ PROMPT_GATE_ACTIONS.cancelPromptReview;
232
+ private readonly registerPlannotatorResults: () => void =
233
+ PLANNOTATOR_RESULT_ACTIONS.registerPlannotatorResults;
234
+ private readonly handlePlannotatorResult: (data: unknown) => Promise<void> =
235
+ PLANNOTATOR_RESULT_ACTIONS.handlePlannotatorResult;
236
+ private readonly settleAfterTransition: (workflow: LoadedWorkflow) => void =
237
+ CORE_ACTIONS.settleAfterTransition;
238
+ private readonly preflight: (
239
+ workflow: LoadedWorkflow,
240
+ stepId: string,
241
+ ) => Array<string> = CORE_ACTIONS.preflight;
242
+ private readonly isolateMainSessionTools: () => void =
243
+ CORE_ACTIONS.isolateMainSessionTools;
244
+ private readonly restoreBaselineTools: () => void =
245
+ CORE_ACTIONS.restoreBaselineTools;
246
+ private readonly captureSkills: (
247
+ skills: ReadonlyArray<{ name: string }> | undefined,
248
+ ) => void = CORE_ACTIONS.captureSkills;
249
+ private readonly enqueueMutation: (
250
+ context: ExtensionContext,
2192
251
  operation: (sessionEpoch: number) => Promise<void>,
2193
- ): Promise<void> {
2194
- if (!this.sessionActive) {
2195
- ctx.ui.notify('The Pi session is still initializing', 'warning');
2196
- return Promise.resolve();
2197
- }
2198
- const sessionEpoch = this.sessionEpoch;
2199
- return this.mutationQueue.run(async () => {
2200
- if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
2201
- ctx.ui.notify(
2202
- 'Workflow command was superseded by a session change',
2203
- 'warning',
2204
- );
2205
- return;
2206
- }
2207
- await operation(sessionEpoch);
2208
- });
2209
- }
2210
-
2211
- private persist(): void {
2212
- if (this.run) {
2213
- this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
2214
- }
252
+ ) => Promise<void> = CORE_ACTIONS.enqueueMutation;
253
+ private readonly persist: () => void = CORE_ACTIONS.persist;
254
+ private readonly restoreFromSession: (context: ExtensionContext) => void =
255
+ CORE_ACTIONS.restoreFromSession;
256
+ private readonly reloadCatalog: (
257
+ context: ExtensionContext,
258
+ shouldAnnounce: boolean,
259
+ ) => Promise<boolean> = CORE_ACTIONS.reloadCatalog;
260
+
261
+ /**
262
+ * Creates a workflow harness and registers its Pi integration surface.
263
+ *
264
+ * @param pi - Pi extension API used by the runtime adapters.
265
+ * @param statusShortcut - Shortcut used to open workflow status.
266
+ * @param dependencyOverrides - Optional effect implementations for tests or
267
+ * alternate runtimes.
268
+ */
269
+ constructor(
270
+ pi: ExtensionAPI,
271
+ statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
272
+ dependencyOverrides: Partial<WorkflowHarnessDependencies> = {},
273
+ ) {
274
+ this.pi = pi;
275
+ this.dependencies = createWorkflowHarnessDependencies(dependencyOverrides);
276
+ this.delegationFailures = createDelegationFailureActions(this.dependencies);
277
+ this.statusShortcut = statusShortcut;
278
+ this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
279
+ this.subagents = this.dependencies.createSubagentClient(pi);
280
+ this.mainSteps = this.dependencies.createMainStepRuntime(pi);
281
+ this.mutationQueue = this.dependencies.createMutationQueue();
282
+ registerHarnessCommands(pi, this);
283
+ this.registerWorkflowStatusShortcut();
284
+ this.registerMultilineCommandInput();
285
+ this.registerLifecycle();
286
+ this.registerPolicy();
287
+ this.registerPlannotatorResults();
2215
288
  }
2216
289
 
2217
- private restoreFromSession(ctx: ExtensionContext): void {
2218
- this.latestContext = ctx;
2219
- const previousBaseline = this.run?.baselineTools;
2220
- const entries = ctx.sessionManager.getBranch();
2221
- const checkpoint = readLatestCheckpoint(entries, STATE_ENTRY_TYPE);
2222
- this.run = checkpoint.status === 'valid' ? checkpoint.run : undefined;
2223
- if (checkpoint.status === 'invalid') {
2224
- ctx.ui.notify(
2225
- 'The newest workflow checkpoint is invalid or from an unsupported version; recovery stopped',
2226
- 'error',
2227
- );
2228
- }
2229
- if (
2230
- this.run &&
2231
- (this.run.status === 'running' || this.run.status === 'awaiting-gate')
2232
- ) {
2233
- this.run = pauseRun(
2234
- this.run,
2235
- 'Session was restored; inspect the checkpoint before resuming',
2236
- Date.now(),
2237
- );
2238
- this.persist();
2239
- }
2240
- if (!this.run && previousBaseline) {
2241
- this.pi.setActiveTools(previousBaseline);
2242
- } else {
2243
- this.restoreBaselineTools();
2244
- }
2245
- if (this.activeDelegation) this.isolateMainSessionTools();
2246
- this.updateStatus();
290
+ /** Returns the loaded workflow identifiers in stable display order. */
291
+ workflowIds(): Array<string> {
292
+ return [...this.catalog.workflows.keys()].sort();
2247
293
  }
2248
294
 
2249
- private async reloadCatalog(
2250
- ctx: ExtensionContext,
2251
- announce: boolean,
2252
- ): Promise<boolean> {
2253
- const loadSequence = ++this.catalogLoadSequence;
2254
- const sessionEpoch = this.sessionEpoch;
2255
- const catalog = await loadCatalog({
2256
- cwd: ctx.cwd,
2257
- projectTrusted: ctx.isProjectTrusted(),
2258
- });
2259
- if (
2260
- loadSequence !== this.catalogLoadSequence ||
2261
- sessionEpoch !== this.sessionEpoch
2262
- ) {
2263
- return false;
2264
- }
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
- }
2275
- const availableCommands = this.pi.getCommands();
2276
- for (const [workflowId, workflow] of catalog.workflows) {
2277
- const command = workflow.definition.command;
2278
- if (
2279
- hasRuntimeCommandConflict(
2280
- command,
2281
- availableCommands,
2282
- this.registeredWorkflowCommands,
2283
- )
2284
- ) {
2285
- catalog.workflows.delete(workflowId);
2286
- catalog.diagnostics.push({
2287
- level: 'error',
2288
- path: workflow.sourcePath,
2289
- message: `command "/${command}" conflicts with another loaded Pi resource`,
2290
- });
2291
- }
2292
- }
2293
- this.catalog = catalog;
2294
- for (const workflow of catalog.workflows.values()) {
2295
- this.pi.registerCommand(workflow.definition.command, {
2296
- description: workflow.definition.description,
2297
- handler: async (args, commandContext) =>
2298
- this.start(workflow.definition.id, args, commandContext),
2299
- });
2300
- this.registeredWorkflowCommands.add(workflow.definition.command);
2301
- }
2302
-
2303
- if (announce) {
2304
- const diagnosticText = formatDiagnostics(this.catalog);
2305
- ctx.ui.notify(
2306
- diagnosticText
2307
- ? `Loaded ${this.catalog.workflows.size} workflow(s)\n${diagnosticText}`
2308
- : `Loaded ${this.catalog.workflows.size} workflow(s)`,
2309
- diagnosticText ? 'warning' : 'info',
2310
- );
2311
- } else if (
2312
- this.catalog.diagnostics.some((item) => item.level === 'error')
2313
- ) {
2314
- ctx.ui.notify(
2315
- `Workflow configuration errors:\n${formatDiagnostics(this.catalog)}`,
2316
- 'warning',
2317
- );
2318
- }
2319
- return true;
295
+ /** Displays the currently loaded workflows. */
296
+ async list(context: ExtensionCommandContext): Promise<void> {
297
+ await this.listWorkflows(context);
2320
298
  }
2321
299
 
2322
- private updateStatus(): void {
2323
- this.refreshStatusWhileRunning();
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
- }
2329
- if (!this.run) {
2330
- this.latestContext.ui.setStatus(STATUS_KEY, undefined);
2331
- return;
2332
- }
2333
- const snapshot = this.workflowStatusSnapshot();
2334
- if (this.run.status !== 'running') {
2335
- this.latestContext.ui.setStatus(STATUS_KEY, undefined);
2336
- return;
2337
- }
2338
- this.latestContext.ui.setStatus(
2339
- STATUS_KEY,
2340
- `${workflowStatusIcon(this.run, snapshot?.now)} ${this.run.workflowId}: working · ${this.statusShortcutLabel}`,
300
+ /** Starts a loaded workflow with the supplied user input. */
301
+ start(
302
+ workflowId: string,
303
+ input: string,
304
+ context: ExtensionCommandContext,
305
+ ): Promise<void> {
306
+ return this.enqueueMutation(context, (sessionEpoch) =>
307
+ this.startNow(
308
+ workflowId,
309
+ input,
310
+ {
311
+ context,
312
+ skills: () => context.getSystemPromptOptions().skills,
313
+ waitForIdle: () => context.waitForIdle(),
314
+ },
315
+ sessionEpoch,
316
+ ),
2341
317
  );
2342
318
  }
2343
319
 
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;
320
+ /** Pauses the active workflow while retaining its checkpoint. */
321
+ pause(reason: string, context: ExtensionCommandContext): Promise<void> {
322
+ return this.enqueueMutation(context, () => this.pauseNow(reason, context));
2360
323
  }
2361
324
 
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
- });
325
+ /** Resumes the active paused workflow after reconciling configuration. */
326
+ resume(context: ExtensionCommandContext): Promise<void> {
327
+ return this.enqueueMutation(context, () => this.resumeNow(context));
2375
328
  }
2376
329
 
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
- });
330
+ /** Aborts the active workflow and cancels any delegated execution. */
331
+ abort(reason: string, context: ExtensionCommandContext): Promise<void> {
332
+ return this.enqueueMutation(context, () => this.abortNow(reason, context));
2384
333
  }
2385
334
 
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
- }
335
+ /** Reloads workflow configuration while no workflow is executing. */
336
+ reload(context: ExtensionCommandContext): Promise<void> {
337
+ return this.enqueueMutation(context, () => this.reloadNow(context));
2405
338
  }
2406
339
  }