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 +2 -0
- package/package.json +1 -1
- package/src/agent-pool.ts +24 -15
- package/src/index.ts +22 -2
- package/src/scheduler.ts +52 -15
- package/src/skills/squad-supervisor/SKILL.md +2 -1
- package/src/types.ts +2 -0
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
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 (
|
|
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>):
|
|
341
|
-
if (!proc.stdin || proc.stdin.destroyed) return;
|
|
342
|
-
|
|
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
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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
|
-
//
|
|
428
|
+
// The session is fully settled, so the RPC process can now close.
|
|
421
429
|
agent.process.kill("SIGTERM");
|
|
422
|
-
setTimeout(() => {
|
|
423
|
-
if (
|
|
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
|
|
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
|
|
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: "
|
|
1149
|
+
from: "orchestrator",
|
|
1126
1150
|
type: "message",
|
|
1127
1151
|
text: message,
|
|
1152
|
+
expectsReply,
|
|
1128
1153
|
});
|
|
1129
1154
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
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
|
}
|