pi-squad 0.1.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/LICENSE +21 -0
- package/README.md +244 -0
- package/package.json +30 -0
- package/src/agent-pool.ts +445 -0
- package/src/agents/_defaults/architect.json +9 -0
- package/src/agents/_defaults/backend.json +9 -0
- package/src/agents/_defaults/debugger.json +9 -0
- package/src/agents/_defaults/devops.json +9 -0
- package/src/agents/_defaults/docs.json +9 -0
- package/src/agents/_defaults/frontend.json +9 -0
- package/src/agents/_defaults/fullstack.json +9 -0
- package/src/agents/_defaults/planner.json +9 -0
- package/src/agents/_defaults/qa.json +9 -0
- package/src/agents/_defaults/researcher.json +9 -0
- package/src/agents/_defaults/security.json +9 -0
- package/src/index.ts +1121 -0
- package/src/monitor.ts +204 -0
- package/src/panel/message-view.ts +232 -0
- package/src/panel/squad-panel.ts +383 -0
- package/src/panel/task-list.ts +264 -0
- package/src/planner.ts +275 -0
- package/src/protocol.ts +265 -0
- package/src/router.ts +207 -0
- package/src/scheduler.ts +732 -0
- package/src/skills/collaboration/SKILL.md +39 -0
- package/src/skills/squad-protocol/SKILL.md +65 -0
- package/src/skills/verification/SKILL.md +64 -0
- package/src/store.ts +458 -0
- package/src/supervisor.ts +143 -0
- package/src/types.ts +210 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* monitor.ts — Health monitoring for running agents.
|
|
3
|
+
*
|
|
4
|
+
* Periodically checks all running agents for:
|
|
5
|
+
* - Idle timeout (no output for N minutes)
|
|
6
|
+
* - Stuck detection (no output for longer)
|
|
7
|
+
* - Loop detection (same tool call repeated)
|
|
8
|
+
* - Hard ceiling (max total runtime)
|
|
9
|
+
* - File conflict warnings
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentPool } from "./agent-pool.js";
|
|
13
|
+
import type { HealthStatus } from "./types.js";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Config
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const IDLE_WARNING_MS = 3 * 60 * 1000; // 3 min no output → warn
|
|
20
|
+
const STUCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no output → intervene
|
|
21
|
+
const HARD_CEILING_MS = 30 * 60 * 1000; // 30 min total → force stop
|
|
22
|
+
const LOOP_THRESHOLD = 5; // same tool call 5x → looping
|
|
23
|
+
const POLL_INTERVAL_MS = 30 * 1000; // check every 30s
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export type MonitorActionType = "steer" | "abort" | "escalate";
|
|
30
|
+
|
|
31
|
+
export interface MonitorAction {
|
|
32
|
+
type: MonitorActionType;
|
|
33
|
+
taskId: string;
|
|
34
|
+
agentName: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type MonitorActionListener = (action: MonitorAction) => void;
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Monitor
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
export class Monitor {
|
|
46
|
+
private pool: AgentPool;
|
|
47
|
+
private squadId: string;
|
|
48
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
49
|
+
private listeners: MonitorActionListener[] = [];
|
|
50
|
+
/** Track which agents have been warned (to avoid repeated warnings) */
|
|
51
|
+
private warned = new Set<string>();
|
|
52
|
+
/** Track which agents have been steered for stuck (to avoid repeated steers) */
|
|
53
|
+
private stuckSteered = new Set<string>();
|
|
54
|
+
|
|
55
|
+
constructor(pool: AgentPool, squadId: string) {
|
|
56
|
+
this.pool = pool;
|
|
57
|
+
this.squadId = squadId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Subscribe to monitor actions */
|
|
61
|
+
onAction(listener: MonitorActionListener): () => void {
|
|
62
|
+
this.listeners.push(listener);
|
|
63
|
+
return () => {
|
|
64
|
+
const idx = this.listeners.indexOf(listener);
|
|
65
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private emit(action: MonitorAction): void {
|
|
70
|
+
for (const listener of this.listeners) {
|
|
71
|
+
try {
|
|
72
|
+
listener(action);
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Start periodic health checks */
|
|
80
|
+
start(): void {
|
|
81
|
+
if (this.interval) return;
|
|
82
|
+
this.interval = setInterval(() => this.checkAll(), POLL_INTERVAL_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop monitoring */
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (this.interval) {
|
|
88
|
+
clearInterval(this.interval);
|
|
89
|
+
this.interval = null;
|
|
90
|
+
}
|
|
91
|
+
this.warned.clear();
|
|
92
|
+
this.stuckSteered.clear();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Check all running agents */
|
|
96
|
+
private checkAll(): void {
|
|
97
|
+
for (const agentName of this.pool.getRunningAgents()) {
|
|
98
|
+
const taskId = this.pool.getTaskIdForAgent(agentName);
|
|
99
|
+
if (!taskId) continue;
|
|
100
|
+
|
|
101
|
+
const activity = this.pool.getActivity(taskId);
|
|
102
|
+
if (!activity) continue;
|
|
103
|
+
|
|
104
|
+
const health = this.checkHealth(activity);
|
|
105
|
+
this.handleHealth(taskId, agentName, health);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Determine health status from activity data */
|
|
110
|
+
checkHealth(activity: {
|
|
111
|
+
lastOutputTs: number;
|
|
112
|
+
startedAt: number;
|
|
113
|
+
recentToolCalls: string[];
|
|
114
|
+
}): HealthStatus {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const idleMs = now - activity.lastOutputTs;
|
|
117
|
+
const totalMs = now - activity.startedAt;
|
|
118
|
+
|
|
119
|
+
// Loop detection: last N tool calls are identical
|
|
120
|
+
const recent = activity.recentToolCalls.slice(-LOOP_THRESHOLD);
|
|
121
|
+
if (recent.length >= LOOP_THRESHOLD) {
|
|
122
|
+
const unique = new Set(recent);
|
|
123
|
+
if (unique.size === 1) return "looping";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (totalMs >= HARD_CEILING_MS) return "exceeded_ceiling";
|
|
127
|
+
if (idleMs >= STUCK_TIMEOUT_MS) return "stuck";
|
|
128
|
+
if (idleMs >= IDLE_WARNING_MS) return "idle_warning";
|
|
129
|
+
|
|
130
|
+
return "healthy";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Take action based on health status */
|
|
134
|
+
private handleHealth(taskId: string, agentName: string, status: HealthStatus): void {
|
|
135
|
+
switch (status) {
|
|
136
|
+
case "healthy":
|
|
137
|
+
// Clear warning flags
|
|
138
|
+
this.warned.delete(taskId);
|
|
139
|
+
this.stuckSteered.delete(taskId);
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case "idle_warning":
|
|
143
|
+
if (!this.warned.has(taskId)) {
|
|
144
|
+
this.warned.add(taskId);
|
|
145
|
+
this.emit({
|
|
146
|
+
type: "steer",
|
|
147
|
+
taskId,
|
|
148
|
+
agentName,
|
|
149
|
+
reason: "idle",
|
|
150
|
+
message:
|
|
151
|
+
"[squad] You've been idle for a few minutes. What's your status? If you're stuck, say so and I'll help.",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case "stuck":
|
|
157
|
+
if (!this.stuckSteered.has(taskId)) {
|
|
158
|
+
this.stuckSteered.add(taskId);
|
|
159
|
+
this.emit({
|
|
160
|
+
type: "steer",
|
|
161
|
+
taskId,
|
|
162
|
+
agentName,
|
|
163
|
+
reason: "stuck",
|
|
164
|
+
message:
|
|
165
|
+
"[squad] You appear stuck — no output for 5 minutes. " +
|
|
166
|
+
"Summarize what you've done and what's blocking you. " +
|
|
167
|
+
"If you can't proceed, state what you need.",
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
// Already steered once, escalate
|
|
171
|
+
this.emit({
|
|
172
|
+
type: "escalate",
|
|
173
|
+
taskId,
|
|
174
|
+
agentName,
|
|
175
|
+
reason: `Agent ${agentName} stuck on ${taskId} — no output after intervention`,
|
|
176
|
+
message: "",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case "looping":
|
|
182
|
+
this.emit({
|
|
183
|
+
type: "steer",
|
|
184
|
+
taskId,
|
|
185
|
+
agentName,
|
|
186
|
+
reason: "looping",
|
|
187
|
+
message:
|
|
188
|
+
"[squad] You're repeating the same action in a loop. " +
|
|
189
|
+
"Stop and reassess your approach. Try a different strategy.",
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
case "exceeded_ceiling":
|
|
194
|
+
this.emit({
|
|
195
|
+
type: "abort",
|
|
196
|
+
taskId,
|
|
197
|
+
agentName,
|
|
198
|
+
reason: `Agent ${agentName} exceeded time ceiling on ${taskId}`,
|
|
199
|
+
message: "",
|
|
200
|
+
});
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message-view.ts — Scrollable message log for a task.
|
|
3
|
+
* Shows tool calls, agent text, @mentions, human messages.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import type { TaskMessage } from "../types.js";
|
|
8
|
+
import * as store from "../store.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Message View
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export class MessageView {
|
|
15
|
+
private theme: Theme;
|
|
16
|
+
private squadId: string;
|
|
17
|
+
private taskId: string | null = null;
|
|
18
|
+
private scrollOffset = 0;
|
|
19
|
+
|
|
20
|
+
constructor(theme: Theme, squadId: string) {
|
|
21
|
+
this.theme = theme;
|
|
22
|
+
this.squadId = squadId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setTaskId(taskId: string): void {
|
|
26
|
+
this.taskId = taskId;
|
|
27
|
+
this.scrollOffset = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getTaskId(): string | null {
|
|
31
|
+
return this.taskId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
scrollUp(): void {
|
|
35
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
scrollDown(): void {
|
|
39
|
+
this.scrollOffset++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
invalidate(): void {
|
|
43
|
+
/* stateless */
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(width: number, maxLines: number): string[] {
|
|
47
|
+
const th = this.theme;
|
|
48
|
+
|
|
49
|
+
if (!this.taskId) {
|
|
50
|
+
return this.padToHeight(["", th.fg("muted", " No task selected")], maxLines);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const task = store.loadTask(this.squadId, this.taskId);
|
|
54
|
+
if (!task) {
|
|
55
|
+
return this.padToHeight(["", th.fg("error", " Task not found")], maxLines);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const messages = store.loadMessages(this.squadId, this.taskId);
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
|
|
61
|
+
// Header: task info
|
|
62
|
+
const statusColor = task.status === "done" ? "success"
|
|
63
|
+
: task.status === "failed" ? "error"
|
|
64
|
+
: task.status === "in_progress" ? "warning"
|
|
65
|
+
: "muted";
|
|
66
|
+
lines.push(` ${th.fg("accent", th.bold(task.id))} · ${th.fg("dim", task.agent)} ${th.fg(statusColor as any, task.status)}`);
|
|
67
|
+
lines.push(` ${th.fg("dim", task.title.slice(0, width - 2))}`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
|
|
70
|
+
if (messages.length === 0) {
|
|
71
|
+
lines.push(th.fg("muted", " No messages yet"));
|
|
72
|
+
return this.padToHeight(lines, maxLines);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Render messages
|
|
76
|
+
const msgLines = this.renderMessages(messages, width);
|
|
77
|
+
|
|
78
|
+
// Apply scroll
|
|
79
|
+
const contentHeight = maxLines - lines.length - 1;
|
|
80
|
+
const maxScroll = Math.max(0, msgLines.length - contentHeight);
|
|
81
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
82
|
+
// Auto-scroll to bottom if near the end
|
|
83
|
+
if (this.scrollOffset >= maxScroll - 2) {
|
|
84
|
+
this.scrollOffset = maxScroll;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const visible = msgLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
|
|
88
|
+
lines.push(...visible);
|
|
89
|
+
|
|
90
|
+
// Scroll indicator
|
|
91
|
+
if (msgLines.length > contentHeight) {
|
|
92
|
+
const pos = maxScroll > 0 ? Math.round((this.scrollOffset / maxScroll) * 100) : 100;
|
|
93
|
+
lines.push(th.fg("dim", ` ─── ${pos}% ───`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this.padToHeight(lines, maxLines);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// Message Rendering
|
|
101
|
+
// =========================================================================
|
|
102
|
+
|
|
103
|
+
private renderMessages(messages: TaskMessage[], width: number): string[] {
|
|
104
|
+
const th = this.theme;
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
let lastFrom: string | null = null;
|
|
107
|
+
|
|
108
|
+
for (const msg of messages) {
|
|
109
|
+
// Skip system status messages that are noise
|
|
110
|
+
if (msg.type === "status" && msg.from === "system" && msg.text === "Agent starting work") {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Group consecutive messages from the same sender
|
|
115
|
+
const showHeader = msg.from !== lastFrom;
|
|
116
|
+
lastFrom = msg.from;
|
|
117
|
+
|
|
118
|
+
if (showHeader) {
|
|
119
|
+
// Blank line between different senders
|
|
120
|
+
if (lines.length > 0) lines.push("");
|
|
121
|
+
|
|
122
|
+
// Sender header with timestamp
|
|
123
|
+
const time = this.formatTime(msg.ts);
|
|
124
|
+
const senderColor = this.getSenderColor(msg.from);
|
|
125
|
+
const senderName = msg.from === "human" ? "YOU" : msg.from;
|
|
126
|
+
lines.push(` ${th.fg("dim", time)} ${th.fg(senderColor as any, senderName)}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Message content
|
|
130
|
+
switch (msg.type) {
|
|
131
|
+
case "tool": {
|
|
132
|
+
const toolName = msg.name || msg.text;
|
|
133
|
+
const argsStr = msg.args?.path || msg.args?.command || "";
|
|
134
|
+
const preview = argsStr
|
|
135
|
+
? `→ ${toolName} ${argsStr}`
|
|
136
|
+
: `→ ${toolName}`;
|
|
137
|
+
lines.push(` ${th.fg("muted", preview.slice(0, width - 4))}`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "mention": {
|
|
142
|
+
const target = msg.to ? `→ ${msg.to}` : "";
|
|
143
|
+
lines.push(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text.slice(0, width - 10))}`);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case "text":
|
|
148
|
+
case "message":
|
|
149
|
+
case "reply": {
|
|
150
|
+
// Wrap long text
|
|
151
|
+
const textLines = this.wrapText(msg.text, width - 4);
|
|
152
|
+
for (const textLine of textLines.slice(0, 10)) {
|
|
153
|
+
lines.push(` ${textLine}`);
|
|
154
|
+
}
|
|
155
|
+
if (textLines.length > 10) {
|
|
156
|
+
lines.push(` ${th.fg("dim", `... +${textLines.length - 10} lines`)}`);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "done": {
|
|
162
|
+
lines.push(` ${th.fg("success", "✓ " + msg.text.slice(0, width - 6))}`);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "error": {
|
|
167
|
+
lines.push(` ${th.fg("error", "✗ " + msg.text.slice(0, width - 6))}`);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "status": {
|
|
172
|
+
lines.push(` ${th.fg("dim", msg.text.slice(0, width - 4))}`);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// =========================================================================
|
|
182
|
+
// Helpers
|
|
183
|
+
// =========================================================================
|
|
184
|
+
|
|
185
|
+
private getSenderColor(from: string): string {
|
|
186
|
+
if (from === "human") return "accent";
|
|
187
|
+
if (from === "system") return "dim";
|
|
188
|
+
// Rotate through available colors for different agents
|
|
189
|
+
const colors = ["success", "warning", "accent", "muted"];
|
|
190
|
+
let hash = 0;
|
|
191
|
+
for (const c of from) hash = (hash * 31 + c.charCodeAt(0)) & 0x7fffffff;
|
|
192
|
+
return colors[hash % colors.length];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private formatTime(ts: string): string {
|
|
196
|
+
try {
|
|
197
|
+
const d = new Date(ts);
|
|
198
|
+
const h = d.getHours().toString().padStart(2, "0");
|
|
199
|
+
const m = d.getMinutes().toString().padStart(2, "0");
|
|
200
|
+
return `${h}:${m}`;
|
|
201
|
+
} catch {
|
|
202
|
+
return "??:??";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private wrapText(text: string, maxWidth: number): string[] {
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
for (const rawLine of text.split("\n")) {
|
|
209
|
+
if (rawLine.length <= maxWidth) {
|
|
210
|
+
lines.push(rawLine);
|
|
211
|
+
} else {
|
|
212
|
+
// Simple word wrap
|
|
213
|
+
let remaining = rawLine;
|
|
214
|
+
while (remaining.length > maxWidth) {
|
|
215
|
+
const breakAt = remaining.lastIndexOf(" ", maxWidth);
|
|
216
|
+
const splitAt = breakAt > maxWidth * 0.3 ? breakAt : maxWidth;
|
|
217
|
+
lines.push(remaining.slice(0, splitAt));
|
|
218
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
219
|
+
}
|
|
220
|
+
if (remaining) lines.push(remaining);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private padToHeight(lines: string[], maxLines: number): string[] {
|
|
227
|
+
while (lines.length < maxLines) {
|
|
228
|
+
lines.push("");
|
|
229
|
+
}
|
|
230
|
+
return lines.slice(0, maxLines);
|
|
231
|
+
}
|
|
232
|
+
}
|