pi-squad 0.16.5 → 0.16.6

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
@@ -209,7 +209,11 @@ Full overlay with task list, live activity preview, and scrollable message view.
209
209
  | `squad` | Start a squad with goal + optional tasks/config |
210
210
  | `squad_status` | Check progress, costs, task states |
211
211
  | `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 |
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 |
213
+
214
+ 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
+
216
+ `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
217
 
214
218
  The main agent sees available agents in its system prompt and squad state when a squad is active.
215
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.5",
3
+ "version": "0.16.6",
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
@@ -46,6 +46,16 @@ let uiCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = n
46
46
  const widgetState: SquadWidgetState = { squadId: null, enabled: true };
47
47
  let widgetControls: { requestUpdate: () => void; dispose: () => void } | null = null;
48
48
 
49
+ /** Format completion against active work while retaining cancelled history. */
50
+ function formatTaskProgress(tasks: Task[]): string {
51
+ const done = tasks.filter((task) => task.status === "done").length;
52
+ const cancelled = tasks.filter((task) => task.status === "cancelled").length;
53
+ const active = tasks.length - cancelled;
54
+ return cancelled > 0
55
+ ? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
56
+ : `${done}/${tasks.length} tasks done`;
57
+ }
58
+
49
59
  /**
50
60
  * Resolve a model string (or null = session default) to its context window.
51
61
  * Reads uiCtx lazily so it always uses the live session's registry.
@@ -268,11 +278,10 @@ export default function (pi: ExtensionAPI) {
268
278
  return;
269
279
  }
270
280
 
271
- const doneCount = tasks.filter((t) => t.status === "done").length;
272
281
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
273
282
 
274
283
  const taskLines = tasks.map((t) => {
275
- const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
284
+ const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : t.status === "cancelled" ? "⊘" : "·";
276
285
  let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
277
286
  if (t.output) line += ` — ${t.output}`;
278
287
  if (t.error) line += ` ERROR: ${t.error}`;
@@ -282,7 +291,7 @@ export default function (pi: ExtensionAPI) {
282
291
  const squadContext = [
283
292
  `<squad_status>`,
284
293
  `Squad: ${squad.id} — ${squad.goal}`,
285
- `Status: ${squad.status} | ${doneCount}/${tasks.length} tasks | $${totalCost.toFixed(2)}`,
294
+ `Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
286
295
  taskLines,
287
296
  `</squad_status>`,
288
297
  ...(squad.status === "review" ? [buildOrchestratorReviewGate(squad, tasks)] : []),
@@ -443,6 +452,7 @@ export default function (pi: ExtensionAPI) {
443
452
  task.status === "in_progress" ? "⏳" :
444
453
  task.status === "blocked" ? "◻" :
445
454
  task.status === "failed" ? "✗" :
455
+ task.status === "cancelled" ? "⊘" :
446
456
  "·";
447
457
  let line = `${icon} ${taskId} (${task.agent}) — ${task.title} [${task.status}]`;
448
458
  if (task.blockedBy?.length) line += ` blocked by: ${task.blockedBy.join(", ")}`;
@@ -450,9 +460,11 @@ export default function (pi: ExtensionAPI) {
450
460
  })
451
461
  .join("\n");
452
462
 
463
+ const durableTasks = store.loadAllTasks(id!);
453
464
  const summary = [
454
465
  `Squad: ${id}`,
455
466
  `Status: ${context.status}`,
467
+ `Progress: ${formatTaskProgress(durableTasks)}`,
456
468
  `Elapsed: ${context.elapsed}`,
457
469
  `Cost: $${context.costs.total.toFixed(4)}`,
458
470
  ...(context.status === "review" ? ["Acceptance: BLOCKED — independent main-orchestrator review required via squad_review"] : []),
@@ -592,12 +604,13 @@ export default function (pi: ExtensionAPI) {
592
604
  pi.registerTool({
593
605
  name: "squad_modify",
594
606
  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.",
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.",
596
608
  parameters: Type.Object({
597
609
  squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
598
610
  action: Type.Union(
599
611
  [
600
612
  Type.Literal("add_task"),
613
+ Type.Literal("set_dependencies"),
601
614
  Type.Literal("cancel_task"),
602
615
  Type.Literal("pause_task"),
603
616
  Type.Literal("resume_task"),
@@ -609,6 +622,7 @@ export default function (pi: ExtensionAPI) {
609
622
  { description: "Action to perform" },
610
623
  ),
611
624
  taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
625
+ depends: Type.Optional(Type.Array(Type.String(), { description: "Complete replacement dependency list; required at top level for set_dependencies" })),
612
626
  output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
613
627
  task: Type.Optional(
614
628
  Type.Object({
@@ -638,8 +652,7 @@ export default function (pi: ExtensionAPI) {
638
652
  return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
639
653
  }
640
654
  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 };
655
+ return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${formatTaskProgress(tasks)}). Agents restarting in background.` }], details: undefined };
643
656
  }
644
657
 
645
658
  const squadId = params.squadId || activeSquadId;
@@ -647,7 +660,7 @@ export default function (pi: ExtensionAPI) {
647
660
  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
661
  }
649
662
  let activeScheduler = schedulers.get(squadId) || null;
650
- if (!activeScheduler && (params.action === "add_task" || params.action === "resume_task")) {
663
+ 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
664
  activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
652
665
  }
653
666
  if (!activeScheduler) {
@@ -699,9 +712,24 @@ export default function (pi: ExtensionAPI) {
699
712
  return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
700
713
  }
701
714
 
715
+ case "set_dependencies": {
716
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
717
+ if (!params.depends) return { content: [{ type: "text" as const, text: "Provide top-level depends for set_dependencies (use [] to remove all dependencies)." }], details: undefined };
718
+ try {
719
+ await activeScheduler.setDependencies(params.taskId, params.depends);
720
+ } catch (err) {
721
+ return { content: [{ type: "text" as const, text: `set_dependencies failed: ${(err as Error).message}` }], details: undefined };
722
+ }
723
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' dependencies updated in squad '${squadId}'.` }], details: undefined };
724
+ }
725
+
702
726
  case "cancel_task": {
703
727
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
704
- await activeScheduler.cancelTask(params.taskId);
728
+ try {
729
+ await activeScheduler.cancelTask(params.taskId);
730
+ } catch (err) {
731
+ return { content: [{ type: "text" as const, text: `cancel_task failed: ${(err as Error).message}` }], details: undefined };
732
+ }
705
733
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' cancelled.` }], details: undefined };
706
734
  }
707
735
 
@@ -799,7 +827,7 @@ export default function (pi: ExtensionAPI) {
799
827
  // occur after the mailbox-first write but before the squad/task is reopened.
800
828
  const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
801
829
  .filter((squad) => store.loadAllTasks(squad.id)
802
- .some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
830
+ .some((task) => task.status !== "cancelled" && store.loadPendingTaskMessages(squad.id, task.id).length > 0))
803
831
  .sort((a, b) => b.created.localeCompare(a.created));
804
832
  for (const squad of pendingMailSquads) {
805
833
  let scheduler = schedulers.get(squad.id);
@@ -828,7 +856,7 @@ export default function (pi: ExtensionAPI) {
828
856
  if (done > 0) {
829
857
  pi.sendMessage({
830
858
  customType: "squad-paused",
831
- content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${done}/${tasks.length} done. ` +
859
+ content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
832
860
  `Use squad_modify with action "resume" to continue, or start a new squad.`,
833
861
  display: true,
834
862
  });
@@ -988,8 +1016,7 @@ export default function (pi: ExtensionAPI) {
988
1016
  return;
989
1017
  }
990
1018
  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");
1019
+ ctx.ui.notify(`Resumed: ${squad.id} (${formatTaskProgress(tasks)})`, "info");
993
1020
  return;
994
1021
  }
995
1022
 
@@ -1164,10 +1191,9 @@ export default function (pi: ExtensionAPI) {
1164
1191
  "🗑 Delete ALL squads",
1165
1192
  ...squads.map((s) => {
1166
1193
  const tasks = store.loadAllTasks(s.id);
1167
- const done = tasks.filter((t) => t.status === "done").length;
1168
1194
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1169
1195
  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)}`;
1196
+ return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`;
1171
1197
  }),
1172
1198
  ];
1173
1199
 
@@ -1474,11 +1500,10 @@ async function pickSquad(
1474
1500
 
1475
1501
  const options = squads.map((s) => {
1476
1502
  const tasks = store.loadAllTasks(s.id);
1477
- const done = tasks.filter((t) => t.status === "done").length;
1478
1503
  const cost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1479
1504
  const icon = s.status === "done" ? "✓" : s.status === "running" ? "⏳" : s.status === "review" ? "◆" : s.status === "failed" ? "✗" : "·";
1480
1505
  const project = showProject ? ` — ${s.cwd.split("/").pop()}` : "";
1481
- return `${icon} ${s.id} [${s.status}] ${done}/${tasks.length} $${cost.toFixed(2)}${project}`;
1506
+ return `${icon} ${s.id} [${s.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}${project}`;
1482
1507
  });
1483
1508
 
1484
1509
  const choice = await ctx.ui.select("Select a squad", options);
@@ -1511,9 +1536,8 @@ function activateSquadView(squadId: string, ctx: import("@earendil-works/pi-codi
1511
1536
  // Compact notification — widget already shows full task details.
1512
1537
  // Avoid large multi-line notifications that can break TUI layout.
1513
1538
  const tasks = store.loadAllTasks(squadId);
1514
- const done = tasks.filter((t) => t.status === "done").length;
1515
1539
  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");
1540
+ ctx.ui.notify(`Viewing: ${squad.id} [${squad.status}] ${formatTaskProgress(tasks)} $${cost.toFixed(2)}`, "info");
1517
1541
  }
1518
1542
 
1519
1543
  // ============================================================================
@@ -1552,11 +1576,10 @@ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: st
1552
1576
  case "squad_failed": {
1553
1577
  const tasks = store.loadAllTasks(squadId);
1554
1578
  const failed = tasks.filter((task) => task.status === "failed");
1555
- const done = tasks.filter((task) => task.status === "done");
1556
1579
  pi.sendMessage({
1557
1580
  customType: "squad-failed",
1558
1581
  content: `[squad] Squad "${squadId}" has stalled. ` +
1559
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1582
+ `${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
1560
1583
  `Failed: ${buildFailureSummary(tasks)}\n` +
1561
1584
  "Use squad_status for details or squad_modify to adjust.",
1562
1585
  display: true,
@@ -23,6 +23,7 @@ function statusIcon(status: TaskStatus, th: Theme): string {
23
23
  case "blocked": return th.fg("muted", "◻");
24
24
  case "failed": return th.fg("error", "✗");
25
25
  case "suspended": return th.fg("muted", "⏸");
26
+ case "cancelled": return th.fg("muted", "⊘");
26
27
  default: return th.fg("dim", "·");
27
28
  }
28
29
  }
@@ -75,6 +76,11 @@ export function setupSquadWidget(
75
76
  const lines: string[] = [];
76
77
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
77
78
  const doneCount = tasks.filter((t) => t.status === "done").length;
79
+ const cancelledCount = tasks.filter((t) => t.status === "cancelled").length;
80
+ const activeCount = tasks.length - cancelledCount;
81
+ const progressText = cancelledCount > 0
82
+ ? `${doneCount}/${activeCount} active tasks done · ${cancelledCount} cancelled · ${tasks.length} total`
83
+ : `${doneCount}/${tasks.length}`;
78
84
  const elapsed = Date.now() - new Date(squad.created).getTime();
79
85
  const taskMessages = new Map<string, TaskMessage[]>();
80
86
  const recentOrchestratorByTask = new Map<string, TaskMessage>();
@@ -103,7 +109,7 @@ export function setupSquadWidget(
103
109
  : "";
104
110
  lines.push(
105
111
  `${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
106
- `${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
112
+ `${th.fg("muted", progressText)} ` +
107
113
  `${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
108
114
  `${th.fg("dim", formatElapsed(elapsed))} ` +
109
115
  `${th.fg("dim", "^q detail · /squad msg")}`
@@ -144,6 +150,8 @@ export function setupSquadWidget(
144
150
  line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
145
151
  }
146
152
  }
153
+ } else if (task.status === "cancelled") {
154
+ line += ` ${th.fg("muted", "cancelled")}`;
147
155
  } else if (task.status === "blocked") {
148
156
  const blockers = task.depends.filter((d) => {
149
157
  const dep = tasks.find((t) => t.id === d);
@@ -164,12 +172,12 @@ export function setupSquadWidget(
164
172
  const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
165
173
 
166
174
  const statusText = squad.status === "done"
167
- ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
175
+ ? th.fg("success", `✓ squad ${progressText}`)
168
176
  : squad.status === "failed"
169
- ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
177
+ ? th.fg("error", `✗ squad ${progressText}`)
170
178
  : squad.status === "review"
171
179
  ? th.fg("warning", `◆ squad review required`)
172
- : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
180
+ : th.fg("accent", `⏳ squad ${progressText} $${totalCost.toFixed(2)}`);
173
181
 
174
182
  return { lines, cacheKey, statusText };
175
183
  }
@@ -24,6 +24,8 @@ function statusIcon(status: TaskStatus, th: Theme): string {
24
24
  return th.fg("error", "✗");
25
25
  case "suspended":
26
26
  return th.fg("muted", "⏸");
27
+ case "cancelled":
28
+ return th.fg("muted", "⊘");
27
29
  case "pending":
28
30
  default:
29
31
  return th.fg("dim", "·");
@@ -42,6 +44,8 @@ function statusLabel(status: TaskStatus): string {
42
44
  return "FAILED";
43
45
  case "suspended":
44
46
  return "paused";
47
+ case "cancelled":
48
+ return "cancelled";
45
49
  case "pending":
46
50
  return "pending";
47
51
  }
@@ -241,11 +245,15 @@ export class TaskListView {
241
245
  private renderSummary(tasks: Task[], width: number): string {
242
246
  const th = this.theme;
243
247
  const done = tasks.filter((t) => t.status === "done").length;
248
+ const cancelled = tasks.filter((t) => t.status === "cancelled").length;
244
249
  const total = tasks.length;
250
+ const activeTotal = total - cancelled;
245
251
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
246
252
 
247
253
  const parts: string[] = [];
248
- parts.push(th.fg("accent", `${done}/${total}`));
254
+ parts.push(th.fg("accent", cancelled > 0
255
+ ? `${done}/${activeTotal} active tasks done · ${cancelled} cancelled · ${total} total`
256
+ : `${done}/${total}`));
249
257
  if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
250
258
 
251
259
  // Find squad creation time for elapsed
package/src/report.ts CHANGED
@@ -5,10 +5,18 @@ import type { Task } from "./types.js";
5
5
  * This is durable report data, not a UI preview: never shorten task output.
6
6
  */
7
7
  export function buildCompletionSummary(tasks: Task[]): string {
8
- return tasks
8
+ const completed = tasks
9
9
  .filter((task) => task.status === "done")
10
10
  .map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
11
11
  .join("\n");
12
+ const cancelled = tasks
13
+ .filter((task) => task.status === "cancelled")
14
+ .map((task) => `- ${task.id} (${task.agent}): cancelled`)
15
+ .join("\n");
16
+
17
+ return [completed, cancelled ? `CANCELLED TASKS (neutral; not successful output)\n${cancelled}` : ""]
18
+ .filter(Boolean)
19
+ .join("\n\n");
12
20
  }
13
21
 
14
22
  /** Build the full failure handoff without shortening diagnostics. */
package/src/scheduler.ts CHANGED
@@ -258,6 +258,7 @@ export class Scheduler {
258
258
  // mutation/RPC delivery, reopen exactly that task on reconstruction.
259
259
  let recoveredMailbox = false;
260
260
  for (const task of tasks) {
261
+ if (task.status === "cancelled") continue;
261
262
  if (this.pool.isRunning(task.id)) continue;
262
263
  if (store.loadPendingTaskMessages(this.squadId, task.id).length === 0) continue;
263
264
  if (task.status === "done") await this.invalidateDescendants(task.id);
@@ -308,6 +309,11 @@ export class Scheduler {
308
309
  }
309
310
 
310
311
  await this.scheduleReadyTasks();
312
+
313
+ const freshSquad = store.loadSquad(this.squadId);
314
+ if (freshSquad && (freshSquad.status === "running" || freshSquad.status === "failed")) {
315
+ this.checkSquadCompletion(store.loadAllTasks(this.squadId), freshSquad);
316
+ }
311
317
  }
312
318
 
313
319
  // =========================================================================
@@ -658,6 +664,7 @@ export class Scheduler {
658
664
  }
659
665
 
660
666
  private handleUnexpectedAgentExit(event: AgentEvent): void {
667
+ if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
661
668
  const exitCode = event.data?.exitCode ?? 1;
662
669
  const turnCount = event.data?.turnCount ?? 0;
663
670
  const stderr = event.data?.stderr || "";
@@ -787,6 +794,7 @@ export class Scheduler {
787
794
  }
788
795
 
789
796
  case "agent_settled": {
797
+ if (store.loadTask(this.squadId, event.taskId)?.status === "cancelled") return;
790
798
  // A mailbox entry not acknowledged by Pi outranks this run's candidate
791
799
  // completion. Reopen the same session so accepted-at-least-once delivery
792
800
  // occurs before the task can become done.
@@ -838,8 +846,8 @@ export class Scheduler {
838
846
  const task = store.loadTask(this.squadId, taskId);
839
847
  if (!task) return;
840
848
 
841
- // Guard against double-completion
842
- if (task.status === "done") return;
849
+ // Guard against double-completion and late callbacks after cancellation.
850
+ if (task.status === "done" || task.status === "cancelled") return;
843
851
 
844
852
  // Extract output from last messages
845
853
  const messages = store.loadMessages(this.squadId, taskId);
@@ -907,6 +915,7 @@ export class Scheduler {
907
915
  }
908
916
 
909
917
  private handleTaskFailed(taskId: string, error: string): void {
918
+ if (store.loadTask(this.squadId, taskId)?.status === "cancelled") return;
910
919
  store.updateTaskStatus(this.squadId, taskId, "failed", {
911
920
  error,
912
921
  completed: store.now(),
@@ -1148,10 +1157,11 @@ export class Scheduler {
1148
1157
  private checkSquadCompletion(tasks: Task[], squad: Squad): void {
1149
1158
  if (tasks.length === 0) return;
1150
1159
 
1151
- const allDone = tasks.every((t) => t.status === "done");
1152
- const anyFailed = tasks.some((t) => t.status === "failed");
1153
- const anyInProgress = tasks.some(
1154
- (t) => t.status === "in_progress" || t.status === "pending",
1160
+ const relevant = tasks.filter((task) => task.status !== "cancelled");
1161
+ const allDone = relevant.every((task) => task.status === "done");
1162
+ const anyFailed = relevant.some((task) => task.status === "failed");
1163
+ const anyInProgress = relevant.some(
1164
+ (task) => task.status === "in_progress" || task.status === "pending",
1155
1165
  );
1156
1166
 
1157
1167
  if (allDone) {
@@ -1163,9 +1173,9 @@ export class Scheduler {
1163
1173
  this.emit({ type: "squad_review_required", squadId: this.squadId });
1164
1174
  } else if (anyFailed && !anyInProgress) {
1165
1175
  // All remaining tasks are blocked/failed with no way forward
1166
- const blockedCount = tasks.filter((t) => t.status === "blocked").length;
1167
- const failedCount = tasks.filter((t) => t.status === "failed").length;
1168
- if (blockedCount + failedCount === tasks.filter((t) => t.status !== "done").length) {
1176
+ const blockedCount = relevant.filter((task) => task.status === "blocked").length;
1177
+ const failedCount = relevant.filter((task) => task.status === "failed").length;
1178
+ if (blockedCount + failedCount === relevant.filter((task) => task.status !== "done").length) {
1169
1179
  squad.status = "failed";
1170
1180
  store.saveSquad(squad);
1171
1181
  this.emit({ type: "squad_failed", squadId: this.squadId });
@@ -1297,6 +1307,7 @@ export class Scheduler {
1297
1307
  if (seen.has(descendant.id)) continue;
1298
1308
  seen.add(descendant.id);
1299
1309
  queue.push(...(byDependency.get(descendant.id) ?? []));
1310
+ if (descendant.status === "cancelled") continue;
1300
1311
  store.updateTaskStatus(this.squadId, descendant.id, "blocked", {
1301
1312
  completed: null,
1302
1313
  error: null,
@@ -1353,6 +1364,8 @@ export class Scheduler {
1353
1364
  expectsReply,
1354
1365
  });
1355
1366
 
1367
+ if (task.status === "cancelled") return false;
1368
+
1356
1369
  const request = expectsReply
1357
1370
  ? `[squad] Main orchestrator requests a direct response:\n${message}\n\nReply directly in your next assistant message. That complete message will be forwarded automatically to the main session.`
1358
1371
  : `[squad] Main orchestrator message:\n${message}`;
@@ -1387,6 +1400,9 @@ export class Scheduler {
1387
1400
 
1388
1401
  /** Pause a running task */
1389
1402
  async pauseTask(taskId: string): Promise<void> {
1403
+ const task = store.loadTask(this.squadId, taskId);
1404
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1405
+ if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; use resume_task to revive it.`);
1390
1406
  if (this.pool.isRunning(taskId)) {
1391
1407
  await this.pool.steer(taskId, "[squad] Task paused by user. Summarize your current state.");
1392
1408
  // Give agent a moment to respond, then kill
@@ -1405,6 +1421,86 @@ export class Scheduler {
1405
1421
  this.updateContext();
1406
1422
  }
1407
1423
 
1424
+ /** Atomically replace one task's dependency list after validating the complete historical DAG. */
1425
+ async setDependencies(taskId: string, depends: string[]): Promise<void> {
1426
+ const tasks = store.loadAllTasks(this.squadId);
1427
+ const target = tasks.find((task) => task.id === taskId);
1428
+ if (!target) throw new Error(`Task not found: ${taskId}`);
1429
+ if (target.status === "in_progress") {
1430
+ throw new Error(`Cannot edit dependencies for in_progress task '${taskId}'; pause or cancel it first.`);
1431
+ }
1432
+ if (target.status === "done") {
1433
+ throw new Error(`Cannot edit dependencies for done task '${taskId}'; resume it first so descendant invalidation remains authoritative.`);
1434
+ }
1435
+
1436
+ const duplicate = depends.find((dependency, index) => depends.indexOf(dependency) !== index);
1437
+ if (duplicate) throw new Error(`Duplicate dependency '${duplicate}' for task '${taskId}'.`);
1438
+ if (depends.includes(taskId)) throw new Error(`Task '${taskId}' cannot depend on itself.`);
1439
+ const known = new Set(tasks.map((task) => task.id));
1440
+ const unknown = depends.filter((dependency) => !known.has(dependency));
1441
+ if (unknown.length > 0) throw new Error(`Unknown dependency task(s): ${unknown.join(", ")}.`);
1442
+
1443
+ const wasRunnable = target.status === "pending" && target.depends.every(
1444
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1445
+ );
1446
+ const graph = new Map(tasks.map((task) => [task.id, [...task.depends]]));
1447
+ graph.set(taskId, [...depends]);
1448
+ for (const [id, dependencies] of graph) {
1449
+ const seen = new Set<string>();
1450
+ for (const dependency of dependencies) {
1451
+ if (!known.has(dependency)) throw new Error(`Task '${id}' depends on unknown task '${dependency}'.`);
1452
+ if (dependency === id) throw new Error(`Task '${id}' cannot depend on itself.`);
1453
+ if (seen.has(dependency)) throw new Error(`Duplicate dependency '${dependency}' for task '${id}'.`);
1454
+ seen.add(dependency);
1455
+ }
1456
+ }
1457
+ const color = new Map<string, 0 | 1 | 2>();
1458
+ const stack: string[] = [];
1459
+ const visit = (id: string): void => {
1460
+ const state = color.get(id) ?? 0;
1461
+ if (state === 2) return;
1462
+ if (state === 1) {
1463
+ const start = stack.indexOf(id);
1464
+ const cycle = [...stack.slice(start), id];
1465
+ throw new Error(`Dependency cycle detected: ${cycle.join(" -> ")}`);
1466
+ }
1467
+ color.set(id, 1);
1468
+ stack.push(id);
1469
+ for (const dependency of graph.get(id) ?? []) visit(dependency);
1470
+ stack.pop();
1471
+ color.set(id, 2);
1472
+ };
1473
+ for (const id of graph.keys()) visit(id);
1474
+
1475
+ target.depends = [...depends];
1476
+ if (target.status === "pending" || target.status === "blocked") {
1477
+ target.status = depends.every(
1478
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1479
+ ) ? "pending" : "blocked";
1480
+ }
1481
+ store.saveTask(this.squadId, target);
1482
+ store.appendMessage(this.squadId, taskId, {
1483
+ ts: store.now(),
1484
+ from: "system",
1485
+ type: "status",
1486
+ text: `Dependencies updated: ${depends.length > 0 ? depends.join(", ") : "none"}`,
1487
+ });
1488
+
1489
+ this.killBlockedAgents();
1490
+ const squad = store.loadSquad(this.squadId);
1491
+ const runnable = target.status === "pending" && target.depends.every(
1492
+ (dependency) => tasks.find((candidate) => candidate.id === dependency)?.status === "done",
1493
+ );
1494
+ const createdRunnableWork = runnable && !wasRunnable;
1495
+ if (createdRunnableWork && squad && squad.status !== "paused" && squad.status !== "running") {
1496
+ this.reopenSquadForWork();
1497
+ await this.start();
1498
+ } else if (squad?.status === "running") {
1499
+ await this.start();
1500
+ }
1501
+ this.updateContext();
1502
+ }
1503
+
1408
1504
  /** Resume one exact task. Reopening completed work invalidates descendants and
1409
1505
  * archives a completed active review before fresh scheduling begins. */
1410
1506
  async resumeTask(taskId: string): Promise<void> {
@@ -1434,6 +1530,7 @@ export class Scheduler {
1434
1530
  const task = store.loadTask(this.squadId, taskId);
1435
1531
  if (!task) throw new Error(`Task not found: ${taskId}`);
1436
1532
  if (task.status === "done") return;
1533
+ if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
1437
1534
 
1438
1535
  if (this.pool.isRunning(taskId)) {
1439
1536
  await this.pool.kill(taskId);
@@ -1467,14 +1564,45 @@ export class Scheduler {
1467
1564
  this.updateContext();
1468
1565
  }
1469
1566
 
1470
- /** Cancel a task */
1567
+ /** Cancel one task after ensuring no live historical task still depends on it. */
1471
1568
  async cancelTask(taskId: string): Promise<void> {
1472
- if (this.pool.isRunning(taskId)) {
1473
- await this.pool.kill(taskId);
1569
+ const task = store.loadTask(this.squadId, taskId);
1570
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1571
+ if (task.status === "cancelled") return;
1572
+
1573
+ const dependents = store.loadAllTasks(this.squadId)
1574
+ .filter((candidate) => candidate.status !== "cancelled" && candidate.depends.includes(taskId))
1575
+ .sort((left, right) => left.id.localeCompare(right.id));
1576
+ if (dependents.length > 0) {
1577
+ throw new Error([
1578
+ `Cannot cancel task '${taskId}': it is still required by:`,
1579
+ ...dependents.map((dependent) => `- ${dependent.id} [${dependent.status}]`),
1580
+ "",
1581
+ 'Update each dependent first with squad_modify action "set_dependencies",',
1582
+ "then retry cancel_task. Cancellation does not rewrite or cascade dependencies.",
1583
+ ].join("\n"));
1474
1584
  }
1475
- store.updateTaskStatus(this.squadId, taskId, "failed", {
1476
- error: "Cancelled by user",
1585
+
1586
+ if (this.pool.isRunning(taskId)) await this.pool.kill(taskId);
1587
+ store.updateTaskStatus(this.squadId, taskId, "cancelled", {
1588
+ error: null,
1589
+ completed: store.now(),
1477
1590
  });
1591
+ store.appendMessage(this.squadId, taskId, {
1592
+ ts: store.now(),
1593
+ from: "system",
1594
+ type: "status",
1595
+ text: "Task cancelled by orchestrator",
1596
+ });
1597
+
1598
+ const squad = store.loadSquad(this.squadId);
1599
+ if (squad) {
1600
+ if (squad.status === "done" || (squad.status === "review" && squad.review?.status !== "pending")) {
1601
+ beginOrchestratorRework(squad);
1602
+ store.saveSquad(squad);
1603
+ }
1604
+ this.checkSquadCompletion(store.loadAllTasks(this.squadId), squad);
1605
+ }
1478
1606
  this.updateContext();
1479
1607
  }
1480
1608
 
@@ -142,14 +142,15 @@ Send small, scoped corrections instead of restarting the task:
142
142
  - Name exactly what to change and what to keep: "Change only the header component — keep the layout and routes as they are."
143
143
  - Add missing information the moment you learn it — don't wait for the agent to get stuck
144
144
  - If the user manually edited or reverted files the agent touched, tell the agent immediately so it doesn't overwrite the human's changes
145
- - If an agent starts doing work that's no longer needed, narrow its scope via `squad_message` or stop it with `squad_modify` `cancel_task` don't let it burn tokens on obsolete work
145
+ - If an agent starts doing work that's no longer needed, narrow its scope via `squad_message`. Before using `squad_modify` `cancel_task`, repair every direct dependent explicitly as described below.
146
146
 
147
147
  ## Modifying a Running Squad
148
148
 
149
149
  Use `squad_modify` when:
150
150
  - **`add_task`**: User requests something not in the original plan
151
- - **`cancel_task`**: A task is no longer needed
152
- - **`pause_task`** / **`resume_task`**: Temporarily halt an agent
151
+ - **`set_dependencies`**: Replace a task's dependency list with top-level `taskId` and `depends`, e.g. `{ action: "set_dependencies", taskId: "publish", depends: ["build"] }`. This is allowed only for tasks that are not running or done; the complete replacement is validated atomically for unknown IDs, self-dependencies, duplicates, and cycles.
152
+ - **`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
+ - **`pause_task`** / **`resume_task`**: Temporarily halt or explicitly revive an agent; `resume_task` is also the only action that revives a cancelled task.
153
154
  - **`pause`** / **`resume`**: Stop/restart the entire squad
154
155
  - **`cancel`**: Abort everything (user changed their mind)
155
156
 
package/src/store.ts CHANGED
@@ -356,8 +356,15 @@ export function findLatestSquad(projectCwd?: string): Squad | null {
356
356
  // Tasks
357
357
  // ============================================================================
358
358
 
359
+ function normalizeLegacyCancelledTask(task: Task | null): Task | null {
360
+ if (task?.status === "failed" && task.error === "Cancelled by user") {
361
+ return { ...task, status: "cancelled", error: null };
362
+ }
363
+ return task;
364
+ }
365
+
359
366
  export function loadTask(squadId: string, taskId: string, parentPath?: string): Task | null {
360
- return readJson<Task>(getTaskFilePath(squadId, taskId, parentPath));
367
+ return normalizeLegacyCancelledTask(readJson<Task>(getTaskFilePath(squadId, taskId, parentPath)));
361
368
  }
362
369
 
363
370
  export function saveTask(squadId: string, task: Task, parentPath?: string): void {
@@ -377,7 +384,7 @@ export function loadAllTasks(squadId: string): Task[] {
377
384
  if (!entry.isDirectory()) continue;
378
385
  if (entry.name === "knowledge") continue;
379
386
  const taskFile = path.join(squadDir, entry.name, "task.json");
380
- const task = readJson<Task>(taskFile);
387
+ const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
381
388
  if (task && !seen.has(task.id)) {
382
389
  seen.add(task.id);
383
390
  tasks.push(task);
@@ -401,7 +408,7 @@ function collectSubtasks(squadDir: string, parentPath: string, tasks: Task[], se
401
408
  for (const entry of entries) {
402
409
  if (!entry.isDirectory()) continue;
403
410
  const taskFile = path.join(parentDir, entry.name, "task.json");
404
- const task = readJson<Task>(taskFile);
411
+ const task = normalizeLegacyCancelledTask(readJson<Task>(taskFile));
405
412
  if (task && !seen.has(task.id)) {
406
413
  seen.add(task.id);
407
414
  tasks.push(task);
package/src/types.ts CHANGED
@@ -122,7 +122,7 @@ export interface Squad {
122
122
  // Tasks
123
123
  // ============================================================================
124
124
 
125
- export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended";
125
+ export type TaskStatus = "pending" | "blocked" | "in_progress" | "done" | "failed" | "suspended" | "cancelled";
126
126
 
127
127
  export interface TaskUsage {
128
128
  inputTokens: number;