@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -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 +463 -0
  11. package/src/cli/commands.js +1150 -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 +132 -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 +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,249 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ DEFAULT_PROVIDER_ID,
5
+ MODEL_ID_PATTERN,
6
+ MAX_MODEL_ID_LENGTH,
7
+ normalizeModelId,
8
+ normalizeProviderId,
9
+ } from '../providers/catalog.js';
10
+
11
+ /**
12
+ * User preferences that survive CLI restarts: the active provider, the active
13
+ * model, and the model ids added with `/addmodel`.
14
+ *
15
+ * The state lives in the workspace's `.ryuu` folder next to sessions, logs,
16
+ * undo and cache, so the choice follows the project the CLI is run from.
17
+ * Values are validated before being read back, so a hand-edited or corrupt
18
+ * file can never put an invalid provider/model id into the config.
19
+ *
20
+ * Shape (all keys optional):
21
+ * {
22
+ * "provider": "tokenharbor",
23
+ * "model": "deepseek-v4.1-flash:free",
24
+ * "providerModel": { "nvidia": "…", "tokenharbor": "…" },
25
+ * "providerModels": { "nvidia": ["added-id"] },
26
+ * "models": ["legacy ids, read as the active provider's list"]
27
+ * }
28
+ */
29
+
30
+ /** Upper bound on user-added models, so the list cannot grow without limit. */
31
+ export const MAX_CUSTOM_MODELS = 100;
32
+ export { MAX_MODEL_ID_LENGTH, MODEL_ID_PATTERN, normalizeModelId };
33
+
34
+ /** Root of the local state folder for a workspace. */
35
+ export function stateDirFor(workspace) {
36
+ return path.join(workspace || '.', '.ryuu');
37
+ }
38
+
39
+ export function preferencesFile(dir) {
40
+ return path.join(dir, 'preferences.json');
41
+ }
42
+
43
+ /** Read the whole preferences object; {} when absent or corrupt. Never throws. */
44
+ export async function readPreferences(dir) {
45
+ try {
46
+ const data = JSON.parse(await fsp.readFile(preferencesFile(dir), 'utf8'));
47
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Merge a patch into preferences.json. Other keys are preserved, so adding or
55
+ * removing a model never forgets the selected model, and switching a model
56
+ * never forgets the added ones.
57
+ */
58
+ async function writePreferences(dir, patch) {
59
+ try {
60
+ const payload = {
61
+ ...(await readPreferences(dir)),
62
+ ...patch,
63
+ updatedAt: new Date().toISOString(),
64
+ };
65
+ await fsp.mkdir(dir, { recursive: true });
66
+ await fsp.writeFile(preferencesFile(dir), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function normalizeModelList(raw) {
74
+ if (!Array.isArray(raw)) return [];
75
+ const models = [];
76
+ for (const entry of raw) {
77
+ const model = normalizeModelId(entry);
78
+ if (model && !models.includes(model)) models.push(model);
79
+ if (models.length >= MAX_CUSTOM_MODELS) break;
80
+ }
81
+ return models;
82
+ }
83
+
84
+ /** A validated `provider -> [model ids]` map (unknown entries skipped). */
85
+ function modelListMap(raw) {
86
+ const map = {};
87
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return map;
88
+ for (const [id, list] of Object.entries(raw)) {
89
+ const providerId = normalizeProviderId(id);
90
+ if (providerId) map[providerId] = normalizeModelList(list);
91
+ }
92
+ return map;
93
+ }
94
+
95
+ /** A validated `provider -> model id` map. */
96
+ function modelChoiceMap(raw) {
97
+ const map = {};
98
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return map;
99
+ for (const [id, model] of Object.entries(raw)) {
100
+ const providerId = normalizeProviderId(id);
101
+ const normalized = normalizeModelId(model);
102
+ if (providerId && normalized) map[providerId] = normalized;
103
+ }
104
+ return map;
105
+ }
106
+
107
+ /** Read the persisted model, or null when absent/invalid. Never throws. */
108
+ export async function readModelPreference(dir) {
109
+ return normalizeModelId((await readPreferences(dir)).model);
110
+ }
111
+
112
+ /**
113
+ * Persist the model choice. With a provider id the choice is also recorded for
114
+ * that provider, so switching providers remembers each one's last model.
115
+ * Returns false (no throw) for invalid ids.
116
+ */
117
+ export async function writeModelPreference(dir, model, { providerId = null } = {}) {
118
+ const id = normalizeModelId(model);
119
+ if (!id) return false;
120
+ const provider = normalizeProviderId(providerId);
121
+ if (!provider) return writePreferences(dir, { model: id });
122
+ const prefs = await readPreferences(dir);
123
+ return writePreferences(dir, {
124
+ model: id,
125
+ providerModel: { ...modelChoiceMap(prefs.providerModel), [provider]: id },
126
+ });
127
+ }
128
+
129
+ /** Last model chosen for a provider (falls back to the legacy top-level value). */
130
+ export async function readProviderModel(dir, providerId) {
131
+ const provider = normalizeProviderId(providerId);
132
+ if (!provider) return null;
133
+ const prefs = await readPreferences(dir);
134
+ const choices = modelChoiceMap(prefs.providerModel);
135
+ if (choices[provider]) return choices[provider];
136
+ const hasChoices = Object.keys(choices).length > 0;
137
+ // Backwards compatibility: files written before per-provider models existed
138
+ // stored one top-level `model` for the then-active provider.
139
+ if (!hasChoices) {
140
+ const active = normalizeProviderId(prefs.provider) || DEFAULT_PROVIDER_ID;
141
+ if (provider === active) return normalizeModelId(prefs.model);
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Persist a model choice for one provider without changing the active model. */
147
+ export async function writeProviderModel(dir, providerId, model) {
148
+ const provider = normalizeProviderId(providerId);
149
+ const id = normalizeModelId(model);
150
+ if (!provider || !id) return false;
151
+ const prefs = await readPreferences(dir);
152
+ return writePreferences(dir, {
153
+ providerModel: { ...modelChoiceMap(prefs.providerModel), [provider]: id },
154
+ });
155
+ }
156
+
157
+ /** Read the persisted provider id, or null when absent/invalid. Never throws. */
158
+ export async function readProviderPreference(dir) {
159
+ return normalizeProviderId((await readPreferences(dir)).provider);
160
+ }
161
+
162
+ /** Persist the active provider id. Returns false (no throw) for invalid ids. */
163
+ export async function writeProviderPreference(dir, providerId) {
164
+ const id = normalizeProviderId(providerId);
165
+ if (!id) return false;
166
+ return writePreferences(dir, { provider: id });
167
+ }
168
+
169
+ /**
170
+ * The ids added with `/addmodel` for one provider, in insertion order,
171
+ * de-duplicated and validated. A corrupt entry is skipped instead of failing
172
+ * the whole list. Without a provider id the legacy shared list is returned.
173
+ */
174
+ export async function readCustomModels(dir, providerId = null) {
175
+ const prefs = await readPreferences(dir);
176
+ const legacy = normalizeModelList(prefs.models);
177
+ const provider = normalizeProviderId(providerId);
178
+ if (!provider) return legacy;
179
+ const map = modelListMap(prefs.providerModels);
180
+ if (Array.isArray(map[provider])) return map[provider];
181
+ const active = normalizeProviderId(prefs.provider) || DEFAULT_PROVIDER_ID;
182
+ return provider === active ? legacy : [];
183
+ }
184
+
185
+ async function writeCustomModels(dir, providerId, models) {
186
+ const provider = normalizeProviderId(providerId);
187
+ if (!provider) return writePreferences(dir, { models });
188
+ const prefs = await readPreferences(dir);
189
+ return writePreferences(dir, {
190
+ providerModels: { ...modelListMap(prefs.providerModels), [provider]: models },
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Add an id to a provider's saved model list. Returns { ok, reason, models };
196
+ * reason is 'invalid' | 'exists' | 'limit' | 'write' when ok is false.
197
+ */
198
+ export async function addCustomModel(dir, id, providerId = null) {
199
+ const models = await readCustomModels(dir, providerId);
200
+ const model = normalizeModelId(id);
201
+ if (!model) return { ok: false, reason: 'invalid', models };
202
+ if (models.includes(model)) return { ok: false, reason: 'exists', models };
203
+ if (models.length >= MAX_CUSTOM_MODELS) return { ok: false, reason: 'limit', models };
204
+ const next = [...models, model];
205
+ const saved = await writeCustomModels(dir, providerId, next);
206
+ return saved ? { ok: true, reason: null, models: next } : { ok: false, reason: 'write', models };
207
+ }
208
+
209
+ /**
210
+ * Remove an id from a provider's saved model list. Returns { ok, reason,
211
+ * models }; reason is 'missing' | 'write' when ok is false.
212
+ */
213
+ export async function removeCustomModel(dir, id, providerId = null) {
214
+ const models = await readCustomModels(dir, providerId);
215
+ const model = normalizeModelId(id);
216
+ if (!model || !models.includes(model)) return { ok: false, reason: 'missing', models };
217
+ const next = models.filter((entry) => entry !== model);
218
+ const saved = await writeCustomModels(dir, providerId, next);
219
+ return saved ? { ok: true, reason: null, models: next } : { ok: false, reason: 'write', models };
220
+ }
221
+
222
+ /**
223
+ * Apply the persisted `/provider` choice at startup. An explicit flag or a
224
+ * `provider` key in config.json wins and is never overwritten. Returns true when raw.provider
225
+ * was changed.
226
+ */
227
+ export async function applyPersistedProvider(raw, { flagProvider = null, stateDir = null } = {}) {
228
+ if (flagProvider || raw.provider || !stateDir) return false;
229
+ const persisted = await readProviderPreference(stateDir);
230
+ if (!persisted) return false;
231
+ raw.provider = persisted;
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * Apply the persisted model at startup.
237
+ * Precedence: explicit --model flag (this run only) > persisted choice for the
238
+ * selected provider > the provider's own model variable > catalog default.
239
+ * Returns true when raw.model was changed.
240
+ */
241
+ export async function applyPersistedModel(raw, { flagModel = null, stateDir = null, providerId = null } = {}) {
242
+ if (flagModel || !stateDir) return false;
243
+ const persisted = providerId
244
+ ? await readProviderModel(stateDir, providerId)
245
+ : await readModelPreference(stateDir);
246
+ if (!persisted || persisted === raw.model) return false;
247
+ raw.model = persisted;
248
+ return true;
249
+ }
@@ -0,0 +1,345 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { ProcessError } from '../utils/errors.js';
7
+ import { redact } from '../utils/redaction.js';
8
+
9
+ const SECRET_ENV_PATTERN = /(API_KEY|ACCESS_TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|GITHUB_TOKEN|GH_TOKEN|NVIDIA_API_KEY)/i;
10
+ const POSIX = process.platform !== 'win32';
11
+
12
+ /** Running child processes, used by the last-resort exit cleanup. */
13
+ const ACTIVE = new Set();
14
+ let exitHookInstalled = false;
15
+
16
+ function installExitHook() {
17
+ if (exitHookInstalled) return;
18
+ exitHookInstalled = true;
19
+ process.once('exit', () => {
20
+ for (const record of ACTIVE) {
21
+ try {
22
+ if (POSIX && record.pid) process.kill(-record.pid, 'SIGKILL');
23
+ else if (record.pid) process.kill(record.pid, 'SIGKILL');
24
+ } catch {
25
+ // Already gone.
26
+ }
27
+ }
28
+ });
29
+ }
30
+
31
+ /** Strip credential-looking variables from a child environment. */
32
+ export function buildChildEnv(base = process.env, { allow = [], extra = {} } = {}) {
33
+ const allowSet = new Set(allow);
34
+ const env = {};
35
+ for (const [key, value] of Object.entries(base)) {
36
+ if (value === undefined) continue;
37
+ if (SECRET_ENV_PATTERN.test(key) && !allowSet.has(key)) continue;
38
+ env[key] = value;
39
+ }
40
+ env.RYUU_AGENT = '1';
41
+ return { ...env, ...extra };
42
+ }
43
+
44
+ function appendBounded(state, chunk, limit) {
45
+ const combined = state.text + chunk;
46
+ if (combined.length <= limit) {
47
+ state.text = combined;
48
+ return;
49
+ }
50
+ state.truncated = true;
51
+ const head = Math.max(0, Math.floor(limit * 0.4));
52
+ const tail = Math.max(0, limit - head);
53
+ state.text = `${combined.slice(0, head)}\n…[output truncated]…\n${combined.slice(combined.length - tail)}`;
54
+ }
55
+
56
+ /**
57
+ * Session-scoped process manager. Commands use spawn with an argument array
58
+ * and shell:false by default; shell syntax requires an explicit, separately
59
+ * approved tool path. Shell execution is not a sandbox.
60
+ */
61
+ export class ProcessManager extends EventEmitter {
62
+ constructor({ workspace, logger = null, maxBufferBytes = 65536, defaultTimeoutMs = 120000, maxProcesses = 8, logDir = null } = {}) {
63
+ super();
64
+ if (!workspace) throw new ProcessError('ProcessManager requires a workspace.');
65
+ this.workspace = workspace;
66
+ this.logger = logger;
67
+ this.maxBufferBytes = maxBufferBytes;
68
+ this.defaultTimeoutMs = defaultTimeoutMs;
69
+ this.maxProcesses = maxProcesses;
70
+ this.logDir = logDir;
71
+ this.records = new Map();
72
+ this.completed = [];
73
+ this.counter = 0;
74
+ installExitHook();
75
+ }
76
+
77
+ #nextId() {
78
+ this.counter += 1;
79
+ return `p${this.counter}`;
80
+ }
81
+
82
+ #runningCount() {
83
+ return [...this.records.values()].filter((record) => record.status === 'running').length;
84
+ }
85
+
86
+ /**
87
+ * @param {object} options
88
+ * @param {string[]} [options.argv] argument array (preferred, shell:false)
89
+ * @param {string} [options.shellCommand] raw shell string (requires allowShell)
90
+ * @param {string} [options.cwd] workspace-relative working directory
91
+ * @param {number} [options.timeoutMs] 0 disables the timeout
92
+ * @param {string} [options.label]
93
+ */
94
+ start({ argv, shellCommand = null, cwd = '.', timeoutMs, label, env = {}, allowShell = false } = {}) {
95
+ if (this.#runningCount() >= this.maxProcesses) {
96
+ throw new ProcessError(`Too many running processes (limit ${this.maxProcesses}). Stop one with stop_process.`);
97
+ }
98
+
99
+ const useShell = Boolean(shellCommand);
100
+ if (useShell && !allowShell) {
101
+ throw new ProcessError('Shell execution is disabled. Set agent.allowShell=true in config.json to permit an approved shell command.');
102
+ }
103
+ let command;
104
+ let args;
105
+ if (useShell) {
106
+ command = shellCommand;
107
+ args = [];
108
+ } else {
109
+ if (!Array.isArray(argv) || argv.length === 0) {
110
+ throw new ProcessError('argv must be a non-empty array of strings, e.g. ["npm", "test"].');
111
+ }
112
+ if (argv.length > 200) throw new ProcessError('Too many arguments (max 200).');
113
+ for (const part of argv) {
114
+ if (typeof part !== 'string' || part.length === 0) throw new ProcessError('Every argv entry must be a non-empty string.');
115
+ if (part.includes('\0')) throw new ProcessError('Arguments must not contain NUL bytes.');
116
+ if (part.length > 20000) throw new ProcessError('Argument is too long (max 20000 chars).');
117
+ }
118
+ [command, ...args] = argv;
119
+ }
120
+
121
+ const resolvedCwd = this.workspace.resolve(cwd || '.');
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(resolvedCwd.absPath);
125
+ } catch {
126
+ throw new ProcessError(`Working directory not found: ${resolvedCwd.relPath}`);
127
+ }
128
+ if (!stat.isDirectory()) throw new ProcessError(`Working directory is not a directory: ${resolvedCwd.relPath}`);
129
+
130
+ const id = this.#nextId();
131
+ const startedAt = Date.now();
132
+ const record = {
133
+ id,
134
+ pid: null,
135
+ label: label || (useShell ? command : argv.join(' ')).slice(0, 120),
136
+ argv: useShell ? [command] : argv,
137
+ shellCommand: useShell ? command : null,
138
+ cwd: resolvedCwd.relPath,
139
+ status: 'running',
140
+ startedAt,
141
+ startedAtIso: new Date(startedAt).toISOString(),
142
+ endedAt: null,
143
+ durationMs: null,
144
+ exitCode: null,
145
+ signal: null,
146
+ timedOut: false,
147
+ stopRequested: false,
148
+ error: null,
149
+ truncated: false,
150
+ stdout: { text: '', truncated: false },
151
+ stderr: { text: '', truncated: false },
152
+ };
153
+ record.done = new Promise((resolveDone) => {
154
+ record.resolveDone = resolveDone;
155
+ });
156
+ this.records.set(id, record);
157
+
158
+ const child = spawn(command, args, {
159
+ cwd: resolvedCwd.absPath,
160
+ shell: useShell,
161
+ detached: POSIX,
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ env: buildChildEnv(process.env, { extra: env }),
164
+ windowsHide: true,
165
+ });
166
+ record.pid = child.pid ?? null;
167
+ record.child = child;
168
+ if (POSIX && child.pid) ACTIVE.add(record);
169
+
170
+ const outDecoder = new StringDecoder('utf8');
171
+ const errDecoder = new StringDecoder('utf8');
172
+ child.stdout?.on('data', (chunk) => {
173
+ const text = outDecoder.write(chunk);
174
+ appendBounded(record.stdout, text, this.maxBufferBytes);
175
+ record.truncated = record.truncated || record.stdout.truncated;
176
+ this.emit('output', { id, stream: 'stdout', text, record });
177
+ });
178
+ child.stderr?.on('data', (chunk) => {
179
+ const text = errDecoder.write(chunk);
180
+ appendBounded(record.stderr, text, this.maxBufferBytes);
181
+ record.truncated = record.truncated || record.stderr.truncated;
182
+ this.emit('output', { id, stream: 'stderr', text, record });
183
+ });
184
+
185
+ let timeoutTimer = null;
186
+ const effectiveTimeout = timeoutMs === undefined ? this.defaultTimeoutMs : timeoutMs;
187
+ if (effectiveTimeout > 0) {
188
+ timeoutTimer = setTimeout(() => {
189
+ if (record.status !== 'running') return;
190
+ record.timedOut = true;
191
+ this.logger?.warn('process timeout, terminating', { id, timeoutMs: effectiveTimeout });
192
+ this.#kill(record, 'SIGTERM');
193
+ const force = setTimeout(() => this.#kill(record, 'SIGKILL'), 2000);
194
+ force.unref();
195
+ }, effectiveTimeout);
196
+ timeoutTimer.unref?.();
197
+ }
198
+
199
+ const finalize = (exitCode, signal, spawnError = null) => {
200
+ if (record.status !== 'running') return;
201
+ if (timeoutTimer) clearTimeout(timeoutTimer);
202
+ if (outDecoder.end) appendBounded(record.stdout, outDecoder.end() || '', this.maxBufferBytes);
203
+ if (errDecoder.end) appendBounded(record.stderr, errDecoder.end() || '', this.maxBufferBytes);
204
+ const endedAt = Date.now();
205
+ record.endedAt = endedAt;
206
+ record.endedAtIso = new Date(endedAt).toISOString();
207
+ record.durationMs = endedAt - startedAt;
208
+ record.exitCode = exitCode;
209
+ record.signal = signal;
210
+ record.error = spawnError ? spawnError.message : null;
211
+ if (spawnError) record.status = 'failed';
212
+ else if (record.timedOut) record.status = 'timeout';
213
+ else if (record.stopRequested) record.status = 'killed';
214
+ else record.status = exitCode === 0 ? 'exited' : 'failed';
215
+ delete record.child;
216
+ ACTIVE.delete(record);
217
+ this.completed.unshift(record);
218
+ if (this.completed.length > 100) this.completed.pop();
219
+ this.emit('exit', { record });
220
+ this.#persist(record);
221
+ record.resolveDone(record);
222
+ };
223
+
224
+ child.on('error', (error) => {
225
+ finalize(null, null, error);
226
+ });
227
+ child.on('close', (code, signal) => {
228
+ finalize(code, signal);
229
+ });
230
+
231
+ this.emit('started', { record });
232
+ this.logger?.info('process started', { id, pid: record.pid, argv: record.argv, cwd: record.cwd, shell: useShell });
233
+ return record;
234
+ }
235
+
236
+ #kill(record, signal) {
237
+ if (!record.pid || record.status !== 'running') return;
238
+ try {
239
+ if (POSIX) process.kill(-record.pid, signal);
240
+ else process.kill(record.pid, signal);
241
+ } catch (error) {
242
+ if (error.code !== 'ESRCH') this.logger?.warn('failed to signal process', { id: record.id, signal, error: error.message });
243
+ }
244
+ }
245
+
246
+ /** Stop a process: graceful SIGTERM, then SIGKILL after graceMs. */
247
+ async stop(id, { graceMs = 3000, signal = 'SIGTERM' } = {}) {
248
+ const record = this.records.get(id);
249
+ if (!record) throw new ProcessError(`Unknown process id: ${id}`, { id });
250
+ if (record.status !== 'running') return { id, stopped: false, status: record.status, exitCode: record.exitCode };
251
+ record.stopRequested = true;
252
+ this.#kill(record, signal);
253
+ const killed = await Promise.race([
254
+ record.done.then(() => true),
255
+ new Promise((resolve) => setTimeout(() => resolve(false), graceMs)),
256
+ ]);
257
+ if (!killed) {
258
+ this.#kill(record, 'SIGKILL');
259
+ await Promise.race([record.done, new Promise((resolve) => setTimeout(resolve, 1000))]);
260
+ }
261
+ return { id, stopped: true, status: record.status, exitCode: record.exitCode, signal: record.signal, durationMs: record.durationMs };
262
+ }
263
+
264
+ async stopAll({ graceMs = 2000 } = {}) {
265
+ const running = [...this.records.values()].filter((record) => record.status === 'running');
266
+ await Promise.allSettled(running.map((record) => this.stop(record.id, { graceMs })));
267
+ return running.length;
268
+ }
269
+
270
+ get(id) {
271
+ const record = this.records.get(id) || this.completed.find((item) => item.id === id);
272
+ if (!record) throw new ProcessError(`Unknown process id: ${id}`, { id });
273
+ return record;
274
+ }
275
+
276
+ getOutput(id, { stream = 'both', tailBytes = 8000 } = {}) {
277
+ const record = this.get(id);
278
+ const tail = (state) => {
279
+ if (state.text.length <= tailBytes) return state.text;
280
+ return `…[showing last ${tailBytes} chars]…\n${state.text.slice(state.text.length - tailBytes)}`;
281
+ };
282
+ const result = { id: record.id, status: record.status, exitCode: record.exitCode, signal: record.signal, durationMs: record.durationMs };
283
+ if (stream === 'stdout' || stream === 'both') result.stdout = tail(record.stdout);
284
+ if (stream === 'stderr' || stream === 'both') result.stderr = tail(record.stderr);
285
+ result.truncated = record.stdout.truncated || record.stderr.truncated;
286
+ return result;
287
+ }
288
+
289
+ list({ includeCompleted = true } = {}) {
290
+ const running = [...this.records.values()].filter((record) => record.status === 'running');
291
+ const rows = running.map(summarize);
292
+ if (includeCompleted) rows.push(...this.completed.slice(0, 20).map(summarize));
293
+ return rows;
294
+ }
295
+
296
+ #persist(record) {
297
+ if (!this.logDir) return;
298
+ try {
299
+ fs.mkdirSync(this.logDir, { recursive: true, mode: 0o700 });
300
+ const payload = {
301
+ id: record.id,
302
+ label: record.label,
303
+ argv: record.argv,
304
+ cwd: record.cwd,
305
+ status: record.status,
306
+ exitCode: record.exitCode,
307
+ signal: record.signal,
308
+ startedAt: record.startedAtIso,
309
+ endedAt: record.endedAtIso,
310
+ durationMs: record.durationMs,
311
+ truncated: record.truncated,
312
+ stdout: redact(record.stdout.text),
313
+ stderr: redact(record.stderr.text),
314
+ };
315
+ fs.writeFileSync(path.join(this.logDir, `process-${record.id}.log`), `${JSON.stringify(payload, null, 2)}\n`, {
316
+ mode: 0o600,
317
+ });
318
+ } catch {
319
+ // Persistence failures must not break execution.
320
+ }
321
+ }
322
+
323
+ /** All records with metadata, used for final summaries. */
324
+ history() {
325
+ return [...this.completed].reverse().map(summarize);
326
+ }
327
+ }
328
+
329
+ function summarize(record) {
330
+ return {
331
+ id: record.id,
332
+ pid: record.pid,
333
+ label: record.label,
334
+ argv: record.argv,
335
+ cwd: record.cwd,
336
+ status: record.status,
337
+ exitCode: record.exitCode,
338
+ signal: record.signal,
339
+ startedAt: record.startedAtIso,
340
+ endedAt: record.endedAtIso,
341
+ durationMs: record.durationMs,
342
+ timedOut: record.timedOut,
343
+ truncated: record.truncated,
344
+ };
345
+ }