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 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 squad progress. Truncated to terminal width no wrapping, deterministic height.
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 running squad |
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; accepts `squadId` for exact same-squad failed-review rework |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
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 (pendingReviewGates.length === 0) return;
250
- return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\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
- if (pendingReviewGates.length > 0) {
259
- return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") };
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 (pendingReviewGates.length > 0) {
266
- return { systemPrompt: event.systemPrompt + "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\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} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
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
- (pendingReviewGates.length > 0 ? "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") : ""),
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
- ...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
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 one exact squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel. add_task/resume_task/resume reconstruct the persisted scheduler after restart.",
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 (recommended for failed-review rework; default: focused/recoverable project squad)" })),
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 done = tasks.filter((task) => task.status === "done").length;
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
- await activeScheduler.cancelTask(params.taskId);
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" is handled above, before the activeScheduler guard.
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}) — ${done}/${tasks.length} done. ` +
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
- const done = tasks.filter((task) => task.status === "done").length;
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 cancelSched = getActiveScheduler();
1107
- if (!cancelSched) {
1108
- ctx.ui.notify("No running squad to cancel", "info");
1200
+ const cancelledId = activeSquadId;
1201
+ if (!cancelledId) {
1202
+ ctx.ui.notify("No focused squad to cancel", "info");
1109
1203
  return;
1110
1204
  }
1111
- await cancelSched.stop();
1112
- const squad = store.loadSquad(activeSquadId!);
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}] ${done}/${tasks.length} $${cost.toFixed(2)}`;
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 icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
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} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}${project}`;
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}] ${done}/${tasks.length} $${cost.toFixed(2)}`, "info");
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
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
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 sIcon = squad.status === "done" ? th.fg("success", "✓")
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", `${doneCount}/${tasks.length}`)} ` +
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 = squad.status === "done"
167
- ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
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 ${doneCount}/${tasks.length}`)
170
- : squad.status === "review"
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