pi-squad 0.16.3 → 0.16.5

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/src/index.ts CHANGED
@@ -176,6 +176,38 @@ function getActiveScheduler(): Scheduler | null {
176
176
  return schedulers.get(activeSquadId) || null;
177
177
  }
178
178
 
179
+ /** Reconstruct and focus one exact persisted squad without creating/linking another. */
180
+ function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
181
+ let scheduler = schedulers.get(squadId);
182
+ if (!scheduler) {
183
+ scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
184
+ schedulers.set(squadId, scheduler);
185
+ wireSchedulerEvents(pi, scheduler, squadId);
186
+ }
187
+ activeSquadId = squadId;
188
+ widgetState.squadId = squadId;
189
+ widgetState.enabled = true;
190
+ widgetControls?.requestUpdate();
191
+ return scheduler;
192
+ }
193
+
194
+ function isResumeCandidate(squad: Squad): boolean {
195
+ return squad.status === "paused" || squad.status === "failed" ||
196
+ (squad.status === "review" && squad.review?.status === "failed") ||
197
+ store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
198
+ }
199
+
200
+ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
201
+ if (explicitId) return store.loadSquad(explicitId);
202
+ if (activeSquadId) {
203
+ const active = store.loadSquad(activeSquadId);
204
+ if (active?.cwd === cwd && isResumeCandidate(active)) return active;
205
+ }
206
+ return store.listSquadsForProject(cwd)
207
+ .filter(isResumeCandidate)
208
+ .sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
209
+ }
210
+
179
211
 
180
212
  // ============================================================================
181
213
  // Extension Entry
@@ -504,7 +536,7 @@ export default function (pi: ExtensionAPI) {
504
536
  pi.registerTool({
505
537
  name: "squad_message",
506
538
  label: "Squad Message",
507
- description: "Send a request to a specific agent/task. The agent's next substantive assistant response is durably forwarded back to the main Pi session and wakes it.",
539
+ description: "Send a durable request to a specific task. Existing tasks reopen their original Pi session; only a currently running task may be selected by agent name.",
508
540
  parameters: Type.Object({
509
541
  message: Type.String({ description: "Message to send" }),
510
542
  taskId: Type.Optional(Type.String({ description: "Target task ID" })),
@@ -513,23 +545,40 @@ export default function (pi: ExtensionAPI) {
513
545
  }),
514
546
 
515
547
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
516
- const activeScheduler = getActiveScheduler();
517
- if (!activeScheduler || !activeSquadId) {
548
+ if (!activeSquadId) {
518
549
  return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
519
550
  }
520
551
 
552
+ let activeScheduler = getActiveScheduler();
521
553
  let taskId = params.taskId;
522
554
 
523
- // If agent specified but no taskId, find their current task
524
- if (!taskId && params.agent) {
525
- taskId = activeScheduler.getPool().getTaskIdForAgent(params.agent) || undefined;
555
+ // Agent name is safe shorthand only when it identifies one live task.
556
+ // Multiple concurrent tasks can share a role, so never guess between them.
557
+ if (!taskId && params.agent && activeScheduler) {
558
+ const liveMatches = store.loadAllTasks(activeSquadId).filter(
559
+ (task) => task.agent === params.agent && activeScheduler!.getPool().isRunning(task.id),
560
+ );
561
+ if (liveMatches.length === 1) taskId = liveMatches[0].id;
526
562
  }
527
563
 
528
564
  if (!taskId) {
529
565
  return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
530
566
  }
567
+ if (!store.loadTask(activeSquadId, taskId)) {
568
+ return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
569
+ }
570
+
571
+ // Review/done squads have no live scheduler after a process restart. An
572
+ // exact task ID is enough to reconstruct it from disk and reopen only that
573
+ // task; the task's immutable session binding supplies --session.
574
+ if (!activeScheduler) {
575
+ activeScheduler = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
576
+ schedulers.set(activeSquadId, activeScheduler);
577
+ wireSchedulerEvents(pi, activeScheduler, activeSquadId);
578
+ await activeScheduler.start();
579
+ }
531
580
 
532
- const sent = await activeScheduler!.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
581
+ const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
533
582
  const status = sent ? "delivered" : "queued for when the agent starts";
534
583
 
535
584
  return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
@@ -543,8 +592,9 @@ export default function (pi: ExtensionAPI) {
543
592
  pi.registerTool({
544
593
  name: "squad_modify",
545
594
  label: "Squad Modify",
546
- description: "Modify the running squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume (also recovers failed squads), cancel (entire squad).",
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.",
547
596
  parameters: Type.Object({
597
+ squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
548
598
  action: Type.Union(
549
599
  [
550
600
  Type.Literal("add_task"),
@@ -573,95 +623,37 @@ export default function (pi: ExtensionAPI) {
573
623
  }),
574
624
 
575
625
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
576
- // Resume can work without an active scheduler — it recreates one from disk
577
626
  if (params.action === "resume") {
578
- // Find a squad to resume: use activeSquadId or find the latest paused one
579
- const squadId = activeSquadId || store.findActiveSquads()
580
- .filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
581
- .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
582
-
583
- if (!squadId) {
584
- return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
627
+ const squad = resolveResumeSquad(ctx.cwd, params.squadId);
628
+ if (!squad) {
629
+ const text = params.squadId
630
+ ? `Squad '${params.squadId}' not found.`
631
+ : "No paused, failed, or failed-review squad found to resume.";
632
+ return { content: [{ type: "text" as const, text }], details: undefined };
585
633
  }
586
-
587
- // Create a fresh scheduler if needed
588
- if (!schedulers.has(squadId)) {
589
- const scheduler = new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext);
590
- schedulers.set(squadId, scheduler);
591
- activeSquadId = squadId;
592
-
593
- // Activate widget
594
- widgetState.squadId = squadId;
595
- widgetState.enabled = true;
596
- widgetControls?.requestUpdate();
597
-
598
- // Wire up events (same as startSquad)
599
- scheduler.onEvent((event: SchedulerEvent) => {
600
- forceWidgetUpdate();
601
- switch (event.type) {
602
- case "squad_review_required": {
603
- const tasks = store.loadAllTasks(squadId);
604
- const summary = buildCompletionSummary(tasks);
605
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
606
- const s = schedulers.get(squadId); if (s) s.updateContext();
607
- const squad = store.loadSquad(squadId)!;
608
- // Wake the main agent into an explicit acceptance gate. Agent reports
609
- // are untrusted inputs; only independent orchestrator evidence can pass it.
610
- pi.sendMessage({
611
- customType: "squad-review-required",
612
- content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\nSquad claims (review inputs only):\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
613
- display: true,
614
- }, { triggerTurn: true, deliverAs: "followUp" });
615
- schedulers.delete(squadId);
616
- forceWidgetUpdate();
617
- break;
618
- }
619
- case "squad_failed": {
620
- const tasks = store.loadAllTasks(squadId);
621
- const failed = tasks.filter((t) => t.status === "failed");
622
- const done = tasks.filter((t) => t.status === "done");
623
- pi.sendMessage({
624
- customType: "squad-failed",
625
- content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
626
- display: true,
627
- }, { triggerTurn: true, deliverAs: "followUp" });
628
- forceWidgetUpdate();
629
- break;
630
- }
631
- case "orchestrator_reply": {
632
- pi.sendMessage({
633
- customType: "squad-agent-reply",
634
- content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
635
- display: true,
636
- }, { triggerTurn: true, deliverAs: "followUp" });
637
- break;
638
- }
639
- case "escalation": {
640
- pi.sendMessage({
641
- customType: "squad-escalation",
642
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n${event.message}`,
643
- display: true,
644
- }, { triggerTurn: true });
645
- break;
646
- }
647
- }
648
- });
634
+ const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
635
+ try {
636
+ await resumeSched.resume();
637
+ } catch (err) {
638
+ return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
649
639
  }
650
-
651
- const resumeSched = schedulers.get(squadId)!;
652
- resumeSched.resume().catch((err) => {
653
- logError("squad", `Resume error: ${(err as Error).message}`);
654
- });
655
-
656
- const tasks = store.loadAllTasks(squadId);
657
- const done = tasks.filter(t => t.status === "done").length;
658
- return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
640
+ 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 };
659
643
  }
660
644
 
661
- const activeScheduler = getActiveScheduler();
662
- if (!activeScheduler || !activeSquadId) {
663
- return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }], details: undefined };
645
+ const squadId = params.squadId || activeSquadId;
646
+ if (!squadId || !store.loadSquad(squadId)) {
647
+ 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 };
664
648
  }
649
+ let activeScheduler = schedulers.get(squadId) || null;
650
+ if (!activeScheduler && (params.action === "add_task" || params.action === "resume_task")) {
651
+ activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
652
+ }
653
+ if (!activeScheduler) {
654
+ return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
655
+ }
656
+ activeSquadId = squadId;
665
657
 
666
658
  switch (params.action) {
667
659
  case "add_task": {
@@ -678,17 +670,19 @@ export default function (pi: ExtensionAPI) {
678
670
  if (badDeps.length > 0) {
679
671
  return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
680
672
  }
681
- if (!store.loadAgentDef(params.task.agent, ctx.cwd)) {
682
- const available = store.loadAllAgentDefs(ctx.cwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
673
+ const targetCwd = store.loadSquad(squadId)!.cwd;
674
+ if (!store.loadAgentDef(params.task.agent, targetCwd)) {
675
+ const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
683
676
  return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
684
677
  }
678
+ const dependencies = params.task.depends || [];
685
679
  const task: Task = {
686
680
  id: params.task.id,
687
681
  title: params.task.title,
688
682
  description: params.task.description || "",
689
683
  agent: params.task.agent,
690
- status: "pending",
691
- depends: params.task.depends || [],
684
+ status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
685
+ depends: dependencies,
692
686
  ...(params.task.inheritContext ? { inheritContext: true } : {}),
693
687
  created: store.now(),
694
688
  started: null,
@@ -697,9 +691,12 @@ export default function (pi: ExtensionAPI) {
697
691
  error: null,
698
692
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
699
693
  };
700
- store.createTask(activeSquadId, task);
701
- activeScheduler.updateContext();
702
- return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }], details: undefined };
694
+ try {
695
+ await activeScheduler.addTask(task);
696
+ } catch (err) {
697
+ return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
698
+ }
699
+ return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
703
700
  }
704
701
 
705
702
  case "cancel_task": {
@@ -716,10 +713,12 @@ export default function (pi: ExtensionAPI) {
716
713
 
717
714
  case "resume_task": {
718
715
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
719
- activeScheduler.resumeTask(params.taskId).catch((err) => {
720
- logError("squad", `Resume task error: ${(err as Error).message}`);
721
- });
722
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
716
+ try {
717
+ await activeScheduler.resumeTask(params.taskId);
718
+ } catch (err) {
719
+ return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
720
+ }
721
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
723
722
  }
724
723
 
725
724
  case "complete_task": {
@@ -795,6 +794,29 @@ export default function (pi: ExtensionAPI) {
795
794
  }
796
795
  }
797
796
 
797
+ // Mailbox recovery is automatic after extension/main-process restart. Scan
798
+ // every project squad (including accepted done squads) because a crash can
799
+ // occur after the mailbox-first write but before the squad/task is reopened.
800
+ const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
801
+ .filter((squad) => store.loadAllTasks(squad.id)
802
+ .some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
803
+ .sort((a, b) => b.created.localeCompare(a.created));
804
+ for (const squad of pendingMailSquads) {
805
+ let scheduler = schedulers.get(squad.id);
806
+ if (!scheduler) {
807
+ scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
808
+ schedulers.set(squad.id, scheduler);
809
+ wireSchedulerEvents(pi, scheduler, squad.id);
810
+ }
811
+ await scheduler.start();
812
+ }
813
+ if (pendingMailSquads.length > 0) {
814
+ activeSquadId = pendingMailSquads[0].id;
815
+ widgetState.squadId = activeSquadId;
816
+ widgetState.enabled = true;
817
+ widgetControls?.requestUpdate();
818
+ }
819
+
798
820
  // Notify about paused squads only if they have real completed work
799
821
  const paused = store.findActiveSquads()
800
822
  .filter((s) => s.cwd === ctx.cwd && s.status === "paused");
@@ -851,7 +873,7 @@ export default function (pi: ExtensionAPI) {
851
873
  }
852
874
 
853
875
  if (activeSquadId) {
854
- openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
876
+ openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
855
877
  }
856
878
  return { consume: true };
857
879
  }
@@ -876,16 +898,17 @@ export default function (pi: ExtensionAPI) {
876
898
  // =========================================================================
877
899
 
878
900
  pi.registerCommand("squad", {
879
- description: "Browse, select, and manage squads. Usage: /squad [list|all|select|agents|msg|widget|panel|cancel|clear]",
901
+ description: "Browse, select, and manage squads. Usage: /squad [list|all|select|resume|agents|msg|widget|panel|cancel|clear]",
880
902
  getArgumentCompletions: (prefix) => {
881
903
  const subs = [
882
904
  { value: "list", label: "list", description: "List squads for current project" },
883
905
  { value: "all", label: "all", description: "List all squads, select to activate" },
884
906
  { value: "select", label: "select", description: "Pick a squad to view (interactive)" },
907
+ { value: "resume", label: "resume", description: "Resume an exact paused/failed/failed-review squad" },
885
908
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
886
909
  { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
887
910
  { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
888
- { value: "msg", label: "msg", description: "Send message to agent: /squad msg [agent] text" },
911
+ { value: "msg", label: "msg", description: "Message a task: /squad msg [task-id|running-agent] text" },
889
912
  { value: "widget", label: "widget", description: "Toggle live widget" },
890
913
  { value: "panel", label: "panel", description: "Toggle overlay panel" },
891
914
  { value: "cancel", label: "cancel", description: "Cancel running squad" },
@@ -951,6 +974,25 @@ export default function (pi: ExtensionAPI) {
951
974
  return;
952
975
  }
953
976
 
977
+ case "resume": {
978
+ const squad = resolveResumeSquad(ctx.cwd, parts[1]);
979
+ if (!squad) {
980
+ ctx.ui.notify(parts[1] ? `Squad '${parts[1]}' not found` : "No paused, failed, or failed-review squad found", "warning");
981
+ return;
982
+ }
983
+ const scheduler = ensureScheduler(pi, squad.id, squadSkillPaths);
984
+ try {
985
+ await scheduler.resume();
986
+ } catch (error) {
987
+ ctx.ui.notify(`Resume failed: ${(error as Error).message}`, "error");
988
+ return;
989
+ }
990
+ 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");
993
+ return;
994
+ }
995
+
954
996
  case "widget": {
955
997
  widgetState.enabled = !widgetState.enabled;
956
998
  if (widgetState.enabled) {
@@ -978,7 +1020,7 @@ export default function (pi: ExtensionAPI) {
978
1020
  }
979
1021
  if (activeSquadId) {
980
1022
  const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
981
- openPanel(ctx, sched, activeSquadId);
1023
+ openPanel(pi, ctx, sched, activeSquadId);
982
1024
  }
983
1025
  return;
984
1026
  }
@@ -989,11 +1031,8 @@ export default function (pi: ExtensionAPI) {
989
1031
  return;
990
1032
  }
991
1033
  const msgSquad = store.loadSquad(activeSquadId);
992
- if (!msgSquad || msgSquad.status !== "running") {
993
- ctx.ui.notify("Squad is not running — messages only reach running agents.", "info");
994
- return;
995
- }
996
- // Parse: /squad msg [agent] message text
1034
+ if (!msgSquad) return;
1035
+ // Parse: /squad msg [task-id|running-agent] message text
997
1036
  const msgParts = parts.slice(1);
998
1037
  let targetAgent: string | undefined;
999
1038
  let msgText: string;
@@ -1014,40 +1053,51 @@ export default function (pi: ExtensionAPI) {
1014
1053
  }
1015
1054
  }
1016
1055
 
1017
- // Find target task
1056
+ // Find one exact target task. A task ID can address completed or
1057
+ // stopped work; an agent name remains shorthand for a live task only.
1018
1058
  const msgTasks = store.loadAllTasks(activeSquadId);
1019
1059
  let targetTaskId: string | undefined;
1020
-
1021
- if (targetAgent) {
1022
- const agentTask = msgTasks.find((t) => t.agent === targetAgent && t.status === "in_progress");
1023
- targetTaskId = agentTask?.id;
1060
+ const explicitTask = msgParts.length > 1
1061
+ ? msgTasks.find((task) => task.id === msgParts[0])
1062
+ : undefined;
1063
+ if (explicitTask) {
1064
+ targetTaskId = explicitTask.id;
1065
+ targetAgent = explicitTask.agent;
1066
+ msgText = msgParts.slice(1).join(" ");
1067
+ } else if (targetAgent) {
1068
+ const liveMatches = msgTasks.filter(
1069
+ (task) => task.agent === targetAgent && getActiveScheduler()?.getPool().isRunning(task.id),
1070
+ );
1071
+ if (liveMatches.length === 1) targetTaskId = liveMatches[0].id;
1024
1072
  if (!targetTaskId) {
1025
- ctx.ui.notify(`Agent '${targetAgent}' has no running task`, "warning");
1073
+ ctx.ui.notify(`Agent '${targetAgent}' is not working on exactly one live task; provide an exact task ID`, "warning");
1026
1074
  return;
1027
1075
  }
1028
1076
  } else {
1029
- const runningTask = msgTasks.find((t) => t.status === "in_progress");
1030
- targetTaskId = runningTask?.id;
1031
- targetAgent = runningTask?.agent;
1032
- if (!targetTaskId) {
1033
- ctx.ui.notify("No running tasks to message", "warning");
1077
+ const liveTasks = msgTasks.filter((task) =>
1078
+ getActiveScheduler()?.getPool().isRunning(task.id),
1079
+ );
1080
+ if (liveTasks.length === 1) {
1081
+ targetTaskId = liveTasks[0].id;
1082
+ targetAgent = liveTasks[0].agent;
1083
+ } else {
1084
+ ctx.ui.notify("No unique live task to message; provide an exact task ID", "warning");
1034
1085
  return;
1035
1086
  }
1036
1087
  }
1037
1088
 
1038
- const msgSched = getActiveScheduler();
1039
- if (msgSched) {
1040
- await msgSched.sendHumanMessage(targetTaskId, msgText);
1041
- ctx.ui.notify(`Sent to ${targetAgent}: "${msgText}"`, "info");
1042
- } else {
1043
- store.appendMessage(activeSquadId, targetTaskId, {
1044
- ts: store.now(),
1045
- from: "human",
1046
- type: "message",
1047
- text: msgText,
1048
- });
1049
- ctx.ui.notify(`Logged to ${targetTaskId} (agent not running)`, "info");
1089
+ let msgSched = getActiveScheduler();
1090
+ if (!msgSched) {
1091
+ msgSched = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
1092
+ schedulers.set(activeSquadId, msgSched);
1093
+ wireSchedulerEvents(pi, msgSched, activeSquadId);
1094
+ await msgSched.start();
1050
1095
  }
1096
+ const delivered = await msgSched.sendHumanMessage(targetTaskId, msgText);
1097
+ ctx.ui.notify(
1098
+ delivered ? `Sent to ${targetAgent}: "${msgText}"` : `Queued durably for ${targetTaskId}`,
1099
+ "info",
1100
+ );
1051
1101
  forceWidgetUpdate();
1052
1102
  return;
1053
1103
  }
@@ -1400,7 +1450,7 @@ export default function (pi: ExtensionAPI) {
1400
1450
  activateSquadView(direct.id, ctx);
1401
1451
  return;
1402
1452
  }
1403
- ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1453
+ ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, resume, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1404
1454
  }
1405
1455
  },
1406
1456
  });
@@ -1475,6 +1525,63 @@ function forceWidgetUpdate(): void {
1475
1525
  widgetControls?.requestUpdate();
1476
1526
  }
1477
1527
 
1528
+ /** Wire one scheduler to durable main-session notifications. */
1529
+ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
1530
+ scheduler.onEvent((event: SchedulerEvent) => {
1531
+ forceWidgetUpdate();
1532
+ switch (event.type) {
1533
+ case "squad_review_required": {
1534
+ const tasks = store.loadAllTasks(squadId);
1535
+ const summary = buildCompletionSummary(tasks);
1536
+ const totalCost = tasks.reduce((sum, task) => sum + task.usage.cost, 0);
1537
+ scheduler.updateContext();
1538
+ const squad = store.loadSquad(squadId);
1539
+ if (!squad) break;
1540
+ pi.sendMessage({
1541
+ customType: "squad-review-required",
1542
+ content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1543
+ `Squad claims (review inputs only):\n${summary}\n\n` +
1544
+ `Total cost: $${totalCost.toFixed(4)}\n\n` +
1545
+ buildOrchestratorReviewGate(squad, tasks),
1546
+ display: true,
1547
+ }, { triggerTurn: true, deliverAs: "followUp" });
1548
+ // Keep the settled scheduler addressable. A later exact-task message
1549
+ // can reopen the task immediately on its bound durable Pi session.
1550
+ break;
1551
+ }
1552
+ case "squad_failed": {
1553
+ const tasks = store.loadAllTasks(squadId);
1554
+ const failed = tasks.filter((task) => task.status === "failed");
1555
+ const done = tasks.filter((task) => task.status === "done");
1556
+ pi.sendMessage({
1557
+ customType: "squad-failed",
1558
+ content: `[squad] Squad "${squadId}" has stalled. ` +
1559
+ `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1560
+ `Failed: ${buildFailureSummary(tasks)}\n` +
1561
+ "Use squad_status for details or squad_modify to adjust.",
1562
+ display: true,
1563
+ }, { triggerTurn: true, deliverAs: "followUp" });
1564
+ break;
1565
+ }
1566
+ case "orchestrator_reply":
1567
+ pi.sendMessage({
1568
+ customType: "squad-agent-reply",
1569
+ content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
1570
+ display: true,
1571
+ }, { triggerTurn: true, deliverAs: "followUp" });
1572
+ break;
1573
+ case "escalation":
1574
+ pi.sendMessage({
1575
+ customType: "squad-escalation",
1576
+ content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1577
+ `${event.message}\n\nReply to me and I'll forward your answer, or use the squad panel.`,
1578
+ display: true,
1579
+ }, { triggerTurn: true });
1580
+ break;
1581
+ }
1582
+ });
1583
+ }
1584
+
1478
1585
  // ============================================================================
1479
1586
  // Panel — overlay via ctx.ui.custom() with proper done() lifecycle
1480
1587
  // ============================================================================
@@ -1485,6 +1592,7 @@ function forceWidgetUpdate(): void {
1485
1592
  * that resolves when done() is called. The panel calls done() on close.
1486
1593
  */
1487
1594
  function openPanel(
1595
+ pi: ExtensionAPI,
1488
1596
  ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
1489
1597
  scheduler: Scheduler,
1490
1598
  squadId: string,
@@ -1502,18 +1610,19 @@ function openPanel(
1502
1610
  const task = store.loadTask(squadId, taskId);
1503
1611
  const agentName = task?.agent || taskId;
1504
1612
  const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
1505
- const panelSched = schedulers.get(squadId);
1506
- if (input && panelSched) {
1507
- await panelSched.sendHumanMessage(taskId, input);
1508
- ctx.ui.notify(`Sent to ${agentName}: "${input}"`, "info");
1509
- } else if (input) {
1510
- store.appendMessage(squadId, taskId, {
1511
- ts: store.now(),
1512
- from: "human",
1513
- type: "message",
1514
- text: input,
1515
- });
1516
- ctx.ui.notify(`Logged to ${taskId}`, "info");
1613
+ if (input) {
1614
+ let panelSched = schedulers.get(squadId);
1615
+ if (!panelSched) {
1616
+ panelSched = scheduler;
1617
+ schedulers.set(squadId, panelSched);
1618
+ wireSchedulerEvents(pi, panelSched, squadId);
1619
+ await panelSched.start();
1620
+ }
1621
+ const delivered = await panelSched.sendHumanMessage(taskId, input);
1622
+ ctx.ui.notify(
1623
+ delivered ? `Sent to ${agentName}: "${input}"` : `Queued durably for ${taskId}`,
1624
+ "info",
1625
+ );
1517
1626
  }
1518
1627
  tui.requestRender();
1519
1628
  };
@@ -1666,82 +1775,8 @@ async function startSquad(
1666
1775
  widgetState.enabled = true;
1667
1776
  widgetControls?.requestUpdate();
1668
1777
 
1669
- // Wire up completion/escalation notifications to main agent
1670
- scheduler.onEvent((event: SchedulerEvent) => {
1671
- // Update widget on every scheduler event
1672
- forceWidgetUpdate();
1673
- switch (event.type) {
1674
- case "squad_review_required": {
1675
- const tasks = store.loadAllTasks(squadId);
1676
- const summary = buildCompletionSummary(tasks);
1677
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1678
-
1679
- // Final context update before clearing scheduler
1680
- const completedSched = schedulers.get(squadId);
1681
- if (completedSched) {
1682
- completedSched.updateContext();
1683
- }
1684
- const squad = store.loadSquad(squadId)!;
1685
-
1686
- // Wake the main agent into an explicit acceptance gate. Never frame
1687
- // agent execution or squad QA as trusted completion.
1688
- pi.sendMessage({
1689
- customType: "squad-review-required",
1690
- content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1691
- `Squad claims (review inputs only):\n${summary}\n\n` +
1692
- `Total cost: $${totalCost.toFixed(4)}\n\n` +
1693
- buildOrchestratorReviewGate(squad, tasks),
1694
- display: true,
1695
- }, { triggerTurn: true, deliverAs: "followUp" });
1696
-
1697
- // Clear scheduler but keep activeSquadId so squad_status still works
1698
- schedulers.delete(squadId);
1699
- forceWidgetUpdate(); // Final update showing done state
1700
- break;
1701
- }
1702
-
1703
- case "squad_failed": {
1704
- const tasks = store.loadAllTasks(squadId);
1705
- const failed = tasks.filter((t) => t.status === "failed");
1706
- const done = tasks.filter((t) => t.status === "done");
1707
-
1708
- pi.sendMessage({
1709
- customType: "squad-failed",
1710
- content: `[squad] Squad "${squadId}" has stalled. ` +
1711
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1712
- `Failed: ${buildFailureSummary(tasks)}\n` +
1713
- `Use squad_status for details or squad_modify to adjust.`,
1714
- display: true,
1715
- }, { triggerTurn: true, deliverAs: "followUp" });
1716
- forceWidgetUpdate();
1717
- break;
1718
- }
1719
-
1720
- case "orchestrator_reply": {
1721
- // Push the complete requested response back into the main session and
1722
- // wake it; ordinary task activity remains panel-only.
1723
- pi.sendMessage({
1724
- customType: "squad-agent-reply",
1725
- content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
1726
- display: true,
1727
- }, { triggerTurn: true, deliverAs: "followUp" });
1728
- break;
1729
- }
1730
-
1731
- case "escalation": {
1732
- // Escalation — agent needs help. triggerTurn so the main agent
1733
- // can respond and relay help.
1734
- pi.sendMessage({
1735
- customType: "squad-escalation",
1736
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1737
- `${event.message}\n\n` +
1738
- `Reply to me and I'll forward your answer, or use the squad panel.`,
1739
- display: true,
1740
- }, { triggerTurn: true });
1741
- break;
1742
- }
1743
- }
1744
- });
1778
+ // Wire up completion/escalation notifications to main agent.
1779
+ wireSchedulerEvents(pi, scheduler, squadId);
1745
1780
 
1746
1781
  // Start scheduling — fire and forget, don't block the tool call.
1747
1782
  // scheduler.start() spawns agents which can take seconds per agent.