@yeaft/webchat-agent 0.1.124 → 0.1.125
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/claude.js +0 -224
- package/connection/message-router.js +2 -6
- package/conversation.js +8 -317
- package/crew/context-loader.js +171 -0
- package/crew.js +3 -0
- package/package.json +1 -1
- package/roleplay-dir.js +0 -305
- package/roleplay-i18n.js +0 -574
- package/roleplay-session.js +0 -184
- package/roleplay.js +0 -1061
package/conversation.js
CHANGED
|
@@ -2,13 +2,6 @@ import ctx from './context.js';
|
|
|
2
2
|
import { loadSessionHistory } from './history.js';
|
|
3
3
|
import { startClaudeQuery } from './claude.js';
|
|
4
4
|
import { crewSessions, loadCrewIndex } from './crew.js';
|
|
5
|
-
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext, refreshCrewContext, initCrewContextMtimes } from './roleplay.js';
|
|
6
|
-
import { loadRolePlaySessionIndex } from './roleplay-session.js';
|
|
7
|
-
import { initRolePlayDir, writeSessionClaudeMd, generateSessionName, getDefaultRoles, getSessionDir } from './roleplay-dir.js';
|
|
8
|
-
import { addRolePlaySession, findRolePlaySessionByConversationId, setActiveRolePlaySession } from './roleplay-session.js';
|
|
9
|
-
|
|
10
|
-
// Restore persisted roleplay sessions on module load (agent startup)
|
|
11
|
-
loadRolePlayIndex();
|
|
12
5
|
|
|
13
6
|
// 不支持的斜杠命令(真正需要交互式 CLI 的命令)
|
|
14
7
|
const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
|
|
@@ -41,7 +34,7 @@ export function parseSlashCommand(message) {
|
|
|
41
34
|
return { type: null, message };
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
// 发送 conversation 列表(含活跃 crew sessions + 索引中已停止的 crew sessions
|
|
37
|
+
// 发送 conversation 列表(含活跃 crew sessions + 索引中已停止的 crew sessions)
|
|
45
38
|
export async function sendConversationList() {
|
|
46
39
|
const list = [];
|
|
47
40
|
for (const [id, state] of ctx.conversations) {
|
|
@@ -54,21 +47,6 @@ export async function sendConversationList() {
|
|
|
54
47
|
userId: state.userId,
|
|
55
48
|
username: state.username
|
|
56
49
|
};
|
|
57
|
-
// roleplay conversations are stored in ctx.conversations but also tracked in rolePlaySessions
|
|
58
|
-
if (rolePlaySessions.has(id)) {
|
|
59
|
-
entry.type = 'rolePlay';
|
|
60
|
-
const rpSession = rolePlaySessions.get(id);
|
|
61
|
-
entry.rolePlayRoles = rpSession.roles;
|
|
62
|
-
// Include route state if initialized
|
|
63
|
-
if (rpSession._routeInitialized) {
|
|
64
|
-
entry.rolePlayState = {
|
|
65
|
-
currentRole: rpSession.currentRole,
|
|
66
|
-
round: rpSession.round,
|
|
67
|
-
features: rpSession.features ? Array.from(rpSession.features.values()) : [],
|
|
68
|
-
waitingHuman: rpSession.waitingHuman || false
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
50
|
list.push(entry);
|
|
73
51
|
}
|
|
74
52
|
// 追加活跃 crew sessions
|
|
@@ -131,92 +109,15 @@ export async function createConversation(msg) {
|
|
|
131
109
|
const { conversationId, workDir, userId, username, disallowedTools } = msg;
|
|
132
110
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
133
111
|
|
|
134
|
-
|
|
135
|
-
let rolePlayConfig = null;
|
|
136
|
-
if (msg.rolePlayConfig) {
|
|
137
|
-
const result = validateRolePlayConfig(msg.rolePlayConfig);
|
|
138
|
-
if (!result.valid) {
|
|
139
|
-
console.warn(`[createConversation] Invalid rolePlayConfig: ${result.error}`);
|
|
140
|
-
sendError(conversationId, `Invalid rolePlayConfig: ${result.error}`);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
rolePlayConfig = result.config;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Load .crew context if rolePlay and projectDir contains .crew directory
|
|
147
|
-
if (rolePlayConfig) {
|
|
148
|
-
const crewContext = loadCrewContext(effectiveWorkDir);
|
|
149
|
-
if (crewContext) {
|
|
150
|
-
rolePlayConfig.crewContext = crewContext;
|
|
151
|
-
console.log(` RolePlay: loaded .crew context (${crewContext.roles.length} roles, ${crewContext.features.length} features)`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ★ RolePlay: initialize .roleplay/ directory, generate session, set cwd
|
|
156
|
-
let rpSessionName = null;
|
|
157
|
-
let rpSessionWorkDir = effectiveWorkDir; // default: project root
|
|
158
|
-
if (rolePlayConfig) {
|
|
159
|
-
try {
|
|
160
|
-
const language = rolePlayConfig.language || 'zh-CN';
|
|
161
|
-
|
|
162
|
-
// 1. Ensure .roleplay/ directory structure exists
|
|
163
|
-
await initRolePlayDir(effectiveWorkDir, language);
|
|
164
|
-
|
|
165
|
-
// 2. Generate unique session name
|
|
166
|
-
rpSessionName = generateSessionName(
|
|
167
|
-
effectiveWorkDir,
|
|
168
|
-
rolePlayConfig.teamType,
|
|
169
|
-
msg.rolePlayConfig?.sessionName || null
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
// 3. Write session CLAUDE.md (role list, ROUTE protocol, workflow)
|
|
173
|
-
await writeSessionClaudeMd(effectiveWorkDir, rpSessionName, {
|
|
174
|
-
teamType: rolePlayConfig.teamType,
|
|
175
|
-
language,
|
|
176
|
-
roles: rolePlayConfig.roles,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// 4. Set cwd to session directory so Claude Code auto-reads its CLAUDE.md
|
|
180
|
-
rpSessionWorkDir = getSessionDir(effectiveWorkDir, rpSessionName);
|
|
181
|
-
|
|
182
|
-
// 5. Build roles snapshot for session.json
|
|
183
|
-
const rolesSnapshot = (rolePlayConfig.roles && rolePlayConfig.roles.length > 0)
|
|
184
|
-
? rolePlayConfig.roles.map(r => ({
|
|
185
|
-
name: r.name,
|
|
186
|
-
displayName: r.displayName || r.name,
|
|
187
|
-
icon: r.icon || '',
|
|
188
|
-
}))
|
|
189
|
-
: getDefaultRoles(rolePlayConfig.teamType, language);
|
|
190
|
-
|
|
191
|
-
// 6. Persist to .roleplay/session.json
|
|
192
|
-
await addRolePlaySession(effectiveWorkDir, {
|
|
193
|
-
name: rpSessionName,
|
|
194
|
-
teamType: rolePlayConfig.teamType,
|
|
195
|
-
language,
|
|
196
|
-
projectDir: effectiveWorkDir,
|
|
197
|
-
conversationId,
|
|
198
|
-
roles: rolesSnapshot,
|
|
199
|
-
createdAt: Date.now(),
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
console.log(` RolePlay: initialized .roleplay/${rpSessionName}, cwd=${rpSessionWorkDir}`);
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.error('[createConversation] Failed to init .roleplay/ dir:', e);
|
|
205
|
-
// Non-fatal: fall back to project root as cwd
|
|
206
|
-
rpSessionWorkDir = effectiveWorkDir;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
console.log(`Creating conversation: ${conversationId} in ${rpSessionWorkDir} (lazy start)`);
|
|
112
|
+
console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
|
|
211
113
|
if (username) console.log(` User: ${username} (${userId})`);
|
|
212
|
-
if (rolePlayConfig) console.log(` RolePlay: teamType=${rolePlayConfig.teamType}, roles=${rolePlayConfig.roles?.length}, session=${rpSessionName}`);
|
|
213
114
|
|
|
214
115
|
// 只创建 conversation 状态,不启动 Claude 进程
|
|
215
116
|
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
216
117
|
ctx.conversations.set(conversationId, {
|
|
217
118
|
query: null,
|
|
218
119
|
inputStream: null,
|
|
219
|
-
workDir:
|
|
120
|
+
workDir: effectiveWorkDir,
|
|
220
121
|
claudeSessionId: null,
|
|
221
122
|
createdAt: Date.now(),
|
|
222
123
|
abortController: null,
|
|
@@ -226,10 +127,6 @@ export async function createConversation(msg) {
|
|
|
226
127
|
userId,
|
|
227
128
|
username,
|
|
228
129
|
disallowedTools: disallowedTools || null, // null = 使用全局默认
|
|
229
|
-
rolePlayConfig: rolePlayConfig || null,
|
|
230
|
-
// Track the original project dir and session name for .roleplay/ operations
|
|
231
|
-
_rpProjectDir: rolePlayConfig ? effectiveWorkDir : null,
|
|
232
|
-
_rpSessionName: rpSessionName,
|
|
233
130
|
usage: {
|
|
234
131
|
inputTokens: 0,
|
|
235
132
|
outputTokens: 0,
|
|
@@ -239,31 +136,13 @@ export async function createConversation(msg) {
|
|
|
239
136
|
}
|
|
240
137
|
});
|
|
241
138
|
|
|
242
|
-
// Register in rolePlaySessions for type inference in sendConversationList
|
|
243
|
-
if (rolePlayConfig) {
|
|
244
|
-
const rpSession = {
|
|
245
|
-
roles: rolePlayConfig.roles,
|
|
246
|
-
teamType: rolePlayConfig.teamType,
|
|
247
|
-
language: rolePlayConfig.language,
|
|
248
|
-
projectDir: effectiveWorkDir,
|
|
249
|
-
createdAt: Date.now(),
|
|
250
|
-
userId,
|
|
251
|
-
username,
|
|
252
|
-
};
|
|
253
|
-
rolePlaySessions.set(conversationId, rpSession);
|
|
254
|
-
// Initialize route state eagerly so it's ready when Claude starts
|
|
255
|
-
initRolePlayRouteState(rpSession, ctx.conversations.get(conversationId));
|
|
256
|
-
saveRolePlayIndex();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
139
|
ctx.sendToServer({
|
|
260
140
|
type: 'conversation_created',
|
|
261
141
|
conversationId,
|
|
262
|
-
workDir:
|
|
142
|
+
workDir: effectiveWorkDir,
|
|
263
143
|
userId,
|
|
264
144
|
username,
|
|
265
|
-
disallowedTools: disallowedTools || null
|
|
266
|
-
rolePlayConfig: rolePlayConfig || null
|
|
145
|
+
disallowedTools: disallowedTools || null
|
|
267
146
|
});
|
|
268
147
|
|
|
269
148
|
// 立即发送 agent 级别的 MCP servers 列表(从 ~/.claude.json 读取的)
|
|
@@ -315,42 +194,10 @@ export async function resumeConversation(msg) {
|
|
|
315
194
|
|
|
316
195
|
// 只创建 conversation 状态并保存 claudeSessionId,不启动 Claude 进程
|
|
317
196
|
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
318
|
-
// Restore rolePlayConfig from persisted rolePlaySessions if available
|
|
319
|
-
const rolePlayEntry = rolePlaySessions.get(conversationId);
|
|
320
|
-
let rolePlayConfig = rolePlayEntry
|
|
321
|
-
? { roles: rolePlayEntry.roles, teamType: rolePlayEntry.teamType, language: rolePlayEntry.language }
|
|
322
|
-
: null;
|
|
323
|
-
|
|
324
|
-
// ★ RolePlay resume: look up session in .roleplay/session.json to restore cwd
|
|
325
|
-
let rpResumeWorkDir = effectiveWorkDir;
|
|
326
|
-
if (rolePlayConfig && rolePlayEntry) {
|
|
327
|
-
const rpProjectDir = rolePlayEntry.projectDir || effectiveWorkDir;
|
|
328
|
-
const rpDiskSession = findRolePlaySessionByConversationId(rpProjectDir, conversationId);
|
|
329
|
-
if (rpDiskSession && rpDiskSession.name) {
|
|
330
|
-
rpResumeWorkDir = getSessionDir(rpProjectDir, rpDiskSession.name);
|
|
331
|
-
// Re-activate the session
|
|
332
|
-
setActiveRolePlaySession(rpProjectDir, rpDiskSession.name).catch(e => {
|
|
333
|
-
console.warn('[Resume] Failed to update activeSession:', e.message);
|
|
334
|
-
});
|
|
335
|
-
console.log(`[Resume] RolePlay: restored session cwd=${rpResumeWorkDir}`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ★ RolePlay resume: refresh .crew context to get latest kanban/features
|
|
340
|
-
if (rolePlayConfig && rolePlayEntry) {
|
|
341
|
-
const crewContext = loadCrewContext(effectiveWorkDir);
|
|
342
|
-
if (crewContext) {
|
|
343
|
-
rolePlayConfig.crewContext = crewContext;
|
|
344
|
-
// Initialize mtime snapshot (without re-loading) so subsequent refreshes can detect changes
|
|
345
|
-
initCrewContextMtimes(effectiveWorkDir, rolePlayEntry);
|
|
346
|
-
console.log(`[Resume] RolePlay: refreshed .crew context (${crewContext.features.length} features)`);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
197
|
ctx.conversations.set(conversationId, {
|
|
351
198
|
query: null,
|
|
352
199
|
inputStream: null,
|
|
353
|
-
workDir:
|
|
200
|
+
workDir: effectiveWorkDir,
|
|
354
201
|
claudeSessionId: claudeSessionId, // 保存要恢复的 session ID
|
|
355
202
|
createdAt: Date.now(),
|
|
356
203
|
abortController: null,
|
|
@@ -360,7 +207,6 @@ export async function resumeConversation(msg) {
|
|
|
360
207
|
userId,
|
|
361
208
|
username,
|
|
362
209
|
disallowedTools: disallowedTools || null, // null = 使用全局默认
|
|
363
|
-
rolePlayConfig,
|
|
364
210
|
usage: {
|
|
365
211
|
inputTokens: 0,
|
|
366
212
|
outputTokens: 0,
|
|
@@ -426,11 +272,6 @@ export function deleteConversation(msg) {
|
|
|
426
272
|
ctx.conversations.delete(conversationId);
|
|
427
273
|
}
|
|
428
274
|
|
|
429
|
-
// Clean up roleplay session if applicable
|
|
430
|
-
if (rolePlaySessions.has(conversationId)) {
|
|
431
|
-
removeRolePlaySession(conversationId);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
275
|
ctx.sendToServer({
|
|
435
276
|
type: 'conversation_deleted',
|
|
436
277
|
conversationId
|
|
@@ -570,18 +411,6 @@ export async function handleUserInput(msg) {
|
|
|
570
411
|
const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
|
|
571
412
|
const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
|
|
572
413
|
|
|
573
|
-
// ★ RolePlay: refresh .crew context before starting a new query
|
|
574
|
-
// so the appendSystemPrompt has the latest kanban/features
|
|
575
|
-
if (state?.rolePlayConfig) {
|
|
576
|
-
const rpSession = rolePlaySessions.get(conversationId);
|
|
577
|
-
if (rpSession) {
|
|
578
|
-
const refreshed = refreshCrewContext(effectiveWorkDir, rpSession, state);
|
|
579
|
-
if (refreshed) {
|
|
580
|
-
console.log(`[SDK] RolePlay: .crew context refreshed before query start`);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
414
|
console.log(`[SDK] Starting Claude for ${conversationId}, resume: ${resumeSessionId || 'none'}`);
|
|
586
415
|
state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
|
|
587
416
|
}
|
|
@@ -590,77 +419,11 @@ export async function handleUserInput(msg) {
|
|
|
590
419
|
// Claude stream-json 模式支持在回复过程中接收新消息(写入 stdin)
|
|
591
420
|
let effectivePrompt = prompt;
|
|
592
421
|
|
|
593
|
-
// ★
|
|
594
|
-
// wrap the user message with context about which role was asking
|
|
595
|
-
const rpSession = rolePlaySessions.get(conversationId);
|
|
596
|
-
if (rpSession && rpSession.waitingHuman && rpSession.waitingHumanContext) {
|
|
597
|
-
const { fromRole, message: requestMessage } = rpSession.waitingHumanContext;
|
|
598
|
-
const fromRoleConfig = rpSession.roles.find?.(r => r.name === fromRole) ||
|
|
599
|
-
(Array.isArray(rpSession.roles) ? rpSession.roles.find(r => r.name === fromRole) : null);
|
|
600
|
-
const fromLabel = fromRoleConfig
|
|
601
|
-
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
602
|
-
: fromRole;
|
|
603
|
-
|
|
604
|
-
effectivePrompt = `人工回复(回应 ${fromLabel} 的请求: "${requestMessage}"):\n\n${prompt}`;
|
|
605
|
-
|
|
606
|
-
rpSession.waitingHuman = false;
|
|
607
|
-
rpSession.waitingHumanContext = null;
|
|
608
|
-
console.log(`[RolePlay] Human responded, resuming from ${fromRole}'s request`);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// ★ Save displayPrompt before rolePrefix injection (preserves waitingHuman prefix but excludes ROLE signal)
|
|
422
|
+
// ★ Save displayPrompt before any modification (preserves original user input)
|
|
612
423
|
const displayPrompt = effectivePrompt;
|
|
613
424
|
|
|
614
|
-
// ★ RolePlay: handle @mention targetRole routing
|
|
615
|
-
const targetRole = msg.targetRole;
|
|
616
|
-
if (targetRole && rpSession && rpSession._routeInitialized) {
|
|
617
|
-
const roleNames = new Set(rpSession.roles.map(r => r.name));
|
|
618
|
-
if (roleNames.has(targetRole)) {
|
|
619
|
-
const targetRoleConfig = rpSession.roles.find(r => r.name === targetRole);
|
|
620
|
-
const targetLabel = targetRoleConfig
|
|
621
|
-
? (targetRoleConfig.icon ? `${targetRoleConfig.icon} ${targetRoleConfig.displayName}` : targetRoleConfig.displayName)
|
|
622
|
-
: targetRole;
|
|
623
|
-
const targetClaudeMd = targetRoleConfig?.claudeMd || '';
|
|
624
|
-
|
|
625
|
-
// Prepend ROLE signal so Claude responds as the target role
|
|
626
|
-
let rolePrefix = `---ROLE: ${targetRole}---\n\n`;
|
|
627
|
-
rolePrefix += `用户指定由 ${targetLabel} 来回复。\n`;
|
|
628
|
-
if (targetClaudeMd) {
|
|
629
|
-
rolePrefix += `<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
|
|
630
|
-
}
|
|
631
|
-
rolePrefix += `请以 ${targetLabel} 的身份回复以下消息:\n\n`;
|
|
632
|
-
effectivePrompt = rolePrefix + effectivePrompt;
|
|
633
|
-
|
|
634
|
-
// Update rpSession state
|
|
635
|
-
const prevRole = rpSession.currentRole;
|
|
636
|
-
rpSession.currentRole = targetRole;
|
|
637
|
-
if (rpSession.roleStates[targetRole]) {
|
|
638
|
-
rpSession.roleStates[targetRole].status = 'active';
|
|
639
|
-
}
|
|
640
|
-
if (prevRole && prevRole !== targetRole && rpSession.roleStates[prevRole]) {
|
|
641
|
-
rpSession.roleStates[prevRole].status = 'idle';
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Send roleplay_status update to frontend
|
|
645
|
-
ctx.sendToServer({
|
|
646
|
-
type: 'roleplay_status',
|
|
647
|
-
conversationId,
|
|
648
|
-
currentRole: rpSession.currentRole,
|
|
649
|
-
round: rpSession.round,
|
|
650
|
-
features: rpSession.features ? Array.from(rpSession.features.values()) : [],
|
|
651
|
-
roleStates: rpSession.roleStates || {},
|
|
652
|
-
waitingHuman: false
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
console.log(`[RolePlay] @mention routing: ${prevRole || 'none'} -> ${targetRole}`);
|
|
656
|
-
} else {
|
|
657
|
-
console.warn(`[RolePlay] @mention target role not found: ${targetRole}`);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
425
|
// ★ Separate display message (shown to user) from Claude message (sent to model)
|
|
662
|
-
// displayPrompt: user's original text
|
|
663
|
-
// effectivePrompt: may include ROLE signal prefix for @mention routing
|
|
426
|
+
// displayPrompt: user's original text (no modifications)
|
|
664
427
|
const userMessage = {
|
|
665
428
|
type: 'user',
|
|
666
429
|
message: { role: 'user', content: effectivePrompt }
|
|
@@ -786,75 +549,3 @@ export function handleAskUserAnswer(msg) {
|
|
|
786
549
|
console.log(`[AskUser] No pending question for requestId: ${msg.requestId}`);
|
|
787
550
|
}
|
|
788
551
|
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Handle check_crew_context request — check if a directory has .crew context
|
|
792
|
-
* and return role/context info for RolePlay auto-import.
|
|
793
|
-
*/
|
|
794
|
-
export function handleCheckCrewContext(msg) {
|
|
795
|
-
const { projectDir, requestId } = msg;
|
|
796
|
-
if (!projectDir) {
|
|
797
|
-
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const crewContext = loadCrewContext(projectDir);
|
|
801
|
-
if (!crewContext) {
|
|
802
|
-
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
// Return a safe subset for the frontend (no full claudeMd content, just metadata)
|
|
806
|
-
ctx.sendToServer({
|
|
807
|
-
type: 'crew_context_result',
|
|
808
|
-
requestId,
|
|
809
|
-
found: true,
|
|
810
|
-
roles: crewContext.roles.map(r => ({
|
|
811
|
-
name: r.name,
|
|
812
|
-
displayName: r.displayName,
|
|
813
|
-
icon: r.icon,
|
|
814
|
-
description: r.description,
|
|
815
|
-
roleType: r.roleType,
|
|
816
|
-
isDecisionMaker: r.isDecisionMaker,
|
|
817
|
-
hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
|
|
818
|
-
})),
|
|
819
|
-
teamType: crewContext.teamType,
|
|
820
|
-
language: crewContext.language,
|
|
821
|
-
featureCount: crewContext.features.length,
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Handle check_roleplay_sessions request — check if a directory has .roleplay/session.json
|
|
827
|
-
* and return the list of existing sessions for restore.
|
|
828
|
-
*/
|
|
829
|
-
export function handleCheckRolePlaySessions(msg) {
|
|
830
|
-
const { projectDir, requestId } = msg;
|
|
831
|
-
if (!projectDir) {
|
|
832
|
-
ctx.sendToServer({ type: 'roleplay_sessions_result', requestId, found: false, sessions: [] });
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
const index = loadRolePlaySessionIndex(projectDir);
|
|
836
|
-
const activeSessions = index.sessions.filter(s => s.status === 'active');
|
|
837
|
-
if (activeSessions.length === 0) {
|
|
838
|
-
ctx.sendToServer({ type: 'roleplay_sessions_result', requestId, found: false, sessions: [] });
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
ctx.sendToServer({
|
|
842
|
-
type: 'roleplay_sessions_result',
|
|
843
|
-
requestId,
|
|
844
|
-
found: true,
|
|
845
|
-
sessions: activeSessions.map(s => ({
|
|
846
|
-
name: s.name,
|
|
847
|
-
teamType: s.teamType,
|
|
848
|
-
language: s.language,
|
|
849
|
-
conversationId: s.conversationId,
|
|
850
|
-
roles: (s.roles || []).map(r => ({
|
|
851
|
-
name: r.name,
|
|
852
|
-
displayName: r.displayName,
|
|
853
|
-
icon: r.icon || '',
|
|
854
|
-
description: r.description || '',
|
|
855
|
-
})),
|
|
856
|
-
createdAt: s.createdAt,
|
|
857
|
-
updatedAt: s.updatedAt,
|
|
858
|
-
})),
|
|
859
|
-
});
|
|
860
|
-
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew Context Loader — detect and parse .crew/ directory structure.
|
|
3
|
+
*
|
|
4
|
+
* Reads .crew/CLAUDE.md, session.json, per-role CLAUDE.md files,
|
|
5
|
+
* kanban, and feature files. Used by the frontend to detect whether
|
|
6
|
+
* a project has a Crew setup and display role metadata.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
11
|
+
import ctx from '../context.js';
|
|
12
|
+
|
|
13
|
+
const MAX_CLAUDE_MD_LEN = 8192;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a file, returning null on any error.
|
|
17
|
+
* @param {string} filePath
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
function readFileOrNull(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(filePath)) return null;
|
|
23
|
+
return readFileSync(filePath, 'utf-8');
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deduplicate Crew roles by roleType.
|
|
31
|
+
* Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" entry.
|
|
32
|
+
* Attaches per-role CLAUDE.md content.
|
|
33
|
+
*/
|
|
34
|
+
function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
35
|
+
const byType = new Map();
|
|
36
|
+
const merged = [];
|
|
37
|
+
|
|
38
|
+
for (const r of sessionRoles) {
|
|
39
|
+
const type = r.roleType || r.name;
|
|
40
|
+
const claudeMd = roleClaudes[r.name] || '';
|
|
41
|
+
|
|
42
|
+
if (byType.has(type)) continue;
|
|
43
|
+
byType.set(type, true);
|
|
44
|
+
|
|
45
|
+
const name = type;
|
|
46
|
+
let displayName = r.displayName || name;
|
|
47
|
+
displayName = displayName.replace(/-\d+$/, '');
|
|
48
|
+
|
|
49
|
+
merged.push({
|
|
50
|
+
name,
|
|
51
|
+
displayName,
|
|
52
|
+
icon: r.icon || '',
|
|
53
|
+
description: r.description || '',
|
|
54
|
+
claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
|
|
55
|
+
roleType: type,
|
|
56
|
+
isDecisionMaker: !!r.isDecisionMaker,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return merged;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load .crew context from a project directory.
|
|
65
|
+
* Returns null if .crew/ doesn't exist.
|
|
66
|
+
*/
|
|
67
|
+
export function loadCrewContext(projectDir) {
|
|
68
|
+
const crewDir = join(projectDir, '.crew');
|
|
69
|
+
if (!existsSync(crewDir)) return null;
|
|
70
|
+
|
|
71
|
+
// 1. Shared CLAUDE.md
|
|
72
|
+
const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
|
|
73
|
+
|
|
74
|
+
// 2. session.json → roles, teamType, language, features
|
|
75
|
+
let sessionRoles = [];
|
|
76
|
+
let teamType = 'dev';
|
|
77
|
+
let language = 'zh-CN';
|
|
78
|
+
let sessionFeatures = [];
|
|
79
|
+
const sessionPath = join(crewDir, 'session.json');
|
|
80
|
+
const sessionJson = readFileOrNull(sessionPath);
|
|
81
|
+
if (sessionJson) {
|
|
82
|
+
try {
|
|
83
|
+
const session = JSON.parse(sessionJson);
|
|
84
|
+
if (Array.isArray(session.roles)) sessionRoles = session.roles;
|
|
85
|
+
if (session.teamType) teamType = session.teamType;
|
|
86
|
+
if (session.language) language = session.language;
|
|
87
|
+
if (Array.isArray(session.features)) sessionFeatures = session.features;
|
|
88
|
+
} catch {
|
|
89
|
+
// Invalid JSON — ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
|
|
94
|
+
const roleClaudes = {};
|
|
95
|
+
const rolesDir = join(crewDir, 'roles');
|
|
96
|
+
if (existsSync(rolesDir)) {
|
|
97
|
+
try {
|
|
98
|
+
const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
|
|
99
|
+
.filter(d => d.isDirectory())
|
|
100
|
+
.map(d => d.name);
|
|
101
|
+
for (const dirName of roleDirs) {
|
|
102
|
+
const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
|
|
103
|
+
if (md) roleClaudes[dirName] = md;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Permission error or similar — ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Merge roles: deduplicate by roleType, attach claudeMd
|
|
111
|
+
const roles = deduplicateRoles(sessionRoles, roleClaudes);
|
|
112
|
+
|
|
113
|
+
// 5. Kanban
|
|
114
|
+
const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
|
|
115
|
+
|
|
116
|
+
// 6. Feature files from context/features/*.md
|
|
117
|
+
const features = [];
|
|
118
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
119
|
+
if (existsSync(featuresDir)) {
|
|
120
|
+
try {
|
|
121
|
+
const files = readdirSync(featuresDir)
|
|
122
|
+
.filter(f => f.endsWith('.md') && f !== 'index.md')
|
|
123
|
+
.sort();
|
|
124
|
+
for (const f of files) {
|
|
125
|
+
const content = readFileOrNull(join(featuresDir, f));
|
|
126
|
+
if (content) {
|
|
127
|
+
features.push({ name: f.replace('.md', ''), content });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handle check_crew_context message — check if a project has .crew/ setup
|
|
140
|
+
* and return role metadata for the frontend.
|
|
141
|
+
*/
|
|
142
|
+
export function handleCheckCrewContext(msg) {
|
|
143
|
+
const { projectDir, requestId } = msg;
|
|
144
|
+
if (!projectDir) {
|
|
145
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const crewContext = loadCrewContext(projectDir);
|
|
149
|
+
if (!crewContext) {
|
|
150
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Return a safe subset for the frontend (no full claudeMd content, just metadata)
|
|
154
|
+
ctx.sendToServer({
|
|
155
|
+
type: 'crew_context_result',
|
|
156
|
+
requestId,
|
|
157
|
+
found: true,
|
|
158
|
+
roles: crewContext.roles.map(r => ({
|
|
159
|
+
name: r.name,
|
|
160
|
+
displayName: r.displayName,
|
|
161
|
+
icon: r.icon,
|
|
162
|
+
description: r.description,
|
|
163
|
+
roleType: r.roleType,
|
|
164
|
+
isDecisionMaker: r.isDecisionMaker,
|
|
165
|
+
hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
|
|
166
|
+
})),
|
|
167
|
+
teamType: crewContext.teamType,
|
|
168
|
+
language: crewContext.language,
|
|
169
|
+
featureCount: crewContext.features.length,
|
|
170
|
+
});
|
|
171
|
+
}
|
package/crew.js
CHANGED