pi-squad 0.14.0 → 0.15.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/README.md CHANGED
@@ -183,7 +183,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
183
183
  | `squad` | Start a squad with goal + optional tasks/config |
184
184
  | `squad_status` | Check progress, costs, task states |
185
185
  | `squad_message` | Send message to a running agent |
186
- | `squad_modify` | Add/cancel/pause/resume tasks or squads |
186
+ | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
187
187
 
188
188
  The main agent sees available agents in its system prompt and squad state when a squad is active.
189
189
 
@@ -280,18 +280,21 @@ Agents must complete at least 1 LLM turn AND make at least 1 tool call to be mar
280
280
  ### Session Resilience
281
281
 
282
282
  - In-progress tasks are **suspended** on session crash, **resumed** on next startup
283
+ - Failure is never terminal: `resume` recovers failed squads (failed tasks reset to pending), `complete_task` marks recovered work done and schedules dependents, and a 60s reconcile loop re-derives scheduling from persisted state so out-of-band store edits can't strand ready tasks
283
284
  - Squads are fully reconstructable from JSON files on disk
284
285
  - Spawn failures are retried once with a 2-second delay
285
286
  - All errors logged to `~/.pi/squad/debug.log` (always for errors, `PI_SQUAD_DEBUG=1` for verbose)
286
287
 
287
288
  ### Health Monitoring
288
289
 
290
+ The monitor never kills or blocks work on its own — its strongest action is notifying the main Pi session so you (or the main agent) can decide.
291
+
289
292
  | Check | Threshold | Action |
290
293
  |---|---|---|
291
294
  | Idle warning | 3 minutes no output | Steer agent with nudge |
292
- | Stuck detection | 5 minutes no output | Abort and fail task |
295
+ | Stuck detection | 5 minutes no output | Steer, then escalate to main session |
293
296
  | Loop detection | Same tool call 5x | Steer with warning |
294
- | Hard ceiling | 30 minutes total | Abort task |
297
+ | Long-running check-in | Every 30 minutes total (`PI_SQUAD_CEILING_MS`) | Notify main session — work continues |
295
298
 
296
299
  ## Data Layout
297
300
 
@@ -320,7 +323,7 @@ src/
320
323
  ├── agent-pool.ts — pi RPC process management, activity tracking
321
324
  ├── protocol.ts — system prompt builder (chain context, sibling awareness, knowledge)
322
325
  ├── router.ts — @mention parsing, cross-agent messaging
323
- ├── monitor.ts — health checks (idle, stuck, loop, ceiling)
326
+ ├── monitor.ts — health checks (idle, stuck, loop, long-run notify)
324
327
  ├── planner.ts — one-shot goal decomposition via LLM
325
328
  ├── logger.ts — file-based logging (never writes to stderr)
326
329
  ├── panel/ — TUI overlay panel and widget
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -48,5 +48,8 @@
48
48
  "publishConfig": {
49
49
  "registry": "https://registry.npmjs.org/",
50
50
  "access": "public"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^26.1.1"
51
54
  }
52
55
  }
package/src/index.ts CHANGED
@@ -461,7 +461,7 @@ export default function (pi: ExtensionAPI) {
461
461
  pi.registerTool({
462
462
  name: "squad_modify",
463
463
  label: "Squad Modify",
464
- description: "Modify the running squad: add_task, cancel_task, pause, resume, cancel (entire squad).",
464
+ description: "Modify the running squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume (also recovers failed squads), cancel (entire squad).",
465
465
  parameters: Type.Object({
466
466
  action: Type.Union(
467
467
  [
@@ -469,6 +469,7 @@ export default function (pi: ExtensionAPI) {
469
469
  Type.Literal("cancel_task"),
470
470
  Type.Literal("pause_task"),
471
471
  Type.Literal("resume_task"),
472
+ Type.Literal("complete_task"),
472
473
  Type.Literal("pause"),
473
474
  Type.Literal("resume"),
474
475
  Type.Literal("cancel"),
@@ -476,6 +477,7 @@ export default function (pi: ExtensionAPI) {
476
477
  { description: "Action to perform" },
477
478
  ),
478
479
  taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
480
+ output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
479
481
  task: Type.Optional(
480
482
  Type.Object({
481
483
  id: Type.String(),
@@ -493,11 +495,11 @@ export default function (pi: ExtensionAPI) {
493
495
  if (params.action === "resume") {
494
496
  // Find a squad to resume: use activeSquadId or find the latest paused one
495
497
  const squadId = activeSquadId || store.findActiveSquads()
496
- .filter((s) => s.cwd === ctx.cwd && s.status === "paused")
498
+ .filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
497
499
  .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
498
500
 
499
501
  if (!squadId) {
500
- return { content: [{ type: "text" as const, text: "No paused squad found to resume." }], details: undefined };
502
+ return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
501
503
  }
502
504
 
503
505
  // Create a fresh scheduler if needed
@@ -632,6 +634,16 @@ export default function (pi: ExtensionAPI) {
632
634
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
633
635
  }
634
636
 
637
+ case "complete_task": {
638
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
639
+ try {
640
+ await activeScheduler.completeTask(params.taskId, params.output);
641
+ } catch (err) {
642
+ return { content: [{ type: "text" as const, text: `complete_task failed: ${(err as Error).message}` }], details: undefined };
643
+ }
644
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' marked done — dependents unblocked and scheduled.` }], details: undefined };
645
+ }
646
+
635
647
  case "pause": {
636
648
  const squad = store.loadSquad(activeSquadId);
637
649
  if (squad) {
package/src/monitor.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * - Idle timeout (no output for N minutes)
6
6
  * - Stuck detection (no output for longer)
7
7
  * - Loop detection (same tool call repeated)
8
- * - Hard ceiling (max total runtime)
8
+ * - Long-running check-in (notify the main session; never aborts)
9
9
  * - File conflict warnings
10
+ *
11
+ * The monitor NEVER kills or blocks work on its own. Its strongest action is
12
+ * `notify`: tell the main Pi session so a human/main agent can check in.
10
13
  */
11
14
 
12
15
  import type { AgentPool } from "./agent-pool.js";
@@ -25,7 +28,7 @@ function envMs(name: string, fallback: number): number {
25
28
 
26
29
  const IDLE_WARNING_MS = envMs("PI_SQUAD_IDLE_MS", 3 * 60 * 1000); // no output → warn
27
30
  const STUCK_TIMEOUT_MS = envMs("PI_SQUAD_STUCK_MS", 5 * 60 * 1000); // no output → intervene
28
- const HARD_CEILING_MS = envMs("PI_SQUAD_CEILING_MS", 30 * 60 * 1000); // total → force stop
31
+ const LONG_RUN_NOTIFY_MS = envMs("PI_SQUAD_CEILING_MS", 30 * 60 * 1000); // total → notify main session (no abort)
29
32
  const LOOP_THRESHOLD = 5; // same tool call 5x → looping
30
33
  const POLL_INTERVAL_MS = envMs("PI_SQUAD_POLL_MS", 30 * 1000); // check interval
31
34
 
@@ -33,7 +36,7 @@ const POLL_INTERVAL_MS = envMs("PI_SQUAD_POLL_MS", 30 * 1000); // check interval
33
36
  // Types
34
37
  // ============================================================================
35
38
 
36
- export type MonitorActionType = "steer" | "abort" | "escalate";
39
+ export type MonitorActionType = "steer" | "escalate" | "notify";
37
40
 
38
41
  export interface MonitorAction {
39
42
  type: MonitorActionType;
@@ -60,6 +63,8 @@ export class Monitor {
60
63
  private stuckSteered = new Set<string>();
61
64
  /** Track which tasks have been escalated (one escalation per stuck episode) */
62
65
  private escalated = new Set<string>();
66
+ /** Last long-run threshold multiple notified per task (notify once per multiple) */
67
+ private longRunNotified = new Map<string, number>();
63
68
 
64
69
  constructor(pool: AgentPool, squadId: string) {
65
70
  this.pool = pool;
@@ -100,6 +105,7 @@ export class Monitor {
100
105
  this.warned.clear();
101
106
  this.stuckSteered.clear();
102
107
  this.escalated.clear();
108
+ this.longRunNotified.clear();
103
109
  }
104
110
 
105
111
  /** Check all running agents */
@@ -112,7 +118,7 @@ export class Monitor {
112
118
  if (!activity) continue;
113
119
 
114
120
  const health = this.checkHealth(activity);
115
- this.handleHealth(taskId, agentName, health);
121
+ this.handleHealth(taskId, agentName, health, activity);
116
122
  }
117
123
  }
118
124
 
@@ -133,7 +139,7 @@ export class Monitor {
133
139
  if (unique.size === 1) return "looping";
134
140
  }
135
141
 
136
- if (totalMs >= HARD_CEILING_MS) return "exceeded_ceiling";
142
+ if (totalMs >= LONG_RUN_NOTIFY_MS) return "long_running";
137
143
  if (idleMs >= STUCK_TIMEOUT_MS) return "stuck";
138
144
  if (idleMs >= IDLE_WARNING_MS) return "idle_warning";
139
145
 
@@ -141,7 +147,12 @@ export class Monitor {
141
147
  }
142
148
 
143
149
  /** Take action based on health status */
144
- private handleHealth(taskId: string, agentName: string, status: HealthStatus): void {
150
+ private handleHealth(
151
+ taskId: string,
152
+ agentName: string,
153
+ status: HealthStatus,
154
+ activity?: { startedAt: number },
155
+ ): void {
145
156
  if (status !== "healthy") debug("squad-monitor", `${taskId} (${agentName}): ${status}`);
146
157
  switch (status) {
147
158
  case "healthy":
@@ -203,15 +214,25 @@ export class Monitor {
203
214
  });
204
215
  break;
205
216
 
206
- case "exceeded_ceiling":
207
- this.emit({
208
- type: "abort",
209
- taskId,
210
- agentName,
211
- reason: `Agent ${agentName} exceeded time ceiling on ${taskId}`,
212
- message: "",
213
- });
217
+ case "long_running": {
218
+ // Never abort. Notify the main Pi session once per threshold multiple
219
+ // (30m, 60m, 90m, …) so it can check in if needed — work continues.
220
+ const totalMs = Date.now() - (activity?.startedAt ?? Date.now());
221
+ const multiple = Math.floor(totalMs / LONG_RUN_NOTIFY_MS);
222
+ const lastNotified = this.longRunNotified.get(taskId) ?? 0;
223
+ if (multiple > lastNotified) {
224
+ this.longRunNotified.set(taskId, multiple);
225
+ const minutes = Math.round(totalMs / 60_000);
226
+ this.emit({
227
+ type: "notify",
228
+ taskId,
229
+ agentName,
230
+ reason: `Agent ${agentName} has been working on ${taskId} for ${minutes}m. Still running — check on it if needed (steer, pause, or let it continue).`,
231
+ message: "",
232
+ });
233
+ }
214
234
  break;
235
+ }
215
236
  }
216
237
  }
217
238
  }
package/src/scheduler.ts CHANGED
@@ -72,6 +72,8 @@ export class Scheduler {
72
72
  private running = false;
73
73
  /** Track spawn retries to allow one retry per task */
74
74
  private spawnRetries = new Set<string>();
75
+ /** Periodic level-triggered reconcile (heals missed events / out-of-band store edits) */
76
+ private reconcileTimer: ReturnType<typeof setInterval> | null = null;
75
77
 
76
78
  /** Get the project cwd for this squad (from squad.json) */
77
79
  getProjectCwd(): string | undefined {
@@ -93,8 +95,16 @@ export class Scheduler {
93
95
  this.monitor.onAction((action) => {
94
96
  if (action.type === "steer") {
95
97
  this.pool.steer(action.taskId, action.message);
96
- } else if (action.type === "abort") {
97
- this.handleTaskFailed(action.taskId, action.reason);
98
+ } else if (action.type === "notify") {
99
+ // Informational only — tell the main Pi session directly (no advisor
100
+ // detour, no kill). The agent keeps working.
101
+ this.emit({
102
+ type: "escalation",
103
+ squadId: this.squadId,
104
+ taskId: action.taskId,
105
+ agentName: action.agentName,
106
+ message: action.reason,
107
+ });
98
108
  } else if (action.type === "escalate") {
99
109
  // Advisor-first: try a strong-model rescue before interrupting the human
100
110
  void this.tryAdvisorRescue(action.taskId, action.agentName, action.reason).then((rescued) => {
@@ -160,13 +170,22 @@ export class Scheduler {
160
170
  async start(): Promise<void> {
161
171
  this.running = true;
162
172
  this.monitor.start();
163
- await this.scheduleReadyTasks();
173
+ await this.reconcile();
174
+ // Level-triggered safety net: periodically re-derive scheduling decisions
175
+ // from persisted state so missed in-memory events (crashes, out-of-band
176
+ // store edits, external recovery) cannot strand ready tasks.
177
+ this.reconcileTimer = setInterval(() => void this.reconcile(), 60_000);
178
+ (this.reconcileTimer as { unref?: () => void }).unref?.();
164
179
  }
165
180
 
166
181
  /** Stop the scheduler — kills all agents, saves state */
167
182
  async stop(): Promise<void> {
168
183
  this.running = false;
169
184
  this.monitor.stop();
185
+ if (this.reconcileTimer) {
186
+ clearInterval(this.reconcileTimer);
187
+ this.reconcileTimer = null;
188
+ }
170
189
 
171
190
  // Suspend in-progress tasks
172
191
  const tasks = store.loadAllTasks(this.squadId);
@@ -179,17 +198,26 @@ export class Scheduler {
179
198
  await this.pool.killAll();
180
199
  }
181
200
 
182
- /** Resume from suspended state */
201
+ /** Resume from suspended OR failed state. Failure is never terminal:
202
+ * failed tasks are reset to pending and a failed squad becomes running. */
183
203
  async resume(): Promise<void> {
184
204
  const tasks = store.loadAllTasks(this.squadId);
185
205
  for (const task of tasks) {
186
206
  if (task.status === "suspended") {
187
207
  store.updateTaskStatus(this.squadId, task.id, "pending");
208
+ } else if (task.status === "failed") {
209
+ store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
210
+ store.appendMessage(this.squadId, task.id, {
211
+ ts: store.now(),
212
+ from: "system",
213
+ type: "status",
214
+ text: "Reset failed → pending on squad resume",
215
+ });
188
216
  }
189
217
  }
190
218
 
191
219
  const squad = store.loadSquad(this.squadId);
192
- if (squad && squad.status === "paused") {
220
+ if (squad && (squad.status === "paused" || squad.status === "failed")) {
193
221
  squad.status = "running";
194
222
  store.saveSquad(squad);
195
223
  }
@@ -197,6 +225,52 @@ export class Scheduler {
197
225
  await this.start();
198
226
  }
199
227
 
228
+ /**
229
+ * Level-triggered reconciliation — derive scheduling from persisted state:
230
+ * 1. Unblock blocked tasks whose deps are all done (missed autoUnblock events)
231
+ * 2. Self-heal a "failed" squad when runnable work exists (out-of-band recovery)
232
+ * 3. Schedule ready tasks
233
+ * Safe to call any time; no-ops when nothing changed.
234
+ */
235
+ async reconcile(): Promise<void> {
236
+ if (!this.running) return;
237
+ const squad = store.loadSquad(this.squadId);
238
+ if (!squad) return;
239
+
240
+ const tasks = store.loadAllTasks(this.squadId);
241
+
242
+ // 1. Blocked → pending when all deps are done (respects autoUnblock config)
243
+ if (squad.config.autoUnblock) {
244
+ for (const task of tasks) {
245
+ if (task.status !== "blocked") continue;
246
+ const allDepsDone = task.depends.every((depId) => {
247
+ const dep = tasks.find((t) => t.id === depId);
248
+ return dep?.status === "done";
249
+ });
250
+ if (allDepsDone) {
251
+ task.status = "pending";
252
+ store.updateTaskStatus(this.squadId, task.id, "pending");
253
+ this.emit({ type: "task_unblocked", squadId: this.squadId, taskId: task.id });
254
+ }
255
+ }
256
+ }
257
+
258
+ // 2. A "failed" squad with runnable work self-heals to running. This makes
259
+ // the terminal status derivable from task state instead of a one-way latch.
260
+ const hasRunnable = tasks.some((t) => {
261
+ if (t.status === "in_progress") return true;
262
+ if (t.status !== "pending") return false;
263
+ return t.depends.every((depId) => tasks.find((x) => x.id === depId)?.status === "done");
264
+ });
265
+ if (squad.status === "failed" && hasRunnable) {
266
+ squad.status = "running";
267
+ store.saveSquad(squad);
268
+ debug("squad-scheduler", "reconcile: failed squad has runnable work — healing to running");
269
+ }
270
+
271
+ await this.scheduleReadyTasks();
272
+ }
273
+
200
274
  // =========================================================================
201
275
  // Task Scheduling
202
276
  // =========================================================================
@@ -1073,10 +1147,53 @@ export class Scheduler {
1073
1147
  this.updateContext();
1074
1148
  }
1075
1149
 
1076
- /** Resume a suspended task */
1150
+ /** Resume a suspended/failed task. Reconciles so a failed squad heals too. */
1077
1151
  async resumeTask(taskId: string): Promise<void> {
1078
- store.updateTaskStatus(this.squadId, taskId, "pending");
1079
- await this.scheduleReadyTasks();
1152
+ store.updateTaskStatus(this.squadId, taskId, "pending", { error: null });
1153
+ await this.reconcile();
1154
+ }
1155
+
1156
+ /**
1157
+ * Mark a task done through the normal completion flow (admin/recovery path).
1158
+ * Unlike editing the store directly, this fires auto-unblock and scheduling,
1159
+ * so dependents transition pending → running. Skips the QA rework check —
1160
+ * a human/main agent marking a task done is an explicit override.
1161
+ */
1162
+ async completeTask(taskId: string, output?: string): Promise<void> {
1163
+ const task = store.loadTask(this.squadId, taskId);
1164
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1165
+ if (task.status === "done") return;
1166
+
1167
+ if (this.pool.isRunning(taskId)) {
1168
+ await this.pool.kill(taskId);
1169
+ }
1170
+
1171
+ store.updateTaskStatus(this.squadId, taskId, "done", {
1172
+ output: output ?? task.output ?? "Marked done (recovered/admin)",
1173
+ error: null,
1174
+ completed: store.now(),
1175
+ });
1176
+ store.appendMessage(this.squadId, taskId, {
1177
+ ts: store.now(),
1178
+ from: "system",
1179
+ type: "done",
1180
+ text: "Task marked done (recovered/admin)",
1181
+ });
1182
+ this.emit({
1183
+ type: "task_completed",
1184
+ squadId: this.squadId,
1185
+ taskId,
1186
+ agentName: task.agent,
1187
+ message: output ?? "",
1188
+ });
1189
+
1190
+ this.autoUnblock(taskId);
1191
+ await this.reconcile();
1192
+
1193
+ const freshTasks = store.loadAllTasks(this.squadId);
1194
+ const freshSquad = store.loadSquad(this.squadId);
1195
+ if (freshSquad) this.checkSquadCompletion(freshTasks, freshSquad);
1196
+ this.updateContext();
1080
1197
  }
1081
1198
 
1082
1199
  /** Cancel a task */
package/src/store.ts CHANGED
@@ -269,9 +269,11 @@ export function listSquads(): string[] {
269
269
  }
270
270
 
271
271
  export function findActiveSquads(): Squad[] {
272
+ // Includes "failed": failure is recoverable (resume resets failed tasks), so
273
+ // failed squads must stay discoverable. All callers filter by status.
272
274
  return listSquads()
273
275
  .map((id) => loadSquad(id))
274
- .filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused"));
276
+ .filter((s): s is Squad => s !== null && (s.status === "running" || s.status === "paused" || s.status === "failed"));
275
277
  }
276
278
 
277
279
  /** List squads filtered by project cwd. If no cwd, returns all. */
package/src/types.ts CHANGED
@@ -223,7 +223,7 @@ export interface AgentActivity {
223
223
  modifiedFiles: Set<string>;
224
224
  }
225
225
 
226
- export type HealthStatus = "healthy" | "idle_warning" | "stuck" | "looping" | "exceeded_ceiling";
226
+ export type HealthStatus = "healthy" | "idle_warning" | "stuck" | "looping" | "long_running";
227
227
 
228
228
  // ============================================================================
229
229
  // Supervisor