pi-squad 0.14.0 → 0.15.1

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
@@ -41,9 +41,13 @@ The planner agent reads your codebase and creates a task breakdown automatically
41
41
  2. A **live widget** appears above the editor showing task progress
42
42
  3. **Specialist agents** spawn as separate pi processes, working in parallel where dependencies allow
43
43
  4. QA agents can trigger **automatic rework loops** when they find bugs
44
- 5. On completion, pi receives a summary with each task's output
44
+ 5. On completion, pi receives a summary with each task's **complete, untruncated output**
45
45
  6. Multiple squads can run concurrently across different projects
46
46
 
47
+ ### No-Truncation Contract
48
+
49
+ Task messages, task outputs, dependency/rework handoffs, QA feedback, advisor handoffs, completion reports, failure diagnostics, and planner errors are persisted and forwarded in full. There is no character or task-count limit on report data. TUI widgets may show width/height-limited **views** to fit the terminal, but the underlying data and agent/main-session handoffs remain complete.
50
+
47
51
  ## Features
48
52
 
49
53
  ### Dependency-Aware Scheduling
@@ -183,7 +187,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
183
187
  | `squad` | Start a squad with goal + optional tasks/config |
184
188
  | `squad_status` | Check progress, costs, task states |
185
189
  | `squad_message` | Send message to a running agent |
186
- | `squad_modify` | Add/cancel/pause/resume tasks or squads |
190
+ | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
187
191
 
188
192
  The main agent sees available agents in its system prompt and squad state when a squad is active.
189
193
 
@@ -280,18 +284,21 @@ Agents must complete at least 1 LLM turn AND make at least 1 tool call to be mar
280
284
  ### Session Resilience
281
285
 
282
286
  - In-progress tasks are **suspended** on session crash, **resumed** on next startup
287
+ - 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
288
  - Squads are fully reconstructable from JSON files on disk
284
289
  - Spawn failures are retried once with a 2-second delay
285
290
  - All errors logged to `~/.pi/squad/debug.log` (always for errors, `PI_SQUAD_DEBUG=1` for verbose)
286
291
 
287
292
  ### Health Monitoring
288
293
 
294
+ 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.
295
+
289
296
  | Check | Threshold | Action |
290
297
  |---|---|---|
291
298
  | Idle warning | 3 minutes no output | Steer agent with nudge |
292
- | Stuck detection | 5 minutes no output | Abort and fail task |
299
+ | Stuck detection | 5 minutes no output | Steer, then escalate to main session |
293
300
  | Loop detection | Same tool call 5x | Steer with warning |
294
- | Hard ceiling | 30 minutes total | Abort task |
301
+ | Long-running check-in | Every 30 minutes total (`PI_SQUAD_CEILING_MS`) | Notify main session — work continues |
295
302
 
296
303
  ## Data Layout
297
304
 
@@ -320,7 +327,7 @@ src/
320
327
  ├── agent-pool.ts — pi RPC process management, activity tracking
321
328
  ├── protocol.ts — system prompt builder (chain context, sibling awareness, knowledge)
322
329
  ├── router.ts — @mention parsing, cross-agent messaging
323
- ├── monitor.ts — health checks (idle, stuck, loop, ceiling)
330
+ ├── monitor.ts — health checks (idle, stuck, loop, long-run notify)
324
331
  ├── planner.ts — one-shot goal decomposition via LLM
325
332
  ├── logger.ts — file-based logging (never writes to stderr)
326
333
  ├── 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.1",
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/advisor.ts CHANGED
@@ -78,18 +78,10 @@ Output format:
78
78
 
79
79
  Keep it short. The agent reads your advice and immediately acts on it.`;
80
80
 
81
- const MAX_MSG_CHARS = 400;
82
- const MAX_MESSAGES = 12;
83
- const MAX_TOOL_CALLS = 10;
84
-
85
- function clamp(text: string, max: number): string {
86
- const t = text.trim();
87
- return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t;
88
- }
89
-
90
81
  /**
91
82
  * Build the user-message digest sent to the advisor model.
92
- * Curated and bounded — summaries, not full transcripts.
83
+ * Preserve complete descriptions, messages, and tool history: advisor handoffs
84
+ * follow the same no-truncation contract as task and completion reports.
93
85
  */
94
86
  export function buildAdvisorConsultText(input: AdvisorConsultInput): string {
95
87
  const lines: string[] = [];
@@ -102,22 +94,21 @@ export function buildAdvisorConsultText(input: AdvisorConsultInput): string {
102
94
  lines.push(`Progress: ${input.turnCount} turns, ~${Math.round(input.elapsedMinutes)} min elapsed`);
103
95
  lines.push("");
104
96
  lines.push(`## Task Description`);
105
- lines.push(clamp(input.taskDescription || "(no description)", 1500));
97
+ lines.push((input.taskDescription || "(no description)").trim());
106
98
 
107
99
  if (input.recentToolCalls.length > 0) {
108
100
  lines.push("");
109
101
  lines.push(`## Recent Tool Activity (newest last)`);
110
- for (const call of input.recentToolCalls.slice(-MAX_TOOL_CALLS)) {
111
- lines.push(`- ${clamp(call, 160)}`);
102
+ for (const call of input.recentToolCalls) {
103
+ lines.push(`- ${call.trim()}`);
112
104
  }
113
105
  }
114
106
 
115
- const messages = input.recentMessages.slice(-MAX_MESSAGES);
116
- if (messages.length > 0) {
107
+ if (input.recentMessages.length > 0) {
117
108
  lines.push("");
118
109
  lines.push(`## Recent Messages (newest last)`);
119
- for (const msg of messages) {
120
- lines.push(`[${msg.from}/${msg.type}] ${clamp(msg.text, MAX_MSG_CHARS)}`);
110
+ for (const msg of input.recentMessages) {
111
+ lines.push(`[${msg.from}/${msg.type}] ${msg.text.trim()}`);
121
112
  }
122
113
  }
123
114
 
@@ -141,5 +132,5 @@ export function formatAdvisorSteerMessage(advice: string, reason: string): strin
141
132
 
142
133
  /** True when the advisor's verdict says a human decision is required. */
143
134
  export function adviceNeedsHuman(advice: string): boolean {
144
- return /needs human input/i.test(advice.slice(0, 200));
135
+ return /needs human input/i.test(advice);
145
136
  }
package/src/agent-pool.ts CHANGED
@@ -217,7 +217,7 @@ export class AgentPool {
217
217
  proc.on("exit", (code, signal) => {
218
218
  // Log diagnostic info for debugging spawn failures
219
219
  if (code !== 0 && code !== null) {
220
- logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr.slice(0, 300) || "(empty)"}`);
220
+ logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr || "(empty)"}`);
221
221
  }
222
222
  // Capture activity stats BEFORE deleting the agent
223
223
  const finalActivity = agentProc.activity;
@@ -232,7 +232,8 @@ export class AgentPool {
232
232
  agentName: agentDef.name,
233
233
  data: {
234
234
  exitCode: code,
235
- stderr: stderr.slice(-2000),
235
+ // Preserve complete diagnostics for task failure reports and recovery.
236
+ stderr,
236
237
  turnCount: finalActivity.turnCount,
237
238
  toolCallCount: finalActivity.recentToolCalls.length,
238
239
  filesModified: finalActivity.modifiedFiles.size,
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
25
25
  import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
26
26
  import * as store from "./store.js";
27
27
  import { debug, logError } from "./logger.js";
28
+ import { buildCompletionSummary, buildFailureSummary } from "./report.js";
28
29
 
29
30
  // ============================================================================
30
31
  // State
@@ -229,8 +230,8 @@ export default function (pi: ExtensionAPI) {
229
230
  const taskLines = tasks.map((t) => {
230
231
  const icon = t.status === "done" ? "✓" : t.status === "in_progress" ? "⏳" : t.status === "failed" ? "✗" : t.status === "blocked" ? "◻" : "·";
231
232
  let line = ` ${icon} ${t.id} (${t.agent}) [${t.status}]`;
232
- if (t.output) line += ` — ${t.output.split("\n")[0].slice(0, 80)}`;
233
- if (t.error) line += ` ERROR: ${t.error.slice(0, 60)}`;
233
+ if (t.output) line += ` — ${t.output}`;
234
+ if (t.error) line += ` ERROR: ${t.error}`;
234
235
  return line;
235
236
  }).join("\n");
236
237
 
@@ -461,7 +462,7 @@ export default function (pi: ExtensionAPI) {
461
462
  pi.registerTool({
462
463
  name: "squad_modify",
463
464
  label: "Squad Modify",
464
- description: "Modify the running squad: add_task, cancel_task, pause, resume, cancel (entire squad).",
465
+ 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
466
  parameters: Type.Object({
466
467
  action: Type.Union(
467
468
  [
@@ -469,6 +470,7 @@ export default function (pi: ExtensionAPI) {
469
470
  Type.Literal("cancel_task"),
470
471
  Type.Literal("pause_task"),
471
472
  Type.Literal("resume_task"),
473
+ Type.Literal("complete_task"),
472
474
  Type.Literal("pause"),
473
475
  Type.Literal("resume"),
474
476
  Type.Literal("cancel"),
@@ -476,6 +478,7 @@ export default function (pi: ExtensionAPI) {
476
478
  { description: "Action to perform" },
477
479
  ),
478
480
  taskId: Type.Optional(Type.String({ description: "Task ID for task-specific actions" })),
481
+ output: Type.Optional(Type.String({ description: "Result summary for complete_task (what was accomplished)" })),
479
482
  task: Type.Optional(
480
483
  Type.Object({
481
484
  id: Type.String(),
@@ -493,11 +496,11 @@ export default function (pi: ExtensionAPI) {
493
496
  if (params.action === "resume") {
494
497
  // Find a squad to resume: use activeSquadId or find the latest paused one
495
498
  const squadId = activeSquadId || store.findActiveSquads()
496
- .filter((s) => s.cwd === ctx.cwd && s.status === "paused")
499
+ .filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
497
500
  .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
498
501
 
499
502
  if (!squadId) {
500
- return { content: [{ type: "text" as const, text: "No paused squad found to resume." }], details: undefined };
503
+ return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
501
504
  }
502
505
 
503
506
  // Create a fresh scheduler if needed
@@ -517,10 +520,7 @@ export default function (pi: ExtensionAPI) {
517
520
  switch (event.type) {
518
521
  case "squad_completed": {
519
522
  const tasks = store.loadAllTasks(squadId);
520
- const summary = tasks
521
- .filter((t) => t.status === "done")
522
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
523
- .join("\n");
523
+ const summary = buildCompletionSummary(tasks);
524
524
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
525
525
  const s = schedulers.get(squadId); if (s) s.updateContext();
526
526
  // followUp + triggerTurn: wake an idle main agent to review;
@@ -540,7 +540,7 @@ export default function (pi: ExtensionAPI) {
540
540
  const done = tasks.filter((t) => t.status === "done");
541
541
  pi.sendMessage({
542
542
  customType: "squad-failed",
543
- content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}`,
543
+ content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
544
544
  display: true,
545
545
  }, { triggerTurn: true, deliverAs: "followUp" });
546
546
  forceWidgetUpdate();
@@ -632,6 +632,16 @@ export default function (pi: ExtensionAPI) {
632
632
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
633
633
  }
634
634
 
635
+ case "complete_task": {
636
+ if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
637
+ try {
638
+ await activeScheduler.completeTask(params.taskId, params.output);
639
+ } catch (err) {
640
+ return { content: [{ type: "text" as const, text: `complete_task failed: ${(err as Error).message}` }], details: undefined };
641
+ }
642
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' marked done — dependents unblocked and scheduled.` }], details: undefined };
643
+ }
644
+
635
645
  case "pause": {
636
646
  const squad = store.loadSquad(activeSquadId);
637
647
  if (squad) {
@@ -920,7 +930,7 @@ export default function (pi: ExtensionAPI) {
920
930
  const msgSched = getActiveScheduler();
921
931
  if (msgSched) {
922
932
  await msgSched.sendHumanMessage(targetTaskId, msgText);
923
- ctx.ui.notify(`Sent to ${targetAgent}: "${msgText.slice(0, 50)}"`, "info");
933
+ ctx.ui.notify(`Sent to ${targetAgent}: "${msgText}"`, "info");
924
934
  } else {
925
935
  store.appendMessage(activeSquadId, targetTaskId, {
926
936
  ts: store.now(),
@@ -1198,7 +1208,7 @@ export default function (pi: ExtensionAPI) {
1198
1208
  `Tags: ${agent.tags.join(", ")}`,
1199
1209
  ``,
1200
1210
  `Prompt:`,
1201
- `${agent.prompt.slice(0, 300)}${agent.prompt.length > 300 ? "..." : ""}`,
1211
+ agent.prompt,
1202
1212
  ``,
1203
1213
  `File: ${store.getGlobalAgentsDir()}/${agent.name}.json`,
1204
1214
  ].join("\n");
@@ -1387,7 +1397,7 @@ function openPanel(
1387
1397
  const panelSched = schedulers.get(squadId);
1388
1398
  if (input && panelSched) {
1389
1399
  await panelSched.sendHumanMessage(taskId, input);
1390
- ctx.ui.notify(`Sent to ${agentName}: "${input.slice(0, 50)}"`, "info");
1400
+ ctx.ui.notify(`Sent to ${agentName}: "${input}"`, "info");
1391
1401
  } else if (input) {
1392
1402
  store.appendMessage(squadId, taskId, {
1393
1403
  ts: store.now(),
@@ -1555,10 +1565,7 @@ async function startSquad(
1555
1565
  switch (event.type) {
1556
1566
  case "squad_completed": {
1557
1567
  const tasks = store.loadAllTasks(squadId);
1558
- const summary = tasks
1559
- .filter((t) => t.status === "done")
1560
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
1561
- .join("\n");
1568
+ const summary = buildCompletionSummary(tasks);
1562
1569
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1563
1570
 
1564
1571
  // Final context update before clearing scheduler
@@ -1593,7 +1600,7 @@ async function startSquad(
1593
1600
  customType: "squad-failed",
1594
1601
  content: `[squad] Squad "${squadId}" has stalled. ` +
1595
1602
  `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1596
- `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
1603
+ `Failed: ${buildFailureSummary(tasks)}\n` +
1597
1604
  `Use squad_status for details or squad_modify to adjust.`,
1598
1605
  display: true,
1599
1606
  }, { triggerTurn: true, deliverAs: "followUp" });
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/planner.ts CHANGED
@@ -152,7 +152,7 @@ async function runPiJson(options: PiJsonOptions): Promise<string> {
152
152
  }
153
153
 
154
154
  if (code !== 0 && messages.length === 0) {
155
- reject(new Error(`Planner failed (code ${code}): ${stderr.slice(0, 500)}`));
155
+ reject(new Error(`Planner failed (code ${code}): ${stderr}`));
156
156
  return;
157
157
  }
158
158
 
@@ -217,7 +217,7 @@ function parsePlannerOutput(text: string, validAgents?: Set<string>): PlannerOut
217
217
  return parsed as PlannerOutput;
218
218
  } catch (error) {
219
219
  if (error instanceof SyntaxError) {
220
- throw new Error(`Planner output is not valid JSON: ${text.slice(0, 200)}`);
220
+ throw new Error(`Planner output is not valid JSON: ${text}`);
221
221
  }
222
222
  throw error;
223
223
  }
package/src/protocol.ts CHANGED
@@ -102,7 +102,6 @@ function buildChainContext(task: Task, allTasks: Task[], squadId: string): strin
102
102
  const messages = loadMessages(squadId, dep.id);
103
103
  const lastText = messages
104
104
  .filter((m) => m.from === dep.agent && (m.type === "text" || m.type === "done"))
105
- .slice(-3)
106
105
  .map((m) => m.text)
107
106
  .join("\n");
108
107
  if (lastText) {
@@ -155,12 +154,9 @@ function buildSiblingAwareness(
155
154
  for (const [agent, files] of fileEntries) {
156
155
  if (files.length > 0) {
157
156
  lines.push(`**${agent}:**`);
158
- for (const f of files.slice(0, 10)) {
157
+ for (const f of files) {
159
158
  lines.push(` - ${f}`);
160
159
  }
161
- if (files.length > 10) {
162
- lines.push(` - ...and ${files.length - 10} more`);
163
- }
164
160
  }
165
161
  }
166
162
  lines.push(
@@ -187,7 +183,7 @@ function buildKnowledgeSection(squadId: string): string {
187
183
 
188
184
  if (decisions.length > 0) {
189
185
  lines.push("## Decisions");
190
- for (const d of decisions.slice(-10)) {
186
+ for (const d of decisions) {
191
187
  lines.push(`- ${d.text} (${d.from})`);
192
188
  }
193
189
  lines.push("");
@@ -195,7 +191,7 @@ function buildKnowledgeSection(squadId: string): string {
195
191
 
196
192
  if (conventions.length > 0) {
197
193
  lines.push("## Project Conventions");
198
- for (const c of conventions.slice(-10)) {
194
+ for (const c of conventions) {
199
195
  lines.push(`- ${c.text} (${c.from})`);
200
196
  }
201
197
  lines.push("");
@@ -203,7 +199,7 @@ function buildKnowledgeSection(squadId: string): string {
203
199
 
204
200
  if (findings.length > 0) {
205
201
  lines.push("## Findings");
206
- for (const f of findings.slice(-10)) {
202
+ for (const f of findings) {
207
203
  lines.push(`- ${f.text} (${f.from})`);
208
204
  }
209
205
  lines.push("");
@@ -256,7 +252,8 @@ function buildReworkContext(task: Task, squadId: string): string {
256
252
 
257
253
  if (originalTask?.output) {
258
254
  lines.push("## What Was Built (Previous Attempt)");
259
- lines.push(originalTask.output.slice(0, 2000));
255
+ // Rework agents need the complete prior handoff, not an arbitrary prefix.
256
+ lines.push(originalTask.output);
260
257
  lines.push("");
261
258
  }
262
259
 
package/src/report.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Task } from "./types.js";
2
+
3
+ /**
4
+ * Build the full task handoff included in squad completion notifications.
5
+ * This is durable report data, not a UI preview: never shorten task output.
6
+ */
7
+ export function buildCompletionSummary(tasks: Task[]): string {
8
+ return tasks
9
+ .filter((task) => task.status === "done")
10
+ .map((task) => `- ${task.id} (${task.agent}): ${task.output || "done"}`)
11
+ .join("\n");
12
+ }
13
+
14
+ /** Build the full failure handoff without shortening diagnostics. */
15
+ export function buildFailureSummary(tasks: Task[]): string {
16
+ return tasks
17
+ .filter((task) => task.status === "failed")
18
+ .map((task) => `${task.id}: ${task.error || "unknown error"}`)
19
+ .join("; ");
20
+ }
package/src/router.ts CHANGED
@@ -202,6 +202,6 @@ export class Router {
202
202
  return line.trim();
203
203
  }
204
204
  }
205
- return text.slice(0, 200);
205
+ return text;
206
206
  }
207
207
  }
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
  // =========================================================================
@@ -391,7 +465,7 @@ export class Scheduler {
391
465
  agentName: task.agent,
392
466
  agentRole: agentDef?.role || task.agent,
393
467
  reason,
394
- recentMessages: store.loadMessages(this.squadId, taskId).slice(-12).map((m) => ({ from: m.from, type: m.type, text: m.text })),
468
+ recentMessages: store.loadMessages(this.squadId, taskId).map((m) => ({ from: m.from, type: m.type, text: m.text })),
395
469
  recentToolCalls: activity ? [...activity.recentToolCalls] : [],
396
470
  turnCount: activity?.turnCount || 0,
397
471
  elapsedMinutes: activity ? (Date.now() - activity.startedAt) / 60000 : 0,
@@ -414,7 +488,7 @@ export class Scheduler {
414
488
  squadId: this.squadId,
415
489
  taskId,
416
490
  agentName,
417
- message: `${reason}\n\nAdvisor assessment:\n${advice.slice(0, 800)}`,
491
+ message: `${reason}\n\nAdvisor assessment:\n${advice}`,
418
492
  });
419
493
  return true; // escalation already emitted with richer context
420
494
  }
@@ -489,7 +563,9 @@ export class Scheduler {
489
563
  ts: store.now(),
490
564
  from: event.agentName,
491
565
  type: "text",
492
- text: text.slice(0, 2000),
566
+ // Persist the complete handoff. Reports can be arbitrarily long;
567
+ // presentation layers may viewport them, but source data must never truncate.
568
+ text,
493
569
  });
494
570
  }
495
571
 
@@ -562,7 +638,7 @@ export class Scheduler {
562
638
  const reason = turnCount === 0
563
639
  ? `exited with 0 turns (likely rate limit or API error)`
564
640
  : `exited with ${turnCount} turns but 0 tool calls (no work done)`;
565
- logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr.slice(0, 200)}`);
641
+ logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
566
642
  store.updateTaskStatus(this.squadId, event.taskId, "pending");
567
643
  store.appendMessage(this.squadId, event.taskId, {
568
644
  ts: store.now(),
@@ -576,7 +652,7 @@ export class Scheduler {
576
652
  }, 2000);
577
653
  } else {
578
654
  const stderr = event.data?.stderr || "";
579
- this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr.slice(0, 500)}`);
655
+ this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr}`);
580
656
  }
581
657
  this.updateContext();
582
658
  }
@@ -608,10 +684,9 @@ export class Scheduler {
608
684
 
609
685
  // Extract output from last messages
610
686
  const messages = store.loadMessages(this.squadId, taskId);
611
- const lastAgentMessages = messages
612
- .filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"))
613
- .slice(-3);
614
- const output = lastAgentMessages.map((m) => m.text).join("\n");
687
+ const agentMessages = messages
688
+ .filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
689
+ const output = agentMessages.map((m) => m.text).join("\n");
615
690
 
616
691
  store.updateTaskStatus(this.squadId, taskId, "done", {
617
692
  output: output || "Task completed",
@@ -795,7 +870,7 @@ export class Scheduler {
795
870
  squadId: this.squadId,
796
871
  taskId: task.id,
797
872
  agentName: task.agent,
798
- message: `QA failed ${originalId} ${retryCount} times. Retry limit reached.\nLatest feedback:\n${feedback.slice(0, 500)}`,
873
+ message: `QA failed ${originalId} ${retryCount} times. Retry limit reached.\nLatest feedback:\n${feedback}`,
799
874
  });
800
875
  continue;
801
876
  }
@@ -900,12 +975,11 @@ export class Scheduler {
900
975
 
901
976
  // Try to extract lines containing "FAIL", "Error", "✗"
902
977
  const failLines = output.split("\n")
903
- .filter((line) => /fail|error|✗|✘|broken|bug/i.test(line))
904
- .slice(0, 20);
978
+ .filter((line) => /fail|error|✗|✘|broken|bug/i.test(line));
905
979
  if (failLines.length > 0) return failLines.join("\n");
906
980
 
907
- // Fallback: last 500 chars
908
- return output.slice(-500);
981
+ // Preserve the complete QA handoff when no structured failure section exists.
982
+ return output;
909
983
  }
910
984
 
911
985
  // =========================================================================
@@ -973,7 +1047,7 @@ export class Scheduler {
973
1047
  status: task.status,
974
1048
  agent: task.agent,
975
1049
  title: task.title,
976
- ...(task.output ? { output: task.output.slice(0, 500) } : {}),
1050
+ ...(task.output ? { output: task.output } : {}),
977
1051
  ...(task.status === "blocked"
978
1052
  ? {
979
1053
  blockedBy: task.depends.filter((d) => {
@@ -1015,7 +1089,7 @@ export class Scheduler {
1015
1089
  action:
1016
1090
  msg.type === "tool"
1017
1091
  ? `→ ${msg.name} ${msg.args?.path || msg.args?.command || ""}`.trim()
1018
- : msg.text.slice(0, 80),
1092
+ : msg.text,
1019
1093
  });
1020
1094
  }
1021
1095
  }
@@ -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/supervisor.ts CHANGED
@@ -70,10 +70,9 @@ export async function analyzeStuckAgent(
70
70
  ): Promise<{ action: "retry" | "reassign" | "escalate"; reason: string; suggestion?: string }> {
71
71
  const task = store.loadTask(squadId, taskId);
72
72
  const messages = store.loadMessages(squadId, taskId);
73
- const recentMessages = messages.slice(-10);
74
73
 
75
- // Simple heuristic for now
76
- const errorMessages = recentMessages.filter((m) => m.type === "error");
74
+ // Simple heuristic for now — inspect the complete task history.
75
+ const errorMessages = messages.filter((m) => m.type === "error");
77
76
  if (errorMessages.length >= 3) {
78
77
  return {
79
78
  action: "escalate",
@@ -128,7 +127,7 @@ export async function analyzeBlockRequest(
128
127
  return {
129
128
  action: "create_subtask",
130
129
  subtask: {
131
- title: `Support: ${blockReason.slice(0, 60)}`,
130
+ title: `Support: ${blockReason}`,
132
131
  agent: "fullstack",
133
132
  description: `Created from block request by ${agentName} on task ${taskId}: ${blockReason}`,
134
133
  },
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