@syntax-syllogism/aloop 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapters.mjs CHANGED
@@ -16,9 +16,29 @@
16
16
  * treats the result as opaque text.
17
17
  */
18
18
 
19
+ import { mkdtemp, rm } from 'node:fs/promises';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { runCommand } from './command.mjs';
23
+
19
24
  /** Fields worth showing for a tool call, most specific first. */
20
25
  const toolSummaryFields = ['command', 'file_path', 'pattern', 'path', 'url', 'description', 'prompt'];
21
26
 
27
+ export const PERMISSIONS = Object.freeze({
28
+ READ_ONLY: 'read-only',
29
+ WRITE_WORKTREE: 'write-worktree',
30
+ PUBLISH: 'publish',
31
+ });
32
+
33
+ /** Resolve a phase permission conservatively for adapters and BYO engines. */
34
+ export function permissionLevel(permissions) {
35
+ const requested = Array.isArray(permissions) ? permissions : [];
36
+ if (requested.includes(PERMISSIONS.READ_ONLY)) return PERMISSIONS.READ_ONLY;
37
+ if (requested.includes(PERMISSIONS.WRITE_WORKTREE)) return PERMISSIONS.WRITE_WORKTREE;
38
+ if (requested.includes(PERMISSIONS.PUBLISH)) return PERMISSIONS.PUBLISH;
39
+ return PERMISSIONS.READ_ONLY;
40
+ }
41
+
22
42
  function condense(text, limit = 120) {
23
43
  const flat = text.replace(/\s+/g, ' ').trim();
24
44
  return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
@@ -51,6 +71,17 @@ function summarizeAgyParams(params) {
51
71
  return '';
52
72
  }
53
73
 
74
+ function cliVersion(command) {
75
+ return async () => {
76
+ const cwd = await mkdtemp(join(tmpdir(), 'aloop-version-'));
77
+ try {
78
+ return (await runCommand(command, ['--version'], { cwd, timeoutMs: 5000 })).stdout.trim();
79
+ } finally {
80
+ await rm(cwd, { recursive: true, force: true });
81
+ }
82
+ };
83
+ }
84
+
54
85
  function renderClaudeBlock(block) {
55
86
  if (block.type === 'text') return block.text?.trim() ? `${block.text.trimEnd()}\n` : '';
56
87
  if (block.type === 'thinking') return ' \u00b7 thinking\n';
@@ -141,10 +172,39 @@ export function renderAgyEvent(event) {
141
172
  */
142
173
  export function createJsonlRenderer(renderEvent) {
143
174
  let pending = '';
175
+ let usage = null;
176
+ const recordUsage = (event) => {
177
+ if (!event || typeof event !== 'object') return;
178
+ const source = event.usage ?? event.result?.usage ?? event;
179
+ if (!source || typeof source !== 'object') return;
180
+ const tokenValues = [
181
+ source.tokens,
182
+ source.total_tokens,
183
+ source.totalTokens,
184
+ source.input_tokens !== undefined && source.output_tokens !== undefined
185
+ ? Number(source.input_tokens) + Number(source.output_tokens)
186
+ : undefined,
187
+ source.prompt_tokens !== undefined && source.completion_tokens !== undefined
188
+ ? Number(source.prompt_tokens) + Number(source.completion_tokens)
189
+ : undefined,
190
+ ].filter((value) => Number.isFinite(value));
191
+ const cost = [event.usage, event.result?.usage, event]
192
+ .filter((candidate) => candidate && typeof candidate === 'object')
193
+ .flatMap((candidate) => [candidate.cost, candidate.total_cost, candidate.totalCost, candidate.total_cost_usd])
194
+ .find((value) => Number.isFinite(value));
195
+ if (tokenValues.length || cost !== undefined) {
196
+ usage = {
197
+ ...(tokenValues.length ? { tokens: tokenValues.at(-1) } : usage?.tokens !== undefined ? { tokens: usage.tokens } : {}),
198
+ ...(cost !== undefined ? { cost } : usage?.cost !== undefined ? { cost: usage.cost } : {}),
199
+ };
200
+ }
201
+ };
144
202
  const renderLine = (line) => {
145
203
  if (!line.trim()) return '';
146
204
  try {
147
- return renderEvent(JSON.parse(line));
205
+ const event = JSON.parse(line);
206
+ recordUsage(event);
207
+ return renderEvent(event);
148
208
  } catch {
149
209
  return `${line}\n`;
150
210
  }
@@ -161,6 +221,9 @@ export function createJsonlRenderer(renderEvent) {
161
221
  pending = '';
162
222
  return renderLine(rest);
163
223
  },
224
+ usage() {
225
+ return usage;
226
+ },
164
227
  };
165
228
  }
166
229
 
@@ -171,9 +234,11 @@ export function passthroughRenderer() {
171
234
 
172
235
  const claudeAdapter = {
173
236
  name: 'claude',
237
+ version: cliVersion('claude'),
174
238
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
175
- command({ prompt, addDirs, agent = {} }) {
176
- // `auto`, not `acceptEdits`: under acceptEdits a non-interactive `-p` run
239
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
240
+ // `auto`, not `acceptEdits`, is used for worktree-writing and
241
+ // artifact-only phases: under acceptEdits a non-interactive `-p` run
177
242
  // auto-denies every Bash call, because there is no one to answer the
178
243
  // prompt it raises. Phases that have to build, test, and commit then spin
179
244
  // until the timeout kills them.
@@ -181,7 +246,9 @@ const claudeAdapter = {
181
246
  // the process exits, so a phase that runs for half an hour looks identical
182
247
  // to one that has hung. stream-json emits an event per step, which
183
248
  // `createRenderer` turns back into readable lines.
184
- const args = ['-p', prompt, '--permission-mode', 'auto', '--output-format', 'stream-json', '--verbose'];
249
+ const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
250
+ const permissionMode = canWrite ? 'auto' : 'plan';
251
+ const args = ['-p', prompt, '--permission-mode', permissionMode, '--output-format', 'stream-json', '--verbose'];
185
252
  if (agent.model) args.push('--model', agent.model);
186
253
  if (agent.effort) args.push('--effort', agent.effort);
187
254
  for (const dir of addDirs) args.push('--add-dir', dir);
@@ -192,9 +259,12 @@ const claudeAdapter = {
192
259
 
193
260
  const codexAdapter = {
194
261
  name: 'codex',
262
+ version: cliVersion('codex'),
195
263
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
196
- command({ prompt, cwd, addDirs, agent = {} }) {
197
- const args = ['exec', prompt, '--sandbox', 'workspace-write', '--cd', cwd];
264
+ command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = {} }) {
265
+ const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
266
+ const sandbox = canWrite ? 'workspace-write' : 'read-only';
267
+ const args = ['exec', prompt, '--sandbox', sandbox, '--cd', cwd];
198
268
  if (agent.model) args.push('--model', agent.model);
199
269
  if (agent.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(agent.effort)}`);
200
270
  for (const dir of addDirs) args.push('--add-dir', dir);
@@ -204,8 +274,9 @@ const codexAdapter = {
204
274
 
205
275
  const agyAdapter = {
206
276
  name: 'agy',
277
+ version: cliVersion('agy'),
207
278
  efforts: ['low', 'medium', 'high'],
208
- command({ prompt, addDirs, timeoutMs, agent = {} }) {
279
+ command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = {} }) {
209
280
  // `--dangerously-skip-permissions`, not bare `accept-edits`: in headless
210
281
  // `--print` mode agy cannot prompt for the `command` permission its Bash-
211
282
  // style tools need, so it auto-denies the first one and exits 0 having done
@@ -218,12 +289,13 @@ const agyAdapter = {
218
289
  // from a hang (the run log shows only the header). stream-json emits an
219
290
  // event per step, which `createRenderer` turns back into readable lines —
220
291
  // same reasoning as the claude adapter above.
292
+ const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
221
293
  const args = [
222
294
  '--print', prompt,
223
- '--mode', 'accept-edits',
224
- '--dangerously-skip-permissions',
295
+ '--mode', canWrite ? 'accept-edits' : 'plan',
225
296
  '--output-format', 'stream-json',
226
297
  ];
298
+ if (canWrite) args.splice(4, 0, '--dangerously-skip-permissions');
227
299
  if (agent.model) args.push('--model', agent.model);
228
300
  if (agent.effort) args.push('--effort', agent.effort);
229
301
  if (timeoutMs) args.push('--print-timeout', `${Math.ceil(timeoutMs / 1000)}s`);
@@ -245,6 +317,9 @@ function validateConfiguredAdapter(name, adapter) {
245
317
  if (adapter.createRenderer !== undefined && typeof adapter.createRenderer !== 'function') {
246
318
  throw new Error(`Invalid adapter "${name}": createRenderer must be a function.`);
247
319
  }
320
+ if (adapter.version !== undefined && typeof adapter.version !== 'function') {
321
+ throw new Error(`Invalid adapter "${name}": version must be a function.`);
322
+ }
248
323
  return adapter;
249
324
  }
250
325
 
@@ -0,0 +1,231 @@
1
+ import { runCommand } from '../command.mjs';
2
+ import { GitFacade } from '../git.mjs';
3
+ import { PublishError } from '../publish.mjs';
4
+
5
+ const DRAFT_RE = /^(draft:\s*|wip:\s*)/i;
6
+ const NO_MERGE_REQUEST_RE = /\b(?:no merge requests? found|merge requests? not found|merge requests? (?:do|does) not exist)\b/i;
7
+
8
+ /**
9
+ * @typedef {object} GitLabMr
10
+ * @property {number} iid
11
+ * @property {string} web_url
12
+ * @property {string} target_branch
13
+ * @property {string} sha - The merge request head commit SHA.
14
+ * @property {boolean} [draft]
15
+ * @property {boolean} [work_in_progress] - Legacy GitLab draft field.
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} GitLabTransport
20
+ * @property {(ctx: { host: string }) => Promise<void>} checkAuth
21
+ * @property {(ctx: { host: string, project: string, branch: string }) => Promise<GitLabMr|null>} getMergeRequest
22
+ * @property {(ctx: { host: string, project: string, sourceBranch: string, targetBranch: string, title: string, description: string, descriptionFilePath?: string, draft: boolean }) => Promise<void>} createMergeRequest
23
+ * @property {(ctx: { host: string, project: string, iid: number, branch: string, title: string, description: string, descriptionFilePath?: string, draft: boolean }) => Promise<void>} updateMergeRequest
24
+ */
25
+
26
+ /**
27
+ * Parse the URL forms emitted by Git, retaining a self-hosted port in `host`.
28
+ * GitLab project paths may contain any number of namespace segments.
29
+ */
30
+ export function parseGitLabRemoteUrl(remoteUrl) {
31
+ if (typeof remoteUrl !== 'string' || !remoteUrl.trim()) {
32
+ throw new PublishError('Configured GitLab remote is not a valid Git remote URL.');
33
+ }
34
+
35
+ const value = remoteUrl.trim();
36
+ let host;
37
+ let project;
38
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(value);
42
+ } catch (error) {
43
+ throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`, { cause: error });
44
+ }
45
+ if (!['http:', 'https:', 'ssh:'].includes(parsed.protocol) || !parsed.host) {
46
+ throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
47
+ }
48
+ host = parsed.host;
49
+ project = parsed.pathname;
50
+ } else {
51
+ const match = /^git@(?:(\[[^\]]+\])|([^:/]+)):(.+)$/i.exec(value);
52
+ if (!match) {
53
+ throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
54
+ }
55
+ host = match[1] ?? match[2];
56
+ project = match[3];
57
+ // Git's scp-like syntax has no standard port notation. Accept the common
58
+ // git@host:2222/group/project form while keeping normal namespace paths.
59
+ const portMatch = /^(\d+)\/(.+)$/.exec(project);
60
+ if (portMatch) {
61
+ host = `${host}:${portMatch[1]}`;
62
+ project = portMatch[2];
63
+ }
64
+ }
65
+
66
+ project = project.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
67
+ if (!host || !project || project.split('/').some((segment) => !segment)) {
68
+ throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
69
+ }
70
+ return { host, project };
71
+ }
72
+
73
+ export const withDraftPrefix = (title) => DRAFT_RE.test(title) ? title : `Draft: ${title}`;
74
+ export const withoutDraftPrefix = (title) => title.replace(DRAFT_RE, '');
75
+
76
+ function errorText(error) {
77
+ return [error?.output, error?.stderr, error?.cause?.output, error?.message]
78
+ .filter(Boolean)
79
+ .join('\n');
80
+ }
81
+
82
+ /**
83
+ * The default GitLab transport. `glab` is intentionally an external runtime
84
+ * requirement, so consumers can replace this transport with REST or MCP code.
85
+ */
86
+ export function glabTransport({ cwd, runner = runCommand, env } = {}) {
87
+ async function run(args) {
88
+ try {
89
+ return await runner('glab', args, { cwd, ...(env ? { env } : {}) });
90
+ } catch (error) {
91
+ if (error.code === 'ENOENT') {
92
+ throw new PublishError('GitLab publishing requires the `glab` CLI, but it is not installed.', { cause: error });
93
+ }
94
+ throw new PublishError(errorText(error).trim() || 'GitLab CLI command failed.', { cause: error });
95
+ }
96
+ }
97
+
98
+ const repository = (host, project) => `https://${host}/${project}`;
99
+ const descriptionArgs = (description, descriptionFilePath) => descriptionFilePath
100
+ ? ['--description-file', descriptionFilePath]
101
+ : ['--description', description];
102
+
103
+ return {
104
+ async checkAuth({ host }) {
105
+ await run(['auth', 'status', '--hostname', host]);
106
+ },
107
+
108
+ async getMergeRequest({ host, project, branch }) {
109
+ try {
110
+ const result = await run(['mr', 'view', branch, '--output', 'json', '--repo', repository(host, project)]);
111
+ return JSON.parse(result.stdout);
112
+ } catch (error) {
113
+ const output = errorText(error);
114
+ if (error.cause?.code === 'ENOENT') throw error;
115
+ // Only the CLI's explicit no-MR messages mean it is safe to create one.
116
+ // Other exit-code-1 failures, such as permissions or connectivity, must
117
+ // reach publish() instead of being mistaken for an absent MR.
118
+ if (NO_MERGE_REQUEST_RE.test(output)) {
119
+ return null;
120
+ }
121
+ throw new PublishError(`Unable to inspect GitLab merge request for ${branch}: ${error.message}`, { cause: error });
122
+ }
123
+ },
124
+
125
+ async createMergeRequest({ host, project, sourceBranch, targetBranch, title, description, descriptionFilePath, draft }) {
126
+ const args = [
127
+ 'mr', 'create',
128
+ '--repo', repository(host, project),
129
+ '--source-branch', sourceBranch,
130
+ '--target-branch', targetBranch,
131
+ '--title', title,
132
+ ...descriptionArgs(description, descriptionFilePath),
133
+ ...(draft ? ['--draft'] : []),
134
+ '--yes',
135
+ ];
136
+ await run(args);
137
+ },
138
+
139
+ async updateMergeRequest({ host, project, iid, title, description, descriptionFilePath, draft }) {
140
+ const args = [
141
+ 'mr', 'update', String(iid),
142
+ '--repo', repository(host, project),
143
+ '--title', title,
144
+ ...descriptionArgs(description, descriptionFilePath),
145
+ draft ? '--draft' : '--ready',
146
+ '--yes',
147
+ ];
148
+ await run(args);
149
+ },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Adapt GitLab-native merge request data to aloop's verified pull-request port.
155
+ *
156
+ * @param {{ cwd: string, git?: GitFacade, transport?: GitLabTransport, env?: object }} options
157
+ */
158
+ export function gitlabBackend({ cwd, git = new GitFacade(cwd), transport, env } = {}) {
159
+ transport ??= glabTransport({ cwd, env });
160
+ const repositories = new Map();
161
+
162
+ async function resolveRepository(remote) {
163
+ if (!repositories.has(remote)) {
164
+ const remoteUrl = await git.remoteUrl(remote);
165
+ repositories.set(remote, parseGitLabRemoteUrl(remoteUrl));
166
+ }
167
+ return repositories.get(remote);
168
+ }
169
+
170
+ async function mergeRequest(remote, branch) {
171
+ const { host, project } = await resolveRepository(remote);
172
+ return { host, project, mr: await transport.getMergeRequest({ host, project, branch }) };
173
+ }
174
+
175
+ function mapMergeRequest(mr) {
176
+ if (!mr) return null;
177
+ return {
178
+ url: mr.web_url,
179
+ baseRefName: mr.target_branch,
180
+ headRefOid: mr.sha,
181
+ isDraft: mr.draft ?? mr.work_in_progress ?? false,
182
+ number: Number(mr.iid),
183
+ };
184
+ }
185
+
186
+ return {
187
+ async precheck({ remote }) {
188
+ try {
189
+ const { host } = await resolveRepository(remote);
190
+ await transport.checkAuth({ host });
191
+ } catch (error) {
192
+ throw new PublishError(`GitLab publishing precheck failed: ${error.message}`, { cause: error });
193
+ }
194
+ },
195
+
196
+ async view({ remote, branch }) {
197
+ return mapMergeRequest((await mergeRequest(remote, branch)).mr);
198
+ },
199
+
200
+ async create({ remote, base, branch, title, body, bodyFilePath, draft }) {
201
+ const { host, project } = await resolveRepository(remote);
202
+ await transport.createMergeRequest({
203
+ host,
204
+ project,
205
+ sourceBranch: branch,
206
+ targetBranch: base,
207
+ title: draft ? withDraftPrefix(title) : withoutDraftPrefix(title),
208
+ description: body,
209
+ descriptionFilePath: bodyFilePath,
210
+ draft,
211
+ });
212
+ },
213
+
214
+ async update({ remote, branch, title, body, bodyFilePath, draft }) {
215
+ const { host, project, mr } = await mergeRequest(remote, branch);
216
+ if (!mr) throw new PublishError(`No GitLab merge request exists for branch ${branch}.`);
217
+ const iid = Number(mr.iid);
218
+ if (!Number.isInteger(iid)) throw new PublishError(`GitLab merge request for branch ${branch} has no valid IID.`);
219
+ await transport.updateMergeRequest({
220
+ host,
221
+ project,
222
+ iid,
223
+ branch,
224
+ title: draft ? withDraftPrefix(title) : withoutDraftPrefix(title),
225
+ description: body,
226
+ descriptionFilePath: bodyFilePath,
227
+ draft,
228
+ });
229
+ },
230
+ };
231
+ }
package/src/command.mjs CHANGED
@@ -1,4 +1,115 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { win32 as windowsPath } from 'node:path';
4
+
5
+ export function signalProcessGroup(pid, signal, {
6
+ platform = process.platform,
7
+ spawnImpl = spawn,
8
+ onFailure,
9
+ } = {}) {
10
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
11
+ if (platform === 'win32') {
12
+ const handleFailure = onFailure ?? (() => {
13
+ try {
14
+ process.kill(pid, signal);
15
+ } catch (error) {
16
+ if (error.code !== 'ESRCH') throw error;
17
+ }
18
+ });
19
+ let killer;
20
+ try {
21
+ killer = spawnImpl('taskkill', ['/PID', String(pid), '/T', '/F'], {
22
+ stdio: 'ignore',
23
+ windowsHide: true,
24
+ });
25
+ } catch {
26
+ handleFailure();
27
+ return false;
28
+ }
29
+ const addListener = killer?.once ?? killer?.on;
30
+ if (!addListener) {
31
+ handleFailure();
32
+ return false;
33
+ }
34
+ let completed = false;
35
+ const fail = () => {
36
+ if (completed) return;
37
+ completed = true;
38
+ handleFailure();
39
+ };
40
+ addListener.call(killer, 'error', fail);
41
+ addListener.call(killer, 'close', (code) => {
42
+ if (completed) return;
43
+ completed = true;
44
+ if (code !== 0) handleFailure();
45
+ });
46
+ killer?.unref?.();
47
+ return true;
48
+ }
49
+ try {
50
+ process.kill(-pid, signal);
51
+ return true;
52
+ } catch (error) {
53
+ if (error.code !== 'ESRCH') throw error;
54
+ return false;
55
+ }
56
+ }
57
+
58
+ const WINDOWS_EXECUTABLE_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD', '.PS1'];
59
+
60
+ function environmentValue(env, name) {
61
+ if (env[name] !== undefined) return env[name];
62
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
63
+ return key ? env[key] : undefined;
64
+ }
65
+
66
+ export function resolveWindowsExecutable(command, env = process.env, { fileExists = existsSync } = {}) {
67
+ const pathEntries = (environmentValue(env, 'PATH') ?? '').split(';').filter(Boolean);
68
+ const pathExtensions = (environmentValue(env, 'PATHEXT') ?? WINDOWS_EXECUTABLE_EXTENSIONS.join(';'))
69
+ .split(';')
70
+ .map((extension) => extension.trim().toUpperCase())
71
+ .filter(Boolean);
72
+ const hasDirectory = command.includes('\\') || command.includes('/');
73
+ const bases = hasDirectory ? [command] : pathEntries.map((directory) => windowsPath.join(directory, command));
74
+ const extensions = windowsPath.extname(command) ? [''] : pathExtensions;
75
+
76
+ for (const base of bases) {
77
+ for (const extension of extensions) {
78
+ const candidate = `${base}${extension}`;
79
+ if (fileExists(candidate)) return candidate;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function windowsSpawnSpec(command, args, env, resolveExecutable) {
86
+ const resolved = resolveExecutable(command, env) ?? command;
87
+ const extension = windowsPath.extname(resolved).toLowerCase();
88
+ if (extension === '.cmd' || extension === '.bat') {
89
+ return {
90
+ command: environmentValue(env, 'ComSpec') ?? 'cmd.exe',
91
+ args: ['/d', '/s', '/c', resolved, ...args],
92
+ };
93
+ }
94
+ if (extension === '.ps1') {
95
+ return {
96
+ command: environmentValue(env, 'SystemRoot')
97
+ ? windowsPath.join(environmentValue(env, 'SystemRoot'), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
98
+ : 'powershell.exe',
99
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolved, ...args],
100
+ };
101
+ }
102
+ return { command: resolved, args };
103
+ }
104
+
105
+ function clearActiveProcess(path) {
106
+ if (!path) return;
107
+ try {
108
+ unlinkSync(path);
109
+ } catch (error) {
110
+ if (error.code !== 'ENOENT') throw error;
111
+ }
112
+ }
2
113
 
3
114
  /**
4
115
  * Run a command, capturing its output while optionally streaming it onward.
@@ -9,17 +120,56 @@ import { spawn } from 'node:child_process';
9
120
  * most common unattended failure and it burns tokens for as long as it hangs.
10
121
  */
11
122
  export async function runCommand(command, args = [], options = {}) {
12
- const { cwd, env = process.env, timeoutMs, onOutput, input } = options;
123
+ const {
124
+ cwd,
125
+ env = process.env,
126
+ timeoutMs,
127
+ onOutput,
128
+ input,
129
+ activeProcessPath,
130
+ platform = process.platform,
131
+ spawnImpl = spawn,
132
+ resolveExecutable = resolveWindowsExecutable,
133
+ } = options;
13
134
  return new Promise((resolve, reject) => {
14
- const child = spawn(command, args, { cwd, env });
135
+ const spawnSpec = platform === 'win32' ? windowsSpawnSpec(command, args, env, resolveExecutable) : { command, args };
136
+ const child = spawnImpl(spawnSpec.command, spawnSpec.args, {
137
+ cwd,
138
+ env,
139
+ detached: platform !== 'win32',
140
+ ...(platform === 'win32' ? { windowsVerbatimArguments: false } : {}),
141
+ });
142
+ if (activeProcessPath) {
143
+ writeFileSync(activeProcessPath, `${JSON.stringify({
144
+ pid: child.pid,
145
+ ...(platform !== 'win32' ? { processGroupId: child.pid } : {}),
146
+ })}\n`);
147
+ }
15
148
  let stdout = '';
16
149
  let stderr = '';
17
150
  let timedOut = false;
18
151
 
152
+ const timeoutError = (cause) => {
153
+ const error = new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`, { cause });
154
+ error.code = 'ETIMEDOUT';
155
+ return decorate(error);
156
+ };
157
+
19
158
  const timer = timeoutMs
20
159
  ? setTimeout(() => {
21
160
  timedOut = true;
22
- child.kill('SIGKILL');
161
+ const killDirectChild = () => {
162
+ try {
163
+ if (child.kill('SIGKILL') === false) reject(timeoutError());
164
+ } catch (error) {
165
+ reject(timeoutError(error));
166
+ }
167
+ };
168
+ if (!signalProcessGroup(child.pid, 'SIGKILL', {
169
+ platform,
170
+ spawnImpl,
171
+ onFailure: killDirectChild,
172
+ })) killDirectChild();
23
173
  }, timeoutMs)
24
174
  : null;
25
175
 
@@ -36,6 +186,7 @@ export async function runCommand(command, args = [], options = {}) {
36
186
 
37
187
  const decorate = (error) => {
38
188
  if (timer) clearTimeout(timer);
189
+ clearActiveProcess(activeProcessPath);
39
190
  error.command = [command, ...args].join(' ');
40
191
  error.stdout = stdout;
41
192
  error.stderr = stderr;
@@ -47,10 +198,9 @@ export async function runCommand(command, args = [], options = {}) {
47
198
  child.on('error', (error) => reject(decorate(error)));
48
199
  child.on('close', (code, signal) => {
49
200
  if (timer) clearTimeout(timer);
201
+ clearActiveProcess(activeProcessPath);
50
202
  if (timedOut) {
51
- const error = decorate(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`));
52
- error.code = 'ETIMEDOUT';
53
- return reject(error);
203
+ return reject(timeoutError());
54
204
  }
55
205
  if (code === 0) return resolve({ stdout, stderr, code });
56
206
  const error = decorate(new Error(`Command failed: ${command} ${args.join(' ')}${stderr ? `\n${stderr}` : ''}`));