pi-subagents 0.25.0 → 0.28.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 (40) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +175 -19
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/skills/pi-subagents/SKILL.md +60 -17
  7. package/src/agents/agent-management.ts +71 -15
  8. package/src/agents/agent-serializer.ts +13 -2
  9. package/src/agents/agents.ts +88 -17
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +2 -0
  12. package/src/extension/index.ts +5 -2
  13. package/src/extension/schemas.ts +132 -6
  14. package/src/intercom/result-intercom.ts +5 -0
  15. package/src/runs/background/async-execution.ts +88 -6
  16. package/src/runs/background/async-status.ts +11 -1
  17. package/src/runs/background/run-status.ts +10 -1
  18. package/src/runs/background/subagent-runner.ts +665 -39
  19. package/src/runs/foreground/chain-execution.ts +369 -118
  20. package/src/runs/foreground/execution.ts +392 -19
  21. package/src/runs/foreground/subagent-executor.ts +126 -3
  22. package/src/runs/shared/acceptance-contract.ts +318 -0
  23. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  24. package/src/runs/shared/acceptance-finalization.ts +173 -0
  25. package/src/runs/shared/acceptance-reports.ts +127 -0
  26. package/src/runs/shared/acceptance.ts +22 -0
  27. package/src/runs/shared/chain-outputs.ts +101 -0
  28. package/src/runs/shared/completion-guard.ts +26 -3
  29. package/src/runs/shared/dynamic-fanout.ts +293 -0
  30. package/src/runs/shared/parallel-utils.ts +33 -1
  31. package/src/runs/shared/pi-args.ts +11 -0
  32. package/src/runs/shared/structured-output.ts +77 -0
  33. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  34. package/src/runs/shared/workflow-graph.ts +210 -0
  35. package/src/shared/formatters.ts +2 -2
  36. package/src/shared/settings.ts +53 -4
  37. package/src/shared/types.ts +265 -1
  38. package/src/shared/utils.ts +7 -0
  39. package/src/slash/slash-commands.ts +41 -3
  40. package/src/tui/render.ts +178 -45
@@ -204,6 +204,13 @@ export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" |
204
204
  return result.finalOutput ?? getFinalOutput(result.messages ?? []);
205
205
  }
206
206
 
207
+ export function formatResourceLimitExceeded(input: { agent: string; kind: "maxExecutionTimeMs" | "maxTokens"; limit: number; observed?: number }): string {
208
+ if (input.kind === "maxExecutionTimeMs") {
209
+ return `Resource limit exceeded for ${input.agent}: maxExecutionTimeMs ${input.limit}ms.`;
210
+ }
211
+ return `Resource limit exceeded for ${input.agent}: maxTokens ${input.limit}${input.observed !== undefined ? ` (observed ${input.observed})` : ""}.`;
212
+ }
213
+
207
214
  /**
208
215
  * Extract display items (text and tool calls) from messages
209
216
  */
@@ -5,7 +5,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
5
5
  import { Key, matchesKey } from "@earendil-works/pi-tui";
6
6
  import { discoverAgents, discoverAgentsAll, type ChainConfig } from "../agents/agents.ts";
7
7
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
- import { isParallelStep, type ChainStep } from "../shared/settings.ts";
8
+ import { isDynamicParallelStep, isParallelStep, type ChainStep } from "../shared/settings.ts";
9
+ import { assertJsonSchemaObject } from "../runs/shared/structured-output.ts";
9
10
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
10
11
  import {
11
12
  applySlashUpdate,
@@ -20,6 +21,7 @@ import {
20
21
  SLASH_SUBAGENT_RESPONSE_EVENT,
21
22
  SLASH_SUBAGENT_STARTED_EVENT,
22
23
  SLASH_SUBAGENT_UPDATE_EVENT,
24
+ type JsonSchemaObject,
23
25
  type SingleResult,
24
26
  type SubagentState,
25
27
  } from "../shared/types.ts";
@@ -123,12 +125,48 @@ const makeChainCompletions = (state: SubagentState) => (prefix: string) => {
123
125
  .map((chain) => ({ value: chain.name, label: chain.name }));
124
126
  };
125
127
 
128
+ function loadSavedOutputSchema(chain: ChainConfig, stepAgent: string, outputSchema: unknown): JsonSchemaObject | undefined {
129
+ if (outputSchema === undefined) return undefined;
130
+ if (typeof outputSchema === "string") {
131
+ const schemaPath = path.isAbsolute(outputSchema)
132
+ ? outputSchema
133
+ : path.join(path.dirname(chain.filePath), outputSchema);
134
+ const parsed = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as unknown;
135
+ assertJsonSchemaObject(parsed, `outputSchema for chain '${chain.name}' step '${stepAgent}' (${schemaPath})`);
136
+ return parsed;
137
+ }
138
+ assertJsonSchemaObject(outputSchema, `outputSchema for chain '${chain.name}' step '${stepAgent}'`);
139
+ return outputSchema;
140
+ }
141
+
126
142
  const mapSavedChainSteps = (chain: ChainConfig, worktree = false): ChainStep[] => {
127
- return (chain.steps as Array<ChainStep & { skills?: string[] | false }>).map((step) => {
128
- if (isParallelStep(step)) return worktree ? { ...step, worktree: true } : { ...step };
143
+ return (chain.steps as unknown as Array<ChainStep & { skills?: string[] | false }>).map((step) => {
144
+ if (isParallelStep(step)) {
145
+ const parallel = step.parallel.map((task) => {
146
+ const { outputSchema: rawOutputSchema, ...rest } = task as typeof task & { outputSchema?: unknown };
147
+ const outputSchema = loadSavedOutputSchema(chain, task.agent, rawOutputSchema);
148
+ return { ...rest, ...(outputSchema ? { outputSchema } : {}) };
149
+ });
150
+ return { ...step, parallel, ...(worktree ? { worktree: true } : {}) };
151
+ }
152
+ if (isDynamicParallelStep(step)) {
153
+ const { outputSchema: rawOutputSchema, ...parallelRest } = step.parallel as typeof step.parallel & { outputSchema?: unknown };
154
+ const outputSchema = loadSavedOutputSchema(chain, step.parallel.agent, rawOutputSchema);
155
+ const collectSchema = loadSavedOutputSchema(chain, `${step.collect.as} collection`, step.collect.outputSchema);
156
+ return {
157
+ ...step,
158
+ parallel: { ...parallelRest, ...(outputSchema ? { outputSchema } : {}) },
159
+ collect: { ...step.collect, ...(collectSchema ? { outputSchema: collectSchema } : {}) },
160
+ };
161
+ }
162
+ const outputSchema = loadSavedOutputSchema(chain, step.agent, (step as { outputSchema?: unknown }).outputSchema);
129
163
  return {
130
164
  agent: step.agent,
131
165
  task: step.task || undefined,
166
+ ...(step.phase ? { phase: step.phase } : {}),
167
+ ...(step.label ? { label: step.label } : {}),
168
+ ...(step.as ? { as: step.as } : {}),
169
+ ...(outputSchema ? { outputSchema } : {}),
132
170
  output: step.output,
133
171
  outputMode: step.outputMode,
134
172
  reads: step.reads,
package/src/tui/render.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type Details,
15
15
  type NestedRunSummary,
16
16
  type NestedStepSummary,
17
+ type WorkflowNodeStatus,
17
18
  MAX_WIDGET_JOBS,
18
19
  WIDGET_KEY,
19
20
  } from "../shared/types.ts";
@@ -221,10 +222,22 @@ function firstOutputLine(text: string): string {
221
222
  return text.split("\n").find((line) => line.trim())?.trim() ?? "";
222
223
  }
223
224
 
225
+ function formatAcceptanceStatus(result: Details["results"][number]): string | undefined {
226
+ const acceptance = result.acceptance;
227
+ if (!acceptance?.status || acceptance.status === "not-required") return undefined;
228
+ const finalization = acceptance.finalization
229
+ ? ` · finalization: ${acceptance.finalization.status} after ${acceptance.finalization.turns.length}/${acceptance.finalization.maxTurns} turns`
230
+ : "";
231
+ return `acceptance: ${acceptance.status}${finalization}`;
232
+ }
233
+
224
234
  function resultStatusLine(result: Details["results"][number], output: string): string {
225
235
  if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
236
+ if (result.timedOut) return `Timed out${result.error ? `: ${result.error}` : ""}`;
226
237
  if (result.interrupted) return "Paused";
227
238
  if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
239
+ const acceptance = formatAcceptanceStatus(result);
240
+ if (acceptance) return `Done · ${acceptance}`;
228
241
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)";
229
242
  return "Done";
230
243
  }
@@ -232,6 +245,7 @@ function resultStatusLine(result: Details["results"][number], output: string): s
232
245
  function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", seed = progressRunningSeed(result.progress ?? result.progressSummary)): string {
233
246
  if (running) return theme.fg("accent", runningGlyph(seed));
234
247
  if (result.detached) return theme.fg("warning", "■");
248
+ if (result.timedOut) return theme.fg("error", "✗");
235
249
  if (result.interrupted) return theme.fg("warning", "■");
236
250
  if (result.exitCode !== 0) return theme.fg("error", "✗");
237
251
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
@@ -351,18 +365,19 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
351
365
  return theme.fg("error", "✗");
352
366
  }
353
367
 
354
- function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
368
+ function widgetStepGlyph(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme, seed?: number): string {
355
369
  if (status === "running") return theme.fg("accent", runningGlyph(seed));
356
370
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
357
- if (status === "failed") return theme.fg("error", "✗");
371
+ if (status === "failed" || status === "timed-out") return theme.fg("error", "✗");
358
372
  if (status === "paused") return theme.fg("warning", "■");
359
373
  return theme.fg("muted", "◦");
360
374
  }
361
375
 
362
- function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string {
376
+ function widgetStepStatus(status: AsyncJobStep["status"] | WorkflowNodeStatus, theme: Theme): string {
363
377
  if (status === "running") return theme.fg("accent", "running");
364
378
  if (status === "complete" || status === "completed") return theme.fg("success", "complete");
365
379
  if (status === "failed") return theme.fg("error", "failed");
380
+ if (status === "timed-out") return theme.fg("error", "timed out");
366
381
  if (status === "paused") return theme.fg("warning", "paused");
367
382
  return theme.fg("dim", status);
368
383
  }
@@ -427,26 +442,44 @@ function parseParallelGroupAgentCount(label: string | undefined): number | undef
427
442
  return inner.split("+").map((part) => part.trim()).filter(Boolean).length;
428
443
  }
429
444
 
430
- function isChainParallelGroupActive(details: Pick<Details, "mode" | "chainAgents" | "currentStepIndex">): boolean {
431
- if (details.mode !== "chain") return false;
432
- if (details.currentStepIndex === undefined) return false;
433
- const currentLabel = details.chainAgents?.[details.currentStepIndex];
434
- return parseParallelGroupAgentCount(currentLabel) !== undefined;
435
- }
436
-
437
445
  interface ChainStepSpan {
438
446
  stepIndex: number;
439
447
  start: number;
440
448
  count: number;
441
449
  isParallel: boolean;
442
- }
450
+ status?: WorkflowNodeStatus;
451
+ label?: string;
452
+ error?: string;
453
+ }
454
+
455
+ function buildChainStepSpans(details: Pick<Details, "chainAgents" | "workflowGraph">): ChainStepSpan[] {
456
+ if (details.workflowGraph?.nodes?.length) {
457
+ const spans: ChainStepSpan[] = [];
458
+ let flatCursor = 0;
459
+ for (const node of details.workflowGraph.nodes) {
460
+ if (node.stepIndex === undefined) continue;
461
+ if (node.kind === "parallel-group" || node.kind === "dynamic-parallel-group") {
462
+ const childFlatIndexes = (node.children ?? [])
463
+ .map((child) => child.flatIndex)
464
+ .filter((value): value is number => typeof value === "number");
465
+ const start = childFlatIndexes.length ? Math.min(...childFlatIndexes) : flatCursor;
466
+ const count = node.children?.length ?? 0;
467
+ spans.push({ stepIndex: node.stepIndex, start, count, isParallel: true, status: node.status, label: node.label, error: node.error });
468
+ flatCursor = Math.max(flatCursor, start + count);
469
+ continue;
470
+ }
471
+ const start = node.flatIndex ?? flatCursor;
472
+ spans.push({ stepIndex: node.stepIndex, start, count: 1, isParallel: false, status: node.status, label: node.label, error: node.error });
473
+ flatCursor = Math.max(flatCursor, start + 1);
474
+ }
475
+ if (spans.length) return spans.sort((left, right) => left.stepIndex - right.stepIndex);
476
+ }
443
477
 
444
- function buildChainStepSpans(chainAgents: string[] | undefined): ChainStepSpan[] {
445
- if (!chainAgents?.length) return [];
478
+ if (!details.chainAgents?.length) return [];
446
479
  const spans: ChainStepSpan[] = [];
447
480
  let start = 0;
448
- for (let stepIndex = 0; stepIndex < chainAgents.length; stepIndex++) {
449
- const label = chainAgents[stepIndex]!;
481
+ for (let stepIndex = 0; stepIndex < details.chainAgents.length; stepIndex++) {
482
+ const label = details.chainAgents[stepIndex]!;
450
483
  const parsedCount = parseParallelGroupAgentCount(label);
451
484
  const count = parsedCount ?? 1;
452
485
  spans.push({ stepIndex, start, count, isParallel: parsedCount !== undefined });
@@ -455,6 +488,12 @@ function buildChainStepSpans(chainAgents: string[] | undefined): ChainStepSpan[]
455
488
  return spans;
456
489
  }
457
490
 
491
+ function isChainParallelGroupActive(details: Pick<Details, "mode" | "chainAgents" | "currentStepIndex" | "workflowGraph">): boolean {
492
+ if (details.mode !== "chain") return false;
493
+ if (details.currentStepIndex === undefined) return false;
494
+ return buildChainStepSpans(details).some((span) => span.stepIndex === details.currentStepIndex && span.isParallel);
495
+ }
496
+
458
497
  function buildAsyncChainStepSpans(total: number, stepCount: number, parallelGroups: AsyncParallelGroupStatus[] = []): ChainStepSpan[] {
459
498
  const spans: ChainStepSpan[] = [];
460
499
  let flatIndex = 0;
@@ -475,10 +514,59 @@ function isDoneResult(result: Details["results"][number]): boolean {
475
514
  const status = result.progress?.status;
476
515
  if (status === "completed") return true;
477
516
  if (status === "running" || status === "pending") return false;
478
- if (result.interrupted || result.detached) return false;
517
+ if (result.interrupted || result.detached || result.timedOut) return false;
479
518
  return result.exitCode === 0;
480
519
  }
481
520
 
521
+ function workflowGraphHasStatus(details: Pick<Details, "workflowGraph">, statuses: WorkflowNodeStatus[]): boolean {
522
+ return details.workflowGraph?.nodes.some((node) => statuses.includes(node.status)) ?? false;
523
+ }
524
+
525
+ interface ChainRenderResultEntry {
526
+ kind: "result";
527
+ resultIndex: number;
528
+ rowNumber: number;
529
+ agentName: string;
530
+ }
531
+
532
+ interface ChainRenderPlaceholderEntry {
533
+ kind: "placeholder";
534
+ rowNumber: number;
535
+ stepLabel: string;
536
+ agentName: string;
537
+ status: WorkflowNodeStatus;
538
+ error?: string;
539
+ }
540
+
541
+ type ChainRenderEntry = ChainRenderResultEntry | ChainRenderPlaceholderEntry;
542
+
543
+ function buildChainRenderEntries(details: Details, label: MultiProgressLabel): ChainRenderEntry[] | undefined {
544
+ if (details.mode !== "chain" || !label.hasParallelInChain || label.showActiveGroupOnly) return undefined;
545
+ const entries: ChainRenderEntry[] = [];
546
+ for (const span of buildChainStepSpans(details)) {
547
+ if (span.isParallel && span.count === 0) {
548
+ entries.push({
549
+ kind: "placeholder",
550
+ rowNumber: span.stepIndex + 1,
551
+ stepLabel: `Step ${span.stepIndex + 1}`,
552
+ agentName: span.label ?? details.chainAgents?.[span.stepIndex] ?? `step-${span.stepIndex + 1}`,
553
+ status: span.status ?? "pending",
554
+ error: span.error,
555
+ });
556
+ continue;
557
+ }
558
+ for (let index = span.start; index < span.start + span.count; index++) {
559
+ entries.push({
560
+ kind: "result",
561
+ resultIndex: index,
562
+ rowNumber: index + 1,
563
+ agentName: details.results[index]?.agent ?? details.chainAgents?.[span.stepIndex] ?? `step-${span.stepIndex + 1}`,
564
+ });
565
+ }
566
+ }
567
+ return entries;
568
+ }
569
+
482
570
  interface MultiProgressLabel {
483
571
  headerLabel: string;
484
572
  itemTitle: "Step" | "Agent";
@@ -490,15 +578,15 @@ interface MultiProgressLabel {
490
578
  showActiveGroupOnly: boolean;
491
579
  }
492
580
 
493
- function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "progress" | "totalSteps" | "currentStepIndex" | "chainAgents">, hasRunning: boolean): MultiProgressLabel {
494
- const stepSpans = buildChainStepSpans(details.chainAgents);
581
+ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "progress" | "totalSteps" | "currentStepIndex" | "chainAgents" | "workflowGraph">, hasRunning: boolean): MultiProgressLabel {
582
+ const stepSpans = buildChainStepSpans(details);
495
583
  const hasParallelInChain = details.mode === "chain" && stepSpans.some((span) => span.isParallel);
496
584
  const activeParallelGroup = isChainParallelGroupActive(details);
497
585
  const itemTitle: "Step" | "Agent" = details.mode === "parallel" || activeParallelGroup ? "Agent" : "Step";
498
586
 
499
587
  if (details.mode === "parallel") {
500
588
  const totalCount = details.totalSteps ?? details.results.length;
501
- const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "detached">;
589
+ const statuses = new Array(totalCount).fill("pending") as WorkflowNodeStatus[];
502
590
  for (const progress of details.progress ?? []) {
503
591
  if (progress.index >= 0 && progress.index < totalCount) statuses[progress.index] = progress.status;
504
592
  }
@@ -509,11 +597,13 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
509
597
  const index = result.progress?.index ?? progressFromArray?.index ?? i;
510
598
  if (index < 0 || index >= totalCount) continue;
511
599
  const status = result.progress?.status
512
- ?? (result.interrupted || result.detached
513
- ? "detached"
514
- : result.exitCode === 0
515
- ? "completed"
516
- : "failed");
600
+ ?? (result.timedOut
601
+ ? "timed-out"
602
+ : result.interrupted || result.detached
603
+ ? "detached"
604
+ : result.exitCode === 0
605
+ ? "completed"
606
+ : "failed");
517
607
  statuses[index] = status;
518
608
  }
519
609
  const running = statuses.filter((status) => status === "running").length;
@@ -555,11 +645,13 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
555
645
  if (details.mode === "chain" && details.chainAgents?.length) {
556
646
  const totalCount = details.totalSteps ?? details.chainAgents.length;
557
647
  const doneLogical = stepSpans.filter((span) => {
648
+ if (span.status && span.status !== "completed") return false;
649
+ if (span.count === 0) return span.status === "completed";
558
650
  for (let index = span.start; index < span.start + span.count; index++) {
559
651
  const progressEntry = details.progress?.find((progress) => progress.index === index);
560
652
  const resultEntry = details.results.find((result) => result.progress?.index === index) ?? details.results[index];
561
- if (progressEntry?.status === "running" || progressEntry?.status === "pending") return false;
562
- if (resultEntry && !isDoneResult(resultEntry)) return false;
653
+ if (progressEntry?.status === "running" || progressEntry?.status === "pending" || progressEntry?.status === "failed") return false;
654
+ if (!resultEntry || !isDoneResult(resultEntry)) return false;
563
655
  }
564
656
  return true;
565
657
  }).length;
@@ -575,9 +667,9 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
575
667
  return { headerLabel, itemTitle, totalCount, hasParallelInChain, activeParallelGroup, groupStartIndex: 0, groupEndIndex: details.results.length, showActiveGroupOnly: false };
576
668
  }
577
669
 
578
- function resultRowLabel(details: Pick<Details, "mode" | "chainAgents">, label: MultiProgressLabel, resultIndex: number, stepNumber: number): string {
670
+ function resultRowLabel(details: Pick<Details, "mode" | "chainAgents" | "workflowGraph">, label: MultiProgressLabel, resultIndex: number, stepNumber: number): string {
579
671
  if (details.mode === "chain" && label.hasParallelInChain) {
580
- const span = buildChainStepSpans(details.chainAgents).find((candidate) => resultIndex >= candidate.start && resultIndex < candidate.start + candidate.count);
672
+ const span = buildChainStepSpans(details).find((candidate) => resultIndex >= candidate.start && resultIndex < candidate.start + candidate.count);
581
673
  if (span?.isParallel) return `Agent ${resultIndex - span.start + 1}/${span.count}`;
582
674
  if (span) return `Step ${span.stepIndex + 1}`;
583
675
  }
@@ -970,9 +1062,12 @@ function renderSingleCompact(d: Details, r: Details["results"][number], theme: T
970
1062
 
971
1063
  function renderMultiCompact(d: Details, theme: Theme): Component {
972
1064
  const hasRunning = d.progress?.some((p) => p.status === "running")
973
- || d.results.some((r) => r.progress?.status === "running");
974
- const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running");
975
- const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running");
1065
+ || d.results.some((r) => r.progress?.status === "running")
1066
+ || workflowGraphHasStatus(d, ["running"]);
1067
+ const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running")
1068
+ || workflowGraphHasStatus(d, ["failed", "timed-out"]);
1069
+ const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running")
1070
+ || workflowGraphHasStatus(d, ["paused", "detached"]);
976
1071
  let totalSummary = d.progressSummary;
977
1072
  if (!totalSummary) {
978
1073
  let sawProgress = false;
@@ -1005,13 +1100,29 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1005
1100
  const useResultsDirectly = multiLabel.hasParallelInChain || !d.chainAgents?.length;
1006
1101
  const displayStart = multiLabel.showActiveGroupOnly ? multiLabel.groupStartIndex : 0;
1007
1102
  const displayEnd = multiLabel.showActiveGroupOnly ? multiLabel.groupEndIndex : (useResultsDirectly ? d.results.length : d.chainAgents!.length);
1008
- for (let i = displayStart; i < displayEnd; i++) {
1103
+ const chainEntries = buildChainRenderEntries(d, multiLabel);
1104
+ const renderEntries = chainEntries ?? Array.from({ length: displayEnd - displayStart }, (_, offset): ChainRenderEntry => {
1105
+ const i = displayStart + offset;
1009
1106
  const r = d.results[i];
1010
1107
  const fallbackLabel = itemTitle.toLowerCase();
1011
1108
  const rowNumber = multiLabel.showActiveGroupOnly ? (i - multiLabel.groupStartIndex + 1) : (i + 1);
1012
- const agentName = useResultsDirectly ? (r?.agent || `${fallbackLabel}-${rowNumber}`) : (d.chainAgents![i] || r?.agent || `${fallbackLabel}-${rowNumber}`);
1109
+ return { kind: "result", resultIndex: i, rowNumber, agentName: useResultsDirectly ? (r?.agent || `${fallbackLabel}-${rowNumber}`) : (d.chainAgents![i] || r?.agent || `${fallbackLabel}-${rowNumber}`) };
1110
+ });
1111
+ for (const entry of renderEntries) {
1112
+ if (entry.kind === "placeholder") {
1113
+ const glyph = widgetStepGlyph(entry.status as AsyncJobStep["status"], theme);
1114
+ const statusLabel = widgetStepStatus(entry.status as AsyncJobStep["status"], theme);
1115
+ c.addChild(new Text(truncLine(` ${glyph} ${entry.stepLabel}: ${themeBold(theme, entry.agentName)} ${theme.fg("dim", "·")} ${statusLabel}`, width), 0, 0));
1116
+ if (entry.error) c.addChild(new Text(truncLine(theme.fg("error", ` ⎿ Error: ${entry.error}`), width), 0, 0));
1117
+ continue;
1118
+ }
1119
+ const i = entry.resultIndex;
1120
+ const r = d.results[i];
1121
+ const rowNumber = entry.rowNumber;
1122
+ const agentName = entry.agentName;
1013
1123
  if (!r) {
1014
- c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${itemTitle} ${rowNumber}: ${agentName} · pending`), width), 0, 0));
1124
+ const pendingLabel = chainEntries ? resultRowLabel(d, multiLabel, i, rowNumber) : `${itemTitle} ${rowNumber}`;
1125
+ c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${pendingLabel}: ${agentName} · pending`), width), 0, 0));
1015
1126
  continue;
1016
1127
  }
1017
1128
  const output = getSingleResultOutput(r);
@@ -1030,7 +1141,7 @@ function renderMultiCompact(d: Details, theme: Theme): Component {
1030
1141
  const activity = compactCurrentActivity(rProg);
1031
1142
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
1032
1143
  c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0));
1033
- } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1144
+ } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || r.timedOut || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1034
1145
  c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
1035
1146
  }
1036
1147
  const outputTarget = extractOutputTarget(r.task);
@@ -1159,20 +1270,27 @@ export function renderSubagentResult(
1159
1270
  if (!expanded) return renderMultiCompact(d, theme);
1160
1271
 
1161
1272
  const hasRunning = d.progress?.some((p) => p.status === "running")
1162
- || d.results.some((r) => r.progress?.status === "running");
1273
+ || d.results.some((r) => r.progress?.status === "running")
1274
+ || workflowGraphHasStatus(d, ["running"]);
1163
1275
  const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
1164
1276
  const hasEmptyWithoutTarget = d.results.some((r) =>
1165
1277
  r.exitCode === 0
1166
1278
  && r.progress?.status !== "running"
1167
1279
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
1168
1280
  );
1281
+ const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed", "timed-out"]);
1282
+ const hasWorkflowPause = workflowGraphHasStatus(d, ["paused", "detached"]);
1169
1283
  const icon = hasRunning
1170
1284
  ? theme.fg("warning", "running")
1171
1285
  : hasEmptyWithoutTarget
1172
1286
  ? theme.fg("warning", "warning")
1173
- : ok === d.results.length
1174
- ? theme.fg("success", "ok")
1175
- : theme.fg("error", "failed");
1287
+ : hasWorkflowFailure
1288
+ ? theme.fg("error", "failed")
1289
+ : hasWorkflowPause
1290
+ ? theme.fg("warning", "paused")
1291
+ : ok === d.results.length
1292
+ ? theme.fg("success", "ok")
1293
+ : theme.fg("error", "failed");
1176
1294
 
1177
1295
  const totalSummary =
1178
1296
  d.progressSummary ||
@@ -1243,18 +1361,33 @@ export function renderSubagentResult(
1243
1361
  const useResultsDirectly = multiLabel.hasParallelInChain || !d.chainAgents?.length;
1244
1362
  const displayStart = multiLabel.showActiveGroupOnly ? multiLabel.groupStartIndex : 0;
1245
1363
  const displayEnd = multiLabel.showActiveGroupOnly ? multiLabel.groupEndIndex : (useResultsDirectly ? d.results.length : d.chainAgents!.length);
1364
+ const chainEntries = buildChainRenderEntries(d, multiLabel);
1365
+ const renderEntries = chainEntries ?? Array.from({ length: displayEnd - displayStart }, (_, offset): ChainRenderEntry => {
1366
+ const i = displayStart + offset;
1367
+ const r = d.results[i];
1368
+ const rowNumber = multiLabel.showActiveGroupOnly ? (i - multiLabel.groupStartIndex + 1) : (i + 1);
1369
+ return { kind: "result", resultIndex: i, rowNumber, agentName: useResultsDirectly ? (r?.agent || `step-${rowNumber}`) : (d.chainAgents![i] || r?.agent || `step-${rowNumber}`) };
1370
+ });
1246
1371
 
1247
1372
  c.addChild(new Spacer(1));
1248
1373
 
1249
- for (let i = displayStart; i < displayEnd; i++) {
1374
+ for (const entry of renderEntries) {
1375
+ if (entry.kind === "placeholder") {
1376
+ const statusLabel = widgetStepStatus(entry.status as AsyncJobStep["status"], theme);
1377
+ c.addChild(new Text(fit(` ${statusLabel} ${entry.stepLabel}: ${theme.bold(entry.agentName)}`), 0, 0));
1378
+ c.addChild(new Text(theme.fg(entry.status === "failed" ? "error" : "dim", ` status: ${entry.status}`), 0, 0));
1379
+ if (entry.error) c.addChild(new Text(theme.fg("error", ` error: ${entry.error}`), 0, 0));
1380
+ c.addChild(new Spacer(1));
1381
+ continue;
1382
+ }
1383
+ const i = entry.resultIndex;
1250
1384
  const r = d.results[i];
1251
- const rowNumber = multiLabel.showActiveGroupOnly ? (i - multiLabel.groupStartIndex + 1) : (i + 1);
1252
- const agentName = useResultsDirectly
1253
- ? (r?.agent || `step-${rowNumber}`)
1254
- : (d.chainAgents![i] || r?.agent || `step-${rowNumber}`);
1385
+ const rowNumber = entry.rowNumber;
1386
+ const agentName = entry.agentName;
1255
1387
 
1256
1388
  if (!r) {
1257
- c.addChild(new Text(fit(theme.fg("dim", ` ${itemTitle} ${rowNumber}: ${agentName}`)), 0, 0));
1389
+ const pendingLabel = chainEntries ? resultRowLabel(d, multiLabel, i, rowNumber) : `${itemTitle} ${rowNumber}`;
1390
+ c.addChild(new Text(fit(theme.fg("dim", ` ${pendingLabel}: ${agentName}`)), 0, 0));
1258
1391
  c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
1259
1392
  c.addChild(new Spacer(1));
1260
1393
  continue;