@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +471 -0
  11. package/src/cli/commands.js +1155 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +137 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { createRedactor } from '../utils/redaction.js';
6
+ import { SessionError } from '../utils/errors.js';
7
+
8
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
9
+
10
+ export function createSessionId(now = new Date()) {
11
+ const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
12
+ return `${stamp}-${crypto.randomBytes(3).toString('hex')}`;
13
+ }
14
+
15
+ export function createSessionRecord({ task, model, workspace, id } = {}) {
16
+ const now = new Date().toISOString();
17
+ return {
18
+ id: id || createSessionId(),
19
+ task: task || '',
20
+ model: model || '',
21
+ workspace: workspace || '',
22
+ status: 'active',
23
+ createdAt: now,
24
+ updatedAt: now,
25
+ steps: 0,
26
+ durationMs: null,
27
+ // Per-task model telemetry: how many calls, how long they took, how long
28
+ // the first token took, and how many reads the worklog made unnecessary.
29
+ modelCalls: 0,
30
+ requestMs: 0,
31
+ firstTokenMs: null,
32
+ cachedReads: 0,
33
+ metrics: {},
34
+ tokenUsage: { prompt: 0, completion: 0, total: 0 },
35
+ changedFiles: [],
36
+ commands: [],
37
+ issues: [],
38
+ plan: '',
39
+ transcript: [],
40
+ // Conversation memory handed to the next task (user/assistant turns only).
41
+ conversation: [],
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Session store under `.ryuu/sessions` (gitignored). Stored transcript entries
47
+ * are bounded previews only; full file contents and secrets are never persisted.
48
+ */
49
+ export class SessionStore {
50
+ constructor({ dir, secrets = [], logger = null }) {
51
+ this.dir = dir;
52
+ this.logger = logger;
53
+ this.redact = createRedactor(secrets);
54
+ }
55
+
56
+ #fileFor(id) {
57
+ if (!ID_PATTERN.test(id)) throw new SessionError(`Invalid session id: ${id}`, { id });
58
+ return path.join(this.dir, `${id}.json`);
59
+ }
60
+
61
+ async #ensureDir() {
62
+ await fsp.mkdir(this.dir, { recursive: true, mode: 0o700 });
63
+ }
64
+
65
+ /** Bound and redact a session before it touches disk. */
66
+ sanitize(session) {
67
+ const sanitized = {
68
+ ...session,
69
+ task: this.redact(String(session.task || '')).slice(0, 4000),
70
+ plan: this.redact(String(session.plan || '')).slice(0, 4000),
71
+ durationMs: Number.isFinite(session.durationMs) ? session.durationMs : null,
72
+ modelCalls: Number(session.modelCalls) || 0,
73
+ requestMs: Number(session.requestMs) || 0,
74
+ firstTokenMs: Number.isFinite(session.firstTokenMs) ? session.firstTokenMs : null,
75
+ cachedReads: Number(session.cachedReads) || 0,
76
+ metrics: session.metrics && typeof session.metrics === 'object' ? { ...session.metrics } : {},
77
+ worklog: this.#sanitizeWorklog(session.worklog),
78
+ changedFiles: (session.changedFiles || []).slice(0, 500),
79
+ commands: (session.commands || []).slice(0, 200).map((cmd) => ({
80
+ ...cmd,
81
+ argv: Array.isArray(cmd.argv) ? cmd.argv.map((part) => this.redact(String(part)).slice(0, 300)) : [],
82
+ output: this.redact(String(cmd.output || '')).slice(0, 2000),
83
+ })),
84
+ issues: (session.issues || []).slice(0, 100).map((issue) => this.redact(String(issue)).slice(0, 1000)),
85
+ conversation: (session.conversation || []).slice(-40).map((entry) => ({
86
+ role: entry.role === 'assistant' ? 'assistant' : 'user',
87
+ content: this.redact(String(entry.content || '')).slice(0, 4000),
88
+ })),
89
+ transcript: (session.transcript || []).slice(-200).map((entry) => ({
90
+ role: entry.role,
91
+ ...(entry.name ? { name: entry.name } : {}),
92
+ ...(entry.ok !== undefined ? { ok: entry.ok } : {}),
93
+ content: this.redact(String(entry.content || '')).slice(0, 4000),
94
+ ...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
95
+ ...(entry.at ? { at: entry.at } : {}),
96
+ })),
97
+ };
98
+ return sanitized;
99
+ }
100
+
101
+ /** Bounded, redacted worklog: enough to resume, small enough to store. */
102
+ #sanitizeWorklog(worklog) {
103
+ if (!worklog || typeof worklog !== 'object') return null;
104
+ return {
105
+ plan: this.redact(String(worklog.plan || '')).slice(0, 1500),
106
+ files: (worklog.files || []).slice(-40).map((entry) => ({
107
+ path: this.redact(String(entry?.path || '')).slice(0, 400),
108
+ read: Boolean(entry?.read),
109
+ changed: Boolean(entry?.changed),
110
+ ranges: Array.isArray(entry?.ranges) ? entry.ranges.slice(-4) : [],
111
+ mtimeMs: Number.isFinite(entry?.mtimeMs) ? entry.mtimeMs : null,
112
+ totalLines: Number.isFinite(entry?.totalLines) ? entry.totalLines : null,
113
+ lines: Number.isFinite(entry?.lines) ? entry.lines : null,
114
+ tool: entry?.tool ? String(entry.tool).slice(0, 40) : null,
115
+ })),
116
+ commands: (worklog.commands || []).slice(-12).map((command) => ({
117
+ argv: this.redact(String(command?.argv || '')).slice(0, 200),
118
+ exitCode: command?.exitCode ?? null,
119
+ durationMs: command?.durationMs ?? null,
120
+ status: command?.status ? String(command.status).slice(0, 40) : null,
121
+ })),
122
+ findings: (worklog.findings || []).slice(-12).map((finding) => this.redact(String(finding)).slice(0, 300)),
123
+ tasks: (worklog.tasks || []).slice(-6).map((entry) => ({
124
+ task: this.redact(String(entry?.task || '')).slice(0, 160),
125
+ status: String(entry?.status || '').slice(0, 40),
126
+ changedFiles: (entry?.changedFiles || []).slice(0, 8).map((file) => this.redact(String(file)).slice(0, 400)),
127
+ })),
128
+ };
129
+ }
130
+
131
+ async save(session) {
132
+ await this.#ensureDir();
133
+ const sanitized = this.sanitize({ ...session, updatedAt: new Date().toISOString() });
134
+ const file = this.#fileFor(sanitized.id);
135
+ const tmp = `${file}.${process.pid}.tmp`;
136
+ await fsp.writeFile(tmp, `${JSON.stringify(sanitized, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
137
+ await fsp.rename(tmp, file);
138
+ this.logger?.debug('session saved', { id: sanitized.id, file });
139
+ return sanitized;
140
+ }
141
+
142
+ async load(id) {
143
+ const file = this.#fileFor(id);
144
+ try {
145
+ const text = await fsp.readFile(file, 'utf8');
146
+ return JSON.parse(text);
147
+ } catch (error) {
148
+ if (error.code === 'ENOENT') throw new SessionError(`Session not found: ${id}`, { id });
149
+ if (error instanceof SyntaxError) throw new SessionError(`Session file is corrupt: ${id}`, { id });
150
+ throw new SessionError(`Cannot load session ${id}: ${error.message}`, { id });
151
+ }
152
+ }
153
+
154
+ async list({ limit = 50 } = {}) {
155
+ if (!fs.existsSync(this.dir)) return [];
156
+ const entries = await fsp.readdir(this.dir);
157
+ const sessions = [];
158
+ for (const entry of entries) {
159
+ if (!entry.endsWith('.json')) continue;
160
+ const id = entry.slice(0, -5);
161
+ if (!ID_PATTERN.test(id)) continue;
162
+ try {
163
+ const data = JSON.parse(await fsp.readFile(path.join(this.dir, entry), 'utf8'));
164
+ sessions.push({
165
+ id: data.id || id,
166
+ task: (data.task || '').slice(0, 120),
167
+ status: data.status,
168
+ model: data.model,
169
+ createdAt: data.createdAt,
170
+ updatedAt: data.updatedAt,
171
+ steps: data.steps || 0,
172
+ durationMs: Number.isFinite(data.durationMs) ? data.durationMs : null,
173
+ tokenUsage: data.tokenUsage || null,
174
+ changedFiles: (data.changedFiles || []).length,
175
+ });
176
+ } catch {
177
+ sessions.push({ id, task: '(unreadable session file)', status: 'corrupt', createdAt: null, updatedAt: null });
178
+ }
179
+ }
180
+ sessions.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
181
+ return sessions.slice(0, limit);
182
+ }
183
+
184
+ async delete(id) {
185
+ const file = this.#fileFor(id);
186
+ try {
187
+ await fsp.unlink(file);
188
+ return true;
189
+ } catch (error) {
190
+ if (error.code === 'ENOENT') return false;
191
+ throw new SessionError(`Cannot delete session ${id}: ${error.message}`, { id });
192
+ }
193
+ }
194
+
195
+ async clear() {
196
+ if (!fs.existsSync(this.dir)) return 0;
197
+ const entries = await fsp.readdir(this.dir);
198
+ let removed = 0;
199
+ for (const entry of entries) {
200
+ if (!entry.endsWith('.json')) continue;
201
+ await fsp.rm(path.join(this.dir, entry), { force: true });
202
+ removed += 1;
203
+ }
204
+ return removed;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Project summary cache. The fingerprint covers manifests and directory shape,
210
+ * so any relevant change invalidates the cached summary automatically.
211
+ */
212
+ export class ProjectSummaryCache {
213
+ constructor({ file, ttlMs = 0 }) {
214
+ this.file = file;
215
+ this.ttlMs = ttlMs;
216
+ }
217
+
218
+ fingerprint(workspace) {
219
+ const hash = crypto.createHash('sha256');
220
+ const manifests = ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
221
+ for (const name of manifests) {
222
+ const file = path.join(workspace, name);
223
+ try {
224
+ const stat = fs.statSync(file);
225
+ hash.update(`${name}:${stat.size}:${stat.mtimeMs}`);
226
+ } catch {
227
+ hash.update(`${name}:-`);
228
+ }
229
+ }
230
+ try {
231
+ const top = fs.readdirSync(workspace, { withFileTypes: true })
232
+ .filter((entry) => !entry.name.startsWith('.'))
233
+ .map((entry) => `${entry.isDirectory() ? 'd' : 'f'}:${entry.name}`)
234
+ .sort();
235
+ hash.update(top.join('|'));
236
+ } catch {
237
+ hash.update('unreadable');
238
+ }
239
+ return hash.digest('hex').slice(0, 32);
240
+ }
241
+
242
+ read() {
243
+ try {
244
+ return JSON.parse(fs.readFileSync(this.file, 'utf8'));
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ get(workspace) {
251
+ const cached = this.read();
252
+ if (!cached) return null;
253
+ const fingerprint = this.fingerprint(workspace);
254
+ if (cached.fingerprint !== fingerprint) return null;
255
+ if (this.ttlMs > 0 && Date.now() - new Date(cached.updatedAt).getTime() > this.ttlMs) return null;
256
+ return cached;
257
+ }
258
+
259
+ set(workspace, summary) {
260
+ const payload = {
261
+ fingerprint: this.fingerprint(workspace),
262
+ summary,
263
+ updatedAt: new Date().toISOString(),
264
+ };
265
+ try {
266
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
267
+ const tmp = `${this.file}.${process.pid}.tmp`;
268
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
269
+ fs.renameSync(tmp, this.file);
270
+ } catch {
271
+ // Cache failures are never fatal.
272
+ }
273
+ return payload;
274
+ }
275
+
276
+ invalidate() {
277
+ fs.rmSync(this.file, { force: true });
278
+ }
279
+ }
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { FileChangedError, ToolError } from '../utils/errors.js';
5
+
6
+ /**
7
+ * Undo records exact before/after snapshots for file operations performed by
8
+ * this agent session. /undo only reverts when the file still matches the
9
+ * recorded after-state; it refuses otherwise instead of clobbering user edits.
10
+ *
11
+ * Snapshots live under .ryuu/undo (gitignored) and never include secret files,
12
+ * which the workspace tool layer blocks before an undo record can be created.
13
+ */
14
+ export class UndoManager {
15
+ constructor({ workspace, dir = null, logger = null, maxEntries = 40 } = {}) {
16
+ this.workspace = workspace;
17
+ this.dir = dir;
18
+ this.logger = logger;
19
+ this.maxEntries = maxEntries;
20
+ this.entries = [];
21
+ this.sessionId = null;
22
+ }
23
+
24
+ reset(sessionId) {
25
+ this.sessionId = sessionId;
26
+ this.entries = [];
27
+ }
28
+
29
+ record(entry) {
30
+ this.entries.push({ ...entry, at: new Date().toISOString() });
31
+ if (this.entries.length > this.maxEntries) this.entries.shift();
32
+ this.#persist();
33
+ }
34
+
35
+ list() {
36
+ return this.entries.map((entry, index) => ({
37
+ index,
38
+ kind: entry.kind,
39
+ path: entry.path,
40
+ to: entry.to,
41
+ at: entry.at,
42
+ }));
43
+ }
44
+
45
+ get size() {
46
+ return this.entries.length;
47
+ }
48
+
49
+ last() {
50
+ return this.entries.at(-1) || null;
51
+ }
52
+
53
+ async revertLast() {
54
+ const entry = this.last();
55
+ if (!entry) throw new ToolError('Nothing to undo in this session.');
56
+ await this.revert(entry);
57
+ this.entries.pop();
58
+ this.#persist();
59
+ return entry;
60
+ }
61
+
62
+ /** Revert a recorded change after checking the file has not changed again. */
63
+ async revert(entry) {
64
+ switch (entry.kind) {
65
+ case 'write': {
66
+ const current = await this.#readCurrent(entry.path);
67
+ if (current !== entry.after) {
68
+ throw new FileChangedError(`${entry.path} changed after the agent edit; refusing to overwrite. Inspect it or use git.`);
69
+ }
70
+ if (entry.before === null) {
71
+ await this.workspace.remove(entry.path);
72
+ } else {
73
+ await this.workspace.writeText(entry.path, entry.before);
74
+ }
75
+ break;
76
+ }
77
+ case 'delete': {
78
+ if (await this.workspace.exists(entry.path)) {
79
+ throw new FileChangedError(`${entry.path} exists again; refusing to overwrite it with the deleted snapshot.`);
80
+ }
81
+ await this.workspace.writeText(entry.path, entry.before);
82
+ break;
83
+ }
84
+ case 'move': {
85
+ if (!(await this.workspace.exists(entry.to))) {
86
+ throw new FileChangedError(`Moved file is missing: ${entry.to}. Cannot move it back.`);
87
+ }
88
+ await this.workspace.move(entry.to, entry.path, { overwrite: false });
89
+ break;
90
+ }
91
+ default:
92
+ throw new ToolError(`Unknown undo entry kind: ${entry.kind}`);
93
+ }
94
+ this.logger?.info('undo applied', { kind: entry.kind, path: entry.path });
95
+ return entry;
96
+ }
97
+
98
+ async #readCurrent(relPath) {
99
+ try {
100
+ const file = await this.workspace.readText(relPath);
101
+ return file.text;
102
+ } catch (error) {
103
+ if (error.code === 'NOT_FOUND') return null;
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ #persist() {
109
+ if (!this.dir || !this.sessionId) return;
110
+ try {
111
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
112
+ const tmp = path.join(this.dir, `${this.sessionId}.tmp`);
113
+ fs.writeFileSync(tmp, `${JSON.stringify(this.entries, null, 2)}\n`, { mode: 0o600 });
114
+ fs.renameSync(tmp, path.join(this.dir, `${this.sessionId}.json`));
115
+ } catch (error) {
116
+ this.logger?.warn('failed to persist undo stack', { error: error.message });
117
+ }
118
+ }
119
+
120
+ async load(sessionId) {
121
+ if (!this.dir) return 0;
122
+ this.sessionId = sessionId;
123
+ try {
124
+ const raw = await fsp.readFile(path.join(this.dir, `${sessionId}.json`), 'utf8');
125
+ const parsed = JSON.parse(raw);
126
+ this.entries = Array.isArray(parsed) ? parsed : [];
127
+ return this.entries.length;
128
+ } catch {
129
+ this.entries = [];
130
+ return 0;
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Shell commands the user runs themselves (`/bash …` or `!…`).
5
+ *
6
+ * These are not model tool calls: the user typed the command, so it runs with
7
+ * their own permissions and without an approval prompt. The environment is
8
+ * still stripped of credential-looking variables, output is captured with a
9
+ * bound, and the child is killed on abort so Ctrl+C cannot leave it behind.
10
+ */
11
+
12
+ const SECRET_ENV = /(api[_-]?key|token|secret|password|passwd|credential|private[_-]?key)/i;
13
+
14
+ /** Copy of `env` without credential-looking variables. */
15
+ export function sanitizedEnv(env = process.env) {
16
+ const clean = {};
17
+ for (const [key, value] of Object.entries(env)) {
18
+ if (SECRET_ENV.test(key)) continue;
19
+ if (value === undefined) continue;
20
+ clean[key] = value;
21
+ }
22
+ return clean;
23
+ }
24
+
25
+ /** Keep the tail of an over-long capture, which is where failures are. */
26
+ function appendBounded(current, text, maxBytes) {
27
+ const next = current + text;
28
+ if (next.length <= maxBytes) return { value: next, truncated: false };
29
+ return { value: next.slice(next.length - maxBytes), truncated: true };
30
+ }
31
+
32
+ /**
33
+ * Run one shell command to completion.
34
+ *
35
+ * @returns {Promise<{command:string, cwd:string, stdout:string, stderr:string,
36
+ * exitCode:number|null, signal:string|null, durationMs:number,
37
+ * truncated:boolean, timedOut:boolean, error:string|null}>}
38
+ */
39
+ export function runUserShell({
40
+ command,
41
+ cwd = process.cwd(),
42
+ env = process.env,
43
+ maxBytes = 200_000,
44
+ timeoutMs = 0,
45
+ signal = null,
46
+ } = {}) {
47
+ const startedAt = Date.now();
48
+ return new Promise((resolve) => {
49
+ let stdout = '';
50
+ let stderr = '';
51
+ let truncated = false;
52
+ let timedOut = false;
53
+ let settled = false;
54
+ let killTimer = null;
55
+ let timeoutTimer = null;
56
+ let child = null;
57
+
58
+ /**
59
+ * Kill the whole process group, not just the shell wrapper.
60
+ *
61
+ * `shell: true` starts `/bin/sh -c <command>`; for a compound command the
62
+ * shell forks the real process instead of exec'ing it, so signalling only
63
+ * the shell leaves the child running and its pipes open — Ctrl+C would look
64
+ * ignored until the command finished on its own.
65
+ */
66
+ const killTree = (signalName) => {
67
+ if (!child?.pid) return;
68
+ try {
69
+ process.kill(-child.pid, signalName);
70
+ } catch {
71
+ try {
72
+ child.kill(signalName);
73
+ } catch {
74
+ // already gone
75
+ }
76
+ }
77
+ };
78
+
79
+ const finish = (exitCode, signalName, error = null) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ if (killTimer) clearTimeout(killTimer);
83
+ if (timeoutTimer) clearTimeout(timeoutTimer);
84
+ signal?.removeEventListener('abort', onAbort);
85
+ resolve({
86
+ command,
87
+ cwd,
88
+ stdout,
89
+ stderr,
90
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
91
+ signal: signalName || null,
92
+ durationMs: Date.now() - startedAt,
93
+ truncated,
94
+ timedOut,
95
+ error: error ? String(error.message || error) : null,
96
+ });
97
+ };
98
+
99
+ const onAbort = () => {
100
+ killTree('SIGTERM');
101
+ killTimer = setTimeout(() => killTree('SIGKILL'), 2000);
102
+ killTimer.unref?.();
103
+ };
104
+
105
+ try {
106
+ child = spawn(String(command), {
107
+ shell: true,
108
+ cwd,
109
+ env: sanitizedEnv(env),
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ detached: true,
112
+ });
113
+ } catch (error) {
114
+ finish(null, null, error);
115
+ return;
116
+ }
117
+
118
+ if (signal) {
119
+ if (signal.aborted) onAbort();
120
+ else signal.addEventListener('abort', onAbort, { once: true });
121
+ }
122
+
123
+ if (Number(timeoutMs) > 0) {
124
+ timeoutTimer = setTimeout(() => {
125
+ timedOut = true;
126
+ killTree('SIGTERM');
127
+ killTimer = setTimeout(() => killTree('SIGKILL'), 2000);
128
+ killTimer.unref?.();
129
+ }, Number(timeoutMs));
130
+ timeoutTimer.unref?.();
131
+ }
132
+
133
+ child.stdout?.on('data', (chunk) => {
134
+ const next = appendBounded(stdout, chunk.toString(), maxBytes);
135
+ stdout = next.value;
136
+ truncated = truncated || next.truncated;
137
+ });
138
+ child.stderr?.on('data', (chunk) => {
139
+ const next = appendBounded(stderr, chunk.toString(), maxBytes);
140
+ stderr = next.value;
141
+ truncated = truncated || next.truncated;
142
+ });
143
+ child.on('error', (error) => finish(null, null, error));
144
+ child.on('close', (code, signalName) => finish(code, signalName));
145
+ });
146
+ }