aegis-bridge 2.12.0 → 2.12.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/dist/events.js CHANGED
@@ -53,8 +53,43 @@ export class SessionEventBus {
53
53
  static BUFFER_SIZE = 50;
54
54
  /** Per-session ring buffer for event replay. */
55
55
  eventBuffers = new Map();
56
+ /** Last activity time per session buffer for LRU eviction. */
57
+ sessionBufferLastTouched = new Map();
56
58
  /** Global ring buffer for event replay across all sessions (Issue #301). */
57
59
  globalEventBuffer = new CircularBuffer(SessionEventBus.BUFFER_SIZE);
60
+ maxSessionBuffers;
61
+ constructor(options = {}) {
62
+ this.maxSessionBuffers = options.maxSessionBuffers ?? 10_000;
63
+ }
64
+ touchSessionBuffer(sessionId) {
65
+ this.sessionBufferLastTouched.set(sessionId, Date.now());
66
+ }
67
+ pruneSessionBufferMap(protectedSessionId) {
68
+ if (this.eventBuffers.size <= this.maxSessionBuffers)
69
+ return;
70
+ let oldestSessionId;
71
+ let oldestTouched = Infinity;
72
+ for (const [sessionId, touchedAt] of this.sessionBufferLastTouched) {
73
+ if (sessionId === protectedSessionId)
74
+ continue;
75
+ if (!this.eventBuffers.has(sessionId)) {
76
+ this.sessionBufferLastTouched.delete(sessionId);
77
+ continue;
78
+ }
79
+ const emitter = this.emitters.get(sessionId);
80
+ if (emitter && emitter.listenerCount('event') > 0) {
81
+ continue;
82
+ }
83
+ if (touchedAt < oldestTouched) {
84
+ oldestTouched = touchedAt;
85
+ oldestSessionId = sessionId;
86
+ }
87
+ }
88
+ if (oldestSessionId !== undefined) {
89
+ this.eventBuffers.delete(oldestSessionId);
90
+ this.sessionBufferLastTouched.delete(oldestSessionId);
91
+ }
92
+ }
58
93
  /** Get or create the emitter for a session. */
59
94
  getEmitter(sessionId) {
60
95
  let emitter = this.emitters.get(sessionId);
@@ -95,6 +130,8 @@ export class SessionEventBus {
95
130
  this.eventBuffers.set(sessionId, buffer);
96
131
  }
97
132
  buffer.push({ id: event.id, event });
133
+ this.touchSessionBuffer(sessionId);
134
+ this.pruneSessionBufferMap(sessionId);
98
135
  const emitter = this.emitters.get(sessionId);
99
136
  if (emitter) {
100
137
  const imm = setImmediate(() => {
@@ -120,6 +157,7 @@ export class SessionEventBus {
120
157
  const buffer = this.eventBuffers.get(sessionId);
121
158
  if (!buffer)
122
159
  return [];
160
+ this.touchSessionBuffer(sessionId);
123
161
  return buffer.toArray().filter(e => e.id > lastEventId).map(e => e.event);
124
162
  }
125
163
  /**
@@ -140,6 +178,7 @@ export class SessionEventBus {
140
178
  newest_id: null,
141
179
  };
142
180
  }
181
+ this.touchSessionBuffer(sessionId);
143
182
  const entries = buffer.toArray();
144
183
  const clampedLimit = Math.min(SessionEventBus.BUFFER_SIZE, Math.max(1, limit));
145
184
  const upperExclusive = beforeId !== undefined
@@ -227,6 +266,7 @@ export class SessionEventBus {
227
266
  this.emitters.delete(sessionId);
228
267
  }
229
268
  this.eventBuffers.delete(sessionId);
269
+ this.sessionBufferLastTouched.delete(sessionId);
230
270
  }, 1000);
231
271
  this.pendingTimeouts.add(timeout);
232
272
  }
@@ -317,6 +357,7 @@ export class SessionEventBus {
317
357
  this.pendingTimeouts.delete(timeout);
318
358
  }
319
359
  this.eventBuffers.delete(sessionId);
360
+ this.sessionBufferLastTouched.delete(sessionId);
320
361
  const emitter = this.emitters.get(sessionId);
321
362
  if (emitter) {
322
363
  emitter.removeAllListeners();
@@ -340,6 +381,7 @@ export class SessionEventBus {
340
381
  }
341
382
  this.emitters.clear();
342
383
  this.eventBuffers.clear();
384
+ this.sessionBufferLastTouched.clear();
343
385
  this.globalEventBuffer.clear();
344
386
  this.globalEmitter?.removeAllListeners();
345
387
  this.globalEmitter = null;
@@ -23,7 +23,7 @@
23
23
  * - WorktreeCreate, WorktreeRemove (worktree management)
24
24
  * - Elicitation, ElicitationResult (MCP-specific)
25
25
  */
26
- declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "PreCompact", "PostCompact", "FileChanged", "CwdChanged", "Notification", "TeammateIdle", "WorktreeCreate", "WorktreeCreateFailed", "WorktreeRemove", "WorktreeRemoveFailed", "Elicitation", "ElicitationResult"];
26
+ declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "PreCompact", "PostCompact", "FileChanged", "CwdChanged", "Notification", "TeammateIdle", "WorktreeCreate", "WorktreeRemove", "Elicitation", "ElicitationResult"];
27
27
  export { HTTP_HOOK_EVENTS };
28
28
  export type HttpHookEvent = typeof HTTP_HOOK_EVENTS[number];
29
29
  /** Shape of a single HTTP hook entry in CC settings.json. */
@@ -15,10 +15,10 @@
15
15
  */
16
16
  import { readFile, writeFile, unlink, mkdir, rmdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { join, resolve, normalize } from 'node:path';
18
+ import { join, resolve } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { randomBytes } from 'node:crypto';
21
- import { ccSettingsSchema } from './validation.js';
21
+ import { ccSettingsSchema, containsTraversalSegment } from './validation.js';
22
22
  function isRecord(value) {
23
23
  return typeof value === 'object' && value !== null && !Array.isArray(value);
24
24
  }
@@ -53,15 +53,9 @@ function normalizeHookBaseUrl(baseUrl) {
53
53
  * @returns Sanitized absolute path, or undefined if validation fails.
54
54
  */
55
55
  function validateWorkDirPath(workDir) {
56
- const normalized = normalize(workDir);
57
- // Reject paths with traversal segments
58
- if (normalized.includes('..'))
56
+ if (containsTraversalSegment(workDir))
59
57
  return undefined;
60
- // Resolve to absolute and verify it doesn't escape upward
61
- const resolved = resolve(normalized);
62
- if (resolved.includes('..'))
63
- return undefined;
64
- return resolved;
58
+ return resolve(workDir);
65
59
  }
66
60
  /** CC hook events that support `type: "http"`.
67
61
  *
@@ -98,11 +92,9 @@ const HTTP_HOOK_EVENTS = [
98
92
  // Notifications
99
93
  'Notification',
100
94
  'TeammateIdle',
101
- // Worktree management
95
+ // Worktree management (only Create/Remove — *Failed variants don't exist in CC, see #1002)
102
96
  'WorktreeCreate',
103
- 'WorktreeCreateFailed',
104
97
  'WorktreeRemove',
105
- 'WorktreeRemoveFailed',
106
98
  // Elicitation
107
99
  'Elicitation',
108
100
  'ElicitationResult',
package/dist/hooks.js CHANGED
@@ -44,9 +44,7 @@ const KNOWN_HOOK_EVENTS = new Set([
44
44
  'PostCompact',
45
45
  'UserPromptSubmit',
46
46
  'WorktreeCreate',
47
- 'WorktreeCreateFailed',
48
47
  'WorktreeRemove',
49
- 'WorktreeRemoveFailed',
50
48
  'Elicitation',
51
49
  'ElicitationResult',
52
50
  'FileChanged',
@@ -74,9 +72,7 @@ function hookToUIState(eventName) {
74
72
  case 'Elicitation':
75
73
  case 'ElicitationResult':
76
74
  case 'WorktreeCreate':
77
- case 'WorktreeCreateFailed':
78
- case 'WorktreeRemove':
79
- case 'WorktreeRemoveFailed': return 'working';
75
+ case 'WorktreeRemove': return 'working';
80
76
  case 'PreCompact': return 'compacting';
81
77
  case 'PermissionRequest': return 'permission_prompt';
82
78
  case 'TeammateIdle': return 'idle';
@@ -151,8 +147,7 @@ export function registerHookRoutes(app, deps) {
151
147
  });
152
148
  }
153
149
  // Issue #89 L26: WorktreeCreate/Remove hooks — informational tracking only
154
- if (eventName === 'WorktreeCreate' || eventName === 'WorktreeCreateFailed' ||
155
- eventName === 'WorktreeRemove' || eventName === 'WorktreeRemoveFailed') {
150
+ if (eventName === 'WorktreeCreate' || eventName === 'WorktreeRemove') {
156
151
  console.log(`Hooks: ${eventName} for session ${sessionId}`);
157
152
  }
158
153
  // Informational events — log and forward to SSE (already forwarded below via emitHook)
@@ -228,15 +223,9 @@ export function registerHookRoutes(app, deps) {
228
223
  case 'WorktreeCreate':
229
224
  deps.eventBus.emitStatus(sessionId, 'working', `Worktree created: ${hookBody.worktree_path || 'unknown'} (hook: WorktreeCreate)`);
230
225
  break;
231
- case 'WorktreeCreateFailed':
232
- deps.eventBus.emitStatus(sessionId, 'working', `Worktree creation failed: ${hookBody.error || 'unknown'} (hook: WorktreeCreateFailed)`);
233
- break;
234
226
  case 'WorktreeRemove':
235
227
  deps.eventBus.emitStatus(sessionId, 'idle', `Worktree removed: ${hookBody.worktree_path || 'unknown'} (hook: WorktreeRemove)`);
236
228
  break;
237
- case 'WorktreeRemoveFailed':
238
- deps.eventBus.emitStatus(sessionId, 'working', `Worktree removal failed: ${hookBody.error || 'unknown'} (hook: WorktreeRemoveFailed)`);
239
- break;
240
229
  case 'PermissionRequest':
241
230
  deps.eventBus.emitApproval(sessionId, hookBody.permission_prompt || 'Permission requested (hook)');
242
231
  break;
package/dist/metrics.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Issue #40: Global and per-session metrics for monitoring.
5
5
  * Counters are in-memory, persisted to disk on shutdown, loaded on startup.
6
6
  */
7
+ import type { GlobalMetrics as GlobalMetricsResponse } from './api-contracts.js';
7
8
  export interface GlobalMetrics {
8
9
  sessionsCreated: number;
9
10
  sessionsCompleted: number;
@@ -100,7 +101,7 @@ export declare class MetricsCollector {
100
101
  clearSessionLatency(sessionId: string): void;
101
102
  /** #357: Clean up all per-session data (call on session destroy). */
102
103
  cleanupSession(sessionId: string): void;
103
- getGlobalMetrics(activeSessionCount: number): Record<string, unknown>;
104
+ getGlobalMetrics(activeSessionCount: number): GlobalMetricsResponse;
104
105
  getSessionMetrics(sessionId: string): SessionMetrics | null;
105
106
  getTotalSessionsCreated(): number;
106
107
  }
package/dist/monitor.js CHANGED
@@ -155,6 +155,13 @@ export class SessionMonitor {
155
155
  }
156
156
  async poll() {
157
157
  const now = Date.now();
158
+ // Issue #397: Run tmux health checks before dead-session reaping.
159
+ // This prevents false "status.dead" events when tmux is temporarily
160
+ // unreachable and windows still exist once the server recovers.
161
+ if (now - this.lastTmuxHealthCheck >= SessionMonitor.TMUX_HEALTH_CHECK_INTERVAL_MS) {
162
+ this.lastTmuxHealthCheck = now;
163
+ await this.checkTmuxHealth();
164
+ }
158
165
  for (const session of this.sessions.listSessions()) {
159
166
  try {
160
167
  // Issue #84: Start watching when jsonlPath is discovered
@@ -178,11 +185,6 @@ export class SessionMonitor {
178
185
  this.lastDeadCheck = now;
179
186
  await this.checkDeadSessions();
180
187
  }
181
- // Issue #397: Tmux server health check (every 10s)
182
- if (now - this.lastTmuxHealthCheck >= SessionMonitor.TMUX_HEALTH_CHECK_INTERVAL_MS) {
183
- this.lastTmuxHealthCheck = now;
184
- await this.checkTmuxHealth();
185
- }
186
188
  }
187
189
  /** Smart stall detection: multiple stall types with graduated thresholds.
188
190
  *
@@ -614,6 +616,10 @@ export class SessionMonitor {
614
616
  }
615
617
  /** Check for dead tmux windows and notify via channels. */
616
618
  async checkDeadSessions() {
619
+ // Issue #397: While tmux server is down, defer dead-session cleanup.
620
+ // tmux commands can fail transiently and make healthy sessions look dead.
621
+ if (this.tmuxWasDown)
622
+ return;
617
623
  const sessions = this.sessions.listSessions();
618
624
  for (const session of sessions) {
619
625
  if (this.deadNotified.has(session.id))
@@ -643,13 +649,34 @@ export class SessionMonitor {
643
649
  async checkTmuxHealth() {
644
650
  if (!this.tmux)
645
651
  return;
646
- const { healthy } = await this.tmux.isServerHealthy();
652
+ let healthy = true;
653
+ let error = null;
654
+ try {
655
+ ({ healthy, error } = await this.tmux.isServerHealthy());
656
+ }
657
+ catch (e) {
658
+ healthy = false;
659
+ error = e instanceof Error ? e.message : String(e);
660
+ }
647
661
  if (!healthy) {
662
+ // Only treat known server/socket failures as "tmux down".
663
+ // Other tmux errors can be transient command failures.
664
+ const serverDown = this.tmux.isTmuxServerError(new Error(error ?? 'tmux unavailable'));
665
+ if (!serverDown) {
666
+ logger.warn({
667
+ component: 'monitor',
668
+ operation: 'tmux_health_check',
669
+ errorCode: 'TMUX_HEALTH_CHECK_ERROR',
670
+ attributes: { error: error ?? 'unknown tmux health error' },
671
+ });
672
+ return;
673
+ }
648
674
  if (!this.tmuxWasDown) {
649
675
  logger.warn({
650
676
  component: 'monitor',
651
677
  operation: 'tmux_health_check',
652
678
  errorCode: 'TMUX_UNREACHABLE',
679
+ attributes: { error: error ?? 'tmux server unavailable' },
653
680
  });
654
681
  this.tmuxWasDown = true;
655
682
  }
package/dist/server.js CHANGED
@@ -179,6 +179,7 @@ function checkIpRateLimit(ip, isMaster) {
179
179
  const authFailLimits = new Map();
180
180
  const AUTH_FAIL_WINDOW_MS = 60_000;
181
181
  const AUTH_FAIL_MAX = 5;
182
+ const MAX_AUTH_FAIL_IP_ENTRIES = 10_000;
182
183
  function checkAuthFailRateLimit(ip) {
183
184
  const now = Date.now();
184
185
  const cutoff = now - AUTH_FAIL_WINDOW_MS;
@@ -187,6 +188,19 @@ function checkAuthFailRateLimit(ip) {
187
188
  bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
188
189
  bucket.timestamps.push(now);
189
190
  authFailLimits.set(ip, bucket);
191
+ if (authFailLimits.size > MAX_AUTH_FAIL_IP_ENTRIES) {
192
+ let oldestIp = '';
193
+ let oldestTime = Infinity;
194
+ for (const [trackedIp, trackedBucket] of authFailLimits) {
195
+ const lastTs = trackedBucket.timestamps[trackedBucket.timestamps.length - 1];
196
+ if (lastTs !== undefined && lastTs < oldestTime) {
197
+ oldestTime = lastTs;
198
+ oldestIp = trackedIp;
199
+ }
200
+ }
201
+ if (oldestIp)
202
+ authFailLimits.delete(oldestIp);
203
+ }
190
204
  return bucket.timestamps.length > AUTH_FAIL_MAX;
191
205
  }
192
206
  function recordAuthFailure(ip) {
@@ -771,9 +785,13 @@ async function spawnChildHandler(req, reply) {
771
785
  return reply.status(404).send({ error: 'Parent session not found' });
772
786
  const { name, prompt, workDir, permissionMode } = req.body ?? {};
773
787
  const childName = name ?? `${parent.windowName ?? 'session'}-child`;
774
- const childWorkDir = workDir ?? parent.workDir;
788
+ const requestedWorkDir = workDir ?? parent.workDir;
789
+ const safeChildWorkDir = await validateWorkDirWithConfig(requestedWorkDir);
790
+ if (typeof safeChildWorkDir === 'object') {
791
+ return reply.status(400).send({ error: `Invalid workDir: ${safeChildWorkDir.error}`, code: safeChildWorkDir.code });
792
+ }
775
793
  const childPermMode = permissionMode ?? parent.permissionMode ?? 'bypassPermissions';
776
- const childSession = await sessions.createSession({ workDir: childWorkDir, name: childName, parentId, permissionMode: childPermMode });
794
+ const childSession = await sessions.createSession({ workDir: safeChildWorkDir, name: childName, parentId, permissionMode: childPermMode });
777
795
  let promptDelivery;
778
796
  if (prompt) {
779
797
  promptDelivery = await sessions.sendInitialPrompt(childSession.id, prompt);
@@ -1504,6 +1522,7 @@ function registerChannels(cfg) {
1504
1522
  botToken: cfg.tgBotToken,
1505
1523
  groupChatId: cfg.tgGroupId,
1506
1524
  allowedUserIds: cfg.tgAllowedUsers,
1525
+ topicTtlMs: cfg.tgTopicTtlMs,
1507
1526
  }));
1508
1527
  }
1509
1528
  // Webhooks (optional)
package/dist/session.d.ts CHANGED
@@ -62,6 +62,8 @@ export declare class SessionManager {
62
62
  private stateFile;
63
63
  private sessionMapFile;
64
64
  private pollTimers;
65
+ /** Next filesystem-scan time (ms epoch) for each discovery poller. */
66
+ private discoveryNextFilesystemScanAt;
65
67
  /** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
66
68
  private discoveryTimeouts;
67
69
  private saveQueue;
@@ -346,12 +348,17 @@ export declare class SessionManager {
346
348
  * and cause new sessions to inherit context from old sessions.
347
349
  */
348
350
  private purgeStaleSessionMapEntries;
349
- /** Try to discover the CC session ID and JSONL path. */
350
- private startSessionIdDiscovery;
351
- /** Issue #16: Filesystem-based discovery for --bare mode (no hooks).
352
- * Scans the Claude projects directory for new .jsonl files created after the session.
351
+ /** Stop and remove the coordinated discovery poller/timer for a session. */
352
+ private stopDiscoveryPolling;
353
+ /** Attempt filesystem-based discovery for a single session poll tick. */
354
+ private maybeDiscoverFromFilesystem;
355
+ /**
356
+ * Coordinated discovery poller for a session.
357
+ *
358
+ * Consolidates hook/session_map sync and filesystem fallback into a single
359
+ * interval loop per session, reducing duplicate independent pollers.
353
360
  */
354
- private startFilesystemDiscovery;
361
+ private startDiscoveryPolling;
355
362
  /** Sync CC session IDs from the hook-written session_map.json. */
356
363
  private syncSessionMap;
357
364
  }