pi-squad 0.17.1 → 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/src/runtime.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { completeSimple, type Message, type TextContent } from "@earendil-works/pi-ai/compat";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ADVISOR_SYSTEM_PROMPT, buildAdvisorConsultText, type AdvisorConsultInput } from "./advisor.js";
4
+ import { logError, debug } from "./logger.js";
5
+ import type { Squad, SquadAgentEntry, SuspendedStallAttention, Task } from "./types.js";
6
+ import { THINKING_LEVELS } from "./types.js";
7
+ import type { Scheduler, SchedulerSpawnContext } from "./scheduler.js";
8
+ import type { SquadWidgetControls, SquadWidgetState } from "./panel/squad-widget.js";
9
+ import * as store from "./store.js";
10
+
11
+ export interface InlineSquadStart {
12
+ goal: string;
13
+ agents?: Record<string, SquadAgentEntry>;
14
+ tasks?: Array<{ id: string; title: string; description?: string; agent: string; depends?: string[]; inheritContext?: boolean }>;
15
+ config?: { maxConcurrency?: number; autoUnblock?: boolean; maxRetries?: number };
16
+ }
17
+
18
+ /** All mutable extension state has one owner. */
19
+ export const runtime = {
20
+ squadEnabled: true,
21
+ schedulers: new Map<string, Scheduler>(),
22
+ activeSquadId: null as string | null,
23
+ overlayOpen: false,
24
+ closeOverlay: null as (() => void) | null,
25
+ uiCtx: null as ExtensionContext | null,
26
+ widgetState: { squadId: null, enabled: true } as SquadWidgetState,
27
+ widgetControls: null as SquadWidgetControls | null,
28
+ getMainSessionThinking: (() => undefined) as () => string | undefined,
29
+ };
30
+
31
+ export const DISABLED_GUIDANCE = "pi-squad is disabled. Run /squad enable, then retry this operation; no squad work was changed.";
32
+ export const disabledToolResult = () => ({
33
+ content: [{ type: "text" as const, text: DISABLED_GUIDANCE }],
34
+ details: undefined,
35
+ });
36
+
37
+ /** Keep main-session focus, compact widget, and detail panel targeting identical. */
38
+ export function focusSquad(squadId: string | null): void {
39
+ runtime.activeSquadId = squadId;
40
+ runtime.widgetState.squadId = squadId;
41
+ if (squadId && runtime.squadEnabled) runtime.widgetState.enabled = true;
42
+ runtime.widgetControls?.refreshNow();
43
+ }
44
+
45
+ /** Format completion against active work while retaining cancelled history. */
46
+ export function formatTaskProgress(tasks: Task[]): string {
47
+ const done = tasks.filter((task) => task.status === "done").length;
48
+ const cancelled = tasks.filter((task) => task.status === "cancelled").length;
49
+ const active = tasks.length - cancelled;
50
+ return cancelled > 0
51
+ ? `${done}/${active} active tasks done · ${cancelled} cancelled · ${tasks.length} total`
52
+ : `${done}/${tasks.length} tasks done`;
53
+ }
54
+
55
+ function resolveContextWindow(model: string | null): number | undefined {
56
+ const ctx = runtime.uiCtx;
57
+ if (!ctx) return undefined;
58
+ try {
59
+ if (!model) return ctx.model?.contextWindow;
60
+ let clean = model;
61
+ const lastColon = model.lastIndexOf(":");
62
+ if (lastColon > 0 && (THINKING_LEVELS as readonly string[]).includes(model.slice(lastColon + 1))) {
63
+ clean = model.slice(0, lastColon);
64
+ }
65
+ const all = ctx.modelRegistry.getAll();
66
+ const slash = clean.indexOf("/");
67
+ if (slash > 0) {
68
+ const provider = clean.slice(0, slash);
69
+ const id = clean.slice(slash + 1);
70
+ const m = all.find((x) => x.provider === provider && x.id === id);
71
+ if (m) return m.contextWindow;
72
+ }
73
+ return all.find((x) => x.id === clean)?.contextWindow;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ export function getMainSessionModel(): string | undefined {
80
+ try {
81
+ const m = runtime.uiCtx?.model;
82
+ return m ? `${m.provider}/${m.id}` : undefined;
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ export function resolveSquadDefaults(): { model?: string; thinking?: string } {
89
+ const settings = store.loadSquadSettings();
90
+ let model: string | undefined;
91
+ if (settings.defaultModel === "main") model = getMainSessionModel();
92
+ else if (settings.defaultModel !== "pi-default") model = settings.defaultModel;
93
+ let thinking: string | undefined;
94
+ if (settings.defaultThinking === "main") thinking = runtime.getMainSessionThinking();
95
+ else if (settings.defaultThinking !== "pi-default") thinking = settings.defaultThinking;
96
+ return { model, thinking };
97
+ }
98
+
99
+ async function consultAdvisor(input: AdvisorConsultInput): Promise<string | null> {
100
+ const ctx = runtime.uiCtx;
101
+ if (!ctx) return null;
102
+ const settings = store.loadSquadSettings();
103
+ if (!settings.advisor.enabled) return null;
104
+
105
+ try {
106
+ let model = settings.advisor.model === "main" ? ctx.model : undefined;
107
+ if (!model && settings.advisor.model !== "main") {
108
+ const ref = settings.advisor.model;
109
+ const slash = ref.indexOf("/");
110
+ if (slash > 0) model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
111
+ }
112
+ if (!model) {
113
+ logError("squad-advisor", `advisor model "${settings.advisor.model}" not resolvable`);
114
+ return null;
115
+ }
116
+
117
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
118
+ if (!auth.ok || !auth.apiKey) {
119
+ logError("squad-advisor", `no auth for advisor model ${model.provider}/${model.id}`);
120
+ return null;
121
+ }
122
+
123
+ const userMessage: Message = {
124
+ role: "user",
125
+ content: [{ type: "text", text: buildAdvisorConsultText(input) }],
126
+ timestamp: Date.now(),
127
+ } as Message;
128
+
129
+ const response = await completeSimple(
130
+ model,
131
+ { systemPrompt: ADVISOR_SYSTEM_PROMPT, messages: [userMessage] },
132
+ {
133
+ apiKey: auth.apiKey,
134
+ headers: auth.headers,
135
+ maxTokens: settings.advisor.maxTokens,
136
+ reasoning: settings.advisor.reasoning as never,
137
+ },
138
+ );
139
+
140
+ const text = response.content
141
+ .filter((b): b is TextContent => b.type === "text")
142
+ .map((b) => b.text)
143
+ .join("\n")
144
+ .trim();
145
+ debug("squad-advisor", `consulted ${model.provider}/${model.id} for ${input.taskId}: in=${response.usage?.input ?? 0} out=${response.usage?.output ?? 0}`);
146
+ return text || null;
147
+ } catch (error) {
148
+ logError("squad-advisor", `consult failed: ${(error as Error).message}`);
149
+ return null;
150
+ }
151
+ }
152
+
153
+ export const schedulerSpawnContext: SchedulerSpawnContext = {
154
+ resolveContextWindow,
155
+ getDefaultModelThinking: resolveSquadDefaults,
156
+ consultAdvisor,
157
+ };
158
+
159
+ export function getActiveScheduler(): Scheduler | null {
160
+ if (!runtime.activeSquadId) return null;
161
+ return runtime.schedulers.get(runtime.activeSquadId) || null;
162
+ }
163
+
164
+ export function repairFocusAfterCancellation(cancelledId: string): void {
165
+ const nextFocus = runtime.activeSquadId === cancelledId || (runtime.activeSquadId !== null && !store.loadSquad(runtime.activeSquadId))
166
+ ? null
167
+ : runtime.activeSquadId;
168
+ focusSquad(nextFocus);
169
+ }
170
+
171
+ export function activeSuspendedAttentionForProject(cwd: string): Array<{ squadId: string; attention: SuspendedStallAttention }> {
172
+ return store.listSquadsForProject(cwd)
173
+ .filter((squad) => Boolean(squad.suspendedStallAttention))
174
+ .map((squad) => ({ squadId: squad.id, attention: squad.suspendedStallAttention! }));
175
+ }
176
+
177
+ export function isResumeCandidate(squad: Squad): boolean {
178
+ return squad.status === "paused" || squad.status === "failed" ||
179
+ (squad.status === "review" && squad.review?.status === "failed") ||
180
+ store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
181
+ }
182
+
183
+ export function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
184
+ if (explicitId) return store.loadSquad(explicitId);
185
+ if (runtime.activeSquadId) {
186
+ const active = store.loadSquad(runtime.activeSquadId);
187
+ if (active?.cwd === cwd && isResumeCandidate(active)) return active;
188
+ }
189
+ return store.listSquadsForProject(cwd)
190
+ .filter(isResumeCandidate)
191
+ .sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
192
+ }
193
+
194
+ export function forceWidgetUpdate(): void {
195
+ runtime.widgetControls?.requestUpdate();
196
+ }
@@ -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
- const msg = event.data;
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
- case "tool_execution_start": {
854
- const data = event.data;
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: "tool",
859
- text: data.toolName || "unknown",
860
- name: data.toolName,
861
- args: data.args,
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
- this.emit({
865
- type: "activity",
866
- squadId: this.squadId,
867
- taskId: event.taskId,
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: "system",
916
- type: "status",
917
- text: "Agent settled with pending durable messages; resuming the same task session",
848
+ from: event.agentName,
849
+ type: "reply",
850
+ to: "orchestrator",
851
+ text: "Response forwarded to main orchestrator",
918
852
  });
919
- if (this.running) void this.reconcile();
920
- this.updateContext();
921
- return;
922
- }
923
-
924
- const turnCount = event.data?.turnCount ?? 0;
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
- case "error": {
941
- const errorMsg = event.data?.message || "Unknown error";
942
- store.appendMessage(this.squadId, event.taskId, {
943
- ts: store.now(),
944
- from: "system",
945
- type: "error",
946
- text: errorMsg,
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
- this.updateContext();
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> {