pi-squad 0.16.5 → 0.16.7
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/package.json +1 -1
- package/src/index.ts +158 -56
- package/src/panel/squad-widget.ts +27 -10
- package/src/panel/task-list.ts +19 -1
- package/src/presentation.ts +38 -0
- package/src/report.ts +9 -1
- package/src/review.ts +11 -2
- package/src/scheduler.ts +263 -16
- package/src/skills/squad-supervisor/SKILL.md +10 -4
- package/src/store.ts +10 -3
- package/src/types.ts +14 -1
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
|
|
|
56
56
|
- Failed review is reworked in the **same authoritative squad**: use `squad_modify` with that `squadId` and `add_task` or `resume_task` (or `resume` when interrupted work exists). These operations reconstruct the scheduler after restart. `/squad resume <squad-id>` provides the same resume path.
|
|
57
57
|
- When rework begins, the failed attempt moves to `reviewHistory`, the squad returns to `running`, and its evidence remains auditable. After every rework task settles, a fresh pending review becomes the active gate and `squad_review` is required again.
|
|
58
58
|
- Pending and failed review gates survive Pi restarts and are restored on the next session. A separate squad never links to, remediates, or accepts the failed gate.
|
|
59
|
+
- Every UI/status surface keeps execution progress adjacent to an explicit acceptance state: `◆ REVIEW PENDING · independent review required` means no verdict exists yet; `✗ REVIEW FAILED · awaiting same-squad rework` means the candidate was rejected. A truthful `3/3` task count never means a failed candidate was accepted.
|
|
59
60
|
|
|
60
61
|
The completion report is explicitly labeled **untrusted and not yet accepted**. Main Pi must never merely relay it or ask whether verification should be run.
|
|
61
62
|
|
|
@@ -77,6 +78,18 @@ architect → frontend ─┘
|
|
|
77
78
|
|
|
78
79
|
Architect runs first. Backend and frontend run in parallel after architect completes. QA waits for both.
|
|
79
80
|
|
|
81
|
+
### Suspended Work Requires Explicit Action
|
|
82
|
+
|
|
83
|
+
Suspension is an explicit pause, not a retry signal. If every remaining non-cancelled task is suspended or transitively blocked by suspended work, pi-squad durably wakes the main orchestrator once for that exact stall state. The widget shows `⚠ SUSPENDED — explicit resume required`; `squad_status` and the detail panel list every exact suspended task ID and blocked descendant.
|
|
84
|
+
|
|
85
|
+
Nothing resumes automatically. Choose each task intentionally with an exact squad and task ID:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
squad_modify({ action: "resume_task", squadId: "<exact-squad-id>", taskId: "<exact-task-id>" })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Repeated reconciliation and restart do not create notification storms. A delivered stall remains visible until explicit resumption changes the state; a different suspended/blocked set is a new actionable episode.
|
|
92
|
+
|
|
80
93
|
### QA Rework Loop
|
|
81
94
|
|
|
82
95
|
When a QA agent outputs `## Verdict: FAIL`, the scheduler automatically:
|
|
@@ -156,9 +169,13 @@ When the main agent provides tasks directly (via the `tasks` parameter), unknown
|
|
|
156
169
|
|
|
157
170
|
### Widget (above editor)
|
|
158
171
|
|
|
159
|
-
Shows live
|
|
172
|
+
Shows live execution progress plus a distinct acceptance/attention state. Truncation is viewport-only: no wrapping, deterministic height, with complete IDs and evidence retained for detail/status output.
|
|
160
173
|
|
|
161
174
|
```
|
|
175
|
+
◆ squad Build task API 3/3 · ◆ REVIEW PENDING · independent review required
|
|
176
|
+
✗ squad Build task API 3/3 · ✗ REVIEW FAILED · awaiting same-squad rework
|
|
177
|
+
⚠ SUSPENDED — explicit resume required · ^q detail
|
|
178
|
+
|
|
162
179
|
⏳ squad Build task API 2/3 $0.58 3m12s ^q detail · /squad msg
|
|
163
180
|
✓ api (backend) 2m12s Created CRUD REST API with validation
|
|
164
181
|
⏳ tests (qa) 45s → bash npm test
|
|
@@ -197,7 +214,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
197
214
|
| `/squad msg [task-id\|running-agent] text` | Message an exact task, or use an agent name only when it has one live task |
|
|
198
215
|
| `/squad widget` | Toggle widget |
|
|
199
216
|
| `/squad panel` | Toggle panel |
|
|
200
|
-
| `/squad cancel` | Cancel
|
|
217
|
+
| `/squad cancel` | Cancel the visibly focused squad; the notification names it and the focused widget/status clear |
|
|
201
218
|
| `/squad clear` | Dismiss widget |
|
|
202
219
|
| `/squad cleanup` | Delete squad data |
|
|
203
220
|
| `/squad enable/disable` | Enable/disable the extension |
|
|
@@ -209,7 +226,13 @@ Full overlay with task list, live activity preview, and scrollable message view.
|
|
|
209
226
|
| `squad` | Start a squad with goal + optional tasks/config |
|
|
210
227
|
| `squad_status` | Check progress, costs, task states |
|
|
211
228
|
| `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
|
|
212
|
-
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads
|
|
229
|
+
| `squad_modify` | Add/cancel/complete/pause/resume tasks or squads, or replace a task's dependencies with `set_dependencies`; exact `squadId` is required for destructive whole-squad `cancel` and recommended for all task actions |
|
|
230
|
+
|
|
231
|
+
Tool-level whole-squad cancellation never infers a target: `squad_modify({ action: "cancel", squadId: "<exact-squad-id>" })` affects only that persisted squad and names it in the result. Omitting `squadId` is rejected without changing any squad. Interactive `/squad cancel` instead uses the squad visibly focused in the current UI and names the affected squad.
|
|
232
|
+
|
|
233
|
+
Dependency repair uses top-level `taskId` and `depends`, for example `squad_modify({ action: "set_dependencies", taskId: "publish", depends: ["build"] })`. The replacement is validated atomically (known IDs, no self-reference, duplicates, or cycles) and is allowed only while the task is not running or done.
|
|
234
|
+
|
|
235
|
+
`cancel_task` is refused while any non-cancelled task directly depends on the target. Update every listed dependent explicitly with `set_dependencies`, then retry cancellation. Cancellation never cascades to dependents and never rewrites their dependency lists automatically; cancelled tasks remain visible in squad history and can be revived only with explicit `resume_task`.
|
|
213
236
|
|
|
214
237
|
The main agent sees available agents in its system prompt and squad state when a squad is active.
|
|
215
238
|
|
package/package.json
CHANGED
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, 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,6 +27,7 @@ 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";
|
|
30
31
|
|
|
31
32
|
// ============================================================================
|
|
32
33
|
// State
|
|
@@ -46,6 +47,16 @@ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = n
|
|
|
46
47
|
const widgetState: SquadWidgetState = { squadId: null, enabled: true };
|
|
47
48
|
let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
|
|
48
49
|
|
|
50
|
+
/** Format completion against active work while retaining cancelled history. */
|
|
51
|
+
function formatTaskProgress(tasks: Task[]): string {
|
|
52
|
+
const done = tasks.filter((task) => task.status === "done").length;
|
|
53
|
+
const cancelled = tasks.filter((task) => task.status === "cancelled").length;
|
|
54
|
+
const active = tasks.length - cancelled;
|
|
55
|
+
return cancelled > 0
|
|
56
|
+
? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
|
|
57
|
+
: `${done}/${tasks.length} tasks done`;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/**
|
|
50
61
|
* Resolve a model string (or null = session default) to its context window.
|
|
51
62
|
* Reads uiCtx lazily so it always uses the live session's registry.
|
|
@@ -176,6 +187,21 @@ function getActiveScheduler(): Scheduler | null {
|
|
|
176
187
|
return schedulers.get(activeSquadId) || null;
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
/** Restore the single focus/widget invariant after destructive exact cancellation. */
|
|
191
|
+
function repairFocusAfterCancellation(cancelledId: string): void {
|
|
192
|
+
if (activeSquadId === cancelledId || (activeSquadId !== null && !store.loadSquad(activeSquadId))) {
|
|
193
|
+
activeSquadId = null;
|
|
194
|
+
}
|
|
195
|
+
widgetState.squadId = activeSquadId;
|
|
196
|
+
widgetControls?.requestUpdate();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
|
|
200
|
+
return store.listSquadsForProject(cwd)
|
|
201
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention))
|
|
202
|
+
.map((squad) => ({ squadId: squad.id, attention: squad.suspendedStallAttention! }));
|
|
203
|
+
}
|
|
204
|
+
|
|
179
205
|
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
180
206
|
function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
181
207
|
let scheduler = schedulers.get(squadId);
|
|
@@ -234,6 +260,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
260
|
const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
|
|
235
261
|
const squadSkillPaths = getSquadSkillPaths(skillsDir);
|
|
236
262
|
|
|
263
|
+
/** Cancel one persisted exact squad without ever inferring or changing focus. */
|
|
264
|
+
const cancelExactSquad = async (squadId: string): Promise<boolean> => {
|
|
265
|
+
const squad = store.loadSquad(squadId);
|
|
266
|
+
if (!squad) return false;
|
|
267
|
+
const live = schedulers.get(squadId);
|
|
268
|
+
if (live) await live.stop();
|
|
269
|
+
else await new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext).stop();
|
|
270
|
+
const fresh = store.loadSquad(squadId);
|
|
271
|
+
if (fresh) {
|
|
272
|
+
fresh.status = "failed";
|
|
273
|
+
delete fresh.suspendedStallAttention;
|
|
274
|
+
store.saveSquad(fresh);
|
|
275
|
+
}
|
|
276
|
+
schedulers.delete(squadId);
|
|
277
|
+
repairFocusAfterCancellation(squadId);
|
|
278
|
+
return true;
|
|
279
|
+
};
|
|
280
|
+
|
|
237
281
|
// =========================================================================
|
|
238
282
|
// Context Injection — give main agent awareness of squad state
|
|
239
283
|
// =========================================================================
|
|
@@ -245,9 +289,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
245
289
|
const pendingReviewGates = store.findActiveSquads()
|
|
246
290
|
.filter((s) => s.cwd === ctx.cwd && s.status === "review")
|
|
247
291
|
.map((s) => ({ squad: s, gate: buildOrchestratorReviewGate(s, store.loadAllTasks(s.id)) }));
|
|
292
|
+
const suspendedAttention = activeSuspendedAttentionForProject(ctx.cwd)
|
|
293
|
+
.map(({ squadId, attention }) => formatSuspendedStallAttention(squadId, attention));
|
|
294
|
+
const durablePrompts = [...pendingReviewGates.map(({ gate }) => gate), ...suspendedAttention];
|
|
248
295
|
if (!squadEnabled) {
|
|
249
|
-
if (
|
|
250
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
296
|
+
if (durablePrompts.length === 0) return;
|
|
297
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
251
298
|
}
|
|
252
299
|
|
|
253
300
|
// When a squad is active, inject its status
|
|
@@ -255,38 +302,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
302
|
const squad = store.loadSquad(activeSquadId);
|
|
256
303
|
if (!squad) {
|
|
257
304
|
activeSquadId = null;
|
|
258
|
-
|
|
259
|
-
|
|
305
|
+
widgetState.squadId = null;
|
|
306
|
+
if (durablePrompts.length > 0) {
|
|
307
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
260
308
|
}
|
|
261
309
|
return;
|
|
262
310
|
}
|
|
263
311
|
const tasks = store.loadAllTasks(activeSquadId);
|
|
264
312
|
if (tasks.length === 0) {
|
|
265
|
-
if (
|
|
266
|
-
return { systemPrompt: event.systemPrompt + "\n\n" +
|
|
313
|
+
if (durablePrompts.length > 0) {
|
|
314
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + durablePrompts.join("\n\n") };
|
|
267
315
|
}
|
|
268
316
|
return;
|
|
269
317
|
}
|
|
270
318
|
|
|
271
|
-
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
272
319
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
273
320
|
|
|
274
321
|
const taskLines = tasks.map((t) => {
|
|
275
|
-
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
|
|
322
|
+
const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
|
|
276
323
|
let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
|
|
277
324
|
if (t.output) line += ` — ${t.output}`;
|
|
278
325
|
if (t.error) line += ` ERROR: ${t.error}`;
|
|
279
326
|
return line;
|
|
280
327
|
}).join("\n");
|
|
281
328
|
|
|
329
|
+
const reviewPresentation = getReviewPresentation(squad);
|
|
282
330
|
const squadContext = [
|
|
283
331
|
`<squad_status>`,
|
|
284
332
|
`Squad: ${squad.id} — ${squad.goal}`,
|
|
285
|
-
`Status: ${squad.status} | ${
|
|
333
|
+
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
334
|
+
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
286
335
|
taskLines,
|
|
287
336
|
`</squad_status>`,
|
|
288
337
|
...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
|
|
289
338
|
...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
|
|
339
|
+
...suspendedAttention,
|
|
290
340
|
`You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
|
|
291
341
|
`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.`,
|
|
292
342
|
].join("\n");
|
|
@@ -314,7 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
314
364
|
|
|
315
365
|
return {
|
|
316
366
|
systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
|
|
317
|
-
(
|
|
367
|
+
(durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
|
|
318
368
|
};
|
|
319
369
|
});
|
|
320
370
|
|
|
@@ -443,6 +493,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
493
|
task.status === "in_progress" ? "⏳" :
|
|
444
494
|
task.status === "blocked" ? "◻" :
|
|
445
495
|
task.status === "failed" ? "✗" :
|
|
496
|
+
task.status === "suspended" ? "⏸" :
|
|
497
|
+
task.status === "cancelled" ? "⊘" :
|
|
446
498
|
"·";
|
|
447
499
|
let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
|
|
448
500
|
if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
|
|
@@ -450,12 +502,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
450
502
|
})
|
|
451
503
|
.join("\n");
|
|
452
504
|
|
|
505
|
+
const durableTasks = store.loadAllTasks(id!);
|
|
506
|
+
const squad = store.loadSquad(id!);
|
|
507
|
+
const review = squad ? getReviewPresentation(squad) : null;
|
|
453
508
|
const summary = [
|
|
454
509
|
`Squad: ${id}`,
|
|
455
510
|
`Status: ${context.status}`,
|
|
511
|
+
`Progress: ${formatTaskProgress(durableTasks)}`,
|
|
456
512
|
`Elapsed: ${context.elapsed}`,
|
|
457
513
|
`Cost: $${context.costs.total.toFixed(4)}`,
|
|
458
|
-
...(
|
|
514
|
+
...(review ? [`Acceptance: ${review.label}`] : []),
|
|
515
|
+
...(squad ? formatSuspendedAttention(squad) : []),
|
|
459
516
|
"",
|
|
460
517
|
"Tasks:",
|
|
461
518
|
taskLines,
|
|
@@ -592,12 +649,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
592
649
|
pi.registerTool({
|
|
593
650
|
name: "squad_modify",
|
|
594
651
|
label: "Squad Modify",
|
|
595
|
-
description: "Modify
|
|
652
|
+
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.",
|
|
596
653
|
parameters: Type.Object({
|
|
597
|
-
squadId: Type.Optional(Type.String({ description: "Exact squad to modify
|
|
654
|
+
squadId: Type.Optional(Type.String({ description: "Exact squad to modify; required for cancel (other actions may use the focused/recoverable project squad)" })),
|
|
598
655
|
action: Type.Union(
|
|
599
656
|
[
|
|
600
657
|
Type.Literal("add_task"),
|
|
658
|
+
Type.Literal("set_dependencies"),
|
|
601
659
|
Type.Literal("cancel_task"),
|
|
602
660
|
Type.Literal("pause_task"),
|
|
603
661
|
Type.Literal("resume_task"),
|
|
@@ -609,6 +667,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
609
667
|
{ description: "Action to perform" },
|
|
610
668
|
),
|
|
611
669
|
taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
|
|
670
|
+
depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
|
|
612
671
|
output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
|
|
613
672
|
task: Type.Optional(
|
|
614
673
|
Type.Object({
|
|
@@ -623,6 +682,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
623
682
|
}),
|
|
624
683
|
|
|
625
684
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
685
|
+
if (params.action === "cancel") {
|
|
686
|
+
if (!params.squadId?.trim()) {
|
|
687
|
+
return { content: [{ type: "text" as const, text: "cancel requires exact squadId; no squad was changed." }], details: undefined };
|
|
688
|
+
}
|
|
689
|
+
const squadId = params.squadId.trim();
|
|
690
|
+
if (!store.loadSquad(squadId)) {
|
|
691
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' not found; no squad was changed.` }], details: undefined };
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
await cancelExactSquad(squadId);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return { content: [{ type: "text" as const, text: `Cancel failed for squad '${squadId}': ${(error as Error).message}` }], details: undefined };
|
|
697
|
+
}
|
|
698
|
+
return { content: [{ type: "text" as const, text: `Squad '${squadId}' cancelled.` }], details: undefined };
|
|
699
|
+
}
|
|
700
|
+
|
|
626
701
|
if (params.action === "resume") {
|
|
627
702
|
const squad = resolveResumeSquad(ctx.cwd, params.squadId);
|
|
628
703
|
if (!squad) {
|
|
@@ -638,8 +713,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
638
713
|
return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
|
|
639
714
|
}
|
|
640
715
|
const tasks = store.loadAllTasks(squad.id);
|
|
641
|
-
const
|
|
642
|
-
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
|
|
716
|
+
return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
|
|
643
717
|
}
|
|
644
718
|
|
|
645
719
|
const squadId = params.squadId || activeSquadId;
|
|
@@ -647,7 +721,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
647
721
|
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 };
|
|
648
722
|
}
|
|
649
723
|
let activeScheduler = schedulers.get(squadId) || null;
|
|
650
|
-
if (!activeScheduler && (params.action === "add_task" || params.action === "resume_task")) {
|
|
724
|
+
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")) {
|
|
651
725
|
activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
|
|
652
726
|
}
|
|
653
727
|
if (!activeScheduler) {
|
|
@@ -699,9 +773,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
699
773
|
return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
|
|
700
774
|
}
|
|
701
775
|
|
|
776
|
+
case "set_dependencies": {
|
|
777
|
+
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
778
|
+
if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
|
|
779
|
+
try {
|
|
780
|
+
await activeScheduler.setDependencies(params.taskId, params.depends);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
|
|
783
|
+
}
|
|
784
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
|
|
785
|
+
}
|
|
786
|
+
|
|
702
787
|
case "cancel_task": {
|
|
703
788
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
704
|
-
|
|
789
|
+
try {
|
|
790
|
+
await activeScheduler.cancelTask(params.taskId);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
|
|
793
|
+
}
|
|
705
794
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
|
|
706
795
|
}
|
|
707
796
|
|
|
@@ -714,9 +803,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
714
803
|
case "resume_task": {
|
|
715
804
|
if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
|
|
716
805
|
try {
|
|
717
|
-
await activeScheduler.resumeTask(params.taskId);
|
|
806
|
+
const result = await activeScheduler.resumeTask(params.taskId);
|
|
807
|
+
if (result === "already_running") {
|
|
808
|
+
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' is already running in squad '${squadId}'; no duplicate resume was started.` }], details: undefined };
|
|
809
|
+
}
|
|
718
810
|
} catch (err) {
|
|
719
|
-
return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
|
|
811
|
+
return { content: [{ type: "text" as const, text: `resume_task failed for task '${params.taskId}' in squad '${squadId}': ${(err as Error).message}` }], details: undefined };
|
|
720
812
|
}
|
|
721
813
|
return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
|
|
722
814
|
}
|
|
@@ -741,19 +833,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
741
833
|
return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
|
|
742
834
|
}
|
|
743
835
|
|
|
744
|
-
// Note: "resume"
|
|
745
|
-
|
|
746
|
-
case "cancel": {
|
|
747
|
-
await activeScheduler.stop();
|
|
748
|
-
const squad = store.loadSquad(activeSquadId);
|
|
749
|
-
if (squad) {
|
|
750
|
-
squad.status = "failed";
|
|
751
|
-
store.saveSquad(squad);
|
|
752
|
-
}
|
|
753
|
-
schedulers.delete(activeSquadId);
|
|
754
|
-
activeSquadId = null;
|
|
755
|
-
return { content: [{ type: "text" as const, text: "Squad cancelled." }], details: undefined };
|
|
756
|
-
}
|
|
836
|
+
// Note: "resume" and exact-ID "cancel" are handled above.
|
|
757
837
|
|
|
758
838
|
default:
|
|
759
839
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
|
|
@@ -794,12 +874,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
794
874
|
}
|
|
795
875
|
}
|
|
796
876
|
|
|
877
|
+
// Reconstruct schedulers for explicit-suspension stalls without resuming any
|
|
878
|
+
// task. Reconcile derives/persists a missing outbox record and emits only a
|
|
879
|
+
// pending fingerprint; delivered attention remains durable and silent.
|
|
880
|
+
const suspensionCandidates = store.listSquadsForProject(ctx.cwd)
|
|
881
|
+
.filter((squad) => Boolean(squad.suspendedStallAttention) || store.loadAllTasks(squad.id).some((task) => task.status === "suspended"));
|
|
882
|
+
for (const squad of suspensionCandidates) {
|
|
883
|
+
let scheduler = schedulers.get(squad.id);
|
|
884
|
+
if (!scheduler) {
|
|
885
|
+
scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
|
|
886
|
+
schedulers.set(squad.id, scheduler);
|
|
887
|
+
wireSchedulerEvents(pi, scheduler, squad.id);
|
|
888
|
+
}
|
|
889
|
+
await scheduler.start();
|
|
890
|
+
}
|
|
891
|
+
|
|
797
892
|
// Mailbox recovery is automatic after extension/main-process restart. Scan
|
|
798
893
|
// every project squad (including accepted done squads) because a crash can
|
|
799
894
|
// occur after the mailbox-first write but before the squad/task is reopened.
|
|
800
895
|
const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
|
|
801
896
|
.filter((squad) => store.loadAllTasks(squad.id)
|
|
802
|
-
.some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
897
|
+
.some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
|
|
803
898
|
.sort((a, b) => b.created.localeCompare(a.created));
|
|
804
899
|
for (const squad of pendingMailSquads) {
|
|
805
900
|
let scheduler = schedulers.get(squad.id);
|
|
@@ -828,7 +923,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
828
923
|
if (done > 0) {
|
|
829
924
|
pi.sendMessage({
|
|
830
925
|
customType: "squad-paused",
|
|
831
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${
|
|
926
|
+
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
832
927
|
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
833
928
|
display: true,
|
|
834
929
|
});
|
|
@@ -988,8 +1083,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1083
|
return;
|
|
989
1084
|
}
|
|
990
1085
|
const tasks = store.loadAllTasks(squad.id);
|
|
991
|
-
|
|
992
|
-
ctx.ui.notify(`Resumed: ${squad.id} (${done}/${tasks.length} done)`, "info");
|
|
1086
|
+
ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
|
|
993
1087
|
return;
|
|
994
1088
|
}
|
|
995
1089
|
|
|
@@ -1103,17 +1197,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1103
1197
|
}
|
|
1104
1198
|
|
|
1105
1199
|
case "cancel": {
|
|
1106
|
-
const
|
|
1107
|
-
if (!
|
|
1108
|
-
ctx.ui.notify("No
|
|
1200
|
+
const cancelledId = activeSquadId;
|
|
1201
|
+
if (!cancelledId) {
|
|
1202
|
+
ctx.ui.notify("No focused squad to cancel", "info");
|
|
1109
1203
|
return;
|
|
1110
1204
|
}
|
|
1111
|
-
await
|
|
1112
|
-
|
|
1113
|
-
if (squad) { squad.status = "failed"; store.saveSquad(squad); }
|
|
1114
|
-
if (activeSquadId) schedulers.delete(activeSquadId);
|
|
1115
|
-
forceWidgetUpdate();
|
|
1116
|
-
ctx.ui.notify("Squad cancelled", "info");
|
|
1205
|
+
await cancelExactSquad(cancelledId);
|
|
1206
|
+
ctx.ui.notify(`Squad '${cancelledId}' cancelled`, "info");
|
|
1117
1207
|
return;
|
|
1118
1208
|
}
|
|
1119
1209
|
|
|
@@ -1164,10 +1254,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1164
1254
|
"🗑 Delete ALL squads",
|
|
1165
1255
|
...squads.map((s) => {
|
|
1166
1256
|
const tasks = store.loadAllTasks(s.id);
|
|
1167
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1168
1257
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1169
1258
|
const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
|
|
1170
|
-
return `${icon} ${s.id} [${s.status}] ${
|
|
1259
|
+
return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
|
|
1171
1260
|
}),
|
|
1172
1261
|
];
|
|
1173
1262
|
|
|
@@ -1474,11 +1563,12 @@ async function pickSquad(
|
|
|
1474
1563
|
|
|
1475
1564
|
const options = squads.map((s) => {
|
|
1476
1565
|
const tasks = store.loadAllTasks(s.id);
|
|
1477
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1478
1566
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1479
|
-
const
|
|
1567
|
+
const review = getReviewPresentation(s);
|
|
1568
|
+
const icon = review?.icon ?? (s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "failed" ? "✗" : "·");
|
|
1569
|
+
const acceptance = review ? ` ${review.label}` : ` [${s.status}]`;
|
|
1480
1570
|
const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
|
|
1481
|
-
return `${icon} ${s.id}
|
|
1571
|
+
return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
|
|
1482
1572
|
});
|
|
1483
1573
|
|
|
1484
1574
|
const choice = await ctx.ui.select("Select a squad", options);
|
|
@@ -1511,9 +1601,8 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
|
|
|
1511
1601
|
// Compact notification — widget already shows full task details.
|
|
1512
1602
|
// Avoid large multi-line notifications that can break TUI layout.
|
|
1513
1603
|
const tasks = store.loadAllTasks(squadId);
|
|
1514
|
-
const done = tasks.filter((t) => t.status === "done").length;
|
|
1515
1604
|
const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
1516
|
-
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${
|
|
1605
|
+
ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
|
|
1517
1606
|
}
|
|
1518
1607
|
|
|
1519
1608
|
// ============================================================================
|
|
@@ -1549,14 +1638,27 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
|
|
|
1549
1638
|
// can reopen the task immediately on its bound durable Pi session.
|
|
1550
1639
|
break;
|
|
1551
1640
|
}
|
|
1641
|
+
case "suspended_stall": {
|
|
1642
|
+
try {
|
|
1643
|
+
const attention = event.data as SuspendedStallAttention;
|
|
1644
|
+
pi.sendMessage({
|
|
1645
|
+
customType: `squad-suspended-stall:${squadId}:${attention.fingerprint}`,
|
|
1646
|
+
content: formatSuspendedStallAttention(squadId, attention),
|
|
1647
|
+
display: true,
|
|
1648
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1649
|
+
scheduler.acknowledgeSuspendedStall(attention.fingerprint);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
logError("squad-scheduler", `suspended-stall delivery failed for ${squadId}: ${(error as Error).message}`);
|
|
1652
|
+
}
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1552
1655
|
case "squad_failed": {
|
|
1553
1656
|
const tasks = store.loadAllTasks(squadId);
|
|
1554
1657
|
const failed = tasks.filter((task) => task.status === "failed");
|
|
1555
|
-
const done = tasks.filter((task) => task.status === "done");
|
|
1556
1658
|
pi.sendMessage({
|
|
1557
1659
|
customType: "squad-failed",
|
|
1558
1660
|
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
1559
|
-
`${
|
|
1661
|
+
`${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
|
|
1560
1662
|
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
1561
1663
|
"Use squad_status for details or squad_modify to adjust.",
|
|
1562
1664
|
display: true,
|
|
@@ -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 {
|
|
@@ -23,6 +24,7 @@ function statusIcon(status: TaskStatus, th: Theme): string {
|
|
|
23
24
|
case "blocked": return th.fg("muted", "◻");
|
|
24
25
|
case "failed": return th.fg("error", "✗");
|
|
25
26
|
case "suspended": return th.fg("muted", "⏸");
|
|
27
|
+
case "cancelled": return th.fg("muted", "⊘");
|
|
26
28
|
default: return th.fg("dim", "·");
|
|
27
29
|
}
|
|
28
30
|
}
|
|
@@ -75,6 +77,11 @@ export function setupSquadWidget(
|
|
|
75
77
|
const lines: string[] = [];
|
|
76
78
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
77
79
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
80
|
+
const cancelledCount = tasks.filter((t) => t.status === "cancelled").length;
|
|
81
|
+
const activeCount = tasks.length - cancelledCount;
|
|
82
|
+
const progressText = cancelledCount > 0
|
|
83
|
+
? `${doneCount}/${activeCount} active tasks done · ${cancelledCount} cancelled · ${tasks.length} total`
|
|
84
|
+
: `${doneCount}/${tasks.length}`;
|
|
78
85
|
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
79
86
|
const taskMessages = new Map<string, TaskMessage[]>();
|
|
80
87
|
const recentOrchestratorByTask = new Map<string, TaskMessage>();
|
|
@@ -93,21 +100,25 @@ export function setupSquadWidget(
|
|
|
93
100
|
if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
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", "✓")
|
|
97
107
|
: squad.status === "failed" ? th.fg("error", "✗")
|
|
98
|
-
: squad.status === "review" ? th.fg("warning", "◆")
|
|
99
108
|
: th.fg("warning", "⏳");
|
|
100
109
|
|
|
101
110
|
const orchestratorSignal = recentOrchestratorByTask.size > 0
|
|
102
111
|
? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
|
|
103
112
|
: "";
|
|
113
|
+
const acceptanceText = review ? ` · ${th.fg(review.tone, review.label)}` : "";
|
|
104
114
|
lines.push(
|
|
105
115
|
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
106
|
-
`${th.fg("muted",
|
|
116
|
+
`${th.fg("muted", progressText)}${acceptanceText} ` +
|
|
107
117
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
108
118
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
109
119
|
`${th.fg("dim", "^q detail · /squad msg")}`
|
|
110
120
|
);
|
|
121
|
+
if (attention) lines.push(` ${th.fg("warning", SUSPENDED_ATTENTION_LABEL)} ${th.fg("dim", "· ^q detail")}`);
|
|
111
122
|
|
|
112
123
|
// Cap visible tasks based on total count
|
|
113
124
|
const maxVisible = tasks.length > 6 ? 4 : tasks.length;
|
|
@@ -144,6 +155,8 @@ export function setupSquadWidget(
|
|
|
144
155
|
line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
|
|
145
156
|
}
|
|
146
157
|
}
|
|
158
|
+
} else if (task.status === "cancelled") {
|
|
159
|
+
line += ` ${th.fg("muted", "cancelled")}`;
|
|
147
160
|
} else if (task.status === "blocked") {
|
|
148
161
|
const blockers = task.depends.filter((d) => {
|
|
149
162
|
const dep = tasks.find((t) => t.id === d);
|
|
@@ -161,15 +174,17 @@ export function setupSquadWidget(
|
|
|
161
174
|
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
162
175
|
}
|
|
163
176
|
|
|
164
|
-
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(",")}`;
|
|
165
178
|
|
|
166
|
-
const statusText =
|
|
167
|
-
? th.fg(
|
|
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"
|
|
184
|
+
? th.fg("success", `✓ squad ${progressText}`)
|
|
168
185
|
: squad.status === "failed"
|
|
169
|
-
? th.fg("error", `✗ squad ${
|
|
170
|
-
:
|
|
171
|
-
? th.fg("warning", `◆ squad review required`)
|
|
172
|
-
: th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
|
|
186
|
+
? th.fg("error", `✗ squad ${progressText}`)
|
|
187
|
+
: th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
|
|
173
188
|
|
|
174
189
|
return { lines, cacheKey, statusText };
|
|
175
190
|
}
|
|
@@ -208,6 +223,8 @@ export function setupSquadWidget(
|
|
|
208
223
|
ctx.ui.setWidget("squad-tasks", undefined);
|
|
209
224
|
ctx.ui.setStatus("squad", undefined);
|
|
210
225
|
widgetInstalled = false;
|
|
226
|
+
widgetComponent = null;
|
|
227
|
+
lastCacheKey = "";
|
|
211
228
|
return;
|
|
212
229
|
}
|
|
213
230
|
|