pi-subagents 0.45.1 → 0.46.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 (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +342 -0
  4. package/docs/configuration.md +320 -0
  5. package/docs/extension-api.md +308 -0
  6. package/docs/missions.md +117 -0
  7. package/docs/models.md +190 -0
  8. package/docs/observability.md +174 -0
  9. package/docs/tool-reference.md +343 -0
  10. package/docs/watchdog.md +176 -0
  11. package/docs/workflows.md +163 -0
  12. package/package.json +4 -2
  13. package/skills/pi-subagents/references/execution-controls.md +2 -2
  14. package/src/agents/agents.ts +17 -8
  15. package/src/agents/frontmatter.ts +7 -3
  16. package/src/agents/skills.ts +2 -9
  17. package/src/api/project-panes.ts +30 -0
  18. package/src/extension/config.ts +15 -1
  19. package/src/extension/index.ts +36 -16
  20. package/src/extension/schemas.ts +3 -2
  21. package/src/extension/subagent-guide.ts +39 -0
  22. package/src/extension/tool-description.ts +4 -4
  23. package/src/inspectors/herdr/project-panes.ts +457 -62
  24. package/src/missions/actions.ts +25 -2
  25. package/src/missions/lifecycle.ts +21 -2
  26. package/src/missions/store.ts +77 -1
  27. package/src/missions/types.ts +33 -0
  28. package/src/runs/background/async-execution.ts +7 -1
  29. package/src/runs/background/completion-replay.ts +267 -0
  30. package/src/runs/background/result-watcher.ts +12 -4
  31. package/src/runs/background/resume-guidance.ts +33 -0
  32. package/src/runs/background/subagent-wait.ts +5 -2
  33. package/src/runs/background/wait-completions.ts +39 -5
  34. package/src/runs/background/wait-subscriptions.ts +21 -5
  35. package/src/runs/foreground/execution.ts +4 -0
  36. package/src/runs/foreground/foreground-history.ts +137 -0
  37. package/src/runs/foreground/subagent-executor.ts +326 -38
  38. package/src/runs/shared/subagent-prompt-runtime.ts +71 -5
  39. package/src/shared/fork-context.ts +13 -0
  40. package/src/shared/prompt-resources.ts +51 -0
  41. package/src/shared/types.ts +30 -1
  42. package/src/shared/utf8.ts +11 -0
  43. package/src/slash/prompt-workflows.ts +2 -15
  44. package/src/slash/slash-commands.ts +19 -1
  45. package/src/tui/fleet-status.ts +8 -2
  46. package/src/tui/fleet.ts +135 -25
  47. package/src/tui/render.ts +120 -7
  48. package/src/workflows/scripted-workflow.ts +187 -11
package/src/tui/fleet.ts CHANGED
@@ -2,10 +2,11 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { getMarkdownTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type MarkdownTheme } from "@earendil-works/pi-tui";
5
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type MarkdownTheme } from "@earendil-works/pi-tui";
6
6
  import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
7
7
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../shared/formatters.ts";
8
- import { DIRS, type AsyncJobState, type Details, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
8
+ 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 { decodeUtf8Tail } from "../shared/utf8.ts";
9
10
  import { readStatus } from "../shared/utils.ts";
10
11
  import { formatAsyncRunTranscript } from "../runs/background/fleet-view.ts";
11
12
  import { listAsyncRuns, type AsyncRunSummary } from "../runs/background/async-status.ts";
@@ -21,6 +22,47 @@ const REFRESH_MS = 750;
21
22
  const MAX_RECENT_ASYNC_RUNS = 20;
22
23
  const MAX_FLEET_HISTORY_CANDIDATES = 100;
23
24
  const TRANSCRIPT_LINES = 200;
25
+ const OUTPUT_TAIL_BYTES = 64 * 1024;
26
+
27
+ export const DEFAULT_FLEET_KEYBINDINGS: Record<FleetKeybindingAction, string[]> = {
28
+ close: ["escape", "ctrl+c", "q"],
29
+ scrollUp: ["K"],
30
+ scrollDown: ["J"],
31
+ selectUp: ["up", "k"],
32
+ selectDown: ["down", "j"],
33
+ selectFirst: ["home"],
34
+ selectLast: ["end"],
35
+ pageUp: ["pageUp"],
36
+ pageDown: ["pageDown"],
37
+ refresh: ["r", "R"],
38
+ steer: ["s"],
39
+ inspect: ["H"],
40
+ stop: ["D"],
41
+ toggleTools: ["x", "X", "ctrl+o"],
42
+ };
43
+
44
+ type ResolvedFleetKeybindings = Record<FleetKeybindingAction, string[]>;
45
+
46
+ export function resolveFleetKeybindings(config: FleetKeybindingsConfig | undefined): ResolvedFleetKeybindings {
47
+ return Object.fromEntries(
48
+ Object.entries(DEFAULT_FLEET_KEYBINDINGS).map(([action, defaults]) => [action, config?.[action as FleetKeybindingAction] ?? defaults]),
49
+ ) as ResolvedFleetKeybindings;
50
+ }
51
+
52
+ function matchesFleetBinding(data: string, binding: string): boolean {
53
+ const key = /^[A-Z]$/.test(binding) ? `shift+${binding.toLowerCase()}` : binding;
54
+ return matchesKey(data, key as Parameters<typeof matchesKey>[1]);
55
+ }
56
+
57
+ function matchesFleetAction(data: string, bindings: ResolvedFleetKeybindings, action: FleetKeybindingAction): boolean {
58
+ return bindings[action].some((binding) => matchesFleetBinding(data, binding));
59
+ }
60
+
61
+ function bindingLabel(bindings: ResolvedFleetKeybindings, action: FleetKeybindingAction): string {
62
+ return bindings[action]
63
+ .map((binding) => binding === "up" ? "↑" : binding === "down" ? "↓" : binding === "escape" ? "Esc" : binding === "return" ? "Enter" : binding)
64
+ .join("/");
65
+ }
24
66
 
25
67
  type Theme = ExtensionContext["ui"]["theme"];
26
68
  type FleetTui = {
@@ -57,6 +99,7 @@ export interface FleetViewOptions {
57
99
  refreshMs?: number;
58
100
  initialKey?: string;
59
101
  markdownTheme?: MarkdownTheme;
102
+ fleetKeybindings?: FleetKeybindingsConfig;
60
103
  actions?: FleetActionHandlers;
61
104
  }
62
105
 
@@ -246,6 +289,7 @@ function foregroundActiveDetail(item: Extract<FleetItem, { kind: "foreground-act
246
289
  "Source: foreground",
247
290
  `State: running`,
248
291
  `Mode: ${control.mode}`,
292
+ control.parentWorkflowRunId ? `Workflow child of: ${control.parentWorkflowRunId}${control.workflowKey ? ` (${control.workflowKey})` : ""}` : undefined,
249
293
  item.index !== undefined ? `Child: ${item.index} (${item.agent})` : `Agent: ${item.agent}`,
250
294
  modelThinking ? `Model: ${modelThinking}` : undefined,
251
295
  `Started: ${new Date(live.startedAt).toISOString()}`,
@@ -260,7 +304,59 @@ function foregroundActiveDetail(item: Extract<FleetItem, { kind: "foreground-act
260
304
  return lines.filter((line): line is string => line !== undefined);
261
305
  }
262
306
 
263
- function foregroundRecentDetail(item: Extract<FleetItem, { kind: "foreground-recent" }>): string[] {
307
+ function pathWithin(base: string, candidate: string): boolean {
308
+ const resolvedBase = path.resolve(base);
309
+ const resolvedCandidate = path.resolve(candidate);
310
+ return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
311
+ }
312
+
313
+ function trustedFileTail(filePath: string, trustedRoots: string[]): { text?: string; warning?: string; unavailable?: string } {
314
+ const resolvedPath = path.resolve(filePath);
315
+ if (trustedRoots.length === 0 || !trustedRoots.some((root) => pathWithin(root, resolvedPath))) return { warning: `output artifact is outside trusted roots: ${filePath}` };
316
+ let stat: fs.Stats;
317
+ try {
318
+ stat = fs.lstatSync(resolvedPath);
319
+ } catch (error) {
320
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { unavailable: `output artifact unavailable: ${filePath}` };
321
+ return { warning: `output artifact could not be inspected: ${error instanceof Error ? error.message : String(error)}` };
322
+ }
323
+ if (stat.isSymbolicLink()) return { warning: `output artifact refused a symlink: ${filePath}` };
324
+ if (!stat.isFile()) return { warning: `output artifact is not a file: ${filePath}` };
325
+ try {
326
+ const realPath = fs.realpathSync(resolvedPath);
327
+ const realRoots = trustedRoots.filter((root) => fs.existsSync(root)).map((root) => fs.realpathSync(root));
328
+ if (!realRoots.some((root) => pathWithin(root, realPath))) return { warning: `output artifact resolves outside trusted roots: ${filePath}` };
329
+ const fd = fs.openSync(realPath, "r");
330
+ try {
331
+ const bytes = Math.min(stat.size, OUTPUT_TAIL_BYTES);
332
+ const buffer = Buffer.alloc(bytes);
333
+ fs.readSync(fd, buffer, 0, bytes, stat.size - bytes);
334
+ return { text: decodeUtf8Tail(buffer) };
335
+ } finally {
336
+ fs.closeSync(fd);
337
+ }
338
+ } catch (error) {
339
+ return { warning: `output artifact could not be read: ${error instanceof Error ? error.message : String(error)}` };
340
+ }
341
+ }
342
+
343
+ function foregroundRecentOutputLines(item: Extract<FleetItem, { kind: "foreground-recent" }>, state: SubagentState): string[] {
344
+ const outputPath = item.child.artifactPaths?.outputPath ?? item.child.savedOutputPath;
345
+ const output = item.child.finalOutput
346
+ ? { text: item.child.finalOutput }
347
+ : outputPath
348
+ ? trustedFileTail(path.isAbsolute(outputPath) ? outputPath : path.resolve(item.run.cwd, outputPath), uniquePaths([
349
+ fleetArtifactsRoot(state, item.run.cwd),
350
+ fleetArtifactsRoot(state, state.baseCwd),
351
+ ]))
352
+ : undefined;
353
+ if (output?.warning) return [`(${output.warning})`];
354
+ if (output?.unavailable) return [`(${output.unavailable})`];
355
+ const outputLines = (output?.text ?? "").split(/\r?\n/).filter((line) => line.trim()).slice(-TRANSCRIPT_LINES);
356
+ return outputLines.length ? outputLines : ["(no recovered output available)"];
357
+ }
358
+
359
+ function foregroundRecentDetail(item: Extract<FleetItem, { kind: "foreground-recent" }>, state: SubagentState): string[] {
264
360
  const { child, run } = item;
265
361
  const outputPath = child.artifactPaths?.outputPath ?? child.savedOutputPath;
266
362
  const modelThinking = formatModelThinking(child.model, child.thinking);
@@ -281,8 +377,7 @@ function foregroundRecentDetail(item: Extract<FleetItem, { kind: "foreground-rec
281
377
  "",
282
378
  "Result transcript tail",
283
379
  ];
284
- const outputLines = (child.finalOutput ?? "").split(/\r?\n/).filter((line) => line.trim()).slice(-TRANSCRIPT_LINES);
285
- lines.push(...(outputLines.length ? outputLines : ["(no recovered output available)"]));
380
+ lines.push(...foregroundRecentOutputLines(item, state));
286
381
  return lines.filter((line): line is string => line !== undefined);
287
382
  }
288
383
 
@@ -306,12 +401,12 @@ function asyncDetail(item: Extract<FleetItem, { kind: "async" }>): string[] {
306
401
  ].filter((line): line is string => line !== undefined);
307
402
  }
308
403
 
309
- function detailLines(item: FleetItem | undefined, error: string | undefined): string[] {
404
+ function detailLines(item: FleetItem | undefined, error: string | undefined, state: SubagentState): string[] {
310
405
  if (!item) return [error ? `Fleet scan failed: ${error}` : "No current-session foreground or recent async children.", "", "New runs appear here automatically while this inspector remains open."];
311
406
  const lines = item.kind === "foreground-active"
312
407
  ? foregroundActiveDetail(item)
313
408
  : item.kind === "foreground-recent"
314
- ? foregroundRecentDetail(item)
409
+ ? foregroundRecentDetail(item, state)
315
410
  : asyncDetail(item);
316
411
  if (error) lines.unshift(`Fleet scan warning: ${error}`, "");
317
412
  return lines;
@@ -497,6 +592,7 @@ export class SubagentFleetComponent implements Component {
497
592
  private readonly state: SubagentState;
498
593
  private readonly done: (result: undefined) => void;
499
594
  private readonly options: FleetViewOptions;
595
+ private readonly keybindings: ResolvedFleetKeybindings;
500
596
 
501
597
  constructor(
502
598
  tui: FleetTui,
@@ -511,6 +607,7 @@ export class SubagentFleetComponent implements Component {
511
607
  this.state = state;
512
608
  this.done = done;
513
609
  this.options = options;
610
+ this.keybindings = resolveFleetKeybindings(options.fleetKeybindings);
514
611
  this.selectedKey = options.initialKey;
515
612
  this.refresh();
516
613
  this.timer = setInterval(() => {
@@ -552,6 +649,19 @@ export class SubagentFleetComponent implements Component {
552
649
  return { item };
553
650
  }
554
651
 
652
+ private selectedHerdrInspectAction(): { runId: string; asyncDir: string; index?: number } | { reason: string } {
653
+ const item = this.snapshot.items[this.selected];
654
+ if (!item) return { reason: "No child is selected." };
655
+ if (item.kind === "async") {
656
+ if (!isActionableAsyncState(item.run.state) || !isActionableAsyncState(item.state)) return { reason: `Selected child is ${item.state}; controls require a running or queued async child.` };
657
+ return { runId: item.runId, asyncDir: item.run.asyncDir, ...(item.index !== undefined ? { index: item.index } : {}) };
658
+ }
659
+ if (item.kind !== "foreground-active" || !item.control.parentWorkflowRunId) return { reason: "Fleet controls are available for current-session top-level async runs only." };
660
+ const parent = this.state.asyncJobs.get(item.control.parentWorkflowRunId) ?? this.state.fleetJobs?.get(item.control.parentWorkflowRunId);
661
+ if (!parent || !isActionableAsyncState(parent.status)) return { reason: "The parent workflow is no longer available for Herdr inspection." };
662
+ return { runId: parent.asyncId, asyncDir: parent.asyncDir };
663
+ }
664
+
555
665
  private actionLines(): string[] {
556
666
  const lines: string[] = [];
557
667
  if (this.actionBusy) lines.push(this.theme.fg("accent", "Action pending..."));
@@ -657,25 +767,25 @@ export class SubagentFleetComponent implements Component {
657
767
  }
658
768
  return;
659
769
  }
660
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
770
+ if (matchesFleetAction(data, this.keybindings, "close")) {
661
771
  this.done(undefined);
662
772
  return;
663
773
  }
664
- if (matchesKey(data, Key.shift("k"))) return this.scrollDetail(-1);
665
- if (matchesKey(data, Key.shift("j"))) return this.scrollDetail(1);
666
- if (matchesKey(data, "up") || matchesKey(data, "k")) return this.moveSelection(-1);
667
- if (matchesKey(data, "down") || matchesKey(data, "j")) return this.moveSelection(1);
668
- if (matchesKey(data, "home")) return this.moveSelection(-this.snapshot.items.length);
669
- if (matchesKey(data, "end")) return this.moveSelection(this.snapshot.items.length);
670
- if (matchesKey(data, "pageUp")) return this.scrollDetail(-this.detailViewportHeight);
671
- if (matchesKey(data, "pageDown")) return this.scrollDetail(this.detailViewportHeight);
672
- if (data.toLowerCase() === "r") {
774
+ if (matchesFleetAction(data, this.keybindings, "scrollUp")) return this.scrollDetail(-1);
775
+ if (matchesFleetAction(data, this.keybindings, "scrollDown")) return this.scrollDetail(1);
776
+ if (matchesFleetAction(data, this.keybindings, "selectUp")) return this.moveSelection(-1);
777
+ if (matchesFleetAction(data, this.keybindings, "selectDown")) return this.moveSelection(1);
778
+ if (matchesFleetAction(data, this.keybindings, "selectFirst")) return this.moveSelection(-this.snapshot.items.length);
779
+ if (matchesFleetAction(data, this.keybindings, "selectLast")) return this.moveSelection(this.snapshot.items.length);
780
+ if (matchesFleetAction(data, this.keybindings, "pageUp")) return this.scrollDetail(-this.detailViewportHeight);
781
+ if (matchesFleetAction(data, this.keybindings, "pageDown")) return this.scrollDetail(this.detailViewportHeight);
782
+ if (matchesFleetAction(data, this.keybindings, "refresh")) {
673
783
  this.transcriptCache = undefined;
674
784
  this.refresh();
675
785
  this.tui.requestRender();
676
786
  return;
677
787
  }
678
- if (data === "s") {
788
+ if (matchesFleetAction(data, this.keybindings, "steer")) {
679
789
  const target = this.selectedAsyncAction();
680
790
  if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
681
791
  else {
@@ -687,13 +797,13 @@ export class SubagentFleetComponent implements Component {
687
797
  }
688
798
  return;
689
799
  }
690
- if (data === "H") {
691
- const target = this.selectedAsyncAction();
800
+ if (matchesFleetAction(data, this.keybindings, "inspect")) {
801
+ const target = this.selectedHerdrInspectAction();
692
802
  if ("reason" in target || !this.options.actions?.inspect) this.setActionNotice({ text: "reason" in target ? target.reason : "Herdr inspector controls are unavailable in this context.", isError: true });
693
- else this.runAction(() => this.options.actions!.inspect!({ runId: target.item.runId, asyncDir: target.item.run.asyncDir, ...(target.item.index !== undefined ? { index: target.item.index } : {}) }));
803
+ else this.runAction(() => this.options.actions!.inspect!(target));
694
804
  return;
695
805
  }
696
- if (data === "D") {
806
+ if (matchesFleetAction(data, this.keybindings, "stop")) {
697
807
  const target = this.selectedAsyncAction();
698
808
  if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
699
809
  else {
@@ -705,7 +815,7 @@ export class SubagentFleetComponent implements Component {
705
815
  }
706
816
  return;
707
817
  }
708
- if (data.toLowerCase() === "x" || matchesKey(data, "ctrl+o")) {
818
+ if (matchesFleetAction(data, this.keybindings, "toggleTools")) {
709
819
  this.expandedTools = !this.expandedTools;
710
820
  this.transcriptCache = undefined;
711
821
  this.tui.requestRender();
@@ -766,7 +876,7 @@ export class SubagentFleetComponent implements Component {
766
876
  }
767
877
  }
768
878
 
769
- const raw = detailLines(selected, this.snapshot.error);
879
+ const raw = detailLines(selected, this.snapshot.error, this.state);
770
880
  if (transcriptWarning) raw.unshift(`Transcript preview warning: ${transcriptWarning}`, "");
771
881
  const lines: string[] = [];
772
882
  for (const line of raw) {
@@ -823,7 +933,7 @@ export class SubagentFleetComponent implements Component {
823
933
  }
824
934
  lines.push(this.theme.fg("border", `├${"─".repeat(rosterWidth)}┴${"─".repeat(detailWidth)}┤`));
825
935
  const position = this.snapshot.items.length ? `${this.selected + 1}/${this.snapshot.items.length}` : "0/0";
826
- const footer = ` ↑↓/jk agent · H Herdr · s steer · D stop · x/Ctrl+O tools · r refresh · Esc close · ${position}`;
936
+ const footer = ` ${bindingLabel(this.keybindings, "selectUp")}/${bindingLabel(this.keybindings, "selectDown")} agent · ${bindingLabel(this.keybindings, "inspect")} Herdr · ${bindingLabel(this.keybindings, "steer")} steer · ${bindingLabel(this.keybindings, "stop")} stop · ${bindingLabel(this.keybindings, "toggleTools")} tools · ${bindingLabel(this.keybindings, "refresh")} refresh · ${bindingLabel(this.keybindings, "close")} close · ${position}`;
827
937
  lines.push(this.theme.fg("border", "│") + fit(this.theme.fg("dim", footer), innerWidth) + this.theme.fg("border", "│"));
828
938
  lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
829
939
  return lines.map((line) => truncateToWidth(line, width));
package/src/tui/render.ts CHANGED
@@ -202,6 +202,118 @@ function getToolCallLines(
202
202
  return result.toolCalls?.map((toolCall) => expanded ? toolCall.expandedText : toolCall.text) ?? [];
203
203
  }
204
204
 
205
+ const ansiEscapePattern = /\x1b\[[0-9;]*m/g;
206
+ const noisyStatusPatterns = [
207
+ /^(?:i|we)\s+(?:will|need|can|should|am|are)\b/i,
208
+ /^i(?:'m|’m| am)\b/i,
209
+ /\bso i (?:will|need|can)\b/i,
210
+ /^(?:checking|fetching|reading|inspecting|verifying|collecting|confirming|polling)\b/i,
211
+ /^(?:async\s+subagent\s+)?[\w.-]+\s*·\s*(?:step|agent)\s+\d+\/\d+\s*·/i,
212
+ /^(?:Step|Agent)\s+\d+\/\d+:\s+[\w.-]+\s*·\s*(?:running|queued|pending|complete|completed)\b/i,
213
+ /^Press\s+\S+\s+for\s+live\s+detail$/i,
214
+ /^output:\s+.+\/async-subagent-runs\//i,
215
+ ];
216
+ const liveOutputWordSignalPattern = /\b(?:access denied|denied|error|exception|fail(?:ed|ure)?|fatal|panic|rejected|timeout|timed out|unable|warning)\b/i;
217
+ const liveOutputCodeSignalPattern = /\bE[A-Z0-9_]{2,}\b/;
218
+
219
+ function oneLine(text: string): string {
220
+ return text.replace(ansiEscapePattern, "").replace(/\s+/g, " ").trim();
221
+ }
222
+
223
+ function hasLiveOutputSignal(line: string): boolean {
224
+ const clean = oneLine(line);
225
+ return liveOutputWordSignalPattern.test(clean) || liveOutputCodeSignalPattern.test(clean);
226
+ }
227
+
228
+ function isNoisyStatusLine(line: string): boolean {
229
+ const clean = oneLine(line);
230
+ return clean.length > 0
231
+ && clean.length <= 240
232
+ && !hasLiveOutputSignal(clean)
233
+ && noisyStatusPatterns.some((pattern) => pattern.test(clean));
234
+ }
235
+
236
+ function latestActivityText(line: string): string {
237
+ return oneLine(line)
238
+ .replace(/^i (?:will|can|need to|am going to)\s+/i, "")
239
+ .replace(/^i(?:'m|’m| am)\s+/i, "");
240
+ }
241
+
242
+ function progressUpdateSummary(lines: string[]): string {
243
+ const counts = new Map<string, number>();
244
+ for (const line of lines) counts.set(line.toLowerCase(), (counts.get(line.toLowerCase()) ?? 0) + 1);
245
+ const exactRepeatCount = Math.max(...counts.values());
246
+ const latest = latestActivityText(lines[lines.length - 1]!);
247
+ const repeat = exactRepeatCount > 1 ? ` · repeated ${exactRepeatCount}×` : "";
248
+ return `↻ ${lines.length} progress updates${repeat} · latest: ${latest}`;
249
+ }
250
+
251
+ function compactRecentOutputLines(recentOutput: string[] | undefined): string[] {
252
+ const lines: string[] = [];
253
+ const noisyLines: string[] = [];
254
+ const otherLines: string[] = [];
255
+ for (const rawLine of recentOutput ?? []) {
256
+ const line = oneLine(rawLine);
257
+ if (!line || line === "(running...)") continue;
258
+ lines.push(line);
259
+ (isNoisyStatusLine(line) ? noisyLines : otherLines).push(line);
260
+ }
261
+ if (noisyLines.length >= 4 && !otherLines.some(hasLiveOutputSignal)) {
262
+ if (otherLines.length === 0) {
263
+ return [
264
+ progressUpdateSummary(noisyLines),
265
+ "pattern: repeated short status lines",
266
+ ];
267
+ }
268
+ const visibleTail = otherLines.slice(-3);
269
+ const hiddenSignals = otherLines.slice(0, -3).filter(hasLiveOutputSignal);
270
+ return [
271
+ progressUpdateSummary(noisyLines),
272
+ ...(hiddenSignals.length > 0 ? [`… ${hiddenSignals.length} older signal ${hiddenSignals.length === 1 ? "line" : "lines"}: ${hiddenSignals.at(-1)}`] : []),
273
+ ...visibleTail,
274
+ ].slice(0, 5);
275
+ }
276
+ if (lines.length <= 5) return lines;
277
+
278
+ const tail = lines.slice(-5);
279
+ const hiddenSignals = lines.slice(0, -5).filter(hasLiveOutputSignal);
280
+ if (hiddenSignals.length === 0) return tail;
281
+ return [
282
+ `… ${hiddenSignals.length} older signal ${hiddenSignals.length === 1 ? "line" : "lines"}: ${hiddenSignals.at(-1)}`,
283
+ ...lines.slice(-4),
284
+ ];
285
+ }
286
+
287
+ function compactWorkflowError(error: string): string {
288
+ const outputMatch = error.match(/(?:^|\n)Output:\s*([\s\S]+)/);
289
+ if (!outputMatch) return oneLine(error);
290
+ const prefix = oneLine(error.slice(0, outputMatch.index)).replace(/:$/, "") || "Failed";
291
+ const outputLines = outputMatch[1]!.split(/\r?\n/).map(oneLine).filter(Boolean);
292
+ const allOutputLinesAreNoisy = outputLines.length > 0 && outputLines.every(isNoisyStatusLine);
293
+ const latest = outputLines.at(-1);
294
+ return allOutputLinesAreNoisy && latest
295
+ ? `${prefix} · latest: ${latestActivityText(latest)}`
296
+ : `${prefix} · ${oneLine(outputMatch[1] ?? "")}`;
297
+ }
298
+
299
+ const WORKFLOW_LIVE_ROW_LIMIT = 8;
300
+
301
+ function visibleWorkflowRows(rows: WorkflowChatProgressRow[]): { rows: WorkflowChatProgressRow[]; hiddenRows: number } {
302
+ if (rows.length <= WORKFLOW_LIVE_ROW_LIMIT) return { rows, hiddenRows: 0 };
303
+ const selected = new Set<string>();
304
+ const add = (row: WorkflowChatProgressRow): void => {
305
+ if (selected.size >= WORKFLOW_LIVE_ROW_LIMIT || selected.has(row.key)) return;
306
+ selected.add(row.key);
307
+ };
308
+ for (const row of [...rows].reverse()) {
309
+ if (row.state === "failed") add(row);
310
+ }
311
+ for (const row of [...rows].reverse()) add(row);
312
+ return {
313
+ rows: rows.filter((row) => selected.has(row.key)),
314
+ hiddenRows: rows.length - selected.size,
315
+ };
316
+ }
205
317
 
206
318
  function snapshotNowForProgress(progress: Pick<AgentProgress, "currentToolStartedAt" | "durationMs" | "lastActivityAt">): number | undefined {
207
319
  if (progress.currentToolStartedAt !== undefined && progress.durationMs !== undefined) return progress.currentToolStartedAt + progress.durationMs;
@@ -1038,7 +1150,7 @@ function foregroundStyleWidgetStepLines(
1038
1150
  const argsPreview = tool.args.length <= maxArgsLen ? tool.args : `${tool.args.slice(0, maxArgsLen)}...`;
1039
1151
  lines.push(` ${theme.fg("dim", `${tool.tool}${argsPreview ? `: ${argsPreview}` : ""}`)}`);
1040
1152
  }
1041
- for (const line of step.recentOutput?.slice(-5) ?? []) {
1153
+ for (const line of compactRecentOutputLines(step.recentOutput)) {
1042
1154
  lines.push(` ${theme.fg("dim", line)}`);
1043
1155
  }
1044
1156
  }
@@ -1513,12 +1625,14 @@ function renderWorkflowChatProgress(d: Details, result: AgentToolResult<Details>
1513
1625
  c.addChild(new Text(truncLine(theme.fg("dim", " ◦ waiting for workflow child launches"), width), 0, 0));
1514
1626
  return c;
1515
1627
  }
1516
- for (const row of rows) {
1628
+ const visible = visibleWorkflowRows(rows);
1629
+ if (visible.hiddenRows > 0) c.addChild(new Text(truncLine(theme.fg("dim", ` … ${visible.hiddenRows} older workflow rows hidden`), width), 0, 0));
1630
+ for (const row of visible.rows) {
1517
1631
  const status = workflowRowStateLabel(row, theme);
1518
- const label = row.label && row.label !== row.key ? ` ${row.label}` : "";
1632
+ const label = row.label && row.label !== row.key ? ` ${oneLine(row.label)}` : "";
1519
1633
  const duration = row.durationMs !== undefined ? ` ${theme.fg("dim", `· ${formatDuration(row.durationMs)}`)}` : "";
1520
1634
  const run = row.runId ? ` ${theme.fg("dim", `[${row.runId.slice(0, 8)}]`)}` : "";
1521
- const error = row.error ? ` ${theme.fg("error", `· ${row.error}`)}` : "";
1635
+ const error = row.error ? ` ${theme.fg("error", `· ${compactWorkflowError(row.error)}`)}` : "";
1522
1636
  c.addChild(new Text(truncLine(` ${workflowRowGlyph(row, theme, frame)} ${status} ${theme.bold(row.key)}${label}${run}${duration}${error}`, width), 0, 0));
1523
1637
  }
1524
1638
  if (workflow?.emits.length) c.addChild(new Text(truncLine(theme.fg("dim", ` Emits ${workflow.emits.length}`), width), 0, 0));
@@ -1762,7 +1876,7 @@ export function renderSubagentResult(
1762
1876
  c.addChild(new Text(fit(theme.fg("dim", `${t.tool}: ${argsPreview}`)), 0, 0));
1763
1877
  }
1764
1878
  }
1765
- for (const line of (r.progress.recentOutput ?? []).slice(-5)) {
1879
+ for (const line of compactRecentOutputLines(r.progress.recentOutput)) {
1766
1880
  c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0));
1767
1881
  }
1768
1882
  if (toolLine || liveStatusLine || r.progress.recentTools?.length || r.progress.recentOutput?.length || r.artifactPaths) {
@@ -1995,8 +2109,7 @@ export function renderSubagentResult(
1995
2109
  c.addChild(new Text(fit(theme.fg("dim", ` ${t.tool}: ${argsPreview}`)), 0, 0));
1996
2110
  }
1997
2111
  }
1998
- const recentLines = (rProg.recentOutput ?? []).slice(-5);
1999
- for (const line of recentLines) {
2112
+ for (const line of compactRecentOutputLines(rProg.recentOutput)) {
2000
2113
  c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0));
2001
2114
  }
2002
2115
  }