aegis-bridge 2.5.2 → 2.5.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/tmux.js CHANGED
@@ -217,6 +217,15 @@ export class TmuxManager {
217
217
  if (lastError) {
218
218
  throw new Error(`Failed to create tmux window after ${MAX_RETRIES} attempts: ${name} — ${lastError.message}`);
219
219
  }
220
+ // #837: Set env vars INSIDE the serialize block to prevent race.
221
+ // Previously setEnvSecure ran after serialize() returned, so concurrent
222
+ // createWindow calls could interleave send-keys between window creation
223
+ // and env injection, corrupting the environment.
224
+ // Uses setEnvSecureDirect (sendKeysDirectInternal) to avoid re-entering
225
+ // serialize from within an active serialize callback.
226
+ if (opts.env && Object.keys(opts.env).length > 0) {
227
+ await this.setEnvSecureDirect(id, opts.env);
228
+ }
220
229
  return { windowId: id, windowName: name };
221
230
  });
222
231
  windowId = creationResult.windowId;
@@ -225,31 +234,6 @@ export class TmuxManager {
225
234
  finally {
226
235
  this._creatingCount--;
227
236
  }
228
- // Set env vars if provided.
229
- // Issue #89 L29: Recommended Claude Code environment variables.
230
- // These are merged in SessionManager.createSession() from config.defaultSessionEnv
231
- // and per-session opts.env (user vars override defaults).
232
- //
233
- // Key env vars CC recognizes:
234
- // ANTHROPIC_API_KEY — Required for Anthropic API direct access
235
- // ANTHROPIC_BASE_URL — Custom API endpoint (e.g. proxy/bedrock)
236
- // ANTHROPIC_MODEL — Override default model selection
237
- // DISABLE_AUTOUPDATER=1 — Suppress CC's auto-update check
238
- // CLAUDE_CODE_SKIP_EULA=1 — Skip EULA prompt on first launch
239
- // CLAUDE_CODE_USE_BEDROCK=1 — Use AWS Bedrock backend
240
- // MCP_TIMEOUT_MS — Timeout for MCP server communication
241
- // NO_COLOR=1 — Disable color output (useful for parsing)
242
- // HOME — User home directory (usually inherited)
243
- // PATH — Binary search path (usually inherited)
244
- //
245
- // Note: The --settings flag already handles proxy config (z.ai) and
246
- // workspace trust, so ANTHROPIC_BASE_URL via env is a fallback.
247
- //
248
- // Issue #23: Use temp file + source instead of send-keys export to prevent
249
- // env var values (tokens, secrets) from appearing in tmux pane history.
250
- if (opts.env && Object.keys(opts.env).length > 0) {
251
- await this.setEnvSecure(windowId, opts.env);
252
- }
253
237
  // Ensure Claude starts a fresh session.
254
238
  // Two-layer defense against CC auto-resuming stale sessions:
255
239
  //
@@ -417,6 +401,38 @@ export class TmuxManager {
417
401
  }
418
402
  catch { /* already deleted by shell */ }
419
403
  }
404
+ /** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
405
+ * sendKeys, safe to call from inside a serialize() callback without deadlocking.
406
+ * Identical logic otherwise. */
407
+ async setEnvSecureDirect(windowId, env) {
408
+ const fs = await import('node:fs/promises');
409
+ const path = await import('node:path');
410
+ for (const key of Object.keys(env)) {
411
+ if (!ENV_KEY_RE.test(key)) {
412
+ throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
413
+ }
414
+ }
415
+ const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}`);
416
+ const lines = Object.entries(env).map(([key, val]) => {
417
+ const escaped = val.replace(/'/g, "'\\''");
418
+ return `export ${key}='${escaped}'`;
419
+ });
420
+ await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
421
+ // Use sendKeysDirectInternal to avoid re-entering serialize()
422
+ const cmd = `source ${shellEscape(tmpFile)} && rm -f ${shellEscape(tmpFile)}`;
423
+ await this.sendKeysDirectInternal(windowId, cmd, true);
424
+ await this.pollUntil(async () => { try {
425
+ await stat(tmpFile);
426
+ return false;
427
+ }
428
+ catch {
429
+ return true;
430
+ } }, 50, 500);
431
+ try {
432
+ await fs.unlink(tmpFile);
433
+ }
434
+ catch { /* already deleted by shell */ }
435
+ }
420
436
  /** P1 fix: Check if a window exists. Returns true if window is in the session.
421
437
  * #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
422
438
  async windowExists(windowId) {
@@ -620,19 +636,13 @@ export class TmuxManager {
620
636
  const raw = await this.tmux('capture-pane', '-t', target, '-p');
621
637
  return raw.replace(/\x1bP[\s\S]*?\x1b\\/g, '');
622
638
  }
623
- /** Capture pane content WITHOUT going through the serialize queue.
624
- * Used for critical-path operations (e.g., sendInitialPrompt) that should
625
- * not be delayed by monitor polls. The queue is for preventing race conditions
626
- * in monitor/concurrent reads, but sendInitialPrompt is the ONLY writer at
627
- * session creation time.
628
- * #403: During window creation (_creatingCount > 0), queues behind serialize
629
- * to avoid racing with the creation sequence.
639
+ /** Capture pane content through the serialize queue.
640
+ * #824: Always serialize to prevent race conditions with concurrent reads
641
+ * from monitor polls and ! command mode. The previous _creatingCount guard
642
+ * only queued during window creation, leaving a race window at other times.
630
643
  */
631
644
  async capturePaneDirect(windowId) {
632
- if (this._creatingCount > 0) {
633
- return this.serialize(() => this.capturePaneDirectInternal(windowId));
634
- }
635
- return this.capturePaneDirectInternal(windowId);
645
+ return this.serialize(() => this.capturePaneDirectInternal(windowId));
636
646
  }
637
647
  async capturePaneDirectInternal(windowId) {
638
648
  const target = `${this.sessionName}:${windowId}`;
@@ -647,6 +657,11 @@ export class TmuxManager {
647
657
  if (e && typeof e === 'object' && 'killed' in e && e.killed) {
648
658
  throw new TmuxTimeoutError(['capture-pane', '-t', target, '-p'], TMUX_DEFAULT_TIMEOUT_MS);
649
659
  }
660
+ // Issue #845: Handle tmux server crash (ECONNREFUSED) gracefully.
661
+ // Return empty string instead of crashing the request handler.
662
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ECONNREFUSED') {
663
+ return '';
664
+ }
650
665
  throw e;
651
666
  }
652
667
  }
@@ -12,15 +12,20 @@ import { readdir } from 'node:fs/promises';
12
12
  import { sessionsIndexSchema } from './validation.js';
13
13
  /** Default Claude projects directory */
14
14
  const DEFAULT_CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
15
- /** Parse a single JSONL line. Returns null if not parseable. */
15
+ /** Parse a single JSONL line. Returns null if not parseable.
16
+ * Issue #823: Logs at error level when a non-empty line is dropped. */
16
17
  function parseLine(line) {
17
18
  const trimmed = line.trim();
18
- if (!trimmed || trimmed[0] !== '{')
19
+ if (!trimmed || trimmed[0] !== '{') {
20
+ // Lines that are blank or don't start with '{' are expected (separators, comments)
19
21
  return null;
22
+ }
20
23
  try {
21
24
  return JSON.parse(trimmed);
22
25
  }
23
- catch { /* malformed JSON — skip line */
26
+ catch (err) {
27
+ // Issue #823: Log malformed JSON lines so data loss is visible
28
+ console.error(`parseLine: dropping malformed JSONL line (${err.message}): ${trimmed.slice(0, 200)}`);
24
29
  return null;
25
30
  }
26
31
  }
@@ -184,11 +189,10 @@ export async function readNewEntries(filePath, fromOffset) {
184
189
  break;
185
190
  }
186
191
  }
187
- // Issue #579: If no newline found and we didn't scan from byte 0,
188
- // fall back to offset 0 to avoid starting mid-line.
189
- if (!foundNewline && scanStart > 0) {
190
- effectiveOffset = 0;
191
- }
192
+ // Issue #836: If no newline found in the scan window, the line is
193
+ // longer than scanSize. Keep effectiveOffset as-is (fromOffset)
194
+ // starting mid-line is handled by JSON.parse rejecting partial lines.
195
+ // Never fall back to offset 0, which causes O(n) re-reads.
192
196
  }
193
197
  const slicedContent = await new Promise((resolve, reject) => {
194
198
  const chunks = [];
@@ -128,10 +128,14 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
128
128
  byteOffset: z.ZodNumber;
129
129
  monitorOffset: z.ZodNumber;
130
130
  status: z.ZodEnum<{
131
+ error: "error";
131
132
  unknown: "unknown";
132
133
  permission_prompt: "permission_prompt";
133
134
  idle: "idle";
134
135
  working: "working";
136
+ compacting: "compacting";
137
+ context_warning: "context_warning";
138
+ waiting_for_input: "waiting_for_input";
135
139
  bash_approval: "bash_approval";
136
140
  plan_mode: "plan_mode";
137
141
  ask_question: "ask_question";
@@ -121,8 +121,9 @@ export function isValidUUID(id) {
121
121
  }
122
122
  // ── JSON.parse boundary validation (Issue #410) ──────────────────
123
123
  const UIStateEnum = z.enum([
124
- 'idle', 'working', 'permission_prompt', 'bash_approval',
125
- 'plan_mode', 'ask_question', 'settings', 'unknown',
124
+ 'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
125
+ 'permission_prompt', 'bash_approval', 'plan_mode', 'ask_question',
126
+ 'settings', 'error', 'unknown',
126
127
  ]);
127
128
  /** Schema for persisted SessionState (sessions: { [id]: SessionInfo }). */
128
129
  export const persistedStateSchema = z.record(z.string(), z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.5.2",
3
+ "version": "2.5.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",