pi-subagents 0.14.0 → 0.14.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.1] - 2026-04-14
6
+
7
+ ### Fixed
8
+ - Completed foreground subagent results now return compact payloads instead of inlining full raw message histories and per-result progress objects, preventing long tool-heavy sync runs from overwhelming the parent agent return path.
9
+ - Prompt-template delegation now rebuilds minimal assistant messages from compact foreground results when raw message arrays are intentionally omitted.
10
+ - UI/status wording now uses plain text labels instead of glyph-heavy markers across foreground rendering, parallel summaries, save-result receipts, installer output, agent manager views, clarify screens, and the corresponding README/CHANGELOG examples.
11
+ - Added a realistic foreground integration repro for issue `#80` and cleaned up the touched tests to remove the remaining blunt `as any` fixture casts.
12
+
5
13
  ## [0.14.0] - 2026-04-14
6
14
 
7
15
  ### Added
@@ -496,7 +504,7 @@
496
504
  - Pre-selects current thinking level if already set
497
505
  - **Model selector in chain TUI** - Press `[m]` to select a different model for any step
498
506
  - Fuzzy search through all available models
499
- - Shows current model with indicator
507
+ - Shows the current model with a `current` badge
500
508
  - Provider/model format (e.g., `anthropic/claude-haiku-4-5`)
501
509
  - Override indicator (✎) when model differs from agent default
502
510
  - **Model visibility in chain execution** - Shows which model each step is using
@@ -534,8 +542,8 @@
534
542
 
535
543
  ### Improved
536
544
  - **Per-step progress indicators** - When progress is enabled, each step shows its role:
537
- - Step 1: `● creates & updates progress.md`
538
- - Step 2+: `↔ reads & updates progress.md`
545
+ - Step 1: `writes progress.md`
546
+ - Step 2+: `reads progress.md`
539
547
  - Clear visualization of progress.md data flow through the chain
540
548
  - **Comprehensive tool descriptions** - Better documentation of chain variables:
541
549
  - Tool description now explains `{task}`, `{previous}`, `{chain_dir}` in detail
@@ -591,7 +599,7 @@
591
599
  ### Improved
592
600
  - **Tool description now explicitly shows the three modes** (SINGLE, CHAIN, PARALLEL) with syntax - helps agents pick the right mode when user says "scout → planner"
593
601
  - **Chain execution observability** - Now shows:
594
- - Chain visualization with status icons: `✓scout → planner` (✓=done, ●=running, ○=pending, ✗=failed) - sequential chains only
602
+ - Chain visualization with status labels: `done scout → running planner` (`done`, `running`, `pending`, `failed`) - sequential chains only
595
603
  - Accurate step counter: "step 1/2" instead of misleading "1/1"
596
604
  - Current tool and recent output for running step
597
605
 
package/README.md CHANGED
@@ -921,8 +921,8 @@ Requirements:
921
921
  During sync execution, the collapsed view shows real-time progress for single, chain, and parallel modes.
922
922
 
923
923
  **Chains:**
924
- - Header: `... chain 1/2 | 8 tools, 1.4k tok, 38s`
925
- - Chain visualization with status: `✓scout → planner` (✓=done, ●=running, ○=pending, ✗=failed)
924
+ - Header: `running chain 1/2 | 8 tools, 1.4k tok, 38s`
925
+ - Chain visualization with status: `done scout → running planner` (`done`, `running`, `pending`, `failed`)
926
926
  - Current tool: `> read: packages/tui/src/...`
927
927
  - Recent output lines (last 2-3 lines)
928
928
 
@@ -933,7 +933,7 @@ During sync execution, the collapsed view shows real-time progress for single, c
933
933
 
934
934
  Press **Ctrl+O** to expand the full streaming view with complete output per step.
935
935
 
936
- > **Note:** Chain visualization (the `✓scout → planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
936
+ > **Note:** Chain visualization (the `done scout → running planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
937
937
 
938
938
  ## Nested subagent recursion guard
939
939
 
@@ -106,7 +106,7 @@ function buildDetailLines(
106
106
 
107
107
  for (const run of recentRuns) {
108
108
  const when = pad(formatRelativeTime(run.ts), 8);
109
- const status = run.status === "ok" ? "✓" : "✗";
109
+ const status = run.status;
110
110
  const task = truncateToWidth(`"${run.task}"`, 34);
111
111
  const tail = run.status === "ok" ? formatDuration(run.duration) : `exit ${run.exit ?? 1}`;
112
112
  lines.push(truncateToWidth(` ${when} ${status} ${task} ${tail}`, contentWidth));
@@ -200,9 +200,9 @@ export function renderList(
200
200
  const count = selectionCount(state.selected, agent.id);
201
201
  const isShadowed = agent.kind === "agent" && agent.source === "project" && userNames.has(agent.name);
202
202
 
203
- const cursorChar = isCursor ? theme.fg("accent", "") : " ";
204
- const selectBadge = count > 1 ? theme.fg("accent", `×${count}`.padStart(2)) : count === 1 ? theme.fg("accent", " ✓") : " ";
205
- const shadowMarker = isShadowed ? theme.fg("warning", "") : " ";
203
+ const cursorChar = isCursor ? theme.fg("accent", ">") : " ";
204
+ const selectBadge = count > 0 ? theme.fg("accent", String(count).padStart(2)) : " ";
205
+ const shadowMarker = isShadowed ? theme.fg("warning", "!") : " ";
206
206
  const prefix = `${cursorChar}${selectBadge}${shadowMarker} `;
207
207
 
208
208
  const modelRaw = agent.kind === "chain" ? `${agent.stepCount ?? 0} steps` : (agent.model ?? "default");
package/chain-clarify.ts CHANGED
@@ -987,7 +987,7 @@ export class ChainClarifyComponent implements Component {
987
987
  const prefix = isSelected ? th.fg("accent", "→ ") : " ";
988
988
  const modelText = isSelected ? th.fg("accent", model.id) : model.id;
989
989
  const providerBadge = th.fg("dim", ` [${model.provider}]`);
990
- const currentBadge = isCurrent ? th.fg("success", " ") : "";
990
+ const currentBadge = isCurrent ? th.fg("success", " current") : "";
991
991
 
992
992
  lines.push(this.row(` ${prefix}${modelText}${providerBadge}${currentBadge}`));
993
993
  }
@@ -1269,7 +1269,7 @@ export class ChainClarifyComponent implements Component {
1269
1269
  lines.push(this.row(` Chain Dir: ${th.fg("dim", chainDirPreview)}`));
1270
1270
 
1271
1271
  const progressEnabled = this.agentConfigs.some((_, i) => this.getEffectiveBehavior(i).progress);
1272
- const progressValue = progressEnabled ? th.fg("success", "enabled") : th.fg("dim", "disabled");
1272
+ const progressValue = progressEnabled ? th.fg("success", "enabled") : th.fg("dim", "disabled");
1273
1273
  lines.push(this.row(` Progress: ${progressValue} ${th.fg("dim", "(press [p] to toggle)")}`));
1274
1274
  lines.push(this.row(""));
1275
1275
 
@@ -1331,8 +1331,8 @@ export class ChainClarifyComponent implements Component {
1331
1331
  if (progressEnabled) {
1332
1332
  const isFirstStep = i === 0;
1333
1333
  const progressAction = isFirstStep
1334
- ? th.fg("success", "●") + th.fg("dim", " creates & updates progress.md")
1335
- : th.fg("accent", "↔") + th.fg("dim", " reads & updates progress.md");
1334
+ ? th.fg("success", "writes progress.md")
1335
+ : th.fg("accent", "reads progress.md");
1336
1336
  const progressLabel = th.fg("dim", "progress: ");
1337
1337
  lines.push(this.row(` ${progressLabel}${progressAction}`));
1338
1338
  }
@@ -28,7 +28,7 @@ import {
28
28
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.ts";
29
29
  import { runSync } from "./execution.ts";
30
30
  import { buildChainSummary } from "./formatters.ts";
31
- import { getSingleResultOutput, mapConcurrent } from "./utils.ts";
31
+ import { compactForegroundDetails, getSingleResultOutput, mapConcurrent } from "./utils.ts";
32
32
  import { recordRun } from "./run-history.ts";
33
33
  import {
34
34
  cleanupWorktrees,
@@ -91,7 +91,7 @@ interface ParallelChainRunInput {
91
91
  }
92
92
 
93
93
  function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details {
94
- return {
94
+ return compactForegroundDetails({
95
95
  mode: "chain",
96
96
  results: input.results,
97
97
  progress: input.includeProgress ? input.allProgress : undefined,
@@ -99,7 +99,7 @@ function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details
99
99
  chainAgents: input.chainAgents,
100
100
  totalSteps: input.totalSteps,
101
101
  currentStepIndex: input.currentStepIndex,
102
- };
102
+ });
103
103
  }
104
104
 
105
105
  function buildChainExecutionErrorResult(message: string, input: ChainExecutionDetailsInput): ChainExecutionResult {
@@ -670,15 +670,16 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
670
670
  });
671
671
  return {
672
672
  content: [{ type: "text", text: summary }],
673
- details: {
674
- mode: "chain",
673
+ details: buildChainExecutionDetails({
675
674
  results,
676
- progress: includeProgress ? allProgress : undefined,
677
- artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
675
+ includeProgress,
676
+ allProgress,
677
+ allArtifactPaths,
678
+ artifactsDir,
678
679
  chainAgents,
679
680
  totalSteps,
680
681
  currentStepIndex: stepIndex,
681
- },
682
+ }),
682
683
  isError: true,
683
684
  };
684
685
  }
@@ -691,13 +692,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
691
692
 
692
693
  return {
693
694
  content: [{ type: "text", text: summary }],
694
- details: {
695
- mode: "chain",
695
+ details: buildChainExecutionDetails({
696
696
  results,
697
- progress: includeProgress ? allProgress : undefined,
698
- artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
697
+ includeProgress,
698
+ allProgress,
699
+ allArtifactPaths,
700
+ artifactsDir,
699
701
  chainAgents,
700
702
  totalSteps,
701
- },
703
+ }),
702
704
  };
703
705
  }
package/install.mjs CHANGED
@@ -38,7 +38,7 @@ if (isRemove) {
38
38
  if (fs.existsSync(EXTENSION_DIR)) {
39
39
  console.log(`Removing ${EXTENSION_DIR}...`);
40
40
  fs.rmSync(EXTENSION_DIR, { recursive: true });
41
- console.log("pi-subagents removed");
41
+ console.log("pi-subagents removed");
42
42
  } else {
43
43
  console.log("pi-subagents is not installed");
44
44
  }
@@ -61,7 +61,7 @@ if (fs.existsSync(EXTENSION_DIR)) {
61
61
  console.log("Updating existing installation...");
62
62
  try {
63
63
  execSync("git pull", { cwd: EXTENSION_DIR, stdio: "inherit" });
64
- console.log("\n✓ pi-subagents updated");
64
+ console.log("\npi-subagents updated");
65
65
  } catch (err) {
66
66
  console.error("Failed to update. Try removing and reinstalling:");
67
67
  console.error(" npx pi-subagents --remove && npx pi-subagents");
@@ -77,7 +77,7 @@ if (fs.existsSync(EXTENSION_DIR)) {
77
77
  console.log(`Cloning to ${EXTENSION_DIR}...`);
78
78
  try {
79
79
  execSync(`git clone ${REPO_URL} "${EXTENSION_DIR}"`, { stdio: "inherit" });
80
- console.log("\n✓ pi-subagents installed");
80
+ console.log("\npi-subagents installed");
81
81
  } catch (err) {
82
82
  console.error("Failed to clone repository");
83
83
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/parallel-utils.ts CHANGED
@@ -102,15 +102,15 @@ export function aggregateParallelOutputs(
102
102
  const hasOutput = Boolean(r.output?.trim());
103
103
  const status =
104
104
  r.exitCode === -1
105
- ? "⏭️ SKIPPED"
105
+ ? "SKIPPED"
106
106
  : r.exitCode !== 0 && r.exitCode !== null
107
- ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
107
+ ? `FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
108
108
  : r.error
109
- ? `⚠️ WARNING: ${r.error}`
109
+ ? `WARNING: ${r.error}`
110
110
  : !hasOutput && r.outputTargetPath && r.outputTargetExists === false
111
- ? `⚠️ EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
111
+ ? `EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
112
112
  : !hasOutput && !r.outputTargetPath
113
- ? "⚠️ EMPTY OUTPUT (no textual response returned)"
113
+ ? "EMPTY OUTPUT (no textual response returned)"
114
114
  : "";
115
115
  const body = status ? (hasOutput ? `${status}\n${r.output}` : status) : r.output;
116
116
  return `${header}\n${body}`;
@@ -78,6 +78,7 @@ interface PromptTemplateBridgeResult {
78
78
  results?: Array<{
79
79
  agent?: string;
80
80
  messages?: unknown[];
81
+ finalOutput?: string;
81
82
  exitCode?: number;
82
83
  error?: string;
83
84
  model?: string;
@@ -180,12 +181,13 @@ function sanitizeRecentTools(
180
181
  tools: Array<{ tool?: string; args?: string }> | undefined,
181
182
  ): Array<{ tool: string; args: string }> | undefined {
182
183
  if (!tools || tools.length === 0) return undefined;
183
- const sanitized = tools
184
- .filter((entry) => typeof entry.tool === "string" && entry.tool.trim().length > 0)
185
- .map((entry) => ({
186
- tool: entry.tool as string,
184
+ const sanitized = tools.flatMap((entry) => {
185
+ if (typeof entry.tool !== "string" || entry.tool.trim().length === 0) return [];
186
+ return [{
187
+ tool: entry.tool,
187
188
  args: typeof entry.args === "string" ? entry.args : String(entry.args ?? ""),
188
- }));
189
+ }];
190
+ });
189
191
  return sanitized.length > 0 ? sanitized : undefined;
190
192
  }
191
193
 
@@ -207,6 +209,15 @@ function resolveProgressModel(
207
209
  return firstWithModel?.model;
208
210
  }
209
211
 
212
+ function buildDelegationMessages(result: { messages?: unknown[]; finalOutput?: string }, fallbackText?: string): unknown[] {
213
+ if (Array.isArray(result.messages) && result.messages.length > 0) return result.messages;
214
+ const text = typeof result.finalOutput === "string" && result.finalOutput.trim().length > 0
215
+ ? result.finalOutput.trim()
216
+ : fallbackText;
217
+ if (!text) return [];
218
+ return [{ role: "assistant", content: [{ type: "text", text }] }];
219
+ }
220
+
210
221
  function toDelegationUpdate(requestId: string, update: PromptTemplateBridgeResult): PromptTemplateDelegationUpdate | undefined {
211
222
  const progress = update.details?.progress?.[0];
212
223
  const taskProgress = update.details?.progress?.map((entry) => {
@@ -324,7 +335,8 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
324
335
  options.events.emit(PROMPT_TEMPLATE_SUBAGENT_UPDATE_EVENT, payload);
325
336
  },
326
337
  );
327
- const messages = result.details?.results?.[0]?.messages ?? [];
338
+ const contentText = firstTextContent(result.content);
339
+ const messages = buildDelegationMessages(result.details?.results?.[0] ?? {}, contentText);
328
340
  const parallelResults = request.tasks
329
341
  ? request.tasks.map<PromptTemplateDelegationParallelResult>((task, index) => {
330
342
  const step = result.details?.results?.[index];
@@ -340,13 +352,12 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
340
352
  const errorText = step.error;
341
353
  return {
342
354
  agent: step.agent ?? task.agent,
343
- messages: step.messages ?? [],
355
+ messages: buildDelegationMessages(step),
344
356
  isError: (exitCode !== undefined && exitCode !== 0) || !!errorText,
345
357
  errorText: errorText || undefined,
346
358
  };
347
359
  })
348
360
  : undefined;
349
- const contentText = firstTextContent(result.content);
350
361
  const response: PromptTemplateDelegationResponse = {
351
362
  ...request,
352
363
  messages,
package/render.ts CHANGED
@@ -20,7 +20,6 @@ function getTermWidth(): number {
20
20
  return process.stdout.columns || 120;
21
21
  }
22
22
 
23
- // Grapheme segmenter for proper Unicode handling (shared instance)
24
23
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
25
24
 
26
25
  /**
@@ -35,42 +34,38 @@ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
35
34
  function truncLine(text: string, maxWidth: number): string {
36
35
  if (visibleWidth(text) <= maxWidth) return text;
37
36
 
38
- const targetWidth = maxWidth - 1; // Room for single ellipsis character
37
+ const targetWidth = maxWidth - 1;
39
38
  let result = "";
40
39
  let currentWidth = 0;
41
- let activeStyles: string[] = []; // Track ALL active styles (not just last)
40
+ let activeStyles: string[] = [];
42
41
  let i = 0;
43
42
 
44
43
  while (i < text.length) {
45
- // Check for ANSI escape code
46
44
  const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
47
45
  if (ansiMatch) {
48
46
  const code = ansiMatch[0];
49
47
  result += code;
50
48
 
51
49
  if (code === "\x1b[0m" || code === "\x1b[m") {
52
- activeStyles = []; // Reset clears all styles
50
+ activeStyles = [];
53
51
  } else {
54
- activeStyles.push(code); // Stack styles (bold + color, etc.)
52
+ activeStyles.push(code);
55
53
  }
56
54
  i += code.length;
57
55
  continue;
58
56
  }
59
57
 
60
- // Find end of non-ANSI text segment
61
58
  let end = i;
62
59
  while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
63
60
  end++;
64
61
  }
65
62
 
66
- // Segment into graphemes for proper Unicode handling
67
63
  const textPortion = text.slice(i, end);
68
64
  for (const seg of segmenter.segment(textPortion)) {
69
65
  const grapheme = seg.segment;
70
66
  const graphemeWidth = visibleWidth(grapheme);
71
67
 
72
68
  if (currentWidth + graphemeWidth > targetWidth) {
73
- // Re-apply all active styles before ellipsis to preserve background/colors
74
69
  return result + activeStyles.join("") + "…";
75
70
  }
76
71
 
@@ -80,16 +75,11 @@ function truncLine(text: string, maxWidth: number): string {
80
75
  i = end;
81
76
  }
82
77
 
83
- // Reached end without exceeding width (shouldn't happen given initial check)
84
78
  return result + activeStyles.join("") + "…";
85
79
  }
86
80
 
87
- // Track last rendered widget state to avoid no-op re-renders
88
81
  let lastWidgetHash = "";
89
82
 
90
- /**
91
- * Compute a simple hash of job states for change detection
92
- */
93
83
  function computeWidgetHash(jobs: AsyncJobState[]): string {
94
84
  return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
95
85
  `${job.asyncId}:${job.status}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
@@ -124,13 +114,11 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
124
114
  return;
125
115
  }
126
116
 
127
- // Check if anything changed since last render
128
- // Always re-render if any displayed job is running (output tail updates constantly)
129
117
  const displayedJobs = jobs.slice(0, MAX_WIDGET_JOBS);
130
118
  const hasRunningJobs = displayedJobs.some(job => job.status === "running");
131
119
  const newHash = computeWidgetHash(jobs);
132
120
  if (!hasRunningJobs && newHash === lastWidgetHash) {
133
- return; // Skip re-render, nothing changed
121
+ return;
134
122
  }
135
123
  lastWidgetHash = newHash;
136
124
 
@@ -194,12 +182,12 @@ export function renderSubagentResult(
194
182
  const r = d.results[0];
195
183
  const isRunning = r.progress?.status === "running";
196
184
  const icon = isRunning
197
- ? theme.fg("warning", "...")
185
+ ? theme.fg("warning", "running")
198
186
  : r.detached
199
- ? theme.fg("warning", "")
187
+ ? theme.fg("warning", "detached")
200
188
  : r.exitCode === 0
201
189
  ? theme.fg("success", "ok")
202
- : theme.fg("error", "X");
190
+ : theme.fg("error", "failed");
203
191
  const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
204
192
  const output = r.truncation?.text || getSingleResultOutput(r);
205
193
 
@@ -265,7 +253,7 @@ export function renderSubagentResult(
265
253
  c.addChild(new Text(truncLine(theme.fg("dim", `Skills: ${r.skills.join(", ")}`), w), 0, 0));
266
254
  }
267
255
  if (r.skillsWarning) {
268
- c.addChild(new Text(truncLine(theme.fg("warning", `⚠️ ${r.skillsWarning}`), w), 0, 0));
256
+ c.addChild(new Text(truncLine(theme.fg("warning", `Warning: ${r.skillsWarning}`), w), 0, 0));
269
257
  }
270
258
  if (r.attemptedModels && r.attemptedModels.length > 1) {
271
259
  c.addChild(new Text(truncLine(theme.fg("dim", `Fallbacks: ${r.attemptedModels.join(" → ")}`), w), 0, 0));
@@ -291,12 +279,12 @@ export function renderSubagentResult(
291
279
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
292
280
  );
293
281
  const icon = hasRunning
294
- ? theme.fg("warning", "...")
282
+ ? theme.fg("warning", "running")
295
283
  : hasEmptyWithoutTarget
296
- ? theme.fg("warning", "")
284
+ ? theme.fg("warning", "warning")
297
285
  : ok === d.results.length
298
286
  ? theme.fg("success", "ok")
299
- : theme.fg("error", "X");
287
+ : theme.fg("error", "failed");
300
288
 
301
289
  const totalSummary =
302
290
  d.progressSummary ||
@@ -323,17 +311,11 @@ export function renderSubagentResult(
323
311
 
324
312
  const modeLabel = d.mode;
325
313
  const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
326
- // For parallel-in-chain, show task count (results) for consistency with step display
327
- // For sequential chains, show logical step count
328
314
  const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
329
315
  const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
330
316
  const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : ok + 1;
331
317
  const stepInfo = hasRunning ? ` ${currentStep}/${totalCount}` : ` ${ok}/${totalCount}`;
332
318
 
333
- // Build chain visualization: "scout → planner" with status icons
334
- // Note: Only works correctly for sequential chains. Chains with parallel steps
335
- // (indicated by "[agent1+agent2]" format) have multiple results per step,
336
- // breaking the 1:1 mapping between chainAgents and results.
337
319
  const chainVis = d.chainAgents?.length && !hasParallelInChain
338
320
  ? d.chainAgents
339
321
  .map((agent, i) => {
@@ -345,14 +327,14 @@ export function renderSubagentResult(
345
327
  && hasEmptyTextOutputWithoutOutputTarget(result.task, getSingleResultOutput(result));
346
328
  const isCurrent = i === (d.currentStepIndex ?? d.results.length);
347
329
  const stepIcon = isFailed
348
- ? theme.fg("error", "")
330
+ ? theme.fg("error", "failed")
349
331
  : isEmptyWithoutTarget
350
- ? theme.fg("warning", "")
332
+ ? theme.fg("warning", "warning")
351
333
  : isComplete
352
- ? theme.fg("success", "")
334
+ ? theme.fg("success", "done")
353
335
  : isCurrent && hasRunning
354
- ? theme.fg("warning", "")
355
- : theme.fg("dim", "");
336
+ ? theme.fg("warning", "running")
337
+ : theme.fg("dim", "pending");
356
338
  return `${stepIcon} ${agent}`;
357
339
  })
358
340
  .join(theme.fg("dim", " → "))
@@ -367,15 +349,10 @@ export function renderSubagentResult(
367
349
  0,
368
350
  ),
369
351
  );
370
- // Show chain visualization
371
352
  if (chainVis) {
372
353
  c.addChild(new Text(truncLine(` ${chainVis}`, w), 0, 0));
373
354
  }
374
355
 
375
- // === STATIC STEP LAYOUT (like clarification UI) ===
376
- // Each step gets a fixed section with task/output/status
377
- // Note: For chains with parallel steps, chainAgents indices don't map 1:1 to results
378
- // (parallel steps produce multiple results). Fall back to result-based iteration.
379
356
  const useResultsDirectly = hasParallelInChain || !d.chainAgents?.length;
380
357
  const stepsToShow = useResultsDirectly ? d.results.length : d.chainAgents!.length;
381
358
 
@@ -388,9 +365,8 @@ export function renderSubagentResult(
388
365
  : (d.chainAgents![i] || r?.agent || `step-${i + 1}`);
389
366
 
390
367
  if (!r) {
391
- // Pending step
392
368
  c.addChild(new Text(truncLine(theme.fg("dim", ` Step ${i + 1}: ${agentName}`), w), 0, 0));
393
- c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
369
+ c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
394
370
  c.addChild(new Spacer(1));
395
371
  continue;
396
372
  }
@@ -402,12 +378,12 @@ export function renderSubagentResult(
402
378
 
403
379
  const resultOutput = getSingleResultOutput(r);
404
380
  const statusIcon = rRunning
405
- ? theme.fg("warning", "")
381
+ ? theme.fg("warning", "running")
406
382
  : r.exitCode !== 0
407
- ? theme.fg("error", "")
383
+ ? theme.fg("error", "failed")
408
384
  : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput)
409
- ? theme.fg("warning", "")
410
- : theme.fg("success", "");
385
+ ? theme.fg("warning", "warning")
386
+ : theme.fg("success", "done");
411
387
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
412
388
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
413
389
  const stepHeader = rRunning
@@ -430,7 +406,7 @@ export function renderSubagentResult(
430
406
  c.addChild(new Text(truncLine(theme.fg("dim", ` skills: ${r.skills.join(", ")}`), w), 0, 0));
431
407
  }
432
408
  if (r.skillsWarning) {
433
- c.addChild(new Text(truncLine(theme.fg("warning", ` ⚠️ ${r.skillsWarning}`), w), 0, 0));
409
+ c.addChild(new Text(truncLine(theme.fg("warning", ` Warning: ${r.skillsWarning}`), w), 0, 0));
434
410
  }
435
411
  if (r.attemptedModels && r.attemptedModels.length > 1) {
436
412
  c.addChild(new Text(truncLine(theme.fg("dim", ` fallbacks: ${r.attemptedModels.join(" → ")}`), w), 0, 0));
@@ -440,7 +416,6 @@ export function renderSubagentResult(
440
416
  if (rProg.skills?.length) {
441
417
  c.addChild(new Text(truncLine(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`), w), 0, 0));
442
418
  }
443
- // Current tool for running step
444
419
  if (rProg.currentTool) {
445
420
  const maxToolArgsLen = Math.max(50, w - 20);
446
421
  const toolArgsPreview = rProg.currentToolArgs
@@ -453,7 +428,6 @@ export function renderSubagentResult(
453
428
  : rProg.currentTool;
454
429
  c.addChild(new Text(truncLine(theme.fg("warning", ` > ${toolLine}`), w), 0, 0));
455
430
  }
456
- // Recent tools
457
431
  if (rProg.recentTools?.length) {
458
432
  for (const t of rProg.recentTools.slice(-3)) {
459
433
  const maxArgsLen = Math.max(40, w - 30);
@@ -463,7 +437,6 @@ export function renderSubagentResult(
463
437
  c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${argsPreview}`), w), 0, 0));
464
438
  }
465
439
  }
466
- // Recent output - let truncLine handle truncation entirely
467
440
  const recentLines = (rProg.recentOutput ?? []).slice(-5);
468
441
  for (const line of recentLines) {
469
442
  c.addChild(new Text(truncLine(theme.fg("dim", ` ${line}`), w), 0, 0));
package/single-output.ts CHANGED
@@ -84,11 +84,11 @@ export function finalizeSingleOutput(params: {
84
84
  }): { displayOutput: string; savedPath?: string; saveError?: string } {
85
85
  let displayOutput = params.truncatedOutput || params.fullOutput;
86
86
  if (params.exitCode === 0 && params.savedPath) {
87
- displayOutput += `\n\n📄 Output saved to: ${params.savedPath}`;
87
+ displayOutput += `\n\nOutput saved to: ${params.savedPath}`;
88
88
  return { displayOutput, savedPath: params.savedPath };
89
89
  }
90
90
  if (params.exitCode === 0 && params.saveError && params.outputPath) {
91
- displayOutput += `\n\n⚠️ Failed to save output to: ${params.outputPath}\n${params.saveError}`;
91
+ displayOutput += `\n\nFailed to save output to: ${params.outputPath}\n${params.saveError}`;
92
92
  return { displayOutput, saveError: params.saveError };
93
93
  }
94
94
  return { displayOutput };
@@ -25,7 +25,7 @@ import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "./async
25
25
  import { createForkContextResolver } from "./fork-context.ts";
26
26
  import { applyIntercomBridgeToAgent, resolveIntercomBridge, resolveIntercomSessionTarget } from "./intercom-bridge.ts";
27
27
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.ts";
28
- import { getSingleResultOutput, mapConcurrent } from "./utils.ts";
28
+ import { compactForegroundDetails, getSingleResultOutput, mapConcurrent } from "./utils.ts";
29
29
  import {
30
30
  cleanupWorktrees,
31
31
  createWorktrees,
@@ -885,12 +885,12 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
885
885
 
886
886
  return {
887
887
  content: [{ type: "text", text: fullContent }],
888
- details: {
888
+ details: compactForegroundDetails({
889
889
  mode: "parallel",
890
890
  results,
891
891
  progress: params.includeProgress ? allProgress : undefined,
892
892
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
893
- },
893
+ }),
894
894
  };
895
895
  } finally {
896
896
  if (worktreeSetup) cleanupWorktrees(worktreeSetup);
@@ -1063,37 +1063,37 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1063
1063
  if (r.detached) {
1064
1064
  return {
1065
1065
  content: [{ type: "text", text: `Detached for intercom coordination: ${params.agent}` }],
1066
- details: {
1066
+ details: compactForegroundDetails({
1067
1067
  mode: "single",
1068
1068
  results: [r],
1069
1069
  progress: params.includeProgress ? allProgress : undefined,
1070
1070
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1071
1071
  truncation: r.truncation,
1072
- },
1072
+ }),
1073
1073
  };
1074
1074
  }
1075
1075
 
1076
1076
  if (r.exitCode !== 0)
1077
1077
  return {
1078
1078
  content: [{ type: "text", text: r.error || "Failed" }],
1079
- details: {
1079
+ details: compactForegroundDetails({
1080
1080
  mode: "single",
1081
1081
  results: [r],
1082
1082
  progress: params.includeProgress ? allProgress : undefined,
1083
1083
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1084
1084
  truncation: r.truncation,
1085
- },
1085
+ }),
1086
1086
  isError: true,
1087
1087
  };
1088
1088
  return {
1089
1089
  content: [{ type: "text", text: finalizedOutput.displayOutput || "(no output)" }],
1090
- details: {
1090
+ details: compactForegroundDetails({
1091
1091
  mode: "single",
1092
1092
  results: [r],
1093
1093
  progress: params.includeProgress ? allProgress : undefined,
1094
1094
  artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
1095
1095
  truncation: r.truncation,
1096
- },
1096
+ }),
1097
1097
  };
1098
1098
  }
1099
1099
 
@@ -555,12 +555,12 @@ async function runSingleStep(
555
555
  }
556
556
  if (resolvedOutput.savedPath) {
557
557
  outputForSummary = outputForSummary
558
- ? `${outputForSummary}\n\n📄 Output saved to: ${resolvedOutput.savedPath}`
559
- : `📄 Output saved to: ${resolvedOutput.savedPath}`;
558
+ ? `${outputForSummary}\n\nOutput saved to: ${resolvedOutput.savedPath}`
559
+ : `Output saved to: ${resolvedOutput.savedPath}`;
560
560
  } else if (resolvedOutput.saveError && step.outputPath && finalResult?.exitCode === 0) {
561
561
  outputForSummary = outputForSummary
562
- ? `${outputForSummary}\n\n⚠️ Failed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`
563
- : `⚠️ Failed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`;
562
+ ? `${outputForSummary}\n\nFailed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`
563
+ : `Failed to save output to: ${step.outputPath}\n${resolvedOutput.saveError}`;
564
564
  }
565
565
 
566
566
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
package/types.ts CHANGED
@@ -85,7 +85,7 @@ export interface SingleResult {
85
85
  exitCode: number;
86
86
  detached?: boolean;
87
87
  detachedReason?: string;
88
- messages: Message[];
88
+ messages?: Message[];
89
89
  usage: Usage;
90
90
  model?: string;
91
91
  attemptedModels?: string[];
package/utils.ts CHANGED
@@ -6,13 +6,12 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { Message } from "@mariozechner/pi-ai";
9
- import type { AsyncStatus, DisplayItem, ErrorInfo, SingleResult } from "./types.ts";
9
+ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult } from "./types.ts";
10
10
 
11
11
  // ============================================================================
12
12
  // File System Utilities
13
13
  // ============================================================================
14
14
 
15
- // Cache for status file reads - avoid re-reading unchanged files
16
15
  const statusCache = new Map<string, { mtime: number; status: AsyncStatus }>();
17
16
 
18
17
  function getErrorMessage(error: unknown): string {
@@ -74,7 +73,6 @@ export function readStatus(asyncDir: string): AsyncStatus | null {
74
73
  return status;
75
74
  }
76
75
 
77
- // Cache for output tail reads - avoid re-reading unchanged files
78
76
  const outputTailCache = new Map<string, { mtime: number; size: number; lines: string[] }>();
79
77
 
80
78
  /**
@@ -87,7 +85,6 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
87
85
  const stat = fs.statSync(outputFile);
88
86
  if (stat.size === 0) return [];
89
87
 
90
- // Check cache using both mtime and size (size changes more frequently during writes)
91
88
  const cached = outputTailCache.get(outputFile);
92
89
  if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
93
90
  return cached.lines;
@@ -102,9 +99,7 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
102
99
  const allLines = content.split("\n").filter((l) => l.trim());
103
100
  const lines = allLines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : ""));
104
101
 
105
- // Cache the result
106
102
  outputTailCache.set(outputFile, { mtime: stat.mtimeMs, size: stat.size, lines });
107
- // Limit cache size
108
103
  if (outputTailCache.size > 20) {
109
104
  const firstKey = outputTailCache.keys().next().value;
110
105
  if (firstKey) outputTailCache.delete(firstKey);
@@ -112,12 +107,15 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
112
107
 
113
108
  return lines;
114
109
  } catch {
110
+ // Output tails are UI-only hints; unreadable or missing files should render as no tail.
115
111
  return [];
116
112
  } finally {
117
113
  if (fd !== null) {
118
114
  try {
119
115
  fs.closeSync(fd);
120
- } catch {}
116
+ } catch {
117
+ // Closing the best-effort tail file handle should not surface over the main status view.
118
+ }
121
119
  }
122
120
  }
123
121
  }
@@ -125,16 +123,16 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
125
123
  /**
126
124
  * Get human-readable last activity time for a file
127
125
  */
128
- export function getLastActivity(outputFile: string | undefined): string {
126
+ export function getLastActivity(outputFile: string | undefined): string {
129
127
  if (!outputFile) return "";
130
128
  try {
131
- // Single stat call - throws if file doesn't exist
132
129
  const stat = fs.statSync(outputFile);
133
130
  const ago = Date.now() - stat.mtimeMs;
134
131
  if (ago < 1000) return "active now";
135
132
  if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
136
133
  return `active ${Math.floor(ago / 60000)}m ago`;
137
134
  } catch {
135
+ // Last-activity text is best effort; missing files should simply omit the hint.
138
136
  return "";
139
137
  }
140
138
  }
@@ -201,13 +199,14 @@ export function getFinalOutput(messages: Message[]): string {
201
199
  }
202
200
 
203
201
  export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" | "messages">): string {
204
- return result.finalOutput ?? getFinalOutput(result.messages);
202
+ return result.finalOutput ?? getFinalOutput(result.messages ?? []);
205
203
  }
206
204
 
207
205
  /**
208
206
  * Extract display items (text and tool calls) from messages
209
207
  */
210
- export function getDisplayItems(messages: Message[]): DisplayItem[] {
208
+ export function getDisplayItems(messages: Message[] | undefined): DisplayItem[] {
209
+ if (!messages || messages.length === 0) return [];
211
210
  const items: DisplayItem[] = [];
212
211
  for (const msg of messages) {
213
212
  if (msg.role === "assistant") {
@@ -220,6 +219,43 @@ export function getDisplayItems(messages: Message[]): DisplayItem[] {
220
219
  return items;
221
220
  }
222
221
 
222
+ function compactCompletedProgress(progress: AgentProgress): AgentProgress {
223
+ if (progress.status === "running") return progress;
224
+ return {
225
+ index: progress.index,
226
+ agent: progress.agent,
227
+ status: progress.status,
228
+ task: progress.task,
229
+ skills: progress.skills,
230
+ toolCount: progress.toolCount,
231
+ tokens: progress.tokens,
232
+ durationMs: progress.durationMs,
233
+ error: progress.error,
234
+ failedTool: progress.failedTool,
235
+ recentTools: [],
236
+ recentOutput: [],
237
+ };
238
+ }
239
+
240
+ export function compactForegroundResult(result: SingleResult): SingleResult {
241
+ if (result.progress?.status === "running") return result;
242
+ return {
243
+ ...result,
244
+ messages: undefined,
245
+ progress: undefined,
246
+ };
247
+ }
248
+
249
+ export function compactForegroundDetails(details: Details): Details {
250
+ return {
251
+ ...details,
252
+ results: details.results.map(compactForegroundResult),
253
+ progress: details.progress
254
+ ? details.progress.map(compactCompletedProgress)
255
+ : undefined,
256
+ };
257
+ }
258
+
223
259
  /**
224
260
  * Detect errors in subagent execution from messages (only errors with no subsequent success)
225
261
  */