@yeaft/webchat-agent 0.0.230 → 0.0.231
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 +32 -3089
- package/package.json +1 -1
package/crew.js
CHANGED
|
@@ -10,3093 +10,36 @@
|
|
|
10
10
|
* - 共享级 .crew/CLAUDE.md(所有角色自动继承)
|
|
11
11
|
* - Session resume(每个角色的 claudeSessionId 持久化)
|
|
12
12
|
* - 自动路由 + 人工混合
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { query, Stream } from './sdk/index.js';
|
|
16
|
-
import { promises as fs } from 'fs';
|
|
17
|
-
import { join, isAbsolute } from 'path';
|
|
18
|
-
import { homedir } from 'os';
|
|
19
|
-
import { execFile as execFileCb } from 'child_process';
|
|
20
|
-
import { promisify } from 'util';
|
|
21
|
-
import ctx from './context.js';
|
|
22
|
-
import { getMessages } from './crew-i18n.js';
|
|
23
|
-
|
|
24
|
-
const execFile = promisify(execFileCb);
|
|
25
|
-
|
|
26
|
-
/** Format role label: "icon displayName" or just "displayName" if no icon */
|
|
27
|
-
function roleLabel(r) {
|
|
28
|
-
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// =====================================================================
|
|
32
|
-
// Data Structures
|
|
33
|
-
// =====================================================================
|
|
34
|
-
|
|
35
|
-
/** @type {Map<string, CrewSession>} */
|
|
36
|
-
const crewSessions = new Map();
|
|
37
|
-
|
|
38
|
-
// 导出供 connection.js / conversation.js 使用
|
|
39
|
-
export { crewSessions };
|
|
40
|
-
|
|
41
|
-
// =====================================================================
|
|
42
|
-
// Role Multi-Instance Expansion
|
|
43
|
-
// =====================================================================
|
|
44
|
-
|
|
45
|
-
// 短前缀映射:用于 count > 1 时生成实例名
|
|
46
|
-
const SHORT_PREFIX = {
|
|
47
|
-
developer: 'dev',
|
|
48
|
-
tester: 'test',
|
|
49
|
-
reviewer: 'rev'
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// 只有执行者角色支持多实例
|
|
53
|
-
const EXPANDABLE_ROLES = new Set(['developer', 'tester', 'reviewer']);
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* 展开角色列表:count > 1 的执行者角色展开为多个实例
|
|
57
|
-
* count === 1 或管理者角色保持原样(向后兼容)
|
|
58
13
|
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return expanded;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// =====================================================================
|
|
99
|
-
// Git Worktree Management
|
|
100
|
-
// =====================================================================
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 为开发组创建 git worktree
|
|
104
|
-
* 每个 groupIndex 对应一个 worktree,同组的 dev/rev/test 共享
|
|
105
|
-
* 所有 EXPANDABLE_ROLES(包括 count=1)都会获得独立 worktree
|
|
106
|
-
*
|
|
107
|
-
* @param {string} projectDir - 主项目目录
|
|
108
|
-
* @param {Array} roles - 展开后的角色列表
|
|
109
|
-
* @returns {Map<number, string>} groupIndex → worktree 路径
|
|
110
|
-
*/
|
|
111
|
-
async function initWorktrees(projectDir, roles) {
|
|
112
|
-
const groupIndices = [...new Set(roles.filter(r => r.groupIndex > 0).map(r => r.groupIndex))];
|
|
113
|
-
if (groupIndices.length === 0) return new Map();
|
|
114
|
-
|
|
115
|
-
const worktreeBase = join(projectDir, '.worktrees');
|
|
116
|
-
await fs.mkdir(worktreeBase, { recursive: true });
|
|
117
|
-
|
|
118
|
-
// 获取 git 已知的 worktree 列表
|
|
119
|
-
let knownWorktrees = new Set();
|
|
120
|
-
try {
|
|
121
|
-
const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir });
|
|
122
|
-
for (const line of stdout.split('\n')) {
|
|
123
|
-
if (line.startsWith('worktree ')) {
|
|
124
|
-
knownWorktrees.add(line.slice('worktree '.length).trim());
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
// git worktree list 失败,视为空集
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const worktreeMap = new Map();
|
|
132
|
-
|
|
133
|
-
for (const idx of groupIndices) {
|
|
134
|
-
const wtDir = join(worktreeBase, `dev-${idx}`);
|
|
135
|
-
const branch = `crew/dev-${idx}`;
|
|
136
|
-
|
|
137
|
-
// 检查目录是否存在
|
|
138
|
-
let dirExists = false;
|
|
139
|
-
try {
|
|
140
|
-
await fs.access(wtDir);
|
|
141
|
-
dirExists = true;
|
|
142
|
-
} catch {}
|
|
143
|
-
|
|
144
|
-
if (dirExists) {
|
|
145
|
-
if (knownWorktrees.has(wtDir)) {
|
|
146
|
-
// 目录存在且 git 记录中也有,直接复用
|
|
147
|
-
console.log(`[Crew] Worktree already exists: ${wtDir}`);
|
|
148
|
-
worktreeMap.set(idx, wtDir);
|
|
149
|
-
continue;
|
|
150
|
-
} else {
|
|
151
|
-
// 孤立目录:目录存在但 git 不认识,先删除再重建
|
|
152
|
-
console.warn(`[Crew] Orphaned worktree dir, removing: ${wtDir}`);
|
|
153
|
-
await fs.rm(wtDir, { recursive: true, force: true }).catch(() => {});
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
// 创建分支(如果不存在)
|
|
159
|
-
try {
|
|
160
|
-
await execFile('git', ['branch', branch], { cwd: projectDir });
|
|
161
|
-
} catch {
|
|
162
|
-
// 分支已存在,忽略
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 创建 worktree
|
|
166
|
-
await execFile('git', ['worktree', 'add', wtDir, branch], { cwd: projectDir });
|
|
167
|
-
console.log(`[Crew] Created worktree: ${wtDir} on branch ${branch}`);
|
|
168
|
-
worktreeMap.set(idx, wtDir);
|
|
169
|
-
} catch (e) {
|
|
170
|
-
console.error(`[Crew] Failed to create worktree for group ${idx}:`, e.message);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return worktreeMap;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* 清理 session 的 git worktrees
|
|
179
|
-
* @param {string} projectDir - 主项目目录
|
|
180
|
-
*/
|
|
181
|
-
async function cleanupWorktrees(projectDir) {
|
|
182
|
-
const worktreeBase = join(projectDir, '.worktrees');
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
await fs.access(worktreeBase);
|
|
186
|
-
} catch {
|
|
187
|
-
return; // .worktrees 目录不存在,无需清理
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const entries = await fs.readdir(worktreeBase);
|
|
192
|
-
for (const entry of entries) {
|
|
193
|
-
if (!entry.startsWith('dev-')) continue;
|
|
194
|
-
const wtDir = join(worktreeBase, entry);
|
|
195
|
-
const branch = `crew/${entry}`;
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
await execFile('git', ['worktree', 'remove', wtDir, '--force'], { cwd: projectDir });
|
|
199
|
-
console.log(`[Crew] Removed worktree: ${wtDir}`);
|
|
200
|
-
} catch (e) {
|
|
201
|
-
console.warn(`[Crew] Failed to remove worktree ${wtDir}:`, e.message);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
await execFile('git', ['branch', '-D', branch], { cwd: projectDir });
|
|
206
|
-
console.log(`[Crew] Deleted branch: ${branch}`);
|
|
207
|
-
} catch (e) {
|
|
208
|
-
console.warn(`[Crew] Failed to delete branch ${branch}:`, e.message);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// 尝试删除 .worktrees 目录(如果已空)
|
|
213
|
-
try {
|
|
214
|
-
await fs.rmdir(worktreeBase);
|
|
215
|
-
} catch {
|
|
216
|
-
// 目录不空或其他原因,忽略
|
|
217
|
-
}
|
|
218
|
-
} catch (e) {
|
|
219
|
-
console.error(`[Crew] Failed to cleanup worktrees:`, e.message);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// =====================================================================
|
|
224
|
-
// Crew Session Index (~/.claude/crew-sessions.json)
|
|
225
|
-
// =====================================================================
|
|
226
|
-
|
|
227
|
-
const CREW_INDEX_PATH = join(homedir(), '.claude', 'crew-sessions.json');
|
|
228
|
-
|
|
229
|
-
// 写入锁:防止并发写入导致文件损坏
|
|
230
|
-
let _indexWriteLock = Promise.resolve();
|
|
231
|
-
|
|
232
|
-
export async function loadCrewIndex() {
|
|
233
|
-
try { return JSON.parse(await fs.readFile(CREW_INDEX_PATH, 'utf-8')); }
|
|
234
|
-
catch { return []; }
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async function saveCrewIndex(index) {
|
|
238
|
-
const doWrite = async () => {
|
|
239
|
-
await fs.mkdir(join(homedir(), '.claude'), { recursive: true });
|
|
240
|
-
const data = JSON.stringify(index, null, 2);
|
|
241
|
-
// 先写临时文件再 rename,保证原子性
|
|
242
|
-
const tmpPath = CREW_INDEX_PATH + '.tmp';
|
|
243
|
-
await fs.writeFile(tmpPath, data);
|
|
244
|
-
await fs.rename(tmpPath, CREW_INDEX_PATH);
|
|
245
|
-
};
|
|
246
|
-
// 串行化写入
|
|
247
|
-
_indexWriteLock = _indexWriteLock.then(doWrite, doWrite);
|
|
248
|
-
return _indexWriteLock;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function sessionToIndexEntry(session) {
|
|
252
|
-
return {
|
|
253
|
-
sessionId: session.id,
|
|
254
|
-
projectDir: session.projectDir,
|
|
255
|
-
sharedDir: session.sharedDir,
|
|
256
|
-
status: session.status,
|
|
257
|
-
name: session.name || '',
|
|
258
|
-
userId: session.userId,
|
|
259
|
-
username: session.username,
|
|
260
|
-
agentId: session.agentId || null,
|
|
261
|
-
createdAt: session.createdAt,
|
|
262
|
-
updatedAt: Date.now()
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async function upsertCrewIndex(session) {
|
|
267
|
-
const index = await loadCrewIndex();
|
|
268
|
-
const entry = sessionToIndexEntry(session);
|
|
269
|
-
const idx = index.findIndex(e => e.sessionId === session.id);
|
|
270
|
-
if (idx >= 0) index[idx] = entry; else index.push(entry);
|
|
271
|
-
await saveCrewIndex(index);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export async function removeFromCrewIndex(sessionId) {
|
|
275
|
-
const index = await loadCrewIndex();
|
|
276
|
-
const entry = index.find(e => e.sessionId === sessionId);
|
|
277
|
-
const filtered = index.filter(e => e.sessionId !== sessionId);
|
|
278
|
-
if (filtered.length !== index.length) {
|
|
279
|
-
await saveCrewIndex(filtered);
|
|
280
|
-
console.log(`[Crew] Removed session ${sessionId} from index`);
|
|
281
|
-
}
|
|
282
|
-
// 从内存中也移除(防止 sendConversationList 重新加入)
|
|
283
|
-
if (crewSessions.has(sessionId)) {
|
|
284
|
-
crewSessions.delete(sessionId);
|
|
285
|
-
console.log(`[Crew] Removed session ${sessionId} from active sessions`);
|
|
286
|
-
}
|
|
287
|
-
// 删除磁盘上的 session 数据文件
|
|
288
|
-
const sharedDir = entry?.sharedDir;
|
|
289
|
-
if (sharedDir) {
|
|
290
|
-
try {
|
|
291
|
-
for (const file of ['session.json', 'messages.json']) {
|
|
292
|
-
await fs.unlink(join(sharedDir, file)).catch(() => {});
|
|
293
|
-
}
|
|
294
|
-
// Clean up message shard files
|
|
295
|
-
await cleanupMessageShards(sharedDir);
|
|
296
|
-
console.log(`[Crew] Cleaned session files in ${sharedDir}`);
|
|
297
|
-
} catch (e) {
|
|
298
|
-
console.warn(`[Crew] Failed to clean session files:`, e.message);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// =====================================================================
|
|
304
|
-
// Session Metadata (.crew/session.json)
|
|
305
|
-
// =====================================================================
|
|
306
|
-
|
|
307
|
-
const MESSAGE_SHARD_SIZE = 256 * 1024; // 256KB per shard
|
|
308
|
-
|
|
309
|
-
async function saveSessionMeta(session) {
|
|
310
|
-
const meta = {
|
|
311
|
-
sessionId: session.id,
|
|
312
|
-
projectDir: session.projectDir,
|
|
313
|
-
sharedDir: session.sharedDir,
|
|
314
|
-
name: session.name || '',
|
|
315
|
-
status: session.status,
|
|
316
|
-
roles: Array.from(session.roles.values()).map(r => ({
|
|
317
|
-
name: r.name, displayName: r.displayName, icon: r.icon,
|
|
318
|
-
description: r.description, isDecisionMaker: r.isDecisionMaker || false,
|
|
319
|
-
groupIndex: r.groupIndex, roleType: r.roleType, model: r.model
|
|
320
|
-
})),
|
|
321
|
-
decisionMaker: session.decisionMaker,
|
|
322
|
-
round: session.round,
|
|
323
|
-
createdAt: session.createdAt,
|
|
324
|
-
updatedAt: Date.now(),
|
|
325
|
-
userId: session.userId,
|
|
326
|
-
username: session.username,
|
|
327
|
-
agentId: session.agentId || null,
|
|
328
|
-
teamType: session.teamType || 'dev',
|
|
329
|
-
language: session.language || 'zh-CN',
|
|
330
|
-
costUsd: session.costUsd,
|
|
331
|
-
totalInputTokens: session.totalInputTokens,
|
|
332
|
-
totalOutputTokens: session.totalOutputTokens,
|
|
333
|
-
features: Array.from(session.features.values()),
|
|
334
|
-
_completedTaskIds: Array.from(session._completedTaskIds || [])
|
|
335
|
-
};
|
|
336
|
-
await fs.writeFile(join(session.sharedDir, 'session.json'), JSON.stringify(meta, null, 2));
|
|
337
|
-
// 保存 UI 消息历史(用于恢复时重放)
|
|
338
|
-
if (session.uiMessages && session.uiMessages.length > 0) {
|
|
339
|
-
// 清理 _streaming 标记后保存
|
|
340
|
-
const cleaned = session.uiMessages.map(m => {
|
|
341
|
-
const { _streaming, ...rest } = m;
|
|
342
|
-
return rest;
|
|
343
|
-
});
|
|
344
|
-
const json = JSON.stringify(cleaned);
|
|
345
|
-
// 超过阈值时直接归档(rotateMessages 内部写两个文件,避免双写)
|
|
346
|
-
if (json.length > MESSAGE_SHARD_SIZE && !session._rotating) {
|
|
347
|
-
await rotateMessages(session, cleaned);
|
|
348
|
-
} else {
|
|
349
|
-
await fs.writeFile(join(session.sharedDir, 'messages.json'), json);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 归档旧消息到分片文件(logrotate 风格)
|
|
356
|
-
* messages.json = 当前活跃分片(最新消息)
|
|
357
|
-
* messages.1.json = 最近归档,messages.2.json = 更早归档 ...
|
|
358
|
-
*/
|
|
359
|
-
async function rotateMessages(session, cleaned) {
|
|
360
|
-
session._rotating = true;
|
|
361
|
-
try {
|
|
362
|
-
// 找到分割点:优先在 turn 边界(route/system 消息)分割,约归档前半部分
|
|
363
|
-
const halfLen = Math.floor(cleaned.length / 2);
|
|
364
|
-
let splitIdx = halfLen;
|
|
365
|
-
// 从 halfLen 附近向前搜索 turn 边界
|
|
366
|
-
for (let i = halfLen; i > Math.max(0, halfLen - 20); i--) {
|
|
367
|
-
if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
|
|
368
|
-
splitIdx = i + 1; // 在边界消息之后分割
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
// 如果向前没找到,向后搜索
|
|
373
|
-
if (splitIdx === halfLen) {
|
|
374
|
-
for (let i = halfLen + 1; i < Math.min(cleaned.length - 1, halfLen + 20); i++) {
|
|
375
|
-
if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
|
|
376
|
-
splitIdx = i + 1;
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
// 确保至少归档 1 条且保留 1 条
|
|
382
|
-
splitIdx = Math.max(1, Math.min(splitIdx, cleaned.length - 1));
|
|
383
|
-
|
|
384
|
-
const archivePart = cleaned.slice(0, splitIdx);
|
|
385
|
-
const remainPart = cleaned.slice(splitIdx);
|
|
386
|
-
|
|
387
|
-
// 将现有归档文件编号 +1(从最大编号开始,避免覆盖)
|
|
388
|
-
const maxShard = await getMaxShardIndex(session.sharedDir);
|
|
389
|
-
for (let i = maxShard; i >= 1; i--) {
|
|
390
|
-
const src = join(session.sharedDir, `messages.${i}.json`);
|
|
391
|
-
const dst = join(session.sharedDir, `messages.${i + 1}.json`);
|
|
392
|
-
await fs.rename(src, dst).catch(() => {});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// 写入归档分片
|
|
396
|
-
await fs.writeFile(join(session.sharedDir, 'messages.1.json'), JSON.stringify(archivePart));
|
|
397
|
-
// 重写当前活跃文件
|
|
398
|
-
await fs.writeFile(join(session.sharedDir, 'messages.json'), JSON.stringify(remainPart));
|
|
399
|
-
// 同步内存中的 uiMessages
|
|
400
|
-
session.uiMessages = remainPart.map(m => ({ ...m }));
|
|
401
|
-
|
|
402
|
-
console.log(`[Crew] Rotated messages: archived ${archivePart.length} msgs to shard 1, kept ${remainPart.length} in active`);
|
|
403
|
-
} finally {
|
|
404
|
-
session._rotating = false;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* 获取当前最大分片编号
|
|
410
|
-
*/
|
|
411
|
-
async function getMaxShardIndex(sharedDir) {
|
|
412
|
-
let max = 0;
|
|
413
|
-
try {
|
|
414
|
-
const files = await fs.readdir(sharedDir);
|
|
415
|
-
for (const f of files) {
|
|
416
|
-
const match = f.match(/^messages\.(\d+)\.json$/);
|
|
417
|
-
if (match) {
|
|
418
|
-
const idx = parseInt(match[1], 10);
|
|
419
|
-
if (idx > max) max = idx;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
} catch { /* dir may not exist */ }
|
|
423
|
-
return max;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* 删除所有消息分片文件(messages.1.json, messages.2.json, ...)
|
|
428
|
-
*/
|
|
429
|
-
async function cleanupMessageShards(sharedDir) {
|
|
430
|
-
try {
|
|
431
|
-
const files = await fs.readdir(sharedDir);
|
|
432
|
-
for (const f of files) {
|
|
433
|
-
if (/^messages\.\d+\.json$/.test(f)) {
|
|
434
|
-
await fs.unlink(join(sharedDir, f)).catch(() => {});
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
} catch { /* dir may not exist */ }
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
async function loadSessionMeta(sharedDir) {
|
|
441
|
-
try { return JSON.parse(await fs.readFile(join(sharedDir, 'session.json'), 'utf-8')); }
|
|
442
|
-
catch { return null; }
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
async function loadSessionMessages(sharedDir) {
|
|
446
|
-
let messages = [];
|
|
447
|
-
try { messages = JSON.parse(await fs.readFile(join(sharedDir, 'messages.json'), 'utf-8')); }
|
|
448
|
-
catch { /* file may not exist */ }
|
|
449
|
-
// Check if older shards exist
|
|
450
|
-
let hasOlderMessages = false;
|
|
451
|
-
try {
|
|
452
|
-
await fs.access(join(sharedDir, 'messages.1.json'));
|
|
453
|
-
hasOlderMessages = true;
|
|
454
|
-
} catch { /* no older shards */ }
|
|
455
|
-
return { messages, hasOlderMessages };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* 加载历史消息分片
|
|
460
|
-
* 前端上滑到顶部时按需请求
|
|
461
|
-
*/
|
|
462
|
-
export async function handleLoadCrewHistory(msg) {
|
|
463
|
-
const { sessionId, requestId } = msg;
|
|
464
|
-
// Validate shardIndex: must be a positive integer to prevent path traversal
|
|
465
|
-
const shardIndex = parseInt(msg.shardIndex, 10);
|
|
466
|
-
if (!Number.isFinite(shardIndex) || shardIndex < 1) {
|
|
467
|
-
sendCrewMessage({
|
|
468
|
-
type: 'crew_history_loaded',
|
|
469
|
-
sessionId,
|
|
470
|
-
shardIndex: msg.shardIndex,
|
|
471
|
-
requestId,
|
|
472
|
-
messages: [],
|
|
473
|
-
hasMore: false
|
|
474
|
-
});
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
const session = crewSessions.get(sessionId);
|
|
478
|
-
if (!session) {
|
|
479
|
-
sendCrewMessage({
|
|
480
|
-
type: 'crew_history_loaded',
|
|
481
|
-
sessionId,
|
|
482
|
-
shardIndex,
|
|
483
|
-
requestId,
|
|
484
|
-
messages: [],
|
|
485
|
-
hasMore: false
|
|
486
|
-
});
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const shardPath = join(session.sharedDir, `messages.${shardIndex}.json`);
|
|
491
|
-
let messages = [];
|
|
492
|
-
try {
|
|
493
|
-
messages = JSON.parse(await fs.readFile(shardPath, 'utf-8'));
|
|
494
|
-
} catch { /* shard file doesn't exist */ }
|
|
495
|
-
|
|
496
|
-
// Check if there's an even older shard
|
|
497
|
-
const hasMore = shardIndex < await getMaxShardIndex(session.sharedDir);
|
|
498
|
-
|
|
499
|
-
sendCrewMessage({
|
|
500
|
-
type: 'crew_history_loaded',
|
|
501
|
-
sessionId,
|
|
502
|
-
shardIndex,
|
|
503
|
-
requestId,
|
|
504
|
-
messages,
|
|
505
|
-
hasMore
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// =====================================================================
|
|
510
|
-
// List & Resume Crew Sessions
|
|
511
|
-
// =====================================================================
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* 列出所有 crew sessions(从索引文件 + 活跃 sessions 合并)
|
|
515
|
-
*/
|
|
516
|
-
export async function handleListCrewSessions(msg) {
|
|
517
|
-
const { requestId, _requestClientId } = msg;
|
|
518
|
-
const index = await loadCrewIndex();
|
|
519
|
-
|
|
520
|
-
// 按 agentId 过滤(兼容旧数据:无 agentId 的 session 在所有 agent 中显示)
|
|
521
|
-
const agentId = ctx.CONFIG?.agentName || null;
|
|
522
|
-
const filtered = agentId
|
|
523
|
-
? index.filter(e => !e.agentId || e.agentId === agentId)
|
|
524
|
-
: index;
|
|
525
|
-
|
|
526
|
-
// 用活跃 session 更新实时状态
|
|
527
|
-
for (const entry of filtered) {
|
|
528
|
-
const active = crewSessions.get(entry.sessionId);
|
|
529
|
-
if (active) {
|
|
530
|
-
entry.status = active.status;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
ctx.sendToServer({
|
|
535
|
-
type: 'crew_sessions_list',
|
|
536
|
-
requestId,
|
|
537
|
-
_requestClientId,
|
|
538
|
-
sessions: filtered
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* 验证 projectDir 路径安全性:必须是绝对路径且不包含路径遍历
|
|
544
|
-
*/
|
|
545
|
-
function isValidProjectDir(dir) {
|
|
546
|
-
if (!dir || typeof dir !== 'string') return false;
|
|
547
|
-
if (!isAbsolute(dir)) return false;
|
|
548
|
-
if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(dir)) return false;
|
|
549
|
-
return true;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* 检查工作目录下是否存在 .crew 目录
|
|
554
|
-
*/
|
|
555
|
-
export async function handleCheckCrewExists(msg) {
|
|
556
|
-
const { projectDir, requestId, _requestClientId } = msg;
|
|
557
|
-
if (!projectDir || !isValidProjectDir(projectDir)) {
|
|
558
|
-
ctx.sendToServer({
|
|
559
|
-
type: 'crew_exists_result',
|
|
560
|
-
requestId,
|
|
561
|
-
_requestClientId,
|
|
562
|
-
exists: false,
|
|
563
|
-
error: 'projectDir is required'
|
|
564
|
-
});
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const crewDir = join(projectDir, '.crew');
|
|
569
|
-
try {
|
|
570
|
-
const stat = await fs.stat(crewDir);
|
|
571
|
-
if (stat.isDirectory()) {
|
|
572
|
-
// 尝试读取 session.json 获取 session 信息
|
|
573
|
-
let sessionInfo = null;
|
|
574
|
-
try {
|
|
575
|
-
const sessionPath = join(crewDir, 'session.json');
|
|
576
|
-
const data = await fs.readFile(sessionPath, 'utf-8');
|
|
577
|
-
sessionInfo = JSON.parse(data);
|
|
578
|
-
} catch {
|
|
579
|
-
// session.json 可能不存在,不影响
|
|
580
|
-
}
|
|
581
|
-
ctx.sendToServer({
|
|
582
|
-
type: 'crew_exists_result',
|
|
583
|
-
requestId,
|
|
584
|
-
_requestClientId,
|
|
585
|
-
exists: true,
|
|
586
|
-
projectDir,
|
|
587
|
-
sessionInfo
|
|
588
|
-
});
|
|
589
|
-
} else {
|
|
590
|
-
ctx.sendToServer({
|
|
591
|
-
type: 'crew_exists_result',
|
|
592
|
-
requestId,
|
|
593
|
-
_requestClientId,
|
|
594
|
-
exists: false,
|
|
595
|
-
projectDir
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
} catch {
|
|
599
|
-
ctx.sendToServer({
|
|
600
|
-
type: 'crew_exists_result',
|
|
601
|
-
requestId,
|
|
602
|
-
_requestClientId,
|
|
603
|
-
exists: false,
|
|
604
|
-
projectDir
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* 删除工作目录下的 .crew 目录
|
|
611
|
-
*/
|
|
612
|
-
export async function handleDeleteCrewDir(msg) {
|
|
613
|
-
const { projectDir, _requestClientId } = msg;
|
|
614
|
-
if (!isValidProjectDir(projectDir)) return;
|
|
615
|
-
const crewDir = join(projectDir, '.crew');
|
|
616
|
-
try {
|
|
617
|
-
await fs.rm(crewDir, { recursive: true, force: true });
|
|
618
|
-
} catch {
|
|
619
|
-
// ignore errors (dir may not exist)
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* 恢复已停止的 crew session
|
|
625
|
-
*/
|
|
626
|
-
export async function resumeCrewSession(msg) {
|
|
627
|
-
const { sessionId, userId, username } = msg;
|
|
628
|
-
|
|
629
|
-
// 如果已经在活跃 sessions 中,重新发送完整信息让前端重建
|
|
630
|
-
if (crewSessions.has(sessionId)) {
|
|
631
|
-
const session = crewSessions.get(sessionId);
|
|
632
|
-
const roles = Array.from(session.roles.values());
|
|
633
|
-
// 如果内存中没有 uiMessages,尝试从磁盘加载
|
|
634
|
-
if ((!session.uiMessages || session.uiMessages.length === 0) && session.sharedDir) {
|
|
635
|
-
const loaded = await loadSessionMessages(session.sharedDir);
|
|
636
|
-
session.uiMessages = loaded.messages;
|
|
637
|
-
}
|
|
638
|
-
// 发送前清理 _streaming 标记(跟磁盘保存逻辑保持一致)
|
|
639
|
-
const cleanedMessages = (session.uiMessages || []).map(m => {
|
|
640
|
-
const { _streaming, ...rest } = m;
|
|
641
|
-
return rest;
|
|
642
|
-
});
|
|
643
|
-
// 检查是否有历史分片
|
|
644
|
-
const hasOlderMessages = await getMaxShardIndex(session.sharedDir) > 0;
|
|
645
|
-
|
|
646
|
-
sendCrewMessage({
|
|
647
|
-
type: 'crew_session_restored',
|
|
648
|
-
sessionId,
|
|
649
|
-
projectDir: session.projectDir,
|
|
650
|
-
sharedDir: session.sharedDir,
|
|
651
|
-
name: session.name || '',
|
|
652
|
-
roles: roles.map(r => ({
|
|
653
|
-
name: r.name, displayName: r.displayName, icon: r.icon,
|
|
654
|
-
description: r.description, isDecisionMaker: r.isDecisionMaker || false,
|
|
655
|
-
groupIndex: r.groupIndex, roleType: r.roleType, model: r.model
|
|
656
|
-
})),
|
|
657
|
-
decisionMaker: session.decisionMaker,
|
|
658
|
-
userId: session.userId,
|
|
659
|
-
username: session.username,
|
|
660
|
-
uiMessages: cleanedMessages,
|
|
661
|
-
hasOlderMessages
|
|
662
|
-
});
|
|
663
|
-
sendStatusUpdate(session);
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// 从索引获取 sharedDir
|
|
668
|
-
const index = await loadCrewIndex();
|
|
669
|
-
const indexEntry = index.find(e => e.sessionId === sessionId);
|
|
670
|
-
if (!indexEntry) {
|
|
671
|
-
sendCrewMessage({ type: 'error', sessionId, message: 'Crew session not found in index' });
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// 从 session.json 加载详细元数据
|
|
676
|
-
const meta = await loadSessionMeta(indexEntry.sharedDir);
|
|
677
|
-
if (!meta) {
|
|
678
|
-
sendCrewMessage({ type: 'error', sessionId, message: 'Crew session metadata not found' });
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// 重建 session(跳过 initSharedDir,目录已存在)
|
|
683
|
-
const roles = meta.roles || [];
|
|
684
|
-
const decisionMaker = meta.decisionMaker || roles[0]?.name || null;
|
|
685
|
-
const session = {
|
|
686
|
-
id: sessionId,
|
|
687
|
-
projectDir: meta.projectDir,
|
|
688
|
-
sharedDir: meta.sharedDir || indexEntry.sharedDir,
|
|
689
|
-
name: meta.name || '',
|
|
690
|
-
roles: new Map(roles.map(r => [r.name, r])),
|
|
691
|
-
roleStates: new Map(),
|
|
692
|
-
decisionMaker,
|
|
693
|
-
status: 'waiting_human',
|
|
694
|
-
round: meta.round || 0,
|
|
695
|
-
costUsd: meta.costUsd || 0,
|
|
696
|
-
totalInputTokens: meta.totalInputTokens || 0,
|
|
697
|
-
totalOutputTokens: meta.totalOutputTokens || 0,
|
|
698
|
-
messageHistory: [],
|
|
699
|
-
uiMessages: [], // will be loaded from messages.json
|
|
700
|
-
humanMessageQueue: [],
|
|
701
|
-
waitingHumanContext: null,
|
|
702
|
-
pendingRoutes: [],
|
|
703
|
-
features: new Map((meta.features || []).map(f => [f.taskId, f])),
|
|
704
|
-
_completedTaskIds: new Set(meta._completedTaskIds || []),
|
|
705
|
-
userId: userId || meta.userId,
|
|
706
|
-
username: username || meta.username,
|
|
707
|
-
agentId: meta.agentId || ctx.CONFIG?.agentName || null,
|
|
708
|
-
teamType: meta.teamType || 'dev',
|
|
709
|
-
language: meta.language || 'zh-CN',
|
|
710
|
-
createdAt: meta.createdAt || Date.now()
|
|
711
|
-
};
|
|
712
|
-
crewSessions.set(sessionId, session);
|
|
713
|
-
|
|
714
|
-
// 加载 UI 消息历史(仅最新分片)
|
|
715
|
-
const loaded = await loadSessionMessages(session.sharedDir);
|
|
716
|
-
session.uiMessages = loaded.messages;
|
|
717
|
-
|
|
718
|
-
// 通知 server
|
|
719
|
-
sendCrewMessage({
|
|
720
|
-
type: 'crew_session_restored',
|
|
721
|
-
sessionId,
|
|
722
|
-
projectDir: session.projectDir,
|
|
723
|
-
sharedDir: session.sharedDir,
|
|
724
|
-
name: session.name || '',
|
|
725
|
-
roles: roles.map(r => ({
|
|
726
|
-
name: r.name, displayName: r.displayName, icon: r.icon,
|
|
727
|
-
description: r.description, isDecisionMaker: r.isDecisionMaker || false,
|
|
728
|
-
groupIndex: r.groupIndex, roleType: r.roleType, model: r.model
|
|
729
|
-
})),
|
|
730
|
-
decisionMaker,
|
|
731
|
-
userId: session.userId,
|
|
732
|
-
username: session.username,
|
|
733
|
-
uiMessages: session.uiMessages,
|
|
734
|
-
hasOlderMessages: loaded.hasOlderMessages
|
|
735
|
-
});
|
|
736
|
-
sendStatusUpdate(session);
|
|
737
|
-
|
|
738
|
-
// 更新索引和 session.json
|
|
739
|
-
await upsertCrewIndex(session);
|
|
740
|
-
await saveSessionMeta(session);
|
|
741
|
-
|
|
742
|
-
console.log(`[Crew] Session ${sessionId} resumed, waiting for human input`);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* 查找指定 projectDir 的已有 crew session(内存活跃 > 磁盘索引)
|
|
747
|
-
*/
|
|
748
|
-
async function findExistingSessionByProjectDir(projectDir) {
|
|
749
|
-
const normalizedDir = projectDir.replace(/\/+$/, '');
|
|
750
|
-
|
|
751
|
-
for (const [, session] of crewSessions) {
|
|
752
|
-
if (session.projectDir.replace(/\/+$/, '') === normalizedDir
|
|
753
|
-
&& session.status !== 'completed') {
|
|
754
|
-
return { sessionId: session.id, source: 'active' };
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
const index = await loadCrewIndex();
|
|
759
|
-
const agentId = ctx.CONFIG?.agentName || null;
|
|
760
|
-
const match = index.find(e =>
|
|
761
|
-
e.projectDir.replace(/\/+$/, '') === normalizedDir
|
|
762
|
-
&& (!agentId || !e.agentId || e.agentId === agentId)
|
|
763
|
-
&& e.status !== 'completed'
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
if (match) {
|
|
767
|
-
const meta = await loadSessionMeta(match.sharedDir);
|
|
768
|
-
if (meta) return { sessionId: match.sessionId, source: 'index' };
|
|
769
|
-
await removeFromCrewIndex(match.sessionId);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// =====================================================================
|
|
776
|
-
// Session Lifecycle
|
|
777
|
-
// =====================================================================
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* 创建 Crew Session
|
|
781
|
-
* 支持带角色创建或空 session(后续动态添加角色)
|
|
782
|
-
*/
|
|
783
|
-
export async function createCrewSession(msg) {
|
|
784
|
-
const {
|
|
785
|
-
sessionId,
|
|
786
|
-
projectDir,
|
|
787
|
-
sharedDir: sharedDirRel,
|
|
788
|
-
name,
|
|
789
|
-
roles: rawRoles = [], // [{ name, displayName, icon, description, claudeMd, model, budget, isDecisionMaker, count }]
|
|
790
|
-
teamType = 'dev',
|
|
791
|
-
language = 'zh-CN',
|
|
792
|
-
userId,
|
|
793
|
-
username
|
|
794
|
-
} = msg;
|
|
795
|
-
|
|
796
|
-
// 同目录检查:如果 projectDir 已有活跃或可恢复的 session,自动 resume
|
|
797
|
-
const existingSession = await findExistingSessionByProjectDir(projectDir);
|
|
798
|
-
if (existingSession) {
|
|
799
|
-
console.log(`[Crew] Found existing session for ${projectDir}: ${existingSession.sessionId}, auto-resuming`);
|
|
800
|
-
await resumeCrewSession({ sessionId: existingSession.sessionId, userId, username });
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// 展开多实例角色(count > 1 的执行者角色)
|
|
805
|
-
const roles = expandRoles(rawRoles);
|
|
806
|
-
|
|
807
|
-
// 解析共享目录(相对路径相对于 projectDir)
|
|
808
|
-
const sharedDir = sharedDirRel?.startsWith('/')
|
|
809
|
-
? sharedDirRel
|
|
810
|
-
: join(projectDir, sharedDirRel || '.crew');
|
|
811
|
-
|
|
812
|
-
// 找到决策者
|
|
813
|
-
const decisionMaker = roles.find(r => r.isDecisionMaker)?.name || roles[0]?.name || null;
|
|
814
|
-
|
|
815
|
-
// ★ 阶段1:立即构建 session 并通知前端,让 UI 先显示
|
|
816
|
-
const session = {
|
|
817
|
-
id: sessionId,
|
|
818
|
-
projectDir,
|
|
819
|
-
sharedDir,
|
|
820
|
-
name: name || '',
|
|
821
|
-
roles: new Map(roles.map(r => [r.name, r])),
|
|
822
|
-
roleStates: new Map(),
|
|
823
|
-
decisionMaker,
|
|
824
|
-
status: 'initializing', // ← 新增初始化状态
|
|
825
|
-
round: 0,
|
|
826
|
-
costUsd: 0,
|
|
827
|
-
totalInputTokens: 0,
|
|
828
|
-
totalOutputTokens: 0,
|
|
829
|
-
messageHistory: [], // 群聊消息历史
|
|
830
|
-
uiMessages: [], // 精简的 UI 消息历史(用于恢复时重放)
|
|
831
|
-
humanMessageQueue: [], // 人的消息排队
|
|
832
|
-
waitingHumanContext: null, // { fromRole, reason, message }
|
|
833
|
-
pendingRoutes: [], // [{ fromRole, route }] — 暂停时未完成的路由
|
|
834
|
-
features: new Map(), // taskId → { taskId, taskTitle, createdAt } — 持久化 feature 列表
|
|
835
|
-
_completedTaskIds: new Set(), // 已完成的 taskId 集合(用于检测新完成的任务)
|
|
836
|
-
initProgress: null, // 'roles' | 'worktrees' | null — 初始化阶段
|
|
837
|
-
userId,
|
|
838
|
-
username,
|
|
839
|
-
agentId: ctx.CONFIG?.agentName || null,
|
|
840
|
-
teamType,
|
|
841
|
-
language,
|
|
842
|
-
createdAt: Date.now()
|
|
843
|
-
};
|
|
844
|
-
|
|
845
|
-
crewSessions.set(sessionId, session);
|
|
846
|
-
|
|
847
|
-
// 立即通知前端:session 已创建,可以显示 UI
|
|
848
|
-
sendCrewMessage({
|
|
849
|
-
type: 'crew_session_created',
|
|
850
|
-
sessionId,
|
|
851
|
-
projectDir,
|
|
852
|
-
sharedDir,
|
|
853
|
-
name: name || '',
|
|
854
|
-
roles: roles.map(r => ({
|
|
855
|
-
name: r.name,
|
|
856
|
-
displayName: r.displayName,
|
|
857
|
-
icon: r.icon,
|
|
858
|
-
description: r.description,
|
|
859
|
-
isDecisionMaker: r.isDecisionMaker || false,
|
|
860
|
-
model: r.model,
|
|
861
|
-
roleType: r.roleType,
|
|
862
|
-
groupIndex: r.groupIndex
|
|
863
|
-
})),
|
|
864
|
-
decisionMaker,
|
|
865
|
-
userId,
|
|
866
|
-
username
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
sendStatusUpdate(session);
|
|
870
|
-
|
|
871
|
-
// ★ 阶段2:异步完成文件系统和 worktree 初始化
|
|
872
|
-
try {
|
|
873
|
-
// 初始化共享区(角色目录 + CLAUDE.md)
|
|
874
|
-
session.initProgress = 'roles';
|
|
875
|
-
sendStatusUpdate(session);
|
|
876
|
-
await initSharedDir(sharedDir, roles, projectDir, language);
|
|
877
|
-
|
|
878
|
-
// 初始化 git worktrees
|
|
879
|
-
const groupIndices = [...new Set(roles.filter(r => r.groupIndex > 0).map(r => r.groupIndex))];
|
|
880
|
-
if (groupIndices.length > 0) {
|
|
881
|
-
session.initProgress = 'worktrees';
|
|
882
|
-
sendStatusUpdate(session);
|
|
883
|
-
}
|
|
884
|
-
const worktreeMap = await initWorktrees(projectDir, roles);
|
|
885
|
-
|
|
886
|
-
// 回填 workDir
|
|
887
|
-
for (const role of roles) {
|
|
888
|
-
if (role.groupIndex > 0 && worktreeMap.has(role.groupIndex)) {
|
|
889
|
-
role.workDir = worktreeMap.get(role.groupIndex);
|
|
890
|
-
await writeRoleClaudeMd(sharedDir, role, language);
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// 持久化
|
|
895
|
-
await upsertCrewIndex(session);
|
|
896
|
-
await saveSessionMeta(session);
|
|
897
|
-
|
|
898
|
-
// 初始化完成,仅在 initializing 状态下切换到 running(避免覆盖用户手动暂停/停止)
|
|
899
|
-
if (session.status === 'initializing') {
|
|
900
|
-
session.status = 'running';
|
|
901
|
-
}
|
|
902
|
-
session.initProgress = null;
|
|
903
|
-
sendStatusUpdate(session);
|
|
904
|
-
} catch (e) {
|
|
905
|
-
console.error('[Crew] Session initialization failed:', e);
|
|
906
|
-
if (session.status === 'initializing') {
|
|
907
|
-
session.status = 'running';
|
|
908
|
-
}
|
|
909
|
-
session.initProgress = null;
|
|
910
|
-
sendStatusUpdate(session);
|
|
911
|
-
sendCrewMessage({
|
|
912
|
-
type: 'crew_output',
|
|
913
|
-
sessionId,
|
|
914
|
-
roleName: 'system',
|
|
915
|
-
roleIcon: 'S',
|
|
916
|
-
roleDisplayName: '系统',
|
|
917
|
-
content: `工作环境初始化失败: ${e.message}`,
|
|
918
|
-
isTurnEnd: true
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
return session;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// =====================================================================
|
|
926
|
-
// Dynamic Role Management
|
|
927
|
-
// =====================================================================
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* 向现有 session 动态添加角色
|
|
931
|
-
*/
|
|
932
|
-
export async function addRoleToSession(msg) {
|
|
933
|
-
const { sessionId, role } = msg;
|
|
934
|
-
const session = crewSessions.get(sessionId);
|
|
935
|
-
if (!session) {
|
|
936
|
-
console.warn(`[Crew] Session not found: ${sessionId}`);
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// 展开多实例(count > 1 时)
|
|
941
|
-
const rolesToAdd = expandRoles([role]);
|
|
942
|
-
|
|
943
|
-
for (const r of rolesToAdd) {
|
|
944
|
-
if (session.roles.has(r.name)) {
|
|
945
|
-
console.warn(`[Crew] Role already exists: ${r.name}`);
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// 添加角色到 session
|
|
950
|
-
session.roles.set(r.name, r);
|
|
951
|
-
|
|
952
|
-
// 如果还没有决策者且新角色是决策者,更新
|
|
953
|
-
if (r.isDecisionMaker) {
|
|
954
|
-
session.decisionMaker = r.name;
|
|
955
|
-
}
|
|
956
|
-
// 如果没有任何决策者,第一个角色作为决策者
|
|
957
|
-
if (!session.decisionMaker) {
|
|
958
|
-
session.decisionMaker = r.name;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// 初始化角色目录(CLAUDE.md + memory.md)
|
|
962
|
-
await initRoleDir(session.sharedDir, r, session.language || 'zh-CN');
|
|
963
|
-
|
|
964
|
-
console.log(`[Crew] Role added: ${r.name} (${r.displayName}) to session ${sessionId}`);
|
|
965
|
-
|
|
966
|
-
// 通知 Web 端
|
|
967
|
-
sendCrewMessage({
|
|
968
|
-
type: 'crew_role_added',
|
|
969
|
-
sessionId,
|
|
970
|
-
role: {
|
|
971
|
-
name: r.name,
|
|
972
|
-
displayName: r.displayName,
|
|
973
|
-
icon: r.icon,
|
|
974
|
-
description: r.description,
|
|
975
|
-
isDecisionMaker: r.isDecisionMaker || false,
|
|
976
|
-
model: r.model,
|
|
977
|
-
roleType: r.roleType,
|
|
978
|
-
groupIndex: r.groupIndex
|
|
979
|
-
},
|
|
980
|
-
decisionMaker: session.decisionMaker
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
// 发送系统消息
|
|
984
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
985
|
-
type: 'assistant',
|
|
986
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(r)} 加入了群聊` }] }
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// 更新共享 CLAUDE.md(增量添加新角色信息)
|
|
991
|
-
await updateSharedClaudeMd(session);
|
|
992
|
-
|
|
993
|
-
sendStatusUpdate(session);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* 从 session 移除角色
|
|
998
|
-
*/
|
|
999
|
-
export async function removeRoleFromSession(msg) {
|
|
1000
|
-
const { sessionId, roleName } = msg;
|
|
1001
|
-
const session = crewSessions.get(sessionId);
|
|
1002
|
-
if (!session) {
|
|
1003
|
-
console.warn(`[Crew] Session not found: ${sessionId}`);
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const role = session.roles.get(roleName);
|
|
1008
|
-
if (!role) {
|
|
1009
|
-
console.warn(`[Crew] Role not found: ${roleName}`);
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// 停止角色的 query(如果正在运行)
|
|
1014
|
-
const roleState = session.roleStates.get(roleName);
|
|
1015
|
-
if (roleState) {
|
|
1016
|
-
// 保存 sessionId 到文件(以便未来恢复)
|
|
1017
|
-
if (roleState.claudeSessionId) {
|
|
1018
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId);
|
|
1019
|
-
}
|
|
1020
|
-
if (roleState.abortController) {
|
|
1021
|
-
roleState.abortController.abort();
|
|
1022
|
-
}
|
|
1023
|
-
session.roleStates.delete(roleName);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// 从 roles 中移除
|
|
1027
|
-
session.roles.delete(roleName);
|
|
1028
|
-
|
|
1029
|
-
// 如果移除的是决策者,重新选择
|
|
1030
|
-
if (session.decisionMaker === roleName) {
|
|
1031
|
-
const remaining = Array.from(session.roles.values());
|
|
1032
|
-
const newDM = remaining.find(r => r.isDecisionMaker) || remaining[0];
|
|
1033
|
-
session.decisionMaker = newDM?.name || null;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// 更新 CLAUDE.md
|
|
1037
|
-
await updateSharedClaudeMd(session);
|
|
1038
|
-
|
|
1039
|
-
// Memory 文件保留(不删除,角色可能重新加入)
|
|
1040
|
-
|
|
1041
|
-
console.log(`[Crew] Role removed: ${roleName} from session ${sessionId}`);
|
|
1042
|
-
|
|
1043
|
-
sendCrewMessage({
|
|
1044
|
-
type: 'crew_role_removed',
|
|
1045
|
-
sessionId,
|
|
1046
|
-
roleName,
|
|
1047
|
-
decisionMaker: session.decisionMaker
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
1051
|
-
type: 'assistant',
|
|
1052
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(role)} 离开了群聊` }] }
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
sendStatusUpdate(session);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* 更新 crew session 的 name
|
|
1060
|
-
*/
|
|
1061
|
-
export async function handleUpdateCrewSession(msg) {
|
|
1062
|
-
const { sessionId, name } = msg;
|
|
1063
|
-
const session = crewSessions.get(sessionId);
|
|
1064
|
-
if (!session) {
|
|
1065
|
-
console.warn(`[Crew] Session not found for update: ${sessionId}`);
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
if (name !== undefined) session.name = name;
|
|
1069
|
-
await saveSessionMeta(session);
|
|
1070
|
-
await upsertCrewIndex(session);
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// =====================================================================
|
|
1074
|
-
// Shared Directory & Memory
|
|
1075
|
-
// =====================================================================
|
|
1076
|
-
|
|
1077
|
-
/**
|
|
1078
|
-
* 初始化共享目录
|
|
1079
|
-
* 结构:
|
|
1080
|
-
* .crew/
|
|
1081
|
-
* ├── CLAUDE.md ← 共享级(团队目标、成员、共享记忆)
|
|
1082
|
-
* ├── context/ ← 文档产出
|
|
1083
|
-
* ├── sessions/ ← sessionId 持久化
|
|
1084
|
-
* └── roles/
|
|
1085
|
-
* └── {roleName}/
|
|
1086
|
-
* └── CLAUDE.md ← 角色定义 + 个人记忆
|
|
1087
|
-
*/
|
|
1088
|
-
async function initSharedDir(sharedDir, roles, projectDir, language = 'zh-CN') {
|
|
1089
|
-
await fs.mkdir(sharedDir, { recursive: true });
|
|
1090
|
-
await fs.mkdir(join(sharedDir, 'context'), { recursive: true });
|
|
1091
|
-
await fs.mkdir(join(sharedDir, 'sessions'), { recursive: true });
|
|
1092
|
-
await fs.mkdir(join(sharedDir, 'roles'), { recursive: true });
|
|
1093
|
-
|
|
1094
|
-
// 初始化每个角色的目录
|
|
1095
|
-
for (const role of roles) {
|
|
1096
|
-
await initRoleDir(sharedDir, role, language);
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// 生成 .crew/CLAUDE.md(共享级)
|
|
1100
|
-
await writeSharedClaudeMd(sharedDir, roles, projectDir, language);
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* 初始化角色目录: .crew/roles/{roleName}/CLAUDE.md
|
|
1105
|
-
*/
|
|
1106
|
-
async function initRoleDir(sharedDir, role, language = 'zh-CN') {
|
|
1107
|
-
const roleDir = join(sharedDir, 'roles', role.name);
|
|
1108
|
-
await fs.mkdir(roleDir, { recursive: true });
|
|
1109
|
-
|
|
1110
|
-
// 角色 CLAUDE.md(仅首次创建,后续角色自己维护记忆内容)
|
|
1111
|
-
const claudeMdPath = join(roleDir, 'CLAUDE.md');
|
|
1112
|
-
try {
|
|
1113
|
-
await fs.access(claudeMdPath);
|
|
1114
|
-
// 已存在,不覆盖(保留角色自己写入的记忆)
|
|
1115
|
-
} catch {
|
|
1116
|
-
await writeRoleClaudeMd(sharedDir, role, language);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
/**
|
|
1121
|
-
* 写入 .crew/CLAUDE.md — 共享级(所有角色自动继承)
|
|
1122
|
-
* 记忆直接写在 CLAUDE.md 中,Claude Code 会自动加载
|
|
1123
|
-
*/
|
|
1124
|
-
async function writeSharedClaudeMd(sharedDir, roles, projectDir, language = 'zh-CN') {
|
|
1125
|
-
const m = getMessages(language);
|
|
1126
|
-
|
|
1127
|
-
const claudeMd = `${m.projectGoal}
|
|
1128
|
-
|
|
1129
|
-
${m.projectCodePath}
|
|
1130
|
-
${projectDir}
|
|
1131
|
-
${m.useAbsolutePath}
|
|
1132
|
-
|
|
1133
|
-
${m.teamMembersTitle}
|
|
1134
|
-
${roles.length > 0 ? roles.map(r => `- ${roleLabel(r)}(${r.name}): ${r.description}${r.isDecisionMaker ? ` (${m.decisionMakerTag})` : ''}`).join('\n') : m.noMembers}
|
|
1135
|
-
|
|
1136
|
-
${m.workConventions}
|
|
1137
|
-
${m.workConventionsContent}
|
|
1138
|
-
|
|
1139
|
-
${m.stuckRules}
|
|
1140
|
-
${m.stuckRulesContent}
|
|
1141
|
-
|
|
1142
|
-
${m.worktreeRules}
|
|
1143
|
-
${m.worktreeRulesContent}
|
|
1144
|
-
|
|
1145
|
-
${m.featureRecordShared}
|
|
1146
|
-
|
|
1147
|
-
${m.sharedMemoryTitle}
|
|
1148
|
-
${m.sharedMemoryDefault}
|
|
1149
|
-
`;
|
|
1150
|
-
|
|
1151
|
-
await fs.writeFile(join(sharedDir, 'CLAUDE.md'), claudeMd);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* 写入 .crew/roles/{roleName}/CLAUDE.md — 角色级
|
|
1156
|
-
* 记忆直接追加在此文件中,Claude Code 自动加载
|
|
1157
|
-
*/
|
|
1158
|
-
async function writeRoleClaudeMd(sharedDir, role, language = 'zh-CN') {
|
|
1159
|
-
const roleDir = join(sharedDir, 'roles', role.name);
|
|
1160
|
-
const m = getMessages(language);
|
|
1161
|
-
|
|
1162
|
-
let claudeMd = `${m.roleTitle(roleLabel(role))}
|
|
1163
|
-
${role.claudeMd || role.description}
|
|
1164
|
-
`;
|
|
1165
|
-
|
|
1166
|
-
// 有独立 worktree 的角色,覆盖代码工作目录
|
|
1167
|
-
if (role.workDir) {
|
|
1168
|
-
claudeMd += `
|
|
1169
|
-
${m.codeWorkDir}
|
|
1170
|
-
${role.workDir}
|
|
1171
|
-
${m.codeWorkDirNote}
|
|
1172
|
-
`;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
claudeMd += `
|
|
1176
|
-
${m.personalMemory}
|
|
1177
|
-
${m.personalMemoryDefault}
|
|
1178
|
-
`;
|
|
1179
|
-
|
|
1180
|
-
await fs.writeFile(join(roleDir, 'CLAUDE.md'), claudeMd);
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
/**
|
|
1184
|
-
* 角色变动时更新 .crew/CLAUDE.md
|
|
1185
|
-
*/
|
|
1186
|
-
async function updateSharedClaudeMd(session) {
|
|
1187
|
-
const roles = Array.from(session.roles.values());
|
|
1188
|
-
await writeSharedClaudeMd(session.sharedDir, roles, session.projectDir, session.language || 'zh-CN');
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// =====================================================================
|
|
1192
|
-
// Task File Management (auto-managed by system)
|
|
1193
|
-
// =====================================================================
|
|
1194
|
-
|
|
1195
|
-
/**
|
|
1196
|
-
* 自动创建 task 进度文件
|
|
1197
|
-
* 当 ROUTE 带有 taskId + taskTitle 时,如果文件不存在则自动创建
|
|
1198
|
-
*/
|
|
1199
|
-
async function ensureTaskFile(session, taskId, taskTitle, assignee, summary) {
|
|
1200
|
-
const featuresDir = join(session.sharedDir, 'context', 'features');
|
|
1201
|
-
const filePath = join(featuresDir, `${taskId}.md`);
|
|
1202
|
-
|
|
1203
|
-
try {
|
|
1204
|
-
await fs.access(filePath);
|
|
1205
|
-
// 文件已存在,不覆盖
|
|
1206
|
-
return;
|
|
1207
|
-
} catch {
|
|
1208
|
-
// 文件不存在,创建
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
await fs.mkdir(featuresDir, { recursive: true });
|
|
1212
|
-
|
|
1213
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
1214
|
-
const now = new Date().toISOString();
|
|
1215
|
-
const content = `# ${m.featureLabel}: ${taskTitle}
|
|
1216
|
-
- task-id: ${taskId}
|
|
1217
|
-
- ${m.statusPending}
|
|
1218
|
-
- ${m.assigneeLabel}: ${assignee}
|
|
1219
|
-
- ${m.createdAtLabel}: ${now}
|
|
1220
|
-
|
|
1221
|
-
${m.requirementDesc}
|
|
1222
|
-
${summary}
|
|
1223
|
-
|
|
1224
|
-
${m.workRecord}
|
|
1225
|
-
`;
|
|
1226
|
-
|
|
1227
|
-
await fs.writeFile(filePath, content);
|
|
1228
|
-
|
|
1229
|
-
// 同步到 session.features
|
|
1230
|
-
if (!session.features.has(taskId)) {
|
|
1231
|
-
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
console.log(`[Crew] Task file created: ${taskId} (${taskTitle})`);
|
|
1235
|
-
|
|
1236
|
-
// 更新 feature 索引
|
|
1237
|
-
updateFeatureIndex(session).catch(e => console.warn('[Crew] Failed to update feature index:', e.message));
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
/**
|
|
1241
|
-
* 追加工作记录到 task 文件
|
|
1242
|
-
* 当角色 ROUTE 时,自动将 summary 追加到对应 task 文件
|
|
1243
|
-
*/
|
|
1244
|
-
async function appendTaskRecord(session, taskId, roleName, summary) {
|
|
1245
|
-
const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
|
|
1246
|
-
|
|
1247
|
-
try {
|
|
1248
|
-
await fs.access(filePath);
|
|
1249
|
-
} catch {
|
|
1250
|
-
// 文件不存在,跳过(不应该发生,但防御性处理)
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
const role = session.roles.get(roleName);
|
|
1255
|
-
const label = role ? roleLabel(role) : roleName;
|
|
1256
|
-
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
|
1257
|
-
const record = `\n### ${label} - ${now}\n${summary}\n`;
|
|
1258
|
-
|
|
1259
|
-
await fs.appendFile(filePath, record);
|
|
1260
|
-
console.log(`[Crew] Task record appended: ${taskId} by ${roleName}`);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* 读取 task 文件内容(用于注入上下文)
|
|
1265
|
-
*/
|
|
1266
|
-
async function readTaskFile(session, taskId) {
|
|
1267
|
-
const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
|
|
1268
|
-
try {
|
|
1269
|
-
return await fs.readFile(filePath, 'utf-8');
|
|
1270
|
-
} catch {
|
|
1271
|
-
return null;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* 从 TASKS block 文本中提取已完成任务的 taskId 集合
|
|
1277
|
-
*/
|
|
1278
|
-
function parseCompletedTasks(text) {
|
|
1279
|
-
const ids = new Set();
|
|
1280
|
-
const match = text.match(/---TASKS---([\s\S]*?)---END_TASKS---/);
|
|
1281
|
-
if (!match) return ids;
|
|
1282
|
-
for (const line of match[1].split('\n')) {
|
|
1283
|
-
const m = line.match(/^-\s*\[[xX]\]\s*.+#(\S+)/);
|
|
1284
|
-
if (m) ids.add(m[1]);
|
|
1285
|
-
}
|
|
1286
|
-
return ids;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* 更新 feature 索引文件 context/features/index.md
|
|
1291
|
-
* 全量重建:根据 session.features 和 session._completedTaskIds 生成分类表格
|
|
1292
|
-
*/
|
|
1293
|
-
async function updateFeatureIndex(session) {
|
|
1294
|
-
const featuresDir = join(session.sharedDir, 'context', 'features');
|
|
1295
|
-
await fs.mkdir(featuresDir, { recursive: true });
|
|
1296
|
-
|
|
1297
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
1298
|
-
const completed = session._completedTaskIds || new Set();
|
|
1299
|
-
const allFeatures = Array.from(session.features.values());
|
|
1300
|
-
|
|
1301
|
-
// 按创建时间排序
|
|
1302
|
-
allFeatures.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1303
|
-
|
|
1304
|
-
const inProgress = allFeatures.filter(f => !completed.has(f.taskId));
|
|
1305
|
-
const done = allFeatures.filter(f => completed.has(f.taskId));
|
|
1306
|
-
|
|
1307
|
-
const locale = (session.language === 'en') ? 'en-US' : 'zh-CN';
|
|
1308
|
-
const now = new Date().toLocaleString(locale, { timeZone: 'Asia/Shanghai' });
|
|
1309
|
-
let content = `${m.featureIndex}\n> ${m.lastUpdated}: ${now}\n`;
|
|
1310
|
-
|
|
1311
|
-
content += `\n${m.inProgressGroup(inProgress.length)}\n`;
|
|
1312
|
-
if (inProgress.length > 0) {
|
|
1313
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.colCreatedAt} |\n|---------|------|----------|\n`;
|
|
1314
|
-
for (const f of inProgress) {
|
|
1315
|
-
const date = f.createdAt ? new Date(f.createdAt).toLocaleDateString(locale) : '-';
|
|
1316
|
-
content += `| ${f.taskId} | ${f.taskTitle} | ${date} |\n`;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
content += `\n${m.completedGroup(done.length)}\n`;
|
|
1321
|
-
if (done.length > 0) {
|
|
1322
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.colCreatedAt} |\n|---------|------|----------|\n`;
|
|
1323
|
-
for (const f of done) {
|
|
1324
|
-
const date = f.createdAt ? new Date(f.createdAt).toLocaleDateString(locale) : '-';
|
|
1325
|
-
content += `| ${f.taskId} | ${f.taskTitle} | ${date} |\n`;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
await fs.writeFile(join(featuresDir, 'index.md'), content);
|
|
1330
|
-
console.log(`[Crew] Feature index updated: ${inProgress.length} in progress, ${done.length} completed`);
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
/**
|
|
1334
|
-
* 追加完成汇总到 context/changelog.md
|
|
1335
|
-
* 从 feature 文件的工作记录中提取最后一条记录作为摘要
|
|
1336
|
-
*/
|
|
1337
|
-
async function appendChangelog(session, taskId, taskTitle) {
|
|
1338
|
-
const contextDir = join(session.sharedDir, 'context');
|
|
1339
|
-
await fs.mkdir(contextDir, { recursive: true });
|
|
1340
|
-
const changelogPath = join(contextDir, 'changelog.md');
|
|
1341
|
-
|
|
1342
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
1343
|
-
|
|
1344
|
-
// 读取 feature 文件提取最后一条工作记录作为摘要
|
|
1345
|
-
const taskContent = await readTaskFile(session, taskId);
|
|
1346
|
-
let summaryText = '';
|
|
1347
|
-
if (taskContent) {
|
|
1348
|
-
// 提取最后一个 ### 块作为摘要
|
|
1349
|
-
const records = taskContent.split(/\n### /);
|
|
1350
|
-
if (records.length > 1) {
|
|
1351
|
-
const lastRecord = records[records.length - 1];
|
|
1352
|
-
// 取第一行之后的内容作为摘要(第一行是角色名和时间)
|
|
1353
|
-
const lines = lastRecord.split('\n');
|
|
1354
|
-
summaryText = lines.slice(1).join('\n').trim();
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
if (!summaryText) {
|
|
1358
|
-
summaryText = m.noSummary;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
// 限制摘要长度
|
|
1362
|
-
if (summaryText.length > 500) {
|
|
1363
|
-
summaryText = summaryText.substring(0, 497) + '...';
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
const locale = (session.language === 'en') ? 'en-US' : 'zh-CN';
|
|
1367
|
-
const now = new Date().toLocaleString(locale, { timeZone: 'Asia/Shanghai' });
|
|
1368
|
-
const entry = `\n## ${taskId}: ${taskTitle}\n- ${m.completedAt}: ${now}\n- ${m.summaryLabel}: ${summaryText}\n`;
|
|
1369
|
-
|
|
1370
|
-
// 如果文件不存在,先写 header
|
|
1371
|
-
let exists = false;
|
|
1372
|
-
try {
|
|
1373
|
-
await fs.access(changelogPath);
|
|
1374
|
-
exists = true;
|
|
1375
|
-
} catch {}
|
|
1376
|
-
|
|
1377
|
-
if (!exists) {
|
|
1378
|
-
await fs.writeFile(changelogPath, `${m.changelogTitle}\n${entry}`);
|
|
1379
|
-
} else {
|
|
1380
|
-
await fs.appendFile(changelogPath, entry);
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
console.log(`[Crew] Changelog appended: ${taskId} (${taskTitle})`);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// =====================================================================
|
|
1387
|
-
// Session Persistence
|
|
1388
|
-
// =====================================================================
|
|
1389
|
-
|
|
1390
|
-
/**
|
|
1391
|
-
* 保存角色的 claudeSessionId 到文件
|
|
1392
|
-
*/
|
|
1393
|
-
async function saveRoleSessionId(sharedDir, roleName, claudeSessionId) {
|
|
1394
|
-
const sessionsDir = join(sharedDir, 'sessions');
|
|
1395
|
-
await fs.mkdir(sessionsDir, { recursive: true });
|
|
1396
|
-
const filePath = join(sessionsDir, `${roleName}.json`);
|
|
1397
|
-
await fs.writeFile(filePath, JSON.stringify({
|
|
1398
|
-
claudeSessionId,
|
|
1399
|
-
savedAt: Date.now()
|
|
1400
|
-
}));
|
|
1401
|
-
console.log(`[Crew] Saved sessionId for ${roleName}: ${claudeSessionId}`);
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
/**
|
|
1405
|
-
* 从文件加载角色的 claudeSessionId
|
|
1406
|
-
*/
|
|
1407
|
-
async function loadRoleSessionId(sharedDir, roleName) {
|
|
1408
|
-
const filePath = join(sharedDir, 'sessions', `${roleName}.json`);
|
|
1409
|
-
try {
|
|
1410
|
-
const data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
1411
|
-
return data.claudeSessionId || null;
|
|
1412
|
-
} catch {
|
|
1413
|
-
return null;
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
/**
|
|
1419
|
-
* 清除角色的 savedSessionId(用于强制新建 conversation)
|
|
1420
|
-
*/
|
|
1421
|
-
async function clearRoleSessionId(sharedDir, roleName) {
|
|
1422
|
-
const filePath = join(sharedDir, 'sessions', `${roleName}.json`);
|
|
1423
|
-
try {
|
|
1424
|
-
await fs.unlink(filePath);
|
|
1425
|
-
console.log(`[Crew] Cleared sessionId for ${roleName} (force new conversation)`);
|
|
1426
|
-
} catch {
|
|
1427
|
-
// 文件不存在也正常
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* 判断角色错误是否可恢复
|
|
1433
|
-
*/
|
|
1434
|
-
function classifyRoleError(error) {
|
|
1435
|
-
const msg = error.message || '';
|
|
1436
|
-
if (/context.*(window|limit|exceeded)|token.*limit|too.*(long|large)|max.*token/i.test(msg)) {
|
|
1437
|
-
return { recoverable: true, reason: 'context_exceeded', skipResume: true };
|
|
1438
|
-
}
|
|
1439
|
-
if (/compact|compress|context.*reduc/i.test(msg)) {
|
|
1440
|
-
return { recoverable: true, reason: 'compact_failed', skipResume: true };
|
|
1441
|
-
}
|
|
1442
|
-
if (/rate.?limit|429|overloaded|503|502|timeout|ECONNRESET|ETIMEDOUT/i.test(msg)) {
|
|
1443
|
-
return { recoverable: true, reason: 'transient_api_error', skipResume: false };
|
|
1444
|
-
}
|
|
1445
|
-
if (/exited with code [1-9]/i.test(msg) && msg.length < 100) {
|
|
1446
|
-
return { recoverable: true, reason: 'process_crashed', skipResume: false };
|
|
1447
|
-
}
|
|
1448
|
-
if (/spawn|ENOENT|not found/i.test(msg)) {
|
|
1449
|
-
return { recoverable: false, reason: 'spawn_failed' };
|
|
1450
|
-
}
|
|
1451
|
-
return { recoverable: true, reason: 'unknown', skipResume: false };
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// =====================================================================
|
|
1455
|
-
// Role Query Management
|
|
1456
|
-
// =====================================================================
|
|
1457
|
-
|
|
1458
|
-
/**
|
|
1459
|
-
* 为角色创建持久 query 实例
|
|
1460
|
-
* 支持 resume:如果角色之前有保存的 sessionId,自动恢复上下文
|
|
1461
|
-
*/
|
|
1462
|
-
async function createRoleQuery(session, roleName) {
|
|
1463
|
-
const role = session.roles.get(roleName);
|
|
1464
|
-
if (!role) throw new Error(`Role not found: ${roleName}`);
|
|
1465
|
-
|
|
1466
|
-
const inputStream = new Stream();
|
|
1467
|
-
const abortController = new AbortController();
|
|
1468
|
-
|
|
1469
|
-
const systemPrompt = buildRoleSystemPrompt(role, session);
|
|
1470
|
-
|
|
1471
|
-
// 尝试加载之前保存的 sessionId
|
|
1472
|
-
const savedSessionId = await loadRoleSessionId(session.sharedDir, roleName);
|
|
1473
|
-
|
|
1474
|
-
// ★ cwd 设为角色目录,Claude Code 自动加载:
|
|
1475
|
-
// 1. .crew/roles/{roleName}/CLAUDE.md(角色定义+个人记忆)
|
|
1476
|
-
// 2. .crew/CLAUDE.md(共享目标+团队信息+共享记忆)
|
|
1477
|
-
// 3. {projectDir}/CLAUDE.md(项目级,如果有的话)
|
|
1478
|
-
const roleCwd = join(session.sharedDir, 'roles', roleName);
|
|
1479
|
-
|
|
1480
|
-
const queryOptions = {
|
|
1481
|
-
cwd: roleCwd,
|
|
1482
|
-
permissionMode: 'bypassPermissions',
|
|
1483
|
-
abort: abortController.signal,
|
|
1484
|
-
model: role.model || undefined,
|
|
1485
|
-
appendSystemPrompt: systemPrompt
|
|
1486
|
-
};
|
|
1487
|
-
|
|
1488
|
-
// 如果有保存的 sessionId,使用 resume 恢复上下文
|
|
1489
|
-
if (savedSessionId) {
|
|
1490
|
-
queryOptions.resume = savedSessionId;
|
|
1491
|
-
console.log(`[Crew] Resuming ${roleName} with sessionId: ${savedSessionId}`);
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
const roleQuery = query({
|
|
1495
|
-
prompt: inputStream,
|
|
1496
|
-
options: queryOptions
|
|
1497
|
-
});
|
|
1498
|
-
|
|
1499
|
-
const roleState = {
|
|
1500
|
-
query: roleQuery,
|
|
1501
|
-
inputStream,
|
|
1502
|
-
abortController,
|
|
1503
|
-
accumulatedText: '',
|
|
1504
|
-
turnActive: false,
|
|
1505
|
-
claudeSessionId: savedSessionId,
|
|
1506
|
-
lastCostUsd: 0,
|
|
1507
|
-
lastInputTokens: 0,
|
|
1508
|
-
lastOutputTokens: 0,
|
|
1509
|
-
consecutiveErrors: 0,
|
|
1510
|
-
lastDispatchContent: null,
|
|
1511
|
-
lastDispatchFrom: null,
|
|
1512
|
-
lastDispatchTaskId: null,
|
|
1513
|
-
lastDispatchTaskTitle: null,
|
|
1514
|
-
// compact 状态
|
|
1515
|
-
_compacting: false, // 是否正在 compact
|
|
1516
|
-
_compactSummaryPending: false, // 是否等待过滤 compact summary
|
|
1517
|
-
_pendingCompactRoutes: null, // compact 期间缓存的待执行路由 Array|null
|
|
1518
|
-
_pendingDispatch: null, // compact 完成后待重派的内容 { content, from, taskId, taskTitle }
|
|
1519
|
-
_fromRole: null // 缓存路由的来源角色
|
|
1520
|
-
};
|
|
1521
|
-
|
|
1522
|
-
session.roleStates.set(roleName, roleState);
|
|
1523
|
-
|
|
1524
|
-
// 异步处理角色输出
|
|
1525
|
-
processRoleOutput(session, roleName, roleQuery, roleState);
|
|
1526
|
-
|
|
1527
|
-
return roleState;
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
/**
|
|
1531
|
-
* 构建角色的 system prompt(精简版)
|
|
1532
|
-
* Memory 和工作区信息已通过 CLAUDE.md 自动加载,此处只补充路由规则
|
|
1533
|
-
*/
|
|
1534
|
-
function buildRoleSystemPrompt(role, session) {
|
|
1535
|
-
const allRoles = Array.from(session.roles.values());
|
|
1536
|
-
|
|
1537
|
-
// 按组裁剪路由目标:
|
|
1538
|
-
// - 有 groupIndex > 0 的执行者只看到同组成员 + 管理者(PM/architect/designer)
|
|
1539
|
-
// - 管理者(groupIndex === 0)看到所有角色
|
|
1540
|
-
let routeTargets;
|
|
1541
|
-
if (role.groupIndex > 0) {
|
|
1542
|
-
routeTargets = allRoles.filter(r =>
|
|
1543
|
-
r.name !== role.name && (r.groupIndex === role.groupIndex || r.groupIndex === 0)
|
|
1544
|
-
);
|
|
1545
|
-
} else {
|
|
1546
|
-
routeTargets = allRoles.filter(r => r.name !== role.name);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
1550
|
-
|
|
1551
|
-
let prompt = `${m.teamCollab}
|
|
1552
|
-
${m.teamCollabIntro()}
|
|
1553
|
-
|
|
1554
|
-
${m.teamMembers}
|
|
1555
|
-
${allRoles.map(r => `- ${roleLabel(r)}: ${r.description}${r.isDecisionMaker ? ` (${m.decisionMakerTag})` : ''}`).join('\n')}`;
|
|
1556
|
-
|
|
1557
|
-
const hasMultiInstance = allRoles.some(r => r.groupIndex > 0);
|
|
1558
|
-
|
|
1559
|
-
if (routeTargets.length > 0) {
|
|
1560
|
-
prompt += `\n\n${m.routingRules}
|
|
1561
|
-
${m.routingIntro}
|
|
1562
|
-
|
|
1563
|
-
\`\`\`
|
|
1564
|
-
---ROUTE---
|
|
1565
|
-
to: <roleName>
|
|
1566
|
-
summary: <brief description>
|
|
1567
|
-
---END_ROUTE---
|
|
1568
|
-
\`\`\`
|
|
1569
|
-
|
|
1570
|
-
${m.routeTargets}
|
|
1571
|
-
${routeTargets.map(r => `- ${r.name}: ${roleLabel(r)} — ${r.description}`).join('\n')}
|
|
1572
|
-
- human: ${m.humanTarget}
|
|
1573
|
-
|
|
1574
|
-
${m.routeNotes(session.decisionMaker)}`;
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
// 决策者额外 prompt
|
|
1578
|
-
if (role.isDecisionMaker) {
|
|
1579
|
-
const isDevTeam = session.teamType === 'dev';
|
|
1580
|
-
|
|
1581
|
-
prompt += `\n\n${m.toolUsage}
|
|
1582
|
-
${m.toolUsageContent(isDevTeam)}`;
|
|
1583
|
-
|
|
1584
|
-
prompt += `\n\n${m.dmRole}
|
|
1585
|
-
${m.dmRoleContent}`;
|
|
1586
|
-
|
|
1587
|
-
if (isDevTeam) {
|
|
1588
|
-
prompt += m.dmDevExtra;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
// 非开发团队:注入讨论模式 prompt
|
|
1592
|
-
if (!isDevTeam) {
|
|
1593
|
-
prompt += `\n\n${m.collabMode}
|
|
1594
|
-
${m.collabModeContent}`;
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// 多实例模式(仅开发团队使用):注入开发组状态和调度规则
|
|
1598
|
-
if (isDevTeam && hasMultiInstance) {
|
|
1599
|
-
// 构建开发组实时状态
|
|
1600
|
-
const maxGroup = Math.max(...allRoles.map(r => r.groupIndex));
|
|
1601
|
-
const groupLines = [];
|
|
1602
|
-
for (let g = 1; g <= maxGroup; g++) {
|
|
1603
|
-
const members = allRoles.filter(r => r.groupIndex === g);
|
|
1604
|
-
const memberStrs = members.map(r => {
|
|
1605
|
-
const state = session.roleStates.get(r.name);
|
|
1606
|
-
const busy = state?.turnActive;
|
|
1607
|
-
const task = state?.currentTask;
|
|
1608
|
-
if (busy && task) return `${r.name}(${m.groupBusy(task.taskId + ' ' + task.taskTitle)})`;
|
|
1609
|
-
if (busy) return `${r.name}(${m.groupBusyShort})`;
|
|
1610
|
-
return `${r.name}(${m.groupIdle})`;
|
|
1611
|
-
});
|
|
1612
|
-
groupLines.push(`${m.groupLabel(g)}: ${memberStrs.join(' ')}`);
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
prompt += `\n\n${m.execGroupStatus}
|
|
1616
|
-
${groupLines.join(' / ')}
|
|
1617
|
-
|
|
1618
|
-
${m.parallelRules}
|
|
1619
|
-
${m.parallelRulesContent(maxGroup)}
|
|
1620
|
-
|
|
1621
|
-
\`\`\`
|
|
1622
|
-
---ROUTE---
|
|
1623
|
-
to: dev-1
|
|
1624
|
-
task: task-1
|
|
1625
|
-
taskTitle: ${m.implLoginPage}
|
|
1626
|
-
summary: ${m.implLoginSummary}
|
|
1627
|
-
---END_ROUTE---
|
|
1628
|
-
|
|
1629
|
-
---ROUTE---
|
|
1630
|
-
to: dev-2
|
|
1631
|
-
task: task-2
|
|
1632
|
-
taskTitle: ${m.implRegisterPage}
|
|
1633
|
-
summary: ${m.implRegisterSummary}
|
|
1634
|
-
---END_ROUTE---
|
|
1635
|
-
\`\`\`
|
|
1636
|
-
|
|
1637
|
-
${m.parallelExample}`;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
prompt += `\n
|
|
1641
|
-
${m.workflowEnd}
|
|
1642
|
-
${m.workflowEndContent(isDevTeam)}
|
|
1643
|
-
|
|
1644
|
-
${m.taskList}
|
|
1645
|
-
${m.taskListContent}
|
|
1646
|
-
|
|
1647
|
-
\`\`\`
|
|
1648
|
-
${m.taskExample}
|
|
1649
|
-
\`\`\`
|
|
1650
|
-
|
|
1651
|
-
${m.taskListNotes}`;
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
// Feature 进度文件说明(系统自动管理,告知角色即可)
|
|
1655
|
-
prompt += `\n\n${m.featureRecordTitle}
|
|
1656
|
-
${m.featureRecordContent}`;
|
|
1657
|
-
|
|
1658
|
-
// 执行者角色的组绑定 prompt(count > 1 时)
|
|
1659
|
-
if (role.groupIndex > 0 && role.roleType === 'developer') {
|
|
1660
|
-
const gi = role.groupIndex;
|
|
1661
|
-
const rev = allRoles.find(r => r.roleType === 'reviewer' && r.groupIndex === gi);
|
|
1662
|
-
const test = allRoles.find(r => r.roleType === 'tester' && r.groupIndex === gi);
|
|
1663
|
-
if (rev && test) {
|
|
1664
|
-
prompt += `\n\n${m.devGroupBinding}
|
|
1665
|
-
${m.devGroupBindingContent(gi, roleLabel(rev), rev.name, roleLabel(test), test.name)}
|
|
1666
|
-
|
|
1667
|
-
\`\`\`
|
|
1668
|
-
---ROUTE---
|
|
1669
|
-
to: ${rev.name}
|
|
1670
|
-
summary: ${m.reviewCode}
|
|
1671
|
-
---END_ROUTE---
|
|
1672
|
-
|
|
1673
|
-
---ROUTE---
|
|
1674
|
-
to: ${test.name}
|
|
1675
|
-
summary: ${m.testFeature}
|
|
1676
|
-
---END_ROUTE---
|
|
1677
|
-
\`\`\`
|
|
1678
|
-
|
|
1679
|
-
${m.devGroupBindingNote}`;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
// Language instruction
|
|
1684
|
-
if (session.language === 'en') {
|
|
1685
|
-
prompt += `\n\n# Language
|
|
1686
|
-
Always respond in English. Use English for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`;
|
|
1687
|
-
} else {
|
|
1688
|
-
prompt += `\n\n# Language
|
|
1689
|
-
Always respond in 中文. Use 中文 for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`;
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
return prompt;
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
// =====================================================================
|
|
1696
|
-
// Role Output Processing
|
|
1697
|
-
// =====================================================================
|
|
1698
|
-
|
|
1699
|
-
// Context 使用率阈值常量
|
|
1700
|
-
const MAX_CONTEXT = 128000; // API max_prompt_tokens 限制
|
|
1701
|
-
const COMPACT_THRESHOLD = 0.85; // 85% → 触发预防性 compact
|
|
1702
|
-
const CLEAR_THRESHOLD = 0.95; // 95% → compact 后仍超限则 clear + rebuild
|
|
1703
|
-
|
|
1704
|
-
/**
|
|
1705
|
-
* 处理角色的流式输出
|
|
1706
|
-
*/
|
|
1707
|
-
async function processRoleOutput(session, roleName, roleQuery, roleState) {
|
|
1708
|
-
try {
|
|
1709
|
-
for await (const message of roleQuery) {
|
|
1710
|
-
// 检查 session 是否已停止或暂停
|
|
1711
|
-
if (session.status === 'stopped' || session.status === 'paused') break;
|
|
1712
|
-
|
|
1713
|
-
if (message.type === 'system' && message.subtype === 'init') {
|
|
1714
|
-
roleState.claudeSessionId = message.session_id;
|
|
1715
|
-
console.log(`[Crew] ${roleName} session: ${message.session_id}`);
|
|
1716
|
-
continue;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
// ★ compact 消息过滤(compact 期间只放行 result,其余过滤)
|
|
1720
|
-
if (roleState._compacting && message.type !== 'result') {
|
|
1721
|
-
if (message.type === 'system') {
|
|
1722
|
-
if (message.subtype === 'compact_boundary') {
|
|
1723
|
-
roleState._compactSummaryPending = true;
|
|
1724
|
-
}
|
|
1725
|
-
continue; // 过滤所有 compact 期间的 system 消息
|
|
1726
|
-
}
|
|
1727
|
-
if (message.type === 'user' && roleState._compactSummaryPending) {
|
|
1728
|
-
roleState._compactSummaryPending = false;
|
|
1729
|
-
continue; // 过滤 compact summary
|
|
1730
|
-
}
|
|
1731
|
-
// 其他消息(assistant 等)在 compact 期间也过滤
|
|
1732
|
-
continue;
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
if (message.type === 'assistant') {
|
|
1736
|
-
// 累积文本用于路由解析
|
|
1737
|
-
const content = message.message?.content;
|
|
1738
|
-
if (content) {
|
|
1739
|
-
if (typeof content === 'string') {
|
|
1740
|
-
roleState.accumulatedText += content;
|
|
1741
|
-
// 转发流式文本到 Web
|
|
1742
|
-
sendCrewOutput(session, roleName, 'text', message);
|
|
1743
|
-
} else if (Array.isArray(content)) {
|
|
1744
|
-
let hasText = false;
|
|
1745
|
-
for (const block of content) {
|
|
1746
|
-
if (block.type === 'text') {
|
|
1747
|
-
roleState.accumulatedText += block.text;
|
|
1748
|
-
hasText = true;
|
|
1749
|
-
} else if (block.type === 'tool_use') {
|
|
1750
|
-
// ★ 修复5: tool_use 时结束该角色前一条 streaming 文本
|
|
1751
|
-
endRoleStreaming(session, roleName);
|
|
1752
|
-
roleState.currentTool = block.name;
|
|
1753
|
-
sendCrewOutput(session, roleName, 'tool_use', message);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
if (hasText) {
|
|
1757
|
-
sendCrewOutput(session, roleName, 'text', message);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
} else if (message.type === 'user') {
|
|
1762
|
-
// tool results — clear currentTool
|
|
1763
|
-
roleState.currentTool = null;
|
|
1764
|
-
sendCrewOutput(session, roleName, 'tool_result', message);
|
|
1765
|
-
} else if (message.type === 'result') {
|
|
1766
|
-
// ★ Turn 完成!
|
|
1767
|
-
console.log(`[Crew] ${roleName} turn completed`);
|
|
1768
|
-
roleState.consecutiveErrors = 0;
|
|
1769
|
-
|
|
1770
|
-
// ★ 修复2: 反向搜索该角色最后一条 streaming 消息并结束
|
|
1771
|
-
endRoleStreaming(session, roleName);
|
|
1772
|
-
|
|
1773
|
-
// 更新费用(差值计算:每个角色独立进程,total_cost_usd 是该角色的累计值)
|
|
1774
|
-
if (message.total_cost_usd != null) {
|
|
1775
|
-
const costDelta = message.total_cost_usd - roleState.lastCostUsd;
|
|
1776
|
-
if (costDelta > 0) session.costUsd += costDelta;
|
|
1777
|
-
roleState.lastCostUsd = message.total_cost_usd;
|
|
1778
|
-
}
|
|
1779
|
-
// 更新 token 用量(差值计算:usage 是 query 实例级累计值)
|
|
1780
|
-
if (message.usage) {
|
|
1781
|
-
const inputDelta = (message.usage.input_tokens || 0) - (roleState.lastInputTokens || 0);
|
|
1782
|
-
const outputDelta = (message.usage.output_tokens || 0) - (roleState.lastOutputTokens || 0);
|
|
1783
|
-
if (inputDelta > 0) session.totalInputTokens += inputDelta;
|
|
1784
|
-
if (outputDelta > 0) session.totalOutputTokens += outputDelta;
|
|
1785
|
-
roleState.lastInputTokens = message.usage.input_tokens || 0;
|
|
1786
|
-
roleState.lastOutputTokens = message.usage.output_tokens || 0;
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
// ★ compact turn 完成的处理
|
|
1790
|
-
if (roleState._compacting) {
|
|
1791
|
-
roleState._compacting = false;
|
|
1792
|
-
const postCompactTokens = message.usage?.input_tokens || 0;
|
|
1793
|
-
const postCompactPercentage = postCompactTokens / MAX_CONTEXT;
|
|
1794
|
-
console.log(`[Crew] ${roleName} compact completed, context now at ${Math.round(postCompactPercentage * 100)}%`);
|
|
1795
|
-
|
|
1796
|
-
sendCrewMessage({
|
|
1797
|
-
type: 'crew_role_compact',
|
|
1798
|
-
sessionId: session.id,
|
|
1799
|
-
role: roleName,
|
|
1800
|
-
contextPercentage: Math.round(postCompactPercentage * 100),
|
|
1801
|
-
status: 'completed'
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
// Layer 2: compact 后仍超 95% → clear + rebuild
|
|
1805
|
-
if (postCompactPercentage >= CLEAR_THRESHOLD) {
|
|
1806
|
-
console.warn(`[Crew] ${roleName} still at ${Math.round(postCompactPercentage * 100)}% after compact, escalating to clear`);
|
|
1807
|
-
|
|
1808
|
-
await clearRoleSessionId(session.sharedDir, roleName);
|
|
1809
|
-
roleState.claudeSessionId = null;
|
|
1810
|
-
|
|
1811
|
-
if (roleState.abortController) roleState.abortController.abort();
|
|
1812
|
-
roleState.query = null;
|
|
1813
|
-
roleState.inputStream = null;
|
|
1814
|
-
|
|
1815
|
-
sendCrewMessage({
|
|
1816
|
-
type: 'crew_role_compact',
|
|
1817
|
-
sessionId: session.id,
|
|
1818
|
-
role: roleName,
|
|
1819
|
-
status: 'cleared'
|
|
1820
|
-
});
|
|
1821
|
-
|
|
1822
|
-
// 重新 dispatch 缓存的路由(用新会话)
|
|
1823
|
-
if (roleState._pendingCompactRoutes) {
|
|
1824
|
-
const routes = roleState._pendingCompactRoutes;
|
|
1825
|
-
const fromRole = roleState._fromRole;
|
|
1826
|
-
roleState._pendingCompactRoutes = null;
|
|
1827
|
-
roleState._fromRole = null;
|
|
1828
|
-
session.round++;
|
|
1829
|
-
const results = await Promise.allSettled(routes.map(route =>
|
|
1830
|
-
executeRoute(session, fromRole, route)
|
|
1831
|
-
));
|
|
1832
|
-
for (const r of results) {
|
|
1833
|
-
if (r.status === 'rejected') {
|
|
1834
|
-
console.warn(`[Crew] Route execution failed:`, r.reason);
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
} else if (roleState._pendingDispatch) {
|
|
1838
|
-
const pd = roleState._pendingDispatch;
|
|
1839
|
-
roleState._pendingDispatch = null;
|
|
1840
|
-
await dispatchToRole(session, roleName, pd.content, pd.from, pd.taskId, pd.taskTitle);
|
|
1841
|
-
}
|
|
1842
|
-
return; // abort 后 query 已清空,退出 processRoleOutput
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
// 执行之前缓存的路由
|
|
1846
|
-
if (roleState._pendingCompactRoutes) {
|
|
1847
|
-
const routes = roleState._pendingCompactRoutes;
|
|
1848
|
-
const fromRole = roleState._fromRole;
|
|
1849
|
-
roleState._pendingCompactRoutes = null;
|
|
1850
|
-
roleState._fromRole = null;
|
|
1851
|
-
session.round++;
|
|
1852
|
-
const results = await Promise.allSettled(routes.map(route =>
|
|
1853
|
-
executeRoute(session, fromRole, route)
|
|
1854
|
-
));
|
|
1855
|
-
for (const r of results) {
|
|
1856
|
-
if (r.status === 'rejected') {
|
|
1857
|
-
console.warn(`[Crew] Route execution failed:`, r.reason);
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
} else if (roleState._pendingDispatch) {
|
|
1861
|
-
const pd = roleState._pendingDispatch;
|
|
1862
|
-
roleState._pendingDispatch = null;
|
|
1863
|
-
await dispatchToRole(session, roleName, pd.content, pd.from, pd.taskId, pd.taskTitle);
|
|
1864
|
-
}
|
|
1865
|
-
continue; // 不要重复处理这个 compact result
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
// ★ 持久化 sessionId(每次 turn 完成后保存,用于后续 resume)
|
|
1869
|
-
if (roleState.claudeSessionId) {
|
|
1870
|
-
saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
1871
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
// ★ context 使用率监控
|
|
1875
|
-
const inputTokens = message.usage?.input_tokens || 0;
|
|
1876
|
-
if (inputTokens > 0) {
|
|
1877
|
-
sendCrewMessage({
|
|
1878
|
-
type: 'crew_context_usage',
|
|
1879
|
-
sessionId: session.id,
|
|
1880
|
-
role: roleName,
|
|
1881
|
-
inputTokens,
|
|
1882
|
-
maxTokens: MAX_CONTEXT,
|
|
1883
|
-
percentage: Math.min(100, Math.round((inputTokens / MAX_CONTEXT) * 100))
|
|
1884
|
-
});
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
const contextPercentage = inputTokens / MAX_CONTEXT;
|
|
1888
|
-
const needCompact = contextPercentage >= COMPACT_THRESHOLD;
|
|
1889
|
-
|
|
1890
|
-
// 解析路由(支持多 ROUTE 块)
|
|
1891
|
-
const routes = parseRoutes(roleState.accumulatedText);
|
|
1892
|
-
|
|
1893
|
-
// ★ 决策者 turn 完成:检测 TASKS block 中新完成的任务
|
|
1894
|
-
const roleConfig = session.roles.get(roleName);
|
|
1895
|
-
if (roleConfig?.isDecisionMaker) {
|
|
1896
|
-
const nowCompleted = parseCompletedTasks(roleState.accumulatedText);
|
|
1897
|
-
if (nowCompleted.size > 0) {
|
|
1898
|
-
const prev = session._completedTaskIds || new Set();
|
|
1899
|
-
const newlyDone = [];
|
|
1900
|
-
for (const tid of nowCompleted) {
|
|
1901
|
-
if (!prev.has(tid)) {
|
|
1902
|
-
prev.add(tid);
|
|
1903
|
-
newlyDone.push(tid);
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
session._completedTaskIds = prev;
|
|
1907
|
-
if (newlyDone.length > 0) {
|
|
1908
|
-
// 更新索引 + 追加 changelog(fire-and-forget)
|
|
1909
|
-
updateFeatureIndex(session).catch(e => console.warn('[Crew] Failed to update feature index:', e.message));
|
|
1910
|
-
for (const tid of newlyDone) {
|
|
1911
|
-
const feature = session.features.get(tid);
|
|
1912
|
-
const title = feature?.taskTitle || tid;
|
|
1913
|
-
appendChangelog(session, tid, title).catch(e => console.warn(`[Crew] Failed to append changelog for ${tid}:`, e.message));
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
roleState.accumulatedText = '';
|
|
1920
|
-
roleState.turnActive = false;
|
|
1921
|
-
|
|
1922
|
-
// 通知 turn 完成
|
|
1923
|
-
sendCrewMessage({
|
|
1924
|
-
type: 'crew_turn_completed',
|
|
1925
|
-
sessionId: session.id,
|
|
1926
|
-
role: roleName
|
|
1927
|
-
});
|
|
1928
|
-
|
|
1929
|
-
// 发送状态更新
|
|
1930
|
-
sendStatusUpdate(session);
|
|
1931
|
-
|
|
1932
|
-
// ★ 需要 compact:缓存路由,先执行 compact
|
|
1933
|
-
if (needCompact) {
|
|
1934
|
-
console.log(`[Crew] ${roleName} context at ${Math.round(contextPercentage * 100)}%, compacting before next dispatch`);
|
|
1935
|
-
|
|
1936
|
-
roleState._pendingCompactRoutes = routes.length > 0 ? routes : null;
|
|
1937
|
-
roleState._compacting = true;
|
|
1938
|
-
roleState._compactSummaryPending = false;
|
|
1939
|
-
roleState._fromRole = roleName;
|
|
1940
|
-
|
|
1941
|
-
// task 继承
|
|
1942
|
-
const currentTask = roleState.currentTask;
|
|
1943
|
-
if (roleState._pendingCompactRoutes) {
|
|
1944
|
-
for (const route of roleState._pendingCompactRoutes) {
|
|
1945
|
-
if (!route.taskId && currentTask) {
|
|
1946
|
-
route.taskId = currentTask.taskId;
|
|
1947
|
-
route.taskTitle = currentTask.taskTitle;
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
// 发送 /compact
|
|
1953
|
-
roleState.inputStream.enqueue({
|
|
1954
|
-
type: 'user',
|
|
1955
|
-
message: { role: 'user', content: '/compact' }
|
|
1956
|
-
});
|
|
1957
|
-
|
|
1958
|
-
sendCrewMessage({
|
|
1959
|
-
type: 'crew_role_compact',
|
|
1960
|
-
sessionId: session.id,
|
|
1961
|
-
role: roleName,
|
|
1962
|
-
contextPercentage: Math.round(contextPercentage * 100),
|
|
1963
|
-
status: 'compacting'
|
|
1964
|
-
});
|
|
1965
|
-
|
|
1966
|
-
continue; // 等 compact turn 完成
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
// 执行路由(无需 compact 时)
|
|
1970
|
-
if (routes.length > 0) {
|
|
1971
|
-
// ★ 修复1: 多 ROUTE 只增 1 轮(round++ 从 executeRoute 移到这里)
|
|
1972
|
-
session.round++;
|
|
1973
|
-
|
|
1974
|
-
// task 继承:如果路由没有指定 taskId,从当前角色继承
|
|
1975
|
-
const currentTask = roleState.currentTask;
|
|
1976
|
-
for (const route of routes) {
|
|
1977
|
-
if (!route.taskId && currentTask) {
|
|
1978
|
-
route.taskId = currentTask.taskId;
|
|
1979
|
-
route.taskTitle = currentTask.taskTitle;
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
|
|
1983
|
-
// 并行执行所有路由(allSettled 保证单个失败不中断其他)
|
|
1984
|
-
const results = await Promise.allSettled(routes.map(route =>
|
|
1985
|
-
executeRoute(session, roleName, route)
|
|
1986
|
-
));
|
|
1987
|
-
for (const r of results) {
|
|
1988
|
-
if (r.status === 'rejected') {
|
|
1989
|
-
console.warn(`[Crew] Route execution failed:`, r.reason);
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
} else {
|
|
1993
|
-
// 没有路由,检查是否有人的消息在排队
|
|
1994
|
-
await processHumanQueue(session);
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
} catch (error) {
|
|
1999
|
-
if (error.name === 'AbortError') {
|
|
2000
|
-
console.log(`[Crew] ${roleName} aborted`);
|
|
2001
|
-
// 暂停时:检查已累积的文本中是否有 route,保存为 pendingRoutes
|
|
2002
|
-
if (session.status === 'paused' && roleState.accumulatedText) {
|
|
2003
|
-
const routes = parseRoutes(roleState.accumulatedText);
|
|
2004
|
-
if (routes.length > 0 && session.pendingRoutes.length === 0) {
|
|
2005
|
-
session.pendingRoutes = routes.map(route => ({ fromRole: roleName, route }));
|
|
2006
|
-
console.log(`[Crew] Saved ${routes.length} pending route(s) from aborted ${roleName}`);
|
|
2007
|
-
}
|
|
2008
|
-
roleState.accumulatedText = '';
|
|
2009
|
-
}
|
|
2010
|
-
} else {
|
|
2011
|
-
console.error(`[Crew] ${roleName} error:`, error.message);
|
|
2012
|
-
|
|
2013
|
-
// Step 1: 清理 roleState(防止后续写入死进程)
|
|
2014
|
-
endRoleStreaming(session, roleName);
|
|
2015
|
-
roleState.query = null;
|
|
2016
|
-
roleState.inputStream = null;
|
|
2017
|
-
roleState.turnActive = false;
|
|
2018
|
-
roleState.accumulatedText = '';
|
|
2019
|
-
// 重置 compact 状态(防止 compact 期间出错导致后续消息被永久过滤)
|
|
2020
|
-
roleState._compacting = false;
|
|
2021
|
-
roleState._compactSummaryPending = false;
|
|
2022
|
-
|
|
2023
|
-
// Step 2: 错误分类
|
|
2024
|
-
const classification = classifyRoleError(error);
|
|
2025
|
-
roleState.consecutiveErrors++;
|
|
2026
|
-
|
|
2027
|
-
// Step 3: 通知前端
|
|
2028
|
-
sendCrewMessage({
|
|
2029
|
-
type: 'crew_role_error',
|
|
2030
|
-
sessionId: session.id,
|
|
2031
|
-
role: roleName,
|
|
2032
|
-
error: error.message.substring(0, 500),
|
|
2033
|
-
reason: classification.reason,
|
|
2034
|
-
recoverable: classification.recoverable,
|
|
2035
|
-
retryCount: roleState.consecutiveErrors
|
|
2036
|
-
});
|
|
2037
|
-
sendStatusUpdate(session);
|
|
2038
|
-
|
|
2039
|
-
// Step 4: 判断是否重试
|
|
2040
|
-
const MAX_RETRIES = 3;
|
|
2041
|
-
if (!classification.recoverable || roleState.consecutiveErrors > MAX_RETRIES) {
|
|
2042
|
-
const exhausted = roleState.consecutiveErrors > MAX_RETRIES;
|
|
2043
|
-
const errDetail = exhausted
|
|
2044
|
-
? `角色 ${roleName} 连续 ${MAX_RETRIES} 次错误后停止重试。最后错误: ${error.message}`
|
|
2045
|
-
: `角色 ${roleName} 不可恢复错误: ${error.message}`;
|
|
2046
|
-
if (roleName !== session.decisionMaker) {
|
|
2047
|
-
await dispatchToRole(session, session.decisionMaker, errDetail, 'system');
|
|
2048
|
-
} else {
|
|
2049
|
-
session.status = 'waiting_human';
|
|
2050
|
-
sendCrewMessage({
|
|
2051
|
-
type: 'crew_human_needed',
|
|
2052
|
-
sessionId: session.id,
|
|
2053
|
-
fromRole: roleName,
|
|
2054
|
-
reason: 'error',
|
|
2055
|
-
message: errDetail
|
|
2056
|
-
});
|
|
2057
|
-
sendStatusUpdate(session);
|
|
2058
|
-
}
|
|
2059
|
-
return;
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// Step 5: 可恢复 → 自动重建并重试
|
|
2063
|
-
console.log(`[Crew] ${roleName} attempting recovery (${classification.reason}), retry ${roleState.consecutiveErrors}/${MAX_RETRIES}`);
|
|
2064
|
-
|
|
2065
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2066
|
-
type: 'assistant',
|
|
2067
|
-
message: { role: 'assistant', content: [{
|
|
2068
|
-
type: 'text',
|
|
2069
|
-
text: `${roleName} 遇到 ${classification.reason},正在自动恢复 (${roleState.consecutiveErrors}/${MAX_RETRIES})...`
|
|
2070
|
-
}] }
|
|
2071
|
-
});
|
|
2072
|
-
|
|
2073
|
-
if (roleState.lastDispatchContent) {
|
|
2074
|
-
// ★ context_exceeded: clear sessionId → rebuild query → /compact → 重派
|
|
2075
|
-
if (classification.reason === 'context_exceeded') {
|
|
2076
|
-
await clearRoleSessionId(session.sharedDir, roleName);
|
|
2077
|
-
const newState = await createRoleQuery(session, roleName);
|
|
2078
|
-
|
|
2079
|
-
// 缓存待重派内容,compact 完成后自动发送
|
|
2080
|
-
newState._pendingDispatch = {
|
|
2081
|
-
content: roleState.lastDispatchContent,
|
|
2082
|
-
from: roleState.lastDispatchFrom || 'system',
|
|
2083
|
-
taskId: roleState.lastDispatchTaskId,
|
|
2084
|
-
taskTitle: roleState.lastDispatchTaskTitle
|
|
2085
|
-
};
|
|
2086
|
-
newState._compacting = true;
|
|
2087
|
-
newState._compactSummaryPending = false;
|
|
2088
|
-
newState.consecutiveErrors = roleState.consecutiveErrors;
|
|
2089
|
-
|
|
2090
|
-
newState.inputStream.enqueue({
|
|
2091
|
-
type: 'user',
|
|
2092
|
-
message: { role: 'user', content: '/compact' }
|
|
2093
|
-
});
|
|
2094
|
-
|
|
2095
|
-
sendCrewMessage({
|
|
2096
|
-
type: 'crew_role_compact',
|
|
2097
|
-
sessionId: session.id,
|
|
2098
|
-
role: roleName,
|
|
2099
|
-
status: 'compacting'
|
|
2100
|
-
});
|
|
2101
|
-
} else {
|
|
2102
|
-
// 其他可恢复错误:原有重派逻辑
|
|
2103
|
-
if (classification.skipResume) {
|
|
2104
|
-
await clearRoleSessionId(session.sharedDir, roleName);
|
|
2105
|
-
}
|
|
2106
|
-
await dispatchToRole(
|
|
2107
|
-
session, roleName,
|
|
2108
|
-
roleState.lastDispatchContent,
|
|
2109
|
-
roleState.lastDispatchFrom || 'system',
|
|
2110
|
-
roleState.lastDispatchTaskId,
|
|
2111
|
-
roleState.lastDispatchTaskTitle
|
|
2112
|
-
);
|
|
2113
|
-
}
|
|
2114
|
-
} else {
|
|
2115
|
-
const msg = `角色 ${roleName} 已恢复(${classification.reason}),但无待重试消息。`;
|
|
2116
|
-
if (roleName !== session.decisionMaker) {
|
|
2117
|
-
await dispatchToRole(session, session.decisionMaker, msg, 'system');
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
|
-
/**
|
|
2125
|
-
* 结束指定角色的最后一条 streaming 消息(反向搜索)
|
|
2126
|
-
*/
|
|
2127
|
-
function endRoleStreaming(session, roleName) {
|
|
2128
|
-
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
2129
|
-
if (session.uiMessages[i].role === roleName && session.uiMessages[i]._streaming) {
|
|
2130
|
-
delete session.uiMessages[i]._streaming;
|
|
2131
|
-
break;
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
// =====================================================================
|
|
2137
|
-
// Route Parsing & Execution
|
|
2138
|
-
// =====================================================================
|
|
2139
|
-
|
|
2140
|
-
/**
|
|
2141
|
-
* 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)
|
|
2142
|
-
* @returns {Array<{ to, summary, taskId, taskTitle }>}
|
|
2143
|
-
*/
|
|
2144
|
-
function parseRoutes(text) {
|
|
2145
|
-
const routes = [];
|
|
2146
|
-
const regex = /---ROUTE---\s*\n([\s\S]*?)---END_ROUTE---/g;
|
|
2147
|
-
let match;
|
|
2148
|
-
|
|
2149
|
-
while ((match = regex.exec(text)) !== null) {
|
|
2150
|
-
const block = match[1];
|
|
2151
|
-
const toMatch = block.match(/to:\s*(.+)/i);
|
|
2152
|
-
if (!toMatch) continue;
|
|
2153
|
-
|
|
2154
|
-
const summaryMatch = block.match(/summary:\s*([\s\S]+)/i);
|
|
2155
|
-
const taskMatch = block.match(/^task:\s*(.+)/im);
|
|
2156
|
-
const taskTitleMatch = block.match(/^taskTitle:\s*(.+)/im);
|
|
2157
|
-
|
|
2158
|
-
routes.push({
|
|
2159
|
-
to: toMatch[1].trim().toLowerCase(),
|
|
2160
|
-
summary: summaryMatch ? summaryMatch[1].trim() : '',
|
|
2161
|
-
taskId: taskMatch ? taskMatch[1].trim() : null,
|
|
2162
|
-
taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
|
|
2163
|
-
});
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
return routes;
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
/**
|
|
2170
|
-
* 执行路由
|
|
2171
|
-
*/
|
|
2172
|
-
async function executeRoute(session, fromRole, route) {
|
|
2173
|
-
const { to, summary, taskId, taskTitle } = route;
|
|
2174
|
-
|
|
2175
|
-
// ★ round++ 已移到 processRoleOutput 中(多 ROUTE 只增 1 轮)
|
|
2176
|
-
|
|
2177
|
-
// 如果 session 已暂停或停止,保存为 pendingRoutes
|
|
2178
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
2179
|
-
session.pendingRoutes.push({ fromRole, route });
|
|
2180
|
-
console.log(`[Crew] Session ${session.status}, route saved as pending: ${fromRole} -> ${to}`);
|
|
2181
|
-
return;
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
// ★ Task 文件自动管理(fire-and-forget,不阻塞路由执行)
|
|
2185
|
-
if (taskId && summary) {
|
|
2186
|
-
// 如果是决策者发出的 ROUTE(分配任务),自动创建 task 文件
|
|
2187
|
-
const fromRoleConfig = session.roles.get(fromRole);
|
|
2188
|
-
if (fromRoleConfig?.isDecisionMaker && taskTitle && to !== 'human') {
|
|
2189
|
-
ensureTaskFile(session, taskId, taskTitle, to, summary)
|
|
2190
|
-
.catch(e => console.warn(`[Crew] Failed to create task file ${taskId}:`, e.message));
|
|
2191
|
-
}
|
|
2192
|
-
// 任何角色的 ROUTE 都追加工作记录
|
|
2193
|
-
appendTaskRecord(session, taskId, fromRole, summary)
|
|
2194
|
-
.catch(e => console.warn(`[Crew] Failed to append task record ${taskId}:`, e.message));
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
// 发送路由消息(UI 显示 → @xxx)
|
|
2198
|
-
sendCrewOutput(session, fromRole, 'route', null, { routeTo: to, routeSummary: summary });
|
|
2199
|
-
|
|
2200
|
-
// 路由到 human
|
|
2201
|
-
if (to === 'human') {
|
|
2202
|
-
session.status = 'waiting_human';
|
|
2203
|
-
session.waitingHumanContext = {
|
|
2204
|
-
fromRole,
|
|
2205
|
-
reason: 'requested',
|
|
2206
|
-
message: summary
|
|
2207
|
-
};
|
|
2208
|
-
sendCrewMessage({
|
|
2209
|
-
type: 'crew_human_needed',
|
|
2210
|
-
sessionId: session.id,
|
|
2211
|
-
fromRole,
|
|
2212
|
-
reason: 'requested',
|
|
2213
|
-
message: summary
|
|
2214
|
-
});
|
|
2215
|
-
sendStatusUpdate(session);
|
|
2216
|
-
return;
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
// 路由到指定角色
|
|
2220
|
-
if (session.roles.has(to)) {
|
|
2221
|
-
// task 信息通过 dispatchToRole 内部设置(createRoleQuery 之后 roleState 才存在)
|
|
2222
|
-
|
|
2223
|
-
// 先检查是否有人的消息在排队
|
|
2224
|
-
if (session.humanMessageQueue.length > 0) {
|
|
2225
|
-
// 人的消息优先
|
|
2226
|
-
await processHumanQueue(session);
|
|
2227
|
-
} else {
|
|
2228
|
-
const taskPrompt = buildRoutePrompt(fromRole, summary, session);
|
|
2229
|
-
await dispatchToRole(session, to, taskPrompt, fromRole, taskId, taskTitle);
|
|
2230
|
-
}
|
|
2231
|
-
} else {
|
|
2232
|
-
console.warn(`[Crew] Unknown route target: ${to}`);
|
|
2233
|
-
// 转给决策者
|
|
2234
|
-
const errorMsg = `路由目标 "${to}" 不存在。来自 ${fromRole} 的消息: ${summary}`;
|
|
2235
|
-
await dispatchToRole(session, session.decisionMaker, errorMsg, 'system');
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
/**
|
|
2240
|
-
* 构建路由转发的 prompt
|
|
2241
|
-
*/
|
|
2242
|
-
function buildRoutePrompt(fromRole, summary, session) {
|
|
2243
|
-
const fromRoleConfig = session.roles.get(fromRole);
|
|
2244
|
-
const fromName = fromRoleConfig ? roleLabel(fromRoleConfig) : fromRole;
|
|
2245
|
-
return `来自 ${fromName} 的消息:\n${summary}\n\n请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
// =====================================================================
|
|
2249
|
-
// Message Dispatching
|
|
2250
|
-
// =====================================================================
|
|
2251
|
-
|
|
2252
|
-
/**
|
|
2253
|
-
* 向角色发送消息
|
|
2254
|
-
*/
|
|
2255
|
-
async function dispatchToRole(session, roleName, content, fromSource, taskId, taskTitle) {
|
|
2256
|
-
if (session.status === 'paused' || session.status === 'stopped' || session.status === 'initializing') {
|
|
2257
|
-
console.log(`[Crew] Session ${session.status}, skipping dispatch to ${roleName}`);
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
let roleState = session.roleStates.get(roleName);
|
|
2262
|
-
|
|
2263
|
-
// 如果角色没有 query 实例,创建一个(支持 resume)
|
|
2264
|
-
if (!roleState || !roleState.query || !roleState.inputStream) {
|
|
2265
|
-
roleState = await createRoleQuery(session, roleName);
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
// 设置 task(createRoleQuery 之后 roleState 一定存在)
|
|
2269
|
-
if (taskId) {
|
|
2270
|
-
roleState.currentTask = { taskId, taskTitle };
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
// ★ Task 上下文注入:如果有 taskId,读取 task 文件注入到消息中
|
|
2274
|
-
const effectiveTaskId = taskId || roleState.currentTask?.taskId;
|
|
2275
|
-
if (effectiveTaskId && typeof content === 'string') {
|
|
2276
|
-
const taskContent = await readTaskFile(session, effectiveTaskId);
|
|
2277
|
-
if (taskContent) {
|
|
2278
|
-
content = `${content}\n\n---\n<task-context file="context/features/${effectiveTaskId}.md">\n${taskContent}\n</task-context>`;
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
// 记录消息历史
|
|
2283
|
-
session.messageHistory.push({
|
|
2284
|
-
from: fromSource,
|
|
2285
|
-
to: roleName,
|
|
2286
|
-
content: typeof content === 'string' ? content.substring(0, 200) : '...',
|
|
2287
|
-
taskId: taskId || roleState.currentTask?.taskId || null,
|
|
2288
|
-
timestamp: Date.now()
|
|
2289
|
-
});
|
|
2290
|
-
|
|
2291
|
-
// 发送
|
|
2292
|
-
roleState.lastDispatchContent = content;
|
|
2293
|
-
roleState.lastDispatchFrom = fromSource;
|
|
2294
|
-
roleState.lastDispatchTaskId = taskId || null;
|
|
2295
|
-
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
2296
|
-
roleState.turnActive = true;
|
|
2297
|
-
roleState.accumulatedText = '';
|
|
2298
|
-
roleState.inputStream.enqueue({
|
|
2299
|
-
type: 'user',
|
|
2300
|
-
message: { role: 'user', content }
|
|
2301
|
-
});
|
|
2302
|
-
|
|
2303
|
-
sendStatusUpdate(session);
|
|
2304
|
-
console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
// =====================================================================
|
|
2308
|
-
// Human Interaction
|
|
2309
|
-
// =====================================================================
|
|
2310
|
-
|
|
2311
|
-
/**
|
|
2312
|
-
* 处理人的输入
|
|
2313
|
-
*/
|
|
2314
|
-
export async function handleCrewHumanInput(msg) {
|
|
2315
|
-
const { sessionId, content, targetRole, files } = msg;
|
|
2316
|
-
const session = crewSessions.get(sessionId);
|
|
2317
|
-
if (!session) {
|
|
2318
|
-
console.warn(`[Crew] Session not found: ${sessionId}`);
|
|
2319
|
-
return;
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
// Build dispatch content (supports image attachments)
|
|
2323
|
-
function buildHumanContent(prefix, text) {
|
|
2324
|
-
if (files && files.length > 0) {
|
|
2325
|
-
const blocks = [];
|
|
2326
|
-
for (const file of files) {
|
|
2327
|
-
if (file.isImage || file.mimeType?.startsWith('image/')) {
|
|
2328
|
-
blocks.push({
|
|
2329
|
-
type: 'image',
|
|
2330
|
-
source: { type: 'base64', media_type: file.mimeType, data: file.data }
|
|
2331
|
-
});
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
blocks.push({ type: 'text', text: `${prefix}\n${text}` });
|
|
2335
|
-
return blocks;
|
|
2336
|
-
}
|
|
2337
|
-
return `${prefix}\n${text}`;
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
// 注意:不在这里发送人的消息到 Web(前端已本地添加,避免重复)
|
|
2341
|
-
// 但需要记录到 uiMessages 用于恢复时重放
|
|
2342
|
-
session.uiMessages.push({
|
|
2343
|
-
role: 'human', roleIcon: '', roleName: '你',
|
|
2344
|
-
type: 'text', content,
|
|
2345
|
-
timestamp: Date.now()
|
|
2346
|
-
});
|
|
2347
|
-
|
|
2348
|
-
// 如果在等待人工介入
|
|
2349
|
-
if (session.status === 'waiting_human') {
|
|
2350
|
-
const waitingContext = session.waitingHumanContext;
|
|
2351
|
-
session.status = 'running';
|
|
2352
|
-
session.waitingHumanContext = null;
|
|
2353
|
-
sendStatusUpdate(session);
|
|
2354
|
-
|
|
2355
|
-
// 发给请求人工介入的角色,或指定的目标角色
|
|
2356
|
-
const target = targetRole || waitingContext?.fromRole || session.decisionMaker;
|
|
2357
|
-
await dispatchToRole(session, target, buildHumanContent('人工回复:', content), 'human');
|
|
2358
|
-
return;
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
// 解析 @role 指令(支持 name 和 displayName)
|
|
2362
|
-
const atMatch = content.match(/^@(\S+)\s*([\s\S]*)/);
|
|
2363
|
-
if (atMatch) {
|
|
2364
|
-
const atTarget = atMatch[1];
|
|
2365
|
-
const message = atMatch[2].trim() || content;
|
|
2366
|
-
|
|
2367
|
-
// 先精确匹配 role.name,再匹配 displayName
|
|
2368
|
-
let target = null;
|
|
2369
|
-
for (const [name, role] of session.roles) {
|
|
2370
|
-
if (name === atTarget.toLowerCase()) {
|
|
2371
|
-
target = name;
|
|
2372
|
-
break;
|
|
2373
|
-
}
|
|
2374
|
-
if (role.displayName === atTarget) {
|
|
2375
|
-
target = name;
|
|
2376
|
-
break;
|
|
2377
|
-
}
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
if (target) {
|
|
2381
|
-
await dispatchToRole(session, target, buildHumanContent('人工消息:', message), 'human');
|
|
2382
|
-
return;
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
// 没有 @ 指定目标,默认发给决策者(PM)
|
|
2387
|
-
const target = targetRole || session.decisionMaker;
|
|
2388
|
-
|
|
2389
|
-
await dispatchToRole(session, target, buildHumanContent('人工消息:', content), 'human');
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
/**
|
|
2393
|
-
* 处理排队的人的消息
|
|
2394
|
-
*/
|
|
2395
|
-
async function processHumanQueue(session) {
|
|
2396
|
-
if (session.humanMessageQueue.length === 0) return;
|
|
2397
|
-
if (session._processingHumanQueue) return;
|
|
2398
|
-
session._processingHumanQueue = true;
|
|
2399
|
-
try {
|
|
2400
|
-
const msgs = session.humanMessageQueue.splice(0);
|
|
2401
|
-
if (msgs.length === 1) {
|
|
2402
|
-
const humanPrompt = `人工消息:\n${msgs[0].content}`;
|
|
2403
|
-
await dispatchToRole(session, msgs[0].target, humanPrompt, 'human');
|
|
2404
|
-
} else {
|
|
2405
|
-
// 按 target 分组,合并发送
|
|
2406
|
-
const byTarget = new Map();
|
|
2407
|
-
for (const m of msgs) {
|
|
2408
|
-
if (!byTarget.has(m.target)) byTarget.set(m.target, []);
|
|
2409
|
-
byTarget.get(m.target).push(m.content);
|
|
2410
|
-
}
|
|
2411
|
-
for (const [target, contents] of byTarget) {
|
|
2412
|
-
const combined = contents.join('\n\n---\n\n');
|
|
2413
|
-
const humanPrompt = `人工消息:\n你有 ${contents.length} 条待处理消息,请一并分析并用多个 ROUTE 块并行分配:\n\n${combined}`;
|
|
2414
|
-
await dispatchToRole(session, target, humanPrompt, 'human');
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
} finally {
|
|
2418
|
-
session._processingHumanQueue = false;
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
/**
|
|
2423
|
-
* 找到当前活跃的角色(最近一个 turnActive 的)
|
|
2424
|
-
*/
|
|
2425
|
-
function findActiveRole(session) {
|
|
2426
|
-
for (const [name, state] of session.roleStates) {
|
|
2427
|
-
if (state.turnActive) return name;
|
|
2428
|
-
}
|
|
2429
|
-
return null;
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
// =====================================================================
|
|
2433
|
-
// Control Operations
|
|
2434
|
-
// =====================================================================
|
|
2435
|
-
|
|
2436
|
-
/**
|
|
2437
|
-
* 清空单个角色的对话(重置为全新状态)
|
|
2438
|
-
*/
|
|
2439
|
-
async function clearSingleRole(session, roleName) {
|
|
2440
|
-
const roleState = session.roleStates.get(roleName);
|
|
2441
|
-
|
|
2442
|
-
// 如果角色正在 streaming,先 abort
|
|
2443
|
-
if (roleState) {
|
|
2444
|
-
if (roleState.abortController) {
|
|
2445
|
-
roleState.abortController.abort();
|
|
2446
|
-
}
|
|
2447
|
-
roleState.query = null;
|
|
2448
|
-
roleState.inputStream = null;
|
|
2449
|
-
roleState.turnActive = false;
|
|
2450
|
-
roleState._compacting = false;
|
|
2451
|
-
roleState._compactSummaryPending = false;
|
|
2452
|
-
roleState._pendingCompactRoutes = null;
|
|
2453
|
-
roleState._pendingDispatch = null;
|
|
2454
|
-
roleState._fromRole = null;
|
|
2455
|
-
roleState.claudeSessionId = null;
|
|
2456
|
-
roleState.consecutiveErrors = 0;
|
|
2457
|
-
roleState.accumulatedText = '';
|
|
2458
|
-
roleState.lastDispatchContent = null;
|
|
2459
|
-
roleState.lastDispatchFrom = null;
|
|
2460
|
-
roleState.lastDispatchTaskId = null;
|
|
2461
|
-
roleState.lastDispatchTaskTitle = null;
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
// 清除持久化的 sessionId
|
|
2465
|
-
await clearRoleSessionId(session.sharedDir, roleName);
|
|
2466
|
-
|
|
2467
|
-
sendCrewMessage({
|
|
2468
|
-
type: 'crew_role_compact',
|
|
2469
|
-
sessionId: session.id,
|
|
2470
|
-
role: roleName,
|
|
2471
|
-
status: 'cleared'
|
|
2472
|
-
});
|
|
2473
|
-
|
|
2474
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2475
|
-
type: 'assistant',
|
|
2476
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 对话已清空` }] }
|
|
2477
|
-
});
|
|
2478
|
-
sendStatusUpdate(session);
|
|
2479
|
-
console.log(`[Crew] Role ${roleName} cleared`);
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
/**
|
|
2483
|
-
* 手动压缩指定角色的上下文
|
|
2484
|
-
* - 无活跃 query → 重建 query 后发 /compact
|
|
2485
|
-
* - 有 query 且非 turnActive → 直接发 /compact
|
|
2486
|
-
* - turnActive → 提示用户先停止该角色
|
|
2487
|
-
*/
|
|
2488
|
-
async function compactRole(session, roleName) {
|
|
2489
|
-
const roleState = session.roleStates.get(roleName);
|
|
2490
|
-
|
|
2491
|
-
// Case 1: 角色正在 streaming,不能 compact
|
|
2492
|
-
if (roleState?.turnActive) {
|
|
2493
|
-
sendCrewMessage({
|
|
2494
|
-
type: 'crew_role_compact',
|
|
2495
|
-
sessionId: session.id,
|
|
2496
|
-
role: roleName,
|
|
2497
|
-
status: 'rejected',
|
|
2498
|
-
reason: '角色正在工作中,请先停止该角色再压缩上下文'
|
|
2499
|
-
});
|
|
2500
|
-
return;
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
// Case 2: 已经在 compacting
|
|
2504
|
-
if (roleState?._compacting) {
|
|
2505
|
-
sendCrewMessage({
|
|
2506
|
-
type: 'crew_role_compact',
|
|
2507
|
-
sessionId: session.id,
|
|
2508
|
-
role: roleName,
|
|
2509
|
-
status: 'rejected',
|
|
2510
|
-
reason: '该角色正在压缩中,请等待完成'
|
|
2511
|
-
});
|
|
2512
|
-
return;
|
|
2513
|
-
}
|
|
2514
|
-
|
|
2515
|
-
// Case 3: 无活跃 query → 重建
|
|
2516
|
-
let state = roleState;
|
|
2517
|
-
if (!state || !state.query || !state.inputStream) {
|
|
2518
|
-
console.log(`[Crew] ${roleName} has no active query, rebuilding for compact`);
|
|
2519
|
-
state = await createRoleQuery(session, roleName);
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
// 发送 /compact
|
|
2523
|
-
console.log(`[Crew] Manual compact requested for ${roleName}`);
|
|
2524
|
-
state._compacting = true;
|
|
2525
|
-
state._compactSummaryPending = false;
|
|
2526
|
-
state._pendingCompactRoutes = null;
|
|
2527
|
-
state._fromRole = null;
|
|
2528
|
-
|
|
2529
|
-
state.inputStream.enqueue({
|
|
2530
|
-
type: 'user',
|
|
2531
|
-
message: { role: 'user', content: '/compact' }
|
|
2532
|
-
});
|
|
2533
|
-
|
|
2534
|
-
sendCrewMessage({
|
|
2535
|
-
type: 'crew_role_compact',
|
|
2536
|
-
sessionId: session.id,
|
|
2537
|
-
role: roleName,
|
|
2538
|
-
status: 'compacting'
|
|
2539
|
-
});
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
/**
|
|
2543
|
-
* 处理控制命令
|
|
2544
|
-
*/
|
|
2545
|
-
export async function handleCrewControl(msg) {
|
|
2546
|
-
const { sessionId, action, targetRole } = msg;
|
|
2547
|
-
const session = crewSessions.get(sessionId);
|
|
2548
|
-
if (!session) {
|
|
2549
|
-
console.warn(`[Crew] Session not found: ${sessionId}`);
|
|
2550
|
-
return;
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
switch (action) {
|
|
2554
|
-
case 'pause':
|
|
2555
|
-
await pauseAll(session);
|
|
2556
|
-
break;
|
|
2557
|
-
case 'resume':
|
|
2558
|
-
await resumeSession(session);
|
|
2559
|
-
break;
|
|
2560
|
-
case 'stop_role':
|
|
2561
|
-
if (targetRole) await stopRole(session, targetRole);
|
|
2562
|
-
break;
|
|
2563
|
-
case 'interrupt_role':
|
|
2564
|
-
if (targetRole && msg.content) {
|
|
2565
|
-
await interruptRole(session, targetRole, msg.content, 'human');
|
|
2566
|
-
}
|
|
2567
|
-
break;
|
|
2568
|
-
case 'abort_role':
|
|
2569
|
-
if (targetRole) await abortRole(session, targetRole);
|
|
2570
|
-
break;
|
|
2571
|
-
case 'compact_role':
|
|
2572
|
-
if (targetRole) await compactRole(session, targetRole);
|
|
2573
|
-
break;
|
|
2574
|
-
case 'clear_role':
|
|
2575
|
-
if (targetRole) await clearSingleRole(session, targetRole);
|
|
2576
|
-
break;
|
|
2577
|
-
case 'stop_all':
|
|
2578
|
-
await stopAll(session);
|
|
2579
|
-
break;
|
|
2580
|
-
case 'clear':
|
|
2581
|
-
await clearSession(session);
|
|
2582
|
-
break;
|
|
2583
|
-
default:
|
|
2584
|
-
console.warn(`[Crew] Unknown control action: ${action}`);
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
|
-
/**
|
|
2589
|
-
* 暂停所有角色
|
|
2590
|
-
* abort 运行中的 query 并保存 sessionId,恢复时 resume
|
|
2591
|
-
*/
|
|
2592
|
-
async function pauseAll(session) {
|
|
2593
|
-
session.status = 'paused';
|
|
2594
|
-
|
|
2595
|
-
// abort 所有运行中的角色,保存 sessionId 以便 resume
|
|
2596
|
-
for (const [roleName, roleState] of session.roleStates) {
|
|
2597
|
-
if (roleState.claudeSessionId) {
|
|
2598
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
2599
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
2600
|
-
}
|
|
2601
|
-
if (roleState.abortController) {
|
|
2602
|
-
roleState.abortController.abort();
|
|
2603
|
-
}
|
|
2604
|
-
// 记录哪些角色在暂停时正在工作
|
|
2605
|
-
roleState.wasActive = roleState.turnActive;
|
|
2606
|
-
roleState.turnActive = false;
|
|
2607
|
-
roleState.query = null;
|
|
2608
|
-
roleState.inputStream = null;
|
|
2609
|
-
}
|
|
2610
|
-
|
|
2611
|
-
console.log(`[Crew] Session ${session.id} paused, all active queries aborted`);
|
|
2612
|
-
|
|
2613
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2614
|
-
type: 'assistant',
|
|
2615
|
-
message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已暂停' }] }
|
|
2616
|
-
});
|
|
2617
|
-
sendStatusUpdate(session);
|
|
2618
|
-
|
|
2619
|
-
// 显式 await 保存,确保暂停状态落盘
|
|
2620
|
-
await saveSessionMeta(session);
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
/**
|
|
2624
|
-
* 恢复 session
|
|
2625
|
-
* 重新执行被暂停时保存的 pendingRoutes
|
|
2626
|
-
*/
|
|
2627
|
-
async function resumeSession(session) {
|
|
2628
|
-
if (session.status !== 'paused') return;
|
|
2629
|
-
|
|
2630
|
-
session.status = 'running';
|
|
2631
|
-
console.log(`[Crew] Session ${session.id} resumed`);
|
|
2632
|
-
|
|
2633
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2634
|
-
type: 'assistant',
|
|
2635
|
-
message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已恢复' }] }
|
|
2636
|
-
});
|
|
2637
|
-
sendStatusUpdate(session);
|
|
2638
|
-
|
|
2639
|
-
// 恢复被中断的路由(可能有多条)
|
|
2640
|
-
if (session.pendingRoutes.length > 0) {
|
|
2641
|
-
const pending = session.pendingRoutes.slice();
|
|
2642
|
-
session.pendingRoutes = [];
|
|
2643
|
-
console.log(`[Crew] Replaying ${pending.length} pending route(s)`);
|
|
2644
|
-
const results = await Promise.allSettled(pending.map(({ fromRole, route }) =>
|
|
2645
|
-
executeRoute(session, fromRole, route)
|
|
2646
|
-
));
|
|
2647
|
-
for (const r of results) {
|
|
2648
|
-
if (r.status === 'rejected') {
|
|
2649
|
-
console.warn(`[Crew] Pending route replay failed:`, r.reason);
|
|
2650
|
-
}
|
|
2651
|
-
}
|
|
2652
|
-
return;
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
// 没有 pendingRoutes,检查排队的人的消息
|
|
2656
|
-
await processHumanQueue(session);
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
/**
|
|
2660
|
-
* 停止单个角色
|
|
2661
|
-
*/
|
|
2662
|
-
/**
|
|
2663
|
-
* 中断角色当前 turn 并发送新消息
|
|
2664
|
-
*/
|
|
2665
|
-
async function interruptRole(session, roleName, newContent, fromSource = 'human') {
|
|
2666
|
-
const roleState = session.roleStates.get(roleName);
|
|
2667
|
-
if (!roleState) {
|
|
2668
|
-
console.warn(`[Crew] Cannot interrupt ${roleName}: no roleState`);
|
|
2669
|
-
return;
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
console.log(`[Crew] Interrupting ${roleName}`);
|
|
2673
|
-
|
|
2674
|
-
// 结束 streaming 状态
|
|
2675
|
-
endRoleStreaming(session, roleName);
|
|
2676
|
-
|
|
2677
|
-
// 保存 sessionId 用于 resume 上下文连续性
|
|
2678
|
-
if (roleState.claudeSessionId) {
|
|
2679
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
2680
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
2681
|
-
}
|
|
2682
|
-
|
|
2683
|
-
// Abort 当前 query
|
|
2684
|
-
if (roleState.abortController) {
|
|
2685
|
-
roleState.abortController.abort();
|
|
2686
|
-
}
|
|
2687
|
-
|
|
2688
|
-
// 清理旧状态
|
|
2689
|
-
roleState.query = null;
|
|
2690
|
-
roleState.inputStream = null;
|
|
2691
|
-
roleState.turnActive = false;
|
|
2692
|
-
roleState.accumulatedText = '';
|
|
2693
|
-
|
|
2694
|
-
// 通知前端中断
|
|
2695
|
-
sendCrewMessage({
|
|
2696
|
-
type: 'crew_turn_completed',
|
|
2697
|
-
sessionId: session.id,
|
|
2698
|
-
role: roleName,
|
|
2699
|
-
interrupted: true
|
|
2700
|
-
});
|
|
2701
|
-
|
|
2702
|
-
sendStatusUpdate(session);
|
|
2703
|
-
|
|
2704
|
-
// 系统消息记录
|
|
2705
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2706
|
-
type: 'assistant',
|
|
2707
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 被中断` }] }
|
|
2708
|
-
});
|
|
2709
|
-
|
|
2710
|
-
// 创建新 query 并 dispatch
|
|
2711
|
-
await dispatchToRole(session, roleName, newContent, fromSource);
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
/**
|
|
2715
|
-
* 中止角色当前 turn(不删除角色状态,不注入新内容)
|
|
2716
|
-
* 与 stopRole 区别:stopRole 会 delete roleState,abortRole 只中断当前 query
|
|
2717
|
-
* 与 interruptRole 区别:interruptRole 中断后会 dispatch 新消息,abortRole 不会
|
|
2718
|
-
*/
|
|
2719
|
-
async function abortRole(session, roleName) {
|
|
2720
|
-
const roleState = session.roleStates.get(roleName);
|
|
2721
|
-
if (!roleState) {
|
|
2722
|
-
console.warn(`[Crew] Cannot abort ${roleName}: no roleState`);
|
|
2723
|
-
return;
|
|
2724
|
-
}
|
|
2725
|
-
|
|
2726
|
-
if (!roleState.turnActive) {
|
|
2727
|
-
console.log(`[Crew] ${roleName} is not active, nothing to abort`);
|
|
2728
|
-
return;
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
console.log(`[Crew] Aborting ${roleName}`);
|
|
2732
|
-
|
|
2733
|
-
// 结束 streaming 状态
|
|
2734
|
-
endRoleStreaming(session, roleName);
|
|
2735
|
-
|
|
2736
|
-
// 保存 sessionId 以便后续继续对话
|
|
2737
|
-
if (roleState.claudeSessionId) {
|
|
2738
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
2739
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
2740
|
-
}
|
|
2741
|
-
|
|
2742
|
-
// Abort 当前 query
|
|
2743
|
-
if (roleState.abortController) {
|
|
2744
|
-
roleState.abortController.abort();
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
// 清理 turn 状态,角色变为 idle
|
|
2748
|
-
roleState.query = null;
|
|
2749
|
-
roleState.inputStream = null;
|
|
2750
|
-
roleState.turnActive = false;
|
|
2751
|
-
roleState.accumulatedText = '';
|
|
2752
|
-
|
|
2753
|
-
// 通知前端 turn 已完成(中断方式)
|
|
2754
|
-
sendCrewMessage({
|
|
2755
|
-
type: 'crew_turn_completed',
|
|
2756
|
-
sessionId: session.id,
|
|
2757
|
-
role: roleName,
|
|
2758
|
-
interrupted: true
|
|
2759
|
-
});
|
|
2760
|
-
|
|
2761
|
-
sendStatusUpdate(session);
|
|
2762
|
-
|
|
2763
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2764
|
-
type: 'assistant',
|
|
2765
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 已中止` }] }
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2769
|
-
async function stopRole(session, roleName) {
|
|
2770
|
-
const roleState = session.roleStates.get(roleName);
|
|
2771
|
-
if (roleState) {
|
|
2772
|
-
// 保存 sessionId
|
|
2773
|
-
if (roleState.claudeSessionId) {
|
|
2774
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
2775
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
2776
|
-
}
|
|
2777
|
-
if (roleState.abortController) {
|
|
2778
|
-
roleState.abortController.abort();
|
|
2779
|
-
}
|
|
2780
|
-
roleState.query = null;
|
|
2781
|
-
roleState.inputStream = null;
|
|
2782
|
-
roleState.turnActive = false;
|
|
2783
|
-
session.roleStates.delete(roleName);
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2787
|
-
type: 'assistant',
|
|
2788
|
-
message: { role: 'assistant', content: [{ type: 'text', text: `${roleName} 已停止` }] }
|
|
2789
|
-
});
|
|
2790
|
-
sendStatusUpdate(session);
|
|
2791
|
-
console.log(`[Crew] Role ${roleName} stopped`);
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
/**
|
|
2795
|
-
* 终止整个 session
|
|
2796
|
-
*/
|
|
2797
|
-
async function stopAll(session) {
|
|
2798
|
-
session.status = 'stopped';
|
|
2799
|
-
|
|
2800
|
-
// 保存所有角色的 sessionId
|
|
2801
|
-
for (const [roleName, roleState] of session.roleStates) {
|
|
2802
|
-
if (roleState.claudeSessionId) {
|
|
2803
|
-
await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
|
|
2804
|
-
.catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
|
|
2805
|
-
}
|
|
2806
|
-
if (roleState.abortController) {
|
|
2807
|
-
roleState.abortController.abort();
|
|
2808
|
-
}
|
|
2809
|
-
console.log(`[Crew] Stopping role: ${roleName}`);
|
|
2810
|
-
}
|
|
2811
|
-
session.roleStates.clear();
|
|
2812
|
-
|
|
2813
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2814
|
-
type: 'assistant',
|
|
2815
|
-
message: { role: 'assistant', content: [{ type: 'text', text: 'Session 已终止' }] }
|
|
2816
|
-
});
|
|
2817
|
-
sendStatusUpdate(session);
|
|
2818
|
-
|
|
2819
|
-
// 清理 git worktrees
|
|
2820
|
-
await cleanupWorktrees(session.projectDir);
|
|
2821
|
-
|
|
2822
|
-
// 显式 await 保存,确保 session.json 落盘后再从内存中移除
|
|
2823
|
-
await saveSessionMeta(session);
|
|
2824
|
-
await upsertCrewIndex(session);
|
|
2825
|
-
|
|
2826
|
-
// 从活跃 sessions 中移除
|
|
2827
|
-
crewSessions.delete(session.id);
|
|
2828
|
-
console.log(`[Crew] Session ${session.id} stopped`);
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
/**
|
|
2832
|
-
* 清空 session:保留角色配置,重置所有对话
|
|
2833
|
-
* 每个角色创建全新的 Claude conversation
|
|
2834
|
-
*/
|
|
2835
|
-
async function clearSession(session) {
|
|
2836
|
-
// 1. Abort 所有运行中的 queries
|
|
2837
|
-
for (const [roleName, roleState] of session.roleStates) {
|
|
2838
|
-
if (roleState.abortController) {
|
|
2839
|
-
roleState.abortController.abort();
|
|
2840
|
-
}
|
|
2841
|
-
console.log(`[Crew] Clearing role: ${roleName}`);
|
|
2842
|
-
}
|
|
2843
|
-
session.roleStates.clear();
|
|
2844
|
-
|
|
2845
|
-
// 2. 删除所有角色的 savedSessionId(强制新建 conversation)
|
|
2846
|
-
for (const [roleName] of session.roles) {
|
|
2847
|
-
await clearRoleSessionId(session.sharedDir, roleName);
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
// 3. 清空消息历史
|
|
2851
|
-
session.messageHistory = [];
|
|
2852
|
-
session.uiMessages = [];
|
|
2853
|
-
session.humanMessageQueue = [];
|
|
2854
|
-
session.waitingHumanContext = null;
|
|
2855
|
-
session.pendingRoutes = [];
|
|
2856
|
-
|
|
2857
|
-
// 4. 重置计数
|
|
2858
|
-
session.round = 0;
|
|
2859
|
-
|
|
2860
|
-
// 5. 清空磁盘上的 messages.json 和所有分片
|
|
2861
|
-
const messagesPath = join(session.sharedDir, 'messages.json');
|
|
2862
|
-
await fs.writeFile(messagesPath, '[]').catch(() => {});
|
|
2863
|
-
await cleanupMessageShards(session.sharedDir);
|
|
2864
|
-
|
|
2865
|
-
// 6. 恢复运行状态
|
|
2866
|
-
session.status = 'running';
|
|
2867
|
-
|
|
2868
|
-
// 7. 通知前端
|
|
2869
|
-
sendCrewMessage({
|
|
2870
|
-
type: 'crew_session_cleared',
|
|
2871
|
-
sessionId: session.id
|
|
2872
|
-
});
|
|
2873
|
-
|
|
2874
|
-
sendCrewOutput(session, 'system', 'system', {
|
|
2875
|
-
type: 'assistant',
|
|
2876
|
-
message: { role: 'assistant', content: [{ type: 'text', text: '会话已清空,所有角色将使用全新对话' }] }
|
|
2877
|
-
});
|
|
2878
|
-
sendStatusUpdate(session);
|
|
2879
|
-
|
|
2880
|
-
// 8. 保存 meta
|
|
2881
|
-
await saveSessionMeta(session);
|
|
2882
|
-
|
|
2883
|
-
console.log(`[Crew] Session ${session.id} cleared`);
|
|
2884
|
-
}
|
|
2885
|
-
|
|
2886
|
-
// =====================================================================
|
|
2887
|
-
// Message Helpers
|
|
2888
|
-
// =====================================================================
|
|
2889
|
-
|
|
2890
|
-
/**
|
|
2891
|
-
* 发送 crew 消息到 server(透传到 Web)
|
|
2892
|
-
*/
|
|
2893
|
-
function sendCrewMessage(msg) {
|
|
2894
|
-
if (ctx.sendToServer) {
|
|
2895
|
-
ctx.sendToServer(msg);
|
|
2896
|
-
}
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
/**
|
|
2900
|
-
* 发送角色输出到 Web
|
|
2901
|
-
*/
|
|
2902
|
-
function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
|
|
2903
|
-
const role = session.roles.get(roleName);
|
|
2904
|
-
const roleIcon = role?.icon || '';
|
|
2905
|
-
const displayName = role?.displayName || roleName;
|
|
2906
|
-
|
|
2907
|
-
// 从 roleState 获取当前 task 信息
|
|
2908
|
-
const roleState = session.roleStates.get(roleName);
|
|
2909
|
-
const taskId = roleState?.currentTask?.taskId || null;
|
|
2910
|
-
const taskTitle = roleState?.currentTask?.taskTitle || null;
|
|
2911
|
-
|
|
2912
|
-
sendCrewMessage({
|
|
2913
|
-
type: 'crew_output',
|
|
2914
|
-
sessionId: session.id,
|
|
2915
|
-
role: roleName,
|
|
2916
|
-
roleIcon,
|
|
2917
|
-
roleName: displayName,
|
|
2918
|
-
outputType, // 'text' | 'tool_use' | 'tool_result' | 'route' | 'system'
|
|
2919
|
-
data: rawMessage,
|
|
2920
|
-
taskId,
|
|
2921
|
-
taskTitle,
|
|
2922
|
-
...extra
|
|
2923
|
-
});
|
|
2924
|
-
|
|
2925
|
-
// ★ 累积 feature 到持久化列表
|
|
2926
|
-
if (taskId && taskTitle && !session.features.has(taskId)) {
|
|
2927
|
-
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
2928
|
-
}
|
|
2929
|
-
|
|
2930
|
-
// ★ 记录精简 UI 消息用于恢复(跳过 tool_use/tool_result,只记录可见内容)
|
|
2931
|
-
if (outputType === 'text') {
|
|
2932
|
-
const content = rawMessage?.message?.content;
|
|
2933
|
-
let text = '';
|
|
2934
|
-
if (typeof content === 'string') {
|
|
2935
|
-
text = content;
|
|
2936
|
-
} else if (Array.isArray(content)) {
|
|
2937
|
-
text = content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
2938
|
-
}
|
|
2939
|
-
if (!text) return;
|
|
2940
|
-
// ★ 修复2: 反向搜索该角色最后一条 _streaming 消息
|
|
2941
|
-
let found = false;
|
|
2942
|
-
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
2943
|
-
const msg = session.uiMessages[i];
|
|
2944
|
-
if (msg.role === roleName && msg.type === 'text' && msg._streaming) {
|
|
2945
|
-
msg.content += text;
|
|
2946
|
-
found = true;
|
|
2947
|
-
break;
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
if (!found) {
|
|
2951
|
-
session.uiMessages.push({
|
|
2952
|
-
role: roleName, roleIcon, roleName: displayName,
|
|
2953
|
-
type: 'text', content: text, _streaming: true,
|
|
2954
|
-
taskId, taskTitle,
|
|
2955
|
-
timestamp: Date.now()
|
|
2956
|
-
});
|
|
2957
|
-
}
|
|
2958
|
-
} else if (outputType === 'route') {
|
|
2959
|
-
// 结束该角色前一条 streaming
|
|
2960
|
-
endRoleStreaming(session, roleName);
|
|
2961
|
-
session.uiMessages.push({
|
|
2962
|
-
role: roleName, roleIcon, roleName: displayName,
|
|
2963
|
-
type: 'route', routeTo: extra.routeTo,
|
|
2964
|
-
routeSummary: extra.routeSummary || '',
|
|
2965
|
-
content: `→ @${extra.routeTo} ${extra.routeSummary || ''}`,
|
|
2966
|
-
taskId, taskTitle,
|
|
2967
|
-
timestamp: Date.now()
|
|
2968
|
-
});
|
|
2969
|
-
} else if (outputType === 'system') {
|
|
2970
|
-
const content = rawMessage?.message?.content;
|
|
2971
|
-
let text = '';
|
|
2972
|
-
if (typeof content === 'string') {
|
|
2973
|
-
text = content;
|
|
2974
|
-
} else if (Array.isArray(content)) {
|
|
2975
|
-
text = content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
2976
|
-
}
|
|
2977
|
-
if (!text) return;
|
|
2978
|
-
session.uiMessages.push({
|
|
2979
|
-
role: roleName, roleIcon, roleName: displayName,
|
|
2980
|
-
type: 'system', content: text,
|
|
2981
|
-
timestamp: Date.now()
|
|
2982
|
-
});
|
|
2983
|
-
} else if (outputType === 'tool_use') {
|
|
2984
|
-
// 结束该角色前一条 streaming
|
|
2985
|
-
endRoleStreaming(session, roleName);
|
|
2986
|
-
const content = rawMessage?.message?.content;
|
|
2987
|
-
if (Array.isArray(content)) {
|
|
2988
|
-
for (const block of content) {
|
|
2989
|
-
if (block.type === 'tool_use') {
|
|
2990
|
-
// Save trimmed toolInput for restore — only key fields, skip large content
|
|
2991
|
-
// TodoWrite: preserve full input (todos array is small and needed for sticky banner)
|
|
2992
|
-
const input = block.input || {};
|
|
2993
|
-
let savedInput;
|
|
2994
|
-
if (block.name === 'TodoWrite') {
|
|
2995
|
-
savedInput = input;
|
|
2996
|
-
} else {
|
|
2997
|
-
const trimmedInput = {};
|
|
2998
|
-
if (input.file_path) trimmedInput.file_path = input.file_path;
|
|
2999
|
-
if (input.command) trimmedInput.command = input.command.substring(0, 200);
|
|
3000
|
-
if (input.pattern) trimmedInput.pattern = input.pattern;
|
|
3001
|
-
if (input.old_string) trimmedInput.old_string = input.old_string.substring(0, 100);
|
|
3002
|
-
if (input.new_string) trimmedInput.new_string = input.new_string.substring(0, 100);
|
|
3003
|
-
if (input.url) trimmedInput.url = input.url;
|
|
3004
|
-
if (input.query) trimmedInput.query = input.query;
|
|
3005
|
-
savedInput = Object.keys(trimmedInput).length > 0 ? trimmedInput : null;
|
|
3006
|
-
}
|
|
3007
|
-
session.uiMessages.push({
|
|
3008
|
-
role: roleName, roleIcon, roleName: displayName,
|
|
3009
|
-
type: 'tool',
|
|
3010
|
-
toolName: block.name,
|
|
3011
|
-
toolId: block.id,
|
|
3012
|
-
toolInput: savedInput,
|
|
3013
|
-
content: `${block.name} ${block.input?.file_path || block.input?.command?.substring(0, 60) || ''}`,
|
|
3014
|
-
hasResult: false,
|
|
3015
|
-
taskId, taskTitle,
|
|
3016
|
-
timestamp: Date.now()
|
|
3017
|
-
});
|
|
3018
|
-
}
|
|
3019
|
-
}
|
|
3020
|
-
}
|
|
3021
|
-
} else if (outputType === 'tool_result') {
|
|
3022
|
-
// 标记对应 tool 的 hasResult
|
|
3023
|
-
const toolId = rawMessage?.message?.tool_use_id;
|
|
3024
|
-
if (toolId) {
|
|
3025
|
-
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
3026
|
-
if (session.uiMessages[i].type === 'tool' && session.uiMessages[i].toolId === toolId) {
|
|
3027
|
-
session.uiMessages[i].hasResult = true;
|
|
3028
|
-
break;
|
|
3029
|
-
}
|
|
3030
|
-
}
|
|
3031
|
-
}
|
|
3032
|
-
// Check for image blocks in tool_result content
|
|
3033
|
-
const resultContent = rawMessage?.message?.content;
|
|
3034
|
-
if (Array.isArray(resultContent)) {
|
|
3035
|
-
for (const item of resultContent) {
|
|
3036
|
-
if (item.type === 'image' && item.source?.type === 'base64') {
|
|
3037
|
-
sendCrewMessage({
|
|
3038
|
-
type: 'crew_image',
|
|
3039
|
-
sessionId: session.id,
|
|
3040
|
-
role: roleName,
|
|
3041
|
-
roleIcon,
|
|
3042
|
-
roleName: displayName,
|
|
3043
|
-
toolId: toolId || '',
|
|
3044
|
-
mimeType: item.source.media_type,
|
|
3045
|
-
data: item.source.data,
|
|
3046
|
-
taskId, taskTitle
|
|
3047
|
-
});
|
|
3048
|
-
session.uiMessages.push({
|
|
3049
|
-
role: roleName, roleIcon, roleName: displayName,
|
|
3050
|
-
type: 'image', toolId: toolId || '',
|
|
3051
|
-
mimeType: item.source.media_type,
|
|
3052
|
-
taskId, taskTitle,
|
|
3053
|
-
timestamp: Date.now()
|
|
3054
|
-
});
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3059
|
-
// tool 只保存精简信息(toolName + 摘要),不存完整 toolInput/toolResult
|
|
3060
|
-
}
|
|
3061
|
-
|
|
3062
|
-
/**
|
|
3063
|
-
* 发送 session 状态更新
|
|
3064
|
-
*/
|
|
3065
|
-
function sendStatusUpdate(session) {
|
|
3066
|
-
const currentRole = findActiveRole(session);
|
|
3067
|
-
|
|
3068
|
-
sendCrewMessage({
|
|
3069
|
-
type: 'crew_status',
|
|
3070
|
-
sessionId: session.id,
|
|
3071
|
-
status: session.status,
|
|
3072
|
-
currentRole,
|
|
3073
|
-
round: session.round,
|
|
3074
|
-
costUsd: session.costUsd,
|
|
3075
|
-
totalInputTokens: session.totalInputTokens,
|
|
3076
|
-
totalOutputTokens: session.totalOutputTokens,
|
|
3077
|
-
roles: Array.from(session.roles.values()).map(r => ({
|
|
3078
|
-
name: r.name,
|
|
3079
|
-
displayName: r.displayName,
|
|
3080
|
-
icon: r.icon,
|
|
3081
|
-
description: r.description,
|
|
3082
|
-
isDecisionMaker: r.isDecisionMaker || false,
|
|
3083
|
-
model: r.model,
|
|
3084
|
-
roleType: r.roleType,
|
|
3085
|
-
groupIndex: r.groupIndex
|
|
3086
|
-
})),
|
|
3087
|
-
activeRoles: Array.from(session.roleStates.entries())
|
|
3088
|
-
.filter(([, s]) => s.turnActive)
|
|
3089
|
-
.map(([name]) => name),
|
|
3090
|
-
currentToolByRole: Object.fromEntries(
|
|
3091
|
-
Array.from(session.roleStates.entries())
|
|
3092
|
-
.filter(([, s]) => s.turnActive && s.currentTool)
|
|
3093
|
-
.map(([name, s]) => [name, s.currentTool])
|
|
3094
|
-
),
|
|
3095
|
-
features: Array.from(session.features.values()),
|
|
3096
|
-
initProgress: session.initProgress || null
|
|
3097
|
-
});
|
|
3098
|
-
|
|
3099
|
-
// 异步更新持久化
|
|
3100
|
-
upsertCrewIndex(session).catch(e => console.warn('[Crew] Failed to update index:', e.message));
|
|
3101
|
-
saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save session meta:', e.message));
|
|
3102
|
-
}
|
|
14
|
+
* 本文件为入口模块,聚合并重新导出各子模块的公共 API。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Session 核心
|
|
18
|
+
export {
|
|
19
|
+
crewSessions,
|
|
20
|
+
createCrewSession,
|
|
21
|
+
resumeCrewSession,
|
|
22
|
+
handleListCrewSessions,
|
|
23
|
+
handleCheckCrewExists,
|
|
24
|
+
handleDeleteCrewDir,
|
|
25
|
+
handleUpdateCrewSession
|
|
26
|
+
} from './crew/session.js';
|
|
27
|
+
|
|
28
|
+
// 持久化
|
|
29
|
+
export {
|
|
30
|
+
loadCrewIndex,
|
|
31
|
+
removeFromCrewIndex,
|
|
32
|
+
handleLoadCrewHistory
|
|
33
|
+
} from './crew/persistence.js';
|
|
34
|
+
|
|
35
|
+
// 控制操作
|
|
36
|
+
export { handleCrewControl } from './crew/control.js';
|
|
37
|
+
|
|
38
|
+
// 人工交互
|
|
39
|
+
export { handleCrewHumanInput } from './crew/human-interaction.js';
|
|
40
|
+
|
|
41
|
+
// 角色管理
|
|
42
|
+
export {
|
|
43
|
+
addRoleToSession,
|
|
44
|
+
removeRoleFromSession
|
|
45
|
+
} from './crew/role-management.js';
|