aegis-bridge 2.15.3 → 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/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
@@ -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/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);
@@ -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
+ /** 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.3",
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
  }