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,5 @@
1
- import { ComponentStatus, } from '../types/components.js';
2
- import { TaskType } from '../types/types.js';
3
- import { createCommand, createRefinement } from './components.js';
1
+ import { formatTaskAsYaml } from '../execution/processing.js';
2
+ import { createRefinement } from './components.js';
4
3
  import { formatErrorMessage, getRefiningMessage } from './messages.js';
5
4
  import { routeTasksWithConfirm } from './router.js';
6
5
  /**
@@ -8,11 +7,6 @@ import { routeTasksWithConfirm } from './router.js';
8
7
  * Called when user selects options from a plan with DEFINE tasks
9
8
  */
10
9
  export async function handleRefinement(selectedTasks, service, originalCommand, lifecycleHandlers, workflowHandlers, requestHandlers) {
11
- // Display the resolved command (from user's selection)
12
- // The first task's action contains the full resolved command
13
- const resolvedCommand = selectedTasks[0]?.action || originalCommand;
14
- const commandDisplay = createCommand({ command: resolvedCommand, service, onAborted: requestHandlers.onAborted }, ComponentStatus.Done);
15
- workflowHandlers.addToTimeline(commandDisplay);
16
10
  // Create and add refinement component to queue
17
11
  const refinementDef = createRefinement({
18
12
  text: getRefiningMessage(),
@@ -22,18 +16,15 @@ export async function handleRefinement(selectedTasks, service, originalCommand,
22
16
  });
23
17
  workflowHandlers.addToQueue(refinementDef);
24
18
  try {
25
- // Build refined command from selected tasks
19
+ // Build refined command with action line followed by YAML metadata
26
20
  const refinedCommand = selectedTasks
27
21
  .map((task) => {
22
+ // Replace commas with dashes for cleaner LLM prompt formatting
28
23
  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})`;
24
+ const metadata = { ...task.params, type: task.type };
25
+ return formatTaskAsYaml(action, metadata);
35
26
  })
36
- .join(', ');
27
+ .join('\n\n');
37
28
  // Call LLM to refine plan with selected tasks
38
29
  const refinedResult = await service.processWithTool(refinedCommand, 'schedule');
39
30
  // Complete the Refinement component with success state
@@ -8,6 +8,20 @@ import { saveConfigLabels } from '../configuration/labels.js';
8
8
  import { createAnswer, createConfig, createConfirm, createExecute, createFeedback, createIntrospect, createSchedule, createValidate, } from './components.js';
9
9
  import { getCancellationMessage, getConfirmationMessage, getUnknownRequestMessage, } from './messages.js';
10
10
  import { validateExecuteTasks } from './validator.js';
11
+ /**
12
+ * Task Routing Architecture
13
+ *
14
+ * Flow: Command -> SCHEDULE -> routeTasksWithConfirm() -> extractTaskGroups()
15
+ * -> routeAllGroups() -> routeGroupTasks() -> Workflow queue
16
+ *
17
+ * Key Concepts:
18
+ * - TaskGroup: Logical grouping for sequential processing
19
+ * - Routing Category: Determines which tasks can be grouped together
20
+ * - Two-Phase Routing: Config/Introspect first, then Execute/Answer
21
+ *
22
+ * Isolation Principle: Explicit Group tasks always become their own
23
+ * TaskGroup, preventing cross-contamination between user-defined groups.
24
+ */
11
25
  /**
12
26
  * Flatten inner task structure completely - removes all nested groups.
13
27
  * Used internally to flatten subtasks within a top-level group.
@@ -92,10 +106,10 @@ export function getOperationName(tasks) {
92
106
  export function routeTasksWithConfirm(tasks, message, service, userRequest, lifecycleHandlers, workflowHandlers, requestHandlers, hasDefineTask = false) {
93
107
  if (tasks.length === 0)
94
108
  return;
95
- // Filter out ignore and discard tasks early
96
- const validTasks = tasks.filter((task) => task.type !== TaskType.Ignore && task.type !== TaskType.Discard);
97
- // Check if no valid tasks remain after filtering
98
- if (validTasks.length === 0) {
109
+ // Check executable tasks (ignore/discard are shown but not executed)
110
+ const executableTasks = tasks.filter((task) => task.type !== TaskType.Ignore && task.type !== TaskType.Discard);
111
+ // Check if no executable tasks remain after filtering
112
+ if (executableTasks.length === 0) {
99
113
  // Use action from first ignore task if available, otherwise generic message
100
114
  const ignoreTask = tasks.find((task) => task.type === TaskType.Ignore);
101
115
  const message = ignoreTask?.action
@@ -104,7 +118,7 @@ export function routeTasksWithConfirm(tasks, message, service, userRequest, life
104
118
  workflowHandlers.addToQueue(createFeedback({ type: FeedbackType.Warning, message }));
105
119
  return;
106
120
  }
107
- const operation = getOperationName(validTasks);
121
+ const operation = getOperationName(executableTasks);
108
122
  // Create routing context for downstream functions
109
123
  const context = {
110
124
  service,
@@ -115,7 +129,8 @@ export function routeTasksWithConfirm(tasks, message, service, userRequest, life
115
129
  if (hasDefineTask) {
116
130
  // Has DEFINE tasks - add Schedule to queue for user selection
117
131
  // Refinement flow will call this function again with refined tasks
118
- const scheduleDefinition = createSchedule({ message, tasks: validTasks });
132
+ // Show all tasks (including ignore) for display
133
+ const scheduleDefinition = createSchedule({ message, tasks });
119
134
  workflowHandlers.addToQueue(scheduleDefinition);
120
135
  }
121
136
  else {
@@ -123,9 +138,10 @@ export function routeTasksWithConfirm(tasks, message, service, userRequest, life
123
138
  // When Schedule activates, Command moves to timeline
124
139
  // When Schedule completes, it moves to pending
125
140
  // When Confirm activates, Schedule stays pending (visible for context)
141
+ // Show all tasks (including ignore) for display
126
142
  const scheduleDefinition = createSchedule({
127
143
  message,
128
- tasks: validTasks,
144
+ tasks,
129
145
  onSelectionConfirmed: () => {
130
146
  // Schedule completed - add Confirm to queue
131
147
  const confirmDefinition = createConfirm({
@@ -133,7 +149,8 @@ export function routeTasksWithConfirm(tasks, message, service, userRequest, life
133
149
  onConfirmed: () => {
134
150
  // User confirmed - complete both Confirm and Schedule, then route
135
151
  lifecycleHandlers.completeActiveAndPending();
136
- executeTasksAfterConfirm(validTasks, context);
152
+ // Only execute non-ignore/non-discard tasks
153
+ executeTasksAfterConfirm(executableTasks, context);
137
154
  },
138
155
  onCancelled: () => {
139
156
  // User cancelled - complete both Confirm and Schedule, then show cancellation
@@ -230,88 +247,187 @@ function executeTasksAfterConfirm(tasks, context) {
230
247
  routeTasksAfterConfig(flatTasks, context);
231
248
  }
232
249
  /**
233
- * Task types that should appear in the upcoming display
250
+ * Collect action names for upcoming display.
251
+ * All task types are shown so users see the full queue of work ahead.
234
252
  */
235
- const UPCOMING_TASK_TYPES = [TaskType.Execute, TaskType.Answer, TaskType.Group];
253
+ function collectUpcomingNames(tasks) {
254
+ return tasks.map((t) => t.action);
255
+ }
236
256
  /**
237
- * Collect action names for tasks that appear in upcoming display.
238
- * Groups are included with their group name (not individual subtask names).
257
+ * Get the routing category for a task type.
258
+ * Tasks in the same category can be grouped together.
259
+ * Config and Introspect are special categories that should be isolated.
239
260
  */
240
- function collectUpcomingNames(tasks) {
241
- return tasks
242
- .filter((t) => UPCOMING_TASK_TYPES.includes(t.type))
243
- .map((t) => t.action);
261
+ export function getRoutingCategory(task) {
262
+ // Groups with subtasks get their own category (based on subtask types)
263
+ if (task.type === TaskType.Group &&
264
+ task.subtasks &&
265
+ task.subtasks.length > 0) {
266
+ // Check what types of subtasks this group has
267
+ const hasConfig = task.subtasks.some((t) => t.type === TaskType.Config);
268
+ const hasIntrospect = task.subtasks.some((t) => t.type === TaskType.Introspect);
269
+ const hasExecute = task.subtasks.some((t) => t.type === TaskType.Execute);
270
+ const hasAnswer = task.subtasks.some((t) => t.type === TaskType.Answer);
271
+ // Mixed types get unique category to ensure isolation
272
+ const typeCount = (hasConfig ? 1 : 0) +
273
+ (hasIntrospect ? 1 : 0) +
274
+ (hasExecute ? 1 : 0) +
275
+ (hasAnswer ? 1 : 0);
276
+ if (typeCount > 1) {
277
+ return `mixed:${task.action}`;
278
+ }
279
+ // Single type groups use that type's category
280
+ if (hasConfig)
281
+ return 'config';
282
+ if (hasIntrospect)
283
+ return 'introspect';
284
+ if (hasExecute)
285
+ return 'execute';
286
+ if (hasAnswer)
287
+ return 'answer';
288
+ return 'group';
289
+ }
290
+ // Standalone tasks use their type as category
291
+ switch (task.type) {
292
+ case TaskType.Config:
293
+ return 'config';
294
+ case TaskType.Introspect:
295
+ return 'introspect';
296
+ case TaskType.Execute:
297
+ return 'execute';
298
+ case TaskType.Answer:
299
+ return 'answer';
300
+ default:
301
+ return task.type;
302
+ }
244
303
  }
245
304
  /**
246
- * Route tasks after config is complete (or when no config is needed)
247
- * Processes task list, routing each task type to its handler.
248
- * Top-level groups are preserved: their subtasks are routed with the group name.
249
- * Config tasks are grouped together; Execute/Answer are routed individually.
305
+ * Extract logical task groups from a flat task list.
306
+ * Each explicit Group (TaskType.Group) becomes its own TaskGroup for isolation.
307
+ * Consecutive standalone tasks of the same routing category are grouped together.
250
308
  */
251
- function routeTasksAfterConfig(tasks, context) {
252
- if (tasks.length === 0)
253
- return;
254
- // Collect all upcoming names for display (Execute, Answer, and Group tasks)
255
- const allUpcomingNames = collectUpcomingNames(tasks);
256
- let upcomingIndex = 0;
257
- // Task types that should be grouped together (one component for all tasks)
258
- const groupedTypes = [TaskType.Config, TaskType.Introspect];
259
- // Route grouped task types together (collect from all tasks including subtasks)
260
- for (const groupedType of groupedTypes) {
261
- const typeTasks = [];
262
- for (const task of tasks) {
263
- if (task.type === groupedType) {
264
- typeTasks.push(task);
309
+ export function extractTaskGroups(tasks) {
310
+ const groups = [];
311
+ let currentGroup = null;
312
+ let currentCategory = null;
313
+ for (const task of tasks) {
314
+ // Skip empty groups
315
+ if (task.type === TaskType.Group &&
316
+ (!task.subtasks || task.subtasks.length === 0)) {
317
+ continue;
318
+ }
319
+ // Explicit Groups always become their own TaskGroup for isolation
320
+ if (task.type === TaskType.Group) {
321
+ // Save current group if exists
322
+ if (currentGroup !== null) {
323
+ groups.push(currentGroup);
324
+ currentGroup = null;
325
+ currentCategory = null;
265
326
  }
266
- else if (task.type === TaskType.Group && task.subtasks) {
267
- typeTasks.push(...task.subtasks.filter((t) => t.type === groupedType));
327
+ // Create standalone TaskGroup for this Group
328
+ groups.push({
329
+ name: task.action,
330
+ tasks: [task],
331
+ });
332
+ continue;
333
+ }
334
+ const category = getRoutingCategory(task);
335
+ // Start new group when category changes (or first task)
336
+ if (currentGroup === null || category !== currentCategory) {
337
+ if (currentGroup !== null) {
338
+ groups.push(currentGroup);
268
339
  }
340
+ currentGroup = {
341
+ name: task.action,
342
+ tasks: [task],
343
+ };
344
+ currentCategory = category;
269
345
  }
270
- if (typeTasks.length > 0) {
271
- routeTasksByType(groupedType, typeTasks, context, []);
346
+ else {
347
+ // Same category - add to current group
348
+ currentGroup.tasks.push(task);
272
349
  }
273
350
  }
274
- // Process Execute, Answer, and Group tasks individually (with upcoming support)
351
+ // Don't forget the last group
352
+ if (currentGroup !== null) {
353
+ groups.push(currentGroup);
354
+ }
355
+ return groups;
356
+ }
357
+ /**
358
+ * Route all groups in order, calculating correct upcoming for each.
359
+ * Groups are processed sequentially by the Workflow's queue mechanism.
360
+ */
361
+ function routeAllGroups(groups, context) {
362
+ for (let i = 0; i < groups.length; i++) {
363
+ const group = groups[i];
364
+ // Calculate upcoming from LATER groups (not current group)
365
+ const laterGroups = groups.slice(i + 1);
366
+ const laterUpcoming = laterGroups.flatMap((g) => collectUpcomingNames(g.tasks));
367
+ // Route this group's tasks with upcoming from later groups
368
+ routeGroupTasks(group, context, laterUpcoming);
369
+ }
370
+ }
371
+ /**
372
+ * Route all tasks within a single group in the order received from LLM.
373
+ * Standalone tasks become individual components.
374
+ * Group subtasks are batched by type (Execute subtasks together, etc.).
375
+ */
376
+ function routeGroupTasks(group, context, groupUpcoming) {
377
+ const tasks = group.tasks;
378
+ const withinGroupUpcoming = collectUpcomingNames(tasks);
275
379
  for (let i = 0; i < tasks.length; i++) {
276
380
  const task = tasks[i];
277
381
  const taskType = task.type;
278
- // Skip grouped task types (already routed above)
279
- if (groupedTypes.includes(taskType))
280
- continue;
382
+ // Calculate upcoming: remaining tasks in this group + tasks from later groups
383
+ const remainingInGroup = withinGroupUpcoming.slice(i + 1);
384
+ const taskUpcoming = [...remainingInGroup, ...groupUpcoming];
385
+ // Handle Group tasks with subtasks - batch subtasks by type
281
386
  if (taskType === TaskType.Group && task.subtasks) {
282
- // Route group's subtasks - Execute tasks get group label, others routed normally
283
- const upcoming = allUpcomingNames.slice(upcomingIndex + 1);
284
- upcomingIndex++;
285
- // Separate subtasks by type
387
+ // Batch Execute subtasks together (sent to LLM as single request)
286
388
  const executeSubtasks = task.subtasks.filter((t) => t.type === TaskType.Execute);
287
- const answerSubtasks = task.subtasks.filter((t) => t.type === TaskType.Answer);
288
- // Route Execute subtasks with group name as label
289
389
  if (executeSubtasks.length > 0) {
290
- routeExecuteTasks(executeSubtasks, context, upcoming, task.action);
390
+ routeExecuteTasks(executeSubtasks, context, taskUpcoming, task.action);
291
391
  }
292
- // Route Answer subtasks individually
392
+ // Route Answer subtasks (each becomes its own component)
393
+ const answerSubtasks = task.subtasks.filter((t) => t.type === TaskType.Answer);
293
394
  if (answerSubtasks.length > 0) {
294
- routeAnswerTasks(answerSubtasks, context, upcoming);
395
+ routeAnswerTasks(answerSubtasks, context, taskUpcoming);
396
+ }
397
+ // Route other subtask types individually
398
+ for (const subtask of task.subtasks) {
399
+ if (subtask.type !== TaskType.Execute &&
400
+ subtask.type !== TaskType.Answer) {
401
+ routeTasksByType(subtask.type, [subtask], context, taskUpcoming);
402
+ }
295
403
  }
296
404
  }
297
405
  else if (taskType === TaskType.Execute) {
298
- // Calculate upcoming for this Execute task
299
- const upcoming = allUpcomingNames.slice(upcomingIndex + 1);
300
- upcomingIndex++;
301
- routeExecuteTasks([task], context, upcoming);
406
+ routeExecuteTasks([task], context, taskUpcoming);
302
407
  }
303
408
  else if (taskType === TaskType.Answer) {
304
- // Calculate upcoming for this Answer task
305
- const upcoming = allUpcomingNames.slice(upcomingIndex + 1);
306
- upcomingIndex++;
307
- routeTasksByType(taskType, [task], context, upcoming);
409
+ routeAnswerTasks([task], context, taskUpcoming);
308
410
  }
309
411
  else {
310
- // For other types (Report, etc.), route without upcoming
311
- routeTasksByType(taskType, [task], context, []);
412
+ routeTasksByType(taskType, [task], context, taskUpcoming);
312
413
  }
313
414
  }
314
415
  }
416
+ /**
417
+ * Route tasks after config is complete (or when no config is needed)
418
+ * Processes task groups in order - groups with different task types are
419
+ * kept separate to ensure proper lifecycle handling.
420
+ */
421
+ function routeTasksAfterConfig(tasks, context) {
422
+ if (tasks.length === 0)
423
+ return;
424
+ // Extract logical task groups
425
+ const groups = extractTaskGroups(tasks);
426
+ if (groups.length === 0)
427
+ return;
428
+ // Route all groups in order
429
+ routeAllGroups(groups, context);
430
+ }
315
431
  /**
316
432
  * Route Answer tasks - creates separate Answer component for each question
317
433
  */
@@ -335,48 +451,60 @@ function routeIntrospectTasks(tasks, context, _upcoming) {
335
451
  context.workflowHandlers.addToQueue(createIntrospect({ tasks, service: context.service }));
336
452
  }
337
453
  /**
338
- * Route Config tasks - extracts keys, caches labels, creates Config component
454
+ * Route Config tasks - extracts keys or uses query, creates Config component
339
455
  */
340
456
  function routeConfigTasks(tasks, context, _upcoming) {
457
+ // Extract specific keys from task params
341
458
  const configKeys = tasks
342
459
  .map((task) => task.params?.key)
343
460
  .filter((key) => key !== undefined);
344
- // Extract and cache labels from task descriptions
345
- // Only cache labels for dynamically discovered keys (not in schema)
346
- const schema = getConfigSchema();
347
- const labels = {};
348
- for (const task of tasks) {
349
- const key = task.params?.key;
350
- if (key && task.action && !(key in schema)) {
351
- labels[key] = task.action;
461
+ // Handler for saving config values
462
+ const onFinished = (config) => {
463
+ try {
464
+ const configBySection = unflattenConfig(config);
465
+ for (const [section, sectionConfig] of Object.entries(configBySection)) {
466
+ saveConfig(section, sectionConfig);
467
+ }
468
+ }
469
+ catch (error) {
470
+ const errorMessage = error instanceof Error ? error.message : 'Failed to save configuration';
471
+ throw new Error(errorMessage);
472
+ }
473
+ };
474
+ const onAborted = (operation) => {
475
+ context.requestHandlers.onAborted(operation);
476
+ };
477
+ if (configKeys.length > 0) {
478
+ // Has specific keys - create steps directly
479
+ const schema = getConfigSchema();
480
+ const labels = {};
481
+ for (const task of tasks) {
482
+ const key = task.params?.key;
483
+ if (key && task.action && !(key in schema)) {
484
+ labels[key] = task.action;
485
+ }
352
486
  }
487
+ if (Object.keys(labels).length > 0) {
488
+ saveConfigLabels(labels);
489
+ }
490
+ context.workflowHandlers.addToQueue(createConfig({
491
+ steps: createConfigStepsFromSchema(configKeys),
492
+ onFinished,
493
+ onAborted,
494
+ }));
353
495
  }
354
- if (Object.keys(labels).length > 0) {
355
- saveConfigLabels(labels);
496
+ else {
497
+ // No keys - use query (Config will resolve via CONFIGURE tool)
498
+ const query = tasks[0]?.params?.query;
499
+ if (query) {
500
+ context.workflowHandlers.addToQueue(createConfig({
501
+ query,
502
+ service: context.service,
503
+ onFinished,
504
+ onAborted,
505
+ }));
506
+ }
356
507
  }
357
- context.workflowHandlers.addToQueue(createConfig({
358
- steps: createConfigStepsFromSchema(configKeys),
359
- onFinished: (config) => {
360
- // Save config - Config component will handle completion and feedback
361
- try {
362
- // Convert flat dotted keys to nested structure grouped by section
363
- const configBySection = unflattenConfig(config);
364
- // Save each section
365
- for (const [section, sectionConfig] of Object.entries(configBySection)) {
366
- saveConfig(section, sectionConfig);
367
- }
368
- }
369
- catch (error) {
370
- const errorMessage = error instanceof Error
371
- ? error.message
372
- : 'Failed to save configuration';
373
- throw new Error(errorMessage);
374
- }
375
- },
376
- onAborted: (operation) => {
377
- context.requestHandlers.onAborted(operation);
378
- },
379
- }));
380
508
  }
381
509
  /**
382
510
  * Route Execute tasks - creates Execute component (validation already done)
@@ -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;
@@ -104,8 +105,10 @@ class OutputStreamer {
104
105
  // Collapse when we have too many chunks to prevent memory growth
105
106
  if (this.chunks.length > 16) {
106
107
  const accumulated = this.chunks.join('');
107
- this.chunks = [limitLines(accumulated)];
108
- this.emittedLength = 0;
108
+ const limited = limitLines(accumulated);
109
+ this.chunks = [limited];
110
+ // Mark all collapsed content as emitted to prevent re-emission
111
+ this.emittedLength = limited.length;
109
112
  }
110
113
  if (!this.callback)
111
114
  return;
@@ -145,6 +148,7 @@ class OutputStreamer {
145
148
  */
146
149
  export class RealExecutor {
147
150
  outputCallback;
151
+ memoryCallback;
148
152
  constructor(outputCallback) {
149
153
  this.outputCallback = outputCallback;
150
154
  }
@@ -154,6 +158,12 @@ export class RealExecutor {
154
158
  setOutputCallback(callback) {
155
159
  this.outputCallback = callback;
156
160
  }
161
+ /**
162
+ * Set or update the memory callback
163
+ */
164
+ setMemoryCallback(callback) {
165
+ this.memoryCallback = callback;
166
+ }
157
167
  execute(cmd, onProgress, _ = 0) {
158
168
  return new Promise((resolve) => {
159
169
  onProgress?.(ExecutionStatus.Running);
@@ -183,18 +193,22 @@ export class RealExecutor {
183
193
  return;
184
194
  }
185
195
  // Handle timeout if specified
186
- const SIGKILL_GRACE_PERIOD = 3000;
187
196
  let timeoutId;
188
197
  let killTimeoutId;
189
198
  if (cmd.timeout && cmd.timeout > 0) {
190
199
  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);
200
+ killTimeoutId = killGracefully(child);
196
201
  }, cmd.timeout);
197
202
  }
203
+ // Handle memory limit monitoring
204
+ let memoryMonitor;
205
+ let memoryInfo;
206
+ if (cmd.memoryLimit) {
207
+ memoryMonitor = new MemoryMonitor(child, cmd.memoryLimit, (info) => {
208
+ memoryInfo = info;
209
+ }, undefined, this.memoryCallback);
210
+ memoryMonitor.start();
211
+ }
198
212
  // Use OutputStreamer for buffered stdout streaming
199
213
  const stdoutStreamer = new OutputStreamer(this.outputCallback);
200
214
  child.stdout.on('data', (data) => {
@@ -217,6 +231,7 @@ export class RealExecutor {
217
231
  clearTimeout(timeoutId);
218
232
  if (killTimeoutId)
219
233
  clearTimeout(killTimeoutId);
234
+ memoryMonitor?.stop();
220
235
  const commandResult = {
221
236
  description: cmd.description,
222
237
  command: cmd.command,
@@ -228,20 +243,32 @@ export class RealExecutor {
228
243
  onProgress?.(ExecutionStatus.Failed);
229
244
  resolve(commandResult);
230
245
  });
231
- child.on('close', (code) => {
246
+ child.on('exit', (code) => {
232
247
  if (timeoutId)
233
248
  clearTimeout(timeoutId);
234
249
  if (killTimeoutId)
235
250
  clearTimeout(killTimeoutId);
236
- const success = code === 0;
251
+ memoryMonitor?.stop();
237
252
  const { output, workdir } = parseWorkdir(stdoutStreamer.getAccumulated());
253
+ // Check if terminated due to memory limit
254
+ const killedByMemoryLimit = memoryMonitor?.wasKilledByMemoryLimit();
255
+ const success = code === 0 && !killedByMemoryLimit;
256
+ let errorMessage;
257
+ if (killedByMemoryLimit && memoryInfo) {
258
+ errorMessage =
259
+ `Process exceeded ${memoryInfo.limit} MB memory limit, ` +
260
+ `${memoryInfo.used} MB was used.`;
261
+ }
262
+ else if (!success) {
263
+ errorMessage = `Exit code: ${code}`;
264
+ }
238
265
  const commandResult = {
239
266
  description: cmd.description,
240
267
  command: cmd.command,
241
268
  output,
242
269
  errors: limitLines(stderr.join('')),
243
270
  result: success ? ExecutionResult.Success : ExecutionResult.Error,
244
- error: success ? undefined : `Exit code: ${code}`,
271
+ error: errorMessage,
245
272
  workdir,
246
273
  };
247
274
  onProgress?.(success ? ExecutionStatus.Success : ExecutionStatus.Failed);
@@ -260,6 +287,12 @@ const executor = realExecutor;
260
287
  export function setOutputCallback(callback) {
261
288
  realExecutor.setOutputCallback(callback);
262
289
  }
290
+ /**
291
+ * Set a callback to receive memory updates during execution
292
+ */
293
+ export function setMemoryCallback(callback) {
294
+ realExecutor.setMemoryCallback(callback);
295
+ }
263
296
  /**
264
297
  * Execute a single shell command
265
298
  */
@@ -25,6 +25,17 @@ export function formatDuration(ms) {
25
25
  }
26
26
  return parts.join(' ');
27
27
  }
28
+ /**
29
+ * Formats memory in megabytes to a human-readable string.
30
+ * Uses MB for values under 1024 MB, GB for larger values.
31
+ */
32
+ export function formatMemory(mb) {
33
+ if (mb >= 1024) {
34
+ const gb = mb / 1024;
35
+ return `${gb.toFixed(1)} GB`;
36
+ }
37
+ return `${mb} MB`;
38
+ }
28
39
  /**
29
40
  * Recursively extracts all leaf tasks from a hierarchical task structure.
30
41
  * Leaf tasks are tasks without subtasks.