prompt-language-shell 0.9.6 → 0.9.8

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
@@ -31,7 +31,7 @@ Here's what I can help with:
31
31
  - Configure - manage and configure system settings
32
32
  - Answer - respond to questions and provide information
33
33
  - Execute - run shell commands and process operations
34
- ```
34
+ ```
35
35
 
36
36
  Skills are custom workflows you can define to teach `pls` about your specific
37
37
  projects and commands. Once defined, you can use them naturally:
@@ -87,16 +87,47 @@ commands your environment requires.
87
87
 
88
88
  ## Configuration
89
89
 
90
- Your configuration is stored in `~/.plsrc` as a YAML file. Supported settings:
90
+ Your configuration is stored in `~/.plsrc` as a YAML file:
91
+
92
+ ```yaml
93
+ # Mandatory
94
+ anthropic:
95
+ key: sk-ant-...
96
+ model: claude-...
97
+
98
+ # Optional
99
+ settings:
100
+ memory: 1024 # Child process memory limit (MB)
101
+ debug: none # none | info | verbose
102
+
103
+ # Custom
104
+ project:
105
+ path: ~/projects/app
106
+ ```
107
+
108
+ Skills can define their own configuration properties via a `Config` section. When
109
+ a skill requires config values that don't exist, `pls` prompts you to provide
110
+ them before execution. See [Skills](#skills) for details.
111
+
112
+ ## Reference
113
+
114
+ ### Debug Mode
115
+ Press `Shift+Tab` during execution to cycle through debug levels
116
+ (none → info → verbose).
117
+ Logs are saved to `~/.pls/logs/` when debug is `info` or `verbose`.
91
118
 
92
- - `anthropic.key` - Your API key
93
- - `anthropic.model` - The model to use
119
+ ### Data Locations
120
+ ```
121
+ ~/.plsrc # Configuration
122
+ ~/.pls/skills/ # Custom skills
123
+ ~/.pls/logs/ # Debug logs
124
+ ```
94
125
 
95
126
  ## Skills
96
127
 
97
128
  Skills let you teach `pls` about your project-specific workflows. Create
98
- markdown files in `~/.pls/skills/` to define custom operations that `pls` can
99
- understand and execute.
129
+ markdown files in `~/.pls/skills/` to define custom operations that
130
+ `pls` can understand and execute.
100
131
 
101
132
  For complete documentation, see [docs/SKILLS.md](./docs/SKILLS.md).
102
133
 
@@ -139,9 +139,9 @@ export function Execute({ tasks: inputTasks, status, service, upcoming, label, r
139
139
  lifecycleHandlers.completeActive();
140
140
  return;
141
141
  }
142
- // Create task data from commands
143
- const tasks = result.commands.map((cmd, index) => ({
144
- label: inputTasks[index]?.action ?? cmd.description,
142
+ // Create task data from commands - use descriptions from execute response
143
+ const tasks = result.commands.map((cmd) => ({
144
+ label: cmd.description,
145
145
  command: cmd,
146
146
  status: ExecutionStatus.Pending,
147
147
  elapsed: 0,
@@ -4,15 +4,21 @@ import { Palette } from '../../services/colors.js';
4
4
  import { ExecutionStatus } from '../../services/shell.js';
5
5
  const MAX_LINES = 8;
6
6
  const MAX_WIDTH = 75;
7
- const SHORT_OUTPUT_THRESHOLD = 4;
8
7
  const MINIMAL_INFO_THRESHOLD = 2;
9
8
  /**
10
- * Get the last N lines from text, filtering out empty/whitespace-only lines
9
+ * Get the last N lines from text, filtering out empty/whitespace-only lines.
10
+ * Handles carriage returns used in progress output by keeping only the
11
+ * content after the last \r in each line.
11
12
  */
12
13
  export function getLastLines(text, maxLines = MAX_LINES) {
13
14
  const lines = text
14
15
  .trim()
15
16
  .split(/\r?\n/)
17
+ .map((line) => {
18
+ // Handle carriage returns: keep only content after the last \r
19
+ const lastCR = line.lastIndexOf('\r');
20
+ return lastCR >= 0 ? line.slice(lastCR + 1) : line;
21
+ })
16
22
  .filter((line) => line.trim().length > 0);
17
23
  return lines.length <= maxLines ? lines : lines.slice(-maxLines);
18
24
  }
@@ -29,9 +35,6 @@ export function computeDisplayConfig(stdout, stderr, status, isFinished) {
29
35
  const stderrLines = hasStderr ? getLastLines(stderr) : [];
30
36
  // Show stdout if no stderr, or if stderr is minimal (provides context)
31
37
  const showStdout = hasStdout && (!hasStderr || stderrLines.length <= MINIMAL_INFO_THRESHOLD);
32
- // Use word wrapping for short outputs to show more detail
33
- const totalLines = stdoutLines.length + stderrLines.length;
34
- const wrapMode = totalLines <= SHORT_OUTPUT_THRESHOLD ? 'wrap' : 'truncate-end';
35
38
  // Darker colors for finished tasks
36
39
  const baseColor = isFinished ? Palette.DarkGray : Palette.Gray;
37
40
  const stderrColor = status === ExecutionStatus.Failed ? Palette.Yellow : baseColor;
@@ -39,7 +42,6 @@ export function computeDisplayConfig(stdout, stderr, status, isFinished) {
39
42
  stdoutLines,
40
43
  stderrLines,
41
44
  showStdout,
42
- wrapMode,
43
45
  stdoutColor: baseColor,
44
46
  stderrColor,
45
47
  };
@@ -48,7 +50,7 @@ export function Output({ stdout, stderr, status, isFinished }) {
48
50
  const config = computeDisplayConfig(stdout, stderr, status, isFinished ?? false);
49
51
  if (!config)
50
52
  return null;
51
- const { stdoutLines, stderrLines, showStdout, wrapMode, stdoutColor, stderrColor, } = config;
53
+ const { stdoutLines, stderrLines, showStdout, stdoutColor, stderrColor } = config;
52
54
  return (_jsxs(Box, { marginTop: 1, marginLeft: 5, flexDirection: "column", width: MAX_WIDTH, children: [showStdout &&
53
- stdoutLines.map((line, index) => (_jsx(Text, { color: stdoutColor, wrap: wrapMode, children: line }, `out-${index}`))), stderrLines.map((line, index) => (_jsx(Text, { color: stderrColor, wrap: wrapMode, children: line }, `err-${index}`)))] }));
55
+ stdoutLines.map((line, index) => (_jsx(Text, { color: stdoutColor, wrap: "wrap", children: line }, `out-${index}`))), stderrLines.map((line, index) => (_jsx(Text, { color: stderrColor, wrap: "wrap", children: line }, `err-${index}`)))] }));
54
56
  }
@@ -104,3 +104,13 @@ export function loadDebugSetting(fs = defaultFileSystem) {
104
104
  return DebugLevel.None;
105
105
  }
106
106
  }
107
+ const DEFAULT_MEMORY_LIMIT = 1024;
108
+ export function loadMemorySetting(fs = defaultFileSystem) {
109
+ try {
110
+ const config = loadConfig(fs);
111
+ return config.settings?.memory ?? DEFAULT_MEMORY_LIMIT;
112
+ }
113
+ catch {
114
+ return DEFAULT_MEMORY_LIMIT;
115
+ }
116
+ }
@@ -38,6 +38,12 @@ const coreConfigSchema = {
38
38
  default: DebugLevel.None,
39
39
  description: 'Debug mode',
40
40
  },
41
+ 'settings.memory': {
42
+ type: ConfigDefinitionType.Number,
43
+ required: false,
44
+ default: 1024,
45
+ description: 'Child process memory limit (MB)',
46
+ },
41
47
  };
42
48
  /**
43
49
  * Get complete configuration schema
@@ -37,6 +37,11 @@ export function validateConfig(parsed) {
37
37
  validatedConfig.settings.debug = settings.debug;
38
38
  }
39
39
  }
40
+ if ('memory' in settings) {
41
+ if (typeof settings.memory === 'number' && settings.memory > 0) {
42
+ validatedConfig.settings.memory = settings.memory;
43
+ }
44
+ }
40
45
  }
41
46
  return validatedConfig;
42
47
  }
@@ -1,3 +1,5 @@
1
+ import { stringify } from 'yaml';
2
+ import { loadMemorySetting } from '../configuration/io.js';
1
3
  import { loadUserConfig } from '../services/loader.js';
2
4
  import { replacePlaceholders } from '../services/resolver.js';
3
5
  import { validatePlaceholderResolution } from './validation.js';
@@ -10,6 +12,36 @@ export function fixEscapedQuotes(command) {
10
12
  // Replace ="value" with =\"value\"
11
13
  return command.replace(/="([^"]*)"/g, '=\\"$1\\"');
12
14
  }
15
+ /**
16
+ * Format a task as YAML with action line and metadata block
17
+ */
18
+ export function formatTaskAsYaml(action, metadata, indent = '') {
19
+ const normalizedAction = action.charAt(0).toLowerCase() + action.slice(1);
20
+ if (!metadata || Object.keys(metadata).length === 0) {
21
+ return normalizedAction;
22
+ }
23
+ const metadataYaml = stringify({ metadata })
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => `${indent}${line}`)
27
+ .join('\n');
28
+ return `${normalizedAction}\n\n${metadataYaml}`;
29
+ }
30
+ /**
31
+ * Build task descriptions for the LLM
32
+ * Single task: use as-is; multiple tasks: add header and bullet prefix
33
+ */
34
+ function buildTaskDescriptions(resolvedTasks) {
35
+ if (resolvedTasks.length === 1) {
36
+ const { action, params } = resolvedTasks[0];
37
+ return formatTaskAsYaml(action, params);
38
+ }
39
+ const header = `complete these ${resolvedTasks.length} tasks:`;
40
+ const bulletedTasks = resolvedTasks
41
+ .map(({ action, params }) => `- ${formatTaskAsYaml(action, params, ' ')}`)
42
+ .join('\n\n');
43
+ return `${header}\n\n${bulletedTasks}`;
44
+ }
13
45
  /**
14
46
  * Processes tasks through the AI service to generate executable commands.
15
47
  * Resolves placeholders in task descriptions and validates the results.
@@ -17,27 +49,26 @@ export function fixEscapedQuotes(command) {
17
49
  export async function processTasks(tasks, service) {
18
50
  // Load user config for placeholder resolution
19
51
  const userConfig = loadUserConfig();
20
- // Format tasks for the execute tool and resolve placeholders
21
- const taskList = tasks
22
- .map((task) => {
23
- const resolvedAction = replacePlaceholders(task.action, userConfig);
24
- const params = task.params
25
- ? ` (params: ${JSON.stringify(task.params)})`
26
- : '';
27
- return `- ${resolvedAction}${params}`;
28
- })
29
- .join('\n');
30
- // Build message with confirmed schedule header
31
- const taskDescriptions = `Confirmed schedule (${tasks.length} tasks):\n${taskList}`;
52
+ const memoryLimitMB = loadMemorySetting();
53
+ // Resolve placeholders in task actions
54
+ const resolvedTasks = tasks.map((task) => ({
55
+ action: replacePlaceholders(task.action, userConfig),
56
+ params: task.params,
57
+ }));
58
+ const taskDescriptions = buildTaskDescriptions(resolvedTasks);
32
59
  // Call execute tool to get commands
33
60
  const result = await service.processWithTool(taskDescriptions, 'execute');
34
- // Resolve placeholders in command strings
61
+ // Resolve placeholders in command strings and inject memory limit
35
62
  const resolvedCommands = (result.commands || []).map((cmd) => {
36
63
  // Fix escaped quotes lost in JSON parsing
37
64
  const fixed = fixEscapedQuotes(cmd.command);
38
65
  const resolved = replacePlaceholders(fixed, userConfig);
39
66
  validatePlaceholderResolution(resolved);
40
- return { ...cmd, command: resolved };
67
+ return {
68
+ ...cmd,
69
+ command: resolved,
70
+ memoryLimit: memoryLimitMB,
71
+ };
41
72
  });
42
73
  return {
43
74
  message: result.message,
@@ -70,7 +70,7 @@ export async function executeTask(command, index, callbacks) {
70
70
  return { status: ExecutionStatus.Success, elapsed, output };
71
71
  }
72
72
  else {
73
- const errorMsg = result.errors || result.error || 'Command failed';
73
+ const errorMsg = result.error || result.errors || 'Command failed';
74
74
  error = errorMsg;
75
75
  const output = createOutput();
76
76
  callbacks.onUpdate(output);
package/dist/index.js CHANGED
@@ -5,7 +5,9 @@ import { dirname, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { render } from 'ink';
7
7
  import { DebugLevel } from './configuration/types.js';
8
+ import { preventPerformanceBufferOverflow } from './services/performance.js';
8
9
  import { Main } from './Main.js';
10
+ preventPerformanceBufferOverflow();
9
11
  const __filename = fileURLToPath(import.meta.url);
10
12
  const __dirname = dirname(__filename);
11
13
  // Get package info
@@ -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
  */
@@ -142,28 +246,28 @@ function formatSkillsForDisplay(formattedSkills) {
142
246
  * - Summary: Returns header + skill summaries (Name, Steps, Execution)
143
247
  */
144
248
  export function formatPromptContent(toolName, command, baseInstructions, formattedSkills, mode, definitions) {
145
- const header = ['', `Tool: ${toolName}`, `Command: ${command}`];
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');
260
+ const header = `\nTool: ${toolName}\nCommand: ${command}`;
157
261
  const summary = definitions
158
262
  ? formatSkillsSummary(definitions)
159
263
  : '(no skills)';
160
- return joinWithSeparators([headerString, summary]);
264
+ return joinWithSeparators([header, summary]);
161
265
  }
162
266
  }
163
267
  }
164
268
  /**
165
269
  * Create debug component for system prompts sent to the LLM
166
- * Only creates at Verbose level
270
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
167
271
  *
168
272
  * @param toolName - Name of the tool being invoked
169
273
  * @param command - User command being processed
@@ -172,6 +276,13 @@ export function formatPromptContent(toolName, command, baseInstructions, formatt
172
276
  * @param definitions - Parsed skill definitions for summary display
173
277
  */
174
278
  export function logPrompt(toolName, command, baseInstructions, formattedSkills, definitions = []) {
279
+ // Write to file at Info or Verbose level (full LLM format)
280
+ if (currentDebugLevel !== DebugLevel.None) {
281
+ const userPrompt = `# User Command\n\n\`\`\`\n${command}\n\`\`\`\n\n`;
282
+ const fileContent = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.LLM);
283
+ appendToLog(userPrompt + '# System Prompt\n' + fileContent + '\n\n');
284
+ }
285
+ // Create UI component only at Verbose level
175
286
  if (currentDebugLevel !== DebugLevel.Verbose) {
176
287
  return null;
177
288
  }
@@ -185,18 +296,27 @@ export function logPrompt(toolName, command, baseInstructions, formattedSkills,
185
296
  }
186
297
  /**
187
298
  * Create debug component for LLM responses received
188
- * Only creates at Verbose level
299
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
189
300
  */
190
301
  export function logResponse(toolName, response, durationMs) {
302
+ const jsonContent = JSON.stringify(response, null, 2);
303
+ // Write to file at Info or Verbose level (markdown format)
304
+ if (currentDebugLevel !== DebugLevel.None) {
305
+ const fileContent = [
306
+ '',
307
+ `**Tool:** ${toolName}`,
308
+ '',
309
+ '```json',
310
+ jsonContent,
311
+ '```',
312
+ ].join('\n');
313
+ appendToLog('# LLM Response\n' + fileContent + '\n\n');
314
+ }
315
+ // Create UI component only at Verbose level
191
316
  if (currentDebugLevel !== DebugLevel.Verbose) {
192
317
  return null;
193
318
  }
194
- const content = [
195
- '',
196
- `Tool: ${toolName}`,
197
- '',
198
- JSON.stringify(response, null, 2),
199
- ].join('\n');
319
+ const content = ['', `Tool: ${toolName}`, '', jsonContent].join('\n');
200
320
  const title = `LLM RESPONSE (${String(durationMs)} ms)`;
201
321
  return createDebug({ title, content, color: Palette.LightGray });
202
322
  }
@@ -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,288 @@
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 = 1000;
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
+ state = MonitorState.Idle;
187
+ getMemoryFn;
188
+ constructor(child, memoryLimitMB, onExceeded, getMemoryFn) {
189
+ this.child = child;
190
+ this.memoryLimit = memoryLimitMB;
191
+ this.limitBytes = memoryLimitMB * 1024 * 1024;
192
+ this.onExceeded = onExceeded;
193
+ // Always monitor full process tree by default
194
+ this.getMemoryFn = getMemoryFn ?? getProcessTreeMemoryBytes;
195
+ }
196
+ /**
197
+ * Start monitoring the child process memory.
198
+ * Uses async self-scheduling loop instead of setInterval for non-blocking.
199
+ */
200
+ start() {
201
+ if (!this.child.pid)
202
+ return;
203
+ this.state = MonitorState.Running;
204
+ this.scheduleNextCheck();
205
+ }
206
+ /**
207
+ * Schedule the next memory check after the configured interval.
208
+ */
209
+ scheduleNextCheck() {
210
+ if (this.state !== MonitorState.Running)
211
+ return;
212
+ this.nextCheckId = setTimeout(() => {
213
+ void this.checkMemory();
214
+ }, MEMORY_CHECK_INTERVAL);
215
+ }
216
+ /**
217
+ * Perform async memory check and schedule next one.
218
+ */
219
+ async checkMemory() {
220
+ if (this.state !== MonitorState.Running || !this.child.pid)
221
+ return;
222
+ let memoryBytes;
223
+ try {
224
+ memoryBytes = await this.getMemoryFn(this.child.pid);
225
+ }
226
+ catch {
227
+ // Memory reading failed, schedule next check and continue
228
+ this.scheduleNextCheck();
229
+ return;
230
+ }
231
+ // Re-check after async operation - state may have changed
232
+ if (this.state !== MonitorState.Running)
233
+ return; // eslint-disable-line @typescript-eslint/no-unnecessary-condition
234
+ if (memoryBytes !== undefined && memoryBytes >= this.limitBytes) {
235
+ this.terminateProcess(memoryBytes);
236
+ }
237
+ else {
238
+ this.scheduleNextCheck();
239
+ }
240
+ }
241
+ /**
242
+ * Stop monitoring and cancel any pending timeouts.
243
+ */
244
+ stop() {
245
+ if (this.state !== MonitorState.Killed) {
246
+ this.state = MonitorState.Stopped;
247
+ }
248
+ if (this.nextCheckId) {
249
+ clearTimeout(this.nextCheckId);
250
+ this.nextCheckId = undefined;
251
+ }
252
+ if (this.killTimeoutId) {
253
+ clearTimeout(this.killTimeoutId);
254
+ this.killTimeoutId = undefined;
255
+ }
256
+ }
257
+ /**
258
+ * Terminate the child process due to memory limit exceeded.
259
+ */
260
+ terminateProcess(currentMemoryBytes) {
261
+ if (this.state === MonitorState.Killed)
262
+ return;
263
+ this.state = MonitorState.Killed;
264
+ // Clear only the next check timeout, keep killTimeoutId for cleanup
265
+ if (this.nextCheckId) {
266
+ clearTimeout(this.nextCheckId);
267
+ this.nextCheckId = undefined;
268
+ }
269
+ // Kill first, then notify - ensures termination even if callback throws
270
+ this.killTimeoutId = killGracefully(this.child);
271
+ const info = {
272
+ used: Math.ceil(currentMemoryBytes / 1024 / 1024),
273
+ limit: this.memoryLimit,
274
+ };
275
+ try {
276
+ this.onExceeded?.(info);
277
+ }
278
+ catch {
279
+ // Ignore callback errors - kill already initiated
280
+ }
281
+ }
282
+ /**
283
+ * Check if the process was killed due to memory limit.
284
+ */
285
+ wasKilledByMemoryLimit() {
286
+ return this.state === MonitorState.Killed;
287
+ }
288
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { ComponentStatus, } from '../types/components.js';
2
- import { TaskType } from '../types/types.js';
2
+ import { formatTaskAsYaml } from '../execution/processing.js';
3
3
  import { createCommand, createRefinement } from './components.js';
4
4
  import { formatErrorMessage, getRefiningMessage } from './messages.js';
5
5
  import { routeTasksWithConfirm } from './router.js';
@@ -22,18 +22,15 @@ export async function handleRefinement(selectedTasks, service, originalCommand,
22
22
  });
23
23
  workflowHandlers.addToQueue(refinementDef);
24
24
  try {
25
- // Build refined command from selected tasks
25
+ // Build refined command with action line followed by YAML metadata
26
26
  const refinedCommand = selectedTasks
27
27
  .map((task) => {
28
+ // Replace commas with dashes for cleaner LLM prompt formatting
28
29
  const action = task.action.replace(/,/g, ' -');
29
- const type = task.type;
30
- // For execute/group tasks, use generic hint - let LLM decide based on skill
31
- if (type === TaskType.Execute || type === TaskType.Group) {
32
- return `${action} (shell execution)`;
33
- }
34
- return `${action} (type: ${type})`;
30
+ const metadata = { ...task.params, type: task.type };
31
+ return formatTaskAsYaml(action, metadata);
35
32
  })
36
- .join(', ');
33
+ .join('\n\n');
37
34
  // Call LLM to refine plan with selected tasks
38
35
  const refinedResult = await service.processWithTool(refinedCommand, 'schedule');
39
36
  // Complete the Refinement component with success state
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
+ import { killGracefully, MemoryMonitor, } from './monitor.js';
2
3
  export var ExecutionStatus;
3
4
  (function (ExecutionStatus) {
4
5
  ExecutionStatus["Pending"] = "pending";
@@ -60,12 +61,12 @@ export class DummyExecutor {
60
61
  }
61
62
  }
62
63
  // Marker for extracting pwd from command output
63
- const PWD_MARKER = '__PWD_MARKER_7x9k2m__';
64
- const MAX_OUTPUT_LINES = 128;
64
+ export const PWD_MARKER = '__PWD_MARKER_7x9k2m__';
65
+ export const MAX_OUTPUT_LINES = 128;
65
66
  /**
66
67
  * Limit output to last MAX_OUTPUT_LINES lines.
67
68
  */
68
- function limitLines(output) {
69
+ export function limitLines(output) {
69
70
  const lines = output.split('\n');
70
71
  return lines.slice(-MAX_OUTPUT_LINES).join('\n');
71
72
  }
@@ -73,7 +74,7 @@ function limitLines(output) {
73
74
  * Parse stdout to extract workdir and clean output.
74
75
  * Returns the cleaned output and the extracted workdir.
75
76
  */
76
- function parseWorkdir(rawOutput) {
77
+ export function parseWorkdir(rawOutput) {
77
78
  const markerIndex = rawOutput.lastIndexOf(PWD_MARKER);
78
79
  if (markerIndex === -1) {
79
80
  return { output: rawOutput };
@@ -88,7 +89,7 @@ function parseWorkdir(rawOutput) {
88
89
  * Manages streaming output while filtering out the PWD marker.
89
90
  * Buffers output to avoid emitting partial markers to the callback.
90
91
  */
91
- class OutputStreamer {
92
+ export class OutputStreamer {
92
93
  chunks = [];
93
94
  emittedLength = 0;
94
95
  callback;
@@ -183,18 +184,22 @@ export class RealExecutor {
183
184
  return;
184
185
  }
185
186
  // Handle timeout if specified
186
- const SIGKILL_GRACE_PERIOD = 3000;
187
187
  let timeoutId;
188
188
  let killTimeoutId;
189
189
  if (cmd.timeout && cmd.timeout > 0) {
190
190
  timeoutId = setTimeout(() => {
191
- child.kill('SIGTERM');
192
- // Escalate to SIGKILL if process doesn't terminate
193
- killTimeoutId = setTimeout(() => {
194
- child.kill('SIGKILL');
195
- }, SIGKILL_GRACE_PERIOD);
191
+ killTimeoutId = killGracefully(child);
196
192
  }, cmd.timeout);
197
193
  }
194
+ // Handle memory limit monitoring
195
+ let memoryMonitor;
196
+ let memoryInfo;
197
+ if (cmd.memoryLimit) {
198
+ memoryMonitor = new MemoryMonitor(child, cmd.memoryLimit, (info) => {
199
+ memoryInfo = info;
200
+ });
201
+ memoryMonitor.start();
202
+ }
198
203
  // Use OutputStreamer for buffered stdout streaming
199
204
  const stdoutStreamer = new OutputStreamer(this.outputCallback);
200
205
  child.stdout.on('data', (data) => {
@@ -217,6 +222,7 @@ export class RealExecutor {
217
222
  clearTimeout(timeoutId);
218
223
  if (killTimeoutId)
219
224
  clearTimeout(killTimeoutId);
225
+ memoryMonitor?.stop();
220
226
  const commandResult = {
221
227
  description: cmd.description,
222
228
  command: cmd.command,
@@ -228,20 +234,32 @@ export class RealExecutor {
228
234
  onProgress?.(ExecutionStatus.Failed);
229
235
  resolve(commandResult);
230
236
  });
231
- child.on('close', (code) => {
237
+ child.on('exit', (code) => {
232
238
  if (timeoutId)
233
239
  clearTimeout(timeoutId);
234
240
  if (killTimeoutId)
235
241
  clearTimeout(killTimeoutId);
236
- const success = code === 0;
242
+ memoryMonitor?.stop();
237
243
  const { output, workdir } = parseWorkdir(stdoutStreamer.getAccumulated());
244
+ // Check if terminated due to memory limit
245
+ const killedByMemoryLimit = memoryMonitor?.wasKilledByMemoryLimit();
246
+ const success = code === 0 && !killedByMemoryLimit;
247
+ let errorMessage;
248
+ if (killedByMemoryLimit && memoryInfo) {
249
+ errorMessage =
250
+ `Process exceeded ${memoryInfo.limit} MB memory limit, ` +
251
+ `${memoryInfo.used} MB was used.`;
252
+ }
253
+ else if (!success) {
254
+ errorMessage = `Exit code: ${code}`;
255
+ }
238
256
  const commandResult = {
239
257
  description: cmd.description,
240
258
  command: cmd.command,
241
259
  output,
242
260
  errors: limitLines(stderr.join('')),
243
261
  result: success ? ExecutionResult.Success : ExecutionResult.Error,
244
- error: success ? undefined : `Exit code: ${code}`,
262
+ error: errorMessage,
245
263
  workdir,
246
264
  };
247
265
  onProgress?.(success ? ExecutionStatus.Success : ExecutionStatus.Failed);
@@ -100,14 +100,28 @@ position.
100
100
 
101
101
  ### How to Generate Commands from Skills
102
102
 
103
+ **CRITICAL - ONE TASK = ONE COMMAND**: Each input task maps to exactly
104
+ ONE command in your response. The task's action tells you WHICH specific
105
+ step from the skill to use. Do NOT expand an entire skill workflow for
106
+ a single task - only generate the command for that specific step.
107
+
103
108
  1. **Identify skill tasks**: Check if tasks have params.skill
104
109
  2. **Find the skill**: Look up the skill in "Available Skills" section
105
110
  below (REQUIRED - must exist)
106
- 3. **Match tasks to Execution**: Each task action came from a Steps line;
107
- use the corresponding Execution line for the command
108
- 4. **Substitute parameters**: Replace {PARAM} placeholders with actual
111
+ 3. **Match task action to skill step**: The task action describes which
112
+ step from the skill's Steps section this task represents. Find the
113
+ matching step by semantic meaning (e.g., "Export results" matches
114
+ "Export the results to {FORMAT}", NOT all three steps of the skill)
115
+ 4. **Use corresponding Execution line**: Once you identify which step
116
+ the task represents, use ONLY that step's corresponding Execution line
117
+ 5. **Substitute parameters**: Replace {PARAM} placeholders with actual
109
118
  values from task params
110
119
 
120
+ **IMPORTANT**: If the schedule contains separate tasks for different
121
+ steps of the same skill (e.g., one task for fetching data, another for
122
+ exporting), each task produces its own single command. Do NOT combine
123
+ them or add steps that weren't scheduled.
124
+
111
125
  ### Example Skill
112
126
 
113
127
  ```markdown
@@ -151,6 +165,28 @@ steps 1 and 3 (with step 2 skipped), use Execution lines 1 and 3
151
165
  which Execution line to use - always match by original position, never
152
166
  by sequential task index.
153
167
 
168
+ ### Expanding Skill References in Execution Lines
169
+
170
+ Execution lines may contain **skill references** in the format
171
+ `[ Skill Name ]`. These are references to other skills that must be
172
+ expanded to actual commands before execution.
173
+
174
+ **Format**: `[ Skill Name ]` with spaces inside the brackets
175
+
176
+ **How to expand**:
177
+ 1. When an Execution line contains `[ Skill Name ]`, look up that skill
178
+ in the "Available Skills" section
179
+ 2. Get the referenced skill's Execution command
180
+ 3. Replace the `[ Skill Name ]` reference with the actual command
181
+
182
+ **IMPORTANT**: Skill references are the ONLY exception to the verbatim
183
+ execution rule below. You MUST expand them - never output `[ ... ]`
184
+ syntax in the final command.
185
+
186
+ **Note**: Use the `skill:` field from task metadata to find the skill
187
+ definition. If that skill's Execution line contains `[ Other Skill ]`,
188
+ look up "Other Skill" and replace the reference with its command.
189
+
154
190
  **CRITICAL - VERBATIM EXECUTION**: Run shell commands EXACTLY as written in
155
191
  the ### Execution section. Do NOT:
156
192
  - Modify the command string in any way
@@ -371,6 +407,41 @@ commands:
371
407
  command: "df -h"
372
408
  ```
373
409
 
410
+ ### Example 8: Partial skill execution (specific steps only)
411
+
412
+ When the schedule breaks a multi-step skill into separate tasks, each
413
+ task produces exactly ONE command for its specific step:
414
+
415
+ Skill "Prepare Report" has 3 steps:
416
+ - Steps: Fetch source data | Transform data | Export results
417
+ - Execution: curl {url} | python3 process.py | cat output.csv
418
+
419
+ Tasks (schedule requested only steps 1 and 3, skipping transform):
420
+ - { action: "Fetch source data", params: { skill: "Prepare Report" } }
421
+ - { action: "Export results", params: { skill: "Prepare Report" } }
422
+
423
+ Response (2 tasks = 2 commands, NOT 3):
424
+ ```
425
+ message: "Prepare report:"
426
+ summary: "Report prepared"
427
+ commands:
428
+ - description: "Fetch source data"
429
+ command: "curl {url}"
430
+ - description: "Export results"
431
+ command: "cat output.csv"
432
+ ```
433
+
434
+ **WRONG** response (adding unscheduled transform step):
435
+ ```
436
+ commands:
437
+ - description: "Fetch source data"
438
+ command: "curl {url}"
439
+ - description: "Transform data" ← NOT IN SCHEDULE - DO NOT ADD
440
+ command: "python3 process.py"
441
+ - description: "Export results"
442
+ command: "cat output.csv"
443
+ ```
444
+
374
445
  ## Handling Complex Operations
375
446
 
376
447
  For complex multi-step operations:
@@ -420,6 +491,10 @@ Example:
420
491
  - **CRITICAL: Assume what commands to run when skill is missing**
421
492
  - **CRITICAL: Replace unknown placeholders with `<UNKNOWN>` - this breaks
422
493
  shell syntax**
494
+ - **CRITICAL: Add steps that weren't in the scheduled tasks** - if the
495
+ schedule has 2 tasks, you MUST return exactly 2 commands
496
+ - **CRITICAL: Expand entire skill workflows** when only specific steps
497
+ were scheduled - match task actions to individual skill steps
423
498
 
424
499
  **DO:**
425
500
  - Match commands precisely to task descriptions
@@ -434,6 +509,10 @@ Example:
434
509
  commands**
435
510
  - Always use skill's Execution section when params.skill is present
436
511
  - Replace all {PARAM} placeholders with values from task params
512
+ - **CRITICAL: Count input tasks and ensure output has same count** -
513
+ N tasks in = N commands out, no exceptions
514
+ - **CRITICAL: Match each task action to its specific skill step** -
515
+ use only that step's Execution line for the command
437
516
 
438
517
  ## Final Validation
439
518
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prompt-language-shell",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Your personal command-line concierge. Ask politely, and it gets things done.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,9 +17,10 @@
17
17
  "dev": "npm run build && tsc --watch",
18
18
  "prepare": "husky",
19
19
  "prepublishOnly": "npm run check",
20
- "test": "vitest run --exclude 'tests/tools/schedule/*.test.tsx'",
21
- "test:watch": "vitest --exclude 'tests/tools/schedule/*.test.tsx'",
20
+ "test": "vitest run --exclude 'tests/tools/schedule/*.test.tsx' --exclude 'tests/shell/*.test.ts'",
21
+ "test:watch": "vitest --exclude 'tests/tools/schedule/*.test.tsx' --exclude 'tests/shell/*.test.ts'",
22
22
  "test:llm": "vitest run tests/tools/schedule/*.test.tsx",
23
+ "test:shell": "vitest run tests/shell/*.test.ts",
23
24
  "format": "prettier --write '**/*.{ts,tsx}'",
24
25
  "format:check": "prettier --check '**/*.{ts,tsx}'",
25
26
  "lint": "eslint .",