• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.32
/src/modules/cli-process.js
1
/**
2
 * CLIProcess — Claude CLI subprocess manager with dual-mode support.
3
 *
4
 * Spawns the `claude` binary directly in headless
5
 * mode.  Supports two lifecycle modes controlled by the `streaming` option:
6
 *
7
 * - **Short-lived** (default, `streaming: false`):  Each `send()` spawns a
8
 *   fresh `claude -p <prompt>` process that exits after returning its result.
9
 *   No token accumulation, clean abort via process kill.
10
 *
11
 * - **Long-lived** (`streaming: true`):  A single subprocess is kept alive
12
 *   across multiple `send()` calls using NDJSON stream-json I/O.  Tokens are
13
 *   tracked and the process is transparently recycled when a configurable
14
 *   threshold is exceeded.
15
 */
16

17
import { spawn } from 'node:child_process';
18
import { existsSync } from 'node:fs';
19
import { dirname, resolve } from 'node:path';
20
import { createInterface } from 'node:readline';
21
import { fileURLToPath } from 'node:url';
22
import { debug, info, error as logError, warn } from '../logger.js';
23
import { CLIProcessError } from '../utils/errors.js';
24

25
// Resolve the `claude` binary path from node_modules/.bin (may not be in PATH in Docker).
26
const __dirname = dirname(fileURLToPath(import.meta.url));
35✔
27
const LOCAL_BIN = resolve(__dirname, '..', '..', 'node_modules', '.bin', 'claude');
35✔
28
const CLAUDE_BIN = existsSync(LOCAL_BIN) ? LOCAL_BIN : 'claude';
35✔
29

30
export { CLIProcessError };
31

32
// ── AsyncQueue ───────────────────────────────────────────────────────────────
33

34
/**
35
 * Push-based async iterable for buffering stdin writes in long-lived mode.
36
 */
37
export class AsyncQueue {
38
  /** @type {Array<*>} */
39
  #queue = [];
5✔
40
  /** @type {Array<Function>} */
41
  #waiters = [];
5✔
42
  #closed = false;
5✔
43

44
  push(value) {
45
    if (this.#closed) return;
5✔
46
    if (this.#waiters.length > 0) {
4✔
47
      const resolve = this.#waiters.shift();
2✔
48
      resolve({ value, done: false });
2✔
49
    } else {
50
      this.#queue.push(value);
2✔
51
    }
52
  }
53

54
  close() {
55
    this.#closed = true;
5✔
56
    for (const resolve of this.#waiters) {
5✔
57
      resolve({ value: undefined, done: true });
1✔
58
    }
59
    this.#waiters.length = 0;
5✔
60
  }
61

62
  [Symbol.asyncIterator]() {
63
    return {
5✔
64
      next: () => {
65
        if (this.#queue.length > 0) {
9✔
66
          return Promise.resolve({ value: this.#queue.shift(), done: false });
2✔
67
        }
68
        if (this.#closed) {
7✔
69
          return Promise.resolve({ value: undefined, done: true });
4✔
70
        }
71
        return new Promise((resolve) => {
3✔
72
          this.#waiters.push(resolve);
3✔
73
        });
74
      },
75
    };
76
  }
77
}
78

79
// ── Helpers ──────────────────────────────────────────────────────────────────
80

81
const MAX_STDERR_LINES = 20;
35✔
82

83
/**
84
 * Build CLI argument array from a flags object.
85
 * @param {Object} flags
86
 * @param {boolean} longLived  Whether to include stream-json input flags.
87
 * @returns {string[]}
88
 */
89
function buildArgs(flags, longLived) {
90
  const args = ['-p'];
44✔
91

92
  // Always output NDJSON and enable verbose diagnostics
93
  args.push('--output-format', 'stream-json');
44✔
94
  args.push('--verbose');
44✔
95

96
  if (longLived) {
44✔
97
    args.push('--input-format', 'stream-json');
19✔
98
  }
99

100
  if (flags.model) {
44✔
101
    args.push('--model', flags.model);
3✔
102
  }
103

104
  if (flags.systemPromptFile) {
44✔
105
    args.push('--system-prompt-file', flags.systemPromptFile);
1✔
106
  }
107

108
  if (flags.systemPrompt) {
44!
109
    args.push('--system-prompt', flags.systemPrompt);
×
110
  }
111

112
  if (flags.appendSystemPrompt) {
44!
113
    args.push('--append-system-prompt', flags.appendSystemPrompt);
×
114
  }
115

116
  if (flags.tools !== undefined) {
44!
117
    args.push('--tools', flags.tools);
×
118
  }
119

120
  if (flags.allowedTools) {
44✔
121
    const toolList = Array.isArray(flags.allowedTools) ? flags.allowedTools : [flags.allowedTools];
1!
122
    for (const tool of toolList) {
1✔
123
      args.push('--allowedTools', tool);
2✔
124
    }
125
  }
126

127
  if (flags.permissionMode) {
44!
128
    args.push('--permission-mode', flags.permissionMode);
×
129
  } else {
130
    args.push('--permission-mode', 'bypassPermissions');
44✔
131
  }
132

133
  // SAFETY: --dangerously-skip-permissions is required for non-interactive
134
  // (headless) use. Without it, the CLI blocks waiting for a TTY-based
135
  // permission prompt that can never be answered in a subprocess context.
136
  // This is safe here because the bot controls what prompts and tools are
137
  // passed — user input is never forwarded raw to the CLI. The bot's own
138
  // permission model (Discord permissions + config.json) gates access.
139
  args.push('--dangerously-skip-permissions');
44✔
140

141
  args.push('--no-session-persistence');
44✔
142

143
  if (flags.maxBudgetUsd != null) {
44✔
144
    args.push('--max-budget-usd', String(flags.maxBudgetUsd));
1✔
145
  }
146

147
  return args;
44✔
148
}
149

150
/**
151
 * Build the subprocess environment with thinking token configuration.
152
 * @param {Object} flags
153
 * @param {string} [flags.baseUrl]  Override ANTHROPIC_BASE_URL (e.g. for claude-code-router proxy)
154
 * @param {string} [flags.apiKey]   Override ANTHROPIC_API_KEY (e.g. for provider-specific key)
155
 * @returns {Object}
156
 */
157
function buildEnv(flags) {
158
  // Security: pass only what the Claude CLI subprocess actually needs.
159
  // Never spread process.env — that would leak DISCORD_TOKEN, DATABASE_URL,
160
  // BOT_API_SECRET, SESSION_SECRET, REDIS_URL, etc. to the child process.
161
  // See: https://github.com/VolvoxLLC/volvox-bot/issues/155
162
  const env = {
44✔
163
    PATH: process.env.PATH,
164
    HOME: process.env.HOME,
165
    ...(process.env.NODE_ENV && { NODE_ENV: process.env.NODE_ENV }),
88✔
166
    ...(process.env.DISABLE_PROMPT_CACHING && {
44!
167
      DISABLE_PROMPT_CACHING: process.env.DISABLE_PROMPT_CACHING,
168
    }),
169
    MAX_THINKING_TOKENS: String(flags.thinkingTokens ?? 4096),
87✔
170
  };
171

172
  // Auth priority: explicit apiKey flag > ANTHROPIC_API_KEY env > CLAUDE_CODE_OAUTH_TOKEN env.
173
  // When flags.apiKey is provided we intentionally omit CLAUDE_CODE_OAUTH_TOKEN
174
  // to avoid conflicting auth headers in the subprocess.
175
  if (flags.apiKey) {
44✔
176
    env.ANTHROPIC_API_KEY = flags.apiKey;
3✔
177
  } else if (process.env.ANTHROPIC_API_KEY) {
41✔
178
    env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
1✔
179
  } else if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
40✔
180
    env.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
1✔
181
  }
182

183
  // baseUrl is set from admin config (triage.classifyBaseUrl / respondBaseUrl),
184
  // never from user input. Validate URL format as defense-in-depth.
185
  if (flags.baseUrl) {
44✔
186
    try {
1✔
187
      const parsed = new URL(flags.baseUrl);
1✔
188
      if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
1!
189
        env.ANTHROPIC_BASE_URL = flags.baseUrl;
1✔
190
      }
191
    } catch {
UNCOV
192
      warn('Ignoring malformed baseUrl — falling back to default Anthropic endpoint', {
×
193
        baseUrl: flags.baseUrl,
194
      });
195
    }
196
  }
197

198
  return env;
44✔
199
}
200

201
// ── CLIProcess ───────────────────────────────────────────────────────────────
202

203
export class CLIProcess {
204
  #name;
205
  #flags;
206
  #streaming;
207
  #tokenLimit;
208
  #timeout;
209

210
  // Long-lived state
211
  #proc = null;
104✔
212
  #sessionId = null;
104✔
213
  #alive = false;
104✔
214
  #accumulatedTokens = 0;
104✔
215
  #stderrBuffer = [];
104✔
216

217
  // Long-lived consume-loop bookkeeping
218
  #pendingResolve = null;
104✔
219
  #pendingReject = null;
104✔
220

221
  // Short-lived: reference to the in-flight process for abort
222
  #inflightProc = null;
104✔
223

224
  // Mutex state — serialises concurrent send() calls.
225
  #mutexPromise = Promise.resolve();
104✔
226

227
  /**
228
   * @param {string} name  Human-readable label ('classifier' | 'responder' | 'ai-chat')
229
   * @param {Object} flags  CLI flag configuration
230
   * @param {string} [flags.model]  Model name (e.g. 'claude-sonnet-4-6')
231
   * @param {string} [flags.systemPromptFile]  Path to system prompt .md file
232
   * @param {string} [flags.systemPrompt]  System prompt as a string
233
   * @param {string} [flags.appendSystemPrompt]  Text appended to system prompt
234
   * @param {string} [flags.tools]  Tools flag ('' to disable all)
235
   * @param {string|string[]} [flags.allowedTools]  Allowed tool names
236
   * @param {string} [flags.permissionMode]  Permission mode (default: 'bypassPermissions')
237
   * @param {number} [flags.maxBudgetUsd]  Budget cap per process lifetime
238
   * @param {number} [flags.thinkingTokens]  MAX_THINKING_TOKENS env (default: 4096)
239
   * @param {string} [flags.baseUrl]  Override ANTHROPIC_BASE_URL (e.g. 'http://router:3456' for CCR proxy)
240
   * @param {string} [flags.apiKey]  Override ANTHROPIC_API_KEY (e.g. provider-specific key for routed requests)
241
   * @param {Object} [meta]
242
   * @param {number} [meta.tokenLimit=20000]  Token threshold before auto-recycle (long-lived only)
243
   * @param {boolean} [meta.streaming=false]  true for long-lived mode
244
   * @param {number} [meta.timeout=120000]  Per-send timeout in milliseconds
245
   */
246
  constructor(name, flags = {}, { tokenLimit = 20000, streaming = false, timeout = 120_000 } = {}) {
520✔
247
    this.#name = name;
104✔
248
    this.#flags = flags;
104✔
249
    this.#streaming = streaming;
104✔
250
    this.#tokenLimit = tokenLimit;
104✔
251
    this.#timeout = timeout;
104✔
252
  }
253

254
  // ── Lifecycle ────────────────────────────────────────────────────────────
255

256
  async start() {
257
    if (this.#streaming) {
107✔
258
      await this.#startLongLived();
19✔
259
    } else {
260
      this.#alive = true;
88✔
261
      this.#accumulatedTokens = 0;
88✔
262
    }
263
  }
264

265
  async #startLongLived() {
266
    this.#accumulatedTokens = 0;
19✔
267
    this.#stderrBuffer = [];
19✔
268
    this.#sessionId = null;
19✔
269

270
    const args = buildArgs(this.#flags, true);
19✔
271
    const env = buildEnv(this.#flags);
19✔
272

273
    this.#proc = spawn(CLAUDE_BIN, args, {
19✔
274
      stdio: ['pipe', 'pipe', 'pipe'],
275
      env,
276
    });
277

278
    // EPIPE protection: if the child dies between the alive check and stdin.write,
279
    // catch the error instead of crashing the host process.
280
    this.#proc.stdin.on('error', (err) => {
19✔
281
      warn(`${this.#name}: stdin error (child may have exited)`, { error: err.message });
1✔
282
      this.#alive = false;
1✔
283
    });
284

285
    // Capture stderr for diagnostics
286
    this.#proc.stderr.on('data', (chunk) => {
19✔
287
      const lines = chunk.toString().split('\n').filter(Boolean);
25✔
288
      this.#stderrBuffer.push(...lines);
25✔
289
      if (this.#stderrBuffer.length > MAX_STDERR_LINES) {
25✔
290
        this.#stderrBuffer = this.#stderrBuffer.slice(-MAX_STDERR_LINES);
5✔
291
      }
292
    });
293

294
    // Handle unexpected exit
295
    this.#proc.on('exit', (code, signal) => {
19✔
296
      if (this.#alive) {
1!
297
        warn(`${this.#name}: long-lived process exited`, { code, signal });
1✔
298
        this.#alive = false;
1✔
299
        if (this.#pendingReject) {
1!
300
          this.#pendingReject(
1✔
301
            new CLIProcessError(
302
              `${this.#name}: process exited unexpectedly (code=${code}, signal=${signal})`,
303
              'exit',
304
              { code, signal },
305
            ),
306
          );
307
          this.#pendingReject = null;
1✔
308
          this.#pendingResolve = null;
1✔
309
        }
310
      }
311
    });
312

313
    // Start the background consume loop
314
    this.#runConsumeLoop();
19✔
315
    this.#alive = true;
19✔
316
    info(`${this.#name}: long-lived process started`, { pid: this.#proc.pid });
19✔
317
  }
318

319
  #runConsumeLoop() {
320
    const rl = createInterface({ input: this.#proc.stdout, crlfDelay: Infinity });
19✔
321

322
    rl.on('line', (line) => {
19✔
323
      if (!line.trim()) return;
5!
324
      let msg;
325
      try {
5✔
326
        msg = JSON.parse(line);
5✔
327
      } catch {
UNCOV
328
        warn(`${this.#name}: non-JSON stdout line`, { line: line.slice(0, 200) });
×
UNCOV
329
        return;
×
330
      }
331

332
      // Capture session_id from init message
333
      if (msg.type === 'system' && msg.subtype === 'init') {
5✔
334
        this.#sessionId = msg.session_id;
1✔
335
        return;
1✔
336
      }
337

338
      if (msg.type === 'result') {
4!
339
        this.#trackTokens(msg);
4✔
340
        this.#pendingResolve?.(msg);
4✔
341
        this.#pendingResolve = null;
4✔
342
        this.#pendingReject = null;
4✔
343
      }
344
    });
345

346
    rl.on('close', () => {
19✔
347
      if (this.#alive) {
1!
348
        this.#alive = false;
1✔
349
        this.#pendingReject?.(
1✔
350
          new CLIProcessError(`${this.#name}: stdout closed unexpectedly`, 'exit'),
351
        );
352
        this.#pendingReject = null;
1✔
353
        this.#pendingResolve = null;
1✔
354
      }
355
    });
356
  }
357

358
  // ── send() ───────────────────────────────────────────────────────────────
359

360
  /**
361
   * Send a prompt and await the result.
362
   * Concurrent calls are serialised via an internal mutex.
363
   *
364
   * @param {string} prompt  The user-turn prompt text.
365
   * @param {Object} [overrides]  Per-call flag overrides (short-lived mode only).
366
   * @param {string} [overrides.systemPrompt]  Override system prompt string.
367
   * @param {string} [overrides.appendSystemPrompt]  Override append-system-prompt.
368
   * @param {string} [overrides.systemPromptFile]  Override system prompt file path.
369
   * @param {Object} [options]  Additional options.
370
   * @param {Function} [options.onEvent]  Callback for intermediate NDJSON messages (short-lived only).
371
   * @returns {Promise<Object>} The result message from the CLI.
372
   */
373
  async send(prompt, overrides = {}, { onEvent } = {}) {
66✔
374
    const release = await this.#acquireMutex();
33✔
375
    try {
33✔
376
      const result = this.#streaming
33✔
377
        ? await this.#sendLongLived(prompt)
378
        : await this.#sendShortLived(prompt, overrides, onEvent);
379

380
      // Token recycling — non-blocking so the caller gets the result now.
381
      if (this.#streaming && this.#accumulatedTokens >= this.#tokenLimit) {
19✔
382
        info(`Recycling ${this.#name} process`, {
1✔
383
          accumulatedTokens: this.#accumulatedTokens,
384
          tokenLimit: this.#tokenLimit,
385
        });
386
        this.recycle().catch((err) =>
1✔
387
          logError(`Failed to recycle ${this.#name}`, { error: err.message }),
388
        );
389
      }
390

391
      return result;
23✔
392
    } finally {
393
      release();
33✔
394
    }
395
  }
396

397
  async #sendShortLived(prompt, overrides = {}, onEvent = null) {
50✔
398
    const mergedFlags = { ...this.#flags, ...overrides };
25✔
399
    const args = buildArgs(mergedFlags, false);
25✔
400

401
    // In short-lived mode, the prompt is a positional argument after -p
402
    args.push(prompt);
25✔
403

404
    const env = buildEnv(mergedFlags);
25✔
405
    const stderrLines = [];
25✔
406

407
    return new Promise((resolve, reject) => {
25✔
408
      const proc = spawn(CLAUDE_BIN, args, {
25✔
409
        stdio: ['ignore', 'pipe', 'pipe'],
410
        env,
411
      });
412

413
      this.#inflightProc = proc;
25✔
414

415
      // Timeout handling
416
      const timer = setTimeout(() => {
25✔
417
        proc.kill('SIGKILL');
1✔
418
        reject(
1✔
419
          new CLIProcessError(
420
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
421
            'timeout',
422
          ),
423
        );
424
      }, this.#timeout);
425

426
      let result = null;
25✔
427

428
      // Capture stderr
429
      proc.stderr.on('data', (chunk) => {
25✔
430
        const lines = chunk.toString().split('\n').filter(Boolean);
1✔
431
        stderrLines.push(...lines);
1✔
432
        if (stderrLines.length > MAX_STDERR_LINES) {
1!
UNCOV
433
          stderrLines.splice(0, stderrLines.length - MAX_STDERR_LINES);
×
434
        }
435
      });
436

437
      const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
25✔
438

439
      rl.on('line', (line) => {
25✔
440
        if (!line.trim()) return;
24✔
441
        let msg;
442
        try {
22✔
443
          msg = JSON.parse(line);
22✔
444
        } catch {
445
          debug(`${this.#name}: non-JSON stdout line (short-lived)`, { line: line.slice(0, 200) });
1✔
446
          return;
1✔
447
        }
448
        if (msg.type === 'result') {
21✔
449
          result = msg;
20✔
450
        } else if (onEvent) {
1!
451
          onEvent(msg);
1✔
452
        }
453
      });
454

455
      proc.on('exit', (code, signal) => {
25✔
456
        clearTimeout(timer);
23✔
457
        this.#inflightProc = null;
23✔
458

459
        if (result) {
23✔
460
          try {
20✔
461
            resolve(this.#extractResult(result));
20✔
462
          } catch (err) {
463
            reject(err);
1✔
464
          }
465
        } else {
466
          const stderr = stderrLines.join('\n');
3✔
467
          reject(
3✔
468
            new CLIProcessError(
469
              `${this.#name}: process exited without result (code=${code}, signal=${signal})${stderr ? `\nstderr: ${stderr}` : ''}`,
3✔
470
              'exit',
471
              { code, signal },
472
            ),
473
          );
474
        }
475
      });
476

477
      proc.on('error', (err) => {
25✔
478
        clearTimeout(timer);
1✔
479
        this.#inflightProc = null;
1✔
480
        reject(
1✔
481
          new CLIProcessError(`${this.#name}: failed to spawn process — ${err.message}`, 'exit'),
482
        );
483
      });
484
    });
485
  }
486

487
  async #sendLongLived(prompt) {
488
    if (!this.#alive) {
8✔
489
      throw new CLIProcessError(`${this.#name}: process is not alive`, 'exit');
1✔
490
    }
491

492
    return new Promise((resolve, reject) => {
7✔
493
      this.#pendingResolve = (msg) => {
7✔
494
        clearTimeout(timer);
4✔
495
        try {
4✔
496
          resolve(this.#extractResult(msg));
4✔
497
        } catch (err) {
UNCOV
498
          reject(err);
×
499
        }
500
      };
501
      this.#pendingReject = (err) => {
7✔
502
        clearTimeout(timer);
2✔
503
        reject(err);
2✔
504
      };
505

506
      // Timeout handling
507
      const timer = setTimeout(() => {
7✔
508
        this.#pendingResolve = null;
1✔
509
        this.#pendingReject = null;
1✔
510
        // Kill and restart the long-lived process
511
        this.#proc?.kill('SIGKILL');
1✔
512
        reject(
1✔
513
          new CLIProcessError(
514
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
515
            'timeout',
516
          ),
517
        );
518
      }, this.#timeout);
519

520
      // Write NDJSON user-turn message to stdin
521
      const message = JSON.stringify({
7✔
522
        type: 'user',
523
        message: { role: 'user', content: prompt },
524
        session_id: this.#sessionId ?? '',
13✔
525
        parent_tool_use_id: null,
526
      });
527

528
      this.#proc.stdin.write(`${message}\n`);
7✔
529
    });
530
  }
531

532
  // ── Result extraction ────────────────────────────────────────────────────
533

534
  #extractResult(message) {
535
    if (message.is_error) {
24✔
536
      const errMsg = message.errors?.map((e) => e.message || e).join('; ') || 'Unknown CLI error';
1!
537
      logError(`${this.#name}: CLI error`, {
1✔
538
        error: errMsg,
539
        errorCount: message.errors?.length ?? 0,
1!
540
        resultSnippet: JSON.stringify(message).slice(0, 500),
541
      });
542
      throw new CLIProcessError(`${this.#name}: CLI error — ${errMsg}`, 'exit');
1✔
543
    }
544
    return message;
23✔
545
  }
546

547
  #trackTokens(message) {
548
    const usage = message.usage;
4✔
549
    if (usage) {
4✔
550
      const inp = usage.inputTokens ?? usage.input_tokens ?? 0;
3!
551
      const out = usage.outputTokens ?? usage.output_tokens ?? 0;
3!
552
      this.#accumulatedTokens += inp + out;
3✔
553
    }
554
  }
555

556
  // ── Recycle / restart ────────────────────────────────────────────────────
557

558
  async recycle() {
559
    this.close();
4✔
560
    await this.start();
4✔
561
  }
562

563
  async restart(attempt = 0) {
2✔
564
    const baseDelay = Math.min(1000 * 2 ** attempt, 30_000);
2✔
565
    const jitter = Math.floor(Math.random() * 1000);
2✔
566
    const delay = baseDelay + jitter;
2✔
567
    warn(`Restarting ${this.#name} process`, { attempt, delayMs: delay });
2✔
568
    await new Promise((r) => setTimeout(r, delay));
2✔
569
    try {
2✔
570
      await this.recycle();
2✔
571
    } catch (err) {
UNCOV
572
      logError(`${this.#name} restart failed`, { error: err.message, attempt });
×
UNCOV
573
      if (attempt < 5) {
×
UNCOV
574
        await this.restart(attempt + 1);
×
575
      } else {
576
        throw err;
×
577
      }
578
    }
579
  }
580

581
  close() {
582
    this.#killProc(this.#proc);
15✔
583
    this.#proc = null;
15✔
584

585
    this.#killProc(this.#inflightProc);
15✔
586
    this.#inflightProc = null;
15✔
587

588
    this.#alive = false;
15✔
589
    this.#sessionId = null;
15✔
590

591
    if (this.#pendingReject) {
15!
UNCOV
592
      this.#pendingReject(new CLIProcessError(`${this.#name}: process closed`, 'killed'));
×
UNCOV
593
      this.#pendingReject = null;
×
UNCOV
594
      this.#pendingResolve = null;
×
595
    }
596
  }
597

598
  /**
599
   * Send SIGTERM to a child process, then escalate to SIGKILL after 2 seconds
600
   * if it hasn't exited. Prevents zombie processes from stuck CLI subprocesses.
601
   * @param {import('node:child_process').ChildProcess|null} proc
602
   */
603
  #killProc(proc) {
604
    if (!proc) return;
30✔
605
    try {
6✔
606
      proc.kill('SIGTERM');
6✔
607
    } catch {
UNCOV
608
      return; // Already exited
×
609
    }
610
    const sigkillTimer = setTimeout(() => {
6✔
611
      try {
2✔
612
        proc.kill('SIGKILL');
2✔
613
      } catch {
614
        // Already exited between SIGTERM and SIGKILL — expected
615
      }
616
    }, 2000);
617
    // Don't keep the event loop alive just for the SIGKILL escalation
618
    sigkillTimer.unref();
6✔
619
  }
620

621
  // ── Mutex ────────────────────────────────────────────────────────────────
622

623
  #acquireMutex() {
624
    let release;
625
    const next = new Promise((resolve) => {
33✔
626
      release = resolve;
33✔
627
    });
628
    const prev = this.#mutexPromise;
33✔
629
    this.#mutexPromise = prev.then(() => next);
33✔
630
    return prev.then(() => release);
33✔
631
  }
632

633
  // ── Accessors ────────────────────────────────────────────────────────────
634

635
  get alive() {
636
    return this.#alive;
11✔
637
  }
638

639
  get tokenCount() {
640
    return this.#accumulatedTokens;
4✔
641
  }
642

643
  get name() {
644
    return this.#name;
2✔
645
  }
646

647
  get stderrDiagnostics() {
648
    return this.#stderrBuffer.join('\n');
2✔
649
  }
650
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc