aegis-bridge 2.15.3 → 2.15.5

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.js CHANGED
@@ -10,6 +10,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
10
  import { authStoreSchema } from './validation.js';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { dirname } from 'node:path';
13
+ import { secureFilePermissions } from './file-utils.js';
13
14
  /** Default SSE token lifetime: 60 seconds. */
14
15
  const SSE_TOKEN_TTL_MS = 60_000;
15
16
  /** Max SSE tokens per bearer token to prevent abuse. */
@@ -61,6 +62,7 @@ export class AuthManager {
61
62
  await mkdir(dir, { recursive: true });
62
63
  }
63
64
  await writeFile(this.keysFile, JSON.stringify(this.store, null, 2), { mode: 0o600 });
65
+ await secureFilePermissions(this.keysFile);
64
66
  }
65
67
  /** Create a new API key. Returns the plaintext key (only shown once). */
66
68
  async createKey(name, rateLimit = 100) {
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Formatting: HTML parse_mode with structured, clean messages.
8
8
  */
9
+ import { homedir } from 'node:os';
9
10
  import { esc, bold, code, italic, quickUpdate, taskComplete, alert as styleAlert, } from './telegram-style.js';
10
11
  /** Call Telegram Bot API with retry on 429. */
11
12
  // ── HTML Helpers ────────────────────────────────────────────────────────────
@@ -25,12 +26,22 @@ function elapsed(ms) {
25
26
  return `${h}h ${m % 60}m`;
26
27
  }
27
28
  function shortPath(path) {
29
+ const normalized = path.replace(/\\/g, '/');
28
30
  // Keep only filename or last 2 segments
29
- const parts = path.replace(/^\//, '').split('/');
31
+ const parts = normalized.replace(/^\//, '').split('/');
30
32
  if (parts.length <= 2)
31
33
  return parts.join('/');
32
34
  return '…/' + parts.slice(-2).join('/');
33
35
  }
36
+ function shortenHomePath(workDir) {
37
+ const normalized = workDir.replace(/\\/g, '/');
38
+ const home = homedir().replace(/\\/g, '/').replace(/\/+$/, '');
39
+ if (normalized === home)
40
+ return '~';
41
+ if (normalized.startsWith(`${home}/`))
42
+ return `~${normalized.slice(home.length)}`;
43
+ return normalized;
44
+ }
34
45
  /**
35
46
  * Strip Claude Code internal XML tags from assistant messages.
36
47
  * These tags (local-command-*, antml:*, etc.) are CC's internal markup
@@ -244,7 +255,7 @@ function md2html(md) {
244
255
  // ── Message Formatting ──────────────────────────────────────────────────────
245
256
  function formatSessionCreated(name, workDir, id, meta) {
246
257
  const shortId = id.slice(0, 8);
247
- const shortDir = workDir.replace(/^\/home\/[^/]+\/projects\//, '~/');
258
+ const shortDir = shortenHomePath(workDir);
248
259
  const parts = [`${bold(name)} ${code(shortDir)} ${code(shortId)}`];
249
260
  const flags = [];
250
261
  if (meta?.permissionMode && meta.permissionMode !== 'default')
package/dist/cli.js CHANGED
@@ -5,23 +5,26 @@
5
5
  * `npx aegis-bridge` or `aegis-bridge` starts the server with sensible defaults.
6
6
  * Auto-detects tmux and claude CLI, prints helpful startup message.
7
7
  */
8
- import { execSync } from 'node:child_process';
8
+ import { execFileSync } from 'node:child_process';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  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;
16
- function checkDependency(name, command) {
17
+ /** Check whether a required external dependency can be executed. */
18
+ function checkDependency(command, args) {
17
19
  try {
18
- execSync(`${command} 2>/dev/null`, { stdio: 'ignore' });
20
+ execFileSync(command, args, { stdio: 'ignore', timeout: 5000 });
19
21
  return true;
20
22
  }
21
23
  catch { /* command not found or exited non-zero */
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
@@ -184,8 +188,8 @@ async function main() {
184
188
  process.env.AEGIS_PORT = args[portIdx + 1];
185
189
  }
186
190
  // Check dependencies
187
- const hasTmux = checkDependency('tmux', 'tmux -V');
188
- const hasClaude = checkDependency('claude', 'claude --version');
191
+ const hasTmux = checkDependency('tmux', ['-V']);
192
+ const hasClaude = checkDependency('claude', ['--version']);
189
193
  if (!hasTmux) {
190
194
  console.error(`
191
195
  ❌ tmux not found.
@@ -0,0 +1,2 @@
1
+ export declare function buildWindowsIcaclsArgs(filePath: string, account: string): string[];
2
+ export declare function secureFilePermissions(filePath: string, platform?: NodeJS.Platform): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { chmod } from 'node:fs/promises';
3
+ const PERMISSIONS_TIMEOUT_MS = 5_000;
4
+ export function buildWindowsIcaclsArgs(filePath, account) {
5
+ return [filePath, '/inheritance:r', '/grant:r', `${account}:(R,W)`];
6
+ }
7
+ function runIcacls(filePath, account) {
8
+ const args = buildWindowsIcaclsArgs(filePath, account);
9
+ return new Promise((resolve, reject) => {
10
+ execFile('icacls', args, { timeout: PERMISSIONS_TIMEOUT_MS }, (error) => {
11
+ if (error) {
12
+ reject(error);
13
+ return;
14
+ }
15
+ resolve();
16
+ });
17
+ });
18
+ }
19
+ export async function secureFilePermissions(filePath, platform = process.platform) {
20
+ if (platform !== 'win32') {
21
+ await chmod(filePath, 0o600);
22
+ return;
23
+ }
24
+ const username = process.env.USERNAME;
25
+ if (!username) {
26
+ console.warn(`Windows permission hardening skipped for ${filePath}: USERNAME is not set`);
27
+ return;
28
+ }
29
+ const account = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${username}` : username;
30
+ try {
31
+ await runIcacls(filePath, account);
32
+ }
33
+ catch (error) {
34
+ const detail = error instanceof Error ? error.message : String(error);
35
+ console.warn(`Windows permission hardening failed for ${filePath}: ${detail}`);
36
+ }
37
+ }
@@ -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
@@ -19,6 +19,7 @@ import { join, resolve } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { ccSettingsSchema, containsTraversalSegment } from './validation.js';
22
+ import { secureFilePermissions } from './file-utils.js';
22
23
  function isRecord(value) {
23
24
  return typeof value === 'object' && value !== null && !Array.isArray(value);
24
25
  }
@@ -45,6 +46,23 @@ function normalizeHookBaseUrl(baseUrl) {
45
46
  return baseUrl.replace('0.0.0.0', '127.0.0.1');
46
47
  }
47
48
  }
49
+ /** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
50
+ export function buildProjectSettingsPath(workDir, platform = process.platform) {
51
+ let normalizedWorkDir = platform === 'win32'
52
+ ? workDir.replace(/\//g, '\\')
53
+ : workDir.replace(/\\/g, '/');
54
+ // On Linux, resolve() prepends CWD to Windows paths like "D:\Users\dev"
55
+ // because Linux doesn't understand Windows drive letters. Only resolve
56
+ // paths that are NOT already absolute on the target platform.
57
+ const isWinAbs = /^[A-Za-z]:\\/.test(normalizedWorkDir);
58
+ const isUnixAbs = /^\//.test(normalizedWorkDir);
59
+ const alreadyAbs = (platform === 'win32' && isWinAbs) || isUnixAbs;
60
+ if (!alreadyAbs)
61
+ normalizedWorkDir = resolve(normalizedWorkDir);
62
+ // Normalize separators to match the target platform.
63
+ const result = join(normalizedWorkDir, '.claude', 'settings.local.json');
64
+ return platform === 'win32' ? result.replace(/\//g, '\\') : result;
65
+ }
48
66
  /**
49
67
  * Validate a workDir path for use in hook settings resolution.
50
68
  * Defense-in-depth against path traversal: rejects paths containing ".." segments
@@ -145,7 +163,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
145
163
  let merged = {};
146
164
  const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
147
165
  if (safeWorkDir) {
148
- const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
166
+ const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
149
167
  if (existsSync(projectSettingsPath)) {
150
168
  try {
151
169
  const raw = await readFile(projectSettingsPath, 'utf-8');
@@ -175,6 +193,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
175
193
  await mkdir(settingsDir, { recursive: true, mode: 0o700 });
176
194
  const filePath = join(settingsDir, `hooks-${sessionId}.json`);
177
195
  await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
196
+ await secureFilePermissions(filePath);
178
197
  return filePath;
179
198
  }
180
199
  /**
@@ -210,7 +229,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
210
229
  const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
211
230
  if (!safeWorkDir)
212
231
  return;
213
- const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
232
+ const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
214
233
  if (!existsSync(projectSettingsPath))
215
234
  return;
216
235
  try {
@@ -243,6 +262,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
243
262
  }
244
263
  if (changed) {
245
264
  await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
265
+ await secureFilePermissions(projectSettingsPath);
246
266
  }
247
267
  }
248
268
  catch {
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
+ }
@@ -15,13 +15,27 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
15
15
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
16
  import { z } from 'zod';
17
17
  import { readFileSync } from 'node:fs';
18
- import { dirname, join } from 'node:path';
18
+ import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { isValidUUID } from './validation.js';
21
21
  // Read version from package.json at startup (matches cli.ts pattern)
22
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
23
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
24
24
  const VERSION = pkg.version;
25
+ function normalizeWorkDirForCompare(workDir) {
26
+ const isWindowsLikePath = /^[a-zA-Z]:[\\/]/.test(workDir) || workDir.startsWith('\\\\');
27
+ const normalizedPath = (isWindowsLikePath ? workDir : resolve(workDir))
28
+ .replace(/\\/g, '/')
29
+ .replace(/\/+$/, '');
30
+ return process.platform === 'win32' || isWindowsLikePath
31
+ ? normalizedPath.toLowerCase()
32
+ : normalizedPath;
33
+ }
34
+ function isSameOrChildWorkDir(candidate, parent) {
35
+ const normalizedCandidate = normalizeWorkDirForCompare(candidate);
36
+ const normalizedParent = normalizeWorkDirForCompare(parent);
37
+ return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}/`);
38
+ }
25
39
  // ── Aegis REST client ───────────────────────────────────────────────
26
40
  export class AegisClient {
27
41
  baseUrl;
@@ -65,7 +79,7 @@ export class AegisClient {
65
79
  sessions = sessions.filter((s) => s.status === filter.status);
66
80
  }
67
81
  if (filter?.workDir) {
68
- sessions = sessions.filter((s) => s.workDir === filter.workDir || s.workDir?.startsWith(filter.workDir + '/'));
82
+ sessions = sessions.filter((s) => isSameOrChildWorkDir(s.workDir, filter.workDir));
69
83
  }
70
84
  return sessions;
71
85
  }
@@ -0,0 +1,4 @@
1
+ export declare function buildWindowsFindPidOnPortScript(port: number): string;
2
+ export declare function buildWindowsReadParentPidScript(pid: number): string;
3
+ export declare function findPidOnPort(port: number): Promise<number[]>;
4
+ export declare function readParentPid(pid: number): Promise<number | null>;
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ const SUBPROCESS_TIMEOUT_MS = 5_000;
4
+ function runCommand(command, args) {
5
+ return new Promise((resolve, reject) => {
6
+ execFile(command, args, { encoding: 'utf-8', timeout: SUBPROCESS_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout) => {
7
+ if (error) {
8
+ reject(error);
9
+ return;
10
+ }
11
+ resolve(stdout);
12
+ });
13
+ });
14
+ }
15
+ function parsePidLines(output) {
16
+ return [...new Set(output
17
+ .trim()
18
+ .split(/\r?\n/)
19
+ .map(line => parseInt(line.trim(), 10))
20
+ .filter(pid => Number.isInteger(pid) && pid > 0))];
21
+ }
22
+ export function buildWindowsFindPidOnPortScript(port) {
23
+ return [
24
+ `Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue`,
25
+ 'Select-Object -ExpandProperty OwningProcess -Unique',
26
+ ].join(' | ');
27
+ }
28
+ export function buildWindowsReadParentPidScript(pid) {
29
+ return [
30
+ `Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
31
+ 'Select-Object -ExpandProperty ParentProcessId',
32
+ ].join(' | ');
33
+ }
34
+ export async function findPidOnPort(port) {
35
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
36
+ return [];
37
+ try {
38
+ if (process.platform === 'win32') {
39
+ const script = buildWindowsFindPidOnPortScript(port);
40
+ const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
41
+ return parsePidLines(stdout);
42
+ }
43
+ const stdout = await runCommand('lsof', ['-ti', `tcp:${port}`]);
44
+ return parsePidLines(stdout);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ }
50
+ export async function readParentPid(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0)
52
+ return null;
53
+ try {
54
+ if (process.platform === 'win32') {
55
+ const script = buildWindowsReadParentPidScript(pid);
56
+ const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
57
+ const parent = parseInt(stdout.trim(), 10);
58
+ return Number.isInteger(parent) && parent > 0 ? parent : null;
59
+ }
60
+ if (process.platform !== 'linux') {
61
+ return null;
62
+ }
63
+ const status = await readFile(`/proc/${pid}/status`, 'utf-8');
64
+ const match = status.match(/^PPid:\s+(\d+)/m);
65
+ if (!match)
66
+ return null;
67
+ const parent = parseInt(match[1], 10);
68
+ return Number.isInteger(parent) && parent > 0 ? parent : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
package/dist/server.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * Notification channels (Telegram, webhooks, etc.) are pluggable —
8
8
  * the server doesn't know which channels are active.
9
9
  */
10
- export { readPpid } from './startup.js';
10
+ export { readParentPid as readPpid } from './process-utils.js';
package/dist/server.js CHANGED
@@ -49,6 +49,7 @@ import { MemoryBridge } from './memory-bridge.js';
49
49
  import { cleanupTerminatedSessionState } from './session-cleanup.js';
50
50
  import { normalizeApiErrorPayload } from './api-error-envelope.js';
51
51
  import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
52
+ import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js';
52
53
  import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
53
54
  const __filename = fileURLToPath(import.meta.url);
54
55
  const __dirname = path.dirname(__filename);
@@ -1676,7 +1677,7 @@ function registerChannels(cfg) {
1676
1677
  }
1677
1678
  }
1678
1679
  // Preserve public export used by tests and external imports.
1679
- export { readPpid } from './startup.js';
1680
+ export { readParentPid as readPpid } from './process-utils.js';
1680
1681
  async function main() {
1681
1682
  // Load configuration
1682
1683
  config = await loadConfig();
@@ -1765,56 +1766,67 @@ async function main() {
1765
1766
  // Issue #361: Graceful shutdown handler
1766
1767
  // Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
1767
1768
  let shuttingDown = false;
1769
+ const shutdownTimeoutMs = parseShutdownTimeoutMs(process.env.AEGIS_SHUTDOWN_TIMEOUT_MS);
1768
1770
  async function gracefulShutdown(signal) {
1769
1771
  console.log(`${signal} received, shutting down gracefully...`);
1770
- // 1. Stop accepting new requests
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?.();
1771
1777
  try {
1772
- await app.close();
1773
- }
1774
- catch (e) {
1775
- console.error('Error closing server:', e);
1776
- }
1777
- // 2. Stop background monitors and intervals
1778
- monitor.stop();
1779
- swarmMonitor.stop();
1780
- clearInterval(reaperInterval);
1781
- clearInterval(zombieReaperInterval);
1782
- clearInterval(metricsSaveInterval);
1783
- clearInterval(ipPruneInterval);
1784
- clearInterval(authFailPruneInterval);
1785
- clearInterval(authSweepInterval);
1786
- // Issue #569: Kill all CC sessions and tmux windows before exit
1787
- try {
1788
- await killAllSessions(sessions, tmux);
1789
- }
1790
- catch (e) {
1791
- console.error('Error killing sessions:', e);
1792
- }
1793
- // 3. Destroy channels (awaits Telegram poll loop)
1794
- try {
1795
- await channels.destroy();
1796
- }
1797
- catch (e) {
1798
- console.error('Error destroying channels:', e);
1799
- }
1800
- // 4. Save session state
1801
- try {
1802
- await sessions.save();
1803
- }
1804
- catch (e) {
1805
- console.error('Error saving sessions:', e);
1806
- }
1807
- // 5. Save metrics
1808
- try {
1809
- 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);
1810
1826
  }
1811
- catch (e) {
1812
- console.error('Error saving metrics:', e);
1827
+ finally {
1828
+ clearTimeout(forceExitTimer);
1813
1829
  }
1814
- // 6. Cleanup PID file
1815
- removePidFile(pidFilePath);
1816
- console.log('Graceful shutdown complete');
1817
- process.exit(0);
1818
1830
  }
1819
1831
  process.on('SIGTERM', () => { if (!shuttingDown) {
1820
1832
  shuttingDown = true;
@@ -1824,6 +1836,14 @@ async function main() {
1824
1836
  shuttingDown = true;
1825
1837
  void gracefulShutdown('SIGINT');
1826
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
+ }
1827
1847
  process.on('unhandledRejection', (reason) => {
1828
1848
  console.error('unhandledRejection:', reason);
1829
1849
  });
package/dist/session.d.ts CHANGED
@@ -10,6 +10,12 @@ import { type UIState } from './terminal-parser.js';
10
10
  import type { Config } from './config.js';
11
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;
@@ -43,6 +49,7 @@ export interface SessionInfo {
43
49
  permissionProfile?: PermissionProfile;
44
50
  prd?: string;
45
51
  }
52
+ /** Persisted session store keyed by Aegis session ID. */
46
53
  export interface SessionState {
47
54
  sessions: Record<string, SessionInfo>;
48
55
  }
@@ -57,6 +64,10 @@ export interface SessionState {
57
64
  export declare function detectApprovalMethod(paneText: string): 'numbered' | 'yes';
58
65
  /** Resolves a pending PermissionRequest hook with a decision. */
59
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
+ */
60
71
  export declare class SessionManager {
61
72
  private tmux;
62
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
- export declare function readPpid(pid: number): number;
4
+ /** Read parent PID with cross-platform fallback. */
5
+ export declare function readPpid(pid: number): Promise<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
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { writeFileSync, unlinkSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { execFileSync } from 'node:child_process';
4
+ import { findPidOnPort, readParentPid } from './process-utils.js';
5
5
  export function writePidFile(stateDir) {
6
6
  try {
7
7
  const pidFilePath = path.join(stateDir, 'aegis.pid');
@@ -41,21 +41,22 @@ function pidExists(pid) {
41
41
  return false;
42
42
  }
43
43
  }
44
- export function readPpid(pid) {
45
- const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
46
- const match = status.match(/^PPid:\s+(\d+)/m);
47
- if (!match)
48
- throw new Error(`no PPid line in /proc/${pid}/status`);
49
- return parseInt(match[1], 10);
44
+ /** Read parent PID with cross-platform fallback. */
45
+ export async function readPpid(pid) {
46
+ const parent = await readParentPid(pid);
47
+ if (parent === null) {
48
+ throw new Error(`no parent PID available for process ${pid}`);
49
+ }
50
+ return parent;
50
51
  }
51
- function isAncestorPid(pid) {
52
+ async function isAncestorPid(pid) {
52
53
  try {
53
54
  let current = process.ppid;
54
55
  for (let depth = 0; depth < 10 && current > 1; depth++) {
55
56
  if (current === pid)
56
57
  return true;
57
58
  try {
58
- current = readPpid(current);
59
+ current = await readPpid(current);
59
60
  }
60
61
  catch {
61
62
  break;
@@ -93,17 +94,14 @@ async function waitForPortRelease(port, maxWaitMs = 5000) {
93
94
  async function killStalePortHolder(port, stateDir) {
94
95
  await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
95
96
  try {
96
- const output = execFileSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf-8', timeout: 5000 }).trim();
97
- if (!output)
98
- return false;
99
- const pids = output.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => !Number.isNaN(n));
97
+ const pids = await findPidOnPort(port);
100
98
  if (pids.length === 0)
101
99
  return false;
102
100
  let killed = false;
103
101
  for (const pid of pids) {
104
102
  if (pid === process.pid)
105
103
  continue;
106
- if (isAncestorPid(pid)) {
104
+ if (await isAncestorPid(pid)) {
107
105
  console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
108
106
  continue;
109
107
  }
@@ -75,10 +75,13 @@ export declare class SwarmMonitor {
75
75
  private lastResult;
76
76
  private timer;
77
77
  private eventHandlers;
78
+ private windowsDisabledLogged;
78
79
  constructor(sessions: SessionManager, config?: SwarmMonitorConfig);
79
80
  /** Register an event handler for teammate lifecycle events. */
80
81
  onEvent(handler: SwarmEventHandler): void;
81
82
  private emitEvent;
83
+ private isWindowsPlatform;
84
+ private logWindowsDisabled;
82
85
  /** Start the periodic scan loop. */
83
86
  start(): void;
84
87
  /** Stop the periodic scan loop. */
@@ -28,6 +28,7 @@ export class SwarmMonitor {
28
28
  lastResult = null;
29
29
  timer = null;
30
30
  eventHandlers = [];
31
+ windowsDisabledLogged = false;
31
32
  constructor(sessions, config = DEFAULT_SWARM_CONFIG) {
32
33
  this.sessions = sessions;
33
34
  this.config = config;
@@ -46,8 +47,21 @@ export class SwarmMonitor {
46
47
  }
47
48
  }
48
49
  }
50
+ isWindowsPlatform() {
51
+ return process.platform === 'win32';
52
+ }
53
+ logWindowsDisabled() {
54
+ if (this.windowsDisabledLogged)
55
+ return;
56
+ console.info('SwarmMonitor disabled on Windows: tmux swarm sockets are not supported on this platform.');
57
+ this.windowsDisabledLogged = true;
58
+ }
49
59
  /** Start the periodic scan loop. */
50
60
  start() {
61
+ if (this.isWindowsPlatform()) {
62
+ this.logWindowsDisabled();
63
+ return;
64
+ }
51
65
  if (this.running)
52
66
  return;
53
67
  this.running = true;
@@ -70,6 +84,16 @@ export class SwarmMonitor {
70
84
  }
71
85
  /** Run a single scan and return the result. */
72
86
  async scan() {
87
+ if (this.isWindowsPlatform()) {
88
+ this.logWindowsDisabled();
89
+ this.lastResult = {
90
+ swarms: [],
91
+ totalSockets: 0,
92
+ totalTeammates: 0,
93
+ scannedAt: Date.now(),
94
+ };
95
+ return this.lastResult;
96
+ }
73
97
  try {
74
98
  const sockets = await this.discoverSwarmSockets();
75
99
  // Issue #353: Inspect sockets in parallel to avoid N×timeout accumulation.
@@ -176,6 +200,17 @@ export class SwarmMonitor {
176
200
  }
177
201
  /** Inspect a single swarm socket and return swarm info. */
178
202
  async inspectSwarmSocket(socketName) {
203
+ if (this.isWindowsPlatform()) {
204
+ const pid = this.extractPid(socketName);
205
+ return {
206
+ socketName,
207
+ pid,
208
+ parentSession: null,
209
+ teammates: [],
210
+ aggregatedStatus: 'no_teammates',
211
+ lastScannedAt: Date.now(),
212
+ };
213
+ }
179
214
  const pid = this.extractPid(socketName);
180
215
  const teammates = await this.listSwarmWindows(socketName);
181
216
  const parentSession = this.findParentSession(pid, teammates);
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
@@ -12,10 +12,21 @@ import { join } from 'node:path';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import { randomBytes } from 'node:crypto';
14
14
  import { computeProjectHash } from './path-utils.js';
15
+ import { secureFilePermissions } from './file-utils.js';
15
16
  /** Shell-escape a string by wrapping in single quotes and escaping embedded single quotes. */
16
17
  function shellEscape(s) {
17
18
  return `'${s.replace(/'/g, "'\\''")}'`;
18
19
  }
20
+ function powerShellSingleQuote(value) {
21
+ return `'${value.replace(/'/g, "''")}'`;
22
+ }
23
+ /** Build the platform-specific launch wrapper that clears inherited tmux vars. */
24
+ export function buildClaudeLaunchCommand(baseCommand, platform = process.platform) {
25
+ if (platform === 'win32') {
26
+ return `Remove-Item Env:TMUX -ErrorAction SilentlyContinue; Remove-Item Env:TMUX_PANE -ErrorAction SilentlyContinue; ${baseCommand}`;
27
+ }
28
+ return `unset TMUX TMUX_PANE && exec ${baseCommand}`;
29
+ }
19
30
  /** Validate that an env var key contains only safe characters (Issue #630: uppercase only, aligned with session.ts). */
20
31
  const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
21
32
  const execFileAsync = promisify(execFile);
@@ -309,13 +320,9 @@ export class TmuxManager {
309
320
  if (existsSync(settingsPath)) {
310
321
  cmd += ` --settings ${shellEscape(settingsPath)}`;
311
322
  }
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}`;
323
+ // Issue #68 / #909: Clear inherited tmux vars before launching CC.
324
+ // Linux/macOS uses `unset`; Windows uses PowerShell env removal.
325
+ cmd = buildClaudeLaunchCommand(cmd);
319
326
  // Send the command to start Claude
320
327
  await this.sendKeys(windowId, cmd, true);
321
328
  // Issue #7: Verify Claude process started by checking pane command.
@@ -393,6 +400,10 @@ export class TmuxManager {
393
400
  * Values never appear in terminal scrollback or capture-pane output.
394
401
  */
395
402
  async setEnvSecure(windowId, env) {
403
+ if (process.platform === 'win32') {
404
+ await this.setEnvSecureWin32(windowId, env);
405
+ return;
406
+ }
396
407
  const fs = await import('node:fs/promises');
397
408
  const path = await import('node:path');
398
409
  // Validate env var keys before interpolation
@@ -410,6 +421,7 @@ export class TmuxManager {
410
421
  return `export ${key}='${escaped}'`;
411
422
  });
412
423
  await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
424
+ await secureFilePermissions(tmpFile);
413
425
  // Source the file and delete it — all in one command so the values
414
426
  // appear in the process environment but not in the terminal history.
415
427
  // The 'source' line is visible but only shows the temp file path, not the values.
@@ -428,10 +440,44 @@ export class TmuxManager {
428
440
  }
429
441
  catch { /* already deleted by shell */ }
430
442
  }
443
+ /** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
444
+ async setEnvSecureWin32(windowId, env) {
445
+ const fs = await import('node:fs/promises');
446
+ const path = await import('node:path');
447
+ for (const key of Object.keys(env)) {
448
+ if (!ENV_KEY_RE.test(key)) {
449
+ throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
450
+ }
451
+ }
452
+ const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
453
+ const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
454
+ await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
455
+ for (const [key, val] of Object.entries(env)) {
456
+ await this.tmux('set-environment', '-t', this.sessionName, key, val);
457
+ }
458
+ const psPath = powerShellSingleQuote(tmpFile);
459
+ const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
460
+ await this.sendKeys(windowId, cmd, true);
461
+ await this.pollUntil(async () => { try {
462
+ await stat(tmpFile);
463
+ return false;
464
+ }
465
+ catch {
466
+ return true;
467
+ } }, 50, 750);
468
+ try {
469
+ await fs.unlink(tmpFile);
470
+ }
471
+ catch { /* already deleted by shell */ }
472
+ }
431
473
  /** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
432
474
  * sendKeys, safe to call from inside a serialize() callback without deadlocking.
433
475
  * Identical logic otherwise. */
434
476
  async setEnvSecureDirect(windowId, env) {
477
+ if (process.platform === 'win32') {
478
+ await this.setEnvSecureDirectWin32(windowId, env);
479
+ return;
480
+ }
435
481
  const fs = await import('node:fs/promises');
436
482
  const path = await import('node:path');
437
483
  for (const key of Object.keys(env)) {
@@ -445,6 +491,7 @@ export class TmuxManager {
445
491
  return `export ${key}='${escaped}'`;
446
492
  });
447
493
  await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
494
+ await secureFilePermissions(tmpFile);
448
495
  // Use sendKeysDirectInternal to avoid re-entering serialize()
449
496
  const cmd = `source ${shellEscape(tmpFile)} && rm -f ${shellEscape(tmpFile)}`;
450
497
  await this.sendKeysDirectInternal(windowId, cmd, true);
@@ -460,6 +507,36 @@ export class TmuxManager {
460
507
  }
461
508
  catch { /* already deleted by shell */ }
462
509
  }
510
+ /** #909: Direct Windows variant that avoids serialize() re-entry. */
511
+ async setEnvSecureDirectWin32(windowId, env) {
512
+ const fs = await import('node:fs/promises');
513
+ const path = await import('node:path');
514
+ for (const key of Object.keys(env)) {
515
+ if (!ENV_KEY_RE.test(key)) {
516
+ throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
517
+ }
518
+ }
519
+ const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
520
+ const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
521
+ await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
522
+ for (const [key, val] of Object.entries(env)) {
523
+ await this.tmuxInternal('set-environment', '-t', this.sessionName, key, val);
524
+ }
525
+ const psPath = powerShellSingleQuote(tmpFile);
526
+ const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
527
+ await this.sendKeysDirectInternal(windowId, cmd, true);
528
+ await this.pollUntil(async () => { try {
529
+ await stat(tmpFile);
530
+ return false;
531
+ }
532
+ catch {
533
+ return true;
534
+ } }, 50, 750);
535
+ try {
536
+ await fs.unlink(tmpFile);
537
+ }
538
+ catch { /* already deleted by shell */ }
539
+ }
463
540
  /** P1 fix: Check if a window exists. Returns true if window is in the session.
464
541
  * #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
465
542
  async windowExists(windowId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.3",
3
+ "version": "2.15.5",
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,10 +27,12 @@
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",
33
34
  "test": "vitest run",
35
+ "test:smoke": "node scripts/uat-smoke.mjs",
34
36
  "test:fault-harness": "vitest run src/__tests__/fault-injection-harness-901.test.ts"
35
37
  },
36
38
  "keywords": [
@@ -72,6 +74,7 @@
72
74
  "@types/ws": "^8.18.1",
73
75
  "lockfile-lint": "5.0.0",
74
76
  "ts-morph": "^27.0.2",
77
+ "typedoc": "^0.28.18",
75
78
  "typescript": "^6.0.2",
76
79
  "vitest": "^4.1.2"
77
80
  }