pi-subagents 0.40.0 → 0.41.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 (119) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +246 -525
  3. package/agents/oracle.md +1 -0
  4. package/package.json +12 -4
  5. package/prompts/parallel-context-build.md +1 -1
  6. package/prompts/parallel-handoff-plan.md +1 -1
  7. package/prompts/review-loop.md +1 -1
  8. package/skills/pi-subagents/SKILL.md +6 -6
  9. package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
  10. package/skills/pi-subagents/references/execution-controls.md +98 -97
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  12. package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
  13. package/src/agents/agent-management.ts +155 -65
  14. package/src/agents/agent-serializer.ts +19 -0
  15. package/src/agents/agents.ts +154 -71
  16. package/src/agents/chain-serializer.ts +10 -7
  17. package/src/agents/frontmatter.ts +5 -3
  18. package/src/agents/identity.ts +1 -1
  19. package/src/agents/proactive-skills.ts +13 -10
  20. package/src/agents/skills.ts +23 -6
  21. package/src/api/control-channel.ts +4 -0
  22. package/src/api/delegation.ts +26 -194
  23. package/src/api/external-runs.ts +129 -0
  24. package/src/api/intercom-bridge.ts +3 -0
  25. package/src/api/pi-args.ts +5 -0
  26. package/src/api/preflight.ts +3 -3
  27. package/src/api/shared-types.ts +19 -0
  28. package/src/extension/config.ts +10 -0
  29. package/src/extension/control-notices.ts +5 -39
  30. package/src/extension/doctor.ts +10 -9
  31. package/src/extension/fanout-child.ts +7 -4
  32. package/src/extension/index.ts +232 -68
  33. package/src/extension/rpc.ts +18 -12
  34. package/src/extension/schemas.ts +48 -37
  35. package/src/extension/tool-description.ts +36 -86
  36. package/src/inspectors/herdr/actions.ts +229 -0
  37. package/src/inspectors/herdr/client.ts +130 -0
  38. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  39. package/src/inspectors/herdr/project-panes.ts +154 -0
  40. package/src/integrations/herdr-status.ts +330 -0
  41. package/src/intercom/intercom-bridge.ts +3 -2
  42. package/src/intercom/result-intercom.ts +5 -1
  43. package/src/missions/actions.ts +372 -0
  44. package/src/missions/lifecycle.ts +314 -0
  45. package/src/missions/store.ts +442 -0
  46. package/src/missions/types.ts +135 -0
  47. package/src/policy/authority.ts +46 -0
  48. package/src/profiles/profiles.ts +29 -3
  49. package/src/runs/background/async-execution.ts +98 -49
  50. package/src/runs/background/async-job-tracker.ts +10 -2
  51. package/src/runs/background/async-resume.ts +6 -6
  52. package/src/runs/background/async-status.ts +29 -1
  53. package/src/runs/background/auto-drain.ts +3 -3
  54. package/src/runs/background/chain-append.ts +3 -2
  55. package/src/runs/background/control-channel.ts +9 -7
  56. package/src/runs/background/fleet-view.ts +3 -4
  57. package/src/runs/background/notify.ts +2 -1
  58. package/src/runs/background/process-terminal.ts +5 -5
  59. package/src/runs/background/result-watcher.ts +13 -5
  60. package/src/runs/background/run-id-resolver.ts +3 -3
  61. package/src/runs/background/run-status.ts +35 -8
  62. package/src/runs/background/scheduled-runs.ts +602 -375
  63. package/src/runs/background/stale-run-reconciler.ts +3 -3
  64. package/src/runs/background/subagent-runner.ts +608 -445
  65. package/src/runs/background/subagent-wait.ts +50 -9
  66. package/src/runs/background/wait-subscriptions.ts +253 -0
  67. package/src/runs/background/wait-tool.ts +12 -4
  68. package/src/runs/foreground/async-steering-action.ts +3 -3
  69. package/src/runs/foreground/chain-clarify.ts +8 -4
  70. package/src/runs/foreground/chain-execution.ts +56 -30
  71. package/src/runs/foreground/execution.ts +15 -2
  72. package/src/runs/foreground/subagent-executor.ts +1023 -273
  73. package/src/runs/shared/acceptance.ts +28 -6
  74. package/src/runs/shared/child-protocol.ts +302 -22
  75. package/src/runs/shared/dynamic-fanout.ts +1 -1
  76. package/src/runs/shared/external-cli-runner.ts +130 -0
  77. package/src/runs/shared/long-running-guard.ts +42 -1
  78. package/src/runs/shared/nested-events.ts +59 -5
  79. package/src/runs/shared/nested-render.ts +9 -4
  80. package/src/runs/shared/parallel-handoff.ts +86 -2
  81. package/src/runs/shared/parallel-utils.ts +11 -2
  82. package/src/runs/shared/permissions.ts +95 -0
  83. package/src/runs/shared/pi-args.ts +11 -1
  84. package/src/runs/shared/pi-spawn.ts +11 -1
  85. package/src/runs/shared/run-history.ts +1 -1
  86. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  87. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  88. package/src/runs/shared/turn-budget.ts +6 -6
  89. package/src/runs/shared/worktree.ts +122 -12
  90. package/src/shared/accessible-dir.ts +29 -7
  91. package/src/shared/artifacts.ts +18 -1
  92. package/src/shared/fork-context.ts +3 -2
  93. package/src/shared/launch-contract.ts +1 -0
  94. package/src/shared/settings.ts +10 -0
  95. package/src/shared/types.ts +156 -14
  96. package/src/shared/utils.ts +8 -6
  97. package/src/slash/delegation-adapters.ts +32 -194
  98. package/src/slash/delegation-request.ts +43 -126
  99. package/src/slash/prompt-template-bridge.ts +158 -205
  100. package/src/slash/prompt-workflows.ts +21 -57
  101. package/src/slash/slash-bridge.ts +14 -0
  102. package/src/slash/slash-commands.ts +31 -632
  103. package/src/slash/subagents-admin.ts +18 -14
  104. package/src/tui/fleet-status.ts +156 -21
  105. package/src/tui/fleet-transcript.ts +110 -5
  106. package/src/tui/fleet.ts +56 -24
  107. package/src/tui/render.ts +291 -109
  108. package/src/types/pi-runtime-compat.d.ts +14 -0
  109. package/src/watchdog/lsp-diagnostics.ts +12 -7
  110. package/src/watchdog/model-selection.ts +2 -2
  111. package/src/watchdog/permission-arbiter.ts +145 -0
  112. package/src/watchdog/register-child.ts +1 -1
  113. package/src/watchdog/register-main.ts +1 -1
  114. package/src/watchdog/review.ts +4 -1
  115. package/src/watchdog/runtime.ts +3 -2
  116. package/src/workflows/chat-progress.ts +140 -0
  117. package/src/workflows/scripted-workflow.ts +415 -0
  118. package/agents/advisor.md +0 -73
  119. package/src/extension/chain-validation.ts +0 -181
@@ -84,22 +84,22 @@ function liveAvailableModels(ctx: ExtensionContext) {
84
84
 
85
85
  function buildBuiltinBase(agent: AgentConfig): BuiltinAgentOverrideBase {
86
86
  return {
87
- model: agent.model,
88
- fallbackModels: agent.fallbackModels ? [...agent.fallbackModels] : undefined,
89
- thinking: agent.thinking,
87
+ ...(agent.model !== undefined ? { model: agent.model } : {}),
88
+ ...(agent.fallbackModels !== undefined ? { fallbackModels: [...agent.fallbackModels] } : {}),
89
+ ...(agent.thinking !== undefined ? { thinking: agent.thinking } : {}),
90
90
  systemPromptMode: agent.systemPromptMode,
91
91
  inheritProjectContext: agent.inheritProjectContext,
92
92
  inheritSkills: agent.inheritSkills,
93
- defaultContext: agent.defaultContext,
94
- acceptanceRole: agent.acceptanceRole,
95
- disabled: agent.disabled,
93
+ ...(agent.defaultContext !== undefined ? { defaultContext: agent.defaultContext } : {}),
94
+ ...(agent.acceptanceRole !== undefined ? { acceptanceRole: agent.acceptanceRole } : {}),
95
+ ...(agent.disabled !== undefined ? { disabled: agent.disabled } : {}),
96
96
  systemPrompt: agent.systemPrompt,
97
- skills: agent.skills ? [...agent.skills] : undefined,
98
- tools: agent.tools ? [...agent.tools] : undefined,
99
- mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
100
- subagentOnlyExtensions: agent.subagentOnlyExtensions ? [...agent.subagentOnlyExtensions] : undefined,
101
- completionGuard: agent.completionGuard,
102
- toolBudget: agent.toolBudget,
97
+ ...(agent.skills !== undefined ? { skills: [...agent.skills] } : {}),
98
+ ...(agent.tools !== undefined ? { tools: [...agent.tools] } : {}),
99
+ ...(agent.mcpDirectTools !== undefined ? { mcpDirectTools: [...agent.mcpDirectTools] } : {}),
100
+ ...(agent.subagentOnlyExtensions !== undefined ? { subagentOnlyExtensions: [...agent.subagentOnlyExtensions] } : {}),
101
+ ...(agent.completionGuard !== undefined ? { completionGuard: agent.completionGuard } : {}),
102
+ ...(agent.toolBudget !== undefined ? { toolBudget: agent.toolBudget } : {}),
103
103
  };
104
104
  }
105
105
 
@@ -290,7 +290,9 @@ async function saveAgentModel(ctx: ExtensionContext, agent: AgentConfig, selecte
290
290
 
291
291
  const readOnlyMessage = readOnlyAgentMessage(agent, "model");
292
292
  if (readOnlyMessage) return readOnlyMessage;
293
- const updated: AgentConfig = { ...editableAgentConfig(agent), model: selectedModel };
293
+ const updated = editableAgentConfig(agent);
294
+ if (selectedModel === undefined) delete updated.model;
295
+ else updated.model = selectedModel;
294
296
  fs.writeFileSync(updated.filePath, serializeAgent(updated, {
295
297
  preserveFrontmatterFields: preservedAgentFrontmatterFields(agent, { model: selectedModel }),
296
298
  }), "utf-8");
@@ -311,7 +313,9 @@ async function saveAgentThinking(ctx: ExtensionContext, agent: AgentConfig, sele
311
313
 
312
314
  const readOnlyMessage = readOnlyAgentMessage(agent, "thinking");
313
315
  if (readOnlyMessage) return readOnlyMessage;
314
- const updated: AgentConfig = { ...editableAgentConfig(agent), thinking: selectedThinking };
316
+ const updated = editableAgentConfig(agent);
317
+ if (selectedThinking === undefined) delete updated.thinking;
318
+ else updated.thinking = selectedThinking;
315
319
  fs.writeFileSync(updated.filePath, serializeAgent(updated, {
316
320
  preserveFrontmatterFields: preservedAgentFrontmatterFields(agent, { thinking: selectedThinking }),
317
321
  }), "utf-8");
@@ -1,11 +1,13 @@
1
1
  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 { formatModelThinking } from "../shared/formatters.ts";
4
- import type { AsyncJobStep, FleetViewPlacement, SubagentState } from "../shared/types.ts";
4
+ import type { AsyncJobStep, FleetViewPlacement, NestedRunSummary, NestedStepSummary, SubagentState } from "../shared/types.ts";
5
+ import { formatWorkflowJsonPreview } from "../workflows/scripted-workflow.ts";
5
6
 
6
7
  export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
7
8
 
8
- const MAX_AGENT_ROWS = 5;
9
+ // Six rows fit the accepted collapsed hierarchy: one owner, four direct leaves, and overflow.
10
+ const MAX_AGENT_ROWS = 6;
9
11
  const REFRESH_MS = 500;
10
12
 
11
13
  type Theme = ExtensionContext["ui"]["theme"];
@@ -19,8 +21,23 @@ type FleetStatusEntry = {
19
21
  description?: string;
20
22
  startedAt: number;
21
23
  tokens: number;
24
+ state: string;
25
+ nestedChildren?: NestedRunSummary[];
22
26
  };
23
27
 
28
+ type FleetNestedRow = {
29
+ name: string;
30
+ state: NestedRunSummary["state"] | NestedStepSummary["status"];
31
+ modelThinking?: string;
32
+ activity?: string;
33
+ startedAt?: number;
34
+ overflow?: number;
35
+ };
36
+
37
+ type FleetTreeRow =
38
+ | { kind: "owner"; entry: FleetStatusEntry }
39
+ | { kind: "nested"; ownerKey: string; row: FleetNestedRow; last: boolean };
40
+
24
41
  export interface FleetStatusOptions {
25
42
  refreshMs?: number;
26
43
  maxAgentRows?: number;
@@ -55,6 +72,67 @@ function isActiveState(value: string): boolean {
55
72
  return value === "running" || value === "queued" || value === "pending";
56
73
  }
57
74
 
75
+ function nestedRunLabel(run: NestedRunSummary): string {
76
+ if (run.agent) return run.agent;
77
+ if (run.agents?.length) return run.agents.length === 1 ? run.agents[0]! : `${run.agents.slice(0, 2).join(", ")}${run.agents.length > 2 ? ` +${run.agents.length - 2}` : ""}`;
78
+ return run.id;
79
+ }
80
+
81
+ function nestedActivity(node: NestedRunSummary | NestedStepSummary): string | undefined {
82
+ if (node.currentTool) return `tool ${node.currentTool}`;
83
+ if (node.currentPath) return node.currentPath.split(/[\\/]/).at(-1);
84
+ if (node.activityState === "needs_attention") return "needs attention";
85
+ if (node.activityState === "active_long_running") return "long-running";
86
+ return undefined;
87
+ }
88
+
89
+ function nestedStatusGlyph(state: FleetNestedRow["state"], theme: Theme): string {
90
+ if (state === "running") return theme.fg("accent", "●");
91
+ if (state === "queued" || state === "pending") return theme.fg("muted", "◦");
92
+ if (state === "complete" || state === "completed") return theme.fg("success", "✓");
93
+ if (state === "failed" || state === "rejected") return theme.fg("error", "✗");
94
+ return theme.fg("warning", "■");
95
+ }
96
+
97
+ function nestedFleetRows(children: NestedRunSummary[] | undefined): FleetNestedRow[] {
98
+ const rows: FleetNestedRow[] = [];
99
+ for (const child of children ?? []) {
100
+ const steps = (child.mode === "parallel" || child.mode === "chain") ? child.steps ?? [] : [];
101
+ if (steps.length > 0) {
102
+ for (const step of steps) {
103
+ rows.push({
104
+ name: step.agent,
105
+ state: step.status,
106
+ ...(formatModelThinking(step.model, step.thinking) ? { modelThinking: formatModelThinking(step.model, step.thinking) } : {}),
107
+ ...(nestedActivity(step) ? { activity: nestedActivity(step) } : {}),
108
+ ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
109
+ });
110
+ }
111
+ continue;
112
+ }
113
+ rows.push({
114
+ name: nestedRunLabel(child),
115
+ state: child.state,
116
+ ...(formatModelThinking(child.model, child.thinking) ? { modelThinking: formatModelThinking(child.model, child.thinking) } : {}),
117
+ ...(nestedActivity(child) ? { activity: nestedActivity(child) } : {}),
118
+ ...(child.startedAt !== undefined ? { startedAt: child.startedAt } : {}),
119
+ });
120
+ }
121
+ const visible = rows.slice(0, 4);
122
+ if (rows.length > visible.length) visible.push({ name: `… +${rows.length - visible.length} more nested leaves`, state: "complete", overflow: rows.length - visible.length });
123
+ return visible;
124
+ }
125
+
126
+ function fleetTreeRows(entries: FleetStatusEntry[]): FleetTreeRow[] {
127
+ const rows: FleetTreeRow[] = [];
128
+ for (const entry of entries) {
129
+ rows.push({ kind: "owner", entry });
130
+ const nested = nestedFleetRows(entry.nestedChildren);
131
+ for (const [index, row] of nested.entries()) rows.push({ kind: "nested", ownerKey: entry.key, row, last: index === nested.length - 1 });
132
+ }
133
+ return rows;
134
+ }
135
+
58
136
  function isStaleExtensionContextError(error: unknown): boolean {
59
137
  // Pi currently exposes stale contexts as plain Errors without a stable code or subtype.
60
138
  return error instanceof Error
@@ -75,6 +153,10 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
75
153
  description: child.description,
76
154
  startedAt: child.startedAt,
77
155
  tokens: child.tokens ?? 0,
156
+ state: "running",
157
+ ...((control.nestedChildren?.filter((nested) => nested.parentStepIndex === child.index).length ?? 0) > 0
158
+ ? { nestedChildren: control.nestedChildren!.filter((nested) => nested.parentStepIndex === child.index) }
159
+ : control.activeChildren.size === 1 && control.nestedChildren?.length ? { nestedChildren: control.nestedChildren } : {}),
78
160
  });
79
161
  }
80
162
  continue;
@@ -87,12 +169,27 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
87
169
  description: control.description,
88
170
  startedAt: control.startedAt,
89
171
  tokens: control.tokens ?? 0,
172
+ state: "running",
173
+ ...(control.nestedChildren?.length ? { nestedChildren: control.nestedChildren } : {}),
90
174
  });
91
175
  }
92
176
 
93
177
  for (const job of state.asyncJobs.values()) {
94
178
  if (!isActiveState(job.status)) continue;
95
179
  const startedAt = job.startedAt ?? job.updatedAt ?? Date.now();
180
+ if (job.mode === "workflow") {
181
+ const latestEmit = job.workflow?.emits?.length ? formatWorkflowJsonPreview(job.workflow.emits.at(-1), 120) : undefined;
182
+ entries.push({
183
+ key: `async:${job.asyncId}`,
184
+ agent: "workflow",
185
+ description: latestEmit !== undefined ? `latest emit: ${latestEmit}` : job.description,
186
+ startedAt,
187
+ tokens: job.totalTokens?.total ?? 0,
188
+ state: job.status,
189
+ ...(job.nestedChildren?.length ? { nestedChildren: job.nestedChildren } : {}),
190
+ });
191
+ continue;
192
+ }
96
193
  const steps: AsyncJobStep[] | undefined = job.steps?.length
97
194
  ? job.steps
98
195
  : job.agents?.map((agent, index) => {
@@ -107,6 +204,8 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
107
204
  description: job.description,
108
205
  startedAt,
109
206
  tokens: job.totalTokens?.total ?? 0,
207
+ state: job.status,
208
+ ...(job.nestedChildren?.length ? { nestedChildren: job.nestedChildren } : {}),
110
209
  });
111
210
  continue;
112
211
  }
@@ -122,6 +221,12 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
122
221
  description: step.description ?? job.description,
123
222
  startedAt: step.startedAt ?? startedAt,
124
223
  tokens: step.tokens?.total ?? (steps.length === 1 ? job.totalTokens?.total ?? 0 : 0),
224
+ state: step.status,
225
+ ...((step.children?.length ?? 0) > 0
226
+ ? { nestedChildren: step.children }
227
+ : job.nestedChildren?.filter((nested) => nested.parentStepIndex === index).length
228
+ ? { nestedChildren: job.nestedChildren.filter((nested) => nested.parentStepIndex === index) }
229
+ : {}),
125
230
  });
126
231
  }
127
232
  }
@@ -302,21 +407,30 @@ export class SubagentFleetStatus {
302
407
 
303
408
  render(width: number, theme: Theme): string[] {
304
409
  if (this.entries.length === 0) return [];
410
+ if (!this.active) {
411
+ const tokens = this.entries.reduce((total, entry) => total + entry.tokens, 0);
412
+ const label = `${this.entries.length} active ${this.entries.length === 1 ? "agent" : "agents"}`;
413
+ return [truncateToWidth(` ${theme.fg("muted", label)} · ${theme.fg("dim", `${formatFleetTokens(tokens)} · ↓/← to inspect`)}`, width)];
414
+ }
305
415
  const roster = this.rosterKeys();
306
416
  const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
307
- const hint = this.active
308
- ? "↑↓/jk select · enter inspect · esc back"
309
- : "esc to interrupt · ← for agents · ↓ to manage";
310
- const lines = [truncateToWidth(` ${theme.fg("dim", hint)}`, width), ""];
417
+ const lines = [truncateToWidth(` ${theme.fg("dim", "↑↓/jk select · enter inspect · esc back")}`, width), ""];
311
418
  lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex, theme)} main`, width));
312
419
 
313
- const visibleCount = Math.min(this.maxAgentRows, this.entries.length);
314
- const selectedAgentIndex = Math.max(0, selectedIndex - 1);
315
- const start = selectedAgentIndex < visibleCount ? 0 : selectedAgentIndex - visibleCount + 1;
316
- const hiddenBelow = this.entries.length - (start + visibleCount);
420
+ const tree = fleetTreeRows(this.entries);
421
+ const selectedTreeIndex = Math.max(0, tree.findIndex((row) => row.kind === "owner" && row.entry.key === this.selectedKey));
422
+ const visibleCount = Math.min(this.maxAgentRows, tree.length);
423
+ const start = selectedTreeIndex < visibleCount ? 0 : selectedTreeIndex - visibleCount + 1;
424
+ const hiddenBelow = tree.length - (start + visibleCount);
317
425
  if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
318
426
  for (let index = start; index < start + visibleCount; index++) {
319
- lines.push(this.renderEntry(index + 1, selectedIndex, this.entries[index]!, width, theme));
427
+ const row = tree[index]!;
428
+ if (row.kind === "owner") {
429
+ const ownerIndex = this.entries.findIndex((entry) => entry.key === row.entry.key);
430
+ lines.push(this.renderEntry(ownerIndex + 1, selectedIndex, row.entry, width, theme));
431
+ } else {
432
+ lines.push(this.renderNestedRow(row.row, row.last, width, theme));
433
+ }
320
434
  }
321
435
  if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
322
436
  return lines;
@@ -325,14 +439,24 @@ export class SubagentFleetStatus {
325
439
  private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetStatusEntry, width: number, theme: Theme): string {
326
440
  const description = entry.description?.replace(/\s+/g, " ").trim();
327
441
  const agent = entry.modelThinking ? `${entry.agent} (${entry.modelThinking})` : entry.agent;
328
- const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)}${description ? ` ${description}` : ""}`;
442
+ const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)} · ${entry.state}${description ? ` ${description}` : ""}`;
329
443
  const elapsed = Date.now() - entry.startedAt;
330
444
  const right = theme.fg("dim", `${formatFleetElapsed(elapsed)} · ${formatFleetTokens(entry.tokens)}`);
331
445
  return rightAlign(left, right, width);
332
446
  }
333
447
 
448
+ private renderNestedRow(row: FleetNestedRow, last: boolean, width: number, theme: Theme): string {
449
+ const marker = last ? "└─" : "├─";
450
+ if (row.overflow !== undefined) return truncateToWidth(` ${marker} ${theme.fg("dim", `+${row.overflow} nested leaves`)}`, width);
451
+ const modelThinking = row.modelThinking ? ` (${row.modelThinking})` : "";
452
+ const activity = row.activity ? ` · ${row.activity}` : "";
453
+ const left = ` ${marker} ${nestedStatusGlyph(row.state, theme)} ${theme.fg("muted", `${row.name}${modelThinking}`)} · ${row.state}${activity}`;
454
+ const elapsed = row.startedAt !== undefined ? ` · ${formatFleetElapsed(Date.now() - row.startedAt)}` : "";
455
+ return truncateToWidth(`${left}${theme.fg("dim", elapsed)}`, width);
456
+ }
457
+
334
458
  private bullet(rosterIndex: number, selectedIndex: number, theme: Theme): string {
335
- return rosterIndex === selectedIndex ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
459
+ return rosterIndex === selectedIndex ? theme.fg("accent", ">") : " ";
336
460
  }
337
461
 
338
462
  private rosterKeys(): string[] {
@@ -368,14 +492,25 @@ export class SubagentFleetStatus {
368
492
  active: this.active,
369
493
  selected: this.selectedKey,
370
494
  inspectorOpen: this.inspectorOpen,
371
- entries: this.entries.map((entry) => [
372
- entry.key,
373
- entry.agent,
374
- entry.modelThinking,
375
- entry.description,
376
- Math.round((now - entry.startedAt) / 1000),
377
- entry.tokens,
378
- ]),
495
+ entries: this.entries.map((entry) => this.active
496
+ ? [
497
+ entry.key,
498
+ entry.agent,
499
+ entry.state,
500
+ entry.modelThinking,
501
+ entry.description,
502
+ Math.round((now - entry.startedAt) / 1000),
503
+ entry.tokens,
504
+ entry.nestedChildren?.map((child) => [
505
+ child.id,
506
+ child.state,
507
+ child.model,
508
+ child.thinking,
509
+ child.lastUpdate,
510
+ child.steps?.map((step) => [step.agent, step.status, step.model, step.thinking, step.lastActivityAt]),
511
+ ]),
512
+ ]
513
+ : [entry.key, entry.state, entry.tokens]),
379
514
  });
380
515
  }
381
516
 
@@ -7,6 +7,89 @@ const DEFAULT_MAX_RECORDS = 240;
7
7
  const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
8
8
  const MAX_MESSAGE_CHARS = 64 * 1024;
9
9
  const TOOL_PREVIEW_LINES = 7;
10
+ const BINARY_CONTENT_PLACEHOLDER = "[binary content omitted for safe display]";
11
+
12
+ function isUnsafeDisplayCodePoint(codePoint: number): boolean {
13
+ const terminalControl = (codePoint <= 0x1f && codePoint !== 0x09 && codePoint !== 0x0a)
14
+ || (codePoint >= 0x7f && codePoint <= 0x9f);
15
+ const bidiControl = codePoint === 0x061c
16
+ || codePoint === 0x200e
17
+ || codePoint === 0x200f
18
+ || (codePoint >= 0x202a && codePoint <= 0x202e)
19
+ || (codePoint >= 0x2066 && codePoint <= 0x2069);
20
+ const privateUse = (codePoint >= 0xe000 && codePoint <= 0xf8ff)
21
+ || (codePoint >= 0xf0000 && codePoint <= 0xffffd)
22
+ || (codePoint >= 0x100000 && codePoint <= 0x10fffd);
23
+ const invalidScalar = codePoint >= 0xd800 && codePoint <= 0xdfff;
24
+ const nonCharacter = (codePoint >= 0xfdd0 && codePoint <= 0xfdef)
25
+ || (codePoint & 0xffff) === 0xfffe
26
+ || (codePoint & 0xffff) === 0xffff;
27
+ return terminalControl || bidiControl || privateUse || invalidScalar || nonCharacter;
28
+ }
29
+
30
+ function looksLikeBinaryContent(text: string): boolean {
31
+ if (text.includes("\0")) return true;
32
+ let suspiciousControls = 0;
33
+ let replacementCharacters = 0;
34
+ let codePoints = 0;
35
+ for (const character of text) {
36
+ codePoints++;
37
+ const codePoint = character.codePointAt(0) ?? 0;
38
+ if ((codePoint <= 0x08) || (codePoint >= 0x0e && codePoint <= 0x1f)) suspiciousControls++;
39
+ if (codePoint === 0xfffd) replacementCharacters++;
40
+ }
41
+ if (codePoints === 0) return false;
42
+ return (suspiciousControls >= 4 && suspiciousControls / codePoints >= 0.1)
43
+ || (replacementCharacters >= 3 && replacementCharacters / codePoints >= 0.1);
44
+ }
45
+
46
+ function safeDisplayText(text: string): string {
47
+ const normalized = text.replace(/\r\n/g, "\n");
48
+ if (looksLikeBinaryContent(normalized)) return BINARY_CONTENT_PLACEHOLDER;
49
+ let safe = "";
50
+ for (const character of normalized) {
51
+ const codePoint = character.codePointAt(0) ?? 0;
52
+ safe += isUnsafeDisplayCodePoint(codePoint)
53
+ ? `[U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}]`
54
+ : character;
55
+ }
56
+ return safe;
57
+ }
58
+
59
+ function sanitizeJsonDisplayValue(value: unknown): { value: unknown; changed: boolean } {
60
+ if (typeof value === "string") {
61
+ const safe = safeDisplayText(value);
62
+ return { value: safe, changed: safe !== value };
63
+ }
64
+ if (Array.isArray(value)) {
65
+ const sanitized = value.map(sanitizeJsonDisplayValue);
66
+ return {
67
+ value: sanitized.map((entry) => entry.value),
68
+ changed: sanitized.some((entry) => entry.changed),
69
+ };
70
+ }
71
+ if (value && typeof value === "object") {
72
+ let changed = false;
73
+ const sanitized: Record<string, unknown> = Object.create(null);
74
+ for (const [key, nested] of Object.entries(value)) {
75
+ const safeKey = safeDisplayText(key);
76
+ const safeValue = sanitizeJsonDisplayValue(nested);
77
+ sanitized[safeKey] = safeValue.value;
78
+ changed ||= safeKey !== key || safeValue.changed;
79
+ }
80
+ return { value: changed ? sanitized : value, changed };
81
+ }
82
+ return { value, changed: false };
83
+ }
84
+
85
+ function safeToolArgsPayload(payload: string): string {
86
+ try {
87
+ const sanitized = sanitizeJsonDisplayValue(JSON.parse(payload));
88
+ return sanitized.changed ? JSON.stringify(sanitized.value) : safeDisplayText(payload);
89
+ } catch {
90
+ return safeDisplayText(payload);
91
+ }
92
+ }
10
93
 
11
94
  type Theme = ExtensionContext["ui"]["theme"];
12
95
 
@@ -142,6 +225,27 @@ function clipMessage(text: string): string {
142
225
  return `${text.slice(0, MAX_MESSAGE_CHARS)}\n\n… message truncated`;
143
226
  }
144
227
 
228
+ function safeTranscriptEvent(event: FleetTranscriptEvent): FleetTranscriptEvent {
229
+ if (event.kind === "assistant") {
230
+ return {
231
+ ...event,
232
+ text: safeDisplayText(event.text),
233
+ ...(event.model ? { model: safeDisplayText(event.model) } : {}),
234
+ };
235
+ }
236
+ if (event.kind === "user" || event.kind === "notice") {
237
+ return { ...event, text: safeDisplayText(event.text) };
238
+ }
239
+ return {
240
+ ...event,
241
+ name: safeDisplayText(event.name),
242
+ ...(event.args !== undefined ? { args: safeDisplayText(event.args) } : {}),
243
+ ...(event.argsPayload !== undefined ? { argsPayload: safeToolArgsPayload(event.argsPayload) } : {}),
244
+ ...(event.output !== undefined ? { output: safeDisplayText(event.output) } : {}),
245
+ ...(event.error !== undefined ? { error: safeDisplayText(event.error) } : {}),
246
+ };
247
+ }
248
+
145
249
  function findTool(
146
250
  events: FleetTranscriptEvent[],
147
251
  toolCallId: string | undefined,
@@ -274,13 +378,13 @@ function parseTranscriptLines(lines: string[], conversationStarted = false): { e
274
378
  for (const event of events) {
275
379
  if (event.kind === "tool") delete (event as MutableToolEvent).resultSeen;
276
380
  }
277
- return { events, malformed, explicitTruncation };
381
+ return { events: events.map(safeTranscriptEvent), malformed, explicitTruncation };
278
382
  }
279
383
 
280
384
  export function readFleetTranscript(filePath: string, options: FleetTranscriptReadOptions): FleetTranscript {
281
385
  const validated = validateTranscriptPath(filePath, options.trustedRoots);
282
386
  if (!validated.resolvedPath) {
283
- return { path: filePath, events: [], truncated: false, ...(validated.warning ? { warning: validated.warning } : {}) };
387
+ return { path: filePath, events: [], truncated: false, ...(validated.warning ? { warning: safeDisplayText(validated.warning) } : {}) };
284
388
  }
285
389
  const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
286
390
  const tail = readTailLines(validated.resolvedPath, Math.max(1024, options.maxBytes ?? DEFAULT_MAX_BYTES));
@@ -295,7 +399,7 @@ export function readFleetTranscript(filePath: string, options: FleetTranscriptRe
295
399
  path: filePath,
296
400
  events: parsed.events,
297
401
  truncated: tail.truncated || tail.lines.length > maxRecords || parsed.explicitTruncation,
298
- ...(warnings.length ? { warning: warnings.join(" ") } : {}),
402
+ ...(warnings.length ? { warning: safeDisplayText(warnings.join(" ")) } : {}),
299
403
  };
300
404
  }
301
405
 
@@ -404,12 +508,13 @@ export function renderFleetTranscript(
404
508
  const lines: string[] = [];
405
509
  if (transcript.truncated) lines.push(bounded(theme.fg("dim", "↑ Earlier activity omitted"), width));
406
510
  if (transcript.warning) {
407
- for (const line of renderWrapped(transcript.warning, Math.max(1, width - 2))) {
511
+ for (const line of renderWrapped(safeDisplayText(transcript.warning), Math.max(1, width - 2))) {
408
512
  lines.push(bounded(`${theme.fg("warning", "!")} ${theme.fg("warning", line)}`, width));
409
513
  }
410
514
  }
411
515
 
412
- for (const event of transcript.events) {
516
+ for (const rawEvent of transcript.events) {
517
+ const event = safeTranscriptEvent(rawEvent);
413
518
  if (event.kind === "tool") {
414
519
  if (options.expandedTools && (event.output || event.argsPayload || event.error)) {
415
520
  lines.push(...renderExpandedTool(event, width, theme));
package/src/tui/fleet.ts CHANGED
@@ -5,7 +5,7 @@ import { getMarkdownTheme, type ExtensionContext } from "@earendil-works/pi-codi
5
5
  import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type MarkdownTheme } from "@earendil-works/pi-tui";
6
6
  import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
7
7
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../shared/formatters.ts";
8
- import { RESULTS_DIR, type AsyncJobState, type Details, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
8
+ import { DIRS, type AsyncJobState, type Details, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
9
9
  import { readStatus } from "../shared/utils.ts";
10
10
  import { formatAsyncRunTranscript } from "../runs/background/fleet-view.ts";
11
11
  import { listAsyncRuns, type AsyncRunSummary } from "../runs/background/async-status.ts";
@@ -14,9 +14,11 @@ import { stopAsyncRun } from "../runs/foreground/async-stop-action.ts";
14
14
  import { contextModeBadge, contextModeLabel } from "../runs/shared/context-mode.ts";
15
15
  import { FLEET_STATUS_WIDGET_KEY } from "./fleet-status.ts";
16
16
  import { readFleetTranscript, renderFleetTranscript, type FleetTranscript } from "./fleet-transcript.ts";
17
+ import { handleHerdrInspectorAction } from "../inspectors/herdr/actions.ts";
17
18
 
18
19
  const REFRESH_MS = 750;
19
20
  const MAX_RECENT_ASYNC_RUNS = 20;
21
+ const MAX_FLEET_HISTORY_CANDIDATES = 100;
20
22
  const TRANSCRIPT_LINES = 200;
21
23
 
22
24
  type Theme = ExtensionContext["ui"]["theme"];
@@ -45,6 +47,7 @@ export interface FleetActionResult {
45
47
  export interface FleetActionHandlers {
46
48
  steer(input: { runId: string; asyncDir: string; index?: number; message: string }): Promise<FleetActionResult>;
47
49
  stop(input: { runId: string; asyncDir: string; index?: number }): Promise<FleetActionResult> | FleetActionResult;
50
+ inspect?(input: { runId: string; asyncDir: string; index?: number }): Promise<FleetActionResult>;
48
51
  }
49
52
 
50
53
  export interface FleetViewOptions {
@@ -104,7 +107,7 @@ function trackedJobSummary(job: AsyncJobState): AsyncRunSummary {
104
107
 
105
108
  function asyncItems(run: AsyncRunSummary, description?: string): FleetItem[] {
106
109
  const updatedAt = run.lastUpdate ?? run.endedAt ?? run.startedAt;
107
- if (run.steps.length === 0) {
110
+ if (run.steps.length === 0 || run.mode === "workflow") {
108
111
  return [{ key: `async:${run.id}`, kind: "async", runId: run.id, agent: run.mode, state: run.state, updatedAt, run, ...(description ? { description } : {}) }];
109
112
  }
110
113
  return run.steps.map((step) => ({
@@ -121,6 +124,14 @@ function asyncItems(run: AsyncRunSummary, description?: string): FleetItem[] {
121
124
  }));
122
125
  }
123
126
 
127
+ function orderFleetAsyncRuns(runs: AsyncRunSummary[], terminalLimit: number): AsyncRunSummary[] {
128
+ const updatedAt = (run: AsyncRunSummary) => run.lastUpdate ?? run.endedAt ?? run.startedAt;
129
+ const byNewest = (left: AsyncRunSummary, right: AsyncRunSummary) => updatedAt(right) - updatedAt(left);
130
+ const active = runs.filter((run) => run.state === "queued" || run.state === "running").sort(byNewest);
131
+ const terminal = runs.filter((run) => run.state !== "queued" && run.state !== "running").sort(byNewest);
132
+ return [...active, ...terminal.slice(0, terminalLimit)];
133
+ }
134
+
124
135
  export function collectFleetSnapshot(
125
136
  state: SubagentState,
126
137
  options: { asyncDirRoot?: string; resultsDir?: string; limit?: number } = {},
@@ -163,30 +174,35 @@ export function collectFleetSnapshot(
163
174
  try {
164
175
  let runs: AsyncRunSummary[];
165
176
  const descriptions = new Map<string, string>();
177
+ const tracked = [...(state.fleetJobs ?? state.asyncJobs).values()]
178
+ .filter((job) => belongsToCurrentSession(job.sessionId, state.currentSessionId));
179
+ const byUpdate = (left: AsyncJobState, right: AsyncJobState) => (right.updatedAt ?? right.startedAt ?? 0) - (left.updatedAt ?? left.startedAt ?? 0);
180
+ const active = tracked.filter((job) => job.status === "queued" || job.status === "running").sort(byUpdate);
181
+ const recent = tracked.filter((job) => job.status !== "queued" && job.status !== "running").sort(byUpdate).slice(0, options.limit ?? MAX_RECENT_ASYNC_RUNS);
182
+ const trackedRuns: AsyncRunSummary[] = [];
183
+ for (const job of [...active, ...recent]) {
184
+ try {
185
+ trackedRuns.push(trackedJobSummary(job));
186
+ if (job.description) descriptions.set(job.asyncId, job.description);
187
+ } catch (cause) {
188
+ error = `Failed to inspect async run '${job.asyncId}': ${cause instanceof Error ? cause.message : String(cause)}`;
189
+ }
190
+ }
166
191
  if (options.asyncDirRoot !== undefined) {
167
- runs = listAsyncRuns(options.asyncDirRoot, {
192
+ const trackedIds = new Set(trackedRuns.map((run) => run.id));
193
+ const history = listAsyncRuns(options.asyncDirRoot, {
168
194
  ...(state.currentSessionId ? { sessionId: state.currentSessionId } : {}),
169
- limit: options.limit ?? MAX_RECENT_ASYNC_RUNS,
170
- resultsDir: options.resultsDir ?? RESULTS_DIR,
195
+ entryLimit: MAX_FLEET_HISTORY_CANDIDATES,
196
+ resultsDir: options.resultsDir ?? DIRS.results,
171
197
  reconcile: false,
172
- });
198
+ }).filter((run) => !trackedIds.has(run.id));
199
+ runs = [...trackedRuns, ...history];
173
200
  } else {
174
- const tracked = [...(state.fleetJobs ?? state.asyncJobs).values()]
175
- .filter((job) => belongsToCurrentSession(job.sessionId, state.currentSessionId));
176
- const byUpdate = (left: AsyncJobState, right: AsyncJobState) => (right.updatedAt ?? right.startedAt ?? 0) - (left.updatedAt ?? left.startedAt ?? 0);
177
- const active = tracked.filter((job) => job.status === "queued" || job.status === "running").sort(byUpdate);
178
- const recent = tracked.filter((job) => job.status !== "queued" && job.status !== "running").sort(byUpdate).slice(0, options.limit ?? MAX_RECENT_ASYNC_RUNS);
179
- runs = [];
180
- for (const job of [...active, ...recent]) {
181
- try {
182
- runs.push(trackedJobSummary(job));
183
- if (job.description) descriptions.set(job.asyncId, job.description);
184
- } catch (cause) {
185
- error = `Failed to inspect async run '${job.asyncId}': ${cause instanceof Error ? cause.message : String(cause)}`;
186
- }
187
- }
201
+ runs = trackedRuns;
202
+ }
203
+ for (const run of orderFleetAsyncRuns(runs, options.limit ?? MAX_RECENT_ASYNC_RUNS)) {
204
+ items.push(...asyncItems(run, descriptions.get(run.id)));
188
205
  }
189
- for (const run of runs) items.push(...asyncItems(run, descriptions.get(run.id)));
190
206
  } catch (cause) {
191
207
  error = cause instanceof Error ? cause.message : String(cause);
192
208
  }
@@ -390,7 +406,7 @@ function itemStats(item: FleetItem): string[] {
390
406
  tokens = item.child.tokens;
391
407
  tools = item.child.toolCount;
392
408
  } else {
393
- model = item.step?.model;
409
+ model = formatModelThinking(item.step?.model, item.step?.thinking) || undefined;
394
410
  tokens = item.step?.tokens?.total ?? (item.index === undefined ? item.run.totalTokens?.total : undefined);
395
411
  tools = item.step?.toolCount ?? (item.index === undefined ? item.run.toolCount : undefined);
396
412
  const terminalRun = item.state !== "queued" && item.state !== "running" && item.state !== "pending";
@@ -662,6 +678,12 @@ export class SubagentFleetComponent implements Component {
662
678
  }
663
679
  return;
664
680
  }
681
+ if (data === "H") {
682
+ const target = this.selectedAsyncAction();
683
+ 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 });
684
+ else this.runAction(() => this.options.actions!.inspect!({ runId: target.item.runId, asyncDir: target.item.run.asyncDir, ...(target.item.index !== undefined ? { index: target.item.index } : {}) }));
685
+ return;
686
+ }
665
687
  if (data === "D") {
666
688
  const target = this.selectedAsyncAction();
667
689
  if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
@@ -792,7 +814,7 @@ export class SubagentFleetComponent implements Component {
792
814
  }
793
815
  lines.push(this.theme.fg("border", `├${"─".repeat(rosterWidth)}┴${"─".repeat(detailWidth)}┤`));
794
816
  const position = this.snapshot.items.length ? `${this.selected + 1}/${this.snapshot.items.length}` : "0/0";
795
- const footer = ` ↑↓/jk agent · s steer · D stop · x/Ctrl+O tools · r refresh · Esc close · ${position}`;
817
+ const footer = ` ↑↓/jk agent · H Herdr · s steer · D stop · x/Ctrl+O tools · r refresh · Esc close · ${position}`;
796
818
  lines.push(this.theme.fg("border", "│") + fit(this.theme.fg("dim", footer), innerWidth) + this.theme.fg("border", "│"));
797
819
  lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
798
820
  return lines.map((line) => truncateToWidth(line, width));
@@ -819,9 +841,19 @@ export async function openSubagentFleet(ctx: ExtensionContext, state: SubagentSt
819
841
  runId: input.runId,
820
842
  ...(input.index !== undefined ? { index: input.index } : {}),
821
843
  message: input.message,
822
- location: { asyncDir: input.asyncDir, resolvedId: input.runId },
844
+ location: { asyncDir: input.asyncDir, resolvedId: input.runId } as Parameters<typeof steerAsyncRun>[0]["location"],
823
845
  }), `Failed to steer async run ${input.runId}.`),
824
846
  stop: (input: { runId: string; asyncDir: string; index?: number }) => firstToolResultText(stopAsyncRun(state, input.runId, undefined, { asyncDir: input.asyncDir, resolvedId: input.runId }), `Failed to stop async run ${input.runId}.`),
847
+ inspect: async (input: { runId: string; asyncDir: string; index?: number }) => firstToolResultText(await handleHerdrInspectorAction("inspector.open", {
848
+ id: input.runId,
849
+ dir: input.asyncDir,
850
+ ...(input.index !== undefined ? { index: input.index } : {}),
851
+ }, {
852
+ state,
853
+ cwd: state.baseCwd,
854
+ ...(state.authorityPolicy ? { authorityPolicy: state.authorityPolicy } : {}),
855
+ ...(state.missionStoreConfig ? { missions: state.missionStoreConfig } : {}),
856
+ }), `Failed to open Herdr inspector for async run ${input.runId}.`),
825
857
  } satisfies FleetActionHandlers;
826
858
  try {
827
859
  await ctx.ui.custom<undefined>(