@runloop/rl-cli 0.1.1 → 0.2.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.
Files changed (96) hide show
  1. package/README.md +54 -0
  2. package/dist/cli.js +73 -60
  3. package/dist/commands/auth.js +0 -1
  4. package/dist/commands/blueprint/create.js +31 -83
  5. package/dist/commands/blueprint/get.js +29 -34
  6. package/dist/commands/blueprint/list.js +215 -213
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/blueprint/preview.js +42 -38
  9. package/dist/commands/config.js +117 -0
  10. package/dist/commands/devbox/create.js +120 -40
  11. package/dist/commands/devbox/delete.js +17 -33
  12. package/dist/commands/devbox/download.js +29 -43
  13. package/dist/commands/devbox/exec.js +22 -39
  14. package/dist/commands/devbox/execAsync.js +20 -37
  15. package/dist/commands/devbox/get.js +13 -35
  16. package/dist/commands/devbox/getAsync.js +12 -34
  17. package/dist/commands/devbox/list.js +241 -402
  18. package/dist/commands/devbox/logs.js +20 -38
  19. package/dist/commands/devbox/read.js +29 -43
  20. package/dist/commands/devbox/resume.js +13 -35
  21. package/dist/commands/devbox/rsync.js +26 -78
  22. package/dist/commands/devbox/scp.js +25 -79
  23. package/dist/commands/devbox/sendStdin.js +41 -0
  24. package/dist/commands/devbox/shutdown.js +13 -35
  25. package/dist/commands/devbox/ssh.js +45 -78
  26. package/dist/commands/devbox/suspend.js +13 -35
  27. package/dist/commands/devbox/tunnel.js +36 -88
  28. package/dist/commands/devbox/upload.js +28 -36
  29. package/dist/commands/devbox/write.js +29 -44
  30. package/dist/commands/mcp-install.js +4 -3
  31. package/dist/commands/menu.js +24 -66
  32. package/dist/commands/object/delete.js +12 -34
  33. package/dist/commands/object/download.js +26 -74
  34. package/dist/commands/object/get.js +12 -34
  35. package/dist/commands/object/list.js +15 -93
  36. package/dist/commands/object/upload.js +35 -96
  37. package/dist/commands/snapshot/create.js +23 -39
  38. package/dist/commands/snapshot/delete.js +17 -33
  39. package/dist/commands/snapshot/get.js +16 -0
  40. package/dist/commands/snapshot/list.js +309 -80
  41. package/dist/commands/snapshot/status.js +12 -34
  42. package/dist/components/ActionsPopup.js +63 -39
  43. package/dist/components/Breadcrumb.js +10 -52
  44. package/dist/components/DevboxActionsMenu.js +182 -110
  45. package/dist/components/DevboxCreatePage.js +12 -7
  46. package/dist/components/DevboxDetailPage.js +76 -28
  47. package/dist/components/ErrorBoundary.js +29 -0
  48. package/dist/components/ErrorMessage.js +10 -2
  49. package/dist/components/Header.js +12 -4
  50. package/dist/components/InteractiveSpawn.js +94 -0
  51. package/dist/components/MainMenu.js +36 -32
  52. package/dist/components/MetadataDisplay.js +4 -4
  53. package/dist/components/OperationsMenu.js +1 -1
  54. package/dist/components/ResourceActionsMenu.js +4 -4
  55. package/dist/components/ResourceListView.js +46 -34
  56. package/dist/components/Spinner.js +7 -2
  57. package/dist/components/StatusBadge.js +1 -1
  58. package/dist/components/SuccessMessage.js +12 -2
  59. package/dist/components/Table.js +16 -6
  60. package/dist/hooks/useCursorPagination.js +125 -85
  61. package/dist/hooks/useExitOnCtrlC.js +14 -0
  62. package/dist/hooks/useViewportHeight.js +47 -0
  63. package/dist/mcp/server.js +65 -6
  64. package/dist/router/Router.js +68 -0
  65. package/dist/router/types.js +1 -0
  66. package/dist/screens/BlueprintListScreen.js +7 -0
  67. package/dist/screens/DevboxActionsScreen.js +25 -0
  68. package/dist/screens/DevboxCreateScreen.js +11 -0
  69. package/dist/screens/DevboxDetailScreen.js +60 -0
  70. package/dist/screens/DevboxListScreen.js +23 -0
  71. package/dist/screens/LogsSessionScreen.js +49 -0
  72. package/dist/screens/MenuScreen.js +23 -0
  73. package/dist/screens/SSHSessionScreen.js +55 -0
  74. package/dist/screens/SnapshotListScreen.js +7 -0
  75. package/dist/services/blueprintService.js +105 -0
  76. package/dist/services/devboxService.js +215 -0
  77. package/dist/services/snapshotService.js +81 -0
  78. package/dist/store/blueprintStore.js +89 -0
  79. package/dist/store/devboxStore.js +105 -0
  80. package/dist/store/index.js +7 -0
  81. package/dist/store/navigationStore.js +101 -0
  82. package/dist/store/snapshotStore.js +87 -0
  83. package/dist/utils/CommandExecutor.js +53 -24
  84. package/dist/utils/client.js +0 -2
  85. package/dist/utils/config.js +20 -90
  86. package/dist/utils/interactiveCommand.js +3 -2
  87. package/dist/utils/logFormatter.js +162 -0
  88. package/dist/utils/memoryMonitor.js +85 -0
  89. package/dist/utils/output.js +150 -59
  90. package/dist/utils/screen.js +23 -0
  91. package/dist/utils/ssh.js +3 -1
  92. package/dist/utils/sshSession.js +5 -29
  93. package/dist/utils/terminalDetection.js +97 -0
  94. package/dist/utils/terminalSync.js +39 -0
  95. package/dist/utils/theme.js +147 -13
  96. package/package.json +16 -13
@@ -5,6 +5,8 @@
5
5
  import { render } from "ink";
6
6
  import { getClient } from "./client.js";
7
7
  import { shouldUseNonInteractiveOutput, outputList, outputResult, } from "./output.js";
8
+ import { enableSynchronousUpdates, disableSynchronousUpdates, } from "./terminalSync.js";
9
+ import { exitAlternateScreenBuffer, enterAlternateScreenBuffer, } from "./screen.js";
8
10
  import YAML from "yaml";
9
11
  export class CommandExecutor {
10
12
  options;
@@ -32,13 +34,16 @@ export class CommandExecutor {
32
34
  return;
33
35
  }
34
36
  // Interactive mode
35
- // Enter alternate screen buffer
36
- process.stdout.write("\x1b[?1049h");
37
- console.clear();
38
- const { waitUntilExit } = render(renderUI());
37
+ // Enter alternate screen buffer (this automatically clears the screen)
38
+ enableSynchronousUpdates();
39
+ const { waitUntilExit } = render(renderUI(), {
40
+ patchConsole: false,
41
+ exitOnCtrlC: false,
42
+ });
39
43
  await waitUntilExit();
40
44
  // Exit alternate screen buffer
41
- process.stdout.write("\x1b[?1049l");
45
+ disableSynchronousUpdates();
46
+ exitAlternateScreenBuffer();
42
47
  }
43
48
  /**
44
49
  * Execute a create/action command with automatic format handling
@@ -55,13 +60,17 @@ export class CommandExecutor {
55
60
  return;
56
61
  }
57
62
  // Interactive mode
58
- // Enter alternate screen buffer
59
- process.stdout.write("\x1b[?1049h");
60
- console.clear();
61
- const { waitUntilExit } = render(renderUI());
63
+ // Enter alternate screen buffer (this automatically clears the screen)
64
+ enterAlternateScreenBuffer();
65
+ enableSynchronousUpdates();
66
+ const { waitUntilExit } = render(renderUI(), {
67
+ patchConsole: false,
68
+ exitOnCtrlC: false,
69
+ });
62
70
  await waitUntilExit();
63
71
  // Exit alternate screen buffer
64
- process.stdout.write("\x1b[?1049l");
72
+ disableSynchronousUpdates();
73
+ exitAlternateScreenBuffer();
65
74
  }
66
75
  /**
67
76
  * Execute a delete command with automatic format handling
@@ -79,30 +88,50 @@ export class CommandExecutor {
79
88
  }
80
89
  // Interactive mode
81
90
  // Enter alternate screen buffer
82
- process.stdout.write("\x1b[?1049h");
83
- const { waitUntilExit } = render(renderUI());
91
+ enterAlternateScreenBuffer();
92
+ enableSynchronousUpdates();
93
+ const { waitUntilExit } = render(renderUI(), {
94
+ patchConsole: false,
95
+ exitOnCtrlC: false,
96
+ });
84
97
  await waitUntilExit();
85
98
  // Exit alternate screen buffer
86
- process.stdout.write("\x1b[?1049l");
99
+ disableSynchronousUpdates();
100
+ exitAlternateScreenBuffer();
87
101
  }
88
102
  /**
89
103
  * Fetch items from an async iterator with optional filtering and limits
104
+ * IMPORTANT: This method tries to access the page data directly first to avoid
105
+ * auto-pagination issues that can cause memory errors with large datasets.
90
106
  */
91
107
  async fetchFromIterator(iterator, options = {}) {
92
108
  const { filter, limit = 100 } = options;
93
- const items = [];
94
- let count = 0;
95
- for await (const item of iterator) {
96
- if (filter && !filter(item)) {
97
- continue;
98
- }
99
- items.push(item);
100
- count++;
101
- if (count >= limit) {
102
- break;
109
+ let items = [];
110
+ // Try to access page data directly to avoid auto-pagination
111
+ const pageData = iterator.data || iterator.items;
112
+ if (pageData && Array.isArray(pageData)) {
113
+ items = pageData;
114
+ }
115
+ else {
116
+ // Fall back to iteration with limit
117
+ let count = 0;
118
+ for await (const item of iterator) {
119
+ if (filter && !filter(item)) {
120
+ continue;
121
+ }
122
+ items.push(item);
123
+ count++;
124
+ if (count >= limit) {
125
+ break;
126
+ }
103
127
  }
104
128
  }
105
- return items;
129
+ // Apply filter if provided
130
+ if (filter) {
131
+ items = items.filter(filter);
132
+ }
133
+ // Apply limit
134
+ return items.slice(0, limit);
106
135
  }
107
136
  /**
108
137
  * Handle errors consistently across all commands
@@ -24,7 +24,5 @@ export function getClient() {
24
24
  return new Runloop({
25
25
  bearerToken: config.apiKey,
26
26
  baseURL,
27
- timeout: 10000, // 10 seconds instead of default 30 seconds
28
- maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
29
27
  });
30
28
  }
@@ -1,9 +1,7 @@
1
1
  import Conf from "conf";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
- import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync } from "fs";
5
- import { fileURLToPath } from "url";
6
- import { dirname } from "path";
4
+ import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
7
5
  const config = new Conf({
8
6
  projectName: "runloop-cli",
9
7
  });
@@ -33,25 +31,6 @@ export function sshUrl() {
33
31
  export function getCacheDir() {
34
32
  return join(homedir(), ".cache", "rl-cli");
35
33
  }
36
- function getCurrentVersion() {
37
- try {
38
- // First try environment variable (when installed via npm)
39
- if (process.env.npm_package_version) {
40
- return process.env.npm_package_version;
41
- }
42
- // Fall back to reading package.json directly
43
- const __filename = fileURLToPath(import.meta.url);
44
- const __dirname = dirname(__filename);
45
- // When running from dist/, we need to go up two levels to find package.json
46
- const packageJsonPath = join(__dirname, "../../package.json");
47
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
48
- return packageJson.version;
49
- }
50
- catch (error) {
51
- // Ultimate fallback
52
- return "0.1.0";
53
- }
54
- }
55
34
  export function shouldCheckForUpdates() {
56
35
  const cacheDir = getCacheDir();
57
36
  const cacheFile = join(cacheDir, "last_update_check");
@@ -59,13 +38,8 @@ export function shouldCheckForUpdates() {
59
38
  return true;
60
39
  }
61
40
  const stats = statSync(cacheFile);
62
- const hoursSinceUpdate = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60);
63
- return hoursSinceUpdate >= 6;
64
- }
65
- export function hasCachedUpdateInfo() {
66
- const cacheDir = getCacheDir();
67
- const cacheFile = join(cacheDir, "last_update_check");
68
- return existsSync(cacheFile);
41
+ const daysSinceUpdate = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60 * 24);
42
+ return daysSinceUpdate >= 1;
69
43
  }
70
44
  export function updateCheckCache() {
71
45
  const cacheDir = getCacheDir();
@@ -77,67 +51,23 @@ export function updateCheckCache() {
77
51
  // Touch the cache file
78
52
  writeFileSync(cacheFile, "");
79
53
  }
80
- export async function checkForUpdates(force = false) {
81
- const currentVersion = getCurrentVersion();
82
- // Always show cached result if available and not forcing
83
- if (!force && hasCachedUpdateInfo() && !shouldCheckForUpdates()) {
84
- // Show cached update info (we know there's an update available)
85
- console.error(`\n🔄 Update available: ${currentVersion} → 0.1.0\n` +
86
- ` Run: npm install -g @runloop/rl-cli@latest\n\n`);
87
- return;
88
- }
89
- // Only fetch from npm if cache is expired or forcing
90
- if (!force && !shouldCheckForUpdates()) {
91
- return;
92
- }
93
- try {
94
- const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
95
- if (!response.ok) {
96
- if (force) {
97
- console.error("❌ Failed to check for updates\n");
98
- }
99
- return; // Silently fail if we can't check
100
- }
101
- const data = await response.json();
102
- const latestVersion = data.version;
103
- if (force) {
104
- console.error(`Current version: ${currentVersion}\n`);
105
- console.error(`Latest version: ${latestVersion}\n`);
106
- }
107
- if (latestVersion && latestVersion !== currentVersion) {
108
- // Check if current version is older than latest
109
- const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
110
- if (isUpdateAvailable) {
111
- console.error(`\n🔄 Update available: ${currentVersion} → ${latestVersion}\n` +
112
- ` Run: npm install -g @runloop/rl-cli@latest\n\n`);
113
- }
114
- else if (force) {
115
- console.error("✅ You're running the latest version!\n");
116
- }
117
- }
118
- else if (force) {
119
- console.error("✅ You're running the latest version!\n");
120
- }
121
- // Update the cache to indicate we've checked
122
- updateCheckCache();
123
- }
124
- catch (error) {
125
- if (force) {
126
- console.error(`❌ Error checking for updates: ${error}\n`);
127
- }
128
- // Silently fail - don't interrupt the user's workflow
54
+ export function getThemePreference() {
55
+ // Check environment variable first, then fall back to stored config
56
+ const envTheme = process.env.RUNLOOP_THEME?.toLowerCase();
57
+ if (envTheme === "light" || envTheme === "dark" || envTheme === "auto") {
58
+ return envTheme;
129
59
  }
60
+ return config.get("theme") || "auto";
130
61
  }
131
- function compareVersions(version1, version2) {
132
- const v1parts = version1.split('.').map(Number);
133
- const v2parts = version2.split('.').map(Number);
134
- for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
135
- const v1part = v1parts[i] || 0;
136
- const v2part = v2parts[i] || 0;
137
- if (v1part > v2part)
138
- return 1;
139
- if (v1part < v2part)
140
- return -1;
141
- }
142
- return 0;
62
+ export function setThemePreference(theme) {
63
+ config.set("theme", theme);
64
+ }
65
+ export function getDetectedTheme() {
66
+ return config.get("detectedTheme") || null;
67
+ }
68
+ export function setDetectedTheme(theme) {
69
+ config.set("detectedTheme", theme);
70
+ }
71
+ export function clearDetectedTheme() {
72
+ config.delete("detectedTheme");
143
73
  }
@@ -1,14 +1,15 @@
1
+ import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "./screen.js";
1
2
  /**
2
3
  * Wrapper for interactive commands that need alternate screen buffer management
3
4
  */
4
5
  export async function runInteractiveCommand(command) {
5
6
  // Enter alternate screen buffer
6
- process.stdout.write("\x1b[?1049h");
7
+ enterAlternateScreenBuffer();
7
8
  try {
8
9
  await command();
9
10
  }
10
11
  finally {
11
12
  // Exit alternate screen buffer
12
- process.stdout.write("\x1b[?1049l");
13
+ exitAlternateScreenBuffer();
13
14
  }
14
15
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Shared log formatting utilities for both CLI and interactive mode
3
+ */
4
+ import chalk from "chalk";
5
+ // Source abbreviations for consistent display
6
+ const SOURCE_CONFIG = {
7
+ setup_commands: { abbrev: "setup", color: "magenta" },
8
+ entrypoint: { abbrev: "entry", color: "cyan" },
9
+ exec: { abbrev: "exec", color: "green" },
10
+ files: { abbrev: "files", color: "yellow" },
11
+ stats: { abbrev: "stats", color: "gray" },
12
+ };
13
+ const SOURCE_WIDTH = 5;
14
+ /**
15
+ * Format timestamp based on how recent the log is
16
+ */
17
+ export function formatTimestamp(timestampMs) {
18
+ const date = new Date(timestampMs);
19
+ const now = new Date();
20
+ const isToday = date.toDateString() === now.toDateString();
21
+ const isThisYear = date.getFullYear() === now.getFullYear();
22
+ const time = date.toLocaleTimeString("en-US", {
23
+ hour12: false,
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ second: "2-digit",
27
+ });
28
+ const ms = date.getMilliseconds().toString().padStart(3, "0");
29
+ if (isToday) {
30
+ return `${time}.${ms}`;
31
+ }
32
+ else if (isThisYear) {
33
+ const monthDay = date.toLocaleDateString("en-US", {
34
+ month: "short",
35
+ day: "numeric",
36
+ });
37
+ return `${monthDay} ${time}`;
38
+ }
39
+ else {
40
+ const fullDate = date.toLocaleDateString("en-US", {
41
+ year: "numeric",
42
+ month: "short",
43
+ day: "numeric",
44
+ });
45
+ return `${fullDate} ${time}`;
46
+ }
47
+ }
48
+ /**
49
+ * Get log level info (normalized name and color)
50
+ */
51
+ export function getLogLevelInfo(level) {
52
+ const normalized = level.toUpperCase();
53
+ switch (normalized) {
54
+ case "ERROR":
55
+ case "ERR":
56
+ return { name: "ERROR", color: "red" };
57
+ case "WARN":
58
+ case "WARNING":
59
+ return { name: "WARN ", color: "yellow" };
60
+ case "INFO":
61
+ return { name: "INFO ", color: "blue" };
62
+ case "DEBUG":
63
+ return { name: "DEBUG", color: "gray" };
64
+ default:
65
+ return { name: normalized.padEnd(5), color: "gray" };
66
+ }
67
+ }
68
+ /**
69
+ * Get source info (abbreviated name and color)
70
+ */
71
+ export function getSourceInfo(source) {
72
+ if (!source) {
73
+ return { abbrev: "sys".padEnd(SOURCE_WIDTH), color: "gray" };
74
+ }
75
+ const config = SOURCE_CONFIG[source];
76
+ if (config) {
77
+ return { abbrev: config.abbrev.padEnd(SOURCE_WIDTH), color: config.color };
78
+ }
79
+ // Unknown source: truncate/pad to width
80
+ const abbrev = source.length > SOURCE_WIDTH
81
+ ? source.slice(0, SOURCE_WIDTH)
82
+ : source.padEnd(SOURCE_WIDTH);
83
+ return { abbrev, color: "white" };
84
+ }
85
+ /**
86
+ * Parse a log entry into formatted parts (for use in Ink UI)
87
+ */
88
+ export function parseLogEntry(log) {
89
+ const levelInfo = getLogLevelInfo(log.level);
90
+ const sourceInfo = getSourceInfo(log.source);
91
+ return {
92
+ timestamp: formatTimestamp(log.timestamp_ms),
93
+ level: levelInfo.name,
94
+ levelColor: levelInfo.color,
95
+ source: sourceInfo.abbrev,
96
+ sourceColor: sourceInfo.color,
97
+ shellName: log.shell_name || null,
98
+ cmd: log.cmd || null,
99
+ message: log.message || "",
100
+ exitCode: log.exit_code ?? null,
101
+ exitCodeColor: log.exit_code === 0 ? "green" : "red",
102
+ };
103
+ }
104
+ /**
105
+ * Format a log entry as a string with chalk colors (for CLI output)
106
+ */
107
+ export function formatLogEntryString(log) {
108
+ const parts = parseLogEntry(log);
109
+ const result = [];
110
+ // Timestamp (dim)
111
+ result.push(chalk.dim(parts.timestamp));
112
+ // Level (colored, bold for errors)
113
+ const levelChalk = parts.levelColor === "red"
114
+ ? chalk.red.bold
115
+ : parts.levelColor === "yellow"
116
+ ? chalk.yellow.bold
117
+ : parts.levelColor === "blue"
118
+ ? chalk.blue
119
+ : chalk.gray;
120
+ result.push(levelChalk(parts.level));
121
+ // Source (colored, in brackets)
122
+ const sourceChalk = {
123
+ magenta: chalk.magenta,
124
+ cyan: chalk.cyan,
125
+ green: chalk.green,
126
+ yellow: chalk.yellow,
127
+ gray: chalk.gray,
128
+ white: chalk.white,
129
+ }[parts.sourceColor] || chalk.white;
130
+ result.push(sourceChalk(`[${parts.source}]`));
131
+ // Shell name if present
132
+ if (parts.shellName) {
133
+ result.push(chalk.dim(`(${parts.shellName})`));
134
+ }
135
+ // Command if present
136
+ if (parts.cmd) {
137
+ result.push(chalk.cyan("$") + " " + chalk.white(parts.cmd));
138
+ }
139
+ // Message
140
+ if (parts.message) {
141
+ result.push(parts.message);
142
+ }
143
+ // Exit code if present
144
+ if (parts.exitCode !== null) {
145
+ const exitChalk = parts.exitCode === 0 ? chalk.green : chalk.red;
146
+ result.push(exitChalk(`exit=${parts.exitCode}`));
147
+ }
148
+ return result.join(" ");
149
+ }
150
+ /**
151
+ * Format logs for CLI output
152
+ */
153
+ export function formatLogsForCLI(response) {
154
+ const logs = response.logs;
155
+ if (!logs || logs.length === 0) {
156
+ console.log(chalk.dim("No logs available"));
157
+ return;
158
+ }
159
+ for (const log of logs) {
160
+ console.log(formatLogEntryString(log));
161
+ }
162
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Memory Monitor - Track memory usage and manage GC
3
+ * Helps prevent heap exhaustion during navigation
4
+ */
5
+ let lastMemoryUsage = null;
6
+ let gcAttempts = 0;
7
+ const MAX_GC_ATTEMPTS_PER_MINUTE = 5;
8
+ let lastGCReset = Date.now();
9
+ // Memory thresholds (in bytes)
10
+ const HEAP_WARNING_THRESHOLD = 3.5e9; // 3.5 GB
11
+ const HEAP_CRITICAL_THRESHOLD = 4e9; // 4 GB
12
+ export function logMemoryUsage(label) {
13
+ if (process.env.NODE_ENV === "development" || process.env.DEBUG_MEMORY) {
14
+ const current = process.memoryUsage();
15
+ const heapUsedMB = (current.heapUsed / 1024 / 1024).toFixed(2);
16
+ const heapTotalMB = (current.heapTotal / 1024 / 1024).toFixed(2);
17
+ const rssMB = (current.rss / 1024 / 1024).toFixed(2);
18
+ let delta = "";
19
+ if (lastMemoryUsage) {
20
+ const heapDelta = current.heapUsed - lastMemoryUsage.heapUsed;
21
+ const heapDeltaMB = (heapDelta / 1024 / 1024).toFixed(2);
22
+ delta = ` (Δ ${heapDeltaMB}MB)`;
23
+ }
24
+ console.error(`[MEMORY] ${label}: Heap ${heapUsedMB}/${heapTotalMB}MB, RSS ${rssMB}MB${delta}`);
25
+ // Warn if approaching limits
26
+ if (current.heapUsed > HEAP_WARNING_THRESHOLD) {
27
+ console.warn(`[MEMORY WARNING] Heap usage is high: ${heapUsedMB}MB (threshold: 3500MB)`);
28
+ }
29
+ lastMemoryUsage = current;
30
+ }
31
+ }
32
+ export function getMemoryPressure() {
33
+ const usage = process.memoryUsage();
34
+ const heapUsedPercent = (usage.heapUsed / usage.heapTotal) * 100;
35
+ if (usage.heapUsed > HEAP_CRITICAL_THRESHOLD || heapUsedPercent > 95)
36
+ return "critical";
37
+ if (usage.heapUsed > HEAP_WARNING_THRESHOLD || heapUsedPercent > 85)
38
+ return "high";
39
+ if (heapUsedPercent > 70)
40
+ return "medium";
41
+ return "low";
42
+ }
43
+ export function shouldTriggerGC() {
44
+ const pressure = getMemoryPressure();
45
+ return pressure === "high" || pressure === "critical";
46
+ }
47
+ /**
48
+ * Force garbage collection if available and needed
49
+ * Respects rate limiting to avoid GC thrashing
50
+ */
51
+ export function tryForceGC(reason) {
52
+ // Reset GC attempt counter every minute
53
+ const now = Date.now();
54
+ if (now - lastGCReset > 60000) {
55
+ gcAttempts = 0;
56
+ lastGCReset = now;
57
+ }
58
+ // Rate limit GC attempts
59
+ if (gcAttempts >= MAX_GC_ATTEMPTS_PER_MINUTE) {
60
+ return false;
61
+ }
62
+ // Check if global.gc is available (requires --expose-gc flag)
63
+ if (typeof global.gc === "function") {
64
+ const beforeHeap = process.memoryUsage().heapUsed;
65
+ global.gc();
66
+ gcAttempts++;
67
+ const afterHeap = process.memoryUsage().heapUsed;
68
+ const freedMB = ((beforeHeap - afterHeap) / 1024 / 1024).toFixed(2);
69
+ if (process.env.DEBUG_MEMORY) {
70
+ console.error(`[MEMORY] Forced GC${reason ? ` (${reason})` : ""}: Freed ${freedMB}MB`);
71
+ }
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Monitor memory and trigger GC if needed
78
+ * Call this after major operations like screen transitions
79
+ */
80
+ export function checkMemoryPressure() {
81
+ const pressure = getMemoryPressure();
82
+ if (pressure === "critical" || pressure === "high") {
83
+ tryForceGC(`Memory pressure: ${pressure}`);
84
+ }
85
+ }