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.
Files changed (41) hide show
  1. package/.tmp/2026-07-08_pi_watchdog_extension_requirement.md +907 -0
  2. package/.tmp/2026-07-08_pi_watchdog_high_level_spec.md +192 -0
  3. package/.tmp/2026-07-08_pi_watchdog_task01_low_level_spec.md +234 -0
  4. package/.tmp/2026-07-08_pi_watchdog_task02_low_level_spec.md +158 -0
  5. package/.tmp/2026-07-08_pi_watchdog_task03_low_level_spec.md +175 -0
  6. package/.tmp/2026-07-08_pi_watchdog_task04_low_level_spec.md +151 -0
  7. package/.tmp/2026-07-08_pi_watchdog_task05_low_level_spec.md +116 -0
  8. package/.tmp/2026-07-08_pi_watchdog_task06_low_level_spec.md +69 -0
  9. package/.tmp/detect_approach.patch +253 -0
  10. package/README.md +176 -0
  11. package/examples/AGENTS.md +27 -0
  12. package/examples/watchdog-supervisor.json +12 -0
  13. package/package.json +39 -0
  14. package/prompts/watchdog-agent.md +36 -0
  15. package/skills/watchdog-supervisor/SKILL.md +35 -0
  16. package/src/checker.ts +54 -0
  17. package/src/collector.ts +223 -0
  18. package/src/commands.ts +159 -0
  19. package/src/config.ts +80 -0
  20. package/src/detector.ts +150 -0
  21. package/src/index.ts +94 -0
  22. package/src/integrations/gotgenes-subagents.ts +45 -0
  23. package/src/lmdebug.ts +128 -0
  24. package/src/normalize.ts +40 -0
  25. package/src/registry.ts +121 -0
  26. package/src/store.ts +137 -0
  27. package/src/tools.ts +289 -0
  28. package/src/types.ts +80 -0
  29. package/test/Screenshot_20260708_220337.png +0 -0
  30. package/test/checker.test.ts +118 -0
  31. package/test/collector.test.ts +201 -0
  32. package/test/config.test.ts +131 -0
  33. package/test/detector.test.ts +224 -0
  34. package/test/integration.test.ts +57 -0
  35. package/test/lmdebug.test.ts +114 -0
  36. package/test/normalize.test.ts +78 -0
  37. package/test/registry.test.ts +149 -0
  38. package/test/store.test.ts +176 -0
  39. package/test/tools.test.ts +315 -0
  40. package/tsconfig.json +13 -0
  41. 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
+ };