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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- package/package.json +71 -0
package/dist/monitor.js
ADDED
|
@@ -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>;
|