pi-squad 0.17.2 → 0.18.0
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/CHANGELOG.md +74 -0
- package/package.json +2 -1
- package/src/commands.ts +585 -0
- package/src/index.ts +14 -2013
- package/src/lifecycle.ts +164 -0
- package/src/panel-runtime.ts +157 -0
- package/src/protocol.ts +3 -47
- package/src/report.ts +40 -2
- package/src/runtime.ts +196 -0
- package/src/scheduler-runtime.ts +117 -0
- package/src/scheduler.ts +149 -135
- package/src/start-squad.ts +176 -0
- package/src/store.ts +0 -43
- package/src/tools-registration.ts +609 -0
- package/src/types.ts +0 -26
- package/src/supervisor.ts +0 -142
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { logError } from "./logger.js";
|
|
3
|
+
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
4
|
+
import { buildOrchestratorReviewGate } from "./review.js";
|
|
5
|
+
import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent } from "./scheduler.js";
|
|
6
|
+
import * as store from "./store.js";
|
|
7
|
+
import type { SuspendedStallAttention } from "./types.js";
|
|
8
|
+
import { forceWidgetUpdate, formatTaskProgress, focusSquad, repairFocusAfterCancellation, runtime, schedulerSpawnContext } from "./runtime.js";
|
|
9
|
+
|
|
10
|
+
/** Wire one scheduler to durable main-session notifications. */
|
|
11
|
+
export function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
|
|
12
|
+
scheduler.onEvent((event: SchedulerEvent) => {
|
|
13
|
+
// Durable scheduler state remains authoritative, but disabled mode suppresses
|
|
14
|
+
// late operational UI, acknowledgements, focus, and ordinary notifications.
|
|
15
|
+
if (!runtime.squadEnabled) return;
|
|
16
|
+
forceWidgetUpdate();
|
|
17
|
+
switch (event.type) {
|
|
18
|
+
case "squad_review_required": {
|
|
19
|
+
const tasks = store.loadAllTasks(squadId);
|
|
20
|
+
const squad = store.loadSquad(squadId);
|
|
21
|
+
const summary = buildCompletionSummary(tasks, squad?.cwd);
|
|
22
|
+
const totalCost = tasks.reduce((sum, task) => sum + task.usage.cost, 0);
|
|
23
|
+
scheduler.updateContext();
|
|
24
|
+
if (!squad) break;
|
|
25
|
+
pi.sendMessage({
|
|
26
|
+
customType: "squad-review-required",
|
|
27
|
+
content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
|
|
28
|
+
`Squad claims (review inputs only):\n${summary}\n\n` +
|
|
29
|
+
`Total cost: $${totalCost.toFixed(4)}\n\n` +
|
|
30
|
+
buildOrchestratorReviewGate(squad, tasks),
|
|
31
|
+
display: true,
|
|
32
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
33
|
+
// Keep the settled scheduler addressable. A later exact-task message
|
|
34
|
+
// can reopen the task immediately on its bound durable Pi session.
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "suspended_stall": {
|
|
38
|
+
try {
|
|
39
|
+
const attention = event.data as SuspendedStallAttention;
|
|
40
|
+
pi.sendMessage({
|
|
41
|
+
customType: `squad-suspended-stall:${squadId}:${attention.fingerprint}`,
|
|
42
|
+
content: formatSuspendedStallAttention(squadId, attention),
|
|
43
|
+
display: true,
|
|
44
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
45
|
+
scheduler.acknowledgeSuspendedStall(attention.fingerprint);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logError("squad-scheduler", `suspended-stall delivery failed for ${squadId}: ${(error as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "squad_failed": {
|
|
52
|
+
const tasks = store.loadAllTasks(squadId);
|
|
53
|
+
const failed = tasks.filter((task) => task.status === "failed");
|
|
54
|
+
pi.sendMessage({
|
|
55
|
+
customType: "squad-failed",
|
|
56
|
+
content: `[squad] Squad "${squadId}" has stalled. ` +
|
|
57
|
+
`${formatTaskProgress(tasks)}, ${failed.length} failed.\n` +
|
|
58
|
+
`Failed: ${buildFailureSummary(tasks)}\n` +
|
|
59
|
+
"Use squad_status for details or squad_modify to adjust.",
|
|
60
|
+
display: true,
|
|
61
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "orchestrator_reply":
|
|
65
|
+
pi.sendMessage({
|
|
66
|
+
customType: "squad-agent-reply",
|
|
67
|
+
content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
|
|
68
|
+
display: true,
|
|
69
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
70
|
+
break;
|
|
71
|
+
case "escalation":
|
|
72
|
+
pi.sendMessage({
|
|
73
|
+
customType: "squad-escalation",
|
|
74
|
+
content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
|
|
75
|
+
`${event.message}\n\nReply to me and I'll forward your answer, or use the squad panel.`,
|
|
76
|
+
display: true,
|
|
77
|
+
}, { triggerTurn: true });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cancel one persisted exact squad without ever inferring or changing focus. */
|
|
84
|
+
export async function cancelExactSquad(squadId: string, skillPaths: string[]): Promise<boolean> {
|
|
85
|
+
const squad = store.loadSquad(squadId);
|
|
86
|
+
if (!squad) return false;
|
|
87
|
+
const live = runtime.schedulers.get(squadId);
|
|
88
|
+
if (live) await live.stop();
|
|
89
|
+
else await new Scheduler(squadId, skillPaths, schedulerSpawnContext).stop();
|
|
90
|
+
const fresh = store.loadSquad(squadId);
|
|
91
|
+
if (fresh) {
|
|
92
|
+
fresh.status = "failed";
|
|
93
|
+
delete fresh.suspendedStallAttention;
|
|
94
|
+
store.saveSquad(fresh);
|
|
95
|
+
}
|
|
96
|
+
runtime.schedulers.delete(squadId);
|
|
97
|
+
repairFocusAfterCancellation(squadId);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Reconstruct one exact persisted squad without changing focus or starting work. */
|
|
102
|
+
export function reviveScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
103
|
+
let scheduler = runtime.schedulers.get(squadId);
|
|
104
|
+
if (!scheduler) {
|
|
105
|
+
scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
|
|
106
|
+
runtime.schedulers.set(squadId, scheduler);
|
|
107
|
+
wireSchedulerEvents(pi, scheduler, squadId);
|
|
108
|
+
}
|
|
109
|
+
return scheduler;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reconstruct and focus one exact persisted squad without creating/linking another. */
|
|
113
|
+
export function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
|
|
114
|
+
const scheduler = reviveScheduler(pi, squadId, skillPaths);
|
|
115
|
+
focusSquad(squadId);
|
|
116
|
+
return scheduler;
|
|
117
|
+
}
|
package/src/scheduler.ts
CHANGED
|
@@ -797,159 +797,173 @@ export class Scheduler {
|
|
|
797
797
|
|
|
798
798
|
private handleAgentEvent(event: AgentEvent): void {
|
|
799
799
|
switch (event.type) {
|
|
800
|
-
case "message_end":
|
|
801
|
-
|
|
802
|
-
if (msg?.role === "assistant") {
|
|
803
|
-
// Extract text from assistant message
|
|
804
|
-
const text = this.extractAssistantText(msg);
|
|
805
|
-
if (text) {
|
|
806
|
-
// Route @mentions
|
|
807
|
-
this.router.processMessage(event.taskId, event.agentName, text);
|
|
808
|
-
|
|
809
|
-
// Log message
|
|
810
|
-
store.appendMessage(this.squadId, event.taskId, {
|
|
811
|
-
ts: store.now(),
|
|
812
|
-
from: event.agentName,
|
|
813
|
-
type: "text",
|
|
814
|
-
// Persist the complete handoff. Reports can be arbitrarily long;
|
|
815
|
-
// presentation layers may viewport them, but source data must never truncate.
|
|
816
|
-
text,
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// A squad_message is a request/response channel, not fire-and-forget.
|
|
820
|
-
// Persist an acknowledgement marker before emitting so restart/focus
|
|
821
|
-
// changes cannot forward the same response twice.
|
|
822
|
-
if (this.hasPendingOrchestratorRequest(event.taskId)) {
|
|
823
|
-
store.appendMessage(this.squadId, event.taskId, {
|
|
824
|
-
ts: store.now(),
|
|
825
|
-
from: event.agentName,
|
|
826
|
-
type: "reply",
|
|
827
|
-
to: "orchestrator",
|
|
828
|
-
text: "Response forwarded to main orchestrator",
|
|
829
|
-
});
|
|
830
|
-
this.emit({
|
|
831
|
-
type: "orchestrator_reply",
|
|
832
|
-
squadId: this.squadId,
|
|
833
|
-
taskId: event.taskId,
|
|
834
|
-
agentName: event.agentName,
|
|
835
|
-
message: text,
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// Track usage
|
|
841
|
-
if (msg.usage) {
|
|
842
|
-
store.updateTaskUsage(this.squadId, event.taskId, {
|
|
843
|
-
inputTokens: msg.usage.input || 0,
|
|
844
|
-
outputTokens: msg.usage.output || 0,
|
|
845
|
-
cost: msg.usage.cost?.total || 0,
|
|
846
|
-
turns: 1,
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
}
|
|
800
|
+
case "message_end":
|
|
801
|
+
this.onMessageEnd(event);
|
|
850
802
|
break;
|
|
851
|
-
|
|
803
|
+
case "tool_execution_start":
|
|
804
|
+
this.onToolExecutionStart(event);
|
|
805
|
+
break;
|
|
806
|
+
case "tool_execution_end":
|
|
807
|
+
this.onToolExecutionEnd(event);
|
|
808
|
+
break;
|
|
809
|
+
case "agent_end":
|
|
810
|
+
if (this.onAgentEnd(event)) return;
|
|
811
|
+
break;
|
|
812
|
+
case "agent_settled":
|
|
813
|
+
this.onAgentSettled(event);
|
|
814
|
+
return;
|
|
815
|
+
case "error":
|
|
816
|
+
this.onError(event);
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
852
819
|
|
|
853
|
-
|
|
854
|
-
|
|
820
|
+
this.updateContext();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private onMessageEnd(event: AgentEvent): void {
|
|
824
|
+
const msg = event.data;
|
|
825
|
+
if (msg?.role === "assistant") {
|
|
826
|
+
// Extract text from assistant message
|
|
827
|
+
const text = this.extractAssistantText(msg);
|
|
828
|
+
if (text) {
|
|
829
|
+
// Route @mentions
|
|
830
|
+
this.router.processMessage(event.taskId, event.agentName, text);
|
|
831
|
+
|
|
832
|
+
// Log message
|
|
855
833
|
store.appendMessage(this.squadId, event.taskId, {
|
|
856
834
|
ts: store.now(),
|
|
857
835
|
from: event.agentName,
|
|
858
|
-
type: "
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
836
|
+
type: "text",
|
|
837
|
+
// Persist the complete handoff. Reports can be arbitrarily long;
|
|
838
|
+
// presentation layers may viewport them, but source data must never truncate.
|
|
839
|
+
text,
|
|
862
840
|
});
|
|
863
841
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
agentName: event.agentName,
|
|
869
|
-
message: `→ ${data.toolName}`,
|
|
870
|
-
data,
|
|
871
|
-
});
|
|
872
|
-
break;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
case "tool_execution_end": {
|
|
876
|
-
// Track file modifications
|
|
877
|
-
const data = event.data;
|
|
878
|
-
if (data.toolName === "write" || data.toolName === "edit") {
|
|
879
|
-
const filePath = data.args?.path || data.args?.file_path;
|
|
880
|
-
if (filePath) {
|
|
881
|
-
this.updateModifiedFiles(event.agentName, filePath);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
break;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
case "agent_end": {
|
|
888
|
-
// Pi's low-level agent_end is not completion. AgentPool normally keeps
|
|
889
|
-
// it internal; if observed here, preserve in_progress. Only an actual
|
|
890
|
-
// child-process exit carries unexpectedExit and enters retry handling.
|
|
891
|
-
if (!event.data?.unexpectedExit) break;
|
|
892
|
-
this.handleUnexpectedAgentExit(event);
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
case "agent_settled": {
|
|
897
|
-
const settledTask = store.loadTask(this.squadId, event.taskId);
|
|
898
|
-
const status = settledTask?.status;
|
|
899
|
-
if (status === "cancelled" || status === "suspended") return;
|
|
900
|
-
const settledSquad = store.loadSquad(this.squadId);
|
|
901
|
-
if (settledTask && settledSquad && !validateTaskSpecAttestation(settledSquad, settledTask)) {
|
|
902
|
-
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null, output: null, error: "Canonical squad spec was not fully delivered; read all chunks with squad_spec_read" });
|
|
903
|
-
store.appendMessage(this.squadId, event.taskId, { ts: store.now(), from: "system", type: "status", text: "Completion rejected: missing or invalid spec-read-attestation; reopening same task session" });
|
|
904
|
-
if (this.running) void this.reconcile();
|
|
905
|
-
this.updateContext();
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
909
|
-
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
910
|
-
// occurs before the task can become done.
|
|
911
|
-
if (store.loadPendingTaskMessages(this.squadId, event.taskId).length > 0) {
|
|
912
|
-
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null });
|
|
842
|
+
// A squad_message is a request/response channel, not fire-and-forget.
|
|
843
|
+
// Persist an acknowledgement marker before emitting so restart/focus
|
|
844
|
+
// changes cannot forward the same response twice.
|
|
845
|
+
if (this.hasPendingOrchestratorRequest(event.taskId)) {
|
|
913
846
|
store.appendMessage(this.squadId, event.taskId, {
|
|
914
847
|
ts: store.now(),
|
|
915
|
-
from:
|
|
916
|
-
type: "
|
|
917
|
-
|
|
848
|
+
from: event.agentName,
|
|
849
|
+
type: "reply",
|
|
850
|
+
to: "orchestrator",
|
|
851
|
+
text: "Response forwarded to main orchestrator",
|
|
918
852
|
});
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const toolCallCount = event.data?.toolCallCount ?? 0;
|
|
926
|
-
const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
|
|
927
|
-
.some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
|
|
928
|
-
const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
|
|
929
|
-
if (hadMeaningfulWork) {
|
|
930
|
-
this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
|
|
931
|
-
} else {
|
|
932
|
-
this.handleUnexpectedAgentExit({
|
|
933
|
-
...event,
|
|
934
|
-
data: { ...event.data, unexpectedExit: true },
|
|
853
|
+
this.emit({
|
|
854
|
+
type: "orchestrator_reply",
|
|
855
|
+
squadId: this.squadId,
|
|
856
|
+
taskId: event.taskId,
|
|
857
|
+
agentName: event.agentName,
|
|
858
|
+
message: text,
|
|
935
859
|
});
|
|
936
860
|
}
|
|
937
|
-
return;
|
|
938
861
|
}
|
|
939
862
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
store.
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
863
|
+
// Track usage
|
|
864
|
+
if (msg.usage) {
|
|
865
|
+
store.updateTaskUsage(this.squadId, event.taskId, {
|
|
866
|
+
inputTokens: msg.usage.input || 0,
|
|
867
|
+
outputTokens: msg.usage.output || 0,
|
|
868
|
+
cost: msg.usage.cost?.total || 0,
|
|
869
|
+
turns: 1,
|
|
947
870
|
});
|
|
948
|
-
break;
|
|
949
871
|
}
|
|
950
872
|
}
|
|
873
|
+
}
|
|
951
874
|
|
|
952
|
-
|
|
875
|
+
private onToolExecutionStart(event: AgentEvent): void {
|
|
876
|
+
const data = event.data;
|
|
877
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
878
|
+
ts: store.now(),
|
|
879
|
+
from: event.agentName,
|
|
880
|
+
type: "tool",
|
|
881
|
+
text: data.toolName || "unknown",
|
|
882
|
+
name: data.toolName,
|
|
883
|
+
args: data.args,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
this.emit({
|
|
887
|
+
type: "activity",
|
|
888
|
+
squadId: this.squadId,
|
|
889
|
+
taskId: event.taskId,
|
|
890
|
+
agentName: event.agentName,
|
|
891
|
+
message: `→ ${data.toolName}`,
|
|
892
|
+
data,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private onToolExecutionEnd(event: AgentEvent): void {
|
|
897
|
+
// Track file modifications
|
|
898
|
+
const data = event.data;
|
|
899
|
+
if (data.toolName === "write" || data.toolName === "edit") {
|
|
900
|
+
const filePath = data.args?.path || data.args?.file_path;
|
|
901
|
+
if (filePath) {
|
|
902
|
+
this.updateModifiedFiles(event.agentName, filePath);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private onAgentEnd(event: AgentEvent): boolean {
|
|
908
|
+
// Pi's low-level agent_end is not completion. AgentPool normally keeps
|
|
909
|
+
// it internal; if observed here, preserve in_progress. Only an actual
|
|
910
|
+
// child-process exit carries unexpectedExit and enters retry handling.
|
|
911
|
+
if (!event.data?.unexpectedExit) return false;
|
|
912
|
+
this.handleUnexpectedAgentExit(event);
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private onAgentSettled(event: AgentEvent): void {
|
|
917
|
+
const settledTask = store.loadTask(this.squadId, event.taskId);
|
|
918
|
+
const status = settledTask?.status;
|
|
919
|
+
if (status === "cancelled" || status === "suspended") return;
|
|
920
|
+
const settledSquad = store.loadSquad(this.squadId);
|
|
921
|
+
if (settledTask && settledSquad && !validateTaskSpecAttestation(settledSquad, settledTask)) {
|
|
922
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null, output: null, error: "Canonical squad spec was not fully delivered; read all chunks with squad_spec_read" });
|
|
923
|
+
store.appendMessage(this.squadId, event.taskId, { ts: store.now(), from: "system", type: "status", text: "Completion rejected: missing or invalid spec-read-attestation; reopening same task session" });
|
|
924
|
+
if (this.running) void this.reconcile();
|
|
925
|
+
this.updateContext();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
929
|
+
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
930
|
+
// occurs before the task can become done.
|
|
931
|
+
if (store.loadPendingTaskMessages(this.squadId, event.taskId).length > 0) {
|
|
932
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null });
|
|
933
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
934
|
+
ts: store.now(),
|
|
935
|
+
from: "system",
|
|
936
|
+
type: "status",
|
|
937
|
+
text: "Agent settled with pending durable messages; resuming the same task session",
|
|
938
|
+
});
|
|
939
|
+
if (this.running) void this.reconcile();
|
|
940
|
+
this.updateContext();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const turnCount = event.data?.turnCount ?? 0;
|
|
945
|
+
const toolCallCount = event.data?.toolCallCount ?? 0;
|
|
946
|
+
const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
|
|
947
|
+
.some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
|
|
948
|
+
const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
|
|
949
|
+
if (hadMeaningfulWork) {
|
|
950
|
+
this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
|
|
951
|
+
} else {
|
|
952
|
+
this.handleUnexpectedAgentExit({
|
|
953
|
+
...event,
|
|
954
|
+
data: { ...event.data, unexpectedExit: true },
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private onError(event: AgentEvent): void {
|
|
960
|
+
const errorMsg = event.data?.message || "Unknown error";
|
|
961
|
+
store.appendMessage(this.squadId, event.taskId, {
|
|
962
|
+
ts: store.now(),
|
|
963
|
+
from: "system",
|
|
964
|
+
type: "error",
|
|
965
|
+
text: errorMsg,
|
|
966
|
+
});
|
|
953
967
|
}
|
|
954
968
|
|
|
955
969
|
private async handleTaskCompleted(taskId: string): Promise<void> {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { chunkRanges, type PreparedSpec } from "./file-spec.js";
|
|
4
|
+
import { logError } from "./logger.js";
|
|
5
|
+
import { validatePlan } from "./plan-rules.js";
|
|
6
|
+
import { runPlanner } from "./planner.js";
|
|
7
|
+
import { Scheduler } from "./scheduler.js";
|
|
8
|
+
import { wireSchedulerEvents } from "./scheduler-runtime.js";
|
|
9
|
+
import * as store from "./store.js";
|
|
10
|
+
import type { PlannerOutput, Squad, SquadAgentEntry, SquadConfig, Task } from "./types.js";
|
|
11
|
+
import { DEFAULT_SQUAD_CONFIG } from "./types.js";
|
|
12
|
+
import { disabledToolResult, focusSquad, resolveSquadDefaults, runtime, schedulerSpawnContext, type InlineSquadStart } from "./runtime.js";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Start Squad
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export async function startSquad(
|
|
19
|
+
squadId: string,
|
|
20
|
+
params: InlineSquadStart,
|
|
21
|
+
cwd: string,
|
|
22
|
+
skillPaths: string[],
|
|
23
|
+
pi: ExtensionAPI,
|
|
24
|
+
sessionFile: string | null = null,
|
|
25
|
+
preparedSpec?: PreparedSpec,
|
|
26
|
+
) {
|
|
27
|
+
let plan: PlannerOutput;
|
|
28
|
+
|
|
29
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
30
|
+
// User provided a plan — use it directly
|
|
31
|
+
plan = {
|
|
32
|
+
agents: params.agents || {},
|
|
33
|
+
tasks: params.tasks.map((t) => ({
|
|
34
|
+
...t,
|
|
35
|
+
description: t.description || "",
|
|
36
|
+
depends: t.depends || [],
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Validate agent names — remap unknown agents to fullstack
|
|
41
|
+
for (const task of plan.tasks) {
|
|
42
|
+
const agentDef = store.loadAgentDef(task.agent, cwd);
|
|
43
|
+
if (!agentDef || agentDef.disabled) {
|
|
44
|
+
if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
|
|
45
|
+
const original = task.agent;
|
|
46
|
+
task.agent = "fullstack";
|
|
47
|
+
task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
// Run planner to generate task breakdown (squad default policy as fallback)
|
|
52
|
+
try {
|
|
53
|
+
const defaults = resolveSquadDefaults();
|
|
54
|
+
plan = await runPlanner({ goal: params.goal, cwd, fallbackModel: defaults.model, fallbackThinking: defaults.thinking });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Throwing marks the tool result as an error for the LLM (returning isError is ignored in current pi)
|
|
57
|
+
throw new Error(`Failed to plan: ${(error as Error).message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A planner may take long enough for /squad disable to run concurrently.
|
|
62
|
+
// Re-check before publishing any squad or constructing a scheduler.
|
|
63
|
+
if (!runtime.squadEnabled) return disabledToolResult();
|
|
64
|
+
|
|
65
|
+
// Merge agent roster
|
|
66
|
+
const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
|
|
67
|
+
if (params.agents) {
|
|
68
|
+
for (const [name, entry] of Object.entries(params.agents)) {
|
|
69
|
+
agents[name] = { ...agents[name], ...entry };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate the plan — same enforcement for main-session and planner plans.
|
|
74
|
+
// Errors block squad creation; warnings are reported back to the plan author.
|
|
75
|
+
const validation = validatePlan(plan.tasks);
|
|
76
|
+
if (validation.errors.length > 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Plan rejected:\n- ${validation.errors.join("\n- ")}\n\nFix the task list and call squad again.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create squad
|
|
83
|
+
const config: SquadConfig = {
|
|
84
|
+
...DEFAULT_SQUAD_CONFIG,
|
|
85
|
+
...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
|
|
86
|
+
...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
|
|
87
|
+
...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const squad: Squad = {
|
|
91
|
+
id: squadId,
|
|
92
|
+
goal: params.goal,
|
|
93
|
+
status: "running",
|
|
94
|
+
created: store.now(),
|
|
95
|
+
cwd,
|
|
96
|
+
sessionFile,
|
|
97
|
+
agents,
|
|
98
|
+
config,
|
|
99
|
+
...(preparedSpec ? { spec: {
|
|
100
|
+
schemaVersion: 1 as const,
|
|
101
|
+
sha256: preparedSpec.sha256,
|
|
102
|
+
bytes: preparedSpec.raw.length,
|
|
103
|
+
path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
|
|
104
|
+
chunkBytes: 32768 as const,
|
|
105
|
+
chunkCount: chunkRanges(preparedSpec.raw).length,
|
|
106
|
+
} } : {}),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
|
|
110
|
+
const initialTasks: Task[] = plan.tasks.map((taskDef) => {
|
|
111
|
+
const task: Task = {
|
|
112
|
+
id: taskDef.id,
|
|
113
|
+
title: taskDef.title,
|
|
114
|
+
description: taskDef.description,
|
|
115
|
+
agent: taskDef.agent,
|
|
116
|
+
status: taskDef.depends.length === 0 ? "pending" : "blocked",
|
|
117
|
+
depends: taskDef.depends,
|
|
118
|
+
...(taskDef.inheritContext ? { inheritContext: true } : {}),
|
|
119
|
+
created: store.now(),
|
|
120
|
+
started: null,
|
|
121
|
+
completed: null,
|
|
122
|
+
output: null,
|
|
123
|
+
error: null,
|
|
124
|
+
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
125
|
+
};
|
|
126
|
+
return task;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
|
|
130
|
+
else {
|
|
131
|
+
store.saveSquad(squad);
|
|
132
|
+
for (const task of initialTasks) store.createTask(squadId, task);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Start scheduler
|
|
136
|
+
const scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
|
|
137
|
+
runtime.schedulers.set(squadId, scheduler);
|
|
138
|
+
// Activate panel/widget/tool focus as one invariant.
|
|
139
|
+
focusSquad(squadId);
|
|
140
|
+
|
|
141
|
+
// Wire up completion/escalation notifications to main agent.
|
|
142
|
+
wireSchedulerEvents(pi, scheduler, squadId);
|
|
143
|
+
|
|
144
|
+
// Start scheduling — fire and forget, don't block the tool call.
|
|
145
|
+
// scheduler.start() spawns agents which can take seconds per agent.
|
|
146
|
+
// We must return immediately so the main agent's turn completes
|
|
147
|
+
// and the user regains interactive control.
|
|
148
|
+
scheduler.start().catch((err) => {
|
|
149
|
+
logError("squad", `Scheduler start error: ${(err as Error).message}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Build response. File mode returns only the bounded descriptor; the canonical
|
|
153
|
+
// contract is never reflected back into the main model's transport.
|
|
154
|
+
const taskSummary = preparedSpec
|
|
155
|
+
? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
|
|
156
|
+
: plan.tasks
|
|
157
|
+
.map((t) => {
|
|
158
|
+
const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
|
|
159
|
+
return `${t.id} → ${t.agent}: ${t.title}${deps}`;
|
|
160
|
+
})
|
|
161
|
+
.join("\n");
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
content: [
|
|
165
|
+
{
|
|
166
|
+
type: "text" as const,
|
|
167
|
+
text: `Squad "${squadId}" started with ${plan.tasks.length} tasks.\n\n${taskSummary}${
|
|
168
|
+
validation.warnings.length > 0
|
|
169
|
+
? `\n\n⚠️ Plan warnings (fix with squad_modify, or address at review):\n- ${validation.warnings.join("\n- ")}`
|
|
170
|
+
: ""
|
|
171
|
+
}\n\nAgents work in the background — you will be woken automatically when the squad completes, fails, or needs help. Report this plan to the user and END YOUR TURN now. Do NOT poll squad_status, do NOT sleep-wait, do NOT loop.`,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
details: undefined,
|
|
175
|
+
};
|
|
176
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -12,7 +12,6 @@ import * as os from "node:os";
|
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import type {
|
|
14
14
|
AgentDef,
|
|
15
|
-
KnowledgeEntry,
|
|
16
15
|
Squad,
|
|
17
16
|
SquadContext,
|
|
18
17
|
Task,
|
|
@@ -103,14 +102,6 @@ export function getContextFilePath(squadId: string): string {
|
|
|
103
102
|
return path.join(getSquadDir(squadId), "context.json");
|
|
104
103
|
}
|
|
105
104
|
|
|
106
|
-
export function getKnowledgeDir(squadId: string): string {
|
|
107
|
-
return path.join(getSquadDir(squadId), "knowledge");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function getMemoryFilePath(): string {
|
|
111
|
-
return path.join(getSquadRoot(), "memory.jsonl");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
105
|
/** Resolve task dir, supporting nested subtasks via parentPath */
|
|
115
106
|
export function getTaskDir(squadId: string, taskId: string, parentPath?: string): string {
|
|
116
107
|
const base = parentPath
|
|
@@ -649,28 +640,6 @@ export function saveContext(squadId: string, context: SquadContext): void {
|
|
|
649
640
|
writeJsonAtomic(getContextFilePath(squadId), context);
|
|
650
641
|
}
|
|
651
642
|
|
|
652
|
-
// ============================================================================
|
|
653
|
-
// Knowledge
|
|
654
|
-
// ============================================================================
|
|
655
|
-
|
|
656
|
-
export function appendKnowledge(squadId: string, type: KnowledgeEntry["type"], entry: KnowledgeEntry): void {
|
|
657
|
-
const file = path.join(getKnowledgeDir(squadId), `${type}s.jsonl`);
|
|
658
|
-
appendJsonl(file, entry);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
export function loadKnowledge(squadId: string, type: KnowledgeEntry["type"]): KnowledgeEntry[] {
|
|
662
|
-
const file = path.join(getKnowledgeDir(squadId), `${type}s.jsonl`);
|
|
663
|
-
return readJsonl<KnowledgeEntry>(file);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
export function loadAllKnowledge(squadId: string): KnowledgeEntry[] {
|
|
667
|
-
return [
|
|
668
|
-
...loadKnowledge(squadId, "decision"),
|
|
669
|
-
...loadKnowledge(squadId, "convention"),
|
|
670
|
-
...loadKnowledge(squadId, "finding"),
|
|
671
|
-
].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
672
|
-
}
|
|
673
|
-
|
|
674
643
|
// ============================================================================
|
|
675
644
|
// Rework Helpers
|
|
676
645
|
// ============================================================================
|
|
@@ -688,18 +657,6 @@ export function getRetryCount(squadId: string, taskId: string): number {
|
|
|
688
657
|
return findRetries(squadId, taskId).length;
|
|
689
658
|
}
|
|
690
659
|
|
|
691
|
-
// ============================================================================
|
|
692
|
-
// Memory (cross-squad)
|
|
693
|
-
// ============================================================================
|
|
694
|
-
|
|
695
|
-
export function appendMemory(entry: KnowledgeEntry): void {
|
|
696
|
-
appendJsonl(getMemoryFilePath(), entry);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
export function loadMemory(): KnowledgeEntry[] {
|
|
700
|
-
return readJsonl<KnowledgeEntry>(getMemoryFilePath());
|
|
701
|
-
}
|
|
702
|
-
|
|
703
660
|
// ============================================================================
|
|
704
661
|
// Bootstrap — first-run agent initialization
|
|
705
662
|
// ============================================================================
|