pi-squad 0.16.1 → 0.16.3

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
@@ -139,6 +139,8 @@ Bundled agent definitions are copied to `~/.pi/squad/agents/` on first run. Edit
139
139
 
140
140
  **@mention routing**: Agents write `@frontend what token format?` in their output. The router delivers it in real-time via RPC `steer()`.
141
141
 
142
+ **Main-session request/reply**: `squad_message` resolves an agent name to its live task, durably records the full request, and writes the documented JSONL RPC command `{"type":"steer","message":"..."}` to the child. By default (`expectReply: true`), the next substantive agent response is durably marked as the reply, pushed into the main Pi session, and wakes it via follow-up delivery; ordinary later activity remains panel-only. The pending reply marker survives scheduler reconstruction. Use `expectReply: false` for fire-and-forget steering. pi-squad waits for Pi's final `agent_settled` event before closing the child; low-level `agent_end` never kills queued continuations.
143
+
142
144
  ### Smart Planner
143
145
 
144
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.1",
3
+ "version": "0.16.3",
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/index.ts CHANGED
@@ -504,11 +504,12 @@ export default function (pi: ExtensionAPI) {
504
504
  pi.registerTool({
505
505
  name: "squad_message",
506
506
  label: "Squad Message",
507
- description: "Send a message to a specific agent or task in the running squad.",
507
+ description: "Send a request to a specific agent/task. The agent's next substantive assistant response is durably forwarded back to the main Pi session and wakes it.",
508
508
  parameters: Type.Object({
509
509
  message: Type.String({ description: "Message to send" }),
510
510
  taskId: Type.Optional(Type.String({ description: "Target task ID" })),
511
511
  agent: Type.Optional(Type.String({ description: "Target agent name" })),
512
+ expectReply: Type.Optional(Type.Boolean({ description: "Forward the agent's next substantive response back to main Pi and wake it (default true)" })),
512
513
  }),
513
514
 
514
515
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -528,7 +529,7 @@ export default function (pi: ExtensionAPI) {
528
529
  return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
529
530
  }
530
531
 
531
- const sent = await activeScheduler!.sendHumanMessage(taskId, params.message);
532
+ const sent = await activeScheduler!.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
532
533
  const status = sent ? "delivered" : "queued for when the agent starts";
533
534
 
534
535
  return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
@@ -627,6 +628,14 @@ export default function (pi: ExtensionAPI) {
627
628
  forceWidgetUpdate();
628
629
  break;
629
630
  }
631
+ case "orchestrator_reply": {
632
+ pi.sendMessage({
633
+ customType: "squad-agent-reply",
634
+ content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
635
+ display: true,
636
+ }, { triggerTurn: true, deliverAs: "followUp" });
637
+ break;
638
+ }
630
639
  case "escalation": {
631
640
  pi.sendMessage({
632
641
  customType: "squad-escalation",
@@ -1708,6 +1717,17 @@ async function startSquad(
1708
1717
  break;
1709
1718
  }
1710
1719
 
1720
+ case "orchestrator_reply": {
1721
+ // Push the complete requested response back into the main session and
1722
+ // wake it; ordinary task activity remains panel-only.
1723
+ pi.sendMessage({
1724
+ customType: "squad-agent-reply",
1725
+ content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
1726
+ display: true,
1727
+ }, { triggerTurn: true, deliverAs: "followUp" });
1728
+ break;
1729
+ }
1730
+
1711
1731
  case "escalation": {
1712
1732
  // Escalation — agent needs help. triggerTurn so the main agent
1713
1733
  // can respond and relay help.
package/src/scheduler.ts CHANGED
@@ -34,6 +34,7 @@ export type SchedulerEventType =
34
34
  | "task_rework"
35
35
  | "squad_review_required"
36
36
  | "squad_failed"
37
+ | "orchestrator_reply"
37
38
  | "escalation"
38
39
  | "activity";
39
40
 
@@ -568,6 +569,26 @@ export class Scheduler {
568
569
  // presentation layers may viewport them, but source data must never truncate.
569
570
  text,
570
571
  });
572
+
573
+ // A squad_message is a request/response channel, not fire-and-forget.
574
+ // Persist an acknowledgement marker before emitting so restart/focus
575
+ // changes cannot forward the same response twice.
576
+ if (this.hasPendingOrchestratorRequest(event.taskId)) {
577
+ store.appendMessage(this.squadId, event.taskId, {
578
+ ts: store.now(),
579
+ from: event.agentName,
580
+ type: "reply",
581
+ to: "orchestrator",
582
+ text: "Response forwarded to main orchestrator",
583
+ });
584
+ this.emit({
585
+ type: "orchestrator_reply",
586
+ squadId: this.squadId,
587
+ taskId: event.taskId,
588
+ agentName: event.agentName,
589
+ message: text,
590
+ });
591
+ }
571
592
  }
572
593
 
573
594
  // Track usage
@@ -1118,31 +1139,47 @@ export class Scheduler {
1118
1139
  // External Actions
1119
1140
  // =========================================================================
1120
1141
 
1121
- /** Send a human message to a task's agent */
1122
- async sendHumanMessage(taskId: string, message: string): Promise<boolean> {
1142
+ /** Send a main-orchestrator request to a task's agent and await its next reply. */
1143
+ async sendHumanMessage(taskId: string, message: string, expectsReply = true): Promise<boolean> {
1144
+ const task = store.loadTask(this.squadId, taskId);
1145
+ if (!task) return false;
1146
+
1123
1147
  store.appendMessage(this.squadId, taskId, {
1124
1148
  ts: store.now(),
1125
- from: "human",
1149
+ from: "orchestrator",
1126
1150
  type: "message",
1127
1151
  text: message,
1152
+ expectsReply,
1128
1153
  });
1129
1154
 
1130
- if (this.pool.isRunning(taskId)) {
1131
- return this.pool.steer(taskId, `[squad] Human: ${message}`);
1132
- }
1133
- // Queue for when agent spawns
1134
- const task = store.loadTask(this.squadId, taskId);
1135
- if (task) {
1136
- this.pool.queueMessage(task.agent, {
1137
- ts: store.now(),
1138
- from: "human",
1139
- type: "message",
1140
- text: message,
1141
- });
1155
+ const request = expectsReply
1156
+ ? `[squad] Main orchestrator requests a direct response:\n${message}\n\nReply directly in your next assistant message. That complete message will be forwarded automatically to the main session.`
1157
+ : `[squad] Main orchestrator message:\n${message}`;
1158
+ if (this.pool.isRunning(taskId) && await this.pool.steer(taskId, request)) {
1159
+ return true;
1142
1160
  }
1161
+
1162
+ // Queue for when the assigned agent starts/restarts. The durable request
1163
+ // marker lets a reconstructed scheduler still forward the eventual reply.
1164
+ this.pool.queueMessage(task.agent, {
1165
+ ts: store.now(),
1166
+ from: "orchestrator",
1167
+ type: "message",
1168
+ text: expectsReply ? `${message}\n\n[Direct response required by main orchestrator]` : message,
1169
+ expectsReply,
1170
+ });
1143
1171
  return false;
1144
1172
  }
1145
1173
 
1174
+ private hasPendingOrchestratorRequest(taskId: string): boolean {
1175
+ let pending = false;
1176
+ for (const message of store.loadMessages(this.squadId, taskId)) {
1177
+ if (message.from === "orchestrator" && message.expectsReply) pending = true;
1178
+ if (message.type === "reply" && message.to === "orchestrator") pending = false;
1179
+ }
1180
+ return pending;
1181
+ }
1182
+
1146
1183
  /** Pause a running task */
1147
1184
  async pauseTask(taskId: string): Promise<void> {
1148
1185
  if (this.pool.isRunning(taskId)) {
@@ -130,7 +130,8 @@ Common escalation patterns:
130
130
  Use `squad_message` with:
131
131
  - `taskId` — target a specific task
132
132
  - `agent` — target whichever task an agent is working on
133
- - `message` — your instruction or answer
133
+ - `message` — your instruction or question
134
+ - `expectReply` — defaults to `true`; the agent's next substantive response is durably pushed back into the main Pi session and wakes you. Set `false` only for fire-and-forget steering.
134
135
 
135
136
  Keep messages **specific and actionable**:
136
137
  - Good: "Use RS256 for JWT signing. The secret is in env var JWT_SECRET."
package/src/types.ts CHANGED
@@ -165,6 +165,8 @@ export interface TaskMessage {
165
165
  type: MessageType;
166
166
  text: string;
167
167
  to?: string;
168
+ /** Main-orchestrator message expects the next substantive agent response. */
169
+ expectsReply?: boolean;
168
170
  name?: string;
169
171
  args?: Record<string, unknown>;
170
172
  }