prompt-language-shell 0.9.6 → 1.0.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,6 +1,6 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import { getAvailableConfigStructure, getConfiguredKeys, } from '../configuration/schema.js';
3
- import { logPrompt, logResponse } from './logger.js';
3
+ import { formatConfigContext, formatSkillsContext, logPrompt, logResponse, } from './logger.js';
4
4
  import { loadSkillsForPrompt } from './skills.js';
5
5
  import { toolRegistry } from './registry.js';
6
6
  import { CommandResultSchema, IntrospectResultSchema, } from '../types/schemas.js';
@@ -77,13 +77,13 @@ export class AnthropicService {
77
77
  // Load base instructions and skills
78
78
  const baseInstructions = customInstructions || toolRegistry.getInstructions(toolName);
79
79
  let formattedSkills = '';
80
- let skillDefinitions = [];
81
80
  let systemPrompt = baseInstructions;
81
+ let context;
82
82
  if (!customInstructions && usesSkills) {
83
83
  const skillsResult = loadSkillsForPrompt();
84
84
  formattedSkills = skillsResult.formatted;
85
- skillDefinitions = skillsResult.definitions;
86
85
  systemPrompt += formattedSkills;
86
+ context = formatSkillsContext(skillsResult.definitions);
87
87
  }
88
88
  // Add config structure for configure tool only
89
89
  if (!customInstructions && toolName === 'configure') {
@@ -95,6 +95,7 @@ export class AnthropicService {
95
95
  '\n\nConfigured keys (keys that exist in config file):\n' +
96
96
  JSON.stringify(configuredKeys, null, 2);
97
97
  systemPrompt += configSection;
98
+ context = formatConfigContext(configStructure, configuredKeys);
98
99
  }
99
100
  // Build tools array - add web search for answer tool
100
101
  const tools = [tool];
@@ -107,7 +108,7 @@ export class AnthropicService {
107
108
  // Collect debug components
108
109
  const debug = [];
109
110
  // Log prompt at Verbose level
110
- const promptDebug = logPrompt(toolName, command, baseInstructions, formattedSkills, skillDefinitions);
111
+ const promptDebug = logPrompt(toolName, command, baseInstructions, formattedSkills, context);
111
112
  if (promptDebug) {
112
113
  debug.push(promptDebug);
113
114
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'fs';
2
2
  import { dirname } from 'path';
3
3
  /**
4
4
  * Real filesystem implementation using Node's fs module
@@ -13,6 +13,9 @@ export class RealFileSystem {
13
13
  writeFile(path, data) {
14
14
  writeFileSync(path, data, 'utf-8');
15
15
  }
16
+ appendFile(path, data) {
17
+ appendFileSync(path, data, 'utf-8');
18
+ }
16
19
  readDirectory(path) {
17
20
  return readdirSync(path);
18
21
  }
@@ -51,6 +54,15 @@ export class MemoryFileSystem {
51
54
  }
52
55
  this.files.set(path, data);
53
56
  }
57
+ appendFile(path, data) {
58
+ // Auto-create parent directories (consistent with writeFile)
59
+ const dir = dirname(path);
60
+ if (dir !== '.' && dir !== path) {
61
+ this.createDirectory(dir, { recursive: true });
62
+ }
63
+ const existing = this.files.get(path) ?? '';
64
+ this.files.set(path, existing + data);
65
+ }
54
66
  readDirectory(path) {
55
67
  if (!this.directories.has(path)) {
56
68
  throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
@@ -1,7 +1,10 @@
1
+ import { homedir, platform } from 'os';
2
+ import { dirname, join } from 'path';
1
3
  import { DebugLevel } from '../configuration/types.js';
2
4
  import { loadDebugSetting } from '../configuration/io.js';
3
5
  import { Palette } from './colors.js';
4
6
  import { createDebug } from './components.js';
7
+ import { defaultFileSystem } from './filesystem.js';
5
8
  /**
6
9
  * Enum controlling what content is shown in debug prompt output
7
10
  * - LLM: Exact prompt as sent to LLM (no display formatting)
@@ -23,6 +26,112 @@ let currentDebugLevel = DebugLevel.None;
23
26
  * Accumulated warnings to be displayed in the timeline
24
27
  */
25
28
  const warnings = [];
29
+ /**
30
+ * Content width for debug display (matches Debug component)
31
+ * Box width 80 - 2 borders - 4 padding = 74 chars
32
+ */
33
+ const DISPLAY_CONTENT_WIDTH = 74;
34
+ /**
35
+ * File logging configuration
36
+ */
37
+ const LOGS_DIR = join(homedir(), '.pls', 'logs');
38
+ /**
39
+ * Whether running on Windows (affects filename separators)
40
+ */
41
+ const IS_WINDOWS = platform() === 'win32';
42
+ /**
43
+ * Maximum number of letter suffixes (a-z) for unique filenames
44
+ */
45
+ const MAX_LETTER_SUFFIXES = 26;
46
+ /**
47
+ * Pad a number with leading zeros to the specified width
48
+ */
49
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
50
+ /**
51
+ * Current session's log file path (null until first log entry)
52
+ */
53
+ let currentLogFile = null;
54
+ /**
55
+ * Filesystem instance for file operations (injectable for testing)
56
+ */
57
+ let fileSystem = defaultFileSystem;
58
+ /**
59
+ * Set the filesystem instance (used for testing)
60
+ */
61
+ export function setFileSystem(fs) {
62
+ fileSystem = fs;
63
+ }
64
+ /**
65
+ * Reset the session log file (used for testing)
66
+ */
67
+ export function resetSessionLog() {
68
+ currentLogFile = null;
69
+ }
70
+ /**
71
+ * Generate a timestamped log file path using local time
72
+ * Format: ~/.pls/logs/YYYY-MM-DD/HH:MM:SS.log.md (HH-MM-SS on Windows)
73
+ */
74
+ function getLogFilePath() {
75
+ const now = new Date();
76
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
77
+ const separator = IS_WINDOWS ? '-' : ':';
78
+ const time = `${pad(now.getHours())}${separator}${pad(now.getMinutes())}${separator}${pad(now.getSeconds())}`;
79
+ return join(LOGS_DIR, date, `${time}.log.md`);
80
+ }
81
+ /**
82
+ * Generate a unique log file path by adding suffix if file exists
83
+ */
84
+ function getUniqueLogFilePath(basePath) {
85
+ if (!fileSystem.exists(basePath)) {
86
+ return basePath;
87
+ }
88
+ const dir = dirname(basePath);
89
+ const ext = '.log.md';
90
+ const name = basePath.slice(dir.length + 1, -ext.length);
91
+ for (let i = 0; i < MAX_LETTER_SUFFIXES; i++) {
92
+ const suffix = String.fromCharCode(97 + i); // a-z
93
+ const candidate = join(dir, `${name}-${suffix}${ext}`);
94
+ if (!fileSystem.exists(candidate)) {
95
+ return candidate;
96
+ }
97
+ }
98
+ // Fallback: use milliseconds for uniqueness (avoids overwriting)
99
+ return join(dir, `${name}-${pad(new Date().getMilliseconds(), 3)}${ext}`);
100
+ }
101
+ /**
102
+ * Initialize the session log file if not already created
103
+ */
104
+ function initializeSessionLog() {
105
+ if (currentLogFile)
106
+ return true;
107
+ try {
108
+ const basePath = getLogFilePath();
109
+ const logDir = dirname(basePath);
110
+ if (!fileSystem.exists(logDir)) {
111
+ fileSystem.createDirectory(logDir, { recursive: true });
112
+ }
113
+ const logPath = getUniqueLogFilePath(basePath);
114
+ fileSystem.writeFile(logPath, '');
115
+ currentLogFile = logPath;
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Append content to the current session's log file
124
+ */
125
+ function appendToLog(content) {
126
+ if (!initializeSessionLog() || !currentLogFile)
127
+ return;
128
+ try {
129
+ fileSystem.appendFile(currentLogFile, content);
130
+ }
131
+ catch {
132
+ // Silently fail - logging should not crash the app
133
+ }
134
+ }
26
135
  /**
27
136
  * Initialize the logger with the current debug level from config
28
137
  */
@@ -61,11 +170,6 @@ export function getWarnings() {
61
170
  warnings.length = 0;
62
171
  return result;
63
172
  }
64
- /**
65
- * Content width for debug display (matches Debug component)
66
- * Box width 80 - 2 borders - 4 padding = 74 chars
67
- */
68
- const DISPLAY_CONTENT_WIDTH = 74;
69
173
  /**
70
174
  * Join sections with separators matching display width
71
175
  */
@@ -139,43 +243,78 @@ function formatSkillsForDisplay(formattedSkills) {
139
243
  *
140
244
  * - LLM: Returns header + base instructions + formatted skills (as sent to LLM)
141
245
  * - Skills: Returns header + skills with visual separators (no base instructions)
142
- * - Summary: Returns header + skill summaries (Name, Steps, Execution)
246
+ * - Summary: Returns header + context (caller-provided summary)
143
247
  */
144
- export function formatPromptContent(toolName, command, baseInstructions, formattedSkills, mode, definitions) {
145
- const header = ['', `Tool: ${toolName}`, `Command: ${command}`];
248
+ export function formatPromptContent(toolName, command, baseInstructions, formattedSkills, mode, context) {
146
249
  switch (mode) {
147
- case PromptDisplay.LLM:
250
+ case PromptDisplay.LLM: {
251
+ const header = ['', `**Tool:** ${toolName}`];
148
252
  return [...header, '', baseInstructions + formattedSkills].join('\n');
253
+ }
149
254
  case PromptDisplay.Skills: {
150
- // Layout: header -> separator -> skills with visual separators
151
- const headerString = header.join('\n');
255
+ const header = `\nTool: ${toolName}\nCommand: ${command}`;
152
256
  const skillsDisplay = formatSkillsForDisplay(formattedSkills);
153
- return joinWithSeparators([headerString, skillsDisplay]);
257
+ return joinWithSeparators([header, skillsDisplay]);
154
258
  }
155
259
  case PromptDisplay.Summary: {
156
- const headerString = header.join('\n');
157
- const summary = definitions
158
- ? formatSkillsSummary(definitions)
159
- : '(no skills)';
160
- return joinWithSeparators([headerString, summary]);
260
+ const header = `\nTool: ${toolName}\nCommand: ${command}`;
261
+ return context
262
+ ? joinWithSeparators([header, context])
263
+ : joinWithSeparators([header]);
161
264
  }
162
265
  }
163
266
  }
267
+ /**
268
+ * Context for tools that have no skills or custom context
269
+ */
270
+ export const NO_SKILLS_CONTEXT = '(no skills)';
271
+ /**
272
+ * Format skill definitions as context for debug display
273
+ * Returns NO_SKILLS_CONTEXT when definitions array is empty
274
+ */
275
+ export function formatSkillsContext(definitions) {
276
+ if (definitions.length === 0) {
277
+ return NO_SKILLS_CONTEXT;
278
+ }
279
+ return formatSkillsSummary(definitions);
280
+ }
281
+ /**
282
+ * Format config structure as context for debug display
283
+ */
284
+ export function formatConfigContext(structure, configuredKeys) {
285
+ const structureYaml = Object.entries(structure)
286
+ .map(([key, desc]) => `${key}: ${String(desc)}`)
287
+ .join('\n');
288
+ const keysSection = configuredKeys.length > 0
289
+ ? configuredKeys.map((k) => `- ${k}`).join('\n')
290
+ : '(none)';
291
+ return ('## Config Structure\n\n' +
292
+ structureYaml +
293
+ '\n\n## Configured Keys\n\n' +
294
+ keysSection);
295
+ }
164
296
  /**
165
297
  * Create debug component for system prompts sent to the LLM
166
- * Only creates at Verbose level
298
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
167
299
  *
168
300
  * @param toolName - Name of the tool being invoked
169
301
  * @param command - User command being processed
170
302
  * @param baseInstructions - Base tool instructions (without skills)
171
303
  * @param formattedSkills - Formatted skills section (as sent to LLM)
172
- * @param definitions - Parsed skill definitions for summary display
304
+ * @param context - Context summary to display in debug output
173
305
  */
174
- export function logPrompt(toolName, command, baseInstructions, formattedSkills, definitions = []) {
306
+ export function logPrompt(toolName, command, baseInstructions, formattedSkills, context) {
307
+ // Write to file at Info or Verbose level (full LLM format)
308
+ if (currentDebugLevel !== DebugLevel.None) {
309
+ const userPrompt = `# User Command\n\n\`\`\`\n${command}\n\`\`\`\n\n`;
310
+ const fileContent = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.LLM);
311
+ appendToLog(userPrompt + '# System Prompt\n' + fileContent + '\n\n');
312
+ }
313
+ // Create UI component only at Verbose level
175
314
  if (currentDebugLevel !== DebugLevel.Verbose) {
176
315
  return null;
177
316
  }
178
- const content = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.Summary, definitions);
317
+ const content = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.Summary, context);
179
318
  // Calculate stats for the full prompt
180
319
  const fullPrompt = baseInstructions + formattedSkills;
181
320
  const lines = fullPrompt.split('\n').length;
@@ -185,18 +324,27 @@ export function logPrompt(toolName, command, baseInstructions, formattedSkills,
185
324
  }
186
325
  /**
187
326
  * Create debug component for LLM responses received
188
- * Only creates at Verbose level
327
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
189
328
  */
190
329
  export function logResponse(toolName, response, durationMs) {
330
+ const jsonContent = JSON.stringify(response, null, 2);
331
+ // Write to file at Info or Verbose level (markdown format)
332
+ if (currentDebugLevel !== DebugLevel.None) {
333
+ const fileContent = [
334
+ '',
335
+ `**Tool:** ${toolName}`,
336
+ '',
337
+ '```json',
338
+ jsonContent,
339
+ '```',
340
+ ].join('\n');
341
+ appendToLog('# LLM Response\n' + fileContent + '\n\n');
342
+ }
343
+ // Create UI component only at Verbose level
191
344
  if (currentDebugLevel !== DebugLevel.Verbose) {
192
345
  return null;
193
346
  }
194
- const content = [
195
- '',
196
- `Tool: ${toolName}`,
197
- '',
198
- JSON.stringify(response, null, 2),
199
- ].join('\n');
347
+ const content = ['', `Tool: ${toolName}`, '', jsonContent].join('\n');
200
348
  const title = `LLM RESPONSE (${String(durationMs)} ms)`;
201
349
  return createDebug({ title, content, color: Palette.LightGray });
202
350
  }
@@ -148,11 +148,14 @@ export function formatErrorMessage(error) {
148
148
  return rawMessage;
149
149
  }
150
150
  /**
151
- * Returns an execution error message with varied phrasing.
152
- * Error details are shown in the task output, so this is just a summary.
153
- * Randomly selects from variations to sound natural.
151
+ * Returns an execution error message.
152
+ * If a specific error is provided, returns it directly.
153
+ * Otherwise, returns a generic failure message with varied phrasing.
154
154
  */
155
- export function getExecutionErrorMessage(_error) {
155
+ export function getExecutionErrorMessage(error) {
156
+ if (error) {
157
+ return error;
158
+ }
156
159
  const messages = [
157
160
  'The execution failed.',
158
161
  'Execution has failed.',
@@ -0,0 +1,304 @@
1
+ import { execFile, execSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { platform } from 'os';
4
+ import { promisify } from 'util';
5
+ // Memory monitoring constants
6
+ const MEMORY_CHECK_INTERVAL = 250;
7
+ const DEFAULT_PAGE_SIZE = 4096;
8
+ export const SIGKILL_GRACE_PERIOD = 3000;
9
+ /**
10
+ * Get system page size in bytes.
11
+ * Computed lazily on first call, then cached at module level.
12
+ */
13
+ let cachedPageSize;
14
+ function getPageSize() {
15
+ if (cachedPageSize !== undefined) {
16
+ return cachedPageSize;
17
+ }
18
+ if (platform() === 'linux') {
19
+ try {
20
+ const output = execSync('getconf PAGESIZE', {
21
+ encoding: 'utf-8',
22
+ timeout: 1000,
23
+ }).trim();
24
+ const size = parseInt(output, 10);
25
+ if (!isNaN(size) && size > 0) {
26
+ cachedPageSize = size;
27
+ return cachedPageSize;
28
+ }
29
+ }
30
+ catch {
31
+ // Fall through to default
32
+ }
33
+ }
34
+ cachedPageSize = DEFAULT_PAGE_SIZE;
35
+ return cachedPageSize;
36
+ }
37
+ /**
38
+ * Gracefully terminate a child process with SIGTERM, escalating to SIGKILL
39
+ * after a grace period if the process doesn't terminate.
40
+ * Returns the kill timeout ID for cleanup.
41
+ */
42
+ export function killGracefully(child, gracePeriod = SIGKILL_GRACE_PERIOD) {
43
+ child.kill('SIGTERM');
44
+ return setTimeout(() => {
45
+ try {
46
+ child.kill('SIGKILL');
47
+ }
48
+ catch {
49
+ // Process already terminated
50
+ }
51
+ }, gracePeriod);
52
+ }
53
+ const execFileAsync = promisify(execFile);
54
+ /**
55
+ * Run a command asynchronously and return stdout.
56
+ * Returns undefined on error or timeout.
57
+ */
58
+ async function runCommandAsync(command, args, timeout = 1000) {
59
+ try {
60
+ const { stdout } = await execFileAsync(command, args, { timeout });
61
+ return stdout.trim();
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Get memory usage of a process in bytes.
69
+ * Returns undefined if the process doesn't exist or memory can't be read.
70
+ * On macOS, uses async subprocess; on Linux, reads from /proc (fast).
71
+ */
72
+ async function getProcessMemoryBytes(pid) {
73
+ try {
74
+ if (platform() === 'linux') {
75
+ // Linux: Read from /proc/[pid]/statm (memory in pages)
76
+ // This is fast and effectively non-blocking for procfs
77
+ const statmPath = `/proc/${pid}/statm`;
78
+ if (!existsSync(statmPath))
79
+ return undefined;
80
+ const statm = readFileSync(statmPath, 'utf-8');
81
+ const rssPages = parseInt(statm.split(' ')[1], 10);
82
+ return rssPages * getPageSize();
83
+ }
84
+ else {
85
+ // macOS/BSD: Use ps command asynchronously
86
+ const output = await runCommandAsync('ps', [
87
+ '-o',
88
+ 'rss=',
89
+ '-p',
90
+ `${pid}`,
91
+ ]);
92
+ if (!output)
93
+ return undefined;
94
+ const rssKB = parseInt(output, 10);
95
+ if (isNaN(rssKB))
96
+ return undefined;
97
+ return rssKB * 1024;
98
+ }
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ /**
105
+ * Get child PIDs of a single process.
106
+ */
107
+ async function getChildPids(pid) {
108
+ try {
109
+ let output;
110
+ if (platform() === 'linux') {
111
+ output = await runCommandAsync('ps', ['-o', 'pid=', '--ppid', `${pid}`]);
112
+ }
113
+ else {
114
+ output = await runCommandAsync('pgrep', ['-P', `${pid}`]);
115
+ }
116
+ if (!output)
117
+ return [];
118
+ return output
119
+ .split('\n')
120
+ .map((p) => parseInt(p.trim(), 10))
121
+ .filter((p) => !isNaN(p) && p > 0);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ /**
128
+ * Get all descendant PIDs of a process.
129
+ * Uses iterative BFS with parallel child lookups at each level.
130
+ */
131
+ async function getAllDescendantPids(pid) {
132
+ const visited = new Set();
133
+ const pids = [];
134
+ let currentLevel = [pid];
135
+ while (currentLevel.length > 0) {
136
+ // Add unvisited PIDs from current level
137
+ const unvisited = currentLevel.filter((p) => !visited.has(p));
138
+ for (const p of unvisited)
139
+ visited.add(p);
140
+ pids.push(...unvisited);
141
+ // Fetch children of all current level PIDs in parallel
142
+ const childArrays = await Promise.all(unvisited.map(getChildPids));
143
+ currentLevel = childArrays.flat();
144
+ }
145
+ return pids;
146
+ }
147
+ /**
148
+ * Get total memory usage of a process tree.
149
+ * Sums memory of the process and all its descendants.
150
+ */
151
+ async function getProcessTreeMemoryBytes(pid) {
152
+ try {
153
+ const allPids = await getAllDescendantPids(pid);
154
+ let totalBytes = 0;
155
+ for (const p of allPids) {
156
+ const mem = await getProcessMemoryBytes(p);
157
+ if (mem)
158
+ totalBytes += mem;
159
+ }
160
+ return totalBytes > 0 ? totalBytes : undefined;
161
+ }
162
+ catch {
163
+ return getProcessMemoryBytes(pid);
164
+ }
165
+ }
166
+ /** Monitor lifecycle state */
167
+ var MonitorState;
168
+ (function (MonitorState) {
169
+ MonitorState["Idle"] = "idle";
170
+ MonitorState["Running"] = "running";
171
+ MonitorState["Stopped"] = "stopped";
172
+ MonitorState["Killed"] = "killed";
173
+ })(MonitorState || (MonitorState = {}));
174
+ /**
175
+ * Monitors a child process memory and kills it when the limit is exceeded.
176
+ * Uses async self-scheduling to avoid blocking the event loop.
177
+ * By default monitors only the root process; set includeDescendants for tree.
178
+ */
179
+ export class MemoryMonitor {
180
+ nextCheckId;
181
+ killTimeoutId;
182
+ child;
183
+ memoryLimit;
184
+ limitBytes;
185
+ onExceeded;
186
+ onMemoryUpdate;
187
+ state = MonitorState.Idle;
188
+ getMemoryFn;
189
+ currentMemoryMB = 0;
190
+ constructor(child, memoryLimitMB, onExceeded, getMemoryFn, onMemoryUpdate) {
191
+ this.child = child;
192
+ this.memoryLimit = memoryLimitMB;
193
+ this.limitBytes = memoryLimitMB * 1024 * 1024;
194
+ this.onExceeded = onExceeded;
195
+ this.onMemoryUpdate = onMemoryUpdate;
196
+ // Always monitor full process tree by default
197
+ this.getMemoryFn = getMemoryFn ?? getProcessTreeMemoryBytes;
198
+ }
199
+ /**
200
+ * Start monitoring the child process memory.
201
+ * Uses async self-scheduling loop instead of setInterval for non-blocking.
202
+ * Performs an immediate check, then polls at regular intervals.
203
+ */
204
+ start() {
205
+ if (!this.child.pid)
206
+ return;
207
+ this.state = MonitorState.Running;
208
+ void this.checkMemory();
209
+ }
210
+ /**
211
+ * Schedule the next memory check after the configured interval.
212
+ */
213
+ scheduleNextCheck() {
214
+ if (this.state !== MonitorState.Running)
215
+ return;
216
+ this.nextCheckId = setTimeout(() => {
217
+ void this.checkMemory();
218
+ }, MEMORY_CHECK_INTERVAL);
219
+ }
220
+ /**
221
+ * Perform async memory check and schedule next one.
222
+ */
223
+ async checkMemory() {
224
+ if (this.state !== MonitorState.Running || !this.child.pid)
225
+ return;
226
+ let memoryBytes;
227
+ try {
228
+ memoryBytes = await this.getMemoryFn(this.child.pid);
229
+ }
230
+ catch {
231
+ // Memory reading failed, schedule next check and continue
232
+ this.scheduleNextCheck();
233
+ return;
234
+ }
235
+ // Re-check after async operation - state may have changed
236
+ if (this.state !== MonitorState.Running)
237
+ return; // eslint-disable-line @typescript-eslint/no-unnecessary-condition
238
+ // Track current memory
239
+ if (memoryBytes !== undefined) {
240
+ this.currentMemoryMB = Math.ceil(memoryBytes / 1024 / 1024);
241
+ this.onMemoryUpdate?.(this.currentMemoryMB);
242
+ }
243
+ if (memoryBytes !== undefined && memoryBytes >= this.limitBytes) {
244
+ this.terminateProcess(memoryBytes);
245
+ }
246
+ else {
247
+ this.scheduleNextCheck();
248
+ }
249
+ }
250
+ /**
251
+ * Stop monitoring and cancel any pending timeouts.
252
+ */
253
+ stop() {
254
+ if (this.state !== MonitorState.Killed) {
255
+ this.state = MonitorState.Stopped;
256
+ }
257
+ if (this.nextCheckId) {
258
+ clearTimeout(this.nextCheckId);
259
+ this.nextCheckId = undefined;
260
+ }
261
+ if (this.killTimeoutId) {
262
+ clearTimeout(this.killTimeoutId);
263
+ this.killTimeoutId = undefined;
264
+ }
265
+ }
266
+ /**
267
+ * Terminate the child process due to memory limit exceeded.
268
+ */
269
+ terminateProcess(currentMemoryBytes) {
270
+ if (this.state === MonitorState.Killed)
271
+ return;
272
+ this.state = MonitorState.Killed;
273
+ // Clear only the next check timeout, keep killTimeoutId for cleanup
274
+ if (this.nextCheckId) {
275
+ clearTimeout(this.nextCheckId);
276
+ this.nextCheckId = undefined;
277
+ }
278
+ // Kill first, then notify - ensures termination even if callback throws
279
+ this.killTimeoutId = killGracefully(this.child);
280
+ const info = {
281
+ used: Math.ceil(currentMemoryBytes / 1024 / 1024),
282
+ limit: this.memoryLimit,
283
+ };
284
+ try {
285
+ this.onExceeded?.(info);
286
+ }
287
+ catch {
288
+ // Ignore callback errors - kill already initiated
289
+ }
290
+ }
291
+ /**
292
+ * Check if the process was killed due to memory limit.
293
+ */
294
+ wasKilledByMemoryLimit() {
295
+ return this.state === MonitorState.Killed;
296
+ }
297
+ /**
298
+ * Get current memory in MB.
299
+ * Returns 0 if no memory has been recorded yet.
300
+ */
301
+ getCurrentMemoryMB() {
302
+ return this.currentMemoryMB;
303
+ }
304
+ }
@@ -0,0 +1,14 @@
1
+ import { performance } from 'perf_hooks';
2
+ /**
3
+ * Prevent perf_hooks memory leak warning during long-running operations.
4
+ * React and Ink create performance measurements internally that accumulate
5
+ * in the global buffer. This clears them immediately and periodically.
6
+ */
7
+ export function preventPerformanceBufferOverflow(intervalMs = 60000) {
8
+ performance.clearMarks();
9
+ performance.clearMeasures();
10
+ setInterval(() => {
11
+ performance.clearMarks();
12
+ performance.clearMeasures();
13
+ }, intervalMs).unref();
14
+ }