pi-workflow-engine 1.0.0 → 1.2.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 (59) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
  4. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  5. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
  7. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  8. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  9. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  10. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  11. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  12. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  13. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
  14. package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
  15. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  16. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  21. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  22. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  23. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  24. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  25. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
  26. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  27. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
  28. package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
  29. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  32. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  33. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  34. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  35. package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  47. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  48. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  49. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  50. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  51. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  52. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  53. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  54. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  55. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  56. package/README.md +12 -4
  57. package/USAGE.md +167 -23
  58. package/package.json +13 -4
  59. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -29,7 +29,8 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
29
29
  return lanes;
30
30
  }
31
31
 
32
- export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
32
+ /** Distinct claims, most important first, bounded to {@link MAX_VERIFICATION_CLAIMS}; `overCap` counts the claims cut. */
33
+ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): { candidates: ResearchClaimCandidate[]; overCap: number } {
33
34
  const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
34
35
  for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
35
36
  const key = item.claim.toLowerCase();
@@ -41,9 +42,8 @@ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): Re
41
42
  if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
42
43
  current.evidence = dedupeEvidence([...current.evidence, item]);
43
44
  }
44
- return [...byClaim.values()]
45
- .sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance])
46
- .slice(0, MAX_VERIFICATION_CLAIMS);
45
+ const ranked = [...byClaim.values()].sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance]);
46
+ return { candidates: ranked.slice(0, MAX_VERIFICATION_CLAIMS), overCap: Math.max(0, ranked.length - MAX_VERIFICATION_CLAIMS) };
47
47
  }
48
48
 
49
49
  export function sanitizeVerification(
@@ -155,8 +155,10 @@ const UNAVAILABLE_RESEARCH = {
155
155
  nextStep: "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments.",
156
156
  },
157
157
  "missing-capability": {
158
- message: "Research could not start because pi exposed no installed external web-search or URL-extraction tool.",
159
- nextStep: "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow.",
158
+ message: "Research could not start because its agents could reach no external web-search or URL-extraction tool.",
159
+ nextStep:
160
+ "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow. " +
161
+ "An MCP web search server counts only when the host agent runs research through the workflow tool, for example after `dynamax`.",
160
162
  },
161
163
  "lanes-failed": {
162
164
  message: "Research could not gather direct-page evidence because one or more research lanes failed.",
@@ -438,7 +438,7 @@ export async function captureWorkflowResumeContext(
438
438
  if (mod.source.kind === "unverifiable") {
439
439
  return unverifiableWorkflowResumeContext(mod.meta.name, mod.source.reason);
440
440
  }
441
- if (mod.source.kind === "fingerprint") {
441
+ if (mod.source.kind === "fingerprint" || mod.source.selfContained) {
442
442
  return mod.source.fingerprint.length > 0
443
443
  ? { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint }
444
444
  : unverifiableWorkflowResumeContext(mod.meta.name, "workflow source fingerprint is empty");
@@ -1,4 +1,5 @@
1
1
  import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import { isMcpToolName } from "./host-tools.ts";
2
3
  import type { AgentToolHint } from "./types.ts";
3
4
 
4
5
  /** Raised before prompting when required semantic tool capabilities are unavailable. */
@@ -6,8 +7,12 @@ export class WorkflowToolHintUnavailableError extends Error {
6
7
  readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
7
8
  readonly hints: readonly AgentToolHint[];
8
9
 
9
- constructor(hints: readonly AgentToolHint[]) {
10
- super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
10
+ constructor(hints: readonly AgentToolHint[], options: { readonly hostToolsReachable?: boolean } = {}) {
11
+ // Host MCP tools only count in runs that can reach them; say so rather than imply none are installed.
12
+ const mcpNote = hints.includes("external-search") && options.hostToolsReachable === false
13
+ ? " (MCP tools reach subagents only in runs started by the workflow tool)"
14
+ : "";
15
+ super(`Required workflow tool capability unavailable: ${hints.join(", ")}${mcpNote}`);
11
16
  this.name = "WorkflowToolHintUnavailableError";
12
17
  this.hints = [...hints];
13
18
  }
@@ -18,7 +23,9 @@ export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boole
18
23
  }
19
24
 
20
25
  function isExternalSearchLikeTool(tool: ToolInfo): boolean {
21
- if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
26
+ if (isMutationLikeToolName(tool.name) || isPiLocalTool(tool)) return false;
27
+ // MCP servers declare these hints: a tool that changes its environment or stays in a closed domain is not web research.
28
+ if (tool.annotations?.readOnlyHint === false || tool.annotations?.openWorldHint === false) return false;
22
29
  const name = tool.name.toLowerCase();
23
30
  const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
24
31
  const text = `${name} ${description}`;
@@ -40,6 +47,11 @@ function isSearchLikeTool(tool: ToolInfo): boolean {
40
47
  return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
41
48
  }
42
49
 
50
+ /** pi's own tools, such as read and bash. MCP tools also come from a built-in extension but reach outside it. */
51
+ function isPiLocalTool(tool: ToolInfo): boolean {
52
+ return tool.sourceInfo.source === "builtin" && !isMcpToolName(tool.name);
53
+ }
54
+
43
55
  function isMutationLikeToolName(name: string): boolean {
44
56
  return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
45
57
  }
@@ -1,4 +1,5 @@
1
1
  import type { Static, TSchema } from "typebox";
2
+ import type { ClassifierAnswer, ClassifierContext } from "@earendil-works/pi-ai";
2
3
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
4
  import type { WorkflowBudget } from "./budget.ts";
4
5
  import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
@@ -10,18 +11,28 @@ import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progres
10
11
  import type { WorkflowUsageSnapshot } from "./usage.ts";
11
12
  import type { WorktreeBaseline } from "./worktree.ts";
12
13
  import type { WorkflowModelProfileName } from "./model-profiles.ts";
14
+ import type { HostToolBridge } from "./host-tools.ts";
13
15
  import type { WorkflowRunState } from "./workflow-run-record.ts";
14
16
 
15
17
  export type { WorkflowLaneItemStatus } from "./progress-types.ts";
16
18
 
17
- /** A reference to a registered workflow by name. */
18
- export type WorkflowRef = string;
19
+ /** A registered workflow by name, or an inline-format script file under the workflow cwd. */
20
+ export type WorkflowRef = string | { readonly scriptPath: string };
21
+
22
+ /** One declared phase of a workflow. */
23
+ export interface WorkflowPhaseMeta {
24
+ title: string;
25
+ /** Profile for agents that pass this title as `phase` and set neither `model` nor `profile`. */
26
+ profile?: WorkflowModelProfileName;
27
+ }
19
28
 
20
29
  /** Metadata every workflow module must export. */
21
30
  export interface WorkflowMeta {
22
31
  name: string;
23
32
  description: string;
24
- phases?: Array<{ title: string }>;
33
+ /** When a host model should choose this workflow; listed in the system prompt's workflow catalog. */
34
+ whenToUse?: string;
35
+ phases?: WorkflowPhaseMeta[];
25
36
  }
26
37
 
27
38
  export interface WorkflowRunStats {
@@ -37,12 +48,16 @@ export interface WorkflowRunMetadata {
37
48
  readonly resumedFromRunId?: string;
38
49
  readonly journalPath: string;
39
50
  readonly recordPath: string;
51
+ /** The inline run's persisted script; absent for registered workflows or when the write failed. */
52
+ readonly scriptPath?: string;
40
53
  }
41
54
 
42
55
  export interface WorkflowBackgroundOrigin {
43
56
  /** Stable pi session id used to route and deduplicate completion delivery. */
44
57
  readonly sessionId: string;
45
58
  readonly requestedAt: number;
59
+ /** Deliver the completion with a new agent turn, because Dynamax was active when the run started. */
60
+ readonly wakeOnComplete?: boolean;
46
61
  }
47
62
 
48
63
  export interface WorkflowRunOptions {
@@ -79,6 +94,8 @@ export interface WorkflowRunOptions {
79
94
  signal?: AbortSignal;
80
95
  /** Internal recorder override for command/tool invocation timing. */
81
96
  perfRecorder?: PerfSink;
97
+ /** Internal bridge to the host session's MCP tools, available while a `workflow` tool call runs. */
98
+ hostTools?: HostToolBridge;
82
99
  /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
83
100
  resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
84
101
  /** Called with the final performance snapshot when perf is enabled. */
@@ -97,6 +114,8 @@ export interface WorkflowProgressSource {
97
114
  snapshot(): WorkflowProgressSnapshot;
98
115
  /** Durable lifecycle state of the run, so a finished snapshot can tell completed from failed. */
99
116
  readonly state?: () => WorkflowRunState;
117
+ /** Skip one running agent by its progress row id; false when it is no longer running. */
118
+ readonly skipAgent?: (agentId: number) => boolean;
100
119
  }
101
120
 
102
121
  /** Options for a single `agent()` call. */
@@ -185,6 +204,19 @@ export interface AgentOptions<S extends TSchema = TSchema> {
185
204
  schema?: S;
186
205
  }
187
206
 
207
+ /** Options for `api.classify()`. */
208
+ export interface ClassifyOptions {
209
+ /**
210
+ * Classifier model as "provider/id", or refs in preference order of which the first with working
211
+ * credentials is used. Default: the first classifier model with working credentials.
212
+ */
213
+ model?: string | readonly string[];
214
+ /** Label for usage reporting. Default: `classify:<provider>/<id>`. */
215
+ label?: string;
216
+ /** Phase to attribute usage to. Default: the current phase. */
217
+ phase?: string;
218
+ }
219
+
188
220
  /**
189
221
  * The primitives injected into every workflow run. A workflow is any module that
190
222
  * exports `meta` plus a default `async (api: WorkflowApi) => result`.
@@ -202,12 +234,21 @@ export interface WorkflowApi {
202
234
  /** Run a subagent and return its final assistant text. */
203
235
  agent(prompt: string, opts?: AgentOptions): Promise<string>;
204
236
  /**
205
- * Run another registered workflow inline as a sub-step and return its result. The child shares
237
+ * Answer typed `choice`/`score`/`bool` questions about JSON `state` with a classifier model, without
238
+ * starting a chat session: a cheap gate for triage, deduplication, or routing between stages. Answers
239
+ * are keyed by question id. Rejects with a recoverable error when no classifier is configured or the
240
+ * call fails; results are not journaled, so a resumed run classifies again.
241
+ */
242
+ classify(context: ClassifierContext, opts?: ClassifyOptions): Promise<Record<string, ClassifierAnswer>>;
243
+ /**
244
+ * Run another workflow inline as a sub-step and return its result: a registered workflow by name, or
245
+ * an inline-format script file via `{ scriptPath }` relative to the workflow cwd. The child shares
206
246
  * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
207
- * `workflow()` from within a sub-workflow rejects. Rejects on unknown name — catch it to handle
208
- * a missing sub-workflow gracefully.
247
+ * `workflow()` from within a sub-workflow rejects. Rejects on an unknown name, an unreadable
248
+ * scriptPath, or a script compile error — catch it to handle a missing sub-workflow gracefully.
249
+ * `args` is the child's input: a string or any JSON value, read there as `api.input` and `api.args`.
209
250
  */
210
- workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
251
+ workflow(ref: WorkflowRef, args?: unknown): Promise<unknown>;
211
252
  /**
212
253
  * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
213
254
  * failures become null slots by default; settled mode retains serialisable errors.
@@ -224,8 +265,13 @@ export interface WorkflowApi {
224
265
  log(message: string): void;
225
266
  /** Emit a structured progress event for native workflow UI surfaces. */
226
267
  progress(event: WorkflowProgressEvent): void;
227
- /** Raw argument string passed after the workflow name. */
268
+ /**
269
+ * The invocation's arguments as text: the string passed after the workflow name, a non-string tool or
270
+ * `workflow()` argument as JSON text, or "" when none was given.
271
+ */
228
272
  args: string;
273
+ /** The invocation's arguments verbatim: the slash command's string, or the tool or `workflow()` JSON value. */
274
+ input: unknown;
229
275
  /** Working directory of the host session (typically the repo root). */
230
276
  cwd: string;
231
277
  /**
@@ -240,8 +286,19 @@ export interface WorkflowApi {
240
286
 
241
287
  export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
242
288
 
289
+ /**
290
+ * `file` workflows resolve by name from `path` under `root`. Their `fingerprint` hashes the `root` tree,
291
+ * revalidated on resume because imports may load lazily, unless `selfContained` marks a compiled saved
292
+ * script: it can import nothing, so the hash of its own content is its whole identity.
293
+ */
243
294
  export type WorkflowSourceIdentity =
244
- | { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
295
+ | {
296
+ readonly kind: "file";
297
+ readonly path: string;
298
+ readonly root: string;
299
+ readonly fingerprint: string;
300
+ readonly selfContained?: true;
301
+ }
245
302
  | { readonly kind: "fingerprint"; readonly fingerprint: string }
246
303
  | { readonly kind: "unverifiable"; readonly reason: string };
247
304
 
@@ -255,4 +312,6 @@ export interface WorkflowModule {
255
312
  export interface LoadedWorkflow extends WorkflowModule {
256
313
  readonly source: WorkflowSourceIdentity;
257
314
  readonly isolatedWorktreeBaseline?: WorktreeBaseline;
315
+ /** An inline run's script source, persisted beside its run record so the run can be read and relaunched. */
316
+ readonly inlineScript?: string;
258
317
  }
@@ -15,6 +15,7 @@ export const GLYPH = {
15
15
  failed: "✗",
16
16
  running: "●",
17
17
  queued: "○",
18
+ skipped: "⊘",
18
19
  warning: "⚠",
19
20
  bullet: "•",
20
21
  cursor: "›",
@@ -77,6 +77,7 @@ export interface WorkflowStatusCounts {
77
77
  readonly running: number;
78
78
  readonly done: number;
79
79
  readonly failed: number;
80
+ readonly skipped: number;
80
81
  readonly total: number;
81
82
  }
82
83
 
@@ -96,7 +97,7 @@ export function formatWorkflowInspection(inspection: WorkflowInspectionSource):
96
97
  `Workflow inspector: ${inspection.name}`,
97
98
  `Run: ${snapshot.runId}`,
98
99
  `Phase: ${snapshot.currentPhase}`,
99
- `Agents: ${counts.running} running, ${counts.queued} queued, ${counts.done} done, ${counts.failed} failed`,
100
+ `Agents: ${counts.running} running, ${counts.queued} queued, ${counts.done} done, ${counts.failed} failed${counts.skipped > 0 ? `, ${counts.skipped} skipped` : ""}`,
100
101
  ];
101
102
  const usage = formatWorkflowUsageLine(snapshot.usage);
102
103
  if (usage) lines.push(usage);
@@ -109,7 +110,7 @@ export function statusTextFromCounts(snapshot: WorkflowProgressSnapshot, counts:
109
110
  if (snapshot.doneAt !== undefined) return undefined;
110
111
 
111
112
  const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
112
- const progress = counts.total > 0 ? ` ${counts.done + counts.failed}/${counts.total}` : "";
113
+ const progress = counts.total > 0 ? ` ${finishedAgents(counts)}/${counts.total}` : "";
113
114
  return [
114
115
  `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("accent", displayName)}`,
115
116
  theme.fg("muted", `${snapshot.currentPhase}${progress}`),
@@ -117,8 +118,13 @@ export function statusTextFromCounts(snapshot: WorkflowProgressSnapshot, counts:
117
118
  ].join(dot(theme));
118
119
  }
119
120
 
121
+ /** Agents that will not run again: done, failed, or skipped. */
122
+ export function finishedAgents(counts: WorkflowStatusCounts): number {
123
+ return counts.done + counts.failed + counts.skipped;
124
+ }
125
+
120
126
  export function countAgents(phases: readonly PhaseSnapshot[]): WorkflowStatusCounts {
121
- const counts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
127
+ const counts = { queued: 0, running: 0, done: 0, failed: 0, skipped: 0, total: 0 };
122
128
  for (const phase of phases) {
123
129
  for (const agent of phase.agents) {
124
130
  counts[agent.status]++;
@@ -25,7 +25,7 @@ import {
25
25
  truncatePath,
26
26
  type ThemeColor,
27
27
  } from "./kit.ts";
28
- import { formatDuration } from "./workflow-format.ts";
28
+ import { countAgents, finishedAgents, formatDuration } from "./workflow-format.ts";
29
29
  import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./workflow-viewer-layout.ts";
30
30
 
31
31
  /** What the inspector knows about a run beyond its progress snapshot. */
@@ -40,6 +40,12 @@ export interface WorkflowInspectorOutcome {
40
40
 
41
41
  type OutcomeSource = WorkflowInspectorOutcome | (() => WorkflowInspectorOutcome | undefined);
42
42
 
43
+ /** Actions on a live run. A retained or finished run has none, so its inspector is read-only. */
44
+ export interface WorkflowInspectorControls {
45
+ /** Skip one running agent by its progress row id; false when it is no longer running. */
46
+ readonly skipAgent?: (agentId: number) => boolean;
47
+ }
48
+
43
49
  /** The tables need about 80 columns, so narrow terminals give the inspector nearly full width instead of 80%. */
44
50
  export const WORKFLOW_INSPECTOR_OVERLAY_OPTIONS = {
45
51
  overlay: true,
@@ -51,9 +57,10 @@ export async function showWorkflowInspector(
51
57
  ui: Pick<ExtensionContext["ui"], "custom">,
52
58
  snapshot: () => WorkflowProgressSnapshot,
53
59
  outcome?: OutcomeSource,
60
+ controls?: WorkflowInspectorControls,
54
61
  ): Promise<void> {
55
62
  await ui.custom<void>(
56
- (...[tui, theme, , done]) => new WorkflowInspector(snapshot, tui, theme, () => done(undefined), outcome),
63
+ (...[tui, theme, , done]) => new WorkflowInspector(snapshot, tui, theme, () => done(undefined), outcome, controls),
57
64
  WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
58
65
  );
59
66
  }
@@ -113,6 +120,12 @@ export class WorkflowInspector {
113
120
  private readonly cursorIndexes = new Map<TabId, number>();
114
121
  private readonly scrollOffsets = new Map<TabId, number>();
115
122
  private readonly expanded = new Set<string>();
123
+ /** Agents whose skip was sent; they keep running until their session winds down. */
124
+ private readonly skipRequested = new Set<number>();
125
+ /** Agent armed by a first skip press; any other key disarms it. */
126
+ private pendingSkip: number | undefined;
127
+ /** One-keypress footer warning, as in the runs browser. */
128
+ private notice: string | undefined;
116
129
  private refreshTimer: ReturnType<typeof setInterval> | undefined;
117
130
 
118
131
  constructor(
@@ -121,6 +134,7 @@ export class WorkflowInspector {
121
134
  private readonly theme: Theme,
122
135
  private readonly close: () => void,
123
136
  private readonly outcomeSource?: OutcomeSource,
137
+ private readonly controls?: WorkflowInspectorControls,
124
138
  ) {}
125
139
 
126
140
  handleInput(data: string): void {
@@ -128,6 +142,10 @@ export class WorkflowInspector {
128
142
  this.close();
129
143
  return;
130
144
  }
145
+ const armedSkip = this.pendingSkip;
146
+ const hadNotice = this.notice !== undefined;
147
+ this.pendingSkip = undefined;
148
+ this.notice = undefined;
131
149
  const tabs = this.tabs(this.outcome());
132
150
  const digit = TAB_DIGITS.findIndex((candidate) => matchesKey(data, candidate));
133
151
  if (matchesKey(data, "tab") || matchesKey(data, "right")) this.tabIndex = (this.tabIndex + 1) % tabs.length;
@@ -142,7 +160,8 @@ export class WorkflowInspector {
142
160
  else if (matchesKey(data, "g") || matchesKey(data, "home")) this.move(-Infinity);
143
161
  else if (data === "G" || matchesKey(data, "shift+g") || matchesKey(data, "end")) this.move(Infinity);
144
162
  else if (matchesKey(data, "enter") || matchesKey(data, "return")) this.toggleExpanded();
145
- else return;
163
+ else if (matchesKey(data, "s")) this.skipSelected(armedSkip);
164
+ else if (!hadNotice) return;
146
165
  this.tui.requestRender();
147
166
  }
148
167
 
@@ -175,11 +194,12 @@ export class WorkflowInspector {
175
194
  const title = `${this.theme.fg("accent", GLYPH.workflow)} ${this.theme.bold(snapshot.title)}`;
176
195
  // Border chrome around the two labels: `╭─ ` + title + ` ` … ` ` + right + ` ─╮`, plus one fill cell.
177
196
  const rightRoom = Math.max(0, width - visibleWidth(title) - 9);
197
+ const skippable = this.skippableAgent(active, snapshot, status) !== undefined;
178
198
  return frame(
179
199
  [tabLine, "", ...visible, ...Array.from({ length: viewRows - visible.length }, () => "")],
180
200
  width,
181
201
  this.theme,
182
- { title, right: this.headerStatus(snapshot, status, rightRoom), footer: this.footer(active, rows, viewRows, offset, below, width) },
202
+ { title, right: this.headerStatus(snapshot, status, rightRoom), footer: this.footer(active, rows, viewRows, offset, below, width, skippable) },
183
203
  );
184
204
  }
185
205
 
@@ -250,6 +270,29 @@ export class WorkflowInspector {
250
270
  else this.expanded.add(key);
251
271
  }
252
272
 
273
+ /** Skipping ends live work, so like stopping a run in the runs browser it takes a second press on the same agent. */
274
+ private skipSelected(armed: number | undefined): void {
275
+ const snapshot = this.snapshotProvider();
276
+ const agent = this.skippableAgent(this.activeTab(), snapshot, runStatus(snapshot, this.outcome()?.state));
277
+ if (!agent) return;
278
+ if (armed !== agent.id) {
279
+ this.pendingSkip = agent.id;
280
+ this.notice = `press s again to skip ${agent.label}`;
281
+ return;
282
+ }
283
+ if (this.controls?.skipAgent?.(agent.id)) this.skipRequested.add(agent.id);
284
+ else this.notice = `${agent.label} is no longer running`;
285
+ }
286
+
287
+ /** The selected agent when it can be skipped: a running row of a live run whose source accepts skips. */
288
+ private skippableAgent(tab: TabId, snapshot: WorkflowProgressSnapshot, status: RunStatus): AgentRowSnapshot | undefined {
289
+ if (!this.controls?.skipAgent || tab !== "agents" || !status.live) return undefined;
290
+ const agents = snapshot.phases.flatMap((phase) => phase.agents);
291
+ const selected = this.selectedKey("agents", agents.map(agentKey));
292
+ const agent = agents.find((candidate) => agentKey(candidate) === selected);
293
+ return agent?.status === "running" && !this.skipRequested.has(agent.id) ? agent : undefined;
294
+ }
295
+
253
296
  /** Resolves the cursor by key so live rows inserted above it do not move the selection. */
254
297
  private cursorIndex(tab: TabId, keys: readonly string[]): number {
255
298
  const byKey = keys.indexOf(this.cursorKeys.get(tab) ?? "");
@@ -299,27 +342,29 @@ export class WorkflowInspector {
299
342
  /** State first; lower-priority parts (run id, then elapsed) drop whole rather than truncating. */
300
343
  private headerStatus(snapshot: WorkflowProgressSnapshot, status: RunStatus, room: number): string {
301
344
  const th = this.theme;
302
- const agents = snapshot.phases.flatMap((phase) => phase.agents);
303
- const failed = agents.filter((agent) => agent.status === "failed").length;
304
- // A run that finished around failed agents is complete but not clean.
305
- const degraded = !status.live && failed > 0 && status.color === "success";
345
+ const counts = countAgents(snapshot.phases);
346
+ // A run that finished around failed or skipped agents is complete but not clean.
347
+ const degraded = !status.live && counts.failed + counts.skipped > 0 && status.color === "success";
306
348
  const state = degraded ? th.fg("warning", `${GLYPH.warning} ${status.word}`) : th.fg(status.color, `${status.glyph} ${status.word}`);
307
- const failures = failed > 0 ? th.fg("error", `${failed} failed`) : undefined;
349
+ const failures = counts.failed > 0 ? th.fg("error", `${counts.failed} failed`) : undefined;
350
+ const skips = counts.skipped > 0 ? th.fg("warning", `${counts.skipped} skipped`) : undefined;
308
351
  const runId = th.fg("dim", `run ${snapshot.runId.slice(0, 8)}`);
309
352
  if (status.live) {
310
- const finished = agents.filter((agent) => agent.status === "done" || agent.status === "failed").length;
311
- const progress = agents.length > 0 ? `${snapshot.currentPhase} ${finished}/${agents.length}` : snapshot.currentPhase;
312
- return fitParts([state, th.fg("muted", progress), failures, th.fg("muted", formatDuration(Date.now() - snapshot.startedAt)), runId], room, th);
353
+ const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
354
+ return fitParts([state, th.fg("muted", progress), failures, skips, th.fg("muted", formatDuration(Date.now() - snapshot.startedAt)), runId], room, th);
313
355
  }
314
356
  const elapsed = snapshot.doneAt !== undefined ? th.fg("muted", formatDuration(snapshot.doneAt - snapshot.startedAt)) : undefined;
315
- return fitParts([state, failures, elapsed, runId], room, th);
357
+ return fitParts([state, failures, skips, elapsed, runId], room, th);
316
358
  }
317
359
 
318
- private footer(tab: TabId, rows: readonly Row[], viewRows: number, offset: number, below: number, width: number): string {
360
+ private footer(tab: TabId, rows: readonly Row[], viewRows: number, offset: number, below: number, width: number, skippable: boolean): string {
361
+ if (this.notice) return this.theme.fg("warning", `${GLYPH.warning} ${this.notice}`);
319
362
  const clipped = rows.length > viewRows;
320
363
  const pairs: (readonly [string, string])[] = [["tab", "sections"]];
321
364
  if (LIST_TABS.has(tab) && selectableKeys(rows).length > 0) pairs.push(["↑↓", "move"], ["enter", "expand"]);
322
365
  else if (clipped) pairs.push(["↑↓", "scroll"]);
366
+ // Like the runs browser's action keys, skip is listed only while the selection can take it.
367
+ if (skippable) pairs.push(["s", "skip"]);
323
368
  pairs.push(["q", "close"]);
324
369
  const hints = keyHints(pairs, this.theme);
325
370
  const scroll = [offset > 0 ? `↑ ${offset} more` : undefined, below > 0 ? `↓ ${below} more` : undefined].filter(Boolean).join(" ");
@@ -367,6 +412,7 @@ export class WorkflowInspector {
367
412
  countNote(phase.agents, "running", "accent", th),
368
413
  countNote(phase.agents, "queued", "dim", th),
369
414
  countNote(phase.agents, "failed", "error", th),
415
+ countNote(phase.agents, "skipped", "warning", th),
370
416
  ],
371
417
  th,
372
418
  );
@@ -449,9 +495,8 @@ export class WorkflowInspector {
449
495
  const now = Date.now();
450
496
  const rows: Row[] = [];
451
497
  for (const phase of phases) {
452
- const failed = phase.agents.filter((agent) => agent.status === "failed").length;
453
- const note = failed > 0 ? ` ${th.fg("error", `${failed} failed`)}` : "";
454
- rows.push({ text: ` ${th.bold(th.fg("muted", phase.title))}${note}`, heading: true });
498
+ const notes = joinParts([countNote(phase.agents, "failed", "error", th), countNote(phase.agents, "skipped", "warning", th)], th);
499
+ rows.push({ text: ` ${th.bold(th.fg("muted", phase.title))}${notes ? ` ${notes}` : ""}`, heading: true });
455
500
  for (const agent of phase.agents) {
456
501
  const key = agentKey(agent);
457
502
  const cursor = key === selected ? th.fg("accent", GLYPH.cursor) : " ";
@@ -460,7 +505,8 @@ export class WorkflowInspector {
460
505
  const tools = agent.toolUses > 0 ? `${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}` : "";
461
506
  const expanded = this.expanded.has(key);
462
507
  // Expanded rows carry the full error and tool below, so the inline note would repeat them.
463
- const note = expanded ? "" : agentNote(agent, th);
508
+ const skipping = agent.status === "running" && this.skipRequested.has(agent.id);
509
+ const note = skipping ? th.fg("warning", "skipping…") : expanded ? "" : agentNote(agent, th);
464
510
  const line = `${cursor} ${agentGlyph(agent, th)} ${label} ${padStartVisible(th.fg("muted", duration), 6)} ${fit(th.fg("dim", tools), 8)} ${note}`;
465
511
  rows.push({ text: line.trimEnd(), key });
466
512
  if (expanded) rows.push(...this.agentDetails(agent, phase, snapshot, width));
@@ -474,6 +520,7 @@ export class WorkflowInspector {
474
520
  const fields: [string, string][] = [];
475
521
  if (agent.error) fields.push(["error", th.fg("error", agent.error)]);
476
522
  if (agent.status === "queued") fields.push(["status", th.fg("muted", "queued, waiting for a free agent slot")]);
523
+ if (agent.status === "skipped") fields.push(["status", th.fg("warning", "skipped by the user; its result was dropped")]);
477
524
  if (agent.lastTool) {
478
525
  const calls = agent.toolUses > 1 ? th.fg("dim", ` · last of ${agent.toolUses} tool calls`) : "";
479
526
  fields.push(["tool", `${agent.lastTool}${calls}`]);
@@ -611,6 +658,7 @@ function phaseGlyph(phase: PhaseSnapshot, activeAndLive: boolean): { glyph: stri
611
658
  if (agents.length > 0 && agents.every((agent) => agent.status === "queued")) return { glyph: GLYPH.queued, color: "dim" };
612
659
  if (agents.some((agent) => agent.status === "queued")) return { glyph: GLYPH.running, color: "accent" };
613
660
  if (agents.length > 0 && agents.every((agent) => agent.status === "failed")) return { glyph: GLYPH.failed, color: "error" };
661
+ if (agents.length > 0 && agents.every((agent) => agent.status === "skipped")) return { glyph: GLYPH.skipped, color: "warning" };
614
662
  return { glyph: GLYPH.done, color: "success" };
615
663
  }
616
664
 
@@ -655,6 +703,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
655
703
  return theme.fg("success", GLYPH.done);
656
704
  case "failed":
657
705
  return theme.fg("error", GLYPH.failed);
706
+ case "skipped":
707
+ return theme.fg("warning", GLYPH.skipped);
658
708
  case "running":
659
709
  return theme.fg("accent", GLYPH.running);
660
710
  case "queued":
@@ -664,6 +714,7 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
664
714
 
665
715
  function agentNote(agent: AgentRowSnapshot, theme: Theme): string {
666
716
  if (agent.status === "failed") return theme.fg("error", firstLine(agent.error ?? "failed"));
717
+ if (agent.status === "skipped") return theme.fg("warning", "skipped");
667
718
  if (agent.status === "queued") return theme.fg("dim", "queued");
668
719
  if (agent.status === "running" && agent.lastTool) return theme.fg("dim", agent.lastTool);
669
720
  return "";
@@ -1,11 +1,13 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { decodeKittyPrintable, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
2
+ import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
3
  import { isRecord } from "../guards.ts";
4
4
  import { formatCount, truncateText } from "../text.ts";
5
5
  import {
6
6
  availableWorkflowRunActions,
7
7
  canRelaunchWorkflowRun,
8
8
  formatWorkflowRunDuration,
9
+ hasRelaunchableWorkflowSource,
10
+ isInlineWorkflowRun,
9
11
  type WorkflowRunActionContext,
10
12
  type WorkflowRunLifecycleAction,
11
13
  } from "../workflow-run-history.ts";
@@ -72,9 +74,12 @@ const OUTCOME_LINES = 3;
72
74
  const OUTCOME_CHARS = 600;
73
75
  const LIST_MIN = 3;
74
76
  const REFRESH_MS = 1_000;
77
+ const INLINE_TAG = "inline";
75
78
 
76
79
  interface RunCells {
77
80
  readonly name: string;
81
+ /** `inline` for a run of an inline script; it stays visible however far the name is truncated. */
82
+ readonly tag: string;
78
83
  readonly state: string;
79
84
  readonly duration: string;
80
85
  readonly age: string;
@@ -212,9 +217,12 @@ export class WorkflowRunsBrowser implements Component {
212
217
  const style = STATE_STYLE[record.state];
213
218
  const selected = index === this.cursor;
214
219
  const cursor = selected ? this.theme.fg("accent", GLYPH.cursor) : " ";
215
- const name = fit(cells.name, layout.name);
220
+ const tagWidth = cells.tag ? visibleWidth(cells.tag) + 1 : 0;
221
+ const name = truncateToWidth(cells.name, Math.max(1, layout.name - tagWidth), "…");
222
+ const tag = cells.tag ? ` ${this.theme.fg("dim", cells.tag)}` : "";
223
+ const padding = " ".repeat(Math.max(0, layout.name - visibleWidth(name) - tagWidth));
216
224
  const parts = [
217
- `${cursor} ${this.theme.fg(style.color, style.glyph)} ${selected ? this.theme.bold(name) : name}`,
225
+ `${cursor} ${this.theme.fg(style.color, style.glyph)} ${selected ? this.theme.bold(name) : name}${tag}${padding}`,
218
226
  this.theme.fg(style.color, fit(cells.state, layout.state)),
219
227
  ];
220
228
  if (layout.duration > 0) parts.push(this.theme.fg("muted", padStartVisible(cells.duration, layout.duration)));
@@ -366,7 +374,7 @@ interface ColumnLayout {
366
374
  function columnLayout(cells: readonly RunCells[], width: number): ColumnLayout {
367
375
  const widest = (pick: (cell: RunCells) => string) => Math.max(0, ...cells.map((cell) => visibleWidth(pick(cell))));
368
376
  let layout: ColumnLayout = {
369
- name: Math.min(NAME_MAX, widest((cell) => cell.name)),
377
+ name: Math.min(NAME_MAX, widest((cell) => (cell.tag ? `${cell.name} ${cell.tag}` : cell.name))),
370
378
  state: widest((cell) => cell.state),
371
379
  duration: widest((cell) => cell.duration),
372
380
  age: widest((cell) => cell.age),
@@ -389,6 +397,7 @@ function runCells(record: WorkflowRunRecord, now: number): RunCells {
389
397
  const cost = record.usage?.totals.cost.total ?? 0;
390
398
  return {
391
399
  name: record.workflow.name,
400
+ tag: isInlineWorkflowRun(record) ? INLINE_TAG : "",
392
401
  state: record.state,
393
402
  duration: record.state === "queued" ? "" : formatWorkflowRunDuration(record, now),
394
403
  age: relativeTime(record.createdAt, now),
@@ -446,8 +455,10 @@ function describeOutcome(record: WorkflowRunRecord): string | undefined {
446
455
  /** Why a finished or paused run cannot be relaunched, mirroring `canRelaunchWorkflowRun`. */
447
456
  function relaunchBlocker(record: WorkflowRunRecord): string | undefined {
448
457
  if (record.state === "queued" || record.state === "running" || canRelaunchWorkflowRun(record)) return undefined;
449
- if (record.workflow.sourceKind !== "file" || typeof record.workflow.sourceFingerprint !== "string") {
450
- return "the workflow source cannot be verified";
458
+ if (!hasRelaunchableWorkflowSource(record)) {
459
+ if (!isInlineWorkflowRun(record)) return "the workflow source cannot be verified";
460
+ if (record.scriptFile === undefined) return "its inline script was not retained";
461
+ return "its inline script has no seal from this pi install";
451
462
  }
452
463
  return record.options.argumentsPresent !== false ? "its arguments were not retained" : undefined;
453
464
  }
@@ -2,6 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
3
  import { isRecord } from "../guards.ts";
4
4
  import { readInlineWorkflowMeta } from "../inline-workflow.ts";
5
+ import { workflowArgsText } from "../workflow-input.ts";
5
6
  import { GLYPH, hangingWrap, keyHints, spread } from "./kit.ts";
6
7
  import { LinesComponent, shortRunId, type WorkflowResultHints } from "./result-layout.ts";
7
8
  import { isWorkflowResult, workflowResultComponent } from "./workflow-result-renderer.ts";
@@ -10,7 +11,9 @@ import { isWorkflowResult, workflowResultComponent } from "./workflow-result-ren
10
11
  export interface WorkflowToolCallArgs {
11
12
  readonly name?: string;
12
13
  readonly script?: string;
13
- readonly args?: string;
14
+ readonly scriptPath?: string;
15
+ /** A string or any JSON value. */
16
+ readonly args?: unknown;
14
17
  readonly background?: boolean;
15
18
  readonly resumeFromRunId?: string;
16
19
  }
@@ -22,11 +25,13 @@ export interface WorkflowToolResultView {
22
25
 
23
26
  /** A named workflow, or an inline one identified by its literal meta (`inline` until that parses). */
24
27
  export function workflowToolTitle(args: WorkflowToolCallArgs): { readonly name: string; readonly detail?: string } {
25
- const detail = collapseWhitespace(args.args ?? "");
28
+ const detail = collapseWhitespace(workflowArgsText(args.args));
26
29
  const named = args.name?.trim();
27
30
  if (named) return { name: named, detail: detail || undefined };
28
31
  const meta = args.script ? readInlineWorkflowMeta(args.script) : undefined;
29
- return { name: meta?.name ?? "inline", detail: detail || collapseWhitespace(meta?.description ?? "") || undefined };
32
+ // The row renders synchronously, so a script file is named by its path rather than read for its meta.
33
+ const fallback = meta?.description ?? args.scriptPath ?? "";
34
+ return { name: meta?.name ?? "inline", detail: detail || collapseWhitespace(fallback) || undefined };
30
35
  }
31
36
 
32
37
  /** `▸ workflow code-review · HEAD~3 (background)`: the detail is shortened before the tags are. */