codemini-cli 0.1.13 → 0.1.15
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/OPERATIONS.md +17 -2
- package/README.md +80 -0
- package/package.json +1 -1
- package/skills/{brainstorming-lite → brainstorm}/SKILL.md +36 -4
- package/skills/superpowers-lite/SKILL.md +12 -2
- package/src/cli.js +1 -1
- package/src/core/agent-loop.js +29 -0
- package/src/core/chat-runtime.js +146 -21
- package/src/core/config-store.js +10 -0
- package/src/core/shell-profile.js +1 -1
- package/src/core/shell.js +135 -9
- package/src/core/tools.js +445 -9
- package/src/tui/chat-app.js +206 -21
- package/skills/executing-plan-lite/SKILL.md +0 -41
package/src/core/shell.js
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const LONG_RUNNING_COMMAND_RE =
|
|
4
|
+
/\b(npm\s+(?:run\s+)?(?:start|dev)\b|pnpm\s+(?:run\s+)?(?:start|dev)\b|yarn\s+(?:start|dev)\b|vite\b|next\s+dev\b|webpack\s+serve\b|python\s+-m\s+http\.server\b|serve\b|java\s+-jar\b|mvn(?:w)?\s+spring-boot:run\b|gradle(?:w)?\s+bootRun\b|gradle(?:w)?\s+run\b|java\b.*\bserver\b|dotnet\s+run\b|go\s+run\b.*\b(server|cmd\/server|main\.go)\b|air\b|cargo\s+run\b.*\b(server|api|web)\b|cargo\s+watch\s+-x\s+run\b)/i;
|
|
5
|
+
const GENERIC_LONG_RUNNING_HINT_RE = /\b(start|serve|server|dev|preview|watch)\b/i;
|
|
6
|
+
const READY_OUTPUT_PATTERNS = [
|
|
7
|
+
/\bready\b/i,
|
|
8
|
+
/\bcompiled successfully\b/i,
|
|
9
|
+
/\blocal:\s*https?:\/\//i,
|
|
10
|
+
/\blistening on\b/i,
|
|
11
|
+
/\bserver running\b/i,
|
|
12
|
+
/\brunning at\b/i,
|
|
13
|
+
/\bserving at\b/i,
|
|
14
|
+
/\bstarted\b.*\bin\b/i,
|
|
15
|
+
/\bstarted\s+[A-Za-z0-9_$.-]+\s+in\b/i,
|
|
16
|
+
/\btomcat started on port\(s\):/i,
|
|
17
|
+
/\bnetty started on port/i,
|
|
18
|
+
/\bnow listening on:\s*https?:\/\//i,
|
|
19
|
+
/\bapplication started\./i,
|
|
20
|
+
/\bserving http on\b/i,
|
|
21
|
+
/\bstarting development server at\b/i,
|
|
22
|
+
/\bactix web server running on\b/i,
|
|
23
|
+
/\bhttp:\/\/127\.0\.0\.1\b/i,
|
|
24
|
+
/\bhttp:\/\/localhost\b/i
|
|
25
|
+
];
|
|
26
|
+
const AUTO_STOP_GRACE_MS = 150;
|
|
27
|
+
const LONG_RUNNING_STARTUP_WINDOW_MS = 1500;
|
|
28
|
+
|
|
29
|
+
export function isLikelyLongRunningCommand(command) {
|
|
30
|
+
const value = String(command || '');
|
|
31
|
+
return LONG_RUNNING_COMMAND_RE.test(value) || GENERIC_LONG_RUNNING_HINT_RE.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hasReadyOutput(text) {
|
|
35
|
+
const value = String(text || '');
|
|
36
|
+
return READY_OUTPUT_PATTERNS.some((pattern) => pattern.test(value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectDescendantPids(rootPid, seen = new Set()) {
|
|
40
|
+
const pid = Number(rootPid);
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid) || process.platform === 'win32') {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
seen.add(pid);
|
|
45
|
+
const result = spawnSync('ps', ['-o', 'pid=', '--ppid', String(pid)], { encoding: 'utf8' });
|
|
46
|
+
if (result.status !== 0 || !result.stdout) return [];
|
|
47
|
+
const directChildren = result.stdout
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((value) => Number(String(value || '').trim()))
|
|
50
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
51
|
+
const descendants = [];
|
|
52
|
+
for (const childPid of directChildren) {
|
|
53
|
+
if (seen.has(childPid)) continue;
|
|
54
|
+
descendants.push(childPid);
|
|
55
|
+
descendants.push(...collectDescendantPids(childPid, seen));
|
|
56
|
+
}
|
|
57
|
+
return descendants;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function terminateChild(child, signal = 'SIGTERM') {
|
|
61
|
+
if (!child) return;
|
|
62
|
+
const pid = Number(child.pid);
|
|
63
|
+
if (process.platform !== 'win32' && Number.isInteger(pid) && pid > 0) {
|
|
64
|
+
const descendants = collectDescendantPids(pid);
|
|
65
|
+
for (const targetPid of descendants.reverse()) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(targetPid, signal);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
child.kill(signal);
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveShell(defaultShell) {
|
|
4
77
|
if (process.platform === 'win32') {
|
|
5
78
|
if (defaultShell === 'cmd') {
|
|
6
79
|
return { command: 'cmd.exe', args: ['/d', '/s', '/c'] };
|
|
@@ -30,9 +103,13 @@ export function runShellCommand({
|
|
|
30
103
|
timeoutMs = 120000
|
|
31
104
|
}) {
|
|
32
105
|
const shellSpec = resolveShell(shell);
|
|
106
|
+
const shellCommand =
|
|
107
|
+
process.platform !== 'win32' && /(?:^|\/)bash(?:\.exe)?$/i.test(shellSpec.command)
|
|
108
|
+
? `exec ${command}`
|
|
109
|
+
: command;
|
|
33
110
|
|
|
34
111
|
return new Promise((resolve, reject) => {
|
|
35
|
-
const child = spawn(shellSpec.command, [...shellSpec.args,
|
|
112
|
+
const child = spawn(shellSpec.command, [...shellSpec.args, shellCommand], {
|
|
36
113
|
cwd,
|
|
37
114
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
38
115
|
});
|
|
@@ -40,32 +117,81 @@ export function runShellCommand({
|
|
|
40
117
|
let stdout = '';
|
|
41
118
|
let stderr = '';
|
|
42
119
|
let timedOut = false;
|
|
120
|
+
let autoStopped = false;
|
|
121
|
+
let stopReason = '';
|
|
122
|
+
let finalized = false;
|
|
123
|
+
const longRunningCommand = isLikelyLongRunningCommand(command);
|
|
124
|
+
const autoStopWindowMs = longRunningCommand
|
|
125
|
+
? Math.min(LONG_RUNNING_STARTUP_WINDOW_MS, Math.max(350, Math.floor(timeoutMs * 0.6)))
|
|
126
|
+
: 0;
|
|
127
|
+
|
|
128
|
+
const finalizeResolve = (value) => {
|
|
129
|
+
if (finalized) return;
|
|
130
|
+
finalized = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
if (autoStopTimer) clearTimeout(autoStopTimer);
|
|
133
|
+
resolve(value);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const finalizeReject = (error) => {
|
|
137
|
+
if (finalized) return;
|
|
138
|
+
finalized = true;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
if (autoStopTimer) clearTimeout(autoStopTimer);
|
|
141
|
+
reject(error);
|
|
142
|
+
};
|
|
43
143
|
|
|
44
144
|
const timer = setTimeout(() => {
|
|
45
145
|
timedOut = true;
|
|
46
|
-
child
|
|
146
|
+
terminateChild(child, 'SIGTERM');
|
|
47
147
|
}, timeoutMs);
|
|
148
|
+
const autoStopTimer =
|
|
149
|
+
autoStopWindowMs > 0
|
|
150
|
+
? setTimeout(() => {
|
|
151
|
+
finalizeAutoStop('startup_window');
|
|
152
|
+
}, autoStopWindowMs)
|
|
153
|
+
: null;
|
|
154
|
+
|
|
155
|
+
const finalizeAutoStop = (reason) => {
|
|
156
|
+
if (timedOut || autoStopped || finalized) return;
|
|
157
|
+
autoStopped = true;
|
|
158
|
+
stopReason = reason;
|
|
159
|
+
terminateChild(child, 'SIGTERM');
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
terminateChild(child, 'SIGKILL');
|
|
162
|
+
}, AUTO_STOP_GRACE_MS);
|
|
163
|
+
finalizeResolve({ code: 0, stdout, stderr, auto_stopped: true, stop_reason: stopReason });
|
|
164
|
+
};
|
|
48
165
|
|
|
49
166
|
child.stdout.on('data', (chunk) => {
|
|
50
167
|
stdout += chunk.toString();
|
|
168
|
+
if (longRunningCommand && hasReadyOutput(stdout)) {
|
|
169
|
+
finalizeAutoStop('ready_output');
|
|
170
|
+
}
|
|
51
171
|
});
|
|
52
172
|
|
|
53
173
|
child.stderr.on('data', (chunk) => {
|
|
54
174
|
stderr += chunk.toString();
|
|
175
|
+
if (longRunningCommand && hasReadyOutput(stderr)) {
|
|
176
|
+
finalizeAutoStop('ready_output');
|
|
177
|
+
}
|
|
55
178
|
});
|
|
56
179
|
|
|
57
180
|
child.on('error', (err) => {
|
|
58
|
-
|
|
59
|
-
reject(err);
|
|
181
|
+
finalizeReject(err);
|
|
60
182
|
});
|
|
61
183
|
|
|
62
184
|
child.on('close', (code) => {
|
|
63
|
-
|
|
185
|
+
if (finalized) return;
|
|
64
186
|
if (timedOut) {
|
|
65
|
-
|
|
187
|
+
finalizeReject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (autoStopped) {
|
|
191
|
+
finalizeResolve({ code: 0, stdout, stderr, auto_stopped: true, stop_reason: stopReason });
|
|
66
192
|
return;
|
|
67
193
|
}
|
|
68
|
-
|
|
194
|
+
finalizeResolve({ code, stdout, stderr });
|
|
69
195
|
});
|
|
70
196
|
});
|
|
71
197
|
}
|
package/src/core/tools.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import {
|
|
7
|
+
hasReadyOutput,
|
|
8
|
+
isDangerousCommand,
|
|
9
|
+
isLikelyLongRunningCommand,
|
|
10
|
+
resolveShell,
|
|
11
|
+
runShellCommand,
|
|
12
|
+
terminateChild
|
|
13
|
+
} from './shell.js';
|
|
5
14
|
import { evaluateCommandPolicy } from './command-policy.js';
|
|
6
15
|
|
|
7
16
|
const SKIP_DIRS = new Set(['.git', 'node_modules', '.coder', '.codemini-cli', 'dist', 'coverage']);
|
|
@@ -46,6 +55,11 @@ const LANGUAGE_FILE_TYPES = {
|
|
|
46
55
|
shell: ['sh', 'ps1'],
|
|
47
56
|
yaml: ['yml', 'yaml']
|
|
48
57
|
};
|
|
58
|
+
const SERVICE_RECENT_LOG_LIMIT = 80;
|
|
59
|
+
const SERVICE_STARTUP_POLL_MS = 150;
|
|
60
|
+
const serviceRegistry = new Map();
|
|
61
|
+
let serviceCounter = 0;
|
|
62
|
+
let serviceLogCursorCounter = 0;
|
|
49
63
|
|
|
50
64
|
function resolveInWorkspace(root, targetPath = '.') {
|
|
51
65
|
const absRoot = path.resolve(root);
|
|
@@ -509,6 +523,9 @@ async function runCommand(root, config, args) {
|
|
|
509
523
|
if (!command.trim()) {
|
|
510
524
|
throw new Error('run_command requires command');
|
|
511
525
|
}
|
|
526
|
+
if (isLikelyLongRunningCommand(command)) {
|
|
527
|
+
throw new Error('Command looks like a long-running service. Use start_service instead of run_command.');
|
|
528
|
+
}
|
|
512
529
|
if (
|
|
513
530
|
!config.policy.allow_dangerous_commands &&
|
|
514
531
|
isDangerousCommand(command, config.policy.blocked_command_patterns)
|
|
@@ -532,6 +549,300 @@ async function runCommand(root, config, args) {
|
|
|
532
549
|
return { ...result, command };
|
|
533
550
|
}
|
|
534
551
|
|
|
552
|
+
function nextServiceId() {
|
|
553
|
+
serviceCounter += 1;
|
|
554
|
+
return `svc_${String(serviceCounter).padStart(3, '0')}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function normalizeSuccessMatchers(items = []) {
|
|
558
|
+
if (!Array.isArray(items)) return [];
|
|
559
|
+
return items.map((item) => String(item || '').trim()).filter(Boolean);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function shellCommandForService(command, shellSpec) {
|
|
563
|
+
return process.platform !== 'win32' && /(?:^|\/)bash(?:\.exe)?$/i.test(shellSpec.command)
|
|
564
|
+
? `exec ${command}`
|
|
565
|
+
: command;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function appendRecentLogs(service, chunk) {
|
|
569
|
+
const lines = String(chunk || '')
|
|
570
|
+
.split(/\r?\n/)
|
|
571
|
+
.map((line) => trimLinePreview(line, 220))
|
|
572
|
+
.filter(Boolean);
|
|
573
|
+
if (lines.length === 0) return;
|
|
574
|
+
for (const line of lines) {
|
|
575
|
+
serviceLogCursorCounter += 1;
|
|
576
|
+
service.recentLogs.push({ cursor: serviceLogCursorCounter, line });
|
|
577
|
+
}
|
|
578
|
+
if (service.recentLogs.length > SERVICE_RECENT_LOG_LIMIT) {
|
|
579
|
+
service.recentLogs.splice(0, service.recentLogs.length - SERVICE_RECENT_LOG_LIMIT);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function matchesServiceSuccess(service, text) {
|
|
584
|
+
const value = String(text || '');
|
|
585
|
+
if (!value) return false;
|
|
586
|
+
if (hasReadyOutput(value)) return true;
|
|
587
|
+
return service.successMatchers.some((matcher) => value.toLowerCase().includes(matcher.toLowerCase()));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function markServiceReady(service, source = 'output') {
|
|
591
|
+
if (service.startupConfirmed) return;
|
|
592
|
+
service.startupConfirmed = true;
|
|
593
|
+
service.startupSource = source;
|
|
594
|
+
service.status = 'running';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function serviceUrlForPort(port) {
|
|
598
|
+
const portNumber = Number(port);
|
|
599
|
+
return Number.isInteger(portNumber) && portNumber > 0 ? `http://127.0.0.1:${portNumber}` : '';
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function normalizeHttpProbe(value) {
|
|
603
|
+
if (!value || typeof value !== 'object') return null;
|
|
604
|
+
const url = String(value.url || '').trim();
|
|
605
|
+
if (!url) return null;
|
|
606
|
+
const expectStatus = Number(value.expect_status ?? value.expectStatus ?? 200);
|
|
607
|
+
return {
|
|
608
|
+
url,
|
|
609
|
+
expect_status: Number.isInteger(expectStatus) ? expectStatus : 200
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function snapshotService(service, tail = 12) {
|
|
614
|
+
const recentLogs = Array.isArray(service.recentLogs)
|
|
615
|
+
? service.recentLogs.slice(-Math.max(1, tail)).map((item) => item.line)
|
|
616
|
+
: [];
|
|
617
|
+
const latestCursor =
|
|
618
|
+
Array.isArray(service.recentLogs) && service.recentLogs.length > 0
|
|
619
|
+
? service.recentLogs[service.recentLogs.length - 1].cursor
|
|
620
|
+
: 0;
|
|
621
|
+
return {
|
|
622
|
+
task_id: service.taskId,
|
|
623
|
+
pid: service.child?.pid || null,
|
|
624
|
+
command: service.command,
|
|
625
|
+
cwd: service.cwd,
|
|
626
|
+
status: service.status,
|
|
627
|
+
startup_confirmed: service.startupConfirmed,
|
|
628
|
+
startup_source: service.startupSource || '',
|
|
629
|
+
http_probe: service.httpProbe || undefined,
|
|
630
|
+
url: serviceUrlForPort(service.portProbe) || undefined,
|
|
631
|
+
recent_logs: recentLogs,
|
|
632
|
+
log_cursor: latestCursor,
|
|
633
|
+
exit_code: service.exitCode ?? undefined,
|
|
634
|
+
signal: service.signal ?? undefined,
|
|
635
|
+
duration_ms: Date.now() - service.startedAt
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function listServiceSnapshots() {
|
|
640
|
+
return Array.from(serviceRegistry.values()).map((service) => snapshotService(service, 4));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function probePortOnce(port, host = '127.0.0.1', timeoutMs = 250) {
|
|
644
|
+
return new Promise((resolve) => {
|
|
645
|
+
const socket = new net.Socket();
|
|
646
|
+
let settled = false;
|
|
647
|
+
const finish = (value) => {
|
|
648
|
+
if (settled) return;
|
|
649
|
+
settled = true;
|
|
650
|
+
socket.destroy();
|
|
651
|
+
resolve(value);
|
|
652
|
+
};
|
|
653
|
+
socket.setTimeout(timeoutMs);
|
|
654
|
+
socket.once('connect', () => finish(true));
|
|
655
|
+
socket.once('timeout', () => finish(false));
|
|
656
|
+
socket.once('error', () => finish(false));
|
|
657
|
+
socket.connect(Number(port), host);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function probeHttpOnce(httpProbe, timeoutMs = 400) {
|
|
662
|
+
if (!httpProbe?.url) return false;
|
|
663
|
+
const controller = new AbortController();
|
|
664
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
665
|
+
try {
|
|
666
|
+
const response = await fetch(httpProbe.url, {
|
|
667
|
+
method: 'GET',
|
|
668
|
+
signal: controller.signal
|
|
669
|
+
});
|
|
670
|
+
return response.status === Number(httpProbe.expect_status || 200);
|
|
671
|
+
} catch {
|
|
672
|
+
return false;
|
|
673
|
+
} finally {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function startService(root, config, args) {
|
|
679
|
+
const command = String(args?.command || args?.cmd || '').trim();
|
|
680
|
+
if (!command) throw new Error('start_service requires command');
|
|
681
|
+
if (
|
|
682
|
+
!config.policy.allow_dangerous_commands &&
|
|
683
|
+
isDangerousCommand(command, config.policy.blocked_command_patterns)
|
|
684
|
+
) {
|
|
685
|
+
throw new Error('Command blocked by policy');
|
|
686
|
+
}
|
|
687
|
+
const check = evaluateCommandPolicy(command, config, root);
|
|
688
|
+
if (!check.allowed) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`Command blocked by safe mode: ${check.reason}${check.suggestion ? ` | ${check.suggestion}` : ''}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const shellSpec = resolveShell(config.shell.default);
|
|
695
|
+
const taskId = nextServiceId();
|
|
696
|
+
const startupTimeoutMs = Math.max(250, Number(args?.startup_timeout_ms || args?.startupTimeoutMs || 20000));
|
|
697
|
+
const successMatchers = normalizeSuccessMatchers(args?.success_matchers || args?.successMatchers);
|
|
698
|
+
const portProbe = Number(args?.port_probe || args?.portProbe || 0) || 0;
|
|
699
|
+
const httpProbe = normalizeHttpProbe(args?.http_probe || args?.httpProbe);
|
|
700
|
+
const service = {
|
|
701
|
+
taskId,
|
|
702
|
+
command,
|
|
703
|
+
cwd: root,
|
|
704
|
+
child: spawn(shellSpec.command, [...shellSpec.args, shellCommandForService(command, shellSpec)], {
|
|
705
|
+
cwd: root,
|
|
706
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
707
|
+
}),
|
|
708
|
+
startedAt: Date.now(),
|
|
709
|
+
status: 'starting',
|
|
710
|
+
startupConfirmed: false,
|
|
711
|
+
startupSource: '',
|
|
712
|
+
successMatchers,
|
|
713
|
+
portProbe,
|
|
714
|
+
httpProbe,
|
|
715
|
+
recentLogs: [],
|
|
716
|
+
exitCode: null,
|
|
717
|
+
signal: null
|
|
718
|
+
};
|
|
719
|
+
serviceRegistry.set(taskId, service);
|
|
720
|
+
|
|
721
|
+
service.closePromise = new Promise((resolve) => {
|
|
722
|
+
service.child.on('close', (code, signal) => {
|
|
723
|
+
service.exitCode = code;
|
|
724
|
+
service.signal = signal;
|
|
725
|
+
service.status = service.status === 'stopped' ? 'stopped' : 'exited';
|
|
726
|
+
resolve();
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const onOutput = (chunk) => {
|
|
731
|
+
appendRecentLogs(service, chunk);
|
|
732
|
+
if (matchesServiceSuccess(service, chunk)) {
|
|
733
|
+
markServiceReady(service, 'output');
|
|
734
|
+
if (service._finishStartup) service._finishStartup();
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
service.child.stdout.on('data', onOutput);
|
|
738
|
+
service.child.stderr.on('data', onOutput);
|
|
739
|
+
service.child.on('error', (error) => {
|
|
740
|
+
appendRecentLogs(service, error?.message || String(error));
|
|
741
|
+
service.status = 'exited';
|
|
742
|
+
if (service._finishStartup) service._finishStartup();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
await new Promise((resolve) => {
|
|
746
|
+
let settled = false;
|
|
747
|
+
const finish = () => {
|
|
748
|
+
if (settled) return;
|
|
749
|
+
settled = true;
|
|
750
|
+
clearTimeout(timeoutHandle);
|
|
751
|
+
clearInterval(portHandle);
|
|
752
|
+
clearInterval(httpHandle);
|
|
753
|
+
service._finishStartup = null;
|
|
754
|
+
resolve();
|
|
755
|
+
};
|
|
756
|
+
service._finishStartup = finish;
|
|
757
|
+
if (service.startupConfirmed || service.status === 'exited') {
|
|
758
|
+
finish();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const timeoutHandle = setTimeout(() => {
|
|
762
|
+
if (service.status === 'starting') {
|
|
763
|
+
if (!service.startupConfirmed) {
|
|
764
|
+
markServiceReady(service, 'startup_window');
|
|
765
|
+
} else {
|
|
766
|
+
service.status = 'running';
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
finish();
|
|
770
|
+
}, startupTimeoutMs);
|
|
771
|
+
const portHandle =
|
|
772
|
+
portProbe > 0
|
|
773
|
+
? setInterval(async () => {
|
|
774
|
+
const open = await probePortOnce(portProbe);
|
|
775
|
+
if (open) {
|
|
776
|
+
markServiceReady(service, 'port_probe');
|
|
777
|
+
finish();
|
|
778
|
+
}
|
|
779
|
+
}, SERVICE_STARTUP_POLL_MS)
|
|
780
|
+
: null;
|
|
781
|
+
const httpHandle =
|
|
782
|
+
httpProbe
|
|
783
|
+
? setInterval(async () => {
|
|
784
|
+
const ok = await probeHttpOnce(httpProbe);
|
|
785
|
+
if (ok) {
|
|
786
|
+
markServiceReady(service, 'http_probe');
|
|
787
|
+
finish();
|
|
788
|
+
}
|
|
789
|
+
}, SERVICE_STARTUP_POLL_MS)
|
|
790
|
+
: null;
|
|
791
|
+
service.child.once('close', () => finish());
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (service.status === 'starting') {
|
|
795
|
+
service.status = 'running';
|
|
796
|
+
}
|
|
797
|
+
return snapshotService(service);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function getServiceOrThrow(taskId) {
|
|
801
|
+
const service = serviceRegistry.get(String(taskId || '').trim());
|
|
802
|
+
if (!service) throw new Error(`Unknown service task: ${taskId}`);
|
|
803
|
+
return service;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function getServiceStatus(_root, args) {
|
|
807
|
+
const service = getServiceOrThrow(args?.task_id || args?.taskId);
|
|
808
|
+
return snapshotService(service);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function listServices() {
|
|
812
|
+
return {
|
|
813
|
+
services: listServiceSnapshots()
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function getServiceLogs(_root, args) {
|
|
818
|
+
const service = getServiceOrThrow(args?.task_id || args?.taskId);
|
|
819
|
+
const tail = Math.max(1, Math.min(200, Number(args?.tail || 40)));
|
|
820
|
+
const afterCursor = Math.max(0, Number(args?.after_cursor || args?.afterCursor || 0));
|
|
821
|
+
const filtered = afterCursor > 0 ? service.recentLogs.filter((item) => item.cursor > afterCursor) : service.recentLogs;
|
|
822
|
+
const selected = filtered.slice(-tail);
|
|
823
|
+
return {
|
|
824
|
+
task_id: service.taskId,
|
|
825
|
+
status: service.status,
|
|
826
|
+
recent_logs: selected.map((item) => item.line),
|
|
827
|
+
next_cursor: selected.length > 0 ? selected[selected.length - 1].cursor : afterCursor
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function stopService(_root, args) {
|
|
832
|
+
const service = getServiceOrThrow(args?.task_id || args?.taskId);
|
|
833
|
+
if (service.status === 'stopped' || service.status === 'exited') {
|
|
834
|
+
return { ...snapshotService(service), stopped: true };
|
|
835
|
+
}
|
|
836
|
+
service.status = 'stopped';
|
|
837
|
+
terminateChild(service.child, 'SIGTERM');
|
|
838
|
+
setTimeout(() => terminateChild(service.child, 'SIGKILL'), 200);
|
|
839
|
+
await Promise.race([
|
|
840
|
+
service.closePromise,
|
|
841
|
+
new Promise((resolve) => setTimeout(resolve, 500))
|
|
842
|
+
]);
|
|
843
|
+
return { ...snapshotService(service), stopped: true };
|
|
844
|
+
}
|
|
845
|
+
|
|
535
846
|
async function searchCode(root, args) {
|
|
536
847
|
const query = String(args?.query || args?.symbol || '').trim();
|
|
537
848
|
if (!query) throw new Error('search_code requires query');
|
|
@@ -794,17 +1105,55 @@ async function openTarget(root, args) {
|
|
|
794
1105
|
};
|
|
795
1106
|
}
|
|
796
1107
|
|
|
797
|
-
|
|
1108
|
+
function normalizeEditTargetArgs(args = {}) {
|
|
798
1109
|
const file = String(args?.file || args?.path || '').trim();
|
|
799
|
-
const
|
|
1110
|
+
const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
|
|
1111
|
+
if (nestedEdit) {
|
|
1112
|
+
return {
|
|
1113
|
+
file,
|
|
1114
|
+
edit: nestedEdit
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
file,
|
|
1119
|
+
edit: {
|
|
1120
|
+
kind: args?.kind,
|
|
1121
|
+
target: args?.target,
|
|
1122
|
+
new_content: args?.new_content ?? args?.content,
|
|
1123
|
+
old_text: args?.old_text,
|
|
1124
|
+
new_text: args?.new_text,
|
|
1125
|
+
anchor_text: args?.anchor_text,
|
|
1126
|
+
content: args?.content
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function editTarget(root, args) {
|
|
1132
|
+
const normalized = normalizeEditTargetArgs(args);
|
|
1133
|
+
const file = normalized.file;
|
|
1134
|
+
const edit = normalized.edit || {};
|
|
800
1135
|
const kind = String(edit.kind || '').trim();
|
|
801
1136
|
if (!file || !kind) throw new Error('edit_target requires file and edit.kind');
|
|
802
1137
|
if (kind === 'replace_block') {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1138
|
+
try {
|
|
1139
|
+
return await replaceBlock(root, {
|
|
1140
|
+
path: file,
|
|
1141
|
+
target: edit.target,
|
|
1142
|
+
new_content: edit.new_content
|
|
1143
|
+
});
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (!/old_hash mismatch/i.test(String(error?.message || ''))) throw error;
|
|
1146
|
+
const validation = await validateEdit(root, {
|
|
1147
|
+
path: file,
|
|
1148
|
+
kind: 'replace_block',
|
|
1149
|
+
target: edit.target
|
|
1150
|
+
});
|
|
1151
|
+
return replaceBlock(root, {
|
|
1152
|
+
path: file,
|
|
1153
|
+
target: validation.target,
|
|
1154
|
+
new_content: edit.new_content
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
808
1157
|
}
|
|
809
1158
|
if (kind === 'replace_text') {
|
|
810
1159
|
return replaceText(root, {
|
|
@@ -1071,7 +1420,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1071
1420
|
type: 'function',
|
|
1072
1421
|
function: {
|
|
1073
1422
|
name: 'run_command',
|
|
1074
|
-
description: 'Execute a shell command in workspace',
|
|
1423
|
+
description: 'Execute a one-shot shell command in workspace. Do not use for long-running services.',
|
|
1075
1424
|
parameters: {
|
|
1076
1425
|
type: 'object',
|
|
1077
1426
|
properties: {
|
|
@@ -1080,6 +1429,88 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1080
1429
|
required: ['command']
|
|
1081
1430
|
}
|
|
1082
1431
|
}
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
type: 'function',
|
|
1435
|
+
function: {
|
|
1436
|
+
name: 'start_service',
|
|
1437
|
+
description: 'Start a long-running local service and return a compact service handle instead of blocking on process exit.',
|
|
1438
|
+
parameters: {
|
|
1439
|
+
type: 'object',
|
|
1440
|
+
properties: {
|
|
1441
|
+
command: { type: 'string' },
|
|
1442
|
+
startup_timeout_ms: { type: 'number' },
|
|
1443
|
+
success_matchers: {
|
|
1444
|
+
type: 'array',
|
|
1445
|
+
items: { type: 'string' }
|
|
1446
|
+
},
|
|
1447
|
+
port_probe: { type: 'number' },
|
|
1448
|
+
http_probe: {
|
|
1449
|
+
type: 'object',
|
|
1450
|
+
properties: {
|
|
1451
|
+
url: { type: 'string' },
|
|
1452
|
+
expect_status: { type: 'number' }
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
required: ['command']
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
type: 'function',
|
|
1462
|
+
function: {
|
|
1463
|
+
name: 'list_services',
|
|
1464
|
+
description: 'List all tracked local services and their compact current status.',
|
|
1465
|
+
parameters: {
|
|
1466
|
+
type: 'object',
|
|
1467
|
+
properties: {}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
type: 'function',
|
|
1473
|
+
function: {
|
|
1474
|
+
name: 'get_service_status',
|
|
1475
|
+
description: 'Get the current status of a previously started service.',
|
|
1476
|
+
parameters: {
|
|
1477
|
+
type: 'object',
|
|
1478
|
+
properties: {
|
|
1479
|
+
task_id: { type: 'string' }
|
|
1480
|
+
},
|
|
1481
|
+
required: ['task_id']
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
type: 'function',
|
|
1487
|
+
function: {
|
|
1488
|
+
name: 'get_service_logs',
|
|
1489
|
+
description: 'Read recent logs from a previously started service.',
|
|
1490
|
+
parameters: {
|
|
1491
|
+
type: 'object',
|
|
1492
|
+
properties: {
|
|
1493
|
+
task_id: { type: 'string' },
|
|
1494
|
+
tail: { type: 'number' },
|
|
1495
|
+
after_cursor: { type: 'number' }
|
|
1496
|
+
},
|
|
1497
|
+
required: ['task_id']
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
type: 'function',
|
|
1503
|
+
function: {
|
|
1504
|
+
name: 'stop_service',
|
|
1505
|
+
description: 'Stop a previously started service.',
|
|
1506
|
+
parameters: {
|
|
1507
|
+
type: 'object',
|
|
1508
|
+
properties: {
|
|
1509
|
+
task_id: { type: 'string' }
|
|
1510
|
+
},
|
|
1511
|
+
required: ['task_id']
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1083
1514
|
}
|
|
1084
1515
|
];
|
|
1085
1516
|
|
|
@@ -1096,6 +1527,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1096
1527
|
insert_before: (args) => insertRelative(workspaceRoot, args, 'insert_before'),
|
|
1097
1528
|
insert_after: (args) => insertRelative(workspaceRoot, args, 'insert_after'),
|
|
1098
1529
|
generate_diff: (args) => generateDiff(workspaceRoot, args),
|
|
1530
|
+
start_service: (args) => startService(workspaceRoot, config, args),
|
|
1531
|
+
list_services: () => listServices(workspaceRoot),
|
|
1532
|
+
get_service_status: (args) => getServiceStatus(workspaceRoot, args),
|
|
1533
|
+
get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
|
|
1534
|
+
stop_service: (args) => stopService(workspaceRoot, args),
|
|
1099
1535
|
read_file: (args) =>
|
|
1100
1536
|
readFile(workspaceRoot, {
|
|
1101
1537
|
...args,
|