pi-squad 0.17.1 → 0.18.0

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