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
package/src/collector.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { WatchdogConfig, WatchdogEventType } from './types.ts';
|
|
3
|
+
import type { WatchdogStore } from './store.ts';
|
|
4
|
+
import { hashOutput, normalizeCommand, normalizeLlmBody } from './normalize.ts';
|
|
5
|
+
|
|
6
|
+
// Minimal event surface used by the collector; satisfied by ExtensionAPI's
|
|
7
|
+
// tool_call / tool_result overloads and by test fakes
|
|
8
|
+
type PiLike = {
|
|
9
|
+
on(event: 'tool_call', handler: (event: unknown) => void): void;
|
|
10
|
+
on(event: 'tool_result', handler: (event: unknown) => void): void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const EDIT_TOOL_NAMES = new Set(['edit', 'write', 'patch', 'multi_edit']);
|
|
14
|
+
|
|
15
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
16
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
17
|
+
|
|
18
|
+
// commandKey: bash command when present, otherwise toolName + first string input value.
|
|
19
|
+
// Edit-family keys also include a hash of the full input, so re-running the exact
|
|
20
|
+
// same edit shares a key (loop signal) while a different edit to the same file
|
|
21
|
+
// gets a new key (progress).
|
|
22
|
+
const extractCommandKey = (toolName: string, input: Record<string, unknown>): string => {
|
|
23
|
+
if (typeof input.command === 'string') {
|
|
24
|
+
return normalizeCommand(input.command);
|
|
25
|
+
}
|
|
26
|
+
const firstString = Object.values(input).find((value) => typeof value === 'string');
|
|
27
|
+
const base = normalizeCommand(firstString ? `${toolName} ${firstString}` : toolName);
|
|
28
|
+
if (EDIT_TOOL_NAMES.has(toolName.toLowerCase())) {
|
|
29
|
+
const inputHash = createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 12);
|
|
30
|
+
return `${base} #${inputHash}`;
|
|
31
|
+
}
|
|
32
|
+
return base;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const extractResultText = (content: unknown): string => {
|
|
36
|
+
if (!Array.isArray(content)) {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
return content
|
|
40
|
+
.map((item) => {
|
|
41
|
+
const record = asRecord(item);
|
|
42
|
+
return record && record.type === 'text' && typeof record.text === 'string'
|
|
43
|
+
? record.text
|
|
44
|
+
: '';
|
|
45
|
+
})
|
|
46
|
+
.filter((text) => text !== '')
|
|
47
|
+
.join('\n');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const startCollector = (
|
|
51
|
+
pi: PiLike,
|
|
52
|
+
store: WatchdogStore,
|
|
53
|
+
sessionId: string,
|
|
54
|
+
config: WatchdogConfig,
|
|
55
|
+
): (() => void) => {
|
|
56
|
+
let active = true;
|
|
57
|
+
|
|
58
|
+
pi.on('tool_call', (event) => {
|
|
59
|
+
if (!active) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const data = asRecord(event);
|
|
63
|
+
const input = asRecord(data?.input);
|
|
64
|
+
if (!data || typeof data.toolName !== 'string' || !input) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const commandKey = extractCommandKey(data.toolName, input);
|
|
68
|
+
const type: WatchdogEventType = EDIT_TOOL_NAMES.has(data.toolName.toLowerCase())
|
|
69
|
+
? 'edit'
|
|
70
|
+
: 'tool_call';
|
|
71
|
+
store.appendEvent(sessionId, {
|
|
72
|
+
at: Date.now(),
|
|
73
|
+
type,
|
|
74
|
+
summary: `${data.toolName}: ${commandKey}`,
|
|
75
|
+
commandKey,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on('tool_result', (event) => {
|
|
80
|
+
if (!active) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const data = asRecord(event);
|
|
84
|
+
if (!data || typeof data.toolName !== 'string') {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const input = asRecord(data.input) ?? {};
|
|
88
|
+
const commandKey = extractCommandKey(data.toolName, input);
|
|
89
|
+
const { hash, preview } = hashOutput(extractResultText(data.content), {
|
|
90
|
+
previewLines: config.maxPreviewLines,
|
|
91
|
+
});
|
|
92
|
+
store.appendEvent(sessionId, {
|
|
93
|
+
at: Date.now(),
|
|
94
|
+
type: 'tool_result',
|
|
95
|
+
summary: `${data.toolName} result${data.isError === true ? ' (error)' : ''}`,
|
|
96
|
+
commandKey,
|
|
97
|
+
outputHash: hash,
|
|
98
|
+
outputPreview: preview,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
active = false;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Minimal event surface for the LLM-level collector (same sources as the
|
|
108
|
+
// lm-debug widget: provider request payload + finalized assistant message)
|
|
109
|
+
type LlmPiLike = {
|
|
110
|
+
on(event: 'before_provider_request', handler: (event: unknown) => void): void;
|
|
111
|
+
on(event: 'message_end', handler: (event: unknown) => void): void;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const SNIPPET_LEN = 100;
|
|
115
|
+
|
|
116
|
+
const toSnippet = (raw: string): string => {
|
|
117
|
+
const flat = raw.replace(/\s+/g, ' ').trim();
|
|
118
|
+
return flat.length > SNIPPET_LEN ? `${flat.slice(0, SNIPPET_LEN)}…` : flat;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Text from OpenAI-style content: plain string or parts with text fields
|
|
122
|
+
const extractPayloadText = (content: unknown): string => {
|
|
123
|
+
if (typeof content === 'string') {
|
|
124
|
+
return content;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(content)) {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
return content
|
|
130
|
+
.map((part) => {
|
|
131
|
+
const record = asRecord(part);
|
|
132
|
+
return typeof record?.text === 'string' ? record.text : '';
|
|
133
|
+
})
|
|
134
|
+
.filter((text) => text !== '')
|
|
135
|
+
.join('\n');
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Body of the newest message sent to the provider. The full payload always
|
|
139
|
+
// grows with the context, so only the last message (the per-turn delta) is
|
|
140
|
+
// comparable across loop iterations.
|
|
141
|
+
export const extractSentBody = (payload: unknown): string | undefined => {
|
|
142
|
+
const record = asRecord(payload);
|
|
143
|
+
const messages = Array.isArray(record?.messages) ? (record.messages as unknown[]) : [];
|
|
144
|
+
const last = asRecord(messages[messages.length - 1]);
|
|
145
|
+
if (!last) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const role = typeof last.role === 'string' ? last.role : 'unknown';
|
|
149
|
+
return `${role}: ${extractPayloadText(last.content)}`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Body of a finalized assistant message: text + thinking + tool calls
|
|
153
|
+
// (name and arguments — a loop usually re-issues the identical call)
|
|
154
|
+
export const extractAssistantBody = (message: unknown): string | undefined => {
|
|
155
|
+
const record = asRecord(message);
|
|
156
|
+
if (record?.role !== 'assistant' || !Array.isArray(record.content)) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
for (const block of record.content) {
|
|
161
|
+
const blockRecord = asRecord(block);
|
|
162
|
+
if (!blockRecord) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (blockRecord.type === 'text' && typeof blockRecord.text === 'string') {
|
|
166
|
+
parts.push(blockRecord.text);
|
|
167
|
+
} else if (blockRecord.type === 'thinking' && typeof blockRecord.thinking === 'string') {
|
|
168
|
+
parts.push(blockRecord.thinking);
|
|
169
|
+
} else if (blockRecord.type === 'toolCall') {
|
|
170
|
+
const name = typeof blockRecord.name === 'string' ? blockRecord.name : 'tool';
|
|
171
|
+
parts.push(`toolCall ${name} ${JSON.stringify(blockRecord.arguments ?? {})}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return parts.join('\n');
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const startLlmCollector = (
|
|
178
|
+
pi: LlmPiLike,
|
|
179
|
+
store: WatchdogStore,
|
|
180
|
+
sessionId: string,
|
|
181
|
+
// Invoked right after each llm event is appended — the hook for the
|
|
182
|
+
// event-driven stuck check
|
|
183
|
+
onEvent?: () => void,
|
|
184
|
+
): (() => void) => {
|
|
185
|
+
let active = true;
|
|
186
|
+
|
|
187
|
+
const append = (type: 'llm_input' | 'llm_output', body: string) => {
|
|
188
|
+
const normalized = normalizeLlmBody(body);
|
|
189
|
+
store.appendEvent(sessionId, {
|
|
190
|
+
at: Date.now(),
|
|
191
|
+
type,
|
|
192
|
+
summary: `${type}: ${toSnippet(normalized)}`,
|
|
193
|
+
commandKey: type,
|
|
194
|
+
outputHash: createHash('sha256').update(normalized).digest('hex'),
|
|
195
|
+
outputPreview: toSnippet(normalized),
|
|
196
|
+
});
|
|
197
|
+
onEvent?.();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
pi.on('before_provider_request', (event) => {
|
|
201
|
+
if (!active) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const body = extractSentBody(asRecord(event)?.payload);
|
|
205
|
+
if (body !== undefined) {
|
|
206
|
+
append('llm_input', body);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on('message_end', (event) => {
|
|
211
|
+
if (!active) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const body = extractAssistantBody(asRecord(event)?.message);
|
|
215
|
+
if (body !== undefined) {
|
|
216
|
+
append('llm_output', body);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return () => {
|
|
221
|
+
active = false;
|
|
222
|
+
};
|
|
223
|
+
};
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { WatchdogConfig, WatchdogEvent, WatchdogTarget } from './types.ts';
|
|
6
|
+
import type { TargetRegistry } from './registry.ts';
|
|
7
|
+
import type { WatchdogStore } from './store.ts';
|
|
8
|
+
import { mergeConfig } from './config.ts';
|
|
9
|
+
import { detectStuck } from './detector.ts';
|
|
10
|
+
import type { SubagentsIntegration } from './integrations/gotgenes-subagents.ts';
|
|
11
|
+
|
|
12
|
+
type WatchdogRuntime = {
|
|
13
|
+
config: WatchdogConfig;
|
|
14
|
+
registry: TargetRegistry;
|
|
15
|
+
getIntegration: () => Promise<SubagentsIntegration>;
|
|
16
|
+
store: WatchdogStore;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SUBCOMMANDS = 'status | config | set rescueMessage <msg> | pause | resume | inspect <targetId>';
|
|
20
|
+
|
|
21
|
+
const formatTargets = (
|
|
22
|
+
targets: WatchdogTarget[],
|
|
23
|
+
store: WatchdogStore,
|
|
24
|
+
config: WatchdogConfig,
|
|
25
|
+
): string => {
|
|
26
|
+
if (targets.length === 0) {
|
|
27
|
+
return ' (none)';
|
|
28
|
+
}
|
|
29
|
+
return targets
|
|
30
|
+
.map((target) => {
|
|
31
|
+
const key = store.resolveTargetKey(target.id);
|
|
32
|
+
const events = key ? store.getEvents(key) : [];
|
|
33
|
+
const analysis =
|
|
34
|
+
events.length > 0
|
|
35
|
+
? detectStuck(events, config, Date.now(), store.getLastAlert(key ?? '')?.at ?? 0)
|
|
36
|
+
: undefined;
|
|
37
|
+
return [
|
|
38
|
+
` [${target.kind}]`.padEnd(13),
|
|
39
|
+
target.id.padEnd(10),
|
|
40
|
+
target.status.padEnd(10),
|
|
41
|
+
`tools=${target.toolCallCount}`.padEnd(10),
|
|
42
|
+
`last=${new Date(target.lastActiveAt).toISOString()}`,
|
|
43
|
+
events.length > 0 ? `events=${events.length}` : '',
|
|
44
|
+
target.name ? `"${target.name}"` : '',
|
|
45
|
+
analysis?.likelyStuck ? `!! likely stuck (${analysis.confidence})` : '',
|
|
46
|
+
]
|
|
47
|
+
.join(' ')
|
|
48
|
+
.trimEnd();
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const formatEvent = (event: WatchdogEvent): string => {
|
|
54
|
+
const time = new Date(event.at).toISOString().slice(11, 19);
|
|
55
|
+
const hash = event.outputHash ? ` [${event.outputHash.slice(0, 8)}]` : '';
|
|
56
|
+
return ` ${time} ${event.type.padEnd(11)} ${event.summary}${hash}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const output = (ctx: ExtensionCommandContext, message: string) => {
|
|
60
|
+
if (ctx.hasUI) {
|
|
61
|
+
ctx.ui.notify(message, 'info');
|
|
62
|
+
} else {
|
|
63
|
+
console.log(message);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const registerWatchdogCommands = (pi: ExtensionAPI, runtime: WatchdogRuntime) => {
|
|
68
|
+
pi.registerCommand('watchdog', {
|
|
69
|
+
description: 'Watchdog supervisor: status, config, pause/resume, rescue message',
|
|
70
|
+
handler: async (args, ctx) => {
|
|
71
|
+
const [subcommand, ...rest] = args.trim().split(/\s+/);
|
|
72
|
+
switch (subcommand) {
|
|
73
|
+
case 'status': {
|
|
74
|
+
const { config, registry, getIntegration, store } = runtime;
|
|
75
|
+
const header = [
|
|
76
|
+
'watchdog-supervisor loaded',
|
|
77
|
+
`enabled=${config.enabled}`,
|
|
78
|
+
`paused=${store.isPaused()}`,
|
|
79
|
+
`alertMode=${config.alertMode}`,
|
|
80
|
+
].join(' | ');
|
|
81
|
+
const integration = await getIntegration();
|
|
82
|
+
if (!integration.available) {
|
|
83
|
+
output(
|
|
84
|
+
ctx,
|
|
85
|
+
`${header}\ntargets: unavailable — install @gotgenes/pi-subagents (${integration.reason})`,
|
|
86
|
+
);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
registry.syncFromRecords(integration.listAgents());
|
|
90
|
+
const targets = registry.list();
|
|
91
|
+
output(
|
|
92
|
+
ctx,
|
|
93
|
+
`${header}\ntargets (${targets.length}):\n${formatTargets(targets, runtime.store, config)}`,
|
|
94
|
+
);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'config': {
|
|
98
|
+
const merged = mergeConfig(runtime.config, runtime.store.getConfigOverride());
|
|
99
|
+
const effective = {
|
|
100
|
+
...merged,
|
|
101
|
+
rescueMessage: runtime.store.getRescueMessage() ?? merged.rescueMessage,
|
|
102
|
+
};
|
|
103
|
+
output(ctx, JSON.stringify(effective, null, 2));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'set': {
|
|
107
|
+
const [key, ...messageParts] = rest;
|
|
108
|
+
const message = messageParts.join(' ');
|
|
109
|
+
if (key !== 'rescueMessage' || message === '') {
|
|
110
|
+
output(ctx, 'Usage: /watchdog set rescueMessage <message>');
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
runtime.store.setRescueMessage(message);
|
|
114
|
+
output(ctx, 'rescueMessage updated for this session');
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'pause':
|
|
118
|
+
runtime.store.setPaused(true);
|
|
119
|
+
output(ctx, 'watchdog alerting paused');
|
|
120
|
+
break;
|
|
121
|
+
case 'resume':
|
|
122
|
+
runtime.store.setPaused(false);
|
|
123
|
+
output(ctx, 'watchdog alerting resumed');
|
|
124
|
+
break;
|
|
125
|
+
case 'inspect': {
|
|
126
|
+
const [targetId] = rest;
|
|
127
|
+
if (!targetId) {
|
|
128
|
+
output(ctx, 'Usage: /watchdog inspect <targetId>');
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const key = runtime.store.resolveTargetKey(targetId);
|
|
132
|
+
if (!key) {
|
|
133
|
+
output(ctx, `Unknown target: ${targetId}`);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const events = runtime.store.getEvents(key, 20);
|
|
137
|
+
if (events.length === 0) {
|
|
138
|
+
output(ctx, `No events recorded for ${targetId}`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const analysis = detectStuck(
|
|
142
|
+
runtime.store.getEvents(key),
|
|
143
|
+
runtime.config,
|
|
144
|
+
Date.now(),
|
|
145
|
+
runtime.store.getLastAlert(key)?.at ?? 0,
|
|
146
|
+
);
|
|
147
|
+
output(
|
|
148
|
+
ctx,
|
|
149
|
+
`events for ${targetId} (latest ${events.length}):\n${events.map(formatEvent).join('\n')}\n` +
|
|
150
|
+
`analysis: likelyStuck=${analysis.likelyStuck} confidence=${analysis.confidence} reasons=[${analysis.reasons.join('; ')}]`,
|
|
151
|
+
);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
output(ctx, `Unknown subcommand. Available: ${SUBCOMMANDS}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { WatchdogConfig } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CONFIG: WatchdogConfig = {
|
|
7
|
+
enabled: true,
|
|
8
|
+
rescueMessage:
|
|
9
|
+
'The AI agent might be stuck. Stop repeating the current actions, then resume the task.',
|
|
10
|
+
llmRepeatThreshold: 3,
|
|
11
|
+
idleNoProgressSec: 0, // 0 = idle_no_progress detection disabled
|
|
12
|
+
cooldownSec: 0, // 0 = no cooldown; -1 = same evidence alerts once; >0 = seconds
|
|
13
|
+
maxPreviewLines: 20,
|
|
14
|
+
maxEventsPerAgent: 200,
|
|
15
|
+
alertMode: 'main_only',
|
|
16
|
+
steerDryRunDefault: null, // null = built-in safe default (dry-run)
|
|
17
|
+
debug: false, // show the lm-debug console (full sent/received messages)
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getConfigPaths = (projectDir: string) => ({
|
|
21
|
+
globalPath: join(homedir(), '.pi', 'agent', 'watchdog-supervisor', 'config.json'),
|
|
22
|
+
projectPath: join(projectDir, '.pi', 'watchdog-supervisor.json'),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const loadConfigFile = (
|
|
26
|
+
path: string,
|
|
27
|
+
): { config: Partial<WatchdogConfig> | null; warning?: string } => {
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = readFileSync(path, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
// missing file is not an error
|
|
33
|
+
return { config: null };
|
|
34
|
+
}
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return { config: null, warning: `Invalid JSON in ${path}; file ignored` };
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
42
|
+
return { config: null, warning: `Expected a JSON object in ${path}; file ignored` };
|
|
43
|
+
}
|
|
44
|
+
return { config: pickKnownConfig(parsed as Record<string, unknown>) };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Keep only WatchdogConfig fields; drop unknown keys
|
|
48
|
+
export const pickKnownConfig = (raw: Record<string, unknown>): Partial<WatchdogConfig> => {
|
|
49
|
+
const config: Partial<WatchdogConfig> = {};
|
|
50
|
+
for (const key of Object.keys(DEFAULT_CONFIG) as Array<keyof WatchdogConfig>) {
|
|
51
|
+
if (key in raw) {
|
|
52
|
+
(config as Record<string, unknown>)[key] = raw[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return config;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const mergeConfig = (
|
|
59
|
+
...parts: Array<Partial<WatchdogConfig> | null | undefined>
|
|
60
|
+
): WatchdogConfig => {
|
|
61
|
+
const merged: WatchdogConfig = { ...DEFAULT_CONFIG };
|
|
62
|
+
for (const part of parts) {
|
|
63
|
+
if (part) {
|
|
64
|
+
Object.assign(merged, part);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const loadEffectiveConfig = (
|
|
71
|
+
projectDir: string,
|
|
72
|
+
): { config: WatchdogConfig; warnings: string[] } => {
|
|
73
|
+
const { globalPath, projectPath } = getConfigPaths(projectDir);
|
|
74
|
+
const globalResult = loadConfigFile(globalPath);
|
|
75
|
+
const projectResult = loadConfigFile(projectPath);
|
|
76
|
+
const warnings = [globalResult.warning, projectResult.warning].filter(
|
|
77
|
+
(warning): warning is string => warning !== undefined,
|
|
78
|
+
);
|
|
79
|
+
return { config: mergeConfig(globalResult.config, projectResult.config), warnings };
|
|
80
|
+
};
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
StuckAnalysis,
|
|
4
|
+
StuckEvidence,
|
|
5
|
+
WatchdogConfig,
|
|
6
|
+
WatchdogEvent,
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
|
|
9
|
+
const RECENT_ACTIVITY_MS = 60_000;
|
|
10
|
+
const MIN_TOOL_EVENTS_FOR_IDLE = 5;
|
|
11
|
+
|
|
12
|
+
// Longest run of consecutive identical normalized-body hashes among events of
|
|
13
|
+
// one LLM type, ignoring events at or before sinceAt. Consecutive (rather than
|
|
14
|
+
// total) keeps ordinary re-visits of an earlier message from counting as a
|
|
15
|
+
// loop; sinceAt resets the counter after an alert so a historical run does not
|
|
16
|
+
// keep re-triggering once the agent has recovered.
|
|
17
|
+
const longestIdenticalRun = (
|
|
18
|
+
events: WatchdogEvent[],
|
|
19
|
+
type: 'llm_input' | 'llm_output',
|
|
20
|
+
sinceAt: number,
|
|
21
|
+
): { length: number; hash?: string } => {
|
|
22
|
+
let best = 0;
|
|
23
|
+
let bestHash: string | undefined;
|
|
24
|
+
let current = 0;
|
|
25
|
+
let currentHash: string | undefined;
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
if (event.type !== type || !event.outputHash || event.at <= sinceAt) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
current = event.outputHash === currentHash ? current + 1 : 1;
|
|
31
|
+
currentHash = event.outputHash;
|
|
32
|
+
if (current > best) {
|
|
33
|
+
best = current;
|
|
34
|
+
bestHash = currentHash;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { length: best, hash: bestHash };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const detectIdleNoProgress = (
|
|
41
|
+
events: WatchdogEvent[],
|
|
42
|
+
config: WatchdogConfig,
|
|
43
|
+
now: number,
|
|
44
|
+
): boolean => {
|
|
45
|
+
// idleNoProgressSec <= 0 disables this rule
|
|
46
|
+
if (config.idleNoProgressSec <= 0 || events.length === 0) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const last = events[events.length - 1];
|
|
50
|
+
if (now - last.at >= RECENT_ACTIVITY_MS) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const idleMs = config.idleNoProgressSec * 1000;
|
|
54
|
+
const lastEdit = [...events].reverse().find((event) => event.type === 'edit');
|
|
55
|
+
const progressCutoff = lastEdit ? lastEdit.at : events[0].at;
|
|
56
|
+
if (lastEdit && now - lastEdit.at < idleMs) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (!lastEdit && now - events[0].at < idleMs) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const toolEvents = events.filter(
|
|
63
|
+
(event) =>
|
|
64
|
+
(event.type === 'tool_call' || event.type === 'tool_result') && event.at >= progressCutoff,
|
|
65
|
+
);
|
|
66
|
+
return toolEvents.length >= MIN_TOOL_EVENTS_FOR_IDLE;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const detectStuck = (
|
|
70
|
+
events: WatchdogEvent[],
|
|
71
|
+
config: WatchdogConfig,
|
|
72
|
+
now: number,
|
|
73
|
+
// Only events after this timestamp count toward llm repetition — pass the
|
|
74
|
+
// last alert time so the counter resets after each alert
|
|
75
|
+
sinceAt = 0,
|
|
76
|
+
): StuckAnalysis => {
|
|
77
|
+
const evidence: StuckEvidence[] = [];
|
|
78
|
+
const reasons: string[] = [];
|
|
79
|
+
const triggers: string[] = [];
|
|
80
|
+
|
|
81
|
+
// LLM-level repetition: same normalized message body (timestamps, ids and
|
|
82
|
+
// digit runs stripped) recurring back-to-back. llmRepeatThreshold <= 0
|
|
83
|
+
// disables this rule.
|
|
84
|
+
if (config.llmRepeatThreshold > 0) {
|
|
85
|
+
const rules = [
|
|
86
|
+
{ type: 'llm_input', evidenceType: 'repeated_llm_input', label: 'input sent to the LLM' },
|
|
87
|
+
{ type: 'llm_output', evidenceType: 'repeated_llm_output', label: 'LLM output' },
|
|
88
|
+
] as const;
|
|
89
|
+
for (const rule of rules) {
|
|
90
|
+
const run = longestIdenticalRun(events, rule.type, sinceAt);
|
|
91
|
+
if (run.length >= config.llmRepeatThreshold && run.hash) {
|
|
92
|
+
evidence.push({
|
|
93
|
+
type: rule.evidenceType,
|
|
94
|
+
summary: `llm message repeated ${run.length} times: same ${rule.label} body (hash ${run.hash.slice(0, 8)})`,
|
|
95
|
+
});
|
|
96
|
+
reasons.push(`the same ${rule.label} recurred ${run.length} times in a row`);
|
|
97
|
+
triggers.push(`${rule.type}:${run.hash}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (detectIdleNoProgress(events, config, now)) {
|
|
103
|
+
evidence.push({
|
|
104
|
+
type: 'idle_no_progress',
|
|
105
|
+
summary: `no edit for >= ${config.idleNoProgressSec}s while tools keep running`,
|
|
106
|
+
});
|
|
107
|
+
reasons.push('tools keep running but nothing has been edited');
|
|
108
|
+
triggers.push('idle_no_progress');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const likelyStuck = evidence.length > 0;
|
|
112
|
+
const distinctTypes = new Set(evidence.map((item) => item.type)).size;
|
|
113
|
+
const evidenceKey = likelyStuck
|
|
114
|
+
? createHash('sha256')
|
|
115
|
+
.update(
|
|
116
|
+
`${[...new Set(evidence.map((item) => item.type))].sort().join(',')}|${[...triggers].sort().join(',')}`,
|
|
117
|
+
)
|
|
118
|
+
.digest('hex')
|
|
119
|
+
.slice(0, 16)
|
|
120
|
+
: '';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
likelyStuck,
|
|
124
|
+
confidence: distinctTypes >= 2 ? 'high' : distinctTypes === 1 ? 'medium' : 'low',
|
|
125
|
+
reasons,
|
|
126
|
+
evidence,
|
|
127
|
+
evidenceKey,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const shouldAlert = (
|
|
132
|
+
lastAlert: { at: number; evidenceKey: string } | undefined,
|
|
133
|
+
analysis: StuckAnalysis,
|
|
134
|
+
now: number,
|
|
135
|
+
cooldownSec: number,
|
|
136
|
+
): boolean => {
|
|
137
|
+
if (!analysis.likelyStuck) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (!lastAlert) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
// cooldownSec === 0: no cooldown, alert on every check that meets the threshold
|
|
144
|
+
if (cooldownSec === 0) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
// cooldownSec < 0: infinite cooldown, same evidence alerts only once
|
|
148
|
+
const inCooldown = cooldownSec < 0 || now - lastAlert.at < cooldownSec * 1000;
|
|
149
|
+
return !(inCooldown && analysis.evidenceKey === lastAlert.evidenceKey);
|
|
150
|
+
};
|