pi-subagents 0.37.0 → 0.37.2

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.
@@ -27,6 +27,7 @@ type ImportKind = keyof typeof IMPORT_PATHS;
27
27
  interface ServerEntry {
28
28
  command?: string;
29
29
  args?: string[];
30
+ socket?: string;
30
31
  env?: Record<string, string>;
31
32
  cwd?: string;
32
33
  url?: string;
@@ -35,6 +36,7 @@ interface ServerEntry {
35
36
  bearerToken?: string;
36
37
  bearerTokenEnv?: string;
37
38
  exposeResources?: boolean;
39
+ includeTools?: string[];
38
40
  excludeTools?: string[];
39
41
  directTools?: boolean | string[];
40
42
  }
@@ -271,14 +273,16 @@ export function computeMcpServerHash(definition: ServerEntry): string {
271
273
  const identity: Record<string, unknown> = {
272
274
  command: definition.command,
273
275
  args: definition.args,
276
+ socket: resolveConfigPath(definition.socket),
274
277
  env: interpolateEnvRecord(definition.env),
275
278
  cwd: resolveConfigPath(definition.cwd),
276
- url: definition.url,
279
+ url: resolveServerUrl(definition),
277
280
  headers: interpolateEnvRecord(definition.headers),
278
281
  auth: definition.auth,
279
282
  bearerToken: resolveBearerToken(definition),
280
283
  bearerTokenEnv: definition.bearerTokenEnv,
281
284
  exposeResources: definition.exposeResources,
285
+ includeTools: definition.includeTools,
282
286
  excludeTools: definition.excludeTools,
283
287
  };
284
288
  return createHash("sha256").update(stableStringify(identity)).digest("hex");
@@ -333,22 +337,51 @@ function resourceNameToToolName(name: string): string {
333
337
  }
334
338
 
335
339
  function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
336
- if (!values || typeof values !== "object" || Array.isArray(values)) return undefined;
337
- const resolved: Record<string, string> = {};
338
- for (const [key, value] of Object.entries(values)) {
339
- if (typeof value === "string") resolved[key] = interpolateEnvVars(value);
340
- }
341
- return resolved;
340
+ if (!values) return undefined;
341
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, interpolateSecretExpression(value)]));
342
342
  }
343
343
 
344
344
  function interpolateEnvVars(value: string): string {
345
345
  return value
346
346
  .replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "")
347
- .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? "");
347
+ .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? "")
348
+ .replace(/\{env:(\w+)\}/g, (_, name: string) => process.env[name] ?? "");
349
+ }
350
+
351
+ function interpolateSecretExpression(value: string): string {
352
+ if (value.startsWith("!!")) return interpolateEnvVars(value.slice(1));
353
+ return value.startsWith("!") ? value : interpolateEnvVars(value);
354
+ }
355
+
356
+ function getMissingEnvVars(value: string): string[] {
357
+ const missing = new Set<string>();
358
+ for (const match of value.matchAll(/\$\{(\w+)\}|\$env:(\w+)|\{env:(\w+)\}/g)) {
359
+ const name = match[1] ?? match[2] ?? match[3];
360
+ if (name && process.env[name] === undefined) missing.add(name);
361
+ }
362
+ return [...missing];
363
+ }
364
+
365
+ function resolveServerUrl(definition: Pick<ServerEntry, "url">): string | undefined {
366
+ if (definition.url == null) return undefined;
367
+ if (typeof definition.url !== "string") throw new Error("MCP server URL must be a string");
368
+
369
+ const missing = getMissingEnvVars(definition.url);
370
+ if (missing.length > 0) {
371
+ throw new Error(`Missing environment variable${missing.length === 1 ? "" : "s"} in MCP server URL: ${missing.join(", ")}`);
372
+ }
373
+
374
+ const resolved = interpolateEnvVars(definition.url);
375
+ try {
376
+ new URL(resolved);
377
+ } catch (error) {
378
+ throw new Error(`Invalid MCP server URL after environment interpolation: ${resolved}`, { cause: error });
379
+ }
380
+ return resolved;
348
381
  }
349
382
 
350
383
  function resolveConfigPath(value: string | undefined): string | undefined {
351
- if (typeof value !== "string") return undefined;
384
+ if (value === undefined) return undefined;
352
385
  const resolved = interpolateEnvVars(value);
353
386
  if (resolved === "~") return os.homedir();
354
387
  if (resolved.startsWith("~/") || resolved.startsWith("~\\")) return path.join(os.homedir(), resolved.slice(2));
@@ -356,8 +389,8 @@ function resolveConfigPath(value: string | undefined): string | undefined {
356
389
  }
357
390
 
358
391
  function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
359
- if (typeof definition.bearerToken === "string") return interpolateEnvVars(definition.bearerToken);
360
- return typeof definition.bearerTokenEnv === "string" ? process.env[definition.bearerTokenEnv] : undefined;
392
+ if (definition.bearerToken !== undefined) return interpolateSecretExpression(definition.bearerToken);
393
+ return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
361
394
  }
362
395
 
363
396
  function stableStringify(value: unknown): string {
@@ -30,6 +30,14 @@ export interface ParentModel {
30
30
  id: string;
31
31
  }
32
32
 
33
+ export function normalizeParentModel(model: unknown): ParentModel | undefined {
34
+ if (!model || typeof model !== "object") return undefined;
35
+ const candidate = model as { provider?: unknown; id?: unknown };
36
+ if (typeof candidate.provider !== "string" || typeof candidate.id !== "string") return undefined;
37
+ if (!candidate.provider || !candidate.id) return undefined;
38
+ return { provider: candidate.provider, id: candidate.id };
39
+ }
40
+
33
41
  /**
34
42
  * Normalize a model id or provider segment for fuzzy comparison: case-fold,
35
43
  * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
@@ -253,6 +253,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
253
253
  }
254
254
  for (const extPath of toolPlan.extensionArgs) args.push("--extension", extPath);
255
255
 
256
+ if (!input.inheritProjectContext) {
257
+ args.push("--no-context-files");
258
+ }
256
259
  if (!input.inheritSkills) {
257
260
  args.push("--no-skills");
258
261
  }
@@ -40,7 +40,7 @@ const REVIEWER_REQUIRED_EDIT_PATTERNS = [
40
40
  const NO_EDIT_PROHIBITION_PATTERN = /\b(?:do not|don't|must not)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b((?:(?!\b(?:but|and|then)\b)[^.;,:!?\n–—-])*)/gi;
41
41
 
42
42
  /** Objects of a no-edit prohibition that mean "the codebase in general" rather than a named scope. */
43
- const GENERIC_PROHIBITION_OBJECT = /^\s*(?:(?:any|all|the|these|those|your|our|existing|project|source|sources|repo|repository)[\s/,-]*)*(?:files?|code|codebase|sources?|anything|repo(?:sitory)?)?\s*$/i;
43
+ const GENERIC_PROHIBITION_OBJECT = /^\s*(?:(?:any|all|the|these|those|your|our|existing|project|product|source|sources|config|configs|repo|repository)[\s/,-]*)*(?:files?|code|codebase|sources?|anything|repo(?:sitory)?)?\s*$/i;
44
44
 
45
45
  const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
46
46
  /\bdo not edit files?\s+outside\b/i,
@@ -877,6 +877,8 @@ export interface Details {
877
877
  processTerminal?: ProcessTerminalV1;
878
878
  };
879
879
  launchContractDigest?: string;
880
+ /** Original launch contract whose persisted session is being revived. */
881
+ sourceLaunchContractDigest?: string;
880
882
  }
881
883
 
882
884
  // ============================================================================
@@ -1202,6 +1204,8 @@ export interface ForegroundResumeChild {
1202
1204
  index: number;
1203
1205
  context?: "fresh" | "fork";
1204
1206
  sessionFile?: string;
1207
+ model?: string;
1208
+ thinking?: string;
1205
1209
  status: SubagentResultStatus;
1206
1210
  activityState?: ActivityState;
1207
1211
  lastActivityAt?: number;
@@ -1223,6 +1227,7 @@ export interface ForegroundResumeChild {
1223
1227
  detachedReason?: string;
1224
1228
  acceptance?: AcceptanceLedger;
1225
1229
  agentContract?: AgentContract;
1230
+ launchContractDigest?: string;
1226
1231
  execution?: ExecutionProjection;
1227
1232
  review?: ReviewProjection;
1228
1233
  effects?: EffectsProjection;
@@ -1290,6 +1295,8 @@ export interface SubagentState {
1290
1295
  /** Runtime-owned artifact resolution inputs used by Fleet transcript targeting. */
1291
1296
  artifactDirPreference?: ArtifactDirPreference;
1292
1297
  parentSessionFile?: string | null;
1298
+ /** Last valid parent session model observed for this session; used when continuation contexts omit ctx.model. */
1299
+ lastParentModel?: { provider: string; id: string };
1293
1300
  subagentInProgress?: boolean;
1294
1301
  subagentSpawns?: {
1295
1302
  sessionId: string | null;
@@ -1429,6 +1436,8 @@ export type IntercomBridgeMode = "off" | "fork-only" | "always";
1429
1436
  export interface IntercomBridgeConfig {
1430
1437
  mode?: IntercomBridgeMode;
1431
1438
  instructionFile?: string;
1439
+ /** Deliver grouped completion messages through an external acknowledged intercom listener. */
1440
+ resultDelivery?: boolean;
1432
1441
  }
1433
1442
 
1434
1443
  interface TopLevelParallelConfig {
@@ -1457,10 +1466,14 @@ export interface ScheduledRunsConfig {
1457
1466
  maxPending?: number;
1458
1467
  }
1459
1468
 
1469
+ export type FleetViewPlacement = "aboveEditor" | "belowEditor";
1470
+
1460
1471
  export interface ExtensionConfig {
1461
1472
  asyncByDefault?: boolean;
1462
- /** Show the Claude Code-style navigable fleet below the editor. Defaults to true. */
1473
+ /** Show the Claude Code-style navigable fleet. Defaults to true. */
1463
1474
  fleetView?: boolean;
1475
+ /** Place the persistent FleetView above or below the editor. Defaults to belowEditor. */
1476
+ fleetViewPlacement?: FleetViewPlacement;
1464
1477
  /** Show the legacy above-editor async runs widget. Defaults to true only when fleetView is disabled. */
1465
1478
  asyncWidget?: boolean;
1466
1479
  /** Tool description variant registered for the parent-facing subagent tool. Defaults to full. */
@@ -69,6 +69,7 @@ interface PromptTemplateDelegationTaskProgress {
69
69
 
70
70
  export interface PromptTemplateDelegationUpdate {
71
71
  requestId: string;
72
+ runId?: string;
72
73
  currentTool?: string;
73
74
  currentToolArgs?: string;
74
75
  recentOutput?: string;
@@ -323,6 +324,7 @@ export function toDelegationUpdate(requestId: string, update: PromptTemplateBrid
323
324
  : undefined;
324
325
  return {
325
326
  requestId,
327
+ ...(update.details?.runId ? { runId: update.details.runId } : {}),
326
328
  currentTool: progress?.currentTool,
327
329
  currentToolArgs: progress?.currentToolArgs,
328
330
  recentOutput: safeLastOutput,
@@ -414,6 +416,7 @@ export function toSubagentDelegationUpdate(requestId: string, result: PromptTemp
414
416
  return {
415
417
  version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
416
418
  requestId,
419
+ ...(legacy.runId ? { runId: legacy.runId } : {}),
417
420
  ...(legacy.currentTool ? { currentTool: legacy.currentTool } : {}),
418
421
  ...(legacy.currentToolArgs ? { currentToolArgs: legacy.currentToolArgs } : {}),
419
422
  ...(legacy.recentOutput ? { recentOutput: legacy.recentOutput } : {}),
@@ -437,6 +440,7 @@ export function toSubagentDelegationV2Update(
437
440
  requestId: request.requestId,
438
441
  ownerRunId: request.ownerRunId,
439
442
  nodeId: request.nodeId,
443
+ ...(legacy.runId ? { runId: legacy.runId } : {}),
440
444
  ...(legacy.currentTool ? { currentTool: legacy.currentTool } : {}),
441
445
  ...(legacy.currentToolArgs ? { currentToolArgs: legacy.currentToolArgs } : {}),
442
446
  ...(legacy.recentOutput ? { recentOutput: legacy.recentOutput } : {}),
@@ -553,6 +557,7 @@ export function toSubagentDelegationV2Response(
553
557
  ...(child?.model ? { model: child.model } : {}),
554
558
  ...(child?.thinking ? { thinking: child.thinking } : {}),
555
559
  ...(typeof child?.exitCode === "number" ? { exitCode: child.exitCode } : {}),
560
+ ...(child?.launchContractDigest ? { launchContractDigest: child.launchContractDigest } : {}),
556
561
  ...(projectedResult ? { result: projectedResult } : {}),
557
562
  ...(usage ? {
558
563
  usage: {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import type { AsyncJobStep, SubagentState } from "../shared/types.ts";
3
+ import type { AsyncJobStep, FleetViewPlacement, SubagentState } from "../shared/types.ts";
4
4
 
5
5
  export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
6
6
 
@@ -22,6 +22,11 @@ type FleetStatusEntry = {
22
22
  export interface FleetStatusOptions {
23
23
  refreshMs?: number;
24
24
  maxAgentRows?: number;
25
+ placement?: FleetViewPlacement;
26
+ }
27
+
28
+ export function resolveFleetViewPlacement(value: unknown): FleetViewPlacement {
29
+ return value === "aboveEditor" ? "aboveEditor" : "belowEditor";
25
30
  }
26
31
 
27
32
  export function formatFleetElapsed(ms: number): string {
@@ -48,6 +53,13 @@ function isActiveState(value: string): boolean {
48
53
  return value === "running" || value === "queued" || value === "pending";
49
54
  }
50
55
 
56
+ function isStaleExtensionContextError(error: unknown): boolean {
57
+ // Pi currently exposes stale contexts as plain Errors without a stable code or subtype.
58
+ return error instanceof Error
59
+ && (error.message.includes("This extension ctx is stale")
60
+ || error.message.includes("Extension context no longer active"));
61
+ }
62
+
51
63
  export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntry[] {
52
64
  const entries: FleetStatusEntry[] = [];
53
65
  for (const control of state.foregroundControls.values()) {
@@ -111,6 +123,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
111
123
 
112
124
  export class SubagentFleetStatus {
113
125
  private ctx: ExtensionContext | undefined;
126
+ private ui: ExtensionContext["ui"] | undefined;
114
127
  private tui: FleetStatusTui | undefined;
115
128
  private inputUnsubscribe: (() => void) | undefined;
116
129
  private timer: ReturnType<typeof setInterval> | undefined;
@@ -124,6 +137,7 @@ export class SubagentFleetStatus {
124
137
  private readonly openInspector: (itemKey: string) => Promise<void> | void;
125
138
  private readonly refreshMs: number;
126
139
  private readonly maxAgentRows: number;
140
+ private readonly placement: FleetViewPlacement;
127
141
 
128
142
  constructor(
129
143
  state: SubagentState,
@@ -134,19 +148,22 @@ export class SubagentFleetStatus {
134
148
  this.openInspector = openInspector;
135
149
  this.refreshMs = options.refreshMs ?? REFRESH_MS;
136
150
  this.maxAgentRows = options.maxAgentRows ?? MAX_AGENT_ROWS;
151
+ this.placement = options.placement ?? "belowEditor";
137
152
  }
138
153
 
139
154
  setContext(ctx: ExtensionContext): void {
140
155
  if (!ctx.hasUI) return;
141
- if (this.ctx?.ui === ctx.ui) {
156
+ const ui = ctx.ui;
157
+ if (this.ui === ui) {
142
158
  this.ctx = ctx;
143
159
  this.refresh();
144
160
  return;
145
161
  }
146
162
  this.clearUiRegistration();
147
163
  this.ctx = ctx;
148
- if (typeof ctx.ui.onTerminalInput === "function") {
149
- this.inputUnsubscribe = ctx.ui.onTerminalInput((data) => this.handleKey(data));
164
+ this.ui = ui;
165
+ if (typeof ui.onTerminalInput === "function") {
166
+ this.inputUnsubscribe = ui.onTerminalInput((data) => this.handleKey(data));
150
167
  }
151
168
  this.timer = setInterval(() => this.refresh(), this.refreshMs);
152
169
  this.timer.unref?.();
@@ -156,6 +173,7 @@ export class SubagentFleetStatus {
156
173
  dispose(): void {
157
174
  this.clearUiRegistration();
158
175
  this.ctx = undefined;
176
+ this.ui = undefined;
159
177
  this.entries = [];
160
178
  this.active = false;
161
179
  this.selectedKey = "main";
@@ -164,8 +182,8 @@ export class SubagentFleetStatus {
164
182
  }
165
183
 
166
184
  refresh(): void {
167
- const ctx = this.ctx;
168
- if (!ctx?.hasUI) return;
185
+ const ctx = this.getActiveUiContext();
186
+ if (!ctx) return;
169
187
  this.entries = collectFleetStatusEntries(this.state);
170
188
  this.clampSelection();
171
189
  if (this.inspectorOpen || this.state.fleetInspectorOpen) {
@@ -204,7 +222,7 @@ export class SubagentFleetStatus {
204
222
  this.tui = undefined;
205
223
  },
206
224
  };
207
- }, { placement: "belowEditor" });
225
+ }, { placement: this.placement });
208
226
  this.widgetRegistered = true;
209
227
  this.lastRenderKey = renderKey;
210
228
  return;
@@ -215,8 +233,8 @@ export class SubagentFleetStatus {
215
233
  }
216
234
 
217
235
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
218
- const ctx = this.ctx;
219
- if (!ctx?.hasUI || this.entries.length === 0 || isKeyRelease(data)) return undefined;
236
+ const ctx = this.getActiveUiContext();
237
+ if (!ctx || this.entries.length === 0 || isKeyRelease(data)) return undefined;
220
238
  if (this.inspectorOpen) return undefined;
221
239
  if (!this.editorHasFocus()) {
222
240
  if (this.active) this.deactivate();
@@ -344,19 +362,47 @@ export class SubagentFleetStatus {
344
362
  });
345
363
  }
346
364
 
365
+ private getActiveUiContext(): ExtensionContext | undefined {
366
+ const ctx = this.ctx;
367
+ if (!ctx) return undefined;
368
+ try {
369
+ return ctx.hasUI ? ctx : undefined;
370
+ } catch (error) {
371
+ if (!isStaleExtensionContextError(error)) throw error;
372
+ this.clearUiRegistration();
373
+ return undefined;
374
+ }
375
+ }
376
+
347
377
  private clearUiRegistration(): void {
348
378
  if (this.timer) clearInterval(this.timer);
349
379
  this.timer = undefined;
350
- this.inputUnsubscribe?.();
380
+
381
+ const inputUnsubscribe = this.inputUnsubscribe;
382
+ const ui = this.ui;
383
+ const widgetRegistered = this.widgetRegistered;
351
384
  this.inputUnsubscribe = undefined;
352
- if (this.ctx?.hasUI && this.widgetRegistered) {
385
+ this.ctx = undefined;
386
+ this.ui = undefined;
387
+ this.widgetRegistered = false;
388
+ this.tui = undefined;
389
+
390
+ const cleanupErrors: unknown[] = [];
391
+ try {
392
+ inputUnsubscribe?.();
393
+ } catch (error) {
394
+ if (!isStaleExtensionContextError(error)) cleanupErrors.push(error);
395
+ }
396
+ if (ui && widgetRegistered) {
353
397
  try {
354
- this.ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
355
- } catch {
356
- // The previous extension context may already be stale during reload/session replacement.
398
+ ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
399
+ } catch (error) {
400
+ if (!isStaleExtensionContextError(error)) cleanupErrors.push(error);
357
401
  }
358
402
  }
359
- this.widgetRegistered = false;
360
- this.tui = undefined;
403
+ if (cleanupErrors.length === 1) throw cleanupErrors[0];
404
+ if (cleanupErrors.length > 1) {
405
+ throw new AggregateError(cleanupErrors, "Failed to clean up FleetView UI registration");
406
+ }
361
407
  }
362
408
  }
package/src/tui/fleet.ts CHANGED
@@ -463,7 +463,7 @@ export class SubagentFleetComponent implements Component {
463
463
  this.refresh();
464
464
  this.timer = setInterval(() => {
465
465
  if (this.disposed) return;
466
- this.refresh();
466
+ this.invalidate();
467
467
  this.tui.requestRender();
468
468
  }, options.refreshMs ?? REFRESH_MS);
469
469
  this.timer.unref?.();
package/src/tui/render.ts CHANGED
@@ -40,6 +40,7 @@ function getTermWidth(): number {
40
40
  }
41
41
 
42
42
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
43
+ const ansiStylePattern = /\x1b\[[0-9;]*m/y;
43
44
 
44
45
  /**
45
46
  * Truncate a line to maxWidth, preserving ANSI styling through the ellipsis.
@@ -50,7 +51,7 @@ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
50
51
  *
51
52
  * Uses Intl.Segmenter for proper Unicode/emoji handling (not char-by-char).
52
53
  */
53
- function truncLine(text: string, maxWidth: number): string {
54
+ export function truncLine(text: string, maxWidth: number): string {
54
55
  if (visibleWidth(text) <= maxWidth) return text;
55
56
 
56
57
  const targetWidth = maxWidth - 1;
@@ -60,7 +61,8 @@ function truncLine(text: string, maxWidth: number): string {
60
61
  let i = 0;
61
62
 
62
63
  while (i < text.length) {
63
- const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
64
+ ansiStylePattern.lastIndex = i;
65
+ const ansiMatch = ansiStylePattern.exec(text);
64
66
  if (ansiMatch) {
65
67
  const code = ansiMatch[0];
66
68
  result += code;
@@ -74,11 +76,9 @@ function truncLine(text: string, maxWidth: number): string {
74
76
  continue;
75
77
  }
76
78
 
77
- let end = i;
78
- while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
79
- end++;
80
- }
81
-
79
+ let end = text.indexOf("\x1b[", i);
80
+ if (end === i) end = text.indexOf("\x1b[", i + 2);
81
+ if (end === -1) end = text.length;
82
82
  const textPortion = text.slice(i, end);
83
83
  for (const seg of segmenter.segment(textPortion)) {
84
84
  const grapheme = seg.segment;
@@ -529,17 +529,15 @@ function buildChainStepSpans(details: Pick<Details, "chainAgents" | "workflowGra
529
529
  return spans;
530
530
  }
531
531
 
532
- function isChainParallelGroupActive(details: Pick<Details, "mode" | "chainAgents" | "currentStepIndex" | "workflowGraph">): boolean {
533
- if (details.mode !== "chain") return false;
534
- if (details.currentStepIndex === undefined) return false;
535
- return buildChainStepSpans(details).some((span) => span.stepIndex === details.currentStepIndex && span.isParallel);
536
- }
537
-
538
532
  function buildAsyncChainStepSpans(total: number, stepCount: number, parallelGroups: AsyncParallelGroupStatus[] = []): ChainStepSpan[] {
533
+ const groupsByStep = new Map<number, AsyncParallelGroupStatus>();
534
+ for (const group of parallelGroups) {
535
+ if (!groupsByStep.has(group.stepIndex)) groupsByStep.set(group.stepIndex, group);
536
+ }
539
537
  const spans: ChainStepSpan[] = [];
540
538
  let flatIndex = 0;
541
539
  for (let stepIndex = 0; stepIndex < total; stepIndex++) {
542
- const group = parallelGroups.find((candidate) => candidate.stepIndex === stepIndex);
540
+ const group = groupsByStep.get(stepIndex);
543
541
  if (group) {
544
542
  spans.push({ stepIndex, start: group.start, count: group.count, isParallel: true });
545
543
  flatIndex = Math.max(flatIndex, group.start + group.count);
@@ -567,6 +565,7 @@ interface ChainRenderResultEntry {
567
565
  kind: "result";
568
566
  resultIndex: number;
569
567
  rowNumber: number;
568
+ rowLabel?: string;
570
569
  agentName: string;
571
570
  }
572
571
 
@@ -601,6 +600,7 @@ function buildChainRenderEntries(details: Details, label: MultiProgressLabel): C
601
600
  kind: "result",
602
601
  resultIndex: index,
603
602
  rowNumber: index + 1,
603
+ rowLabel: span.isParallel ? `Agent ${index - span.start + 1}/${span.count}` : `Step ${span.stepIndex + 1}`,
604
604
  agentName: details.results[index]?.agent ?? details.chainAgents?.[span.stepIndex] ?? `step-${span.stepIndex + 1}`,
605
605
  });
606
606
  }
@@ -622,7 +622,9 @@ interface MultiProgressLabel {
622
622
  function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "progress" | "totalSteps" | "currentStepIndex" | "chainAgents" | "workflowGraph">, hasRunning: boolean): MultiProgressLabel {
623
623
  const stepSpans = buildChainStepSpans(details);
624
624
  const hasParallelInChain = details.mode === "chain" && stepSpans.some((span) => span.isParallel);
625
- const activeParallelGroup = isChainParallelGroupActive(details);
625
+ const activeParallelGroup = details.mode === "chain"
626
+ && details.currentStepIndex !== undefined
627
+ && stepSpans.some((span) => span.stepIndex === details.currentStepIndex && span.isParallel);
626
628
  const itemTitle: "Step" | "Agent" = details.mode === "parallel" || activeParallelGroup ? "Agent" : "Step";
627
629
 
628
630
  if (details.mode === "parallel") {
@@ -708,15 +710,10 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
708
710
  return { headerLabel, itemTitle, totalCount, hasParallelInChain, activeParallelGroup, groupStartIndex: 0, groupEndIndex: details.results.length, showActiveGroupOnly: false };
709
711
  }
710
712
 
711
- function resultRowLabel(details: Pick<Details, "mode" | "chainAgents" | "workflowGraph">, label: MultiProgressLabel, resultIndex: number, stepNumber: number): string {
712
- if (details.mode === "chain" && label.hasParallelInChain) {
713
- const span = buildChainStepSpans(details).find((candidate) => resultIndex >= candidate.start && resultIndex < candidate.start + candidate.count);
714
- if (span?.isParallel) return `Agent ${resultIndex - span.start + 1}/${span.count}`;
715
- if (span) return `Step ${span.stepIndex + 1}`;
716
- }
713
+ function resultRowLabel(label: MultiProgressLabel, resultIndex: number, stepNumber: number): string {
717
714
  if (label.itemTitle === "Agent") {
718
715
  const localStepNumber = label.activeParallelGroup
719
- ? Math.max(1, stepNumber - label.groupStartIndex)
716
+ ? resultIndex - label.groupStartIndex + 1
720
717
  : stepNumber;
721
718
  return `Agent ${localStepNumber}/${label.totalCount}`;
722
719
  }
@@ -1417,7 +1414,7 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1417
1414
  const rowNumber = entry.rowNumber;
1418
1415
  const agentName = entry.agentName;
1419
1416
  if (!r) {
1420
- const pendingLabel = chainEntries ? resultRowLabel(d, multiLabel, i, rowNumber) : `${itemTitle} ${rowNumber}`;
1417
+ const pendingLabel = entry.rowLabel ?? `${itemTitle} ${rowNumber}`;
1421
1418
  c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${pendingLabel}: ${agentName} · pending`), width), 0, 0));
1422
1419
  continue;
1423
1420
  }
@@ -1430,7 +1427,7 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1430
1427
  const stepStats = formatProgressStats(theme, rProg);
1431
1428
  const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning, progressRunningSeed(rProg), frame);
1432
1429
  const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
1433
- const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
1430
+ const stepLabel = entry.rowLabel ?? resultRowLabel(multiLabel, i, stepNumber);
1434
1431
  const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${contextModeBadge(theme, r.context)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
1435
1432
  c.addChild(new Text(truncLine(` ${line}`, width), 0, 0));
1436
1433
  if (rRunning && rProg && "status" in rProg) {
@@ -1717,7 +1714,7 @@ export function renderSubagentResult(
1717
1714
  const agentName = entry.agentName;
1718
1715
 
1719
1716
  if (!r) {
1720
- const pendingLabel = chainEntries ? resultRowLabel(d, multiLabel, i, rowNumber) : `${itemTitle} ${rowNumber}`;
1717
+ const pendingLabel = entry.rowLabel ?? `${itemTitle} ${rowNumber}`;
1721
1718
  c.addChild(new Text(fit(theme.fg("dim", ` ${pendingLabel}: ${agentName}`)), 0, 0));
1722
1719
  c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
1723
1720
  c.addChild(new Spacer(1));
@@ -1740,7 +1737,7 @@ export function renderSubagentResult(
1740
1737
  : theme.fg("success", "done");
1741
1738
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
1742
1739
  const modelDisplay = modelThinkingBadge(theme, r.model);
1743
- const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
1740
+ const stepLabel = entry.rowLabel ?? resultRowLabel(multiLabel, i, stepNumber);
1744
1741
  const contextBadge = contextModeBadge(theme, r.context);
1745
1742
  const stepHeader = rRunning
1746
1743
  ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${contextBadge}${modelDisplay}${stats}`