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/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
+ }