pi-squad 0.16.2 → 0.16.4
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 +18 -12
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +161 -158
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/router.ts +37 -35
- package/src/scheduler.ts +292 -65
- package/src/skills/squad-supervisor/SKILL.md +2 -1
- package/src/store.ts +195 -1
- package/src/types.ts +26 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
14
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import type { TaskStatus } from "../types.js";
|
|
16
|
+
import type { TaskMessage, TaskStatus } from "../types.js";
|
|
17
17
|
import * as store from "../store.js";
|
|
18
18
|
|
|
19
19
|
function statusIcon(status: TaskStatus, th: Theme): string {
|
|
@@ -76,14 +76,33 @@ export function setupSquadWidget(
|
|
|
76
76
|
const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
|
|
77
77
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
|
78
78
|
const elapsed = Date.now() - new Date(squad.created).getTime();
|
|
79
|
+
const taskMessages = new Map<string, TaskMessage[]>();
|
|
80
|
+
const recentOrchestratorByTask = new Map<string, TaskMessage>();
|
|
81
|
+
const recentMessageKeys: string[] = [];
|
|
82
|
+
for (const task of tasks) {
|
|
83
|
+
if (task.status !== "in_progress") continue;
|
|
84
|
+
const messages = store.loadMessages(state.squadId, task.id);
|
|
85
|
+
taskMessages.set(task.id, messages);
|
|
86
|
+
const latest = messages.at(-1);
|
|
87
|
+
recentMessageKeys.push(`${task.id}:${messages.length}:${latest?.id || latest?.ts || "none"}`);
|
|
88
|
+
const recentOrchestrator = [...messages.slice(-5)].reverse().find((message) =>
|
|
89
|
+
message.from === "orchestrator" &&
|
|
90
|
+
(message.type === "text" || message.type === "message" ||
|
|
91
|
+
message.type === "reply" || message.type === "mention"),
|
|
92
|
+
);
|
|
93
|
+
if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
|
|
94
|
+
}
|
|
79
95
|
|
|
80
96
|
const sIcon = squad.status === "done" ? th.fg("success", "✓")
|
|
81
97
|
: squad.status === "failed" ? th.fg("error", "✗")
|
|
82
98
|
: squad.status === "review" ? th.fg("warning", "◆")
|
|
83
99
|
: th.fg("warning", "⏳");
|
|
84
100
|
|
|
101
|
+
const orchestratorSignal = recentOrchestratorByTask.size > 0
|
|
102
|
+
? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
|
|
103
|
+
: "";
|
|
85
104
|
lines.push(
|
|
86
|
-
`${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
105
|
+
`${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
|
|
87
106
|
`${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
|
|
88
107
|
`${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
|
|
89
108
|
`${th.fg("dim", formatElapsed(elapsed))} ` +
|
|
@@ -111,13 +130,19 @@ export function setupSquadWidget(
|
|
|
111
130
|
const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
|
|
112
131
|
const timeColor = runningFor > 180_000 ? "warning" : "dim";
|
|
113
132
|
line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
|
|
114
|
-
const messages =
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
const messages = taskMessages.get(task.id) || [];
|
|
134
|
+
const recentOrchestrator = recentOrchestratorByTask.get(task.id);
|
|
135
|
+
if (recentOrchestrator) {
|
|
136
|
+
const preview = recentOrchestrator.text.split("\n")[0].slice(0, 24);
|
|
137
|
+
line += ` ${th.fg("accent", "← ORCH")} ${th.fg("dim", preview)}`;
|
|
138
|
+
} else {
|
|
139
|
+
const lastTool = [...messages].reverse().find(m => m.type === "tool");
|
|
140
|
+
if (lastTool) {
|
|
141
|
+
const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
|
|
142
|
+
const detail = rawDetail.split("\n")[0]; // first line only
|
|
143
|
+
const toolStr = `→ ${lastTool.name || lastTool.text}`;
|
|
144
|
+
line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
|
|
145
|
+
}
|
|
121
146
|
}
|
|
122
147
|
} else if (task.status === "blocked") {
|
|
123
148
|
const blockers = task.depends.filter((d) => {
|
|
@@ -136,7 +161,7 @@ export function setupSquadWidget(
|
|
|
136
161
|
lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
|
|
137
162
|
}
|
|
138
163
|
|
|
139
|
-
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
|
|
164
|
+
const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
|
|
140
165
|
|
|
141
166
|
const statusText = squad.status === "done"
|
|
142
167
|
? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
|
package/src/panel/task-list.ts
CHANGED
|
@@ -200,15 +200,25 @@ export class TaskListView {
|
|
|
200
200
|
const recent = messages.slice(-3);
|
|
201
201
|
|
|
202
202
|
for (const msg of recent) {
|
|
203
|
-
|
|
203
|
+
const isConversation = msg.type === "text" || msg.type === "message" ||
|
|
204
|
+
msg.type === "reply" || msg.type === "mention";
|
|
205
|
+
if (msg.from === "orchestrator" && isConversation) {
|
|
206
|
+
const preview = msg.text.split("\n")[0];
|
|
207
|
+
lines.push(truncateToWidth(
|
|
208
|
+
` ${th.fg("accent", "ORCHESTRATOR")} ${th.fg("dim", `"${preview}"`)}`,
|
|
209
|
+
width,
|
|
210
|
+
"…",
|
|
211
|
+
));
|
|
212
|
+
} else if (msg.type === "tool") {
|
|
204
213
|
const toolStr = `→ ${msg.name || msg.text}`;
|
|
205
214
|
const rawArgs = (msg.args?.path || msg.args?.command || "").toString();
|
|
206
215
|
const argsStr = rawArgs.split("\n")[0]; // first line only
|
|
207
216
|
const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
|
|
208
217
|
lines.push(truncateToWidth(` ${th.fg("muted", preview)}`, width, "…"));
|
|
209
|
-
} else if (
|
|
218
|
+
} else if (isConversation && msg.from !== "system") {
|
|
210
219
|
const preview = msg.text.split("\n")[0];
|
|
211
|
-
|
|
220
|
+
const sender = msg.from === "human" ? "YOU" : msg.from;
|
|
221
|
+
lines.push(truncateToWidth(` ${th.fg("dim", `${sender}: "${preview}"`)}`, width, "…"));
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
package/src/router.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* router.ts — @mention parsing and cross-agent message delivery.
|
|
3
3
|
*
|
|
4
4
|
* Parses assistant text for @agentname patterns.
|
|
5
|
-
* Routes messages to target
|
|
6
|
-
* or
|
|
5
|
+
* Routes messages to exact target tasks via steer() if running,
|
|
6
|
+
* or a durable task-owned mailbox for delivery on next spawn.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AgentPool } from "./agent-pool.js";
|
|
@@ -83,30 +83,41 @@ export class Router {
|
|
|
83
83
|
text: message,
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.pool.steer(targetTaskId, steerMessage);
|
|
86
|
+
// Agent-name mentions are safe only when exactly one live task owns that
|
|
87
|
+
// role. Multiple concurrent same-role tasks must never receive guessed mail.
|
|
88
|
+
const liveTargets = store.loadAllTasks(this.squadId).filter(
|
|
89
|
+
(task) => task.agent === targetAgent && this.pool.isRunning(task.id),
|
|
90
|
+
);
|
|
91
|
+
const targetTaskId = liveTargets.length === 1 ? liveTargets[0].id : undefined;
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
store.
|
|
93
|
+
if (targetTaskId) {
|
|
94
|
+
const queued = store.queueTaskMessage(this.squadId, targetTaskId, {
|
|
96
95
|
ts: store.now(),
|
|
97
96
|
from: fromAgent,
|
|
98
97
|
type: "mention",
|
|
99
98
|
to: targetAgent,
|
|
100
99
|
text: message,
|
|
101
100
|
});
|
|
101
|
+
const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
|
|
102
|
+
void this.pool.steer(targetTaskId, steerMessage).then((delivered) => {
|
|
103
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, targetTaskId, [queued.id]);
|
|
104
|
+
});
|
|
102
105
|
return false;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
|
|
106
|
-
const
|
|
107
|
-
task.status === "
|
|
109
|
+
const futureTasks = targetTasks.filter((task) =>
|
|
110
|
+
task.status === "in_progress" || task.status === "pending" || task.status === "suspended" || task.status === "blocked",
|
|
108
111
|
);
|
|
109
|
-
|
|
112
|
+
const interrupted = futureTasks.filter((task) => task.status === "in_progress");
|
|
113
|
+
// Never guess between two tasks assigned to the same role. A uniquely
|
|
114
|
+
// interrupted task is authoritative; otherwise only one future task is safe.
|
|
115
|
+
const futureTask = interrupted.length === 1
|
|
116
|
+
? interrupted[0]
|
|
117
|
+
: futureTasks.length === 1
|
|
118
|
+
? futureTasks[0]
|
|
119
|
+
: undefined;
|
|
120
|
+
if (futureTasks.length === 0) {
|
|
110
121
|
const completed = targetTasks.filter((task) => task.status === "done" && task.output);
|
|
111
122
|
if (completed.length > 0) {
|
|
112
123
|
const durableReply = [
|
|
@@ -120,21 +131,20 @@ export class Router {
|
|
|
120
131
|
to: fromAgent,
|
|
121
132
|
text: durableReply,
|
|
122
133
|
};
|
|
123
|
-
store.
|
|
134
|
+
const queued = store.queueTaskMessage(this.squadId, sourceTaskId, reply);
|
|
124
135
|
if (this.pool.isRunning(sourceTaskId)) {
|
|
125
|
-
this.pool.steer(sourceTaskId, durableReply)
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
void this.pool.steer(sourceTaskId, durableReply).then((delivered) => {
|
|
137
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, sourceTaskId, [queued.id]);
|
|
138
|
+
});
|
|
128
139
|
}
|
|
129
140
|
return true;
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
|
|
133
|
-
// Target has future work and may spawn again
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.pool.queueMessage(targetAgent, {
|
|
144
|
+
// Target has future work and may spawn again. Bind the message to exactly
|
|
145
|
+
// one durable task, never to the shared role name.
|
|
146
|
+
if (futureTask) {
|
|
147
|
+
store.queueTaskMessage(this.squadId, futureTask.id, {
|
|
138
148
|
ts: store.now(),
|
|
139
149
|
from: fromAgent,
|
|
140
150
|
type: "mention",
|
|
@@ -149,7 +159,7 @@ export class Router {
|
|
|
149
159
|
* Route a human message to an agent.
|
|
150
160
|
*/
|
|
151
161
|
routeHumanMessage(taskId: string, message: string): void {
|
|
152
|
-
store.
|
|
162
|
+
const queued = store.queueTaskMessage(this.squadId, taskId, {
|
|
153
163
|
ts: store.now(),
|
|
154
164
|
from: "human",
|
|
155
165
|
type: "message",
|
|
@@ -157,17 +167,9 @@ export class Router {
|
|
|
157
167
|
});
|
|
158
168
|
|
|
159
169
|
if (this.pool.isRunning(taskId)) {
|
|
160
|
-
this.pool.steer(taskId, `[squad] Human: ${message}`)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (task) {
|
|
164
|
-
this.pool.queueMessage(task.agent, {
|
|
165
|
-
ts: store.now(),
|
|
166
|
-
from: "human",
|
|
167
|
-
type: "message",
|
|
168
|
-
text: message,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
170
|
+
void this.pool.steer(taskId, `[squad] Human: ${message}`).then((delivered) => {
|
|
171
|
+
if (delivered) store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
|
|
172
|
+
});
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|