pi-squad 0.16.6 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/docs/behavioral-contract-exact-targeting-review-suspended-attention.md +204 -0
- package/docs/file-spec-and-full-read-attestation-contract.md +412 -0
- package/docs/fleet-bridge.md +79 -0
- package/package.json +2 -1
- package/src/agent-pool.ts +16 -4
- package/src/file-spec.ts +451 -0
- package/src/index.ts +209 -83
- package/src/panel/squad-widget.ts +16 -7
- package/src/panel/task-list.ts +10 -0
- package/src/presentation.ts +38 -0
- package/src/protocol.ts +21 -0
- package/src/review.ts +19 -6
- package/src/scheduler.ts +211 -18
- package/src/skills/squad-supervisor/SKILL.md +6 -1
- package/src/store.ts +39 -2
- package/src/types.ts +18 -1
package/src/index.ts
CHANGED
|
@@ -14,9 +14,9 @@ import * as path from "node:path";
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import type { Squad, Task, SquadConfig, PlannerOutput } from "./types.js";
|
|
17
|
+
import type { Squad, Task, SquadConfig, SquadAgentEntry, PlannerOutput, SuspendedStallAttention } from "./types.js";
|
|
18
18
|
import { DEFAULT_SQUAD_CONFIG, THINKING_LEVELS } from "./types.js";
|
|
19
|
-
import { Scheduler, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
|
|
19
|
+
import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
|
|
20
20
|
import { runPlanner } from "./planner.js";
|
|
21
21
|
import { validatePlan, PLAN_STRUCTURE_RULES } from "./plan-rules.js";
|
|
22
22
|
import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
|
|
@@ -27,11 +27,20 @@ import * as store from "./store.js";
|
|
|
27
27
|
import { debug, logError } from "./logger.js";
|
|
28
28
|
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
29
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";
|
|
30
32
|
|
|
31
33
|
// ============================================================================
|
|
32
34
|
// State
|
|
33
35
|
// ============================================================================
|
|
34
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
|
+
|
|
35
44
|
/** Master switch — when false, all squad tools, hooks, and widget are disabled */
|
|
36
45
|
let squadEnabled = true;
|
|
37
46
|
/** Registry of all running schedulers — supports multiple concurrent squads */
|
|
@@ -186,6 +195,21 @@ function getActiveScheduler(): Scheduler | null {
|
|
|
186
195
|
return schedulers.get(activeSquadId) || null;
|
|
187
196
|
}
|
|
188
197
|
|
|
198
|
+
/** Restore the single focus/widget invariant after destructive exact cancellation. */
|
|
199
|
+
function repairFocusAfterCancellation(cancelledId: string): void {
|
|
200
|
+
if (activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))) {
|
|
201
|
+
activeSquadId = null;
|
|
202
|
+
}
|
|
203
|
+
widgetState.squadId = activeSquadId;
|
|
204
|
+
widgetControls?.requestUpdate();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
|
|
208
|
+
return store.listSquadsForProject(cwd)
|
|
209
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention))
|
|
210
|
+
.map((squad) => ({ squadId: squad.id, attention: squad.suspendedStallAttention! }));
|
|
211
|
+
}
|
|
212
|
+
|
|
189
213
|
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
190
214
|
function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
191
215
|
let scheduler = schedulers.get(squadId);
|
|
@@ -224,8 +248,8 @@ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
|
|
|
224
248
|
// ============================================================================
|
|
225
249
|
|
|
226
250
|
export default function (pi: ExtensionAPI) {
|
|
227
|
-
//
|
|
228
|
-
if (process.env.PI_SQUAD_CHILD === "1") return;
|
|
251
|
+
// File-spec children load only the non-recursive reader and fail-closed guard.
|
|
252
|
+
if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
|
|
229
253
|
|
|
230
254
|
// Wire main-session thinking lookup (needs `pi`, guarded against stale API)
|
|
231
255
|
getMainSessionThinking = () => {
|
|
@@ -244,6 +268,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
268
|
const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
|
|
245
269
|
const squadSkillPaths = getSquadSkillPaths(skillsDir);
|
|
246
270
|
|
|
271
|
+
/** Cancel one persisted exact squad without ever inferring or changing focus. */
|
|
272
|
+
const cancelExactSquad = async (squadId: string): Promise<boolean> => {
|
|
273
|
+
const squad = store.loadSquad(squadId);
|
|
274
|
+
if (!squad) return false;
|
|
275
|
+
const live = schedulers.get(squadId);
|
|
276
|
+
if (live) await live.stop();
|
|
277
|
+
else await new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext).stop();
|
|
278
|
+
const fresh = store.loadSquad(squadId);
|
|
279
|
+
if (fresh) {
|
|
280
|
+
fresh.status = "failed";
|
|
281
|
+
delete fresh.suspendedStallAttention;
|
|
282
|
+
store.saveSquad(fresh);
|
|
283
|
+
}
|
|
284
|
+
schedulers.delete(squadId);
|
|
285
|
+
repairFocusAfterCancellation(squadId);
|
|
286
|
+
return true;
|
|
287
|
+
};
|
|
288
|
+
|
|
247
289
|
// =========================================================================
|
|
248
290
|
// Context Injection — give main agent awareness of squad state
|
|
249
291
|
// =========================================================================
|
|
@@ -255,9 +297,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
297
|
const pendingReviewGates = store.findActiveSquads()
|
|
256
298
|
.filter((s) => s.cwd === ctx.cwd && s.status === "review")
|
|
257
299
|
.map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
|
|
300
|
+
const suspendedAttention = activeSuspendedAttentionForProject(ctx.cwd)
|
|
301
|
+
.map(({ squadId, attention }) => formatSuspendedStallAttention(squadId, attention));
|
|
302
|
+
const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
|
|
258
303
|
if (!squadEnabled) {
|
|
259
|
-
if (
|
|
260
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
304
|
+
if (durablePrompts.length === 0) return;
|
|
305
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
261
306
|
}
|
|
262
307
|
|
|
263
308
|
// When a squad is active, inject its status
|
|
@@ -265,15 +310,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
265
310
|
const squad = store.loadSquad(activeSquadId);
|
|
266
311
|
if (!squad) {
|
|
267
312
|
activeSquadId = null;
|
|
268
|
-
|
|
269
|
-
|
|
313
|
+
widgetState.squadId = null;
|
|
314
|
+
if (durablePrompts.length > 0) {
|
|
315
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
270
316
|
}
|
|
271
317
|
return;
|
|
272
318
|
}
|
|
273
319
|
const tasks = store.loadAllTasks(activeSquadId);
|
|
274
320
|
if (tasks.length === 0) {
|
|
275
|
-
if (
|
|
276
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
321
|
+
if (durablePrompts.length > 0) {
|
|
322
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
277
323
|
}
|
|
278
324
|
return;
|
|
279
325
|
}
|
|
@@ -281,21 +327,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
327
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
282
328
|
|
|
283
329
|
const taskLines = tasks.map((t) => {
|
|
284
|
-
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
|
|
330
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
|
|
285
331
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
286
332
|
if (t.output) line += ` — ${t.output}`;
|
|
287
333
|
if (t.error) line += ` ERROR: ${t.error}`;
|
|
288
334
|
return line;
|
|
289
335
|
}).join("\n");
|
|
290
336
|
|
|
337
|
+
const reviewPresentation = getReviewPresentation(squad);
|
|
338
|
+
const squadReference = squad.spec
|
|
339
|
+
? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
|
|
340
|
+
: squad.goal;
|
|
291
341
|
const squadContext = [
|
|
292
342
|
`<squad_status>`,
|
|
293
|
-
`Squad: ${squad.id} — ${
|
|
343
|
+
`Squad: ${squad.id} — ${squadReference}`,
|
|
294
344
|
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
345
|
+
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
295
346
|
taskLines,
|
|
296
347
|
`</squad_status>`,
|
|
297
348
|
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
298
349
|
...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
|
|
350
|
+
...suspendedAttention,
|
|
299
351
|
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
300
352
|
`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.`,
|
|
301
353
|
].join("\n");
|
|
@@ -323,7 +375,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
375
|
|
|
324
376
|
return {
|
|
325
377
|
systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
|
|
326
|
-
(
|
|
378
|
+
(durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
|
|
327
379
|
};
|
|
328
380
|
});
|
|
329
381
|
|
|
@@ -347,16 +399,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
347
399
|
PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
|
|
348
400
|
"Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
|
|
349
401
|
].join(" "),
|
|
350
|
-
promptSnippet: "squad({ goal, tasks?, agents? }):
|
|
402
|
+
promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
|
|
351
403
|
promptGuidelines: [
|
|
352
404
|
"Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
|
|
405
|
+
"For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
|
|
353
406
|
"Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
|
|
354
407
|
"Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
|
|
355
408
|
"Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
|
|
356
409
|
"After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
|
|
357
410
|
"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",
|
|
358
411
|
],
|
|
359
|
-
parameters: Type.
|
|
412
|
+
parameters: Type.Union([
|
|
413
|
+
Type.Object({
|
|
360
414
|
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." }),
|
|
361
415
|
agents: Type.Optional(
|
|
362
416
|
Type.Record(
|
|
@@ -386,7 +440,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
386
440
|
maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
|
|
387
441
|
}),
|
|
388
442
|
),
|
|
389
|
-
}),
|
|
443
|
+
}, { additionalProperties: false }),
|
|
444
|
+
Type.Object({
|
|
445
|
+
specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
|
|
446
|
+
specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
|
|
447
|
+
}, { additionalProperties: false }),
|
|
448
|
+
]),
|
|
390
449
|
|
|
391
450
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
392
451
|
if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
|
|
@@ -401,13 +460,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
401
460
|
// cwd may differ (e.g. when a relative --session-dir was used).
|
|
402
461
|
const rawSessionFile = ctx.sessionManager.getSessionFile();
|
|
403
462
|
const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
463
|
+
let prepared: PreparedSpec | undefined;
|
|
464
|
+
let effective: InlineSquadStart;
|
|
465
|
+
if ("specFile" in params) {
|
|
466
|
+
prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
|
|
467
|
+
effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
|
|
468
|
+
} else {
|
|
469
|
+
effective = params;
|
|
408
470
|
}
|
|
409
|
-
|
|
410
|
-
|
|
471
|
+
const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
|
|
472
|
+
const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
|
|
473
|
+
return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
|
|
411
474
|
},
|
|
412
475
|
});
|
|
413
476
|
|
|
@@ -452,6 +515,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
452
515
|
task.status === "in_progress" ? "⏳" :
|
|
453
516
|
task.status === "blocked" ? "◻" :
|
|
454
517
|
task.status === "failed" ? "✗" :
|
|
518
|
+
task.status === "suspended" ? "⏸" :
|
|
455
519
|
task.status === "cancelled" ? "⊘" :
|
|
456
520
|
"·";
|
|
457
521
|
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
@@ -461,13 +525,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
461
525
|
.join("\n");
|
|
462
526
|
|
|
463
527
|
const durableTasks = store.loadAllTasks(id!);
|
|
528
|
+
const squad = store.loadSquad(id!);
|
|
529
|
+
const review = squad ? getReviewPresentation(squad) : null;
|
|
464
530
|
const summary = [
|
|
465
531
|
`Squad: ${id}`,
|
|
466
532
|
`Status: ${context.status}`,
|
|
467
533
|
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
468
534
|
`Elapsed: ${context.elapsed}`,
|
|
469
535
|
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
470
|
-
...(
|
|
536
|
+
...(review ? [`Acceptance: ${review.label}`] : []),
|
|
537
|
+
...(squad ? formatSuspendedAttention(squad) : []),
|
|
471
538
|
"",
|
|
472
539
|
"Tasks:",
|
|
473
540
|
taskLines,
|
|
@@ -517,6 +584,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
517
584
|
if (!squad) {
|
|
518
585
|
return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
|
|
519
586
|
}
|
|
587
|
+
const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
|
|
588
|
+
if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
|
|
589
|
+
const invalidAttestations = await attestationScheduler.auditSpecAttestations();
|
|
590
|
+
if (invalidAttestations.length > 0) {
|
|
591
|
+
void attestationScheduler.start();
|
|
592
|
+
return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
|
|
593
|
+
}
|
|
520
594
|
|
|
521
595
|
try {
|
|
522
596
|
recordOrchestratorReview(squad, {
|
|
@@ -604,9 +678,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
678
|
pi.registerTool({
|
|
605
679
|
name: "squad_modify",
|
|
606
680
|
label: "Squad Modify",
|
|
607
|
-
description: "Modify
|
|
681
|
+
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.",
|
|
608
682
|
parameters: Type.Object({
|
|
609
|
-
squadId: Type.Optional(Type.String({ description: "Exact squad to modify
|
|
683
|
+
squadId: Type.Optional(Type.String({ description: "Exact squad to modify; required for cancel (other actions may use the focused/recoverable project squad)" })),
|
|
610
684
|
action: Type.Union(
|
|
611
685
|
[
|
|
612
686
|
Type.Literal("add_task"),
|
|
@@ -637,6 +711,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
637
711
|
}),
|
|
638
712
|
|
|
639
713
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
714
|
+
if (params.action === "cancel") {
|
|
715
|
+
if (!params.squadId?.trim()) {
|
|
716
|
+
return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
|
|
717
|
+
}
|
|
718
|
+
const squadId = params.squadId.trim();
|
|
719
|
+
if (!store.loadSquad(squadId)) {
|
|
720
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' not found; no squad was changed.` }], details: undefined };
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
await cancelExactSquad(squadId);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
return { content: [{ type: "text" as const, text: `Cancel failed for squad '${squadId}': ${(error as Error).message}` }], details: undefined };
|
|
726
|
+
}
|
|
727
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' cancelled.` }], details: undefined };
|
|
728
|
+
}
|
|
729
|
+
|
|
640
730
|
if (params.action === "resume") {
|
|
641
731
|
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
642
732
|
if (!squad) {
|
|
@@ -674,7 +764,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
674
764
|
return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
|
|
675
765
|
}
|
|
676
766
|
// Validate against the live squad: deps must exist, agent must exist
|
|
677
|
-
const
|
|
767
|
+
const targetSquad = store.loadSquad(squadId)!;
|
|
768
|
+
if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
|
|
769
|
+
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 };
|
|
770
|
+
}
|
|
771
|
+
const existing = store.loadAllTasks(squadId);
|
|
678
772
|
const existingIds = new Set(existing.map((t) => t.id));
|
|
679
773
|
if (existingIds.has(params.task.id)) {
|
|
680
774
|
return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
|
|
@@ -697,6 +791,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
791
|
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
698
792
|
depends: dependencies,
|
|
699
793
|
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
794
|
+
...(targetSquad.spec ? { fileSpecDelta: true } : {}),
|
|
700
795
|
created: store.now(),
|
|
701
796
|
started: null,
|
|
702
797
|
completed: null,
|
|
@@ -742,9 +837,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
742
837
|
case "resume_task": {
|
|
743
838
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
744
839
|
try {
|
|
745
|
-
await activeScheduler.resumeTask(params.taskId);
|
|
840
|
+
const result = await activeScheduler.resumeTask(params.taskId);
|
|
841
|
+
if (result === "already_running") {
|
|
842
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' is already running in squad '${squadId}'; no duplicate resume was started.` }], details: undefined };
|
|
843
|
+
}
|
|
746
844
|
} catch (err) {
|
|
747
|
-
return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
|
|
845
|
+
return { content: [{ type: "text" as const, text: `resume_task failed for task '${params.taskId}' in squad '${squadId}': ${(err as Error).message}` }], details: undefined };
|
|
748
846
|
}
|
|
749
847
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
750
848
|
}
|
|
@@ -760,7 +858,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
760
858
|
}
|
|
761
859
|
|
|
762
860
|
case "pause": {
|
|
763
|
-
const squad = store.loadSquad(
|
|
861
|
+
const squad = store.loadSquad(squadId);
|
|
764
862
|
if (squad) {
|
|
765
863
|
squad.status = "paused";
|
|
766
864
|
store.saveSquad(squad);
|
|
@@ -769,19 +867,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
769
867
|
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
|
|
770
868
|
}
|
|
771
869
|
|
|
772
|
-
// Note: "resume"
|
|
773
|
-
|
|
774
|
-
case "cancel": {
|
|
775
|
-
await activeScheduler.stop();
|
|
776
|
-
const squad = store.loadSquad(activeSquadId);
|
|
777
|
-
if (squad) {
|
|
778
|
-
squad.status = "failed";
|
|
779
|
-
store.saveSquad(squad);
|
|
780
|
-
}
|
|
781
|
-
schedulers.delete(activeSquadId);
|
|
782
|
-
activeSquadId = null;
|
|
783
|
-
return { content: [{ type: "text" as const, text: "Squad cancelled." }], details: undefined };
|
|
784
|
-
}
|
|
870
|
+
// Note: "resume" and exact-ID "cancel" are handled above.
|
|
785
871
|
|
|
786
872
|
default:
|
|
787
873
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
|
|
@@ -822,6 +908,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
822
908
|
}
|
|
823
909
|
}
|
|
824
910
|
|
|
911
|
+
// Audit file-spec evidence on restart. Review/running/failed work is reopened;
|
|
912
|
+
// an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
|
|
913
|
+
for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
|
|
914
|
+
let scheduler = schedulers.get(squad.id);
|
|
915
|
+
if (!scheduler) { scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext); schedulers.set(squad.id, scheduler); wireSchedulerEvents(pi, scheduler, squad.id); }
|
|
916
|
+
const invalid = await scheduler.auditSpecAttestations();
|
|
917
|
+
if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Reconstruct schedulers for explicit-suspension stalls without resuming any
|
|
921
|
+
// task. Reconcile derives/persists a missing outbox record and emits only a
|
|
922
|
+
// pending fingerprint; delivered attention remains durable and silent.
|
|
923
|
+
const suspensionCandidates = store.listSquadsForProject(ctx.cwd)
|
|
924
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention) || store.loadAllTasks(squad.id).some((task) => task.status === "suspended"));
|
|
925
|
+
for (const squad of suspensionCandidates) {
|
|
926
|
+
let scheduler = schedulers.get(squad.id);
|
|
927
|
+
if (!scheduler) {
|
|
928
|
+
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
929
|
+
schedulers.set(squad.id, scheduler);
|
|
930
|
+
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
931
|
+
}
|
|
932
|
+
await scheduler.start();
|
|
933
|
+
}
|
|
934
|
+
|
|
825
935
|
// Mailbox recovery is automatic after extension/main-process restart. Scan
|
|
826
936
|
// every project squad (including accepted done squads) because a crash can
|
|
827
937
|
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
@@ -856,7 +966,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
856
966
|
if (done > 0) {
|
|
857
967
|
pi.sendMessage({
|
|
858
968
|
customType: "squad-paused",
|
|
859
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
969
|
+
content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
860
970
|
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
861
971
|
display: true,
|
|
862
972
|
});
|
|
@@ -1130,17 +1240,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1130
1240
|
}
|
|
1131
1241
|
|
|
1132
1242
|
case "cancel": {
|
|
1133
|
-
const
|
|
1134
|
-
if (!
|
|
1135
|
-
ctx.ui.notify("No
|
|
1243
|
+
const cancelledId = activeSquadId;
|
|
1244
|
+
if (!cancelledId) {
|
|
1245
|
+
ctx.ui.notify("No focused squad to cancel", "info");
|
|
1136
1246
|
return;
|
|
1137
1247
|
}
|
|
1138
|
-
await
|
|
1139
|
-
|
|
1140
|
-
if (squad) { squad.status = "failed"; store.saveSquad(squad); }
|
|
1141
|
-
if (activeSquadId) schedulers.delete(activeSquadId);
|
|
1142
|
-
forceWidgetUpdate();
|
|
1143
|
-
ctx.ui.notify("Squad cancelled", "info");
|
|
1248
|
+
await cancelExactSquad(cancelledId);
|
|
1249
|
+
ctx.ui.notify(`Squad '${cancelledId}' cancelled`, "info");
|
|
1144
1250
|
return;
|
|
1145
1251
|
}
|
|
1146
1252
|
|
|
@@ -1501,9 +1607,11 @@ async function pickSquad(
|
|
|
1501
1607
|
const options = squads.map((s) => {
|
|
1502
1608
|
const tasks = store.loadAllTasks(s.id);
|
|
1503
1609
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1504
|
-
const
|
|
1610
|
+
const review = getReviewPresentation(s);
|
|
1611
|
+
const icon = review?.icon ?? (s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·");
|
|
1612
|
+
const acceptance = review ? ` ${review.label}` : ` [${s.status}]`;
|
|
1505
1613
|
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1506
|
-
return `${icon} ${s.id}
|
|
1614
|
+
return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1507
1615
|
});
|
|
1508
1616
|
|
|
1509
1617
|
const choice = await ctx.ui.select("Select a squad", options);
|
|
@@ -1573,6 +1681,20 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
|
|
|
1573
1681
|
// can reopen the task immediately on its bound durable Pi session.
|
|
1574
1682
|
break;
|
|
1575
1683
|
}
|
|
1684
|
+
case "suspended_stall": {
|
|
1685
|
+
try {
|
|
1686
|
+
const attention = event.data as SuspendedStallAttention;
|
|
1687
|
+
pi.sendMessage({
|
|
1688
|
+
customType: `squad-suspended-stall:${squadId}:${attention.fingerprint}`,
|
|
1689
|
+
content: formatSuspendedStallAttention(squadId, attention),
|
|
1690
|
+
display: true,
|
|
1691
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1692
|
+
scheduler.acknowledgeSuspendedStall(attention.fingerprint);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
logError("squad-scheduler", `suspended-stall delivery failed for ${squadId}: ${(error as Error).message}`);
|
|
1695
|
+
}
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1576
1698
|
case "squad_failed": {
|
|
1577
1699
|
const tasks = store.loadAllTasks(squadId);
|
|
1578
1700
|
const failed = tasks.filter((task) => task.status === "failed");
|
|
@@ -1678,23 +1800,12 @@ function openPanel(
|
|
|
1678
1800
|
|
|
1679
1801
|
async function startSquad(
|
|
1680
1802
|
squadId: string,
|
|
1681
|
-
params:
|
|
1682
|
-
goal: string;
|
|
1683
|
-
agents?: Record<string, { model?: string; thinking?: string }>;
|
|
1684
|
-
tasks?: Array<{
|
|
1685
|
-
id: string;
|
|
1686
|
-
title: string;
|
|
1687
|
-
description?: string;
|
|
1688
|
-
agent: string;
|
|
1689
|
-
depends?: string[];
|
|
1690
|
-
inheritContext?: boolean;
|
|
1691
|
-
}>;
|
|
1692
|
-
config?: { maxConcurrency?: number };
|
|
1693
|
-
},
|
|
1803
|
+
params: InlineSquadStart,
|
|
1694
1804
|
cwd: string,
|
|
1695
1805
|
skillPaths: string[],
|
|
1696
1806
|
pi: ExtensionAPI,
|
|
1697
1807
|
sessionFile: string | null = null,
|
|
1808
|
+
preparedSpec?: PreparedSpec,
|
|
1698
1809
|
) {
|
|
1699
1810
|
let plan: PlannerOutput;
|
|
1700
1811
|
|
|
@@ -1712,7 +1823,8 @@ async function startSquad(
|
|
|
1712
1823
|
// Validate agent names — remap unknown agents to fullstack
|
|
1713
1824
|
for (const task of plan.tasks) {
|
|
1714
1825
|
const agentDef = store.loadAgentDef(task.agent, cwd);
|
|
1715
|
-
if (!agentDef) {
|
|
1826
|
+
if (!agentDef || agentDef.disabled) {
|
|
1827
|
+
if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
|
|
1716
1828
|
const original = task.agent;
|
|
1717
1829
|
task.agent = "fullstack";
|
|
1718
1830
|
task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
|
|
@@ -1730,7 +1842,7 @@ async function startSquad(
|
|
|
1730
1842
|
}
|
|
1731
1843
|
|
|
1732
1844
|
// Merge agent roster
|
|
1733
|
-
const agents: Record<string,
|
|
1845
|
+
const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
|
|
1734
1846
|
if (params.agents) {
|
|
1735
1847
|
for (const [name, entry] of Object.entries(params.agents)) {
|
|
1736
1848
|
agents[name] = { ...agents[name], ...entry };
|
|
@@ -1750,6 +1862,8 @@ async function startSquad(
|
|
|
1750
1862
|
const config: SquadConfig = {
|
|
1751
1863
|
...DEFAULT_SQUAD_CONFIG,
|
|
1752
1864
|
...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
|
|
1865
|
+
...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
|
|
1866
|
+
...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
|
|
1753
1867
|
};
|
|
1754
1868
|
|
|
1755
1869
|
const squad: Squad = {
|
|
@@ -1761,12 +1875,18 @@ async function startSquad(
|
|
|
1761
1875
|
sessionFile,
|
|
1762
1876
|
agents,
|
|
1763
1877
|
config,
|
|
1878
|
+
...(preparedSpec ? { spec: {
|
|
1879
|
+
schemaVersion: 1 as const,
|
|
1880
|
+
sha256: preparedSpec.sha256,
|
|
1881
|
+
bytes: preparedSpec.raw.length,
|
|
1882
|
+
path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
|
|
1883
|
+
chunkBytes: 32768 as const,
|
|
1884
|
+
chunkCount: chunkRanges(preparedSpec.raw).length,
|
|
1885
|
+
} } : {}),
|
|
1764
1886
|
};
|
|
1765
1887
|
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
// Create task files
|
|
1769
|
-
for (const taskDef of plan.tasks) {
|
|
1888
|
+
// Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
|
|
1889
|
+
const initialTasks: Task[] = plan.tasks.map((taskDef) => {
|
|
1770
1890
|
const task: Task = {
|
|
1771
1891
|
id: taskDef.id,
|
|
1772
1892
|
title: taskDef.title,
|
|
@@ -1782,10 +1902,13 @@ async function startSquad(
|
|
|
1782
1902
|
error: null,
|
|
1783
1903
|
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
1784
1904
|
};
|
|
1785
|
-
|
|
1786
|
-
|
|
1905
|
+
return task;
|
|
1906
|
+
});
|
|
1787
1907
|
|
|
1788
|
-
|
|
1908
|
+
if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
|
|
1909
|
+
else {
|
|
1910
|
+
store.saveSquad(squad);
|
|
1911
|
+
for (const task of initialTasks) store.createTask(squadId, task);
|
|
1789
1912
|
}
|
|
1790
1913
|
|
|
1791
1914
|
// Start scheduler
|
|
@@ -1809,13 +1932,16 @@ async function startSquad(
|
|
|
1809
1932
|
logError("squad", `Scheduler start error: ${(err as Error).message}`);
|
|
1810
1933
|
});
|
|
1811
1934
|
|
|
1812
|
-
// Build response
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1935
|
+
// Build response. File mode returns only the bounded descriptor; the canonical
|
|
1936
|
+
// contract is never reflected back into the main model's transport.
|
|
1937
|
+
const taskSummary = preparedSpec
|
|
1938
|
+
? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
|
|
1939
|
+
: plan.tasks
|
|
1940
|
+
.map((t) => {
|
|
1941
|
+
const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
|
|
1942
|
+
return `${t.id} → ${t.agent}: ${t.title}${deps}`;
|
|
1943
|
+
})
|
|
1944
|
+
.join("\n");
|
|
1819
1945
|
|
|
1820
1946
|
return {
|
|
1821
1947
|
content: [
|
|
@@ -14,6 +14,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
14
14
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { TaskMessage, TaskStatus } from "../types.js";
|
|
17
|
+
import { getReviewPresentation, getSuspendedAttention, SUSPENDED_ATTENTION_LABEL } from "../presentation.js";
|
|
17
18
|
import * as store from "../store.js";
|
|
18
19
|
|
|
19
20
|
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
@@ -99,21 +100,25 @@ export function setupSquadWidget(
|
|
|
99
100
|
if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const
|
|
103
|
+
const review = getReviewPresentation(squad);
|
|
104
|
+
const attention = getSuspendedAttention(squad);
|
|
105
|
+
const sIcon = review ? th.fg(review.tone, review.icon)
|
|
106
|
+
: squad.status === "done" ? th.fg("success", "✓")
|
|
103
107
|
: squad.status === "failed" ? th.fg("error", "✗")
|
|
104
|
-
: squad.status === "review" ? th.fg("warning", "◆")
|
|
105
108
|
: th.fg("warning", "⏳");
|
|
106
109
|
|
|
107
110
|
const orchestratorSignal = recentOrchestratorByTask.size > 0
|
|
108
111
|
? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
|
|
109
112
|
: "";
|
|
113
|
+
const acceptanceText = review ? ` · ${th.fg(review.tone, review.label)}` : "";
|
|
110
114
|
lines.push(
|
|
111
115
|
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
112
|
-
`${th.fg("muted", progressText)} ` +
|
|
116
|
+
`${th.fg("muted", progressText)}${acceptanceText} ` +
|
|
113
117
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
114
118
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
115
119
|
`${th.fg("dim", "^q detail · /squad msg")}`
|
|
116
120
|
);
|
|
121
|
+
if (attention) lines.push(` ${th.fg("warning", SUSPENDED_ATTENTION_LABEL)} ${th.fg("dim", "· ^q detail")}`);
|
|
117
122
|
|
|
118
123
|
// Cap visible tasks based on total count
|
|
119
124
|
const maxVisible = tasks.length > 6 ? 4 : tasks.length;
|
|
@@ -169,14 +174,16 @@ export function setupSquadWidget(
|
|
|
169
174
|
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
177
|
+
const cacheKey = `${state.squadId}:${squad.status}:${squad.review?.status ?? "none"}:${attention?.fingerprint ?? "no-attention"}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
173
178
|
|
|
174
|
-
const statusText =
|
|
179
|
+
const statusText = review
|
|
180
|
+
? th.fg(review.tone, `${review.label} · ${progressText}`)
|
|
181
|
+
: attention
|
|
182
|
+
? th.fg("warning", `${SUSPENDED_ATTENTION_LABEL} · ${progressText}`)
|
|
183
|
+
: squad.status === "done"
|
|
175
184
|
? th.fg("success", `✓ squad ${progressText}`)
|
|
176
185
|
: squad.status === "failed"
|
|
177
186
|
? th.fg("error", `✗ squad ${progressText}`)
|
|
178
|
-
: squad.status === "review"
|
|
179
|
-
? th.fg("warning", `◆ squad review required`)
|
|
180
187
|
: th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
|
|
181
188
|
|
|
182
189
|
return { lines, cacheKey, statusText };
|
|
@@ -216,6 +223,8 @@ export function setupSquadWidget(
|
|
|
216
223
|
ctx.ui.setWidget("squad-tasks", undefined);
|
|
217
224
|
ctx.ui.setStatus("squad", undefined);
|
|
218
225
|
widgetInstalled = false;
|
|
226
|
+
widgetComponent = null;
|
|
227
|
+
lastCacheKey = "";
|
|
219
228
|
return;
|
|
220
229
|
}
|
|
221
230
|
|