@automagik/genie 0.260202.1607 → 0.260202.1901

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,442 @@
1
+ /**
2
+ * Event monitor for Claude Code sessions
3
+ *
4
+ * Provides real-time monitoring of Claude Code sessions via polling,
5
+ * emitting events for state changes, output, and silence detection.
6
+ */
7
+
8
+ import { EventEmitter } from 'events';
9
+ import * as tmux from '../tmux.js';
10
+ import { ClaudeState, detectState, detectCompletion } from './state-detector.js';
11
+
12
+ export interface ClaudeEvent {
13
+ type:
14
+ | 'state_change'
15
+ | 'output'
16
+ | 'silence'
17
+ | 'activity'
18
+ | 'permission'
19
+ | 'question'
20
+ | 'error'
21
+ | 'complete';
22
+ state?: ClaudeState;
23
+ output?: string;
24
+ silenceMs?: number;
25
+ timestamp: number;
26
+ }
27
+
28
+ export interface EventMonitorOptions {
29
+ /** Polling interval in milliseconds (default: 500) */
30
+ pollIntervalMs?: number;
31
+ /** Number of lines to capture (default: 30) */
32
+ captureLines?: number;
33
+ /** Silence threshold for completion detection (default: 3000) */
34
+ silenceThresholdMs?: number;
35
+ /** Specific pane ID to monitor (default: first pane of first window) */
36
+ paneId?: string;
37
+ }
38
+
39
+ export class EventMonitor extends EventEmitter {
40
+ private sessionName: string;
41
+ private paneId: string | null = null;
42
+ private explicitPaneId: string | null = null;
43
+ private options: Required<Omit<EventMonitorOptions, 'paneId'>>;
44
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
45
+ private lastOutput: string = '';
46
+ private lastOutputTime: number = Date.now();
47
+ private lastState: ClaudeState | null = null;
48
+ private running: boolean = false;
49
+
50
+ constructor(sessionName: string, options: EventMonitorOptions = {}) {
51
+ super();
52
+ this.sessionName = sessionName;
53
+ this.explicitPaneId = options.paneId || null;
54
+ this.options = {
55
+ pollIntervalMs: options.pollIntervalMs ?? 500,
56
+ captureLines: options.captureLines ?? 30,
57
+ silenceThresholdMs: options.silenceThresholdMs ?? 3000,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Start monitoring the session
63
+ */
64
+ async start(): Promise<void> {
65
+ if (this.running) return;
66
+
67
+ // Use explicit pane ID if provided
68
+ if (this.explicitPaneId) {
69
+ this.paneId = this.explicitPaneId.startsWith('%')
70
+ ? this.explicitPaneId
71
+ : `%${this.explicitPaneId}`;
72
+ } else {
73
+ // Find session and get pane ID
74
+ const session = await tmux.findSessionByName(this.sessionName);
75
+ if (!session) {
76
+ throw new Error(`Session "${this.sessionName}" not found`);
77
+ }
78
+
79
+ const windows = await tmux.listWindows(session.id);
80
+ if (!windows || windows.length === 0) {
81
+ throw new Error(`No windows found in session "${this.sessionName}"`);
82
+ }
83
+
84
+ const panes = await tmux.listPanes(windows[0].id);
85
+ if (!panes || panes.length === 0) {
86
+ throw new Error(`No panes found in session "${this.sessionName}"`);
87
+ }
88
+
89
+ this.paneId = panes[0].id;
90
+ }
91
+
92
+ this.running = true;
93
+ this.lastOutputTime = Date.now();
94
+
95
+ // Initial capture
96
+ await this.poll();
97
+
98
+ // Start polling
99
+ this.pollTimer = setInterval(() => this.poll(), this.options.pollIntervalMs);
100
+
101
+ this.emit('started', { sessionName: this.sessionName, paneId: this.paneId });
102
+ }
103
+
104
+ /**
105
+ * Stop monitoring
106
+ */
107
+ stop(): void {
108
+ if (this.pollTimer) {
109
+ clearInterval(this.pollTimer);
110
+ this.pollTimer = null;
111
+ }
112
+ this.running = false;
113
+ this.emit('stopped');
114
+ }
115
+
116
+ /**
117
+ * Check if monitor is running
118
+ */
119
+ isRunning(): boolean {
120
+ return this.running;
121
+ }
122
+
123
+ /**
124
+ * Get current state
125
+ */
126
+ getCurrentState(): ClaudeState | null {
127
+ return this.lastState;
128
+ }
129
+
130
+ /**
131
+ * Get time since last output change
132
+ */
133
+ getSilenceMs(): number {
134
+ return Date.now() - this.lastOutputTime;
135
+ }
136
+
137
+ /**
138
+ * Poll for changes
139
+ */
140
+ private async poll(): Promise<void> {
141
+ if (!this.paneId || !this.running) return;
142
+
143
+ try {
144
+ const output = await tmux.capturePaneContent(
145
+ this.paneId,
146
+ this.options.captureLines
147
+ );
148
+ const now = Date.now();
149
+
150
+ // Check for new output
151
+ if (output !== this.lastOutput) {
152
+ const newContent = this.getNewContent(this.lastOutput, output);
153
+
154
+ if (newContent) {
155
+ this.lastOutputTime = now;
156
+ this.emitEvent({
157
+ type: 'output',
158
+ output: newContent,
159
+ timestamp: now,
160
+ });
161
+
162
+ this.emitEvent({
163
+ type: 'activity',
164
+ timestamp: now,
165
+ });
166
+ }
167
+
168
+ // Detect state from new output
169
+ const newState = detectState(output);
170
+
171
+ // Check for state changes
172
+ if (this.lastState && newState.type !== this.lastState.type) {
173
+ this.emitEvent({
174
+ type: 'state_change',
175
+ state: newState,
176
+ timestamp: now,
177
+ });
178
+
179
+ // Emit specific events for important state changes
180
+ if (newState.type === 'permission') {
181
+ this.emitEvent({
182
+ type: 'permission',
183
+ state: newState,
184
+ timestamp: now,
185
+ });
186
+ } else if (newState.type === 'question') {
187
+ this.emitEvent({
188
+ type: 'question',
189
+ state: newState,
190
+ timestamp: now,
191
+ });
192
+ } else if (newState.type === 'error') {
193
+ this.emitEvent({
194
+ type: 'error',
195
+ state: newState,
196
+ timestamp: now,
197
+ });
198
+ }
199
+
200
+ // Check for completion
201
+ const completion = detectCompletion(output, this.lastOutput);
202
+ if (completion.complete && completion.confidence > 0.6) {
203
+ this.emitEvent({
204
+ type: 'complete',
205
+ state: newState,
206
+ timestamp: now,
207
+ });
208
+ }
209
+ }
210
+
211
+ this.lastState = newState;
212
+ this.lastOutput = output;
213
+ } else {
214
+ // No change - check silence threshold
215
+ const silenceMs = now - this.lastOutputTime;
216
+
217
+ // Emit silence events at threshold intervals
218
+ if (
219
+ silenceMs >= this.options.silenceThresholdMs &&
220
+ silenceMs % this.options.silenceThresholdMs < this.options.pollIntervalMs
221
+ ) {
222
+ this.emitEvent({
223
+ type: 'silence',
224
+ silenceMs,
225
+ timestamp: now,
226
+ });
227
+ }
228
+ }
229
+ } catch (error) {
230
+ // Emit error but continue polling
231
+ this.emit('poll_error', error);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get new content since last poll
237
+ */
238
+ private getNewContent(oldOutput: string, newOutput: string): string | null {
239
+ if (oldOutput === newOutput) return null;
240
+
241
+ // If old output is empty, return all new output
242
+ if (!oldOutput) return newOutput;
243
+
244
+ // Find where old output ends in new output
245
+ const oldLines = oldOutput.split('\n');
246
+ const newLines = newOutput.split('\n');
247
+
248
+ // Simple approach: find the last line of old output in new output
249
+ const lastOldLine = oldLines[oldLines.length - 1];
250
+ const lastOldLineIndex = newLines.lastIndexOf(lastOldLine);
251
+
252
+ if (lastOldLineIndex >= 0 && lastOldLineIndex < newLines.length - 1) {
253
+ return newLines.slice(lastOldLineIndex + 1).join('\n');
254
+ }
255
+
256
+ // If we can't find exact match, return the diff
257
+ // (this happens when lines scroll out of the capture buffer)
258
+ const oldSet = new Set(oldLines);
259
+ const newContent = newLines.filter((line) => !oldSet.has(line));
260
+
261
+ return newContent.length > 0 ? newContent.join('\n') : null;
262
+ }
263
+
264
+ /**
265
+ * Emit a Claude event
266
+ */
267
+ private emitEvent(event: ClaudeEvent): void {
268
+ this.emit(event.type, event);
269
+ this.emit('event', event);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Wait for a specific state or condition
275
+ */
276
+ export async function waitForState(
277
+ monitor: EventMonitor,
278
+ predicate: (state: ClaudeState) => boolean,
279
+ timeoutMs: number = 60000
280
+ ): Promise<ClaudeState> {
281
+ return new Promise((resolve, reject) => {
282
+ const timeout = setTimeout(() => {
283
+ cleanup();
284
+ reject(new Error('Timeout waiting for state'));
285
+ }, timeoutMs);
286
+
287
+ const handler = (event: ClaudeEvent) => {
288
+ if (event.state && predicate(event.state)) {
289
+ cleanup();
290
+ resolve(event.state);
291
+ }
292
+ };
293
+
294
+ const cleanup = () => {
295
+ clearTimeout(timeout);
296
+ monitor.off('state_change', handler);
297
+ };
298
+
299
+ // Check current state first
300
+ const current = monitor.getCurrentState();
301
+ if (current && predicate(current)) {
302
+ cleanup();
303
+ resolve(current);
304
+ return;
305
+ }
306
+
307
+ monitor.on('state_change', handler);
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Wait for silence (no output) for a duration
313
+ */
314
+ export async function waitForSilence(
315
+ monitor: EventMonitor,
316
+ silenceMs: number,
317
+ timeoutMs: number = 120000
318
+ ): Promise<void> {
319
+ return new Promise((resolve, reject) => {
320
+ const startTime = Date.now();
321
+ let lastActivityTime = startTime;
322
+
323
+ const timeout = setTimeout(() => {
324
+ cleanup();
325
+ reject(new Error('Timeout waiting for silence'));
326
+ }, timeoutMs);
327
+
328
+ const activityHandler = () => {
329
+ lastActivityTime = Date.now();
330
+ };
331
+
332
+ const checkSilence = setInterval(() => {
333
+ const silenceDuration = Date.now() - lastActivityTime;
334
+ if (silenceDuration >= silenceMs) {
335
+ cleanup();
336
+ resolve();
337
+ }
338
+ }, 100);
339
+
340
+ const cleanup = () => {
341
+ clearTimeout(timeout);
342
+ clearInterval(checkSilence);
343
+ monitor.off('activity', activityHandler);
344
+ };
345
+
346
+ monitor.on('activity', activityHandler);
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Wait for completion (idle state after activity)
352
+ */
353
+ export async function waitForCompletion(
354
+ monitor: EventMonitor,
355
+ options: {
356
+ silenceMs?: number;
357
+ timeoutMs?: number;
358
+ requireIdle?: boolean;
359
+ } = {}
360
+ ): Promise<{ state: ClaudeState; reason: string }> {
361
+ const { silenceMs = 3000, timeoutMs = 120000, requireIdle = true } = options;
362
+
363
+ return new Promise((resolve, reject) => {
364
+ const timeout = setTimeout(() => {
365
+ cleanup();
366
+ reject(new Error('Timeout waiting for completion'));
367
+ }, timeoutMs);
368
+
369
+ let lastActivityTime = Date.now();
370
+ let silenceCheckInterval: ReturnType<typeof setInterval> | null = null;
371
+
372
+ const completeHandler = (event: ClaudeEvent) => {
373
+ if (event.state) {
374
+ cleanup();
375
+ resolve({ state: event.state, reason: 'complete event' });
376
+ }
377
+ };
378
+
379
+ const activityHandler = () => {
380
+ lastActivityTime = Date.now();
381
+ };
382
+
383
+ const stateHandler = (event: ClaudeEvent) => {
384
+ // If we get a permission or question, we're not complete
385
+ if (event.state?.type === 'permission' || event.state?.type === 'question') {
386
+ return;
387
+ }
388
+
389
+ // If idle and requireIdle, resolve
390
+ if (requireIdle && event.state?.type === 'idle') {
391
+ cleanup();
392
+ resolve({ state: event.state, reason: 'idle state' });
393
+ }
394
+
395
+ // If error, resolve (task failed but is "complete")
396
+ if (event.state?.type === 'error') {
397
+ cleanup();
398
+ resolve({ state: event.state, reason: 'error' });
399
+ }
400
+ };
401
+
402
+ const cleanup = () => {
403
+ clearTimeout(timeout);
404
+ if (silenceCheckInterval) clearInterval(silenceCheckInterval);
405
+ monitor.off('complete', completeHandler);
406
+ monitor.off('activity', activityHandler);
407
+ monitor.off('state_change', stateHandler);
408
+ };
409
+
410
+ // Check silence periodically
411
+ silenceCheckInterval = setInterval(() => {
412
+ const silenceDuration = Date.now() - lastActivityTime;
413
+ const currentState = monitor.getCurrentState();
414
+
415
+ if (silenceDuration >= silenceMs) {
416
+ // Check if current state indicates completion
417
+ if (
418
+ currentState &&
419
+ (currentState.type === 'idle' ||
420
+ currentState.type === 'complete' ||
421
+ currentState.type === 'error')
422
+ ) {
423
+ cleanup();
424
+ resolve({
425
+ state: currentState,
426
+ reason: `silence (${silenceDuration}ms)`,
427
+ });
428
+ } else if (!requireIdle) {
429
+ cleanup();
430
+ resolve({
431
+ state: currentState || { type: 'unknown', timestamp: Date.now(), rawOutput: '', confidence: 0 },
432
+ reason: `silence (${silenceDuration}ms) - non-idle`,
433
+ });
434
+ }
435
+ }
436
+ }, 500);
437
+
438
+ monitor.on('complete', completeHandler);
439
+ monitor.on('activity', activityHandler);
440
+ monitor.on('state_change', stateHandler);
441
+ });
442
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Orchestrator - Claude Code session orchestration library
3
+ *
4
+ * Provides tools for monitoring, controlling, and automating
5
+ * Claude Code sessions running in tmux.
6
+ */
7
+
8
+ // Re-export all public APIs
9
+ export * from './patterns.js';
10
+ export * from './state-detector.js';
11
+ export * from './event-monitor.js';
12
+ export * from './completion.js';