@yeaft/webchat-agent 0.0.99 → 0.0.101

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.
Files changed (2) hide show
  1. package/crew.js +496 -115
  2. package/package.json +1 -1
package/crew.js CHANGED
@@ -16,8 +16,12 @@ import { query, Stream } from './sdk/index.js';
16
16
  import { promises as fs } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { homedir } from 'os';
19
+ import { execFile as execFileCb } from 'child_process';
20
+ import { promisify } from 'util';
19
21
  import ctx from './context.js';
20
22
 
23
+ const execFile = promisify(execFileCb);
24
+
21
25
  // =====================================================================
22
26
  // Data Structures
23
27
  // =====================================================================
@@ -28,6 +32,188 @@ const crewSessions = new Map();
28
32
  // 导出供 connection.js / conversation.js 使用
29
33
  export { crewSessions };
30
34
 
35
+ // =====================================================================
36
+ // Role Multi-Instance Expansion
37
+ // =====================================================================
38
+
39
+ // 短前缀映射:用于 count > 1 时生成实例名
40
+ const SHORT_PREFIX = {
41
+ developer: 'dev',
42
+ tester: 'test',
43
+ reviewer: 'rev'
44
+ };
45
+
46
+ // 只有执行者角色支持多实例
47
+ const EXPANDABLE_ROLES = new Set(['developer', 'tester', 'reviewer']);
48
+
49
+ /**
50
+ * 展开角色列表:count > 1 的执行者角色展开为多个实例
51
+ * count === 1 或管理者角色保持原样(向后兼容)
52
+ *
53
+ * @param {Array} roles - 原始角色配置
54
+ * @returns {Array} 展开后的角色列表
55
+ */
56
+ function expandRoles(roles) {
57
+ // 找到 developer 的 count,reviewer/tester 自动跟随
58
+ const devRole = roles.find(r => r.name === 'developer');
59
+ const devCount = devRole?.count > 1 ? devRole.count : 1;
60
+
61
+ const expanded = [];
62
+ for (const role of roles) {
63
+ const isExpandable = EXPANDABLE_ROLES.has(role.name);
64
+ // reviewer/tester 跟随 developer 的 count
65
+ const count = isExpandable ? devCount : 1;
66
+
67
+ if (count <= 1 || !isExpandable) {
68
+ // 单实例:保持原样,添加元数据
69
+ expanded.push({
70
+ ...role,
71
+ roleType: role.name,
72
+ groupIndex: 0
73
+ });
74
+ } else {
75
+ // 多实例展开
76
+ const prefix = SHORT_PREFIX[role.name] || role.name;
77
+ for (let i = 1; i <= count; i++) {
78
+ expanded.push({
79
+ ...role,
80
+ name: `${prefix}-${i}`,
81
+ displayName: `${role.displayName}-${i}`,
82
+ roleType: role.name,
83
+ groupIndex: i,
84
+ count: undefined // 展开后不再需要 count
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return expanded;
90
+ }
91
+
92
+ // =====================================================================
93
+ // Git Worktree Management
94
+ // =====================================================================
95
+
96
+ /**
97
+ * 为多实例开发组创建 git worktree
98
+ * 每个 groupIndex 对应一个 worktree,同组的 dev/rev/test 共享
99
+ * count=1 时不创建(向后兼容)
100
+ *
101
+ * @param {string} projectDir - 主项目目录
102
+ * @param {Array} roles - 展开后的角色列表
103
+ * @returns {Map<number, string>} groupIndex → worktree 路径
104
+ */
105
+ async function initWorktrees(projectDir, roles) {
106
+ const groupIndices = [...new Set(roles.filter(r => r.groupIndex > 0).map(r => r.groupIndex))];
107
+ if (groupIndices.length === 0) return new Map();
108
+
109
+ const worktreeBase = join(projectDir, '.worktrees');
110
+ await fs.mkdir(worktreeBase, { recursive: true });
111
+
112
+ // 获取 git 已知的 worktree 列表
113
+ let knownWorktrees = new Set();
114
+ try {
115
+ const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir });
116
+ for (const line of stdout.split('\n')) {
117
+ if (line.startsWith('worktree ')) {
118
+ knownWorktrees.add(line.slice('worktree '.length).trim());
119
+ }
120
+ }
121
+ } catch {
122
+ // git worktree list 失败,视为空集
123
+ }
124
+
125
+ const worktreeMap = new Map();
126
+
127
+ for (const idx of groupIndices) {
128
+ const wtDir = join(worktreeBase, `dev-${idx}`);
129
+ const branch = `crew/dev-${idx}`;
130
+
131
+ // 检查目录是否存在
132
+ let dirExists = false;
133
+ try {
134
+ await fs.access(wtDir);
135
+ dirExists = true;
136
+ } catch {}
137
+
138
+ if (dirExists) {
139
+ if (knownWorktrees.has(wtDir)) {
140
+ // 目录存在且 git 记录中也有,直接复用
141
+ console.log(`[Crew] Worktree already exists: ${wtDir}`);
142
+ worktreeMap.set(idx, wtDir);
143
+ continue;
144
+ } else {
145
+ // 孤立目录:目录存在但 git 不认识,先删除再重建
146
+ console.warn(`[Crew] Orphaned worktree dir, removing: ${wtDir}`);
147
+ await fs.rm(wtDir, { recursive: true, force: true }).catch(() => {});
148
+ }
149
+ }
150
+
151
+ try {
152
+ // 创建分支(如果不存在)
153
+ try {
154
+ await execFile('git', ['branch', branch], { cwd: projectDir });
155
+ } catch {
156
+ // 分支已存在,忽略
157
+ }
158
+
159
+ // 创建 worktree
160
+ await execFile('git', ['worktree', 'add', wtDir, branch], { cwd: projectDir });
161
+ console.log(`[Crew] Created worktree: ${wtDir} on branch ${branch}`);
162
+ worktreeMap.set(idx, wtDir);
163
+ } catch (e) {
164
+ console.error(`[Crew] Failed to create worktree for group ${idx}:`, e.message);
165
+ }
166
+ }
167
+
168
+ return worktreeMap;
169
+ }
170
+
171
+ /**
172
+ * 清理 session 的 git worktrees
173
+ * @param {string} projectDir - 主项目目录
174
+ */
175
+ async function cleanupWorktrees(projectDir) {
176
+ const worktreeBase = join(projectDir, '.worktrees');
177
+
178
+ try {
179
+ await fs.access(worktreeBase);
180
+ } catch {
181
+ return; // .worktrees 目录不存在,无需清理
182
+ }
183
+
184
+ try {
185
+ const entries = await fs.readdir(worktreeBase);
186
+ for (const entry of entries) {
187
+ if (!entry.startsWith('dev-')) continue;
188
+ const wtDir = join(worktreeBase, entry);
189
+ const branch = `crew/${entry}`;
190
+
191
+ try {
192
+ await execFile('git', ['worktree', 'remove', wtDir, '--force'], { cwd: projectDir });
193
+ console.log(`[Crew] Removed worktree: ${wtDir}`);
194
+ } catch (e) {
195
+ console.warn(`[Crew] Failed to remove worktree ${wtDir}:`, e.message);
196
+ }
197
+
198
+ try {
199
+ await execFile('git', ['branch', '-D', branch], { cwd: projectDir });
200
+ console.log(`[Crew] Deleted branch: ${branch}`);
201
+ } catch (e) {
202
+ console.warn(`[Crew] Failed to delete branch ${branch}:`, e.message);
203
+ }
204
+ }
205
+
206
+ // 尝试删除 .worktrees 目录(如果已空)
207
+ try {
208
+ await fs.rmdir(worktreeBase);
209
+ } catch {
210
+ // 目录不空或其他原因,忽略
211
+ }
212
+ } catch (e) {
213
+ console.error(`[Crew] Failed to cleanup worktrees:`, e.message);
214
+ }
215
+ }
216
+
31
217
  // =====================================================================
32
218
  // Crew Session Index (~/.claude/crew-sessions.json)
33
219
  // =====================================================================
@@ -247,7 +433,7 @@ export async function resumeCrewSession(msg) {
247
433
  uiMessages: [], // will be loaded from messages.json
248
434
  humanMessageQueue: [],
249
435
  waitingHumanContext: null,
250
- pendingRoute: null,
436
+ pendingRoutes: [],
251
437
  userId: userId || meta.userId,
252
438
  username: username || meta.username,
253
439
  createdAt: meta.createdAt || Date.now()
@@ -297,12 +483,15 @@ export async function createCrewSession(msg) {
297
483
  projectDir,
298
484
  sharedDir: sharedDirRel,
299
485
  goal,
300
- roles = [], // [{ name, displayName, icon, description, claudeMd, model, budget, isDecisionMaker }]
486
+ roles: rawRoles = [], // [{ name, displayName, icon, description, claudeMd, model, budget, isDecisionMaker, count }]
301
487
  maxRounds = 20,
302
488
  userId,
303
489
  username
304
490
  } = msg;
305
491
 
492
+ // 展开多实例角色(count > 1 的执行者角色)
493
+ const roles = expandRoles(rawRoles);
494
+
306
495
  // 解析共享目录(相对路径相对于 projectDir)
307
496
  const sharedDir = sharedDirRel?.startsWith('/')
308
497
  ? sharedDirRel
@@ -311,6 +500,17 @@ export async function createCrewSession(msg) {
311
500
  // 初始化共享区
312
501
  await initSharedDir(sharedDir, goal, roles, projectDir);
313
502
 
503
+ // 初始化 git worktrees(仅多实例时)
504
+ const worktreeMap = await initWorktrees(projectDir, roles);
505
+ // 回填 workDir:同组的 dev-N/rev-N/test-N 共享同一个 worktree
506
+ for (const role of roles) {
507
+ if (role.groupIndex > 0 && worktreeMap.has(role.groupIndex)) {
508
+ role.workDir = worktreeMap.get(role.groupIndex);
509
+ // 重新写入 CLAUDE.md(加入工作目录信息)
510
+ await writeRoleClaudeMd(sharedDir, role);
511
+ }
512
+ }
513
+
314
514
  // 找到决策者
315
515
  const decisionMaker = roles.find(r => r.isDecisionMaker)?.name || roles[0]?.name || null;
316
516
 
@@ -332,7 +532,7 @@ export async function createCrewSession(msg) {
332
532
  uiMessages: [], // 精简的 UI 消息历史(用于恢复时重放)
333
533
  humanMessageQueue: [], // 人的消息排队
334
534
  waitingHumanContext: null, // { fromRole, reason, message }
335
- pendingRoute: null, // { fromRole, route } — 暂停时未完成的路由
535
+ pendingRoutes: [], // [{ fromRole, route }] — 暂停时未完成的路由
336
536
  userId,
337
537
  username,
338
538
  createdAt: Date.now()
@@ -353,7 +553,9 @@ export async function createCrewSession(msg) {
353
553
  icon: r.icon,
354
554
  description: r.description,
355
555
  isDecisionMaker: r.isDecisionMaker || false,
356
- model: r.model
556
+ model: r.model,
557
+ roleType: r.roleType,
558
+ groupIndex: r.groupIndex
357
559
  })),
358
560
  decisionMaker,
359
561
  maxRounds,
@@ -395,51 +597,58 @@ export async function addRoleToSession(msg) {
395
597
  return;
396
598
  }
397
599
 
398
- if (session.roles.has(role.name)) {
399
- console.warn(`[Crew] Role already exists: ${role.name}`);
400
- return;
401
- }
600
+ // 展开多实例(count > 1 时)
601
+ const rolesToAdd = expandRoles([role]);
402
602
 
403
- // 添加角色到 session
404
- session.roles.set(role.name, role);
603
+ for (const r of rolesToAdd) {
604
+ if (session.roles.has(r.name)) {
605
+ console.warn(`[Crew] Role already exists: ${r.name}`);
606
+ continue;
607
+ }
405
608
 
406
- // 如果还没有决策者且新角色是决策者,更新
407
- if (role.isDecisionMaker) {
408
- session.decisionMaker = role.name;
409
- }
410
- // 如果没有任何决策者,第一个角色作为决策者
411
- if (!session.decisionMaker) {
412
- session.decisionMaker = role.name;
413
- }
609
+ // 添加角色到 session
610
+ session.roles.set(r.name, r);
414
611
 
415
- // 初始化角色目录(CLAUDE.md + memory.md)
416
- await initRoleDir(session.sharedDir, role);
612
+ // 如果还没有决策者且新角色是决策者,更新
613
+ if (r.isDecisionMaker) {
614
+ session.decisionMaker = r.name;
615
+ }
616
+ // 如果没有任何决策者,第一个角色作为决策者
617
+ if (!session.decisionMaker) {
618
+ session.decisionMaker = r.name;
619
+ }
417
620
 
418
- // 更新共享 CLAUDE.md(增量添加新角色信息)
419
- await updateSharedClaudeMd(session);
621
+ // 初始化角色目录(CLAUDE.md + memory.md)
622
+ await initRoleDir(session.sharedDir, r);
420
623
 
421
- console.log(`[Crew] Role added: ${role.name} (${role.displayName}) to session ${sessionId}`);
624
+ console.log(`[Crew] Role added: ${r.name} (${r.displayName}) to session ${sessionId}`);
422
625
 
423
- // 通知 Web 端
424
- sendCrewMessage({
425
- type: 'crew_role_added',
426
- sessionId,
427
- role: {
428
- name: role.name,
429
- displayName: role.displayName,
430
- icon: role.icon,
431
- description: role.description,
432
- isDecisionMaker: role.isDecisionMaker || false,
433
- model: role.model
434
- },
435
- decisionMaker: session.decisionMaker
436
- });
626
+ // 通知 Web 端
627
+ sendCrewMessage({
628
+ type: 'crew_role_added',
629
+ sessionId,
630
+ role: {
631
+ name: r.name,
632
+ displayName: r.displayName,
633
+ icon: r.icon,
634
+ description: r.description,
635
+ isDecisionMaker: r.isDecisionMaker || false,
636
+ model: r.model,
637
+ roleType: r.roleType,
638
+ groupIndex: r.groupIndex
639
+ },
640
+ decisionMaker: session.decisionMaker
641
+ });
437
642
 
438
- // 发送系统消息
439
- sendCrewOutput(session, 'system', 'system', {
440
- type: 'assistant',
441
- message: { role: 'assistant', content: [{ type: 'text', text: `${role.icon} ${role.displayName} 加入了群聊` }] }
442
- });
643
+ // 发送系统消息
644
+ sendCrewOutput(session, 'system', 'system', {
645
+ type: 'assistant',
646
+ message: { role: 'assistant', content: [{ type: 'text', text: `${r.icon} ${r.displayName} 加入了群聊` }] }
647
+ });
648
+ }
649
+
650
+ // 更新共享 CLAUDE.md(增量添加新角色信息)
651
+ await updateSharedClaudeMd(session);
443
652
 
444
653
  sendStatusUpdate(session);
445
654
  }
@@ -587,9 +796,20 @@ _团队共同维护,记录重要的共识、决策和信息。_
587
796
  async function writeRoleClaudeMd(sharedDir, role) {
588
797
  const roleDir = join(sharedDir, 'roles', role.name);
589
798
 
590
- const claudeMd = `# 角色: ${role.icon} ${role.displayName}
799
+ let claudeMd = `# 角色: ${role.icon} ${role.displayName}
591
800
  ${role.claudeMd || role.description}
801
+ `;
802
+
803
+ // 有独立 worktree 的角色,覆盖代码工作目录
804
+ if (role.workDir) {
805
+ claudeMd += `
806
+ # 代码工作目录
807
+ ${role.workDir}
808
+ 所有代码操作请使用此路径。不要使用项目主目录。
809
+ `;
810
+ }
592
811
 
812
+ claudeMd += `
593
813
  # 个人记忆
594
814
  _在这里记录重要的信息、决策、进展和待办事项。_
595
815
  `;
@@ -704,7 +924,18 @@ async function createRoleQuery(session, roleName) {
704
924
  */
705
925
  function buildRoleSystemPrompt(role, session) {
706
926
  const allRoles = Array.from(session.roles.values());
707
- const otherRoles = allRoles.filter(r => r.name !== role.name);
927
+
928
+ // 按组裁剪路由目标:
929
+ // - 有 groupIndex > 0 的执行者只看到同组成员 + 管理者(PM/architect/designer)
930
+ // - 管理者(groupIndex === 0)看到所有角色
931
+ let routeTargets;
932
+ if (role.groupIndex > 0) {
933
+ routeTargets = allRoles.filter(r =>
934
+ r.name !== role.name && (r.groupIndex === role.groupIndex || r.groupIndex === 0)
935
+ );
936
+ } else {
937
+ routeTargets = allRoles.filter(r => r.name !== role.name);
938
+ }
708
939
 
709
940
  let prompt = `# 团队协作
710
941
  你正在一个 AI 团队中工作。${session.goal ? `项目目标是: ${session.goal}` : '等待用户提出任务或问题。'}
@@ -712,7 +943,10 @@ function buildRoleSystemPrompt(role, session) {
712
943
  团队成员:
713
944
  ${allRoles.map(r => `- ${r.icon} ${r.displayName}: ${r.description}${r.isDecisionMaker ? ' (决策者)' : ''}`).join('\n')}`;
714
945
 
715
- if (otherRoles.length > 0) {
946
+ const hasMultiInstance = allRoles.some(r => r.groupIndex > 0);
947
+
948
+ if (routeTargets.length > 0) {
949
+ const multiRouteAllowed = role.isDecisionMaker && hasMultiInstance;
716
950
  prompt += `\n\n# 路由规则
717
951
  当你完成当前任务并需要将结果传递给其他角色时,在你的回复最末尾添加一个 ROUTE 块:
718
952
 
@@ -724,14 +958,14 @@ summary: <简要说明要传递什么>
724
958
  \`\`\`
725
959
 
726
960
  可用的路由目标:
727
- ${otherRoles.map(r => `- ${r.name}: ${r.icon} ${r.displayName} — ${r.description}`).join('\n')}
961
+ ${routeTargets.map(r => `- ${r.name}: ${r.icon} ${r.displayName} — ${r.description}`).join('\n')}
728
962
  - human: 人工(只在决策者也无法决定时使用)
729
963
 
730
964
  注意:
731
965
  - 如果你的工作还没完成,不需要添加 ROUTE 块
732
966
  - 如果你遇到不确定的问题,@ 决策者 "${session.decisionMaker}",而不是直接 @ human
733
967
  - 如果你是决策者且遇到需要人类判断的问题,才 @ human
734
- - 每次回复最多只能有一个 ROUTE 块
968
+ ${multiRouteAllowed ? '- 决策者可以一次发多个 ROUTE 块来并行分配任务' : '- 每次回复最多只能有一个 ROUTE 块'}
735
969
  - ROUTE 块必须在回复的最末尾
736
970
  - 当你的任务已完成且不需要其他角色继续时,ROUTE 回决策者 "${session.decisionMaker}" 做总结
737
971
  - 在正文中可用 @角色name 提及某个角色(如 @developer),但这不会触发路由,仅供阅读`;
@@ -739,14 +973,63 @@ ${otherRoles.map(r => `- ${r.name}: ${r.icon} ${r.displayName} — ${r.descripti
739
973
 
740
974
  // 决策者额外 prompt
741
975
  if (role.isDecisionMaker) {
976
+
742
977
  prompt += `\n\n# 决策者职责
743
978
  你是团队的决策者。其他角色遇到不确定的情况会请求你的决策。
744
979
  - 如果你有足够的信息做出决策,直接决定并 @相关角色执行
745
980
  - 如果你需要更多信息,@具体角色请求补充
746
981
  - 如果问题超出你的能力范围或需要业务判断,@human 请人类决定
747
982
  - 你可以随时审查其他角色的工作并给出反馈
748
- - PM 拥有 commit + push + tag 的自主权。只要修改没有大的 regression 影响(测试全通过),PM 可以自行决定 commit、push 和 tag,无需等待人工确认。只有当改动会直接影响对话交互逻辑时,才需要人工介入审核。
983
+ - PM 拥有 commit + push + tag 的自主权。只要修改没有大的 regression 影响(测试全通过),PM 可以自行决定 commit、push 和 tag,无需等待人工确认。只有当改动会直接影响对话交互逻辑时,才需要人工介入审核。`;
984
+
985
+ // 多实例模式:注入开发组状态和调度规则
986
+ if (hasMultiInstance) {
987
+ // 构建开发组实时状态
988
+ const maxGroup = Math.max(...allRoles.map(r => r.groupIndex));
989
+ const groupLines = [];
990
+ for (let g = 1; g <= maxGroup; g++) {
991
+ const members = allRoles.filter(r => r.groupIndex === g);
992
+ const memberStrs = members.map(r => {
993
+ const state = session.roleStates.get(r.name);
994
+ const busy = state?.turnActive;
995
+ const task = state?.currentTask;
996
+ if (busy && task) return `${r.name}(忙:${task.taskId} ${task.taskTitle})`;
997
+ if (busy) return `${r.name}(忙)`;
998
+ return `${r.name}(空闲)`;
999
+ });
1000
+ groupLines.push(`组${g}: ${memberStrs.join(' ')}`);
1001
+ }
1002
+
1003
+ prompt += `\n\n# 执行组状态
1004
+ ${groupLines.join(' / ')}
1005
+
1006
+ # 并行任务调度规则
1007
+ 你有 ${maxGroup} 个开发组可以并行工作。拆分任务时:
1008
+ 1. 每个子任务分配 task-id(如 task-1)和 taskTitle(如 "实现登录页面")
1009
+ 2. 优先分配给**空闲**的开发组,避免给忙碌的 dev 发新任务
1010
+ 3. 一次可以发**多个 ROUTE 块**来并行分配任务:
749
1011
 
1012
+ \`\`\`
1013
+ ---ROUTE---
1014
+ to: dev-1
1015
+ task: task-1
1016
+ taskTitle: 实现登录页面
1017
+ summary: 请实现登录页面,包括表单验证和API调用
1018
+ ---END_ROUTE---
1019
+
1020
+ ---ROUTE---
1021
+ to: dev-2
1022
+ task: task-2
1023
+ taskTitle: 实现注册页面
1024
+ summary: 请实现注册页面,包括邮箱验证
1025
+ ---END_ROUTE---
1026
+ \`\`\`
1027
+
1028
+ 4. 每个 dev 完成后会独立经过 reviewer 和 tester 审核,最后 ROUTE 回你
1029
+ 5. 等待**所有子任务完成**后再做汇总报告`;
1030
+ }
1031
+
1032
+ prompt += `\n
750
1033
  # 工作流终结点
751
1034
  团队的工作流有明确的结束条件。当以下任一条件满足时,你应该给出总结并结束当前工作流:
752
1035
  1. **代码已提交** - 所有代码修改已经 commit(如需要,可让 developer 执行 git commit)
@@ -772,6 +1055,35 @@ ${otherRoles.map(r => `- ${r.name}: ${r.icon} ${r.displayName} — ${r.descripti
772
1055
  - TASKS 块不需要在回复最末尾,可以放在任意位置`;
773
1056
  }
774
1057
 
1058
+ // 执行者角色的组绑定 prompt(count > 1 时)
1059
+ if (role.groupIndex > 0 && role.roleType === 'developer') {
1060
+ const gi = role.groupIndex;
1061
+ const rev = allRoles.find(r => r.roleType === 'reviewer' && r.groupIndex === gi);
1062
+ const test = allRoles.find(r => r.roleType === 'tester' && r.groupIndex === gi);
1063
+ if (rev && test) {
1064
+ prompt += `\n\n# 开发组绑定
1065
+ 你属于开发组 ${gi}。你的搭档:
1066
+ - 审查者: ${rev.icon} ${rev.name}
1067
+ - 测试: ${test.icon} ${test.name}
1068
+
1069
+ 开发完成后,请同时发两个 ROUTE 块分别给 ${rev.name} 和 ${test.name}:
1070
+
1071
+ \`\`\`
1072
+ ---ROUTE---
1073
+ to: ${rev.name}
1074
+ summary: 请审查代码变更
1075
+ ---END_ROUTE---
1076
+
1077
+ ---ROUTE---
1078
+ to: ${test.name}
1079
+ summary: 请测试功能
1080
+ ---END_ROUTE---
1081
+ \`\`\`
1082
+
1083
+ 两者会并行工作,各自完成后独立 ROUTE 回 PM。`;
1084
+ }
1085
+ }
1086
+
775
1087
  return prompt;
776
1088
  }
777
1089
 
@@ -810,24 +1122,29 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
810
1122
  }
811
1123
 
812
1124
  if (message.type === 'assistant') {
813
- // 转发流式输出到 Web
814
- sendCrewOutput(session, roleName, 'text', message);
815
-
816
1125
  // 累积文本用于路由解析
817
1126
  const content = message.message?.content;
818
1127
  if (content) {
819
1128
  if (typeof content === 'string') {
820
1129
  roleState.accumulatedText += content;
1130
+ // 转发流式文本到 Web
1131
+ sendCrewOutput(session, roleName, 'text', message);
821
1132
  } else if (Array.isArray(content)) {
1133
+ let hasText = false;
822
1134
  for (const block of content) {
823
1135
  if (block.type === 'text') {
824
1136
  roleState.accumulatedText += block.text;
1137
+ hasText = true;
825
1138
  } else if (block.type === 'tool_use') {
826
- // 转发 tool use + 记录当前工具
1139
+ // 修复5: tool_use 时结束该角色前一条 streaming 文本
1140
+ endRoleStreaming(session, roleName);
827
1141
  roleState.currentTool = block.name;
828
1142
  sendCrewOutput(session, roleName, 'tool_use', message);
829
1143
  }
830
1144
  }
1145
+ if (hasText) {
1146
+ sendCrewOutput(session, roleName, 'text', message);
1147
+ }
831
1148
  }
832
1149
  }
833
1150
  } else if (message.type === 'user') {
@@ -838,9 +1155,8 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
838
1155
  // ★ Turn 完成!
839
1156
  console.log(`[Crew] ${roleName} turn completed`);
840
1157
 
841
- // 结束 uiMessages 中最后一条的 streaming 标记
842
- const lastUi = session.uiMessages[session.uiMessages.length - 1];
843
- if (lastUi && lastUi._streaming) delete lastUi._streaming;
1158
+ // 修复2: 反向搜索该角色最后一条 streaming 消息并结束
1159
+ endRoleStreaming(session, roleName);
844
1160
 
845
1161
  // 更新费用
846
1162
  if (message.total_cost_usd) {
@@ -858,8 +1174,8 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
858
1174
  .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
859
1175
  }
860
1176
 
861
- // 解析路由
862
- const route = parseRoute(roleState.accumulatedText);
1177
+ // 解析路由(支持多 ROUTE 块)
1178
+ const routes = parseRoutes(roleState.accumulatedText);
863
1179
  roleState.accumulatedText = '';
864
1180
  roleState.turnActive = false;
865
1181
 
@@ -874,11 +1190,30 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
874
1190
  sendStatusUpdate(session);
875
1191
 
876
1192
  // 执行路由
877
- if (route) {
878
- await executeRoute(session, roleName, route);
1193
+ if (routes.length > 0) {
1194
+ // ★ 修复1: 多 ROUTE 只增 1 轮(round++ 从 executeRoute 移到这里)
1195
+ session.round++;
1196
+
1197
+ // task 继承:如果路由没有指定 taskId,从当前角色继承
1198
+ const currentTask = roleState.currentTask;
1199
+ for (const route of routes) {
1200
+ if (!route.taskId && currentTask) {
1201
+ route.taskId = currentTask.taskId;
1202
+ route.taskTitle = currentTask.taskTitle;
1203
+ }
1204
+ }
1205
+
1206
+ // 并行执行所有路由(allSettled 保证单个失败不中断其他)
1207
+ const results = await Promise.allSettled(routes.map(route =>
1208
+ executeRoute(session, roleName, route)
1209
+ ));
1210
+ for (const r of results) {
1211
+ if (r.status === 'rejected') {
1212
+ console.warn(`[Crew] Route execution failed:`, r.reason);
1213
+ }
1214
+ }
879
1215
  } else {
880
- // 没有路由,角色完成了当前工作但没有指定下一步
881
- // 检查是否有人的消息在排队
1216
+ // 没有路由,检查是否有人的消息在排队
882
1217
  await processHumanQueue(session);
883
1218
  }
884
1219
  }
@@ -886,12 +1221,12 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
886
1221
  } catch (error) {
887
1222
  if (error.name === 'AbortError') {
888
1223
  console.log(`[Crew] ${roleName} aborted`);
889
- // 暂停时:检查已累积的文本中是否有 route,保存为 pendingRoute
1224
+ // 暂停时:检查已累积的文本中是否有 route,保存为 pendingRoutes
890
1225
  if (session.status === 'paused' && roleState.accumulatedText) {
891
- const route = parseRoute(roleState.accumulatedText);
892
- if (route && !session.pendingRoute) {
893
- session.pendingRoute = { fromRole: roleName, route };
894
- console.log(`[Crew] Saved pending route from aborted ${roleName}: -> ${route.to}`);
1226
+ const routes = parseRoutes(roleState.accumulatedText);
1227
+ if (routes.length > 0 && session.pendingRoutes.length === 0) {
1228
+ session.pendingRoutes = routes.map(route => ({ fromRole: roleName, route }));
1229
+ console.log(`[Crew] Saved ${routes.length} pending route(s) from aborted ${roleName}`);
895
1230
  }
896
1231
  roleState.accumulatedText = '';
897
1232
  }
@@ -917,52 +1252,62 @@ async function processRoleOutput(session, roleName, roleQuery, roleState) {
917
1252
  }
918
1253
  }
919
1254
 
1255
+ /**
1256
+ * 结束指定角色的最后一条 streaming 消息(反向搜索)
1257
+ */
1258
+ function endRoleStreaming(session, roleName) {
1259
+ for (let i = session.uiMessages.length - 1; i >= 0; i--) {
1260
+ if (session.uiMessages[i].role === roleName && session.uiMessages[i]._streaming) {
1261
+ delete session.uiMessages[i]._streaming;
1262
+ break;
1263
+ }
1264
+ }
1265
+ }
1266
+
920
1267
  // =====================================================================
921
1268
  // Route Parsing & Execution
922
1269
  // =====================================================================
923
1270
 
924
1271
  /**
925
- * 从累积文本中解析 ROUTE
1272
+ * 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)
1273
+ * @returns {Array<{ to, summary, taskId, taskTitle }>}
926
1274
  */
927
- function parseRoute(text) {
928
- // 主格式
929
- const match = text.match(/---ROUTE---\s*\n\s*to:\s*(.+?)\s*\n\s*summary:\s*(.+?)\s*\n\s*---END_ROUTE---/s);
930
- if (match) {
931
- return {
932
- to: match[1].trim().toLowerCase(),
933
- summary: match[2].trim()
934
- };
935
- }
1275
+ function parseRoutes(text) {
1276
+ const routes = [];
1277
+ const regex = /---ROUTE---\s*\n([\s\S]*?)---END_ROUTE---/g;
1278
+ let match;
936
1279
 
937
- // 备用格式(更宽松)
938
- const altMatch = text.match(/---ROUTE---\s*\n([\s\S]*?)---END_ROUTE---/);
939
- if (altMatch) {
940
- const block = altMatch[1];
1280
+ while ((match = regex.exec(text)) !== null) {
1281
+ const block = match[1];
941
1282
  const toMatch = block.match(/to:\s*(.+)/i);
1283
+ if (!toMatch) continue;
1284
+
942
1285
  const summaryMatch = block.match(/summary:\s*(.+)/i);
943
- if (toMatch) {
944
- return {
945
- to: toMatch[1].trim().toLowerCase(),
946
- summary: summaryMatch ? summaryMatch[1].trim() : ''
947
- };
948
- }
1286
+ const taskMatch = block.match(/^task:\s*(.+)/im);
1287
+ const taskTitleMatch = block.match(/^taskTitle:\s*(.+)/im);
1288
+
1289
+ routes.push({
1290
+ to: toMatch[1].trim().toLowerCase(),
1291
+ summary: summaryMatch ? summaryMatch[1].trim() : '',
1292
+ taskId: taskMatch ? taskMatch[1].trim() : null,
1293
+ taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
1294
+ });
949
1295
  }
950
1296
 
951
- return null;
1297
+ return routes;
952
1298
  }
953
1299
 
954
1300
  /**
955
1301
  * 执行路由
956
1302
  */
957
1303
  async function executeRoute(session, fromRole, route) {
958
- const { to, summary } = route;
1304
+ const { to, summary, taskId, taskTitle } = route;
959
1305
 
960
- // 增加轮次计数
961
- session.round++;
1306
+ // ★ round++ 已移到 processRoleOutput 中(多 ROUTE 只增 1 轮)
962
1307
 
963
- // 如果 session 已暂停或停止,保存 pendingRoute 等恢复时重放
1308
+ // 如果 session 已暂停或停止,保存为 pendingRoutes
964
1309
  if (session.status === 'paused' || session.status === 'stopped') {
965
- session.pendingRoute = { fromRole, route };
1310
+ session.pendingRoutes.push({ fromRole, route });
966
1311
  console.log(`[Crew] Session ${session.status}, route saved as pending: ${fromRole} -> ${to}`);
967
1312
  return;
968
1313
  }
@@ -991,13 +1336,15 @@ async function executeRoute(session, fromRole, route) {
991
1336
 
992
1337
  // 路由到指定角色
993
1338
  if (session.roles.has(to)) {
1339
+ // task 信息通过 dispatchToRole 内部设置(createRoleQuery 之后 roleState 才存在)
1340
+
994
1341
  // 先检查是否有人的消息在排队
995
1342
  if (session.humanMessageQueue.length > 0) {
996
1343
  // 人的消息优先
997
1344
  await processHumanQueue(session);
998
1345
  } else {
999
1346
  const taskPrompt = buildRoutePrompt(fromRole, summary, session);
1000
- await dispatchToRole(session, to, taskPrompt, fromRole);
1347
+ await dispatchToRole(session, to, taskPrompt, fromRole, taskId, taskTitle);
1001
1348
  }
1002
1349
  } else {
1003
1350
  console.warn(`[Crew] Unknown route target: ${to}`);
@@ -1023,7 +1370,7 @@ function buildRoutePrompt(fromRole, summary, session) {
1023
1370
  /**
1024
1371
  * 向角色发送消息
1025
1372
  */
1026
- async function dispatchToRole(session, roleName, content, fromSource) {
1373
+ async function dispatchToRole(session, roleName, content, fromSource, taskId, taskTitle) {
1027
1374
  if (session.status === 'paused' || session.status === 'stopped') {
1028
1375
  console.log(`[Crew] Session ${session.status}, skipping dispatch to ${roleName}`);
1029
1376
  return;
@@ -1036,11 +1383,17 @@ async function dispatchToRole(session, roleName, content, fromSource) {
1036
1383
  roleState = await createRoleQuery(session, roleName);
1037
1384
  }
1038
1385
 
1386
+ // 设置 task(createRoleQuery 之后 roleState 一定存在)
1387
+ if (taskId) {
1388
+ roleState.currentTask = { taskId, taskTitle };
1389
+ }
1390
+
1039
1391
  // 记录消息历史
1040
1392
  session.messageHistory.push({
1041
1393
  from: fromSource,
1042
1394
  to: roleName,
1043
1395
  content: typeof content === 'string' ? content.substring(0, 200) : '...',
1396
+ taskId: taskId || roleState.currentTask?.taskId || null,
1044
1397
  timestamp: Date.now()
1045
1398
  });
1046
1399
 
@@ -1052,7 +1405,7 @@ async function dispatchToRole(session, roleName, content, fromSource) {
1052
1405
  message: { role: 'user', content }
1053
1406
  });
1054
1407
 
1055
- console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}`);
1408
+ console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
1056
1409
  }
1057
1410
 
1058
1411
  // =====================================================================
@@ -1134,10 +1487,15 @@ export async function handleCrewHumanInput(msg) {
1134
1487
  */
1135
1488
  async function processHumanQueue(session) {
1136
1489
  if (session.humanMessageQueue.length === 0) return;
1137
-
1138
- const msg = session.humanMessageQueue.shift();
1139
- const humanPrompt = `人工消息:\n${msg.content}`;
1140
- await dispatchToRole(session, msg.target, humanPrompt, 'human');
1490
+ if (session._processingHumanQueue) return;
1491
+ session._processingHumanQueue = true;
1492
+ try {
1493
+ const msg = session.humanMessageQueue.shift();
1494
+ const humanPrompt = `人工消息:\n${msg.content}`;
1495
+ await dispatchToRole(session, msg.target, humanPrompt, 'human');
1496
+ } finally {
1497
+ session._processingHumanQueue = false;
1498
+ }
1141
1499
  }
1142
1500
 
1143
1501
  /**
@@ -1217,7 +1575,7 @@ async function pauseAll(session) {
1217
1575
 
1218
1576
  /**
1219
1577
  * 恢复 session
1220
- * 重新执行被暂停时保存的 pendingRoute
1578
+ * 重新执行被暂停时保存的 pendingRoutes
1221
1579
  */
1222
1580
  async function resumeSession(session) {
1223
1581
  if (session.status !== 'paused') return;
@@ -1231,16 +1589,23 @@ async function resumeSession(session) {
1231
1589
  });
1232
1590
  sendStatusUpdate(session);
1233
1591
 
1234
- // 恢复被中断的路由
1235
- if (session.pendingRoute) {
1236
- const { fromRole, route } = session.pendingRoute;
1237
- session.pendingRoute = null;
1238
- console.log(`[Crew] Replaying pending route: ${fromRole} -> ${route.to}`);
1239
- await executeRoute(session, fromRole, route);
1592
+ // 恢复被中断的路由(可能有多条)
1593
+ if (session.pendingRoutes.length > 0) {
1594
+ const pending = session.pendingRoutes.slice();
1595
+ session.pendingRoutes = [];
1596
+ console.log(`[Crew] Replaying ${pending.length} pending route(s)`);
1597
+ const results = await Promise.allSettled(pending.map(({ fromRole, route }) =>
1598
+ executeRoute(session, fromRole, route)
1599
+ ));
1600
+ for (const r of results) {
1601
+ if (r.status === 'rejected') {
1602
+ console.warn(`[Crew] Pending route replay failed:`, r.reason);
1603
+ }
1604
+ }
1240
1605
  return;
1241
1606
  }
1242
1607
 
1243
- // 没有 pendingRoute,检查排队的人的消息
1608
+ // 没有 pendingRoutes,检查排队的人的消息
1244
1609
  await processHumanQueue(session);
1245
1610
  }
1246
1611
 
@@ -1297,6 +1662,9 @@ async function stopAll(session) {
1297
1662
  });
1298
1663
  sendStatusUpdate(session);
1299
1664
 
1665
+ // 清理 git worktrees
1666
+ await cleanupWorktrees(session.projectDir);
1667
+
1300
1668
  // 从活跃 sessions 中移除
1301
1669
  crewSessions.delete(session.id);
1302
1670
  console.log(`[Crew] Session ${session.id} stopped`);
@@ -1323,6 +1691,11 @@ function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
1323
1691
  const roleIcon = role?.icon || (roleName === 'human' ? 'H' : roleName === 'system' ? 'S' : 'A');
1324
1692
  const displayName = role?.displayName || roleName;
1325
1693
 
1694
+ // 从 roleState 获取当前 task 信息
1695
+ const roleState = session.roleStates.get(roleName);
1696
+ const taskId = roleState?.currentTask?.taskId || null;
1697
+ const taskTitle = roleState?.currentTask?.taskTitle || null;
1698
+
1326
1699
  sendCrewMessage({
1327
1700
  type: 'crew_output',
1328
1701
  sessionId: session.id,
@@ -1331,6 +1704,8 @@ function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
1331
1704
  roleName: displayName,
1332
1705
  outputType, // 'text' | 'tool_use' | 'tool_result' | 'route' | 'system'
1333
1706
  data: rawMessage,
1707
+ taskId,
1708
+ taskTitle,
1334
1709
  ...extra
1335
1710
  });
1336
1711
 
@@ -1344,21 +1719,27 @@ function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
1344
1719
  text = content.filter(b => b.type === 'text').map(b => b.text).join('');
1345
1720
  }
1346
1721
  if (!text) return;
1347
- // 合并同一角色的连续文本
1348
- const last = session.uiMessages[session.uiMessages.length - 1];
1349
- if (last && last.role === roleName && last.type === 'text' && last._streaming) {
1350
- last.content += text;
1351
- } else {
1722
+ // ★ 修复2: 反向搜索该角色最后一条 _streaming 消息
1723
+ let found = false;
1724
+ for (let i = session.uiMessages.length - 1; i >= 0; i--) {
1725
+ const msg = session.uiMessages[i];
1726
+ if (msg.role === roleName && msg.type === 'text' && msg._streaming) {
1727
+ msg.content += text;
1728
+ found = true;
1729
+ break;
1730
+ }
1731
+ }
1732
+ if (!found) {
1352
1733
  session.uiMessages.push({
1353
1734
  role: roleName, roleIcon, roleName: displayName,
1354
1735
  type: 'text', content: text, _streaming: true,
1736
+ taskId, taskTitle,
1355
1737
  timestamp: Date.now()
1356
1738
  });
1357
1739
  }
1358
1740
  } else if (outputType === 'route') {
1359
- // 结束前一条消息的 streaming
1360
- const last = session.uiMessages[session.uiMessages.length - 1];
1361
- if (last && last._streaming) delete last._streaming;
1741
+ // 结束该角色前一条 streaming
1742
+ endRoleStreaming(session, roleName);
1362
1743
  session.uiMessages.push({
1363
1744
  role: roleName, roleIcon, roleName: displayName,
1364
1745
  type: 'route', routeTo: extra.routeTo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.0.99",
3
+ "version": "0.0.101",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",