@the-open-engine/zeroshot 6.7.0 → 6.7.2

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 (61) hide show
  1. package/cli/commands/providers.js +4 -1
  2. package/cli/index.js +131 -27
  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/git-remote-utils.js +90 -7
  27. package/lib/settings.js +1 -0
  28. package/package.json +6 -15
  29. package/protocol/openengine-cluster/v1/worker.schema.json +47 -0
  30. package/scripts/assert-release-published.js +128 -0
  31. package/scripts/release-dry-run.js +83 -0
  32. package/scripts/release-preflight.js +24 -12
  33. package/scripts/release-recovery.js +149 -0
  34. package/scripts/run-lint-staged-no-stash.js +68 -0
  35. package/scripts/semantic-release-notes.js +44 -0
  36. package/scripts/setup-merge-queue.sh +104 -118
  37. package/src/agent/agent-lifecycle.js +368 -91
  38. package/src/agent/agent-task-executor.js +384 -101
  39. package/src/agent-cli-provider/adapters/claude.ts +29 -11
  40. package/src/agent-cli-provider/adapters/codex.ts +4 -0
  41. package/src/agent-cli-provider/adapters/common.ts +2 -1
  42. package/src/agent-cli-provider/contract-options.ts +2 -1
  43. package/src/agent-cli-provider/index.ts +1 -0
  44. package/src/agent-cli-provider/provider-registry.ts +1 -1
  45. package/src/agent-cli-provider/single-agent-runtime.ts +8 -2
  46. package/src/agent-cli-provider/types.ts +3 -1
  47. package/src/agent-wrapper.js +9 -2
  48. package/src/agents/git-pusher-template.js +94 -19
  49. package/src/attach/socket-discovery.js +9 -19
  50. package/src/attach/socket-paths.js +85 -0
  51. package/src/config-validator.js +10 -11
  52. package/src/isolation-manager.js +164 -74
  53. package/src/orchestrator.js +101 -40
  54. package/task-lib/attachable-watcher.js +13 -10
  55. package/task-lib/commands/kill.js +34 -9
  56. package/task-lib/commands/run.js +17 -2
  57. package/task-lib/commands/status.js +13 -3
  58. package/task-lib/process-termination.js +202 -0
  59. package/task-lib/runner.js +29 -23
  60. package/task-lib/store.js +28 -6
  61. package/task-lib/watcher.js +14 -2
@@ -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,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { spawnTask } from '../runner.js';
2
+ import { shouldUseAttachableWatcher, spawnTask } from '../runner.js';
3
3
 
4
4
  export async function runTask(prompt, options = {}) {
5
5
  if (!prompt || prompt.trim().length === 0) {
@@ -46,8 +46,23 @@ export async function runTask(prompt, options = {}) {
46
46
  console.log(chalk.dim(` Log: ${task.logFile}`));
47
47
  console.log(chalk.dim(` CWD: ${task.cwd}`));
48
48
 
49
+ const attachSupported = shouldUseAttachableWatcher(
50
+ {
51
+ jsonSchema: outputFormat === 'json' ? jsonSchema : null,
52
+ },
53
+ task.provider
54
+ );
55
+
49
56
  console.log(chalk.dim('\nCommands:'));
50
- console.log(chalk.dim(` zeroshot attach ${task.id} # Attach to task (Ctrl+B d to detach)`));
57
+ if (attachSupported) {
58
+ console.log(chalk.dim(` zeroshot attach ${task.id} # Attach to task (Ctrl+B d to detach)`));
59
+ } else {
60
+ console.log(
61
+ chalk.dim(
62
+ ` Attach unavailable: ${task.provider} strict structured output uses a non-PTY watcher`
63
+ )
64
+ );
65
+ }
51
66
  console.log(chalk.dim(` zeroshot logs ${task.id} # View output`));
52
67
  console.log(chalk.dim(` zeroshot logs -f ${task.id} # Follow output`));
53
68
  console.log(chalk.dim(` zeroshot status ${task.id} # Check status`));
@@ -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
+ }
@@ -7,6 +7,12 @@ import { createRequire } from 'module';
7
7
 
8
8
  const require = createRequire(import.meta.url);
9
9
  const { prepareSingleAgentProviderCommand } = require('./provider-helper-runtime.js');
10
+ export {
11
+ isOwnedProcessTreeRunning,
12
+ isProcessRunning,
13
+ killTask,
14
+ terminateProcess,
15
+ } from './process-termination.js';
10
16
 
11
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
18
 
@@ -47,7 +53,13 @@ export function spawnTask(prompt, options = {}) {
47
53
  providerName,
48
54
  commandSpec
49
55
  );
50
- const watcherScript = resolveWatcherScript(options);
56
+ const watcherScript = resolveWatcherScript(
57
+ {
58
+ attachable: options.attachable,
59
+ jsonSchema,
60
+ },
61
+ providerName
62
+ );
51
63
  spawnWatcher({
52
64
  watcherScript,
53
65
  id,
@@ -136,6 +148,8 @@ function buildTaskRecord({ id, prompt, cwd, options, logFile, providerName, mode
136
148
  // Attach support
137
149
  socketPath: null,
138
150
  attachable: false,
151
+ processGroupId: null,
152
+ terminationStrategy: null,
139
153
  };
140
154
  }
141
155
 
@@ -157,8 +171,20 @@ function buildWatcherCommandSpec(commandSpec) {
157
171
  return watcherCommandSpec;
158
172
  }
159
173
 
160
- function resolveWatcherScript(options) {
161
- const useAttachable = options.attachable !== false && !options.jsonSchema;
174
+ export function shouldUseAttachableWatcher(options, providerName) {
175
+ if (options.attachable === false) {
176
+ return false;
177
+ }
178
+
179
+ // Claude strict structured output still needs the non-PTY watcher. Claude
180
+ // can treat PTY notifications as streaming commands and reject the run.
181
+ // Other providers, including Codex, support their structured-output mode in
182
+ // the attachable PTY watcher and must not lose the advertised attach socket.
183
+ return !(providerName === 'claude' && options.jsonSchema);
184
+ }
185
+
186
+ function resolveWatcherScript(options, providerName) {
187
+ const useAttachable = shouldUseAttachableWatcher(options, providerName);
162
188
  return useAttachable ? join(__dirname, 'attachable-watcher.js') : join(__dirname, 'watcher.js');
163
189
  }
164
190
 
@@ -176,23 +202,3 @@ function spawnWatcher({ watcherScript, id, cwd, logFile, finalArgs, watcherConfi
176
202
  watcher.unref();
177
203
  watcher.disconnect(); // Close IPC channel so parent can exit
178
204
  }
179
-
180
- export function isProcessRunning(pid) {
181
- if (!pid) return false;
182
- try {
183
- process.kill(pid, 0);
184
- return true;
185
- } catch {
186
- return false;
187
- }
188
- }
189
-
190
- export function killTask(pid) {
191
- if (!pid) return false;
192
- try {
193
- process.kill(pid, 'SIGTERM');
194
- return true;
195
- } catch {
196
- return false;
197
- }
198
- }
package/task-lib/store.js CHANGED
@@ -50,7 +50,9 @@ function getDb() {
50
50
  model TEXT,
51
51
  schedule_id TEXT,
52
52
  socket_path TEXT,
53
- attachable INTEGER DEFAULT 0
53
+ attachable INTEGER DEFAULT 0,
54
+ process_group_id INTEGER,
55
+ termination_strategy TEXT
54
56
  );
55
57
 
56
58
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
@@ -75,9 +77,19 @@ function getDb() {
75
77
  CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON schedules(enabled);
76
78
  `);
77
79
 
80
+ ensureTaskColumn(db, 'process_group_id', 'INTEGER');
81
+ ensureTaskColumn(db, 'termination_strategy', 'TEXT');
82
+
78
83
  return db;
79
84
  }
80
85
 
86
+ function ensureTaskColumn(database, name, definition) {
87
+ const columns = database.pragma('table_info(tasks)');
88
+ if (!columns.some((column) => column.name === name)) {
89
+ database.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${definition}`);
90
+ }
91
+ }
92
+
81
93
  export function ensureDirs() {
82
94
  if (!existsSync(TASKS_DIR)) mkdirSync(TASKS_DIR, { recursive: true });
83
95
  if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
@@ -110,6 +122,8 @@ function rowToTask(row) {
110
122
  scheduleId: row.schedule_id,
111
123
  socketPath: row.socket_path,
112
124
  attachable: Boolean(row.attachable),
125
+ processGroupId: row.process_group_id,
126
+ terminationStrategy: row.termination_strategy,
113
127
  };
114
128
  }
115
129
 
@@ -137,11 +151,11 @@ export function saveTasks(tasks) {
137
151
  INSERT OR REPLACE INTO tasks (
138
152
  id, prompt, full_prompt, cwd, status, pid, session_id, log_file,
139
153
  created_at, updated_at, exit_code, error, provider, model,
140
- schedule_id, socket_path, attachable
154
+ schedule_id, socket_path, attachable, process_group_id, termination_strategy
141
155
  ) VALUES (
142
156
  @id, @prompt, @fullPrompt, @cwd, @status, @pid, @sessionId, @logFile,
143
157
  @createdAt, @updatedAt, @exitCode, @error, @provider, @model,
144
- @scheduleId, @socketPath, @attachable
158
+ @scheduleId, @socketPath, @attachable, @processGroupId, @terminationStrategy
145
159
  )
146
160
  `);
147
161
 
@@ -168,6 +182,8 @@ export function saveTasks(tasks) {
168
182
  scheduleId: task.scheduleId || null,
169
183
  socketPath: task.socketPath || null,
170
184
  attachable: task.attachable ? 1 : 0,
185
+ processGroupId: task.processGroupId || null,
186
+ terminationStrategy: task.terminationStrategy || null,
171
187
  });
172
188
  }
173
189
  });
@@ -232,7 +248,9 @@ export function updateTask(id, updates) {
232
248
  model = @model,
233
249
  schedule_id = @scheduleId,
234
250
  socket_path = @socketPath,
235
- attachable = @attachable
251
+ attachable = @attachable,
252
+ process_group_id = @processGroupId,
253
+ termination_strategy = @terminationStrategy
236
254
  WHERE id = @id
237
255
  `
238
256
  )
@@ -253,6 +271,8 @@ export function updateTask(id, updates) {
253
271
  scheduleId: updated.scheduleId || null,
254
272
  socketPath: updated.socketPath || null,
255
273
  attachable: updated.attachable ? 1 : 0,
274
+ processGroupId: updated.processGroupId || null,
275
+ terminationStrategy: updated.terminationStrategy || null,
256
276
  });
257
277
 
258
278
  return updated;
@@ -277,11 +297,11 @@ export function addTask(task) {
277
297
  INSERT INTO tasks (
278
298
  id, prompt, full_prompt, cwd, status, pid, session_id, log_file,
279
299
  created_at, updated_at, exit_code, error, provider, model,
280
- schedule_id, socket_path, attachable
300
+ schedule_id, socket_path, attachable, process_group_id, termination_strategy
281
301
  ) VALUES (
282
302
  @id, @prompt, @fullPrompt, @cwd, @status, @pid, @sessionId, @logFile,
283
303
  @createdAt, @updatedAt, @exitCode, @error, @provider, @model,
284
- @scheduleId, @socketPath, @attachable
304
+ @scheduleId, @socketPath, @attachable, @processGroupId, @terminationStrategy
285
305
  )
286
306
  `
287
307
  )
@@ -303,6 +323,8 @@ export function addTask(task) {
303
323
  scheduleId: fullTask.scheduleId || null,
304
324
  socketPath: fullTask.socketPath || null,
305
325
  attachable: fullTask.attachable ? 1 : 0,
326
+ processGroupId: fullTask.processGroupId || null,
327
+ terminationStrategy: fullTask.terminationStrategy || null,
306
328
  });
307
329
 
308
330
  return fullTask;
@@ -45,10 +45,15 @@ const child = spawn(command, finalArgs, {
45
45
  cwd: commandSpec.cwd || cwd,
46
46
  env,
47
47
  stdio: ['ignore', 'pipe', 'pipe'],
48
+ detached: process.platform !== 'win32',
48
49
  windowsHide: true,
49
50
  });
50
51
 
51
- updateTask(taskId, { pid: child.pid });
52
+ updateTask(taskId, {
53
+ pid: child.pid,
54
+ processGroupId: process.platform === 'win32' ? null : child.pid,
55
+ terminationStrategy: process.platform === 'win32' ? 'process-tree' : 'process-group',
56
+ });
52
57
 
53
58
  const silentJsonMode =
54
59
  config.outputFormat === 'json' &&
@@ -285,6 +290,8 @@ child.on('close', async (code, signal) => {
285
290
  try {
286
291
  await updateTask(taskId, {
287
292
  status,
293
+ pid: null,
294
+ processGroupId: null,
288
295
  exitCode: resolvedCode,
289
296
  error: fatalError || (resolvedCode !== 0 && signal ? `Killed by ${signal}` : null),
290
297
  });
@@ -298,7 +305,12 @@ child.on('error', async (err) => {
298
305
  log(`\nError: ${err.message}\n`);
299
306
  cleanupCommandSpecSync();
300
307
  try {
301
- await updateTask(taskId, { status: 'failed', error: err.message });
308
+ await updateTask(taskId, {
309
+ status: 'failed',
310
+ pid: null,
311
+ processGroupId: null,
312
+ error: err.message,
313
+ });
302
314
  } catch (updateError) {
303
315
  log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`);
304
316
  }