aegis-bridge 2.2.5 → 2.3.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.
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +10 -0
- package/dist/events.d.ts +2 -0
- package/dist/events.js +9 -0
- package/dist/mcp-server.js +2 -1
- package/dist/monitor.js +31 -3
- package/dist/server.js +33 -3
- package/dist/session.d.ts +2 -0
- package/dist/session.js +8 -2
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export declare class AuthManager {
|
|
|
53
53
|
};
|
|
54
54
|
/** Hash a key with SHA-256. */
|
|
55
55
|
static hashKey(key: string): string;
|
|
56
|
+
/** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */
|
|
57
|
+
sweepStaleRateLimits(): void;
|
|
56
58
|
/** Check if auth is enabled (master token or any keys). */
|
|
57
59
|
get authEnabled(): boolean;
|
|
58
60
|
/**
|
package/dist/auth.js
CHANGED
|
@@ -127,6 +127,16 @@ export class AuthManager {
|
|
|
127
127
|
static hashKey(key) {
|
|
128
128
|
return createHash('sha256').update(key).digest('hex');
|
|
129
129
|
}
|
|
130
|
+
/** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */
|
|
131
|
+
sweepStaleRateLimits() {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const windowMs = 60_000; // 1 minute
|
|
134
|
+
for (const [keyId, bucket] of this.rateLimits) {
|
|
135
|
+
if (now - bucket.windowStart > windowMs) {
|
|
136
|
+
this.rateLimits.delete(keyId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
130
140
|
/** Check if auth is enabled (master token or any keys). */
|
|
131
141
|
get authEnabled() {
|
|
132
142
|
return !!this.masterToken || this.store.keys.length > 0;
|
package/dist/events.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export declare class SessionEventBus {
|
|
|
81
81
|
id: number;
|
|
82
82
|
event: GlobalSSEEvent;
|
|
83
83
|
}>;
|
|
84
|
+
/** #398: Clean up per-session state (call when session is killed). */
|
|
85
|
+
cleanupSession(sessionId: string): void;
|
|
84
86
|
/** Clean up all emitters. */
|
|
85
87
|
destroy(): void;
|
|
86
88
|
}
|
package/dist/events.js
CHANGED
|
@@ -243,6 +243,15 @@ export class SessionEventBus {
|
|
|
243
243
|
getGlobalEventsSince(lastEventId) {
|
|
244
244
|
return this.globalEventBuffer.filter(e => e.id > lastEventId);
|
|
245
245
|
}
|
|
246
|
+
/** #398: Clean up per-session state (call when session is killed). */
|
|
247
|
+
cleanupSession(sessionId) {
|
|
248
|
+
this.eventBuffers.delete(sessionId);
|
|
249
|
+
const emitter = this.emitters.get(sessionId);
|
|
250
|
+
if (emitter) {
|
|
251
|
+
emitter.removeAllListeners();
|
|
252
|
+
this.emitters.delete(sessionId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
246
255
|
/** Clean up all emitters. */
|
|
247
256
|
destroy() {
|
|
248
257
|
for (const emitter of this.emitters.values()) {
|
package/dist/mcp-server.js
CHANGED
|
@@ -36,8 +36,9 @@ export class AegisClient {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
async request(path, opts) {
|
|
39
|
+
const hasBody = opts?.body !== undefined;
|
|
39
40
|
const headers = {
|
|
40
|
-
'Content-Type': 'application/json',
|
|
41
|
+
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
|
41
42
|
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
|
42
43
|
};
|
|
43
44
|
let res;
|
package/dist/monitor.js
CHANGED
|
@@ -256,6 +256,23 @@ export class SessionMonitor {
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
+
// --- Type 5: Extended working stall (working too long regardless of byte changes, ---
|
|
260
|
+
// Catches CC stuck in "Misting" state where internal loop detection
|
|
261
|
+
if (currentStatus === 'working') {
|
|
262
|
+
const entry = this.stateSince.get(session.id);
|
|
263
|
+
if (entry && entry.state === 'working') {
|
|
264
|
+
const workingDuration = now - entry.since;
|
|
265
|
+
const maxWorkingMs = this.config.stallThresholdMs * 3; // 15 min default
|
|
266
|
+
if (workingDuration >= maxWorkingMs && !this.stallNotified.has(`${session.id}:stall:extended_working`)) {
|
|
267
|
+
this.stallNotified.add(`${session.id}:stall:extended_working`);
|
|
268
|
+
const minutes = Math.round(workingDuration / 60000);
|
|
269
|
+
const detail = `Session stalled: in "working" state for ${minutes}min. ` +
|
|
270
|
+
`CC may be stuck in an internal loop (e.g., Misting). Consider: POST /v1/sessions/${session.id}/interrupt or /kill`;
|
|
271
|
+
this.eventBus?.emitStall(session.id, 'extended_working', detail);
|
|
272
|
+
await this.channels.statusChange(this.makePayload('status.stall', session, detail));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
259
276
|
// Clean up stall notifications on state transitions (using prevStallStatus)
|
|
260
277
|
if (prevStallStatus && prevStallStatus !== currentStatus) {
|
|
261
278
|
const exitedPermission = prevStallStatus === 'permission_prompt' || prevStallStatus === 'bash_approval';
|
|
@@ -361,12 +378,20 @@ export class SessionMonitor {
|
|
|
361
378
|
// Update last activity
|
|
362
379
|
session.lastActivity = Date.now();
|
|
363
380
|
}
|
|
364
|
-
// Update JSONL stall tracking —
|
|
381
|
+
// Update JSONL stall tracking — only reset stall timer when real messages arrive
|
|
382
|
+
// When no messages, only update bytes tracking (keep timestamp)
|
|
365
383
|
const now = Date.now();
|
|
366
384
|
const prev = this.lastBytesSeen.get(event.sessionId);
|
|
367
385
|
if (event.newOffset > (prev?.bytes ?? -1)) {
|
|
368
|
-
|
|
369
|
-
|
|
386
|
+
if (event.messages.length > 0) {
|
|
387
|
+
// Real output — reset stall timer
|
|
388
|
+
this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
|
|
389
|
+
this.stallNotified.delete(`${event.sessionId}:stall:jsonl`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// File grew but no messages — only update bytes, keep timestamp
|
|
393
|
+
this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: prev?.at ?? now });
|
|
394
|
+
}
|
|
370
395
|
}
|
|
371
396
|
}
|
|
372
397
|
async checkSession(session) {
|
|
@@ -407,6 +432,9 @@ export class SessionMonitor {
|
|
|
407
432
|
const latestResult = { statusText: result.statusText, interactiveContent: result.interactiveContent };
|
|
408
433
|
this.statusChangeDebounce.set(session.id, setTimeout(() => {
|
|
409
434
|
this.statusChangeDebounce.delete(session.id);
|
|
435
|
+
// #511: Skip broadcast if session was killed while debounce was pending
|
|
436
|
+
if (!this.lastStatus.has(session.id))
|
|
437
|
+
return;
|
|
410
438
|
void this.broadcastStatusChange(session, latestStatus, latestPrevStatus, latestResult)
|
|
411
439
|
.catch(e => console.error(`Monitor: broadcastStatusChange failed for ${session.id}:`, e));
|
|
412
440
|
}, STATUS_CHANGE_DEBOUNCE_MS));
|
package/dist/server.js
CHANGED
|
@@ -517,13 +517,13 @@ app.get('/v1/sessions/:id', async (req, reply) => {
|
|
|
517
517
|
const session = sessions.getSession(req.params.id);
|
|
518
518
|
if (!session)
|
|
519
519
|
return reply.status(404).send({ error: 'Session not found' });
|
|
520
|
-
return addActionHints(session);
|
|
520
|
+
return addActionHints(session, sessions);
|
|
521
521
|
});
|
|
522
522
|
app.get('/sessions/:id', async (req, reply) => {
|
|
523
523
|
const session = sessions.getSession(req.params.id);
|
|
524
524
|
if (!session)
|
|
525
525
|
return reply.status(404).send({ error: 'Session not found' });
|
|
526
|
-
return addActionHints(session);
|
|
526
|
+
return addActionHints(session, sessions);
|
|
527
527
|
});
|
|
528
528
|
// #128: Bulk health check — returns health for all sessions in one request
|
|
529
529
|
app.get('/v1/sessions/health', async () => {
|
|
@@ -1110,6 +1110,7 @@ async function reapStaleSessions(maxAgeMs) {
|
|
|
1110
1110
|
session: { id: session.id, name: session.windowName, workDir: session.workDir },
|
|
1111
1111
|
detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
|
|
1112
1112
|
});
|
|
1113
|
+
eventBus.cleanupSession(session.id);
|
|
1113
1114
|
await sessions.killSession(session.id);
|
|
1114
1115
|
monitor.removeSession(session.id);
|
|
1115
1116
|
metrics.cleanupSession(session.id);
|
|
@@ -1139,6 +1140,7 @@ async function reapZombieSessions() {
|
|
|
1139
1140
|
console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
|
|
1140
1141
|
try {
|
|
1141
1142
|
monitor.removeSession(session.id);
|
|
1143
|
+
eventBus.cleanupSession(session.id);
|
|
1142
1144
|
await sessions.killSession(session.id);
|
|
1143
1145
|
metrics.cleanupSession(session.id);
|
|
1144
1146
|
await channels.sessionEnded({
|
|
@@ -1155,7 +1157,7 @@ async function reapZombieSessions() {
|
|
|
1155
1157
|
}
|
|
1156
1158
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
1157
1159
|
/** Issue #20: Add actionHints to session response for interactive states. */
|
|
1158
|
-
function addActionHints(session) {
|
|
1160
|
+
function addActionHints(session, sessions) {
|
|
1159
1161
|
// #357: Convert Set to array for JSON serialization
|
|
1160
1162
|
const result = {
|
|
1161
1163
|
...session,
|
|
@@ -1167,8 +1169,33 @@ function addActionHints(session) {
|
|
|
1167
1169
|
reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
|
|
1168
1170
|
};
|
|
1169
1171
|
}
|
|
1172
|
+
// #599: Expose pending question data for MCP/REST callers
|
|
1173
|
+
if (session.status === 'ask_question' && sessions) {
|
|
1174
|
+
const info = sessions.getPendingQuestionInfo(session.id);
|
|
1175
|
+
if (info) {
|
|
1176
|
+
result.pendingQuestion = {
|
|
1177
|
+
toolUseId: info.toolUseId,
|
|
1178
|
+
content: info.question,
|
|
1179
|
+
options: extractQuestionOptions(info.question),
|
|
1180
|
+
since: info.timestamp,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1170
1184
|
return result;
|
|
1171
1185
|
}
|
|
1186
|
+
/** #599: Extract selectable options from AskUserQuestion text. */
|
|
1187
|
+
function extractQuestionOptions(text) {
|
|
1188
|
+
// Numbered options: "1. Foo\n2. Bar"
|
|
1189
|
+
const numberedRegex = /^\s*(\d+)\.\s+(.+)$/gm;
|
|
1190
|
+
const options = [];
|
|
1191
|
+
let m;
|
|
1192
|
+
while ((m = numberedRegex.exec(text)) !== null) {
|
|
1193
|
+
options.push(m[2].trim());
|
|
1194
|
+
}
|
|
1195
|
+
if (options.length >= 2)
|
|
1196
|
+
return options.slice(0, 4);
|
|
1197
|
+
return null;
|
|
1198
|
+
}
|
|
1172
1199
|
function makePayload(event, sessionId, detail, meta) {
|
|
1173
1200
|
const session = sessions.getSession(sessionId);
|
|
1174
1201
|
return {
|
|
@@ -1422,6 +1449,8 @@ async function main() {
|
|
|
1422
1449
|
const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
|
|
1423
1450
|
// #357: Prune stale IP rate-limit entries every minute
|
|
1424
1451
|
const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
|
|
1452
|
+
// #398: Sweep stale API key rate limit buckets every 5 minutes
|
|
1453
|
+
const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000);
|
|
1425
1454
|
// Issue #361: Graceful shutdown handler
|
|
1426
1455
|
// Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
|
|
1427
1456
|
let shuttingDown = false;
|
|
@@ -1441,6 +1470,7 @@ async function main() {
|
|
|
1441
1470
|
clearInterval(zombieReaperInterval);
|
|
1442
1471
|
clearInterval(metricsSaveInterval);
|
|
1443
1472
|
clearInterval(ipPruneInterval);
|
|
1473
|
+
clearInterval(authSweepInterval);
|
|
1444
1474
|
// 3. Destroy channels (awaits Telegram poll loop)
|
|
1445
1475
|
try {
|
|
1446
1476
|
await channels.destroy();
|
package/dist/session.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare class SessionManager {
|
|
|
61
61
|
private static readonly SAVE_DEBOUNCE_MS;
|
|
62
62
|
private pendingPermissions;
|
|
63
63
|
private pendingQuestions;
|
|
64
|
+
private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
|
|
64
65
|
private parsedEntriesCache;
|
|
65
66
|
constructor(tmux: TmuxManager, config: Config);
|
|
66
67
|
/** Validate that parsed data looks like a valid SessionState. */
|
|
@@ -227,6 +228,7 @@ export declare class SessionManager {
|
|
|
227
228
|
getPendingQuestionInfo(sessionId: string): {
|
|
228
229
|
toolUseId: string;
|
|
229
230
|
question: string;
|
|
231
|
+
timestamp: number;
|
|
230
232
|
} | null;
|
|
231
233
|
/** Issue #336: Clean up any pending question for a session. */
|
|
232
234
|
cleanupPendingQuestion(sessionId: string): void;
|
package/dist/session.js
CHANGED
|
@@ -43,6 +43,8 @@ export class SessionManager {
|
|
|
43
43
|
pendingPermissions = new Map();
|
|
44
44
|
pendingQuestions = new Map();
|
|
45
45
|
// #357: Cache of all parsed JSONL entries per session to avoid re-reading from offset 0
|
|
46
|
+
// #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
|
|
47
|
+
static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
|
|
46
48
|
parsedEntriesCache = new Map();
|
|
47
49
|
constructor(tmux, config) {
|
|
48
50
|
this.tmux = tmux;
|
|
@@ -808,7 +810,7 @@ export class SessionManager {
|
|
|
808
810
|
console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
|
|
809
811
|
resolve(null);
|
|
810
812
|
}, timeoutMs);
|
|
811
|
-
this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question });
|
|
813
|
+
this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question, timestamp: Date.now() });
|
|
812
814
|
});
|
|
813
815
|
}
|
|
814
816
|
/** Issue #336: Submit an answer to a pending question. Returns true if resolved. */
|
|
@@ -830,7 +832,7 @@ export class SessionManager {
|
|
|
830
832
|
/** Issue #336: Get info about a pending question. */
|
|
831
833
|
getPendingQuestionInfo(sessionId) {
|
|
832
834
|
const pending = this.pendingQuestions.get(sessionId);
|
|
833
|
-
return pending ? { toolUseId: pending.toolUseId, question: pending.question } : null;
|
|
835
|
+
return pending ? { toolUseId: pending.toolUseId, question: pending.question, timestamp: pending.timestamp } : null;
|
|
834
836
|
}
|
|
835
837
|
/** Issue #336: Clean up any pending question for a session. */
|
|
836
838
|
cleanupPendingQuestion(sessionId) {
|
|
@@ -946,6 +948,10 @@ export class SessionManager {
|
|
|
946
948
|
if (cached) {
|
|
947
949
|
cached.entries.push(...result.entries);
|
|
948
950
|
cached.offset = result.newOffset;
|
|
951
|
+
// #424: Evict oldest entries when cache exceeds per-session cap
|
|
952
|
+
if (cached.entries.length > SessionManager.MAX_CACHE_ENTRIES_PER_SESSION) {
|
|
953
|
+
cached.entries.splice(0, cached.entries.length - SessionManager.MAX_CACHE_ENTRIES_PER_SESSION);
|
|
954
|
+
}
|
|
949
955
|
return cached.entries;
|
|
950
956
|
}
|
|
951
957
|
// First read — cache it
|