pi-squad 0.17.2 → 0.19.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/CHANGELOG.md +89 -0
- package/package.json +3 -1
- package/src/commands.ts +585 -0
- package/src/index.ts +14 -2013
- package/src/inline-input.ts +139 -0
- package/src/lifecycle.ts +164 -0
- package/src/panel-runtime.ts +157 -0
- package/src/planner.ts +2 -3
- package/src/protocol.ts +3 -47
- package/src/report.ts +40 -2
- package/src/runtime.ts +196 -0
- package/src/scheduler-runtime.ts +117 -0
- package/src/scheduler.ts +149 -135
- package/src/skills/squad-plan/SKILL.md +103 -0
- package/src/skills/squad-plan/validate-spec.mjs +65 -0
- package/src/skills/squad-supervisor/SKILL.md +1 -0
- package/src/start-squad.ts +176 -0
- package/src/store.ts +0 -43
- package/src/tools-registration.ts +626 -0
- package/src/types.ts +0 -26
- package/src/supervisor.ts +0 -142
package/src/index.ts
CHANGED
|
@@ -1,274 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-squad — Multi-agent collaboration extension for Pi.
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
5
|
-
* - squad tool (start a squad)
|
|
6
|
-
* - squad_status tool (check progress)
|
|
7
|
-
* - squad_message tool (send message to agent)
|
|
8
|
-
* - squad_modify tool (add/remove/reassign tasks)
|
|
9
|
-
* - Panel toggle keybinding
|
|
10
|
-
* - Session lifecycle hooks
|
|
4
|
+
* Registers tools, commands, panel/widget controls, and session lifecycle hooks.
|
|
11
5
|
*/
|
|
12
6
|
|
|
13
7
|
import * as path from "node:path";
|
|
14
8
|
import * as fs from "node:fs";
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { runPlanner } from "./planner.js";
|
|
21
|
-
import { validatePlan, PLAN_STRUCTURE_RULES } from "./plan-rules.js";
|
|
22
|
-
import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
|
|
23
|
-
import { completeSimple, type Message, type TextContent } from "@earendil-works/pi-ai/compat";
|
|
24
|
-
import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
|
|
25
|
-
import { setupSquadWidget, type SquadWidgetControls, type SquadWidgetState } from "./panel/squad-widget.js";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerChildSpecReader } from "./file-spec.js";
|
|
11
|
+
import { registerCommands } from "./commands.js";
|
|
12
|
+
import { registerLifecycle } from "./lifecycle.js";
|
|
13
|
+
import { focusSquad, runtime } from "./runtime.js";
|
|
26
14
|
import * as store from "./store.js";
|
|
27
|
-
import {
|
|
28
|
-
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
29
|
-
import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
|
|
30
|
-
import { formatSuspendedAttention, getReviewPresentation } from "./presentation.js";
|
|
31
|
-
import { prepareSpec, chunkRanges, isFileSpecTaskId, registerChildSpecReader, type PreparedSpec } from "./file-spec.js";
|
|
32
|
-
|
|
33
|
-
// ============================================================================
|
|
34
|
-
// State
|
|
35
|
-
// ============================================================================
|
|
36
|
-
|
|
37
|
-
interface InlineSquadStart {
|
|
38
|
-
goal: string;
|
|
39
|
-
agents?: Record<string, SquadAgentEntry>;
|
|
40
|
-
tasks?: Array<{ id: string; title: string; description?: string; agent: string; depends?: string[]; inheritContext?: boolean }>;
|
|
41
|
-
config?: { maxConcurrency?: number; autoUnblock?: boolean; maxRetries?: number };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Master switch — loaded from ~/.pi/squad/settings.json before lifecycle work. */
|
|
45
|
-
let squadEnabled = true;
|
|
46
|
-
const DISABLED_GUIDANCE = "pi-squad is disabled. Run /squad enable, then retry this operation; no squad work was changed.";
|
|
47
|
-
const disabledToolResult = () => ({
|
|
48
|
-
content: [{ type: "text" as const, text: DISABLED_GUIDANCE }],
|
|
49
|
-
details: undefined,
|
|
50
|
-
});
|
|
51
|
-
/** Registry of all running schedulers — supports multiple concurrent squads */
|
|
52
|
-
const schedulers = new Map<string, Scheduler>();
|
|
53
|
-
/** The currently viewed/focused squad (for widget, panel, status) */
|
|
54
|
-
let activeSquadId: string | null = null;
|
|
55
|
-
/** Whether an overlay panel is currently open (prevents double-open) */
|
|
56
|
-
let overlayOpen = false;
|
|
57
|
-
/** Close an already-open overlay when the master switch is disabled. */
|
|
58
|
-
let closeOverlay: (() => void) | null = null;
|
|
59
|
-
/** Stored ExtensionContext for widget updates from background scheduler events */
|
|
60
|
-
let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
|
|
61
|
-
/** Component-based widget state + controls */
|
|
62
|
-
const widgetState: SquadWidgetState = { squadId: null, enabled: true };
|
|
63
|
-
let widgetControls: SquadWidgetControls | null = null;
|
|
64
|
-
|
|
65
|
-
/** Keep main-session focus, compact widget, and detail panel targeting identical. */
|
|
66
|
-
function focusSquad(squadId: string | null): void {
|
|
67
|
-
activeSquadId = squadId;
|
|
68
|
-
widgetState.squadId = squadId;
|
|
69
|
-
if (squadId && squadEnabled) widgetState.enabled = true;
|
|
70
|
-
widgetControls?.refreshNow();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Format completion against active work while retaining cancelled history. */
|
|
74
|
-
function formatTaskProgress(tasks: Task[]): string {
|
|
75
|
-
const done = tasks.filter((task) => task.status === "done").length;
|
|
76
|
-
const cancelled = tasks.filter((task) => task.status === "cancelled").length;
|
|
77
|
-
const active = tasks.length - cancelled;
|
|
78
|
-
return cancelled > 0
|
|
79
|
-
? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
|
|
80
|
-
: `${done}/${tasks.length} tasks done`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Resolve a model string (or null = session default) to its context window.
|
|
85
|
-
* Reads uiCtx lazily so it always uses the live session's registry.
|
|
86
|
-
*/
|
|
87
|
-
function resolveContextWindow(model: string | null): number | undefined {
|
|
88
|
-
const ctx = uiCtx;
|
|
89
|
-
if (!ctx) return undefined;
|
|
90
|
-
try {
|
|
91
|
-
if (!model) return ctx.model?.contextWindow;
|
|
92
|
-
// Strip a :<thinking> suffix if present
|
|
93
|
-
let clean = model;
|
|
94
|
-
const lastColon = model.lastIndexOf(":");
|
|
95
|
-
if (lastColon > 0 && (THINKING_LEVELS as readonly string[]).includes(model.slice(lastColon + 1))) {
|
|
96
|
-
clean = model.slice(0, lastColon);
|
|
97
|
-
}
|
|
98
|
-
const all = ctx.modelRegistry.getAll();
|
|
99
|
-
const slash = clean.indexOf("/");
|
|
100
|
-
if (slash > 0) {
|
|
101
|
-
const provider = clean.slice(0, slash);
|
|
102
|
-
const id = clean.slice(slash + 1);
|
|
103
|
-
const m = all.find((x) => x.provider === provider && x.id === id);
|
|
104
|
-
if (m) return m.contextWindow;
|
|
105
|
-
}
|
|
106
|
-
return all.find((x) => x.id === clean)?.contextWindow;
|
|
107
|
-
} catch {
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Main session's current model as "provider/id", if known */
|
|
113
|
-
function getMainSessionModel(): string | undefined {
|
|
114
|
-
try {
|
|
115
|
-
const m = uiCtx?.model;
|
|
116
|
-
return m ? `${m.provider}/${m.id}` : undefined;
|
|
117
|
-
} catch {
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Main session's current thinking level (set inside the extension entry, needs `pi`) */
|
|
123
|
-
let getMainSessionThinking: () => string | undefined = () => undefined;
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Resolve the effective squad defaults from ~/.pi/squad/settings.json.
|
|
127
|
-
* "main" follows the live main session; "pi-default" leaves values unset
|
|
128
|
-
* (child pi resolves its own default); anything else is an explicit value.
|
|
129
|
-
*/
|
|
130
|
-
function resolveSquadDefaults(): { model?: string; thinking?: string } {
|
|
131
|
-
const settings = store.loadSquadSettings();
|
|
132
|
-
let model: string | undefined;
|
|
133
|
-
if (settings.defaultModel === "main") model = getMainSessionModel();
|
|
134
|
-
else if (settings.defaultModel !== "pi-default") model = settings.defaultModel;
|
|
135
|
-
let thinking: string | undefined;
|
|
136
|
-
if (settings.defaultThinking === "main") thinking = getMainSessionThinking();
|
|
137
|
-
else if (settings.defaultThinking !== "pi-default") thinking = settings.defaultThinking;
|
|
138
|
-
return { model, thinking };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Consult the advisor model in-process via pi-ai (no subprocess).
|
|
143
|
-
* Returns advice text or null when disabled/unresolvable.
|
|
144
|
-
*/
|
|
145
|
-
async function consultAdvisor(input: AdvisorConsultInput): Promise<string | null> {
|
|
146
|
-
const ctx = uiCtx;
|
|
147
|
-
if (!ctx) return null;
|
|
148
|
-
const settings = store.loadSquadSettings();
|
|
149
|
-
if (!settings.advisor.enabled) return null;
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
// Resolve advisor model: "main" = the main session's live model object
|
|
153
|
-
let model = settings.advisor.model === "main" ? ctx.model : undefined;
|
|
154
|
-
if (!model && settings.advisor.model !== "main") {
|
|
155
|
-
const ref = settings.advisor.model;
|
|
156
|
-
const slash = ref.indexOf("/");
|
|
157
|
-
if (slash > 0) model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
158
|
-
}
|
|
159
|
-
if (!model) {
|
|
160
|
-
logError("squad-advisor", `advisor model "${settings.advisor.model}" not resolvable`);
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
165
|
-
if (!auth.ok || !auth.apiKey) {
|
|
166
|
-
logError("squad-advisor", `no auth for advisor model ${model.provider}/${model.id}`);
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const userMessage: Message = {
|
|
171
|
-
role: "user",
|
|
172
|
-
content: [{ type: "text", text: buildAdvisorConsultText(input) }],
|
|
173
|
-
timestamp: Date.now(),
|
|
174
|
-
} as Message;
|
|
175
|
-
|
|
176
|
-
const response = await completeSimple(
|
|
177
|
-
model,
|
|
178
|
-
{ systemPrompt: ADVISOR_SYSTEM_PROMPT, messages: [userMessage] },
|
|
179
|
-
{
|
|
180
|
-
apiKey: auth.apiKey,
|
|
181
|
-
headers: auth.headers,
|
|
182
|
-
maxTokens: settings.advisor.maxTokens,
|
|
183
|
-
reasoning: settings.advisor.reasoning as never,
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const text = response.content
|
|
188
|
-
.filter((b): b is TextContent => b.type === "text")
|
|
189
|
-
.map((b) => b.text)
|
|
190
|
-
.join("\n")
|
|
191
|
-
.trim();
|
|
192
|
-
debug("squad-advisor", `consulted ${model.provider}/${model.id} for ${input.taskId}: in=${response.usage?.input ?? 0} out=${response.usage?.output ?? 0}`);
|
|
193
|
-
return text || null;
|
|
194
|
-
} catch (error) {
|
|
195
|
-
logError("squad-advisor", `consult failed: ${(error as Error).message}`);
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Spawn context shared by all Scheduler instances */
|
|
201
|
-
const schedulerSpawnContext: SchedulerSpawnContext = {
|
|
202
|
-
resolveContextWindow,
|
|
203
|
-
getDefaultModelThinking: resolveSquadDefaults,
|
|
204
|
-
consultAdvisor,
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
/** Get the active scheduler (for the focused squad) */
|
|
208
|
-
function getActiveScheduler(): Scheduler | null {
|
|
209
|
-
if (!activeSquadId) return null;
|
|
210
|
-
return schedulers.get(activeSquadId) || null;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Restore the single focus/widget invariant after destructive exact cancellation. */
|
|
214
|
-
function repairFocusAfterCancellation(cancelledId: string): void {
|
|
215
|
-
const nextFocus = activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))
|
|
216
|
-
? null
|
|
217
|
-
: activeSquadId;
|
|
218
|
-
focusSquad(nextFocus);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
|
|
222
|
-
return store.listSquadsForProject(cwd)
|
|
223
|
-
.filter((squad) => Boolean(squad.suspendedStallAttention))
|
|
224
|
-
.map((squad) => ({ squadId: squad.id, attention: squad.suspendedStallAttention! }));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
228
|
-
function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
229
|
-
let scheduler = schedulers.get(squadId);
|
|
230
|
-
if (!scheduler) {
|
|
231
|
-
scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
|
|
232
|
-
schedulers.set(squadId, scheduler);
|
|
233
|
-
wireSchedulerEvents(pi, scheduler, squadId);
|
|
234
|
-
}
|
|
235
|
-
focusSquad(squadId);
|
|
236
|
-
return scheduler;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function isResumeCandidate(squad: Squad): boolean {
|
|
240
|
-
return squad.status === "paused" || squad.status === "failed" ||
|
|
241
|
-
(squad.status === "review" && squad.review?.status === "failed") ||
|
|
242
|
-
store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
|
|
246
|
-
if (explicitId) return store.loadSquad(explicitId);
|
|
247
|
-
if (activeSquadId) {
|
|
248
|
-
const active = store.loadSquad(activeSquadId);
|
|
249
|
-
if (active?.cwd === cwd && isResumeCandidate(active)) return active;
|
|
250
|
-
}
|
|
251
|
-
return store.listSquadsForProject(cwd)
|
|
252
|
-
.filter(isResumeCandidate)
|
|
253
|
-
.sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// ============================================================================
|
|
258
|
-
// Extension Entry
|
|
259
|
-
// ============================================================================
|
|
15
|
+
import { registerTools } from "./tools-registration.js";
|
|
260
16
|
|
|
261
17
|
export default function (pi: ExtensionAPI) {
|
|
262
18
|
// File-spec children load only the non-recursive reader and fail-closed guard.
|
|
263
19
|
if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
|
|
264
20
|
|
|
265
21
|
// Load the global master switch before any session lifecycle work can run.
|
|
266
|
-
squadEnabled = store.loadSquadSettings().enabled;
|
|
267
|
-
widgetState.enabled = squadEnabled;
|
|
268
|
-
if (!squadEnabled) focusSquad(null);
|
|
22
|
+
runtime.squadEnabled = store.loadSquadSettings().enabled;
|
|
23
|
+
runtime.widgetState.enabled = runtime.squadEnabled;
|
|
24
|
+
if (!runtime.squadEnabled) focusSquad(null);
|
|
269
25
|
|
|
270
26
|
// Wire main-session thinking lookup (needs `pi`, guarded against stale API)
|
|
271
|
-
getMainSessionThinking = () => {
|
|
27
|
+
runtime.getMainSessionThinking = () => {
|
|
272
28
|
try {
|
|
273
29
|
return pi.getThinkingLevel();
|
|
274
30
|
} catch {
|
|
@@ -284,1764 +40,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
284
40
|
const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
|
|
285
41
|
const squadSkillPaths = getSquadSkillPaths(skillsDir);
|
|
286
42
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (!squad) return false;
|
|
291
|
-
const live = schedulers.get(squadId);
|
|
292
|
-
if (live) await live.stop();
|
|
293
|
-
else await new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext).stop();
|
|
294
|
-
const fresh = store.loadSquad(squadId);
|
|
295
|
-
if (fresh) {
|
|
296
|
-
fresh.status = "failed";
|
|
297
|
-
delete fresh.suspendedStallAttention;
|
|
298
|
-
store.saveSquad(fresh);
|
|
299
|
-
}
|
|
300
|
-
schedulers.delete(squadId);
|
|
301
|
-
repairFocusAfterCancellation(squadId);
|
|
302
|
-
return true;
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
/** Register a dynamically gated Ctrl+Q path; disabled mode can only show guidance. */
|
|
306
|
-
const registerTerminalPanelToggle = (ctx: ExtensionContext): void => {
|
|
307
|
-
if (!ctx.hasUI) return;
|
|
308
|
-
ctx.ui.onTerminalInput((data) => {
|
|
309
|
-
if (data !== "\x11") return undefined;
|
|
310
|
-
if (!squadEnabled) {
|
|
311
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
312
|
-
return { consume: true };
|
|
313
|
-
}
|
|
314
|
-
// If overlay is already open, let the panel's own handler deal with it.
|
|
315
|
-
if (overlayOpen) return undefined;
|
|
316
|
-
if (!activeSquadId) {
|
|
317
|
-
const latest = store.findLatestSquad(ctx.cwd)
|
|
318
|
-
|| store.listSquads().map((id) => store.loadSquad(id)).filter((s): s is Squad => s !== null).sort((a, b) => b.created.localeCompare(a.created))[0];
|
|
319
|
-
if (latest) activateSquadView(latest.id, ctx);
|
|
320
|
-
else {
|
|
321
|
-
ctx.ui.notify("No squads found. Use /squad or the squad tool.", "info");
|
|
322
|
-
return { consume: true };
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
if (activeSquadId) {
|
|
326
|
-
openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
|
|
327
|
-
}
|
|
328
|
-
return { consume: true };
|
|
329
|
-
});
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
// =========================================================================
|
|
333
|
-
// Context Injection — give main agent awareness of squad state
|
|
334
|
-
// =========================================================================
|
|
335
|
-
|
|
336
|
-
// Inject squad awareness before each LLM call
|
|
337
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
338
|
-
// Review gates are project-wide and survive focus changes, new squads, and
|
|
339
|
-
// disabling normal squad operations. Unaccepted work must stay visible.
|
|
340
|
-
const pendingReviewGates = store.findActiveSquads()
|
|
341
|
-
.filter((s) => s.cwd === ctx.cwd && s.status === "review")
|
|
342
|
-
.map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
|
|
343
|
-
const suspendedAttention = activeSuspendedAttentionForProject(ctx.cwd)
|
|
344
|
-
.map(({ squadId, attention }) => formatSuspendedStallAttention(squadId, attention));
|
|
345
|
-
const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
|
|
346
|
-
if (!squadEnabled) {
|
|
347
|
-
if (durablePrompts.length === 0) return;
|
|
348
|
-
const enableFirst = durablePrompts.map((prompt) =>
|
|
349
|
-
`${prompt}\npi-squad is disabled. Run /squad enable first; then perform only the explicit review or exact-task resume described above.`,
|
|
350
|
-
);
|
|
351
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + enableFirst.join("\n\n") };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// When a squad is active, inject its status
|
|
355
|
-
if (activeSquadId) {
|
|
356
|
-
const squad = store.loadSquad(activeSquadId);
|
|
357
|
-
if (!squad) {
|
|
358
|
-
focusSquad(null);
|
|
359
|
-
if (durablePrompts.length > 0) {
|
|
360
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
361
|
-
}
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const tasks = store.loadAllTasks(activeSquadId);
|
|
365
|
-
if (tasks.length === 0) {
|
|
366
|
-
if (durablePrompts.length > 0) {
|
|
367
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
368
|
-
}
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
373
|
-
|
|
374
|
-
const taskLines = tasks.map((t) => {
|
|
375
|
-
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
|
|
376
|
-
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
377
|
-
if (t.output) line += ` — ${t.output}`;
|
|
378
|
-
if (t.error) line += ` ERROR: ${t.error}`;
|
|
379
|
-
return line;
|
|
380
|
-
}).join("\n");
|
|
381
|
-
|
|
382
|
-
const reviewPresentation = getReviewPresentation(squad);
|
|
383
|
-
const squadReference = squad.spec
|
|
384
|
-
? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
|
|
385
|
-
: squad.goal;
|
|
386
|
-
const squadContext = [
|
|
387
|
-
`<squad_status>`,
|
|
388
|
-
`Squad: ${squad.id} — ${squadReference}`,
|
|
389
|
-
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
390
|
-
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
391
|
-
taskLines,
|
|
392
|
-
`</squad_status>`,
|
|
393
|
-
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
394
|
-
...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
|
|
395
|
-
...suspendedAttention,
|
|
396
|
-
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
397
|
-
`Do NOT poll squad_status in a loop or sleep-wait — the squad wakes you automatically on completion, failure, or escalation. Keep helping the user with other work, or end your turn and stay idle.`,
|
|
398
|
-
].join("\n");
|
|
399
|
-
|
|
400
|
-
return {
|
|
401
|
-
systemPrompt: event.systemPrompt + "\n\n" + squadContext,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// When NO squad is active, nudge the agent to consider using squad for complex tasks
|
|
406
|
-
const allAgents = store.loadAllAgentDefs(ctx.cwd).filter((a) => a.name !== "planner" && !a.disabled);
|
|
407
|
-
const agentList = allAgents.map((a) => `${a.name} (${a.role})`).join(", ");
|
|
408
|
-
const squadNudge = [
|
|
409
|
-
`<squad_hint>`,
|
|
410
|
-
`You have the "squad" tool available for multi-agent collaboration.`,
|
|
411
|
-
`Use it when the user's request involves multiple concerns (e.g. backend + frontend + tests + docs),`,
|
|
412
|
-
`would benefit from parallel execution, or is too large for a single agent context.`,
|
|
413
|
-
`The squad tool decomposes work into tasks, assigns specialist agents, and runs them in parallel.`,
|
|
414
|
-
`When in doubt about whether a task is complex enough, prefer using squad — it handles the coordination for you.`,
|
|
415
|
-
allAgents.length > 0 ? `Available agents: ${agentList}. When providing tasks, the "agent" field must be one of these names.` : ``,
|
|
416
|
-
`When you provide tasks yourself, you take the planner's role — follow its rules: contract/design task first for shared interfaces, final QA task for user-facing changes, 3-7 tasks, first task(s) with empty depends.`,
|
|
417
|
-
`Structure descriptions as: Goal (outcome first), Context (files to read), Output (deliverable), Boundaries (what must not change), Verify (proving command).`,
|
|
418
|
-
`</squad_hint>`,
|
|
419
|
-
].filter(Boolean).join("\n");
|
|
420
|
-
|
|
421
|
-
return {
|
|
422
|
-
systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
|
|
423
|
-
(durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
|
|
424
|
-
};
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
// =========================================================================
|
|
428
|
-
// Tool: squad
|
|
429
|
-
// =========================================================================
|
|
430
|
-
|
|
431
|
-
pi.registerTool({
|
|
432
|
-
name: "squad",
|
|
433
|
-
label: "Squad",
|
|
434
|
-
description: [
|
|
435
|
-
"Start a multi-agent squad for complex, multi-step tasks.",
|
|
436
|
-
"ALWAYS use squad when a task involves 2+ of: backend, frontend, testing, docs, devops, security.",
|
|
437
|
-
"Use when a task has natural parallelism, touches multiple files/systems, or would overflow a single agent's context.",
|
|
438
|
-
"Examples that NEED squad: 'build a REST API with auth and tests', 'add a feature with frontend + backend + docs',",
|
|
439
|
-
"'refactor the auth system and update tests', 'set up CI/CD with Docker and deployment'.",
|
|
440
|
-
"Do NOT use for simple single-file changes, quick bug fixes, or tasks a single agent can handle in a few minutes.",
|
|
441
|
-
"When in doubt about complexity, use squad — it's better to parallelize than to do everything sequentially.",
|
|
442
|
-
"Non-blocking: returns immediately with the plan while agents work in background.",
|
|
443
|
-
"If you provide tasks yourself (skipping the planner agent), follow the same rules the planner follows:",
|
|
444
|
-
PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
|
|
445
|
-
"Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
|
|
446
|
-
].join(" "),
|
|
447
|
-
promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
|
|
448
|
-
promptGuidelines: [
|
|
449
|
-
"Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
|
|
450
|
-
"For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
|
|
451
|
-
"Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
|
|
452
|
-
"Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
|
|
453
|
-
"Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
|
|
454
|
-
"After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
|
|
455
|
-
"When agents finish, treat every squad report and QA verdict as untrusted; independently inspect the diff/source and rerun contract verification + integration/E2E, then call squad_review before reporting success",
|
|
456
|
-
],
|
|
457
|
-
parameters: Type.Union([
|
|
458
|
-
Type.Object({
|
|
459
|
-
goal: Type.String({ description: "Complete original user outcome/acceptance contract the squad should accomplish. Preserve requirements and boundaries; this is shown during mandatory main-orchestrator review." }),
|
|
460
|
-
agents: Type.Optional(
|
|
461
|
-
Type.Record(
|
|
462
|
-
Type.String(),
|
|
463
|
-
Type.Object({
|
|
464
|
-
model: Type.Optional(Type.String({ description: "Model override (e.g. 'github-copilot/claude-sonnet-5')" })),
|
|
465
|
-
thinking: Type.Optional(Type.String({ description: "Thinking level: off, minimal, low, medium, high, xhigh, max" })),
|
|
466
|
-
}),
|
|
467
|
-
{ description: "Agent roster with optional model/thinking overrides. Keys must match agent names in .pi/squad/agents/" },
|
|
468
|
-
),
|
|
469
|
-
),
|
|
470
|
-
tasks: Type.Optional(
|
|
471
|
-
Type.Array(
|
|
472
|
-
Type.Object({
|
|
473
|
-
id: Type.String(),
|
|
474
|
-
title: Type.String(),
|
|
475
|
-
description: Type.Optional(Type.String({ description: "Structure as: Goal (outcome first, not steps), Context (files/contracts to read), Output (deliverable), Boundaries (what must NOT change), Verify (command that proves it works). Include only the parts that help." })),
|
|
476
|
-
agent: Type.String(),
|
|
477
|
-
depends: Type.Optional(Type.Array(Type.String())),
|
|
478
|
-
inheritContext: Type.Optional(Type.Boolean({ description: "Fork the current pi session so the agent inherits this conversation's full context. Use ONLY when the task depends on decisions/details discussed here that can't be restated briefly. Costly (agent pays the whole history as input each turn) and auto-skipped when the session exceeds 50% of the agent model's context window — prefer restating key context in the description." })),
|
|
479
|
-
}),
|
|
480
|
-
{ description: "Pre-defined task breakdown. If provided, skips the planner agent. Scope tasks to required work only — no optional polish." },
|
|
481
|
-
),
|
|
482
|
-
),
|
|
483
|
-
config: Type.Optional(
|
|
484
|
-
Type.Object({
|
|
485
|
-
maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
|
|
486
|
-
}),
|
|
487
|
-
),
|
|
488
|
-
}, { additionalProperties: false }),
|
|
489
|
-
Type.Object({
|
|
490
|
-
specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
|
|
491
|
-
specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
|
|
492
|
-
}, { additionalProperties: false }),
|
|
493
|
-
]),
|
|
494
|
-
|
|
495
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
496
|
-
if (!squadEnabled) return disabledToolResult();
|
|
497
|
-
if (!uiCtx) uiCtx = ctx;
|
|
498
|
-
|
|
499
|
-
// Check if the user cancelled before we start
|
|
500
|
-
if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled." }], details: undefined };
|
|
501
|
-
|
|
502
|
-
// Multiple squads can run concurrently — no guard needed
|
|
503
|
-
|
|
504
|
-
// Resolve to absolute: the fork happens later from a child process whose
|
|
505
|
-
// cwd may differ (e.g. when a relative --session-dir was used).
|
|
506
|
-
const rawSessionFile = ctx.sessionManager.getSessionFile();
|
|
507
|
-
const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
|
|
508
|
-
let prepared: PreparedSpec | undefined;
|
|
509
|
-
let effective: InlineSquadStart;
|
|
510
|
-
if ("specFile" in params) {
|
|
511
|
-
prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
|
|
512
|
-
effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
|
|
513
|
-
} else {
|
|
514
|
-
effective = params;
|
|
515
|
-
}
|
|
516
|
-
const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
|
|
517
|
-
const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
|
|
518
|
-
return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
// =========================================================================
|
|
523
|
-
// Tool: squad_status
|
|
524
|
-
// =========================================================================
|
|
525
|
-
|
|
526
|
-
pi.registerTool({
|
|
527
|
-
name: "squad_status",
|
|
528
|
-
label: "Squad Status",
|
|
529
|
-
description: "Check current squad status, task progress, and recent activity. Do NOT call this in a loop or after sleep-waits — squad completion/failure/escalations wake you automatically. Use only when the user asks for a status update or after being woken by a squad event.",
|
|
530
|
-
parameters: Type.Object({
|
|
531
|
-
squadId: Type.Optional(Type.String({ description: "Specific squad ID (default: most recent)" })),
|
|
532
|
-
}),
|
|
533
|
-
|
|
534
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
535
|
-
if (!squadEnabled) return disabledToolResult();
|
|
536
|
-
let id = params.squadId || activeSquadId;
|
|
537
|
-
|
|
538
|
-
// If no active squad, find the most recent one for this project
|
|
539
|
-
if (!id) {
|
|
540
|
-
const latest = store.findLatestSquad(ctx.cwd);
|
|
541
|
-
if (latest) id = latest.id;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (!id) {
|
|
545
|
-
return { content: [{ type: "text" as const, text: "No squads found. Use the squad tool to start one." }], details: undefined };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// If scheduler is running, force a context refresh
|
|
549
|
-
const sched = schedulers.get(id!);
|
|
550
|
-
if (sched) sched.updateContext();
|
|
551
|
-
|
|
552
|
-
const context = store.loadContext(id);
|
|
553
|
-
if (!context) {
|
|
554
|
-
return { content: [{ type: "text" as const, text: `Squad '${id}' not found or has no context yet.` }], details: undefined };
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const taskLines = Object.entries(context.tasks)
|
|
558
|
-
.map(([taskId, task]) => {
|
|
559
|
-
const icon =
|
|
560
|
-
task.status === "done" ? "✓" :
|
|
561
|
-
task.status === "in_progress" ? "⏳" :
|
|
562
|
-
task.status === "blocked" ? "◻" :
|
|
563
|
-
task.status === "failed" ? "✗" :
|
|
564
|
-
task.status === "suspended" ? "⏸" :
|
|
565
|
-
task.status === "cancelled" ? "⊘" :
|
|
566
|
-
"·";
|
|
567
|
-
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
568
|
-
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
569
|
-
return line;
|
|
570
|
-
})
|
|
571
|
-
.join("\n");
|
|
572
|
-
|
|
573
|
-
const durableTasks = store.loadAllTasks(id!);
|
|
574
|
-
const squad = store.loadSquad(id!);
|
|
575
|
-
const review = squad ? getReviewPresentation(squad) : null;
|
|
576
|
-
const summary = [
|
|
577
|
-
`Squad: ${id}`,
|
|
578
|
-
`Status: ${context.status}`,
|
|
579
|
-
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
580
|
-
`Elapsed: ${context.elapsed}`,
|
|
581
|
-
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
582
|
-
...(review ? [`Acceptance: ${review.label}`] : []),
|
|
583
|
-
...(squad ? formatSuspendedAttention(squad) : []),
|
|
584
|
-
"",
|
|
585
|
-
"Tasks:",
|
|
586
|
-
taskLines,
|
|
587
|
-
].join("\n");
|
|
588
|
-
|
|
589
|
-
return { content: [{ type: "text" as const, text: summary }], details: undefined };
|
|
590
|
-
},
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
// =========================================================================
|
|
594
|
-
// Tool: squad_review — mandatory main-orchestrator acceptance gate
|
|
595
|
-
// =========================================================================
|
|
596
|
-
|
|
597
|
-
pi.registerTool({
|
|
598
|
-
name: "squad_review",
|
|
599
|
-
label: "Record Independent Squad Review",
|
|
600
|
-
description: "Record the MAIN Pi/orchestrator's independent review of completed squad work against the original user contract. Call only after inspecting the actual diff/source and independently running verification plus integration/E2E where applicable. Squad reports and squad QA evidence are not sufficient.",
|
|
601
|
-
parameters: Type.Object({
|
|
602
|
-
squadId: Type.Optional(Type.String({ description: "Squad awaiting review (default: active/latest)" })),
|
|
603
|
-
verdict: Type.Union([
|
|
604
|
-
Type.Literal("pass"),
|
|
605
|
-
Type.Literal("pass_with_issues"),
|
|
606
|
-
Type.Literal("fail"),
|
|
607
|
-
]),
|
|
608
|
-
contractChecks: Type.Array(Type.String(), { minItems: 1, description: "Requirement-by-requirement checks against the ORIGINAL user request and later clarifications; include observed result for each" }),
|
|
609
|
-
diffReview: Type.String({ description: "What you independently inspected in the actual diff/source, including scope and integration concerns" }),
|
|
610
|
-
verificationEvidence: Type.Array(Type.String(), { minItems: 1, description: "Commands/checks YOU ran and their actual results; do not copy squad claims" }),
|
|
611
|
-
integrationEvidence: Type.String({ description: "Integration/E2E result from the real target or production-like environment, or a precise reason it is not applicable/impossible and therefore unverified" }),
|
|
612
|
-
issues: Type.Array(Type.String(), { description: "Every discovered or remaining issue; required for fail/pass_with_issues" }),
|
|
613
|
-
}),
|
|
614
|
-
|
|
615
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
616
|
-
if (!squadEnabled) return disabledToolResult();
|
|
617
|
-
let id = params.squadId;
|
|
618
|
-
if (!id && activeSquadId && store.loadSquad(activeSquadId)?.status === "review") {
|
|
619
|
-
id = activeSquadId;
|
|
620
|
-
}
|
|
621
|
-
if (!id) {
|
|
622
|
-
id = store.listSquadsForProject(ctx.cwd)
|
|
623
|
-
.filter((s) => s.status === "review")
|
|
624
|
-
.sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
|
|
625
|
-
}
|
|
626
|
-
if (!id) {
|
|
627
|
-
return { content: [{ type: "text" as const, text: "No squad is awaiting orchestrator review." }], details: undefined };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const squad = store.loadSquad(id);
|
|
631
|
-
if (!squad) {
|
|
632
|
-
return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
|
|
633
|
-
}
|
|
634
|
-
const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
|
|
635
|
-
if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
|
|
636
|
-
const invalidAttestations = await attestationScheduler.auditSpecAttestations();
|
|
637
|
-
if (!squadEnabled) return disabledToolResult();
|
|
638
|
-
if (invalidAttestations.length > 0) {
|
|
639
|
-
void attestationScheduler.start();
|
|
640
|
-
return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
try {
|
|
644
|
-
recordOrchestratorReview(squad, {
|
|
645
|
-
verdict: params.verdict,
|
|
646
|
-
contractChecks: params.contractChecks,
|
|
647
|
-
diffReview: params.diffReview,
|
|
648
|
-
verificationEvidence: params.verificationEvidence,
|
|
649
|
-
integrationEvidence: params.integrationEvidence,
|
|
650
|
-
issues: params.issues,
|
|
651
|
-
});
|
|
652
|
-
} catch (error) {
|
|
653
|
-
return { content: [{ type: "text" as const, text: `Review rejected: ${(error as Error).message}` }], details: undefined };
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
store.saveSquad(squad);
|
|
657
|
-
forceWidgetUpdate();
|
|
658
|
-
const accepted = squad.status === "done";
|
|
659
|
-
const text = accepted
|
|
660
|
-
? `Independent orchestrator review recorded for '${id}' (${params.verdict}). The squad is now accepted as done.`
|
|
661
|
-
: `Independent review FAILED for '${id}'. The squad remains review-required. Fix every issue, rerun verification/E2E, then submit a fresh squad_review.`;
|
|
662
|
-
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
663
|
-
},
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
// =========================================================================
|
|
667
|
-
// Tool: squad_message
|
|
668
|
-
// =========================================================================
|
|
669
|
-
|
|
670
|
-
pi.registerTool({
|
|
671
|
-
name: "squad_message",
|
|
672
|
-
label: "Squad Message",
|
|
673
|
-
description: "Send a durable request to a specific task. Existing tasks reopen their original Pi session; only a currently running task may be selected by agent name.",
|
|
674
|
-
parameters: Type.Object({
|
|
675
|
-
message: Type.String({ description: "Message to send" }),
|
|
676
|
-
taskId: Type.Optional(Type.String({ description: "Target task ID" })),
|
|
677
|
-
agent: Type.Optional(Type.String({ description: "Target agent name" })),
|
|
678
|
-
expectReply: Type.Optional(Type.Boolean({ description: "Forward the agent's next substantive response back to main Pi and wake it (default true)" })),
|
|
679
|
-
}),
|
|
680
|
-
|
|
681
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
682
|
-
if (!squadEnabled) return disabledToolResult();
|
|
683
|
-
if (!activeSquadId) {
|
|
684
|
-
return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
let activeScheduler = getActiveScheduler();
|
|
688
|
-
let taskId = params.taskId;
|
|
689
|
-
|
|
690
|
-
// Agent name is safe shorthand only when it identifies one live task.
|
|
691
|
-
// Multiple concurrent tasks can share a role, so never guess between them.
|
|
692
|
-
if (!taskId && params.agent && activeScheduler) {
|
|
693
|
-
const liveMatches = store.loadAllTasks(activeSquadId).filter(
|
|
694
|
-
(task) => task.agent === params.agent && activeScheduler!.getPool().isRunning(task.id),
|
|
695
|
-
);
|
|
696
|
-
if (liveMatches.length === 1) taskId = liveMatches[0].id;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (!taskId) {
|
|
700
|
-
return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
|
|
701
|
-
}
|
|
702
|
-
if (!store.loadTask(activeSquadId, taskId)) {
|
|
703
|
-
return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// Review/done squads have no live scheduler after a process restart. An
|
|
707
|
-
// exact task ID is enough to reconstruct it from disk and reopen only that
|
|
708
|
-
// task; the task's immutable session binding supplies --session.
|
|
709
|
-
if (!activeScheduler) {
|
|
710
|
-
activeScheduler = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
711
|
-
schedulers.set(activeSquadId, activeScheduler);
|
|
712
|
-
wireSchedulerEvents(pi, activeScheduler, activeSquadId);
|
|
713
|
-
await activeScheduler.start();
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
|
|
717
|
-
const status = sent ? "delivered" : "queued for when the agent starts";
|
|
718
|
-
|
|
719
|
-
return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
// =========================================================================
|
|
724
|
-
// Tool: squad_modify
|
|
725
|
-
// =========================================================================
|
|
726
|
-
|
|
727
|
-
pi.registerTool({
|
|
728
|
-
name: "squad_modify",
|
|
729
|
-
label: "Squad Modify",
|
|
730
|
-
description: "Modify a squad. The destructive cancel action requires an exact squadId and never infers focus; task actions reconstruct the persisted scheduler after restart when needed.",
|
|
731
|
-
parameters: Type.Object({
|
|
732
|
-
squadId: Type.Optional(Type.String({ description: "Exact squad to modify; required for cancel (other actions may use the focused/recoverable project squad)" })),
|
|
733
|
-
action: Type.Union(
|
|
734
|
-
[
|
|
735
|
-
Type.Literal("add_task"),
|
|
736
|
-
Type.Literal("set_dependencies"),
|
|
737
|
-
Type.Literal("cancel_task"),
|
|
738
|
-
Type.Literal("pause_task"),
|
|
739
|
-
Type.Literal("resume_task"),
|
|
740
|
-
Type.Literal("complete_task"),
|
|
741
|
-
Type.Literal("pause"),
|
|
742
|
-
Type.Literal("resume"),
|
|
743
|
-
Type.Literal("cancel"),
|
|
744
|
-
],
|
|
745
|
-
{ description: "Action to perform" },
|
|
746
|
-
),
|
|
747
|
-
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
748
|
-
depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
|
|
749
|
-
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
750
|
-
task: Type.Optional(
|
|
751
|
-
Type.Object({
|
|
752
|
-
id: Type.String(),
|
|
753
|
-
title: Type.String(),
|
|
754
|
-
description: Type.Optional(Type.String()),
|
|
755
|
-
agent: Type.String(),
|
|
756
|
-
depends: Type.Optional(Type.Array(Type.String())),
|
|
757
|
-
inheritContext: Type.Optional(Type.Boolean({ description: "Fork the current pi session so the agent inherits this conversation's context (see squad tool docs for caveats)" })),
|
|
758
|
-
}, { description: "Task definition for add_task" }),
|
|
759
|
-
),
|
|
760
|
-
}),
|
|
761
|
-
|
|
762
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
763
|
-
if (!squadEnabled) return disabledToolResult();
|
|
764
|
-
if (params.action === "cancel") {
|
|
765
|
-
if (!params.squadId?.trim()) {
|
|
766
|
-
return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
|
|
767
|
-
}
|
|
768
|
-
const squadId = params.squadId.trim();
|
|
769
|
-
if (!store.loadSquad(squadId)) {
|
|
770
|
-
return { content: [{ type: "text" as const, text: `Squad '${squadId}' not found; no squad was changed.` }], details: undefined };
|
|
771
|
-
}
|
|
772
|
-
try {
|
|
773
|
-
await cancelExactSquad(squadId);
|
|
774
|
-
} catch (error) {
|
|
775
|
-
return { content: [{ type: "text" as const, text: `Cancel failed for squad '${squadId}': ${(error as Error).message}` }], details: undefined };
|
|
776
|
-
}
|
|
777
|
-
return { content: [{ type: "text" as const, text: `Squad '${squadId}' cancelled.` }], details: undefined };
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (params.action === "resume") {
|
|
781
|
-
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
782
|
-
if (!squad) {
|
|
783
|
-
const text = params.squadId
|
|
784
|
-
? `Squad '${params.squadId}' not found.`
|
|
785
|
-
: "No paused, failed, or failed-review squad found to resume.";
|
|
786
|
-
return { content: [{ type: "text" as const, text }], details: undefined };
|
|
787
|
-
}
|
|
788
|
-
const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
|
|
789
|
-
try {
|
|
790
|
-
await resumeSched.resume();
|
|
791
|
-
} catch (err) {
|
|
792
|
-
return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
|
|
793
|
-
}
|
|
794
|
-
const tasks = store.loadAllTasks(squad.id);
|
|
795
|
-
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const squadId = params.squadId || activeSquadId;
|
|
799
|
-
if (!squadId || !store.loadSquad(squadId)) {
|
|
800
|
-
return { content: [{ type: "text" as const, text: params.squadId ? `Squad '${params.squadId}' not found.` : "No active squad. Provide squadId, select the squad, or start a new one." }], details: undefined };
|
|
801
|
-
}
|
|
802
|
-
let activeScheduler = schedulers.get(squadId) || null;
|
|
803
|
-
if (!activeScheduler && (params.action === "add_task" || params.action === "set_dependencies" || params.action === "cancel_task" || params.action === "resume_task" || params.action === "complete_task" || params.action === "pause_task")) {
|
|
804
|
-
activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
|
|
805
|
-
}
|
|
806
|
-
if (!activeScheduler) {
|
|
807
|
-
return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
|
|
808
|
-
}
|
|
809
|
-
focusSquad(squadId);
|
|
810
|
-
|
|
811
|
-
switch (params.action) {
|
|
812
|
-
case "add_task": {
|
|
813
|
-
if (!params.task) {
|
|
814
|
-
return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
|
|
815
|
-
}
|
|
816
|
-
// Validate against the live squad: deps must exist, agent must exist
|
|
817
|
-
const targetSquad = store.loadSquad(squadId)!;
|
|
818
|
-
if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
|
|
819
|
-
return { content: [{ type: "text" as const, text: `Invalid file-spec task id '${params.task.id}'. Use 1..64 lowercase letters/digits with internal hyphens.` }], details: undefined };
|
|
820
|
-
}
|
|
821
|
-
const existing = store.loadAllTasks(squadId);
|
|
822
|
-
const existingIds = new Set(existing.map((t) => t.id));
|
|
823
|
-
if (existingIds.has(params.task.id)) {
|
|
824
|
-
return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
|
|
825
|
-
}
|
|
826
|
-
const badDeps = (params.task.depends || []).filter((d) => !existingIds.has(d));
|
|
827
|
-
if (badDeps.length > 0) {
|
|
828
|
-
return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
|
|
829
|
-
}
|
|
830
|
-
const targetCwd = store.loadSquad(squadId)!.cwd;
|
|
831
|
-
if (!store.loadAgentDef(params.task.agent, targetCwd)) {
|
|
832
|
-
const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
|
|
833
|
-
return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
|
|
834
|
-
}
|
|
835
|
-
const dependencies = params.task.depends || [];
|
|
836
|
-
const task: Task = {
|
|
837
|
-
id: params.task.id,
|
|
838
|
-
title: params.task.title,
|
|
839
|
-
description: params.task.description || "",
|
|
840
|
-
agent: params.task.agent,
|
|
841
|
-
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
842
|
-
depends: dependencies,
|
|
843
|
-
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
844
|
-
...(targetSquad.spec ? { fileSpecDelta: true } : {}),
|
|
845
|
-
created: store.now(),
|
|
846
|
-
started: null,
|
|
847
|
-
completed: null,
|
|
848
|
-
output: null,
|
|
849
|
-
error: null,
|
|
850
|
-
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
851
|
-
};
|
|
852
|
-
try {
|
|
853
|
-
await activeScheduler.addTask(task);
|
|
854
|
-
} catch (err) {
|
|
855
|
-
return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
|
|
856
|
-
}
|
|
857
|
-
return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
case "set_dependencies": {
|
|
861
|
-
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
862
|
-
if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
|
|
863
|
-
try {
|
|
864
|
-
await activeScheduler.setDependencies(params.taskId, params.depends);
|
|
865
|
-
} catch (err) {
|
|
866
|
-
return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
|
|
867
|
-
}
|
|
868
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
case "cancel_task": {
|
|
872
|
-
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
873
|
-
try {
|
|
874
|
-
await activeScheduler.cancelTask(params.taskId);
|
|
875
|
-
} catch (err) {
|
|
876
|
-
return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
|
|
877
|
-
}
|
|
878
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
case "pause_task": {
|
|
882
|
-
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
883
|
-
await activeScheduler.pauseTask(params.taskId);
|
|
884
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' paused.` }], details: undefined };
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
case "resume_task": {
|
|
888
|
-
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
889
|
-
try {
|
|
890
|
-
const result = await activeScheduler.resumeTask(params.taskId);
|
|
891
|
-
if (result === "already_running") {
|
|
892
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' is already running in squad '${squadId}'; no duplicate resume was started.` }], details: undefined };
|
|
893
|
-
}
|
|
894
|
-
} catch (err) {
|
|
895
|
-
return { content: [{ type: "text" as const, text: `resume_task failed for task '${params.taskId}' in squad '${squadId}': ${(err as Error).message}` }], details: undefined };
|
|
896
|
-
}
|
|
897
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
case "complete_task": {
|
|
901
|
-
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
902
|
-
try {
|
|
903
|
-
await activeScheduler.completeTask(params.taskId, params.output);
|
|
904
|
-
} catch (err) {
|
|
905
|
-
return { content: [{ type: "text" as const, text: `complete_task failed: ${(err as Error).message}` }], details: undefined };
|
|
906
|
-
}
|
|
907
|
-
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' marked done — dependents unblocked and scheduled.` }], details: undefined };
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
case "pause": {
|
|
911
|
-
const squad = store.loadSquad(squadId);
|
|
912
|
-
if (squad) {
|
|
913
|
-
squad.status = "paused";
|
|
914
|
-
store.saveSquad(squad);
|
|
915
|
-
}
|
|
916
|
-
await activeScheduler.stop();
|
|
917
|
-
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// Note: "resume" and exact-ID "cancel" are handled above.
|
|
921
|
-
|
|
922
|
-
default:
|
|
923
|
-
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
|
|
924
|
-
}
|
|
925
|
-
},
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
// =========================================================================
|
|
929
|
-
// Session Lifecycle
|
|
930
|
-
// =========================================================================
|
|
931
|
-
|
|
932
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
933
|
-
// Re-read before touching focus, widgets, schedulers, or persisted work.
|
|
934
|
-
squadEnabled = store.loadSquadSettings().enabled;
|
|
935
|
-
uiCtx = ctx;
|
|
936
|
-
|
|
937
|
-
// A session replacement may not deliver shutdown first. Never carry a
|
|
938
|
-
// focused squad across projects, and never seed a new widget from stale state.
|
|
939
|
-
widgetControls?.dispose();
|
|
940
|
-
widgetControls = null;
|
|
941
|
-
registerTerminalPanelToggle(ctx);
|
|
942
|
-
if (!squadEnabled) {
|
|
943
|
-
widgetState.enabled = false;
|
|
944
|
-
focusSquad(null);
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
widgetState.enabled = true;
|
|
948
|
-
const focused = activeSquadId ? store.loadSquad(activeSquadId) : null;
|
|
949
|
-
focusSquad(focused?.cwd === ctx.cwd ? activeSquadId : null);
|
|
950
|
-
|
|
951
|
-
// Install component-based widget
|
|
952
|
-
if (ctx.hasUI) {
|
|
953
|
-
widgetControls = setupSquadWidget(ctx, widgetState);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Clean up orphaned squads from crashed sessions:
|
|
957
|
-
// If a squad is "running" but has no live scheduler, its parent died.
|
|
958
|
-
// Suspend in-progress tasks and mark the squad as paused so it doesn't
|
|
959
|
-
// block new squads or trigger confusing followUp messages.
|
|
960
|
-
const orphaned = store.findActiveSquads()
|
|
961
|
-
.filter((s) => s.cwd === ctx.cwd && s.status === "running");
|
|
962
|
-
for (const squad of orphaned) {
|
|
963
|
-
const tasks = store.loadAllTasks(squad.id);
|
|
964
|
-
let hadInProgress = false;
|
|
965
|
-
for (const task of tasks) {
|
|
966
|
-
if (task.status === "in_progress") {
|
|
967
|
-
store.updateTaskStatus(squad.id, task.id, "suspended");
|
|
968
|
-
hadInProgress = true;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
if (hadInProgress) {
|
|
972
|
-
squad.status = "paused";
|
|
973
|
-
store.saveSquad(squad);
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// Audit file-spec evidence on restart. Review/running/failed work is reopened;
|
|
978
|
-
// an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
|
|
979
|
-
for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
|
|
980
|
-
let scheduler = schedulers.get(squad.id);
|
|
981
|
-
if (!scheduler) { scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext); schedulers.set(squad.id, scheduler); wireSchedulerEvents(pi, scheduler, squad.id); }
|
|
982
|
-
const invalid = await scheduler.auditSpecAttestations();
|
|
983
|
-
if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Reconstruct schedulers for explicit-suspension stalls without resuming any
|
|
987
|
-
// task. Reconcile derives/persists a missing outbox record and emits only a
|
|
988
|
-
// pending fingerprint; delivered attention remains durable and silent.
|
|
989
|
-
const suspensionCandidates = store.listSquadsForProject(ctx.cwd)
|
|
990
|
-
.filter((squad) => Boolean(squad.suspendedStallAttention) || store.loadAllTasks(squad.id).some((task) => task.status === "suspended"));
|
|
991
|
-
for (const squad of suspensionCandidates) {
|
|
992
|
-
let scheduler = schedulers.get(squad.id);
|
|
993
|
-
if (!scheduler) {
|
|
994
|
-
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
995
|
-
schedulers.set(squad.id, scheduler);
|
|
996
|
-
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
997
|
-
}
|
|
998
|
-
await scheduler.start();
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// Mailbox recovery is automatic after extension/main-process restart. Scan
|
|
1002
|
-
// every project squad (including accepted done squads) because a crash can
|
|
1003
|
-
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
1004
|
-
const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
|
|
1005
|
-
.filter((squad) => store.loadAllTasks(squad.id)
|
|
1006
|
-
.some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
1007
|
-
.sort((a, b) => b.created.localeCompare(a.created));
|
|
1008
|
-
for (const squad of pendingMailSquads) {
|
|
1009
|
-
let scheduler = schedulers.get(squad.id);
|
|
1010
|
-
if (!scheduler) {
|
|
1011
|
-
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
1012
|
-
schedulers.set(squad.id, scheduler);
|
|
1013
|
-
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
1014
|
-
}
|
|
1015
|
-
await scheduler.start();
|
|
1016
|
-
}
|
|
1017
|
-
if (pendingMailSquads.length > 0) focusSquad(pendingMailSquads[0].id);
|
|
1018
|
-
|
|
1019
|
-
// Notify about paused squads only if they have real completed work
|
|
1020
|
-
const paused = store.findActiveSquads()
|
|
1021
|
-
.filter((s) => s.cwd === ctx.cwd && s.status === "paused");
|
|
1022
|
-
if (paused.length > 0) {
|
|
1023
|
-
const squad = paused[0];
|
|
1024
|
-
const tasks = store.loadAllTasks(squad.id);
|
|
1025
|
-
const done = tasks.filter(t => t.status === "done").length;
|
|
1026
|
-
// Only notify if at least 1 task completed — worth resuming
|
|
1027
|
-
if (done > 0) {
|
|
1028
|
-
pi.sendMessage({
|
|
1029
|
-
customType: "squad-paused",
|
|
1030
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
1031
|
-
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
1032
|
-
display: true,
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// Restore pending acceptance gates after a main-session restart. Review is
|
|
1038
|
-
// persisted state, not a one-shot completion message that can be missed.
|
|
1039
|
-
const pendingReviews = store.findActiveSquads()
|
|
1040
|
-
.filter((s) => s.cwd === ctx.cwd && s.status === "review");
|
|
1041
|
-
if (pendingReviews.length > 0) {
|
|
1042
|
-
const squad = pendingReviews.sort((a, b) => b.created.localeCompare(a.created))[0];
|
|
1043
|
-
focusSquad(squad.id);
|
|
1044
|
-
const tasks = store.loadAllTasks(squad.id);
|
|
1045
|
-
pi.sendMessage({
|
|
1046
|
-
customType: "squad-review-required",
|
|
1047
|
-
content: `[squad] Restored mandatory orchestrator review for "${squad.id}" after session restart. The work remains untrusted and not accepted.\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
|
|
1048
|
-
display: true,
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
});
|
|
1053
|
-
|
|
1054
|
-
pi.on("session_shutdown", async () => {
|
|
1055
|
-
focusSquad(null);
|
|
1056
|
-
widgetControls?.dispose();
|
|
1057
|
-
widgetControls = null;
|
|
1058
|
-
for (const [id, sched] of schedulers) {
|
|
1059
|
-
await sched.stop();
|
|
1060
|
-
}
|
|
1061
|
-
schedulers.clear();
|
|
1062
|
-
uiCtx = null;
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
// =========================================================================
|
|
1066
|
-
// Slash Commands
|
|
1067
|
-
// =========================================================================
|
|
1068
|
-
|
|
1069
|
-
pi.registerCommand("squad", {
|
|
1070
|
-
description: "Browse, select, and manage squads. Usage: /squad [list|all|select|resume|agents|msg|widget|panel|cancel|clear]",
|
|
1071
|
-
getArgumentCompletions: (prefix) => {
|
|
1072
|
-
const subs = [
|
|
1073
|
-
{ value: "list", label: "list", description: "List squads for current project" },
|
|
1074
|
-
{ value: "all", label: "all", description: "List all squads, select to activate" },
|
|
1075
|
-
{ value: "select", label: "select", description: "Pick a squad to view (interactive)" },
|
|
1076
|
-
{ value: "resume", label: "resume", description: "Resume an exact paused/failed/failed-review squad" },
|
|
1077
|
-
{ value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
|
|
1078
|
-
{ value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
|
|
1079
|
-
{ value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
|
|
1080
|
-
{ value: "msg", label: "msg", description: "Message a task: /squad msg [task-id|running-agent] text" },
|
|
1081
|
-
{ value: "widget", label: "widget", description: "Toggle live widget" },
|
|
1082
|
-
{ value: "panel", label: "panel", description: "Toggle overlay panel" },
|
|
1083
|
-
{ value: "cancel", label: "cancel", description: "Cancel running squad" },
|
|
1084
|
-
{ value: "clear", label: "clear", description: "Dismiss widget and deactivate squad" },
|
|
1085
|
-
{ value: "cleanup", label: "cleanup", description: "Delete squad data (select or all)" },
|
|
1086
|
-
{ value: "enable", label: "enable", description: "Enable pi-squad (tools, widget, system prompt)" },
|
|
1087
|
-
{ value: "disable", label: "disable", description: "Disable pi-squad completely" },
|
|
1088
|
-
];
|
|
1089
|
-
return subs.filter((s) => s.value.startsWith(prefix));
|
|
1090
|
-
},
|
|
1091
|
-
handler: async (args, ctx) => {
|
|
1092
|
-
const requested = args.trim();
|
|
1093
|
-
if (!squadEnabled && requested !== "enable" && requested !== "disable") {
|
|
1094
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
const parts = requested.split(/\s+/);
|
|
1098
|
-
const sub = parts[0] || "select";
|
|
1099
|
-
|
|
1100
|
-
switch (sub) {
|
|
1101
|
-
case "list": {
|
|
1102
|
-
const squads = store.listSquadsForProject(ctx.cwd);
|
|
1103
|
-
if (squads.length === 0) {
|
|
1104
|
-
ctx.ui.notify(`No squads for this project`, "info");
|
|
1105
|
-
return;
|
|
1106
|
-
}
|
|
1107
|
-
const selected = await pickSquad(ctx, squads);
|
|
1108
|
-
if (selected) activateSquadView(selected.id, ctx);
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
case "all": {
|
|
1113
|
-
const all = store.listSquads()
|
|
1114
|
-
.map((id) => store.loadSquad(id))
|
|
1115
|
-
.filter((s): s is Squad => s !== null)
|
|
1116
|
-
.sort((a, b) => b.created.localeCompare(a.created));
|
|
1117
|
-
if (all.length === 0) {
|
|
1118
|
-
ctx.ui.notify("No squads found", "info");
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
const selected = await pickSquad(ctx, all, true);
|
|
1122
|
-
if (selected) activateSquadView(selected.id, ctx);
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
case "select": {
|
|
1127
|
-
// Interactive selector — show project squads first, fall back to all
|
|
1128
|
-
let squads = store.listSquadsForProject(ctx.cwd);
|
|
1129
|
-
let showProject = false;
|
|
1130
|
-
if (squads.length === 0) {
|
|
1131
|
-
squads = store.listSquads()
|
|
1132
|
-
.map((id) => store.loadSquad(id))
|
|
1133
|
-
.filter((s): s is Squad => s !== null)
|
|
1134
|
-
.sort((a, b) => b.created.localeCompare(a.created));
|
|
1135
|
-
showProject = true;
|
|
1136
|
-
}
|
|
1137
|
-
if (squads.length === 0) {
|
|
1138
|
-
ctx.ui.notify("No squads found", "info");
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
|
-
// If only one, activate it directly
|
|
1142
|
-
if (squads.length === 1) {
|
|
1143
|
-
activateSquadView(squads[0].id, ctx);
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
const selected = await pickSquad(ctx, squads, showProject);
|
|
1147
|
-
if (selected) activateSquadView(selected.id, ctx);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
case "resume": {
|
|
1152
|
-
const squad = resolveResumeSquad(ctx.cwd, parts[1]);
|
|
1153
|
-
if (!squad) {
|
|
1154
|
-
ctx.ui.notify(parts[1] ? `Squad '${parts[1]}' not found` : "No paused, failed, or failed-review squad found", "warning");
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
const scheduler = ensureScheduler(pi, squad.id, squadSkillPaths);
|
|
1158
|
-
try {
|
|
1159
|
-
await scheduler.resume();
|
|
1160
|
-
} catch (error) {
|
|
1161
|
-
ctx.ui.notify(`Resume failed: ${(error as Error).message}`, "error");
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
const tasks = store.loadAllTasks(squad.id);
|
|
1165
|
-
ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
case "widget": {
|
|
1170
|
-
widgetState.enabled = !widgetState.enabled;
|
|
1171
|
-
if (widgetState.enabled) {
|
|
1172
|
-
if (!activeSquadId) {
|
|
1173
|
-
const latest = store.findLatestSquad(ctx.cwd);
|
|
1174
|
-
if (latest) activateSquadView(latest.id, ctx);
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
// requestUpdate handles both enable (renders) and disable (clears)
|
|
1178
|
-
widgetControls?.requestUpdate();
|
|
1179
|
-
ctx.ui.notify(`Squad widget ${widgetState.enabled ? "enabled" : "disabled"}`, "info");
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
case "panel": {
|
|
1184
|
-
// Activate latest squad if none active
|
|
1185
|
-
if (!activeSquadId) {
|
|
1186
|
-
const latest = store.findLatestSquad(ctx.cwd);
|
|
1187
|
-
if (latest) {
|
|
1188
|
-
activateSquadView(latest.id, ctx);
|
|
1189
|
-
} else {
|
|
1190
|
-
ctx.ui.notify("No squads found", "info");
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
if (activeSquadId) {
|
|
1195
|
-
const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
1196
|
-
openPanel(pi, ctx, sched, activeSquadId);
|
|
1197
|
-
}
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
case "msg": {
|
|
1202
|
-
if (!activeSquadId) {
|
|
1203
|
-
ctx.ui.notify("No active squad. Use /squad select first.", "info");
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
const msgSquad = store.loadSquad(activeSquadId);
|
|
1207
|
-
if (!msgSquad) return;
|
|
1208
|
-
// Parse: /squad msg [task-id|running-agent] message text
|
|
1209
|
-
const msgParts = parts.slice(1);
|
|
1210
|
-
let targetAgent: string | undefined;
|
|
1211
|
-
let msgText: string;
|
|
1212
|
-
|
|
1213
|
-
if (msgParts.length === 0) {
|
|
1214
|
-
// Interactive: ask for message
|
|
1215
|
-
const input = await ctx.ui.input("Message to squad agent", "Type your message...");
|
|
1216
|
-
if (!input) return;
|
|
1217
|
-
msgText = input;
|
|
1218
|
-
} else {
|
|
1219
|
-
// Check if first word is an agent name
|
|
1220
|
-
const maybeAgent = store.loadAgentDef(msgParts[0], msgSquad.cwd);
|
|
1221
|
-
if (maybeAgent && msgParts.length > 1) {
|
|
1222
|
-
targetAgent = msgParts[0];
|
|
1223
|
-
msgText = msgParts.slice(1).join(" ");
|
|
1224
|
-
} else {
|
|
1225
|
-
msgText = msgParts.join(" ");
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// Find one exact target task. A task ID can address completed or
|
|
1230
|
-
// stopped work; an agent name remains shorthand for a live task only.
|
|
1231
|
-
const msgTasks = store.loadAllTasks(activeSquadId);
|
|
1232
|
-
let targetTaskId: string | undefined;
|
|
1233
|
-
const explicitTask = msgParts.length > 1
|
|
1234
|
-
? msgTasks.find((task) => task.id === msgParts[0])
|
|
1235
|
-
: undefined;
|
|
1236
|
-
if (explicitTask) {
|
|
1237
|
-
targetTaskId = explicitTask.id;
|
|
1238
|
-
targetAgent = explicitTask.agent;
|
|
1239
|
-
msgText = msgParts.slice(1).join(" ");
|
|
1240
|
-
} else if (targetAgent) {
|
|
1241
|
-
const liveMatches = msgTasks.filter(
|
|
1242
|
-
(task) => task.agent === targetAgent && getActiveScheduler()?.getPool().isRunning(task.id),
|
|
1243
|
-
);
|
|
1244
|
-
if (liveMatches.length === 1) targetTaskId = liveMatches[0].id;
|
|
1245
|
-
if (!targetTaskId) {
|
|
1246
|
-
ctx.ui.notify(`Agent '${targetAgent}' is not working on exactly one live task; provide an exact task ID`, "warning");
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
} else {
|
|
1250
|
-
const liveTasks = msgTasks.filter((task) =>
|
|
1251
|
-
getActiveScheduler()?.getPool().isRunning(task.id),
|
|
1252
|
-
);
|
|
1253
|
-
if (liveTasks.length === 1) {
|
|
1254
|
-
targetTaskId = liveTasks[0].id;
|
|
1255
|
-
targetAgent = liveTasks[0].agent;
|
|
1256
|
-
} else {
|
|
1257
|
-
ctx.ui.notify("No unique live task to message; provide an exact task ID", "warning");
|
|
1258
|
-
return;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
let msgSched = getActiveScheduler();
|
|
1263
|
-
if (!msgSched) {
|
|
1264
|
-
msgSched = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
|
|
1265
|
-
schedulers.set(activeSquadId, msgSched);
|
|
1266
|
-
wireSchedulerEvents(pi, msgSched, activeSquadId);
|
|
1267
|
-
await msgSched.start();
|
|
1268
|
-
}
|
|
1269
|
-
const delivered = await msgSched.sendHumanMessage(targetTaskId, msgText);
|
|
1270
|
-
ctx.ui.notify(
|
|
1271
|
-
delivered ? `Sent to ${targetAgent}: "${msgText}"` : `Queued durably for ${targetTaskId}`,
|
|
1272
|
-
"info",
|
|
1273
|
-
);
|
|
1274
|
-
forceWidgetUpdate();
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
case "cancel": {
|
|
1279
|
-
const cancelledId = activeSquadId;
|
|
1280
|
-
if (!cancelledId) {
|
|
1281
|
-
ctx.ui.notify("No focused squad to cancel", "info");
|
|
1282
|
-
return;
|
|
1283
|
-
}
|
|
1284
|
-
await cancelExactSquad(cancelledId);
|
|
1285
|
-
ctx.ui.notify(`Squad '${cancelledId}' cancelled`, "info");
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
case "clear": {
|
|
1290
|
-
if (activeSquadId) schedulers.delete(activeSquadId);
|
|
1291
|
-
focusSquad(null);
|
|
1292
|
-
widgetControls?.dispose();
|
|
1293
|
-
ctx.ui.notify("Squad view cleared", "info");
|
|
1294
|
-
return;
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
case "cleanup": {
|
|
1298
|
-
const cleanupArg = parts[1];
|
|
1299
|
-
const allSquadIds = store.listSquads();
|
|
1300
|
-
|
|
1301
|
-
if (allSquadIds.length === 0) {
|
|
1302
|
-
ctx.ui.notify("No squads to clean up", "info");
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
if (cleanupArg === "all") {
|
|
1307
|
-
// Stop any running schedulers first
|
|
1308
|
-
for (const [id, sched] of schedulers) {
|
|
1309
|
-
await sched.stop();
|
|
1310
|
-
}
|
|
1311
|
-
schedulers.clear();
|
|
1312
|
-
focusSquad(null);
|
|
1313
|
-
|
|
1314
|
-
let count = 0;
|
|
1315
|
-
for (const id of allSquadIds) {
|
|
1316
|
-
fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
|
|
1317
|
-
count++;
|
|
1318
|
-
}
|
|
1319
|
-
ctx.ui.notify(`Deleted ${count} squad(s)`, "info");
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// Interactive: pick squads to delete
|
|
1324
|
-
const squads = allSquadIds
|
|
1325
|
-
.map((id) => store.loadSquad(id))
|
|
1326
|
-
.filter((s): s is Squad => s !== null)
|
|
1327
|
-
.sort((a, b) => b.created.localeCompare(a.created));
|
|
1328
|
-
|
|
1329
|
-
const options = [
|
|
1330
|
-
"🗑 Delete ALL squads",
|
|
1331
|
-
...squads.map((s) => {
|
|
1332
|
-
const tasks = store.loadAllTasks(s.id);
|
|
1333
|
-
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1334
|
-
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1335
|
-
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
|
|
1336
|
-
}),
|
|
1337
|
-
];
|
|
1338
|
-
|
|
1339
|
-
const choice = await ctx.ui.select("Delete squad data", options);
|
|
1340
|
-
if (!choice) return;
|
|
1341
|
-
|
|
1342
|
-
if (choice.startsWith("🗑")) {
|
|
1343
|
-
// Delete all
|
|
1344
|
-
for (const [id, sched] of schedulers) {
|
|
1345
|
-
await sched.stop();
|
|
1346
|
-
}
|
|
1347
|
-
schedulers.clear();
|
|
1348
|
-
focusSquad(null);
|
|
1349
|
-
let count = 0;
|
|
1350
|
-
for (const id of allSquadIds) {
|
|
1351
|
-
fs.rmSync(store.getSquadDir(id), { recursive: true, force: true });
|
|
1352
|
-
count++;
|
|
1353
|
-
}
|
|
1354
|
-
ctx.ui.notify(`Deleted ${count} squad(s)`, "info");
|
|
1355
|
-
} else {
|
|
1356
|
-
// Delete selected
|
|
1357
|
-
const idx = options.indexOf(choice) - 1; // -1 for the "Delete ALL" option
|
|
1358
|
-
if (idx >= 0 && idx < squads.length) {
|
|
1359
|
-
const squad = squads[idx];
|
|
1360
|
-
// Stop scheduler if running
|
|
1361
|
-
const sched = schedulers.get(squad.id);
|
|
1362
|
-
if (sched) {
|
|
1363
|
-
await sched.stop();
|
|
1364
|
-
schedulers.delete(squad.id);
|
|
1365
|
-
}
|
|
1366
|
-
if (activeSquadId === squad.id) focusSquad(null);
|
|
1367
|
-
fs.rmSync(store.getSquadDir(squad.id), { recursive: true, force: true });
|
|
1368
|
-
ctx.ui.notify(`Deleted: ${squad.id}`, "info");
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
return;
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
case "enable": {
|
|
1375
|
-
const wasEnabled = squadEnabled;
|
|
1376
|
-
try {
|
|
1377
|
-
const settings = store.loadSquadSettings();
|
|
1378
|
-
settings.enabled = true;
|
|
1379
|
-
store.saveSquadSettings(settings);
|
|
1380
|
-
} catch (error) {
|
|
1381
|
-
ctx.ui.notify(`Could not enable pi-squad: ${(error as Error).message}`, "error");
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
squadEnabled = true;
|
|
1385
|
-
widgetState.enabled = true;
|
|
1386
|
-
if (!wasEnabled) widgetState.squadId = null;
|
|
1387
|
-
if (!widgetControls && uiCtx?.hasUI) widgetControls = setupSquadWidget(uiCtx, widgetState);
|
|
1388
|
-
widgetControls?.requestUpdate();
|
|
1389
|
-
ctx.ui.notify("pi-squad enabled. No suspended work was resumed; use /squad select and an explicit /squad resume <id> or exact resume_task if needed.", "info");
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
case "disable": {
|
|
1394
|
-
if (!squadEnabled) {
|
|
1395
|
-
ctx.ui.notify("pi-squad is already disabled. Run /squad enable to use squad operations.", "info");
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1398
|
-
try {
|
|
1399
|
-
const settings = store.loadSquadSettings();
|
|
1400
|
-
settings.enabled = false;
|
|
1401
|
-
store.saveSquadSettings(settings);
|
|
1402
|
-
} catch (error) {
|
|
1403
|
-
ctx.ui.notify(`Could not disable pi-squad: ${(error as Error).message}`, "error");
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
// The persisted state is authoritative before shutdown begins.
|
|
1407
|
-
squadEnabled = false;
|
|
1408
|
-
closeOverlay?.();
|
|
1409
|
-
for (const sched of schedulers.values()) await sched.stop();
|
|
1410
|
-
schedulers.clear();
|
|
1411
|
-
widgetState.enabled = false;
|
|
1412
|
-
widgetState.squadId = null;
|
|
1413
|
-
focusSquad(null);
|
|
1414
|
-
widgetControls?.dispose();
|
|
1415
|
-
widgetControls = null;
|
|
1416
|
-
ctx.ui.notify("pi-squad disabled. Running work was durably suspended; run /squad enable before any squad operation.", "info");
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
case "defaults": {
|
|
1421
|
-
const settings = store.loadSquadSettings();
|
|
1422
|
-
const mainModel = getMainSessionModel() || "(unknown)";
|
|
1423
|
-
const mainThinking = getMainSessionThinking() || "(unknown)";
|
|
1424
|
-
const fmtPolicy = (v: string, live: string) =>
|
|
1425
|
-
v === "main" ? `follow main session (now: ${live})` : v === "pi-default" ? "pi default" : v;
|
|
1426
|
-
|
|
1427
|
-
const which = await ctx.ui.select(
|
|
1428
|
-
`Squad defaults — model: ${fmtPolicy(settings.defaultModel, mainModel)} | thinking: ${fmtPolicy(settings.defaultThinking, mainThinking)}`,
|
|
1429
|
-
["Change default model", "Change default thinking", "Cancel"],
|
|
1430
|
-
);
|
|
1431
|
-
if (!which || which === "Cancel") return;
|
|
1432
|
-
|
|
1433
|
-
if (which === "Change default model") {
|
|
1434
|
-
const choice = await ctx.ui.select("Default model for squad agents", [
|
|
1435
|
-
`Follow main session (now: ${mainModel})`,
|
|
1436
|
-
"pi default (child pi resolves its own)",
|
|
1437
|
-
"Custom model…",
|
|
1438
|
-
]);
|
|
1439
|
-
if (!choice) return;
|
|
1440
|
-
if (choice.startsWith("Follow")) settings.defaultModel = "main";
|
|
1441
|
-
else if (choice.startsWith("pi default")) settings.defaultModel = "pi-default";
|
|
1442
|
-
else {
|
|
1443
|
-
const custom = await ctx.ui.input("Model id (e.g. openai-codex/gpt-5.6-terra)", settings.defaultModel === "main" || settings.defaultModel === "pi-default" ? "" : settings.defaultModel);
|
|
1444
|
-
if (!custom || !custom.trim()) return;
|
|
1445
|
-
settings.defaultModel = custom.trim();
|
|
1446
|
-
}
|
|
1447
|
-
store.saveSquadSettings(settings);
|
|
1448
|
-
ctx.ui.notify(`Squad default model → ${fmtPolicy(settings.defaultModel, mainModel)}`, "info");
|
|
1449
|
-
} else {
|
|
1450
|
-
const choice = await ctx.ui.select("Default thinking for squad agents", [
|
|
1451
|
-
`Follow main session (now: ${mainThinking})`,
|
|
1452
|
-
"pi default (child pi resolves its own)",
|
|
1453
|
-
...THINKING_LEVELS,
|
|
1454
|
-
]);
|
|
1455
|
-
if (!choice) return;
|
|
1456
|
-
if (choice.startsWith("Follow")) settings.defaultThinking = "main";
|
|
1457
|
-
else if (choice.startsWith("pi default")) settings.defaultThinking = "pi-default";
|
|
1458
|
-
else settings.defaultThinking = choice;
|
|
1459
|
-
store.saveSquadSettings(settings);
|
|
1460
|
-
ctx.ui.notify(`Squad default thinking → ${fmtPolicy(settings.defaultThinking, mainThinking)}`, "info");
|
|
1461
|
-
}
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
case "advisor": {
|
|
1466
|
-
const settings = store.loadSquadSettings();
|
|
1467
|
-
const adv = settings.advisor;
|
|
1468
|
-
const mainModelLabel = getMainSessionModel() || "(unknown)";
|
|
1469
|
-
const modelLabel = adv.model === "main" ? `main session (now: ${mainModelLabel})` : adv.model;
|
|
1470
|
-
|
|
1471
|
-
const choice = await ctx.ui.select(
|
|
1472
|
-
`Squad advisor — ${adv.enabled ? "ON" : "OFF"} | model: ${modelLabel} | ${adv.maxCallsPerTask} calls/task, ${adv.reasoning} reasoning`,
|
|
1473
|
-
[adv.enabled ? "Disable advisor" : "Enable advisor", "Change advisor model", "Change max calls per task", "Change reasoning effort", "Cancel"],
|
|
1474
|
-
);
|
|
1475
|
-
if (!choice || choice === "Cancel") return;
|
|
1476
|
-
|
|
1477
|
-
if (choice.startsWith("Disable") || choice.startsWith("Enable")) {
|
|
1478
|
-
adv.enabled = !adv.enabled;
|
|
1479
|
-
ctx.ui.notify(`Squad advisor ${adv.enabled ? "enabled — stuck agents get a strong-model rescue before escalating" : "disabled — stuck agents escalate directly"}`, "info");
|
|
1480
|
-
} else if (choice === "Change advisor model") {
|
|
1481
|
-
const sel = await ctx.ui.select("Advisor model", [`Follow main session (now: ${mainModelLabel})`, "Custom model…"]);
|
|
1482
|
-
if (!sel) return;
|
|
1483
|
-
if (sel.startsWith("Follow")) adv.model = "main";
|
|
1484
|
-
else {
|
|
1485
|
-
const custom = await ctx.ui.input("Advisor model (provider/id)", adv.model === "main" ? "" : adv.model);
|
|
1486
|
-
if (!custom || !custom.trim()) return;
|
|
1487
|
-
adv.model = custom.trim();
|
|
1488
|
-
}
|
|
1489
|
-
ctx.ui.notify(`Advisor model → ${adv.model}`, "info");
|
|
1490
|
-
} else if (choice === "Change max calls per task") {
|
|
1491
|
-
const n = await ctx.ui.input("Max advisor calls per task", String(adv.maxCallsPerTask));
|
|
1492
|
-
const parsed = n ? Number.parseInt(n, 10) : NaN;
|
|
1493
|
-
if (!Number.isFinite(parsed) || parsed < 0) return;
|
|
1494
|
-
adv.maxCallsPerTask = parsed;
|
|
1495
|
-
ctx.ui.notify(`Advisor max calls/task → ${parsed}`, "info");
|
|
1496
|
-
} else {
|
|
1497
|
-
const lvl = await ctx.ui.select("Advisor reasoning effort", ["minimal", "low", "medium", "high", "xhigh"]);
|
|
1498
|
-
if (!lvl) return;
|
|
1499
|
-
adv.reasoning = lvl;
|
|
1500
|
-
ctx.ui.notify(`Advisor reasoning → ${lvl}`, "info");
|
|
1501
|
-
}
|
|
1502
|
-
store.saveSquadSettings(settings);
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
case "agents": {
|
|
1507
|
-
const agentArg = parts[1];
|
|
1508
|
-
const allAgents = store.loadAllAgentDefs(ctx.cwd);
|
|
1509
|
-
|
|
1510
|
-
if (!agentArg) {
|
|
1511
|
-
// List all agents — interactive selector
|
|
1512
|
-
if (allAgents.length === 0) {
|
|
1513
|
-
ctx.ui.notify("No agents found", "info");
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
const options = allAgents.map((a) => {
|
|
1517
|
-
const model = a.model ? ` [${a.model}${a.thinking ? `:${a.thinking}` : ""}]` : a.thinking ? ` [default:${a.thinking}]` : " [default]";
|
|
1518
|
-
const status = a.disabled ? " ✗ disabled" : "";
|
|
1519
|
-
return `${a.name} — ${a.role}${model}${status}`;
|
|
1520
|
-
});
|
|
1521
|
-
const choice = await ctx.ui.select("Squad Agents (select to view/edit)", options);
|
|
1522
|
-
if (!choice) return;
|
|
1523
|
-
const selectedName = choice.split(" — ")[0];
|
|
1524
|
-
const agent = allAgents.find((a) => a.name === selectedName);
|
|
1525
|
-
if (!agent) return;
|
|
1526
|
-
|
|
1527
|
-
// Show agent details and offer actions
|
|
1528
|
-
const disableLabel = agent.disabled ? "Enable agent" : "Disable agent";
|
|
1529
|
-
const actions = [
|
|
1530
|
-
"View details",
|
|
1531
|
-
"Edit in editor",
|
|
1532
|
-
"Change model",
|
|
1533
|
-
"Change thinking",
|
|
1534
|
-
"Toggle tools (restrict/unrestrict)",
|
|
1535
|
-
disableLabel,
|
|
1536
|
-
"Cancel",
|
|
1537
|
-
];
|
|
1538
|
-
const action = await ctx.ui.select(`${agent.name} (${agent.role})`, actions);
|
|
1539
|
-
if (!action || action === "Cancel") return;
|
|
1540
|
-
|
|
1541
|
-
if (action === "View details") {
|
|
1542
|
-
const details = [
|
|
1543
|
-
`Name: ${agent.name}`,
|
|
1544
|
-
`Role: ${agent.role}`,
|
|
1545
|
-
`Description: ${agent.description}`,
|
|
1546
|
-
`Model: ${agent.model || "(default)"}`,
|
|
1547
|
-
`Thinking: ${agent.thinking || "(default)"}`,
|
|
1548
|
-
`Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
|
|
1549
|
-
`Tags: ${agent.tags.join(", ")}`,
|
|
1550
|
-
``,
|
|
1551
|
-
`Prompt:`,
|
|
1552
|
-
agent.prompt,
|
|
1553
|
-
``,
|
|
1554
|
-
`File: ${store.getGlobalAgentsDir()}/${agent.name}.json`,
|
|
1555
|
-
].join("\n");
|
|
1556
|
-
ctx.ui.notify(details, "info");
|
|
1557
|
-
} else if (action === "Edit in editor") {
|
|
1558
|
-
// Check for local override first, fall back to global
|
|
1559
|
-
const localPath = `${store.getLocalAgentsDir(ctx.cwd)}/${agent.name}.json`;
|
|
1560
|
-
const globalPath = `${store.getGlobalAgentsDir()}/${agent.name}.json`;
|
|
1561
|
-
const filePath = fs.existsSync(localPath) ? localPath : globalPath;
|
|
1562
|
-
pi.sendMessage({
|
|
1563
|
-
customType: "squad-edit-agent",
|
|
1564
|
-
content: `Edit agent file: ${filePath}`,
|
|
1565
|
-
display: true,
|
|
1566
|
-
}, { triggerTurn: true });
|
|
1567
|
-
} else if (action === "Change model") {
|
|
1568
|
-
const newModel = await ctx.ui.input(
|
|
1569
|
-
`Model for ${agent.name} (empty = default)`,
|
|
1570
|
-
agent.model || "",
|
|
1571
|
-
);
|
|
1572
|
-
if (newModel !== undefined) {
|
|
1573
|
-
agent.model = newModel.trim() || null;
|
|
1574
|
-
store.saveAgentDef(agent);
|
|
1575
|
-
ctx.ui.notify(`${agent.name} model → ${agent.model || "(default)"}`, "info");
|
|
1576
|
-
}
|
|
1577
|
-
} else if (action === "Change thinking") {
|
|
1578
|
-
const levels = ["(default)", ...THINKING_LEVELS];
|
|
1579
|
-
const level = await ctx.ui.select(`Thinking level for ${agent.name}`, levels);
|
|
1580
|
-
if (level !== undefined) {
|
|
1581
|
-
agent.thinking = level === "(default)" ? null : level;
|
|
1582
|
-
store.saveAgentDef(agent);
|
|
1583
|
-
ctx.ui.notify(`${agent.name} thinking → ${agent.thinking || "(default)"}`, "info");
|
|
1584
|
-
}
|
|
1585
|
-
} else if (action === disableLabel) {
|
|
1586
|
-
agent.disabled = !agent.disabled;
|
|
1587
|
-
store.saveAgentDef(agent);
|
|
1588
|
-
const newState = agent.disabled ? "disabled — planner will not assign tasks to this agent" : "enabled";
|
|
1589
|
-
ctx.ui.notify(`${agent.name}: ${newState}`, "info");
|
|
1590
|
-
} else if (action === "Toggle tools") {
|
|
1591
|
-
if (agent.tools) {
|
|
1592
|
-
agent.tools = null;
|
|
1593
|
-
store.saveAgentDef(agent);
|
|
1594
|
-
ctx.ui.notify(`${agent.name}: all tools enabled`, "info");
|
|
1595
|
-
} else {
|
|
1596
|
-
const toolList = await ctx.ui.input(
|
|
1597
|
-
`Tools for ${agent.name} (comma-separated)`,
|
|
1598
|
-
"bash,read,write,edit",
|
|
1599
|
-
);
|
|
1600
|
-
if (toolList) {
|
|
1601
|
-
agent.tools = toolList.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1602
|
-
store.saveAgentDef(agent);
|
|
1603
|
-
ctx.ui.notify(`${agent.name}: tools = [${agent.tools.join(", ")}]`, "info");
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
return;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
// /squad agents <name> — show specific agent
|
|
1611
|
-
const agent = store.loadAgentDef(agentArg, ctx.cwd);
|
|
1612
|
-
if (agent) {
|
|
1613
|
-
const status = agent.disabled ? " ✗ DISABLED" : "";
|
|
1614
|
-
const details = [
|
|
1615
|
-
`${agent.name} — ${agent.role}${status}`,
|
|
1616
|
-
`${agent.description}`,
|
|
1617
|
-
`Model: ${agent.model || "(default)"}`,
|
|
1618
|
-
`Thinking: ${agent.thinking || "(default)"}`,
|
|
1619
|
-
`Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
|
|
1620
|
-
`Tags: ${agent.tags.join(", ")}`,
|
|
1621
|
-
].join("\n");
|
|
1622
|
-
ctx.ui.notify(details, "info");
|
|
1623
|
-
} else {
|
|
1624
|
-
ctx.ui.notify(`Agent '${agentArg}' not found`, "warning");
|
|
1625
|
-
}
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
default:
|
|
1630
|
-
// Treat as a squad ID — try to activate it directly
|
|
1631
|
-
const direct = store.loadSquad(sub);
|
|
1632
|
-
if (direct) {
|
|
1633
|
-
activateSquadView(direct.id, ctx);
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, resume, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
|
|
1637
|
-
}
|
|
1638
|
-
},
|
|
1639
|
-
});
|
|
1640
|
-
|
|
43
|
+
registerTools(pi, squadSkillPaths);
|
|
44
|
+
registerLifecycle(pi, squadSkillPaths);
|
|
45
|
+
registerCommands(pi, squadSkillPaths);
|
|
1641
46
|
}
|
|
1642
47
|
|
|
1643
|
-
// ============================================================================
|
|
1644
|
-
// Squad Selection & Activation
|
|
1645
|
-
// ============================================================================
|
|
1646
|
-
|
|
1647
|
-
/**
|
|
1648
|
-
* Show an interactive selector to pick a squad.
|
|
1649
|
-
* Returns the selected squad or undefined if cancelled.
|
|
1650
|
-
*/
|
|
1651
|
-
async function pickSquad(
|
|
1652
|
-
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext,
|
|
1653
|
-
squads: Squad[],
|
|
1654
|
-
showProject = false,
|
|
1655
|
-
): Promise<Squad | undefined> {
|
|
1656
|
-
if (squads.length === 0) return undefined;
|
|
1657
|
-
|
|
1658
|
-
const options = squads.map((s) => {
|
|
1659
|
-
const tasks = store.loadAllTasks(s.id);
|
|
1660
|
-
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1661
|
-
const review = getReviewPresentation(s);
|
|
1662
|
-
const icon = review?.icon ?? (s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·");
|
|
1663
|
-
const acceptance = review ? ` ${review.label}` : ` [${s.status}]`;
|
|
1664
|
-
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1665
|
-
return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1666
|
-
});
|
|
1667
|
-
|
|
1668
|
-
const choice = await ctx.ui.select("Select a squad", options);
|
|
1669
|
-
if (choice === undefined) return undefined;
|
|
1670
|
-
|
|
1671
|
-
const idx = options.indexOf(choice);
|
|
1672
|
-
return idx >= 0 ? squads[idx] : undefined;
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
/**
|
|
1676
|
-
* Activate a squad for viewing in this session.
|
|
1677
|
-
* Sets activeSquadId, starts widget, shows notification.
|
|
1678
|
-
* Does NOT start a scheduler (view-only unless squad needs resuming).
|
|
1679
|
-
*/
|
|
1680
|
-
function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext): void {
|
|
1681
|
-
if (!squadEnabled) {
|
|
1682
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
1683
|
-
return;
|
|
1684
|
-
}
|
|
1685
|
-
const squad = store.loadSquad(squadId);
|
|
1686
|
-
if (!squad) {
|
|
1687
|
-
ctx.ui.notify(`Squad '${squadId}' not found`, "error");
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
// Selection is one atomic focus operation: panel, widget, status, and tools
|
|
1692
|
-
// must all target this exact squad before control returns to the caller.
|
|
1693
|
-
focusSquad(squadId);
|
|
1694
|
-
|
|
1695
|
-
// Compact notification — widget already shows full task details.
|
|
1696
|
-
// Avoid large multi-line notifications that can break TUI layout.
|
|
1697
|
-
const tasks = store.loadAllTasks(squadId);
|
|
1698
|
-
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1699
|
-
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// ============================================================================
|
|
1703
|
-
// Widget — component-based, event-driven (inspired by pi-interactive-shell)
|
|
1704
|
-
// ============================================================================
|
|
1705
|
-
|
|
1706
|
-
/** Trigger widget re-render from scheduler events */
|
|
1707
|
-
function forceWidgetUpdate(): void {
|
|
1708
|
-
widgetControls?.requestUpdate();
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
/** Wire one scheduler to durable main-session notifications. */
|
|
1712
|
-
function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
|
|
1713
|
-
scheduler.onEvent((event: SchedulerEvent) => {
|
|
1714
|
-
// Durable scheduler state remains authoritative, but disabled mode suppresses
|
|
1715
|
-
// late operational UI, acknowledgements, focus, and ordinary notifications.
|
|
1716
|
-
if (!squadEnabled) return;
|
|
1717
|
-
forceWidgetUpdate();
|
|
1718
|
-
switch (event.type) {
|
|
1719
|
-
case "squad_review_required": {
|
|
1720
|
-
const tasks = store.loadAllTasks(squadId);
|
|
1721
|
-
const summary = buildCompletionSummary(tasks);
|
|
1722
|
-
const totalCost = tasks.reduce((sum, task) => sum + task.usage.cost, 0);
|
|
1723
|
-
scheduler.updateContext();
|
|
1724
|
-
const squad = store.loadSquad(squadId);
|
|
1725
|
-
if (!squad) break;
|
|
1726
|
-
pi.sendMessage({
|
|
1727
|
-
customType: "squad-review-required",
|
|
1728
|
-
content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
|
|
1729
|
-
`Squad claims (review inputs only):\n${summary}\n\n` +
|
|
1730
|
-
`Total cost: $${totalCost.toFixed(4)}\n\n` +
|
|
1731
|
-
buildOrchestratorReviewGate(squad, tasks),
|
|
1732
|
-
display: true,
|
|
1733
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1734
|
-
// Keep the settled scheduler addressable. A later exact-task message
|
|
1735
|
-
// can reopen the task immediately on its bound durable Pi session.
|
|
1736
|
-
break;
|
|
1737
|
-
}
|
|
1738
|
-
case "suspended_stall": {
|
|
1739
|
-
try {
|
|
1740
|
-
const attention = event.data as SuspendedStallAttention;
|
|
1741
|
-
pi.sendMessage({
|
|
1742
|
-
customType: `squad-suspended-stall:${squadId}:${attention.fingerprint}`,
|
|
1743
|
-
content: formatSuspendedStallAttention(squadId, attention),
|
|
1744
|
-
display: true,
|
|
1745
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1746
|
-
scheduler.acknowledgeSuspendedStall(attention.fingerprint);
|
|
1747
|
-
} catch (error) {
|
|
1748
|
-
logError("squad-scheduler", `suspended-stall delivery failed for ${squadId}: ${(error as Error).message}`);
|
|
1749
|
-
}
|
|
1750
|
-
break;
|
|
1751
|
-
}
|
|
1752
|
-
case "squad_failed": {
|
|
1753
|
-
const tasks = store.loadAllTasks(squadId);
|
|
1754
|
-
const failed = tasks.filter((task) => task.status === "failed");
|
|
1755
|
-
pi.sendMessage({
|
|
1756
|
-
customType: "squad-failed",
|
|
1757
|
-
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1758
|
-
`${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
|
|
1759
|
-
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1760
|
-
"Use squad_status for details or squad_modify to adjust.",
|
|
1761
|
-
display: true,
|
|
1762
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1763
|
-
break;
|
|
1764
|
-
}
|
|
1765
|
-
case "orchestrator_reply":
|
|
1766
|
-
pi.sendMessage({
|
|
1767
|
-
customType: "squad-agent-reply",
|
|
1768
|
-
content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
|
|
1769
|
-
display: true,
|
|
1770
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1771
|
-
break;
|
|
1772
|
-
case "escalation":
|
|
1773
|
-
pi.sendMessage({
|
|
1774
|
-
customType: "squad-escalation",
|
|
1775
|
-
content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
|
|
1776
|
-
`${event.message}\n\nReply to me and I'll forward your answer, or use the squad panel.`,
|
|
1777
|
-
display: true,
|
|
1778
|
-
}, { triggerTurn: true });
|
|
1779
|
-
break;
|
|
1780
|
-
}
|
|
1781
|
-
});
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
// ============================================================================
|
|
1785
|
-
// Panel — overlay via ctx.ui.custom() with proper done() lifecycle
|
|
1786
|
-
// ============================================================================
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* Open the squad panel overlay.
|
|
1790
|
-
* Uses the pi-interactive-shell pattern: ctx.ui.custom() returns a Promise
|
|
1791
|
-
* that resolves when done() is called. The panel calls done() on close.
|
|
1792
|
-
*/
|
|
1793
|
-
function openPanel(
|
|
1794
|
-
pi: ExtensionAPI,
|
|
1795
|
-
ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
|
|
1796
|
-
scheduler: Scheduler,
|
|
1797
|
-
squadId: string,
|
|
1798
|
-
): void {
|
|
1799
|
-
if (!squadEnabled) {
|
|
1800
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
1801
|
-
return;
|
|
1802
|
-
}
|
|
1803
|
-
if (overlayOpen) return;
|
|
1804
|
-
// Opening details also establishes authoritative focus. This covers callers
|
|
1805
|
-
// that reconstructed or targeted a scheduler without going through selector UI.
|
|
1806
|
-
focusSquad(squadId);
|
|
1807
|
-
overlayOpen = true;
|
|
1808
|
-
|
|
1809
|
-
// The promise resolves when the panel calls done()
|
|
1810
|
-
const panelPromise = ctx.ui.custom<SquadPanelResult>(
|
|
1811
|
-
(tui, theme, _kb, done) => {
|
|
1812
|
-
const panel = new SquadPanel(
|
|
1813
|
-
tui,
|
|
1814
|
-
theme,
|
|
1815
|
-
scheduler,
|
|
1816
|
-
squadId,
|
|
1817
|
-
done,
|
|
1818
|
-
() => squadEnabled,
|
|
1819
|
-
() => ctx.ui.notify(DISABLED_GUIDANCE, "warning"),
|
|
1820
|
-
);
|
|
1821
|
-
closeOverlay = () => panel.close();
|
|
1822
|
-
|
|
1823
|
-
// Wire up message sending from panel
|
|
1824
|
-
panel.onSendMessage = async (taskId: string, _prefill: string) => {
|
|
1825
|
-
if (!squadEnabled) {
|
|
1826
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
1827
|
-
return;
|
|
1828
|
-
}
|
|
1829
|
-
const task = store.loadTask(squadId, taskId);
|
|
1830
|
-
const agentName = task?.agent || taskId;
|
|
1831
|
-
const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
|
|
1832
|
-
if (!squadEnabled) {
|
|
1833
|
-
ctx.ui.notify(DISABLED_GUIDANCE, "warning");
|
|
1834
|
-
return;
|
|
1835
|
-
}
|
|
1836
|
-
if (input) {
|
|
1837
|
-
let panelSched = schedulers.get(squadId);
|
|
1838
|
-
if (!panelSched) {
|
|
1839
|
-
panelSched = scheduler;
|
|
1840
|
-
schedulers.set(squadId, panelSched);
|
|
1841
|
-
wireSchedulerEvents(pi, panelSched, squadId);
|
|
1842
|
-
await panelSched.start();
|
|
1843
|
-
}
|
|
1844
|
-
const delivered = await panelSched.sendHumanMessage(taskId, input);
|
|
1845
|
-
ctx.ui.notify(
|
|
1846
|
-
delivered ? `Sent to ${agentName}: "${input}"` : `Queued durably for ${taskId}`,
|
|
1847
|
-
"info",
|
|
1848
|
-
);
|
|
1849
|
-
}
|
|
1850
|
-
tui.requestRender();
|
|
1851
|
-
};
|
|
1852
|
-
|
|
1853
|
-
return panel;
|
|
1854
|
-
},
|
|
1855
|
-
{
|
|
1856
|
-
overlay: true,
|
|
1857
|
-
overlayOptions: {
|
|
1858
|
-
anchor: "center" as const,
|
|
1859
|
-
width: "80%" as const,
|
|
1860
|
-
maxHeight: "80%" as const,
|
|
1861
|
-
margin: 2,
|
|
1862
|
-
},
|
|
1863
|
-
},
|
|
1864
|
-
);
|
|
1865
|
-
|
|
1866
|
-
// When panel closes (done() called), clean up
|
|
1867
|
-
panelPromise.then(() => {
|
|
1868
|
-
overlayOpen = false;
|
|
1869
|
-
closeOverlay = null;
|
|
1870
|
-
forceWidgetUpdate();
|
|
1871
|
-
}).catch(() => {
|
|
1872
|
-
overlayOpen = false;
|
|
1873
|
-
closeOverlay = null;
|
|
1874
|
-
});
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
// ============================================================================
|
|
1878
|
-
// Start Squad
|
|
1879
|
-
// ============================================================================
|
|
1880
|
-
|
|
1881
|
-
async function startSquad(
|
|
1882
|
-
squadId: string,
|
|
1883
|
-
params: InlineSquadStart,
|
|
1884
|
-
cwd: string,
|
|
1885
|
-
skillPaths: string[],
|
|
1886
|
-
pi: ExtensionAPI,
|
|
1887
|
-
sessionFile: string | null = null,
|
|
1888
|
-
preparedSpec?: PreparedSpec,
|
|
1889
|
-
) {
|
|
1890
|
-
let plan: PlannerOutput;
|
|
1891
|
-
|
|
1892
|
-
if (params.tasks && params.tasks.length > 0) {
|
|
1893
|
-
// User provided a plan — use it directly
|
|
1894
|
-
plan = {
|
|
1895
|
-
agents: params.agents || {},
|
|
1896
|
-
tasks: params.tasks.map((t) => ({
|
|
1897
|
-
...t,
|
|
1898
|
-
description: t.description || "",
|
|
1899
|
-
depends: t.depends || [],
|
|
1900
|
-
})),
|
|
1901
|
-
};
|
|
1902
|
-
|
|
1903
|
-
// Validate agent names — remap unknown agents to fullstack
|
|
1904
|
-
for (const task of plan.tasks) {
|
|
1905
|
-
const agentDef = store.loadAgentDef(task.agent, cwd);
|
|
1906
|
-
if (!agentDef || agentDef.disabled) {
|
|
1907
|
-
if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
|
|
1908
|
-
const original = task.agent;
|
|
1909
|
-
task.agent = "fullstack";
|
|
1910
|
-
task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
} else {
|
|
1914
|
-
// Run planner to generate task breakdown (squad default policy as fallback)
|
|
1915
|
-
try {
|
|
1916
|
-
const defaults = resolveSquadDefaults();
|
|
1917
|
-
plan = await runPlanner({ goal: params.goal, cwd, fallbackModel: defaults.model, fallbackThinking: defaults.thinking });
|
|
1918
|
-
} catch (error) {
|
|
1919
|
-
// Throwing marks the tool result as an error for the LLM (returning isError is ignored in current pi)
|
|
1920
|
-
throw new Error(`Failed to plan: ${(error as Error).message}`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// A planner may take long enough for /squad disable to run concurrently.
|
|
1925
|
-
// Re-check before publishing any squad or constructing a scheduler.
|
|
1926
|
-
if (!squadEnabled) return disabledToolResult();
|
|
1927
|
-
|
|
1928
|
-
// Merge agent roster
|
|
1929
|
-
const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
|
|
1930
|
-
if (params.agents) {
|
|
1931
|
-
for (const [name, entry] of Object.entries(params.agents)) {
|
|
1932
|
-
agents[name] = { ...agents[name], ...entry };
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// Validate the plan — same enforcement for main-session and planner plans.
|
|
1937
|
-
// Errors block squad creation; warnings are reported back to the plan author.
|
|
1938
|
-
const validation = validatePlan(plan.tasks);
|
|
1939
|
-
if (validation.errors.length > 0) {
|
|
1940
|
-
throw new Error(
|
|
1941
|
-
`Plan rejected:\n- ${validation.errors.join("\n- ")}\n\nFix the task list and call squad again.`,
|
|
1942
|
-
);
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
// Create squad
|
|
1946
|
-
const config: SquadConfig = {
|
|
1947
|
-
...DEFAULT_SQUAD_CONFIG,
|
|
1948
|
-
...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
|
|
1949
|
-
...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
|
|
1950
|
-
...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
|
|
1951
|
-
};
|
|
1952
|
-
|
|
1953
|
-
const squad: Squad = {
|
|
1954
|
-
id: squadId,
|
|
1955
|
-
goal: params.goal,
|
|
1956
|
-
status: "running",
|
|
1957
|
-
created: store.now(),
|
|
1958
|
-
cwd,
|
|
1959
|
-
sessionFile,
|
|
1960
|
-
agents,
|
|
1961
|
-
config,
|
|
1962
|
-
...(preparedSpec ? { spec: {
|
|
1963
|
-
schemaVersion: 1 as const,
|
|
1964
|
-
sha256: preparedSpec.sha256,
|
|
1965
|
-
bytes: preparedSpec.raw.length,
|
|
1966
|
-
path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
|
|
1967
|
-
chunkBytes: 32768 as const,
|
|
1968
|
-
chunkCount: chunkRanges(preparedSpec.raw).length,
|
|
1969
|
-
} } : {}),
|
|
1970
|
-
};
|
|
1971
|
-
|
|
1972
|
-
// Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
|
|
1973
|
-
const initialTasks: Task[] = plan.tasks.map((taskDef) => {
|
|
1974
|
-
const task: Task = {
|
|
1975
|
-
id: taskDef.id,
|
|
1976
|
-
title: taskDef.title,
|
|
1977
|
-
description: taskDef.description,
|
|
1978
|
-
agent: taskDef.agent,
|
|
1979
|
-
status: taskDef.depends.length === 0 ? "pending" : "blocked",
|
|
1980
|
-
depends: taskDef.depends,
|
|
1981
|
-
...(taskDef.inheritContext ? { inheritContext: true } : {}),
|
|
1982
|
-
created: store.now(),
|
|
1983
|
-
started: null,
|
|
1984
|
-
completed: null,
|
|
1985
|
-
output: null,
|
|
1986
|
-
error: null,
|
|
1987
|
-
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
1988
|
-
};
|
|
1989
|
-
return task;
|
|
1990
|
-
});
|
|
1991
|
-
|
|
1992
|
-
if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
|
|
1993
|
-
else {
|
|
1994
|
-
store.saveSquad(squad);
|
|
1995
|
-
for (const task of initialTasks) store.createTask(squadId, task);
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
// Start scheduler
|
|
1999
|
-
const scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
|
|
2000
|
-
schedulers.set(squadId, scheduler);
|
|
2001
|
-
// Activate panel/widget/tool focus as one invariant.
|
|
2002
|
-
focusSquad(squadId);
|
|
2003
|
-
|
|
2004
|
-
// Wire up completion/escalation notifications to main agent.
|
|
2005
|
-
wireSchedulerEvents(pi, scheduler, squadId);
|
|
2006
|
-
|
|
2007
|
-
// Start scheduling — fire and forget, don't block the tool call.
|
|
2008
|
-
// scheduler.start() spawns agents which can take seconds per agent.
|
|
2009
|
-
// We must return immediately so the main agent's turn completes
|
|
2010
|
-
// and the user regains interactive control.
|
|
2011
|
-
scheduler.start().catch((err) => {
|
|
2012
|
-
logError("squad", `Scheduler start error: ${(err as Error).message}`);
|
|
2013
|
-
});
|
|
2014
|
-
|
|
2015
|
-
// Build response. File mode returns only the bounded descriptor; the canonical
|
|
2016
|
-
// contract is never reflected back into the main model's transport.
|
|
2017
|
-
const taskSummary = preparedSpec
|
|
2018
|
-
? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
|
|
2019
|
-
: plan.tasks
|
|
2020
|
-
.map((t) => {
|
|
2021
|
-
const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
|
|
2022
|
-
return `${t.id} → ${t.agent}: ${t.title}${deps}`;
|
|
2023
|
-
})
|
|
2024
|
-
.join("\n");
|
|
2025
|
-
|
|
2026
|
-
return {
|
|
2027
|
-
content: [
|
|
2028
|
-
{
|
|
2029
|
-
type: "text" as const,
|
|
2030
|
-
text: `Squad "${squadId}" started with ${plan.tasks.length} tasks.\n\n${taskSummary}${
|
|
2031
|
-
validation.warnings.length > 0
|
|
2032
|
-
? `\n\n⚠️ Plan warnings (fix with squad_modify, or address at review):\n- ${validation.warnings.join("\n- ")}`
|
|
2033
|
-
: ""
|
|
2034
|
-
}\n\nAgents work in the background — you will be woken automatically when the squad completes, fails, or needs help. Report this plan to the user and END YOUR TURN now. Do NOT poll squad_status, do NOT sleep-wait, do NOT loop.`,
|
|
2035
|
-
},
|
|
2036
|
-
],
|
|
2037
|
-
details: undefined,
|
|
2038
|
-
};
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
// ============================================================================
|
|
2042
|
-
// Helpers
|
|
2043
|
-
// ============================================================================
|
|
2044
|
-
|
|
2045
48
|
function getSquadSkillPaths(skillsDir: string): string[] {
|
|
2046
49
|
if (!fs.existsSync(skillsDir)) return [];
|
|
2047
50
|
return fs
|
|
@@ -2050,5 +53,3 @@ function getSquadSkillPaths(skillsDir: string): string[] {
|
|
|
2050
53
|
.map((d) => path.join(skillsDir, d.name))
|
|
2051
54
|
.filter((dir) => fs.existsSync(path.join(dir, "SKILL.md")));
|
|
2052
55
|
}
|
|
2053
|
-
|
|
2054
|
-
|