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/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { loadEffectiveConfig } from './config.ts';
|
|
3
|
+
import { registerWatchdogCommands } from './commands.ts';
|
|
4
|
+
import { registerWatchdogTools } from './tools.ts';
|
|
5
|
+
import { createTargetRegistry } from './registry.ts';
|
|
6
|
+
import { getOrCreateStore } from './store.ts';
|
|
7
|
+
import { startCollector, startLlmCollector } from './collector.ts';
|
|
8
|
+
import { createStuckChecker } from './checker.ts';
|
|
9
|
+
import { registerLmDebugWidget } from './lmdebug.ts';
|
|
10
|
+
import {
|
|
11
|
+
connectSubagents,
|
|
12
|
+
SUBAGENT_EVENT_CHANNELS,
|
|
13
|
+
type SubagentsIntegration,
|
|
14
|
+
} from './integrations/gotgenes-subagents.ts';
|
|
15
|
+
|
|
16
|
+
// This factory runs in the parent session AND in every child session
|
|
17
|
+
// (pi-subagents children always load the parent's extensions).
|
|
18
|
+
export default function watchdogSupervisor(pi: ExtensionAPI) {
|
|
19
|
+
const { config, warnings } = loadEffectiveConfig(process.cwd());
|
|
20
|
+
const registry = createTargetRegistry();
|
|
21
|
+
const store = getOrCreateStore(config.maxEventsPerAgent);
|
|
22
|
+
|
|
23
|
+
// Lazy + retry: the subagents extension may publish its service after we load
|
|
24
|
+
let integration: SubagentsIntegration | undefined;
|
|
25
|
+
const getIntegration = async () => {
|
|
26
|
+
if (!integration?.available) {
|
|
27
|
+
integration = await connectSubagents();
|
|
28
|
+
}
|
|
29
|
+
return integration;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Parent side: fires before the child's extensions bind (synchronous dispatch),
|
|
33
|
+
// so the child instance can recognize itself in session_start below.
|
|
34
|
+
pi.events.on('subagents:child:session-created', (data) => {
|
|
35
|
+
const record = typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
|
|
36
|
+
if (record && typeof record.sessionId === 'string') {
|
|
37
|
+
store.registerChild(
|
|
38
|
+
record.sessionId,
|
|
39
|
+
typeof record.parentSessionId === 'string' ? record.parentSessionId : undefined,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const channel of SUBAGENT_EVENT_CHANNELS) {
|
|
45
|
+
pi.events.on(channel, (data) => {
|
|
46
|
+
registry.applyEvent(channel, data, Date.now());
|
|
47
|
+
if (channel === 'subagents:started') {
|
|
48
|
+
const record = typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
|
|
49
|
+
if (record && typeof record.id === 'string') {
|
|
50
|
+
store.linkNextAgent(record.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
registerLmDebugWidget(pi, config.debug);
|
|
57
|
+
registerWatchdogCommands(pi, { config, registry, getIntegration, store });
|
|
58
|
+
registerWatchdogTools(pi, {
|
|
59
|
+
store,
|
|
60
|
+
registry,
|
|
61
|
+
getIntegration,
|
|
62
|
+
baseConfig: config,
|
|
63
|
+
now: () => Date.now(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
pi.on('session_start', (_event, ctx) => {
|
|
67
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
68
|
+
// Every session (main or child) collects its own tool + llm events and
|
|
69
|
+
// self-checks on every LLM round-trip (no timer), so a stuck main session
|
|
70
|
+
// rescues itself immediately even with no watchdog sub-agent around.
|
|
71
|
+
const checkStuck = createStuckChecker(pi, store, sessionId, config);
|
|
72
|
+
startCollector(pi, store, sessionId, config);
|
|
73
|
+
startLlmCollector(pi, store, sessionId, checkStuck);
|
|
74
|
+
pi.on('after_provider_response', () => checkStuck());
|
|
75
|
+
if (!store.getChild(sessionId)) {
|
|
76
|
+
// Non-child (main) side: receive alerts from watchdog tools running in
|
|
77
|
+
// child sessions. Last non-child session wins if several exist.
|
|
78
|
+
store.setAlertSink((message, severity) => {
|
|
79
|
+
pi.sendMessage(
|
|
80
|
+
{ customType: 'watchdog-alert', content: message, display: true },
|
|
81
|
+
// 'steer' reaches a looping session; 'nextTurn' would wait for a
|
|
82
|
+
// user prompt that never comes while the agent is stuck
|
|
83
|
+
{ deliverAs: 'steer', triggerTurn: severity === 'critical' },
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (ctx.hasUI) {
|
|
89
|
+
for (const warning of warnings) {
|
|
90
|
+
ctx.ui.notify(warning, 'warning');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SubagentRecordLike } from '../registry.ts';
|
|
2
|
+
|
|
3
|
+
export type SubagentsIntegration =
|
|
4
|
+
| {
|
|
5
|
+
available: true;
|
|
6
|
+
listAgents: () => SubagentRecordLike[];
|
|
7
|
+
steer: (agentId: string, message: string) => Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
| { available: false; reason: string };
|
|
10
|
+
|
|
11
|
+
// Lifecycle channels emitted by @gotgenes/pi-subagents on pi.events
|
|
12
|
+
export const SUBAGENT_EVENT_CHANNELS = [
|
|
13
|
+
'subagents:created',
|
|
14
|
+
'subagents:started',
|
|
15
|
+
'subagents:completed',
|
|
16
|
+
'subagents:failed',
|
|
17
|
+
'subagents:steered',
|
|
18
|
+
'subagents:compacted',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
type ServiceLike = {
|
|
22
|
+
listAgents: () => SubagentRecordLike[];
|
|
23
|
+
steer: (agentId: string, message: string) => Promise<boolean>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const connectSubagents = async (): Promise<SubagentsIntegration> => {
|
|
27
|
+
let getService: (() => ServiceLike | undefined) | undefined;
|
|
28
|
+
try {
|
|
29
|
+
const mod = (await import('@gotgenes/pi-subagents')) as {
|
|
30
|
+
getSubagentsService?: () => ServiceLike | undefined;
|
|
31
|
+
};
|
|
32
|
+
getService = mod.getSubagentsService;
|
|
33
|
+
} catch {
|
|
34
|
+
return { available: false, reason: '@gotgenes/pi-subagents is not installed' };
|
|
35
|
+
}
|
|
36
|
+
const service = getService?.();
|
|
37
|
+
if (!service) {
|
|
38
|
+
return { available: false, reason: 'subagents service not published (extension not loaded?)' };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
available: true,
|
|
42
|
+
listAgents: () => service.listAgents(),
|
|
43
|
+
steer: (agentId, message) => service.steer(agentId, message),
|
|
44
|
+
};
|
|
45
|
+
};
|
package/src/lmdebug.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Debug console at the bottom of the pi TUI: shows the latest request sent to
|
|
2
|
+
// the LLM provider (LM Studio) and the latest assistant message received,
|
|
3
|
+
// each with a timestamp and the full message body (newlines preserved, no
|
|
4
|
+
// truncation). Enabled via the `debug` config flag (default false).
|
|
5
|
+
const WIDGET_KEY = 'watchdog-lm-debug';
|
|
6
|
+
|
|
7
|
+
// Minimal surface used here; satisfied by ExtensionAPI and test fakes
|
|
8
|
+
type PiLike = {
|
|
9
|
+
on(
|
|
10
|
+
event: 'before_provider_request',
|
|
11
|
+
handler: (event: { payload: unknown }, ctx: UiCtxLike) => void,
|
|
12
|
+
): void;
|
|
13
|
+
on(
|
|
14
|
+
event: 'message_end',
|
|
15
|
+
handler: (event: { message: unknown }, ctx: UiCtxLike) => void,
|
|
16
|
+
): void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type UiCtxLike = {
|
|
20
|
+
hasUI: boolean;
|
|
21
|
+
ui: {
|
|
22
|
+
setWidget(
|
|
23
|
+
key: string,
|
|
24
|
+
content: string[] | undefined,
|
|
25
|
+
options?: { placement?: 'aboveEditor' | 'belowEditor' },
|
|
26
|
+
): void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
31
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
32
|
+
|
|
33
|
+
// Text from OpenAI-style content: plain string or [{type:'text',text}] parts
|
|
34
|
+
const extractPartsText = (content: unknown): string => {
|
|
35
|
+
if (typeof content === 'string') {
|
|
36
|
+
return content;
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(content)) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
return content
|
|
42
|
+
.map((part) => {
|
|
43
|
+
const record = asRecord(part);
|
|
44
|
+
return typeof record?.text === 'string' ? record.text : '';
|
|
45
|
+
})
|
|
46
|
+
.filter((text) => text !== '')
|
|
47
|
+
.join('\n');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const describeSentPayload = (payload: unknown): string => {
|
|
51
|
+
const record = asRecord(payload);
|
|
52
|
+
const messages = Array.isArray(record?.messages) ? (record.messages as unknown[]) : [];
|
|
53
|
+
const last = asRecord(messages[messages.length - 1]);
|
|
54
|
+
if (!last) {
|
|
55
|
+
return 'no messages in payload';
|
|
56
|
+
}
|
|
57
|
+
const role = typeof last.role === 'string' ? last.role : 'unknown';
|
|
58
|
+
const text = extractPartsText(last.content);
|
|
59
|
+
// newline before the body so the message starts on its own line
|
|
60
|
+
return `#${messages.length} ${role}:\n${text || '(no text)'}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Assistant messages only; other roles return undefined
|
|
64
|
+
export const describeAssistantMessage = (message: unknown): string | undefined => {
|
|
65
|
+
const record = asRecord(message);
|
|
66
|
+
if (record?.role !== 'assistant' || !Array.isArray(record.content)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
for (const block of record.content) {
|
|
71
|
+
const blockRecord = asRecord(block);
|
|
72
|
+
if (!blockRecord) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (blockRecord.type === 'text' && typeof blockRecord.text === 'string') {
|
|
76
|
+
parts.push(blockRecord.text);
|
|
77
|
+
} else if (blockRecord.type === 'toolCall') {
|
|
78
|
+
const name = typeof blockRecord.name === 'string' ? blockRecord.name : 'tool';
|
|
79
|
+
parts.push(`[toolCall ${name}]`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// newline before the body so the message starts on its own line
|
|
83
|
+
return `assistant:\n${parts.join('\n') || '(no text)'}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const formatTime = (now: number): string => {
|
|
87
|
+
const date = new Date(now);
|
|
88
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
89
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const registerLmDebugWidget = (
|
|
93
|
+
pi: PiLike,
|
|
94
|
+
debug: boolean,
|
|
95
|
+
now: () => number = Date.now,
|
|
96
|
+
): void => {
|
|
97
|
+
if (!debug) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let sentLines: string[] = [];
|
|
102
|
+
let receivedLines: string[] = [];
|
|
103
|
+
|
|
104
|
+
const render = (ctx: UiCtxLike) => {
|
|
105
|
+
if (!ctx.hasUI) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// blank line between the sent block and the recv block
|
|
109
|
+
const separator = sentLines.length > 0 && receivedLines.length > 0 ? [''] : [];
|
|
110
|
+
ctx.ui.setWidget(WIDGET_KEY, [...sentLines, ...separator, ...receivedLines], {
|
|
111
|
+
placement: 'belowEditor',
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
pi.on('before_provider_request', (event, ctx) => {
|
|
116
|
+
sentLines = `▲ sent ${formatTime(now())} ${describeSentPayload(event.payload)}`.split('\n');
|
|
117
|
+
render(ctx);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.on('message_end', (event, ctx) => {
|
|
121
|
+
const description = describeAssistantMessage(event.message);
|
|
122
|
+
if (!description) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
receivedLines = `▼ recv ${formatTime(now())} ${description}`.split('\n');
|
|
126
|
+
render(ctx);
|
|
127
|
+
});
|
|
128
|
+
};
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
4
|
+
|
|
5
|
+
const stripAnsi = (raw: string): string => raw.replace(ANSI_PATTERN, '');
|
|
6
|
+
|
|
7
|
+
export const normalizeCommand = (raw: string): string =>
|
|
8
|
+
stripAnsi(raw).trim().replace(/\s+/g, ' ');
|
|
9
|
+
|
|
10
|
+
// Normalize an LLM message body for repetition comparison: strip volatile
|
|
11
|
+
// parts (timestamps, dates, UUIDs, long hex ids, digit runs) so two loop
|
|
12
|
+
// iterations that differ only in time/sequence markers hash identically.
|
|
13
|
+
export const normalizeLlmBody = (raw: string): string =>
|
|
14
|
+
stripAnsi(raw)
|
|
15
|
+
.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>')
|
|
16
|
+
.replace(/\d{4}[-/]\d{1,2}[-/]\d{1,2}/g, '<date>')
|
|
17
|
+
.replace(/\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?\b/gi, '<time>')
|
|
18
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
|
|
19
|
+
.replace(/\b[0-9a-f]{8,}\b/gi, '<id>')
|
|
20
|
+
.replace(/\d+/g, '<n>')
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, ' ');
|
|
23
|
+
|
|
24
|
+
export type HashOutputOptions = {
|
|
25
|
+
maxBytes?: number;
|
|
26
|
+
previewLines: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MAX_BYTES = 65536;
|
|
30
|
+
|
|
31
|
+
export const hashOutput = (
|
|
32
|
+
raw: string,
|
|
33
|
+
{ maxBytes = DEFAULT_MAX_BYTES, previewLines }: HashOutputOptions,
|
|
34
|
+
): { hash: string; preview: string } => {
|
|
35
|
+
const stripped = stripAnsi(raw);
|
|
36
|
+
const truncated = stripped.slice(0, maxBytes);
|
|
37
|
+
const hash = createHash('sha256').update(truncated).digest('hex');
|
|
38
|
+
const preview = truncated.split('\n').slice(0, previewLines).join('\n');
|
|
39
|
+
return { hash, preview };
|
|
40
|
+
};
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { TargetKind, TargetStatus, WatchdogTarget } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// Local structural type for @gotgenes/pi-subagents records; no direct type import (adapter isolation)
|
|
4
|
+
export type SubagentRecordLike = {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
description: string;
|
|
8
|
+
status: TargetStatus;
|
|
9
|
+
toolUses: number;
|
|
10
|
+
startedAt: number;
|
|
11
|
+
completedAt?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const TARGET_STATUSES: readonly TargetStatus[] = [
|
|
15
|
+
'queued',
|
|
16
|
+
'running',
|
|
17
|
+
'completed',
|
|
18
|
+
'steered',
|
|
19
|
+
'aborted',
|
|
20
|
+
'stopped',
|
|
21
|
+
'error',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const isTargetStatus = (value: unknown): value is TargetStatus =>
|
|
25
|
+
TARGET_STATUSES.includes(value as TargetStatus);
|
|
26
|
+
|
|
27
|
+
export const classifyKind = (type?: string, description?: string): TargetKind => {
|
|
28
|
+
if (!type && !description) {
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
const haystack = `${type ?? ''} ${description ?? ''}`.toLowerCase();
|
|
32
|
+
return haystack.includes('watchdog') ? 'watchdog' : 'task';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const toWatchdogTarget = (record: SubagentRecordLike): WatchdogTarget => ({
|
|
36
|
+
id: record.id,
|
|
37
|
+
name: record.description,
|
|
38
|
+
kind: classifyKind(record.type, record.description),
|
|
39
|
+
status: record.status,
|
|
40
|
+
toolCallCount: record.toolUses,
|
|
41
|
+
createdAt: record.startedAt,
|
|
42
|
+
lastActiveAt: record.completedAt ?? record.startedAt,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Status implied by each lifecycle channel; steered/compacted only refresh activity
|
|
46
|
+
const CHANNEL_STATUS: Record<string, TargetStatus | undefined> = {
|
|
47
|
+
'subagents:created': 'queued',
|
|
48
|
+
'subagents:started': 'running',
|
|
49
|
+
'subagents:completed': 'completed',
|
|
50
|
+
'subagents:failed': 'error',
|
|
51
|
+
'subagents:steered': undefined,
|
|
52
|
+
'subagents:compacted': undefined,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const applyEvent = (
|
|
56
|
+
targets: Map<string, WatchdogTarget>,
|
|
57
|
+
channel: string,
|
|
58
|
+
payload: unknown,
|
|
59
|
+
now: number,
|
|
60
|
+
): Map<string, WatchdogTarget> => {
|
|
61
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
62
|
+
return targets;
|
|
63
|
+
}
|
|
64
|
+
const data = payload as Record<string, unknown>;
|
|
65
|
+
if (typeof data.id !== 'string') {
|
|
66
|
+
return targets;
|
|
67
|
+
}
|
|
68
|
+
const existing = targets.get(data.id);
|
|
69
|
+
const payloadStatus = isTargetStatus(data.status) ? data.status : undefined;
|
|
70
|
+
const status = payloadStatus ?? CHANNEL_STATUS[channel] ?? existing?.status ?? 'queued';
|
|
71
|
+
const base: WatchdogTarget = existing ?? {
|
|
72
|
+
id: data.id,
|
|
73
|
+
name: typeof data.description === 'string' ? data.description : undefined,
|
|
74
|
+
kind: classifyKind(
|
|
75
|
+
typeof data.type === 'string' ? data.type : undefined,
|
|
76
|
+
typeof data.description === 'string' ? data.description : undefined,
|
|
77
|
+
),
|
|
78
|
+
status,
|
|
79
|
+
toolCallCount: 0,
|
|
80
|
+
createdAt: now,
|
|
81
|
+
lastActiveAt: now,
|
|
82
|
+
};
|
|
83
|
+
const next = new Map(targets);
|
|
84
|
+
next.set(data.id, { ...base, status, lastActiveAt: now });
|
|
85
|
+
return next;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const syncFromRecords = (
|
|
89
|
+
targets: Map<string, WatchdogTarget>,
|
|
90
|
+
records: SubagentRecordLike[],
|
|
91
|
+
): Map<string, WatchdogTarget> => {
|
|
92
|
+
const next = new Map(targets);
|
|
93
|
+
for (const record of records) {
|
|
94
|
+
const mapped = toWatchdogTarget(record);
|
|
95
|
+
const existing = next.get(record.id);
|
|
96
|
+
next.set(record.id, {
|
|
97
|
+
...mapped,
|
|
98
|
+
lastActiveAt: Math.max(mapped.lastActiveAt, existing?.lastActiveAt ?? 0),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return next;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type TargetRegistry = {
|
|
105
|
+
applyEvent: (channel: string, payload: unknown, now: number) => void;
|
|
106
|
+
syncFromRecords: (records: SubagentRecordLike[]) => void;
|
|
107
|
+
list: () => WatchdogTarget[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const createTargetRegistry = (): TargetRegistry => {
|
|
111
|
+
let targets = new Map<string, WatchdogTarget>();
|
|
112
|
+
return {
|
|
113
|
+
applyEvent: (channel, payload, now) => {
|
|
114
|
+
targets = applyEvent(targets, channel, payload, now);
|
|
115
|
+
},
|
|
116
|
+
syncFromRecords: (records) => {
|
|
117
|
+
targets = syncFromRecords(targets, records);
|
|
118
|
+
},
|
|
119
|
+
list: () => [...targets.values()],
|
|
120
|
+
};
|
|
121
|
+
};
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { WatchdogConfig, WatchdogEvent } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
|
4
|
+
|
|
5
|
+
export type AlertSink = (message: string, severity: AlertSeverity) => void;
|
|
6
|
+
|
|
7
|
+
export type WatchdogStore = {
|
|
8
|
+
registerChild: (sessionId: string, parentSessionId?: string) => void;
|
|
9
|
+
getChild: (sessionId: string) => { parentSessionId?: string } | undefined;
|
|
10
|
+
linkAgent: (agentId: string, sessionId: string) => void;
|
|
11
|
+
linkNextAgent: (agentId: string) => void;
|
|
12
|
+
resolveTargetKey: (idOrSessionId: string) => string | undefined;
|
|
13
|
+
resolveAgentId: (idOrSessionId: string) => string | undefined;
|
|
14
|
+
appendEvent: (sessionId: string, event: Omit<WatchdogEvent, 'id' | 'targetId'>) => void;
|
|
15
|
+
getEvents: (sessionId: string, limit?: number) => WatchdogEvent[];
|
|
16
|
+
getLastAlert: (targetId: string) => { at: number; evidenceKey: string } | undefined;
|
|
17
|
+
recordAlert: (targetId: string, evidenceKey: string, at: number) => void;
|
|
18
|
+
isPaused: () => boolean;
|
|
19
|
+
setPaused: (paused: boolean) => void;
|
|
20
|
+
getRescueMessage: () => string | undefined;
|
|
21
|
+
setRescueMessage: (message: string) => void;
|
|
22
|
+
getConfigOverride: () => Partial<WatchdogConfig>;
|
|
23
|
+
setConfigOverride: (override: Partial<WatchdogConfig>) => void;
|
|
24
|
+
setAlertSink: (sink: AlertSink) => void;
|
|
25
|
+
alert: (message: string, severity: AlertSeverity) => boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Shared across all extension instances in this process (parent and child sessions)
|
|
29
|
+
const STORE_KEY = Symbol.for('pi-watchdog-supervisor:store');
|
|
30
|
+
|
|
31
|
+
const createStore = (maxEventsPerAgent: number): WatchdogStore => {
|
|
32
|
+
const children = new Map<string, { parentSessionId?: string }>();
|
|
33
|
+
const agentToSession = new Map<string, string>();
|
|
34
|
+
const buffers = new Map<string, WatchdogEvent[]>();
|
|
35
|
+
const seqs = new Map<string, number>();
|
|
36
|
+
const alerts = new Map<string, { at: number; evidenceKey: string }>();
|
|
37
|
+
// FIFO of child sessionIds not yet linked to an agent id.
|
|
38
|
+
// Heuristic: session-created and started fire adjacently per spawn (in-process,
|
|
39
|
+
// synchronous), so pairing oldest-unlinked-first is correct for serialized
|
|
40
|
+
// assembly. A mismatch only affects display linkage, not event collection.
|
|
41
|
+
const unlinked: string[] = [];
|
|
42
|
+
// Runtime policy shared across parent and child instances
|
|
43
|
+
let paused = false;
|
|
44
|
+
let rescueMessage: string | undefined;
|
|
45
|
+
let configOverride: Partial<WatchdogConfig> = {};
|
|
46
|
+
let alertSink: AlertSink | undefined;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
registerChild: (sessionId, parentSessionId) => {
|
|
50
|
+
children.set(sessionId, { parentSessionId });
|
|
51
|
+
unlinked.push(sessionId);
|
|
52
|
+
},
|
|
53
|
+
getChild: (sessionId) => children.get(sessionId),
|
|
54
|
+
linkAgent: (agentId, sessionId) => {
|
|
55
|
+
agentToSession.set(agentId, sessionId);
|
|
56
|
+
const index = unlinked.indexOf(sessionId);
|
|
57
|
+
if (index !== -1) {
|
|
58
|
+
unlinked.splice(index, 1);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
linkNextAgent: (agentId) => {
|
|
62
|
+
const sessionId = unlinked.shift();
|
|
63
|
+
if (sessionId) {
|
|
64
|
+
agentToSession.set(agentId, sessionId);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
resolveTargetKey: (idOrSessionId) => {
|
|
68
|
+
if (children.has(idOrSessionId) || buffers.has(idOrSessionId)) {
|
|
69
|
+
return idOrSessionId;
|
|
70
|
+
}
|
|
71
|
+
return agentToSession.get(idOrSessionId);
|
|
72
|
+
},
|
|
73
|
+
resolveAgentId: (idOrSessionId) => {
|
|
74
|
+
if (agentToSession.has(idOrSessionId)) {
|
|
75
|
+
return idOrSessionId;
|
|
76
|
+
}
|
|
77
|
+
for (const [agentId, sessionId] of agentToSession) {
|
|
78
|
+
if (sessionId === idOrSessionId) {
|
|
79
|
+
return agentId;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
},
|
|
84
|
+
appendEvent: (sessionId, event) => {
|
|
85
|
+
const seq = (seqs.get(sessionId) ?? 0) + 1;
|
|
86
|
+
seqs.set(sessionId, seq);
|
|
87
|
+
const buffer = buffers.get(sessionId) ?? [];
|
|
88
|
+
buffer.push({ ...event, id: `${sessionId}-${seq}`, targetId: sessionId });
|
|
89
|
+
if (buffer.length > maxEventsPerAgent) {
|
|
90
|
+
buffer.splice(0, buffer.length - maxEventsPerAgent);
|
|
91
|
+
}
|
|
92
|
+
buffers.set(sessionId, buffer);
|
|
93
|
+
},
|
|
94
|
+
getEvents: (sessionId, limit) => {
|
|
95
|
+
const buffer = buffers.get(sessionId) ?? [];
|
|
96
|
+
return limit === undefined ? [...buffer] : buffer.slice(-limit);
|
|
97
|
+
},
|
|
98
|
+
getLastAlert: (targetId) => alerts.get(targetId),
|
|
99
|
+
recordAlert: (targetId, evidenceKey, at) => {
|
|
100
|
+
alerts.set(targetId, { at, evidenceKey });
|
|
101
|
+
},
|
|
102
|
+
isPaused: () => paused,
|
|
103
|
+
setPaused: (next) => {
|
|
104
|
+
paused = next;
|
|
105
|
+
},
|
|
106
|
+
getRescueMessage: () => rescueMessage,
|
|
107
|
+
setRescueMessage: (message) => {
|
|
108
|
+
rescueMessage = message;
|
|
109
|
+
},
|
|
110
|
+
getConfigOverride: () => ({ ...configOverride }),
|
|
111
|
+
setConfigOverride: (override) => {
|
|
112
|
+
configOverride = { ...configOverride, ...override };
|
|
113
|
+
},
|
|
114
|
+
setAlertSink: (sink) => {
|
|
115
|
+
alertSink = sink;
|
|
116
|
+
},
|
|
117
|
+
alert: (message, severity) => {
|
|
118
|
+
if (!alertSink) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
alertSink(message, severity);
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const getOrCreateStore = (maxEventsPerAgent: number): WatchdogStore => {
|
|
128
|
+
const holder = globalThis as Record<symbol, unknown>;
|
|
129
|
+
if (!holder[STORE_KEY]) {
|
|
130
|
+
holder[STORE_KEY] = createStore(maxEventsPerAgent);
|
|
131
|
+
}
|
|
132
|
+
return holder[STORE_KEY] as WatchdogStore;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const resetStoreForTest = (): void => {
|
|
136
|
+
delete (globalThis as Record<symbol, unknown>)[STORE_KEY];
|
|
137
|
+
};
|