pi-subagents 0.52.0 → 0.53.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 (54) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +4 -0
  3. package/docs/configuration.md +11 -1
  4. package/docs/extension-api.md +3 -1
  5. package/docs/workflows.md +2 -0
  6. package/package.json +2 -1
  7. package/prompts/council.md +48 -0
  8. package/skills/council-mode/SKILL.md +230 -0
  9. package/skills/pi-subagents/SKILL.md +2 -0
  10. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
  11. package/skills/pi-subagents/references/execution-controls.md +11 -0
  12. package/skills/pi-subagents/references/multi-lane-orchestration.md +39 -0
  13. package/src/agents/agent-management.ts +22 -3
  14. package/src/agents/agent-serializer.ts +2 -0
  15. package/src/agents/agents.ts +29 -13
  16. package/src/agents/builtin-names.ts +9 -0
  17. package/src/agents/runtime-agent-registry.ts +418 -0
  18. package/src/api/agents.ts +7 -0
  19. package/src/api/external-job-provider.ts +3 -2
  20. package/src/api/preflight.ts +1 -1
  21. package/src/extension/config.ts +3 -0
  22. package/src/extension/doctor.ts +1 -0
  23. package/src/extension/index.ts +17 -2
  24. package/src/extension/rpc.ts +41 -1
  25. package/src/extension/schemas.ts +7 -4
  26. package/src/extension/tool-description.ts +2 -2
  27. package/src/runs/background/async-execution.ts +2 -1
  28. package/src/runs/background/async-job-tracker.ts +4 -3
  29. package/src/runs/background/async-resume.ts +2 -1
  30. package/src/runs/background/async-status-snapshot.ts +14 -5
  31. package/src/runs/background/auto-drain.ts +1 -0
  32. package/src/runs/background/result-watcher.ts +8 -0
  33. package/src/runs/background/subagent-runner.ts +7 -4
  34. package/src/runs/background/subagent-wait.ts +9 -5
  35. package/src/runs/background/terminal-run-index.ts +15 -6
  36. package/src/runs/background/wait-tool.ts +1 -0
  37. package/src/runs/foreground/execution.ts +5 -1
  38. package/src/runs/foreground/subagent-executor.ts +182 -46
  39. package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
  40. package/src/runs/shared/acceptance.ts +44 -1
  41. package/src/runs/shared/model-exclusions.ts +242 -0
  42. package/src/runs/shared/model-fallback.ts +55 -2
  43. package/src/runs/shared/subagent-control.ts +25 -3
  44. package/src/shared/fork-context.ts +17 -1
  45. package/src/shared/model-info.ts +20 -0
  46. package/src/shared/settings.ts +2 -2
  47. package/src/shared/types.ts +35 -0
  48. package/src/slash/slash-commands.ts +20 -6
  49. package/src/slash/slash-live-state.ts +3 -3
  50. package/src/tui/fleet-status.ts +86 -1
  51. package/src/tui/fleet.ts +55 -2
  52. package/src/tui/render.ts +73 -3
  53. package/src/workflows/scripted-workflow.ts +100 -12
  54. package/src/workflows/workflow-receipt.ts +140 -0
@@ -282,7 +282,7 @@ export function resolveStepBehavior(
282
282
  }
283
283
  }
284
284
 
285
- const outputMode = stepOverrides.outputMode ?? "inline";
285
+ const outputMode = stepOverrides.outputMode ?? agentConfig.outputMode ?? "inline";
286
286
  const model = stepOverrides.model ?? agentConfig.model;
287
287
  return { output, outputMode, reads, progress, skills, model };
288
288
  }
@@ -465,7 +465,7 @@ export function resolveParallelBehaviors(
465
465
  }
466
466
  }
467
467
 
468
- const outputMode = task.outputMode ?? "inline";
468
+ const outputMode = task.outputMode ?? config.outputMode ?? "inline";
469
469
  const model = task.model ?? config.model;
470
470
  return { output, outputMode, reads, progress, skills, model };
471
471
  });
@@ -71,6 +71,29 @@ export interface WorkflowGraphSnapshot {
71
71
  currentNodeId?: string;
72
72
  }
73
73
 
74
+ export type WorkflowReceiptState = "complete" | "failed" | "paused" | "stopped";
75
+
76
+ type WorkflowReceiptEntryResumability =
77
+ | { latestRunId: string; resumability: { state: "resumable" } }
78
+ | { latestRunId?: string; resumability: { state: "not-resumable"; reason: string } };
79
+
80
+ export type WorkflowReceiptEntry = WorkflowReceiptEntryResumability & {
81
+ key: string;
82
+ agent?: string;
83
+ requestedContext?: "fresh" | "fork";
84
+ resolvedContext?: "fresh" | "fork" | "mixed";
85
+ outputReference?: string;
86
+ continuation: { runIds: string[] };
87
+ };
88
+
89
+ export interface WorkflowReceipt {
90
+ version: 1;
91
+ workflowRunId: string;
92
+ state: WorkflowReceiptState;
93
+ createdAt: number;
94
+ entries: Record<string, WorkflowReceiptEntry>;
95
+ }
96
+
74
97
  export interface SavedOutputReference {
75
98
  path: string;
76
99
  bytes: number;
@@ -161,6 +184,7 @@ export interface ControlConfig {
161
184
  export interface ResolvedControlConfig {
162
185
  enabled: boolean;
163
186
  needsAttentionAfterMs: number;
187
+ needsAttentionAfterMsIsExplicit?: boolean;
164
188
  activeNoticeAfterMs: number;
165
189
  activeNoticeAfterTurns?: number;
166
190
  activeNoticeAfterTokens?: number;
@@ -529,6 +553,8 @@ export interface SteeringRecoveryDescriptor {
529
553
  structuredOutputSchema?: JsonSchemaObject;
530
554
  acceptance?: AcceptanceInput;
531
555
  controlConfig?: ResolvedControlConfig;
556
+ /** Resolved launch context for this async child. */
557
+ context?: "fresh" | "fork";
532
558
  /** Raw per-run bridge override. Omitted descriptors continue to use global config. */
533
559
  intercomBridge?: IntercomBridgeConfig;
534
560
  absoluteDeadlineAt?: number;
@@ -1099,6 +1125,8 @@ export interface Details {
1099
1125
  }>;
1100
1126
  emits: unknown[];
1101
1127
  console: Array<{ level: "log" | "info" | "warn" | "error"; text: string }>;
1128
+ /** Terminal keyed child receipt. Async workflows also persist it beside status.json. */
1129
+ receipt?: WorkflowReceipt;
1102
1130
  };
1103
1131
  chatProgress?: {
1104
1132
  mode: "off" | "live-card";
@@ -2026,6 +2054,13 @@ export interface ExtensionConfig {
2026
2054
  /** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
2027
2055
  artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
2028
2056
  intercomBridge?: IntercomBridgeConfig;
2057
+ /** Control how slow result-index scans are logged. Defaults to \"all\".
2058
+ * - \"all\": log every slow scan, including scans that find nothing.
2059
+ * - \"activity\": log only slow scans that found or scheduled work. Silences
2060
+ * the periodic healthy rescan that inspects zero files while no async runs
2061
+ * are pending, which otherwise spams the session transcript.
2062
+ * - \"off\": never log slow result-index scans. */
2063
+ resultScanLogging?: "all" | "activity" | "off";
2029
2064
  proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
2030
2065
  scheduledRuns?: ScheduledRunsConfig;
2031
2066
  /** Durable mission behavior. Missions are automatic by default; set enabled:false to disable auto-create. Explicit mission actions/fields still work. */
@@ -3,7 +3,8 @@ 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, findBlockingAgentDiagnostic, resolveAgentName } from "../agents/agents.ts";
6
+ import { BUILTIN_AGENT_NAMES, discoverAgents, discoverAgentsAll, findBlockingAgentDiagnostic, resolveAgentName, type AgentConfig, type AgentDiscoveryDiagnostic, type AgentScope } from "../agents/agents.ts";
7
+ import { listRuntimeAgentConfigs, mergeRuntimeAgents } from "../agents/runtime-agent-registry.ts";
7
8
  import { resolveExistingReadPaths } from "../shared/settings.ts";
8
9
  import {
9
10
  DEFAULT_PROVIDER_MODELS_MAX_AGE_DAYS,
@@ -107,9 +108,22 @@ const extractExecutionFlags = (rawArgs: string): { args: string; bg: boolean; fo
107
108
  return { args, bg, fork };
108
109
  };
109
110
 
110
- const makeAgentCompletions = (state: SubagentState) => (prefix: string) => {
111
+ function discoverSlashAgents(pi: ExtensionAPI, cwd: string, scope: AgentScope): { agents: AgentConfig[]; agentDiagnostics?: AgentDiscoveryDiagnostic[] } {
112
+ const discovered = discoverAgents(cwd, scope);
113
+ if (listRuntimeAgentConfigs(pi).length === 0) return discovered;
114
+ const all = discoverAgentsAll(cwd);
115
+ const configuredAgents: AgentConfig[] = [
116
+ ...all.builtin,
117
+ ...all.package,
118
+ ...(scope !== "project" ? all.user : []),
119
+ ...(scope !== "user" ? all.project : []),
120
+ ];
121
+ return mergeRuntimeAgents(pi, discovered, configuredAgents);
122
+ }
123
+
124
+ const makeAgentCompletions = (pi: ExtensionAPI, state: SubagentState) => (prefix: string) => {
111
125
  if (!state.baseCwd || prefix.includes(" ")) return null;
112
- return discoverAgents(state.baseCwd, "both").agents
126
+ return discoverSlashAgents(pi, state.baseCwd, "both").agents
113
127
  .filter((agent) => agent.name.startsWith(prefix))
114
128
  .map((agent) => ({ value: agent.name, label: agent.name }));
115
129
  };
@@ -662,7 +676,7 @@ export function registerSlashCommands(
662
676
 
663
677
  pi.registerCommand("run", {
664
678
  description: "Run one subagent through workflowScript: /run agent[output=file] [task] [--bg] [--fork]",
665
- getArgumentCompletions: makeAgentCompletions(state),
679
+ getArgumentCompletions: makeAgentCompletions(pi, state),
666
680
  handler: async (args, ctx) => {
667
681
  const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
668
682
  const input = cleanedArgs.trim();
@@ -672,7 +686,7 @@ export function registerSlashCommands(
672
686
  const task = firstSpace === -1 ? "" : input.slice(firstSpace + 1).trim();
673
687
 
674
688
  if (!state.baseCwd) { ctx.ui.notify("Subagent session cwd is not initialized yet", "error"); return; }
675
- const discovered = discoverAgents(state.baseCwd, "both");
689
+ const discovered = discoverSlashAgents(pi, state.baseCwd, "both");
676
690
  const resolvedAgent = resolveAgentName(agentName, discovered.agents);
677
691
  const candidates = resolvedAgent.error
678
692
  ? discovered.agents.filter((agent) => resolveAgentName(agentName, [agent]).agent)
@@ -746,7 +760,7 @@ export function registerSlashCommands(
746
760
 
747
761
  pi.registerCommand("subagents-refine", {
748
762
  description: "Generate a bounded project-local refinement overlay for one subagent",
749
- getArgumentCompletions: makeAgentCompletions(state),
763
+ getArgumentCompletions: makeAgentCompletions(pi, state),
750
764
  handler: async (args, ctx) => {
751
765
  const parts = args.trim().split(/\s+/).filter(Boolean);
752
766
  if (parts.length !== 1) {
@@ -82,7 +82,7 @@ function buildParallelInitialResult(params: SubagentParamsLike): AgentToolResult
82
82
  details: {
83
83
  mode: "parallel",
84
84
  ...(params.async ? { background: true } : {}),
85
- ...(params.context ? { context: params.context } : {}),
85
+ ...(params.context === "fresh" || params.context === "fork" ? { context: params.context } : {}),
86
86
  results: tasks.map((task, index) => createPlaceholderResult(task.agent, task.task, "running", index)),
87
87
  progress: tasks.map((task, index) => ({
88
88
  index,
@@ -138,7 +138,7 @@ function buildChainInitialResult(params: SubagentParamsLike): AgentToolResult<De
138
138
  details: {
139
139
  mode: "chain",
140
140
  ...(params.async ? { background: true } : {}),
141
- ...(params.context ? { context: params.context } : {}),
141
+ ...(params.context === "fresh" || params.context === "fork" ? { context: params.context } : {}),
142
142
  results,
143
143
  progress: results.map((result, index) => ({
144
144
  index,
@@ -167,7 +167,7 @@ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<D
167
167
  details: {
168
168
  mode: "single",
169
169
  ...(params.async ? { background: true } : {}),
170
- ...(params.context ? { context: params.context } : {}),
170
+ ...(params.context === "fresh" || params.context === "fork" ? { context: params.context } : {}),
171
171
  results: [createPlaceholderResult(agent, task, "running", 0)],
172
172
  progress: [{
173
173
  index: 0,
@@ -27,6 +27,17 @@ type FleetStatusEntry = {
27
27
  state: string;
28
28
  external?: true;
29
29
  nestedChildren?: NestedRunSummary[];
30
+ workflowRows?: FleetWorkflowRow[];
31
+ };
32
+
33
+ type FleetWorkflowRow = {
34
+ name: string;
35
+ state: AsyncJobStep["status"];
36
+ modelThinking?: string;
37
+ activity?: string;
38
+ startedAt?: number;
39
+ tokens?: number;
40
+ overflow?: number;
30
41
  };
31
42
 
32
43
  type FleetNestedRow = {
@@ -42,6 +53,7 @@ type FleetNestedRow = {
42
53
  type FleetTreeRow =
43
54
  | { kind: "owner"; entry: FleetStatusEntry }
44
55
  | { kind: "child"; entry: FleetStatusEntry; last: boolean }
56
+ | { kind: "workflow"; ownerKey: string; row: FleetWorkflowRow; last: boolean }
45
57
  | { kind: "nested"; ownerKey: string; row: FleetNestedRow; last: boolean };
46
58
 
47
59
  export interface FleetStatusOptions {
@@ -92,6 +104,51 @@ function nestedActivity(node: NestedRunSummary | NestedStepSummary): string | un
92
104
  return undefined;
93
105
  }
94
106
 
107
+ function workflowStepActivity(step: AsyncJobStep): string | undefined {
108
+ if (step.currentTool) return `tool ${step.currentTool}`;
109
+ if (step.currentPath) return step.currentPath.split(/[\\/]/).at(-1);
110
+ if (step.activityState === "needs_attention") return "needs attention";
111
+ if (step.activityState === "active_long_running") return "long-running";
112
+ if (step.turnCount !== undefined) return `${step.turnCount} turns`;
113
+ if (step.toolCount !== undefined) return `${step.toolCount} tools`;
114
+ return undefined;
115
+ }
116
+
117
+ function workflowStepName(step: AsyncJobStep, index: number): string {
118
+ const key = step.workflowKey ?? `step ${index + 1}`;
119
+ const label = step.label && step.label !== key ? ` · ${step.label}` : "";
120
+ const phase = step.phase ? `${step.phase}: ` : "";
121
+ return `${phase}${key}${label} (${step.agent})`;
122
+ }
123
+
124
+ function workflowFleetRows(steps: AsyncJobStep[] | undefined): FleetWorkflowRow[] {
125
+ return (steps ?? []).map((step, index) => {
126
+ const modelThinking = formatModelThinking(step.model, step.thinking) || undefined;
127
+ const activity = workflowStepActivity(step);
128
+ return {
129
+ name: workflowStepName(step, index),
130
+ state: step.status,
131
+ ...(modelThinking ? { modelThinking } : {}),
132
+ ...(activity ? { activity } : {}),
133
+ ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
134
+ ...(step.tokens?.total !== undefined ? { tokens: step.tokens.total } : {}),
135
+ };
136
+ });
137
+ }
138
+
139
+ function visibleWorkflowRows(rows: FleetWorkflowRow[] | undefined, visibleLimit: number): FleetWorkflowRow[] {
140
+ if (!rows?.length) return [];
141
+ if (rows.length <= visibleLimit) return rows;
142
+ const selected = new Set<number>();
143
+ for (const [index, row] of rows.entries()) {
144
+ if (row.state !== "complete" && row.state !== "completed") selected.add(index);
145
+ if (selected.size >= visibleLimit) break;
146
+ }
147
+ for (let index = rows.length - 1; index >= 0 && selected.size < visibleLimit; index--) selected.add(index);
148
+ const visible = [...selected].sort((left, right) => left - right).map((index) => rows[index]!);
149
+ return [{ name: `… +${rows.length - visible.length} hidden workflow steps`, state: "complete", overflow: rows.length - visible.length }, ...visible];
150
+ }
151
+
95
152
  function nestedStatusGlyph(state: FleetNestedRow["state"], theme: Theme): string {
96
153
  if (state === "running") return theme.fg("accent", "●");
97
154
  if (state === "queued" || state === "pending") return theme.fg("muted", "◦");
@@ -194,9 +251,10 @@ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
194
251
  if (entry.parentKey && entryKeys.has(entry.parentKey)) continue;
195
252
  rows.push({ kind: "owner", entry });
196
253
  const attached = childrenByParent.get(entry.key) ?? [];
254
+ const workflowRows = visibleWorkflowRows(entry.workflowRows, attached.length > 0 ? 2 : 4);
197
255
  for (const [index, child] of attached.entries()) {
198
256
  const nested = nestedFleetRows(child.nestedChildren, 3);
199
- const laterRows = index < attached.length - 1 || Boolean(entry.nestedChildren?.length);
257
+ const laterRows = index < attached.length - 1 || workflowRows.length > 0 || Boolean(entry.nestedChildren?.length);
200
258
  rows.push({ kind: "child", entry: child, last: !laterRows && nested.length === 0 });
201
259
  for (const [nestedIndex, row] of nested.entries()) rows.push({
202
260
  kind: "nested",
@@ -205,6 +263,7 @@ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
205
263
  last: nestedIndex === nested.length - 1 && !laterRows,
206
264
  });
207
265
  }
266
+ for (const [index, row] of workflowRows.entries()) rows.push({ kind: "workflow", ownerKey: entry.key, row, last: index === workflowRows.length - 1 && !entry.nestedChildren?.length });
208
267
  const nested = nestedFleetRows(entry.nestedChildren, attached.length > 0 ? 3 : 4);
209
268
  for (const [index, row] of nested.entries()) rows.push({ kind: "nested", ownerKey: entry.key, row, last: index === nested.length - 1 });
210
269
  }
@@ -282,6 +341,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
282
341
  startedAt,
283
342
  tokens: job.totalTokens?.total ?? 0,
284
343
  state: job.status,
344
+ ...(job.steps?.length ? { workflowRows: workflowFleetRows(job.steps) } : {}),
285
345
  ...(job.nestedChildren?.length ? { nestedChildren: job.nestedChildren } : {}),
286
346
  });
287
347
  continue;
@@ -548,6 +608,8 @@ export class SubagentFleetStatus {
548
608
  if (row.kind === "owner" || row.kind === "child") {
549
609
  const ownerIndex = this.entries.findIndex((entry) => entry.key === row.entry.key);
550
610
  lines.push(this.renderEntry(ownerIndex + 1, selectedIndex, row.entry, width, theme, row.kind === "child" ? (row.last ? "└─" : "├─") : undefined));
611
+ } else if (row.kind === "workflow") {
612
+ lines.push(this.renderWorkflowRow(row.row, row.last, width, theme));
551
613
  } else {
552
614
  lines.push(this.renderNestedRow(row.row, row.last, width, theme));
553
615
  }
@@ -576,6 +638,20 @@ export class SubagentFleetStatus {
576
638
  return truncateToWidth(`${left}${theme.fg("dim", elapsed)}`, width);
577
639
  }
578
640
 
641
+ private renderWorkflowRow(row: FleetWorkflowRow, last: boolean, width: number, theme: Theme): string {
642
+ const marker = last ? "└─" : "├─";
643
+ const indent = " ";
644
+ if (row.overflow !== undefined) return truncateToWidth(`${indent}${marker} ${theme.fg("dim", `+${row.overflow} hidden workflow steps`)}`, width);
645
+ const modelThinking = row.modelThinking ? ` (${row.modelThinking})` : "";
646
+ const activity = row.activity ? ` · ${row.activity}` : "";
647
+ const left = `${indent}${marker} ${nestedStatusGlyph(row.state, theme)} ${theme.fg("muted", `${row.name}${modelThinking}`)} · ${row.state}${activity}`;
648
+ const details = [
649
+ row.startedAt !== undefined ? formatFleetElapsed(Date.now() - row.startedAt) : undefined,
650
+ row.tokens !== undefined ? formatFleetTokens(row.tokens) : undefined,
651
+ ].filter(Boolean).join(" · ");
652
+ return truncateToWidth(`${left}${details ? theme.fg("dim", ` · ${details}`) : ""}`, width);
653
+ }
654
+
579
655
  private bullet(rosterIndex: number, selectedIndex: number, theme: Theme): string {
580
656
  return rosterIndex === selectedIndex ? theme.fg("accent", ">") : " ";
581
657
  }
@@ -624,6 +700,15 @@ export class SubagentFleetStatus {
624
700
  entry.external,
625
701
  Math.round((now - entry.startedAt) / 1000),
626
702
  entry.tokens,
703
+ visibleWorkflowRows(entry.workflowRows, entry.parentKey ? 2 : 4).map((row) => [
704
+ row.name,
705
+ row.state,
706
+ row.modelThinking,
707
+ row.activity,
708
+ row.startedAt,
709
+ row.tokens,
710
+ row.overflow,
711
+ ]),
627
712
  nestedFleetRows(entry.nestedChildren, entry.parentKey ? 3 : 4).map((row) => [
628
713
  row.name,
629
714
  row.state,
package/src/tui/fleet.ts CHANGED
@@ -6,7 +6,7 @@ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Compo
6
6
  import { snapshotExternalRuns, type ExternalRun } from "../api/external-runs.ts";
7
7
  import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
8
8
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../shared/formatters.ts";
9
- import { DIRS, type AsyncJobState, type Details, type FleetKeybindingAction, type FleetKeybindingsConfig, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
9
+ import { DIRS, type AsyncJobState, type AsyncJobStep, type Details, type FleetKeybindingAction, type FleetKeybindingsConfig, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
10
10
  import { decodeUtf8Tail } from "../shared/utf8.ts";
11
11
  import { readStatus } from "../shared/utils.ts";
12
12
  import { formatAsyncRunTranscript } from "../runs/background/fleet-view.ts";
@@ -494,15 +494,68 @@ function externalElapsedEnd(run: ExternalRun): number {
494
494
  return terminal ? (run.endedAt ?? run.updatedAt ?? Date.now()) : Date.now();
495
495
  }
496
496
 
497
+ function workflowStepLabel(step: AsyncJobStep, index: number): string {
498
+ const key = step.workflowKey ?? `step ${index + 1}`;
499
+ const label = step.label && step.label !== key ? ` · ${step.label}` : "";
500
+ const phase = step.phase ? `${step.phase}: ` : "";
501
+ return `${phase}${key}${label} (${step.agent})`;
502
+ }
503
+
504
+ function workflowStepActivity(step: AsyncJobStep): string | undefined {
505
+ if (step.currentTool) return `tool ${step.currentTool}`;
506
+ if (step.currentPath) return shortenPath(step.currentPath);
507
+ if (step.activityState === "needs_attention") return "needs attention";
508
+ if (step.activityState === "active_long_running") return "long-running";
509
+ if (step.turnCount !== undefined) return `${step.turnCount} turns`;
510
+ if (step.toolCount !== undefined) return `${step.toolCount} tools`;
511
+ return undefined;
512
+ }
513
+
514
+ function visibleWorkflowProgressSteps(steps: AsyncJobStep[], visibleLimit: number): Array<{ step: AsyncJobStep; index: number } | { hidden: number }> {
515
+ if (steps.length <= visibleLimit) return steps.map((step, index) => ({ step, index }));
516
+ const selected = new Set<number>();
517
+ for (const [index, step] of steps.entries()) {
518
+ if (step.status !== "complete" && step.status !== "completed") selected.add(index);
519
+ if (selected.size >= visibleLimit) break;
520
+ }
521
+ for (let index = steps.length - 1; index >= 0 && selected.size < visibleLimit; index--) selected.add(index);
522
+ const visible = [...selected].sort((left, right) => left - right).map((index) => ({ step: steps[index]!, index }));
523
+ return [{ hidden: steps.length - visible.length }, ...visible];
524
+ }
525
+
526
+ function workflowProgressLines(steps: AsyncJobStep[] | undefined): string[] {
527
+ if (!steps?.length) return [];
528
+ const lines = ["Workflow progress:"];
529
+ for (const row of visibleWorkflowProgressSteps(steps, 8)) {
530
+ if ("hidden" in row) {
531
+ lines.push(` +${row.hidden} hidden workflow steps`);
532
+ continue;
533
+ }
534
+ const activity = workflowStepActivity(row.step);
535
+ const context = contextModeLabel(row.step.context);
536
+ const details = [row.step.status, activity, context, row.step.tokens?.total !== undefined ? formatTokens(row.step.tokens.total) : undefined].filter(Boolean).join(" · ");
537
+ lines.push(` ${row.index + 1}. ${workflowStepLabel(row.step, row.index)}${details ? ` — ${details}` : ""}`);
538
+ }
539
+ return lines;
540
+ }
541
+
497
542
  function asyncDetail(item: Extract<FleetItem, { kind: "async" }>, state: SubagentState): string[] {
498
543
  const status = readStatus(item.run.asyncDir);
499
544
  if (status) {
500
545
  const trackedJob = state.fleetJobs?.get(item.runId) ?? state.asyncJobs.get(item.runId);
501
- return formatAsyncRunTranscript(status, item.run.asyncDir, {
546
+ const lines = formatAsyncRunTranscript(status, item.run.asyncDir, {
502
547
  index: item.index,
503
548
  lines: TRANSCRIPT_LINES,
504
549
  sessionRoots: uniquePaths([...(state.trustedSessionRoots ?? []), trackedJob?.sessionRoot]),
505
550
  }).split("\n");
551
+ if (status.mode === "workflow" && item.index === undefined) {
552
+ const progress = workflowProgressLines((status.steps ?? item.run.steps) as AsyncJobStep[]);
553
+ if (progress.length) {
554
+ const modeIndex = lines.findIndex((line) => line.startsWith("Mode:"));
555
+ lines.splice(modeIndex >= 0 ? modeIndex + 1 : 0, 0, "", ...progress);
556
+ }
557
+ }
558
+ return lines;
506
559
  }
507
560
  const outputPath = item.index !== undefined ? path.join(item.run.asyncDir, `output-${item.index}.log`) : undefined;
508
561
  return [
package/src/tui/render.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import * as path from "node:path";
6
+ import { createHash } from "node:crypto";
6
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
7
8
  import { getMarkdownTheme, keyText, type ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
@@ -535,7 +536,76 @@ function compactCurrentActivity(progress: AgentProgress): string {
535
536
  return formatCurrentToolLine(progress, getTermWidth() - 4, false, snapshotNow) ?? buildLiveStatusLine(progress, snapshotNow) ?? "thinking…";
536
537
  }
537
538
 
538
- export function widgetRenderKey(job: AsyncJobState): string {
539
+ function textDigest(text: string): string {
540
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
541
+ }
542
+
543
+ function displayTextRenderKey(text: string): unknown[] {
544
+ const normalized = sanitizeDisplayText(text);
545
+ return [normalized.length, textDigest(normalized)];
546
+ }
547
+
548
+ function expandedStepActivityRenderKey(step: AsyncJobStep): unknown[] {
549
+ return [
550
+ step.recentTools?.slice(-3).map((tool) => [tool.tool, displayTextRenderKey(tool.args), tool.endMs]),
551
+ compactRecentOutputLines(step.recentOutput).map(displayTextRenderKey),
552
+ ];
553
+ }
554
+
555
+ function widgetStepRenderKey(step: AsyncJobStep, index: number, expanded = false): unknown[] {
556
+ return [
557
+ step.index ?? index,
558
+ step.agent,
559
+ step.workflowKey,
560
+ step.phase,
561
+ step.label,
562
+ step.status,
563
+ step.activityState,
564
+ step.lastActivityAt,
565
+ step.currentTool,
566
+ step.currentToolArgs,
567
+ step.currentToolStartedAt,
568
+ step.currentPath,
569
+ step.turnCount,
570
+ step.toolCount,
571
+ step.startedAt,
572
+ step.endedAt,
573
+ step.durationMs,
574
+ step.tokens?.total,
575
+ step.model,
576
+ step.thinking,
577
+ step.context,
578
+ step.error,
579
+ expanded ? expandedStepActivityRenderKey(step) : undefined,
580
+ nestedRenderKey(step.children, expanded),
581
+ ];
582
+ }
583
+
584
+ function nestedRenderKey(children: NestedRunSummary[] | undefined, expanded = false): unknown[] {
585
+ return (children ?? []).map((child) => [
586
+ child.id,
587
+ child.state,
588
+ child.agent,
589
+ child.model,
590
+ child.thinking,
591
+ child.activityState,
592
+ child.lastActivityAt,
593
+ child.currentTool,
594
+ child.currentToolStartedAt,
595
+ child.currentPath,
596
+ child.turnCount,
597
+ child.toolCount,
598
+ child.startedAt,
599
+ child.endedAt,
600
+ child.lastUpdate,
601
+ child.error,
602
+ child.totalTokens?.total,
603
+ child.steps?.map((step, index) => widgetStepRenderKey({ ...step, agent: step.agent, status: step.status }, index, expanded)),
604
+ nestedRenderKey(child.children, expanded),
605
+ ]);
606
+ }
607
+
608
+ export function widgetRenderKey(job: AsyncJobState, expanded = false): string {
539
609
  return JSON.stringify({
540
610
  asyncDir: job.asyncDir,
541
611
  status: job.status,
@@ -551,8 +621,8 @@ export function widgetRenderKey(job: AsyncJobState): string {
551
621
  currentStep: job.currentStep,
552
622
  chainStepCount: job.chainStepCount,
553
623
  parallelGroups: job.parallelGroups,
554
- steps: job.steps,
555
- nestedChildren: job.nestedChildren,
624
+ steps: job.steps?.map((step, index) => widgetStepRenderKey(step, index, expanded)),
625
+ nestedChildren: nestedRenderKey(job.nestedChildren, expanded),
556
626
  stepsTotal: job.stepsTotal,
557
627
  runningSteps: job.runningSteps,
558
628
  completedSteps: job.completedSteps,