@ian2018cs/agenthub 0.1.76 → 0.1.78

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/dist/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-B5imuFpg.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-NaMmXkCt.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-Bv0Nkan8.js">
30
30
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-sVRjxPVQ.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-utils-00TdZexr.js">
@@ -34,7 +34,7 @@
34
34
  <link rel="modulepreload" crossorigin href="/assets/vendor-markdown-CjscLcYM.js">
35
35
  <link rel="modulepreload" crossorigin href="/assets/vendor-syntax-BKENXTeY.js">
36
36
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CvdiG4-n.js">
37
- <link rel="stylesheet" crossorigin href="/assets/index-oUz7uC99.css">
37
+ <link rel="stylesheet" crossorigin href="/assets/index-DkNpDSsg.css">
38
38
  </head>
39
39
  <body>
40
40
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian2018cs/agenthub",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -51,7 +51,7 @@
51
51
  "access": "public"
52
52
  },
53
53
  "dependencies": {
54
- "@anthropic-ai/claude-agent-sdk": "^0.2.77",
54
+ "@anthropic-ai/claude-agent-sdk": "^0.2.79",
55
55
  "@codemirror/lang-css": "^6.3.1",
56
56
  "@codemirror/lang-html": "^6.4.9",
57
57
  "@codemirror/lang-javascript": "^6.2.4",
@@ -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) {
@@ -364,6 +447,17 @@ app.use(express.static(path.join(__dirname, '../dist'), {
364
447
  // /api/config endpoint removed - no longer needed
365
448
  // Frontend now uses window.location for WebSocket URLs
366
449
 
450
+ // Public version endpoint - reads from package.json at runtime to avoid build-time lag
451
+ app.get('/api/version', (req, res) => {
452
+ try {
453
+ const pkgPath = path.join(__dirname, '..', 'package.json');
454
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
455
+ res.json({ version: pkg.version });
456
+ } catch (e) {
457
+ res.json({ version: 'unknown' });
458
+ }
459
+ });
460
+
367
461
  app.get('/api/projects', authenticateToken, async (req, res) => {
368
462
  try {
369
463
  const projects = await getProjects(req.user.uuid);
@@ -788,6 +882,17 @@ function handleChatConnection(ws, userData) {
788
882
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
789
883
  const writer = new WebSocketWriter(ws);
790
884
 
885
+ // Register in user connections mapping (for background task result delivery)
886
+ addUserConnection(userUuid, ws, writer);
887
+
888
+ // On reconnect, deliver any pending background task results
889
+ if (userUuid) {
890
+ const pendingSessions = getAllPendingForUser(userUuid);
891
+ for (const { sessionId } of pendingSessions) {
892
+ tryDeliverBgResult(userUuid, sessionId);
893
+ }
894
+ }
895
+
791
896
  ws.on('message', async (message) => {
792
897
  try {
793
898
  const data = JSON.parse(message);
@@ -818,6 +923,14 @@ function handleChatConnection(ws, userData) {
818
923
  }
819
924
  }
820
925
  }
926
+
927
+ // Query 结束后,尝试投递待处理的后台任务结果
928
+ {
929
+ const sid = data.options?.sessionId || writer.getSessionId();
930
+ if (userUuid && sid) {
931
+ tryDeliverBgResult(userUuid, sid);
932
+ }
933
+ }
821
934
  } else if (data.type === 'abort-session') {
822
935
  console.log('[DEBUG] Abort session request:', data.sessionId);
823
936
  // Use Claude Agents SDK
@@ -841,17 +954,9 @@ function handleChatConnection(ws, userData) {
841
954
  rememberEntry: data.rememberEntry
842
955
  });
843
956
  }
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
- }
957
+ } else if (builtinTools.canHandleResponse(data.type) && data.requestId) {
958
+ // Relay the user's builtin-tool response back to the PreToolUse hook.
959
+ builtinTools.resolveResponse(data.requestId, data);
855
960
  } else if (data.type === 'check-session-status') {
856
961
  // Check if a specific session is currently processing
857
962
  const sessionId = data.sessionId;
@@ -891,6 +996,7 @@ function handleChatConnection(ws, userData) {
891
996
  console.log('🔌 Chat client disconnected');
892
997
  // Remove from connected clients
893
998
  connectedClients.delete(ws);
999
+ removeUserConnection(userUuid, ws);
894
1000
  // Cleanup projects watcher for this user
895
1001
  if (userUuid) {
896
1002
  cleanupUserProjectsWatcher(userUuid, ws);
@@ -901,6 +1007,7 @@ function handleChatConnection(ws, userData) {
901
1007
  console.error('[ERROR] Chat WebSocket error:', error.message);
902
1008
  // Ensure cleanup on error as well (close may not fire after error)
903
1009
  connectedClients.delete(ws);
1010
+ removeUserConnection(userUuid, ws);
904
1011
  if (userUuid) {
905
1012
  cleanupUserProjectsWatcher(userUuid, ws);
906
1013
  }