@melihmucuk/pi-crew 1.0.17 → 1.0.18
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/extension/catalog.ts +543 -0
- package/extension/crew.ts +383 -0
- package/extension/index.ts +5 -6
- package/extension/subagent-session.ts +270 -0
- package/extension/tools.ts +323 -0
- package/extension/ui.ts +309 -0
- package/package.json +1 -1
- package/extension/agent-catalog.ts +0 -369
- package/extension/agent-config-fields.ts +0 -359
- package/extension/agent-discovery.ts +0 -123
- package/extension/bootstrap-session.ts +0 -131
- package/extension/integration/crew-tool-actions.ts +0 -306
- package/extension/integration/crew-tool-executor.ts +0 -109
- package/extension/integration/register-renderers.ts +0 -77
- package/extension/integration/register-tools.ts +0 -47
- package/extension/integration/tool-presentation.ts +0 -30
- package/extension/integration/tools/crew-abort.ts +0 -56
- package/extension/integration/tools/crew-done.ts +0 -27
- package/extension/integration/tools/crew-list.ts +0 -36
- package/extension/integration/tools/crew-respond.ts +0 -38
- package/extension/integration/tools/crew-spawn.ts +0 -46
- package/extension/message-delivery-policy.ts +0 -22
- package/extension/runtime/crew-runtime.ts +0 -263
- package/extension/runtime/owner-session-coordinator.ts +0 -138
- package/extension/runtime/subagent-lifecycle.ts +0 -203
- package/extension/runtime/subagent-registry.ts +0 -122
- package/extension/runtime/subagent-transitions.ts +0 -100
- package/extension/status-widget.ts +0 -107
- package/extension/subagent-messages.ts +0 -116
- package/extension/tool-registry.ts +0 -19
- /package/extension/{runtime/overflow-recovery.ts → overflow-recovery.ts} +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import {
|
|
6
|
+
discoverAgents,
|
|
7
|
+
type AgentConfig,
|
|
8
|
+
type AgentDiscoveryWarning,
|
|
9
|
+
} from "./catalog.js";
|
|
10
|
+
import type { AbortOwnedResult, ActiveAgentSummary, CrewRuntime } from "./crew.js";
|
|
11
|
+
import { STATUS_ICON, renderCrewCall, renderCrewResult, sendCrewListActiveWarning } from "./ui.js";
|
|
12
|
+
|
|
13
|
+
export type CrewToolResult = AgentToolResult<unknown> & {
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
terminate?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type RegisteredTool = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
19
|
+
type ToolRenderCall = Exclude<RegisteredTool["renderCall"], undefined>;
|
|
20
|
+
|
|
21
|
+
interface ToolContext {
|
|
22
|
+
cwd: string;
|
|
23
|
+
callerSessionId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getToolContext(ctx: ExtensionContext): ToolContext {
|
|
27
|
+
return {
|
|
28
|
+
cwd: ctx.cwd,
|
|
29
|
+
callerSessionId: ctx.sessionManager.getSessionId(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolError(text: string): CrewToolResult {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text }],
|
|
36
|
+
isError: true,
|
|
37
|
+
details: { error: true },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toolSuccess(
|
|
42
|
+
text: string,
|
|
43
|
+
details: Record<string, unknown> = {},
|
|
44
|
+
options: { terminate?: boolean } = {},
|
|
45
|
+
): CrewToolResult {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text }],
|
|
48
|
+
details,
|
|
49
|
+
...(options.terminate ? { terminate: true } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatAvailableAgents(agents: AgentConfig[]): string[] {
|
|
54
|
+
if (agents.length === 0) {
|
|
55
|
+
return ["No valid subagent definitions found. Add `.md` files to `<cwd>/.pi/agents/` or `~/.pi/agent/agents/`."];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return agents.flatMap((agent) => [
|
|
59
|
+
"",
|
|
60
|
+
`name: ${agent.name}`,
|
|
61
|
+
`description: ${agent.description}`,
|
|
62
|
+
`interactive: ${agent.interactive ? "true" : "false"}`,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatWarnings(warnings: AgentDiscoveryWarning[]): string[] {
|
|
67
|
+
if (warnings.length === 0) return [];
|
|
68
|
+
return [
|
|
69
|
+
"",
|
|
70
|
+
"## Ignored subagent definitions",
|
|
71
|
+
...warnings.map((warning) => `- ${warning.message} (${warning.filePath})`),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatActiveAgents(running: ActiveAgentSummary[]): string[] {
|
|
76
|
+
if (running.length === 0) return ["No subagents currently active."];
|
|
77
|
+
return running.flatMap((agent) => {
|
|
78
|
+
const icon = STATUS_ICON[agent.status] ?? "❓";
|
|
79
|
+
return ["", `id: ${agent.id}`, `name: ${agent.agentName}`, `status: ${icon} ${agent.status}`];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatAbortToolMessage(result: AbortOwnedResult): string {
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
if (result.abortedIds.length > 0) parts.push(`Aborted ${result.abortedIds.length} subagent(s): ${result.abortedIds.join(", ")}`);
|
|
86
|
+
if (result.missingIds.length > 0) parts.push(`Not found or already finished: ${result.missingIds.join(", ")}`);
|
|
87
|
+
if (result.foreignIds.length > 0) parts.push(`Belong to a different session: ${result.foreignIds.join(", ")}`);
|
|
88
|
+
return parts.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notifyDiscoveryWarnings(
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
shownDiscoveryWarnings: Set<string>,
|
|
94
|
+
warnings: AgentDiscoveryWarning[],
|
|
95
|
+
): void {
|
|
96
|
+
if (!ctx.hasUI) return;
|
|
97
|
+
for (const warning of warnings) {
|
|
98
|
+
const key = `${warning.filePath}:${warning.message}`;
|
|
99
|
+
if (shownDiscoveryWarnings.has(key)) continue;
|
|
100
|
+
shownDiscoveryWarnings.add(key);
|
|
101
|
+
ctx.ui.notify(`${warning.message} (${warning.filePath})`, "error");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function showActiveListWarning(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
106
|
+
Promise.resolve().then(() => {
|
|
107
|
+
sendCrewListActiveWarning(pi.sendMessage.bind(pi), {
|
|
108
|
+
isIdle: ctx.isIdle(),
|
|
109
|
+
triggerTurn: true,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registerActionTool<Params extends object>(
|
|
115
|
+
pi: ExtensionAPI,
|
|
116
|
+
options: Omit<RegisteredTool, "execute" | "renderResult" | "renderCall"> & {
|
|
117
|
+
action: (params: Params, ctx: ExtensionContext) => CrewToolResult;
|
|
118
|
+
renderCall?: (
|
|
119
|
+
args: Partial<Params>,
|
|
120
|
+
theme: Parameters<ToolRenderCall>[1],
|
|
121
|
+
context: Parameters<ToolRenderCall>[2],
|
|
122
|
+
) => ReturnType<ToolRenderCall>;
|
|
123
|
+
},
|
|
124
|
+
): void {
|
|
125
|
+
const { action, renderCall, ...tool } = options;
|
|
126
|
+
pi.registerTool({
|
|
127
|
+
...tool,
|
|
128
|
+
...(renderCall ? { renderCall: (args, theme, context) => renderCall(args as Partial<Params>, theme, context) } : {}),
|
|
129
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
130
|
+
return action(params as Params, ctx);
|
|
131
|
+
},
|
|
132
|
+
renderResult(result, _options, theme, _context) {
|
|
133
|
+
return renderCrewResult(result, theme);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerCrewTools(pi: ExtensionAPI, crew: CrewRuntime, extensionDir: string): void {
|
|
139
|
+
const shownDiscoveryWarnings = new Set<string>();
|
|
140
|
+
|
|
141
|
+
pi.registerTool({
|
|
142
|
+
name: "crew_list",
|
|
143
|
+
label: "List Crew",
|
|
144
|
+
description:
|
|
145
|
+
"List available subagent definitions and currently running subagents with their status. Use only to discover which subagents exist or to get a one-time status snapshot. Do NOT call this repeatedly to check if a subagent has finished — results are delivered automatically as steering messages.",
|
|
146
|
+
parameters: Type.Object({}),
|
|
147
|
+
promptSnippet: "List subagent definitions and active subagents",
|
|
148
|
+
promptGuidelines: [
|
|
149
|
+
"crew_list: List available subagents and active subagents owned by this session.",
|
|
150
|
+
"crew_list: Use before crew_spawn to discover names, descriptions, and interactive status.",
|
|
151
|
+
"crew_list: Use only for discovery or a requested status snapshot; do not poll for completion.",
|
|
152
|
+
],
|
|
153
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
154
|
+
const toolCtx = getToolContext(ctx);
|
|
155
|
+
const { agents, warnings } = discoverAgents(toolCtx.cwd);
|
|
156
|
+
const running = crew.getActiveSummariesForOwner(toolCtx.callerSessionId);
|
|
157
|
+
const lines = [
|
|
158
|
+
"## Available Subagents",
|
|
159
|
+
...formatAvailableAgents(agents),
|
|
160
|
+
...formatWarnings(warnings),
|
|
161
|
+
"",
|
|
162
|
+
"## Active Subagents",
|
|
163
|
+
...formatActiveAgents(running),
|
|
164
|
+
];
|
|
165
|
+
notifyDiscoveryWarnings(ctx, shownDiscoveryWarnings, warnings);
|
|
166
|
+
if (running.length > 0) showActiveListWarning(pi, ctx);
|
|
167
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
168
|
+
},
|
|
169
|
+
renderCall(_args, theme, _context) {
|
|
170
|
+
return new Text(theme.fg("toolTitle", theme.bold("crew_list")), 0, 0);
|
|
171
|
+
},
|
|
172
|
+
renderResult(result, _options, _theme, _context) {
|
|
173
|
+
const text = result.content[0];
|
|
174
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
registerActionTool<{ subagent: string; task: string }>(pi, {
|
|
179
|
+
name: "crew_spawn",
|
|
180
|
+
label: "Spawn Crew",
|
|
181
|
+
description:
|
|
182
|
+
"Spawn a non-blocking subagent that runs in an isolated session. The subagent works independently while your session stays interactive. Results are delivered back to your session as steering messages.",
|
|
183
|
+
parameters: Type.Object({
|
|
184
|
+
subagent: Type.String({ description: "Subagent name from crew_list" }),
|
|
185
|
+
task: Type.String({ description: "Task to delegate to the subagent" }),
|
|
186
|
+
}),
|
|
187
|
+
promptSnippet: "Spawn a non-blocking subagent. Use crew_list first to see available subagents.",
|
|
188
|
+
promptGuidelines: [
|
|
189
|
+
"crew_spawn: Spawn a discovered subagent for one clearly delegated, self-contained task.",
|
|
190
|
+
"crew_spawn: Include only needed context: constraints, relevant files, acceptance criteria, and expected output.",
|
|
191
|
+
"crew_spawn: After spawning, ownership transfers to the subagent; do not work on that task yourself.",
|
|
192
|
+
"crew_spawn: Results arrive as steering messages; do not poll crew_list or fabricate results.",
|
|
193
|
+
"crew_spawn: Use the bundled pi-crew skill for detailed delegation patterns.",
|
|
194
|
+
],
|
|
195
|
+
action: (params, ctx) => {
|
|
196
|
+
const toolCtx = getToolContext(ctx);
|
|
197
|
+
const { agents, warnings } = discoverAgents(toolCtx.cwd);
|
|
198
|
+
notifyDiscoveryWarnings(ctx, shownDiscoveryWarnings, warnings);
|
|
199
|
+
const subagent = agents.find((candidate) => candidate.name === params.subagent);
|
|
200
|
+
if (!subagent) {
|
|
201
|
+
const available = agents.map((candidate) => candidate.name).join(", ") || "none";
|
|
202
|
+
return toolError(`Unknown subagent: "${params.subagent}". Available: ${available}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const id = crew.spawn(
|
|
206
|
+
subagent,
|
|
207
|
+
params.task,
|
|
208
|
+
toolCtx.cwd,
|
|
209
|
+
toolCtx.callerSessionId,
|
|
210
|
+
{
|
|
211
|
+
model: ctx.model,
|
|
212
|
+
modelRegistry: ctx.modelRegistry,
|
|
213
|
+
agentDir: getAgentDir(),
|
|
214
|
+
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
215
|
+
onWarning: (msg) => ctx.ui.notify(msg, "warning"),
|
|
216
|
+
},
|
|
217
|
+
extensionDir,
|
|
218
|
+
);
|
|
219
|
+
return toolSuccess(
|
|
220
|
+
`Subagent '${subagent.name}' spawned as ${id}. Result will be delivered as a steering message when done.`,
|
|
221
|
+
{ id, agentName: subagent.name, task: params.task },
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
renderCall(args, theme, _context) {
|
|
225
|
+
return renderCrewCall(theme, "crew_spawn", args.subagent || "...", args.task);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
registerActionTool<{ subagent_id?: string; subagent_ids?: string[]; all?: boolean }>(pi, {
|
|
230
|
+
name: "crew_abort",
|
|
231
|
+
label: "Abort Crew",
|
|
232
|
+
description: "Abort one, many, or all active subagents owned by the current session.",
|
|
233
|
+
parameters: Type.Object({
|
|
234
|
+
subagent_id: Type.Optional(Type.String({ description: "Single subagent ID to abort" })),
|
|
235
|
+
subagent_ids: Type.Optional(Type.Array(Type.String(), { minItems: 1, description: "Multiple subagent IDs to abort" })),
|
|
236
|
+
all: Type.Optional(Type.Boolean({ description: "Abort all active subagents owned by the current session" })),
|
|
237
|
+
}),
|
|
238
|
+
promptSnippet: "Abort one, many, or all active subagents from this session.",
|
|
239
|
+
promptGuidelines: [
|
|
240
|
+
"crew_abort: Abort one, many, or all active subagents owned by this session.",
|
|
241
|
+
"crew_abort: Provide exactly one mode: subagent_id, subagent_ids, or all=true.",
|
|
242
|
+
"crew_abort: Use only when delegated work is obsolete, wrong, or explicitly cancelled.",
|
|
243
|
+
],
|
|
244
|
+
action: (params, ctx) => {
|
|
245
|
+
const { callerSessionId } = getToolContext(ctx);
|
|
246
|
+
const modeCount = Number(Boolean(params.subagent_id)) + Number(Boolean(params.subagent_ids?.length)) + Number(params.all === true);
|
|
247
|
+
if (modeCount !== 1) return toolError("Provide exactly one of: subagent_id, subagent_ids, or all=true.");
|
|
248
|
+
|
|
249
|
+
if (params.all) {
|
|
250
|
+
const abortedIds = crew.abortAllOwned(callerSessionId, { reason: "Aborted by tool request" });
|
|
251
|
+
if (abortedIds.length === 0) return toolError("No active subagents in the current session.");
|
|
252
|
+
return toolSuccess(`Aborted ${abortedIds.length} subagent(s): ${abortedIds.join(", ")}`, { ids: abortedIds }, { terminate: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ids = params.subagent_id ? [params.subagent_id] : (params.subagent_ids ?? []);
|
|
256
|
+
const result = crew.abortOwned(ids, callerSessionId, { reason: "Aborted by tool request" });
|
|
257
|
+
const message = formatAbortToolMessage(result);
|
|
258
|
+
if (result.abortedIds.length === 0) return toolError(message || "No subagents were aborted.");
|
|
259
|
+
return toolSuccess(
|
|
260
|
+
message,
|
|
261
|
+
{ ids: result.abortedIds, missing_ids: result.missingIds, foreign_ids: result.foreignIds },
|
|
262
|
+
{ terminate: true },
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
renderCall(args, theme, _context) {
|
|
266
|
+
if (args.all) return renderCrewCall(theme, "crew_abort", "all");
|
|
267
|
+
if (args.subagent_id) return renderCrewCall(theme, "crew_abort", args.subagent_id);
|
|
268
|
+
const count = Array.isArray(args.subagent_ids) ? args.subagent_ids.length : 0;
|
|
269
|
+
return renderCrewCall(theme, "crew_abort", `${count} ids`);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
registerActionTool<{ subagent_id: string; message: string }>(pi, {
|
|
274
|
+
name: "crew_respond",
|
|
275
|
+
label: "Respond to Crew",
|
|
276
|
+
description: "Send a follow-up message to an interactive subagent that is waiting for a response.",
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
subagent_id: Type.String({ description: "ID of the waiting subagent (from crew_list or crew_spawn result)" }),
|
|
279
|
+
message: Type.String({ description: "Message to send to the subagent" }),
|
|
280
|
+
}),
|
|
281
|
+
promptSnippet: "Send a follow-up message to a waiting interactive subagent.",
|
|
282
|
+
promptGuidelines: [
|
|
283
|
+
"crew_respond: Send a complete follow-up message to a waiting interactive subagent.",
|
|
284
|
+
"crew_respond: Use the waiting subagent ID from crew_spawn results or crew_list.",
|
|
285
|
+
"crew_respond: The response arrives as a steering message; do not poll crew_list.",
|
|
286
|
+
],
|
|
287
|
+
action: (params, ctx) => {
|
|
288
|
+
const { callerSessionId } = getToolContext(ctx);
|
|
289
|
+
const { error } = crew.respond(params.subagent_id, params.message, callerSessionId);
|
|
290
|
+
if (error) return toolError(error);
|
|
291
|
+
return toolSuccess(
|
|
292
|
+
`Message sent to subagent ${params.subagent_id}. Response will be delivered as a steering message.`,
|
|
293
|
+
{ id: params.subagent_id, message: params.message },
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
renderCall(args, theme, _context) {
|
|
297
|
+
return renderCrewCall(theme, "crew_respond", args.subagent_id || "...", args.message);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
registerActionTool<{ subagent_id: string }>(pi, {
|
|
302
|
+
name: "crew_done",
|
|
303
|
+
label: "Done with Crew",
|
|
304
|
+
description: "Close an interactive subagent session. Use when you no longer need to interact with the subagent.",
|
|
305
|
+
parameters: Type.Object({
|
|
306
|
+
subagent_id: Type.String({ description: "ID of the subagent to close" }),
|
|
307
|
+
}),
|
|
308
|
+
promptSnippet: "Close an interactive subagent session when done.",
|
|
309
|
+
promptGuidelines: [
|
|
310
|
+
"crew_done: Close a waiting interactive subagent owned by this session.",
|
|
311
|
+
"crew_done: Use only when no further follow-up is needed; otherwise use crew_respond.",
|
|
312
|
+
],
|
|
313
|
+
action: (params, ctx) => {
|
|
314
|
+
const { callerSessionId } = getToolContext(ctx);
|
|
315
|
+
const { error } = crew.done(params.subagent_id, callerSessionId);
|
|
316
|
+
if (error) return toolError(error);
|
|
317
|
+
return toolSuccess(`Subagent ${params.subagent_id} closed.`, { id: params.subagent_id });
|
|
318
|
+
},
|
|
319
|
+
renderCall(args, theme, _context) {
|
|
320
|
+
return renderCrewCall(theme, "crew_done", args.subagent_id || "...");
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
package/extension/ui.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
getMarkdownTheme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { ActiveAgentSummary, CrewRuntime } from "./crew.js";
|
|
9
|
+
|
|
10
|
+
export type SendMessageFn = ExtensionAPI["sendMessage"];
|
|
11
|
+
type Message = Parameters<SendMessageFn>[0];
|
|
12
|
+
|
|
13
|
+
type ToolTheme = Parameters<Exclude<Parameters<ExtensionAPI["registerTool"]>[0]["renderCall"], undefined>>[1];
|
|
14
|
+
export type ToolResult = AgentToolResult<unknown>;
|
|
15
|
+
|
|
16
|
+
export type SubagentStatus = "running" | "waiting" | "done" | "error" | "aborted";
|
|
17
|
+
|
|
18
|
+
export const STATUS_ICON: Record<SubagentStatus, string> = {
|
|
19
|
+
running: "⏳",
|
|
20
|
+
waiting: "⏳",
|
|
21
|
+
done: "✅",
|
|
22
|
+
error: "❌",
|
|
23
|
+
aborted: "⏹️",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const STATUS_LABEL: Record<SubagentStatus, string> = {
|
|
27
|
+
running: "running",
|
|
28
|
+
waiting: "waiting for response",
|
|
29
|
+
done: "done",
|
|
30
|
+
error: "failed",
|
|
31
|
+
aborted: "aborted",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface SteeringPayload {
|
|
35
|
+
id: string;
|
|
36
|
+
agentName: string;
|
|
37
|
+
sessionFile?: string;
|
|
38
|
+
status: SubagentStatus;
|
|
39
|
+
result?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CrewResultMessageDetails {
|
|
44
|
+
agentId: string;
|
|
45
|
+
agentName: string;
|
|
46
|
+
sessionFile?: string;
|
|
47
|
+
status: SubagentStatus;
|
|
48
|
+
body?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getCrewResultTitle(details: {
|
|
52
|
+
agentId: string;
|
|
53
|
+
agentName: string;
|
|
54
|
+
status: SubagentStatus;
|
|
55
|
+
}): string {
|
|
56
|
+
return `Subagent '${details.agentName}' (${details.agentId}) ${STATUS_LABEL[details.status]}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sendWithDeliveryPolicy(
|
|
60
|
+
message: Message,
|
|
61
|
+
sendMessage: SendMessageFn,
|
|
62
|
+
opts: { isIdle: boolean; triggerTurn: boolean },
|
|
63
|
+
): void {
|
|
64
|
+
sendMessage(
|
|
65
|
+
message,
|
|
66
|
+
opts.isIdle
|
|
67
|
+
? { triggerTurn: opts.triggerTurn }
|
|
68
|
+
: { deliverAs: "steer", triggerTurn: opts.triggerTurn },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getSteeringBody(payload: SteeringPayload): string | undefined {
|
|
73
|
+
return (payload.status === "error" || payload.status === "aborted")
|
|
74
|
+
? (payload.error ?? payload.result)
|
|
75
|
+
: (payload.result ?? payload.error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sendSteeringMessage(
|
|
79
|
+
payload: SteeringPayload,
|
|
80
|
+
sendMessage: SendMessageFn,
|
|
81
|
+
opts: { isIdle: boolean; triggerTurn: boolean },
|
|
82
|
+
): void {
|
|
83
|
+
const body = getSteeringBody(payload);
|
|
84
|
+
const title = getCrewResultTitle({ agentId: payload.id, agentName: payload.agentName, status: payload.status });
|
|
85
|
+
const content = body
|
|
86
|
+
? `**${STATUS_ICON[payload.status]} ${title}**\n\n${body}`
|
|
87
|
+
: `**${STATUS_ICON[payload.status]} ${title}**`;
|
|
88
|
+
|
|
89
|
+
sendWithDeliveryPolicy(
|
|
90
|
+
{
|
|
91
|
+
customType: "crew-result",
|
|
92
|
+
content,
|
|
93
|
+
display: true,
|
|
94
|
+
details: {
|
|
95
|
+
agentId: payload.id,
|
|
96
|
+
agentName: payload.agentName,
|
|
97
|
+
sessionFile: payload.sessionFile,
|
|
98
|
+
status: payload.status,
|
|
99
|
+
body,
|
|
100
|
+
} satisfies CrewResultMessageDetails,
|
|
101
|
+
},
|
|
102
|
+
sendMessage,
|
|
103
|
+
opts,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sendRemainingNote(
|
|
108
|
+
remainingCount: number,
|
|
109
|
+
sendMessage: SendMessageFn,
|
|
110
|
+
opts: { isIdle: boolean; triggerTurn: boolean },
|
|
111
|
+
): void {
|
|
112
|
+
if (remainingCount <= 0) return;
|
|
113
|
+
sendWithDeliveryPolicy(
|
|
114
|
+
{
|
|
115
|
+
customType: "crew-remaining",
|
|
116
|
+
content: `⏳ ${remainingCount} subagent(s) still running`,
|
|
117
|
+
display: true,
|
|
118
|
+
},
|
|
119
|
+
sendMessage,
|
|
120
|
+
opts,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sendCrewListActiveWarning(
|
|
125
|
+
sendMessage: SendMessageFn,
|
|
126
|
+
opts: { isIdle: boolean; triggerTurn: boolean },
|
|
127
|
+
): void {
|
|
128
|
+
sendWithDeliveryPolicy(
|
|
129
|
+
{
|
|
130
|
+
customType: "crew-list-warning",
|
|
131
|
+
content:
|
|
132
|
+
"⚠ Active subagents detected. Do not poll crew_list for completion — results arrive as steering messages. Continue with unrelated work or end your turn and wait for the steering messages.",
|
|
133
|
+
display: true,
|
|
134
|
+
},
|
|
135
|
+
sendMessage,
|
|
136
|
+
opts,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getStatusColor(status: CrewResultMessageDetails["status"]): "success" | "error" | "warning" | "muted" {
|
|
141
|
+
switch (status) {
|
|
142
|
+
case "done":
|
|
143
|
+
return "success";
|
|
144
|
+
case "error":
|
|
145
|
+
case "aborted":
|
|
146
|
+
return "error";
|
|
147
|
+
case "running":
|
|
148
|
+
case "waiting":
|
|
149
|
+
return "warning";
|
|
150
|
+
default:
|
|
151
|
+
return "muted";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type MessageRenderer = Parameters<ExtensionAPI["registerMessageRenderer"]>[1];
|
|
156
|
+
type MessageRendererTheme = Parameters<MessageRenderer>[2];
|
|
157
|
+
|
|
158
|
+
function renderWarningMessage(content: unknown, theme: MessageRendererTheme): Box {
|
|
159
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
160
|
+
box.addChild(new Text(theme.fg("warning", String(content ?? "")), 0, 0));
|
|
161
|
+
return box;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function registerCrewMessageRenderers(pi: ExtensionAPI): void {
|
|
165
|
+
pi.registerMessageRenderer("crew-result", (message, { expanded }, theme) => {
|
|
166
|
+
const details = message.details as CrewResultMessageDetails | undefined;
|
|
167
|
+
const title = details ? getCrewResultTitle(details) : "Subagent update";
|
|
168
|
+
const icon = details
|
|
169
|
+
? theme.fg(getStatusColor(details.status), STATUS_ICON[details.status])
|
|
170
|
+
: theme.fg("muted", "ℹ");
|
|
171
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold(title))}`;
|
|
172
|
+
const body = details?.body ?? (!details && message.content ? String(message.content) : undefined);
|
|
173
|
+
|
|
174
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
175
|
+
box.addChild(new Text(header, 0, 0));
|
|
176
|
+
|
|
177
|
+
if (details?.sessionFile) {
|
|
178
|
+
box.addChild(new Text(theme.fg("muted", `📁 ${details.sessionFile}`), 0, 0));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (body) {
|
|
182
|
+
if (expanded) {
|
|
183
|
+
box.addChild(new Text("", 0, 0));
|
|
184
|
+
box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
|
|
185
|
+
} else {
|
|
186
|
+
const lines = body.split("\n");
|
|
187
|
+
const preview = lines.slice(0, 5).join("\n");
|
|
188
|
+
box.addChild(new Text(theme.fg("dim", preview), 0, 0));
|
|
189
|
+
if (lines.length > 5) box.addChild(new Text(theme.fg("muted", "(Ctrl+O to expand)"), 0, 0));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return box;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.registerMessageRenderer("crew-remaining", (message, _options, theme) => renderWarningMessage(message.content, theme));
|
|
197
|
+
pi.registerMessageRenderer("crew-list-warning", (message, _options, theme) => renderWarningMessage(message.content, theme));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderCrewCall(theme: ToolTheme, name: string, id: string, preview?: string): Box {
|
|
201
|
+
const box = new Box(1, 1);
|
|
202
|
+
box.addChild(new Text(theme.fg("toolTitle", theme.bold(`${name} `)) + theme.fg("accent", id), 0, 0));
|
|
203
|
+
if (preview) box.addChild(new Text(theme.fg("dim", preview), 0, 0));
|
|
204
|
+
return box;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function renderCrewResult(result: ToolResult, theme: ToolTheme): Text {
|
|
208
|
+
const text = result.content[0];
|
|
209
|
+
const details = result.details as { error?: boolean } | undefined;
|
|
210
|
+
const content = text?.type === "text" && text.text ? text.text : "(no output)";
|
|
211
|
+
return new Text(details?.error ? theme.fg("error", content) : theme.fg("success", content), 0, 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
215
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
216
|
+
|
|
217
|
+
function formatTokens(tokens: number): string {
|
|
218
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
219
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
220
|
+
return String(tokens);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildWidgetLine(agent: ActiveAgentSummary, frame: string): string {
|
|
224
|
+
const model = agent.model ?? "…";
|
|
225
|
+
const icon = agent.status === "waiting" ? "⏳" : frame;
|
|
226
|
+
return `${icon} ${agent.id} (${model}) · turn ${agent.turns} · ${formatTokens(agent.contextTokens)} ctx`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface WidgetState {
|
|
230
|
+
ctx: ExtensionContext;
|
|
231
|
+
text: Text;
|
|
232
|
+
// biome-ignore lint: TUI type from factory param
|
|
233
|
+
tui: any;
|
|
234
|
+
timer: ReturnType<typeof setInterval>;
|
|
235
|
+
frameIndex: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let widget: WidgetState | undefined;
|
|
239
|
+
|
|
240
|
+
function disposeWidget(state: WidgetState): void {
|
|
241
|
+
clearInterval(state.timer);
|
|
242
|
+
if (widget === state) widget = undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function clearWidget(): void {
|
|
246
|
+
const current = widget;
|
|
247
|
+
if (!current) return;
|
|
248
|
+
disposeWidget(current);
|
|
249
|
+
current.ctx.ui.setWidget("crew-status", undefined);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hasRunningAgent(agents: ActiveAgentSummary[]): boolean {
|
|
253
|
+
return agents.some((agent) => agent.status === "running");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function syncWidgetText(state: WidgetState, agents: ActiveAgentSummary[]): void {
|
|
257
|
+
const frame = SPINNER_FRAMES[state.frameIndex % SPINNER_FRAMES.length];
|
|
258
|
+
state.text.setText(agents.map((agent) => buildWidgetLine(agent, frame)).join("\n"));
|
|
259
|
+
state.tui.requestRender();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function updateWidget(ctx: ExtensionContext, crew: CrewRuntime): void {
|
|
263
|
+
if (!ctx.hasUI) {
|
|
264
|
+
clearWidget();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ownerSessionId = ctx.sessionManager.getSessionId();
|
|
269
|
+
const running = crew.getActiveSummariesForOwner(ownerSessionId);
|
|
270
|
+
if (running.length === 0) {
|
|
271
|
+
clearWidget();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (widget && widget.ctx !== ctx) clearWidget();
|
|
276
|
+
if (widget) {
|
|
277
|
+
syncWidgetText(widget, running);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
ctx.ui.setWidget("crew-status", (tui, _theme) => {
|
|
282
|
+
const text = new Text("", 1, 0);
|
|
283
|
+
const state: WidgetState = {
|
|
284
|
+
ctx,
|
|
285
|
+
text,
|
|
286
|
+
tui,
|
|
287
|
+
frameIndex: 0,
|
|
288
|
+
timer: setInterval(() => {
|
|
289
|
+
const agents = crew.getActiveSummariesForOwner(ownerSessionId);
|
|
290
|
+
if (agents.length === 0) {
|
|
291
|
+
clearWidget();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!hasRunningAgent(agents)) return;
|
|
295
|
+
state.frameIndex++;
|
|
296
|
+
syncWidgetText(state, agents);
|
|
297
|
+
}, SPINNER_INTERVAL_MS),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
widget = state;
|
|
301
|
+
syncWidgetText(state, running);
|
|
302
|
+
|
|
303
|
+
return Object.assign(text, {
|
|
304
|
+
dispose() {
|
|
305
|
+
disposeWidget(state);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|