pi-squad 0.16.6 → 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,9 @@ 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, or replace a task's dependencies with `set_dependencies`; 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.
213
232
 
214
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.
215
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.6",
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
@@ -186,6 +187,21 @@ function getActiveScheduler(): Scheduler | null {
186
187
  return schedulers.get(activeSquadId) || null;
187
188
  }
188
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
+
189
205
  /** Reconstruct and focus one exact persisted squad without creating/linking another. */
190
206
  function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
191
207
  let scheduler = schedulers.get(squadId);
@@ -244,6 +260,24 @@ export default function (pi: ExtensionAPI) {
244
260
  const skillsDir = path.join(path.dirname(new URL(import.meta.url).pathname), "skills");
245
261
  const squadSkillPaths = getSquadSkillPaths(skillsDir);
246
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
+
247
281
  // =========================================================================
248
282
  // Context Injection — give main agent awareness of squad state
249
283
  // =========================================================================
@@ -255,9 +289,12 @@ export default function (pi: ExtensionAPI) {
255
289
  const pendingReviewGates = store.findActiveSquads()
256
290
  .filter((s) => s.cwd === ctx.cwd && s.status === "review")
257
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];
258
295
  if (!squadEnabled) {
259
- if (pendingReviewGates.length === 0) return;
260
- 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") };
261
298
  }
262
299
 
263
300
  // When a squad is active, inject its status
@@ -265,15 +302,16 @@ export default function (pi: ExtensionAPI) {
265
302
  const squad = store.loadSquad(activeSquadId);
266
303
  if (!squad) {
267
304
  activeSquadId = null;
268
- if (pendingReviewGates.length > 0) {
269
- 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") };
270
308
  }
271
309
  return;
272
310
  }
273
311
  const tasks = store.loadAllTasks(activeSquadId);
274
312
  if (tasks.length === 0) {
275
- if (pendingReviewGates.length > 0) {
276
- 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") };
277
315
  }
278
316
  return;
279
317
  }
@@ -281,21 +319,24 @@ export default function (pi: ExtensionAPI) {
281
319
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
282
320
 
283
321
  const taskLines = tasks.map((t) => {
284
- const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
322
+ const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "suspended" ? "⏸" : t.status === "cancelled" ? "⊘" : "·";
285
323
  let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
286
324
  if (t.output) line += ` — ${t.output}`;
287
325
  if (t.error) line += ` ERROR: ${t.error}`;
288
326
  return line;
289
327
  }).join("\n");
290
328
 
329
+ const reviewPresentation = getReviewPresentation(squad);
291
330
  const squadContext = [
292
331
  `<squad_status>`,
293
332
  `Squad: ${squad.id} — ${squad.goal}`,
294
333
  `Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
334
+ ...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
295
335
  taskLines,
296
336
  `</squad_status>`,
297
337
  ...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
298
338
  ...pendingReviewGates.filter(({ squad: pending }) => pending.id !== squad.id).map(({ gate }) => gate),
339
+ ...suspendedAttention,
299
340
  `You have an active squad. Use squad_message to talk to agents, squad_status for details, squad_modify to change tasks.`,
300
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.`,
301
342
  ].join("\n");
@@ -323,7 +364,7 @@ export default function (pi: ExtensionAPI) {
323
364
 
324
365
  return {
325
366
  systemPrompt: event.systemPrompt + "\n\n" + squadNudge +
326
- (pendingReviewGates.length > 0 ? "\n\n" + pendingReviewGates.map(({ gate }) => gate).join("\n\n") : ""),
367
+ (durablePrompts.length > 0 ? "\n\n" + durablePrompts.join("\n\n") : ""),
327
368
  };
328
369
  });
329
370
 
@@ -452,6 +493,7 @@ export default function (pi: ExtensionAPI) {
452
493
  task.status === "in_progress" ? "⏳" :
453
494
  task.status === "blocked" ? "◻" :
454
495
  task.status === "failed" ? "✗" :
496
+ task.status === "suspended" ? "⏸" :
455
497
  task.status === "cancelled" ? "⊘" :
456
498
  "·";
457
499
  let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
@@ -461,13 +503,16 @@ export default function (pi: ExtensionAPI) {
461
503
  .join("\n");
462
504
 
463
505
  const durableTasks = store.loadAllTasks(id!);
506
+ const squad = store.loadSquad(id!);
507
+ const review = squad ? getReviewPresentation(squad) : null;
464
508
  const summary = [
465
509
  `Squad: ${id}`,
466
510
  `Status: ${context.status}`,
467
511
  `Progress: ${formatTaskProgress(durableTasks)}`,
468
512
  `Elapsed: ${context.elapsed}`,
469
513
  `Cost: $${context.costs.total.toFixed(4)}`,
470
- ...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
514
+ ...(review ? [`Acceptance: ${review.label}`] : []),
515
+ ...(squad ? formatSuspendedAttention(squad) : []),
471
516
  "",
472
517
  "Tasks:",
473
518
  taskLines,
@@ -604,9 +649,9 @@ export default function (pi: ExtensionAPI) {
604
649
  pi.registerTool({
605
650
  name: "squad_modify",
606
651
  label: "Squad Modify",
607
- description: "Modify one exact squad: add_task, set_dependencies (top-level depends), cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel. Task actions reconstruct the persisted scheduler after restart when needed.",
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.",
608
653
  parameters: Type.Object({
609
- 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)" })),
610
655
  action: Type.Union(
611
656
  [
612
657
  Type.Literal("add_task"),
@@ -637,6 +682,22 @@ export default function (pi: ExtensionAPI) {
637
682
  }),
638
683
 
639
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
+
640
701
  if (params.action === "resume") {
641
702
  const squad = resolveResumeSquad(ctx.cwd, params.squadId);
642
703
  if (!squad) {
@@ -742,9 +803,12 @@ export default function (pi: ExtensionAPI) {
742
803
  case "resume_task": {
743
804
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
744
805
  try {
745
- 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
+ }
746
810
  } catch (err) {
747
- 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 };
748
812
  }
749
813
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
750
814
  }
@@ -769,19 +833,7 @@ export default function (pi: ExtensionAPI) {
769
833
  return { content: [{ type: "text" as const, text: "Squad paused. Use squad_modify with action 'resume' to continue." }], details: undefined };
770
834
  }
771
835
 
772
- // Note: "resume" is handled above, before the activeScheduler guard.
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
- }
836
+ // Note: "resume" and exact-ID "cancel" are handled above.
785
837
 
786
838
  default:
787
839
  return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], details: undefined };
@@ -822,6 +874,21 @@ export default function (pi: ExtensionAPI) {
822
874
  }
823
875
  }
824
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
+
825
892
  // Mailbox recovery is automatic after extension/main-process restart. Scan
826
893
  // every project squad (including accepted done squads) because a crash can
827
894
  // occur after the mailbox-first write but before the squad/task is reopened.
@@ -1130,17 +1197,13 @@ export default function (pi: ExtensionAPI) {
1130
1197
  }
1131
1198
 
1132
1199
  case "cancel": {
1133
- const cancelSched = getActiveScheduler();
1134
- if (!cancelSched) {
1135
- 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");
1136
1203
  return;
1137
1204
  }
1138
- await cancelSched.stop();
1139
- const squad = store.loadSquad(activeSquadId!);
1140
- if (squad) { squad.status = "failed"; store.saveSquad(squad); }
1141
- if (activeSquadId) schedulers.delete(activeSquadId);
1142
- forceWidgetUpdate();
1143
- ctx.ui.notify("Squad cancelled", "info");
1205
+ await cancelExactSquad(cancelledId);
1206
+ ctx.ui.notify(`Squad '${cancelledId}' cancelled`, "info");
1144
1207
  return;
1145
1208
  }
1146
1209
 
@@ -1501,9 +1564,11 @@ async function pickSquad(
1501
1564
  const options = squads.map((s) => {
1502
1565
  const tasks = store.loadAllTasks(s.id);
1503
1566
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1504
- 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}]`;
1505
1570
  const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
1506
- return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
1571
+ return `${icon} ${s.id}${acceptance} · ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
1507
1572
  });
1508
1573
 
1509
1574
  const choice = await ctx.ui.select("Select a squad", options);
@@ -1573,6 +1638,20 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
1573
1638
  // can reopen the task immediately on its bound durable Pi session.
1574
1639
  break;
1575
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
+ }
1576
1655
  case "squad_failed": {
1577
1656
  const tasks = store.loadAllTasks(squadId);
1578
1657
  const failed = tasks.filter((task) => task.status === "failed");
@@ -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 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", "✓")
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 = squad.status === "done"
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
 
@@ -6,6 +6,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth } from "@earendil-works/pi-tui";
7
7
  import type { Task, TaskStatus } from "../types.js";
8
8
  import type { Scheduler } from "../scheduler.js";
9
+ import { formatSuspendedAttention, getReviewPresentation } from "../presentation.js";
9
10
  import * as store from "../store.js";
10
11
 
11
12
  // ============================================================================
@@ -90,6 +91,15 @@ export class TaskListView {
90
91
  bottomLines.push("");
91
92
  bottomLines.push(truncateToWidth(th.fg("border", " " + "─".repeat(width - 2)), width, ""));
92
93
 
94
+ const squad = store.loadSquad(this.squadId);
95
+ const review = squad ? getReviewPresentation(squad) : null;
96
+ if (review) bottomLines.push(truncateToWidth(` ${th.fg(review.tone, review.label)}`, width, ""));
97
+ if (squad) {
98
+ for (const line of formatSuspendedAttention(squad)) {
99
+ bottomLines.push(truncateToWidth(` ${th.fg("warning", line)}`, width, ""));
100
+ }
101
+ }
102
+
93
103
  const runningTask = tasks.find((t) => t.status === "in_progress");
94
104
  if (runningTask) {
95
105
  bottomLines.push(...this.renderLiveActivity(runningTask, width, scheduler));
@@ -0,0 +1,38 @@
1
+ import type { Squad, SuspendedStallAttention } from "./types.js";
2
+
3
+ export const REVIEW_PENDING_LABEL = "◆ REVIEW PENDING · independent review required";
4
+ export const REVIEW_FAILED_LABEL = "✗ REVIEW FAILED · awaiting same-squad rework";
5
+ export const SUSPENDED_ATTENTION_LABEL = "⚠ SUSPENDED — explicit resume required";
6
+
7
+ export interface ReviewPresentation {
8
+ kind: "pending" | "failed";
9
+ icon: "◆" | "✗";
10
+ label: string;
11
+ tone: "warning" | "error";
12
+ }
13
+
14
+ /** Keep execution progress separate from the independent acceptance gate. */
15
+ export function getReviewPresentation(squad: Squad): ReviewPresentation | null {
16
+ if (squad.status !== "review") return null;
17
+ if (squad.review?.status === "failed") {
18
+ return { kind: "failed", icon: "✗", label: REVIEW_FAILED_LABEL, tone: "error" };
19
+ }
20
+ return { kind: "pending", icon: "◆", label: REVIEW_PENDING_LABEL, tone: "warning" };
21
+ }
22
+
23
+ export function getSuspendedAttention(squad: Squad): SuspendedStallAttention | null {
24
+ return squad.suspendedStallAttention?.kind === "suspended_stall" ? squad.suspendedStallAttention : null;
25
+ }
26
+
27
+ /** Full-fidelity operator output. Terminal components may clip only while rendering. */
28
+ export function formatSuspendedAttention(squad: Squad): string[] {
29
+ const attention = getSuspendedAttention(squad);
30
+ if (!attention) return [];
31
+ return [
32
+ `Attention: ${SUSPENDED_ATTENTION_LABEL}`,
33
+ `Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}`,
34
+ `Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}`,
35
+ `No task was resumed automatically.`,
36
+ `Resume intentionally with squad_modify { action: "resume_task", squadId: "${squad.id}", taskId: "<exact-task-id>" } for each task you choose.`,
37
+ ];
38
+ }
package/src/review.ts CHANGED
@@ -92,6 +92,13 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
92
92
 
93
93
  /** Persistent system-prompt contract shown until squad_review accepts the work. */
94
94
  export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string {
95
+ const failed = squad.review?.status === "failed";
96
+ const reviewLabel = failed
97
+ ? "✗ REVIEW FAILED · awaiting same-squad rework"
98
+ : "◆ REVIEW PENDING · independent review required";
99
+ const reviewAction = failed
100
+ ? `This candidate was rejected. Do not submit another verdict yet. Start concrete rework in this same exact squad with squad_modify and squadId: "${squad.id}"; the failed evidence remains immutable history. After rework settles, a fresh REVIEW PENDING gate will require a new independent review.`
101
+ : `This candidate is awaiting its first verdict. Complete the checks below, then call squad_review for squadId: "${squad.id}".`;
95
102
  const delegatedPlan = tasks
96
103
  .map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`)
97
104
  .join("\n");
@@ -101,6 +108,8 @@ UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
101
108
 
102
109
  Authoritative contract: re-read the user's ORIGINAL request and all later clarifications in this main-session conversation. The squad report and delegated task descriptions are claims, not proof and not substitutes for that contract.
103
110
  Recorded squad goal (secondary reference): ${squad.goal}
111
+ Acceptance: ${reviewLabel}
112
+ ${reviewAction}
104
113
 
105
114
  Delegated plan (non-authoritative):
106
115
  ${delegatedPlan}
@@ -111,9 +120,9 @@ Before reporting success, completion, or acceptance to the user, YOU (the main P
111
120
  3. Independently run the original Verify commands and appropriate build/tests. Do not rely on pasted squad output.
112
121
  4. Run integration/E2E in the real target or production-like environment when the request affects runtime behavior. If genuinely not applicable or impossible, record the precise reason and mark it unverified.
113
122
  5. Fix discovered defects and repeat checks. Squad QA PASS does not override your findings.
114
- 6. Call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues.
123
+ 6. When the active gate is REVIEW PENDING, call squad_review with contract checks, diff review, actual command/result evidence, integration/E2E evidence, and remaining issues. When it is REVIEW FAILED, route same-squad rework first; another verdict cannot overwrite the failed evidence.
115
124
 
116
- Do not ask the user whether you should verify. Do not merely summarize the squad report. Until squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
125
+ Do not ask the user whether you should verify. Do not merely summarize the squad report. Until fresh rework is independently reviewed and squad_review records PASS/PASS_WITH_ISSUES, the work is not accepted.
117
126
  </squad_review_required>`;
118
127
  }
119
128
 
package/src/scheduler.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import type { AgentDef, Squad, SquadConfig, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
14
+ import type { AgentDef, Squad, SquadConfig, SuspendedStallAttention, Task, TaskMailboxEntry, TaskMessage, TaskStatus } from "./types.js";
15
15
  import { AgentPool, type AgentEvent } from "./agent-pool.js";
16
16
  import { Monitor } from "./monitor.js";
17
17
  import { Router } from "./router.js";
@@ -34,6 +34,7 @@ export type SchedulerEventType =
34
34
  | "task_rework"
35
35
  | "squad_review_required"
36
36
  | "squad_failed"
37
+ | "suspended_stall"
37
38
  | "orchestrator_reply"
38
39
  | "escalation"
39
40
  | "activity";
@@ -49,6 +50,61 @@ export interface SchedulerEvent {
49
50
 
50
51
  export type SchedulerEventListener = (event: SchedulerEvent) => void;
51
52
 
53
+ export interface SuspendedStallState {
54
+ fingerprint: string;
55
+ suspendedTaskIds: string[];
56
+ blockedTaskIds: string[];
57
+ }
58
+
59
+ /** Pure derivation of an explicit-suspension stall from one persisted DAG. */
60
+ export function deriveSuspendedStall(tasks: Task[]): SuspendedStallState | null {
61
+ const relevant = tasks.filter((task) => task.status !== "cancelled");
62
+ const byId = new Map(relevant.map((task) => [task.id, task]));
63
+ const suspendedTaskIds = relevant
64
+ .filter((task) => task.status === "suspended")
65
+ .map((task) => task.id)
66
+ .sort((left, right) => left.localeCompare(right));
67
+ if (suspendedTaskIds.length === 0) return null;
68
+ const suspended = new Set(suspendedTaskIds);
69
+
70
+ const reachesSuspended = (taskId: string, seen = new Set<string>()): boolean => {
71
+ if (suspended.has(taskId)) return true;
72
+ if (seen.has(taskId)) return false;
73
+ seen.add(taskId);
74
+ const task = byId.get(taskId);
75
+ if (!task) return false;
76
+ return task.depends.some((dependencyId) => reachesSuspended(dependencyId, seen));
77
+ };
78
+ const suspensionBlocked = (task: Task): boolean => task.status === "blocked" && task.depends.some((dependencyId) => {
79
+ const dependency = byId.get(dependencyId);
80
+ return dependency?.status !== "done" && reachesSuspended(dependencyId);
81
+ });
82
+ const runnableOrLive = relevant.some((task) => task.status === "in_progress" || (
83
+ task.status === "pending" && task.depends.every((dependencyId) => byId.get(dependencyId)?.status === "done")
84
+ ));
85
+ if (runnableOrLive) return null;
86
+
87
+ const blockedTaskIds = relevant.filter(suspensionBlocked).map((task) => task.id).sort((left, right) => left.localeCompare(right));
88
+ const blocked = new Set(blockedTaskIds);
89
+ const terminal = new Set<TaskStatus>(["done", "failed", "cancelled"]);
90
+ if (!relevant.every((task) => suspended.has(task.id) || terminal.has(task.status) || blocked.has(task.id))) return null;
91
+
92
+ return {
93
+ fingerprint: JSON.stringify([suspendedTaskIds, blockedTaskIds]),
94
+ suspendedTaskIds,
95
+ blockedTaskIds,
96
+ };
97
+ }
98
+
99
+ /** Complete, actionable wake text; semantic task IDs are never abbreviated. */
100
+ export function formatSuspendedStallAttention(squadId: string, attention: Pick<SuspendedStallAttention, "suspendedTaskIds" | "blockedTaskIds">): string {
101
+ return `[squad] SUSPENDED WORK NEEDS ACTION in '${squadId}'.\n` +
102
+ `Suspended task IDs: ${attention.suspendedTaskIds.join(", ")}\n` +
103
+ `Blocked by suspended work: ${attention.blockedTaskIds.length > 0 ? attention.blockedTaskIds.join(", ") : "none"}\n` +
104
+ "No task was resumed automatically.\n" +
105
+ `Resume intentionally with squad_modify { action: "resume_task", squadId: "${squadId}", taskId: "<exact-task-id>" } for each task you choose.`;
106
+ }
107
+
52
108
  /** Host-session capabilities passed in by the extension (index.ts) */
53
109
  export interface SchedulerSpawnContext {
54
110
  /** Resolve a model string (or null = default model) to its context window in tokens */
@@ -76,6 +132,8 @@ export class Scheduler {
76
132
  private spawnRetries = new Set<string>();
77
133
  /** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
78
134
  private reconcileTimer: ReturnType<typeof setInterval> | null = null;
135
+ /** Suppress duplicate edge emission within one scheduler; disk remains the outbox. */
136
+ private attentionEmittedFingerprint: string | null = null;
79
137
 
80
138
  /** Get the project cwd for this squad (from squad.json) */
81
139
  getProjectCwd(): string | undefined {
@@ -258,7 +316,7 @@ export class Scheduler {
258
316
  // mutation/RPC delivery, reopen exactly that task on reconstruction.
259
317
  let recoveredMailbox = false;
260
318
  for (const task of tasks) {
261
- if (task.status === "cancelled") continue;
319
+ if (task.status === "cancelled" || task.status === "suspended") continue;
262
320
  if (this.pool.isRunning(task.id)) continue;
263
321
  if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
264
322
  if (task.status === "done") await this.invalidateDescendants(task.id);
@@ -309,6 +367,7 @@ export class Scheduler {
309
367
  }
310
368
 
311
369
  await this.scheduleReadyTasks();
370
+ this.reconcileSuspendedStallAttention();
312
371
 
313
372
  const freshSquad = store.loadSquad(this.squadId);
314
373
  if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
@@ -664,7 +723,8 @@ export class Scheduler {
664
723
  }
665
724
 
666
725
  private handleUnexpectedAgentExit(event: AgentEvent): void {
667
- if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
726
+ const status = store.loadTask(this.squadId, event.taskId)?.status;
727
+ if (status === "cancelled" || status === "suspended") return;
668
728
  const exitCode = event.data?.exitCode ?? 1;
669
729
  const turnCount = event.data?.turnCount ?? 0;
670
730
  const stderr = event.data?.stderr || "";
@@ -794,7 +854,8 @@ export class Scheduler {
794
854
  }
795
855
 
796
856
  case "agent_settled": {
797
- if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
857
+ const status = store.loadTask(this.squadId, event.taskId)?.status;
858
+ if (status === "cancelled" || status === "suspended") return;
798
859
  // A mailbox entry not acknowledged by Pi outranks this run's candidate
799
860
  // completion. Reopen the same session so accepted-at-least-once delivery
800
861
  // occurs before the task can become done.
@@ -846,8 +907,9 @@ export class Scheduler {
846
907
  const task = store.loadTask(this.squadId, taskId);
847
908
  if (!task) return;
848
909
 
849
- // Guard against double-completion and late callbacks after cancellation.
850
- if (task.status === "done" || task.status === "cancelled") return;
910
+ // Guard against double-completion and late callbacks after cancellation or
911
+ // an explicit pause. Only exact resume_task may revive suspended work.
912
+ if (task.status === "done" || task.status === "cancelled" || task.status === "suspended") return;
851
913
 
852
914
  // Extract output from last messages
853
915
  const messages = store.loadMessages(this.squadId, taskId);
@@ -905,6 +967,11 @@ export class Scheduler {
905
967
  debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
906
968
  await this.scheduleReadyTasks();
907
969
 
970
+ // A completion can remove the last independent runnable task and expose a
971
+ // stall behind an explicitly suspended task. Derive/wake immediately rather
972
+ // than waiting for the periodic reconciliation timer.
973
+ this.reconcileSuspendedStallAttention();
974
+
908
975
  // Re-check squad completion with fresh data AFTER scheduling
909
976
  const freshTasks = store.loadAllTasks(this.squadId);
910
977
  const freshSquad = store.loadSquad(this.squadId);
@@ -938,6 +1005,9 @@ export class Scheduler {
938
1005
  this.pool.kill(taskId);
939
1006
  this.updateContext();
940
1007
 
1008
+ // Failure can likewise expose a suspended-only cut in the remaining DAG.
1009
+ this.reconcileSuspendedStallAttention();
1010
+
941
1011
  // Check if squad should be marked failed
942
1012
  const tasks = store.loadAllTasks(this.squadId);
943
1013
  const squad = store.loadSquad(this.squadId);
@@ -1409,6 +1479,7 @@ export class Scheduler {
1409
1479
  setTimeout(() => this.pool.kill(taskId), 3000);
1410
1480
  }
1411
1481
  store.updateTaskStatus(this.squadId, taskId, "suspended");
1482
+ this.reconcileSuspendedStallAttention();
1412
1483
  this.updateContext();
1413
1484
  }
1414
1485
 
@@ -1503,9 +1574,12 @@ export class Scheduler {
1503
1574
 
1504
1575
  /** Resume one exact task. Reopening completed work invalidates descendants and
1505
1576
  * archives a completed active review before fresh scheduling begins. */
1506
- async resumeTask(taskId: string): Promise<void> {
1577
+ async resumeTask(taskId: string): Promise<"resumed" | "already_running"> {
1507
1578
  const task = store.loadTask(this.squadId, taskId);
1508
1579
  if (!task) throw new Error(`Task not found: ${taskId}`);
1580
+ const live = this.pool.isRunning(taskId);
1581
+ if (task.status === "in_progress" && live) return "already_running";
1582
+ if (live) throw new Error(`Task '${taskId}' has a live child but durable status '${task.status}'; no duplicate resume was started.`);
1509
1583
  if (task.status === "done") await this.invalidateDescendants(taskId);
1510
1584
  const tasks = store.loadAllTasks(this.squadId);
1511
1585
  const dependenciesDone = task.depends.every(
@@ -1518,6 +1592,18 @@ export class Scheduler {
1518
1592
  this.reopenSquadForWork();
1519
1593
  await this.start();
1520
1594
  this.updateContext();
1595
+ return "resumed";
1596
+ }
1597
+
1598
+ /** Mark an exact pending outbox fingerprint delivered after host acceptance. */
1599
+ acknowledgeSuspendedStall(fingerprint: string): boolean {
1600
+ const squad = store.loadSquad(this.squadId);
1601
+ const attention = squad?.suspendedStallAttention;
1602
+ if (!squad || !attention || attention.fingerprint !== fingerprint || attention.delivery !== "pending") return false;
1603
+ attention.delivery = "delivered";
1604
+ attention.deliveredAt = store.now();
1605
+ store.saveSquad(squad);
1606
+ return true;
1521
1607
  }
1522
1608
 
1523
1609
  /**
@@ -1601,6 +1687,7 @@ export class Scheduler {
1601
1687
  beginOrchestratorRework(squad);
1602
1688
  store.saveSquad(squad);
1603
1689
  }
1690
+ this.reconcileSuspendedStallAttention();
1604
1691
  this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
1605
1692
  }
1606
1693
  this.updateContext();
@@ -1610,6 +1697,38 @@ export class Scheduler {
1610
1697
  // Helpers
1611
1698
  // =========================================================================
1612
1699
 
1700
+ private reconcileSuspendedStallAttention(): void {
1701
+ const squad = store.loadSquad(this.squadId);
1702
+ if (!squad) return;
1703
+ const derived = deriveSuspendedStall(store.loadAllTasks(this.squadId));
1704
+ if (!derived) {
1705
+ if (squad.suspendedStallAttention) {
1706
+ delete squad.suspendedStallAttention;
1707
+ store.saveSquad(squad);
1708
+ }
1709
+ this.attentionEmittedFingerprint = null;
1710
+ return;
1711
+ }
1712
+
1713
+ let attention = squad.suspendedStallAttention;
1714
+ if (!attention || attention.fingerprint !== derived.fingerprint) {
1715
+ attention = {
1716
+ kind: "suspended_stall",
1717
+ ...derived,
1718
+ detectedAt: store.now(),
1719
+ delivery: "pending",
1720
+ deliveredAt: null,
1721
+ };
1722
+ squad.suspendedStallAttention = attention;
1723
+ store.saveSquad(squad);
1724
+ this.attentionEmittedFingerprint = null;
1725
+ }
1726
+ if (attention.delivery === "pending" && this.attentionEmittedFingerprint !== attention.fingerprint) {
1727
+ this.attentionEmittedFingerprint = attention.fingerprint;
1728
+ this.emit({ type: "suspended_stall", squadId: this.squadId, data: attention });
1729
+ }
1730
+ }
1731
+
1613
1732
  private extractAssistantText(msg: any): string | null {
1614
1733
  if (!msg.content) return null;
1615
1734
  const textParts = msg.content
@@ -120,6 +120,7 @@ When you receive `[squad] Agent needs attention`:
120
120
  4. **If you can't**: ask the user, then relay their answer via `squad_message`
121
121
 
122
122
  Common escalation patterns:
123
+ - **`SUSPENDED WORK NEEDS ACTION`** → Read every exact suspended task ID and blocked descendant. Do not assume consent and do not use whole-squad `resume`; resume only each intended task with `squad_modify { action: "resume_task", squadId: "<exact squad>", taskId: "<exact task>" }`. Nothing resumes automatically.
123
124
  - **"Which approach should I use?"** → Ask the user for preference, relay via `squad_message`
124
125
  - **"I need info from another agent"** → Check if that agent is done, relay their output
125
126
  - **"I'm blocked by a failing test"** → Check the error, suggest a fix via `squad_message`
@@ -152,12 +153,16 @@ Use `squad_modify` when:
152
153
  - **`cancel_task`**: A task is no longer needed. Cancellation is refused while any non-cancelled task directly depends on it. First call `set_dependencies` for every dependent named in the refusal, then retry `cancel_task`. Cancellation never cascades and never removes or rewrites dependencies automatically.
153
154
  - **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
154
155
  - **`pause`** / **`resume`**: Stop/restart the entire squad
155
- - **`cancel`**: Abort everything (user changed their mind)
156
+ - **`cancel`**: Abort everything (user changed their mind). This destructive tool action requires the exact `squadId`; never infer it from focus or recency. The result must name the affected squad. Interactive `/squad cancel` is the only cancellation shorthand that may use the visibly focused squad.
157
+
158
+ A suspended task is an explicit pause. Scheduler restart, reconciliation, dependency edits, and attention delivery must not be treated as permission to resume it. When durable suspended-stall attention is active, use `squad_status` once to see the complete IDs and exact-squad guidance, then resume only the tasks deliberately chosen.
156
159
 
157
160
  ## After Agents Finish — Mandatory Independent Orchestrator Review
158
161
 
159
162
  Agent execution finishing is **not completion or acceptance**. When you receive `[squad] TASK EXECUTION FINISHED` / `<squad_review_required>`, every squad output—including QA PASS—is an untrusted claim. You are the independent acceptance authority.
160
163
 
164
+ Read acceptance labels literally: `◆ REVIEW PENDING · independent review required` means review has not happened; `✗ REVIEW FAILED · awaiting same-squad rework` means review happened and rejected the candidate. Task progress such as `3/3` is execution progress only. A failed gate requires concrete rework in that same exact squad before a fresh review can exist; never overwrite the failed verdict or create a separate squad to evade it.
165
+
161
166
  You MUST do all of this before telling the user the work succeeded:
162
167
  1. **Re-read the original contract**: reconstruct every requirement, boundary, and later clarification from the user's main-session conversation. The squad goal/task plan is only a secondary aid and cannot narrow the original request.
163
168
  2. **Inspect reality, not reports**: read the actual working-tree/commit diff and relevant source. Check every changed line for correctness, scope, integration points, unintended changes, error paths, security, and regressions.
package/src/types.ts CHANGED
@@ -85,6 +85,17 @@ export interface SquadReview {
85
85
  issues: string[];
86
86
  }
87
87
 
88
+ export interface SuspendedStallAttention {
89
+ kind: "suspended_stall";
90
+ /** Canonical identity of the exact suspended/blocked task sets. */
91
+ fingerprint: string;
92
+ suspendedTaskIds: string[];
93
+ blockedTaskIds: string[];
94
+ detectedAt: string;
95
+ delivery: "pending" | "delivered";
96
+ deliveredAt: string | null;
97
+ }
98
+
88
99
  export interface SquadConfig {
89
100
  maxConcurrency: number;
90
101
  autoUnblock: boolean;
@@ -116,6 +127,8 @@ export interface Squad {
116
127
  review?: SquadReview;
117
128
  /** Completed prior review attempts retained as same-squad audit evidence. */
118
129
  reviewHistory?: SquadReview[];
130
+ /** Durable, level-triggered attention for an explicit-suspension stall. */
131
+ suspendedStallAttention?: SuspendedStallAttention;
119
132
  }
120
133
 
121
134
  // ============================================================================