pi-subagents 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import type { Message } from "@earendil-works/pi-ai";
3
3
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
4
4
  import { previewSimpleWorkflowRun } from "../workflows/scripted-workflow.ts";
5
+ import { deriveChildSessionName } from "../shared/child-session-name.ts";
5
6
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
6
7
  import { type Details, type SingleResult, type Usage, SLASH_RESULT_TYPE } from "../shared/types.ts";
7
8
 
@@ -54,9 +55,11 @@ function createPlaceholderResult(
54
55
  status: "pending" | "running",
55
56
  index: number,
56
57
  ): SingleResult {
58
+ const sessionName = deriveChildSessionName({ agent, task });
57
59
  return {
58
60
  agent,
59
61
  task,
62
+ ...(sessionName ? { sessionName } : {}),
60
63
  index,
61
64
  exitCode: 0,
62
65
  messages: EMPTY_MESSAGES,
@@ -64,6 +67,7 @@ function createPlaceholderResult(
64
67
  progress: {
65
68
  index,
66
69
  agent,
70
+ ...(sessionName ? { sessionName } : {}),
67
71
  status,
68
72
  task,
69
73
  recentTools: [],
@@ -84,17 +88,21 @@ function buildParallelInitialResult(params: SubagentParamsLike): AgentToolResult
84
88
  ...(params.async ? { background: true } : {}),
85
89
  ...(params.context === "fresh" || params.context === "fork" ? { context: params.context } : {}),
86
90
  results: tasks.map((task, index) => createPlaceholderResult(task.agent, task.task, "running", index)),
87
- progress: tasks.map((task, index) => ({
88
- index,
89
- agent: task.agent,
90
- status: "running" as const,
91
- task: task.task,
92
- recentTools: [],
93
- recentOutput: [],
94
- toolCount: 0,
95
- tokens: 0,
96
- durationMs: 0,
97
- })),
91
+ progress: tasks.map((task, index) => {
92
+ const sessionName = deriveChildSessionName({ agent: task.agent, task: task.task });
93
+ return {
94
+ index,
95
+ agent: task.agent,
96
+ ...(sessionName ? { sessionName } : {}),
97
+ status: "running" as const,
98
+ task: task.task,
99
+ recentTools: [],
100
+ recentOutput: [],
101
+ toolCount: 0,
102
+ tokens: 0,
103
+ durationMs: 0,
104
+ };
105
+ }),
98
106
  },
99
107
  };
100
108
  }
@@ -143,6 +151,7 @@ function buildChainInitialResult(params: SubagentParamsLike): AgentToolResult<De
143
151
  progress: results.map((result, index) => ({
144
152
  index,
145
153
  agent: result.agent,
154
+ ...(result.sessionName ? { sessionName: result.sessionName } : {}),
146
155
  status: index === 0 ? "running" as const : "pending" as const,
147
156
  task: result.task,
148
157
  recentTools: [],
@@ -162,6 +171,7 @@ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<D
162
171
  const preview = previewSimpleWorkflowRun(params.workflowScript) ?? {};
163
172
  const agent = params.agent ?? preview.agent ?? "subagent";
164
173
  const task = params.task ?? preview.task ?? "";
174
+ const sessionName = deriveChildSessionName({ agent, task });
165
175
  return {
166
176
  content: [{ type: "text", text: task }],
167
177
  details: {
@@ -172,6 +182,7 @@ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<D
172
182
  progress: [{
173
183
  index: 0,
174
184
  agent,
185
+ ...(sessionName ? { sessionName } : {}),
175
186
  status: "running",
176
187
  task,
177
188
  recentTools: [],
@@ -2,8 +2,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type EditorComponent, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { snapshotExternalRuns } from "../api/external-runs.ts";
4
4
  import { formatModelThinking } from "../shared/formatters.ts";
5
- import type { AsyncJobState, AsyncJobStep, FleetViewPlacement, HerdrProjectPaneSnapshot, NestedRunSummary, NestedStepSummary, SubagentState } from "../shared/types.ts";
5
+ import type { AsyncJobState, AsyncJobStep, FleetViewPlacement, HerdrProjectPaneSnapshot, HostStepState, HostStepVerdict, NestedRunSummary, NestedStepSummary, SubagentState } from "../shared/types.ts";
6
+ import { projectAsyncWorkflowRows, type AsyncStatusWorkflowRow } from "../runs/shared/async-status-projection.ts";
7
+ import { contextModeLabel } from "../runs/shared/context-mode.ts";
6
8
  import { formatWorkflowJsonPreview } from "../workflows/scripted-workflow.ts";
9
+ import { hostStepReportName, hostStepVerdictLabel } from "../runs/shared/host-step-status.ts";
10
+ import { isStaleExtensionContextError } from "../shared/extension-context.ts";
7
11
 
8
12
  export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
9
13
 
@@ -30,18 +34,7 @@ type FleetStatusEntry = {
30
34
  external?: true;
31
35
  projectPane?: HerdrProjectPaneSnapshot;
32
36
  nestedChildren?: NestedRunSummary[];
33
- workflowRows?: FleetWorkflowRow[];
34
- };
35
-
36
- type FleetWorkflowRow = {
37
- name: string;
38
- state: AsyncJobStep["status"];
39
- modelThinking?: string;
40
- activity?: string;
41
- startedAt?: number;
42
- tokens?: number;
43
- window?: number;
44
- overflow?: number;
37
+ workflowRows?: AsyncStatusWorkflowRow[];
45
38
  };
46
39
 
47
40
  type FleetNestedRow = {
@@ -57,7 +50,7 @@ type FleetNestedRow = {
57
50
  type FleetTreeRow =
58
51
  | { kind: "owner"; entry: FleetStatusEntry }
59
52
  | { kind: "child"; entry: FleetStatusEntry; last: boolean }
60
- | { kind: "workflow"; ownerKey: string; row: FleetWorkflowRow; last: boolean }
53
+ | { kind: "workflow"; ownerKey: string; row: AsyncStatusWorkflowRow; last: boolean }
61
54
  | { kind: "nested"; ownerKey: string; row: FleetNestedRow; last: boolean };
62
55
 
63
56
  export interface FleetStatusOptions {
@@ -111,45 +104,12 @@ function nestedActivity(node: NestedRunSummary | NestedStepSummary): string | un
111
104
  return undefined;
112
105
  }
113
106
 
114
- function workflowStepActivity(step: AsyncJobStep): string | undefined {
115
- if (step.currentTool) return `tool ${step.currentTool}`;
116
- if (step.currentPath) return step.currentPath.split(/[\\/]/).at(-1);
117
- if (step.activityState === "needs_attention") return "needs attention";
118
- if (step.activityState === "active_long_running") return "long-running";
119
- if (step.turnCount !== undefined) return `${step.turnCount} turns`;
120
- if (step.toolCount !== undefined) return `${step.toolCount} tools`;
121
- return undefined;
122
- }
123
-
124
- function workflowStepName(step: AsyncJobStep, index: number): string {
125
- const key = step.workflowKey ?? `step ${index + 1}`;
126
- const label = step.label && step.label !== key ? ` · ${step.label}` : "";
127
- const phase = step.phase ? `${step.phase}: ` : "";
128
- return `${phase}${key}${label} (${step.agent})`;
129
- }
130
-
131
- function workflowFleetRows(steps: AsyncJobStep[] | undefined): FleetWorkflowRow[] {
132
- return (steps ?? []).map((step, index) => {
133
- const modelThinking = formatModelThinking(step.model, step.thinking) || undefined;
134
- const activity = workflowStepActivity(step);
135
- return {
136
- name: workflowStepName(step, index),
137
- state: step.status,
138
- ...(modelThinking ? { modelThinking } : {}),
139
- ...(activity ? { activity } : {}),
140
- ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
141
- ...(step.tokens?.total !== undefined ? { tokens: step.tokens.total } : {}),
142
- ...(step.tokens?.window !== undefined ? { window: step.tokens.window } : {}),
143
- };
144
- });
145
- }
146
-
147
- function visibleWorkflowRows(rows: FleetWorkflowRow[] | undefined, visibleLimit: number): FleetWorkflowRow[] {
107
+ function visibleWorkflowRows(rows: AsyncStatusWorkflowRow[] | undefined, visibleLimit: number): AsyncStatusWorkflowRow[] {
148
108
  if (!rows?.length) return [];
149
109
  if (rows.length <= visibleLimit) return rows;
150
110
  const selected = new Set<number>();
151
111
  for (const [index, row] of rows.entries()) {
152
- if (row.state !== "complete" && row.state !== "completed") selected.add(index);
112
+ if (!isWorkflowRowTerminal(row)) selected.add(index);
153
113
  if (selected.size >= visibleLimit) break;
154
114
  }
155
115
  for (let index = rows.length - 1; index >= 0 && selected.size < visibleLimit; index--) selected.add(index);
@@ -157,9 +117,14 @@ function visibleWorkflowRows(rows: FleetWorkflowRow[] | undefined, visibleLimit:
157
117
  return [{ name: `… +${rows.length - visible.length} hidden workflow steps`, state: "complete", overflow: rows.length - visible.length }, ...visible];
158
118
  }
159
119
 
160
- function nestedStatusGlyph(state: FleetNestedRow["state"], theme: Theme): string {
120
+ function isWorkflowRowTerminal(row: AsyncStatusWorkflowRow): boolean {
121
+ if (row.kind) return row.state === "done" || row.state === "cancelled" || row.state === "error";
122
+ return row.state === "complete" || row.state === "completed";
123
+ }
124
+
125
+ function nestedStatusGlyph(state: FleetNestedRow["state"] | "planned", theme: Theme): string {
161
126
  if (state === "running") return theme.fg("accent", "●");
162
- if (state === "queued" || state === "pending") return theme.fg("muted", "◦");
127
+ if (state === "queued" || state === "pending" || state === "planned") return theme.fg("muted", "◦");
163
128
  if (state === "complete" || state === "completed") return theme.fg("success", "✓");
164
129
  if (state === "failed" || state === "rejected") return theme.fg("error", "✗");
165
130
  return theme.fg("warning", "■");
@@ -278,19 +243,31 @@ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
278
243
  return rows;
279
244
  }
280
245
 
281
- function isStaleExtensionContextError(error: unknown): boolean {
282
- // Pi currently exposes stale contexts as plain Errors without a stable code or subtype.
283
- return error instanceof Error
284
- && (error.message.includes("This extension ctx is stale")
285
- || error.message.includes("Extension context no longer active"));
286
- }
287
-
288
246
  function foregroundDescription(control: { parentWorkflowRunId?: string; workflowKey?: string }, description: string | undefined): string | undefined {
289
247
  if (!control.parentWorkflowRunId) return description;
290
248
  const workflow = `workflow child: ${control.parentWorkflowRunId}${control.workflowKey ? ` (${control.workflowKey})` : ""}`;
291
249
  return description ? `${workflow} · ${description}` : workflow;
292
250
  }
293
251
 
252
+ function workflowIdentityCandidates(step: Pick<AsyncJobStep, "workflowKey" | "runId">): string[] {
253
+ return [...new Set([step.workflowKey, step.runId].filter((value): value is string => typeof value === "string" && value.length > 0))];
254
+ }
255
+
256
+ function asyncJobIdentityCandidates(job: AsyncJobState): string[] {
257
+ return [...new Set([job.workflowKey, job.asyncId].filter((value): value is string => typeof value === "string" && value.length > 0))];
258
+ }
259
+
260
+ function linkedWorkflowParentKey(parentWorkflowRunId: string | undefined, activeWorkflowKeys: ReadonlySet<string>): string | undefined {
261
+ if (!parentWorkflowRunId) return undefined;
262
+ const parentKey = `async:${parentWorkflowRunId}`;
263
+ return activeWorkflowKeys.has(parentKey) ? parentKey : undefined;
264
+ }
265
+
266
+ function workflowStepsWithoutMaterializedChildren(steps: AsyncJobStep[] | undefined, materializedChildIds: ReadonlySet<string> | undefined): AsyncJobStep[] | undefined {
267
+ if (!steps?.length || !materializedChildIds?.size) return steps;
268
+ return steps.filter((step) => !workflowIdentityCandidates(step).some((identity) => materializedChildIds.has(identity)));
269
+ }
270
+
294
271
  function activeLeafAgentCount(entries: FleetStatusEntry[]): number {
295
272
  return entries.filter((entry) => !entry.workflowWrapper && !entry.surface).length;
296
273
  }
@@ -325,12 +302,30 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
325
302
  const activeWorkflowKeys = new Set([...state.asyncJobs.values()]
326
303
  .filter((job) => job.mode === "workflow" && isActiveState(job.status))
327
304
  .map((job) => `async:${job.asyncId}`));
305
+ const materializedChildrenByWorkflow = new Map<string, Set<string>>();
306
+ for (const job of state.asyncJobs.values()) {
307
+ if (!isActiveState(job.status) || !job.parentWorkflowRunId) continue;
308
+ const parentKey = linkedWorkflowParentKey(job.parentWorkflowRunId, activeWorkflowKeys);
309
+ if (!parentKey) continue;
310
+ const childIds = materializedChildrenByWorkflow.get(parentKey) ?? new Set<string>();
311
+ for (const identity of asyncJobIdentityCandidates(job)) childIds.add(identity);
312
+ materializedChildrenByWorkflow.set(parentKey, childIds);
313
+ }
328
314
  for (const control of state.foregroundControls.values()) {
329
- const parentKey = control.parentWorkflowRunId ? `async:${control.parentWorkflowRunId}` : undefined;
330
- const linkedParentKey = parentKey && activeWorkflowKeys.has(parentKey) ? parentKey : undefined;
315
+ const linkedParentKey = linkedWorkflowParentKey(control.parentWorkflowRunId, activeWorkflowKeys);
331
316
  if (control.activeChildren) {
317
+ const nestedChildren = control.nestedChildren ?? [];
318
+ const nestedChildrenByParentStep = new Map<number, NestedRunSummary[]>();
319
+ for (const nested of nestedChildren) {
320
+ if (nested.parentStepIndex === undefined) continue;
321
+ const children = nestedChildrenByParentStep.get(nested.parentStepIndex) ?? [];
322
+ children.push(nested);
323
+ nestedChildrenByParentStep.set(nested.parentStepIndex, children);
324
+ }
332
325
  for (const child of [...control.activeChildren.values()].sort((left, right) => left.index - right.index)) {
333
326
  const modelThinking = formatModelThinking(child.model, child.thinking) || undefined;
327
+ const childNestedChildren = nestedChildrenByParentStep.get(child.index)
328
+ ?? (control.activeChildren.size === 1 && nestedChildren.length ? nestedChildren : undefined);
334
329
  entries.push({
335
330
  key: `foreground-active:${control.runId}:${child.index}`,
336
331
  ...(linkedParentKey ? { parentKey: linkedParentKey } : {}),
@@ -338,12 +333,10 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
338
333
  ...(modelThinking ? { modelThinking } : {}),
339
334
  description: foregroundDescription(control, child.description),
340
335
  startedAt: child.startedAt,
341
- tokens: child.tokens ?? 0,
342
- ...(child.window !== undefined ? { window: child.window } : {}),
336
+ tokens: child.tokens ?? 0,
337
+ ...(child.window !== undefined ? { window: child.window } : {}),
343
338
  state: "running",
344
- ...((control.nestedChildren?.filter((nested) => nested.parentStepIndex === child.index).length ?? 0) > 0
345
- ? { nestedChildren: control.nestedChildren!.filter((nested) => nested.parentStepIndex === child.index) }
346
- : control.activeChildren.size === 1 && control.nestedChildren?.length ? { nestedChildren: control.nestedChildren } : {}),
339
+ ...(childNestedChildren?.length ? { nestedChildren: childNestedChildren } : {}),
347
340
  });
348
341
  }
349
342
  continue;
@@ -366,10 +359,14 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
366
359
  for (const job of state.asyncJobs.values()) {
367
360
  if (!isActiveState(job.status)) continue;
368
361
  const startedAt = job.startedAt ?? job.updatedAt ?? Date.now();
362
+ const linkedParentKey = linkedWorkflowParentKey(job.parentWorkflowRunId, activeWorkflowKeys);
369
363
  if (job.mode === "workflow") {
370
364
  const latestEmit = job.workflow?.emits?.length ? formatWorkflowJsonPreview(job.workflow.emits.at(-1), 120) : undefined;
365
+ const workflowSteps = workflowStepsWithoutMaterializedChildren(job.steps, materializedChildrenByWorkflow.get(`async:${job.asyncId}`));
366
+ const workflowRows = projectAsyncWorkflowRows(workflowSteps, job.workflowGraph ?? job.hostSteps, job.preflight);
371
367
  entries.push({
372
368
  key: `async:${job.asyncId}`,
369
+ ...(linkedParentKey ? { parentKey: linkedParentKey } : {}),
373
370
  workflowWrapper: true,
374
371
  agent: "workflow",
375
372
  description: latestEmit !== undefined ? `latest emit: ${latestEmit}` : job.description,
@@ -377,7 +374,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
377
374
  tokens: job.totalTokens?.total ?? 0,
378
375
  ...(job.totalTokens?.window !== undefined ? { window: job.totalTokens.window } : {}),
379
376
  state: job.status,
380
- ...(job.steps?.length ? { workflowRows: workflowFleetRows(job.steps) } : {}),
377
+ ...(workflowRows.length ? { workflowRows } : {}),
381
378
  ...(job.nestedChildren?.length ? { nestedChildren: job.nestedChildren } : {}),
382
379
  });
383
380
  continue;
@@ -392,6 +389,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
392
389
  if (!steps?.length) {
393
390
  entries.push({
394
391
  key: `async:${job.asyncId}`,
392
+ ...(linkedParentKey ? { parentKey: linkedParentKey } : {}),
395
393
  agent: job.mode ?? "subagent",
396
394
  description: job.description,
397
395
  startedAt,
@@ -409,6 +407,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
409
407
  const modelThinking = formatModelThinking(step.model, step.thinking) || undefined;
410
408
  entries.push({
411
409
  key: `async:${job.asyncId}:${index}`,
410
+ ...(linkedParentKey ? { parentKey: linkedParentKey } : {}),
412
411
  agent: step.label ? `${step.label} (${step.agent})` : step.agent,
413
412
  ...(modelThinking ? { modelThinking } : {}),
414
413
  description: step.description ?? job.description,
@@ -552,7 +551,12 @@ export class SubagentFleetStatus {
552
551
  this.lastRenderKey = renderKey;
553
552
  return;
554
553
  }
555
- if (renderKey === this.lastRenderKey) return;
554
+ if (renderKey === this.lastRenderKey) {
555
+ // Repaint anyway while anything is running so the wall-clock
556
+ // spinner animates between state changes (500ms tick).
557
+ if (this.entries.some((entry) => entry.state === "running")) this.tui?.requestRender();
558
+ return;
559
+ }
556
560
  this.lastRenderKey = renderKey;
557
561
  this.tui?.requestRender();
558
562
  }
@@ -643,6 +647,10 @@ export class SubagentFleetStatus {
643
647
  }
644
648
  const roster = this.rosterKeys();
645
649
  const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
650
+ const rosterIndexByKey = new Map<string, number>();
651
+ for (const [index, entry] of this.entries.entries()) {
652
+ if (!rosterIndexByKey.has(entry.key)) rosterIndexByKey.set(entry.key, index + 1);
653
+ }
646
654
  const lines = [truncateToWidth(` ${theme.fg("dim", "↑↓/jk select · enter inspect · esc back")}`, width), ""];
647
655
  lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex, theme)} main`, width));
648
656
 
@@ -656,8 +664,8 @@ export class SubagentFleetStatus {
656
664
  for (let index = start; index < start + visibleCount; index++) {
657
665
  const row = tree[index]!;
658
666
  if (row.kind === "owner" || row.kind === "child") {
659
- const ownerIndex = this.entries.findIndex((entry) => entry.key === row.entry.key);
660
- lines.push(this.renderEntry(ownerIndex + 1, selectedIndex, row.entry, width, theme, row.kind === "child" ? (row.last ? "└─" : "├─") : undefined));
667
+ const rosterIndex = rosterIndexByKey.get(row.entry.key) ?? 0;
668
+ lines.push(this.renderEntry(rosterIndex, selectedIndex, row.entry, width, theme, row.kind === "child" ? (row.last ? "└─" : "├─") : undefined));
661
669
  } else if (row.kind === "workflow") {
662
670
  lines.push(this.renderWorkflowRow(row.row, row.last, width, theme));
663
671
  } else {
@@ -665,16 +673,16 @@ export class SubagentFleetStatus {
665
673
  }
666
674
  }
667
675
  if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
668
- this.renderProjectPaneSection(lines, selectedIndex, width, theme);
676
+ this.renderProjectPaneSection(lines, selectedIndex, width, theme, rosterIndexByKey);
669
677
  return lines;
670
678
  }
671
679
 
672
- private renderProjectPaneSection(lines: string[], selectedIndex: number, width: number, theme: Theme): void {
680
+ private renderProjectPaneSection(lines: string[], selectedIndex: number, width: number, theme: Theme, rosterIndexByKey: ReadonlyMap<string, number>): void {
673
681
  const entries = this.entries.filter((entry) => entry.surface === "project-pane");
674
682
  if (!entries.length) return;
675
683
  lines.push("", truncateToWidth(` ${theme.fg("dim", "project panes")}`, width));
676
684
  for (const entry of entries) {
677
- const rosterIndex = this.entries.findIndex((candidate) => candidate.key === entry.key) + 1;
685
+ const rosterIndex = rosterIndexByKey.get(entry.key) ?? 0;
678
686
  lines.push(this.renderEntry(rosterIndex, selectedIndex, entry, width, theme));
679
687
  }
680
688
  }
@@ -703,16 +711,51 @@ export class SubagentFleetStatus {
703
711
  return truncateToWidth(`${left}${theme.fg("dim", elapsed)}`, width);
704
712
  }
705
713
 
706
- private renderWorkflowRow(row: FleetWorkflowRow, last: boolean, width: number, theme: Theme): string {
714
+ private workflowRowGlyph(row: AsyncStatusWorkflowRow, theme: Theme): string {
715
+ if (!row.kind) return nestedStatusGlyph(row.state as FleetNestedRow["state"], theme);
716
+ const state = row.state as HostStepState;
717
+ if (state === "pending") return theme.fg("muted", "◦");
718
+ if (state === "running") return theme.fg("accent", "●");
719
+ if (state === "done") return row.verdict === "pass" ? theme.fg("success", "✓") : row.verdict === "fail" ? theme.fg("error", "✗") : theme.fg("warning", "■");
720
+ if (state === "error") return theme.fg("error", "✗");
721
+ return theme.fg("warning", "■");
722
+ }
723
+
724
+ private workflowRowStateLabel(row: AsyncStatusWorkflowRow, theme: Theme): string {
725
+ const state = row.kind ? hostStepVerdictLabel(row.state as HostStepState, row.verdict as HostStepVerdict | undefined) : row.state;
726
+ if (state === "running") return theme.fg("accent", state);
727
+ if (state === "pending" || state === "queued") return theme.fg("muted", state);
728
+ if (state === "pass" || state === "complete" || state === "completed") return theme.fg("success", state === "pass" ? "pass" : "complete");
729
+ if (state === "fail" || state === "failed" || state === "error") return theme.fg("error", state === "fail" ? "fail" : state);
730
+ return theme.fg("warning", state);
731
+ }
732
+
733
+ private renderWorkflowRow(row: AsyncStatusWorkflowRow, last: boolean, width: number, theme: Theme): string {
707
734
  const marker = last ? "└─" : "├─";
708
735
  const indent = " ";
709
736
  if (row.overflow !== undefined) return truncateToWidth(`${indent}${marker} ${theme.fg("dim", `+${row.overflow} hidden workflow steps`)}`, width);
737
+ const context = contextModeLabel(row.context);
710
738
  const modelThinking = row.modelThinking ? ` (${row.modelThinking})` : "";
711
739
  const activity = row.activity ? ` · ${row.activity}` : "";
712
- const left = `${indent}${marker} ${nestedStatusGlyph(row.state, theme)} ${theme.fg("muted", `${row.name}${modelThinking}`)} · ${row.state}${activity}`;
740
+ const kind = row.kind ? `${row.kind}: ` : "";
741
+ const hints = row.preflight ? [
742
+ row.preflight.mode ? `mode:${row.preflight.mode}` : undefined,
743
+ row.preflight.decision ? `decision:${row.preflight.decision}` : undefined,
744
+ row.preflight.claims?.length ? `claims:${row.preflight.claims.join(",")}` : undefined,
745
+ row.preflight.expectedOutput ? `expected:${row.preflight.expectedOutput}` : undefined,
746
+ row.preflight.independence ? `independence:${row.preflight.independence}` : undefined,
747
+ ].filter((value): value is string => Boolean(value)).join(" · ") : "";
748
+ const left = `${indent}${marker} ${this.workflowRowGlyph(row, theme)} ${theme.fg("muted", `${kind}${row.name}${context ? ` ${context}` : ""}${modelThinking}`)} · ${this.workflowRowStateLabel(row, theme)}${activity}${hints ? ` · ${hints}` : ""}`;
713
749
  const details = [
714
750
  row.startedAt !== undefined ? formatFleetElapsed(Date.now() - row.startedAt) : undefined,
715
751
  row.tokens !== undefined ? formatFleetTokens(row.tokens, row.window) : undefined,
752
+ row.provider ? `provider:${row.provider}` : undefined,
753
+ row.role ? `role:${row.role}` : undefined,
754
+ row.target,
755
+ row.detail,
756
+ row.reasonCode ? `reason:${row.reasonCode}` : undefined,
757
+ row.freshness?.stale ? "stale" : row.freshness?.observedRef ? `ref:${row.freshness.observedRef}` : undefined,
758
+ row.reportPath ? `out:${hostStepReportName(row.reportPath)}` : undefined,
716
759
  ].filter(Boolean).join(" · ");
717
760
  return truncateToWidth(`${left}${details ? theme.fg("dim", ` · ${details}`) : ""}`, width);
718
761
  }
@@ -767,12 +810,22 @@ export class SubagentFleetStatus {
767
810
  Math.round((now - entry.startedAt) / 1000),
768
811
  entry.tokens,
769
812
  visibleWorkflowRows(entry.workflowRows, entry.parentKey ? 2 : 4).map((row) => [
813
+ row.kind,
770
814
  row.name,
771
815
  row.state,
816
+ row.context,
772
817
  row.modelThinking,
773
818
  row.activity,
774
819
  row.startedAt,
775
820
  row.tokens,
821
+ row.provider,
822
+ row.role,
823
+ row.verdict,
824
+ row.reasonCode,
825
+ row.detail,
826
+ row.target,
827
+ row.freshness,
828
+ row.reportPath,
776
829
  row.overflow,
777
830
  ]),
778
831
  nestedFleetRows(entry.nestedChildren, entry.parentKey ? 3 : 4).map((row) => [
@@ -807,7 +860,13 @@ export class SubagentFleetStatus {
807
860
 
808
861
  private clearWidget(): void {
809
862
  if (!this.widgetRegistered) return;
810
- this.ui?.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
863
+ try {
864
+ this.ui?.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
865
+ } catch (error) {
866
+ if (!isStaleExtensionContextError(error)) throw error;
867
+ this.clearUiRegistration();
868
+ return;
869
+ }
811
870
  this.widgetRegistered = false;
812
871
  this.tui = undefined;
813
872
  }
package/src/tui/fleet.ts CHANGED
@@ -147,6 +147,7 @@ function trackedJobSummary(job: AsyncJobState): AsyncRunSummary {
147
147
  ...(job.currentStep !== undefined ? { currentStep: job.currentStep } : {}),
148
148
  ...(job.chainStepCount !== undefined ? { chainStepCount: job.chainStepCount } : {}),
149
149
  ...(job.parallelGroups?.length ? { parallelGroups: job.parallelGroups } : {}),
150
+ ...(job.preflight ? { preflight: job.preflight } : {}),
150
151
  steps: (job.steps ?? job.agents?.map((agent, index) => ({ agent, index, status: job.status === "queued" ? "pending" as const : job.status })) ?? []).map((step, index) => ({
151
152
  ...step,
152
153
  index: step.index ?? index,
@@ -355,7 +356,12 @@ function foregroundPromptAuditCount(item: Extract<FleetItem, { kind: "foreground
355
356
  return [...item.control.activeChildren.keys()].filter((index) => getLivePromptAudit(item.control, index)).length;
356
357
  }
357
358
 
358
- function authoredPromptSummary(text: string): string | undefined {
359
+ function promptAuditString(value: unknown): string | undefined {
360
+ return typeof value === "string" ? value : undefined;
361
+ }
362
+
363
+ function authoredPromptSummary(text: unknown): string | undefined {
364
+ if (typeof text !== "string") return undefined;
359
365
  const summary = text.replace(/\s+/g, " ").trim();
360
366
  return summary ? truncateToWidth(summary, PROMPT_AUDIT_SUMMARY_WIDTH, "…") : undefined;
361
367
  }
@@ -366,11 +372,11 @@ function foregroundAuthoredPromptSummary(item: Extract<FleetItem, { kind: "foreg
366
372
  return prompt ? authoredPromptSummary(prompt.authoredTask) : undefined;
367
373
  }
368
374
 
369
- function promptAuditText(prompt: LivePromptAudit, view: PromptAuditView): string {
375
+ function promptAuditText(prompt: LivePromptAudit, view: PromptAuditView): string | undefined {
370
376
  switch (view) {
371
- case "authored": return prompt.authoredTask;
372
- case "runtime": return prompt.runtimeAdditions;
373
- case "effective": return prompt.finalEffectivePrompt;
377
+ case "authored": return promptAuditString(prompt.authoredTask);
378
+ case "runtime": return promptAuditString(prompt.runtimeAdditions);
379
+ case "effective": return promptAuditString(prompt.finalEffectivePrompt);
374
380
  }
375
381
  }
376
382
 
@@ -30,6 +30,37 @@ export function fuzzyFilter<T extends { name: string; description: string; model
30
30
  .map((x) => x.item);
31
31
  }
32
32
 
33
+ /** Remove a repeated job name from a child label when a safe separator follows it. */
34
+ export function stripRepeatedAgentPrefix(label: string, jobName: string | undefined): string {
35
+ const value = label.trim();
36
+ const prefix = jobName?.trim();
37
+ if (!prefix || !value.startsWith(prefix)) return value;
38
+
39
+ const suffix = value.slice(prefix.length);
40
+ if (!suffix || !/^(?::|·|\s)/u.test(suffix)) return value;
41
+ return suffix.replace(/^\s*(?::|·)?\s*/u, "").trim();
42
+ }
43
+
44
+ /** Whether a status label may omit the one logical step marker. */
45
+ export function shouldSuppressSingleStep(chainStepCount?: number, stepsTotal?: number): boolean {
46
+ return (chainStepCount ?? stepsTotal) === 1;
47
+ }
48
+
49
+ /** Add an Agent fraction only when visible candidates collide. */
50
+ export function withDuplicateLabelDiscriminators<T extends { index: number; displayName: string }>(
51
+ rows: readonly T[],
52
+ total: number,
53
+ ): Array<T & { rowLabel: string }> {
54
+ const counts = new Map<string, number>();
55
+ for (const row of rows) counts.set(row.displayName, (counts.get(row.displayName) ?? 0) + 1);
56
+ return rows.map((row) => ({
57
+ ...row,
58
+ rowLabel: (counts.get(row.displayName) ?? 0) > 1
59
+ ? `Agent ${row.index + 1}/${total}: ${row.displayName}`
60
+ : row.displayName,
61
+ }));
62
+ }
63
+
33
64
  export function pad(s: string, len: number): string {
34
65
  const vis = visibleWidth(s);
35
66
  return s + " ".repeat(Math.max(0, len - vis));