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
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
+ };
@@ -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
+ };
@@ -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
+ };