pi-workflow-engine 0.13.0 → 1.1.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 (98) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +152 -416
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +12 -13
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +131 -117
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  18. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
  23. package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
  24. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  25. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  26. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  27. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  33. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  34. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  35. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  36. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  37. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  38. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  40. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  41. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  42. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  43. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
  44. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  45. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  46. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  55. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  56. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  57. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  58. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
  60. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  61. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  62. package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  65. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  68. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  69. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  70. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  71. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  72. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  73. package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  79. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  80. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  81. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  82. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  83. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  84. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  85. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  86. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  87. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  88. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  89. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  90. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  91. package/README.md +1 -1
  92. package/USAGE.md +79 -20
  93. package/package.json +1 -2
  94. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  95. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  96. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  97. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  98. package/.pi/settings.json +0 -3
@@ -62,12 +62,13 @@ export function resolveAgentSkillRequest(prompt: string, explicitSkills: unknown
62
62
  }
63
63
 
64
64
  export function extractSkillSelectorsFromText(text: string): string[] {
65
- const selectors: string[] = [];
66
- collectPatternMatches(text, SLASH_SKILL_PATTERN, selectors, 1);
67
- collectListPatternMatches(text, SKILLS_FIELD_PATTERN, selectors);
68
- collectListPatternMatches(text, VERB_SKILL_LIST_PATTERN, selectors);
69
- collectPatternMatches(text, VERB_NAMED_SKILL_PATTERN, selectors, 2);
70
- return uniqueSelectors(selectors);
65
+ const selectors = [
66
+ ...Array.from(text.matchAll(SLASH_SKILL_PATTERN), (match) => match[1] ?? ""),
67
+ ...[...text.matchAll(SKILLS_FIELD_PATTERN), ...text.matchAll(VERB_SKILL_LIST_PATTERN)]
68
+ .flatMap((match) => parseSkillList(match[1] ?? "")),
69
+ ...Array.from(text.matchAll(VERB_NAMED_SKILL_PATTERN), (match) => match[2] ?? ""),
70
+ ].map(normalizeSkillSelector);
71
+ return [...new Set(selectors.filter((value) => value && !isGenericSkillWord(value)))];
71
72
  }
72
73
 
73
74
  export function normalizeSkillSelector(value: string): string {
@@ -179,23 +180,6 @@ export async function captureAgentSkillIdentities(
179
180
  return { kind: "verified", skills: identities };
180
181
  }
181
182
 
182
- function collectPatternMatches(text: string, pattern: RegExp, selectors: string[], captureIndex: number): void {
183
- pattern.lastIndex = 0;
184
- for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
185
- const value = match[captureIndex];
186
- if (value !== undefined) selectors.push(value);
187
- }
188
- }
189
-
190
- function collectListPatternMatches(text: string, pattern: RegExp, selectors: string[]): void {
191
- pattern.lastIndex = 0;
192
- for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
193
- const value = match[1];
194
- if (value === undefined) continue;
195
- selectors.push(...parseSkillList(value));
196
- }
197
- }
198
-
199
183
  function parseSkillList(fragment: string): string[] {
200
184
  const scoped = fragment.replace(PURPOSE_BOUNDARY_PATTERN, "");
201
185
  return scoped
@@ -208,9 +192,7 @@ function normalizeExplicitSkillSelectors(value: unknown): string[] {
208
192
  if (!Array.isArray(value)) {
209
193
  throw new Error("Invalid subagent skills option: expected an array of skill names.");
210
194
  }
211
- const seen = new Set<string>();
212
- const result: string[] = [];
213
- for (const item of value) {
195
+ const selectors = value.map((item: unknown) => {
214
196
  if (typeof item !== "string") {
215
197
  throw new Error("Invalid subagent skills option: every skill name must be a string.");
216
198
  }
@@ -218,11 +200,9 @@ function normalizeExplicitSkillSelectors(value: unknown): string[] {
218
200
  if (!normalized || isGenericSkillWord(normalized)) {
219
201
  throw new Error(`Invalid subagent skills option: \"${item}\" is not a valid skill name.`);
220
202
  }
221
- if (seen.has(normalized)) continue;
222
- seen.add(normalized);
223
- result.push(normalized);
224
- }
225
- return result;
203
+ return normalized;
204
+ });
205
+ return [...new Set(selectors)];
226
206
  }
227
207
 
228
208
  function logSkillDiagnostics(log: ((message: string) => void) | undefined, diagnostics: readonly ResourceDiagnostic[], requested: boolean): void {
@@ -233,18 +213,6 @@ function logSkillDiagnostics(log: ((message: string) => void) | undefined, diagn
233
213
  }
234
214
  }
235
215
 
236
- function uniqueSelectors(values: Iterable<string>): string[] {
237
- const seen = new Set<string>();
238
- const result: string[] = [];
239
- for (const value of values) {
240
- const normalized = normalizeSkillSelector(value);
241
- if (!normalized || isGenericSkillWord(normalized) || seen.has(normalized)) continue;
242
- seen.add(normalized);
243
- result.push(normalized);
244
- }
245
- return result;
246
- }
247
-
248
216
  function isGenericSkillWord(value: string): boolean {
249
217
  return value === "skill" || value === "skills" || value === "the" || value === "this" || value === "these" || value === "that";
250
218
  }
@@ -1,5 +1,4 @@
1
1
  import type { AgentExecutionOptions, AgentProgress } from "./agent-runner-types.ts";
2
- import { unknownErrorMessage } from "./unknown-error.ts";
3
2
  import type { WorktreeRegistry } from "./worktree.ts";
4
3
 
5
4
  interface AgentWorkspaceBase {
@@ -45,7 +44,7 @@ export async function createAgentWorkspace(
45
44
 
46
45
  const probe = await rc.worktrees.probe(rc.signal);
47
46
  if (!probe.ok) {
48
- throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
47
+ throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error}`);
49
48
  }
50
49
  if (!probe.inside) {
51
50
  throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
@@ -59,11 +58,11 @@ export async function createAgentWorkspace(
59
58
  if (opts.candidatePatch.baselineOid !== added.baselineOid) throw new Error("Candidate baseline differs from evaluator baseline");
60
59
  if (opts.candidatePatch.patch.trim()) {
61
60
  const applied = await rc.worktrees.applyPatch(worktreePath, opts.candidatePatch.patch, rc.signal);
62
- if (!applied.ok) throw new Error(`Candidate patch could not be applied: ${applied.error ?? applied.stderr}`);
61
+ if (!applied.ok) throw new Error(`Candidate patch could not be applied: ${applied.error}`);
63
62
  }
64
63
  } catch (error) {
65
64
  const removed = await rc.worktrees.remove(worktreePath);
66
- if (!removed.ok) rc.progress.log(`${label}: evaluator setup cleanup failed: ${removed.error ?? removed.stderr}`);
65
+ if (!removed.ok) rc.progress.log(`${label}: evaluator setup cleanup failed: ${removed.error}`);
67
66
  throw error;
68
67
  }
69
68
  }
@@ -82,22 +81,9 @@ export async function createAgentWorkspace(
82
81
  const removed = await rc.worktrees.remove(worktreePath);
83
82
  if (!removed.ok) {
84
83
  rc.progress.log(
85
- `${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
84
+ `${label}: failed to remove isolated worktree (${removed.error})`,
86
85
  );
87
86
  }
88
87
  },
89
88
  };
90
89
  }
91
-
92
- export async function disposeAgentWorkspace(
93
- rc: Pick<AgentWorkspaceContext, "progress">,
94
- label: string,
95
- workspace: AgentWorkspace | undefined,
96
- ): Promise<void> {
97
- if (!workspace) return;
98
- try {
99
- await workspace.dispose();
100
- } catch (error) {
101
- rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
102
- }
103
- }
@@ -7,6 +7,7 @@ import { unknownErrorMessage } from "./unknown-error.ts";
7
7
  export interface BackgroundWorkflowToolResult {
8
8
  readonly content: Array<{ readonly type: "text"; readonly text: string }>;
9
9
  readonly details: Readonly<Record<string, unknown>>;
10
+ readonly isError?: true;
10
11
  }
11
12
 
12
13
  export interface BackgroundWorkflowToolStartInput {
@@ -25,6 +26,7 @@ export function backgroundUnavailableResult(mode: ExtensionContext["mode"]): Bac
25
26
  text: `Background workflows are unavailable in ${mode} mode because pi exits after the prompt. Run synchronously or use TUI/RPC mode.`,
26
27
  }],
27
28
  details: { error: "background_unavailable", mode },
29
+ isError: true,
28
30
  };
29
31
  }
30
32
 
@@ -63,6 +65,7 @@ export async function startBackgroundWorkflowTool(
63
65
  return {
64
66
  content: [{ type: "text", text: `Background workflow did not start: ${message}` }],
65
67
  details: { error: "background_start_failed", message, runId },
68
+ isError: true,
66
69
  };
67
70
  }
68
71
  return {
@@ -1,18 +1,20 @@
1
1
  import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
3
+ import { isRecord } from "./guards.ts";
3
4
  import type { WorkflowBackgroundOrigin } from "./types.ts";
4
5
  import {
6
+ stopWorkflowRunRecord,
5
7
  transitionWorkflowRun,
6
8
  type WorkflowRunRecord,
7
9
  type WorkflowRunState,
8
10
  } from "./workflow-run-record.ts";
9
- import { updateWorkflowRunDelivery } from "./workflow-run-background.ts";
11
+ import { updateWorkflowRunDelivery, type WorkflowRunDelivery } from "./workflow-run-background.ts";
10
12
  import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
13
+ import { truncateText } from "./text.ts";
14
+ import { workflowResultSummary } from "./workflow-execution.ts";
11
15
  import { unknownErrorMessage } from "./unknown-error.ts";
12
- import { emptyWorkflowUsageTotals } from "./usage.ts";
13
16
 
14
17
  const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
15
- const BACKGROUND_WIDGET_KEY = "workflow-background";
16
18
  const SHUTDOWN_WAIT_MS = 5_000;
17
19
  const SUMMARY_LIMIT = 500;
18
20
 
@@ -25,7 +27,10 @@ export interface BackgroundWorkflowStartInput {
25
27
 
26
28
  export interface BackgroundWorkflowResultDetails {
27
29
  readonly name: string;
28
- readonly result: { readonly summary: string };
30
+ /** The retained result of a completed run, so the delivery renders like a foreground result; else `{ summary }`. */
31
+ readonly result: unknown;
32
+ readonly summary: string;
33
+ readonly startedAt?: number;
29
34
  readonly completedAt: number;
30
35
  readonly usage: WorkflowRunRecord["usage"];
31
36
  readonly runId: string;
@@ -56,7 +61,7 @@ type BackgroundWorkflowSettledListener = (ctx: ExtensionContext, runId: string)
56
61
  /** Owns background runs for one extension instance and routes their durable completion delivery. */
57
62
  export class BackgroundWorkflowCoordinator {
58
63
  private readonly active = new Map<string, ActiveBackgroundRun>();
59
- private readonly settledListeners = new Set<BackgroundWorkflowSettledListener>();
64
+ private settledListener: BackgroundWorkflowSettledListener | undefined;
60
65
  private readonly pendingDelivery = new Map<string, Set<string>>();
61
66
  private readonly shuttingDown = new Set<string>();
62
67
  private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
@@ -82,13 +87,8 @@ export class BackgroundWorkflowCoordinator {
82
87
  let accepted = false;
83
88
  let deliveryScheduled = false;
84
89
  let settled = false;
85
- let startedSignalled = false;
86
- let resolveStarted: (() => void) | undefined;
87
- let rejectStarted: ((error: unknown) => void) | undefined;
88
- const started = new Promise<void>((resolve, reject) => {
89
- resolveStarted = resolve;
90
- rejectStarted = reject;
91
- });
90
+ // Settles once: a rejection after the run published its metadata is ignored.
91
+ const started = Promise.withResolvers<void>();
92
92
 
93
93
  const scheduleDelivery = (): void => {
94
94
  if (deliveryScheduled || !accepted || !settled || this.shuttingDown.has(sessionId)) return;
@@ -100,18 +100,13 @@ export class BackgroundWorkflowCoordinator {
100
100
 
101
101
  const settledPromise = (async () => {
102
102
  try {
103
- await input.run(controller.signal, () => {
104
- if (startedSignalled) return;
105
- startedSignalled = true;
106
- resolveStarted?.();
107
- });
108
- if (!startedSignalled) rejectStarted?.(new Error("Background workflow ended before publishing run metadata."));
103
+ await input.run(controller.signal, () => started.resolve());
104
+ started.reject(new Error("Background workflow ended before publishing run metadata."));
109
105
  } catch (error) {
110
- if (!startedSignalled) rejectStarted?.(error);
106
+ started.reject(error);
111
107
  } finally {
112
108
  settled = true;
113
109
  this.active.delete(input.runId);
114
- this.updateBackgroundSurface(input.ctx);
115
110
  await this.notifyRunSettled(input.ctx, input.runId);
116
111
  scheduleDelivery();
117
112
  }
@@ -124,7 +119,7 @@ export class BackgroundWorkflowCoordinator {
124
119
  isSettled: () => settled,
125
120
  });
126
121
 
127
- await started;
122
+ await started.promise;
128
123
  const record = await this.storeForCwd(input.ctx.cwd).load(input.runId);
129
124
  if (record?.state !== "running" || !record.background || record.background.origin.sessionId !== sessionId) {
130
125
  controller.abort(new WorkflowAbortError("Background workflow could not persist its origin metadata."));
@@ -132,7 +127,6 @@ export class BackgroundWorkflowCoordinator {
132
127
  }
133
128
 
134
129
  accepted = true;
135
- this.updateBackgroundSurface(input.ctx);
136
130
  scheduleDelivery();
137
131
  }
138
132
 
@@ -145,9 +139,8 @@ export class BackgroundWorkflowCoordinator {
145
139
  );
146
140
  }
147
141
 
148
- onRunSettled(listener: BackgroundWorkflowSettledListener): () => void {
149
- this.settledListeners.add(listener);
150
- return () => this.settledListeners.delete(listener);
142
+ onRunSettled(listener: BackgroundWorkflowSettledListener): void {
143
+ this.settledListener = listener;
151
144
  }
152
145
 
153
146
  async stop(ctx: ExtensionContext, runId: string): Promise<WorkflowRunRecord> {
@@ -243,7 +236,6 @@ export class BackgroundWorkflowCoordinator {
243
236
  this.log(`[workflow:${runId}] failed to force paused state during shutdown: ${unknownErrorMessage(error)}`);
244
237
  }
245
238
  }
246
- if (ctx.hasUI) ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
247
239
  }
248
240
 
249
241
  private async queueOrDeliver(ctx: ExtensionContext, runId: string): Promise<void> {
@@ -262,12 +254,10 @@ export class BackgroundWorkflowCoordinator {
262
254
  }
263
255
 
264
256
  private async notifyRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
265
- for (const listener of this.settledListeners) {
266
- try {
267
- await listener(ctx, runId);
268
- } catch (error) {
269
- this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
270
- }
257
+ try {
258
+ await this.settledListener?.(ctx, runId);
259
+ } catch (error) {
260
+ this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
271
261
  }
272
262
  }
273
263
 
@@ -292,23 +282,6 @@ export class BackgroundWorkflowCoordinator {
292
282
  this.pendingDelivery.set(sessionId, pending);
293
283
  }
294
284
 
295
- private updateBackgroundSurface(ctx: ExtensionContext): void {
296
- if (!ctx.hasUI) return;
297
- const sessionId = ctx.sessionManager.getSessionId();
298
- const runs = [...this.active.entries()]
299
- .filter(([, run]) => run.sessionId === sessionId)
300
- .map(([runId, run]) => ({ runId, name: run.name }));
301
- if (runs.length === 0) {
302
- ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
303
- return;
304
- }
305
- ctx.ui.setWidget(
306
- BACKGROUND_WIDGET_KEY,
307
- [formatBackgroundActivity(runs, ctx.ui.theme)],
308
- { placement: "aboveEditor" },
309
- );
310
- }
311
-
312
285
  private async deliver(ctx: ExtensionContext, runId: string): Promise<boolean> {
313
286
  const store = this.storeForCwd(ctx.cwd);
314
287
  const record = await store.load(runId);
@@ -333,25 +306,18 @@ export class BackgroundWorkflowCoordinator {
333
306
  }
334
307
  }
335
308
 
336
- function formatBackgroundActivity(
337
- runs: readonly { readonly runId: string; readonly name: string }[],
338
- theme: ExtensionContext["ui"]["theme"],
339
- ): string {
340
- const visible = runs.slice(0, 2).map((run) => `${run.name} ${run.runId.slice(0, 8)}`);
341
- const hidden = runs.length - visible.length;
342
- const suffix = hidden > 0 ? ` · +${hidden} more` : "";
343
- return `${theme.fg("accent", "●")} ${theme.bold("Background workflows")} ${theme.fg("dim", `· ${visible.join(" · ")}${suffix}`)}`;
344
- }
345
-
346
309
  export function backgroundOrigin(ctx: Pick<ExtensionContext, "sessionManager">, requestedAt = Date.now()): WorkflowBackgroundOrigin {
347
310
  return { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
348
311
  }
349
312
 
350
313
  export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
351
314
  if (!isDeliverableState(record.state)) throw new Error(`Workflow run ${record.runId} has not finished or paused.`);
315
+ const summary = backgroundSummary(record);
352
316
  return {
353
317
  name: record.workflow.name,
354
- result: { summary: backgroundSummary(record) },
318
+ result: record.state === "completed" && record.result.kind === "value" ? record.result.value : { summary },
319
+ summary,
320
+ startedAt: record.startedAt,
355
321
  completedAt: record.endedAt ?? record.updatedAt,
356
322
  usage: record.usage,
357
323
  runId: record.runId,
@@ -362,14 +328,12 @@ export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWo
362
328
  }
363
329
 
364
330
  function backgroundSummary(record: WorkflowRunRecord): string {
365
- if (record.state !== "completed") return boundedSummary(`Workflow ${record.state}: ${record.message}`);
331
+ if (record.state !== "completed") return truncateText(`Workflow ${record.state}: ${record.message}`, SUMMARY_LIMIT);
366
332
  if (record.result.kind === "unavailable") {
367
- return boundedSummary(`Workflow completed; retained result is unavailable: ${record.result.reason}`);
333
+ return truncateText(`Workflow completed; retained result is unavailable: ${record.result.reason}`, SUMMARY_LIMIT);
368
334
  }
369
- const value = record.result.value;
370
- if (typeof value === "string") return boundedSummary(value);
371
- if (isRecord(value) && typeof value.summary === "string") return boundedSummary(value.summary);
372
- return "Workflow completed. Open run history for the retained result.";
335
+ const summary = workflowResultSummary(record.result.value);
336
+ return summary === undefined ? "Workflow completed. Open run history for the retained result." : truncateText(summary, SUMMARY_LIMIT);
373
337
  }
374
338
 
375
339
  function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): string {
@@ -379,7 +343,7 @@ function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): str
379
343
  `Run ID: ${details.runId}`,
380
344
  `State: ${details.status}`,
381
345
  "",
382
- details.result.summary,
346
+ details.summary,
383
347
  ].join("\n");
384
348
  }
385
349
 
@@ -404,7 +368,7 @@ function isDeliverableState(state: WorkflowRunState): state is "completed" | "fa
404
368
  async function markDelivery(
405
369
  store: WorkflowRunStore,
406
370
  runId: string,
407
- delivery: Parameters<typeof updateWorkflowRunDelivery>[1],
371
+ delivery: WorkflowRunDelivery,
408
372
  ): Promise<void> {
409
373
  const latest = await store.load(runId);
410
374
  if (!latest?.background || latest.background.delivery.state !== "pending") return;
@@ -424,16 +388,7 @@ async function forcePausedRecord(store: WorkflowRunStore, runId: string): Promis
424
388
  async function forceStoppedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
425
389
  const record = await store.load(runId);
426
390
  if (!record || (record.state !== "queued" && record.state !== "running")) return;
427
- await store.save(transitionWorkflowRun(record, {
428
- state: "stopped",
429
- progress: record.progress,
430
- usage: record.usage ?? record.progress.usage ?? {
431
- agents: [],
432
- totals: emptyWorkflowUsageTotals(),
433
- assistantMessages: 0,
434
- },
435
- error: new WorkflowAbortError("Workflow stopped by user."),
436
- }));
391
+ await store.save(stopWorkflowRunRecord(record));
437
392
  }
438
393
 
439
394
  async function reconcileInterruptedRun(
@@ -482,11 +437,3 @@ async function waitForRuns(runs: readonly Promise<void>[], timeoutMs: number): P
482
437
  if (timer) clearTimeout(timer);
483
438
  }
484
439
  }
485
-
486
- function boundedSummary(value: string): string {
487
- return value.length <= SUMMARY_LIMIT ? value : `${value.slice(0, SUMMARY_LIMIT - 1)}…`;
488
- }
489
-
490
- function isRecord(value: unknown): value is Record<string, unknown> {
491
- return typeof value === "object" && value !== null && !Array.isArray(value);
492
- }
@@ -27,7 +27,7 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
27
27
  export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
28
28
  if (signal?.aborted) return true;
29
29
  if (safeInstanceOf(error, WorkflowAbortError)) return true;
30
- if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
30
+ if (safeInstanceOf(error, DOMException)) {
31
31
  try {
32
32
  return error.name === "AbortError";
33
33
  } catch {
@@ -67,10 +67,6 @@ export async function raceWithAbort<T>(operation: () => Promise<T>, signal: Abor
67
67
  };
68
68
  const onAbort = () => finish(() => reject(abortReason(signal)));
69
69
  signal.addEventListener("abort", onAbort, { once: true });
70
- if (signal.aborted) {
71
- onAbort();
72
- return;
73
- }
74
70
  void operation().then(
75
71
  (value) => finish(() => resolve(value)),
76
72
  (error: unknown) => finish(() => reject(error)),
@@ -0,0 +1,107 @@
1
+ import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import type { RunContext } from "./agent-runner-types.ts";
4
+ import { parseAgentModelRef } from "./model-ref.ts";
5
+ import { assertWorkflowBudgetAvailable } from "./budget.ts";
6
+ import { throwIfAborted } from "./cancellation.ts";
7
+ import type { ClassifyOptions } from "./types.ts";
8
+
9
+ type ClassifierRegistry = Pick<ModelRegistry, "classify" | "getAvailableOfType" | "getModelOfType">;
10
+
11
+ /** No classifier model is configured, or the requested one is unknown. Recoverable inside `parallel`. */
12
+ export class WorkflowClassifierUnavailableError extends Error {
13
+ readonly code = "WORKFLOW_CLASSIFIER_UNAVAILABLE";
14
+
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = "WorkflowClassifierUnavailableError";
18
+ }
19
+ }
20
+
21
+ /** The classifier answered with an error or no answer. Recoverable inside `parallel`. */
22
+ export class WorkflowClassifierError extends Error {
23
+ readonly code = "WORKFLOW_CLASSIFIER_FAILED";
24
+
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "WorkflowClassifierError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * `api.classify()`: answer typed questions about JSON state with a classifier model through the host's
33
+ * credentials, without a chat session. It shares the run's concurrency cap, budget, and per-agent timeout,
34
+ * records usage like an agent, and is not journaled, so a resumed run asks again.
35
+ */
36
+ export async function runClassifier(
37
+ rc: RunContext,
38
+ context: ClassifierContext,
39
+ opts: ClassifyOptions = {},
40
+ ): Promise<Record<string, ClassifierAnswer>> {
41
+ const registry = rc.modelRegistry;
42
+ return await rc.semaphore.run(async () => {
43
+ assertWorkflowBudgetAvailable(rc.budget);
44
+ const model = await cachedClassifierModel(rc, registry, opts.model);
45
+ const label = opts.label ?? `classify:${model.provider}/${model.id}`;
46
+ // A stalled classifier must not hold a concurrency slot past the limit agents get.
47
+ const timeout = AbortSignal.timeout(rc.agentTimeoutMs);
48
+ const result = await registry.classify(model, context, { signal: rc.signal ? AbortSignal.any([rc.signal, timeout]) : timeout });
49
+ // One classifier response is one model response: recording it like one keeps per-model usage and the budget
50
+ // whole. Record before the abort check, since the provider charged for a call that returned as the run stopped.
51
+ rc.usage.recordAgentSession({
52
+ label,
53
+ phase: opts.phase,
54
+ messages: [{ role: "assistant", provider: result.provider, model: result.model, usage: result.usage }],
55
+ });
56
+ throwIfAborted(rc.signal);
57
+ if (result.stopReason !== "stop" && timeout.aborted) {
58
+ throw new WorkflowClassifierError(`${label}: classifier timed out after ${rc.agentTimeoutMs}ms`);
59
+ }
60
+ if (result.stopReason !== "stop") {
61
+ throw new WorkflowClassifierError(`${label}: classifier ${result.stopReason}${result.errorMessage ? `: ${result.errorMessage}` : ""}`);
62
+ }
63
+ return result.answers;
64
+ }, { signal: rc.signal });
65
+ }
66
+
67
+ /** One credential lookup per route and run; a route that failed to resolve keeps failing for the run. */
68
+ function cachedClassifierModel(
69
+ rc: RunContext,
70
+ registry: ClassifierRegistry,
71
+ ref: string | readonly string[] | undefined,
72
+ ): Promise<ClassifierModel<ClassifierApi>> {
73
+ const key = JSON.stringify(ref ?? null);
74
+ const cached = rc.classifierRoutes?.get(key);
75
+ if (cached) return cached;
76
+ const resolved = resolveClassifierModel(registry, ref);
77
+ rc.classifierRoutes?.set(key, resolved);
78
+ return resolved;
79
+ }
80
+
81
+ async function resolveClassifierModel(
82
+ registry: ClassifierRegistry,
83
+ ref: string | readonly string[] | undefined,
84
+ ): Promise<ClassifierModel<ClassifierApi>> {
85
+ if (ref !== undefined && typeof ref !== "string") {
86
+ const available = await registry.getAvailableOfType("classifier");
87
+ const preferred = ref.map((candidate) => parseAgentModelRef(candidate))
88
+ .map(({ provider, id }) => available.find((model) => model.provider === provider && model.id === id))
89
+ .find((model) => model !== undefined);
90
+ if (!preferred) throw new WorkflowClassifierUnavailableError(`None of the classifier models ${ref.join(", ")} has working credentials.`);
91
+ return preferred;
92
+ }
93
+ if (ref === undefined) {
94
+ const [available] = await registry.getAvailableOfType("classifier");
95
+ if (!available) {
96
+ throw new WorkflowClassifierUnavailableError(
97
+ "No classifier model has working credentials. Configure one (for example TypeSafe Jev via OpenRouter) or pass { model: \"provider/id\" }.",
98
+ );
99
+ }
100
+ return available;
101
+ }
102
+ // Same ref rules as agent({ model }): malformed refs throw, bare ids mean Anthropic.
103
+ const { provider, id } = parseAgentModelRef(ref);
104
+ const model = registry.getModelOfType("classifier", provider, id);
105
+ if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found (resolved as ${provider}/${id}).`);
106
+ return model;
107
+ }