pi-subagents 0.60.0 → 0.61.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 (57) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/docs/agents.md +2 -2
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +12 -3
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/SKILL.md +6 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +42 -11
  14. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
  16. package/skills/pi-subagents/references/review-and-validation.md +1 -1
  17. package/src/agents/agent-management.ts +102 -63
  18. package/src/agents/agents.ts +527 -221
  19. package/src/api/background-work.ts +7 -2
  20. package/src/api/external-runs.ts +67 -4
  21. package/src/api/preflight.ts +13 -8
  22. package/src/api/shared-types.ts +1 -0
  23. package/src/extension/index.ts +7 -4
  24. package/src/extension/public-execution.ts +47 -4
  25. package/src/extension/rpc.ts +62 -4
  26. package/src/extension/schemas.ts +11 -7
  27. package/src/extension/tool-description.ts +14 -18
  28. package/src/runs/background/async-execution.ts +51 -35
  29. package/src/runs/background/async-job-tracker.ts +62 -3
  30. package/src/runs/background/async-resume.ts +3 -1
  31. package/src/runs/background/async-status.ts +59 -9
  32. package/src/runs/background/auto-drain.ts +1 -1
  33. package/src/runs/background/fleet-view.ts +1 -1
  34. package/src/runs/background/result-watcher.ts +1 -1
  35. package/src/runs/background/resume-guidance.ts +1 -1
  36. package/src/runs/background/run-status.ts +2 -2
  37. package/src/runs/background/subagent-runner.ts +1 -2
  38. package/src/runs/background/subagent-wait.ts +20 -21
  39. package/src/runs/background/wait-completions.ts +1 -1
  40. package/src/runs/background/wait-tool.ts +24 -18
  41. package/src/runs/foreground/execution.ts +61 -2
  42. package/src/runs/foreground/subagent-executor.ts +178 -58
  43. package/src/runs/shared/acceptance.ts +43 -18
  44. package/src/runs/shared/host-step-status.ts +1 -0
  45. package/src/runs/shared/model-fallback.ts +61 -17
  46. package/src/runs/shared/permissions.ts +1 -1
  47. package/src/runs/shared/tool-timeout.ts +1 -1
  48. package/src/runs/shared/workflow-graph.ts +3 -2
  49. package/src/shared/types.ts +40 -3
  50. package/src/shared/workflow-child-permit.ts +91 -0
  51. package/src/slash/prompt-template-bridge.ts +37 -1
  52. package/src/slash/slash-commands.ts +18 -26
  53. package/src/tui/render.ts +95 -52
  54. package/src/workflows/scripted-workflow.ts +153 -4
  55. package/src/workflows/workflow-child-summary.ts +1 -1
  56. package/src/workflows/workflow-receipt.ts +41 -4
  57. package/src/workflows/workflow-resources.ts +150 -0
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { keyText, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Key, matchesKey, truncateToWidth, type Component, type KeyId, type TUI } from "@earendil-works/pi-tui";
6
- import { BUILTIN_AGENT_NAMES, discoverAgents, discoverAgentsAll, findBlockingAgentDiagnostic, formatUnknownAgentError, resolveAgentName, unknownAgentDiagnosticContext, type AgentConfig, type AgentDiscoveryDiagnostic, type AgentScope, type UnknownAgentDiagnosticContext } from "../agents/agents.ts";
6
+ import { discoverAgentSnapshot, discoverAgents, findBlockingAgentDiagnostic, formatUnknownAgentError, resolveAgentName, unknownAgentDiagnosticContext, type AgentConfig, type AgentDiscoveryDiagnostic, type AgentScope, type UnknownAgentDiagnosticContext } from "../agents/agents.ts";
7
7
  import { listRuntimeAgentConfigs, mergeRuntimeAgents } from "../agents/runtime-agent-registry.ts";
8
8
  import { resolveExistingReadPaths } from "../shared/settings.ts";
9
9
  import {
@@ -113,9 +113,13 @@ const extractExecutionFlags = (rawArgs: string): { args: string; bg: boolean; fo
113
113
  };
114
114
 
115
115
  function discoverSlashAgents(pi: ExtensionAPI, cwd: string, scope: AgentScope): { agents: AgentConfig[]; agentDiagnostics?: AgentDiscoveryDiagnostic[]; unknownAgentDiagnosticContext: UnknownAgentDiagnosticContext } {
116
- const discovered = discoverAgents(cwd, scope);
117
- if (listRuntimeAgentConfigs(pi).length === 0) return { ...discovered, unknownAgentDiagnosticContext: unknownAgentDiagnosticContext(discovered) };
118
- const all = discoverAgentsAll(cwd);
116
+ if (listRuntimeAgentConfigs(pi).length === 0) {
117
+ const discovered = discoverAgents(cwd, scope);
118
+ return { ...discovered, unknownAgentDiagnosticContext: unknownAgentDiagnosticContext(discovered) };
119
+ }
120
+ const snapshot = discoverAgentSnapshot(cwd, scope, undefined, { includeChains: false });
121
+ const discovered = snapshot.effective;
122
+ const all = snapshot.all;
119
123
  const configuredAgents: AgentConfig[] = [
120
124
  ...all.builtin,
121
125
  ...all.package,
@@ -133,13 +137,6 @@ const makeAgentCompletions = (pi: ExtensionAPI, state: SubagentState) => (prefix
133
137
  .map((agent) => ({ value: agent.name, label: agent.name }));
134
138
  };
135
139
 
136
- const makeBuiltinAgentNameCompletions = () => (prefix: string) => {
137
- if (prefix.includes(" ")) return null;
138
- return BUILTIN_AGENT_NAMES
139
- .filter((name) => name.startsWith(prefix))
140
- .map((name) => ({ value: name, label: name }));
141
- };
142
-
143
140
  const makeProviderCompletions = (state: SubagentState) => (prefix: string) => {
144
141
  if (prefix.includes(" ")) return null;
145
142
  const available = state.lastUiContext?.modelRegistry?.getAvailable?.();
@@ -420,7 +417,7 @@ function detailsFromSessionEntry(entry: unknown): Details | undefined {
420
417
  }
421
418
  if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
422
419
  const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
423
- if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "subagent_wait")) return undefined;
420
+ if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait" && message.toolName !== "subagent_wait")) return undefined;
424
421
  return isSubagentDetails(message.details) ? message.details : undefined;
425
422
  }
426
423
 
@@ -1005,7 +1002,7 @@ export function registerSlashCommands(
1005
1002
  ctx.ui.notify(`Foreground run ${control.runId} is not currently detachable.`, "info");
1006
1003
  return;
1007
1004
  }
1008
- sendSlashText(pi, `Detached foreground run ${control.runId} without terminating its child. Use subagent({ action: "status", id: ${JSON.stringify(control.runId)} }) or subagent_wait({ id: ${JSON.stringify(control.runId)} }) to recover the eventual result. This does not daemonize the process or guarantee survival across Pi reload/restart.`);
1005
+ sendSlashText(pi, `Detached foreground run ${control.runId} without terminating its child. Use subagent({ action: "status", id: ${JSON.stringify(control.runId)} }) or bg_wait({ id: ${JSON.stringify(control.runId)} }) to recover the eventual result. This does not daemonize the process or guarantee survival across Pi reload/restart.`);
1009
1006
  };
1010
1007
 
1011
1008
  pi.registerCommand("subagents-detach", {
@@ -1140,25 +1137,20 @@ export function registerSlashCommands(
1140
1137
  });
1141
1138
 
1142
1139
  pi.registerCommand("subagents-models", {
1143
- description: "Show runtime-loaded builtin subagent models",
1144
- getArgumentCompletions: makeBuiltinAgentNameCompletions(),
1145
- handler: async (args, ctx) => {
1146
- const trimmed = args.trim();
1147
- if (!trimmed) {
1148
- await runCommand(ctx, { action: "models" });
1140
+ description: "Show model mappings for discovered subagents",
1141
+ getArgumentCompletions: makeAgentCompletions(pi, state),
1142
+ handler: async (args, ctx) => {
1143
+ const trimmed = args.trim();
1144
+ if (!trimmed) {
1145
+ await runCommand(ctx, { action: "models" });
1149
1146
  return;
1150
1147
  }
1151
1148
  const parts = trimmed.split(/\s+/).filter(Boolean);
1152
1149
  if (parts.length !== 1) {
1153
- ctx.ui.notify("Usage: /subagents-models [builtin-agent-name]", "error");
1154
- return;
1155
- }
1156
- const agent = parts[0]!;
1157
- if (!(BUILTIN_AGENT_NAMES as readonly string[]).includes(agent)) {
1158
- ctx.ui.notify(`Unknown builtin agent: ${agent}`, "error");
1150
+ ctx.ui.notify("Usage: /subagents-models [agent-name]", "error");
1159
1151
  return;
1160
1152
  }
1161
- await runCommand(ctx, { action: "models", agent });
1153
+ await runCommand(ctx, { action: "models", agent: parts[0]! });
1162
1154
  },
1163
1155
  });
1164
1156
 
package/src/tui/render.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type HostStepVerdict,
18
18
  type NestedRunSummary,
19
19
  type NestedStepSummary,
20
+ type WorkflowGraphNode,
20
21
  type WorkflowNodeStatus,
21
22
  type MainWindowRendererConfig,
22
23
  MAX_WIDGET_JOBS,
@@ -41,6 +42,15 @@ import { workflowGraphStageNodes } from "../runs/shared/workflow-graph.ts";
41
42
 
42
43
  type Theme = ExtensionContext["ui"]["theme"];
43
44
 
45
+ interface WorkflowWidgetProjection {
46
+ stages: WorkflowGraphNode[];
47
+ steps: AsyncJobStep[];
48
+ stageProgress?: { total: number; current?: number };
49
+ plannedKeys?: Set<string>;
50
+ }
51
+
52
+ type WorkflowWidgetProjectionLookup = (job: AsyncJobState) => WorkflowWidgetProjection;
53
+
44
54
  interface MainWindowRenderLayout {
45
55
  horizontalSpacing: number;
46
56
  compactResultMaxLines?: number;
@@ -194,10 +204,7 @@ function runningSeed(...values: Array<number | undefined>): number | undefined {
194
204
 
195
205
  function runningGlyph(seed?: number): string {
196
206
  if (seed === undefined) return STATIC_RUNNING_GLYPH;
197
- // Free-running wall-clock spinner: advance ~8 fps regardless of model
198
- // activity; keep the seed so parallel rows stay phase-offset.
199
- const clock = Math.floor(Date.now() / 125);
200
- return RUNNING_FRAMES[Math.abs(seed + clock) % RUNNING_FRAMES.length]!;
207
+ return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
201
208
  }
202
209
 
203
210
  function animatedSeed(seed: number | undefined, frame: number | undefined): number | undefined {
@@ -358,8 +365,7 @@ function workflowStepPriority(step: AsyncJobStep, currentNodeId?: string): numbe
358
365
  }
359
366
 
360
367
  /** Merge materialized child rows with the planned workflow stages for rendering. */
361
- function workflowWidgetSteps(job: AsyncJobState): AsyncJobStep[] {
362
- const planned = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
368
+ function workflowWidgetSteps(job: AsyncJobState, planned = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : []): AsyncJobStep[] {
363
369
  const loaded = job.steps ?? [];
364
370
  if (planned.length === 0) return loaded;
365
371
 
@@ -415,9 +421,8 @@ function workflowWidgetSteps(job: AsyncJobState): AsyncJobStep[] {
415
421
  return entries.map(({ step }) => step);
416
422
  }
417
423
 
418
- function workflowStageProgress(job: AsyncJobState): { total: number; current?: number } | undefined {
424
+ function workflowStageProgress(job: AsyncJobState, stages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : []): { total: number; current?: number } | undefined {
419
425
  if (job.mode !== "workflow") return undefined;
420
- const stages = workflowGraphStageNodes(job.workflowGraph);
421
426
  if (stages.length === 0) return undefined;
422
427
  const currentId = job.workflowGraph?.currentNodeId;
423
428
  const currentIndex = currentId ? stages.findIndex((stage) => stage.id === currentId) : -1;
@@ -430,8 +435,28 @@ function workflowStageProgress(job: AsyncJobState): { total: number; current?: n
430
435
  return { total: stages.length };
431
436
  }
432
437
 
433
- function laneStepForJob(job: AsyncJobState): AsyncJobStep | undefined {
434
- const steps = workflowWidgetSteps(job);
438
+ function buildWorkflowWidgetProjection(job: AsyncJobState): WorkflowWidgetProjection {
439
+ const stages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
440
+ const stageProgress = workflowStageProgress(job, stages);
441
+ const steps = workflowWidgetSteps(job, stages);
442
+ const projection: WorkflowWidgetProjection = { stages, steps };
443
+ if (stageProgress) projection.stageProgress = stageProgress;
444
+ if (stages.length) projection.plannedKeys = new Set(stages.map((node) => node.id));
445
+ return projection;
446
+ }
447
+
448
+ function workflowWidgetProjectionLookup(): WorkflowWidgetProjectionLookup {
449
+ const projections = new WeakMap<AsyncJobState, WorkflowWidgetProjection>();
450
+ return (job: AsyncJobState) => {
451
+ const cached = projections.get(job);
452
+ if (cached) return cached;
453
+ const projection = buildWorkflowWidgetProjection(job);
454
+ projections.set(job, projection);
455
+ return projection;
456
+ };
457
+ }
458
+
459
+ function laneStepForJob(job: AsyncJobState, steps = workflowWidgetSteps(job)): AsyncJobStep | undefined {
435
460
  if (steps.length === 0) return undefined;
436
461
  const graphCurrent = job.workflowGraph?.currentNodeId;
437
462
  if (graphCurrent) {
@@ -487,7 +512,8 @@ function isTerminalLaneState(state: AsyncJobState["status"]): boolean {
487
512
  }
488
513
 
489
514
  /** Project already-loaded async status facts into one bounded, render-only lane row. */
490
- export function projectAsyncLane(job: AsyncJobState, selectedStep = laneStepForJob(job)): AsyncLaneProjection | undefined {
515
+ export function projectAsyncLane(job: AsyncJobState, ...args: [selectedStep?: AsyncJobStep]): AsyncLaneProjection | undefined {
516
+ const selectedStep = args.length === 0 ? laneStepForJob(job) : args[0];
491
517
  const trace = laneTraceForJob(job);
492
518
  const workspace = job.cwd ? boundedLaneValue(shortenPath(job.cwd)) : undefined;
493
519
  const label = compactTaskText(selectedStep?.description, selectedStep?.label)
@@ -553,14 +579,16 @@ function formatLaneProjectionLines(lane: AsyncLaneProjection, theme: Theme, inde
553
579
  ];
554
580
  }
555
581
 
556
- function laneRenderKey(job: AsyncJobState): unknown {
557
- const lane = projectAsyncLane(job);
582
+ function laneRenderKey(job: AsyncJobState, projection?: WorkflowWidgetProjection): unknown {
583
+ const selectedStep = projection ? laneStepForJob(job, projection.steps) : laneStepForJob(job);
584
+ const lane = projectAsyncLane(job, selectedStep);
558
585
  return lane ? [lane.label, lane.role, lane.phase, lane.state, lane.gate, lane.next, lane.output, lane.workspace, lane.ref, lane.chips] : undefined;
559
586
  }
560
587
 
561
- function widgetLaneDetailLines(job: AsyncJobState, theme: Theme): string[] {
588
+ function widgetLaneDetailLines(job: AsyncJobState, theme: Theme, projection?: WorkflowWidgetProjection): string[] {
562
589
  if (job.steps?.length && (job.mode === "parallel" || job.mode === "chain")) return [];
563
- const lane = projectAsyncLane(job);
590
+ const selectedStep = projection ? laneStepForJob(job, projection.steps) : laneStepForJob(job);
591
+ const lane = projectAsyncLane(job, selectedStep);
564
592
  return lane ? formatLaneProjectionLines(lane, theme, " ") : [];
565
593
  }
566
594
 
@@ -1008,6 +1036,7 @@ function hostStepRenderKey(row: AsyncStatusWorkflowRow): unknown[] {
1008
1036
  }
1009
1037
 
1010
1038
  export function widgetRenderKey(job: AsyncJobState, expanded = false): string {
1039
+ const projection = buildWorkflowWidgetProjection(job);
1011
1040
  return JSON.stringify({
1012
1041
  asyncDir: job.asyncDir,
1013
1042
  status: job.status,
@@ -1026,13 +1055,13 @@ export function widgetRenderKey(job: AsyncJobState, expanded = false): string {
1026
1055
  workflowHostSteps: projectAsyncWorkflowRows([], job.hostSteps).map(hostStepRenderKey),
1027
1056
  workflowGraph: job.mode === "workflow" && job.workflowGraph ? {
1028
1057
  currentNodeId: job.workflowGraph.currentNodeId,
1029
- stages: workflowGraphStageNodes(job.workflowGraph).map((node) => [node.id, node.status, node.agent, node.phase, node.label, node.flatIndex, node.outputName, node.structured, node.error]),
1058
+ stages: projection.stages.map((node) => [node.id, node.status, node.agent, node.phase, node.label, node.flatIndex, node.outputName, node.structured, node.error]),
1030
1059
  } : undefined,
1031
1060
  preflight: expanded ? job.preflight : job.preflight ? formatWorkflowPreflightPlanSummary(job.preflight) : undefined,
1032
1061
  preflightWarnings: expanded ? job.workflow?.preflightWarnings : job.workflow?.preflightWarnings?.length || undefined,
1033
1062
  steps: job.steps?.map((step, index) => widgetStepRenderKey(step, index, expanded)),
1034
1063
  nestedChildren: nestedRenderKey(job.nestedChildren, expanded),
1035
- lane: laneRenderKey(job),
1064
+ lane: laneRenderKey(job, projection),
1036
1065
  stepsTotal: job.stepsTotal,
1037
1066
  runningSteps: job.runningSteps,
1038
1067
  completedSteps: job.completedSteps,
@@ -1696,14 +1725,13 @@ function buildForegroundResultEntries(
1696
1725
  return label.itemTitle === "Agent" ? withDuplicateForegroundLabels(entries, label.totalCount) : entries;
1697
1726
  }
1698
1727
 
1699
- function widgetStats(job: AsyncJobState, theme: Theme): string {
1728
+ function widgetStats(job: AsyncJobState, theme: Theme, projection = buildWorkflowWidgetProjection(job)): string {
1700
1729
  const parts: string[] = [];
1701
- const stageProgress = workflowStageProgress(job);
1730
+ const { stageProgress } = projection;
1702
1731
  const stepsTotal = stageProgress?.total ?? job.stepsTotal ?? (job.agents?.length ?? 1);
1703
1732
  const isSingleChild = isSingleChildAsyncJob(job);
1704
1733
  if (stageProgress) {
1705
- const stages = workflowGraphStageNodes(job.workflowGraph);
1706
- const currentStage = stageProgress.current !== undefined ? stages[stageProgress.current] : undefined;
1734
+ const currentStage = stageProgress.current !== undefined ? projection.stages[stageProgress.current] : undefined;
1707
1735
  const focus = currentStage
1708
1736
  ? [compactTaskText(undefined, currentStage.label) ?? boundedLaneValue(currentStage.id), currentStage.agent ? boundedLaneValue(currentStage.agent) : "", workflowNodeStatusLabel(currentStage.status)].filter(Boolean)
1709
1737
  : [];
@@ -2014,10 +2042,10 @@ function hostStepWidgetLines(job: AsyncJobState, theme: Theme, indent: string):
2014
2042
  return lines;
2015
2043
  }
2016
2044
 
2017
- function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number, frame?: number): string[] {
2018
- const steps = workflowWidgetSteps(job);
2045
+ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number, frame?: number, projection = buildWorkflowWidgetProjection(job)): string[] {
2046
+ const { steps } = projection;
2019
2047
  if (!steps.length) {
2020
- const lane = projectAsyncLane(job);
2048
+ const lane = projectAsyncLane(job, laneStepForJob(job, steps));
2021
2049
  return [
2022
2050
  ...workflowPreflightLines(job, expanded),
2023
2051
  ...(lane ? formatLaneProjectionLines(lane, theme, " ") : []),
@@ -2032,12 +2060,11 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
2032
2060
  if (group) {
2033
2061
  lines.push(...parallelWidgetGroupDetails(job, theme, group, expanded, width, frame, Boolean(job.activeParallelGroup)));
2034
2062
  } else {
2035
- const stageProgress = workflowStageProgress(job);
2063
+ const { stageProgress, plannedKeys } = projection;
2036
2064
  const total = job.mode === "chain"
2037
2065
  ? job.chainStepCount ?? job.stepsTotal ?? steps.length
2038
2066
  : stageProgress?.total ?? job.stepsTotal ?? steps.length;
2039
2067
  const stageTotal = stageProgress?.total ?? total;
2040
- const plannedKeys = stageProgress ? new Set(workflowGraphStageNodes(job.workflowGraph).map((node) => node.id)) : undefined;
2041
2068
  const extraStepCount = plannedKeys
2042
2069
  ? steps.filter((step) => step.workflowKey === undefined || !plannedKeys.has(step.workflowKey)).length
2043
2070
  : total;
@@ -2059,9 +2086,9 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
2059
2086
  return lines;
2060
2087
  }
2061
2088
 
2062
- function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
2063
- const stats = widgetStats(job, theme);
2064
- const count = job.mode === "chain" ? job.chainStepCount : workflowStageProgress(job)?.total ?? job.stepsTotal ?? job.agents?.length ?? job.steps?.length;
2089
+ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean, frame?: number, projection = buildWorkflowWidgetProjection(job)): string[] {
2090
+ const stats = widgetStats(job, theme, projection);
2091
+ const count = job.mode === "chain" ? job.chainStepCount : projection.stageProgress?.total ?? job.stepsTotal ?? job.agents?.length ?? job.steps?.length;
2065
2092
  const mode = widgetJobName(job);
2066
2093
  const title = isSingleChildAsyncJob(job)
2067
2094
  ? "async subagent"
@@ -2071,12 +2098,12 @@ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number,
2071
2098
  return [
2072
2099
  `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`,
2073
2100
  ...(collapseDetails ? [] : [summary]),
2074
- ...foregroundStyleWidgetDetails(job, theme, expanded, width, frame),
2101
+ ...foregroundStyleWidgetDetails(job, theme, expanded, width, frame, projection),
2075
2102
  ].map((line) => truncLine(line, width));
2076
2103
  }
2077
2104
 
2078
- function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, frame?: number): string[] {
2079
- const fullLines = buildSingleWidgetLines(job, theme, width, false, frame);
2105
+ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, frame?: number, projection = buildWorkflowWidgetProjection(job)): string[] {
2106
+ const fullLines = buildSingleWidgetLines(job, theme, width, false, frame, projection);
2080
2107
  if (fullLines.length <= 10 || !job.steps?.length || (job.mode !== "parallel" && !job.activeParallelGroup)) return fullLines;
2081
2108
 
2082
2109
  const group = activeParallelWidgetGroup(job);
@@ -2232,11 +2259,11 @@ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: numbe
2232
2259
  return truncLine(`${theme.fg(hasActive ? "accent" : "dim", glyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(", ") || `${jobs.length} total`)}`, width);
2233
2260
  }
2234
2261
 
2235
- function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number, frame?: number): string {
2236
- const stats = widgetStats(job, theme);
2262
+ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number, frame?: number, projection = buildWorkflowWidgetProjection(job)): string {
2263
+ const stats = widgetStats(job, theme, projection);
2237
2264
  const activity = widgetActivity(job);
2238
2265
  const status = job.status === "complete" ? "done" : job.status;
2239
- const lane = projectAsyncLane(job);
2266
+ const lane = projectAsyncLane(job, laneStepForJob(job, projection.steps));
2240
2267
  const laneSummary = lane
2241
2268
  ? [lane.label ?? lane.role, lane.phase ? `phase:${lane.phase}` : undefined, lane.output ? `out:${lane.output}` : undefined, lane.workspace ? `workspace:${lane.workspace}` : `ref:${lane.ref}`].filter(Boolean).join(" · ")
2242
2269
  : "";
@@ -2264,7 +2291,7 @@ function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width:
2264
2291
  return truncLine(theme.fg("dim", ` +${hiddenJobs.length} more${parts.length ? ` (${parts.join(", ")})` : ""}`), width);
2265
2292
  }
2266
2293
 
2267
- function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[], frame?: number): { lines: string[]; visibleJobKeys: string[] } {
2294
+ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[], frame?: number, projectionFor: WorkflowWidgetProjectionLookup = workflowWidgetProjectionLookup()): { lines: string[]; visibleJobKeys: string[] } {
2268
2295
  const rowCount = Math.max(1, lockedRows);
2269
2296
  if (rowCount === 1) return { lines: buildSingleLineWidgetLines(jobs, theme, width, frame), visibleJobKeys: [] };
2270
2297
 
@@ -2283,7 +2310,7 @@ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width:
2283
2310
 
2284
2311
  const lines = [
2285
2312
  progressiveHeaderLine(jobs, theme, width, frame),
2286
- ...visibleJobs.map((job) => progressiveJobLine(job, theme, width, frame)),
2313
+ ...visibleJobs.map((job) => progressiveJobLine(job, theme, width, frame, projectionFor(job))),
2287
2314
  ];
2288
2315
  if (hiddenJobs.length > 0 && lines.length < rowCount) lines.push(progressiveHiddenLine(hiddenJobs, theme, width));
2289
2316
  while (lines.length < rowCount) lines.push(" ");
@@ -2314,7 +2341,7 @@ function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expan
2314
2341
  return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)];
2315
2342
  }
2316
2343
 
2317
- function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[], theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
2344
+ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[], theme: Theme, width: number, expanded: boolean, frame?: number, projectionFor?: WorkflowWidgetProjectionLookup): string[] {
2318
2345
  if (expanded) {
2319
2346
  resetWidgetLayoutSession();
2320
2347
  return fitWidgetLineBudget(buildLines(), theme, width, true);
@@ -2330,7 +2357,7 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[
2330
2357
  }
2331
2358
 
2332
2359
  if (hasMatchingSession && widgetLayoutSession?.tier === "progressive" && widgetLayoutSession.lockedRows !== undefined) {
2333
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys, frame);
2360
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys, frame, projectionFor);
2334
2361
  widgetLayoutSession.visibleJobKeys = rendered.visibleJobKeys;
2335
2362
  return rendered.lines;
2336
2363
  }
@@ -2340,7 +2367,7 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[
2340
2367
  widgetLayoutSession = { expanded, rows, columns, tier: "full", visibleJobKeys: [] };
2341
2368
  return fitWidgetLineBudget(lines, theme, width, false);
2342
2369
  }
2343
- if (availableRows > 2 && jobs.length === 1 && workflowStageProgress(jobs[0]!)) {
2370
+ if (availableRows > 2 && jobs.length === 1 && projectionFor?.(jobs[0]!).stageProgress) {
2344
2371
  widgetLayoutSession = { expanded, rows, columns, tier: "full", visibleJobKeys: [] };
2345
2372
  return fitWidgetLineBudget(lines, theme, width, false);
2346
2373
  }
@@ -2351,31 +2378,42 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], buildLines: () => string[
2351
2378
  }
2352
2379
 
2353
2380
  const lockedRows = Math.min(availableRows, collapsedWidgetLineBudget(rows));
2354
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, [], frame);
2381
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, [], frame, projectionFor);
2355
2382
  widgetLayoutSession = { expanded, rows, columns, tier: "progressive", lockedRows, visibleJobKeys: rendered.visibleJobKeys };
2356
2383
  return rendered.lines;
2357
2384
  }
2358
2385
 
2359
- function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui: unknown, theme: Theme) => Component {
2386
+ function buildWidgetComponent(jobs: AsyncJobState[], isExpanded: () => boolean): (_tui: unknown, theme: Theme) => Component {
2360
2387
  return (_tui, theme) => {
2361
2388
  const container = new Container();
2389
+ let cachedRenderWidth: number | undefined;
2390
+ let cachedFrame: number | undefined;
2391
+ let cachedExpanded: boolean | undefined;
2392
+ let cachedLines: string[] | undefined;
2362
2393
  container.render = (renderWidth: number): string[] => {
2363
- const width = Math.max(0, renderWidth - 2);
2364
2394
  const frame = Math.floor(Date.now() / WIDGET_ANIMATION_INTERVAL_MS);
2395
+ const expanded = isExpanded();
2396
+ if (cachedLines && cachedRenderWidth === renderWidth && cachedFrame === frame && cachedExpanded === expanded) return cachedLines;
2397
+ const width = Math.max(0, renderWidth - 2);
2398
+ const projectionFor = workflowWidgetProjectionLookup();
2365
2399
  const buildLines = (): string[] => expanded
2366
- ? buildWidgetLines(jobs, theme, width, true, frame)
2400
+ ? buildWidgetLinesWithProjection(jobs, theme, width, true, frame, projectionFor)
2367
2401
  : jobs.length === 1
2368
- ? compactSingleWidgetLines(jobs[0]!, theme, width, frame)
2369
- : buildWidgetLines(jobs, theme, width, false, frame);
2370
- return fitAdaptiveWidgetLines(jobs, buildLines, theme, width, expanded, frame).map((line) => paddedWidgetLine(line, renderWidth));
2402
+ ? compactSingleWidgetLines(jobs[0]!, theme, width, frame, projectionFor(jobs[0]!))
2403
+ : buildWidgetLinesWithProjection(jobs, theme, width, false, frame, projectionFor);
2404
+ cachedRenderWidth = renderWidth;
2405
+ cachedFrame = frame;
2406
+ cachedExpanded = expanded;
2407
+ cachedLines = fitAdaptiveWidgetLines(jobs, buildLines, theme, width, expanded, frame, projectionFor).map((line) => paddedWidgetLine(line, renderWidth));
2408
+ return cachedLines;
2371
2409
  };
2372
2410
  return container;
2373
2411
  };
2374
2412
  }
2375
2413
 
2376
- export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false, frame?: number): string[] {
2414
+ function buildWidgetLinesWithProjection(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false, frame?: number, projectionFor: WorkflowWidgetProjectionLookup = workflowWidgetProjectionLookup()): string[] {
2377
2415
  if (jobs.length === 0) return [];
2378
- if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded, frame);
2416
+ if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded, frame, projectionFor(jobs[0]!));
2379
2417
  const running = jobs.filter((job) => job.status === "running");
2380
2418
  const queued = jobs.filter((job) => job.status === "queued");
2381
2419
  const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
@@ -2391,11 +2429,12 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
2391
2429
  let queuedSummaryShown = false;
2392
2430
  let slots = MAX_WIDGET_JOBS;
2393
2431
  const appendJob = (job: AsyncJobState): void => {
2394
- const stats = widgetStats(job, theme);
2432
+ const projection = projectionFor(job);
2433
+ const stats = widgetStats(job, theme, projection);
2395
2434
  items.push([
2396
2435
  `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
2397
2436
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
2398
- ...widgetLaneDetailLines(job, theme),
2437
+ ...widgetLaneDetailLines(job, theme, projection),
2399
2438
  ...widgetParallelAgentDetails(job, theme, expanded, width, frame),
2400
2439
  ]);
2401
2440
  };
@@ -2442,6 +2481,10 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
2442
2481
  return lines;
2443
2482
  }
2444
2483
 
2484
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false, frame?: number): string[] {
2485
+ return buildWidgetLinesWithProjection(jobs, theme, width, expanded, frame);
2486
+ }
2487
+
2445
2488
  /**
2446
2489
  * Render the async jobs widget
2447
2490
  */
@@ -2456,7 +2499,7 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
2456
2499
  ctx.ui.setWidget(WIDGET_KEY, encodeAsyncStatusSnapshotWidget(jobs));
2457
2500
  return;
2458
2501
  }
2459
- ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false));
2502
+ ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, () => ctx.ui.getToolsExpanded?.() ?? false));
2460
2503
  }
2461
2504
 
2462
2505
  function renderSingleCompact(