@the-open-engine/zeroshot 6.7.0 → 6.7.1

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.
Files changed (48) hide show
  1. package/cli/commands/providers.js +4 -1
  2. package/cli/index.js +18 -12
  3. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/claude.js +23 -10
  5. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  6. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  7. package/lib/agent-cli-provider/adapters/codex.js +4 -0
  8. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  9. package/lib/agent-cli-provider/adapters/common.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/adapters/common.js +2 -1
  11. package/lib/agent-cli-provider/adapters/common.js.map +1 -1
  12. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  13. package/lib/agent-cli-provider/contract-options.js +2 -1
  14. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  15. package/lib/agent-cli-provider/index.d.ts +1 -1
  16. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/index.js.map +1 -1
  18. package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
  19. package/lib/agent-cli-provider/provider-registry.js +1 -1
  20. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  21. package/lib/agent-cli-provider/single-agent-runtime.js +6 -2
  22. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  23. package/lib/agent-cli-provider/types.d.ts +3 -1
  24. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  25. package/lib/agent-cli-provider/types.js.map +1 -1
  26. package/lib/settings.js +1 -0
  27. package/package.json +1 -1
  28. package/scripts/run-lint-staged-no-stash.js +68 -0
  29. package/src/agent/agent-lifecycle.js +368 -91
  30. package/src/agent/agent-task-executor.js +384 -101
  31. package/src/agent-cli-provider/adapters/claude.ts +29 -11
  32. package/src/agent-cli-provider/adapters/codex.ts +4 -0
  33. package/src/agent-cli-provider/adapters/common.ts +2 -1
  34. package/src/agent-cli-provider/contract-options.ts +2 -1
  35. package/src/agent-cli-provider/index.ts +1 -0
  36. package/src/agent-cli-provider/provider-registry.ts +1 -1
  37. package/src/agent-cli-provider/single-agent-runtime.ts +8 -2
  38. package/src/agent-cli-provider/types.ts +3 -1
  39. package/src/agent-wrapper.js +9 -2
  40. package/src/config-validator.js +10 -11
  41. package/src/orchestrator.js +18 -6
  42. package/task-lib/attachable-watcher.js +10 -1
  43. package/task-lib/commands/kill.js +34 -9
  44. package/task-lib/commands/status.js +13 -3
  45. package/task-lib/process-termination.js +202 -0
  46. package/task-lib/runner.js +8 -20
  47. package/task-lib/store.js +28 -6
  48. package/task-lib/watcher.js +14 -2
@@ -4,7 +4,6 @@ import {
4
4
  type ClaudeCliFeatures,
5
5
  type CommandSpec,
6
6
  type ErrorClassification,
7
- InvalidProviderModelError,
8
7
  type LevelModelSpec,
9
8
  type LevelOverrides,
10
9
  type ModelCatalogEntry,
@@ -28,8 +27,23 @@ import { parseClaudeEvent } from './claude-parser';
28
27
 
29
28
  const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
30
29
  haiku: { rank: 1 },
30
+ 'claude-haiku-4-5': { rank: 1 },
31
+ 'claude-haiku-4-5-20251001': { rank: 1 },
31
32
  sonnet: { rank: 2 },
33
+ 'claude-sonnet-4-5': { rank: 2 },
34
+ 'claude-sonnet-4-5-20250929': { rank: 2 },
35
+ 'claude-sonnet-4-6': { rank: 2 },
36
+ 'claude-sonnet-5': { rank: 2 },
32
37
  opus: { rank: 3 },
38
+ 'claude-opus-4-5': { rank: 3 },
39
+ 'claude-opus-4-5-20251101': { rank: 3 },
40
+ 'claude-opus-4-6': { rank: 3 },
41
+ 'claude-opus-4-7': { rank: 3 },
42
+ 'claude-opus-4-8': { rank: 3 },
43
+ fable: { rank: 3 },
44
+ 'claude-fable-5': { rank: 3 },
45
+ 'claude-mythos-5': { rank: 3 },
46
+ 'claude-mythos-preview': { rank: 3 },
33
47
  };
34
48
 
35
49
  const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
@@ -50,6 +64,7 @@ function detectCliFeatures(helpText?: string | null): ClaudeCliFeatures {
50
64
  supportsIncludePartials: unknown ? true : /--include-partial-messages/.test(help),
51
65
  supportsVerbose: unknown ? true : /--verbose/.test(help),
52
66
  supportsModel: unknown ? true : /--model/.test(help),
67
+ supportsEffort: unknown ? true : /--effort/.test(help),
53
68
  unknown,
54
69
  };
55
70
  }
@@ -86,6 +101,9 @@ function addModelArgs(args: string[], options: BuildProviderCommandOptions): voi
86
101
  if (options.modelSpec?.model && features.supportsModel !== false) {
87
102
  args.push('--model', options.modelSpec.model);
88
103
  }
104
+ if (options.modelSpec?.reasoningEffort && features.supportsEffort !== false) {
105
+ args.push('--effort', options.modelSpec.reasoningEffort);
106
+ }
89
107
  }
90
108
 
91
109
  function addAutoApproveArgs(args: string[], options: BuildProviderCommandOptions): void {
@@ -139,6 +157,15 @@ function collectWarnings(options: BuildProviderCommandOptions): WarningMetadata[
139
157
  )
140
158
  );
141
159
  }
160
+ if (options.modelSpec?.reasoningEffort && features.supportsEffort === false) {
161
+ warnings.push(
162
+ warning(
163
+ 'claude',
164
+ 'claude-reasoning',
165
+ 'Claude CLI does not support --effort; skipping reasoningEffort.'
166
+ )
167
+ );
168
+ }
142
169
  return warnings;
143
170
  }
144
171
 
@@ -176,16 +203,7 @@ function resolveModelSpec(level: ModelLevel, overrides?: LevelOverrides): Resolv
176
203
  }
177
204
 
178
205
  function validateModelId(modelId: string | null | undefined): string | null | undefined {
179
- try {
180
- return validateModelIdFromCatalog('claude', MODEL_CATALOG, modelId);
181
- } catch (error) {
182
- if (modelId && (modelId === 'opus-4.6' || modelId === 'claude-opus-4-6')) {
183
- throw new InvalidProviderModelError(
184
- `Invalid model "${modelId}" for provider "claude". Use canonical model ids: haiku, sonnet, opus.`
185
- );
186
- }
187
- throw error;
188
- }
206
+ return validateModelIdFromCatalog('claude', MODEL_CATALOG, modelId);
189
207
  }
190
208
 
191
209
  function classifyError(error: unknown): ErrorClassification {
@@ -27,6 +27,10 @@ import { parseCodexEvent } from './codex-parser';
27
27
  const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
28
28
  'gpt-5.4': { rank: 2 },
29
29
  'gpt-5.5': { rank: 3 },
30
+ 'gpt-5.6': { rank: 3 },
31
+ 'gpt-5.6-sol': { rank: 3 },
32
+ 'gpt-5.6-terra': { rank: 2 },
33
+ 'gpt-5.6-luna': { rank: 1 },
30
34
  };
31
35
 
32
36
  const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
@@ -100,8 +100,9 @@ export function validateModelIdFromCatalog(
100
100
  ): string | null | undefined {
101
101
  if (!modelId) return modelId;
102
102
  if (catalog[modelId] !== undefined) return modelId;
103
+ const validModels = Object.keys(catalog).join(', ');
103
104
  throw new InvalidProviderModelError(
104
- `Invalid model "${modelId}" for provider "${provider}". Use a model listed in provider settings/catalog.`
105
+ `Invalid model "${modelId}" for provider "${provider}". Valid models: ${validModels}.`
105
106
  );
106
107
  }
107
108
 
@@ -13,7 +13,7 @@ import type {
13
13
 
14
14
  const OUTPUT_FORMATS: readonly OutputFormat[] = ['text', 'json', 'stream-json'];
15
15
  const MODEL_LEVELS: readonly ModelLevel[] = ['level1', 'level2', 'level3'];
16
- const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
16
+ const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
17
17
  const CLI_FEATURE_FIELDS = [
18
18
  'supportsOutputFormat',
19
19
  'supportsStreamJson',
@@ -22,6 +22,7 @@ const CLI_FEATURE_FIELDS = [
22
22
  'supportsIncludePartials',
23
23
  'supportsVerbose',
24
24
  'supportsModel',
25
+ 'supportsEffort',
25
26
  'supportsJson',
26
27
  'supportsOutputSchema',
27
28
  'supportsDir',
@@ -91,6 +91,7 @@ export type {
91
91
  ProviderId,
92
92
  ProviderRegistryEntry,
93
93
  RedactionMetadata,
94
+ ReasoningEffort,
94
95
  ResolvedGatewayBuildOptions,
95
96
  ResolvedModelSpec,
96
97
  ResultEvent,
@@ -160,7 +160,7 @@ export const providerRegistry = [
160
160
  capabilities: {
161
161
  ...STANDARD_CAPABILITIES,
162
162
  jsonSchema: true,
163
- reasoningEffort: false,
163
+ reasoningEffort: true,
164
164
  },
165
165
  docs: {
166
166
  label: 'Claude',
@@ -61,7 +61,7 @@ type MutableModelSpec = {
61
61
  };
62
62
 
63
63
  const MODEL_LEVELS: readonly ModelLevel[] = ['level1', 'level2', 'level3'];
64
- const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
64
+ const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
65
65
  const settingsModule: unknown = require('../../lib/settings');
66
66
  const providerDetectionModule: unknown = require('../../lib/provider-detection');
67
67
  const claudeAuthModule: unknown = require('../../lib/settings/claude-auth');
@@ -420,7 +420,13 @@ function optionalModelLevel(value: unknown, field: string): ModelLevel | undefin
420
420
 
421
421
  function optionalReasoningEffort(value: unknown, field: string): ReasoningEffort | undefined {
422
422
  if (value === undefined) return undefined;
423
- if (value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh') {
423
+ if (
424
+ value === 'low' ||
425
+ value === 'medium' ||
426
+ value === 'high' ||
427
+ value === 'xhigh' ||
428
+ value === 'max'
429
+ ) {
424
430
  return value;
425
431
  }
426
432
  throw new Error(`${field} must be one of: ${REASONING_EFFORTS.join(', ')}.`);
@@ -15,7 +15,7 @@ export type ProviderId = (typeof providerIds)[number];
15
15
  export type ProviderAlias = (typeof providerAliases)[number];
16
16
  export type KnownProviderName = ProviderId | ProviderAlias;
17
17
  export type ModelLevel = 'level1' | 'level2' | 'level3';
18
- export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
18
+ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
19
19
  export type OutputFormat = 'text' | 'json' | 'stream-json';
20
20
  export type {
21
21
  ProviderCapabilities,
@@ -95,6 +95,7 @@ export interface ClaudeCliFeatures extends BaseCliFeatures {
95
95
  readonly supportsIncludePartials: boolean;
96
96
  readonly supportsVerbose: boolean;
97
97
  readonly supportsModel: boolean;
98
+ readonly supportsEffort: boolean;
98
99
  }
99
100
 
100
101
  export interface CodexCliFeatures extends BaseCliFeatures {
@@ -186,6 +187,7 @@ export interface CliFeatureOverrides {
186
187
  readonly supportsIncludePartials?: boolean;
187
188
  readonly supportsVerbose?: boolean;
188
189
  readonly supportsModel?: boolean;
190
+ readonly supportsEffort?: boolean;
189
191
  readonly supportsJson?: boolean;
190
192
  readonly supportsOutputSchema?: boolean;
191
193
  readonly supportsDir?: boolean;
@@ -81,8 +81,15 @@ class AgentWrapper {
81
81
  // LIVENESS DETECTION - Track output freshness to detect stuck agents
82
82
  /** @type {number | null} */
83
83
  this.lastOutputTime = null; // Timestamp of last output received
84
+ /** @type {number | null} */
85
+ this.taskStartedAt = null; // Timestamp used for absolute task timeout
84
86
  /** @type {NodeJS.Timeout | null} */
85
87
  this.livenessCheckInterval = null; // Interval for health checks
88
+ this.consecutiveStaleWarnings = 0;
89
+ this.livenessTerminationStarted = false;
90
+ this.livenessTerminationContext = null;
91
+ this.livenessTerminationAttempts = 0;
92
+ this.livenessTerminationRetryAt = 0;
86
93
  this.staleDuration = normalizedConfig.staleDuration;
87
94
  this.enableLivenessCheck = normalizedConfig.enableLivenessCheck;
88
95
 
@@ -499,8 +506,8 @@ class AgentWrapper {
499
506
  * Kill current task
500
507
  * @private
501
508
  */
502
- _killTask() {
503
- return killTask(this);
509
+ _killTask(reason) {
510
+ return killTask(this, reason);
504
511
  }
505
512
 
506
513
  /**
@@ -21,6 +21,9 @@ const { getProvider } = require('./providers');
21
21
  const { CAPABILITIES } = require('./providers/capabilities');
22
22
  const { GUIDANCE_TOPICS } = require('./guidance-topics');
23
23
 
24
+ const REASONING_EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
25
+ const REASONING_EFFORT_LABEL = REASONING_EFFORTS.join('|');
26
+
24
27
  const HOOK_ACTION_TOPIC_CONTRACTS = Object.freeze({
25
28
  verify_pull_request: Object.freeze([
26
29
  { topic: 'CLUSTER_COMPLETE', keys: null },
@@ -2054,14 +2057,13 @@ function validateProviderSettings(provider, providerSettings) {
2054
2057
  providerModule.validateModelId(override.model);
2055
2058
  }
2056
2059
  if (override?.reasoningEffort && !providerSupportsCapability(provider, 'reasoningEffort')) {
2057
- throw new Error(`reasoningEffort overrides are only supported for Codex and Opencode`);
2060
+ throw new Error(
2061
+ `reasoningEffort overrides are only supported for Claude, Codex, and Opencode`
2062
+ );
2058
2063
  }
2059
- if (
2060
- override?.reasoningEffort &&
2061
- !['low', 'medium', 'high', 'xhigh'].includes(override.reasoningEffort)
2062
- ) {
2064
+ if (override?.reasoningEffort && !REASONING_EFFORTS.includes(override.reasoningEffort)) {
2063
2065
  throw new Error(
2064
- `Invalid reasoningEffort "${override.reasoningEffort}" (low|medium|high|xhigh)`
2066
+ `Invalid reasoningEffort "${override.reasoningEffort}" (${REASONING_EFFORT_LABEL})`
2065
2067
  );
2066
2068
  }
2067
2069
  }
@@ -2172,12 +2174,9 @@ function validateModelRulesSupport(agent, provider, providerModule, levels, warn
2172
2174
  function validateReasoningEffortSupport(agent, provider, warnings) {
2173
2175
  if (agent.reasoningEffort && !providerSupportsCapability(provider, 'reasoningEffort')) {
2174
2176
  warnings.push(`Agent "${agent.id}" sets reasoningEffort but ${provider} does not support it`);
2175
- } else if (
2176
- agent.reasoningEffort &&
2177
- !['low', 'medium', 'high', 'xhigh'].includes(agent.reasoningEffort)
2178
- ) {
2177
+ } else if (agent.reasoningEffort && !REASONING_EFFORTS.includes(agent.reasoningEffort)) {
2179
2178
  warnings.push(
2180
- `Agent "${agent.id}" has invalid reasoningEffort "${agent.reasoningEffort}" (low|medium|high|xhigh)`
2179
+ `Agent "${agent.id}" has invalid reasoningEffort "${agent.reasoningEffort}" (${REASONING_EFFORT_LABEL})`
2181
2180
  );
2182
2181
  }
2183
2182
  }
@@ -1535,6 +1535,7 @@ class Orchestrator {
1535
1535
  const agentRole = message.content?.data?.role;
1536
1536
  const attempts = message.content?.data?.attempts || 1;
1537
1537
  const hookFailure = message.content?.data?.hookFailure === true;
1538
+ const restartExhausted = message.content?.data?.restartExhausted === true;
1538
1539
 
1539
1540
  await this._saveClusters();
1540
1541
 
@@ -1542,7 +1543,7 @@ class Orchestrator {
1542
1543
  agentRole === 'implementation' ||
1543
1544
  agentRole === 'coordinator' ||
1544
1545
  message.sender === 'consensus-coordinator';
1545
- const shouldStop = shouldStopForRole && (hookFailure || attempts >= 3);
1546
+ const shouldStop = shouldStopForRole && (hookFailure || restartExhausted || attempts >= 3);
1546
1547
 
1547
1548
  if (shouldStop) {
1548
1549
  this._log(`\n${'='.repeat(80)}`);
@@ -1551,6 +1552,7 @@ class Orchestrator {
1551
1552
  this._log(
1552
1553
  `${message.sender} (${agentRole || 'unknown role'}) failed` +
1553
1554
  (hookFailure ? ` (hookFailure=true)` : ``) +
1555
+ (restartExhausted ? ` (restartExhausted=true)` : ``) +
1554
1556
  ` after ${attempts} attempts`
1555
1557
  );
1556
1558
  this._log(`Error: ${message.content?.data?.error || 'unknown'}`);
@@ -1573,7 +1575,7 @@ class Orchestrator {
1573
1575
  });
1574
1576
  }
1575
1577
 
1576
- _registerAgentLifecycleHandlers(messageBus, clusterId) {
1578
+ _registerAgentLifecycleHandlers(messageBus, _clusterId) {
1577
1579
  messageBus.on('topic:AGENT_LIFECYCLE', async (message) => {
1578
1580
  const event = message.content?.data?.event;
1579
1581
  if (
@@ -1596,13 +1598,14 @@ class Orchestrator {
1596
1598
  const timeSinceLastOutput = message.content?.data?.timeSinceLastOutput;
1597
1599
  const analysis = message.content?.data?.analysis || 'No analysis available';
1598
1600
 
1601
+ const consecutiveWarnings = message.content?.data?.consecutiveWarnings;
1602
+ const warningsBeforeKill = message.content?.data?.warningsBeforeKill;
1599
1603
  this._log(
1600
- `⚠️ Orchestrator: Agent ${agentId} appears stale (${Math.round(timeSinceLastOutput / 1000)}s no output) but will NOT be killed`
1604
+ `⚠️ Orchestrator: Agent ${agentId} has produced no output for ${Math.round(timeSinceLastOutput / 1000)}s ` +
1605
+ `(warning ${consecutiveWarnings}/${warningsBeforeKill})`
1601
1606
  );
1602
1607
  this._log(` Analysis: ${analysis}`);
1603
- this._log(
1604
- ` Manual intervention may be needed - use 'zeroshot resume ${clusterId}' if stuck`
1605
- );
1608
+ this._log(` Zeroshot will terminate and restart the task if inactivity persists`);
1606
1609
  });
1607
1610
  }
1608
1611
 
@@ -2361,6 +2364,15 @@ class Orchestrator {
2361
2364
  return;
2362
2365
  }
2363
2366
  this.closed = true;
2367
+
2368
+ for (const cluster of this.clusters.values()) {
2369
+ if (typeof cluster.snapshotter?.stop === 'function') {
2370
+ cluster.snapshotter.stop();
2371
+ }
2372
+ if (typeof cluster.messageBus?.close === 'function') {
2373
+ cluster.messageBus.close();
2374
+ }
2375
+ }
2364
2376
  }
2365
2377
 
2366
2378
  /**
@@ -319,6 +319,8 @@ server.on('exit', async ({ exitCode, signal }) => {
319
319
  try {
320
320
  await updateTask(taskId, {
321
321
  status,
322
+ pid: null,
323
+ processGroupId: null,
322
324
  exitCode: resolvedCode,
323
325
  error: fatalError || (resolvedCode !== 0 && signal ? `Killed by ${signal}` : null),
324
326
  socketPath: null,
@@ -336,7 +338,12 @@ server.on('error', async (err) => {
336
338
  log(`\nError: ${err.message}\n`);
337
339
  await cleanupCommandSpec();
338
340
  try {
339
- await updateTask(taskId, { status: 'failed', error: err.message });
341
+ await updateTask(taskId, {
342
+ status: 'failed',
343
+ pid: null,
344
+ processGroupId: null,
345
+ error: err.message,
346
+ });
340
347
  } catch (updateError) {
341
348
  log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`);
342
349
  }
@@ -358,6 +365,8 @@ try {
358
365
  pid: server.pid,
359
366
  socketPath,
360
367
  attachable: true,
368
+ processGroupId: process.platform === 'win32' ? null : server.pid,
369
+ terminationStrategy: process.platform === 'win32' ? 'process-tree' : 'process-group',
361
370
  });
362
371
 
363
372
  log(`[${Date.now()}][SYSTEM] Started with PTY (attachable)\n`);
@@ -1,8 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { getTask, updateTask } from '../store.js';
3
- import { killTask as killProcess, isProcessRunning } from '../runner.js';
3
+ import { terminateProcess } from '../runner.js';
4
4
 
5
- export function killTaskCommand(taskId) {
5
+ export async function killTaskCommand(taskId, options = {}) {
6
6
  const task = getTask(taskId);
7
7
 
8
8
  if (!task) {
@@ -15,18 +15,43 @@ export function killTaskCommand(taskId) {
15
15
  return;
16
16
  }
17
17
 
18
- if (!isProcessRunning(task.pid)) {
18
+ const terminationOptions = {
19
+ ...options,
20
+ processGroupId: task.processGroupId,
21
+ terminationStrategy: task.terminationStrategy || 'process',
22
+ };
23
+
24
+ const result = await terminateProcess(task.pid, terminationOptions);
25
+
26
+ if (result.terminated && result.alreadyDead) {
19
27
  console.log(chalk.yellow('Process already dead, updating status...'));
20
- updateTask(taskId, { status: 'stale', error: 'Process died unexpectedly' });
28
+ updateTask(taskId, {
29
+ status: 'stale',
30
+ pid: null,
31
+ processGroupId: null,
32
+ error: 'Process died unexpectedly',
33
+ });
21
34
  return;
22
35
  }
23
36
 
24
- const killed = killProcess(task.pid);
25
-
26
- if (killed) {
27
- console.log(chalk.green(`✓ Sent SIGTERM to task ${taskId} (PID: ${task.pid})`));
28
- updateTask(taskId, { status: 'killed', error: 'Killed by user' });
37
+ if (result.terminated) {
38
+ const suffix = result.escalated ? ' after SIGKILL escalation' : ' with SIGTERM';
39
+ console.log(chalk.green(`✓ Killed task ${taskId} (PID: ${task.pid})${suffix}`));
40
+ if (result.degraded) {
41
+ console.log(chalk.yellow(`Warning: ${result.degradedReason}`));
42
+ }
43
+ updateTask(taskId, {
44
+ status: 'killed',
45
+ pid: null,
46
+ processGroupId: null,
47
+ exitCode: result.escalated ? 137 : 143,
48
+ error: result.escalated ? 'Killed by user after SIGKILL escalation' : 'Killed by user',
49
+ });
29
50
  } else {
30
51
  console.log(chalk.red(`Failed to kill task ${taskId}`));
52
+ updateTask(taskId, {
53
+ error: result.error || 'Process termination failed',
54
+ });
55
+ process.exitCode = 1;
31
56
  }
32
57
  }
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { getTask } from '../store.js';
3
- import { isProcessRunning } from '../runner.js';
3
+ import { isOwnedProcessTreeRunning } from '../runner.js';
4
4
 
5
5
  export function showStatus(taskId) {
6
6
  const task = getTask(taskId);
@@ -12,8 +12,18 @@ export function showStatus(taskId) {
12
12
 
13
13
  // Verify running status
14
14
  let status = task.status;
15
- if (status === 'running' && !isProcessRunning(task.pid)) {
16
- status = 'stale (process died)';
15
+ if (status === 'running') {
16
+ try {
17
+ const running = isOwnedProcessTreeRunning(task.pid, {
18
+ processGroupId: task.processGroupId,
19
+ terminationStrategy: task.terminationStrategy || 'process',
20
+ });
21
+ if (!running) {
22
+ status = 'stale (process died)';
23
+ }
24
+ } catch (error) {
25
+ status = `stale (invalid process ownership: ${error.message})`;
26
+ }
17
27
  }
18
28
 
19
29
  const statusColor =
@@ -0,0 +1,202 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export function isProcessRunning(pid) {
7
+ if (!pid) return false;
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export function killTask(pid) {
17
+ if (!pid) return false;
18
+ try {
19
+ process.kill(pid, 'SIGTERM');
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function sleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+
30
+ function normalizeTerminationOwnership(pid, options) {
31
+ const terminationStrategy = options.terminationStrategy || 'process';
32
+ const processGroupId = Number(options.processGroupId) || null;
33
+
34
+ if (terminationStrategy === 'process-group') {
35
+ if (process.platform === 'win32') {
36
+ throw new Error(
37
+ 'Process-group termination is unavailable on Windows; use terminationStrategy "process-tree"'
38
+ );
39
+ }
40
+ if (!processGroupId || processGroupId !== pid) {
41
+ throw new Error(
42
+ `Refusing process-group termination for PID ${pid}: owned processGroupId must equal the provider root PID`
43
+ );
44
+ }
45
+ }
46
+
47
+ if (terminationStrategy === 'process-tree' && process.platform !== 'win32') {
48
+ throw new Error(
49
+ `Process-tree termination is only supported on Windows; use terminationStrategy "process-group" on ${process.platform}`
50
+ );
51
+ }
52
+
53
+ if (!['process', 'process-group', 'process-tree'].includes(terminationStrategy)) {
54
+ throw new Error(`Unsupported termination strategy: ${terminationStrategy}`);
55
+ }
56
+
57
+ return { terminationStrategy, processGroupId };
58
+ }
59
+
60
+ function isProcessGroupRunning(processGroupId) {
61
+ try {
62
+ process.kill(-processGroupId, 0);
63
+ return true;
64
+ } catch (error) {
65
+ return error?.code === 'EPERM';
66
+ }
67
+ }
68
+
69
+ export function isOwnedProcessTreeRunning(pid, options = {}) {
70
+ if (!pid) return false;
71
+ const ownership = normalizeTerminationOwnership(pid, options);
72
+ if (ownership.terminationStrategy === 'process-group') {
73
+ return isProcessGroupRunning(ownership.processGroupId);
74
+ }
75
+ return isProcessRunning(pid);
76
+ }
77
+
78
+ async function signalWindowsProcessTree(pid, force) {
79
+ const args = ['/PID', String(pid), '/T'];
80
+ if (force) args.push('/F');
81
+ await execFileAsync('taskkill', args, { windowsHide: true });
82
+ }
83
+
84
+ async function signalOwnedProcessTree(pid, signal, ownership) {
85
+ if (ownership.terminationStrategy === 'process-group') {
86
+ process.kill(-ownership.processGroupId, signal);
87
+ return;
88
+ }
89
+ if (ownership.terminationStrategy === 'process-tree') {
90
+ await signalWindowsProcessTree(pid, signal === 'SIGKILL');
91
+ return;
92
+ }
93
+ process.kill(pid, signal);
94
+ }
95
+
96
+ async function waitForOwnedProcessTreeExit(pid, ownership, timeoutMs, pollMs) {
97
+ const deadline = Date.now() + timeoutMs;
98
+ const options = {
99
+ terminationStrategy: ownership.terminationStrategy,
100
+ processGroupId: ownership.processGroupId,
101
+ };
102
+ while (Date.now() < deadline) {
103
+ if (!isOwnedProcessTreeRunning(pid, options)) return true;
104
+ await sleep(pollMs);
105
+ }
106
+ return !isOwnedProcessTreeRunning(pid, options);
107
+ }
108
+
109
+ function terminationResult(ownership, overrides = {}) {
110
+ const degraded = ownership.terminationStrategy === 'process';
111
+ return {
112
+ terminated: false,
113
+ alreadyDead: false,
114
+ escalated: false,
115
+ signal: null,
116
+ scope: ownership.terminationStrategy,
117
+ degraded,
118
+ degradedReason: degraded
119
+ ? 'Task has no process-tree ownership metadata; only the provider root PID can be terminated'
120
+ : null,
121
+ ...overrides,
122
+ };
123
+ }
124
+
125
+ async function signalAndWait(pid, ownership, signal, timeoutMs, pollMs) {
126
+ const escalated = signal === 'SIGKILL';
127
+ try {
128
+ await signalOwnedProcessTree(pid, signal, ownership);
129
+ } catch (error) {
130
+ if (
131
+ !isOwnedProcessTreeRunning(pid, {
132
+ terminationStrategy: ownership.terminationStrategy,
133
+ processGroupId: ownership.processGroupId,
134
+ })
135
+ ) {
136
+ return terminationResult(ownership, {
137
+ terminated: true,
138
+ alreadyDead: signal === 'SIGTERM',
139
+ escalated,
140
+ signal: escalated ? signal : null,
141
+ });
142
+ }
143
+ return terminationResult(ownership, {
144
+ escalated,
145
+ signal,
146
+ error: error.message,
147
+ });
148
+ }
149
+
150
+ const terminated = await waitForOwnedProcessTreeExit(pid, ownership, timeoutMs, pollMs);
151
+ return terminationResult(ownership, {
152
+ terminated,
153
+ escalated,
154
+ signal,
155
+ error:
156
+ terminated || !escalated
157
+ ? null
158
+ : `Owned ${ownership.terminationStrategy} for PID ${pid} survived ${signal}`,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Terminate an owned provider process tree. Watchers create a dedicated process
164
+ * group on POSIX and persist that ownership boundary; Windows uses taskkill /T.
165
+ * Legacy tasks without ownership metadata fall back to root-only termination
166
+ * and report the degraded scope explicitly.
167
+ */
168
+ export async function terminateProcess(pid, options = {}) {
169
+ let ownership;
170
+ try {
171
+ ownership = normalizeTerminationOwnership(pid, options);
172
+ } catch (error) {
173
+ return {
174
+ terminated: false,
175
+ alreadyDead: false,
176
+ escalated: false,
177
+ signal: null,
178
+ error: error.message,
179
+ };
180
+ }
181
+
182
+ if (!isOwnedProcessTreeRunning(pid, options)) {
183
+ return terminationResult(ownership, { terminated: true, alreadyDead: true });
184
+ }
185
+
186
+ const graceful = await signalAndWait(
187
+ pid,
188
+ ownership,
189
+ 'SIGTERM',
190
+ options.graceMs ?? 5000,
191
+ options.pollMs ?? 50
192
+ );
193
+ if (graceful.terminated || graceful.error) return graceful;
194
+
195
+ return signalAndWait(
196
+ pid,
197
+ ownership,
198
+ 'SIGKILL',
199
+ options.hardKillWaitMs ?? 1000,
200
+ options.pollMs ?? 50
201
+ );
202
+ }