@ian2018cs/agenthub 0.1.76 → 0.1.77

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * 内置工具:后台任务执行器 + 状态查询
3
+ *
4
+ * __bg_exec__: AI 调用 Bash(__bg_exec__ '<json>') 提交后台任务
5
+ * __bg_status__: AI 调用 Bash(__bg_status__) 或 Bash(__bg_status__ '<taskId>') 查询任务状态
6
+ *
7
+ * 飞书端不支持此工具(无法自动发起新 query 投递结果)。
8
+ */
9
+
10
+ import { backgroundTaskPool } from './background-task-pool.js';
11
+ import { evaluate as evaluateToolGuard } from '../tool-guard/index.js';
12
+
13
+ const ENABLED = process.env.BG_TASK_ENABLED !== 'false';
14
+
15
+ // ─── __bg_exec__:提交后台任务 ───
16
+
17
+ export default {
18
+ name: '__bg_exec__',
19
+
20
+ // 不需要 responseType — 结果通过 PendingResultQueue 投递,不走 resolveResponse 路径
21
+
22
+ systemPrompt: `
23
+
24
+ ## 后台任务执行器
25
+
26
+ 当你需要执行可能耗时超过 30 秒的命令时(如 git clone、npm install、大型构建、长时间脚本等),
27
+ 使用后台任务执行器代替 Bash 的 run_in_background + TaskOutput 轮询模式。
28
+
29
+ ### 提交后台任务
30
+ \`\`\`bash
31
+ __bg_exec__ '{"command":"要执行的命令","timeout":600000,"label":"任务描述"}'
32
+ \`\`\`
33
+ 参数说明:
34
+ - command(必需):要执行的 shell 命令
35
+ - timeout(可选):超时时间(毫秒),默认 600000(10分钟),最大 1800000(30分钟)
36
+ - label(可选):人类可读的任务描述
37
+
38
+ 任务会在后台执行,你会立即收到任务 ID 确认。任务完成后结果会自动推送给你,
39
+ 你不需要轮询或等待,可以继续处理其他工作。
40
+
41
+ ### 查询任务状态
42
+ \`\`\`bash
43
+ __bg_status__
44
+ \`\`\`
45
+ 不带参数:列出当前会话所有后台任务的状态。
46
+
47
+ \`\`\`bash
48
+ __bg_status__ bg_xxxxxxxxxxxx
49
+ \`\`\`
50
+ 带任务 ID:查询指定任务的详细状态(包含已产生的输出)。
51
+
52
+ 注意事项:
53
+ - 每个用户最多同时运行 3 个后台任务
54
+ - 不要对交互式命令使用此工具(如需要用户输入的命令)
55
+ - 不要对很快就能完成的命令使用此工具(<30 秒的用普通 Bash)
56
+ - 如果服务器重启,正在运行的后台任务会丢失
57
+ - 不要使用 TaskOutput 来查询后台任务状态,请使用 __bg_status__
58
+ `,
59
+
60
+ match(hookInput) {
61
+ if (!ENABLED) return false;
62
+ return hookInput.tool_name === 'Bash' &&
63
+ hookInput.tool_input?.command?.trimStart().startsWith('__bg_exec__');
64
+ },
65
+
66
+ async execute(hookInput, context) {
67
+ const { userUuid, mutableWriter, cwd } = context;
68
+
69
+ // 飞书模式检测
70
+ if (!mutableWriter?.current?.ws) {
71
+ return { decision: 'deny', reason: '后台任务执行功能仅支持网页端使用。' };
72
+ }
73
+
74
+ // 解析参数
75
+ const rawArgs = hookInput.tool_input.command.replace(/^\s*__bg_exec__\s*/, '');
76
+ let jsonStr = rawArgs.trim();
77
+ if ((jsonStr.startsWith("'") && jsonStr.endsWith("'")) ||
78
+ (jsonStr.startsWith('"') && jsonStr.endsWith('"'))) {
79
+ jsonStr = jsonStr.slice(1, -1);
80
+ }
81
+
82
+ let params;
83
+ try {
84
+ params = JSON.parse(jsonStr);
85
+ } catch (e) {
86
+ return { decision: 'deny', reason: `参数解析失败: ${e.message}。请使用 JSON 格式:__bg_exec__ '{"command":"..."}'` };
87
+ }
88
+
89
+ if (!params.command || typeof params.command !== 'string') {
90
+ return { decision: 'deny', reason: '缺少必需参数 command(要执行的命令)。' };
91
+ }
92
+
93
+ const actualCommand = params.command.trim();
94
+ if (!actualCommand) {
95
+ return { decision: 'deny', reason: 'command 不能为空。' };
96
+ }
97
+
98
+ // ToolGuard 安全检查(对实际命令而非 __bg_exec__ 包装)
99
+ try {
100
+ const guardResult = await evaluateToolGuard('Bash', { command: actualCommand }, {
101
+ userUuid,
102
+ cwd,
103
+ });
104
+ if (!guardResult.allowed) {
105
+ return { decision: 'deny', reason: `[系统安全策略] ${guardResult.reason}` };
106
+ }
107
+ } catch (err) {
108
+ console.error(`[BgTask] ToolGuard error:`, err.message);
109
+ // 守卫出错不阻塞,降级为允许
110
+ }
111
+
112
+ // 提交到进程池
113
+ let task;
114
+ try {
115
+ task = backgroundTaskPool.submit({
116
+ userUuid,
117
+ sessionId: mutableWriter.getSessionId(),
118
+ cwd,
119
+ command: actualCommand,
120
+ timeout: params.timeout,
121
+ label: params.label,
122
+ });
123
+ } catch (err) {
124
+ return { decision: 'deny', reason: `后台任务提交失败: ${err.message}` };
125
+ }
126
+
127
+ const timeoutSec = Math.round(task.timeout / 1000);
128
+ return {
129
+ decision: 'deny',
130
+ reason: `后台任务已启动。\n` +
131
+ `- 任务 ID: ${task.id}\n` +
132
+ `- 命令: ${actualCommand}\n` +
133
+ `- 超时: ${timeoutSec}s\n` +
134
+ `任务完成后结果会自动推送给你,请继续处理其他工作。`,
135
+ };
136
+ }
137
+ };
138
+
139
+ // ─── __bg_status__:查询后台任务状态 ───
140
+
141
+ function formatDuration(ms) {
142
+ if (ms < 1000) return `${ms}ms`;
143
+ const s = Math.round(ms / 1000);
144
+ if (s < 60) return `${s}s`;
145
+ return `${Math.floor(s / 60)}m${s % 60}s`;
146
+ }
147
+
148
+ function formatTaskSummary(task) {
149
+ const elapsed = (task.endTime || Date.now()) - task.startTime;
150
+ const lines = [
151
+ `[${task.id}] ${task.status.toUpperCase()}`,
152
+ ` 命令: ${task.command}`,
153
+ ` 耗时: ${formatDuration(elapsed)}`,
154
+ ];
155
+ if (task.status !== 'running') {
156
+ lines.push(` 退出码: ${task.exitCode ?? 'N/A'}`);
157
+ }
158
+ if (task.label && task.label !== task.command.slice(0, 80)) {
159
+ lines.splice(1, 0, ` 标签: ${task.label}`);
160
+ }
161
+ return lines.join('\n');
162
+ }
163
+
164
+ function formatTaskDetail(task) {
165
+ const elapsed = (task.endTime || Date.now()) - task.startTime;
166
+ const lines = [
167
+ `任务 ID: ${task.id}`,
168
+ `状态: ${task.status}`,
169
+ `命令: ${task.command}`,
170
+ `耗时: ${formatDuration(elapsed)}`,
171
+ `退出码: ${task.exitCode ?? 'N/A'}`,
172
+ ];
173
+ if (task.truncated) lines.push('(输出已截断至 100KB)');
174
+ if (task.stdout) lines.push('', '--- STDOUT ---', task.stdout);
175
+ if (task.stderr) lines.push('', '--- STDERR ---', task.stderr);
176
+ if (task.status === 'running' && !task.stdout && !task.stderr) {
177
+ lines.push('', '(暂无输出)');
178
+ }
179
+ return lines.join('\n');
180
+ }
181
+
182
+ export const bgStatusTool = {
183
+ name: '__bg_status__',
184
+
185
+ // systemPrompt 合并在 __bg_exec__ 的 systemPrompt 中,不需要单独的
186
+
187
+ systemPrompt: '',
188
+
189
+ match(hookInput) {
190
+ if (!ENABLED) return false;
191
+ return hookInput.tool_name === 'Bash' &&
192
+ hookInput.tool_input?.command?.trimStart().startsWith('__bg_status__');
193
+ },
194
+
195
+ async execute(hookInput, context) {
196
+ const { userUuid, mutableWriter } = context;
197
+ const sessionId = mutableWriter?.getSessionId();
198
+
199
+ // 解析可选的 taskId 参数
200
+ const rawArgs = hookInput.tool_input.command.replace(/^\s*__bg_status__\s*/, '').trim();
201
+ // 去掉可能的引号
202
+ let taskId = rawArgs.replace(/^['"]|['"]$/g, '').trim();
203
+
204
+ if (taskId) {
205
+ // 查询指定任务
206
+ const task = backgroundTaskPool.getTask(taskId);
207
+ if (!task) {
208
+ return { decision: 'deny', reason: `未找到任务 ${taskId}。任务可能已过期被清理。` };
209
+ }
210
+ if (task.userUuid !== userUuid) {
211
+ return { decision: 'deny', reason: `未找到任务 ${taskId}。` };
212
+ }
213
+ return { decision: 'deny', reason: formatTaskDetail(task) };
214
+ }
215
+
216
+ // 列出当前会话所有任务
217
+ const tasks = sessionId
218
+ ? backgroundTaskPool.getSessionTasks(userUuid, sessionId)
219
+ : backgroundTaskPool.getUserTasks(userUuid);
220
+
221
+ if (tasks.length === 0) {
222
+ return { decision: 'deny', reason: '当前没有后台任务。' };
223
+ }
224
+
225
+ const lines = [`后台任务列表(共 ${tasks.length} 个):`, ''];
226
+ for (const task of tasks) {
227
+ lines.push(formatTaskSummary(task), '');
228
+ }
229
+ return { decision: 'deny', reason: lines.join('\n') };
230
+ }
231
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Builtin Tool Registry — 内置工具注册中心
3
+ *
4
+ * claude-agent-sdk 不提供添加自定义内置工具的 API,因此通过
5
+ * "注入 system prompt + PreToolUse hook 拦截 Bash 命令"的方式模拟。
6
+ *
7
+ * 本模块采用 Registry + Strategy 模式:
8
+ * - 每个工具是一个自包含的策略对象(name, systemPrompt, match, execute)
9
+ * - Registry 统一管理注册、system prompt 合并、hook 分发、异步响应等待
10
+ *
11
+ * 扩展新工具只需在 tools/ 目录添加新模块并调用 registry.register()。
12
+ */
13
+
14
+ import crypto from 'crypto';
15
+
16
+ // ─── 通用工具 ───
17
+
18
+ /**
19
+ * 生成唯一请求 ID(用于 UI 审批/异步响应流)
20
+ */
21
+ export function createRequestId() {
22
+ if (typeof crypto.randomUUID === 'function') {
23
+ return crypto.randomUUID();
24
+ }
25
+ return crypto.randomBytes(16).toString('hex');
26
+ }
27
+
28
+ // ─── Registry ───
29
+
30
+ class BuiltinToolRegistry {
31
+ #tools = [];
32
+ #responseTypes = new Set();
33
+ #pendingRequests = new Map();
34
+
35
+ /**
36
+ * 注册一个内置工具
37
+ * @param {{ name: string, responseType?: string, systemPrompt: string, match: Function, execute: Function }} tool
38
+ */
39
+ register(tool) {
40
+ this.#tools.push(tool);
41
+ if (tool.responseType) {
42
+ this.#responseTypes.add(tool.responseType);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 合并所有工具的 systemPrompt 描述文本
48
+ */
49
+ getSystemPromptAppend() {
50
+ return this.#tools.map(t => t.systemPrompt).join('\n');
51
+ }
52
+
53
+ /**
54
+ * PreToolUse hook 入口:遍历工具匹配并执行
55
+ * @param {object} hookInput - SDK PreToolUse hook 输入
56
+ * @param {{ userUuid: string, cwd: string, mutableWriter: object }} ctx
57
+ * @returns {object|null} SDK hook 返回值,无匹配时返回 null
58
+ */
59
+ async handlePreToolUse(hookInput, ctx) {
60
+ for (const tool of this.#tools) {
61
+ if (tool.match(hookInput)) {
62
+ const toolContext = {
63
+ ...ctx,
64
+ createRequestId,
65
+ waitForResponse: (requestId, timeoutMs) => this.#waitForResponse(requestId, timeoutMs),
66
+ };
67
+
68
+ try {
69
+ const result = await tool.execute(hookInput, toolContext);
70
+ return {
71
+ hookSpecificOutput: {
72
+ hookEventName: 'PreToolUse',
73
+ permissionDecision: result.decision,
74
+ ...(result.reason && { permissionDecisionReason: result.reason }),
75
+ ...(result.updatedInput && { updatedInput: result.updatedInput }),
76
+ }
77
+ };
78
+ } catch (err) {
79
+ return {
80
+ hookSpecificOutput: {
81
+ hookEventName: 'PreToolUse',
82
+ permissionDecision: 'deny',
83
+ permissionDecisionReason: `内置工具 ${tool.name} 执行失败: ${err.message}`,
84
+ }
85
+ };
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * 检查 WS 消息类型是否属于内置工具的响应
94
+ */
95
+ canHandleResponse(type) {
96
+ return this.#responseTypes.has(type);
97
+ }
98
+
99
+ /**
100
+ * 解析异步等待中的 Promise(由 server/index.js WS 消息路由调用)
101
+ */
102
+ resolveResponse(requestId, data) {
103
+ const resolver = this.#pendingRequests.get(requestId);
104
+ if (resolver) resolver(data);
105
+ }
106
+
107
+ /**
108
+ * 带超时的异步响应等待
109
+ */
110
+ #waitForResponse(requestId, timeoutMs) {
111
+ return new Promise(resolve => {
112
+ let settled = false;
113
+ const finalize = (result) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ this.#pendingRequests.delete(requestId);
117
+ clearTimeout(timeout);
118
+ resolve(result);
119
+ };
120
+ const timeout = setTimeout(() => finalize(null), timeoutMs);
121
+ this.#pendingRequests.set(requestId, finalize);
122
+ });
123
+ }
124
+ }
125
+
126
+ // ─── 单例创建 & 工具注册 ───
127
+
128
+ const registry = new BuiltinToolRegistry();
129
+
130
+ import shareProjectTemplate from './share-project-template.js';
131
+ registry.register(shareProjectTemplate);
132
+
133
+ import backgroundTask, { bgStatusTool } from './background-task.js';
134
+ registry.register(backgroundTask);
135
+ registry.register(bgStatusTool);
136
+
137
+ // 重新导出后台任务相关模块,供 server/index.js 使用
138
+ export {
139
+ backgroundTaskPool,
140
+ enqueueResult,
141
+ dequeueResult,
142
+ hasResults,
143
+ getAllPendingForUser,
144
+ } from './background-task-pool.js';
145
+
146
+ export default registry;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 内置工具:分享项目模板
3
+ *
4
+ * AI 调用 Bash(__share_project_template__ '<json>') 命令,
5
+ * 由 PreToolUse hook 拦截 → 通过 WebSocket 打开前端弹窗 → 等待用户操作 → 返回结果。
6
+ *
7
+ * 飞书端不支持此工具(检测 mutableWriter.current.ws)。
8
+ */
9
+
10
+ import { loadProjectConfig, addProjectManually } from '../../projects.js';
11
+
12
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟(用户需要时间审阅和修改模板)
13
+
14
+ export default {
15
+ name: '__share_project_template__',
16
+ responseType: 'share-project-template-response',
17
+
18
+ systemPrompt: `
19
+
20
+ ## 分享项目模板工具
21
+
22
+ 当用户想要分享项目、创建项目模板、发布共享项目时,使用以下 Bash 命令打开分享弹窗:
23
+
24
+ \`\`\`bash
25
+ __share_project_template__ '{"path":"/项目路径","displayName":"显示名称","description":"描述","updateNotes":"更新说明","skills":["skill1"],"mcps":["mcp1"],"files":["file1.md"],"gitRepos":[{"name":"子目录名","repo":"git远程地址","branch":"分支名"}]}'
26
+ \`\`\`
27
+
28
+ 参数说明:
29
+ - path (必填): 项目文件夹路径,可以是当前项目、子文件夹或其他项目路径
30
+ - displayName (可选): 模板显示名称,仅首次创建时使用(更新时忽略)
31
+ - description (可选): 模板描述,仅首次创建时使用(更新时忽略)
32
+ - updateNotes (可选): 更新说明,仅更新已有模板时使用
33
+ - skills (可选): 要包含的技能名称数组,空数组=让用户在弹窗中选择
34
+ - mcps (可选): 要包含的 MCP 服务名称数组,空数组=让用户在弹窗中选择
35
+ - files (可选): 要包含的文件相对路径数组,空数组=让用户在弹窗中选择
36
+ - gitRepos (可选): 要包含的 Git 仓库依赖数组,每项包含 name(子目录名)、repo(远程地址)、branch(分支),空数组=让用户在弹窗中选择
37
+
38
+ 该命令会打开一个 UI 弹窗供用户确认和修改,等待用户操作完成后返回结果。
39
+ `,
40
+
41
+ match(hookInput) {
42
+ return hookInput.tool_name === 'Bash' &&
43
+ hookInput.tool_input?.command?.trimStart().startsWith('__share_project_template__');
44
+ },
45
+
46
+ async execute(hookInput, context) {
47
+ const { userUuid, mutableWriter, createRequestId, waitForResponse } = context;
48
+
49
+ // 检测飞书模式:MutableWriter.current 有 .ws 属性(WebSocketWriter),FakeSendWriter 没有
50
+ if (!mutableWriter?.current?.ws) {
51
+ return { decision: 'deny', reason: '分享项目模板功能仅支持网页端使用。' };
52
+ }
53
+
54
+ // 解析参数
55
+ const rawArgs = hookInput.tool_input.command.replace(/^\s*__share_project_template__\s*/, '');
56
+ let jsonStr = rawArgs.trim();
57
+ if ((jsonStr.startsWith("'") && jsonStr.endsWith("'")) ||
58
+ (jsonStr.startsWith('"') && jsonStr.endsWith('"'))) {
59
+ jsonStr = jsonStr.slice(1, -1);
60
+ }
61
+ const params = JSON.parse(jsonStr);
62
+
63
+ if (!params.path) {
64
+ return { decision: 'deny', reason: '缺少必需参数 path(项目路径)。' };
65
+ }
66
+
67
+ // 解析 path → projectKey,并判断是否为已有项目
68
+ const config = await loadProjectConfig(userUuid);
69
+ let projectKey = null;
70
+ let isExistingProject = false;
71
+ for (const [key, entry] of Object.entries(config)) {
72
+ if ((entry.originalPath || key.replace(/-/g, '/')) === params.path) {
73
+ projectKey = key;
74
+ isExistingProject = true;
75
+ break;
76
+ }
77
+ }
78
+ if (!projectKey) {
79
+ // 路径不在项目列表中(可能是子文件夹),自动添加为新项目
80
+ try {
81
+ const project = await addProjectManually(params.path, params.displayName, userUuid);
82
+ projectKey = project.name;
83
+ isExistingProject = false;
84
+ } catch (e) {
85
+ return { decision: 'deny', reason: `无法解析项目路径: ${e.message}` };
86
+ }
87
+ }
88
+
89
+ // 发送 WebSocket 消息打开分享弹窗
90
+ const shareRequestId = createRequestId();
91
+ mutableWriter.send({
92
+ type: 'share-project-template-request',
93
+ requestId: shareRequestId,
94
+ prefillData: {
95
+ projectKey,
96
+ projectPath: params.path,
97
+ isExistingProject,
98
+ displayName: params.displayName || '',
99
+ description: params.description || '',
100
+ updateNotes: params.updateNotes || '',
101
+ skills: params.skills || [],
102
+ mcps: params.mcps || [],
103
+ files: params.files || [],
104
+ gitRepos: params.gitRepos || [],
105
+ }
106
+ });
107
+
108
+ // 等待前端响应(5 分钟超时)
109
+ const response = await waitForResponse(shareRequestId, TIMEOUT_MS);
110
+
111
+ let reason;
112
+ if (!response) {
113
+ reason = '分享项目模板请求超时(5分钟内未收到响应)。';
114
+ } else if (response.cancelled) {
115
+ reason = '用户取消了分享项目模板操作。';
116
+ } else if (response.success) {
117
+ reason = `项目模板已成功提交!提交 ID: ${response.submissionId}。${response.message || ''}`;
118
+ } else {
119
+ reason = `提交失败: ${response.error || '未知错误'}`;
120
+ }
121
+
122
+ return { decision: 'deny', reason };
123
+ }
124
+ };
@@ -89,7 +89,8 @@ async function parseAgentYaml(agentDir) {
89
89
  author: '',
90
90
  skills: [],
91
91
  mcps: [],
92
- files: []
92
+ files: [],
93
+ git_repos: []
93
94
  };
94
95
 
95
96
  // Parse scalar fields
@@ -153,6 +154,21 @@ async function parseAgentYaml(agentDir) {
153
154
  }
154
155
  }
155
156
 
157
+ // Parse git_repos list
158
+ const gitReposSection = content.match(/^git_repos:\s*\n((?:[ \t]+.+\n?)*)/m);
159
+ if (gitReposSection) {
160
+ const lines = gitReposSection[1].split('\n');
161
+ let current = null;
162
+ for (const line of lines) {
163
+ const nameLine = line.match(/^\s+-\s+name:\s*["']?(.+?)["']?\s*$/);
164
+ const repoLine = line.match(/^\s+repo:\s*["']?(.+?)["']?\s*$/);
165
+ const branchLine = line.match(/^\s+branch:\s*["']?(.+?)["']?\s*$/);
166
+ if (nameLine) { current = { name: nameLine[1].trim() }; result.git_repos.push(current); }
167
+ else if (repoLine && current) { current.repo = repoLine[1].trim(); }
168
+ else if (branchLine && current) { current.branch = branchLine[1].trim(); }
169
+ }
170
+ }
171
+
156
172
  return result;
157
173
  } catch {
158
174
  return null;
@@ -43,6 +43,7 @@ export function getPublicPaths() {
43
43
  mcpRepoDir: path.join(DATA_DIR, 'mcp-repo'),
44
44
  agentRepoDir: path.join(DATA_DIR, 'agent-repo'),
45
45
  agentSubmissionsDir: path.join(DATA_DIR, 'agent-submissions'),
46
+ gitRepoDir: path.join(DATA_DIR, 'git-repo'),
46
47
  };
47
48
  }
48
49
 
package/shared/brand.js CHANGED
@@ -27,6 +27,10 @@ export const CHAT_EXAMPLE_PROMPTS = [
27
27
  { icon: '💡', text: '给我一些解决问题的思路' },
28
28
  ];
29
29
 
30
+ // 新对话的默认权限模式。
31
+ // 可选值: 'default'(默认模式)| 'acceptEdits'(接受编辑)| 'bypassPermissions'(跳过权限)| 'plan'(计划模式)
32
+ export const DEFAULT_PERMISSION_MODE = 'default';
33
+
30
34
  // 飞书绑定引导文案(出现 3 次,统一成函数避免漂移)
31
35
  export const feishuBindingGuide = () =>
32
36
  `👋 你好!请先完成账号绑定:\n\n` +