pi-squad 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 +244 -0
- package/package.json +30 -0
- package/src/agent-pool.ts +445 -0
- package/src/agents/_defaults/architect.json +9 -0
- package/src/agents/_defaults/backend.json +9 -0
- package/src/agents/_defaults/debugger.json +9 -0
- package/src/agents/_defaults/devops.json +9 -0
- package/src/agents/_defaults/docs.json +9 -0
- package/src/agents/_defaults/frontend.json +9 -0
- package/src/agents/_defaults/fullstack.json +9 -0
- package/src/agents/_defaults/planner.json +9 -0
- package/src/agents/_defaults/qa.json +9 -0
- package/src/agents/_defaults/researcher.json +9 -0
- package/src/agents/_defaults/security.json +9 -0
- package/src/index.ts +1121 -0
- package/src/monitor.ts +204 -0
- package/src/panel/message-view.ts +232 -0
- package/src/panel/squad-panel.ts +383 -0
- package/src/panel/task-list.ts +264 -0
- package/src/planner.ts +275 -0
- package/src/protocol.ts +265 -0
- package/src/router.ts +207 -0
- package/src/scheduler.ts +732 -0
- package/src/skills/collaboration/SKILL.md +39 -0
- package/src/skills/squad-protocol/SKILL.md +65 -0
- package/src/skills/verification/SKILL.md +64 -0
- package/src/store.ts +458 -0
- package/src/supervisor.ts +143 -0
- package/src/types.ts +210 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-squad — Multi-agent collaboration extension for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - squad tool (start a squad)
|
|
6
|
+
* - squad_status tool (check progress)
|
|
7
|
+
* - squad_message tool (send message to agent)
|
|
8
|
+
* - squad_modify tool (add/remove/reassign tasks)
|
|
9
|
+
* - Panel toggle keybinding
|
|
10
|
+
* - Session lifecycle hooks
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import { Type } from "@sinclair/typebox";
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import type { Squad, Task, SquadConfig, PlannerOutput } from "./types.js";
|
|
18
|
+
import { DEFAULT_SQUAD_CONFIG } from "./types.js";
|
|
19
|
+
import { Scheduler, type SchedulerEvent } from "./scheduler.js";
|
|
20
|
+
import { runPlanner } from "./planner.js";
|
|
21
|
+
import { SquadPanel } from "./panel/squad-panel.js";
|
|
22
|
+
import * as store from "./store.js";
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// State
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
let activeScheduler: Scheduler | null = null;
|
|
29
|
+
let activeSquadId: string | null = null;
|
|
30
|
+
let activePanel: SquadPanel | null = null;
|
|
31
|
+
/** Stored ExtensionContext for widget updates from background scheduler events */
|
|
32
|
+
let uiCtx: import("@mariozechner/pi-coding-agent").ExtensionContext | null = null;
|
|
33
|
+
/** Interval for periodic widget refresh */
|
|
34
|
+
let widgetInterval: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Extension Entry
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export default function (pi: ExtensionAPI) {
|
|
41
|
+
// Don't load in child agent processes (prevent recursive squad-in-squad)
|
|
42
|
+
if (process.env.PI_SQUAD_CHILD === "1") return;
|
|
43
|
+
|
|
44
|
+
// Bootstrap default agents on first load
|
|
45
|
+
const defaultsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "agents", "_defaults");
|
|
46
|
+
store.bootstrapAgents(defaultsDir);
|
|
47
|
+
|
|
48
|
+
// Collect squad skill paths
|
|
49
|
+
const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
|
|
50
|
+
const squadSkillPaths = getSquadSkillPaths(skillsDir);
|
|
51
|
+
|
|
52
|
+
// =========================================================================
|
|
53
|
+
// Context Injection — give main agent awareness of squad state
|
|
54
|
+
// =========================================================================
|
|
55
|
+
|
|
56
|
+
// Inject squad status before each LLM call so the main agent knows squad state
|
|
57
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
58
|
+
if (!activeSquadId) return;
|
|
59
|
+
const squad = store.loadSquad(activeSquadId);
|
|
60
|
+
if (!squad) return;
|
|
61
|
+
const tasks = store.loadAllTasks(activeSquadId);
|
|
62
|
+
if (tasks.length === 0) return;
|
|
63
|
+
|
|
64
|
+
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
65
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
66
|
+
|
|
67
|
+
const taskLines = tasks.map((t) => {
|
|
68
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
|
|
69
|
+
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
70
|
+
if (t.output) line += ` — ${t.output.split("\n")[0].slice(0, 80)}`;
|
|
71
|
+
if (t.error) line += ` ERROR: ${t.error.slice(0, 60)}`;
|
|
72
|
+
return line;
|
|
73
|
+
}).join("\n");
|
|
74
|
+
|
|
75
|
+
const squadContext = [
|
|
76
|
+
`<squad_status>`,
|
|
77
|
+
`Squad: ${squad.id} — ${squad.goal}`,
|
|
78
|
+
`Status: ${squad.status} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
|
|
79
|
+
taskLines,
|
|
80
|
+
`</squad_status>`,
|
|
81
|
+
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
82
|
+
].join("\n");
|
|
83
|
+
|
|
84
|
+
// Append to system prompt so the agent always sees it
|
|
85
|
+
return {
|
|
86
|
+
systemPrompt: event.systemPrompt + "\n\n" + squadContext,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// =========================================================================
|
|
91
|
+
// Tool: squad
|
|
92
|
+
// =========================================================================
|
|
93
|
+
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: "squad",
|
|
96
|
+
label: "Squad",
|
|
97
|
+
description: [
|
|
98
|
+
"Start a multi-agent squad for complex, multi-step tasks.",
|
|
99
|
+
"Use when a task needs multiple specialized skills (backend + frontend + testing),",
|
|
100
|
+
"has natural parallelism, or would overflow a single agent's context.",
|
|
101
|
+
"Don't use for simple single-file changes or tasks a single agent can handle.",
|
|
102
|
+
"Non-blocking: returns immediately with the plan while agents work in background.",
|
|
103
|
+
].join(" "),
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
goal: Type.String({ description: "What the squad should accomplish" }),
|
|
106
|
+
agents: Type.Optional(
|
|
107
|
+
Type.Record(
|
|
108
|
+
Type.String(),
|
|
109
|
+
Type.Object({
|
|
110
|
+
model: Type.Optional(Type.String()),
|
|
111
|
+
}),
|
|
112
|
+
{ description: "Agent roster with optional model overrides. Keys must match agent names in .pi/squad/agents/" },
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
tasks: Type.Optional(
|
|
116
|
+
Type.Array(
|
|
117
|
+
Type.Object({
|
|
118
|
+
id: Type.String(),
|
|
119
|
+
title: Type.String(),
|
|
120
|
+
description: Type.Optional(Type.String()),
|
|
121
|
+
agent: Type.String(),
|
|
122
|
+
depends: Type.Optional(Type.Array(Type.String())),
|
|
123
|
+
}),
|
|
124
|
+
{ description: "Pre-defined task breakdown. If provided, skips the planner agent." },
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
config: Type.Optional(
|
|
128
|
+
Type.Object({
|
|
129
|
+
maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
}),
|
|
133
|
+
|
|
134
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
135
|
+
if (!uiCtx) uiCtx = ctx;
|
|
136
|
+
|
|
137
|
+
if (activeScheduler) {
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text" as const,
|
|
142
|
+
text: `A squad is already running (${activeSquadId}). Use squad_status to check progress, or squad_modify to cancel it.`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const squadId = store.makeTaskId(params.goal);
|
|
149
|
+
if (store.squadExists(squadId)) {
|
|
150
|
+
// Append timestamp to make unique
|
|
151
|
+
const uniqueId = `${squadId}-${Date.now().toString(36)}`;
|
|
152
|
+
return await startSquad(uniqueId, params, ctx.cwd, squadSkillPaths, pi);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return await startSquad(squadId, params, ctx.cwd, squadSkillPaths, pi);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// =========================================================================
|
|
160
|
+
// Tool: squad_status
|
|
161
|
+
// =========================================================================
|
|
162
|
+
|
|
163
|
+
pi.registerTool({
|
|
164
|
+
name: "squad_status",
|
|
165
|
+
label: "Squad Status",
|
|
166
|
+
description: "Check current squad status, task progress, and recent activity.",
|
|
167
|
+
parameters: Type.Object({
|
|
168
|
+
squadId: Type.Optional(Type.String({ description: "Specific squad ID (default: most recent)" })),
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
172
|
+
let id = params.squadId || activeSquadId;
|
|
173
|
+
|
|
174
|
+
// If no active squad, find the most recent one for this project
|
|
175
|
+
if (!id) {
|
|
176
|
+
const latest = store.findLatestSquad(ctx.cwd);
|
|
177
|
+
if (latest) id = latest.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!id) {
|
|
181
|
+
return { content: [{ type: "text" as const, text: "No squads found. Use the squad tool to start one." }] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// If scheduler is running, force a context refresh
|
|
185
|
+
if (activeScheduler && activeSquadId === id) {
|
|
186
|
+
activeScheduler.updateContext();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const context = store.loadContext(id);
|
|
190
|
+
if (!context) {
|
|
191
|
+
return { content: [{ type: "text" as const, text: `Squad '${id}' not found or has no context yet.` }] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const taskLines = Object.entries(context.tasks)
|
|
195
|
+
.map(([taskId, task]) => {
|
|
196
|
+
const icon =
|
|
197
|
+
task.status === "done" ? "✓" :
|
|
198
|
+
task.status === "in_progress" ? "⏳" :
|
|
199
|
+
task.status === "blocked" ? "◻" :
|
|
200
|
+
task.status === "failed" ? "✗" :
|
|
201
|
+
"·";
|
|
202
|
+
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
203
|
+
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
204
|
+
return line;
|
|
205
|
+
})
|
|
206
|
+
.join("\n");
|
|
207
|
+
|
|
208
|
+
const summary = [
|
|
209
|
+
`Squad: ${id}`,
|
|
210
|
+
`Status: ${context.status}`,
|
|
211
|
+
`Elapsed: ${context.elapsed}`,
|
|
212
|
+
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
213
|
+
"",
|
|
214
|
+
"Tasks:",
|
|
215
|
+
taskLines,
|
|
216
|
+
].join("\n");
|
|
217
|
+
|
|
218
|
+
return { content: [{ type: "text" as const, text: summary }] };
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// =========================================================================
|
|
223
|
+
// Tool: squad_message
|
|
224
|
+
// =========================================================================
|
|
225
|
+
|
|
226
|
+
pi.registerTool({
|
|
227
|
+
name: "squad_message",
|
|
228
|
+
label: "Squad Message",
|
|
229
|
+
description: "Send a message to a specific agent or task in the running squad.",
|
|
230
|
+
parameters: Type.Object({
|
|
231
|
+
message: Type.String({ description: "Message to send" }),
|
|
232
|
+
taskId: Type.Optional(Type.String({ description: "Target task ID" })),
|
|
233
|
+
agent: Type.Optional(Type.String({ description: "Target agent name" })),
|
|
234
|
+
}),
|
|
235
|
+
|
|
236
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
237
|
+
if (!activeScheduler || !activeSquadId) {
|
|
238
|
+
return { content: [{ type: "text" as const, text: "No active squad." }] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let taskId = params.taskId;
|
|
242
|
+
|
|
243
|
+
// If agent specified but no taskId, find their current task
|
|
244
|
+
if (!taskId && params.agent) {
|
|
245
|
+
taskId = activeScheduler.getPool().getTaskIdForAgent(params.agent) || undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!taskId) {
|
|
249
|
+
return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sent = await activeScheduler.sendHumanMessage(taskId, params.message);
|
|
253
|
+
const status = sent ? "delivered" : "queued for when the agent starts";
|
|
254
|
+
|
|
255
|
+
return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }] };
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// =========================================================================
|
|
260
|
+
// Tool: squad_modify
|
|
261
|
+
// =========================================================================
|
|
262
|
+
|
|
263
|
+
pi.registerTool({
|
|
264
|
+
name: "squad_modify",
|
|
265
|
+
label: "Squad Modify",
|
|
266
|
+
description: "Modify the running squad: add_task, cancel_task, pause, resume, cancel (entire squad).",
|
|
267
|
+
parameters: Type.Object({
|
|
268
|
+
action: Type.Union(
|
|
269
|
+
[
|
|
270
|
+
Type.Literal("add_task"),
|
|
271
|
+
Type.Literal("cancel_task"),
|
|
272
|
+
Type.Literal("pause_task"),
|
|
273
|
+
Type.Literal("resume_task"),
|
|
274
|
+
Type.Literal("pause"),
|
|
275
|
+
Type.Literal("resume"),
|
|
276
|
+
Type.Literal("cancel"),
|
|
277
|
+
],
|
|
278
|
+
{ description: "Action to perform" },
|
|
279
|
+
),
|
|
280
|
+
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
281
|
+
task: Type.Optional(
|
|
282
|
+
Type.Object({
|
|
283
|
+
id: Type.String(),
|
|
284
|
+
title: Type.String(),
|
|
285
|
+
description: Type.Optional(Type.String()),
|
|
286
|
+
agent: Type.String(),
|
|
287
|
+
depends: Type.Optional(Type.Array(Type.String())),
|
|
288
|
+
}),
|
|
289
|
+
{ description: "Task definition for add_task" },
|
|
290
|
+
),
|
|
291
|
+
}),
|
|
292
|
+
|
|
293
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
294
|
+
if (!activeScheduler || !activeSquadId) {
|
|
295
|
+
return { content: [{ type: "text" as const, text: "No active squad." }] };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
switch (params.action) {
|
|
299
|
+
case "add_task": {
|
|
300
|
+
if (!params.task) {
|
|
301
|
+
return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }] };
|
|
302
|
+
}
|
|
303
|
+
const task: Task = {
|
|
304
|
+
id: params.task.id,
|
|
305
|
+
title: params.task.title,
|
|
306
|
+
description: params.task.description || "",
|
|
307
|
+
agent: params.task.agent,
|
|
308
|
+
status: "pending",
|
|
309
|
+
depends: params.task.depends || [],
|
|
310
|
+
created: store.now(),
|
|
311
|
+
started: null,
|
|
312
|
+
completed: null,
|
|
313
|
+
output: null,
|
|
314
|
+
error: null,
|
|
315
|
+
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
316
|
+
};
|
|
317
|
+
store.createTask(activeSquadId, task);
|
|
318
|
+
activeScheduler.updateContext();
|
|
319
|
+
return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case "cancel_task": {
|
|
323
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
|
|
324
|
+
await activeScheduler.cancelTask(params.taskId);
|
|
325
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }] };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case "pause_task": {
|
|
329
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
|
|
330
|
+
await activeScheduler.pauseTask(params.taskId);
|
|
331
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' paused.` }] };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case "resume_task": {
|
|
335
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
|
|
336
|
+
await activeScheduler.resumeTask(params.taskId);
|
|
337
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case "pause": {
|
|
341
|
+
const squad = store.loadSquad(activeSquadId);
|
|
342
|
+
if (squad) {
|
|
343
|
+
squad.status = "paused";
|
|
344
|
+
store.saveSquad(squad);
|
|
345
|
+
}
|
|
346
|
+
await activeScheduler.stop();
|
|
347
|
+
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }] };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case "resume": {
|
|
351
|
+
await activeScheduler.resume();
|
|
352
|
+
return { content: [{ type: "text" as const, text: "Squad resumed." }] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case "cancel": {
|
|
356
|
+
await activeScheduler.stop();
|
|
357
|
+
const squad = store.loadSquad(activeSquadId);
|
|
358
|
+
if (squad) {
|
|
359
|
+
squad.status = "failed";
|
|
360
|
+
store.saveSquad(squad);
|
|
361
|
+
}
|
|
362
|
+
activeScheduler = null;
|
|
363
|
+
activeSquadId = null;
|
|
364
|
+
return { content: [{ type: "text" as const, text: "Squad cancelled." }] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
default:
|
|
368
|
+
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// =========================================================================
|
|
374
|
+
// Session Lifecycle
|
|
375
|
+
// =========================================================================
|
|
376
|
+
|
|
377
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
378
|
+
uiCtx = ctx;
|
|
379
|
+
// Check for active squads for this project
|
|
380
|
+
const active = store.findActiveSquads()
|
|
381
|
+
.filter((s) => s.cwd === ctx.cwd);
|
|
382
|
+
if (active.length > 0) {
|
|
383
|
+
const squad = active[0];
|
|
384
|
+
pi.sendUserMessage(
|
|
385
|
+
`[squad] Found suspended squad "${squad.id}" (${squad.goal}). ` +
|
|
386
|
+
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
387
|
+
{ deliverAs: "followUp" },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Register Ctrl+Q terminal input handler for panel toggle
|
|
392
|
+
if (ctx.hasUI) {
|
|
393
|
+
ctx.ui.onTerminalInput((data) => {
|
|
394
|
+
if (data === "\x11") {
|
|
395
|
+
if (!activePanel) {
|
|
396
|
+
// Auto-pick a squad if none active
|
|
397
|
+
if (!activeSquadId) {
|
|
398
|
+
const latest = store.findLatestSquad(ctx.cwd)
|
|
399
|
+
|| store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
|
|
400
|
+
if (latest) {
|
|
401
|
+
activateSquadView(latest.id, ctx);
|
|
402
|
+
} else {
|
|
403
|
+
ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
|
|
404
|
+
return { consume: true };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (activeSquadId) {
|
|
408
|
+
const sched = activeScheduler || new Scheduler(activeSquadId, squadSkillPaths);
|
|
409
|
+
createPanel(ctx, sched, activeSquadId);
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
activePanel.toggleFocus();
|
|
413
|
+
}
|
|
414
|
+
return { consume: true };
|
|
415
|
+
}
|
|
416
|
+
return undefined;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
pi.on("session_shutdown", async () => {
|
|
422
|
+
clearWidgetRefresh();
|
|
423
|
+
if (activePanel) {
|
|
424
|
+
activePanel.dispose();
|
|
425
|
+
activePanel = null;
|
|
426
|
+
}
|
|
427
|
+
if (activeScheduler) {
|
|
428
|
+
await activeScheduler.stop();
|
|
429
|
+
activeScheduler = null;
|
|
430
|
+
activeSquadId = null;
|
|
431
|
+
}
|
|
432
|
+
uiCtx = null;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// =========================================================================
|
|
436
|
+
// Slash Commands
|
|
437
|
+
// =========================================================================
|
|
438
|
+
|
|
439
|
+
pi.registerCommand("squad", {
|
|
440
|
+
description: "Browse, select, and manage squads. Usage: /squad [list|all|select|msg|widget|panel|cancel|clear]",
|
|
441
|
+
getArgumentCompletions: (prefix) => {
|
|
442
|
+
const subs = [
|
|
443
|
+
{ value: "list", label: "list", description: "List squads for current project" },
|
|
444
|
+
{ value: "all", label: "all", description: "List all squads, select to activate" },
|
|
445
|
+
{ value: "select", label: "select", description: "Pick a squad to view (interactive)" },
|
|
446
|
+
{ value: "msg", label: "msg", description: "Send message to agent: /squad msg [agent] text" },
|
|
447
|
+
{ value: "widget", label: "widget", description: "Toggle live widget" },
|
|
448
|
+
{ value: "panel", label: "panel", description: "Toggle overlay panel" },
|
|
449
|
+
{ value: "cancel", label: "cancel", description: "Cancel running squad" },
|
|
450
|
+
{ value: "clear", label: "clear", description: "Dismiss widget and deactivate squad" },
|
|
451
|
+
];
|
|
452
|
+
return subs.filter((s) => s.value.startsWith(prefix));
|
|
453
|
+
},
|
|
454
|
+
handler: async (args, ctx) => {
|
|
455
|
+
const parts = args.trim().split(/\s+/);
|
|
456
|
+
const sub = parts[0] || "select";
|
|
457
|
+
|
|
458
|
+
switch (sub) {
|
|
459
|
+
case "list": {
|
|
460
|
+
const squads = store.listSquadsForProject(ctx.cwd);
|
|
461
|
+
if (squads.length === 0) {
|
|
462
|
+
ctx.ui.notify(`No squads for this project`, "info");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const selected = await pickSquad(ctx, squads);
|
|
466
|
+
if (selected) activateSquadView(selected.id, ctx);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
case "all": {
|
|
471
|
+
const all = store.listSquads()
|
|
472
|
+
.map((id) => store.loadSquad(id))
|
|
473
|
+
.filter((s): s is Squad => s !== null)
|
|
474
|
+
.sort((a, b) => b.created.localeCompare(a.created));
|
|
475
|
+
if (all.length === 0) {
|
|
476
|
+
ctx.ui.notify("No squads found", "info");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const selected = await pickSquad(ctx, all, true);
|
|
480
|
+
if (selected) activateSquadView(selected.id, ctx);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case "select": {
|
|
485
|
+
// Interactive selector — show project squads first, fall back to all
|
|
486
|
+
let squads = store.listSquadsForProject(ctx.cwd);
|
|
487
|
+
let showProject = false;
|
|
488
|
+
if (squads.length === 0) {
|
|
489
|
+
squads = store.listSquads()
|
|
490
|
+
.map((id) => store.loadSquad(id))
|
|
491
|
+
.filter((s): s is Squad => s !== null)
|
|
492
|
+
.sort((a, b) => b.created.localeCompare(a.created));
|
|
493
|
+
showProject = true;
|
|
494
|
+
}
|
|
495
|
+
if (squads.length === 0) {
|
|
496
|
+
ctx.ui.notify("No squads found", "info");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// If only one, activate it directly
|
|
500
|
+
if (squads.length === 1) {
|
|
501
|
+
activateSquadView(squads[0].id, ctx);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const selected = await pickSquad(ctx, squads, showProject);
|
|
505
|
+
if (selected) activateSquadView(selected.id, ctx);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
case "widget": {
|
|
510
|
+
if (!widgetEnabled) {
|
|
511
|
+
widgetEnabled = true;
|
|
512
|
+
// If no active squad, try to pick one
|
|
513
|
+
if (!activeSquadId) {
|
|
514
|
+
const latest = store.findLatestSquad(ctx.cwd);
|
|
515
|
+
if (latest) activateSquadView(latest.id, ctx);
|
|
516
|
+
}
|
|
517
|
+
updateWidget();
|
|
518
|
+
ctx.ui.notify("Squad widget enabled", "info");
|
|
519
|
+
} else {
|
|
520
|
+
widgetEnabled = false;
|
|
521
|
+
ctx.ui.setWidget("squad-tasks", undefined);
|
|
522
|
+
ctx.ui.setStatus("squad", undefined);
|
|
523
|
+
ctx.ui.notify("Squad widget disabled", "info");
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case "panel": {
|
|
529
|
+
// Activate latest squad if none active
|
|
530
|
+
if (!activeSquadId) {
|
|
531
|
+
const latest = store.findLatestSquad(ctx.cwd);
|
|
532
|
+
if (latest) {
|
|
533
|
+
activateSquadView(latest.id, ctx);
|
|
534
|
+
} else {
|
|
535
|
+
ctx.ui.notify("No squads found", "info");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!activePanel && activeSquadId) {
|
|
540
|
+
// Panel needs a scheduler — create a dummy one for view-only if none running
|
|
541
|
+
const sched = activeScheduler || new Scheduler(activeSquadId, squadSkillPaths);
|
|
542
|
+
createPanel(ctx, sched, activeSquadId);
|
|
543
|
+
} else if (activePanel) {
|
|
544
|
+
activePanel.toggleFocus();
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
case "msg": {
|
|
550
|
+
if (!activeSquadId) {
|
|
551
|
+
ctx.ui.notify("No active squad. Use /squad select first.", "info");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const msgSquad = store.loadSquad(activeSquadId);
|
|
555
|
+
if (!msgSquad || msgSquad.status !== "running") {
|
|
556
|
+
ctx.ui.notify("Squad is not running — messages only reach running agents.", "info");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Parse: /squad msg [agent] message text
|
|
560
|
+
const msgParts = parts.slice(1);
|
|
561
|
+
let targetAgent: string | undefined;
|
|
562
|
+
let msgText: string;
|
|
563
|
+
|
|
564
|
+
if (msgParts.length === 0) {
|
|
565
|
+
// Interactive: ask for message
|
|
566
|
+
const input = await ctx.ui.input("Message to squad agent", "Type your message...");
|
|
567
|
+
if (!input) return;
|
|
568
|
+
msgText = input;
|
|
569
|
+
} else {
|
|
570
|
+
// Check if first word is an agent name
|
|
571
|
+
const maybeAgent = store.loadAgentDef(msgParts[0], msgSquad.cwd);
|
|
572
|
+
if (maybeAgent && msgParts.length > 1) {
|
|
573
|
+
targetAgent = msgParts[0];
|
|
574
|
+
msgText = msgParts.slice(1).join(" ");
|
|
575
|
+
} else {
|
|
576
|
+
msgText = msgParts.join(" ");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Find target task
|
|
581
|
+
const msgTasks = store.loadAllTasks(activeSquadId);
|
|
582
|
+
let targetTaskId: string | undefined;
|
|
583
|
+
|
|
584
|
+
if (targetAgent) {
|
|
585
|
+
const agentTask = msgTasks.find((t) => t.agent === targetAgent && t.status === "in_progress");
|
|
586
|
+
targetTaskId = agentTask?.id;
|
|
587
|
+
if (!targetTaskId) {
|
|
588
|
+
ctx.ui.notify(`Agent '${targetAgent}' has no running task`, "warning");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
const runningTask = msgTasks.find((t) => t.status === "in_progress");
|
|
593
|
+
targetTaskId = runningTask?.id;
|
|
594
|
+
targetAgent = runningTask?.agent;
|
|
595
|
+
if (!targetTaskId) {
|
|
596
|
+
ctx.ui.notify("No running tasks to message", "warning");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (activeScheduler) {
|
|
602
|
+
await activeScheduler.sendHumanMessage(targetTaskId, msgText);
|
|
603
|
+
ctx.ui.notify(`Sent to ${targetAgent}: "${msgText.slice(0, 50)}"`, "info");
|
|
604
|
+
} else {
|
|
605
|
+
store.appendMessage(activeSquadId, targetTaskId, {
|
|
606
|
+
ts: store.now(),
|
|
607
|
+
from: "human",
|
|
608
|
+
type: "message",
|
|
609
|
+
text: msgText,
|
|
610
|
+
});
|
|
611
|
+
ctx.ui.notify(`Logged to ${targetTaskId} (agent not running)`, "info");
|
|
612
|
+
}
|
|
613
|
+
updateWidget();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
case "cancel": {
|
|
618
|
+
if (!activeScheduler) {
|
|
619
|
+
ctx.ui.notify("No running squad to cancel", "info");
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
await activeScheduler.stop();
|
|
623
|
+
const squad = store.loadSquad(activeSquadId!);
|
|
624
|
+
if (squad) { squad.status = "failed"; store.saveSquad(squad); }
|
|
625
|
+
activeScheduler = null;
|
|
626
|
+
clearWidgetRefresh();
|
|
627
|
+
updateWidget();
|
|
628
|
+
ctx.ui.notify("Squad cancelled", "info");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
case "clear": {
|
|
633
|
+
activeSquadId = null;
|
|
634
|
+
activeScheduler = null;
|
|
635
|
+
clearWidgetRefresh();
|
|
636
|
+
if (activePanel) { activePanel.dispose(); activePanel = null; }
|
|
637
|
+
ctx.ui.setWidget("squad-tasks", undefined);
|
|
638
|
+
ctx.ui.setStatus("squad", undefined);
|
|
639
|
+
ctx.ui.notify("Squad view cleared", "info");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
default:
|
|
644
|
+
// Treat as a squad ID — try to activate it directly
|
|
645
|
+
const direct = store.loadSquad(sub);
|
|
646
|
+
if (direct) {
|
|
647
|
+
activateSquadView(direct.id, ctx);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, widget, panel, cancel, clear`, "warning");
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ============================================================================
|
|
658
|
+
// Squad Selection & Activation
|
|
659
|
+
// ============================================================================
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Show an interactive selector to pick a squad.
|
|
663
|
+
* Returns the selected squad or undefined if cancelled.
|
|
664
|
+
*/
|
|
665
|
+
async function pickSquad(
|
|
666
|
+
ctx: import("@mariozechner/pi-coding-agent").ExtensionContext | import("@mariozechner/pi-coding-agent").ExtensionCommandContext,
|
|
667
|
+
squads: Squad[],
|
|
668
|
+
showProject = false,
|
|
669
|
+
): Promise<Squad | undefined> {
|
|
670
|
+
if (squads.length === 0) return undefined;
|
|
671
|
+
|
|
672
|
+
const options = squads.map((s) => {
|
|
673
|
+
const tasks = store.loadAllTasks(s.id);
|
|
674
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
675
|
+
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
676
|
+
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·";
|
|
677
|
+
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
678
|
+
return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}${project}`;
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const choice = await ctx.ui.select("Select a squad", options);
|
|
682
|
+
if (choice === undefined) return undefined;
|
|
683
|
+
|
|
684
|
+
const idx = options.indexOf(choice);
|
|
685
|
+
return idx >= 0 ? squads[idx] : undefined;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Activate a squad for viewing in this session.
|
|
690
|
+
* Sets activeSquadId, starts widget, shows notification.
|
|
691
|
+
* Does NOT start a scheduler (view-only unless squad needs resuming).
|
|
692
|
+
*/
|
|
693
|
+
function activateSquadView(squadId: string, ctx: import("@mariozechner/pi-coding-agent").ExtensionContext | import("@mariozechner/pi-coding-agent").ExtensionCommandContext): void {
|
|
694
|
+
const squad = store.loadSquad(squadId);
|
|
695
|
+
if (!squad) {
|
|
696
|
+
ctx.ui.notify(`Squad '${squadId}' not found`, "error");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
activeSquadId = squadId;
|
|
701
|
+
|
|
702
|
+
// Show widget for this squad
|
|
703
|
+
widgetEnabled = true;
|
|
704
|
+
updateWidget();
|
|
705
|
+
|
|
706
|
+
// Start refresh if squad is still running (live updates from disk)
|
|
707
|
+
if (squad.status === "running" || squad.status === "paused") {
|
|
708
|
+
startWidgetRefresh();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const tasks = store.loadAllTasks(squadId);
|
|
712
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
713
|
+
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
714
|
+
const project = squad.cwd.split("/").pop();
|
|
715
|
+
|
|
716
|
+
const taskLines = tasks.map((t) => {
|
|
717
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
|
|
718
|
+
return ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
719
|
+
}).join("\n");
|
|
720
|
+
|
|
721
|
+
ctx.ui.notify(
|
|
722
|
+
`Viewing: ${squad.id} [${squad.status}]\n` +
|
|
723
|
+
`Project: ${project}\n` +
|
|
724
|
+
`Tasks: ${done}/${tasks.length} done · $${cost.toFixed(2)}\n` +
|
|
725
|
+
taskLines,
|
|
726
|
+
"info",
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ============================================================================
|
|
731
|
+
// Widget — live task status above the editor
|
|
732
|
+
// ============================================================================
|
|
733
|
+
|
|
734
|
+
function updateWidget(): void {
|
|
735
|
+
if (!uiCtx?.hasUI || !widgetEnabled || !activeSquadId) return;
|
|
736
|
+
|
|
737
|
+
const tasks = store.loadAllTasks(activeSquadId);
|
|
738
|
+
const squad = store.loadSquad(activeSquadId);
|
|
739
|
+
if (!squad || tasks.length === 0) return;
|
|
740
|
+
|
|
741
|
+
const th = uiCtx.ui.theme;
|
|
742
|
+
|
|
743
|
+
// Build widget lines
|
|
744
|
+
const lines: string[] = [];
|
|
745
|
+
|
|
746
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
747
|
+
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
748
|
+
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
749
|
+
const elapsedStr = formatElapsedShort(elapsed);
|
|
750
|
+
|
|
751
|
+
// Header line with shortcut hint
|
|
752
|
+
const statusIcon = squad.status === "done" ? th.fg("success", "✓")
|
|
753
|
+
: squad.status === "failed" ? th.fg("error", "✗")
|
|
754
|
+
: th.fg("warning", "⏳");
|
|
755
|
+
const hint = th.fg("dim", "ctrl+q panel · /squad");
|
|
756
|
+
lines.push(
|
|
757
|
+
`${statusIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 30))} ` +
|
|
758
|
+
`${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
|
|
759
|
+
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
760
|
+
`${th.fg("dim", elapsedStr)} ` +
|
|
761
|
+
`${hint}`
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// Task lines
|
|
765
|
+
for (const task of tasks) {
|
|
766
|
+
const icon = task.status === "done" ? th.fg("success", "✓")
|
|
767
|
+
: task.status === "in_progress" ? th.fg("warning", "⏳")
|
|
768
|
+
: task.status === "failed" ? th.fg("error", "✗")
|
|
769
|
+
: task.status === "blocked" ? th.fg("muted", "◻")
|
|
770
|
+
: th.fg("dim", "·");
|
|
771
|
+
|
|
772
|
+
let line = ` ${icon} ${th.fg("muted", task.id)} ${th.fg("dim", `(${task.agent})`)}`;
|
|
773
|
+
|
|
774
|
+
// Show live activity for in_progress tasks
|
|
775
|
+
if (task.status === "in_progress") {
|
|
776
|
+
const messages = store.loadMessages(activeSquadId, task.id);
|
|
777
|
+
const lastTool = [...messages].reverse().find((m) => m.type === "tool");
|
|
778
|
+
const lastText = [...messages].reverse().find((m) => m.type === "text" && m.from !== "system");
|
|
779
|
+
if (lastTool) {
|
|
780
|
+
const toolPreview = lastTool.name || lastTool.text;
|
|
781
|
+
const argPreview = lastTool.args?.path || lastTool.args?.command || "";
|
|
782
|
+
const preview = argPreview ? `${toolPreview} ${argPreview}` : toolPreview;
|
|
783
|
+
line += ` ${th.fg("dim", "→ " + preview.slice(0, 40))}`;
|
|
784
|
+
} else if (lastText) {
|
|
785
|
+
line += ` ${th.fg("dim", lastText.text.split("\n")[0].slice(0, 40))}`;
|
|
786
|
+
}
|
|
787
|
+
} else if (task.status === "done" && task.output) {
|
|
788
|
+
line += ` ${th.fg("dim", task.output.split("\n")[0].slice(0, 40))}`;
|
|
789
|
+
} else if (task.status === "failed" && task.error) {
|
|
790
|
+
line += ` ${th.fg("error", task.error.slice(0, 40))}`;
|
|
791
|
+
} else if (task.status === "blocked") {
|
|
792
|
+
const blockers = task.depends.filter((d) => {
|
|
793
|
+
const dep = tasks.find((t) => t.id === d);
|
|
794
|
+
return dep && dep.status !== "done";
|
|
795
|
+
});
|
|
796
|
+
if (blockers.length > 0) {
|
|
797
|
+
line += ` ${th.fg("dim", "← " + blockers.join(", "))}`;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
lines.push(line);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
uiCtx.ui.setWidget("squad-tasks", lines);
|
|
805
|
+
|
|
806
|
+
// Footer status
|
|
807
|
+
const statusText = squad.status === "done"
|
|
808
|
+
? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
|
|
809
|
+
: squad.status === "failed"
|
|
810
|
+
? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
|
|
811
|
+
: th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
|
|
812
|
+
uiCtx.ui.setStatus("squad", statusText);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function clearWidget(): void {
|
|
816
|
+
if (uiCtx?.hasUI) {
|
|
817
|
+
uiCtx.ui.setWidget("squad-tasks", undefined);
|
|
818
|
+
uiCtx.ui.setStatus("squad", undefined);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function startWidgetRefresh(): void {
|
|
823
|
+
if (widgetInterval) return;
|
|
824
|
+
updateWidget();
|
|
825
|
+
widgetInterval = setInterval(() => updateWidget(), 2000);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function clearWidgetRefresh(): void {
|
|
829
|
+
if (widgetInterval) {
|
|
830
|
+
clearInterval(widgetInterval);
|
|
831
|
+
widgetInterval = null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function formatElapsedShort(ms: number): string {
|
|
836
|
+
const s = Math.floor(ms / 1000);
|
|
837
|
+
const m = Math.floor(s / 60);
|
|
838
|
+
const h = Math.floor(m / 60);
|
|
839
|
+
if (h > 0) return `${h}h${m % 60}m`;
|
|
840
|
+
if (m > 0) return `${m}m${s % 60}s`;
|
|
841
|
+
return `${s}s`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Shared widget-enabled flag — declared in the extension body, referenced here */
|
|
845
|
+
let widgetEnabled = true;
|
|
846
|
+
|
|
847
|
+
// ============================================================================
|
|
848
|
+
// Panel Creation
|
|
849
|
+
// ============================================================================
|
|
850
|
+
|
|
851
|
+
function createPanel(
|
|
852
|
+
ctx: import("@mariozechner/pi-coding-agent").ExtensionContext,
|
|
853
|
+
scheduler: Scheduler,
|
|
854
|
+
squadId: string,
|
|
855
|
+
): void {
|
|
856
|
+
// Fire and forget — never awaited. The custom() Promise resolves when done() is called,
|
|
857
|
+
// but we never call done() because the panel is persistent.
|
|
858
|
+
ctx.ui.custom(
|
|
859
|
+
(tui, theme, _kb, _done) => {
|
|
860
|
+
const panel = new SquadPanel(tui, theme, scheduler, squadId);
|
|
861
|
+
activePanel = panel;
|
|
862
|
+
|
|
863
|
+
// Wire up message sending from panel
|
|
864
|
+
panel.onSendMessage = async (taskId: string, _prefill: string) => {
|
|
865
|
+
// Temporarily release panel focus so input dialog works
|
|
866
|
+
(panel as any).handle?.unfocus();
|
|
867
|
+
const task = store.loadTask(squadId, taskId);
|
|
868
|
+
const agentName = task?.agent || taskId;
|
|
869
|
+
const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
|
|
870
|
+
if (input && activeScheduler) {
|
|
871
|
+
await activeScheduler.sendHumanMessage(taskId, input);
|
|
872
|
+
ctx.ui.notify(`Sent to ${agentName}: "${input.slice(0, 50)}"`, "info");
|
|
873
|
+
} else if (input) {
|
|
874
|
+
store.appendMessage(squadId, taskId, {
|
|
875
|
+
ts: store.now(),
|
|
876
|
+
from: "human",
|
|
877
|
+
type: "message",
|
|
878
|
+
text: input,
|
|
879
|
+
});
|
|
880
|
+
ctx.ui.notify(`Logged to ${taskId}`, "info");
|
|
881
|
+
}
|
|
882
|
+
// Re-focus panel after input
|
|
883
|
+
(panel as any).handle?.focus();
|
|
884
|
+
tui.requestRender();
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
return panel;
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
overlay: true,
|
|
891
|
+
overlayOptions: () => {
|
|
892
|
+
const wide = (process.stdout.columns || 80) >= 160;
|
|
893
|
+
if (wide) {
|
|
894
|
+
return {
|
|
895
|
+
anchor: "top-right" as const,
|
|
896
|
+
width: "35%" as const,
|
|
897
|
+
maxHeight: "100%" as const,
|
|
898
|
+
margin: { top: 0, right: 0, bottom: 1, left: 0 },
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
anchor: "center" as const,
|
|
903
|
+
width: "90%" as const,
|
|
904
|
+
maxHeight: "85%" as const,
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
onHandle: (handle) => {
|
|
908
|
+
if (activePanel) {
|
|
909
|
+
(activePanel as any).handle = handle;
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ============================================================================
|
|
917
|
+
// Start Squad
|
|
918
|
+
// ============================================================================
|
|
919
|
+
|
|
920
|
+
async function startSquad(
|
|
921
|
+
squadId: string,
|
|
922
|
+
params: {
|
|
923
|
+
goal: string;
|
|
924
|
+
agents?: Record<string, { model?: string }>;
|
|
925
|
+
tasks?: Array<{
|
|
926
|
+
id: string;
|
|
927
|
+
title: string;
|
|
928
|
+
description?: string;
|
|
929
|
+
agent: string;
|
|
930
|
+
depends?: string[];
|
|
931
|
+
}>;
|
|
932
|
+
config?: { maxConcurrency?: number };
|
|
933
|
+
},
|
|
934
|
+
cwd: string,
|
|
935
|
+
skillPaths: string[],
|
|
936
|
+
pi: ExtensionAPI,
|
|
937
|
+
) {
|
|
938
|
+
let plan: PlannerOutput;
|
|
939
|
+
|
|
940
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
941
|
+
// User provided a plan — use it directly
|
|
942
|
+
plan = {
|
|
943
|
+
agents: params.agents || {},
|
|
944
|
+
tasks: params.tasks.map((t) => ({
|
|
945
|
+
...t,
|
|
946
|
+
description: t.description || "",
|
|
947
|
+
depends: t.depends || [],
|
|
948
|
+
})),
|
|
949
|
+
};
|
|
950
|
+
} else {
|
|
951
|
+
// Run planner to generate task breakdown
|
|
952
|
+
try {
|
|
953
|
+
plan = await runPlanner({ goal: params.goal, cwd });
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return {
|
|
956
|
+
content: [
|
|
957
|
+
{ type: "text" as const, text: `Failed to plan: ${(error as Error).message}` },
|
|
958
|
+
],
|
|
959
|
+
isError: true,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Merge agent roster
|
|
965
|
+
const agents: Record<string, { model?: string }> = { ...plan.agents };
|
|
966
|
+
if (params.agents) {
|
|
967
|
+
for (const [name, entry] of Object.entries(params.agents)) {
|
|
968
|
+
agents[name] = { ...agents[name], ...entry };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Create squad
|
|
973
|
+
const config: SquadConfig = {
|
|
974
|
+
...DEFAULT_SQUAD_CONFIG,
|
|
975
|
+
...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const squad: Squad = {
|
|
979
|
+
id: squadId,
|
|
980
|
+
goal: params.goal,
|
|
981
|
+
status: "running",
|
|
982
|
+
created: store.now(),
|
|
983
|
+
cwd,
|
|
984
|
+
agents,
|
|
985
|
+
config,
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
store.saveSquad(squad);
|
|
989
|
+
|
|
990
|
+
// Create task files
|
|
991
|
+
for (const taskDef of plan.tasks) {
|
|
992
|
+
const task: Task = {
|
|
993
|
+
id: taskDef.id,
|
|
994
|
+
title: taskDef.title,
|
|
995
|
+
description: taskDef.description,
|
|
996
|
+
agent: taskDef.agent,
|
|
997
|
+
status: taskDef.depends.length === 0 ? "pending" : "blocked",
|
|
998
|
+
depends: taskDef.depends,
|
|
999
|
+
created: store.now(),
|
|
1000
|
+
started: null,
|
|
1001
|
+
completed: null,
|
|
1002
|
+
output: null,
|
|
1003
|
+
error: null,
|
|
1004
|
+
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// Mark tasks with unmet deps as blocked
|
|
1008
|
+
if (task.depends.length > 0) {
|
|
1009
|
+
const allDepsMet = task.depends.every((depId) =>
|
|
1010
|
+
plan.tasks.some((t) => t.id === depId),
|
|
1011
|
+
);
|
|
1012
|
+
if (!allDepsMet) {
|
|
1013
|
+
task.status = "pending"; // deps reference external tasks, treat as ready
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
store.createTask(squadId, task);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Start scheduler
|
|
1021
|
+
const scheduler = new Scheduler(squadId, skillPaths);
|
|
1022
|
+
activeScheduler = scheduler;
|
|
1023
|
+
activeSquadId = squadId;
|
|
1024
|
+
|
|
1025
|
+
// Start live widget updates
|
|
1026
|
+
startWidgetRefresh();
|
|
1027
|
+
|
|
1028
|
+
// Wire up completion/escalation notifications to main agent
|
|
1029
|
+
scheduler.onEvent((event: SchedulerEvent) => {
|
|
1030
|
+
// Update widget on every scheduler event
|
|
1031
|
+
updateWidget();
|
|
1032
|
+
switch (event.type) {
|
|
1033
|
+
case "squad_completed": {
|
|
1034
|
+
const tasks = store.loadAllTasks(squadId);
|
|
1035
|
+
const summary = tasks
|
|
1036
|
+
.filter((t) => t.status === "done")
|
|
1037
|
+
.map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
|
|
1038
|
+
.join("\n");
|
|
1039
|
+
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1040
|
+
|
|
1041
|
+
// Final context update before clearing scheduler
|
|
1042
|
+
if (activeScheduler) {
|
|
1043
|
+
activeScheduler.updateContext();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
pi.sendUserMessage(
|
|
1047
|
+
`[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
|
|
1048
|
+
`Summary:\n${summary}\n\n` +
|
|
1049
|
+
`Total cost: $${totalCost.toFixed(4)}`,
|
|
1050
|
+
{ deliverAs: "followUp" },
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
// Clear scheduler but keep activeSquadId so squad_status still works
|
|
1054
|
+
activeScheduler = null;
|
|
1055
|
+
clearWidgetRefresh();
|
|
1056
|
+
updateWidget(); // Final update showing done state
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
case "squad_failed": {
|
|
1061
|
+
const tasks = store.loadAllTasks(squadId);
|
|
1062
|
+
const failed = tasks.filter((t) => t.status === "failed");
|
|
1063
|
+
const done = tasks.filter((t) => t.status === "done");
|
|
1064
|
+
|
|
1065
|
+
pi.sendUserMessage(
|
|
1066
|
+
`[squad] Squad "${squadId}" has stalled. ` +
|
|
1067
|
+
`${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
|
|
1068
|
+
`Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
|
|
1069
|
+
`Use squad_status for details or squad_modify to adjust.`,
|
|
1070
|
+
{ deliverAs: "followUp" },
|
|
1071
|
+
);
|
|
1072
|
+
clearWidgetRefresh();
|
|
1073
|
+
updateWidget();
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
case "escalation": {
|
|
1078
|
+
pi.sendUserMessage(
|
|
1079
|
+
`[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
|
|
1080
|
+
`${event.message}\n\n` +
|
|
1081
|
+
`Reply to me and I'll forward your answer, or use the squad panel.`,
|
|
1082
|
+
{ deliverAs: "followUp" },
|
|
1083
|
+
);
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// Start scheduling
|
|
1090
|
+
await scheduler.start();
|
|
1091
|
+
|
|
1092
|
+
// Build response
|
|
1093
|
+
const taskSummary = plan.tasks
|
|
1094
|
+
.map((t) => {
|
|
1095
|
+
const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
|
|
1096
|
+
return `${t.id} → ${t.agent}: ${t.title}${deps}`;
|
|
1097
|
+
})
|
|
1098
|
+
.join("\n");
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
content: [
|
|
1102
|
+
{
|
|
1103
|
+
type: "text" as const,
|
|
1104
|
+
text: `Squad "${squadId}" started with ${plan.tasks.length} tasks.\n\n${taskSummary}\n\nAgents are working in the background. Use squad_status to check progress.`,
|
|
1105
|
+
},
|
|
1106
|
+
],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ============================================================================
|
|
1111
|
+
// Helpers
|
|
1112
|
+
// ============================================================================
|
|
1113
|
+
|
|
1114
|
+
function getSquadSkillPaths(skillsDir: string): string[] {
|
|
1115
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
1116
|
+
return fs
|
|
1117
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
1118
|
+
.filter((d) => d.isDirectory())
|
|
1119
|
+
.map((d) => path.join(skillsDir, d.name))
|
|
1120
|
+
.filter((dir) => fs.existsSync(path.join(dir, "SKILL.md")));
|
|
1121
|
+
}
|