pi-subagents 0.61.0 → 0.63.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 (63) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/docs/agents.md +11 -6
  3. package/docs/configuration.md +31 -5
  4. package/docs/extension-api.md +2 -2
  5. package/docs/models.md +5 -5
  6. package/docs/observability.md +5 -2
  7. package/docs/tool-reference.md +3 -3
  8. package/install.mjs +0 -1
  9. package/package.json +1 -1
  10. package/skills/pi-subagents/references/execution-controls.md +3 -4
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  12. package/skills/pi-subagents/references/prompting-and-roles.md +3 -3
  13. package/src/agents/agent-management.ts +41 -4
  14. package/src/agents/agent-serializer.ts +3 -0
  15. package/src/agents/agents.ts +120 -124
  16. package/src/agents/runtime-agent-registry.ts +5 -1
  17. package/src/api/preflight.ts +4 -0
  18. package/src/api/shared-types.ts +3 -0
  19. package/src/extension/config.ts +20 -0
  20. package/src/extension/public-execution.ts +1 -0
  21. package/src/extension/schemas.ts +6 -2
  22. package/src/extension/tool-description.ts +1 -1
  23. package/src/inspectors/herdr/inspector-runner.ts +19 -13
  24. package/src/runs/background/active-async-capacity.ts +26 -8
  25. package/src/runs/background/async-execution.ts +100 -23
  26. package/src/runs/background/async-resume.ts +6 -2
  27. package/src/runs/background/async-status.ts +18 -2
  28. package/src/runs/background/notify.ts +13 -1
  29. package/src/runs/background/process-terminal.ts +16 -0
  30. package/src/runs/background/run-status.ts +22 -2
  31. package/src/runs/background/scheduled-runs.ts +63 -6
  32. package/src/runs/background/steering.ts +4 -1
  33. package/src/runs/background/subagent-runner.ts +44 -9
  34. package/src/runs/background/wait-completions.ts +13 -0
  35. package/src/runs/background/wait-tool.ts +1 -7
  36. package/src/runs/foreground/execution.ts +14 -7
  37. package/src/runs/foreground/subagent-executor.ts +38 -5
  38. package/src/runs/shared/acceptance.ts +85 -18
  39. package/src/runs/shared/capability-ceiling.ts +1 -0
  40. package/src/runs/shared/dynamic-fanout.ts +1 -1
  41. package/src/runs/shared/lane-metadata.ts +24 -3
  42. package/src/runs/shared/parallel-handoff.ts +4 -0
  43. package/src/runs/shared/parallel-utils.ts +2 -6
  44. package/src/runs/shared/permissions.ts +1 -1
  45. package/src/runs/shared/pi-args.ts +32 -14
  46. package/src/runs/shared/pi-spawn.ts +69 -35
  47. package/src/runs/shared/structured-output.ts +33 -6
  48. package/src/runs/shared/subagent-prompt-runtime.ts +20 -3
  49. package/src/runs/shared/task-intent.ts +21 -7
  50. package/src/runs/shared/tool-timeout.ts +1 -1
  51. package/src/runs/shared/worktree.ts +467 -63
  52. package/src/shared/atomic-json.ts +3 -1
  53. package/src/shared/fork-context.ts +0 -12
  54. package/src/shared/fork-session-cwd.ts +27 -0
  55. package/src/shared/launch-contract.ts +3 -0
  56. package/src/shared/types.ts +32 -1
  57. package/src/shared/utils.ts +18 -7
  58. package/src/slash/slash-commands.ts +1 -1
  59. package/src/slash/subagents-admin.ts +26 -12
  60. package/src/tui/fleet-status.ts +61 -2
  61. package/src/tui/fleet.ts +12 -7
  62. package/src/tui/render.ts +222 -14
  63. package/src/workflows/workflow-checklist.ts +441 -0
@@ -15,6 +15,7 @@ type AtomicJsonWriterOptions = {
15
15
  mode?: number;
16
16
  retryRenameErrors?: boolean;
17
17
  retryDirectoryErrors?: boolean;
18
+ ignoreCleanupErrorAfterSuccess?: boolean;
18
19
  retryDelaysMs?: readonly number[];
19
20
  wait?: (delayMs: number) => void;
20
21
  };
@@ -46,6 +47,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
46
47
  const mode = options.mode;
47
48
  const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
48
49
  const retryDirectoryErrors = options.retryDirectoryErrors ?? retryRenameErrors;
50
+ const ignoreCleanupErrorAfterSuccess = options.ignoreCleanupErrorAfterSuccess ?? false;
49
51
  const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS;
50
52
  const renameRetryDelaysMs = retryRenameErrors ? retryDelaysMs : [];
51
53
  const directoryRetryDelaysMs = retryDirectoryErrors ? retryDelaysMs : [];
@@ -71,7 +73,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
71
73
  } catch (cleanupError) {
72
74
  // Preserve the write/rename failure: cleanup is best effort and must
73
75
  // not hide the error callers need to classify or report.
74
- if (writeError === undefined) throw cleanupError;
76
+ if (writeError === undefined && !ignoreCleanupErrorAfterSuccess) throw cleanupError;
75
77
  }
76
78
  }
77
79
  };
@@ -174,18 +174,6 @@ function readSessionEntries(sessionFile: string): BranchSessionEntry[] {
174
174
  });
175
175
  }
176
176
 
177
- /** Keep Pi from restoring a forked session into the parent's cwd instead of the child launch cwd. */
178
- export function alignForkedSessionCwd(sessionFile: string, cwd: string): void {
179
- const entries = readSessionEntries(sessionFile);
180
- const header = entries[0];
181
- if (header?.type !== "session") throw new Error(`Forked session ${sessionFile} does not start with a session header.`);
182
- const resolvedCwd = path.resolve(cwd);
183
- const effectiveCwd = fs.realpathSync.native(resolvedCwd);
184
- if (header.cwd === effectiveCwd) return;
185
- header.cwd = effectiveCwd;
186
- fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
187
- }
188
-
189
177
  export function createForkContextResolver(
190
178
  sessionManager: ForkableSessionManager,
191
179
  requestedContext: unknown,
@@ -0,0 +1,27 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ interface SessionEntry {
5
+ type?: string;
6
+ cwd?: string;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ /** Keep Pi from restoring a forked session into the parent's cwd instead of the child launch cwd. */
11
+ export function alignForkedSessionCwd(sessionFile: string, cwd: string): void {
12
+ const lines = fs.readFileSync(sessionFile, "utf-8").split("\n").filter((line) => line.trim().length > 0);
13
+ const entries = lines.map((line, index) => {
14
+ try {
15
+ return JSON.parse(line) as SessionEntry;
16
+ } catch (error) {
17
+ const cause = error instanceof Error ? error : new Error(String(error));
18
+ throw new Error(`Unable to inspect forked session ${sessionFile}: invalid JSONL on line ${index + 1}: ${cause.message}`, { cause });
19
+ }
20
+ });
21
+ const header = entries[0];
22
+ if (header?.type !== "session") throw new Error(`Forked session ${sessionFile} does not start with a session header.`);
23
+ const effectiveCwd = fs.realpathSync.native(path.resolve(cwd));
24
+ if (header.cwd === effectiveCwd) return;
25
+ header.cwd = effectiveCwd;
26
+ fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
27
+ }
@@ -51,6 +51,7 @@ export function projectAgentDefinition(agent: AgentConfig): Record<string, unkno
51
51
  fast: agent.fast,
52
52
  thinking: agent.thinking,
53
53
  tools: agent.tools,
54
+ excludeTools: agent.excludeTools,
54
55
  allowNestedSubagents: agent.allowNestedSubagents,
55
56
  mcpDirectTools: agent.mcpDirectTools,
56
57
  extensions: agent.extensions,
@@ -93,6 +94,7 @@ export interface LaunchBindingInput {
93
94
  inheritSkills: boolean;
94
95
  skills?: string[];
95
96
  tools?: string[];
97
+ excludeTools?: string[];
96
98
  extensions?: string[];
97
99
  subagentOnlyExtensions?: string[];
98
100
  mcpDirectTools?: string[];
@@ -120,6 +122,7 @@ export function projectLaunchBinding(input: LaunchBindingInput): Record<string,
120
122
  inheritSkills: input.inheritSkills,
121
123
  skills: input.skills,
122
124
  tools: input.tools,
125
+ excludeTools: input.excludeTools,
123
126
  extensions: input.extensions,
124
127
  subagentOnlyExtensions: input.subagentOnlyExtensions,
125
128
  mcpDirectTools: input.mcpDirectTools,
@@ -382,6 +382,17 @@ export type SubagentResultStatus = "completed" | "failed" | "paused" | "stopped"
382
382
  export type SubagentOutputState = "present" | "absent" | "unknown";
383
383
  export type SubagentRunMode = "single" | "parallel" | "chain" | "workflow";
384
384
  export type SubagentResultMode = SubagentRunMode;
385
+ export type WorktreeProvider = "auto" | "native" | "worktrunk";
386
+ export type ManagedWorktreeProvider = Exclude<WorktreeProvider, "auto">;
387
+
388
+ export interface WorktreeNaming {
389
+ requestedBranch: string;
390
+ branchPrefix: string;
391
+ label: string;
392
+ sanitizedPathComponent: string;
393
+ collision?: "branch" | "path" | "both";
394
+ collisionSuffix?: string;
395
+ }
385
396
 
386
397
  export interface ParallelHandoffPatch {
387
398
  path: string;
@@ -425,6 +436,10 @@ export interface ParallelHandoffCleanupTask {
425
436
  index: number;
426
437
  path: string;
427
438
  branch: string;
439
+ /** Provider that allocated this worktree; omitted in old manifests. */
440
+ provider?: ManagedWorktreeProvider;
441
+ /** Branch/path naming evidence retained with the cleanup authority. */
442
+ naming?: WorktreeNaming;
428
443
  worktreeRemoved: boolean;
429
444
  branchRemoved: boolean;
430
445
  preserved?: boolean;
@@ -780,6 +795,7 @@ export interface SteeringRecoveryDescriptor {
780
795
  thinking?: string;
781
796
  thinkingCeiling?: ThinkingLevel;
782
797
  tools?: string[];
798
+ excludeTools?: string[];
783
799
  allowNestedSubagents?: boolean;
784
800
  extensions?: string[];
785
801
  subagentOnlyExtensions?: string[];
@@ -994,6 +1010,7 @@ export interface AcceptanceReviewGate {
994
1010
 
995
1011
  export interface AcceptanceConfig {
996
1012
  level?: AcceptanceLevel;
1013
+ report?: "on" | "off";
997
1014
  criteria?: Array<string | AcceptanceGate>;
998
1015
  evidence?: AcceptanceEvidenceKind[];
999
1016
  verify?: AcceptanceVerifyCommand[];
@@ -1200,6 +1217,8 @@ export interface SingleResult {
1200
1217
  * result row's array position.
1201
1218
  */
1202
1219
  index: number;
1220
+ /** Workflow child key that owns this result when returned from workflow details. */
1221
+ workflowKey?: string;
1203
1222
  agent: string;
1204
1223
  task: string;
1205
1224
  /** Human-readable display name for the child's own session (agent + task
@@ -1305,6 +1324,8 @@ export interface WaitCompletionChild {
1305
1324
  sessionFile?: string;
1306
1325
  success?: boolean;
1307
1326
  outputState?: SubagentOutputState;
1327
+ structuredOutput?: unknown;
1328
+ structuredOutputPath?: string;
1308
1329
  error?: string;
1309
1330
  model?: string;
1310
1331
  contextOverflow?: boolean;
@@ -1342,7 +1363,7 @@ export interface AgentCapabilityRow {
1342
1363
  restrictionSources?: string[];
1343
1364
  aliases?: string[];
1344
1365
  runner: { type: "pi" } | { type: "external-cli"; adapter?: string; capabilities: ExternalCliCapabilities } | { type: "external-job"; provider: string; available?: boolean; capabilities: ExternalJobRunnerStatus["capabilities"] };
1345
- tools: { ambient: boolean; names: string[]; mcpDirectTools: string[]; mutationTools?: string[] };
1366
+ tools: { ambient: boolean; names: string[]; excludeTools?: string[]; mcpDirectTools: string[]; mutationTools?: string[] };
1346
1367
  model?: { value?: string; fallbackModels?: string[]; thinking?: string | false };
1347
1368
  execution?: { defaultAsync?: boolean; timeoutMs?: number };
1348
1369
  output?: { path?: string; mode?: OutputMode };
@@ -1839,6 +1860,10 @@ export interface AsyncStatus {
1839
1860
  worktreePath?: string;
1840
1861
  /** Display-only branch copied at launch; handoff remains authoritative. */
1841
1862
  branch?: string;
1863
+ /** Display-only provisioning provider copied at launch; handoff remains authoritative. */
1864
+ provider?: ManagedWorktreeProvider;
1865
+ /** Display-only naming evidence copied at launch; handoff remains authoritative. */
1866
+ naming?: WorktreeNaming;
1842
1867
  /** Child run identity for workflow capacity reconciliation. */
1843
1868
  runId?: string;
1844
1869
  /** True only when this workflow child owns a detached async runner. */
@@ -2552,6 +2577,12 @@ export interface ExtensionConfig {
2552
2577
  worktreeSetupHook?: string;
2553
2578
  worktreeSetupHookTimeoutMs?: number;
2554
2579
  worktreeBaseDir?: string;
2580
+ /** Enable managed worktrees when a launch does not provide an explicit value. */
2581
+ worktree?: boolean;
2582
+ /** Worktree allocator selection. Defaults to auto. */
2583
+ worktreeProvider?: WorktreeProvider;
2584
+ /** Namespace used by managed worktree branches. Defaults to pi-subagents/. */
2585
+ worktreeBranchPrefix?: string;
2555
2586
  /** Where to store subagent artifact files. Defaults to "session" (the pi session directory, or OS temp when unavailable). Set to "project" for cwd/.pi/subagents. */
2556
2587
  artifactDir?: ArtifactDirPreference;
2557
2588
  /** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
@@ -237,6 +237,12 @@ export function findLatestSessionFile(sessionDir: string): string | null {
237
237
  return latest ? latest.path : null;
238
238
  }
239
239
 
240
+ const PI_TURN_TIMING_FOOTER = /(?:\r?\n)*\x1b\[38;2;136;136;136m✻ Turn took [^()\r\n]+ \(Total time [^·\r\n]+ · \d+ turns?\)\x1b\[0m[ \t]*$/u;
241
+
242
+ function stripPiTurnTimingFooter(text: string): string {
243
+ return text.replace(PI_TURN_TIMING_FOOTER, "");
244
+ }
245
+
240
246
  export function getFinalOutput(messages: Message[]): string {
241
247
  const validTextParts: string[] = [];
242
248
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -246,21 +252,26 @@ export function getFinalOutput(messages: Message[]): string {
246
252
  || ("stopReason" in msg && msg.stopReason === "error");
247
253
  if (hasAssistantError) continue;
248
254
  const messageText = msg.content
249
- .filter((part) => part.type === "text" && part.text.trim().length > 0)
250
- .map((part) => part.type === "text" ? part.text : "")
255
+ .flatMap((part) => {
256
+ if (part.type !== "text") return [];
257
+ const text = stripPiTurnTimingFooter(part.text);
258
+ return text.trim().length > 0 ? [text] : [];
259
+ })
251
260
  .join("\n");
252
261
  for (let j = msg.content.length - 1; j >= 0; j--) {
253
262
  const part = msg.content[j];
254
- if (!part || part.type !== "text" || part.text.trim().length === 0) continue;
255
- validTextParts.push(part.text);
256
- if (/```acceptance[-_]report\s*\n[\s\S]*?```/i.test(part.text)) return messageText;
257
- for (const match of part.text.matchAll(/```(?:json|jsonc|json5)\s*\n([\s\S]*?)```/gi)) {
263
+ if (!part || part.type !== "text") continue;
264
+ const text = stripPiTurnTimingFooter(part.text);
265
+ if (text.trim().length === 0) continue;
266
+ validTextParts.push(text);
267
+ if (/```acceptance[-_]report\s*\n[\s\S]*?```/i.test(text)) return messageText;
268
+ for (const match of text.matchAll(/```(?:json|jsonc|json5)\s*\n([\s\S]*?)```/gi)) {
258
269
  const body = match[1] ?? "";
259
270
  if (/"(?:criteriaSatisfied|criteria_satisfied)"/.test(body) && /"(?:changedFiles|changed_files|testsAddedOrUpdated|tests_added_or_updated|commandsRun|commands_run|validationOutput|validation_output|residualRisks|residual_risks|noStagedFiles|no_staged_files|diffSummary|diff_summary|reviewFindings|review_findings|manualNotes|manual_notes)"/.test(body)) {
260
271
  return messageText;
261
272
  }
262
273
  }
263
- if (/ACCEPTANCE_REPORT\s*:/i.test(part.text)) return messageText;
274
+ if (/ACCEPTANCE_REPORT\s*:/i.test(text)) return messageText;
264
275
  }
265
276
  }
266
277
  return validTextParts[0] ?? "";
@@ -417,7 +417,7 @@ function detailsFromSessionEntry(entry: unknown): Details | undefined {
417
417
  }
418
418
  if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
419
419
  const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
420
- if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait" && message.toolName !== "subagent_wait")) return undefined;
420
+ if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait")) return undefined;
421
421
  return isSubagentDetails(message.details) ? message.details : undefined;
422
422
  }
423
423
 
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
- agentHasFrontmatterField,
6
5
  discoverAgentsAll,
7
6
  EXTRA_AGENT_DIRS_ENV,
8
7
  frontmatterNameForConfig,
@@ -97,6 +96,7 @@ function buildBuiltinBase(agent: AgentConfig): BuiltinAgentOverrideBase {
97
96
  systemPrompt: agent.systemPrompt,
98
97
  ...(agent.skills !== undefined ? { skills: [...agent.skills] } : {}),
99
98
  ...(agent.tools !== undefined ? { tools: [...agent.tools] } : {}),
99
+ ...(agent.excludeTools !== undefined ? { excludeTools: [...agent.excludeTools] } : {}),
100
100
  ...(agent.mcpDirectTools !== undefined ? { mcpDirectTools: [...agent.mcpDirectTools] } : {}),
101
101
  ...(agent.subagentOnlyExtensions !== undefined ? { subagentOnlyExtensions: [...agent.subagentOnlyExtensions] } : {}),
102
102
  ...(agent.mutationTools !== undefined ? { mutationTools: [...agent.mutationTools] } : {}),
@@ -115,18 +115,15 @@ type AgentSelection =
115
115
 
116
116
  function savesThroughSettings(agent: AgentConfig, field: EditableOverrideField): boolean {
117
117
  if (agent.source === "builtin") return true;
118
- if (agent.source === "package") {
119
- if (field === "systemPrompt") return false;
120
- return !agentHasFrontmatterField(agent, field);
121
- }
118
+ if (agent.source === "package") return true;
122
119
  if (!agent.override) return false;
120
+ if (agent.override.fields?.includes(field) === true) return true;
123
121
  // A lower-scope override can flow into a higher-scope custom agent with the
124
122
  // same name. Persist that agent's edits in its own frontmatter instead of
125
123
  // rewriting the shared lower-scope override used by another agent.
126
124
  if (agent.source !== agent.override.scope) return false;
127
- // Custom-agent overrides fill only fields absent from frontmatter. Compare the
128
- // effective value with the pre-override base so an override on one field does
129
- // not redirect edits to an unrelated frontmatter-owned field.
125
+ // Compare the effective value with the pre-override base so an override on one
126
+ // field does not redirect edits to an unrelated frontmatter-owned field.
130
127
  return agent[field] !== agent.override.base[field];
131
128
  }
132
129
 
@@ -188,6 +185,7 @@ function metadataFor(agent: AgentConfig): string {
188
185
  if (agent.fallbackModels?.length) lines.push(`Fallback models: ${agent.fallbackModels.join(", ")}`);
189
186
  if (agent.thinking !== undefined) lines.push(`Thinking: ${agent.thinking === false ? "off" : agent.thinking}`);
190
187
  if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
188
+ if (agent.excludeTools?.length) lines.push(`Excluded tools: ${agent.excludeTools.join(", ")}`);
191
189
  if (agent.skills?.length) lines.push(`Skills: ${agent.skills.join(", ")}`);
192
190
  lines.push(`System prompt mode: ${agent.systemPromptMode}`);
193
191
  lines.push(`Inherit project context: ${agent.inheritProjectContext ? "true" : "false"}`);
@@ -254,10 +252,12 @@ async function chooseThinking(ctx: ExtensionContext, agent: AgentConfig): Promis
254
252
  }
255
253
 
256
254
  async function chooseOverrideScope(ctx: ExtensionContext, agent: AgentConfig): Promise<"user" | "project" | undefined> {
257
- if (agent.override?.scope) return agent.override.scope;
258
255
  const d = discoverAgentsAll(ctx.cwd);
256
+ if (agent.override?.scope) {
257
+ return agent.source === "project" && agent.override.scope === "user" && d.projectSettingsPath ? "project" : agent.override.scope;
258
+ }
259
259
  if (!d.projectSettingsPath || !ctx.hasUI) return "user";
260
- const choice = await ctx.ui.select(`Save builtin override for ${agent.name}`, ["user", "project"]);
260
+ const choice = await ctx.ui.select(`Save subagent override for ${agent.name}`, ["user", "project"]);
261
261
  return choice === "user" || choice === "project" ? choice : undefined;
262
262
  }
263
263
 
@@ -269,6 +269,18 @@ function persistSettingsField(
269
269
  value: string | undefined,
270
270
  ): { filePath: string; overridden: boolean } {
271
271
  const base = agent.override?.base ?? buildBuiltinBase(agent);
272
+ const shadowsLowerScope = scope === "project" && agent.override?.fieldScopes?.[field]?.includes("user") === true;
273
+ if (shadowsLowerScope && (value === undefined || value === base[field])) {
274
+ const filePath = field === "model"
275
+ ? mergeBuiltinAgentOverride(ctx.cwd, agent.name, scope, { model: value ?? base.model ?? false })
276
+ : field === "thinking"
277
+ ? mergeBuiltinAgentOverride(ctx.cwd, agent.name, scope, { thinking: value ?? (base.thinking === false ? "off" : base.thinking) ?? false })
278
+ : mergeBuiltinAgentOverride(ctx.cwd, agent.name, scope, { systemPrompt: value ?? base.systemPrompt });
279
+ return {
280
+ filePath,
281
+ overridden: true,
282
+ };
283
+ }
272
284
  if (value === undefined || value === base[field]) {
273
285
  return {
274
286
  filePath: removeBuiltinAgentOverrideFields(ctx.cwd, agent.name, scope, [field]).path,
@@ -356,13 +368,15 @@ async function saveAgentSystemPrompt(ctx: ExtensionContext, agent: AgentConfig,
356
368
  }
357
369
 
358
370
  async function editSystemPrompt(ctx: ExtensionContext, agent: AgentConfig): Promise<string | null> {
359
- if (!savesThroughSettings(agent, "systemPrompt")) {
371
+ const saveThroughSettings = savesThroughSettings(agent, "systemPrompt");
372
+ if (!saveThroughSettings) {
360
373
  const readOnlyMessage = readOnlyAgentMessage(agent, "systemPrompt");
361
374
  if (readOnlyMessage) return readOnlyMessage;
362
375
  }
363
376
  const edited = await ctx.ui.editor(`Edit '${agent.name}' system prompt`, agent.systemPrompt ?? "");
364
377
  if (edited === undefined) return null;
365
- if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
378
+ const projectOwnsLowerScopeOverride = agent.source === "project" && agent.override?.scope === "user" && discoverAgentsAll(ctx.cwd).projectSettingsPath;
379
+ if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "") && !(saveThroughSettings && projectOwnsLowerScopeOverride)) {
366
380
  return `System prompt for '${agent.name}' left unchanged.`;
367
381
  }
368
382
  return saveAgentSystemPrompt(ctx, agent, edited);
@@ -8,6 +8,7 @@ import { contextModeLabel } from "../runs/shared/context-mode.ts";
8
8
  import { formatWorkflowJsonPreview } from "../workflows/scripted-workflow.ts";
9
9
  import { hostStepReportName, hostStepVerdictLabel } from "../runs/shared/host-step-status.ts";
10
10
  import { isStaleExtensionContextError } from "../shared/extension-context.ts";
11
+ import { formatWorkflowChecklistBottleneck, formatWorkflowChecklistPhase, formatWorkflowChecklistSummary, projectWorkflowChecklist, type WorkflowChecklistPhase, type WorkflowChecklistProjection } from "../workflows/workflow-checklist.ts";
11
12
 
12
13
  export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
13
14
 
@@ -35,6 +36,7 @@ type FleetStatusEntry = {
35
36
  projectPane?: HerdrProjectPaneSnapshot;
36
37
  nestedChildren?: NestedRunSummary[];
37
38
  workflowRows?: AsyncStatusWorkflowRow[];
39
+ workflowChecklist?: WorkflowChecklistProjection;
38
40
  };
39
41
 
40
42
  type FleetNestedRow = {
@@ -50,6 +52,7 @@ type FleetNestedRow = {
50
52
  type FleetTreeRow =
51
53
  | { kind: "owner"; entry: FleetStatusEntry }
52
54
  | { kind: "child"; entry: FleetStatusEntry; last: boolean }
55
+ | { kind: "workflow-phase"; ownerKey: string; phase: WorkflowChecklistPhase; last: boolean }
53
56
  | { kind: "workflow"; ownerKey: string; row: AsyncStatusWorkflowRow; last: boolean }
54
57
  | { kind: "nested"; ownerKey: string; row: FleetNestedRow; last: boolean };
55
58
 
@@ -117,6 +120,18 @@ function visibleWorkflowRows(rows: AsyncStatusWorkflowRow[] | undefined, visible
117
120
  return [{ name: `… +${rows.length - visible.length} hidden workflow steps`, state: "complete", overflow: rows.length - visible.length }, ...visible];
118
121
  }
119
122
 
123
+ function visibleWorkflowPhases(checklist: WorkflowChecklistProjection | undefined, visibleLimit: number): WorkflowChecklistPhase[] {
124
+ const phases = checklist?.phases ?? [];
125
+ if (phases.length <= visibleLimit) return phases;
126
+ const selected = new Set<number>();
127
+ for (const [index, phase] of phases.entries()) {
128
+ if (phase.state !== "complete") selected.add(index);
129
+ if (selected.size >= visibleLimit) break;
130
+ }
131
+ for (let index = phases.length - 1; index >= 0 && selected.size < visibleLimit; index--) selected.add(index);
132
+ return [...selected].sort((left, right) => left - right).map((index) => phases[index]!);
133
+ }
134
+
120
135
  function isWorkflowRowTerminal(row: AsyncStatusWorkflowRow): boolean {
121
136
  if (row.kind) return row.state === "done" || row.state === "cancelled" || row.state === "error";
122
137
  return row.state === "complete" || row.state === "completed";
@@ -224,10 +239,11 @@ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
224
239
  if (entry.parentKey && entryKeys.has(entry.parentKey)) continue;
225
240
  rows.push({ kind: "owner", entry });
226
241
  const attached = childrenByParent.get(entry.key) ?? [];
242
+ const workflowPhases = visibleWorkflowPhases(entry.workflowChecklist, attached.length > 0 ? 2 : 4);
227
243
  const workflowRows = visibleWorkflowRows(entry.workflowRows, attached.length > 0 ? 2 : 4);
228
244
  for (const [index, child] of attached.entries()) {
229
245
  const nested = nestedFleetRows(child.nestedChildren, 3);
230
- const laterRows = index < attached.length - 1 || workflowRows.length > 0 || Boolean(entry.nestedChildren?.length);
246
+ const laterRows = index < attached.length - 1 || workflowPhases.length > 0 || workflowRows.length > 0 || Boolean(entry.nestedChildren?.length);
231
247
  rows.push({ kind: "child", entry: child, last: !laterRows && nested.length === 0 });
232
248
  for (const [nestedIndex, row] of nested.entries()) rows.push({
233
249
  kind: "nested",
@@ -236,6 +252,12 @@ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
236
252
  last: nestedIndex === nested.length - 1 && !laterRows,
237
253
  });
238
254
  }
255
+ for (const [index, phase] of workflowPhases.entries()) rows.push({
256
+ kind: "workflow-phase",
257
+ ownerKey: entry.key,
258
+ phase,
259
+ last: index === workflowPhases.length - 1 && workflowRows.length === 0 && !entry.nestedChildren?.length,
260
+ });
239
261
  for (const [index, row] of workflowRows.entries()) rows.push({ kind: "workflow", ownerKey: entry.key, row, last: index === workflowRows.length - 1 && !entry.nestedChildren?.length });
240
262
  const nested = nestedFleetRows(entry.nestedChildren, attached.length > 0 ? 3 : 4);
241
263
  for (const [index, row] of nested.entries()) rows.push({ kind: "nested", ownerKey: entry.key, row, last: index === nested.length - 1 });
@@ -364,6 +386,14 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
364
386
  const latestEmit = job.workflow?.emits?.length ? formatWorkflowJsonPreview(job.workflow.emits.at(-1), 120) : undefined;
365
387
  const workflowSteps = workflowStepsWithoutMaterializedChildren(job.steps, materializedChildrenByWorkflow.get(`async:${job.asyncId}`));
366
388
  const workflowRows = projectAsyncWorkflowRows(workflowSteps, job.workflowGraph ?? job.hostSteps, job.preflight);
389
+ const workflowChecklist = projectWorkflowChecklist({
390
+ graph: job.workflowGraph,
391
+ steps: job.steps,
392
+ hostSteps: job.hostSteps,
393
+ preflight: job.preflight,
394
+ trace: job.workflow?.trace,
395
+ now: job.updatedAt ?? Date.now(),
396
+ });
367
397
  entries.push({
368
398
  key: `async:${job.asyncId}`,
369
399
  ...(linkedParentKey ? { parentKey: linkedParentKey } : {}),
@@ -375,6 +405,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
375
405
  ...(job.totalTokens?.window !== undefined ? { window: job.totalTokens.window } : {}),
376
406
  state: job.status,
377
407
  ...(workflowRows.length ? { workflowRows } : {}),
408
+ ...(workflowChecklist.total ? { workflowChecklist } : {}),
378
409
  ...(job.nestedChildren?.length ? { nestedChildren: job.nestedChildren } : {}),
379
410
  });
380
411
  continue;
@@ -668,6 +699,8 @@ export class SubagentFleetStatus {
668
699
  lines.push(this.renderEntry(rosterIndex, selectedIndex, row.entry, width, theme, row.kind === "child" ? (row.last ? "└─" : "├─") : undefined));
669
700
  } else if (row.kind === "workflow") {
670
701
  lines.push(this.renderWorkflowRow(row.row, row.last, width, theme));
702
+ } else if (row.kind === "workflow-phase") {
703
+ lines.push(this.renderWorkflowPhaseRow(row.phase, row.last, width, theme));
671
704
  } else {
672
705
  lines.push(this.renderNestedRow(row.row, row.last, width, theme));
673
706
  }
@@ -691,7 +724,10 @@ export class SubagentFleetStatus {
691
724
  private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetStatusEntry, width: number, theme: Theme, branch?: string): string {
692
725
  const agent = entry.modelThinking ? `${entry.agent} (${entry.modelThinking})` : entry.agent;
693
726
  const prefix = branch ? ` ${branch}` : " ";
694
- const left = `${prefix} ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)} · ${entry.state}`;
727
+ const checklist = entry.workflowWrapper && entry.workflowChecklist
728
+ ? ` · checklist ${formatWorkflowChecklistSummary(entry.workflowChecklist)}${entry.workflowChecklist.bottleneck ? ` · bottleneck ${formatWorkflowChecklistBottleneck(entry.workflowChecklist.bottleneck)}` : ""}`
729
+ : "";
730
+ const left = `${prefix} ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)} · ${entry.state}${checklist}`;
695
731
  const elapsed = Date.now() - entry.startedAt;
696
732
  const rightText = entry.projectPane
697
733
  ? `${entry.projectPane.summary ?? "—"} · ${formatFleetElapsed(Date.now() - entry.projectPane.refreshedAt)} ago`
@@ -730,6 +766,20 @@ export class SubagentFleetStatus {
730
766
  return theme.fg("warning", state);
731
767
  }
732
768
 
769
+ private renderWorkflowPhaseRow(phase: WorkflowChecklistPhase, last: boolean, width: number, theme: Theme): string {
770
+ const marker = last ? "└─" : "├─";
771
+ const glyph = phase.state === "complete"
772
+ ? theme.fg("success", "✓")
773
+ : phase.state === "running"
774
+ ? theme.fg("accent", "●")
775
+ : phase.state === "blocked" || phase.state === "failed"
776
+ ? theme.fg("error", phase.state === "blocked" ? "!" : "✗")
777
+ : phase.state === "queued"
778
+ ? theme.fg("muted", "◦")
779
+ : theme.fg("warning", "■");
780
+ return truncateToWidth(` ${marker} ${glyph} ${theme.fg("muted", formatWorkflowChecklistPhase(phase))}`, width);
781
+ }
782
+
733
783
  private renderWorkflowRow(row: AsyncStatusWorkflowRow, last: boolean, width: number, theme: Theme): string {
734
784
  const marker = last ? "└─" : "├─";
735
785
  const indent = " ";
@@ -809,6 +859,15 @@ export class SubagentFleetStatus {
809
859
  entry.external,
810
860
  Math.round((now - entry.startedAt) / 1000),
811
861
  entry.tokens,
862
+ entry.workflowChecklist ? [
863
+ entry.workflowChecklist.total,
864
+ entry.workflowChecklist.done,
865
+ entry.workflowChecklist.running,
866
+ entry.workflowChecklist.queued,
867
+ entry.workflowChecklist.blocked,
868
+ entry.workflowChecklist.failed,
869
+ entry.workflowChecklist.phases.map((phase) => [phase.key, phase.state, phase.done, phase.total, phase.running, phase.queued, phase.blocked, phase.failed, phase.items.map((item) => [item.key, item.state, item.currentTool, item.currentPath, item.durationMs, item.toolCount, item.error])]),
870
+ ] : undefined,
812
871
  visibleWorkflowRows(entry.workflowRows, entry.parentKey ? 2 : 4).map((row) => [
813
872
  row.kind,
814
873
  row.name,
package/src/tui/fleet.ts CHANGED
@@ -42,7 +42,7 @@ export const DEFAULT_FLEET_KEYBINDINGS: Record<FleetKeybindingAction, string[]>
42
42
  pageDown: ["pageDown"],
43
43
  refresh: ["r", "R"],
44
44
  steer: ["s"],
45
- inspect: ["H"],
45
+ inspect: ["return", "H"],
46
46
  stop: ["D"],
47
47
  toggleTools: ["x", "X", "ctrl+o"],
48
48
  };
@@ -952,6 +952,12 @@ export class SubagentFleetComponent implements Component {
952
952
  return { runId: parent.asyncId, asyncDir: parent.asyncDir };
953
953
  }
954
954
 
955
+ private inspectSelectedHerdr(): void {
956
+ const target = this.selectedHerdrInspectAction();
957
+ if ("reason" in target || !this.options.actions?.inspect) this.setActionNotice({ text: "reason" in target ? target.reason : "Herdr inspector controls are unavailable in this context.", isError: true });
958
+ else this.runAction(() => this.options.actions!.inspect!(target));
959
+ }
960
+
955
961
  private actionLines(): string[] {
956
962
  const lines: string[] = [];
957
963
  if (this.actionBusy) lines.push(this.theme.fg("accent", "Action pending..."));
@@ -1179,12 +1185,6 @@ export class SubagentFleetComponent implements Component {
1179
1185
  }
1180
1186
  return;
1181
1187
  }
1182
- if (matchesFleetAction(data, this.keybindings, "inspect")) {
1183
- const target = this.selectedHerdrInspectAction();
1184
- if ("reason" in target || !this.options.actions?.inspect) this.setActionNotice({ text: "reason" in target ? target.reason : "Herdr inspector controls are unavailable in this context.", isError: true });
1185
- else this.runAction(() => this.options.actions!.inspect!(target));
1186
- return;
1187
- }
1188
1188
  if (matchesFleetAction(data, this.keybindings, "stop")) {
1189
1189
  const target = this.selectedAsyncAction();
1190
1190
  if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
@@ -1201,6 +1201,11 @@ export class SubagentFleetComponent implements Component {
1201
1201
  this.expandedTools = !this.expandedTools;
1202
1202
  this.transcriptCache = undefined;
1203
1203
  this.tui.requestRender();
1204
+ return;
1205
+ }
1206
+ if (matchesFleetAction(data, this.keybindings, "inspect")) {
1207
+ this.inspectSelectedHerdr();
1208
+ return;
1204
1209
  }
1205
1210
  }
1206
1211