aegis-bridge 2.2.2

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
  4. package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +219 -0
  8. package/dist/channels/index.d.ts +8 -0
  9. package/dist/channels/index.js +9 -0
  10. package/dist/channels/manager.d.ts +39 -0
  11. package/dist/channels/manager.js +101 -0
  12. package/dist/channels/telegram-style.d.ts +118 -0
  13. package/dist/channels/telegram-style.js +203 -0
  14. package/dist/channels/telegram.d.ts +76 -0
  15. package/dist/channels/telegram.js +1396 -0
  16. package/dist/channels/types.d.ts +77 -0
  17. package/dist/channels/types.js +9 -0
  18. package/dist/channels/webhook.d.ts +58 -0
  19. package/dist/channels/webhook.js +162 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.js +223 -0
  22. package/dist/config.d.ts +60 -0
  23. package/dist/config.js +188 -0
  24. package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
  25. package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
  26. package/dist/dashboard/index.html +14 -0
  27. package/dist/events.d.ts +86 -0
  28. package/dist/events.js +258 -0
  29. package/dist/hook-settings.d.ts +67 -0
  30. package/dist/hook-settings.js +138 -0
  31. package/dist/hook.d.ts +18 -0
  32. package/dist/hook.js +199 -0
  33. package/dist/hooks.d.ts +32 -0
  34. package/dist/hooks.js +279 -0
  35. package/dist/jsonl-watcher.d.ts +57 -0
  36. package/dist/jsonl-watcher.js +159 -0
  37. package/dist/mcp-server.d.ts +60 -0
  38. package/dist/mcp-server.js +788 -0
  39. package/dist/metrics.d.ts +104 -0
  40. package/dist/metrics.js +226 -0
  41. package/dist/monitor.d.ts +84 -0
  42. package/dist/monitor.js +553 -0
  43. package/dist/permission-guard.d.ts +51 -0
  44. package/dist/permission-guard.js +197 -0
  45. package/dist/pipeline.d.ts +84 -0
  46. package/dist/pipeline.js +218 -0
  47. package/dist/screenshot.d.ts +26 -0
  48. package/dist/screenshot.js +57 -0
  49. package/dist/server.d.ts +10 -0
  50. package/dist/server.js +1577 -0
  51. package/dist/session.d.ts +297 -0
  52. package/dist/session.js +1275 -0
  53. package/dist/sse-limiter.d.ts +47 -0
  54. package/dist/sse-limiter.js +62 -0
  55. package/dist/sse-writer.d.ts +31 -0
  56. package/dist/sse-writer.js +95 -0
  57. package/dist/ssrf.d.ts +57 -0
  58. package/dist/ssrf.js +169 -0
  59. package/dist/swarm-monitor.d.ts +114 -0
  60. package/dist/swarm-monitor.js +267 -0
  61. package/dist/terminal-parser.d.ts +16 -0
  62. package/dist/terminal-parser.js +343 -0
  63. package/dist/tmux.d.ts +161 -0
  64. package/dist/tmux.js +725 -0
  65. package/dist/transcript.d.ts +47 -0
  66. package/dist/transcript.js +244 -0
  67. package/dist/validation.d.ts +222 -0
  68. package/dist/validation.js +268 -0
  69. package/dist/ws-terminal.d.ts +32 -0
  70. package/dist/ws-terminal.js +297 -0
  71. package/package.json +71 -0
@@ -0,0 +1,553 @@
1
+ /**
2
+ * monitor.ts — Background monitor that polls sessions and routes events to channels.
3
+ *
4
+ * Runs a polling loop that:
5
+ * 1. Checks each active session for new JSONL entries
6
+ * 2. Detects status changes (working → idle, permission prompts, etc.)
7
+ * 3. Routes events to the ChannelManager (which fans out to Telegram, webhooks, etc.)
8
+ */
9
+ import { readFile } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import { stopSignalsSchema } from './validation.js';
14
+ /** Issue #89 L4: Debounce interval for status change broadcasts (ms). */
15
+ const STATUS_CHANGE_DEBOUNCE_MS = 500;
16
+ export const DEFAULT_MONITOR_CONFIG = {
17
+ pollIntervalMs: 30_000, // 30s base — hooks are the primary signal (Issue #169 Phase 3)
18
+ fastPollIntervalMs: 5_000, // 5s when hooks are quiet — fallback safety net
19
+ hookQuietMs: 60_000, // 60s without a hook → switch to fast polling
20
+ stallThresholdMs: 5 * 60 * 1000, // 5 minutes (Issue #4: reduced from 60 min)
21
+ stallCheckIntervalMs: 30 * 1000, // check every 30 seconds (faster for shorter thresholds)
22
+ deadCheckIntervalMs: 10 * 1000, // check every 10 seconds (Issue M19: faster dead detection)
23
+ permissionStallMs: 5 * 60 * 1000, // 5 min waiting for permission = stalled
24
+ unknownStallMs: 3 * 60 * 1000, // 3 min in unknown state = stalled
25
+ permissionTimeoutMs: 10 * 60 * 1000, // 10 min → auto-reject permission
26
+ };
27
+ export class SessionMonitor {
28
+ sessions;
29
+ channels;
30
+ config;
31
+ running = false;
32
+ lastStatus = new Map();
33
+ lastBytesSeen = new Map();
34
+ stallNotified = new Set(); // don't spam stall events
35
+ lastStallCheck = 0;
36
+ lastDeadCheck = 0;
37
+ idleNotified = new Set(); // prevent idle spam
38
+ idleSince = new Map(); // debounce: when idle started
39
+ processedStopSignals = new Set(); // Issue #15: don't re-process signals
40
+ static MAX_PROCESSED_STOP_SIGNALS = 1000; // #220: prevent unbounded growth
41
+ // Smart stall detection: track when each non-working state started
42
+ stateSince = new Map(); // sessionId → { state, since } (one entry per session)
43
+ deadNotified = new Set(); // don't spam dead session events
44
+ prevStatusForStall = new Map(); // track previous status for stall transition detection
45
+ rateLimitedSessions = new Set(); // sessions in rate-limit backoff
46
+ /** Issue #89 L4: Debounce status change broadcasts per session.
47
+ * If multiple status changes happen within 500ms, only emit the last one.
48
+ * Prevents rapid-fire notifications during state transitions. */
49
+ statusChangeDebounce = new Map();
50
+ /** Issue #32: Optional SSE event bus for real-time streaming. */
51
+ eventBus;
52
+ /** Issue #84: fs.watch-based JSONL watcher for near-instant message detection. */
53
+ jsonlWatcher;
54
+ constructor(sessions, channels, config = DEFAULT_MONITOR_CONFIG) {
55
+ this.sessions = sessions;
56
+ this.channels = channels;
57
+ this.config = config;
58
+ this.config = { ...DEFAULT_MONITOR_CONFIG, ...config };
59
+ }
60
+ /** Issue #32: Set the event bus for SSE streaming. */
61
+ setEventBus(bus) {
62
+ this.eventBus = bus;
63
+ }
64
+ /** Issue #84: Set the JSONL watcher for fs.watch-based message detection. */
65
+ setJsonlWatcher(watcher) {
66
+ this.jsonlWatcher = watcher;
67
+ watcher.onEntries((event) => {
68
+ this.handleWatcherEvent(event);
69
+ });
70
+ }
71
+ start() {
72
+ if (this.running)
73
+ return;
74
+ this.running = true;
75
+ this.loop();
76
+ }
77
+ stop() {
78
+ this.running = false;
79
+ }
80
+ async loop() {
81
+ while (this.running) {
82
+ try {
83
+ await this.poll();
84
+ }
85
+ catch (e) {
86
+ console.error('Monitor poll error:', e);
87
+ }
88
+ // Issue #169 Phase 3: Adaptive polling — use fast interval if any session
89
+ // hasn't received a hook recently (hooks may have stopped working).
90
+ const interval = this.needsFastPolling() ? this.config.fastPollIntervalMs : this.config.pollIntervalMs;
91
+ await sleep(interval);
92
+ }
93
+ }
94
+ /** Check if any active session hasn't received a hook recently. */
95
+ needsFastPolling() {
96
+ const now = Date.now();
97
+ for (const session of this.sessions.listSessions()) {
98
+ const lastHook = session.lastHookAt;
99
+ // If a session has never received a hook, always fast-poll (hooks may not be configured)
100
+ if (lastHook === undefined)
101
+ return true;
102
+ // If no hook for hookQuietMs, switch to fast polling
103
+ if (now - lastHook > this.config.hookQuietMs)
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ async poll() {
109
+ const now = Date.now();
110
+ for (const session of this.sessions.listSessions()) {
111
+ try {
112
+ // Issue #84: Start watching when jsonlPath is discovered
113
+ if (this.jsonlWatcher && session.jsonlPath && !this.jsonlWatcher.isWatching(session.id)) {
114
+ this.jsonlWatcher.watch(session.id, session.jsonlPath, session.monitorOffset);
115
+ }
116
+ await this.checkSession(session);
117
+ }
118
+ catch {
119
+ // Session may have been killed during poll
120
+ }
121
+ }
122
+ // Stall detection: run less frequently than message polling
123
+ if (now - this.lastStallCheck >= this.config.stallCheckIntervalMs) {
124
+ this.lastStallCheck = now;
125
+ await this.checkForStalls(now);
126
+ await this.checkStopSignals();
127
+ }
128
+ // Dead session detection: independent timer (M19: 10s default)
129
+ if (now - this.lastDeadCheck >= this.config.deadCheckIntervalMs) {
130
+ this.lastDeadCheck = now;
131
+ await this.checkDeadSessions();
132
+ }
133
+ }
134
+ /** Smart stall detection: multiple stall types with graduated thresholds.
135
+ *
136
+ * Detects 4 types of stalls:
137
+ * 1. JSONL stall: "working" but no new JSONL bytes for stallThresholdMs
138
+ * 2. Permission stall: permission_prompt/bash_approval for permissionStallMs
139
+ * 3. Unknown stall: unknown state for unknownStallMs (CC stuck in transition)
140
+ * 4. State duration stall: any non-idle state for 2x its threshold
141
+ */
142
+ async checkForStalls(now) {
143
+ for (const session of this.sessions.listSessions()) {
144
+ const currentStatus = this.lastStatus.get(session.id);
145
+ const prevStallStatus = this.prevStatusForStall.get(session.id);
146
+ // Track state transitions — one entry per session, preserving timer across
147
+ // permission_prompt ↔ bash_approval transitions (both are "permission" states)
148
+ if (currentStatus && currentStatus !== 'idle') {
149
+ const entry = this.stateSince.get(session.id);
150
+ if (!entry) {
151
+ this.stateSince.set(session.id, { state: currentStatus, since: now });
152
+ }
153
+ else if (entry.state !== currentStatus) {
154
+ const isPermState = (s) => s === 'permission_prompt' || s === 'bash_approval';
155
+ if (isPermState(entry.state) && isPermState(currentStatus)) {
156
+ entry.state = currentStatus; // preserve since across permission sub-type transitions
157
+ }
158
+ else {
159
+ this.stateSince.set(session.id, { state: currentStatus, since: now });
160
+ }
161
+ }
162
+ }
163
+ // --- Type 1: JSONL stall (working but no output) ---
164
+ if (currentStatus === 'working') {
165
+ // Skip stall detection for rate-limited sessions — CC is in backoff
166
+ if (this.rateLimitedSessions.has(session.id)) {
167
+ continue;
168
+ }
169
+ const prev = this.lastBytesSeen.get(session.id);
170
+ const currentBytes = session.monitorOffset;
171
+ if (!prev) {
172
+ this.lastBytesSeen.set(session.id, { bytes: currentBytes, at: now });
173
+ continue;
174
+ }
175
+ if (currentBytes > prev.bytes) {
176
+ this.lastBytesSeen.set(session.id, { bytes: currentBytes, at: now });
177
+ this.stallNotified.delete(`${session.id}:stall:jsonl`);
178
+ }
179
+ else {
180
+ const stallDuration = now - prev.at;
181
+ const threshold = session.stallThresholdMs || this.config.stallThresholdMs;
182
+ if (stallDuration >= threshold && !this.stallNotified.has(`${session.id}:stall:jsonl`)) {
183
+ this.stallNotified.add(`${session.id}:stall:jsonl`);
184
+ const minutes = Math.round(stallDuration / 60000);
185
+ const detail = `Session stalled: "working" for ${minutes}min with no new output. ` +
186
+ `Last activity: ${new Date(session.lastActivity).toISOString()}`;
187
+ this.eventBus?.emitStall(session.id, 'jsonl', detail);
188
+ await this.channels.statusChange(this.makePayload('status.stall', session, detail));
189
+ }
190
+ }
191
+ }
192
+ else {
193
+ // Reset JSONL stall tracking when not working
194
+ this.stallNotified.delete(`${session.id}:stall:jsonl`);
195
+ }
196
+ // --- Type 2: Permission stall (waiting for approval too long) ---
197
+ if (currentStatus === 'permission_prompt' || currentStatus === 'bash_approval') {
198
+ const entry = this.stateSince.get(session.id);
199
+ const permDuration = entry ? now - entry.since : 0;
200
+ if (permDuration >= this.config.permissionStallMs) {
201
+ if (!this.stallNotified.has(`${session.id}:stall:permission`)) {
202
+ this.stallNotified.add(`${session.id}:stall:permission`);
203
+ const minutes = Math.round(permDuration / 60000);
204
+ const detail = `Session stalled: waiting for permission approval for ${minutes}min. ` +
205
+ `Auto-approve this session or POST /v1/sessions/${session.id}/approve`;
206
+ this.eventBus?.emitStall(session.id, 'permission', detail);
207
+ await this.channels.statusChange(this.makePayload('status.stall', session, detail));
208
+ }
209
+ }
210
+ // L9: Auto-reject permission after timeout
211
+ if (permDuration >= this.config.permissionTimeoutMs) {
212
+ if (!this.stallNotified.has(`${session.id}:stall:permission_timeout`)) {
213
+ this.stallNotified.add(`${session.id}:stall:permission_timeout`);
214
+ const minutes = Math.round(permDuration / 60000);
215
+ console.warn(`Monitor: auto-rejecting permission for session ${session.windowName} after ${minutes}min`);
216
+ try {
217
+ await this.sessions.reject(session.id);
218
+ const detail = `Permission auto-rejected after ${minutes}min timeout (session ${session.windowName})`;
219
+ this.eventBus?.emitStall(session.id, 'permission_timeout', detail);
220
+ await this.channels.statusChange(this.makePayload('status.permission_timeout', session, detail));
221
+ }
222
+ catch (e) {
223
+ console.error(`Monitor: auto-reject failed for session ${session.id}: ${e.message}`);
224
+ }
225
+ }
226
+ }
227
+ }
228
+ // --- Type 3: Unknown stall (CC stuck in transition) ---
229
+ if (currentStatus === 'unknown') {
230
+ const entry = this.stateSince.get(session.id);
231
+ const unkDuration = entry ? now - entry.since : 0;
232
+ if (unkDuration >= this.config.unknownStallMs) {
233
+ if (!this.stallNotified.has(`${session.id}:stall:unknown`)) {
234
+ this.stallNotified.add(`${session.id}:stall:unknown`);
235
+ const minutes = Math.round(unkDuration / 60000);
236
+ const detail = `Session stalled: in "unknown" state for ${minutes}min. ` +
237
+ `CC may be stuck. Try: POST /v1/sessions/${session.id}/interrupt or /kill`;
238
+ this.eventBus?.emitStall(session.id, 'unknown', detail);
239
+ await this.channels.statusChange(this.makePayload('status.stall', session, detail));
240
+ }
241
+ }
242
+ }
243
+ // --- Type 4: Extended state stall (any state held too long) ---
244
+ if (currentStatus && currentStatus !== 'idle' && currentStatus !== 'working') {
245
+ const entry = this.stateSince.get(session.id);
246
+ const stateDuration = entry ? now - entry.since : 0;
247
+ const extendedThreshold = this.config.stallThresholdMs * 2;
248
+ if (stateDuration >= extendedThreshold) {
249
+ if (!this.stallNotified.has(`${session.id}:stall:extended`)) {
250
+ this.stallNotified.add(`${session.id}:stall:extended`);
251
+ const minutes = Math.round(stateDuration / 60000);
252
+ const detail = `Session stalled: "${currentStatus}" state for ${minutes}min. ` +
253
+ `May need intervention: /interrupt, /approve, or /kill`;
254
+ this.eventBus?.emitStall(session.id, 'extended', detail);
255
+ await this.channels.statusChange(this.makePayload('status.stall', session, detail));
256
+ }
257
+ }
258
+ }
259
+ // Clean up stall notifications on state transitions (using prevStallStatus)
260
+ if (prevStallStatus && prevStallStatus !== currentStatus) {
261
+ const exitedPermission = prevStallStatus === 'permission_prompt' || prevStallStatus === 'bash_approval';
262
+ const exitedUnknown = prevStallStatus === 'unknown';
263
+ if (exitedPermission) {
264
+ this.stallNotified.delete(`${session.id}:stall:permission`);
265
+ this.stallNotified.delete(`${session.id}:stall:permission_timeout`);
266
+ }
267
+ if (exitedUnknown) {
268
+ this.stallNotified.delete(`${session.id}:stall:unknown`);
269
+ }
270
+ }
271
+ // Clean up all state tracking when idle (catch-all)
272
+ if (currentStatus === 'idle') {
273
+ this.rateLimitedSessions.delete(session.id);
274
+ this.stateSince.delete(session.id);
275
+ // Clean stall notifications (session recovered)
276
+ for (const key of this.stallNotified) {
277
+ if (key.startsWith(session.id)) {
278
+ this.stallNotified.delete(key);
279
+ }
280
+ }
281
+ }
282
+ // Update prevStatusForStall for next cycle
283
+ if (currentStatus) {
284
+ this.prevStatusForStall.set(session.id, currentStatus);
285
+ }
286
+ else {
287
+ this.prevStatusForStall.delete(session.id);
288
+ }
289
+ }
290
+ }
291
+ /** Issue #15: Check for Stop/StopFailure signals written by hook.ts. */
292
+ async checkStopSignals() {
293
+ // Check both aegis and manus dirs for backward compat
294
+ const aegisDir = join(homedir(), '.aegis');
295
+ const manusDir = join(homedir(), '.manus');
296
+ const signalFile = existsSync(join(aegisDir, 'stop_signals.json'))
297
+ ? join(aegisDir, 'stop_signals.json')
298
+ : join(manusDir, 'stop_signals.json');
299
+ if (!existsSync(signalFile))
300
+ return;
301
+ try {
302
+ const raw = await readFile(signalFile, 'utf-8');
303
+ const parsed = stopSignalsSchema.safeParse(JSON.parse(raw));
304
+ if (!parsed.success) {
305
+ console.warn('stop_signals.json failed validation in checkStopSignals');
306
+ return;
307
+ }
308
+ const signals = parsed.data;
309
+ for (const session of this.sessions.listSessions()) {
310
+ if (!session.claudeSessionId)
311
+ continue;
312
+ const signal = signals[session.claudeSessionId];
313
+ if (!signal)
314
+ continue;
315
+ const signalKey = `${session.claudeSessionId}:${signal.timestamp}`;
316
+ if (this.processedStopSignals.has(signalKey))
317
+ continue;
318
+ this.processedStopSignals.add(signalKey);
319
+ // #220: Prune oldest entries when Set exceeds max size
320
+ // #510: Collect keys first, then delete — avoid mutation during iteration
321
+ if (this.processedStopSignals.size > SessionMonitor.MAX_PROCESSED_STOP_SIGNALS) {
322
+ const toRemove = this.processedStopSignals.size - SessionMonitor.MAX_PROCESSED_STOP_SIGNALS;
323
+ const keysToDelete = [...this.processedStopSignals].slice(0, toRemove);
324
+ for (const key of keysToDelete) {
325
+ this.processedStopSignals.delete(key);
326
+ }
327
+ }
328
+ if (signal.event === 'StopFailure') {
329
+ const stopReason = signal.stop_reason || '';
330
+ if (stopReason === 'rate_limit' || stopReason === 'overloaded') {
331
+ this.rateLimitedSessions.add(session.id);
332
+ await this.channels.statusChange(this.makePayload('status.rate_limited', session, `Claude API rate limited (${stopReason}). Session will resume when the backoff window expires.`));
333
+ }
334
+ else {
335
+ const errorDetail = signal.error || signal.stop_reason || 'Unknown API error';
336
+ await this.channels.statusChange(this.makePayload('status.error', session, `⚠️ Claude Code error: ${errorDetail}`));
337
+ }
338
+ }
339
+ else if (signal.event === 'Stop') {
340
+ await this.channels.statusChange(this.makePayload('status.stopped', session, 'Claude Code session ended normally'));
341
+ }
342
+ }
343
+ }
344
+ catch { /* ignore parse errors */ }
345
+ }
346
+ /** Issue #84: Handle new entries from the fs.watch-based JSONL watcher.
347
+ * Forwards messages to channels and updates stall tracking. */
348
+ handleWatcherEvent(event) {
349
+ const session = this.sessions.getSession(event.sessionId);
350
+ if (!session)
351
+ return;
352
+ // Update monitor offset from watcher
353
+ session.monitorOffset = event.newOffset;
354
+ if (event.messages.length > 0) {
355
+ // Clear rate-limited state — CC resumed producing real output
356
+ this.rateLimitedSessions.delete(event.sessionId);
357
+ for (const msg of event.messages) {
358
+ // Forward asynchronously (fire-and-forget) — catch to prevent unhandled rejection (#404)
359
+ void this.forwardMessage(session, msg).catch(e => console.error(`Monitor: forwardMessage failed for ${session.id}:`, e));
360
+ }
361
+ // Update last activity
362
+ session.lastActivity = Date.now();
363
+ }
364
+ // Update JSONL stall tracking — always initialize on watcher events
365
+ const now = Date.now();
366
+ const prev = this.lastBytesSeen.get(event.sessionId);
367
+ if (event.newOffset > (prev?.bytes ?? -1)) {
368
+ this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
369
+ this.stallNotified.delete(`${event.sessionId}:stall:jsonl`);
370
+ }
371
+ }
372
+ async checkSession(session) {
373
+ // When the JSONL watcher is active, messages are forwarded via handleWatcherEvent.
374
+ // Here we only need to capture the terminal UI state (permission prompts, idle, etc.)
375
+ const result = await this.sessions.readMessagesForMonitor(session.id);
376
+ const prevStatus = this.lastStatus.get(session.id);
377
+ // Forward messages only when watcher is NOT active (fallback polling path)
378
+ if (!this.jsonlWatcher && result.messages.length > 0) {
379
+ this.rateLimitedSessions.delete(session.id);
380
+ for (const msg of result.messages) {
381
+ await this.forwardMessage(session, msg);
382
+ }
383
+ }
384
+ // Idle debounce: only emit idle after 10s of continuous idle
385
+ if (result.status === 'idle') {
386
+ if (!this.idleSince.has(session.id)) {
387
+ this.idleSince.set(session.id, Date.now());
388
+ }
389
+ }
390
+ else {
391
+ this.idleSince.delete(session.id);
392
+ // Reset idle notification guard when genuinely not idle
393
+ if (result.status === 'working' || result.status === 'unknown') {
394
+ this.idleNotified.delete(session.id);
395
+ }
396
+ }
397
+ // Detect and broadcast status changes (debounced)
398
+ if (result.status !== prevStatus) {
399
+ // Issue #89 L4: Debounce rapid status changes per session.
400
+ // If multiple transitions happen within STATUS_CHANGE_DEBOUNCE_MS,
401
+ // only the last one triggers a broadcast.
402
+ const existing = this.statusChangeDebounce.get(session.id);
403
+ if (existing)
404
+ clearTimeout(existing);
405
+ const latestStatus = result.status;
406
+ const latestPrevStatus = prevStatus;
407
+ const latestResult = { statusText: result.statusText, interactiveContent: result.interactiveContent };
408
+ this.statusChangeDebounce.set(session.id, setTimeout(() => {
409
+ this.statusChangeDebounce.delete(session.id);
410
+ void this.broadcastStatusChange(session, latestStatus, latestPrevStatus, latestResult)
411
+ .catch(e => console.error(`Monitor: broadcastStatusChange failed for ${session.id}:`, e));
412
+ }, STATUS_CHANGE_DEBOUNCE_MS));
413
+ }
414
+ this.lastStatus.set(session.id, result.status);
415
+ }
416
+ async forwardMessage(session, msg) {
417
+ const eventMap = {
418
+ 'user:text': 'message.user',
419
+ 'assistant:text': 'message.assistant',
420
+ 'assistant:thinking': 'message.thinking',
421
+ 'assistant:tool_use': 'message.tool_use',
422
+ 'assistant:tool_result': 'message.tool_result',
423
+ };
424
+ const key = `${msg.role}:${msg.contentType}`;
425
+ // Issue #89 L33: System entries get a different SSE event type
426
+ if (msg.role === 'system') {
427
+ this.eventBus?.emitSystem(session.id, msg.text, msg.contentType);
428
+ return;
429
+ }
430
+ const event = eventMap[key];
431
+ if (!event)
432
+ return;
433
+ // Issue #32: Emit SSE message event (L11: include tool metadata)
434
+ this.eventBus?.emitMessage(session.id, msg.role, msg.text, msg.contentType, msg.toolName || msg.toolUseId ? { tool_name: msg.toolName, tool_id: msg.toolUseId } : undefined);
435
+ await this.channels.message(this.makePayload(event, session, msg.text));
436
+ }
437
+ async broadcastStatusChange(session, status, prevStatus, result) {
438
+ if (status === 'permission_prompt' || status === 'bash_approval') {
439
+ // Issue #32: Emit SSE approval event
440
+ this.eventBus?.emitApproval(session.id, result.interactiveContent || 'Permission requested');
441
+ // Auto-approve if session has a non-default permission mode
442
+ // that auto-approves permission prompts (bypassPermissions, dontAsk,
443
+ // acceptEdits, plan, auto all handle their own permissions).
444
+ const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits', 'plan', 'auto']);
445
+ if (session.permissionMode !== 'default' && AUTO_APPROVE_MODES.has(session.permissionMode)) {
446
+ console.log(`[AUTO-APPROVED] Session ${session.windowName} (${session.id.slice(0, 8)}): ${result.interactiveContent || 'permission prompt'}`);
447
+ try {
448
+ await this.sessions.approve(session.id);
449
+ await this.channels.statusChange(this.makePayload('status.permission', session, `[AUTO-APPROVED] ${result.interactiveContent || 'Permission auto-approved'}`));
450
+ }
451
+ catch (e) {
452
+ const errMsg = e instanceof Error ? e.message : String(e);
453
+ console.error(`[AUTO-APPROVE FAILED] Session ${session.id}: ${errMsg}`);
454
+ await this.channels.statusChange(this.makePayload('status.permission', session, `[AUTO-APPROVE FAILED] ${result.interactiveContent || 'Permission requested'}: ${errMsg}`));
455
+ }
456
+ }
457
+ else {
458
+ await this.channels.statusChange(this.makePayload('status.permission', session, result.interactiveContent || 'Permission requested'));
459
+ }
460
+ }
461
+ else if (status === 'plan_mode') {
462
+ this.eventBus?.emitStatus(session.id, 'plan_mode', result.interactiveContent || 'Plan review requested');
463
+ await this.channels.statusChange(this.makePayload('status.plan', session, result.interactiveContent || 'Plan review requested'));
464
+ }
465
+ else if (status === 'idle') {
466
+ const idleStart = this.idleSince.get(session.id) || Date.now();
467
+ const idleDuration = Date.now() - idleStart;
468
+ // Only notify after 3s of continuous idle, and only once (M23: reduced from 10s)
469
+ if (idleDuration >= 3_000 && !this.idleNotified.has(session.id)) {
470
+ this.idleNotified.add(session.id);
471
+ this.eventBus?.emitStatus(session.id, 'idle', result.statusText || 'Session finished working, awaiting input');
472
+ await this.channels.statusChange(this.makePayload('status.idle', session, result.statusText || 'Session finished working, awaiting input'));
473
+ }
474
+ }
475
+ else if (status === 'ask_question' && prevStatus !== 'ask_question') {
476
+ this.eventBus?.emitStatus(session.id, 'ask_question', result.interactiveContent || 'Session is asking a question');
477
+ await this.channels.statusChange(this.makePayload('status.question', session, result.interactiveContent || 'Session is asking a question'));
478
+ }
479
+ // Issue #32: Emit working status via SSE
480
+ if (status === 'working' && prevStatus !== 'working') {
481
+ this.eventBus?.emitStatus(session.id, 'working', 'Claude is working');
482
+ }
483
+ }
484
+ makePayload(event, session, detail) {
485
+ return {
486
+ event,
487
+ timestamp: new Date().toISOString(),
488
+ session: {
489
+ id: session.id,
490
+ name: session.windowName,
491
+ workDir: session.workDir,
492
+ },
493
+ detail: detail.slice(0, 2000),
494
+ };
495
+ }
496
+ /** Check for dead tmux windows and notify via channels. */
497
+ async checkDeadSessions() {
498
+ const sessions = this.sessions.listSessions();
499
+ for (const session of sessions) {
500
+ if (this.deadNotified.has(session.id))
501
+ continue;
502
+ const alive = await this.sessions.isWindowAlive(session.id);
503
+ if (!alive) {
504
+ this.deadNotified.add(session.id);
505
+ // Track when the session died so the zombie reaper can clean it up
506
+ session.lastDeadAt = Date.now();
507
+ const detail = `Session "${session.windowName}" died — tmux window no longer exists. ` +
508
+ `Last activity: ${new Date(session.lastActivity).toISOString()}`;
509
+ this.eventBus?.emitDead(session.id, detail);
510
+ await this.channels.statusChange(this.makePayload('status.dead', session, detail));
511
+ this.removeSession(session.id);
512
+ // #262: Also remove from SessionManager so dead sessions don't linger
513
+ try {
514
+ await this.sessions.killSession(session.id);
515
+ }
516
+ catch {
517
+ // Window already gone — that's fine, session is dead
518
+ }
519
+ }
520
+ }
521
+ }
522
+ /** Clean up tracking for a killed session. */
523
+ removeSession(sessionId) {
524
+ // Issue #84: Stop watching JSONL file for this session
525
+ this.jsonlWatcher?.unwatch(sessionId);
526
+ this.lastStatus.delete(sessionId);
527
+ this.lastBytesSeen.delete(sessionId);
528
+ this.deadNotified.delete(sessionId);
529
+ this.rateLimitedSessions.delete(sessionId);
530
+ // Issue #89 L4: Clear pending debounce timer
531
+ const pending = this.statusChangeDebounce.get(sessionId);
532
+ if (pending) {
533
+ clearTimeout(pending);
534
+ this.statusChangeDebounce.delete(sessionId);
535
+ }
536
+ // Clean all stall notifications for this session
537
+ for (const key of this.stallNotified) {
538
+ if (key.startsWith(sessionId)) {
539
+ this.stallNotified.delete(key);
540
+ }
541
+ }
542
+ this.idleNotified.delete(sessionId);
543
+ this.idleSince.delete(sessionId);
544
+ this.stateSince.delete(sessionId);
545
+ this.prevStatusForStall.delete(sessionId);
546
+ // Note: processedStopSignals uses claudeSessionId:timestamp keys, not bridge sessionId.
547
+ // We don't clean them here — they're small and prevent re-processing.
548
+ }
549
+ }
550
+ function sleep(ms) {
551
+ return new Promise(resolve => setTimeout(resolve, ms));
552
+ }
553
+ //# sourceMappingURL=monitor.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * permission-guard.ts — Guard against settings overriding CLI permission mode.
3
+ *
4
+ * Problem: Claude Code checks settings in 3 locations (priority order):
5
+ * 1. ~/.claude/settings.json (user-level)
6
+ * 2. <project>/.claude/settings.json (project-level, committed)
7
+ * 3. <project>/.claude/settings.local.json (project-level, local)
8
+ *
9
+ * Any of these can set `permissions.defaultMode: "bypassPermissions"` which
10
+ * OVERRIDES the CLI `--permission-mode default` flag. When Aegis spawns a
11
+ * session with autoApprove: false, the user expects permission prompts — but
12
+ * the settings silently bypass them.
13
+ *
14
+ * Fix: Before launching CC, if autoApprove is false, we neutralize any
15
+ * `bypassPermissions` in ALL 3 settings files by backing them up and patching
16
+ * the permission mode. On session cleanup we restore them.
17
+ *
18
+ * Issue #102 safety: Backups are stored in ~/.aegis/permission-backups/
19
+ * instead of the project directory to prevent accidental commit of secrets.
20
+ */
21
+ /** Location 3: project-level local settings */
22
+ export declare function settingsPath(workDir: string): string;
23
+ /** Location 2: project-level committed settings */
24
+ export declare function projectSettingsPath(workDir: string): string;
25
+ /** Location 1: user-level settings. Accepts optional homeDir for test isolation. */
26
+ export declare function userSettingsPath(homeDir?: string): string;
27
+ /** Backup directory for a given workDir. Accepts optional homeDir for test isolation. */
28
+ export declare function backupDirForWorkDir(workDir: string, homeDir?: string): string;
29
+ /** Get backup path for settings.local.json. Accepts optional homeDir for test isolation. */
30
+ export declare function backupPath(workDir: string, homeDir?: string): string;
31
+ /**
32
+ * Check all 3 CC settings locations for bypassPermissions. Back up and patch
33
+ * any that have it. Returns true if ANY file was patched.
34
+ *
35
+ * @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
36
+ */
37
+ export declare function neutralizeBypassPermissions(workDir: string, targetMode?: string, homeDir?: string): Promise<boolean>;
38
+ /**
39
+ * Restore all 3 settings files from backups.
40
+ * Checks new backup locations first, then legacy location for backward compat.
41
+ *
42
+ * @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
43
+ */
44
+ export declare function restoreSettings(workDir: string, homeDir?: string): Promise<void>;
45
+ /**
46
+ * Clean up any orphaned backups (e.g. from a crash).
47
+ * Restores all 3 settings files from their backups.
48
+ *
49
+ * @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
50
+ */
51
+ export declare function cleanOrphanedBackup(workDir: string, homeDir?: string): Promise<void>;