@ian-pascoe/pi-minimal-subagents 0.1.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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/package.json +51 -0
- package/src/index.ts +1 -0
- package/src/minimal-subagents-capabilities.ts +118 -0
- package/src/minimal-subagents-config.ts +217 -0
- package/src/minimal-subagents-context.ts +70 -0
- package/src/minimal-subagents-coordinator.ts +1230 -0
- package/src/minimal-subagents-extension.ts +279 -0
- package/src/minimal-subagents-fork-lifecycle.ts +36 -0
- package/src/minimal-subagents-registry.ts +219 -0
- package/src/minimal-subagents-rendering.ts +717 -0
- package/src/minimal-subagents-sessions.ts +702 -0
- package/src/minimal-subagents-shutdown.ts +29 -0
- package/src/minimal-subagents-tool-schemas.ts +66 -0
- package/src/minimal-subagents-tools.ts +285 -0
- package/src/minimal-subagents-types.ts +305 -0
- package/src/minimal-subagents-ui.ts +326 -0
- package/src/minimal-subagents-usage.ts +24 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
|
|
5
|
+
import {
|
|
6
|
+
formatSubagentDuration,
|
|
7
|
+
renderSubagentStatusLabel,
|
|
8
|
+
renderSubagentStatusSymbol,
|
|
9
|
+
} from "./minimal-subagents-rendering.js";
|
|
10
|
+
import type { AgentSummary, HierarchyStatusResult, TurnStatus } from "./minimal-subagents-types.js";
|
|
11
|
+
|
|
12
|
+
const MINIMAL_SUBAGENTS_UI_KEY = "minimal-subagents";
|
|
13
|
+
const MINIMAL_SUBAGENTS_RECENT_LIMIT = 3;
|
|
14
|
+
const MINIMAL_SUBAGENTS_WIDGET_ROW_LIMIT = 8;
|
|
15
|
+
const MINIMAL_SUBAGENTS_REFRESH_MS = 1_000;
|
|
16
|
+
const MINIMAL_SUBAGENTS_COOLDOWN_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export interface MinimalSubagentsWidgetRow {
|
|
19
|
+
agentId: string;
|
|
20
|
+
depth: number;
|
|
21
|
+
status: TurnStatus | "idle" | "unavailable";
|
|
22
|
+
elapsedMs?: number;
|
|
23
|
+
task?: string;
|
|
24
|
+
structural: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MinimalSubagentsWidgetView {
|
|
28
|
+
runningCount: number;
|
|
29
|
+
retainedCount: number;
|
|
30
|
+
recentCount: number;
|
|
31
|
+
rows: MinimalSubagentsWidgetRow[];
|
|
32
|
+
overflowCount: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FlattenedAgentSummary {
|
|
36
|
+
agent: AgentSummary;
|
|
37
|
+
depth: number;
|
|
38
|
+
parentId?: string;
|
|
39
|
+
order: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flattenAgentHierarchy(agents: readonly AgentSummary[]): FlattenedAgentSummary[] {
|
|
43
|
+
const flattened: FlattenedAgentSummary[] = [];
|
|
44
|
+
const visit = (agent: AgentSummary, depth: number, parentId?: string) => {
|
|
45
|
+
flattened.push({ agent, depth, parentId, order: flattened.length });
|
|
46
|
+
for (const child of agent.children) visit(child, depth + 1, agent.agent_id);
|
|
47
|
+
};
|
|
48
|
+
for (const agent of agents) visit(agent, 0);
|
|
49
|
+
return flattened;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function agentTerminalStatus(agent: AgentSummary): TurnStatus | "idle" | "unavailable" {
|
|
53
|
+
if (agent.availability === "unavailable") return "unavailable";
|
|
54
|
+
if (agent.state === "running") return "running";
|
|
55
|
+
return agent.latest_turn?.status ?? "idle";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function terminalTimestamp(agent: AgentSummary): number {
|
|
59
|
+
const value = Date.parse(agent.latest_activity_at ?? "");
|
|
60
|
+
return Number.isFinite(value) ? value : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function terminalFailurePriority(agent: AgentSummary): number {
|
|
64
|
+
const status = agentTerminalStatus(agent);
|
|
65
|
+
return status === "failed" || status === "unavailable" ? 0 : 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function candidatePath(
|
|
69
|
+
candidate: FlattenedAgentSummary,
|
|
70
|
+
byId: ReadonlyMap<string, FlattenedAgentSummary>,
|
|
71
|
+
): FlattenedAgentSummary[] {
|
|
72
|
+
const path: FlattenedAgentSummary[] = [candidate];
|
|
73
|
+
let parentId = candidate.parentId;
|
|
74
|
+
while (parentId) {
|
|
75
|
+
const parent = byId.get(parentId);
|
|
76
|
+
if (!parent) break;
|
|
77
|
+
path.push(parent);
|
|
78
|
+
parentId = parent.parentId;
|
|
79
|
+
}
|
|
80
|
+
return path.reverse();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Project coordinator status into the bounded active/ancestor/recent widget hierarchy. */
|
|
84
|
+
export function buildMinimalSubagentsWidgetView(
|
|
85
|
+
status: HierarchyStatusResult,
|
|
86
|
+
): MinimalSubagentsWidgetView {
|
|
87
|
+
const agents = "agents" in status ? status.agents : [status.agent];
|
|
88
|
+
const flattened = flattenAgentHierarchy(agents);
|
|
89
|
+
const byId = new Map(flattened.map((item) => [item.agent.agent_id, item]));
|
|
90
|
+
const running = flattened.filter((item) => item.agent.state === "running");
|
|
91
|
+
const recent = flattened
|
|
92
|
+
.filter(
|
|
93
|
+
(item) =>
|
|
94
|
+
item.agent.state !== "running" &&
|
|
95
|
+
(item.agent.availability === "unavailable" || item.agent.latest_turn !== undefined),
|
|
96
|
+
)
|
|
97
|
+
.sort(
|
|
98
|
+
(left, right) =>
|
|
99
|
+
terminalFailurePriority(left.agent) - terminalFailurePriority(right.agent) ||
|
|
100
|
+
terminalTimestamp(right.agent) - terminalTimestamp(left.agent) ||
|
|
101
|
+
left.order - right.order,
|
|
102
|
+
)
|
|
103
|
+
.slice(0, MINIMAL_SUBAGENTS_RECENT_LIMIT);
|
|
104
|
+
const meaningfulIds = new Set(
|
|
105
|
+
[...running, ...recent].map((candidate) => candidate.agent.agent_id),
|
|
106
|
+
);
|
|
107
|
+
const desiredIds = new Set<string>();
|
|
108
|
+
const chosenIds = new Set<string>();
|
|
109
|
+
for (const candidate of [...running, ...recent]) {
|
|
110
|
+
const path = candidatePath(candidate, byId);
|
|
111
|
+
for (const item of path) desiredIds.add(item.agent.agent_id);
|
|
112
|
+
const additions = path.filter((item) => !chosenIds.has(item.agent.agent_id));
|
|
113
|
+
if (chosenIds.size + additions.length > MINIMAL_SUBAGENTS_WIDGET_ROW_LIMIT) continue;
|
|
114
|
+
for (const item of additions) chosenIds.add(item.agent.agent_id);
|
|
115
|
+
}
|
|
116
|
+
const rows = flattened
|
|
117
|
+
.filter((item) => chosenIds.has(item.agent.agent_id))
|
|
118
|
+
.map((item): MinimalSubagentsWidgetRow => {
|
|
119
|
+
const structural = !meaningfulIds.has(item.agent.agent_id);
|
|
120
|
+
return {
|
|
121
|
+
agentId: item.agent.agent_id,
|
|
122
|
+
depth: item.depth,
|
|
123
|
+
status: agentTerminalStatus(item.agent),
|
|
124
|
+
elapsedMs: item.agent.elapsed_ms,
|
|
125
|
+
task: structural ? undefined : item.agent.task,
|
|
126
|
+
structural,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
runningCount: running.length,
|
|
131
|
+
retainedCount: flattened.length,
|
|
132
|
+
recentCount: recent.filter((item) => chosenIds.has(item.agent.agent_id)).length,
|
|
133
|
+
rows,
|
|
134
|
+
overflowCount: Math.max(0, desiredIds.size - chosenIds.size),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Render a responsive widget snapshot with ANSI-safe terminal-width truncation. */
|
|
139
|
+
export function renderMinimalSubagentsWidgetLines(
|
|
140
|
+
view: MinimalSubagentsWidgetView,
|
|
141
|
+
width: number,
|
|
142
|
+
theme: Theme,
|
|
143
|
+
): string[] {
|
|
144
|
+
if (width <= 0) return [];
|
|
145
|
+
const separator = theme.fg("dim", " · ");
|
|
146
|
+
const activity =
|
|
147
|
+
view.runningCount > 0
|
|
148
|
+
? theme.fg("accent", `${view.runningCount} running`)
|
|
149
|
+
: theme.fg("dim", "idle");
|
|
150
|
+
const lines = [
|
|
151
|
+
truncateToWidth(
|
|
152
|
+
[
|
|
153
|
+
theme.fg("toolTitle", theme.bold("Subagents")),
|
|
154
|
+
activity,
|
|
155
|
+
width >= 44 && view.recentCount > 0
|
|
156
|
+
? theme.fg("muted", `${view.recentCount} recent`)
|
|
157
|
+
: undefined,
|
|
158
|
+
]
|
|
159
|
+
.filter((part): part is string => Boolean(part))
|
|
160
|
+
.join(separator),
|
|
161
|
+
width,
|
|
162
|
+
"…",
|
|
163
|
+
),
|
|
164
|
+
];
|
|
165
|
+
for (const row of view.rows) {
|
|
166
|
+
const duration = formatSubagentDuration(row.elapsedMs);
|
|
167
|
+
const task = row.task?.replace(/\s+/g, " ").trim();
|
|
168
|
+
const branch =
|
|
169
|
+
row.depth > 0
|
|
170
|
+
? theme.fg("borderMuted", `${" ".repeat(row.depth)}╰─ `)
|
|
171
|
+
: theme.fg("borderMuted", " ");
|
|
172
|
+
const agentId = row.structural ? theme.fg("muted", row.agentId) : theme.bold(row.agentId);
|
|
173
|
+
const parts = [
|
|
174
|
+
`${branch}${renderSubagentStatusSymbol(theme, row.status)} ${agentId}`,
|
|
175
|
+
row.structural ? undefined : renderSubagentStatusLabel(theme, row.status),
|
|
176
|
+
task ? theme.fg("muted", task) : undefined,
|
|
177
|
+
duration ? theme.fg("muted", duration) : undefined,
|
|
178
|
+
].filter((part): part is string => Boolean(part));
|
|
179
|
+
lines.push(truncateToWidth(parts.join(separator), width, "…"));
|
|
180
|
+
}
|
|
181
|
+
if (view.overflowCount > 0) {
|
|
182
|
+
lines.push(truncateToWidth(theme.fg("dim", ` … +${view.overflowCount} more`), width, "…"));
|
|
183
|
+
}
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class MinimalSubagentsWidgetComponent implements Component {
|
|
188
|
+
constructor(
|
|
189
|
+
private view: MinimalSubagentsWidgetView,
|
|
190
|
+
private readonly tui: TUI,
|
|
191
|
+
private readonly theme: Theme,
|
|
192
|
+
) {}
|
|
193
|
+
|
|
194
|
+
update(view: MinimalSubagentsWidgetView): void {
|
|
195
|
+
this.view = view;
|
|
196
|
+
this.tui.requestRender();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
render(width: number): string[] {
|
|
200
|
+
return renderMinimalSubagentsWidgetLines(this.view, width, this.theme);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Own the root session's widget, footer, live refresh, cooldown, and idempotent cleanup. */
|
|
207
|
+
export class MinimalSubagentsUiController {
|
|
208
|
+
private disposed = false;
|
|
209
|
+
private widgetMounted = false;
|
|
210
|
+
private widgetComponent?: MinimalSubagentsWidgetComponent;
|
|
211
|
+
private refreshInterval?: ReturnType<typeof setInterval>;
|
|
212
|
+
private cooldownTimeout?: ReturnType<typeof setTimeout>;
|
|
213
|
+
private previousRunningCount = 0;
|
|
214
|
+
private restorationAttentionConsumed = false;
|
|
215
|
+
private currentView: MinimalSubagentsWidgetView = {
|
|
216
|
+
runningCount: 0,
|
|
217
|
+
retainedCount: 0,
|
|
218
|
+
recentCount: 0,
|
|
219
|
+
rows: [],
|
|
220
|
+
overflowCount: 0,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
constructor(
|
|
224
|
+
private readonly coordinator: MinimalSubagentsCoordinator,
|
|
225
|
+
private readonly context: ExtensionContext,
|
|
226
|
+
) {}
|
|
227
|
+
|
|
228
|
+
refresh(): void {
|
|
229
|
+
if (this.disposed || this.context.mode !== "tui") return;
|
|
230
|
+
const status = this.coordinator.inspectStatus();
|
|
231
|
+
const nextView = buildMinimalSubagentsWidgetView(status);
|
|
232
|
+
this.currentView = nextView;
|
|
233
|
+
if (nextView.runningCount > 0) {
|
|
234
|
+
this.clearCooldown();
|
|
235
|
+
this.ensureRefreshInterval();
|
|
236
|
+
this.showWidget(nextView);
|
|
237
|
+
this.context.ui.setStatus(
|
|
238
|
+
MINIMAL_SUBAGENTS_UI_KEY,
|
|
239
|
+
[
|
|
240
|
+
this.context.ui.theme.fg("accent", `◉ ${nextView.runningCount} running`),
|
|
241
|
+
this.context.ui.theme.fg("muted", `${nextView.retainedCount} retained`),
|
|
242
|
+
].join(this.context.ui.theme.fg("dim", " · ")),
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
this.clearRefreshInterval();
|
|
246
|
+
this.context.ui.setStatus(MINIMAL_SUBAGENTS_UI_KEY, undefined);
|
|
247
|
+
const restoredUnavailable =
|
|
248
|
+
!this.restorationAttentionConsumed &&
|
|
249
|
+
this.previousRunningCount === 0 &&
|
|
250
|
+
nextView.rows.some((row) => row.status === "unavailable");
|
|
251
|
+
this.restorationAttentionConsumed = true;
|
|
252
|
+
if (this.previousRunningCount > 0 || restoredUnavailable) {
|
|
253
|
+
this.showWidget(nextView);
|
|
254
|
+
this.ensureCooldown();
|
|
255
|
+
} else if (this.cooldownTimeout) {
|
|
256
|
+
this.showWidget(nextView);
|
|
257
|
+
} else {
|
|
258
|
+
this.hideWidget();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.previousRunningCount = nextView.runningCount;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dispose(): void {
|
|
265
|
+
if (this.disposed) return;
|
|
266
|
+
this.disposed = true;
|
|
267
|
+
this.clearRefreshInterval();
|
|
268
|
+
this.clearCooldown();
|
|
269
|
+
if (this.context.mode === "tui") {
|
|
270
|
+
this.context.ui.setStatus(MINIMAL_SUBAGENTS_UI_KEY, undefined);
|
|
271
|
+
this.context.ui.setWidget(MINIMAL_SUBAGENTS_UI_KEY, undefined);
|
|
272
|
+
}
|
|
273
|
+
this.widgetMounted = false;
|
|
274
|
+
this.widgetComponent = undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private showWidget(view: MinimalSubagentsWidgetView): void {
|
|
278
|
+
if (this.widgetMounted) {
|
|
279
|
+
this.widgetComponent?.update(view);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.context.ui.setWidget(
|
|
283
|
+
MINIMAL_SUBAGENTS_UI_KEY,
|
|
284
|
+
(tui, theme) => {
|
|
285
|
+
this.widgetComponent = new MinimalSubagentsWidgetComponent(this.currentView, tui, theme);
|
|
286
|
+
return this.widgetComponent;
|
|
287
|
+
},
|
|
288
|
+
{ placement: "aboveEditor" },
|
|
289
|
+
);
|
|
290
|
+
this.widgetMounted = true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private hideWidget(): void {
|
|
294
|
+
if (!this.widgetMounted) return;
|
|
295
|
+
this.context.ui.setWidget(MINIMAL_SUBAGENTS_UI_KEY, undefined);
|
|
296
|
+
this.widgetMounted = false;
|
|
297
|
+
this.widgetComponent = undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private ensureRefreshInterval(): void {
|
|
301
|
+
if (this.refreshInterval) return;
|
|
302
|
+
this.refreshInterval = setInterval(() => this.refresh(), MINIMAL_SUBAGENTS_REFRESH_MS);
|
|
303
|
+
this.refreshInterval.unref?.();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private clearRefreshInterval(): void {
|
|
307
|
+
if (!this.refreshInterval) return;
|
|
308
|
+
clearInterval(this.refreshInterval);
|
|
309
|
+
this.refreshInterval = undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private ensureCooldown(): void {
|
|
313
|
+
this.clearCooldown();
|
|
314
|
+
this.cooldownTimeout = setTimeout(() => {
|
|
315
|
+
this.cooldownTimeout = undefined;
|
|
316
|
+
this.hideWidget();
|
|
317
|
+
}, MINIMAL_SUBAGENTS_COOLDOWN_MS);
|
|
318
|
+
this.cooldownTimeout.unref?.();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private clearCooldown(): void {
|
|
322
|
+
if (!this.cooldownTimeout) return;
|
|
323
|
+
clearTimeout(this.cooldownTimeout);
|
|
324
|
+
this.cooldownTimeout = undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/** Add two optional Pi usage totals without retaining mutable references to either input. */
|
|
4
|
+
export function addMinimalSubagentsUsage(
|
|
5
|
+
left: Usage | undefined,
|
|
6
|
+
right: Usage | undefined,
|
|
7
|
+
): Usage | undefined {
|
|
8
|
+
if (!left) return right ? structuredClone(right) : undefined;
|
|
9
|
+
if (!right) return structuredClone(left);
|
|
10
|
+
return {
|
|
11
|
+
input: left.input + right.input,
|
|
12
|
+
output: left.output + right.output,
|
|
13
|
+
cacheRead: left.cacheRead + right.cacheRead,
|
|
14
|
+
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
15
|
+
totalTokens: left.totalTokens + right.totalTokens,
|
|
16
|
+
cost: {
|
|
17
|
+
input: left.cost.input + right.cost.input,
|
|
18
|
+
output: left.cost.output + right.cost.output,
|
|
19
|
+
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
|
20
|
+
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
|
21
|
+
total: left.cost.total + right.cost.total,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|