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/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
+ }