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

VolvoxLLC / volvox-bot / 23932826918

03 Apr 2026 03:46AM UTC coverage: 90.56% (-0.1%) from 90.669%
23932826918

push

github

web-flow
Add dashboard permission access controls (#360)

* Add dashboard permission access controls

* Harden guild access resolution

* Validate web guild access responses

* Fix retry header fallback handling

* Align dashboard roles with moderator access

* fix(api): parallelize guild access checks

* refactor(web): share guild directory state

* fix(web): correct retry attempt logging

* docs: align moderator access comments

* fix(api): preserve dashboard xp actor attribution

* fix(api): cap guild access batch size

* fix(web): require botPresent in guild context

* fix(web): validate forwarded discord identities

* test: align dashboard and permission coverage

* test: increase root vitest timeout budget

* Refactor conditional checks for claude-review job

Signed-off-by: Bill Chirico <bill@chirico.dev>

* Update claude-review workflow to specify project context and enhance inline comment guidelines

- Clarified the project context in the review prompt for the Discord bot Volvox.Bot.
- Added detailed instructions for posting inline comments, including formatting requirements and examples for AI fix prompts.
- Ensured that the review process emphasizes only reporting issues without providing praise or compliments.

* feat(dashboard): add WYSIWYG Discord markdown editor component (#422)

* feat(dashboard): add Discord markdown parser and WYSIWYG editor component

- Add discord-markdown.ts utility with parser, wrapSelection, insertAtCursor, wrapLine
- Add DiscordMarkdownEditor component with:
  - Toolbar (Bold, Italic, Underline, Strikethrough, Code, Code Block, Spoiler, Quote, H1-H3, Lists)
  - Variable inserter dropdown for template variables
  - Split view: raw editor + live Discord-style preview
  - Character counter with configurable limit
  - Keyboard shortcuts (Ctrl+B, Ctrl+I, Ctrl+U)
  - Follows existing Radix UI + Tailwind + Lucide patterns

* test: add DiscordMarkdownEditor and discord-markdown parser tests

- 29 tests covering parser, utility f... (continued)

6742 of 7894 branches covered (85.41%)

Branch coverage included in aggregate %.

82 of 87 new or added lines in 8 files covered. (94.25%)

47 existing lines in 8 files now uncovered.

11438 of 12181 relevant lines covered (93.9%)

218.54 hits per line

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

84.88
/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));
30✔
27
const LOCAL_BIN = resolve(__dirname, '..', '..', 'node_modules', '.bin', 'claude');
30✔
28
const CLAUDE_BIN = existsSync(LOCAL_BIN) ? LOCAL_BIN : 'claude';
30✔
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;
30✔
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 {
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;
89✔
212
  #sessionId = null;
89✔
213
  #alive = false;
89✔
214
  #accumulatedTokens = 0;
89✔
215
  #stderrBuffer = [];
89✔
216

217
  // Long-lived consume-loop bookkeeping
218
  #pendingResolve = null;
89✔
219
  #pendingReject = null;
89✔
220
  /** @type {string[]} Accumulated text blocks for the current long-lived turn */
221
  #longLivedTextParts = [];
89✔
222

223
  // Short-lived: reference to the in-flight process for abort
224
  #inflightProc = null;
89✔
225

226
  // Mutex state — serialises concurrent send() calls.
227
  #mutexPromise = Promise.resolve();
89✔
228

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

256
  // ── Lifecycle ────────────────────────────────────────────────────────────
257

258
  async start() {
259
    if (this.#streaming) {
91✔
260
      await this.#startLongLived();
19✔
261
    } else {
262
      this.#alive = true;
72✔
263
      this.#accumulatedTokens = 0;
72✔
264
    }
265
  }
266

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

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

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

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

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

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

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

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

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

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

340
      // Accumulate text from assistant messages (long-lived mode)
341
      if (msg.type === 'assistant' && msg.message?.content) {
4!
342
        for (const block of msg.message.content) {
×
343
          if (block.type === 'text' && block.text) {
×
344
            this.#longLivedTextParts.push(block.text);
×
345
          }
346
        }
347
      }
348

349
      if (msg.type === 'result') {
4!
350
        // Reconstruct result text from accumulated assistant blocks
351
        if (msg.result === undefined && this.#longLivedTextParts.length > 0) {
4!
352
          msg.result = this.#longLivedTextParts.join('');
×
353
        }
354
        this.#longLivedTextParts = [];
4✔
355
        this.#trackTokens(msg);
4✔
356
        this.#pendingResolve?.(msg);
4✔
357
        this.#pendingResolve = null;
4✔
358
        this.#pendingReject = null;
4✔
359
      }
360
    });
361

362
    rl.on('close', () => {
19✔
363
      if (this.#alive) {
1!
364
        this.#alive = false;
1✔
365
        this.#pendingReject?.(
1✔
366
          new CLIProcessError(`${this.#name}: stdout closed unexpectedly`, 'exit'),
367
        );
368
        this.#pendingReject = null;
1✔
369
        this.#pendingResolve = null;
1✔
370
      }
371
    });
372
  }
373

374
  // ── send() ───────────────────────────────────────────────────────────────
375

376
  /**
377
   * Send a prompt and await the result.
378
   * Concurrent calls are serialised via an internal mutex.
379
   *
380
   * @param {string} prompt  The user-turn prompt text.
381
   * @param {Object} [overrides]  Per-call flag overrides (short-lived mode only).
382
   * @param {string} [overrides.systemPrompt]  Override system prompt string.
383
   * @param {string} [overrides.appendSystemPrompt]  Override append-system-prompt.
384
   * @param {string} [overrides.systemPromptFile]  Override system prompt file path.
385
   * @param {Object} [options]  Additional options.
386
   * @param {Function} [options.onEvent]  Callback for intermediate NDJSON messages (short-lived only).
387
   * @returns {Promise<Object>} The result message from the CLI.
388
   */
389
  async send(prompt, overrides = {}, { onEvent } = {}) {
66✔
390
    const release = await this.#acquireMutex();
33✔
391
    try {
33✔
392
      const result = this.#streaming
33✔
393
        ? await this.#sendLongLived(prompt)
394
        : await this.#sendShortLived(prompt, overrides, onEvent);
395

396
      // Token recycling — non-blocking so the caller gets the result now.
397
      if (this.#streaming && this.#accumulatedTokens >= this.#tokenLimit) {
19✔
398
        info(`Recycling ${this.#name} process`, {
1✔
399
          accumulatedTokens: this.#accumulatedTokens,
400
          tokenLimit: this.#tokenLimit,
401
        });
402
        this.recycle().catch((err) =>
1✔
403
          logError(`Failed to recycle ${this.#name}`, { error: err.message }),
404
        );
405
      }
406

407
      return result;
23✔
408
    } finally {
409
      release();
33✔
410
    }
411
  }
412

413
  /**
414
   * Parse a single stdout line from the CLI process, accumulating text parts
415
   * and detecting the result message.
416
   */
417
  #parseStdoutLine(line, textParts, onEvent) {
418
    if (!line.trim()) return null;
24✔
419
    let msg;
420
    try {
22✔
421
      msg = JSON.parse(line);
22✔
422
    } catch {
423
      debug(`${this.#name}: non-JSON stdout line (short-lived)`, { line: line.slice(0, 200) });
1✔
424
      return null;
1✔
425
    }
426
    if (msg.type === 'result') {
21✔
427
      if (msg.result === undefined && textParts.length > 0) {
20!
428
        msg.result = textParts.join('');
×
429
      }
430
      return msg;
20✔
431
    }
432
    // Accumulate text from assistant messages (claude-code >=2.1.77)
433
    if (msg.type === 'assistant' && msg.message?.content) {
1!
434
      for (const block of msg.message.content) {
×
435
        if (block.type === 'text' && block.text) {
×
436
          textParts.push(block.text);
×
437
        }
438
      }
439
    }
440
    if (onEvent) {
1!
441
      onEvent(msg);
1✔
442
    }
443
    return null;
1✔
444
  }
445

446
  async #sendShortLived(prompt, overrides = {}, onEvent = null) {
50✔
447
    const mergedFlags = { ...this.#flags, ...overrides };
25✔
448
    const args = buildArgs(mergedFlags, false);
25✔
449

450
    // In short-lived mode, the prompt is a positional argument after -p
451
    args.push(prompt);
25✔
452

453
    const env = buildEnv(mergedFlags);
25✔
454
    const stderrLines = [];
25✔
455

456
    return new Promise((resolve, reject) => {
25✔
457
      const proc = spawn(CLAUDE_BIN, args, {
25✔
458
        stdio: ['ignore', 'pipe', 'pipe'],
459
        env,
460
      });
461

462
      this.#inflightProc = proc;
25✔
463

464
      // Timeout handling
465
      const timer = setTimeout(() => {
25✔
466
        proc.kill('SIGKILL');
1✔
467
        reject(
1✔
468
          new CLIProcessError(
469
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
470
            'timeout',
471
          ),
472
        );
473
      }, this.#timeout);
474

475
      let result = null;
25✔
476
      const textParts = [];
25✔
477

478
      // Capture stderr
479
      proc.stderr.on('data', (chunk) => {
25✔
480
        const lines = chunk.toString().split('\n').filter(Boolean);
1✔
481
        stderrLines.push(...lines);
1✔
482
        if (stderrLines.length > MAX_STDERR_LINES) {
1!
483
          stderrLines.splice(0, stderrLines.length - MAX_STDERR_LINES);
×
484
        }
485
      });
486

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

489
      rl.on('line', (line) => {
25✔
490
        const parsed = this.#parseStdoutLine(line, textParts, onEvent);
24✔
491
        if (parsed) result = parsed;
24✔
492
      });
493

494
      proc.on('exit', (code, signal) => {
25✔
495
        clearTimeout(timer);
23✔
496
        this.#inflightProc = null;
23✔
497

498
        if (result) {
23✔
499
          try {
20✔
500
            resolve(this.#extractResult(result));
20✔
501
          } catch (err) {
502
            reject(err);
1✔
503
          }
504
        } else {
505
          const stderr = stderrLines.join('\n');
3✔
506
          reject(
3✔
507
            new CLIProcessError(
508
              `${this.#name}: process exited without result (code=${code}, signal=${signal})${stderr ? `\nstderr: ${stderr}` : ''}`,
3✔
509
              'exit',
510
              { code, signal },
511
            ),
512
          );
513
        }
514
      });
515

516
      proc.on('error', (err) => {
25✔
517
        clearTimeout(timer);
1✔
518
        this.#inflightProc = null;
1✔
519
        reject(
1✔
520
          new CLIProcessError(`${this.#name}: failed to spawn process — ${err.message}`, 'exit'),
521
        );
522
      });
523
    });
524
  }
525

526
  async #sendLongLived(prompt) {
527
    if (!this.#alive) {
8✔
528
      throw new CLIProcessError(`${this.#name}: process is not alive`, 'exit');
1✔
529
    }
530

531
    // Reset text accumulator for new turn
532
    this.#longLivedTextParts = [];
7✔
533

534
    return new Promise((resolve, reject) => {
7✔
535
      this.#pendingResolve = (msg) => {
7✔
536
        clearTimeout(timer);
4✔
537
        try {
4✔
538
          resolve(this.#extractResult(msg));
4✔
539
        } catch (err) {
540
          reject(err);
×
541
        }
542
      };
543
      this.#pendingReject = (err) => {
7✔
544
        clearTimeout(timer);
2✔
545
        reject(err);
2✔
546
      };
547

548
      // Timeout handling
549
      const timer = setTimeout(() => {
7✔
550
        this.#pendingResolve = null;
1✔
551
        this.#pendingReject = null;
1✔
552
        // Kill and restart the long-lived process
553
        this.#proc?.kill('SIGKILL');
1✔
554
        reject(
1✔
555
          new CLIProcessError(
556
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
557
            'timeout',
558
          ),
559
        );
560
      }, this.#timeout);
561

562
      // Write NDJSON user-turn message to stdin
563
      const message = JSON.stringify({
7✔
564
        type: 'user',
565
        message: { role: 'user', content: prompt },
566
        session_id: this.#sessionId ?? '',
13✔
567
        parent_tool_use_id: null,
568
      });
569

570
      this.#proc.stdin.write(`${message}\n`);
7✔
571
    });
572
  }
573

574
  // ── Result extraction ────────────────────────────────────────────────────
575

576
  #extractResult(message) {
577
    if (message.is_error) {
24✔
578
      const errMsg = message.errors?.map((e) => e.message || e).join('; ') || 'Unknown CLI error';
1!
579
      logError(`${this.#name}: CLI error`, {
1✔
580
        error: errMsg,
581
        errorCount: message.errors?.length ?? 0,
1!
582
        resultSnippet: JSON.stringify(message).slice(0, 500),
583
      });
584
      throw new CLIProcessError(`${this.#name}: CLI error — ${errMsg}`, 'exit');
1✔
585
    }
586
    return message;
23✔
587
  }
588

589
  #trackTokens(message) {
590
    const usage = message.usage;
4✔
591
    if (usage) {
4✔
592
      const inp = usage.inputTokens ?? usage.input_tokens ?? 0;
3!
593
      const out = usage.outputTokens ?? usage.output_tokens ?? 0;
3!
594
      this.#accumulatedTokens += inp + out;
3✔
595
    }
596
  }
597

598
  // ── Recycle / restart ────────────────────────────────────────────────────
599

600
  async recycle() {
601
    this.close();
4✔
602
    await this.start();
4✔
603
  }
604

605
  async restart(attempt = 0) {
2✔
606
    const baseDelay = Math.min(1000 * 2 ** attempt, 30_000);
2✔
607
    const jitter = Math.floor(Math.random() * 1000);
2✔
608
    const delay = baseDelay + jitter;
2✔
609
    warn(`Restarting ${this.#name} process`, { attempt, delayMs: delay });
2✔
610
    await new Promise((r) => setTimeout(r, delay));
2✔
611
    try {
2✔
612
      await this.recycle();
2✔
613
    } catch (err) {
614
      logError(`${this.#name} restart failed`, { error: err.message, attempt });
×
615
      if (attempt < 5) {
×
616
        await this.restart(attempt + 1);
×
617
      } else {
618
        throw err;
×
619
      }
620
    }
621
  }
622

623
  close() {
624
    this.#killProc(this.#proc);
15✔
625
    this.#proc = null;
15✔
626

627
    this.#killProc(this.#inflightProc);
15✔
628
    this.#inflightProc = null;
15✔
629

630
    this.#alive = false;
15✔
631
    this.#sessionId = null;
15✔
632

633
    if (this.#pendingReject) {
15!
634
      this.#pendingReject(new CLIProcessError(`${this.#name}: process closed`, 'killed'));
×
635
      this.#pendingReject = null;
×
636
      this.#pendingResolve = null;
×
637
    }
638
  }
639

640
  /**
641
   * Send SIGTERM to a child process, then escalate to SIGKILL after 2 seconds
642
   * if it hasn't exited. Prevents zombie processes from stuck CLI subprocesses.
643
   * @param {import('node:child_process').ChildProcess|null} proc
644
   */
645
  #killProc(proc) {
646
    if (!proc) return;
30✔
647
    try {
6✔
648
      proc.kill('SIGTERM');
6✔
649
    } catch {
650
      return; // Already exited
×
651
    }
652
    const sigkillTimer = setTimeout(() => {
6✔
UNCOV
653
      try {
×
UNCOV
654
        proc.kill('SIGKILL');
×
655
      } catch {
656
        // Already exited between SIGTERM and SIGKILL — expected
657
      }
658
    }, 2000);
659
    // Don't keep the event loop alive just for the SIGKILL escalation
660
    sigkillTimer.unref();
6✔
661
  }
662

663
  // ── Mutex ────────────────────────────────────────────────────────────────
664

665
  #acquireMutex() {
666
    let release;
667
    const next = new Promise((resolve) => {
33✔
668
      release = resolve;
33✔
669
    });
670
    const prev = this.#mutexPromise;
33✔
671
    this.#mutexPromise = prev.then(() => next);
33✔
672
    return prev.then(() => release);
33✔
673
  }
674

675
  // ── Accessors ────────────────────────────────────────────────────────────
676

677
  get alive() {
678
    return this.#alive;
11✔
679
  }
680

681
  get tokenCount() {
682
    return this.#accumulatedTokens;
4✔
683
  }
684

685
  get name() {
686
    return this.#name;
2✔
687
  }
688

689
  get stderrDiagnostics() {
690
    return this.#stderrBuffer.join('\n');
2✔
691
  }
692
}
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