@yeaft/webchat-agent 0.0.38 → 0.0.41

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/crew.js ADDED
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * Crew Mode - Multi-Agent Orchestrator
3
+ *
4
+ * 管理多个 AI 角色的协作:每个角色是一个独立的持久 query 实例,
5
+ * 编排器负责解析路由、分发消息、管理生命周期。
6
+ *
7
+ * 支持:
8
+ * - 动态添加/移除角色(群聊加人)
9
+ * - 角色级 CLAUDE.md + memory.md(利用 Claude Code 的 CLAUDE.md 自动向上查找机制)
10
+ * - 共享级 .crew/CLAUDE.md(所有角色自动继承)
11
+ * - Session resume(每个角色的 claudeSessionId 持久化)
12
+ * - 自动路由 + 人工混合
13
+ */
14
+
15
+ import { query, Stream } from './sdk/index.js';
16
+ import { promises as fs } from 'fs';
17
+ import { join } from 'path';
18
+ import ctx from './context.js';
19
+
20
+ // =====================================================================
21
+ // Data Structures
22
+ // =====================================================================
23
+
24
+ /** @type {Map<string, CrewSession>} */
25
+ const crewSessions = new Map();
26
+
27
+ // 导出供 connection.js 使用
28
+ export { crewSessions };
29
+
30
+ // =====================================================================
31
+ // Session Lifecycle
32
+ // =====================================================================
33
+
34
+ /**
35
+ * 创建 Crew Session
36
+ * 支持带角色创建或空 session(后续动态添加角色)
37
+ */
38
+ export async function createCrewSession(msg) {
39
+ const {
40
+ sessionId,
41
+ projectDir,
42
+ sharedDir: sharedDirRel,
43
+ goal,
44
+ roles = [], // [{ name, displayName, icon, description, claudeMd, model, budget, isDecisionMaker }]
45
+ maxRounds = 20,
46
+ userId,
47
+ username
48
+ } = msg;
49
+
50
+ // 解析共享目录(相对路径相对于 projectDir)
51
+ const sharedDir = sharedDirRel?.startsWith('/')
52
+ ? sharedDirRel
53
+ : join(projectDir, sharedDirRel || '.crew');
54
+
55
+ // 初始化共享区
56
+ await initSharedDir(sharedDir, goal, roles, projectDir);
57
+
58
+ // 找到决策者
59
+ const decisionMaker = roles.find(r => r.isDecisionMaker)?.name || roles[0]?.name || null;
60
+
61
+ const session = {
62
+ id: sessionId,
63
+ projectDir,
64
+ sharedDir,
65
+ goal,
66
+ roles: new Map(roles.map(r => [r.name, r])),
67
+ roleStates: new Map(),
68
+ decisionMaker,
69
+ status: 'running', // running | paused | waiting_human | completed | stopped
70
+ round: 0,
71
+ maxRounds,
72
+ costUsd: 0,
73
+ messageHistory: [], // 群聊消息历史
74
+ humanMessageQueue: [], // 人的消息排队
75
+ waitingHumanContext: null, // { fromRole, reason, message }
76
+ userId,
77
+ username,
78
+ createdAt: Date.now()
79
+ };
80
+
81
+ crewSessions.set(sessionId, session);
82
+
83
+ // 通知 server
84
+ sendCrewMessage({
85
+ type: 'crew_session_created',
86
+ sessionId,
87
+ projectDir,
88
+ sharedDir,
89
+ goal,
90
+ roles: roles.map(r => ({
91
+ name: r.name,
92
+ displayName: r.displayName,
93
+ icon: r.icon,
94
+ description: r.description,
95
+ isDecisionMaker: r.isDecisionMaker || false,
96
+ model: r.model
97
+ })),
98
+ decisionMaker,
99
+ maxRounds,
100
+ userId,
101
+ username
102
+ });
103
+
104
+ // 发送状态
105
+ sendStatusUpdate(session);
106
+
107
+ // 如果有预设角色,启动第一个
108
+ if (roles.length > 0) {
109
+ const firstRole = roles.find(r => r.name === 'pm') || roles[0];
110
+ if (firstRole) {
111
+ const initialPrompt = buildInitialTask(goal, firstRole, roles);
112
+ await dispatchToRole(session, firstRole.name, initialPrompt, 'system');
113
+ }
114
+ }
115
+
116
+ return session;
117
+ }
118
+
119
+ // =====================================================================
120
+ // Dynamic Role Management
121
+ // =====================================================================
122
+
123
+ /**
124
+ * 向现有 session 动态添加角色
125
+ */
126
+ export async function addRoleToSession(msg) {
127
+ const { sessionId, role } = msg;
128
+ const session = crewSessions.get(sessionId);
129
+ if (!session) {
130
+ console.warn(`[Crew] Session not found: ${sessionId}`);
131
+ return;
132
+ }
133
+
134
+ if (session.roles.has(role.name)) {
135
+ console.warn(`[Crew] Role already exists: ${role.name}`);
136
+ return;
137
+ }
138
+
139
+ // 添加角色到 session
140
+ session.roles.set(role.name, role);
141
+
142
+ // 如果还没有决策者且新角色是决策者,更新
143
+ if (role.isDecisionMaker) {
144
+ session.decisionMaker = role.name;
145
+ }
146
+ // 如果没有任何决策者,第一个角色作为决策者
147
+ if (!session.decisionMaker) {
148
+ session.decisionMaker = role.name;
149
+ }
150
+
151
+ // 初始化角色目录(CLAUDE.md + memory.md)
152
+ await initRoleDir(session.sharedDir, role);
153
+
154
+ // 更新共享 CLAUDE.md(增量添加新角色信息)
155
+ await updateSharedClaudeMd(session);
156
+
157
+ console.log(`[Crew] Role added: ${role.name} (${role.displayName}) to session ${sessionId}`);
158
+
159
+ // 通知 Web 端
160
+ sendCrewMessage({
161
+ type: 'crew_role_added',
162
+ sessionId,
163
+ role: {
164
+ name: role.name,
165
+ displayName: role.displayName,
166
+ icon: role.icon,
167
+ description: role.description,
168
+ isDecisionMaker: role.isDecisionMaker || false,
169
+ model: role.model
170
+ },
171
+ decisionMaker: session.decisionMaker
172
+ });
173
+
174
+ // 发送系统消息
175
+ sendCrewOutput(session, 'system', 'system', {
176
+ type: 'assistant',
177
+ message: { role: 'assistant', content: [{ type: 'text', text: `${role.icon} ${role.displayName} 加入了群聊` }] }
178
+ });
179
+
180
+ sendStatusUpdate(session);
181
+ }
182
+
183
+ /**
184
+ * 从 session 移除角色
185
+ */
186
+ export async function removeRoleFromSession(msg) {
187
+ const { sessionId, roleName } = msg;
188
+ const session = crewSessions.get(sessionId);
189
+ if (!session) {
190
+ console.warn(`[Crew] Session not found: ${sessionId}`);
191
+ return;
192
+ }
193
+
194
+ const role = session.roles.get(roleName);
195
+ if (!role) {
196
+ console.warn(`[Crew] Role not found: ${roleName}`);
197
+ return;
198
+ }
199
+
200
+ // 停止角色的 query(如果正在运行)
201
+ const roleState = session.roleStates.get(roleName);
202
+ if (roleState) {
203
+ // 保存 sessionId 到文件(以便未来恢复)
204
+ if (roleState.claudeSessionId) {
205
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId);
206
+ }
207
+ if (roleState.abortController) {
208
+ roleState.abortController.abort();
209
+ }
210
+ session.roleStates.delete(roleName);
211
+ }
212
+
213
+ // 从 roles 中移除
214
+ session.roles.delete(roleName);
215
+
216
+ // 如果移除的是决策者,重新选择
217
+ if (session.decisionMaker === roleName) {
218
+ const remaining = Array.from(session.roles.values());
219
+ const newDM = remaining.find(r => r.isDecisionMaker) || remaining[0];
220
+ session.decisionMaker = newDM?.name || null;
221
+ }
222
+
223
+ // 更新 CLAUDE.md
224
+ await updateSharedClaudeMd(session);
225
+
226
+ // Memory 文件保留(不删除,角色可能重新加入)
227
+
228
+ console.log(`[Crew] Role removed: ${roleName} from session ${sessionId}`);
229
+
230
+ sendCrewMessage({
231
+ type: 'crew_role_removed',
232
+ sessionId,
233
+ roleName,
234
+ decisionMaker: session.decisionMaker
235
+ });
236
+
237
+ sendCrewOutput(session, 'system', 'system', {
238
+ type: 'assistant',
239
+ message: { role: 'assistant', content: [{ type: 'text', text: `${role.icon} ${role.displayName} 离开了群聊` }] }
240
+ });
241
+
242
+ sendStatusUpdate(session);
243
+ }
244
+
245
+ // =====================================================================
246
+ // Shared Directory & Memory
247
+ // =====================================================================
248
+
249
+ /**
250
+ * 初始化共享目录
251
+ * 结构:
252
+ * .crew/
253
+ * ├── CLAUDE.md ← 共享级(团队目标、成员、共享记忆)
254
+ * ├── context/ ← 文档产出
255
+ * ├── sessions/ ← sessionId 持久化
256
+ * └── roles/
257
+ * └── {roleName}/
258
+ * └── CLAUDE.md ← 角色定义 + 个人记忆
259
+ */
260
+ async function initSharedDir(sharedDir, goal, roles, projectDir) {
261
+ await fs.mkdir(sharedDir, { recursive: true });
262
+ await fs.mkdir(join(sharedDir, 'context'), { recursive: true });
263
+ await fs.mkdir(join(sharedDir, 'sessions'), { recursive: true });
264
+ await fs.mkdir(join(sharedDir, 'roles'), { recursive: true });
265
+
266
+ // 初始化每个角色的目录
267
+ for (const role of roles) {
268
+ await initRoleDir(sharedDir, role);
269
+ }
270
+
271
+ // 生成 .crew/CLAUDE.md(共享级)
272
+ await writeSharedClaudeMd(sharedDir, goal, roles, projectDir);
273
+ }
274
+
275
+ /**
276
+ * 初始化角色目录: .crew/roles/{roleName}/CLAUDE.md
277
+ */
278
+ async function initRoleDir(sharedDir, role) {
279
+ const roleDir = join(sharedDir, 'roles', role.name);
280
+ await fs.mkdir(roleDir, { recursive: true });
281
+
282
+ // 角色 CLAUDE.md(仅首次创建,后续角色自己维护记忆内容)
283
+ const claudeMdPath = join(roleDir, 'CLAUDE.md');
284
+ try {
285
+ await fs.access(claudeMdPath);
286
+ // 已存在,不覆盖(保留角色自己写入的记忆)
287
+ } catch {
288
+ await writeRoleClaudeMd(sharedDir, role);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * 写入 .crew/CLAUDE.md — 共享级(所有角色自动继承)
294
+ * 记忆直接写在 CLAUDE.md 中,Claude Code 会自动加载
295
+ */
296
+ async function writeSharedClaudeMd(sharedDir, goal, roles, projectDir) {
297
+ const claudeMd = `# 项目目标
298
+ ${goal}
299
+
300
+ # 项目代码路径
301
+ ${projectDir}
302
+ 所有代码操作请使用此绝对路径。
303
+
304
+ # 团队成员
305
+ ${roles.length > 0 ? roles.map(r => `- ${r.icon} ${r.displayName}(${r.name}): ${r.description}${r.isDecisionMaker ? ' (决策者)' : ''}`).join('\n') : '_暂无成员_'}
306
+
307
+ # 工作约定
308
+ - 文档产出写入 context/ 目录
309
+ - 重要决策记录在 context/decisions.md
310
+ - 代码修改使用项目代码路径的绝对路径
311
+
312
+ # 共享记忆
313
+ _团队共同维护,记录重要的共识、决策和信息。_
314
+ `;
315
+
316
+ await fs.writeFile(join(sharedDir, 'CLAUDE.md'), claudeMd);
317
+ }
318
+
319
+ /**
320
+ * 写入 .crew/roles/{roleName}/CLAUDE.md — 角色级
321
+ * 记忆直接追加在此文件中,Claude Code 自动加载
322
+ */
323
+ async function writeRoleClaudeMd(sharedDir, role) {
324
+ const roleDir = join(sharedDir, 'roles', role.name);
325
+
326
+ const claudeMd = `# 角色: ${role.icon} ${role.displayName}
327
+ ${role.claudeMd || role.description}
328
+
329
+ # 个人记忆
330
+ _在这里记录重要的信息、决策、进展和待办事项。_
331
+ `;
332
+
333
+ await fs.writeFile(join(roleDir, 'CLAUDE.md'), claudeMd);
334
+ }
335
+
336
+ /**
337
+ * 角色变动时更新 .crew/CLAUDE.md
338
+ */
339
+ async function updateSharedClaudeMd(session) {
340
+ const roles = Array.from(session.roles.values());
341
+ await writeSharedClaudeMd(session.sharedDir, session.goal, roles, session.projectDir);
342
+ }
343
+
344
+ // =====================================================================
345
+ // Session Persistence
346
+ // =====================================================================
347
+
348
+ /**
349
+ * 保存角色的 claudeSessionId 到文件
350
+ */
351
+ async function saveRoleSessionId(sharedDir, roleName, claudeSessionId) {
352
+ const sessionsDir = join(sharedDir, 'sessions');
353
+ await fs.mkdir(sessionsDir, { recursive: true });
354
+ const filePath = join(sessionsDir, `${roleName}.json`);
355
+ await fs.writeFile(filePath, JSON.stringify({
356
+ claudeSessionId,
357
+ savedAt: Date.now()
358
+ }));
359
+ console.log(`[Crew] Saved sessionId for ${roleName}: ${claudeSessionId}`);
360
+ }
361
+
362
+ /**
363
+ * 从文件加载角色的 claudeSessionId
364
+ */
365
+ async function loadRoleSessionId(sharedDir, roleName) {
366
+ const filePath = join(sharedDir, 'sessions', `${roleName}.json`);
367
+ try {
368
+ const data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
369
+ return data.claudeSessionId || null;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ // =====================================================================
376
+ // Role Query Management
377
+ // =====================================================================
378
+
379
+ /**
380
+ * 为角色创建持久 query 实例
381
+ * 支持 resume:如果角色之前有保存的 sessionId,自动恢复上下文
382
+ */
383
+ async function createRoleQuery(session, roleName) {
384
+ const role = session.roles.get(roleName);
385
+ if (!role) throw new Error(`Role not found: ${roleName}`);
386
+
387
+ const inputStream = new Stream();
388
+ const abortController = new AbortController();
389
+
390
+ const systemPrompt = buildRoleSystemPrompt(role, session);
391
+
392
+ // 尝试加载之前保存的 sessionId
393
+ const savedSessionId = await loadRoleSessionId(session.sharedDir, roleName);
394
+
395
+ // ★ cwd 设为角色目录,Claude Code 自动加载:
396
+ // 1. .crew/roles/{roleName}/CLAUDE.md(角色定义+个人记忆)
397
+ // 2. .crew/CLAUDE.md(共享目标+团队信息+共享记忆)
398
+ // 3. {projectDir}/CLAUDE.md(项目级,如果有的话)
399
+ const roleCwd = join(session.sharedDir, 'roles', roleName);
400
+
401
+ const queryOptions = {
402
+ cwd: roleCwd,
403
+ permissionMode: 'bypassPermissions',
404
+ abort: abortController.signal,
405
+ model: role.model || 'sonnet',
406
+ appendSystemPrompt: systemPrompt
407
+ };
408
+
409
+ // 如果有保存的 sessionId,使用 resume 恢复上下文
410
+ if (savedSessionId) {
411
+ queryOptions.resume = savedSessionId;
412
+ console.log(`[Crew] Resuming ${roleName} with sessionId: ${savedSessionId}`);
413
+ }
414
+
415
+ const roleQuery = query({
416
+ prompt: inputStream,
417
+ options: queryOptions
418
+ });
419
+
420
+ const roleState = {
421
+ query: roleQuery,
422
+ inputStream,
423
+ abortController,
424
+ accumulatedText: '',
425
+ turnActive: false,
426
+ claudeSessionId: savedSessionId
427
+ };
428
+
429
+ session.roleStates.set(roleName, roleState);
430
+
431
+ // 异步处理角色输出
432
+ processRoleOutput(session, roleName, roleQuery, roleState);
433
+
434
+ return roleState;
435
+ }
436
+
437
+ /**
438
+ * 构建角色的 system prompt(精简版)
439
+ * Memory 和工作区信息已通过 CLAUDE.md 自动加载,此处只补充路由规则
440
+ */
441
+ function buildRoleSystemPrompt(role, session) {
442
+ const allRoles = Array.from(session.roles.values());
443
+ const otherRoles = allRoles.filter(r => r.name !== role.name);
444
+
445
+ let prompt = `# 团队协作
446
+ 你正在一个 AI 团队中工作。项目目标是: ${session.goal}
447
+
448
+ 团队成员:
449
+ ${allRoles.map(r => `- ${r.icon} ${r.displayName}: ${r.description}${r.isDecisionMaker ? ' (决策者)' : ''}`).join('\n')}`;
450
+
451
+ if (otherRoles.length > 0) {
452
+ prompt += `\n\n# 路由规则
453
+ 当你完成当前任务并需要将结果传递给其他角色时,在你的回复最末尾添加一个 ROUTE 块:
454
+
455
+ \`\`\`
456
+ ---ROUTE---
457
+ to: <角色name>
458
+ summary: <简要说明要传递什么>
459
+ ---END_ROUTE---
460
+ \`\`\`
461
+
462
+ 可用的路由目标:
463
+ ${otherRoles.map(r => `- ${r.name}: ${r.displayName}`).join('\n')}
464
+ - human: 人工(只在决策者也无法决定时使用)
465
+
466
+ 注意:
467
+ - 如果你的工作还没完成,不需要添加 ROUTE 块
468
+ - 如果你遇到不确定的问题,@ 决策者 "${session.decisionMaker}",而不是直接 @ human
469
+ - 如果你是决策者且遇到需要人类判断的问题,才 @ human
470
+ - 每次回复最多只能有一个 ROUTE 块
471
+ - ROUTE 块必须在回复的最末尾`;
472
+ }
473
+
474
+ // 决策者额外 prompt
475
+ if (role.isDecisionMaker) {
476
+ prompt += `\n\n# 决策者职责
477
+ 你是团队的决策者。其他角色遇到不确定的情况会请求你的决策。
478
+ - 如果你有足够的信息做出决策,直接决定并 @相关角色执行
479
+ - 如果你需要更多信息,@具体角色请求补充
480
+ - 如果问题超出你的能力范围或需要业务判断,@human 请人类决定
481
+ - 你可以随时审查其他角色的工作并给出反馈`;
482
+ }
483
+
484
+ return prompt;
485
+ }
486
+
487
+ /**
488
+ * 构建初始任务 prompt
489
+ */
490
+ function buildInitialTask(goal, firstRole, allRoles) {
491
+ return `项目启动!
492
+
493
+ 目标: ${goal}
494
+
495
+ 你是第一个开始工作的角色。请分析目标,开始你的工作。
496
+ 完成后,通过 ROUTE 块将结果传递给下一个合适的角色。
497
+
498
+ 团队中可用的角色:
499
+ ${allRoles.map(r => `- ${r.icon} ${r.name}: ${r.displayName} - ${r.description}`).join('\n')}`;
500
+ }
501
+
502
+ // =====================================================================
503
+ // Role Output Processing
504
+ // =====================================================================
505
+
506
+ /**
507
+ * 处理角色的流式输出
508
+ */
509
+ async function processRoleOutput(session, roleName, roleQuery, roleState) {
510
+ try {
511
+ for await (const message of roleQuery) {
512
+ // 检查 session 是否已停止
513
+ if (session.status === 'stopped') break;
514
+
515
+ if (message.type === 'system' && message.subtype === 'init') {
516
+ roleState.claudeSessionId = message.session_id;
517
+ console.log(`[Crew] ${roleName} session: ${message.session_id}`);
518
+ continue;
519
+ }
520
+
521
+ if (message.type === 'assistant') {
522
+ // 转发流式输出到 Web
523
+ sendCrewOutput(session, roleName, 'text', message);
524
+
525
+ // 累积文本用于路由解析
526
+ const content = message.message?.content;
527
+ if (content) {
528
+ if (typeof content === 'string') {
529
+ roleState.accumulatedText += content;
530
+ } else if (Array.isArray(content)) {
531
+ for (const block of content) {
532
+ if (block.type === 'text') {
533
+ roleState.accumulatedText += block.text;
534
+ } else if (block.type === 'tool_use') {
535
+ // 转发 tool use
536
+ sendCrewOutput(session, roleName, 'tool_use', message);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ } else if (message.type === 'user') {
542
+ // tool results
543
+ sendCrewOutput(session, roleName, 'tool_result', message);
544
+ } else if (message.type === 'result') {
545
+ // ★ Turn 完成!
546
+ console.log(`[Crew] ${roleName} turn completed`);
547
+
548
+ // 更新费用
549
+ if (message.total_cost_usd) {
550
+ session.costUsd += message.total_cost_usd;
551
+ }
552
+
553
+ // ★ 持久化 sessionId(每次 turn 完成后保存,用于后续 resume)
554
+ if (roleState.claudeSessionId) {
555
+ saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
556
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
557
+ }
558
+
559
+ // 解析路由
560
+ const route = parseRoute(roleState.accumulatedText);
561
+ roleState.accumulatedText = '';
562
+ roleState.turnActive = false;
563
+
564
+ // 通知 turn 完成
565
+ sendCrewMessage({
566
+ type: 'crew_turn_completed',
567
+ sessionId: session.id,
568
+ role: roleName
569
+ });
570
+
571
+ // 发送状态更新
572
+ sendStatusUpdate(session);
573
+
574
+ // 执行路由
575
+ if (route) {
576
+ await executeRoute(session, roleName, route);
577
+ } else {
578
+ // 没有路由,角色完成了当前工作但没有指定下一步
579
+ // 检查是否有人的消息在排队
580
+ await processHumanQueue(session);
581
+ }
582
+ }
583
+ }
584
+ } catch (error) {
585
+ if (error.name === 'AbortError') {
586
+ console.log(`[Crew] ${roleName} aborted`);
587
+ } else {
588
+ console.error(`[Crew] ${roleName} error:`, error.message);
589
+ // 通知决策者
590
+ if (roleName !== session.decisionMaker) {
591
+ const errorMsg = `角色 ${roleName} 发生错误: ${error.message}`;
592
+ await dispatchToRole(session, session.decisionMaker, errorMsg, roleName);
593
+ } else {
594
+ // 决策者自己出错了,通知人
595
+ sendCrewMessage({
596
+ type: 'crew_human_needed',
597
+ sessionId: session.id,
598
+ fromRole: roleName,
599
+ reason: 'error',
600
+ message: `决策者 ${roleName} 发生错误: ${error.message}`
601
+ });
602
+ session.status = 'waiting_human';
603
+ sendStatusUpdate(session);
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ // =====================================================================
610
+ // Route Parsing & Execution
611
+ // =====================================================================
612
+
613
+ /**
614
+ * 从累积文本中解析 ROUTE 块
615
+ */
616
+ function parseRoute(text) {
617
+ // 主格式
618
+ const match = text.match(/---ROUTE---\s*\n\s*to:\s*(.+?)\s*\n\s*summary:\s*(.+?)\s*\n\s*---END_ROUTE---/s);
619
+ if (match) {
620
+ return {
621
+ to: match[1].trim().toLowerCase(),
622
+ summary: match[2].trim()
623
+ };
624
+ }
625
+
626
+ // 备用格式(更宽松)
627
+ const altMatch = text.match(/---ROUTE---\s*\n([\s\S]*?)---END_ROUTE---/);
628
+ if (altMatch) {
629
+ const block = altMatch[1];
630
+ const toMatch = block.match(/to:\s*(.+)/i);
631
+ const summaryMatch = block.match(/summary:\s*(.+)/i);
632
+ if (toMatch) {
633
+ return {
634
+ to: toMatch[1].trim().toLowerCase(),
635
+ summary: summaryMatch ? summaryMatch[1].trim() : ''
636
+ };
637
+ }
638
+ }
639
+
640
+ return null;
641
+ }
642
+
643
+ /**
644
+ * 执行路由
645
+ */
646
+ async function executeRoute(session, fromRole, route) {
647
+ const { to, summary } = route;
648
+
649
+ // 增加轮次计数
650
+ session.round++;
651
+
652
+ // 检查最大轮次
653
+ if (session.round >= session.maxRounds) {
654
+ console.log(`[Crew] Max rounds (${session.maxRounds}) reached`);
655
+ session.status = 'completed';
656
+ sendCrewMessage({
657
+ type: 'crew_status',
658
+ sessionId: session.id,
659
+ status: 'max_rounds_reached',
660
+ round: session.round,
661
+ maxRounds: session.maxRounds
662
+ });
663
+ sendStatusUpdate(session);
664
+ return;
665
+ }
666
+
667
+ // 发送路由消息(UI 显示 → @xxx)
668
+ sendCrewOutput(session, fromRole, 'route', null, { routeTo: to, routeSummary: summary });
669
+
670
+ // 路由到 human
671
+ if (to === 'human') {
672
+ session.status = 'waiting_human';
673
+ session.waitingHumanContext = {
674
+ fromRole,
675
+ reason: 'requested',
676
+ message: summary
677
+ };
678
+ sendCrewMessage({
679
+ type: 'crew_human_needed',
680
+ sessionId: session.id,
681
+ fromRole,
682
+ reason: 'requested',
683
+ message: summary
684
+ });
685
+ sendStatusUpdate(session);
686
+ return;
687
+ }
688
+
689
+ // 路由到指定角色
690
+ if (session.roles.has(to)) {
691
+ // 先检查是否有人的消息在排队
692
+ if (session.humanMessageQueue.length > 0) {
693
+ // 人的消息优先
694
+ await processHumanQueue(session);
695
+ } else {
696
+ const taskPrompt = buildRoutePrompt(fromRole, summary, session);
697
+ await dispatchToRole(session, to, taskPrompt, fromRole);
698
+ }
699
+ } else {
700
+ console.warn(`[Crew] Unknown route target: ${to}`);
701
+ // 转给决策者
702
+ const errorMsg = `路由目标 "${to}" 不存在。来自 ${fromRole} 的消息: ${summary}`;
703
+ await dispatchToRole(session, session.decisionMaker, errorMsg, 'system');
704
+ }
705
+ }
706
+
707
+ /**
708
+ * 构建路由转发的 prompt
709
+ */
710
+ function buildRoutePrompt(fromRole, summary, session) {
711
+ const fromRoleConfig = session.roles.get(fromRole);
712
+ const fromName = fromRoleConfig ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRole;
713
+ return `来自 ${fromName} 的消息:\n${summary}\n\n请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
714
+ }
715
+
716
+ // =====================================================================
717
+ // Message Dispatching
718
+ // =====================================================================
719
+
720
+ /**
721
+ * 向角色发送消息
722
+ */
723
+ async function dispatchToRole(session, roleName, content, fromSource) {
724
+ if (session.status === 'paused' || session.status === 'stopped') {
725
+ console.log(`[Crew] Session ${session.status}, skipping dispatch to ${roleName}`);
726
+ return;
727
+ }
728
+
729
+ let roleState = session.roleStates.get(roleName);
730
+
731
+ // 如果角色没有 query 实例,创建一个(支持 resume)
732
+ if (!roleState || !roleState.query || !roleState.inputStream) {
733
+ roleState = await createRoleQuery(session, roleName);
734
+ }
735
+
736
+ // 记录消息历史
737
+ session.messageHistory.push({
738
+ from: fromSource,
739
+ to: roleName,
740
+ content: typeof content === 'string' ? content.substring(0, 200) : '...',
741
+ timestamp: Date.now()
742
+ });
743
+
744
+ // 发送
745
+ roleState.turnActive = true;
746
+ roleState.accumulatedText = '';
747
+ roleState.inputStream.enqueue({
748
+ type: 'user',
749
+ message: { role: 'user', content }
750
+ });
751
+
752
+ console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}`);
753
+ }
754
+
755
+ // =====================================================================
756
+ // Human Interaction
757
+ // =====================================================================
758
+
759
+ /**
760
+ * 处理人的输入
761
+ */
762
+ export async function handleCrewHumanInput(msg) {
763
+ const { sessionId, content, targetRole } = msg;
764
+ const session = crewSessions.get(sessionId);
765
+ if (!session) {
766
+ console.warn(`[Crew] Session not found: ${sessionId}`);
767
+ return;
768
+ }
769
+
770
+ // 注意:不在这里发送人的消息到 Web(前端已本地添加,避免重复)
771
+
772
+ // 如果在等待人工介入
773
+ if (session.status === 'waiting_human') {
774
+ const waitingContext = session.waitingHumanContext;
775
+ session.status = 'running';
776
+ session.waitingHumanContext = null;
777
+ sendStatusUpdate(session);
778
+
779
+ // 发给请求人工介入的角色,或指定的目标角色
780
+ const target = targetRole || waitingContext?.fromRole || session.decisionMaker;
781
+ const humanPrompt = `人工回复:\n${content}`;
782
+ await dispatchToRole(session, target, humanPrompt, 'human');
783
+ return;
784
+ }
785
+
786
+ // 解析 @role 指令
787
+ const atMatch = content.match(/^@(\w+)\s*([\s\S]*)/);
788
+ if (atMatch) {
789
+ const target = atMatch[1].toLowerCase();
790
+ const message = atMatch[2].trim() || content;
791
+
792
+ if (session.roles.has(target)) {
793
+ // 检查目标角色是否正在忙
794
+ const targetState = session.roleStates.get(target);
795
+ if (targetState?.turnActive) {
796
+ // 排队
797
+ session.humanMessageQueue.push({ target, content: message, timestamp: Date.now() });
798
+ console.log(`[Crew] Human message queued for ${target} (busy)`);
799
+ return;
800
+ }
801
+ const humanPrompt = `人工消息:\n${message}`;
802
+ await dispatchToRole(session, target, humanPrompt, 'human');
803
+ return;
804
+ }
805
+ }
806
+
807
+ // 没有 @ 指定目标,发给决策者或当前活跃角色
808
+ const activeRole = findActiveRole(session);
809
+ const target = targetRole || activeRole || session.decisionMaker;
810
+
811
+ // 检查目标是否忙
812
+ const targetState = session.roleStates.get(target);
813
+ if (targetState?.turnActive) {
814
+ session.humanMessageQueue.push({ target, content, timestamp: Date.now() });
815
+ console.log(`[Crew] Human message queued for ${target} (busy)`);
816
+ return;
817
+ }
818
+
819
+ const humanPrompt = `人工消息:\n${content}`;
820
+ await dispatchToRole(session, target, humanPrompt, 'human');
821
+ }
822
+
823
+ /**
824
+ * 处理排队的人的消息
825
+ */
826
+ async function processHumanQueue(session) {
827
+ if (session.humanMessageQueue.length === 0) return;
828
+
829
+ const msg = session.humanMessageQueue.shift();
830
+ const humanPrompt = `人工消息:\n${msg.content}`;
831
+ await dispatchToRole(session, msg.target, humanPrompt, 'human');
832
+ }
833
+
834
+ /**
835
+ * 找到当前活跃的角色(最近一个 turnActive 的)
836
+ */
837
+ function findActiveRole(session) {
838
+ for (const [name, state] of session.roleStates) {
839
+ if (state.turnActive) return name;
840
+ }
841
+ return null;
842
+ }
843
+
844
+ // =====================================================================
845
+ // Control Operations
846
+ // =====================================================================
847
+
848
+ /**
849
+ * 处理控制命令
850
+ */
851
+ export async function handleCrewControl(msg) {
852
+ const { sessionId, action, targetRole } = msg;
853
+ const session = crewSessions.get(sessionId);
854
+ if (!session) {
855
+ console.warn(`[Crew] Session not found: ${sessionId}`);
856
+ return;
857
+ }
858
+
859
+ switch (action) {
860
+ case 'pause':
861
+ pauseAll(session);
862
+ break;
863
+ case 'resume':
864
+ await resumeSession(session);
865
+ break;
866
+ case 'stop_role':
867
+ if (targetRole) await stopRole(session, targetRole);
868
+ break;
869
+ case 'stop_all':
870
+ await stopAll(session);
871
+ break;
872
+ default:
873
+ console.warn(`[Crew] Unknown control action: ${action}`);
874
+ }
875
+ }
876
+
877
+ /**
878
+ * 暂停所有角色
879
+ */
880
+ function pauseAll(session) {
881
+ session.status = 'paused';
882
+ console.log(`[Crew] Session ${session.id} paused`);
883
+
884
+ sendCrewOutput(session, 'system', 'system', {
885
+ type: 'assistant',
886
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已暂停' }] }
887
+ });
888
+ sendStatusUpdate(session);
889
+ }
890
+
891
+ /**
892
+ * 恢复 session
893
+ */
894
+ async function resumeSession(session) {
895
+ if (session.status !== 'paused') return;
896
+
897
+ session.status = 'running';
898
+ console.log(`[Crew] Session ${session.id} resumed`);
899
+
900
+ sendCrewOutput(session, 'system', 'system', {
901
+ type: 'assistant',
902
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已恢复' }] }
903
+ });
904
+ sendStatusUpdate(session);
905
+
906
+ // 恢复后检查是否有排队的人的消息
907
+ await processHumanQueue(session);
908
+ }
909
+
910
+ /**
911
+ * 停止单个角色
912
+ */
913
+ async function stopRole(session, roleName) {
914
+ const roleState = session.roleStates.get(roleName);
915
+ if (roleState) {
916
+ // 保存 sessionId
917
+ if (roleState.claudeSessionId) {
918
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
919
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
920
+ }
921
+ if (roleState.abortController) {
922
+ roleState.abortController.abort();
923
+ }
924
+ roleState.query = null;
925
+ roleState.inputStream = null;
926
+ roleState.turnActive = false;
927
+ session.roleStates.delete(roleName);
928
+ }
929
+
930
+ sendCrewOutput(session, 'system', 'system', {
931
+ type: 'assistant',
932
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 已停止` }] }
933
+ });
934
+ sendStatusUpdate(session);
935
+ console.log(`[Crew] Role ${roleName} stopped`);
936
+ }
937
+
938
+ /**
939
+ * 终止整个 session
940
+ */
941
+ async function stopAll(session) {
942
+ session.status = 'stopped';
943
+
944
+ // 保存所有角色的 sessionId
945
+ for (const [roleName, roleState] of session.roleStates) {
946
+ if (roleState.claudeSessionId) {
947
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
948
+ .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
949
+ }
950
+ if (roleState.abortController) {
951
+ roleState.abortController.abort();
952
+ }
953
+ console.log(`[Crew] Stopping role: ${roleName}`);
954
+ }
955
+ session.roleStates.clear();
956
+
957
+ sendCrewOutput(session, 'system', 'system', {
958
+ type: 'assistant',
959
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已终止' }] }
960
+ });
961
+ sendStatusUpdate(session);
962
+
963
+ // 从活跃 sessions 中移除
964
+ crewSessions.delete(session.id);
965
+ console.log(`[Crew] Session ${session.id} stopped`);
966
+ }
967
+
968
+ // =====================================================================
969
+ // Message Helpers
970
+ // =====================================================================
971
+
972
+ /**
973
+ * 发送 crew 消息到 server(透传到 Web)
974
+ */
975
+ function sendCrewMessage(msg) {
976
+ if (ctx.sendToServer) {
977
+ ctx.sendToServer(msg);
978
+ }
979
+ }
980
+
981
+ /**
982
+ * 发送角色输出到 Web
983
+ */
984
+ function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
985
+ const role = session.roles.get(roleName);
986
+
987
+ sendCrewMessage({
988
+ type: 'crew_output',
989
+ sessionId: session.id,
990
+ role: roleName,
991
+ roleIcon: role?.icon || (roleName === 'human' ? 'H' : roleName === 'system' ? 'S' : 'A'),
992
+ roleName: role?.displayName || roleName,
993
+ outputType, // 'text' | 'tool_use' | 'tool_result' | 'route' | 'system'
994
+ data: rawMessage,
995
+ ...extra
996
+ });
997
+ }
998
+
999
+ /**
1000
+ * 发送 session 状态更新
1001
+ */
1002
+ function sendStatusUpdate(session) {
1003
+ const currentRole = findActiveRole(session);
1004
+
1005
+ sendCrewMessage({
1006
+ type: 'crew_status',
1007
+ sessionId: session.id,
1008
+ status: session.status,
1009
+ currentRole,
1010
+ round: session.round,
1011
+ maxRounds: session.maxRounds,
1012
+ costUsd: session.costUsd,
1013
+ roles: Array.from(session.roles.values()).map(r => ({
1014
+ name: r.name,
1015
+ displayName: r.displayName,
1016
+ icon: r.icon,
1017
+ description: r.description,
1018
+ isDecisionMaker: r.isDecisionMaker || false,
1019
+ model: r.model
1020
+ })),
1021
+ activeRoles: Array.from(session.roleStates.entries())
1022
+ .filter(([, s]) => s.turnActive)
1023
+ .map(([name]) => name)
1024
+ });
1025
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.0.38",
3
+ "version": "0.0.41",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -32,20 +32,9 @@
32
32
  "url": "https://github.com/yeaft/claude-web-chat/issues"
33
33
  },
34
34
  "files": [
35
- "cli.js",
36
- "service.js",
37
- "index.js",
38
- "context.js",
39
- "connection.js",
40
- "conversation.js",
41
- "claude.js",
42
- "terminal.js",
43
- "proxy.js",
44
- "workbench.js",
45
- "history.js",
46
- "encryption.js",
35
+ "*.js",
47
36
  "sdk/",
48
- "scripts/agent-tray.ps1"
37
+ "scripts/"
49
38
  ],
50
39
  "license": "MIT",
51
40
  "author": "Yeaft",
@@ -0,0 +1,18 @@
1
+ # 创建开机自启动快捷方式
2
+ $AgentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
3
+ $StartupFolder = [Environment]::GetFolderPath('Startup')
4
+ $ShortcutPath = Join-Path $StartupFolder "Claude Agent.lnk"
5
+
6
+ $WshShell = New-Object -ComObject WScript.Shell
7
+ $Shortcut = $WshShell.CreateShortcut($ShortcutPath)
8
+ $Shortcut.TargetPath = "powershell.exe"
9
+ $Shortcut.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$AgentDir\start-with-tray.ps1`""
10
+ $Shortcut.WorkingDirectory = $AgentDir
11
+ $Shortcut.Description = "Claude Agent with Tray"
12
+ $Shortcut.WindowStyle = 7 # Minimized
13
+ $Shortcut.Save()
14
+
15
+ Write-Host "Startup shortcut created at:" -ForegroundColor Green
16
+ Write-Host " $ShortcutPath" -ForegroundColor Cyan
17
+ Write-Host ""
18
+ Write-Host "The agent will now start automatically when you log in." -ForegroundColor Green
@@ -0,0 +1,46 @@
1
+ // 加载 .env 文件
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function loadEnv() {
6
+ const agentDir = path.join(__dirname, '..');
7
+ const envPath = path.join(agentDir, '.env');
8
+ const env = { NODE_ENV: 'production' };
9
+
10
+ if (fs.existsSync(envPath)) {
11
+ const content = fs.readFileSync(envPath, 'utf-8');
12
+ for (const line of content.split('\n')) {
13
+ const match = line.match(/^\s*([^#][^=]*)\s*=\s*(.*)$/);
14
+ if (match) {
15
+ const key = match[1].trim();
16
+ let value = match[2].trim();
17
+ // 移除引号
18
+ value = value.replace(/^["']|["']$/g, '');
19
+ env[key] = value;
20
+ }
21
+ }
22
+ }
23
+ return env;
24
+ }
25
+
26
+ module.exports = {
27
+ apps: [{
28
+ name: 'claude-agent',
29
+ script: 'cli.js',
30
+ cwd: path.join(__dirname, '..'),
31
+ // 从 .env 文件加载环境变量
32
+ env: loadEnv(),
33
+ // 自动重启配置
34
+ autorestart: true,
35
+ watch: false,
36
+ max_restarts: 10,
37
+ restart_delay: 5000,
38
+ // 日志配置
39
+ log_date_format: 'YYYY-MM-DD HH:mm:ss',
40
+ error_file: 'logs/error.log',
41
+ out_file: 'logs/out.log',
42
+ merge_logs: true,
43
+ // 内存超限自动重启
44
+ max_memory_restart: '500M',
45
+ }]
46
+ };
@@ -0,0 +1,59 @@
1
+ # Claude Agent PM2 托管启动脚本
2
+ # 用法: .\pm2-start.ps1
3
+
4
+ $ErrorActionPreference = "Stop"
5
+ $AgentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
6
+
7
+ # 切换到 agent 目录
8
+ Set-Location $AgentDir
9
+
10
+ # 检查 pm2 是否安装
11
+ $pm2Path = Get-Command pm2 -ErrorAction SilentlyContinue
12
+ if (-not $pm2Path) {
13
+ Write-Host "Installing pm2 globally..." -ForegroundColor Yellow
14
+ npm install -g pm2
15
+ # Windows 上需要安装 pm2-windows-startup
16
+ npm install -g pm2-windows-startup
17
+ }
18
+
19
+ # 安装依赖 (每次都检查确保完整)
20
+ Write-Host "Checking dependencies..." -ForegroundColor Yellow
21
+ npm install --silent
22
+ if ($LASTEXITCODE -ne 0) {
23
+ Write-Host "Failed to install dependencies!" -ForegroundColor Red
24
+ exit 1
25
+ }
26
+
27
+ # 检查 .env 文件
28
+ if (-not (Test-Path ".env")) {
29
+ Write-Host "No .env file found. Creating from .env.example..." -ForegroundColor Yellow
30
+ if (Test-Path ".env.example") {
31
+ Copy-Item ".env.example" ".env"
32
+ Write-Host "Please edit .env with your configuration, then run this script again." -ForegroundColor Red
33
+ exit 1
34
+ } else {
35
+ Write-Host "No .env.example found!" -ForegroundColor Red
36
+ exit 1
37
+ }
38
+ }
39
+
40
+ # 启动 pm2
41
+ Write-Host ""
42
+ Write-Host "Starting Claude Agent with pm2..." -ForegroundColor Green
43
+ pm2 start ecosystem.config.cjs
44
+
45
+ Write-Host ""
46
+ Write-Host "Agent started!" -ForegroundColor Green
47
+ Write-Host ""
48
+ Write-Host "Management commands:" -ForegroundColor Cyan
49
+ Write-Host " pm2 status - Check status"
50
+ Write-Host " pm2 logs claude-agent - View logs (follow mode)"
51
+ Write-Host " pm2 logs claude-agent --lines 100 - View last 100 lines"
52
+ Write-Host " pm2 stop claude-agent - Stop agent"
53
+ Write-Host " pm2 restart claude-agent - Restart agent"
54
+ Write-Host " pm2 delete claude-agent - Remove from pm2"
55
+ Write-Host ""
56
+ Write-Host "To enable startup on Windows boot:" -ForegroundColor Yellow
57
+ Write-Host " 1. pm2 save"
58
+ Write-Host " 2. pm2-startup install"
59
+ Write-Host ""
@@ -0,0 +1,36 @@
1
+ # 启动 Claude Agent (pm2 托管) + 托盘图标
2
+ $AgentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
3
+ Set-Location $AgentDir
4
+
5
+ # 检查 pm2
6
+ $pm2 = Get-Command pm2 -ErrorAction SilentlyContinue
7
+ if (-not $pm2) {
8
+ Write-Host "Installing pm2..." -ForegroundColor Yellow
9
+ npm install -g pm2
10
+ }
11
+
12
+ # 检查并安装 pm2-logrotate
13
+ $logrotateInstalled = pm2 list 2>$null | Select-String "pm2-logrotate"
14
+ if (-not $logrotateInstalled) {
15
+ Write-Host "Installing pm2-logrotate..." -ForegroundColor Yellow
16
+ pm2 install pm2-logrotate
17
+ # 配置 logrotate
18
+ pm2 set pm2-logrotate:max_size 10M
19
+ pm2 set pm2-logrotate:retain 7
20
+ pm2 set pm2-logrotate:compress true
21
+ Write-Host "pm2-logrotate configured (max 10MB, keep 7 files)" -ForegroundColor Cyan
22
+ }
23
+
24
+ # 先停止已有进程,确保干净启动
25
+ Write-Host "Checking existing processes..." -ForegroundColor Gray
26
+ pm2 delete claude-agent 2>$null | Out-Null
27
+
28
+ # 启动 agent
29
+ Write-Host "Starting Claude Agent..." -ForegroundColor Green
30
+ pm2 start ecosystem.config.cjs
31
+
32
+ # 启动托盘(隐藏窗口)
33
+ Write-Host "Starting tray icon..." -ForegroundColor Green
34
+ Start-Process powershell -ArgumentList "-WindowStyle Hidden -File `"$AgentDir\agent-tray.ps1`"" -WindowStyle Hidden
35
+
36
+ Write-Host "Done! Check the system tray." -ForegroundColor Green