cralph 1.0.0-beta.1 → 1.0.0-beta.2

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/README.md CHANGED
@@ -36,7 +36,7 @@ bun add -g cralph
36
36
  ## Quick Start
37
37
 
38
38
  ```bash
39
- # In an empty directory - creates starter structure
39
+ # In any directory without .ralph/ - creates starter structure
40
40
  cralph
41
41
 
42
42
  # Edit rule.md with your instructions, then run again
@@ -59,10 +59,11 @@ cralph --yes
59
59
  ## How It Works
60
60
 
61
61
  1. Checks Claude CLI auth (cached for 6 hours)
62
- 2. Loads config from `.ralph/paths.json`
63
- 3. Runs `claude -p --dangerously-skip-permissions` in a loop
64
- 4. Claude updates `.ralph/TODO.md` after each iteration
65
- 5. Stops when Claude outputs `<promise>COMPLETE</promise>`
62
+ 2. Looks for `.ralph/` in current directory only (not subdirectories)
63
+ 3. Loads config from `.ralph/paths.json` or creates starter structure
64
+ 4. Runs `claude -p --dangerously-skip-permissions` in a loop
65
+ 5. Claude updates `.ralph/TODO.md` after each iteration
66
+ 6. Stops when Claude outputs `<promise>COMPLETE</promise>`
66
67
 
67
68
  ## Config
68
69
 
@@ -102,23 +103,31 @@ Claude maintains this structure:
102
103
  Any relevant context
103
104
  ```
104
105
 
105
- ## First Run (Empty Directory)
106
+ ## First Run (No .ralph/ in cwd)
106
107
 
107
108
  ```
108
- ? No directories found. Create starter structure in /path/to/dir? (Y/n)
109
+ No .ralph/ found in /path/to/dir
110
+ ● 📦 Create starter structure
111
+ ○ ⚙️ Configure manually
112
+ ```
113
+
114
+ Select **Create starter structure** to generate the default config:
109
115
 
116
+ ```
110
117
  ℹ Created .ralph/refs/ directory
111
118
  ℹ Created .ralph/rule.md with starter template
112
119
  ℹ Created .ralph/paths.json
113
120
 
114
- ╭──────────────────────────────────────────────╮
115
- 1. Add source files to .ralph/refs/
116
- 2. Edit .ralph/rule.md with your instructions│
117
- 3. Run cralph again
118
- ╰──────────────────────────────────────────────╯
121
+ ╭─────────────────────────────────────────────────╮
122
+ 1. Add source files to .ralph/refs/
123
+ 2. Edit .ralph/rule.md with your instructions
124
+ 3. Run cralph again
125
+ ╰─────────────────────────────────────────────────╯
119
126
  ```
120
127
 
121
- Use `--yes` to skip confirmation (for CI/automation).
128
+ Select **Configure manually** to skip starter creation and pick your own refs/rule/output.
129
+
130
+ Use `--yes` to auto-create starter structure (for CI/automation).
122
131
 
123
132
  ## Prompts
124
133
 
@@ -140,11 +149,17 @@ Use `--yes` to skip confirmation (for CI/automation).
140
149
  - **Enter** - Confirm
141
150
  - **Ctrl+C** - Exit
142
151
 
143
- ## Platform Notes
152
+ ## Platform Support
153
+
154
+ cralph works on **macOS**, **Linux**, and **Windows** with platform-specific handling:
144
155
 
145
- ### macOS Protected Directories
156
+ | Platform | Protected Directories Skipped |
157
+ |----------|------------------------------|
158
+ | macOS | Library, Photos Library, Photo Booth Library |
159
+ | Linux | lost+found, proc, sys |
160
+ | Windows | System Volume Information, $Recycle.Bin, Windows |
146
161
 
147
- cralph gracefully handles macOS permission errors (`EPERM`, `EACCES`) when scanning directories. Protected locations like `~/Pictures/Photo Booth Library` or iCloud folders are silently skipped, allowing the CLI to run from any directory including root (`/`).
162
+ Permission errors (`EPERM`, `EACCES`) are handled gracefully on all platforms, allowing the CLI to run from any directory.
148
163
 
149
164
  ## Testing
150
165
 
@@ -152,7 +167,7 @@ cralph gracefully handles macOS permission errors (`EPERM`, `EACCES`) when scann
152
167
  bun test
153
168
  ```
154
169
 
155
- - **Unit tests** - Config, prompt building, CLI, access error handling
170
+ - **Unit tests** - Config, prompt building, CLI, access error handling, platform detection, shutdown state
156
171
  - **E2E tests** - Full loop with Claude (requires auth)
157
172
 
158
173
  ## Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cralph",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Claude in a loop. Point at refs, give it a rule, let it cook.",
5
5
  "author": "mguleryuz",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -5,38 +5,39 @@ import { consola } from "consola";
5
5
  import { resolve, join } from "path";
6
6
  import { mkdir } from "fs/promises";
7
7
  import {
8
- buildConfig,
9
8
  loadPathsFile,
10
9
  validateConfig,
11
10
  selectRefs,
12
11
  selectRule,
13
12
  selectOutput,
14
13
  checkForPathsFile,
14
+ resolvePathsConfig,
15
+ toRelativePath,
15
16
  } from "./paths";
16
- import { run, cleanupSubprocess, checkClaudeAuth } from "./runner";
17
+ import { run, checkClaudeAuth } from "./runner";
17
18
  import type { RalphConfig } from "./types";
19
+ import { setShuttingDown, isShuttingDown, cleanupSubprocess } from "./state";
18
20
 
19
21
  // Graceful shutdown on Ctrl+C
20
22
  function setupGracefulExit() {
21
- let shuttingDown = false;
22
-
23
23
  process.on("SIGINT", () => {
24
- if (shuttingDown) {
24
+ if (isShuttingDown()) {
25
25
  // Force exit on second Ctrl+C
26
- process.exit(1);
26
+ Bun.exit(1);
27
27
  }
28
- shuttingDown = true;
28
+ setShuttingDown();
29
29
  cleanupSubprocess();
30
30
  console.log("\n");
31
31
  consola.info("Cancelled.");
32
- // Use setImmediate to ensure output is flushed
33
- setImmediate(() => process.exit(0));
32
+ // Use Bun.exit for immediate termination
33
+ Bun.exit(0);
34
34
  });
35
35
 
36
36
  // Also handle SIGTERM
37
37
  process.on("SIGTERM", () => {
38
+ setShuttingDown();
38
39
  cleanupSubprocess();
39
- process.exit(0);
40
+ Bun.exit(0);
40
41
  });
41
42
  }
42
43
 
@@ -109,11 +110,7 @@ const main = defineCommand({
109
110
  // Use existing config file
110
111
  consola.info(`Loading config from ${pathsFileResult.path}`);
111
112
  const loaded = await loadPathsFile(pathsFileResult.path);
112
- config = {
113
- refs: loaded.refs.map((r) => resolve(cwd, r)),
114
- rule: resolve(cwd, loaded.rule),
115
- output: resolve(cwd, loaded.output),
116
- };
113
+ config = resolvePathsConfig(loaded, cwd);
117
114
  } else {
118
115
  // Load existing config for edit mode defaults
119
116
  let existingConfig: RalphConfig | null = null;
@@ -123,11 +120,7 @@ const main = defineCommand({
123
120
  const file = Bun.file(filePath);
124
121
  if (await file.exists()) {
125
122
  const loaded = await loadPathsFile(filePath);
126
- existingConfig = {
127
- refs: loaded.refs.map((r) => resolve(cwd, r)),
128
- rule: resolve(cwd, loaded.rule),
129
- output: resolve(cwd, loaded.output),
130
- };
123
+ existingConfig = resolvePathsConfig(loaded, cwd);
131
124
  }
132
125
  } else {
133
126
  consola.info("Interactive configuration mode");
@@ -159,9 +152,9 @@ const main = defineCommand({
159
152
  await mkdir(ralphDir, { recursive: true });
160
153
 
161
154
  const pathsConfig = {
162
- refs: config.refs.map((r) => "./" + r.replace(cwd + "/", "")),
163
- rule: "./" + config.rule.replace(cwd + "/", ""),
164
- output: config.output === cwd ? "." : "./" + config.output.replace(cwd + "/", ""),
155
+ refs: config.refs.map((r) => toRelativePath(r, cwd)),
156
+ rule: toRelativePath(config.rule, cwd),
157
+ output: toRelativePath(config.output, cwd),
165
158
  };
166
159
  await Bun.write(
167
160
  join(ralphDir, "paths.json"),
package/src/paths.ts CHANGED
@@ -1,12 +1,47 @@
1
1
  import { consola } from "consola";
2
2
  import { resolve, join } from "path";
3
- import { readdir, stat, mkdir } from "fs/promises";
3
+ import { readdir, stat, mkdir, type Dirent } from "fs/promises";
4
4
  import type { PathsFileConfig, RalphConfig } from "./types";
5
+ import { isAccessError, shouldExcludeDir } from "./platform";
6
+ import { isShuttingDown } from "./state";
7
+
8
+ // Starter rule template for new projects
9
+ const STARTER_RULE = `I want a file named hello.txt
10
+ `;
5
11
 
6
12
  // Dim text helper
7
13
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
8
14
  const CONTROLS = dim("↑↓ Navigate • Space Toggle • Enter • Ctrl+C Exit");
9
15
 
16
+ /**
17
+ * Convert a PathsFileConfig to a resolved RalphConfig
18
+ */
19
+ export function resolvePathsConfig(loaded: PathsFileConfig, cwd: string): RalphConfig {
20
+ return {
21
+ refs: loaded.refs.map((r) => resolve(cwd, r)),
22
+ rule: resolve(cwd, loaded.rule),
23
+ output: resolve(cwd, loaded.output),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Convert an absolute path to a relative path for config storage
29
+ */
30
+ export function toRelativePath(absolutePath: string, cwd: string): string {
31
+ if (absolutePath === cwd) return ".";
32
+ return "./" + absolutePath.replace(cwd + "/", "");
33
+ }
34
+
35
+ /**
36
+ * Check if a directory entry should be skipped during traversal
37
+ */
38
+ function shouldSkipDirectory(entry: Dirent): boolean {
39
+ if (!entry.isDirectory()) return true;
40
+ if (entry.name.startsWith(".")) return true;
41
+ if (shouldExcludeDir(entry.name)) return true;
42
+ return false;
43
+ }
44
+
10
45
  /**
11
46
  * List directories in a given path
12
47
  */
@@ -17,7 +52,7 @@ async function listDirectories(basePath: string): Promise<string[]> {
17
52
  .filter((e) => e.isDirectory() && !e.name.startsWith("."))
18
53
  .map((e) => e.name);
19
54
  } catch (error) {
20
- // Silently skip directories we can't access (EPERM, EACCES)
55
+ // Silently skip directories we can't access
21
56
  if (isAccessError(error)) {
22
57
  return [];
23
58
  }
@@ -25,34 +60,6 @@ async function listDirectories(basePath: string): Promise<string[]> {
25
60
  }
26
61
  }
27
62
 
28
- /**
29
- * Check if an error is a permission/access error
30
- */
31
- export function isAccessError(error: unknown): boolean {
32
- if (error && typeof error === "object" && "code" in error) {
33
- const code = (error as { code: string }).code;
34
- return code === "EPERM" || code === "EACCES";
35
- }
36
- return false;
37
- }
38
-
39
- /**
40
- * Directories to exclude from listing
41
- */
42
- const EXCLUDED_DIRS = [
43
- "node_modules",
44
- "dist",
45
- "build",
46
- ".git",
47
- ".next",
48
- ".nuxt",
49
- ".output",
50
- "coverage",
51
- "__pycache__",
52
- "vendor",
53
- ".cache",
54
- ];
55
-
56
63
  /**
57
64
  * List directories recursively up to a certain depth
58
65
  */
@@ -69,7 +76,7 @@ export async function listDirectoriesRecursive(
69
76
  try {
70
77
  entries = await readdir(dir, { withFileTypes: true });
71
78
  } catch (error) {
72
- // Silently skip directories we can't access (EPERM, EACCES)
79
+ // Silently skip directories we can't access
73
80
  if (isAccessError(error)) {
74
81
  return;
75
82
  }
@@ -77,10 +84,7 @@ export async function listDirectoriesRecursive(
77
84
  }
78
85
 
79
86
  for (const entry of entries) {
80
- // Skip hidden and excluded directories
81
- if (!entry.isDirectory()) continue;
82
- if (entry.name.startsWith(".")) continue;
83
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
87
+ if (shouldSkipDirectory(entry)) continue;
84
88
 
85
89
  const fullPath = join(dir, entry.name);
86
90
  results.push(fullPath);
@@ -106,7 +110,7 @@ export async function listFilesRecursive(
106
110
  try {
107
111
  entries = await readdir(dir, { withFileTypes: true });
108
112
  } catch (error) {
109
- // Silently skip directories we can't access (EPERM, EACCES)
113
+ // Silently skip directories we can't access
110
114
  if (isAccessError(error)) {
111
115
  return;
112
116
  }
@@ -116,9 +120,7 @@ export async function listFilesRecursive(
116
120
  for (const entry of entries) {
117
121
  const fullPath = join(dir, entry.name);
118
122
  if (entry.isDirectory()) {
119
- // Skip hidden and excluded directories
120
- if (entry.name.startsWith(".")) continue;
121
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
123
+ if (shouldSkipDirectory(entry)) continue;
122
124
  await walk(fullPath);
123
125
  } else if (entry.isFile()) {
124
126
  if (extensions.some((ext) => entry.name.endsWith(ext))) {
@@ -179,27 +181,58 @@ export async function createStarterStructure(cwd: string): Promise<void> {
179
181
  * @param autoConfirm - If true, skip confirmation prompts
180
182
  */
181
183
  export async function selectRefs(cwd: string, defaults?: string[], autoConfirm?: boolean): Promise<string[]> {
182
- // Get all directories up to 3 levels deep
183
- let allDirs = await listDirectoriesRecursive(cwd, 3);
184
-
185
- if (allDirs.length === 0) {
184
+ // Check if .ralph/ exists in cwd - if not, offer to create starter structure
185
+ const ralphDir = join(cwd, ".ralph");
186
+ let ralphExists = false;
187
+ try {
188
+ await stat(ralphDir);
189
+ ralphExists = true;
190
+ } catch {
191
+ ralphExists = false;
192
+ }
193
+
194
+ if (!ralphExists) {
186
195
  // Ask before creating starter structure (skip if autoConfirm)
187
196
  if (!autoConfirm) {
188
- const confirm = await consola.prompt(
189
- `No directories found. Create starter structure in ${cwd}?`,
197
+ console.log(CONTROLS);
198
+ const action = await consola.prompt(
199
+ `No .ralph/ found in ${cwd}`,
190
200
  {
191
- type: "confirm",
192
- initial: true,
201
+ type: "select",
202
+ options: [
203
+ { label: "📦 Create starter structure", value: "create" },
204
+ { label: "⚙️ Configure manually", value: "manual" },
205
+ ],
193
206
  }
194
207
  );
195
208
 
196
- if (confirm !== true) {
197
- throw new Error("Setup cancelled");
209
+ // Handle Ctrl+C (returns Symbol) or shutdown in progress or unexpected values
210
+ if (typeof action === "symbol" || isShuttingDown() || (action !== "create" && action !== "manual")) {
211
+ throw new Error("Selection cancelled");
198
212
  }
213
+
214
+ if (action === "create") {
215
+ // Double-check we're not shutting down before executing
216
+ if (isShuttingDown()) {
217
+ throw new Error("Selection cancelled");
218
+ }
219
+ await createStarterStructure(cwd);
220
+ process.exit(0);
221
+ }
222
+
223
+ // action === "manual" - continue to directory selection
224
+ } else {
225
+ // Auto-confirm mode: create starter structure
226
+ await createStarterStructure(cwd);
227
+ process.exit(0);
199
228
  }
200
-
201
- await createStarterStructure(cwd);
202
- process.exit(0);
229
+ }
230
+
231
+ // Get all directories up to 3 levels deep
232
+ let allDirs = await listDirectoriesRecursive(cwd, 3);
233
+
234
+ if (allDirs.length === 0) {
235
+ throw new Error("No directories found to select from");
203
236
  }
204
237
 
205
238
  // Convert to relative paths for display
@@ -231,9 +264,6 @@ export async function selectRefs(cwd: string, defaults?: string[], autoConfirm?:
231
264
  return selected as string[];
232
265
  }
233
266
 
234
- const STARTER_RULE = `I want a file named hello.txt
235
- `;
236
-
237
267
  /**
238
268
  * Prompt user to select a rule file
239
269
  */
@@ -371,27 +401,3 @@ export async function validateConfig(config: RalphConfig): Promise<void> {
371
401
 
372
402
  // Output directory will be created if needed
373
403
  }
374
-
375
- /**
376
- * Interactive configuration builder
377
- */
378
- export async function buildConfig(cwd: string): Promise<RalphConfig> {
379
- // Check for existing paths file first
380
- const pathsFile = await checkForPathsFile(cwd);
381
-
382
- if (pathsFile) {
383
- const loaded = await loadPathsFile(pathsFile);
384
- return {
385
- refs: loaded.refs.map((r) => resolve(cwd, r)),
386
- rule: resolve(cwd, loaded.rule),
387
- output: resolve(cwd, loaded.output),
388
- };
389
- }
390
-
391
- // Interactive selection
392
- const refs = await selectRefs(cwd);
393
- const rule = await selectRule(cwd);
394
- const output = await selectOutput(cwd);
395
-
396
- return { refs, rule, output };
397
- }
@@ -0,0 +1,113 @@
1
+ import { platform } from "os";
2
+
3
+ /**
4
+ * Supported platforms
5
+ */
6
+ export type Platform = "darwin" | "linux" | "win32" | "unknown";
7
+
8
+ /**
9
+ * Get the current platform
10
+ */
11
+ export function getPlatform(): Platform {
12
+ const p = platform();
13
+ if (p === "darwin" || p === "linux" || p === "win32") {
14
+ return p;
15
+ }
16
+ return "unknown";
17
+ }
18
+
19
+ /**
20
+ * Platform-specific configuration
21
+ */
22
+ interface PlatformConfig {
23
+ /** Error codes that indicate permission/access denied */
24
+ accessErrorCodes: string[];
25
+ /** Directories to always exclude from scanning */
26
+ systemExcludedDirs: string[];
27
+ }
28
+
29
+ /**
30
+ * Platform configurations
31
+ */
32
+ const PLATFORM_CONFIGS: Record<Platform, PlatformConfig> = {
33
+ darwin: {
34
+ accessErrorCodes: ["EPERM", "EACCES"],
35
+ systemExcludedDirs: [
36
+ // macOS protected directories
37
+ "Library",
38
+ "Photos Library.photoslibrary",
39
+ "Photo Booth Library",
40
+ ],
41
+ },
42
+ linux: {
43
+ accessErrorCodes: ["EPERM", "EACCES"],
44
+ systemExcludedDirs: [
45
+ // Linux protected directories
46
+ "lost+found",
47
+ "proc",
48
+ "sys",
49
+ ],
50
+ },
51
+ win32: {
52
+ accessErrorCodes: ["EPERM", "EACCES"],
53
+ systemExcludedDirs: [
54
+ // Windows protected directories
55
+ "System Volume Information",
56
+ "$Recycle.Bin",
57
+ "Windows",
58
+ ],
59
+ },
60
+ unknown: {
61
+ accessErrorCodes: ["EPERM", "EACCES"],
62
+ systemExcludedDirs: [],
63
+ },
64
+ };
65
+
66
+ /**
67
+ * Get platform-specific configuration
68
+ */
69
+ export function getPlatformConfig(): PlatformConfig {
70
+ return PLATFORM_CONFIGS[getPlatform()];
71
+ }
72
+
73
+ /**
74
+ * Check if an error is a permission/access error for the current platform
75
+ */
76
+ export function isAccessError(error: unknown): boolean {
77
+ if (error && typeof error === "object" && "code" in error) {
78
+ const code = (error as { code: string }).code;
79
+ return getPlatformConfig().accessErrorCodes.includes(code);
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Check if a directory should be excluded on the current platform
86
+ */
87
+ export function isSystemExcludedDir(dirName: string): boolean {
88
+ return getPlatformConfig().systemExcludedDirs.includes(dirName);
89
+ }
90
+
91
+ /**
92
+ * Common directories to exclude across all platforms (project-related)
93
+ */
94
+ export const EXCLUDED_DIRS = [
95
+ "node_modules",
96
+ "dist",
97
+ "build",
98
+ ".git",
99
+ ".next",
100
+ ".nuxt",
101
+ ".output",
102
+ "coverage",
103
+ "__pycache__",
104
+ "vendor",
105
+ ".cache",
106
+ ];
107
+
108
+ /**
109
+ * Check if a directory should be excluded (combines common + platform-specific)
110
+ */
111
+ export function shouldExcludeDir(dirName: string): boolean {
112
+ return EXCLUDED_DIRS.includes(dirName) || isSystemExcludedDir(dirName);
113
+ }
package/src/runner.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdir } from "fs/promises";
4
4
  import { homedir } from "os";
5
5
  import type { RalphConfig, RunnerState, IterationResult } from "./types";
6
6
  import { createPrompt } from "./prompt";
7
+ import { setCurrentProcess } from "./state";
7
8
 
8
9
  const COMPLETION_SIGNAL = "<promise>COMPLETE</promise>";
9
10
  const AUTH_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -175,23 +176,6 @@ async function log(state: RunnerState, message: string): Promise<void> {
175
176
  await Bun.write(state.logFile, existing + logLine);
176
177
  }
177
178
 
178
- // Track current subprocess for cleanup
179
- let currentProc: ReturnType<typeof Bun.spawn> | null = null;
180
-
181
- /**
182
- * Kill any running subprocess on exit
183
- */
184
- export function cleanupSubprocess() {
185
- if (currentProc) {
186
- try {
187
- currentProc.kill();
188
- } catch {
189
- // Process may have already exited
190
- }
191
- currentProc = null;
192
- }
193
- }
194
-
195
179
  /**
196
180
  * Run a single Claude iteration
197
181
  */
@@ -213,14 +197,14 @@ async function runIteration(
213
197
  cwd,
214
198
  });
215
199
 
216
- currentProc = proc;
200
+ setCurrentProcess(proc);
217
201
 
218
202
  // Collect output
219
203
  const stdout = await new Response(proc.stdout).text();
220
204
  const stderr = await new Response(proc.stderr).text();
221
205
  const exitCode = await proc.exited;
222
206
 
223
- currentProc = null;
207
+ setCurrentProcess(null);
224
208
 
225
209
  const output = stdout + stderr;
226
210
 
package/src/state.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Global state shared across modules
3
+ *
4
+ * This module provides centralized state management for:
5
+ * - Graceful shutdown handling (Ctrl+C / SIGINT / SIGTERM)
6
+ * - Subprocess tracking for cleanup
7
+ */
8
+
9
+ // Shutdown state
10
+ let shuttingDown = false;
11
+
12
+ /**
13
+ * Mark the process as shutting down
14
+ */
15
+ export function setShuttingDown(): void {
16
+ shuttingDown = true;
17
+ }
18
+
19
+ /**
20
+ * Check if the process is shutting down (Ctrl+C was pressed)
21
+ */
22
+ export function isShuttingDown(): boolean {
23
+ return shuttingDown;
24
+ }
25
+
26
+ /**
27
+ * Reset shutdown state (for testing purposes only)
28
+ */
29
+ export function resetShutdownState(): void {
30
+ shuttingDown = false;
31
+ }
32
+
33
+ // Subprocess tracking
34
+ let currentProc: ReturnType<typeof Bun.spawn> | null = null;
35
+
36
+ /**
37
+ * Set the current running subprocess for tracking
38
+ */
39
+ export function setCurrentProcess(proc: ReturnType<typeof Bun.spawn> | null): void {
40
+ currentProc = proc;
41
+ }
42
+
43
+ /**
44
+ * Kill any running subprocess on exit
45
+ */
46
+ export function cleanupSubprocess(): void {
47
+ if (currentProc) {
48
+ try {
49
+ currentProc.kill();
50
+ } catch {
51
+ // Process may have already exited
52
+ }
53
+ currentProc = null;
54
+ }
55
+ }