bgrun 3.3.3 → 3.7.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.
@@ -1,4 +1,4 @@
1
- import { CommandOptions } from "../types";
1
+ import type { CommandOptions } from "../types";
2
2
  import { getProcess, removeProcessByName, retryDatabaseOperation, insertProcess } from "../db";
3
3
  import { isProcessRunning, terminateProcess, getHomeDir, getShellCommand, killProcessOnPort, findChildPid, getProcessPorts, waitForPortFree } from "../platform";
4
4
  import { error, announce } from "../logger";
@@ -7,8 +7,10 @@ import { parseConfigFile } from "../config";
7
7
  import { $ } from "bun";
8
8
  import { sleep } from "bun";
9
9
  import { join } from "path";
10
+ import { createMeasure } from "measure-fn";
10
11
 
11
12
  const homePath = getHomeDir();
13
+ const run = createMeasure('run');
12
14
 
13
15
  export async function handleRun(options: CommandOptions) {
14
16
  const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options;
@@ -24,18 +26,20 @@ export async function handleRun(options: CommandOptions) {
24
26
  if (!require('fs').existsSync(require('path').join(finalDirectory, '.git'))) {
25
27
  error(`Cannot --fetch: '${finalDirectory}' is not a Git repository.`);
26
28
  }
27
- try {
28
- await $`git fetch origin`;
29
- const localHash = (await $`git rev-parse HEAD`.text()).trim();
30
- const remoteHash = (await $`git rev-parse origin/$(git rev-parse --abbrev-ref HEAD)`.text()).trim();
31
-
32
- if (localHash !== remoteHash) {
33
- await $`git pull origin $(git rev-parse --abbrev-ref HEAD)`;
34
- announce("📥 Pulled latest changes", "Git Update");
29
+ await run.measure(`Git fetch "${name}"`, async () => {
30
+ try {
31
+ await $`git fetch origin`;
32
+ const localHash = (await $`git rev-parse HEAD`.text()).trim();
33
+ const remoteHash = (await $`git rev-parse origin/$(git rev-parse --abbrev-ref HEAD)`.text()).trim();
34
+
35
+ if (localHash !== remoteHash) {
36
+ await $`git pull origin $(git rev-parse --abbrev-ref HEAD)`;
37
+ announce("📥 Pulled latest changes", "Git Update");
38
+ }
39
+ } catch (err) {
40
+ error(`Failed to pull latest changes: ${err}`);
35
41
  }
36
- } catch (err) {
37
- error(`Failed to pull latest changes: ${err}`);
38
- }
42
+ });
39
43
  }
40
44
 
41
45
  const isRunning = await isProcessRunning(existingProcess.pid);
@@ -50,23 +54,26 @@ export async function handleRun(options: CommandOptions) {
50
54
  }
51
55
 
52
56
  if (isRunning) {
53
- await terminateProcess(existingProcess.pid);
54
- announce(`🔥 Terminated existing process '${name}'`, "Process Terminated");
57
+ await run.measure(`Terminate "${name}" (PID ${existingProcess.pid})`, async () => {
58
+ await terminateProcess(existingProcess.pid);
59
+ announce(`🔥 Terminated existing process '${name}'`, "Process Terminated");
60
+ });
55
61
  }
56
62
 
57
63
  // Kill anything still on the ports the old process was using
58
- for (const port of detectedPorts) {
59
- await killProcessOnPort(port);
60
- }
61
-
62
- // Wait for all detected ports to free up
63
- for (const port of detectedPorts) {
64
- const freed = await waitForPortFree(port, 5000);
65
- if (!freed) {
66
- // Retry kill and wait once more
67
- await killProcessOnPort(port);
68
- await waitForPortFree(port, 3000);
69
- }
64
+ if (detectedPorts.length > 0) {
65
+ await run.measure(`Port cleanup [${detectedPorts.join(', ')}]`, async () => {
66
+ for (const port of detectedPorts) {
67
+ await killProcessOnPort(port);
68
+ }
69
+ for (const port of detectedPorts) {
70
+ const freed = await waitForPortFree(port, 5000);
71
+ if (!freed) {
72
+ await killProcessOnPort(port);
73
+ await waitForPortFree(port, 3000);
74
+ }
75
+ }
76
+ });
70
77
  }
71
78
 
72
79
  await retryDatabaseOperation(() =>
@@ -97,12 +104,17 @@ export async function handleRun(options: CommandOptions) {
97
104
  const fullConfigPath = join(finalDirectory, finalConfigPath);
98
105
 
99
106
  if (await Bun.file(fullConfigPath).exists()) {
100
- try {
101
- const newConfigEnv = await parseConfigFile(fullConfigPath);
102
- finalEnv = { ...finalEnv, ...newConfigEnv };
107
+ const configEnv = await run.measure(`Parse config "${finalConfigPath}"`, async () => {
108
+ try {
109
+ return await parseConfigFile(fullConfigPath);
110
+ } catch (err: any) {
111
+ console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`);
112
+ return null;
113
+ }
114
+ });
115
+ if (configEnv) {
116
+ finalEnv = { ...finalEnv, ...configEnv };
103
117
  console.log(`Loaded config from ${finalConfigPath}`);
104
- } catch (err: any) {
105
- console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`);
106
118
  }
107
119
  } else {
108
120
  console.log(`Config file '${finalConfigPath}' not found, continuing without it.`);
@@ -114,20 +126,23 @@ export async function handleRun(options: CommandOptions) {
114
126
  const stderrPath = stderr || existingProcess?.stderr_path || join(homePath, ".bgr", `${name}-err.txt`);
115
127
  Bun.write(stderrPath, '');
116
128
 
117
- const newProcess = Bun.spawn(getShellCommand(finalCommand!), {
118
- env: { ...Bun.env, ...finalEnv },
119
- cwd: finalDirectory,
120
- stdout: Bun.file(stdoutPath),
121
- stderr: Bun.file(stderrPath),
122
- });
123
-
124
- newProcess.unref();
125
- // Give shell a moment to spawn child, then find PID before shell exits
126
- await sleep(100);
127
- // Find the actual child PID (shell wrapper exits immediately after spawning)
128
- const actualPid = await findChildPid(newProcess.pid);
129
- // Wait more for subprocess to initialize
130
- await sleep(400);
129
+ const actualPid = await run.measure(`Spawn "${name}" → ${finalCommand}`, async () => {
130
+ const newProcess = Bun.spawn(getShellCommand(finalCommand!), {
131
+ env: { ...Bun.env, ...finalEnv },
132
+ cwd: finalDirectory,
133
+ stdout: Bun.file(stdoutPath),
134
+ stderr: Bun.file(stderrPath),
135
+ });
136
+
137
+ newProcess.unref();
138
+ // Give shell a moment to spawn child, then find PID before shell exits
139
+ await sleep(100);
140
+ // Find the actual child PID (shell wrapper exits immediately after spawning)
141
+ const pid = await findChildPid(newProcess.pid);
142
+ // Wait more for subprocess to initialize
143
+ await sleep(400);
144
+ return pid;
145
+ }) ?? 0;
131
146
 
132
147
  await retryDatabaseOperation(() =>
133
148
  insertProcess({
@@ -147,5 +162,3 @@ export async function handleRun(options: CommandOptions) {
147
162
  "Process Started"
148
163
  );
149
164
  }
150
-
151
-
package/src/db.ts CHANGED
@@ -2,6 +2,7 @@ import { Database, z } from "sqlite-zod-orm";
2
2
  import { getHomeDir, ensureDir } from "./platform";
3
3
  import { join } from "path";
4
4
  import { sleep } from "bun";
5
+ import { existsSync, copyFileSync } from "fs";
5
6
 
6
7
  // =============================================================================
7
8
  // SCHEMA (inline — single table, no need for a separate file)
@@ -26,9 +27,24 @@ export type Process = z.infer<typeof ProcessSchema> & { id: number };
26
27
  // =============================================================================
27
28
 
28
29
  const homePath = getHomeDir();
29
- const dbName = process.env.DB_NAME ?? "bgr";
30
- const dbPath = join(homePath, ".bgr", `${dbName}_v2.sqlite`);
31
- ensureDir(join(homePath, ".bgr"));
30
+ const bgrDir = join(homePath, ".bgr");
31
+ ensureDir(bgrDir);
32
+
33
+ // DB filename: configurable via BGRUN_DB env, default "bgrun.sqlite"
34
+ const dbFilename = process.env.BGRUN_DB ?? "bgrun.sqlite";
35
+ export const dbPath = join(bgrDir, dbFilename);
36
+ export const bgrHome = bgrDir;
37
+
38
+ // Auto-migration: if new DB doesn't exist but old one does, copy it over
39
+ const legacyDbPath = join(bgrDir, "bgr_v2.sqlite");
40
+ if (!existsSync(dbPath) && existsSync(legacyDbPath)) {
41
+ try {
42
+ copyFileSync(legacyDbPath, dbPath);
43
+ console.log(`[bgrun] Migrated database: ${legacyDbPath} → ${dbPath}`);
44
+ } catch (e) {
45
+ // Migration failed — start fresh
46
+ }
47
+ }
32
48
 
33
49
  export const db = new Database(dbPath, {
34
50
  process: ProcessSchema,
@@ -88,6 +104,14 @@ export function removeProcessByName(name: string) {
88
104
  }
89
105
  }
90
106
 
107
+ /** Update the stored PID for a process (used by PID reconciliation) */
108
+ export function updateProcessPid(name: string, newPid: number) {
109
+ const proc = db.process.select().where({ name }).limit(1).get();
110
+ if (proc) {
111
+ db.process.update(proc.id, { pid: newPid });
112
+ }
113
+ }
114
+
91
115
  export function removeAllProcesses() {
92
116
  const all = db.process.select().all();
93
117
  for (const p of all) {
@@ -95,6 +119,19 @@ export function removeAllProcesses() {
95
119
  }
96
120
  }
97
121
 
122
+ // =============================================================================
123
+ // DEBUG / INFO
124
+ // =============================================================================
125
+
126
+ export function getDbInfo() {
127
+ return {
128
+ dbPath,
129
+ bgrHome,
130
+ dbFilename,
131
+ exists: existsSync(dbPath),
132
+ };
133
+ }
134
+
98
135
  // =============================================================================
99
136
  // UTILITIES
100
137
  // =============================================================================
package/src/index.ts CHANGED
@@ -12,11 +12,18 @@ import type { CommandOptions } from "./types";
12
12
  import { error, announce } from "./logger";
13
13
  import { startServer } from "./server";
14
14
  import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree } from "./platform";
15
- import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation } from "./db";
15
+ import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation, getDbInfo } from "./db";
16
16
  import dedent from "dedent";
17
17
  import chalk from "chalk";
18
18
  import { join } from "path";
19
19
  import { sleep } from "bun";
20
+ import { configure } from "measure-fn";
21
+
22
+ if (!Bun.argv.includes("--_serve")) {
23
+ if (!Bun.env.MEASURE_SILENT) {
24
+ configure({ silent: true });
25
+ }
26
+ }
20
27
 
21
28
  async function showHelp() {
22
29
  const usage = dedent`
@@ -51,6 +58,7 @@ async function showHelp() {
51
58
  --log-stderr Show only stderr logs
52
59
  --lines <n> Number of log lines to show (default: all)
53
60
  --version Show version
61
+ --debug Show debug info (DB path, BGR home, etc.)
54
62
  --dashboard Launch web dashboard as bgrun-managed process
55
63
  --port <number> Port for dashboard (default: 3000)
56
64
  --help Show this help message
@@ -92,6 +100,7 @@ async function run() {
92
100
  stdout: { type: 'string' },
93
101
  stderr: { type: 'string' },
94
102
  dashboard: { type: 'boolean' },
103
+ debug: { type: 'boolean' },
95
104
  "_serve": { type: 'boolean' },
96
105
  port: { type: 'string' },
97
106
  },
@@ -233,6 +242,23 @@ async function run() {
233
242
  return;
234
243
  }
235
244
 
245
+ if (values.debug) {
246
+ const info = getDbInfo();
247
+ const version = await getVersion();
248
+ console.log(dedent`
249
+ ${chalk.bold('bgrun debug info')}
250
+ ${chalk.gray('─'.repeat(40))}
251
+ Version: ${chalk.cyan(version)}
252
+ BGR Home: ${chalk.yellow(info.bgrHome)}
253
+ DB Path: ${chalk.yellow(info.dbPath)}
254
+ DB File: ${info.dbFilename}
255
+ DB Exists: ${info.exists ? chalk.green('✓') : chalk.red('✗')}
256
+ Platform: ${process.platform}
257
+ Bun: ${Bun.version}
258
+ `);
259
+ return;
260
+ }
261
+
236
262
  // Commands flow
237
263
  if (values.nuke) {
238
264
  await handleDeleteAll();
package/src/platform.ts CHANGED
@@ -5,7 +5,11 @@
5
5
 
6
6
  import * as fs from "fs";
7
7
  import * as os from "os";
8
+ import { join } from "path";
8
9
  import { $ } from "bun";
10
+ import { measure, createMeasure } from "measure-fn";
11
+
12
+ const plat = createMeasure('platform');
9
13
 
10
14
  /** Detect if running on Windows - use function to prevent bundler tree-shaking */
11
15
  export function isWindows(): boolean {
@@ -24,24 +28,24 @@ export function getHomeDir(): string {
24
28
  * For Docker containers, checks container status instead of PID
25
29
  */
26
30
  export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
27
- try {
28
- // Docker container detection
29
- if (command && (command.includes('docker run') || command.includes('docker-compose up') || command.includes('docker compose up'))) {
30
- return await isDockerContainerRunning(command);
31
- }
31
+ return plat.measure(`PID ${pid} alive?`, async () => {
32
+ try {
33
+ // Docker container detection
34
+ if (command && (command.includes('docker run') || command.includes('docker-compose up') || command.includes('docker compose up'))) {
35
+ return await isDockerContainerRunning(command);
36
+ }
32
37
 
33
- if (isWindows()) {
34
- // On Windows, use tasklist to check for PID
35
- const result = await $`tasklist /FI "PID eq ${pid}" /NH`.nothrow().text();
36
- return result.includes(`${pid}`);
37
- } else {
38
- // On Unix, use ps -p
39
- const result = await $`ps -p ${pid}`.nothrow().text();
40
- return result.includes(`${pid}`);
38
+ if (isWindows()) {
39
+ const result = await $`tasklist /FI "PID eq ${pid}" /NH`.nothrow().text();
40
+ return result.includes(`${pid}`);
41
+ } else {
42
+ const result = await $`ps -p ${pid}`.nothrow().text();
43
+ return result.includes(`${pid}`);
44
+ }
45
+ } catch {
46
+ return false;
41
47
  }
42
- } catch {
43
- return false;
44
- }
48
+ });
45
49
  }
46
50
 
47
51
  /**
@@ -79,8 +83,8 @@ async function isDockerContainerRunning(command: string): Promise<boolean> {
79
83
  async function getChildPids(pid: number): Promise<number[]> {
80
84
  try {
81
85
  if (isWindows()) {
82
- // On Windows, use wmic to get child processes
83
- const result = await $`wmic process where (ParentProcessId=${pid}) get ProcessId`.nothrow().text();
86
+ // On Windows, use PowerShell to get child processes
87
+ const result = await $`powershell -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}' | Select-Object -ExpandProperty ProcessId"`.nothrow().text();
84
88
  return result
85
89
  .split('\n')
86
90
  .map(line => parseInt(line.trim()))
@@ -104,46 +108,48 @@ async function getChildPids(pid: number): Promise<number[]> {
104
108
  * Terminate a process and its children
105
109
  */
106
110
  export async function terminateProcess(pid: number, force: boolean = false): Promise<void> {
107
- // First, kill children
108
- const children = await getChildPids(pid);
111
+ await plat.measure(`Terminate PID ${pid}`, async (m) => {
112
+ // First, kill children
113
+ const children = await m('Get children', () => getChildPids(pid)) ?? [];
109
114
 
110
- for (const childPid of children) {
111
- try {
112
- if (isWindows()) {
113
- if (force) {
114
- await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
115
+ for (const childPid of children) {
116
+ try {
117
+ if (isWindows()) {
118
+ if (force) {
119
+ await $`taskkill /F /PID ${childPid}`.nothrow().quiet();
120
+ } else {
121
+ await $`taskkill /PID ${childPid}`.nothrow().quiet();
122
+ }
115
123
  } else {
116
- await $`taskkill /PID ${childPid}`.nothrow().quiet();
124
+ const signal = force ? 'KILL' : 'TERM';
125
+ await $`kill -${signal} ${childPid}`.nothrow();
117
126
  }
118
- } else {
119
- const signal = force ? 'KILL' : 'TERM';
120
- await $`kill -${signal} ${childPid}`.nothrow();
127
+ } catch {
128
+ // Ignore errors for already-dead processes
121
129
  }
122
- } catch {
123
- // Ignore errors for already-dead processes
124
130
  }
125
- }
126
131
 
127
- // Wait a bit for graceful shutdown
128
- await Bun.sleep(500);
132
+ // Wait a bit for graceful shutdown
133
+ await Bun.sleep(500);
129
134
 
130
- // Then kill the parent if still running
131
- if (await isProcessRunning(pid)) {
132
- try {
133
- if (isWindows()) {
134
- if (force) {
135
- await $`taskkill /F /PID ${pid}`.nothrow().quiet();
135
+ // Then kill the parent if still running
136
+ if (await isProcessRunning(pid)) {
137
+ try {
138
+ if (isWindows()) {
139
+ if (force) {
140
+ await $`taskkill /F /PID ${pid}`.nothrow().quiet();
141
+ } else {
142
+ await $`taskkill /PID ${pid}`.nothrow().quiet();
143
+ }
136
144
  } else {
137
- await $`taskkill /PID ${pid}`.nothrow().quiet();
145
+ const signal = force ? 'KILL' : 'TERM';
146
+ await $`kill -${signal} ${pid}`.nothrow();
138
147
  }
139
- } else {
140
- const signal = force ? 'KILL' : 'TERM';
141
- await $`kill -${signal} ${pid}`.nothrow();
148
+ } catch {
149
+ // Ignore errors
142
150
  }
143
- } catch {
144
- // Ignore errors
145
151
  }
146
- }
152
+ });
147
153
  }
148
154
 
149
155
  /**
@@ -297,6 +303,108 @@ export async function findChildPid(parentPid: number): Promise<number> {
297
303
  return currentPid;
298
304
  }
299
305
 
306
+ /**
307
+ * Reconcile stale PIDs: when a stored PID is dead, search for a live process
308
+ * matching the same command line and update the DB with the correct PID.
309
+ *
310
+ * This handles the case where cmd.exe wrapper PIDs die after spawning the
311
+ * actual bun.exe child process, or after a system reboot where PIDs change.
312
+ *
313
+ * Returns a map of process name → reconciled PID for all matched processes.
314
+ */
315
+ export async function reconcileProcessPids(
316
+ processes: Array<{ name: string; pid: number; command: string; workdir: string }>,
317
+ deadPids: Set<number>,
318
+ ): Promise<Map<string, number>> {
319
+ return await plat.measure('Reconcile PIDs', async () => {
320
+ const result = new Map<string, number>();
321
+ const needsReconciliation = processes.filter(p => deadPids.has(p.pid));
322
+ if (needsReconciliation.length === 0) return result;
323
+
324
+ try {
325
+ // Get all running processes with their command lines
326
+ let runningProcs: Array<{ pid: number; cmdLine: string }> = [];
327
+
328
+ if (isWindows()) {
329
+ // Write a temp PS1 script to avoid quoting issues with $() in Bun's shell
330
+ const tmpScript = join(os.tmpdir(), 'bgr-reconcile.ps1');
331
+ const psCode = `Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'bun.exe' } | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.CommandLine)" }`;
332
+ await Bun.write(tmpScript, psCode);
333
+
334
+ const ps = Bun.spawnSync(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpScript]);
335
+ const output = ps.stdout.toString();
336
+
337
+ for (const line of output.split('\n')) {
338
+ const sepIdx = line.indexOf('|');
339
+ if (sepIdx === -1) continue;
340
+ const pid = parseInt(line.substring(0, sepIdx).trim());
341
+ const cmdLine = line.substring(sepIdx + 1).trim();
342
+ if (!isNaN(pid) && pid > 0 && cmdLine) {
343
+ runningProcs.push({ pid, cmdLine });
344
+ }
345
+ }
346
+ } else {
347
+ const psOutput = await $`ps -eo pid,args --no-headers`.nothrow().quiet().text();
348
+ for (const line of psOutput.trim().split('\n')) {
349
+ const match = line.trim().match(/^(\d+)\s+(.+)/);
350
+ if (match) {
351
+ runningProcs.push({ pid: parseInt(match[1]), cmdLine: match[2] });
352
+ }
353
+ }
354
+ }
355
+
356
+ // For each dead process, try to find a matching live process
357
+ // Uses multi-criteria scoring to avoid false matches when multiple
358
+ // processes share similar commands (e.g. "bun run server.ts")
359
+ for (const proc of needsReconciliation) {
360
+ const cmdParts = proc.command.split(/\s+/);
361
+ // Extract meaningful parts: full command and workdir path segments
362
+ const workdirParts = proc.workdir.replace(/\\/g, '/').split('/').filter(Boolean);
363
+ const workdirLast = workdirParts[workdirParts.length - 1]?.toLowerCase() || '';
364
+
365
+ let bestMatch: { pid: number; score: number } | null = null;
366
+ let ambiguous = false;
367
+
368
+ for (const running of runningProcs) {
369
+ const cmdLower = running.cmdLine.toLowerCase();
370
+ let score = 0;
371
+
372
+ // Score 1: command parts match (e.g. "run", "server.ts")
373
+ for (const part of cmdParts) {
374
+ if (part.length > 2 && cmdLower.includes(part.toLowerCase())) score++;
375
+ }
376
+
377
+ // Score 2: workdir folder name appears in command line path
378
+ // This distinguishes "bun run server.ts" in different directories
379
+ if (workdirLast && cmdLower.includes(workdirLast)) score += 3;
380
+
381
+ // Score 3: full workdir path match (strongest signal)
382
+ if (cmdLower.includes(proc.workdir.toLowerCase().replace(/\\/g, '/'))) score += 5;
383
+ if (cmdLower.includes(proc.workdir.toLowerCase())) score += 5;
384
+
385
+ if (score < 4) continue; // Require workdir evidence — generic cmd matches alone aren't enough
386
+
387
+ if (!bestMatch || score > bestMatch.score) {
388
+ ambiguous = false;
389
+ bestMatch = { pid: running.pid, score };
390
+ } else if (score === bestMatch.score) {
391
+ ambiguous = true; // Multiple equally good matches — skip
392
+ }
393
+ }
394
+
395
+ if (bestMatch && !ambiguous) {
396
+ result.set(proc.name, bestMatch.pid);
397
+ runningProcs = runningProcs.filter(p => p.pid !== bestMatch!.pid);
398
+ }
399
+ }
400
+ } catch {
401
+ // Reconciliation is best-effort — return partial results
402
+ }
403
+
404
+ return result;
405
+ }) ?? new Map();
406
+ }
407
+
300
408
  /**
301
409
  * Wait for a port to become active and return the PID listening on it.
302
410
  * More reliable than findChildPid since it waits for the actual server
@@ -341,19 +449,21 @@ export async function findPidByPort(port: number, maxWaitMs = 8000): Promise<num
341
449
  }
342
450
 
343
451
  export async function readFileTail(filePath: string, lines?: number): Promise<string> {
344
- try {
345
- const content = await Bun.file(filePath).text();
452
+ return plat.measure(`Read tail ${lines ?? 'all'}L`, async () => {
453
+ try {
454
+ const content = await Bun.file(filePath).text();
346
455
 
347
- if (!lines) {
348
- return content;
349
- }
456
+ if (!lines) {
457
+ return content;
458
+ }
350
459
 
351
- const allLines = content.split(/\r?\n/);
352
- const tailLines = allLines.slice(-lines);
353
- return tailLines.join('\n');
354
- } catch (error) {
355
- throw new Error(`Error reading file: ${error}`);
356
- }
460
+ const allLines = content.split(/\r?\n/);
461
+ const tailLines = allLines.slice(-lines);
462
+ return tailLines.join('\n');
463
+ } catch (error) {
464
+ throw new Error(`Error reading file: ${error}`);
465
+ }
466
+ });
357
467
  }
358
468
 
359
469
  /**
@@ -367,24 +477,65 @@ export function copyFile(src: string, dest: string): void {
367
477
  * Get memory usage of a process in bytes
368
478
  */
369
479
  export async function getProcessMemory(pid: number): Promise<number> {
370
- try {
371
- if (isWindows()) {
372
- // On Windows, use wmic to get memory
373
- const result = await $`wmic process where ProcessId=${pid} get WorkingSetSize`.nothrow().text();
374
- const lines = result.split('\n').filter(line => line.trim() && !line.includes('WorkingSetSize'));
375
- if (lines.length > 0) {
376
- return parseInt(lines[0].trim()) || 0;
480
+ const map = await getProcessBatchMemory([pid]);
481
+ return map.get(pid) || 0;
482
+ }
483
+
484
+ /**
485
+ * Get memory usage for a batch of PIDs in bytes.
486
+ * Returns a Map of PID -> Memory (bytes).
487
+ *
488
+ * Optimization: Fetches ALL processes in one go and filters in-memory
489
+ * to avoid spawning N subprocesses.
490
+ */
491
+ export async function getProcessBatchMemory(pids: number[]): Promise<Map<number, number>> {
492
+ if (pids.length === 0) return new Map();
493
+
494
+ return await plat.measure(`Batch memory (${pids.length} PIDs)`, async () => {
495
+ const memoryMap = new Map<number, number>();
496
+ const pidSet = new Set(pids);
497
+
498
+ try {
499
+ if (isWindows()) {
500
+ const result = await $`powershell -Command "Get-Process | Select-Object Id, WorkingSet"`.nothrow().quiet().text();
501
+ const lines = result.trim().split('\n');
502
+
503
+ for (const line of lines) {
504
+ const trimmed = line.trim();
505
+ if (!trimmed || trimmed.startsWith('Id') || trimmed.startsWith('--')) continue;
506
+
507
+ const parts = trimmed.split(/\s+/);
508
+ if (parts.length >= 2) {
509
+ const val1 = parseInt(parts[0]);
510
+ const val2 = parseInt(parts[parts.length - 1]);
511
+
512
+ if (!isNaN(val1) && !isNaN(val2)) {
513
+ if (pidSet.has(val1)) memoryMap.set(val1, val2);
514
+ }
515
+ }
516
+ }
517
+ } else {
518
+ const result = await $`ps -eo pid,rss`.nothrow().quiet().text();
519
+ const lines = result.trim().split('\n');
520
+
521
+ for (let i = 1; i < lines.length; i++) {
522
+ const line = lines[i].trim();
523
+ if (!line) continue;
524
+ const [pidStr, rssStr] = line.split(/\s+/);
525
+ const pid = parseInt(pidStr);
526
+ const rss = parseInt(rssStr);
527
+
528
+ if (pidSet.has(pid)) {
529
+ memoryMap.set(pid, rss * 1024);
530
+ }
531
+ }
377
532
  }
378
- return 0;
379
- } else {
380
- // On Unix, use ps to get RSS in KB
381
- const result = await $`ps -o rss= -p ${pid}`.text();
382
- const memoryKB = parseInt(result.trim());
383
- return memoryKB * 1024; // Convert to bytes
533
+ } catch (e) {
534
+ // silently fail
384
535
  }
385
- } catch {
386
- return 0;
387
- }
536
+
537
+ return memoryMap;
538
+ }) ?? new Map();
388
539
  }
389
540
 
390
541
  /**
@@ -437,4 +588,3 @@ export async function getProcessPorts(pid: number): Promise<number[]> {
437
588
  return [];
438
589
  }
439
590
  }
440
-
package/src/table.ts CHANGED
@@ -21,6 +21,7 @@ export interface ProcessTableRow {
21
21
  pid: number;
22
22
  name: string;
23
23
  port: string;
24
+ memory: string;
24
25
  command: string;
25
26
  workdir: string;
26
27
  status: string;
@@ -220,6 +221,7 @@ export function renderProcessTable(processes: ProcessTableRow[], options?: Table
220
221
  { key: "pid", header: "PID", formatter: (pid) => chalk.yellow(pid) },
221
222
  { key: "name", header: "Name", formatter: (name) => chalk.cyan.bold(name) },
222
223
  { key: "port", header: "Port", formatter: (port) => port === '-' ? chalk.gray(port) : chalk.hex('#FF6B6B')(port) },
224
+ { key: "memory", header: "Memory", formatter: (mem) => mem === '-' ? chalk.gray(mem) : chalk.hex('#4ECDC4')(mem) },
223
225
  { key: "command", header: "Command" },
224
226
  { key: "workdir", header: "Directory", formatter: (dir) => chalk.gray(dir), truncator: truncatePath },
225
227
  { key: "status", header: "Status" },