pi-squad 0.4.14 → 0.6.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
@@ -22,7 +22,7 @@ Ask pi to do something complex. It calls the `squad` tool automatically:
22
22
  > Build a REST API with authentication, tests, and documentation
23
23
  ```
24
24
 
25
- Or be explicit with predefined tasks:
25
+ The planner agent reads your codebase and creates a task breakdown automatically. Or be explicit:
26
26
 
27
27
  ```
28
28
  > Use squad: goal="Build task API", tasks=[
@@ -34,17 +34,18 @@ Or be explicit with predefined tasks:
34
34
 
35
35
  ### What Happens
36
36
 
37
- 1. Extension creates tasks and starts the scheduler
37
+ 1. Planner analyzes the codebase and creates tasks (or uses your predefined tasks)
38
38
  2. A **live widget** appears above the editor showing task status
39
39
  3. Agents spawn as separate pi processes, work in parallel where dependencies allow
40
40
  4. QA agents can trigger **automatic rework loops** when they find bugs
41
- 5. When complete, pi reports the summary with costs
41
+ 5. When complete, pi reports the summary notifications don't pollute message history
42
+ 6. Multiple squads can run concurrently across different projects
42
43
 
43
44
  ## User Interface
44
45
 
45
46
  ### Widget (above editor)
46
47
 
47
- Always visible when a squad is active. Component-based, event-driven rendering — updates on every scheduler event with no polling.
48
+ Always visible when a squad is active. Debounced updates with cache key to avoid flicker.
48
49
 
49
50
  ```
50
51
  ⏳ squad Build task API 2/3 $0.58 3m12s ^q detail · /squad msg
@@ -57,7 +58,7 @@ Features:
57
58
  - Per-task elapsed time (warning color after 3 minutes)
58
59
  - Live tool call preview for running tasks
59
60
  - Stale detection — shows `⏳ idle` when agent has no output for 2+ minutes
60
- - Responsive — caps visible tasks based on terminal height
61
+ - Responsive — caps visible tasks on small terminals, shows `+N more` overflow
61
62
  - Completion duration for finished tasks
62
63
 
63
64
  ### Status Bar (footer)
@@ -68,9 +69,9 @@ Features:
68
69
 
69
70
  ### Panel (Ctrl+Q)
70
71
 
71
- Centered overlay panel. Press `Ctrl+Q` to open/close.
72
+ Centered overlay panel with proper lifecycle management. Press `Ctrl+Q` to open/close.
72
73
 
73
- **Task list:**
74
+ **Task list** with sticky summary at bottom:
74
75
  ```
75
76
  ╭─ squad: Build task API ─────────────────────╮
76
77
  │ ▸ ✓ api (backend) 2m12s │
@@ -86,15 +87,18 @@ Centered overlay panel. Press `Ctrl+Q` to open/close.
86
87
  ╰──────────────────────────────────────────────╯
87
88
  ```
88
89
 
89
- **Message view** (Enter on a task):
90
+ **Message view** (Enter on a task) — long text wraps instead of truncating:
90
91
  ```
91
92
  ╭─ tests · qa ⏳ ─────────────────────────────╮
92
93
  │ 10:03 qa │
93
- │ Starting test implementation
94
+ │ Starting test implementation. Will cover
95
+ │ all CRUD endpoints, validation, and │
96
+ │ edge cases for empty input. │
94
97
  │ 10:04 qa │
95
98
  │ → write src/tasks.test.js │
96
99
  │ 10:05 YOU │
97
100
  │ Also test edge cases for empty input │
101
+ │ ─ 65% ─ 24 msgs ─ ↑↓ scroll │
98
102
  ├──────────────────────────────────────────────┤
99
103
  │ ↑↓ scroll m send esc back ^q close │
100
104
  ╰──────────────────────────────────────────────╯
@@ -110,10 +114,14 @@ Centered overlay panel. Press `Ctrl+Q` to open/close.
110
114
  | `/squad agents` | List, view, edit, enable/disable agents |
111
115
  | `/squad agents <name>` | Quick view of a specific agent |
112
116
  | `/squad msg [agent] text` | Send message to a running agent |
113
- | `/squad widget` | Toggle live widget |
117
+ | `/squad widget` | Toggle live widget on/off |
114
118
  | `/squad panel` | Toggle overlay panel |
115
119
  | `/squad cancel` | Cancel running squad |
116
120
  | `/squad clear` | Dismiss widget, deactivate view |
121
+ | `/squad cleanup` | Delete squad data (select one or all) |
122
+ | `/squad cleanup all` | Delete all squad data |
123
+ | `/squad enable` | Enable pi-squad (tools, widget, system prompt) |
124
+ | `/squad disable` | Disable pi-squad completely |
117
125
 
118
126
  ## Keyboard Shortcuts
119
127
 
@@ -137,7 +145,21 @@ Centered overlay panel. Press `Ctrl+Q` to open/close.
137
145
  | `squad_message` | Send message to a running agent via `steer()` |
138
146
  | `squad_modify` | Add/cancel/pause/resume tasks, or cancel/resume entire squad |
139
147
 
140
- The main pi agent sees squad state in its system prompt automatically (`<squad_status>` block) and can relay messages, add tasks, or check status on your behalf.
148
+ The main pi agent sees squad state in its system prompt automatically (`<squad_status>` block) and can relay messages, add tasks, or check status on your behalf. Squad notifications use `sendMessage({ display: true })` so they don't pollute the LLM's message history.
149
+
150
+ ## Multiple Concurrent Squads
151
+
152
+ Multiple squads can run in parallel from the same pi session. Each squad has its own scheduler, agent pool, and disk state.
153
+
154
+ ```
155
+ > Build the auth API → starts squad-1
156
+ > Build the dashboard UI → starts squad-2 (runs in parallel)
157
+
158
+ /squad list → see both, select which to view
159
+ /squad select → switch widget/panel focus
160
+ ```
161
+
162
+ All squads run independently in the background. The widget/panel shows whichever squad you're focused on.
141
163
 
142
164
  ## QA Rework Loop
143
165
 
@@ -190,7 +212,7 @@ All 42 tests passing. No issues found.
190
212
 
191
213
  ```
192
214
  config: {
193
- maxConcurrency: 3, // parallel agents
215
+ maxConcurrency: 3, // parallel agents (default: 2)
194
216
  maxRetries: 2, // rework attempts before escalation (default: 2)
195
217
  }
196
218
  ```
@@ -269,7 +291,7 @@ Create a JSON file in `~/.pi/squad/agents/` (global) or `{project}/.pi/squad/age
269
291
 
270
292
  - `model`: `null` = pi default. Override with any model ID your provider supports.
271
293
  - `tools`: `null` = all tools. Array like `["bash", "read", "write", "edit"]` to restrict.
272
- - `tags`: Used by the planner to match agents to tasks.
294
+ - `tags`: Used by the planner to match agents to tasks automatically.
273
295
  - `disabled`: `true` = hidden from planner, tasks assigned to this agent will fail.
274
296
  - Project-local agents override global agents with the same name.
275
297
 
@@ -277,7 +299,7 @@ Create a JSON file in `~/.pi/squad/agents/` (global) or `{project}/.pi/squad/age
277
299
 
278
300
  ### Chain Context (primary)
279
301
 
280
- When task A completes, its **full output** is injected into task B's system prompt as a `# Completed Dependencies` section. Downstream agents read what was built and extend it.
302
+ When task A completes, its **full output** is injected into task B's system prompt as a `# Completed Dependencies` section. Downstream agents read what was built and extend it — no need for explicit communication.
281
303
 
282
304
  ### Shared Filesystem
283
305
 
@@ -290,7 +312,7 @@ When agents run in parallel, each sees the other's status and modified files in
290
312
  ```
291
313
  # Sibling Tasks
292
314
  - template-engine [in_progress] — fullstack — Create HTML templates
293
-
315
+
294
316
  ## Files Modified by Other Agents
295
317
  **fullstack:** src/templateEngine.js, templates/post.html
296
318
  ⚠️ Coordinate with the owning agent before editing files listed above.
@@ -319,6 +341,7 @@ When a pi session crashes (tmux dies, SIGKILL, etc.):
319
341
  - In-progress tasks are automatically **suspended** on next startup
320
342
  - Squad is marked as **paused**
321
343
  - Only squads with completed tasks trigger a notification
344
+ - Squads with zero progress are silently paused (no noise)
322
345
 
323
346
  ### Resume
324
347
 
@@ -331,9 +354,14 @@ Resume a paused squad across sessions:
331
354
  The agent calls `squad_modify({ action: "resume" })`, which:
332
355
  1. Finds the latest paused squad for the project
333
356
  2. Creates a fresh scheduler from disk state
334
- 3. Restarts suspended tasks
357
+ 3. Wires up event handlers for completion/failure/escalation
358
+ 4. Restarts suspended tasks
359
+
360
+ All state lives on disk — squads are fully reconstructable from JSON files.
335
361
 
336
- All state lives on disk — squads are fully reconstructable.
362
+ ### Spawn Resilience
363
+
364
+ Child agent processes occasionally fail during startup (transient resource pressure). The scheduler automatically retries once after a 2-second delay. If the retry also fails, the task is marked failed with a diagnostic log including PID, exit code, stderr, and stdout line count.
337
365
 
338
366
  ## Governance
339
367
 
@@ -343,13 +371,15 @@ All state lives on disk — squads are fully reconstructable.
343
371
  | **Concurrency control** | `maxConcurrency` limits parallel agents (default: 2) |
344
372
  | **Health monitoring** | Idle warning (3m), stuck intervention (5m), loop detection (same tool 5x), 30m hard ceiling |
345
373
  | **QA rework loop** | Auto-creates fix + retest tasks when QA fails (up to `maxRetries`) |
374
+ | **Spawn retry** | Retries once on transient startup failures, then fails with diagnostics |
346
375
  | **File tracking** | Warns agents about files modified by other agents |
347
376
  | **@mention routing** | Parsed from output, delivered via RPC `steer()` |
348
- | **Escalation** | Blocked agents, stuck agents, and retry limit → notify main agent |
377
+ | **Escalation** | Blocked agents, stuck agents, retry limit, spawn failure → notify main agent |
378
+ | **Clean notifications** | Uses `sendMessage({ display: true })` — visible to user, not in LLM history |
349
379
 
350
380
  ## Data Layout
351
381
 
352
- All state lives in `~/.pi/squad/`. No database, no daemon, no external services. Writes are atomic (temp file + rename).
382
+ All state lives in `~/.pi/squad/`. No database, no daemon, no external services. Writes are atomic (temp file + rename). JSONL reads are fault-tolerant (skip corrupt lines from concurrent writes).
353
383
 
354
384
  ```
355
385
  ~/.pi/squad/
@@ -364,7 +394,7 @@ All state lives in `~/.pi/squad/`. No database, no daemon, no external services.
364
394
  │ ├── decisions.jsonl
365
395
  │ └── findings.jsonl
366
396
  └── {task-id}/ — one directory per task
367
- ├── task.json — status, agent, output, usage
397
+ ├── task.json — status, agent, output, usage, retryOf, qaFeedback
368
398
  └── messages.jsonl — full conversation log
369
399
  ```
370
400
 
@@ -374,46 +404,51 @@ All state lives in `~/.pi/squad/`. No database, no daemon, no external services.
374
404
  src/
375
405
  ├── index.ts — extension entry: tools, commands, widget, panel, lifecycle
376
406
  ├── types.ts — Squad, Task, Agent, Message types
377
- ├── store.ts — JSON/JSONL file I/O with atomic writes
378
- ├── scheduler.ts — dependency DAG, concurrency, rework loop, task lifecycle
379
- ├── agent-pool.ts — pi RPC process spawn/steer/kill
380
- ├── protocol.ts — system prompt builder (7 sections)
407
+ ├── store.ts — JSON/JSONL file I/O with atomic writes, fault-tolerant reads
408
+ ├── scheduler.ts — dependency DAG, concurrency, rework loop, spawn retry, task lifecycle
409
+ ├── agent-pool.ts — pi RPC process spawn/steer/kill, diagnostic logging
410
+ ├── protocol.ts — system prompt builder (7 sections + rework context)
381
411
  ├── router.ts — @mention parsing, cross-agent messaging
382
412
  ├── monitor.ts — health checks: idle/stuck/loop/ceiling
383
413
  ├── supervisor.ts — quality review, block analysis
384
414
  ├── planner.ts — one-shot goal decomposition via LLM
385
415
  ├── panel/
386
416
  │ ├── squad-panel.ts — overlay component (ctx.ui.custom with done())
387
- │ ├── squad-widget.ts — component-based widget (setWidget factory)
388
- │ ├── task-list.ts — task tree with status icons, elapsed time
389
- │ └── message-view.ts — scrollable message log per task
417
+ │ ├── squad-widget.ts — string[] widget with debounce and cache
418
+ │ ├── task-list.ts — task tree with status icons, elapsed time, sticky summary
419
+ │ └── message-view.ts — scrollable message log with word wrap, fixed-height layout
390
420
  ├── skills/
391
421
  │ ├── supervisor/ — teaches main agent to supervise squads
392
422
  │ ├── squad-protocol/ — communication rules for child agents
393
423
  │ ├── collaboration/ — team interaction patterns
394
- │ └── verification/ — verify-before-done + structured verdicts
424
+ │ └── verification/ — verify-before-done + structured QA verdicts
395
425
  └── agents/_defaults/ — 11 bundled agent definitions
396
426
  ```
397
427
 
398
428
  ## Test Results
399
429
 
400
430
  ### Simple (3 tasks, linear chain)
401
- - URL shortener API: 3 tasks, $0.83, 4.5 minutes
431
+ - URL shortener API: 3 tasks, 30 turns, $0.83, 4.5 minutes
402
432
  - Backend → QA → Docs, all passing
403
433
 
404
- ### Medium (6 tasks, diamond dependency)
405
- - Static blog engine: 6 tasks, $4.56, 24 minutes
406
- - Markdown parser HTML converter site generator Express server → QA + Docs
407
- - 87+ tests passing
434
+ ### Medium (5 tasks, planner-decomposed)
435
+ - Quiz API: 5 tasks, 82 turns, $3.11, 14 minutes
436
+ - Planner auto-created: architectbackendfullstack → QA + docs
437
+ - 111 vitest tests passing, 3,637 lines of code
408
438
 
409
- ### Complex (5 original + 4 auto-rework = 9 tasks)
410
- - Chat API with auth + rooms: 9 tasks, $4.60, 26 minutes
439
+ ### Complex with rework (5 original + 4 auto-rework = 9 tasks)
440
+ - Chat API with auth + rooms: 9 tasks, 176 turns, $4.60, 26 minutes
411
441
  - QA found 2 bugs → 2 automatic rework cycles → both fixed and verified
412
- - 176 turns across 5 specialist agents
442
+ - Dependency chain with parallel QA and rework loop
443
+
444
+ ### Parallel squads (2 squads running simultaneously)
445
+ - Bookmarks API (2/2, $0.83) + Notes CLI (6/6 with 2 rework cycles, $1.17)
446
+ - Both ran concurrently from the same pi session
447
+ - Combined: 12 tasks, 101 turns, $2.00
413
448
 
414
449
  ## Requirements
415
450
 
416
- - [pi](https://github.com/badlogic/pi-mono) coding agent
451
+ - [pi](https://github.com/badlogic/pi-mono) coding agent (v0.63.0+, recommended v0.64.0+)
417
452
  - An API key configured in pi (Anthropic, OpenRouter, etc.)
418
453
  - Node.js 18+
419
454
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.4.14",
3
+ "version": "0.6.0",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "pi": {
package/src/agent-pool.ts CHANGED
@@ -11,6 +11,7 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import type { AgentDef, AgentActivity, Task, TaskMessage } from "./types.js";
13
13
  import { buildAgentSystemPrompt, type ProtocolBuildOptions } from "./protocol.js";
14
+ import { debug, logError } from "./logger.js";
14
15
 
15
16
  // ============================================================================
16
17
  // Types
@@ -165,6 +166,7 @@ export class AgentPool {
165
166
 
166
167
  // Spawn pi process — set env var to prevent recursive squad extension loading
167
168
  const invocation = getPiInvocation(["--mode", "rpc", ...args]);
169
+ debug("squad-pool", `spawn ${agentDef.name}: ${invocation.command} ${invocation.args.slice(0, 3).join(" ")} ...`);
168
170
  const proc = spawn(invocation.command, invocation.args, {
169
171
  cwd,
170
172
  stdio: ["pipe", "pipe", "pipe"],
@@ -199,6 +201,7 @@ export class AgentPool {
199
201
  });
200
202
 
201
203
  attachLineReader(proc.stdout!, (line) => {
204
+ stdoutLines++;
202
205
  try {
203
206
  const event = JSON.parse(line);
204
207
  this.handleRpcEvent(agentProc, event);
@@ -208,7 +211,14 @@ export class AgentPool {
208
211
  });
209
212
 
210
213
  let agentEndEmitted = false;
211
- proc.on("exit", (code) => {
214
+ let stdoutLines = 0;
215
+ proc.on("exit", (code, signal) => {
216
+ // Log diagnostic info for debugging spawn failures
217
+ if (code !== 0 && code !== null) {
218
+ logError("squad-pool", `${agentDef.name} exited: code=${code} signal=${signal} pid=${proc.pid} stdoutLines=${stdoutLines} stderr=${stderr.slice(0, 300) || "(empty)"}`);
219
+ }
220
+ // Clean up agents map so getRunningAgents() doesn't count dead processes
221
+ this.agents.delete(taskId);
212
222
  // Only emit if we haven't already emitted via RPC agent_end event
213
223
  if (!agentEndEmitted) {
214
224
  agentEndEmitted = true;
@@ -368,7 +378,7 @@ export class AgentPool {
368
378
  // Pi RPC mode emits agent_end when the agent loop finishes.
369
379
  // The RPC process stays alive waiting for more commands,
370
380
  // so we need to explicitly kill it and emit our own agent_end.
371
- console.error(`[squad-pool] agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
381
+ debug("squad-pool", `agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
372
382
  // Mark the guard to prevent double-emit from proc.on("exit")
373
383
  const guardFn = (agent as any)._agentEndEmitted;
374
384
  if (guardFn) guardFn();
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { runPlanner } from "./planner.js";
21
21
  import { SquadPanel, type SquadPanelResult } from "./panel/squad-panel.js";
22
22
  import { setupSquadWidget, type SquadWidgetState } from "./panel/squad-widget.js";
23
23
  import * as store from "./store.js";
24
+ import { debug, logError } from "./logger.js";
24
25
 
25
26
  // ============================================================================
26
27
  // State
@@ -166,15 +167,17 @@ export default function (pi: ExtensionAPI) {
166
167
  ),
167
168
  }),
168
169
 
169
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
170
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
170
171
  if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }] };
171
172
  if (!uiCtx) uiCtx = ctx;
172
173
 
174
+ // Check if the user cancelled before we start
175
+ if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled." }] };
176
+
173
177
  // Multiple squads can run concurrently — no guard needed
174
178
 
175
179
  const squadId = store.makeTaskId(params.goal);
176
180
  if (store.squadExists(squadId)) {
177
- // Append timestamp to make unique
178
181
  const uniqueId = `${squadId}-${Date.now().toString(36)}`;
179
182
  return await startSquad(uniqueId, params, ctx.cwd, squadSkillPaths, pi);
180
183
  }
@@ -346,15 +349,14 @@ export default function (pi: ExtensionAPI) {
346
349
  switch (event.type) {
347
350
  case "squad_completed": {
348
351
  const tasks = store.loadAllTasks(squadId);
349
- const summary = tasks
350
- .filter((t) => t.status === "done")
351
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
352
- .join("\n");
353
352
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
354
353
  const s = schedulers.get(squadId); if (s) s.updateContext();
354
+ const overview = store.loadOverview(squadId);
355
355
  pi.sendMessage({
356
356
  customType: "squad-completed",
357
- content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\nSummary:\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}`,
357
+ content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
358
+ (overview ? `## Squad Overview\n\n${overview}\n\n` : "") +
359
+ `Total cost: $${totalCost.toFixed(4)}`,
358
360
  display: true,
359
361
  });
360
362
  schedulers.delete(squadId);
@@ -365,9 +367,12 @@ export default function (pi: ExtensionAPI) {
365
367
  const tasks = store.loadAllTasks(squadId);
366
368
  const failed = tasks.filter((t) => t.status === "failed");
367
369
  const done = tasks.filter((t) => t.status === "done");
370
+ const overview = store.loadOverview(squadId);
368
371
  pi.sendMessage({
369
372
  customType: "squad-failed",
370
- 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("; ")}`,
373
+ content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\n` +
374
+ `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}` +
375
+ (overview ? `\n\n## Squad Overview\n\n${overview}` : ""),
371
376
  display: true,
372
377
  }, { triggerTurn: true });
373
378
  forceWidgetUpdate();
@@ -387,7 +392,7 @@ export default function (pi: ExtensionAPI) {
387
392
 
388
393
  const resumeSched = schedulers.get(squadId)!;
389
394
  resumeSched.resume().catch((err) => {
390
- console.error(`[squad] Resume error: ${(err as Error).message}`);
395
+ logError("squad", `Resume error: ${(err as Error).message}`);
391
396
  });
392
397
 
393
398
  const tasks = store.loadAllTasks(squadId);
@@ -438,7 +443,7 @@ export default function (pi: ExtensionAPI) {
438
443
  case "resume_task": {
439
444
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }] };
440
445
  activeScheduler.resumeTask(params.taskId).catch((err) => {
441
- console.error(`[squad] Resume task error: ${(err as Error).message}`);
446
+ logError("squad", `Resume task error: ${(err as Error).message}`);
442
447
  });
443
448
  return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }] };
444
449
  }
@@ -1211,6 +1216,31 @@ async function startSquad(
1211
1216
 
1212
1217
  store.saveSquad(squad);
1213
1218
 
1219
+ // Create initial OVERVIEW.md with squad goal, task plan, and design contract
1220
+ const planLines = [
1221
+ `# Squad: ${params.goal}`,
1222
+ ``,
1223
+ `**Created:** ${squad.created}`,
1224
+ `**Tasks:** ${plan.tasks.length}`,
1225
+ ``,
1226
+ `## Task Plan`,
1227
+ ``,
1228
+ ...plan.tasks.map((t) => {
1229
+ const deps = t.depends.length > 0 ? ` (after: ${t.depends.join(", ")})` : "";
1230
+ return `- **${t.id}** → ${t.agent}: ${t.title}${deps}`;
1231
+ }),
1232
+ ``,
1233
+ ];
1234
+
1235
+ // Extract design contract from task descriptions — API paths, schemas,
1236
+ // ports, file conventions — so parallel agents share a single source of truth.
1237
+ const contractLines = buildDesignContract(plan.tasks, params.goal);
1238
+ if (contractLines.length > 0) {
1239
+ planLines.push(...contractLines);
1240
+ }
1241
+
1242
+ store.appendOverview(squadId, planLines.join("\n"));
1243
+
1214
1244
  // Create task files
1215
1245
  for (const taskDef of plan.tasks) {
1216
1246
  const task: Task = {
@@ -1256,12 +1286,8 @@ async function startSquad(
1256
1286
  // Update widget on every scheduler event
1257
1287
  forceWidgetUpdate();
1258
1288
  switch (event.type) {
1259
- case "squad_completed": {
1289
+ case "squad_completed": {
1260
1290
  const tasks = store.loadAllTasks(squadId);
1261
- const summary = tasks
1262
- .filter((t) => t.status === "done")
1263
- .map((t) => `- ${t.id} (${t.agent}): ${t.output?.slice(0, 150) || "done"}`)
1264
- .join("\n");
1265
1291
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1266
1292
 
1267
1293
  // Final context update before clearing scheduler
@@ -1270,12 +1296,17 @@ async function startSquad(
1270
1296
  completedSched.updateContext();
1271
1297
  }
1272
1298
 
1273
- // Use sendMessage with display:truevisible to user but doesn't
1274
- // pollute LLM message history. No triggerTurn needed for completions.
1299
+ // Load the full overview document contains the narrative of
1300
+ // each task's output, decisions, issues, and files modified.
1301
+ const overview = store.loadOverview(squadId);
1302
+
1303
+ // Send into LLM context (not display-only) so the main agent
1304
+ // knows exactly what happened. No triggerTurn — user decides
1305
+ // what to do next.
1275
1306
  pi.sendMessage({
1276
1307
  customType: "squad-completed",
1277
1308
  content: `[squad] Squad "${squadId}" completed all ${tasks.length} tasks.\n\n` +
1278
- `Summary:\n${summary}\n\n` +
1309
+ (overview ? `## Squad Overview\n\n${overview}\n\n` : "") +
1279
1310
  `Total cost: $${totalCost.toFixed(4)}`,
1280
1311
  display: true,
1281
1312
  });
@@ -1290,14 +1321,14 @@ async function startSquad(
1290
1321
  const tasks = store.loadAllTasks(squadId);
1291
1322
  const failed = tasks.filter((t) => t.status === "failed");
1292
1323
  const done = tasks.filter((t) => t.status === "done");
1324
+ const overview = store.loadOverview(squadId);
1293
1325
 
1294
- // Squad failed — notify user. Use triggerTurn so the agent can
1295
- // suggest next steps (retry, modify, cancel).
1296
1326
  pi.sendMessage({
1297
1327
  customType: "squad-failed",
1298
1328
  content: `[squad] Squad "${squadId}" has stalled. ` +
1299
1329
  `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1300
1330
  `Failed: ${failed.map((t) => `${t.id}: ${t.error?.slice(0, 100)}`).join("; ")}\n` +
1331
+ (overview ? `\n## Squad Overview\n\n${overview}\n\n` : "\n") +
1301
1332
  `Use squad_status for details or squad_modify to adjust.`,
1302
1333
  display: true,
1303
1334
  }, { triggerTurn: true });
@@ -1325,7 +1356,7 @@ async function startSquad(
1325
1356
  // We must return immediately so the main agent's turn completes
1326
1357
  // and the user regains interactive control.
1327
1358
  scheduler.start().catch((err) => {
1328
- console.error(`[squad] Scheduler start error: ${(err as Error).message}`);
1359
+ logError("squad", `Scheduler start error: ${(err as Error).message}`);
1329
1360
  });
1330
1361
 
1331
1362
  // Build response
@@ -1358,3 +1389,99 @@ function getSquadSkillPaths(skillsDir: string): string[] {
1358
1389
  .map((d) => path.join(skillsDir, d.name))
1359
1390
  .filter((dir) => fs.existsSync(path.join(dir, "SKILL.md")));
1360
1391
  }
1392
+
1393
+ /**
1394
+ * Extract a design contract from task descriptions.
1395
+ *
1396
+ * Scans all tasks for API paths, ports, file names, data schemas, and
1397
+ * shared conventions. Produces a "## Design Contract" section in the
1398
+ * OVERVIEW.md so that ALL agents (including parallel ones) share a
1399
+ * single source of truth before they start working.
1400
+ */
1401
+ function buildDesignContract(
1402
+ tasks: Array<{ id: string; title: string; description: string; agent: string; depends: string[] }>,
1403
+ goal: string,
1404
+ ): string[] {
1405
+ const lines: string[] = [];
1406
+
1407
+ // Collect API routes mentioned in any task description
1408
+ const apiRoutes: string[] = [];
1409
+ const ports: string[] = [];
1410
+ const files: string[] = [];
1411
+ const schemas: string[] = [];
1412
+
1413
+ const allText = tasks.map((t) => `${t.title}\n${t.description}`).join("\n") + "\n" + goal;
1414
+
1415
+ // Extract API paths like GET /foo, POST /bar/:id
1416
+ const routePattern = /\b(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\/[\w\/:]+)/gi;
1417
+ for (const match of allText.matchAll(routePattern)) {
1418
+ const route = `${match[1].toUpperCase()} ${match[2]}`;
1419
+ if (!apiRoutes.includes(route)) apiRoutes.push(route);
1420
+ }
1421
+
1422
+ // Extract port numbers
1423
+ const portPattern = /\b(?:port|PORT|Port)\s*[=:]?\s*(\d{4,5})\b/gi;
1424
+ for (const match of allText.matchAll(portPattern)) {
1425
+ if (!ports.includes(match[1])) ports.push(match[1]);
1426
+ }
1427
+
1428
+ // Extract key file names mentioned (e.g., server.js, index.html)
1429
+ const filePattern = /\b([\w-]+\.(js|ts|json|html|css|sh|mjs|cjs))\b/gi;
1430
+ for (const match of allText.matchAll(filePattern)) {
1431
+ const f = match[1];
1432
+ if (!files.includes(f) && !['package.json'].includes(f)) files.push(f);
1433
+ }
1434
+
1435
+ // Extract data schemas like {field, field, field}
1436
+ const schemaPattern = /\{([a-zA-Z_][\w,\s\[\]?]+)\}/g;
1437
+ for (const match of allText.matchAll(schemaPattern)) {
1438
+ const fields = match[1].trim();
1439
+ if (fields.includes(',') && fields.length > 5 && fields.length < 200) {
1440
+ if (!schemas.includes(fields)) schemas.push(fields);
1441
+ }
1442
+ }
1443
+
1444
+ // Only emit a contract section if we found shared design elements
1445
+ if (apiRoutes.length === 0 && ports.length === 0 && schemas.length === 0) {
1446
+ return [];
1447
+ }
1448
+
1449
+ lines.push(`## Design Contract`);
1450
+ lines.push(``);
1451
+ lines.push(`> **All agents MUST follow these specifications.** Do not invent`);
1452
+ lines.push(`> alternative paths, ports, or schemas. If you need to deviate,`);
1453
+ lines.push(`> document the reason in your output.`);
1454
+ lines.push(``);
1455
+
1456
+ if (ports.length > 0) {
1457
+ lines.push(`### Server`);
1458
+ lines.push(`- Port: **${ports.join(", ")}**`);
1459
+ lines.push(``);
1460
+ }
1461
+
1462
+ if (apiRoutes.length > 0) {
1463
+ lines.push(`### API Endpoints`);
1464
+ for (const r of apiRoutes) {
1465
+ lines.push(`- \`${r}\``);
1466
+ }
1467
+ lines.push(``);
1468
+ }
1469
+
1470
+ if (schemas.length > 0) {
1471
+ lines.push(`### Data Schemas`);
1472
+ for (const s of schemas) {
1473
+ lines.push(`- \`{ ${s} }\``);
1474
+ }
1475
+ lines.push(``);
1476
+ }
1477
+
1478
+ if (files.length > 0) {
1479
+ lines.push(`### Key Files`);
1480
+ for (const f of files) {
1481
+ lines.push(`- \`${f}\``);
1482
+ }
1483
+ lines.push(``);
1484
+ }
1485
+
1486
+ return lines;
1487
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * logger.ts — Squad debug logging to file, NOT stderr.
3
+ *
4
+ * Writing to stderr (console.error) corrupts the TUI because it bypasses
5
+ * the TUI's differential renderer. All squad debug output goes to a log
6
+ * file instead: ~/.pi/squad/debug.log
7
+ *
8
+ * Set PI_SQUAD_DEBUG=1 to enable logging. Without it, logs are silent.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+
15
+ const LOG_DIR = path.join(os.homedir(), ".pi", "squad");
16
+ const LOG_FILE = path.join(LOG_DIR, "debug.log");
17
+ const DEBUG = process.env.PI_SQUAD_DEBUG === "1";
18
+
19
+ /** Max log file size before rotation (2MB) */
20
+ const MAX_LOG_SIZE = 2 * 1024 * 1024;
21
+
22
+ let rotationChecked = false;
23
+
24
+ function ensureLogDir(): void {
25
+ try {
26
+ fs.mkdirSync(LOG_DIR, { recursive: true });
27
+ } catch { /* ignore */ }
28
+ }
29
+
30
+ function rotateIfNeeded(): void {
31
+ if (rotationChecked) return;
32
+ rotationChecked = true;
33
+ try {
34
+ const stat = fs.statSync(LOG_FILE);
35
+ if (stat.size > MAX_LOG_SIZE) {
36
+ const backup = LOG_FILE + ".old";
37
+ try { fs.unlinkSync(backup); } catch { /* ignore */ }
38
+ fs.renameSync(LOG_FILE, backup);
39
+ }
40
+ } catch { /* file doesn't exist yet, fine */ }
41
+ }
42
+
43
+ /**
44
+ * Log a debug message to ~/.pi/squad/debug.log.
45
+ * Only writes when PI_SQUAD_DEBUG=1 is set. Silent otherwise.
46
+ */
47
+ export function debug(prefix: string, ...args: unknown[]): void {
48
+ if (!DEBUG) return;
49
+ write(prefix, ...args);
50
+ }
51
+
52
+ /**
53
+ * Always log to ~/.pi/squad/debug.log regardless of PI_SQUAD_DEBUG.
54
+ * Use for errors and critical events that should never be lost.
55
+ */
56
+ export function logError(prefix: string, ...args: unknown[]): void {
57
+ write(prefix, ...args);
58
+ }
59
+
60
+ function write(prefix: string, ...args: unknown[]): void {
61
+ try {
62
+ ensureLogDir();
63
+ rotateIfNeeded();
64
+ const ts = new Date().toISOString();
65
+ const msg = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
66
+ fs.appendFileSync(LOG_FILE, `[${ts}] [${prefix}] ${msg}\n`);
67
+ } catch { /* never throw from logging */ }
68
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Theme } from "@mariozechner/pi-coding-agent";
8
- import { truncateToWidth } from "@mariozechner/pi-tui";
8
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
9
  import type { TaskMessage } from "../types.js";
10
10
  import * as store from "../store.js";
11
11
 
@@ -159,11 +159,12 @@ export class MessageView {
159
159
  case "text":
160
160
  case "message":
161
161
  case "reply": {
162
- // Split text, strip any \r, ensure no embedded newlines survive
163
162
  const textLines = msg.text.replace(/\r/g, "").split("\n");
164
163
  const show = textLines.slice(0, MAX_TEXT_LINES);
165
164
  for (const tl of show) {
166
- lines.push(fit(` ${tl}`, width));
165
+ // Wrap long lines instead of truncating
166
+ const wrapped = wrap(` ${tl}`, width, " ");
167
+ lines.push(...wrapped);
167
168
  }
168
169
  if (textLines.length > MAX_TEXT_LINES) {
169
170
  lines.push(fit(` ${th.fg("dim", `... +${textLines.length - MAX_TEXT_LINES} lines`)}`, width));
@@ -171,10 +172,10 @@ export class MessageView {
171
172
  break;
172
173
  }
173
174
  case "done":
174
- lines.push(fit(` ${th.fg("success", "✓ " + msg.text)}`, width));
175
+ lines.push(...wrap(` ${msg.text}`, width, " "));
175
176
  break;
176
177
  case "error":
177
- lines.push(fit(` ${th.fg("error", "✗ " + msg.text)}`, width));
178
+ lines.push(...wrap(` ${msg.text}`, width, " "));
178
179
  break;
179
180
  case "status":
180
181
  lines.push(fit(` ${th.fg("dim", msg.text)}`, width));
@@ -186,12 +187,54 @@ export class MessageView {
186
187
  }
187
188
  }
188
189
 
190
+ /** Truncate a single line (for headers, tool calls, status — not text content) */
189
191
  function fit(line: string, width: number): string {
190
- // Strip any newlines that would create extra terminal lines and break layout math
191
192
  const clean = line.replace(/[\n\r]/g, " ");
192
193
  return truncateToWidth(clean, width, "…");
193
194
  }
194
195
 
196
+ /** Wrap a text line into multiple lines that fit within width.
197
+ * Uses ANSI-aware visibleWidth for correct wrapping with styled text.
198
+ * Returns array of lines, each guaranteed to fit within width. */
199
+ function wrap(line: string, width: number, indent: string = " "): string[] {
200
+ const clean = line.replace(/[\n\r]/g, " ");
201
+ // Fast path: already fits
202
+ if (visibleWidth(clean) <= width) return [clean];
203
+
204
+ // For styled text, we can't word-wrap by chars (ANSI codes break).
205
+ // Instead, strip to plain text, wrap that, then truncate styled lines.
206
+ const plain = stripAnsi(clean);
207
+ const indentW = visibleWidth(indent);
208
+ const firstW = width;
209
+ const contW = width - indentW;
210
+
211
+ const results: string[] = [];
212
+ let remaining = plain;
213
+ let isFirst = true;
214
+
215
+ while (remaining.length > 0) {
216
+ const maxW = isFirst ? firstW : contW;
217
+ if (remaining.length <= maxW) {
218
+ results.push(isFirst ? remaining : indent + remaining);
219
+ break;
220
+ }
221
+ // Find word break point
222
+ let breakAt = remaining.lastIndexOf(" ", maxW);
223
+ if (breakAt <= maxW * 0.3) breakAt = maxW; // No good break, hard cut
224
+ const chunk = remaining.slice(0, breakAt);
225
+ results.push(isFirst ? chunk : indent + chunk);
226
+ remaining = remaining.slice(breakAt).trimStart();
227
+ isFirst = false;
228
+ }
229
+
230
+ // Truncate each to be safe (handles edge cases)
231
+ return results.map(r => truncateToWidth(r, width, ""));
232
+ }
233
+
234
+ function stripAnsi(str: string): string {
235
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
236
+ }
237
+
195
238
  function pad(lines: string[], max: number): string[] {
196
239
  while (lines.length < max) lines.push("");
197
240
  return lines.slice(0, max);
@@ -1,15 +1,17 @@
1
1
  /**
2
- * squad-widget.ts — Simple string[] widget for squad status.
2
+ * squad-widget.ts — Compact widget for squad status above the editor.
3
3
  *
4
- * Uses the simple setWidget(key, string[]) API which pi-tui handles
5
- * reliably. Component-based widgets with variable height cause TUI
6
- * layout corruption.
4
+ * Uses a component factory for setWidget so it can access terminal width
5
+ * and truncate every line to prevent Text wrapping. Without truncation,
6
+ * long lines wrap inside the Text component, causing unpredictable
7
+ * widget height changes that corrupt the TUI diff renderer.
7
8
  *
8
9
  * Updates are pushed by calling requestUpdate() which rebuilds the
9
- * string[] and calls setWidget() again.
10
+ * lines and calls setWidget() again.
10
11
  */
11
12
 
12
- import { truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
14
+ import type { Component, TUI } from "@mariozechner/pi-tui";
13
15
  import type { Theme } from "@mariozechner/pi-coding-agent";
14
16
  import type { TaskStatus } from "../types.js";
15
17
  import * as store from "../store.js";
@@ -41,7 +43,10 @@ export interface SquadWidgetState {
41
43
 
42
44
  /**
43
45
  * Set up the squad widget. Returns control functions.
44
- * Uses simple string[] setWidget — no component factory.
46
+ *
47
+ * Uses a component factory so we can access TUI.terminal.columns and
48
+ * truncate every line, preventing Text word-wrapping that would cause
49
+ * unpredictable widget height changes.
45
50
  */
46
51
  export function setupSquadWidget(
47
52
  ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
@@ -56,22 +61,16 @@ export function setupSquadWidget(
56
61
  let renderTimer: ReturnType<typeof setTimeout> | null = null;
57
62
  /** Cache key to skip redundant setWidget calls */
58
63
  let lastCacheKey = "";
64
+ /** Last built lines — the factory re-uses these on each TUI render */
65
+ let currentLines: string[] = [];
59
66
 
60
- function render(): void {
61
- if (!state.enabled || !state.squadId) {
62
- ctx.ui.setWidget("squad-tasks", undefined);
63
- ctx.ui.setStatus("squad", undefined);
64
- return;
65
- }
67
+ function buildLines(): { lines: string[]; cacheKey: string; statusText: string } | null {
68
+ if (!state.enabled || !state.squadId) return null;
66
69
 
67
70
  const th = ctx.ui.theme;
68
71
  const tasks = store.loadAllTasks(state.squadId);
69
72
  const squad = store.loadSquad(state.squadId);
70
- if (!squad || tasks.length === 0) {
71
- ctx.ui.setWidget("squad-tasks", undefined);
72
- ctx.ui.setStatus("squad", undefined);
73
- return;
74
- }
73
+ if (!squad || tasks.length === 0) return null;
75
74
 
76
75
  const lines: string[] = [];
77
76
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
@@ -136,19 +135,77 @@ export function setupSquadWidget(
136
135
  lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
137
136
  }
138
137
 
139
- // Skip if nothing changed (avoid redundant setWidget calls that cause flicker)
140
138
  const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
141
- if (cacheKey === lastCacheKey) return;
142
- lastCacheKey = cacheKey;
143
139
 
144
- ctx.ui.setWidget("squad-tasks", lines);
145
-
146
- // Update status bar
147
140
  const statusText = squad.status === "done"
148
141
  ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
149
142
  : squad.status === "failed"
150
143
  ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
151
144
  : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
145
+
146
+ return { lines, cacheKey, statusText };
147
+ }
148
+
149
+ /**
150
+ * Widget component that renders pre-built lines truncated to terminal width.
151
+ * Each line is guaranteed to fit on exactly one terminal row — no wrapping.
152
+ * This keeps the widget height deterministic (= lines.length) so the TUI
153
+ * diff renderer never sees unexpected height changes.
154
+ */
155
+ class SquadWidgetComponent implements Component {
156
+ lines: string[] = [];
157
+
158
+ invalidate(): void { /* stateless — lines are rebuilt externally */ }
159
+
160
+ render(width: number): string[] {
161
+ // Truncate every line to terminal width so Text wrapping cannot
162
+ // add extra rows. One input line → exactly one output line.
163
+ return this.lines.map(line => {
164
+ const truncated = truncateToWidth(line, width);
165
+ // Pad to full width to prevent leftover characters from previous renders
166
+ const vw = visibleWidth(truncated);
167
+ const pad = Math.max(0, width - vw);
168
+ return truncated + " ".repeat(pad);
169
+ });
170
+ }
171
+ }
172
+
173
+ /** Persistent widget component instance — survives across setWidget calls */
174
+ let widgetComponent: SquadWidgetComponent | null = null;
175
+ let widgetInstalled = false;
176
+
177
+ function render(): void {
178
+ const result = buildLines();
179
+ if (!result) {
180
+ ctx.ui.setWidget("squad-tasks", undefined);
181
+ ctx.ui.setStatus("squad", undefined);
182
+ widgetInstalled = false;
183
+ return;
184
+ }
185
+
186
+ const { lines, cacheKey, statusText } = result;
187
+
188
+ // Skip if nothing changed (avoid redundant setWidget calls)
189
+ if (cacheKey === lastCacheKey) return;
190
+ lastCacheKey = cacheKey;
191
+
192
+ currentLines = lines;
193
+
194
+ if (!widgetInstalled) {
195
+ // Install the component factory once. On subsequent updates we just
196
+ // mutate widgetComponent.lines and requestRender — no setWidget churn.
197
+ widgetComponent = new SquadWidgetComponent();
198
+ widgetComponent.lines = lines;
199
+
200
+ const comp = widgetComponent;
201
+ ctx.ui.setWidget("squad-tasks", (_tui: TUI, _theme: Theme) => comp);
202
+ widgetInstalled = true;
203
+ } else if (widgetComponent) {
204
+ // Update lines in-place — the next TUI render picks them up.
205
+ // No setWidget call needed, avoiding component tree rebuild.
206
+ widgetComponent.lines = lines;
207
+ }
208
+
152
209
  ctx.ui.setStatus("squad", statusText);
153
210
  }
154
211
 
package/src/planner.ts CHANGED
@@ -33,6 +33,14 @@ export async function runPlanner(options: PlannerOptions): Promise<PlannerOutput
33
33
  const plannerDef = loadAgentDef("planner", cwd);
34
34
  const allAgents = loadAllAgentDefs(cwd).filter((a) => a.name !== "planner" && !a.disabled);
35
35
 
36
+ if (allAgents.length === 0) {
37
+ throw new Error(
38
+ "No squad agents found. Agent definitions should be in ~/.pi/squad/agents/. " +
39
+ "This usually means the extension failed to load on first run (check /reload for errors). " +
40
+ "Try: /reload, then run the squad command again."
41
+ );
42
+ }
43
+
36
44
  const agentList = allAgents
37
45
  .map((a) => `- **${a.name}** (${a.role}): ${a.description} [tags: ${a.tags.join(", ")}]`)
38
46
  .join("\n");
@@ -250,6 +258,13 @@ Respond with a JSON object (and nothing else outside the JSON):
250
258
  - Include a final QA/verification task if there are user-facing changes
251
259
  - Keep descriptions actionable — agent should know exactly what to build
252
260
  - Don't over-decompose — 3-7 tasks is usually right for most goals
261
+
262
+ ## Shared Contracts
263
+ - When tasks share an interface (e.g., API endpoints, database schema, data formats), create a design/architecture task first that defines the contract, and make consuming tasks depend on it
264
+ - If tasks run in parallel, they MUST depend on a shared spec task that defines the interface between them
265
+ - Frontend tasks should depend on backend API tasks — the frontend agent needs a running API to test against
266
+ - In each task description, specify the exact API paths, data schemas, and conventions that the agent should follow or create
267
+
253
268
  `;
254
269
  }
255
270
 
package/src/protocol.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { AgentDef, KnowledgeEntry, Squad, Task, TaskMessage } from "./types.js";
14
- import { loadAllKnowledge, loadAllTasks, loadMessages } from "./store.js";
14
+ import { loadAllKnowledge, loadAllTasks, loadMessages, loadOverview } from "./store.js";
15
15
 
16
16
  // ============================================================================
17
17
  // Squad Protocol (injected into every agent)
@@ -54,7 +54,9 @@ The squad system will detect this and route help.
54
54
  ## Rules
55
55
  - Stay focused on YOUR task — don't do work assigned to other agents
56
56
  - Read the dependency outputs below — don't redo completed work
57
+ - **Follow the Design Contract in the Squad Progress Document** — use the exact API paths, ports, schemas, and file names specified. Do NOT invent alternatives
57
58
  - Check the modified files list — coordinate before editing shared files
59
+ - When creating APIs, clearly document all endpoints, request/response shapes, and status codes in your completion output
58
60
  - Ask for help if stuck — don't spin for more than a few minutes
59
61
  - Verify your work before claiming done
60
62
  `;
@@ -114,6 +116,20 @@ ${sections.join("\n---\n\n")}
114
116
  `;
115
117
  }
116
118
 
119
+ // ============================================================================
120
+ // Squad Progress Overview
121
+ // ============================================================================
122
+
123
+ export function buildOverviewSection(squadId: string): string {
124
+ const content = loadOverview(squadId);
125
+ if (!content.trim()) return "";
126
+
127
+ return `# Squad Progress Document
128
+
129
+ ${content.trim()}
130
+ `;
131
+ }
132
+
117
133
  // ============================================================================
118
134
  // Sibling Awareness
119
135
  // ============================================================================
@@ -293,6 +309,7 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
293
309
  buildTaskSection(task),
294
310
  buildReworkContext(task, squadId),
295
311
  buildChainContext(task, allTasks, squadId),
312
+ buildOverviewSection(squadId),
296
313
  buildSiblingAwareness(task, allTasks, modifiedFiles),
297
314
  buildKnowledgeSection(squadId),
298
315
  buildQueuedMessages(queuedMessages),
package/src/scheduler.ts CHANGED
@@ -14,6 +14,7 @@ import { AgentPool, type AgentEvent } from "./agent-pool.js";
14
14
  import { Monitor } from "./monitor.js";
15
15
  import { Router } from "./router.js";
16
16
  import * as store from "./store.js";
17
+ import { debug, logError } from "./logger.js";
17
18
  import { buildAgentSystemPrompt } from "./protocol.js";
18
19
 
19
20
  // ============================================================================
@@ -55,6 +56,8 @@ export class Scheduler {
55
56
  private listeners: SchedulerEventListener[] = [];
56
57
  private skillPaths: string[] = [];
57
58
  private running = false;
59
+ /** Track spawn retries to allow one retry per task */
60
+ private spawnRetries = new Set<string>();
58
61
 
59
62
  /** Get the project cwd for this squad (from squad.json) */
60
63
  getProjectCwd(): string | undefined {
@@ -181,13 +184,13 @@ export class Scheduler {
181
184
  /** Find and spawn ready tasks up to concurrency limit */
182
185
  private async scheduleReadyTasks(): Promise<void> {
183
186
  if (!this.running) {
184
- console.error("[squad-scheduler] scheduleReadyTasks: not running, skipping");
187
+ debug("squad-scheduler", "scheduleReadyTasks: not running, skipping");
185
188
  return;
186
189
  }
187
190
 
188
191
  const squad = store.loadSquad(this.squadId);
189
192
  if (!squad || squad.status !== "running") {
190
- console.error(`[squad-scheduler] scheduleReadyTasks: squad status=${squad?.status}, skipping`);
193
+ debug("squad-scheduler", `scheduleReadyTasks: squad status=${squad?.status}, skipping`);
191
194
  return;
192
195
  }
193
196
 
@@ -195,22 +198,25 @@ export class Scheduler {
195
198
  const runningCount = this.pool.getRunningAgents().length;
196
199
  const available = squad.config.maxConcurrency - runningCount;
197
200
 
198
- console.error(`[squad-scheduler] scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
201
+ debug("squad-scheduler", `scheduleReadyTasks: ${tasks.length} tasks, ${runningCount} running, ${available} slots`);
199
202
 
200
203
  if (available <= 0) {
201
- console.error("[squad-scheduler] scheduleReadyTasks: no available slots");
204
+ debug("squad-scheduler", "scheduleReadyTasks: no available slots");
202
205
  return;
203
206
  }
204
207
 
205
208
  const ready = this.getReadyTasks(tasks);
206
- console.error(`[squad-scheduler] scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
209
+ debug("squad-scheduler", `scheduleReadyTasks: ${ready.length} ready tasks: ${ready.map(t => t.id).join(", ")}`);
207
210
  const toSpawn = ready.slice(0, available);
208
211
 
209
212
  for (const task of toSpawn) {
210
213
  try {
211
214
  await this.spawnAgentForTask(task, squad);
212
215
  } catch (error) {
213
- console.error(`[squad-scheduler] Failed to spawn ${task.id}: ${(error as Error).message}`);
216
+ logError("squad-scheduler", `Failed to spawn ${task.id}: ${(error as Error).message}`);
217
+ // MUST fail the task — otherwise it stays in_progress forever
218
+ // with no process (zombie state)
219
+ this.handleTaskFailed(task.id, `Spawn failed: ${(error as Error).message}`);
214
220
  }
215
221
  }
216
222
 
@@ -381,8 +387,29 @@ export class Scheduler {
381
387
  if (exitCode === 0 || hadMeaningfulWork) {
382
388
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
383
389
  } else {
384
- const stderr = event.data?.stderr || "";
385
- this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode}. ${stderr.slice(0, 500)}`);
390
+ // Agent died with no work done — retry once before failing.
391
+ // Transient failures (resource pressure, startup race) are common
392
+ // when multiple agents spawn simultaneously.
393
+ const retryKey = `spawn-retry:${event.taskId}`;
394
+ if (!this.spawnRetries.has(retryKey)) {
395
+ this.spawnRetries.add(retryKey);
396
+ const stderr = event.data?.stderr || "";
397
+ logError("squad-scheduler", `Agent ${event.agentName} died instantly (code ${exitCode}). Retrying in 2s... stderr: ${stderr.slice(0, 200)}`);
398
+ store.updateTaskStatus(this.squadId, event.taskId, "pending");
399
+ store.appendMessage(this.squadId, event.taskId, {
400
+ ts: store.now(),
401
+ from: "system",
402
+ type: "status",
403
+ text: `Agent crashed on startup (code ${exitCode}). Retrying...`,
404
+ });
405
+ // Delay retry to let resources settle
406
+ setTimeout(() => {
407
+ if (this.running) this.scheduleReadyTasks();
408
+ }, 2000);
409
+ } else {
410
+ const stderr = event.data?.stderr || "";
411
+ this.handleTaskFailed(event.taskId, `Agent exited with code ${exitCode} (retry exhausted). ${stderr.slice(0, 500)}`);
412
+ }
386
413
  this.updateContext();
387
414
  }
388
415
  // Skip the updateContext() below — handled in the branches above
@@ -430,6 +457,10 @@ export class Scheduler {
430
457
  text: "Task completed",
431
458
  });
432
459
 
460
+ // Reload task after status update so overview has output, completed time, etc.
461
+ const updatedTask = store.loadTask(this.squadId, taskId) || task;
462
+ this.appendTaskOverview(updatedTask, taskId, messages);
463
+
433
464
  this.emit({
434
465
  type: "task_completed",
435
466
  squadId: this.squadId,
@@ -444,7 +475,7 @@ export class Scheduler {
444
475
 
445
476
  if (!reworkCreated) {
446
477
  // Normal flow: auto-unblock dependents
447
- console.error(`[squad-scheduler] handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
478
+ debug("squad-scheduler", `handleTaskCompleted: ${taskId} done, auto-unblocking dependents`);
448
479
  this.autoUnblock(taskId);
449
480
 
450
481
  // If this is a passing retest, also unblock dependents of the ORIGINAL
@@ -459,19 +490,19 @@ export class Scheduler {
459
490
  rootId = root.retryOf;
460
491
  root = allTasks.find((t) => t.id === rootId);
461
492
  }
462
- console.error(`[squad-scheduler] Retest passed — also unblocking dependents of original: ${rootId}`);
493
+ debug("squad-scheduler", `Retest passed — also unblocking dependents of original: ${rootId}`);
463
494
  this.autoUnblock(rootId);
464
495
  }
465
496
  }
466
497
 
467
498
  // Schedule next ready tasks (may spawn new agents)
468
- console.error(`[squad-scheduler] handleTaskCompleted: scheduling next ready tasks`);
499
+ debug("squad-scheduler", `handleTaskCompleted: scheduling next ready tasks`);
469
500
  await this.scheduleReadyTasks();
470
501
 
471
502
  // Re-check squad completion with fresh data AFTER scheduling
472
503
  const freshTasks = store.loadAllTasks(this.squadId);
473
504
  const freshSquad = store.loadSquad(this.squadId);
474
- console.error(`[squad-scheduler] handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
505
+ debug("squad-scheduler", `handleTaskCompleted: final check — tasks: ${freshTasks.map(t => `${t.id}:${t.status}`).join(", ")}`);
475
506
  if (freshSquad) {
476
507
  this.checkSquadCompletion(freshTasks, freshSquad);
477
508
  }
@@ -594,7 +625,7 @@ export class Scheduler {
594
625
  const originalId = implTask.retryOf || implTask.id;
595
626
 
596
627
  if (retryCount >= squad.config.maxRetries) {
597
- console.error(`[squad-scheduler] Retry limit reached for ${originalId} (${retryCount}/${squad.config.maxRetries})`);
628
+ debug("squad-scheduler", `Retry limit reached for ${originalId} (${retryCount}/${squad.config.maxRetries})`);
598
629
  this.emit({
599
630
  type: "escalation",
600
631
  squadId: this.squadId,
@@ -663,7 +694,7 @@ export class Scheduler {
663
694
  message: `QA found issues in ${implTask.id}. Rework attempt ${fixN}.`,
664
695
  });
665
696
 
666
- console.error(`[squad-scheduler] Rework: ${reworkId} (${implTask.agent}) + retest ${retestId} (${task.agent})`);
697
+ debug("squad-scheduler", `Rework: ${reworkId} (${implTask.agent}) + retest ${retestId} (${task.agent})`);
667
698
  createdAny = true;
668
699
  }
669
700
 
@@ -905,6 +936,89 @@ export class Scheduler {
905
936
  .map((p: any) => p.text);
906
937
  return textParts.length > 0 ? textParts.join("\n") : null;
907
938
  }
939
+
940
+ // =========================================================================
941
+ // Overview Document — append task summary after completion
942
+ // =========================================================================
943
+
944
+ /**
945
+ * Build a concise markdown summary of a completed task and append it
946
+ * to the squad's OVERVIEW.md. This gives subsequent agents a narrative
947
+ * understanding of what was accomplished, issues hit, and decisions made.
948
+ */
949
+ private appendTaskOverview(
950
+ task: Task,
951
+ taskId: string,
952
+ messages: import("./types.js").TaskMessage[],
953
+ ): void {
954
+ try {
955
+ const lines: string[] = [];
956
+
957
+ // Header
958
+ lines.push(`## ${task.id}: ${task.title}`);
959
+ lines.push(``);
960
+ lines.push(`**Agent:** ${task.agent} | **Status:** ${task.status}`);
961
+ if (task.started && task.completed) {
962
+ const dur = new Date(task.completed).getTime() - new Date(task.started).getTime();
963
+ lines.push(`**Duration:** ${formatElapsed(dur)}`);
964
+ }
965
+ lines.push(``);
966
+
967
+ // Output
968
+ if (task.output) {
969
+ lines.push(`### Output`);
970
+ lines.push(task.output.slice(0, 800));
971
+ if (task.output.length > 800) lines.push(`... (truncated)`);
972
+ lines.push(``);
973
+ }
974
+
975
+ // Issues / errors encountered
976
+ const errors = messages.filter((m) => m.type === "error" && m.from !== "system");
977
+ if (errors.length > 0) {
978
+ lines.push(`### Issues Encountered`);
979
+ for (const err of errors.slice(-5)) {
980
+ lines.push(`- ${err.text.split("\n")[0].slice(0, 200)}`);
981
+ }
982
+ lines.push(``);
983
+ }
984
+
985
+ // Decisions — scan agent text for decision-like language
986
+ const decisionPatterns = /\b(decided|chose|approach|instead of|trade-?off|opted|going with|switched to|using .+ because)\b/i;
987
+ const agentTexts = messages.filter((m) => m.from === task.agent && m.type === "text");
988
+ const decisions: string[] = [];
989
+ for (const msg of agentTexts) {
990
+ for (const line of msg.text.split("\n")) {
991
+ if (decisionPatterns.test(line) && line.trim().length > 10) {
992
+ decisions.push(line.trim().slice(0, 200));
993
+ }
994
+ }
995
+ }
996
+ if (decisions.length > 0) {
997
+ lines.push(`### Decisions`);
998
+ for (const d of decisions.slice(-5)) {
999
+ lines.push(`- ${d}`);
1000
+ }
1001
+ lines.push(``);
1002
+ }
1003
+
1004
+ // Files modified
1005
+ const activity = this.pool.getActivity(taskId);
1006
+ if (activity && activity.modifiedFiles.size > 0) {
1007
+ lines.push(`### Files Modified`);
1008
+ for (const f of Array.from(activity.modifiedFiles).slice(0, 15)) {
1009
+ lines.push(`- ${f}`);
1010
+ }
1011
+ if (activity.modifiedFiles.size > 15) {
1012
+ lines.push(`- ... and ${activity.modifiedFiles.size - 15} more`);
1013
+ }
1014
+ lines.push(``);
1015
+ }
1016
+
1017
+ store.appendOverview(this.squadId, lines.join("\n"));
1018
+ } catch (err) {
1019
+ logError("squad-scheduler", `Failed to append overview for ${taskId}: ${(err as Error).message}`);
1020
+ }
1021
+ }
908
1022
  }
909
1023
 
910
1024
  function formatElapsed(ms: number): string {
package/src/store.ts CHANGED
@@ -405,6 +405,32 @@ export function loadAllKnowledge(squadId: string): KnowledgeEntry[] {
405
405
  ].sort((a, b) => a.ts.localeCompare(b.ts));
406
406
  }
407
407
 
408
+ // ============================================================================
409
+ // Overview Document (shared squad summary)
410
+ // ============================================================================
411
+
412
+ export function getOverviewPath(squadId: string): string {
413
+ return path.join(getSquadDir(squadId), "OVERVIEW.md");
414
+ }
415
+
416
+ export function loadOverview(squadId: string): string {
417
+ try {
418
+ return fs.readFileSync(getOverviewPath(squadId), "utf-8");
419
+ } catch {
420
+ return "";
421
+ }
422
+ }
423
+
424
+ export function appendOverview(squadId: string, section: string): void {
425
+ const filePath = getOverviewPath(squadId);
426
+ ensureDir(path.dirname(filePath));
427
+ const existing = loadOverview(squadId);
428
+ const content = existing
429
+ ? existing.trimEnd() + "\n\n---\n\n" + section.trim() + "\n"
430
+ : section.trim() + "\n";
431
+ fs.writeFileSync(filePath, content, "utf-8");
432
+ }
433
+
408
434
  // ============================================================================
409
435
  // Rework Helpers
410
436
  // ============================================================================