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/README.md +288 -75
- package/package.json +7 -3
- package/src/agent-pool.ts +10 -1
- package/src/index.ts +563 -322
- package/src/panel/message-view.ts +140 -122
- package/src/panel/squad-panel.ts +94 -148
- package/src/panel/squad-widget.ts +192 -0
- package/src/panel/task-list.ts +38 -28
- package/src/planner.ts +1 -1
- package/src/protocol.ts +37 -0
- package/src/scheduler.ts +216 -5
- package/src/skills/supervisor/SKILL.md +111 -0
- package/src/skills/verification/SKILL.md +32 -0
- package/src/store.ts +29 -1
- package/src/types.ts +11 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
|
70
|
+
// Inject squad awareness before each LLM call
|
|
57
71
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
58
|
-
if (!
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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" +
|
|
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
|
-
"
|
|
100
|
-
"has natural parallelism, or would overflow a single agent's context.",
|
|
101
|
-
"
|
|
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,
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
}
|
|
412
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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 (
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
747
|
+
forceWidgetUpdate();
|
|
614
748
|
return;
|
|
615
749
|
}
|
|
616
750
|
|
|
617
751
|
case "cancel": {
|
|
618
|
-
|
|
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
|
|
757
|
+
await cancelSched.stop();
|
|
623
758
|
const squad = store.loadSquad(activeSquadId!);
|
|
624
759
|
if (squad) { squad.status = "failed"; store.saveSquad(squad); }
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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 —
|
|
1069
|
+
// Widget — component-based, event-driven (inspired by pi-interactive-shell)
|
|
732
1070
|
// ============================================================================
|
|
733
1071
|
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
1078
|
+
// Panel — overlay via ctx.ui.custom() with proper done() lifecycle
|
|
849
1079
|
// ============================================================================
|
|
850
1080
|
|
|
851
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
1248
|
+
schedulers.set(squadId, scheduler);
|
|
1023
1249
|
activeSquadId = squadId;
|
|
1024
1250
|
|
|
1025
|
-
//
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1270
|
+
const completedSched = schedulers.get(squadId);
|
|
1271
|
+
if (completedSched) {
|
|
1272
|
+
completedSched.updateContext();
|
|
1044
1273
|
}
|
|
1045
1274
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
`
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
{
|
|
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
|
-
|
|
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
|