pi-squad 0.7.2 → 0.14.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
@@ -12,12 +12,15 @@
12
12
 
13
13
  import * as path from "node:path";
14
14
  import * as fs from "node:fs";
15
- import { Type } from "@sinclair/typebox";
16
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { Type } from "typebox";
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import type { Squad, Task, SquadConfig, PlannerOutput } from "./types.js";
18
- import { DEFAULT_SQUAD_CONFIG } from "./types.js";
19
- import { Scheduler, type SchedulerEvent } from "./scheduler.js";
18
+ import { DEFAULT_SQUAD_CONFIG, THINKING_LEVELS } from "./types.js";
19
+ import { Scheduler, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
20
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";
21
24
  import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
22
25
  import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
23
26
  import * as store from "./store.js";
@@ -36,11 +39,143 @@ let activeSquadId: string | null = null;
36
39
  /** Whether an overlay panel is currently open (prevents double-open) */
37
40
  let overlayOpen = false;
38
41
  /** Stored ExtensionContext for widget updates from background scheduler events */
39
- let uiCtx: import("@mariozechner/pi-coding-agent").ExtensionContext | null = null;
42
+ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
40
43
  /** Component-based widget state + controls */
41
44
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
42
45
  let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
43
46
 
47
+ /** Reviewer instructions appended to squad-completed notifications —
48
+ * makes the main session behave like the QA/verification agents do. */
49
+ const REVIEW_INSTRUCTIONS =
50
+ "Before reporting success to the user, REVIEW the work like a QA agent would: " +
51
+ "(1) check task outputs for QA verdicts (## Verdict: PASS/FAIL) and verification evidence (commands + results); " +
52
+ "(2) if a task claimed done without evidence, run its Verify command yourself; " +
53
+ "(3) report to the user what was verified (with evidence) and flag anything unverified — don't just relay the summary.";
54
+
55
+ /**
56
+ * Resolve a model string (or null = session default) to its context window.
57
+ * Reads uiCtx lazily so it always uses the live session's registry.
58
+ */
59
+ function resolveContextWindow(model: string | null): number | undefined {
60
+ const ctx = uiCtx;
61
+ if (!ctx) return undefined;
62
+ try {
63
+ if (!model) return ctx.model?.contextWindow;
64
+ // Strip a :<thinking> suffix if present
65
+ let clean = model;
66
+ const lastColon = model.lastIndexOf(":");
67
+ if (lastColon > 0 && (THINKING_LEVELS as readonly string[]).includes(model.slice(lastColon + 1))) {
68
+ clean = model.slice(0, lastColon);
69
+ }
70
+ const all = ctx.modelRegistry.getAll();
71
+ const slash = clean.indexOf("/");
72
+ if (slash > 0) {
73
+ const provider = clean.slice(0, slash);
74
+ const id = clean.slice(slash + 1);
75
+ const m = all.find((x) => x.provider === provider && x.id === id);
76
+ if (m) return m.contextWindow;
77
+ }
78
+ return all.find((x) => x.id === clean)?.contextWindow;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Main session's current model as "provider/id", if known */
85
+ function getMainSessionModel(): string | undefined {
86
+ try {
87
+ const m = uiCtx?.model;
88
+ return m ? `${m.provider}/${m.id}` : undefined;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Main session's current thinking level (set inside the extension entry, needs `pi`) */
95
+ let getMainSessionThinking: () => string | undefined = () => undefined;
96
+
97
+ /**
98
+ * Resolve the effective squad defaults from ~/.pi/squad/settings.json.
99
+ * "main" follows the live main session; "pi-default" leaves values unset
100
+ * (child pi resolves its own default); anything else is an explicit value.
101
+ */
102
+ function resolveSquadDefaults(): { model?: string; thinking?: string } {
103
+ const settings = store.loadSquadSettings();
104
+ let model: string | undefined;
105
+ if (settings.defaultModel === "main") model = getMainSessionModel();
106
+ else if (settings.defaultModel !== "pi-default") model = settings.defaultModel;
107
+ let thinking: string | undefined;
108
+ if (settings.defaultThinking === "main") thinking = getMainSessionThinking();
109
+ else if (settings.defaultThinking !== "pi-default") thinking = settings.defaultThinking;
110
+ return { model, thinking };
111
+ }
112
+
113
+ /**
114
+ * Consult the advisor model in-process via pi-ai (no subprocess).
115
+ * Returns advice text or null when disabled/unresolvable.
116
+ */
117
+ async function consultAdvisor(input: AdvisorConsultInput): Promise<string | null> {
118
+ const ctx = uiCtx;
119
+ if (!ctx) return null;
120
+ const settings = store.loadSquadSettings();
121
+ if (!settings.advisor.enabled) return null;
122
+
123
+ try {
124
+ // Resolve advisor model: "main" = the main session's live model object
125
+ let model = settings.advisor.model === "main" ? ctx.model : undefined;
126
+ if (!model && settings.advisor.model !== "main") {
127
+ const ref = settings.advisor.model;
128
+ const slash = ref.indexOf("/");
129
+ if (slash > 0) model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
130
+ }
131
+ if (!model) {
132
+ logError("squad-advisor", `advisor model "${settings.advisor.model}" not resolvable`);
133
+ return null;
134
+ }
135
+
136
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
137
+ if (!auth.ok || !auth.apiKey) {
138
+ logError("squad-advisor", `no auth for advisor model ${model.provider}/${model.id}`);
139
+ return null;
140
+ }
141
+
142
+ const userMessage: Message = {
143
+ role: "user",
144
+ content: [{ type: "text", text: buildAdvisorConsultText(input) }],
145
+ timestamp: Date.now(),
146
+ } as Message;
147
+
148
+ const response = await completeSimple(
149
+ model,
150
+ { systemPrompt: ADVISOR_SYSTEM_PROMPT, messages: [userMessage] },
151
+ {
152
+ apiKey: auth.apiKey,
153
+ headers: auth.headers,
154
+ maxTokens: settings.advisor.maxTokens,
155
+ reasoning: settings.advisor.reasoning as never,
156
+ },
157
+ );
158
+
159
+ const text = response.content
160
+ .filter((b): b is TextContent => b.type === "text")
161
+ .map((b) => b.text)
162
+ .join("\n")
163
+ .trim();
164
+ debug("squad-advisor", `consulted ${model.provider}/${model.id} for ${input.taskId}: in=${response.usage?.input ?? 0} out=${response.usage?.output ?? 0}`);
165
+ return text || null;
166
+ } catch (error) {
167
+ logError("squad-advisor", `consult failed: ${(error as Error).message}`);
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /** Spawn context shared by all Scheduler instances */
173
+ const schedulerSpawnContext: SchedulerSpawnContext = {
174
+ resolveContextWindow,
175
+ getDefaultModelThinking: resolveSquadDefaults,
176
+ consultAdvisor,
177
+ };
178
+
44
179
  /** Get the active scheduler (for the focused squad) */
45
180
  function getActiveScheduler(): Scheduler | null {
46
181
  if (!activeSquadId) return null;
@@ -56,6 +191,15 @@ export default function (pi: ExtensionAPI) {
56
191
  // Don't load in child agent processes (prevent recursive squad-in-squad)
57
192
  if (process.env.PI_SQUAD_CHILD === "1") return;
58
193
 
194
+ // Wire main-session thinking lookup (needs `pi`, guarded against stale API)
195
+ getMainSessionThinking = () => {
196
+ try {
197
+ return pi.getThinkingLevel();
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ };
202
+
59
203
  // Bootstrap default agents on first load
60
204
  const defaultsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "agents", "_defaults");
61
205
  store.bootstrapAgents(defaultsDir);
@@ -97,6 +241,7 @@ export default function (pi: ExtensionAPI) {
97
241
  taskLines,
98
242
  `</squad_status>`,
99
243
  `You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
244
+ `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.`,
100
245
  ].join("\n");
101
246
 
102
247
  return {
@@ -115,6 +260,8 @@ export default function (pi: ExtensionAPI) {
115
260
  `The squad tool decomposes work into tasks, assigns specialist agents, and runs them in parallel.`,
116
261
  `When in doubt about whether a task is complex enough, prefer using squad — it handles the coordination for you.`,
117
262
  allAgents.length > 0 ? `Available agents: ${agentList}. When providing tasks, the "agent" field must be one of these names.` : ``,
263
+ `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.`,
264
+ `Structure descriptions as: Goal (outcome first), Context (files to read), Output (deliverable), Boundaries (what must not change), Verify (proving command).`,
118
265
  `</squad_hint>`,
119
266
  ].filter(Boolean).join("\n");
120
267
 
@@ -139,16 +286,29 @@ export default function (pi: ExtensionAPI) {
139
286
  "Do NOT use for simple single-file changes, quick bug fixes, or tasks a single agent can handle in a few minutes.",
140
287
  "When in doubt about complexity, use squad — it's better to parallelize than to do everything sequentially.",
141
288
  "Non-blocking: returns immediately with the plan while agents work in background.",
289
+ "If you provide tasks yourself (skipping the planner agent), follow the same rules the planner follows:",
290
+ PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
291
+ "Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
142
292
  ].join(" "),
293
+ promptSnippet: "squad({ goal, tasks?, agents? }): decompose complex work into parallel specialist agents → non-blocking, monitor via squad_status",
294
+ promptGuidelines: [
295
+ "Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
296
+ "Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
297
+ "Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
298
+ "Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
299
+ "After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
300
+ "When the squad completes, review evidence like a QA agent before reporting success",
301
+ ],
143
302
  parameters: Type.Object({
144
303
  goal: Type.String({ description: "What the squad should accomplish" }),
145
304
  agents: Type.Optional(
146
305
  Type.Record(
147
306
  Type.String(),
148
307
  Type.Object({
149
- model: Type.Optional(Type.String()),
308
+ model: Type.Optional(Type.String({ description: "Model override (e.g. 'github-copilot/claude-sonnet-5')" })),
309
+ thinking: Type.Optional(Type.String({ description: "Thinking level: off, minimal, low, medium, high, xhigh, max" })),
150
310
  }),
151
- { description: "Agent roster with optional model overrides. Keys must match agent names in .pi/squad/agents/" },
311
+ { description: "Agent roster with optional model/thinking overrides. Keys must match agent names in .pi/squad/agents/" },
152
312
  ),
153
313
  ),
154
314
  tasks: Type.Optional(
@@ -156,11 +316,12 @@ export default function (pi: ExtensionAPI) {
156
316
  Type.Object({
157
317
  id: Type.String(),
158
318
  title: Type.String(),
159
- description: Type.Optional(Type.String()),
319
+ 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." })),
160
320
  agent: Type.String(),
161
321
  depends: Type.Optional(Type.Array(Type.String())),
322
+ 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." })),
162
323
  }),
163
- { description: "Pre-defined task breakdown. If provided, skips the planner agent." },
324
+ { description: "Pre-defined task breakdown. If provided, skips the planner agent. Scope tasks to required work only — no optional polish." },
164
325
  ),
165
326
  ),
166
327
  config: Type.Optional(
@@ -171,21 +332,25 @@ export default function (pi: ExtensionAPI) {
171
332
  }),
172
333
 
173
334
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
174
- if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }] };
335
+ if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
175
336
  if (!uiCtx) uiCtx = ctx;
176
337
 
177
338
  // Check if the user cancelled before we start
178
- if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled." }] };
339
+ if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled." }], details: undefined };
179
340
 
180
341
  // Multiple squads can run concurrently — no guard needed
181
342
 
343
+ // Resolve to absolute: the fork happens later from a child process whose
344
+ // cwd may differ (e.g. when a relative --session-dir was used).
345
+ const rawSessionFile = ctx.sessionManager.getSessionFile();
346
+ const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
182
347
  const squadId = store.makeTaskId(params.goal);
183
348
  if (store.squadExists(squadId)) {
184
349
  const uniqueId = `${squadId}-${Date.now().toString(36)}`;
185
- return await startSquad(uniqueId, params, ctx.cwd, squadSkillPaths, pi);
350
+ return await startSquad(uniqueId, params, ctx.cwd, squadSkillPaths, pi, sessionFile);
186
351
  }
187
352
 
188
- return await startSquad(squadId, params, ctx.cwd, squadSkillPaths, pi);
353
+ return await startSquad(squadId, params, ctx.cwd, squadSkillPaths, pi, sessionFile);
189
354
  },
190
355
  });
191
356
 
@@ -196,7 +361,7 @@ export default function (pi: ExtensionAPI) {
196
361
  pi.registerTool({
197
362
  name: "squad_status",
198
363
  label: "Squad Status",
199
- description: "Check current squad status, task progress, and recent activity.",
364
+ 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.",
200
365
  parameters: Type.Object({
201
366
  squadId: Type.Optional(Type.String({ description: "Specific squad ID (default: most recent)" })),
202
367
  }),
@@ -211,7 +376,7 @@ export default function (pi: ExtensionAPI) {
211
376
  }
212
377
 
213
378
  if (!id) {
214
- return { content: [{ type: "text" as const, text: "No squads found. Use the squad tool to start one." }] };
379
+ return { content: [{ type: "text" as const, text: "No squads found. Use the squad tool to start one." }], details: undefined };
215
380
  }
216
381
 
217
382
  // If scheduler is running, force a context refresh
@@ -220,7 +385,7 @@ export default function (pi: ExtensionAPI) {
220
385
 
221
386
  const context = store.loadContext(id);
222
387
  if (!context) {
223
- return { content: [{ type: "text" as const, text: `Squad '${id}' not found or has no context yet.` }] };
388
+ return { content: [{ type: "text" as const, text: `Squad '${id}' not found or has no context yet.` }], details: undefined };
224
389
  }
225
390
 
226
391
  const taskLines = Object.entries(context.tasks)
@@ -247,7 +412,7 @@ export default function (pi: ExtensionAPI) {
247
412
  taskLines,
248
413
  ].join("\n");
249
414
 
250
- return { content: [{ type: "text" as const, text: summary }] };
415
+ return { content: [{ type: "text" as const, text: summary }], details: undefined };
251
416
  },
252
417
  });
253
418
 
@@ -268,7 +433,7 @@ export default function (pi: ExtensionAPI) {
268
433
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
269
434
  const activeScheduler = getActiveScheduler();
270
435
  if (!activeScheduler || !activeSquadId) {
271
- return { content: [{ type: "text" as const, text: "No active squad." }] };
436
+ return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
272
437
  }
273
438
 
274
439
  let taskId = params.taskId;
@@ -279,13 +444,13 @@ export default function (pi: ExtensionAPI) {
279
444
  }
280
445
 
281
446
  if (!taskId) {
282
- return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }] };
447
+ return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
283
448
  }
284
449
 
285
450
  const sent = await activeScheduler!.sendHumanMessage(taskId, params.message);
286
451
  const status = sent ? "delivered" : "queued for when the agent starts";
287
452
 
288
- return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }] };
453
+ return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
289
454
  },
290
455
  });
291
456
 
@@ -318,8 +483,8 @@ export default function (pi: ExtensionAPI) {
318
483
  description: Type.Optional(Type.String()),
319
484
  agent: Type.String(),
320
485
  depends: Type.Optional(Type.Array(Type.String())),
321
- }),
322
- { description: "Task definition for add_task" },
486
+ 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)" })),
487
+ }, { description: "Task definition for add_task" }),
323
488
  ),
324
489
  }),
325
490
 
@@ -332,12 +497,12 @@ export default function (pi: ExtensionAPI) {
332
497
  .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
333
498
 
334
499
  if (!squadId) {
335
- return { content: [{ type: "text" as const, text: "No paused squad found to resume." }] };
500
+ return { content: [{ type: "text" as const, text: "No paused squad found to resume." }], details: undefined };
336
501
  }
337
502
 
338
503
  // Create a fresh scheduler if needed
339
504
  if (!schedulers.has(squadId)) {
340
- const scheduler = new Scheduler(squadId, squadSkillPaths);
505
+ const scheduler = new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext);
341
506
  schedulers.set(squadId, scheduler);
342
507
  activeSquadId = squadId;
343
508
 
@@ -358,11 +523,13 @@ export default function (pi: ExtensionAPI) {
358
523
  .join("\n");
359
524
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
360
525
  const s = schedulers.get(squadId); if (s) s.updateContext();
526
+ // followUp + triggerTurn: wake an idle main agent to review;
527
+ // if it's mid-conversation with the human, deliver after it finishes
361
528
  pi.sendMessage({
362
529
  customType: "squad-completed",
363
- content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}`,
530
+ content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${REVIEW_INSTRUCTIONS}`,
364
531
  display: true,
365
- });
532
+ }, { triggerTurn: true, deliverAs: "followUp" });
366
533
  schedulers.delete(squadId);
367
534
  forceWidgetUpdate();
368
535
  break;
@@ -375,7 +542,7 @@ export default function (pi: ExtensionAPI) {
375
542
  customType: "squad-failed",
376
543
  content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}`,
377
544
  display: true,
378
- }, { triggerTurn: true });
545
+ }, { triggerTurn: true, deliverAs: "followUp" });
379
546
  forceWidgetUpdate();
380
547
  break;
381
548
  }
@@ -398,17 +565,32 @@ export default function (pi: ExtensionAPI) {
398
565
 
399
566
  const tasks = store.loadAllTasks(squadId);
400
567
  const done = tasks.filter(t => t.status === "done").length;
401
- return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }] };
568
+ return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
402
569
  }
403
570
 
571
+ const activeScheduler = getActiveScheduler();
404
572
  if (!activeScheduler || !activeSquadId) {
405
- return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }] };
573
+ return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }], details: undefined };
406
574
  }
407
575
 
408
576
  switch (params.action) {
409
577
  case "add_task": {
410
578
  if (!params.task) {
411
- return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }] };
579
+ return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
580
+ }
581
+ // Validate against the live squad: deps must exist, agent must exist
582
+ const existing = store.loadAllTasks(activeSquadId);
583
+ const existingIds = new Set(existing.map((t) => t.id));
584
+ if (existingIds.has(params.task.id)) {
585
+ return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
586
+ }
587
+ const badDeps = (params.task.depends || []).filter((d) => !existingIds.has(d));
588
+ if (badDeps.length > 0) {
589
+ return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
590
+ }
591
+ if (!store.loadAgentDef(params.task.agent, ctx.cwd)) {
592
+ const available = store.loadAllAgentDefs(ctx.cwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
593
+ return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
412
594
  }
413
595
  const task: Task = {
414
596
  id: params.task.id,
@@ -417,6 +599,7 @@ export default function (pi: ExtensionAPI) {
417
599
  agent: params.task.agent,
418
600
  status: "pending",
419
601
  depends: params.task.depends || [],
602
+ ...(params.task.inheritContext ? { inheritContext: true } : {}),
420
603
  created: store.now(),
421
604
  started: null,
422
605
  completed: null,
@@ -426,27 +609,27 @@ export default function (pi: ExtensionAPI) {
426
609
  };
427
610
  store.createTask(activeSquadId, task);
428
611
  activeScheduler.updateContext();
429
- return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }] };
612
+ return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }], details: undefined };
430
613
  }
431
614
 
432
615
  case "cancel_task": {
433
- if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
616
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
434
617
  await activeScheduler.cancelTask(params.taskId);
435
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }] };
618
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
436
619
  }
437
620
 
438
621
  case "pause_task": {
439
- if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
622
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
440
623
  await activeScheduler.pauseTask(params.taskId);
441
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' paused.` }] };
624
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' paused.` }], details: undefined };
442
625
  }
443
626
 
444
627
  case "resume_task": {
445
- if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
628
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
446
629
  activeScheduler.resumeTask(params.taskId).catch((err) => {
447
630
  logError("squad", `Resume task error: ${(err as Error).message}`);
448
631
  });
449
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }] };
632
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
450
633
  }
451
634
 
452
635
  case "pause": {
@@ -456,13 +639,10 @@ export default function (pi: ExtensionAPI) {
456
639
  store.saveSquad(squad);
457
640
  }
458
641
  await activeScheduler.stop();
459
- return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }] };
642
+ return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
460
643
  }
461
644
 
462
- case "resume": {
463
- // Handled above (before the activeScheduler guard)
464
- return { content: [{ type: "text" as const, text: "Squad resumed." }] };
465
- }
645
+ // Note: "resume" is handled above, before the activeScheduler guard.
466
646
 
467
647
  case "cancel": {
468
648
  await activeScheduler.stop();
@@ -473,11 +653,11 @@ export default function (pi: ExtensionAPI) {
473
653
  }
474
654
  schedulers.delete(activeSquadId);
475
655
  activeSquadId = null;
476
- return { content: [{ type: "text" as const, text: "Squad cancelled." }] };
656
+ return { content: [{ type: "text" as const, text: "Squad cancelled." }], details: undefined };
477
657
  }
478
658
 
479
659
  default:
480
- return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
660
+ return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
481
661
  }
482
662
  },
483
663
  });
@@ -553,7 +733,7 @@ export default function (pi: ExtensionAPI) {
553
733
  }
554
734
 
555
735
  if (activeSquadId) {
556
- openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths), activeSquadId);
736
+ openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
557
737
  }
558
738
  return { consume: true };
559
739
  }
@@ -585,6 +765,8 @@ export default function (pi: ExtensionAPI) {
585
765
  { value: "all", label: "all", description: "List all squads, select to activate" },
586
766
  { value: "select", label: "select", description: "Pick a squad to view (interactive)" },
587
767
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
768
+ { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
769
+ { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
588
770
  { value: "msg", label: "msg", description: "Send message to agent: /squad msg [agent] text" },
589
771
  { value: "widget", label: "widget", description: "Toggle live widget" },
590
772
  { value: "panel", label: "panel", description: "Toggle overlay panel" },
@@ -677,7 +859,7 @@ export default function (pi: ExtensionAPI) {
677
859
  }
678
860
  }
679
861
  if (activeSquadId) {
680
- const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths);
862
+ const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
681
863
  openPanel(ctx, sched, activeSquadId);
682
864
  }
683
865
  return;
@@ -884,6 +1066,92 @@ export default function (pi: ExtensionAPI) {
884
1066
  return;
885
1067
  }
886
1068
 
1069
+ case "defaults": {
1070
+ const settings = store.loadSquadSettings();
1071
+ const mainModel = getMainSessionModel() || "(unknown)";
1072
+ const mainThinking = getMainSessionThinking() || "(unknown)";
1073
+ const fmtPolicy = (v: string, live: string) =>
1074
+ v === "main" ? `follow main session (now: ${live})` : v === "pi-default" ? "pi default" : v;
1075
+
1076
+ const which = await ctx.ui.select(
1077
+ `Squad defaults — model: ${fmtPolicy(settings.defaultModel, mainModel)} | thinking: ${fmtPolicy(settings.defaultThinking, mainThinking)}`,
1078
+ ["Change default model", "Change default thinking", "Cancel"],
1079
+ );
1080
+ if (!which || which === "Cancel") return;
1081
+
1082
+ if (which === "Change default model") {
1083
+ const choice = await ctx.ui.select("Default model for squad agents", [
1084
+ `Follow main session (now: ${mainModel})`,
1085
+ "pi default (child pi resolves its own)",
1086
+ "Custom model…",
1087
+ ]);
1088
+ if (!choice) return;
1089
+ if (choice.startsWith("Follow")) settings.defaultModel = "main";
1090
+ else if (choice.startsWith("pi default")) settings.defaultModel = "pi-default";
1091
+ else {
1092
+ 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);
1093
+ if (!custom || !custom.trim()) return;
1094
+ settings.defaultModel = custom.trim();
1095
+ }
1096
+ store.saveSquadSettings(settings);
1097
+ ctx.ui.notify(`Squad default model → ${fmtPolicy(settings.defaultModel, mainModel)}`, "info");
1098
+ } else {
1099
+ const choice = await ctx.ui.select("Default thinking for squad agents", [
1100
+ `Follow main session (now: ${mainThinking})`,
1101
+ "pi default (child pi resolves its own)",
1102
+ ...THINKING_LEVELS,
1103
+ ]);
1104
+ if (!choice) return;
1105
+ if (choice.startsWith("Follow")) settings.defaultThinking = "main";
1106
+ else if (choice.startsWith("pi default")) settings.defaultThinking = "pi-default";
1107
+ else settings.defaultThinking = choice;
1108
+ store.saveSquadSettings(settings);
1109
+ ctx.ui.notify(`Squad default thinking → ${fmtPolicy(settings.defaultThinking, mainThinking)}`, "info");
1110
+ }
1111
+ return;
1112
+ }
1113
+
1114
+ case "advisor": {
1115
+ const settings = store.loadSquadSettings();
1116
+ const adv = settings.advisor;
1117
+ const mainModelLabel = getMainSessionModel() || "(unknown)";
1118
+ const modelLabel = adv.model === "main" ? `main session (now: ${mainModelLabel})` : adv.model;
1119
+
1120
+ const choice = await ctx.ui.select(
1121
+ `Squad advisor — ${adv.enabled ? "ON" : "OFF"} | model: ${modelLabel} | ${adv.maxCallsPerTask} calls/task, ${adv.reasoning} reasoning`,
1122
+ [adv.enabled ? "Disable advisor" : "Enable advisor", "Change advisor model", "Change max calls per task", "Change reasoning effort", "Cancel"],
1123
+ );
1124
+ if (!choice || choice === "Cancel") return;
1125
+
1126
+ if (choice.startsWith("Disable") || choice.startsWith("Enable")) {
1127
+ adv.enabled = !adv.enabled;
1128
+ ctx.ui.notify(`Squad advisor ${adv.enabled ? "enabled — stuck agents get a strong-model rescue before escalating" : "disabled — stuck agents escalate directly"}`, "info");
1129
+ } else if (choice === "Change advisor model") {
1130
+ const sel = await ctx.ui.select("Advisor model", [`Follow main session (now: ${mainModelLabel})`, "Custom model…"]);
1131
+ if (!sel) return;
1132
+ if (sel.startsWith("Follow")) adv.model = "main";
1133
+ else {
1134
+ const custom = await ctx.ui.input("Advisor model (provider/id)", adv.model === "main" ? "" : adv.model);
1135
+ if (!custom || !custom.trim()) return;
1136
+ adv.model = custom.trim();
1137
+ }
1138
+ ctx.ui.notify(`Advisor model → ${adv.model}`, "info");
1139
+ } else if (choice === "Change max calls per task") {
1140
+ const n = await ctx.ui.input("Max advisor calls per task", String(adv.maxCallsPerTask));
1141
+ const parsed = n ? Number.parseInt(n, 10) : NaN;
1142
+ if (!Number.isFinite(parsed) || parsed < 0) return;
1143
+ adv.maxCallsPerTask = parsed;
1144
+ ctx.ui.notify(`Advisor max calls/task → ${parsed}`, "info");
1145
+ } else {
1146
+ const lvl = await ctx.ui.select("Advisor reasoning effort", ["minimal", "low", "medium", "high", "xhigh"]);
1147
+ if (!lvl) return;
1148
+ adv.reasoning = lvl;
1149
+ ctx.ui.notify(`Advisor reasoning → ${lvl}`, "info");
1150
+ }
1151
+ store.saveSquadSettings(settings);
1152
+ return;
1153
+ }
1154
+
887
1155
  case "agents": {
888
1156
  const agentArg = parts[1];
889
1157
  const allAgents = store.loadAllAgentDefs(ctx.cwd);
@@ -895,7 +1163,7 @@ export default function (pi: ExtensionAPI) {
895
1163
  return;
896
1164
  }
897
1165
  const options = allAgents.map((a) => {
898
- const model = a.model ? ` [${a.model}]` : " [default]";
1166
+ const model = a.model ? ` [${a.model}${a.thinking ? `:${a.thinking}` : ""}]` : a.thinking ? ` [default:${a.thinking}]` : " [default]";
899
1167
  const status = a.disabled ? " ✗ disabled" : "";
900
1168
  return `${a.name} — ${a.role}${model}${status}`;
901
1169
  });
@@ -911,6 +1179,7 @@ export default function (pi: ExtensionAPI) {
911
1179
  "View details",
912
1180
  "Edit in editor",
913
1181
  "Change model",
1182
+ "Change thinking",
914
1183
  "Toggle tools (restrict/unrestrict)",
915
1184
  disableLabel,
916
1185
  "Cancel",
@@ -924,6 +1193,7 @@ export default function (pi: ExtensionAPI) {
924
1193
  `Role: ${agent.role}`,
925
1194
  `Description: ${agent.description}`,
926
1195
  `Model: ${agent.model || "(default)"}`,
1196
+ `Thinking: ${agent.thinking || "(default)"}`,
927
1197
  `Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
928
1198
  `Tags: ${agent.tags.join(", ")}`,
929
1199
  ``,
@@ -953,6 +1223,14 @@ export default function (pi: ExtensionAPI) {
953
1223
  store.saveAgentDef(agent);
954
1224
  ctx.ui.notify(`${agent.name} model → ${agent.model || "(default)"}`, "info");
955
1225
  }
1226
+ } else if (action === "Change thinking") {
1227
+ const levels = ["(default)", ...THINKING_LEVELS];
1228
+ const level = await ctx.ui.select(`Thinking level for ${agent.name}`, levels);
1229
+ if (level !== undefined) {
1230
+ agent.thinking = level === "(default)" ? null : level;
1231
+ store.saveAgentDef(agent);
1232
+ ctx.ui.notify(`${agent.name} thinking → ${agent.thinking || "(default)"}`, "info");
1233
+ }
956
1234
  } else if (action === disableLabel) {
957
1235
  agent.disabled = !agent.disabled;
958
1236
  store.saveAgentDef(agent);
@@ -986,6 +1264,7 @@ export default function (pi: ExtensionAPI) {
986
1264
  `${agent.name} — ${agent.role}${status}`,
987
1265
  `${agent.description}`,
988
1266
  `Model: ${agent.model || "(default)"}`,
1267
+ `Thinking: ${agent.thinking || "(default)"}`,
989
1268
  `Tools: ${agent.tools ? agent.tools.join(", ") : "(all)"}`,
990
1269
  `Tags: ${agent.tags.join(", ")}`,
991
1270
  ].join("\n");
@@ -1003,7 +1282,7 @@ export default function (pi: ExtensionAPI) {
1003
1282
  activateSquadView(direct.id, ctx);
1004
1283
  return;
1005
1284
  }
1006
- ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, widget, panel, cancel, clear`, "warning");
1285
+ ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1007
1286
  }
1008
1287
  },
1009
1288
  });
@@ -1019,7 +1298,7 @@ export default function (pi: ExtensionAPI) {
1019
1298
  * Returns the selected squad or undefined if cancelled.
1020
1299
  */
1021
1300
  async function pickSquad(
1022
- ctx: import("@mariozechner/pi-coding-agent").ExtensionContext | import("@mariozechner/pi-coding-agent").ExtensionCommandContext,
1301
+ ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext,
1023
1302
  squads: Squad[],
1024
1303
  showProject = false,
1025
1304
  ): Promise<Squad | undefined> {
@@ -1046,7 +1325,7 @@ async function pickSquad(
1046
1325
  * Sets activeSquadId, starts widget, shows notification.
1047
1326
  * Does NOT start a scheduler (view-only unless squad needs resuming).
1048
1327
  */
1049
- function activateSquadView(squadId: string, ctx: import("@mariozechner/pi-coding-agent").ExtensionContext | import("@mariozechner/pi-coding-agent").ExtensionCommandContext): void {
1328
+ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-coding-agent").ExtensionContext | import("@earendil-works/pi-coding-agent").ExtensionCommandContext): void {
1050
1329
  const squad = store.loadSquad(squadId);
1051
1330
  if (!squad) {
1052
1331
  ctx.ui.notify(`Squad '${squadId}' not found`, "error");
@@ -1088,7 +1367,7 @@ function forceWidgetUpdate(): void {
1088
1367
  * that resolves when done() is called. The panel calls done() on close.
1089
1368
  */
1090
1369
  function openPanel(
1091
- ctx: import("@mariozechner/pi-coding-agent").ExtensionContext,
1370
+ ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
1092
1371
  scheduler: Scheduler,
1093
1372
  squadId: string,
1094
1373
  ): void {
@@ -1151,19 +1430,21 @@ async function startSquad(
1151
1430
  squadId: string,
1152
1431
  params: {
1153
1432
  goal: string;
1154
- agents?: Record<string, { model?: string }>;
1433
+ agents?: Record<string, { model?: string; thinking?: string }>;
1155
1434
  tasks?: Array<{
1156
1435
  id: string;
1157
1436
  title: string;
1158
1437
  description?: string;
1159
1438
  agent: string;
1160
1439
  depends?: string[];
1440
+ inheritContext?: boolean;
1161
1441
  }>;
1162
1442
  config?: { maxConcurrency?: number };
1163
1443
  },
1164
1444
  cwd: string,
1165
1445
  skillPaths: string[],
1166
1446
  pi: ExtensionAPI,
1447
+ sessionFile: string | null = null,
1167
1448
  ) {
1168
1449
  let plan: PlannerOutput;
1169
1450
 
@@ -1188,27 +1469,33 @@ async function startSquad(
1188
1469
  }
1189
1470
  }
1190
1471
  } else {
1191
- // Run planner to generate task breakdown
1472
+ // Run planner to generate task breakdown (squad default policy as fallback)
1192
1473
  try {
1193
- plan = await runPlanner({ goal: params.goal, cwd });
1474
+ const defaults = resolveSquadDefaults();
1475
+ plan = await runPlanner({ goal: params.goal, cwd, fallbackModel: defaults.model, fallbackThinking: defaults.thinking });
1194
1476
  } catch (error) {
1195
- return {
1196
- content: [
1197
- { type: "text" as const, text: `Failed to plan: ${(error as Error).message}` },
1198
- ],
1199
- isError: true,
1200
- };
1477
+ // Throwing marks the tool result as an error for the LLM (returning isError is ignored in current pi)
1478
+ throw new Error(`Failed to plan: ${(error as Error).message}`);
1201
1479
  }
1202
1480
  }
1203
1481
 
1204
1482
  // Merge agent roster
1205
- const agents: Record<string, { model?: string }> = { ...plan.agents };
1483
+ const agents: Record<string, { model?: string; thinking?: string }> = { ...plan.agents };
1206
1484
  if (params.agents) {
1207
1485
  for (const [name, entry] of Object.entries(params.agents)) {
1208
1486
  agents[name] = { ...agents[name], ...entry };
1209
1487
  }
1210
1488
  }
1211
1489
 
1490
+ // Validate the plan — same enforcement for main-session and planner plans.
1491
+ // Errors block squad creation; warnings are reported back to the plan author.
1492
+ const validation = validatePlan(plan.tasks);
1493
+ if (validation.errors.length > 0) {
1494
+ throw new Error(
1495
+ `Plan rejected:\n- ${validation.errors.join("\n- ")}\n\nFix the task list and call squad again.`,
1496
+ );
1497
+ }
1498
+
1212
1499
  // Create squad
1213
1500
  const config: SquadConfig = {
1214
1501
  ...DEFAULT_SQUAD_CONFIG,
@@ -1221,6 +1508,7 @@ async function startSquad(
1221
1508
  status: "running",
1222
1509
  created: store.now(),
1223
1510
  cwd,
1511
+ sessionFile,
1224
1512
  agents,
1225
1513
  config,
1226
1514
  };
@@ -1236,6 +1524,7 @@ async function startSquad(
1236
1524
  agent: taskDef.agent,
1237
1525
  status: taskDef.depends.length === 0 ? "pending" : "blocked",
1238
1526
  depends: taskDef.depends,
1527
+ ...(taskDef.inheritContext ? { inheritContext: true } : {}),
1239
1528
  created: store.now(),
1240
1529
  started: null,
1241
1530
  completed: null,
@@ -1243,22 +1532,14 @@ async function startSquad(
1243
1532
  error: null,
1244
1533
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
1245
1534
  };
1246
-
1247
- // Mark tasks with unmet deps as blocked
1248
- if (task.depends.length > 0) {
1249
- const allDepsMet = task.depends.every((depId) =>
1250
- plan.tasks.some((t) => t.id === depId),
1251
- );
1252
- if (!allDepsMet) {
1253
- task.status = "pending"; // deps reference external tasks, treat as ready
1254
- }
1255
- }
1535
+ // Note: unknown dependency references are hard validation errors above,
1536
+ // so blocked tasks here always have resolvable deps.
1256
1537
 
1257
1538
  store.createTask(squadId, task);
1258
1539
  }
1259
1540
 
1260
1541
  // Start scheduler
1261
- const scheduler = new Scheduler(squadId, skillPaths);
1542
+ const scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
1262
1543
  schedulers.set(squadId, scheduler);
1263
1544
  activeSquadId = squadId;
1264
1545
 
@@ -1286,13 +1567,16 @@ async function startSquad(
1286
1567
  completedSched.updateContext();
1287
1568
  }
1288
1569
 
1570
+ // followUp + triggerTurn: wake an idle main agent to review; if it's
1571
+ // mid-conversation with the human, deliver after it finishes
1289
1572
  pi.sendMessage({
1290
1573
  customType: "squad-completed",
1291
1574
  content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
1292
1575
  `Summary:\n${summary}\n\n` +
1293
- `Total cost: $${totalCost.toFixed(4)}`,
1576
+ `Total cost: $${totalCost.toFixed(4)}\n\n` +
1577
+ REVIEW_INSTRUCTIONS,
1294
1578
  display: true,
1295
- });
1579
+ }, { triggerTurn: true, deliverAs: "followUp" });
1296
1580
 
1297
1581
  // Clear scheduler but keep activeSquadId so squad_status still works
1298
1582
  schedulers.delete(squadId);
@@ -1312,7 +1596,7 @@ async function startSquad(
1312
1596
  `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
1313
1597
  `Use squad_status for details or squad_modify to adjust.`,
1314
1598
  display: true,
1315
- }, { triggerTurn: true });
1599
+ }, { triggerTurn: true, deliverAs: "followUp" });
1316
1600
  forceWidgetUpdate();
1317
1601
  break;
1318
1602
  }
@@ -1352,9 +1636,14 @@ async function startSquad(
1352
1636
  content: [
1353
1637
  {
1354
1638
  type: "text" as const,
1355
- text: `Squad "${squadId}" started with ${plan.tasks.length} tasks.\n\n${taskSummary}\n\nAgents are working in the background. Use squad_status to check progress.`,
1639
+ text: `Squad "${squadId}" started with ${plan.tasks.length} tasks.\n\n${taskSummary}${
1640
+ validation.warnings.length > 0
1641
+ ? `\n\n⚠️ Plan warnings (fix with squad_modify, or address at review):\n- ${validation.warnings.join("\n- ")}`
1642
+ : ""
1643
+ }\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.`,
1356
1644
  },
1357
1645
  ],
1646
+ details: undefined,
1358
1647
  };
1359
1648
  }
1360
1649