aegis-bridge 2.15.2 → 2.15.4

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/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  <img src="https://img.shields.io/github/actions/workflow/status/OneStepAt4time/aegis/ci.yml?branch=main" alt="CI" />
8
8
  <img src="https://img.shields.io/npm/l/aegis-bridge.svg" alt="license" />
9
9
  <img src="https://img.shields.io/badge/node-%3E%3D20.0.0-blue.svg" alt="node" />
10
+ <img src="https://img.shields.io/badge/MCP-ready-green.svg" alt="MCP ready" />
10
11
  </p>
11
12
 
12
13
  <p align="center">
@@ -96,6 +97,14 @@ Or via `.mcp.json`:
96
97
 
97
98
  **3 prompts** — `implement_issue`, `review_pr`, `debug_session`
98
99
 
100
+ ## Ecosystem Integrations
101
+
102
+ Aegis works beyond Claude Code anywhere an MCP host can launch a local stdio server.
103
+
104
+ - [Cursor integration](docs/integrations/cursor.md)
105
+ - [Windsurf integration](docs/integrations/windsurf.md)
106
+ - [MCP Registry preparation](docs/integrations/mcp-registry.md)
107
+
99
108
  ---
100
109
 
101
110
  ## REST API
package/dist/cli.js CHANGED
@@ -12,7 +12,9 @@ import { fileURLToPath } from 'node:url';
12
12
  import { parseIntSafe, getErrorMessage } from './validation.js';
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
15
+ /** Current aegis-bridge version read from package.json at startup. */
15
16
  const VERSION = pkg.version;
17
+ /** Check whether a required external dependency can be executed. */
16
18
  function checkDependency(name, command) {
17
19
  try {
18
20
  execSync(`${command} 2>/dev/null`, { stdio: 'ignore' });
@@ -22,6 +24,7 @@ function checkDependency(name, command) {
22
24
  return false;
23
25
  }
24
26
  }
27
+ /** Render the startup banner shown when launching the HTTP server. */
25
28
  function printBanner(port) {
26
29
  console.log(`
27
30
  ┌─────────────────────────────────────────┐
@@ -107,6 +110,7 @@ async function handleCreate(args) {
107
110
  console.log(` Read: curl ${baseUrl}/v1/sessions/${sessionId}/read`);
108
111
  console.log(` Kill: curl -X DELETE ${baseUrl}/v1/sessions/${sessionId}`);
109
112
  }
113
+ /** Main CLI entry point that dispatches subcommands and bootstraps the server. */
110
114
  async function main() {
111
115
  const args = process.argv.slice(2);
112
116
  // Help
@@ -0,0 +1,16 @@
1
+ export type ConsensusFocusArea = 'correctness' | 'security' | 'performance';
2
+ export interface ConsensusRequest {
3
+ id: string;
4
+ targetSessionId: string;
5
+ reviewerIds: string[];
6
+ focusAreas: ConsensusFocusArea[];
7
+ status: 'running' | 'completed' | 'failed';
8
+ createdAt: number;
9
+ }
10
+ export interface ConsensusReview {
11
+ reviewerId: string;
12
+ focusArea: ConsensusFocusArea;
13
+ findings: string[];
14
+ }
15
+ export declare function buildConsensusPrompt(targetSessionId: string, focusArea: ConsensusFocusArea): string;
16
+ export declare function mergeConsensusFindings(reviews: ConsensusReview[]): string[];
@@ -0,0 +1,19 @@
1
+ export function buildConsensusPrompt(targetSessionId, focusArea) {
2
+ return [
3
+ `Review Aegis session ${targetSessionId}.`,
4
+ `Focus area: ${focusArea}.`,
5
+ 'Return concise findings ordered by severity.',
6
+ 'Prefer concrete regressions, risks, and missing verification.',
7
+ ].join(' ');
8
+ }
9
+ export function mergeConsensusFindings(reviews) {
10
+ const merged = new Set();
11
+ for (const review of reviews) {
12
+ for (const finding of review.findings) {
13
+ const normalized = finding.trim();
14
+ if (normalized)
15
+ merged.add(normalized);
16
+ }
17
+ }
18
+ return Array.from(merged.values());
19
+ }
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * Issue #169: Phase 2 — Inject CC settings.json with HTTP hooks.
15
15
  */
16
+ /** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
17
+ export declare function buildProjectSettingsPath(workDir: string, platform?: NodeJS.Platform): string;
16
18
  /** CC hook events that support `type: "http"`.
17
19
  *
18
20
  * All CC hook events support HTTP hooks. We register the most useful ones
@@ -45,6 +45,23 @@ function normalizeHookBaseUrl(baseUrl) {
45
45
  return baseUrl.replace('0.0.0.0', '127.0.0.1');
46
46
  }
47
47
  }
48
+ /** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
49
+ export function buildProjectSettingsPath(workDir, platform = process.platform) {
50
+ let normalizedWorkDir = platform === 'win32'
51
+ ? workDir.replace(/\//g, '\\')
52
+ : workDir.replace(/\\/g, '/');
53
+ // On Linux, resolve() prepends CWD to Windows paths like "D:\Users\dev"
54
+ // because Linux doesn't understand Windows drive letters. Only resolve
55
+ // paths that are NOT already absolute on the target platform.
56
+ const isWinAbs = /^[A-Za-z]:\\/.test(normalizedWorkDir);
57
+ const isUnixAbs = /^\//.test(normalizedWorkDir);
58
+ const alreadyAbs = (platform === 'win32' && isWinAbs) || isUnixAbs;
59
+ if (!alreadyAbs)
60
+ normalizedWorkDir = resolve(normalizedWorkDir);
61
+ // Normalize separators to match the target platform.
62
+ const result = join(normalizedWorkDir, '.claude', 'settings.local.json');
63
+ return platform === 'win32' ? result.replace(/\//g, '\\') : result;
64
+ }
48
65
  /**
49
66
  * Validate a workDir path for use in hook settings resolution.
50
67
  * Defense-in-depth against path traversal: rejects paths containing ".." segments
@@ -145,7 +162,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
145
162
  let merged = {};
146
163
  const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
147
164
  if (safeWorkDir) {
148
- const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
165
+ const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
149
166
  if (existsSync(projectSettingsPath)) {
150
167
  try {
151
168
  const raw = await readFile(projectSettingsPath, 'utf-8');
@@ -210,7 +227,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
210
227
  const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
211
228
  if (!safeWorkDir)
212
229
  return;
213
- const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
230
+ const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
214
231
  if (!existsSync(projectSettingsPath))
215
232
  return;
216
233
  try {
package/dist/hook.d.ts CHANGED
@@ -15,4 +15,5 @@
15
15
  * }
16
16
  * }
17
17
  */
18
- export {};
18
+ /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */
19
+ export declare function buildHookCommand(scriptPath: string, nodeExecutable?: string, platform?: NodeJS.Platform): string;
package/dist/hook.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * }
17
17
  */
18
18
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
19
- import { join, dirname } from 'node:path';
19
+ import { join, dirname, resolve } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { execFileSync } from 'node:child_process';
22
22
  import { fileURLToPath } from 'node:url';
@@ -32,6 +32,17 @@ const MAP_FILE = join(BRIDGE_DIR, 'session_map.json');
32
32
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
33
33
  const TMUX_PANE_RE = /^%\d+$/;
34
34
  const DEFAULT_POINTER_TTL_MS = 24 * 60 * 60 * 1000;
35
+ function normalizeCommandPath(pathValue, platform = process.platform) {
36
+ return platform === 'win32' ? pathValue.replace(/\//g, '\\') : pathValue.replace(/\\/g, '/');
37
+ }
38
+ function quoteCommandPath(pathValue, platform = process.platform) {
39
+ const normalized = normalizeCommandPath(pathValue, platform);
40
+ return `"${normalized.replace(/"/g, '\\"')}"`;
41
+ }
42
+ /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */
43
+ export function buildHookCommand(scriptPath, nodeExecutable = process.execPath, platform = process.platform) {
44
+ return `${quoteCommandPath(nodeExecutable, platform)} ${quoteCommandPath(scriptPath, platform)}`;
45
+ }
35
46
  function getPointerTtlMs() {
36
47
  const raw = process.env.AEGIS_CONTINUATION_POINTER_TTL_MS ?? process.env.MANUS_CONTINUATION_POINTER_TTL_MS;
37
48
  const parsed = raw ? Number(raw) : NaN;
@@ -176,7 +187,7 @@ function install() {
176
187
  }
177
188
  settings = parsed.data;
178
189
  }
179
- const hookCommand = `node ${join(__dirname, 'hook.js')}`;
190
+ const hookCommand = buildHookCommand(join(__dirname, 'hook.js'));
180
191
  const hooks = (settings.hooks || {});
181
192
  const sessionStart = (hooks.SessionStart || []);
182
193
  // Check if already installed
@@ -204,4 +215,17 @@ function install() {
204
215
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
205
216
  console.log(`Aegis hook installed in ${settingsPath}`);
206
217
  }
207
- main();
218
+ const isDirectExecution = (() => {
219
+ const argv1 = process.argv[1];
220
+ if (!argv1)
221
+ return false;
222
+ try {
223
+ return resolve(argv1) === resolve(__filename);
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ })();
229
+ if (isDirectExecution) {
230
+ main();
231
+ }
package/dist/hooks.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * Issue #169: Phase 3 — Hook-driven status detection.
16
16
  */
17
17
  import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js';
18
+ import { evaluatePermissionProfile } from './permission-evaluator.js';
18
19
  /** CC hook events that require a decision response. */
19
20
  const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
20
21
  /** Permission modes that should be auto-approved via hook response. */
@@ -289,6 +290,37 @@ export function registerHookRoutes(app, deps) {
289
290
  // Timeout: allow without answer (CC shows question to user in terminal)
290
291
  console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
291
292
  }
293
+ if (session.permissionProfile) {
294
+ const evaluation = evaluatePermissionProfile(session.permissionProfile, {
295
+ toolName,
296
+ toolInput: hookBody.tool_input,
297
+ });
298
+ if (evaluation.behavior === 'deny') {
299
+ deps.eventBus.emit(sessionId, {
300
+ event: 'permission_denied',
301
+ sessionId,
302
+ timestamp: new Date().toISOString(),
303
+ data: { toolName, reason: evaluation.reason },
304
+ });
305
+ return reply.status(200).send({
306
+ hookSpecificOutput: {
307
+ hookEventName: 'PreToolUse',
308
+ permissionDecision: 'deny',
309
+ reason: evaluation.reason,
310
+ },
311
+ });
312
+ }
313
+ if (evaluation.behavior === 'ask') {
314
+ deps.eventBus.emitApproval(sessionId, `Permission profile requires approval for ${toolName}`);
315
+ const decision = await deps.sessions.waitForPermissionDecision(sessionId, PERMISSION_TIMEOUT_MS, toolName, evaluation.reason);
316
+ return reply.status(200).send({
317
+ hookSpecificOutput: {
318
+ hookEventName: 'PreToolUse',
319
+ permissionDecision: decision,
320
+ },
321
+ });
322
+ }
323
+ }
292
324
  // Default: allow without modification
293
325
  return reply.status(200).send({
294
326
  hookSpecificOutput: {
@@ -0,0 +1,10 @@
1
+ import type { PermissionProfile } from './validation.js';
2
+ export interface PermissionEvaluationInput {
3
+ toolName: string;
4
+ toolInput?: Record<string, unknown>;
5
+ }
6
+ export interface PermissionEvaluationResult {
7
+ behavior: 'allow' | 'deny' | 'ask';
8
+ reason: string;
9
+ }
10
+ export declare function evaluatePermissionProfile(profile: PermissionProfile, input: PermissionEvaluationInput): PermissionEvaluationResult;
@@ -0,0 +1,48 @@
1
+ function globToRegExp(pattern) {
2
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
3
+ return new RegExp(`^${escaped}$`, 'i');
4
+ }
5
+ function extractCandidatePaths(toolInput) {
6
+ if (!toolInput)
7
+ return [];
8
+ const values = [toolInput.path, toolInput.file_path, toolInput.target, ...(Array.isArray(toolInput.paths) ? toolInput.paths : [])];
9
+ return values.filter((v) => typeof v === 'string');
10
+ }
11
+ function extractContentSize(toolInput) {
12
+ const content = toolInput?.content;
13
+ return typeof content === 'string' ? content.length : null;
14
+ }
15
+ function isLikelyWriteTool(toolName) {
16
+ return /write|edit|delete|rename|move|create/i.test(toolName);
17
+ }
18
+ export function evaluatePermissionProfile(profile, input) {
19
+ for (const rule of profile.rules) {
20
+ if (rule.tool !== input.toolName)
21
+ continue;
22
+ if (rule.pattern) {
23
+ const candidate = typeof input.toolInput?.command === 'string'
24
+ ? input.toolInput.command
25
+ : JSON.stringify(input.toolInput ?? {});
26
+ if (!globToRegExp(rule.pattern).test(candidate))
27
+ continue;
28
+ }
29
+ if (rule.constraints?.readOnly && isLikelyWriteTool(input.toolName)) {
30
+ return { behavior: 'deny', reason: `Denied by readOnly constraint for ${input.toolName}` };
31
+ }
32
+ if (rule.constraints?.paths && rule.constraints.paths.length > 0) {
33
+ const paths = extractCandidatePaths(input.toolInput);
34
+ const allowed = paths.every((candidate) => rule.constraints.paths.some((prefix) => candidate.startsWith(prefix)));
35
+ if (!allowed) {
36
+ return { behavior: 'deny', reason: `Denied by path constraint for ${input.toolName}` };
37
+ }
38
+ }
39
+ if (rule.constraints?.maxFileSize) {
40
+ const size = extractContentSize(input.toolInput);
41
+ if (size !== null && size > rule.constraints.maxFileSize) {
42
+ return { behavior: 'deny', reason: `Denied by maxFileSize constraint for ${input.toolName}` };
43
+ }
44
+ }
45
+ return { behavior: rule.behavior, reason: `Matched rule for ${input.toolName}` };
46
+ }
47
+ return { behavior: profile.defaultBehavior, reason: 'No matching permission rule' };
48
+ }
package/dist/server.js CHANGED
@@ -37,6 +37,7 @@ import { registerHookRoutes } from './hooks.js';
37
37
  import { registerWsTerminalRoute } from './ws-terminal.js';
38
38
  import { registerMemoryRoutes } from './memory-routes.js';
39
39
  import { registerModelRouterRoutes } from './model-router.js';
40
+ import { buildConsensusPrompt } from './consensus.js';
40
41
  import * as templateStore from './template-store.js';
41
42
  import { SwarmMonitor } from './swarm-monitor.js';
42
43
  import { killAllSessions } from './signal-cleanup-helper.js';
@@ -48,9 +49,11 @@ import { MemoryBridge } from './memory-bridge.js';
48
49
  import { cleanupTerminatedSessionState } from './session-cleanup.js';
49
50
  import { normalizeApiErrorPayload } from './api-error-envelope.js';
50
51
  import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
51
- import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, } from './validation.js';
52
+ import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js';
53
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
52
54
  const __filename = fileURLToPath(import.meta.url);
53
55
  const __dirname = path.dirname(__filename);
56
+ const consensusRequests = new Map();
54
57
  // ── Configuration ────────────────────────────────────────────────────
55
58
  // Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
56
59
  const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:";
@@ -904,6 +907,48 @@ async function forkSessionHandler(req, reply) {
904
907
  }
905
908
  app.post('/v1/sessions/:id/fork', forkSessionHandler);
906
909
  app.post('/sessions/:id/fork', forkSessionHandler);
910
+ async function createConsensusHandler(req, reply) {
911
+ const targetSessionId = req.params.id;
912
+ const target = sessions.getSession(targetSessionId);
913
+ if (!target)
914
+ return reply.status(404).send({ error: 'Target session not found' });
915
+ const focusAreas = (req.body?.focusAreas && req.body.focusAreas.length > 0)
916
+ ? req.body.focusAreas
917
+ : ['correctness', 'security', 'performance'];
918
+ const reviewerCount = Math.min(5, Math.max(1, req.body?.reviewerCount ?? focusAreas.length));
919
+ const selectedFocus = focusAreas.slice(0, reviewerCount);
920
+ const reviewerIds = [];
921
+ for (let i = 0; i < selectedFocus.length; i += 1) {
922
+ const focus = selectedFocus[i];
923
+ const child = await sessions.createSession({
924
+ workDir: target.workDir,
925
+ name: `consensus-${focus}-${targetSessionId.slice(0, 6)}`,
926
+ parentId: targetSessionId,
927
+ permissionMode: target.permissionMode,
928
+ });
929
+ reviewerIds.push(child.id);
930
+ await sessions.sendInitialPrompt(child.id, buildConsensusPrompt(targetSessionId, focus));
931
+ }
932
+ const consensusId = crypto.randomUUID();
933
+ const record = {
934
+ id: consensusId,
935
+ targetSessionId,
936
+ reviewerIds,
937
+ focusAreas: selectedFocus,
938
+ status: 'running',
939
+ createdAt: Date.now(),
940
+ };
941
+ consensusRequests.set(consensusId, record);
942
+ return reply.status(202).send(record);
943
+ }
944
+ function getConsensusHandler(req, reply) {
945
+ const item = consensusRequests.get(req.params.id);
946
+ if (!item)
947
+ return reply.status(404).send({ error: 'Consensus request not found' });
948
+ return item;
949
+ }
950
+ app.post('/v1/sessions/:id/consensus', createConsensusHandler);
951
+ app.get('/v1/consensus/:id', getConsensusHandler);
907
952
  async function getPermissionPolicyHandler(req, reply) {
908
953
  const sessionId = req.params.id;
909
954
  const session = sessions.getSession(sessionId);
@@ -928,6 +973,29 @@ app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
928
973
  app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
929
974
  app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
930
975
  app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
976
+ async function getPermissionProfileHandler(req, reply) {
977
+ const sessionId = req.params.id;
978
+ const session = sessions.getSession(sessionId);
979
+ if (!session)
980
+ return reply.status(404).send({ error: 'Session not found' });
981
+ return { permissionProfile: session.permissionProfile ?? null };
982
+ }
983
+ async function updatePermissionProfileHandler(req, reply) {
984
+ const sessionId = req.params.id;
985
+ const session = sessions.getSession(sessionId);
986
+ if (!session)
987
+ return reply.status(404).send({ error: 'Session not found' });
988
+ const parsed = permissionProfileSchema.safeParse(req.body ?? {});
989
+ if (!parsed.success)
990
+ return reply.status(400).send({ error: 'Invalid permission profile', details: parsed.error.issues });
991
+ session.permissionProfile = parsed.data;
992
+ await sessions.save();
993
+ return { permissionProfile: parsed.data };
994
+ }
995
+ app.get('/v1/sessions/:id/permission-profile', getPermissionProfileHandler);
996
+ app.put('/v1/sessions/:id/permission-profile', updatePermissionProfileHandler);
997
+ app.get('/sessions/:id/permission-profile', getPermissionProfileHandler);
998
+ app.put('/sessions/:id/permission-profile', updatePermissionProfileHandler);
931
999
  // Read messages
932
1000
  async function readMessagesHandler(req, reply) {
933
1001
  try {
@@ -1698,56 +1766,67 @@ async function main() {
1698
1766
  // Issue #361: Graceful shutdown handler
1699
1767
  // Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
1700
1768
  let shuttingDown = false;
1769
+ const shutdownTimeoutMs = parseShutdownTimeoutMs(process.env.AEGIS_SHUTDOWN_TIMEOUT_MS);
1701
1770
  async function gracefulShutdown(signal) {
1702
1771
  console.log(`${signal} received, shutting down gracefully...`);
1703
- // 1. Stop accepting new requests
1704
- try {
1705
- await app.close();
1706
- }
1707
- catch (e) {
1708
- console.error('Error closing server:', e);
1709
- }
1710
- // 2. Stop background monitors and intervals
1711
- monitor.stop();
1712
- swarmMonitor.stop();
1713
- clearInterval(reaperInterval);
1714
- clearInterval(zombieReaperInterval);
1715
- clearInterval(metricsSaveInterval);
1716
- clearInterval(ipPruneInterval);
1717
- clearInterval(authFailPruneInterval);
1718
- clearInterval(authSweepInterval);
1719
- // Issue #569: Kill all CC sessions and tmux windows before exit
1720
- try {
1721
- await killAllSessions(sessions, tmux);
1722
- }
1723
- catch (e) {
1724
- console.error('Error killing sessions:', e);
1725
- }
1726
- // 3. Destroy channels (awaits Telegram poll loop)
1772
+ const forceExitTimer = setTimeout(() => {
1773
+ console.error(`Graceful shutdown timed out after ${shutdownTimeoutMs}ms — forcing process exit`);
1774
+ process.exit(1);
1775
+ }, shutdownTimeoutMs);
1776
+ forceExitTimer.unref?.();
1727
1777
  try {
1728
- await channels.destroy();
1729
- }
1730
- catch (e) {
1731
- console.error('Error destroying channels:', e);
1732
- }
1733
- // 4. Save session state
1734
- try {
1735
- await sessions.save();
1736
- }
1737
- catch (e) {
1738
- console.error('Error saving sessions:', e);
1739
- }
1740
- // 5. Save metrics
1741
- try {
1742
- await metrics.save();
1778
+ // 1. Stop accepting new requests
1779
+ try {
1780
+ await app.close();
1781
+ }
1782
+ catch (e) {
1783
+ console.error('Error closing server:', e);
1784
+ }
1785
+ // 2. Stop background monitors and intervals
1786
+ monitor.stop();
1787
+ swarmMonitor.stop();
1788
+ clearInterval(reaperInterval);
1789
+ clearInterval(zombieReaperInterval);
1790
+ clearInterval(metricsSaveInterval);
1791
+ clearInterval(ipPruneInterval);
1792
+ clearInterval(authFailPruneInterval);
1793
+ clearInterval(authSweepInterval);
1794
+ // Issue #569: Kill all CC sessions and tmux windows before exit
1795
+ try {
1796
+ await killAllSessions(sessions, tmux);
1797
+ }
1798
+ catch (e) {
1799
+ console.error('Error killing sessions:', e);
1800
+ }
1801
+ // 3. Destroy channels (awaits Telegram poll loop)
1802
+ try {
1803
+ await channels.destroy();
1804
+ }
1805
+ catch (e) {
1806
+ console.error('Error destroying channels:', e);
1807
+ }
1808
+ // 4. Save session state
1809
+ try {
1810
+ await sessions.save();
1811
+ }
1812
+ catch (e) {
1813
+ console.error('Error saving sessions:', e);
1814
+ }
1815
+ // 5. Save metrics
1816
+ try {
1817
+ await metrics.save();
1818
+ }
1819
+ catch (e) {
1820
+ console.error('Error saving metrics:', e);
1821
+ }
1822
+ // 6. Cleanup PID file
1823
+ removePidFile(pidFilePath);
1824
+ console.log('Graceful shutdown complete');
1825
+ process.exit(0);
1743
1826
  }
1744
- catch (e) {
1745
- console.error('Error saving metrics:', e);
1827
+ finally {
1828
+ clearTimeout(forceExitTimer);
1746
1829
  }
1747
- // 6. Cleanup PID file
1748
- removePidFile(pidFilePath);
1749
- console.log('Graceful shutdown complete');
1750
- process.exit(0);
1751
1830
  }
1752
1831
  process.on('SIGTERM', () => { if (!shuttingDown) {
1753
1832
  shuttingDown = true;
@@ -1757,6 +1836,14 @@ async function main() {
1757
1836
  shuttingDown = true;
1758
1837
  void gracefulShutdown('SIGINT');
1759
1838
  } });
1839
+ if (process.platform === 'win32') {
1840
+ process.on('message', (message) => {
1841
+ if (!shuttingDown && isWindowsShutdownMessage(message)) {
1842
+ shuttingDown = true;
1843
+ void gracefulShutdown('WINMSG');
1844
+ }
1845
+ });
1846
+ }
1760
1847
  process.on('unhandledRejection', (reason) => {
1761
1848
  console.error('unhandledRejection:', reason);
1762
1849
  });
package/dist/session.d.ts CHANGED
@@ -8,8 +8,14 @@ import { TmuxManager } from './tmux.js';
8
8
  import { type ParsedEntry } from './transcript.js';
9
9
  import { type UIState } from './terminal-parser.js';
10
10
  import type { Config } from './config.js';
11
- import { type PermissionPolicy } from './validation.js';
11
+ import { type PermissionPolicy, type PermissionProfile } from './validation.js';
12
12
  import { type PermissionDecision } from './permission-request-manager.js';
13
+ /**
14
+ * Canonical runtime metadata for an Aegis-managed Claude Code session.
15
+ *
16
+ * This structure is persisted to disk and reused by the REST API, SSE layer,
17
+ * monitoring loop, and session recovery logic.
18
+ */
13
19
  export interface SessionInfo {
14
20
  id: string;
15
21
  windowId: string;
@@ -40,8 +46,10 @@ export interface SessionInfo {
40
46
  parentId?: string;
41
47
  children?: string[];
42
48
  permissionPolicy?: PermissionPolicy;
49
+ permissionProfile?: PermissionProfile;
43
50
  prd?: string;
44
51
  }
52
+ /** Persisted session store keyed by Aegis session ID. */
45
53
  export interface SessionState {
46
54
  sessions: Record<string, SessionInfo>;
47
55
  }
@@ -56,6 +64,10 @@ export interface SessionState {
56
64
  export declare function detectApprovalMethod(paneText: string): 'numbered' | 'yes';
57
65
  /** Resolves a pending PermissionRequest hook with a decision. */
58
66
  export type { PermissionDecision };
67
+ /**
68
+ * Coordinates session lifecycle, persistence, transcript discovery, and
69
+ * interactive approval/question flows for all managed Claude Code sessions.
70
+ */
59
71
  export declare class SessionManager {
60
72
  private tmux;
61
73
  private config;
package/dist/session.js CHANGED
@@ -52,6 +52,10 @@ export function detectApprovalMethod(paneText) {
52
52
  }
53
53
  return 'yes';
54
54
  }
55
+ /**
56
+ * Coordinates session lifecycle, persistence, transcript discovery, and
57
+ * interactive approval/question flows for all managed Claude Code sessions.
58
+ */
55
59
  export class SessionManager {
56
60
  tmux;
57
61
  config;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * shutdown-utils.ts — reusable shutdown helpers for server signal handling.
3
+ */
4
+ export declare function parseShutdownTimeoutMs(rawValue: string | undefined, fallbackMs?: number): number;
5
+ export declare function isWindowsShutdownMessage(message: unknown): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * shutdown-utils.ts — reusable shutdown helpers for server signal handling.
3
+ */
4
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 15_000;
5
+ export function parseShutdownTimeoutMs(rawValue, fallbackMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
6
+ const parsed = Number(rawValue);
7
+ if (!Number.isFinite(parsed) || parsed < 1_000)
8
+ return fallbackMs;
9
+ return Math.floor(parsed);
10
+ }
11
+ export function isWindowsShutdownMessage(message) {
12
+ if (typeof message === 'string') {
13
+ const normalized = message.trim().toLowerCase();
14
+ return normalized === 'shutdown' || normalized === 'graceful-shutdown';
15
+ }
16
+ if (typeof message === 'object' && message !== null && 'type' in message) {
17
+ const typeValue = message.type;
18
+ if (typeof typeValue === 'string') {
19
+ const normalized = typeValue.trim().toLowerCase();
20
+ return normalized === 'shutdown' || normalized === 'graceful-shutdown';
21
+ }
22
+ }
23
+ return false;
24
+ }
package/dist/startup.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import Fastify from 'fastify';
2
2
  export declare function writePidFile(stateDir: string): string;
3
3
  export declare function removePidFile(pidFilePath: string): void;
4
+ /** Read the parent PID for a Linux process from /proc. */
4
5
  export declare function readPpid(pid: number): number;
5
6
  export declare function listenWithRetry(app: ReturnType<typeof Fastify>, port: number, host: string, stateDir: string, maxRetries?: number): Promise<void>;
package/dist/startup.js CHANGED
@@ -41,6 +41,7 @@ function pidExists(pid) {
41
41
  return false;
42
42
  }
43
43
  }
44
+ /** Read the parent PID for a Linux process from /proc. */
44
45
  export function readPpid(pid) {
45
46
  const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
46
47
  const match = status.match(/^PPid:\s+(\d+)/m);
package/dist/tmux.d.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Wraps tmux CLI commands to manage windows inside a named session.
5
5
  * Port of CCBot's tmux_manager.py to TypeScript.
6
6
  */
7
+ /** Build the platform-specific launch wrapper that clears inherited tmux vars. */
8
+ export declare function buildClaudeLaunchCommand(baseCommand: string, platform?: NodeJS.Platform): string;
7
9
  /** Thrown when a tmux command exceeds its timeout. */
8
10
  export declare class TmuxTimeoutError extends Error {
9
11
  constructor(args: string[], timeoutMs: number);
@@ -89,10 +91,14 @@ export declare class TmuxManager {
89
91
  * Values never appear in terminal scrollback or capture-pane output.
90
92
  */
91
93
  private setEnvSecure;
94
+ /** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
95
+ private setEnvSecureWin32;
92
96
  /** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
93
97
  * sendKeys, safe to call from inside a serialize() callback without deadlocking.
94
98
  * Identical logic otherwise. */
95
99
  private setEnvSecureDirect;
100
+ /** #909: Direct Windows variant that avoids serialize() re-entry. */
101
+ private setEnvSecureDirectWin32;
96
102
  /** P1 fix: Check if a window exists. Returns true if window is in the session.
97
103
  * #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
98
104
  windowExists(windowId: string): Promise<boolean>;
package/dist/tmux.js CHANGED
@@ -16,6 +16,16 @@ import { computeProjectHash } from './path-utils.js';
16
16
  function shellEscape(s) {
17
17
  return `'${s.replace(/'/g, "'\\''")}'`;
18
18
  }
19
+ function powerShellSingleQuote(value) {
20
+ return `'${value.replace(/'/g, "''")}'`;
21
+ }
22
+ /** Build the platform-specific launch wrapper that clears inherited tmux vars. */
23
+ export function buildClaudeLaunchCommand(baseCommand, platform = process.platform) {
24
+ if (platform === 'win32') {
25
+ return `Remove-Item Env:TMUX -ErrorAction SilentlyContinue; Remove-Item Env:TMUX_PANE -ErrorAction SilentlyContinue; ${baseCommand}`;
26
+ }
27
+ return `unset TMUX TMUX_PANE && exec ${baseCommand}`;
28
+ }
19
29
  /** Validate that an env var key contains only safe characters (Issue #630: uppercase only, aligned with session.ts). */
20
30
  const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
21
31
  const execFileAsync = promisify(execFile);
@@ -309,13 +319,9 @@ export class TmuxManager {
309
319
  if (existsSync(settingsPath)) {
310
320
  cmd += ` --settings ${shellEscape(settingsPath)}`;
311
321
  }
312
- // Issue #68: Unset $TMUX and $TMUX_PANE before launching Claude Code.
313
- // If Aegis itself runs inside tmux, CC inherits these vars and:
314
- // - Teammate spawns attempt split-pane in Aegis session (not isolated)
315
- // - Color capabilities reduced to 256
316
- // - Clipboard passthrough via tmux load-buffer instead of OSC 52
317
- // Prefixing with 'unset' ensures CC gets a clean environment.
318
- cmd = `unset TMUX TMUX_PANE && exec ${cmd}`;
322
+ // Issue #68 / #909: Clear inherited tmux vars before launching CC.
323
+ // Linux/macOS uses `unset`; Windows uses PowerShell env removal.
324
+ cmd = buildClaudeLaunchCommand(cmd);
319
325
  // Send the command to start Claude
320
326
  await this.sendKeys(windowId, cmd, true);
321
327
  // Issue #7: Verify Claude process started by checking pane command.
@@ -393,6 +399,10 @@ export class TmuxManager {
393
399
  * Values never appear in terminal scrollback or capture-pane output.
394
400
  */
395
401
  async setEnvSecure(windowId, env) {
402
+ if (process.platform === 'win32') {
403
+ await this.setEnvSecureWin32(windowId, env);
404
+ return;
405
+ }
396
406
  const fs = await import('node:fs/promises');
397
407
  const path = await import('node:path');
398
408
  // Validate env var keys before interpolation
@@ -428,10 +438,44 @@ export class TmuxManager {
428
438
  }
429
439
  catch { /* already deleted by shell */ }
430
440
  }
441
+ /** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
442
+ async setEnvSecureWin32(windowId, env) {
443
+ const fs = await import('node:fs/promises');
444
+ const path = await import('node:path');
445
+ for (const key of Object.keys(env)) {
446
+ if (!ENV_KEY_RE.test(key)) {
447
+ throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
448
+ }
449
+ }
450
+ const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
451
+ const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
452
+ await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
453
+ for (const [key, val] of Object.entries(env)) {
454
+ await this.tmux('set-environment', '-t', this.sessionName, key, val);
455
+ }
456
+ const psPath = powerShellSingleQuote(tmpFile);
457
+ const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
458
+ await this.sendKeys(windowId, cmd, true);
459
+ await this.pollUntil(async () => { try {
460
+ await stat(tmpFile);
461
+ return false;
462
+ }
463
+ catch {
464
+ return true;
465
+ } }, 50, 750);
466
+ try {
467
+ await fs.unlink(tmpFile);
468
+ }
469
+ catch { /* already deleted by shell */ }
470
+ }
431
471
  /** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
432
472
  * sendKeys, safe to call from inside a serialize() callback without deadlocking.
433
473
  * Identical logic otherwise. */
434
474
  async setEnvSecureDirect(windowId, env) {
475
+ if (process.platform === 'win32') {
476
+ await this.setEnvSecureDirectWin32(windowId, env);
477
+ return;
478
+ }
435
479
  const fs = await import('node:fs/promises');
436
480
  const path = await import('node:path');
437
481
  for (const key of Object.keys(env)) {
@@ -460,6 +504,36 @@ export class TmuxManager {
460
504
  }
461
505
  catch { /* already deleted by shell */ }
462
506
  }
507
+ /** #909: Direct Windows variant that avoids serialize() re-entry. */
508
+ async setEnvSecureDirectWin32(windowId, env) {
509
+ const fs = await import('node:fs/promises');
510
+ const path = await import('node:path');
511
+ for (const key of Object.keys(env)) {
512
+ if (!ENV_KEY_RE.test(key)) {
513
+ throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
514
+ }
515
+ }
516
+ const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
517
+ const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
518
+ await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
519
+ for (const [key, val] of Object.entries(env)) {
520
+ await this.tmuxInternal('set-environment', '-t', this.sessionName, key, val);
521
+ }
522
+ const psPath = powerShellSingleQuote(tmpFile);
523
+ const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
524
+ await this.sendKeysDirectInternal(windowId, cmd, true);
525
+ await this.pollUntil(async () => { try {
526
+ await stat(tmpFile);
527
+ return false;
528
+ }
529
+ catch {
530
+ return true;
531
+ } }, 50, 750);
532
+ try {
533
+ await fs.unlink(tmpFile);
534
+ }
535
+ catch { /* already deleted by shell */ }
536
+ }
463
537
  /** P1 fix: Check if a window exists. Returns true if window is in the session.
464
538
  * #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
465
539
  async windowExists(windowId) {
@@ -141,6 +141,48 @@ export declare const permissionRuleSchema: z.ZodObject<{
141
141
  commandPattern: z.ZodOptional<z.ZodString>;
142
142
  }, z.core.$strip>;
143
143
  export type PermissionPolicy = z.infer<typeof permissionRuleSchema>[];
144
+ /** Issue #742: richer per-session permission profile. */
145
+ export declare const permissionConstraintSchema: z.ZodObject<{
146
+ readOnly: z.ZodOptional<z.ZodBoolean>;
147
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
148
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
149
+ }, z.core.$strict>;
150
+ export declare const permissionProfileRuleSchema: z.ZodObject<{
151
+ tool: z.ZodString;
152
+ behavior: z.ZodEnum<{
153
+ allow: "allow";
154
+ deny: "deny";
155
+ ask: "ask";
156
+ }>;
157
+ pattern: z.ZodOptional<z.ZodString>;
158
+ constraints: z.ZodOptional<z.ZodObject<{
159
+ readOnly: z.ZodOptional<z.ZodBoolean>;
160
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
161
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
162
+ }, z.core.$strict>>;
163
+ }, z.core.$strict>;
164
+ export declare const permissionProfileSchema: z.ZodObject<{
165
+ defaultBehavior: z.ZodEnum<{
166
+ allow: "allow";
167
+ deny: "deny";
168
+ ask: "ask";
169
+ }>;
170
+ rules: z.ZodArray<z.ZodObject<{
171
+ tool: z.ZodString;
172
+ behavior: z.ZodEnum<{
173
+ allow: "allow";
174
+ deny: "deny";
175
+ ask: "ask";
176
+ }>;
177
+ pattern: z.ZodOptional<z.ZodString>;
178
+ constraints: z.ZodOptional<z.ZodObject<{
179
+ readOnly: z.ZodOptional<z.ZodBoolean>;
180
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
182
+ }, z.core.$strict>>;
183
+ }, z.core.$strict>>;
184
+ }, z.core.$strict>;
185
+ export type PermissionProfile = z.infer<typeof permissionProfileSchema>;
144
186
  /** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
145
187
  export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
146
188
  id: z.ZodString;
@@ -206,6 +248,27 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
206
248
  toolName: z.ZodOptional<z.ZodString>;
207
249
  commandPattern: z.ZodOptional<z.ZodString>;
208
250
  }, z.core.$strip>>>;
251
+ permissionProfile: z.ZodOptional<z.ZodObject<{
252
+ defaultBehavior: z.ZodEnum<{
253
+ allow: "allow";
254
+ deny: "deny";
255
+ ask: "ask";
256
+ }>;
257
+ rules: z.ZodArray<z.ZodObject<{
258
+ tool: z.ZodString;
259
+ behavior: z.ZodEnum<{
260
+ allow: "allow";
261
+ deny: "deny";
262
+ ask: "ask";
263
+ }>;
264
+ pattern: z.ZodOptional<z.ZodString>;
265
+ constraints: z.ZodOptional<z.ZodObject<{
266
+ readOnly: z.ZodOptional<z.ZodBoolean>;
267
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
268
+ maxFileSize: z.ZodOptional<z.ZodNumber>;
269
+ }, z.core.$strict>>;
270
+ }, z.core.$strict>>;
271
+ }, z.core.$strict>>;
209
272
  }, z.core.$strip>>;
210
273
  /** Schema for a single continuation pointer entry in session_map.json (Issue #900). */
211
274
  export declare const sessionMapEntrySchema: z.ZodObject<{
@@ -138,6 +138,22 @@ export const permissionRuleSchema = z.object({
138
138
  toolName: z.string().optional(),
139
139
  commandPattern: z.string().optional(),
140
140
  });
141
+ /** Issue #742: richer per-session permission profile. */
142
+ export const permissionConstraintSchema = z.object({
143
+ readOnly: z.boolean().optional(),
144
+ paths: z.array(z.string().min(1)).max(50).optional(),
145
+ maxFileSize: z.number().int().positive().max(10_000_000).optional(),
146
+ }).strict();
147
+ export const permissionProfileRuleSchema = z.object({
148
+ tool: z.string().min(1),
149
+ behavior: z.enum(['allow', 'deny', 'ask']),
150
+ pattern: z.string().optional(),
151
+ constraints: permissionConstraintSchema.optional(),
152
+ }).strict();
153
+ export const permissionProfileSchema = z.object({
154
+ defaultBehavior: z.enum(['allow', 'deny', 'ask']),
155
+ rules: z.array(permissionProfileRuleSchema).max(100),
156
+ }).strict();
141
157
  /** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
142
158
  export const persistedStateSchema = z.record(z.string(), z.object({
143
159
  id: z.string(),
@@ -173,6 +189,7 @@ export const persistedStateSchema = z.record(z.string(), z.object({
173
189
  toolName: z.string().optional(),
174
190
  commandPattern: z.string().optional(),
175
191
  })).optional(),
192
+ permissionProfile: permissionProfileSchema.optional(),
176
193
  }));
177
194
  /** Schema for a single continuation pointer entry in session_map.json (Issue #900). */
178
195
  export const sessionMapEntrySchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.2",
3
+ "version": "2.15.4",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",
@@ -27,6 +27,7 @@
27
27
  "build": "tsc && npm run build:copy-dashboard",
28
28
  "build:copy-dashboard": "node scripts/copy-dashboard.mjs",
29
29
  "build:dashboard": "cd dashboard && npm ci && npm run build",
30
+ "docs": "typedoc",
30
31
  "start": "node dist/cli.js",
31
32
  "dev": "tsc && node dist/cli.js",
32
33
  "prepublishOnly": "npm run build:dashboard && npm run build",
@@ -72,6 +73,7 @@
72
73
  "@types/ws": "^8.18.1",
73
74
  "lockfile-lint": "5.0.0",
74
75
  "ts-morph": "^27.0.2",
76
+ "typedoc": "^0.28.18",
75
77
  "typescript": "^6.0.2",
76
78
  "vitest": "^4.1.2"
77
79
  }