pi-subagents 0.35.0 → 0.36.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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +132 -24
  3. package/agents/advisor.md +73 -0
  4. package/package.json +8 -12
  5. package/skills/pi-subagents/SKILL.md +22 -9
  6. package/src/agents/agents.ts +22 -5
  7. package/src/api/delegation.ts +125 -0
  8. package/src/extension/config.ts +7 -1
  9. package/src/extension/index.ts +50 -38
  10. package/src/extension/rpc.ts +27 -2
  11. package/src/extension/schemas.ts +22 -2
  12. package/src/extension/tool-description.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +1 -1
  14. package/src/intercom/native-supervisor-channel.ts +45 -6
  15. package/src/intercom/result-intercom.ts +7 -0
  16. package/src/runs/background/async-execution.ts +34 -4
  17. package/src/runs/background/async-job-tracker.ts +4 -0
  18. package/src/runs/background/async-resume.ts +27 -5
  19. package/src/runs/background/async-status.ts +76 -3
  20. package/src/runs/background/chain-append.ts +2 -0
  21. package/src/runs/background/completion-batcher.ts +6 -4
  22. package/src/runs/background/completion-dedupe.ts +2 -11
  23. package/src/runs/background/fleet-view.ts +9 -4
  24. package/src/runs/background/notify.ts +132 -120
  25. package/src/runs/background/result-watcher.ts +138 -78
  26. package/src/runs/background/run-status.ts +3 -1
  27. package/src/runs/background/subagent-runner.ts +225 -43
  28. package/src/runs/background/subagent-wait.ts +130 -4
  29. package/src/runs/background/wait-tool.ts +2 -2
  30. package/src/runs/foreground/chain-execution.ts +176 -111
  31. package/src/runs/foreground/execution.ts +90 -36
  32. package/src/runs/foreground/foreground-control.ts +90 -0
  33. package/src/runs/foreground/subagent-executor.ts +394 -163
  34. package/src/runs/shared/acceptance.ts +55 -13
  35. package/src/runs/shared/agent-contract.ts +38 -0
  36. package/src/runs/shared/child-protocol.ts +1 -1
  37. package/src/runs/shared/completion-guard.ts +36 -5
  38. package/src/runs/shared/context-mode.ts +44 -0
  39. package/src/runs/shared/dynamic-fanout.ts +4 -4
  40. package/src/runs/shared/long-running-guard.ts +4 -0
  41. package/src/runs/shared/nested-events.ts +27 -2
  42. package/src/runs/shared/parallel-handoff.ts +154 -0
  43. package/src/runs/shared/parallel-utils.ts +6 -0
  44. package/src/runs/shared/pi-args.ts +23 -14
  45. package/src/runs/shared/run-history.ts +90 -5
  46. package/src/runs/shared/structured-output.ts +112 -7
  47. package/src/runs/shared/subagent-control.ts +4 -0
  48. package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
  49. package/src/runs/shared/task-intent.ts +10 -5
  50. package/src/runs/shared/tool-availability.ts +3 -1
  51. package/src/runs/shared/tool-budget.ts +11 -5
  52. package/src/runs/shared/turn-budget.ts +2 -1
  53. package/src/runs/shared/worktree.ts +63 -14
  54. package/src/shared/accessible-dir.ts +25 -0
  55. package/src/shared/artifacts.ts +37 -7
  56. package/src/shared/atomic-json.ts +14 -42
  57. package/src/shared/child-transcript.ts +52 -0
  58. package/src/shared/file-system-retry.ts +47 -0
  59. package/src/shared/settings.ts +9 -1
  60. package/src/shared/types.ts +211 -22
  61. package/src/slash/delegation-adapters.ts +152 -5
  62. package/src/slash/delegation-json.ts +108 -0
  63. package/src/slash/delegation-request.ts +182 -36
  64. package/src/slash/prompt-template-bridge.ts +222 -37
  65. package/src/slash/selector.ts +147 -0
  66. package/src/slash/slash-commands.ts +14 -5
  67. package/src/slash/slash-live-state.ts +2 -2
  68. package/src/slash/subagents-admin.ts +42 -42
  69. package/src/tui/fleet-status.ts +362 -0
  70. package/src/tui/fleet-transcript.ts +472 -0
  71. package/src/tui/fleet.ts +318 -59
  72. package/src/tui/render.ts +25 -15
  73. package/src/watchdog/change-signature.ts +105 -12
  74. package/src/watchdog/review.ts +7 -2
  75. package/src/watchdog/runtime.ts +5 -3
  76. package/src/slash/subagents-editor.ts +0 -86
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import {
@@ -15,7 +14,7 @@ import {
15
14
  import { serializeAgent } from "../agents/agent-serializer.ts";
16
15
  import { editableAgentConfig, preservedAgentFrontmatterFields } from "../agents/agent-management.ts";
17
16
  import { findModelInfo, getSupportedThinkingLevels, toModelInfo } from "../shared/model-info.ts";
18
- import { editorLabel, resolveEditorCommand, runEditorAndWait } from "./subagents-editor.ts";
17
+ import { SelectorComponent, type SelectorItem, type SelectorResult } from "./selector.ts";
19
18
 
20
19
  const ADMIN_MESSAGE_TYPE = "subagents-admin";
21
20
  const INHERIT_MODEL_CHOICE = "Default / inherit session model";
@@ -52,6 +51,10 @@ function agentChoices(agents: AgentConfig[]): Map<string, AgentConfig> {
52
51
  }));
53
52
  }
54
53
 
54
+ function agentSelectItems(byLabel: Map<string, AgentConfig>): SelectorItem[] {
55
+ return [...byLabel.keys()].map((label) => ({ value: label, label }));
56
+ }
57
+
55
58
  function agentMatches(agent: AgentConfig, rawName: string): boolean {
56
59
  const name = rawName.trim();
57
60
  return agent.name === name || frontmatterNameForConfig(agent) === name;
@@ -156,7 +159,7 @@ async function selectAgent(ctx: ExtensionContext, args: string): Promise<AgentSe
156
159
  if (matches.length > 1 && !ctx.hasUI) return { kind: "ambiguous", requestedName, matches };
157
160
  if (matches.length > 1) {
158
161
  const byLabel = agentChoices(matches);
159
- const choice = await ctx.ui.select(`Multiple subagents named '${requestedName}'`, [...byLabel.keys()]);
162
+ const choice = await selectFromList(ctx, `Multiple subagents named '${requestedName}'`, undefined, agentSelectItems(byLabel));
160
163
  return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
161
164
  }
162
165
  return { kind: "not-found", agents, requestedName };
@@ -164,7 +167,7 @@ async function selectAgent(ctx: ExtensionContext, args: string): Promise<AgentSe
164
167
 
165
168
  if (!ctx.hasUI) return { kind: "not-found", agents };
166
169
  const byLabel = agentChoices(agents);
167
- const choice = await ctx.ui.select("Select subagent", [...byLabel.keys()]);
170
+ const choice = await selectFromList(ctx, "Select subagent", undefined, agentSelectItems(byLabel));
168
171
  return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
169
172
  }
170
173
 
@@ -198,13 +201,33 @@ function metadataFor(agent: AgentConfig): string {
198
201
  return lines.join("\n");
199
202
  }
200
203
 
204
+ async function selectFromList(ctx: ExtensionContext, title: string, subtitle: string | undefined, items: SelectorItem[]): Promise<string | undefined> {
205
+ if (typeof ctx.ui.custom === "function") {
206
+ const result = await ctx.ui.custom<SelectorResult>(
207
+ (tui, theme, kb, done) => new SelectorComponent(tui, theme, kb, { title, subtitle, items, done }),
208
+ { overlay: false },
209
+ );
210
+ return result?.confirmed ? result.value : undefined;
211
+ }
212
+ const flatTitle = subtitle ? `${title}\nCurrent: ${subtitle}` : title;
213
+ const labelToValue = new Map(items.map((item) => [item.label, item.value] as const));
214
+ const choice = await ctx.ui.select(flatTitle, items.map((item) => item.value));
215
+ return choice ? (items.find((item) => item.value === choice)?.value ?? labelToValue.get(choice)) : undefined;
216
+ }
217
+
201
218
  async function chooseModel(ctx: ExtensionContext, agent: AgentConfig): Promise<string | undefined | null> {
202
- const models = liveAvailableModels(ctx).map((model) => modelFullId(model));
219
+ const models = liveAvailableModels(ctx);
203
220
  const current = agent.model ?? INHERIT_MODEL_CHOICE;
204
- const choices = [INHERIT_MODEL_CHOICE, ...models.filter((model) => model !== agent.model)];
205
- if (agent.model && !choices.includes(agent.model)) choices.splice(1, 0, agent.model);
206
- const choice = await ctx.ui.select(`Select model for ${agent.name}\nCurrent: ${current}`, choices);
207
- if (!choice) return null;
221
+ const items: SelectorItem[] = [{ value: INHERIT_MODEL_CHOICE, label: INHERIT_MODEL_CHOICE, current: !agent.model }];
222
+ if (agent.model && !models.some((model) => modelFullId(model) === agent.model)) {
223
+ items.push({ value: agent.model, label: agent.model, current: true });
224
+ }
225
+ for (const model of models) {
226
+ const fullId = modelFullId(model);
227
+ items.push({ value: fullId, label: model.id, badge: model.provider, current: fullId === agent.model });
228
+ }
229
+ const choice = await selectFromList(ctx, `Select model for ${agent.name}`, current, items);
230
+ if (choice === undefined) return null;
208
231
  return choice === INHERIT_MODEL_CHOICE ? undefined : choice;
209
232
  }
210
233
 
@@ -214,18 +237,16 @@ async function chooseThinking(ctx: ExtensionContext, agent: AgentConfig): Promis
214
237
  const modelInfo = findModelInfo(effectiveModel, availableModels, ctx.model?.provider);
215
238
  const levels = getSupportedThinkingLevels(modelInfo);
216
239
  const current = agent.thinking === false ? "off" : agent.thinking ?? INHERIT_THINKING_CHOICE;
217
- const choices: string[] = [INHERIT_THINKING_CHOICE, ...levels];
218
- if (current !== INHERIT_THINKING_CHOICE && !choices.includes(current)) choices.splice(1, 0, current);
240
+ const values: string[] = [INHERIT_THINKING_CHOICE, ...levels];
241
+ if (current !== INHERIT_THINKING_CHOICE && !values.includes(current)) values.splice(1, 0, current);
219
242
  const modelNote = agent.model
220
243
  ? `Model: ${agent.model}`
221
244
  : effectiveModel
222
245
  ? `Session model: ${effectiveModel}`
223
246
  : "Model: default / inherit";
224
- const choice = await ctx.ui.select(
225
- `Select thinking level for ${agent.name}\n${modelNote} · Current: ${current}`,
226
- choices,
227
- );
228
- if (!choice) return null;
247
+ const items: SelectorItem[] = values.map((value) => ({ value, label: value, current: value === current }));
248
+ const choice = await selectFromList(ctx, `Select thinking level for ${agent.name}`, `${modelNote} · ${current}`, items);
249
+ if (choice === undefined) return null;
229
250
  return choice === INHERIT_THINKING_CHOICE ? undefined : choice;
230
251
  }
231
252
 
@@ -327,38 +348,17 @@ async function saveAgentSystemPrompt(ctx: ExtensionContext, agent: AgentConfig,
327
348
  return `Updated '${agent.name}' system prompt in ${updated.filePath}.`;
328
349
  }
329
350
 
330
- /**
331
- * Round-trip the agent's system prompt through the user's external editor: write it to a
332
- * temp .md file, open the editor and wait for it to close, then persist the result.
333
- * Returns the status message, or null when nothing should be posted.
334
- */
335
351
  async function editSystemPrompt(ctx: ExtensionContext, agent: AgentConfig): Promise<string | null> {
336
352
  if (!savesThroughSettings(agent, "systemPrompt")) {
337
353
  const readOnlyMessage = readOnlyAgentMessage(agent, "systemPrompt");
338
354
  if (readOnlyMessage) return readOnlyMessage;
339
355
  }
340
- const editor = resolveEditorCommand();
341
- if (!editor) {
342
- return "No editor configured. Set $VISUAL or $EDITOR (e.g. 'open -W -n -a MarkEdit').";
343
- }
344
- const label = editorLabel(editor);
345
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-prompt-"));
346
- const safeName = path.basename(agent.name.replace(/[^a-z0-9._-]/gi, "_")) || "agent";
347
- const tmpPath = path.join(dir, `${safeName}.md`);
348
- try {
349
- fs.writeFileSync(tmpPath, agent.systemPrompt ?? "", "utf-8");
350
- ctx.ui.notify(`Editing '${agent.name}' system prompt in ${label}. Save and close the editor window to apply.`, "info");
351
- ctx.ui.setStatus("subagents-edit", `Waiting for ${label} to close…`);
352
- await runEditorAndWait(editor, tmpPath);
353
- const edited = fs.readFileSync(tmpPath, "utf-8");
354
- if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
355
- return `System prompt for '${agent.name}' left unchanged.`;
356
- }
357
- return await saveAgentSystemPrompt(ctx, agent, edited);
358
- } finally {
359
- ctx.ui.setStatus("subagents-edit", undefined);
360
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort cleanup */ }
356
+ const edited = await ctx.ui.editor(`Edit '${agent.name}' system prompt`, agent.systemPrompt ?? "");
357
+ if (edited === undefined) return null;
358
+ if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
359
+ return `System prompt for '${agent.name}' left unchanged.`;
361
360
  }
361
+ return saveAgentSystemPrompt(ctx, agent, edited);
362
362
  }
363
363
 
364
364
  export async function openSubagentsAdmin(pi: ExtensionAPI, ctx: ExtensionContext, args = ""): Promise<void> {
@@ -0,0 +1,362 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { AsyncJobStep, SubagentState } from "../shared/types.ts";
4
+
5
+ export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
6
+
7
+ const MAX_AGENT_ROWS = 5;
8
+ const REFRESH_MS = 500;
9
+
10
+ type Theme = ExtensionContext["ui"]["theme"];
11
+ type FleetStatusTui = {
12
+ requestRender(): void;
13
+ };
14
+ type FleetStatusEntry = {
15
+ key: string;
16
+ agent: string;
17
+ description?: string;
18
+ startedAt: number;
19
+ tokens: number;
20
+ };
21
+
22
+ export interface FleetStatusOptions {
23
+ refreshMs?: number;
24
+ maxAgentRows?: number;
25
+ }
26
+
27
+ export function formatFleetElapsed(ms: number): string {
28
+ return `${Math.max(0, Math.round(ms / 1000))}s`;
29
+ }
30
+
31
+ export function formatFleetTokens(count: number): string {
32
+ let compact: string;
33
+ if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
34
+ else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
35
+ else compact = `${Math.max(0, Math.round(count))}`;
36
+ return `↓ ${compact} tokens`;
37
+ }
38
+
39
+ function rightAlign(left: string, right: string, width: number): string {
40
+ const rightWidth = visibleWidth(right);
41
+ const maxLeftWidth = Math.max(0, width - rightWidth - 1);
42
+ const leftClamped = truncateToWidth(left, maxLeftWidth);
43
+ const gap = Math.max(1, width - visibleWidth(leftClamped) - rightWidth);
44
+ return truncateToWidth(`${leftClamped}${" ".repeat(gap)}${right}`, width);
45
+ }
46
+
47
+ function isActiveState(value: string): boolean {
48
+ return value === "running" || value === "queued" || value === "pending";
49
+ }
50
+
51
+ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntry[] {
52
+ const entries: FleetStatusEntry[] = [];
53
+ for (const control of state.foregroundControls.values()) {
54
+ if (control.activeChildren) {
55
+ for (const child of [...control.activeChildren.values()].sort((left, right) => left.index - right.index)) {
56
+ entries.push({
57
+ key: `foreground-active:${control.runId}:${child.index}`,
58
+ agent: child.agent,
59
+ description: child.description,
60
+ startedAt: child.startedAt,
61
+ tokens: child.tokens ?? 0,
62
+ });
63
+ }
64
+ continue;
65
+ }
66
+ entries.push({
67
+ key: `foreground-active:${control.runId}:${control.currentIndex ?? 0}`,
68
+ agent: control.currentAgent ?? control.mode,
69
+ description: control.description,
70
+ startedAt: control.startedAt,
71
+ tokens: control.tokens ?? 0,
72
+ });
73
+ }
74
+
75
+ for (const job of state.asyncJobs.values()) {
76
+ if (!isActiveState(job.status)) continue;
77
+ const startedAt = job.startedAt ?? job.updatedAt ?? Date.now();
78
+ const steps: AsyncJobStep[] | undefined = job.steps?.length
79
+ ? job.steps
80
+ : job.agents?.map((agent, index) => {
81
+ const pending = job.status === "queued"
82
+ || (job.mode === "chain" && !job.activeParallelGroup && index !== (job.currentStep ?? 0));
83
+ return { agent, index, status: pending ? "pending" : "running" };
84
+ });
85
+ if (!steps?.length) {
86
+ entries.push({
87
+ key: `async:${job.asyncId}`,
88
+ agent: job.mode ?? "subagent",
89
+ description: job.description,
90
+ startedAt,
91
+ tokens: job.totalTokens?.total ?? 0,
92
+ });
93
+ continue;
94
+ }
95
+ for (const [offset, step] of steps.entries()) {
96
+ if (!isActiveState(step.status)) continue;
97
+ const index = step.index ?? offset;
98
+ if (step.status === "pending" && job.mode === "chain" && !job.activeParallelGroup && index !== (job.currentStep ?? 0)) continue;
99
+ entries.push({
100
+ key: `async:${job.asyncId}:${index}`,
101
+ agent: step.label ? `${step.label} (${step.agent})` : step.agent,
102
+ description: job.description,
103
+ startedAt: step.startedAt ?? startedAt,
104
+ tokens: step.tokens?.total ?? (steps.length === 1 ? job.totalTokens?.total ?? 0 : 0),
105
+ });
106
+ }
107
+ }
108
+
109
+ return entries.sort((left, right) => left.startedAt - right.startedAt || left.key.localeCompare(right.key));
110
+ }
111
+
112
+ export class SubagentFleetStatus {
113
+ private ctx: ExtensionContext | undefined;
114
+ private tui: FleetStatusTui | undefined;
115
+ private inputUnsubscribe: (() => void) | undefined;
116
+ private timer: ReturnType<typeof setInterval> | undefined;
117
+ private widgetRegistered = false;
118
+ private active = false;
119
+ private selectedKey = "main";
120
+ private inspectorOpen = false;
121
+ private lastRenderKey = "";
122
+ private entries: FleetStatusEntry[] = [];
123
+ private readonly state: SubagentState;
124
+ private readonly openInspector: (itemKey: string) => Promise<void> | void;
125
+ private readonly refreshMs: number;
126
+ private readonly maxAgentRows: number;
127
+
128
+ constructor(
129
+ state: SubagentState,
130
+ openInspector: (itemKey: string) => Promise<void> | void,
131
+ options: FleetStatusOptions = {},
132
+ ) {
133
+ this.state = state;
134
+ this.openInspector = openInspector;
135
+ this.refreshMs = options.refreshMs ?? REFRESH_MS;
136
+ this.maxAgentRows = options.maxAgentRows ?? MAX_AGENT_ROWS;
137
+ }
138
+
139
+ setContext(ctx: ExtensionContext): void {
140
+ if (!ctx.hasUI) return;
141
+ if (this.ctx?.ui === ctx.ui) {
142
+ this.ctx = ctx;
143
+ this.refresh();
144
+ return;
145
+ }
146
+ this.clearUiRegistration();
147
+ this.ctx = ctx;
148
+ if (typeof ctx.ui.onTerminalInput === "function") {
149
+ this.inputUnsubscribe = ctx.ui.onTerminalInput((data) => this.handleKey(data));
150
+ }
151
+ this.timer = setInterval(() => this.refresh(), this.refreshMs);
152
+ this.timer.unref?.();
153
+ this.refresh();
154
+ }
155
+
156
+ dispose(): void {
157
+ this.clearUiRegistration();
158
+ this.ctx = undefined;
159
+ this.entries = [];
160
+ this.active = false;
161
+ this.selectedKey = "main";
162
+ this.inspectorOpen = false;
163
+ this.lastRenderKey = "";
164
+ }
165
+
166
+ refresh(): void {
167
+ const ctx = this.ctx;
168
+ if (!ctx?.hasUI) return;
169
+ this.entries = collectFleetStatusEntries(this.state);
170
+ this.clampSelection();
171
+ if (this.inspectorOpen || this.state.fleetInspectorOpen) {
172
+ this.lastRenderKey = "";
173
+ if (this.widgetRegistered) {
174
+ ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
175
+ this.widgetRegistered = false;
176
+ this.tui = undefined;
177
+ }
178
+ return;
179
+ }
180
+ if (this.entries.length === 0) {
181
+ this.active = false;
182
+ this.selectedKey = "main";
183
+ this.lastRenderKey = "";
184
+ if (this.widgetRegistered) {
185
+ ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
186
+ this.widgetRegistered = false;
187
+ this.tui = undefined;
188
+ }
189
+ return;
190
+ }
191
+
192
+ const renderKey = this.getRenderKey();
193
+ if (!this.widgetRegistered) {
194
+ ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, (tui, theme) => {
195
+ this.tui = tui;
196
+ return {
197
+ render: (width: number) => this.render(width, theme),
198
+ invalidate: () => {
199
+ this.lastRenderKey = "";
200
+ },
201
+ dispose: () => {
202
+ if (this.tui !== tui) return;
203
+ this.widgetRegistered = false;
204
+ this.tui = undefined;
205
+ },
206
+ };
207
+ }, { placement: "belowEditor" });
208
+ this.widgetRegistered = true;
209
+ this.lastRenderKey = renderKey;
210
+ return;
211
+ }
212
+ if (renderKey === this.lastRenderKey) return;
213
+ this.lastRenderKey = renderKey;
214
+ this.tui?.requestRender();
215
+ }
216
+
217
+ handleKey(data: string): { consume?: boolean; data?: string } | undefined {
218
+ const ctx = this.ctx;
219
+ if (!ctx?.hasUI || this.entries.length === 0 || isKeyRelease(data)) return undefined;
220
+ if (this.inspectorOpen) return undefined;
221
+ if (!this.editorHasFocus()) {
222
+ if (this.active) this.deactivate();
223
+ return undefined;
224
+ }
225
+
226
+ if (!this.active) {
227
+ const activates = matchesKey(data, "down") || matchesKey(data, "left");
228
+ if (!activates || ctx.ui.getEditorText() !== "") return undefined;
229
+ this.active = true;
230
+ this.selectedKey = "main";
231
+ this.refresh();
232
+ return { consume: true };
233
+ }
234
+
235
+ const roster = this.rosterKeys();
236
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
237
+ if (matchesKey(data, "down")) {
238
+ this.selectedKey = roster[Math.min(roster.length - 1, selectedIndex + 1)] ?? "main";
239
+ this.refresh();
240
+ return { consume: true };
241
+ }
242
+ if (matchesKey(data, "up")) {
243
+ if (selectedIndex === 0) {
244
+ this.deactivate();
245
+ return { consume: true };
246
+ }
247
+ this.selectedKey = roster[selectedIndex - 1] ?? "main";
248
+ this.refresh();
249
+ return { consume: true };
250
+ }
251
+ if (matchesKey(data, "escape")) {
252
+ this.deactivate();
253
+ return { consume: true };
254
+ }
255
+ if (matchesKey(data, Key.enter)) {
256
+ if (this.selectedKey === "main") {
257
+ this.deactivate();
258
+ return { consume: true };
259
+ }
260
+ this.inspectorOpen = true;
261
+ this.refresh();
262
+ const selectedKey = this.selectedKey;
263
+ void Promise.resolve()
264
+ .then(() => this.openInspector(selectedKey))
265
+ .catch((error) => ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"))
266
+ .finally(() => {
267
+ this.inspectorOpen = false;
268
+ this.refresh();
269
+ });
270
+ return { consume: true };
271
+ }
272
+
273
+ this.deactivate();
274
+ return undefined;
275
+ }
276
+
277
+ render(width: number, theme: Theme): string[] {
278
+ if (this.entries.length === 0) return [];
279
+ const roster = this.rosterKeys();
280
+ const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
281
+ const hint = this.active
282
+ ? "↑↓ select · enter inspect · esc back"
283
+ : "esc to interrupt · ← for agents · ↓ to manage";
284
+ const lines = [truncateToWidth(` ${theme.fg("dim", hint)}`, width), ""];
285
+ lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex, theme)} main`, width));
286
+
287
+ const visibleCount = Math.min(this.maxAgentRows, this.entries.length);
288
+ const selectedAgentIndex = Math.max(0, selectedIndex - 1);
289
+ const start = selectedAgentIndex < visibleCount ? 0 : selectedAgentIndex - visibleCount + 1;
290
+ const hiddenBelow = this.entries.length - (start + visibleCount);
291
+ if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
292
+ for (let index = start; index < start + visibleCount; index++) {
293
+ lines.push(this.renderEntry(index + 1, selectedIndex, this.entries[index]!, width, theme));
294
+ }
295
+ if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
296
+ return lines;
297
+ }
298
+
299
+ private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetStatusEntry, width: number, theme: Theme): string {
300
+ const description = entry.description?.replace(/\s+/g, " ").trim();
301
+ const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", entry.agent)}${description ? ` ${description}` : ""}`;
302
+ const elapsed = Date.now() - entry.startedAt;
303
+ const right = theme.fg("dim", `${formatFleetElapsed(elapsed)} · ${formatFleetTokens(entry.tokens)}`);
304
+ return rightAlign(left, right, width);
305
+ }
306
+
307
+ private bullet(rosterIndex: number, selectedIndex: number, theme: Theme): string {
308
+ return rosterIndex === selectedIndex ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
309
+ }
310
+
311
+ private rosterKeys(): string[] {
312
+ return ["main", ...this.entries.map((entry) => entry.key)];
313
+ }
314
+
315
+ private clampSelection(): void {
316
+ if (!this.rosterKeys().includes(this.selectedKey)) this.selectedKey = "main";
317
+ }
318
+
319
+ private deactivate(): void {
320
+ this.active = false;
321
+ this.selectedKey = "main";
322
+ this.refresh();
323
+ }
324
+
325
+ private editorHasFocus(): boolean {
326
+ // pi-tui exposes focus mutation but no focus getter; fail closed if this compatibility seam disappears.
327
+ const focused = (this.tui as unknown as { focusedComponent?: unknown } | undefined)?.focusedComponent;
328
+ return focused instanceof Editor;
329
+ }
330
+
331
+ private getRenderKey(): string {
332
+ const now = Date.now();
333
+ return JSON.stringify({
334
+ active: this.active,
335
+ selected: this.selectedKey,
336
+ inspectorOpen: this.inspectorOpen,
337
+ entries: this.entries.map((entry) => [
338
+ entry.key,
339
+ entry.agent,
340
+ entry.description,
341
+ Math.round((now - entry.startedAt) / 1000),
342
+ entry.tokens,
343
+ ]),
344
+ });
345
+ }
346
+
347
+ private clearUiRegistration(): void {
348
+ if (this.timer) clearInterval(this.timer);
349
+ this.timer = undefined;
350
+ this.inputUnsubscribe?.();
351
+ this.inputUnsubscribe = undefined;
352
+ if (this.ctx?.hasUI && this.widgetRegistered) {
353
+ try {
354
+ this.ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
355
+ } catch {
356
+ // The previous extension context may already be stale during reload/session replacement.
357
+ }
358
+ }
359
+ this.widgetRegistered = false;
360
+ this.tui = undefined;
361
+ }
362
+ }