@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,118 @@
1
+ import { RyuuError, ToolError, ToolNotFoundError, ValidationError } from '../utils/errors.js';
2
+ import { validateSchema } from '../utils/validation.js';
3
+
4
+ export const RISK_LEVELS = ['read', 'write', 'exec', 'destructive'];
5
+ const NAME_PATTERN = /^[a-z][a-z0-9_]{1,60}$/;
6
+
7
+ /**
8
+ * Define a tool with validation. Every tool is: name, description, JSON schema,
9
+ * a risk level, an async handler, and optional preview/approval metadata.
10
+ */
11
+ export function defineTool(definition) {
12
+ if (!definition || typeof definition !== 'object') throw new ToolError('Tool definition must be an object.');
13
+ const { name, description, schema, risk = 'read', handler, preview = null, requiresApproval } = definition;
14
+ if (typeof name !== 'string' || !NAME_PATTERN.test(name)) {
15
+ throw new ToolError(`Invalid tool name: ${JSON.stringify(name)} (expected lowercase snake_case).`);
16
+ }
17
+ if (typeof description !== 'string' || description.trim().length < 10) {
18
+ throw new ToolError(`Tool ${name} needs a descriptive description.`);
19
+ }
20
+ if (!schema || schema.type !== 'object' || typeof schema.properties !== 'object') {
21
+ throw new ToolError(`Tool ${name} needs an object JSON schema with properties.`);
22
+ }
23
+ if (!RISK_LEVELS.includes(risk)) {
24
+ throw new ToolError(`Tool ${name} has invalid risk "${risk}" (allowed: ${RISK_LEVELS.join(', ')}).`);
25
+ }
26
+ if (typeof handler !== 'function') throw new ToolError(`Tool ${name} needs a handler function.`);
27
+ if (preview !== null && typeof preview !== 'function') throw new ToolError(`Tool ${name} preview must be a function.`);
28
+ return Object.freeze({
29
+ ...definition,
30
+ risk,
31
+ requiresApproval: requiresApproval ?? risk !== 'read',
32
+ });
33
+ }
34
+
35
+ export class ToolRegistry {
36
+ constructor({ logger = null } = {}) {
37
+ this.logger = logger;
38
+ this.tools = new Map();
39
+ this.plugins = new Map();
40
+ }
41
+
42
+ register(toolDefinition) {
43
+ const tool = defineTool(toolDefinition);
44
+ if (this.tools.has(tool.name)) {
45
+ throw new ToolError(`Tool name collision: ${tool.name} is already registered.`);
46
+ }
47
+ this.tools.set(tool.name, tool);
48
+ this.logger?.debug('tool registered', { name: tool.name, risk: tool.risk });
49
+ return tool;
50
+ }
51
+
52
+ /**
53
+ * Register a group of tools as a plugin. Collisions with built-ins fail loudly
54
+ * so a plugin can never silently shadow workspace tooling.
55
+ */
56
+ registerPlugin(plugin) {
57
+ if (!plugin || typeof plugin !== 'object') throw new ToolError('Plugin must be an object.');
58
+ const name = String(plugin.name || '');
59
+ if (!/^[a-z][a-z0-9_-]{1,40}$/.test(name)) throw new ToolError(`Invalid plugin name: ${JSON.stringify(plugin.name)}`);
60
+ if (this.plugins.has(name)) throw new ToolError(`Plugin already registered: ${name}`);
61
+ if (!Array.isArray(plugin.tools) || plugin.tools.length === 0) throw new ToolError(`Plugin ${name} must expose at least one tool.`);
62
+ const prepared = plugin.tools.map((tool) => defineTool(tool));
63
+ for (const tool of prepared) {
64
+ if (this.tools.has(tool.name)) {
65
+ throw new ToolError(`Plugin ${name} tool collision: ${tool.name} is already registered.`);
66
+ }
67
+ }
68
+ for (const tool of prepared) this.tools.set(tool.name, tool);
69
+ this.plugins.set(name, { name, version: plugin.version || '0.0.0', toolNames: prepared.map((t) => t.name) });
70
+ return { name, tools: prepared.map((t) => t.name) };
71
+ }
72
+
73
+ get(name) {
74
+ const tool = this.tools.get(name);
75
+ if (!tool) throw new ToolNotFoundError(name);
76
+ return tool;
77
+ }
78
+
79
+ has(name) {
80
+ return this.tools.has(name);
81
+ }
82
+
83
+ list() {
84
+ return [...this.tools.values()];
85
+ }
86
+
87
+ /** OpenAI-compatible tool schemas for chat.completions. */
88
+ toOpenAITools() {
89
+ return this.list().map((tool) => ({
90
+ type: 'function',
91
+ function: {
92
+ name: tool.name,
93
+ description: tool.description,
94
+ parameters: tool.schema,
95
+ },
96
+ }));
97
+ }
98
+
99
+ /** Validate arguments and run the handler. Unknown tools cannot execute. */
100
+ async execute(name, args, ctx = {}) {
101
+ const tool = this.get(name);
102
+ const errors = validateSchema(tool.schema, args ?? {});
103
+ if (errors.length > 0) {
104
+ throw new ValidationError(`Invalid arguments for ${name}: ${errors.join('; ')}`, { tool: name, errors });
105
+ }
106
+ const startedAt = Date.now();
107
+ try {
108
+ const result = await tool.handler(args ?? {}, ctx);
109
+ return { result, durationMs: Date.now() - startedAt };
110
+ } catch (error) {
111
+ if (error?.name === 'AbortError' || error?.code === 'CANCELLED') throw error;
112
+ // Preserve actionable semantics (path escape, secret block, approvals...).
113
+ if (error instanceof RyuuError) throw error;
114
+ throw new ToolError(`Tool ${name} failed: ${error.message}`, { tool: name, cause: error?.message });
115
+ }
116
+ }
117
+ }
118
+
@@ -0,0 +1,210 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { defineTool } from './registry.js';
4
+ import { isIgnoredName, isSecretPath } from '../services/workspace.js';
5
+ import { ToolError } from '../utils/errors.js';
6
+
7
+ const MAX_VISITED = 20000;
8
+ const MAX_FILES = 5000;
9
+ const MAX_LINE_PREVIEW = 240;
10
+
11
+ /**
12
+ * Build the search service. Bounded in every dimension so a large repository
13
+ * cannot hang the agent or flood the context window.
14
+ */
15
+ export function createSearchService({ workspace, config, logger = null }) {
16
+ return async function searchFiles({
17
+ query,
18
+ target = 'both',
19
+ path: startPath = '.',
20
+ regex = false,
21
+ caseSensitive = false,
22
+ maxResults,
23
+ includeGenerated = false,
24
+ }) {
25
+ if (typeof query !== 'string' || query.length === 0) throw new ToolError('query must be a non-empty string.');
26
+ const limit = Math.max(1, Math.min(maxResults ?? config.maxSearchResults, config.maxSearchResults));
27
+ const root = workspace.resolve(startPath || '.');
28
+ let rootStat;
29
+ try {
30
+ rootStat = await fsp.stat(root.absPath);
31
+ } catch {
32
+ throw new ToolError(`Search path not found: ${root.relPath}`);
33
+ }
34
+
35
+ let matcher;
36
+ if (regex) {
37
+ try {
38
+ matcher = new RegExp(query, `${caseSensitive ? '' : 'i'}g`);
39
+ } catch (error) {
40
+ throw new ToolError(`Invalid regular expression: ${error.message}`);
41
+ }
42
+ } else {
43
+ matcher = null;
44
+ }
45
+
46
+ const matches = [];
47
+ let visited = 0;
48
+ let filesScanned = 0;
49
+ let directories = 0;
50
+ let truncated = false;
51
+ const warnings = [];
52
+
53
+ const matchText = (text) => {
54
+ if (matcher) {
55
+ matcher.lastIndex = 0;
56
+ return matcher.test(text);
57
+ }
58
+ return caseSensitive ? text.includes(query) : text.toLowerCase().includes(query.toLowerCase());
59
+ };
60
+
61
+ const searchContent = async (absFile, relFile, size) => {
62
+ if (size > config.maxFileBytes) {
63
+ warnings.push(`Skipped large file (${size} bytes): ${relFile}`);
64
+ return;
65
+ }
66
+ let buffer;
67
+ try {
68
+ buffer = await fsp.readFile(absFile);
69
+ } catch (error) {
70
+ warnings.push(`Unreadable file: ${relFile} (${error.code || error.message})`);
71
+ return;
72
+ }
73
+ if (buffer.includes(0)) return;
74
+ const text = new TextDecoder('utf-8').decode(buffer);
75
+ if (text.includes('\uFFFD')) {
76
+ warnings.push(`Skipped non-UTF-8 file: ${relFile}`);
77
+ return;
78
+ }
79
+ filesScanned += 1;
80
+ const lines = text.split(/\r?\n/);
81
+ for (let index = 0; index < lines.length && matches.length < limit; index += 1) {
82
+ const line = lines[index];
83
+ if (!matchText(line)) continue;
84
+ let column = 1;
85
+ if (matcher) {
86
+ matcher.lastIndex = 0;
87
+ const found = matcher.exec(line);
88
+ column = (found?.index ?? 0) + 1;
89
+ } else {
90
+ const found = caseSensitive ? line.indexOf(query) : line.toLowerCase().indexOf(query.toLowerCase());
91
+ column = (found < 0 ? 0 : found) + 1;
92
+ }
93
+ const preview = line.length > MAX_LINE_PREVIEW ? `${line.slice(0, MAX_LINE_PREVIEW)}…` : line;
94
+ matches.push({ type: 'content', file: relFile, line: index + 1, column, text: preview });
95
+ }
96
+ };
97
+
98
+ const consider = async (absFile, relFile, size) => {
99
+ if (matches.length >= limit) {
100
+ truncated = true;
101
+ return;
102
+ }
103
+ if (target === 'name' || target === 'both') {
104
+ if (matchText(path.basename(relFile))) {
105
+ matches.push({ type: 'name', file: relFile, size });
106
+ }
107
+ }
108
+ if ((target === 'content' || target === 'both') && matches.length < limit) {
109
+ await searchContent(absFile, relFile, size);
110
+ }
111
+ };
112
+
113
+ if (rootStat.isFile()) {
114
+ const rel = root.relPath;
115
+ if (!isSecretPath(rel)) await consider(root.absPath, rel, rootStat.size);
116
+ } else if (rootStat.isDirectory()) {
117
+ const stack = [{ abs: root.absPath, rel: root.relPath }];
118
+ outer: while (stack.length > 0) {
119
+ const dir = stack.pop();
120
+ let entries;
121
+ try {
122
+ entries = await fsp.readdir(dir.abs, { withFileTypes: true });
123
+ } catch (error) {
124
+ warnings.push(`Unreadable directory: ${dir.rel} (${error.code || error.message})`);
125
+ continue;
126
+ }
127
+ directories += 1;
128
+ for (const entry of entries) {
129
+ visited += 1;
130
+ if (visited > MAX_VISITED) {
131
+ truncated = true;
132
+ break outer;
133
+ }
134
+ const rel = dir.rel === '.' ? entry.name : `${dir.rel}/${entry.name}`;
135
+ if (isSecretPath(rel)) continue;
136
+ if (!includeGenerated && isIgnoredName(entry.name)) continue;
137
+ if (entry.name.startsWith('.ryuu-tmp-')) continue;
138
+ if (entry.isSymbolicLink()) continue;
139
+ if (entry.isDirectory()) {
140
+ stack.push({ abs: path.join(dir.abs, entry.name), rel });
141
+ continue;
142
+ }
143
+ if (!entry.isFile()) continue;
144
+ if (filesScanned >= MAX_FILES) {
145
+ truncated = true;
146
+ break outer;
147
+ }
148
+ let stat;
149
+ try {
150
+ stat = await fsp.stat(path.join(dir.abs, entry.name));
151
+ } catch {
152
+ continue;
153
+ }
154
+ await consider(path.join(dir.abs, entry.name), rel, stat.size);
155
+ if (matches.length >= limit) {
156
+ truncated = true;
157
+ break outer;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ logger?.debug('search complete', { query: query.slice(0, 80), matches: matches.length, visited });
164
+ return {
165
+ query: regex ? `/${query}/` : query,
166
+ target,
167
+ root: root.relPath,
168
+ matches,
169
+ count: matches.length,
170
+ truncated,
171
+ limits: { maxResults: limit, visited, maxVisited: MAX_VISITED, filesScanned, maxFiles: MAX_FILES, directories },
172
+ warnings: warnings.slice(0, 10),
173
+ };
174
+ };
175
+ }
176
+
177
+ export function createSearchTools({ workspace, config, logger }) {
178
+ const searchFiles = createSearchService({ workspace, config, logger });
179
+ return [
180
+ defineTool({
181
+ name: 'search_files',
182
+ description:
183
+ 'Search file names and/or text contents in the workspace. Skips secret files, binaries, generated directories and oversized files. Results are bounded; refine the path or query when truncated.',
184
+ risk: 'read',
185
+ schema: {
186
+ type: 'object',
187
+ properties: {
188
+ query: { type: 'string', minLength: 1, maxLength: 500, description: 'Literal text or regular expression.' },
189
+ target: { type: 'string', enum: ['content', 'name', 'both'], description: 'What to search (default both).' },
190
+ path: { type: 'string', minLength: 1, maxLength: 4096, description: 'Directory to search (default workspace root).' },
191
+ regex: { type: 'boolean', description: 'Interpret query as a regular expression.' },
192
+ case_sensitive: { type: 'boolean' },
193
+ max_results: { type: 'integer', minimum: 1, maximum: 500 },
194
+ include_generated: { type: 'boolean', description: 'Include node_modules/.git/dist (default false).' },
195
+ },
196
+ required: ['query'],
197
+ additionalProperties: false,
198
+ },
199
+ handler: async (args) => searchFiles({
200
+ query: args.query,
201
+ target: args.target ?? 'both',
202
+ path: args.path ?? '.',
203
+ regex: args.regex ?? false,
204
+ caseSensitive: args.case_sensitive ?? false,
205
+ maxResults: args.max_results,
206
+ includeGenerated: args.include_generated ?? false,
207
+ }),
208
+ }),
209
+ ];
210
+ }
@@ -0,0 +1,279 @@
1
+ import { defineTool } from './registry.js';
2
+ import { ToolError } from '../utils/errors.js';
3
+
4
+ /** Read-only binaries that are reasonable to run during debugging. */
5
+ const SAFE_BINARIES = new Set(['git', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'python', 'python3', 'pytest', 'go', 'cargo', 'rustc', 'tsc', 'eslint', 'vitest', 'jest', 'mocha', 'ls', 'cat', 'grep', 'rg', 'find', 'head', 'tail', 'wc', 'diff']);
6
+
7
+ const CRITICAL_BINARIES = new Set(['sudo', 'su', 'doas', 'pkexec', 'mkfs', 'mkfs.ext4', 'dd', 'shutdown', 'reboot', 'halt', 'poweroff', 'fdisk', 'parted']);
8
+
9
+ /**
10
+ * Static, best-effort command assessment. This is a UX signal for the approval
11
+ * prompt, not a security boundary: any approval-gated command can still be
12
+ * dangerous, and shell execution is explicitly not a sandbox.
13
+ */
14
+ export function assessCommand(argv) {
15
+ const reasons = [];
16
+ let level = 'normal';
17
+ const command = argv[0] || '';
18
+ const rest = argv.slice(1);
19
+ const base = command.split('/').pop();
20
+
21
+ if (CRITICAL_BINARIES.has(base)) {
22
+ level = 'critical';
23
+ reasons.push(`privileged/destructive binary: ${base}`);
24
+ }
25
+ if (/^(rm|unlink|rmdir)$/.test(base)) {
26
+ const recursive = rest.some((arg) => /^-[a-zA-Z]*[rR]/.test(arg));
27
+ const targets = rest.filter((arg) => !arg.startsWith('-'));
28
+ if (recursive && targets.some((target) => target === '/' || target === '~' || target === '..' || /^\/(\*|$)/.test(target))) {
29
+ level = 'critical';
30
+ reasons.push('recursive delete of a root/home/parent path');
31
+ } else {
32
+ if (level === 'normal') level = 'high';
33
+ reasons.push('file deletion');
34
+ }
35
+ }
36
+ if (base === 'git' && rest.some((arg) => ['push', 'reset', 'clean', 'checkout'].includes(arg))) {
37
+ const force = rest.some((arg) => ['--force', '-f', '--hard', '-fd', '-df'].includes(arg));
38
+ if (force) {
39
+ level = 'critical';
40
+ reasons.push('destructive git operation with force/hard flags');
41
+ } else {
42
+ if (level === 'normal') level = 'high';
43
+ reasons.push('git operation that can modify history or working tree');
44
+ }
45
+ }
46
+ if (base === 'npm' && ['publish', 'unpublish'].includes(rest[0])) {
47
+ level = 'critical';
48
+ reasons.push('publishes packages to a registry');
49
+ }
50
+ if (['npx', 'bunx'].includes(base) || (base === 'npm' && rest[0] === 'exec')) {
51
+ if (level === 'normal') level = 'high';
52
+ reasons.push('downloads and executes a remote package');
53
+ }
54
+ if ((base === 'npm' || base === 'pnpm' || base === 'yarn') && ['install', 'add', 'i'].includes(rest[0])) {
55
+ if (level === 'normal') level = 'high';
56
+ reasons.push('dependency installation (network + scripts)');
57
+ }
58
+ if (['chmod', 'chown', 'chgrp'].includes(base)) {
59
+ if (level === 'normal') level = 'high';
60
+ reasons.push('permission/ownership change');
61
+ }
62
+ if (base === 'kill' || base === 'pkill' || base === 'killall') {
63
+ if (level === 'normal') level = 'high';
64
+ reasons.push('signals other processes');
65
+ }
66
+ if (base.startsWith('curl') || base === 'wget') {
67
+ if (level === 'normal') level = 'high';
68
+ reasons.push('network download');
69
+ }
70
+ if (!SAFE_BINARIES.has(base) && level === 'normal') {
71
+ reasons.push('non-standard binary (review carefully)');
72
+ }
73
+ return { level, reasons };
74
+ }
75
+
76
+ export function assessShellString(command) {
77
+ const reasons = [];
78
+ let level = 'high';
79
+ const text = String(command || '');
80
+ if (/\bsudo\b|\bsu\s/.test(text)) {
81
+ level = 'critical';
82
+ reasons.push('privilege escalation');
83
+ }
84
+ if (/\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*\s+)?\/(\s|$|\*)/.test(text) || /\brm\s+-rf\s+~/.test(text)) {
85
+ level = 'critical';
86
+ reasons.push('recursive delete at filesystem root or home');
87
+ }
88
+ if (/\|\s*(ba)?sh\b|\|\s*python/.test(text)) {
89
+ level = 'critical';
90
+ reasons.push('pipes a remote/untrusted payload into an interpreter');
91
+ }
92
+ if (/mkfs|\bdd\s+.*of=\/dev|>\s*\/dev\/sd/.test(text)) {
93
+ level = 'critical';
94
+ reasons.push('writes directly to block devices');
95
+ }
96
+ if (level !== 'critical') reasons.push('raw shell syntax: pipes, redirects or chaining may run anything');
97
+ return { level, reasons };
98
+ }
99
+
100
+ const outputLimit = (config) => Math.max(4000, Math.min(config.maxToolOutputBytes, 200000));
101
+
102
+ export function createShellTools({ workspace, config, processes, logger = null }) {
103
+ return [
104
+ defineTool({
105
+ name: 'run_command',
106
+ description:
107
+ 'Run a command in the workspace and wait for completion. Prefer argv (e.g. ["npm","test"]); shell:false is used. Raw shell syntax requires agent.allowShell=true in config.json and an explicit approval. Every execution is approval-gated. Shell execution is NOT a sandbox.',
108
+ risk: 'exec',
109
+ requiresApproval: true,
110
+ schema: {
111
+ type: 'object',
112
+ properties: {
113
+ argv: {
114
+ type: 'array',
115
+ items: { type: 'string', minLength: 1, maxLength: 20000 },
116
+ minItems: 1,
117
+ maxItems: 200,
118
+ description: 'Command and arguments, e.g. ["npm", "test"].',
119
+ },
120
+ shell_command: { type: 'string', minLength: 1, maxLength: 4000, description: 'Raw shell string (only when agent.allowShell=true).' },
121
+ cwd: { type: 'string', minLength: 1, maxLength: 4096, description: 'Workspace-relative working directory.' },
122
+ timeout_ms: { type: 'integer', minimum: 1000, maximum: 600000, description: 'Timeout (default 120000).' },
123
+ description: { type: 'string', maxLength: 200, description: 'Why this command is needed.' },
124
+ },
125
+ additionalProperties: false,
126
+ },
127
+ assess: (args) => (args.shell_command ? assessShellString(args.shell_command) : assessCommand(args.argv || [])),
128
+ preview: async (args) => {
129
+ const display = args.shell_command ? `$ ${args.shell_command}\n(shell syntax)` : `$ ${(args.argv || []).join(' ')}`;
130
+ const cwd = args.cwd || '.';
131
+ const assessment = args.shell_command ? assessShellString(args.shell_command) : assessCommand(args.argv || []);
132
+ const lines = [`${display}\ncwd: ${workspace.resolve(cwd).relPath}`, `risk: ${assessment.level}`];
133
+ if (assessment.reasons.length > 0) lines.push(`notes:\n- ${assessment.reasons.join('\n- ')}`);
134
+ lines.push('Shell execution is not a sandbox.');
135
+ return lines.join('\n');
136
+ },
137
+ handler: async (args, ctx = {}) => {
138
+ if (args.argv && args.shell_command) {
139
+ throw new ToolError('Provide either argv or shell_command, not both.');
140
+ }
141
+ if (args.shell_command && !config.allowShell) {
142
+ throw new ToolError(
143
+ 'Shell syntax is disabled. Pass an argv array instead, or ask the user to set agent.allowShell=true in config.json and approve the raw command.',
144
+ );
145
+ }
146
+ if (!args.argv && !args.shell_command) throw new ToolError('Provide argv (preferred) or shell_command.');
147
+ const assessment = args.shell_command ? assessShellString(args.shell_command) : assessCommand(args.argv);
148
+ const record = processes.start({
149
+ argv: args.argv,
150
+ shellCommand: args.shell_command || null,
151
+ cwd: args.cwd || '.',
152
+ timeoutMs: args.timeout_ms ?? 120000,
153
+ allowShell: config.allowShell,
154
+ label: args.description || undefined,
155
+ });
156
+ const onAbort = () => {
157
+ processes.stop(record.id, { graceMs: 1000 }).catch(() => {});
158
+ };
159
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
160
+ try {
161
+ await record.done;
162
+ } finally {
163
+ ctx.signal?.removeEventListener('abort', onAbort);
164
+ }
165
+ const limit = outputLimit(config);
166
+ const stdout = record.stdout.text;
167
+ const stderr = record.stderr.text;
168
+ logger?.info('command finished', {
169
+ id: record.id,
170
+ argv: record.argv,
171
+ exitCode: record.exitCode,
172
+ status: record.status,
173
+ durationMs: record.durationMs,
174
+ });
175
+ return {
176
+ process_id: record.id,
177
+ argv: record.argv,
178
+ cwd: record.cwd,
179
+ status: record.status,
180
+ exit_code: record.exitCode,
181
+ signal: record.signal,
182
+ duration_ms: record.durationMs,
183
+ timed_out: record.timedOut,
184
+ risk: assessment,
185
+ stdout: stdout.length > limit ? `…[tail of output]…\n${stdout.slice(-limit)}` : stdout,
186
+ stderr: stderr.length > limit ? `…[tail of output]…\n${stderr.slice(-limit)}` : stderr,
187
+ truncated: record.truncated,
188
+ ok: record.status === 'exited' && record.exitCode === 0,
189
+ };
190
+ },
191
+ }),
192
+
193
+ defineTool({
194
+ name: 'start_process',
195
+ description:
196
+ 'Start a long-running process (dev server, watcher) without blocking the agent. Returns a process id immediately. Use get_process_output to poll it and stop_process to stop it. Approval-gated.',
197
+ risk: 'exec',
198
+ requiresApproval: true,
199
+ schema: {
200
+ type: 'object',
201
+ properties: {
202
+ argv: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 20000 }, minItems: 1, maxItems: 200 },
203
+ cwd: { type: 'string', minLength: 1, maxLength: 4096 },
204
+ label: { type: 'string', maxLength: 120 },
205
+ timeout_ms: { type: 'integer', minimum: 0, maximum: 3600000, description: '0 means no timeout (default 0 for background processes).' },
206
+ },
207
+ required: ['argv'],
208
+ additionalProperties: false,
209
+ },
210
+ assess: (args) => assessCommand(args.argv),
211
+ preview: async (args) => `Start background process: ${args.argv.join(' ')}\ncwd: ${workspace.resolve(args.cwd || '.').relPath}`,
212
+ handler: async (args) => {
213
+ const assessment = assessCommand(args.argv);
214
+ const record = processes.start({
215
+ argv: args.argv,
216
+ cwd: args.cwd || '.',
217
+ timeoutMs: args.timeout_ms ?? 0,
218
+ label: args.label,
219
+ });
220
+ return {
221
+ process_id: record.id,
222
+ pid: record.pid,
223
+ status: record.status,
224
+ argv: record.argv,
225
+ cwd: record.cwd,
226
+ risk: assessment,
227
+ note: 'Verify with get_process_output before assuming the service is ready.',
228
+ };
229
+ },
230
+ }),
231
+
232
+ defineTool({
233
+ name: 'list_processes',
234
+ description: 'List running and recently completed processes started in this session.',
235
+ risk: 'read',
236
+ schema: { type: 'object', properties: {}, additionalProperties: false },
237
+ handler: async () => ({ processes: processes.list() }),
238
+ }),
239
+
240
+ defineTool({
241
+ name: 'get_process_output',
242
+ description: 'Read captured stdout/stderr and status for a process started in this session.',
243
+ risk: 'read',
244
+ schema: {
245
+ type: 'object',
246
+ properties: {
247
+ process_id: { type: 'string', minLength: 1, maxLength: 40 },
248
+ stream: { type: 'string', enum: ['stdout', 'stderr', 'both'] },
249
+ tail_bytes: { type: 'integer', minimum: 100, maximum: 200000 },
250
+ },
251
+ required: ['process_id'],
252
+ additionalProperties: false,
253
+ },
254
+ handler: async (args) => processes.getOutput(args.process_id, {
255
+ stream: args.stream ?? 'both',
256
+ tailBytes: args.tail_bytes ?? outputLimit(config),
257
+ }),
258
+ }),
259
+
260
+ defineTool({
261
+ name: 'stop_process',
262
+ description: 'Stop a running process with SIGTERM, escalating to SIGKILL after a grace period. Approval-gated.',
263
+ risk: 'exec',
264
+ requiresApproval: true,
265
+ schema: {
266
+ type: 'object',
267
+ properties: {
268
+ process_id: { type: 'string', minLength: 1, maxLength: 40 },
269
+ grace_ms: { type: 'integer', minimum: 100, maximum: 10000 },
270
+ },
271
+ required: ['process_id'],
272
+ additionalProperties: false,
273
+ },
274
+ assess: () => ({ level: 'high', reasons: ['terminates a running process started in this session'] }),
275
+ preview: async (args) => `Stop process ${args.process_id}`,
276
+ handler: async (args) => processes.stop(args.process_id, { graceMs: args.grace_ms ?? 3000 }),
277
+ }),
278
+ ];
279
+ }