@ebowwa/osascript 1.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.
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Action Events System for osascript MCP
3
+ *
4
+ * Provides:
5
+ * - Completion callbacks for AppleScript commands
6
+ * - System event monitoring (keystrokes, apps, windows)
7
+ * - Webhook integration for external automation
8
+ * - MCP lifecycle hooks integration
9
+ */
10
+
11
+ import { spawn, ChildProcess } from "child_process";
12
+ import { EventEmitter } from "events";
13
+
14
+ // ==============
15
+ // Types
16
+ // ==============
17
+
18
+ export interface ActionEvent {
19
+ id: string;
20
+ type: EventType;
21
+ timestamp: number;
22
+ source: string;
23
+ data: Record<string, any>;
24
+ success: boolean;
25
+ error?: string;
26
+ }
27
+
28
+ export type EventType =
29
+ | "keystroke"
30
+ | "keycode"
31
+ | "app_launch"
32
+ | "app_quit"
33
+ | "app_activate"
34
+ | "window_change"
35
+ | "clipboard_change"
36
+ | "volume_change"
37
+ | "display_change"
38
+ | "script_complete"
39
+ | "script_error"
40
+ | "webhook_sent"
41
+ | "lifecycle_start"
42
+ | "lifecycle_end"
43
+ | "lifecycle_tool_use";
44
+
45
+ export interface EventListener {
46
+ id: string;
47
+ eventType: EventType | "*";
48
+ active: boolean;
49
+ webhookUrl?: string;
50
+ filter?: (event: ActionEvent) => boolean;
51
+ callback?: (event: ActionEvent) => void | Promise<void>;
52
+ }
53
+
54
+ export interface WebhookConfig {
55
+ url: string;
56
+ method?: "POST" | "PUT";
57
+ headers?: Record<string, string>;
58
+ timeout?: number;
59
+ retries?: number;
60
+ }
61
+
62
+ export interface KeystrokeMonitorConfig {
63
+ captureKeys: boolean;
64
+ captureModifiers: boolean;
65
+ targetApp?: string;
66
+ excludeApps?: string[];
67
+ }
68
+
69
+ // ==============
70
+ // Event Bus
71
+ // ==============
72
+
73
+ class ActionEventBus extends EventEmitter {
74
+ private listeners: Map<string, EventListener> = new Map();
75
+ private eventHistory: ActionEvent[] = [];
76
+ private maxHistorySize: number = 1000;
77
+ private monitors: Map<string, ChildProcess> = new Map();
78
+
79
+ emit(event: ActionEvent): boolean {
80
+ // Store in history
81
+ this.eventHistory.push(event);
82
+ if (this.eventHistory.length > this.maxHistorySize) {
83
+ this.eventHistory.shift();
84
+ }
85
+
86
+ // Notify all matching listeners
87
+ for (const [id, listener] of this.listeners) {
88
+ if (!listener.active) continue;
89
+ if (listener.eventType !== "*" && listener.eventType !== event.type) continue;
90
+ if (listener.filter && !listener.filter(event)) continue;
91
+
92
+ // Call local callback
93
+ if (listener.callback) {
94
+ Promise.resolve(listener.callback(event)).catch(console.error);
95
+ }
96
+
97
+ // Send webhook
98
+ if (listener.webhookUrl) {
99
+ this.sendWebhook(listener.webhookUrl, event).catch(console.error);
100
+ }
101
+ }
102
+
103
+ return super.emit(event.type, event);
104
+ }
105
+
106
+ addListener(listener: EventListener): void {
107
+ this.listeners.set(listener.id, listener);
108
+ }
109
+
110
+ removeListener(id: string): boolean {
111
+ return this.listeners.delete(id);
112
+ }
113
+
114
+ getListeners(): EventListener[] {
115
+ return Array.from(this.listeners.values());
116
+ }
117
+
118
+ getHistory(since?: number, type?: EventType): ActionEvent[] {
119
+ let events = this.eventHistory;
120
+ if (since) {
121
+ events = events.filter((e) => e.timestamp >= since);
122
+ }
123
+ if (type) {
124
+ events = events.filter((e) => e.type === type);
125
+ }
126
+ return events;
127
+ }
128
+
129
+ clearHistory(): void {
130
+ this.eventHistory = [];
131
+ }
132
+
133
+ async sendWebhook(url: string, event: ActionEvent, config?: WebhookConfig): Promise<boolean> {
134
+ const retries = config?.retries || 3;
135
+ const timeout = config?.timeout || 5000;
136
+
137
+ for (let attempt = 0; attempt < retries; attempt++) {
138
+ try {
139
+ const controller = new AbortController();
140
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
141
+
142
+ const response = await fetch(url, {
143
+ method: config?.method || "POST",
144
+ headers: {
145
+ "Content-Type": "application/json",
146
+ ...config?.headers,
147
+ },
148
+ body: JSON.stringify(event),
149
+ signal: controller.signal,
150
+ });
151
+
152
+ clearTimeout(timeoutId);
153
+
154
+ if (response.ok) {
155
+ return true;
156
+ }
157
+ } catch (error) {
158
+ if (attempt === retries - 1) {
159
+ console.error(`Webhook failed after ${retries} attempts:`, error);
160
+ }
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ addMonitor(id: string, process: ChildProcess): void {
167
+ this.monitors.set(id, process);
168
+ }
169
+
170
+ removeMonitor(id: string): void {
171
+ const proc = this.monitors.get(id);
172
+ if (proc) {
173
+ proc.kill();
174
+ this.monitors.delete(id);
175
+ }
176
+ }
177
+
178
+ stopAllMonitors(): void {
179
+ for (const [id, proc] of this.monitors) {
180
+ proc.kill();
181
+ }
182
+ this.monitors.clear();
183
+ }
184
+ }
185
+
186
+ // Global event bus instance
187
+ export const eventBus = new ActionEventBus();
188
+
189
+ // ==============
190
+ // Event Generators
191
+ // ==============
192
+
193
+ let eventCounter = 0;
194
+
195
+ function generateEventId(): string {
196
+ return `evt_${Date.now()}_${++eventCounter}`;
197
+ }
198
+
199
+ export function createEvent(
200
+ type: EventType,
201
+ source: string,
202
+ data: Record<string, any>,
203
+ success: boolean = true,
204
+ error?: string
205
+ ): ActionEvent {
206
+ return {
207
+ id: generateEventId(),
208
+ type,
209
+ timestamp: Date.now(),
210
+ source,
211
+ data,
212
+ success,
213
+ error,
214
+ };
215
+ }
216
+
217
+ // ==============
218
+ // Monitoring Functions
219
+ // ==============
220
+
221
+ /**
222
+ * Start monitoring keystrokes (requires accessibility permissions)
223
+ */
224
+ export async function startKeystrokeMonitor(config: KeystrokeMonitorConfig = {
225
+ captureKeys: true,
226
+ captureModifiers: true,
227
+ }): Promise<string> {
228
+ const monitorId = `keystroke_${Date.now()}`;
229
+
230
+ // AppleScript to monitor keystrokes via System Events
231
+ const script = `
232
+ tell application "System Events"
233
+ set lastKey to ""
234
+ repeat
235
+ try
236
+ -- This is a polling approach; real keylogging requires more permissions
237
+ -- For MCP purposes, we track when we send keystrokes, not passive monitoring
238
+ delay 0.1
239
+ end try
240
+ end repeat
241
+ end tell
242
+ `;
243
+
244
+ // Note: Passive keystroke monitoring requires accessibility permissions
245
+ // and is restricted by macOS. We'll emit events for our own keystroke actions.
246
+
247
+ return monitorId;
248
+ }
249
+
250
+ /**
251
+ * Start monitoring application changes
252
+ */
253
+ export async function startAppMonitor(): Promise<string> {
254
+ const monitorId = `app_${Date.now()}`;
255
+
256
+ // Create a background process to monitor app changes
257
+ const script = `
258
+ set lastApp to ""
259
+ repeat
260
+ tell application "System Events"
261
+ set currentApp to name of first process whose frontmost is true
262
+ end tell
263
+ if currentApp is not lastApp then
264
+ do shell script "echo " & quoted form of currentApp
265
+ set lastApp to currentApp
266
+ end if
267
+ delay 0.5
268
+ end repeat
269
+ `;
270
+
271
+ const proc = spawn("osascript", ["-e", script]);
272
+ eventBus.addMonitor(monitorId, proc);
273
+
274
+ proc.stdout?.on("data", (data) => {
275
+ const appName = data.toString().trim();
276
+ eventBus.emit(createEvent("app_activate", "app_monitor", { appName }));
277
+ });
278
+
279
+ proc.stderr?.on("data", (data) => {
280
+ console.error(`App monitor error: ${data}`);
281
+ });
282
+
283
+ proc.on("close", () => {
284
+ eventBus.removeMonitor(monitorId);
285
+ });
286
+
287
+ return monitorId;
288
+ }
289
+
290
+ /**
291
+ * Start monitoring clipboard changes
292
+ */
293
+ export async function startClipboardMonitor(): Promise<string> {
294
+ const monitorId = `clipboard_${Date.now()}`;
295
+
296
+ const script = `
297
+ set lastClipboard to the clipboard as text
298
+ repeat
299
+ delay 0.5
300
+ try
301
+ set currentClipboard to the clipboard as text
302
+ if currentClipboard is not lastClipboard then
303
+ do shell script "echo CLIPBOARD_CHANGED"
304
+ set lastClipboard to currentClipboard
305
+ end if
306
+ end try
307
+ end repeat
308
+ `;
309
+
310
+ const proc = spawn("osascript", ["-e", script]);
311
+ eventBus.addMonitor(monitorId, proc);
312
+
313
+ proc.stdout?.on("data", (data) => {
314
+ if (data.toString().includes("CLIPBOARD_CHANGED")) {
315
+ eventBus.emit(createEvent("clipboard_change", "clipboard_monitor", {}));
316
+ }
317
+ });
318
+
319
+ proc.stderr?.on("data", (data) => {
320
+ console.error(`Clipboard monitor error: ${data}`);
321
+ });
322
+
323
+ proc.on("close", () => {
324
+ eventBus.removeMonitor(monitorId);
325
+ });
326
+
327
+ return monitorId;
328
+ }
329
+
330
+ /**
331
+ * Stop a specific monitor
332
+ */
333
+ export function stopMonitor(monitorId: string): boolean {
334
+ eventBus.removeMonitor(monitorId);
335
+ return true;
336
+ }
337
+
338
+ /**
339
+ * Stop all active monitors
340
+ */
341
+ export function stopAllMonitors(): void {
342
+ eventBus.stopAllMonitors();
343
+ }
344
+
345
+ // ==============
346
+ // Event Listener Management
347
+ // ==============
348
+
349
+ export function registerEventListener(
350
+ eventType: EventType | "*",
351
+ options: {
352
+ webhookUrl?: string;
353
+ callback?: (event: ActionEvent) => void | Promise<void>;
354
+ filter?: (event: ActionEvent) => boolean;
355
+ } = {}
356
+ ): string {
357
+ const listenerId = `listener_${Date.now()}_${Math.random().toString(36).slice(2)}`;
358
+
359
+ eventBus.addListener({
360
+ id: listenerId,
361
+ eventType,
362
+ active: true,
363
+ webhookUrl: options.webhookUrl,
364
+ callback: options.callback,
365
+ filter: options.filter,
366
+ });
367
+
368
+ return listenerId;
369
+ }
370
+
371
+ export function unregisterEventListener(listenerId: string): boolean {
372
+ return eventBus.removeListener(listenerId);
373
+ }
374
+
375
+ export function listEventListeners(): EventListener[] {
376
+ return eventBus.getListeners();
377
+ }
378
+
379
+ export function toggleListener(listenerId: string, active: boolean): boolean {
380
+ const listeners = eventBus.getListeners();
381
+ const listener = listeners.find((l) => l.id === listenerId);
382
+ if (listener) {
383
+ listener.active = active;
384
+ return true;
385
+ }
386
+ return false;
387
+ }
388
+
389
+ // ==============
390
+ // Event History
391
+ // ==============
392
+
393
+ export function getEventHistory(since?: number, type?: EventType): ActionEvent[] {
394
+ return eventBus.getHistory(since, type);
395
+ }
396
+
397
+ export function clearEventHistory(): void {
398
+ eventBus.clearHistory();
399
+ }
400
+
401
+ // ==============
402
+ // Webhook Integration
403
+ // ==============
404
+
405
+ export async function sendEventWebhook(
406
+ url: string,
407
+ event: ActionEvent,
408
+ config?: WebhookConfig
409
+ ): Promise<{ success: boolean; error?: string }> {
410
+ const success = await eventBus.sendWebhook(url, event, config);
411
+ return { success, error: success ? undefined : "Webhook delivery failed" };
412
+ }
413
+
414
+ // ==============
415
+ // MCP Lifecycle Integration
416
+ // ==============
417
+
418
+ export interface MCPLifecycleEvent {
419
+ sessionId?: string;
420
+ toolName?: string;
421
+ action: "start" | "end" | "tool_use" | "tool_result";
422
+ metadata?: Record<string, any>;
423
+ }
424
+
425
+ export function emitLifecycleEvent(lifecycleEvent: MCPLifecycleEvent): ActionEvent {
426
+ const eventType: EventType = `lifecycle_${lifecycleEvent.action}` as EventType;
427
+ const event = createEvent(eventType, "mcp_lifecycle", {
428
+ sessionId: lifecycleEvent.sessionId,
429
+ toolName: lifecycleEvent.toolName,
430
+ metadata: lifecycleEvent.metadata,
431
+ });
432
+ eventBus.emit(event);
433
+ return event;
434
+ }
435
+
436
+ // ==============
437
+ // Convenience: Emit event after action
438
+ // ==============
439
+
440
+ export function withEvent<T>(
441
+ type: EventType,
442
+ source: string,
443
+ action: () => T | Promise<T>
444
+ ): Promise<{ result: T; event: ActionEvent }> {
445
+ return Promise.resolve(action())
446
+ .then((result) => {
447
+ const event = createEvent(type, source, { result }, true);
448
+ eventBus.emit(event);
449
+ return { result, event };
450
+ })
451
+ .catch((error) => {
452
+ const event = createEvent(type, source, {}, false, error.message);
453
+ eventBus.emit(event);
454
+ throw error;
455
+ });
456
+ }
457
+
458
+ // ==============
459
+ // Export active monitor count
460
+ // ==============
461
+
462
+ export function getActiveMonitorCount(): number {
463
+ return eventBus["monitors"].size;
464
+ }
465
+
466
+ export function getActiveListenerCount(): number {
467
+ return eventBus.getListeners().filter((l) => l.active).length;
468
+ }