codemini-cli 0.1.13 → 0.1.14
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/README.md +58 -0
- package/package.json +1 -1
- package/src/core/agent-loop.js +29 -0
- 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 +400 -2
- package/src/tui/chat-app.js +206 -21
package/README.md
CHANGED
|
@@ -23,6 +23,10 @@ It is designed for teams that want a coding assistant that feels practical, cont
|
|
|
23
23
|
- Windows-aware shell profile with PowerShell-focused defaults
|
|
24
24
|
- Safe mode enabled by default
|
|
25
25
|
- Built-in lite skills for planning, execution, and collaboration
|
|
26
|
+
- Configurable reply language through `ui.reply_language` (`zh` / `en`)
|
|
27
|
+
- Richer slash completion with priority sorting, inline descriptions, and left/right paging
|
|
28
|
+
- Structured code tools for small models: `locate`, `open_target`, `edit_target`
|
|
29
|
+
- More conservative `plan auto` acceptance checks with reviewer/tester goal checklists
|
|
26
30
|
- Tone presets through `soul`, without changing plans or code behavior
|
|
27
31
|
- Sub-agents for planning, coding, review, and testing
|
|
28
32
|
|
|
@@ -32,6 +36,7 @@ It is designed for teams that want a coding assistant that feels practical, cont
|
|
|
32
36
|
codemini config set gateway.base_url http://your-internal-gateway/v1
|
|
33
37
|
codemini config set gateway.api_key your_token
|
|
34
38
|
codemini config set shell.default powershell
|
|
39
|
+
codemini config set ui.reply_language zh
|
|
35
40
|
codemini config set model.name your-30b-model
|
|
36
41
|
codemini doctor
|
|
37
42
|
codemini
|
|
@@ -54,6 +59,30 @@ codemini doctor
|
|
|
54
59
|
codemini skill list|install|enable|disable|inspect|reindex
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
### Notable Workflow Features
|
|
63
|
+
|
|
64
|
+
- `ui.reply_language` controls the assistant reply language at the prompt layer and also nudges generated docs and code comments to match
|
|
65
|
+
- Slash completion now prioritizes important commands and config keys, shows short descriptions, and supports `←/→` page switching
|
|
66
|
+
- `plan auto` now turns the original goal into an acceptance checklist, uses a lighter chain only for truly tiny tasks, and treats unmet checklist items as failure signals
|
|
67
|
+
- Structured code tools reduce shell-noise for small models by preferring `locate -> open_target -> edit_target`
|
|
68
|
+
|
|
69
|
+
### Skill Loading
|
|
70
|
+
|
|
71
|
+
CodeMini CLI loads skills from these locations:
|
|
72
|
+
|
|
73
|
+
- Bundled repo skills: `skills/<name>/SKILL.md`
|
|
74
|
+
- Installed global skills: `<base-config-dir>/skills/<name>/SKILL.md`
|
|
75
|
+
- Project-scoped legacy skills: `.coder/skills/<name>/SKILL.md`
|
|
76
|
+
|
|
77
|
+
The base config directory is resolved in this order:
|
|
78
|
+
|
|
79
|
+
- `CODEMINI_CONFIG_DIR`
|
|
80
|
+
- `COMPANY_CODER_CONFIG_DIR`
|
|
81
|
+
- Windows: `%APPDATA%\\codemini-cli\\`
|
|
82
|
+
- macOS: `~/Library/Preferences/codemini-cli`
|
|
83
|
+
- Linux/XDG: `$XDG_CONFIG_HOME/codemini-cli`
|
|
84
|
+
- Fallback in restricted environments: `.codemini-cli/`
|
|
85
|
+
|
|
57
86
|
### Documentation
|
|
58
87
|
|
|
59
88
|
- Operator guide and common command patterns: [OPERATIONS.md](/mnt/e/Git%20Projects/qurio-coder/OPERATIONS.md)
|
|
@@ -95,6 +124,10 @@ CodeMini CLI 是一个为小模型工作流优化过的代码助手 CLI,重点
|
|
|
95
124
|
- 面向 Windows 的 PowerShell 默认配置
|
|
96
125
|
- safe mode 默认开启
|
|
97
126
|
- 内置 lite skills,覆盖规划、执行和协作
|
|
127
|
+
- 支持通过 `ui.reply_language` 配置回复语言,当前支持 `zh` / `en`
|
|
128
|
+
- slash 补全支持优先级排序、右侧简短说明和左右分页
|
|
129
|
+
- 为小模型补了结构化代码工具:`locate`、`open_target`、`edit_target`
|
|
130
|
+
- `plan auto` 会基于原始目标生成验收清单,并更保守地处理 reviewer/tester 结果
|
|
98
131
|
- `soul` 只影响语气,不影响计划和代码行为
|
|
99
132
|
- 支持 planner、coder、reviewer、tester 多角色 sub-agent
|
|
100
133
|
|
|
@@ -104,6 +137,7 @@ CodeMini CLI 是一个为小模型工作流优化过的代码助手 CLI,重点
|
|
|
104
137
|
codemini config set gateway.base_url http://your-internal-gateway/v1
|
|
105
138
|
codemini config set gateway.api_key your_token
|
|
106
139
|
codemini config set shell.default powershell
|
|
140
|
+
codemini config set ui.reply_language zh
|
|
107
141
|
codemini config set model.name your-30b-model
|
|
108
142
|
codemini doctor
|
|
109
143
|
codemini
|
|
@@ -126,6 +160,30 @@ codemini doctor
|
|
|
126
160
|
codemini skill list|install|enable|disable|inspect|reindex
|
|
127
161
|
```
|
|
128
162
|
|
|
163
|
+
### 近期工作流增强
|
|
164
|
+
|
|
165
|
+
- `ui.reply_language` 通过 prompt 层控制模型回复语言,也会尽量让生成文档和代码注释跟随该语言
|
|
166
|
+
- slash 补全会优先展示更重要的命令和配置项,显示简短说明,并支持 `←/→` 翻页
|
|
167
|
+
- `plan auto` 会先把原始目标展开成验收清单;只有真正很小的任务才会走轻量链路;如果 reviewer 或 tester 标记了未满足或未验证的验收项,就不会按成功处理
|
|
168
|
+
- 为了减少小模型被 shell 原始输出干扰,新增了 `locate -> open_target -> edit_target` 这套结构化代码工具流
|
|
169
|
+
|
|
170
|
+
### Skill 加载位置
|
|
171
|
+
|
|
172
|
+
CodeMini CLI 会从这些位置读取 skill:
|
|
173
|
+
|
|
174
|
+
- 仓库内置 skill:`skills/<name>/SKILL.md`
|
|
175
|
+
- 全局已安装 skill:`<base-config-dir>/skills/<name>/SKILL.md`
|
|
176
|
+
- 项目级旧式 skill:`.coder/skills/<name>/SKILL.md`
|
|
177
|
+
|
|
178
|
+
`base-config-dir` 的解析顺序是:
|
|
179
|
+
|
|
180
|
+
- `CODEMINI_CONFIG_DIR`
|
|
181
|
+
- `COMPANY_CODER_CONFIG_DIR`
|
|
182
|
+
- Windows:`%APPDATA%\\codemini-cli\\`
|
|
183
|
+
- macOS:`~/Library/Preferences/codemini-cli`
|
|
184
|
+
- Linux / XDG:`$XDG_CONFIG_HOME/codemini-cli`
|
|
185
|
+
- 受限环境回退:`.codemini-cli/`
|
|
186
|
+
|
|
129
187
|
### 文档入口
|
|
130
188
|
|
|
131
189
|
- 操作手册与常见命令组合:[OPERATIONS.md](/mnt/e/Git%20Projects/qurio-coder/OPERATIONS.md)
|
package/package.json
CHANGED
package/src/core/agent-loop.js
CHANGED
|
@@ -61,6 +61,24 @@ function summarizeToolResult(result) {
|
|
|
61
61
|
if (stderr) return `${lead}exit ${obj.code ?? 0}\nstderr: ${stderr}`;
|
|
62
62
|
return `${lead}exit ${obj.code ?? 0}`;
|
|
63
63
|
}
|
|
64
|
+
if ('task_id' in obj && 'startup_confirmed' in obj) {
|
|
65
|
+
const status = trimInline(obj.status || 'unknown', 32);
|
|
66
|
+
const taskId = trimInline(obj.task_id || '', 24);
|
|
67
|
+
const source = trimInline(obj.startup_source || '', 24);
|
|
68
|
+
const logs = Array.isArray(obj.recent_logs) ? trimInline(obj.recent_logs.slice(-1)[0] || '', 96) : '';
|
|
69
|
+
return `${taskId || 'service'} ${status}${source ? ` (${source})` : ''}${logs ? `\n${logs}` : ''}`;
|
|
70
|
+
}
|
|
71
|
+
if ('services' in obj && Array.isArray(obj.services)) {
|
|
72
|
+
const count = obj.services.length;
|
|
73
|
+
const first = obj.services[0];
|
|
74
|
+
const lead = first?.task_id ? `${trimInline(first.task_id, 24)} ${trimInline(first.status || 'unknown', 24)}` : '';
|
|
75
|
+
return `services(${count})${lead ? `\n${lead}` : ''}`;
|
|
76
|
+
}
|
|
77
|
+
if ('task_id' in obj && 'recent_logs' in obj) {
|
|
78
|
+
const taskId = trimInline(obj.task_id || '', 24);
|
|
79
|
+
const logs = Array.isArray(obj.recent_logs) ? trimInline(obj.recent_logs.slice(-1)[0] || '', 96) : '';
|
|
80
|
+
return `${taskId || 'service logs'}${logs ? `\n${logs}` : ''}`;
|
|
81
|
+
}
|
|
64
82
|
if ('created' in obj && Array.isArray(obj.created)) {
|
|
65
83
|
return `created ${obj.created.length} task(s)`;
|
|
66
84
|
}
|
|
@@ -96,6 +114,17 @@ function formatToolDisplayName(name, args) {
|
|
|
96
114
|
const command = trimInline(args?.command || '', 96);
|
|
97
115
|
return command ? `${name}(${command})` : name;
|
|
98
116
|
}
|
|
117
|
+
if (name === 'start_service') {
|
|
118
|
+
const command = trimInline(args?.command || args?.cmd || '', 96);
|
|
119
|
+
return command ? `${name}(${command})` : name;
|
|
120
|
+
}
|
|
121
|
+
if (name === 'list_services') {
|
|
122
|
+
return name;
|
|
123
|
+
}
|
|
124
|
+
if (name === 'get_service_status' || name === 'get_service_logs' || name === 'stop_service') {
|
|
125
|
+
const taskId = trimInline(args?.task_id || args?.taskId || '', 96);
|
|
126
|
+
return taskId ? `${name}(${taskId})` : name;
|
|
127
|
+
}
|
|
99
128
|
if (
|
|
100
129
|
name === 'locate' ||
|
|
101
130
|
name === 'open_target' ||
|
package/src/core/config-store.js
CHANGED
|
@@ -46,6 +46,11 @@ const DEFAULT_CONFIG = {
|
|
|
46
46
|
'insert_before',
|
|
47
47
|
'insert_after',
|
|
48
48
|
'generate_diff',
|
|
49
|
+
'start_service',
|
|
50
|
+
'list_services',
|
|
51
|
+
'get_service_status',
|
|
52
|
+
'get_service_logs',
|
|
53
|
+
'stop_service',
|
|
49
54
|
'run_command',
|
|
50
55
|
'read_file',
|
|
51
56
|
'write_file'
|
|
@@ -141,6 +146,11 @@ function normalizePolicyLists(config) {
|
|
|
141
146
|
'insert_before',
|
|
142
147
|
'insert_after',
|
|
143
148
|
'generate_diff',
|
|
149
|
+
'start_service',
|
|
150
|
+
'list_services',
|
|
151
|
+
'get_service_status',
|
|
152
|
+
'get_service_logs',
|
|
153
|
+
'stop_service',
|
|
144
154
|
'run_command',
|
|
145
155
|
'read_file',
|
|
146
156
|
'write_file',
|
|
@@ -118,5 +118,5 @@ export function getEffectivePolicy(config) {
|
|
|
118
118
|
|
|
119
119
|
export function getShellSystemPrompt(value) {
|
|
120
120
|
const profile = getShellProfile(value);
|
|
121
|
-
return `You are CodeMini CLI working in a ${profile.label} shell environment. Prefer the high-level structured workflow first: use locate to find candidates, open_target to inspect the smallest useful block and receive edit metadata, and edit_target to apply minimal edits. When you need lower-level control, use search_code, read_block, read_symbol_context, validate_edit, replace_block, replace_text, insert_before, insert_after, and generate_diff. Use run_command only
|
|
121
|
+
return `You are CodeMini CLI working in a ${profile.label} shell environment. Prefer the high-level structured workflow first: use locate to find candidates, open_target to inspect the smallest useful block and receive edit metadata, and edit_target to apply minimal edits. When you need lower-level control, use search_code, read_block, read_symbol_context, validate_edit, replace_block, replace_text, insert_before, insert_after, and generate_diff. Use start_service, list_services, get_service_status, get_service_logs, and stop_service for long-running servers or watchers. Use run_command only for one-shot commands that should exit on their own. Use read_file only when structured reads are not enough. Use write_file only for full-file writes and always provide a concrete file path, not a directory. Avoid unnecessary tool calls.`;
|
|
122
122
|
}
|
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');
|
|
@@ -1071,7 +1382,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1071
1382
|
type: 'function',
|
|
1072
1383
|
function: {
|
|
1073
1384
|
name: 'run_command',
|
|
1074
|
-
description: 'Execute a shell command in workspace',
|
|
1385
|
+
description: 'Execute a one-shot shell command in workspace. Do not use for long-running services.',
|
|
1075
1386
|
parameters: {
|
|
1076
1387
|
type: 'object',
|
|
1077
1388
|
properties: {
|
|
@@ -1080,6 +1391,88 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1080
1391
|
required: ['command']
|
|
1081
1392
|
}
|
|
1082
1393
|
}
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
type: 'function',
|
|
1397
|
+
function: {
|
|
1398
|
+
name: 'start_service',
|
|
1399
|
+
description: 'Start a long-running local service and return a compact service handle instead of blocking on process exit.',
|
|
1400
|
+
parameters: {
|
|
1401
|
+
type: 'object',
|
|
1402
|
+
properties: {
|
|
1403
|
+
command: { type: 'string' },
|
|
1404
|
+
startup_timeout_ms: { type: 'number' },
|
|
1405
|
+
success_matchers: {
|
|
1406
|
+
type: 'array',
|
|
1407
|
+
items: { type: 'string' }
|
|
1408
|
+
},
|
|
1409
|
+
port_probe: { type: 'number' },
|
|
1410
|
+
http_probe: {
|
|
1411
|
+
type: 'object',
|
|
1412
|
+
properties: {
|
|
1413
|
+
url: { type: 'string' },
|
|
1414
|
+
expect_status: { type: 'number' }
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
1418
|
+
required: ['command']
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
{
|
|
1423
|
+
type: 'function',
|
|
1424
|
+
function: {
|
|
1425
|
+
name: 'list_services',
|
|
1426
|
+
description: 'List all tracked local services and their compact current status.',
|
|
1427
|
+
parameters: {
|
|
1428
|
+
type: 'object',
|
|
1429
|
+
properties: {}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
type: 'function',
|
|
1435
|
+
function: {
|
|
1436
|
+
name: 'get_service_status',
|
|
1437
|
+
description: 'Get the current status of a previously started service.',
|
|
1438
|
+
parameters: {
|
|
1439
|
+
type: 'object',
|
|
1440
|
+
properties: {
|
|
1441
|
+
task_id: { type: 'string' }
|
|
1442
|
+
},
|
|
1443
|
+
required: ['task_id']
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
},
|
|
1447
|
+
{
|
|
1448
|
+
type: 'function',
|
|
1449
|
+
function: {
|
|
1450
|
+
name: 'get_service_logs',
|
|
1451
|
+
description: 'Read recent logs from a previously started service.',
|
|
1452
|
+
parameters: {
|
|
1453
|
+
type: 'object',
|
|
1454
|
+
properties: {
|
|
1455
|
+
task_id: { type: 'string' },
|
|
1456
|
+
tail: { type: 'number' },
|
|
1457
|
+
after_cursor: { type: 'number' }
|
|
1458
|
+
},
|
|
1459
|
+
required: ['task_id']
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
type: 'function',
|
|
1465
|
+
function: {
|
|
1466
|
+
name: 'stop_service',
|
|
1467
|
+
description: 'Stop a previously started service.',
|
|
1468
|
+
parameters: {
|
|
1469
|
+
type: 'object',
|
|
1470
|
+
properties: {
|
|
1471
|
+
task_id: { type: 'string' }
|
|
1472
|
+
},
|
|
1473
|
+
required: ['task_id']
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1083
1476
|
}
|
|
1084
1477
|
];
|
|
1085
1478
|
|
|
@@ -1096,6 +1489,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
|
|
|
1096
1489
|
insert_before: (args) => insertRelative(workspaceRoot, args, 'insert_before'),
|
|
1097
1490
|
insert_after: (args) => insertRelative(workspaceRoot, args, 'insert_after'),
|
|
1098
1491
|
generate_diff: (args) => generateDiff(workspaceRoot, args),
|
|
1492
|
+
start_service: (args) => startService(workspaceRoot, config, args),
|
|
1493
|
+
list_services: () => listServices(workspaceRoot),
|
|
1494
|
+
get_service_status: (args) => getServiceStatus(workspaceRoot, args),
|
|
1495
|
+
get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
|
|
1496
|
+
stop_service: (args) => stopService(workspaceRoot, args),
|
|
1099
1497
|
read_file: (args) =>
|
|
1100
1498
|
readFile(workspaceRoot, {
|
|
1101
1499
|
...args,
|
package/src/tui/chat-app.js
CHANGED
|
@@ -316,6 +316,11 @@ function getActivityDisplayParts(activity) {
|
|
|
316
316
|
read_file: 'Read',
|
|
317
317
|
write_file: 'Write',
|
|
318
318
|
run_command: 'Command',
|
|
319
|
+
start_service: 'Service',
|
|
320
|
+
list_services: 'Service',
|
|
321
|
+
get_service_status: 'Service',
|
|
322
|
+
get_service_logs: 'Service',
|
|
323
|
+
stop_service: 'Service',
|
|
319
324
|
list_files: 'Glob',
|
|
320
325
|
create_task: 'Task',
|
|
321
326
|
update_task: 'Task'
|
|
@@ -357,6 +362,13 @@ function describeToolActivity(name, copy, { done = false, blocked = false } = {}
|
|
|
357
362
|
? `${copy.toolActivity.doneCommand}: ${safeTarget || 'run_command'}`
|
|
358
363
|
: `${copy.toolActivity.doingCommand}: ${safeTarget || 'run_command'}`;
|
|
359
364
|
}
|
|
365
|
+
if (base === 'start_service' || base === 'list_services' || base === 'get_service_status' || base === 'get_service_logs' || base === 'stop_service') {
|
|
366
|
+
return blocked
|
|
367
|
+
? `${copy.toolActivity.blocked}: ${safeTarget || base}`
|
|
368
|
+
: done
|
|
369
|
+
? `${copy.toolActivity.doneGeneric}: ${safeTarget || base}`
|
|
370
|
+
: `${copy.toolActivity.doingGeneric}: ${safeTarget || base}`;
|
|
371
|
+
}
|
|
360
372
|
if (base === 'create_task') {
|
|
361
373
|
return blocked ? `${copy.toolActivity.blocked}: create_task` : done ? copy.toolActivity.doneCreateTask : copy.toolActivity.doingCreateTask;
|
|
362
374
|
}
|
|
@@ -472,9 +484,13 @@ function PlanStrip({ planState, copy }) {
|
|
|
472
484
|
...planState.steps.slice(-4).map((step, idx) =>
|
|
473
485
|
h(
|
|
474
486
|
Box,
|
|
475
|
-
{ key: `plan-step-${idx}
|
|
487
|
+
{ key: `plan-step-${idx}`, marginTop: idx === 0 ? 0 : 1 },
|
|
476
488
|
h(Text, { color: step.status === 'active' ? 'cyanBright' : step.status === 'failed' ? 'redBright' : 'gray' }, `${step.status === 'active' ? '>' : step.status === 'failed' ? 'x' : '·'} `),
|
|
477
|
-
h(Text, { color: step.status === 'active' ? '
|
|
489
|
+
h(Text, { color: step.status === 'active' ? 'yellowBright' : step.status === 'failed' ? 'redBright' : 'gray' }, `${step.index}/${step.total}`),
|
|
490
|
+
h(Text, { color: 'gray' }, ' '),
|
|
491
|
+
h(Text, { color: step.status === 'active' ? 'magentaBright' : step.status === 'failed' ? 'redBright' : 'gray' }, String(step.role || 'agent').toUpperCase()),
|
|
492
|
+
h(Text, { color: 'gray' }, ' '),
|
|
493
|
+
h(Text, { color: step.status === 'active' ? 'white' : step.status === 'failed' ? 'redBright' : 'gray' }, step.title)
|
|
478
494
|
)
|
|
479
495
|
)
|
|
480
496
|
)
|
|
@@ -583,6 +599,39 @@ export function parseAutoPlanSummaryMessage(text) {
|
|
|
583
599
|
return parsed;
|
|
584
600
|
}
|
|
585
601
|
|
|
602
|
+
export function parsePlanProgressLine(text) {
|
|
603
|
+
const raw = String(text || '').trim();
|
|
604
|
+
const match = raw.match(/^\[plan\]\s+Step\s+(\d+)\/(\d+)\s+->\s+([^:]+):\s+(.+)$/i);
|
|
605
|
+
if (!match) return null;
|
|
606
|
+
return {
|
|
607
|
+
current: Number(match[1]),
|
|
608
|
+
total: Number(match[2]),
|
|
609
|
+
role: String(match[3] || '').trim(),
|
|
610
|
+
title: String(match[4] || '').trim()
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function injectPlanStateMessage(messages, planState, activeUserMessageId, activeAssistantId) {
|
|
615
|
+
const source = Array.isArray(messages) ? messages : [];
|
|
616
|
+
if (!planState || !planState.total) return source;
|
|
617
|
+
const synthetic = {
|
|
618
|
+
id: `plan-state-${planState.current}-${planState.total}-${planState.role || 'agent'}`,
|
|
619
|
+
label: 'system',
|
|
620
|
+
planStrip: true,
|
|
621
|
+
planState
|
|
622
|
+
};
|
|
623
|
+
const withNoPlanStrip = source.filter((message) => !message?.planStrip);
|
|
624
|
+
const userIdx = withNoPlanStrip.findIndex((message) => message.id === activeUserMessageId);
|
|
625
|
+
if (userIdx !== -1) {
|
|
626
|
+
return [...withNoPlanStrip.slice(0, userIdx + 1), synthetic, ...withNoPlanStrip.slice(userIdx + 1)];
|
|
627
|
+
}
|
|
628
|
+
const assistantIdx = withNoPlanStrip.findIndex((message) => message.id === activeAssistantId);
|
|
629
|
+
if (assistantIdx !== -1) {
|
|
630
|
+
return [...withNoPlanStrip.slice(0, assistantIdx), synthetic, ...withNoPlanStrip.slice(assistantIdx)];
|
|
631
|
+
}
|
|
632
|
+
return [...withNoPlanStrip, synthetic];
|
|
633
|
+
}
|
|
634
|
+
|
|
586
635
|
function PlanSummaryBubble({ msg, copy }) {
|
|
587
636
|
const theme = roleStyle(msg.label);
|
|
588
637
|
const summary = msg.planSummary || parseAutoPlanSummaryMessage(msg.text);
|
|
@@ -760,6 +809,114 @@ function pushWrappedRow(rows, baseRow, contentWidth) {
|
|
|
760
809
|
});
|
|
761
810
|
}
|
|
762
811
|
|
|
812
|
+
function isActivityRow(row) {
|
|
813
|
+
return row?.kind === 'activity' || row?.kind === 'activity-summary';
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function isBlankTextRow(row) {
|
|
817
|
+
return row?.kind === 'text' && String(row?.text || '').trim() === '';
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function normalizeActivitySpacingRows(inputRows) {
|
|
821
|
+
const rows = Array.isArray(inputRows) ? inputRows : [];
|
|
822
|
+
const normalized = [];
|
|
823
|
+
|
|
824
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
825
|
+
const row = rows[index];
|
|
826
|
+
const prev = normalized.at(-1);
|
|
827
|
+
const next = rows[index + 1];
|
|
828
|
+
|
|
829
|
+
if (isBlankTextRow(row)) {
|
|
830
|
+
let lookahead = index + 1;
|
|
831
|
+
while (lookahead < rows.length && isBlankTextRow(rows[lookahead])) {
|
|
832
|
+
lookahead += 1;
|
|
833
|
+
}
|
|
834
|
+
if (isActivityRow(rows[lookahead])) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (isBlankTextRow(row) && isActivityRow(next)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
normalized.push(row);
|
|
844
|
+
|
|
845
|
+
if (isActivityRow(row) && !isActivityRow(next) && next) {
|
|
846
|
+
const last = normalized.at(-1);
|
|
847
|
+
if (!isBlankTextRow(last) && !(next.kind === 'status')) {
|
|
848
|
+
normalized.push({
|
|
849
|
+
kind: 'text',
|
|
850
|
+
text: ' ',
|
|
851
|
+
color: 'white'
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (isBlankTextRow(row) && isBlankTextRow(prev)) {
|
|
857
|
+
normalized.pop();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return normalized;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function isReadActivityName(name) {
|
|
865
|
+
const parsed = parseToolDisplayName(name);
|
|
866
|
+
return parsed.base === 'read_file' || parsed.base === 'Read';
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function isIgnorableSegmentAfterRead(item, activityType, activityName) {
|
|
870
|
+
if (!item) return true;
|
|
871
|
+
if (item.type === 'text') {
|
|
872
|
+
return String(item.text || '').trim() === '';
|
|
873
|
+
}
|
|
874
|
+
return (item.type || 'tool') === activityType && item.name === activityName;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function findActivityUpdateIndex(items, toolEvent) {
|
|
878
|
+
const source = Array.isArray(items) ? items : [];
|
|
879
|
+
const activityType = toolEvent?.type || 'tool';
|
|
880
|
+
const byId = toolEvent?.id
|
|
881
|
+
? source.findIndex((item) => item.type === activityType && item.id && item.id === toolEvent.id)
|
|
882
|
+
: -1;
|
|
883
|
+
if (byId !== -1) return byId;
|
|
884
|
+
|
|
885
|
+
const byNameRunning = source.findIndex(
|
|
886
|
+
(item) => (item.type || 'tool') === activityType && item.name === toolEvent?.name && item.status !== 'done'
|
|
887
|
+
);
|
|
888
|
+
if (byNameRunning !== -1) return byNameRunning;
|
|
889
|
+
|
|
890
|
+
if (isReadActivityName(toolEvent?.name)) {
|
|
891
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
892
|
+
const item = source[index];
|
|
893
|
+
if ((item?.type || 'tool') !== activityType || item?.name !== toolEvent?.name) continue;
|
|
894
|
+
const trailing = source.slice(index + 1);
|
|
895
|
+
if (trailing.every((entry) => isIgnorableSegmentAfterRead(entry, activityType, toolEvent?.name))) {
|
|
896
|
+
return index;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return -1;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function mergeActivitySummary(previousSummary, nextSummary, activityName) {
|
|
905
|
+
const prev = String(previousSummary || '').trim();
|
|
906
|
+
const next = String(nextSummary || '').trim();
|
|
907
|
+
if (!next) return prev;
|
|
908
|
+
if (!prev) return next;
|
|
909
|
+
if (!isReadActivityName(activityName) || prev === next) return next;
|
|
910
|
+
|
|
911
|
+
const lines = [];
|
|
912
|
+
for (const line of `${prev}\n${next}`.split('\n')) {
|
|
913
|
+
const trimmed = String(line || '').trim();
|
|
914
|
+
if (!trimmed) continue;
|
|
915
|
+
if (!lines.includes(trimmed)) lines.push(trimmed);
|
|
916
|
+
}
|
|
917
|
+
return lines.join('\n');
|
|
918
|
+
}
|
|
919
|
+
|
|
763
920
|
function buildMessageRows(msg, showToolDetails, contentWidth = 72) {
|
|
764
921
|
const rows = [];
|
|
765
922
|
const pushTextRows = (text) => {
|
|
@@ -767,6 +924,17 @@ function buildMessageRows(msg, showToolDetails, contentWidth = 72) {
|
|
|
767
924
|
let codeFence = false;
|
|
768
925
|
for (const line of lines) {
|
|
769
926
|
const trimmed = line.trim();
|
|
927
|
+
const planProgress = parsePlanProgressLine(trimmed);
|
|
928
|
+
if (planProgress) {
|
|
929
|
+
rows.push({
|
|
930
|
+
kind: 'plan-progress',
|
|
931
|
+
current: planProgress.current,
|
|
932
|
+
total: planProgress.total,
|
|
933
|
+
role: planProgress.role,
|
|
934
|
+
title: trimText(planProgress.title, Math.max(12, contentWidth - 18))
|
|
935
|
+
});
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
770
938
|
if (trimmed.startsWith('```')) {
|
|
771
939
|
codeFence = !codeFence;
|
|
772
940
|
continue;
|
|
@@ -843,7 +1011,7 @@ function buildMessageRows(msg, showToolDetails, contentWidth = 72) {
|
|
|
843
1011
|
);
|
|
844
1012
|
}
|
|
845
1013
|
|
|
846
|
-
return rows;
|
|
1014
|
+
return normalizeActivitySpacingRows(rows);
|
|
847
1015
|
}
|
|
848
1016
|
|
|
849
1017
|
|
|
@@ -945,6 +1113,13 @@ export function moveSuggestionSelection(currentIndex, itemCount, direction, page
|
|
|
945
1113
|
}
|
|
946
1114
|
|
|
947
1115
|
function MessageBubble({ msg, loaderTick, showToolDetails, rowWindow = null, contentWidth = 72, copy }) {
|
|
1116
|
+
if (msg?.planStrip) {
|
|
1117
|
+
return h(
|
|
1118
|
+
Box,
|
|
1119
|
+
{ marginBottom: 1 },
|
|
1120
|
+
h(PlanStrip, { planState: msg.planState, copy })
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
948
1123
|
if (msg?.planSummary || parseAutoPlanSummaryMessage(msg?.text)) {
|
|
949
1124
|
return h(PlanSummaryBubble, { msg, copy });
|
|
950
1125
|
}
|
|
@@ -991,6 +1166,18 @@ function MessageBubble({ msg, loaderTick, showToolDetails, rowWindow = null, con
|
|
|
991
1166
|
h(Text, { color: 'gray' }, `└ ${row.text}`)
|
|
992
1167
|
);
|
|
993
1168
|
}
|
|
1169
|
+
if (row.kind === 'plan-progress') {
|
|
1170
|
+
return h(
|
|
1171
|
+
Box,
|
|
1172
|
+
{ key: `row-plan-progress-${msg.id}-${idx}`, marginTop: 1, marginBottom: 1 },
|
|
1173
|
+
h(Text, { color: 'cyanBright' }, '[plan] '),
|
|
1174
|
+
h(Text, { color: 'yellowBright' }, `Step ${row.current}/${row.total}`),
|
|
1175
|
+
h(Text, { color: 'gray' }, ' -> '),
|
|
1176
|
+
h(Text, { color: 'magentaBright' }, String(row.role || 'agent').toUpperCase()),
|
|
1177
|
+
h(Text, { color: 'gray' }, ': '),
|
|
1178
|
+
h(Text, { color: 'white' }, row.title)
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
994
1181
|
if (row.kind === 'status') {
|
|
995
1182
|
const dots = '.'.repeat((loaderTick % 3) + 1);
|
|
996
1183
|
const phase = msg.phase;
|
|
@@ -1456,13 +1643,7 @@ export function ChatApp({ runtime, sessionId, model, language = 'zh', shellName
|
|
|
1456
1643
|
if (m.id !== targetId) return m;
|
|
1457
1644
|
const toolCalls = Array.isArray(m.toolCalls) ? [...m.toolCalls] : [];
|
|
1458
1645
|
const activityType = toolEvent.type || 'tool';
|
|
1459
|
-
const
|
|
1460
|
-
? toolCalls.findIndex((t) => t.type === activityType && t.id && t.id === toolEvent.id)
|
|
1461
|
-
: -1;
|
|
1462
|
-
const byNameRunning = toolCalls.findIndex(
|
|
1463
|
-
(t) => (t.type || 'tool') === activityType && t.name === toolEvent.name && t.status !== 'done'
|
|
1464
|
-
);
|
|
1465
|
-
const idx = byId !== -1 ? byId : byNameRunning;
|
|
1646
|
+
const idx = findActivityUpdateIndex(toolCalls, toolEvent);
|
|
1466
1647
|
|
|
1467
1648
|
if (idx === -1) {
|
|
1468
1649
|
toolCalls.push({
|
|
@@ -1480,17 +1661,13 @@ export function ChatApp({ runtime, sessionId, model, language = 'zh', shellName
|
|
|
1480
1661
|
id: toolEvent.id || toolCalls[idx].id,
|
|
1481
1662
|
status: toolEvent.status,
|
|
1482
1663
|
...(toolEvent.durationMs !== undefined ? { durationMs: toolEvent.durationMs } : {}),
|
|
1483
|
-
...(toolEvent.summary
|
|
1664
|
+
...(toolEvent.summary
|
|
1665
|
+
? { summary: mergeActivitySummary(toolCalls[idx].summary, toolEvent.summary, toolEvent.name) }
|
|
1666
|
+
: {})
|
|
1484
1667
|
};
|
|
1485
1668
|
}
|
|
1486
1669
|
const segments = Array.isArray(m.segments) ? [...m.segments] : [];
|
|
1487
|
-
const
|
|
1488
|
-
? segments.findIndex((segment) => segment.type === activityType && segment.id === toolEvent.id)
|
|
1489
|
-
: -1;
|
|
1490
|
-
const bySegmentName = segments.findIndex(
|
|
1491
|
-
(segment) => segment.type === activityType && segment.name === toolEvent.name && segment.status !== 'done'
|
|
1492
|
-
);
|
|
1493
|
-
const segmentIdx = bySegmentId !== -1 ? bySegmentId : bySegmentName;
|
|
1670
|
+
const segmentIdx = findActivityUpdateIndex(segments, toolEvent);
|
|
1494
1671
|
const patch = {
|
|
1495
1672
|
type: activityType,
|
|
1496
1673
|
id: toolEvent.id || '',
|
|
@@ -1504,7 +1681,10 @@ export function ChatApp({ runtime, sessionId, model, language = 'zh', shellName
|
|
|
1504
1681
|
} else {
|
|
1505
1682
|
segments[segmentIdx] = {
|
|
1506
1683
|
...segments[segmentIdx],
|
|
1507
|
-
...patch
|
|
1684
|
+
...patch,
|
|
1685
|
+
...(toolEvent.summary
|
|
1686
|
+
? { summary: mergeActivitySummary(segments[segmentIdx].summary, toolEvent.summary, toolEvent.name) }
|
|
1687
|
+
: {})
|
|
1508
1688
|
};
|
|
1509
1689
|
}
|
|
1510
1690
|
return { ...m, toolCalls, segments };
|
|
@@ -2192,16 +2372,21 @@ export function ChatApp({ runtime, sessionId, model, language = 'zh', shellName
|
|
|
2192
2372
|
const hasConversationStarted = messages.some((m) =>
|
|
2193
2373
|
['you', 'coder', 'pending', 'error'].includes(m.label)
|
|
2194
2374
|
);
|
|
2195
|
-
const
|
|
2375
|
+
const baseVisibleMessages = hasConversationStarted
|
|
2196
2376
|
? messages.filter((m) => !(m.label === 'system' && m.text === startupHint))
|
|
2197
2377
|
: messages;
|
|
2378
|
+
const visibleMessages = injectPlanStateMessage(
|
|
2379
|
+
baseVisibleMessages,
|
|
2380
|
+
planState,
|
|
2381
|
+
activeUserMessageIdRef.current,
|
|
2382
|
+
activeAssistantIdRef.current
|
|
2383
|
+
);
|
|
2198
2384
|
|
|
2199
2385
|
return h(
|
|
2200
2386
|
Box,
|
|
2201
2387
|
{ flexDirection: 'column' },
|
|
2202
2388
|
h(Header, { sessionId: displaySessionId, model: displayModel, shellName }),
|
|
2203
2389
|
h(RuntimeStrip, { busy, runtimeStatus, loaderTick, copy }),
|
|
2204
|
-
h(PlanStrip, { planState, copy }),
|
|
2205
2390
|
h(MessageList, {
|
|
2206
2391
|
messages: visibleMessages,
|
|
2207
2392
|
loaderTick,
|