pi-squad 0.16.0 → 0.16.2

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
@@ -127,7 +127,11 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
127
127
 
128
128
  ### Agent Collaboration
129
129
 
130
- **Chain context**: When task A completes, its output is injected into task B's system prompt. Downstream agents know what was built.
130
+ **Chain context**: When task A completes, its complete output is injected into downstream prompts across the full dependency-ancestor closure (ancestors first, diamond dependencies deduplicated), not only the final direct edge. Integration and QA tasks therefore receive the original contracts their inputs were built from.
131
+
132
+ **Completed-agent replies**: An `@agent` request aimed at an agent whose task already finished is answered immediately from that agent's durable task output. It is never queued for a nonexistent future spawn, and a blocker resolved this way does not escalate to the human.
133
+
134
+ **Report-only work**: Planning/review agents may complete with a substantive assistant artifact even when they needed no tool call. Their output still passes through mandatory independent orchestrator review.
131
135
 
132
136
  **Shared filesystem**: All agents work in the same project directory. Upstream agents create files, downstream agents read and modify them.
133
137
 
@@ -135,6 +139,8 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
135
139
 
136
140
  **@mention routing**: Agents write `@frontend what token format?` in their output. The router delivers it in real-time via RPC `steer()`.
137
141
 
142
+ **Main-session steering**: `squad_message` resolves an agent name to its live task, durably records the full message, and writes the documented JSONL RPC command `{"type":"steer","message":"..."}` to the child. pi-squad waits for Pi's final `agent_settled` event before closing the child; low-level `agent_end` no longer kills queued steering/follow-up continuations.
143
+
138
144
  ### Smart Planner
139
145
 
140
146
  The planner creates task breakdowns with proper dependency ordering:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/agent-pool.ts CHANGED
@@ -270,16 +270,14 @@ export class AgentPool {
270
270
  async steer(taskId: string, message: string): Promise<boolean> {
271
271
  const agent = this.agents.get(taskId);
272
272
  if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
273
- this.sendRpcCommand(agent.process, { type: "steer", message });
274
- return true;
273
+ return this.sendRpcCommand(agent.process, { type: "steer", message });
275
274
  }
276
275
 
277
276
  /** Queue a follow-up message for after the current turn */
278
277
  async followUp(taskId: string, message: string): Promise<boolean> {
279
278
  const agent = this.agents.get(taskId);
280
279
  if (!agent || agent.aborted || agent.process.exitCode !== null) return false;
281
- this.sendRpcCommand(agent.process, { type: "follow_up", message });
282
- return true;
280
+ return this.sendRpcCommand(agent.process, { type: "follow_up", message });
283
281
  }
284
282
 
285
283
  /** Abort the current operation */
@@ -301,7 +299,7 @@ export class AgentPool {
301
299
  agent.process.kill("SIGTERM");
302
300
  // Force kill after 5s
303
301
  const timer = setTimeout(() => {
304
- if (!agent.process.killed) agent.process.kill("SIGKILL");
302
+ if (agent.process.exitCode === null) agent.process.kill("SIGKILL");
305
303
  }, 5000);
306
304
  await new Promise<void>((resolve) => {
307
305
  agent.process.on("exit", () => {
@@ -337,9 +335,17 @@ export class AgentPool {
337
335
  // Internal
338
336
  // =========================================================================
339
337
 
340
- private sendRpcCommand(proc: ChildProcess, command: Record<string, unknown>): void {
341
- if (!proc.stdin || proc.stdin.destroyed) return;
342
- proc.stdin.write(serializeJsonLine(command));
338
+ private sendRpcCommand(proc: ChildProcess, command: Record<string, unknown>): boolean {
339
+ if (!proc.stdin || proc.stdin.destroyed) return false;
340
+ try {
341
+ // Writable.write(false) means backpressure, not rejection; the bytes are
342
+ // still buffered. Reaching write() without throwing means the JSONL
343
+ // command was handed to the child stream.
344
+ proc.stdin.write(serializeJsonLine(command));
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
343
349
  }
344
350
 
345
351
  private handleRpcEvent(agent: AgentProcess, event: any): void {
@@ -394,10 +400,12 @@ export class AgentPool {
394
400
  data: event,
395
401
  });
396
402
  } else if (event.type === "agent_end") {
397
- // Pi RPC mode emits agent_end when the agent loop finishes.
398
- // The RPC process stays alive waiting for more commands,
399
- // so we need to explicitly kill it and emit our own agent_end.
400
- debug("squad-pool", `agent_end from RPC: ${agent.agentName} (task: ${agent.taskId})`);
403
+ // agent_end is one low-level run. Pi may still process queued steer,
404
+ // follow-up, retry, or compaction continuations. Killing here races and
405
+ // drops main-session steering. agent_settled is the final lifecycle edge.
406
+ debug("squad-pool", `agent_end from RPC (awaiting settled): ${agent.agentName} (task: ${agent.taskId})`);
407
+ } else if (event.type === "agent_settled") {
408
+ debug("squad-pool", `agent_settled from RPC: ${agent.agentName} (task: ${agent.taskId})`);
401
409
  // Mark the guard to prevent double-emit from proc.on("exit")
402
410
  const guardFn = (agent as any)._agentEndEmitted;
403
411
  if (guardFn) guardFn();
@@ -417,11 +425,12 @@ export class AgentPool {
417
425
  filesModified: endActivity.modifiedFiles.size,
418
426
  },
419
427
  });
420
- // Kill the RPC process since the agent's work is done
428
+ // The session is fully settled, so the RPC process can now close.
421
429
  agent.process.kill("SIGTERM");
422
- setTimeout(() => {
423
- if (!agent.process.killed) agent.process.kill("SIGKILL");
430
+ const forceKill = setTimeout(() => {
431
+ if (agent.process.exitCode === null) agent.process.kill("SIGKILL");
424
432
  }, 3000);
433
+ forceKill.unref();
425
434
  } else if (event.type === "error") {
426
435
  this.emit({
427
436
  type: "error",
package/src/plan-rules.ts CHANGED
@@ -53,6 +53,29 @@ function isQaLikeTask(task: PlanTaskInput): boolean {
53
53
  return /\b(qa|test|tests|testing|verif\w*|review|audit)\b/.test(hay);
54
54
  }
55
55
 
56
+ /** Task IDs explicitly named in a "depend on ..." Context sentence. */
57
+ function describedDependencies(task: PlanTaskInput, knownIds: Set<string>): string[] {
58
+ const refs = new Set<string>();
59
+ for (const clause of task.description.matchAll(/\bdepend(?:s|ing)?\s+on\b([^.;\n]*)/gi)) {
60
+ for (const quoted of clause[1].matchAll(/`([^`]+)`/g)) {
61
+ if (knownIds.has(quoted[1])) refs.add(quoted[1]);
62
+ }
63
+ }
64
+ return [...refs];
65
+ }
66
+
67
+ function dependencyClosure(task: PlanTaskInput, byId: Map<string, PlanTaskInput>): Set<string> {
68
+ const closure = new Set<string>();
69
+ const visit = (id: string): void => {
70
+ if (closure.has(id)) return;
71
+ closure.add(id);
72
+ const dependency = byId.get(id);
73
+ if (dependency) for (const ancestor of dependency.depends) visit(ancestor);
74
+ };
75
+ for (const dependency of task.depends) visit(dependency);
76
+ return closure;
77
+ }
78
+
56
79
  /**
57
80
  * Validate a plan's structure. Errors block squad creation;
58
81
  * warnings are returned to the plan author for correction.
@@ -125,8 +148,16 @@ export function validatePlan(tasks: PlanTaskInput[]): PlanValidation {
125
148
  for (const t of tasks) {
126
149
  if (!t.description || t.description.trim().length === 0) {
127
150
  warnings.push(`Task "${t.id}" has no description — the agent only gets the title.`);
128
- } else if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
129
- warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
151
+ } else {
152
+ if (!/verif|test|check|curl|run\b|npm |pnpm |cargo |pytest|tsc\b/i.test(t.description)) {
153
+ warnings.push(`Task "${t.id}" description has no Verify criterion — the agent won't know how to prove it's done.`);
154
+ }
155
+ const closure = dependencyClosure(t, byId);
156
+ for (const described of describedDependencies(t, ids)) {
157
+ if (!closure.has(described)) {
158
+ warnings.push(`Task "${t.id}" says it depends on "${described}", but that task is absent from its formal dependency closure.`);
159
+ }
160
+ }
130
161
  }
131
162
  }
132
163
 
package/src/protocol.ts CHANGED
@@ -90,9 +90,10 @@ function buildChainContext(task: Task, allTasks: Task[], squadId: string): strin
90
90
 
91
91
  const sections: string[] = [];
92
92
 
93
- for (const depId of task.depends) {
94
- const dep = allTasks.find((t) => t.id === depId);
95
- if (!dep || dep.status !== "done") continue;
93
+ // A downstream integration/QA task needs the contracts its direct inputs
94
+ // were built from, not only the last edge in the DAG. Walk the complete
95
+ // ancestor closure, ancestors first, and deduplicate diamond dependencies.
96
+ for (const dep of completedDependencyClosure(task, allTasks)) {
96
97
 
97
98
  let section = `## ${dep.id} (done by ${dep.agent})\n**${dep.title}**\n`;
98
99
  if (dep.output) {
@@ -119,6 +120,24 @@ ${sections.join("\n---\n\n")}
119
120
  `;
120
121
  }
121
122
 
123
+ function completedDependencyClosure(task: Task, allTasks: Task[]): Task[] {
124
+ const byId = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
125
+ const seen = new Set<string>();
126
+ const ordered: Task[] = [];
127
+
128
+ const visit = (id: string): void => {
129
+ if (seen.has(id)) return;
130
+ seen.add(id);
131
+ const dependency = byId.get(id);
132
+ if (!dependency) return;
133
+ for (const ancestorId of dependency.depends) visit(ancestorId);
134
+ if (dependency.status === "done") ordered.push(dependency);
135
+ };
136
+
137
+ for (const dependencyId of task.depends) visit(dependencyId);
138
+ return ordered;
139
+ }
140
+
122
141
  // ============================================================================
123
142
  // Sibling Awareness
124
143
  // ============================================================================
package/src/router.ts CHANGED
@@ -47,12 +47,18 @@ export class Router {
47
47
  processMessage(taskId: string, fromAgent: string, text: string): void {
48
48
  // Parse @mentions
49
49
  const mentions = this.parseMentions(text, fromAgent);
50
+ let resolvedFromDurableOutput = false;
50
51
  for (const mention of mentions) {
51
- this.routeMention(taskId, fromAgent, mention.target, mention.message);
52
+ const resolved = this.routeMention(taskId, fromAgent, mention.target, mention.message);
53
+ // Only suppress escalation when the resolved mention itself expressed
54
+ // the blocker; an unrelated completed-agent FYI must not hide another block.
55
+ resolvedFromDurableOutput =
56
+ (resolved && this.isBlockSignal(mention.message)) || resolvedFromDurableOutput;
52
57
  }
53
58
 
54
- // Detect block signals
55
- if (this.isBlockSignal(text)) {
59
+ // Do not wake the human for a blocker we immediately resolved from a
60
+ // completed agent's durable task output.
61
+ if (this.isBlockSignal(text) && !resolvedFromDurableOutput) {
56
62
  for (const listener of this.escalationListeners) {
57
63
  listener(taskId, fromAgent, this.extractBlockReason(text));
58
64
  }
@@ -67,7 +73,7 @@ export class Router {
67
73
  fromAgent: string,
68
74
  targetAgent: string,
69
75
  message: string,
70
- ): void {
76
+ ): boolean {
71
77
  // Log the mention in the source task
72
78
  store.appendMessage(this.squadId, sourceTaskId, {
73
79
  ts: store.now(),
@@ -93,8 +99,41 @@ export class Router {
93
99
  to: targetAgent,
94
100
  text: message,
95
101
  });
96
- } else {
97
- // Target not running — queue for later
102
+ return false;
103
+ }
104
+
105
+ const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
106
+ const hasFutureRun = targetTasks.some((task) =>
107
+ task.status === "pending" || task.status === "blocked" || task.status === "suspended" || task.status === "in_progress",
108
+ );
109
+ if (!hasFutureRun) {
110
+ const completed = targetTasks.filter((task) => task.status === "done" && task.output);
111
+ if (completed.length > 0) {
112
+ const durableReply = [
113
+ `[squad] @${targetAgent} has completed and is no longer running. Durable completed output:`,
114
+ ...completed.map((task) => `\n## ${task.id}: ${task.title}\n${task.output}`),
115
+ ].join("\n");
116
+ const reply = {
117
+ ts: store.now(),
118
+ from: targetAgent,
119
+ type: "reply" as const,
120
+ to: fromAgent,
121
+ text: durableReply,
122
+ };
123
+ store.appendMessage(this.squadId, sourceTaskId, reply);
124
+ if (this.pool.isRunning(sourceTaskId)) {
125
+ this.pool.steer(sourceTaskId, durableReply);
126
+ } else {
127
+ this.pool.queueMessage(fromAgent, reply);
128
+ }
129
+ return true;
130
+ }
131
+ }
132
+
133
+ // Target has future work and may spawn again — queue for that run. If the
134
+ // target is terminal with no output, do not create an undeliverable queue;
135
+ // leave the blocker unresolved so it escalates to the main orchestrator.
136
+ if (hasFutureRun) {
98
137
  this.pool.queueMessage(targetAgent, {
99
138
  ts: store.now(),
100
139
  from: fromAgent,
@@ -103,6 +142,7 @@ export class Router {
103
142
  text: message,
104
143
  });
105
144
  }
145
+ return false;
106
146
  }
107
147
 
108
148
  /**
package/src/scheduler.ts CHANGED
@@ -622,15 +622,18 @@ export class Scheduler {
622
622
  const turnCount = event.data?.turnCount ?? 0;
623
623
  const toolCallCount = event.data?.toolCallCount ?? 0;
624
624
 
625
- // Agent must have done real work: at least 1 turn AND at least 1 tool call.
626
- // An agent that exits cleanly but with 0 turns/tools did nothing —
627
- // likely hit a rate limit or API error. Treat as crash, not success.
628
- const hadMeaningfulWork = turnCount > 0 && toolCallCount > 0;
625
+ // Tool use is strong evidence of work, but planning/review tasks can
626
+ // legitimately deliver a substantive report without calling a tool.
627
+ // Accept durable assistant output; the mandatory main-orchestrator
628
+ // review gate still decides whether the work satisfies the contract.
629
+ const hasSubstantiveOutput = store.loadMessages(this.squadId, event.taskId)
630
+ .some((message) => message.from === event.agentName && message.type === "text" && message.text.trim().length > 0);
631
+ const hadMeaningfulWork = turnCount > 0 && (toolCallCount > 0 || hasSubstantiveOutput);
629
632
  if (hadMeaningfulWork) {
630
633
  this.handleTaskCompleted(event.taskId).then(() => this.updateContext());
631
634
  } else {
632
- // Agent exited without doing real work (0 turns or 0 tool calls).
633
- // Common causes: rate limit, API error, resource pressure, crash.
635
+ // Agent exited without a completed turn, tool work, or substantive
636
+ // assistant artifact. Common causes: rate limit/API/resource failure.
634
637
  // Retry once before failing.
635
638
  const retryKey = `spawn-retry:${event.taskId}`;
636
639
  if (!this.spawnRetries.has(retryKey)) {
@@ -638,7 +641,7 @@ export class Scheduler {
638
641
  const stderr = event.data?.stderr || "";
639
642
  const reason = turnCount === 0
640
643
  ? `exited with 0 turns (likely rate limit or API error)`
641
- : `exited with ${turnCount} turns but 0 tool calls (no work done)`;
644
+ : `exited with ${turnCount} turns but no tool calls or substantive output`;
642
645
  logError("squad-scheduler", `Agent ${event.agentName} ${reason}, code=${exitCode}. Retrying in 2s... stderr: ${stderr}`);
643
646
  store.updateTaskStatus(this.squadId, event.taskId, "pending");
644
647
  store.appendMessage(this.squadId, event.taskId, {