mocode-ai 1.2.2 → 1.2.4

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.
@@ -1,43 +1,201 @@
1
- // 可执行 skill runner:把 skill 的「工作流」封装成隔离子 agent 执行。
2
- // 对齐 Claude Code Agent Skills context: fork 模型——skill 内容成为驱动子 agent
3
- // 的 prompt(协议/操作规范),子 agent 用受控工具子集在隔离上下文里执行,结果摘要回灌。
1
+ // skill 执行内核(L2-①/②/③):占位符渲染 + 动态命令注入 + fork 子 agent 执行。
2
+ // use_skill(inline 渲染 / fork 引导)与 run_skill(fork 执行)共用。
4
3
  //
5
- // 依赖:agent/spawn.ts 的 spawnAgent(已具备隔离 history / 工具白名单 / maxSteps /
6
- // read-write overlay / abort 透传 / usage 统计),这里只做「渲染 + 参数映射」,不重复造执行器。
4
+ // 设计要点(对齐 docs/skill-system-design.md §3.3–3.5):
5
+ // - 不为 scripts/ 做任何新机制:作者用 ${SKILL_DIR} 拼出绝对路径,模型自行 run_command。
6
+ // - 不为参数做 shell 插值:参数渲染进 prompt 文本,落到命令行时是模型写 run_command,
7
+ // 走既有 denylist + 权限确认。转义器本身就是注入面,不写它比写对它更安全。
8
+ // - !`cmd` 注入仅在非 project 或已信任 skill 上允许;单 skill 最多 4 处,单处输出截 4KB。
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { resolve, sep } from 'node:path';
11
+ import { getSkillBody, findSkill } from './index.js';
12
+ import { isSkillTrusted, ensureSkillTrust } from './trust.js';
13
+ import { mapSkillTools } from './toolmap.js';
7
14
  import { spawnAgent } from '../agent/spawn.js';
8
- /** agent(Explore/Plan 等)类型 只读/写模式。缺省按 read 保守处理。 */
9
- const AGENT_READ_MODE = new Set(['explore', 'plan', 'read', 'research']);
15
+ import { runCommandRaw } from '../tools/builtins/run-command.js';
16
+ /** raw ChangeSet 折成 ToolOutcome 需要的 ChangeSetSummary(哈希缺失位填 null,仅用于展示)。 */
17
+ function toChangeSetSummary(cs) {
18
+ return {
19
+ id: cs.id,
20
+ changedFiles: cs.changes.map((c) => c.path),
21
+ changes: cs.changes.map((c) => ({
22
+ path: c.path,
23
+ operation: c.operation,
24
+ beforeHash: null,
25
+ afterHash: null,
26
+ })),
27
+ };
28
+ }
29
+ const MAX_INJECTIONS = 4;
30
+ const MAX_INJECTION_OUTPUT = 4096;
31
+ const INJECT_TIMEOUT = 10_000;
32
+ /** fork 子 agent 收到的协议头:明确它是「执行某个 skill」,并要求最终给一句话摘要。 */
33
+ const SKILL_PROTOCOL_HEADER = `You are executing a packaged skill workflow. Follow the instructions below literally and completely. When done, end your reply with a concise summary of what you did, the files changed, and any issues. Do not ask the user questions unless blocked.`;
34
+ /** 把 $ARGUMENTS / $1..$9 / ${SKILL_DIR} 渲染进正文。 */
35
+ function substituteArgs(body, args, skillDir) {
36
+ const values = args && typeof args === 'object' ? Object.values(args) : [];
37
+ return body.replace(/\$(?:ARGUMENTS|(\d)|\{SKILL_DIR\})/g, (_m, digit) => {
38
+ if (digit != null) {
39
+ const idx = Number(digit) - 1;
40
+ const v = values[idx];
41
+ return v === undefined ? '' : typeof v === 'string' ? v : JSON.stringify(v);
42
+ }
43
+ return skillDir;
44
+ });
45
+ }
46
+ /** 执行 !`cmd` 注入:逐个跑命令,用 fenced 输出替换;失败降级为提示而非中断。 */
47
+ async function injectCommands(body, skillDir, signal) {
48
+ const re = /!`([^`]+)`/g;
49
+ let count = 0;
50
+ const out = [];
51
+ let m;
52
+ while ((m = re.exec(body)) !== null && count < MAX_INJECTIONS) {
53
+ count++;
54
+ const cmd = m[1];
55
+ let replacement;
56
+ try {
57
+ const res = await runCommandRaw(cmd, INJECT_TIMEOUT, signal, skillDir);
58
+ if (res.status === 'denied') {
59
+ replacement = `(command denied: ${res.output})`;
60
+ }
61
+ else if (res.status === 'timed_out') {
62
+ replacement = '(command timed out)';
63
+ }
64
+ else if (res.status === 'aborted') {
65
+ replacement = '(command aborted)';
66
+ }
67
+ else {
68
+ const text = res.output.length > MAX_INJECTION_OUTPUT
69
+ ? res.output.slice(0, MAX_INJECTION_OUTPUT) + '\n…(truncated)'
70
+ : res.output;
71
+ replacement = text.trim() || '(no output)';
72
+ }
73
+ }
74
+ catch (e) {
75
+ replacement = `(command failed: ${e instanceof Error ? e.message : String(e)})`;
76
+ }
77
+ out.push({ full: m[0], replacement: '```\n' + replacement + '\n```' });
78
+ }
79
+ let result = body;
80
+ for (const { full, replacement } of out) {
81
+ result = result.replace(full, replacement);
82
+ }
83
+ return result;
84
+ }
85
+ /**
86
+ * 渲染 skill 正文(占位符 + 可选命令注入)。
87
+ * 非 project 或已信任的 project skill 才执行注入;否则先尝试一次性确认,未通过则跳过注入。
88
+ * 返回 null 表示正文缺失(调用方生成「未找到」错误)。
89
+ */
90
+ export async function renderSkillBody(skill, args, signal) {
91
+ const raw = getSkillBody(skill.name);
92
+ if (raw === null)
93
+ return null;
94
+ let body = substituteArgs(raw, args, skill.dir);
95
+ if (/!`[^`]+`/.test(body)) {
96
+ // 非 project 恒信任;project 依次查信任记录、再弹一次性确认。
97
+ // ensureSkillTrust 的 true 覆盖 'trusted'(已记录)与 'once'(仅本次)两种,直接据此注入。
98
+ const trusted = skill.origin !== 'project' || isSkillTrusted(skill) || (await ensureSkillTrust(skill));
99
+ if (trusted) {
100
+ body = await injectCommands(body, skill.dir, signal);
101
+ }
102
+ else {
103
+ // 未信任:清掉注入标记,避免把未授权命令留在提示里。
104
+ body = body.replace(/!`[^`]+`/g, '_(command injection skipped: skill not trusted)_');
105
+ }
106
+ }
107
+ return body;
108
+ }
109
+ /** 把 SpawnResult 转成 ToolOutcome,汇总/计费/变更集透传,不丢回滚信息。 */
110
+ function toOutcome(res) {
111
+ const status = res.status === 'completed' ? 'success' : res.status === 'aborted' ? 'aborted' : 'error';
112
+ return {
113
+ status,
114
+ code: res.status === 'completed' ? 'OK'
115
+ : res.status === 'aborted' ? 'ABORTED'
116
+ : 'EXECUTION_ERROR',
117
+ retryable: false,
118
+ output: res.summary ?? (res.status === 'failed' ? '(skill failed with no output)' : ''),
119
+ changeSet: res.changeSet ? toChangeSetSummary(res.changeSet) : undefined,
120
+ usage: res.usage,
121
+ };
122
+ }
123
+ /** 读取 skill 目录内附属文件(L2 渐进式披露);越界 / 不存在 / 过大返 null。 */
124
+ export function readSkillFile(skill, file, maxBytes) {
125
+ // 归一为绝对路径,并强制留在 skill.dir 内(禁止 ../ 逃逸)。
126
+ const base = resolve(skill.dir);
127
+ const abs = resolve(base, file);
128
+ if (abs !== base && !abs.startsWith(base + sep))
129
+ return null;
130
+ if (!existsSync(abs))
131
+ return null;
132
+ try {
133
+ if (statSync(abs).size > maxBytes)
134
+ return `(file too large: ${file})`;
135
+ return readFileSync(abs, 'utf8');
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /** 能产生副作用的 mocode 工具;用于在未显式声明 agent: 时推断 fork 子 agent 的模式。 */
142
+ const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'run_command']);
10
143
  /**
11
- * 把参数渲染进 skill 正文:替换 $ARGUMENTS / ${} / $1 / ${1} 等占位符。
12
- * 仅替换存在的占位符,无占位符正文原样返回(兼容纯文本 skill)
144
+ * fork agent 模式:`agent:` 显式声明优先;否则按工具面推断——
145
+ * 未声明 allowed-tools(完整工具集)或白名单含写工具 → 'write',纯只读白名单 → 'read'
146
+ * 避免写类 skill 因缺省字段被静默降级为只读。
147
+ * 导出仅供 scripts/check-skills.ts 离线断言。
13
148
  */
14
- export function renderBody(skill, args) {
15
- const body = skill.body?.trim() || '';
16
- if (!body)
17
- return body;
18
- const arg = args ?? {};
19
- const named = JSON.stringify(arg, null, 2) || '{}';
20
- const positional = Array.isArray(arg)
21
- ? arg.map((v) => String(v))
22
- : (Object.values(arg).map((v) => String(v)));
23
- const at = (i) => positional[i] ?? '';
24
- return body
25
- .replace(/\$ARGUMENTS\b/gi, named)
26
- .replace(/\$\{?(\d+)\}?/g, (_m, idx) => at(Number(idx) - 1))
27
- .replace(/\$\{ARGUMENTS\}/gi, named);
149
+ export function resolveSpawnMode(skill, tools) {
150
+ if (skill.agentMode)
151
+ return skill.agentMode;
152
+ if (tools === null || tools.some((t) => WRITE_TOOLS.has(t)))
153
+ return 'write';
154
+ return 'read';
28
155
  }
29
156
  /**
30
- * fork 执行:把 skill 作为隔离子 agent 的协议,派生受控子 agent 执行其工作流。
31
- * skill.allowed_tools工具白名单;skill.agent 只读/写模式;args 序列化进用户 prompt。
157
+ * 执行一个 skill(无论 inline 还是 fork 都走隔离子 agent,保证「可执行」语义统一):
158
+ * - 找不到UNKNOWN_TOOL 语义 error
159
+ * - 执行面门禁(ensureSkillTrust)未过 → denied
160
+ * - 渲染正文 → spawnAgent(白名单工具 / mode / maxSteps / signal)
161
+ * - 子 agent 摘要回灌为 output,usage / changeSet 透传
32
162
  */
33
- export async function runSkillForked(skill, args, ctx) {
34
- const rendered = renderBody(skill, args);
35
- const mode = skill.agent && AGENT_READ_MODE.has(skill.agent.trim().toLowerCase()) ? 'read' : 'write';
36
- return spawnAgent({
37
- prompt: `Execute the "${skill.name}" skill workflow now. Follow its protocol exactly, use the available tools, and when done return a concise summary of what you did, key findings, any files changed, and blockers.\n\n--- Skill protocol ---\n\n${rendered || '(skill body is empty; follow the skill contract described in the list above)'}`,
38
- tools: skill.allowed_tools,
39
- mode,
163
+ export async function runSkill(a, ctx) {
164
+ const name = String(a.name ?? '').trim();
165
+ if (!name) {
166
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: '错误:缺少 skill 名。' };
167
+ }
168
+ const skill = findSkill(name);
169
+ if (!skill) {
170
+ return { status: 'error', code: 'UNKNOWN_TOOL', retryable: false, output: `错误:未找到 skill "${name}"。` };
171
+ }
172
+ if (!(await ensureSkillTrust(skill))) {
173
+ return {
174
+ status: 'denied',
175
+ code: 'PERMISSION_DENIED',
176
+ retryable: false,
177
+ output: `拒绝:skill "${name}" 未获信任,已取消执行。`,
178
+ };
179
+ }
180
+ let body = await renderSkillBody(skill, a.args, ctx?.signal);
181
+ if (body === null) {
182
+ return { status: 'error', code: 'UNKNOWN_TOOL', retryable: false, output: `错误:未找到 skill "${name}" 的正文。` };
183
+ }
184
+ const { tools, unknown } = mapSkillTools(skill.allowedTools);
185
+ if (unknown.length) {
186
+ // 仅记到正文前导,模型可感知哪些 allowed-tools 被忽略(不阻断执行)。
187
+ body = `> 注意:以下 allowed-tools 无法映射到 mocode 工具,已忽略: ${unknown.join(', ')}\n\n` + body;
188
+ }
189
+ const res = await spawnAgent({
190
+ prompt: SKILL_PROTOCOL_HEADER + '\n\n' + body,
191
+ tools: tools ?? undefined,
192
+ mode: resolveSpawnMode(skill, tools),
193
+ maxSteps: skill.maxSteps,
40
194
  signal: ctx?.signal,
41
- context: args ? `Skill arguments:\n${JSON.stringify(args, null, 2)}` : undefined,
195
+ context: a.context,
196
+ systemPromptSuffix: `You are executing the "${skill.name}" skill. SKILL_DIR=${skill.dir}`,
197
+ quiet: true, // fork skill 是 opaque workflow,不产可展开 batch
198
+ quietLabel: `执行 ${skill.name}…`,
42
199
  });
200
+ return toOutcome(res);
43
201
  }
@@ -0,0 +1,56 @@
1
+ // skill 工具名映射(叶子模块,零依赖,避免激活态/runner 与 tools/constants 之间的循环依赖)。
2
+ // 把 Agent Skills 开放标准里的工具 token 归一为 mocode 工具名。
3
+ //
4
+ // 关键约束:只做「名字翻译」,绝不把 `Bash(git:*)` 的括号内容当作命令白名单——
5
+ // mocode 的权限是 run_command 粒度(permissions/index.ts 的 permissionFingerprint),
6
+ // 不解析 shell 前缀。`Bash(git:*)` 在 mocode 里等同于「允许子 agent 用 run_command」,
7
+ // 具体命令仍走 denylist + 沙箱。
8
+ /** 标准 token(大小写不敏感)→ mocode 工具名。带括号的 `Bash(...)` 也按 Bash 前缀归并。
9
+ * 同时接受 mocode 原生工具名(write_file / web_fetch…),即标准名与原生名两种写法。 */
10
+ const TOKEN_MAP = [
11
+ { re: /^read(_file)?$/i, tool: 'read_file' },
12
+ { re: /^grep$/i, tool: 'grep' },
13
+ { re: /^glob$/i, tool: 'glob' },
14
+ { re: /^bash$/i, tool: 'run_command' }, // Bash(...) 也落到 run_command
15
+ { re: /^run_command$/i, tool: 'run_command' },
16
+ { re: /^write(_file)?$/i, tool: 'write_file' },
17
+ { re: /^edit(_file)?$/i, tool: 'edit_file' },
18
+ { re: /^web_?search$/i, tool: 'web_search' },
19
+ { re: /^web_?fetch$/i, tool: 'web_fetch' },
20
+ { re: /^use_skill$/i, tool: 'use_skill' },
21
+ { re: /^run_skill$/i, tool: 'run_skill' },
22
+ { re: /^memory_search$/i, tool: 'memory_search' },
23
+ { re: /^memory_list$/i, tool: 'memory_list' },
24
+ ];
25
+ /** 把单个标准 token 映射成 mocode 工具名;未知 token 返 null(调用方记 warning + 忽略)。 */
26
+ export function mapSkillToolName(token) {
27
+ const t = token.trim();
28
+ if (!t)
29
+ return null;
30
+ // 去掉可能的括号后缀(Bash(git:*) → Bash)
31
+ const base = t.replace(/\(.*\)$/, '').trim();
32
+ for (const { re, tool } of TOKEN_MAP) {
33
+ if (re.test(base))
34
+ return tool;
35
+ }
36
+ return null;
37
+ }
38
+ /** 把 allowed-tools / disallowed-tools 的 token 列表映射为 mocode 工具名集合;
39
+ * 未知 token 收集到 unknown 返回,供调用方记 warning。返回 null 表示未声明(不约束)。 */
40
+ export function mapSkillTools(tokens) {
41
+ if (!tokens || tokens.length === 0)
42
+ return { tools: null, unknown: [] };
43
+ const tools = [];
44
+ const unknown = [];
45
+ for (const tk of tokens) {
46
+ const mapped = mapSkillToolName(tk);
47
+ if (mapped) {
48
+ if (!tools.includes(mapped))
49
+ tools.push(mapped);
50
+ }
51
+ else {
52
+ unknown.push(tk);
53
+ }
54
+ }
55
+ return { tools: tools.length ? tools : null, unknown };
56
+ }
@@ -0,0 +1,134 @@
1
+ // skill 信任门禁(执行面的安全前置)。
2
+ // 来源策略:
3
+ // - builtin:恒信任(随 mocode 发布)。
4
+ // - user(~/.claude|.mocode/skills):免门禁(用户自己放的)。
5
+ // - project(<cwd>/.mocode/skills):很可能来自 git clone,首次使用执行面时一次性确认;
6
+ // 记录 sha256(SKILL.md + scripts/** + references/**),内容变更 → 失效重问。
7
+ //
8
+ // 非 TTY(管道 / CI / host 嵌入)严格失败关闭:未信任的 project skill 执行面一律拒绝,
9
+ // 对齐 Snyk 关于注册表 skill 携带恶意载荷的数据(设计文档 §1.4)。
10
+ import { createHash } from 'node:crypto';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { promptIntervention } from '../ui/intervention.js';
15
+ import * as layout from '../ui/layout.js';
16
+ const TRUST_PATH = join(homedir(), '.mocode', 'skill-trust.json');
17
+ let trustCache = null;
18
+ function loadTrust() {
19
+ if (trustCache)
20
+ return trustCache;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(TRUST_PATH, 'utf8'));
23
+ trustCache = parsed && typeof parsed === 'object' ? parsed : {};
24
+ }
25
+ catch {
26
+ trustCache = {};
27
+ }
28
+ return trustCache;
29
+ }
30
+ function saveTrust(rec) {
31
+ trustCache = rec;
32
+ try {
33
+ mkdirSync(dirname(TRUST_PATH), { recursive: true });
34
+ writeFileSync(TRUST_PATH, JSON.stringify(rec, null, 2));
35
+ }
36
+ catch {
37
+ // 写入失败(权限 / 只读 home)不阻断,仅本次会话内存态生效。
38
+ }
39
+ }
40
+ /** 递归收集目录下所有文件(绝对路径)。 */
41
+ function collectFiles(dir, out) {
42
+ let ents;
43
+ try {
44
+ ents = readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ for (const e of ents) {
50
+ const p = join(dir, e.name);
51
+ if (e.isDirectory())
52
+ collectFiles(p, out);
53
+ else
54
+ out.push(p);
55
+ }
56
+ }
57
+ /** 计算 skill 内容哈希(SKILL.md + scripts/** + references/**)。 */
58
+ export function computeSkillHash(skill) {
59
+ const h = createHash('sha256');
60
+ const files = [skill.skillMdPath];
61
+ for (const sub of ['scripts', 'references']) {
62
+ const d = join(skill.dir, sub);
63
+ if (existsSync(d))
64
+ collectFiles(d, files);
65
+ }
66
+ files.sort();
67
+ for (const f of files) {
68
+ try {
69
+ h.update(f + '\0');
70
+ h.update(readFileSync(f));
71
+ }
72
+ catch {
73
+ // 单文件读失败:跳过该项(哈希覆盖其余内容,足够检测变更)。
74
+ }
75
+ }
76
+ return h.digest('hex');
77
+ }
78
+ /** 该 skill 当前是否已受信任(内容未变)。非 project 来源恒 true。 */
79
+ export function isSkillTrusted(skill) {
80
+ if (skill.origin !== 'project')
81
+ return true;
82
+ const rec = loadTrust()[skill.name];
83
+ if (!rec)
84
+ return false;
85
+ return rec.hash === computeSkillHash(skill);
86
+ }
87
+ function recordTrust(skill) {
88
+ const rec = loadTrust();
89
+ rec[skill.name] = { hash: computeSkillHash(skill), trustedAt: Date.now() };
90
+ saveTrust(rec);
91
+ }
92
+ /**
93
+ * 面向用户的信任确认。非 TTY 直接拒绝(失败关闭)。
94
+ * 返回 'trusted'(记录哈希) / 'once'(仅本次) / 'deny'。
95
+ */
96
+ export async function promptTrust(skill) {
97
+ if (!layout.isActive())
98
+ return 'deny';
99
+ const res = await promptIntervention({
100
+ type: 'choice',
101
+ title: `信任并运行 skill "${skill.name}"?`,
102
+ detail: `${skill.dir}\n` +
103
+ `该 skill 配置了执行面(fork / scripts / allowed-tools)。首次执行需确认;` +
104
+ `其 SKILL.md / scripts / references 内容变更后将重新询问。`,
105
+ options: [
106
+ { label: '信任并运行', detail: '记录内容哈希,今后自动信任' },
107
+ { label: '仅本次运行', detail: '本会话执行一次,不记录' },
108
+ { label: '拒绝', detail: '不执行' },
109
+ ],
110
+ allowCustom: false,
111
+ });
112
+ if (res.action === 'cancelled')
113
+ return 'deny';
114
+ const v = res.value ?? '拒绝';
115
+ if (v.startsWith('信任')) {
116
+ recordTrust(skill);
117
+ return 'trusted';
118
+ }
119
+ if (v.startsWith('仅本次'))
120
+ return 'once';
121
+ return 'deny';
122
+ }
123
+ /**
124
+ * 执行面前的信任检查:已信任返回 true;未信任则弹确认。
125
+ * 非 project / 已信任 → true;未信任 project 在非 TTY → false;用户拒绝 → false。
126
+ */
127
+ export async function ensureSkillTrust(skill) {
128
+ if (skill.origin !== 'project')
129
+ return true;
130
+ if (isSkillTrusted(skill))
131
+ return true;
132
+ const decision = await promptTrust(skill);
133
+ return decision !== 'deny';
134
+ }
@@ -11,6 +11,7 @@ import { grepTool } from './grep.js';
11
11
  import { webSearchTool } from './web-search.js';
12
12
  import { webFetchTool } from './web-fetch.js';
13
13
  import { useSkillTool } from './use-skill.js';
14
+ import { runSkillTool } from './run-skill.js';
14
15
  import { askHumanTool } from './ask-human.js';
15
16
  import { planUpdateTool } from './plan-update.js';
16
17
  import { memorySaveTool } from './memory-save.js';
@@ -60,6 +61,7 @@ const CAPABILITIES = {
60
61
  web_search: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
61
62
  web_fetch: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
62
63
  use_skill: { effect: 'read', concurrency: 'serial' },
64
+ run_skill: { effect: 'process', concurrency: 'serial', delegatesResourceLocks: true, supportsAbort: true },
63
65
  ask_human: { effect: 'read', concurrency: 'serial' },
64
66
  // plan_update 只写内部 notes.md(session 工作面),不作为用户代码 mutation 追踪/回滚/diff;
65
67
  // 串行即可(调用不频繁),固定资源键让并发调用排队。
@@ -94,6 +96,7 @@ const rawBuiltinTools = [
94
96
  webSearchTool,
95
97
  webFetchTool,
96
98
  useSkillTool,
99
+ runSkillTool,
97
100
  askHumanTool,
98
101
  planUpdateTool,
99
102
  ..._memoryTools,
@@ -0,0 +1,42 @@
1
+ import { runSkill } from '../../skills/runner.js';
2
+ // ---------- run_skill ----------
3
+ // 唯一新增的常驻工具(L2-①):把某个 skill 作为隔离工作流(fork 子 agent)执行并返回摘要。
4
+ // 无论装 100 个还是 1000 个 skill,常驻工具表只多这 1 个。上下文 / 工具面 / 副作用 / 中断
5
+ // 全部由 spawnAgent 现成能力承接(设计 §3.4)。
6
+ export const runSkillTool = {
7
+ name: 'run_skill',
8
+ description: 'Execute a skill as an isolated workflow (forked sub-agent) and return its summary. ' +
9
+ 'Use for skills marked [fork] in the skill list. Args are rendered into the skill body.',
10
+ parameters: {
11
+ type: 'object',
12
+ properties: {
13
+ name: {
14
+ type: 'string',
15
+ description: 'Name of the skill to execute (see the skill list in the system prompt).',
16
+ },
17
+ args: {
18
+ type: 'object',
19
+ description: 'Arguments rendered into the skill body ($ARGUMENTS, $1..$9). Optional.',
20
+ },
21
+ context: {
22
+ type: 'string',
23
+ description: 'Optional extra context/facts to inject into the sub-agent (authoritative; not rediscovered).',
24
+ },
25
+ },
26
+ required: ['name'],
27
+ },
28
+ risk: 'confirm',
29
+ capabilities: {
30
+ effect: 'process',
31
+ concurrency: 'serial',
32
+ delegatesResourceLocks: true, // 与 sub-agent 一致:锁由内层工具取,避免父子自锁
33
+ supportsAbort: true,
34
+ },
35
+ async execute(args, ctx) {
36
+ return runSkill({
37
+ name: String(args.name ?? ''),
38
+ args: args.args,
39
+ context: typeof args.context === 'string' ? args.context : undefined,
40
+ }, ctx);
41
+ },
42
+ };
@@ -1,10 +1,21 @@
1
- import { getSkillBody } from '../../skills/index.js';
1
+ import { findSkill } from '../../skills/index.js';
2
+ import { renderSkillBody, readSkillFile } from '../../skills/runner.js';
3
+ import { activateSkill } from '../../skills/activation.js';
2
4
  // ---------- use_skill ----------
3
5
  // 模型按需加载某 skill 的 SKILL.md 正文(渐进式披露第②层)。
4
6
  // 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
7
+ //
8
+ // 设计 §3.3 升级:
9
+ // - args:渲染 $ARGUMENTS / $1..$9 / ${SKILL_DIR}
10
+ // - file:读 skill 目录内附属文件(L2 披露,jail 约束)
11
+ // - context: fork 的 skill 不返回正文,改为引导调 run_skill(隔离白做才是真隔离)
12
+ // - inline skill 成功加载后激活会话级工具面约束(allowed/disallowed-tools)
13
+ const MAX_SKILL_FILE = 200_000;
5
14
  export const useSkillTool = {
6
15
  name: 'use_skill',
7
- description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each.',
16
+ description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each. ' +
17
+ 'Supports args (renders $ARGUMENTS / $1.. / ${SKILL_DIR}) and file (reads a bundled reference file). ' +
18
+ 'For skills marked [fork], this returns a guide to call run_skill instead of loading the body inline.',
8
19
  parameters: {
9
20
  type: 'object',
10
21
  properties: {
@@ -12,16 +23,43 @@ export const useSkillTool = {
12
23
  type: 'string',
13
24
  description: 'Name of the skill to load (see the skill list in the system prompt, or the /skills command)',
14
25
  },
26
+ args: {
27
+ type: 'object',
28
+ description: 'Arguments rendered into the skill body ($ARGUMENTS, $1..$9). Optional.',
29
+ },
30
+ file: {
31
+ type: 'string',
32
+ description: 'Optional bundled file inside the skill directory to read (e.g. references/api.md). Subject to jail bounds.',
33
+ },
15
34
  },
16
35
  required: ['name'],
17
36
  },
18
- async execute(args) {
37
+ async execute(args, ctx) {
19
38
  const name = String(args.name ?? '').trim();
20
39
  if (!name)
21
40
  return '错误:缺少 skill 名。用 /skills 查看可用 skill 列表。';
22
- const body = getSkillBody(name);
23
- if (body === null)
41
+ const skill = findSkill(name);
42
+ if (!skill)
24
43
  return `错误:未找到 skill "${name}"。用 /skills 查看可用 skill 列表。`;
44
+ // fork skill:不把正文读进主上下文,引导走 run_skill。
45
+ if (skill.context === 'fork') {
46
+ return (`# Skill: ${name}\n\n` +
47
+ `该 skill 以隔离工作流(fork)形式执行。请勿在此加载其正文——调用 ` +
48
+ `\`run_skill({ name: "${name}"${Object.keys(args.args ?? {}).length ? ', args: {...}' : ''} })\` ` +
49
+ `即可在隔离子 agent 中执行并返回摘要。`);
50
+ }
51
+ // file 优先:L2 渐进式披露
52
+ if (typeof args.file === 'string' && args.file.trim()) {
53
+ const content = readSkillFile(skill, args.file.trim(), MAX_SKILL_FILE);
54
+ if (content === null)
55
+ return `错误:无法读取 skill "${name}" 的文件 "${args.file}"(不存在 / 越界 / 过大)。`;
56
+ return `# Skill: ${name} · ${args.file}\n\n${content}`;
57
+ }
58
+ const body = await renderSkillBody(skill, args.args, ctx?.signal);
59
+ if (body === null)
60
+ return `错误:未找到 skill "${name}" 的正文。用 /skills 查看可用 skill 列表。`;
61
+ // 激活 inline skill 的工具面约束(allowed/disallowed),本轮内生效。
62
+ activateSkill(skill);
25
63
  return `# Skill: ${name}\n\n${body}`;
26
64
  },
27
65
  };
@@ -1,5 +1,6 @@
1
1
  /** 工具共享的截断 / 上限 / 忽略规则。 */
2
2
  import { isMemoryEnabled, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
+ import { getActiveSkill } from '../skills/activation.js';
3
4
  export const MAX_FILE_LINES = 2000;
4
5
  export const MAX_OUTPUT = 20000;
5
6
  export const MAX_RESULTS = 100;
@@ -56,6 +57,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
56
57
  'memory_update',
57
58
  'memory_forget',
58
59
  'sub-agent',
60
+ 'run_skill', // fork 子 agent 执行面;plan 模式不应派生子工作流
59
61
  ]);
60
62
  /**
61
63
  * 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
@@ -89,5 +91,11 @@ export function getRuntimeDisabledTools() {
89
91
  for (const name of FRONTEND_TOOLS)
90
92
  disabled.add(name);
91
93
  }
94
+ // inline skill 激活态的 disallowed-tools:即便模型幻觉调用也执行不了(设计 §3.6)。
95
+ const active = getActiveSkill();
96
+ if (active?.disallowed) {
97
+ for (const name of active.disallowed)
98
+ disabled.add(name);
99
+ }
92
100
  return disabled;
93
101
  }
@@ -210,7 +210,7 @@ export async function promptIntervention(req) {
210
210
  layout.resetScroll();
211
211
  else
212
212
  layout.repaintViewport();
213
- // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 200ms 刷新。
213
+ // 恢复走时计时器(RUNNING 态):面板期间 stopTurnTimer 停了心跳,退出后恢复状态行 80ms 刷新。
214
214
  layout.startTurnTimerIfRunning();
215
215
  }
216
216
  function onKey(_str, key) {
@@ -339,7 +339,7 @@ export async function promptIntervention(req) {
339
339
  return new Promise((res, rej) => {
340
340
  resolve = res;
341
341
  try {
342
- // 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 200ms 心跳
342
+ // 进入面板:停 spinner(避免 onFrame 覆盖)+ 停走时计时器(避免 drawStatusBar 80ms 心跳
343
343
  // 把真光标拉到 runningCaretPos 覆盖 paintInput 的正确光标位)+ 禁鼠标框选(防拖拽 viewport 重画覆盖菜单)+ 回尾(若用户正滚动回看)
344
344
  Spinner.pauseCurrent();
345
345
  layout.stopTurnTimer();