pi-watchdog-supervisor 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/.tmp/2026-07-08_pi_watchdog_extension_requirement.md +907 -0
- package/.tmp/2026-07-08_pi_watchdog_high_level_spec.md +192 -0
- package/.tmp/2026-07-08_pi_watchdog_task01_low_level_spec.md +234 -0
- package/.tmp/2026-07-08_pi_watchdog_task02_low_level_spec.md +158 -0
- package/.tmp/2026-07-08_pi_watchdog_task03_low_level_spec.md +175 -0
- package/.tmp/2026-07-08_pi_watchdog_task04_low_level_spec.md +151 -0
- package/.tmp/2026-07-08_pi_watchdog_task05_low_level_spec.md +116 -0
- package/.tmp/2026-07-08_pi_watchdog_task06_low_level_spec.md +69 -0
- package/.tmp/detect_approach.patch +253 -0
- package/README.md +176 -0
- package/examples/AGENTS.md +27 -0
- package/examples/watchdog-supervisor.json +12 -0
- package/package.json +39 -0
- package/prompts/watchdog-agent.md +36 -0
- package/skills/watchdog-supervisor/SKILL.md +35 -0
- package/src/checker.ts +54 -0
- package/src/collector.ts +223 -0
- package/src/commands.ts +159 -0
- package/src/config.ts +80 -0
- package/src/detector.ts +150 -0
- package/src/index.ts +94 -0
- package/src/integrations/gotgenes-subagents.ts +45 -0
- package/src/lmdebug.ts +128 -0
- package/src/normalize.ts +40 -0
- package/src/registry.ts +121 -0
- package/src/store.ts +137 -0
- package/src/tools.ts +289 -0
- package/src/types.ts +80 -0
- package/test/Screenshot_20260708_220337.png +0 -0
- package/test/checker.test.ts +118 -0
- package/test/collector.test.ts +201 -0
- package/test/config.test.ts +131 -0
- package/test/detector.test.ts +224 -0
- package/test/integration.test.ts +57 -0
- package/test/lmdebug.test.ts +114 -0
- package/test/normalize.test.ts +78 -0
- package/test/registry.test.ts +149 -0
- package/test/store.test.ts +176 -0
- package/test/tools.test.ts +315 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
diff --git a/src/index.ts b/src/index.ts
|
|
2
|
+
index 658e0bc..b7a28d5 100644
|
|
3
|
+
--- a/src/index.ts
|
|
4
|
+
+++ b/src/index.ts
|
|
5
|
+
@@ -9,6 +9,7 @@ import { startTicker } from './ticker.ts';
|
|
6
|
+
import { mergeConfig } from './config.ts';
|
|
7
|
+
import { detectStuck } from './detector.ts';
|
|
8
|
+
import { writeRepeatStatsLog } from './statslog.ts';
|
|
9
|
+
+import { registerLmDebugWidget } from './lmdebug.ts';
|
|
10
|
+
import {
|
|
11
|
+
connectSubagents,
|
|
12
|
+
SUBAGENT_EVENT_CHANNELS,
|
|
13
|
+
@@ -55,6 +56,7 @@ export default function watchdogSupervisor(pi: ExtensionAPI) {
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
+ registerLmDebugWidget(pi);
|
|
18
|
+
registerWatchdogCommands(pi, { config, registry, getIntegration, store });
|
|
19
|
+
registerWatchdogTools(pi, {
|
|
20
|
+
store,
|
|
21
|
+
diff --git a/src/lmdebug.ts b/src/lmdebug.ts
|
|
22
|
+
new file mode 100644
|
|
23
|
+
index 0000000..3eda58b
|
|
24
|
+
--- /dev/null
|
|
25
|
+
+++ b/src/lmdebug.ts
|
|
26
|
+
@@ -0,0 +1,121 @@
|
|
27
|
+
+// Debug widget at the bottom of the pi console: shows the latest request sent
|
|
28
|
+
+// to the LLM provider (LM Studio) and the latest assistant message received,
|
|
29
|
+
+// each with a timestamp.
|
|
30
|
+
+const WIDGET_KEY = 'watchdog-lm-debug';
|
|
31
|
+
+const SNIPPET_LEN = 120;
|
|
32
|
+
+
|
|
33
|
+
+// Minimal surface used here; satisfied by ExtensionAPI and test fakes
|
|
34
|
+
+type PiLike = {
|
|
35
|
+
+ on(
|
|
36
|
+
+ event: 'before_provider_request',
|
|
37
|
+
+ handler: (event: { payload: unknown }, ctx: UiCtxLike) => void,
|
|
38
|
+
+ ): void;
|
|
39
|
+
+ on(
|
|
40
|
+
+ event: 'message_end',
|
|
41
|
+
+ handler: (event: { message: unknown }, ctx: UiCtxLike) => void,
|
|
42
|
+
+ ): void;
|
|
43
|
+
+};
|
|
44
|
+
+
|
|
45
|
+
+type UiCtxLike = {
|
|
46
|
+
+ hasUI: boolean;
|
|
47
|
+
+ ui: {
|
|
48
|
+
+ setWidget(
|
|
49
|
+
+ key: string,
|
|
50
|
+
+ content: string[] | undefined,
|
|
51
|
+
+ options?: { placement?: 'aboveEditor' | 'belowEditor' },
|
|
52
|
+
+ ): void;
|
|
53
|
+
+ };
|
|
54
|
+
+};
|
|
55
|
+
+
|
|
56
|
+
+const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
57
|
+
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
58
|
+
+
|
|
59
|
+
+const toSnippet = (raw: string): string => {
|
|
60
|
+
+ const flat = raw.replace(/\s+/g, ' ').trim();
|
|
61
|
+
+ return flat.length > SNIPPET_LEN ? `${flat.slice(0, SNIPPET_LEN)}…` : flat;
|
|
62
|
+
+};
|
|
63
|
+
+
|
|
64
|
+
+// Text from OpenAI-style content: plain string or [{type:'text',text}] parts
|
|
65
|
+
+const extractPartsText = (content: unknown): string => {
|
|
66
|
+
+ if (typeof content === 'string') {
|
|
67
|
+
+ return content;
|
|
68
|
+
+ }
|
|
69
|
+
+ if (!Array.isArray(content)) {
|
|
70
|
+
+ return '';
|
|
71
|
+
+ }
|
|
72
|
+
+ return content
|
|
73
|
+
+ .map((part) => {
|
|
74
|
+
+ const record = asRecord(part);
|
|
75
|
+
+ return typeof record?.text === 'string' ? record.text : '';
|
|
76
|
+
+ })
|
|
77
|
+
+ .filter((text) => text !== '')
|
|
78
|
+
+ .join(' ');
|
|
79
|
+
+};
|
|
80
|
+
+
|
|
81
|
+
+export const describeSentPayload = (payload: unknown): string => {
|
|
82
|
+
+ const record = asRecord(payload);
|
|
83
|
+
+ const messages = Array.isArray(record?.messages) ? (record.messages as unknown[]) : [];
|
|
84
|
+
+ const last = asRecord(messages[messages.length - 1]);
|
|
85
|
+
+ if (!last) {
|
|
86
|
+
+ return 'no messages in payload';
|
|
87
|
+
+ }
|
|
88
|
+
+ const role = typeof last.role === 'string' ? last.role : 'unknown';
|
|
89
|
+
+ const text = toSnippet(extractPartsText(last.content));
|
|
90
|
+
+ return `#${messages.length} ${role}: ${text || '(no text)'}`;
|
|
91
|
+
+};
|
|
92
|
+
+
|
|
93
|
+
+// Assistant messages only; other roles return undefined
|
|
94
|
+
+export const describeAssistantMessage = (message: unknown): string | undefined => {
|
|
95
|
+
+ const record = asRecord(message);
|
|
96
|
+
+ if (record?.role !== 'assistant' || !Array.isArray(record.content)) {
|
|
97
|
+
+ return undefined;
|
|
98
|
+
+ }
|
|
99
|
+
+ const parts: string[] = [];
|
|
100
|
+
+ for (const block of record.content) {
|
|
101
|
+
+ const blockRecord = asRecord(block);
|
|
102
|
+
+ if (!blockRecord) {
|
|
103
|
+
+ continue;
|
|
104
|
+
+ }
|
|
105
|
+
+ if (blockRecord.type === 'text' && typeof blockRecord.text === 'string') {
|
|
106
|
+
+ parts.push(blockRecord.text);
|
|
107
|
+
+ } else if (blockRecord.type === 'toolCall') {
|
|
108
|
+
+ const name = typeof blockRecord.name === 'string' ? blockRecord.name : 'tool';
|
|
109
|
+
+ parts.push(`[toolCall ${name}]`);
|
|
110
|
+
+ }
|
|
111
|
+
+ }
|
|
112
|
+
+ return `assistant: ${toSnippet(parts.join(' ')) || '(no text)'}`;
|
|
113
|
+
+};
|
|
114
|
+
+
|
|
115
|
+
+const formatTime = (now: number): string => {
|
|
116
|
+
+ const date = new Date(now);
|
|
117
|
+
+ const pad = (value: number) => String(value).padStart(2, '0');
|
|
118
|
+
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
119
|
+
+};
|
|
120
|
+
+
|
|
121
|
+
+export const registerLmDebugWidget = (pi: PiLike, now: () => number = Date.now): void => {
|
|
122
|
+
+ let lastSent: string | undefined;
|
|
123
|
+
+ let lastReceived: string | undefined;
|
|
124
|
+
+
|
|
125
|
+
+ const render = (ctx: UiCtxLike) => {
|
|
126
|
+
+ if (!ctx.hasUI) {
|
|
127
|
+
+ return;
|
|
128
|
+
+ }
|
|
129
|
+
+ ctx.ui.setWidget(WIDGET_KEY, [lastSent, lastReceived].filter((line): line is string => line !== undefined), {
|
|
130
|
+
+ placement: 'belowEditor',
|
|
131
|
+
+ });
|
|
132
|
+
+ };
|
|
133
|
+
+
|
|
134
|
+
+ pi.on('before_provider_request', (event, ctx) => {
|
|
135
|
+
+ lastSent = `▲ sent ${formatTime(now())} ${describeSentPayload(event.payload)}`;
|
|
136
|
+
+ render(ctx);
|
|
137
|
+
+ });
|
|
138
|
+
+
|
|
139
|
+
+ pi.on('message_end', (event, ctx) => {
|
|
140
|
+
+ const description = describeAssistantMessage(event.message);
|
|
141
|
+
+ if (!description) {
|
|
142
|
+
+ return;
|
|
143
|
+
+ }
|
|
144
|
+
+ lastReceived = `▼ recv ${formatTime(now())} ${description}`;
|
|
145
|
+
+ render(ctx);
|
|
146
|
+
+ });
|
|
147
|
+
+};
|
|
148
|
+
diff --git a/test/lmdebug.test.ts b/test/lmdebug.test.ts
|
|
149
|
+
new file mode 100644
|
|
150
|
+
index 0000000..62739ea
|
|
151
|
+
--- /dev/null
|
|
152
|
+
+++ b/test/lmdebug.test.ts
|
|
153
|
+
@@ -0,0 +1,100 @@
|
|
154
|
+
+import { describe, expect, it } from 'vitest';
|
|
155
|
+
+import {
|
|
156
|
+
+ describeAssistantMessage,
|
|
157
|
+
+ describeSentPayload,
|
|
158
|
+
+ registerLmDebugWidget,
|
|
159
|
+
+} from '../src/lmdebug.ts';
|
|
160
|
+
+
|
|
161
|
+
+const NOW = new Date('2026-07-08T14:15:23').getTime();
|
|
162
|
+
+
|
|
163
|
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariant fake for overloads
|
|
164
|
+
+type Handler = (event: any, ctx: any) => void;
|
|
165
|
+
+
|
|
166
|
+
+const createFakePi = () => {
|
|
167
|
+
+ const handlers = new Map<string, Handler[]>();
|
|
168
|
+
+ const widgets: Array<{ key: string; content: string[] | undefined; options: unknown }> = [];
|
|
169
|
+
+ const ctx = {
|
|
170
|
+
+ hasUI: true,
|
|
171
|
+
+ ui: {
|
|
172
|
+
+ setWidget: (key: string, content: string[] | undefined, options?: unknown) => {
|
|
173
|
+
+ widgets.push({ key, content, options });
|
|
174
|
+
+ },
|
|
175
|
+
+ },
|
|
176
|
+
+ };
|
|
177
|
+
+ return {
|
|
178
|
+
+ pi: {
|
|
179
|
+
+ on: (event: string, handler: Handler) => {
|
|
180
|
+
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
181
|
+
+ },
|
|
182
|
+
+ },
|
|
183
|
+
+ emit: (event: string, payload: unknown) => {
|
|
184
|
+
+ for (const handler of handlers.get(event) ?? []) {
|
|
185
|
+
+ handler(payload, ctx);
|
|
186
|
+
+ }
|
|
187
|
+
+ },
|
|
188
|
+
+ widgets,
|
|
189
|
+
+ };
|
|
190
|
+
+};
|
|
191
|
+
+
|
|
192
|
+
+describe('describeSentPayload', () => {
|
|
193
|
+
+ it('summarizes the last message of an OpenAI-style payload', () => {
|
|
194
|
+
+ const text = describeSentPayload({
|
|
195
|
+
+ messages: [
|
|
196
|
+
+ { role: 'system', content: 'sys' },
|
|
197
|
+
+ { role: 'user', content: 'fix the bug in index.tsx' },
|
|
198
|
+
+ ],
|
|
199
|
+
+ });
|
|
200
|
+
+ expect(text).toBe('#2 user: fix the bug in index.tsx');
|
|
201
|
+
+ });
|
|
202
|
+
+
|
|
203
|
+
+ it('handles content parts arrays and missing payloads', () => {
|
|
204
|
+
+ expect(
|
|
205
|
+
+ describeSentPayload({ messages: [{ role: 'tool', content: [{ type: 'text', text: 'ok' }] }] }),
|
|
206
|
+
+ ).toBe('#1 tool: ok');
|
|
207
|
+
+ expect(describeSentPayload(undefined)).toBe('no messages in payload');
|
|
208
|
+
+ });
|
|
209
|
+
+});
|
|
210
|
+
+
|
|
211
|
+
+describe('describeAssistantMessage', () => {
|
|
212
|
+
+ it('summarizes text and toolCall blocks', () => {
|
|
213
|
+
+ const text = describeAssistantMessage({
|
|
214
|
+
+ role: 'assistant',
|
|
215
|
+
+ content: [
|
|
216
|
+
+ { type: 'text', text: 'I will edit the file' },
|
|
217
|
+
+ { type: 'toolCall', name: 'edit', arguments: {} },
|
|
218
|
+
+ ],
|
|
219
|
+
+ });
|
|
220
|
+
+ expect(text).toBe('assistant: I will edit the file [toolCall edit]');
|
|
221
|
+
+ });
|
|
222
|
+
+
|
|
223
|
+
+ it('returns undefined for non-assistant messages', () => {
|
|
224
|
+
+ expect(describeAssistantMessage({ role: 'user', content: [] })).toBeUndefined();
|
|
225
|
+
+ });
|
|
226
|
+
+});
|
|
227
|
+
+
|
|
228
|
+
+describe('registerLmDebugWidget', () => {
|
|
229
|
+
+ it('renders sent/received lines with timestamps into a belowEditor widget', () => {
|
|
230
|
+
+ const { pi, emit, widgets } = createFakePi();
|
|
231
|
+
+ registerLmDebugWidget(pi, () => NOW);
|
|
232
|
+
+ emit('before_provider_request', {
|
|
233
|
+
+ payload: { messages: [{ role: 'user', content: 'hello' }] },
|
|
234
|
+
+ });
|
|
235
|
+
+ emit('message_end', {
|
|
236
|
+
+ message: { role: 'assistant', content: [{ type: 'text', text: 'hi there' }] },
|
|
237
|
+
+ });
|
|
238
|
+
+ expect(widgets).toHaveLength(2);
|
|
239
|
+
+ expect(widgets[1].key).toBe('watchdog-lm-debug');
|
|
240
|
+
+ expect(widgets[1].options).toEqual({ placement: 'belowEditor' });
|
|
241
|
+
+ expect(widgets[1].content).toEqual([
|
|
242
|
+
+ '▲ sent 14:15:23 #1 user: hello',
|
|
243
|
+
+ '▼ recv 14:15:23 assistant: hi there',
|
|
244
|
+
+ ]);
|
|
245
|
+
+ });
|
|
246
|
+
+
|
|
247
|
+
+ it('ignores non-assistant message_end events', () => {
|
|
248
|
+
+ const { pi, emit, widgets } = createFakePi();
|
|
249
|
+
+ registerLmDebugWidget(pi, () => NOW);
|
|
250
|
+
+ emit('message_end', { message: { role: 'toolResult', content: [] } });
|
|
251
|
+
+ expect(widgets).toHaveLength(0);
|
|
252
|
+
+ });
|
|
253
|
+
+});
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# pi-watchdog-supervisor
|
|
2
|
+
|
|
3
|
+
A [Pi](https://pi.dev) extension that lets a **watchdog sub-agent** supervise
|
|
4
|
+
sibling task sub-agents and report likely stuck/loop states to the main agent
|
|
5
|
+
session.
|
|
6
|
+
|
|
7
|
+
Typical failure it catches: a task sub-agent re-running the same `rg`/`grep`
|
|
8
|
+
command, getting the same output, producing no new edits — forever.
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
Three layers with separate responsibilities:
|
|
13
|
+
|
|
14
|
+
```txt
|
|
15
|
+
Every Session (main AND child — children inherit parent extensions)
|
|
16
|
+
│ ├─ listens to its OWN llm events (before_provider_request / message_end)
|
|
17
|
+
│ │ and tool_call / tool_result events
|
|
18
|
+
│ ├─ normalizes message bodies (timestamps/ids stripped), hashes them
|
|
19
|
+
│ ├─ appends compact events to a process-shared store
|
|
20
|
+
│ └─ event-driven self-check on every LLM round-trip:
|
|
21
|
+
│ detect stuck on own buffer → steer rescue message into itself
|
|
22
|
+
│ (no timer, no watchdog sub-agent needed)
|
|
23
|
+
│ ↓
|
|
24
|
+
Main Agent Session (parent)
|
|
25
|
+
│ this extension
|
|
26
|
+
│ ├─ tracks sub-agent lifecycle (@gotgenes/pi-subagents events)
|
|
27
|
+
│ ├─ runs deterministic stuck detection + cooldown
|
|
28
|
+
│ └─ receives [Watchdog Alert] messages via an alert sink
|
|
29
|
+
│ ↓
|
|
30
|
+
Watchdog Sub-agent Session (optional, for cross-agent supervision)
|
|
31
|
+
calls watchdog_* tools → analyzes → alerts the main agent
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **Extension runtime** = deterministic collector + detector (no LLM guessing
|
|
35
|
+
for repeat counts / timeouts).
|
|
36
|
+
- **Watchdog sub-agent** = reasoning/reporting layer (uses the tools below).
|
|
37
|
+
- **Main agent** = final decision maker; nothing is auto-killed or auto-steered
|
|
38
|
+
by default.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- `@gotgenes/pi-subagents` (optional peer dependency) — without it the
|
|
43
|
+
extension still loads, but target discovery is unavailable.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pi install npm:pi-watchdog-supervisor # once published
|
|
49
|
+
# or for local development:
|
|
50
|
+
pi -e ./src/index.ts
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Behavior |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `/watchdog status` | List targets with kind/status/tool count and stuck flag |
|
|
58
|
+
| `/watchdog config` | Show effective config (defaults ← global ← project ← runtime overrides) |
|
|
59
|
+
| `/watchdog set rescueMessage <msg>` | Override the rescue message for this run |
|
|
60
|
+
| `/watchdog pause` | Suppress alerting temporarily |
|
|
61
|
+
| `/watchdog resume` | Re-enable alerting |
|
|
62
|
+
| `/watchdog inspect <targetId>` | Show recent compact events + stuck analysis for one target |
|
|
63
|
+
|
|
64
|
+
## Tools (for the watchdog sub-agent)
|
|
65
|
+
|
|
66
|
+
| Tool | Purpose |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `watchdog_list_targets` | List sub-agents with status and `likelyStuck` signal |
|
|
69
|
+
| `watchdog_read_events` | Read compact recent events (commands, output hashes, edits) |
|
|
70
|
+
| `watchdog_detect_stuck` | Deterministic stuck analysis + `suggestedRescueMessage` |
|
|
71
|
+
| `watchdog_alert_main` | Send a `[Watchdog Alert]` into the main session |
|
|
72
|
+
| `watchdog_steer_subagent` | Send a rescue message to a target (defaults to dry-run) |
|
|
73
|
+
| `watchdog_config` | Read/update policy at runtime |
|
|
74
|
+
|
|
75
|
+
## Stuck detection rules
|
|
76
|
+
|
|
77
|
+
Detection works at the LLM message level (same sources as the lm-debug
|
|
78
|
+
widget): `before_provider_request` captures the newest message sent to the
|
|
79
|
+
provider, `message_end` captures each finalized assistant message. Bodies are
|
|
80
|
+
normalized before hashing — timestamps, dates, UUIDs, long hex ids, and digit
|
|
81
|
+
runs are replaced with placeholders — so two loop iterations that differ only
|
|
82
|
+
in time or sequence markers compare as identical.
|
|
83
|
+
|
|
84
|
+
- `repeated_llm_input`: the same normalized input body sent to the LLM ≥
|
|
85
|
+
`llmRepeatThreshold` (3) times in a row.
|
|
86
|
+
- `repeated_llm_output`: the same normalized assistant output body (text +
|
|
87
|
+
thinking + tool calls with arguments) ≥ `llmRepeatThreshold` times in a row.
|
|
88
|
+
- `idle_no_progress`: tools keep running but no edit/write for ≥
|
|
89
|
+
`idleNoProgressSec`. **Disabled by default** (`0`); set a positive number of
|
|
90
|
+
seconds (e.g. `300`) to enable.
|
|
91
|
+
|
|
92
|
+
Setting `llmRepeatThreshold` to `0` disables the LLM repetition rules. The
|
|
93
|
+
repeats must be consecutive; a different message in between resets the run.
|
|
94
|
+
Confidence: one evidence type → `medium`, two or more → `high`.
|
|
95
|
+
|
|
96
|
+
## Safety & anti-spam
|
|
97
|
+
|
|
98
|
+
- `alertMode`:
|
|
99
|
+
- `main_only` (default): only shows an alert message in the main session;
|
|
100
|
+
nothing is sent to the stuck sub-agent (no extra AI provider message).
|
|
101
|
+
- `direct_subagent` / `both`: additionally allow `watchdog_steer_subagent`
|
|
102
|
+
to send the rescue message into the stuck sub-agent (with
|
|
103
|
+
`dryRun: false`; the tool defaults to dry-run).
|
|
104
|
+
- `dryRun` is a parameter of the `watchdog_steer_subagent` tool call,
|
|
105
|
+
passed by the watchdog agent per invocation. When the call omits it, the
|
|
106
|
+
`steerDryRunDefault` config field decides:
|
|
107
|
+
- `null` (default) or `true`: dry-run.
|
|
108
|
+
- `false`: steer for real without a per-call `dryRun: false` — opt-in to
|
|
109
|
+
fully automatic rescue (still requires `alertMode` other than
|
|
110
|
+
`main_only`).
|
|
111
|
+
- An explicit `dryRun` in the call always wins over the config default.
|
|
112
|
+
- Alerts respect a per-target cooldown (`cooldownSec`):
|
|
113
|
+
- `0` (default): no cooldown — every check that meets the threshold alerts.
|
|
114
|
+
- `-1`: infinite cooldown — the same evidence key alerts only once; new
|
|
115
|
+
evidence still alerts.
|
|
116
|
+
- `> 0`: the same evidence key is not re-alerted within that many seconds;
|
|
117
|
+
new evidence still alerts immediately.
|
|
118
|
+
- The repetition counter resets at each alert: only llm messages recorded
|
|
119
|
+
after the last alert count toward the next one. A recovered agent stops
|
|
120
|
+
alerting immediately; an agent still looping re-accumulates the threshold
|
|
121
|
+
within a few round-trips.
|
|
122
|
+
- `/watchdog pause` suppresses all alerts until `/watchdog resume` (including
|
|
123
|
+
the automatic tick).
|
|
124
|
+
- Only output hashes and short previews are stored, never full outputs.
|
|
125
|
+
- Besides the watchdog-agent-driven flow (Option A), the extension runs an
|
|
126
|
+
event-driven self-check in every session (Option B): each session (main or
|
|
127
|
+
child) re-runs stuck detection on its own event buffer on every LLM
|
|
128
|
+
round-trip — after each `before_provider_request` / `message_end` llm event
|
|
129
|
+
and on `after_provider_response` — and steers the rescue message into itself
|
|
130
|
+
the moment the threshold is crossed. No timer is involved. The check
|
|
131
|
+
respects `enabled`, `cooldownSec`, and the pause state.
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
Project config `.pi/watchdog-supervisor.json` overrides global config
|
|
136
|
+
`~/.pi/agent/watchdog-supervisor/config.json`. See
|
|
137
|
+
[examples/watchdog-supervisor.json](examples/watchdog-supervisor.json) for all
|
|
138
|
+
fields (values match the defaults, except `idleNoProgressSec` which is shown
|
|
139
|
+
enabled at `300`; its default is `0` = disabled). The default `rescueMessage`
|
|
140
|
+
asks the agent to stop repeating the current actions and resume the task —
|
|
141
|
+
override it per project or per session as needed.
|
|
142
|
+
|
|
143
|
+
Set `debug: true` (default `false`) to show a debug console below the editor
|
|
144
|
+
with the latest message sent to the provider and the latest assistant message
|
|
145
|
+
received, each timestamped, in full (newlines preserved, no truncation).
|
|
146
|
+
|
|
147
|
+
## Manual E2E scenario
|
|
148
|
+
|
|
149
|
+
Prerequisites: `@gotgenes/pi-subagents` installed, this extension loaded
|
|
150
|
+
(`pi -e ./src/index.ts`), API key available.
|
|
151
|
+
|
|
152
|
+
1. In the main session, spawn a watchdog sub-agent using
|
|
153
|
+
[prompts/watchdog-agent.md](prompts/watchdog-agent.md) plus
|
|
154
|
+
"supervise the task agents".
|
|
155
|
+
2. Spawn a dummy loop task sub-agent:
|
|
156
|
+
`Repeat the exact same step five times: run `rg "SOME_TOKEN" src` and
|
|
157
|
+
report the result with the same sentence each time.`
|
|
158
|
+
3. After the task agent has looped a few turns, prompt the watchdog:
|
|
159
|
+
"Check task sub-agent status now."
|
|
160
|
+
4. Expected:
|
|
161
|
+
- `watchdog_list_targets` shows the task agent with `likelyStuck: true`
|
|
162
|
+
- `watchdog_detect_stuck` returns `repeated_llm_input` /
|
|
163
|
+
`repeated_llm_output` evidence
|
|
164
|
+
- `watchdog_alert_main` puts a `[Watchdog Alert]` into the main session
|
|
165
|
+
5. Main agent decides: ask the watchdog to run `watchdog_steer_subagent`
|
|
166
|
+
(dry-run by default); real steering requires
|
|
167
|
+
`watchdog_config set { "alertMode": "both" }` first.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm install
|
|
173
|
+
npm run type-check
|
|
174
|
+
npm test
|
|
175
|
+
pi -e ./src/index.ts
|
|
176
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Example main-agent instructions
|
|
2
|
+
|
|
3
|
+
Copy the section below into your project's `AGENTS.md` to enable the
|
|
4
|
+
supervisor workflow.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Sub-agent watchdog workflow
|
|
9
|
+
|
|
10
|
+
When running multiple background sub-agents:
|
|
11
|
+
|
|
12
|
+
1. Start one watchdog sub-agent using the prompt in
|
|
13
|
+
`prompts/watchdog-agent.md` (from the pi-watchdog-supervisor package).
|
|
14
|
+
2. Start task sub-agents normally.
|
|
15
|
+
3. Periodically ask the watchdog sub-agent to inspect task sub-agent status
|
|
16
|
+
("Check task sub-agent status now.") — the watchdog uses the
|
|
17
|
+
`watchdog_list_targets` / `watchdog_detect_stuck` / `watchdog_read_events`
|
|
18
|
+
tools and reports via `watchdog_alert_main`.
|
|
19
|
+
4. If the watchdog reports a likely stuck task, decide whether to steer the
|
|
20
|
+
task sub-agent.
|
|
21
|
+
5. Prefer sending a rescue message before stopping/restarting a sub-agent.
|
|
22
|
+
Direct steering via `watchdog_steer_subagent` requires setting
|
|
23
|
+
`alertMode` to `direct_subagent` or `both` first (default is `main_only`).
|
|
24
|
+
|
|
25
|
+
Note (MVP / Option A): the watchdog sub-agent does not wake up by itself —
|
|
26
|
+
the main agent drives the check cadence. An extension-driven periodic tick
|
|
27
|
+
(Option B) is post-MVP.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"rescueMessage": "The AI agent might be stuck. Stop repeating the current actions, then resume the task.",
|
|
4
|
+
"llmRepeatThreshold": 3,
|
|
5
|
+
"idleNoProgressSec": 300,
|
|
6
|
+
"cooldownSec": 0,
|
|
7
|
+
"maxPreviewLines": 20,
|
|
8
|
+
"maxEventsPerAgent": 200,
|
|
9
|
+
"alertMode": "main_only",
|
|
10
|
+
"steerDryRunDefault": null,
|
|
11
|
+
"debug": false
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-watchdog-supervisor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: watchdog supervisor for task sub-agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"pi": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"./src/index.ts"
|
|
12
|
+
],
|
|
13
|
+
"skills": [
|
|
14
|
+
"./skills"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"type-check": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@gotgenes/pi-subagents": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"@gotgenes/pi-subagents": {
|
|
26
|
+
"optional": true
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
31
|
+
"@gotgenes/pi-subagents": "^18.0.1",
|
|
32
|
+
"@types/node": "^26.1.1",
|
|
33
|
+
"typescript": "^5.5.0",
|
|
34
|
+
"vitest": "^3.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"typebox": "^1.3.5"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Watchdog Supervisor Agent Prompt
|
|
2
|
+
|
|
3
|
+
Use this prompt when spawning a watchdog sub-agent.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are a watchdog supervisor for Pi task sub-agents.
|
|
8
|
+
|
|
9
|
+
Your job:
|
|
10
|
+
- Check task sub-agent status through the watchdog tools.
|
|
11
|
+
- Detect likely loop/stuck cases.
|
|
12
|
+
- Report compact alerts to the main agent.
|
|
13
|
+
- Do not fix code yourself unless explicitly asked.
|
|
14
|
+
- Do not spam alerts; the tools enforce a per-target cooldown — respect it.
|
|
15
|
+
- Prefer evidence-based alerts: run `watchdog_detect_stuck` before alerting.
|
|
16
|
+
|
|
17
|
+
Workflow for each check:
|
|
18
|
+
1. Call `watchdog_list_targets` to see running sub-agents.
|
|
19
|
+
2. Ignore yourself and completed/failed agents.
|
|
20
|
+
3. For each running task agent, call `watchdog_detect_stuck`.
|
|
21
|
+
4. If `likelyStuck` is true, call `watchdog_read_events` to gather evidence.
|
|
22
|
+
5. Call `watchdog_alert_main` with a compact summary.
|
|
23
|
+
6. Do not call `watchdog_steer_subagent` with `dryRun: false` unless the main
|
|
24
|
+
agent explicitly allowed direct steering (and `alertMode` permits it).
|
|
25
|
+
Exception: when the config sets `steerDryRunDefault: false`, the user has
|
|
26
|
+
pre-authorized automatic steering — you may steer without per-case
|
|
27
|
+
approval.
|
|
28
|
+
|
|
29
|
+
When reporting a stuck task, include:
|
|
30
|
+
- sub-agent id/name
|
|
31
|
+
- reason
|
|
32
|
+
- repeated LLM message or error summary
|
|
33
|
+
- last activity time
|
|
34
|
+
- suggested rescue message (from `watchdog_detect_stuck` output)
|
|
35
|
+
|
|
36
|
+
If nothing is stuck, reply briefly that all targets look healthy — do not alert.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: watchdog-supervisor
|
|
3
|
+
description: |
|
|
4
|
+
Supervise Pi task sub-agents for stuck/loop states. Use when acting as a
|
|
5
|
+
watchdog sub-agent, or as a main agent coordinating task sub-agents with a
|
|
6
|
+
watchdog. Detects repeated LLM input/output message bodies (timestamps and
|
|
7
|
+
ids normalized away) and idle-without-progress via deterministic watchdog tools, then reports
|
|
8
|
+
compact alerts to the main session.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Watchdog Supervisor
|
|
12
|
+
|
|
13
|
+
Use this skill when supervising task sub-agents.
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
1. Call `watchdog_list_targets`.
|
|
18
|
+
2. Ignore yourself and completed agents.
|
|
19
|
+
3. For each running task agent, call `watchdog_detect_stuck`.
|
|
20
|
+
4. If likely stuck, call `watchdog_read_events` for evidence.
|
|
21
|
+
5. Report a compact alert to the main agent via `watchdog_alert_main`.
|
|
22
|
+
6. Do not directly steer unless explicitly allowed — `watchdog_steer_subagent`
|
|
23
|
+
defaults to dry-run, and real steering is refused while `alertMode` is
|
|
24
|
+
`main_only`.
|
|
25
|
+
|
|
26
|
+
## Notes
|
|
27
|
+
|
|
28
|
+
- Alerts respect a per-target cooldown (`cooldownSec`: `0` default = no
|
|
29
|
+
cooldown, `-1` = same evidence alerts once, `>0` = seconds) and the
|
|
30
|
+
pause state (`/watchdog pause`); a suppressed alert returns a clear reason.
|
|
31
|
+
- The repetition counter resets at each alert: only llm messages after the
|
|
32
|
+
last alert count toward the next one.
|
|
33
|
+
- `watchdog_detect_stuck` output includes `suggestedRescueMessage` — include it
|
|
34
|
+
in the alert so the main agent can forward it as-is.
|
|
35
|
+
- Read or tune policy with `watchdog_config` (`action: "get" | "set"`).
|
package/src/checker.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { WatchdogConfig } from './types.ts';
|
|
2
|
+
import type { WatchdogStore } from './store.ts';
|
|
3
|
+
import { mergeConfig } from './config.ts';
|
|
4
|
+
import { detectStuck, shouldAlert } from './detector.ts';
|
|
5
|
+
|
|
6
|
+
// Minimal message surface used by the checker; satisfied by ExtensionAPI
|
|
7
|
+
export type SendMessageLike = {
|
|
8
|
+
sendMessage(
|
|
9
|
+
message: { customType: string; content: string; display: boolean },
|
|
10
|
+
options: { deliverAs: 'steer'; triggerTurn: boolean },
|
|
11
|
+
): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Event-driven self-check: invoked on every LLM round-trip (provider request /
|
|
15
|
+
// response) instead of a timer, so a loop is caught on the round-trip that
|
|
16
|
+
// crosses the threshold. Runs in every session (main or child); each session
|
|
17
|
+
// checks its own buffer and steers itself.
|
|
18
|
+
export const createStuckChecker = (
|
|
19
|
+
pi: SendMessageLike,
|
|
20
|
+
store: WatchdogStore,
|
|
21
|
+
sessionId: string,
|
|
22
|
+
config: WatchdogConfig,
|
|
23
|
+
): (() => void) => {
|
|
24
|
+
return () => {
|
|
25
|
+
if (store.isPaused()) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const effective = mergeConfig(config, store.getConfigOverride());
|
|
29
|
+
if (!effective.enabled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const lastAlert = store.getLastAlert(sessionId);
|
|
34
|
+
const analysis = detectStuck(store.getEvents(sessionId), effective, now, lastAlert?.at ?? 0);
|
|
35
|
+
if (!analysis.likelyStuck) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!shouldAlert(lastAlert, analysis, now, effective.cooldownSec)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const rescueMessage = store.getRescueMessage() ?? effective.rescueMessage;
|
|
42
|
+
pi.sendMessage(
|
|
43
|
+
{
|
|
44
|
+
customType: 'watchdog-alert',
|
|
45
|
+
content: `[Watchdog] ${rescueMessage}\nReasons: ${analysis.reasons.join('; ')}`,
|
|
46
|
+
display: true,
|
|
47
|
+
},
|
|
48
|
+
// 'steer' is delivered after the current tool calls, before the next LLM
|
|
49
|
+
// call; 'nextTurn' would wait for a user prompt that never comes in a loop
|
|
50
|
+
{ deliverAs: 'steer', triggerTurn: true },
|
|
51
|
+
);
|
|
52
|
+
store.recordAlert(sessionId, analysis.evidenceKey, now);
|
|
53
|
+
};
|
|
54
|
+
};
|