@hackerai/local 0.5.1 → 0.6.0

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/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * HackerAI Local Sandbox Client
5
5
  *
6
- * Connects to HackerAI backend via Convex and executes commands
7
- * on the local machine (either in Docker or directly on the host OS).
6
+ * Connects to HackerAI backend via Convex for connection lifecycle
7
+ * and uses Centrifugo for real-time command relay and streaming output.
8
8
  *
9
9
  * Usage:
10
10
  * npx @hackerai/local --token TOKEN --name "My Laptop"
@@ -15,12 +15,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  const browser_1 = require("convex/browser");
18
+ const centrifuge_1 = require("centrifuge");
18
19
  const child_process_1 = require("child_process");
19
20
  const os_1 = __importDefault(require("os"));
20
21
  const utils_1 = require("./utils");
21
22
  const DEFAULT_SHELL = (0, utils_1.getDefaultShell)(os_1.default.platform());
22
23
  // Idle timeout: auto-terminate after 1 hour without commands
23
24
  const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
25
+ // Idle check interval: check every 5 minutes
26
+ const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
24
27
  /**
25
28
  * Runs a shell command using spawn for better output control.
26
29
  * Collects stdout/stderr and handles timeouts gracefully.
@@ -64,7 +67,7 @@ function runShellCommand(command, options = {}) {
64
67
  proc.on("close", (code) => {
65
68
  if (timeoutId)
66
69
  clearTimeout(timeoutId);
67
- // Final truncation to fit Convex limits
70
+ // Final truncation
68
71
  const truncatedStdout = (0, utils_1.truncateOutput)(stdout, maxOutputSize);
69
72
  const truncatedStderr = (0, utils_1.truncateOutput)(stderr, maxOutputSize);
70
73
  if (killed) {
@@ -113,11 +116,8 @@ const DEFAULT_IMAGE = "hackerai/sandbox";
113
116
  const api = {
114
117
  localSandbox: {
115
118
  connect: "localSandbox:connect",
116
- heartbeat: "localSandbox:heartbeat",
117
119
  disconnect: "localSandbox:disconnect",
118
- getPendingCommands: "localSandbox:getPendingCommands",
119
- markCommandExecuting: "localSandbox:markCommandExecuting",
120
- submitResult: "localSandbox:submitResult",
120
+ refreshCentrifugoToken: "localSandbox:refreshCentrifugoToken",
121
121
  },
122
122
  };
123
123
  // ANSI color codes for terminal output
@@ -132,19 +132,19 @@ const chalk = {
132
132
  };
133
133
  class LocalSandboxClient {
134
134
  config;
135
- convex;
135
+ convexHttp;
136
+ centrifuge;
137
+ subscription;
136
138
  containerId;
137
- containerShell = "/bin/bash"; // Detected shell, defaults to bash
139
+ containerShell = "/bin/bash";
138
140
  userId;
139
141
  connectionId;
140
- session;
141
- heartbeatInterval;
142
- commandSubscription;
143
142
  isShuttingDown = false;
144
143
  lastActivityTime;
144
+ idleCheckInterval;
145
145
  constructor(config) {
146
146
  this.config = config;
147
- this.convex = new browser_1.ConvexClient(config.convexUrl);
147
+ this.convexHttp = new browser_1.ConvexHttpClient(config.convexUrl);
148
148
  this.lastActivityTime = Date.now();
149
149
  }
150
150
  async start() {
@@ -160,7 +160,6 @@ class LocalSandboxClient {
160
160
  console.log(chalk.green("✓ Docker is available"));
161
161
  this.containerId = await this.createContainer();
162
162
  console.log(chalk.green(`✓ Container: ${this.containerId.slice(0, 12)}`));
163
- // Detect available shell (bash or sh fallback for Alpine/minimal images)
164
163
  await this.detectContainerShell();
165
164
  }
166
165
  else {
@@ -169,8 +168,6 @@ class LocalSandboxClient {
169
168
  await this.connect();
170
169
  }
171
170
  getContainerName() {
172
- // Generate a predictable container name for --persist mode
173
- // Sanitize the connection name to be docker-compatible
174
171
  const sanitized = this.config.name
175
172
  .toLowerCase()
176
173
  .replace(/[^a-z0-9-]/g, "-")
@@ -179,7 +176,6 @@ class LocalSandboxClient {
179
176
  return `hackerai-sandbox-${sanitized || "default"}`;
180
177
  }
181
178
  async findExistingContainer(containerName) {
182
- // Check if container with this name exists
183
179
  const result = await runShellCommand(`docker ps -a --filter "name=^${containerName}$" --format "{{.ID}}|{{.State}}"`, { timeout: 5000 });
184
180
  if (result.exitCode !== 0 || !result.stdout.trim()) {
185
181
  return null;
@@ -188,7 +184,6 @@ class LocalSandboxClient {
188
184
  return { id, running: state === "running" };
189
185
  }
190
186
  async createContainer() {
191
- // In persist mode, try to reuse existing container
192
187
  if (this.config.persist) {
193
188
  const containerName = this.getContainerName();
194
189
  const existing = await this.findExistingContainer(containerName);
@@ -198,14 +193,12 @@ class LocalSandboxClient {
198
193
  return existing.id;
199
194
  }
200
195
  else {
201
- // Container exists but stopped - start it
202
196
  console.log(chalk.blue(`Starting existing container: ${containerName}`));
203
197
  const startResult = await runShellCommand(`docker start ${existing.id}`, { timeout: 30000 });
204
198
  if (startResult.exitCode === 0) {
205
199
  console.log(chalk.green(`✓ Container started: ${containerName}`));
206
200
  return existing.id;
207
201
  }
208
- // If start failed, remove and create fresh
209
202
  console.log(chalk.yellow(`⚠️ Failed to start, creating new container...`));
210
203
  await runShellCommand(`docker rm -f ${existing.id}`, {
211
204
  timeout: 5000,
@@ -230,7 +223,6 @@ class LocalSandboxClient {
230
223
  process.exit(1);
231
224
  }
232
225
  console.log(chalk.blue("Creating Docker container..."));
233
- // Build docker run command with capabilities for penetration testing tools
234
226
  const dockerCommand = (0, utils_1.buildDockerRunCommand)({
235
227
  image: DEFAULT_IMAGE,
236
228
  containerName: this.config.persist ? this.getContainerName() : undefined,
@@ -261,23 +253,15 @@ class LocalSandboxClient {
261
253
  }
262
254
  return "Docker";
263
255
  }
264
- /**
265
- * Detects which shell is available in the container.
266
- * Tries bash first, falls back to sh if bash is not available.
267
- * This handles Alpine/BusyBox images that don't have bash installed.
268
- */
269
256
  async detectContainerShell() {
270
257
  if (!this.containerId)
271
258
  return;
272
- // Try to detect available shell using 'command -v' (POSIX compliant)
273
- // We use 'sh' to run the detection since it's guaranteed to exist
274
259
  const result = await runShellCommand(`docker exec ${this.containerId} sh -c 'command -v bash || command -v sh || echo /bin/sh'`, { timeout: 5000 });
275
260
  if (result.exitCode === 0) {
276
261
  this.containerShell = (0, utils_1.parseShellDetectionOutput)(result.stdout);
277
262
  console.log(chalk.green(`✓ Shell: ${this.containerShell}`));
278
263
  }
279
264
  else {
280
- // Fallback to /bin/sh if detection failed
281
265
  this.containerShell = "/bin/sh";
282
266
  console.log(chalk.yellow(`⚠️ Shell detection failed, using ${this.containerShell}`));
283
267
  }
@@ -285,7 +269,7 @@ class LocalSandboxClient {
285
269
  async connect() {
286
270
  console.log(chalk.blue("Connecting to HackerAI..."));
287
271
  try {
288
- const result = (await this.convex.mutation(api.localSandbox.connect, {
272
+ const result = (await this.convexHttp.mutation(api.localSandbox.connect, {
289
273
  token: this.config.token,
290
274
  connectionName: this.config.name,
291
275
  containerId: this.containerId,
@@ -293,18 +277,19 @@ class LocalSandboxClient {
293
277
  mode: this.getMode(),
294
278
  osInfo: this.config.dangerous ? this.getOsInfo() : undefined,
295
279
  }));
296
- if (!result.success || !result.session) {
280
+ if (!result.success ||
281
+ !result.centrifugoToken ||
282
+ !result.centrifugoWsUrl) {
297
283
  throw new Error(result.error || "Authentication failed");
298
284
  }
299
285
  this.userId = result.userId;
300
286
  this.connectionId = result.connectionId;
301
- this.session = result.session;
302
287
  console.log(chalk.green("✓ Authenticated"));
303
288
  console.log(chalk.bold(chalk.green("🎉 Local sandbox is ready!")));
304
289
  console.log(chalk.gray(`Connection: ${this.connectionId}`));
305
290
  console.log(chalk.gray(`Mode: ${this.getModeDisplay()}${this.config.persist ? " (persistent)" : ""}`));
306
- this.startHeartbeat();
307
- this.startCommandSubscription();
291
+ this.setupCentrifugo(result.centrifugoWsUrl, result.centrifugoToken);
292
+ this.startIdleCheck();
308
293
  }
309
294
  catch (error) {
310
295
  const err = error;
@@ -318,52 +303,89 @@ class LocalSandboxClient {
318
303
  process.exit(1);
319
304
  }
320
305
  }
321
- startCommandSubscription() {
322
- if (!this.connectionId || !this.userId || !this.session)
323
- return;
324
- // Use Convex subscription for real-time command updates
325
- this.commandSubscription = this.convex.onUpdate(api.localSandbox.getPendingCommands, {
326
- connectionId: this.connectionId,
327
- // Pass signed session for secure verification without DB lookups
328
- session: {
329
- userId: this.userId,
330
- expiresAt: this.session.expiresAt,
331
- signature: this.session.signature,
306
+ setupCentrifugo(wsUrl, initialToken) {
307
+ this.centrifuge = new centrifuge_1.Centrifuge(wsUrl, {
308
+ token: initialToken,
309
+ getToken: async () => {
310
+ if (!this.connectionId) {
311
+ throw new Error("Cannot refresh token: connectionId is null");
312
+ }
313
+ try {
314
+ const result = (await this.convexHttp.mutation(api.localSandbox.refreshCentrifugoToken, {
315
+ token: this.config.token,
316
+ connectionId: this.connectionId,
317
+ }));
318
+ return result.centrifugoToken;
319
+ }
320
+ catch (error) {
321
+ console.error(chalk.red("Failed to refresh Centrifugo token:"), error);
322
+ throw error;
323
+ }
332
324
  },
333
- }, async (data) => {
325
+ });
326
+ const channel = `sandbox:user#${this.userId}`;
327
+ this.subscription = this.centrifuge.newSubscription(channel);
328
+ this.subscription.on("publication", (ctx) => {
334
329
  if (this.isShuttingDown)
335
330
  return;
336
- // Handle session auth errors - client needs to re-authenticate
337
- if (data?.authError) {
338
- console.debug("Session expired or invalid, will refresh on next heartbeat");
339
- return;
331
+ const message = ctx.data;
332
+ if (message.type === "command") {
333
+ if (message.targetConnectionId &&
334
+ message.targetConnectionId !== this.connectionId) {
335
+ return;
336
+ }
337
+ this.lastActivityTime = Date.now();
338
+ this.handleCommand(message).catch((error) => {
339
+ const errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
340
+ console.error(chalk.red(`Error handling command: ${errorMsg}`));
341
+ });
340
342
  }
341
- if (!data?.commands)
342
- return;
343
- for (const cmd of data.commands) {
344
- await this.executeCommand(cmd);
343
+ });
344
+ this.centrifuge.on("disconnected", (ctx) => {
345
+ if (!this.isShuttingDown) {
346
+ const isConnectionLimit = ctx.reason?.includes("connection limit") || ctx.code === 4503;
347
+ if (isConnectionLimit) {
348
+ console.error(chalk.red("❌ Connection limit reached. The server has too many active connections."));
349
+ console.error(chalk.yellow("Please try again later or contact support."));
350
+ this.cleanup().then(() => process.exit(1));
351
+ }
352
+ else {
353
+ console.log(chalk.yellow(`⚠️ Disconnected from Centrifugo: ${ctx.reason}`));
354
+ }
345
355
  }
346
356
  });
357
+ this.centrifuge.on("connected", () => {
358
+ console.log(chalk.green("✓ Connected to command relay"));
359
+ });
360
+ this.subscription.subscribe();
361
+ this.centrifuge.connect();
347
362
  }
348
- async executeCommand(cmd) {
349
- const { command_id, command, env, cwd, timeout, background, display_name } = cmd;
350
- const startTime = Date.now();
351
- // Update activity time to prevent idle timeout
352
- this.lastActivityTime = Date.now();
363
+ async publishToChannel(data) {
364
+ if (!this.subscription) {
365
+ console.error(chalk.red("Cannot publish: no active subscription"));
366
+ return;
367
+ }
368
+ try {
369
+ await this.subscription.publish(data);
370
+ }
371
+ catch (err) {
372
+ const msg = err instanceof Error ? err.message : JSON.stringify(err);
373
+ console.error(chalk.red(`Publish failed: ${msg}`));
374
+ throw err;
375
+ }
376
+ }
377
+ async handleCommand(msg) {
378
+ const { commandId, command, env, cwd, timeout, background, displayName } = msg;
353
379
  // Determine what to show in console:
354
- // - display_name === "" (empty string): hide command entirely
355
- // - display_name === "something": show that instead of command
356
- // - display_name === undefined: show actual command
357
- const shouldShow = display_name !== "";
358
- const displayText = display_name || command;
380
+ // - displayName === "" (empty string): hide command entirely
381
+ // - displayName === "something": show that instead of command
382
+ // - displayName === undefined: show actual command
383
+ const shouldShow = displayName !== "";
384
+ const displayText = displayName || command;
359
385
  if (shouldShow) {
360
386
  console.log(chalk.cyan(`▶ ${background ? "[BG] " : ""}${displayText}`));
361
387
  }
362
388
  try {
363
- await this.convex.mutation(api.localSandbox.markCommandExecuting, {
364
- token: this.config.token,
365
- commandId: command_id,
366
- });
367
389
  let fullCommand = command;
368
390
  if (cwd && cwd.trim() !== "") {
369
391
  fullCommand = `cd "${cwd}" 2>/dev/null && ${fullCommand}`;
@@ -371,7 +393,6 @@ class LocalSandboxClient {
371
393
  if (env) {
372
394
  const envString = Object.entries(env)
373
395
  .map(([k, v]) => {
374
- // Escape quotes, backticks, and $ to prevent shell injection
375
396
  const escaped = v
376
397
  .replace(/\\/g, "\\\\")
377
398
  .replace(/"/g, '\\"')
@@ -382,93 +403,152 @@ class LocalSandboxClient {
382
403
  .join("; ");
383
404
  fullCommand = `${envString}; ${fullCommand}`;
384
405
  }
385
- // Handle background mode - spawn and return immediately with PID
386
406
  if (background) {
387
407
  const pid = await this.spawnBackground(fullCommand);
388
- const duration = Date.now() - startTime;
389
- await this.convex.mutation(api.localSandbox.submitResult, {
390
- commandId: command_id,
391
- token: this.config.token,
392
- stdout: "",
393
- stderr: "",
408
+ await this.publishToChannel({
409
+ type: "exit",
410
+ commandId,
394
411
  exitCode: 0,
395
412
  pid,
396
- duration,
397
413
  });
398
414
  console.log(chalk.green(`✓ Background process started with PID: ${pid}`));
399
415
  return;
400
416
  }
401
- let result;
417
+ await this.streamCommand(commandId, fullCommand, timeout, shouldShow, displayText);
418
+ }
419
+ catch (error) {
420
+ const message = error instanceof Error ? error.message : String(error);
421
+ await this.publishToChannel({
422
+ type: "error",
423
+ commandId,
424
+ message: (0, utils_1.truncateOutput)(message),
425
+ });
426
+ console.log(chalk.red(`✗ ${displayText}: ${message}`));
427
+ }
428
+ }
429
+ async streamCommand(commandId, fullCommand, timeout, shouldShow, displayText) {
430
+ const startTime = Date.now();
431
+ const commandTimeout = timeout ?? 30000;
432
+ return new Promise((resolve) => {
433
+ let killed = false;
434
+ let timeoutId;
435
+ let accumulatedStdout = "";
436
+ let accumulatedStderr = "";
437
+ let proc;
402
438
  if (this.config.dangerous) {
403
- result = await runShellCommand(fullCommand, {
404
- timeout: timeout ?? 30000,
405
- // Use platform-appropriate shell (powershell on Windows, bash on Unix)
439
+ proc = (0, child_process_1.spawn)(DEFAULT_SHELL.shell, [DEFAULT_SHELL.shellFlag, fullCommand], {
440
+ stdio: ["ignore", "pipe", "pipe"],
406
441
  });
407
442
  }
408
443
  else {
409
- // Use single quotes to prevent host shell from interpreting $(), backticks, etc.
410
- // This ensures ALL command execution happens inside the Docker container
411
444
  const escapedCommand = fullCommand.replace(/'/g, "'\\''");
412
- // Extract shell name (e.g., "bash" from "/bin/bash" or "/usr/bin/bash")
413
445
  const shellName = this.containerShell.split("/").pop() || "sh";
414
- result = await runShellCommand(`docker exec ${this.containerId} ${shellName} -c '${escapedCommand}'`, { timeout: timeout ?? 30000 });
446
+ proc = (0, child_process_1.spawn)("docker", ["exec", this.containerId, shellName, "-c", escapedCommand], { stdio: ["ignore", "pipe", "pipe"] });
415
447
  }
416
- const duration = Date.now() - startTime;
417
- await this.convex.mutation(api.localSandbox.submitResult, {
418
- commandId: command_id,
419
- token: this.config.token,
420
- stdout: result.stdout,
421
- stderr: result.stderr,
422
- exitCode: result.exitCode,
423
- duration,
448
+ if (commandTimeout > 0) {
449
+ timeoutId = setTimeout(() => {
450
+ killed = true;
451
+ proc.kill("SIGTERM");
452
+ setTimeout(() => {
453
+ if (!proc.killed) {
454
+ proc.kill("SIGKILL");
455
+ }
456
+ }, 2000);
457
+ }, commandTimeout);
458
+ }
459
+ proc.stdout?.on("data", (data) => {
460
+ const chunk = data.toString();
461
+ accumulatedStdout += chunk;
462
+ this.publishToChannel({
463
+ type: "stdout",
464
+ commandId,
465
+ data: chunk,
466
+ }).catch((err) => {
467
+ console.error(chalk.red(`[ERROR] Failed to publish stdout: ${err instanceof Error ? err.message : String(err)}`));
468
+ });
424
469
  });
425
- if (shouldShow) {
426
- if (result.exitCode === 0) {
427
- console.log(chalk.green(`✓ ${displayText} ${chalk.gray(`(${duration}ms)`)}`));
470
+ proc.stderr?.on("data", (data) => {
471
+ const chunk = data.toString();
472
+ accumulatedStderr += chunk;
473
+ this.publishToChannel({
474
+ type: "stderr",
475
+ commandId,
476
+ data: chunk,
477
+ }).catch((err) => {
478
+ console.error(chalk.red(`[ERROR] Failed to publish stderr: ${err instanceof Error ? err.message : String(err)}`));
479
+ });
480
+ });
481
+ proc.on("close", (code) => {
482
+ if (timeoutId)
483
+ clearTimeout(timeoutId);
484
+ const duration = Date.now() - startTime;
485
+ const exitCode = killed ? 124 : (code ?? 1);
486
+ if (killed) {
487
+ this.publishToChannel({
488
+ type: "stderr",
489
+ commandId,
490
+ data: "\n[Command timed out and was terminated]",
491
+ }).catch((err) => {
492
+ console.error(chalk.red(`[ERROR] Failed to publish timeout stderr: ${err instanceof Error ? err.message : String(err)}`));
493
+ });
428
494
  }
429
- else {
430
- console.log(chalk.red(`✗ ${displayText} ${chalk.gray(`(exit ${result.exitCode}, ${duration}ms)`)}`));
431
- if (result.stderr.trim()) {
432
- // Indent each line of stderr for readability
433
- const indented = result.stderr
434
- .trim()
435
- .split("\n")
436
- .map((l) => ` ${l}`)
437
- .join("\n");
438
- console.log(chalk.red(indented));
495
+ this.publishToChannel({
496
+ type: "exit",
497
+ commandId,
498
+ exitCode,
499
+ }).catch((err) => {
500
+ console.error(chalk.red(`[CRITICAL] Failed to publish EXIT message: ${err instanceof Error ? err.message : String(err)}`));
501
+ });
502
+ if (shouldShow) {
503
+ if (exitCode === 0) {
504
+ console.log(chalk.green(`✓ ${displayText} ${chalk.gray(`(${duration}ms)`)}`));
505
+ }
506
+ else {
507
+ console.log(chalk.red(`✗ ${displayText} ${chalk.gray(`(exit ${exitCode}, ${duration}ms)`)}`));
508
+ if (accumulatedStderr.trim()) {
509
+ const indented = accumulatedStderr
510
+ .trim()
511
+ .split("\n")
512
+ .map((l) => ` ${l}`)
513
+ .join("\n");
514
+ console.log(chalk.red(indented));
515
+ }
439
516
  }
440
517
  }
441
- }
442
- }
443
- catch (error) {
444
- const duration = Date.now() - startTime;
445
- const message = error instanceof Error ? error.message : String(error);
446
- await this.convex.mutation(api.localSandbox.submitResult, {
447
- commandId: command_id,
448
- token: this.config.token,
449
- stdout: "",
450
- stderr: (0, utils_1.truncateOutput)(message),
451
- exitCode: 1,
452
- duration,
518
+ resolve();
453
519
  });
454
- console.log(chalk.red(`✗ ${displayText} ${chalk.gray(`(${duration}ms)`)}: ${message}`));
455
- }
520
+ proc.on("error", (error) => {
521
+ if (timeoutId)
522
+ clearTimeout(timeoutId);
523
+ this.publishToChannel({
524
+ type: "error",
525
+ commandId,
526
+ message: error.message,
527
+ }).catch((err) => {
528
+ console.error(chalk.red(`[ERROR] Failed to publish error message: ${err instanceof Error ? err.message : String(err)}`));
529
+ });
530
+ this.publishToChannel({
531
+ type: "exit",
532
+ commandId,
533
+ exitCode: 1,
534
+ }).catch((err) => {
535
+ console.error(chalk.red(`[CRITICAL] Failed to publish EXIT after process error: ${err instanceof Error ? err.message : String(err)}`));
536
+ });
537
+ resolve();
538
+ });
539
+ });
456
540
  }
457
541
  async spawnBackground(fullCommand) {
458
542
  if (this.config.dangerous) {
459
- // Spawn directly on host in dangerous mode using platform-appropriate shell
460
543
  const child = (0, child_process_1.spawn)(DEFAULT_SHELL.shell, [DEFAULT_SHELL.shellFlag, fullCommand], {
461
- detached: os_1.default.platform() !== "win32", // detached doesn't work the same on Windows
544
+ detached: os_1.default.platform() !== "win32",
462
545
  stdio: "ignore",
463
546
  });
464
547
  child.unref();
465
548
  return child.pid ?? -1;
466
549
  }
467
550
  else {
468
- // For Docker, start the process in background inside container and get its PID
469
- // Using 'nohup command & echo $!' to get the container process PID
470
551
  const escapedCommand = fullCommand.replace(/'/g, "'\\''");
471
- // Extract shell name (e.g., "bash" from "/bin/bash" or "/usr/bin/bash")
472
552
  const shellName = this.containerShell.split("/").pop() || "sh";
473
553
  const result = await runShellCommand(`docker exec ${this.containerId} ${shellName} -c 'nohup ${escapedCommand} > /dev/null 2>&1 & echo $!'`, { timeout: 5000 });
474
554
  if (result.exitCode === 0 && result.stdout.trim()) {
@@ -478,74 +558,36 @@ class LocalSandboxClient {
478
558
  return -1;
479
559
  }
480
560
  }
481
- scheduleNextHeartbeat() {
482
- // Add jitter (±10s) to prevent thundering herd when multiple clients connect
483
- const baseInterval = 60000; // 1 minute
484
- const jitter = Math.floor(Math.random() * 20000) - 10000; // -10000 to +10000
485
- const interval = baseInterval + jitter;
486
- this.heartbeatInterval = setTimeout(async () => {
487
- if (this.connectionId && !this.isShuttingDown) {
488
- // Check for idle timeout
489
- const idleTime = Date.now() - this.lastActivityTime;
490
- if (idleTime >= IDLE_TIMEOUT_MS) {
491
- const idleMinutes = Math.floor(idleTime / 60000);
492
- console.log(chalk.yellow(`\n⏰ Idle timeout: No commands received for ${idleMinutes} minutes`));
493
- console.log(chalk.yellow("Auto-terminating to save resources..."));
494
- await this.cleanup();
495
- process.exit(0);
496
- }
497
- try {
498
- const result = (await this.convex.mutation(api.localSandbox.heartbeat, {
499
- token: this.config.token,
500
- connectionId: this.connectionId,
501
- }));
502
- if (!result.success) {
503
- console.log(chalk.red("\n❌ Connection invalidated (token may have been regenerated)"));
504
- console.log(chalk.yellow("Shutting down..."));
505
- await this.cleanup();
506
- process.exit(1);
507
- }
508
- // Refresh session and restart subscription with new session
509
- if (result.session) {
510
- this.session = result.session;
511
- this.restartCommandSubscription();
512
- }
513
- }
514
- catch (error) {
515
- const message = error instanceof Error ? error.message : String(error);
516
- console.debug(`Heartbeat error (will retry): ${message}`);
517
- }
561
+ startIdleCheck() {
562
+ this.idleCheckInterval = setInterval(() => {
563
+ const idleTime = Date.now() - this.lastActivityTime;
564
+ if (idleTime >= IDLE_TIMEOUT_MS) {
565
+ const idleMinutes = Math.floor(idleTime / 60000);
566
+ console.log(chalk.yellow(`\n⏰ Idle timeout: No commands received for ${idleMinutes} minutes`));
567
+ console.log(chalk.yellow("Auto-terminating to save resources..."));
568
+ this.cleanup().then(() => process.exit(0));
518
569
  }
519
- // Schedule next heartbeat with fresh jitter
520
- if (!this.isShuttingDown) {
521
- this.scheduleNextHeartbeat();
522
- }
523
- }, interval);
524
- }
525
- startHeartbeat() {
526
- this.scheduleNextHeartbeat();
570
+ }, IDLE_CHECK_INTERVAL_MS);
527
571
  }
528
- stopHeartbeat() {
529
- if (this.heartbeatInterval) {
530
- clearTimeout(this.heartbeatInterval);
531
- this.heartbeatInterval = undefined;
572
+ stopIdleCheck() {
573
+ if (this.idleCheckInterval) {
574
+ clearInterval(this.idleCheckInterval);
575
+ this.idleCheckInterval = undefined;
532
576
  }
533
577
  }
534
- stopCommandSubscription() {
535
- if (this.commandSubscription) {
536
- this.commandSubscription();
537
- this.commandSubscription = undefined;
538
- }
539
- }
540
- restartCommandSubscription() {
541
- this.stopCommandSubscription();
542
- this.startCommandSubscription();
543
- }
544
578
  async cleanup() {
545
579
  console.log(chalk.blue("\n🧹 Cleaning up..."));
546
580
  this.isShuttingDown = true;
547
- this.stopHeartbeat();
548
- this.stopCommandSubscription();
581
+ this.stopIdleCheck();
582
+ // Disconnect Centrifugo
583
+ if (this.subscription) {
584
+ this.subscription.unsubscribe();
585
+ this.subscription = undefined;
586
+ }
587
+ if (this.centrifuge) {
588
+ this.centrifuge.disconnect();
589
+ this.centrifuge = undefined;
590
+ }
549
591
  // Set up force-exit timeout (5 seconds)
550
592
  const forceExitTimeout = setTimeout(() => {
551
593
  console.log(chalk.yellow("⚠️ Force exiting after 5 second timeout..."));
@@ -554,7 +596,7 @@ class LocalSandboxClient {
554
596
  try {
555
597
  if (this.connectionId) {
556
598
  try {
557
- await this.convex.mutation(api.localSandbox.disconnect, {
599
+ await this.convexHttp.mutation(api.localSandbox.disconnect, {
558
600
  token: this.config.token,
559
601
  connectionId: this.connectionId,
560
602
  });
@@ -582,8 +624,6 @@ class LocalSandboxClient {
582
624
  }
583
625
  }
584
626
  }
585
- // Close the Convex client to clean up WebSocket connection
586
- await this.convex.close();
587
627
  }
588
628
  finally {
589
629
  clearTimeout(forceExitTimeout);