mocode-ai 0.1.4 → 0.1.6
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 +196 -188
- package/dist/agent/core.js +309 -0
- package/dist/agent/index.js +62 -249
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +81 -36
- package/dist/context/classifier.js +83 -0
- package/dist/context/encoders/index.js +10 -0
- package/dist/context/encoders/passthrough.js +23 -0
- package/dist/context/index.js +10 -0
- package/dist/context/pipeline.js +84 -0
- package/dist/context/registry.js +30 -0
- package/dist/context/types.js +12 -0
- package/dist/index.js +12 -2
- package/dist/llm/index.js +11 -9
- package/dist/memory/index.js +2 -2
- package/dist/memory/reflect.js +11 -11
- package/dist/memory/store.js +4 -4
- package/dist/repl/index.js +269 -109
- package/dist/sandbox/command.js +41 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/jail.js +75 -0
- package/dist/sandbox/policy.js +58 -0
- package/dist/sandbox/root.js +19 -0
- package/dist/session/compact.js +1 -1
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +7 -7
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +10 -5
- package/dist/tools/builtins/grep.js +14 -9
- package/dist/tools/builtins/index.js +7 -1
- package/dist/tools/builtins/memory-forget.js +2 -2
- package/dist/tools/builtins/memory-list.js +2 -2
- package/dist/tools/builtins/memory-save.js +7 -7
- package/dist/tools/builtins/memory-search.js +4 -4
- package/dist/tools/builtins/memory-update.js +6 -6
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +47 -8
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +16 -0
- package/dist/tools/registry.js +18 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +24 -3
- package/dist/ui/layout.js +295 -81
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +259 -5
- package/dist/ui/render.js +1 -1
- package/dist/ui/theme.js +146 -13
- package/package.json +2 -2
|
@@ -1,30 +1,62 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
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
4
|
// ---------- run_command ----------
|
|
4
5
|
export const runCommandTool = {
|
|
5
6
|
name: 'run_command',
|
|
6
|
-
description: '
|
|
7
|
+
description: 'Execute a shell command, returning combined stdout+stderr. Default timeout 120s. Use for running tests, builds, git, etc.',
|
|
7
8
|
parameters: {
|
|
8
9
|
type: 'object',
|
|
9
10
|
properties: {
|
|
10
|
-
command: { type: 'string', description: '
|
|
11
|
-
timeout: { type: 'integer', description: '
|
|
11
|
+
command: { type: 'string', description: 'Command to execute (single line)' },
|
|
12
|
+
timeout: { type: 'integer', description: 'Timeout in milliseconds, default 120000' },
|
|
12
13
|
},
|
|
13
14
|
required: ['command'],
|
|
14
15
|
},
|
|
15
|
-
async execute(args) {
|
|
16
|
+
async execute(args, ctx) {
|
|
16
17
|
const command = String(args.command);
|
|
17
18
|
const timeout = Number(args.timeout ?? 120000);
|
|
19
|
+
// 沙箱 best-effort:灾难性文件操作 denylist(非安全边界,只挡误操作;真隔离需 OS jailer)。
|
|
20
|
+
const deny = isCommandDenied(command);
|
|
21
|
+
if (deny)
|
|
22
|
+
return `错误:${deny}`;
|
|
18
23
|
return new Promise((done) => {
|
|
19
24
|
const isWin = process.platform === 'win32';
|
|
20
|
-
|
|
25
|
+
// 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
|
|
26
|
+
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
|
|
21
27
|
let out = '';
|
|
22
28
|
let finished = false;
|
|
29
|
+
let timer;
|
|
30
|
+
// 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
|
|
31
|
+
// 故 Win 用 taskkill /T /F 树杀;Unix child.kill('SIGTERM')(bash -c 通常转发给前台子进程,best-effort)。
|
|
32
|
+
const killTree = () => {
|
|
33
|
+
try {
|
|
34
|
+
if (isWin) {
|
|
35
|
+
if (child.pid != null) {
|
|
36
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
37
|
+
stdio: 'ignore',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
child.kill('SIGTERM');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 进程已退出 / kill 失败:忽略(close 事件会兜底 finish)
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
// abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
|
|
50
|
+
const onAbort = () => {
|
|
51
|
+
killTree();
|
|
52
|
+
finish(`[已中断]\n${out.trim()}`);
|
|
53
|
+
};
|
|
23
54
|
const finish = (s) => {
|
|
24
55
|
if (finished)
|
|
25
56
|
return;
|
|
26
57
|
finished = true;
|
|
27
58
|
clearTimeout(timer);
|
|
59
|
+
ctx?.signal?.removeEventListener('abort', onAbort);
|
|
28
60
|
done(s);
|
|
29
61
|
};
|
|
30
62
|
const onChunk = (chunk) => {
|
|
@@ -40,10 +72,17 @@ export const runCommandTool = {
|
|
|
40
72
|
r += '\n...(输出已截断)';
|
|
41
73
|
finish(`[退出码 ${code}]\n${r || '(无输出)'}`);
|
|
42
74
|
});
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
timer = setTimeout(() => {
|
|
76
|
+
killTree();
|
|
45
77
|
finish(`[超时,已终止]\n${out.trim()}`);
|
|
46
78
|
}, timeout);
|
|
79
|
+
// 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
|
|
80
|
+
if (ctx?.signal) {
|
|
81
|
+
if (ctx.signal.aborted)
|
|
82
|
+
onAbort();
|
|
83
|
+
else
|
|
84
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
}
|
|
47
86
|
});
|
|
48
87
|
},
|
|
49
88
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { setAgentMode, getAgentMode } from '../../agent/mode.js';
|
|
2
|
+
// ---------- switch_mode ----------
|
|
3
|
+
export const switchModeTool = {
|
|
4
|
+
name: 'switch_mode',
|
|
5
|
+
description: [
|
|
6
|
+
'Switch the agent mode between "plan" (read-only investigation + planning) and "auto" (full tool execution).',
|
|
7
|
+
'In plan mode, write_file / edit_file / run_command / memory_save / memory_update / memory_forget are disabled; in auto mode, all tools are available.',
|
|
8
|
+
'Use this to autonomously transition from planning to execution WITHIN THE SAME TURN: investigate in plan mode, produce a plan, then call switch_mode("auto") and continue implementing it in the same turn — ONLY when the user has explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution.',
|
|
9
|
+
'Do NOT switch to auto if the user entered plan mode manually (via /plan or Shift+Tab) for a safety review — in that case present the plan and STOP; the user will approve via a prompt and execution happens in a follow-up turn.',
|
|
10
|
+
'Switching to plan from auto is rarely needed; do it only if you realize you should investigate before changing anything.',
|
|
11
|
+
].join(' '),
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
mode: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
enum: ['auto', 'plan'],
|
|
18
|
+
description: 'Target mode: "plan" (read-only) or "auto" (full tools)',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ['mode'],
|
|
22
|
+
},
|
|
23
|
+
async execute(args) {
|
|
24
|
+
const target = String(args.mode);
|
|
25
|
+
if (target !== 'auto' && target !== 'plan') {
|
|
26
|
+
return `错误:mode 必须是 "auto" 或 "plan",收到 "${target}"`;
|
|
27
|
+
}
|
|
28
|
+
const prev = getAgentMode();
|
|
29
|
+
if (prev === target) {
|
|
30
|
+
return `已在 ${target} 模式,无需切换。`;
|
|
31
|
+
}
|
|
32
|
+
setAgentMode(target); // 触发 listener:repl 重写 history[0] 系统提示 + 刷状态行 modeTag
|
|
33
|
+
if (target === 'auto') {
|
|
34
|
+
return [
|
|
35
|
+
`已从 plan 切换到 auto 模式。`,
|
|
36
|
+
`写盘 / 命令 / 记忆写入工具(write_file / edit_file / run_command / memory_save / memory_update / memory_forget)现已恢复可用。`,
|
|
37
|
+
`请按你刚才产出的计划继续执行(同一轮内,无需重述计划)。`,
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
`已从 auto 切换到 plan 模式(只读探查)。`,
|
|
42
|
+
`写盘 / 命令 / 记忆写入工具已禁用;只用 read_file / glob / grep / codegraph / web_search / web_fetch / use_skill / ask_human / memory_search / memory_list 探查。`,
|
|
43
|
+
`产出计划后 STOP 等用户审批,或(若用户要求自主执行)再调 switch_mode("auto") 续跑。`,
|
|
44
|
+
].join('\n');
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawnAgent } from '../../agent/spawn.js';
|
|
2
|
+
import { MAX_OUTPUT } from '../constants.js';
|
|
3
|
+
// ---------- task ----------
|
|
4
|
+
// 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
|
|
5
|
+
// 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
|
|
6
|
+
//
|
|
7
|
+
// 适用:并行探查多个文件 / 分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
|
|
8
|
+
// 子 agent 中间过程不写主屏,只返回最终摘要;主 agent 据摘要决定下一步。
|
|
9
|
+
export const taskTool = {
|
|
10
|
+
name: 'task',
|
|
11
|
+
description: [
|
|
12
|
+
'Spawn a sub-agent to handle an isolated sub-task with its own conversation history (independent of the main thread). The sub-agent runs to completion and returns a concise summary.',
|
|
13
|
+
'Use when: a task can be decomposed into independent sub-tasks, you want to explore multiple files/areas without polluting the main history, or a sub-task involves many tool calls that would bloat the main context window.',
|
|
14
|
+
'The sub-agent has its own history; only its final summary is returned to you as the tool result. It cannot recursively spawn further sub-agents (no "task" tool available to it).',
|
|
15
|
+
'Optionally restrict the sub-agent to a subset of tools (e.g. read-only tools for pure investigation) via the "tools" parameter.',
|
|
16
|
+
].join(''),
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
prompt: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'The sub-task instruction for the sub-agent. Be specific about what to investigate/implement and what to report back.',
|
|
23
|
+
},
|
|
24
|
+
tools: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: { type: 'string' },
|
|
27
|
+
description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools.',
|
|
28
|
+
},
|
|
29
|
+
maxSteps: {
|
|
30
|
+
type: 'number',
|
|
31
|
+
description: 'Optional step limit for the sub-agent (default 50). Lower if the sub-task should be quick.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ['prompt'],
|
|
35
|
+
},
|
|
36
|
+
async execute(args, ctx) {
|
|
37
|
+
const prompt = String(args.prompt ?? '');
|
|
38
|
+
if (!prompt)
|
|
39
|
+
return '错误:缺少 prompt(子任务指令)。';
|
|
40
|
+
const tools = Array.isArray(args.tools)
|
|
41
|
+
? args.tools.map((t) => String(t))
|
|
42
|
+
: undefined;
|
|
43
|
+
const maxSteps = typeof args.maxSteps === 'number' && args.maxSteps > 0
|
|
44
|
+
? Math.floor(args.maxSteps)
|
|
45
|
+
: undefined;
|
|
46
|
+
// 透传主 agent 的 abort signal:主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
|
|
47
|
+
const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal });
|
|
48
|
+
if (!result.completed) {
|
|
49
|
+
return '子 agent 被中断,未完成。';
|
|
50
|
+
}
|
|
51
|
+
if (!result.summary) {
|
|
52
|
+
return '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。';
|
|
53
|
+
}
|
|
54
|
+
// 摘要可能很长,截到 MAX_OUTPUT 保主 history 不爆。
|
|
55
|
+
const summary = result.summary;
|
|
56
|
+
if (summary.length > MAX_OUTPUT) {
|
|
57
|
+
return (summary.slice(0, MAX_OUTPUT) +
|
|
58
|
+
`\n\n…(子 agent 摘要已截断 ${summary.length - MAX_OUTPUT} 字符)`);
|
|
59
|
+
}
|
|
60
|
+
return summary;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -4,13 +4,13 @@ import { getSkillBody } from '../../skills/index.js';
|
|
|
4
4
|
// 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
|
|
5
5
|
export const useSkillTool = {
|
|
6
6
|
name: 'use_skill',
|
|
7
|
-
description: '
|
|
7
|
+
description: 'Load and return the full SKILL.md instructions for a given skill (pass name). See the skill list in the system prompt for when to use each.',
|
|
8
8
|
parameters: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
11
11
|
name: {
|
|
12
12
|
type: 'string',
|
|
13
|
-
description: '
|
|
13
|
+
description: 'Name of the skill to load (see the skill list in the system prompt, or the /skills command)',
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
16
|
required: ['name'],
|
|
@@ -4,15 +4,15 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
|
|
|
4
4
|
// ---------- web_fetch ----------
|
|
5
5
|
export const webFetchTool = {
|
|
6
6
|
name: 'web_fetch',
|
|
7
|
-
description: '
|
|
7
|
+
description: 'Fetch the web page at a given URL and clean it into plain text (strips HTML tags/scripts/styles, keeps the body). Use to read a link from search results, or a specific URL given by the user.',
|
|
8
8
|
parameters: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
11
|
-
url: { type: 'string', description: '
|
|
11
|
+
url: { type: 'string', description: 'Full URL to fetch; must be http/https' },
|
|
12
12
|
},
|
|
13
13
|
required: ['url'],
|
|
14
14
|
},
|
|
15
|
-
async execute(args) {
|
|
15
|
+
async execute(args, ctx) {
|
|
16
16
|
const rawUrl = String(args.url ?? '').trim();
|
|
17
17
|
if (!rawUrl)
|
|
18
18
|
return '错误:url 不能为空。';
|
|
@@ -28,6 +28,15 @@ export const webFetchTool = {
|
|
|
28
28
|
}
|
|
29
29
|
const ctrl = new AbortController();
|
|
30
30
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
31
|
+
// 外部 abort signal(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 合并到 ctrl,fetch 即时取消(不等 30s 超时)
|
|
32
|
+
const onExternalAbort = () => ctrl.abort();
|
|
33
|
+
const externalSignal = ctx?.signal;
|
|
34
|
+
if (externalSignal) {
|
|
35
|
+
if (externalSignal.aborted)
|
|
36
|
+
ctrl.abort();
|
|
37
|
+
else
|
|
38
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
39
|
+
}
|
|
31
40
|
try {
|
|
32
41
|
const resp = await fetch(url.href, {
|
|
33
42
|
method: 'GET',
|
|
@@ -56,6 +65,8 @@ export const webFetchTool = {
|
|
|
56
65
|
}
|
|
57
66
|
catch (e) {
|
|
58
67
|
if (ctrl.signal.aborted) {
|
|
68
|
+
if (externalSignal?.aborted)
|
|
69
|
+
return `错误:已中断: ${url.href}`;
|
|
59
70
|
return `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`;
|
|
60
71
|
}
|
|
61
72
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -63,6 +74,8 @@ export const webFetchTool = {
|
|
|
63
74
|
}
|
|
64
75
|
finally {
|
|
65
76
|
clearTimeout(timer);
|
|
77
|
+
if (externalSignal)
|
|
78
|
+
externalSignal.removeEventListener('abort', onExternalAbort);
|
|
66
79
|
}
|
|
67
80
|
},
|
|
68
81
|
};
|
|
@@ -6,20 +6,20 @@ const MAX_CONTENT_CHARS = 800;
|
|
|
6
6
|
// ---------- web_search ----------
|
|
7
7
|
export const webSearchTool = {
|
|
8
8
|
name: 'web_search',
|
|
9
|
-
description: '
|
|
9
|
+
description: 'Search the web (AnySearch). Returns title/url/snippet/body for each result. Optional tag to switch sub-domain capability (see tag param).',
|
|
10
10
|
parameters: {
|
|
11
11
|
type: 'object',
|
|
12
12
|
properties: {
|
|
13
|
-
query: { type: 'string', description: '
|
|
14
|
-
max_results: { type: 'integer', description: '
|
|
13
|
+
query: { type: 'string', description: 'Search query' },
|
|
14
|
+
max_results: { type: 'integer', description: 'Number of results, 1-20, default 10' },
|
|
15
15
|
tag: {
|
|
16
16
|
type: 'string',
|
|
17
|
-
description: '
|
|
17
|
+
description: 'Sub-domain capability tag, e.g. general.general / code.doc / code.snippet; omit for general search',
|
|
18
18
|
},
|
|
19
|
-
language: { type: 'string', description: '
|
|
19
|
+
language: { type: 'string', description: 'Preferred language, e.g. zh-CN / en, default zh-CN' },
|
|
20
20
|
params: {
|
|
21
21
|
type: 'object',
|
|
22
|
-
description: '
|
|
22
|
+
description: 'Extended params for a specific tag, e.g. {"library":"golang"} for code.doc; not needed for general search',
|
|
23
23
|
},
|
|
24
24
|
},
|
|
25
25
|
required: ['query'],
|
|
@@ -3,12 +3,12 @@ import { resolve, dirname } from 'node:path';
|
|
|
3
3
|
// ---------- write_file ----------
|
|
4
4
|
export const writeFileTool = {
|
|
5
5
|
name: 'write_file',
|
|
6
|
-
description: '
|
|
6
|
+
description: 'Create or overwrite a file, creating parent directories as needed.',
|
|
7
7
|
parameters: {
|
|
8
8
|
type: 'object',
|
|
9
9
|
properties: {
|
|
10
|
-
path: { type: 'string', description: '
|
|
11
|
-
content: { type: 'string', description: '
|
|
10
|
+
path: { type: 'string', description: 'File path' },
|
|
11
|
+
content: { type: 'string', description: 'Full file content' },
|
|
12
12
|
},
|
|
13
13
|
required: ['path', 'content'],
|
|
14
14
|
},
|
package/dist/tools/constants.js
CHANGED
|
@@ -22,3 +22,19 @@ export const GC_DAYS = 90;
|
|
|
22
22
|
/** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
|
|
23
23
|
export const MAX_MEMORY_RESULT = 64000;
|
|
24
24
|
export const IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
25
|
+
// ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
|
|
28
|
+
* 写盘 / 命令 / 记忆写入类 + task(派生子 agent,plan 模式只读不可有副作用)。单一事实源,
|
|
29
|
+
* 被 llm(planChatTools)与 agent(防御 backstop)共用。
|
|
30
|
+
* 只读工具(read_file/glob/grep/codegraph/web_search/web_fetch/use_skill/ask_human/memory_search/memory_list)保留。
|
|
31
|
+
*/
|
|
32
|
+
export const PLAN_DISABLED_TOOLS = new Set([
|
|
33
|
+
'write_file',
|
|
34
|
+
'edit_file',
|
|
35
|
+
'run_command',
|
|
36
|
+
'memory_save',
|
|
37
|
+
'memory_update',
|
|
38
|
+
'memory_forget',
|
|
39
|
+
'task',
|
|
40
|
+
]);
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { builtinTools } from './builtins/index.js';
|
|
2
2
|
import { recordMutation } from '../rollback/index.js';
|
|
3
|
+
import { enforceSandbox } from '../sandbox/index.js';
|
|
3
4
|
/**
|
|
4
5
|
* 工具注册表。当前 = 内置工具;
|
|
5
6
|
* 未来可在此合并 MCP 工具、用户自定义工具等(见 src/mcp/)。
|
|
6
7
|
*/
|
|
7
8
|
export const tools = builtinTools;
|
|
8
|
-
/**
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* 按名调度工具,统一 try/catch + JSON 解析,返回字符串而非抛错。
|
|
11
|
+
* signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
|
|
12
|
+
* 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
|
|
13
|
+
* opts.skipRollback:子 agent 逻辑隔离用——跳过 recordMutation,子 agent 改动不进主回滚快照链。
|
|
14
|
+
*/
|
|
15
|
+
export async function executeTool(name, argsRaw, signal, opts) {
|
|
10
16
|
const tool = tools.find((t) => t.name === name);
|
|
11
17
|
if (!tool)
|
|
12
18
|
return `错误:未知工具 "${name}"`;
|
|
@@ -18,13 +24,21 @@ export async function executeTool(name, argsRaw) {
|
|
|
18
24
|
return `错误:工具 ${name} 的 arguments 不是合法 JSON: ${argsRaw}`;
|
|
19
25
|
}
|
|
20
26
|
try {
|
|
27
|
+
// 沙箱:路径类工具(读/写/改)越界拒绝 + args.path 重写为牢内绝对;glob/grep pattern 校验。
|
|
28
|
+
// 返 string = 拒绝(直接喂 LLM,不执行);返 null = 放行(可能已重写 args.path)。
|
|
29
|
+
// 须在 recordMutation 前:让快照路径 = 牢内绝对路径,与回滚一致。不抛(契约:调度器永不抛错)。
|
|
30
|
+
const sbErr = enforceSandbox(name, args);
|
|
31
|
+
if (sbErr)
|
|
32
|
+
return sbErr;
|
|
21
33
|
// 撤销回滚用:write_file/edit_file 改动前记 before 快照(回滚时恢复到轮末状态)。
|
|
22
|
-
|
|
34
|
+
// 子 agent(skipRollback)跳过:其改动不进主回滚链,主 /rollback 不撤销(靠 git 兜底)。
|
|
35
|
+
if (!opts?.skipRollback &&
|
|
36
|
+
(name === 'write_file' || name === 'edit_file') &&
|
|
23
37
|
typeof args.path === 'string' &&
|
|
24
38
|
args.path) {
|
|
25
39
|
recordMutation(args.path);
|
|
26
40
|
}
|
|
27
|
-
return await tool.execute(args);
|
|
41
|
+
return await tool.execute(args, { signal, skipRollback: opts?.skipRollback });
|
|
28
42
|
}
|
|
29
43
|
catch (e) {
|
|
30
44
|
return `错误:工具 ${name} 执行失败: ${e instanceof Error ? e.message : String(e)}`;
|
package/dist/ui/content.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 内容物理行缓冲(Phase 2 滚动回滚用)。
|
|
3
3
|
*
|
|
4
4
|
* 存折行后的物理行,每行自洽带色,供 layout.repaintViewport 单行直出、无需继承 SGR 状态。
|
|
5
|
-
* contentWrite 逐字符喂入(feedChar / feedSgr / breakRow);
|
|
5
|
+
* contentWrite 逐字符喂入(feedChar / feedSgr / breakRow);setLines 替换当前段(md 流式渲染用)。
|
|
6
6
|
*
|
|
7
7
|
* **SGR 自洽模型**:每行存 `rowStartSgr + curRaw + \x1B[0m`:
|
|
8
8
|
* - rowStartSgr:本行起始时继承的活跃 SGR 前缀(来自上一行末状态);
|
|
@@ -23,7 +23,7 @@ let curSgr = ''; // 当前活跃 SGR 状态(行内演进;行末值 = 下行继
|
|
|
23
23
|
let rowStartSgr = ''; // 当前行起始继承的 SGR 前缀
|
|
24
24
|
let curRaw = ''; // 当前行原始字节(可见 + 行内 SGR 码)
|
|
25
25
|
let hasCurrent = false; // 是否有当前行(feed/breakRow 后;新空行也算)
|
|
26
|
-
let segMark = null; // 段起点快照(
|
|
26
|
+
let segMark = null; // 段起点快照(md 段:setLines 截断用,commitSegment 清)
|
|
27
27
|
export function reset() {
|
|
28
28
|
rows = [];
|
|
29
29
|
curSgr = '';
|
|
@@ -55,27 +55,35 @@ export function breakRow() {
|
|
|
55
55
|
rowStartSgr = curSgr; // 下行继承本行末状态
|
|
56
56
|
hasCurrent = true; // 新空行即当前行
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
58
|
+
/** 标记段起点:快照当前缓冲状态,供 setLines 截断定位段头(md 流式渲染每 chunk 截断重渲)。 */
|
|
59
59
|
export function beginSegment() {
|
|
60
60
|
segMark = { rowIdx: rows.length, rowStartSgr, curSgr, curRaw, hasCurrent };
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* 用预渲染的自洽 ANSI 行替换当前段(从 segMark 起),供 markdown 流式渲染:
|
|
64
|
+
* layout.contentWriteMd 每 chunk 把累积 text 经 renderMarkdown 渲成自洽行,调此替换缓冲段。
|
|
65
|
+
* 截断 rows 到 segMark.rowIdx(擦上次渲染)+ push 新行;curSgr 复位为默认(行末 reset);
|
|
66
|
+
* **保留 segMark**(下次 setLines 还要截断),由 commitSegment 清除。
|
|
67
|
+
* 行须各自自洽(行内 SGR 自带、行末 reset)——不经 feedChar/feedSgr,直接入 rows,
|
|
68
|
+
* 故 setLines 后 hasCurrent=false(snapshot 只返 rows;续写位由 layout 跟踪)。
|
|
66
69
|
*/
|
|
67
|
-
export function
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
export function setLines(lines) {
|
|
71
|
+
if (segMark) {
|
|
72
|
+
rows.length = segMark.rowIdx;
|
|
73
|
+
}
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
rows.push(line);
|
|
76
|
+
}
|
|
77
|
+
if (rows.length > MAX_ROWS + 512)
|
|
78
|
+
rows.splice(0, rows.length - MAX_ROWS);
|
|
79
|
+
curSgr = '';
|
|
80
|
+
rowStartSgr = '';
|
|
81
|
+
curRaw = '';
|
|
82
|
+
hasCurrent = false;
|
|
83
|
+
}
|
|
84
|
+
/** 提交段:清 segMark,后续写入不再被 setLines 截断(md 段结束、非 md 内容接续时调)。 */
|
|
85
|
+
export function commitSegment() {
|
|
77
86
|
segMark = null;
|
|
78
|
-
return committedErased + currentErased;
|
|
79
87
|
}
|
|
80
88
|
/** 快照(已提交行 + 当前行若有)。viewport 取行窗用。 */
|
|
81
89
|
function snapshot() {
|
package/dist/ui/diff.js
CHANGED
|
@@ -36,35 +36,37 @@ function langForPath(p) {
|
|
|
36
36
|
}
|
|
37
37
|
return LANG_BY_EXT[base];
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* 包一层:token 文本 + 颜色 + reset,自成闭区间(防 SGR 跨行泄漏)。
|
|
41
|
+
* 取 ColorKey 而非 ANSI 串——闭包调用时才读 `ui[key]`,故 setTheme 后 diff 高亮即跟随。 */
|
|
42
|
+
const paint = (key) => (s) => `${ui[key]}${s}${ui.reset}`;
|
|
41
43
|
/** 自定义主题:default=plain(未匹配文本不着色,避免默认主题把普通代码染黄),常见 token 用 mocode ui 色板。 */
|
|
42
44
|
const THEME = {
|
|
43
45
|
default: plain,
|
|
44
|
-
keyword: paint(
|
|
45
|
-
'built_in': paint(
|
|
46
|
-
type: paint(
|
|
47
|
-
literal: paint(
|
|
48
|
-
number: paint(
|
|
49
|
-
string: paint(
|
|
50
|
-
subst: paint(
|
|
51
|
-
comment: paint(
|
|
52
|
-
doctag: paint(
|
|
53
|
-
function: paint(
|
|
54
|
-
title: paint(
|
|
55
|
-
class: paint(
|
|
56
|
-
meta: paint(
|
|
57
|
-
'meta-keyword': paint(
|
|
58
|
-
regexp: paint(
|
|
59
|
-
attr: paint(
|
|
60
|
-
attribute: paint(
|
|
61
|
-
variable: paint(
|
|
62
|
-
tag: paint(
|
|
63
|
-
name: paint(
|
|
64
|
-
symbol: paint(
|
|
65
|
-
section: paint(
|
|
66
|
-
addition: paint(
|
|
67
|
-
deletion: paint(
|
|
46
|
+
keyword: paint('magenta'),
|
|
47
|
+
'built_in': paint('cyan'),
|
|
48
|
+
type: paint('cyan'),
|
|
49
|
+
literal: paint('cyan'),
|
|
50
|
+
number: paint('green'),
|
|
51
|
+
string: paint('green'),
|
|
52
|
+
subst: paint('green'),
|
|
53
|
+
comment: paint('gray'),
|
|
54
|
+
doctag: paint('gray'),
|
|
55
|
+
function: paint('blue'),
|
|
56
|
+
title: paint('blue'),
|
|
57
|
+
class: paint('brightCyan'),
|
|
58
|
+
meta: paint('gray'),
|
|
59
|
+
'meta-keyword': paint('magenta'),
|
|
60
|
+
regexp: paint('red'),
|
|
61
|
+
attr: paint('cyan'),
|
|
62
|
+
attribute: paint('cyan'),
|
|
63
|
+
variable: paint('red'),
|
|
64
|
+
tag: paint('red'),
|
|
65
|
+
name: paint('cyan'),
|
|
66
|
+
symbol: paint('cyan'),
|
|
67
|
+
section: paint('brightMagenta'),
|
|
68
|
+
addition: paint('green'),
|
|
69
|
+
deletion: paint('red'),
|
|
68
70
|
};
|
|
69
71
|
function highlightLine(text, lang) {
|
|
70
72
|
if (!lang || text === '')
|
package/dist/ui/intervention.js
CHANGED
|
@@ -3,6 +3,7 @@ import { stdin, stderr } from 'node:process';
|
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import { displayWidth, truncateDisplay } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
|
+
import * as mouse from './mouse.js';
|
|
6
7
|
import { Spinner } from './spinner.js';
|
|
7
8
|
const emitter = stdin;
|
|
8
9
|
/** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
|
|
@@ -128,8 +129,9 @@ export async function promptIntervention(req) {
|
|
|
128
129
|
emitter.on('keypress', l);
|
|
129
130
|
savedListeners = [];
|
|
130
131
|
// 先 paintInput(dim,menu:null):擦菜单(用 lastMenuRows)+ 画 dim 占位底栏 + 复位 lastMenuRows=0;
|
|
131
|
-
//
|
|
132
|
-
//
|
|
132
|
+
// 再处理滚动:面板内可能滚过滚轮(scrollOffset>0)→ resetScroll 回尾让 agent 后续输出可见;
|
|
133
|
+
// 未滚则 repaintViewport 从 content 缓冲重画内容区(恢复被菜单覆盖的 ● ask_human 行)。
|
|
134
|
+
// 顺序不可反——resetScroll/repaintViewport 在菜单还在(lastMenuRows>0)时会用内容覆盖菜单区,擦不掉菜单。
|
|
133
135
|
layout.paintInput({
|
|
134
136
|
prompt: '❯ ',
|
|
135
137
|
lines: [''],
|
|
@@ -138,11 +140,24 @@ export async function promptIntervention(req) {
|
|
|
138
140
|
menu: null,
|
|
139
141
|
dim: true,
|
|
140
142
|
});
|
|
141
|
-
layout.
|
|
143
|
+
if (layout.isScrolled())
|
|
144
|
+
layout.resetScroll();
|
|
145
|
+
else
|
|
146
|
+
layout.repaintViewport();
|
|
142
147
|
}
|
|
143
148
|
function onKey(_str, key) {
|
|
144
149
|
if (resolved || !key)
|
|
145
150
|
return;
|
|
151
|
+
// SGR 鼠标 fragment:readline 拆碎 \x1B[<…M,经 mouse.consumeMouse 重组;
|
|
152
|
+
// 滚轮 → scrollBy(±5) 滚动查看 agent 输出内容(菜单只靠 ↑↓ 选,滚轮不选菜单);
|
|
153
|
+
// fragment 期间 suppress 吞掉防砸进 onKeyInput 的可打印插入。菜单经 scrollBy 末尾
|
|
154
|
+
// repaint()→paintInput(lastView) 重画,不被 viewport 滚动覆盖。
|
|
155
|
+
const _m = mouse.consumeMouse(key.sequence ?? '');
|
|
156
|
+
if (_m.suppress) {
|
|
157
|
+
if (_m.wheel)
|
|
158
|
+
layout.scrollBy(_m.wheel * 5);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
146
161
|
// Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
|
|
147
162
|
if (key.ctrl && key.name === 'c') {
|
|
148
163
|
finish({ action: 'cancelled' });
|
|
@@ -158,6 +173,12 @@ export async function promptIntervention(req) {
|
|
|
158
173
|
finish({ action: 'cancelled' });
|
|
159
174
|
return;
|
|
160
175
|
}
|
|
176
|
+
// PgUp/PgDn:翻页滚动查看 agent 输出内容(与 onRunningKey/prompt.ts 一致;↑↓ 仍归菜单导航,不抢)。
|
|
177
|
+
if (key.name === 'pageup' || key.name === 'pagedown') {
|
|
178
|
+
const pageH = layout.getGeo().contentBottom;
|
|
179
|
+
layout.scrollBy(key.name === 'pageup' ? pageH : -pageH);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
161
182
|
if (mode === 'choice') {
|
|
162
183
|
onKeyChoice(key);
|
|
163
184
|
}
|