mocode-ai 0.6.10 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/README.zh-CN.md +3 -3
- package/dist/agent/core.js +162 -108
- package/dist/agent/index.js +20 -0
- package/dist/agent/spawn.js +9 -1
- package/dist/config/index.js +27 -5
- package/dist/i18n/index.js +34 -0
- package/dist/index.js +1 -1
- package/dist/llm/index.js +6 -2
- package/dist/mcp/index.js +15 -2
- package/dist/permissions/index.js +110 -93
- package/dist/repl/index.js +111 -21
- package/dist/rollback/index.js +31 -0
- package/dist/session/index.js +1 -0
- package/dist/session/persist.js +13 -5
- package/dist/session/trace.js +26 -0
- package/dist/tools/builtins/index.js +35 -4
- package/dist/tools/builtins/run-command.js +115 -66
- package/dist/tools/builtins/task.js +5 -2
- package/dist/tools/constants.js +5 -1
- package/dist/tools/registry.js +108 -25
- package/dist/ui/prompt.js +64 -11
- package/dist/verification/affected.js +149 -0
- package/dist/verification/discovery.js +40 -0
- package/dist/verification/index.js +163 -0
- package/dist/verification/profile.js +237 -0
- package/dist/verification/types.js +1 -0
- package/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
|
-
import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
|
|
3
|
+
import { getSandboxRoot, filterEnv, isCommandDenied, jailResolve } from '../../sandbox/index.js';
|
|
4
4
|
import { t } from '../../i18n/index.js';
|
|
5
5
|
const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
|
|
6
6
|
const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
|
|
@@ -25,6 +25,119 @@ class BoundedCommandOutput {
|
|
|
25
25
|
return `${this.head}\n${t('command.outputTruncated', { count: removed })}\n${this.tail}`;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
|
|
29
|
+
export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const deny = isCommandDenied(command);
|
|
32
|
+
if (deny) {
|
|
33
|
+
return { status: 'denied', exitCode: null, output: `错误:${deny}`, durationMs: 0 };
|
|
34
|
+
}
|
|
35
|
+
let executionCwd = getSandboxRoot() ?? process.cwd();
|
|
36
|
+
if (cwd) {
|
|
37
|
+
try {
|
|
38
|
+
executionCwd = jailResolve(cwd);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
return { status: 'denied', exitCode: null, output: `错误:${message}`, durationMs: 0 };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new Promise((done) => {
|
|
46
|
+
const isWin = process.platform === 'win32';
|
|
47
|
+
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/d', '/s', '/c', command] : ['-c', command], {
|
|
48
|
+
cwd: executionCwd,
|
|
49
|
+
env: filterEnv(process.env),
|
|
50
|
+
// Without this, Node re-quotes cmd.exe arguments and `node -e "..."` can become
|
|
51
|
+
// a string literal that exits 0, causing false-positive validation on Windows.
|
|
52
|
+
windowsVerbatimArguments: isWin,
|
|
53
|
+
});
|
|
54
|
+
const output = new BoundedCommandOutput();
|
|
55
|
+
let finished = false;
|
|
56
|
+
let timer;
|
|
57
|
+
const killTree = () => {
|
|
58
|
+
try {
|
|
59
|
+
if (isWin) {
|
|
60
|
+
if (child.pid != null) {
|
|
61
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
child.kill('SIGTERM');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Process already exited or best-effort termination failed.
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const finish = (result) => {
|
|
73
|
+
if (finished)
|
|
74
|
+
return;
|
|
75
|
+
finished = true;
|
|
76
|
+
if (timer)
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
signal?.removeEventListener('abort', onAbort);
|
|
79
|
+
done({ ...result, durationMs: Date.now() - startedAt });
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
killTree();
|
|
83
|
+
finish({ status: 'aborted', exitCode: null, output: output.render().trim() });
|
|
84
|
+
};
|
|
85
|
+
const onChunk = (chunk) => output.append(chunk.toString('utf8'));
|
|
86
|
+
child.stdout.on('data', onChunk);
|
|
87
|
+
child.stderr.on('data', onChunk);
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
finish({ status: 'spawn_error', exitCode: null, output: error.message });
|
|
90
|
+
});
|
|
91
|
+
child.on('close', (code) => {
|
|
92
|
+
finish({
|
|
93
|
+
status: code === 0 ? 'passed' : 'failed',
|
|
94
|
+
exitCode: code,
|
|
95
|
+
output: output.render().trim(),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
timer = setTimeout(() => {
|
|
99
|
+
killTree();
|
|
100
|
+
finish({ status: 'timed_out', exitCode: null, output: output.render().trim() });
|
|
101
|
+
}, timeout);
|
|
102
|
+
if (signal) {
|
|
103
|
+
if (signal.aborted)
|
|
104
|
+
onAbort();
|
|
105
|
+
else
|
|
106
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Preserve the public run_command text protocol while exposing structured status internally. */
|
|
111
|
+
export function formatCommandResult(result) {
|
|
112
|
+
const output = result.output.trim();
|
|
113
|
+
if (result.status === 'denied')
|
|
114
|
+
return result.output;
|
|
115
|
+
if (result.status === 'aborted')
|
|
116
|
+
return `${t('command.interrupted')}\n${output}`;
|
|
117
|
+
if (result.status === 'timed_out')
|
|
118
|
+
return `${t('command.timedOut')}\n${output}`;
|
|
119
|
+
if (result.status === 'spawn_error')
|
|
120
|
+
return t('command.executionFailed', { message: result.output });
|
|
121
|
+
return `${t('command.exitCode', { code: result.exitCode ?? 'null' })}\n${output || t('toolSummary.noOutput')}`;
|
|
122
|
+
}
|
|
123
|
+
/** Convert the raw process status into the common structured tool contract. */
|
|
124
|
+
function commandOutcome(result) {
|
|
125
|
+
const output = formatCommandResult(result);
|
|
126
|
+
switch (result.status) {
|
|
127
|
+
case 'passed':
|
|
128
|
+
return { status: 'success', code: 'OK', retryable: false, output, durationMs: result.durationMs };
|
|
129
|
+
case 'aborted':
|
|
130
|
+
return { status: 'aborted', code: 'ABORTED', retryable: false, output, durationMs: result.durationMs };
|
|
131
|
+
case 'denied':
|
|
132
|
+
return { status: 'denied', code: 'SANDBOX_DENIED', retryable: false, output, durationMs: result.durationMs };
|
|
133
|
+
case 'timed_out':
|
|
134
|
+
return { status: 'error', code: 'TIMEOUT', retryable: false, output, durationMs: result.durationMs };
|
|
135
|
+
case 'failed':
|
|
136
|
+
return { status: 'error', code: 'PROCESS_FAILED', retryable: false, output, durationMs: result.durationMs };
|
|
137
|
+
case 'spawn_error':
|
|
138
|
+
return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output, durationMs: result.durationMs };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
28
141
|
// ---------- run_command ----------
|
|
29
142
|
export const runCommandTool = {
|
|
30
143
|
name: 'run_command',
|
|
@@ -41,70 +154,6 @@ export const runCommandTool = {
|
|
|
41
154
|
async execute(args, ctx) {
|
|
42
155
|
const command = String(args.command);
|
|
43
156
|
const timeout = Number(args.timeout ?? 120000);
|
|
44
|
-
|
|
45
|
-
const deny = isCommandDenied(command);
|
|
46
|
-
if (deny)
|
|
47
|
-
return `错误:${deny}`;
|
|
48
|
-
return new Promise((done) => {
|
|
49
|
-
const isWin = process.platform === 'win32';
|
|
50
|
-
// 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
|
|
51
|
-
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
|
|
52
|
-
const output = new BoundedCommandOutput();
|
|
53
|
-
let finished = false;
|
|
54
|
-
let timer;
|
|
55
|
-
// 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
|
|
56
|
-
// 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
|
|
57
|
-
const killTree = () => {
|
|
58
|
-
try {
|
|
59
|
-
if (isWin) {
|
|
60
|
-
if (child.pid != null) {
|
|
61
|
-
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
62
|
-
stdio: 'ignore',
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
child.kill('SIGTERM');
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
// 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
// abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
|
|
75
|
-
const onAbort = () => {
|
|
76
|
-
killTree();
|
|
77
|
-
finish(`${t('command.interrupted')}\n${output.render().trim()}`);
|
|
78
|
-
};
|
|
79
|
-
const finish = (s) => {
|
|
80
|
-
if (finished)
|
|
81
|
-
return;
|
|
82
|
-
finished = true;
|
|
83
|
-
clearTimeout(timer);
|
|
84
|
-
ctx?.signal?.removeEventListener('abort', onAbort);
|
|
85
|
-
done(s);
|
|
86
|
-
};
|
|
87
|
-
const onChunk = (chunk) => {
|
|
88
|
-
output.append(chunk.toString('utf8'));
|
|
89
|
-
};
|
|
90
|
-
child.stdout.on('data', onChunk);
|
|
91
|
-
child.stderr.on('data', onChunk);
|
|
92
|
-
child.on('error', (e) => finish(t('command.executionFailed', { message: e.message })));
|
|
93
|
-
child.on('close', (code) => {
|
|
94
|
-
const result = output.render().trim();
|
|
95
|
-
finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
|
|
96
|
-
});
|
|
97
|
-
timer = setTimeout(() => {
|
|
98
|
-
killTree();
|
|
99
|
-
finish(`${t('command.timedOut')}\n${output.render().trim()}`);
|
|
100
|
-
}, timeout);
|
|
101
|
-
// 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
|
|
102
|
-
if (ctx?.signal) {
|
|
103
|
-
if (ctx.signal.aborted)
|
|
104
|
-
onAbort();
|
|
105
|
-
else
|
|
106
|
-
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
107
|
-
}
|
|
108
|
-
});
|
|
157
|
+
return commandOutcome(await runCommandRaw(command, timeout, ctx?.signal));
|
|
109
158
|
},
|
|
110
159
|
};
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawnAgent } from '../../agent/spawn.js';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
3
|
import { t } from '../../i18n/index.js';
|
|
4
|
+
import { isSubAgentEnabled } from '../../config/index.js';
|
|
4
5
|
// ---------- task ----------
|
|
5
6
|
// 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
|
|
6
7
|
// 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// 适用:分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
|
|
10
|
+
// 多个 task 在共享工作区期间由 capability scheduler 串行执行;隔离 workspace 落地后再开放并行写。
|
|
10
11
|
export const taskTool = {
|
|
11
12
|
name: 'task',
|
|
12
13
|
risk: 'dangerous',
|
|
@@ -35,6 +36,8 @@ export const taskTool = {
|
|
|
35
36
|
required: ['prompt'],
|
|
36
37
|
},
|
|
37
38
|
async execute(args, ctx) {
|
|
39
|
+
if (!isSubAgentEnabled())
|
|
40
|
+
return t('task.disabled');
|
|
38
41
|
const prompt = String(args.prompt ?? '');
|
|
39
42
|
if (!prompt)
|
|
40
43
|
return t('task.missingPrompt');
|
package/dist/tools/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** 工具共享的截断 / 上限 / 忽略规则。 */
|
|
2
|
-
import { isMemoryEnabled } from '../config/index.js';
|
|
2
|
+
import { isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
|
|
3
3
|
export const MAX_FILE_LINES = 2000;
|
|
4
4
|
export const MAX_OUTPUT = 20000;
|
|
5
5
|
export const MAX_RESULTS = 100;
|
|
@@ -54,3 +54,7 @@ export function getPlanDisabledTools() {
|
|
|
54
54
|
next.delete('memory_forget');
|
|
55
55
|
return next;
|
|
56
56
|
}
|
|
57
|
+
/** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
|
|
58
|
+
export function getRuntimeDisabledTools() {
|
|
59
|
+
return isSubAgentEnabled() ? new Set() : new Set(['task']);
|
|
60
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { builtinTools } from './builtins/index.js';
|
|
2
|
-
import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, } from '../rollback/index.js';
|
|
2
|
+
import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
|
|
3
3
|
import { enforceSandbox } from '../sandbox/index.js';
|
|
4
4
|
import { t } from '../i18n/index.js';
|
|
5
|
+
import { isToolErrorOutput } from './result.js';
|
|
5
6
|
/**
|
|
6
7
|
* 可扩展工具注册表。数组实例始终稳定,使已经持有 tools 引用的 agent/LLM 能看到运行时新增工具。
|
|
7
8
|
* 扩展按 source 替换,MCP 重连或配置刷新不会累积旧工具。
|
|
@@ -33,40 +34,103 @@ function rebuildTools() {
|
|
|
33
34
|
}
|
|
34
35
|
tools.splice(0, tools.length, ...next);
|
|
35
36
|
}
|
|
37
|
+
const DEFAULT_CAPABILITIES = Object.freeze({
|
|
38
|
+
effect: 'unknown',
|
|
39
|
+
concurrency: 'serial',
|
|
40
|
+
retry: 'never',
|
|
41
|
+
});
|
|
42
|
+
export function findTool(name) {
|
|
43
|
+
return tools.find((tool) => tool.name === name);
|
|
44
|
+
}
|
|
45
|
+
/** 缺少声明或找不到工具时返回保守能力,绝不把未知扩展并发执行。 */
|
|
46
|
+
export function getToolCapabilities(toolOrName) {
|
|
47
|
+
const tool = typeof toolOrName === 'string' ? findTool(toolOrName) : toolOrName;
|
|
48
|
+
return tool?.capabilities ?? DEFAULT_CAPABILITIES;
|
|
49
|
+
}
|
|
50
|
+
export function getToolResourceKeys(toolOrName, args) {
|
|
51
|
+
const capabilities = getToolCapabilities(toolOrName);
|
|
52
|
+
try {
|
|
53
|
+
return capabilities.resources?.(args) ?? [];
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** resource-locked write 是可生成文件 diff/按路径记 rollback 的文件 mutation。 */
|
|
60
|
+
export function isFileMutationTool(name) {
|
|
61
|
+
const capabilities = getToolCapabilities(name);
|
|
62
|
+
return capabilities.effect === 'write' && capabilities.concurrency === 'resource-locked';
|
|
63
|
+
}
|
|
64
|
+
function isStructuredOutcome(value) {
|
|
65
|
+
return typeof value === 'object' && value !== null &&
|
|
66
|
+
typeof value.status === 'string' && typeof value.code === 'string' &&
|
|
67
|
+
typeof value.retryable === 'boolean' && typeof value.output === 'string';
|
|
68
|
+
}
|
|
69
|
+
function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
|
|
70
|
+
if (isStructuredOutcome(value)) {
|
|
71
|
+
return {
|
|
72
|
+
...value,
|
|
73
|
+
durationMs: value.durationMs ?? durationMs,
|
|
74
|
+
changedFiles: value.changedFiles ?? changedFiles,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const failed = isToolErrorOutput(value);
|
|
78
|
+
return {
|
|
79
|
+
status: failed ? 'error' : 'success',
|
|
80
|
+
code: failed ? 'EXECUTION_ERROR' : 'OK',
|
|
81
|
+
retryable: failed && capabilities.retry !== 'never',
|
|
82
|
+
output: value,
|
|
83
|
+
changedFiles,
|
|
84
|
+
durationMs,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
|
|
88
|
+
return {
|
|
89
|
+
status,
|
|
90
|
+
code,
|
|
91
|
+
retryable: false,
|
|
92
|
+
output,
|
|
93
|
+
changedFiles,
|
|
94
|
+
durationMs: Date.now() - startedAt,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
36
97
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
|
|
40
|
-
* opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
|
|
98
|
+
* 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
|
|
99
|
+
* 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
|
|
41
100
|
*/
|
|
42
|
-
export async function
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return t('
|
|
101
|
+
export async function executeToolOutcome(name, argsRaw, signal, opts) {
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
105
|
+
}
|
|
106
|
+
const tool = findTool(name);
|
|
107
|
+
if (!tool) {
|
|
108
|
+
return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
|
|
109
|
+
}
|
|
46
110
|
let args;
|
|
47
111
|
try {
|
|
48
112
|
args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
49
113
|
}
|
|
50
114
|
catch {
|
|
51
|
-
return t('toolError.invalidJson', { name, arguments: argsRaw });
|
|
115
|
+
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
52
116
|
}
|
|
117
|
+
const capabilities = getToolCapabilities(tool);
|
|
118
|
+
const mutationBefore = getCurrentTurnMutationState();
|
|
53
119
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const pathCapture = (name
|
|
59
|
-
typeof args.path === 'string' &&
|
|
60
|
-
args.path
|
|
120
|
+
const sandboxError = enforceSandbox(name, args);
|
|
121
|
+
if (sandboxError) {
|
|
122
|
+
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
123
|
+
}
|
|
124
|
+
const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
|
|
61
125
|
? beginPathMutation(args.path)
|
|
62
126
|
: null;
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
const workspaceCapture = name === 'run_command' || name.startsWith('mcp__')
|
|
127
|
+
// 进程和未知扩展可能间接改动任意文件;已声明 write 的非文件工具自行管理其状态。
|
|
128
|
+
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
66
129
|
? beginWorkspaceMutation()
|
|
67
130
|
: null;
|
|
131
|
+
let raw;
|
|
68
132
|
try {
|
|
69
|
-
|
|
133
|
+
raw = await tool.execute(args, {
|
|
70
134
|
signal,
|
|
71
135
|
dropContext: opts?.dropContext,
|
|
72
136
|
});
|
|
@@ -77,11 +141,30 @@ export async function executeTool(name, argsRaw, signal, opts) {
|
|
|
77
141
|
if (workspaceCapture)
|
|
78
142
|
endWorkspaceMutation(workspaceCapture, name);
|
|
79
143
|
}
|
|
144
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
145
|
+
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
146
|
+
? mutationAfter.changedFiles.map((item) => item.path)
|
|
147
|
+
: [];
|
|
148
|
+
if (signal?.aborted) {
|
|
149
|
+
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
150
|
+
}
|
|
151
|
+
return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
|
|
80
152
|
}
|
|
81
|
-
catch (
|
|
82
|
-
|
|
153
|
+
catch (error) {
|
|
154
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
155
|
+
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
156
|
+
? mutationAfter.changedFiles.map((item) => item.path)
|
|
157
|
+
: [];
|
|
158
|
+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
159
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
|
|
160
|
+
}
|
|
161
|
+
return terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
|
|
83
162
|
name,
|
|
84
|
-
message:
|
|
85
|
-
});
|
|
163
|
+
message: error instanceof Error ? error.message : String(error),
|
|
164
|
+
}), startedAt, changedFiles);
|
|
86
165
|
}
|
|
87
166
|
}
|
|
167
|
+
/** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
|
|
168
|
+
export async function executeTool(name, argsRaw, signal, opts) {
|
|
169
|
+
return (await executeToolOutcome(name, argsRaw, signal, opts)).output;
|
|
170
|
+
}
|
package/dist/ui/prompt.js
CHANGED
|
@@ -90,6 +90,59 @@ export async function promptWithSlashMenu(opts) {
|
|
|
90
90
|
let resolved = false;
|
|
91
91
|
let resolve;
|
|
92
92
|
let reject;
|
|
93
|
+
const queryHistory = opts.queryHistory ?? [];
|
|
94
|
+
let historyIndex = queryHistory.length; // length = 草稿哨兵;0..length-1 = 历史项
|
|
95
|
+
let historyDraft = null;
|
|
96
|
+
/** 用历史 query 替换编辑缓冲;历史内容恢复为普通可编辑文本。 */
|
|
97
|
+
function loadHistoryEntry(value) {
|
|
98
|
+
lines = value.split('\n');
|
|
99
|
+
cl = lines.length - 1;
|
|
100
|
+
cc = lines[cl].length;
|
|
101
|
+
chip = null;
|
|
102
|
+
chipPre = '';
|
|
103
|
+
selected = 0;
|
|
104
|
+
menuTop = 0;
|
|
105
|
+
justSawCR = false;
|
|
106
|
+
computeFiltered();
|
|
107
|
+
redraw();
|
|
108
|
+
}
|
|
109
|
+
/** 在首次离开最新位置时保存完整草稿,然后向更旧的 query 移动。 */
|
|
110
|
+
function recallPrevious() {
|
|
111
|
+
if (queryHistory.length === 0 || historyIndex <= 0)
|
|
112
|
+
return;
|
|
113
|
+
if (historyIndex === queryHistory.length) {
|
|
114
|
+
historyDraft = {
|
|
115
|
+
lines: [...lines],
|
|
116
|
+
cl,
|
|
117
|
+
cc,
|
|
118
|
+
chip,
|
|
119
|
+
chipPre,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
historyIndex--;
|
|
123
|
+
loadHistoryEntry(queryHistory[historyIndex]);
|
|
124
|
+
}
|
|
125
|
+
/** 向更新的 query 移动;越过最新历史时恢复进入历史前的草稿。 */
|
|
126
|
+
function recallNext() {
|
|
127
|
+
if (historyIndex >= queryHistory.length)
|
|
128
|
+
return;
|
|
129
|
+
historyIndex++;
|
|
130
|
+
if (historyIndex < queryHistory.length) {
|
|
131
|
+
loadHistoryEntry(queryHistory[historyIndex]);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const draft = historyDraft;
|
|
135
|
+
lines = draft ? [...draft.lines] : [''];
|
|
136
|
+
cl = draft?.cl ?? 0;
|
|
137
|
+
cc = draft?.cc ?? 0;
|
|
138
|
+
chip = draft?.chip ?? null;
|
|
139
|
+
chipPre = draft?.chipPre ?? '';
|
|
140
|
+
selected = 0;
|
|
141
|
+
menuTop = 0;
|
|
142
|
+
justSawCR = false;
|
|
143
|
+
computeFiltered();
|
|
144
|
+
redraw();
|
|
145
|
+
}
|
|
93
146
|
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
|
|
94
147
|
function menuLines() {
|
|
95
148
|
if (!menuOpen || filtered.length === 0)
|
|
@@ -456,6 +509,8 @@ export async function promptWithSlashMenu(opts) {
|
|
|
456
509
|
filtered = [];
|
|
457
510
|
selected = 0;
|
|
458
511
|
menuTop = 0;
|
|
512
|
+
historyIndex = queryHistory.length;
|
|
513
|
+
historyDraft = null;
|
|
459
514
|
if (pasteTimer) {
|
|
460
515
|
clearTimeout(pasteTimer);
|
|
461
516
|
pasteTimer = null;
|
|
@@ -518,19 +573,11 @@ export async function promptWithSlashMenu(opts) {
|
|
|
518
573
|
pasteParts.push(s);
|
|
519
574
|
return;
|
|
520
575
|
}
|
|
521
|
-
// 滚动回看键(优先;不触发回尾):PgUp/PgDn
|
|
522
|
-
//
|
|
523
|
-
// 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
|
|
524
|
-
const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
|
|
525
|
-
!key.ctrl &&
|
|
526
|
-
!key.meta &&
|
|
527
|
-
!key.shift &&
|
|
528
|
-
lines.length <= 1 &&
|
|
529
|
-
!(menuOpen && filtered.length > 0);
|
|
576
|
+
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
|
|
577
|
+
// 裸 ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
|
|
530
578
|
if (key.name === 'pageup' ||
|
|
531
579
|
key.name === 'pagedown' ||
|
|
532
|
-
(key.ctrl && (key.name === 'up' || key.name === 'down'))
|
|
533
|
-
plainArrowScroll) {
|
|
580
|
+
(key.ctrl && (key.name === 'up' || key.name === 'down'))) {
|
|
534
581
|
const pageH = layout.getGeo().contentBottom;
|
|
535
582
|
if (key.name === 'pageup')
|
|
536
583
|
layout.scrollBy(pageH);
|
|
@@ -622,6 +669,9 @@ export async function promptWithSlashMenu(opts) {
|
|
|
622
669
|
cc = Math.min(cc, lines[cl].length);
|
|
623
670
|
redraw();
|
|
624
671
|
}
|
|
672
|
+
else {
|
|
673
|
+
recallPrevious();
|
|
674
|
+
}
|
|
625
675
|
return;
|
|
626
676
|
case 'down':
|
|
627
677
|
if (menuOpen && filtered.length) {
|
|
@@ -633,6 +683,9 @@ export async function promptWithSlashMenu(opts) {
|
|
|
633
683
|
cc = Math.min(cc, lines[cl].length);
|
|
634
684
|
redraw();
|
|
635
685
|
}
|
|
686
|
+
else {
|
|
687
|
+
recallNext();
|
|
688
|
+
}
|
|
636
689
|
return;
|
|
637
690
|
case 'tab':
|
|
638
691
|
if (menuOpen && filtered[selected])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const isWindows = process.platform === 'win32';
|
|
3
|
+
const ROOT_CONFIG_NAMES = new Set([
|
|
4
|
+
'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml',
|
|
5
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
6
|
+
]);
|
|
7
|
+
function comparisonKey(value) {
|
|
8
|
+
const resolved = path.resolve(value);
|
|
9
|
+
return isWindows ? resolved.toLowerCase() : resolved;
|
|
10
|
+
}
|
|
11
|
+
function isInside(parent, child) {
|
|
12
|
+
const relative = path.relative(comparisonKey(parent), comparisonKey(child));
|
|
13
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
function toNativeSeparators(value) {
|
|
16
|
+
return value.replace(/[\\/]/g, path.sep);
|
|
17
|
+
}
|
|
18
|
+
function canonicalize(profile, input, base) {
|
|
19
|
+
if (!input.trim() || input.includes('\0'))
|
|
20
|
+
return { input, reason: 'invalid_path' };
|
|
21
|
+
try {
|
|
22
|
+
const absolute = path.resolve(base, toNativeSeparators(input));
|
|
23
|
+
if (!isInside(profile.root, absolute))
|
|
24
|
+
return { input, reason: 'outside_project' };
|
|
25
|
+
const relative = path.relative(profile.root, absolute);
|
|
26
|
+
return {
|
|
27
|
+
absolute,
|
|
28
|
+
key: comparisonKey(absolute),
|
|
29
|
+
display: relative === '' ? '.' : relative.split(path.sep).join('/'),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { input, reason: 'invalid_path' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function matchesAnyRoot(file, roots) {
|
|
37
|
+
return roots.some((root) => isInside(root, file));
|
|
38
|
+
}
|
|
39
|
+
function classify(file, owner) {
|
|
40
|
+
if (matchesAnyRoot(file, owner.fixtureRoots))
|
|
41
|
+
return 'fixture';
|
|
42
|
+
if (matchesAnyRoot(file, owner.generatedRoots))
|
|
43
|
+
return 'generated';
|
|
44
|
+
if (matchesAnyRoot(file, owner.vendorRoots))
|
|
45
|
+
return 'vendor';
|
|
46
|
+
if (matchesAnyRoot(file, owner.testRoots))
|
|
47
|
+
return 'test';
|
|
48
|
+
if (matchesAnyRoot(file, owner.sourceRoots))
|
|
49
|
+
return 'source';
|
|
50
|
+
return 'other';
|
|
51
|
+
}
|
|
52
|
+
function rootConfigReason(profile, file) {
|
|
53
|
+
const workspaceKeys = new Set(profile.workspaceConfigPaths.map(comparisonKey));
|
|
54
|
+
if (workspaceKeys.has(file.key))
|
|
55
|
+
return 'workspace_config_change';
|
|
56
|
+
const rootPackage = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(profile.root));
|
|
57
|
+
const configKeys = new Set([
|
|
58
|
+
...(rootPackage?.tsconfigPaths ?? []),
|
|
59
|
+
...(rootPackage?.testConfigPaths ?? []),
|
|
60
|
+
...(rootPackage?.lintConfigPaths ?? []),
|
|
61
|
+
].map(comparisonKey));
|
|
62
|
+
if (configKeys.has(file.key))
|
|
63
|
+
return 'root_config_change';
|
|
64
|
+
if (comparisonKey(path.dirname(file.absolute)) !== comparisonKey(profile.root))
|
|
65
|
+
return null;
|
|
66
|
+
const name = path.basename(file.absolute).toLowerCase();
|
|
67
|
+
if (ROOT_CONFIG_NAMES.has(name) || /^tsconfig(?:\..+)?\.json$/i.test(name)) {
|
|
68
|
+
return name.startsWith('pnpm-workspace') ? 'workspace_config_change' : 'root_config_change';
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function addReason(selections, packageProfile, reason) {
|
|
73
|
+
const key = comparisonKey(packageProfile.root);
|
|
74
|
+
let selected = selections.get(key);
|
|
75
|
+
if (!selected) {
|
|
76
|
+
selected = { package: packageProfile, reasons: [] };
|
|
77
|
+
selections.set(key, selected);
|
|
78
|
+
}
|
|
79
|
+
if (!selected.reasons.some((item) => item.kind === reason.kind
|
|
80
|
+
&& item.changedPath === reason.changedPath
|
|
81
|
+
&& item.sourcePackage === reason.sourcePackage)) {
|
|
82
|
+
selected.reasons.push(reason);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Map changed paths to their longest matching package roots without touching the filesystem. */
|
|
86
|
+
export function resolveAffectedPackages(profile, changedFiles, options) {
|
|
87
|
+
const canonicalByKey = new Map();
|
|
88
|
+
const rejected = [];
|
|
89
|
+
for (const input of changedFiles) {
|
|
90
|
+
const result = canonicalize(profile, input, path.resolve(options.changedFilesBase));
|
|
91
|
+
if ('reason' in result)
|
|
92
|
+
rejected.push(result);
|
|
93
|
+
else if (!canonicalByKey.has(result.key))
|
|
94
|
+
canonicalByKey.set(result.key, result);
|
|
95
|
+
}
|
|
96
|
+
const canonical = [...canonicalByKey.values()];
|
|
97
|
+
const packageByDepth = [...profile.packages].sort((left, right) => comparisonKey(right.root).length - comparisonKey(left.root).length);
|
|
98
|
+
const selections = new Map();
|
|
99
|
+
const unmatchedFiles = [];
|
|
100
|
+
let affectsAll = false;
|
|
101
|
+
for (const file of canonical) {
|
|
102
|
+
const configReason = rootConfigReason(profile, file);
|
|
103
|
+
if (configReason) {
|
|
104
|
+
affectsAll = true;
|
|
105
|
+
for (const packageProfile of profile.packages) {
|
|
106
|
+
addReason(selections, packageProfile, {
|
|
107
|
+
kind: configReason,
|
|
108
|
+
changedPath: file.display,
|
|
109
|
+
classification: 'other',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const owner = packageByDepth.find((packageProfile) => isInside(packageProfile.root, file.absolute));
|
|
115
|
+
if (!owner) {
|
|
116
|
+
unmatchedFiles.push(file.display);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
addReason(selections, owner, {
|
|
120
|
+
kind: 'direct_change',
|
|
121
|
+
changedPath: file.display,
|
|
122
|
+
classification: classify(file.absolute, owner),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!affectsAll && options.expandDependents && selections.size > 0) {
|
|
126
|
+
const direct = profile.packages.filter((item) => selections.has(comparisonKey(item.root)));
|
|
127
|
+
const sourcePackage = direct.map((item) => item.name).join(', ');
|
|
128
|
+
for (const expanded of options.expandDependents(direct, profile)) {
|
|
129
|
+
const packageProfile = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(expanded.root));
|
|
130
|
+
if (!packageProfile || selections.has(comparisonKey(packageProfile.root)))
|
|
131
|
+
continue;
|
|
132
|
+
addReason(selections, packageProfile, {
|
|
133
|
+
kind: 'dependent_change',
|
|
134
|
+
changedPath: canonical[0]?.display ?? '.',
|
|
135
|
+
classification: 'other',
|
|
136
|
+
sourcePackage,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
packages: profile.packages
|
|
142
|
+
.map((item) => selections.get(comparisonKey(item.root)))
|
|
143
|
+
.filter((item) => item !== undefined),
|
|
144
|
+
canonicalChangedFiles: canonical.map((item) => item.display),
|
|
145
|
+
rejected,
|
|
146
|
+
unmatchedFiles,
|
|
147
|
+
affectsAll,
|
|
148
|
+
};
|
|
149
|
+
}
|