amicus 1.7.5 → 1.7.7

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.
@@ -110,7 +110,10 @@ function updateSessionStatus(sessionDir, status) {
110
110
  return meta;
111
111
  }
112
112
 
113
- /** Resume a previous sidecar session - Spec Reference: §4.3, §8.3 */
113
+ /**
114
+ * Resume a previous sidecar session - Spec Reference: §4.3, §8.3
115
+ * @returns {Promise<number>} process exit code
116
+ */
114
117
  async function resumeSidecar(options) {
115
118
  const {
116
119
  taskId, project = process.cwd(), headless = false, timeout = 15,
@@ -163,6 +166,7 @@ async function resumeSidecar(options) {
163
166
  heartbeat = createHeartbeat();
164
167
 
165
168
  let summary;
169
+ let result;
166
170
  const effectiveAgent = metadata.agent || 'Build';
167
171
 
168
172
  // Load conversation for both paths (interactive already did this, headless didn't)
@@ -173,7 +177,7 @@ async function resumeSidecar(options) {
173
177
 
174
178
  if (headless) {
175
179
  const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
176
- const result = await runHeadless(
180
+ result = await runHeadless(
177
181
  metadata.model, resumePrompt, userMessage,
178
182
  taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
179
183
  );
@@ -184,7 +188,7 @@ async function resumeSidecar(options) {
184
188
  } else {
185
189
  logger.info('Launching interactive resume', { taskId, model: metadata.model });
186
190
 
187
- const result = await runInteractive(
191
+ result = await runInteractive(
188
192
  metadata.model, resumePrompt, metadata.briefing || '',
189
193
  taskId, project,
190
194
  {
@@ -202,10 +206,22 @@ async function resumeSidecar(options) {
202
206
  // Output summary
203
207
  outputSummary(summary);
204
208
 
205
- // Finalize session (use updatedMetadata which has resumedAt). Pass status
206
- // explicitly to preserve the pre-#36 default ('complete') and stay out of
207
- // the empty-summary guard interactive resume legitimately has no summary.
208
- finalizeSession(sessionDir, summary, project, updatedMetadata, { status: 'complete' });
209
+ // Map the run result to the canonical terminal status + exit code —
210
+ // mirrors start.js. Explicit status preserves the interactive
211
+ // empty-summary carve-out (the #36 guard never re-classifies it).
212
+ const { resolveTerminalState } = require('./session-finalize');
213
+ const terminal = resolveTerminalState(result);
214
+ const metaPath = SessionPaths.metadataFile(sessionDir);
215
+ if (terminal.status === 'error') {
216
+ updatedMetadata.status = 'error';
217
+ updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
218
+ updatedMetadata.completedAt = new Date().toISOString();
219
+ fs.writeFileSync(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
220
+ logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
221
+ } else {
222
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
223
+ }
224
+ return terminal.exitCode; // finally below still releases the lock first
209
225
  } finally {
210
226
  if (heartbeat) { heartbeat.stop(); }
211
227
  releaseLock(sessionDir);
@@ -55,6 +55,14 @@ async function launchSetupWindow() {
55
55
  logger.debug('Setup window stderr', { data: chunk.trim() });
56
56
  });
57
57
 
58
+ // A spawn failure (ENOENT/EACCES) emits 'error' and NEVER 'close'. Without
59
+ // this listener the Promise would hang forever and Node would treat the
60
+ // unhandled child 'error' as a crash. Resolve with a clear failure instead.
61
+ proc.on('error', (err) => {
62
+ logger.error('Setup window failed to spawn', { error: err.message });
63
+ resolve({ success: false, error: `Failed to start setup window: ${err.message}` });
64
+ });
65
+
58
66
  proc.on('close', (code) => {
59
67
  logger.info('Setup window closed', { code });
60
68
 
@@ -23,6 +23,7 @@ const { acquireLock, releaseLock } = require('../utils/session-lock');
23
23
  const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
24
24
  const { mapAgentToOpenCode } = require('../utils/agent-mapping');
25
25
  const { discoverParentMcps } = require('../utils/mcp-discovery');
26
+ const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
26
27
 
27
28
  /** Generate a unique 8-character hex task ID */
28
29
  function generateTaskId() {
@@ -79,10 +80,12 @@ function createSessionMetadata(taskId, project, options) {
79
80
  * @param {string} [options.clientType] - Parent client type for discovery
80
81
  * @param {boolean} [options.noMcp] - Skip MCP inheritance from parent
81
82
  * @param {string[]} [options.excludeMcp] - Server names to exclude
83
+ * @param {string} [options.projectDir] - Target project directory used to resolve
84
+ * a project-scoped opencode.json (NOT process.cwd() under Claude Code/MCP/Cowork)
82
85
  * @returns {object|null} MCP server configs or null
83
86
  */
84
87
  function buildMcpConfig(options) {
85
- const { mcp, mcpConfig, clientType, noMcp, excludeMcp } = options;
88
+ const { mcp, mcpConfig, clientType, noMcp, excludeMcp, projectDir } = options;
86
89
  let mcpServers = null;
87
90
 
88
91
  // Layer 1: Discover parent MCPs (unless --no-mcp)
@@ -94,8 +97,10 @@ function buildMcpConfig(options) {
94
97
  }
95
98
  }
96
99
 
97
- // Layer 2: File config (opencode.json) overrides discovered
98
- const fileConfig = loadMcpConfig(mcpConfig);
100
+ // Layer 2: File config (opencode.json) overrides discovered.
101
+ // Resolve the project-scoped opencode.json against the target project dir,
102
+ // not process.cwd() (which differs under Claude Code/MCP/Cowork).
103
+ const fileConfig = loadMcpConfig(mcpConfig, projectDir);
99
104
  if (fileConfig) {
100
105
  mcpServers = mcpServers ? { ...mcpServers, ...fileConfig } : { ...fileConfig };
101
106
  logger.debug('Loaded MCP config from file', { serverCount: Object.keys(fileConfig).length });
@@ -113,13 +118,11 @@ function buildMcpConfig(options) {
113
118
  }
114
119
  }
115
120
 
116
- // Always exclude the sidecar itself to prevent recursive spawning.
117
- // When launched from Cowork, the discovered MCP list includes "sidecar"
118
- // which would cause an infinite spawn loop.
119
- if (mcpServers && mcpServers.sidecar) {
120
- delete mcpServers.sidecar;
121
- logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
122
- }
121
+ // Always exclude amicus itself under ANY registered name or aliased
122
+ // invocation to prevent recursive spawning. When launched from Cowork or
123
+ // Claude Code the discovered list includes 'amicus'/'sidecar' (and possibly
124
+ // a user alias), which would cause an infinite spawn loop.
125
+ if (mcpServers) { stripSelfMcpEntries(mcpServers, logger); }
123
126
 
124
127
  // Apply explicit exclusions
125
128
  if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
@@ -154,7 +157,9 @@ async function startSidecar(options) {
154
157
  const effectiveSession = sessionId || session;
155
158
  const effectiveProject = cwd || project;
156
159
  const effectiveHeadless = noUi !== undefined ? noUi : headless;
157
- const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
160
+ const mcpServers = buildMcpConfig({
161
+ mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
162
+ });
158
163
  const taskId = options.taskId || generateTaskId();
159
164
  const reasoning = thinking ? { effort: thinking } : undefined;
160
165
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Atomic file write helper.
3
+ *
4
+ * Writes data to a unique temp file alongside the target, then renames it into
5
+ * place. Rename is atomic on a single filesystem, so a crash mid-write leaves
6
+ * the original file intact rather than a truncated/corrupt one. This is the
7
+ * pattern already used by council/verdict.js, session-index.js, and
8
+ * model-catalog.js; this module shares it for metadata writes.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+
15
+ /**
16
+ * Atomically write `data` to `filePath` via temp file + rename.
17
+ *
18
+ * The temp name is unique per write (pid + random suffix) so concurrent writers
19
+ * never collide on the same temp file. On any failure the temp file is cleaned
20
+ * up best-effort. Errors propagate to the caller.
21
+ *
22
+ * @param {string} filePath - Destination path.
23
+ * @param {string|Buffer} data - Content to write.
24
+ * @param {{mode?: number}} [opts] - Write options; `mode` sets file permissions.
25
+ */
26
+ function writeFileAtomic(filePath, data, opts = {}) {
27
+ const dir = path.dirname(filePath);
28
+ const base = path.basename(filePath);
29
+ const tmp = path.join(dir, `.${base}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
30
+ try {
31
+ fs.writeFileSync(tmp, data, { mode: opts.mode });
32
+ fs.renameSync(tmp, filePath); // atomic on a single filesystem
33
+ } catch (err) {
34
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ }
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ module.exports = { writeFileAtomic };
@@ -48,6 +48,9 @@ function failJson(useJson, { code, message, hint = null, command = null }) {
48
48
  process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
49
49
  } else {
50
50
  process.stderr.write(message + '\n');
51
+ // Parity with --json (whose envelope carries error.hint): surface the
52
+ // actionable hint to humans too, in doctor's arrow style.
53
+ if (hint) { process.stderr.write(` → ${hint}\n`); }
51
54
  }
52
55
  return 1;
53
56
  }
@@ -0,0 +1,24 @@
1
+ // src/utils/format-duration.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module utils/format-duration
6
+ * Single ms->human duration formatter shared by the fanout and council
7
+ * renderers. Rolls minutes up ("1m5s" / "42s") and lets each caller pick its
8
+ * own null placeholder ("-" for fanout stdout, "—" for the council report).
9
+ */
10
+
11
+ /**
12
+ * Format a millisecond duration as "1m5s" / "42s".
13
+ * @param {number|null|undefined} ms - Duration in milliseconds.
14
+ * @param {string} [empty='-'] - Placeholder for null/undefined input.
15
+ * @returns {string}
16
+ */
17
+ function formatDuration(ms, empty = '-') {
18
+ if (ms === null || ms === undefined) { return empty; }
19
+ const s = Math.round(ms / 1000);
20
+ const m = Math.floor(s / 60);
21
+ return m > 0 ? `${m}m${s % 60}s` : `${s}s`;
22
+ }
23
+
24
+ module.exports = { formatDuration };
@@ -12,6 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const os = require('os');
14
14
  const { logger } = require('./logger');
15
+ const { stripSelfMcpEntries } = require('./mcp-self-identity');
15
16
 
16
17
  /**
17
18
  * Normalize .mcp.json to a flat { name: config } map.
@@ -70,15 +71,13 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
70
71
  const settingsPath = path.join(baseDir, 'settings.json');
71
72
  if (!fs.existsSync(settingsPath)) {
72
73
  // No settings.json — skip plugin discovery, may still have claude.json servers
73
- const merged = { ...claudeJsonServers };
74
- delete merged.sidecar;
74
+ const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
75
75
  return Object.keys(merged).length > 0 ? merged : null;
76
76
  }
77
77
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
78
78
  const enabledPlugins = settings.enabledPlugins;
79
79
  if (!enabledPlugins || typeof enabledPlugins !== 'object') {
80
- const merged = { ...claudeJsonServers };
81
- delete merged.sidecar;
80
+ const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
82
81
  return Object.keys(merged).length > 0 ? merged : null;
83
82
  }
84
83
 
@@ -133,11 +132,9 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
133
132
  logger.debug('Failed to read Claude Code settings', { error: err.message });
134
133
  }
135
134
 
136
- // Merge: plugin servers first, then claude.json overwrites (higher priority)
137
- const merged = { ...pluginServers, ...claudeJsonServers };
138
-
139
- // Always exclude sidecar itself to prevent recursive spawning
140
- delete merged.sidecar;
135
+ // Merge: plugin servers first, then claude.json overwrites (higher priority).
136
+ // Recursive-spawn guard: drop every entry that resolves to amicus itself.
137
+ const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
141
138
 
142
139
  return Object.keys(merged).length > 0 ? merged : null;
143
140
  }
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module mcp-self-identity
5
+ * Recursive-spawn guard. A child sidecar that inherits an MCP entry launching
6
+ * amicus itself would spawn amicus inside amicus, forever. The shipped server
7
+ * registers as 'amicus' (scripts/postinstall.js, .claude-plugin/plugin.json)
8
+ * plus a deprecated 'sidecar' shim — and users can alias it under ANY name —
9
+ * so we exclude both reserved names AND any entry whose command+args resolve
10
+ * to an amicus MCP invocation.
11
+ */
12
+
13
+ /** Server names amicus registers itself under. */
14
+ const SELF_MCP_NAMES = Object.freeze(['amicus', 'sidecar']);
15
+
16
+ /** Shipped bin aliases (package.json "bin") → ./bin/amicus.js */
17
+ const SELF_BIN_NAMES = new Set(['amicus', 'am', 'sidecar', 'claude-sidecar']);
18
+
19
+ /**
20
+ * Normalize one command/arg token for identity matching: lower-case, forward
21
+ * slashes, basename, strip a trailing .exe/.cmd/.js, strip an @version spec.
22
+ * 'C:\\x\\bin\\amicus.js' → 'amicus'; 'amicus@latest' → 'amicus'; 'npx' → 'npx'.
23
+ * @param {unknown} token
24
+ * @returns {string}
25
+ */
26
+ function normalizeToken(token) {
27
+ const t = String(token).toLowerCase().replace(/\\/g, '/');
28
+ const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
29
+ return base.replace(/\.(exe|cmd|js)$/, '').replace(/@[^@]*$/, '');
30
+ }
31
+
32
+ /**
33
+ * True when this MCP server config would launch amicus's own MCP server:
34
+ * some non-flag token resolves to an amicus binary/package and a LATER token
35
+ * is 'mcp'. URL-only (command-less) configs are never self.
36
+ * @param {{command?:string, args?:unknown[]}|null|undefined} config
37
+ * @returns {boolean}
38
+ */
39
+ function isAmicusMcpConfig(config) {
40
+ if (!config || typeof config !== 'object' || !config.command) { return false; }
41
+ const tokens = [config.command, ...(Array.isArray(config.args) ? config.args : [])].map(String);
42
+ for (let i = 0; i < tokens.length; i++) {
43
+ if (tokens[i].startsWith('-')) { continue; } // flags (-y, --yes) are never the binary
44
+ if (SELF_BIN_NAMES.has(normalizeToken(tokens[i]))) {
45
+ return tokens.slice(i + 1).some((t) => String(t).toLowerCase() === 'mcp');
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Delete every self entry (reserved name OR command identity) from an
53
+ * mcpServers map. Mutates and returns the same object.
54
+ * @param {object|null|undefined} mcpServers
55
+ * @param {{debug?:Function}} [log]
56
+ * @returns {object|null|undefined}
57
+ */
58
+ function stripSelfMcpEntries(mcpServers, log) {
59
+ if (!mcpServers || typeof mcpServers !== 'object') { return mcpServers; }
60
+ for (const name of Object.keys(mcpServers)) {
61
+ if (SELF_MCP_NAMES.includes(name) || isAmicusMcpConfig(mcpServers[name])) {
62
+ delete mcpServers[name];
63
+ if (log && log.debug) { log.debug('Auto-excluded amicus MCP entry (recursive spawn prevention)', { name }); }
64
+ }
65
+ }
66
+ return mcpServers;
67
+ }
68
+
69
+ module.exports = { SELF_MCP_NAMES, isAmicusMcpConfig, stripSelfMcpEntries, normalizeToken };
@@ -4,8 +4,8 @@
4
4
  * Handles port management and cleanup for the OpenCode server.
5
5
  */
6
6
 
7
- const { execFileSync } = require('child_process');
8
7
  const { logger } = require('./logger');
8
+ const { findListenerPid } = require('./port-pid');
9
9
 
10
10
  const DEFAULT_PORT = 4096;
11
11
 
@@ -15,18 +15,9 @@ const DEFAULT_PORT = 4096;
15
15
  * @returns {number|null} PID or null if not in use
16
16
  */
17
17
  function getPortPid(port) {
18
- try {
19
- // Use execFileSync with arguments array (safe from injection)
20
- const result = execFileSync('lsof', ['-ti', `:${port}`], {
21
- encoding: 'utf8',
22
- stdio: ['pipe', 'pipe', 'pipe']
23
- });
24
- const pid = parseInt(result.trim(), 10);
25
- return isNaN(pid) ? null : pid;
26
- } catch {
27
- // lsof returns non-zero if no process found
28
- return null;
29
- }
18
+ // Delegate to the cross-platform lookup (netstat on Windows, lsof elsewhere)
19
+ // so the port-in-use check and kill path work off Unix too.
20
+ return findListenerPid(port);
30
21
  }
31
22
 
32
23
  /**
@@ -11,6 +11,7 @@
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
+ const { writeFileAtomic } = require('./atomic-write');
14
15
 
15
16
  /**
16
17
  * Synchronously write a terminal status to a session's metadata. Best-effort: never throws.
@@ -28,7 +29,8 @@ function markTerminal(sessionDir, status, reason) {
28
29
  meta.status = status;
29
30
  meta.reason = reason;
30
31
  meta[status === 'aborted' ? 'abortedAt' : 'completedAt'] = new Date().toISOString();
31
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
32
+ // Atomic (temp + rename): a crash mid-write must not corrupt the marker.
33
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
32
34
  return true;
33
35
  } catch {
34
36
  return false;
@@ -11,6 +11,7 @@ const { getCompatEnv } = require('./env-compat');
11
11
  const MAX_RESTARTS = 3;
12
12
  const RESTART_WINDOW = 5 * 60 * 1000;
13
13
  const RESTART_BACKOFF = 2000;
14
+ const CRASH_POLL_INTERVAL = 5000; // ms; env-tunable via AMICUS_CRASH_POLL_MS
14
15
 
15
16
  /**
16
17
  * SharedServerManager - manages a single shared OpenCode server for MCP sessions.
@@ -45,6 +46,16 @@ class SharedServerManager {
45
46
 
46
47
  /** @type {number[]} Timestamps of recent restart attempts */
47
48
  this._restartTimestamps = [];
49
+
50
+ /** @type {NodeJS.Timeout|null} goPid liveness poll (H7) */
51
+ this._crashPoll = null;
52
+
53
+ /** @type {NodeJS.Timeout|null} Pending crash-restart backoff timer */
54
+ this._restartTimer = null;
55
+
56
+ /** Pid-liveness probe (test seam). Lazy default keeps construction light. */
57
+ this._isProcessAlive = options.isProcessAlive
58
+ || ((pid) => require('../sidecar/session-utils').isProcessAlive(pid));
48
59
  }
49
60
 
50
61
  /**
@@ -73,6 +84,7 @@ class SharedServerManager {
73
84
  this.server = server;
74
85
  this.client = client;
75
86
  this._starting = null;
87
+ this._wireCrashListener(server);
76
88
  this._serverWatchdog = new IdleWatchdog({
77
89
  mode: 'server',
78
90
  onTimeout: () => {
@@ -162,6 +174,8 @@ class SharedServerManager {
162
174
  this._serverWatchdog.cancel();
163
175
  this._serverWatchdog = null;
164
176
  }
177
+ this._stopCrashPoll();
178
+ if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
165
179
  if (this.server) {
166
180
  this.server.close();
167
181
  this.server = null;
@@ -169,19 +183,68 @@ class SharedServerManager {
169
183
  }
170
184
  }
171
185
 
186
+ /**
187
+ * Wire crash detection onto a freshly started server handle. The REAL handle
188
+ * from buildServerHandle is { url, goPid, close } — it surfaces NO lifecycle
189
+ * events, so the emitter path alone was dead code (H7). Detection now polls
190
+ * the Go engine pid; emitter wiring is kept for handles that do expose events.
191
+ * @param {object} server - Server handle returned by _doStartServer
192
+ */
193
+ _wireCrashListener(server) {
194
+ const emitter = (server && typeof server.on === 'function')
195
+ ? server
196
+ : (server && server.process && typeof server.process.on === 'function')
197
+ ? server.process
198
+ : null;
199
+ if (emitter && !emitter._amicusCrashWired) {
200
+ emitter._amicusCrashWired = true;
201
+ const onExit = (code) => {
202
+ if (this.server !== server) { return; } // stale handle already replaced/closed
203
+ this._onServerCrash(code);
204
+ };
205
+ emitter.on('exit', onExit);
206
+ emitter.on('close', onExit);
207
+ }
208
+ if (server && server.goPid) {
209
+ this._startCrashPoll(server);
210
+ } else if (!emitter) {
211
+ this.logger.debug?.('Server handle has no goPid and no emitter — crash detection unavailable');
212
+ }
213
+ }
214
+
215
+ /** Poll the Go engine pid; pid death IS the crash signal (H7). */
216
+ _startCrashPoll(server) {
217
+ this._stopCrashPoll();
218
+ const interval = Number(getCompatEnv('CRASH_POLL_MS')) || CRASH_POLL_INTERVAL;
219
+ this._crashPoll = setInterval(() => {
220
+ if (this.server !== server) { this._stopCrashPoll(); return; }
221
+ if (!this._isProcessAlive(server.goPid)) {
222
+ this._stopCrashPoll();
223
+ this._onServerCrash(null);
224
+ }
225
+ }, interval);
226
+ if (this._crashPoll.unref) { this._crashPoll.unref(); }
227
+ }
228
+
229
+ _stopCrashPoll() {
230
+ if (this._crashPoll) { clearInterval(this._crashPoll); this._crashPoll = null; }
231
+ }
232
+
172
233
  /**
173
234
  * Handle a server crash: log, notify sessions, then schedule restart.
174
235
  *
175
236
  * @param {number} exitCode - Process exit code from the crashed server
176
237
  */
177
238
  _onServerCrash(exitCode) {
239
+ this._stopCrashPoll();
178
240
  this.logger.error?.('Shared server crashed', { exitCode });
179
241
  for (const [id] of this._sessionWatchdogs) {
180
242
  this.logger.warn?.('Session interrupted by server crash', { sessionId: id });
181
243
  }
182
244
  this.server = null;
183
245
  this.client = null;
184
- setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
246
+ this._restartTimer = setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
247
+ if (this._restartTimer.unref) { this._restartTimer.unref(); }
185
248
  }
186
249
 
187
250
  /**