@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.
@@ -13,9 +13,6 @@
13
13
  */
14
14
 
15
15
  import { query, renameSession, forkSession } from '@anthropic-ai/claude-agent-sdk';
16
- // Used to mint unique approval request IDs when randomUUID is not available.
17
- // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18
- import crypto from 'crypto';
19
16
  import { promises as fs } from 'fs';
20
17
  import path from 'path';
21
18
  import { CLAUDE_MODELS } from '../shared/modelConstants.js';
@@ -24,7 +21,7 @@ import { getUserPaths } from './services/user-directories.js';
24
21
  import { usageDb } from './database/db.js';
25
22
  import { calculateCost, normalizeModelName } from './services/pricing.js';
26
23
  import { evaluate as evaluateToolGuard } from './services/tool-guard/index.js';
27
- import { loadProjectConfig, addProjectManually } from './projects.js';
24
+ import builtinTools, { createRequestId } from './services/builtin-tools/index.js';
28
25
 
29
26
  // Session tracking: Map of session IDs to active query instances
30
27
  const activeSessions = new Map();
@@ -78,41 +75,6 @@ const pendingToolApprovals = new Map();
78
75
  // introduced to avoid hanging the run when no decision arrives.
79
76
  const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
80
77
 
81
- // ─── 分享项目模板:pending 请求管理 ───
82
- const pendingShareTemplateRequests = new Map();
83
- const SHARE_TEMPLATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟(用户需要时间审阅和修改模板)
84
-
85
- function waitForShareTemplateResponse(requestId) {
86
- return new Promise(resolve => {
87
- let settled = false;
88
- const finalize = (result) => {
89
- if (settled) return;
90
- settled = true;
91
- pendingShareTemplateRequests.delete(requestId);
92
- clearTimeout(timeout);
93
- resolve(result);
94
- };
95
- const timeout = setTimeout(() => finalize(null), SHARE_TEMPLATE_TIMEOUT_MS);
96
- pendingShareTemplateRequests.set(requestId, finalize);
97
- });
98
- }
99
-
100
- function resolveShareTemplateRequest(requestId, result) {
101
- const resolver = pendingShareTemplateRequests.get(requestId);
102
- if (resolver) resolver(result);
103
- }
104
-
105
- // Generate a stable request ID for UI approval flows.
106
- // This does not encode tool details or get shown to users; it exists so the UI
107
- // can respond to the correct pending request without collisions.
108
- function createRequestId() {
109
- // if clause is used because randomUUID is not available in older Node.js versions
110
- if (typeof crypto.randomUUID === 'function') {
111
- return crypto.randomUUID();
112
- }
113
- return crypto.randomBytes(16).toString('hex');
114
- }
115
-
116
78
  // Wait for a UI approval decision, honoring SDK cancellation.
117
79
  // This does not auto-approve or auto-deny; it only resolves with UI input,
118
80
  // and it cleans up the pending map to avoid leaks, introduced to prevent
@@ -271,28 +233,7 @@ function mapCliOptionsToSDK(options = {}) {
271
233
  console.log(`Using model: ${sdkOptions.model}`);
272
234
 
273
235
  // Map system prompt configuration
274
- const SHARE_PROJECT_TEMPLATE_TOOL_DESC = `
275
-
276
- ## 分享项目模板工具
277
-
278
- 当用户想要分享项目、创建项目模板、发布共享项目时,使用以下 Bash 命令打开分享弹窗:
279
-
280
- \`\`\`bash
281
- __share_project_template__ '{"path":"/项目路径","displayName":"显示名称","description":"描述","updateNotes":"更新说明","skills":["skill1"],"mcps":["mcp1"],"files":["file1.md"]}'
282
- \`\`\`
283
-
284
- 参数说明:
285
- - path (必填): 项目文件夹路径,可以是当前项目、子文件夹或其他项目路径
286
- - displayName (可选): 模板显示名称,仅首次创建时使用(更新时忽略)
287
- - description (可选): 模板描述,仅首次创建时使用(更新时忽略)
288
- - updateNotes (可选): 更新说明,仅更新已有模板时使用
289
- - skills (可选): 要包含的技能名称数组,空数组=让用户在弹窗中选择
290
- - mcps (可选): 要包含的 MCP 服务名称数组,空数组=让用户在弹窗中选择
291
- - files (可选): 要包含的文件相对路径数组,空数组=让用户在弹窗中选择
292
-
293
- 该命令会打开一个 UI 弹窗供用户确认和修改,等待用户操作完成后返回结果。
294
- `;
295
- const baseAppend = PRODUCT_SYSTEM_DESC + SHARE_PROJECT_TEMPLATE_TOOL_DESC;
236
+ const baseAppend = PRODUCT_SYSTEM_DESC + builtinTools.getSystemPromptAppend();
296
237
  const extraAppend = options.appendSystemPrompt ? `\n\n${options.appendSystemPrompt}` : '';
297
238
  sdkOptions.systemPrompt = {
298
239
  type: 'preset',
@@ -647,96 +588,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
647
588
  ...((sdkOptions.hooks || {}).PreToolUse || []),
648
589
  {
649
590
  hooks: [async (hookInput) => {
650
- // ===== 分享项目模板拦截(在 ToolGuard 之前,匹配后直接 return) =====
651
- if (hookInput.tool_name === 'Bash' &&
652
- hookInput.tool_input?.command?.trimStart().startsWith('__share_project_template__')) {
653
- try {
654
- // 检测飞书模式:MutableWriter.current .ws 属性(WebSocketWriter),FakeSendWriter 没有
655
- if (!mutableWriter?.current?.ws) {
656
- return {
657
- hookSpecificOutput: {
658
- hookEventName: 'PreToolUse',
659
- permissionDecision: 'deny',
660
- permissionDecisionReason: '分享项目模板功能仅支持网页端使用。',
661
- }
662
- };
663
- }
664
-
665
- // 解析参数
666
- const rawArgs = hookInput.tool_input.command.replace(/^\s*__share_project_template__\s*/, '');
667
- let jsonStr = rawArgs.trim();
668
- if ((jsonStr.startsWith("'") && jsonStr.endsWith("'")) ||
669
- (jsonStr.startsWith('"') && jsonStr.endsWith('"'))) {
670
- jsonStr = jsonStr.slice(1, -1);
671
- }
672
- const params = JSON.parse(jsonStr);
673
-
674
- if (!params.path) {
675
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
676
- permissionDecisionReason: '缺少必需参数 path(项目路径)。' } };
677
- }
678
-
679
- // 解析 path → projectKey,并判断是否为已有项目
680
- const config = await loadProjectConfig(userUuid);
681
- let projectKey = null;
682
- let isExistingProject = false;
683
- for (const [key, entry] of Object.entries(config)) {
684
- if ((entry.originalPath || key.replace(/-/g, '/')) === params.path) {
685
- projectKey = key;
686
- isExistingProject = true;
687
- break;
688
- }
689
- }
690
- if (!projectKey) {
691
- // 路径不在项目列表中(可能是子文件夹),自动添加为新项目
692
- try {
693
- const project = await addProjectManually(params.path, params.displayName, userUuid);
694
- projectKey = project.name;
695
- isExistingProject = false;
696
- } catch (e) {
697
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
698
- permissionDecisionReason: `无法解析项目路径: ${e.message}` } };
699
- }
700
- }
701
-
702
- // 发送 WebSocket 消息打开分享弹窗
703
- const shareRequestId = createRequestId();
704
- mutableWriter.send({
705
- type: 'share-project-template-request',
706
- requestId: shareRequestId,
707
- prefillData: {
708
- projectKey,
709
- projectPath: params.path,
710
- isExistingProject,
711
- displayName: params.displayName || '',
712
- description: params.description || '',
713
- updateNotes: params.updateNotes || '',
714
- skills: params.skills || [],
715
- mcps: params.mcps || [],
716
- files: params.files || [],
717
- }
718
- });
719
-
720
- // 等待前端响应(5 分钟超时)
721
- const response = await waitForShareTemplateResponse(shareRequestId);
722
-
723
- let reason;
724
- if (!response) {
725
- reason = '分享项目模板请求超时(5分钟内未收到响应)。';
726
- } else if (response.cancelled) {
727
- reason = '用户取消了分享项目模板操作。';
728
- } else if (response.success) {
729
- reason = `项目模板已成功提交!提交 ID: ${response.submissionId}。${response.message || ''}`;
730
- } else {
731
- reason = `提交失败: ${response.error || '未知错误'}`;
732
- }
733
-
734
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
735
- } catch (err) {
736
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
737
- permissionDecisionReason: `分享项目模板失败: ${err.message}` } };
738
- }
739
- }
591
+ // ===== 内置工具拦截(在 ToolGuard 之前,匹配后直接 return) =====
592
+ const builtinResult = await builtinTools.handlePreToolUse(hookInput, {
593
+ userUuid, mutableWriter, cwd: sdkOptions.cwd,
594
+ });
595
+ if (builtinResult) return builtinResult;
740
596
 
741
597
  // ===== 安全守卫(Tool Guard)=====
742
598
  try {
@@ -1140,7 +996,6 @@ export {
1140
996
  isClaudeSDKSessionActive,
1141
997
  getActiveClaudeSDKSessions,
1142
998
  resolveToolApproval,
1143
- resolveShareTemplateRequest,
1144
999
  renameSessionForUser,
1145
1000
  forkSessionForUser,
1146
1001
  updateSessionWriter
package/server/index.js CHANGED
@@ -64,7 +64,14 @@ import fetch from 'node-fetch';
64
64
  import mime from 'mime-types';
65
65
 
66
66
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, updateProjectLastActivity } from './projects.js';
67
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, resolveShareTemplateRequest, renameSessionForUser, forkSessionForUser, updateSessionWriter } from './claude-sdk.js';
67
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, renameSessionForUser, forkSessionForUser, updateSessionWriter } from './claude-sdk.js';
68
+ import builtinTools, {
69
+ backgroundTaskPool,
70
+ enqueueResult,
71
+ dequeueResult,
72
+ hasResults,
73
+ getAllPendingForUser,
74
+ } from './services/builtin-tools/index.js';
68
75
  import authRoutes from './routes/auth.js';
69
76
  import mcpRoutes from './routes/mcp.js';
70
77
  import mcpUtilsRoutes from './routes/mcp-utils.js';
@@ -88,6 +95,82 @@ import { startImageCleanup } from './services/image-cleanup.js';
88
95
  const userWatchers = new Map(); // Map<userUuid, { watcher, clients: Set<ws> }>
89
96
  const connectedClients = new Set();
90
97
 
98
+ // ─── 后台任务:用户连接映射 & 结果投递 ───
99
+ // Map<userUuid, Set<{ws, writer}>> — 追踪每个用户的所有 WebSocket 连接
100
+ const userConnections = new Map();
101
+
102
+ function addUserConnection(userUuid, ws, writer) {
103
+ if (!userUuid) return;
104
+ if (!userConnections.has(userUuid)) userConnections.set(userUuid, new Set());
105
+ userConnections.get(userUuid).add({ ws, writer });
106
+ }
107
+
108
+ function removeUserConnection(userUuid, ws) {
109
+ if (!userUuid) return;
110
+ const conns = userConnections.get(userUuid);
111
+ if (!conns) return;
112
+ for (const conn of conns) {
113
+ if (conn.ws === ws) { conns.delete(conn); break; }
114
+ }
115
+ if (conns.size === 0) userConnections.delete(userUuid);
116
+ }
117
+
118
+ /**
119
+ * 尝试向前端投递一条后台任务完成结果
120
+ * 条件:有待投递结果 + session 空闲 + 用户在线
121
+ */
122
+ function tryDeliverBgResult(userUuid, sessionId) {
123
+ if (!hasResults(userUuid, sessionId)) return;
124
+ if (isClaudeSDKSessionActive(sessionId)) return; // session 忙,等 query 结束后再投递
125
+
126
+ const result = dequeueResult(userUuid, sessionId);
127
+ if (!result) return;
128
+
129
+ const conns = userConnections.get(userUuid);
130
+ if (!conns || conns.size === 0) {
131
+ // 用户不在线,放回队列(入队到头部)
132
+ enqueueResult(userUuid, sessionId, result);
133
+ return;
134
+ }
135
+
136
+ // 找到一个可用的 WebSocket 连接发送通知
137
+ for (const { ws } of conns) {
138
+ if (ws.readyState === 1) { // WebSocket.OPEN
139
+ try {
140
+ ws.send(JSON.stringify({
141
+ type: 'background-task-complete',
142
+ taskId: result.id,
143
+ sessionId: result.sessionId,
144
+ command: result.command,
145
+ label: result.label,
146
+ status: result.status,
147
+ exitCode: result.exitCode,
148
+ signal: result.signal,
149
+ stdout: result.stdout,
150
+ stderr: result.stderr,
151
+ truncated: result.truncated,
152
+ duration: (result.endTime || Date.now()) - result.startTime,
153
+ cwd: result.cwd,
154
+ }));
155
+ console.log(`[BgTask] Delivered result ${result.id} to user ${userUuid}, session ${sessionId}`);
156
+ return;
157
+ } catch (err) {
158
+ console.error(`[BgTask] Failed to send result:`, err.message);
159
+ }
160
+ }
161
+ }
162
+
163
+ // 所有连接都不可用,放回队列
164
+ enqueueResult(userUuid, sessionId, result);
165
+ }
166
+
167
+ // 监听后台任务完成事件
168
+ backgroundTaskPool.on('task-complete', (task) => {
169
+ enqueueResult(task.userUuid, task.sessionId, task);
170
+ // 立即尝试投递
171
+ tryDeliverBgResult(task.userUuid, task.sessionId);
172
+ });
173
+
91
174
  // Setup file system watcher for a specific user's Claude projects folder
92
175
  async function setupUserProjectsWatcher(userUuid, ws) {
93
176
  if (!userUuid) {
@@ -788,6 +871,17 @@ function handleChatConnection(ws, userData) {
788
871
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
789
872
  const writer = new WebSocketWriter(ws);
790
873
 
874
+ // Register in user connections mapping (for background task result delivery)
875
+ addUserConnection(userUuid, ws, writer);
876
+
877
+ // On reconnect, deliver any pending background task results
878
+ if (userUuid) {
879
+ const pendingSessions = getAllPendingForUser(userUuid);
880
+ for (const { sessionId } of pendingSessions) {
881
+ tryDeliverBgResult(userUuid, sessionId);
882
+ }
883
+ }
884
+
791
885
  ws.on('message', async (message) => {
792
886
  try {
793
887
  const data = JSON.parse(message);
@@ -818,6 +912,14 @@ function handleChatConnection(ws, userData) {
818
912
  }
819
913
  }
820
914
  }
915
+
916
+ // Query 结束后,尝试投递待处理的后台任务结果
917
+ {
918
+ const sid = data.options?.sessionId || writer.getSessionId();
919
+ if (userUuid && sid) {
920
+ tryDeliverBgResult(userUuid, sid);
921
+ }
922
+ }
821
923
  } else if (data.type === 'abort-session') {
822
924
  console.log('[DEBUG] Abort session request:', data.sessionId);
823
925
  // Use Claude Agents SDK
@@ -841,17 +943,9 @@ function handleChatConnection(ws, userData) {
841
943
  rememberEntry: data.rememberEntry
842
944
  });
843
945
  }
844
- } else if (data.type === 'share-project-template-response') {
845
- // Relay the user's share-project-template decision back to the PreToolUse hook.
846
- if (data.requestId) {
847
- resolveShareTemplateRequest(data.requestId, {
848
- success: Boolean(data.success),
849
- cancelled: Boolean(data.cancelled),
850
- submissionId: data.submissionId || null,
851
- message: data.message || '',
852
- error: data.error || '',
853
- });
854
- }
946
+ } else if (builtinTools.canHandleResponse(data.type) && data.requestId) {
947
+ // Relay the user's builtin-tool response back to the PreToolUse hook.
948
+ builtinTools.resolveResponse(data.requestId, data);
855
949
  } else if (data.type === 'check-session-status') {
856
950
  // Check if a specific session is currently processing
857
951
  const sessionId = data.sessionId;
@@ -891,6 +985,7 @@ function handleChatConnection(ws, userData) {
891
985
  console.log('🔌 Chat client disconnected');
892
986
  // Remove from connected clients
893
987
  connectedClients.delete(ws);
988
+ removeUserConnection(userUuid, ws);
894
989
  // Cleanup projects watcher for this user
895
990
  if (userUuid) {
896
991
  cleanupUserProjectsWatcher(userUuid, ws);
@@ -901,6 +996,7 @@ function handleChatConnection(ws, userData) {
901
996
  console.error('[ERROR] Chat WebSocket error:', error.message);
902
997
  // Ensure cleanup on error as well (close may not fire after error)
903
998
  connectedClients.delete(ws);
999
+ removeUserConnection(userUuid, ws);
904
1000
  if (userUuid) {
905
1001
  cleanupUserProjectsWatcher(userUuid, ws);
906
1002
  }