pi-squad 0.1.0 → 0.4.14

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 CHANGED
@@ -18,20 +18,34 @@ import type { Squad, Task, SquadConfig, PlannerOutput } from "./types.js";
18
18
  import { DEFAULT_SQUAD_CONFIG } from "./types.js";
19
19
  import { Scheduler, type SchedulerEvent } from "./scheduler.js";
20
20
  import { runPlanner } from "./planner.js";
21
- import { SquadPanel } from "./panel/squad-panel.js";
21
+ import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
22
+ import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
22
23
  import * as store from "./store.js";
23
24
 
24
25
  // ============================================================================
25
26
  // State
26
27
  // ============================================================================
27
28
 
28
- let activeScheduler: Scheduler | null = null;
29
+ /** Master switch when false, all squad tools, hooks, and widget are disabled */
30
+ let squadEnabled = true;
31
+ /** Registry of all running schedulers — supports multiple concurrent squads */
32
+ const schedulers = new Map<string, Scheduler>();
33
+ /** The currently viewed/focused squad (for widget, panel, status) */
29
34
  let activeSquadId: string | null = null;
30
- let activePanel: SquadPanel | null = null;
35
+ /** Whether an overlay panel is currently open (prevents double-open) */
36
+ let overlayOpen = false;
31
37
  /** Stored ExtensionContext for widget updates from background scheduler events */
32
38
  let uiCtx: import("@mariozechner/pi-coding-agent").ExtensionContext | null = null;
33
- /** Interval for periodic widget refresh */
34
- let widgetInterval: ReturnType<typeof setInterval> | null = null;
39
+ /** Component-based widget state + controls */
40
+ const widgetState: SquadWidgetState = { squadId: null, enabled: true };
41
+ let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
42
+
43
+ /** Get the active scheduler (for the focused squad) */
44
+ function getActiveScheduler(): Scheduler | null {
45
+ if (!activeSquadId) return null;
46
+ return schedulers.get(activeSquadId) || null;
47
+ }
48
+
35
49
 
36
50
  // ============================================================================
37
51
  // Extension Entry
@@ -53,37 +67,55 @@ export default function (pi: ExtensionAPI) {
53
67
  // Context Injection — give main agent awareness of squad state
54
68
  // =========================================================================
55
69
 
56
- // Inject squad status before each LLM call so the main agent knows squad state
70
+ // Inject squad awareness before each LLM call
57
71
  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.`,
72
+ if (!squadEnabled) return;
73
+
74
+ // When a squad is active, inject its status
75
+ if (activeSquadId) {
76
+ const squad = store.loadSquad(activeSquadId);
77
+ if (!squad) return;
78
+ const tasks = store.loadAllTasks(activeSquadId);
79
+ if (tasks.length === 0) return;
80
+
81
+ const doneCount = tasks.filter((t) => t.status === "done").length;
82
+ const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
83
+
84
+ const taskLines = tasks.map((t) => {
85
+ const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
86
+ let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
87
+ if (t.output) line += ` — ${t.output.split("\n")[0].slice(0, 80)}`;
88
+ if (t.error) line += ` ERROR: ${t.error.slice(0, 60)}`;
89
+ return line;
90
+ }).join("\n");
91
+
92
+ const squadContext = [
93
+ `<squad_status>`,
94
+ `Squad: ${squad.id} — ${squad.goal}`,
95
+ `Status: ${squad.status} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
96
+ taskLines,
97
+ `</squad_status>`,
98
+ `You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
99
+ ].join("\n");
100
+
101
+ return {
102
+ systemPrompt: event.systemPrompt + "\n\n" + squadContext,
103
+ };
104
+ }
105
+
106
+ // When NO squad is active, nudge the agent to consider using squad for complex tasks
107
+ const squadNudge = [
108
+ `<squad_hint>`,
109
+ `You have the "squad" tool available for multi-agent collaboration.`,
110
+ `Use it when the user's request involves multiple concerns (e.g. backend + frontend + tests + docs),`,
111
+ `would benefit from parallel execution, or is too large for a single agent context.`,
112
+ `The squad tool decomposes work into tasks, assigns specialist agents, and runs them in parallel.`,
113
+ `When in doubt about whether a task is complex enough, prefer using squad — it handles the coordination for you.`,
114
+ `</squad_hint>`,
82
115
  ].join("\n");
83
116
 
84
- // Append to system prompt so the agent always sees it
85
117
  return {
86
- systemPrompt: event.systemPrompt + "\n\n" + squadContext,
118
+ systemPrompt: event.systemPrompt + "\n\n" + squadNudge,
87
119
  };
88
120
  });
89
121
 
@@ -96,9 +128,12 @@ export default function (pi: ExtensionAPI) {
96
128
  label: "Squad",
97
129
  description: [
98
130
  "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.",
131
+ "ALWAYS use squad when a task involves 2+ of: backend, frontend, testing, docs, devops, security.",
132
+ "Use when a task has natural parallelism, touches multiple files/systems, or would overflow a single agent's context.",
133
+ "Examples that NEED squad: 'build a REST API with auth and tests', 'add a feature with frontend + backend + docs',",
134
+ "'refactor the auth system and update tests', 'set up CI/CD with Docker and deployment'.",
135
+ "Do NOT use for simple single-file changes, quick bug fixes, or tasks a single agent can handle in a few minutes.",
136
+ "When in doubt about complexity, use squad — it's better to parallelize than to do everything sequentially.",
102
137
  "Non-blocking: returns immediately with the plan while agents work in background.",
103
138
  ].join(" "),
104
139
  parameters: Type.Object({
@@ -132,18 +167,10 @@ export default function (pi: ExtensionAPI) {
132
167
  }),
133
168
 
134
169
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
170
+ if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }] };
135
171
  if (!uiCtx) uiCtx = ctx;
136
172
 
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
- }
173
+ // Multiple squads can run concurrently — no guard needed
147
174
 
148
175
  const squadId = store.makeTaskId(params.goal);
149
176
  if (store.squadExists(squadId)) {
@@ -182,9 +209,8 @@ export default function (pi: ExtensionAPI) {
182
209
  }
183
210
 
184
211
  // If scheduler is running, force a context refresh
185
- if (activeScheduler && activeSquadId === id) {
186
- activeScheduler.updateContext();
187
- }
212
+ const sched = schedulers.get(id!);
213
+ if (sched) sched.updateContext();
188
214
 
189
215
  const context = store.loadContext(id);
190
216
  if (!context) {
@@ -234,6 +260,7 @@ export default function (pi: ExtensionAPI) {
234
260
  }),
235
261
 
236
262
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
263
+ const activeScheduler = getActiveScheduler();
237
264
  if (!activeScheduler || !activeSquadId) {
238
265
  return { content: [{ type: "text" as const, text: "No active squad." }] };
239
266
  }
@@ -249,7 +276,7 @@ export default function (pi: ExtensionAPI) {
249
276
  return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }] };
250
277
  }
251
278
 
252
- const sent = await activeScheduler.sendHumanMessage(taskId, params.message);
279
+ const sent = await activeScheduler!.sendHumanMessage(taskId, params.message);
253
280
  const status = sent ? "delivered" : "queued for when the agent starts";
254
281
 
255
282
  return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }] };
@@ -290,9 +317,86 @@ export default function (pi: ExtensionAPI) {
290
317
  ),
291
318
  }),
292
319
 
293
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
320
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
321
+ // Resume can work without an active scheduler — it recreates one from disk
322
+ if (params.action === "resume") {
323
+ // Find a squad to resume: use activeSquadId or find the latest paused one
324
+ const squadId = activeSquadId || store.findActiveSquads()
325
+ .filter((s) => s.cwd === ctx.cwd && s.status === "paused")
326
+ .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
327
+
328
+ if (!squadId) {
329
+ return { content: [{ type: "text" as const, text: "No paused squad found to resume." }] };
330
+ }
331
+
332
+ // Create a fresh scheduler if needed
333
+ if (!schedulers.has(squadId)) {
334
+ const scheduler = new Scheduler(squadId, squadSkillPaths);
335
+ schedulers.set(squadId, scheduler);
336
+ activeSquadId = squadId;
337
+
338
+ // Activate widget
339
+ widgetState.squadId = squadId;
340
+ widgetState.enabled = true;
341
+ widgetControls?.requestUpdate();
342
+
343
+ // Wire up events (same as startSquad)
344
+ scheduler.onEvent((event: SchedulerEvent) => {
345
+ forceWidgetUpdate();
346
+ switch (event.type) {
347
+ case "squad_completed": {
348
+ const tasks = store.loadAllTasks(squadId);
349
+ const summary = tasks
350
+ .filter((t) => t.status === "done")
351
+ .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
352
+ .join("\n");
353
+ const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
354
+ const s = schedulers.get(squadId); if (s) s.updateContext();
355
+ pi.sendMessage({
356
+ customType: "squad-completed",
357
+ content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}`,
358
+ display: true,
359
+ });
360
+ schedulers.delete(squadId);
361
+ forceWidgetUpdate();
362
+ break;
363
+ }
364
+ case "squad_failed": {
365
+ const tasks = store.loadAllTasks(squadId);
366
+ const failed = tasks.filter((t) => t.status === "failed");
367
+ const done = tasks.filter((t) => t.status === "done");
368
+ pi.sendMessage({
369
+ customType: "squad-failed",
370
+ content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}`,
371
+ display: true,
372
+ }, { triggerTurn: true });
373
+ forceWidgetUpdate();
374
+ break;
375
+ }
376
+ case "escalation": {
377
+ pi.sendMessage({
378
+ customType: "squad-escalation",
379
+ content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n${event.message}`,
380
+ display: true,
381
+ }, { triggerTurn: true });
382
+ break;
383
+ }
384
+ }
385
+ });
386
+ }
387
+
388
+ const resumeSched = schedulers.get(squadId)!;
389
+ resumeSched.resume().catch((err) => {
390
+ console.error(`[squad] Resume error: ${(err as Error).message}`);
391
+ });
392
+
393
+ const tasks = store.loadAllTasks(squadId);
394
+ const done = tasks.filter(t => t.status === "done").length;
395
+ return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }] };
396
+ }
397
+
294
398
  if (!activeScheduler || !activeSquadId) {
295
- return { content: [{ type: "text" as const, text: "No active squad." }] };
399
+ return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }] };
296
400
  }
297
401
 
298
402
  switch (params.action) {
@@ -333,7 +437,9 @@ export default function (pi: ExtensionAPI) {
333
437
 
334
438
  case "resume_task": {
335
439
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
336
- await activeScheduler.resumeTask(params.taskId);
440
+ activeScheduler.resumeTask(params.taskId).catch((err) => {
441
+ console.error(`[squad] Resume task error: ${(err as Error).message}`);
442
+ });
337
443
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }] };
338
444
  }
339
445
 
@@ -348,7 +454,7 @@ export default function (pi: ExtensionAPI) {
348
454
  }
349
455
 
350
456
  case "resume": {
351
- await activeScheduler.resume();
457
+ // Handled above (before the activeScheduler guard)
352
458
  return { content: [{ type: "text" as const, text: "Squad resumed." }] };
353
459
  }
354
460
 
@@ -359,7 +465,7 @@ export default function (pi: ExtensionAPI) {
359
465
  squad.status = "failed";
360
466
  store.saveSquad(squad);
361
467
  }
362
- activeScheduler = null;
468
+ schedulers.delete(activeSquadId);
363
469
  activeSquadId = null;
364
470
  return { content: [{ type: "text" as const, text: "Squad cancelled." }] };
365
471
  }
@@ -376,40 +482,72 @@ export default function (pi: ExtensionAPI) {
376
482
 
377
483
  pi.on("session_start", async (_event, ctx) => {
378
484
  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
- );
485
+
486
+ // Install component-based widget
487
+ if (ctx.hasUI) {
488
+ widgetControls = setupSquadWidget(ctx, widgetState);
489
+ }
490
+
491
+ // Clean up orphaned squads from crashed sessions:
492
+ // If a squad is "running" but has no live scheduler, its parent died.
493
+ // Suspend in-progress tasks and mark the squad as paused so it doesn't
494
+ // block new squads or trigger confusing followUp messages.
495
+ const orphaned = store.findActiveSquads()
496
+ .filter((s) => s.cwd === ctx.cwd && s.status === "running");
497
+ for (const squad of orphaned) {
498
+ const tasks = store.loadAllTasks(squad.id);
499
+ let hadInProgress = false;
500
+ for (const task of tasks) {
501
+ if (task.status === "in_progress") {
502
+ store.updateTaskStatus(squad.id, task.id, "suspended");
503
+ hadInProgress = true;
504
+ }
505
+ }
506
+ if (hadInProgress) {
507
+ squad.status = "paused";
508
+ store.saveSquad(squad);
509
+ }
510
+ }
511
+
512
+ // Notify about paused squads only if they have real completed work
513
+ const paused = store.findActiveSquads()
514
+ .filter((s) => s.cwd === ctx.cwd && s.status === "paused");
515
+ if (paused.length > 0) {
516
+ const squad = paused[0];
517
+ const tasks = store.loadAllTasks(squad.id);
518
+ const done = tasks.filter(t => t.status === "done").length;
519
+ // Only notify if at least 1 task completed — worth resuming
520
+ if (done > 0) {
521
+ pi.sendMessage({
522
+ customType: "squad-paused",
523
+ content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${done}/${tasks.length} done. ` +
524
+ `Use squad_modify with action "resume" to continue, or start a new squad.`,
525
+ display: true,
526
+ });
527
+ }
389
528
  }
390
529
 
391
530
  // Register Ctrl+Q terminal input handler for panel toggle
392
531
  if (ctx.hasUI) {
393
532
  ctx.ui.onTerminalInput((data) => {
394
533
  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);
534
+ // If overlay is already open, let the panel's own handler deal with it
535
+ if (overlayOpen) return undefined;
536
+
537
+ // Auto-pick a squad if none active
538
+ if (!activeSquadId) {
539
+ const latest = store.findLatestSquad(ctx.cwd)
540
+ || store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
541
+ if (latest) {
542
+ activateSquadView(latest.id, ctx);
543
+ } else {
544
+ ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
545
+ return { consume: true };
410
546
  }
411
- } else {
412
- activePanel.toggleFocus();
547
+ }
548
+
549
+ if (activeSquadId) {
550
+ openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths), activeSquadId);
413
551
  }
414
552
  return { consume: true };
415
553
  }
@@ -419,16 +557,13 @@ export default function (pi: ExtensionAPI) {
419
557
  });
420
558
 
421
559
  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;
560
+ widgetControls?.dispose();
561
+ widgetControls = null;
562
+ for (const [id, sched] of schedulers) {
563
+ await sched.stop();
431
564
  }
565
+ schedulers.clear();
566
+ activeSquadId = null;
432
567
  uiCtx = null;
433
568
  });
434
569
 
@@ -437,17 +572,21 @@ export default function (pi: ExtensionAPI) {
437
572
  // =========================================================================
438
573
 
439
574
  pi.registerCommand("squad", {
440
- description: "Browse, select, and manage squads. Usage: /squad [list|all|select|msg|widget|panel|cancel|clear]",
575
+ description: "Browse, select, and manage squads. Usage: /squad [list|all|select|agents|msg|widget|panel|cancel|clear]",
441
576
  getArgumentCompletions: (prefix) => {
442
577
  const subs = [
443
578
  { value: "list", label: "list", description: "List squads for current project" },
444
579
  { value: "all", label: "all", description: "List all squads, select to activate" },
445
580
  { value: "select", label: "select", description: "Pick a squad to view (interactive)" },
581
+ { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
446
582
  { value: "msg", label: "msg", description: "Send message to agent: /squad msg [agent] text" },
447
583
  { value: "widget", label: "widget", description: "Toggle live widget" },
448
584
  { value: "panel", label: "panel", description: "Toggle overlay panel" },
449
585
  { value: "cancel", label: "cancel", description: "Cancel running squad" },
450
586
  { value: "clear", label: "clear", description: "Dismiss widget and deactivate squad" },
587
+ { value: "cleanup", label: "cleanup", description: "Delete squad data (select or all)" },
588
+ { value: "enable", label: "enable", description: "Enable pi-squad (tools, widget, system prompt)" },
589
+ { value: "disable", label: "disable", description: "Disable pi-squad completely" },
451
590
  ];
452
591
  return subs.filter((s) => s.value.startsWith(prefix));
453
592
  },
@@ -507,21 +646,16 @@ export default function (pi: ExtensionAPI) {
507
646
  }
508
647
 
509
648
  case "widget": {
510
- if (!widgetEnabled) {
511
- widgetEnabled = true;
512
- // If no active squad, try to pick one
649
+ widgetState.enabled = !widgetState.enabled;
650
+ if (widgetState.enabled) {
513
651
  if (!activeSquadId) {
514
652
  const latest = store.findLatestSquad(ctx.cwd);
515
653
  if (latest) activateSquadView(latest.id, ctx);
516
654
  }
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
655
  }
656
+ // requestUpdate handles both enable (renders) and disable (clears)
657
+ widgetControls?.requestUpdate();
658
+ ctx.ui.notify(`Squad widget ${widgetState.enabled ? "enabled" : "disabled"}`, "info");
525
659
  return;
526
660
  }
527
661
 
@@ -536,12 +670,9 @@ export default function (pi: ExtensionAPI) {
536
670
  return;
537
671
  }
538
672
  }
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();
673
+ if (activeSquadId) {
674
+ const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths);
675
+ openPanel(ctx, sched, activeSquadId);
545
676
  }
546
677
  return;
547
678
  }
@@ -598,8 +729,9 @@ export default function (pi: ExtensionAPI) {
598
729
  }
599
730
  }
600
731
 
601
- if (activeScheduler) {
602
- await activeScheduler.sendHumanMessage(targetTaskId, msgText);
732
+ const msgSched = getActiveScheduler();
733
+ if (msgSched) {
734
+ await msgSched.sendHumanMessage(targetTaskId, msgText);
603
735
  ctx.ui.notify(`Sent to ${targetAgent}: "${msgText.slice(0, 50)}"`, "info");
604
736
  } else {
605
737
  store.appendMessage(activeSquadId, targetTaskId, {
@@ -610,36 +742,254 @@ export default function (pi: ExtensionAPI) {
610
742
  });
611
743
  ctx.ui.notify(`Logged to ${targetTaskId} (agent not running)`, "info");
612
744
  }
613
- updateWidget();
745
+ forceWidgetUpdate();
614
746
  return;
615
747
  }
616
748
 
617
749
  case "cancel": {
618
- if (!activeScheduler) {
750
+ const cancelSched = getActiveScheduler();
751
+ if (!cancelSched) {
619
752
  ctx.ui.notify("No running squad to cancel", "info");
620
753
  return;
621
754
  }
622
- await activeScheduler.stop();
755
+ await cancelSched.stop();
623
756
  const squad = store.loadSquad(activeSquadId!);
624
757
  if (squad) { squad.status = "failed"; store.saveSquad(squad); }
625
- activeScheduler = null;
626
- clearWidgetRefresh();
627
- updateWidget();
758
+ if (activeSquadId) schedulers.delete(activeSquadId);
759
+ forceWidgetUpdate();
628
760
  ctx.ui.notify("Squad cancelled", "info");
629
761
  return;
630
762
  }
631
763
 
632
764
  case "clear": {
765
+ if (activeSquadId) schedulers.delete(activeSquadId);
633
766
  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);
767
+ widgetState.squadId = null;
768
+ widgetControls?.dispose();
639
769
  ctx.ui.notify("Squad view cleared", "info");
640
770
  return;
641
771
  }
642
772
 
773
+ case "cleanup": {
774
+ const cleanupArg = parts[1];
775
+ const allSquadIds = store.listSquads();
776
+
777
+ if (allSquadIds.length === 0) {
778
+ ctx.ui.notify("No squads to clean up", "info");
779
+ return;
780
+ }
781
+
782
+ if (cleanupArg === "all") {
783
+ // Stop any running schedulers first
784
+ for (const [id, sched] of schedulers) {
785
+ await sched.stop();
786
+ }
787
+ schedulers.clear();
788
+ activeSquadId = null;
789
+ widgetState.squadId = null;
790
+ widgetControls?.requestUpdate();
791
+
792
+ let count = 0;
793
+ for (const id of allSquadIds) {
794
+ fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
795
+ count++;
796
+ }
797
+ ctx.ui.notify(`Deleted ${count} squad(s)`, "info");
798
+ return;
799
+ }
800
+
801
+ // Interactive: pick squads to delete
802
+ const squads = allSquadIds
803
+ .map((id) => store.loadSquad(id))
804
+ .filter((s): s is Squad => s !== null)
805
+ .sort((a, b) => b.created.localeCompare(a.created));
806
+
807
+ const options = [
808
+ "🗑 Delete ALL squads",
809
+ ...squads.map((s) => {
810
+ const tasks = store.loadAllTasks(s.id);
811
+ const done = tasks.filter((t) => t.status === "done").length;
812
+ const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
813
+ const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·";
814
+ return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}`;
815
+ }),
816
+ ];
817
+
818
+ const choice = await ctx.ui.select("Delete squad data", options);
819
+ if (!choice) return;
820
+
821
+ if (choice.startsWith("🗑")) {
822
+ // Delete all
823
+ for (const [id, sched] of schedulers) {
824
+ await sched.stop();
825
+ }
826
+ schedulers.clear();
827
+ activeSquadId = null;
828
+ widgetState.squadId = null;
829
+ widgetControls?.requestUpdate();
830
+ let count = 0;
831
+ for (const id of allSquadIds) {
832
+ fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
833
+ count++;
834
+ }
835
+ ctx.ui.notify(`Deleted ${count} squad(s)`, "info");
836
+ } else {
837
+ // Delete selected
838
+ const idx = options.indexOf(choice) - 1; // -1 for the "Delete ALL" option
839
+ if (idx >= 0 && idx < squads.length) {
840
+ const squad = squads[idx];
841
+ // Stop scheduler if running
842
+ const sched = schedulers.get(squad.id);
843
+ if (sched) {
844
+ await sched.stop();
845
+ schedulers.delete(squad.id);
846
+ }
847
+ if (activeSquadId === squad.id) {
848
+ activeSquadId = null;
849
+ widgetState.squadId = null;
850
+ widgetControls?.requestUpdate();
851
+ }
852
+ fs.rmSync(store.getSquadDir(squad.id), { recursive: true, force: true });
853
+ ctx.ui.notify(`Deleted: ${squad.id}`, "info");
854
+ }
855
+ }
856
+ return;
857
+ }
858
+
859
+ case "enable": {
860
+ squadEnabled = true;
861
+ widgetControls?.requestUpdate();
862
+ ctx.ui.notify("pi-squad enabled — tools, widget, and system prompt active", "info");
863
+ return;
864
+ }
865
+
866
+ case "disable": {
867
+ squadEnabled = false;
868
+ // Stop all running schedulers
869
+ for (const [id, sched] of schedulers) {
870
+ await sched.stop();
871
+ }
872
+ schedulers.clear();
873
+ activeSquadId = null;
874
+ widgetState.squadId = null;
875
+ widgetState.enabled = false;
876
+ widgetControls?.requestUpdate();
877
+ ctx.ui.notify("pi-squad disabled — all tools, widget, and system prompt injection stopped", "info");
878
+ return;
879
+ }
880
+
881
+ case "agents": {
882
+ const agentArg = parts[1];
883
+ const allAgents = store.loadAllAgentDefs(ctx.cwd);
884
+
885
+ if (!agentArg) {
886
+ // List all agents — interactive selector
887
+ if (allAgents.length === 0) {
888
+ ctx.ui.notify("No agents found", "info");
889
+ return;
890
+ }
891
+ const options = allAgents.map((a) => {
892
+ const model = a.model ? ` [${a.model}]` : " [default]";
893
+ const status = a.disabled ? " ✗ disabled" : "";
894
+ return `${a.name} — ${a.role}${model}${status}`;
895
+ });
896
+ const choice = await ctx.ui.select("Squad Agents (select to view/edit)", options);
897
+ if (!choice) return;
898
+ const selectedName = choice.split(" — ")[0];
899
+ const agent = allAgents.find((a) => a.name === selectedName);
900
+ if (!agent) return;
901
+
902
+ // Show agent details and offer actions
903
+ const disableLabel = agent.disabled ? "Enable agent" : "Disable agent";
904
+ const actions = [
905
+ "View details",
906
+ "Edit in editor",
907
+ "Change model",
908
+ "Toggle tools (restrict/unrestrict)",
909
+ disableLabel,
910
+ "Cancel",
911
+ ];
912
+ const action = await ctx.ui.select(`${agent.name} (${agent.role})`, actions);
913
+ if (!action || action === "Cancel") return;
914
+
915
+ if (action === "View details") {
916
+ const details = [
917
+ `Name: ${agent.name}`,
918
+ `Role: ${agent.role}`,
919
+ `Description: ${agent.description}`,
920
+ `Model: ${agent.model || "(default)"}`,
921
+ `Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
922
+ `Tags: ${agent.tags.join(", ")}`,
923
+ ``,
924
+ `Prompt:`,
925
+ `${agent.prompt.slice(0, 300)}${agent.prompt.length > 300 ? "..." : ""}`,
926
+ ``,
927
+ `File: ${store.getGlobalAgentsDir()}/${agent.name}.json`,
928
+ ].join("\n");
929
+ ctx.ui.notify(details, "info");
930
+ } else if (action === "Edit in editor") {
931
+ // Check for local override first, fall back to global
932
+ const localPath = `${store.getLocalAgentsDir(ctx.cwd)}/${agent.name}.json`;
933
+ const globalPath = `${store.getGlobalAgentsDir()}/${agent.name}.json`;
934
+ const filePath = fs.existsSync(localPath) ? localPath : globalPath;
935
+ pi.sendMessage({
936
+ customType: "squad-edit-agent",
937
+ content: `Edit agent file: ${filePath}`,
938
+ display: true,
939
+ }, { triggerTurn: true });
940
+ } else if (action === "Change model") {
941
+ const newModel = await ctx.ui.input(
942
+ `Model for ${agent.name} (empty = default)`,
943
+ agent.model || "",
944
+ );
945
+ if (newModel !== undefined) {
946
+ agent.model = newModel.trim() || null;
947
+ store.saveAgentDef(agent);
948
+ ctx.ui.notify(`${agent.name} model → ${agent.model || "(default)"}`, "info");
949
+ }
950
+ } else if (action === disableLabel) {
951
+ agent.disabled = !agent.disabled;
952
+ store.saveAgentDef(agent);
953
+ const newState = agent.disabled ? "disabled — planner will not assign tasks to this agent" : "enabled";
954
+ ctx.ui.notify(`${agent.name}: ${newState}`, "info");
955
+ } else if (action === "Toggle tools") {
956
+ if (agent.tools) {
957
+ agent.tools = null;
958
+ store.saveAgentDef(agent);
959
+ ctx.ui.notify(`${agent.name}: all tools enabled`, "info");
960
+ } else {
961
+ const toolList = await ctx.ui.input(
962
+ `Tools for ${agent.name} (comma-separated)`,
963
+ "bash,read,write,edit",
964
+ );
965
+ if (toolList) {
966
+ agent.tools = toolList.split(",").map((t) => t.trim()).filter(Boolean);
967
+ store.saveAgentDef(agent);
968
+ ctx.ui.notify(`${agent.name}: tools = [${agent.tools.join(", ")}]`, "info");
969
+ }
970
+ }
971
+ }
972
+ return;
973
+ }
974
+
975
+ // /squad agents <name> — show specific agent
976
+ const agent = store.loadAgentDef(agentArg, ctx.cwd);
977
+ if (agent) {
978
+ const status = agent.disabled ? " ✗ DISABLED" : "";
979
+ const details = [
980
+ `${agent.name} — ${agent.role}${status}`,
981
+ `${agent.description}`,
982
+ `Model: ${agent.model || "(default)"}`,
983
+ `Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
984
+ `Tags: ${agent.tags.join(", ")}`,
985
+ ].join("\n");
986
+ ctx.ui.notify(details, "info");
987
+ } else {
988
+ ctx.ui.notify(`Agent '${agentArg}' not found`, "warning");
989
+ }
990
+ return;
991
+ }
992
+
643
993
  default:
644
994
  // Treat as a squad ID — try to activate it directly
645
995
  const direct = store.loadSquad(sub);
@@ -699,176 +1049,59 @@ function activateSquadView(squadId: string, ctx: import("@mariozechner/pi-coding
699
1049
 
700
1050
  activeSquadId = squadId;
701
1051
 
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
- }
1052
+ // Update widget to show the new squad. The widget reads squadId on each
1053
+ // render, so just updating the state and requesting a render is enough.
1054
+ widgetState.squadId = squadId;
1055
+ widgetState.enabled = true;
1056
+ widgetControls?.requestUpdate();
710
1057
 
1058
+ // Compact notification — widget already shows full task details.
1059
+ // Avoid large multi-line notifications that can break TUI layout.
711
1060
  const tasks = store.loadAllTasks(squadId);
712
1061
  const done = tasks.filter((t) => t.status === "done").length;
713
1062
  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
- );
1063
+ ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${done}/${tasks.length} $${cost.toFixed(2)}`, "info");
728
1064
  }
729
1065
 
730
1066
  // ============================================================================
731
- // Widget — live task status above the editor
1067
+ // Widget — component-based, event-driven (inspired by pi-interactive-shell)
732
1068
  // ============================================================================
733
1069
 
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);
1070
+ /** Trigger widget re-render from scheduler events */
1071
+ function forceWidgetUpdate(): void {
1072
+ widgetControls?.requestUpdate();
826
1073
  }
827
1074
 
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
1075
  // ============================================================================
848
- // Panel Creation
1076
+ // Panel — overlay via ctx.ui.custom() with proper done() lifecycle
849
1077
  // ============================================================================
850
1078
 
851
- function createPanel(
1079
+ /**
1080
+ * Open the squad panel overlay.
1081
+ * Uses the pi-interactive-shell pattern: ctx.ui.custom() returns a Promise
1082
+ * that resolves when done() is called. The panel calls done() on close.
1083
+ */
1084
+ function openPanel(
852
1085
  ctx: import("@mariozechner/pi-coding-agent").ExtensionContext,
853
1086
  scheduler: Scheduler,
854
1087
  squadId: string,
855
1088
  ): 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;
1089
+ if (overlayOpen) return;
1090
+ overlayOpen = true;
1091
+
1092
+ // The promise resolves when the panel calls done()
1093
+ const panelPromise = ctx.ui.custom<SquadPanelResult>(
1094
+ (tui, theme, _kb, done) => {
1095
+ const panel = new SquadPanel(tui, theme, scheduler, squadId, done);
862
1096
 
863
1097
  // Wire up message sending from panel
864
1098
  panel.onSendMessage = async (taskId: string, _prefill: string) => {
865
- // Temporarily release panel focus so input dialog works
866
- (panel as any).handle?.unfocus();
867
1099
  const task = store.loadTask(squadId, taskId);
868
1100
  const agentName = task?.agent || taskId;
869
1101
  const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
870
- if (input && activeScheduler) {
871
- await activeScheduler.sendHumanMessage(taskId, input);
1102
+ const panelSched = schedulers.get(squadId);
1103
+ if (input && panelSched) {
1104
+ await panelSched.sendHumanMessage(taskId, input);
872
1105
  ctx.ui.notify(`Sent to ${agentName}: "${input.slice(0, 50)}"`, "info");
873
1106
  } else if (input) {
874
1107
  store.appendMessage(squadId, taskId, {
@@ -879,8 +1112,6 @@ function createPanel(
879
1112
  });
880
1113
  ctx.ui.notify(`Logged to ${taskId}`, "info");
881
1114
  }
882
- // Re-focus panel after input
883
- (panel as any).handle?.focus();
884
1115
  tui.requestRender();
885
1116
  };
886
1117
 
@@ -888,29 +1119,22 @@ function createPanel(
888
1119
  },
889
1120
  {
890
1121
  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
- }
1122
+ overlayOptions: {
1123
+ anchor: "center" as const,
1124
+ width: "80%" as const,
1125
+ maxHeight: "80%" as const,
1126
+ margin: 2,
911
1127
  },
912
1128
  },
913
1129
  );
1130
+
1131
+ // When panel closes (done() called), clean up
1132
+ panelPromise.then(() => {
1133
+ overlayOpen = false;
1134
+ forceWidgetUpdate();
1135
+ }).catch(() => {
1136
+ overlayOpen = false;
1137
+ });
914
1138
  }
915
1139
 
916
1140
  // ============================================================================
@@ -1019,16 +1243,18 @@ async function startSquad(
1019
1243
 
1020
1244
  // Start scheduler
1021
1245
  const scheduler = new Scheduler(squadId, skillPaths);
1022
- activeScheduler = scheduler;
1246
+ schedulers.set(squadId, scheduler);
1023
1247
  activeSquadId = squadId;
1024
1248
 
1025
- // Start live widget updates
1026
- startWidgetRefresh();
1249
+ // Activate widget for this squad
1250
+ widgetState.squadId = squadId;
1251
+ widgetState.enabled = true;
1252
+ widgetControls?.requestUpdate();
1027
1253
 
1028
1254
  // Wire up completion/escalation notifications to main agent
1029
1255
  scheduler.onEvent((event: SchedulerEvent) => {
1030
1256
  // Update widget on every scheduler event
1031
- updateWidget();
1257
+ forceWidgetUpdate();
1032
1258
  switch (event.type) {
1033
1259
  case "squad_completed": {
1034
1260
  const tasks = store.loadAllTasks(squadId);
@@ -1039,21 +1265,24 @@ async function startSquad(
1039
1265
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1040
1266
 
1041
1267
  // Final context update before clearing scheduler
1042
- if (activeScheduler) {
1043
- activeScheduler.updateContext();
1268
+ const completedSched = schedulers.get(squadId);
1269
+ if (completedSched) {
1270
+ completedSched.updateContext();
1044
1271
  }
1045
1272
 
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
- );
1273
+ // Use sendMessage with display:true — visible to user but doesn't
1274
+ // pollute LLM message history. No triggerTurn needed for completions.
1275
+ pi.sendMessage({
1276
+ customType: "squad-completed",
1277
+ content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
1278
+ `Summary:\n${summary}\n\n` +
1279
+ `Total cost: $${totalCost.toFixed(4)}`,
1280
+ display: true,
1281
+ });
1052
1282
 
1053
1283
  // Clear scheduler but keep activeSquadId so squad_status still works
1054
- activeScheduler = null;
1055
- clearWidgetRefresh();
1056
- updateWidget(); // Final update showing done state
1284
+ schedulers.delete(squadId);
1285
+ forceWidgetUpdate(); // Final update showing done state
1057
1286
  break;
1058
1287
  }
1059
1288
 
@@ -1062,32 +1291,42 @@ async function startSquad(
1062
1291
  const failed = tasks.filter((t) => t.status === "failed");
1063
1292
  const done = tasks.filter((t) => t.status === "done");
1064
1293
 
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();
1294
+ // Squad failed — notify user. Use triggerTurn so the agent can
1295
+ // suggest next steps (retry, modify, cancel).
1296
+ pi.sendMessage({
1297
+ customType: "squad-failed",
1298
+ content: `[squad] Squad "${squadId}" has stalled. ` +
1299
+ `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1300
+ `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
1301
+ `Use squad_status for details or squad_modify to adjust.`,
1302
+ display: true,
1303
+ }, { triggerTurn: true });
1304
+ forceWidgetUpdate();
1074
1305
  break;
1075
1306
  }
1076
1307
 
1077
1308
  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
- );
1309
+ // Escalation — agent needs help. triggerTurn so the main agent
1310
+ // can respond and relay help.
1311
+ pi.sendMessage({
1312
+ customType: "squad-escalation",
1313
+ content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1314
+ `${event.message}\n\n` +
1315
+ `Reply to me and I'll forward your answer, or use the squad panel.`,
1316
+ display: true,
1317
+ }, { triggerTurn: true });
1084
1318
  break;
1085
1319
  }
1086
1320
  }
1087
1321
  });
1088
1322
 
1089
- // Start scheduling
1090
- await scheduler.start();
1323
+ // Start scheduling — fire and forget, don't block the tool call.
1324
+ // scheduler.start() spawns agents which can take seconds per agent.
1325
+ // We must return immediately so the main agent's turn completes
1326
+ // and the user regains interactive control.
1327
+ scheduler.start().catch((err) => {
1328
+ console.error(`[squad] Scheduler start error: ${(err as Error).message}`);
1329
+ });
1091
1330
 
1092
1331
  // Build response
1093
1332
  const taskSummary = plan.tasks