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