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.
- package/README.md +134 -46
- package/dist/components/Component.js +5 -3
- package/dist/components/controllers/Command.js +3 -19
- package/dist/components/controllers/Config.js +109 -75
- package/dist/components/controllers/Execute.js +17 -14
- package/dist/components/controllers/Introspect.js +2 -4
- package/dist/components/controllers/Validate.js +3 -2
- package/dist/components/views/Execute.js +1 -1
- package/dist/components/views/Output.js +89 -35
- package/dist/components/views/Subtask.js +12 -7
- package/dist/components/views/Task.js +4 -5
- package/dist/components/views/Upcoming.js +1 -1
- package/dist/configuration/io.js +10 -0
- package/dist/configuration/schema.js +6 -0
- package/dist/configuration/validation.js +5 -0
- package/dist/execution/processing.js +45 -14
- package/dist/execution/runner.js +46 -30
- package/dist/index.js +2 -0
- package/dist/services/anthropic.js +5 -4
- package/dist/services/filesystem.js +13 -1
- package/dist/services/logger.js +176 -28
- package/dist/services/messages.js +7 -4
- package/dist/services/monitor.js +304 -0
- package/dist/services/performance.js +14 -0
- package/dist/services/refinement.js +7 -16
- package/dist/services/router.js +223 -95
- package/dist/services/shell.js +49 -16
- package/dist/services/utils.js +11 -0
- package/dist/skills/execute.md +82 -3
- package/dist/skills/schedule.md +7 -0
- package/package.json +11 -10
|
@@ -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,
|
|
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}'`);
|
package/dist/services/logger.js
CHANGED
|
@@ -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 +
|
|
246
|
+
* - Summary: Returns header + context (caller-provided summary)
|
|
143
247
|
*/
|
|
144
|
-
export function formatPromptContent(toolName, command, baseInstructions, formattedSkills, mode,
|
|
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
|
-
|
|
151
|
-
const headerString = header.join('\n');
|
|
255
|
+
const header = `\nTool: ${toolName}\nCommand: ${command}`;
|
|
152
256
|
const skillsDisplay = formatSkillsForDisplay(formattedSkills);
|
|
153
|
-
return joinWithSeparators([
|
|
257
|
+
return joinWithSeparators([header, skillsDisplay]);
|
|
154
258
|
}
|
|
155
259
|
case PromptDisplay.Summary: {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
?
|
|
159
|
-
:
|
|
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
|
-
*
|
|
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
|
|
304
|
+
* @param context - Context summary to display in debug output
|
|
173
305
|
*/
|
|
174
|
-
export function logPrompt(toolName, command, baseInstructions, formattedSkills,
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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(
|
|
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
|
+
}
|