pi-workflow-engine 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -85
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  8. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  9. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  10. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  11. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  12. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  13. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  14. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
  15. package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
  16. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -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/progress-types.ts +1 -1
  21. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  22. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  23. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  24. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  25. package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
  26. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  29. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
  48. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
  49. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
  50. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
  51. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  52. package/README.md +11 -3
  53. package/USAGE.md +124 -23
  54. package/package.json +13 -4
  55. package/skills/workflow-authoring/SKILL.md +162 -0
@@ -16,14 +16,23 @@ import type { WorkflowRunState } from "./workflow-run-record.ts";
16
16
 
17
17
  export type { WorkflowLaneItemStatus } from "./progress-types.ts";
18
18
 
19
- /** A reference to a registered workflow by name. */
20
- 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
+ }
21
28
 
22
29
  /** Metadata every workflow module must export. */
23
30
  export interface WorkflowMeta {
24
31
  name: string;
25
32
  description: string;
26
- 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[];
27
36
  }
28
37
 
29
38
  export interface WorkflowRunStats {
@@ -39,12 +48,16 @@ export interface WorkflowRunMetadata {
39
48
  readonly resumedFromRunId?: string;
40
49
  readonly journalPath: string;
41
50
  readonly recordPath: string;
51
+ /** The inline run's persisted script; absent for registered workflows or when the write failed. */
52
+ readonly scriptPath?: string;
42
53
  }
43
54
 
44
55
  export interface WorkflowBackgroundOrigin {
45
56
  /** Stable pi session id used to route and deduplicate completion delivery. */
46
57
  readonly sessionId: string;
47
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;
48
61
  }
49
62
 
50
63
  export interface WorkflowRunOptions {
@@ -101,6 +114,8 @@ export interface WorkflowProgressSource {
101
114
  snapshot(): WorkflowProgressSnapshot;
102
115
  /** Durable lifecycle state of the run, so a finished snapshot can tell completed from failed. */
103
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;
104
119
  }
105
120
 
106
121
  /** Options for a single `agent()` call. */
@@ -226,12 +241,14 @@ export interface WorkflowApi {
226
241
  */
227
242
  classify(context: ClassifierContext, opts?: ClassifyOptions): Promise<Record<string, ClassifierAnswer>>;
228
243
  /**
229
- * Run another registered workflow inline as a sub-step and return its result. The child shares
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
230
246
  * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
231
- * `workflow()` from within a sub-workflow rejects. Rejects on unknown name — catch it to handle
232
- * 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`.
233
250
  */
234
- workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
251
+ workflow(ref: WorkflowRef, args?: unknown): Promise<unknown>;
235
252
  /**
236
253
  * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
237
254
  * failures become null slots by default; settled mode retains serialisable errors.
@@ -248,8 +265,13 @@ export interface WorkflowApi {
248
265
  log(message: string): void;
249
266
  /** Emit a structured progress event for native workflow UI surfaces. */
250
267
  progress(event: WorkflowProgressEvent): void;
251
- /** 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
+ */
252
272
  args: string;
273
+ /** The invocation's arguments verbatim: the slash command's string, or the tool or `workflow()` JSON value. */
274
+ input: unknown;
253
275
  /** Working directory of the host session (typically the repo root). */
254
276
  cwd: string;
255
277
  /**
@@ -258,14 +280,35 @@ export interface WorkflowApi {
258
280
  * `agent()` throws `WorkflowBudgetExceededError` once the ceiling is reached.
259
281
  */
260
282
  budget: WorkflowBudget;
283
+ /**
284
+ * The run's live-agent limit. `remaining()` is live: admissions left before `agent()` throws
285
+ * `WorkflowAgentLimitError` (journal replays take none). Reserve what later stages need before a fan-out.
286
+ */
287
+ agents: WorkflowAgentCapacity;
261
288
  /** Abort signal for the run, propagated from the host. */
262
289
  signal: AbortSignal | undefined;
263
290
  }
264
291
 
292
+ export interface WorkflowAgentCapacity {
293
+ readonly max: number;
294
+ remaining(): number;
295
+ }
296
+
265
297
  export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
266
298
 
299
+ /**
300
+ * `file` workflows resolve by name from `path` under `root`. Their `fingerprint` hashes the `root` tree,
301
+ * revalidated on resume because imports may load lazily, unless `selfContained` marks a compiled saved
302
+ * script: it can import nothing, so the hash of its own content is its whole identity.
303
+ */
267
304
  export type WorkflowSourceIdentity =
268
- | { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
305
+ | {
306
+ readonly kind: "file";
307
+ readonly path: string;
308
+ readonly root: string;
309
+ readonly fingerprint: string;
310
+ readonly selfContained?: true;
311
+ }
269
312
  | { readonly kind: "fingerprint"; readonly fingerprint: string }
270
313
  | { readonly kind: "unverifiable"; readonly reason: string };
271
314
 
@@ -279,4 +322,6 @@ export interface WorkflowModule {
279
322
  export interface LoadedWorkflow extends WorkflowModule {
280
323
  readonly source: WorkflowSourceIdentity;
281
324
  readonly isolatedWorktreeBaseline?: WorktreeBaseline;
325
+ /** An inline run's script source, persisted beside its run record so the run can be read and relaunched. */
326
+ readonly inlineScript?: string;
282
327
  }
@@ -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. */
@@ -3,7 +3,7 @@ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/p
3
3
  import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
4
4
  import { formatCount } from "../text.ts";
5
5
  import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
6
- import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
6
+ import { countAgents, finishedAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
7
7
 
8
8
  /** pi caps string widgets at this many rows; the component keeps to the same budget. */
9
9
  const MAX_WIDGET_LINES = 10;
@@ -64,7 +64,7 @@ export class WidthAwareWidget implements Component {
64
64
  /** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
65
65
  export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
66
66
  const counts = countAgents(snapshot.phases);
67
- const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts.done + counts.failed}/${counts.total}` : snapshot.currentPhase;
67
+ const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
68
68
  const line = joinParts([
69
69
  `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
70
70
  `${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
@@ -92,7 +92,7 @@ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
92
92
 
93
93
  function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
94
94
  const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
95
- const finished = counts.done + counts.failed;
95
+ const finished = finishedAgents(counts);
96
96
  const stats = joinParts([
97
97
  counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
98
98
  theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
@@ -156,8 +156,8 @@ function phaseBody(
156
156
 
157
157
  /**
158
158
  * Spread the row budget over active phases by importance — a reserved share for failures so they always
159
- * show, then running, remaining failures, done, queued — while each phase lists its chosen rows as
160
- * running, failed, done, queued.
159
+ * show, then running, remaining failures, skipped, done, queued — while each phase lists its chosen rows
160
+ * in that same status order.
161
161
  */
162
162
  function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
163
163
  const byStatus = (status: AgentRowSnapshot["status"]) =>
@@ -177,10 +177,11 @@ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<Ph
177
177
  take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
178
178
  take(byStatus("running"));
179
179
  take(failed);
180
+ take(byStatus("skipped"));
180
181
  take(byStatus("done"));
181
182
  take(byStatus("queued"));
182
183
 
183
- const rank = { running: 0, failed: 1, done: 2, queued: 3 } as const;
184
+ const rank = { running: 0, failed: 1, skipped: 2, done: 3, queued: 4 } as const;
184
185
  const plans = new Map<PhaseSnapshot, AgentRowPlan>();
185
186
  for (const phase of active) {
186
187
  const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
@@ -196,7 +197,7 @@ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width:
196
197
 
197
198
  function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
198
199
  const counts = countAgents([phase]);
199
- const glyph = counts.failed === 0
200
+ const glyph = counts.failed === 0 && counts.skipped === 0
200
201
  ? theme.fg("success", GLYPH.done)
201
202
  : counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
202
203
  const span = phaseSpan(phase.agents);
@@ -204,17 +205,19 @@ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Them
204
205
  counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
205
206
  span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
206
207
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
208
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
207
209
  ], theme);
208
210
  return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
209
211
  }
210
212
 
211
213
  function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
212
214
  const counts = countAgents(phases);
213
- const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
215
+ const glyph = counts.failed > 0 || counts.skipped > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
214
216
  const titles = phases.map((phase) => phase.title).join(" · ");
215
217
  const detail = joinParts([
216
218
  theme.fg("muted", plural(counts.total, "agent")),
217
219
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
220
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
218
221
  ], theme);
219
222
  return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
220
223
  }
@@ -226,6 +229,7 @@ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme):
226
229
  counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
227
230
  counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
228
231
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
232
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
229
233
  plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
230
234
  ], theme);
231
235
  return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
@@ -246,6 +250,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
246
250
  return theme.fg("error", GLYPH.failed);
247
251
  case "done":
248
252
  return theme.fg("success", GLYPH.done);
253
+ case "skipped":
254
+ return theme.fg("warning", GLYPH.skipped);
249
255
  case "queued":
250
256
  return theme.fg("dim", GLYPH.queued);
251
257
  }
@@ -264,6 +270,8 @@ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string
264
270
  ], theme);
265
271
  case "failed":
266
272
  return theme.fg("error", oneLine(agent.error ?? "failed"));
273
+ case "skipped":
274
+ return theme.fg("warning", "skipped");
267
275
  case "queued":
268
276
  return theme.fg("dim", "queued");
269
277
  }