Skip to content

Commit d1636bc

Browse files
committed
refactor(frame-manager): extract WhatsApp sync into lifecycle hooks
- Create new frame-lifecycle-hooks.ts module for extensible frame events - Remove WhatsApp-specific code from FrameManager (was 20 lines of tight coupling) - FrameManager now calls frameLifecycleHooks.triggerClose() instead - WhatsApp sync registers itself as a hook via registerWhatsAppSyncHook() - Benefits: - FrameManager no longer knows about WhatsApp - Other integrations can subscribe to frame events - Better testability and separation of concerns - Fire-and-forget hooks don't block frame operations https://claude.ai/code/session_014ojs76858uGzWb6Hrbs4VG
1 parent eba2a74 commit d1636bc

5 files changed

Lines changed: 252 additions & 47 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Frame Lifecycle Hooks
3+
* Allows external modules to subscribe to frame events without coupling to FrameManager
4+
*/
5+
6+
import { logger } from '../monitoring/logger.js';
7+
import type { Frame, Event, Anchor } from './frame-manager.js';
8+
9+
/**
10+
* Data passed to frame close hooks
11+
*/
12+
export interface FrameCloseData {
13+
frame: Frame;
14+
events: Event[];
15+
anchors: Anchor[];
16+
}
17+
18+
/**
19+
* Hook function type for frame close events
20+
*/
21+
export type FrameCloseHook = (data: FrameCloseData) => Promise<void>;
22+
23+
/**
24+
* Hook function type for frame create events
25+
*/
26+
export type FrameCreateHook = (frame: Frame) => Promise<void>;
27+
28+
/**
29+
* Registered hook with metadata
30+
*/
31+
interface RegisteredHook<T> {
32+
name: string;
33+
handler: T;
34+
priority: number;
35+
}
36+
37+
/**
38+
* Frame Lifecycle Hooks Registry
39+
* Singleton that manages all frame lifecycle hooks
40+
*/
41+
class FrameLifecycleHooksRegistry {
42+
private closeHooks: RegisteredHook<FrameCloseHook>[] = [];
43+
private createHooks: RegisteredHook<FrameCreateHook>[] = [];
44+
45+
/**
46+
* Register a hook to be called when a frame is closed
47+
* @param name - Unique name for the hook (for logging/debugging)
48+
* @param handler - Async function to call when frame closes
49+
* @param priority - Higher priority hooks run first (default: 0)
50+
*/
51+
onFrameClosed(
52+
name: string,
53+
handler: FrameCloseHook,
54+
priority: number = 0
55+
): () => void {
56+
const hook: RegisteredHook<FrameCloseHook> = { name, handler, priority };
57+
this.closeHooks.push(hook);
58+
this.closeHooks.sort((a, b) => b.priority - a.priority);
59+
60+
logger.debug('Registered frame close hook', { name, priority });
61+
62+
// Return unregister function
63+
return () => {
64+
this.closeHooks = this.closeHooks.filter((h) => h !== hook);
65+
logger.debug('Unregistered frame close hook', { name });
66+
};
67+
}
68+
69+
/**
70+
* Register a hook to be called when a frame is created
71+
* @param name - Unique name for the hook (for logging/debugging)
72+
* @param handler - Async function to call when frame is created
73+
* @param priority - Higher priority hooks run first (default: 0)
74+
*/
75+
onFrameCreated(
76+
name: string,
77+
handler: FrameCreateHook,
78+
priority: number = 0
79+
): () => void {
80+
const hook: RegisteredHook<FrameCreateHook> = { name, handler, priority };
81+
this.createHooks.push(hook);
82+
this.createHooks.sort((a, b) => b.priority - a.priority);
83+
84+
logger.debug('Registered frame create hook', { name, priority });
85+
86+
// Return unregister function
87+
return () => {
88+
this.createHooks = this.createHooks.filter((h) => h !== hook);
89+
logger.debug('Unregistered frame create hook', { name });
90+
};
91+
}
92+
93+
/**
94+
* Trigger all close hooks (called by FrameManager)
95+
* Hooks are fire-and-forget - errors don't affect frame closure
96+
*/
97+
async triggerClose(data: FrameCloseData): Promise<void> {
98+
if (this.closeHooks.length === 0) return;
99+
100+
const results = await Promise.allSettled(
101+
this.closeHooks.map(async (hook) => {
102+
try {
103+
await hook.handler(data);
104+
} catch (error) {
105+
logger.warn(`Frame close hook "${hook.name}" failed`, {
106+
error: error instanceof Error ? error.message : String(error),
107+
frameId: data.frame.frame_id,
108+
frameName: data.frame.name,
109+
});
110+
}
111+
})
112+
);
113+
114+
const failed = results.filter((r) => r.status === 'rejected').length;
115+
if (failed > 0) {
116+
logger.debug('Some frame close hooks failed', {
117+
total: this.closeHooks.length,
118+
failed,
119+
frameId: data.frame.frame_id,
120+
});
121+
}
122+
}
123+
124+
/**
125+
* Trigger all create hooks (called by FrameManager)
126+
* Hooks are fire-and-forget - errors don't affect frame creation
127+
*/
128+
async triggerCreate(frame: Frame): Promise<void> {
129+
if (this.createHooks.length === 0) return;
130+
131+
const results = await Promise.allSettled(
132+
this.createHooks.map(async (hook) => {
133+
try {
134+
await hook.handler(frame);
135+
} catch (error) {
136+
logger.warn(`Frame create hook "${hook.name}" failed`, {
137+
error: error instanceof Error ? error.message : String(error),
138+
frameId: frame.frame_id,
139+
frameName: frame.name,
140+
});
141+
}
142+
})
143+
);
144+
145+
const failed = results.filter((r) => r.status === 'rejected').length;
146+
if (failed > 0) {
147+
logger.debug('Some frame create hooks failed', {
148+
total: this.createHooks.length,
149+
failed,
150+
frameId: frame.frame_id,
151+
});
152+
}
153+
}
154+
155+
/**
156+
* Get count of registered hooks (useful for testing)
157+
*/
158+
getHookCounts(): { close: number; create: number } {
159+
return {
160+
close: this.closeHooks.length,
161+
create: this.createHooks.length,
162+
};
163+
}
164+
165+
/**
166+
* Clear all hooks (useful for testing)
167+
*/
168+
clearAll(): void {
169+
this.closeHooks = [];
170+
this.createHooks = [];
171+
logger.debug('Cleared all frame lifecycle hooks');
172+
}
173+
}
174+
175+
/**
176+
* Singleton instance of the hooks registry
177+
*/
178+
export const frameLifecycleHooks = new FrameLifecycleHooksRegistry();

src/core/context/frame-manager.ts

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,7 @@ import {
1818
import { retry, withTimeout } from '../errors/recovery.js';
1919
import { sessionManager, FrameQueryMode } from '../session/index.js';
2020
import { contextBridge } from './context-bridge.js';
21-
22-
// WhatsApp sync integration - lazy loaded to avoid breaking if module has issues
23-
let whatsappSync: {
24-
onFrameClosed: (data: any) => Promise<any>;
25-
createFrameDigestData: (frame: any, events: any[], anchors: any[]) => any;
26-
} | null = null;
27-
28-
async function loadWhatsAppSync() {
29-
if (whatsappSync !== null) return whatsappSync;
30-
try {
31-
const mod = await import('../../hooks/whatsapp-sync.js');
32-
whatsappSync = {
33-
onFrameClosed: mod.onFrameClosed,
34-
createFrameDigestData: mod.createFrameDigestData,
35-
};
36-
return whatsappSync;
37-
} catch {
38-
// Module not available or has errors - disable integration
39-
whatsappSync = null;
40-
return null;
41-
}
42-
}
21+
import { frameLifecycleHooks } from './frame-lifecycle-hooks.js';
4322

4423
// Constants for frame validation
4524
const MAX_FRAME_DEPTH = 100; // Maximum allowed frame depth
@@ -721,10 +700,14 @@ export class FrameManager {
721700
// Close all child frames recursively
722701
this.closeChildFrames(targetFrameId);
723702

724-
// Trigger WhatsApp auto-sync if enabled (fire and forget)
725-
this.triggerWhatsAppSync(frame, targetFrameId).catch(() => {
726-
// Silently ignore errors - sync is non-critical
727-
});
703+
// Trigger lifecycle hooks (fire and forget)
704+
const events = this.getFrameEvents(targetFrameId);
705+
const anchors = this.getFrameAnchors(targetFrameId);
706+
frameLifecycleHooks
707+
.triggerClose({ frame: { ...frame, state: 'closed' }, events, anchors })
708+
.catch(() => {
709+
// Silently ignore errors - hooks are non-critical
710+
});
728711

729712
logger.info('Closed frame', {
730713
frameId: targetFrameId,
@@ -735,26 +718,6 @@ export class FrameManager {
735718
});
736719
}
737720

738-
/**
739-
* Trigger WhatsApp sync for closed frame (non-blocking)
740-
*/
741-
private async triggerWhatsAppSync(
742-
frame: Frame,
743-
frameId: string
744-
): Promise<void> {
745-
const sync = await loadWhatsAppSync();
746-
if (!sync) return;
747-
748-
try {
749-
const events = this.getFrameEvents(frameId);
750-
const anchors = this.getFrameAnchors(frameId);
751-
const digestData = sync.createFrameDigestData(frame, events, anchors);
752-
await sync.onFrameClosed(digestData);
753-
} catch {
754-
// Silently ignore - WhatsApp sync is optional
755-
}
756-
}
757-
758721
/**
759722
* Delete a frame completely from the database (used in handoffs)
760723
*/

src/core/context/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export { FrameDatabase } from './frame-database.js';
2424
export { FrameStack } from './frame-stack.js';
2525
export { FrameDigestGenerator } from './frame-digest.js';
2626

27+
// Export lifecycle hooks for external integrations
28+
export {
29+
frameLifecycleHooks,
30+
type FrameCloseData,
31+
type FrameCloseHook,
32+
type FrameCreateHook,
33+
} from './frame-lifecycle-hooks.js';
34+
2735
// Re-export from old frame-manager for backwards compatibility
2836
// This allows existing code to continue working without changes
2937
export { FrameManager as LegacyFrameManager } from './frame-manager.js';

src/hooks/whatsapp-sync.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
22
* WhatsApp Context Sync Engine
33
* Push frame digests and context updates to WhatsApp
4+
*
5+
* Uses the frame lifecycle hooks system to receive frame close events.
6+
* Call `registerWhatsAppSyncHook()` to enable automatic sync on frame close.
47
*/
58

69
import { existsSync, readFileSync } from 'fs';
@@ -14,6 +17,10 @@ import {
1417
} from './sms-notify.js';
1518
import { writeFileSecure, ensureSecureDir } from './secure-fs.js';
1619
import { SyncOptionsSchema, parseConfigSafe } from './schemas.js';
20+
import {
21+
frameLifecycleHooks,
22+
type FrameCloseData,
23+
} from '../core/context/frame-lifecycle-hooks.js';
1724

1825
export interface SyncOptions {
1926
autoSyncOnClose: boolean;
@@ -454,6 +461,55 @@ export async function onFrameClosed(
454461
return syncFrameData(frameData, options);
455462
}
456463

464+
/**
465+
* Internal hook handler that receives FrameCloseData from lifecycle hooks
466+
*/
467+
async function handleFrameCloseHook(data: FrameCloseData): Promise<void> {
468+
const digestData = createFrameDigestData(
469+
data.frame,
470+
data.events,
471+
data.anchors
472+
);
473+
await onFrameClosed(digestData);
474+
}
475+
476+
// Track if hook is registered to avoid duplicates
477+
let hookUnregister: (() => void) | null = null;
478+
479+
/**
480+
* Register WhatsApp sync as a frame lifecycle hook
481+
* This enables automatic sync when frames are closed
482+
* Call this during app initialization to enable the integration
483+
*
484+
* @returns Unregister function to disable the hook
485+
*/
486+
export function registerWhatsAppSyncHook(): () => void {
487+
// Avoid duplicate registration
488+
if (hookUnregister) {
489+
return hookUnregister;
490+
}
491+
492+
hookUnregister = frameLifecycleHooks.onFrameClosed(
493+
'whatsapp-sync',
494+
handleFrameCloseHook,
495+
-10 // Low priority - run after other hooks
496+
);
497+
498+
return () => {
499+
if (hookUnregister) {
500+
hookUnregister();
501+
hookUnregister = null;
502+
}
503+
};
504+
}
505+
506+
/**
507+
* Check if the WhatsApp sync hook is currently registered
508+
*/
509+
export function isHookRegistered(): boolean {
510+
return hookUnregister !== null;
511+
}
512+
457513
/**
458514
* Create frame digest data from raw frame info
459515
* Helper for integration with frame-manager

src/mcp/stackmemory-mcp-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const frameManager = new FrameManager(db, PROJECT_ROOT, undefined);
4040
const agentTaskManager = new AgentTaskManager(taskStore, frameManager);
4141

4242
// Track active Claude session
43-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
43+
4444
let _claudeSessionId: string | null = null;
4545
let claudeFrameId: string | null = null;
4646

0 commit comments

Comments
 (0)