pi-subagents 0.21.4 → 0.21.5

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.21.5] - 2026-05-02
6
+
7
+ ### Fixed
8
+ - Show top-level async parallel runs as `parallel` instead of `chain`, with foreground-style running/done wording in widgets and status output, and group running async chain detail by chain step.
9
+ - Scoped `/subagents-status` to async runs launched from the current pi session instead of showing prior or unrelated sessions.
10
+ - Declared the Pi TUI package as a direct dev dependency and added a manifest guard so CI installs do not rely on transitive optional peer dependencies for tests.
11
+ - Made prompt-runtime extension path assertions portable on Windows.
12
+
5
13
  ## [0.21.4] - 2026-05-01
6
14
 
7
15
  ### Added
package/README.md CHANGED
@@ -150,12 +150,14 @@ Use `~/.pi/agent/settings.json` for a user override or `.pi/settings.json` for a
150
150
 
151
151
  Foreground runs stream progress in the conversation while they run.
152
152
 
153
- Background runs keep working after control returns to you. They show completion notifications and can be inspected with:
153
+ Background runs keep working after control returns to you. They show a compact async widget, send completion notifications, and can be inspected with:
154
154
 
155
155
  ```text
156
156
  /subagents-status
157
157
  ```
158
158
 
159
+ The status view shows active and recent runs for the current Pi session. Parallel background runs are shown as parallel work, with per-agent progress instead of fake chain steps. Chains with parallel groups keep their grouped shape in both progress and results views, so failed or paused agents stay visible next to completed ones.
160
+
159
161
  You can also ask naturally:
160
162
 
161
163
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.21.4",
3
+ "version": "0.21.5",
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",
@@ -77,6 +77,7 @@
77
77
  "devDependencies": {
78
78
  "@mariozechner/pi-agent-core": "^0.65.0",
79
79
  "@mariozechner/pi-ai": "^0.65.0",
80
- "@mariozechner/pi-coding-agent": "^0.65.0"
80
+ "@mariozechner/pi-coding-agent": "^0.65.0",
81
+ "@mariozechner/pi-tui": "^0.65.2"
81
82
  }
82
83
  }
@@ -20,6 +20,7 @@ import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@
20
20
  import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@mariozechner/pi-tui";
21
21
  import { discoverAgents } from "../agents/agents.ts";
22
22
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
23
+ import { resolveCurrentSessionId } from "../shared/session-identity.ts";
23
24
  import { cleanupOldChainDirs } from "../shared/settings.ts";
24
25
  import { renderWidget, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, syncResultAnimation } from "../tui/render.ts";
25
26
  import { SubagentParams } from "./schemas.ts";
@@ -271,6 +272,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
271
272
  const runtimeCleanup = () => {
272
273
  stopWidgetAnimation();
273
274
  stopResultAnimations();
275
+ stopResultWatcher();
274
276
  clearPendingForegroundControlNotices(state);
275
277
  if (state.poller) {
276
278
  clearInterval(state.poller);
@@ -523,12 +525,13 @@ DIAGNOSTICS:
523
525
 
524
526
  const resetSessionState = (ctx: ExtensionContext) => {
525
527
  state.baseCwd = ctx.cwd;
526
- state.currentSessionId = ctx.sessionManager.getSessionFile() ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
528
+ state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
527
529
  state.lastUiContext = ctx;
528
530
  cleanupSessionArtifacts(ctx);
529
531
  clearPendingForegroundControlNotices(state);
530
532
  resetJobs(ctx);
531
533
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
534
+ primeExistingResults();
532
535
  };
533
536
 
534
537
  pi.on("session_start", (_event, ctx) => {
@@ -6,6 +6,7 @@ import {
6
6
  type SubagentResultIntercomChild,
7
7
  type SubagentResultIntercomPayload,
8
8
  type SubagentResultStatus,
9
+ type SubagentRunMode,
9
10
  SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT,
10
11
  SUBAGENT_RESULT_INTERCOM_EVENT,
11
12
  } from "../shared/types.ts";
@@ -61,7 +62,7 @@ function resolveGroupedStatus(children: SubagentResultIntercomChild[]): Subagent
61
62
  interface GroupedResultIntercomMessageInput {
62
63
  to: string;
63
64
  runId: string;
64
- mode: "single" | "parallel" | "chain";
65
+ mode: SubagentRunMode;
65
66
  source: "foreground" | "async";
66
67
  children: SubagentResultIntercomChild[];
67
68
  asyncId?: string;
@@ -84,7 +85,7 @@ function asyncResumeGuidance(input: {
84
85
 
85
86
  function formatSubagentResultIntercomMessage(input: {
86
87
  runId: string;
87
- mode: "single" | "parallel" | "chain";
88
+ mode: SubagentRunMode;
88
89
  status: SubagentResultStatus;
89
90
  source: "foreground" | "async";
90
91
  children: SubagentResultIntercomChild[];
@@ -218,7 +219,7 @@ export function stripDetailsOutputsForIntercomReceipt(details: Details): Details
218
219
  }
219
220
 
220
221
  export function formatSubagentResultReceipt(input: {
221
- mode: "single" | "parallel" | "chain";
222
+ mode: SubagentRunMode;
222
223
  runId: string;
223
224
  payload: SubagentResultIntercomPayload;
224
225
  }): string {
@@ -24,6 +24,7 @@ import {
24
24
  type Details,
25
25
  type MaxOutputConfig,
26
26
  type ResolvedControlConfig,
27
+ type SubagentRunMode,
27
28
  ASYNC_DIR,
28
29
  RESULTS_DIR,
29
30
  SUBAGENT_ASYNC_STARTED_EVENT,
@@ -64,7 +65,7 @@ interface AsyncExecutionContext {
64
65
 
65
66
  interface AsyncChainParams {
66
67
  chain: ChainStep[];
67
- resultMode?: "parallel" | "chain";
68
+ resultMode?: Exclude<SubagentRunMode, "single">;
68
69
  agents: AgentConfig[];
69
70
  ctx: AsyncExecutionContext;
70
71
  availableModels?: AvailableModelInfo[];
@@ -160,7 +161,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
160
161
  return { pid: proc.pid };
161
162
  }
162
163
 
163
- function formatAsyncStartError(mode: "single" | "chain", message: string): AsyncExecutionResult {
164
+ function formatAsyncStartError(mode: SubagentRunMode, message: string): AsyncExecutionResult {
164
165
  return {
165
166
  content: [{ type: "text", text: message }],
166
167
  isError: true,
@@ -198,6 +199,7 @@ export function executeAsyncChain(
198
199
  controlIntercomTarget,
199
200
  childIntercomTarget,
200
201
  } = params;
202
+ const resultMode = params.resultMode ?? "chain";
201
203
  const chainSkills = params.chainSkills ?? [];
202
204
  const availableModels = params.availableModels;
203
205
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
@@ -211,7 +213,7 @@ export function executeAsyncChain(
211
213
  return {
212
214
  content: [{ type: "text", text: `Unknown agent: ${agentName}` }],
213
215
  isError: true,
214
- details: { mode: "chain" as const, results: [] },
216
+ details: { mode: resultMode, results: [] },
215
217
  };
216
218
  }
217
219
  }
@@ -225,7 +227,7 @@ export function executeAsyncChain(
225
227
  return {
226
228
  content: [{ type: "text", text: `Failed to create async run directory '${asyncDir}': ${message}` }],
227
229
  isError: true,
228
- details: { mode: "chain" as const, results: [] },
230
+ details: { mode: resultMode, results: [] },
229
231
  };
230
232
  }
231
233
 
@@ -329,7 +331,7 @@ export function executeAsyncChain(
329
331
  return buildSeqStep(s as SequentialStep, nextSessionFile());
330
332
  });
331
333
  } catch (error) {
332
- if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return formatAsyncStartError("chain", error.message);
334
+ if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return formatAsyncStartError(resultMode, error.message);
333
335
  throw error;
334
336
  }
335
337
  let childTargetIndex = 0;
@@ -363,18 +365,18 @@ export function executeAsyncChain(
363
365
  controlConfig,
364
366
  controlIntercomTarget,
365
367
  childIntercomTargets,
366
- resultMode: params.resultMode ?? "chain",
368
+ resultMode,
367
369
  },
368
370
  id,
369
371
  runnerCwd,
370
372
  );
371
373
  } catch (error) {
372
374
  const message = error instanceof Error ? error.message : String(error);
373
- return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${message}`);
375
+ return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${message}`);
374
376
  }
375
377
 
376
378
  if (spawnResult.error) {
377
- return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${spawnResult.error}`);
379
+ return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${spawnResult.error}`);
378
380
  }
379
381
 
380
382
  if (spawnResult.pid) {
@@ -399,6 +401,8 @@ export function executeAsyncChain(
399
401
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
400
402
  id,
401
403
  pid: spawnResult.pid,
404
+ sessionId: ctx.currentSessionId,
405
+ mode: resultMode,
402
406
  agent: firstAgents[0],
403
407
  agents: flatAgents,
404
408
  task: isParallelStep(firstStep)
@@ -421,8 +425,8 @@ export function executeAsyncChain(
421
425
  .join(" -> ");
422
426
 
423
427
  return {
424
- content: [{ type: "text", text: `Async chain: ${chainDesc} [${id}]` }],
425
- details: { mode: "chain", results: [], asyncId: id, asyncDir },
428
+ content: [{ type: "text", text: `Async ${resultMode}: ${chainDesc} [${id}]` }],
429
+ details: { mode: resultMode, results: [], asyncId: id, asyncDir },
426
430
  };
427
431
  }
428
432
 
@@ -543,6 +547,8 @@ export function executeAsyncSingle(
543
547
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
544
548
  id,
545
549
  pid: spawnResult.pid,
550
+ sessionId: ctx.currentSessionId,
551
+ mode: "single",
546
552
  agent,
547
553
  task: task?.slice(0, 50),
548
554
  cwd: runnerCwd,
@@ -5,7 +5,6 @@ import { renderWidget } from "../../tui/render.ts";
5
5
  import { formatControlNoticeMessage } from "../shared/subagent-control.ts";
6
6
  import {
7
7
  type AsyncJobState,
8
- type AsyncParallelGroupStatus,
9
8
  type AsyncStartedEvent,
10
9
  type ControlEvent,
11
10
  type SubagentState,
@@ -15,25 +14,9 @@ import {
15
14
  SUBAGENT_CONTROL_INTERCOM_EVENT,
16
15
  } from "../../shared/types.ts";
17
16
  import { readStatus } from "../../shared/utils.ts";
17
+ import { normalizeParallelGroups } from "./parallel-groups.ts";
18
18
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
19
19
 
20
-
21
- function isValidParallelGroup(group: AsyncParallelGroupStatus, stepCount: number, chainStepCount: number): boolean {
22
- return Number.isInteger(group.start)
23
- && Number.isInteger(group.count)
24
- && Number.isInteger(group.stepIndex)
25
- && group.start >= 0
26
- && group.count > 0
27
- && group.stepIndex >= 0
28
- && group.stepIndex < chainStepCount
29
- && group.start + group.count <= stepCount;
30
- }
31
-
32
- function normalizeParallelGroups(groups: AsyncParallelGroupStatus[] | undefined, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
33
- if (!groups?.length) return [];
34
- return groups.filter((group) => isValidParallelGroup(group, stepCount, chainStepCount));
35
- }
36
-
37
20
  interface AsyncJobTrackerOptions {
38
21
  completionRetentionMs?: number;
39
22
  pollIntervalMs?: number;
@@ -145,8 +128,11 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
145
128
  startedRun: {
146
129
  runId: job.asyncId,
147
130
  pid: job.pid,
131
+ sessionId: job.sessionId,
148
132
  mode: job.mode,
149
133
  agents: job.agents,
134
+ chainStepCount: job.chainStepCount,
135
+ parallelGroups: job.parallelGroups,
150
136
  startedAt: job.startedAt,
151
137
  sessionFile: job.sessionFile,
152
138
  },
@@ -155,6 +141,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
155
141
  if (status) {
156
142
  const previousStatus = job.status;
157
143
  job.status = status.state;
144
+ job.sessionId = status.sessionId ?? job.sessionId;
158
145
  job.activityState = status.activityState;
159
146
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
160
147
  job.currentTool = status.currentTool;
@@ -164,10 +151,12 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
164
151
  job.toolCount = status.toolCount ?? job.toolCount;
165
152
  job.mode = status.mode;
166
153
  job.currentStep = status.currentStep ?? job.currentStep;
154
+ job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
167
155
  job.startedAt = status.startedAt ?? job.startedAt;
168
156
  job.updatedAt = status.lastUpdate ?? Date.now();
169
157
  if (status.steps?.length) {
170
158
  const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
159
+ job.parallelGroups = groups.length ? groups : job.parallelGroups;
171
160
  job.hasParallelGroups = groups.length > 0 || job.hasParallelGroups;
172
161
  const activeGroup = status.currentStep !== undefined
173
162
  ? groups.find((group) => status.currentStep! >= group.start && status.currentStep! < group.start + group.count)
@@ -177,6 +166,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
177
166
  : status.steps;
178
167
  job.activeParallelGroup = Boolean(activeGroup);
179
168
  job.agents = visibleSteps.map((step) => step.agent);
169
+ job.steps = visibleSteps;
180
170
  job.stepsTotal = visibleSteps.length;
181
171
  job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
182
172
  job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
@@ -225,8 +215,11 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
225
215
  asyncDir,
226
216
  status: "queued",
227
217
  pid: typeof info.pid === "number" ? info.pid : undefined,
228
- mode: info.chain ? "chain" : "single",
218
+ ...(typeof info.sessionId === "string" ? { sessionId: info.sessionId } : {}),
219
+ mode: info.mode ?? (info.chain ? "chain" : "single"),
229
220
  agents,
221
+ chainStepCount: info.chainStepCount,
222
+ parallelGroups: validParallelGroups,
230
223
  stepsTotal: firstGroupCount ?? agents?.length,
231
224
  hasParallelGroups: validParallelGroups.length > 0,
232
225
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
@@ -202,6 +202,7 @@ function resultState(result: AsyncResultFile): AsyncStatus["state"] {
202
202
  function validateStatusForResume(status: AsyncStatus | null, source: string): void {
203
203
  if (!status) return;
204
204
  if (typeof status.runId !== "string") throw new Error(`Invalid async status '${source}': runId must be a string.`);
205
+ if (status.sessionId !== undefined && typeof status.sessionId !== "string") throw new Error(`Invalid async status '${source}': sessionId must be a string.`);
205
206
  if (status.cwd !== undefined && typeof status.cwd !== "string") throw new Error(`Invalid async status '${source}': cwd must be a string.`);
206
207
  if (status.sessionFile !== undefined && typeof status.sessionFile !== "string") throw new Error(`Invalid async status '${source}': sessionFile must be a string.`);
207
208
  if (status.steps !== undefined) {
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
- import { type ActivityState, type AsyncParallelGroupStatus, type AsyncStatus, type TokenUsage } from "../../shared/types.ts";
4
+ import { type ActivityState, type AsyncParallelGroupStatus, type AsyncStatus, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
5
  import { readStatus } from "../../shared/utils.ts";
6
+ import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
6
7
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
7
8
 
8
9
  interface AsyncRunStepSummary {
@@ -27,6 +28,7 @@ interface AsyncRunStepSummary {
27
28
  export interface AsyncRunSummary {
28
29
  id: string;
29
30
  asyncDir: string;
31
+ sessionId?: string;
30
32
  state: "queued" | "running" | "complete" | "failed" | "paused";
31
33
  activityState?: ActivityState;
32
34
  lastActivityAt?: number;
@@ -35,7 +37,7 @@ export interface AsyncRunSummary {
35
37
  currentPath?: string;
36
38
  turnCount?: number;
37
39
  toolCount?: number;
38
- mode: "single" | "chain";
40
+ mode: SubagentRunMode;
39
41
  cwd?: string;
40
42
  startedAt: number;
41
43
  lastUpdate?: number;
@@ -50,45 +52,9 @@ export interface AsyncRunSummary {
50
52
  sessionFile?: string;
51
53
  }
52
54
 
53
- function isValidParallelGroup(group: AsyncParallelGroupStatus, stepCount: number, chainStepCount: number): boolean {
54
- return Number.isInteger(group.start)
55
- && Number.isInteger(group.count)
56
- && Number.isInteger(group.stepIndex)
57
- && group.start >= 0
58
- && group.count > 0
59
- && group.stepIndex >= 0
60
- && group.stepIndex < chainStepCount
61
- && group.start + group.count <= stepCount;
62
- }
63
-
64
- function normalizeParallelGroups(groups: AsyncParallelGroupStatus[] | undefined, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
65
- if (!groups?.length) return [];
66
- return groups.filter((group) => isValidParallelGroup(group, stepCount, chainStepCount));
67
- }
68
-
69
- function flatToLogicalStepIndex(flatIndex: number, chainStepCount: number, parallelGroups: AsyncParallelGroupStatus[]): number {
70
- let logicalIndex = 0;
71
- let cursor = 0;
72
- for (const group of parallelGroups) {
73
- while (logicalIndex < chainStepCount && cursor < group.start) {
74
- if (flatIndex === cursor) return logicalIndex;
75
- logicalIndex++;
76
- cursor++;
77
- }
78
- if (flatIndex >= group.start && flatIndex < group.start + group.count) return group.stepIndex;
79
- logicalIndex = Math.max(logicalIndex, group.stepIndex + 1);
80
- cursor = group.start + group.count;
81
- }
82
- while (logicalIndex < chainStepCount) {
83
- if (flatIndex === cursor) return logicalIndex;
84
- logicalIndex++;
85
- cursor++;
86
- }
87
- return Math.max(0, chainStepCount - 1);
88
- }
89
-
90
55
  interface AsyncRunListOptions {
91
56
  states?: Array<AsyncRunSummary["state"]>;
57
+ sessionId?: string;
92
58
  limit?: number;
93
59
  resultsDir?: string;
94
60
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
@@ -101,6 +67,11 @@ export interface AsyncRunOverlayData {
101
67
  recent: AsyncRunSummary[];
102
68
  }
103
69
 
70
+ export interface AsyncRunOverlayOptions {
71
+ recentLimit?: number;
72
+ sessionId?: string;
73
+ }
74
+
104
75
  function getErrorMessage(error: unknown): string {
105
76
  return error instanceof Error ? error.message : String(error);
106
77
  }
@@ -147,6 +118,9 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
147
118
  }
148
119
 
149
120
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }): AsyncRunSummary {
121
+ if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
122
+ throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
123
+ }
150
124
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
151
125
  const steps = status.steps ?? [];
152
126
  const chainStepCount = status.chainStepCount ?? steps.length;
@@ -154,6 +128,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
154
128
  return {
155
129
  id: status.runId || path.basename(asyncDir),
156
130
  asyncDir,
131
+ ...(status.sessionId ? { sessionId: status.sessionId } : {}),
157
132
  state: status.state,
158
133
  activityState,
159
134
  lastActivityAt,
@@ -240,6 +215,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
240
215
  if (!status) continue;
241
216
  const summary = statusToSummary(asyncDir, status);
242
217
  if (allowedStates && !allowedStates.has(summary.state)) continue;
218
+ if (options.sessionId && summary.sessionId !== options.sessionId) continue;
243
219
  runs.push(summary);
244
220
  }
245
221
 
@@ -247,8 +223,9 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
247
223
  return options.limit !== undefined ? sorted.slice(0, options.limit) : sorted;
248
224
  }
249
225
 
250
- export function listAsyncRunsForOverlay(asyncDirRoot: string, recentLimit = 5): AsyncRunOverlayData {
251
- const all = listAsyncRuns(asyncDirRoot);
226
+ export function listAsyncRunsForOverlay(asyncDirRoot: string, options: AsyncRunOverlayOptions = {}): AsyncRunOverlayData {
227
+ const recentLimit = options.recentLimit ?? 5;
228
+ const all = listAsyncRuns(asyncDirRoot, { sessionId: options.sessionId });
252
229
  const recent = all
253
230
  .filter((run) => run.state === "complete" || run.state === "failed" || run.state === "paused")
254
231
  .sort((a, b) => (b.lastUpdate ?? b.endedAt ?? b.startedAt) - (a.lastUpdate ?? a.endedAt ?? a.startedAt))
@@ -287,6 +264,18 @@ function formatStepLine(step: AsyncRunStepSummary): string {
287
264
  return parts.join(" | ");
288
265
  }
289
266
 
267
+ function formatParallelProgress(steps: Pick<AsyncRunStepSummary, "status">[], total: number, showRunning: boolean): string {
268
+ const running = steps.filter((step) => step.status === "running").length;
269
+ const done = steps.filter((step) => step.status === "complete" || step.status === "completed").length;
270
+ const failed = steps.filter((step) => step.status === "failed").length;
271
+ const paused = steps.filter((step) => step.status === "paused").length;
272
+ const parts = [`${done}/${total} done`];
273
+ if (showRunning) parts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
274
+ if (failed > 0) parts.push(`${failed} failed`);
275
+ if (paused > 0) parts.push(`${paused} paused`);
276
+ return parts.join(" · ");
277
+ }
278
+
290
279
  export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" | "state" | "currentStep" | "chainStepCount" | "parallelGroups" | "steps">): string {
291
280
  const stepCount = run.steps.length || 1;
292
281
  const chainStepCount = run.chainStepCount ?? stepCount;
@@ -294,16 +283,13 @@ export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" |
294
283
  const activeGroup = run.currentStep !== undefined
295
284
  ? groups.find((group) => run.currentStep! >= group.start && run.currentStep! < group.start + group.count)
296
285
  : undefined;
297
- if (run.mode === "chain" && activeGroup) {
286
+ if (activeGroup) {
298
287
  const groupSteps = run.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count);
299
- const running = groupSteps.filter((step) => step.status === "running").length;
300
- const done = groupSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
301
- const runningLabel = running === 1 ? "1 agent running" : `${running} agents running`;
302
- const groupLabel = run.state === "running"
303
- ? `parallel group: ${runningLabel} · ${done}/${activeGroup.count} done`
304
- : `parallel group: ${done}/${activeGroup.count} done`;
305
- return `step ${activeGroup.stepIndex + 1}/${chainStepCount} · ${groupLabel}`;
288
+ const groupLabel = formatParallelProgress(groupSteps, activeGroup.count, run.state === "running");
289
+ if (run.mode === "parallel") return groupLabel;
290
+ return `step ${activeGroup.stepIndex + 1}/${chainStepCount} · parallel group: ${groupLabel}`;
306
291
  }
292
+ if (run.mode === "parallel") return formatParallelProgress(run.steps, stepCount, run.state === "running");
307
293
  if (run.mode === "chain" && run.currentStep !== undefined && groups.length > 0) {
308
294
  const logicalStep = flatToLogicalStepIndex(run.currentStep, chainStepCount, groups);
309
295
  return `step ${logicalStep + 1}/${chainStepCount}`;
@@ -0,0 +1,45 @@
1
+ import type { AsyncParallelGroupStatus } from "../../shared/types.ts";
2
+
3
+ function isValidParallelGroup(group: unknown, stepCount: number, chainStepCount: number): group is AsyncParallelGroupStatus {
4
+ if (typeof group !== "object" || group === null) return false;
5
+ const { start, count, stepIndex } = group as Partial<AsyncParallelGroupStatus>;
6
+ return typeof start === "number"
7
+ && typeof count === "number"
8
+ && typeof stepIndex === "number"
9
+ && Number.isInteger(start)
10
+ && Number.isInteger(count)
11
+ && Number.isInteger(stepIndex)
12
+ && start >= 0
13
+ && count > 0
14
+ && stepIndex >= 0
15
+ && stepIndex < chainStepCount
16
+ && start + count <= stepCount;
17
+ }
18
+
19
+ export function normalizeParallelGroups(groups: unknown, stepCount: number, chainStepCount: number): AsyncParallelGroupStatus[] {
20
+ if (!Array.isArray(groups)) return [];
21
+ return groups
22
+ .filter((group): group is AsyncParallelGroupStatus => isValidParallelGroup(group, stepCount, chainStepCount))
23
+ .sort((left, right) => left.stepIndex - right.stepIndex || left.start - right.start);
24
+ }
25
+
26
+ export function flatToLogicalStepIndex(flatIndex: number, chainStepCount: number, groups: AsyncParallelGroupStatus[]): number {
27
+ let logicalIndex = 0;
28
+ let cursor = 0;
29
+ for (const group of groups) {
30
+ while (cursor < group.start && logicalIndex < chainStepCount) {
31
+ if (cursor === flatIndex) return logicalIndex;
32
+ cursor++;
33
+ logicalIndex++;
34
+ }
35
+ if (flatIndex >= group.start && flatIndex < group.start + group.count) return group.stepIndex;
36
+ cursor = group.start + group.count;
37
+ logicalIndex = group.stepIndex + 1;
38
+ }
39
+ while (cursor <= flatIndex && logicalIndex < chainStepCount) {
40
+ if (cursor === flatIndex) return logicalIndex;
41
+ cursor++;
42
+ logicalIndex++;
43
+ }
44
+ return Math.max(0, chainStepCount - 1);
45
+ }
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
3
  import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
5
4
  import { createFileCoalescer } from "../../shared/file-coalescer.ts";
6
5
  import {
7
6
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
+ type IntercomEventBus,
8
8
  type SubagentState,
9
9
  } from "../../shared/types.ts";
10
10
  import {
@@ -46,7 +46,7 @@ function shouldFallBackToPolling(error: unknown): boolean {
46
46
  }
47
47
 
48
48
  export function createResultWatcher(
49
- pi: ExtensionAPI,
49
+ pi: { events: IntercomEventBus },
50
50
  state: SubagentState,
51
51
  resultsDir: string,
52
52
  completionTtlMs: number,
@@ -1,10 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
4
- import { formatAsyncRunList, listAsyncRuns } from "./async-status.ts";
5
- import { ASYNC_DIR, RESULTS_DIR, type Details } from "../../shared/types.ts";
4
+ import { formatAsyncRunList, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
5
+ import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details } from "../../shared/types.ts";
6
6
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
7
7
  import { resolveAsyncRunLocation } from "./async-resume.ts";
8
+ import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
8
9
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
9
10
 
10
11
  interface RunStatusParams {
@@ -31,6 +32,19 @@ function canShowRevive(stepCount: number, sessionFile: unknown): sessionFile is
31
32
  return stepCount === 1 && typeof sessionFile === "string" && fs.existsSync(sessionFile);
32
33
  }
33
34
 
35
+ function stepLineLabel(status: AsyncStatus, index: number): string {
36
+ const steps = status.steps ?? [];
37
+ if (status.mode === "parallel") return `Agent ${index + 1}/${steps.length || 1}`;
38
+ if (status.mode === "chain") {
39
+ const chainStepCount = status.chainStepCount ?? (steps.length || 1);
40
+ const groups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
41
+ const group = groups.find((candidate) => index >= candidate.start && index < candidate.start + candidate.count);
42
+ if (group) return `Step ${group.stepIndex + 1}/${chainStepCount} Agent ${index - group.start + 1}/${group.count}`;
43
+ return `Step ${flatToLogicalStepIndex(index, chainStepCount, groups) + 1}/${chainStepCount}`;
44
+ }
45
+ return `Step ${index + 1}`;
46
+ }
47
+
34
48
  export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDeps = {}): AgentToolResult<Details> {
35
49
  const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
36
50
  const resultsDir = deps.resultsDir ?? RESULTS_DIR;
@@ -89,9 +103,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
89
103
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
90
104
  const eventsPath = path.join(asyncDir, "events.jsonl");
91
105
  if (status) {
92
- const stepsTotal = status.steps?.length ?? 1;
93
- const current = status.currentStep !== undefined ? status.currentStep + 1 : undefined;
94
- const stepLine = current !== undefined ? `Step: ${current}/${stepsTotal}` : `Steps: ${stepsTotal}`;
106
+ const progressLabel = formatAsyncRunProgressLabel({
107
+ mode: status.mode,
108
+ state: status.state,
109
+ currentStep: status.currentStep,
110
+ chainStepCount: status.chainStepCount,
111
+ parallelGroups: status.parallelGroups,
112
+ steps: (status.steps ?? []).map((step, index) => ({ index, agent: step.agent, status: step.status })),
113
+ });
95
114
  const started = new Date(status.startedAt).toISOString();
96
115
  const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
97
116
  const statusActivityText = status.state === "running" ? activityText(status.activityState, status.lastActivityAt) : undefined;
@@ -101,7 +120,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
101
120
  `State: ${status.state}`,
102
121
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
103
122
  `Mode: ${status.mode}`,
104
- stepLine,
123
+ `Progress: ${progressLabel}`,
105
124
  `Started: ${started}`,
106
125
  `Updated: ${updated}`,
107
126
  `Dir: ${asyncDir}`,
@@ -111,7 +130,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
111
130
  for (const [index, step] of (status.steps ?? []).entries()) {
112
131
  const stepActivityText = step.status === "running" ? activityText(step.activityState, step.lastActivityAt) : undefined;
113
132
  const errorText = step.error ? `, error: ${step.error}` : "";
114
- lines.push(`Step ${index + 1}: ${step.agent} ${step.status}${stepActivityText ? `, ${stepActivityText}` : ""}${errorText}`);
133
+ lines.push(`${stepLineLabel(status, index)}: ${step.agent} ${step.status}${stepActivityText ? `, ${stepActivityText}` : ""}${errorText}`);
115
134
  if (step.status === "running") {
116
135
  lines.push(` Intercom target: ${resolveSubagentIntercomTarget(status.runId, step.agent, index)} (if registered)`);
117
136
  }
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
- import { RESULTS_DIR, type AsyncStatus } from "../../shared/types.ts";
4
+ import { RESULTS_DIR, type AsyncParallelGroupStatus, type AsyncStatus, type SubagentRunMode } from "../../shared/types.ts";
5
+ import { normalizeParallelGroups } from "./parallel-groups.ts";
5
6
 
6
7
  export type PidLiveness = "alive" | "dead" | "unknown";
7
8
 
@@ -10,8 +11,11 @@ type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
10
11
  interface StartedRunMetadata {
11
12
  runId: string;
12
13
  pid?: number;
13
- mode?: "single" | "chain";
14
+ sessionId?: string;
15
+ mode?: SubagentRunMode;
14
16
  agents?: string[];
17
+ chainStepCount?: number;
18
+ parallelGroups?: AsyncParallelGroupStatus[];
15
19
  startedAt?: number;
16
20
  sessionFile?: string;
17
21
  }
@@ -133,13 +137,21 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
133
137
  function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, now: number): AsyncStatus {
134
138
  const startedAt = startedRun.startedAt ?? now;
135
139
  const agents = startedRun.agents?.length ? startedRun.agents : ["subagent"];
140
+ const chainStepCount = startedRun.chainStepCount;
141
+ const parallelGroups = chainStepCount !== undefined
142
+ ? normalizeParallelGroups(startedRun.parallelGroups, agents.length, chainStepCount)
143
+ : [];
136
144
  return {
137
145
  runId: startedRun.runId || path.basename(asyncDir),
146
+ ...(startedRun.sessionId ? { sessionId: startedRun.sessionId } : {}),
138
147
  mode: startedRun.mode ?? "single",
139
148
  state: "running",
140
149
  pid: startedRun.pid,
141
150
  startedAt,
142
151
  lastUpdate: now,
152
+ currentStep: 0,
153
+ ...(chainStepCount !== undefined ? { chainStepCount } : {}),
154
+ ...(parallelGroups.length ? { parallelGroups } : {}),
143
155
  steps: agents.map((agent) => ({
144
156
  agent,
145
157
  status: "running" as const,
@@ -197,6 +209,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
197
209
  timestamp: now,
198
210
  durationMs: Math.max(0, now - status.startedAt),
199
211
  asyncDir,
212
+ sessionId: status.sessionId,
200
213
  sessionFile: status.sessionFile,
201
214
  },
202
215
  };
@@ -16,6 +16,7 @@ import {
16
16
  type AsyncStatus,
17
17
  type ModelAttempt,
18
18
  type ResolvedControlConfig,
19
+ type SubagentRunMode,
19
20
  type Usage,
20
21
  DEFAULT_MAX_OUTPUT,
21
22
  type MaxOutputConfig,
@@ -90,7 +91,7 @@ interface SubagentRunConfig {
90
91
  controlConfig?: ResolvedControlConfig;
91
92
  controlIntercomTarget?: string;
92
93
  childIntercomTargets?: Array<string | undefined>;
93
- resultMode?: "single" | "parallel" | "chain";
94
+ resultMode?: SubagentRunMode;
94
95
  }
95
96
 
96
97
  interface StepResult {
@@ -472,7 +473,7 @@ function writeRunLog(
472
473
  logPath: string,
473
474
  input: {
474
475
  id: string;
475
- mode: "single" | "chain";
476
+ mode: SubagentRunMode;
476
477
  cwd: string;
477
478
  startedAt: number;
478
479
  endedAt: number;
@@ -877,7 +878,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
877
878
  || flatSteps.some((step) => Boolean(step.sessionFile));
878
879
  const statusPayload: RunnerStatusPayload = {
879
880
  runId: id,
880
- mode: flatSteps.length > 1 ? "chain" : "single",
881
+ ...(config.sessionId ? { sessionId: config.sessionId } : {}),
882
+ mode: config.resultMode ?? (flatSteps.length > 1 ? "chain" : "single"),
881
883
  state: "running",
882
884
  lastActivityAt: overallStartTime,
883
885
  startedAt: overallStartTime,
@@ -29,6 +29,7 @@ import {
29
29
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
30
30
  import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "../background/async-execution.ts";
31
31
  import { createForkContextResolver } from "../../shared/fork-context.ts";
32
+ import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
32
33
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
33
34
  import { formatControlIntercomMessage, formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
34
35
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
@@ -65,6 +66,7 @@ import {
65
66
  type MaxOutputConfig,
66
67
  type ResolvedControlConfig,
67
68
  type SingleResult,
69
+ type SubagentRunMode,
68
70
  type SubagentState,
69
71
  DEFAULT_ARTIFACT_CONFIG,
70
72
  SUBAGENT_ACTIONS,
@@ -332,7 +334,7 @@ async function resumeAsyncRun(input: {
332
334
  }
333
335
 
334
336
  const parentSessionFile = input.ctx.sessionManager.getSessionFile() ?? null;
335
- input.deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
337
+ input.deps.state.currentSessionId = resolveCurrentSessionId(input.ctx.sessionManager);
336
338
  const effectiveCwd = target.cwd ?? input.requestCwd;
337
339
  const scope: AgentScope = resolveExecutionAgentScope(input.params.agentScope);
338
340
  const discoveredAgents = input.deps.discoverAgents(effectiveCwd, scope).agents;
@@ -423,7 +425,7 @@ async function emitForegroundResultIntercom(input: {
423
425
  pi: ExtensionAPI;
424
426
  intercomBridge: IntercomBridgeState;
425
427
  runId: string;
426
- mode: "single" | "parallel" | "chain";
428
+ mode: SubagentRunMode;
427
429
  results: SingleResult[];
428
430
  chainSteps?: number;
429
431
  }): Promise<ReturnType<typeof buildSubagentResultIntercomPayload> | null> {
@@ -459,7 +461,7 @@ async function maybeBuildForegroundIntercomReceipt(input: {
459
461
  pi: ExtensionAPI;
460
462
  intercomBridge: IntercomBridgeState;
461
463
  runId: string;
462
- mode: "single" | "parallel" | "chain";
464
+ mode: SubagentRunMode;
463
465
  details: Details;
464
466
  }): Promise<{ text: string; details: Details } | null> {
465
467
  const payload = await emitForegroundResultIntercom({
@@ -1966,7 +1968,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1966
1968
  const scope: AgentScope = resolveExecutionAgentScope(effectiveParams.agentScope);
1967
1969
  const effectiveCwd = effectiveParams.cwd ?? ctx.cwd;
1968
1970
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
1969
- deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1971
+ deps.state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
1970
1972
  const discoveredAgents = deps.discoverAgents(effectiveCwd, scope).agents;
1971
1973
  effectiveParams = applyAgentDefaultContext(effectiveParams, discoveredAgents);
1972
1974
  const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
@@ -0,0 +1,10 @@
1
+ interface SessionIdentityManager {
2
+ getSessionFile(): string | null | undefined;
3
+ getSessionId(): string | null | undefined;
4
+ }
5
+
6
+ export function resolveCurrentSessionId(sessionManager: SessionIdentityManager): string {
7
+ const sessionId = sessionManager.getSessionFile() ?? sessionManager.getSessionId();
8
+ if (!sessionId) throw new Error("Current session identity is unavailable.");
9
+ return sessionId;
10
+ }
@@ -96,6 +96,7 @@ export interface ControlEvent {
96
96
  }
97
97
 
98
98
  export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached";
99
+ export type SubagentRunMode = "single" | "parallel" | "chain";
99
100
 
100
101
  export interface SubagentResultIntercomChild {
101
102
  agent: string;
@@ -112,7 +113,7 @@ export interface SubagentResultIntercomPayload {
112
113
  message: string;
113
114
  requestId?: string;
114
115
  runId: string;
115
- mode: "single" | "parallel" | "chain";
116
+ mode: SubagentRunMode;
116
117
  status: SubagentResultStatus;
117
118
  summary: string;
118
119
  source: "foreground" | "async";
@@ -205,7 +206,7 @@ export interface SingleResult {
205
206
  }
206
207
 
207
208
  export interface Details {
208
- mode: "single" | "parallel" | "chain" | "management";
209
+ mode: SubagentRunMode | "management";
209
210
  context?: "fresh" | "fork";
210
211
  results: SingleResult[];
211
212
  controlEvents?: ControlEvent[];
@@ -263,6 +264,8 @@ export interface AsyncStartedEvent {
263
264
  id?: string;
264
265
  asyncDir?: string;
265
266
  pid?: number;
267
+ sessionId?: string;
268
+ mode?: SubagentRunMode;
266
269
  agent?: string;
267
270
  agents?: string[];
268
271
  chain?: string[];
@@ -272,7 +275,8 @@ export interface AsyncStartedEvent {
272
275
 
273
276
  export interface AsyncStatus {
274
277
  runId: string;
275
- mode: "single" | "chain";
278
+ sessionId?: string;
279
+ mode: SubagentRunMode;
276
280
  state: "queued" | "running" | "complete" | "failed" | "paused";
277
281
  activityState?: ActivityState;
278
282
  lastActivityAt?: number;
@@ -321,6 +325,7 @@ export interface AsyncJobState {
321
325
  asyncDir: string;
322
326
  status: "queued" | "running" | "complete" | "failed" | "paused";
323
327
  pid?: number;
328
+ sessionId?: string;
324
329
  activityState?: ActivityState;
325
330
  lastActivityAt?: number;
326
331
  currentTool?: string;
@@ -328,9 +333,12 @@ export interface AsyncJobState {
328
333
  currentPath?: string;
329
334
  turnCount?: number;
330
335
  toolCount?: number;
331
- mode?: "single" | "chain";
336
+ mode?: SubagentRunMode;
332
337
  agents?: string[];
333
338
  currentStep?: number;
339
+ chainStepCount?: number;
340
+ parallelGroups?: AsyncParallelGroupStatus[];
341
+ steps?: AsyncStatus["steps"];
334
342
  stepsTotal?: number;
335
343
  runningSteps?: number;
336
344
  completedSteps?: number;
@@ -351,7 +359,7 @@ export interface SubagentState {
351
359
  asyncJobs: Map<string, AsyncJobState>;
352
360
  foregroundControls: Map<string, {
353
361
  runId: string;
354
- mode: "single" | "parallel" | "chain";
362
+ mode: SubagentRunMode;
355
363
  startedAt: number;
356
364
  updatedAt: number;
357
365
  currentAgent?: string;
@@ -8,6 +8,7 @@ import { AgentManagerComponent, type ManagerResult } from "../manager-ui/agent-m
8
8
  import { SubagentsStatusComponent } from "../tui/subagents-status.ts";
9
9
  import { discoverAvailableSkills } from "../agents/skills.ts";
10
10
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
11
+ import { resolveCurrentSessionId } from "../shared/session-identity.ts";
11
12
  import { isParallelStep, type ChainStep } from "../shared/settings.ts";
12
13
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
13
14
  import {
@@ -571,8 +572,11 @@ export function registerSlashCommands(
571
572
  pi.registerCommand("subagents-status", {
572
573
  description: "Show active and recent async subagent runs",
573
574
  handler: async (_args, ctx) => {
575
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
576
+ state.baseCwd = ctx.cwd;
577
+ state.currentSessionId = sessionId;
574
578
  await ctx.ui.custom<void>(
575
- (tui, theme, _kb, done) => new SubagentsStatusComponent(tui, theme, () => done(undefined)),
579
+ (tui, theme, _kb, done) => new SubagentsStatusComponent(tui, theme, () => done(undefined), { sessionId }),
576
580
  { overlay: true, overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" } },
577
581
  );
578
582
  },
package/src/tui/render.ts CHANGED
@@ -256,7 +256,17 @@ function hasAnimatedWidgetJobs(jobs: AsyncJobState[]): boolean {
256
256
  return jobs.some((job) => job.status === "running");
257
257
  }
258
258
 
259
+ function formatWidgetAgents(agents: string[]): string {
260
+ const distinct = [...new Set(agents)];
261
+ if (distinct.length === 1 && agents.length > 1) return `${distinct[0]} ×${agents.length}`;
262
+ if (agents.length > 3) return `${agents.slice(0, 2).join(", ")} +${agents.length - 2} more`;
263
+ return agents.join(", ");
264
+ }
265
+
259
266
  function widgetJobName(job: AsyncJobState): string {
267
+ const agents = job.agents?.length ? formatWidgetAgents(job.agents) : undefined;
268
+ if (job.mode === "parallel") return agents ? `parallel · ${agents}` : "parallel";
269
+ if (job.activeParallelGroup) return agents ? `parallel group · ${agents}` : "parallel group";
260
270
  if (job.agents?.length) return job.agents.join(" → ");
261
271
  return job.mode ?? "subagent";
262
272
  }
@@ -297,6 +307,47 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
297
307
  return theme.fg("error", "✗");
298
308
  }
299
309
 
310
+ function widgetStepGlyph(status: string, theme: Theme): string {
311
+ if (status === "running") return theme.fg("accent", "▶");
312
+ if (status === "complete" || status === "completed") return theme.fg("success", "✓");
313
+ if (status === "failed") return theme.fg("error", "✗");
314
+ if (status === "paused") return theme.fg("warning", "■");
315
+ return theme.fg("muted", "◦");
316
+ }
317
+
318
+ function widgetStepStatus(status: string, theme: Theme): string {
319
+ if (status === "running") return theme.fg("accent", "running");
320
+ if (status === "complete" || status === "completed") return theme.fg("success", "complete");
321
+ if (status === "failed") return theme.fg("error", "failed");
322
+ if (status === "paused") return theme.fg("warning", "paused");
323
+ return theme.fg("dim", status);
324
+ }
325
+
326
+ function widgetStepActivity(step: NonNullable<AsyncJobState["steps"]>[number]): string {
327
+ const facts: string[] = [];
328
+ if (step.currentTool && step.currentToolStartedAt !== undefined) facts.push(`${step.currentTool} ${formatDuration(Math.max(0, Date.now() - step.currentToolStartedAt))}`);
329
+ else if (step.currentTool) facts.push(step.currentTool);
330
+ if (step.currentPath) facts.push(shortenPath(step.currentPath));
331
+ if (step.turnCount !== undefined) facts.push(`${step.turnCount} turns`);
332
+ if (step.toolCount !== undefined) facts.push(`${step.toolCount} tools`);
333
+ if (step.tokens?.total) facts.push(formatTokenStat(step.tokens.total));
334
+ const activity = formatActivityLabel(step.lastActivityAt, step.activityState);
335
+ if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`;
336
+ if (activity) return activity;
337
+ return facts.join(" · ");
338
+ }
339
+
340
+ function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme): string[] {
341
+ if (!job.activeParallelGroup || !job.steps?.length) return [];
342
+ if (job.mode !== "parallel" && job.mode !== "chain") return [];
343
+ const total = job.stepsTotal ?? job.steps.length;
344
+ return job.steps.map((step, index) => {
345
+ const marker = index === job.steps!.length - 1 ? "└" : "├";
346
+ const activity = widgetStepActivity(step);
347
+ return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} Agent ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${activity ? ` · ${activity}` : ""}`)}`;
348
+ });
349
+ }
350
+
300
351
  function parseParallelGroupAgentCount(label: string | undefined): number | undefined {
301
352
  if (!label || !label.startsWith("[") || !label.endsWith("]")) return undefined;
302
353
  const inner = label.slice(1, -1).trim();
@@ -461,8 +512,20 @@ function widgetStats(job: AsyncJobState, theme: Theme): string {
461
512
  if (job.activeParallelGroup) {
462
513
  const running = job.runningSteps ?? (job.status === "running" ? 1 : 0);
463
514
  const done = job.completedSteps ?? (job.status === "complete" ? stepsTotal : 0);
464
- if (job.status === "running") parts.push(formatAgentRunningLabel(running));
465
- if (stepsTotal > 0) parts.push(`${done}/${stepsTotal} done`);
515
+ if (job.mode === "parallel") {
516
+ if (job.status === "running") parts.push(formatAgentRunningLabel(running));
517
+ if (stepsTotal > 0) parts.push(`${done}/${stepsTotal} done`);
518
+ } else {
519
+ const activeGroup = job.currentStep !== undefined
520
+ ? job.parallelGroups?.find((group) => job.currentStep! >= group.start && job.currentStep! < group.start + group.count)
521
+ : job.parallelGroups?.find((group) => group.start === 0);
522
+ const logicalStep = activeGroup?.stepIndex ?? job.currentStep ?? 0;
523
+ const total = job.chainStepCount ?? stepsTotal;
524
+ const groupProgress = job.status === "running"
525
+ ? `${formatAgentRunningLabel(running)} · ${done}/${stepsTotal} done`
526
+ : `${done}/${stepsTotal} done`;
527
+ parts.push(`step ${logicalStep + 1}/${total} · parallel group: ${groupProgress}`);
528
+ }
466
529
  } else if (job.currentStep !== undefined) {
467
530
  parts.push(`step ${job.currentStep + 1}/${stepsTotal}`);
468
531
  } else if (stepsTotal > 1) {
@@ -496,6 +559,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
496
559
  items.push([
497
560
  `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
498
561
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
562
+ ...widgetParallelAgentDetails(job, theme),
499
563
  ]);
500
564
  slots--;
501
565
  }
@@ -512,6 +576,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
512
576
  items.push([
513
577
  `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
514
578
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
579
+ ...widgetParallelAgentDetails(job, theme),
515
580
  ]);
516
581
  slots--;
517
582
  }
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { Theme } from "@mariozechner/pi-coding-agent";
4
4
  import type { Component, TUI } from "@mariozechner/pi-tui";
5
5
  import { matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
6
- import { type AsyncRunOverlayData, type AsyncRunSummary, formatAsyncRunProgressLabel, listAsyncRunsForOverlay } from "../runs/background/async-status.ts";
6
+ import { type AsyncRunOverlayData, type AsyncRunOverlayOptions, type AsyncRunSummary, formatAsyncRunProgressLabel, listAsyncRunsForOverlay } from "../runs/background/async-status.ts";
7
7
  import { ASYNC_DIR } from "../shared/types.ts";
8
8
  import { formatDuration, formatTokens, shortenPath } from "../shared/formatters.ts";
9
9
  import { formatScrollInfo, renderFooter, renderHeader, row } from "./render-helpers.ts";
@@ -20,8 +20,18 @@ interface StatusRow {
20
20
  run?: AsyncRunSummary;
21
21
  }
22
22
 
23
+ type AsyncRunStep = AsyncRunSummary["steps"][number];
24
+
25
+ interface ChainStepSpan {
26
+ stepIndex: number;
27
+ start: number;
28
+ count: number;
29
+ isParallel: boolean;
30
+ }
31
+
23
32
  interface StatusOverlayDeps {
24
- listRunsForOverlay?: (asyncDirRoot: string, recentLimit?: number) => AsyncRunOverlayData;
33
+ sessionId: string;
34
+ listRunsForOverlay?: (asyncDirRoot: string, options?: AsyncRunOverlayOptions) => AsyncRunOverlayData;
25
35
  refreshMs?: number;
26
36
  }
27
37
 
@@ -44,6 +54,14 @@ function stepStatusColor(theme: Theme, status: string): string {
44
54
  return status;
45
55
  }
46
56
 
57
+ function stepGlyph(theme: Theme, status: string): string {
58
+ if (status === "running") return theme.fg("accent", "▶");
59
+ if (status === "complete" || status === "completed") return theme.fg("success", "✓");
60
+ if (status === "failed") return theme.fg("error", "✗");
61
+ if (status === "paused") return theme.fg("warning", "■");
62
+ return theme.fg("dim", "◦");
63
+ }
64
+
47
65
  function runLabel(theme: Theme, run: AsyncRunSummary, selected: boolean): string {
48
66
  const prefix = selected ? theme.fg("accent", ">") : " ";
49
67
  const stepLabel = formatAsyncRunProgressLabel(run);
@@ -76,6 +94,40 @@ function buildRows(active: AsyncRunSummary[], recent: AsyncRunSummary[]): Status
76
94
  return rows;
77
95
  }
78
96
 
97
+ function buildChainStepSpans(run: AsyncRunSummary): ChainStepSpan[] {
98
+ const total = run.chainStepCount ?? run.steps.length;
99
+ const groups = [...(run.parallelGroups ?? [])].sort((a, b) => a.stepIndex - b.stepIndex);
100
+ const spans: ChainStepSpan[] = [];
101
+ let flatIndex = 0;
102
+ for (let stepIndex = 0; stepIndex < total; stepIndex++) {
103
+ const group = groups.find((candidate) => candidate.stepIndex === stepIndex);
104
+ if (group) {
105
+ spans.push({ stepIndex, start: group.start, count: group.count, isParallel: true });
106
+ flatIndex = Math.max(flatIndex, group.start + group.count);
107
+ continue;
108
+ }
109
+ spans.push({ stepIndex, start: flatIndex, count: flatIndex < run.steps.length ? 1 : 0, isParallel: false });
110
+ flatIndex++;
111
+ }
112
+ return spans;
113
+ }
114
+
115
+ function aggregateStepStatus(steps: AsyncRunStep[]): string {
116
+ if (steps.some((step) => step.status === "running")) return "running";
117
+ if (steps.some((step) => step.status === "failed")) return "failed";
118
+ if (steps.some((step) => step.status === "paused")) return "paused";
119
+ if (steps.length > 0 && steps.every((step) => step.status === "complete" || step.status === "completed")) return "complete";
120
+ return "pending";
121
+ }
122
+
123
+ function compactStepStats(step: AsyncRunStep): string {
124
+ const stats: string[] = [];
125
+ if (step.toolCount !== undefined) stats.push(`${step.toolCount} tools`);
126
+ if (step.tokens) stats.push(`${formatTokens(step.tokens.total)} tok`);
127
+ if (step.durationMs !== undefined) stats.push(formatDuration(step.durationMs));
128
+ return stats.join(" · ");
129
+ }
130
+
79
131
  function resolveRunPath(asyncDir: string, filePath: string): string {
80
132
  return path.isAbsolute(filePath) ? filePath : path.join(asyncDir, filePath);
81
133
  }
@@ -178,7 +230,8 @@ function readRecentEvents(eventsPath: string, limit: number): { events: string[]
178
230
  export class SubagentsStatusComponent implements Component {
179
231
  private readonly width = 84;
180
232
  private readonly viewportHeight = 12;
181
- private readonly listRunsForOverlay: (asyncDirRoot: string, recentLimit?: number) => AsyncRunOverlayData;
233
+ private readonly listRunsForOverlay: (asyncDirRoot: string, options?: AsyncRunOverlayOptions) => AsyncRunOverlayData;
234
+ private readonly sessionId: string;
182
235
  private readonly refreshTimer: NodeJS.Timeout;
183
236
  private screen: "list" | "detail" = "list";
184
237
  private cursor = 0;
@@ -197,12 +250,13 @@ export class SubagentsStatusComponent implements Component {
197
250
  tui: TUI,
198
251
  theme: Theme,
199
252
  done: () => void,
200
- deps: StatusOverlayDeps = {},
253
+ deps: StatusOverlayDeps,
201
254
  ) {
202
255
  this.tui = tui;
203
256
  this.theme = theme;
204
257
  this.done = done;
205
258
  this.listRunsForOverlay = deps.listRunsForOverlay ?? listAsyncRunsForOverlay;
259
+ this.sessionId = deps.sessionId;
206
260
  const refreshMs = deps.refreshMs ?? AUTO_REFRESH_MS;
207
261
  this.reload();
208
262
  this.refreshTimer = setInterval(() => {
@@ -215,7 +269,7 @@ export class SubagentsStatusComponent implements Component {
215
269
  private reload(): void {
216
270
  const previousSelectedId = selectedRun(this.rows, this.cursor)?.id;
217
271
  try {
218
- const overlayData = this.listRunsForOverlay(ASYNC_DIR, 5);
272
+ const overlayData = this.listRunsForOverlay(ASYNC_DIR, { recentLimit: 5, sessionId: this.sessionId });
219
273
  this.active = overlayData.active;
220
274
  this.recent = overlayData.recent;
221
275
  this.rows = buildRows(this.active, this.recent);
@@ -282,6 +336,13 @@ export class SubagentsStatusComponent implements Component {
282
336
  return lines;
283
337
  }
284
338
 
339
+ private formatStepActivity(step: AsyncRunStep): string {
340
+ if (!step.lastActivityAt) return "";
341
+ if (step.activityState === "needs_attention") return `no activity for ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))}`;
342
+ if (step.activityState === "active_long_running") return `active but long-running; last activity ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))} ago`;
343
+ return `active ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))} ago`;
344
+ }
345
+
285
346
  private renderStepRows(run: AsyncRunSummary, width: number, innerW: number, options: { wrap?: boolean } = {}): string[] {
286
347
  const lines: string[] = [];
287
348
  for (const step of run.steps) {
@@ -291,14 +352,8 @@ export class SubagentsStatusComponent implements Component {
291
352
  : "";
292
353
  const duration = step.durationMs !== undefined ? ` | ${formatDuration(step.durationMs)}` : "";
293
354
  const tokens = step.tokens ? ` | ${formatTokens(step.tokens.total)} tok` : "";
294
- const activity = step.lastActivityAt
295
- ? step.activityState === "needs_attention"
296
- ? ` | no activity for ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))}`
297
- : step.activityState === "active_long_running"
298
- ? ` | active but long-running; last activity ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))} ago`
299
- : ` | active ${formatDuration(Math.max(0, Date.now() - step.lastActivityAt))} ago`
300
- : "";
301
- const line = ` ${step.index + 1}. ${step.agent} | ${stepStatusColor(this.theme, step.status)}${activity}${model}${attempts}${duration}${tokens}`;
355
+ const activity = this.formatStepActivity(step);
356
+ const line = ` ${step.index + 1}. ${step.agent} | ${stepStatusColor(this.theme, step.status)}${activity ? ` | ${activity}` : ""}${model}${attempts}${duration}${tokens}`;
302
357
  if (options.wrap) {
303
358
  lines.push(...detailRows(line, width, innerW, this.theme));
304
359
  } else {
@@ -318,6 +373,57 @@ export class SubagentsStatusComponent implements Component {
318
373
  return lines;
319
374
  }
320
375
 
376
+ private renderStructuredStepRow(prefix: string, step: AsyncRunStep, width: number, innerW: number, errorIndent: string): string[] {
377
+ const suffix = [this.formatStepActivity(step), step.model, compactStepStats(step)].filter(Boolean).join(" · ");
378
+ const lines = detailRows(`${prefix}${step.agent} · ${stepStatusColor(this.theme, step.status)}${suffix ? ` · ${suffix}` : ""}`, width, innerW, this.theme);
379
+ if (step.error) lines.push(...detailRows(`${errorIndent}${step.error}`, width, innerW, this.theme));
380
+ return lines;
381
+ }
382
+
383
+ private renderAgentRows(run: AsyncRunSummary, width: number, innerW: number): string[] {
384
+ if (run.steps.length === 0) return [row(this.theme.fg("dim", " No agent details available yet."), width, this.theme)];
385
+ const lines: string[] = [];
386
+ const total = run.steps.length;
387
+ for (const [index, step] of run.steps.entries()) {
388
+ lines.push(...this.renderStructuredStepRow(` ${stepGlyph(this.theme, step.status)} Agent ${index + 1}/${total}: `, step, width, innerW, " "));
389
+ }
390
+ return lines;
391
+ }
392
+
393
+ private renderChainProgressRows(run: AsyncRunSummary, width: number, innerW: number): string[] {
394
+ if (run.steps.length === 0) return [row(this.theme.fg("dim", " No step details available yet."), width, this.theme)];
395
+ const lines: string[] = [];
396
+ const spans = buildChainStepSpans(run);
397
+ const total = run.chainStepCount ?? spans.length;
398
+ for (const span of spans) {
399
+ const steps = run.steps.slice(span.start, span.start + span.count);
400
+ const status = aggregateStepStatus(steps);
401
+ if (span.isParallel) {
402
+ const running = steps.filter((step) => step.status === "running").length;
403
+ const done = steps.filter((step) => step.status === "complete" || step.status === "completed").length;
404
+ const failed = steps.filter((step) => step.status === "failed").length;
405
+ const paused = steps.filter((step) => step.status === "paused").length;
406
+ const outcomeCounts = [`${done}/${span.count} done`];
407
+ if (failed > 0) outcomeCounts.push(`${failed} failed`);
408
+ if (paused > 0) outcomeCounts.push(`${paused} paused`);
409
+ if (running > 0) outcomeCounts.unshift(running === 1 ? "1 agent running" : `${running} agents running`);
410
+ const label = `${stepGlyph(this.theme, status)} Step ${span.stepIndex + 1}/${total}: parallel group · ${outcomeCounts.join(" · ")}`;
411
+ lines.push(...detailRows(` ${label}`, width, innerW, this.theme));
412
+ for (const [localIndex, step] of steps.entries()) {
413
+ lines.push(...this.renderStructuredStepRow(` ${stepGlyph(this.theme, step.status)} Agent ${localIndex + 1}/${span.count}: `, step, width, innerW, " "));
414
+ }
415
+ continue;
416
+ }
417
+ const step = steps[0];
418
+ if (!step) {
419
+ lines.push(row(this.theme.fg("dim", ` ◦ Step ${span.stepIndex + 1}/${total}: pending`), width, this.theme));
420
+ continue;
421
+ }
422
+ lines.push(...this.renderStructuredStepRow(` ${stepGlyph(this.theme, step.status)} Step ${span.stepIndex + 1}/${total}: `, step, width, innerW, " "));
423
+ }
424
+ return lines;
425
+ }
426
+
321
427
  private renderDetail(run: AsyncRunSummary, width: number, innerW: number): string[] {
322
428
  const stepLabel = formatAsyncRunProgressLabel(run);
323
429
  const duration = run.endedAt !== undefined
@@ -335,8 +441,16 @@ export class SubagentsStatusComponent implements Component {
335
441
  body.push(...detailRows(`${run.id} | ${statusColor(this.theme, run.state)} | ${run.mode} | ${stepLabel} | ${duration}`, width, innerW, this.theme));
336
442
  if (activity) body.push(...detailRows(activity, width, innerW, this.theme));
337
443
  body.push(row("", width, this.theme));
338
- body.push(row(this.theme.fg("accent", "Steps"), width, this.theme));
339
- body.push(...this.renderStepRows(run, width, innerW, { wrap: true }));
444
+ if (run.mode === "chain" && (run.chainStepCount !== undefined || run.parallelGroups?.length)) {
445
+ body.push(row(this.theme.fg("accent", run.state === "running" ? "Chain progress" : "Chain results"), width, this.theme));
446
+ body.push(...this.renderChainProgressRows(run, width, innerW));
447
+ } else if (run.mode === "parallel") {
448
+ body.push(row(this.theme.fg("accent", "Agents"), width, this.theme));
449
+ body.push(...this.renderAgentRows(run, width, innerW));
450
+ } else {
451
+ body.push(row(this.theme.fg("accent", "Steps"), width, this.theme));
452
+ body.push(...this.renderStepRows(run, width, innerW, { wrap: true }));
453
+ }
340
454
 
341
455
  const eventsPath = path.join(run.asyncDir, "events.jsonl");
342
456
  const eventResult = readRecentEvents(eventsPath, DETAIL_EVENT_LIMIT);