@ian2018cs/agenthub 0.1.69 → 0.1.71

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.
@@ -12,7 +12,7 @@
12
12
  * - WebSocket message streaming
13
13
  */
14
14
 
15
- import { query, renameSession } from '@anthropic-ai/claude-agent-sdk';
15
+ import { query, renameSession, forkSession } from '@anthropic-ai/claude-agent-sdk';
16
16
  // Used to mint unique approval request IDs when randomUUID is not available.
17
17
  // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18
18
  import crypto from 'crypto';
@@ -24,9 +24,50 @@ import { getUserPaths } from './services/user-directories.js';
24
24
  import { usageDb } from './database/db.js';
25
25
  import { calculateCost, normalizeModelName } from './services/pricing.js';
26
26
  import { evaluate as evaluateToolGuard } from './services/tool-guard/index.js';
27
+ import { loadProjectConfig, addProjectManually } from './projects.js';
27
28
 
28
29
  // Session tracking: Map of session IDs to active query instances
29
30
  const activeSessions = new Map();
31
+
32
+ /**
33
+ * MutableWriter wraps a WebSocketWriter and buffers messages while the client
34
+ * is disconnected. When the client reconnects, switchTo() replays the buffer
35
+ * to the new writer so no streaming chunks are lost.
36
+ */
37
+ class MutableWriter {
38
+ constructor(ws) {
39
+ this.current = ws;
40
+ this.buffer = [];
41
+ this.MAX_BUFFER = 500;
42
+ }
43
+
44
+ send(data) {
45
+ if (this.current?.ws?.readyState === 1) {
46
+ this.current.send(data);
47
+ } else if (this.buffer.length < this.MAX_BUFFER) {
48
+ this.buffer.push(data);
49
+ }
50
+ }
51
+
52
+ setSessionId(sessionId) {
53
+ this.current?.setSessionId(sessionId);
54
+ }
55
+
56
+ getSessionId() {
57
+ return this.current?.getSessionId();
58
+ }
59
+
60
+ /** Attach a new WebSocketWriter and replay any buffered messages to it. */
61
+ switchTo(newWs) {
62
+ this.current = newWs;
63
+ const buf = this.buffer;
64
+ this.buffer = [];
65
+ for (const msg of buf) {
66
+ newWs.send(msg);
67
+ }
68
+ }
69
+ }
70
+
30
71
  // In-memory registry of pending tool approvals keyed by requestId.
31
72
  // This does not persist approvals or share across processes; it exists so the
32
73
  // SDK can pause tool execution while the UI decides what to do.
@@ -37,6 +78,30 @@ const pendingToolApprovals = new Map();
37
78
  // introduced to avoid hanging the run when no decision arrives.
38
79
  const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
39
80
 
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
+
40
105
  // Generate a stable request ID for UI approval flows.
41
106
  // This does not encode tool details or get shown to users; it exists so the UI
42
107
  // can respond to the correct pending request without collisions.
@@ -206,7 +271,28 @@ function mapCliOptionsToSDK(options = {}) {
206
271
  console.log(`Using model: ${sdkOptions.model}`);
207
272
 
208
273
  // Map system prompt configuration
209
- const baseAppend = PRODUCT_SYSTEM_DESC;
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;
210
296
  const extraAppend = options.appendSystemPrompt ? `\n\n${options.appendSystemPrompt}` : '';
211
297
  sdkOptions.systemPrompt = {
212
298
  type: 'preset',
@@ -233,14 +319,15 @@ function mapCliOptionsToSDK(options = {}) {
233
319
  * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
234
320
  * @param {string} tempDir - Temp directory for cleanup
235
321
  */
236
- function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, abortController = null) {
322
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, abortController = null, mutableWriter = null) {
237
323
  activeSessions.set(sessionId, {
238
324
  instance: queryInstance,
239
325
  startTime: Date.now(),
240
326
  status: 'active',
241
327
  tempImagePaths,
242
328
  tempDir,
243
- abortController
329
+ abortController,
330
+ mutableWriter
244
331
  });
245
332
  }
246
333
 
@@ -261,6 +348,22 @@ function getSession(sessionId) {
261
348
  return activeSessions.get(sessionId);
262
349
  }
263
350
 
351
+ /**
352
+ * Attaches a new WebSocketWriter to an active session, replaying any messages
353
+ * that were buffered while the previous connection was closed.
354
+ * @param {string} sessionId - Session identifier
355
+ * @param {Object} newWs - New WebSocketWriter instance
356
+ * @returns {boolean} True if the session was found and updated
357
+ */
358
+ function updateSessionWriter(sessionId, newWs) {
359
+ const session = activeSessions.get(sessionId);
360
+ if (session?.mutableWriter) {
361
+ session.mutableWriter.switchTo(newWs);
362
+ return true;
363
+ }
364
+ return false;
365
+ }
366
+
264
367
  /**
265
368
  * Gets all active session IDs
266
369
  * @returns {Array<string>} Array of active session IDs
@@ -497,6 +600,10 @@ async function loadMcpConfig(cwd, userUuid) {
497
600
  */
498
601
  async function queryClaudeSDK(command, options = {}, ws) {
499
602
  const { sessionId, userUuid } = options;
603
+
604
+ // Wrap the WebSocketWriter in a MutableWriter so messages are buffered
605
+ // if the client disconnects mid-stream and replayed on reconnect.
606
+ const mutableWriter = new MutableWriter(ws);
500
607
  let capturedSessionId = sessionId;
501
608
  let sessionCreatedSent = false;
502
609
  let tempImagePaths = [];
@@ -540,6 +647,98 @@ async function queryClaudeSDK(command, options = {}, ws) {
540
647
  ...((sdkOptions.hooks || {}).PreToolUse || []),
541
648
  {
542
649
  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
+ }
740
+
741
+ // ===== 安全守卫(Tool Guard)=====
543
742
  try {
544
743
  const guardResult = await evaluateToolGuard(hookInput.tool_name, hookInput.tool_input, {
545
744
  userUuid,
@@ -603,7 +802,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
603
802
  }
604
803
 
605
804
  const requestId = createRequestId();
606
- ws.send({
805
+ mutableWriter.send({
607
806
  type: 'claude-permission-request',
608
807
  requestId,
609
808
  toolName,
@@ -619,7 +818,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
619
818
  timeoutMs: approvalTimeoutMs,
620
819
  signal: context?.signal,
621
820
  onCancel: (reason) => {
622
- ws.send({
821
+ mutableWriter.send({
623
822
  type: 'claude-permission-cancelled',
624
823
  requestId,
625
824
  reason,
@@ -660,7 +859,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
660
859
 
661
860
  // Track the query instance for abort capability
662
861
  if (capturedSessionId) {
663
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController);
862
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController, mutableWriter);
664
863
  }
665
864
 
666
865
  // Process streaming messages
@@ -670,17 +869,17 @@ async function queryClaudeSDK(command, options = {}, ws) {
670
869
  if (message.session_id && !capturedSessionId) {
671
870
 
672
871
  capturedSessionId = message.session_id;
673
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController);
872
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController, mutableWriter);
674
873
 
675
874
  // Set session ID on writer
676
875
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
677
- ws.setSessionId(capturedSessionId);
876
+ mutableWriter.setSessionId(capturedSessionId);
678
877
  }
679
878
 
680
879
  // Send session-created event only once for new sessions
681
880
  if (!sessionId && !sessionCreatedSent) {
682
881
  sessionCreatedSent = true;
683
- ws.send({
882
+ mutableWriter.send({
684
883
  type: 'session-created',
685
884
  sessionId: capturedSessionId
686
885
  });
@@ -693,7 +892,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
693
892
 
694
893
  // Transform and send message to WebSocket
695
894
  const transformedMessage = transformMessage(message);
696
- ws.send({
895
+ mutableWriter.send({
697
896
  type: 'claude-response',
698
897
  data: transformedMessage
699
898
  });
@@ -703,7 +902,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
703
902
  const tokenBudget = extractTokenBudget(message);
704
903
  if (tokenBudget) {
705
904
  console.log('Token budget from modelUsage:', tokenBudget);
706
- ws.send({
905
+ mutableWriter.send({
707
906
  type: 'token-budget',
708
907
  data: tokenBudget
709
908
  });
@@ -789,7 +988,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
789
988
  // to the frontend, so we must not send a duplicate 'claude-complete'.
790
989
  if (!abortController.signal.aborted) {
791
990
  console.log('Streaming complete, sending claude-complete event');
792
- ws.send({
991
+ mutableWriter.send({
793
992
  type: 'claude-complete',
794
993
  sessionId: capturedSessionId,
795
994
  exitCode: 0,
@@ -822,7 +1021,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
822
1021
  await cleanupTempFiles(tempImagePaths, tempDir);
823
1022
 
824
1023
  // Send error to WebSocket
825
- ws.send({
1024
+ mutableWriter.send({
826
1025
  type: 'claude-error',
827
1026
  error: error.message
828
1027
  });
@@ -908,6 +1107,20 @@ async function renameSessionForUser(sessionId, title, userUuid) {
908
1107
  await renameSession(sessionId, title);
909
1108
  }
910
1109
 
1110
+ /**
1111
+ * Fork a session at a given message, creating a new branch conversation.
1112
+ * @param {string} sessionId - Session to fork
1113
+ * @param {string} userUuid - User UUID for directory isolation
1114
+ * @param {object} opts - Fork options (upToMessageId, title)
1115
+ * @returns {Promise<string>} New session UUID
1116
+ */
1117
+ async function forkSessionForUser(sessionId, userUuid, opts = {}) {
1118
+ const userPaths = getUserPaths(userUuid);
1119
+ process.env.CLAUDE_CONFIG_DIR = userPaths.claudeDir;
1120
+ const result = await forkSession(sessionId, opts);
1121
+ return result.sessionId;
1122
+ }
1123
+
911
1124
  // Export public API
912
1125
  export {
913
1126
  queryClaudeSDK,
@@ -915,5 +1128,8 @@ export {
915
1128
  isClaudeSDKSessionActive,
916
1129
  getActiveClaudeSDKSessions,
917
1130
  resolveToolApproval,
918
- renameSessionForUser
1131
+ resolveShareTemplateRequest,
1132
+ renameSessionForUser,
1133
+ forkSessionForUser,
1134
+ updateSessionWriter
919
1135
  };
package/server/index.js CHANGED
@@ -43,7 +43,7 @@ import fetch from 'node-fetch';
43
43
  import mime from 'mime-types';
44
44
 
45
45
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, updateProjectLastActivity } from './projects.js';
46
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, renameSessionForUser } from './claude-sdk.js';
46
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, resolveShareTemplateRequest, renameSessionForUser, forkSessionForUser, updateSessionWriter } from './claude-sdk.js';
47
47
  import authRoutes from './routes/auth.js';
48
48
  import mcpRoutes from './routes/mcp.js';
49
49
  import mcpUtilsRoutes from './routes/mcp-utils.js';
@@ -455,6 +455,22 @@ app.put('/api/projects/:projectName/sessions/:sessionId/rename', authenticateTok
455
455
  }
456
456
  });
457
457
 
458
+ // Fork session endpoint — creates a new branch conversation from a given point
459
+ app.post('/api/projects/:projectName/sessions/:sessionId/fork', authenticateToken, async (req, res) => {
460
+ try {
461
+ const { sessionId } = req.params;
462
+ const opts = {};
463
+ if (req.body?.upToMessageId) {
464
+ opts.upToMessageId = req.body.upToMessageId;
465
+ }
466
+ const newSessionId = await forkSessionForUser(sessionId, req.user.uuid, opts);
467
+ res.json({ newSessionId });
468
+ } catch (error) {
469
+ console.error(`[API] Error forking session ${req.params.sessionId}:`, error);
470
+ res.status(500).json({ error: error.message });
471
+ }
472
+ });
473
+
458
474
  // Delete project endpoint
459
475
  app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
460
476
  try {
@@ -803,11 +819,27 @@ function handleChatConnection(ws, userData) {
803
819
  rememberEntry: data.rememberEntry
804
820
  });
805
821
  }
822
+ } else if (data.type === 'share-project-template-response') {
823
+ // Relay the user's share-project-template decision back to the PreToolUse hook.
824
+ if (data.requestId) {
825
+ resolveShareTemplateRequest(data.requestId, {
826
+ success: Boolean(data.success),
827
+ cancelled: Boolean(data.cancelled),
828
+ submissionId: data.submissionId || null,
829
+ message: data.message || '',
830
+ error: data.error || '',
831
+ });
832
+ }
806
833
  } else if (data.type === 'check-session-status') {
807
834
  // Check if a specific session is currently processing
808
835
  const sessionId = data.sessionId;
809
836
  const isActive = isClaudeSDKSessionActive(sessionId);
810
837
 
838
+ // If still running, attach new writer to receive buffered + future messages
839
+ if (isActive) {
840
+ updateSessionWriter(sessionId, writer);
841
+ }
842
+
811
843
  writer.send({
812
844
  type: 'session-status',
813
845
  sessionId,